A vendor EGL can export eglSwapBuffersWithDamageEXT and then answer EGL_FALSE on
a window surface without setting an error (measured on an Android 4.14 vendor
kernel with a Mali driver; the surface is current, the context is current, the
ANativeWindow reports its full extent, and the plain entry point presents the
same surface happily). Trusting that pointer kept the frame: the window's buffer
queue never saw a buffer, the compositor's own window stayed a solid colour on
screen, and every client of the session fell back to wl_shm because the
compositor could not sample what it could not present.
A refused damage swap now falls back to eglSwapBuffers and says so once. A
driver without the extension presents through the plain call as before.
(st_dev, st_ino) is how an incoming dma-buf descriptor names the image it was
exported from, and it is the only identity the registry had. Some kernels do not
keep those apart: on a 4.14 vendor kernel every descriptor of every image reports
dev 12 ino 10658 with st_size 0, and /proc/self/fdinfo carries nothing else, so
the registry correctly detected the ambiguity and then refused every import -
which is every zero-copy window a compositor ever tries to sample, leaving the
whole stack on the wl_shm fallback.
The identity stays the kernel's to give. When it is ambiguous, the import's own
extent and format select among the live images exported under that identity, in
allocation order and each claimed once, which is the order a producer allocates
and hands them over in. Identify() takes the extent and the fourcc the import
record already carries; nothing else changes.
- eglSwapBuffersWithDamageEXT is an extension entry point that Android's
framework libEGL does not export through dlsym; requiring it was a
bring-up FATAL on TB321FU. Resolve EXT, fall back to the KHR spelling
(same function, interchangeable signature), then to plain
eglSwapBuffers (a zero-rectangle damage call is defined to be exactly
that). The GLES dlsym fallback gains a self-reference guard for
MobileGL installed under a GLES soname, where dlsym would otherwise
hand the backend the frontend's own entry points and recurse forever.
- NormalizePixelFormat maps GL_STENCIL_INDEX8/GL_STENCIL_INDEX to
GL_STENCIL_INDEX/GL_UNSIGNED_BYTE instead of falling through to
RGBA/UBYTE, which no driver can pair with stencil-only storage.
- Tests pin the EXT->KHR->none preference order and the stencil pair.
- The anland handoff is rewritten to the post-P14 landed state: device
evidence (win-smoke green screen, Konsole on screen, eglinfo/glxinfo),
the remote-only recovery procedure, and the known gaps (Plasma session
bring-up, cursor scale, the no-CPU-blit requirement, resize/surface
loss, AVC audit).
A session that dies without its teardown (crash, SIGKILLed client) leaks
its native window surface; the server-owned display window is one
ANativeWindow for the process's lifetime, and Adreno refuses a second
eglCreateWindowSurface on it with EGL_BAD_NATIVE_WINDOW - on TB321FU this
made every session after the first die with 'MobileGL could not acquire
the app Surface 300b'. InitWindowSurface now finds and destroys any
surface still bound to the window it is about to use, and the session
registry records the window with each surface to make that possible.
The document still described the state before the state-ownership work
landed: it called the unified server design unfinished, listed the
KernelSU grant as unsolved, said the directory bind had not taken effect,
and presented MobileGLAppsService plus the per-app fork worker as pending
drafts. None of that is true now.
Rewritten against the current tree and the device:
- header: architecture landed per the user's correction, acceptance still
open; the rejected fork-worker/AppsService path is removed and the
unified server lives in the MobileGL library (design 11-state-ownership,
commits d52c031c / 4ed1c9ce / 5c6399e2 / ff212061 / 5a0f13fd, plus the
landed 6fb880cd / ebc552a0 that used to be listed as uncommitted)
- MobileGL: HEAD moved to 5a0f13fd; the state-ownership section now records
what S1-S6 landed and the debts that remain (process-level unit shadows,
g_backendContextGeneration, per-record ContextSlot, the glIsBuffer probe,
the window half of pbuffer->window)
- anland: 2ffa1f5 and 33e496c pushed; the draft inventory is now a landed
inventory plus the three uncommitted KWin-acceptance fixes
- device: KernelSU grant resolved (reinstall changed the uid to 10270), the
clone restarted with the directory bind effective, the 5a0f13fd APK
serving @anland-mobilegl with six completed client sessions, and the
client library installed with a stale build stamp
- new: concurrency warning (parallel sessions have overwritten the dist and
the source tar), the current desktop-session.service failure state, and a
rewritten continuation order
Verified: doc citations resolve (scripts/check_doc_citations.py), device
facts checked against HA27Q3LQ at refresh time.
GL_ARB_shader_objects/vertex_shader/fragment_shader and
GL_ARB_texture_non_power_of_two are core since GL 2.0 and trivially true
for the 4.6 this backend claims, but KWin 6's EglContext::checkSupported()
requires exactly these strings before it brings up compositing. Found on
TB321FU: kwin_wayland --anland failed with 'Could not initialize rendering
context' after a successful context create + makeCurrent on the
server-owned window surface.
The last piece of state ownership this phase owed: the eight DirectGLES twin
tables resolved a handle process-wide, so two contexts of one session that do
NOT share a share group - which have independent name spaces, so the same
{slot, gen} in both is legal - were handed the SAME twin, driver ids,
persistent map and pooled store included. Two contexts that DO share a group
were served by whichever of them twinned first. Design and per-slice shape:
docs/Disaggregated/design/11-state-ownership.md (S6).
Key: DirectGLES.h's TwinKey{SessionKey, ShareGroupKey} - the same two words
the applier's object records are filed under, with the group derived by
MGPipeApplierShareGroupKeyFor so a record and the twin describing it can never
land in different buckets. Session is in the key because a twin names a driver
id and no driver id crosses a connection.
Resolution: SessionRuntime.cpp's ResolveThreadTwinKey, installed by
InstallSessionRuntimeHooks beside the S2 PipeInputs probe, the S4 native
tuple key and the S5 applier key. It reads the ThreadSessionScope's session
and CurrentContextToken() - apply-thread state only, never MG_State, so the
frontend-keyed-lookup prohibition on the apply thread still holds. A thread
that belongs to no session answers {0, 0}, which is the process singleton this
replaces; the monolith and every unit case are unchanged by construction.
Shape: SlotTables.h's SlotTableRegistry holds one SharedPtr bucket per key,
with a thread-local last-key memo. It forwards every handle-keyed entry -
GetOrCreate, FindByHandle, LiveGenAt, HandleOf, StateForHandle,
NoteStateForHandle, ReleaseByHandle, ForEachLive, the counters - to the
CALLING THREAD's bucket, so the ~150 call sites in Managers.cpp and
DirectGLES.cpp are literally unchanged. The holder list stays process-wide: a
death notice is about an object, not a bucket, and each shared holder is one
bucket. ForEachLive now walks the calling thread's bucket (what a context can
see is its own group); the death and diagnostic walks take the new
ForEachBucket.
DropEveryTwinForEndedServerSession is session-scoped: it runs on the apply
thread of the session that is ending, while S2 lets neighbour sessions keep
rendering in the same process. No session on the thread keeps the old
process-wide drop verbatim. Managers.h's "a texture is owned by exactly one
context (share groups are not implemented)" is corrected: a texture belongs to
a share group and its sibling contexts legitimately mutate it.
Tests: MG_Test/Wire/TwinOwnershipTest.cpp, five cases driving the real twin
tables with no EGL and no wire - the same handle in two nonshared groups is
two twins and neither overwrites the other; two contexts of one group resolve
ONE twin; a per-session drop takes only that session's; a thread with no
session still gets the process-wide table; and the six-registry template
repeats the first three. Negative control run and reverted: making the
registry answer a single {0, 0} bucket turns four of the five red.
Full host ctest against a stashed baseline, compared by test name: 0 new
failures. The one name in the difference set
(LogForwardChannel.APeerThatStopsReadingCostsWarnLinesNotTheCallersTime) is a
pre-existing flake, failing on the baseline too with the identical assertion.
The 103-case acceptance set plus the five new cases is 108/108 green.
Remaining debts (documented): the unit shadows (g_activeTextureUnit,
g_boundTexturesCache, g_boundSamplersCache) are still process-global; the
driver-level glIsBuffer probe is still not usable on this host, so sharing is
proven at the twin layer (the twin IS the object holding the driver id); the
backend context/sync generations are still process-wide; the per-record
ContextSlot is still S1's edge declaration.
Breaks the one process = one server = one session = one context equation
that the whole stack's correctness used to rest on. Design and per-slice
shape: docs/Disaggregated/design/11-state-ownership.md.
S1 wire: bind_context ring record (opcode 84) announces the current
context at the make-current edge; CreateContext/DestroyContext join the
SurfaceOp control frames; control revision 5 -> 6; the server keeps a
per-session token table and refuses unknown tokens by name.
S2 server: SessionRuntime per session with thread-scoped resolution
(singletons become fallbacks), per-thread gPipeInputs, per-session latch
domains, per-thread segment resolvers; the in-process unix supervisor
serves up to MOBILEGL_IPC_INPROC_MAX_SESSIONS (16) concurrent sessions;
surface mode is declared per session and the display lease refuses a
second on-screen session by name.
S3 frontend: ShareGroupState owns the shared object stores and name
generators (buffer/texture/renderbuffer/sampler/program), GLContext keeps
per-context bindings/errors/container objects; pGLContext becomes
thread-local with a process default context preserving the single-context
world verbatim; eglCreateContext builds a real GLContext and joins the
sharing context's group.
S4 backend: native EGL tuples keyed by (session, context token);
InitDisplayAndContext's unconditional teardown is gone - creating a
second context or surface no longer destroys the first; eglCreateContext
sharing is wired to the native driver; bind_context is the native switch
point.
S5 applier: MGPipeApplierState splits into MGPipeObjectRecords (per
share group) and MGPipeWorkingState (per context), resolved through a
{session, token} registry with a thread-local cached fast path.
Tests: MultiSessionTest (incl. two sessions x two contexts interleaved
with real clear/readback witnesses), ApplierOwnershipTest (with a
null-resolver negative control), ContextStateOwnershipTest,
ServerLoopEglTest native-context liveness; 99/99 of the acceptance set
green on the host build.
Known debts (documented in the design doc): twin registries still keyed
by handle alone, no per-record ContextSlot, process-global backend
generations, MakeCurrent dedup per current binding.
Wayland Present() failure now returns EGL_BAD_SURFACE instead of being
dropped. In spawn mode a GLX X drawable is backed by a remote pbuffer so
the XID is never mistaken for an Android ANativeWindow; X11 pixel present
and remote resize are still unimplemented.
The whole stack assumes one process = one server = one session = one
context = one share group. The anland unified server needs that equation
broken in the library instead of fork-per-app workers. This document fixes
the four ownership layers (session, context, share group, thread current),
the wire changes (per-record context slot, CreateContext/DestroyContext
SurfaceOps, context-tagged reverse events), and the staged plan P14a-d
with acceptance tests.
A Wayland client's window is its own wl_surface, and what the compositor shows is the wl_buffer
attached to it - but the pixels of a MobileGL frame are drawn where the backend is, on a split
build in another process on another OS. So far a wl_egl_window was taken for an X11 window and
its client-side address sent to the Android server (EGL_BAD_NATIVE_WINDOW at best), and under
glvnd a Wayland display never reached this vendor at all.
A window on a Wayland display - asked for as EGL_PLATFORM_WAYLAND, or a wl_display recognised by
its first word being wl_display_interface - is now a window surface in the EGL state at the
wl_egl_window's size with a pbuffer of that size behind it in the backend, whatever
MOBILEGL_IPC_SURFACE says. At every swap the frame is read back out of the default framebuffer
through this library's own glReadPixels (the application's read framebuffer, pack buffer and pack
state put back as they were), its rows flipped and swizzled into a wl_shm buffer, and attached,
damaged and committed on the application's wl_surface. Three buffers rotate on their release
events, dispatched on an event queue of the surface's own. libwayland-client is loaded at run
time, so neither the build nor the library depends on it. EGL_KHR_platform_wayland and
EGL_EXT_platform_wayland are announced on Linux.
Measured: glmark2-wayland runs its build scene at 13 FPS (400x300) and the window shows the frame
under kwin. A window resize is not followed yet; the surface keeps its creation size.
Two defects, both found by asking the client directly rather than by reading its log.
The backend refused to initialize any EGLDisplay but the first - "already bound to a different
EGLDisplay" - and the field it compared against has no reader anywhere else in the tree: the domain
rule was the whole of the rule. What it cost was a compositor, which asks for the display it will
draw into AFTER probing another one. The probe succeeded, the real display was refused, and the
caller reported the refusal one layer up as "no usable DRM render device" - a message that names
where it stopped, not why.
The second: glvnd asks a vendor for entry points from two places. getProcAddress is how it builds
its own static EGL table, and getDispatchAddress is how it answers eglGetProcAddress for every name
it does not know itself - which is every extension entry point, the device queries among them. This
side answered the second from a GL-only table, so an application asking for eglQueryDeviceStringEXT
got a null for a function the vendor implements.
On a system with libglvnd the libEGL.so.1 an application links is a dispatcher, not an
implementation, and the supported way to be the implementation it talks to is to be a vendor
library it loads and routes to. On this machine it is also the ONLY way that reaches a caller which
does not link EGL directly: kwin goes through libepoxy, which dlopen()s libEGL.so.1 and dlsyms its
entry points at startup, so a library injected with LD_PRELOAD is invisible to it however loudly it
is named - measured, with the client in kwin's maps and not one call into it.
So this is the vendor half of that ABI: __egl_Main answers glvnd with the entry points by name, the
platforms this implementation serves, and the device it is associated with - the render node of the
machine, because that is what lets a compositor match its own node to the display it is about to
draw into, and because glvnd drops a vendor that does not answer at all.
Two details are the difference between a vendor that is loaded and a vendor that is used. glvnd
requires setDispatchIndex to exist even when a vendor has nothing to record, which this one has -
its entry points are the implementation, they never re-enter glvnd. And a vendor must hand over ITS
OWN entry points: with default visibility a reference inside this library resolves against the whole
process, where another libEGL that loaded first wins. That is what -Bsymbolic-functions is for;
declaring the entry points protected says the same thing but does not compile against the platform
headers, which declare them default.
A vendor string and an eglQueryString answer have to agree, and they are read by two different
parties: the system's libglvnd reads the first to decide which vendor a platform request should be
routed to, and an application reads the second before it decides to make that request at all. Kept
apart they drift, and the drift has a signature - a library that is loaded, initialized, and never
called, while the caller reports that it found no provider for a function it never reached.
The list gains the device family for the same reason: a compositor asks which devices exist and
reads the DRM node off one before it asks for a display on it, so an implementation that answers
"no devices" is one it cannot use no matter how well the implementation draws.
Two MIT headers from libglvnd, verbatim, because MobileGL is about to implement the vendor half of
glvnd's EGL ABI and the distribution ships the runtime without them.
The "Trace APK must not expose a launcher activity" check predates the render-server
screen: 1f72597dd gave the trace APK a launcher on purpose and 884f6aac9 moved the entry
icon back to PostActivity with a bottom navigation bar between the two screens, so the
apk.yml build job has been red ever since - taking the whole retrace matrix down with it
(skipped, not failed), which hid the distinction.
What is left of the old isolation contract is the count: exactly one
android.intent.action.MAIN in the merged manifest (a second one means a screen grew a
launcher entry by accident), and it belongs to PostActivity. The fclPlugin metadata ban is
unchanged.
6bccf98d7 put TakeSendStats/TakeReadStats on the concrete StreamLink class, so
ClientSession.cpp and ServerLoop.cpp had to include <MG_Remote/Transport/StreamLink.h> -
which the P6.5 seam gate (scripts/ci/link_seam_purity.py, "concrete link included above
Transport") rejects, and the MGPipe generators and hygiene gates job has been red since.
The counters stay in StreamLink.cpp beside the recv/sendmsg loops that feed them; only the
struct and the two Take functions move, to LinkMetrics.h as LinkIoStats +
LinkMetricsTakeReadStats/TakeSendStats. The readers drop the concrete include; the hot
loops are untouched.
The trace flavor manifest removed PostActivity and made ServerControlActivity
the launcher, so the APK opened straight into the render server screen. Keep
both screens: PostActivity is the launcher again, and a bottom navigation bar
(NavBar, plugin flavor unaffected) switches between them via
FLAG_ACTIVITY_REORDER_TO_FRONT with transitions suppressed, so each screen
keeps its state. ServerControlActivity is singleTop now and handles extras in
onNewIntent so adb-driven starts still work. The POST screen also shows an
indeterminate progress bar while the self-test runs.
run_matrix.sh drives interleaved monolith/inproc/spawn+shm/spawn+tcp
benchmark rounds on an adb device with per-run arm-marker verification;
profile_matrix.sh layers simpleperf, ftrace wakeup capture and a /proc
sampler on top; make_report.py aggregates runs into REPORT.md. The wifi
kit carries the cross-host control-plane hang repro and phone-side
strace/fd-reconcile helpers. .trace-work/ (run output) is now ignored.
listen/token/env/onscreen prefill the form, --ez start true starts the
server, --ez stop true stops it - adb drives the screen without the IME,
which is how the on-screen unix path (@name + shm, ServerOwned window)
was verified end to end from the UI.
mobilegl_server_serve_inprocess refused anything but tcp:// because its
only supervisor was the TCP one's thread hand-off. A unix endpoint
(@abstract name or filesystem path) now runs a supervisor of the same
shape minus the TCP exposure machinery: AcceptPair pairs a client's
control and SCM_RIGHTS connections by arrival order, there is no
DataBind to route and no pre-auth gate, sessions run on threads one at
a time with Busy for a second client, the latch resets between sessions
and g_inProcessStop ends it. That gives the on-screen server the
on-device fast path: ServerOwned windows over a unix socket with shm
data, no TCP copies.
The control screen's tcp-only guard for the on-screen mode goes with
the refusal it mirrored.
MOBILEGL_BUILD_TRACE_REPLAY now also produces mobilegl_trace_replay on
ANDROID: the same CLI as desktop, linked against the APK runner's
glws/glproc glue. On the device it dials a render server with no
Activity at all - MOBILEGL_IPC_CONTROL=unix:@name with shm data to the
offscreen supervisor (a cross-uid abstract connect needs root or an
SELinux waiver; loopback TCP works from plain adb shell), or
tcp:// with MOBILEGL_IPC_SURFACE=server to the on-screen display server.
glws_android grows the desktop glws_egl's server-owned null-window
createWindowSurface, gated on MOBILEGL_TRACE_SURFACE=window so the APK's
pbuffer mode keeps its old answer, and the config carries EGL_WINDOW_BIT
for it. The control screen refuses unix endpoints in on-screen mode:
the in-process display server serves tcp:// only.
Verified on-device with an OpenRA retrace, ssim=1.0 both ways:
offscreen service on @mgl-cli with the CLI as root, and the display
Activity on tcp://127.0.0.1:40613 with the replay's frames on the
phone's screen.
transport_wait_ns measures wall time in Ring::Wait's flush/spin/sleep, so
the client's parked time is read directly instead of inferred by
subtracting CPU clocks that run concurrently with the server.
frame_stats.py matches client and server present serials into per-frame
avg/p50/p99 splits; the README documents the MOBILEGL_PIPE_STATS=1
workflow.
An On-screen window checkbox switches Start from the offscreen service
to MobileGLDisplayActivity (process :mglwin), which serves in its own
process and renders a client's window surface onto its SurfaceView.
Stop ends either shape: stopService for the supervisor, killProcess for
:mglwin, and the running check covers both. Verified end to end with an
OpenRA retrace: the client dialed tcp://127.0.0.1:40613 with
MOBILEGL_IPC_SURFACE=server from a freeform window while the display
Activity held the screen, frames landed on the SurfaceView and the run
passed with ssim=1.0.
The endpoint field accepts @abstract names and filesystem paths beside
tcp://, matching what mobilegl_server_main's Listen already binds - on
Android the abstract namespace is the on-device rendezvous that always
works, and a relative path lands in the app's files dir, the service's
working directory. Unix endpoints skip the token rule (same-device
reach by construction) and are probed with LocalSocket instead of a
TCP connect. The screen is now titled MobileGL Render Server.
ServerControlActivity (trace flavor) collects the listen endpoint, auth
token and extra env, then drives MobileGLServerService with the same
extras adb used. Non-loopback listens are held to the 16-byte token the
server enforces, and a connect probe confirms the supervisor bound its
socket. The screen is the APK's launcher icon, which the trace flavor
lacked since PostActivity is removed there.
THE STRUCTURAL FIX, not just one more case. Three rounds of this feature were green in
the lanes while being inert on the device, because every window-level gate in the tree
runs in RemoteClientControls, whose session is TransportMode::InProcess - ShmLink - and
since P12 the window is OFF there by design. So the lanes and the device ran different
transports, and nothing in the tree could tell.
StreamClientPublicationTest.EveryDeferredCreateAnswerComesBackOnTheShippingArm runs on a
real StreamLink with a real ClientSession and a peer that ANSWERS: a scripted servo pops
each record, replies with its seq, advances and flushes the watermark - i.e. exactly what
the drain's WaitForApplied oracle needs, and nothing more (nothing applies a record).
IT IS PROVED TO GO RED, and by the right name. Reverting the store to "keep the last 8
arrivals" makes it die on the diagnostic added with the store:
MGPipe: Fatal{ReplyMissing, "create window"} - seq 1 was declared and APPLIED, and
its answer is no longer in the link's store; the window cannot advance past it and
nothing recovers a lost answer
AND THE FIRST VERSION OF THIS GATE WAS WORTHLESS, which is the part worth keeping: with
24 creates and nothing else it passed against the ring as well, because between a push
and its read only a couple of answers arrive. What killed the device was TRAFFIC - the
server is behind and its late answers to earlier records fill a bounded buffer first - so
the case now emits twelve MORE records whose answers are wanted (declared
SetTextureParams rows) between the first create and the rest, waits for the peer to
answer them, and only then lets the drain come back for the create. Two things were
needed to make that deterministic: the settlement wait (otherwise it is a race the ring
can win by luck) and glFlush(), because a published record reaches the peer on a flush
and not on PublishAndNotify - the neighbouring case in the same file is built on that.
ALSO IN THIS COMMIT: the assertion is the CONSERVATION LAW, not "taken == creates".
Nothing drains the window when no further create arrives, so the last few answers stay
held - taken + pending == creates, with pending <= window, is the honest contract, and
taken == creates would be asserting a drain on a path the row does not have.
318 tests green across five lanes (172 + 15 + 22 + 103 + 6).
The three things that made the deferral inert are all fixed, in the order they were
found: the reply ring could not hold an answer across the ~1,000 records between two
creates; a residency budget could not help because what displaces an answer is what
the SERVER sends, not what the client emits; and so the mechanism had to be retention.
WHAT LANDED
* ILink::DeclareReplyRead + LinkCapabilities::RetainsReplies. The wire declares a
seq will be read BEFORE it publishes, so the declaration cannot race its own
answer; a link with the capability false ignores it, and the window does not
defer on one (CreateWindowEffective() is gated on it).
* StreamLink's ring of the last 8 answers becomes a declared-answer store: a
monotone whitelist plus a cursor, answers kept until their reader TAKES them
(take-and-delete under one lock, bytes swapped into the scratch), and a capacity
of 24 that is FATAL to exceed rather than evicting. An evicted wanted answer is
unrecoverable - re-latching by sending another create wipes the server's record
(PipeApply.cpp:2033) and its PendingUploads (PipeApply.h:332) while the client
cleared its dirty flags at emission (PipeApply.h:298-304) - so the capacity is
sized so it cannot be reached and reaching it says its own name.
* An answer nobody declared is DROPPED, which is what keeps the store bounded by the
client's deferral depth instead of by the server's answer rate. Retain-on-arrival
grows ~11.3 entries per create against a server that ignores the no-reply bit.
* A read that fails after WaitForApplied has returned Reached is now Fatal{ReplyMissing}
naming the seq: on the stream arm the reply frame strictly precedes the progress
frame that publishes appliedSeq, so that combination is a broken invariant, and
retrying it (what the row used to do) wedges the window for the rest of the
process while leaving every deferred object latched on a provisional accept.
* The residency budget is deleted (WireTables.cpp keeps its history), the window
retirement branch drains once before falling back - it could otherwise strand the
answers it was holding - and Detach clears the store.
MEASURED ON THE DEVICE, load frame, both ends rebuilt, frame-sent identical per op
(create 4,536, records 103,097) throughout:
window 1 (R-5's shape) 4,536 takes 28.66 s
window 2 explicit 2,500 17.27 s
window 2 AS THE SHIPPED DEFAULT 2,563 16.52 s
window 4 1,644 11.02 s
window 2 against an OLD SERVER 2,663 16.38 s (zero Fatals)
The last row is the version-skew case the design review named as a must: a server
built before the no-reply bit answers ~55,903 records a frame, the whitelist drops
every answer nobody declared, and the deferral still reads its own back.
GATES (172 + 15 green), and the important one is proved to go red:
* ADeclaredAnswerSurvivesFarMoreInterveningAnswersThanAnyFixedBuffer - reverting the
store to "keep the last 8 arrivals" fails it immediately with "the declared answer
did not survive 64 intervening ones". That is the line between the ring-1024
stand-in and a bounded mechanism.
* AnAnswerNobodyDeclaredIsNotKeptAtAll - red if everything is retained.
* TheWindowRefusesToDeferOnALinkThatCannotRetainAnswers - on the in-process (ShmLink)
arm the knob at 4 must still block every create, red if the capability gate goes.
* The refused-create case now reads the repair that exists on THAT arm (the latch),
and asserts the window's own books are empty there.
NOT DONE, and named: the window has no unit gate on the STREAM arm yet - only the
device runs. Three rounds of this feature were green in the lanes while inert on the
device because the lanes and the device run different transports; the transport-level
retention gates close that for the store, and the window-level one should be built on
StreamClientPublicationTest's real stream session next.
The second one-line experiment, and the one that decides whether the retention design
is worth building. Ring 1024 plus the residency budget DISABLED (its constant raised
out of reach), W=2 and W=4:
arm waits rtt_mean by_op 2 wall
W=1 (today's default) 4,543 5,672 us 4,536 28.66 s
budget on + ring 8 9,078 2,598 us 9,071 27.04 s
ring 1024 + budget 7 3,450 5,367 us 3,443 23.04 s
ring 1024 + budget OFF W=2 2,560 4,916 us 2,553 16.80 s
ring 1024 + budget OFF W=4 1,691 4,224 us 1,684 11.45 s
rtt_mean does NOT grow (4,916 / 4,224 us, both BELOW the 5,672 us baseline) while the
waits fall by 2x and 2.7x and the wall follows. So the ~5 ms is a WAKEUP round trip and
the server drains a batch per wakeup - which is exactly what makes W pay, and it is the
question the audit listed as undecidable from the repo and the watershed for whether
this is worth doing at all. It is.
AND IT BEATS THE HANDOFF'S OWN FLOOR: handoff-2 section 0.4 put the window's landing at
15-20 s, treating the client's ~13.4 s CPU as ADDITIVE with the waits. This frame turns
out to be wait-dominated, so amortising four creates per wakeup lands at 11.45 s, 3.5 s
below that estimate.
1024 IS A STAND-IN, NOT THE DESIGN: it works only because 1024 happens to be deep enough
for this workload, and 1024 retained answers is ~2 GiB worst case (a ReadPixels answer can
be 2 MiB). Retention gives the same property - the answer is still there - with a bound
tied to the client's own deferral depth, which is why the implementation the audit
specified is what gets built, not "make the ring 1024".
Also corrects the note's section 7 path 1 (audit F15): on the stream arm SEG_REPLY and the
Hello's ReplySlotCount have nothing to do with answers - they travel as Reply frames and
StreamLink::ReadReply reads the client's own store - so "deepen the pool" does not address
the arm that was measured, and should not be paid for.
Reverted (ring 8, budget 7, probes deleted); 172 + 13 green.
One-line experiment before writing the retention table: StreamLink's kReplyRing 8 -> 1024,
nothing else changed, same load frame on the same device.
ring = 8 (today) ring = 1024
readfail 8,872 0
taken 63 4,499 / 4,500
blocked (fell back) 4,435 0
by_op 2 9,071 3,443
wall 27.9 s 23.04 s
frame-sent create 4,536, records 103,097 identical per op
TWO CONCLUSIONS. First, eviction was the ONLY cause: with a buffer deep enough, every
deferred answer was read back and not one create fell through to the blocking path.
The wedge (want=310) and the useless budget (budget=4,451) were two faces of "eight
slots is not enough". Second, what brakes now is the BUDGET, not the buffer: by_op 2 =
3,443 matches the number of times the budget forced an early read (3,395), i.e. every
one of those is a real blocking wait. Its constant is 7, sized for the 8-slot pool, so
against a 1,024-deep buffer it became the only throttle and the wall only moved 27.9 ->
23.0 s.
WHAT IT MEANS FOR THE DESIGN. Once the answers are RETAINED until read, correctness no
longer depends on how long one survives in a bounded buffer, so the budget must be
sized to the RETENTION CAPACITY rather than to the pool - or be replaced by "retained
means safe". by_op 2 = records/W then puts W=2 at ~2,268 waits and the wall at ~15 s,
which is the goal's own success criterion.
Experiment reverted (ring back to 8, probes deleted); 172 + 13 + 22 + 103 green.
Steps 1 and 2 of the plan the design review set out, landed and measured on the
device. Step 1 answers the question step 2 depended on; step 2 answers the one the
whole line depended on, and the answer is that the window's shape is wrong.
STEP 1 - CAN AN ANSWER BE LOST IN DELIVERY? NO. ReplyPool now counts what the link
took (PostedReplies) and what it refused (FailedReplies), and a non-OK result is
NAMED instead of swallowed: it checked only BUFFER_TOO_SMALL and let
TRANSPORT_CLOSED vanish with no line and no counter, so "the server never sent it"
and "it was sent and the buffer overwrote it" were the same symptom with opposite
fixes. Measured: posted=4501 failed=0. The twenty-answer gap between the server's
tally and the client's was a sampling difference, not a drop - and the server's
m_answered counts ATTEMPTS (incremented before PostReply), which is why it could
never have settled it.
STEP 2 - DOES A RESIDENCY BUDGET FIX THE WINDOW? NO.
Wire_ResourceCreate now measures how many reply-owning records the client has
emitted since the oldest window entry was pushed (ClientSession::ReplyPostings, a
new clock) and takes the oldest answer back as soon as that approaches the reply
buffer's depth, instead of deferring past the point where the answer can still
exist. On the device:
P12win call=4501 cfg=2 eff=2 pending=2 taken=63 pushed=65 blocked=4435
budget=4451 readfail=8872 posts=54377
kind=frame-op frame=14 wait_replies=9078 by_op=2=9071 wall 27.87 s
budget=4451: it fires on essentially EVERY create, the window stops deferring
(pushed=65), and the drain still reads nothing back. THE REASON IS STRUCTURAL, not
a constant:
* the window only gets control WHEN A CREATE ARRIVES. Between two creates this
client emits on the order of a thousand records - at the FIRST failed read the
reply ring already spanned [1175..1504] while the head was seq 310 - and nothing
looks at the window in that gap;
* what displaces a deferred answer is not what this client emits, it is WHAT THE
SERVER ANSWERS. The server is the one behind (one answer per ~5 ms wakeup while
the client runs ahead), so the replies arriving during the gap are its LATE
ANSWERS TO THE EARLIER CREATES - they fill the 8 slots first;
* so the check fires too late by construction. Tightening it does not fix that; it
only removes the deferral entirely, which is what pushed=65 is.
WHAT THAT LEAVES. A deferral is readable only if the client KEEPS the answers it
deferred. The buffer is bounded by COUNT and the client cannot bound how many
answers the server will post between two of its creates, so hoping the buffer still
holds one is a race the client loses - which is also why the earlier two diagnoses
(reply-pool churn, then FIFO-with-no-way-out) were both describing symptoms of the
same thing. The shape that can work is retention: the reader keeps the answers for
the seqs still outstanding (a handful), drops them when read, and reports a LOSS
when a read asks for one it no longer has. That is a transport change - StreamLink's
ring becomes a retention map with a cap and a lost counter - and it is the next step.
BOTH STEPS STAY: step 1 is a net gain on its own, and step 2 is the safe direction
(it can only degrade the window to no-deferral, i.e. the pre-window shape) and its
clock is the one the retention design needs. MOBILEGL_IPC_CREATE_WINDOW stays at 1.
172/172 and 13/13 after the probes were removed. The measurements and the revised
conclusion are in notes/p12/CREATE-WINDOW-MEASURED.md section 9.
The create window could not collect its deferred answers. The previous commit
diagnosed that as the reply pool being 8 slots deep against 55,903 replies in one
frame, and left a no-reply bit as the way out. This lands that bit, measures it on
the device, and reports that the diagnosis was half wrong.
WHAT LANDED. ClientSession::EmitAndWaitTails takes a second question beside
wantReply: willReadReply. They are not the same question and the window is the case
that separates them - it emits with wantReply=false and READS the answer later, so
its records must still be answered. kRecNoReply (RingRecordFlags bit 5, which
aliases kOptional in the call-flag space; the flag-space asserts were extended and
the alias named) is stamped when willReadReply is false, and the decoder skips
PostReply while keeping every acceptance counter - "nobody reads the answer" is not
"nothing happened". The two illegal combinations are Fatal by name.
Three call sites say it: SetTextureParams for a confirmed object, the respecify
row's fire-and-forget half, and sub-data where MGPipeSubDataWantsItsReply decides.
MEASURED ON THE DEVICE (aikjs4s8cys8ba7l, rebuilt APK + client, both halves new):
server: skipped=50,001 answered=4,521 (a frame's replies: 55,903 -> 4,543)
client: Reply frames received seen=4,501, latest seq=97,181
frame-sent: identical per op, records untouched
create row: by_op 2 = 9,070 (was 4,536) - because the drain's blocking take is
now counted, so the row's number stays honest instead of reading as
"create stopped waiting" while the waiting merely moved
AND THE WINDOW STILL READ BACK NOTHING: readfail 8,951 -> 8,872, i.e. -0.9%.
THE REAL CAUSE, from the miss probe:
P12miss n=1 want=310 replySeq=1504 ring=[1175..1504]
P12miss n=9001 want=310 replySeq=102915 ring=[102866..102915]
want=310 every time. The drain is STRICTLY FIFO and a failed read has no way out -
it retries the same head for ever. Any reply buffer is bounded by design (8 pool
slots, or the 8-frame ring the stream link now keeps), so with 4,536 deferred
answers at least one is overwritten before it is read, and that one wedges the
window permanently. Cutting the churn by two orders of magnitude does not help: it
turns a certainty into a certainty. That is the constraint, not the depth.
THE FIX IS NAMED AND NOT TAKEN (the goal this work ran under stops here on purpose).
On a failed read of the head, abandon it: MGPipeNoteHandleUnpublished +
NoteCreateSuspect + pop - the DECLINE path reused for "unknown". The window then
self-heals through the blocking path and the object merely gets slower. It needs its
own gate: a failed read must let the window ADVANCE.
Gates, all green (172 + 13 + 22 + 103):
PipeWireCodecTest.TheNoReplyBitSkipsTheAnswerAndKeepsTheAcceptanceRecord - the skip,
the kept tally, the answered/skipped split, and the negative control that a record
WITHOUT the bit is still answered (a server that stopped answering everything
would otherwise pass)
RemoteClientControls.ACreateWindowDefersTheAnswersAndTheWindowOffDoesNot - now also
asserts the answers CAME BACK (answers_taken=11, outstanding=1, fallbacks=0 against
12 records). RED-ONCE PROVED: giving the window's creates the literal
"fire-and-forget => skip" predicate makes this case fail.
RingTest - the sixth and last flag alias, and the hand-written exhaustiveness list
extended (with a note that it is only as exhaustive as that list).
Server side: AnsweredRecords()/SkippedAnswers() - the number that made this
debuggable, since a wire change whose effect is invisible is undebuggable.
Client-first is safe: an old server ignores bit 5 and replies as before, so the two
cross-version directions degrade to today's behaviour - this is "both ends must
upgrade to take EFFECT", not "to avoid breaking". Hit that in practice first.
An independent review of the one design door the measurement left open (stop sending
the ~51,000 answers nobody reads, so the 8-slot pool stops churning and the create
window becomes collectable). Verdict: feasible, worth doing, and the proposal AS
WRITTEN would disable the thing it exists to enable.
THE CATCH. The windowed create is wantReply=false AND its answer is read -
WireTables.cpp's drain reads exactly those seqs. A bit keyed on "fire-and-forget"
therefore stops the server answering the creates the window is waiting for: the
window goes from "cannot be collected" to "there is nothing to collect", and the
readfail counter that is the only way to diagnose it disappears with it. It fails
silently, not loudly. The predicate has to be per-record "this answer will never be
read" - true of params / respecify-escape / sub-data, false of a windowed create -
carried as a separate input from wantReply (willReadReply), with
willReadReply && !rowCarriesReplySlot rejected rather than ignored.
VERIFIED HERE, not taken on trust (the reviewer's file:line claims, checked):
- the flag space is RingRecordFlags (Ring.h:281-285), bits 0-4 taken, BIT 5 FREE,
and the header's own table (PipeWire.inc:35-65) says "bit 5 kOptional vs (nothing)
no ring counterpart" - so a new ring bit aliases kOptional, which is exactly what
the assert at PipeWireCodec.cpp:142-145 refuses;
- the three static_asserts that will trip are real and deliberate
(PipeWireCodec.cpp:106, :142-145, RingTest.cpp:740);
- WireFingerprint mixes payload-layout, catalogue, render-state digests + OpCount,
none of which a ring bit moves - so no protocol revision bump is required, and
both cross-version directions degrade to today's behaviour. That makes this
"must upgrade both ends to take EFFECT", not "must upgrade both ends not to
BREAK" - one notch cheaper than handoff-2 section 3.2's protocol surface;
- StreamLink.cpp:564-571 is the independent progress flush (>= 64 records or
>= 1 ms) and :577 is PostReply piggybacking SendProgress on the reply - so
skipping replies coarsens progress granularity. Bounded, but unmeasured.
AND IT CORRECTED TWO OF MY OWN NUMBERS. The frame's reply count is 55,903, not
59,673: op 4 ResourceDestroy carries kNone in the catalogue (PipeWire.inc:179) and
PostReply Fatals on a row with no kReplySlot, so it never answers - I had added its
3,770 records to the total. The derived figures move with it (12.3 replies per create,
0.65 of a create of survival, 6,988 pool churns), and every copy is fixed here:
Config.h, ConfigLoader.cpp, WireTables.cpp's section comment, CONTRACT-P5E.md,
CREATE-WINDOW-MEASURED.md.
TWO MORE THINGS THE FOLLOW-UP MUST CARRY, both recorded in the note's section 7:
- LinkMetricsBeginReply counts ownsReplySlot, so once the flag lands by_op 2 falls
to ~0 - and section 1 of the note tells the reader to use by_op 2 to check whether
the window engaged. That check becomes a FALSE GREEN; the flag needs a
server-side "how many times did this op answer" counter beside it.
- the ~112,000 sendmsg/frame the flag removes are on the apply thread, so the
flag's own value is a measurable claim that nobody has measured.
172/172 and 13/13 after the comment corrections.
handoff-2 section 3.3 asks for a gate on the one scope that empties the applier's
object records while the frontend objects live on (MGPipeApplierReleaseObjectRecords,
"latent" because its only caller in the tree is MG_Test). The gate was expected green
for a CHANGED descriptor: the respecify is refused, RespecifyOnce turns the refusal
into a create plus one retry. It is not green.
MEASURED:
dropped-records probe: creates=0 applier_records=0 published=1
Nothing is emitted at all. The refusal can only reach the emitter through an answer
the row READS, and P12 made both rows answer themselves for a "confirmed" object:
the respecify is fire-and-forget (MGPipeResourceRespecifyWantsItsReply) and the
params row writes a PROVISIONAL kStatusOk (Wire_SetTextureParams). "Confirmed" is
MGPipeHandleIsPublished, which answers "did a create GO OUT" - and it stays true
after the records are dropped, which is the scope's whole definition. The only row
left that reads the real answer is create, and create is only sent when the latch is
clear - the one thing that stops being true.
So section 3.1's "clearing the latch is not enough" is the smaller half: nothing
clears the latch either, and nothing can until the applier can say "I hold nothing for
this handle" - section 3.2's third option, the reverse channel, i.e. the protocol
surface the handoff hoped to avoid paying for. That hope was priced on the trigger
being unreachable; it is still unreachable (no production caller), so the call stands,
but the cost of a production caller is now known to be the whole thing.
THE CASE IS WRITTEN AS A DEBT RATHER THAN A SPEC, and says so in its name and its
comment: it pins today's three numbers, so whoever lands the signal sees THIS case go
red and knows the repair started working. Removing the heal from RespecifyOnce would
not make it red - nothing reaches the heal - which is exactly why the assertion is on
the applier's table and the latch rather than on a count (handoff-2 section 3.3: "a
count cannot show this class of error").
Also rewritten: notes/p12/UNPUBLISH-GUARD-SIGNALS.md sections 4 and 6 now carry this
as 4.2 (sections 2 and 3 stand: neither caps generation nor FreshlyPrimed is a signal).
172/172.
The window was the handoff's remaining item: let up to N resource_create answers be
outstanding, read a provisional accept, collect the answers in order, and un-latch an
object whose late answer is DECLINED. It is a sound mechanism and the gates below pin
it - and on the device it does nothing at all, which is why the default is 1.
MEASURED, same device, same frame, same 101,421,396 B staged, same binary:
MOBILEGL_IPC_CREATE_WINDOW=4 by_op 2 = 4,536 wall 28.64 s
MOBILEGL_IPC_CREATE_WINDOW=1 by_op 2 = 4,536 wall 28.64 s
(the handoff's pre-window baseline: 31.3 s)
by_op 2 counts the creates that took the BLOCKING path (LinkMetricsBeginReply is
called with ownsReplySlot = rowCarriesReplySlot && wantReply), so it not moving means
no create deferred. A temporary counter in the row said why: pushed=24, blocked=4,476,
took=20, readfail=8,951, pending pinned at 4 - the drain READ the answers back and
failed every time.
WHY, IN THE PROTOCOL'S OWN ARITHMETIC. An answer is readable only until its pool slot
is reused, and the pool is kDefaultReplySlotCount = 8. The server answers EVERY
reply-owning record, not only the rows that wait, because it cannot know which those
are (ClientSession.cpp:1897-1900: "The server posts either way"). This frame posts
59,673 of them: create 4,536 + respecify 20,633 + params 18,056 + sub-data 12,671 +
the rest. 8 slots against 59,673 replies churns 7,459 times per frame, i.e. 13.2
replies between two creates, so an answer is overwritten after 8 replies - 0.61 of a
create. The deferral is not slow, it is UNREADABLE, and no window depth fixes that.
AND IT IS NOT NEUTRAL: with the window on, the provisional accept latches an object
the applier may have refused, and the un-latch that corrects it lives in that same
unreadable drain. A refusal would become an object the client believes is published -
the silent divergence the suspect list exists to prevent. So the machinery is kept
(one experiment away from usable: a deeper pool, or a "do not answer me" bit, both
wire changes) and the default is off.
Worktree note: the window implementation itself was uncommitted in the tree at
82ed1df9 (MOBILEGL-CS-HANDOFF-2.md section 2, "can compile, gates mostly green, never
run on a device"). This commit lands it in the state the measurement supports. The
row also gains the heal the suspect list was missing - a BLOCKING create that lands
takes its object back out of it, or kCreateSuspectMax recoveries would retire the
window for the rest of the process - and the clamp that ties the environment's value
to the array, with a static_assert tying the array to half the reply pool so "someone
raises kCreateWindowMax to 8 and steps on the pool" is a compile error.
Gates (RemoteClientControls.inc), all green, 171/171 RemoteClientTest:
ACreateWindowDefersTheAnswersAndTheWindowOffDoesNot - window on = 12 records 0 waits;
the SAME CHILD turns the knob to 1 and it is 12 records 12 waits (the negative
control); pending <= effective after every create; 99 clamps to the array; the
array is <= half the pool
ARefusedCreateUnlatchesItsObjectAndItsNextCreateBlocks - a real refusal (the
server's consumed mask narrowed) clears the latch, +1 suspect, the object's next
create BLOCKS (2 records 2 waits), and restoring the consumer HEALS it (latch
back, suspect -1)
Docs: CONTRACT-P5E.md section 2.5 rewritten (it still said all three rows keep
kWaitReply), plus
docs/Disaggregated/notes/p12/CREATE-WINDOW-MEASURED.md (the numbers, the arithmetic,
the picture evidence: both arms SSIM 0.977) and
docs/Disaggregated/notes/p12/UNPUBLISH-GUARD-SIGNALS.md (handoff section 3: neither
caps generation nor FreshlyPrimed is a signal for "the applier dropped its records",
and the residual gap is one line - the dedup at TextureEmit.h:838 does not read the
latch, so an UNCHANGED descriptor after a drop produces no record and therefore no
refusal to self-heal from).
ILink::ReadReply takes a SEQ (ILink.h:285) and ShmLink implements it against a
ReplySlotPool, so any answer the pool still holds is readable. StreamLink kept ONE
reply - replySeq/replyStatus/reply - and then asked the wrong question of it:
x.replyReady.wait(lock, [&]{ return closed || x.replySeq >= seq || appliedSeq >= seq; });
if (x.replySeq != seq) return ... MOBILEGL_ERR_PROTOCOL_MISMATCH;
The wait is written as "at least this seq"; the check is "exactly this seq". Every
answer older than the newest was therefore unreadable, which nothing noticed because
every caller read the answer it had just waited for - i.e. always the newest one. It
is the transport half of the P12 create window's failure: the window defers up to
four answers and collects them in order, and this link could not hand one back.
THE RING, AT THE POOL'S OWN DEPTH. kReplyRing = kDefaultReplySlotCount (8), because
that is the pool this link stands in for: an answer a reader has not taken by the time
eight newer ones have arrived is one the shm arm has already overwritten too, so the
two transports now lose the same answers and no more. Vectors are sized per reply, so
an idle link pays eight empty ones.
This does NOT make the create window work - on the device it changed nothing, because
the pool itself is 8 slots against 59,673 replies in one frame (see the next commit).
It is kept because the two transports disagreeing about ILink's contract is a bug in
its own right, and because the single slot silently failed any read that raced a newer
reply.
Gate: StreamLinkTest.AnOlderReplyIsStillReadableByItsSeq posts three replies and reads
them oldest-first - the order the window's drain takes them in.
- The predicate is one function again: MGPipeResourceRespecifyWantsItsReply (PipeRoute.h) is read
by the wire emitter, so the two halves of this row cannot disagree about whether an answer is
owed. The buffer half never owes one (both call sites discard the Bool, PipeFill.cpp:1105/:1142);
the texture half owes one exactly until MGPipeHandleIsPublished, and that latch is set only by an
ACCEPTED create - which EmitResourceRespecify already sends, blocking, when it is clear
(TextureEmit.h:869-879). So the object pays once, at first use, and every record after it is
fire-and-forget.
- MEASURED on the device, same frame, same 101,421,396 B staged, stock power policy: waits
25,154 -> 4,543 and wall 130.9 s -> 31.3 s. The op split is the proof of what changed and what
did not: waits by_op 3=20611 -> absent (only 2=4536, 5=6, 9=1 remain), while the RECORDS sent
are unchanged - frame-sent still shows 3=20633 and 103,094 records total. The round trips went
away; the work did not.
- It also lands on an independent prediction: an audit run beside this work (mgl-device/scratch/
scheme-review.md) computed 4,543 remaining waits and a 24-37 s credible range (midpoint ~30 s)
from the archived logs, a host-inproc round trip of 35.4 us and a measured 43.1 MiB/s link.
Measured: 31.3 s, at the midpoint.
- Cumulative on that frame since this work started: 43,232 waits / 221.5 s -> 4,543 / 31.3 s (7.1x).
What is left is the create row (4,536 waits, ~23 s) - the confirmation itself, not waste - plus
hard floors of ~13.4 s client CPU and ~2.2 s of transfer. Going below that means batching the
create row, which keeps real answers.
- TWO DEBTS, STATED: (1) the invalidation guard for the one silent-loss case is NOT wired; the
audit confirmed independently that no production path can drop a texture record today, and if
one appears the notice it needs is protocol-surface (a tenth reverse callback). (2) No SSIM or
visual check has been taken since these rows stopped asking - frame-sent agreement is strong
evidence but it is not a picture.
- Two stale controls in older cases were fixed rather than left: the item-B gate now names the
CREATE row (its old total-based control only moved because a respecify was still waiting), and
the by-op measurement asserts records-sent for the rows that deliberately stopped waiting.
RemoteClientTest 169/169; red-once for the predicate is forcing it back to "always wait".
- The two halves of this row are not the same row. PipeFill.cpp:1105 and :1142 both call
MGPipeRouteResourceRespecify(desc, nullptr) and drop the Bool, so for a buffer the wait bought a
value that was thrown away. It goes fire-and-forget with the provisional-accept shape
resource_subdata has had since item B.
- The texture half keeps its answer ON PURPOSE: there the Bool is the self-heal trigger AND the
gate on the acceptance mirror, and TextureEmit.h:885-888 (ID-18 M3) says advancing that mirror on
an unconfirmed answer is how the next identical call gets suppressed "against a record that was
never stored". That half needs the provisional/really-accepted distinction plumbed through the
reply mailbox before it can stop waiting; it is not a flag flip.
- MEASURED, same frame, same 101,421,396 B staged: waits 25,176 -> 25,154 and by_op 3=20633 ->
3=20611. So the buffer half is worth 22 waits (0.1%) and the remaining 20,611 are texture
respecifies - which is what says the next step is the mirror design, not another flag. Wall
134.4 s -> 130.9 s.
- The ERROR-is-not-a-decline case moves to the texture half: a row that reads no answer cannot fold
ERROR into a decision, so the rule it exists for belongs to the half that still waits (the
transport fault is caught at the next row that does). New case
ABufferRespecifyTakesNoReplyWhileATextureOneStillDoes pins both directions; red-once with the
branch removed is exit 155. RemoteClientTest 169/169.
- The device run put 18,056 of one atlas frame's 43,232 round trips on this row, at ~5 ms each, for
records carrying a few hundred bytes. The acceptance it waited for says exactly one thing - "the
applier holds a record for this handle" - and the client already keeps its own copy of that fact
in MGPipeHandleIsPublished, which is set when the create was ACCEPTED and is never cleared while
the record lives (the applier keeps texture records across a make-current, PipeFill.cpp:3756-3759).
The wait was the client asking the server a question it had already answered.
- SO THE CONFIRMATION MOVES TO THE FIRST USE. EmitTextureParams now checks the latch BEFORE it
sends: an unpublished object gets HealUnpublishedRecord - the same create-with-no-storage, the
same storage respecify, the same one re-send the refusal branch has always run, extracted so both
callers share it - and that create is the ONE blocking record the object owes. Every parameter
record after it is emitted fire-and-forget and the caller reads a provisional accept, exactly as
resource_subdata has done since item B. Wire_SetTextureParams reads the same latch to decide, so
the two halves cannot disagree about whether a wait is owed.
- THE FALLBACK IS THE OLD PATH, unchanged: an object whose create was refused stays unpublished, so
its next parameter record still takes the real answer and the existing one-retry heal still runs.
That is also the red-once: with the unconditional EmitAndWait put back, the new case fails -
RemoteClientControls.AParameterChangeForAConfirmedObjectTakesNoReply exits 153, having asserted
that the eight parameter records still go OUT while none of them takes a reply.
- WHAT IS GIVEN UP, stated rather than implied: a loss the client believes is published (a bug, or a
served-context teardown whose only caller today is a test) is now silent on the CLIENT - it stays
counted and named on the server, which is the same residue item B accepted for the upload row.
- The measurement case's "the counter is live" control moves to create/respecify, because this row's
zero is now the fix rather than a dead counter. 168/168 in RemoteClientTest; the unit lane is
2617/2617. Tracks: docs/Disaggregated/notes/p12/DEVICELOST-AND-UPLOAD-WAITS.md.
- 2.9 established that a record's ~5 ms is mostly the DEVICE waking out of a deep idle state.
That points at a knob that is already wired: how long the server's apply thread spins before
parking. Raised from 50 us to 2000 us (via the Activity's existing `env` extra, which
ServerEnvironment.apply parses as KEY=VALUE;KEY=VALUE and which does not scrub
MOBILEGL_IPC_SPIN_US), the same 43,232-record frame goes 221.5 s -> 182.8 s (-17%), with
rtt_mean 5,040 -> 4,145 us. Pinning the device and disabling its deep idle states is worth
166.5 s (-25%) but needs root; the spin needs neither root nor a rebuild.
- The server log proves the knob landed (`mgl-srv-apply started ... spin 2000 us`) rather than
leaving the reader to trust an env var, and the note carries the exact am start line.
- So the load stall now has three ordered levers with numbers behind them: batch the waits
(client-only, not built), keep the server hot (this commit, usable today, trades device CPU for
wall clock on a frame that is 94% waiting), and pin the device for measurement discipline.
- Three one-variable experiments on the same device, same workload (the 43k-record frame): a 40x
larger client spin moves the per-wait mean by ~10% (so it is not the client park/wake); the
same frames over adb forward - a link whose raw RTT is several times lower - by ~25-30% (so it
is not the link, which is what the handoff saw in 3.6 and could not account for); and pinning
the device (governor performance on all eight cores, cpuoff_l / clusteroff_l / mcusysoff /
s2idle disabled) by 26% per wait and 25% of the whole frame: 221.5 s -> 166.5 s for 43,218
records and 100.5 MB staged, against 43,232 and 101.4 MB in the baseline.
- The mechanism: the server's apply thread parks between records, the SoC drops into a deep idle
state, and every record pays the exit latency - milliseconds, once per record, which is the
entire shape of the frame. It also matches the server profile the handoff took (65% socket
syscalls, ~15% doorbell spin: waiting and waking, not working).
- Stated where it matters: the handoff's 249 s was taken on an UNPINNED device, so its absolute
numbers carry this idle tax - the project has a pinning discipline for exactly this reason.
- The lever list is now ordered: batch the waits (K <= 8 by the reply-slot pool, client-only),
keep the server hot between records (its own spin budget, which the APK does not wire yet), and
measure split numbers on a pinned device. The device was restored to stock afterwards.
- A wait count alone cannot say whether a frame is slow because it sends too much or because it
waits too often. The device run made that the next question: one atlas frame paid 43,232 waits
while the upload row paid none.
- LinkMetricsNoteRecord is called at the encoder's one commit point (PipeWireCodec.cpp, beside the
existing CallClass::WireRecords count, for the comment's reason: after chunking no emitter knows
how many records its call produced). LinkMetricsRecordsFor reads it back, and every frame that
emitted something now logs `P65LinkMetrics kind=frame-sent frame=N records=M by_op=...`.
- The host measurement now prints and asserts both halves for the same workload. An atlas-shaped
load (64 sub-uploads into one level) sends 128 ResourceSubData records and waits for ZERO of
them, while ResourceCreate 76/76, ResourceRespecify 130/130 and SetTextureParams 130/130 are
one wait per record. That is item B stated exactly: the records still go out; the round trips do
not. It also says what the next thing to fix is - the record COUNT of those three rows, not their
waits, which the contract rules they must keep.
- The device run that motivated this made the point: 43,232 of a session's 43,629 reply waits were
in ONE atlas frame, and the exit summary never appeared at all - a SIGTERM does not take the
session's clean stop path, so LinkMetricsEnd is never reached. The split added one commit ago
was therefore invisible on exactly the run that needed it.
- Emit now writes a second line for any frame that bought a wait:
`P65LinkMetrics kind=frame-op frame=N wait_replies=M by_op=<id>=<count>,...`. The formatting is
factored into FormatOpSplit and shared with the exit line. Frames that bought nothing stay
quiet, so a steady-state session does not double its log.
- Measured on the device (MC 26.2 into a world over TCP, MOBILEGL_PIPE_STATS=1): the steady state
is one wait per frame and it is op 9 (Present); the upload-heavy startup frames are
`2=50,3=68,47=62` - ResourceCreate / ResourceRespecify / SetTextureParams, with ResourceSubData
(48) absent, which is item B working on the wire it was written for.
- The tally added beside this is only useful if somebody can read it where the problem is. The
handoff says why that was hard: under spawn the client does not advance the frame window, so the
per-frame P65LinkMetrics lines never print, and a SIGKILL takes the exit-time dump with it -
"only the dump at exit, and SIGKILL does not get it".
- LinkMetricsEnd writes the split on its own line from the one place a client that ends the
ordinary way always reaches: P65LinkMetrics kind=per-op wait_replies=N by_op=<id>=<count>,... .
Ids stay raw (this file includes only <cstdint> and friends, not the op table) and the reader
names them: 2 ResourceCreate, 3 ResourceRespecify, 47 SetTextureParams, 48 ResourceSubData. The
mapping is written down in mgl-device/README.md, which is the page the device run reads.
- What was open: the handoff's 4.3 called ResourceCreate and SetTextureParams "a second problem of
the same kind, needing its own analysis" and left it there; 5.4 said the per-op split of the
55,428 waits was never quantified. The measurement committed beside this answers both.
- The ruling (CONTRACT-P5E.md 2.5): ResourceCreate, ResourceRespecify and SetTextureParams stay
kWaitReply, because each answer carries a fact about the SERVER's record table that the client's
own tracker provably cannot know. RespecifyOnce says it outright - "the applier's REFUSAL is the
only signal that says 'I hold nothing for this handle'", which is the scope
MGPipeApplierReleaseObjectRecords leaves for a served context whose frontend objects live on;
EmitTextureParams uses its own refusal as the same self-heal trigger; PublishCreate may not take
the publication latch on a refused create (D-I1/c0b) or the death path emits a resource_destroy
for a record that does not exist. Unlike the upload half these are not "the emission gate already
answered it" rows, so removing their waits removes the signal.
- The numbers, both shapes a stitcher produces: an atlas stitch goes 64 -> 0 reply waits; a texture
per sprite goes 384 -> 320 with ResourceSubData now zero and the remainder all three of those
rows. So item B hits exactly the shape it was aimed at, and what is left is per-resource /
per-call rather than per-chunk.
- notes/p12/DEVICELOST-AND-UPLOAD-WAITS.md gains 2.4 with the table and the ruling, and its "second
problem of the same kind" entry is closed rather than carried; MEASUREMENTS.md records the two
shapes beside the 1 -> 0 single-upload number.
- The handoff measured the total (55,428 reply waits in one Minecraft frame) and left "which rows"
open: a total is the same number whether the waits are uploads, creates or parameter sets, and
those three have different fixes - so knowing the split is what says whether P12 item B is the
whole story or a fraction of one.
- LinkMetricsBeginReply now takes the op and Window carries a per-op tally, so
LinkMetricsReplyWaitsFor(op) reads back what the P65LinkMetrics line prints as `wait_replies`
split by row. Same activation as everything else in that file (MOBILEGL_PIPE_STATS=1), one array
in the window, and a value past the table lands on the last slot so the per-op numbers still SUM
to the total.
- RemoteClientControls.ReplyWaitsByOpForAnAtlasShapedLoad drives the two shapes a stitcher
produces and prints the table: an atlas stitch (64 glTexSubImage2D into one level) goes from 64
reply waits to ZERO, and a texture per sprite (64 textures, 256 glTexParameteri) from 384 to 320
with the remainder all ResourceCreate 76 / ResourceRespecify 130 / SetTextureParams 130.
- It prints before it asserts: the table is the point of the case, and a gate that fired first
would hide the numbers on exactly the run that needs them - the red one. The assertions are the
structural ones that can be wrong about the mechanism (upload rows zero, the other rows not, the
per-op tally summing to the total); the numbers themselves are recorded, not gated.
- The A/B is a real run: with the wire-arm arm test removed the case goes red with the full table,
which is where the 64 and 384 above come from.
- CONTRACT-P5E.md 2.5 said "the TEXTURE half keeps its wait because DrainTextureSubData clears the
level's dirty flag on the accepted reply (D-D5; trailing)". The trailing is paid, so the clause
now states the three facts that make that answer DERIVABLE on the wire arm (the emission gate IS
the consumer question the applier's own acceptance belt asks; what remains is a loud bug class;
a client-cancelled record still answers "not accepted"), where the wait moves to (SEG_STAGE),
that MGPipeSubDataWantsItsReply stays the one place that decides, and the gate with its red-once.
ResourceCreate and SetTextureParams are named as analyzed separately rather than swept in.
- notes/p12/DEVICELOST-AND-UPLOAD-WAITS.md: 2.3 now carries the G1 A/B this machine ran - .text
13872002 bytes on both sides, 31746 symbols, 0 added / 0 removed, no per-symbol difference -
instead of deferring it to CI, and 3 trades its "G1 not run" item for the device tooling
(mgl-device/: launch-mc.py, gl-aliases.sh, start-phone-server.sh) the next round needs.
- notes/p12/DEVICELOST-AND-UPLOAD-WAITS.md: the two items, their root causes with file:line anchors,
the five cases that pin them, the red each one produced when the fix was taken back out, and the
four things this round did NOT do (device end-to-end, local G1, the ResourceCreate /
SetTextureParams pair, and the sandbox's missing /dev/dri - which reds the two EventForfeitPeer
lanes for reasons that have nothing to do with the change).
- p12/README.md: a line in the summary and the new file in this directory's table.
- MEASUREMENTS.md: the P12 conclusion and row now carry the number the gate pins - one reply wait
per pure texture upload becomes zero - with the device wall clock named as the measurement still
owed rather than left implied.
- MGPipeSubDataWantsItsReply is the ONE place that decides whether a resource_subdata record waits
for its answer (WireTables.cpp:370 and PipeRoute.h:328 both read it, and the comment beside it
says that a second spelling is how the client comes to wait for an answer the emitter told the
server not to bother with). The buffer half has answered "no" since P5e; the texture half now
does too, ON THE WIRE ARM ONLY.
- THE ANSWER IS DERIVABLE THERE, which is the argument - not that nobody reads it:
* The emission is already gated on the answer's content. A texture record is produced only where
FamilyIsLive(kMGPipeSubsystemTextureResources, ...) holds, and under split that gate IS
CapsMirrorInstance().ServerConsumes(bit 10) (PipeFill.cpp:1719-1740) - the same published fact
the applier's own acceptance belt reads (PipeApply.cpp:1408-1419, NoP4aConsumer). So the one
refusal this acceptance exists to carry, "no consumer" (ID-39 / D-D5 / ID-18 M3, which is why
the texture half ever asked), cannot be the answer to a record this client emitted.
* What remains is a bug class, and it is loud already: a record that names no texture, declares
no texels or carries no bytes is an MGP_TRIP_WIRE_REPORT (PipeApply.cpp:1201-1218, :2245-2252),
and a handle the applier does not hold is counted and named (ResolveResourceIn, :721-728) with
the client's own tracker preventing it from arising (PipeFill.cpp:1045-1051).
* Every answer the client can still get for free, it still gets: the emitter reads this route's
Bool exactly as before, so a record the CLIENT cancelled (the device-lost latch, an exhausted
stage) still answers "not accepted" and the level stays dirty and is retried - D-D5's safe
direction, unchanged.
- THE WAIT MOVES TO THE STAGE. A run that does not fit SEG_STAGE reclaims retired bytes and parks
on the stage doorbell (PipeWireCodec.cpp StageAllocate / ReclaimStagedBytes) - the one wait a
run-ahead client owes, and it is proportional to the WINDOW rather than to the call count. On
Minecraft 26.2's atlas frame that is the handoff's 55,428 reply waits at ~4.4 ms each: 96 MiB of
uploads through a 32 MiB window.
- THE GATE IS THE NUMBER ITSELF. LinkMetricsReplyWaits is added so a case can read back the same
`wait_replies` the per-frame P65LinkMetrics line prints - LinkMetrics.cpp:76-80 counts exactly
the records that own a reply slot. TextureUploadsStopOwningAReplyOnTheWireArm asserts that a pure
glTexSubImage2D crosses the wire and moves it by ZERO, while a resource_create row - kWaitReply,
and it always was - still moves it, so the zero cannot be "nothing was counted". Red-once run:
with the arm test removed the child exits 142.
- Monolith and every non-wire arm are untouched, and that is pinned twice: the existing
RemoteRunAhead.OnlyTheTextureHalfOfResourceSubDataWantsItsReply still reads true with no arm
installed, and the new test sits inside MOBILEGL_BUILD_DISAGGREGATED with the original expression
as its fall-through, so a pull build preprocesses to the line it had (G1).
- Verification debt, stated rather than implied: the host gate is the COUNT, not a wall clock.
In-process control sessions run lockstep (the caps peer publishes no kCapRunAheadApply), so the
verb barrier still waits there; the end-to-end wall-clock win is the device measurement the
handoff describes and it has not been taken yet.
- ReadFenceReply (EmitTables.cpp) and ReadQueryReply (QueryEmit.inc) tested `status != kStatusOk`,
which is two values in one branch. ReplySink's own header says "DECLINED IS A REAL ANSWER, not
a failure" (PipeWireCodec.h:457-460), and the escape rows are held to the other half of the same
rule - "ERROR IS NOT A DECLINE ... an escape may not fold 2 into `false`" (WireTables.cpp:446-450).
- BOTH VALUES OF 1 ARE REACHABLE WITHOUT A BYTE OF CORRUPTION: the server declines these five rows
by design whenever it cannot answer them (PipeWireCodec.cpp:1820-1830 for the two fence rows,
:1841-1861 for the three query rows - a zero-length answer to a kWaitReply row, R-5), and the
client produces the same status itself once the session stops carrying records
(ClientSession.cpp:1837 the device-lost latch, :2025 a doorbell that died during teardown, where
the comment says outright that "the verb did not happen" is reported as DECLINED and not ERROR).
- WHAT THE FOLD COST: after a clean device loss Minecraft 26.2's next glClientWaitSync - the
chunk-upload fences - died of Fatal{ProtocolCorruption, "Fence.reply"} (rc=134) instead of taking
the no-op the design promises ("GL calls become no-ops", design/08-runtime-and-platform.md:33).
The query rows had the same shape waiting for them.
- The no-op is the one this frontend already gives for a sync it cannot wait on: GL_Sync.cpp:103-106,
PipeApplier.cpp:175-177 and DirectGLES.cpp:16489 all answer GL_ALREADY_SIGNALED. A query row
answers its own "no answer yet" value - availability false, Produced 0 (which the caller already
returns as false with the out-parameter untouched), timestamp 0.
- ERROR and a short OK keep the named Fatal they always had: a transport fault is not the server
saying no, and an OK answer that arrived empty is a reply nobody can read. ReadPixels keeps its
deliberate Fatal{ReadbackDeclined}: unlike these rows a blocking readback cannot succeed short of
complete pixels, and that ruling is untouched.
- PipeFill.cpp's UncarriedInitialBytes self-check now spares a session that has latched device-lost.
The resource_subdata it counts is DECLINED by design there, so "it emitted nothing" is the latch
working rather than bytes this client forgot to carry - and there is no server left for them to
exist on. P12's exit gate (a) is that killing the server gives a CLEAN device-lost latch, not a
corpse at the next glBufferData that carries data.
- Four cases in RemoteClientControls.inc drive the production consumers over a live session whose
peer declines them. Red-once run for all three Fatals (Fence.reply, Query.reply,
UncarriedInitialBytes), and ErrorFenceReplyStillRefusesByName is the control that keeps the
tolerance from becoming blanket. RemoteClientTest 166/166; the host unit lane 2610/2610.
- Split only: both consumer files compile under MOBILEGL_BUILD_DISAGGREGATED and the PipeFill hunk
sits inside its `#if`, so a pull build preprocesses to exactly what it had (G1).
- PublishCapsSnapshot filled that field with info.RendererGLInfo.TargetGLVersion.toString(). The
client reads it back through BackendObject_Remote::GetBackendAPIVersionString (CapsMirror's
ApiVersion) and prints it in the parentheses of GL_RENDERER, which is the only place a real
device is named; a monolith build fills the same parentheses from the backend's own
GetBackendAPIVersionString. A split client therefore answered "Espryt (MobileGL Core) (4.6.0)"
where monolith answered "Espryt (MobileGL Core) (Adreno (TM) 750, OpenGL ES 3.2)".
- The target version still crosses the wire inside the renderer-info blob (CapsCodec), which is
where GL_VERSION reads it, so no reader loses it.
- Content only: the field already existed and the blob layout is untouched, so the wire
fingerprint needs no bump, and the file compiles only under MOBILEGL_BUILD_DISAGGREGATED.
- Verified with a PC client against a phone server: Minecraft and Sodium both log
"Espryt (MobileGL Core) (Adreno (TM) 750, OpenGL ES 3.2)".
- README, ARCHITECTURE, ROADMAP, MEASUREMENTS and CURRENT_STAGE_PROGRESS are rewritten as indexes
(84 KB -> 15 KB): what the project is, where it stands, and where each detail lives. ROADMAP is one
plain sentence per phase; MEASUREMENTS is the five conclusions that matter plus one line per phase;
CURRENT_STAGE_PROGRESS is the P12 goal, a checklist and the next steps.
- ARCHITECTURE's body moves unchanged into ten design/ files, keeping §1-§17 and appendices A/B, so
`ARCHITECTURE.md §N` references in code resolve through the table in ARCHITECTURE.md.
- guide/ holds build-and-run, code map, glossary and the engineering discipline (from README and
ROADMAP), and the device pinning record that used to sit in devices/.
- The open-debt table moves to notes/DEBTS.md; the detailed current-stage tables (with decision IDs,
the open line items and evidence locations) move into notes/p12/README.md. notes/README.md maps
every moved path and section.
- The documentation citation lint in test.yml also covers design/ and guide/, which describe the
current tree as the top-level files did.
- ROADMAP, MEASUREMENTS and ARCHITECTURE are rewritten as summaries (333 KB of index text -> 84 KB).
ROADMAP's phase table is one line per phase plus a link; MEASUREMENTS keeps headline numbers under
its old §1-§13 numbering (plus §14-§16 for P6.5, P7 and P12); ARCHITECTURE keeps every decided rule
under the same §1-§17 / appendix numbering, so the `ARCHITECTURE.md §N` references in code still
land, and §17 becomes an index of the phase landing shapes.
- CURRENT_STAGE_PROGRESS now describes the current stage (the P12 on-screen-server subset) and absorbs
CURRENT_STAGE_STATUS, which is removed. The P7 status tables, the P6 closing snapshot and the P6.5
first-wave snapshot move to notes/p7, notes/p6 and notes/p65.
- Each phase gets notes/<phase>/README.md: a short summary, then the verbatim material that left
the index documents (its ROADMAP row, its MEASUREMENTS section with subsection numbers kept, its
ARCHITECTURE §17 part, milestones) and a file index. P8-P11 and P13 get their planned scope;
the full open-question list moves to notes/OPEN-QUESTIONS.md.
- The phase reports that sat at the top level move into their phase folders with unchanged names:
P5D-INPROC-PERFORMANCE, P5E-RUNAHEAD, P5F-WIRE-COMPLETENESS, P6-SPAWN-PLAN, P6-CONTRACT-DRAFT,
P6-ENDSTATE-REVIEW. Their relative links, CONTRACT-P6.md's links to them and one tool docstring
are updated; notes/README.md maps every old path and section number to its new place.
- 71 files under notes/recovered/ that were byte-identical copies of phase-folder files are removed.
- Corrections made while condensing: the server process reads Transport as Spawn (CONTRACT-P6 §3.1
amended the old "child forces monolith" rule); both backends now publish kCapRunAheadApply;
MOBILEGL_IPC_RESPAWN is a named refusal and MOBILEGL_IPC_IDLE_EXIT_S is not parsed; the Redmi
pin row is in tools/device_bench/pin_device.sh.
check_doc_citations over docs/Disaggregated/*.md: 0 problems. No relative link is newly broken.
- unit 2605/2605, integration-spawn/tcp and the three Magma two-process lanes green, hygiene 12/12, census 79 / ratchet 171 unchanged, revision 3 pinned.
- Major 1 red without its fix ("Detach answered TimedOut in 3000 ms after 200 of 200 streamed records"), major 2 red without its fix (all four expectations), both green restored.
- SplitLogPaths / spawn_lane_parity / integration-gpu reds are registration artifacts of this container's CMake 3.28 (lane environment missing) and 4.4 (lane environment flattened into one variable; gate-review-d04ac7d1-cmake4.log); the four PersistentCoherentMapScenario and three F1WireScenario cases behave identically at cb0404f2 and at this head when the binary is run directly with the same environment. No CMake file changed since cb0404f2.
gate/gate-review-3c342c97.log: the P12 gate at head 3c342c97 (dirty=0) in a 4-core container with clang-19, lavapipe (mesa 25.2) and ctest 4.4.3 - unit 2605/2605 (the four new review cases included), integration-spawn/tcp and the three Magma two-process lanes green, hygiene 12/12, fatal census 79 / link ratchet 171 unchanged, protocol revision 3 holds. Still being attributed: SplitLogPaths and spawn_lane_parity (the container's ctest tooling; no CMake file changed since cb0404f2), four PersistentCoherentMapScenario and three F1WireScenario cases in integration-gpu (being run at base cb0404f2 with the same flags), and IterationRPProgram203Scenario (out of scope, ID-P12-12). gate-container.sh is gate.sh with the host paths removed and G1 run separately.
- notes/p12/INTEGRATOR-DECISIONS-P12.md (new): ID-P12-1..4 the subset's shape (the user rulings, one surface mode per session, the lost-window order, the in-process thread hand-off), ID-P12-5..11 the review round's ten fixes (two majors: the lost window answered before every ring record, the ended session's Espryt unit shadows emptied with its twins), ID-P12-12 IterationRP out of scope, ID-P12-13 the device stage not re-run in this environment.
- ARCHITECTURE.md §15.2: the deleted Surface -> AIDL window path is struck through, and the server-owned window path, the lost-window contract and the in-process session boundary are described.
- ROADMAP.md: the P12 row's status names the review round and that the device checks were not repeated.
The Vulkan renderer (swapchain and VkSurfaceKHR on the window) is a process global that only ReleaseEGLResources or a released surface drops, and ~BackendObject_DirectVulkan was defaulted. A client that went away without eglTerminate (a crash, a kill, a dropped connection) sends neither, so after its session the renderer stayed on the display Activity's window past the end of the session's lease. Under a transport the server backend's destruction - the session's end, on the apply thread, before the lease ends - now drops the renderer (with the generation bump and the program reflection caches, as ReleaseEGLResources does). Split build only; the pull build's destructor is still defaulted (G1).
Major. DropEveryTwinForEndedServerSession drops every twin with InProcessTeardown() answering true, which is exactly the case in which a texture / sampler twin's destructor does NOT scrub itself out of TextureImpl::g_boundTexturesCache / SamplerImpl::g_boundSamplersCache. The shadows kept raw pointers to freed twins; the next in-process session's twin allocated at a recycled address read "already bound" and skipped its glBindTexture / glBindSampler on a context where nothing was bound, so its uploads went to texture 0 and it rendered black. The drop now empties both shadows and puts the active-unit shadow back to 0 (the new context's GL_TEXTURE0). SanityTest EsprytServerSession.TheServerBackendsDestructionEmptiesTheTextureAndSamplerUnitShadows (split build only; the pull build is unchanged, G1).
SetLogForwarder forwards every server-role thread's line, which is right for a session child (the process is the session) and wrong for the display Activity's process, where MOBILEGL_IPC_ROLE=server makes every thread a server thread: the UI thread's surface callbacks and the listener's lines - naming other peers' addresses, Busy refusals included - went into the live client's control socket. And the forward was a blocking SendFrame under the global log mutex, so a client that stopped reading held that mutex and hung the UI thread at its next log line (surfaceDestroyed's Detach logs).
In-process, RunSession now installs SetSessionLogForwarder: only the session thread and the threads that opt in (the session's apply thread, SetThreadForwardsLogToPeer) forward, and the send happens after the log mutex is released, under a forward mutex of its own (WithLogBarrier takes both, so the flush ack still follows every send that began; a line logged by the send itself is not re-sent). The forked session child keeps SetLogForwarder unchanged; the pull build's Log() is unchanged (G1).
DirectGLES keeps the requested eglSwapInterval in a process global on purpose (a surface re-created mid-session re-applies it), and every server process used to serve one session. The in-process display server serves the next session in the same process, which then silently inherited the previous client's interval. The server backend's destruction under a transport now calls DirectGLES::ForgetRequestedSwapInterval() beside DropEveryTwinForEndedServerSession (split build only; the pull build is unchanged, G1).
- Client resize: eglResize on a headless client's server-owned surface only rewrote the backend's record of the window - the phone's SurfaceView kept its size - and the client's EGL state answered the size it asked for. The server now recognises the surface as its own (ServerLoop::m_serverOwnedSurfaces, per session) and resizes it as a geometry request (the lease re-taken by its holder, the display waited for), resizes the backend surface to the window's real extent and replies with it; BackendObject_Remote adopts that extent (ServerResizeServerOwnedWindowSurface).
- Size tracking: the client held ONE server-owned surface, so a second one took the first's geometry updates and releasing it left the first with none. ClientSession now tracks every server-owned surface and applies an extent to all of them (they share the server's one window).
- Size reports without the knob: Espryt's new extent publish fired for every window surface of a spawn/tcp session, including windows a client named itself without MOBILEGL_IPC_SURFACE=server, switching their consumer to the reallocating arm. ServerLoop now names its own leased window to the backend (MGPipeServerSetOwnedWindow, split-only) and Espryt publishes the extent only for that window.
AcquireFor(0, 0) granted the lease at once, at whatever the window reported - and right after a session that fixed the size, that is still the fixed size: setSizeFromLayout answers asynchronously, like setFixedSize. ServerDisplay now remembers the geometry last requested and the layout's last reported extent; a 0/0 request that follows a fixed one waits (within the geometry grace) for the layout's extent, or for any report after the request that is not the old fixed size. ServerDisplayTest.TheWindowsOwnSizeAfterAFixedSizeIsTheLayoutsNotTheStaleFixedOne.
- Major: the window-lost release was picked up only between drain batches (PumpControlRequest), and a client streaming frames keeps the command ring from emptying for seconds - surfaceDestroyed waited out its 3 s bound on the UI thread, answered TimedOut, and the session kept presenting into the destroyed window. DrainRing now checks the request after its latch check, before every pop. ServerLoopLatchTest.ALostServerWindowIsReleasedMidBatchWhileTheClientStreams streams a 4 s batch under Detach.
- Stop while streaming: mobilegl_server_stop_inprocess ended the session through loop.Stop(), whose batch and final drain of a ring the client keeps refilling could outlast the bounded join (Fatal{ApplyThreadJoinTimeout} aborts the display Activity's process). RunSession now calls ServerLoop::AbandonQueuedRecords() first when the server is stopping: the drain stops after the record in hand and the exit path declines the rest, as a ReverseChannelForfeit does. ServerLoopTest.StoppingTheServerUnderAStreamingClientLeavesTheQueueAndJoinsPromptly.
- the two counts were left as placeholders in the previous commit; they are 1 / 1 (DirectGLES / DirectVulkan), and the client summary of 4 / 3 is that one entry report plus the server read arm's 3 / 2, the divergence counter being process-wide
- split_log_paths.py's verify_split_arming docstring said the old count-only floor left ~117 entries unnamed; 117 is the lane's skip count, and the entries the floor left unnamed are the ones that did not arm - 155 on the last host run (1064 per-entry client logs, 909 armed), of which the 117 skips are a part
- CONTRACT-P7 §6 cited the monolith verify block by a CMakeLists line number ("约 :1715 起", with a note about an earlier wrong range); the guard `if (MOBILEGL_PIPE_VERIFY)` is the durable citation and now stands alone
- the summary line ("N divergence(s) survived MOBILEGL_PIPE_VERIFY_FATAL=0") came from a namespace-scope static's destructor, which runs from __run_exit_handlers AFTER MobileGL::Destroy has called MG_Util::Debug::Close(); Close nulls the role's sink, Log.cpp's WriteToFile re-runs InitFile() for the next line, and InitFile opens the role's file with "w" - so that one line was the only line the client half of every FATAL=0 run kept (121 B: the entry compare's reports, the arming line and the config dump all gone, on the split corrupt / read-corrupt entries and on the monolith arm's VerifyCorrupted. log alike)
- fixed in verify-only code, since Log.cpp is in the pull build and G1 admits no byte there: VerifyState gains FlushSummary(), which reports the count once (idempotent - a second call with nothing new, the destructor's own included, writes nothing); MGPipeVerifyFlushSummary() exports it from PipeFill.cpp, declared in PipeInputs.h beside the other verify-only entry points, and DestroyImpl calls it right before Close under #if MOBILEGL_PIPE_VERIFY
- the note's A' paragraph no longer says the entry compare is silent on the pack field - it reports it (the client summaries read 4 divergence(s) on DirectGLES / 3 on DirectVulkan); the red line can only come from the read arm because of where=read and the server-role file, and the paragraph now names the truncation and carries the numbers
- measured on the same entry, pipe-verify-split-read-corrupt-DirectGLES.client.log: 121 B / 1 line before, 7461 B / 85 lines after (DirectVulkan 121 B / 1 line -> 7386 B / 85 lines), with the where=entry report (1 each; the summary's 4 / 3 is that one plus the server's 3 / 2 read reports, the counter being process-wide) and the arming line present and the summary as the last line
- measured with one block removed at a time: without the re-applied block both server halves drop to 1 (DirectGLES 1 / DirectVulkan 1, both entries red); without the first block DirectVulkan drops to 1 (red) while DirectGLES stays at 2 (green), because the GLES backend reads the pack field twice after the neutral pack is installed (3 = the saved-pack read + 2 post-install) and DirectVulkan once (2 = 1 + 1)
- so the >= 2 requirement falsifies the re-applied block on both backends and the first block on DirectVulkan; the CI step runs both backends and needs both, which it already does
- the scenario, hook and CI comments now say exactly that, instead of "removing either block drops it to 1"
- the server reads GetPixelStoreParameters twice inside the ReadPixels window - PipeApplier::read_pixels saves the application's pack through the accessor before it installs the neutral one, and the backend's ReadPixels reads the field after - and both reads sit inside ServerReadsInsideTheNeutralPackWindow; the hook's first perturbation is what turns the saved-pack read red (past the first compare the application's pack differs from the neutral oracle on its own), and only the perturbation re-applied after the window's overwrite can turn the post-install read red, because there the stored value is the neutral pack
- so a control that accepted ONE `where=read` line was satisfied by either block alone and falsified neither: the second block, which protects the lane's post-install backend reads, had no falsifier
- CorruptedFieldIsReportedOnTheServerRead now counts the `where=read` reports for the field in the server half since its mark and requires at least two; the CI read-side step does the same with grep -c; both block comments in the hook say which read each turns red
- measured on the host: both entries green, server half DirectGLES 3 / DirectVulkan 2; removing only the second block rebuilds green and turns both entries red with exactly one report per server half (1 / 1, ctest rc 8); the file was restored byte-identical and rebuilt
- ADrawAfterTheEarlyReclaimFollowsTheNewStoreNotTheMemoizedHandle rides the same split/spawn
fixture as the three gauge cases and the tcp arm does not carry it (magma-m2.md §9.3); the
integration tree keys this list by exact tail, so the fourth name goes in beside the three
- M2 round 2 left this to the integrator: lastUseSubmitIndex is B3's field and exists only on
the integration tree; without the reset the fresh store answers busy-by-submission until the
old index retires (a needless staged copy, conservative not unsafe)
- Section 0 cites the six commits by their pipe-tree SHAs (b81a8827c569c9160acee60a9328bdc7f0356dbe5d8a2b99), with the branch SHAs alongside; every in-text cite follows.
- Section 2.1 keeps "the frame-serial floor is not a proof" on its first reason and drops the second, a stale citation of B3's fix; 6.3 is reworded to match.
- New section 9 records the review's must-fix (ID-P7-43): the handle-reuse ABA against UniformManager's memos, the destroy-epoch fix, the deterministic scenario and its red-once on both arms (the dead store's colour read back at base), the finding that the integration binary links the backend statically so a red-once variant has to be built into it as well (round 1's reds re-verified that way, unchanged), the three more knobs the spawn scrub keeps, the review's smaller items, the gates, and what is left for the integrator (the pipe-tree-only lastUseSubmitIndex reset in RespecifyWireBuffer).
- The environment scrub kept exactly one MOBILEGL_IPC_* knob (WIRE_DEFERRED_MB, M2); these three are also read by the server and were silently defaulting in the spawned child for the same reason: SPIN_US is the doorbell spin budget both roles read, SERVER_AFFINITY the apply thread's CPU mask, AUDIT the 0xDD fill of retired stage bytes. Each is a tuning or audit value; none names an endpoint, a role, a path or a segment size, and ServerMain's catch (b) keeps verifying the four it verifies (TRANSPORT, SERVER_PATH, RING_MB, STAGE_MB).
- Everything else under the prefix still goes (the fable review of M2, item on ServerSpawn.cpp).
- ADrawAfterTheEarlyReclaimFollowsTheNewStoreNotTheMemoizedHandle is the round-2 red-once for ID-P7-43, deterministic at every step: a draw from a 256-byte UBO store memoizes its handle; glBufferData(0) orphans it without minting; glClientWaitSync with the flush bit and a zero timeout submits the batch without waiting (the review's flush-without-wait); a glBufferSubData into the busy vertex store records a staged copy so the blocking glClientWaitSync that follows cannot take the frame-boundary drain that clears the memos; the vertex store's own glBufferData(0) is the park whose sweep destroys the dead store; a 64 KiB glBufferData through the same GL buffer mints through the freed handle value (glibc tcache, LIFO) into memory that cannot alias the 256-byte hole a 1 MiB pin store bounds; the second draw resolves the same (handle, range). The pixel must be the new store's colour; at base it is the dead store's.
- Red-once (rule J), the epoch guards removed from UniformManager.cpp and the variant built into MobileGL, MobileGLServer AND MobileGLIntegrationTest: Split and Spawn both read rgba(200,60,30,255) - the dead store's colour - against the wanted rgba(60,200,90,255); green with the guards. The integration binary links the backend statically (MobileGL_s), so a variant built into the two shared targets alone never reaches the inproc arm - the first run of this red-once read Split green for that reason, and a re-run of round 1's base variant into all three targets reproduces round 1's reds on both arms unchanged (1025 vs 4; 49,287,168 vs 9,437,184 and 49 vs 11; 3001 vs 1027).
- ManySmallRespecifyAndDrawRoundsStayUnderTheStoreCountCeiling asserts the wlivepk gauge is a reading before bounding it, as the other two cases already did: an absent gauge read -1 and passed the one-sided bound as a leak that never happened.
- Registered on the Split and Spawn Reclaim entries beside the three; spawn_lane_parity.py's MAGMA_NOT_ON_TCP names the fixture by prefix, so nothing there moves.
- M2's defer path destroys a wire store MID-FRAME (the serial-0 park, the sweep's prefix walk, the watermark); before it a wire store only died at the boundary that also clears UniformManager's memos (BeginFrame from Present or a drain). Those memos are keyed on the VkBuffer HANDLE - m_descriptorReuseMemo hashes the VkDescriptorBufferInfo words, FastRebindMemo compares uboBuffer - and on the wire arm the direct-bind UBO and the SSBO descriptor name the store's own handle. A store destroyed at a park, its handle value re-minted by the next Create (a heap pointer under lavapipe, LIFO out of glibc's tcache), and a draw resolving the same (handle, range) reused a set whose descriptor still pointed at the dead store's memory: silent wrong bytes, no Fatal (ID-P7-43, the fable review's must-fix).
- VkBufferManager gains m_wireStoreDestroyEpoch, bumped on every path that destroys a wire store's VkBuffer (DeferWireRelease's immediate destroy, SweepDeferredWireReleases once per sweep that freed anything, DestroyAllDeferredWireReleases, Shutdown's record teardown) and never reset, with GetWireStoreDestroyEpoch(). UniformManager mixes it into the descriptor-reuse signature and records it in FastRebindMemo, refusing the fast rebind when it moved; all under MOBILEGL_BUILD_DISAGGREGATED. Deliberately not m_sliceEpochCounter, which every WriteWireBuffer bumps and would defeat the memo on every glBufferSubData. On the wire arm only the signature is reached (SetupWireDraw passes no sampler hint), the FastRebindMemo field is the defensive half.
- Two comments from the same review: SweepDeferredWireReleases no longer claims GetSyncPointSubmitIndex() never decreases - it steps back by one across an abandoned recording (RecreateSwapchain, the minimized Present), and the prefix walk is conservative across that, not wrong; and the header's "floor is not a proof" keeps its decision on its first reason alone, the second was a stale citation of a fix that is on the integration tree.
- Section 9 partition exception for UniformManager.{h,cpp} granted by the integrator for this fix.
- docs/Disaggregated/notes/p7/x2-caps-flake.md: the failure text (Fatal{ProtocolCorruption,
"StreamLink"} invalid data frame header, SIGABRT), the mechanism (ConnectTo/AcceptPair pairs
two loopback TCP connections by accept order and the kernel reorders them under load: 5 of
3000 in a raw-socket probe), the TEST verdict against the product's DataBind rendezvous, the
before/after loop counts (2/500, 5/500, 3/500 -> 0/500 x3 under the same load) and the gate
numbers (unit 2435/2435, census 79, ratchet 173, G1 .text 0xa52203 and +0/-0 symbols)
- open: AcceptPair/ConnectTo over tcp:// are still callable and still pair by order; refusing
them by name is a product change with its own red-once
- TcpCarriesControlAndIndependentDataWithKeepalive had the same assumption as the
InitialCapsStartup fixture: ConnectTo/AcceptPair over loopback TCP, paired by accept order;
when the kernel reordered the two connects the bytes sent on the client's data socket arrived
on the server's control socket and recv(serverData, MSG_WAITALL) hung until the 60 s kill -
3 of 500 runs under the same load, 0 of 500 afterwards
- the case now opens the control connection, accepts it, then opens the data connection with a
DataBind nonce and decodes it on the accepted side, exactly the shape the product uses on TCP;
it keeps its NODELAY, keepalive and ShareFd-unsupported checks on both control transports and
its independent-data check on the bound data sockets, and asserts that a control-only TCP
transport owns no data descriptor (TakeDataFd == -1)
- the fixture rendezvoused with ConnectTo/AcceptPair over loopback TCP, which pairs the two
connections by the order the listener accepts them; under host load the kernel delivers two
back-to-back loopback connects to the accept queue out of connect order (measured 5 of 3000
with 20 CPU hogs), the peer's control socket became the client's data socket, the first control
frame hit the StreamLink reader and the process died of Fatal{ProtocolCorruption, "StreamLink"}
invalid data frame header - 2 of 200 and 2 of 500 runs of ANullInitialSnapshot..., 5 of 500 of
DelayedTcpSnapshot..., every one in 3 of the last 4 integration gates on the loaded host
- the flake is in the TEST: PH-7 (4) already took by-order pairing out of the product on TCP
("never paired by arrival order"); the fixture now does what ServerMain's ListenerSource does -
ConnectControl is accepted before ConnectDataConnection is opened, and the data connection
names itself with a DataBind nonce that the peer decodes and matches in constant time
- 0 of 500 for both cases under the same load afterwards; AHealthyPeer... shares the fixture and
is covered by the same change; no product bytes touched
- review round 4 nit: the second gate of the flipped multisample resolve case read `!SplitLane::IsSplitLane() && !PeekSplitRuntime().transportResolved`; the marker term is dead - with the marker set and no transport resolved, the fixture's SetUp already skipped through SplitLane::SkipReasonForSplitOnlyAssertions() -> SplitRuntimeSkipReason(), so Ready() is false and the gate is never reached; in every reachable state it equals `!PeekSplitRuntime().transportResolved`, which is what it now says (the SplitLane.h include goes with it), and the comment no longer describes an unreachable branch; behaviour unchanged - monolith entries still Skipped with the same text, the Full entries still Passed
- review round 4 nit: magma-b2.md §6 items 1, 2, 3 cited WireFramebuffer.inc by line numbers that round 3 had already moved; they now name the arm inside BlitWireFramebuffers's `blit` lambda and the MGLOG_E_ONCE / RecordTextureCopyError text, and the monolith cross-reference names VulkanRenderer::BlitFramebuffer's vkCmdResolveImage + vkCmdBlitImage instead of pipe line numbers
- §2.11 records the round: the should-fix (window primed magenta/0.625, the one-shot wrong-surface demonstration and its readings), the two nits, and a round 4 line under §7 with this tree's readings
- test and docs only; no backend source touched (G1 unaffected)
- review round 4 should-fix: round 3 primed the window blue at depth 0.5, the same colour and depth the user framebuffer `resolved.fbo` was primed with and still held after its own declined blit, so a glBindFramebuffer(GL_FRAMEBUFFER, 0) that landed on the user framebuffer, or a default-framebuffer readback that resolved the wrong surface, read the priming it was meant to reject and the leg passed vacuously
- the window is now primed magenta at depth 0.625 - the user framebuffer is blue at 0.5, the source red/green at 0.25/0.75 - and the three probes expect exactly that
- shown once and restored: with the probes pointed at `resolved.fbo` instead of 0, all three arms (`DirectVulkan.{Split,Spawn,Tcp}.MsFlip.`) fail on both aspects at (1, 1): colour reads {0,0,255,255} against {255,0,255,255} ("must not have written its colour at (1, 1)"), depth reads 0.5 against 0.625 ("must have left its depth alone at (1, 1)"); under round 3's priming the same probes on that surface were green
- test only; no backend source touched (G1 unaffected)
- §0: rows 28-31 for this round
- §1.5: the two walk holes the third review found (a log under `#if 0` accepted, an indented
`#else` walked through), the fail-open-for-one-guard rule, and the two new fixtures' readings
on the round-2 audit and on this one; 16/16, tree 53 / 53 / 0; the probe shapes that are not
fixtures
- §4.1: the merged server-log fatal names MOBILEGL_IPC_CONTROL; demo 1 re-run on the new runner
- §4.5: the EVIDENCE empty-pattern guard; the stub's executable bit restored
- §7.2: the B4 sentence gives both numberings (package tree :13404 / :13471, landed tree
:13533 / :13600); two debts recorded - the tcp pid-arm-proof check still runs before the
marker check, and LOG_WINDOW counts the blank and `#if` lines it skips
- §5: the round's gate on the code head; no product file, so G1 is unaffected; lanes not re-run
- every line of EVIDENCE is a grep -F pattern and an empty line matches everything: a trailing
newline or a blank line between the two sentences would turn hole 2 into "any red will do".
The smoke's "red without the transport-resolution message" case catches that (22/23), but
only when somebody runs it; a `case` after the assignment now refuses a blank line or a
leading/trailing newline with ::error:: and rc 1, before the selection is counted or the
library is swapped. The red-check's perturbation line is untouched; smoke 23/23
- scripts/ci/testdata/stub_ctest.sh went 100755 -> 100644 in the round-2 commit that added
its retrace-evidence-nolog mode (a Windows-mount artefact); restored with
`git update-index --chmod=+x`. The smoke copies it and chmods the copy, so nothing ran
differently
- the round-2 merge folded "TCP session has no forwarded server log" into the one server-log
check for every transport, and its message named only MOBILEGL_TRANSPORT - which is `spawn`
for the tcp arm too (tcp = spawn + MOBILEGL_IPC_CONTROL=tcp://), so the tcp arm's missing
forwarded log read as a plain spawn red
- the message carries MOBILEGL_IPC_CONTROL='...' after the transport (empty for inproc and
plain spawn); message only, no check moved
- stepping over `#endif` accepted a log that is compiled out: `#if 0` / `MGLOG_W(...)` /
`#endif` / `Count(...)` read as logged, and so did a log under `#ifdef
MOBILEGL_VERBOSE_DECLINES` - a Count that counts every time and logs in no shipped build
- the walk now counts the `#endif`s it crosses; a same-indent log met with one outstanding
counts only if every opener enclosing it, reached by continuing upward, is the whole-line
`#if MOBILEGL_BUILD_DISAGGREGATED` (`&& X` is not it). `#if`/`#ifdef`/`#ifndef` with no
`#endif` outstanding is the Count's own guard and is still stepped over, so the column-0
`#if/#endif` fixture stays green; the comment says the walk is fail-open for that one
guard and nothing else
- `#else`/`#elif` stopped the walk only at column 0, as a line of lesser indentation; one at
the Count's own indentation walked straight through to the log above it. PP_STOP now
returns False at any indentation, before the indent test
- two red fixtures, both green on the round-2 audit: "log only under a #if 0 above the
Count" and "log only on the other side of an INDENTED #else at the Count's indentation";
16/16, the tree stays 53 / 53 / 0
- ph-f.md: §0 rows F-a..F-d with package-tree SHAs; §2.2 states the asymmetry (new server + old client -> Refuse{WireFingerprint}; old server + new client -> the old AcceptPair closes both after 2000 ms and the client sees `no Welcome ... (rc=6)`, no Refuse frame); §6 the four fix commits with their red-once lines; §6.4 the unix arrival-order pairing (250 ms / 2000 ms) as a local-peer debt with the SELinux assumption named, and the two pre-auth exposures; §7 the gates on 2b2a0ae0
- CONTRACT-P7 §12: the two pre-auth exposures (fork before Hello with a 10 s wait; ValidatePeerHandshake before AuthenticatePeerToken sends the fingerprint and build stamp to an unauthenticated peer), the unix pairing debt, and the fingerprint asymmetry, for PH-7 (5)
- PLAN-PH-P34B-P7: PH-7 (3) says the minimum is checked at the TCP listen; PH-8 says "ignored, server-sized" rather than "clamped"; the no-token listen row's remaining-work cell no longer claims the smoke is unreferenced
- device-window-1-runbook §2 step 3: the un-redeployed-phone symptom, and where to look first
- ROADMAP §6 and peer-loss-probe: `MOBILEGL_IPC_TOKEN=devtoken` (8 bytes, which now exits 72) becomes a 25-byte token in every example
- AuthToken.h: ConstantTimeTokenMatch loops kAuthTokenCompareBytes (64) iterations with both operands read as zero past their ends - its trip count no longer tells a peer the configured token's length; a token longer than 64 is still compared to its last byte (SocketTransportTest pins the 70-byte case)
- ServerSession.cpp: an ask in Hello.linkTerms above the granted terms logs one MGLOG_W naming both sides ("the ask is ignored and the session is server-sized"); the production client asks for nothing, so a session that was never mis-configured never logs it; SessionHandshakeTest's 64 GiB case requires the line (red before: absent)
- ServerSession.cpp: a MintNonce failure sends Refuse{LinkTerms} "stream data plane unavailable: the server could not mint a data nonce" before returning the fault it is (still exit 67, still counted) instead of a bare close
- ServerMain.cpp: a first frame with the wrong magic or over 1 MiB while a session is live is Refuse{MalformedHello} "first frame is not a control frame", drained before the close so the refusal is not RST away; Busy stays the answer for a well-formed frame that is not a DataBind
- tcp_supervisor_smoke.py: garbage_first_frame_while_busy (magic XXXX on a live session -> code 8; red before: code 7)
- fatal census unchanged: 79 abort sites, 3 refusal words (the new refusals go through the enum, not a literal)
- the revision is a hand-bumped integer mixed into wireFingerprint and nothing tied it to protocol.fbs's content; scripts/ci/protocol_revision_pin.py holds {revision -> sha256(protocol.fbs)} in protocol_revision_pins.json and refuses a schema whose digest is not the current revision's, or a revision with no row
- registered as ProtocolSchema.RevisionPinsDigest (label unit, MG_Test/Wire/CMakeLists.txt, --self-test proves both perturbations red before it says green) and as a step of CI's flatc-check job, the one that regenerates the header on every schema edit
- --write records a bump's row and refuses to re-pin an existing revision to a different digest, which is what a schema edit without a bump looks like
- mg_protocol_base.h says where the pin lives beside the revision it pins
- red-once: protocol.fbs touched with one trailing comment, revision left at 1 -> "protocol.fbs has sha256 a7b5c7fb... but MOBILEGL_PROTOCOL_CONTROL_REVISION is still 1, which is pinned to 7fc859f7..." rc=1; restored, green
- ServerMain.cpp: RunSession takes the descriptor its data-connection source reads from (the child's hand-off end under --serve, the listener itself without it) and closes it the moment Accept returns; the source object goes with it
- the supervisor's hand-off send is MSG_DONTWAIT (FdPassing::SendFd dontWait), so a peer that stopped reading answers at once; "peer gone" on a datagram pair (ECONNREFUSED, ENOTCONN) is an answer the caller names, not a logged fault
- before: every DataBind after the bind sat in the child's receive queue with no reply, its TCP connection held open until the session ended, and past the queue limit (net.unix.max_dgram_qlen 10 on the phone; ~288 datagrams of sender buffer on this host) the supervisor blocked in sendmsg for the rest of the session - no accepts, no Busy, no reaping
- before, without --serve: the listener stayed open after the data connection bound, so a second client or a retry completed a TCP handshake nobody would accept and waited out its 20 s connect budget instead of the ECONNREFUSED it got before PH-7 (4)
- tcp_supervisor_smoke.py: data_bind_after_bound (12 extra DataBinds on a bound session each get Refuse{Authentication} "data connection could not be handed to the live session", and a fresh control connection is still Busy) and single_session_listener (a connect after `ready` is ECONNREFUSED); connect_control retries ECONNREFUSED at startup for pair() and held_session() alike
- red-once at da313010: smoke TimeoutError on the first extra's receive; probe with qlen+2 fire-and-forget DataBinds: 288 queued silently, then the supervisor stopped accepting (fresh control: connect timed out) while still alive; single-session connect after ready succeeded
Before/after on host with M1's sampler and probe for bsl-esc-menu-854 and iris-bsl-in-world on
monolith / inproc / spawn (pixels identical on every arm), the mechanism with citations, the
knob's semantics, the red-once table, the gates, and what remains: the wire arm's descriptor-set
growth between BeginFrame rewinds (27,377 live sets == 28,134 maps; now bounded only because
M2's forced syncs let the drain rewind the cursors), CollectGarbage/pruneWire never reached on
the wire arm (B4), the frame-serial proof deferred until B3's floor fix, the tcp arm's gauge
reach, the ServerSpawn.cpp touch and the knob for the integrator to ratify, and the Redmi
acceptance run.
Measured on bsl-esc-menu-854 (spawn, lavapipe) with the byte watermark alone: the dead VkBuffers
at the end fell from 25,935 to 32, but one stretch with no submission still parked 12,498 stores
in 39.5 MB - under 64 MiB, so nothing forced a sync. A fixed ceiling
(VkBufferManager::kWireDeferredCountCeiling = 1024, not a knob) now rides the same
WaitForSubmitIndex call; MOBILEGL_IPC_WIRE_DEFERRED_MB=0 still turns both triggers off.
MagmaWireReclaimScenario gains the case: 3000 respecify-and-draw rounds of a 256-byte store in one
frame (750 KB, far below the lane's 8 MiB). Red before this commit on inproc and spawn (live peak
3001 vs a bound of 1027), green after (1025, two forced syncs).
Rule J's red-once for M2. The server role's VkBufferManager publishes four PipeStats gauges
(push builds only, so the pull image is untouched): wire buffer records alive, and the run
maxima of live wire VkBuffers and of parked bytes, plus the watermark's forced-sync count -
maxima because the defect lived inside one frame and a swap-time sample reads after the frame
boundary's own sweep. Two cases, each inside a single frame: 1024 undrawn respecifies of one
4 KiB buffer (live peak <= 4 x wire buffers), and 48 respecify-and-draw rounds of a 1 MiB store
under the lane's MOBILEGL_IPC_WIRE_DEFERRED_MB=8 (parked peak <= budget + one store, every strip
its own colour). Red at base (ec46a550's defer path, counted): 1025 vs 4 live, 49,287,168 vs
9,437,184 bytes and 49 vs 11 live, on both arms; green after: 1 live, 9,437,184 bytes / 9 live
with 5 forced syncs. The knob's negative control (=0) reds the second case with the same
49,287,168 bytes. Inproc and spawn only: the tcp server is the lane-wide fixture supervisor,
whose environment and log the entry cannot reach - named in spawn_lane_parity.py as
MAGMA_NOT_ON_TCP (the ID-P7-14 shape), and PipeStatsTest pins the four short names.
BuildChildEnv drops every MOBILEGL_IPC_* entry, so the server-owned watermark meant something
under inproc and silently nothing under spawn - measured: the spawn server parked 47 MiB against
a lane budget of 8 with zero forced syncs, because it had parsed the default out of an
environment that no longer carried the variable. The knob names no endpoint, role or segment
size, so neither anti-recursion catch (Dial=No, ServerMain's scrub check of TRANSPORT /
SERVER_PATH / RING_MB / STAGE_MB) is weakened; it is listed by name, not by widening the prefix.
The defer-path sweep frees an orphan once its last submission has retired, but inside one long
frame a store that was drawn from is named by recorded, unsubmitted work, and nothing submits it
until the frame ends. The knob is read once through MG_Config::Ipc like its siblings; above it
the park calls WaitForSubmitIndex(GetSyncPointSubmitIndex(), flush) - the call
WaitForWireBufferHostAccess makes - and sweeps again, which empties the list because no entry can
be tagged past the sync point just waited out. A sync point that cannot complete dies through
the existing Fatal{ResourceUnavailable, "buffer-write-sync"} site, now one helper shared with the
host write (fatal_census: 79 abort sites, unchanged). 0 is the negative control.
RespecifyWireBuffer (every wire glBufferData) and DestroyWireBuffer parked the old VkBuffer in
the per-frame-slot buckets, which only BeginFrame and the idle drain empty. A snapshot-exiting
pbuffer replay delivers one present per run (ID-P7-32), so on bsl-esc-menu-854 the split server
held 25,923 dead stores. The wire arm now keeps its own flat list: a store whose last-use serial
is 0 (never named by a GPU command) is destroyed at the park; any other is tagged with the
renderer's sync-point submit index and destroyed by the sweep every park runs, once that index
has completed or the renderer is idle. The frame-serial floor is deliberately not a proof here
(it cannot move mid-frame, and on this base it is the floor B3 found unsound). The transient
arena's releases and the monolith OnRespecify path are unchanged.
- §2.10 (new): the skip gate is the process's resolved transport, with the Skipped/Passed lines as measured (monolith + DirectGLES Skipped, Split/Spawn/Tcp.Full Passed); why VerifySplit. cannot be measured on this branch (a -DMOBILEGL_PIPE_VERIFY=ON build of this tree aborts every split entry's bring-up in MGPipeApplyResourceRespecify on the apply thread - the verify+split debt the verify-split package relaxes) and its measurement on a throwaway detached worktree of the landed head plus commit a (Passed); the default draw framebuffer joining the pre-pass, with the red-once (round-2 guard restored -> Split/Spawn MsFlip. read the source's red/green in the window); the two wording fixes.
- §2.9 items 1 and 3 and §6 items 1 and 5: the entries that now RUN the case (Full. x3, VerifySplit. on the landed tree), the corrected default-draw sentence, and "a declined call writes nothing" scoped to SHAPE declines - the capability decline in ResolveWireDepthStencil is still per aspect, rule I (a).
- VulkanRenderer.h: the DeclineWireDepthStencilResolveShape comment says the same (comment only).
- §6 item 4 and §7: seven server-env-knob tails on the landed tree, naming .StaleSerial. (this branch predates B3 and measures six).
- §6 items 8-10 (recorded, not fixed): a legal same-size MSAA-FBO -> default-framebuffer depth/stencil blit is a session Fatal (multisample-depth-resolve-region); a scissor that empties the destination returns silently even for shapes GL still errors on; MS->MS blits decide per aspect, so a COLOR|DEPTH call can copy colour and decline depth.
- §7 is one measured row for this tree (p7/magma-b2@1bf48cfb + this commit's comment, remeasured on the final head) and names the landed tree as the integrator's gate, without numbers or SHAs.
- BlitWireFramebuffers' round-2 pre-pass left a default DRAW framebuffer to the per-aspect lambdas on the belief that a default draw behind a multisample read was ResolveWireDepthStencil's region Fatal whatever the shape. It is not: that arm calls DeclineWireDepthStencilResolveShape BEFORE its destination.isDefault region check, so a scaled (or X-mirrored) COLOR|DEPTH blit out of a multisample framebuffer onto the window ran the colour lambda first (BlitWireColorToDefault wrote the swapchain image) and then recorded INVALID_OPERATION on its depth - the partial effect magma-b2.md §2.9 item 3 declared closed.
- The guard drops !draw->IsDefault, and the pre-pass resolves both sides as READS (isWriteTarget=false): only their format and samples are consulted, and DefaultFramebufferReadIndex is const, so nothing re-points the default framebuffer at the acquired image before a write.
- The flipped-resolve case gains a leg: the window is primed blue at depth 0.5, the multisample source (red below, green above) is blown up over the whole of it with COLOR|DEPTH, the call must be INVALID_OPERATION, and three probes (corner, centre, far corner) must read the priming on both aspects through glReadPixels - the only way the harness's pbuffer default framebuffer can be looked at.
- Red-once (split + spawn, restored): the round-2 guard (&& !draw->IsDefault) put back -> DirectVulkan.{Split,Spawn}.MsFlip. fail "the declined COLOR|DEPTH scale onto the window must not have written its colour at (1, 1)" reading {255,0,0,255} (the source's bottom band), and {0,255,0,255} at (64, 48) and (126, 94), for {0,0,255,255}; the INVALID_OPERATION assertion still held, which is the partial effect exactly.
- Verified: the 107 multisample blit/resolve and depth/stencil readback entries on the three wire arms (MsFlip/MsFlip1/MsBlit/MsResolve0/1, Full x3 for both readback scenarios) and the monolith DirectVulkan.DepthStencilReadback* entries pass, pre-existing skips unchanged.
- MGITEST_SPLIT_LANE marks the CURATED split lanes, not the transport: DirectVulkan.{Split,Spawn,Tcp}.Full. (and the VerifySplit. lane where it is registered) run inproc/spawn/tcp WITHOUT it on purpose, and the round-2 marker gate skipped the case there with a message about a monolith arm that was not running - before round 2 it ran and passed on them.
- The gate is now the process's resolved transport (PeekSplitRuntime().transportResolved, i.e. MG_Config::Transport != Monolith), which is the very fork VulkanRenderer::BlitFramebuffer takes to reach BlitWireFramebuffers; the marker on its own still keeps a curated lane running the case, and its fixture's arming assertion says whether that lane got the transport it asked for.
- Verified on build-split: DirectVulkan.DepthStencilReadbackMatrixScenario.AFlipped… still Skipped ("the monolith DirectVulkan transport … refuses a flipped depth blit …"); DirectGLES.{,ForcedDepthStencilEmulation.}…AFlipped… still Skipped (Espryt reason); DirectVulkan.{Split,Spawn,Tcp}.Full.…AFlipped… Passed (all three were Skipped before); DirectVulkan.{Split,Spawn}.{MsFlip,MsFlip1}. and Tcp.MsFlip. Passed.
- the first class said its 22 names were "the same 13 entries per backend that tier 3 measured as reds", which is wrong twice: 13 was the whole count, not a per-backend one, and those 13 (AdvertisedLimitsScenario x8, SpirvShaderBinaryScenario x4, ProgramPipelineScenario's reserved name) are spread across this class and the next
- the sentence now says what tier 3 actually measured and why it is the same property seen from the other side
- integration-verify goes 1136 to 1138 (273 skip): the new CorruptedFieldIsReportedOnTheServerRead case is discovered by the ambient Verify. registration too, where it skips for want of the CORRUPT knob - the same shape CorruptedFieldIsReported already has there
- integration-verify-split is 1072/1072 with 117 skip, re-measured on the final head
- 0 gains a row for the fix round, one line per finding, pointing at the section each landed in
- 2.2 and the hook's own comment both said the comparator can no longer see a client that pushed a WRONG pack state on the server's ReadPixels. Too broad: PipeApplier.cpp reads savedPack THROUGH THE ACCESSOR, and therefore through the hook, BEFORE it installs the neutral value, so the pushed pack is compared against the live one right there
- a correct push is equal and returns; a wrong push that is not exactly the neutral pack fails both compares and is still Fatal; only a wrong push that equals {SwapBytes 0, LSBFirst 0, RowLength 0, ImageHeight 0, Skip* 0, Alignment 1} is invisible, and only while the application's own pack differs from it
- notes/p7/verify-split.md 2.2 and 6 say so, and the comment above ServerReadsInsideTheNeutralPackWindow says so
- the measured evidence is the read-side control's own server half: 2 to 3 where=read reports per readback, which is the savedPack read plus the backend read
- 6 opened with "verify is registered on monolith only", which stopped being true when wave 3 landed the split arm, and cited CMakeLists.txt:1684-1760 for a block that starts around :1715; it now reads as the gate's STARTING POINT, cites the guard by name rather than a stale range, and names the block that registers the split arm
- 8's (7) marker said "done" flat, a sentence away from the midpoint tally that lists it as not done; it is now dated to wave 3 V1 and says the two sentences are about different moments
- no SHA citation touched
- the verify-split block and notes/p7/verify-split.md 3.3 both said the spawn server runs no compare "because MG_Impl is not in it", which is false and sent a reader looking for a link-time gap: MobileGLServer is an executable over ServerEntry.cpp linked against the WHOLE library (top-level CMakeLists.txt), PipeFill.cpp included, so MGPipeVerifyReadHook is present and is called on every backend read
- the true mechanism: the server image's main reaches MG_Backend::InitServerRoleForSpawn() and never MG_State::Init(), so MG_State::pGLContext is null and the hook returns at its ctx == nullptr line before comparing anything
- the inproc-only conclusion is unchanged; what changes is the shape of the fix, which is a cross-process compare rather than a link edit
- CONTRACT-P7 12 records that debt by name, and the note's 6 cites it
- both negative-control steps re-run 12 ambient VerifySplit. entries with a knob exported, and an ambient entry writes to its own per-entry path under verify-split-logs/ which the library opens "w" - so the upload at the end of the job collected twelve CONTROL logs sitting under the LANE's names
- if: always() made it worse rather than better: a failure investigation is exactly when the overwritten half is the half somebody needs
- the arm proof is now followed by a cp -a to verify-split-logs-lane/ (plus the lane's JUnit), and the upload takes both trees - the green run the arm proof read, and whatever the controls left behind
- the landing shape was a count - at least 850 armed of at least 1000 client logs - which leaves the remaining 155 unnamed: the lane could lose fifty arms, gain fifty skips, and the floor would not move
- the rule is now per entry and two-sided, the one dualblock-expected-fatals.txt already rides: each client log must carry the arming line, or belong to an entry ctest reported SKIPPED, or be named in the new Harness/verify-split-unarmed-expected.txt
- a gtest skip is the recognised no-verb marker and is NOT named, because the skip set moves with the driver - naming it would ratchet on lavapipe rather than on the comparator
- both other directions fail: a listed entry that armed, and a listed name with no client log in this run
- split_log_paths.py gains the verify-split-arming mode; the lane step gains --output-junit so a skip can be told from a pass; test_split_log_paths.py gains six controls covering the four ways the census can be wrong
- measured on the host over 1064 private client logs: 909 armed, 99 unarmed and skipped, 56 unarmed and named in five classes - caps/limits/extension advertisement 22, shader and program object surfaces 16, object state written and read back or a call rejected before it becomes a verb 14, framebuffer completeness queries 2 (DirectGLES only; DirectVulkan skips both), the poison parent that only forks 2
- ReadChildLog concatenated the child's two role halves and the assertion searched the union, so VerifySplitPoisonOmitted. would have been just as green if the stamp the knob withheld had been the CLIENT's - which is the case it exists to rule out, the mechanism under test being the server-side verb stamp honouring MOBILEGL_PIPE_POISON_OMIT
- kOmittedPairs carries FiresOnTheServer; ReadChildLogHalves keeps Client, Server and the union apart; the split pair now asserts the Fatal in the child's SERVER half under MOBILEGL_BUILD_DISAGGREGATED, the monolith pair keeps the union because it has one role
- measured on both backends: client half 0 Fatal{UnmigratedPipeInput lines, server half 1 each - the union search could not tell them apart
- monolith PoisonOmitted. entries stay green (2/2), split VerifySplitPoisonOmitted. stay green (2/2)
- MGPipeVerifyReadHook honours MOBILEGL_PIPE_VERIFY_CORRUPT: it perturbs the ORACLE after CopyField, and AGAIN after the ID-49 neutral-pack overwrite, because that overwrite replaces the pack half wholesale and CorruptStorage perturbs an array's element 0 - which is the pack half
- until now the knob only ever reached EntryCompare, so every red it could produce was the client thread's where=entry and the hook that runs on the server's apply thread at every backend read had no falsifier at all
- new entry per backend, DirectGLES/DirectVulkan.VerifySplitReadCorrupted., with CORRUPT=GetPixelStoreParameters and VERIFY_FATAL=0 - the field is in the kReadback fill mask, so the client entry compare would otherwise abort the process before the server reached the read
- the case reads the SERVER role's half of its private log alone: with the knob armed a union search over both halves would be satisfied by the arm that is not under test
- PipeStatsWindow.h gains ReadServerLogSince for that
- test.yml grows the matching control step - run the two entries, then re-read both server halves from outside for the where=read report
- measured: 2/2 green, server halves 3 and 2 lines, client halves 0 lines; revert-and-run with the two corruption blocks removed gives 2/2 red and both server halves 0 lines
- the lane goes 1070 to 1072
- MG_Pipe/MGPipeTypes.h gains inline MGPipeNeutralReadPixelsPack() next to MGPPixelPackState, whose owner it is; the applier that installs the neutral state and the comparator whose oracle it is now call the same function
- MG_Remote/Server/PipeApplier.cpp read_pixels takes the constant from there instead of building it field by field
- MG_Impl/Pipe/PipeFill.cpp drops ServerReadPixelsNeutralPack, the re-typed second copy: a drift between two hand-typed spellings costs a FALSE DIVERGENCE on the server read, not a build break, which is the direction that fails quietly
- notes/p7/verify-split.md 2.2 says one spelling and why the old reasoning (do not reach into MG_Remote/Server from MG_Impl) did not survive the failure-direction argument
- §0: the previous round's note commit gets its own row (20), and rows 21-27 for this round
- §1.5: the three audit holes the second review found and the fixtures that pin them, with
each new fixture's reading on the previous audit and on this one (three false greens, two
false reds, one that stays red on purpose); 14/14, tree 53 / 53 / 0
- §4.1: the server-log fatal now sits after the marker and ARMED checks and covers inproc;
the four fail-closed demonstrations with the fatal at its new position, and the case where
the old order reported the server-log message for a launch-failure shape
- §4.5 (new): the pull-library control had refused its own red since P6's per-role log rename
- read from Log.h and the runner, then measured with the real ctest - and the one-place fix
- §7.2: the landed-tree line of the stale WaitForFrameSerial comment corrected; two debts
recorded: the census sits after the replay's exit-code fatal, and B4's restart must not
read `record` after OnSubmitsCompletedUpTo erases it
- §5: the round's gate readings on the code head
- WireDeclineTally.h cited `MGL_WIRE_DECLINE_FALSE`, which does not exist; the statement pair
a site actually writes is `MGL_WIRE_DECLINE_AT(Site, "why", ...); return false;`, and the
bare-Count sites stand under a W/E of their own, which the audit holds them to
- WireDeclines.def's banner said "EVERY WAY A MAGMA WIRE DRAW CAN DECLINE", contradicted by
the header's "a decline is not always a skipped draw" paragraph: the Tex*/Shape*/Upload*
rows count per texture sync, reached from the descriptor resolver, attachments,
CopyTexSubImage, mipmap generation, CopyImageSubData and readback as well as from a draw.
The banner now says the wire arm, and carries the per-sync sentence with a pointer to the
header
- both files are disaggregated-only (the def is included only from the guarded header), and
the edits are comments: the pull build's .text and symbol set are unchanged (G1)
- retrace_pull_library_control.sh required run_trace_case.cmake's "never reported resolving
it", the marker-search sentence. Since P6's one-log-per-role rename a pull library never
reaches that search: it has one role and writes MOBILEGL_LOG_FILE_PATH unchanged
(output/mobilegl.log, Log.h's pull arm of RoleLogPath), the runner reads
output/mobilegl.client.log and stops one check earlier with "the run wrote no <client log>,
so there is no evidence the transport ever resolved" - so the control's hole-2 check
refused its own red and the step exited 1 on every pull-library run
- measured on this tree with the real ctest, build-linux's pull library over build-split's
frozen split library, SPLIT_TRANSPORT=inproc: control rc 1, "::error::the split retrace
went red (ctest exit 8) ... never says the library did not resolve the transport", the
runner's fatal at the no-client-log check. With this change the same run passes for its
own reason on both inproc and spawn
- EVIDENCE carries both sentences as grep -F's newline-separated patterns, so the evidence
line the red-check perturbs is unchanged; both are the transport-identity assertion
refusing the same library for the same reason, and neither is a loader failure, a missing
fixture, a timeout or an SSIM drop
- stub_ctest.sh gains retrace-evidence-nolog, the no-client-log fatal CMake-wrapped across
lines the way the real one arrives; control_smoke_test.sh expects it to PASS (23/23); the
red-check still reds "red without the transport-resolution message"
- the spawn server-log fatal from the previous round sat BEFORE the marker check and the
`spawn ARMED ... pid` check, so a launch or handshake failure - no server process, no ARMED
line - reported "the run wrote no server log" instead of the more specific "resolved, but
carries no spawn ARMED", and sent the reader to the wrong process. It now sits just before
the census; the tcp arm's "no forwarded server log" check, which had the same placement,
moves with it
- the inproc row demands the server log too. Under inproc every server-role line is routed
by thread role to the `.server.log` sink, opened lazily on the first such line, and at the
INFO level this lane demands ServerLoop writes one when mgl-srv-apply starts - so a clean
inproc run always has the file, and "no file" read as "0 lines" was a green Fatal{ and
MGWIRE-FLOOR census of the role that emits both. The two census reads are unconditional
now: the fatal above them is what makes the file exist
- fail-closed demonstrations, OpenRA DirectVulkan, the replay wrapped to damage one log after
a real run: inproc with the server log deleted went rc 0 on the previous runner (both
censuses printed 0) and is now a fatal at the census; spawn with the server log deleted is
the same fatal; spawn with the server log deleted AND the ARMED line removed reports the
ARMED message, where the previous runner reported the server-log one; spawn with only the
ARMED line removed reports the ARMED message. Undamaged, OpenRA DirectVulkan and DirectGLES
SPLIT and SPAWN stay green, 0 unsound lines
- a same-indent `case`/`default:`/`break;`/`return`/`continue;`/`goto`/`throw` line between a
log and a bare Count ends the walk: control cannot flow from that log to the Count, so it
is not the Count's log. A `case 1: MGLOG_W; break; case 2: Count` switch with its labels at
statement indentation, and a log above a `return` with the Count under a same-indent label,
both read as logged before
- `#if`/`#ifdef`/`#ifndef`/`#endif` lines are passed over: a log and its Count that straddle a
`#if MOBILEGL_BUILD_DISAGGREGATED` are one block on both sides of the preprocessor, and the
column-0 line ended the walk for no defect. `#else`/`#elif` still end it - a log on the other
side of one is in a different build, not this one
- four fixtures: same-indent return (red), same-indent switch (red), log across a #else (red,
and red on the previous audit too - it pins that #else still stops), Count under its log
across #if/#endif (green; a false red before). 14/14 fixtures; the tree stays 53 / 53 / 0
- L'"', u'"', U'"' and u8'"': the `'` after an identifier character was taken for a C++14
digit separator, the `"` inside it opened a phantom string, and the rest of the line was
blanked - a site on that line vanished and its row read as NO SITE (fail-closed: a false red)
- CHAR_PREFIX accepts a u8/u/U/L prefix before `'` the way RAW_PREFIX does before `"`, with
a non-identifier character (or the start of the text) in front of it, so a real separator
(0xAB'CD, 1'000) is untouched
- self-test fixture with L'"' and u8'"' ahead of two AT sites on their own lines: rc 0; it
was rc 1 on the previous audit. The tree has no prefixed character literal today (53 / 53 / 0)
- the wire declines audit's LOGGED pattern was unanchored, so a guarded one-liner at the
Count's indentation (`if (g) MGLOG_W("x");`, `g ? MGLOG_W("x") : (void)0;`) counted as
the site's own log and the site read as logged - a decline counted every time but logged
only sometimes, which is the unlogged site the check exists to refuse
- anchored to the statement start; the block walk matches the line, not searches it
- self-test fixture "guarded one-liner log at the Count's indentation" (both shapes) reds
with the NO_LOG sentence; it was green on the previous audit. The tree stays 53 / 53 / 0
- the two ROADMAP row segments E1 changed that did not merge onto the integrator's rows
are re-applied by hand (host census results, P9 exception list empty, wave 3 items done)
- the running table names the fable rounds B2 r3 and B3 fix r2 and the F review
- the census and §7.2 pages were written against 1135c664 before the trailer rewrite; the
citations now name its rewrite 3c80cd62 and the other remapped commits
- the trailer rewrite the user asked for is recorded (origin 1135c664 -> 3c80cd62, same trees)
- CONTRACT-P7 §12 transcribes the debt lines from magma-b2.md §6, magma-b3.md §7.2,
verify-split.md §6 and ph-f.md §4
- the status page's gate table, landed table, running table and next steps follow
- 144 commits from 785fed0b0 onward were rewritten to drop the Co-Authored-By trailer the
project forbids; origin/feat/disaggregated moved from 1135c664 to 3c80cd62 (same trees)
- the old->new map is ~/w7/logs/sha-map-20260922.txt; every 7..40-hex citation in the
Disaggregated docs and the MG_Remote contracts that matched an old SHA is replaced by the
same-length prefix of its rewrite; no other content changes
The fix round's commits changed what §1.5 says the audit checks and what
§4.1 says the runner reads, so the note is brought up to the tree:
- §0: rows 11-19 for the fix round.
- §1.5: the three holes (a site in a comment or string, the branch-blind
8-line window, a .def row with a trailing comment), what the audit does
now, and the eight --self-test fixtures run first in the CI step.
- §4.1: the spawn server-log fail-closed and its demonstration (runner at
aab01275 rc 0 on a run whose server log was removed; this runner rc 1,
the CMake Error quoted). inproc also writes mobilegl.server.log and is
still read if(EXISTS); only spawn was tightened, as the review asked.
- §5: the fix round's gate on package tree 88d1a913, pre-merge: split
build rc 0; G1 rc 0 .text 0xa52203 nm 0/0; census 79; audit self-test
8/8, tree 53/53/0; ratchet unchanged at 186; parity rc 0; unit
2420/2420, integration-magma-split 90/90, integration-magma-spawn 69/69;
OpenRA DirectVulkan SPLIT / SPAWN ssim 1.000000, 0 px, 0 MGWIRE-FLOOR.
Fable review of the B3 rework, note for CONTRACT-P7 §12. On the
disaggregated build VulkanRenderer::WaitForFrameSerial waits the FIRST
in-flight record with frameSerial >= serial, calls OnSubmitsCompletedUpTo
and returns true. With the §3.1 clamp, a later record carrying the same
serial (S2, the Present submission - the two-submissions-per-serial shape
of §2.2) keeps the floor below `serial`, so the function returns true
while IsFrameSerialComplete(serial) is false. The only caller,
WaitForTimerQueryResult, re-checks IsTimerQueryResultReady, so today it
degrades to a spurious timeout, not a wrong read.
The fix belongs to B4 and is not made here: after OnSubmitsCompletedUpTo,
return only if IsFrameSerialComplete(serial), else restart the scan (the
call erases from the vector being iterated, so restart, never continue).
Fable review of the B3 rework, nit: §7.2 cited the comment as
VulkanRenderer.cpp:13400-13403, which is the package tree's line; on the
landed tree pipe@6207c9c9 it is 13530-13533. The bullet now names the
comment by where it sits (above the fence-wait loop in
VulkanRenderer::WaitForFrameSerial) and gives both lines, the function name
being the reference.
Fable review of the B3 rework, should-fix: §5's table and prose (unit 2420,
integration-split 226, magma 90/69/70, full 527, ratchet 186) were read on
the package tree before the merge, but read as if they described what
landed; and the quoted parity output ("1 entry not registered on the tcp
arm by name") is the package script's, which did not land.
- The initial gate is labelled package tree 0d425499, pre-merge; the
review-round gate package tree 774bc17f, pre-merge.
- A landed-tree column/paragraph from the integrator's gate on
pipe@6207c9c9: unit 2429 / integration-split 304 /
integration-magma-{split,spawn,tcp} 96/75/71 / full 535; G1 rc 0 .text
0xa52203 nm 0/0; census 79; ratchet 173; parity rc 0; audit 53 rows 53
sited 0 unlogged; OpenRA DirectVulkan inproc + spawn retrace ssim
1.000000, 0 px, 0 unsound-serial-complete lines. The landed lane counts
include other packages' entries.
- The parity quote is replaced by the landed wording ("N server-env-knob
entrie(s) excluded from the TCP comparison only"), and §4.2 / §5 name
the landed list MAGMA_SERVER_ENV_KNOB_NO_TCP (compare_arms(no_tcp=),
CMake mglItestServerEnvKnobArm), into which the integrator folded
MAGMA_NOT_OVER_TCP.
Fable review of the B3 rework, nit: MGL_WIRE_DECLINE_AT logged "Magma wire
draw declined [Site]" from every site, but the Tex*, Shape* and Upload*
rows are reached through SyncTextureResourceByHandle, whose callers are not
only the draw/dispatch texture preparation: the descriptor resolver,
framebuffer attachments, CopyTexSubImage, mipmap generation and
CopyImageSubData each Fatal right after a failed sync, and texture
readback falls back to the unbacked shadow. Those counters are per sync,
not per draw - a draw sampling three unsyncable textures counts three, a
failed attachment sync counts once and is then a Fatal.
- WireDeclineTally.h: the line is "Magma wire decline [Site]: ...", and
the macro's comment says the texture rows are per sync and names the
callers. Disaggregated-only header; no test or script matches the old
text (git grep).
- magma-b3.md §1.5: says the counters are per sync and that the log lines
quoted in §1.3 / §1.5 carry the old wording.
Fable review of the B3 rework, nit: the comment over the ShapeImageFlags-
Unsupported site called it "the one Shape* exit that left no line on the
builds that ship". At 0d425499 SyncWireTextureShape had five such exits:
ViewOf (bare return false), NoLevels (bare return false), preserve-flush
(bare return false), image flags (MGLOG_D, compiled away in Release) and
preserve-copy (bare return false). The comment now names all five.
Comment-only, inside the disaggregated-only SyncWireTextureShape; pull
build unaffected.
Fable review of the B3 rework, should-fix: run_trace_case.cmake read the
server log only `if(EXISTS ...)`. Under spawn the server process writes its
own file, and the lines the runner counts out of it - the applier Fatal{
lines and the MGWIRE-FLOOR unsound-serial-complete line, which only the
server's VulkanRenderer::OnSubmitsCompletedUpTo emits - are nowhere else,
so a server that died before its first line read as "0 lines" and went
green. The client log was already fail-closed, and so was the tcp arm's
forwarded server log; plain spawn was not.
The plain-spawn branch of the transport block now FATALs when
mobilegl.server.log does not exist, beside the tcp arm's identical check.
Demonstration: the SPAWN ctest command line with TRACE_REPLAY_EXE replaced
by a wrapper that runs the real replay and then deletes
output/mobilegl.server.log (541 lines):
runner at aab01275 -> rc 0, "Fatal{ lines across ... : 0",
"MGWIRE-FLOOR unsound-serial-complete lines: 0" (green, half unseen)
this runner -> rc 1:
CMake Error at run_trace_case.cmake:321 (message):
OpenRA DirectVulkan: MOBILEGL_TRANSPORT=spawn but the run wrote no
.../output/mobilegl.server.log, so the server role's Fatal{ and
MGWIRE-FLOOR lines cannot be counted. A spawn retrace with no
server log cannot be counted as a clean one.
MobileGLTraceReplay.OpenRA.DirectVulkan.SPLIT / .SPAWN unmodified: both
pass, ssim 1.000000, 0 px, 0 MGWIRE-FLOOR lines.
Fable review of the B3 rework, should-fix: the audit had no negative
control, so a green run could not say it would have been red. It now takes
--self-test, which writes each inline fixture as a one-file renderer tree in
a temp dir, runs the audit on it and checks both the rc and the line the
audit must print (so a red is red for the fixture's reason):
comment-only site (//) -> rc 1, "has NO SITE"
comment-only site (/* */) -> rc 1, "has NO SITE"
string-literal site -> rc 1, "has NO SITE"
other-branch log -> rc 1, "with no MGLOG_W/E in its own block"
nested-branch log -> rc 1, same
log only in a /* */ comment above -> rc 1, same
nearest log is MGLOG_D -> rc 1, same
good sites (AT form, bare Count under
its own MGLOG_E_ONCE, def row with a
trailing comment) -> rc 0, "0 unlogged, 0 unknown"
8/8, rc 0. Mutants: with the strip disabled 5 of 8 fail (the four
comment/string reds go green and the good fixture's commented row is
lost); with the old 8-line window in place of the block walk 2 of 8 fail
(other-branch, nested-branch). Against the audit as it stood at aab01275,
six of the seven red fixtures passed and the good one failed.
.github/workflows/test.yml: the "Wire declines audit (B3)" step runs
--self-test and then the tree audit, the way the next step runs
trace_cases.py --self-test-transport-matrices before it computes matrices.
Fable review of the B3 rework, should-fix: the 8-lines-above MGLOG_W/E window
was branch-blind - a Count() in `if (x == 2) { ... }` whose only log sat in a
preceding `if (x == 1) { ... }` passed, as did a log in a nested branch just
above. All 12 bare sites in the tree are under their own line today, so this
was a gate hole, not a present defect.
has_own_log() walks upwards from the Count line (still capped at 8 lines):
blank and deeper-indented lines (a log's continuation lines, a nested block)
are passed over, only a line at the Count's own indentation can be its log,
and the first line indented less than the Count - the block's opening, a
sibling's `} else {` - ends the walk.
WireDeclines.def is now read through the same comment/string strip, and ROW
tolerates spaces inside the parentheses: `MGL_WIRE_DECLINE(Name) // note`
used to fail `\)\s*$`, dropping the row, and its real site was then
reported as naming no row.
Tree: 53 rows, 53 with sites, 53 sites, 0 unlogged, rc 0 (unchanged).
Fixtures the previous commit passed and this one reds: other-branch log,
nested-branch log (rc 0 -> 1); a def row with a trailing comment plus its
site (rc 1 -> 0).
Fable review of the B3 rework, must-fix: a row was "sited" by any line the
AT_SITE / COUNT_SITE regex matched, including a // comment, a /* */ block or
a string literal - `// MGL_WIRE_DECLINE_AT(GhostRow, "...");` satisfied the
audit for a row nothing counts, and an MGLOG_W inside a comment above a bare
Count() satisfied the log rule.
wire_declines_audit.py now blanks //...$ (with backslash-newline
continuation), /* ... */, "..." and '...' (raw strings R"d(...)d" and
C++14 digit separators handled) out of each source before any match. Every
character but the newline becomes a space, so the text keeps its length and
line count and each report still cites the real line.
Tree: 53 rows, 53 with sites, 53 sites, 0 unlogged, rc 0 (unchanged).
Fixtures that the previous audit passed and this one reds (rc 1): a
comment-only site (//), a comment-only site (/* */), a string-literal site,
a log only in a /* */ comment above a bare Count. The branch-blind window
and the .def trailing-comment row are the next commit.
- §2.9 (new): the lane-marker/backend skips and their verified Skipped lines, the 4-byte row stride and its validation-layer red-once, the whole-call shape decision (the round-1 partial effect: a scaled or X-mirrored COLOR|DEPTH blit wrote its colour and then errored on its depth) with its red-once, the split once-sites, the empty-side decline, MsFlip1 and the exact-one parity tail check, each red-once as run.
- §2.6: MsFlip goes red on a device whose renderbuffer and texture D24S8 map to different VkFormats (cross-format Y decline); §5 item 2 says to check it on the Redmi.
- §2.8: the round-1 bufferRowLength=0 note and the per-aspect decline are marked superseded by §2.9.
- §6: item 1 cites VulkanRenderer.cpp:10066 (the vkCmdBlitImage; :10060 is the vkCmdResolveImage) and states the scale partial effect as closed; item 5 states the X/cross-format-Y partial effect as closed and that its INVALID_OPERATION is a rule I (a) refusal of a legal blit; item 4 counts six knob entries; new item 6 (monolith DirectVulkan flipped/scaled multisample depth blit, VulkanRenderer.cpp:9594/:9608, a wire-vs-monolith divergence where the wire arm is the correct one, P13/G1-bound) and item 7 (Espryt flipped/scaled multisample depth resolve, P3b/P4b); stale WireFramebuffer.inc line numbers refreshed.
- §7 is two labelled rows: this tree (p7/magma-b2@4442ea6d, measured) and the landed tree (pipe@3b66d1b1, integrator-measured), instead of numbers pinned to 5d896170, which is not an ancestor of the landed tree.
- BlitWireFramebuffers runs colour first. A scaled COLOR|DEPTH blit out of a multisample framebuffer scaled and wrote its colour and then recorded INVALID_OPERATION on its depth, and an X-mirrored one mirrored its colour and left its depth alone - partial effects GL 18.3.1 does not allow. The shape decline (scale, X mirror, cross-format Y mirror) moves into DeclineWireDepthStencilResolveShape, which BlitWireFramebuffers asks for every depth/stencil aspect that would take the multisample resolve arm before any aspect runs; ResolveWireDepthStencil asks it too for any other caller. Default read/draw framebuffers are left to the lambda (a default read is never multisampled; a default draw behind one is the region Fatal, and resolving it early would move its write index).
- The X decline and the cross-format Y decline get their own MGLOG_E_ONCE sites; one site used to cover both, so after the first X decline a cross-format Y decline logged nothing.
- An empty rectangle on one side only (64x48 onto 0x48) is a size mismatch and now takes the scale decline (INVALID_OPERATION) instead of returning silently; empty on both sides stays a silent no-op.
- DirectVulkan.{Split,Spawn}.MsFlip1. forces MGITEST_MAGMA_FORCE_SHADER_DEPTH_RESOLVE=1, so the shader arm min-corner rectangle under a flip runs on this host; split+spawn only and named in MAGMA_SERVER_ENV_KNOB_NO_TCP.
- spawn_lane_parity.py: every server-env-knob tail must match exactly one split key, so a dead or over-broad tail fails the gate instead of riding silently.
- The flipped-resolve case gains a COLOR|DEPTH scale leg (error, colour untouched, depth untouched) and an empty-destination leg (error, depth untouched).
- Red-once, each on split + spawn (+ tcp MsFlip.) and restored: pre-pass disabled -> all five MsFlip/MsFlip1 entries fail "the declined COLOR|DEPTH scale must not have written its colour either" (read {255,0,0,255}, want {0,0,255,255}); round-1 silent empty-side return -> all five fail the empty-destination leg (error 0, want 1282); shader arm handed sx0/sy0 instead of the min corner -> MsFlip1 fails on both arms ("768 of 768 depth values differ from 0.75", "928 of 3072 stencil values are wrong; first at (20, 4): got 255, want 99") while MsFlip. stays green; a dead ".MsFlip2." tail -> parity rc 1 "matches 0 split entrie(s)".
- The split once-sites have no lane red-once: the cross-format Y decline needs a device whose renderbuffer and texture D24S8 map to different VkFormats, which lavapipe is not.
- ResolveWireDepthStencil moved the resolved aspect through a tightly packed buffer and, for a vertical mirror, started one region per row at row * width * texelSize. A depth/stencil buffer->image copy needs every bufferOffset on a multiple of 4 (VUID-vkCmdCopyBufferToImage-dstImage-07978), so a stencil aspect (1-byte texel) whose width is not a multiple of 4, or a D16 aspect with an odd width, was illegal on every row after the first; scissor clipping produces such widths.
- paddedWidth = width rounded up to 4 / texelSize texels; the buffer is paddedWidth * height * texelSize, both copies name it as bufferRowLength, and each row region starts at row * paddedWidth * texelSize.
- The flipped-resolve case gains a stencil leg: stencil bands 11/99 in the multisample source, a 29x32 flipped STENCIL_BUFFER_BIT blit from (5,8) to (20,4), and every stencil value of the destination checked against the mirrored band or the untouched prime.
- Red-once (split + spawn, under VK_LOADER_LAYERS_ENABLE=*khronos_validation): lavapipe does not enforce the VU and the stencil bytes came back right on the unpadded code, so the red is the validation layer: 10 x "pRegions[1].bufferOffset (29) must be a multiple 4 if using a depth/stencil format (VK_FORMAT_D24_UNORM_S8_UINT)" on each arm before, 0 validation errors of any kind after.
- The suite is discovered whole on the monolith DirectVulkan arm and on every DirectGLES arm, and the case was red on all of them: the monolith refuses a flipped depth blit and scales a multisample one GL calls INVALID_OPERATION, and Espryt writes nothing for the flip and raises nothing for the scale.
- A backend gate first (Gl().BackendName() != "DirectVulkan", Espryt reason, P3b/P4b), then the lane marker (SplitLane::IsSplitLane(), monolith reason, P13/G1-bound), so a DirectGLES entry names the backend that owes the fix whichever lane it sits in.
- DirectVulkan.{Split,Spawn,Tcp}.MsFlip. stay armed (all carry MGITEST_SPLIT_LANE=1) and pass.
- Verified: DirectVulkan.DepthStencilReadbackMatrixScenario.AFlipped* and DirectGLES.{,ForcedDepthStencilEmulation.}DepthStencilReadbackMatrixScenario.AFlipped* report Skipped with those reasons.
- the sixteen unpushed integration commits after 1135c664 were rewritten to drop the
Co-Authored-By trailer the project forbids (commit-style rule, user 2026-07-15 and
2026-09-09); their SHAs changed, so every citation of the old SHAs is remapped here
- no content change beyond the SHA text
The status page's exit-gate table now says item (7) verify x split is reported and
on pipe pending its gate and review, and the running table names the B3 fix round
and the V1 review.
The B3 rework's parity hunk and B2's round-1 hunk described the same asymmetry
under two names; the merge kept B2's compare_arms(no_tcp=) and added .StaleSerial.
to MAGMA_SERVER_ENV_KNOB_NO_TCP. One line of the package tree's other mechanism
(`not_on_arm = not_on_arm or {}`) survived outside the conflict hunks and would
have been a NameError on the first run; it goes, and the note's two mentions of
MAGMA_NOT_OVER_TCP now name the list that exists.
Records review ID-P7-34's four should-fix items as landed:
- §1.5: the .def audit (52 rows / 40 sited / 10 unlogged, rc 1 -> 53 / 53 /
0, rc 0), the per-function distribution, and the forced ShapeNoVkFormat
red; §1.2's "32 exits" is corrected against the tree.
- §3.2: the submission term is a probe and a defence, not the fix - the
stamp can name S1 while the draw rides S2 through the preserve flush -
and the :273 early return and :432 re-stamp.
- §4.1: the MGWIRE-FLOOR red condition in run_trace_case.cmake, with the
red (clamp deleted: 26 lines, ssim 1.0, FAIL on SPLIT and SPAWN) and the
green.
- §4.2: StaleSerial. on split and spawn only; MAGMA_NOT_OVER_TCP.
- §5: the review-round gate sweep; integration-magma-tcp 71 -> 70.
- §7.2 (for CONTRACT-P7 §12): the disaggregated monolith arm is now
sounder than the pull build on mid-frame flushes (OnSubData reads the same
floor via IsResourceBusy; the pull build keeps the latent bug under G1 /
P13), and the stale comments at WaitForFrameSerial and
CollectAllDeferredReleases; the reviewer's verdict on the clamp and on B4.
Review ID-P7-34, item 2. The primary B3 fix - the clamp in
VulkanRenderer::OnSubmitsCompletedUpTo that raises the completed-frame-
serial floor only to a serial no in-flight submission still carries - had
nothing behind it: deleting the clamp loop left every lane green and the
OpenRA picture gold on lavapipe (the host's barriers hide the tear). Its
only observable was the per-event MGLOG_W "MGWIRE-FLOOR
unsound-serial-complete".
run_trace_case.cmake now reads that line out of both roles' logs on every
split run, exactly as it already reads Fatal{: the count is printed pass
or fail and any nonzero count is a FATAL_ERROR. The R-16 counter (26 -> 0
on the OpenRA retrace) is thereby a gate.
Red/green on MobileGLTraceReplay.OpenRA.DirectVulkan.SPLIT / .SPAWN:
clamp loop deleted -> both FAIL at ssim 1.000000 / 0 px:
MGPipe split: OpenRA DirectVulkan transport=inproc, MGWIRE-FLOOR
unsound-serial-complete lines: 26
[mgl-srv-apply/WARN]: MGWIRE-FLOOR unsound-serial-complete #1: advancing
the completed frame-serial floor to 3, which submission 3 (serial 3)
still carries
(spawn: identical, 26)
clamp restored -> both PASS, 0 lines.
Review ID-P7-34, item 3. 02bb5789 presented the submission term as the fix
and still carried the every-8th-drain mechanism that magma-b3.md §2.4
refutes (the probe saw one successful drain and zero boundary works across
the OpenRA replay). The term is not sound alone: lastUseSubmitIndex is
stamped at AcquireWireSlice, but a mid-draw flush (BindProgramUniformBuffers
-> SyncWireTextureShape's preserve path -> FlushWirePendingCommandsFor-
TextureUpdate) can submit the stamped index without the draw, so the stamp
names S1 while the draw rides S2. The guarantee is the floor in
OnSubmitsCompletedUpTo; the term is a defence and a device probe.
- WriteWireBuffer's block comment, the WireBufferResource field comment,
the AcquireWireSlice stamp comment, the StreamedBufferSubData CMake
comment and the F1WireScenario case comment are rewritten on that
footing; the drain story is gone. The drain probe's comment now says it
refuted the drain as the mechanism.
- WaitForWireBufferHostAccess's early return also requires the submission
term complete, so a caller with the serial term idle cannot skip a wait
the term demands.
- ReadWireBuffer re-stamps lastUseSubmitIndex beside lastUseSerial after
its wait, preserving the pending draw's reservation in both halves.
All disaggregated-only code. OpenRA DirectVulkan SPLIT / SPAWN retraces
ssim 1.000000, 0 px, zero MGWIRE-FLOOR lines; integration-magma-split 90/90,
-spawn 69/69, -tcp 70/70.
MGITEST_MAGMA_FORCE_STALE_BUFFER_SERIAL is read by the server. The tcp
arm's server is the lane fixture (scripts/ci/tcp_server_fixture.py),
started once from its own environment, so an entry-level ENVIRONMENT never
reaches it: DirectVulkan.Tcp.StaleSerial.* ran the unforced case under a
name that claimed the knob and could never red (review ID-P7-34, item 1).
The entry is now registered only when the arm is Split or Spawn, and
spawn_lane_parity.py grows a second named asymmetry beside
MAGMA_INPROC_ONLY: MAGMA_NOT_OVER_TCP, key prefixes the reference arm has
and the tcp arm does not by name. It comes off the reference for the tcp
arm alone, so the same entry missing from spawn is still an error. B2 adds
its own knob entries to the same tuple in its tree.
integration-magma-tcp 71 -> 70 entries (68 cases + the two fixture
entries); split 90, spawn 69 unchanged. spawn_lane_parity.py build-split
rc 0; tcp_lane_tools_test.py 6/6.
Integrator merge onto pipe: B2's round-1 mechanism (`mglItestServerEnvKnobArm` in
CMake, `compare_arms(no_tcp=)` in the parity script) already covers this shape, so
`.StaleSerial.` joins MAGMA_SERVER_ENV_KNOB_NO_TCP instead of the separate
MAGMA_NOT_OVER_TCP the package tree introduced.
The B3 review (ID-P7-34) counted 12 of the 52 rows with no site at all -
TexViewFormat, TexNoApplierRecord, TexRecordDeadOrStale and the nine Shape*
rows were declared and never counted - and ten sites that counted with no (MGLOG_D included)
line, so "a name, a tally and a decline line" did not describe the tree.
- SyncWireTextureShape: all nine Shape* exits now count; the ViewOf exit
and the preserve-flush exit were bare `return false` and are now
MGL_WIRE_DECLINE_AT; the image-flags exit logged only MGLOG_D, which a
Release build compiles away, and is promoted likewise.
- The preserve-copy exit was the one Shape exit with no row: appended as
ShapePreserveCopyFailed (rows are append-only), 53 rows.
- SyncTextureResourceByHandle / UploadPendingWireLevels: the count-only
sites without a log of their own are promoted to MGL_WIRE_DECLINE_AT;
the ones already under a W/E _ONCE line keep that line and gain the count.
- scripts/ci/wire_declines_audit.py: fails on a row with no site, a site
naming no row, or a bare Count() with no MGLOG_W/E within 8 lines above
it. WireDeclineTally.h (the macro's definition) is not a site. Wired into
the job that runs the fatal census.
Audit before: 52 rows, 40 with sites, 10 unlogged under the W/E rule (rc 1). After: 53 rows,
53 with sites, 0 unlogged (rc 0). Forced red (ShapeNoVkFormat made true for
1x1 textures, reverted): Split.Fm.F1WireScenario.VertexIdSampler... fails on
pixels with ShapeNoVkFormat=2 / TexShapeSync=2 in the shutdown tally.
The status page and the ROADMAP P7 row now say what is in flight after the six
packages were relaunched on claude-opus-5-5: B2 rework round 2, B3 rework, M2, V1,
F and E1. pipe stays at 1b5d97bd and is not pushed until round 2 lands.
ID-P7-31 review, note 3 and nits.
* §0's preamble: the +4 over package B's 84/63/65 is not "package A landing after
B" - A landed BEFORE B and is +2 (magma-a.md:151-153); package C is the other +2
(magma-c.md:59-61). 73 + 11 (B) + 2 (A) + 2 (C) = 88/67/69.
* §2.3 / §2.5: MsResolve1 is split+spawn only; the day-of +3 on tcp is marked as
superseded.
* §2.7 (the server-side knob has no tcp arm, measured) and §2.8 (flip mirrored,
scale declined, X / cross-format mirror declined; three red-once lines).
* §6: the lines for CONTRACT-P7 §12 - the scaled second leg is unclipped
(VUID-vkCmdBlitImage-dstOffset-00248/00249, shared with the monolith);
MagmaWireFatal("multisample-resolve-region") for a source rect outside the read
buffer, where GL promises undefined values rather than an error; the 1->N
identical-rect decline declines a legal shape (18.3.1 sample replication) because
the monolith's own observable is a VU violation; the tcp lane cannot carry a
server-side knob; X and cross-format Y mirrors of a depth/stencil resolve decline.
* §7: the review round's gates - magma 94/73/70, full-split 525, unit 2423, parity
RC 0 naming the five MAGMA_SERVER_ENV_KNOB_NO_TCP entries, census 79, ratchet 173,
G1 .text 0xa52203 with 0/0 symbol drift, @P7 19 comment lines.
* link_ratchet_baseline.txt: `base commit` points at 6af422b3, the tree that
measured 173, not 4bee1313.
The "@P7 = 24 lines" nit needed no edit: the note already reads 19 at 6af422b3, which
is what the tree measures.
ID-P7-31 review, should-fix 2.
ResolveWireDepthStencil required sx1 > sx0, sy1 > sy0 and identical signed spans, and
took the session with Magma:multisample-depth-resolve-region otherwise - so
glBlitFramebuffer(0, h, w, 0, 0, 0, w, h, COLOR|DEPTH) out of a multisample framebuffer
resolved its colour correctly and then died on its depth. GL 4.6 core 18.3.1 asks for
identical DIMENSIONS, not identical corners.
* spans are signed; everything downstream (renderArea, the shader arm's rectangle, the
buffer copy offsets, BlitDepthAcrossFormats' corners) works off the min corner;
* a SCALE is INVALID_OPERATION in 18.3.1 and now declines by rule I (a) with the colour
arm's three lines (MGLOG_E_ONCE, RecordTextureCopyError, return);
* a same-format Y mirror is done in the copy-out: one VkBufferImageCopy per row,
buffer row r written to destination row dyMin + height - 1 - r;
* an X mirror (a region per texel is not a copy) and a Y mirror across two formats
(BlitDepthAcrossFormats, shared with the monolith, has no flip) decline by name.
The region Fatal stays for a rectangle outside either image.
Scenario DepthStencilReadbackMatrixScenario.AFlippedMultisampleResolveMirrorsTheBandsAnd-
AScaleDeclines, registered as MsFlip. on all three magma arms (no knob): unequal depth
bands, COLOR|DEPTH in one flipped call with colour and depth asserted the same way up,
then a scaled depth blit that must be INVALID_OPERATION and leave the destination alone.
Red once (R-16, executed and reverted, split and spawn each):
* the old guard restored -> both arms die by
Fatal{UnmigratedVerb, "Magma:multisample-depth-resolve-region"};
* the scale decline turned back into that Fatal -> the flip half passes, both arms die
at the scale;
* the per-row copy-out bypassed (if (!mirrorY) forced true) -> both arms live and fail
"flipped resolve: the destination's bottom band: 768 of 768 depth values differ from
0.75; first bad value 0.25" (and the top band the other way).
ID-P7-31 review, should-fix 1.
Every MGITEST_MAGMA_FORCE_* knob is read BY THE SERVER (the getenv readers in
WireFramebuffer.inc run on the apply thread), and a ctest ENVIRONMENT property only
reaches the process ctest starts - the client. Inproc runs the server in that process
and ServerSpawn.cpp copies ::environ into the spawned child, so both arms see the knob;
the tcp arm's server is the lane-wide TcpServer.Start fixture, started once from
scripts/ci/tcp_server_fixture.py's own os.environ, and nothing a per-case property says
reaches it. The tcp copy of each knob=1 entry ran the DEFAULT arm under a name claiming
otherwise.
Measured, not deduced: a temporary fatal in each of the three readers killed all five
knob=1 entries on split and on spawn and left all five green on tcp.
So ShaderMip1, ShaderMip2, DepthMip, DefaultBlitShape1 and MsResolve1 are registered on
split+spawn only (the first four are package B's pre-existing vacuity of the same
shape). The knob=0 twins DefaultBlitShape0 / MsResolve0 keep their tcp entries: zero is
the default and means the same thing on every arm.
spawn_lane_parity.py names the exception, following ID-P7-14's MAGMA_INPROC_ONLY:
MAGMA_SERVER_ENV_KNOB_NO_TCP, applied through a new compare_arms(no_tcp=) that drops the
keys from the tcp comparison ONLY - folding them into inproc_only would stop requiring
them on spawn, which is where they do most of their work.
p7w5 (B3 on top of B2 and D3) renders OpenRA to the golden on every arm
the acceptance asked for - inproc and spawn, run-ahead and lockstep,
cold and warm, FIF=8 and the dump-armed variant that failed 0/5 before -
and the three regressions are bit-identical to p7w4. Gate 3's anchor
case is closed; bsl-esc-menu (M2) and the three-repeat final form remain.
docs/Disaggregated/CURRENT_STAGE_STATUS.md is new: the nine-item exit
denominator with evidence, what landed, what is running, what is next.
The ROADMAP's P7, Ph and P3b/P4b rows carry the wave-2 landing, the 6/9
checkpoint and the open items, and OQ-8/10/13 are marked answered.
Mechanism with file:line and the submission structure measured rather than assumed, the
confirmation tallies (zero unforced, which refutes the silent-draw-drop hypothesis the
investigation ranked first, and which the judge refuted independently on the prefix-tear
forensics), the provable floor, the fence-backed second term in WriteWireBuffer, the red-once in
three forms (a deterministic counter 26/26/0 -> 0/0/0, a two-process lane case, and forced OpenRA
retraces), the gate numbers, and the device acceptance with three predictions H1 must satisfy.
Records honestly: an earlier draft of this note blamed TryDrainFrameTransients' every-8th-drain
frame boundary, which this package's own probe refutes (one successful drain in the whole replay,
zero frame-boundary ones, all 33 serial bumps from Present); the GPU-drag knob the judge suggested
was not built, with the reason; the aggregate-wait secondary fix is left to a separate package
with the lane evidence that nothing in this tree's coverage regressed without it; and M1's
"present fires once" observation does not hold for OpenRA, whose pbuffer replay presents every
frame.
THE OpenRA DEVICE DIVERGENCE. "Frame-serial completion piggybacks on submission completion"
holds only while a frame serial has ONE submission. On the wire arm it has two, measured on this
trace: 73 submissions over 32 serials on inproc and on spawn, 30 over 32 on monolith.
S1 is the per-frame palette glTexImage2D, which reaches UploadPendingWireLevels ->
FlushWirePendingCommandsForTextureUpdate -> FlushPendingCommands at the first palette-sampling
draw and submits under a POOLED fence. S2 is Present, and it carries the frame's draws AND the
barriered vkCmdCopyBuffer that StagedWireRangeCopy records for every streamed glBufferSubData.
RegisterSubmit stamps both with the serial current at submit time, so they share it.
S1 is tiny and signals almost immediately. RefreshCompletedSubmits retires it alone, and
OnSubmitsCompletedUpTo called NotifyFrameSerialComplete per retired record - advancing the floor
to N-1 while S2(N-1), the submission actually carrying frame N-1's buffer copies, was still
executing. GetCompletedSerial then returned N-1, so frame N's first write to that buffer failed
WriteWireBuffer's busy predicate and took the unordered host memcpy at :268 - and S2(N-1)'s
queued copies landed on top of it afterwards.
That is why the wrong pictures are a PREFIX TEAR of one draw's vertex range rather than a missing
draw: 318 vertices / 6 = 53 quads at 11448/53 = 216 bytes each, and 6696 = exactly 31 of them.
6696 and 11448 are both real write sizes of that same buffer (26 and 5 occurrences in this
replay), so the damage is the previous frame's write list replayed over the new bytes, and its
length is the largest write in whatever suffix of that list had not executed yet. It also
explains the device's FRAMESINFLIGHT=4/8 result (6/6 whole-write loss): more frames in flight
puts the GPU further behind, so the pending suffix contains the larger writes.
The floor now advances only to a serial NO remaining in-flight submission still carries.
NotifyFrameSerialComplete is monotone and already refuses the still-recording serial, so this can
only advance the floor later, never further; the disaggregated build's monolith arm is
behaviour-identical (one submission per serial, and its counter reads zero either way). The #else
branch is the pull build's statement verbatim, which is what keeps G1 byte-identical.
The counter is the red-once's reading, per lens C: an advance naming a serial some remaining
submission still carries. Base 26 on inproc, 26 on spawn, 0 on monolith; zero on all three after
this commit. It is logged per event rather than dumped at teardown because this trace's renderer
is never Shutdown() - the process exits - so a teardown dump reports the start-up context's zero.
F1WireScenario.StreamedBufferSubDataBeforeEachDrawIsOrdered is OpenRA's frame reduced to four
quads: one GL_DYNAMIC_DRAW VBO that is never orphaned, four batches written over the SAME
offset-0 range and each drawn immediately, each in its own two-pixel column and its own colour.
The case asserts PIXELS, because this path has no GL error and no log line to assert - a draw
that read another batch's bytes still executes and still reports success.
Two entries per arm. The plain Fm. entry is a regression gate that runs on every driver. The
StaleSerial. entry carries MGITEST_MAGMA_FORCE_STALE_BUFFER_SERIAL=1 and is the rule-J red-once:
on integration-magma-spawn, at the package base, batches 0..2 lose their own vertices and batch 3
- the last write - keeps them, which is the mechanism spelled out in pixels. Its own ENVIRONMENT
rather than a lane-wide flip, for the reason the ShaderMip pair gives: the knob changes how every
streamed write in the process is answered.
WriteWireBuffer answers a resource_subdata either with an ordered vkCmdCopyBuffer or with an
immediate host memcpy, and it chose on `lastUseSerial > GetCompletedSerial()` alone.
GetCompletedSerial is max(m_frameSerial - frameCount, m_completedSerialFloor) and its first term
is PURE COUNTING - it asserts that work from frameCount serials ago has finished with no fence
behind it. On the wire arm m_frameSerial is advanced not only by Present but by
TryDrainFrameTransients -> VkBufferManager::BeginFrame on every 8th drain, which happens
MID-FRAME, and package B established that mid-frame pooled-fence submissions are not covered by
frame.imageInFlightFence. So the predicate could answer "idle" about a buffer that a
recorded-or-in-flight draw still reads; the write was then applied unsynchronised and the draw
executed reading somebody else's vertices - drawing another batch's geometry, or nothing at all,
with no log line and no GL error.
That is OpenRA's frame: ~24 glBufferSubData -> glDrawArrays pairs over the SAME range of one
GL_DYNAMIC_DRAW buffer that is never orphaned, and a picture missing exactly one batch's quads.
The predicate now has a second, fence-backed term: WireBufferResource carries lastUseSubmitIndex,
set at every draw bind and staged copy to GetWireNextSubmitIndex() (m_submitCounter + 1 - what
RetireWireObjects already tags future objects with, and deliberately NOT GetSyncPointSubmitIndex,
which answers m_submitCounter when nothing is recorded yet, exactly the hole a draw acquiring its
vertex slices before BeginCommandRecording falls into), and IsSubmitIndexComplete reports an
unsubmitted index as incomplete. The counting term is kept because it only ever over-reports
busy; the two are OR'd, so the answer is monotonically safer. GetCompletedSerial itself is
untouched - it also serves the monolith VkBufferResource path and changing it would move the pull
build's .text.
MGITEST_MAGMA_FORCE_STALE_BUFFER_SERIAL forces the counting term to answer "idle" (all writes, or
the Nth one alone), which is the state a mid-frame idle drain puts the device into and which no
host lane reaches by itself. It forces only the lie, not a bypass of the ordered copy, so it stays
a valid red-once across this fix.
Rule I admits two shapes for a refusal on the wire arm, a decline and a named Fatal. The draw
path had a third one the rule does not allow: roughly forty bare `return false` statements with
no log, no counter and no census entry, so a dropped draw left no trace anywhere - which is how
the OpenRA device divergence survived four device windows with client and server logs that were
line-identical between a passing and a failing run.
WireDeclines.def names every one of them; WireDeclineTally.h expands that into a counter table
plus MGL_WIRE_DECLINE_AT (the tally always moves, the log is once per site), in the shape of
PipeApplier.h's ServerVerbSink tallies and for the same reason (R-16: a probe may not arm
against a stub). Covered: SetupWireDraw and DispatchWireCompute (25), the three exits of
PrepareWireTextureResources, the four of SyncTextureResourceByHandle, and UploadPendingWireLevels'
five false exits PLUS the two that only `continue` - those report SUCCESS while leaving the entry
pending, i.e. a draw that samples a level which was never uploaded, so they are counted apart.
Also the pending-upload gauges (entries, bytes, peak bytes), sampled at the funnel, because a
refused upload does not consume its entry and the level shadow behind it stays alive.
Shutdown dumps the tally and is silent when it is all zero: a green lane stays quiet and a lane
that dropped a draw cannot. Plus the drain probe behind MOBILEGL_MAGMA_WIREBUF_PROBE, which is
what measured that a host replay reaches the mid-frame frame-boundary drain zero times.
Four lenses and an adversarial judge: the two wrong OpenRA pictures are
prefix tears of one draw's vertex range, produced when frame N-1's first
pooled-fence submission retires and raises the buffer manager's floor
while the Present submission carrying the staged copies is still running,
so frame N's first glBufferSubData takes WriteWireBuffer's host-memcpy path
and the queued copies overwrite it. FIF=4/8 fail 6/6, as that predicts.
The later device sessions' logs (spawn, texture dumps, bsl memory, FIF)
join the evidence directory.
CONTRACT-P7 §5.2's (B'), which §4.2 needs.
WHAT MOVES. In a disaggregated build the monolith arm now drives the SAME baked
modules the wire arm does:
* InitializeBlitResources / InitializeDepthMipmapResources return true - there is
nothing to build on any transport;
* TryBlitToDefaultFramebufferWithShader turns its two BlitImageBindings into
WireImages and calls BlitWireColorToDefault (WireColorBlit.inc);
* GenerateDepthMipmapWithShader loops GenerateWireDepthMipLevel
(WireDepthMipmap.inc) and hands the image back in the finalLayout its caller
promised;
* GetOrCreateBlitPipeline, the three GLSL strings, the eight kHidden* ids and the
frontend members of BlitResources / DepthMipmapResources compile out.
The `#else` is the pull build's code, statement for statement.
WHY AN `#if` AND NOT A RUNTIME BRANCH - this is the whole of §4.2. P5f fv already
gave both initializers a non-monolith early return, and the ratchet counted the
symbols anyway, because a reference is a reference whether or not the branch runs.
And the MEMBER has to go with the construction: a SharedPtr member's destructor is
itself a reference to ~ProgramObject.
THE BUCKET TABLE (scripts/link_ratchet.py --bucket, build-split):
bucket before after a6
p6-core 11 11 6
p7-magma 101 88 101
p3b-p4b-espryt 13 18 14
both-backends 61 56 60
total 186 173 184
p3b-p4b-espryt's +5 is not a regression: those five MOVED out of both-backends
because DirectVulkan stopped referring to them and only DirectGLES still does.
Thirteen symbols went away outright, and they are exactly the three classes §4.2
names: ProgramObject::{AllocateLifetimeId, AttachShader, Link, ~ProgramObject},
SamplerObject::{SetLodRange, SetWrapR, SetWrapS, SetWrapT} and
ShaderObject::{Compile, DropCompileNode, JoinPendingCompile, ReleaseCompileNode,
SetShaderSource}. Re-baselined in this commit with --write-baseline; the 7 `# P13`
annotations carried over.
§4.2's "down to 0" DOES NOT HOLD, and the gap is not (B') left half-done - it is
that 88 of the 101 were never about the hidden programs. 62 come from
UniformManager.cpp.o and 14 more from VkSamplerManager.cpp.o: the MONOLITH
descriptor binder and sampler cache reading the application's own GL texture and
sampler objects (TextureObjectBase 35, TextureObjectWithOneMipmap 20 with its
vtable and typeinfo, SamplerObject's fourteen getters). Under a transport
SetupDraw branches to SetupWireDraw and not one of those lines runs - but that
branch is a RUNTIME one, which ID-P7-5 deliberately leaves to the optimisation
phase. notes/p7/magma-b2.md §4.3 names all eleven referring-object groups and what
each is.
NONE OF THEM IS ANNOTATED `# P13`, on purpose: that mark is for a symbol that
cannot fall without moving pull `.text`, and nothing above has shown that.
UniformManager's and VkSamplerManager's monolith arms are pull-build code, and
`#if`-ing them is the same operation this commit performed on VulkanRenderer.cpp.
They are wave 3's work, not P13's boundary problem, and marking them P13 early
would record an untried thing as a tried-and-impossible one.
Gates: integration-gpu 1705 (every monolith `DirectVulkan.` entry is in there),
integration-magma-split 93, -spawn 72, -tcp 74, integration-magma-full-split 524,
ctest -L unit 2423, all green; fatal_census 79 abort sites / 44 family words / 0
unmarked; spawn_lane_parity green; link_ratchet --assert-monotone unchanged at 173
against the re-baselined file, and --self-test OK.
RETRACE, which is this slice's load-bearing reading because the monolith arm
changed implementation: OpenRA.DirectVulkan (monolith) ssim 1.000000,
mismatchPixels 0, twice; OpenRA.DirectVulkan.SPLIT ssim 1.000000, 0. The whole
DirectVulkan matrix is 39/40 monolith and 38/39 split, and both reds are the SAME
case, minecraft-1.21.4-fabric-iris-iterationrp-in-world (ssim 0.027915), which is
PRE-EXISTING - it fails identically on ~/w7/p7-magma-b (this branch's parent),
because it needs CONTRACT-P7 §2.4's three iterationRP knobs and those are exported
by the CI job rather than by the ctest entry.
G1 pull .text 0xa52203, nm --defined-only identical to p7-before/pull-syms.txt
(30570 lines, 0 diffs).
Device debt: the monolith arm in the Android disaggregated package now runs the
baked blit, and its ROTATE_90/180/270 geometry has never executed - every surface
on this host is IDENTITY. notes/p7/magma-b2.md §5 lists it with the package's three
other device items.
CONTRACT-P7 §5.2's "the two MagmaP7AllocatorDebtScope are deleted with it (prove
first, with a temporary assert, that they never arm under a non-monolith
transport)".
WHAT THE DEBT WAS. The hidden blit program, its two samplers and the hidden depth
-mipmap program are FRONTEND objects. Under an active transport the server backend
created them on the apply thread and destroyed them on it, where their destructors
run the client death helper and its lifetime-id probe - so P5c gave the two
teardowns a named exemption and P5e ruling 12 renamed it after the debt.
WHAT RETIRES IT IS NOT AN EXEMPTION BUT AN ABSENCE. P5f fv already gave
InitializeBlitResources and InitializeDepthMipmapResources an early return for
every non-monolith transport, so off the monolith arm neither resource ever holds
anything and `m_blitResources = {}` destroys nothing. The scopes have been
markers over a path that cannot run since that day.
PROVED, NOT ARGUED. A temporary probe in both teardowns failed the session by name
if either resource was non-null under a non-monolith transport. With it armed:
integration-magma-split 93, -spawn 72, -tcp 74, integration-magma-full-split 524,
-full-spawn 524, integration-split 187 and integration-magma-buffers 21 all green -
it never fired. The negative control is what makes that a measurement: dropping the
probe's transport test killed EVERY monolith `DirectVulkan.` entry at teardown
(8/8 aborted, after the case itself passed) while the split entries stayed green.
integration-magma-full-tcp's one red under the probe is the documented StreamLink x
Magma exposure texel (CONTRACT-P7 §2.5's named tcp entry,
IterationRPProgram203Scenario), not a probe death - it fails, it does not abort.
THE COUNT AGAINST P5e RULING 12. That ruling named FOUR Magma sites
(VulkanRenderer.cpp x3 and ResourceTracker.h x1). Two had already gone before P7
began - `git log -S` puts the ResourceTracker.h site's removal in P5f fv (4f3d2d6b)
and the third VulkanRenderer site's in P5f fm (036c3a3d) - so the P7 baseline
78b7d6be carried TWO, which are the two this commit deletes. Magma's apply-thread
allocator debt is now ZERO sites.
THE TYPE STAYS, and its comment now says why: MG_Test/Wire/RemoteClientTest's
RemoteGuards.BarrieredLegacyScopesCannotExemptAllocator and
.BarrieredFrontendRegistryMembersRefuseBothLegacyScopes construct it on the apply
thread and assert the allocator refuses ANYWAY. That is a negative control for the
property P5f (fr) established, and deleting the class would delete the only case
that pins it. No backend use remains.
Gates: integration-magma-split 93, -spawn 72, -tcp 74, integration-magma-full-split
524, ctest -L unit 2423, all green (one InitialCapsStartup death test flaked once
under -j 4 beside a sibling worktree's build and is green on re-run and on a second
full lane); fatal_census 79 abort sites / 44 family words / 0 unmarked;
link_ratchet --assert-monotone unchanged at 186; G1 pull .text 0xa52203, nm
--defined-only identical to p7-before/pull-syms.txt (30570 lines, 0 diffs).
CONTRACT-P7 §3.2's `multisample-blit-aspect@P7`, the last `@P7` refusal in the tree.
Two halves again, and neither is the other's fallback.
THE HALF WITH AN ARM. vkCmdResolveImage is COLOUR ONLY. The one way Vulkan
resolves a depth or stencil aspect without a shader is a render pass with a
depth/stencil resolve attachment, which needs VK_KHR_create_renderpass2 and
VK_KHR_depth_stencil_resolve - and a device with neither ended the session at
`Magma:multisample-depth-resolve-capability` on the first
glBlitFramebuffer(GL_DEPTH_BUFFER_BIT) out of a multisampled read framebuffer,
which is what every multisample depth resolve in GL is. §3.2 names the second arm
and this is it: WireMultisampleResolve.inc, three baked modules (one vertex stage,
one fragment stage per aspect), fetching SAMPLE ZERO - the same sample
VK_RESOLVE_MODE_SAMPLE_ZERO_BIT picks, so the two arms answer identically and the
lane can assert that.
The pass carries nothing: the scratch has the source's extent and coordinate
space, so the fragment stage indexes by gl_FragCoord and the rectangle is the
scissor. No push constants, no varyings, no frontend object. The stencil half
needs VK_EXT_shader_stencil_export to exist at all (a fragment shader cannot write
the stencil aspect otherwise); where it is absent the arm declines stencil rather
than leaving the aspect undefined, and the module is not even created, because
handing vkCreateShaderModule SPIR-V with an unsupported capability is undefined
rather than an error.
The two capability tests are deliberately not the same test: the subpass resolves
every aspect the FORMAT has and needs a mode for each, while the pass writes only
the aspect this blit asked for - which is safe because the copy-out reads exactly
that aspect.
THE HALF WITH NO ARM. Sample counts that differ with the DESTINATION multisampled:
one sample fanned out to N, or N reduced to M. vkCmdResolveImage only ever writes
a single-sample destination, vkCmdBlitImage refuses a multisampled side, and
vkCmdCopyImage needs the counts equal. Rule I (a): name both counts, record
INVALID_OPERATION, leave the destination alone.
Lanes. DepthStencilReadbackMatrixScenario.AResolvedMultisampleDepth… is PROMOTED
from the informational tier into the three gated Magma arms - it already owns the
observable (clear a 4x DEPTH24_STENCIL8, resolve with glBlitFramebuffer, read both
aspects back, check that a depth-only resolve left the destination's stencil alone
and vice versa), it just had no gated entry. Two entries over it per arm: plain,
and under MGITEST_MAGMA_FORCE_SHADER_DEPTH_RESOLVE, which is the only way the new
arm is reachable here at all - lavapipe carries BOTH resolve extensions, so the
extension arm answers every resolve on this host. The new F1 case is the decline.
It does NOT skip on this host: the shader arm resolves depth and stencil, because
lavapipe also carries VK_EXT_shader_stencil_export.
Three rows join MOBILEGL_BAKED_INTERNAL_SHADERS (8 -> 11).
Red once (R-16, executed and reverted), three times:
* restore MagmaWireFatal("multisample-depth-resolve-capability") for the
non-extension arm -> DirectVulkan.Split.MsResolve1.… dies by that name while
MsResolve0 stays green, which is also the proof that the knob routes;
* restore MagmaWireFatal("multisample-blit-aspect@P7") ->
DirectVulkan.Split.MsBlit.…FromASingleSampleSourceDeclines… dies by that name;
* fetch sample 1 instead of 0 in WireMultisampleStencilResolve.frag (no rebuild -
the gate reads the source at run time) -> WireMultisampleStencilResolveFragment
reds at word 158, naming the symbol, the header and the regeneration command.
Reverted, 15/15 green on the three arms and 11/11 on the freshness gate.
`grep -rn "@P7" MobileGL/ --include=*.cpp --include=*.inc --include=*.h` now
returns COMMENTS ONLY - 19 lines, every one of them prose about a retirement or a
red-once. No refusal in the tree carries the mark any more.
Gates: integration-magma-split 93 (+3), -spawn 72 (+3), -tcp 74 (+3),
integration-magma-full-split 524 (+1), ctest -L unit 2423 (+3), all green;
bake_internal_shaders.py --check reports all five headers fresh; fatal_census 79
abort sites / 44 family words / 0 unmarked; link_ratchet --assert-monotone
unchanged at 186; G1 pull .text 0xa52203, nm --defined-only identical to
p7-before/pull-syms.txt (30570 lines, 0 diffs) - every line lives under
`#if MOBILEGL_BUILD_DISAGGREGATED` or in the test tree.
Device debt: the shader arm is the arm the PHONE takes wherever
VK_KHR_depth_stencil_resolve is missing, and this host can only reach it through
the knob. Its geometry needs one look on the Redmi.
CONTRACT-P7 §3.2's `multisample-blit-shape@P7`. The name covered two halves that
are not the same question.
The half with an arm: a multisample colour blit whose destination rectangle is a
different size from its source. vkCmdResolveImage carries one extent and one
offset per side, so it can express neither the scale nor the flip - which is why
this arm already resolved into a single-sample scratch at raw offsets and blitted
THAT for the flip. The two `width != |dx1-dx0|` clauses were the only thing
keeping the scale out of the same route, and the monolith arm reaches it the same
way (VulkanRenderer.cpp's `regionWasTransformed` branch). They are gone; the
scratch is still sized by the SOURCE rect, so only the second leg sees the scale.
The filter only started mattering with that scale - at 1:1 LINEAR and NEAREST read
the same texel - so the second leg now asks whether the format can be filtered
before it passes VK_FILTER_LINEAR, which is undefined behaviour on a format whose
optimalTilingFeatures lack SAMPLED_IMAGE_FILTER_LINEAR. GL ignores filter for a
multisample resolve anyway, so the fallback is NEAREST and not a refusal.
The half with no arm: a MULTISAMPLE DESTINATION. vkCmdCopyImage is the only
command that writes one, it takes a single extent, and GL 4.6 core 18.3.1 makes a
blit whose rectangles differ in size INVALID_OPERATION when either framebuffer is
multisampled. There is no picture to get right, so rule I (a): MGLOG_E_ONCE with
both rectangles and both sample counts, the error GL names, the destination left
alone, the session kept.
Two entries on each of the three Magma arms (`MsBlit.`), split-only because the
monolith arm drops the scale (its vkCmdResolveImage lands 1:1 at the destination
offset), so an equality between the arms would assert that the wire arm is as
wrong. The scale and the flip share one case over one source: dropping the scale
lands an 8x4 band in a 4x2 attachment, dropping the flip returns the right two
colours in the wrong order, and the second blit's expectation is the first's with
the rows swapped.
Red once (R-16, executed and reverted), twice:
* restore the two size-equality clauses ->
DirectVulkan.Split.MsBlit.…ScalesAndFlipsThroughTheResolveScratch dies as
Fatal{UnmigratedVerb, "Magma:multisample-resolve-region"};
* restore MagmaWireFatal("multisample-blit-shape@P7") ->
DirectVulkan.Split.MsBlit.…DeclinesAndKeepsTheSession dies by that name.
Reverted, 6/6 green on the three arms.
Measured on the way in: the decline's GL error reaches the client with a LATER
reply, not with the blit call, so the case's glFinish is load-bearing - without it
the INVALID_OPERATION surfaced attached to the 1:1 resolve twenty lines down.
Gates: integration-magma-split 90 (+2), -spawn 69 (+2), -tcp 71 (+2),
integration-magma-full-split 523 (+2), ctest -L unit 2420, all green;
fatal_census 79 abort sites / 44 family words / 0 unmarked; link_ratchet
--assert-monotone unchanged at 186; G1 pull .text 0xa52203 with an nm
--defined-only list identical to p7-before/pull-syms.txt (30570 lines, 0 diffs) -
every line of this commit is inside WireFramebuffer.inc's
`#if MOBILEGL_BUILD_DISAGGREGATED` or in the test tree.
The vertex-input and buffer-bindings rows of SubsystemDeps.def became live
behaviour when every reader started reading the table; two static_asserts
now pin them the way the texture and programs rows already were. The
"safe by construction" story about destroy ordering was inverted for the
case where a view holds its owner's last reference - the owner's destroy
reaches the wire first - so the three places that told it now say that the
walk's Live/Gen test is the guarantee.
Rework round 4 of 4 after the D2 review (fable, ID-P7-8).
COUNTS. The note said 71 cases per arm where its own table sums to 72, and "+37 entries
per split arm" - a number that matched nothing. The +37 came from differencing against
~/w7/pipe/build-split, the integrator's LIVE build directory, which had already moved past
this package's base (7ed5da52 -> 8c5dd6fc). Measured against the pinned
~/w7/p7-before/ctest-names-split.txt baseline this package adds 76 DirectGLES.{Split,Spawn,Tcp}.
entries per arm: 72 census + 3 TextureViewAlias + 1 TextureUploadShape. The raw diff shows
77; the extra one is CtWireScenario's kCapResidentSubData case from b56d70177, one of the
five P7 commits between that baseline and this package's base, and no CtWire file is
touched here. Both numbers and their provenance are now in the note.
GATES. The note pointed at espryt-d2.md for readings espryt-d2.md did not carry. It now
has its own table the way espryt-d1.md does: four lane totals, G1 .text and nm, the G14
name delta, and the five CI gates.
CMAKE. Drops the MGL_SPLIT_D2_FILTER_<Scenario> indirection, which was copied from the d1
and i1 blocks but never defined for any scenario here - a branch no run takes. Every D2
scenario is registered whole because the census found no case in any of them that needs
excluding; the d1/i1 shape is what to reach for the day one does.
And records that the TcpDevice arm gets the texture-view knob on its CLIENT half only. The
loopback Tcp arm's server is started by the TcpServer.Start fixture, which this package
also gives the knob; the TcpDevice arm's server is a supervisor already running on a
device, started outside this build, whose environment nothing here can reach. If it was
started without the knob every texture-view case in that arm green-skips - exactly what
the loopback arm did before the fixture entry got it. The arm is unconfigured in this repo
(empty endpoint, so it is not created at all), so this is a note for whoever configures it.
Rework round 3 of 4 after the D2 review (fable, ID-P7-8). Three defects, each of a kind
the gate could not have reported about itself.
(a) TWO STRUCTURAL SELF-CHECKS. family_spans() returned whatever matched and nothing ever
asserted that each of the five FAMILIES anchors found its declaration, so a renamed struct
would drop a whole family while every rule kept passing. And an allow-list entry no rule
can consult documents a carve-out that is not happening - a claim about the tree that
nothing verifies. main() now fails on either, naming the anchor or the entry and saying
which of the two repairs is wanted. Both are exercised: renaming an anchor and adding a
dead entry each red the gate.
(b) THE DOCSTRING AND THE test.yml COMMENT WERE WRONG about what the guards mean.
MOBILEGL_PIPE_LEGACY_MEMOS defaults ON (CMakeLists.txt:43) and no job here clears it, so
CI split builds DO compile those arms - TwinLookupMemo (DirectGLES.cpp:114) sits inside
one and is compiled into every lane this repo runs. The rule is "the legacy arm MAY KEEP
pointer keys", a policy exemption; only !MOBILEGL_BUILD_DISAGGREGATED is code a split
build genuinely never sees. Both now say so.
(c) FRONTEND HAD NO LEADING WORD BOUNDARY, so BackendSamplerObject* matched SamplerObject
and the gate reported the memos' backend VALUES as frontend keys - which the first version
answered with two allow-list entries, i.e. it documented a false positive instead of
fixing it. (?<!\w) added, with controls both ways: the backend twins must stay green and
the qualified MG_State::GLState::ProgramObject* must still match.
The allow list therefore goes from six entries to ONE. Four were dead on arrival - two
false positives now fixed at the source, one naming a typedef with no frontend type in it,
and const void* program / Uint64 programLifetimeId, which no rule could ever consult
because void is not in FRONTEND and neither line calls GetLifetimeId(). The
ResolvedTextureBindingMemo monolith key those last two described is still deliberate and
still there; it simply never needed an exemption, and saying it did was misleading. What
remains is the twin registry's UnorderedMap<StateObject*, Entry>, which really is
suppressed and really is the pull build's only arm.
Rework round 2 of 4 after the D2 review (fable, ID-P7-8), which found that this gate's
numbers were right and its SUBJECT was wrong.
The scatter wrote 40 2x2 rects at x=((i*7)%32)*2, y=((i*5)%32)*2 into a 64-px atlas.
Both strides are surjective onto 0..31 over 40 iterations - rect 9 lands at x=62, rect 19
at y=62 - so the rects' union box covered the WHOLE LEVEL, and the server's subRectEligible
(Managers.cpp:8486-8491) requires !dirtyRegion.CoversWholeLevel, a BOUNDING-BOX test
(MipmapStorage.h:30-33). Both scatter textures were rect-INELIGIBLE on every arm before
:8649's unpack-ring policy was ever consulted. The box-versus-rect decision the scenario
exists to pin was never reached for the two textures it scatters into, and the only
texture that could move under any red-once was the contiguous control band - which is
exactly what the old substitute red-once's box=6 rect=3 jobs=12 was.
kScatterMargin insets the scatter by one rect: same 40 writes, same coarse stride, union
box bounded at (2,2)..(62,54). The gold row is UNCHANGED at emit=9 box=9 rect=0 jobs=9 on
all four arms - but it now means what it says.
THE RED-ONCE IS NOW ZERO-PATCH, which is stronger than the throwaway source edit it
replaces: MOBILEGL_ESPRYT_DISABLE_UNPACK_RING=1 alone gives box=3 rect=6 jobs=183 with
bytes/f[tex] falling from 104448 to 9024, while all 26 pixel cases stay green. BOTH
SCATTER TEXTURES moved - box=3 is the contiguous band alone, rect=6 is the plain atlas
AND the atlas written through the glTextureView, and jobs=183 = 3 + 6x30 confirms thirty
rects per scatter emission. The same knob was green before the inset, which is the
evidence that CoversWholeLevel was short-circuiting the decision.
The three prose sites are rewritten accordingly: the scenario's gold-table rationale, the
MEASUREMENTS.md 13.1 reading and the package note's red-once section now explain
attempts 1-3 as mutations of a decision the client already makes correctly, and attempt 4
by CoversWholeLevel rather than by the ring.
Also PipeStatsWindow gains LastFrom{Client,Server}LogSince, and the gate reads its two
role windows since a mark taken before the counted workload. The server writes an EMPTY
teardown window after the counted one, so "the last line in the file" is one shutdown
away from reporting four zeroes as the workload's shape. Moving to a marked read exposed
a second latent defect in the same assertion: ASSERT_TRUE(clientWindow.found) over the
whole file was satisfied by the "counters ON" BANNER in all three split arms, claiming a
window had been read when none had. The assertion is now on the window that actually
carries the tex[] bracket.
Rework round 1 of 4 after the D2 review (fable, ID-P7-8). Mirrors the integrator's
6da19f71 so that later commits from this worktree apply cleanly, and takes the two
review notes attached to the same file.
THE GUARD. The four Resolve<Family>SubsystemArm() declarations live inside
did not compile in the pull build (ID-P7-11). Both now GTEST_SKIP under
!MOBILEGL_PIPE_PUSH the way the client-side case already did. Verified by re-running
the file's own compile command with -DMOBILEGL_PIPE_PUSH=0 and MOBILEGL_BUILD_DISAGGREGATED
undefined: -fsyntax-only rc 0.
WHAT KEPT THE MASK SWEEP ALIVE, now asserted instead of assumed: every resolver ends in
ClassifyPipeSubsystemArm, and a NoArm verdict calls StopOnArmlessPipeSubsystem
(Managers.cpp:2928-2938), which ABORTS. The sweep deliberately drives masks that refuse a
family's handle arm, so it survives only because MOBILEGL_PIPE_LEGACY_MEMOS defaults ON
(Config.h:399) and leaves a legacy arm standing. ASSERT_TRUE on that, with the reason -
the day the default flips, this file would have aborted with no explanation.
THE FRAMEBUFFER RESOLVER IS RECORDED, NOT ASSERTED, and the asymmetry is the finding:
ResolveFramebufferSubsystemArm folds D-C3's wire-caps refusal in beside the dependency
rule (Managers.cpp:4323-4325), and that half asks the server's published caps, which a
bare unit binary with no session cannot answer in the affirmative. So a false says nothing
about D-K2 and asserting true would pin whatever the binary happens to have linked. The
sweep still covers it in the direction that matters. Also adds the 0x5ff refusal lane
beside 0x7ff.
P3b/P4b wave 2-D package D3, slice 3.
docs/Disaggregated/notes/p34b/espryt-d3.md, in espryt-d1.md's shape: the mechanism end to end,
the decision between resolving the predicate and indexing the reverse edge with the argument
that decided it (deletion ordering), the red-once evidence with the exact ctest invocations and
the red and green lines per arm, the gate numbers, and six things that remain.
The decision, stated once here because the note argues it at length: the gate resolves through
Desc.ViewOf to the storage record; the reverse index was rejected because it needs pruning on
VIEW death or an owner upload bumps a recycled slot's Serial, because it makes
ApplyTextureUpload write a record other than the one the call names, and because
VkTextureManager::ResolveWireTextureStorage already resolves the same way on the Magma side -
an index would have given the two backends two answers to one question.
notes/p34b/split-scenario-census.md is updated where the fix falsified it - the summary row,
the texture-family row and the exclusion section - and NOT rewritten anywhere else. D2's text
stands and D3's resolution sits above it in a quoted block, because a package note is a record
of what that package did rather than a live statement of the tree. Two citation corrections
taken while reading the device-window baseline: the CTS block is KHR-GL46 and not KHR-GL43, and
texture_view is 1/7 fail there with the failing case view_sampling, NOT coherency - coherency
reads Pass, which is what D2 claimed and is now checked per case against report-texture.json
rather than against a summary line.
espryt-d2.md itself is left alone and the note says why it is left alone.
Gates on this head, all measured rather than carried over: G1 .text 0xa52203, nm --defined-only
30570 symbols 0 added / 0 removed, -D 0/0, ninja -C build-linux rc 0. fatal_census rc 0 (79
abort sites, 0 unmarked - unchanged, rule I). link_ratchet --assert-monotone rc 0 (unchanged at
186). spawn_lane_parity rc 0. Lanes: unit 2418/2418, integration-split 264/264,
integration-spawn 180/180, integration-tcp 183/183, integration-magma-split 75/75, all 100%.
Name sets against a real ctest -N at a7c06f4f: +1 on each of split/spawn/tcp (the un-excluded
case), 0 elsewhere, 0 removed anywhere.
P3b/P4b wave 2-D package D3, slice 2 - D2's two loose ends, now in-partition because D1 has
landed on feat/disaggregated and MG_Backend/DirectGLES/ is free.
THE STALE PROSE, in the header of the file that implements the refusal.
MG_Backend/DirectGLES/Managers.h's resolver block said "THREE OF THEM CARRY A DEPENDENCY"
(there are six rows; bits 8 and 13 were the two it did not know about) and listed
"10 without 11" among the mirror pairs that are FINE - when 10-without-11 is precisely D-K2's
fourth row (ID-14/ID-15), refused twenty lines below by ResolveTextureResourceSubsystemArm and
withheld by the client. MGPipe.h lost the identical pair of defects with R-5; this copy was
left behind because the file belonged to a package in flight. It does not restate the rows
again: the file that states them is the file the code reads.
BOTH READERS NOW READ MG_Pipe/SubsystemDeps.def. More than the two one-line changes D2
estimated, because the rule had six statements and the estimate counted two:
PipeFill.cpp P4aFamilyDependencyBits its own four-row kMGPipeP4aFamilyDependencies[]
-> MGPipeSubsystemRequires(); table deleted
PipeFill.cpp P5eFamilyIsLive hand-coded (pushMask & bit 7) == 0
-> MGPipeSubsystemDependenciesAreSet(bit 13, mask)
Managers.cpp PipeSubsystemDependencyMissing took a literal dependency BIT per call site
-> takes the FAMILY and asks the table
Managers.cpp the four resolvers five calls, five hand-written sentences
-> four calls, one per family, naming only the family
Managers.cpp ResolveVertexInputSubsystemArm hand-rolled bitSet && !resourcesBitSet
-> the table
DirectGLES.cpp ResolveBufferBindingSubsystemArm the same hand-rolled pair -> the table
MGPipe.h gains one accessor, MGPipeSubsystemDependencyWhy(), so a reader that refuses prints
the ROW'S OWN sentence instead of a copy of it. The .def has carried a Why column since R-5
and nothing read it; its "WHO READS IT" section is rewritten because "THEY ARE NOT SWITCHED
OVER IN THIS COMMIT" is no longer true of anything.
THE CLIENT'S ARGUMENT IS MASKED TO kMGPipeP4aFamilySubsystems, and that mask is the whole
difference between a rewrite and a behaviour change. The .def carries SIX rows; the table
deleted from PipeFill.cpp carried FOUR, and that predicate is wants()'s fourth conjunct for
P4a's families only. Reading all six unmasked makes the CLIENT withhold bit 8's vertex-input
emissions at a mask with bit 7 clear, which is P3a's own rule and not this gate's - caught,
not reasoned about, by TextureEmitTest's
EveryDKTwoDependencyRowGatesItsOwnFamilyAndTheMirrorPairsStayLive going red on exactly that
assertion ("bit 8 alone, with bit 7 clear: P3a's own rule, which this table must not touch").
Bit 13 is not narrowed there either; it asks the table for itself in P5eFamilyIsLive, because
bit 13's conjunct is bit 13's own. The ROWS come from one place; WHO each gate speaks for is
still each gate's.
ONE BEHAVIOUR IMPROVEMENT FALLS OUT rather than being designed in: the texture family is the
only row with two required bits and used to be two guarded calls, so a mask missing bit 11 but
carrying bit 7 was reported with the bit-7 sentence. One call now computes required & ~mask
from the row, so the refusal names whichever bit is actually missing.
WHAT THIS COSTS, said out loud. SubsystemDepsTest used to compare two COPIES of the rule; it
now compares two READERS of one table and cannot catch a typo in a row. It is kept and the
.def says why: both sides still fold in reasons of their own - the server's own family bit and
D-C3's wire-caps refusal, the client's consumer and wired conjuncts - and those can still
disagree at a mask. The compile-time half of D2's red-once (drop the fourth row from the .def
-> MGPipe.h:225/:230 fail) is untouched and is now the load-bearing one.
SubsystemDepsTest 5/5, TextureEmit's D-K2 case green, unit lane 2418/2418.
G1: .text 0xa52203, nm --defined-only 30570 symbols, 0 added / 0 removed, -D 0/0. Every line
is inside #if MOBILEGL_PIPE_PUSH or #if MOBILEGL_BUILD_DISAGGREGATED, and MGPipe.h's new
accessor is inline constexpr and unreferenced in the pull build.
P3b/P4b wave 2-D package D3, slice 1. The defect package D2 found with its new
TextureViewAliasScenario gate and excluded by name because the fix is outside D2's footprint.
THE HOLE. Once a glTextureView had been SAMPLED, a later glTexSubImage through the OWNER's
own name never became visible through the view, on inproc, spawn and tcp, green under
monolith. The client half was never at fault: the case's own emit-counter assertions pass on
the red run, so the records were emitted and the applier accepted them. A view owns no texels
and the client keeps ONE emission cursor per storage - an upload through a view's name is
remapped onto its storage owner before it is emitted (MG_Impl/Pipe/TextureEmit.h) - so every
resource_subdata for either name is keyed on the OWNER and ApplyTextureUpload moves the
OWNER's Serial and PendingUploads alone. BackendTextureObject::IsDrawSyncCleanByRecord read
the VIEW's Serial and PendingUploads, which nothing an upload does can move, so the gate
answered CLEAN for every owner write after the view's first sample and the sync that would
have drained the owner's pending uploads never ran.
THE FIX, AND WHY THIS ONE. The gate's two STORAGE clauses are asked of the STORAGE record -
this record for a texture that owns its texels, the record Desc.ViewOf names for a view -
through the new PipeTextureStorageRecordForRecord. The three PARAMETER clauses stay the
view's own record's, because a view has its own texture parameters and its own built-in
sampler, which is the whole reason the second name exists. SyncTextureViewToBackendByRecord
stamps that same storage serial at both its arms, so the stamp and the gate name one
quantity. This is the split equivalent of the monolith gate's GetContentVersion(), which
TextureObjectView forwards to its owner (TextureObjectView.cpp:100-102).
THE ALTERNATIVE WAS REJECTED. A reverse index owner->views, so an owner-side resource_subdata
bumps every viewing record's Serial, is the other shape D2 named. It is wrong here on three
counts: it puts new mutable state in MG_Pipe's applier, which both backends read, to answer a
question one backend's gate asks; it makes ApplyTextureUpload write a record other than the
one the call names, which breaks the applier's own serial discipline; and its index has to be
pruned on view death or an owner upload bumps a recycled slot's Serial - a silent
cross-texture hazard with no test that would catch it. The resolve direction needs no state,
no pruning, and is checked at every use: PipeTextureRecordForHandle tests Live and Gen.
DELETION ORDERING, WHICH DECIDED IT. A view holds a STRONG SharedPtr to its storage owner
(TextureObjectView::m_storageOwner) and the client emits resource_destroy from the DESTRUCTOR,
not from glDeleteTextures - "the last SharedPtr to this object dropping, not the glDelete*
that only marks the name and leaves a still-bound object very much alive"
(TextureObject.cpp:63). So an owner's record outlives every record that views it whatever
order the application deletes the two names in, and the forward edge cannot dangle. The walk
checks anyway, and a null answer is NOT clean - the direction that re-syncs.
VIEWS OF VIEWS. ONE hop always reaches storage: glTextureView composes a view-of-a-view onto
the ROOT at creation (the spec's additive min-level rule), the client's emitter says so and
TextureObjectView's invariant is that its owner is never a view. The walk is transitive
anyway and BOUNDED at four hops, because this side may not depend on a client invariant to
terminate; a cyclic chain the wire could forge ends as a null answer, which is "not clean",
not a refusal. No new abort site and no new Fatal (rule I): fatal_census abort_sites stays 79.
RED-ONCE (rule J), the case D2 excluded, with the exclusion lifted and nothing else changed,
at D2's head a7c06f4f:
ctest -R 'DirectGLES\.(Split|Spawn|Tcp)\.TextureViewAliasScenario' -j 3
-> 79% tests passed, 3 tests failed out of 14
4042 - DirectGLES.Split.TextureViewAliasScenario.TheOwnerUploadDrainRunsAgainAfterADrawBoundary (Failed)
4046 - DirectGLES.Spawn.TextureViewAliasScenario.TheOwnerUploadDrainRunsAgainAfterADrawBoundary (Failed)
4050 - DirectGLES.Tcp.TextureViewAliasScenario.TheOwnerUploadDrainRunsAgainAfterADrawBoundary (Failed)
the first owner write, through the view: 11408 of 11408 pixels disagree;
first at (2, 2) is rgba(50,160,30,255), expected rgba(0,0,255,255) +/- 2
the second owner write, through the view: 11408 of 11408 pixels disagree;
first at (2, 2) is rgba(50,160,30,255), expected rgba(255,255,0,255) +/- 2
Only the PIXEL channel is red; the drain's emit counters pass, which is what places the
defect on the server. The monolith control DirectGLES.TextureViewAliasScenario.* is 4/4 in
the same run. With the fix: 14/14, 100%.
CONTRACT-P5E.md 5.2's clean gate is amended to state the rule the code now implements, and
says out loud that the rec->Serial form it carried through P5e was wrong rather than
restating it. The CMake exclusion is lifted; the scenario registers all four cases on all
three arms again (G14: the name never left any lane).
G1: .text 0xa52203, nm --defined-only 30570 symbols, 0 added / 0 removed, -D 0/0. Every line
touched in Managers.cpp is inside #if MOBILEGL_PIPE_PUSH (the gate and the resolver) or
#if MOBILEGL_BUILD_DISAGGREGATED (the view sync), and Managers.h's declaration is inside the
PIPE_PUSH block, so the pull build's preprocessed text is unchanged.
Lens D placed 100 % of the wrong pixels inside the screen-space union of a
single glDrawArrays whose vertex data was the frame's first glBufferSubData
into the streaming buffer; the palette respecify is not the mechanism. The
candidates are WriteWireBuffer's serial-gated branch and the mid-frame drain
that bumps the frame serial without a fence.
D2's fable review says land with fixes: the TUS gate's scatter workload has a
union box that covers the whole level (rect=0 is geometry, not the unpack-ring
policy), the memo purity script fails open on a renamed family, and the census
numbers were not reproducible - all sent back to D2 as one rework round; the
excluded TextureViewAlias case gets its §12 row (owner D3).
bsl-esc-menu-854 on the spawn arm: monolith passes at 807 MiB peak RSS, the
server process dies at ~1.2 GiB in scudo's secondary allocator with 8 GB of
system memory free, inproc dies at 1.6 GiB. That is the wire arm holding about
twice the bytes (or the mappings) of the monolith, a disaggregation defect
under ID-P7-12's rule, so it stays in the gate-3 denominator; M1 attributes it.
Device window 1 W4 (APK p7w4 = 8c5dd6fc): iterationt x2 pass, the 39-case
inproc matrix has 34/36 of the denominator within 0.0005 of the monolith
reading, bsl-esc-menu still OOMs under inproc (spawn arm pending).
ID-P7-19's cold-driver-cache reading of OpenRA is withdrawn: 26 runs show a
~50 % per-run coin flip that ignores cache temperature, IPC_AUDIT and FIF=1,
reproduces on the two-process spawn arm, and only ever yields three pictures
(golden / 9836 px / 14658 px). The monolith snapshot at the previous swap is
the golden and the palette texture is byte-correct at the snapshot, so the
wrong pictures are frames with a bounded run of draws silently skipped (nine
sprite blobs absent over intact terrain). Gate 3's acceptance for OpenRA is
now six run-ahead and six lockstep runs all equal to the golden; the fix is
package B3.
D2's SubsystemDepsTest called the four Resolve*SubsystemArm resolvers, which
Managers.h declares only under MOBILEGL_PIPE_PUSH (:664), so build-linux
(the pull flavour, ID-P7-11) stopped compiling while the stale libMobileGL.so
kept G1 looking green. Cases 3 and 4 now skip in the pull flavour the way
case 2 already did; the unit name set is the same in both flavours. Pull
build rc 0, .text 0xa52203, symbols 0 added / 0 removed; push flavour 5/5.
P3b/P4b R-5 (and R-3's cheap half), landing under the P7 program (PLAN-PH-P34B-P7.md
1.2, wave 2-D package D2).
The rule had SIX statements and nothing compared them, and two of the four prose ones
were wrong in the same way: MG_Pipe/MGPipe.h and MG_Backend/DirectGLES/Managers.h both
said "THREE OF THEM HAVE A DEPENDENCY" (there are six rows) and both said "the mirror
pairs (10 without 11, ...) are all fine" - when 10-without-11 is precisely D-K2's FOURTH
row (ID-14/ID-15), refused by the server and withheld by the client, with PipeFill.cpp
saying so out loud: "The brief's original 'bit 10 without 11 is fine' is WITHDRAWN for
P4a as built." Neither wrong comment could fail anything; Managers.h's copy sits in the
header of the file that implements the refusal.
The rows, once, in MG_Pipe/SubsystemDeps.def (X-macro, FatalFamilies.def's convention),
expanded by MGPipe.h into kMGPipeSubsystemDependencies[] / MGPipeSubsystemRequires() /
MGPipeSubsystemDependenciesAreSet() with four static_asserts. Six rows: 8->7, 9->10,
10->{7,11}, 11->10, 12->none (a ROW, not an absence), 13->7. Bits 8 and 13 are the two
the old comment did not know about; bit 13's row lived fifteen lines further down in the
same header, which is how "THREE OF THEM" survived two phases.
THE TWO READERS DO NOT YET READ IT, deliberately: MG_Impl/Pipe/PipeFill.cpp is the
contract package's file for the whole phase (TextureEmit.h states that rule) and
MG_Backend/DirectGLES/Managers.cpp belongs to wave 2-D package D1 while it is in flight.
Switching them is two one-line changes for the integrator. The anti-drift property does
not wait: MG_Test/Backend/DirectGLES/SubsystemDepsTest.cpp is the first thing in the tree
that calls BOTH readers at the same mask - sweeping the phase constants, the two
documented refusal lanes and one mask per row - and compares each against the .def. It
lives in that binary because that binary links both sides.
Both comparisons are one-directional, each for a measured reason. The server resolvers
also fold in the family's own bit and D-C3's wire-caps refusal, so a resolver may say NO
for its own reasons but never YES where the table refuses. MGPipeP4aFamilyEmits is a
QUADRUPLE and the first version of this case asserted equality and reddened at 0x1fff on
the sampler and program families because a bare unit binary registers no consumer - the
assertion being wrong, not the product, and recorded in the note because the temptation
to assert equality here is strong.
Red-once (R-16), twice: dropping the fourth row from the .def fails at COMPILE time
(MGPipe.h's static_asserts); making the server's ResolveSamplerSubsystemArm stop checking
bit 10 (throwaway, reverted) reds the runtime case and names the mask - 0x9ff, which is
exactly the lane CMakeLists.txt already pins for G12.
R-3's cheap half: CONTRACT-P5.md claimed the image-access, sampler-view-target and
draw-buffer encodings as contract by pointing at package-header LINE RANGES, and all
three had drifted (ImageEmit.h:146-159 no longer holds the encoding at all). All three
rows now state the encoding by enumerator and function name, and the two confusable
siblings are named. The remaining half - MGPSamplerView::Target still has no Count bound,
no ...IsValid and no Fatal for an out-of-range value - is stated in the row rather than
closed, being an MG_Pipe change outside this package's footprint.
Managers.h:690-695's copy of the wrong prose is NOT corrected here: it is package D1's
file, and its two defects are named in notes/p34b/espryt-d2.md so the integrator can take
them in one line.
G1 after this change: pull .text 10822147 = 0xa52203, nm --defined-only identical to
~/w7/p7-before/pull-syms.txt (30570 symbols) - the new inline constexpr table does not
reach the pull build.
P3b/P4b deepening, landing under the P7 program (PLAN-PH-P34B-P7.md 1.2, "raw-depth-fetch
sampler 的 monolith 清理,步骤 1+2", wave 2-D package D2).
STEP 1 DONE. ContextEpochTest gains a case that drives GetRawDepthFetchSampler() on BOTH
arms in one process and asserts they are two different BackendSamplerObjects, which is the
observable form of the claim DirectGLES.cpp:334-336 makes in prose - "the legacy pair is
not constructed or consulted by the server". A server that had fallen through to
g_rawDepthFetchSamplerBackend would hand back the same pointer under both transports. It
also asserts the transport arm's cached native sampler survives a round trip through the
monolith arm, so neither arm is rebuilding state the other owns.
STEP 2 NOT DONE, AND THE PACKAGE'S OWN CONDITION IS WHY. The instruction was to delete what
the transport arm makes unreachable "ONLY if it keeps the pull build's .text identical".
It does not. Every candidate is UNCONDITIONAL code - the two file-static SharedPtrs
(DirectGLES.cpp:82-83), the monolith arm (:361-371), NeedsRawDepthFetchSampler (:393-399)
and the pre-handle sampler-pass branch (:8015-8024). None sits behind
#if !MOBILEGL_BUILD_DISAGGREGATED; the pull build configures DISAGGREGATED=OFF and
PIPE_PUSH=OFF and links all of it, and the monolith arm is the ENTIRE body
GetRawDepthFetchSampler has there. Deleting any of it moves pull .text and fails G1's
0/0/0/0 - the rule ARCHITECTURE.md:340 states for this exact class of retired-but-compiled
code. Recorded as P13, beside the existing S7 entry in notes/p5f/f0-statics.md, rather
than attempted and reverted.
G1 on this head: .text 10822147 = 0xa52203, nm --defined-only identical to
~/w7/p7-before/pull-syms.txt (30570 symbols).
P3b/P4b deepening, landing under the P7 program (PLAN-PH-P34B-P7.md 1.2, "门:纹理 /
program 场景 split 覆盖", wave 2-D package D2). Each candidate was run once under
MOBILEGL_TRANSPORT=inproc on build-split and its first blocker recorded; the row per
scenario is in notes/p34b/split-scenario-census.md.
Registered (all green on inproc, spawn and tcp): TextureView,
TextureParamsWithoutASamplerView, SampledSetStaleness, ImageSizeAfterRespec,
ImageTargetKind, IntegerBorderColor, SwizzleAccessRoutine, PostLinkAttach,
RelinkStageSet, UniformInitializer, Glsl420Declaration, IoBlockNameCollision. 71 cases
per arm, 213 new gating entries; the recorded split-arm registration count the tier-2
Magma replay walks goes 33 -> 46.
TextureParamsWithoutASamplerView settles the debt table's 待核 row: all four G9 cases
pass under all three transports, so the P6 inspection-forwarder debt is closed for this
half - and it is now re-taken on every CI run rather than by inspection.
ProgramPipelineScenario beyond *StorageBlock* is NOT registered, and that is the rule
working rather than a blocker to retire: the other nine cases are pure name/state cases
that draw nothing, and an armed DirectGLES.Split. case whose workload produced no record
is red by design (ScenarioFixture.h:84-97 - the client encoder's record ordinal must
move). Registering them would assert "records crossed" about cases with no records to
send.
DirectVulkan arms default to none, deliberately, and the note says why: the
informational tier-2 block already replays every recorded registration on DirectVulkan
under integration-magma-all-{split,spawn,tcp}, so these scenarios do get a Magma x
three-transport reading - just not a gating one. Gating them would assert P7 wave 2's
exit gate 1 from inside a P3b/P4b package, on a backend whose own @P7 refusals are still
being retired in waves A/B/C.
D1 owns PixelStoreSweep, DepthStencilReadbackMatrix, PackedWordReadback and
LayeredTextureReadback; they are not in this census.
P3b/P4b deepening, landing under the P7 program (PLAN-PH-P34B-P7.md 1.2, wave 2-D
package D2): "一条纯度 grep 门:split 构建下这些 memo 键里不得出现前端指针类型;接 CI".
scripts/ci/espryt_memo_purity.py, a sibling of link_seam_purity.py in the same style:
a hard-coded source list, a code_only() comment strip, (pattern, message) rules, and
a --self-test that runs the negative controls and then the real scan in one
invocation - which is what lets test.yml pass only --self-test and still get the
gate. Wired in next to link_seam_purity.py.
It scopes the rules to the BRACES of the families the plan names -
ResolvedTextureBindingMemo, SamplerPassMemo, UnitSamplerLookupMemo, TwinLookupMemo,
StateBackendObjectRegistry (the program registry and its five siblings) - plus the
image sweep, which is seven file statics rather than a struct and is matched by name.
Scoping matters: these two files pass frontend pointers around legitimately in
hundreds of places, and a file-wide grep would be a list of exceptions rather than a
gate.
Guard-awareness is a small conditional walker, not a preprocessor: it recognises
#if MOBILEGL_PIPE_LEGACY_MEMOS, #if !MOBILEGL_BUILD_DISAGGREGATED and the #else of
#if MOBILEGL_BUILD_DISAGGREGATED, which is every spelling these files use, and says
so in the docstring.
Six allow-list entries, each carrying its one-line reason. The two that matter:
StateBackendObjectRegistry's UnorderedMap<StateObject*, Entry> and
ResolvedTextureBindingMemo's type-erased `const void* program` are the PULL build's
only arms - deleting them moves pull .text and fails G1's 0/0/0/0, so they retire
with the pull path (P13), not here. The other four are memo VALUES rather than keys.
Seven negative controls turn red; four positive controls stay green, including a
LEGACY_MEMOS arm, the #else of a DISAGGREGATED arm, the allow-listed type-erased key
and a frontend pointer outside every memo family. Verified once more by injecting a
fresh unguarded ProgramObject* field into the real ResolvedTextureBindingMemo: the
gate reddens and names it.
P3b/P4b R-11 / D-D4, landing under the P7 program (PLAN-PH-P34B-P7.md 1.2, wave 2-D
package D2). P4a recorded the emission shape rather than gating it because the
per-storage-owner cursor with view/owner remapping was the next phase's and gating an
unfinished shape pins a non-answer. That work landed, so the RecordProperty block is
now EXPECT_EQ against a gold row MEASURED on this cursor, and the workload carries a
third texture written through a glTextureView so the remap is inside the counted
window.
The gold row, identical on monolith / inproc / spawn / tcp:
emit=9 box=9 rect=0 jobs=9, client emitter 9
keyed on the workload constants by static_assert. P4a's emit=6 box=6 rect=0 jobs=6
was a two-texture workload with no client emitter and is not comparable.
The client half is read from MGPipeTextureEmitter::SubDataCount() rather than from
ctu=, and that is a correction: under spawn and tcp the two roles are two processes
whose PipeStats windows close on different Present cadences (measured ctu=13 against
emit=9, and usually not yet written when the case reads), so ctu == emit is not a
well-posed comparison there. The log field is kept as a record and asserted only
where the transport says the two are window-aligned - asking the NUMBER was the first
mistake, because the server publishes a readable ctu=0 under spawn and the comparison
silently became 0 == 9. PipeStatsWindow gains LastFromClientLog/LastFromServerLog.
Split/spawn/tcp arms added as explicit per-arm blocks rather than through
mgl_itest_register_split_arms: the macro hands every arm the shared environment, and
this case needs a lane marker, PipeStats period 1, a push mask and above all a
MOBILEGL_LOG_FILE_PATH private to the arm - the library opens the log "w", so two
entries sharing a path truncate each other and the failure looks exactly like "the
counter was never emitted".
Red-once (R-16): the prescribed flip of summedArea*4 >= unionArea*3 leaves the gate
GREEN, and that is a finding. Two gates downstream dominate it - the server needs
dirtyRectCount >= 2, and UnpackRingAvailable() zeroes the rect list by design ("one
box, one job", the existing Mali-cliff mitigation). So rect=0 is the policy's
intended output, not an accident of the scatter pattern. The red-once that bites
splits the union box in two AND disables the unpack ring: box=6 rect=3 jobs=12
against the gold, while 26 pixel cases stay green. Four attempts and their reasons
are in the note.
The Mali frame-time delta D-D4 asks for beside the gold is NOT schedulable - one
Redmi, an Adreno, no Mali part - and is recorded as a declared deviation in the
scenario header and MEASUREMENTS.md 13.3 rather than modelled.
Also corrects the three places that said the client emitter had not landed: the
scenario header, the CMake probe's comment, and gates-v1.md's re-run table.
TextureEmit.h's EmitOneLevel has been incrementing it.
P3b/P4b deepening, landing under the P7 program (PLAN-PH-P34B-P7.md 1.2, wave 2-D
package D2). The code half of "按存储属主键控的发射游标与 view 索引重映射" landed in
P4a/P5e; this is the verification half it never had.
TextureViewAliasScenario, four cases: an upload through a layer-restricted view
sampled through the owner and the reverse, each once and again across draw
boundaries so the drain runs three times. Each case asserts on TWO channels at
once - sampled texels AND MGPipeTextureEmitter's SubDataCount / RefusedSubDataCount
/ DrainListSize, read through the new Harness/TextureEmitPeek. The counters are the
load-bearing half: a level that never made it onto the drain list is still uploaded
by the next whole-level respecify, by a glGenerateMipmap or by the monolith sync
path, so the picture comes out right one frame late and a pixel-only case goes green
over the regression on most trees.
Registered in the three GLES split arms, and MOBILEGL_ESPRYT_ENABLE_TEXTURE_VIEW=1
added to MGL_ITEST_GLES_{SPLIT,SPAWN,TCP,TCPDEVICE}_ENVIRONMENT - views are Espryt's
opt-in and without it every case green-skips. The Tcp arm needed the knob on the
TcpServer.Start fixture as well: that lane's server is pre-started rather than
spawned from the client, so it withheld GL_ARB_texture_view and all four cases
SKIPPED while the identical Split and Spawn arms ran.
Red-once (R-16): TextureObjectView::MarkStorageDirtyRegion made to call
PipeNoteLevelDirty on ITSELF instead of forwarding to the storage owner - the
two-cursor shape. TheViewUploadDrainRunsAgainAfterADrawBoundary goes red on both
channels simultaneously (stale owner texels AND SubDataDelta() == 0). The plan
predicted case (a); it is (c) that reds, because the first drain of a texture's life
carries the whole level anyway - recorded in the note.
TheOwnerUploadDrainRunsAgainAfterADrawBoundary is EXCLUDED BY NAME from the split
arms: it is red on all three transports and green under monolith, because an
owner-side resource_subdata moves only the owner's record.Serial and nothing
propagates that to the records viewing it. Cause and the two candidate fix sites are
named in the CMake comment and in notes/p34b/espryt-d2.md. The name stays in every
lane (G14).
P3b/P4b espryt D1 slice 4, landing under the P7 program (notes/p7/PLAN-PH-P34B-P7.md
§1.2 row "回读 / pack state", §3 wave 2-D).
(a) THE CENSUS, AND IT CAME BACK CLEAN. Twenty-two cases per arm now run on the three split
arms: the two F1 pack-state cases, PixelStoreSweepScenario, FramebufferChurnScenario,
PackedWordReadbackScenario, LayeredTextureReadbackScenario and
DepthStencilReadbackMatrixScenario. The row expected reds and said so ("Expect reds - that
is the point"); the first run was 65/65 before any of this commit's code changes. That is
the finding: the 回读 half was assumed open because nothing had ever run it under a
transport, and the assumption was what was wrong - ScatterTightReadbackIntoPackState and the
server's neutral-pack read were already carrying it. No case needed a fix and no case needed
a named deferral.
DepthStencilReadbackMatrixScenario gets a per-arm block of its own, because the registration
macro cannot carry MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION=1 and without that pin the
scenario is unfalsifiable here: ES has no core depth/stencil readback, Mesa accepts the
reads anyway, and on llvmpipe every case goes green through a path the device does not have.
Its SeparateDepthAndStencilAttachmentsAreBothReadable SKIPS on all three arms and on monolith
too, naming the driver reason itself - a driver fact, not a transport one.
(b) THE TWO std::abort EMULATIONS, checked rather than assumed. copy-image-shadow-mirror
ALREADY cannot abort: MirrorCopyImageIntoDestinationShadow returns under a transport before
reaching MGPipeUnmigratedEmulation, and has since P5b package i1. get-tex-image-shadow is
retired here, and the reason is that its own premise did not hold: P4a wrote "a split server
holds no shadow to convert", but the level storage this converts IS the server's own, fed by
the staged texture store. When MapMipmapData answers, the conversion reads bytes that exist
on this side. So it converts when the shadow is there and DECLINES BY NAME when it is not
(rule I: MGLOG_E_ONCE + GL_INVALID_OPERATION, destination untouched). std::abort inside a
server answering a client's readback takes the session down for a level it happens not to
hold, and bypasses Session::Fail, which is the funnel the census gate watches. The funnel
keeps its teeth for the other four sites - generate-mipmap-storage,
generate-mipmap-cpu-fallback, generate-mipmap-cpu-filter, texture-remint-pull - which really
do reach into a client address space; fatal_census is unchanged at 79 abort sites because
the abort lives in MGPipeUnmigratedEmulation and that function stays. Stated plainly in the
notes: no case in the armed census reaches this site under a transport (the server answers
GetTextureImage with ReadTextureImageWire, never through the backend's GetTexImage slot), so
the change is defence in depth and has no integration red-once of its own.
(c) THE CASE NOTHING COVERED, and this slice's red-once:
PixelStoreSweepScenario.ReadPixelsIntoAPackBufferHonoursPackStateAndLeavesTheGapsAlone -
glReadPixels into a bound GL_PIXEL_PACK_BUFFER at a non-zero buffer offset with
PACK_ALIGNMENT/ROW_LENGTH/SKIP_ROWS/SKIP_PIXELS all non-default, read back with
glMapBufferRange. It is the only thing that drives EmitTables.cpp's `if (pbo)` branch, the
client servicing the pack PBO itself after the reply lands; the existing sweep case reads
into a client pointer, which is the other half and has unit cover. Every number makes a term
matter: ROW_LENGTH 5 at 4 bpp is 20, which ALIGNMENT 8 rounds up to a 24-byte stride;
SKIP_ROWS 1 and SKIP_PIXELS 2 put the first pixel at image byte 32; the read is issued at
buffer offset 16, so it lands at 48. The whole buffer starts 0xCD and the case asserts both
halves - the rows land where the pack state says, and every other byte is still poison.
Red-once (R-16): dropping just the buffer-offset term from EmitTables.cpp's `start` reds the
three split entries with "readback[at] Which is: '\xCD' (205)" - the destination still poison
because the rows landed 16 bytes early - while the ambient monolith entries pass, since the
driver services its own PBO there. EmitTables.cpp is not part of this commit.
(d) SplitReadbackPackBuffer's comment claimed "The client refuses PACK_BUFFER before
emission". It does not: it KEEPS the PBO and services it after the reply, and a refusal would
have been observable as a GL error. The conclusion (the server must see no pack buffer) was
right and the reason was backwards; rewritten to ID-49's actual split of the work.
Gates: integration-split 225/225 (base 186), integration-spawn 141/141 (base 102),
integration-tcp 144/144, ctest -L unit 2408/2408, integration-magma-split 73/73,
spawn_lane_parity / fatal_census (79 sites, 0 unmarked) / link_ratchet (186, unchanged)
green, G1 .text 0xa52203 with both nm lists byte-identical to ~/w7/p7-before.
P3b/P4b espryt D1 slice 5, landing under the P7 program (notes/p7/PLAN-PH-P34B-P7.md
§1.2 row "g_fboTextureSyncList", §3 wave 2-D).
ResolveFramebufferSubsystemArm and its three siblings classify a family's arm as Handles,
Legacy or NoArm and stop only on NoArm. Under an active transport Legacy is not a slower
answer, it is a postponed crash: all four pre-handle arms reach frontend state through
accessors that are BARRIER_PULLED rows (FieldOwnership.def), and under a transport that
state belongs to a client that is no longer parked behind this apply, so the accessor's
honest answer is Fatal{UnmigratedPipeInput, "<field>@<verb>"} on the first verb that reaches
it. That is the audit's own claim about g_fboTextureSyncList - "separation-safe, because the
record arm returns first" - which holds only while the record arm is ARMED, and nothing
checked that.
Each of the four now stops, under #if MOBILEGL_BUILD_DISAGGREGATED, when the transport is
not monolith and the verdict is not Handles, through one shared
StopOnTransportWithoutRecordArm(bit, detail) that formats both causes and hands them to
BufferImpl::StopOnArmlessPipeSubsystem - this file's existing single voice for "the
configuration left no arm to run". A transport with the record arm off IS that condition
with a different cause: the legacy arm is present but unreachable. Reusing the voice keeps
the census's family word and puts the cause in the message rather than in a new word the
census would have to learn.
Said precisely, this is a RESOLVE-TIME stop and not a startup one. The four resolvers are
function-local statics resolved lazily at first use, deliberately - Managers.cpp's own
comment says a stop inside eglMakeCurrent reads to the harness as "no usable GPU" and skips
- so the stop still lands in a scenario body where ctest reports it. What it no longer does
is wait for a verb to dereference the client's state and then blame the accessor.
Red-once (R-16), and it is the safety argument the audit asserted but never ran:
MOBILEGL_PIPE_PUSH=0x1dff (the Handles lane's 0x1fff minus 0x200,
kMGPipeSubsystemFramebuffer) with MOBILEGL_TRANSPORT=inproc, running
ClearThenReadPixelsScenario.ClearWithNoDrawIsVisibleToDefaultFramebufferReadPixels. Both
runs abort; what changes is what the abort says. Without the stop:
Fatal{UnmigratedPipeInput, "GetFramebufferBindingSlot@Clear"} - mid-frame, naming an
accessor and a verb, with nothing pointing at the mask. With it:
Fatal{PipeLegacyMemosDisabled, "MOBILEGL_TRANSPORT is not monolith and
kMGPipeSubsystemFramebuffer (bit 9) is clear (or refused), so the record arm is off and the
legacy arm is unreachable: ..."}.
Gates: integration-split 202/202, integration-spawn 118/118, integration-tcp 121/121,
ctest -L unit 2408/2408, integration-magma-split 73/73, spawn_lane_parity / fatal_census (79
sites, 0 unmarked) / link_ratchet (186, unchanged) green, G1 .text 0xa52203 with both nm
lists byte-identical - the four blocks are inside MOBILEGL_BUILD_DISAGGREGATED and the pull
build compiles none of them.
P3b/P4b espryt D1 slice 3, landing under the P7 program (notes/p7/PLAN-PH-P34B-P7.md
§1.2 row "XFB scatter" item 3; CONTRACT-P7.md §139 already records the deletion).
Plan B section 7.1 named ten reverse-channel callbacks. The tenth, OnXfbScatterReady,
belonged to a design the tree does not have: plan B put the XFB scatter on the CLIENT - the
server would hand back the packed scratch and the client would run the patch loop over its
own shadow - so something had to tell the client the layout. P5c/P5f went the other way,
and the declaration was left behind with zero producers, zero consumers and no EventKind of
its own, while the static_assert beside kMGPipeCallbackCount made it look load-bearing.
Deleted; kMGPipeCallbackCount 10 -> 9.
Three pieces of prose that still described the old design go with it. MGPipeCallbacks.h's
own header said "ten callbacks", and now says nine and why the tenth went.
MGPipeTypes.h's MGPXfbAccounting comment said the scatter "is a read-modify-write of the
client's shadow and lives there"; it lives on the server's staged shadow, and nothing about
the scatter crosses in that record. ARCHITECTURE.md §8.5, titled "XFB scatter 搬到 client",
is rewritten to what the tree does: the pre-capture bytes are the server's own staged
shadow (rule C, since MappedData() on the apply thread is Fatal{RoleViolation,
"buffer-legacy-arm"}), the reconciled whole range comes back as one OnBufferWriteback
sliced against SEG_EVENT's capacity (slice 2), and an orphaned target is a legal one (slice
1). Its row in §8.1's callback table goes too - leaving it would be a new staleness rather
than an inherited one.
PipeCatalogue.ReverseChannelHasTenCallbacks now asserts 9. The CASE NAME IS DELIBERATELY
NOT RENAMED: G2/G14 say the ctest name set only ever grows, so renaming would delete a name
the gate watches. The count it asserts is what has to be right and the comment carries the
number.
Red-once (R-16): with the member and the count restored the case fails with
"kMGPipeCallbackCount Which is: 10 / 9u Which is: 9", which is what makes the struct's
shrink a measured fact - the static_assert beside the constant only proves the two agree
with each other.
G1 matters for this slice in particular, because MGPipeCallbacks.h is included from
MG_Backend and the struct is NOT behind MOBILEGL_BUILD_DISAGGREGATED, so
sizeof(gMGPipeCallbacks) really does shrink by 8 bytes in the pull build. The shrink is in
.bss and nothing indexes the table by count: .text is still 0xa52203 and both nm lists are
byte-identical to ~/w7/p7-before.
Gates: integration-split 202/202, integration-spawn 118/118, integration-tcp 121/121,
ctest -L unit 2408/2408, spawn_lane_parity / fatal_census / link_ratchet (186, unchanged)
green.
P3b/P4b espryt D1 slice 2, landing under the P7 program (notes/p7/PLAN-PH-P34B-P7.md
§1.2 row "XFB scatter", §3 wave 2-D).
Both XFB writeback producers posted ONE unbounded OnBufferWriteback covering the whole
bound range - ScatterCapturedRecords from its staged vector, ReadbackCapturedRanges from a
live glMapBufferRange mapping. ServerOnBufferWriteback turns each into one SEG_EVENT record
of sizeof(EventBufferWritebackHead) + bytes, and RingProducer::Reserve refuses any record
above Capacity()/2 outright - not "when full" but always, because above half a capacity a
record is placeable at some head offsets and not at others and waiting for room would be a
hang. EventRingBytes defaults to 256 KiB, so any capture target wider than ~128 KiB was
Fatal{EventRingOverflow} and the SERVER PROCESS DIED, on a buffer size, with nothing else
wrong. The producers' own Fatal text already said what to do: raise MOBILEGL_IPC_EVENT_KB
or slice the event at its producer, "as the writeback path already does" - which was true
of the client's request path and of nothing on this side.
MG_Pipe now carries the one number, published the way gMGPipeSegmentResolver is published:
gMGPipeEventRingCapacityBytes, installed by ServerSession::Accept beside the four reverse
producers and released at Close only if it is still ours, and
MGPipeBufferWritebackSliceBytes() which turns it into a per-record width - a quarter of the
ring with a 4 KiB floor, the same number and the same reasoning as the client-linkage
BufferWritebackSliceBytes one ring over. The backend can reach neither MG_Remote::Client
(wrong linkage) nor ServerSession (wrong layer), so the hook is the seam; 0 means "do not
slice", which is monolith, where the callback is a direct call with no ring under it.
Both producers loop against ONE helper, XfbWritebackSliceStep, because a second spelling of
the width is how one of them ends up still killing the server. The readback site slices
inside its mapping and still unmaps once; the scatter site slices the staged vector and
keeps the single glBufferSubData whole, since the ES store has no ring and cutting it would
change how many backend calls one application call makes. In-order delivery makes the last
slice's landing imply every earlier one, so the client needs nothing new.
Red-once (R-16): XfbAfterClipDistanceScenario.{AScatteredCapture,APlainCapture}
IntoAMegabyteRangeReachesTheApplication - a 1 MiB bound range, about eight times what one
record can ever be, with the same six capture records; glBindBufferBase over the whole
store is what makes the mirrored range a megabyte. Two cases for the two producers. With
the slicing reverted all six split entries go red on Fatal{EventRingOverflow}
("kEventBufferWriteback needs 1048600 bytes and SEG_EVENT caps ONE record at 131072") -
aborting the test process under inproc, losing the peer under spawn and tcp - while both
monolith controls stay green.
XfbAfterClipDistanceScenario and XfbRepeatedCaptureScenario now join the three split arms
in full; slice 1 could register only OrphanedCaptureTarget* because the rest could not
survive the unsliced event. XfbRepeatedCaptureScenario's gl_NextBuffer compacted-target
shape is the first thing outside the census to exercise the archive-served stride and
varyings (ProgramArchive->Link's xfb fields) on the server, and it passed on all three arms
with no further change.
Gates: integration-split 202/202, integration-spawn 118/118, integration-tcp 121/121,
ctest -L unit 2408/2408, the monolith and split XFB families 120/120, spawn_lane_parity /
fatal_census (79 sites, 0 unmarked) / link_ratchet (186, unchanged) green, G1 .text
0xa52203 with both nm lists byte-identical to ~/w7/p7-before.
P3b/P4b espryt D1 slice 1, landing under the P7 program (notes/p7/PLAN-PH-P34B-P7.md
§1.2 row "XFB scatter", §3 wave 2-D).
ScatterCapturedRecords is a read-modify-write of the capture destination's pre-capture
bytes - a gl_SkipComponents hole has to come back holding whatever the application left
there - and under a transport those bytes are the server's staged shadow rather than a
frontend MappedData(). The split arm read a null shadow as "this target has no
pre-capture bytes, discard it". A null shadow is not an error: MGPResourceDesc's
HasDefinedContent == 0 is the store the application ORPHANED with glBufferData(size,
NULL), and Managers.cpp's own M-3 comment says every byte it has not staged since is
undefined by its own declaration. So the continue threw a whole capture away for the
ordinary streaming idiom (orphan, upload part of it, capture into it), leaving the
application's buffer holding its pre-draw bytes under GL_NO_ERROR. The partly uploaded
shape did not even reach the continue: it has a shadow, so it asked RequireStagedCoverage
for the whole bound range and died on Fatal{StageSnapshotTooNarrow,
"xfb_scatter_pre_capture"}.
Monolith has always been right here and is right by construction: `Vector<Uint8>
staged(rangeBytes)` is value-initialised, so scattering over an unreadable source scatters
over zeroes and uploads them, which is byte for byte what a fresh MappedData() hands back.
The split arm now skips only the Memcpy, never the target. Discard-with-log is kept for
splitResource == nullptr alone, which really is impossible and has no backendId to upload
into either. RequireStagedCoverage is guarded by ResourceContentIsDeclared, exactly as
Managers.cpp's pool-reuse ladder guards its own whole-store MGL_SERVER_STAGED_REQUIRE: a
declared store still Fatals on a coverage gap, an orphaned one does not.
ResourceContentIsDeclared moves out of Managers.cpp's anonymous namespace to namespace
scope beside RequireStagedCoverage and is declared in Managers.h. The two callers must not
be able to answer "did the application declare these bytes" differently. Both live inside
#if MOBILEGL_BUILD_DISAGGREGATED, so the pull build compiles neither.
Red-once (R-16): XfbAfterClipDistanceScenario.OrphanedCaptureTarget{WithAPartialUpload,
WithNoUploadAtAll}KeepsItsVaryings, registered on the three split arms under the Xfb.
prefix. With the fix reverted the six split entries go red - the partial-upload three on
Fatal{StageSnapshotTooNarrow, "xfb_scatter_pre_capture"} ([0, 696) of a buffer whose
staged coverage does not include it), the whole-orphan three on a captured varying that is
still zero - while the two ambient monolith entries pass, which is what makes it a
transport fact. Details and the lane numbers in docs/Disaggregated/notes/p34b/espryt-d1.md.
The narrow filter is deliberate: the rest of XfbAfterClipDistanceScenario is a DirectVulkan
allocation-pattern regression that has never run under a transport, and it joins the arms
in slice 2 once the writeback event it posts is sliced.
Gates: integration-split 188/188 (186 + 2), integration-spawn 104/104, integration-tcp
107/107, ctest -L unit 2408/2408, the monolith DirectGLES XFB + readback families 66/66,
spawn_lane_parity / fatal_census / link_ratchet (186, unchanged) all green, G1 .text
0xa52203 with both nm lists byte-identical to ~/w7/p7-before.
Exit gate 3, OpenRA on the Redmi. The mechanism, in two lines of this tree:
WireFramebuffer.inc, ReadWirePixels: `if (HasPendingRecordedWork() &&
!FlushPendingCommands())` - FlushPendingCommands (VulkanRenderer.cpp:13534-13598)
puts the frame's draws on the queue under a POOLED FENCE OF ITS OWN and returns
without waiting; there is no vkWaitForFences on its success path. The
vkCmdCopyImageToBuffer that follows is recorded into a fresh command buffer and
becomes a LATER submission.
VulkanRenderer.cpp:10707-10748, SubmitReadbackCommandsAndWait: it waited ONE fence,
its own, and then called OnSubmitsCompletedUpTo(frame.lastSubmitIndex) - which
RECORDED the earlier submission as complete and retired its fence, so afterwards
nothing could observe that it had not been waited for. The comment beneath it said
"the wait proved every submission complete". That is true of the monolith arm, which
never flushes before a readback and therefore has its draws and its copy in one
submission under one fence. It was not true of the wire arm.
The only thing tying the two submissions together was the pipeline barrier that the
readback's TransitionWireImage records - and that barrier derives its SOURCE scope from
the image's tracked layout, through GetImageTransitionSourceState
(VulkanRenderer.cpp:1784-1825). Two of its arms, VK_IMAGE_LAYOUT_UNDEFINED and
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR (:1787-1791), return VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT
with no access bits, which is Vulkan's spelling of an EMPTY first scope: wait for
nothing. A just-presented default framebuffer is in the second of those layouts. The
slower pipeline creation is, the longer the draws' submission runs, and the more of the
frame's LAST-drawn objects the copy can miss - which is what the device's diff image
shows.
Measured on this host (OpenRA, DirectVulkan, inproc), at the moment the copy is
recorded: submit=72 completed=71. The submission carrying the whole frame's draws is in
flight and unwaited. That is structural, not a coin flip, and it reproduces every run.
The fix is two halves, both wire-only:
- RecordWireReadbackWriteBarrier (WireFramebuffer.inc, new): the readback records its
own ALL_COMMANDS -> TRANSFER, MEMORY_WRITE -> TRANSFER_READ|MEMORY_READ barrier
before the copy rather than inferring from a layout who it should wait for. A
barrier's first scope is every command earlier in SUBMISSION order on the queue, so
it reaches across the submission boundary.
- SubmitReadbackCommandsAndWait now waits every in-flight fence at or below its own
submission index. Under #if MOBILEGL_BUILD_DISAGGREGATED: the pull build has no wire
arm, cannot flush before a readback, and is the image G1 pins - its single-fence wait
is unchanged. The cost is nothing a readback does not already pay.
THE COORDINATOR'S REPRODUCTION PATH DOES NOT WORK ON THIS HOST, and that is recorded
rather than glossed. MOBILEGL_TEST_PIPELINE_CREATE_DELAY_MS is added as asked
(split-only, inert unless set, on the pipeline-creation MISS path in
GetOrCreatePipelineWithInput) and it does bite - the OpenRA DirectVulkan SPLIT retrace
goes from 1.88 s to 4.53 s at 25 ms - but ssim stays 1.000000. The reason is in the
probe above: this host's default framebuffer is tracked as VK_IMAGE_LAYOUT_GENERAL at
the readback, which takes GetImageTransitionSourceState's `default:` arm (ALL_COMMANDS /
MEMORY_WRITE) and orders the copy by accident. The device's is not GENERAL.
Red once (executed, reverted) is therefore a COUNT, not a picture, and it is
deterministic: instrumenting how many fences the readback actually waits on gives
fences=2 upTo=73 completed=71 with the fix (submission 72, the draws, plus its own 73)
and fences=1 without it, with completed=71 either way - i.e. 72 provably outstanding and
provably unwaited before.
Lane: three readback-after-work cases (VertexIdSamplerAndScalarUniformPixels,
ComputeImageStoreFramebufferPixels, GenerateMipmapPixels) registered on the three Magma
arms under MOBILEGL_TEST_PIPELINE_CREATE_DELAY_MS=25, prefix .PipelineDelay. They are a
REGRESSION GATE, not the reproduction: they pin that a readback still returns the draw's
pixels when every pipeline creation on the path costs 25 ms.
Gates: integration-magma-split 84 (+3), -spawn 63 (+3), -tcp 65 (+3), 0 red;
ctest -L unit 2410; informational integration-magma-full-split 516, 0 red, 59 skip;
fatal_census green; link_ratchet --assert-monotone unchanged at 186, p7-magma 101;
G1 pull .text 0xa52203, defined-symbol name list identical (30570); OpenRA DirectVulkan
SPLIT retrace ssim 1.000000 with and without the delay, two runs each.
Owed to the device: the three cold-cache repeats. If they still diverge, the next two
suspects are named in notes/p7/magma-b.md §5.6 - the eleven silent `return false` drop
sites in SetupWireDraw (one of which is `pipeline == VK_NULL_HANDLE`, on the same path
as the device log's three "handing vkCreateShaderModule an INVALID module" errors), and
SetImageContentDefined being set BEFORE the draw is recorded on the wire arm while the
monolith sets it from EndRenderPass, which silences the "no write is recorded" warning
exactly when it should fire. Neither is in this commit's scope.
CONTRACT-P7 §3.2's `default-color-blit-shape@P7`, retired in three pieces, because one
Fatal was covering three unrelated situations.
The shader pass it guarded is there for ROTATION and nothing else: it can only SAMPLE
its source - sampler2D, a float result, one of two 2D view types - so an RGBA8UI
attachment, a 3D slice or an array layer made it return false. The refusal then fired
EVEN ON A SURFACE WITH NO ROTATION, where the ordinary vkCmdBlitImage arm twenty lines
below would have done the blit and does handle those shapes.
- identity: fall through to that arm;
- a non-identity rotation with a source the pass cannot sample: move the region into an
owned 2D single-sample image of a sampled-float format in the SAME size-compatibility
class and shader-blit that;
- outside the four rotations (a mirror, or a mirror-rotation): decline by name, saying
which transform. Neither arm can express a reflection and this package has no device
that reports one to check a result against.
Reinterpretation is the point for the integer case and GL agrees: glBlitFramebuffer
between an integer and a normalized format is INVALID_OPERATION (4.6 core 18.3.1), so
there is no defined result to be wrong about - only a shape that used to end the session
and now produces the source's bits. For a float source of the wrong view type the copy
is a plain slice extraction and the result is exactly right.
THE RETIREMENT UNCOVERED A REAL BUG, which is the part worth reading. The ordinary blit
arm addressed a 3D source as `{aspect, level, source.layer, 1}` with z 0..1. A 3D image's
slice rides Z; its only subresource is layer 0 - which is how the copy endpoint's
CopyImageSliceMapping and the multisample resolve arm above already address one. So a
blit from slice 1 of a 3D texture read slice 0, out of a layer the image does not have.
It was invisible because that shape hit the Fatal first and never reached the arm: the
new scenario's first run had the scratch arm correct and the fall-through arm wrong in
all four quadrants, which is how it fell out. Both arms agree now.
The scenario's source is slice 1 of a 3D texture - ordinary GL, and exactly the shape the
shader pass cannot sample - with slice 0 filled in a colour that appears in no quadrant,
so a scratch copy that forgot the z origin would still be reading real texels and had to
be made distinguishable. It is registered twice on each arm: plain, taking the identity
fall-through, and under MGITEST_MAGMA_FORCE_DEFAULT_BLIT_SCRATCH, taking the rotated
arm's scratch path on an identity surface, where it must produce THE SAME PIXELS. That
equality is the assertion, and it is the only way the scratch arm is reachable from a
host lane at all: every surface this lane can create reports
VK_SURFACE_TRANSFORM_IDENTITY, as does the Redmi pbuffer this cluster's evidence comes
from. The knob does not fake a rotation - nothing downstream is told the surface turned.
Red once (executed, reverted): with MagmaWireFatal("default-color-blit-shape@P7")
restored, both entries die by that name; without it, 2/2 green.
Gates: integration-magma-split 81 (+2), -spawn 60 (+2), -tcp 62 (+2), 0 red;
ctest -L unit 2410; informational integration-magma-full-split 516, 0 red, 59 skip;
fatal_census green; link_ratchet --assert-monotone unchanged at 186, p7-magma 101;
G1 pull .text 0xa52203, defined-symbol name list identical (30570).
Owed to a device window: the scratch arm's geometry under a REAL ROTATE_90/180/270. The
knob proves it produces the fall-through's pixels on an identity surface, which exercises
every line of it except the rotation constant the vertex shader applies.
WireColorBlit.inc and VulkanRenderer.h are edited outside this package's named file list.
Both belong to this cluster (the colour blit's implementation, and four private
declarations beside the existing blit ones); flagged for the integrator.
CONTRACT-P7 §3.2's `copy-image-in-place@P7`, retired.
glCopyImageSubData between two subresources of ONE texture is ordinary, defined GL -
4.6 core 18.3.2 spells out that a texture and a view of it are two objects over one
image and may be copied between. The wire arm took the session down for it, and the
reason it gave was true of the code beneath it and of nothing else: the
TRANSFER_SRC/TRANSFER_DST pair cannot describe one image, because both endpoints share
one tracked VkImageLayout and the second transition undoes the first.
VK_IMAGE_LAYOUT_GENERAL can describe it - both sides of vkCmdCopyImage accept it. So a
same-image copy now takes ONE transition of the whole image to GENERAL (the source level
and the destination level are both inside it), GENERAL on both sides of the command, and
ONE restore, with an access mask carrying both directions because that one barrier is
the edge for both. The monolith arm still declines every in-place copy a few lines up,
so the wire arm is the one that performs this - which is what §3.2 asks for rather than
parity. `inPlace` is constexpr false in the pull build so every branch it guards folds:
this function is in the monolith image and G1 pins that image's .text.
The one shape that stays a decline is same image + same level + overlapping slices +
overlapping rectangle, all three at once. GL leaves that undefined, and GENERAL makes
the command legal to RECORD rather than meaningful to execute - vkCmdCopyImage orders
nothing between its own reads and writes inside a region, so a tiler produces whichever
half of each texel it reached first. Level 0 to level 1, disjoint layers, and disjoint
rectangles of one level are all defined, and all performed.
Two split-only cases on the three Magma arms, over one 8x8x2 two-level array texture
whose every touched subresource is a different colour - a flat fill would let "copied to
the wrong level", "copied to the wrong layer" and "did not copy" all read as correct.
CopyImageInPlaceAcrossLevelsAndLayers does level 0 -> level 1 and then layer 0 -> layer 1
and checks the untouched subresources too; CopyImageInPlaceOverlapDeclinesAndLeavesTheLevelAlone
copies (0,0)-(4,4) onto (2,2) and pins every texel unchanged - an executed copy would
turn the texel at (4,4) from yellow to red, so the assertion can tell.
Red once (executed, reverted): with MagmaWireFatal("copy-image-in-place@P7") restored,
both entries die with Fatal{UnmigratedVerb, "Magma:copy-image-in-place@P7"}; without it,
2/2 green.
Gates: integration-magma-split 79 (+2), -spawn 58 (+2), -tcp 60 (+2), 0 red;
ctest -L unit 2410; informational integration-magma-full-split 515, 0 red, 59 skip;
fatal_census green (79 sites / 43 words / 0 unmarked); link_ratchet --assert-monotone
unchanged at 186, p7-magma 101; G1 pull .text 0xa52203 and the defined-symbol name list
identical (30570) - the constexpr-false branches fold as intended.
This tree's integration TCP endpoint moved to 127.0.0.1:40913: a sibling worktree's
libMobileGLServer was already listening on pipe's default 40613 and took the whole tcp
lane down at TcpServer.Start. It is a local configure option, not a lane semantic.
CONTRACT-P7 §5.1's (A) and (D), and (D) is what makes (A) safe to have.
(A) WireDepthMipmap.{vert,frag,inc}. VK_FORMAT_FEATURE_BLIT_DST is OPTIONAL for depth
formats, so a depth chain through a shader is the ordinary case and not the exotic
one - the monolith arm has carried such a fallback since before the split. The wire
arm had none, so a depth texture the device would not blit died on the first
disjunction of `mipmap-shader-format-or-shape`, and a DEPTH|STENCIL one died a guard
earlier as `depth-stencil-mipmap@P7` whether or not it could be blitted.
The monolith's fallback is not reusable, which is the whole of (A): it builds a GL
ProgramObject, two ShaderObjects and a GL sampler, binds through the uniform manager's
global UBO and writes uSrcTexelSize as a default-block uniform - four frontend objects
and a GL compiler on the server's apply thread. The baked program is the same shader
with the same arithmetic on a push constant and its own descriptor set. The fragment
stage is a PORT and not a rewrite: §3.2 retires a refusal by making the wire arm
observably the monolith's, so the 2x2 box lands where the monolith's lands, including
the half texel its ivec2(texCoord * srcTexelSize) sits off the destination centre.
`depth-stencil-mipmap@P7` therefore splits. DEPTH-only retires into the baked program.
DEPTH|STENCIL declines - MGLOG_E_ONCE and return, before the chain is grown, so a level
this verb will not fill is not created and then recorded as defined. A decline rather
than a Fatal because the monolith does no better: its fallback asserts depth-only, and
an assert is nothing in the release build both arms ship as. Nothing in GL promises a
stencil mip either, and this pass writes gl_FragDepth - it would leave the stencil
aspect of the level it just produced undefined.
(D) MOBILEGL_BAKED_INTERNAL_SHADERS. A baked header is a compiler output committed with
its input beside it and nothing connecting the two: edit the source and the build stays
green while the shader that runs is the one from before the edit. MG_Test's
BakedInternalShadersTest recompiles all eight arrays - WireColorBlit x2,
WireDepthMipmap x2, the iterationRP witness, the three probe stages - from their
committed GLSL with the in-tree glslang and compares word for word.
THE GATE IS THE BAKER. scripts/bake_internal_shaders.py compiles nothing; it runs this
binary with MOBILEGL_BAKE_INTERNAL_SHADERS and splices the words it emits. A gate that
used the glslang LIBRARY while the baker shelled out to a glslangValidator BINARY would
be two compilers with no rule about which is right, and the day their defaults diverge
the gate reds on a header nobody can regenerate.
What the first run measured, which is the one thing nobody could know beforehand:
WireColorBlitSpirv.h and all three arrays of PrimitivesGeneratedNoXfbProbeSpv.h were
already fresh - the hand-run glslangValidator words reproduce exactly. The iterationRP
witness was stale, as its own prose said; it is regenerated here (2142 -> 2143 words),
and the witness magic 0x50323033 is still there because it is a constant in the .comp
rather than a property of the words. All three provenance comments now point at the
script and the gate instead of at a tool on somebody's PATH.
Scenarios, on the three Magma arms: GenerateMipmapDepthWithoutNativeBlitPixels runs
DEPTH_COMPONENT24 and DEPTH_COMPONENT32F under MGITEST_MAGMA_FORCE_SHADER_MIPMAP=1 and
reads level one back through a real sampled attachment, asserting per half and leaving
row 3 - the straddle band - unpinned, because pinning it would assert the half-texel
offset rather than the mip. GenerateMipmapDepthStencilDeclinesAndKeepsTheSession takes
no knob (the decline is on the aspect, before blit support is asked about) and pins that
the session survives and level zero still holds what was cleared into it.
Red once, three of them, executed and reverted:
- a one-character edit to WireDepthMipmap.frag (no rebuild - the gate reads the source
at runtime) reds the WireDepthMipmapFragment row at word 279;
- with the depth arm disabled, the DepthMip entries die with Fatal{UnmigratedVerb,
"Magma:mipmap-shader-format-or-shape format=125 ... aspect=0x2 ...
missing=COLOR_ATTACHMENT"} - format 125 is X8_D24_UNORM_PACK32, which slice 1's
diagnostic names for us;
- with the Fatal restored, the decline case dies as
Fatal{UnmigratedVerb, "Magma:depth-stencil-mipmap@P7"}.
Gates: integration-magma-split 77 (+2), -spawn 56 (+2), -tcp 58 (+2), 0 red;
ctest -L unit 2410 (+8, the gate's rows); informational integration-magma-full-split
513, 0 red, 59 skip (base 510/0/57); fatal_census green (79 abort sites, 43 words, 0
unmarked); link_ratchet --assert-monotone unchanged at 186, p7-magma 101.
G1 holds and it was not obvious: the witness header is compiled into the pull build
too, and its word count changed. Pull .text is still 0xa52203 and the defined-symbol
name list is identical to ~/w7/p7-before/pull-syms.txt line for line (30570) - the
change lands in .rodata and widens no instruction encoding.
VulkanRenderer.h is edited outside this package's file list: the new pass needs four
private declarations next to the colour blit's. Flagged for the integrator.
Two Iris cases died on the Redmi (Adreno 830v2, DirectVulkan inproc) with
Fatal{UnmigratedVerb, "Magma:mipmap-shader-format-or-shape"}, deterministically and
under both run-ahead and lockstep, while the monolith arm and the whole host lane
stayed green. That one line was the entire evidence, and five disjunctions share the
name, so the one thing the device could have told us - which format, at what shape,
missing which feature bit - was the one thing it did not.
The name stays first and unprefixed, because the census, the retrace refusal tally
and every log grep key on the leading word; the driver's own answer follows it:
format enum, the view's storage format when it reinterprets, extent, levels, aspect,
view type, usage bits, layer range, and the names of the optimalTilingFeatures bits
that are ABSENT. The next device run reads a line instead of re-deriving it.
The refusal also loses half its territory. The shader arm rendered STRAIGHT INTO the
next level, which needs the image to carry COLOR_ATTACHMENT usage - and a texture
whose format the device declines as a colour attachment never gets that bit, so every
sampled-only texture without BLIT support fell into the refusal rather than into a
fallback. When the FORMAT can be rendered and only the IMAGE cannot, an owned scratch
attachment of the destination level's size takes the pass and vkCmdCopyImage moves the
mip in: a raw texel move, no conversion, texels still from GPU contents rather than a
staged shadow, and the scratch retires with the submit that consumes it - the shape the
multisample resolve arm above it already uses.
Neither shader arm had ever executed on the gate machine. lavapipe reports
BLIT_SRC|BLIT_DST for every colour format this binary can create, so GenerateWireMipmap
takes its native arm for all of them; GenerateMipmapPackedFloatPixels next door looks
like it covers the shader arm and does not, because R11F_G11F_B10F blits natively here
too. MGITEST_MAGMA_FORCE_SHADER_MIPMAP drops the native arm instead, one tier per shape
(1 = render into the level, 2 = render into scratch and copy back), read once and
compiled only into the disaggregated build.
F1WireScenario.GenerateMipmapWithoutNativeBlitPixels paints two different halves rather
than one flat colour - a pass that loses GL's row order averages to the same number
everywhere, and a single-colour assertion would call that correct - and asserts level
one per half. It is registered on the three Magma arms twice, ONE ENVIRONMENT EACH: the
knob changes how every mipmap in the process is generated, so a lane-wide flip would
silently retarget the four green mipmap entries beside it.
Red once (executed, reverted): with the scratch arm forced off, the tier-2 entry dies
with Fatal{UnmigratedVerb, "Magma:mipmap-shader-format-or-shape format=37 extent=8x8x1
levels=4 aspect=0x1 view=1 usage=0x17 layers=0+1/1 missing=DEPTH_STENCIL_ATTACHMENT"},
by name and with the shape in it; restored, the same filter is 8/8 green.
Gates: integration-magma-split 75 (+2), -spawn 54 (+2), -tcp 56 (+2), 0 red;
ctest -L unit 2402/2402; informational integration-magma-full-split 511, 0 red, 58 skip
(the new case skips where the knob is unset); fatal_census green (79 abort sites, 43
family words, 0 unmarked - no site added, the funnel is unchanged); link_ratchet
--assert-monotone unchanged at 186, p7-magma bucket 101.
notes/p7/magma-a.md: the per-site table (retired / declined / kept-fatal with the
reason), every red-once with its captured output, the gate readings, and what is
handed on.
The three places package A's own prose still named `uniform-buffer-byte-tail@P7`
lose the marker. They are comments, not refusal sites, and leaving the marker in
would have made a tree-wide `grep @P7` count them against packages B and C. The
census now reads 15 -> 7, and all seven are real: WireFramebuffer.inc x4 and
VulkanRenderer.cpp x1 (package B), WireDraw.inc x2 (package C).
Three findings worth the integrator's attention, all in the note:
1. Most of this cluster was unreachable, and that is provable rather than
assumed. The frontend validates buffer-range offsets against the very caps the
backend then re-checks - GL_{UNIFORM,SHADER_STORAGE,TEXTURE}_BUFFER_OFFSET_ALIGNMENT
and GL_MAX_UNIFORM_BLOCK_SIZE are all published from the same Vulkan limits -
so five of the eight sites could only ever have fired on a malformed peer. The
two that CAN fire are `uniform-buffer-byte-tail` (GL constrains a range's
offset but not its size) and `unaligned-atomic-buffer-range` (GL gives
GL_ATOMIC_COUNTER_BUFFER no queryable alignment, so the frontend only enforces
offset % 4 while the backend needs minStorageBufferOffsetAlignment).
2. fatal_census does not move for any of this, and no re-baseline is due. Wave 0
already routed these funnels through MGPipeSessionFailHook, so the abort lives
in FatalFunnel.cpp and retiring a WireDescriptorFatal call site changes no
census number: 79 abort sites / 43 family words, unchanged, RC=0. The brief
expected the count to fall because the sites were bare aborts before wave 0.
3. DirectVulkan.Split.Full.IterationRPProgram203Scenario.FixedCompleteInputProducesFixedCompleteGoldenOutput
is red on this worktree's build-split and is NOT ours: it reproduces 3/3 with
package A's backend diff fully reverted, and the mismatch bits are identical
to the S1 exposure texel in magma-two-process-first-run.md. The contract books
that one against the tcp arm, and ~/w7/pipe's own build-split passes it, so
whether it is a split-arm regression between 460a3a72 and 610fa281 or a
configure-dependent S1 needs the integrator.
Gates on the finished branch: integration-magma-split 75/75, -spawn 54/54, -tcp
56/56, -buffers 21/21, full-split 512 entries 454 PASS / 57 SKIP / 1 red (the
pre-existing one above; base was 453/57), ctest -L unit 2408/2408,
spawn_lane_parity RC=0, link_ratchet unchanged at 186, fatal_census RC=0, and G1
pull .text = a52203 with nm --defined-only identical to p7-before/pull-syms.txt
(added=0 removed=0).
`texel-buffer-native-format@P7` becomes a decline (rule I (a)), and with it the
last refusal site in UniformManager.cpp is gone. GL's buffer-texture format list
is wider than what Vulkan mandates for texel buffers - GL_RGB32{F,I,UI} and the
16-bit UNORM members are the realistic misses on a real driver - so this is a
device capability gap, not anything the application did wrong and nothing it
could have sent differently.
The placeholder is the right answer here where it was not for the unaligned range
in 7c373d60, and the difference is whether a correct answer exists at all. There
the bytes are addressable and only the descriptor cannot name them, so a
placeholder would have hidden a fixable gap behind silently dropped writes. Here
the device cannot represent this format as a texel buffer in any form, so the
honest observable is monolith's for a missing view: the fetch reads zeros and the
draw survives. AcquireUnboundTexelBufferView already falls back to the R32 member
of the shader's numeric class, whose texel-buffer support is mandatory, so the
fallback cannot itself fail for want of device features.
red-once (R-16). lavapipe supports every format the buffer-texture cases use, so
the site is not reachable on the host - the red is the same case with the device
support check forced to fail, both shapes built and run back to back:
DirectVulkan.{Split,Spawn}.Full.BufferTextureScenario.VertexStageTexelFetchReadsTheBufferAndTracksItsUpdates
unforced 2/2 Passed (the format IS supported here)
before (7c373d60 + forced) Subprocess aborted -
Fatal{UnmigratedVerb, "Magma:texel-buffer-native-format@P7"}
after (this commit + forced) ***Failed on the texel data and NOT on a Fatal:
the fetch read the zero placeholder, the session lived
restored 2/2 Passed, no force markers left
Which format actually trips this is a device question, not a host one. Adreno is
where GL_RGB32F buffer textures are expected to miss, so the decline's real
observable wants a device pass; notes/p7/magma-a.md lists it.
Lanes: integration-magma-split 75/75, -spawn 54/54, -tcp 56/56,
integration-magma-buffers 21/21. `grep -n '@P7'` on this file now returns one
line, and it is the comment that records the A.1 retirement - no refusal sites
remain.
`unaligned-atomic-buffer-range@P7`, `unaligned-storage-buffer-range@P7` and
`unaligned-texel-buffer-range@P7` become named declines (rule I (a)). The two
offset-overflow guards next to them keep their named Fatal under their own words:
those are arithmetic that cannot happen, not shapes an application can bind.
The atomic-counter one is the only member of this cluster an application can
actually reach, and it is a real live gap, not a hypothetical. GL 4.6 core 6.1.1
gives GL_ATOMIC_COUNTER_BUFFER no queryable offset alignment, so
ValidateBufferRangeOffsetAndSize only enforces offset % 4 on it - while glslang
lowers every atomic_uint onto a synthesized storage block whose descriptor offset
must be a multiple of minStorageBufferOffsetAlignment (16 on lavapipe, 64 on
Adreno). `glBindBufferRange(GL_ATOMIC_COUNTER_BUFFER, 0, buf, 4, 4)` is a legal
GL bind that this backend cannot express, and it used to take the session with
it. The plain SSBO and texel halves are unreachable by comparison:
GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT and GL_TEXTURE_BUFFER_OFFSET_ALIGNMENT
are published from the same Vulkan limits, so the frontend already answers
GL_INVALID_VALUE.
DECLINED RATHER THAN COPIED, and this is the package's one real design choice.
Copying the window into an aligned transient slice reads correctly and then
silently DROPS every shader write. This site cannot tell a writable storage block
from a read-only one - there is no readonly bit in the storage-block reflection,
an atomic counter is written by definition, and MarkWireBufferGpuWritten right
below already assumes any binding here may be written. A correct copy needs a
post-dispatch copy-back from the transient slice to the unaligned source, ordered
with a barrier, and the place to record that is the draw/dispatch tail in
VulkanRenderer.cpp / WireDraw.inc - wave-2 packages B and C, not this file.
Returning stale counters with no error is worse than losing the dispatch with a
named line in the role log. notes/p7/magma-a.md carries the recipe for whoever
holds those files.
The texel half keeps the draw where it safely can: a read-only buffer texture
falls back to the same placeholder view an unbound one gets (zeros, which is what
a missing view gives in monolith), while a writable imageBuffer declines - the
placeholder is shared by every unbound binding, so absorbing real stores into it
would alias other bindings as well as lose the writes. Copying is not available
there either: the transient arena carries no {UNIFORM,STORAGE}_TEXEL_BUFFER
usage, so no VkBufferView can be made over it, and widening a shared per-frame
allocation to buy an unreachable path is the wrong trade.
red-once (R-16), a reachable case this time, all three Magma two-process arms:
DirectVulkan.{Split,Spawn,Tcp}.Buffers.F1WireScenario.UnalignedAtomicCounterRangeDeclinesAndTheSessionLives
before Subprocess aborted - Fatal{UnmigratedVerb, "Magma:unaligned-atomic-buffer-range@P7"}
after 3/3 Passed, and the role log carries
"block 'gl_AtomicCounterBlock_0' is bound at offset 4, which is not a
multiple of this device's minStorageBufferOffsetAlignment (16);
atomic-counter ranges cannot be expressed as a descriptor ... DECLINED
(unaligned-atomic-buffer-range)"
The case pins the decline's whole observable: the bind raises no GL error, the
dispatch does not run, every byte of the counter buffer is unchanged, and the
context still serves ordinary work afterwards. A future copy-back turns one
EXPECT into seed[1] + 1 and leaves the rest standing.
Lanes: integration-magma-split 75/75, -spawn 54/54, -tcp 56/56,
integration-magma-buffers 21/21.
`uniform-block-native-range@P7` and `storage-buffer-native-range@P7` both become
clamps with an MGLOG_E_ONCE (rule I (a)). Neither is reachable from a conformant
program: GL_MAX_UNIFORM_BLOCK_SIZE is published straight from the same
maxUniformBufferRange and GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT /
GL_MAX_SHADER_STORAGE_BLOCK_SIZE from the same storage limits
(BackendLoaders/Vulkan/Loader.cpp), so a block this big fails to link long before
a draw. Refusing was a Fatal guarding a shape nothing can produce.
The monolith arm is the argument. It binds whatever the GL range asked for and
never consults maxStorageBufferRange at all (ResolveStorageBufferDescriptor's
resident path), so the wire arm was inventing a death the other backend does not
have. If the two limits ever disagree, the honest answer is the bytes the device
CAN name: everything past the clamp is outside the descriptor and reads back as
zero under robustBufferAccess, which is inside GL's "undefined" for a block the
implementation never promised to hold. Clamping keeps the range constant across
draws, so the descriptor-set reuse hash is unaffected.
red-once (R-16). Neither site is reachable from a test, so the red is the same
two cases with the device limit forced down to 16 bytes, both shapes built and
run back to back on the Split and Spawn arms:
uniform-block-native-range (reflected block 32, limit forced to 16)
before Subprocess aborted - Fatal{UnmigratedVerb, "Magma:uniform-block-native-range@P7"}
after BOTH ARMS PASSED. The clamp keeps every byte the descriptor can name,
so the case's own pixel is unchanged, and the role log carries
"reflected uniform block 0 is 32 bytes, past this device's
maxUniformBufferRange of 16; the binding is clamped to the limit
and the remainder reads zero"
storage-buffer-native-range (block 'Output' 24 bytes, limit forced to 16)
before Subprocess aborted - Fatal{UnmigratedVerb, "Magma:storage-buffer-native-range@P7"}
after ***Failed on the data and NOT on a Fatal - the session lives, 8 of the
24 bytes are genuinely past what the forced limit can name, and the
role log carries "block 'Output' bound 24 bytes, past this device's
maxStorageBufferRange of 16; the binding is clamped to the limit"
restored (this commit as committed): 4/4 green, no force markers left.
Lanes: integration-magma-split 74/74, -spawn 53/53, -tcp 55/55,
integration-magma-buffers 20/20.
`uniform-buffer-dynamic-offset@P7` becomes a decline (rule I (a)). A Vulkan
dynamic offset is a uint32_t, so a transient uniform ring that grew past 4 GiB
inside one frame has nowhere to put the padded block - which is our ring's
problem, not a protocol fault, and nothing the peer could have sent differently.
Killing the session for it was the wrong shape twice over.
The monolith arm has the same ceiling and truncates silently on the way out
(ResolveDynamicUboDescriptor's static_cast<Uint32>), i.e. it binds a WRONG window
rather than none. Declining is the strictly better observable of the two: one
binding is skipped, the draw is lost, the session keeps running, and the next
frame's rewound ring resolves it.
red-once (R-16). The site is not reachable from a test - a 4 GiB transient arena
inside one frame is not something an integration case can build - so the red is
the same case with the ceiling condition forced true, both shapes built and run
back to back on the Split and Spawn arms:
before (fb25ed6b + forced condition)
DirectVulkan.Split.Buffers.F1WireScenario.ShortUniformBufferRangePadsMissingBytes
DirectVulkan.Spawn.Buffers.F1WireScenario.ShortUniformBufferRangePadsMissingBytes
Subprocess aborted - MGPipe: Fatal{UnmigratedVerb, "Magma:uniform-buffer-dynamic-offset@P7"}
after (this commit + forced condition)
both ***Failed on the pixel, NO Fatal and NO abort, and the role log carries
ResolveWireUniformBufferPayload: the transient uniform ring reached offset 0,
past the 4 GiB a Vulkan dynamic offset can name; uniform binding point 3 is
declined for this draw
restored (this commit as committed): both green, no force markers left.
Lanes: integration-magma-split 74/74, -spawn 53/53, integration-magma-buffers
20/20. -tcp not read this round: port 40613 is held by a concurrently running
lane in another worktree, which is a fixture collision and not a result.
`uniform-buffer-byte-tail@P7` is retired. It was the live boundary the Redmi
Minecraft run reported (notes/p5f/magma-inproc-fix.md #5) and, since wave 0, a
named Fatal that took the session with it.
Only the SIZE of a uniform range can land off a word. glBindBufferRange already
forces the offset onto GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT and records
GL_INVALID_VALUE otherwise (GL_Buffer.cpp ValidateBufferRangeOffsetAndSize), and
every device reports that as a multiple of four - but GL 4.6 core 6.1.1 puts no
such rule on size, so a 19-byte bind is legal and 19 % 4 is not 0. The sub-word
start is only reachable protocol-side; the new arm handles it anyway rather than
leaving a second shape to trip over later.
CopyWireBufferSubWordRangeToSlice rounds the read of the APPLICATION's store OUT
to whole words, clamped to the store's end so the widened window can never touch
a byte the application does not own, and lands it in a transient staging slice.
Only the staging -> padded-block shift is sub-word, and that happens inside our
own arena; vkCmdCopyBuffer places no alignment rule on a region's offsets or size
(unlike vkCmdUpdateBuffer / vkCmdFillBuffer), so the shift is a plain legal copy.
The block still starts at the padded slice's own offset, which is what lets the
descriptor keep a minUniformBufferOffsetAlignment-aligned dynamic offset.
red-once (R-16), all three Magma two-process arms, before the backend half:
DirectVulkan.Split.Buffers.F1WireScenario.SubWordUniformBufferRangePadsMissingBytes
DirectVulkan.Spawn.Buffers.F1WireScenario.SubWordUniformBufferRangePadsMissingBytes
DirectVulkan.Tcp.Buffers.F1WireScenario.SubWordUniformBufferRangePadsMissingBytes
Subprocess aborted - MGPipe: Fatal{UnmigratedVerb, "Magma:uniform-buffer-byte-tail@P7"}
and green after it. The case is the sibling of
ShortUniformBufferRangePadsMissingBytes with a 19-byte range; the byte just past
the range is poisoned, so a rounding that leaks it reads tail.x as 0.5f and the
red channel comes back 127 instead of 0.
Lanes: integration-magma-split 74 (was 73), -spawn 53 (52), -tcp 55 (54),
integration-magma-buffers 20 (19), all green. fatal_census unchanged at 79 abort
sites / 43 family words - these refusals stopped being bare aborts in wave 0, so
a retirement moves no census number and needs no re-baseline. link_ratchet
unchanged at 186.
The per-slice red-once records were written with each slice. This adds what only exists
once the four are together: the gate table (unit 2403, magma split/spawn/tcp 75/54/56 with
the two death-case skips now gone, integration-split 187, integration-spawn 103,
full-split 513 entries 0 red 55 skipped = 458 pass against the 453/57 bar, census
unchanged, parity green, @P7 15/4 -> 13/3, ratchet 186 monotone, G1 .text 0xa52203 with
every section +0 and the pull .so byte-identical), the ratchet bucket split with an honest
note that I did not re-measure the buckets at the branch point, and a numbered list of the
nine things this package changes that live outside its own files.
Three of those nine need an integrator decision rather than a note: §5.4's white-box case
is blocked by R-6 and not by this package; §5.3's g_programResourceCaches deletion is
achieved in the disaggregated build only, because the archive member has to be guarded for
G1; and buffer-window's family word is UnmigratedVerb where ProtocolCorruption is the
honest one, which costs a row in MG_Pipe and an adapter arm that §3.3 deliberately has not
spent.
CONTRACT-P7 §5.3, OQ-8. MagmaProgramSource::EnsureStorageBlocks re-ran SPIRV-Reflect over
every stage module of the program PER DRAW: the class is a borrowed view that
WireDraw.inc's WireProgramSource constructs afresh for each wire draw, so its `mutable`
memo was cold every time it was read. The list it rebuilt is a pure function of the linked
program, so it is now built once at link time and travels in the archive.
LinkArtifacts gains storageBlocks{name, binding, dataSize} with its own VisitFields table,
the codec version goes 2 -> 3, and the schema fingerprint and wireFingerprint move with
it - which is expected, and is what makes a mixed-version pair refuse at the handshake
instead of at the first program.
FILLED IN PHASE A, and that is not where it looks like it belongs. The only function that
sees both finished halves is ProgramSpirvTask's L1 cache insert, but its writes reach the
TRANSLATION CACHE only: phase A's artifacts have already been moved into the ProgramObject
by then. A field filled there would be present on a cache hit and absent on every cold
link. So the fill is glslang-derived, and the equivalence is MEASURED rather than assumed:
ProgramTest.TheArchivesStorageBlockOrderIsTheOneSpirvReflectProduces runs the old
consumer's own algorithm over the real modules and compares element for element, on a
multi-stage program with an arrayed SSBO and an atomic-counter block - the three cases
that could have diverged (the instance array collapses to one entry, the synthesized
counter block IS in the backend's space although GL's enumeration excludes it, and the
cross-stage dedupe walks stages in linked order).
MEASURED, same binary and same window (frames=1 draws=1 acc=18, identical bytes),
DirectVulkan x inproc over the SSBO scenarios: spvReflectCreateShaderModule on the wire
draw path 4 -> 0. The 0 is structural: the call site is gone and MagmaProgramSource.h no
longer includes spirv_reflect.h at all, which is the gate a future edit breaks on purpose.
THE MONOLITH CONSUMER IS SWITCHED TOO, under #if symmetry, so the disaggregated build
retires g_programResourceCaches, ProgramResourceCache, StorageBlockResource,
BufferVariableResource, AddBufferVariablesRecursive, NormalizeDescriptorName,
GetProgramResourceCache and the rehash hazard - the open-addressed-map dangling reference
that was a reproducible segfault in ProgramPipelineScenario's two storage-block cases.
Nothing outside DirectVulkan.cpp read any of it (measured; GL_BUFFER_VARIABLE and
GL_ACTIVE_VARIABLES are answered by ProgramInterface.cpp out of a different index space),
so the two arms differ in exactly the two functions their callers use.
THE PULL BUILD KEEPS ALL OF IT, and the integrator should see why: the archive member is
MOBILEGL_BUILD_DISAGGREGATED-only because one more Vector in LinkArtifacts changes its
size, its implicit destructor and its move constructor, and G1 pins the pull build's .text
byte for byte. So §5.3's deletion is achieved in the disaggregated build and not in the
pull one; retiring the pull half is a one-line change the day that pin is retired or the
member moves. Filed as P13 in notes/p7/magma-c.md.
One improvement rides along: the monolith binding getter now asks the program for a
rebind on every read, over an immutable list, where it used to read a binding patched into
a mutable cache - so a rebind can no longer be lost to a cache rebuild that raced a
state-version bump.
G1: .text 0xa52203 unchanged, 27837 -> 27837 defined symbols, 0 added / 0 removed / 0
resized / 0 renamed, file byte-identical. An unguarded call site caught the guard rule
once mid-slice by failing the pull compile, which is the loud version.
RED-ONCE: publishing nothing reds the order-equivalence case (with the reference list
printed) and 25 of 60 storage-block scenarios - the MONOLITH DirectVulkan arm beside all
three transports, which is the evidence that the monolith consumer really was switched.
Restored: 61/61.
CONTRACT-P7 §3.2's `vertex-layout` and `vertex-format-conversion` rows.
BuildWireVertexInput answered eight different conditions with one `false`, and the single
caller answered that with a P7-marked MagmaWireFatal. So a Magma split session that met a
GL_FIXED array, a dvec on a device with native fp64, or any format without
VK_FORMAT_FEATURE_VERTEX_BUFFER_BIT did not decline the draw: it killed the process. The
monolith arm on the same device has always masked the attribute out and then declined the
draw loudly and once.
The split is by what the reason is ABOUT, not by severity. `buffer-window` is a statement
about the RECORD - an enabled attribute the program reads whose slot lies outside the
window set_vertex_buffers published - so it stays a named Fatal through the hook; it is
what caught the Redmi VAO hash collision fixed in 376c04be. Every other reason is a
statement about what Vulkan can express on this device, and those now set
unsupportedAttribMask and continue, with the monolith arm's own pre-flight in WireDraw.inc
declining the draw when a masked bit is one the program actually reads.
The contract says "the other six"; there are seven. `offset-overflow` post-dates the
audit's count and is an arithmetic guard rather than a shape one. It is masked with the
rest: an attribute whose base offsets cannot be added is one Vulkan cannot fetch, the
monolith arm never computes that sum at all, and masking is strictly safer than a Fatal
for a number no application can reach on purpose.
unsupportedAttribMask now enters the wire layout hash, for the reason the monolith entry's
hash already carries it: two VAOs can otherwise agree on every binding, attribute and
divisor and differ only in which enabled attribute was masked, and a blind hash would hand
one of them the other's pipeline. Omitting it was safe only while the mask was always zero.
vertex-format-conversion becomes a decline for a narrower reason: the converter is only
ever asked for a conversion BuildWireVertexInput already chose, so a refusal means the two
disagree - a defect on this side, not a peer's malformed record.
@P7 census: 15 sites / 4 files -> 13 / 3. Renderer/WireDraw.inc leaves the list entirely.
fatal_census.py unchanged at 79 abort sites / 43 family words / 0 unmarked, as expected:
wave 0 already funnelled these, so retiring a MagmaWireFatal CALL moves no abort count.
New MagmaVertexLayoutScenario, DirectVulkan only (Espryt hands GL_FIXED to a driver that
supports it, so the same name would assert a different fact there). DataType::Fixed32 has
no arm in ToVkVertexFormat at all, which makes it the one unmappable shape reachable on
host lavapipe - §2.5 records that no other P7-marked refusal is. One case has the program
READ the array and asserts the draw declines; the other leaves it enabled but unread and
asserts the draw lands, which is what stops the first being satisfied by "decline every
draw that mentions GL_FIXED". Registered on all three transports and picked up by the
ambient monolith DirectVulkan discovery, so the A/B is direct.
RED-ONCE: `format-map` put back on the protocol verdict reds the three split arms with
`Subprocess aborted` / `Fatal{UnmigratedVerb, "Magma:vertex-layout-buffer-window"}` while
the monolith control stays green - exactly the divergence this closes. Restored: 10/10.
The gating Magma lanes grow with the new entries: split 73 -> 75, spawn 52 -> 54, tcp
54 -> 56. Growth only.
Open for the integrator: the honest family word for buffer-window is ProtocolCorruption,
and MGPipeFatalFamily carries only UnmigratedVerb and RoleViolation. Adding a third costs
a row there plus an adapter arm, and §3.3 keeps the three Magma funnels on UnmigratedVerb
deliberately so the refusal census stays comparable to P5b. Flagged, not decided.
CONTRACT-P7 §5.4, OQ-10. InitServerRoleCommon computes the whole capability mask and
never ORed this bit, so MGPipeResourceOpsHaveSubDataResident answered false under every
transport and BOTH backends were pinned to the ordered in-place host memcpy - which made
Magma's SubDataResident arm (VkBufferManager.cpp's g_vulkanWireResourceOps) dead code on
the wire even though the arm has been there since P5f.
The bit is derived from g_resourceOpsAtStep2->SubDataResident, i.e. from the pointer this
function already pinned at step 2 and that step 5 already refuses to see swapped - never
from ActiveBackendType. That is ID-39's lesson and not a style preference: a mask is a
statement about THIS backend's registered table, and a bit derived from the enum would
keep claiming the capability for a build, a flavour or a future backend whose table does
not carry the arm.
`rsd=` joins the emit[] bracket on the PipeStats summary line, counted at the emitter once
per buffer_subdata_resident record rather than once per call, because a write past the
content chunk cap is cut into several records. It is the only observable that separates
the resident arm from the memcpy beside it: the bytes in the buffer are identical either
way, which is exactly how the capability stayed unpublished for a phase with every lane
green.
WHAT THIS DOES NOT YET PROVE, and R-6 is the reason rather than an omission. §5.4 asks for
a white-box case showing opcode 49 on the wire. None can exist at this tree: the resident
arm is reached only from a GPU-RESIDENT store, residency comes only from
AdoptPersistentMap, and every persistent-map acquisition declines under split by R-6
(PipeApply.cpp:2331 "A SPLIT BUILD RUNS AT TIER T2 AND DECLINES EVERY ACQUISITION,
ALWAYS"; T0/T1 are P11's and AdoptTierIsEmulate is a named Fatal for them). So the
capability is wired AHEAD of its consumer, deliberately, and `rsd=` is what will show the
records the day P11 lands a tier that adopts. Filed for the integrator in
notes/p7/magma-c.md.
What IS proved is both halves of the bit itself. RemoteClientTest's Magma bootstrap case
now asserts the published bit EQUALS `ops->SubDataResident != nullptr` - the two compared
to each other rather than both to true, so the case also covers a backend without the arm
on the day one exists - and a new CtWireScenario case reads the bit out of the client's
own caps mirror, through the same accessor the frontend reads, on both backends and all
three transports.
RED-ONCE, both levels. Deleting the OR reds
CapsMirrorTest.MagmaTransportPublishesRealBufferConsumersWithoutRunAhead
(RemoteClientTest.cpp:692, "Which is: false / Which is: true") and all SIX arms of
DirectGLES/DirectVulkan x {Split,Spawn,Tcp} Ct.TheServerPublishesTheResidentSubData
CapabilityFromItsOwnTable ("the client's caps mirror did not receive
kCapResidentSubData"). Restored: green.
Also carries the P4a death-helper comment from the previous commit's sweep: it no longer
says Magma installs no death table, because it now does and still frees no slot.
CONTRACT-P7 §5.5. DirectVulkan had no StateObjectDeathOps at all, so under a Magma
inproc session NotifyStateObjectDestroyed reached nobody and the server was never told
that a frontend object had died. Package L measured the shape: with the WireTables
install condition made backend-agnostic, CtWireScenario's two death cases PASS on Magma
spawn and tcp - the remote client has no local backend, so the client-side emitter
installed there answers the notice - and FAIL on inproc, where the install condition
deliberately stands aside for "the backend's own dispatcher" and Magma had none.
g_magmaStateObjectDeathOps is that dispatcher. It mirrors g_glesStateObjectDeathOps
(DirectGLES/Managers.cpp:335) with ONE arm rather than two: Espryt's other arm destroys
a backend twin keyed by lifetime id, and Magma keeps no such twin - its two
client-minted kinds are keyed on {slot, gen} by MagmaPipeIdentityTables and the other
six are still reached from their frontend objects - so what is left is exactly the emit
of object_death off the apply thread. Installed from
BackendObject_DirectVulkan::Initialize(), i.e. ServerLoop::CreateBackend, step 1 of
InitServerRoleCommon: the one place BOTH Magma server roles pass through, and before any
client session exists. #if MOBILEGL_BUILD_DISAGGREGATED && MOBILEGL_PIPE_PUSH, so the
pull build compiles nothing new.
The two skip guards in CtWireScenario go with it. Every other "Magma installs none" site
the audit found is comment text whose ARGUMENT survives, and each is reworded rather than
deleted: the table emits the wire record and frees no client slot, so P4a's
backend-neutral NotifyAndFree is still the only thing that returns one, HandleRecycle's
C-1 case still measures the only protection the allocator has, and MagmaPipeArms' age
sweep is still the only thing that retires an identity slot.
The framebuffer death case now reads the client's allocator beside the server's tally.
That correction matters: the plan called this a slot leak provable with PipeSlotPeek, and
it is not - it has been backend-neutral since P4a. What leaked was the SERVER's twin, and
a framebuffer has no wire delete opcode at all, so object_death is its only delivery.
Two installers cannot race although the global is last-writer-wins with no stacking:
Magma's runs only in a process that owns a DirectVulkan backend, and
InstallClientWireTables' arm runs only under Transport == Spawn, which is by construction
the process that owns none.
RED-ONCE: `if (false) InstallStateObjectDeathOps();` reds the two Split cases alone
(`Expected: (afterCounters.deaths) > (deathsBefore), actual: 0 vs 0`, CtWireScenario.cpp:264)
and leaves spawn and tcp green - the exact split of responsibility the comment claims.
Restored: 8/8. Recorded in docs/Disaggregated/notes/p7/magma-c.md.
X1: 26.3 and create-instancing pass under inproc on both backends, zero declined
faults, tracker line confirms tag=0xb4. F1: the R-8 line is gone in every run, yet
OpenRA reads 0.976494 on a cold driver cache (three pm-clear runs identical), 1.0
warm, 1.0 cold monolith, and FRAMESINFLIGHT=1 flips it half the time - a GPU-timing
race on the wire readback/present path, handed to package B as slice 0.
`notes/p7/f1-caps-placeholder.md`, per CONTRACT-P7 §9's "one red-once per package
recorded in notes/p7/". It carries the call chain from `MobileGL::Initialize` down
to `CapsMirror::ServerConsumes(0x400)`, the three corrections to the brief's reading
(it is not a race; P6.5's fix already covers inproc; a bounded wait alone deadlocks),
the three red-once arms, and the two things still owed to the integrator.
The arm worth reading twice is A. On lavapipe the BASE build carries the R-8 line on
every transport, with and without the delay knob, and OpenRA still scores
ssim 1.000000 / mismatch 0. So the eleven dropped resource_creates cost nothing
visible there, F1's red-once is the LOG LINE and the new Fatal rather than an SSIM,
and this package must not be read as having explained the device's 0.976494 /
0.988998. That still needs the phone.
`CapsMirrorTest.APlaceholderMirrorConsumesNothing` asserted the defect F1 closes -
"with no snapshot the mask is zero, every family answers no consumer, the client
emits nothing and the legacy pull path runs" - and it was green for five packages
because nothing on the client ever checked whether that pull path exists. It does
not. Four cases replace it, and each one is the control for a different way the
new gate could go wrong:
* `APlaceholderMirrorRefusesToAnswerAndNamesTheFamily` forks, asks a placeholder
mirror about TextureResources with no session anywhere, and requires the death
to carry `Fatal{CapsBeforeFirstSnapshot, "TextureResources"}` AND `0x400` -
R-16's rule, because "something aborted" is satisfied by any of the other 43
family words.
* `ThePlaceholderWaitsForASnapshotAnotherThreadIsFetching` drives the PRODUCTION
bring-up (`MG_Backend::Init`) with `MOBILEGL_TEST_DELAY_FIRST_CAPS_MS=50` and
asks from a second thread. The wait must return the server's real answer, not
the placeholder's false and not a death. It is the only shape in which "block
with a bounded wait" is a correct answer to F1.
* `ARealSnapshotThatWithholdsAFamilyStillAnswersFalse` is what keeps this from
being a callMask defaulted to all-ones: a server that really consumes nothing
is still answered "no", counted and named. Only the PLACEHOLDER is refused.
* `TheReadAccessorsStillAnswerFromThePlaceholder` pins the half of P5's ruling
that F1 does NOT narrow.
`ResetCapsMirrorForTest()` is new and test-only: a fork child inherits whatever the
previous case adopted, so without it a case about the placeholder would be a case
about that snapshot.
`ServerLoopTest`'s main gains no code, only the sentence that explains why it has
none: it sets `MG_Config::Transport` by hand and never runs `MG_ConfigLoader::Init`,
which is exactly what keeps the client-side gate disarmed for a binary that builds
server sessions and the server's own frontend GLContext and never a client at all.
F1. `MG_State::Init()` runs before `MG_Backend::Init()`, and the second is what
starts the session - so `GLContext`'s `TextureState` constructor materialised its
eleven default texture objects, each of them emitted `resource_create` through
`FamilyIsLive`, and `CapsMirror::ServerConsumes(0x400)` answered every one of them
from a generation-0 placeholder with `callMask=0`. All eleven records were dropped
and the whole event was one WARN line:
MG_Remote client: the server does not consume MGPipe subsystem 0x400 - this
family emits NOTHING and the legacy pull path runs for it (R-8). callMask=0x0,
caps generation 0
It is NOT a race. The line reproduces byte-for-byte on lavapipe and on the Adreno
830, because the two Init calls are two statements on one thread. And "the legacy
pull path runs for it" is false on the client under split: there is no pull path
on this side of the wire, so withholding is not a fallback, it is a drop that only
a later respecify's self-healing create ever undoes.
Two of the three options the brief offered do not apply, and both facts are worth
recording. (b) "extend the P6.5 fix to inproc" is already the tree: `FinishStartup`
step 7 is the one funnel every transport reaches and it will not return without a
real snapshot. (a) "a bounded wait in Consumes()" cannot work alone and would
deadlock: at `MG_State::Init()` no handshake is in flight, and the one that will
land is started later BY THE SAME THREAD.
So the fix is the ORDER, with the rule behind it as the gate:
* `MobileGL::Initialize` calls `MG_Backend::InitSplitRolesBeforeState()` before
`MG_State::Init()`. `Init()`'s split arm becomes one body, `InitSplitBackend()`,
shared with the direct callers that still reach `Init()`; the monolith arm keeps
its place after `MG_State::Init()`, because moving it would move pull `.text`.
* `CapsMirror::ServerConsumes()` refuses to answer from a placeholder. It waits,
bounded, when a bring-up is genuinely in flight on ANOTHER thread, and otherwise
dies as `Fatal{CapsBeforeFirstSnapshot, "<family>"}` through `SessionFail` -
a new family word, one row in `FatalFamilies.def`, no new abort site.
* Two arms are exempt and say why in place: the server arm (`MGPipeServerArm()` -
the apply thread and the spawn server, where the client's mirror is the wrong
object rather than a late one), and a process whose own configuration never
asked for a split transport (`MG_Config::SplitTransportRequestedByConfig`, set
by `MG_ConfigLoader::InitTransport`, which is what tells a real run apart from a
fixture that assigns `MG_Config::Transport` by hand).
The read accessors keep P5's ruling untouched: `LogBackendInfo` reads
`GetRendererInfo()` during `MG_Backend::Init()` and an abort there is a process that
cannot start. Only the DECISION changed. The warning was not made quieter and the
callMask was not defaulted to all-ones.
`MOBILEGL_TEST_DELAY_FIRST_CAPS_MS` (server side, disaggregated builds, inert unless
set) holds back the FIRST CapsSnapshot. On spawn/tcp that is a genuinely late
snapshot in another process and it exercises the client's step-7 wait; on inproc it
only makes `Start` slower, which is itself the proof that the only window a client
can read a placeholder in is the one before `Start` is called.
G1: `.text` 0xa52203 and the 30570-symbol list identical to p7-before/pull-syms.txt.
Census: 79 abort sites unchanged, 44 family words (was 43). Link ratchet: 186,
unchanged.
shader-image 63/56/6, ssbo 124/0/0, dsa 368/0/2, texture 815/221/18, packed-pixels
4732/0/0 (Pass/NotSupported/Fail); every block complete (no unrun, hung or crashed);
library p7w1-d260f110, glcts from VK-GL-CTS 0b04c470e with the FBO colour-texture
patch, API probe confirms GL 4.6. The AFTER reading compares inproc against this at
0.5 pp per block with new crashes as a separate hard red.
run_cts.py silently tolerates a missing --skip-file, so the known device-killers from
the skill (clip_distance.functional, the multisampled_to_singlesampled blit family,
the rgb565 repeat-mode case) would each have cost a reboot in the gate-5 window.
Wave 2 package X1. Three CI split-subset traces died on the Redmi under inproc and
passed under monolith on the same device and APK, on the client thread, in the
replayer's memcpy into a pointer MobileGL had handed it: SIGSEGV / SEGV_ACCERR on the
destination's FIRST byte, destination page-aligned.
The tombstone names the same address twice, in two different address spaces:
x0 b4000073ac9a5000 ... x2 0000000001800000
signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x00000073ac9a5000
x0 is the pointer the application writes through, carrying bionic's 0xb4 heap tag
(the same tombstone reports tagged_addr_ctrl PR_TAGGED_ADDR_ENABLE). fault addr is
si_addr, which the kernel reports UNTAGGED.
TrackWriteMap took shadowBase from MappedData() - a tagged heap pointer - kept the tag
through the page masking, mprotect()ed the mapped range PROT_READ and published the
TAGGED base/end into the slot. The handler compared si_addr against it:
if (base <= kSlotSettingUp || address < base) continue;
0x73ac9a5000 < 0xb4000073ac9a5000 is true for every fault, forever. Ownership test (2)
never matched, and every fault on a page the tracker had itself protected was chained
to debuggerd. mprotect was fine throughout - do_mprotect_pkey untags its own argument -
so the right pages really were read-only; only the recognition was broken. x86-64 has
no top-byte tag, which is why every host lane has always been green.
The fix is two rows and one ordering:
* UntagAddress(), applied to the shadow base at registration, to the shadow base in
PushBlocksFor (so trackedBase - shadowBase stays in one space) and to si_addr in
the handler. Written WITHOUT an #if defined(__aarch64__): masking bits 56-63 is
the identity for every valid userspace address off arm64, and an arch guard would
have put the only gate that can catch this on the one platform CI never runs.
Dereferences keep the tagged pointer they came from.
* SlotOwnsFault(), the ownership row the handler and the unit case both read.
* REGISTRATION ARMS NOTHING. AcquireMemoryRange registered and only then returned
Bytes() + range.start, so glMapBufferRange handed the application a PROT_READ
pointer whose writability depended on a handler reaching the writing thread - and
the application is free to write a mapped arena from a worker that blocks every
signal. Nothing is lost by waiting: every page bit is already set at registration,
so the first push ships the whole interior regardless, and it is that push which
arms the pages. 6144 pointless faults per 24 MB arena go with it, and the
invariant the file rests on (bit set <=> page writable) is now true from
registration onward instead of only from the first push.
Diagnostic, independent of the fix: one MGLOG_I_ONCE at the first registration naming
the mapping the way the device run needed and did not have (allocator, pointer, ITS
TAG, extent, range, published span, pages, alignment arm), and a decline record in the
handler - relaxed atomic stores plus one async-signal-safe write(2) - readable through
DeclinedFaults(). A non-zero decline count whose si_addr lies inside nearest-tracked
once the top byte is masked off is this defect's signature and nothing else's.
Not the mechanism, and checked: libsigchain (the tombstone's "F libc : Fatal signal 11"
is bionic's own handler, so the chain ran), the 16 KiB-page refusal (the device is a
4 KiB kernel and the protection demonstrably took), RefaultOfANonWriteAccess (matched
is false long before it is consulted), and the P5d shadow allocator (correct - it is
what makes the destination page-aligned in the tombstone).
Red-once, two and orthogonal (MG_Test/Wire/PersistentMapFaultTest.cpp, 6 cases):
dropping the two UntagAddress calls in SlotOwnsFault reds only
AKernelsUntaggedFaultAddressIsOwnedByATaggedShadowsSlot, which runs the device's own
two addresses through the real predicate on an x86-64 host; restoring the mprotect at
the end of TrackWriteMap reds only AMapHandsBackARangeWritableWithoutTheFaultHandler
(the child dies by SIGSEGV) and RegistrationLeavesEveryPageOfTheRangeReadWrite.
AnArmedPageIsStillAnsweredByTheHandlerAndMadeWritable is the anti-vacuity case.
Gates: ctest -L unit 2408/2408; integration-split 186/186; integration-spawn 102/102;
integration-magma-split 73/73 (PersistentCoherentMapScenario green in the Split, Spawn,
PersistentMapArm and SmallRing variants); fatal_census.py 0 unmarked. G1 holds - pull
.text 10822147 = 0xa52203 unchanged, symbol report 0 added / 0 removed / 0 resized /
0 renamed, nm --defined-only identical at 30570 and nm -D at 11933. Every line is under
MOBILEGL_BUILD_DISAGGREGATED (CMakeLists.txt:592-659), so the pull build sees none of it.
Evidence and the device experiment: docs/Disaggregated/notes/p7/x1-persistent-map-segv.md
CONTRACT-P7: the lane counts (magma-split 73 / spawn 52 / tcp 54; full-suite 510 x 3
with one StreamLink x Magma red), the census baseline (79 abort sites over 20 files,
43 family words, 0 unmarked), the ratchet baseline (186 = p7-magma 101 / espryt 14 /
both 60 / core 11; espryt's clearable count is 9) and the @P7 count (15 sites / 4
files) replace the 待填 markers.
Device window #1a (Redmi 2f7cbe2e, APK p7w1-d260f110, reboot-clean): E0a monolith
39/39 deterministic, three below threshold on monolith itself (create-indirect,
1.21.11 main-menu, iris-photon - Adreno refuses its pipelines with VK_ERROR_UNKNOWN);
E0a inproc: OpenRA 0.976494 (run-ahead) / E1 lockstep 0.988998 - both historical
numbers were OpenRA, and its client log shows a TextureResources verb answered from
caps generation 0 (package F1); 26.3 / create-* SIGSEGV in the replayer's memcpy on
BOTH backends and with the persistent-map push off (package X1); iterationt x2 die
by name on Magma:mipmap-shader-format-or-shape (package B); bsl-esc-menu hits
scudo OOM under inproc. Gate 3's denominator is 36; run-ahead and present credit
are off the suspect list.
Package D added a test that patches lane_cases and hands spawn_lane_parity.py a
placeholder build dir; package L then made the three Magma tiers read through a new
lane_names, so the placeholder reached a real ctest and the unit label went red on the
integrated tree. Patching lane_names to an empty set on every arm makes those tiers
report "nothing to compare" and leaves the assertion about lane_cases intact.
CONTRACT-P6.md 12.1 has carried "a ratchet recomputes the 184 in CI and asserts it only
ever falls" as an obligation since P6. The roadmap handed it to P6.5; the P6.5 line never
received it, and scripts/ci/ has never had anything that computes this number. ID-P7-7
gives it to P7 wave 0. This is it.
scripts/link_ratchet.py mechanises a6's hand experiment: partition the per-object output
of the single MobileGL target into SERVER / FRONTEND / SHARED, then compute
(SERVER undefined) - (SERVER defined) - (SHARED defined) intersect (FRONTEND defined),
which is the undefined-symbol list a real link of a server-only image would print. The
partition is an explicit table with a rationale per row, because it IS the module boundary
the build system does not have (SOURCE_FILES is one flat list; boundaries are P13's work).
An object matching no row is a hard failure, as is a build whose CMakeCache lacks the three
disaggregated options, or an empty role set - every way this gate could go green without
having measured anything is an explicit exit 2.
Measured on 78b7d6be: 186 symbols. The three backend buckets reproduce a6's hand-made
101 / 14 / 60 exactly, as do its per-object referrer and definer distributions
(UniformManager 90, VulkanRenderer 60, Managers 53, DirectGLES 51; TextureObject 59,
SamplerObject 30, BufferObject 18, FramebufferObject 16, PipeFill 9). Nothing from a6's
184 has been cleared; the +2 are both P6.5's:
Client::ClientSession::StartSpawned() - fork-per-session
GLState::ProgramArtifactsSchemaFingerprint() - archive v2 caps
The baseline stores the LIST, not a count (ID-P7-7): a count-only ratchet goes green on the
day one symbol is cleared and another added. --assert-monotone fails only on symbols absent
from the baseline and names each one's defining object and every referring object; symbols
that disappeared are reported with an instruction to re-baseline in the same commit, and
never fail.
The CI step goes in build-linux-split, not build-linux: build-linux takes the three options
from MOBILEGL_CI_DISAGGREGATED, which is ON only on feat/disaggregated and
codex/fix-github-ci, so on any other branch it builds a monolith and the gate would measure
a SERVER set with no transport half. The JSON is uploaded even when the gate fires.
Two findings for the integrator, both written up in the note:
- a6's four published bucket totals sum to 181 against its own headline of 184. These
buckets are a strict partition and always sum to the headline; the difference is in
a6's core bucket, which it listed selectively.
- 5 of the 14 "P3b/P4b" symbols are MG_Pipe slot-allocator and apply-thread plumbing
that sits under MG_Impl/Pipe/ by file location alone. P3b/P4b cannot clear them by
migrating Espryt functionality; they need P13's module move. That bucket's floor is
5, not 0, so the plan's 0.5-day Tier 1 estimate for "D12's 14 symbols" should be
re-cut against 9. All 7 symbols from that object carry a `# P13` annotation.
red-once (R-16) is recorded verbatim in the note. Worth knowing: the plan's own example
(SamplerObject::GetMinFilter) does NOT turn this gate red, because it is already in the
baseline - the ratchet reddens on new symbols, so a negative control has to pick one that
is not in the 186. A reach to GetBorderColorForm() from DirectGLES/Utils.cpp does, naming
the symbol, its definer and its referrer, exit 1; reverted and green again.
Tier 2 replays the split-arm registrations - ~100 scenarios, the set Espryt's disaggregation work
has carried since P5 - and it measured green on Magma across all three transports. That is a real
result but it is NOT exit gate 1. The gate says the INTEGRATION BINARY is green under Magma split,
and the set that names is the one the monolith DirectVulkan. arm runs: the whole suite minus
MGL_ITEST_MONOLITH_MECHANISM_FILTER. 510 cases against tier 2's 102, and the readback matrices,
the same-texture copy shapes and the odd-offset uniform ranges are in it and nowhere else in the
disaggregated registry.
integration-magma-full-{split,spawn,tcp}, in no gating step, private log per entry through a list
of its own. THE RESULT: 453/0/57 on inproc, 453/0/57 on spawn, 454/1/57 on tcp - and not one of
the 1530 entries reaches any of the tree's fifteen @P7 named refusals. The single red is
IterationRPProgram203Scenario.FixedCompleteInputProducesFixedCompleteGoldenOutput on the TCP arm:
one texel of 262656, texel 0, which is the case's own exposure value
(IterationRPProgram203Scenario.cpp:371, actual half bits (0x357a, 0x4b65) against golden
(0x3a74, 0x4821)). Deterministic 5/5 on tcp; 5/5 GREEN on inproc, on spawn and on the monolith
control. spawn and tcp differ only in MOBILEGL_IPC_DATA, so the suspicion is the STREAM data path
and not "two processes"; the GLES tcp lane is green in the same run, so it is not the fixture.
Not fixed here - it is what the census is for.
TWO ENVIRONMENT DIFFERENCES FROM THE GATED TIERS, both measured rather than assumed:
* NO MGITEST_SPLIT_LANE=1. That marker arms ScenarioFixture's destructor assertion - a case in
an armed split lane must have moved the client encoder's record ordinal - which is right for
a curated lane and wrong for a whole-binary census. With it set, the first run produced 13
reds, all `EmitSeq was 20 at SetUp and is 20 now`: AdvertisedLimitsScenario (8),
SpirvShaderBinaryScenario (4) and ProgramPipelineScenario.AReservedName - a limits query is
answered from the client's CapsMirror and a shader-binary error surface never reaches a verb.
Those 13 would red on a DirectGLES census too. What it gives up is stated in the comment: this
tier does not prove its records crossed, and the tcp topology proof is inert in it.
* DualBlockScenario.* filtered out. Its green REQUIRES MOBILEGL_IPC_ROLE_SPLIT_STATE=1 and a
census must not set that knob; without it the case is red by name, which is its documented
negative-control behaviour. The monolith arm skips it for the same class of reason.
CtWireScenario gets MOBILEGL_TEST_SERVER_COUNTERS=1 in this tier - a switch that makes a counter
readable, not an oracle that asserts - because without it the spawn arm's ApplierReset case reds
on "server control-record telemetry missing", which says nothing about Magma. The strict-arming,
pmap and persistent-map-arm markers stay out.
The inproc-only exclusion in spawn_lane_parity.py is now PER TIER: in the gated tier the two
scenarios are registered on the inproc arm alone, in the census they are on every arm and simply
skip, so excluding them there would manufacture a spurious "extra".
Measured after the change: every pre-existing lane unchanged and green (magma-split 73,
spawn 52, tcp 54, all-* 102/102/104, GLES 186/102/104, buffers 19, runahead 14, caches 7), the
dual-block census 257 green / 2 skipped / 0 red with an empty ratchet, both new CI steps green,
parity RC=0 across all three tiers, G14 4155 -> 6095 with removed=0.
The note now carries the grouped red list, the 57 named skips by reason, the four pieces of
evidence that the tcp red is a defect and not plumbing, and six findings - the first being that
wave 2's clusters cannot be scheduled from this lane's red list, because on lavapipe there is
essentially none.
Integrator ruling, completing the alignment package L started. The split retrace job now also
exports MOBILEGL_MAGMA_R11G11B10F_FALLBACK for every DirectVulkan row and
MOBILEGL_MAGMA_DISABLE_BLENDED_DEPTH_WRITE for improved-transparency-minecraft-26.3, copied
from the monolith job (:2180 / :2195) with the same guards.
The point is comparability, not the knobs: a split retrace whose monolith control ran a
different repair set cannot say whether a divergence is a transport fact. With the three
iterationRP repairs from the previous commit, every DirectVulkan row of the 154-cell split
matrix now runs the quirk configuration its control runs.
retrace-verify is left alone, as the ruling says.
Integrator ruling on the asymmetry package L reported. The six hand-laid Magma lanes (Fm,
Buffers, RunAhead, Caches, NamedBlit, P5fRsp) appended ${MGL_ITEST_COMMON_ENV}, so the monolith
DirectVulkan. arm ran with VK_ICD_FILENAMES pinned and the three lavapipe iterationRP repairs
forced on, while the 73 integration-magma-split entries ran against whatever ICD the loader
found first with those repairs at their device auto-detection default.
That is a latent flake rather than a semantic: the day a runner or a workstation grows a second
ICD, the lane measures a different driver from the gate it mirrors and nothing in its output
says so. Consistency wins - all six now append ${MGL_ITEST_VULKAN_ENV}, the same list the new
MGL_ITEST_VULKAN_{SPLIT,SPAWN,TCP}_ENVIRONMENT use.
The ICD pin follows the BACKEND, not the lane, in the two blocks that register both: NamedBlit
and P5fRsp loop over DirectGLES and DirectVulkan, and handing VK_ICD_FILENAMES to the DirectGLES
half would pin an ICD for a lane that never opens Vulkan. Verified in the discovery: all 73
integration-magma-split entries carry VK_ICD_FILENAMES and all three repair variables, and the
two DirectGLES.Split.NamedBlit. entries carry neither.
Re-run after the change, as the ruling asks: integration-magma-split 71 PASS / 0 FAIL / 2 SKIP
out of 73, unchanged - the two skips are the same ClipDistance pair with the same reason.
Three changes, one theme - making the two halves comparable, then gating what measured green.
retrace-split's "Retrace and validate under MOBILEGL_TRANSPORT" step exported no Magma knob at
all, while the monolith retrace job (:2183) exports three iterationRP repairs for
minecraft-1.21.4-fabric-iris-iterationrp-in-world x DirectVulkan. Those repairs are tri-state
quirks defaulting to device auto-detection and lavapipe is on no auto list, so that cell of the
split matrix (154 cells, and it contains that case x DirectVulkan x {inproc, spawn}) was being
compared against a monolith control running a different repair set. The same backend+case guard
is now copied verbatim into the split job.
Two gating steps for the lanes this wave added, and ONLY because they measured green on the
first run: integration-magma-{spawn,tcp} run like the existing magma-split step, and the
nineteen application-buffer cases get require_green BY NAME on both new arms - the tcp arm's
required list includes TcpServer.Start / Stop on purpose, because ctest lists the fixture
entries under the label and a lane whose supervisor failed to start has nothing to say about the
nineteen. The informational integration-magma-all-* tier is deliberately in no gating step.
Both steps were executed locally, not merely linted: spawn 19/19 and tcp 19/19 with
"require-green: N PASS, 0 skip, 0 failed, complete discovery". All 99 run: blocks in the
workflow pass bash -n.
The note records the first run in full: per-lane PASS/FAIL/SKIP, the two blocker groups the
first registration attempt produced (both lane plumbing, neither a server abort, wrong pixels
nor a hang), the arming evidence, both red-once demonstrations, the parity and G14 numbers,
what was NOT verified (no device, no retrace run, no G1) and six findings for the integrator -
the first of which is that P7 exit gate 1 has no host-side red list at all, which the wave 2
plan is built on top of.
WireTables.cpp:740 installed the client-side object_death emitter only when
ActiveBackendType == DirectGLES. P6.5 wrote that because DirectGLES was the only backend with a
two-process lane, so the condition read as "the case this can happen in" rather than as a policy
- and under a Magma spawn or tcp client it is a silent hole of exactly the shape P6.5 closed for
Espryt: no notice installed, NotifyAndFree drops every object_death before freeing the slot, and
CtWireScenario's two death cases report ObjectDeaths=0 and prove nothing.
The two remaining conditions carry P6.5's intent, both halves of it: Transport == Spawn (this is
the remote client, the one with no local twin table; under inproc the backend's own dispatcher
is the right one) and GetStateObjectDeathOps() == nullptr (whoever installed first keeps the
notice).
RED-ONCE: with the install site short-circuited, both DirectGLES spawn death cases red on
CtWireScenario.cpp:187/:246 with
Expected: (afterCounters.deaths) > (deathsBefore), actual: 0 vs 0
which is the silent zero this closes. Restored and green.
WHAT IT DOES NOT DO is give Magma a death table - that is wave 2 package C. But it already does
more than the plan predicted: with CtWireScenario's own BackendName() != "DirectGLES" skip guard
temporarily lifted (a probe, not committed), both DirectVulkan SPAWN death cases PASS, while the
DirectVulkan inproc arm still fails. So the two-process Magma death path works today and the
remaining gap is the inproc arm alone. Recorded in the package note; narrowing that skip guard
is package C's call and its file.
P7 exit gate 1 asks for integration green "under Magma push and split". Until now it was not
merely unmet, it was UNMEASURABLE: every arm of mgl_itest_register_split_arms hardcoded
MOBILEGL_BACKEND_TYPE=DirectGLES (:2002/:2025/:2031) and MGL_ITEST_MAGMA_WIRE_ENVIRONMENT
hardcoded MOBILEGL_TRANSPORT=inproc (:2456), so the shared integration-split / -spawn / -tcp
labels held ZERO DirectVulkan entries and the 73 integration-magma-split cases only ever ran in
one process.
The registration macro now takes the backend as its first argument; the old name is a thin
wrapper, so all 32 call sites are untouched and every DirectGLES name, label and private log
path is byte-for-byte what it was. Two tiers come out of it, and the CMake comment states both:
* GATED - integration-magma-{split,spawn,tcp}. The six hand-laid Magma lanes (Fm, Buffers,
RunAhead, Caches, NamedBlit, P5fRsp) grow arms through one shared shape macro; the inproc
arm keeps its names, its labels and its log paths, so the CI steps and the two magma_*
checker scripts that name them by hand keep working unchanged.
* INFORMATIONAL - integration-magma-all-{split,spawn,tcp}, in no gating step. Every scenario
the split arms run, on Magma, on all three transports, generated by REPLAYING the macro's
recorded call list - so a scenario added to the split arms tomorrow joins the Magma census
the same day. Its job is to be able to go red while CI stays green; a case moves up into the
gated tier with the commit that retires its blocker.
The label spellings are chosen against ctest -L's regex (ID-131 again): the tier word goes in
the MIDDLE, because integration-magma-spawn-all WOULD have been selected by
-L integration-magma-spawn and pulled ~100 expected-red entries into a gated lane. Sub-labels
mirror it: the inproc arm keeps integration-magma-buffers, the others put the arm word first.
MagmaRunAheadScenario and MagmaWireCacheScenario stay on the inproc arm alone, and that is
MEASURED rather than assumed: with all three arms registered, all 21 entries red on spawn and
tcp with no Fatal in either role log - the first arms its hold through
ServerLoopInstance().SetBeforeRetireHookForTesting, a hook on THIS process's server loop, and
the second asserts transportName == "inproc" in its SetUp. Both are named in the CMakeLists and
in spawn_lane_parity.py rather than allowed to widen a tolerance.
split_log_paths.py's is_split prefixes gain DirectVulkan.Spawn. / .Tcp. - on those arms two
processes append to one path, so a shared log does not merely blur attribution, it interleaves
two sessions. spawn_lane_parity.py gains the Magma dimension, normalising only the ARM segment:
RunAhead.Credit1. and .Credit3. are two entries of one case and a tail-stripping normaliser
would fold them together and then report an equal-sized set that had lost one.
Measured on this tree (lavapipe): magma-split 73 unchanged and green, magma-spawn 52/52,
magma-tcp 52/52, the three informational lanes 102 each, GLES split 186 / spawn 102 / tcp 104
all unchanged and green. G14 against ~/w7/p7-before: 4155 to 4565, removed 0, added 410.
Device window #1 E0a: monolith x DirectVulkan on the Redmi is 0.996-1.000 against the
desktop goldens (only OpenRA is 1.0), so "monolith 1.0" was an OpenRA fact, not a rule.
Gate 3 now reads: per case, inproc and spawn bit-identical over three repeats, within
the case threshold, and within 0.0005 of the same-session monolith reading. The
denominator excludes what monolith itself fails - create-indirect (OQ-17), the
1.21.11 main-menu trace artifact, and iris-photon, whose pipelines Adreno 830 refuses
with VK_ERROR_UNKNOWN (PipelineFactory.cpp:660) - which leaves 36. Gate 5 records the
real block sources: UBO lives only in the GTF module, SSBO joins, gl33 is the fallback.
On Git Bash the script protects its own adb calls with MSYS_NO_PATHCONV=1, but the
two python invocations relied on the caller NOT having exported it; a session that
did (for the adb-side /data/... arguments the runbook mentions) got json.load failing
on /c/... for a result.json that existed - every case reported "trace replay failed"
over a passed result. host_path_for_adb already exists for exactly this; use it.
The application id is top.mobilegl.plugin.p7w1.trace (flavour suffix after the id
suffix), pgrep -f dex2oat matched the shell running the loop and never exited, and
adb.exe cannot stat an MSYS /c/ path once MSYS_NO_PATHCONV is set. All three were
hit on the first execution of section 3.
KHR-GL46 has no uniform_buffer_object group; the 90 UBO cases are
GTF-GL46.gtf31.GL3Tests.uniform_buffer_object*, present only when the deployed glcts
was built with GTF - the device probe decides. KHR-GL46.shader_storage_buffer_object
(124) is the block that exercises what cluster A actually refuses today: unaligned
and over-range SSBO bindings, not only UBO byte tails.
Cut from VK-GL-CTS 0b04c470ed78569b0b778ece1262d88455ab2b00 (main; the deployed glcts build's source) mustpass
gl46-main.txt and gl33-main.txt: texture_*, shader_image*, packed_pixels,
direct_state_access, uniform_buffer*. The gl33 variants are the fallback the runbook
names for an Android runner that cannot open a 4.6 core context; whichever version the
probe selects, BASE and AFTER read the same list. A 0.5 pp gate needs a committed
denominator, not a glob over someone's checkout.
Two corrections to the two commits before this one.
The widening's docstring was written against the 83 sites it found, and the commit that
routed five of them through Session::Fail did not revisit the prose. It now describes the
79-site floor the baseline actually records, says which five went and where, and states that
the enforceable artefact is the baseline's per-file map rather than the counts in the prose -
so a reader who finds the two disagreeing knows which one to believe. The three buckets are
restated too: `(ii)` is no longer "owned by a Ph / P7 row", because with the Magma wire arms
routed what is left there is the peer-reachable deaths in the applier and the two backends,
which is exactly the set ID-P7-1 scopes to Ph's fuzz arm 2 AFTER P7.
And the routing commit's own comments spelled the at-sign refusal marker while explaining
what routes through them, which put SEVEN comment lines into the retirement census: `grep
@P7` went from 15 sites in 4 files to 22 in 6, and that grep is how wave 2 will measure which
refusals it has retired. The prose says the word instead, and the count is 15/4 again.
Ph slice (1) (ID-P7-1). Three `::waitpid(active, nullptr, WNOHANG)` calls in the `--serve`
loop threw the status away, so a session child that died of SIGABRT inside SessionFail and
one that returned from its control loop and _exit(0)'d were the SAME EVENT to the
supervisor: `active = -1`, no line, nothing to count. ID-P7-1 declines the literal 98-site
latch translation because P6.5's fork-per-session already delivers "the next connection is
served as usual"; what was missing is that anyone could TELL a faulted session from a
finished one, which is what an operator, a fuzz arm and a flake triage all actually need.
One `ReapActiveSession()` where there were three call sites, asking WIFEXITED / WIFSIGNALED /
WTERMSIG - the same three questions ServerSpawn.cpp:289 asks of the same kind of child - and
logging `pid=<n> reaped exit=<c>` or `signal=<n>` with the supervisor's lifetime
`sessionsFaulted` beside it. A refused handshake exits 0 and is NOT a fault, which is right:
protocol.fbs:86 says a rejection is a normal session outcome. The running total is on every
reap line as well as on the summary, because the fixtures end a supervisor with SIGTERM and a
figure only printed at a chosen exit would never be seen.
THE IDENTIFIER ROW'S PREMISE IS WRONG, MEASURED. The plan asks for
CtrlEnvelopeBufferHasIdentifier at ServerMain.cpp:193 because ServerSession::ParseEnvelope
asks it and this loop did not - but the generated VerifyCtrlEnvelopeBuffer already IS
`VerifyBuffer<CtrlEnvelope>(CtrlEnvelopeIdentifier())` (protocol_generated.h:2378), so a
wrong identifier was refused here and ParseEnvelope's second call is redundant too. Recorded
for the integrator. What was real is that both refusals were SILENT: a peer whose
post-Welcome frame was garbage had its session ended with no line in the log, and a fuzz arm
that cannot tell "refused, by name" from "the child went away" measures nothing. So the
identifier is now asked first - it is the specific diagnosis where the verifier's is the
general one - with the length guard that asking it first requires, and both say so.
scripts/ci/tcp_supervisor_smoke.py has been in the tree since P6.5 asserting four supervisor
controls and was referenced by NOTHING - no ctest entry, no workflow step - so nobody could
have said whether those four were green. It is now `TcpLane.SupervisorProtocolControls`
beside the TCP lane's own fixtures, labelled integration-tcp, under the lane's resource lock
(a second supervisor's child would answer the other's peers with Busy). It needs flatc, which
is deliberately not in the build graph, so it resolves one gen_protocol.py's way and exits 77
- ctest SKIP - when there is none, rather than passing on a run that did not happen.
Two cases added to it: a session child killed with SIGABRT must be named by pid and
`signal=6` with `sessionsFaulted` one higher than before, and the next Hello must still be
welcomed; and a steady-state frame carrying the wrong file identifier must be named rather
than silently dropped.
Red-once, both ways, against the real image: making the reap discard the status again reds
with "the supervisor did not name session pid=N as killed by signal=6", and deleting the
identifier branch reds with "read a frame with the wrong file identifier without saying so".
Five deaths in the split server image raised their own std::abort(). Three of them are
funnels - MagmaWireFatal (Renderer/WireFramebuffer.inc), WireDescriptorFatal
(Renderer/UniformManager.cpp) and WireBufferLegacyFatal (Renderer/WireDraw.inc) - and the
fifteen `@P7` refusals on the tree (8 in UniformManager.cpp, 4 in WireFramebuffer.inc, 2 in
WireDraw.inc, 1 in VulkanRenderer.cpp; plan section 1.3's count confirmed) all die through
two of them. Every one of those deaths published NO SessionFault frame, so the peer read a
bare EOF; bumped NO SessionFaultCount(), so exit gate S8 could neither confirm nor falsify
it; and carried a hand-typed family word with no path to FatalFamilies.def's projection.
MG_Backend may not name an MG_Remote symbol on its own account, so the route is a seam in
MG_Pipe with MGP_TRIP_WIRE_REPORT's shape (PipeApply.cpp:66, and the new code sits beside
it): MG_Pipe/PipeSessionFail.h declares MGPipeSessionFail, PipeApply.cpp defines it inside
`#if MOBILEGL_BUILD_DISAGGREGATED`, the DEFAULT behaviour is byte-identical to what the
three funnels did on their own, and MG_Remote::InstallPipeSessionFailHook() points it at
SessionFail. The install is in InitServerRoleCommon (MG_Backend/Init.cpp), step 0, because
that is the one function BOTH server roles run - the inproc role and the spawn/TCP session
child - so there is no per-transport install to get wrong.
MGPipeFatalFamily is a two-value boundary enum, NOT MGFatalFamily cast to an integer: the
.def's enumerators are positional and a row inserted in its middle would silently re-point
every hook call at a different family. FatalFunnel.cpp's adapter is the one place the two
vocabularies meet, as a switch with a named default.
The family word stays UnmigratedVerb rather than becoming a new MagmaWireRefusal. Three
reasons: the message strings are required to stay byte-identical (5.2), and the retrace
refusal censuses recorded since P5b count these deaths by that word - a rename invalidates
every `@P7` measurement taken against them; `Magma:` in the detail already separates the
Magma arm from the GLES UnmigratedVerb deaths, so a new word adds no discrimination a grep
does not have; and its wire projection would be ProtocolCorruption, exactly UnmigratedVerb's.
Recorded for the integrator.
Ph slice (2) in the same commit: Server/StagedShadow.h's RequireCovered and
Server/StagedTextureStore.h's RequireLevelBytes were MGLOG_F + bare abort. Both are inside
MG_Remote already, so they call SessionFail directly, message verbatim;
check_include_closure.py's four probes do not reach either header and the gate is green.
Census: 83 abort sites -> 79 over 20 files (five sites retired, one gained for the seam's
no-hook default, which is argued into FUNNEL_SITES), baseline rewritten in this commit - the
downward ratchet's notice is what asked for it. Two gate bugs the widening exposed and this
commit fixes: the SessionFail call regex also matched `MGPipeSessionFail(` (a lookbehind
now), and a funnel's anchor needed a wider window than a marker's (kFunnelAnchorWindow).
Red-once, three ways. Pointing MagmaWireFatal back at its own abort reds the census naming
WireFramebuffer.inc 0 -> 1. Making the seam skip the hook reds both new death cases -
`TheMagmaWireVerbDeathReachesSessionFailAndBumpsTheFaultCount` on the exit code, because it
reads SessionFaultCount() out of a SIGABRT handler and gets 0 instead of 1, and
`TheSeamHandsTheInstalledHookItsFamilyAndItsLineVerbatim` on the line the hook echoed.
`scripts/ci/fatal_census.py` scanned `MG_Remote/` and saw four abort sites. CONTRACT-P6
§12.1 already says what the server image is - "P6's server image is the whole
libMobileGL.so" - and the server process was executing seventy-nine more deaths the gate
could not see: 15 in DirectGLES.cpp, 12 in Managers.cpp, 11 in VkBufferManager.cpp, 7 in
PipeRoute.cpp. SCAN_ROOTS is now MG_Remote / MG_Pipe / MG_Backend / MG_Impl/Pipe /
MG_State, `.inc` joins `.cpp`/`.h` (the Magma wire arms and the generated wire records are
.inc files, and 20 sites are reachable only through one), and MG_Benchmark /
MG_IntegrationTest join MG_Test in the exclusion.
Four rules now, where there were two:
1. an abort names its family within twelve lines, OR it is argued into FUNNEL_FILES /
the new FUNNEL_SITES table. FUNNEL_SITES exempts an individual funnel inside a file
that has ordinary named deaths around it, and only when one of the file's anchors
appears in the same window - so the exemption follows the funnel if it moves, expires
if it is deleted, and does not cover an unrelated abort added to the same file. Two
entries, one line of reason each: PipeApply.cpp's MGP_TRIP_WIRE_REPORT and
PipeWire.inc's MGPipeWireProtocolFatal.
2. unchanged: a WireLogFatal / SessionFail call carries a `Fatal{` in its string.
3. NEW: every family word found in the perimeter has a row in FatalFamilies.def. Eleven
words did not, and now do (see below).
4. NEW: every `Refuse{Word}` names a RefuseCode enumerator. This is what catches
`Refuse{AuthenticationRequired}` (SocketTransport.cpp:161), a word protocol.fbs has
never had - PH-7 (2). Renamed to the enum's own `Authentication`, and
SocketTransportTest pins the spelling to `EnumNameRefuseCode` so the two cannot
drift again.
`abort_sites` was recorded and never compared. It is now a DOWNWARD ratchet with a
per-file map beside it, so "83 became 84" reports as "VkBufferManager.cpp went 11 -> 12,
here are its lines"; fewer sites - the direction `@P7` retirement moves the number - asks
for a re-baseline rather than blocking one.
Eleven new `.def` rows for the eleven rowless words. Ten are ProtocolCorruption for the
reason that bucket already states. ResourceUnavailable is ServerCrashed, not SegmentMismatch:
SegmentMismatch means a shared SEGMENT was the wrong size, and telling a peer that when a
VkBuffer allocation failed would be a lie about whose geometry was wrong. A twelfth word was
collapsed rather than given a row - `InitialBytesNotCarried` (PipeFill.cpp) said the same
thing as `UncarriedInitialBytes` (WireTables.cpp) about the same record, and two words for
one family is the drift a6 censused.
Red-once, four ways: an unnamed `std::abort()` appended under MG_State reds naming the file
and the line; deleting the `Fatal{` from PipeRoute.cpp's ResidualBlockSize reds; putting
`Refuse{AuthenticationRequired}` back reds rule 4; a typo'd family word reds rule 3 and the
vocabulary ratchet together.
One reboot-clean session on Redmi 2f7cbe2e, copy-pasteable, with the shell each
block runs in marked: pin and wake/Doze, APK install identity, P7-7 E0-E3, the
P6.5 residual 39-case DirectGLES TCP golden matrix, the CTS $BASE for monolith x
DirectVulkan, and the temp-environment restoration. Every step states in one line
which result supports which hypothesis, and where the evidence is archived.
Four things the runbook had to settle rather than restate:
E1's proof. `running lockstep` is NOT the line that proves lockstep took effect:
ClientSession.cpp:1873 guards it with `RunAhead != 0 && m_barrierArmed`, so it is
the "the knob is set but the server lacks kCapRunAheadApply" warning and
MOBILEGL_IPC_RUN_AHEAD=0 prints nothing at all from LatchRunAheadFromCaps. The
proof is a line present and a line absent: `Config: IPC ... run-ahead=0 ...`
(ConfigLoader.cpp:459) with zero `run-ahead ARMED` - the shape p5f/magma-runahead.md
already recorded for the RA=0 arm.
E1 will exit non-zero, by design. --transport inproc adds --require-inproc, whose
gate demands run-ahead=1 in the Config: IPC line (trace-replay-ci.sh:601), and the
proof runs AFTER result.json is pulled and checked. So the SSIM and the archive are
usable and the arm is verified by hand against the table above; dropping the --env
to make it green would delete the experiment.
E0's budget. 39 cases x 2 transports x 3 repeats is 234 replays, roughly 3-4 h,
which does not fit beside the TCP matrix and CTS in one window. It is split into a
one-pass census that produces the divergent-case list and a three-repeat pass over
that list plus two controls - the determinism question only has meaning for cases
that already diverge.
CTS is flagged, not promised. No caselist is in-tree, and the local VK-GL-CTS
checkout has an empty external/openglcts/data with no mustpass file anywhere, so
the five blocks cannot be cut in-tree; the exact pre-window procedure to fetch a
tagged checkout and commit tools/cts/caselists/p7-*.txt is written out instead.
And KHR-GL46 is unproven on the Android runner - every device reference result in
the skill is KHR-GL33, the 4.5 numbers are desktop lavapipe, and the platform hands
the package's version straight to eglCreateContext - so the runbook carries a
five-minute pre-window probe and an explicit integrator decision: if 4.6 is
unreachable the baseline is taken on KHR-GL33 and the AFTER reading must use the
same version, because 0.5 pp between two different API versions means nothing.
wave 0 item 5. Three instruments P7's device window needs, and the CI steps that
can make each of them red.
run_tcp_matrix.py --backend DirectVulkan end to end. --backend already existed and
already filtered by each case's ci_backends; what did not exist is a per-backend
GOLDEN. The two backends shared `golden` plus an optional `alternate_golden`, and
alternate_golden is an OR across backends, not a per-backend key - so a
DirectVulkan run that matched the DirectGLES picture passed, which is the "went
green on the wrong arm" shape the exit gate exists to catch. trace_cases.json
gains `backend_overrides.<backend>` ({golden, alternate_golden, ssim_threshold}
only: restating target_call or a crop would stop the two backends retracing the
same frame through the same window). A per-backend golden REPLACES the shared
pair, matching another backend's declared image is refused BY NAME, and a --case
named explicitly that does not run a requested backend is an error instead of one
plan fewer and no message. The CMake emitter and the APK matrix refuse a case that
declares the key rather than silently emitting one golden for both arms.
DirectGLES is unchanged and that is asserted, not asserted-in-prose: a case with no
override resolves to itself, and the key is dropped from the resolved case even for
the backend that overrides nothing - so giving DirectVulkan its own golden does not
move the DirectGLES identity fingerprint and does not invalidate its checkpoints.
Each results.json row now records the golden, alternate golden and threshold it was
scored against.
run_android_retrace_local.py gets --matrix (the CI split subset, 39 cases, each
running only its own ci_backends - not --all, which adds the rd12 workload ID-P7-4
excludes), --repeat N, and --archive-dir. The archive is the point: the result root
is keyed by case and backend and by NOTHING else, so repeat 2 overwrites repeat 1
and the only artefact that survives a three-repeat run is the last one, which makes
"were the three repeats the same picture?" unanswerable after the fact - in the one
place where re-running to find out is expensive. It also refuses --benchmark with a
non-monolith --transport, which has always produced a monolith run under a spawn
label (run_benchmark_case passes args.env alone; p6/README.md records the pitfall).
compare_actuals.py computes the pair result.json cannot: SSIM between two actual
PNGs, plus a case x repeat x ssim-vs-golden x ssim-vs-first-repeat table over an
archive. It is the retrace gate's OWN SSIM - a transcription of ComputeChannelSsim
/ ComputeRgbSsim (trace_replay_core.cpp:630/:671), the only SSIM in the tree - and
the crop comes from each repeat's own result.json, so the two columns describe the
same rectangle. PNG decode is stdlib zlib, because an unavailable Pillow in the
device window would turn "the repeats diverged" into "the tool did not run".
TraceReplayActivity: resolveSpawnServerPath's two decisions were String.contains()
on the whole joined K=V;K=V list and neither was entry-aware. The one that bit:
`--env MOBILEGL_IPC_SERVER_PATH` with no '=' is the documented UNSET spelling
(trace_env_overrides_test.cpp:92) and the only way to ask for "spawn with no image"
and watch LaunchServer refuse by name - it did not match "MOBILEGL_IPC_SERVER_PATH="
so a resolved path was appended AFTER it and, because ApplyEnvOverrides applies in
order with setenv(...,1), the deliberate unset was silently undone. That is exactly
the "silently replacing it would make that knob untestable" the method's own comment
forbids. Two more: the key inside another entry's VALUE flipped both decisions the
wrong way, and the transport match was case-sensitive while ConfigLoader lower-cases
the value before matching (ConfigLoader.cpp:322). The parse moves to
SpawnServerPath, a class with no android.* import, so test_android_lifecycle.py
compiles and runs it with plain javac - the parse had no host-side coverage at all,
and its on-device failure mode is "the knob had no effect".
Only spawn gets a path, deliberately: it is the only transport that execs an image.
Red-once, all four: a stub case whose result names the DirectGLES golden fails a
--backend DirectVulkan run with "matched a golden another backend declares"; a
synthetic PNG pair with a closed-form SSIM (identical = 1.0 exactly, constant
black vs white = C1/(255^2+C1), inverted halves = (C2-2v)/(2v+C2)); the pre-fix
resolveSpawnServerPath body fails eight of SpawnServerPathTest's checks; and the
DirectVulkan TCP proof battery is the DirectGLES one under the other prefix,
because an empty prefix selection raises nothing and reads as a clean pass.
CI: test_run_tcp_matrix.py and test_compare_actuals.py join the R-16 step. The
matrix driver's stub regressions had NO CI step - they were run by hand and
recorded in a note - and they guard the tool that decides whether a device golden
run passed.
Counts in WSL: test_run_tcp_matrix 12 -> 18, tcp_lane_tools 4 -> 6,
test_compare_actuals 12 new, test_android_lifecycle green with the new Java unit.
ROADMAP.md: the Ph / P3b-P4b / P7 rows carry the audit's verdicts and the factual
corrections it found - the @P7 refusal list is 15 sites in 4 files (the row printed 8),
the 29-commit DirectVulkan shape wave was never recorded, the device divergence is two
observations with no per-case attribution and no lockstep control, SetupDrawSnapshot /
content-addressed CSO are declined for this stage, "XFB scatter to client" is retired
in favour of the two real holes, readback/pack state splits into closed and open
halves, the NDK size pin is obsolete, PACK-PBO readback landed in 70df57bb, the ABI
fingerprint landed in P6.5, and OQ-8 / OQ-10 / OQ-13 / OQ-21 get their current answers.
P6-ENDSTATE-REVIEW.md:284 qualifies an Init.cpp citation the lint reported ambiguous.
MG_Remote/CONTRACT-P7.md: rules I (no std::abort on the Magma wire arm) and J (every
fix reds once on a two-process arm), the five exit gates in testable form, the
retire-vs-decline table for every @P7 name, the ratchet's definition and P7 target,
the nine-subsystem denominator for the 40% checkpoint, the wave-2 file partition,
and the amendments to CONTRACT-P6. Census, ratchet and lane numbers are marked
待填 until wave 0's packages report them.
Plan for driving the roadmap to P7 close-out, from a per-item audit of the three
rows against a96875bf (19 reader groups, two refuters per done/obsolete verdict,
one completeness critic): what is landed, what is declined and why, what remains
with file anchors, five waves, the P7 subsystem denominator for the 40%
re-baseline checkpoint, and eleven integrator decisions (ID-P7-1..11).
The three findings the roadmap text did not carry: Magma has zero two-process
integration coverage (every split arm hardcodes DirectGLES), the 184-symbol
ratchet has no instrument, and the device DirectVulkan divergence was never
diagnosed (the lockstep control was never run under golden).
MG_Test/Util/PipeStatsTest.cpp: the pull flavour (MOBILEGL_PIPE_PUSH=OFF) did not
compile since the P6 wrap-up - the window-arithmetic case used the push-only
PipeStats::Gauge API unguarded. CI's pull control builds with
MOBILEGL_BUILD_TEST=OFF and never saw it. The gauge lines are now #if
MOBILEGL_PIPE_PUSH; the case name stays registered in both flavours for G2/G14.
build-linux-verify could not compile PipeStatsTest: RoleDerivedJsonDumpPathForTesting is DISAGGREGATED-only and the verify flavour is deliberately monolith. The case name stays registered and GTEST_SKIPs there, matching the PIPE_PUSH pattern, so ctest -N still matches between flavours.
runtime_mode_proof.py logs opened MOBILEGL_LOG_FILE_PATH as a file. After the split that name is a base and the sink writes <base>.client.log / <base>.server.log, so the inproc proof died on FileNotFoundError after a green lane. It now reads through split_log_paths' role helpers and names the paths it looked for when none exist.
The device gate: Redmi 2f7cbe2e, reboot-clean, paired A/B in one thermal
window (arms interleaved, best-of-3 then best-of-2), rd12 + DirectGLES.
inproc vs spawn client CPU p50 7.880/7.889 vs 7.876/7.869 (-0.15%) and
7.987/7.989 vs 7.992/8.009 (+0.16%) - a tie inside the 0.2-4.2% within-
arm spread, sign flipping with arm order, recorded and not gated.
monolith is ~30% dearer. 22/22 runner exits clean, 44/44 PINNED, zero
Fatal.
The three mandatory numbers: socket doorbell vs condvar is a tie too -
client 121.1 waits/frame identical across transports, server 10 866-11
963 (inproc) vs 10 415-10 416 (spawn) waits/frame, and the contract's
'converting that into ~4 blocking reads may be cheaper' did not pay off;
the only column where the socket is worse is clipark/f (6.07 vs 5.60/5.75).
SEG_STAGE bytes/frame: 130.8 KB median / 8.01 MB steady (OpenRA), 816.2
KB / 1.53 MB (rd12). Records/frame post-chunking: 211/206 and 7 497/7
504. maxrec stays 1808 - the 8 MiB chunk budget is never reached. Both
framings reported throughout (the startup window runs ~7x the steady
state) rather than the flattering one.
MEASUREMENTS.md gains section 12 with the full protocol and tables; the
6.3 'no post-chunking distribution exists' gap now points there.
CURRENT_STAGE_PROGRESS.md marks gates 7/8 met and records the closeout
work plus two structural limits (spawn client windowed fields are zero
by construction - OnPresent runs in the server process - though its run
totals are valid stores; the stats dump is role-derived). ROADMAP marks
P6 closed in the stage table, the header and the milestone.
Evidence: notes/p6/gate7-device-ab.md, gate8-doorbell-device.md,
gate8-wire-counters.md, git-worktree-fix.md. check_doc_citations: 82
citations in the three documents, 0 problems.
Gate 8's last two numbers (SEG_STAGE bytes/frame, records/frame post-
chunking) had no producer at all: the summary line's byte classes are
backend-to-driver bytes and maxrec is a max, not a per-frame. Two push-
guarded counters answer them at their single choke points - ByteClass::
StageSegmentBytes at PipeWireCodec's StageAllocate (the only allocation
behind StageBytes, so every buffer walk, texture slab and CSO archive
lands there and emitters cannot double-count) and CallClass::WireRecords
on EncodeRecord's committed path (post-chunking an emitter no longer
knows how many records its one call produced). A staged-blob histogram
rides along. G1 holds: symbols 0/0/0/0, .text byte-identical, pull build
carries no MG_Remote symbols.
The spawn server never counted anything: PipeStats::Init() is a step of
MobileGL::Initialize(), which ServerMain never reaches, so every Enabled()
site in that process - including ServerLoop's own ServerWaits/ServerParks
publish - stayed false and the server log carried no summary line at all
(a zero that means 'another process' printed in the shape of one that
means 'never waited' is the milder misreading; the line was simply
absent). ServerMain now arms and closes PipeStats at its role boundary -
after MG_ConfigLoader::Init (Init latches from the parsed config), after
the server-role latch (the log sink's role), and before ::_exit(0).
Red-once: 0 server summary lines before, 29 after, SSIM 1.0 unchanged.
MOBILEGL_PIPE_STATS_FILE now derives per role through the log sink's
exported RoleLogPath rule - run.client.json / run.server.json, the bare
name resolves to no file - because both roles reach FormatJson() and ofstream
trunc made the second writer replace the first. G1 caught three separate
leaks on the way (a string temporary in the monolith body, one resized
Shutdown() from unequal #if arms, two rewritten MGLOG_W literals in
.rodata); the pull arm is statement-for-statement the original.
Device tooling for the paired A/B lands with it: pin_device.sh gains a
measured 2f7cbe2e entry (this board clamps the GPU at 1050 MHz -
thermal_pwrlevel writes 0 and reads back 1, max_gpuclk is read-only;
absolute times are not comparable with the 1100 MHz-era rows), and
tools/device_bench/p6/ carries the session collector and renderer beside
run_android_retrace_local.py's MOBILEGL_TRACE_PACKAGE override.
Unit 2367/2367 (PipeStats 25/25), spawn integration 102/102.
ROADMAP gains the section "the immediate next item": a design, not an
implementation, for running the frontend on an x86_64 WSL process and the
backend on the Redmi over TCP, with the integration lane and the CI retrace
subset as the acceptance. The P6.5 row now points at it.
What the design rests on, verified in the tree: the control plane already
treats the rendezvous as a NAME with two ordered connections (SocketTransport),
ClientSession::StartOverSocket already separates "how the socket came" from the
handshake, MGPBlobRef addresses kSegStage only through one ResolveOrFatal, the
reply header and the event heads are already wire-shaped, LogLine exists in
CtrlMsg with no producer, goldens are shared between desktop and device, and
the trace APK can already exec libMobileGLServer.so from nativeLibraryDir.
What it does not rest on, stated rather than assumed: ILink is declared and
unused beyond LinkProgress, ShmLink does not exist, both sessions hold
SessionSegments directly - the lk seam body from the end-state review was never
built and is this wave's first package (lk2). The manifest has no INTERNET
permission.
The mechanism: StreamLink as mirrored private segments replicated by messages -
same sizes both ends, same offsets, so the codec, the watermark rules, the apply
thread and the encoder do not change; RingControl becomes Progress frames sent
on flush-on-idle (the recorded amendment to "appliedSeq is never lazy": late by
at most one flush, never early); replies become seq-stamped messages; the event
ring is mirrored with the P5e blocking rule intact. Cross-architecture makes
the PipeFields.def layout digest mandatory (wf). Server logs on another machine
make LogLine forwarding mandatory (lf) so the 34 server-log readers stay
unchanged. A --serve supervisor forks one child per session on the device,
started by a foreground Service in the trace APK.
Packages lk2 -> wf -> ct -> sl -> lf -> sv -> t65, each with red-once; the gate
list, the mandatory numbers (kWaitReply per frame x RTT, SEG_STAGE bytes/frame
against the link, PRESENT_CREDIT 1..3), the two-sided configuration runbook and
the risks (RTT per reply row, single large uploads, AP isolation) are in the
section. check_doc_citations: 74 citations, 0 problems.
The end state is re-ruled: client and server may sit on different machines,
OSes and architectures, joined over TCP. The AVF pVM / AF_VSOCK end state the
P6 end-state review assumed is withdrawn (retraction at its head, form D added
to its table, the vsock probe and both external unknowns struck).
The transport stack becomes two independently selectable axes, negotiated in
the handshake and mixable: control plane (ITransport: fork / unix:<path> /
tcp://host:port) and data plane (ILink: ShmLink / StreamLink). On one machine
control over TCP with data over shared segments is a legal pair; ShmLink brings
its own AF_UNIX aux rendezvous for SCM_RIGHTS, so ITransport narrows to control
frames. `auto` is decided by a successful segment delivery, never by inspecting
addresses, and a fall-back is named. Nothing above Transport/ may branch on the
link kind. ARCHITECTURE.md gains 11.9 for this; CONTRACT-P6.md gains 10.3.
ROADMAP: P6.5 and Ph get rows (both were referenced by the contract and absent
from the stage table); the IPC track becomes P6 -> P6.5 -> Ph -> P12 -> P9 ->
P10 -> P11(same-machine arm). Every later row is reconciled against the tree:
items already landed are struck (client vertex arrays, client-minted query
handles, credit default 1, SEG_EVENT overflow mechanism), the unreachable P7
gate (`nm | grep glslang` on a server image that IS libMobileGL.so) is replaced
by the 184-symbol ratchet share, fixed-width wire form moves from P7 to P6.5,
P12 is cut down per the review (server-owned display, no Surface passing), and
the debt table's stale claims are corrected: class C is down to two slots, not
15 and not 511; PACK-PBO and the 2 MiB reply cap are P9's, not P6's.
Verified against the tree and recorded honestly: the contract's three hs rows
(LinkTerms, Refuse, the fingerprint split) never landed - protocol.fbs has
neither - so CURRENT_STAGE_PROGRESS.md's claim that S6 ran red is withdrawn and
the rows move to P6.5; the 184-symbol ratchet gate does not exist in CI; the
server process never initialises PipeStats, so gate 8's `srv=0 srvpark=0` is a
structural zero (fix in progress in the tree, not in this commit).
check_doc_citations.py: 159 citations, one pre-existing basename ambiguity.
The per-role log split renamed BOTH halves so that an un-updated reader would
fail loudly instead of silently reading half a session. It worked: CI found
four readers this way. This is them.
- MG_Util/Debug/Log.h: the role vocabulary now exists in the PULL build too,
header-only. It was wholly inside #if MOBILEGL_BUILD_DISAGGREGATED, so
PipeInputsTest - which builds in the verify flavour - could not name
ReadRoleLogs and took the whole build-linux-verify job down at compile time.
Inline rather than out-of-line because of G1: nothing in the library calls
these, so an inline definition emits code into the readers and NOTHING into
libMobileGL.so. Verified: pull symbols 30570 -> 30570, 0 added / 0 removed,
.text still 0xa52203, and zero role-log symbols in the pull library.
- PipeStatsWindow.h: the harness copy of the naming rule is now flavour-aware.
It derived `.client` in BOTH flavours, so in the verify build - one role,
MOBILEGL_LOG_FILE_PATH written unchanged - it opened a file that never
existed. The copy has to stay a copy: MG_Util's RoleLogPath is `t` (local)
in the shipping .so this module links, so calling it does not link.
Added LogMark/MarkLaneLog/ReadLaneLogSince: one offset PER ROLE, because a
single scalar over the concatenation slides by whatever the other role wrote.
- The four arming scenarios: they asserted on lines the BACKEND emits, and
under inproc the backend runs on the apply thread - the server role - so the
line lands in the server's log. Reading only the client's half reported the
emulation unarmed, i.e. accused the product of a defect the reader invented.
Four copies of the same private helper pair now delegate to the harness.
- scripts/ci/magma_runahead_checks.py: it unlinked and read the base name as
if it were a file, so the negative arm saw an empty log and could not prove
the knob took. It now imports split_log_paths' derivation - the authoritative
one - rather than becoming a third copy.
Local verification (WSL, lk-split + c6-pull):
- the 4 CI-red entries: 10/10 PASS under inproc, 10/10 under monolith
- unit role-log readers: 510/510 PASS
- magma_runahead_checks negative: run_ahead_0 EXPECTED_RED, restored GREEN
- the 5 affected TUs compile clean with MOBILEGL_BUILD_DISAGGREGATED=0
- G1: 0/0/0/0, .text 0xa52203
Red-once is the CI run itself (35677991453), which failed for each of these
reasons before the change.
All ten P6 packages (a6/c6/lk/so/sm/cp/hs/dl/st/t6) are on feat/disaggregated
through 16bfab10. The two docs now say so honestly:
- ROADMAP.md (cumulative): the status line, the P6 stage row, and a new
milestone bullet record every package landed, exit-gate items 1-6 met, and
items 7 (device paired A/B in one thermal window) and 8 (the three mandatory
perf numbers) still pending -- so P6 is "implementation complete, exit
perf/device numbers pending", NOT closed.
- CURRENT_STAGE_PROGRESS.md: rewritten to hold ONLY the current stage. All
P5* history is gone (it lives in ROADMAP.md and the P5* reports); what
remains is P6's package table, the CONTRACT-P6 section-9 gate with each
item's honest status, the device results, the per-role log split, and the
explicitly-not-P6 list.
§5.2 gains a LANDED note: the three steps (the FatalFamilies.def vocabulary,
the SessionFail funnel that byte-identically routes ~90 aborts through one
place, and the SessionFault frame that names the family to the peer), and the
one deliberate deviation from the prose - seq/op are not threaded through the
90 sites because the message already names the op, so the frame carries code,
family and message rather than a field that is always zero.
Step three of CONTRACT-P6 5.2, the funnel's whole point: instead of the peer
reading a bare EOF and guessing, the session publishes a SessionFault frame
that names the family before it aborts.
THE WIRE CHANGE IS D2's, exactly: `table Fatal` appends `family: string`,
append-only, so an older peer still reads `code` and `message`. `code` stays
the coarse seven-value projection; `family` carries the full word.
SessionFail publishes it before the abort, on whichever session's control
plane is active - the client has a ClientSession, the server a ServerSession,
so under spawn the frame reaches the far side whichever role is dying. Under
inproc both exist and it goes to an in-process transport the aborting process
will never read, which is harmless and cheaper than a special case that would
then be the untested one. Best-effort by nature: this thread is about to die,
so a send that blocks or fails changes nothing.
seq/op from the prose are NOT threaded through the 90 sites: the message
already names the op where it matters, so the frame carries what it can name
honestly (code, family, message) rather than a field that is always zero.
The client recognizes it: RunRemoteSurfaceControlFrame's reply loop, which
already demuxes CapsSnapshot from SurfaceReply, gains a Fatal arm that logs the
family and detail, latches device-lost from it, and returns transport-closed.
A server that DETECTS a fatal condition and aborts itself now tells the client
why; a kill -9 still bypasses abort entirely and is caught by dl's hangup
witness instead - the two cover the two ways a server can die.
The census learns SessionFail: a SessionFail call, like a WireLogFatal call,
must carry a Fatal{ word in its string, so the enum and the string cannot
disagree. FatalFunnel.{h,cpp} join the funnel exemption. Baseline is 4 aborts.
FatalFamilyTest gains the wire round-trip: every family's frame carries its
word and its projected code across encode/verify/decode.
G1 re-measured after the schema change: 0 symbol diff, .text unchanged at
0xa52203. Unit 2359 (one pre-existing environmental failure), integration-split
186/186, integration-spawn 102/102, retrace 6/6, census 0 unmarked.
Step two of CONTRACT-P6 5.2. The ~90 scattered `MGLOG_F("...Fatal{...}"); std::abort();`
pairs under MG_Remote/ become one call each to SessionFail(MGFatalFamily, fmt, ...),
which logs, echoes to stderr, counts, and aborts in one place. a6's census
found no single chokepoint existed; now there is one, and step three hangs the
SessionFault-to-peer publish off it.
THE MESSAGE STRING IS PASSED VERBATIM. The contract requires the log lines, the
CI greps and every red-once byte-identical, so SessionFail does not compose the
line from the family - it takes the SAME string the site handed MGLOG_F and
forwards it unchanged. The family enum rides alongside, redundantly, so the
funnel has the coarse wire FatalCode without parsing the string. The census now
checks that redundancy the way it checks WireLogFatal: every SessionFail call's
string must carry a Fatal{ word, so the enum and the string cannot silently
disagree.
The conversion was mechanical - 89 sites by a transform that injects the family
as MGLOG_F's new first argument and drops the trailing abort - with three sites
fixed by hand that the transform would have broken: a stub macro, and two
if/else-then-shared-abort blocks (PersistentMapTracker's adopt-tier and
SurfaceOpCodec's three surface-error branches) where a naive rewrite stole the
abort from the earlier branches and would have let them fall through. The
death tests are the safety net for byte-identity, and every one stayed green.
Four std::abort() remain under MG_Remote/, all inside the two funnels
(WireLog.cpp, FatalFunnel.cpp); the census exempts both files because their
callers carry the family word, and its baseline drops from 93 aborts to 4.
SessionFaultCount() is the one telemetry point S8 will assert zero over a good
run.
G1 re-measured: 0 symbol diff, .text unchanged at 0xa52203 (FatalFunnel and
FatalFamily live under MG_Remote/, compiled only in the disaggregated build).
Unit 2358 (one pre-existing environmental failure), integration-split 186/186,
integration-spawn 102/102, retrace 6/6, census 0 unmarked.
CONTRACT-P6 5.2's D2 ruling, step one of the Session::Fail funnel: the internal
vocabulary before the funnel that routes through it.
a6 censused thirty family words against a seven-value wire FatalCode, so
Session::Fail(FatalCode, detail) cannot carry the vocabulary in its first
argument - the word would collapse to one of seven and the log would say
ProtocolCorruption where the code said UnmigratedVerb. FatalFamilies.def is the
internal enum; the wire keeps FatalCode; each row carries the coarse code the
PEER is told when that family ends the session.
THE PROJECTION IS A TOTAL TABLE, NOT A SWITCH WITH A DEFAULT. This build has no
-Werror anywhere, so a default arm would silently swallow a family added
without a code. A row IS the arm; a family with no row is not a value of the
enum. FatalFamily.h generates the enum, the projection and the name from the
one .def, so a family, its word and its code cannot drift apart.
The buckets are documented per row in the .def: ProtocolCorruption for
malformed records / unimplemented verbs / role invariants / reply-contract
breaks; RingOverrun for a ring or event channel overrun; SegmentMismatch for a
segment too small for what a role must place in it; AbiMismatch for layout or
version disagreement; ServerCrashed for the liveness ends a WAITING peer
attributes to the other side. DeviceLost gets no row on purpose - it is armed
from the doorbell (5.3), never raised as a Fatal, and a test asserts no family
projects onto it so an abort can never masquerade as a device loss.
Five tests, all walking the table so a new row is covered without editing them:
count agreement, every family projects to a non-None code, none projects onto
DeviceLost, names distinct and non-empty, and a handful of anchors pinned by
meaning so a bulk-edit drift is caught.
No call site changes yet; the funnel that uses this is the next step.
The spawn work had the two roles append to one MOBILEGL_LOG_FILE_PATH under
O_APPEND. It was correct - two processes at one offset would corrupt the file,
O_APPEND made each write atomic - but it put two sessions' lines in one place
and made every per-side assertion a search. This splits the sink: the client
role writes <base>.client.log and the server role writes <base>.server.log.
THE BASE NAME NAMES NO FILE ANY MORE, AND THAT IS THE POINT. Leaving the
client at the old unsuffixed path would have been the compatible choice and the
wrong one: a reader that was not updated would go on finding a file, go on
passing, and go on seeing only half the session - and a refusal census that
reports a confident zero for the half it cannot see is worse than one that
fails. With both names moved, an un-updated reader gets "file not found" and
says so. There were 34 such readers; every one is moved here.
THE ROLE IS PER-THREAD, not per-process, because under inproc both roles are
threads of one process. Log.cpp keys the sink on a thread-local role: the apply
thread declares itself server (ServerLoop.cpp), a spawned image is server for
its whole life (MOBILEGL_IPC_ROLE=server, read once), and everything else is
the client. The naming rule (RoleLogPath) and the two whole-log operations
(ReadRoleLogs / TruncateRoleLogs) are EXPORTED from the library, so no reader
reimplements the derivation - a second copy is the copy that falls behind.
Readers, by how they read:
- unit mains set MOBILEGL_LOG_FILE_PATH and read a role file: they keep the
base in the env and derive the client path from the library helper.
- the death-test child runners (ReadLog) now concatenate BOTH roles: a death
test asserts the CHILD said something, and the applier's refusals - the very
diagnostics these check - are written under the server role.
- the offset-based Emit readers derive the client path once after setenv.
- PipeStatsWindow reads both, because draws/vbs increment in the backend's
PrepareForDraw - the apply thread, the server role - so the summary line is
in the server file even under inproc.
- run_trace_case.cmake's split census reads both files; the client keeps the
transport markers.
- split_log_paths.py (and its self-test, and the control stub) derive both
role files from the declared base.
- the Android script pulls both role files off the device and concatenates
them, so every downstream check is unchanged - and it judges a role file by
CONTENT, because `run-as test -f` through `exec-out` returned 0 for a missing
file on the device and `exec-out` folds cat's own not-found line into stdout.
The monolith arm legitimately has no server file, and that is a silent skip
rather than a one-line server log.
Guarded so a pull build is untouched: RoleLogPath and the role sink are wholly
inside MOBILEGL_BUILD_DISAGGREGATED; a non-disaggregated build writes the base
path exactly as before. G1 re-measured - 0 symbol diff, .text unchanged at
0xa52203.
Verified on device 2f7cbe2e: monolith writes only the client log (no phantom
server log), inproc and spawn each write both and pass their transport proofs
(spawn: server role in pid 16125). Unit 2353 (one pre-existing environmental
failure), integration-split 186/186, integration-spawn 102/102, retrace 6/6,
control smoke 12/12, split-log-paths self-test 9/9.
Exit gates 9.2 and 9.3. Until now nothing ran the integration suite across a
process boundary at all - the spawn shape was proven only by the retrace lane,
which exercises one trace per case and no scenario assertions.
integration-spawn: 102 cases, 102 green.
THE SET IDENTITY IS THE POINT, AND IT IS NOT MAINTAINED BY HAND. 9.2 asks that
the spawn lane's case-name set equal the split lane's. Registering the spawn
arm as a second copy of each block would satisfy that today and drift the first
time somebody adds a scenario to one of them - and a spawn lane covering LESS
goes green while proving less, with nothing about a smaller green lane looking
wrong. The eleven split-environment blocks become eleven calls to one macro
that emits BOTH arms from one filter, so a case added to either is added to
both or to neither.
scripts/ci/spawn_lane_parity.py asserts it anyway, because a macro is one edit
from a hand-written block. It compares SETS rather than counts - a count can
agree while the membership does not - and carries exactly one sanctioned
asymmetry, named in the file: integration-split also labels the PUSH-MONOLITH
arm, whose scenario is monolith-only by construction. R-16: introduce a
split-only block and it reds naming the case and the macro to use.
THREE CASES SKIP UNDER SPAWN, BY NAME. CtWireScenario reads PipeApplier's
in-process tallies through ServerVerbs(); under spawn those live in the SERVER's
process and this one's copy is 0 forever, so the assertions were failing about
somebody else's memory. That is a harness limit, not a product defect - the
records themselves cross, which the retrace lane proves. They are SKIPPED and
not deregistered: a case dropped from the registration would make the two sets
differ silently, which is the drift the macro exists to prevent. What retires
the skip is cross-process telemetry, which is also what S8's SessionFaultCount
needs.
TWO REAL REGRESSIONS THE EXISTING GATES CAUGHT, both from this refactor:
* the macro dropped the per-block TEST_LIST variables, which were what
SplitLogPaths fed to give every entry a PRIVATE MOBILEGL_LOG_FILE_PATH.
SplitLogPaths.PrivateAndDistinct went red naming the first orphan. The
macro accumulates list NAMES now, so a block added later is covered without
anyone remembering to extend a list - and the names have to be unique,
because gtest_discover_tests' TEST_LIST is populated when CTEST runs, not
at configure time, so one shared variable held only the last block's
entries. That matters more on the spawn arm: there TWO PROCESSES append to
the path, so a shared file interleaves two sessions rather than merely
being ambiguous.
* the configure_file that substitutes those names ran BEFORE the F1 and Ct
blocks registered, so it captured neither. Same gate, naming
F1WireScenario.ClearBufferfiPixels. It now runs last.
junit_tally gains --require-spawn-ran beside --require-split-ran: the prefix IS
the arm, and a shared flag would let one lane tally the other's entries and call
itself run. Verified to discriminate - the spawn junit reads 96/0/5 under its
own flag and 0/0/0 under the split flag, which correctly fails.
Two death tests follow the ADOPT_TIER message the census commit retagged with
Fatal{UnimplementedAdoptTier}; their regexes asserted the old wording.
Unit 2353 (one pre-existing environmental failure), integration-split 186/186,
integration-spawn 102/102, retrace 6/6, fatal census 0 unmarked, G1 0 symbols
and .text unchanged at 0xa52203.
Exit gate S7, and the precondition for CONTRACT-P6 5.2's funnel rather than
its replacement.
S7 says "a bare abort() added outside Session::Fail -> the census gate goes
red". Session::Fail does not exist: it is the 93-site refactor 5.2 describes,
and it is not in this commit. What IS checkable today is the property that
funnel exists to guarantee and that every consumer already depends on - every
death under MG_Remote/ names a FAMILY, in a Fatal{Word} marker a grep can
find. run_trace_case.cmake reds a split retrace on any `Fatal{` line, the
retrace lane's refusal census counts them by name, and MEASUREMENTS quotes
the distinct words. A death with no word is invisible to all three.
TWO RULES, BECAUSE ONE WOULD LAUNDER THE OTHER. An abort must carry a marker
within twelve lines; and every WireLogFatal call must carry one in its format
string. WireLog.cpp's own abort is exempt from the first rule ONLY because its
five callers satisfy the second - a funnel that accepted an unmarked string
would be a hole shaped exactly like the rule.
THE BASELINE BOUNDS SILENCE, NOT DEATHS. Abort sites may grow freely; a new
family word may not appear unrecorded. Freezing the total would make every new
guard a gate change, which is how a gate stops being run. The wire's FatalCode
has seven values and the internal vocabulary is thirty-two, which is the
divergence D2 exists to bound - so growing it has to be an act, not an
accident.
THE CENSUS ALSO CORRECTS ITSELF. Its first run reported WireLog.h's own PROSE
as an unmarked death: the comment explaining why the stderr echo exists
contains the words std::abort(). So the scanner strips comments before
matching, with a character-level pass rather than a regex - a `"http://"`
inside a string literal is not a comment - while KEEPING string literals,
because the markers it looks for live in them. Real numbers after that fix:
93 abort sites, 32 family words, 0 unmarked. a6 had counted 92 and 30 from a
scan that did not make the distinction.
AND THE ONE REAL FINDING. a6 named two aborts with no marker at all;
WireLog.cpp's is the sanctioned funnel, so the genuine one is
PersistentMapTracker's invalid-MOBILEGL_IPC_ADOPT_TIER death. It now carries
Fatal{UnimplementedAdoptTier}, which is what makes the census zero.
R-16 both ways, run: a bare std::abort() reds it naming the file and line, and
a new family word reds it naming the word and how to record it.
CONTRACT-P6 3.2 (D1c), 3.3 (D13) and 3.4 (D10).
Every guard below asked a question a SERVER PROCESS answers wrongly - "is a
ClientSession active" (never, there) or "am I on the apply thread" (every
thread is, there) - so all four were present, compiled, and permanently
disarmed in the one shape they exist for. That is worse than absent: it reads
as coverage.
D13 FIRST, THEN THE WIDENING. MGPipeRoleSplitActive() meant "the REHEARSAL
knob is on AND the transport is real", so widening it in place would have left
a narrow name describing a wide predicate. It is renamed to
MGPipeRoleSplitRehearsalActive() across its 12 sites - including the
DualBlockScenario assertion message that embeds it - and the wider predicate
is introduced BESIDE it.
MGPipeBlocksAreDistinct() IS TRUE FOR TWO DIFFERENT REASONS, which is the
whole point: under the rehearsal the two blocks are distinct objects in one
process; under SPAWN they are distinct because they are in different ADDRESS
SPACES, and no knob is involved. The spawn retrace runs with
role-split-state=0 - measured off the lane's own log - so every guard gated on
the rehearsal was off there.
THE CRASH. MGPipeServerBlockNoteIdentity early-returned on the rehearsal, so a
spawn server's gPipeInputs.ContextIdentity() stayed nullptr for the life of the
process. DirectGLES' framebuffer binding-slot memo compares that identity FIRST
and with NO generation, against a cache variable that also starts nullptr - so
nullptr == nullptr reads as a CACHE HIT and the function returns a slot pointer
nothing ever filled. `sm` widens the gate; `st` lands the named Fatal
PipeInputs.h had already asked for, so a reopening says which memo and which
identity instead of corrupting memory three frames later. Guarded on
MOBILEGL_PIPE_PUSH: in a pull build that identity is the live GLContext's
address and is never null, so the test would be dead code and G1 measures that.
DISJUNCTS, NOT SUBSTITUTIONS - and this was measured the hard way. The first
attempt REPLACED OnApplyThread() with MGPipeServerArm() and
ClientSession::Active() with MGPipeSessionLive(). 14 RemoteGuards and
RemoteClientControls cases went green by omission: a ServerLoop fixture with no
client session has only the original facts, so swapping them out disarmed every
test that drives the apply thread directly. The originals stay; the new
predicates are added beside them.
AND THE PROBE HAD TO BE THE RIGHT ONE. Installing
ClientSession::ApplyThreadIsInsideApplier as the apply-thread probe made every
GL-thread call a role violation whenever the applier happened to be busy - the
inproc retrace died on Fatal{RoleViolation, "MGPipeSlots"} raised from the GL
thread. "Am I the apply thread" and "is the apply thread inside the applier"
are different questions; the second is R-1's mutual-exclusion probe and is
about another thread entirely. ServerLoop::OnApplyThread is the one the guards
mean.
Three of D1c's six predicates land here, the three the four guards need. The
other three (MGPipeSplitActive, MGPipeShouldDial, MGPipeBackendIsLocal) have no
D10 site and are not added as dead code; their call sites are ContextEpoch and
ActiveXfbState, which this commit does not touch.
D14 holds: the non-disaggregated build gets constexpr false definitions, not
runtime-false predicates, so every consult folds at compile time. G1
re-measured - 0 symbol diff, .text unchanged at 0xa52203.
Two test names added, both falsifiable by construction: the spawn case asserts
the blocks are distinct with the rehearsal knob OFF, and the monolith case
asserts the predicate is not simply a constant. Unit suite 2353 tests, one
pre-existing environmental failure. All six OpenRA arms green.
CONTRACT-P6 4.2 and 4.3. Three defects, all of them things the handshake
believed it was checking and was not.
1. THE BUILD STAMP WAS "" AND NOTHING SAID SO. CMakeLists took the git hash
through an execute_process with NO RESULT_VARIABLE, so a failed
`git rev-parse` yielded the empty string. Verified live rather than
inferred: this worktree's `.git` is a file holding a WINDOWS path, WSL's
git answers `fatal: not a git repository`, result 128 - and every WSL build
mixed "" into its ABI fingerprint. Two builds from different commits then
agreed exactly, which is the one thing the stamp exists to prevent. The
already-failing SessionHandshakeTest was reporting this the whole time.
The fix may NOT touch GIT_COMMIT_HASH_SHORT: it is baked into
glGetString(GL_VERSION), so redefining it changes an application-visible GL
string and the pull build's .rodata - a behaviour leak and a G1 break at
once. MGGitHash.h.in gains a SECOND pair instead, consumed only by the
fingerprint, plus -DMOBILEGL_BUILD_STAMP for trees git cannot read (CI has
${GITHUB_SHA} in hand). A stampless build is now a configure-time WARNING
naming the consequence, not silence.
MOBILEGL_BUILD_STAMP_PRESENT IS MIXED AS ITS OWN FIELD. "this build cannot
name its commit" and "this build's commit is the empty string" are different
claims, and collapsing them is how every stampless build in the world agreed
with every other. The test asserts the flag moves the fingerprint in EITHER
build state, and the `BuildStamp = ""` perturbation is now guarded - in a
stampless build production's value already IS "", so that assertion was
passing while proving nothing.
2. THE CLIENT NEVER READ abiMajor/abiMinor. Hello has carried them since P5
and the SERVER checks them; the client compared only the fingerprint. The
fingerprint mixes AbiVersion so a mismatch would fire anyway - but as
"struct shapes", which sends the reader looking for a layout change that is
not there. Under spawn the peer is a SEPARATE IMAGE the operator chose with
MOBILEGL_IPC_SERVER_PATH, so this is exactly the failure that needs its own
sentence. Both handshake sites call one helper; there are two sites, and
only one of them was reachable before `sm`, which is how the gap survived.
3. Hello::pid WAS 0 AND Welcome::serverPid ECHOED IT. So both were always 0 and
neither named anything, while §9.5's arm-proof gate wants exactly the child
pid. Both now carry real ids, and the pair is the cheapest arm proof in the
protocol: under spawn they MUST differ, and a same-process session cannot
produce two different values. The client keeps what the server STATED -
local launcher knowledge is a different fact from one that crossed the wire,
and only the second can be asserted from the server's own mouth.
R-16 both ways: revert serverPid to echoing hello->pid() and the new test reds
on both halves, naming 4.3. G1 re-measured after the CMake and header changes:
0 symbol diff, .text unchanged at 0xa52203.
Unit suite goes from two long-standing failures to one - the remaining
PipeCatalogue.FrontendNeverTakesAnApplierAddress is an environmental path
problem in this tree, unrelated. All six OpenRA arms still green.
Before this, CONTRACT-P6 5.3's own sentence held: "a dead server leaves the
client silently DECLINE-ing every verb forever with glGetError clean".
FatalCode::DeviceLost had zero producers and zero consumers and
glGetGraphicsResetStatus returned GL_NO_ERROR unconditionally, so an
application had no way to learn that the process holding its GPU objects was
gone. Measured cost of that: a server killed on its first Clear left the
client waiting out the full 120 s barrier and reporting Fatal{BarrierTimeout}
- the wrong diagnosis, two minutes late. It now notices in 0.01 s.
THE MACHINERY WAS ALREADY THERE. Doorbell::Wait breaks out on Dead(),
SessionProducer::Park turns that into SessionWait::ShutDown, and ClientSession
already had the named DECLINED path. What did not exist was the death FACT:
D5c's finding is that the client parks on clientBell[0] and rings ITSELF
through clientBell[1], holding both ends, so EOF can never arrive on the one
descriptor it watches. A bell that can wake itself cannot hear a death.
SetDeathWitness(fd) fixes that with no new descriptor: the control socket's
far end is held only by the server. The subtlety is that the bell must watch a
socket it must NEVER read - the transport's reassembler owns those bytes and a
control reply is not a death. So the witness enters the poll set with
`events = POLLRDHUP` and nothing else: POLLHUP/POLLERR/POLLNVAL arrive in
revents unrequested, so the hangup is seen while POLLIN never is. Nothing is
recv'd from it and its ownership stays with SocketTransport.
Dead() AND PeerHungUp() ARE TWO FACTS. Park sets m_dead for POLLERR, POLLNVAL
and unrecognised revents too - each a fault in THIS process's descriptor - and
CondVarDoorbell::Kill() sets it on every orderly Stop(). A latch taken from
Dead() would report a lost GPU for a local fd bug and a context reset for
every clean exit, so only PeerHungUp() arms it.
GL_UNKNOWN_CONTEXT_RESET, not GUILTY or INNOCENT: those assign blame, and the
server died for a reason that never crossed the wire.
D5b, in the control-reply wait, which had the right comment and no code: EOF
on the control stream is device loss; a TIMEOUT with the socket still open is
"ALIVE BUT SILENT" and says so by name. 5.4 forbids calling a frozen or merely
slow peer dead, and under P5e run-ahead a server one frame behind is the
INTENDED steady state - a latch armed by a deadline would fire on a healthy
session under load.
MOBILEGL_IPC_RESPAWN is now a NAMED REFUSAL rather than a variable nothing
reads. The latch is one-way on purpose: every handle this client minted names
an object inside the server process, so recovery means re-pushing the world.
THREE TEST NAMES ADDED, none renamed (G14), and R-16 run both ways:
* remove the witness -> AKilledServerLatchesDeviceLostFromTheHangupAndNot-
ADeadline goes red, takes 2 s instead of 0.01 s, and names D5c.
* collapse PeerHungUp() into Dead() -> ADeadBellIsNotAlwaysAHungUpPeer goes
red naming the local-fd-bug consequence.
* AnOrderlyStopIsNotADeviceLoss stays GREEN under that collapse, and its
comment now says so. The first draft claimed it was the control for it;
running the falsification proved otherwise - under spawn the self bell is a
SocketDoorbell and Stop() never kills one. The comment was corrected rather
than the claim quietly kept.
G1 RE-MEASURED, NOT ASSUMED: 0 symbol diff against the c6 baseline and .text
unchanged at 0xa52203. That measurement earned its keep - it caught a 16-byte
drift that had nothing to do with dl: P6's O_APPEND log-sink fix was compiling
into the PULL build, where a second process can never exist. Now guarded, and
.text is back to the baseline exactly.
Full unit suite: 2350 tests, the same two pre-existing environmental failures
and no others. All six OpenRA arms (2 backends x monolith/inproc/spawn) green.
The Android retrace matrix gains a third lane, spawn-acceptance, beside
monolith-control and inproc-acceptance. Until now nothing in CI had ever
started a second MobileGL process on a device at all.
The apk.yml retrace job already carried a `transport` matrix dimension and
already branched on it, so the lane itself is three edits: a row in
github_transport_matrix, an elif in the retrace step, and a summary to render.
What needed thought was the gate.
THE SPAWN LANE IS APK-ONLY, and that is a statement about where evidence is
missing rather than about what spawn can do. Linux already has
test.yml's `retrace-split`, which is {backend, case, transport} over inproc AND
spawn with the two negative controls that make it mean anything. A spawn row in
the Linux transport matrix would pay for a second full sweep proving nothing -
and test.yml's `retrace` job stages no server image, so the lane would not
merely be redundant, it would be red. self_test_transport_matrices now asserts
the absence in both directions, because an accidental removal of the `if apk`
has to be caught in the self-test rather than in CI.
--require-spawn REQUIRES TWO SENTENCES, and the second is the one that matters.
ConfigLoader's marker proves the VALUE RESOLVED - it exists only in the Spawn
arm, which exists only under MOBILEGL_BUILD_DISAGGREGATED - but that is a
statement about a parser. `spawn ARMED - the server role runs in pid N` is
written only after the launch, the connect and the handshake have all
succeeded, and the pid in it is the SERVER's. It is the only line in the log a
same-process run cannot produce.
Both halves red once, measured:
* a monolith run demanded to prove spawn: REFUSED, transport_resolution_marker
false, server_pid null - while the retrace itself scored ssim=1.000000,
which is exactly the trap. OpenRA matches its golden under monolith too, so
the picture has never been the evidence.
* the same run's real spawn log with the one ARMED line deleted: REFUSED,
transport_resolution_marker TRUE and server_pid null. That is the
"resolved the transport and then never left the process" case, isolated.
THE PROOF USES `any` WHERE THE inproc GATE USES `all`, and the difference is
load-bearing rather than a relaxation. Under spawn two processes append to one
MOBILEGL_LOG_FILE_PATH, and the launcher deliberately scrubs every
MOBILEGL_IPC_* from the child's environment (anti-recursion catch (b)) - so the
server's own `Config: IPC` line reads strict=0 role-split-state=0 BY
CONSTRUCTION. Demanding every line match would red every spawn run for the one
thing the design requires.
--use-pbuffer is not optional on this lane: until P12 a spawned server cannot be
handed a window, because an ANativeWindow* is a pointer into the CLIENT's
process (Rule H). MOBILEGL_IPC_SERVER_PATH is deliberately NOT passed -
nativeLibraryDir carries an install-time hash that nothing off the device can
spell, and the Activity resolves it.
The local runner now passes the same knobs and the same --require-* flag CI
does, so "it works locally" can no longer mean "it ran monolith locally". That
change immediately caught its own omission: the first local run with the gate
armed failed on client_ipc_line_present=false because the runner had been
sending MOBILEGL_TRANSPORT alone.
Verified on device 2f7cbe2e: OpenRA.DirectGLES spawn, ssim=1.000000,
mismatchPixels=0, "real spawn transport, server role in pid 9885, and zero
Fatal records confirmed".
OpenRA.DirectGLES, transport=spawn, on a Redmi: ssim=1.000000,
mismatchPixels=0, zero Fatal{ lines, three runs for three. The server is a
process the client launched out of the APK's lib/arm64/, and the client's own
log names its pid.
Four things stood between the desktop green and the device green, and every
one of them was invisible on desktop for the same reason: a build tree is not
a deployment, and Linux is not Android.
1. THE SERVER COULD NOT LINK. It links libMobileGL.so and is EXEC'd, not
dlopen'd, so it gets a fresh linker namespace with the DEFAULT search path
- and an APK's lib/<abi>/ is not on it. `CANNOT LINK EXECUTABLE ...:
library "libMobileGL.so" not found: needed by main executable`, before a
single line of MobileGL ran. On desktop CMake had been giving the target an
absolute build-tree RPATH, so the WSL server resolved its dependency
through a path no phone has. $ORIGIN is asked for via BUILD_RPATH (a raw
-Wl,-rpath,$ORIGIN reaches the generator unquoted and the SHELL eats it -
the device linker said `normalize_path - invalid input: "$"`), and because
the NDK drops RPATH from the link anyway, the LAUNCHER states
LD_LIBRARY_PATH: it is the one component that has already resolved where
the image lives, and a pair that is not co-located is not a pair.
2. THE RENDEZVOUS NEEDED A WRITABLE DIRECTORY. An Android app has no /tmp, so
bind() in the child had nowhere to put the node and the client refused by
name 20 s later. AF_UNIX endpoints now accept the ABSTRACT namespace with a
leading '@' - the systemd/D-Bus convention - which has no filesystem
presence at all: no directory, no mode bits, and no stale node to unlink
after a crash, which is the bind() failure Listen() has been working
around. Its reach is the network namespace, which for an app is the app.
StartSpawned mints '@' names; an explicit path still works everywhere, and
that is what P12's "connect to something somebody else is listening on"
will use.
3. PBUFFER WAS A DirectGLES-ONLY KNOB. Twice over: trace-replay-ci.sh gated
`--ez use_pbuffer true` on the backend name, and the JNI demanded a native
window for DirectVulkan unconditionally. A surface SHAPE is not a backend
property, and the coupling mattered because the Android GLWS makes a window
surface whenever gNativeWindow is non-null - so handing Magma a window was
the same as asking for one. Until P12 a spawned server cannot be given a
window at all: an ANativeWindow* is a pointer into the CLIENT's process and
SetWindowHandle is refused on the way across with Fatal{UnmigratedSurface,
"AndroidNativeWindow@P12"} (Rule H). Which is exactly what happened, one op
after the server had come up green.
4. THE RUNNER LOOKED IN ONE OF THE TWO OUTPUT DIRECTORIES. assembleTraceRelease
writes apk/trace/release; CI has staged the same artifact under
apk/trace/debug. A tree that has built locally has both, and picking the
wrong one retraces last month's APK and says nothing about it. Newest of
either, now.
MEASURED AND NOT FIXED: DirectVulkan on this device diverges under BOTH split
transports and does so nondeterministically - inproc scored ssim=0.988998 /
9836 px, spawn scored 0.976494 / 14658 px and then 0.988998 / 9836 px on a
repeat, while monolith with the same pbuffer surface scores 1.000000 / 0 px.
The two transports draw from the same set of outcomes, so this is the split
path on Magma against a real GPU, not the process boundary: P6 neither caused
it nor can fix it. DirectVulkan spawn IS green on desktop (lavapipe, 1.000000).
G14: one test name added, AnAbstractEndpointNeedsNoWritableDirectoryAndLeaves-
NoFile, which pins both halves - the handshake crosses an abstract name, and
nothing is left on the filesystem. A '@' mistaken for a path would have bound
a file of that literal name, passed here, and failed on the one platform this
exists for.
Measured while running the 78-entry spawn matrix, not predicted.
minecraft-1.21.4-fabric-iris-iterationrp-in-world kills the SERVER process
outright on this machine - llvmpipe cannot lower the shader pack's
%llvm.x86.vcvtps2ph.256 and aborts at call 159429 - and the client did NOT
take the SessionWait::ShutDown path, which already exists and is already
correct. It sat out the full 120 s and reported Fatal{BarrierTimeout,
"ResourceRespecify"}: the wrong diagnosis, at two minutes a case, for a
server that had been gone since the first second. The same trace fails in 3 s
under monolith.
The cause is structural and is the price of `sm`'s two-fd bell. The client
parks on clientBell[0] and rings ITSELF through clientBell[1], so it holds
both ends of that socketpair. EOF arrives only when every writer closes and
the client is one of them, so that descriptor can never hang up no matter
what happens to the peer.
`dl` therefore needs a witness descriptor whose far end only the server
holds, polled beside the park fd and never read from, whose POLLHUP/EOF
latches Dead(). The control socket already is exactly that, so no new
descriptor is needed.
This is NOT a defect in `sm`: holding both ends is what self-notification
requires, and without it the server's control pump could not wake its own
apply thread - the bug that cost this branch an afternoon. The mistake would
be assuming one descriptor can answer both questions.
Recorded rather than fixed: it turns no red case green, and the only case
that reaches it fails identically in all three arms for a reason that is not
MobileGL's. `dl` owns the fix and S2 already names the red-once for it.
Two small pieces, so an Android spawn arm is a flag rather than a project.
THE RUNNER GAINS --transport. It already had a generic env passthrough - one
intent extra carries `K=V;K=V` all the way to setenv before the library is
dlopened - so the transport itself costs one entry and no new plumbing in the
five files between here and the device. monolith passes NOTHING rather than
MOBILEGL_TRANSPORT=monolith, so the default arm's environment stays
byte-identical to what it was before this option existed: a control that
changed when the option landed would not be one.
THE SERVER PATH CANNOT COME FROM THE HOST. Every other knob can, but this one
names a file that must be EXEC-ABLE, and on Android there is exactly one such
place: an APK's lib/<abi>/. W^X has forbidden the app's own data dirs since
API 29, and the packager only puts a file in lib/<abi>/ if it is named
lib*.so - which is why the server executable wears that name, and what P0
spike A proved on two devices. nativeLibraryDir contains an install-time
hash, so no host-side script can spell the path; the process about to launch
the server is the only one that can. TraceReplayActivity resolves it, and
only when the caller actually asked for spawn.
AN EXPLICIT MOBILEGL_IPC_SERVER_PATH STILL WINS. A caller that set one is
pointing somewhere deliberately - at a pushed build, say - and silently
replacing it would make that knob untestable on the one platform where it
matters most.
The APK must be built with -Pmobilegl.buildDisaggregated=ON or none of this
is reachable: without it ConfigLoader has no transport parser, accepts the
value, ignores it (CONTRACT-P5 rule 5), and the run is monolith under a name
that says spawn. The --transport help says so where somebody will read it.
It was one case on one backend under one transport: OpenRA, DirectGLES,
inproc. It is now every CI case, both backends, inproc AND spawn - 154 CI
entries, 156 local ctest entries - because every defect the spawn work turned
up was a question about the SERVER's process and not about any one trace or
either backend.
THE DirectVulkan ARM. tools/trace_replay/CMakeLists.txt carried "a
DirectVulkan arm would be measuring a server nobody has written yet". True
when P5 wrote it; false since P5f/P7 landed Magma's server consumers and its
run-ahead readiness gate. Leaving it would have shipped a split shape whose
second backend nothing ever ran - the same class of hole as a lane that runs
monolith under a name that says split. Magma now brings up a server process
of its own and OpenRA retraces across it at ssim=1.000000, which is only
possible because this branch also taught the child to load its config: before
that, a DirectVulkan session brought up a DirectGLES server and the two
halves disagreed about which backend the capability mask describes.
`split` BECOMES AN OPT-OUT. It began as an opt-in naming one case, because
one case was the work. Marking 39 cases `"split": true` would carry no
information and would reinstate the hole the key was written to close, only
mirrored: a case added later would get the monolith arms and be silently
absent from the split ones, and absent is the one state nothing reds on. A CI
case is now in the subset unless it says `"split": false`, which is a
reviewable claim that a trace cannot cross a role boundary.
THE CI MATRIX GAINS A THIRD DIMENSION. The job took {backend, case} and
hard-coded MOBILEGL_TRANSPORT=inproc in its own run block, so `spawn` had no
way into CI at all; the .SPAWN ctest entries existed only for a local build.
It is {backend, case, transport} now, the backends are the case's own
ci_backends rather than a hard-coded DirectGLES, and the artifact names carry
the transport or the two arms of one {backend, case} would overwrite each
other's uploads.
inproc STAYS AS THE CONTROL. Same library, same codec, same applier, same
picture; the only difference is which process the server role runs in.
Running only spawn would leave "did the transport break it?" and "did the
phase break it?" answerable only together - which is the question every
failure in this phase has turned on, including the SIGSEGV that D1 explained.
BOTH NEGATIVE CONTROLS FOLLOW THE ARM. retrace_pull_library_control.sh and
retrace_drop_draw_control.sh each exported MOBILEGL_TRANSPORT=inproc
themselves, so on a spawn job they would have gone on proving something about
the other arm while spawn ran unguarded. They read SPLIT_TRANSPORT now, and
default to inproc so a caller that sets nothing behaves exactly as before.
scripts/ci/control_smoke_test.sh: 22 passed, 0 failed against the stubbed
ctest, including the two modes that matter most - a red whose SSIM never fell
and a knob that armed and dropped nothing.
The spawn arm needs a second binary and the inproc arm needs none, so the job
places libMobileGLServer.so only on the spawn arm and a missing one is a
named failure there rather than a LaunchServer refusal 78 jobs deep.
Measured locally as this lands: 39 of the 78 SPAWN entries complete, all
green. The rest are still running.
OpenRA.DirectGLES.SPAWN is green: ssim=1.000000, mismatchPixels=0, zero
Fatal{ lines, against a server in a process the client started by name. The
inproc SPLIT arm stays beside it as the control, so "did the transport break
it" and "did the phase break it" remain separately answerable.
Five defects stood between the handshake and that picture. Each one was
silent in its own way, and none of them looked like what it was.
1. THE DOORBELL COULD NOT WAKE ITSELF. SocketDoorbell's one-fd form sends on
the end it polls, so a write is delivered to the PEER - correct for waking
the other process, useless for waking a thread in this one. The server's
control pump posts into the apply thread's mailbox and must ring the bell
that thread parks on. Symptom: every eglInitialize crossed, landed in the
mailbox, and timed out at 5 s. The two-fd form (park on [0], notify on [1])
is restored here, and the bell handover goes from two descriptors to THREE
- the client needs the same split for the same reason. Seven offers now.
2. InitCapabilities HAD NO WIRE KIND. CONTRACT-P6 §2 predicted this exactly
("the request has a live client caller and the codec refuses to encode
it"). SurfaceOpKind gains 11, append-only; the decode range's upper bound
moves with it, or the new tag reads as out-of-range and surfaces as
Fatal{ProtocolCorruption} rather than the missing row it is. The op's name
loses its InprocOnly suffix, which stopped being true here.
3. THE CHILD LOADED NO CONFIG AT ALL. mobilegl_server_main calls
InitServerRoleForSpawn directly, and MG_ConfigLoader::Init is a step of
MobileGL::Initialize - so every MG_Config value in the server was its
static default. MOBILEGL_BACKEND_TYPE survives the launcher's scrub on
purpose and was read by nobody: a DirectVulkan session would have brought
up a DirectGLES server and then disagreed with its client about which
backend the capability mask describes.
4. AND THEREFORE Transport READ AS Monolith, which CONTRACT-P6 §3.1 (D1)
forbids in as many words. a6 measured the cost: 226 live lines test
`Transport != Monolith` to select the SERVER arm, 148 in MG_Backend.
Monolith flips all of them to frontend glue in the one process with no
frontend. Observed precisely as predicted - UnitTexturesByHandle() answered
false, Clear took the legacy arm that dereferences borrowed FRONTEND slot
pointers, and the apply thread took a SIGSEGV inside SyncNeccessaryTextures
on the first Clear record of the trace. It is pinned in-process rather than
through the environment because the environment is where it must NOT be:
LooksLikeAClientEnvironment exits 65 on a surviving MOBILEGL_TRANSPORT, and
that check is anti-recursion catch (b). Dial, not Transport, is that axis.
5. THE LOG WAS CORRUPT, NOT MERELY INTERLEAVED. Two processes opened the same
MOBILEGL_LOG_FILE_PATH with "w" and each carried its OWN file offset, so
they overwrote each other's bytes - half-lines like `ote client: CapsMirror
generation 1 adopted`, and the server's whole startup block vanishing under
the client's later writes. This cost real diagnosis time twice, because a
missing line read as a code path not taken. O_APPEND now; the client still
truncates once so a fresh run is not a pile of old ones, and the server
never does - it would delete the very startup lines a failed session needs.
run_trace_case.cmake's Fatal{ census reads exactly this file, so it was
also reading a lie.
The retrace runner's split assertions become transport-aware: one row per
transport it admits, and a transport with no row is a FATAL rather than a
fall-through, because the fall-through is the accident the block exists to
prevent. The spawn row carries a SECOND sentence and it is the stronger half
- ConfigLoader's marker proves the value RESOLVED, while `spawn ARMED - the
server role runs in pid N` is written only after the launch, the connect and
the handshake all succeeded, and carries a pid that is not this process's.
inproc resolves its own marker while running the server role on a thread
here; nothing on that path can write this one.
G14: one test name added (TheEleventhFramedOpIsInitCapabilitiesAndItCrosses-
TheWire), none removed. The ten-op table keeps its name and its scope rather
than being renamed to eleven.
Two unit failures predate this change and are environmental in this tree:
PipeCatalogue.FrontendNeverTakesAnApplierAddress (the frontend source root
resolves empty) and SessionHandshakeTest.TheAbiFingerprintChangesWhenAnyOf-
ItsInputsDoes (GIT_COMMIT_HASH_SHORT is empty here, which a6 already recorded
as red in this configuration).
An eglInitialize issued in one process is dispatched in another and its answer
comes back. That is the last thing standing between the session and a native
context, because the server creates its context on the apply thread when the
client's first eglMakeCurrent crosses - and until now nothing crossed.
ONE SEAM, NOT TWELVE. a6 row A4-13 counted twelve Server* forwarders, each
ending in ServerLoopInstance().RunSurfaceControlFrame(frame) with no role
branch, and concluded twelve edits. They all funnel through that one function,
so the branch goes there and the forwarders are untouched.
A HOOK, NOT A CALL. ServerLoop::SetRemoteControlSink takes a function pointer
the client installs at handshake. The obvious alternative - the server half
asking ClientSession::Active() - would have added a fifth symbol to the six
MG_Remote already owes itself (a6-link-experiment §5), in the one file with a
clean reason not to.
The branch is BEFORE the local seq mint, deliberately. RunSurfaceControlFrame
numbers unnumbered local posts; a wire request must keep the CLIENT's number all
the way through dispatch and reply or its answer cannot be correlated
(CONTRACT-P6 §6.3, which says the client mints, always).
The client sink encodes with EncodeSurfaceOpFrame, sends, and waits for the
SurfaceReply carrying its own seq - under one mutex, because the twelve
forwarders are already caller-serialised on the server's m_callerMutex under
inproc and two replies racing for one correlation is the same bug on either
side. A CapsSnapshot arriving while it waits is ADOPTED rather than dropped:
the server republishes caps from inside its MakeCurrent dispatch (a6 row A4-30)
so a snapshot legitimately precedes the reply to the op that produced it. The
wait is bounded and its expiry is NOT fatal - a peer that is gone is a different
fact from a peer that is slow, and only the doorbell's death latch may say which.
The server's pump decodes and applies through ServerApplyWireSurfaceOp, which
a6 found had no production caller anywhere. It runs on ServerMain's own thread -
NOT the apply thread - which is what §6.2 asks for: a control arrival must not
wake the applier, and frame decode must not happen on the thread holding the
native context. A frame that is not a verifiable SurfaceOp ends the session
rather than being ignored, because a silent drop is the failure mode this
project refuses everywhere else.
THE TEST'S ASSERTION IS THE OUT-PARAMETERS, not the EGL result. A headless
machine has no display to initialise, so the op is expected to be refused - and
ServerInitializeEGLDisplay writes major/minor "whenever the dispatch ran,
success or not" (the forwarder's own comment). They move off -1 if and only if
a SurfaceReply came back from the other process; a transport failure returns
before the write-back. I got this backwards first and the test said so.
Gates: ServerSpawnTest 5/5; unit suite 2345 with the same two pre-existing
environmental failures; G1 pull 0 added / 0 removed, .text 10822147 unchanged.
Not TCP. It was considered and dropped for now: TCP has no SCM_RIGHTS, so the
four segments cannot cross it, and a TCP control plane would have to refuse a
data plane by name until StreamLink exists (P6.5). Recorded here so the reason
is on file rather than re-derived.
The cross-process session is up. A server process is started on its own with an
endpoint and nothing else, it listens; a client started separately connects to
that name; Hello crosses, Welcome comes back with four SegmentRefs, six
descriptors cross by SCM_RIGHTS, and the client maps memory another process
created. ClientSession::Active() is non-null at the end of it, which is the flag
that arms the thirty-seven wire emitters.
INDEPENDENT, NOT FORKED - and that is a correction to this package's first
shape. The version committed an hour ago forked the server and handed it fds
3..6. It worked, and it was less code, but inheritance can only ever produce a
server that is a CHILD OF ITS CLIENT, which is the one arrangement the end state
cannot use: there the server is a standing application and the client is
somewhere else entirely, possibly on another kernel. CONTRACT-P6 §3.1 called
this Dial == Connect and filed it as a later shape. It is the primary one, and
fork/execve survives only as a way to START a process - every descriptor it used
to carry is gone.
What that costs and buys, concretely: the rendezvous becomes a NAME, so
SocketTransport grows Listen/AcceptPair/ConnectTo, with a bounded connect retry
that is the CLIENT's (a launcher that waited for the server to be ready would be
coupling the two again by the back door). The bells stop being inherited fds and
are created by the server and handed over with the segments - slots 4 and 5 of
six SCM_RIGHTS offers. And the client connects TWICE to the same path: an fd
offer is a sendmsg whose ancillary data rides with specific bytes, so it may not
share a socket with the framed control stream, or the frame reassembler and the
descriptor receiver race for the same bytes.
The pieces behind it:
- SessionSegments::AdoptFromDescriptors, which is the same Adopt+Map loop
AttachInProcess has run on every inproc session since P5. That was the point
of writing the inproc attach as dup+Adopt+Map rather than as an alias: only
the origin of the descriptor is new.
- ServerSession::SetExternalDoorbells, the narrow fix for a6 row A7-1 - the
§3.9 accessors abort for every role but InProcess, so a spawn apply thread
died before popping its first record. lk deletes them; until then an injected
pair is honest about where the bells come from.
- MG_Backend::InitServerRoleForSpawn: steps 1 and 2 of the split bring-up
extracted so the server process runs the SAME code the inproc server role
runs. The capability bits are a statement about the server's own backend and
a second copy would diverge the first time a backend registers a slot.
- ClientSession::FinishStartup: everything below the attach - producer, reply
pool, event ring, segment table, encoder, first CapsSnapshot, emitter tables -
shared verbatim between inproc and spawn. Its one parameter is not a
transport question: under inproc the client starts the apply thread because
there is no server-side main; under spawn there is one, and it started the
thread before it sent the first descriptor.
G1 CAUGHT A REAL LEAK on the way: InitServerRoleForSpawn was guarded on the
inside, so the pull build still DEFINED the symbol - 2 added, 1 removed, .text
+16 bytes. The whole function and its declaration are now inside the guard.
"It returns false there" is not the same as "it is not there", and the gate is
what said so.
Gates: ServerSpawnTest 4/4; unit suite 2344 with the same two pre-existing
environmental failures; G1 pull 0 added / 0 removed, .text 10822147 unchanged.
The first time this project has had a second process. fork + execve of a real
server image, the control socket surviving the exec, bytes crossing a process
boundary, and the child exiting on EOF with nothing left behind.
mobilegl_server_main EXISTS NOW. a6 found ARCHITECTURE.md:488-496 had described
it for three phases while the symbol was nowhere in the tree, and the only
MobileGLServer target was the P0 spike stub, which links no MobileGL library at
all. The spike keeps its evidence and its name becomes MobileGLServerSpike; the
real image links the library, so an undefined symbol is a build error rather
than a dlsym failure in a child whose stderr is /dev/null.
WHAT sm OWNS AND WHAT IT LEAVES: the process, the inherited descriptors, the
transport and the exit discipline. Not the session, not the applier, not the
four segments - wiring those from ServerMain before lk has a seam to wire them
THROUGH is how call sites get written twice. The loop is a read/answer/exit-on-
EOF stand-in whose SHAPE is the part that matters: one reader, EOF ends the
process, and no timeout is ever allowed to stand in for the death fact (§5.4).
ANTI-RECURSION IS TWO CATCHES, and neither is ARCHITECTURE.md:488's. That rule
says the child hard-sets Transport to Monolith; a6 measured that 226 live lines
test `Transport != Monolith` to select the SERVER arm, 148 in MG_Backend, so
obeying it flips the whole backend to frontend glue in the one process with no
frontend. Instead: (a) MOBILEGL_IPC_DIAL=no, structural and separate from role,
and (b) MOBILEGL_TRANSPORT and every MOBILEGL_IPC_* scrubbed from the child's
envp. The child refuses with 64 for (a) and 65 for (b) so the two safeties are
distinguishable - two that cannot be told apart are one.
TWO BUGS WORTH RECORDING, both found by the test rather than by review:
dup2(n, n) IS A NO-OP THAT DOES NOT CLEAR CLOSE-ON-EXEC. FdPassing sets
SOCK_CLOEXEC on the aux pair, so the child depends on dup2 to clear it - but in
a gtest process a fresh socketpair lands on exactly fds 3..8, so a source fd
often IS its own target. The no-op left CLOEXEC set and the following close()
then shut the descriptor we had just "installed". The child came up with no
socket and the parent saw TRANSPORT_CLOSED on its first send. Now spelled out
rather than written cleverly.
THE CHILD'S DIAGNOSTICS WERE INVISIBLE. WireLogError reaches the file sink
only - Defines.h builds the logger with the console sink off - so a child that
refused to start said so into a file the parent does not know the path of.
ARCHITECTURE.md §15.2 warns about exactly this on Android. Both refusal paths
now also write stderr directly, and both carry a distinct exit code the parent
can name without reading anything.
TESTS: six cases, 6/6. Each exists because something would otherwise pass
silently - the reply carries the child's own getpid() so a test that talked to
itself cannot pass (ID-124's lesson at the smallest scale); the process tree is
COUNTED before, during and after, not promised; the child is proven not to exit
while merely idle before it is proven to exit on EOF; an unresolvable image is a
named refusal that spawns nothing (S1); a surviving client knob is caught by
name (S3); and a descriptor crosses execve by SCM_RIGHTS, which is the mechanism
the four segments will ride on and is now exercised before it is needed.
Also records the asio decision in the contract: declined for P6 because spawn
needs five platform mechanisms and asio covers one - its own socketpair returns
operation_not_supported on Windows, it has no descriptor passing, segment
delivery is POSIX-only by design, and there is no fork. Revisit at P6.5, where
asio::generic::stream_protocol carries a raw family/type/protocol triple and
therefore gives AF_VSOCK from one implementation.
Gates: unit suite 2346, up 6, same two pre-existing environmental failures. G1
pull 0 added / 0 removed, .text size 10822147 unchanged, and zero spawn, server
or SocketTransport symbols in the pull build.
The first P6 code that runs. ITransport over a connected AF_UNIX SOCK_STREAM
socket, with SCM_RIGHTS descriptor passing on a dedicated SOCK_DGRAM aux pair.
No shared memory: this is the CONTROL plane, and the hot path bypasses
ITransport entirely by its own header's account. What the socket carries is the
handshake, surface ops, resync, aux requests and fatals - and, once sm exists,
the four segment descriptors that let the peer map the data plane. It moves the
keys, not the bytes.
CONTRACT-P6 §8 and the spawn plan both require this green over a plain
socketpair with two threads BEFORE a child is ever forked, for the reason
SessionRings.h gives about the attach half: a transport first exercised on the
day the process appears is a transport whose bugs all arrive at once, on the
hardest day to debug them. That requirement is now met.
It reinvents nothing. Framing.h already had AppendFrame and a real byte-stream
reassembler with exactly the BUFFER_TOO_SMALL keep-the-message semantics
ITransport specifies - written for this and never before driven by a socket.
FdPassing.h already had SCM_RIGHTS over a dedicated datagram pair. What is new
is ~250 lines joining them, plus the platform refusal: Windows has no
SCM_RIGHTS and ShmSegment::Adopt is POSIX-only, so CreatePair refuses BY NAME
at construction rather than handing back a transport that fails every later
call.
SOCK_STREAM rather than SOCK_SEQPACKET, deliberately: Doorbell.h:390-398
already chose a stream socket so a closed peer surfaces as POLLIN|POLLHUP with
recv()==0 rather than as a hang, and the control plane may not disagree with
the bell about what death looks like. Framing.h is what makes a stream safe -
the message boundary is in the payload, not in the socket.
TESTS: the same nine ITransport-contract cases InProcessTransportTest runs,
copied rather than shared. Folding both suites into one parameterised fixture
would rename the sixteen existing cases and G14 says names only grow; and the
two suites do not share a handle anyway, since InProcessTransport's tests reach
into condvar doorbells this class does not have. Nine names added, zero
removed. The seven Doorbell* cases are deliberately absent: they exercise
CondVarDoorbell, not ITransport.
Plus three a stream has and a message queue cannot: a 256 KiB payload that
provably crosses in several reads; a garbage stream that must latch failed and
must NOT resync onto a boundary it invented; and adopting an already-connected
fd with no aux channel, which answers UNSUPPORTED to ShareFd rather than
pretending - rule G, a transport declares what it supports.
Red-once, both run: drop the message on BUFFER_TOO_SMALL and six cases go red;
make the framing latch recoverable and AGarbageStreamIsLatchedFailed fails by
name.
Gates: 12/12 in 677 ms. Unit suite 2340 tests, up 12, with the same two
pre-existing environmental failures. G1 pull 0 added / 0 removed, .text size
10822147 unchanged, and zero SocketTransport symbols in the pull build.
One note for the VM track this eventually serves: swapping AF_UNIX for
AF_VSOCK is one line here and nothing at all in Framing. It is the shared-memory
half that does not cross a kernel.
The shared control page both roles map had a mixed-writer cache line:
{appliedSeq, submittedSeq, retiredSeq, completedFrameSerial, presentAckSerial}
sat together, but submittedSeq is producer-written (Ring.h's "THE FIVE
WATERMARKS") while the other four are consumer-written. That is why no
single-writer progress block could be aliased over it, and it is what this
package fixes: submittedSeq moves up beside cmdHead, the other thing the
producer writes, and the four that remain become a named LinkProgress member -
the struct c6 declared in ILink.h - so a data plane with no shared page can own
the same four values and every wait predicate in SessionRings.h keeps ONE
implementation at source level.
The two event flags deliberately stay in the doorbell group. Promoting them
would put the client's per-drain exchange(0) on eventRingFull (EventRing.h:200)
onto the line the client's own spin predicate reads 1e5-1e6 times a frame.
73 call sites rewritten, 15 in the library and 58 in tests. The rewrite is
receiver-scoped rather than field-scoped, because SplitRuntimePeek.h:77 has its
own struct with a field of the same name and SplitRuntimePeek.cpp:146 has both
on one line - a field-scoped rename would have silently broken the peek struct
that MagmaRunAheadScenario reads nine times.
THE ASSERTS ARE THE PART THAT NEEDED A SECOND PASS. The first revision shipped
four aggregate asserts and an adversarial review broke them by counterexample:
swapping retiredSeq and completedFrameSerial inside LinkProgress compiled
clean, passed all four, and passed the runtime layout test too - because
appliedSeq stays at offset 0. Three wire-visible offsets on a shared page were
pinned by nothing. Nothing else in the tree covers it either: the ABI
fingerprint takes no input from this page, and sizeof(RingControl) is 4096
whatever the members do, because alignas(4096) makes it so.
So the block is now per-field as CONTRACT-P6 §8.3 actually asked, plus a
standard-layout precondition, a tail bound that alignas cannot satisfy for us,
and the LinkEventFlags mirror. Each was run red: the field swap reds the
per-field asserts by name, a fifth member inside LinkProgress reds sizeof.
One of them cannot go red, and Ring.h now says so rather than implying
otherwise. A SMALL member inserted between Progress and serverEpoch lands at
offset 288, inside Progress's own line, and serverEpoch's alignas(64) absorbs
up to 32 bytes of it - so both line asserts still pass. The review's proposed
fix does not catch it either; I tried it. A gate nobody can falsify is worse
than no gate, so the limit is written down beside the asserts.
Also: RingTest.ControlPageLayoutIsTheSharedContract said nothing about the one
field this package moved, and now asserts the producer/consumer separation and
the event flags' exclusion itself rather than borrowing the claim from Ring.h.
Ring.h grew 30 lines, so the Ring.h:NN citations this package and c6 authored
are re-anchored to named blocks instead of line numbers. ARCHITECTURE.md §11.2
is updated for the new layout, and a factual error introduced in the previous
revision of that paragraph - submittedSeq annotated as releasing staging, which
retiredSeq does - is corrected.
Gates: pull 0 added / 0 removed, .text size 10822147 unchanged; split 0/0,
.text size 11681395 unchanged; unit suite 2326/2328 with the same two
pre-existing environmental failures as the baseline. The five edits behind
MGITEST_SPLIT_RUNTIME_PEEK were proven compiled by a red-once, not assumed.
Note on the .text claim: that is a SIZE, not the bytes - field offsets moved,
so immediates moved with them. And this page's layout IS observable across
binaries; what is unobservable is behaviour within one binary. The contract
already books the layout change deliberately, before spawn exists to care.
CONTRACT-P6 §8.5 requires lk to publish its blast radius before it starts,
because an earlier draft's ratchet baseline was wrong by a factor of three and
a ratchet installed on the wrong number is red on its first CI run for reasons
that have nothing to do with a leak. The itemisation is mechanical, per file,
with the grep spelling stated: 250 seam-identifier references outside
Transport/ across 23 files (127 production, 123 test), and 44 member-access
sites that the regroup itself must rewrite, across 9 files - 17 of them in
MG_IntegrationTest, which is why lk builds with the integration tests ON.
The earlier build did not, and a missed edit there would have been green.
THE CORRECTION. Measuring that radius turned up a mistake in c6, landed two
commits ago. §8.3 and ILink.h both said lk should promote eventRingFull and
eventDropped into the watermark group and called the result single-writer.
eventRingFull has TWO writers: the server latches it with store(1) when
SEG_EVENT fills (EventRing.h:140) and the CLIENT clears it with an exchange(0)
on EVERY DRAIN (:200). Promoting it would put a per-drain read-modify-write by
the client on the same cache line as appliedSeq - which the server
release-stores every 64 records and the client's spin predicate reads 1e5-1e6
times a frame. That is new false sharing on the hottest line in the system,
and it would land in exactly the numbers this package's gate has to hold
constant. It is also the same class of error the earlier draft made with
submittedSeq, one field over.
So LinkProgress is now the four consumer-written watermarks and nothing else -
genuinely single-writer - and the two flags stay in the doorbell/generation
group, where mixed writers already live beside consumerParked/producerParked,
crossing the seam through a separate EventFlags() accessor on its own line.
The contract records the correction and its reasoning rather than quietly
restating the rule.
ALSO CORRECTED, from running the baseline suite: a6's link-experiment report
said the silently-empty build stamp had "no log and no gate to say so". That
is false. SessionHandshakeTest.cpp:140 asserts BuildStamp[0] != '\0' and :180
asserts that perturbing it changes the fingerprint, and BOTH GO RED in this
configuration right now - the worktree's .git holds a Windows path WSL's git
cannot resolve, so GIT_COMMIT_HASH_SHORT is "". The finding is therefore
stronger, not weaker: the gate that catches it already exists and is already
red. What is silent is the BUILD, and hs owes a named configure-time failure
rather than a new gate.
Baseline for lk, recorded: 3878 tests, two failures, both environmental and
understood - the fingerprint one above, and PipeCatalogue.FrontendNeverTakes-
AnApplierAddress, which walks up from cwd looking for MobileGL/MG_Impl and so
fails for any out-of-source build directory.
No behaviour change. The headers are still included by nothing.
c6 is P6's contract package. It lands CONTRACT-P6.md plus two headers and
changes nothing: no caller moves, no byte moves on any wire.
THE CONTRACT supersedes P6-CONTRACT-DRAFT.md, which was written before its own
audit and is wrong in four places - the ABI row, "the latch is implemented
unchanged", "cp is transport only", and "a transport swap and nothing else".
a6 measured all four. It carries 21 rulings, of which the load-bearing one is
D1: the spawned child keeps Transport == Spawn and anti-recursion becomes a
separate Dial axis. ARCHITECTURE.md:488's literal instruction is amended,
because 226 live lines test `Transport != Monolith` to select the SERVER arm
and forcing Monolith flips 148 MG_Backend lines to frontend glue in the one
process that has no frontend.
That ruling can be stated normatively without changing behaviour because of
something a6 found and the contract exploits: TransportMode::Spawn is
UNREACHABLE at this head. ConfigLoader.cpp:344 is the only writer for the
string "spawn" and it writes Monolith. So every rule conditioned on
Transport == Spawn constrains a state no process can enter - and the same fact
is why ConfigLoader's named refusal cannot currently falsify itself, which is
sm's to fix and §9.5's arm-proof gate to catch.
THE SEAM. ITransport says in its own header that the hot path bypasses it;
below it there was no interface of any kind, because Ring.h, ReplySlot.h and
EventRing.h each take a raw void* plus a capacity. ILink.h is that missing
thing. It is setup-time, not hot-path: no method is called per record or inside
a spin predicate, because Doorbell::Wait's predicate runs 1e5-1e6 times a frame
reading watermarks straight off the page, and because the only lane that could
measure a virtual there has four times the dispersion of the effect - a gate
that can only print "within noise" is ID-124 one level down.
StreamLink.h is declared and implemented by nobody, and that is its job: ILink's
adequacy is a claim only a compile can settle. Writing it forced three methods
the brief did not name - ResolveSpan, FlushProgress, and the reply pair - so it
has already earned its keep. Delete one ILink method it declares and the build
goes red.
Deliberately NOT here, each for a reason the contract records: Watermark::Reached
is not redefined (SessionRings.h owns it); Ring.cpp's reserve/pop bodies are not
relocated into a header that would then need the logger; RingControl is not
regrouped, because that rename is 109 production sites with five behind a
non-Android #if, so it is lk's with a published itemisation; and the schema rows
are specified but left to hs, because deprecating SurfaceReply.defaultFb while
appending `result` changes CreateSurfaceReply's arity and the existing
six-argument call would silently bind its defaultFb 0 to result.
INERTNESS IS STRUCTURAL, not measured. Zero object dependency files reference
either header, zero build rules do, and nothing in the tree includes them.
Verified anyway on both flavours: pull and split each have 0 symbols added,
0 removed, and .text byte-identical (10822147 / 11681395). check_doc_citations
resolves all 61 citations.
a6 is the read-only audit P6-SPAWN-PLAN puts first. Seven static items, each
produced by one auditor and re-resolved line by line by one adversarial
verifier; 198 rows, 65 of them contested, and only the surviving ones carry
into the report. Item 5 - can the server link without MG_Impl - is the one
experiment, and it is answered.
The headline is that P6's transport half is smaller than the plan assumed
(fc and fs really did land) and its process/role half is much larger. Four
statements in P6-SPAWN-PLAN / P6-CONTRACT-DRAFT are false at this head:
- `spawn` is not an observable state at all. ConfigLoader.cpp:339-346
recognises it and then writes Transport = Monolith, so "the child was
forced to monolith" and "the parent fell back" are the same value and
that named refusal cannot falsify itself. Meanwhile 226 live lines test
`Transport != Monolith` to select the SERVER arm, 148 of them in
MG_Backend - so ARCHITECTURE.md:488's rule, implemented literally, flips
the whole backend to frontend glue in the one process with no frontend.
- `cp` is not transport-only: the InitCapabilities REQUEST has no wire form,
and the codec refuses to encode the kind a live client still calls.
- the device-lost latch is not implemented; it was booked as carriage.
- the server cannot link without MG_Impl: 184 symbols, 177 of them real
function calls, owed 101 to P7 / 14 to P3b-P4b / 60 shared / 6 to P6.
There is also no module target to link, so the question could not have
been answered by editing a link line.
Four more blockers the earlier review missed: the apply thread aborts in
ConsumerDoorbell before popping its first record (contract 3.9's ruling, which
lk should retire rather than amend); neither side of the control plane is
wired to a socket; the control-reply wait has no deadline, so a frozen peer
wedges the GL thread inside eglMakeCurrent; and SurfaceReply cannot carry a
MobileGLResult, so a client cannot tell a dead server from a legitimate
refusal.
The abort census makes dl's funnel a redesign rather than a rename: 92 sites,
30 distinct family words against a 7-value wire enum, and three unbounded
peer-driven allocations with no try/catch anywhere under MG_Remote.
P6-ENDSTATE-REVIEW.md is the re-examination of P6 against the gfxstream-shaped
end state that produced a6's three added items. a6 overturned four of its own
claims - the MG_State eight's shape, the abort count, m_windowHandle's
injection points and a predicate name collision - and those are corrected in
place rather than left to be rediscovered.
Docs only. No code, no gate, no behaviour.
Two comments from the previous commit, corrected. The CMakeLists.txt reference in
DirectGLES.cpp pointed at the link libraries instead of the Debug/Release
visibility preset it names (707-719, not 629-641). The Managers.h note said the
overload is "reached by the pull, verify and push build" through "the three
wrappers", which reads as if all three called it; only
VertexArrayImpl::SyncClientSideVertexArraysForFetch does, and what those flavors
compile is the declaration, which is the point being made.
No code change: the four flavors' libraries are byte-identical to the previous
commit's (comment-only).
The indexed and indirect client-memory upload added by 62bfe461 landed inside
`#if MOBILEGL_BUILD_DISAGGREGATED`, and that guard was wrong in the one way a
guard can be wrong without a test saying so: the code exists FOR the monolith
arm - every entry point in the family early-returns when `MG_Config::Transport
!= Monolith` - and the monolith arm is what the pull, verify and push builds
run, not a split-arm fallback. Only the split lane was building the change, so
only the split lane could compile it.
Both CI jobs that build without MOBILEGL_BUILD_DISAGGREGATED were red on the
same twenty diagnostics: `build explicit pull runtime control` (the pull
library retrace's monolith control measures) and `build-linux-verify`
(MOBILEGL_PIPE_VERIFY=ON). DirectGLES.cpp:8437/8455/8521/8537 are the two
indirect executors and :9225/9251/9623/9637/9675/9700 the indexed family,
every one of them in an entry point that compiles in all four flavors, calling
names the pull build had no declaration for:
error: no member named 'SyncClientSideVertexArraysForIndirectFetch' in
namespace 'MobileGL::MG_Backend::DirectGLES::VertexArrayImpl'
... and SyncClientSideVertexArraysForIndexedFetch, likewise.
The block moves out of the guard rather than being duplicated per flavor, so
the split build's preprocessed text and behaviour are unchanged: the three
wrappers keep their `Transport != Monolith` early return, and in the
transport-free flavors that comparison folds to a compile-time false
(Config.h:566) exactly as it does in every other monolith-only body in this
file.
What the guard also covered, and moves with it:
* ClientFetchPlan.h - no longer behind `MOBILEGL_BUILD_DISAGGREGATED &&
MOBILEGL_PIPE_PUSH`. The plan IS the monolith arm's arithmetic (the file's
own header says so: "the monolith arm asks it to size the per-draw upload"),
so a pull build that cannot see it cannot answer the question.
* BackendVertexArrayObject::SyncClientSideAttributesForDraw - declaration and
definition, the callee of SyncClientSideVertexArraysForFetch.
* The `#include <MG_Impl/Pipe/ClientFetchPlan.h>` in Managers.h, which had
been inside the same `#if MOBILEGL_PIPE_PUSH` as the two P3a/P4a includes
it was appended to.
G1: the three wrappers and the byte reader take internal linkage (`static`),
the spelling the sibling SyncClientSideVertexArraysForDrawArrays already uses
at DirectGLES.cpp:2422 for exactly this reason. Verified on the pull build
(Release/INFO, LTO off, library target only, the monolith-symbol-report job's
own flags): 0 added / 0 removed / 0 resized / 0 renamed, .text +0, and every
one of the new names is LOCAL (`t`/`b`) with 0 of them in the dynamic table.
Had they kept external linkage they would have been 0 in the pull symbol delta
anyway (CXX_VISIBILITY_PRESET hidden) and 4 in a Debug build's dynamic table,
which is the configuration CMakeLists.txt:629-641 switches to `default`.
Verified: build-pull rc=0, build-push rc=0, build-verify
(MOBILEGL_PIPE_VERIFY=ON, DISAGGREGATED=OFF) rc=0, build-split rc=0; split
regressions green under both MOBILEGL_TRANSPORT=monolith and =inproc for
ClientVertexArrayScenario (18/18 each) and DrawRangeElements|MultiDraw (119/119
each), so the client-array behaviour 62bfe461..cb64b62b repaired is not
regressed.
The step located the Android CMake identity by reading CMakeCache.txt next
to each compile_commands.json, but the trace flavor's build also produces
.cxx/tools/<flavor>/<abi>/compile_commands.json copies whose cache lives in
the real per-ABI build dir, so the proof failed on a file-layout assumption
rather than on missing evidence. Remove the step and its artifact upload.
MEASUREMENTS 7.3 records what 82683d4a did, so the 30 s in that row stays as
written; a note under the table now says which value is live and where the
reason for the change is argued, so a reader looking up the barrier budget by
grep does not come away with the superseded number.
lavapipe/llvmpipe stall the apply thread for tens of seconds inside texture-handle
registration (vkCreateImageView -> lvp_CreateImageView -> llvmpipe_register_texture,
a JIT/futex path): one measured stall is 29.3-39.7 s and a whole inproc retrace run
accumulates ~44.6 s of it. At a 30 s bound those runs red intermittently on
Fatal{BarrierTimeout, "ResourceRespecify"} while the same stall under monolith is
merely slow - monolith has no cap on that path. Since the monolith shares the stall,
the bound, not the workload, was the defect.
kBarrierTimeoutMs becomes 120000, >3x the largest observed stall, which still leaves
the watchdog job: a record that is never retired still terminates the run and the
Fatal still names the seq. The comment now says deadlock detector rather than
performance gate and carries the measurement.
The fake-clock case that pins the budget moves with it: FenceWaitBudgetHonorsGl-
TimeoutAndFiniteTransportChunks expected 30000/30001/90000 and now expects
120000/120001/180000, so the assertion still pins the value instead of drifting.
Texts that wrote the old number down are updated: ARCHITECTURE 17.1, CONTRACT-P5E
2.4 (which now names kBarrierTimeoutMs rather than the literal), and the two
bring-up comments in PipeRoute.h / WireTables.cpp that dated a self-wait to
"thirty seconds".
Three follow-ups the first pass needed:
DirectGLES glGetTexImage has to attach a STENCIL_INDEX texture at
GL_STENCIL_ATTACHMENT. It went through the colour branch, which left the scratch
READ framebuffer incomplete for a stencil-only texture, so the readback bailed out
before the new stencil branch could run. ScratchFBOImpl::EnsureDepthAttachment2D
gains the stencil-only point; the shadow remembers which of the three points it
holds so a later depth attach is not deduped against it.
DirectVulkan's aspect staging buffer is recorded into the current frame's command
buffer and outlives the glBlitFramebuffer call that records it, so it retires with
the frame. VkBufferManager::DeferRelease is the manager's own deferred-release
entry point and moves to its public section for that.
f5234b9e taught the WIRE depth/stencil readback to scale a depth into the signed
integer widths' own range and to answer GL_HALF_FLOAT; 7b926429 taught the WIRE
depth/stencil resolve to copy only the requested aspect. Both landings stopped at
the wire entry points, so with MOBILEGL_TRANSPORT=monolith the same seven
DepthStencilReadbackMatrixScenario cases still failed on the same assertions.
DirectGLES: IsSupportedDepthStencilReadPixelsPair turned GL_BYTE/GL_SHORT/GL_INT/
GL_HALF_FLOAT depth and GL_HALF_FLOAT stencil away before the helper that can serve
them, and glGetTexImage had no GL_STENCIL_INDEX branch at all, so the read fell
through to a native ES glReadPixels the driver refuses and nothing was written.
ReadPixelsDepthComponent and ReadPixelsStencilViaNative now answer the signed
widths out of their own ranges and encode the half-float widths.
DirectVulkan: ReadDepthStencilImageToClient carried its own copy of the packing
and had drifted - GL_SHORT was scaled into 65535 (a 0.5 depth came back as
0x8000, i.e. -32768), GL_BYTE/GL_UNSIGNED_BYTE emitted the stencil byte for a
DEPTH_COMPONENT read, and GL_HALF_FLOAT was rejected outright. The packing moves
into one PackDepthStencilTight that both arms call, so they cannot disagree again.
DirectVulkan blit: a single-aspect depth/stencil copy of a packed format went out
as a whole-image vkCmdCopyImage, which moves the packed native word and therefore
replaced the destination's other aspect with the source's - a depth-only
multisample resolve overwrote the destination's stencil. Same-format,
same-size, single-layer single-aspect copies now stage that one aspect through a
device-local buffer, exactly as ResolveWireDepthStencil does.
The indexed wrapper reads the bound VAO to tell a client index array from an
element-buffer offset, and the indirect one reconciles the command buffer's
shadow - both frontend reads, and under a transport the entry points run on the
apply thread where the client owns those bytes (Fatal{RoleViolation}, which the
split lane aborted on).
The client-memory upload's index scan waits for the GPU (SyncGpuWrites), and that
wait flushes the batch the frame's command buffer belongs to - so a handle taken
before it is stale by the time the draw records into it. Reconciling in the entry
point, before SetupDraw starts recording, keeps the wait out of the recording.
The backend draw entry points run on the apply thread when a transport is live,
where MGB_CTX->GetBoundVertexArray() is a frontend read the server role must not
make (Fatal{RoleViolation}) - which is what the split lane aborted on. With a
transport the CLIENT owns those bytes and snapshots them from the GL thread, so
the server has nothing to stage.
The commands live in GPU memory, so the draw is issued natively and the vertex
range was left unknown (vertexCount = 0, instanceCount = 1). That is exactly
right for a buffer-backed array and wrong for a client-memory one: its bytes must
be in the store before the GPU reads the command, and the range the upload is
bounded by came out as one element - so the quad the command named was never
staged and the draw painted nothing.
The words are now resolved on the CPU for exactly that case, with the shadow
reconciled first (they may be shader-written), and the draw stays native.
DirectGLES: the client snapshot's baseInstance shift belongs to the backend's
emulation, not to the element set. A driver that applies baseInstance itself
(UseNativeBaseInstance) must not also receive a shifted pointer - doing both
moved instance 0 from element 1 to element 2 and left the instance that reads
element 1 outside the snapshot. EmulatedFetchBaseInstance is not declared this
early in the file, so the wrapper states the same answer inline.
Magma: ResolveIndirectCommandBytes reconciles a GPU-written command block before
the callers read it. Every one of them reads the words on the CPU - the
per-command expansion, the baseInstance the shader emulation is fed, and the
range a client-memory vertex array's upload is bounded by - so a command a
compute shader wrote this frame was expanded as the poison the shadow still
held, which is no draw at all for glDrawArraysIndirect.
Magma: a client-memory INSTANCE-RATE binding is bounded by its instances
(baseInstance + (instanceCount - 1) / divisor), not by the vertex range; the
upload now covers the union of the two, which is what instances shifted off the
front of the array needed.
The element set a divisor'd client array fetches starts at GL's raw
baseInstance, but the pointer shift that carries it belongs to this backend's
emulation: when the driver applies baseInstance itself (UseNativeBaseInstance),
EmulatedFetchBaseInstance answers 0 and shifting the pointer as well would move
the fetch twice - instance 0 read element 2 instead of element 1, and the
instance that used element 1 read past the snapshot and vanished.
Also drops the two 'continue' statements the indirect executors' lambdas cannot
carry (a lambda is not a loop; 'return' leaves the pass).
The wire arm snapshots the elements a draw fetches out of the application's own
vertex/index memory (beba0256's MGPipeOwnedDrawInputs). The monolith arm's upload
answered only the non-indexed family's contiguous (first, count) run, so an
INDEXED or INDIRECT draw over a client-memory array staged nothing: the enabled
array kept whatever the ES context last held for it - normally disabled, i.e. every
vertex reading the generic current value - and the draw painted nothing with no GL
error anywhere.
ClientFetchPlan.h is that question factored out once (the fetches, the referenced
vertices, the per-divisor element sets) so both arms answer it from one place;
OwnedDrawInputs now calls it and keeps its behaviour byte for byte.
DirectGLES: SyncClientSideAttributesForDraw stages the sparse snapshot for a draw
whose elements the (first, count) run does not describe, driven by the plan; the
indexed entry points and both indirect executors ask for it, and the arrays path
learns the baseInstance shift a divisor'd client array needs.
W3: DirectGLES' six monolith failures are the four indexed/indirect shapes.
5310ff62 generates the R11F_G11F_B10F chain with a real GPU shader blit and gives
F1WireScenario.GenerateMipmapPackedFloatPixels pixel assertions, so the entry now
passes under strict split state and `Magma:mipmap-native-format@P7` survives in no
source file. The old arm would therefore have failed its own `test rc -ne 0`.
Both halves of the arm are retired in one commit, because either alone is a lie:
CMakeLists' fm loop drops the special case that gave this slot the
`integration-magma-p7-known-red` label, so the entry is discovered by the ordinary
`integration-magma-split` lane - the same label, environment and pixels assertion
as GenerateMipmapPixels and GenerateMipmapDepthPixels next to it, and the same
label the dual-block census lane already re-runs.
The lane membership alone would not be the old gate's equal: a lane counts its
entries' exit codes, not their absence. So the CI step keeps its shape and inverts
its expectation - the same single named entry, discovered and run by name under
MOBILEGL_IPC_STRICT_ERRORS=1 and MOBILEGL_IPC_ROLE_SPLIT_STATE=1, now asserted
through split_log_paths.py's require_green, which rejects a skip, a missing entry
and a failure alike. No assertion is skipped away or weakened: the pixels are
checked by the test, and the named-refusal check the log carried is what the GPU
blit retired.
40c5443f appended LevelWidth/LevelHeight/LevelDepth/LevelPad to MGPSubData and raised
MGP_ASSERT_POD to 88, but left MGP_FIELDS_MGPSubData at its old eight rows.
gen_pipe.py's check_field_lists_cover_struct_members runs in both modes and in
pipe-gates, so "member(s) with no F(...) in PipeFields.def: LevelWidth, LevelHeight,
LevelDepth, LevelPad" reddened both 'Regenerate the MGPipe interface' and
'--self-test'.
The four rows go in declaration order. LevelWidth/Height/Depth are ordinary members
(the record's full staged mip extent, which the ResourceSubData codec's
"ResourceSubData.LevelExtent" arm bounds the dirty box against). LevelPad is padding
by role only: PADDING_MEMBER_RE is ^Pad\d*$, so a member named LevelPad is a member
to the cover check and MUST have a row. That row risks no false positive - every
producer builds the record as `MGPSubData{}` and the payload rides the wire as one
POD memcpy of all 88 bytes, so both sides of a comparison read the same zero.
Field tables only describe the existing struct, so no generated file changes:
--check stays byte-identical.
The record carries eleven surfaces, but the walk writes the attachment points
only - eight colour plus depth and stencil - and the read surface is applied by
ApplyReadBufferFromRecord rather than by the walk. Say "the SAME attachment
points on it" instead of a count that is wrong for the walk.
Magma's generate path (VulkanRenderer::GenerateMipmap) took its window from the
BOUND object and its blits from the STORAGE image, and the two do not coincide
for a texture view: glTextureView gives a view its own BASE_LEVEL/MAX_LEVEL and
layer slice over somebody else's image. Three things followed from the mix-up,
all visible through a two-layer view over a four-layer 2D array:
* EnsureGenerateMipmapStorageAllocated allocated the chain THROUGH the bound
object from that object's OWN base extent, so the storage owner's level was
resized to the view's layer count - the storage behind its other layers was
gone - and the levels outside the view's window were redefined with it.
Immutable storage and views already own every level generation can write,
so neither allocates anything now, and a mutable owner gets only the levels
its window is missing (AllocateStorage RESIZES the level it is handed, so
an existing level keeps the shadow bytes its own upload path wrote).
* The level the chain starts at was the view's, applied to the storage image:
a view whose MIN_LEVEL is 1 generated from the storage's level 1 instead of
its level 2. The window now comes from the frontend's own plan
(MipmapGenerationPlan.h - the same numbers MGPMipPlan::LevelCount publishes)
and is shifted into the storage's numbering before any blit or barrier.
* Every layer of the storage was written instead of the view's window. The
blits carry the view's baseArrayLayer and layerCount now; every layer
outside that window belongs to the storage owner and is left as it was.
The end of the window follows MAX_LEVEL for the same reason the frontend's plan
does: a chain MAX_LEVEL cuts short is no longer generated past it.
The readback had the same shape of bug pointing the other way.
VulkanRenderer::GetTextureImage sized its copy by the storage image's array
layer count while starting it at the caller's layer origin, so a two-layer view
of a four-layer array copied four layers - two outside the window, and the last
one past the end of the image. glGetTexImage carries no destination size, while
the size glGetTextureImage validates is built from the VIEW's own extents, so a
caller that sized its buffer for the view had it overrun by exactly the layers
outside its window. The copy covers the caller's own layers now.
TextureViewScenario.GenerateMipmapThroughAViewLeavesTheOwnerShapeAlone runs on
both backends now. Its DirectVulkan skip named the reallocation above, and the
sub-image readback the skip blamed as well is not broken: a control run of the
same scenario with the generate removed reads every seeded level and layer of a
2D array back, mip 2 and 3 included, through both glGetTexImage and a per-layer
FBO attachment - the two mismatches that control reported are the colours the
generate was supposed to write. GetTexImageThroughAViewReturnsOnlyItsOwnLayers
is new and pins the readback window with canary slices past the view's last
layer; it fails on the reverted readback and passes on DirectGLES too.
Every place that stated "P5 does not chunk" as current behaviour except the
Fatal texts (previous commit) now records the stage chunk budget and what it
does not cover.
- CONTRACT-P5.md section 5: the STAGE_MB row names MGPipeStageChunkBytes() =
clamp(segment/4, 4096, segment), the two buffer walks (1e7c372e) and one
texture level's whole-width slabs (9469d48e, assembled by
StagedTextureStore::AdoptRun), and the Fatal that stays the backstop for a row
with no cut; the RING_MB row says the record bound is about the record's own
bytes.
- BRIEF-P5.md's R-10 section is no longer headed "does not chunk": it keeps the
record bound and the maxrec proof obligation unchanged and records that
content is cut, with the encoder Fatal as the fallback.
- ROADMAP.md's staging debt row and open question 11, ARCHITECTURE.md's
record-bound bullet and SEG_STAGE table note, README.md's knob row,
MEASUREMENTS.md section 6.3 and CURRENT_STAGE_PROGRESS.md's open item: the
explicit MOBILEGL_IPC_STAGE_MB=256 profile is no longer needed for the two cut
paths, and the carrier design for the rest stays P8.
- Historical results (notes/**/p5-results/, p5b/) are left exactly as recorded.
R-10's "P5 does not chunk (R-10)" wording predates the stage chunk budget: buffer
content ranges are cut at MGPipeStageChunkBytes() (1e7c372e) and a texture level
at whole-width slabs (9469d48e), so the claim is false for the two rows that can
outgrow SEG_STAGE - and these messages are what a bring-up reads.
- PipeWireEncoder::StageAllocate's two Fatal{RingOverrun, "SEG_STAGE"} sites now
say what reaching them means: a blob is staged whole, a row that cuts its
content at the stage chunk budget never arrives, so this is a record type with
no cut or a piece still larger than the arena.
- EncodeRecord's MaxRecordBytes arm says the bound is about the record's own
bytes - the stage chunk budget cuts blobs, not a header, payload or inline
tail - and keeps the integrator decision it names.
- Comments in PipeWireCodec.h, EmitTables.cpp's maxrecop line, Config.h's
STAGE_MB knob, PipeStats.h's MaxRecordBytes gauge, SplitRuntimePeek.h and
WireLedgerChecks.h said P5 does no chunking; they now say what is cut and what
is not.
- PipeWireCodecTest.ARecordLargerThanHalfTheRingIsFatalRingOverrun asserts the
new diagnostic text instead of "does not chunk (R-10)".
SyncToBackendByHandle gated its record-driven attachment walk (the eleven
applyPoint calls plus NoteFramebufferTextureAttachments) on
m_syncedRecordHashes[asTarget], a PER-TARGET slot, while the work inside is
object-level: glFramebufferTexture2D/glFramebufferRenderbuffer write the
driver FBO behind whichever binding is current, so a Draw walk and a Read walk
write the same points. Two slots can therefore hold two individually true,
jointly false accounts of one physical state.
improved-transparency flips a composite framebuffer between two attachment
states and syncs one of them as DRAW in a depth pre-pass. That walk detaches
the colour point, stamps the Draw slot; the end-of-frame READ-side sync of the
other record then found its own slot already stamped by an earlier identical
sync, walked nothing, left the point detached, and the driver refused the
following glBlitFramebuffer with INVALID_OPERATION - the default framebuffer
froze from frame 835. The object form is immune because a per-point version
array gates it underneath; the handle arm has no frontend array, so this memo
is its only gate.
Replace it there with m_syncedAttachmentRecordHash, one slot for the object.
The per-target array stays for the object arm, where it is a re-arm trigger
over that version array rather than a claim. InvalidateSyncedState now zeroes
both memos: it invalidates the version array, which is not the handle arm's
gate, so a memo left standing would let a re-sync of the same record skip the
re-attach that call exists to force.
Both cases were written against the wrong property and went red on the first WGB
build, which is the one thing a case may not do.
TextureEmit.ALevelTooLargeForTheStageChunkIsCutIntoSlabs asserted D == 8 slices for
every slab of the 512x128x33 level, whose last slab is the 33rd slice alone. The
per-slab check now names the scheme (Z = 8 * index, D = 8 for every slab but the
remainder) instead of restating one number for all of them.
StagedTextureStoreTest.ALevelWithAPieceMissingIsRefusedAsAWholeLevelReadByName
wanted "not covered" out of a single LEADING run, and the store cannot answer that:
it knows no format, so it cannot know how many bytes the level should have - the
check is "the range the reader asks is covered". The case now uses the shape the
check really is about, two runs with a hole between them (ServerLoopTest's
R-11 coverage case, one store over), and the looseness is written down: in the
header's coverage ruling, and as an assertion on the leading-run case beside it.
ONE RECORD'S BLOB IS STAGED WHOLE IN SEG_STAGE, a linear arena, so a texture
level larger than the segment's chunk budget was Fatal{RingOverrun, "SEG_STAGE"}
at the encoder rather than a split - and the CI traces hit it: the texture half of
resource_subdata stages the whole level shadow regardless of how little of it
changed, and a 512x128x33 GL_RGBA32F level is 34,603,008 bytes while a 192-cube
GL_RGBA16 level is 56,623,104, both against the default 32 MiB segment. Three
retrace cases aborted there: iterationt-in-world and iterationt-nodsa-in-world in
both backends, and iterationrp-in-world in DirectVulkan.
THE CLIENT now cuts such a level into WHOLE-WIDTH SLABS, one resource_subdata
each (TextureEmit.h's MGPipeForEachTextureSlab, driven at MGPipeStageChunkBytes -
the same cap PipeFill.cpp's buffer walks are cut at). A piece is a set of
consecutive rows of the level shadow, so its box names both its texels and its
byte run; whole slices are the coarsest cut, a slice bigger than the cap falls
back to whole rows and a row bigger than the cap to whole texels inside one row.
Every piece is emitted, dirty or not: the server assembles one level image out of
the runs, so a piece left out would leave a hole no texel read can answer. Each
piece carries at least one region (its clipped dirty rects, or its own box when
none reaches it) because RegionCount == 0 remains the whole-level spelling, and
each region's SrcOffset is rebased onto its own piece's run - MGPipeTypes.h has
always said SrcOffset is "into the blob" - while the strides stay the level's. A
level that FITS keeps the single whole-level record, field for field what it has
always been, which is what every existing gate is written against.
THE SERVER now assembles levels from those runs. StagedTextureStore::AdoptRun
places one run at an image offset, its coverage set is exactly the runs it has
adopted (StagedShadowStore's CoverageAdd, the buffer half's helpers), and only a
FULLY covered level satisfies IsCovered / RequireLevelBytes - a level with one
piece missing is as unreadable as one that never arrived, and it is the same
Fatal{StageSnapshotTooNarrow}. The whole-level spelling survives as Adopt, its
offset-0 replacement, and the two are told apart by the run's own LENGTH: the
whole-level run is the level's complete shadow whatever its box says, a piece's
run is exactly its box's byte extent. Both adoption call sites -
Ops_H_TextureSubData and PipeApply.cpp's hookless fallback, which is what Magma
takes - read the offset that way.
THE PROTOCOL IS UNCHANGED: no field moved, no caps bit was added. The one
compatibility statement is that a split level now crosses as several records of
one apply batch, so an OLD server (which replaces the level on every record) would
keep only the last piece - inproc runs both roles out of one library, so the
constraint is noted here rather than armed with a gate.
Tests. TextureEmit.ALevelTooLargeForTheStageChunkIsCutIntoSlabs drives the split
at MGPipeStageChunkBytesFor's clamp with both trace shapes (5 and 7 slabs), the
row-granularity and texel-granularity fallbacks, the three shapes that refuse to
cut, the region clipping/rebasing, and the piece no rect reaches.
StagedTextureStoreTest gains the multi-run assembly, the gap that must not read as
coverage, the partial-level Fatal by name, the run-offset discriminator, and two
production cases - the applier's hookless arm and DirectGLES's real TextureSubData
hook - that would put both halves at the front of the image if the offset were
dropped.
EnsureGeneratedMipmapStorageAllocated branched on the transport. The split arm
planned the generation window from BASE_LEVEL/MAX_LEVEL and refused to touch
immutable storage (70c8230b); the monolith arm rebuilt the chain from the bound
object's level-0 extent and then truncated it. Through a view that is
destructive in a way the split arm had already been fixed for: a view's level-0
extent is one slice of the owner's layers, so a two-layer view of a four-layer
array resized every owner level the generation touched to two layers - the
storage behind the owner's other layers was gone - and redefined the owner's
levels out of the view's window as well.
Both arms now take one path. An immutable texture or a view returns before any
allocation: it already owns every level generation can write. A mutable texture
gets levels from the plan's Base..End window and nothing else moves - the chain
is not truncated, because neither MAX_LEVEL nor a generate is a truncation (a
level-0 respecification drops the stale tail itself, in
DiscardMipmapChainOnBaseRespecification). MipmapGenerationPlan.h is no longer
behind MOBILEGL_BUILD_DISAGGREGATED for the same reason: the window is a
property of the GL call, not of how the backend is reached, and the plan the
wire carrier publishes (MGPMipPlan::LevelCount, end-exclusive) is this one.
The window rule was asserted only by the split-only F1WireScenario, whose
fixture skips under every other transport - so the monolith arm, which is what
a non-disaggregated library runs for every application, had no coverage at all.
TextureViewScenario.GenerateMipmapThroughAViewLeavesTheOwnerShapeAlone runs in
an integration-gpu lane under both transports and pins the owner's layer counts,
its own level extents, and the pixels of the levels and layers outside the
view's window. It is DirectGLES-only, and the skip names why: Magma reallocates
the chain through the bound object in its own generate path
(DirectVulkan/Renderer/VulkanRenderer.cpp's EnsureGenerateMipmapStorageAllocated),
and a CPU sub-image into the layers of mip 2 or 3 does not read back through
Magma at all - a run of the same scenario with the generate removed fails
identically - so the per-level colours this case is built on are unobservable on
that backend rather than merely wrong.
DirectVulkan resolved every default-framebuffer role against
m_imageIndexAcquired. Present() advances that to the image it acquires for
the frame that is about to start, and a present record is kWaitPresent - the
client does not wait for the apply - so a readback belonging to the frame
being presented (the retrace snapshot, taken at the swap call) can be applied
after the acquire and then resolve an image nothing had written. That is the
pure-black sundial-lite DirectVulkan retrace frame; DirectGLES is immune
because the driver keeps handing back the buffer the app drew into.
Split the GL-visible index out (VulkanRenderer::m_defaultFramebufferImageIndex)
and keep it out of the present/acquire path: it only re-points at the acquired
image when something WRITES the default framebuffer (DefaultFramebufferWriteIndex,
called at the FBO-0 render-pass creation, the deferred-clear-free direct writes
and the wire draw/clear/blit resolutions) and is reset on RecreateSwapchain.
Reads - glReadPixels both arms, the parked-clear materialization, a blit or copy
SOURCED from FBO 0, ResolveWireImage's read role (carrying the resolved index in
WireImage) - resolve DefaultFramebufferReadIndex instead, i.e. whatever that is.
A readback that resolves a swapchain image with no recorded write now says so
once (MGLOG_W_ONCE, both arms) instead of silently handing back raw pixels.
New integration scenario DirectVulkan|DirectGLES.DefaultFramebufferAcrossSwap
pins both halves: a readback after a swap still sees the drawn frame, and the
first draw after a swap lands in the image the readback then resolves.
glGenerateMipmap's native arm carries BASE_LEVEL/MAX_LEVEL of the view's own
ES name, so the LEVEL axis was already clipped the way the view describes, but
the layer set it writes is every layer of the storage the view aliases. A
generation through a windowed texture view therefore rewrote the owner's layers
outside the window (F1WireScenario
.GenerateMipmapThroughViewKeepsOwnerLevelsAndLayersOutsideItsWindow).
Both arms now read the window and generate the chain with per-layer blits
between the owner's levels MinLevel + L, over layers
[MinLayer, MinLayer + NumLayers), and leave the native call for the shapes it
already gets right: no view, a view whose layer window covers the whole
storage, a multisample view, and aspects the layer blits do not carry. The
record arm reads the window from the sampler view CSO with the same validation
SyncTextureViewToBackendByRecord uses (Fatal{ProtocolCorruption} on a stale or
foreign record); the monolith arm reads the same numbers off the frontend view
object. ScratchFBOImpl gains EnsureDepthAttachmentLayer for the depth-only
aspect.
PipeFill.cpp reaches MG_Remote::Client::MGPipeStageChunkBytes through
GpuWritePending.h, which the TU did not include: the declaration was visible to
the helper's own TU and to no other, so both MobileGL translation units for
PipeFill.cpp failed to compile while the shared objects it defines built fine.
PipeFill.cpp.
ResourceEmit.AWideBufferSubDataSplitsAtTheStageChunkBytes: the walk driven at
MGPipeStageChunkBytesFor's clamp (a quarter of the segment, floored at 4096 and
never above the segment), with the trace's own 128 MiB arena against the default
32 MiB segment - sixteen contiguous ascending pieces, each encodable by the
record builder, covering the range exactly once. The no-session answer (0 = keep
the record's own bound) is asserted beside it.
ResourceEmit.AZeroByteRespecifyDefinesTheStoreAndCarriesNoContent: armed the way
the split-only unit cases are (Transport = InProcess, an op table, and the caps
mirror R-8's liveness gate reads), BufferObject::Respecify(0, nullptr) must not
abort, must land Width == 0 && HasDefinedContent == 1 on the applier, and must
emit no resource_subdata at all. The drive runs in a child because the defect's
verdict is std::abort() and a case that aborts its own binary reports nothing.
MG_Test/Pipe/ResourceEmitTest.cpp.
glBufferData(target, 0, NULL, usage) DEFINES its store - that is not the
orphaning idiom - so HasDefinedContent is set, and MappedData() answers a
non-null pointer for it because the shadow reserves one byte whatever the size
(PipeResource.h:140-143). Reading MappedData() alone therefore put the call on
the split arm's respecify(nullptr)-plus-follow-up shape, whose walk emits
nothing for a zero-length range (ResourceTracker.h:253), and the
InitialBytesNotCarried self-check aborted by name on the first such call in the
bsl-esc-menu trace.
The predicate is 'HasDefinedContent && GetSize() > 0': there are bytes here,
not merely a defined store. The split arm's own condition is untouched - moving
it instead would send the zero-byte case to the UncarriedInitialBytes refusal
in the wire escape.
PipeFill.cpp.
One resource_subdata record's blob is staged whole in SEG_STAGE, so a range
larger than that arena is Fatal{RingOverrun, SEG_STAGE} at the encoder rather
than a split: the CI traces abort there on a 128 MiB arena's whole-buffer
follow-up against a 32 MiB segment.
- ClientSession::StageCapacityBytes(), the twin of EventRingCapacityBytes, so
the emitter can size against the arena the codec allocates from.
- MGPipeStageChunkBytes(): clamp(segment/4, 4096, segment), 0 when there is
nothing to fit (monolith, the server role's own uploads, no session).
- PipeFill.cpp's two buffer content walks take that cap instead of the
record's own 2^32-1 bound, so an over-long range becomes several contiguous
ascending records. StagedShadowStore::Adopt already accumulates coverage.
ClientSession.h/.cpp, GpuWritePending.h/.cpp, PipeFill.cpp.
The remove-artifact-clutter jobs deleted trace fixtures and results while
reading job/artifact inventories through unchecked process substitution, so
a failed GitHub API page looked like 'no failed cases' and fixtures for a
red retrace could be deleted. test.yml cleanup also did not wait for the
retrace-split lane even though those jobs download the same fixtures.
Read both paginated inventories into files before any deletion so an API
failure fails the step, add retrace-split to the cleanup dependencies, and
retain fixtures plus result artifacts for failed cases in all three lanes.
The twelve Server* forwarders' one-slot mailbox carried a raw function
pointer plus a void* to a stack-local Args struct (P5c audit row G4);
neither means anything across a process boundary. The slot now carries
one SurfaceControlFrame by value: kind is the op, scalars are the
arguments, and ok/eglMajor/eglMinor come back in the same value. The
blocking handshake, the shadow, the doorbell order and the C2 exit
block are unchanged.
- protocol.fbs: append-only - SurfaceOpKind gains SetSwapInterval=8,
ReleaseResources=9, SetWindowHandle=10; WindowKind gains MetalLayer=6;
SurfaceOp gains readSurface and context (the MakeCurrent four-tuple).
protocol_generated.h regenerated with the pinned flatc.
- Server/SurfaceControlFrame.{h,cpp}: the frame (trivially copyable by
static_assert) and the op enum - ten wire kinds plus four inproc-only
kinds (InitCapabilities is answered by CapsSnapshot on the wire,
SwapBuffers/InitWindowSurface have no production caller - f0-egl F8,
kept for the inproc test lane).
- Protocol/SurfaceOpCodec.{h,cpp}: the frame <-> SurfaceOp/SurfaceReply
codec, the explicit WindowBackend <-> WindowKind table, and the wire
entry ServerApplyWireSurfaceOp whose refusals are named:
Fatal{UnmigratedSurface, "AndroidNativeWindow@P12"} and
Fatal{ProtocolCorruption, "SurfaceOp"}.
- ServerLoop: RunOnApplyThread(fn, void*) -> RunSurfaceControlFrame(frame)
plus the dispatch ApplySurfaceControlFrame; the old per-forwarder Args
bodies moved into the dispatch unchanged (C7/ID-54, N-3, R-12 included).
RunProbeOnApplyThreadForTesting is the test seam through the same
channel (the hook lives in a member, never in the slot).
- Tests: RemoteClientTest/ServerLoopTest migrated to the probe seam; new
SurfaceControlFrameTest suite (mapping tables, wire round-trips, the
two named refusals) plus ServerLoopTest's AVoidForwarderCrossesAsOne-
DispatchedFrame (the frame channel's red-once handle) and
ServerLoopEglTest's TheInitializeDisplayReplyArrivesThroughTheFrame.
Statics, reverse channel, Magma cross-role sites, EGL control plane,
registry rekey, build/gate mechanics, the 15 BARRIER_PULLED fields'
carrier rulings, and the dual-block mechanism's design recon.
The section explained what gPipeInputs IS and what breaks under spawn, but not the one fact
that decides the shape of the fix, and not how a call actually reaches the driver through it.
Both are now in the document rather than only in the conversation that found them.
- THE POINTER FACT, now stated first because everything else follows from it: several
BARRIER_PULLED fields do not hold values, they hold pointers into client-owned objects.
PipeFill.cpp:274 fills m_textureUnitBase with `&ctx.GetTextureUnitObject(0)` - a raw base
into the frontend's array - and :245 fills m_programForDraw with a SharedPtr<ProgramObject>.
PipeInputs.h:669 dereferences it as `m_textureUnitBase[unit]`. PipeFill.cpp:2591-2592 says
the same of the shader-buffer field: "four raw bases into the frontend's binding-point
table".
The consequence gets its own paragraph in 1.4, because it is the step most likely to be got
wrong: this CANNOT be fixed by moving the block into shared memory. Mapping the same pages
into both processes does not make a client heap address meaningful in the server, and a
SharedPtr's refcount does not cross at all. The 15 fields each need a different SOURCE, not
a shared location.
- NEW 1.2, one draw end to end: glBindTexture marks a tracker bit and sends nothing, because
push only happens at a verb boundary; glDrawArrays enters MGPipeValidateForVerb
(PipeFill.cpp:3347) whose four steps are barrier decision, dirty walk, emission (records,
carrying handles), and the residual fill - which is where BARRIER_PULLED happens; the record
goes on SEG_CMD; the apply thread dispatches to the backend; PrepareForDraw reads either a
record-carried handle against the server's own tables, or gPipeInputs; then the real driver
call. Plus the field lifetime: stamped with verb N's serial, read during verb N's apply,
Fatal{UnmigratedPipeInput} if read at verb N+1 without a refill - one verb, never longer.
- The same example carries the Espryt/Magma split concretely: the texture binding reaches
Espryt as a record with a handle (P5e retired GetTextureUnitObject for it) and reaches Magma
through gPipeInputs into the client's array. That is FieldOwnership.def's own
"P5e (Espryt unbarriered), P7 (Magma)" made legible, and it is why section 2.1's table has
eight rows in that state.
Sections renumbered 1.1-1.4. Doc citation lint: 129 citations, 0 problems.
P6's premise is that it is "only a transport swap". That premise was bought by P5c and has
expired. Measured on this head, the two roles still share state directly, so a spawned server
would have no source for it. P5f is the phase that makes the premise true, and P6 does not
start before it.
WHAT THE MEASUREMENT FOUND
- FieldOwnership.def still carries 21 BARRIER_PULLED rows over 15 distinct fields. That class
is defined as "the server answers by reading a value the client's residual fill left in THE
SINGLE SHARED gPipeInputs while the verb barrier holds both threads apart". Eight of those
fields were retired by P5e for Espryt's unbarriered path only - Magma has not moved at all.
The strict lane measures 10 live <field>@<verb> pairs today.
- gPipeInputs (MG_Backend/MGPipe/PipeInputs.h:835) is a plain process global,
`inline PipeInputs& gPipeInputs = *new PipeInputs();` - not a shm segment. The client fills
it (MG_Impl/Pipe/PipeFill.cpp), the backend reads it. Under spawn the two processes have
DIFFERENT objects, the 15 BARRIER_PULLED fields have no filler in the server's copy, and
every read is Fatal{UnmigratedPipeInput}. MG_Impl is not in the server, so "let the server
fill it" is not available either. The header says this itself at :279 and :902.
- The twelve Server* EGL forwarders do not use the wire at all: they post a function pointer
plus a stack-local void* through a one-slot mailbox (ServerLoop.cpp:533-568, :645). The
SurfaceOp / SurfaceReply / WindowKind schema is defined in protocol.fbs and unused.
- Process-level statics with per-context semantics (g_syncedRenderStateParameters,
ScopedDefaultUnpackState::s_synced) - and the full list is unknown, because this defect class
is structurally invisible to inproc.
- MGPipeFrontendKeyedRegistryScope is still live at ~12 sites in DirectGLES.cpp, carrying
P3b/P4b's debt: the registry is still keyed by frontend address / lifetime id.
THE SCOPE RULE
P5f takes the cross-process-blocking subset of P3b/P4b/P7/P8/P9 and nothing else. One test:
does it still hold in another address space? No -> P5f. Yes but slow or ugly -> it stays with
the phase that owns it. So Magma's nine fields and its direct writes into client storage come
here; content-addressed CSO, internal shader baking and the fixed-width rewrite stay P7's. The
reverse channel's asynchrony and ordering stay P9's; only the parts that touch client memory
move. Class C is NOT P5f's - a named refusal behaves identically in two processes.
THE CENTRAL MECHANISM: A TWO-BLOCK REHEARSAL
P5c set the same rule (rule E) and checked it by AUDIT - a human reading 59 lines. P5f checks
it by MACHINE: give each role its own PipeInputs under inproc, and its own copy of every
per-context static, behind a knob. Anything that depends on the two being the same object
becomes Fatal{UnmigratedPipeInput, "<Field>@<Verb>"} with the field and verb named.
That rehearses the process split's SEMANTICS without a second process. It matters because
today "the server read the value the client filled" and "the server read the value a record
carried" are indistinguishable at runtime - which is exactly what P6 would discover on day
one. The package order puts the mechanism (f1) BEFORE the migrations deliberately: let the red
appear first and let it be the worklist, rather than having each package declare itself done
and then looking for a criterion.
Exit gate: the two-block lane green; strict-expected-markers.txt EMPTY with its two-sided
ratchet still armed; zero BARRIER_PULLED rows; rsp reading zero per frame; Magma judged by the
same criteria as Espryt. The negative control is that turning the two-block knob OFF must move
at least one named case from green to red - otherwise the mechanism proves nothing, which is
ID-122's lesson.
ALSO IN THIS COMMIT
- ROADMAP gains the P5f row and the IPC track becomes P5e -> P5f -> P6; P6's status cell
becomes "blocked on P5f".
- README and CURRENT_STAGE_PROGRESS gain the same, plus the file-map entry.
- Both P6 documents gain a blocked banner naming what is not true yet.
Doc citation lint: 124 citations across docs/Disaggregated/*.md, 0 problems.
Two things land together: the plan and contract draft P6 needs before it starts, and the
working notes that until now existed on exactly one machine.
1. P6's plan and contract draft (neither started; not one line of code)
docs/Disaggregated/P6-SPAWN-PLAN.md the package plan
docs/Disaggregated/P6-CONTRACT-DRAFT.md the contract draft (moves to
MG_Remote/CONTRACT-P6.md when c6 lands)
The three findings that mattered, all written into the documents:
a. P6 is SMALLER than its roadmap cell suggests. The transport primitives are already in
the tree and already tested: SocketDoorbell including hangup detection and the two
lost-wakeup fences (Doorbell.cpp:211-360, four cases in FdPassingTest), SCM_RIGHTS fd
passing, ShmSegment::Adopt, and the SurfaceOp / WindowKind schema already defined in
protocol.fbs. inproc uses a real second shm mapping rather than an alias, and
SessionRings.h:22-28 says that was done so today is not the first time it runs. P6 is
assembly plus a control plane plus a process, not "write a socket transport".
b. The evidence for "P6 is only a transport swap" has EXPIRED. It was bought by P5c, whose
audit found 59 direct memory accesses in a tree that already looked clean. P5e changed
the tree again. So the first package, a6, is a read-only audit that writes no code, and
every contract row that depends on it is marked PENDING a6 rather than decided in
advance.
c. P6 does not get a game running on the phone. ANativeWindow* is meaningless in the child,
and protocol.fbs:186-196's own comment says Android transfers the window out of band -
that band is P12. P6 lands pbuffer / surfaceless / offscreen, which is what HeadlessGL,
the split-equivalent lane and trace replay need, and which is exactly what P6's own exit
gate measures. A real window reaching the server is a named refusal. This gets its own
section because "P6 is done" would otherwise read as "the game runs now".
The contract adds rule G: a role may not name a process-local handle. It extends P5c's
rule E (a role may not name the other role's memory) from memory to identity - EGLDisplay /
EGLSurface / EGLContext / ANativeWindow* / fd number / pid / mapped address either cross as
a token this contract defines or they do not cross, and a descriptor crosses only through
SCM_RIGHTS, where the kernel renumbers it.
What P5e left for P6 gets its own section. The latch semantics are already designed
(ARCHITECTURE.md:504) and are copied unchanged. What is new is that after run-ahead the
client no longer waits while the peer process can DIE: the client holds records that were
published and will never be applied, and Android's freezer suspends the child without
closing anything. So slow and dead must be distinguishable WITHOUT a timeout threshold.
That is P6's only new design; everything else is carriage. It shares a predicate with E1's
outstanding re-specification (ID-122), and the two should be designed together.
Two gates carried over from P5e's lessons: G2 requires integration-spawn's test-name set to
match integration-split's name for name, because a differing set means the lane is quietly
running less; and the arm-proof gate requires every entry to record the child pid and
transport=spawn in its OWN private log - ID-124 established that this kind of false green
really happens, and a spawn lane that actually ran monolith would pass every other gate.
S2's shape must be settled before it is written, and the document says why: E1 is the
cautionary case.
2. The working notes move into the repository (docs/Disaggregated/notes/, 415 .md files)
ROADMAP / CURRENT_STAGE_PROGRESS / MEASUREMENTS cite ruling numbers, audit line numbers and
the provenance of gate figures from ~/w7/notes/, a directory that exists on one machine and
is under no version control. A record cited hundreds of times that lives only locally is not
a record, so it moves in whole, with its directory structure intact.
Only .md moves. Scripts, run logs, census and gate result JSON, patches, APKs and nm dumps
stay where they are: they are evidence DATA rather than documents - large, regenerated per
run, and mostly carrying absolute paths and device serials that mean something only on the
machine that produced them. The mapping and this split are written in
docs/Disaggregated/notes/README.md.
Two exceptions are recorded there too: P6's BRIEF has been promoted to a first-class
document and keeps no copy under notes/, so the two cannot drift apart; and recovered/ holds
material salvaged from failed workflow runs, which may duplicate the like-named phase
directories and is kept because a few of those were the only complete version at the time.
CI's citation lint takes docs/Disaggregated/*.md only (non-recursive), and notes/ is
deliberately outside it: those notes' file:line citations were written against their own
code heads, and resolving them against today's HEAD would fail wholesale - which is not an
error, it is their correct state as a historical record. To check one, use the revision it
declares (--rev).
3. Inconsistencies fixed in passing
- README.md's status line still said P5e was in progress while the stage table already said
closed.
- CURRENT_STAGE_PROGRESS's next-step item 1 still listed the exit gate as pending, though
(a) and (b) were run in the previous commit; rewritten to closed plus the one item that
actually remains (E1).
- ROADMAP's P6 row still had the pre-phase plan in three of its cells; it now carries the
package order, the scope boundary and the two new gates.
Checked with check_doc_citations.py: 125 citations across docs/Disaggregated/*.md, 0 problems.
It said "in progress" because BRIEF-P5E section 3's exit gate had never been RUN, not because
the work was unfinished. It has now been run, item by item, recorded in ROADMAP's P5e
exit-gate cell:
- item 1: unit 2256/2256 under both strict arms.
- item 2/3: integration-split 179/179; the per-entry census has zero Fatal{ and exactly eight
Admitted{ rows.
- item 4(a): RUN_AHEAD=0 as the PAIRED control, 179/179 green - same server code, same
records, the only difference is whether the client waits.
- item 4(b): VERB_BARRIER=0 goes red (62/179 in the lane).
- item 4(c): Magma logs, verbatim, "run-ahead requested, server does not publish
kCapRunAheadApply - running lockstep", and the Espryt control logs "run-ahead ARMED". Both
taken from each entry's own private log.
- item 5: red-once rerun on the integrated tree, in the form ID-121 reordered it into -
reverting pa's arm yields GetProgramForDraw@DrawArrays, reverting mv's yields
GetBoundVertexArray@DrawArrays, EXACTLY ONE NAMED PAIR EACH, and each restores green.
- item 7: MOBILEGL_IPC_AUDIT=1, 179/179.
- item 6's G1 remains CI's to assert (ID-123).
The only item that does not pass is E1's control script itself (ID-122), and the diagnosis is
sharper than before: with its two masking defects fixed it is still red, and the
Fatal{BarrierViolation} it demands appears ZERO times in the whole run - at BATCH_WAITS's
default of 1 that overlap does not happen, and with run-ahead armed VERB_BARRIER=0 also
disarms run-ahead (ID-114), so the arm it produces is neither lockstep nor run-ahead. E1 needs
its falsification target redefined, not its threshold adjusted.
So the status is "closed, with one named outstanding item" rather than an unqualified close:
hiding a control that never passed behind the word "closed" is precisely the class of mistake
this phase kept recording.
BRIEF-P5E section 3 item 4 calls for three negative controls. Running them showed E1
(MOBILEGL_IPC_VERB_BARRIER=0) had THREE problems stacked, with the first two hiding the third.
This fixes the first two and narrows the third into something executable.
1. It selected a case that can never run in this lane.
E1's selection regex is (Triangle|ClearThenReadPixels), and the positive control gl added
under ID-115, TriangleScenario.TheServerStampedAVerbBoundaryOnThisDrawingFrame, matches it.
That case reads a PipeStats window out of the lane's OWN private log, so it runs only in the
strict-arming lane and skips everywhere else BY DESIGN, whatever this knob is set to. ID-62
makes a SKIPPED SELECTED entry a hard failure - correctly, because a control that silently
loses its subjects proves nothing. So E1 reported "the knob killed the pre-flight, not the
entry" while the knob was in fact working: four other selected entries went red with the
abort in the same run.
The fix excludes a case that can never run here, rather than teaching E1 to tolerate skips,
which would have thrown ID-62 away. ctest -R is POSIX ERE with no negative lookahead, so
run_control gains a fifth positional: a real ctest -E.
2. ctest and the log helper disagreed about who was selected.
run_control passed -E to ctest, but split_log_paths.py still filtered on -R alone, so it
counted the excluded entry as selected and then reported it as "did not run". The helper now
reads the same exclusion (SPLIT_LOG_EXCLUDE). An env var rather than another positional
because the helper's positional layout differs per mode - and a control whose two halves
disagree about its own subject set is exactly the class of bug this script exists to
prevent.
3. The real defect. ID-122 was right, and there is now a sharper statement of it.
With the first two fixed E1 is still red, and red on "10 selected entries did not fail": 4
of 14 went red, AND Fatal{BarrierViolation} appears ZERO times in the entire run - those 4
are red for other reasons. The evidence E1 demands has never appeared. The Fatal at
ClientSession.cpp:878 asserts an overlap OBSERVED BY THE APPLY THREAD, and at
MOBILEGL_IPC_BATCH_WAITS's default of 1 the barrier is taken at the next residual-reading
verb, so that overlap does not happen.
Further: with run-ahead armed, VERB_BARRIER=0 ALSO disarms run-ahead (it is the first
conjunct of RunAheadArmed(), ID-114), so the arm this knob produces is neither lockstep nor
run-ahead - it cannot serve as the negative control for either side.
E1 needs its falsification target REDEFINED, not its threshold adjusted. Left as a named
outstanding item, recorded in ID-122 and the open items.
E1's counting rule ("every selected entry must go red") is deliberately NOT relaxed here: it
was set to block the "any non-zero exit counts" false green, and loosening it before knowing
what E1 should falsify would only trade one false green for another.
The previous commit changed the status cell to "twelve packages landed, run-ahead armed" and
left the scope and exit-gate cells saying eight packages, 19 rulings, "the integration commit
flips kMGPipeP5eRunAheadReady", and an exit gate drafted BEFORE the phase started - several of
whose items later proved unexecutable as written. All three cells are rewritten:
- Scope cell: twelve packages in three waves (including fix1 / pa / mv / gl / ra2), rulings
ID-80..136, both constants flipped. It also names the largest migration (the per-draw
GetProgramForDraw) as having changed NO wire structure at all - the record already carried
the per-link ProgramArchive, and what was missing was a reader that still asked the frontend
object. Links to P5E-RUNAHEAD.md.
- Exit-gate cell: rewritten into what was actually measured (unit 2256, integration-split
179/179, integration-gpu 1357/1357, three flavours with each arm asserting its EFFECTIVE
compile define, strict hard green with zero Fatal, the device at VD32 level with monolith and
+69% p50 against lockstep), plus what has not been executed (the three negative controls, the
per-package red-once reruns, E1's control failing its own self-check, G1 still asserted by
CI), plus the criteria that are now VOID (rsp = 0 is empty by construction, rewritten by
ID-119; VERB_BARRIER=0 may not serve as the paired control, ID-114, because it also disarms
run-ahead).
- Debt table, rsp row: P5e rewrote what that number means. It now reads as the hard-green lane
plus eight Admitted rows, and says why the original criterion was empty - CountBarrierPull's
unbarriered arm sits before ++g_residualPulls and is [[noreturn]].
- Debt table, unmigrated row: client vertex arrays are a named refusal under run-ahead
(ID-82) and have their own expected-red lane, integration-clientarrays-split (ID-134);
staging stays P8's.
- Open question 18 added: the two sides of split are unbalanced (at VD32, apply 11-12 ms
against client 15.7 ms, with the client ~94% saturated). Moving work from client to apply
lowers the bottleneck directly - meaningless under lockstep, where the two add serially, and
a lever only because of run-ahead. It needs a fresh profile first: with the wait gone, every
tail item's denominator has changed.
- The header status line gains the P5E-RUNAHEAD.md link.
Doc citation check: seven files under docs/Disaggregated, 58 citations, 0 problems.
- New P5E-RUNAHEAD.md: P5e's phase report, a sibling of P5D-INPROC-PERFORMANCE.md. It carries
the twelve-package list, the three defect classes and the lesson each one cost (a runtime arm
no gate covered / build flavours no gate covered, where the FIRST gate added for them was
itself a false green / a guard that could never fire), the profile and both device tiers, the
unfinished items, and the next opportunity run-ahead unlocked.
- CURRENT_STAGE_PROGRESS.md: the stage table's P5e row becomes twelve packages + run-ahead
armed + ID-80..136; new section 2.7, the P5e landed-content table, in the same shape as
P5c's and P5b's; section 2.5 retitled as historical; two sections both numbered 4 fixed; five
P5e entries added to the open items; next-step item 1 rewritten from c0e to the evidence the
close still lacks; the ruling index extended from ID-79 to the load-bearing entries of
ID-136.
- ROADMAP.md: the P11 row gains P5e's correction - retiring the draw lockstep did NOT need
gPipeInputs versioning, because that race was in the metadata scalars. The row still holds
for the general case, and contract section 3.2's per-role stamp storage is still unlanded.
- README.md: the status line gains P5e and run-ahead, and "next is P6" becomes "P6 comes after
P5e".
Doc citation check: 3 problems, all pre-existing entries in CONTRACT-P5.md, none related to
this change.
Section 11's matrix ran at VD12, where BOTH monolith and run-ahead hit the 120 Hz panel
ceiling - so that section answers "can it reach the ceiling", not "which is faster". At render
distance 32 the load rises to ~3550 draws/frame (4.2x) and neither arm is near any cap (the
game's own enableVsync:false / maxFps:260, and the measured max is only 37-71). That is the
comparable round.
- Five runs per arm, interleaved, CPU clocks pinned (checked before AND after, both PINNED),
GPU held at pwrlevel 0, fan on; 75-150 s in-world settle before a 40 s window - VD32's chunk
streaming runs far longer than VD12's and reusing the 20 s settle would have counted loading
as steady state.
- monolith p50 median 58.5 (range 56.3-67.9), inproc run-ahead p50 median 59.9 (range
56.8-61.1), client CPU 16.19 vs 15.72 ms/frame. Level, run-ahead marginally ahead, and with
half monolith's spread.
- Against the lockstep it replaces: p50 +69%, client CPU -40%, apply CPU -43%. The heavier the
load the more it is worth, which is what the mechanism predicts: rendezvous count follows
draw count, ~900 per frame at VD12 and ~3600 at VD32.
- Both arms are client-thread saturated (~94%), so the client ms/frame column is each arm's
real bottleneck and the two are directly comparable.
New section 11.1 records an OPPORTUNITY rather than a defect: apply spends 11-12 ms against
the client's 15.7 ms, so the two sides are unbalanced and moving work from client to apply
lowers the bottleneck directly. Under lockstep that was meaningless (the two sides add
serially); run-ahead is what turns it into a lever. Left to a later phase.
options.txt restored from .p5e-backup to renderDistance:12; the device is unpinned, fan off,
GPU back to governed.
- CURRENT_STAGE_PROGRESS.md gains section 2.6: the four packages (pa / mv / gl / ra2), the
admission rule's three disjuncts and which real entry forced each one, coverage going from
116 to 179 entries plus 1357, the race the flip exposed and its root cause, and the finding
that a hard-green strict lane is necessary and NOT sufficient (ID-132). Code head is now
25fba0d5.
- MEASUREMENTS.md gains section 11: the device matrix. The section leads with clock pinning
because the first matrix was run without it and had to be thrown away - monolith's OWN arm
moved 4.43 -> 7.07 ms/frame between two runs, and the SLOWER run was the COOLER one: the
governor had dropped policy0 from 2745 to 748 MHz. It records pin_redmi.sh's write order and
read-back verification, and why pin_device.sh refusing to guess this machine's serial is
correct rather than a gap. Conclusion: at both pinned tiers monolith and run-ahead each hit
the 120 Hz panel ceiling and only lockstep cannot reach it.
- ROADMAP.md's P5e row and status line now say twelve packages landed, run-ahead armed.
The phase is NOT closed: which arm is faster ABOVE the panel ceiling is unanswered (this
device cannot hold a higher clock), and BRIEF-P5E section 3's three negative controls and
section 5's per-package red-once reruns have been reordered by ID-121 to after the lane goes
green, so they have not been executed yet.
- TOUCHING mv's FILE UNDER ID-136, deliberately and by ruling: MG_Backend/DirectGLES/
MultiDraw.cpp is mv's per ID-113, and the integrator overrode that boundary for this one
change because the two halves must not exist apart on any head. The boundary has not drifted.
- BoundDrawIndirectBufferId (MultiDraw.cpp) gets the handle arm its neighbour
ResolveBoundIndexBuffer already had, in mv's own idiom rather than a second one beside it:
the arm is `MG_Config::Transport != Monolith`, STATED (ID-81) and never inferred from a null
handle (ID-110); a null handle is "this verb bound no indirect buffer", the same 0 the
monolith arm returns for an empty slot; a missing backend resource is the named refusal
Fatal{RoleViolation, "multidraw-indirect-buffer-arm"}, a sibling of mv's
RefuseMissingIndexBufferRecord, never a quiet fall-back to the frontend. The monolith body
below it is unchanged token for token. The function moved down beside BoundIndexBufferId,
which is the same question about the other target.
- WHY A HANDLE ARM AND NOT A MIGRATION: the tier never reads a byte of the client's indirect
buffer. It binds its OWN scratch command buffer and wants to put back the name that was
there - a save/restore. With a transport the only other writer of this process's
GL_DRAW_INDIRECT_BUFFER is DirectGLES.cpp's DrawSyncBit::IndirectBuffer arm, which binds from
MGPipeApplier().VerbIndirectBuffer and nothing else, so "what was bound" IS "what the verb's
record named".
- Escalation (iii) withdrawn in the same commit - MGPipeBarriered's clause, the client's
verb-keyed half in ClientVerbIsBarriered, and the contract row. It was never wrong, it was
the wrong instrument: there is one draw opcode and the tier is chosen server-side per batch,
so the narrowest key both roles could compute charged EVERY plain glMultiDraw* on the
auto -> ext default - a per-batch rendezvous on the arm the phone ships, added to make a lane
green. THE CHECK ON AN ESCALATION IS "WHICH ARM PAYS FOR IT", NOT "WHICH LANE GOES GREEN";
the rule is recorded in PipeApply.h beside the three that remain and in CONTRACT-P5E §2.1.
- Red-once D: `if (false && ...)` on the new arm and the entry dies 3/3, deterministically, on
`Fatal{UnmigratedPipeInput, "GetBufferBindingSlot@DrawArrays"} [BARRIER-PULLED, UNBARRIERED,
the client did not fill it, retires in P8 (indirect), P9 (readback), P13 (transfer)]`.
Its counterpart is this head itself: the escalation is GONE and the lane is green, so nothing
needed it - and the strict census's admitted set drops from nine rows back to ID-128's eight,
because the pull no longer happens at all rather than being legalised.
- Gate on the flipped head: unit 2256/2256, isplit 179/179, gens rc=0, lane counts unchanged
(179 / 1357 / 2 / 2).
- ID-133: escalation (iii) in MGPipeBarriered - a draw_vbo with NumDraws > 1 that is NOT
kDrawIsIndirect is barriered. Espryt's indirect multi-draw tiers reach
MultiDrawImpl::RunIndirect, whose apply reads the client's GL_DRAW_INDIRECT_BUFFER binding
(BoundDrawIndirectBufferId, MultiDraw.cpp:87) - GetBufferBindingSlot, retiring in P8 - and
unbarriered that is Fatal{UnmigratedPipeInput, "GetBufferBindingSlot@DrawArrays"} on 18 lane
entries. Backtrace of the aborting thread, verbatim frames: CountBarrierPull <-
MultiDrawImpl::RunIndirect <- MultiDrawImpl::DrawElementsBatch <- ServerVerbSink::OnDrawVbo
<- PipeWireDecoder::DecodeAndApply <- PipeApplier::ApplyOne.
- TWO PREMISE CORRECTIONS, both measured, both in the report and in the contract. (a) There is
no indirect multi-draw OP: all twenty draw entry points collapse onto draw_vbo, and the tier
is resolved on the server per batch (ResolveTierForBatch), so the escalation cannot be keyed
on the tier and its cost is NOT confined to the opt-in tier lanes - a plain glMultiDraw* now
waits on the auto -> ext default too. NumDraws > 1 is the narrowest wire fact that contains
the reaching set; kDrawIsIndirect is excluded so Sodium's indirect draw, which resolves by
handle and pulls nothing, keeps running ahead. (b) The read is a SAVE/RESTORE of a GL binding
name around the tier's own scratch buffer, not a data dependency - so it can be retired
outright by giving BoundDrawIndirectBufferId the handle arm its neighbour
ResolveBoundIndexBuffer already has, and this escalation withdrawn. That is three lines in
MultiDraw.cpp, which ID-113 gives to mv, so it is reported rather than edited here.
- The client's half is verb-keyed because the fill decision is asked before the record exists:
{MultiDrawArrays, MultiDrawElements, MultiDrawElementsBaseVertex} is exactly the set whose
record can carry NumDraws > 1 without kDrawIsIndirect. The inclusion goes that way round on
purpose - a record the server calls barriered whose fields the client did not fill would be
an ADMITTED pull of a stale value, which is worse than the abort.
- ID-134: the two ClientVertexArrayScenario entries get the label integration-clientarrays-split
and an expected-red CI step that asserts the MARKER, shaped like gl's Magma lane. ID-131 heeded
by COUNTING, not by assuming: -L integration-split 181 -> 179, -L integration-gpu 1359 -> 1357,
the new label selects exactly 2. The step branches on whether run-ahead is actually ARMED,
because kMGPipeP5eRunAheadReady is a BUILD constant: on a head where it is false the refusal is
unreachable and the entries are legitimately green, so an unconditional demand for red would
fail for the one reason that is not a defect. The client says which world it is in in its own
log line; delete the green branch when the flip lands for good.
- CONTRACT-P5E §3.5 amended to the code: "outside a barriered fill" rested on the caller's claim
about the FUTURE, and a guard whose exemption cannot be false on the class it exists for is not
a guard - it never fired on any of the 70 red entries. The landed rule is "not while the applier
is inside, barriered fill or not", with the fill sites taking §2.5's forced wait first, twice
per filling verb because the block is written either side of the emitters.
- AND §3.2 IS MARKED UNLANDED, which nobody had noticed: "the server's stamp is server-private
… never into the shared block" describes storage the applier owns, and
MGPipeServerStampVerbBoundary still writes m_filled / m_currentVerb / m_serverStampedVerb into
gPipeInputs itself. That gap is what made the race possible. Splitting the stamp is the P11
item - but per-role stamps WITHOUT versioning the BARRIER_PULLED values would be worse than
today, because it turns a loud Fatal into a silent stale read.
- §2.1 carries escalation (iii) as a row, per its own rule that a new escalation is a ruling and
a row in the contract rather than a condition at a call site.
- Gate on the flipped head: unit 2256/2256, isplit 179/179 HARD GREEN, gens rc=0, doc citations
rc=0 (the three remaining are CONTRACT-P5.md's and pre-date this branch).
- ID-132's finding, diagnosed: with the flip thrown the 70 red lane entries are ONE race
and two debts. The race is the client's residual fill writing gPipeInputs while the apply
thread is inside an earlier UNBARRIERED record. Measured interleaving, verbatim:
`[MobileGLIntegra/ERROR] RA2-PROBE fill verb=DrawElements wireop=81 waitclass=-1
applierInside=1` then `[mgl-srv-apply/ERROR] RA2-PROBE unfresh field=IsCapabilityEnabled
verb=DrawElements serverStamped=0 ownership=RECORD-SUPPLIED` then
`[mgl-srv-apply/FATAL] MGPipe: Fatal{UnmigratedPipeInput, "IsCapabilityEnabled@DrawElements"}`.
The GL thread's MGPipeServerClearVerbBoundary withdrew the applier's own stamp mid-record.
- The marker's verb is the CLIENT's verb, which is what identifies the writer without a
probe at all: the applier can only ever put a verb of MGP_VERB_OP_LIST into the block, and
40 of the 54 unbracketed aborts named a verb that is not in it.
- A/B control on the same binary and the same records: MOBILEGL_IPC_RUN_AHEAD=0 is 8/8 green
where the default arm is 2/6 red, so the defect is in the wait rule and not in a record.
- RefusePipeInputsTouchWhileApplierOwnsIt could not fire on any of it: its run-ahead arm read
`if (isBarrieredFill) return;`, and that sentence is a claim about the FUTURE ("this thread
is about to park behind the record"). The order at the validate point is fill, then emit,
then park. So the arm now tests the fact - `isBarrieredFill && !ApplyThreadIsInsideApplier()`
- and the fill sites MAKE it true first with §2.5's forced wait (QuiesceApplierBeforeFill).
- TWICE PER VERB, because the block is written in two phases that straddle publication: the
serial bump / stamp withdrawal / verb rename run before the fourteen emitters and the
63-field walk of step 4 runs after them, and the records those emitters published are
records whose apply reads the block.
- ClientVerbIsBarriered: MGP_VERB_OP_LIST joins the whole draw family through one row
(DrawVbo -> DrawArrays), so inverting it verb-first answered kOpCount for DrawElements and
eighteen other draw verbs, and the "unknown verb answers barriered" default filled for each
while the record it published was a kWaitNone draw_vbo it never parked behind. The family's
row is applied to the family; every other unknown verb keeps the conservative answer, which
the wait above now makes honest. Measured alone it is worth 112 -> 133 isplit; with the wait
in place the lane is 161 either way, so this half is the PERFORMANCE half - without it every
indexed draw takes a forced drain and run-ahead is lockstep by another name on the draw path.
- Red-once A: put the guard's arm back to `if (isBarrieredFill) return;` and
RemoteGuards.ClientBarrieredFillWhileTheApplierIsInsideUnderRunAheadIsFatalByName exits 0
instead of aborting (`Value of: DiedOfAbort(child) Actual: false`), while its green control
keeps passing. Red-once B: delete the kDraw fallback and
RemoteRunAhead.AnIndexedDrawVerbIsUnbarrieredAndTouchesPipeInputsNotAtAll dies of signal 6
with `Fatal{RoleViolation, "gPipeInputs"} - the GL thread touched gPipeInputs
(MGPipeValidateForVerb) on a RUN-AHEAD session in a barriered fill that did not first wait
for the applier to catch up`.
- Both new cases run in the ordinary unit lane with run-ahead armed by the fixture, which is
what ID-132(1) asks for: the strict lane runs under lockstep and cannot see this class.
- Gate on the flipped head: unit 2256/2256, isplit 161/181 (was 112), gpu 1339/1359 (was 1290),
gens rc=0, flavours rc=0. The remaining 20 are two named debts, not this race: 18 are
`GetBufferBindingSlot@DrawArrays [UNBARRIERED]` on the two indirect multi-draw tiers, whose
retiring phase is P8, and 2 are ID-82's own refusal of client vertex arrays.
- The arm is stated and not inferred (ID-81): WaitForApplyToCatchUp's first test is
`m_runAheadArmed && m_started`, which reduces to Transport != Monolith. Under lockstep,
under monolith and under MOBILEGL_IPC_RUN_AHEAD=0 every line added here is a call that
returns, so those arms are unchanged.
kMGPipeP5eRunAheadReady (MG_Backend/Init.cpp) and kMGPipeP5eClientWaitRuleLanded
(MG_Pipe/PipeApply.h) both to true. They are ONE switch with two spellings and the integration
commit throws both.
THIS BRANCH IS NOT MERGEABLE AS IT STANDS and is parked so the defect it exposes can be worked on
against a reproducible head. Measured on this commit:
build rc=0 · unit 2254/2254 · gens rc=0 · flavours rc=0 (pull 915 TUs, push 929 TUs)
integration-split 112 / 181 (69 failed)
integration-gpu 1290 / 1359 (69 failed)
and the failures are FLAKY: 71 failed at -j 4, 68 at -j 1, and a single entry run alone passes and
then aborts on a repeat. A census over all 181 entries under each entry's own command and
ENVIRONMENT gives 70 red on 17 distinct <field>@<verb> pairs, over just FOUR fields:
27 GetRenderStateParametersVersion
25 GetTextureContextId
16 GetBufferBindingSlot
2 IsCapabilityEnabled
spread across eleven verbs - Clear, DrawArrays, DrawElements, DrawElementsBaseVertex,
DrawElementsIndirect, DrawElementsInstancedBaseVertex, EndTransformFeedback, GetIntegeri_v,
GetSyncStatus, MultiDrawElementsBaseVertex, ReadPixels. Several of those verbs are BARRIERED, which
is what makes this interesting: 16 of the 70 abort on the UNBARRIERED arm
(why=UNBARRIERED) and the other 54 are the plain unfresh-read abort with no [BARRIER-PULLED]
bracket at all, i.e. a RECORD-SUPPLIED field that the freshness stamp says was never filled.
One field at one verb, consistently, would be a missing migration. Four fields scattered over
eleven verbs, flaky run to run, including barriered ones, is a RACE - and the obvious candidate is
that gPipeInputs is shared mutable state whose mutual exclusion WAS the lockstep. The client still
fills it for barriered verbs while the apply thread is draining earlier unbarriered records, so the
client's fill and the server's verb-boundary stamp can now overlap in time. That is consistent with
P5d's own research conclusion, recorded in the roadmap, that retiring this barrier needs
gPipeInputs VERSIONING or double-buffering, deferred to P11 after P3b/P4b's handle-keyed twin
tables.
The strict lane being hard green (181/181, zero Fatal) is necessary and NOT sufficient, and this
commit is the proof: strict runs under lockstep, where ApplyOne stamps every record barriered, so it
cannot see anything that only happens when the client stops waiting. The lane measures remaining
DEBT; it does not measure whether run-ahead is correct.
The file carried gl's base measurement (7c6f6886, before pa and mv landed) and said so rather than
hiding it. The merge discharged that note exactly as written, and the two-sided ratchet named both
deltas by itself rather than being told:
- GetProgramForDraw@DrawArrays no longer appears - pa retired it, 62 entries to 0, and it was the
read that fired once per draw and kept every draw barriered. Removed here, in the wave that
retired it, which is the whole point of the other side of the ratchet: an expected set that keeps
rows nothing writes any more is how a lane rots green.
- GetBufferBindingSlot@DrawArrays is new, 18 entries, all of them the two indirect multi-draw
tiers. mv NAMED it as a latent row before it could fire - BoundDrawIndirectBufferId's
GetBufferBindingSlot(DrawIndirect), which "would surface as GetBufferBindingSlot@DrawArrays if an
indirect tier were ever forced under strict" - and then gated the five tiers, which forced it.
It retires in P8 (FieldOwnership.def:78), so it is a debt owed by a later phase and not by this
one. Added with that reason.
Measured on the integration tree, two independent instruments agreeing (Harness/split_log_paths.py
markers, and a separate per-entry census that drives each entry's own command and ENVIRONMENT from
ctest --show-only=json-v1):
integration-split 181 / 181 green
Fatal{UnmigratedPipeInput 0 distinct pairs
Admitted{ 10 distinct pairs - 8 by the generated table, 2 by escalation
So "the strict lane is hard green" is now a statement a script evaluates, not one a reader argues:
every entry completes, no Fatal marker over the log set ctest itself declares, every Admitted marker
checked against the same table the C++ answers from, and the ratchet fails in BOTH directions.
Gate on this head: build rc=0, unit 2254/2254, integration-split 181/181, integration-gpu 1359/1359,
gens all rc=0, flavours rc=0 (pull 915 TUs push-absent, push 929 TUs push=1).
- CONTRACT-P5E section 8 item 8 says GetBufferBindingPointCount's sticky forward becomes FATAL
under split. It is not in the code and must not be put there this phase: FieldOwnership.def:147
(field row) and :179 (forward row) both say BARRIER_PULLED, and the generator refuses a
field/forward pair that disagrees - pinned by
FieldOwnershipTest.TheSevenStickyForwardsAgreeWithTheirFieldRows.
- Landing it as written would either trip the generator or drag the FIELD row to FATAL with it,
and that second reading moves 23 pairs of the derived admitted set: the row's retiring phase is
"P7/P13", which is exactly what admits it on every stamped verb under ID-125, and a FATAL row is
admitted nowhere - so 23 reads this phase never promised to migrate would start aborting.
- MEASURED, NOT INHERITED: ID-120 said 10 pairs; that count predates ID-125's retiring-phase
disjunct. `--print-admitted | grep -c '^GetBufferBindingPointCount@'` says 23 on this head, and
the note records the command as well as the number.
- Annotated in place, struck through with the phase that lands it (P7/P13), for ID-105's reason:
a contract clause that is arithmetically impossible against the code is a clause that changes,
and the divergence is better stated than discovered from a red gate two phases from now.
- Both DirectVulkan.Split.NamedBlit entries carried LABELS "integration-split" and abort under
strict on GetFramebufferBindingSlot@Clear (VulkanRenderer.cpp:7648). P5e does not unbarrier that
field on Magma - it retires there in P7 - so the Espryt lane could not go hard green with them
in it.
- ARGUED RATHER THAN ASSUMED, because the brief allowed either reading: admitting the row instead
is the WORSE option. FieldOwnership.def carries ONE retiring-phase string for both backends
("P5e (Espryt unbarriered), P7 (Magma)"), so a rule that admitted the pair for Magma would admit
it for Espryt too - and an Espryt Clear reading the frontend's binding slot again is exactly the
regression fb's handle arm exists to prevent. The lane split costs two entries; the admission
would cost the gate.
- THE LABEL IS `integration-magma-split`, NOT `integration-split-magma`, and the spelling is
load-bearing: `ctest -L` takes a REGEX, so `-L integration-split` matches a label spelled
`integration-split-magma` as a substring. Measured with the obvious name first: both DirectVulkan
entries were still listed by `ctest -N -L integration-split`, i.e. the split looked done in every
listing and had not happened.
- Its CI step asserts the run is RED **and** that a private log carries the named marker, so
"Magma is expected red" cannot decay into "Magma did not run". Measured: 0/2 under strict with
two logs carrying Fatal{UnmigratedPipeInput, "GetFramebufferBindingSlot@Clear"}, and 2/2 green
without the knob. integration-split is 117 and integration-magma-split is 2.
- CONTRACT-P5E section 7 states the split and the reason it is not an allowlist entry.
Three measured defects in the strict lane, one commit each in spirit and one in fact because
they are the same step.
1. THE LOG SET CAME FROM A DIRECTORY. The CI step grepped MG_IntegrationTest/split-logs/, which
holds 90 of the lane's entries. The ones it could not see were the F1. readback block, both
NamedBlit pairs, the four Ct. entries and PersistentMapArm - precisely the readback population
the allowlist is ABOUT, plus the only DirectVulkan entries in the lane. split_log_paths.py
gains a `markers` mode that walks ctest's own {entry: MOBILEGL_LOG_FILE_PATH} map and
classifies every Fatal{ and Admitted{ line. Measured on this head: 113 of 113 entries read.
The lane is now a TWO-SIDED RATCHET - an unadmitted marker fails, an admitted pair not in
Harness/strict-expected-markers.txt fails, AND an expected pair that no longer appears fails,
so the lane cannot rot green by outliving its own debts.
2. THE `rsp` CHECK GREPPED FOR A COUNTER NO SPLIT ENTRY EMITS, and the pin behind it was vacuous:
CountBarrierPull's unbarriered arm is [[noreturn]] and runs BEFORE ++g_residualPulls, so
"rsp = 0 on unbarriered records" was true of every possible implementation. Replaced by an
assertion where the counter actually is - one entry, DirectGLES.Split.StrictArming., with
MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 and a private log path of its own (a
whole-lane env flip races under -j: the library opens that path "w"). It asserts
rsp == 0 || rsp >= draws, which is the device evidence's shape - rsp ~= the draw count under
inproc, 0 under monolith - so the draw path's retirement is visible as rsp ceasing to scale.
3. THE LANE HAD NO POSITIVE CONTROL (ID-115). Of the seven entries that passed strict, three were
monolith transport - where nothing stamps a verb boundary, so the whole mechanism is
structurally unreachable - two self-skipped, one was a death test and one was a Python check.
Not one was a record-carrying split GL scenario, so "green" and "strict was never armed" were
the same observation. New push-only counter `vbs` (PipeStats::ServerVerbBoundaries), counted
at MGPipeServerStampVerbBoundary behind Enabled(), and the same entry asserts vbs > 0 on a
frame that drew. It is deliberately NOT rsp: rsp reaches zero when the phase SUCCEEDS, so a
control built on it would start failing on the day the debt is paid.
- Measured on this head (base 7c6f6886, before pa and mv): isplit 119/119; under strict the
census is Fatal{ GetProgramForDraw@DrawArrays 52, GetBoundVertexArray@DrawArrays 9,
GetProgramForDispatch@DispatchCompute 7 - exactly what pa and mv owe - and Admitted{ on ten
pairs, three of them ADMITTED-ESCALATED. The expected-marker file carries all ten and says in
its header that the wave-3 merge retires three of them.
- CONTRACT-P5E §7 rewritten around what is now checkable: the derived allowlist and its three
disjuncts, the two-sided ratchet, the restated rsp pin, and the positive control.
- The integrator's census over the merged tree left two markers nothing admitted, both
DirectGLES.Split.ClientVertexArrayScenario: GetBoundVertexArray@DrawArrays. Disjunct 1 fails
(draw_vbo is kWaitNone) and disjunct 2 fails (that row's retiring phase names P5e, correctly,
for the ORDINARY draw path vi and mv retired). But these two entries pull the pair on a record
the client IS parked behind: MGPipeBarriered escalates a draw carrying client vertex arrays
(ID-83), ID-82 puts client arrays outside P5e entirely, and the read site
(SyncClientSideVertexArraysForDrawArrays) aborts by its own name if it is ever applied
unbarriered. The pull is legal; no static table could see it.
- Third disjunct: the record was barriered BY ESCALATION, i.e. wireSaysBarriered(op, payload,
applierState) && MGPipeWaitClassFor(op) == kWaitNone. Both halves were already computed on
every record - ApplyOne computes the predicate unconditionally for ID-103's reason - so this is
one more bit stamped beside the barriered stamp, not new plumbing.
- It is a SEPARATE flag rather than a refinement of the barriered stamp, because the two answer
different questions: the stamp answers "is the client parked" (the allocator and registry
guards ask that), this answers "and for a reason the static table cannot see" (only the strict
knob asks). Its default is false - the answer that admits nothing - which is the opposite of
the stamp's default and deliberately so.
- The marker says WHICH disjunct: ADMITTED when the generated table answered, ADMITTED-ESCALATED
when only the record did. That keeps the lane's comparison against --print-admitted exact
instead of widening the static list with every pair that could ever escalate - and the tag sits
in the slot the grammar already has for the reason, so Fatal{UnmigratedPipeInput filters are
untouched.
- Escalation also happens to be the truer justification for GetTransformFeedbackProgram@DrawArrays,
which ID-125 admits by phase. Both now hold; ID-125's disjunct is kept, as ruled.
- Red-once: AnEscalatedRecordsPullIsAdmittedAndSaysWhy. Dropping `&& !escalated` from the strict
arm kills it - "FieldOwnershipTest.cpp:938: Failure / Actual: false / signal 6" with
'Fatal{UnmigratedPipeInput, "GetBoundVertexArray@DrawArrays"} [BARRIER-PULLED,
MOBILEGL_IPC_STRICT_ERRORS=1, retires in P5e (Espryt unbarriered), P7 (Magma)]'. Its control is
StrictErrorsTurnsABarrierPulledReadIntoANamedAbort, which drives the identical pull with the
flag at its default and must keep aborting.
- ID-116's static rule failed on a row no package in this phase may touch. pa retired the
per-draw program pull, which unmasked GetTransformFeedbackProgram@DrawArrays (11 entries):
BARRIER_PULLED, on kWaitNone draw_vbo, and ruled out of P5e entirely by CONTRACT-P5E section
5.7, with a retiring phase of "P3b/P4b (Espryt), P7 (Magma)". The rule was wrong, not the code.
- A pull is now admitted if the field is BARRIER_PULLED, the verb has a stamp row, the field is
in the verb class's may-read mask, and EITHER the verb's op is statically barriered OR the
field's retiring phase does not name P5e. This table is only ever consulted on a record the
server already stamped BARRIERED - CountBarrierPull's unbarriered arm is [[noreturn]] and
fires first - so the pull is legal by construction and the only open question is whose debt it
is. The retiring-phase column is exactly that answer and was already in the table.
- A verb with NO stamp row still admits nothing under either disjunct: the server stamps only
MGP_VERB_OP_LIST's ops, so any other bit would be a row nothing can exercise. 111 -> 174 pairs.
- The phase column is matched as a TOKEN, not a substring, so a "P5" or a future "P5e2" cannot
answer for "P5e".
- All six of ID-125's survivors checked: GetFramebufferBindingSlot@ReadPixels and
GetTextureUnitObject@CopyTexImage2D by disjunct 1; GetTransformFeedbackProgram@DrawArrays
(P3b/P4b), GetTextureObject@CopyImageSubData (P7) and ValidateProgramName@ShaderStorageBlockBinding
(P9) by disjunct 2. GetTextureObject@CopyImageSubData holds by BOTH, asserted separately.
GetProgramForDraw@DrawArrays, GetBoundVertexArray@DrawArrays and
GetProgramForDispatch@DispatchCompute stay rejected.
- Magma's GetFramebufferBindingSlot@Clear is NOT admitted and cannot be: the retiring-phase
column is one string for both backends and it names P5e for Espryt. The DirectVulkan entries
get their own lane label instead (item 7), which keeps the Espryt allowlist about Espryt.
- StrictErrorsAlsoPromotesTheStickyForwards is RENAMED to
StrictErrorsReachTheStickyForwardsAndNameThemByField and restated. Not one of the seven sticky
forwards names P5e (P7, P7/P9, P7/P13, P9), so on a stamped verb they are all admitted now.
The claim that still has teeth is that the detector REACHES them - asserted by rsp moving and
by the marker naming the field and its phase, so "did not abort" cannot pass for "never
detected". The Fatal half keeps its own case next door on GetBoundVertexArray, a P5e row.
- Six new derivation controls, including one that makes GetTransformFeedbackProgram this phase's
debt and requires it to leave the unbarriered verb's allowlist - without which disjunct 2
could be a constant `true` and every other control would still hold.
Three sites, all pre-existing at 7c6f6886, all the same disease: something guarded by
MOBILEGL_BUILD_DISAGGREGATED used from code that is not. Nothing in the gate built either
flavour, so nobody knew.
- Managers.cpp: StagedTextureTargetForPipeTarget is a pure MGPipeResourceTarget -> TextureTarget
switch with no wire, no record and no role in it, but it sat inside a nested
#if MOBILEGL_BUILD_DISAGGREGATED while its only caller MGB_TEXPARAM_TARGET is guarded by
#if MOBILEGL_PIPE_PUSH alone. Let out of the inner block without moving any text. The narrower
fix - guarding the macro block too - is WRONG and was tried: that block also carries
TextureDiagName and MGB_TEXTURE_RECORD_ARM_SELECTED, which the push flavour does need, and
removing them broke it differently.
- DirectGLES.cpp:5575: the storage-block-signature clause called
ComputeShaderStorageBlockBindingSignatureOf, declared only under MOBILEGL_PIPE_PUSH, from an
UNGUARDED condition list. The clause is push-only in substance too - the signature it compares
is over the override map the wire carries.
- DirectGLES.cpp BindCurrentTextures: keysMatch was declared inside
#if MOBILEGL_BUILD_DISAGGREGATED while the ternary's else-arm and every use sat outside it.
The legacy arm becomes a LAZY lambda, not an eager Bool: it reads currentProgram, which is
null on the handle arm after pa, so the laziness the ternary gave for free is load-bearing
(ID-81 / ID-110 - the arm decides, and the other arm's reads must not happen at all).
Gate: flavours rc=0 - pull 915 TUs (push define absent), push 929 TUs (push=1), both arms
asserted against compile_commands.json rather than against the flags, because the first version
of this step passed -DMOBILEGL_PIPE_PUSH=OFF beside an option that implies it and reported a
clean 982-TU "pull" build that was a second copy of the split build. The cache entry still read
OFF. Split build unaffected.
- CopyImageSubData's apply reads GetTextureObject, a BARRIER_PULLED row that retires in P7, not
in P5e. resource_copy_region was kWaitNone, so after the flip that is a genuinely UNBARRIERED
record reading client memory: an unconditional Fatal, no knob, standing for two phases.
- The wait class moves rather than the field being special-cased into the allowlist. The
allowlist is derived (ID-116), so an exception written into it would be a real defect kept
quiet instead of a debt stated - and with the class moved the derivation admits the pair with
no exception at all: 101 -> 111 pairs, CopyImageSubData joins the 12 barriered verbs.
- kWaitApplied AND NOT kWaitReply: the row posts no reply slot, so the client parks until the
record applies. That is exactly what copy_framebuffer_to_texture - the two framebuffer-sourced
copies, same kBlitOrCopy verb class - already does. THE COST IS MEASURED: glCopyImageSubData
is zero per frame in the Minecraft 26.3-rc-3 scene, so the wait is on no per-frame path.
- PipeCatalogue's wait-class census moves with it, both halves in this commit: the named
kWaitApplied set gains the row, the count goes 9 -> 10, and the kWaitNone offset 24 -> 25.
- Red-once: putting the row back to kWaitNone drops CopyImageSubData out of the derived
allowlist (111 -> 101 pairs, 0 @CopyImageSubData) and fails
"FieldOwnershipTest.cpp:422: Failure / resource_copy_region is unbarriered again: after the
flip, a copy record reading the client's texture object is an unconditional Fatal and P7 is
two phases away", with PipeCatalogueTest red on three counts beside it.
- The resolved multi-draw tier is ONE process-wide read of MOBILEGL_ESPRYT_MULTIDRAW_MODE
(MultiDraw.cpp's ResolveTierOnce) and the d1 block leaves it at "auto", which on this
driver lands on "ext" - the one tier that asks the bound index buffer nothing. Three of
the five sites the previous commit migrated therefore had no entry in the set the gate
runs: ID-107's shape one level down, with the env knob in place of MG_Config::Transport.
- Five blocks, one per tier "auto" cannot reach (basevertex, multiindirect, indirect,
drawelements, compute), prefix DirectGLES.Split.MultiDrawTier<Tier>., both labels, and
MOBILEGL_ESPRYT_MULTIDRAW_MODE pinned as a ctest ENVIRONMENT property rather than a
job-level export - review finding N-6's reason, and the PushMonolithArm block's precedent.
- "ext" is deliberately NOT registered: the d1 block already IS the ext lane. Every other
tier was confirmed to resolve to ITSELF here rather than falling back to ext, so no entry
in this set is a green that silently means "ext again".
- The filter is the tier-sensitive workload: MultiDrawScenario (minus the client-indices
case d1 already excludes by name) plus IndexedDrawFamilyScenario's three MultiDraw cases.
12 entries per tier, 60 in total.
- SplitLogPaths.cmake.in gives each of them a private log path. That is the rule every
DirectGLES.Split. entry follows (SplitLogPaths.PrivateAndDistinct), and for these it also
repairs the census blind spot: a third name segment means strict_census2.sh would re-run
them without the very pin under test, so the marker is read where the lane wrote it.
- integration-split 116 -> 176, integration-gpu 1294 -> 1354, both fully green. Under the
strict lane all 60 stop on GetProgramForDraw@DrawArrays, which is pa's row: PrepareForDraw
runs before the tier dispatch, so no new marker class appears and mv's row stays at 0.
- CountBarrierPull aborted under MOBILEGL_IPC_STRICT_ERRORS on EVERY barrier-pulled read,
admitted or not. The run's rc is therefore non-zero before CI ever reaches its allowlist
comparison, so that comparison was unreachable code and "hard green" was impossible with the
knob as written - the lane could not tell a debt this phase leaves standing from a defect.
- Third state, with the other two untouched: unbarriered stays an unconditional Fatal; barriered
and NOT admitted stays a Fatal under strict; barriered and admitted logs one MGLOG_W and the
entry completes. Admission is MGPipeBarrierPullAdmitted, generated from the three tables
(ID-116), so there is no list here to drift.
- The marker keeps the grammar and changes only the tag: Admitted{UnmigratedPipeInput,
"<F>@<V>"} [BARRIER-PULLED, ADMITTED, retires in <phase>]. Every filter written since P5c
matches Fatal{UnmigratedPipeInput and therefore still means exactly "red".
- Deduped per (field, verb) per process. An 852-draw frame reaches an admitted readback row once
per draw; undeduped, one frame writes 852 identical lines and the marker census is unreadable.
- CHECKED, NOT ASSUMED (the brief's instruction): StrictErrorsTurnsABarrierPulledReadIntoANamedAbort
and StrictErrorsAlsoPromotesTheStickyForwards both still go red. Both stamp DrawArrays, whose
op is kWaitNone, so neither pair is admitted.
- ID-119: the old "rsp = 0 on unbarriered records" comment at this function is withdrawn as
vacuous - the unbarriered arm is [[noreturn]] and runs BEFORE ++g_residualPulls, so the
sentence was true of every possible implementation.
- Red-once: AnAdmittedBarrierPullIsLoudOnceAndNotFatal is the same sticky forward as the case
above it under a barriered verb; making the strict arm unconditional again kills it -
"FieldOwnershipTest.cpp:867: Failure / Actual: false / signal 6".
- MOBILEGL_IPC_STRICT_ERRORS=1 had no registered ctest entry anywhere: the phase's
gate driver sets the knob by hand and pg's declared red-once for
GetProgramForDraw never landed, so the two rows pa retires could come back and
every gate would stay green. ID-109's finding, one scale down: the gap is a
missing gated entry, not a missing case.
- DirectGLES.Split.StrictProgramArm. registers the only two scenarios that are
strict-CLEAN after pa - one per half. MultiDrawScenario.BaseVertexDrawsReject-
MalformedArguments prepares a draw and asserts on GL errors rather than pixels;
AtomicCounterScenario's three cases are the dispatch half. Everything else in the
lane still ends in a readback, whose GetFramebufferBindingSlot@ReadPixels is an
allowlisted row only P7 retires.
- integration-split alone, and the prefix says which arm: the monolith arm of these
scenarios is registered under DirectGLES. already and the knob is a no-op there,
so labelling them integration-gpu would add entries that cannot fail for the
reason they exist (R-16).
- Negative control run: with the arm reverted all four abort, two with
GetProgramForDraw@DrawArrays and two with GetProgramForDispatch@DispatchCompute.
- Each entry takes a private log path through SplitLogPaths, for vi's reason: an
entry that exists to carry a marker cannot share the file the marker is in.
- ra landed a hand-kept 13-row copy of CONTRACT-P5E section 7 in .github/workflows/test.yml, and
it was wrong in BOTH directions at once: it omitted GetFramebufferBindingSlot@ReadPixels (21
entries, the lane's largest survivor) and carried GetTextureObject@CopyImageSubData, which
resource_copy_region's kWaitNone makes a genuine unbarriered read of client memory. Neither
error is visible by reading the list. Both are obvious once the list is derived.
- gen_pipe_field_ownership.py now joins the three tables ID-84 already states the rule in -
kMGPipeFieldOwnership, kMGPipeWaitClasses (generated/PipeWire.inc) and kMGPipeClassFieldMask /
kMGPipeVerbClass (generated/PipeFillPoints.inc) - and emits kMGPipeAdmittedPullMask plus
MGPipeBarrierPullAdmitted(field, verb): 101 admitted pairs across 12 barriered verbs.
- The three inputs are READ, never restated, so the allowlist moves with the code: retiring a
verb's wait class narrows it with no edit anywhere, and a new BARRIER_PULLED row widens it only
on verbs that wait. A verb with no stamp row admits nothing - only MGP_VERB_OP_LIST's ops can
ever be the record a pull is reported against.
- --print-admitted gives the CI shell the SAME table the C++ answers from, which is what makes
the hand copy unnecessary rather than merely corrected.
- Three new negative controls (each source renamed away must stop the script - a missing source
yields a silently EMPTY allowlist, which reads as rigour and is blindness) and five derivation
controls that move an input and read the allowlist back.
- Red-once: dropping the wait-class conjunct from build_admitted fails the self-test by name -
"3 admission-derivation control(s) did not hold: unbarriering ReadPixels' op removes every
@ReadPixels pair; unbarriering ResourceCopyRegion removes every @CopyImageSubData pair; no pair
is admitted on an unbarriered verb (DrawArrays)" - and the new unit case fails with
"GetBoundVertexArray@DrawArrays is admitted on a verb no barriered op stamps".
- PrepareForDraw's single GetProgramForDraw() and PrepareForCompute's
GetProgramForDispatch() are the 68 red entries this package owns (61 + 7 in the
base census; 62 + 7 once the PersistentMapArm entry is re-run under its own lane
environment). Merely CALLING the accessor is what trips MGP_INPUT_CHECK, so the
row retires by not making the call.
- DrawProgramFromRecords() states the arm ONCE and as a CONJUNCTION:
ProgramHandleArm() AND TextureImpl::UnitTexturesByHandle(). The hoisted value
serves four callees and the arm has to name every one of them - the second
conjunct is BindCurrentTextures', whose legacy memo key and whose
sampledTargetForUnit lambda both read a real object. The family bits are
independent A/Bs, so the
- EmittedCallSuppliesTheWholeField answers false for eight rows; seven of them are pointer-backed
and this residual fill is Magma's ONLY source for them, dereferenced on its first draw
(Magma is lockstep for the whole phase, ID-90). NoP4aFamilyFieldIsWhollySupplied is keyed on
the EMITTER'S SUBSYSTEM, so it covered five of the seven: GetBoundVertexArray (P3a's,
BindVertexElements) and GetBufferBindingPoint (sb's, SetShaderBuffers) sit outside
kMGPipeP4aFamilySubsystems and had no compile-time protection at all.
- A package that tidied one of those two away would not break the build - it would SIGSEGV Magma
on a device, which is the shape that already cost this phase 38 scenarios (ID-107). The seven
are now named in one list with their own assertion, so the most likely mistake of every
remaining package is a build break instead.
- The comment says where the fired assertion sends you: a row retires in the phase that stops
the backend reading a frontend object (P7/P8), never in a consumer-side cleanup.
- Red-once: deleting the GetBoundVertexArray case fails the build - "PipeFill.cpp:2720:23: error:
static assertion failed due to requirement 'NoPointerBackedRowIsWhollySupplied()': a
pointer-backed residual row stopped being pulled (ID-112)". The pre-existing P4a assertion
stayed silent on the same edit, which is the gap ID-112 names.
- ApplyOne stamped `kMGPipeP5eClientWaitRuleLanded ? wireSaysBarriered : true`, which reads a
build constant and never asks which server this is. kCapRunAheadApply is never published on
DirectVulkan (ID-90), yet draw_vbo / blit / clear / launch_grid are all kWaitNone - so the day
the constant flips, a Magma server would stamp every draw record UNBARRIERED while its client
is still lockstep. CountBarrierPull is an unconditional Fatal on an unbarriered pull, no knob
involved, so Magma dies on its first draw and MagmaP7AllocatorDebtScope's exemption goes with it.
- The stamp is now `MGPipeApplierStampsBarriered(waitRuleLanded, serverPublishesRunAhead, wireSaysBarriered)`,
a pure function beside the applier, so ID-90 is something a unit case can hold AT THE POST-FLIP
value of the constant rather than a claim that first becomes testable on integration day. That
is MGPipeRunAheadCapBitsFor's shape, for MGPipeRunAheadCapBitsFor's reason.
- Both predicates stay computed unconditionally, as arguments rather than short-circuit arms:
ID-103's reason, extended to the capability probe.
- `ServerPublishesRunAhead` is promoted out of the anonymous namespace as
`MGPipeServerPublishesRunAhead` (body unchanged) so the red-once can state "this session lacks
bit 10" in its own words instead of inferring it from the stamp under test (ID-102).
- Red-once: ADrawOnASessionWithoutTheRunAheadBitStaysStampedBarriered drives a real DrawVbo
through ApplyOne on a SetCapabilityBits(0) session. Deleting the new conjunct makes the
post-flip assertion false - "RemoteClientControls.inc:143: Failure / Expected: true / exited 124".
- MultiDraw.cpp asked the bound element array buffer three questions (is one bound,
what GL name is bound and how big is its store, give me its bytes on the CPU) and
asked all three of MGB_CTX->GetBoundVertexArray() on the apply thread. That was the
last unguarded read of that field in the backend: package vi retired the ordinary
draw path's copy and this one survived only because these 9 entries used to die
earlier, on the framebuffer row fb has since retired.
- ResolveBoundIndexBuffer(question, entry) answers all three from
MGPipeApplier().IndexBuffer.Res on the record arm - EnsureBufferResourceForHandle
for the driver name, ResourceWidthForHandle for the size, the server's staged
shadow (hostBytes + RequireStagedCoverage) for the CPU bytes - and keeps the
monolith body token for token below it. No wire row was added.
- The arm is STATED, not inferred (ID-81 / ID-110): BufferImpl::VertexInputReadsRecords(),
the family's own selector, which reduces to Transport != Monolith AND the vertex-input
bit. Nothing here decides an arm from a null pointer or a noted handle.
- A missing record on the handle arm is a named refusal:
Fatal{RoleViolation, multidraw-index-buffer-arm}, the shape of
RefuseNullFrontendTextureOffTheHandleArm. The frontend element slot is not a second
opinion on this side - it answers for whatever the client has bound now.
- VertexInputReadsRecords() moved from DirectGLES.cpp to Managers.h, unchanged, so the
two translation units on this draw path share ONE selector rather than a copy each.
- Strict census: GetBoundVertexArray@DrawArrays 9 -> 0. The same 9 entries now stop on
GetProgramForDraw@DrawArrays, which is pa's row; no other row moved, no new row.
fix1's report asked for a ruling on Managers.cpp's buffer-texture backing seam, which hands
kNoFrontendBuffer to the lookup on the strength of a NOTED HANDLE and was unreachable under
Transport == Monolith only because every writer of such a note happens to be transport-gated.
That is safe by a chain rather than by its own statement, and it is exactly the shape that cost
the monolith arm 137 scenarios across 38 files (ID-107): fix1's root cause was one seam reading
"a handle was noted" as "the record arm is selected", which are different claims.
- MGB_TEXTURE_RECORD_ARM_SELECTED() names the second claim once: Transport != Monolith AND
TextureResourceSubsystemEnabled(), the two conjuncts every null-frontend-servable read already
lives behind (CONTRACT-P5E 5.2 / 5.8). It is (false) in the pull build, so nothing moves there.
- RefuseNullFrontendTextureOffTheHandleArm spells it instead of repeating it inline, which also
removes its nested #if.
- texBufferByRecord gains it as a third conjunct. Under a transport it is already implied by
`pushedStorage != nullptr`, so no behaviour changes and no door closes; the arm now says what
it depends on instead of depending on it silently.
Gate on the integration head: build rc=0, unit 2250/2250, integration-split 116/116, gens all
rc=0 (the CONTRACT-P5.md:524 doc-citation ambiguity is the standing pre-existing one).
- FramebufferImpl::SyncAttachmentSurface selects its STORAGE sync by MG_Config::Transport
(ID-81 / CONTRACT-P5E §5.8) instead of routing both arms through tx2's by-handle seam. Under
a transport it is unchanged (SyncMipmapsToBackendByHandle / RBO SyncToBackendByHandle); under
Transport == Monolith it is the frontend sync it was at f6cfcbd3, driven by a monolith-glue
attachment pointer the object-form caller passes and the handle-form caller leaves null.
- The crash this repairs: the push-monolith build drives its framebuffer attachments from the
record (P4a D-C2, gated on FramebufferSubsystemEnabled() alone), so glClear on an FBO holding
a texture reached SyncMipmapsToBackendByHandle, which passes a NULL SharedPtr and relies on a
record arm that `Transport != Monolith` selects. stateTextureObject->IsTextureView() then
dereferenced null - the device SIGSEGV in Lightmap.<init> -> clearColorTexture -> glClear.
- The three texture sync bodies stop treating "a handle was noted" as "the record arm will be
selected": RefuseNullFrontendTextureOffTheHandleArm is one shared check and a null frontend
object on an arm that cannot serve it is Fatal{RoleViolation, "texture-handle-arm"} instead of
a null dereference. It folds to the same constant in the pull build, so no call is added there.
- The renderbuffer arm is arm-selected for the same reason: its by-handle form dereferences
nothing but drops the application-visible GL_OUT_OF_MEMORY RecordError, which the
push-monolith build still has an application to report to.
- New lane entries (MonolithAttachmentClearScenario, three cases: a mutable texture attachment,
an immutable one and a renderbuffer) and a DirectGLES.PushMonolithArm. registration that pins
MOBILEGL_TRANSPORT=monolith and carries the integration-split label, so the gate covers BOTH
runtime arms of the push build instead of only the inproc one (ID-107's coverage hole).
- Gate: build rc=0, unit 2250/2250, integration-split 116/116 (113 + 3), integration-gpu
1294/1294 (137 DirectGLES SEGFAULTs before this commit), gens all rc=0.
- vi and sb both added a block at the same point of DirectGLES.cpp's buffer
section; both belong, and vi's arm used to borrow the `#endif` that follows the
hunk, so it gets one of its own and sb's block opens below it.
- pg and sb both rewrote the UBO loop's first lines: pg changed where `binding`
comes from (the archived block-binding tail, because glUniformBlockBinding moves
it after the link), sb changed what `binding` indexes (the applier's window
instead of the frontend binding-point array). The merge keeps pg's source and
sb's consumer, which is the boundary both reports named.
- pg wrote a comment stating the shape of the texture memo key because the line
belongs to tx2; tx2 wrote the fields. Both kept.
- PipeCatalogue's routed-row pin read 38 at the base, and sb (one generated row)
and pg (one escape) each landed 39 against it. The merged tree has 40, which is
what the count now asserts, with both rows named in the comment.
- Gates on the merged tree: unit 2250/2250, integration-split 113/113, gen_pipe
and gen_pipe_field_ownership --check/--self-test green, include closure 4 probes
0 problems, dirty surface --check/--self-test green, doc citations one
pre-existing CONTRACT-P5.md ambiguity. Strict is still the phase's two-sided
lane; its first-blocker markers fell from {GetTextureUnitObject@Clear 13,
GetFramebufferBindingSlot@Clear 6, GetTextureUnitObject@GenerateMipmap 2} to
{GetFramebufferBindingSlot@Clear 2, GetProgramForDraw@DrawArrays 1} plus the
allowlisted barriered rows (ReadPixels, CopyTexImage2D) that were always behind
them. Run-ahead is still OFF: kMGPipeP5eRunAheadReady and
kMGPipeP5eClientWaitRuleLanded are both false, so this is the lockstep split
with every family reading records instead of the frontend.
- AUniformBlockBindingAfterTheLinkTravelsOnItsOwnRecord: the create unlatches the
bindings and the first emission after it always goes out (the ordering the applier
depends on), an unchanged binding set emits nothing, and a post-link unit assignment
moves the latch. This is the case the package's first red-once is built on: the
integration-split lane has no scenario with a named uniform block bound to a non-zero
point, so emptying the tail there is invisible and the statement has to be made here.
- TheThreeBindingTailsAreWholeSetsAndAReIssuedCreateDropsThem: whole-set replacement in
all three tails, the override NAME copied rather than pointed at (rule C), BindingsSerial
advancing, and the re-issued create dropping every tail because they index into the
archive it just replaced.
- AFailedRelinkReIssuesWithLinkStatusZeroAndNeverAnObjectDeath: ID-88's "iff the frontend
reports it unlinked" is asserted against the frontend rather than assumed, so the day
Link()'s prologue stops withdrawing LINK_STATUS this case says the ruling has to be
re-decided instead of the server silently drawing a dead executable.
- TheFramedArchiveRoundTripsWithTheStageOfEveryModule: two modules of the SAME stage -
the shape MGPProgramDesc::StageMask cannot describe - survive the frame, the skipped
member comes back null, and a truncated frame is refused with both outputs defined.
- Reverts f4675d1a. tx2 owns TextureImpl::SyncTextureToBackendByHandle and
BackendTextureObject::SyncMipmapsToBackendByHandle; fb bodied them temporarily only so
that fb's own integration-split and strict lanes could be measured at all, and this
puts the c0e stubs back so the two packages cannot collide at the merge.
- WHAT THIS COSTS, AND IT IS SAID HERE RATHER THAN DISCOVERED: with the stubs back,
every integration-split entry whose framebuffer has a TEXTURE attachment - or whose
draw binds an image - aborts on
Fatal{UnmigratedVerb, "TextureImpl::SyncTextureToBackendByHandle"} rather than on
anything fb wrote. fb's lane is green the moment tx2 lands beside it; the numbers
measured with the scaffold are in the package report, with the scaffold's own cost
(a GetTextureObject pull per call) named.
- CONTRACT-P5E §1. The latch means "what THIS session's first caps adoption said", and
ClientSession is a process singleton that Start/Stop can cycle. Left standing, a second
Start would inherit a verdict about a server that is gone - and because a later
snapshot may only turn run-ahead OFF, an inherited `true` could never be corrected by
the honest answer arriving afterwards.
- The present serial resets with it: a credit's id space belongs to the session that
paces on it, and a server that never saw frame 1 cannot return its credit.
- CONTRACT-P5E §7. integration-split-strict was an EXPECTED-RED control: every entry
aborts on its first BARRIER-PULLED read, so the step gated on the run failing for
that reason. After the flip it is a HARD GREEN GATE - an unbarriered record's pull is
Fatal whatever the knob says (§3.3), so strict adds only the barriered rows, and those
are a short named allowlist of <field>@<verb>. A revert of any handle arm in
vi/sb/pg/tx2/fb then goes red HERE, by field and verb, instead of in a picture.
- THE ARM IS CHOSEN BY THE TREE. The step greps kMGPipeP5eRunAheadReady out of
MG_Backend/Init.cpp, which is the one constant the integration commit flips and is
exactly the fact the lane's expectation depends on. A lane whose flip is a separate
human step is a lane that is wrong for as long as that step is outstanding - and the
pre-flip arm is unchanged, so this commit moves no CI verdict today.
- The allowlist names the package that retires each row: readbacks and the pack PBO
(P8/P9), the CopyTex endpoint and GenerateMipmap (tx2), set_storage_block_binding
(pg), XFB (P3b/P4b/P9), resource_copy_region's GL names (the declared P5b debt). A
row leaves it by deletion, never by widening. The lane also warns on a non-zero rsp,
which after P5e can only be a barriered row.
- The allowlist is built with printf rather than a heredoc: a quoted heredoc's
terminator has to start at column 0, which inside a YAML block scalar would end the
scalar instead of the heredoc.
- ARCHITECTURE §11.6: the wait rule is the generated WaitClass column now, not the call
class, and it REPLACES the BatchWaits skip; glFinish stops being a no-op under
run-ahead; the forced waits (EGL forwarders, the map/readback family) and glGetError's
named window are listed. §11.7: the P9 plan is code - the producer blocks, the drain
clears AND rings, the client's parks break on the latch, and two named refusals remain
for the impossible cases. §14: the present credit is client-minted, 1-based, one per
swap, paid before the encode. §17.7: what P5e is, in one section - the predicate, rule
F, gPipeInputs' ownership, the latch and the control arms.
- 附 A gains MOBILEGL_IPC_RUN_AHEAD and moves MOBILEGL_IPC_PRESENT_CREDIT out of the
"P6+" line: a knob listed as not-yet-effective while it paces the frame is how an A/B
stops being one.
- SyncBufferBindingPointsByRecord(class, glTarget), file-local beside the walk it replaces:
the window is the applier's ShaderBufferStart/Count, each entry resolves through
EnsureBufferResourceForHandle(nullptr, Res), and whole-vs-range is
`Offset == 0 && Size == kMGPipeWholeBuffer` - so obj->GetSize() leaves the function and the
extent of a base binding is re-resolved HERE, against this server's own storage, which is
what makes a glBufferData between the emission and the apply bind the new extent as GL does.
The ES clamp stays exactly where it was: the device ceiling is a server question.
- The arm is transport + bit 13, with bit 13 requiring bit 7 refused by name rather than
half-run - ResolveVertexInputSubsystemArm's shape, and for its reason: without bit 7 every
point would log once and `continue` WITHOUT unbinding.
- SyncAtomicCounterBuffers reads the applier's window instead of
GetBufferBindingPointCount's sticky forward; glBindings stays the backend program's own
list. A binding outside the window takes the UNBIND arm rather than being skipped, because
skipping would leave whatever the driver last had at that ESSL point.
- The UBO loop's POINT half reads BoundShaderBuffers[Uniform][binding] in the same index
space, with IsBufferDrawCleanByHandle / FindBufferResourceForHandle in place of the
frontend probes. GetUniformBlockBinding above it is untouched - that line is package pg's.
- MarkShaderStorageBuffersGpuWritten and SyncAtomicCounterBuffers' MarkBufferGpuWritten are
DELETED under a transport. They walked client memory and wrote into a client object, which
is the one thing that cannot survive run-ahead, and the client's own MarkGpuWritesForDraw
has covered the same set since P5d. Withholding the client's two rows turns the atomic
scenarios red (5 vs 13, 100 vs 116), which is what says the duplicate really is gone.
- Every frontend arm is kept token for token under Transport=monolith (G1, ruling 1), as the
`if (…) else` the indirect buffer already uses.
- Fifteen cases, and every one of them arms run-ahead through the production latch
(publish kCapRunAheadApply into the caps mirror before Start) rather than through a
test-only setter - ID-102's lesson: a red-once whose probe is a branch beside the
production path is a red-once that proves the branch.
- RemoteRunAhead: the knob alone does not arm it; VERB_BARRIER=0 disarms it even with
the cap bit; a credit-1 client pays at every present after the first and a credit-3
client at none of its first three; an unbarriered verb fills nothing and is allowed;
a 3 MB event burst behind an unwaited sequence flow-controls instead of aborting and
arrives LOSSLESS; only the texture half of resource_subdata wants its reply.
- RemoteGuards: a gPipeInputs touch outside a barriered fill under run-ahead is
Fatal{RoleViolation, "gPipeInputs"} by name and the barriered fill is allowed; a
residual pull under an UNBARRIERED record is Fatal WITHOUT the strict knob while the
same pull under a barriered one still only counts (ruling 4, in executable form); a
deferred destroy from an unbarriered apply is the finding and from a barriered one is
still served (ruling 13).
- SessionTest: a full SEG_EVENT wakes a client parked on appliedSeq AND one parked on
the present credit, without touching either watermark; the drain clears the latch and
RINGS, once, and is silent when the latch was already down.
- The event burst's drainer starts 50 ms late on purpose. A drainer that starts at once
can keep a 256 KiB ring from ever being full, and a red-once that depends on the
scheduler losing a race is not a red-once.
- ClientVerbIsBarriered now takes a nullable context: the validate point asks the
question before it knows whether there is anything to fill, so the answer is about the
RECORD. A null context has no open XFB span, and treating it as "barriered" instead
made the fill red-once unreachable in any fixture without a live GLContext.
- kMGPipeP5eClientWaitRuleLanded flips WITH kMGPipeP5eRunAheadReady, in the integration
commit - an amendment to ID-103's wording ("the same commit that lands the wait rule").
The argument ID-103 makes is about whether the CLIENT is parked: RunAheadArmed() needs
the server's caps bit, so between ra landing and the bit being published the client
still blocks after every record, and a `false` stamp there would withdraw §4.4's
exemptions from probes that wait still makes safe. Two spellings, one switch.
- MGPipeApplyResourceCreate declines a TEXTURE create on a process with no P4a consumer
(NoP4aConsumer), which is every unit-lane process, so the record-driven shape of this
case skipped instead of running. The ABA answer is the twin TABLE's - a forward
generation resets the entry - so the case now drives g_backendTextureObjects directly
and gates on EsprytSlotTablesEnabled() rather than on the family bit.
- And it does NOT compare the successor's twin ADDRESS against the predecessor's: the
reset released the predecessor, so the allocator hands the successor the same heap
address, which is precisely why an address is never an identity here. "The slot came
back empty" is the assertion.
- ImageEmit.TheThreeAccessConstantsArePinnedOnBothSidesOfTheWire: ruling 16 / ID-94 is
that the encode and the decode are ONE table, so the case reaches both halves - the
numbers, MGPipeEncodeImageAccess, MGPipeDecodeImageAccess, the validity predicate and
the two read/write predicates - and then round-trips all three GL tokens through a
real glBindImageTexture so what is pinned is the byte that actually crosses. The
drift it catches (1 <-> 2) is a `writeonly` image the shader can read back, which no
pixel test on a lockstep tree could see while the frontend value was answering.
- FramebufferEmit.TheRecordsElevenSurfacesAreTheWholePointSetTheServerReads: the server
twin of AnAttachmentPointAboveTheWireWidthIsRefusedNotTruncated. P5e spends that
refusal as a licence - the by-handle attachment walk iterates Color[0..7] + Depth +
Stencil instead of the frontend's 41 points - so both halves are pinned: a
fully-populated framebuffer round-trips every point into the applier's record, and a
point above the width leaves the record the server holds completely unmoved (hash and
FramebufferSerial), rather than merely counting a refusal.
- FramebufferEmit.ADefaultFramebufferResizeReEmitsTheRecordWithItsNewExtent: ruling 15,
which S3 flagged as unsettled and said was load-bearing because under run-ahead there
is no other extent source. The case drives the surface-changed consumer's own call -
AllocateStorage on the attachment at the new extent, no rebind, no re-attach - which
is exactly the shape the framebuffer shutter would have suppressed.
- All three get their pull-build skip twin through the suites' X-macro lists (G2/G14).
- BackendProgramObjectImpl::SyncToBackendByHandle is real. It and the frontend overload
feed ONE body (SyncToBackendFromSource) through ProgramArchiveSource, a view whose
accessors are ProgramObject's names one for one - so the body is written once and the
PULL build compiles the same text against a ProgramObject. In a pull build
ProgramBuildSource IS ProgramObject, the function keeps its own name and its null
guard, and G1's byte identity survives a rename and nothing else.
- The view answers the three POST-LINK MUTABLE reflection fields from the record's
set_program_bindings tails and everything else from the record's archive. The tails are
a whole-set replacement, so a location the sampler tail does not name has NO unit - not
whatever the link-time snapshot said - and "has a bindings record been applied at all"
is BindingsSerial != 0 rather than a non-empty tail.
- The nine-clause clean condition keeps its clause count and moves its inputs (D-H5):
record.Serial vs the m_syncedShaderCsoSerial stamp P4a landed and nothing has read
until now, BindingsSerial vs a new m_syncedBindingsSerial (a glUniform1i on an IMAGE
uniform is baked into the ESSL, so the record that carries it must force a rebuild),
record.Signature, Desc.LinkStatus and Desc.SpirvStatus.
- The signature has ONE authority per arm: the handle arm stamps and compares the
CLIENT's number verbatim, so the two commutative hashes never have to stay
bit-identical for a program with an override to stop rebuilding every draw.
- DirectGLES: SyncCurrentProgramByHandle, the stash re-keyed on the handle,
PrepareForDraw/PrepareForCompute reading MGPipeApplier().DrawProgram/DispatchProgram,
BindCurrentProgramWithResources and GetCurrentBackendProgram on the handle arm, the
block binding and the sampler unit from the record's tails, and the sampler-pass memo
on BindingsSerial instead of the frontend's backend-state version.
- MapUBO() has no run-ahead answer and now says so: every decline of
ResolveGlobalConstantsRecord under a transport is Fatal{UnmigratedVerb,
"set_global_constants"} rather than a torn read of the client's live globalUboScratch.
- The fragColor broadcast count takes a POLICY, because a dispatch is a third case: it
has no draw framebuffer and no fragment stage, PrepareForCompute runs no SyncCurrentFBO,
so it may neither fall back to a client row nor abort when the records are untrusted.
- Three of the family's five named frontend-keyed scope sites are now unreachable under a
transport (ResolveGlobalConstantsRecord, SyncCurrentProgram,
BindCurrentProgramWithResources). The other two are kept BY CONTRACT (§5.5):
GetBackendProgramId is a GL-thread entry point and ShaderStorageBlockBinding is reached
from the barriered set_storage_block_binding, which is pg's trailing item.
- SanityTest's DirectGLESVertexInputDraw pair drives the PRODUCTION decisions
(ResolveDrawVertexBuffersFromRecord / ResolveDrawIndexBufferFromRecord) with the frontend
VAO deliberately pointing at a buffer the record does not name - the state a run-ahead
client makes ordinary and one process cannot otherwise produce. Both functions take the
applier state and nothing else, so the only revert is a frontend read inside them and
the cases then go red naming the field (ID-102's rule about what a red-once may test).
G2/G14 skip twins in the pull arm.
- VertexInputEmit pins ID-95 / ruling 19 on the producer side: set_vertex_buffers' window
must cover every attribute create_vertex_elements declares ENABLED, with a sparse
configuration so "covers the enabled set" is not "is as long as the array". The sink half
is tx2's CheckUnitWindows.
- ClientVertexArrayScenario is BRIEF-P5E's open item (a), answered: NO existing scenario
exercises a client-memory vertex array under split (DoublePrecisionScenario uses the
binding-model calls against real buffers), so the lane had no entry that could reach the
refusal at all. Registered in the split lane with a private log path, and it is also the
positive control for keeping the upload arm alive under lockstep.
- ReadDrawBindings answers ClientVertexArrays in the SAME single read of the bindings the
rest of the draw plan is made from: an enabled attribute with no buffer behind it, which is
exactly what EmitVertexBuffers publishes as Res == kMGPipeNullHandle.
- PlanDrawInfo sets kDrawClientArrays for every draw shape, not only the array ones - a
glDrawElements can fetch its VERTICES from client memory too - because MGPipeBarriered is
computed from the record on both sides and a null Res inside the window is also what a
disabled attribute publishes.
- EmitDrawRecord and the reduced DrawArrays path refuse such a draw under run-ahead with
Fatal{UnmigratedVerb, "DrawArrays+CLIENT_ARRAYS"} on the GL thread, before the hooks
(ID-82 / ruling 2). LOCKSTEP IS UNCHANGED: the client is parked in WaitForApplied for that
record, so the server's upload reads memory that is not moving (ruling 4). Staging the
bytes as a per-attribute {BindingIndex, MGHostSpan} tail is P8's.
- The armed predicate is spelled here from the three inputs MGPipeTypes.h names for
RunAheadArmed() rather than called, because ClientSession::RunAheadArmed is package ra's;
it is inert until the integration commit publishes the caps bit, and ra collapses this
body into that latch.
- ResolveVaoTwin takes st.BoundVertexElements under a transport (id's by-handle body); the
frontend overload survives as monolith glue and its MGPipeFrontendKeyedRegistryScope is
DELETED, which is what makes a revert of the twin key abort instead of being exempted
(CONTRACT-P5E §4.1, §4.4, §5.8). Two of this family's three scope sites go with it.
- SyncVaoAttributeBuffersByRecord beside SyncVaoAttributeBuffersByHandle: the walk is over
st.VertexBuffers[Start..+Count).Res, deduped on {slot, gen}, ensured with
EnsureBufferResourceForHandle(nullptr, Res) and probed with a null frontend. The
push-monolith arm keeps its frontend walk token for token (ruling 1 / ID-81).
- The IBO arm reads st.IndexBuffer.Res with st.IndexBufferSerial, and the serial JOINS the
identity compare because this arm has no live element slot to re-read: a re-emitted
set_index_buffer on the same handle would otherwise read clean. ResolvedDrawBuffers gains
iboSerial; Entry::frontend stays declared for the legacy arm and is never written here.
- PrepareForDraw stops reading GetBoundVertexArray and GetConfigVersion under a transport;
SyncCurrentVertexAttributeValues keys on {elementsHandle, ContentSerial, activeMask} and
reads rec->Attributes[i].Enabled. The two program reads there are pg's and stay.
- The DrawArrays / MultiDrawArrays client-array arms resolve the twin by handle and take the
frontend VAO only when the RECORD says an enabled attribute is client-sourced, which is
what retires GetBoundVertexArray@DrawArrays for every ordinary draw.
- The two source decisions are named functions over the applier state alone
(ResolveDrawVertexBuffersFromRecord / ResolveDrawIndexBufferFromRecord) so a unit case can
drive the production decision rather than a copy of it.
- BumpBufferMutationEpoch pins BRIEF-P5E's open question 2: a bump off the apply thread while
one is running is counted and Fatal under the strict lane. The measured answer is that the
only non-apply-thread bump in the tree is BackendObject_DirectGLES::Initialize's
RegisterBufferBackendOps, before the apply thread exists.
- The restart helpers refuse a frontend element-slot read under a transport by name; the
substitution's own body already resolved from the applier since P5c.
- The strict lane named GetImageTextureBinding@BindImageTexture on thirteen entries:
bind_shader_image is an unbarriered row (CONTRACT-P5E §2.2) whose backend entry point
receives GL arguments and a GL NAME - MGPImageBind::Res stops at the sink - so this
funnel had no way to resolve the twin and read the frontend binding instead.
- Under a transport it now records only the high-water mark and returns. Nothing is
lost: the sweep at the next validate point re-binds every unit up to the mark from
the applier's own MGPImageView array, and the sink bumps the texture shutter serial
immediately after this call, which is one of the four values the sweep's gate is
keyed on - so the sweep that follows a bind cannot be suppressed. An image unit has
no reader but a shader, and no shader runs in between.
- The mark itself must still be raised here, because it is the "no draw in this context
can be reading an image" early-out.
- MGPShaderBuffers::WritableMask is Uint32[3] and the POD grows 32 -> 40 (ID-104). One
Uint32 could describe only the first 32 of an 84-point window, so a storage buffer bound
at point 32 or above read as unwritable with nothing able to see it; the ruling widens the
field rather than narrowing the window, which would change what an application may bind.
MGPipeShaderBufferMaskHas / ...Set are the one spelling of the mask, so the emitter, the
applier and the four backend sites cannot disagree about which word a point lives in.
- MGPipeApplySetShaderBuffers has its body: per-class window, Start/Count validated against
the capacity, the mask copied by word, ONE ShaderBuffersSerial advanced on every applied
record and ADVANCED - never zeroed - by both resets, which also clear the three windows.
- Route rows for opcode 38 on both arms (Mono_ and Wire_) and the server sink in the wire
codec's decode switch, which is where a kCtxState row's sink lives - PipeApplier.cpp's
ServerVerbSink has no method for it and the brief's "OnSetShaderBuffers" names no code.
The codec also bounds Class and Start+Count where Count becomes a byte length.
- New MG_Impl/Pipe/ShaderBufferEmit.h: one record per class, window = the touched
high-water mark clamped to 84, zero early-out before any hash, a BASE binding travelling
as kMGPipeWholeBuffer so the server re-resolves the extent at use, three suppressor slots
(ruling 11), and the writable mask that replaces the backend's GPU-write walk.
- BufferState gains a per-target bind-point generation (push-only, G1) and dirty bits
15/16/17 shutter on it instead of the buffer CONTENT aggregate. That aggregate answered
the wrong question in both directions: any glBufferSubData fired all three, and
glBindBufferBase fired none of them, because it mutates a binding point through a returned
reference. Same defect class as P4a's glBindSampler hole.
- Init.cpp publishes consumer bit 13 for DirectGLES and not for Magma (ID-106), in the same
commit as the wired constant, and PipeFill's liveness gate asks R-8 for bit 13 in
particular plus bit 13-requires-bit-7.
- `Touch` joins the dirty-surface scanner's mutator prefixes; TouchBufferBindingPoint gets
its row and SetNamedTransformFeedbackBinding's is rewritten now that bit 17 reads a
generation a binding moves. Negative control 6 is re-pointed: the pair it used to refute
is a live answer, and a control that has quietly become positive is what the self-test is
for.
- fb calls TextureImpl::SyncTextureToBackendByHandle and
BackendTextureObject::SyncMipmapsToBackendByHandle from four places. tx2 owns those
bodies and is being written in parallel, so with c0e's aborting stubs every
integration-split entry that touches a texture dies on the seam and fb's own isplit
and strict numbers cannot be read at all.
- This commit bodies the two temporarily, in the one way that does not pre-empt tx2's
design: the frontend texture is resolved from the record's own GL name through the
BARRIER-PULLED sticky forward GetTextureObject, exactly as the copy-image sink
resolves its endpoints, and the existing frontend overloads do the work. Correct
under lockstep, and deliberately NOT the shape tx2 will write - tx2 drives the
storage from the descriptor and holds no frontend object.
- IT IS REVERTED BY THE NEXT-BUT-ONE COMMIT. It exists in history so the integrator can
see exactly what fb assumed about the seam and what fb's measured numbers rest on.
- RemoteClientTest's MGL_TEXTURE_GUARD_TEST block gains the seven MipmapStorage shape
reads P5c deliberately exempted, with the sentence that exempted them ("the per-draw
binding walk reads them every draw") replaced by the reason it is no longer true.
- SanityTest gains ARecycledTextureSlotReMintsTheTwinOnTheHandleArm beside
UnitMemoRefusesToDriveATwinFromAnotherTexture: the borrowed-slot list needs a pointer
compare because its key is derived state; the by-handle list needs none, because a
recycled slot arrives as {s, g+1} and the forward-Gen reset gives the successor a fresh
twin while the predecessor's handle stops resolving. G2/G14 skip twin included.
- The doors: RefuseFramebufferBindingSlotRead raises
Fatal{UnmigratedPipeInput, "GetFramebufferBindingSlot@<verb>"} where a decline used
to fall through to the pre-handle arm. Under a transport with the framebuffer bit
set the fallback is not a safe default - it would read a row belonging to a client
this apply is no longer synchronous with - so SyncCurrentFBO's and BindCurrentFBO's
declines become the detector CONTRACT-P5E §5.4 asks for.
- The two attachment texture lists are the package's core: the one unconditional
per-draw live read of this family, taken whether the memo hit or missed. They are
now keyed {Fbo, ContentHash, ContextSerial, g_backendContextGeneration} with entries
{Res, twin} that borrow nothing, and the dedupe asks the two BOUND HANDLES rather
than two pointers. Three frontend reads go in one move: both binding slots, the
FramebufferObject the entries borrowed their attachment slots from, and
GetTextureContextId.
- SyncCurrentFBOByRecord drives the twin through SyncToBackendByHandle /
SyncReadBufferToBackendByHandle; NoteStateForHandle is gone, which was the server's
last cross-record hold on a frontend framebuffer.
- FramebufferRecordMatchesBinding is monolith-only: the record was resolved from the
applier's own bound handle, so there is no second answer left to corroborate.
- ForceBindCurrentFBO reads the binding slot INSIDE the else instead of on both sides
of its own handle arm (S3 finding #7); the three legacy stamps go with it, which is
safe for the reason the serial memo is invalidated rather than stamped there.
- SyncAndBindFramebufferByHandle configures from the record, so a DSA framebuffer that
was never bound is configured correctly instead of being named and bound unconfigured.
- Both blit arms take two const MGPFramebufferState&: draw buffers from DrawBuffers[],
the read buffer by matching ReadSurface against Color[], kind/level/layer/layered/
format from the surface, Samples from the record, the textures by handle.
- The detach walk uses the reverse index and the records instead of ForEachLive +
StateForHandle + the frontend attachment array. The frontend-keyed scope stays at
this site: every caller reaches it from a barriered row (§4.4).
- SyncRenderbufferObjectToBackend refuses by name under a transport - its only caller
is the copy-image endpoint, whose renderbuffer form both the emitter and the sink
already refuse - and the scope at that site is deleted.
- Clear's debug-only draw-FBO diagnostic reads the record under a transport.
- The widened MipmapStorage rows are backend-kind-keyed (ruling 12's shape): Magma is
lockstep through P7 and its named blit still asks a frontend texture IsComplete()
(VulkanRenderer's ResolveColorBlitBinding), which the unconditional widening aborted.
The MUTATOR rows stay unconditional - no backend may write a frontend texture's storage
from the apply thread, and none does.
- CopyTexImage2D / CopyTexSubImage2D sync their DESTINATION by handle instead of merely
looking its twin up. A copy destination is not sampled by the draw program, so the
sampler-view window never brings it across; the pre-P5e frontend walk over every slot of
every touched unit happened to sync it as a side effect. This is the "waited texture
ops" term of the named narrowing (P5e-5) being made real rather than assumed.
- CONTRACT-P5E §2.3. EmitAndWaitTails now switches on the generated WaitClass column
when RunAheadArmed(): kWaitNone and kWaitPresent return at the publish, kWaitApplied
waits and then releases the fill pins, and a reply row is unchanged. It REPLACES the
BatchWaits skip rather than layering on it - the batch rule reads the call CLASS and
this one reads what an apply READS, and the server's MGPipeBarriered reads the second,
so two rules layered is two halves of the wire disagreeing about whether the client is
parked.
- §1: RunAheadArmed() = barrierArmed && Ipc.RunAhead && Caps().HasCap(kCapRunAheadApply),
latched at the first caps adoption after Start and demotable but never promotable - a
record already published without a wait cannot be un-published. A knob set against a
server that does not publish the bit logs once and runs lockstep.
- §2.4: MGPPresent::FrameSerial is minted client-side, 1-based, by AcquirePresentCredit,
which pays the credit BEFORE the encode (a client that encoded first would hold a
SEG_CMD reservation across the park). ServerVerbSink::OnPresent returns one credit per
swap through ServerSession::ReturnPresentCredit, which has had no production caller
since v1 wrote it; serial 0 on a run-ahead server is Fatal{ProtocolCorruption,
"Present.FrameSerial"}. The stats line and the wire ledger gain credit-waits.
- §2.5, the drains and the forced waits: every wait exit drains SEG_EVENT (the applied
wait, the present-ack wait, the cmd-space wait and - through a hook the session lends
the encoder - the stage bell); glFinish becomes WaitForApplyToCatchUp, since "the
commands issued so far have completed" is a promise a queue nobody waits on can break;
every Server* EGL forwarder waits first, because the control mailbox is pumped BETWEEN
drain batches and a make-current landing between two run-ahead records is not a wrong
pixel but a wrong everything. ServerSwapEGLBuffers is not on that list: present is the
swap.
- §2.5 / ruling 15 (ID-93): resource_subdata's BUFFER half stops waiting. The row keeps
kReplySlot, the server still posts, and the slot is never read - which is what makes
the 64 KB persistent-map push fire-and-forget instead of a hidden round trip per block.
MGPipeSubDataWantsItsReply is the one predicate, read by the route and by the emit
table, so the two cannot disagree about whether a wait is owed. The texture half keeps
its reply: DrainTextureSubData clears a level's dirty flag on the accepted answer.
- §3: gPipeInputs becomes server-role memory for unbarriered records. The tracker walk
and the emitters still run - they produce records - but the serial bump, the verb
stamp, the server-stamp withdrawal and the whole of step 4 do not, and
MGPipeNoteFrontendMutation / MGPipeLeaveVerb return instead of writing. `fillOwed` is
a separate name from `barriered` on purpose: the red-once is that one line.
- §3.3: MGPipeInputUnfreshRead's counter becomes an UNCONDITIONAL abort under an
unbarriered record, knob or no knob, because the value is torn or stale by
construction and there is nothing for a counter to count. The marker gains its reason
so the lane's allowlist can tell the two apart.
- §3.5: RefusePipeInputsTouchWhileApplierOwnsIt is re-derived - under run-ahead the rule
is "not at all unless you are the barriered fill", and the BatchWaits early return
goes with the argument that justified it.
- §2.3's ReleaseFillPins drops the four O-class SharedPtr rows once a barriered apply
returns: under lockstep the next fill overwrote them within a verb, under run-ahead a
pinned VAO can make the apply thread its last owner a frame later.
- §2.7 / ruling 13 (ID-91): the deferred-destroy queue stays live, and an enqueue from
an UNBARRIERED apply is now a named finding - loud once, Fatal under strict.
- NAMED DEVIATION from §3.1: the identity/liveness pair is still written on every verb.
It is not a field of the residual model - no ownership row owns it, no record carries
it, no server stamp can answer it - it is "does this process still have a GL context",
which only this thread knows and every null-context guard reads.
- Inert until the integration commit: RunAheadArmed() is false while
kMGPipeP5eRunAheadReady is, so every branch above answers as it did before.
- CONTRACT-P5E §2.6. Fatal{EventRingOverflow}'s premise was "the client drains at
every verb, so a full 256 KiB ring is a burst no workload has". A run-ahead client
drains at its waits, which on the steady path are one present apart, so a full ring
is an ordinary backlog and aborting on it would turn the reverse channel's capacity
into a workload limit.
- The producer blocks instead (ReserveEventOrBlock, one entry point for all four
event kinds): publish what is in the ring, ring the client, park on this server's
own bell until the latch clears, retry. A server that does not publish
kCapRunAheadApply keeps P5C's Fatal byte for byte.
- Two named refusals survive, and they are the impossible cases rather than the busy
ones: a single event larger than half the ring (no drain can ever make room) and a
30 s wait for a client that is not draining because it is not running.
- The deadlock argument needs both halves, so both are here.
EventRingConsumer::Drained() now CLEARS AND RINGS - a cleared flag with no bell is
the lost wakeup the forward direction's publish-then-ring order exists to prevent -
and SessionProducer gains WaitForAppliedOrEventBacklog /
WaitForPresentAckOrEventBacklog so a parked client is always wakeable by the server
that is waiting for it. The old two waits keep their callers: teardown has no ring
to drain and must not learn about one.
- ServerLoop's park condition gains a fourth clause, and it is the one that says "not
now" rather than "wake up": with the ring full a waiting record is not work yet,
because applying it would only park the producer half way through. Stop and Control
still win, so a teardown is never held up by a client that stopped draining.
- Inert this phase: nothing publishes kCapRunAheadApply until the integration commit,
so every arm above answers as it did before.
- SyncTextureToBackendByHandle and SyncMipmapsToBackendByHandle get their bodies
(the c0e seams): record first, twin by GetOrCreateByHandle, three syncs from the
record, no frontend touch and no allocator probe on the path.
- BackendTextureObject carries m_pushedSyncHandle (CONTRACT-P5E §4.1's m_handle), which
is what the three sync prologues read instead of probing the client allocator with
HandleOf, and what selects the by-handle arm inside them.
- IsDrawSyncCleanByRecord replaces IsDrawSyncClean(frontend) on the memo-hit path: the
same conjunction over the record's serials and the twin's own, with BOTH halves of each
resync pair read and neither clearing the other's (D-E2, scout R4).
- The unit work list is built from st.BoundSamplerViews[] and keyed on
(ContextSerial, SamplerViewsSerial, window, backend context generation); PairingsIntact
is not carried on it, because a {slot, gen} has no stale-pairing hazard to catch.
- ResolveAndBindUnitTextures is one pass over the window with the target from the view
CSO, then an unbind driven by g_boundTexturesCache; the alias arbiter dies (the client
arbitrated, SamplerEmit.h).
- BindCurrentTextures' memo key is ruling 6's both halves, entry selection included.
- The sampler pass reads BoundSamplerStates / the built-in sampler CSO / the view's
InternalFormat; the apply-thread mint and ResolveUnitSamplerBackend become a named
refusal under a transport (§4.2).
- GenerateMipmap resolves VerbMipRes and never the active unit; the detach sweep is keyed
by the texture handle over the applier's framebuffer records. The row is NOT flipped to
kWaitNone: the RGB16F/RGB32F CPU filter still reaches the client's level shadows, so
ruling 14's escape applies and the flip is trailing.
- RequireImageBindableStorage gets a by-handle entry whose re-dirty is
Fatal{UnmigratedEmulation, "image-bindable-redirty"} at the entry rather than three
frames deep at a MarkStorageDirty guard.
- The buffer-texture arm resolves scout R5 with Desc.BufferForTexBuffer / BufOffset /
BufSize and EnsureBufferResourceForHandle(nullptr, h) - that function is client-free
under an active transport (kimi audit row 29).
- CheckUnitWindows(st) at draw and dispatch makes the §5.3 window promise checkable and
checked: narrower than [0, MaxTouchedTextureUnit] is
Fatal{ProtocolCorruption, "SetSamplerViews.Count"} / "BindSamplerStates.Count".
- MipmapStorage's guard list gains the read-only shape accessors P5c deliberately
exempted, now that nothing on the draw path reads them.
- SyncAttachmentSurface loses its frontend attachment parameter (CONTRACT-P5E §5.4).
The storage syncs have by-handle forms now, so the object was only there to be
cross-checked - and a second answer only a monolith build can produce is not a
check. Review N-6's one-directional hole ("record empty, frontend still holding")
becomes unrepresentable: the caller's detach and the attach read one test.
- The renderbuffer cross-check at the old Managers.cpp:9711 is DELETED rather than
transport-gated (§4.4). It was the one HandleOf in this family that was not gated
while its texture sibling was; it ran a client-allocator probe on the apply thread
under split, legal only because the scope named it and the client was parked.
- BackendFramebufferObject::SyncToBackendByHandle and
BackendRenderbufferObject::SyncToBackendByHandle land in place of c0e's aborting
seams, beside the object forms they overload. The record's eleven surfaces are the
point set; the per-point frontend version memo is gone and the record hash is the
only gate, with g_attachmentBackendIdGeneration and the re-entry kept because no
client-side value can answer what they answer.
- SyncReadBufferToBackend's decision moves into ApplyReadBufferFromRecord, which takes
no frontend object; both overloads funnel through it.
- The reverse index texture -> framebuffers, maintained at the by-handle sync, for the
detach walk (tx2 needs it) instead of a walk over every twin's frontend attachments.
- Images: ResolveShaderImageRecord and its identity probe are gone - under a transport
there is no second answer to arbitrate. SyncImageTextureBinding gains a record
overload; Access is DECODED through MGPipeValueTypes.h (ruling 16 / ID-94) and the
stale "the encoding does not exist" comment is deleted. The bind's format rules are
factored into one helper both arms call rather than written twice.
- The image sweep gate is re-keyed onto (ShaderImagesSerial, TextureShutterSerial,
ContextSerial, g_backendContextGeneration) under a transport (ruling 7 / ID-86),
never a backend re-mint counter; the window/high-water union is kept and the walk
reads BoundShaderImages[].
- MarkWritableImageBufferTexturesGpuWritten returns early under a transport - the
client owns the GPU-write set; paired with sb's deletion of the backend marks.
- BoundImageUnitFormat answers from MGPImageView::InternalFormat under a transport.
- MGPipeShaderCsoRecord gains Archive, a SharedPtr<const ProgramArchive> the create
ADOPTS under a transport. Before this the applier stored the descriptor and the two
companion pointers named the frontend's own LinkArtifacts/SpirvArtifacts, which
ProgramObject::Link() replaces in place - so every reflection read on the apply thread
was torn or stale by construction the moment the client runs ahead (gap G-A).
- MGPipeApplyCreateShaderState takes the archive as a fourth argument, undefaulted for
PipeRoute.h's stated reason: a caller that has one and forgets it would compile, and
the failure would be silent. Null on the monolith arm, which keeps its frontend arms
token for token (ruling 1 / ID-81).
- ProgramArtifactsCodec gains ProgramArchive and the FRAME. SpirvArtifacts carries no
stage list and MGPProgramDesc::StageMask cannot stand in for one: it is a bit set, and
GL lets two shader objects share a stage, so a list rebuilt from it can be shorter than
generatedSpirv - which the backend pairs with it by one running index.
- Codec completeness, as CONTRACT-P5E §5.5 asks: the skipped member is named
(LinkArtifacts::program, the live glslang TProgram) and proven unread by either backend
twin; the libc++ caveat at the size echo is read as what it is - a cross-toolchain
check, exact for an in-process split - and both verdicts are written where they were
asked for.
- set_program_bindings (opcode 80) lands end to end: EmitProgramBindings on the client,
the FIFTH ROUTE ESCAPE (three tails in three index spaces plus a parallel name array -
no generated row can express it, so op 80's gMGPipeContext slot stays null as
PipeCatalogueTest pins), the wire emitter, the decoder arm that resolves and
NUL-checks every name span, and the applier body that copies the names into the record
(rule C) and advances BindingsSerial.
- The record is emitted AFTER create_shader_state, not before as the draft note said: a
re-issued create clears all three tails because they index into the archive it just
replaced, so bindings published ahead of it are wiped by the create that follows.
- MGPProgramDesc::LinkStatus is filled, and ID-88 is VERIFIED rather than assumed:
ProgramObject::Link()'s prologue bumps m_linkVersion and assigns m_artifacts = {} -
"the complete not-linked state" - before the body runs, and every ProgramLinkTask
failure arm leaves linkStatus false. A failed relink therefore reports unlinked, the
re-issue carries LinkStatus = 0, and nothing needs an object_death.
- ID-87's measurement gets its instrument: the archive's byte count is added to
PipeStats' CsoBlobBytes where the bytes actually exist, one sample per link.
- c0e and id were implemented in parallel and each declared the two shared seams,
so the merge produced five duplicate definitions the text could not collide:
MGPipeBarriered, MGPipeApplierCurrentRecordIsBarriered and the three by-handle
twin resolvers. Resolved per ID-99 and ID-103.
- MGPipeBarriered keeps c0e's real body (the static wait-class column, the XFB
escalation and the client-array escalation); id's constant-true placeholder goes.
- The current-record flag keeps c0e's thread_local storage and setter and loses
id's MGPipeApplierState member: it describes which record THIS THREAD is inside,
not per-context state the applier owns, and the allocator guard that reads it
would otherwise have to find the right context's applier first. id's test hook
posts through the setter.
- The three resolvers keep id's real bodies beside each registry; c0e's asserting
stubs for them go. The seam stubs no package has bodied yet stay.
- ApplyOne stamps `true` while kMGPipeP5eClientWaitRuleLanded is false. The
predicate says what the client WILL do; until ra changes EmitAndWaitTails the
client still blocks after every record, so a `false` stamp would withdraw the
§4.4 exemption from a probe the client's own wait still makes safe. The
predicate is computed on every record regardless, so it is exercised for the
whole phase instead of first running on the day it starts deciding.
- Gates on the merged tree: unit 2206/2206, integration-split 111/111, both
generators --check/--self-test green, include closure 0 problems, dirty surface
rc 0, doc citations one pre-existing CONTRACT-P5.md line (c0e retired another).
- SlotAllocator.cpp:410-419 named lines that do not exist; the Gen-on-reuse rule is at :176-189
for the ordinary table and :240-258 for the composite band. The doc-citation lint now reports
the same four pre-existing problems it reports at the base and no new one.
- MG_Remote/CONTRACT-P5E.md: the draft edited into its landed form. The nineteen rulings are
as the integrator decided them (ID-80..98) and are restated here rather than referenced,
because a package reads this file and not the brief. The "draft" wording is gone and every
file:line was re-resolved at this head.
- FOUR PLACES WHERE THE PLAN WAS WRONG AND THE CODE IS RIGHT, each argued where it sits: the
descriptor has no spare byte so MGPProgramDesc grew 192 -> 200; the applier's per-class
windows are ShaderBufferStart/Count/WritableMask, not bare Start/Count/WritableMask; the
three suppressor slots are named per Class; and the wire codec's layout carries three tails
now, because set_program_bindings has three and the third one's offset is derived from the
first two.
- One finding the contract now carries rather than leaves for sb to discover: MGPShaderBuffers'
WritableMask is a Uint32 and the window is 84, so the mask can only describe the first 32
points of a class. sb asks for the ruling; it is a wire field and not a package's to pick.
- A new section 10 lists, by name, every symbol c0e put in the tree and what each package
compiles against - plus the three red-once evidence lines.
- ROADMAP.md gains the P5e row after P5d (status, the eight packages in dependency order, the
integration gate and the device exit) and the two lines that said P6 was next now say P5e is.
CURRENT_STAGE_PROGRESS.md section 1 gains its row and section 6 its next-step entry.
- MGPipeBarriered(op, payload, applierState), IMPLEMENTED, not stubbed: the static wait-class
column plus the two payload-derived escalations the contract names (an open XFB span, a draw
carrying kDrawClientArrays). One function and two callers is the point - the emit table and
the sink must reach the same answer or the client runs on while the server reads a value that
has already moved. set_context_values now mirrors IsTransformFeedbackActive into the applier
state as well, because a predicate that read it out of gPipeInputs would be reading the very
block run-ahead stops filling.
- MGPipeApplierCurrentRecordIsBarriered() with the writer beside it. It answers true by default
and that IS the landed lockstep answer, not a stub: on a server without kCapRunAheadApply
every record is barriered and CONTRACT-P5C's two exemption scopes keep working unchanged.
- The sb applier fields (BoundShaderBuffers[3][84], the three windows, the writable masks and
ShaderBuffersSerial) and the pg record fields (the three binding tails, Signature,
BindingsSerial) with the two entry points MGPipeApplySetShaderBuffers /
MGPipeApplySetProgramBindings declared and ABORTING BY NAME.
- The by-handle backend seams in Managers.h with aborting bodies in one block: ResolveVaoTwin,
ResolveTextureTwin, ResolveProgramTwin, SyncTextureToBackendByHandle,
SyncMipmapsToBackendByHandle, and the FBO / RBO / program SyncToBackendByHandle overloads.
Five packages sit on opposite sides of these calls - tx2 writes what fb calls, fb writes what
tx2 needs, id rekeys the registries all three read - so the signature is fixed once here and
each family replaces a body. Abort and not null: a resolver that answered null renders a
blank draw and a sync that did nothing renders stale pixels, and both are green lanes.
- Coverage.def's GetBufferBindingPoint -> SetShaderBuffers row with BOTH of its PipeFill.cpp
switch arms and the pairing asserts, shape-only and inert. It lands with the contract package
because PipeFill.cpp belongs to the contract package for the whole phase - an emitter
enumerator whose dispatch arm lived in the family's worktree is the merge trap that file's
own comment was written about.
- FieldOwnership.def re-annotates the six object-class rows plus GetProgramForDispatch to
"P5e (Espryt unbarriered), P7 (Magma)", and the strict-abort case follows the string, which
IS the debt entry rather than decoration. kMGPipeMaxBufferBindingPoints is pinned against
BufferState.h's 84 where both are visible. gen_pipe_field_ownership's refusal count is 8.
- New DirectGLESSlotTable.ACompositeHandleDoesNotGrowTheOrdinaryTable (with its
G2/G14 skip twin) pins the band: a composite handle grows m_band by four
entries and the ORDINARY table by none, the lookup finds it, an ordinary slot
beside it disturbs neither space, ForEachLive reports the composite at the slot
THE CLIENT MINTED rather than at its band index, and §4.3's recycle answer
holds inside the band. It asserts on the two spaces' CAPACITIES, not on
liveness: the table holds one live entry either way, so LiveCount could not see
the growth this case exists to catch.
- RemoteGuards.AnAllocatorProbeInsideAnExemptionScopeOnTheApplyThreadIsAllowed is
DELETED. It asserted that a named scope exempts unconditionally, which is the
rule CONTRACT-P5E §4.4 retires. Its replacement,
...FromTheApplyThreadIsFatalEvenAtTheOldDebtSites, opens the same scope with
the applier's CurrentRecordBarriered forced false and expects HandleOf to
abort. The hook is the production field ApplyOne stamps, not a test-only branch
beside it; the texture and the table are built on the CLIENT thread, because a
TextureObject2D's constructor mints and building it on the apply thread would
abort at the mint with the same diagnostic - a case passing for the wrong
reason.
- The green half of the deleted case needs no replacement: every Espryt
integration-split entry runs the frontend-keyed scope's surviving sites on the
apply thread behind barriered records, so a guard that stopped exempting would
take the whole lane down.
- Unit 2203 -> 2204 (the new name only adds); integration-split unchanged at 111.
- OnFrontendStateObjectDestroyed's split arm loses the mailbox hop
(CONTRACT-P5E §4.4). It existed for the ONE shape the object_death record
cannot carry - a transport with no client session at all, i.e. a ServerLoop
fixture driving a server role with no client, never a real split - and it
posted a stack struct to the apply thread, which re-entered this function and
ran DestroyByLifetimeId: a probe AND a Free in the CLIENT'S allocator from the
apply thread. That is not a read a barrier could ever make safe, because under
run-ahead there is no wait behind which the allocator stands still. Such a
fixture's twins die with the backend at Stop, which is what already happened
for every death raised past Running() == false.
- The frontend-keyed scope this function opened goes with it: the one arm that
needed it was the hop. Falling through under a transport now means the death
was raised ON the apply thread - the server backend destroying a frontend
object it created itself - and those sites carry their own named scope.
- ID-96, ANSWERED BY MEASUREMENT rather than by reading: a probe that aborted in
exactly the deleted branch (NoSession with a RUNNING server loop) was run over
the whole unit lane (2204 entries) and the whole integration-split lane (111)
and NEVER FIRED. The only fixture family that could reach it is
ServerLoopTest.cpp's ServerFixture / EglServerFixture - the tree's only server
role with no client session - and its one case that lets a re-keyed frontend
object die off the apply thread
(ServerLoopEglTest.FrontendFramebufferDeathDeletesOnTheContextOwner) is already
refused at its FIRST step by P5c's allocator guard. So the set of fixtures that
lose death delivery is empty: none rewritten, none deleted.
- ReleaseTwinsForWireObjectDeath gains §4.2's death moment and §4.3's ABA answer
in its own voice: the ordering is the argument (every record naming {s,g} is
applied before object_death{s,g}, which is applied before create{s,g+1}) and
the Gen compare is the proof (forward re-mints, a stale handle resolves to
null, a backward generation is refused) - so a skipped death is answered by the
forward-Gen reset alone and no wait is added at any delete.
- The three by-handle resolvers the per-family packages call, in the shape
SamplerImpl::ResolveSamplerCsoTwin already runs (CONTRACT-P5E §4.1/§4.2):
handle in, THE RECORD FIRST, then AdoptTwinByHandle, then a twin if the slot is
empty. A handle with no applier record is a seam defect and adopting a slot for
it would leave a twin nothing can sync, so it is a loud null rather than an
empty twin; a generation behind the live entry's is refused by
PipeTwinHandleIsAdoptable rather than dropping the incumbent's driver object.
- WHAT THEY DO NOT DO is the deliverable: no frontend object is touched and the
client's slot allocator is never probed. They also do not SYNC - which serials
gate a VAO / texture / program sync, and what the sync reads, belongs to vi,
tx2 and pg, and a resolver that synced would have to know.
- ResolveProgramTwin resolves through the band-aware PipeShaderCsoRecordForHandle
and adopts into SlotTables.h's m_band, which is why the band landed in the same
package: after the rekey a program-pipeline composite's bind is the ordinary
path through this function.
- Declared beside each family's registry global rather than in the registry class
region BRIEF-P5E names, so that the three read exactly like the sampler
resolver they copy. Reported to the integrator as a range widening.
- BackendSlotTable gains m_band, the ShaderCso composite space indexed by
(slot - kMGPipeShaderCsoCompositeSlotBase), beside the vector the ordinary
slots live in - the shape MGPipeSlotAllocator::KindState and the applier's
CompositeShaderCsos already use. EntryAt used to index m_slots by slot and
resize to it, so ONE program pipeline grew g_backendProgramObjects to ~983k
entries (~40 MB). That was reachable only through GetOrCreate(StatePtr) for a
composite program; after id's rekey the by-handle resolution IS the ordinary
path, which is why the band is a P5e prerequisite and not a follow-up.
EntryOrNull / EntryAt / IndexOfSlot are the one place either space is chosen.
- ForEachLive is re-typed fn(MGPipeHandle, const BackendPtr&) (§4.1): a walk that
handed a frontend SharedPtr out of server memory on every step was the last
place the server could hold one across records. Its single caller,
ScopedDetachedTextureFramebufferAttachments, asks StateForHandle for the object
instead - one named read inside the scope that owns the debt. The set of
framebuffers walked is unchanged: an entry the old walk skipped because its
stateRef would not lock is one StateForHandle answers null for.
StateBackendObjectRegistry::ForEachLive is now handle-arm-only; the legacy map
has no handle to hand over and its one caller has always had its own
begin()/end() walk beside the call.
- The frontend-keyed half is KEPT, not deleted (§5.8, ruling 1): the push build
under Transport=monolith keeps its frontend arms token for token, which the
PIPE_VERIFY comparator needs and which is what makes RUN_AHEAD=0 a pure
wait-rule A/B on identical server code. Every one of the six surfaces is a
named Fatal from an unbarriered apply - the allocator guard for the three that
call the allocator, MGPipeRefuseFrontendKeyedRegistryFromUnbarrieredApply for
NoteStateForHandle / StateForHandle. No silent path.
- NoteStateForHandle / StateForHandle move from MOBILEGL_BUILD_DISAGGREGATED to
MOBILEGL_PIPE_PUSH, because the re-typed walk's caller needs them in the
push-monolith build too.
- CONTRACT-P5E §2.1's predicate gets its two names in MG_Pipe/PipeApply.h:
MGPipeBarriered(op, payload, st) and MGPipeApplierCurrentRecordIsBarriered(),
with MGPipeApplierState::CurrentRecordBarriered as the per-record flag that
PipeApplier::ApplyOne stamps before every decode. The body answers TRUE for
every record until ra lands the wait rule, so lockstep is unchanged and this
package alters no behaviour; ra rebases onto that line rather than adding it.
Package c0e owns the final declaration of both names - the overlap is one hunk.
- §4.4: MGPipeRefuseAllocatorFromApplyThread now exempts a probe only inside a
named scope AND while the record being applied is barriered. The client's wait
is the whole of what makes an apply-thread frontend probe safe; behind an
unbarriered record the allocator's free list and lifetimeId -> slot map are
moving under the read, so the value is wrong by construction - there is no
"count it" arm and MOBILEGL_IPC_STRICT_ERRORS is not consulted.
- MGPipeApplierIsUnbarrieredApply() and
MGPipeRefuseFrontendKeyedRegistryFromUnbarrieredApply() are the non-allocator
half of the same rule, for the surfaces that answer from a frontend SharedPtr
without calling the allocator (SlotTables.h's state note and its reader).
- Ruling 12: MGPipeReverseAnnouncementScope -> MagmaP7AllocatorDebtScope, keyed
on MG_Config::ActiveBackendType == DirectVulkan. Magma's four apply-thread
allocator debts move onto it (VulkanRenderer's two hidden-resource shutdowns
and the named-blit endpoint resolve, ResourceTracker's GPU-write
announcement); an Espryt probe can no longer borrow Magma's exemption by
constructing its scope.
- BufferImpl::HandleOfBuffer is Espryt's debt, not Magma's, so it moves to
MGPipeFrontendKeyedRegistryScope - which the Magma-keyed scope would not have
exempted at all. It retires when vi/sb carry the handle into
EnsureBufferResourceForHandle / IsBufferDrawCleanByHandle.
- IpcTable::RunAhead (MOBILEGL_IPC_RUN_AHEAD, default 1, forced 0 under PipeVerify beside
BatchWaits) and IpcTable::PresentCredit (MOBILEGL_IPC_PRESENT_CREDIT, default 1, range
1..8). Both parsed unconditionally like every other IPC knob and both in the IPC log
line, because a knob that silently means nothing on one arm of an A/B is how an A/B
stops being one.
- RUN_AHEAD=0 is an A/B CONTROL and not a negative one: identical server code, identical
records, identical fill decision, and only the client's wait differs. VERB_BARRIER=0
keeps its own meaning as the lockstep arm's negative control.
- MG_Backend/Init.cpp: kMGPipeP5eRunAheadReady = false, and the caps arm goes through
MGPipeRunAheadCapBitsFor so the "DirectGLES only, and only once the integration commit
flips the constant" decision has one spelling. A constant and not a knob: an operator
must not be able to turn a half-migrated server into a run-ahead one, because the
failure mode is a torn read that renders wrong rather than aborting.
- MagmaPipeIdentityTest pins both halves - no bit 10 for DirectVulkan whatever the
integration constant says, bit 10 for DirectGLES only when it is true, and that one bit
and nothing else.
- The push default becomes kMGPipeSubsystemsMigratedAtP5e (0x3fff). Inert: bit 13 is not
in kMGPipeWiredSubsystems, so no emission gate opens on it; 0x1fff survives as P5e's
"everything P4a had and nothing of mine" control.
- PipeCalls.def gains a FIFTH COLUMN, WaitClass, on every row, and gen_pipe.py generates
MGPipeWaitClassFor(op) beside kMGPipeCallFlags. It is a VALUE and not a new op: opcode
order and the flag column are untouched, and the point of generating it is that the emit
table and the sink compute the barriered predicate's static half from ONE table instead
of two copies that agree until they do not.
- Two new generator gates, each with its own --self-test negative control: a token that is
not an MGPipeWaitClass enumerator, and - both ways - a kReplySlot row that does not wait
for its reply or a kWaitReply row that owns no slot. The first would be a waiter that
hangs, the second a block on an answer nobody posts. Twelve controls trip now, up from
nine.
- set_program_bindings appended as opcode 80 (kCtxState, kVarTail|kHostSpan): the three
POST-LINK mutable reflection fields, which live inside the LinkArtifacts the archive
snapshots and all move after the link, so no existing row can carry them. It lands with
a NULL ROUTE and a sink arm that validates and declines, exactly as set_shader_buffers
has sat catalogued-and-dead since P4a.
- The codec's record layout grows to THREE tails for it, with each declared count refused
against its bound rather than truncated, and the third tail's name spans walked through
CheckHostSpanIsHonest in the decode arm - they are members of a 40-byte element, so the
encoder's blanket second-tail pass would read one span and a quarter of garbage.
- PipeCatalogueTest: the new sizes (MGPProgramDesc 200), the opcode-80 and null-route pins,
the draw flag, and a wait-class case that re-states CONTRACT-P5E section 2.2's table BY
NAME rather than reading the def - every other statement about the column is derived
from the def and moves with it, so this is the second place a demoted row has to be
argued. The ABI fingerprint moves with MGPWireOp::kOpCount, which the handshake case
already pins.
- kCapRunAheadApply (bit 10) and MGPipeRunAheadCapBitsFor(backend, ready): the arm is a
pure function so "Magma never publishes it" is a unit case rather than a line only a
live split session reaches. Espryt's half is gated on the integration constant.
- MGPProgramBindings (opcode 80's head) plus its two tail element types, the three
declared bounds, and MGPProgramDesc::LinkStatus. The descriptor grows 192 -> 200: its
four trailing Uint8s end exactly at offset 24 and MGPBlobRef is 8-aligned, so the
draft's "in the descriptor's existing pad" was wrong and there is no spare byte. The
payload is instantiated only under MOBILEGL_PIPE_PUSH, so G1 does not see it.
- kDrawClientArrays (bit 6) and kMGPipeMaxBufferBindingPoints (84). The draw flag is what
makes a client-memory vertex array visible on the wire; it is refused under run-ahead
by vi, staged by P8, and the lockstep arm is unchanged.
- MGPipeImageAccess + MGPipeDecodeImageAccess in MGPipeValueTypes.h, and the client
encode in ImageEmit.h moved onto those enumerators instead of literal 0/1/2. One table,
both roles: the server's open-coded reverse mapping and its "the encoding does not
exist" comment were the drift this closes. The encode is now a free function so the
unit case can reach both halves.
- MGPipeWaitClass, the enum PipeCalls.def's fifth column spells; kMGPipeSubsystemBufferBindings
(bit 13) with kMGPipeSubsystemsMigratedAtP5e = 0x3fff; the three suppressor slots for
set_shader_buffers, one per Class, so an emission of one class cannot cancel another's.
- Tracker.h maps dirty bits 15/16/17 onto bit 13 - inert, because the emission gate also
tests kMGPipeWiredSubsystems and the family's wired constant is still 0 - and
TrackerTest follows the phase constant, which is now the whole bit set.
- P5D-INPROC-PERFORMANCE.md: the round-2 diagnosis is corrected against the
symbolized call graph (the apply thread's mutex time was the idle poll, not
the backend pendingMutex), the four round-3 packages and the two race fixes are
described, the numbers table carries the two device caveats (monolith is
120 Hz vsync-capped in most runs; the kernel ignores thread affinity, so the
client thread runs on a mid core), and the lockstep-feasibility research says
why the deferred draw barrier waits for P3b/P4b and P11.
- MEASUREMENTS.md section 9: the per-arm table with per-thread CPU ms/frame as
the primary metric, the interleaved session and the wait/park counters.
- ROADMAP.md: the P5d row is the three-round shape; P11 gains the gPipeInputs
versioning item the research points at.
- CURRENT_STAGE_PROGRESS.md, README.md, ARCHITECTURE.md (env table: the
generate_mipmap exception to batched waits, the calibrated spin, the affinity
readback): head 1f8de61b, the gate numbers and ruling ID-79.
- The round-2 report guessed the apply thread's ~48% mutex time was the backend
pendingMutex; the symbolized call graph at 56a77348 says it is the idle poll in
ServerLoop::ApplyThreadMain taking m_controlMutex twice per iteration
(ControlIsPending in the ready lambda, PumpControlRequest), with DrainRing at
29% of the thread. Every package below is cut from that graph, not from self%.
- T (transport): the control mailbox gets an atomic shadow bit m_controlPosted so
the idle poll takes no lock (handshake, C2 critical section and the
NOT_INITIALIZED answer unchanged); Doorbell::Wait spins on a once-calibrated
iteration budget and reads no clock in steady state (the park phase alone
computes deadlines); client/server wait and park counts ride the stats line
as wait[srv= srvpark= cli= clipark=] and the wire ledger as cliwait=/clipark=.
- D (role guards): OnApplyThread() is inline - one relaxed atomic key against the
caller's thread pointer (aarch64 __builtin_thread_pointer, else
std::this_thread::get_id) - and m_applyThreadId, the function-static guard and
the pthread_equal call are gone; the three scope depth counters stop being
thread_local (their only reader is the apply-thread guard, so they count only
when OnApplyThread() is true; a GL-thread scope no longer exempts the apply
thread, pinned by a new case); the guard chains are ordered cheapest-first.
- B (persistent maps): the shadow allocator rounds the SIZE to a page multiple, so
TrackWriteMap aligns OUTWARD when the shadow is page-granular - a sub-page map
is one protected page, edges and their per-draw XXH3 disappear; the inward arm
stays as the fallback. Review found a hole the edge hashes had hidden since
round 1: a map that faulted but was not consumed by the walk that advanced the
epoch was never pushed. The walk now drains every marked member before the
binding walk, registration bumps the epoch so a fresh map ships once on the
draw path, and a non-4 KB kernel page refuses the mprotect arm by name.
- C (client per verb): an image-unit high-water mark (push build only, fed by
glBindImageTexture) replaces the 192-unit sweep per draw; the residual fill's
7-term supplied conjunction is a memo keyed on its four inputs; the server's
63-field stamp per verb is a constexpr per-class mask table.
- generate_mipmap no longer skips its wait under MOBILEGL_IPC_BATCH_WAITS: its sink
is the backend GenerateMipmap, which reads MGB_CTX->GetActiveTextureUnit(), and
a batched publish let the next validate move the serial under it -
Fatal{UnmigratedPipeInput, "GetActiveTextureUnit@ReadPixels"} on the gate tree.
The rule is written at the site: a record may skip its wait only if its apply
reads nothing the residual fill writes. The texture-death scenario gets the
framebuffer case's fence before it reads the object_death tally.
- Redmi 2f7cbe2e, FCL + Minecraft 26.3-rc-3, VD12, CPU pinned: inproc 64.3 fps
(client 15.0 ms/frame, apply 12.4) -> 103-114 fps (client 8.7-9.2 ms/frame on a
mid core, apply 7.0-7.4); monolith 115 (120 Hz vsync-capped runs) / 206 (one
uncapped run, 5.0 ms/frame on the big core). Gates: unit 2203/2203,
integration-split 111/111 (the two formerly flaky cases 6/6 on repeat),
generators, include closure, dirty surface and doc citations green.
P5D-INPROC-PERFORMANCE.md states what the phase set out to do (inproc frame
rate comparable to monolith at VD12 without touching wire semantics or the
correctness gates), what the two landed commits did (cb06538c, 56a77348:
7-13 -> 63-65 fps against monolith 110-118, pmap 25 MB/frame -> 0.23
MB/frame), and what is explicitly not done - the backend sync mutex complex
(~48% of the apply thread's own time), the R-1 serialization (P11's
versioned inputs), the emutls/set-hash small items, and the verification
debt (79-trace set, Redmi retest window, arm64-only design note, the
uncommitted FCL-side test plumbing).
Follow-up to the performance package (cb06538c): on device the epoch skip
never engaged, because the workload always has sub-page persistent maps and
any untracked (hash-arm) member vetoed it - the full binding walk ran at
every draw (~8% of the client thread at VD12), and the direct-scan
alternative measured strictly worse (41-43 vs 58-62 fps: per-draw whole-range
scans of every untracked member plus 64-slot tracker lookups per member).
The two-part answer, after the membership map started caching the tracker
slot pointer in the parent commit:
- a sub-page range no longer falls back to the hash arm: it registers with an
EMPTY interior (no protected pages, no bitmap) and hasEdges set, so its
whole range is served by the two bounded edge hashes instead of vetoing the
skip for every buffer in the process;
- the shadow's allocation alignment is a full page
(SHADOW_ALLOCATION_ALIGNMENT = 4096, GL_MIN_MAP_BUFFER_ALIGNMENT still
answers 64): every page a shadow occupies is wholly its own, so the
tracker can protect the interior without ever covering a neighbour
allocation's byte, and edge spans - and the zero-page shape itself -
virtually disappear from real workloads. Monolith shares the allocator and
is unaffected (110-118 fps A/B, no behaviour-visible change).
VD12 Minecraft, same scene: 63-65 fps (parent commit 60-66, with the skip now
engaging every steady-state draw rather than never). GetBindingPoint and the
binding walk no longer appear in the profile at all; XXH3 drops 6.1 -> 2.6%
(the rest is the set-record dedup hash, a pre-existing P5 site). Lanes:
unit 2188/2188, integration-split 110/111 with the one red again the
monolith-inert GenerateMipmapDepthPixels environmental flake.
VD12 Minecraft on the Redmi (FCL, DirectGLES): the split's in-world frame rate
13 -> 60-66 fps against monolith's 110-114, and persistent-map wire bytes
25 MB/frame -> ~0.23 MB/frame. The residual split-specific costs are named at
the end, not hidden: the R-1 serialization (WaitForApplied ~12%, P11's
versioned-inputs design) and a split-only mutex complex still under
investigation.
Persistent-map push, three arms picked per buffer at map time:
- mprotect dirty tracker: the mapped range's page-aligned interior is
protected PROT_READ; the app's next write faults, the SIGSEGV handler marks
the page in the slot's bitmap and unprotects, and the push ships one 64 KB
block per block containing a faulted page. Ownership is three tests -
SEGV_ACCERR only (a userspace raise and an unmapped page chain verbatim),
inside a live tracked range, and not a refault on a still-set bit (a write
we unprotected succeeds, so that is a non-write access and it crashes
honestly). The alignment is INWARD: an outward-aligned edge page holds
foreign bytes, and a signal-blocking worker thread faulting on one is a
process kill - that was the ctest-only SmallRing segfault. Edge spans
(at most two per map) are hashed per push and ship their containing block
only on change (unconditional edges measured 112 MB/frame). Re-arm is one
mprotect per contiguous run. The first push ships everything once.
- hash-suppress arm (MOBILEGL_IPC_PERSISTENT_HASH_SUPPRESS, default 1):
per-block XXH3 against the last push's hashes; untracked buffers only.
- PushDrawConsumers: only the buffers the upcoming draw/dispatch can read
(VAO attribs/element, indexed binding points, indirect/parameter slots,
TexBuffer backing - the last also retires the per-frame-cadence cloud
twitch), plus the fault-epoch skip: an unmoved epoch proves no interior
byte changed, tracked edges are hashed in place, and any hash-arm member
vetoes. The membership map caches the tracker slot pointer (a 64-slot
rescan per buffer per draw was measured and removed).
MOBILEGL_IPC_BATCH_WAITS (default 1; MOBILEGL_PIPE_VERIFY forces 0): a
value-class record (kCtxState/kCtxCso/kCtxObject with no reply slot) is
published without waiting for its own apply; the barrier is taken at the next
pull-reading verb, the only place BARRIER-PULLED fields are read, so the R-1
fence posts are untouched and only the round trips between them change. Two
consequences are pinned rather than argued: kCtxObject verbs the tests read
synchronously get explicit fences (GenerateMipmapFieldsCross,
FramebufferDeathCrosses), and a frontend object whose last SharedPtr drops on
the apply thread - impossible before batching - now enqueues its
(kind, lifetimeId) on the deferred destroy queue and the GL thread replays
the DestroyAndFree helper at the verb hooks (CopyImageLayeredScenario aborted
Fatal{RoleViolation, MGPipeSlots} without it).
Doorbell::Wait's spin reads the clock once per 64 yields: on the workload
device a steady_clock read is a real syscall (no vDSO) and the per-iteration
reads were ~20% of the whole process at ~1000 waits/frame.
Gates: gen_pipe --check/--self-test green; unit 2188/2188;
integration-split 110/111, the one red being GenerateMipmapDepthPixels whose
DirectGLES and DirectVulkan MONOLITH siblings flake identically with every
code path of this package provably inert (PushIsArmed false, no ClientSession,
deferral's monolith early-out).
ROADMAP marks P5c closed at the triage head and P6 next; CURRENT_STAGE_PROGRESS carries
the current-head gate table (unit 2187/2187, integration-split 111/111, the census
classification with every newly-red entry evidenced by its Fatal name, the three named G1
resizes, the 0xDD audit, the per-frame rsp measurements, and the two items deliberately
not done locally: the Redmi retest and the 79-trace census); MEASUREMENTS gains section 8
with the per-gate evidence. CONTRACT-P5C takes the amendments ratified at gt's landing
and at the regression triage: per-level extent is retired at the sites rather than
guarded on the accessor, the strict CI lane is two-sided (unit green gate plus
expected-red scenario lane with the marker asserted), the drain points are two classes
(the EGL RPC returns beside EmitAndWait), and oversized writebacks are client-sliced at a
quarter of the event ring.
The broad inproc census against a same-machine 11ac3de6 baseline found 183 newly-failing;
172 are the design-red the contract rules (the legacy-arm refusal under transport: the
subsystem-off and legacy control lanes, and Magma's P7 surface - every one carries its
Fatal name in ~/p5c-fatal-map.tsv), and the other eleven were three REAL regressions,
fixed here:
1. The surface-changed event drained at the first verb's EmitAndWait, but a pre-verb
glGetFramebufferAttachmentParameteriv on the default framebuffer was answered from the
placeholder attachments, and buffers allocated from that answer are blit-incompatible
with the real surface (DepthStencilReadbackAttachmentShapeScenario). The event ring
gains its SECOND drain-point class: the blocking EGL lifecycle RPC returns
(BackendObject_Remote's surface create/resize and make-current) - the same known-idle
premise as EmitAndWait's post-barrier point. CONTRACT-P5C section 4.1 amended.
2. A whole-buffer writeback larger than the event ring could never fit one record
(capacity/2), aborting the server with Fatal{EventRingOverflow} on the large-arena
readbacks. The client now slices the request at a quarter of the ring (header and
co-posted small events ride along), each slice round-tripping its own barrier + drain;
the whole-buffer flag is cleared by hand after the last slice lands, which is exactly
what WritebackFromBackend's clear says. CONTRACT-P5C section 4.4 amended.
3. ScatterCapturedRecords / ReadbackCapturedRanges read the capture buffers through the
frontend MappedData() (a layer-1 surface since gt) and wrote back by direct call. The
XFB capture path now reads the server's staged shadow (RequireStagedCoverage asserted,
syncedChangeSerial bumped so no later draw overwrites the capture with stale shadow)
and writes back through OnBufferWriteback, the Ops_H_Readback order.
CtWireScenario's two death cases skip on DirectVulkan by name: object_death's producer is
the Espryt-side death-notice ops and Magma installs none until P7.
Evidence: unit 2187/2187; integration-split 111/111; the three families' named cases green
again; census newly-failing reduced to the evidenced design-red set.
Layer 1 arms the texture object surfaces at MipmapStorage (the one point every concrete
texture class and view forwards through): AllocateStorage, TruncateMipmapLevels,
UpdateMipmapSubData, MapMipmapData, MarkStorageDirty(Region), IsStorageDirty,
GetStorageDirtyRegion(s) take Fatal{RoleViolation, texture-legacy-arm} from the apply
thread with an active transport. What the lane then exposed is fixed, not exempted: the
completeness gate answers from the staged store's defined-ness, the two logging reads go
through the descriptor macro, and the remint replay loop keeps its named
Fatal{UnmigratedEmulation} ahead of the guard. Magma's texture sync read path is the one
named exemption (MGPipeTextureLegacyArmScope, P7). Layer 2 wires InBarrierWait as the
client half of the gPipeInputs single-writer rule (RefusePipeInputsTouchWhileApplierOwnsIt:
applier owns it AND this thread not in a barrier wait => Fatal{RoleViolation,
gPipeInputs}; the barrier-off negative control is deferred to its own Fatal), and stale
reads of RECORD-SUPPLIED / APPLIER-DERIVED fields under a server stamp now report
RoleViolation with the field named. The RemoteGuards suite pins all nine texture surfaces
plus HasDefinedContent by name, and a negative control for the fill window. CI gains the
integration-split-strict job: unit lane as the hard gate under MOBILEGL_IPC_STRICT_ERRORS=1,
the scenario lane expected-red with the marker asserted (rewritable to plain green once P7
retires the object-class rows). rsp is per-frame in the stats line and measured on the
locally available traces (bsl in-world 948.5/frame avg over 123 frames, complementary
1591.9, iterationrp 286 until its named remint-pull Fatal); value-class pulls are zero by
rv's pinned list, what remains is the object-class fifteen.
Evidence: unit 2187/2187 (also under STRICT_ERRORS=1); integration-split 111/111; red-once
per layer (the texture guard and the gPipeInputs guard each turned their named death test
red and restored green). CONTRACT-P5C sections 5.4, 6.
set_context_values (opcode 79, MGPContextValues 96B, kCtxState) carries the active texture
unit, the max touched unit, the fifteen touched-binding-point counts and the five value-class
XFB rows, emitted at validate under the subsystem bit and hash-suppressed as a whole; the
emit gate and the residual-skip gate read the same answer so they can never disagree.
MGPipeApplySetContextValues writes gPipeInputs (the PackState shape). MGPAttribValue grows
24->56B to carry the frontend's converted three views, and MGPipeApplySetVertexAttribDefaults
finally writes them (the 'ignores MGPAttribValue::ValueClass' warning dies), so
GetCurrentVertexAttribute leaves the refusal set. The three texture shutters answer the
applier's own serials under a server-stamped verb (APPLIER_DERIVED), storage answer kept
verbatim elsewhere. FieldOwnership.def: nine value-class rows become derived RECORD_SUPPLIED,
the three shutters APPLIER_DERIVED, the nine object-class rows keep their phases;
FieldOwnershipTest pins that the remaining pulls are exactly the object-class list.
gen_pipe.py gains SCAN_EXEMPT_ACCESSORS with per-name reasons and retiring phases (it repairs
the --check gate the hd merge's two G6 probes had left red).
Evidence: unit 2175/2175; integration-split 111/111; both generators' --check and
--self-test green; rsp on the Xfb capture scenario 36 -> 22; red-once - gating the emission
off turns seven named scenarios red, restoring returns 111/111. CONTRACT-P5C section 5.3.
Table 0 gains the MGPSurfaceInfo zero-extent semantics row (ev). section 2.1 is amended
to the handle-keyed store with whole-run coverage and defined-ness tracking (tx's three
in-field rulings: a twin-address key would force glGenTextures at adopt time; region-
precise coverage false-Fatals the legal texStorage-then-small-box flow; Levels=N says
nothing about a sparse chain). section 2.3 records Espryt's driver-side mip generation
as a final shape beside Magma's store marking. section 3.1 gains the two named scoped
exemptions (the un-emitted binding records family, P4b/P7; the G6 frontend-keyed
registry family, P3b/P4b) and section 3.3/3.6 take hd's in-field shapes (the verb-handle
workspace with the state note; HasDefinedContent OR staged coverage, because the
streaming idiom defines content through subdata with no new descriptor). section 5.2
takes ct's documented NoSession server-only-fixture arm. FieldOwnership.def moves
InvalidateCompileEnv (field and forward rows) to FATAL with the deletion as mechanism
(P5c ev), regenerated .inc checked in; --check and --self-test green.
Conflict: VulkanRenderer.cpp's disaggregated include block took both halves.
Merge coordination (integrator, with the evidence inline):
- hd's Fatal{RoleViolation, MGPipeSlots} guard as landed was kind-blind: it fired on the
G6 frontend-keyed twin registry (texture/VAO/program/sampler resolver probes - P3b/P4b's
to rekey) and on the families whose handle-carrying records the client does not emit
until P4b (the buffer binding-point ensures, the GPU-written announcement). Integration-
split went 104/107 red at the first Clear. Two NAMED, scoped exemptions keep the guard's
teeth for every other caller (SlotAllocator.{h,cpp}): MGPipeReverseAnnouncementScope
(buffer ensure + announcement family, P4b/P7) and MGPipeFrontendKeyedRegistryScope
(the G6 registry family, P3b/P4b; also the mailbox death switch until ct's object_death
record retires it). ~30 resolver sites wrapped, each naming its debt in the comment.
- ScopedRestartIndexSubstitution needed NO exemption: the EBO resolves from the record's
IndexBuffer.Res and the bytes read from the server staged shadow - CONTRACT-P5B d1's
own sentence, implemented.
- Magma's named blit takes a G6 consume arm (frontend FBOs resolved by lifetime id inside
the scope, VerbBlitNamedConsumed set, loud refusal kept for a failed resolution), so the
two DirectVulkan.Split.NamedBlit correctness cases stay green; Magma's hidden blit and
depth-mipmap resources' teardown probes are wrapped and named (P7: give them non-frontend
storage).
- MG_State gains GLContext::FindFramebufferObjectByLifetimeId (behaviour-neutral accessor
for the arm above).
Gates on the merged tree: unit 2166/2166; integration-split 107/107 (2 design skips);
hd's RemoteGuards death tests still abort the unwrapped paths by name; PrimitiveRestart
7/7; the two Magma named-blit cases render. CONTRACT-P5C sections 3, 5.4.
Two appended wire records replace the two cross-role control bypasses:
applier_reset (opcode 77, MGPApplierReset{Uint64 ContextSerial}, kScreen) is emitted by
the GL thread at the FreshlyPrimed make-current edge, before the client-side resets, so
the record's barrier orders the server's MGPipeApplierReset against every verb that
follows; a direct MGPipeApplierReset() from a non-apply thread with a live session is
Fatal{RoleViolation, "g_applier"} (MGPipeApply.cpp carries the layer-2 guard; the
apply-thread and no-session bring-up shapes keep the direct call verbatim).
object_death (opcode 78, reuses MGPipeHandleOnly, kCtxObject) is the framebuffer
family's first wire delete opcode: the client thread resolves the dying object's handle
in its OWN allocator, emits nothing for a handle the server never saw, and EmitAndWaits
otherwise - the wait preserving the death's ordering against in-flight records that
name the handle, the only property the blocking mailbox hop provided. The sink releases
per-kind twins through a handle-keyed ReleaseTwinByHandle (seven kinds, SamplerViewCso's
idempotent second path kept); a null arriving handle is
Fatal{ProtocolCorruption, "ObjectDeath.Handle"}. A documented NoSession fallback arm
delivers a death to a server-only fixture's loop; monolith keeps the mailbox hop and
the FreshlyPrimed direct call character for character.
Evidence: gen_pipe --check/--self-test and gen_pipe_field_ownership --check/--self-test
green; unit 2155/2155; integration-split 111/111 (107 plus four new CtWireScenario
entries, all genuinely run, each with its own log path and an EXPECT_EXIT death case);
red-once - shorting the emission to the direct call turns the four Ct cases red with
Fatal{RoleViolation, "g_applier"} by name, then restores green verbatim.
CONTRACT-P5C sections 1, 5.1-5.2, 7.
A split-only verb-handle workspace in MGPipeApplierState (written by the sinks before
dispatch, read by the backend within the same verb) carries the record's handles to the
backend entries whose shared function-table signatures cannot grow them without moving
G1. OnBlit resolves ReadFbo/DrawFbo through the FBO twin table by handle (the named arm
of BlitFramebuffer, a loud refusal when the backend does not consume it) and the split
path's ScopedBlitBindings rebinding is deleted; CopyTex resolves MGPCopyFromFramebuffer::Dst;
the mip descriptor check resolves MGPMipPlan::Res; the indirect families resolve their
buffer handles from the records and read the bytes from the server staged shadow with
RequireStagedCoverage. EnsureBufferResourceForHandle answers HasDefinedContent from
Desc.HasDefinedContent OR the staged coverage set (the streaming idiom defines content
through subdata with no new descriptor). The minting GetOrCreate(StatePtr), HandleOf and
the death switch's lifetime-id arms take Fatal{RoleViolation, MGPipeSlots} from the apply
thread, as do the three MGPipeSlots() entry points; the frontend BufferObject legacy
accessors take Fatal{RoleViolation, buffer-legacy-arm}; Magma's four caps reads go to the
server's own backend and the client-mirror fallback arm is a named refusal.
Evidence: unit 2156/2156 (incl. 9 new RemoteF1 + 10 new RemoteGuards death tests pinning
the four Fatal names); monolith smoke green; six production mutations each turned their
named test red and were restored; c1f_redcheck M5 RED then RESTORED GREEN. The mip
descriptor check overlaps tx and is coordinated at the merge (tx's sync rekey wins the
sync body, this handle resolution wins the identity check). CONTRACT-P5C section 3.
The three reverse MGPipeCallbacks entries become the SERVER session's producer callbacks
(Reserve + fill the head + copy the payload inline + PublishEvents; the host pointer never
reaches the wire), installed at Accept and uninstalled at Close, a second installation
over a claimed entry being Fatal{RoleViolation, callback-double-install}. Producers wired:
buffer writeback (the mapped bytes are copied into the record's inline tail instead of a
raw pointer in MGPBlobRef::Offset), GPU-written (Magma's three direct MarkGpuWritten
bypasses are routed through the callback), surface-changed (the backend posts
MGPSurfaceInfo; the client consumer replays the allocate/format writes against
pDefaultFramebufferInfo on the GL thread, where R3's ownership always was), and
kEventGlError = 4 for PipeInputs::RecordError (ordering stays P9's).
PipeInputs::InvalidateCompileEnv's forward is deleted with an active transport - R-12's
caps re-publication already does its job. The consumer's monolith guard is replaced by
the three segment arms (kSegEvent resolves through the session's SegmentTable;
kMGHostSpanSegNone is monolith-only and Fatal{ProtocolCorruption} on the wire; anything
else the same), and DrainEventRing calls the client consumers BY NAME, never through the
global table. Overflow is Fatal{EventRingOverflow} - P5c's events are lossless, P9 owns
the drop policy. Monolith arms keep the pre-P5c expressions character for character.
Evidence: unit 2139/2139 (incl. the new kEventGlError round-trip), integration-split
107/107, monolith GPU subset 24/24; negative control executed - a writeback blobref
reverted to the raw-pointer shape turns DirectGLES.Split.AtomicCounterScenario.
SubDataAfterDispatchSurvivesAnImmediateReadback red with Fatal{ProtocolCorruption,
OnBufferWriteback.Seg}. CONTRACT-P5C sections 1, 4.
StagedTextureStore (MG_Remote/Server/StagedTextureStore.h), the texture twin of R-11's
StagedShadowStore: keyed by the wire handle (a twin-address key would force glGenTextures
at adopt time), coverage = the whole staged run per (uploadTarget, level), defined-ness
tracked from the respecify hook so sparse chains keep their holes. ApplyTextureUpload
adopts the staged bytes at the last instant they are alive (rule C) through three new
disaggregated-only MGPipeResourceOps members; SyncMipmapsToBackend's four arms read the
store and the descriptor instead of the client's MipmapStorage (texels, extent, target,
defined-ness, dirty); Magma's GenerateMipmap marks the server shadow Defined+GpuDirty
instead of writing the client's level storage (T5). Monolith arms reproduce the pre-tx
expressions character for character behind the same macro discipline as P4a.
Evidence: unit 2147/2147 incl. two named-Fatal death tests and the copies/no-copies
difference suite; DirectGLES.Split 104/104 on WSL GPU; red-once - reverting the adoption
to pointer-dropping turns exactly StagedTextureProductionTest red; the two monolith lane
failures reproduce on the base commit (DirectVulkan.IterationRPProgram203Scenario,
DirectVulkan.F1WireScenario.GenerateMipmapPackedFloatPixels). CONTRACT-P5C section 2.
Texture staged shadow ownership and exact per-level coverage (tx); SEG_EVENT blobref convention with Seg = kSegEvent plus the kEventGlError kind, producer ownership and the consumer segment arm that replaces the monolith guard (ev); handle-keyed sink and twin resolution with MGPipeSlots() ruled a client-only surface (hd); the applier_reset (77) and object_death (78) control records (ct); set_context_values (79) and the residual-value field table retiring every value-class BARRIER_PULLED row (rv); the two Fatal{RoleViolation} guard layers with InBarrierWait wired rather than deleted (gt); FieldOwnership.def and Table 3 amendments; the six E-P5c exit gates.
Stage updates to adopted GPU-resident buffers on the command timeline instead of overwriting memory that earlier draws still read. Wait for submitted copies as well as pending commands before CPU readback.
Commit coherent mapped writes before Android trace buffer copies, and retire buffer shadows on deletion so copies see current vertices and name reuse cannot leave dangling dirty-shadow entries.
Validated draw ordering, submitted-copy readback, traced buffer copies, and mapped-buffer deletion/name reuse on Redmi Adreno 830. Minecraft 26.3-rc-3 Magma passed movement, HUD, and menu checks and exited normally; the new trace parsed all 64,410 frames without warnings.
- VulkanRenderer::LookupVaoDrawMemo's legacy arm read MG_Config::Features.PipeHandleAbaControl
a fourth time, bypassing the one-question accessor MagmaPipeAbaControlDefeatsIdentity that
55d2af9b's own message says all the control's sites go through. It could never be false
there: the control's early return fires ahead of BOTH arms, so a run that reaches the legacy
compare has the knob off. The compare is unconditional again, with a note saying where the
question is answered and to route through the accessor if that early return is ever narrowed
- which is the trap the dead line was, since narrowing it would have silently restored D18's
retired semantics at this one site.
- HandleRecycleScenario's vertex-array case still said "BOTH the VAO and the buffer are
recycled here so that a key built out of raw addresses matches", which the body has
contradicted since 55d2af9b: both buffers are created before the ABA window and neither is
deleted inside it, deliberately, so that no buffer traffic moves VkBufferManager's
slice-epoch counter inside the window. Only the VAO is recycled, and only its GL name - the
heap block is not handed back, which is why the knob rather than the allocator constructs the
AbaControl arms' collision.
- 55d2af9b claimed - in its message, in MagmaPipeArms.h, in VertexInputStateFactory.cpp and
in MG_IntegrationTest/CMakeLists.txt - that the AbaControlHandles lane defeats the
GENERATION in {slot, gen}. It does not, and no lane of that shape can. Magma's mint has no
death notification (nothing in MG_Backend/DirectVulkan consumes NotifyStateObjectDestroyed)
and returns a slot only through OnFrameBoundary's age sweep, kSweepInterval 256 /
kRetireAgeBoundaries 1024; HandleRecycleScenario issues five frame boundaries, so the
replacement VAO acquires against an empty free list and gets a BRAND-NEW slot at Gen 1
(measured: redVao slot=2 gen=1, greenVao slot=3 gen=1). The knob-off FRESH verdict there is
decided by the SLOT alone, and deleting ++m_entries[index].Gen leaves all 32 HandleRecycle
entries green - re-measured this round.
- What the lane does defeat is the object identity that SELECTS the slot, which IS the key the
handle arm ships, and that is what the three code sites now say. The two requirements are
mutually exclusive for the pixel-visible memo: a genuine slot reuse needs >= 1024 idle
boundaries after the dead object's last draw, which necessarily puts the two draws in
different frames, and ResolvedVertexBindings - the only memo carrying a GPU slice rather
than a layout - declines across frames by design.
- So the generation is covered where it IS expressible. MG_Test/Pipe/MagmaPipeIdentityTest.cpp
drives the mint's real retire -> reuse (1280 boundaries, with a keep-alive object holding the
first allocatable slot so the reuse is not the slot the control aliases onto) and asserts
four things: the retired slot comes back with Gen+1; with the knob OFF a memo stamped at
{slot, gen=N} is NOT served at {slot, gen=N+1}; with the knob ON it IS, out of one uncleared
and unclaimed entry; and a live object keeps its slot, its generation and its memo across two
sweeps, so the generation cannot be "fixed" by bumping it on every acquisition.
- The claim rule itself moves into MagmaPipeArms.h as MagmaPipeClaimSlotMemos so the suite
exercises production code rather than a copy of it. VertexInputStateFactory::MemosFor is now
one call to it and is otherwise unchanged, on both the knob-on and the knob-off path.
- Load-bearing, measured: with ++m_entries[index].Gen commented out, ctest -L unit in
build-push goes 1563/1566 - three of the four new cases red, one of them naming the inherited
0xDEAD payload out of the same slot - while ctest -R HandleRecycle stays 32/32. Restored, all
four pass in build-push and build-verify and skip visibly in the pull build, so the ctest name
sets stay identical (G2).
- an ENVIRONMENT property overrides only the variables it names, so the gate's all-pull control
arm (MOBILEGL_PIPE_PUSH=0 over the whole integration-gpu label) leaked into the Handles and
AbaControlHandles lanes, turned their LEGACY_MEMOS=0 into D14's armless combination and made
the bring-up abort by design; the lanes now pin the P2 default bitmask themselves
- MOBILEGL_PIPE_HANDLE_ABA_CONTROL asserted the corruption and saw correct pixels, so
DirectVulkan.HandleRecycle.AbaControl.*AVertexArray* was RED in an always-on
integration-gpu lane while every guard it was supposed to be defeating stood. Two
measured reasons, neither of them the {slot, gen} re-key: (1) D18 spelled the control as
"hash the raw BufferObject* instead of its lifetime id, and skip the vaoLifetimeId
compare", which only collides if the allocator hands the freed block back - it does not.
glGen* recycles the NAME, but a VertexArrayObject is 3920 bytes, past glibc's tcache, so
its chunk goes to the unsorted bin and is split by the next allocation the replacement
path makes; four create/delete cycles in one run gave four addresses ~1 MiB apart, and
the BufferObject behaves the same. (2) The reproducer put a frame boundary between the
arming draw and the recycled draw, and the only memo that carries a GPU slice rather
than a layout - ResolvedVertexBindings - declines across frames by design, so no key
collision whatsoever could have shown up in pixels.
- The control no longer asks the allocator for the collision: on both arms it replaces the
object identity in DirectVulkan's vertex-input keys with a constant, which is the
strongest form of "the block came back" and is deterministic. Three sites, all behind
one question (MagmaPipeAbaControlDefeatsIdentity): the buffer identity leaves
VertexInputStateFactory::ComputeHash, VertexInputStateFactory::MemosFor claims one entry
without its Owner compare, and VulkanRenderer::LookupVaoDrawMemo hands one entry back
uncleared ahead of both arms.
- That is what makes the control cover the key P2 SHIPS. Under MOBILEGL_PIPE_PUSH=0 the
handle arm is not executed at all, so the old control said nothing about the generation
in {slot, gen} - the whole of what makes the re-keyed memos ABA-safe. A second lane,
DirectVulkan.HandleRecycle.AbaControlHandles., runs the handle arm with the knob and
asserts the same corruption; D18's lane is kept verbatim beside it for the pre-handle arm.
- The reproducer's two draws now share a frame, and both buffers are realised before the
window, so a moved slice epoch cannot mask the ABA behind a gate that is not about
identity. Nothing else is relaxed: the frame serial, the slice epochs and the host-map
check stay in force, so a green arm still means "a replacement object was handed its
predecessor's resolved vertex bindings because the identity halves of the keys were
defeated".
- ExpectPixelsFor now prints what it OBSERVED (STALE/FRESH/NEITHER) next to what the arm
expected, on every arm and whether or not the case passes.
- Knob-off is unchanged and the pull build is untouched: every new branch is
#if MOBILEGL_PIPE_PUSH, and symbol_report.py --threshold 0 against the pre-P2 baseline
still reports 0 added / 0 removed / 0 renamed and the same four resized symbols
(RenderState::RenderState, SetCapability, IsCapabilityEnabled, _GLOBAL__sub_I_DirectGLES.cpp).
- bench.sh's pin_freqs writes /proc/ppm and /proc/gpufreq, which exist on neither 35d0befa
(SM8750) nor 3B159D009VZ00000 (MT6993 dropped both for /proc/gpufreqv2); the guard the
PROFILE_VERIFIED key exists for was about to be defeated on the device its own comment
guessed was safe
- pin_device.sh pins big/little/GPU through the nodes each device actually has (Xiaomi:
policy6 1958400 / policy0 1555200 / kgsl pwrlevel 0; Oppo: policy4 2000000 + policy7
2000000 / policy0 1600000 / gpufreqv2 fix_target_opp_index 0), checks only against its own
pins because ColorOS moves stock maxima by itself, and refuses to call a zero-key read a verdict
- both profiles are PROFILE_VERIFIED=1 on 30 s pinned windows with zero drift; the evidence and
the two things not verified (Oppo GPU held at DVFS-request level only, no full bench window)
are in docs/Disaggregated/devices/pin-verification-2026-09-07.md
- -Pmobilegl.pipePush=ON (or MOBILEGL_PIPE_PUSH_APK=ON) passes -DMOBILEGL_PIPE_PUSH=ON to
the NDK build the way the spike opt-in already does; OFF by default so nothing shipped moves
- spans (d1a7c5f1) removed GetPixelStoreParameters from the emitted list because the call
carries the pack half of a two-element field, so the generated MGPipeFieldEmitter no longer
has a SetPixelPackState enumerator; the tracker's subsystem map and its static_assert, written
against the contract, still named it and the push and verify builds did not compile on the
integrated tree
- the NEW_PIXEL_PACK dirty bit still names the pixel-pack subsystem, which is what the
emission gate consults; the field itself stays in the residual fill loop by design
- bench.sh and session.sh are invoked as ./tools/device_bench/bench.sh in their own README
and are the harness for the paired two-device A/B the GO/NO-GO asks for; a previous commit
in this package dropped the bit and every check invoked them through bash, so nothing saw it
- The verified-profile guard read the process environment as well as the profile: the test ran
after the source, so PROFILE_VERIFIED=1 exported in an operator's shell re-opened the fail-open
hole for every profile that says nothing. Both scripts now set PROFILE_VERIFIED=0 immediately
before sourcing, so the file is the only thing that can answer.
- A --device path that cannot be sourced was diagnosed as an unverified profile, because both
scripts cd to their own directory first and neither checked readability. The path is now also
tried relative to the directory the script was invoked from (which is what a repo-root-relative
--device means), and an unreadable one is reported as unreadable, naming both places tried.
- Verified: exported PROFILE_VERIFIED=1 + an unverified profile -> rc 2; exported 1 + a profile
with no key -> rc 2; a repo-root-relative path -> resolved, then refused for its own reason;
a missing file -> "cannot read the device profile"; odinlite.env -> past the guard;
--allow-unverified-profile -> the three warnings, then proceeds.
- test.yml's new step described a mechanism the tree does not have. G6's and G10's entries are
registered in the pull build too - they must be, for G2's name-for-name comparison - and skip
inside their bodies. The step's value is unchanged and its comment now says the true thing: the
`test` job runs those names as a column of skips, and this is the first CI job that unpacks a
build which compiled the assertions.
- trace_benchmark takes the wall baseline before the CPU baseline, the order OnFrameBoundary
already reads them in, so frame 0 stops reporting a CPU delta biased upward against its own
wall delta; and it includes <time.h> rather than <ctime> for the POSIX names it uses.
- All four capability markers are now content probes over the directory the owning package owns,
through one helper. The magma pair still read a single hard-coded VertexInputStateFactory.cpp
while package D already keeps one of its two Features.PipeHandleAbaControl consumers in
Renderer/VulkanRenderer.cpp, so one file move on D's side was a permanent AbaControl skip - the
same defect the CSO probe was rewritten for. The DirectGLES probe stops asking whether
SlotTables.h exists and asks for kMGPipeSubsystemEsprytSlots, the bit the arm is actually gated
on. Every globbed file stays in CMAKE_CONFIGURE_DEPENDS, and the glob is CONFIGURE_DEPENDS.
- Verified: with one throwaway header naming each symbol, build-push configures to "keyed on
{slot, gen}" / "has an emitter" / "has a consumer" and all four MGITEST_* markers appear 24
times in the generated ctest environments; with the headers gone, all four are back to 0 and
the four "will SKIP" verdicts return. The magma sim sat in Renderer/, not in the path the old
probe hard-coded.
- g7_negative_control.sh no longer exits 0 when the control trips for the wrong reason. A
SetterConsistency that had gone red for an unrelated reason satisfied "ctest failed" and never
named SetColorMask, and the integrator's D.3 reads this script's rc. The verdict is now taken
after the restore and the rebuild - a broken build directory is worse than any exit status -
and reported as rc 1 with the output kept, alongside the existing "did not trip" rc 1.
- HandleRecycleScenario writes down what the name-recycle proxy costs: the AbaControl arm asserts
corruption that needs the heap BLOCK back, sees only the NAME, and so can red an always-on
integration-gpu lane for an allocator reason. That trade is deliberate - the alternative is an
arm that is green on the day the reproducer stops reproducing - and the consequence is now
written both in the header and at the skip that is the last thing standing between the two.
- DriverBenchStateToggle was an entry that could not fail for the reason it was added. A case
name matching nothing in kBenchCases selected nothing, run_case is void, and main returned 0
unconditionally, so renaming or dropping mc_state_toggle left the entry green while measuring
nothing - the exact state it was landed to end (ROADMAP.md:7).
- DriverBench now refuses an unknown case name before any GL work (exit 2, listing the cases it
does have), so a caller that names a case - run_driver_bench.sh included - learns the case is
gone instead of getting an empty CSV.
- Both ctest entries additionally require the case's own output row via PASS_REGULAR_EXPRESSION,
so the gate stands on the evidence rather than on that check staying in the binary. The
toggle entry pins the ops-per-frame column to 46, because the mc_* cases are deliberately
excluded from the DRIVERBENCH_DRAWS scaling and 46 toggles per frame is part of what "this
case still runs" means. A PASS_REGULAR_EXPRESSION makes ctest ignore the exit code, which is
why the row is what is checked; the comment says so.
- Verified: renaming mc_state_toggle in kBenchCases -> DriverBenchStateToggle FAILS; setting its
ops-per-frame to 45 -> FAILS; restored -> both entries pass again.
- format_benchmark printed a p50 taken with the nearest-rank rule beside a medianFrameCpuMs the
device computes as the average of the two middle frames, and documented the two as one rule; on
an even window they differ (the pre-flight printed p50=8.261ms next to medianCpuMs=271.766).
p50 now goes through series_median, which is SummarizeSeries' rule transcribed; p95 and p99 stay
nearest rank, which is the device's rule for p95 and the honest extension of it for the p99 the
device does not compute at all
- require_verified_profile treated a profile that simply omits PROFILE_VERIFIED as verified, which
is the fail-open default a profile written by copying another one inherits - exactly the case the
guard exists for. It defaults to unverified now, odinlite.env carries PROFILE_VERIFIED=1
explicitly (it is the one profile that earned it), and the refusal says "says 0, or says nothing"
- the two new profiles claimed profile.sh refuses an unverified profile; it has no such check and
needs none - it records a simpleperf profile and pins nothing. The claim is corrected in both
profiles and in the README rather than a guard added where there is nothing to guard
- the handle-ABA / CSO control step in test.yml set only MOBILEGL_ITEST_REQUIRE_GPU while its
sibling verify step sets the three MOBILEGL_MAGMA_* fixes and arms core dumps. It runs the same
DirectVulkan binary on the same runner, so a crash there left no core; it now carries both
- the three capability markers were decided from source-tree file existence / file text alone, so
after packages C and D land they would have armed the PULL build too, where every arm they name
is compiled out: the AbaControl lane would have gone hard red on `ctest -L integration-gpu`
(gate G2 requires it green in both builds) and the Handles lane green against a library with no
{slot, gen} key at all. The whole block now sits under the same `if (MOBILEGL_PIPE_PUSH)` as
MGITEST_PIPE_PUSH_BUILD, and HandleRecycleScenario re-checks that marker before either push arm
asserts, so a hand-forced environment cannot arm an arm this build does not have either
- the two push-only knobs of those lanes (MOBILEGL_PIPE_LEGACY_MEMOS=0, MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1)
are set only in a push build. In a pull build the legacy arm is the only arm and every subsystem
bit is clear, which is D14's startup Fatal{PipeLegacyMemosDisabled} - the process would abort
before the scenario could report its skip. Test NAMES are unaffected, so G2 still compares equal
- the CSO control armed itself off `MG_Impl/Pipe/Tracker.cpp`, a file the owning package does not
create: it implements the tracker and the cache header-only, so all four CsoContentAddressing
entries would have kept skipping after it landed, with a reason that had become false. The probe
now greps every source under MG_Impl/Pipe/ for the two counters the control actually reads
(RenderStateCsoMints / RenderStateCsoBinds), watching the directory and each file, so the owning
package keeps control of its file layout
- an unrecognised MGITEST_HANDLE_ARM is a FAIL in SetUp instead of a silent downgrade to the
Legacy arm, which would have passed while claiming to be the lane it was not
- CsoContentAddressingScenario reads the library's own summary line, and a log is a per-LANE
resource: the library opens it fopen(path, "w"), so every process in a lane truncates it. The
file had TWO cases in each lane, which under `ctest -j` is a race whose failure mode is an
empty read - indistinguishable from "the counters were never emitted", which is precisely the
thing the case exists to report on.
- The separate plumbing case is folded into the control as its first ASSERT, keeping its own
message, so nothing is lost but the flake. Splitting it out bought a clearer failure message
and paid for it with a flake in the mechanism that message is about.
- This is the same hazard the file's existing comments describe for the arming lane; it is worth
saying out loud that the rule is "a log-reading case owns its lane", not "a log-reading case
owns its log path".
- Verified at -j 4: 44/44 on build-verify and 24/24 on build-push, and the pull/push ctest name
lists are still identical (1402 entries each; 0 names removed against the contract tree, 34
added).
- pipe-gates stops printing gen_pipe_dirty_surface.py --summary and runs --check && --self-test.
--check fails both directions - a scanned mutator with no row in MG_Pipe/DirtySurface.def, and
a row naming a mutator the scan no longer finds - so a deleted mutator cannot leave a stale row
behind claiming coverage. --self-test is what keeps --check honest: a completeness check that
silently stopped checking is indistinguishable from a complete mapping, so two canned negative
controls must both trip. Same shape as gen_pipe.py --self-test next to it.
- integration-verify gains `ctest -L unit`. G6's chunk-table walk and G10's residual assertions
live in MG_Test/Pipe, compiled only under MOBILEGL_PIPE_PUSH, and the `test` job builds the
PULL library - so before this those tests ran in no CI job at all. The artifact already carries
them (the packaging step tars MobileGL/MG_Test whole), so the whole cost is the run: ~14 s for
~1490 entries, measured locally on this tree.
- integration-verify also runs the two always-on negative controls by name. They are labelled
integration-gpu rather than integration-verify - they are about the handle key and the CSO
switch, not the comparator - and this is the only CI job that unpacks a push build, which
CsoContentAddressingScenario needs because both counters and the cso[] bracket are
#if MOBILEGL_PIPE_PUSH.
- build-linux-verify's arming check accepts the per-verb entry point under either of its two
names. P2 renames MGPipeFillForVerb to MGPipeValidateForVerb, and a check that named only the
old one would go red on the rename for a reason unrelated to what it tests. What it tests is
unchanged: the artifact has a per-verb entry point, and it still fails when there is none.
- G2 requires `ctest -L integration-gpu` to be name-for-name IDENTICAL between the pull build and
the push build, and the four CsoContentAddressing lanes were registered inside
`if (MOBILEGL_PIPE_PUSH)`. That is four entries the push build has and the pull build does not,
which breaks the comparison for this package and for every package that lands after it.
- They now register unconditionally. What the pull build lacks is not the entry but the thing the
entry is about, so the build passes MGITEST_PIPE_PUSH_BUILD in and the scenario skips saying
exactly that: no render-state CSO exists, no cso[] bracket is compiled into the summary line,
and the content-addressing bit steers nothing.
- The marker also sharpens the plumbing assertion it guards. Past that skip the process is known
to be a push build, and the cso[] bracket is unconditional inside that same #if - so a missing
bracket can no longer mean "wrong build configuration" and the failure message stops offering
that as an explanation.
- Verified: build-linux and build-push now differ by zero ctest names (diff empty over 1408
entries each), and the pull lanes skip with the push-build reason while the push lanes skip
with the tracker-not-landed reason.
- DriverBenchStateToggle runs mc_state_toggle as its own ctest entry. The case has been in
kBenchCases since P0 and nothing executed it, so nothing would have noticed it rotting - and it
is the exact enable/draw/disable/draw shape the microbenchmark P2 owes the GO/NO-GO measures.
About 1.2 s inside an existing three-minute job.
- scripts/g7_negative_control.sh breaks the pipeline/dynamic split on purpose: it inserts two
boundaries so ColorMasks becomes a dynamic chunk of its own, which keeps the partition sorted,
non-overlapping and complete - so it still COMPILES - while making glColorMask bump
m_pipelineStateVersion without moving the pipeline-subset hash. A non-zero ctest is the pass.
- Everything that could make that control lie is refused rather than reported: a missing
SetterConsistency test exits 2 instead of reading "no tests matched" as a failure; a tree that
is already red or already broken exits 2; a patched table that does not compile exits 2, since
a build break would prove the static_asserts work rather than that the test still checks; and
the restore is from byte-for-byte copies (never from git, so a dirty tree is given back
intact), followed by a rebuild and a re-run that must be green. --verify-patch-only exercises
the mechanism where the test does not exist yet and says explicitly that it is not a pass.
- Profiles for the two campaign devices, and the guard that stops them being trusted early. Both
carry PROFILE_VERIFIED=0 and every device-specific field is TODO_VERIFY_ON_DEVICE rather than a
guess: the harness pins through MediaTek nodes and 35d0befa is a Qualcomm part, where
`su -c 'echo ... > /proc/ppm/...'` fails with a zero exit and the run would report numbers it
believes were pinned. bench.sh and session.sh now refuse an unverified profile unless
--allow-unverified-profile is passed, which warns that the run is not comparable with a pinned
one. The README records what earns PROFILE_VERIFIED=1.
- The metric the disaggregation GO/NO-GO hangs on is per-thread CPU p50/p99, and the tree had no
first-party collector for it: no CLOCK_THREAD_CPUTIME_ID, no getrusage, no RUSAGE_THREAD, no
/proc/self/task anywhere under MobileGL/, tools/, android-plugin/ or scripts/.
- The retrace loop is the cheapest honest place to take it. Retrace runs --singlethread and
trace_benchmark states that Begin/OnFrameBoundary/End are only ever reached from that one
thread, so that thread's CPU time IS the client-side CPU cost: one extra clock_gettime per
frame, no root, no profiler, no debuggable build, no sampling - and no instrumentation
committed to a hot path inside the library.
- The CPU reading is taken before the wall reading at each boundary, so the syscall lands in the
wall delta rather than hiding inside the CPU delta. An inflated wall number is visible; a
deflated CPU number would not be.
- Report::frameCpuMs is the same length as frameMs or it is EMPTY. A clock that started failing
mid-run would otherwise be silently re-indexed and put frame N next to frame N+k, and an empty
series and a series of zeroes are different claims about the platform.
- SummarizeBenchmark is split into SummarizeSeries and reused verbatim for the CPU series rather
than duplicated: same tail window, same even-count median rule, same nearest-rank p95, or the
delta between the two series stops meaning anything.
- benchmark.json gains meanFrameCpuMs / medianFrameCpuMs / p95FrameCpuMs and the WHOLE
frameCpuTimesMs[] array beside frameTimesMs[]; result.json and the completion line gain the
three headline numbers. p99 therefore needs no device change - it is a host-side reduction over
an artefact that already exists, and run_android_retrace_local.py prints p50/p95/p99 off the
same trailing window the device summarised.
- Pre-flighted on the desktop CLI, which shares the same core: 2-frame run, cpu series aligned
with the wall series and strictly below it (wall 1290.787/20.225 ms, cpu 535.271/8.261 ms).
- HandleRecycleScenario builds the ABA the Track H re-key has to survive: an object is drawn
for three frames so every per-object memo is armed against it, unbound so its last SharedPtr
drops, deleted, and replaced immediately by one with a byte-identical configuration and
different contents. Three kinds - a vertex array whose buffer is recycled with it, a texture,
a framebuffer - and the readback must come from the replacement.
- The reproducer is asserted, not assumed. TheReproducerRecyclesEveryName pins that the name
allocators hand every deleted name straight back, and a case whose names were not recycled
SKIPS as "inconclusive, not proven" rather than passing - the shape ObjectLifetimeIdTest
already uses.
- Three always-on arms, one ctest lane each, named by the harness marker MGITEST_HANDLE_ARM:
Handles ({slot, gen} only), Legacy (today's lifetimeId + weak_ptr guards) and AbaControl
(MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1, which expects the CORRUPTION so that a reproducer that
stopped reproducing is a red rather than a quieter green). AbaControl is DirectVulkan only:
the knob reverts two DirectVulkan guards and steers nothing on DirectGLES.
- CsoContentAddressingScenario is the G12 control. A Blaze3D blend toggle - enable/draw/
disable/draw x 8 inside one frame - must mint a BOUNDED number of CSOs with content
addressing on and exactly one per bind with bit 63 of MOBILEGL_PIPE_PUSH set, while the
pixels do not move at all. csom == csob is the reading a dead switch cannot produce.
- The counters are read from the library's own "MGPipe stats:" line, because PipeStats is
internal and this module links the shipping library on Android. Each arm therefore gets
MOBILEGL_PIPE_STATS_PERIOD=1 and a private MOBILEGL_LOG_FILE_PATH, the same per-lane rule
the arming lane already follows, and the workload is bracketed by two swaps so the window
covers itself and nothing else.
- Both scenarios skip in the ambient entries, which configure none of the knobs their arms are
about, and both name what is missing when the package they depend on has not landed. What
decides that is the BUILD, not a hand-written guard: CMakeLists looks for
DirectGLES/SlotTables.h, MG_Impl/Pipe/Tracker.cpp and the two markers inside
VertexInputStateFactory.cpp, prints each verdict, and re-evaluates through CONFIGURE_DEPENDS -
so the arms arm themselves when packages B, C and D land.
- The live-object high-water mark is the number review v2's MAJOR 1 wants measured on
minecraft-1.21.4-in-world and ...-sodium-in-world, and no desktop gate can produce it. It
was emitted at MGLOG_D, which is compiled out of every build that ships and of every build
P2 measures, so the line existed only in a configuration nobody runs.
- MGLOG_I instead, still only on the allocate-a-new-slot branch and still only at powers of
two from 1024 up: at most a handful of lines for a whole session, never one on a draw
(ROADMAP.md:7). Declared as a narrow deviation from D20's "MGLOG_D for anything
non-critical" in the comment beside it.
- MagmaPipeIdentityTable was a FIXED 2048/8192-entry, 2-way set-associative LRU. Above
capacity it evicted LIVE objects, and every memo keyed on the handle died with them: a
verbatim transcription of the previous Acquire lost 54% of uses' handles at 2500 live VAOs
against 2048 entries, and 20% at 1024 live VAOs once the lifetime ids are sparse (an app
that creates and destroys VAOs - the Minecraft chunk shape this exists for).
- Two of the three memos it fed had NO capacity before this package: the content-hash memo
and the resolved-state memo were unbounded mutable fields on VertexArrayObject. Eviction
there turns one ComputeHash per VAO reconfiguration into one per DRAW; once the buffer
table thrashes too, the vertex-input content hash becomes a per-draw value that inserts a
fresh heap-allocated BackendVertexInputState into an unbounded map on every draw, swept
only every 256 frame boundaries. That is a worse leak than the one the fixed table was
introduced to avoid.
- So the mint grows on demand and reclaims by AGE: a lifetime-id map with a one-entry front
memo, a free list, and an OnFrameBoundary sweep on the same cadence and retirement age as
the cache entries those slots key. Footprint tracks the live DRAWN working set instead of
objects ever created, which is the property MG_Impl/Pipe/SlotAllocator cannot have here
(nothing in P2 can call its Free). Re-run of the same workloads: handle churn is 0.0% at
512, 1024, 2048, 2500, 3000, 4000, 8192, 10000 and 16000 live objects, consecutive and
sparse ids alike, at 1 and 5 acquisitions per use.
- VertexInputStateFactory::m_vaoMemos follows the mint with no capacity of its own, through a
chunked table whose entry addresses never move - which is what the fixed table's only real
guarantee was, and D12.4's grow-on-demand ask without a relocating Vector.
- VulkanRenderer::m_vaoDrawMemoTable deliberately keeps the base ref's 2048 entries and the
base ref's older-frameSerial victim rule, and changes only its KEY. It is the one memo of
the three that had a capacity before P2, a VaoDrawMemo is ~450 B, and losing one costs one
vertex-binding re-resolve. Measured steady-state miss rate against the base ref's
address-hashed table: 0.0% vs 6.4% at 512 live VAOs, 0.0% vs 24.0% at 1024, 0.0% vs 60.0%
at 2048, 36.2% vs 69.5% at 2500, 63.5% vs 79.1% at 3000; both are ~100% at 4096 (2x
capacity), where an LRU on a cyclic pattern cannot win.
- The two tables are now a MagmaPipeIdentityTables member of VulkanRenderer, handed to its
VertexInputStateFactory, instead of two function-local statics that outlived every context
and shared one reclamation clock across two.
- A Gen that reaches 2^32-1 retires its slot for good rather than wrapping. MOBILEGL_ASSERT
is compiled out of every build P2 runs, and a DEBUG-level build of this tree does not
compile at all (MG_Util/Types.h uses MOBILEGL_ASSERT before MGLOG_F is declared - untouched
since the base ref, and not this package's file), so the defence has to be on the release
path to exist.
- The no-CSO pipeline-memo fallback stops using MGLOG_W_ONCE. MOBILEGL_LOG_ONCE_INTERNAL is
an unconditional std::atomic_flag::test_and_set - a locked xchg per evaluation, not "one
static bool test" - and this site is on the per-draw path in exactly the configuration that
reaches it. A plain per-renderer bool replaces it, and the comment now says what the
warning's absence does and does not prove (nothing at all while bit 0 is clear).
- MOBILEGL_PIPE_LEGACY_MEMOS=0 with kMGPipeSubsystemRenderState clear still runs the
pre-handle state hash - there is a correct answer there and bit 0 is not Track H, so it is
not fatal - but it is no longer silent: the startup gate names the combination.
- The D12.3 static_assert block now names D19's DynamicChunksCoverMagmasDynamicTailKey, whose
ctest entry lives in package A's file, so the integrator can see which half is missing.
- MagmaPipeSlotIndex subtracted kMGPipeFirstAllocatableSlot unconditionally, so a null
handle would have indexed a per-slot table at ~0u. It is unreachable - both lifetime-id
sources start at 1, which is why Acquire's zero guard never fires - but the consequence
of being wrong about that is an out-of-range write, not a wrong answer, so it is asserted
and the index is pinned to 0 in a release build.
- MOBILEGL_PIPE_LEGACY_MEMOS=0 no longer aborts a draw. D14 spends that lever at
STARTUP and only on a Track-H subsystem, so MagmaPipeValidateSubsystemConfiguration
runs once from VulkanRenderer::Initialize and checks bit 6 alone: an Espryt-side
bitmask cannot kill a Magma run, and bit 0 - which is not Track H and not a memo
re-key - is out of the lever's scope entirely. MOBILEGL_PIPE_PUSH=0x60 with the
lever off went from 9/9 aborted to 432/432.
- A pipeline memo with no render-state CSO bound falls back instead of aborting.
delete_render_state clears the binding, so the null handle is reachable on any
tree; the fallback is the pre-handle state hash where one is compiled, and the
client's own MGPipeComputePipelineSubsetHash over the same 396 pipeline bytes
where it is not - which is what makes -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF a runnable
configuration (180/432 aborted before, 432/432 now) instead of a build that dies
on its first draw. The fallback warns once, so a run that never keys on a CSO
handle says so in its log instead of passing silently.
- The handles are minted by a fixed-capacity, self-recycling identity table in the
backend, not by MG_Impl's client allocator. Nothing in P2 frees a VertexElementsCso
or Buffer slot - the frontend has no death notification Magma can hook - so the
allocator's live Allocate and dead Free grew one SlotState plus one hash-map node
per object ever created, for the life of the process. The table is 2-way
set-associative with an LRU victim and a Gen bump on reuse: bounded (32 KB for
VAOs, 128 KB for buffers), exactly as ABA-proof, and it takes MG_Backend's only
include of MG_Impl back out.
- Both per-slot memo tables are now a BIJECTION with that mint rather than a masked
direct map, so two live VAOs cannot share an entry and the eviction decision lives
once, in the identity table, instead of once per consumer table. The density claim
the masked tables rested on was false while slots grew monotonically, and the
masked form had also dropped the second candidate and the frame-serial victim
choice the address-hashed table used to have.
- snap.vaoHandle is stamped only when bit 6 is on. It was guarded by the compile
switch alone, so MOBILEGL_PIPE_PUSH=0 - the all-pull control D14 defines as
reproducing P1 exactly, and the arm D.4.3's T2 is measured on - paid a mint per
new VAO and a compare per draw for a field that arm never reads.
- Every re-keyed Track-H site now asks the same MagmaPipeTrackHArmIsHandles helper,
including VertexInputStateFactory::ComputeHash, which decided for itself before
and could key on the pre-handle identity while its neighbours keyed on the handle.
- The pull build's two pipeline-memo sites keep the base ref's text statement for
statement: G1 is back to the contract's four resized symbols, 0 added/removed.
- P2 D12.5 (ARCHITECTURE.md 9.5). VertexArrayObject carried three `mutable` memos for the
backend: a content hash, a raw pointer into VertexInputStateFactory's heap-allocated
cache entry plus that cache's eviction epoch, and two aux words. A frontend state object
holding the backend's pointer is what P2 retires - under split the backend is in another
process and its cache entry has no address a client could store.
- The hash and state memos move into a slot-indexed table the FACTORY owns, keyed on the
VAO's {slot, gen} and guarded by exactly the same config version, so nothing is
recomputed more often than it was. Fixed and direct-mapped for the same reason m3's
VaoDrawMemo table is: nothing frees a VertexElementsCso slot in P2, so a grow-on-demand
table would keep one entry per VAO ever created. 2048 x 48 B is 96 KB.
- The AUX memo is deleted rather than moved, as the brief says: its two words already live
in VulkanRenderer::VaoDrawMemo (layoutHash / layoutAuxMasks) and GetBackendAuxMemo has no
live reader anywhere in the tree - the only writer was the line this commit stops
executing.
- The eviction-epoch dance shrinks with them. The PROCESS-WIDE s_evictionEpochSource exists
because the memos live on frontend VAOs and therefore outlive the factory; the handle
arm's table dies with the factory, so a per-instance counter is enough there. The epoch
itself stays - it guards the POINTEE, which is still a cache entry a frame boundary can
erase, and moving the memo does not change that. (The brief reads as if a slot-indexed
table removes the need for an epoch; it removes the need for a process-wide one.)
- The three draw-path readers that asked the VAO "is your content hash already memoized?"
now ask whichever side owns the memo, through a force-inlined wrapper so the PULL build's
two loads stay two loads.
- All three accessors and their storage are kept under MOBILEGL_PIPE_LEGACY_MEMOS rather
than deleted from the file, because that is the arm the pre-handle A/B runs (D14) and
because a pull build forces the option ON, where G1 admits no change at all. Configuring
with -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF is what makes the deletion real, and that build
compiles clean - which is the check that nothing else still reaches for them.
- Verification: pull symbol_report --threshold 0 is 0 added / 0 removed / 0 renamed with
the contract's four resizes and no fifth; ctest -L unit 1489/1489 in both the pull and
the push build; ctest -L integration-gpu -R DirectVulkan 432/432 under the default
bitmask and 432/432 under MOBILEGL_PIPE_PUSH=0. The LEGACY_MEMOS=OFF build compiles but
cannot RUN on this tree, and that is the D14 gate working rather than a defect: no
tracker binds a render-state CSO here, so the handle arm has no key and
Fatal{PipeLegacyMemosDisabled} fires at the first draw instead of the memo quietly
aliasing every render state onto one entry. Re-run it once p2/tracker has landed.
- Track H subsystem 4 (P2 brief D12.4, ARCHITECTURE.md 9.5), behind
kMGPipeSubsystemMagmaVertexInput.
- VertexInputStateFactory::ComputeHash's buffer identity component becomes the buffer's
{slot, gen} - "lifetimeId -> gen mixed into every server-side content hash". Both are
equally ABA-proof (the allocator maps one onto the other and bumps Gen only on slot
REUSE); what changes is that the hash now carries the identity the SERVER will be handed
once buffers travel as handles, instead of a number only the client can mint.
- LookupVaoDrawMemo becomes a direct slot index: the slot IS the index, and the whole
validation is one handle compare. Gone with the re-key are the Fibonacci mix of the VAO's
address, the two-way probe, the frame-serial eviction choice and the (pointer, lifetime
id) pair - slots are dense by construction, so consecutive VAOs land in consecutive
entries and the collision the address hash existed to spread does not arise below the
table size.
- The table stays FIXED at 2048 entries and the slot index wraps, where the brief calls
for a grow-on-demand vector. Reason, and it is a tree fact the brief does not carry:
nothing in P2 frees a VertexElementsCso slot. The frontend death notification is Espryt
0b's e2 and it covers Espryt's six kinds; buffers are the only kind with an OnDestroy
hook today. A grow-on-demand table would therefore hold one ~1 KB VaoDrawMemo per VAO
EVER created, which on a chunk-cycling Minecraft frame is tens of megabytes. Above the
table size this degrades to a direct-mapped cache validated by the full {slot, gen}:
never wrong, only colder, and strictly better than the address hash it replaces.
Revisit when object deletion reaches the client allocator.
- SetupDrawSnapshot's VAO identity collapses to the same handle - one compare instead of
(address, lifetime id) - so the snapshot and the draw memo cannot disagree about whether
the VAO moved. The config version stays: it answers a different question.
- Handle acquisition sits behind a one-entry memo in the renderer. Acquiring is a hash
probe into the allocator's lifetimeId -> slot map and LookupVaoDrawMemo runs per draw, so
without it the arm would have swapped the address hash it deletes for another probe; a
run of draws over one VAO now pays a single Uint64 compare. Magma acquires the handles
itself because the tracker does not emit object-class state in P2 (it emits for dirty
bits 0-4 only); when it does, these become reads of what the client already sent.
- Negative control C (MOBILEGL_PIPE_HANDLE_ABA_CONTROL, brief D18) is implemented here
because the two guards it defeats live here: it makes ComputeHash hash the raw
BufferObject* and makes LookupVaoDrawMemo skip the lifetime-id compare - the exact state
the table was in before the ABA fix. It applies to the PRE-HANDLE arm, which is what
HandleRecycleScenario.AbaControl runs (MOBILEGL_PIPE_PUSH=0), and it is what proves that
scenario's reproducer still reproduces instead of passing for the wrong reason.
- Verification on this tree: ctest -L integration-gpu -R DirectVulkan is 432/432 under the
default bitmask and 432/432 under MOBILEGL_PIPE_PUSH=0, and -L unit is green. Pull build
symbol_report --threshold 0: 0 added / 0 removed / 0 renamed, 4 resized, all four the
contract commit's.
- P2 D12.3. ApplyDynamicDrawStateTail keeps reading GetRenderStateParametersVersion, and
under MOBILEGL_PIPE_PUSH that accessor is RE-SOURCED: it returns
PipeInputs::m_renderStateParametersVersion, which the applier publishes from
MGPDynamicState::Version and MGPBindRenderState::Version. The gate now reads what the
client pushed rather than what the backend pulled.
- The brief expects the same change to stop a PIPELINE-only change invalidating the tail.
It does not, and the tree is right against the brief: the applier publishes
bind_render_state's Version into the same counter, and it has to - Espryt's
SyncRenderState uses that counter as its all-state change detector and G5 forbids
touching one line of it, so a bind that rewrote the pipeline half while leaving the
counter still would make Espryt skip re-syncing the state it just changed. Getting the
finer gate needs a second, dynamic-only version on the wire, which is a CONTRACT change;
recorded for the integrator rather than smuggled in here. The second-level DynamicTailKey
compare already absorbs a pipeline-only change at the cost of one key build and no
vkCmd*, exactly as it did before P2.
- The coverage check D12.3 asks for, as static_asserts rather than a unit test: every
RenderStateParameters member DynamicTailKey reads is checked against the P2 chunk table
(MGPipeRenderStateSpans.h), including the three stencil members PER FACE, since D6
splits StencilFaceState through the middle. The tail's hand-written input inventory and
the offsetof-derived chunk table were written for different reasons, so making them
check each other is free evidence, and a chunk edit that demoted one of these is a build
break here instead of a tail that stops being re-run when its input moves. A ctest entry
would have had to live in MG_Test/Pipe/RenderStateSpansTest.cpp, which the ownership
table gives to package A; a static_assert in the file that owns the reader is both
in-scope and stricter.
- ScissorTestEnabledMask is the one input that is NOT dynamic, and the brief says it
should be. The tree wins: the split's only rule is "pipeline iff a setter that calls
BumpVersions writes it", and SetCapability(ScissorTest) does, so it sits in pipeline
chunk P6 with the other capability bools. It is pinned with the assertion INVERTED, so
demoting it - which would be a real G7 violation - is also a build break. Reading it in
the tail stays harmless because BumpVersions moves both counters together.
- Push-only: the whole block is inside MOBILEGL_PIPE_PUSH and the pull build is unchanged
(symbol_report --threshold 0: 0 added / 0 removed / 0 renamed, 4 resized, all four the
contract commit's).
- P2 D12.1. GetOrCreatePipeline's memo compared a VALUE hash of the pipeline-relevant
fixed-function state that Magma recomputed itself. After P2 the CLIENT hashes exactly
those bytes when it mints a content-addressed render-state CSO
(MGPipeComputePipelineSubsetHash over the seven pipeline chunks), so the bound CSO
handle IS that key and ComputePipelineStateHash was doing the boundary's work twice.
The client's pipeline subset is a strict SUPERSET of the 24 members the hash read, so
the handle discriminates at least as finely as the hash it replaces.
- renderPassHash STAYS in the key, and that is load-bearing rather than conservative:
ComputePipelineStateHash was never a pure function of RenderStateParameters - its
signature took colorAttachmentCount and rasterizationSamples, and
ResolveEffectiveSampleMask reads the latter - so those two render-pass facts have to
stay separated by something. entry.renderPassHash already separates them (the pass
hash folds each attachment's sample count and the attachment set), which is why
collapsing the state half onto a handle loses no discrimination.
ResolveEffectiveSampleMask is NOT deleted with the hash: it is a payload computation,
and it keeps reading Multisample / SampleMask / SampleMaskValue out of the working
block.
- Both memo probes are re-keyed, not just the full path's: TrySetupDrawFastPath carries
its own copy of the probe, and a fast path that keyed differently from the full path
would hand back a pipeline the full path would not have matched.
- The arm is chosen at runtime, per D14: kMGPipeSubsystemRenderState in the
MOBILEGL_PIPE_PUSH bitmask AND a non-null bound CSO. The second half is not belt and
braces - a tree whose tracker does not emit create/bind_render_state yet has no handle
to key on, and keying every draw on the null handle would alias every render state onto
one memo entry. Falling into the pre-handle arm with Features.PipeLegacyMemos=0 is
Fatal{PipeLegacyMemosDisabled}, so HandleRecycleScenario.Handles cannot go green by
quietly running the old code.
- ComputePipelineStateHash and its five cached-hash members (m_pipelineStateHash{,Valid,
Version,ColorCount,SampleCount}) survive only under MOBILEGL_PIPE_LEGACY_MEMOS, which
a pull build forces ON: they exist purely to avoid re-hashing, and the handle arm never
hashes. InvalidatePipelineMemo loses them on the same condition.
- New MagmaPipeArms.h holds the two-switch arm selector shared by the P2 Magma re-keys.
- G1, pull build, symbol_report --threshold 0 against ~/w7/p2-before-libMobileGL.so:
0 added, 0 removed, 0 renamed, 4 resized - and all four are the CONTRACT commit's
(RenderState::{RenderState,SetCapability,IsCapabilityEnabled} and
_GLOBAL__sub_I_DirectGLES.cpp). This commit adds none: every edit is inside a
MOBILEGL_PIPE_PUSH arm and the pre-handle statements are left where they stood, which
is why ResolveBoundRenderStateCso is push-only rather than a shared helper - an earlier
shared-helper shape moved 104 bytes of GetOrCreatePipeline around for no behaviour
change and the gate saw it.
- The writer side recorded a member-rooted write bound to a reference (`for (auto& blendState : m_parameters.BlendStates)`) as the field alone, so seven RenderState setters read as writing nothing; the reader side resolved `render.PatchVertices` to the whole of m_parameters, so every setter that touched any byte of it "supported" NEW_PATCH_STATE and a row saying glClearColor publishes the patch state was green. Both were the same defect: the two sides did not resolve to the same token.
- Both sides now carry MEM:<member> and FIELD:<member>.<leaf>; a whole-member write or read is every field. A reference, pointer or range-for alias bound to a member-rooted lvalue is followed (rebinds and aliases of aliases included), a write through a call-result lvalue and a mutating call on a member-rooted lvalue count as whole writes, a const alias cannot be written through with `.`.
- A write, or a non-read-only method call, whose root the analysis cannot place - a reference parameter, a call result, a member without the m_ prefix, an unattributable assignment operator - taints the function; the taint rides the call-graph fixed point and every (row, bit) that depends on a tainted function is UNDECIDED, never a verdict. Nothing is trusted by name.
- Match rule: a writer supports a bit iff the two sides share a member and, both field-resolved, their field sets intersect; a member in common with no field information on one side is COARSE, reported and never counted; UNDER-FIRING only when both sides are resolved and disjoint for every member the shutter reads.
- --check counts only supported answers as derived, prints the COARSE and UNDECIDED tallies, and fails on an UNDECIDED row unless MGP_DIRTY_SURFACE_UNDECIDED_LIST in DirtySurface.def marks it; a mark on a row the derivation decides is a red gate too. The list is empty: all 8 non-render bit answers are supported at field level, and NEW_PATCH_STATE has exactly three legal carriers again.
- --self-test grows from 10 to 21 negative controls, including the synthetic bodies of every shape above through the real extractor, the NEW_PATCH_STATE analogue of the value-class control, the taint, COARSE and stale-mark paths, and positive controls for SetPixelStoreParam's pasted writes and the seven alias setters.
- the write analysis under-approximated in the exact direction its own claim forbids:
written_tokens recorded a write through a member's field (m_foo.bar = v) as FIELD:bar
and never as MEM:m_foo, while resolve_reader resolves a shutter's accessor to
MEM:m_foo, so for any struct-valued member the two halves could not meet; a new
MEMBER_ROOTED_WRITE_RE records both, for m_x.f, m_x[i].f, m_x->f and nested
- it could not see RenderState::SetPixelStoreParam's sixteen writes twice over, because
they are spelled with the token-pasting operator and the file was read raw - the
"field" it recorded was the macro parameter name, paramNameTail. The derivation now
expands the function-like macros defined under its two roots (directives blanked,
parameters substituted, ## pasted), which is also what makes SET_CAPABILITY's
m_parameters.capability##Enabled writes visible
- and it now DECLINES rather than answers wherever it cannot say it read every writer:
a body carrying a construct it does not model (an unexpandable token paste), anything
that reaches such a body through the call-graph fixed point the writes already travel,
and any shutter member with a write-shaped occurrence outside the analysed roots.
--check prints every decline with its site, plus how many bodies and files the absence
claim rests on and the one place it stays coarse
- the BitwiseEqual bits' shutter window now also starts at the last `}` before the
`dirty |=`, so the pack block's trailing `m_pack = pack;` no longer leaks the pixel
store into NEW_PATCH_STATE's reader set
- consequence in the map: X(SetPixelStoreParam, NEW_PIXEL_PACK) went from a verdict the
gate could not support - no function name in the tree could carry that bit - to an
accepted, checked answer, and the row it forced (kPulledEveryVerb, documented as "no
shutter exists, and none is needed yet") said that of the only mutator behind the
shipped set_pixel_pack_state. The row is now kPulledPartialShutter|NEW_PIXEL_PACK: the
pull is what holds on every mutating path, the bit moves on the eight Pack arms, and
both facts are machine-readable for the P3a reader D16 writes this file for
- --self-test grows from 7 negative controls to 10 - kPulledPartialShutter naming no
bit, a mutator whose write analysis is incomplete, and a shutter member written
outside the roots, the last two asserting a DECLINE and no verdict - and gains a
positive control that fails if SetPixelStoreParam's pasted writes ever go unread again
- X(SetNamedTransformFeedbackBinding, NEW_SO_TARGETS) was false on EVERY path: that
mutator binds a BufferState binding point or writes a saved-bindings entry, while the
bit's shutter mixes the buffer-CONTENT aggregate with the transform-feedback
generation, and a binding moves neither. It answers kPulledEveryVerb, which is what
reaches the backend today (GetBufferBindingPoint, in the class's may-read mask).
- X(SetPixelStoreParam, NEW_PIXEL_PACK) was false on the eight Unpack arms: the setter
writes both halves and the tracker's bit 2 is a byte compare of the PACK half alone,
because set_pixel_pack_state deliberately has no unpack counterpart. It answers
kPulledEveryVerb, the one publisher every arm has.
- --check no longer rubber-stamps the 28 rows the RenderState derivation cannot reach.
It reads Tracker.h's Update() for what each bit's shutter READS, resolves those
accessors through MG_State's getters to the members behind them, computes what every
mutator transitively WRITES as a fixed point over MG_State/GLState and MG_Impl/Pipe
(expanding MGP_NOTE_AGGREGATE through MGPipeNoteAggregate's own switch rather than
assuming the hop), and fails a row naming a bit whose shutter its mutator moves on no
path. One-directional by construction: the write analysis over-approximates, so it can
prove absence and not presence, and absence is the under-firing direction.
- the enumerator spelling and the NEW_* spelling are paired BY POSITION out of Tracker.h,
so the enum and kMGPipeDirtyNames drifting apart is itself a gate failure.
- two more self-test negative controls, one per family, both built from the defect that
was really in the file; 7 controls now, all tripping.
- --check prints what it did NOT check: how many rows carry a prose answer, and every row
the derivation declined, so "all mapped" cannot be read as "all verified".
- render_state_publishers() folds the bodies of one name with INTERSECTION, so two
overloads - one BumpVersions, one bare ++m_version - can no longer derive as "both
always fire" and bless an under-firing row.
- the header states what "every path" means: every path that MUTATES, so a redundant-write
guard does not make its publisher conditional, while a publisher reached on only some
mutating paths must not be named.
- the residual value block armed on NEW_PIPELINE_STATE, so glEnable(GL_CLIP_DISTANCE0)
never re-armed it: SetCapability's ClipDistance0..7 arms are deliberately not
BumpVersions() and those eight are 8 of the 35 CapabilityInputs the block carries, so
the D9/G10 trip wire was disarmed for them for an unbounded window - and invisibly, a
block that is never emitted cannot diverge. It now arms on either render-state counter,
the same answer DirtySurface.def derives for SetCapability, and the comment that
asserted the opposite ("every SET_CAPABILITY arm calls BumpVersions") is corrected.
- the arming moved outside the residual subsystem gate: whether the capability set may
have moved is a fact about the frontend, not about which subsystems this build pushes.
- set_vertex_attrib_defaults published NOTHING across a context change. Tracker::Reset()
sets the staging mirror to the GL defaults and a fresh GLContext holds the same, so the
per-attribute diff was empty on the one walk that must publish a COMPLETE state, while
MGPipeApplierReset() leaves gPipeInputs.m_currentVertexAttribute holding the previous
context's values - which cancelled, two lines later, the InvalidateAll() written for
exactly that case. It now sends all 32 when the tracker is freshly primed, the arm
EmitRenderState already had.
- the fresh-context reset of the CSO cache and the applier moved out of EmitRenderState,
which runs only when bit 0 of MOBILEGL_PIPE_PUSH is set: the per-subsystem A/B D14
invites gave a fresh context a never-reset applier while every suppressor slot was
invalidated.
- MGPipeVertexAttribDefaultsLastHeader() is the observable for both properties of that
call that cannot be read back without a poisoned read of m_currentVertexAttribute.
- the repair case now asserts the invariant (the call named exactly what moved, at most
one repair) instead of repairs == before + 1, which pinned today's applier and would
have gone red the day package A honours MGPAttribValue::ValueClass.
- a static_assert that no MGPipeDirty bit owns kMGPipeSubsystemResidualValues, which is
what makes the residual block's direct subsystem test the one safe exception to
MGPipeSubsystemForDirty, and a note that the NEW_PIPELINE_STATE/NEW_RENDER_STATE
MOBILEGL_ASSERT is a debug/verify alarm over behaviour that is safe in every build.
- the kUnpublishedDestroy note said the three kinds were outside the six D13 scopes explicit
destroy to; the tree's reason is sharper and does not depend on reading D13's prose one way
or the other: those three have no per-object handle on the wire at all, so their DirectGLES
twins are still reclaimed by the backend's own registry teardown
- records the brief/tree disagreement in place: D13 says "six kinds" while the Core.cpp line
ranges it cites also cover MarkProgram/MarkShaderForDeletion
- set_vertex_attrib_defaults hard-coded MGPAttribValue::ValueClass to 0 for every attribute
and always sent the FLOAT view's four words. A CurrentVertexAttributeValue is one value in
three views and GLContext converts numerically between them, so those bytes cannot
reproduce the frontend value: glVertexAttrib4f(loc, 1.5f, ..) leaves 1 in intValue and
0x3FC00000 in floatValue, and every glVertexAttribI4i/ui default was wrong too
- GLContext now records which view each glVertexAttrib* write filled directly
(GetCurrentVertexAttributeClass, push-only) and the payload carries that class and THAT
class's own words. It is kept beside the value rather than inside it because
CurrentVertexAttributeValue is mirrored into PipeInputs and compared there by a memcmp
whose size assertion lives in a file this package does not own
- MGPipeFillAttribValue is the flattening, in one named place, so TrackerAttribPayload can
pin it: the old defect turns three of its four cases red
- the applier (package A's) still memcpys the four words into all three views regardless of
ValueClass, so the emitter now CHECKS: it compares the mirror the applier wrote against the
frontend's value and, when they differ, copies the field itself and says so once. That
closes the window the old code left wrong - a glVertexAttrib* write followed by a verb
whose class does not read the field, where the residual fill does not run for it - and it
stops repairing by itself the day A's applier honours the class
- MGPipeVertexAttribDefaultRepairCount() makes that repair observable to a test without
reading storage the fill table forbids that verb to read
- the emission gate now goes through MGPipeSubsystemForDirty, the one bit-to-subsystem map,
instead of a second copy of it written out by hand at the validate point; five
static_asserts tie that map to the field-emitter map it has to agree with
- the staging mirror is advanced only by the branch that sent dynamic bytes, with the
invariant it used to rely on (BumpVersions moves both counters, RenderState.h) asserted
here rather than assumed of another package's file
- TrackerShippedEmitter drives MGPipeValidateForVerb itself and reads the real singletons
back, so the blend-toggle and viewport shapes are pinned on the shipped emitter and not
only on the unit tests' local re-implementation; the fixtures reset the applier, the cache
and the tracker together, which is the only consistent state of the three
- comments: the derivation probe is a one-field sample, the residual block's trip wire is
half a tautology until package A's c1 lands, and the widened counter cannot see a change of
exactly 65536 - all three recorded where the code is, not only in a review
- Counters::Binds was declared, documented as one of the three numbers P13's capacity retune
reads, and incremented nowhere: the retune would have read a permanent zero, and the unit
tests counted binds in a local of their own
- counted in Acquire, which has exactly one caller and is followed by a bind_render_state
every time, so the count cannot drift from the emitter forgetting to tick it
- SetCapability named NEW_PIPELINE_STATE, but its ClipDistance0..7 arms write
ClipDistanceEnabledMask (dynamic chunk D7) and deliberately do not BumpVersions, so that
publisher does not fire at all for glEnable(GL_CLIP_DISTANCE0); SetStencilFunc named it
too, while ++m_pipelineStateVersion there is conditional on Func moving, so a ref-only
glStencilFunc does not move it either. Both are now NEW_RENDER_STATE, the answer that
holds on every path
- a row may now carry several publishers joined with '|', which is what lets the 18 setters
that call BumpVersions on every path state both counters, and the patch trio state its own
bit and the two render counters it also moves
- --check no longer validates only row existence and answer vocabulary: it reads
RenderState.cpp, derives per setter which of the two counters moves on EVERY path
(BumpVersions moves both, a bare ++m_version only the first, a setter with both kinds of
path only the first, a delegating setter inherits its callee's) and fails when a row
claims a publisher that under-fires or omits one that always fires
- two new self-test negative controls, one per direction, both built from the defects that
were actually in the file
- MarkProgram/MarkProgramPipeline/MarkShaderForDeletion answered kExplicitDestroy, a
mechanism D13 does not build for them: Espryt 0b's explicit destroy is scoped to six
object kinds that exclude programs, pipelines and shaders. They answer kUnpublishedDestroy
now - a recorded hole rather than a mechanism that does not exist
- G2 requires the pull and push ctest name sets to be identical, name for name. The two new
suites had a single hand-written "SkippedInAPullBuild" placeholder each, which made the pull
build 30 names short - a diff G2 exists to catch.
- Each file now carries an X-macro list of its push-only suite.name pairs, expanded in the
pull branch into cases that GTEST_SKIP. A case added on one side and forgotten on the other
is a visible ctest-name diff rather than a test that silently is not there.
- Measured with a CORRECTED gate command. The brief's G2/G14 grep is '^\s+Test #', which only
matches a four-digit test number: ctest right-aligns the number, so tests 1..999 print as
"Test #7:" with more than one space, and on this tree that silently dropped 999 of 2368
names - i.e. the gate as written passes while looking at 58% of the list. The pattern that
works is '^ +Test +#[0-9]+: '. Both gates are green under it: 2396 names in the pull build
and 2396 in the push build with a zero-line diff, and zero of the 2363 baseline names gone.
- TrackerWalk drives the tracker and the cache DIRECTLY rather than through
MGPipeValidateForVerb: the validate point reaches the library's one process-wide tracker, and
a unit test that asserts on a shared singleton fails the moment ctest runs the suite in
parallel. The three lines of emission logic it reproduces are the same three lines.
- BlendToggleReusesTwoCsos is the Blaze3D shape the whole "push at validate, not in the
setter" decision was made for: 32 enable/draw/disable/draw pairs mint exactly TWO CSOs, bind
64 times and hit 62. A per-setter design would show up here as 64 mints.
- ViewportDoesNotMintACso is the regression RenderState.h records: 16 glViewports mint nothing,
never move the pipeline version, and each sends exactly chunk D0 - not the other seven.
- WrapAroundRePushesButNeverMisses drives m_version past 65535 and asserts every one of 70000
changes fired. The alternating value deliberately never touches the default: a setter that
early-outs would otherwise make the first iteration a false miss and hide a real one.
- AggregateGenerationCatchesABoundTextureMoving is the first test of the direction the P1
verify comparator cannot see - it compares object-class fields by identity only, so a bound
texture whose content moved looks unchanged to it. The bit fires and then settles, so it is a
shutter and not a stuck flag.
- ANaNPatchLevelEqualsItselfAndDoesNotFireForever: a NaN outer level is a legal
glPatchParameterfv value, float equality says it differs from itself and a byte compare says
it does not. That is why the shutter is a memcmp.
- ThePixelPackShutterIsAByteCompareOfThePackHalfOnly asserts an UNPACK write does not move the
pack shutter, which is the half that deliberately has no carrier.
- HashCollisionDoesNotAliasTwoStates needed a seam and got one: MGPipeCsoCache::s_hashForTest,
null in every real build, one never-taken branch on a path that runs only when the pipeline
version moved. Without it the memcmp confirm is unreachable code that nothing can prove is
doing anything, and what it stops - two different render states on one CSO - is silent wrong
pixels with no gate that can see it.
- ContentAddressingOffMintsEveryTime pins that bit 63 really changes mint/reuse behaviour, so
the negative control cannot rot into a dead switch.
- The set-hash suppressor is exercised on all seven slots even though P2 wires one, including
the reserved-zero contract: a computed hash of 0 is remapped to 1 so it is never confused
with "never emitted".
- MG_Pipe/DirtySurface.def: 73 rows, one per distinct mutator the scanner finds, each answering
"what publishes this". The answer vocabulary is a MGPipeDirty bit name or one of five
non-bit answers, and each of the five is documented in the file's header rather than left to
be inferred: kImmediate, kReverseChannel, kNoBackendRead, kExplicitDestroy and
kPulledEveryVerb. Where a mutator has more than one true answer the row carries the COARSER
one - the one that cannot under-fire.
- gen_pipe_dirty_surface.py --check is the gate and it fails in BOTH directions: an unmapped
mutator renders stale, and a row naming a mutator the scan no longer finds keeps a real hole
looking covered. It also rejects an answer that is neither a documented non-bit answer nor a
bit name read out of Tracker.h's own kMGPipeDirtyNames, so a renamed bit cannot leave a row
silently pointing at nothing.
- --self-test runs three canned negative controls - a withheld mutator, a stale row, a bad
answer - and each must trip; trips == 0 is itself an error, the shape
check_include_closure.py and gen_pipe.py --self-test already use. ROADMAP.md's rule is that
every gate must be able to go red for the reason it exists.
- --summary keeps working unchanged, because the CI file that still calls it belongs to
another package until it lands.
- The human report prints the mapped answer where it printed UNMAPPED.
- FillPoints.def: the verdict on the eight statically over-approximated rows, recorded per
group in the def's own comment. All eight are KEPT and the reason is the same in all three
groups - each row names a concrete backend path (the depth/stencil read emulation's paused
capture, VkClearManager::PreCompensateSrgbClearColor's GL_FRAMEBUFFER_SRGB read, the shader
blit's viewport / provoking vertex / binding-point reads), and the only evidence that could
retire one is dynamic. A corpus that never reaches a path proves nothing about it, and a row
dropped on that basis turns a rare path into Fatal{UnmigratedPipeInput} in a shipped build.
The contract's new FramebufferSrgb storage in fact makes one of the eight MORE load-bearing
than it was, not less: it used to read a compile-time constant.
- set_residual_value_state emits the 35 capability bits, read from the FRONTEND's own
IsCapabilityEnabled rather than from the assembled mirror. That direction is the whole
design: the applier then compares the carried answer against the assembled one, so the block
is an independent oracle instead of a tautology - which is the failure the P1 entry compare
had and P2 is paying to remove.
- It goes out AFTER the residual fill, not with the other emissions: the mirror the trip wire
compares against is written either by the applier's derivation or by that fill, so before it
the block would be compared against the previous verb's answer.
- It is HELD, not dropped, when the verb's class does not carry IsCapabilityEnabled. kQuery and
kXfbSpan do not read it, so at those verbs the mirror is stale by construction; a capability
that moved between two queries would silently disarm the wire if the emission were skipped
instead of deferred.
- ByteClass::ResidualValueBlock has been a placeholder that "stays at 0 until P2" since P0.
This makes it non-zero, which is half of G10.
- The wire is not theoretical: the first version of this commit fired it for real -
Fatal{PipeResidualDiverged, "Dither"} carried=1 assembled=0, on every verb whose class does
not read the capability mirror - and that is what the holding latch above is for. GL_DITHER
defaults to enabled, so the very first mismatch the block could have found is the one it
found.
- integration-verify 818 green, integration-gpu 878 green under the default bitmask and again
under MOBILEGL_PIPE_PUSH=0, unit 1499 green.
- set_pixel_pack_state on NEW_PIXEL_PACK, set_patch_state on NEW_PATCH_STATE,
set_vertex_attrib_defaults on NEW_VERTEX_ATTRIB_DEFAULTS, each gated on its own runtime
subsystem bit so the bitmask stays a per-subsystem A/B.
- MG_Impl/Pipe/SetHashSuppressor.h: seven slots, one per kVarTail set_*, with
SetVertexAttribDefaults the one P2 wires. 0 is reserved for "never emitted" and a computed 0
is remapped to 1, so the first emission always goes out. The other six are the carrier for
the ~175 lines of backend debounce that move in P3b/P4b; landing the mechanism now means the
shape is pinned by a test rather than by a plan.
- The var-tail carries only the attributes that differ from the tracker's mirror, underneath
the set-hash suppression of the whole resolved set - the two suppressors answer different
questions and both are cheap.
Two rows of Coverage.def's emitted list CANNOT yet retire their pull, and each says why in the
code rather than being silently absent:
- GetPixelStoreParameters is BOTH halves of the pixel store and set_pixel_pack_state
deliberately carries only PACK, so the unpack half has no carrier at all. The field keeps
being pulled and the verify comparator keeps proving it.
- GetCurrentVertexAttribute's three views are not bit-identical - GLContext CONVERTS between
them - while MGPipeApplySetVertexAttribDefaults memcpys one Data[4] into all three and
ignores MGPAttribValue::ValueClass, which the wire type carries precisely so it does not
have to. Until that applier reads ValueClass the carrier cannot reproduce the frontend
value. The call is still emitted, so the wire shape, the payload bytes and the suppressor
are all real; the residual fill runs after emission, so the mirror ends up correct either
way.
Both are contract-side defects in files this package does not own; they are reported to the
integrator with the exact fix rather than worked around here.
- MG_Impl/Pipe/CsoCache.h: 64 entries, LRU, hash -> probe -> MEMCMP -> handle. The memcmp is
not optional: a bare 64-bit hash equality would let a collision alias two different render
states onto one CSO, which is silent wrong pixels with no gate that can see it, and Mesa's
cso_cache memcmps for exactly that reason. It runs only when the pipeline version moved, so
never in the steady state. Eviction emits delete_render_state and frees the client slot.
- kMGPipeBehaviourNoCsoContentAddressing (bit 63) turns off the PROBE and the handle reuse,
not the records: every pipeline-version change then mints, binds and evicts, which is the
whole-block content addressing the design is measured against.
- The validate point's step 3: bind_render_state when the pipeline version moved (12 bytes, no
hashing, no blob when the cache hits), set_dynamic_state when m_version moved, carrying only
the dynamic chunks that differ from the tracker's staging mirror. An EMPTY chunk mask still
sends the 32-byte header, because the version is what Magma's dynamic tail gates on and it
moved.
- The residual fill now skips a field a P2 call supplies, driven by the generated
kMGPipeFieldEmittedBy[] and gated per subsystem on the runtime MOBILEGL_PIPE_PUSH bitmask, so
the bitmask is a true per-subsystem A/B. THE STAMP IS UNCHANGED: a stamp says "this verb
published this field", which is as true of an emitted field as of a copied one, and
withholding it would abort every backend read of the fields the migration just took over.
- PipeStats::RecordDrawPayloadBytes has been implemented, unit-tested and called by nothing
since P0. This is its first emitter.
- A field that reaches PipeInputs only through MGPipeDeriveRenderStateFields is skipped only
when that derivation is really there. It is package A's and is a declared stub on the
p2/contract tag this branch starts from, so rather than hard-code which branch this is, the
filler probes once: a sentinel in a scratch block, the mirror cleared, the derivation run,
the answer latched. It stays useful after A lands - if the derivation is ever deleted the
filler degrades to PULLING those fields rather than rendering a default.
- integration-verify, 818 entries, green: the comparator re-reads every field from the live
context at every backend read, so "the assembled block equals the live context" is now
proven rather than asserted, and the entry compare has stopped being a tautology.
- MGPipeFillForVerb becomes MGPipeValidateForVerb and MGP_FILL expands to the new name. The
macro spelling, the 83 call sites and the verb enum do not change: the dispatch is
kMGPipeVerbClass's nine classes, which is the same code as nine named ValidateFor* entry
points with one call site per verb instead of nine (P2 brief D1, against ARCHITECTURE.md's
eight - FillPoints.def argues in its own comment for splitting kProgramOp out, and the landed
table is what runs).
- MG_Impl/Pipe/Tracker.h: MGPipeDirty's 18 bits, the widened Uint16 shutters, the per-verb walk
and the per-bit-per-verb-class fire tallies. The widening happens in the TRACKER and MG_State
is not changed for it; a wrap costs one extra re-push and never a missed one.
- NOTHING IS EMITTED YET. The mask is computed, latched and counted, and the full P1 residual
fill runs after it unchanged. That is the point of this step: it says the walk is
semantically free before any field stops being pulled, so a regression in the next commit
cannot be blamed on the walk.
- Every shutter over-fires on purpose. Bits 2 and 3 are BYTE compares, not value compares,
because a NaN patch level is a legal glPatchParameterfv value and has to equal itself; bits
5..17 are composed with a mixing hash, which can in principle collide, and that is stated in
the file and is acceptable only because nothing consumes those bits in P2.
- The shutter for bits 6..8 reads the current program's version counters WITHOUT
GetProgramForDraw, so the walk never joins a pending link to answer "did the shader move".
- Header-only rather than Tracker.{h,cpp}: the root CMakeLists.txt that would have to name a
new .cpp belongs to package A and is frozen behind the p2/contract tag. One translation unit
in the library includes it, so inline costs nothing, and splitting it out is one list(APPEND)
line whenever the ownership allows.
- MG_Test/ScopedPipeVerb.h and MG_Test/Pipe/PipeInputsTest.cpp follow the rename. Five comment
references to the old name live in package A's files (MGPipe.h, the two generated .inc, and
gen_pipe.py) and are deliberately left for their owner.
- MGP_NOTE_AGGREGATE(Aggregate) next to MGP_NOTE_MUTATION in MG_Pipe/PipeMutation.h, ((void)0)
in the pull build for the same reason and with the same shape. It answers a DIFFERENT
question from MGP_NOTE_MUTATION - "did any object of this class move since the tracker last
looked", not "did a backend move a frontend value inside its own verb" - which is why it is a
second macro rather than an overload.
- The counters are members of the owning MG_State container (VertexArrayState,
FramebufferState, TextureState x2, BufferState) and are reached through a push-only GLContext
facade, because the bump points sit on OBJECTS and an object has no back-pointer to the state
that owns it. That is the free-function form P2 brief D4 allows, and it costs a global load on
a path that has just written object state.
- A SIXTH aggregate, VertexAttribDefault on GLContext, which D4 does not list. Its bit
(NEW_VERTEX_ATTRIB_DEFAULTS) is specified there with a ContentHash over all 32
CurrentVertexAttributeValues, and hashing 768 bytes on every draw does not fit inside the T1
ceiling the same brief pins. The hash still decides whether to EMIT (D11's set-hash
suppressor); the generation decides whether to hash at all.
- 30 bump points: 3 VertexArrayObject config-version sites, 3 FramebufferObject object-version
sites (one of them inside MOBILEGL_DEFINE_FRAMEBUFFER_DEFAULT_SETTER, so the statement
carries its own line continuation), 5 texture content-version sites, 12 texture
params-version sites, SamplerObject::BumpVersion as the sampler choke point, 7 BufferObject
change-serial sites and the 3 glVertexAttrib* defaults.
- Every counter is deliberately COARSER than the state it guards: over-firing costs one extra
push, under-firing renders stale, and under-firing is the direction ARCHITECTURE.md 13.2
names as the dangerous one and the P1 verify comparator cannot see for object-class state.
- TrackerTest: each bump point moves ITS aggregate and no other, plus a null-context note.
- G1: the pull build is 0 added / 0 removed / 0 renamed and the four resized symbols are the
contract commit's own, unchanged by this commit.
- TheArmlessCasesLeaveTheLogPathAndTheConfigAsTheyFoundThem drives ScopedLogFileRedirect and ScopedArmlessKnobPair with and without a pre-set MOBILEGL_LOG_FILE_PATH and asserts the restore byte for byte
- in a build without the legacy arm the verdict is Handles whatever the knobs say, so the case pins the knob values rather than the NoArm verdict there
- BackendSlotTable links every instance into a per-type holder list; OnFrontendObjectDestroyed resolves the handle once, drops the twin in each holder by handle and frees the slot once, last - a notice delivered to one registry left the fixture's saved copy holding a dead twin, and its driver storage, for the life of the process (review v4 minor 2)
- ReclaimDeadSlots and CollectGarbageNow leave the slot table: nothing called them, so the header's "backstop" was a claim without a caller; the registry's pre-P2 CollectGarbageNow is a no-op on the handle arm (minor 1)
- HandleOf no longer memoises a null answer, which a second holder's acquire could never refresh (minor 8)
- EnsureProcessTeardownSentinel is armed by the slot table's first insertion, as D13 says; the registry arms it only on the legacy arm (minor 9)
- new SanityTest cases: OneDeathNoticeDropsTheTwinInEveryHolderOfTheKind, ASavedCopyOfARealRegistryDropsTheTwinOnTheSameNotice, ANegativeLookupIsNotCachedAcrossAnotherHoldersAcquire, and EglBringUpUnderTheArmlessKnobPairReturnsInsteadOfStopping, which runs InitPbufferSurface under the pair in a forked child and fails naming both knobs if InitDisplayAndContext ever stops there again (minor 4)
- AnArmlessKnobCombinationStopsInsteadOfSkippingTheLane writes a per-process, per-case log path and restores MOBILEGL_LOG_FILE_PATH and MG_Config::Features through RAII guards on every exit path (minor 5)
- ForEachLive walked with a range-for and handed fn a reference INTO m_slots, so a callee
that reached GetOrCreate on the same table would resize the vector under both. Index loop
and a copied twin, the shape ReclaimDeadSlots already uses. The one caller today happens
not to insert; that is not a property the walk should depend on.
- GetOrCreate(nullptr) reset the parking twin on EVERY call, so a second null call destroyed
what the first was handed. The map arm kept its null-keyed entry until a sweep, so this
was an arm difference in the one path (SyncTextureObjectToBackend) that documents relying
on the tolerance. It now keeps the parked twin, and the case makes a second call.
- The one-entry memo's comment claimed the three per-draw resolution paths ask for the same
object every draw. Two of them do not: BindCurrentFBO resolves both targets in a frame and
ResolveUnitSamplerBackend asks per texture unit, so both thrash a single-entry memo and
pay a probe P1 did not. The comment now says so and names the fix (per-unit / per-target)
and the gate that would price it (G11, device-side, owed).
- HandleOf caches a NULL answer too - deliberate, because a bound-but-never-synced object
would otherwise re-probe every draw - and what makes it safe is that GetOrCreate refreshes
the memo. Nothing pinned that; RepeatedLookupsOfALiveObjectKeepOneHandle now does.
- Removed the dead #if MOBILEGL_PIPE_PUSH nested inside #if MOBILEGL_PIPE_PUSH in
ScopedDetachedTextureFramebufferAttachments.
- The D13 "must not break" pins made ZERO slot acquisitions: the scratch-FBO scrub and the
three context-generation guards build their twins with MakeShared directly or under
ScopedStateGuardMocks, so they never reach StateBackendObjectRegistry and pass
identically on both arms. The four acquisitions in the binary were all kind Texture, and
the eleven DirectGLESSlotTable cases drive BackendSlotTable directly on the throwaway
kinds Query and Fence - so they would pass had the six registries never been re-keyed.
Framebuffer, Renderbuffer, SamplerCso, ShaderCso and VertexElementsCso had no case that
could go red for the switch-over.
- EverySwitchedOverKindResolvesItsTwinThroughTheHandleArm walks all six through the real
registry global the shipping paths call: GetOrCreate mints a non-null {slot, gen}, Find
and FindByHandle name the same twin storage, the object's own destructor notice frees the
slot with no sweep, and the successor lands on the freed slot with a moved Gen while the
predecessor's handle resolves to nothing. A kind still on the legacy arm answers the null
handle and fails the assertion by name.
- Evidence: gdb breakpoint on MGPipeSlotAllocator::Acquire counts 12 hits for this case
alone in build-push - six kinds times the two objects each - against 4 for the whole rest
of the binary. It skips visibly on the legacy arm and in the pull build (G2 parity).
- e2 was landed for two of six kinds, so Texture, Framebuffer, SamplerCso and
VertexElementsCso still discovered death in a sweep and ROADMAP.md:18's "delete the GC"
was undelivered. TextureObjectBase (the one base every concrete texture derives from),
FramebufferObject, SamplerObject and VertexArrayObject now raise
NotifyStateObjectDestroyed from their destructor, on RenderbufferObject's pattern: out of
line, declared only under MOBILEGL_PIPE_PUSH, so the pull build keeps its implicit
destructor and its symbol set (G1 still 0 added / 0 removed / 0 renamed and 0 resized
against p2/contract).
- With all six announcing, BackendSlotTable loses BOTH sweep drivers: no draw tick, no
creation tick, no kGCInterval / kCreationGCInterval / m_gcTick / m_creationTick.
CollectGarbageIfNeeded() is empty on this arm; CollectGarbageNow() stays as an EXPLICIT
collection and is the backstop for a notice that InProcessTeardown() drops.
- The seven CollectGarbageIfNeeded call sites in DirectGLES.cpp keep their spelling because
they are the legacy registry's driver and that arm is still compiled beside this one; the
registry's body is now guarded on MOBILEGL_PIPE_LEGACY_MEMOS, so a build without the
legacy arm has no collector at all. On the handle arm each site is a predicted branch.
- The weak_ptr per entry stays for exactly two jobs it is honest about: ForEachLive()'s
strong hand-over to ScopedDetachedTextureFramebufferAttachments, and the explicit
collection. It is never an identity test; Gen is.
- Tests: AProgramAndARenderbufferAnnounceTheirOwnDeath becomes
EveryReKeyedObjectClassAnnouncesItsOwnDeath and drives all six classes, by membership
rather than count because every texture owns a private sampler that also announces;
ObjectChurnAloneDrivesTheSweep becomes
AnnouncedDeathKeepsObjectChurnFromAccumulatingWithoutASweep and pins that 256 churned
objects hold one live twin at a time with no CollectGarbage* call anywhere.
- Fatal{PipeLegacyMemosDisabled} was raised from InitDisplayAndContext(), i.e. from inside
eglMakeCurrent. The integration harness pre-flights that exact sequence in a forked child
(MG_IntegrationTest/Harness/HeadlessGL.cpp) and reports a child that dies on a signal as
"no usable GPU/display/ICD", so every scenario SKIPPED and ctest called the lane 100%
passed while running nothing - on the very pair of env vars the D14/D18 A/B is driven
with. ROADMAP.md:7 forbids a gate that cannot go red for the reason it exists.
- The arm decision becomes a pure function of the two knobs, ClassifyEsprytSlotArm(), with
three verdicts. Bring-up now calls DiagnoseEsprytSlotArm(), which names both knobs at
ERROR and RETURNS; the stop stays in ResolveEsprytSlotTablesArm(), which the inline latch
reaches at the first twin lookup - a scenario body, where a crash is a test failure.
- A process that never looks a twin up never needs an arm and is no longer stopped by one
it would not have used. That is the only behaviour this moves.
- SanityTest gains an always-on case: the four knob combinations of the pure classifier,
that the diagnosis does not stop, and that the stop is SIGABRT whose log line names
PipeLegacyMemosDisabled, MOBILEGL_PIPE_PUSH, MOBILEGL_PIPE_LEGACY_MEMOS=0 and the bit -
the message and not merely the signal, because "Subprocess aborted" alone tells an
operator nothing. It skips visibly in the pull and no-legacy builds (G2 name parity).
- UnitBindingsSnapshot was split by #if MOBILEGL_PIPE_PUSH, so a push build ran P2's
lifetime-id debounce on the MOBILEGL_PIPE_PUSH=0 arm too. That arm has to reproduce P1
(ConfigLoader.cpp), or the integrator's A/B measures this slice's mechanism on both sides
and attributes it to neither - the same complaint g_fbSlotCache was already fixed for. The
snapshot now carries P1's WeakPtr fields beside the lifetime ids whenever the legacy arm
is compiled, and Capture/Unchanged pick by EsprytSlotTablesEnabled(). The two answers are
equivalent (OwnerEquals on two empty pointers is true and LifetimeIdOf(nullptr) == 0 == 0;
a live-versus-expired control block and two distinct lifetime ids both compare unequal),
so this is A/B fidelity, not a behaviour change, and a build with no legacy arm carries
neither the fields nor the branch.
- TwoTablesOfTheSameKindShareOneSlotAndKeepTheirOwnTwin deliberately never swept, so it left
a live MGPipeKind::Query slot behind for good, and ObjectChurnAloneDrivesTheSweep reads
HighWater/LiveCount of that same process-global kind. Deltas made them pass today, but
--gtest_shuffle or a third case on kind Query would have made them interact. The
two-holder case now has a kind to itself and returns its slot at the end.
- Its comment claimed two live tables of one kind "cannot arise outside this case". They
can and do: ScopedDirectGLESTextureBindings holds a second live table of kind Texture, and
package D's subsystem 4 re-keys VaoDrawMemo out of the same per-kind allocator. The
comment now states the real hazard (whichever holder frees first orphans the other's
entry; safe, because Free is generation-guarded and FindByHandle compares Gen, but not
free) and flags it for the integrator.
- P2 step e2, as far as the file-ownership table lets one package take it. New frontend
header MG_State/GLState/StateObjectDeathNotice.h carries BufferBackendOps' shape for the
other six kinds: an ops table the backend fills in, and one entry point that takes
{kind, lifetimeId} rather than the object, because by the time the last SharedPtr has
dropped there is no object left to pass and the lifetime id is exactly what the client
slot allocator resolves a handle from. Declared only under MOBILEGL_PIPE_PUSH, so the
pull build's symbol set is untouched.
- BackendSlotTable::DestroyByLifetimeId drops the twin and returns the slot at the moment
the object goes, instead of at the next sweep - which for a renderbuffer or a texture
atlas is the difference between freeing the driver allocation now and freeing it 64
creations from now. It returns the slot only when THIS table holds it: two holders of one
kind already exist (the ScopedDirectGLESTextureBindings fixture; Magma's subsystem-4
table shares the VertexElementsCso kind), and a table that never twinned the object must
not free a slot the other one still names. The legacy arm keys on the frontend heap
address, cannot answer a notice at all, and keeps the sweep - which is the announced-
versus-discovered half of the A/B the compile-time arm exists for.
- Managers.cpp registers one dispatcher for all six kinds from ResolveEsprytSlotTablesArm(),
i.e. exactly when the arm that can answer a notice is the arm that runs, and drops a
notice that arrives after exit() has begun.
- FIRING it needs a destructor per class, and the P2 ownership table gives
{Texture,Framebuffer,Sampler,VertexArray}State/* to other packages, so only ProgramObject
and RenderbufferObject raise it here. The other four still rely on the sweep; their four
one-line calls retire it entirely.
- Three cases pin the three halves: the slot comes back with no sweep and the notice is
idempotent and does not free another holder's slot; a program and a renderbuffer announce
their own death when the last SharedPtr drops and not before; and the handle arm actually
installs a consumer, rather than the two halves each being fine on their own.
- SanityTest never calls MG_ConfigLoader::Init, so MG_Config::Features.PipePush kept its
static default of 0 - the value a PULL build ships - and every case in the binary took
StateBackendObjectRegistry's legacy UnorderedMap arm. In build-linux, build-push AND
build-verify alike, the whole D13 "must not break" list (the scratch-FBO scrub, the three
context-generation guards on the texture/framebuffer/renderbuffer twins, the sampled-set
staleness walk and the whole-registry ScopedDirectGLESTextureBindings fixture) was
therefore evidence about code this package did not change; a gdb breakpoint on
MGPipeSlotAllocator::Acquire was the only way to see it.
- A gtest global Environment now seeds Features.PipePush with ConfigLoader's own push-build
default, so the binary runs the arm that SHIPS in the build it was compiled for: legacy in
build-linux (where the handle arm is not compiled and every DirectGLESSlotTable case skips
visibly), handles in build-push and build-verify. MOBILEGL_PIPE_PUSH in the environment
overrides it with ConfigLoader's decimal/0x contract, so the legacy-arm run of the same
binary is one env var. It is an Environment and not a static initializer because
MG_Config::Features has a String member and is dynamically initialised.
- TheTwinRegistryCasesInThisBinaryRunOnTheHandleArm is that gdb probe made falsifiable:
delete the environment and it goes red naming the arm rather than a symptom.
- ObjectChurnAloneDrivesTheSweep churns 256 objects through GetOrCreate and never calls
CollectGarbage{IfNeeded,Now}: nothing but the creation tick can collect them. Verified
red - it is the only case that fails - when the creation-driven sweep is neutered, and
green again on restore. The churn count is a fixed constant rather than a multiple of the
interval so that a negative control which pushes the interval out of reach makes the case
FAIL instead of running for 2^32 iterations.
- TwoTablesOfTheSameKindAgreeOnOneObjectsHandle could not fail: HandleOf never reads the
table, so any two tables agree for any implementation. Replaced by a case that asserts
what is actually load-bearing - ONE slot of the kind is consumed however many tables hold
a twin of the object (which is what makes Magma's subsystem-4 table resolve the same
handle for the same VAO), and the shared handle still addresses each table's own twin.
- GetOrCreateToleratesANullStateObject pins the release-build behaviour the map arm had.
- The three names are registered as visible GTEST_SKIPs in the pull build, like the five
before them, so the pull and push builds keep listing the same ctest entries.
- The handle arm took only ONE of the registry's two sweep drivers. The map arm sweeps
every 64 first-time insertions BECAUSE object churn, not draw count, is what makes the
sweep urgent: a CTS-shaped case runs ~10 per-draw ticks, so the 1024-tick draw-path
driver alone spans ~100 cases' worth of dead, gigabyte-sized twins. BackendSlotTable now
carries the same kCreationGCInterval = 64 creation tick, swept before the entry reference
exists for the same reason the map arm sweeps there. Without this the slice REGRESSED the
memory it was supposed to leave unchanged.
- Fatal{PipeLegacyMemosDisabled} now aborts. It logged and then returned false, which fell
straight into the legacy arm the operator had just made unreachable: a green run measured
on the wrong arm, and the exact lever HandleRecycleScenario's arms are selected with. It
is also resolved at backend context creation now, not on the first twin lookup, so a
process that twins nothing still learns its knobs leave it with no arm at all.
- EsprytSlotTablesEnabled() becomes an inline latch over an out-of-line resolver. It is
consulted on every Find/GetOrCreate/HandleOf/ForEachLive/CollectGarbage*, i.e. several
times per draw, and as a cross-TU call with no LTO that was a PLT call per lookup.
- HandleOf keeps a one-entry lifetimeId -> handle memo, so the three per-draw resolution
paths whose TwinLookupMemos this slice deleted go back to an integer compare plus an
array index instead of the allocator's ByLifetimeId hash - which is the "direct slot
indexing" the memo removal was traded for. It cannot serve a stale answer: a lifetime id
is never handed out twice, and FindByHandle compares Gen anyway.
- GetOrCreate(nullptr) returns a parking slot instead of dereferencing null in a release
build; the map arm inserted a null key and SyncTextureObjectToBackend documents relying
on that tolerance.
- ReclaimDeadSlots moves the twin out before it writes the entry, so a twin destructor that
re-entered GetOrCreate and grew m_slots could not make the writes land in freed memory.
- The framebuffer binding-slot cache is gated on kMGPipeSubsystemEsprytSlots rather than on
the compile-time MOBILEGL_PIPE_PUSH, so MOBILEGL_PIPE_PUSH=0 stays the faithful
all-subsystems-pull control ConfigLoader.cpp documents. The poison bypass stays closed on
the arm that ships.
- MGB_TWIN_KIND_PARAM/ARG stop leaking into every TU that includes Managers.h: the twelve
declaration and definition sites name a TwinRegistry alias template that swallows the
kind in the pull build, and the one remaining macro is #undef'd after the class.
- The twin lookup inside BindCurrentFBO stops shadowing the framebuffer binding slot in a
function whose whole subject is which "slot" is meant.
- SyncTextureObjectToBackend re-resolves its slot after the nested glTextureView sync. The
assert that it is still there is right - the caller holds the frontend object, so nothing
can reclaim its slot - but the function returns a REFERENCE, and in a release build the
assert is gone and the deref is not. It now falls back to GetOrCreate and puts the twin
the caller is about to use back.
- ResolveVaoTwin documents why its raw twin pointer survives the whole draw on both arms
instead of pointing at TwinLookupMemo, which the handle arm does not compile.
- Five cases against BackendSlotTable directly, through a stand-in state object that carries
only GetLifetimeId(), so none of them needs a GLContext, a driver or ES entry points.
- The load-bearing one is the ABA: an object dies, the sweep returns its slot, the next
object takes the same slot with a moved generation, and the predecessor handle answers
null instead of the successor twin. That is the property the address key could only paper
over with a weak_ptr.
- Gen moves on reuse and only on reuse; Find never mutates the table (which is what lets
SyncTextureObjectToBackend drop its second Find); a whole table saves, resets with = {} and
restores, which is the shape ScopedDirectGLESTextureBindings needs; and two tables of one
kind agree on a single object handle, because the identity comes from the client allocator
rather than from either table.
- ResolveVaoTwin, SyncCurrentProgram and BindCurrentFBO stop consulting the three
TwinLookupMemos on the handle arm. The memo existed to replace the registry hash probe
with an array index, and the slot table Find already IS that array index; its safety
argument - owner-equality of a weak snapshot against a recycled heap address - is
answered by the generation instead of re-derived per lookup. OwnerEquals, the memo
template and its three instances are now compiled only under MOBILEGL_PIPE_LEGACY_MEMOS.
- UnitSamplerLookupMemo compares {slot, gen} instead of owner-equality, and keeps its "a
miss is never cached" contract verbatim: the sampler twin is created later in the same
draw by the program pass.
- UnitBindingsSnapshot holds lifetime ids rather than weak_ptrs under push. It cannot hold
handles: a bound-but-never-synced texture has no twin and so no handle, and two of those
would read as equal. A lifetime id exists before the twin does and is never handed out
twice, which is the property the weak_ptr was there for.
- SyncTextureObjectToBackend keeps its by-value copy only to hold the twin alive across the
nested glTextureView sync; the second Find-or-create and the put-the-twin-back repair are
gone on the handle arm, because nothing there erases a live entry.
- The one direct-iteration site walks ForEachLive, which hands over a strong reference to
the framebuffer instead of the map key - the raw frontend address it had to null- and
expiry-check by hand before dereferencing.
- GetFramebufferBindingSlotFast becomes GetFramebufferBindingSlotChecked and, under push,
reads MGB_CTX->GetFramebufferBindingSlot(target) every time. This closes the P1 accessor
bypass: the cached raw pointer ran the checked accessor once per context change and then
handed out the pointee forever, so the per-verb poison stamp and the verify read-hook
were skipped at all five call sites.
- Every one of these is a push-build arm; the pull build compiles the pre-P2 text and its
symbol report stays 0 added / 0 removed / 0 renamed with no new resize.
- SlotTables.h: BackendSlotTable<StateObject, BackendObject, kKind>, indexed by
MGPipeHandle::Slot and validated by Gen, with the handle minted by the client
MGPipeSlotAllocator off the frontend object GetLifetimeId(). A lookup is one bounds
check plus one array index, and unlike the registry Find it never mutates the table,
so a returned BackendPtr* is not invalidated by the next call on it.
- StateBackendObjectRegistry keeps its name, its signature and all ~40 call sites, and
becomes the two-arm facade ARCHITECTURE.md 9.6 asks for: the pre-handle map under
MOBILEGL_PIPE_LEGACY_MEMOS, the slot table under MOBILEGL_PIPE_PUSH, chosen once per
process by EsprytSlotTablesEnabled() off kMGPipeSubsystemEsprytSlots. A clear bit with
MOBILEGL_PIPE_LEGACY_MEMOS=0 leaves no arm at all and is Fatal{PipeLegacyMemosDisabled}.
- The kind is a template parameter only in the push build (MGB_TWIN_KIND_ARG): a third
template argument would rename every instantiation and G1 wants the pull build byte
identical. Pull-build symbol report is 0 added / 0 removed / 0 renamed and adds no
resize beyond the three RenderState symbols the contract commit already moved.
- ForEachLive replaces begin()/end() under push and hands the callee a strong reference
to the state object instead of the map key, which was the raw frontend address.
- Nothing switches over yet: the tables are built and reachable, and the twins still go
through whichever arm the bit selects.
Add the persistent buffer ordering probe to the GLES POST's known-driver-bug inventory. Queue updates and draws into independent FBOs before reading them back, covering SubData and copies from both coherent persistent and ordinary staging buffers. Retry fresh mapped allocations for intermittent corruption.
Require passing never-mapped and fully serialized controls before reporting a finding. Include finish-before, map-then-unmap and barrier diagnostics, preserve caller GL state, and treat setup, allocation and GL errors as inconclusive. This adds detection and reporting only; no rendering workaround is enabled.
Validation: all 46 DriverBugProbesTest tests pass, including 11 new ordering, control, collector and cleanup cases. The Android API 26 / NDK 27 native probe detects all three upload paths on Mali-G1-Ultra r54p1 with clean controls and no GL errors. llvmpipe reports no finding after 240 mapped FBO checks per upload path.
Advance the buffer-id generation when Ops_Respecify retires immutable storage on the context thread, matching the existing adoption and deferred-retirement paths.
Add pixel regression coverage for unchanged VBO/IBO attachments across same-size redefinition, growth and shrinkage, including bound and unbound VAOs sharing a vertex arena and an index arena returning to shadow storage.
- A mode change on its own, so it does not ride inside a code commit. The other three of the
four files created with 0755 were already corrected; this is the last one.
- Coverage.def's emitted list named GetPixelStoreParameters, but the field is
PipeInputs::m_pixelStore[2] - pack AND unpack - and set_pixel_pack_state carries the pack half
only, deliberately and permanently. An emitted row is a licence for the residual fill loop to
skip the field, so the moment the render-state bitmask has its bit set the unpack half would
be written by nothing while its poison stamp said it was published, invisible to the poison
and to the verify comparator alike. The row is gone and the reason is in the file; the pack
half is simply written twice until the field is split.
- kMGPipeRenderStateChunkTableVersion was a promise nobody enforced: a boundary could move,
the two byte-count assertions be updated, and every persisted key stay valid. The hash is now
seeded with the version XOR a compile-time checksum of the boundary table, so a moved boundary
invalidates the keys whether or not anyone remembered - and without a static_assert on the
boundaries, which would turn G7's negative control into a build break instead of a red test.
- Five of the applier's eight entry points had no caller in any build, including both wires the
redundancy of the patch trio and of the residual block exists to arm. ROADMAP.md asks that
every gate be able to go red for the reason it exists; these could not change colour at all.
- Each wire is now driven in three states: DISARMED (the applier has not scattered the bytes it
would compare, which is the MOBILEGL_PIPE_PUSH=0x10 shape), ARMED AND AGREEING, and ARMED AND
DIVERGING. The diverging state is asserted in the form the build gives it - a poison or verify
build aborts and the parent reads SIGABRT and the Fatal line out of the log, a shipped push
build counts and logs - so neither case is skipped anywhere.
- ResidualTripWireHoldsAcrossAVerbClassThatDoesNotPublishTheBlock is the draw-then-dispatch
sequence itself: it aborts on the pre-ledger form of the wire and passes on this one.
- The patch case carries a NaN outer level, a legal glPatchParameterfv value that must compare
equal to itself, which is why the wire memcmps rather than compares.
- set_pixel_pack_state, set_vertex_attrib_defaults and delete_render_state are driven and read
back through the accessor a backend would use, each under the verb class that publishes it.
MGPipeDeriveRenderStateFields - D-7's kept whole-block form, which production does not call -
is checked against the chunk-scoped one.
- The suite gains its own main() (and links gtest, not gtest_main) for PipeInputsTest's reason:
the diverging cases read the wire's line back out of a log file this process names before
anything logs.
- SetterConsistency drives SetStencilOp and SetStencilFunc and SetStencilMask on BOTH faces and
SetHint on all four targets. Face 0's Func ends chunk P2 and its three ops open P3, which face
1's Func closes, so the previous one-face-each walk never wrote P3 at all.
- MGPipeApplierState gains ScatteredChunkBits: the global chunk bits this applier has itself
scattered into PipeInputs::m_renderState, set by bind_render_state (the whole pipeline half)
and set_dynamic_state (the chunks it names), cleared by MGPipeApplierReset. set_patch_state's
own write is deliberately NOT in it - that is the other carrier, and a wire comparing against
bytes it had just written would be a tautology.
- The residual trip wire now compares capability i only once every chunk that capability's
answer is read out of is in the ledger. Both of the previous form's contracts were undeclared
and one of them was wrong: with the render-state subsystem off (MOBILEGL_PIPE_PUSH=0x10 is a
legal per-subsystem A/B, D14) the working block is the per-verb fill loop's, published per
verb CLASS, and FillPoints.def does not publish GetRenderStateParameters at kDispatch or
kTextureOp - so at a dispatch after a draw the block held the draw's bytes and a correct
context could abort. With the ledger empty the wire now says nothing there, and with the
subsystem on the applier is the block's only writer and its bytes are current at every class.
- The per-capability grain is not decoration: a bind alone owns the pipeline half, and the
eight ClipDistances are answered from ClipDistanceEnabledMask in dynamic chunk D7, so between
a bind and the first set_dynamic_state exactly those eight are unanswerable. The source
chunks come from the same MGP_PLAIN_CAPABILITY_LIST and the same boundary table
DeriveCapability reads, so the two cannot drift.
- The patch-carrier wire arms the same way, which replaces its "some CSO is bound" condition -
a process-global that stayed set from the first bind onward - and covers the verb-class
contract as well as the ordering one.
- Both wires now run in the shipped push build too, counting and logging where a poison or
verify build aborts (one MGP_TRIP_WIRE_REPORT/TAG pair, so only the fatal arm writes the
"Fatal{...}" marker G4 greps for). A wire compiled out of every build a device runs is not a
wire, and the counters are what let a unit case see it fire in every build.
- create_render_state no longer leaves a dead BaseCso to MOBILEGL_ASSERT, which is inert at
INFO - the level every gate and every shipped build uses. A recycled slot's record still
holds the previous occupant's 396 bytes, so inheriting nothing and scattering the delta on
top handed out a record that was half one CSO and half another. Both arms now start from a
defined base and report. The same for a brand-new CSO that does not name every chunk.
- set_vertex_attrib_defaults walks the mask's 32 bits rather than the slot array, consumes a
tail entry for every named location so a named-but-unstorable attribute cannot desynchronise
the rest, and reports all three consistency faults in every build.
- LifetimeIdSurvivesARecycledAddress could not distinguish what its name claimed:
Acquire never sees an address, and each round freed its handle, so the next handle
differed whether or not the heap repeated the address - a restatement of
GenMovesOnlyOnSlotReuse - and the case could silently GTEST_SKIP on a machine that
never repeats one. The reuse count is now recorded rather than depended on, and a
deterministic arm proves the strictly stronger form: re-acquiring the SAME LIFETIME
ID after a free - the key the map is actually built on, which MG_State never
reissues - still cannot reproduce the handle, because the reused slot carries a new
generation. If that cannot reproduce a handle, no recycled address can.
- CompositeShaderBandIsNeverHandedOut skipped the whole case in a DEBUG build,
including the two arms that trip no assert. The eight low-slot handouts and the
"every other kind is unaffected" arm now run in every build; only the exhaustion
walk, which trips the allocator's own "slot space is exhausted" MOBILEGL_ASSERT on
purpose, is behind the skip.
- ChunkTablePartitionsTheBlock asserted only that a named pipeline member is TOUCHED
by the pipeline half. A boundary that demotes part of one - four bytes at the head
of BlendStates, i.e. the glEnablei(GL_BLEND, 0) bit - left the case green except
for the two byte-count constants, which P3 will legitimately move; after that a
partial demotion would have been invisible, and its consequence is one CSO handle
serving two different pipeline states. Now IsWhollyPipeline for every named member,
with StencilStates the one documented straddler.
- Every indexed setter is driven at index 0 as well as at a middle index. Index 0 is
the element both backends consume and the one a head-of-array boundary demotes
first; with it, the demotion above also fails SetterConsistency, naming the setter.
- The round trip D2 rests on is asserted: the assembled block is memcmp-equal to the
live one. That is the only cover for the ~25 members with no derived field at all -
SampleCoverage*, SampleMaskValue, PolygonModeBack, the hints, ScissorBoxes[1..15],
ClipDistanceEnabledMask and the raw capability bools - which is exactly the set
Espryt's SyncRenderState reads through its span memcmp.
- IncrementalChunksKeepEveryDerivedFieldInStep: the shape the tracker actually emits.
A create_render_state naming only the pipeline chunks that moved against a BaseCso
(D7 step 2's miss path) and a set_dynamic_state naming only the dynamic chunks that
moved (D8's suppressor), ten steps plus all 35 capabilities one at a time, each
followed by the full derived-field comparison. It is the first caller of the
base-inherit branch, of MGPipeScatterPipelineChunks, MGPipePipelineChunkBlobBytes,
MGPipe{Dynamic,Pipeline}ChunksThatMoved and MGPipeHashPipelineBytes, and it is the
oracle for the applier's chunk-scoped derivation - a whole-block apply asks for
every chunk and so cannot tell a correct guard from one that is too narrow.
- The 25 kDraw comparisons move into ExpectDerivedDrawFieldsMatch, shared by the
whole-block walk and the incremental one.
- MGPipeDeriveRenderStateFieldsForChunks: the applier no longer recomputes all 29
fields on every scatter. The four wide walks - the 8-wide blend/colour-mask loop,
the 16-wide viewport loop, the 16-wide depth-range loop and the 35-arm capability
switch - are guarded by the chunks whose bytes they read, so a per-frame glViewport
(the D8 case that sends dynamic chunk D0 alone) pays for one 16-entry copy instead
of ~170 stores and 35 switch dispatches. That cost sat on the per-draw path and the
gate it threatened is G11's pinned ns/draw.
- Every guard is MGPipeRenderStateChunkBitsCovering(offsetof(member), sizeof(member))
over the members the guarded block reads, computed from the boundary table: there
is no second, hand-maintained member-to-chunk mapping to go stale when a boundary
moves. The scalar copies stay unguarded on purpose - they cannot go stale, which
keeps the risk of the scoping confined to four guards.
- MGP_PLAIN_CAPABILITY_LIST is written once and used twice, for DeriveCapability's
switch arms and for the capability guard's chunk set, so the two cannot drift.
- set_patch_state now asserts under poison/verify that the trio agrees with what
pipeline chunk P0 delivered, which D6 and D10 both ask for and which was missing:
a stale set_patch_state silently clobbered the CSO-delivered levels. Compared
bitwise, because a NaN outer level is legal and must equal itself; armed only once
a CSO has been bound, which states the ordering contract rather than assuming it.
- set_residual_value_state's trip wire compares the carried bits against the WORKING
BLOCK instead of PipeInputs::m_capability. Only the derivation writes m_capability,
so a residual block emitted before the first bind of a context - what "once per
context" means - compared against all-false storage while Dither and Multisample
default to true, and aborted under poison. The check is unchanged in strength and
now has no ordering contract at all.
- PipeApply.h no longer implies the verify comparator is this package's oracle: it
arms off MG_Config::Features.PipeVerify, which a unit-test process never sets, so
the unit oracle is named for what it is and the comparator is credited to the
retrace and integration-verify lanes.
- Everything Track H keys off is only as sound as these statements, so each case is the
answer to a bug the {slot, gen} pair exists to close rather than a coverage exercise.
- GenMovesOnlyOnSlotReuse: the first handout of a slot is generation 0; nothing in the
interface can move a live handle's generation, which is "never on a respecify" stated as
an absence; the bump lands on the NEXT handout rather than on the free, so a double free
cannot skip a generation; and the stale handle then fails IsLive and cannot free the slot
its successor owns. Kinds are independent slot spaces.
- FreedSlotComesBackBeforeHighWaterGrows: two freed slots are both handed back before a
ninth is minted. Density is not a nicety - it is what lets the server's object table be
an array indexed by slot rather than a hash map.
- SlotZeroIsNeverHandedOut, over every kind and across a free/allocate churn: {0, 0} is
null for every kind and {0, 1} is the default framebuffer, so neither may be minted.
- LifetimeIdSurvivesARecycledAddress drives 64 construct/destroy rounds of a real
VertexArrayObject through a volatile address sink (ObjectLifetimeIdTest's trick, so the
new/delete pairs are not elided) and asserts that when the heap hands the same address
back, the handle is still a different one. It also asserts Acquire is an identity - the
same live object always answers the same handle - and that a freed lifetime id stops
resolving. When the allocator refuses to repeat an address the case SKIPS with
"inconclusive, not proven" rather than passing for the wrong reason.
- CompositeShaderBandIsNeverHandedOut walks the ShaderCso space to the composite base and
asserts the last ordinary slot is base - 1 and that the next call REFUSES rather than
stepping in. It skips in a DEBUG build, where reaching the edge trips the allocator's
own exhaustion assert on purpose; the INFO builds the gates run are where it is checked.
- Replaces the contract commit's placeholder, whose one live claim survives as
ReservedHandlesAreWhatMGPipeHandlesSaysTheyAre - the only case that is not push-only.
- SetterConsistency is G7. It drives every public RenderState setter with a value that
differs from the one stored, and asserts the pipeline-subset hash moves IF AND ONLY IF
m_pipelineStateVersion moves. Every case also asserts m_version moved, which is the
vacuity guard: a setter handed the value it already holds satisfies "neither moved"
trivially and proves nothing.
- The cases that are not just a list: SetStencilFunc twice (a reference-only change must
move the version and NOT the hash, because Ref/ValueMask are dynamic and Func is
pipeline, and RenderState.cpp's pipeline bump is conditional on Func); SetPolygonMode
with only the back face moving (PolygonModeBack is one of the members P2's subset added
over the 24 ComputePipelineStateHash hashed); the eight ClipDistance capabilities (the
one family that moves m_version alone, so the hash must hold); SetScissorBox across the
ScissorBoxWrittenMask transition; all 25 SET_CAPABILITY names including the three that
had no storage before the contract commit; and SetPixelStoreParam, which must move
neither counter and touch no byte of the block.
- Verified red for the right reason: moving the P1/D2 boundary so ColorMasks falls in the
dynamic half - the partition stays complete, so it still compiles - makes
SetterConsistency fail naming SetColorMask and SetColorMaskIndexed, and nothing else.
- ChunkTablePartitionsTheBlock re-states the header's static_assert at run time and adds
the half the hash cannot check for itself: membership. It walks PipeFields.def's
MGP_FIELDS_RenderStateParameters - the same list gen_pipe.py checks against the struct -
and asserts a member is covered by a pipeline chunk exactly when
kMGPipePipelineStateMembers names it, that every other member is wholly dynamic, and
that StencilStates straddles at exactly the sub-member granularity the table intends.
- DerivationMatchesTheFrontendGetters drives 30-odd setters on a live GLContext AFTER the
filler has run, assembles the working block through the real create/bind/set_dynamic_state
path, and compares the derived fields against the frontend getters. The stale fill is the
point: an ASSERT_NE before each apply proves the block disagrees first, so nothing here
can pass by comparing the filler with itself. It runs in THREE verb phases, because the
fill table is the only thing that says what a verb may read: 25 of these fields are
kDraw's, the three clear values are kClear's and GetClampReadColor is kReadback's alone,
and reading a clear value under DrawArrays is Fatal{UnmigratedPipeInput} - correctly, and
the poison caught exactly that in the verify build before this shape.
- GetViewport's rounding is exercised on (1.5, 2.5, 63.5, 32.25) and the expected literal is
std::lround's answer - half away from zero - not the banker's rounding nearbyint gives.
- DynamicChunksCoverMagmasDynamicTailKey checks every GL-state input of DirectVulkan's
ApplyDynamicDrawStateTail against the dynamic half, and records the one exception the
design implies but no document states: ScissorTestEnabledMask is read by DynamicTailKey's
scissorEnabled yet is PIPELINE state, because SetCapability(ScissorTest) calls
BumpVersions(). Harmless - BumpVersions moves m_version too, so MGPDynamicState::Version
still moves and the tail still re-runs - and asserted the other way round so a later
table edit that demotes the mask is loud here.
- Replaces the contract commit's placeholder case, which existed only so the target had a
test before this package filled it in. The four names are the same four in every build:
in a pull build each is a visible SKIP, never a vanishing test.
- 29 of the 47 kDraw PipeInputs fields are pure functions of RenderStateParameters. Once
bind_render_state and set_dynamic_state have assembled the working block - which IS
PipeInputs::m_renderState - copying those fields out of GLContext a second time is the
per-verb pull P2 exists to remove. The applier now derives them after any scatter.
- Each line is a transcription of the RenderState getter of the same name; GLContext's
accessors are one-line forwards to those, so the derivation and the pull path answer the
same question from the same bytes. Two are not field copies and are transcribed exactly:
GetViewport (viewport 0 rounded with std::lround, because glGetIntegerv on float state
rounds to nearest and truncating a 63.5-wide viewport would hand the backends a rectangle
one pixel short), and IsCapabilityEnabled (the 35-way switch, including Blend ->
BlendStates[0].Enabled, ScissorTest -> ScissorTestEnabledMask & 1 and the ClipDistance
run). The indexed twin is the same for Blend[8] and ScissorTest[16].
- IsCapabilityEnabled could not have been written before the contract commit: DepthClamp,
FramebufferSrgb and TextureCubeMapSeamless fell to `default: return false`, so three of
the 35 answers were a compile-time constant rather than state.
- This departs from P1 brief D4's "no derivation logic is re-implemented in PipeInputs",
deliberately: the alternative is to keep pulling those 29 fields per verb. The guard is
the oracle P1 built - MOBILEGL_PIPE_VERIFY's compare-at-read re-reads every one of them
from the live context at EVERY backend read and compares field-wise, so a transcription
error is caught on the first draw that reads it, across 79 retraces and the
integration-verify entries.
- The body sits in MGPipeApplyAccess, the struct PipeInputs already names as its friend, so
no new friend and no new accessor per field. Pull build untouched: PipeApply.cpp compiles
only under MOBILEGL_PIPE_PUSH.
- FramebufferSrgb, DepthClamp and TextureCubeMapSeamless get real storage. All three fell
to SetCapability's "not supported currently" arm and IsCapabilityEnabled's default:
glEnable was swallowed and glIsEnabled lied, so DirectGLES' sRGB block and the
DirectVulkan read points consumed a constant. The three Bools land in the three
alignment bytes at [581, 584) between ColorMasks and ClearColor, so
sizeof(RenderStateParameters) stays 1168 and NO existing offset moves - Espryt's
kBlendSpanBegin/kBlendSpanEnd (312/536) and the whole chunk table depend on that.
- MGPipeRenderStateSpans.{h,cpp}: the pipeline/dynamic split, written in exactly one place.
The rule is the only rule - a byte is pipeline state iff a public RenderState setter that
calls BumpVersions() writes it - which makes G7's "the subset hash moves iff
m_pipelineStateVersion moves" true by construction. 16 boundaries, all offsetof or
sizeof, alternating dynamic/pipeline: 8 dynamic chunks / 772 bytes and 7 pipeline chunks
/ 396 bytes, partitioning [0, 1168) exactly, asserted at compile time.
MGPipeComputePipelineSubsetHash is XXH64 over the seven pipeline chunks, seeded with a
table version so a chunk-table change invalidates every persisted key.
- The pipeline subset is now a strict SUPERSET of the 24 members ComputePipelineStateHash
hashed: 44 members, adding sample coverage, the front face, the provoking vertex, the
scissor-test mask, the back polygon mode, eleven capability bools the hash never read and
the three above. Demoting those setters to ++m_version instead would have changed
MG_State semantics in the PULL build for the push path's sake. The hash runs only when
m_pipelineStateVersion moves, which is exactly when Magma re-hashed before.
- PipeApply.{h,cpp}: the in-process applier, the server half of the P2 calls. The server's
working RenderStateParameters IS PipeInputs::m_renderState, which is why DirectGLES'
SyncRenderState is not one line changed and why the verify comparator stops being a
tautology. Per-context CSO store indexed by slot, gen-validated; the residual block's
capability bits are compared against the assembled block, so a capability a later call
takes over and forgets to carry is Fatal{PipeResidualDiverged}.
MGPipeDeriveRenderStateFields is a declared STUB - its 29 derivations are commit c1.
- SlotAllocator.{h,cpp}: the client's per-kind {slot, gen} allocator, free list plus
high-water, first allocatable slot 1, gen bumping only on slot REUSE, a debug assert on
gen wrap, the composite ShaderCso band held back, and a lifetimeId -> slot map per kind so
a GL name never enters a key. In the contract because both Track H slices need it.
- ResidualValueBlock 1248 -> 8 bytes, one Uint64 of capability bits.
RenderStateParameters retired to create/bind_render_state and set_dynamic_state, Pack to
set_pixel_pack_state, the patch quintet to set_patch_state. gen_pipe.py now emits the
member-by-member offsetof assertions the ratchet comment always promised.
- gen_pipe.py: PIPELINE_STATE_MEMBERS grows to the 44-member set in declaration order and
PipeSpanTable.inc's "deliberately absent" block records the answers instead of the
questions; Coverage.def gains MGP_COVERAGE_EMITTED_LIST (34 rows) and PipeFilled.inc
gains kMGPipeFieldEmittedBy[], which is what lets the residual fill loop skip a field a
P2 call now supplies. One more --self-test negative control covers the new list.
- MOBILEGL_PIPE_PUSH becomes a per-subsystem bitmask with named bits (0..6 migrated at P2,
bit 63 the CSO-content-addressing negative control), defaulting to 0x7f in a push build
and staying 0 in a pull build. New CMake option MOBILEGL_PIPE_LEGACY_MEMOS, ON, forced ON
when MOBILEGL_PIPE_PUSH=OFF where it is the only arm. New Features.PipeHandleAbaControl
under MOBILEGL_PIPE_PUSH, negative control C for HandleRecycleScenario.
- PipeStats gains CallClass::{RenderStateCsoMints, RenderStateCsoBinds} (csom / csob on the
summary line), and they are PUSH-ONLY: growing the enum in the pull build would resize
the counter arrays, the name table and FormatWindowLine for two counters that could never
leave zero, and G1 admits no such resize.
- Four MG_Test/Pipe stubs plus their CMake registration, so the packages that own their
contents never touch MG_Test/Pipe/CMakeLists.txt.
G1, pull build, symbol_report --threshold 0: 0 added, 0 removed, 0 renamed, 4 resized, and
every resize is attributed:
RenderState::RenderState() 1700 -> 1848 (+148) the three {}
RenderState::SetCapability(CapabilityInput,bool) 850 -> 927 (+77) three switch arms
RenderState::IsCapabilityEnabled(CapabilityInput) 239 -> 268 (+29) three switch arms
_GLOBAL__sub_I_DirectGLES.cpp 1340 -> 1331 (-9) the static
initialiser of DirectGLES.cpp's `static RenderStateParameters
g_syncedRenderStateParameters` re-scheduling around the three new default-initialised
members. A shrink, and the only unforeseen entry; it is a direct consequence of the
struct gaining members and touches no interface.
- the MGPipe phases land as a series of pushes and each one needs the full lane; dispatching
by hand after every merge is a step that gets forgotten
- both entries carry a remove-before-merging-to-dev note: dev's trigger set is what ships
- README status, the ROADMAP P1 row with the measured site and accessor counts, and the
ARCHITECTURE correction from 293 to the 277 arrow sites the tree actually has
- MEASUREMENTS gains the P1 scale table, the two classes of finding the verify lane
produced (nine missing fill rows, three fields a backend moves inside its own verb) with
the push-on-mutation decision and its three hooks, and the acceptance numbers
- MGPipeLeaveVerb() bumps the serial and puts the current verb back to none, so a test that
drives a backend helper directly stops declaring where it says it stops and a later
unguarded read aborts as "<Field>@<none>" instead of naming an unrelated verb
- check_include_closure.py gains a fourth probe: F2 put MGP_NOTE_MUTATION into frontend
mutators, so MG_State includes MG_Pipe/PipeMutation.h and that header must never reach
back into MG_State, MG_Impl or a backend
- ScopedPipeVerb (MG_Test/ScopedPipeVerb.h): an RAII "as if we were inside verb X" object
that runs the real MGPipeFillForVerb for the verb it names, so the eleven unit entries
that construct a GLContext by hand and call a backend helper with no GL entry point in
between stop reading an empty, unstamped PipeInputs block
- it weakens nothing: it fills exactly that verb class's may-read mask, so a read outside
it is still Fatal{UnmigratedPipeInput} naming the field and the declared verb; leaving
the scope re-arms the poison with a one-field kQuery fill, so one case's declaration
cannot cover a later one when the binary runs as a single process
- placed the way MGP_FILL is placed in production: immediately before the backend call,
after every frontend mutation it is meant to see; a second call after the test moved
state is a second verb (Renew()), a helper of another class gets a nested scope
- no-op in the pull build, no test renamed, no test added, no production source touched
- AFrontendMutationInsideAVerbRefreshesThePushedField: fills DrawArrays, then
does what UniformManager's fallback path does mid-draw (a SamplerObject filter
change) and what a bind reached from inside a verb does
(NoteTextureUnitTouched), and asserts the block still equals the live context
for GetSamplingResolutionGeneration, GetTextureBindGeneration and
GetMaxTouchedTextureUnit.
- AFrontendMutationInsideAVerbDoesNotDivergeAtRead: the lane failure end to end,
under the armed comparator in a forked child - the read after the mutation
must complete and the log must carry no Fatal{.
- TheMutationNoticeRefreshesTheValueButNotTheStamp: the notice must not restamp
a field whose stamp the fill withheld (negative control B), and must not stamp
a field the verb class never fills (the generation under a kQuery verb).
- Falsified: with the notice's body short-circuited, the first two fail (the
read-side one by SIGABRT on Fatal{PipeVerifyDiffer,
"GetSamplingResolutionGeneration@DrawArrays", where=read}) and the third
stays green, which is what a guard case should do.
- The three names also exist as visible GTEST_SKIPs in the pull build, as every
other case in this file does.
- The verify lane aborted eight integration entries and two retrace cases with
Fatal{PipeVerifyDiffer, "GetSamplingResolutionGeneration@DrawArrays",
where=read}, always one line after "ResolveSamplerDescriptor: using fallback
texture for unbound sampler". The backends write into frontend objects during
their own verb - Magma synthesises a fallback texture for an unbound sampler
and gives it a shape, materialises a queued clear, overrides a unit's sampler
filter - and every one of those writes moves a counter MGP_FILL already
copied, so the pushed block stops equalling the live context for the rest of
the verb. That is a real divergence, not a harness artefact: the pull build
reads the moved value and the push build reads the boundary one.
- Takes the findings' preferred option, push on mutation, over the volatile-in-
verb class: it keeps the comparator's invariant ("the pushed block equals the
live context at every read") literally true, keeps push semantics equal to
pull, and is the shape P2's tracker needs. The fallback would have had to skip
compare-at-read for the field, which is the one comparator arm that is real in
P1 - it would have blinded the gate on the very field that found the bug.
- MG_Pipe/PipeMutation.h declares MGP_NOTE_MUTATION(Field), a no-op that
includes nothing in the pull build; MG_Impl/Pipe/PipeFill.cpp defines the
notice next to the filler it shares CopyField with. The notice refreshes one
field's value when a context is live, a verb has been filled, and the field is
in that verb class's may-read mask; it never touches the poison stamp, so a
stamp MOBILEGL_PIPE_POISON_OMIT withheld stays withheld and a field the verb
never filled stays Fatal{UnmigratedPipeInput} rather than being healed.
- The enumeration behind the three hook sites: of the ~40 backend->frontend
write sites, only the texture family reaches a pushed value. Every path
through them funnels into TextureState::BumpSamplingResolutionGeneration
(SamplerObject::BumpVersion for the sampler setters,
TextureObjectBase::BumpShapeVersion for AllocateStorage / SetInternalFormat /
TruncateMipmapLevels / SetSamples / SetFixedSampleLocations),
BumpTextureBindGeneration (a default texture becoming defined, delete-unbind,
a unit's sampler object changing) or NoteUnitTouched (which also moves the
touched-unit high-water mark), so the notice sits on the counters rather than
on each writer and covers the whole family including writers added later.
The buffer, program and VAO writes reach no pushed field: their objects are
read back through O-class live references, not copied values.
- The verify retrace aborted four cases with Fatal{UnmigratedPipeInput,
"IsCapabilityEnabled@GenerateMipmap"} (x3) and "@DispatchCompute" (x1):
Magma materialises a texture's queued clear inside both verbs
(GenerateMipmap -> MaterializePendingClearForTexture,
DispatchCompute -> PrepareStorageImageTextures -> the same), and the clear
pre-compensates its colour against GL_FRAMEBUFFER_SRGB in
VkClearManager::PreCompensateSrgbClearColor. Neither class named the field.
- Audited every class the same way rather than stopping at those two rows. Two
more helper-program draws sit inside verbs whose class did not name what they
read: GenerateMipmap takes GenerateDepthMipmapWithShader for a depth texture
and BlitFramebuffer takes TryBlitToDefaultFramebufferWithShader for the
default framebuffer. Both bind their helper's descriptors through
BindProgramUniformBuffers, whose sampler resolver reads the draw framebuffer
for its feedback-loop check and whose buffer-block resolvers read the frontend
binding points; the blit additionally sets the dynamic viewport through
ApplyGLViewportState -> ComputeGLViewport and picks its pipeline's provoking
vertex through GetOrCreateBlitPipeline -> SelectProvokingVertexMode.
- kTextureOp gains IsCapabilityEnabled, GetFramebufferBindingSlot and
GetBufferBindingPoint; kDispatch gains IsCapabilityEnabled; kBlitOrCopy gains
GetViewportIndexed, GetDepthRangeIndexed, GetProvokingVertexMode and
GetBufferBindingPoint. Every row carries the path it was derived from.
- Also unfolds the kReadback transform-feedback rows 9087f133 landed on one
1200-column line back into the file's one-row-per-line shape; no row changes.
- both `nm -D --defined-only ... | grep -q MGPipe...` gates could never pass: the library
is built CXX_VISIBILITY_PRESET hidden in every non-Debug configuration and the MGPipe
entry points carry no export attribute, so the dynamic table holds none of them (0 of
11930 exported symbols on this tree, while `nm --defined-only` finds MGPipeFillForVerb
as a local `t`). build-linux-verify, and with it integration-verify and every
retrace-verify entry, would have been red forever for a reason unrelated to the
comparator. Both now read the static table, name what a miss means, and refuse a
stripped artifact instead of reporting its silence as a missing symbol
- "Every verify process really armed" grepped one shared per-lane log that holds only the
LAST process of 406 - and the last ambient entry is the poison control's parent, which
forks, execve()s and waits without ever issuing a verb, so the step would have red a
healthy lane while proving nothing about the other 405. It now greps the two
VerifyArming. lanes' own logs, requires one per backend, and says what that establishes
- remove-artifact-clutter kept fixtures for failed `retrace (` jobs only, so a failed
`retrace verify (` case lost the fixture needed to reproduce it; both prefixes now count
- the retrace negative control replayed OpenRA into the same case directory, so
"Upload actual image" shipped the deliberately corrupted run's output under the good
run's name. The verified output is put aside and restored before the verdict
- build-linux-verify regains build-linux's "Show installed toolchain" step, and its
deliberate divergence (Release even under ACTIONS_STEP_DEBUG - a Debug build would flip
visibility and arm the poison through a different #if arm) is written down
- the lane's scope is stated where it is run: every integration ENTRY under the
comparator, not every configuration - `integration`'s second
MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH=1 pass (186 entries here) is not affordable at
the 5-10x the comparator costs, so that tier stays covered unverified by `integration`
- the block looked for Fatal{PipeVerifyDiffer and Fatal{UnmigratedPipeInput only. A
misspelt MOBILEGL_PIPE_VERIFY_CORRUPT / MOBILEGL_PIPE_POISON_OMIT reports
Fatal{PipeVerifyBadKnob, and it was caught only because D2 makes that one abort the
process - which is precisely what MOBILEGL_PIPE_VERIFY_FATAL=0, the supported triage
configuration, takes away. A typo'd knob would have left a negative-control run
looking healthy
- the FATAL_ERROR text now says what each of the three means and where the two
vocabularies live, because that message is the whole diagnosis a `cmake -P` step gets
- G1 is spelled "added == removed == resized == renamed == 0, .text delta 0", but
gate_failures() took only added and removed and fired on text_delta > N: a pull build
whose .text SHRANK, or whose functions were resized with zero net delta - the exact
shape a null-guard or ternary rewrite produces - walked through
`--threshold 0 --fail-on-symbol-set-change --fail-on-added-bytes 0` green
- --fail-on-symbol-set-change now covers the four buckets the report prints, and
--fail-on-added-bytes 0 means byte-identical rather than "did not grow" (a positive
budget keeps the one-sided meaning). --fail-on-text-delta is the explicit spelling for
a run that wants no byte budget at all
- the gates read the threshold-0 buckets whatever --threshold says: --threshold is a
report control, and a gate that read the thresholded resize list would have quietly
weakened itself the day someone raised it. The run says so in its output
- the self-test drives the three shapes that used to pass (a shrunk .text at budget 0, a
resize and a rename at zero net delta) from the same canned transcripts
- the arming case read the ambient lane's MOBILEGL_LOG_FILE_PATH, and that log is
opened fopen(path, "w") by every process in the lane: with 406 entries per backend
and CI running them -j 4, a whole-file read races a neighbour's bring-up, and the
file that survives the lane holds only the LAST writer. Every other log-reading
scenario in this suite (UnlocatedIoBlocks, the primgen reroute, the point-size
demotion) is registered in a filtered lane with its own log for exactly that reason;
PipeVerifyArmingScenario.Armed now follows them, in DirectGLES.VerifyArming. /
DirectVulkan.VerifyArming., and skips anywhere MGITEST_PIPE_ARMING_LANE is unset
- what that can prove is written down where it is asserted: arming is a property of
(this library, this environment) and these two processes share both with their ~400
ambient siblings. A per-process census is not available through a shared log, and a
comment that claimed one was the reason CI grepped a file that could not answer
- the re-exec'd poison child ran RunSequence() and then _exit(0) unconditionally, so a
fatal assertion inside it - the shader failing to compile, say - returned before the
draw and the glGenerateMipmap and still reported success: WithoutOmissionCompletes,
the one green entry negative control B turns red, passed on a child that ran none of
the sequence. It now exits HasFailure() ? 1 : 0, and checks glGetError() after the
mipmap so a rejected sequence is part of the answer rather than stderr nobody reads
- build-linux-verify is a second Release/INFO build with -DMOBILEGL_PIPE_VERIFY=ON,
because the comparator is a compile-time option and does not exist in the shipped
library. It refuses to ship an artifact whose libMobileGL.so does not export
MGPipeVerifyInputs and MGPipeFillForVerb: a typo'd -D is not an error in CMake,
and every lane below would then be green having compared nothing.
- integration-verify runs the suite with the comparator armed and then proves it
armed twice over: --no-tests=error reds a build whose verify entries were never
registered, and a step greps every pipe-verify-*.log for the arming line.
- The two negative controls are steps of that job, not a manual exercise: a gate
that can only be shown to work by someone remembering to break it has already
stopped working. Each passes when ctest FAILS, and each first counts its own
selection - an empty selection also exits non-zero under --no-tests=error, and a
control that passed because it ran nothing would be worse than no control.
- Control B targets PoisonOmissionScenario.WithoutOmissionCompletes, the only
integration entry in the tree that calls glGenerateMipmap at all; the case no
longer skips itself when the omission knob is set, precisely so that the control
has a green entry to turn red.
- retrace-verify replays the eight "verify": true cases against the verify library,
copied over build-linux/libMobileGL.so because build-retrace freezes that absolute
path into every case, with an nm check that the swap happened and an inverted
OpenRA step that must go red under MOBILEGL_PIPE_VERIFY_CORRUPT.
remove-artifact-clutter now waits for it: it deletes the trace fixtures these jobs
download.
- monolith-symbol-report is G1 as a job: two pull builds with identical flags and
LTO off, the baseline named by a workflow_dispatch input, symbol_report.py with
both hard gates, and a refusal of any MG_Remote symbol in the monolith. It is
dispatch-only because its answer is about a baseline, not about this push.
- pipe-gates gains the two --self-test steps. Regenerating and diffing cannot see a
structural check that silently stopped checking; a broken gate and a clean tree
produce the same green. Its dirty-surface comment now says P2, which is where
ROADMAP.md:18 puts the first mapping round.
- --fail-on-added-bytes was a reserved no-op that printed "this run stays
informational"; it now exits non-zero when .text grew past the budget, and
--fail-on-symbol-set-change joins it for the added/removed buckets. Together they
are the spelling of P1's G1 ("the pull build is byte-identical"): --threshold 0
--fail-on-symbol-set-change --fail-on-added-bytes 0.
- Default behaviour is unchanged: with no gate flag the tool prints its report and
exits 0, which is what every existing caller and the informational
monolith-symbol-report job expect.
- A gate fires AFTER the Markdown and JSON are written, never before: the report is
the diagnosis, and a CI job that failed before uploading its artifact is one
nobody can act on.
- The decision lives in a pure gate_failures(), so --self-test drives it from the
same two canned transcripts as the buckets: each flag fires on the canned
add/remove/+100 delta, each stays quiet when it was not asked for, and a
tolerated budget is tolerated. A gate whose only test is a real build is a gate
nobody re-tests.
- A retrace that exports MOBILEGL_PIPE_VERIFY=1 at a library configured without
-DMOBILEGL_PIPE_VERIFY=ON is a no-op: the frames still match their goldens and the
case reports green having compared nothing. run_trace_case.cmake now demands the
evidence whenever the variable is set to anything but 0/false - mobilegl.log must
exist, must carry "MGPipe: verify armed", and must carry neither
Fatal{PipeVerifyDiffer nor Fatal{UnmigratedPipeInput. With the variable unset the
script is byte-for-byte the old one.
- The Fatal scan is not redundant with the replay's exit status:
MOBILEGL_PIPE_VERIFY_FATAL=0 is the supported triage configuration, and there a
divergence is logged and counted rather than aborted, so the run would finish 0
with its own report sitting unread in the log.
- LABELS retrace on both registrations: the lane was selectable only by regex, so
`ctest -L retrace --no-tests=error` - the spelling that reds a lane which
registered nothing - could not be written at all.
- "verify": true on eight cases (the five 180s cases plus minecraft-1.21.4-in-world,
minecraft-1.21.4-fabric-sodium-in-world and improved-transparency-minecraft-26.3),
and --format github-verify-matrix over that subset: 16 entries, against the full
lane's 77. The verify build compares at every verb boundary and again at every
accessor read, which the design budgets at 5-10x, so the per-push job runs the
subset and the full sweep is a phase-exit / workflow_dispatch run.
- The flag is validated in the manifest loader, not at the matrix, so a non-boolean
or a verify case excluded from CI is a loud error in every consumer instead of a
subset that is quietly one case short.
- ARCHITECTURE.md 13.2-(2) asks for a third CI mode, and a third mode whose only
evidence is "ctest was green" proves nothing: MOBILEGL_PIPE_VERIFY=1 against a
library that never compiled the comparator in is a silent no-op that looks
exactly like a clean pass. Six registrations, all under if (MOBILEGL_PIPE_VERIFY)
and all labelled integration-verify, make both halves falsifiable - a
mis-configured build registers nothing and --no-tests=error reds the lane, and
PipeVerifyArmingScenario.Armed fails a lane whose library never printed its
arming line.
- PipeVerifyArmingScenario.CorruptedFieldIsReported is negative control A (G4):
its lane pins MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters with
MOBILEGL_PIPE_VERIFY_FATAL=0 so the process survives its own divergence and can
read the report back; the CI step that exports the same knob against the ambient
lane, where FATAL keeps its default, asserts the other half - the abort.
- PoisonOmissionScenario is negative control B (G5), in two cases that cannot share
a process because the knob is process-wide: the omitted (verb, field) pair must
abort the glGenerateMipmap and NOT the draw before it, and the same sequence with
the knob unset must complete with no Fatal at all.
- The sequence runs in a fork()+execve() of this same binary rather than a bare
fork(): the fixture has already brought a context up, and a bare fork of a
process holding a live Vulkan device inherits the driver's mutexes with no
threads to release them - measured here as a 120s wedge on DirectVulkan against a
clean pass on DirectGLES. The child gets its own MOBILEGL_LOG_FILE_PATH because
the library opens its log with fopen(path, "w") and would otherwise truncate the
file the parent is about to read.
- No ambient Verify. entry names MOBILEGL_PIPE_VERIFY_CORRUPT or
MOBILEGL_PIPE_POISON_OMIT in its ENVIRONMENT property, because a property entry
overrides the job environment for the names it lists: the two CI negative-control
steps export those knobs into the job environment and must reach the processes.
Every list appends MGL_ITEST_COMMON_ENV / MGL_ITEST_VULKAN_ENV for the same
reason, so the vendor and ICD pinning survives.
- Every MG_State::pGLContext-> in the seven DirectVulkan TUs becomes MGB_CTX->
(DirectVulkan.cpp 12, BackendObject_DirectVulkan.cpp 2, UniformManager.cpp 14,
VkClearManager.cpp 1, VkRenderPassManager.cpp 3, VkTextureManager.cpp 2,
VulkanRenderer.cpp 130 occurrences on 127 lines); each TU includes
<MG_Pipe/PipeInputsSwitch.h> right after its MG_State/GLState/Core.h include
(VkRenderPassManager.cpp after its include block, it never included Core.h).
- The 43 MOBILEGL_ASSERT(pGLContext) / (pGLContext != nullptr) lines become
MOBILEGL_ASSERT(MGB_CTX_LIVE, ...): identical in every INFO build (the macro is
empty there) and still a null-context assert in a DEBUG build.
- The six code-bearing guards are like-for-like pointer tests in the pull arm:
if (MGB_CTX_LIVE) at the two InvalidateCompileEnv sites, MGB_CTX_LIVE in the
XFB query counter condition and the ternary that snapshots the paused-primitive
counter, !MGB_CTX_LIVE in BeginXfbCaptureForDraw, MGB_CTX_LIVE && in the
provoking-vertex resolve. No semantic rewrite: under push MGB_CTX_LIVE is simply
"a context exists", the real meaning of these guards is P2's business.
- VertexInputStateFactory.h's comment stops naming pGLContext so purity gate C
(grep -rc pGLContext MobileGL/MG_Backend/DirectVulkan) reads 0 in every file.
- The 12 SyncPersistentMappedRange and 3 SyncGpuWrites sites of the D10 table are
untouched; PipeStats AddCalls literals unchanged.
- Proof on the pull build (Release/INFO, LTO off, vs feat/disaggregated@087685d1):
symbol_report --threshold 0 -> 27799 symbols, 0 added / 0 removed / 0 resized /
0 renamed, .text 10792579 -> 10792579 (+0); nm --defined-only name set identical;
ctest -N name set identical (2334); unit 1466/1466; DirectVulkan integration lane
427/427 on lavapipe (two ArmedWhenTheEnvironmentPinsItOn entries flake under -j 4
exactly as on the baseline and pass serially). The push build compiles and links.
- P1 package B (BRIEF-P1 C.2): the four DirectGLES TUs include <MG_Pipe/PipeInputsSwitch.h> right after
their MG_State/GLState/Core.h include (Managers.cpp after its Managers.h include) and spell every
frontend read MGB_CTX->Accessor(...). In the pull build MGB_CTX is MG_State::pGLContext, so the
code is the tree before this commit token for token; in the push build it is &gPipeInputs, the block
the frontend fills at every verb boundary.
- 113 arrow occurrences on 113 lines went through the mechanical sed (DirectGLES.cpp 91, Managers.cpp 15,
MultiDraw.cpp 5, Utils.cpp 2). The 9 non-arrow lines follow D9: Managers.cpp's five bare/compound
guards and three ternary conditions become MGB_CTX_LIVE (UniquePtr::operator bool spelled out, so the
pull build does not move); DirectGLES.cpp's GetFramebufferBindingSlotFast keys its static cache on
MGB_CTX_IDENTITY (a const void* compare) instead of pGLContext.get().
- The cache refill loop dereferences MGB_CTX once into a local reference and reads the slots through it,
instead of &MGB_CTX->GetFramebufferBindingSlot(i) per iteration as D9 spells it: a store into
g_fbSlotCache (a pointer) may alias the unique_ptr's own pointer under clang's TBAA, so the per-iteration
spelling re-reads pGLContext inside the loop and grows the three functions the loop is inlined into
(SyncCurrentProgram +16, ForceBindCurrentFBO +9, BlitNamedFramebuffer +1; .text +32). Hoisting the
dereference restores the single load and a zero .text delta.
- grep -rc pGLContext MobileGL/MG_Backend/DirectGLES reports 0 in every file; symbol_report --threshold 0
against the 087685d1 baseline: 0 added / 0 removed / 0 resized / 0 renamed, .text +0, nm --defined-only
set identical. The only bytes that move are __LINE__ immediates in RecordError sites after the inserted
include line. The 8 SyncPersistentMappedRange and 3 SyncGpuWrites sites and the PipeStats AddCalls
literals are untouched.
- the depth/stencil read emulation opens a ScopedEmulationDrawState that pauses an active
capture around its own draw, so ReadPixels/GetTexImage read IsTransformFeedbackActive and
IsTransformFeedbackPaused; without the two kReadback rows every emulated readback aborts
with Fatal{UnmigratedPipeInput, "IsTransformFeedbackActive@ReadPixels"} once the Espryt
sites are converted
- PipeCatalogue.PipeInputFieldsStartUnfilled asserted "unfilled" only after setting the serial to 1 by hand, stepping around the serial-0 window; it now asserts every field stale on a value-initialised state first (sticky fields included).
- PipeInputsTest.ReadingBeforeAnyFillAbortsNamingNoVerb: a live context, no fill, gPipeInputs.GetLineWidth() in a forked child; the parent expects SIGABRT, exactly Fatal{UnmigratedPipeInput, "GetLineWidth@<none>"}, no PipeVerifyDiffer and no arming line. In a verify build the child sets Features.PipeVerify first, the lane's shape.
- VerifyFatalOffLogsTheDivergenceAndContinues: the child _exit(0)ed, so VerifyState's destructor never ran and the "verify summary" line was covered only by a lane run; std::exit(0) runs it, and the parent now asserts "2 divergence(s) survived MOBILEGL_PIPE_VERIFY_FATAL=0".
- The seven F-class forwarders are the declared exception to D4's "every accessor body": a forward is a live call, not a stored value, and InvalidateCompileEnv is reached from backend initialisation before any verb has filled, where a check would be Fatal{...@<none>} on every start. Their sticky stamp is consulted by no accessor; the tests pin it through MGPipeInputFieldIsFresh directly. Written down so P2's tracker does not "fix" the missing check.
- MGPipeVerifyReadHook: InHook is what keeps a GetProgramForDraw-triggered backend re-entry from recursing into a second hook, and the whole-field re-read is a per-read cost to keep in mind when reading the verify lane's wall time.
- GetBufferBindingSlot(Index) is polymorphic on GLContext (the bound VAO's element-buffer slot) and null in the block by design: a push Fatal there is not a missing row. No backend reads it today.
- ArmVerify latched PipeVerifyFatal and PipeVerifyCorrupt at the moment PipeVerify changed; a later change to either without a PipeVerify toggle was not seen, so 878db2c4's "re-parse when their value changes" held for one knob of three.
- The latch now keys on all three Features values. A lane loads Features before its first fill, so it still arms once per process; cost is two Bool compares and one String compare per fill, verify builds only.
- MGPipeInputFieldIsFresh (gen_pipe.py gen_filled, emitted into PipeFilled.inc) compared FilledGen == CurrentVerbSerial for a non-sticky field; before the first MGPipeFillForVerb both are 0, so 55 of the 63 fields read as fresh and served their default-constructed storage silently, with no log line and no abort. Only the four raw-pointer O-class accessors tripped, through their null-base checks.
- D6 says the serial starts at 1 so that FilledGen == 0 means never filled, and names the window "<Field>@<none>"; the predicate now refuses a stamp of 0 before consulting the sticky branch or the serial, so the window is the poison's case as documented. This is the window E's risk table expects the verify lane to find (an init-time read, the first link, anything reached from eglMakeCurrent).
- Reproduced with the reviewer's pre-fill program (a live context with line width 7, no fill, gPipeInputs.GetLineWidth()): stored=0, rc=0 before; rc=134 with exactly Fatal{UnmigratedPipeInput, "GetLineWidth@<none>"} after, with and without Features.PipeVerify set first.
- The compare-at-read hook arms at the first fill and cannot see this window either; a static_assert next to it pins that a verify build always carries the poison, which is what covers the reads before arming.
- gtest_discover_tests runs each case as its own process; under ctest -j the five shared one fixed log path, each main() unlinked it and each fork parent re-read it by path, so a sibling's Fatal line or unlink landed in another case's assertion (red 40/40 at -j 8, for a reason unrelated to the poison). The name now carries the pid and the file is removed on the way out; a forked child inherits the path on purpose.
- MutatedFieldIsNamedAtRead: the first falsifier of MGPipeVerifyReadHook - the child arms verify, fills DrawArrays, reads GetLineWidth (completes), mutates the live context's line width and reads again, and the parent expects SIGABRT with Fatal{PipeVerifyDiffer, "GetLineWidth@DrawArrays", verb=<serial>, where=read} and no where=entry.
- PoisonOmitKnobArmsTheOmission / BadPoisonOmitKnobIsFatalNamingTheKnob / VerifyCorruptKnobNamesTheFieldAtEntry / BadVerifyCorruptKnobIsFatalNamingTheKnob / VerifyFatalOffLogsTheDivergenceAndContinues: the knob parsers through MG_Config::Features, their arming lines, the exact Fatal{PipeVerifyBadKnob} text, and a FATAL=0 run that logs two divergences at consecutive serials and exits 0.
- OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale now loops every field: fresh iff in kTextureOp's mask and not the omitted one, so the name is true.
- The fork/waitpid/log-delta shape is one RunInChild helper; every case is still a visible skip where its switch is off.
- The two parsers (ParsePoisonOmissionKnob, ArmVerify) latched on the first fill, so the only way to reach them was a lane that loads MG_Config::Features before any fill; no unit test could exercise the parse, the arming lines or Fatal{PipeVerifyBadKnob}.
- The latch now keys on the value: a lane still parses once (Features is loaded before the first fill), while a forked test child that sets Features after its parent filled gets its own parse. An empty omission value never clears an omission armed through MGPipeSetPoisonOmission.
- Re-arming resets the CORRUPT field so a stale corruption cannot outlive the knob that named it.
- kMGPipeForwardedFieldCount = 7 was a hand-written twin of the generated kMGPipeInputStickyFieldCount, tied only by PipeCatalogue.StickyFieldsAreExactlyTheSeven; a static_assert in the header makes a PipeFields.def sticky row without a forwarder a build error instead of a test failure.
- D7 says a verb whose table entry is null never bumps the serial; the sweep's fill sat before `if (backendDeleteSync && syncObject->backendHandle)`, so a backend without DeleteSync, or a sync without a backend handle, bumped once per orphan with nothing reading the fill.
- The sibling sweep in GL_Query.cpp (DeleteBackendQuery) already fills inside its guard; this makes the two the same shape and leaves the declared list of guarded-expression sites at nine.
- Pull build unchanged: MGP_FILL is ((void)0) there.
- PipeCatalogueTest (header-only, every build): VerbTableIsTheFunctionTable (69 verbs, 9 non-empty classes, the seven sticky bits in every class mask, D7's edges), StickyFieldsAreExactlyTheSeven, FloatVectorsCompareBitwise (a NaN FloatVec4 equals itself, -0.0f differs from 0.0f, a differing BlendStates[3] names RenderState then BlendStates), SixValueStructsHaveFieldLists (69 payloads, PixelStoreParameters and MGHostSpan compared member-wise with Pad0 ignored).
- PipeInputsTest, a new unit target linking the static library with its own main() that points MOBILEGL_LOG_FILE_PATH at a temp file: a fake GLContext, the real filler and accessors. OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale (G5 layer 1), ReadingAnOmittedFieldAbortsNamingTheVerb (G5 layer 2: fork, the child reads the omitted field after a draw and a sibling read that must not abort, the parent expects SIGABRT and the exact Fatal line and no @DrawArrays), ReadingAFilledFieldCompletes (the sibling without the omission: _exit(0), no Fatal), CorruptedSnapshotFieldIsNamedWithItsSerial (G4 at block level: clean compare true, a corrupted GetRenderStateParameters is named, its stamp is the fill serial, a corruption outside the mask is not seen), EveryVerbFillsItsClassAndNothingElse (after each of the 69 fills a field is fresh iff its class bit is set).
- Every PipeInputsTest case is a visible GTEST_SKIP in a pull build and the poison/verify cases skip in a push build without them; the ctest name set is the same in all three configurations (additions only: 9 names).
- PipeFields.def gains MGP_FIELDS_RenderStateParameters (65 members), PixelStoreParameters (8), PerBufferBlendState (7), StencilFaceState (7), DynamicBackendParameters (85) and MGHostSpan (Ptr, Seg, Size, Offset), all appended to MGP_VERIFY_PAYLOAD_LIST: kMGPipeVerifiedPayloadCount 63 -> 69, and ResidualValueBlock / MGPPixelPackState / MGPCaps are now compared field by field all the way down.
- gen_verify: MEMCMP_FALLBACK_TYPES is empty and the generic MGPipeFieldEqual's last branch is static_assert(sizeof(T) == 0) - a struct without a field list is a compile error, not a padding false positive; Array<T, N> gets an element-wise overload, and a VecBase-derived vector (FloatVec4, IntVec4, BoolVec4...) is detected by a probe and compared bitwise over its data, because VecBase::operator== is IEEE == and a derived-to-base overload would lose resolution to the exact-match generic template.
- check_field_lists_cover_struct_members(): for every payload in MGP_VERIFY_PAYLOAD_LIST, parse `struct <Name> {` out of MGPipeTypes.h / MGPipeValueTypes.h / MGPipeHostSpan.h / BackendObject.h (comments and strings masked, statics, functions, nested types and Pad<n> members excluded) and refuse a member without an F(...) or an F(...) that is not a member; runs in both modes, so it is a pipe-gates gate.
- scan_live_accessors(): every MGB_CTX-> / pGLContext-> read under MG_Backend must have a Coverage.def row (rows nobody reads are printed: today only the dead GetBoundTransformFeedbackName).
- --self-test: six negative controls (struct member without F, F without member, payload without struct, verb missing from FillPoints.def, verb outside GLFunctionsTable, field row naming a non-accessor) that must each trip, plus a positive control; zero trips is itself an error.
- Entry compare: at the end of MGPipeFillForVerb a file-static second PipeInputs is filled by SnapshotFromGLContext (the branch that survives P13) over the same class mask, MOBILEGL_PIPE_VERIFY_CORRUPT perturbs one field of that snapshot arm, and MGPipeVerifyInputs compares every field in the mask through MGPipeInputsFieldEqual (V by G4's MGPipeFieldEqual, O by identity, F equal by definition). Both arms come from the same context at the same instant, so this arm is tautological until P2 - the CORRUPT knob keeps it falsifiable.
- Compare-at-read: MGP_INPUT_VERIFY_READ now calls MGPipeVerifyReadHook(*this, field, i0, i1), which re-reads the whole field from the live context into a scratch block and compares it against the stored value on the live block only - a superset of "the same indices"; the indices decorate the report. This is the arm that is real in P1.
- Reporting per D8: MGLOG_F("MGPipe: Fatal{PipeVerifyDiffer, \"Field@Verb\", verb=<serial>, where=entry|read}") then abort unless MOBILEGL_PIPE_VERIFY_FATAL=0, which counts and summarises at teardown with MGLOG_E; arming logs "MGPipe: verify armed - 63 fields, 69 verbs, fatal=N" once, the knobs acknowledge themselves, an unknown field name is Fatal{PipeVerifyBadKnob}; a push build without the comparator answers MOBILEGL_PIPE_VERIFY=1 with one MGLOG_W_ONCE.
- MGPipeVerifyInputs carries default visibility so the retrace-verify job's nm -D probe can prove the verify library was the one swapped in; pointer corruption flips low bits instead of nulling (a null pointer already null was invisible to the compare), a SharedPtr becomes an aliasing pointer with no control block; CurrentVertexAttributeValue gets its own bitwise equality.
- Every call through gBackendFunctionsTable.GL in the seven MG_Impl TUs (Drawing 36, Framebuffer 11, Texture 8, Getter 3, Program 1, Query 18, Sync 6) is preceded by MGP_FILL(<Verb>); placed after every early return the call sits behind - the conditional-render check, the null-entry guards, the loop bodies - so a verb whose entry is null on this backend never bumps the serial.
- Seven calls sit on a continuation line of a guarded expression (GetQueryResult64 x2, BeginXfbPrimitivesQuery, BeginTimeElapsedQuery, QueryCounterTimestamp, IsTimerQuerySupported, GetSyncStatus) and two more fold the null guard into the same expression (IsQueryResultAvailable, ClientWaitSync's guarded return); there the fill precedes the statement, so a null entry bumps the serial once with nothing to read it - harmless for the poison, recorded for the record.
- Each TU includes <MG_Impl/Pipe/PipeFill.h> after its last MG_State/MG_Backend include; under MOBILEGL_PIPE_PUSH=OFF the macro is ((void)0) and the pull library is symbol-identical with a .text delta of zero.
- MGPipeFillForVerb now walks kMGPipeClassFieldMask[kMGPipeVerbClass[verb]] and copies every field in it by calling the GLContext accessor of the same name (MGPipeFillAccess::CopyField, one switch over the 56 stored fields; the seven forwarded fields copy nothing), stamping each with the new serial; the sticky seven get FilledGen = 1 on the first live fill through the same mask walk, since every class mask carries them.
- MOBILEGL_PIPE_POISON_OMIT (<Verb>:<FieldName>) is parsed once on the first fill and MGPipeSetPoisonOmission is the programmatic form for the unit tests; the omitted pair keeps its value copy and loses only its stamp, so the omission is indistinguishable from a forgotten FillPoints.def row. An unknown name is Fatal{PipeVerifyBadKnob}; a non-poison push build acknowledges the knob with one warning because no stamp exists to omit.
- PipeInputs names one friend, struct MGPipeFillAccess, instead of two friend functions, and VisitStorage gains a const overload for the comparator that follows.
- MG_Pipe/PipeInputsSwitch.h is the strangler switch (ARCHITECTURE.md 9.2): MGB_CTX is the
live GLContext in the pull build and &gPipeInputs under MOBILEGL_PIPE_PUSH, so the pull
arm's pGLContext spelling stays outside MG_Backend/ and purity gate C's grep.
- MG_Backend/MGPipe/PipeInputs.h holds one struct with every accessor a backend reads (63:
the 61 Coverage.def rows plus GetBoundTransformFeedbackLifetimeId and
HasOpenTransformFeedbackSpan), each keeping its GLContext name, parameters and return
type so the site conversion is type-neutral; V fields are copied values, O fields are
SharedPtr copies or pointers into the context, the seven F fields forward to the live
context from MG_Impl/Pipe/PipeFill.cpp and are the only sticky ones (Coverage.def's
MGP_COVERAGE_STICKY_LIST argues each: argument-keyed lookups and reverse-channel writes,
never a version or generation counter).
- MG_Pipe/FillPoints.def is the verb table: one row per GLFunctionsTable function pointer in
declaration order (69), nine classes and the may-read field rows; gen_pipe.py parses the
struct and refuses a row set that is not exactly its member set, then emits
generated/PipeFillPoints.inc (verb enum, class tables, per-class field masks with the
sticky fields OR'ed in). MGP_FILL(Verb) in MG_Impl/Pipe/PipeFill.h is the fill point;
MGPipeFillForVerb only bumps the serial, records the verb and stamps the sticky fields
here - the per-class copies land in the next commit, the fill points in MG_Impl after.
- MOBILEGL_PIPE_POISON is derived once in PipeInputs.h from MOBILEGL_PIPE_PUSH and the DEBUG
level, MOBILEGL_BUILD_DISAGGREGATED or MOBILEGL_PIPE_VERIFY (the tree has no
MOBILEGL_DEBUG); under it every accessor is a read-side freshness check that aborts with
Fatal{UnmigratedPipeInput, "Field@Verb"}.
- CMake: MOBILEGL_PIPE_PUSH and MOBILEGL_PIPE_VERIFY options (VERIFY forces PUSH on), the two
new sources appended only under PUSH, the compile definitions; Config.h/ConfigLoader.cpp
gain PipeVerifyFatal / PipeVerifyCorrupt / PipePoisonOmit under #if MOBILEGL_PIPE_PUSH so
the pull build's FeaturesTable does not change size.
- Pull build proof: symbol_report.py against the 087685d1 baseline reports 0 added / 0
removed / 0 resized / 0 renamed and a .text delta of 0; ctest -N names unchanged; gen_pipe
--check clean; unit tests green in the pull, push and verify builds.
- Includes.h defines VK_USE_PLATFORM_XLIB_KHR before vulkan.h, so the clang-mode probes and
the negative controls need X11/Xlib.h on the runner; run 34008829271 failed on exactly that
- README status, the ROADMAP P0.5 row (measured outcome, the 8-includer correction, the
DynamicBackendParameters exception) and the ARCHITECTURE note on which header gate A asserts
- check_include_closure.py keeps the directory of a two-token -isystem, makes --require-all
fail on a missing required header even when --probe narrowed the run, and removes its
temp dir at exit
- ProgramArtifactsTest follows whichever STL branch the header pinned (#ifdef the size
macro) instead of re-spelling the libstdc++ condition, and loses its stray executable bit
- MGPipeTypes.h's debt comment says what its closure still reaches (TextureEnum.h via
BackendObject.h), which is why gate A asserts MGPipeValueTypes.h and not this header
- One VisitFields table per type (ARCHITECTURE.md:259), a free constrained template so
the moved struct bodies stay verbatim and one table serves both the const (serialize)
and non-const (deserialize) direction. LinkArtifacts::program is deliberately absent:
it is null for every archived instance and must never be serialized.
- Trip wires: TypeFacts is pinned at 44 bytes on every ABI; the container-bearing four
are pinned per standard library - libstdc++ 64-bit here (128/128/1056/88, measured on
this build), the libc++ branch left inert for the integrator to pin from the NDK
build, MSVC unasserted. A member added without a table entry changes the size and
the assertion message sends the author to the table.
- ProgramArtifactsTest counts the tables (20/14/11/57/8; const and non-const walks
agree, names distinct), proves constness passes through, and records every sizeof as
a ctest property so a new toolchain's numbers are readable from any `ctest -V` log.
- The include line was the header's only match for spvc_*/SpvReflect*/SpvcMetadata/
SpvcSession; it only ever forwarded <spirv_reflect.h> and the session type to the
eight includers, every one of which builds without it (no consumer needed a direct
include added).
- One less transpiler header behind ProgramObject.h, on the way to a ProgramArtifacts.h
closure that stays clear of MG_Util/ShaderTranspiler/ (P0.5 gate A).
- P0.5 of the MGPipe disaggregation (ROADMAP P0.5, ARCHITECTURE.md:260): the five
reflection types a link produces now live in a header that includes only
<Includes.h> and <set>, so a future server-side consumer can name them without
dragging ShaderObject.h / SpvcSession.h / the transpiler behind it.
- Struct bodies move verbatim, comments included, re-indented one level; no field is
added, removed, reordered or re-typed. The two glslang-typed members
(LinkArtifacts::program, uniformInitialValues) stay as they are (B.0 D5); the
comment mentions of glslang types are respelled without the scope token so the
include-closure gate's "glslang:: exactly twice" limit holds.
- kInvalidUniformOffset moves to namespace scope (SpirvArtifacts defaults to it);
ProgramObject::kInvalidUniformOffset is defined from it, so the two cannot drift.
- ProgramObject keeps in-class aliases (fully qualified on the right-hand side) for all
nine names, so none of the 8 includers nor any ProgramObject::X spelling changes.
- ProgramArtifactsTest pins the aliases as the same types (is_same_v) and TypeFacts as
a 44-byte POD; its first include is the new header, so it is also the proof that the
header is self-contained.
- ROADMAP P0.5 / ARCHITECTURE.md value-header manifest: MGPipeTypes.h embedded
RenderStateParameters and PixelStoreParameters through RenderState.h, which drags
FramebufferObject.h and the whole texture/renderbuffer/sampler chain into MG_Pipe;
purity gate A (no MG_State/MG_Impl/MG_Backend/MG_Remote in the closure) could not
be armed for anything in MG_Pipe while that include existed.
- MGPipeValueTypes.h is a verbatim cut, comments included: the eleven RenderState.h
enums (all of them - a split would be the maintenance trap), PixelStoreParameters,
PerBufferBlendState, StencilFaceState, RenderStateParameters (member order
untouched: DirectGLES' offsetof spans and PipeSpanTable.inc name the members),
the six SamplerObject.h enums and SamplerParameters (BorderColorForm stays Uint8,
it sets the tail padding), and VertexAttribute / VertexBufferBindingPoint /
VertexAttributeVersion, which keep namespace MobileGL::MG_State::GLState with a
forward-declared BufferObject so no mangled name changes.
- MAX_DRAW_BUFFERS becomes inline constexpr kMGMaxDrawBuffers in namespace MobileGL
and FramebufferObject::MAX_DRAW_BUFFERS is defined from it, so the eighty existing
spellings keep working and the two cannot drift. No other constant is added.
- The four MG_State headers become forwarders (include the value header, keep their
class definitions); RenderState.cpp gains a direct FramebufferObject.h include
because it spells FramebufferObject::MAX_DRAW_BUFFERS and only ever got that
header transitively. No other TU lost a transitive include: the full build
(Release, clang, tests + integration tests) passed without touching anything
under MG_Backend, MG_Impl or MG_Util.
- DynamicBackendParameters does NOT move (SizeT members and TextureTarget-taking
member functions make that a type change, not a move); MGPipeTypes.h keeps its
BackendObject.h include and the debt comment now says so, which is why gate A
asserts MGPipeValueTypes.h rather than MGPipeTypes.h.
- New trip wires in the header: trivially-copyable + exact sizeof for
PixelStoreParameters/PerBufferBlendState/StencilFaceState (28), SamplerParameters
(100), VertexAttributeVersion (6), RenderStateParameters (1168, standard layout,
BlendStates before LogicOp, BlendStates sized by kMGMaxDrawBuffers). Their runtime
twins ValueTypeLayoutsArePinned and the carrier check
ResidualBlockIsExactlyItsTwoValueStructsPlusPatchTail (Pack at 1168,
CapabilityBits at 1200) are added to PipeCatalogueTest without a new include.
- gen_pipe.py's "field lists of their own in P0.5" comment now says P1 (the
comparator needs std::array<struct> support first); PipeVerify.inc regenerated.
- Verified: ctest -L unit 1460/1460 and -L integration-gpu green; ctest -N names
a superset of feat/disaggregated@6672778b (two added, none lost); one definition
per moved type; the -H closure of the new header contains no MG_State/MG_Backend/
MG_Impl/MG_Remote header and the header compiles alone; nm --defined-only -S
against the base libMobileGL.so: 0 added / 0 removed / 0 resized, .text
byte-identical, the only differing bytes are the build-id and two stamp strings.
- P0.5's acceptance gate requires every `nm --defined-only -S` delta to be explainable
per symbol, and nothing in the tree reads nm or size today.
- The problem the tool exists to solve: de-nesting a type renames every mangled name
that mentions it, including inside template arguments, so a raw nm diff of a pure move
looks catastrophic. --strip-scope 'A::B::C::' rewrites 'A::B::C::X' to 'A::B::X' on the
demangled name before comparing, which folds those into a renamed-only bucket - same
normalised name, byte-identical size - and leaves the real churn visible.
- Buckets sorted by |delta|: removed, added, resized, renamed-only, unchanged, plus
.text/.data/.bss/Total from `size --format=sysv`; --only-names narrows the listing,
--markdown/--json write the report the integrator pastes into the merge commit.
- Always exits 0 (this is informational, ARCHITECTURE.md:507); --fail-on-added-bytes is
accepted and documented as reserved for the day it becomes a hard gate.
- The docstring carries the guard rails a reader would otherwise supply by assumption:
same CMAKE_BUILD_TYPE (the visibility presets differ per configuration), LTO off on
both sides, same compiler - and every report prints both paths and their byte sizes.
- --self-test runs two canned nm/size transcripts through the same parser and bucketer
and pins all five buckets, including that a de-nested member folds to renamed rather
than to an added+removed pair.
- ROADMAP P0.5 asks for a CI assertion on the include closure of the two headers the
phase extracts, and ROADMAP.md:7 asks every gate to be able to fail for the reason it
exists. `nm --undefined-only` cannot express either: a header that is included but
whose types are never named leaves no symbol behind, and "included at all" is exactly
the coupling P1 and P7 have to sever. The preprocessor's own `-H` transcript can.
- Three probes, coded against the fixed path contract of the P0.5 brief so this package
lands before the headers do: value-header (MG_Pipe/MGPipeValueTypes.h: no MG_State/,
MG_Impl/, MG_Backend/, MG_Remote/), artifacts-header (ProgramArtifacts.h: no
ShaderObject.h, ShaderTranspiler/, Config.h, MG_Backend/, BufferState/, ProgramState/
Shader*, plus a budget of two `glslang::` tokens for the two members D5 keeps
verbatim) and wire-header (ITransport.h must not reach Includes.h - green today, so
the gate has a live probe from its first commit).
- The forbidden sets say nothing about glslang, spirv-cross or vulkan on purpose:
Includes.h pulls all three unconditionally and both new headers are allowed
<Includes.h>, so a textually glslang-free closure is unsatisfiable by construction.
P7 measures that with `nm -D | grep glslang` on the server binary instead.
- Two modes because they check different things. Text mode walks literal #include lines,
needs no compiler and no submodules, and is what the ctest runs (the CI `test` job's
runner has neither); clang mode is the arbiter, and adds a -fsyntax-only pass proving
the header is self-contained. `--mode both` additionally fails on a disagreement
between the two violation sets, so text mode's blindness to `#if` cannot hide a hit.
- -H parsing normalises before matching (today's transcripts contain
TextureState/../SamplerState/SamplerObject.h) and accepts only `^\.+ ` lines, which
discards the "Multiple include guards may be useful for:" paragraph g++ appends.
Both are pinned by a canned-transcript check inside --self-test.
- D9 skip semantics: a probe whose header does not exist prints SKIP and is counted, and
--require-all turns every SKIP into a failure. That is what lets the gate land first
and still stops an all-SKIP run from passing for free once the headers exist; the
integrator flips --require-all on after all three P0.5 packages land.
- --self-test is always on in both the ctest and the CI job: it synthesizes its TUs in a
tempdir (it never touches a tracked file) and requires a negative control that does
not depend on P0.5 at all - MGPipeHandles.h plus RenderState.h checked against the
value-header list - to report RenderState.h as a depth-1 violation in every enabled
mode. Zero trips anywhere is an ::error:: and exit 1, because a gate that cannot go
red is not a gate. Controls 2 and 3 arm themselves as the two headers appear.
- Registered as MobileGLPurity.IncludeClosure with LABELS unit so `ctest -L unit` runs
it, and with no ENVIRONMENT property, which would replace the job env wholesale.
- 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
- 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
- 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
- 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
- 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).
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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).
- 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).
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- docs/Disaggregated/PLAN.md is now the one plan: the MGPipe design with the transport, control-plane, present, threading, EGL/process, monolith/build chapters inlined as real chapters (7-13) instead of references, sections renumbered 0-17 + appendices, every internal citation updated
- the replica-GLContext plan and its review record are removed at the user's request; REVIEW.md is the MGPipe design-competition and adversarial-review record only, with the comparison verdicts dropped and the remaining finding text reworded to the new section numbers
- day-43 GO/NO-GO now names its two outcomes (continue / shrink to headless tooling or re-evaluate) without any rollback path
HeadlessGL reported the literal "<unset>" as the backend name when the variable was
not set, while MG_ConfigLoader::InitBackendType defaults it to DirectGLES and brought
DirectGLES up. Every ctest entry sets the variable through its ENVIRONMENT property,
which is why nothing ever noticed - but run straight from an adb shell, where nothing
sets it, the name matched neither backend and every case gated on DirectGLES skipped
as though DirectGLES were not running. On an Adreno 830 that silently disabled the
gl_ViewportIndex emulation control and the DirectGLES-only attachment scenarios.
Android links this module against the SHIPPING libMobileGL.so on purpose, so the
on-device run validates the real artifact - and that library is -fvisibility=hidden.
Six symbols the scenarios reached for were therefore undefined and the executable
could not be linked at all: MG_Config::Features, SetAsyncShaderCompileSuspended,
ShaderCompilePool::Get/GetThreadCount/SetMaxConcurrency and
AsyncShaderCompileEnabled. All six are gone rather than exported.
CompilerThreadScope now restores the pool with glMaxShaderCompilerThreadsKHR
(0xFFFFFFFF), which MaxShaderCompilerThreadsKHR_State defines as exactly the two
steps it used to perform by hand, and AsyncAndSyncProgramsRenderIdenticalFrames
picks its two modes with the same entry point - a zero count compiles inline, a
nonzero one lifts that - so the switching is now itself under test.
ExtensionStringMatchesTheConfiguration stops deriving its expectation from
AsyncShaderCompileEnabled(), the very function the backends gate the extension
string on: it was asserting the implementation against itself and would have passed
however wrong both halves were. The expectation is now MOBILEGL_ASYNC_SHADER_COMPILE
as the process inherited it, and the case skips where that is unset because the
built-in default is a value only the implementation knows.
Both-mode coverage in one ctest run is preserved by registration rather than by
in-process forcing, and is wider than before. New entries, each APPENDING to the
common/Vulkan environment so the EGL-vendor and ICD pinning is not lost - a ctest
ENVIRONMENT property replaces the job environment rather than adding to it:
DirectGLES./DirectVulkan.AsyncOn. run all of AsyncCompileScenario with
MOBILEGL_ASYNC_SHADER_COMPILE=1; .AsyncOff. run the extension case with =0, which
asserts the withdrawn side that nothing covered before;
.OptimisticShaderStatus. run the Iris-shaped case with
MOBILEGL_ASYNC_OPTIMISTIC_SHADER_STATUS=1 (its own entries because the quirk is not
neutral for the rest of the scenario); DirectGLES.NoViewportArrayEmulation. runs the
emulation control with MOBILEGL_FORCE_VIEWPORT_ARRAY_EMULATION=0. Run from a device
shell with nothing set, the ambient configuration runs and the rest skip cleanly.
1836 -> 1853 ctest entries, all green.
Initialize the desktop replay Request from the three iterationRP environment flags before loading MobileGL. Without this, the Request defaults caused the replay core to unset CI's exported flags, leaving all repairs disabled despite the workflow configuration.
Run the lavapipe Program 203 golden test with the same subgroup scratch, derived topology, and missing-barrier repairs as the iterationRP retrace matrix. This keeps the prerequisite integration job from failing before retrace jobs can start.
Program 203 reuses prefixSumCache for a second subgroup reduction before every workgroup invocation has consumed the first result. Add a fingerprint-gated SPIR-V pass that inserts the missing Workgroup acquire-release barrier while preserving native subgroup operations.
Keep the repair opt-in behind MOBILEGL_ITERATIONRP_FIX_BARRIER, cover insertion, pass-through, and idempotence, and enable it together with the existing iterationRP subgroup repairs for the matching Linux and Android CI retraces.
Add a deterministic iterationRP Program 203 fixture that dispatches the original shader and compares every RG16F texel against fixed half-float golden bits. This catches both a wrong exposure result and collateral writes without retaining a serial reference shader.
Make MobileGLIntegrationTest runnable as a standalone Android executable by linking the shared MobileGL library and backing EGL with an AImageReader window; desktop keeps its static-library pbuffer path.
Validation: Adreno 830 passes with 0/262656 mismatches; lavapipe reproduces the current reduction defect with 1/262656 mismatches at the exposure texel.
Final audit round over the DirectGLES scratch/shadow mechanisms; three verified
defects fixed:
- ~ScopedEmulationDrawState restored the APPLICATION's per-buffer colour masks,
not what SyncRenderState actually pushed: a widened attachment's alpha-off
doctoring (g_syncedColorMaskAlphaWidenMask) was dropped while the memo still
claimed it applied, so the next sync early-outed and draws wrote fragment
alpha into the widened buffer - breaking the stored-alpha==1.0 invariant the
widen discipline exists to protect. The restore now re-applies the doctoring.
- The same restore loop gated on the core glColorMaski name only, while the sync
push falls back to glColorMaskiEXT/OES: EXT/OES-only devices were left holding
buffer 0's mask broadcast across every draw buffer with the shadow recording
the divergent set (never repaired). The restore now uses the same three-way
pointer fallback.
- ResolveThenBlit ran its resolve-into-scratch staging blit under the
application's scissor: a box not covering the scratch-origin rect clipped the
resolve silently (no GL error), and the second blit then copied stale scratch
renderbuffer texels into the destination. The staging blit now runs scissor-off
(shadow-tracked, like ScopedScissorDisable); the caller-visible blit keeps its
native scissor semantics.
Six more verified defects from the residual memo/cache mechanisms:
- Program resource cache (DirectVulkan reflection): glShaderStorageBlockBinding
deliberately does not bump the backend state version, and the SSO pipeline
composite is unnamed so the by-name in-place patch can never reach its slot -
the composite kept serving pre-rebind SSBO bindings. The cache now keys on the
program's block-binding version; a binding-only change re-applies the overrides
by name instead of re-running spirv-reflect. SetShaderStorageBlockBinding also
gains the equality bail-out its uniform-block sibling has, so the composite
mirror's replay stops churning the version every draw.
- LinkProgram's allowVSOnlyPrograms function-static latch never set its own
initialized flag (dead memo, re-read every call) - and completing it would have
frozen a per-backend capability across re-initialization. Replaced with a fresh
per-link read from the null-checked active backend.
- Query object registry: drained at full library teardown (DestroyAllQueryObjects,
mirroring DestroyAllSyncObjects) - undeleted queries and their backend wrappers
leaked across Destroy/Initialize cycles, stale ids stayed IsQuery == GL_TRUE in
the re-initialized library, and a later delete could hand the old backend's
wrapper to a different backend's DeleteBackendQuery.
- Converted vertex streams and the host-side EBO max-index scan now SyncGpuWrites
before reading the coherent mapping: XFB/SSBO/image writes are merely recorded
at that point, so the conversion read pre-write bytes (the restart-index
rewrite already synced; these two host reads did not).
- Zero-stride converted bindings: both converters rejected stride 0, making the
factory's documented single-element conversion unreachable and silently
dropping every draw using such a binding; the stride is substituted with the
element size for the one-element case.
- DemoteFloat64Pass block relayout: measurement queued into the module eagerly,
so a mid-struct failure left a half-relaid-out block (compacted offsets before
the failing member, 64-bit offsets after) while claiming the block was left
alone. Decoration writes are now collected and committed only when the whole
block measures successfully.
Audit of every memoization implementation; sixteen verified defects fixed:
DirectGLES backend:
- Broadcast draw-buffer memo: cleared at MakeCurrent/DestroyEGLContext like its
sibling shadows; its identity+version key is only monotonic within one GLContext,
so a library teardown + re-init could false-hit on a recycled FBO address.
- Backend texture id re-mint (RecreateBackendTexture) now bumps an attachment
generation that the SyncCurrentFBO gate and every FBO twin compare, so driver
FBOs re-attach instead of keeping the deleted texture name; the attachment walk
re-enters until the generation is quiescent (a walk itself can re-mint).
- Buffer id re-mint (persistent-map adoption, immutable-store retire) now bumps a
generation the VAO twin sync compares, forcing a full re-emit of the baked
glVertexAttribPointer / element-array bindings that frontend versions cannot see.
- VAO element-array sync memo: bound-object identity joins the wrapping Uint16
slot version (same pairing the ResolvedDrawBuffers IBO memo already uses).
DirectVulkan backend:
- EBO slice memo gains the mapped-buffer guard its vertex-binding sibling has: a
shadow-backed persistent map mutates with no epoch bump, so a hit must decline.
- VkClearManager::MergeClearPayload keeps colorEncoding/colorInt/colorUint with
the color, so deferred glClearBufferiv/uiv no longer degrade to all-zero float.
- GetOrCreateComputePipeline no longer memoizes a failed creation (same contract
as PipelineFactory): a transient driver failure was permanently disabling every
dispatch of that program.
- Explicit-LOD-0 verdict memo keys on the sampling-resolution generation; sampler
filter/aniso/LOD setters bump only that counter, so the old key served a stale
verdict (wrong SPIR-V variant) after glTexParameter/glSamplerParameter changes.
- SetupDraw fast path declines instead of re-arming on a moved sampling-resolution
generation (the snapshot bakes the LOD verdict into its pipeline), and
recomputes the XfbCapture bit so the first draw after glBeginTransformFeedback
cannot bind the undecorated variant and silently capture nothing.
- VertexInputStateFactory eviction epoch is drawn from a process-wide source: VAO
state-pointer memos outlive the factory across renderer recreation, and a fresh
factory restarting at epoch 1 would dereference a dead factory's entry.
- Cached render passes re-read the live renderbuffer clear payload at begin (the
clear VALUE is not in the pass hash; the entry's inline snapshot replayed the
creation-time color and dropped the newly queued one).
- FramebufferObject gains a never-reused lifetime id, keyed into the render-pass
fast-path memo and the SetupDraw snapshot beside the raw pointer + Uint16
version pair, which address reuse plus fresh version counts could equal.
- SyncTextureResource's preserved-content image goes through the deferred-release
ring on both failure paths instead of a synchronous destructor under the GPU.
MG_State frontend:
- Layer-1 compile memo is env-disciplined like layers 2/3: a node computed against
a dead CompileEnv (e.g. pre-capability fallback limits) no longer answers
glCompileShader forever once the environment's content changes.
- Pipeline composite cache rebuilds from each stage program's last-link shader
snapshot (new LinkedShaderRef list + pinned link inputs) instead of the live
attach list and current compile nodes: post-link glAttachShader/glCompileShader
must not leak into the composite while the (lifetimeId, linkVersion) signature
still hits - GL's "as last linked" rule.
The previous commit's fingerprint was pinned to one array's incidental
dimensions - workgroup exactly 32x16x1, element exactly vec2, length
exactly 32 - which is the auto-exposure reduction and nothing else. The
pack ships the same idiom twice:
- auto-exposure: 32x16 (512 invocations), shared vec2 prefixSumCache[32]
- RTW warp: 1024 invocations, shared float prefixSumCache[64]
so the warp kept writing 128 subgroups into 64 entries on an 8-lane
device and the retrace stayed bit-identically wrong (ssim 0.027902).
Key the fingerprint on the pack's idiom instead of one array's shape: a
workgroup array of 32-bit floats indexed by gl_SubgroupID, fed by a
subgroup scan, whose declared length is below ceil(invocations / native
width). Three properties keep that a targeted repair rather than a
general array resizer:
- the index must BE gl_SubgroupID (through OpCopyObject, a signedness
OpBitcast, or a spill whose every store is that id), so an index
masked or clamped into range is left alone;
- the >= 16-lane early-out is retained, so every module on the devices
the pack was written for passes through byte-identical;
- growth is certified against maxComputeSharedMemorySize using a
natural-alignment layout model, and declined outright when a
declaration cannot be sized, so a patched module can never fail
pipeline creation where the original would not have.
Verified against the shaders the CI trace actually contains: of the 14
compute modules in the fixture exactly these two change, the other
twelve are byte-identical, and all fourteen pass spirv-val. The
integration scenario grows a second case for the 1024-invocation shape;
both abort with heap corruption when the patch is disabled.
iterationRP's Program 203 declares shared vec2 prefixSumCache[32] for a
512-invocation workgroup indexed by gl_SubgroupID; any device narrower
than 16 lanes partitions into more than 32 subgroups and the pack writes
shared memory out of bounds (heap corruption on lavapipe's CPU
rasterizer, ssim 0.028 on the CI retrace). Fix it where the fault lies -
in the fixture - and keep the GL contract sound everywhere else:
- FixIterationRPSubgroupScratchPass: fingerprint-gated SPIR-V pass that
grows exactly that array to ceil(invocations/width) entries on sub-16-lane devices; every other module passes through byte-identical.
- DeriveNumSubgroupsPass stays default-on for the Adreno topology bug
and is made spec-sound: pipelines request REQUIRE_FULL_SUBGROUPS
whenever the workgroup shape makes the flag legal (computeFullSubgroups
enabled, local_size_x a multiple of the native width, subgroup count
within maxComputeWorkgroupSubgroups).
- EmulateSubgroupsPass: 32-lane virtual-subgroup lowering kept in-tree
as a last resort, enabled only by MOBILEGL_MAGMA_EMULATE_SUBGROUP=1 on
devices with no native subgroup support; fails closed on extended
subgroup instructions and on modules whose added scratch would exceed
maxComputeSharedMemorySize.
- IterationRPFirstReductionScenario skips gracefully outside the pack's
16..256-lane source domain; the new IterationRPScratchFixScenario runs
the fixture-shaped reduction on any width and asserts the exact
width-independent total. DriverPost keeps reporting FAIL on
out-of-domain devices.
- Program203 -> IterationRP rename throughout; the per-trace
num_subgroups_quirk plumbing is removed from the trace replayer, JNI
chain, and CI workflows.
Complementary Reimagined would not load through Espryt on Mali: Iris got
GL_FRAMEBUFFER_UNSUPPORTED building its composite framebuffer, because
colortex1 is RGB8_SNORM and colortex2 is RGB16F - three-channel formats
that no real ES driver can render to (EXT_render_snorm covers R/RG/RGBA
only, and the float extensions exclude the RGB forms). The frontend's
probe cache diagnosed this correctly and then had nothing to offer: the
NoThreeChannelRenderTarget widening machinery existed but was gated to
multisample targets alone. llvmpipe turns out to refuse most of the same
attachments - CI retrace stayed green only because a replay never
branches on glCheckFramebufferStatus - so this was never a desktop-vs-
device split, just an unlit path.
The widening now applies to every color-attachable image, renderbuffers
included, riding the driver-probe branch so the native format is still
tried first and substituted only on refusal. One ThreeChannelWidening
table owns the widened (internalformat, format, type) triple per source
format - the previous per-case branches disagreed with each other and
could emit an unuploadable (RGBA16F, GL_RGB, GL_BYTE) combination or
widen into another three-channel format the driver refuses just the
same. Uploads repack three-component client data to four with the
format's own one in the alpha channel (127 is not 1 for RGB8I - the
integer arms carry integer ones); readback drops the synthetic alpha,
derived from the actual image being read, not the bound framebuffer,
so glGetTexImage through a scratch FBO cannot be confused by an
unrelated widened attachment.
Stored alpha on a widened attachment is now an invariant 1.0 rather
than an accident: the color-mask sync clears the alpha bit per draw
buffer (glColorMaski for MRT mixes), and clears route through
glClearBufferfv with alpha substituted on widened slots only -
scissored clears inherit the discipline for free, integer color
buffers keep their explicit integer-clear path, and glGet still
answers the application's own mask. GL_DST_ALPHA blending, blits and
readback therefore all see 1.0 without further interception.
DriverPost grows the rows this bug earned: EXT_color_buffer_float
detection (previously unreferenced anywhere) with a FAIL row when
absent, the missing EXT_render_snorm row, and a three-channel-
attachment row that reports one representative per widening class -
graded so a half-float-only driver warns about the 32-bit float gap
instead of being declared unsupported.
Gates: 606/606 unit at default and with the async kill switch; full
retrace, both backends - the complementary fixtures now run with the
widening ACTIVE on llvmpipe and pass with a slightly better SSIM than
before; ext caselist DirectGLES holds 3914/4867 with zero set drift
while 54 cases move from NotSupported to genuinely passing; on the
Mali-G77 device, Complementary Reimagined builds its pipeline and
renders in-world through Espryt (md5-verified build), BSL still green.
A new ThreeChannelAttachmentScenario pins the frontend answer -
COMPLETE where it used to say UNSUPPORTED - on the real driver.
minecraft-1.21.4-fabric-iris-bsl-esc-menu-854: Minecraft 1.21.4 Fabric +
Sodium + Iris + BSL 10.1.3 through Espryt on Mali-G77, paused at the ESC
menu over the BSL-blurred world, captured at 854x480 through the FCL
trace-capture skill. The trimmed trace keeps the whole session preamble
deliberately: the 107669b3 bug class is triggered while Iris BUILDS the
pipeline (a by-name texture call swapping the active unit's binding under
the sync memo), and the pause menu afterwards is where its damage is
legible - every glyph alpha-discards against the zeroed lightmap while
the button frames survive.
Ablation-proven as a net before landing: against a build with both layers
of 107669b3 reverted the case fails at ssim 0.949284 and the diff image
is text and only text - menu title, all button labels, the tutorial
toast; against the fixed library it matches the golden exactly, and
Magma passes the same golden at 0.998402 with no alternate needed.
Replays in about a minute per backend on llvmpipe/lavapipe (the 1.3M-call
preamble; well under the timeout and smaller than several existing
fixtures). Existing fixtures spot-checked green.
Field report: on Espryt with a BSL Iris pipeline built, every glyph in the
game died - HUD, menu labels, even the vanilla title screen after leaving
the world - while sprites kept rendering. Captured on-device (FCL apitrace
rig), reproduced headlessly on llvmpipe, and pinned with a three-way replay:
the same trace renders full text on raw Mesa desktop GL and on Magma, so
the stream was intact and the execution was Espryt's.
MECHANISM. WithTemporarilyBoundNamedTexture implements the by-name (DSA)
texture entry points by binding the named texture onto the active unit's
real slot, running the bound-texture code, and restoring - without moving
the texture bind generation on either edge. DirectGLES's per-draw texture
sync memo keys on that generation and BORROWS the slot pointer, so a memo
built for texture A kept passing every key while a by-name call had
texture B sitting in the slot: A's backend twin was driven with B's
frontend object, and SyncMipmapsToBackend re-specified A's storage with
B's shape. In the trace, a by-name upload to a BSL 2048x2048 map while
the 16x16 lightmap was bound re-specified the lightmap's GL texture
2048x2048-NULL and back 16x16-NULL. The lightmap exists only as render
output - no glTexSubImage2D ever touches it - so it stayed zero forever,
and rendertype_text (vertexColor = Color * texelFetch(lightmap, ...)),
alpha-discards every glyph. Background quads never sample the lightmap,
which is why only text died.
FIX, class-level, two layers:
- Frontend (shared, closes the same hole for DirectVulkan's generation-
keyed memos): the temporary bind and the restore each bump the texture
bind generation (only when the slot actually changed), and the restore
is an RAII scope guard so a throwing body can no longer leak the
temporary binding - a second latent bug of the same class. Deliberately
a generation bump and not a touched-unit note: the high-water mark must
not chase by-name calls, and a completed bind/restore pair leaves the
content epoch unchanged, so the cost is an owner-compare re-walk, not a
memo rebuild.
- DirectGLES defense in depth: both borrowed-pair memos
(g_unitTextureSyncList, g_fboTextureSyncList) record which frontend
texture each backend twin was paired with and re-check it before any
replay (last in the key conjunction, behind the context-id compare). A
stale pairing now costs a list rebuild instead of silent cross-texture
storage corruption.
Tests, both red with their own layer reverted:
TextureTest.NamedTextureCallKeepsUnitBindingAccountingCoherent (the
accounting contract) and DirectGLESTextureSync.UnitMemoRefusesToDriveA-
TwinFromAnotherTexture (the corrupting sequence shape against a mock GLES
table, asserting the resident texture's storage is never re-specified).
595/595 unit at default and with the async kill switch. Replay evidence:
the captured BSL ESC-menu trace renders all text through Espryt post-fix,
byte-comparable to the Mesa-direct and Magma replays; the no-shaderpack
control is unchanged. A trace fixture wiring this scene into CI follows
in a separate commit.
Wave 2 of the advertised-extension conformance campaign.
PROGRAM INTERFACE QUERIES (the load-bearing piece). glGetProgramInterfaceiv
and the five glGetProgramResource* entry points were answered by the
BACKENDS - Espryt asked the real driver about SPIRV-Cross-generated ESSL
whose namespace is not the GL one (default-block uniforms live in
MGL_GLOBAL_UBO there), and Magma kept a second, partial reflection that
hardcoded types and diverged from the frontend. Both are now deleted; a new
frontend resource-model layer (ProgramInterface.{h,cpp}) answers every
interface - uniforms, uniform blocks, atomic-counter buffers (recovered
from glslang's synthesized gl_AtomicCounterBlock_<binding> lowering),
buffer variables, shader-storage blocks (classified by TType storage
qualifier since glslang reflects them as uniform blocks), program inputs/
outputs (built-ins' layoutLocationEnd sentinel mapped to -1), and
transform-feedback varyings including the gl_NextBuffer/gl_SkipComponentsN
pseudo-varyings - from the glslang reflection the frontend already trusts
for glGetActiveUniform. Name/index round-tripping, the "[0]" array
spelling, and the GL 4.6 table 7.2 prop/error matrix live in the new layer
only; GetActiveUniform*/GetActiveAttrib* are untouched.
glShaderStorageBlockBinding now takes the interface-layer index (the one
GetProgramResourceIndex returns, with a range check it never had), records
the binding on the program keyed by block NAME - the one coordinate all
three index spaces agree on - and delegates by name across the backend
boundary. Both backends reseed the recorded bindings on their own program
rebuilds, so an unrelated resync can no longer silently revert a rebound
block, and GL_BUFFER_BINDING reports the live binding, not the declared
one. The Espryt delegate applies only to an already-synced twin and can no
longer trigger SyncToBackend from a getter; the sync path's GL query
out-params are initialized and clamped (a load-dependent stack-garbage
Vector size crash caught by the gate, reproduced 3/50 pre-fix, 100/100
post-fix under saturating load).
ESPRYT RENDER-STATE SHADOW RESET (rides along because it shares
DirectGLES.cpp): the render-state shadow is file-static and survives
MobileGL context switches, so GL_FRAMEBUFFER_SRGB (and the whole synced-cap
class) leaked between contexts - the cross-test leakage class the CTS maps
have carried for a week. MakeCurrent now invalidates the shadow like it
already invalidates the program/FBO/buffer caches, and the resync resolves
a never-set scissor box to the current surface instead of pushing the
(0,0,0,0) sentinel verbatim (which scissored everything away - caught by
the retrace gate, bisected to the exact field via a bitmask probe, and
fixed by resolving like the viewport path rather than reverting).
Frontend riders exposed by the layer: glGetUniformLocation resolves
arrays-of-arrays element addressing ("a[2][1]"); transform-feedback capture
accepts element-addressed varying names ("b[1]") and snapshots the request
verbatim for the interface (Magma's decorate pass logs loudly that element
capture is unimplemented there - follow-up).
KNOWN GAPS, documented in code and tests: the 6 subroutines-* cases
(glslang refuses subroutine for SPIR-V; wave 3), the 5 separate-programs-*
cases (glslang's pipe-I/O reflection cannot see a separable non-vertex
stage's own inputs; needs stage-aware output validation first), and
uniform-block-types' per-instance stage masks (not derivable from the
reflection).
Gate: 593/593 unit at default and kill-switch, x10 each, plus the SSB race
case 100/100 under 20-way CPU load; ext caselist Espryt 77.87% -> 80.42%,
Magma 77.58% -> 79.39% (+212 fixed, 0 newly broken); program_interface_query
2/43 -> 31/43 unique on Espryt, 9/43 -> 31/43 on Magma, backends now
byte-identical; KHR-GL45.direct_state_access 370/371 + 371/371 with the 4
sRGB leak victims recovered in cross-test ordering; KHR-GL33 held at
9884/9886; full 39x2 CI retrace with zero wave-attributable failures (the
3 failing newly-added fixtures are bit-identical on the pristine baseline).
First wave of the advertised-extension CTS campaign (targeted caselist: the
glcts groups of every extension both backends advertise, 4867 cases across the
KHR-GL41..46 namespaces). All frontend, shared by both backends:
- Non-square float matrix uniforms actually upload: glUniformMatrix{2x3,3x2,
2x4,4x2,3x4,4x3}fv and the six glProgramUniformMatrix* twins were
validate-only no-ops; they now write column-at-a-time at the global UBO's
16-byte std140 column stride, honouring transpose. glUniformMatrix2fv had
the sibling bug - mat2 written as 4 contiguous floats put column 1 at byte
8 instead of 16. The readback path only ever un-padded mat3, so
glGetUniformfv is fixed for mat2, mat3x2 (previously mis-gathered) and
every non-square shape, with the bounds check widened to the padded span.
- glBindBufferRange validates offset/size at last: size <= 0, offset < 0,
SSBO and UBO offset alignment, transform-feedback offset AND size
multiples of 4 - all before any state write (a negative offset used to
reach Range1D unchecked). glBindBuffersRange inherits per element, with
the ARB_multi_bind up-front [first, first+count) checks added to the
BindBuffersBase/Range and BindSamplers prologues.
- BufferSubData's second, wrong mapped-overlap test deleted (it rejected
every write at or after a mapped range's start, mapped or not); the state
layer's assert relaxed to the same half-open intersection the frontend
checks. BufferStorage error precedence fixed: no-bound-buffer now beats
bad-size/flags.
- glSamplerParameteri accepts the full GL_NEVER..GL_ALWAYS compare-func
range (NEVER/LESS/EQUAL were rejected by a wrong lower bound).
glBindSampler's unit gate uses GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS instead
of the frontend array capacity, shared with glBindSamplers by construction.
- Getters: GL_MAX_SHADER_STORAGE_BLOCK_SIZE in glGetIntegerv; atomic-counter
buffer limits; all 11 per-unit GL_TEXTURE_BINDING_* plus GL_SAMPLER_BINDING
in glGetIntegeri_v; GL_VERTEX_ATTRIB_BINDING/_RELATIVE_OFFSET across the
vertex-attrib query family; glGetFloati_v/glGetDoublei_v implemented (were
stubs); KHR_debug limits raised to spec floors.
- glCreateShader records INVALID_ENUM for an unknown type (it previously
handed out a usable name with no error at all); glCreateShaderProgramv
validates count up front. glDispatchCompute/Indirect validate work-group
counts, offset alignment and indirect-buffer presence.
- glVertexAttribIFormat & friends take a positive integer-type whitelist -
GL_FLOAT/GL_HALF_FLOAT/GL_DOUBLE/GL_FIXED no longer slip through as
integer attributes.
Gate (headless Mesa, default config = async on): 570/570 unit at default and
with the kill switch; ext caselist Espryt 76.29% -> 77.87% (+81 fixed, 6
crashes -> 0, the whole list now runs in one glcts process), Magma 75.94% ->
77.58% (+80 fixed, 0 newly broken); KHR-GL33 full mustpass lost nothing
(9884/9886, the 2 known Mesa-drift failures); retrace smoke clean (the
bsl-GLES miss is the documented golden drift, bit-identical on the pristine
baseline). The 4 DirectGLES direct_state_access.renderbuffers_storage* cases
that turned red are a PRE-EXISTING GL_FRAMEBUFFER_SRGB cross-test leak,
A/B-proven on an unpatched 2e6fc1ff build - wave 1 removed the two accidental
maskers (a crash partition and a failing case whose error path reset the
state). Fixing the leak itself is queued.
kAsyncShaderCompileDefault flips to true, which also advertises
GL_KHR_parallel_shader_compile by default on both backends. Unset
MOBILEGL_ASYNC_SHADER_COMPILE now resolves to ON; =0 remains the complete kill
switch (reverts the threading and withdraws the extension together).
The gate behind the flip (headless Mesa - llvmpipe for Espryt, lavapipe for
Magma - at c6299f75): GL30/31/32/33/40 mustpass plus the
KHR-GL46.parallel_shader_compile group, both backends, async=1 with the
extension advertised. 58,344 case-runs, 8 failures - and every one of the 8
also fails standalone at async=0, in the full async=0 suite arms with an
identical per-case failure set, and under the pre-P1-stage-6 library. Zero
async-attributable deltas; the 8 are Mesa-upgrade drift (4 unique signatures:
Espryt GL40 transform_feedback.draw_xfb{,_feedbackk}_test, Magma
texture_size_promotion.functional + packed_pixels rgb9_e5_format_red on
GL32/33/40), recorded for separate follow-up.
Validation under the flipped default (no env var): 553/553 unit at the default
AND with the kill switch; parallel_shader_compile 3/3 on both backends proving
the default advertises; 44+44 integration scenarios; 71/72 CI trace-replay
fixtures (the one failure is the pre-existing create-indirect lavapipe crash,
identical under the pre-fix library). The lifecycle test's contract updates
with the default: AsyncIsOnByDefaultAndTheOverrideDecidesEitherWay.
Not covered by this gate and deliberately left open: SSO/DSA suites (GL41+,
separate follow-up per review), and real-driver confirmation on NVIDIA/Mali -
the Mali-G77 on-device sweep runs separately as a report-only pass.
A destroyed VertexArrayObject's heap address is handed straight back by the
next allocation of its size, and so is a destroyed BufferObject's. DirectVulkan
keyed its per-VAO draw memo on the VAO POINTER and folded the bound buffer's
ADDRESS into the content hash that validates the memoised bindings, so a
delete/recreate pair under a byte-identical attribute layout reproduced both
the key and its validating hash at once. The successor VAO then inherited the
dead one's resolved bindings and the draw fetched from a destroyed VkBuffer.
Both stated defences failed together, because both reduce to the content hash
and the hash's buffer-identity component was itself a recycled address.
VertexArrayObject and BufferObject now carry a globally-unique, never-reused
GetLifetimeId() - the same contract as ProgramObject's, minted from an atomic
starting at 1 so a zero-initialised slot can never name a live object.
VaoDrawMemo matches on (address, lifetime id) and stores the id on recycle,
SetupDrawSnapshot's "the VAO did not move" test compares the id alongside the
config version, and VertexInputStateFactory::ComputeHash hashes the bound
buffer's id instead of its pointer (0 for client memory).
Proven: the use-after-free reproduces at 100% incidence headless on lavapipe,
including a SEGV whose backtrace is the driver dereferencing a destroyed vertex
buffer inside lvp_queue_submit, and it is gone with the fix. New coverage -
MG_Test/State/ObjectLifetimeIdTest (deterministic, GPU-free, no context: it
waits for the real allocator to repeat an address and asserts the id differs,
and skips loudly rather than passing quietly if it never gets the chance), and
MG_IntegrationTest XfbAfterClipDistanceScenario, registered for DirectGLES,
DirectVulkan, and a third DirectVulkan run with async shader compilation pinned
on because that is a second allocation pattern. Gates: 553/553 unit green at
async=0 and async=1; the scenario 5/5 headless at both flag states; 71/72 CI
trace-replay fixtures over both backends, the one failure a pre-existing
lavapipe crash proven not a regression (identical SIGSEGV at the identical
call number under the pre-fix library).
Pending NVIDIA/X11 confirmation: the KHR-GL{32,40} transform_feedback failures
that opened this investigation never reproduced on lavapipe - the -2/-101
pre-fill signature appears in zero pre-fix runs there - so whether this clears
them is UNPROVEN and must be re-measured on the NVIDIA rig against a freshly
re-run pre-fix baseline. The residual suspect is deliberately untouched here:
m_xfbCounterSlotByObject keys its counter slot on the raw GL transform-feedback
name, so a recycled name whose generation check happens to pass would RESUME
instead of BEGIN. That path was never exercised on lavapipe and is neither
confirmed nor exonerated.
~21% of a shaderpack's glCompileShader calls hand different shader objects
byte-identical source; the P0b cache only helps after one finishes, so under
async two workers would run the whole pipeline twice. Now the GL thread
consults a per-context (stage, hash, length, envFingerprint) -> weak-node
map at enqueue and ADOPTS the in-flight (or completed) node instead of
posting a duplicate - a hit is honored only after a full byte comparison
(the hash never decides), a cancel-requested or settled-cancelled node is
never adopted, and no worker ever waits.
Sharing a node makes the unconditional cancel wrong, so release is now
adopter-counted: a plain GL-thread Int (every mutation site is a GL entry
point; the single-threadedness argument and the terminal-early-out that
keeps the count exact are in the header), and the cancel fires only at
count zero AND with no pending link pinning the node (the stage-4
MarkLinkReferenced precedence). Adoption also re-points the object's source
at the node's snapshot so the layer-1 memo's pointer compare stays armed -
without that, an adopter's next glCompileShader would re-enqueue the very
duplicate this stage removes. Both guards are negative-control-proven: each
removed guard fails exactly its own tests. Count discipline was proven with
a temporary hard-abort on underflow/leak across the full suite and retrace
corpus - zero hits.
18 new tests (13 GL-surface incl. shared-node re-source/delete/orphan-sweep
isolation, shared failure logs, 48-over-6 stress with a deterministic
adoption count, flag-off and KHR-suspended zero-adoption guards; 5 direct
map cases incl. fingerprint mismatch and cancelled/expired pruning).
Gates: 538/538 unit both flag states, async suites x5 no flakes, NVIDIA
DirectGLES retrace identical sets both states. Timing: 2-worker
(Android-shaped) 1-3% faster consistently on complementary and BSL;
4-worker unchanged - the win this stage exists for lands where CPU is
scarce.
Same gated push as the Espryt commit. The tests ride here because they
exercise both backends' advertisement paths and every piece of the
extension: ParallelShaderCompileTest (13 unit cases - the held-job proof
that GL_FALSE is observable and a second poll still shows outstanding work,
program equivalent, untouched objects read TRUE, always-TRUE with async
off, unknown pnames still INVALID_ENUM, zero-count join+inline for compiles
AND links, nonzero restores while Initialize() does not, clamping and
0xFFFFFFFF and KHR/ARB sharing one state, the getter vs the budget, the
string tracking configuration through glGetString AND glGetStringi) and
AsyncCompileScenario (5 real-GPU cases per the design: 64-compile polling,
forced-join correctness, string/thread-count checks against a live driver,
zero-count synchronous settlement, and async-vs-sync frames rendered
byte-identical with quadrant signatures so two identically-wrong images
cannot pass). Verified 5/5 on NVIDIA in the full 2x2 backend x flag matrix
with MOBILEGL_ITEST_REQUIRE_GPU=1.
Gated on the configuration flag on purpose: the string is the one change a
retrace can never cover (Iris/Sodium pipeline their submissions differently
once they see it), so the kill switch has to withdraw the app-visible
behaviour along with the threading.
GL_COMPLETION_STATUS_KHR in both object getters, reading the non-joining
node-direct state - the one query that must never block is asserted never
to reach a join gate. glMaxShaderCompilerThreadsKHR/ARB share one
implementation: a zero count suspends async FIRST and then joins every
outstanding compile and link this context owns (suspend-before-join is the
only order whose post-condition is 'nothing in flight'), a nonzero count
restores; the suspension is a process latch the extension controls, kept
distinct from the configuration flag that gates the ADVERTISEMENT - an app
that turned threading off has not made the extension disappear.
GL_MAX_SHADER_COMPILER_THREADS_KHR reports the thread count. DriverPost
gains the MobileGL-side async row (PASS/INFO naming the env knob) and an
informational host-driver row backed by a new GLES capability probe.
The extension string itself lands per backend in the two follow-up
commits, keeping this one green stand-alone.
glLinkProgram with the flag on snapshots its inputs in a GL-thread prologue
(stage-sorted shaders with their compile nodes taken without joining, env,
explicit locations/fragdata/xfb, draw-buffer count), then runs the whole
link body - glslang link/mapIO, SPIR-V, reflection, routing tables - as a
ProgramLinkTask that auto-posts when its last compile dependency settles
(+1-guarded countdown; no worker ever waits on another job). The publish is
one move of the LinkArtifacts block at the join, with the second version
bump so nothing memoized during the pending window survives.
The consume-once TShader claim moved onto the shared compile node as a CAS:
two link jobs racing for one shader resolve to winner-takes-the-parse,
loser re-parses the preprocessed source against the node's own env -
identical SPIR-V pinned by test for 2 and for 12 sharing programs.
Two deliberate corrections to the design's cancel matrix, both test-proven:
attach/detach do NOT cancel a pending link (the snapshot isolates it, and
glCreateShaderProgramv's link-then-detach would otherwise discard its own
result before anyone read it); and a compile node a pending link depends on
is pinned against the orphan-name sweep - the ordinary LWJGL teardown
compile/attach/link/detach/delete used to cancel the dependency and turn a
must-pass link into GL_FALSE.
Continuations are now throw-contained per-item (a stage-3 leftover made
load-bearing by the first real continuation), and the review's deadlock
find is fixed: the dispatch loop no longer cancels a node while holding the
pool mutex, since that cancel can run OnDepSettled -> Post -> same mutex.
Explicit joins: the draw path (GetProgramForDraw, both the pipeline stage
loop and the plain-UseProgram half) and the composite-link site; destroy
paths cancel-not-join; COMPLETION_STATUS readers stay non-joining.
Gates: 506/506 unit both flag states; AsyncCompile/AsyncLink/AsyncTeardown
suites x10 repeats clean both states (teardown with 128 jobs in flight,
then re-Initialize); full NVIDIA DirectGLES retrace flag on twice - result
sets identical to flag off, zero new deltas. Compile-phase prefix-diff,
flag on vs off: complementary-reimagined 5.21s -> 2.16s, BSL 1.72s ->
0.90s - past the design's final acceptance targets before the KHR
extension is even advertised. Default remains OFF until stage 5+7.
glCompileShader with MOBILEGL_ASYNC_SHADER_COMPILE=1 snapshots its inputs on
the GL thread (source SharedPtr, CompileEnv, cache handle) and runs the whole
pure pipeline - preprocess, validators, extractors, glslang parse - as a
ShaderCompileTask on the worker pool, returning immediately. Every read of
compile-produced state joins through the single Compiled() gate; links stay
synchronous this stage and join their attached shaders at the top of the
body. Flag off, the path is the same code run inline.
Mechanics: the job node owns all its inputs (no back-pointer, no lifetime
tie to the shader object), so re-sourcing or deleting a pending shader is
cancel-and-drop, never a wait; glslang worker hygiene is a TLS-allocator
scope guard plus GL-thread builtin prewarm (gated on the flag, latch reset
on Destroy so re-initialization re-warms); worker-side diagnostics defer
through the job and replay on the GL thread at the join, enforced by
IsPoolThread asserts in RecordError and an empty-deferred-errors tripwire.
A body that throws publishes a COMPLETE failed compile (status false, real
info log) rather than an abandoned node, and never memoizes away the retry;
a failed enqueue (OOM) cancels the node instead of stranding the joiner -
including inside the dispatch loop, where the in-flight slot is repaid.
The pool StopAndDrains from an atexit sentinel too: workers still inside
glslang parse while exit() ran static destructors was a real 2-in-5 SIGSEGV,
reproduced and fixed (15/15 clean after).
Backend-internal shader objects (default FS, DirectVulkan blit/mipmap) are
cache-less and always compile inline - compile-and-read-in-one-breath needs
no round trip.
Gates: unit suite 488/488 with the flag off AND on (x5); AsyncCompileTest
(12 e2e cases: pending re-source/delete/recompile, byte-identical failure
logs across modes, 48-compile cache stress) x10 repeats clean both modes;
full NVIDIA DirectGLES retrace identical result sets flag off/on (zero new
deltas); compile-phase timing flat as designed (links still serial - the
parallel win arrives with stage 4's async link + stage 5's
KHR_parallel_shader_compile).
Still fully synchronous - EnsureLinkJoined()/EnsureCompileJoined() are empty
inline no-ops (verified to fold away at every one of the ~1200 call sites;
this project builds without LTO) - but every read of link- or compile-produced
state now goes through a private accessor the compiler enforces, so when
stage 4 moves the bodies onto pool workers, 'which reads must join' is a
type-system fact instead of a 400-line audit.
- ProgramObject: the 31 fields ResetLinkArtifacts clears plus the 5 link
outputs it forgot (infoLog, linkedFragData{Location,Index}, the geometry
strip-capture pair) move into a nested LinkArtifacts behind Artifacts().
ResetLinkArtifacts is now a worker-safe pure clear; the link-observable
version bumps (backendState/link/uboContent) move to a GL-thread-only
BumpLinkObservableVersions() called once from Link()'s prologue and from
glProgramBinary's mandated failure - the link body never writes them, so
a stage-4 worker cannot lose an invalidation against the draw path.
- ShaderObject: compile artifacts (TShader, preprocessed source, side-channel
maps, status/log, consume-once flag) behind Compiled(); the P0b layer-1
memo trio deliberately stays outside as the future non-joining
COMPLETION_STATUS_KHR fast path.
- CompileEnv (new): a GL-thread snapshot of everything the compile pipeline
used to read live from the backend mid-parse - compute limits (the
GetIntegeri_v reach-back is gone from the worker path), advertised
extensions, device quirks, TBuiltInResource inputs. Captured lazily per
backend activation; the consume-once re-parse now runs against the same
env as the original parse.
- The GL-thread prologue / worker-body boundary is marked in Link() where
the stage sort ends; everything below is a pure function of the snapshot.
Public getter signatures unchanged - MG_Impl and both backends compile
untouched. Unit 476/476, Program suites 117/117, DirectGLES retrace 38/39 on
llvmpipe (the one failure is the known pre-existing non-CI iterationrp case;
the NVIDIA userspace driver was updated out from under the running kernel
module mid-session, so GLX there is down until a reboot).
Standalone Asio (submodule, asio-1-38-2 @ 8806a680, ASIO_STANDALONE +
ASIO_NO_DEPRECATED, header-only - no linked artifact) and the job machinery
the async shader pipeline will run on: JobNode (state machine with deferred
errors, continuations firing exactly once, dependency counters, cancel
semantics split into request vs outcome) and ShaderCompilePool
(asio::thread_pool behind a pimpl so no header leaks asio; big-core count
via cpufreq at >=85% of peak clamped to [1,4]; lazily constructed, so with
the flag off no worker thread ever exists; StopAndDrain leads DestroyImpl).
MOBILEGL_ASYNC_SHADER_COMPILE / _THREADS config knobs, default OFF. Nothing
in the GL pipeline references the pool yet - grep-verified; the full
DirectGLES retrace and compile benches are byte- and time-identical. 25
threaded unit tests, clean across 20x gtest_repeat.
- SpvcSession's move constructor and move assignment dropped the parsed
metadata, so a moved-to session silently reported empty reflection.
- ParseComputeLocalSize used std::stoull, whose std::out_of_range escaped
glCompileShader on an oversized local_size literal; now std::from_chars
saturating to UINT_MAX, pinned by a regression test that reproduced the
escaping exception.
- The compute local_size std::regex was rebuilt on every compile; hoisted.
- LinkProgram dumped every shader's full source through MGLOG_D per link.
- glslang::FinalizeProcess ran before the GL context tore down, leaving the
context's live TShaders pointing at freed builtin symbol tables.
Iris-style packs hand MobileGL the same source text repeatedly: probed across
three shaderpack traces, 28-32% of all glCompileShader work was redundant -
~9% same-object recompiles with byte-identical source, ~21% distinct shader
objects sharing identical source (the same common GLSL chunk glued into many
program stages). Two layers, both keyed by XXH64 + length with a full byte
compare on every hit (correctness never rides on the hash):
- Per-object: a successful (or failed) compile remembers its source hash;
glShaderSource with byte-identical text keeps the compiled state and
glCompileShader on unchanged source returns immediately. Deterministic
(stage, source) pipeline makes the memo observationally identical to
recompiling; the consume-once TakeShaderForLink re-parse path is untouched.
- Cross-object: a per-context bounded cache (ProgramState-owned, declared to
outlive every shader object) shares the preprocessed source, both explicit
side-channel maps, and the validation verdicts between objects with equal
source; only the glslang parse stays per-object. Single-GL-thread today;
flagged for a mutex when compiles go async (P1).
Interleaved A/B on the iterationrp trace (the recompile-heavy pack):
5.65s -> 5.46s median total replay, every round faster; BSL/complementary
stay flat (their duplicate sources are the small common shaders, so calls
drop but wall time is parse-bound on unique sources). Full DirectGLES
retrace, 445-test unit suite, and dedupe-semantics tests (no-op recompile,
invalidation on new source, failed-compile memo, cache bounds) all green.
Three scoped changes to ShaderSourceProcessor, none altering any transform's
output (pinned by a byte-stability test across the legacy-shader anchor path):
- Delete BlankBlockComments and RemoveDefineForIdentifier - dead since their
callers left; the former's newline-terminated quote handling moves into
MaskCommentsAndQuotedText (below) together with its rationale comment.
- Fix MaskCommentsAndQuotedText treating a quote as running past end-of-line.
GLSL has no multi-line literals, but a stray apostrophe in a directive or
comment tail ("#pragma message can't") blanked the REST OF THE FILE for
every masked consumer - the tokenizer, the version inspection, and the P0a
explicit-location/binding extractors silently lost everything after it.
- Inspect the shader language once per PreprocessShaderSource run instead of
up to five times: NormalizeVersionDirective now takes the already-computed
ShaderLanguageInfo, and the two after-version injections share one
AfterVersionAnchor instead of re-running a full masked sweep each
(FindAfterVersionDirective -> InspectShaderLanguage) to find the same spot.
Compile-phase timings hold (BSL 1.848s, complementary-reimagined ~5.7s);
retraces and the 435-test unit suite unchanged.
RenameBuiltinShadowingFunction probed the whole source ten times per compile
(5 names x mask + scan, each a full-text pass) and still had two blind spots:
a 5-name list and single-line-definition-only detection. On Complementary-scale
packs (4.5MB of sources) that was ~68% of the compile phase.
The rename is now split by FAILURE LAYER, both halves sharing one name table
header so they cannot drift:
- A SPIR-V OpName pass in SanitizeAndOptimizeBinary covers the full ESSL 3.20
builtin set (~146 names). Renaming a function id is safe by construction:
builtin calls are GLSL.std.450 instructions and can never resolve to a user
OpFunction, overloads are distinct ids (a helper overload delegating to the
real builtin keeps working), dead preprocessor branches never reach SPIR-V,
and macro-expanded definitions are covered. ESSL 3.x is the only consumer
that forbids the redefinitions, and this pass runs before its transpile.
- A lexical pass covers only the 5 names whose exact-signature redefinitions
glslang's relaxed parse rejects outright (never producing SPIR-V for the
backstop): the historical fma/max3/min3/round/tanh. One TokenizeCode pass;
definition detection requires brace depth 0, a type-identifier previous
token that is neither a statement keyword nor a directive tail, and skips
files whose token-level braces do not balance (preprocessor-asymmetric
arms) - over-detection is unrecoverable, so every ambiguity falls through
to the backstop.
Measured on the compile phase (prefix-diff, 3-run medians, Espryt/NVIDIA):
complementary-reimagined 20.0s -> 5.5s, BSL 2.14s -> 1.85s. bliss (the pack
that ships from-scratch fma/tanh helpers) stays at SSIM 0.999962.
Tests: end-to-end ESSL assertions for the multiline-definition and
new-overload shapes, the three adversarial-review reproductions (statement-
keyword call under asymmetric braces, dead-#if compat shim, overload
delegating to the shadowed builtin), and a source-level assertion pinning
the lexical half specifically.
glCompileShader used to parse every source twice: once under the GL client
(reflection only) and once under the relaxed Vulkan client (SPIR-V + the
plain-uniform global UBO), with GenerateBinary re-preprocessing, re-parsing
and re-linking every attached shader on every glLinkProgram. The GL-client
pass is gone: Compile() performs the one link-compatible relaxed parse and
the linked TProgram serves reflection and codegen both. Measured on the BSL
shaderpack compile phase: Espryt 2.80s -> 2.14s, Magma 3.78s -> 3.07s.
What the relaxed parse cannot provide is restored explicitly:
- explicit layout(location/binding) qualifiers on default-block uniforms and
samplers are extracted lexically at Compile() (the relaxed parse strips
them) and merged per link with cross-stage conflict checks;
- uniforms the relaxed parse sweeps into MGL_GLOBAL_UBO but no stage reads
are filtered from the GL reflection surface through GL<->TProgram index
translation maps (dead uniforms stay inactive, the synthesized block stays
hidden, builtins reflect under their GL spellings);
- SPIR-V is generated BEFORE buildReflection touches the program (its
live-variable analysis perturbs GlslangToSpv output - generated modules
stay bit-identical to the old pipeline's), while the glUniform*-to-scratch
routing tables are built strictly AFTER reflection, whose results size and
key them;
- a TShader feeds exactly one link (mapIO mutates the intermediate); relinks
and multi-program attachments re-parse the stored preprocessed source.
Validated: DirectGLES retrace suite green (two pre-existing local-driver
failures unchanged old vs new), KHR-GL30 877/878 on Espryt/NVIDIA (the one
failure pre-exists this change), unit tests green, per-module SPIR-V hashes
identical across a full DirectVulkan replay.
GL 3.3 core 2.11 puts program and shader names in one name space: a shader
name passed where a program is expected must fail with INVALID_OPERATION,
and vice versa. Two independent IndexGenerators handed out colliding names
(shader 2 and program 2 could coexist), so CheckProgramNameValidity resolved
a shader handle to an unrelated linked program and the error checks in
KHR-GL30.get_uniform_tests.get_uniform were silently swallowed - the case
only ever passed because the collided program happened to reject the queried
location. One shared generator keeps the names disjoint; the per-kind object
tables are unchanged.
Three DriverPost rows per the POST rule, since the ladder took on two
new driver dependencies: glDrawElementsBaseVertex (WARN when absent -
every base-vertex draw then costs a CPU index rewrite and an upload) and
compute shaders (INFO - the default tiers never use them). The third
names the tier that will actually run, with the full set the driver
supports, resolved by the same function the backend calls so the two can
not drift. The existing "Multi-draw base vertex" row stopped saying the
fallback is a per-draw loop, which is no longer the whole truth.
Scenario D asserts the one contract every tier shares: a multi-draw
paints exactly what the unrolled single draws paint. The reference side
is a loop of glDrawElementsBaseVertex and never enters the emulation, so
a tier cannot make itself look right by breaking both sides alike, and a
blank-frame pair is rejected outright - drawing nothing is the failure
mode this path actually has.
Nine cases, chosen for the shapes the Minecraft retraces contain none
of: narrow index types, a base vertex past the index type's range,
primitive restart inside a strip on two index types, client-memory index
arrays, and a batch with zero-count sub-draws (whose prefix sums the
flattening tier's binary search has to skip). Each of the six tiers
passes all nine on NVIDIA, and ext/auto/compute also pass on Mesa where
the ext tier is reachable.
The suite is falsifiable, not merely green: rewriting the rebase the way
MobileGlues does it - truncate to the source width, no restart
passthrough - fails exactly three cases on the drawelements tier (both
restart cases and the out-of-range base vertex) and leaves basevertex,
which rewrites nothing, passing. That control is also what turned up the
restart hole in the flattening tier fixed in the previous commit.
ES has neither glMultiDrawElements nor glMultiDrawElementsBaseVertex, so
both are emulated. DirectGLES had two ways of doing it - one
glMultiDrawElementsBaseVertexEXT where the driver has the extension
interaction, otherwise a per-draw loop. This adds the five MobileGlues
uses (gl/multidraw.cpp), so the ladder is now: one
glMultiDrawElementsBaseVertexEXT; one glMultiDrawElementsIndirectEXT
over a synthesized command buffer; one glDrawElementsIndirect per
command over that same buffer; the base-vertex replay; plain
glDrawElements over a CPU-rewritten index stream, for drivers with no
base-vertex draw at all; and a compute shader that flattens the whole
batch into one rebased index buffer drawn by a single glDrawElements.
They live in their own translation unit that owns the entry point
outright, preparation included - the compute tier has to dispatch BEFORE
PrepareForDraw, or it would have to unpick the program, storage-block
and index bindings the preparation just made, and a dispatch inside an
open transform-feedback span is not legal at all.
The auto ladder is ext -> basevertex -> multiindirect -> indirect ->
drawelements, which is NOT MobileGlues' order (it puts the indirect
tiers first). Measured on mc_sodium_multidraw, ns/op, median of three:
NVIDIA ES 3.2 basevertex 2500 vs multiindirect 5700 and indirect 5800;
Mesa llvmpipe ext 19300, basevertex 25200, multiindirect 27600,
drawelements 28700, indirect 31000. Ring-allocating the command staging
instead of respecifying per batch was tried first and moved the indirect
tiers by less than noise, so the cost is the indirect draw path itself,
not the upload; only a real multi-draw entry point beats replaying the
sub-draws. auto therefore resolves to basevertex on this box - byte for
byte the behaviour that shipped - and the new tiers are what a driver
with the ext interaction, or without base vertex at all, now gets.
compute is never chosen by auto (nor by MobileGlues'): it rewrites the
primitive stream rather than replaying it, and it measured slowest here.
Four places this deliberately does not follow MobileGlues, each a
correctness bug there. A rewritten stream is emitted as GL_UNSIGNED_INT
whatever came in, because GL adds baseVertex at full precision and
folding it into ushort indices wraps. The restart sentinel is carried
across a rebase unrebased, or an enabled primitive restart is lost. The
flattening tier declines strip/loop/fan modes, any sub-draw whose count
is not a whole number of primitives, and any batch at all while
primitive restart is enabled - a restart ends a primitive, so leftover
vertices would find a third vertex in the next sub-draw and become a
triangle GL never draws. And the indirect tiers decline client-memory
index arrays, which have no buffer to address.
gl_DrawID gets better rather than worse: the unrolled tiers now feed
each sub-draw its index (the spec's value, where the old loop left the
uniform untouched), and a program that actually reads it demotes the
batched tiers, which can only hold one value for the whole batch. The
per-batch cost is nil for the programs that do not read it.
Verified: the five DirectGLES retraces are byte-identical (md5) across
all six tiers on NVIDIA and on Mesa, each tier proven to have really
executed rather than silently demoted, via a per-tier announcement in
the log. Unit suite 421/421. The full retrace suite's five failures all
reproduce unchanged on a stashed tree, so none are new.
MOBILEGL_ESPRYT_MULTIDRAW_MODE=ext|multiindirect|indirect|basevertex|
drawelements|compute|auto names the DirectGLES emulation tier for
glMultiDrawElements(BaseVertex). Same contract as the Magma knob: a
preference, not a demand, clamped at resolution time to what the driver
actually has, and invalid values keep auto. Nothing reads it yet.
The two capability flags the ladder selects on are new because neither
existed in the shape the choice needs. SupportsDrawElementsBaseVertex is
the weaker sibling of SupportsMultiDrawElementsBaseVertex - ES 3.2 core
or EXT/OES_draw_elements_base_vertex, with no GL_EXT_multi_draw_arrays
requirement - and it decides whether a batch can replay its sub-draws
with their own base vertices or has to fold them into rewritten indices.
SupportsComputeShader is ES 3.1 core plus the dispatch, barrier and
shader-object entry points. Both keep the house rule the multi-draw
flags already follow: the extension/version check is what proves
support, the resolved pointer only confirms it, because
eglGetProcAddress may hand back a live-looking stub for a function the
context does not implement.
The bug: DirectVulkan.cpp::MultiDrawElements had its entire body
commented out - plain glMultiDrawElements on Magma recorded NOTHING,
no error, no pixels (readback shows the deferred clear never even
materialized). It now shares the tuned base-vertex implementation, and
both plain entries are pixel-proven by a 4-sub-draw harness.
The feature: every CPU-side multi-draw form dispatches through three
tiers after round-9's contiguous-run merge (restructured to merge into
a span BEFORE dispatch, so every tier consumes the shrunken array):
1. VK_EXT_multi_draw: one vkCmdDrawMulti(Indexed)EXT, chunked by
maxMultiDrawCount; per-draw vertexOffset rides in the struct. The
extension is requested only when enumerated and its feature bit
confirmed, entry points via vkGetDeviceProcAddr, demoted if
missing.
2. multiDrawIndirect: the param span uploads DIRECTLY as a transient
INDIRECT-usage buffer - DrawIndexedCmdParam is layout-identical
to VkDrawIndexedIndirectCommand and DrawCmdParam's head is a
legal 24-byte-stride VkDrawIndirectCommand, both static_asserted,
so no repacking - then one vkCmdDraw(Indexed)Indirect per
maxDrawIndirectCount chunk. firstInstance!=0 additionally
requires drawIndirectFirstInstance or the batch drops a tier.
3. The byte-identical unroll.
gl_DrawID: tiers 1-2 are spec-correct (0,1,2,3 across a probe's
sub-draws); the unroll tier keeps the pre-existing always-0 contract.
The default tiers strictly improve DrawID correctness.
Adversarially verified: the five real DirectVulkan retrace images are
BIT-IDENTICAL (md5) across auto/ext/indirect/unroll; zero validation
VUIDs on every tier; a simulated no-EXT device resolves to indirect
and renders the same bytes; the known-red create-indirect fixture
crashes at the identical call before and after (not worse, not fixed).
Unit suite 423/423 on the rebased tree, retrace subset 10/10. Bench:
mc_sodium_multidraw's contiguous shape merges 32->1 before dispatch,
so no bench delta - the tiers' beneficiaries are non-contiguous real
streams (the sodium RETRACE pushes ~58-sub-draw batches, in=out
243101 with zero merges) and mobile drivers. A reproducible +3-4%
code-layout drift on mc_use_program (zero shared code, I-cache
displacement from +400 lines) stays under the action gate and is
booked here rather than hidden.
MOBILEGL_MAGMA_MULTIDRAW_MODE=ext|indirect|unroll|auto selects the
DirectVulkan multi-draw dispatch tier, clamped to what the device
supports with one INFO line when it falls back; auto (and unset) picks
the best supported tier. Invalid values keep auto. Magma-only: the
variable has no effect on DirectGLES. Note for the escape hatch:
mode=unroll also forces the GL indirect multi-draw paths onto their
per-command loop, where gl_DrawID reads 0 for every sub-draw -
Flywheel-style content that keys on flw_drawId renders accordingly.
Three DriverPost rows per the POST rule: VK_EXT_multi_draw
(PASS/INFO), the multiDrawIndirect feature (WARN downgraded to INFO -
there is always a fallback tier), and the resolved dispatch tier with
the full chain. drawIndirectFirstInstance gains a row too, since the
indirect tier's legality check now relies on it.
When SupportsMultiDrawElementsBaseVertex is true, glMultiDrawElements-
BaseVertex issues one glMultiDrawElementsBaseVertexEXT instead of a
per-draw loop; the fallback loop is byte-identical otherwise.
The local NVIDIA ES driver lacks GL_EXT_multi_draw_arrays, so the
batch cannot engage here and no local win is claimed (counter-proven:
batched=0 / fallback=264329 across a sodium retrace). On Mesa llvmpipe,
which implements the full interaction, the batch engages (batched=4566,
~58 sub-draws per call) and is pixel-identical to a forced-fallback
control (same SSIM to the last digit). The beneficiaries are mobile
drivers advertising the interaction - the Sodium chunk path collapses
32 driver entries into one - and the DriverPost row shows which side
any device falls on. A/B on both backends: every case inside the 5%
bar. Unit suite 423/423, retrace subset 10/10.
On GLVND Linux eglGetProcAddress returns a non-NULL trampoline for ANY
name - including a fabricated one - so pointer-nullness can never
signal driver support. The three EXT multi-draw entry points were
registered as required (spurious error logs on drivers without them)
and their pointers were trusted; the NVIDIA ES driver hands back a
stub for glMultiDrawElementsBaseVertexEXT that SILENTLY DROPS draws,
which once made a "77% faster" multi-draw batch that rendered nothing.
The three entries are optional now, and two extension-derived
capability flags follow the established Supports* pattern - each is an
extension-string check AND a resolved pointer, so a flag alone is
sufficient at a call site:
SupportsMultiDrawIndirect: GL_EXT_multi_draw_indirect + both entry
points resolved.
SupportsMultiDrawElementsBaseVertex: (GL_EXT or
GL_OES_draw_elements_base_vertex) + GL_EXT_multi_draw_arrays + the
entry point resolved. The multi_draw_arrays conjunct is the registry
fact the stub exploited: glMultiDrawElementsBaseVertexEXT exists only
in interaction with GL_EXT_multi_draw_arrays, and this NVIDIA driver
advertises everything else EXCEPT that one - so the entry point is
genuinely unsupported while eglGetProcAddress still "resolves" it.
Two DriverPost rows report both capabilities (INFO when absent - a
fallback always exists). Unit tests pin the stub shape, the exact
NVIDIA shape, the supported shape and extension-without-pointer.
Proven load-bearing: forcing the old pointer-only condition on the
NVIDIA ES driver reproduces the silent drop exactly (sodium retrace
SSIM 1.000000 -> 0.329522, no crash, no GL error); with the gate the
same run is a literal 1.000000. Unit suite 423/423 (two new tests),
retrace subset 10/10, integration suite 52/52.
Two per-draw costs from the round-10 profiles. A per-program sampled-set
epoch inside UniformManager skips the per-binding descriptor proof walk
when no texture or sampler API ran since that program's previous draw -
the mc_sampler_churn/mc_tex_param pattern. The pass-switch path stops
re-deriving render-pass state that its own value hash already pins.
Load-gated 6-round order-alternating A/B (medians): magma tex_param
-13.3%, pass_switch -10.9%, state_toggle -5.4%; espryt untouched and
unmoved. The two matrix flags (sodium +7.5%, tex_stream +6.2%) reversed
under 10-pair isolated alternating re-runs (-5.5% and +2.5%) - the same
position-bias artifact every previous round's flags showed. Unit tests
421/421; retrace subset and the 52-entry integration suite pass.
Landing note: this diff was authored by a round-10 agent whose session
died before adjudication; the A/B data survived (r10bmag_ab_raw.csv)
and the flags were adjudicated before landing. Its relink also exposed
the pre-existing exit-teardown SIGSEGV fixed in the previous commit.
The static twin registries destroy their backend objects from
__run_exit_handlers, and a twin destructor then jumps through
g_GLESFuncs into a driver library that exit() may already have torn
down - a latent SIGSEGV that DriverBench has been dumping core with on
every exit, and that any relink shuffling static destructor order can
hand to the trace-replay binary (a byte-perfect replay then "fails with
status Segmentation fault").
A process-teardown flag now short-circuits the program, VAO and texture
twin destructors: past exit() the driver reclaims every GPU object
anyway, so the skip is a deliberate leak of nothing. The flag is set by
a std::atexit handler registered lazily on first registry use - by then
every static everywhere has finished constructing, so the handler runs
BEFORE any static destructor. A registry-destructor hook was tried
first and is wrong: tests and cache resets destroy temporary registry
instances mid-run, which latched the flag while the process was alive
(caught by DirectGLESBackendTexture.DestructorDeletesIdAndScrubsBindingCache).
421/421 unit tests, the retrace subset exits cleanly on both backends,
and the 52-entry integration suite passes.
Both d7976326 bugs passed every unit test while corrupting real frames -
state-level assertions cannot see them. This module renders and reads
back.
A headless EGL-pbuffer harness (no window, no GLFW) linking MobileGL_s
directly, registered once per backend under the ctest label
integration-gpu, behind the default-OFF option
MOBILEGL_BUILD_INTEGRATION_TEST. The platform pre-flight runs the ENTIRE
bring-up in a forked child first - MobileGL aborts rather than returning
errors on an unusable platform, and the child dying on any signal turns
into a clean GTEST_SKIP instead of taking the test binary down.
MOBILEGL_ITEST_REQUIRE_GPU makes the label falsifiable: with it set, an
unusable harness (or a context that lands on a software rasterizer) is a
FAILURE - without it, a CI runner whose driver pinning silently broke
reports the same green as one that rendered every frame. Configure-time
detection pins the EGL vendor and Vulkan ICD jsons, preferring hardware
vendors and never selecting llvmpipe/lavapipe.
Scenarios assert on glReadPixels with whole-region pixel counts (a
2x2 quadrant pattern whose signature distinguishes all eight square
symmetries; every region predicate reports the first offending pixel):
- OrientationScenario: default -> FBO -> default, pinning the
transform-flags memo key. Keying GetBaseTransformFlagsRaw on the
pre-transform alone fails exactly 3 entries.
- StreamedArenaScenario: an untouched streamed vertex buffer must
survive transient-arena recycling. Re-enabling only the cross-frame
vertex revalidation fails exactly this entry.
- CrossFrameBufferScenario + ResidentIndexScenario: cross-frame
mutation matrix (SubData, map/unmap, persistent+flush, coherent
persistent, orphan, CopyBufferSubData; vertex and index) plus six
adversarial resident-EBO constructions. Instrumentation showed the
cross-frame EBO memo cannot be made to serve wrong bytes from GL
level on this stack (89 entries, 81 accepts, zero divergent slices) -
these cases are freshness tripwires, documented as such in-file; the
EBO half of d7976326 remains unpinned by a failing test.
At the buggy commit 72ee7c43 the suite fails 4 entries (3 orientation +
1 streamed-arena); at d7976326 all 52 pass, 5 consecutive runs, zero
flakes, and the default build is bit-for-bit unaffected (unit suite
unchanged). Adversarially verified twice, including hostile-platform
sweeps (26 configurations, all clean skips) and hand-edits of each
production hole in isolation.
Two correctness holes from the round-7/8 fast-path work, found by
bisecting the retrace matrix after corruption reports on device.
Cross-frame slice trust: the vertex-binding and EBO memos skipped the
acquire - the frame's content-sync point - whenever their recorded
slice epochs still matched, trusting the BumpSliceEpoch inventory to
cover every way a buffer's GPU copy can go stale. At least one mutation
path escapes it: journeymap and common-mods retraces shipped visibly
corrupted, and Sodium on an Adreno device rendered random triangles
from stale vertex data. A memo recorded in an earlier frame now
declines, so the first draw of each (VAO, frame) re-runs the full
acquire; the same-frame paths (layout memo, factory-chase elimination,
one-compare rescue) are untouched. The cross-frame idea can return once
the bump-site inventory is proven complete against exactly these traces.
Transform-flags memo key: GetShaderTransformFlags reads the swapchain
pre-transform AND whether the bound draw framebuffer is the default one
- only a presenting pass gets the Y-flip/rotation bits. The memo
declared it pure in the pre-transform, so after any render-to-texture
pass the next default-framebuffer pass inherited the FBO's unflipped
flags: 1.17-main-menu retraced as a perfectly rendered, perfectly
upside-down frame (SSIM 0.052, deterministic), and cloud passes
flickered on device. The memo now keys on (preTransform, isDefaultFbo).
DirectVulkan retraces for 1.17-main-menu, journeymap, common-mods,
sodium and xaero-world-map all pass on lavapipe; unit tests 421/421.
61% of mc_sodium_multidraw's steady-state CPU sat inside the driver
encoding one vkCmdDrawIndexed per sub-draw. MultiDrawElements now
collapses contiguous runs: merge only when the topology is a list
(POINTS/LINES/TRIANGLES), the accumulated count sits on a primitive
boundary, primitive restart is off, baseVertex/instanceCount/
firstInstance are identical and firstIndex is adjacent, with a
count-overflow guard - the bench's 132x32 sub-draws become 132x1.
Dangling-index discard semantics for list topologies are what the GL
spec already mandates per draw. No new Vulkan feature, so no DriverPost
gate; VK_EXT_multi_draw stays a gated follow-up.
The draw fast path's single SetupDraw snapshot died on every program
ping-pong (use_program's A/B pattern sent every other draw down the
full path, CollectSampledTextures alone 6.2% self). A 4-entry
program-keyed snapshot table (MRU by program lifetime id, per-entry
sampled-set copies, per-entry invalidation on decline or full-path
start, all entries still cleared at command-buffer boundary, pipeline
age-out and swapchain recreate) keeps all cycling programs hot.
Load-gated 6-round order-alternating A/B, sha1-fingerprinted pair:
sodium_multidraw -41.3%, use_program -23.9%, pass_switch -12.7%,
tex_param -3.8%, vanilla -2.1%; the one flag (tex_stream +5.4%)
reversed to -0.5% across 10 isolated alternating pairs. Espryt
untouched and unmoved. Unit tests 421/421.
Consume MipmapStorage's new dirty-rect list: pack each rect tightly
into the staging block and issue ONE vkCmdCopyBufferToImage with N
regions instead of staging the whole union box. Offsets are computed
identically in the pack and copy loops; disjoint rects mean no
overlapping copy destinations; the combined depth-stencil and
RGB-expand/depth-convert paths keep their single-box route (gated to
the color-aspect, no-conversion case).
54% of mc_tex_stream's steady-state CPU was the one shadow->staging
memmove of the union box; staged bytes drop to 4.8% (~2MB -> ~95KB per
frame) and the case improves ~-49% (5945 -> 3048 ns/op, ~2.2x native
to ~1.2x). Zero validation-layer findings on the 95-region copy. Unit
tests 421/421.
Consume MipmapStorage's new dirty-rect list: when a level offers a
profitable rect list, the sync path issues one glTexSubImage2D/3D per
rect under a single UNPACK_ROW_LENGTH set/reset instead of one call
covering the union box. Striding is the exact scheme the single-box
path already uses (UNPACK_ALIGNMENT pinned to 1 by
ScopedDefaultUnpackState, so every bpp is stride-exact); levels
without a profitable list take the old path unchanged.
On the atlas-streaming case this trades one ~2MB upload for ~95 small
ones totalling ~95KB - roughly a wash in driver-call overhead on
desktop NVIDIA GL (mc_tex_stream ~-3%), a clear byte-volume win for
tiled/mobile GLES where the driver shadow-copies every upload. Unit
tests 421/421.
A Minecraft frame updates ~95 scattered 16x16 sprites in a 1024x512
atlas; MipmapStorage's single union dirty box turned ~95KB of changed
texels into a ~2MB upload on every backend. The storage now keeps a
bounded (96-slot) list of pairwise-disjoint dirty rects BEHIND the
untouched union box: rects cascade-merge on touch or overlap, overflow
folds the pair with minimum enlargement and re-cascades, whole-level
dirties and respecifies just clear the list (empty list = "union box
tells all"). GetDirtyRects hands the list out only when it has 2+
rects, fits the caller's capacity, and its summed area is under 75% of
the union box - fewer driver calls beat equal bytes - so consumers can
never stage more than the union box did.
The list is maintained inside the same four mutation funnels every
texel writer already goes through (MarkDirty, MarkDirtyRegion,
AllocateLevel, TruncateToLevelCount - callers enumerated at the
declaration), so list and union box cannot disagree. Backends OPT IN:
the union-box API and its update order are byte-identical, and an
unmodified backend keeps rendering exactly as before.
96 slots is measured, not guessed: on the bench's 95-sprite lattice a
16-slot list collapses to >93% of the union box, 96 slots reach 4.8%
(~2MB -> ~95KB staged per frame). Verified by a 2859-check fuzz run
against a reference dirty bitmap (union exactness, full coverage,
disjointness, bounds, profitability). Unit tests 421/421.
The per-VAO resolved-bindings map probe was ~45% of
UploadAndBindVertexBuffers' self time, and the aux-memo pointer chase
was the single hottest instruction left in TrySetupDrawFastPath. Both
die together: a fixed 2048-slot two-probe 64B-aligned VaoDrawMemo table
embeds the VAO key, content-hash-validated layout facts and the
bindings payload reordered hot-to-cold. Layout facts hold exactly while
the slot's content hash equals the live VAO's own config-guarded hash;
a recycled VAO address either misses or reproduces a byte-identical
config, for which the facts are correct by construction. Bindings keep
their full per-draw revalidation; recycled slots zero their frame
serials so half-filled entries can never match.
ComputePipelineStateHash, the depth/stencil probe and the
primitive-restart probe now take one bulk GetRenderStateParameters()
fetch instead of ~17 cross-TU accessor calls (verified pure field
reads, identical bit packing). The EBO slice memo gained the same
manager-wide epoch one-compare rescue the vertex half uses.
GetShaderTransformFlags is memoized on pre-transform. Sodium's
MultiDrawElementsBaseVertex hoists GetGLTypeSize out of the
per-sub-draw loop, replaces the division with a shift, and skips
unsupported index types loudly instead of dividing by zero.
Also verified: a GL_BLEND toggle recompiles nothing in steady state -
the glslang frames in earlier state_toggle profiles were startup
contamination.
Quiet-box load-gated 6-round A/B: sodium_multidraw -8.0%, tex_param
-4.1%, use_program -3.3%; steady-state vanilla_draw CPU -20% ns/op at
4096 frames (the 80-frame matrix compresses CPU wins under GPU boost
clocks; profiles confirm UploadAndBindVertexBuffers 6.3% -> 4.4%
including the table probe, and the aux cold-line load gone). The one
matrix flag (pass_switch +7.5%) reversed to -3.2% in 10-pair isolated
re-runs. Unit tests 421/421.
mc_use_program cycles programs whose texture bindings never change, yet
every switch re-walked the units. Six fixes, one theme: a switch back to
a known program should find its own state waiting.
Per-program 4-entry resolved-texture-binding memo (round-robin, shadow
memcmp on hit) skips the unit walk when a program returns with its
bindings intact. The whole sampler-uniform pass in
BindCurrentProgramWithResources is memoized per program twin behind
(context, unitBindingsEpoch, samplingGeneration, backendStateVersion,
textureContextGeneration) plus a per-sampled-unit sampler-shadow row
compare, invalidated on relink/backend rebuild; the
BindCurrentUnitSamplers walk sits behind the same keys. Every unit
assignment, sampler-parameter change and bind path was verified to bump
one of those inputs.
UboRingAllocate's common path is now a generation check, a power-of-two
mask, an overrun check and a head bump - the duplicate availability
probe, frame-mark retirement and divisions moved to the wrap slow path.
The per-context framebuffer binding slots (the frontend getter
linear-scans per call) are cached as direct pointers - slots are
by-value members of GLContext, so the pointers are stable by
construction - feeding SyncCurrentFBO, SyncNeccessaryTextures and the
broadcast memo; BindCurrentFBO's per-draw registry hash Find became a
TwinLookupMemo probe. The VAO config-version cold-line load is hoisted
to the top of PrepareForDraw to overlap its miss.
Quiet-box load-gated 6-round order-alternating A/B, all nine cases,
both backends: use_program -27.7%, vanilla_draw -16.2%, ubo_range
-13.4%, pass_switch -11.1%, sampler_churn -10.7%, state_toggle -9.2%,
sodium_multidraw -5.3%, rest flat. No regression on either backend
(magma's one matrix flag disproved by isolated re-runs against
byte-identical DirectVulkan sources). Unit tests 421/421.
The draw fast path still paid for its own proofs: the hottest single
load (20% of TrySetupDrawFastPath) was chasing the cold
VertexInputStateFactory heap entry just to answer "same vertex-input
layout?". That answer now comes from the frontend VAO's config-guarded
aux memo (layout hash + attribute masks), and a VAO-cycling stream with
a stable layout skips the pre-flight AND pipeline re-resolution
entirely. The VkProgramObject* is memoized on the snapshot behind a new
ProgramFactory cache-structure epoch (bumped on every insert/erase; use
is re-stamped so the idle sweep can never evict a live entry). A
render-state version move no longer forces the full path: the pipeline
value hash is refreshed in place and the 8-entry memo probed directly
(the GL_BLEND-toggle case).
The resolved-vertex-bindings memo now revalidates all-resident unmapped
entries ACROSS frames via per-binding slice epochs - minted from a
process-lifetime counter so a recycled address can never revalidate,
with every mutation path funnelled through BumpSliceEpoch - while
stamping each resource's GPU-use serial exactly as the skipped acquire
would, preserving the busy-tracking that glBufferSubData's
host-write-vs-staged-copy choice depends on. Resident index buffers get
the same treatment through an EBO slice memo.
The six-part dynamic-state tail (viewport/scissor/blend constants/depth
bias/line width/stencil) is gated behind one render-state-parameters
version + pass-geometry compare per command buffer. GetSlice is inlined;
SampledBindingsUnchanged walks only the program's declared bindings.
Quiet-box 6-round order-alternating A/B (on top of the frontend
VAO-bind commit): vanilla_draw -20.8% (790 -> 626 ns/op, 3.4x native to
2.5x), sampler_churn -28.2%, ubo_range -8.4%, state_toggle -3.8%;
tex_param's matrix flag (+10%) was adjudicated by an isolated
alternating re-run at +1.0% - position bias, not regression. Unit tests
421/421.
Four draw-path costs, one theme: re-proving what nothing invalidated.
A manager-wide buffer-mutation epoch (atomic; bumped with release AFTER
every mutation lands: all six BufferBackendOps via tracking wrappers,
every backend-initiated writeback - XFB readback/scatter, the five
pack-PBO readbacks - registry registration changes, and backend context
destruction; the full site inventory lives in a comment at the accessor)
lets the per-VAO resolved-buffers memo stamp the epoch after one
all-clean probe pass and skip every IsBufferDrawClean probe while it
holds. The IBO keeps its bound-object identity compare - only the probe
is elided. Non-bumping paths are enumerated with why they are safe:
GPU-authoritative writes are ignored by the probe, persistent-mapped
resources are clean by construction, and draws on non-persistent maps
are frontend-rejected GL errors.
GetProgramForDraw is hoisted to one call per PrepareForDraw and handed
to the four consumers that each re-derived it. The enabled-draw-buffers
walk feeding the fragColor broadcast count is memoized on the
(FBO, slot version, object version) trio. The UBO-binding loop probes
IsBufferDrawClean before falling back to EnsureBufferResource.
The texture chain captures (context, maxTouchedUnit, samplingGeneration,
unitBindingsEpoch) once per draw - shared by SyncNeccessaryTextures and
BindCurrentTextures, halving the epoch computations - and an aggregate
gate that is the exact conjunction of the three Sync*ToBackend
early-outs skips the per-texture cross-TU calls.
The t_egl* thread_local verification pair became owner-thread-guarded
atomics reset by MakeCurrent/ReleaseCurrent, removing __tls_get_addr
from the draw loop.
Quiet-box 6-round order-alternating A/B (with the frontend VAO-bind
commit): all NINE Espryt cases improved - sampler_churn -11.0%,
state_toggle -9.3%, ubo_range -9.1%, vanilla_draw -5.4%, pass_switch
-3.5%, the rest -1% to -2.5%. Unit tests 421/421.
perf annotate put 94% of VertexArrayState::Bind's 10.5% self time on the
two lock-prefixed shared_ptr refcount RMWs each bind performs. The bound
VAO is now stored as a slot index into m_vertexArrays - no SharedPtr
copy, no atomics on the bind path. The lifetime invariant (the bound
object is kept alive by its slot; any cold path that clobbers a bound
slot - delete-while-bound including slot 0, create-over-bound-slot -
detaches the old object into m_boundDetached so GetBoundVertexArray
keeps answering with it) is enforced in MarkVertexArrayForDeletion /
CreateVertexArrayObject rather than assumed, and documented at the
change. Out-of-range binds and null slots keep their exact old
semantics.
VertexArrayObject also gains two opaque config-version-guarded backend
aux memo words, letting a backend answer "same vertex-input layout?"
from the frontend object instead of chasing its own cold cache entry.
After this change the frontend Bind drops out of the DirectVulkan draw
profile entirely (11.5% -> 0.5%). Measured jointly with the two backend
rounds that land on top: quiet-box 6-round order-alternating A/B,
all nine cases, no case worse than noise on either backend. Unit tests
421/421.
TrySetupDrawFastPath declined on its VAO pointer check for every draw of
a 512-VAO cycle - the Blaze3D chunk-render shape - so the fast path was
dead exactly where it mattered: full SetupDraw, per-draw
ResolveSamplerDescriptor, SyncTextureAndGetDescriptor and render-pass
re-fetch, for draws whose only change was the VAO.
Three fixes. A moved VAO now re-runs only the vertex-input pre-flight
and re-resolves the pipeline instead of declining to the full path. That
resolution probes the value-keyed pipeline memo directly off a cached
pipeline-state hash and snapshot render-pass hash, skipping
GetOrCreateRenderPass and its GetPendingRenderbufferClear probes per
draw; a stale cached hash can only miss, never false-hit. And when the
sampler-descriptor hint holds and the program's single dynamic UBO
re-resolves to the same VkBuffer and range - only the dynamic offset
moved, the per-draw glUniform case - the descriptor walk collapses to
one offset recompute and a vkCmdBindDescriptorSets of the same recorded
set with new pDynamicOffsets. The rebind memo is invalidated at
BeginFrame, layout destruction and override walks; the program lifetime
id never repeats, and per-frame descriptor sets are never rewritten
within their frame.
mc_vanilla_draw -36.9% (1260 -> 795 ns/op, 4.6x native to 3.4x),
sodium_multidraw -18.0%, state_toggle -14.9%, sampler_churn -7.8%,
use_program -7.7%, ubo_range -7.3%, tex_param -7.3%. All nine cases on
both backends, interleaved A/B; no attributable regression. Unit tests
421/421.
Four per-draw costs, all lookups that re-answer the same question.
SyncNeccessaryBuffers walked all 32 attribute slots cold and ran
EnsureBufferResource per buffer on every draw. The backend VAO twin now
hosts a resolved-draw-buffers memo: the deduped enabled-attribute buffers
and the index buffer resolve once per VAO config version, and each hit
re-validates every entry with IsBufferDrawClean - a shadow probe mirroring
every no-op branch of EnsureBufferResource (resource identity, context
generation, pending ops, change serial) - falling back to the full path
for just the dirty entries. The IBO entry is checked against the live
bound object each draw, so slot-version wrap cannot false-hit.
The registry hash Finds that resolve state objects to their backend twins
ran several times per draw. TwinLookupMemo - a direct-mapped,
Fibonacci-hashed table (4096 VAO / 256 program slots) with weak-ptr owner
equality against address reuse - answers them in one probe; collisions
fall back to the registry. A live entry's twin is never replaced once
set, so owner equality proves the raw pointer.
SyncCurrentVertexAttributeValues' pending-mask memo was a function-static
single entry that missed every draw once the app cycled VAOs; it now
lives on the twin. CurrentXfb()'s per-draw FastSTL map lookup became a
cached pointer invalidated at every map mutation (open addressing moves
values on any insert/erase/clear).
mc_vanilla_draw -12.6% (3.4x native to 3.0x), ubo_range -9.8%,
sampler_churn -7.5%, pass_switch -6.5%, sodium_multidraw -6.0%,
state_toggle -4.6%. All nine cases measured on both backends, interleaved
A/B; no case regressed. Unit tests 421/421.
Two per-draw churn costs, one cause each.
A blend toggle switched pipelines through a memo keyed on a monotonic
pipeline-state version - which never repeats, so flipping GL_BLEND off and back
on produced a "new" key both times, forced the full SetupDraw and rebuilt the
whole pipeline payload for a pipeline the cache already held. The memo now keys
on a value hash of the pipeline-relevant fixed-function state, recomputed only
when the state version moved, and the consecutive-draw fast path re-resolves
just the pipeline through it when nothing but render state changed. Blaze3D
brackets every batch with exactly this toggle; mc_state_toggle drops 36%
(6629 -> 4230 ns/op, 4.8x native to 3.7x).
The sampler-churn cost had the same shape as the Espryt side fixed separately:
glBindSampler bumps the frontend texture-bind generation even when it re-binds
the sampler the unit already holds, so the per-draw fast path died every draw.
The fast path now proves each binding's descriptor inputs unchanged - texture
and sampler lifetime ids, parameter and content sums, the sampling-resolution
generation, image epochs and exact layouts - and reuses the binding's cached
VkDescriptorImageInfo instead of re-running the resolve chain.
mc_sampler_churn drops 30% (1597 -> 1125), and the proof machinery pays for
itself on the uniform-range case too (-17%).
mc_tex_param stays where it is on this backend deliberately: profiling shows its
remaining cost is frontend validation with zero backend work, unreachable from
Renderer/.
All nine cases measured on both backends, interleaved A/B, no case worse than
noise. Unit tests 421/421.
The texture-binding memos added earlier keyed on the frontend texture-bind
generation, and 26.2-style unit switching defeats them: glBindSampler bumps the
generation even when it re-binds the sampler the unit already carries, so a
frame that cycles active units re-ran the full two-pass, eleven-slot alias
resolution and the unbind walks on every draw. mc_sampler_churn sat at 1674
ns/op against the native driver's 239 - the worst multiplier left on this
backend - with about half the time in two virtual calls per binding slot.
The units now carry an epoch: a snapshot of each touched unit's slot objects and
sampler object, compared by weak_ptr OWNERSHIP rather than raw pointer - a held
weak_ptr pins its control block, so a freed-and-recycled object can never
owner-equal its predecessor, which is the ABA hole a pointer key would have and
the reason version keying was rejected (WithTemporarilyBoundNamedTexture bumps
slot versions without touching the bind generation). The
(context id, bind generation, high-water mark) triple gates the snapshot walk to
at most once per draw; the epoch moves only when a binding really changed. Both
per-draw memos key on the epoch plus the sampling-resolution generation, which
carries what the epoch cannot see: a default texture's image appearing, and
every completeness input. Two smaller memos ride along: the per-unit
sampler-registry lookup (owner-keyed, misses never cached - the backend object
may be created later in the same draw), and the pending-vertex-attribute mask,
whose first version scanned all 32 slots and put +10% on the VAO-cycling case
before being restricted to the program's active locations.
ns per op, DriverBench on a GTX 1660 SUPER, isolated A/B, all nine cases on both
backends: mc_sampler_churn 1673 -> 732, mc_use_program 4513 -> 4279,
mc_state_toggle 2365 -> 2247, everything else within noise and nothing worse.
7.0x native to 3.1x on the churn case.
Unit tests 421/421.
Every dirty texture bought itself a fresh staging buffer (vmaCreateBuffer +
vmaMapMemory), a fresh command buffer, a fresh fence, and its own vkQueueSubmit.
A perf profile of the sprite-animation case put 41% of the whole run in the
kernel on the resulting ioctl traffic; the reclaim list already avoided waiting
on the fences, so the cost was the allocation and submission machinery itself,
paid per texture per frame.
Staging now comes from a pool of persistently-mapped blocks (1 MiB minimum,
exact-size beyond that, bump-allocated, 32 MiB idle cap), and uploads record
into one shared batch command buffer from a dedicated command pool, going out as
one submit with one pooled fence per flush. Fences, command buffers and blocks
all recycle through the existing fence-list reclaim instead of being destroyed.
Flush points: before every frame command buffer submission (which is what
preserves the old ordering argument - the batch reaches the queue strictly
before anything that could sample its images), on the glFlush finite-time path,
when a batch would outgrow its staging bound, and eagerly at 128 KiB, which
measured faster because the GPU overlaps the copy with the rest of the frame's
CPU recording. The mid-frame upload-draw-upload-again sequence detects itself
through the batch image list and flushes first, reproducing the old two-submit
granularity exactly; a deferred image release flushes any open batch that still
references the image, because drain proofs only cover submitted work.
ns per op, DriverBench on a GTX 1660 SUPER: mc_tex_stream 9405 -> 5373 (2.3x
the native driver, from 3.9x), atlas_sprite -57%, lightmap -89%, chunk_upload
-10%; draw-path cases unchanged. The suite's sampler-churn number reads a few
percent worse right after the now-much-faster upload case, which was chased to
schedutil downclocking during the newly-blocking-free frames - isolated and
frequency-pinned runs measure parity; noted here so the next person does not
re-chase it.
Unit tests 421/421; Vulkan validation layer clean across draw and upload cases.
Four per-draw costs in DirectGLES, all of the same species: work re-done for an
answer that had not changed.
SyncRenderState was guarded by a single version compare, so one blend toggle -
the way Blaze3D brackets every batch - re-diffed the whole ~40-field render
state block and copied the full struct back into the shadow, every draw. The
parameter struct is now split into three contiguous byte spans, each gated by a
memcmp against the backend shadow; a per-draw blend flip touches only the blend
span. The shadow is byte-cloned after each sync so the span compares stay exact,
padding included. Blocks whose inputs live outside the parameter struct (the
surface-size viewport fallback, the sRGB context capability) stay ungated, and
the dual-source-blend hard-fail still fires every draw because a throwing sync
never stamps the shadow.
SyncMipmapsToBackend gained a first-level clean gate on (context id,
sampling-resolution generation, content version, params version) that skips the
IsComplete walk and the eight-field shape probe outright; every shape mutation
funnels through BumpShapeVersion, which is what makes the gate sound.
SyncToBackend for vertex arrays compares one aggregate config version instead of
three stamps per attribute slot. And SyncNeccessaryTextures memoises the
draw-framebuffer attachment list, keyed the same way the framebuffer sync memo
already is, instead of re-walking attachments per draw.
ns per draw, DriverBench on a GTX 1660 SUPER, isolated A/B: mc_state_toggle
3151 -> 2397, mc_ubo_range 792 -> 579, mc_vanilla_draw 1111 -> 881,
mc_sampler_churn 2019 -> 1676, mc_use_program 5132 -> 4356; every one of the
nine cases improved. Against the native driver Espryt now stands at 3.6x on the
plain draw path, 2.8x on the per-draw uniform-range path and 2.1x on the blend
toggle, from 8.7x / 9.1x / 7.2x when this effort began.
Unit tests 421/421.
The benchmark job is the only one that brings a real GL context up - DriverBench
dlopens libEGL.so.1 and renders through it - but its apt list only asks for
libegl1, which is glvnd's dispatch layer and nothing more. The vendor library
behind it, libegl-mesa0, has been arriving as a Recommends of libegl1 rather
than because anything asked for it. That is too quiet a dependency for the one
job whose whole purpose is running a driver: a base image change, or
--no-install-recommends turning up anywhere upstream, would leave eglInitialize
with no vendor to dispatch to and fail the job for a reason nothing in the
workflow explains. Name it, next to libgl1-mesa-dri, which is listed for
exactly the same reason.
Verified with a full headless ctest -C Release -L benchmark - no $DISPLAY, no
$EGL_PLATFORM, mesa as the only EGL vendor: SanityBench, ProgramBench,
BufferBench and DriverBench all pass.
The captured traces contain per-frame patterns the bench did not exercise, and
first measurements show two of them are now the worst remaining multipliers -
which is exactly what the missing cases were hiding.
mc_pass_switch: the 26.2 snapshot switches render targets 132 times a frame and
re-declares draw buffers 198 times. Render-target churn is where a Vulkan
backend pays for render-pass breaks and where a tiler pays most on device, and
no case measured it. mc_state_toggle: Blaze3D brackets batches with blend
toggles - 46 enable/disable pairs and 28 blend-func changes per vanilla frame.
mc_tex_param: 26.2 re-sets texture parameters 612 times a frame, almost always
to the value already in place, so this measures redundant-parameter filtering.
mc_use_program: Sodium switches programs 62 times a frame with a mat4 upload on
each, roughly one switch per multi-draw.
All four live in the shared case file at the measured per-frame rates, so the
desktop harness, the on-device harness and the POST screen's Run Bench report
comparable numbers. First desktop measurements (ns/op, native / Espryt / Magma):
pass_switch 8877 / 18502 / 13896, state_toggle 1182 / 8526 / 8305,
tex_param 42 / 102 / 197, use_program 2182 / 10648 / 5096. The state-toggle
multiplier - 7x on both backends - is the largest newly exposed gap and the next
optimization target.
Unit tests 421/421; the Android JNI translation unit compiles against the
extended case set.
Every draw re-resolved its whole vertex binding array: for each enabled binding,
look up the buffer, acquire a slice from the buffer manager, apply the binding's
base offset, fill the VkBuffer and offset arrays, bind. In the Minecraft-shaped
benchmark the same few hundred vertex array objects cycle for the whole run and
each one's answer is stable, so UploadAndBindVertexBuffers was the single largest
cost in the backend at 7.9% of the render thread, with AcquireResidentSlice
another 3.8% underneath it.
The resolved array is now kept per vertex array object and revalidated instead of
rebuilt. Validation is two-tier. The vertex array's own configuration version
already invalidates its backend vertex-input state, so a changed attribute,
format, buffer or base offset yields a different state object - the memo compares
both that object's address and its hash, which mixes the bound buffers and the
whole layout. What that does not cover is the slice moving underneath an
unchanged configuration, so the buffer manager now carries a monotonic epoch that
every writer of slice-deciding state bumps: resident storage creation, respecify,
sub-data, flush of a mapped range, the promotion and demotion between streamed
and resident storage, each fresh arena allocation, and bulk release. The counter
is manager-wide and never reset, so a resource created at a recycled address
cannot reproduce a value some memo still holds.
The miss path was the thing to get right, because the previous attempt in this
area regressed the texture-upload and sampler-churn cases by 60-85%: it added a
verification pass that re-ran the resolution work it was trying to skip, so every
miss paid for it twice. Here a miss is one pointer-keyed lookup and a few stores,
and nothing else runs that the full path would not have run anyway.
ns per draw, DriverBench on a GTX 1660 SUPER: mc_ubo_range 924 -> 767,
mc_vanilla_draw 1346 -> 1227, mc_sampler_churn 1397 -> 1279,
mc_sodium_multidraw 3365 -> 3266. Magma is now 4.1x the native driver on the
per-draw uniform-range case, from 5.4x when this round started. No case
regressed on either backend.
Unit tests 421/421.
SyncCurrentFBO has an early-out that compares three memos, and it could never
fire. One of the three, g_fboBindVersions, was only ever stamped by
ForceBindCurrentFBO - which runs from glBlitFramebuffer and the DSA
glClearNamedFramebuffer* paths and nowhere else. An application that touches
neither leaves that memo at 0 while the binding slot's version is at least 1 from
its first glBindFramebuffer, so the first term mismatched forever and the guard
was dead code rather than merely too coarse. Every draw therefore re-walked all
40-odd attachment slots and rebuilt the 8-slot snorm/unorm clamp mask for a
framebuffer that had not changed since the previous draw.
SyncCurrentFBO now stamps all three memos itself, through one helper, on every
path that leaves the target synced - including the default-framebuffer
"nothing to do" path, which previously returned without stamping anything. The
memo is renamed to say what it now records (a sync, not a bind). Instrumenting a
throwaway build put it at 539998 hits against 2 misses, the misses being the
first bind of each target; it was 0 hits before.
Skipping the sync also skips the Bind() inside it, so all eleven call sites were
checked: every one issues its own bind afterwards (PrepareForDraw and the
glClearBuffer* paths bind Draw, ReadPixels and the CopyTexSubImage paths bind
Read, BlitFramebuffer binds both, GetTexImage uses its own scoped binder). The
global snorm/unorm clamp masks written inside the sync stay correct because they
can only be stale if a different framebuffer was synced as Draw in between, which
moves the pointer or slot version and forces the re-sync that rewrites them.
InvalidateFramebufferBindingCache now also clears these memos: both its callers
mean the ES context may have been reset, and a live early-out must not survive
that.
Two smaller items in the same pass. StateBackendObjectRegistry kept the backend
twin and its liveness weak_ptr in two maps, so every lookup cost two hash probes
and the draw path does ten to twenty of them; they are one map with one entry
type now, one probe. The weak_ptr check itself is load-bearing and stays -
glDeleteVertexArrays followed by glGenVertexArrays recycles heap addresses
readily. And SyncNeccessaryBuffers ran the full EnsureBufferResource check once
per enabled vertex attribute, which on an interleaved Minecraft-shaped VAO means
four to eight times over the same VBO; it is deduplicated per distinct buffer now.
ns per draw, DriverBench on a GTX 1660 SUPER, A/B against a build differing only
by this diff: mc_vanilla_draw 1403 -> 1113, mc_ubo_range 983 -> 797,
mc_sampler_churn 2309 -> 2003, mc_sodium_multidraw 3232 -> 3023. Against the
native driver Espryt is now 4.3x on both the plain draw and the per-draw
uniform-range case, from 8.7x and 9.1x at the start of this work.
Unit tests 421/421. Also replayed all 38 locally-available DirectGLES trace
fixtures against a baseline library: every one produced bit-identical ssim and
mismatched-pixel counts, including the improved-transparency OIT trace whose
scratch clear framebuffer is exactly the draw-buffer hazard the code comments
warn about.
DirectGLES re-derived the whole texture binding state for every draw: for each
touched unit, two alias-resolution passes over all binding slots, then a third
walk to unbind native targets nothing claimed, then the sampler. With the
Minecraft-shaped bench that was 13.2% of the render thread in BindCurrentTextures
alone, plus 4.6% in SyncNeccessaryTextures deciding which textures to consider.
The answer is identical across a whole terrain batch.
The resolution is now memoised, and what makes replaying it as a no-op legitimate
is that the memo does not merely trust a key: it compares the backend's own bound
texture shadow against the one resolution left behind. Every path that binds a
texture behind this function's back already maintains that shadow - the scratch
bind an upload does on the temp unit, CopyTexSubImage2D and GenerateMipmap
binding on the active unit, the glBindTextures fast path, the scrub a backend
texture performs when it is destroyed or respecified - so a memcmp catches all of
them without having to enumerate them. On top of that the key covers the texture
bind generation, the program that arbitrates aliased targets (pointer, lifetime
id, backend state version, link status), and the ES context generation.
Two invalidation sources had no signal at all and needed one. Mipmap completeness
decides whether a texture is bound in the first place, and it moves with texture
shape and with the effective sampler's filter - so a sampling-resolution
generation now moves with both, routed through single choke points
(TextureObjectBase::BumpShapeVersion, SamplerObject::BumpVersion) so a future
bump site cannot forget it. A texture context id was needed because both
generations restart at zero in a new GLContext, which can land on the old heap
address.
This also closes a pre-existing hole rather than working around it:
glDeleteSamplers unbinds the sampler from every unit straight through
TextureUnit::SetSamplerObject, bypassing the touch bookkeeping, so that setter now
bumps the bind generation on a real change. The sampler bind step itself stays
outside the memo and runs every draw - the program's raw-depth-fetch substitution
rewrites unit samplers immediately afterwards, so a memo there could never hit.
ns per draw, DriverBench on a GTX 1660 SUPER (native / Espryt):
mc_vanilla_draw 253 / 2037->1315, mc_ubo_range 202 / 1684->955,
mc_sodium_multidraw 739 / 3939->3150. Espryt goes from 8.3x to 4.7x the native
driver on the per-draw uniform-range case. Magma is unaffected (the MG_State
additions are counter bumps), and no case regressed.
Unit tests 421/421.
Every draw asks, for every bound texture, whether it is mipmap-complete for the
filter in use, and the answer was recomputed from scratch each time: walk the
level chain, read each level's texel size, verify each is half the previous.
With the Minecraft-shaped bench that walk plus the GetTexelSize calls under it
measured about 8% of the render thread on both backends.
The answer depends only on the texture's shape - internal format, stored level
set, level sizes, level range - and never on its texel content, which is the
thing that actually changes between draws. A shape version now moves on exactly
those four mutations (SetInternalFormat, SetBaseLevel/SetMaxLevel, and the
AllocateStorage/TruncateMipmapLevels pair on both mipmap storage classes), and
the completeness answer is memoised against it, one slot for the mipmapped
question and one for the plain one. An upload leaves the memo standing, which is
the whole point; anything that could change the answer invalidates it.
ns per draw, DriverBench on a GTX 1660 SUPER (native / Espryt / Magma):
mc_vanilla_draw 257 / 2201->2037 / 1550->1346, mc_ubo_range 203 / 1832->1684 /
1089->934, mc_sampler_churn 272 / 2349->2325 / 1533->1396. Texture-upload cases
are unchanged, as expected - they were never asking this question in a loop.
Unit tests 421/421.
A per-draw CPU profile of a real Minecraft frame (perf on the render thread,
which sits at 100% of one core on both backends) said the deficit is translation
overhead, not the GPU, and named where it goes. This removes the largest items
it found, on both backends and in the shared frontend they both feed.
The single biggest one was not translation at all: IsBackendContextCurrentOnThisThread
called eglGetCurrentContext on every invocation, and glvnd answers that with a
getpid() fork check - a real syscall. The predicate sits two and three deep in
every draw (the deferred-release drain, the global-UBO ring availability check,
and the ring allocation), so it accounted for 16.3% of the render thread. EGL is
still the ground truth, but re-verifying it once per thread per frame catches an
external migration at the next frame boundary rather than the next call, which
recovers the same bookkeeping.
Texture uploads now carry a dirty region instead of a per-level flag. Minecraft
animates atlas sprites with 16x16 glTexSubImage2D calls into a 1024x512 atlas
and respecifies the lightmap every frame; a per-level flag turned each of those
into a full-level re-upload - about 3.6 MB a frame of texels nobody changed.
MipmapStorage accumulates the written box, Espryt uploads it with
UNPACK_ROW_LENGTH striding into the level shadow, and Magma stages just that box.
The box is a union, not a range list: repeated writes to one level widen it and
it degrades to exactly the old whole-level upload, which is the honest worst case.
glBufferData(NULL) is the orphaning idiom, and the backend was answering it by
uploading the stale CPU shadow - turning a rename the driver does for free into
a full synchronized upload. BufferObject now records that a NULL respecify leaves
the store undefined, and the upload is skipped until content is actually written.
The rest are smaller and of a kind: the deferred-release queue is probed without
taking its mutex, the UBO ring waits on the frame fence that frees the space it
needs instead of draining the whole pipeline with glFinish at the size cap, VAO
binds go through a shadow so a draw's second bind of the same object does not
reach the driver, the per-draw clean-texture probe short-circuits on the content
version before rebuilding shape info, glUniform drops byte-identical writes
(which otherwise dirty the whole UBO for the next draw), re-binding the texture
or VAO a slot already holds no longer bumps the generation counters a backend
fast path is keyed on, and the texture validators stopped taking shared_ptr by
value.
On Magma: descriptor-set reuse keeps four entries instead of one, because draws
alternating between two programs - the chunk/entity ping-pong - thrashed a single
slot into a full re-allocate and re-write every draw; a DynamicDraw buffer whose
contents survive two frame boundaries is promoted to resident storage instead of
being re-copied into the per-frame arena forever; and sampled-read barriers name
only the shader stages whose device feature is enabled, which also removes a
latent VUID violation (ALL_GRAPHICS names geometry and tessellation stages a
device need not have).
Measured with the Minecraft rig (render distance 32, p50 fps, same machine,
single sample each): vanilla 1.21.1 Espryt 10.8 -> 36.3 and Magma 31.3 -> 44.6;
26.2 snapshot Magma 114.5 -> 210.5. Fabric+Sodium moved inside noise on Magma
(854 -> 766) with the native baseline itself moving 838 -> 1031 between the two
sessions, so treat that cell as unresolved rather than a regression measured.
Unit tests 421/421. The CTS A/B was not run: these numbers and the test suite are
the whole of the evidence, and a conformance regression would not have been
caught here.
The Minecraft-shaped driver benchmark could only be run from a desktop shell
against a desktop driver, which is the wrong machine: MobileGL exists to run on
mobile GPUs, and nothing said what its translation costs there. This puts the
same cases on an Android device, both in the plugin's POST screen and from a
shell, and adds the native-driver baseline they have to be read against.
The cases move into DriverBenchCases.inc so both harnesses run byte-identical
bodies - the desktop program resolving entry points from one EGL provider, and
DriverBenchJni.cpp calling MobileGL's frontend in-process. The JNI file binds
every gl*/egl* name to MG_Impl by macro rather than by linkage: this library
legitimately has the platform libEGL and libGLESv3 in its own lookup scope, and
a benchmark that quietly measured the device driver instead of the translation
layer would have looked like very good news.
Frames are now closed with a fence wait instead of glFinish. MobileGL implements
glFinish and glFlush as no-ops, so the old loop timed submit-plus-GPU on a native
driver and submit-only on a MobileGL backend, and the two numbers did not
describe the same work.
To measure a device's own driver the cases needed to be expressible in GLES:
ESSL 3.20 twins of the four shaders (chosen at runtime from GL_VERSION, since
MobileGL is deliberately still fed desktop GLSL - translating it is the thing
under test), a multi-draw hook that loops DrawElementsBaseVertex where the
multi-draw entry point does not exist, and an EGL bootstrap that falls back from
desktop GL to GLES 3. The binary cross-compiles for arm64 unchanged.
BenchService hosts each run in its own process and exits afterwards. That is not
caution: the backend is latched from MOBILEGL_BACKEND_TYPE at initialization, so
Espryt and Magma can never share a process, and Espryt's teardown terminates the
process-default EGL display, which would take the POST activity's own EGL
objects with it.
Running it found that Magma could not create a windowless context on Mali at
all - CreateInstance required VK_EXT_headless_surface, which no mobile driver
here exposes, and aborted the process. The Xlib path already probes and falls
back to a hidden window for the same reason on NVIDIA; Android now probes too
and hands the WSI an AImageReader's ANativeWindow, a real producer surface
attached to no display whose images are never acquired. DriverPost reports the
extension's absence as a WARN so the fallback is visible rather than silent.
Measured on a Mali-G77 MC9 (native / Espryt / Magma, ns per operation):
5495 chunk draws 14397 / 36934 / 33763, the 26.2 per-draw uniform-range pattern
13710 / 31205 / 21252, sodium-style multi-draw 256956 / 238389 / 209527. The
translation costs about 2.4x per draw here against 5-9x on the desktop, because
the mobile driver's own per-call cost dwarfs it - and both backends beat the
native driver on multi-draw, which it has to emulate.
Desktop unit tests 421/421; the POST screen and both Run Bench buttons verified
on the device.
The benchmark tree had nothing that exercised a driver: SanityBench times
std::vector, and the Buffer/Program benches call into MobileGL_s directly, so
neither can say what a backend costs against the native driver. This adds a
headless EGL client that can, and shapes its cases from measured traces rather
than guesses.
DriverBench dlopens exactly one EGL provider - the system libEGL.so.1, or a
libMobileGL.so with MOBILEGL_BACKEND_TYPE selecting Espryt or Magma - so the
same binary measures all three stacks with no LD_LIBRARY_PATH shadowing, which
matters because MobileGL's own loader has to keep finding the real driver
underneath. It renders into its own renderbuffer FBO on a 64x64 pbuffer and
paces frames with glFinish, so it needs no window and no compositor.
The six mc_* cases replay the per-frame call mix of 30-second render-distance-32
captures of three Minecraft versions, at the rates those captures measured:
vanilla 1.21.1 issues 5495 glDrawElements per frame, each preceded by its own
glBindVertexArray and glUniform3fv; Fabric+Sodium collapses the same scene into
132 glMultiDrawElementsBaseVertex; the 26.2 snapshot issues 3401
glDrawElementsBaseVertex, each preceded by glBindBufferRange + glBindBuffer.
The texture case wraps every 16x16 atlas upload in the four glPixelStorei and
two glTexParameteri calls Blaze3D re-sets around it, because that wrapper is a
large part of what an upload costs a translation layer. One bench frame
therefore costs what one real frame of that version costs, and ns_per_op is
directly comparable across renderers.
run_driver_bench.sh pins __EGL_VENDOR_LIBRARY_FILENAMES and VK_ICD_FILENAMES.
Without that, eglGetDisplay(EGL_DEFAULT_DISPLAY) on this glvnd system resolves
to Mesa llvmpipe and the "native" numbers silently describe a software
rasteriser - the first run of this bench reported 11 us per draw before the
pin, versus 250 ns on the real GPU.
Verified against the NVIDIA 610.43.03 driver, Espryt and Magma on a GTX 1660
SUPER; the CMake target builds and runs from a clean configure.
ChooseConfigForSurface prefilters candidate configs with eglChooseConfig
requiring EGL_ALPHA_SIZE 8, then tries to match the window's X visual. On
NVIDIA's X11 EGL every alpha-8 config lives on the 32-bit ARGB visual, and the
default depth-24 TrueColor visual only appears on alpha-0 configs - so for any
window created with the default visual the match loop scanned a list that
could not contain its visual, fell through to a 32-bit-visual config, and
eglCreateWindowSurface failed with EGL_BAD_CONFIG.
Keep the alpha-8 list as the first tier and add an alpha-relaxed second tier
used only for the visual match; the sizeless fallbacks below still run on the
alpha-8 list. Mesa is unaffected (its default-visual configs carry alpha), and
a destination-alpha-free default framebuffer is exactly what native GLX hands
out on these visuals anyway.
Found by running Minecraft through the new GLXImpl on Espryt: NVIDIA EGL also
needs EGL_PLATFORM=x11 under a Wayland session or eglGetDisplay itself returns
no display, which is a launcher-environment concern, not a library one.
Desktop Linux GL apps (GLFW/LWJGL, glxgears, anything X11) create contexts
through GLX, and MobileGL only spoke EGL - the two exported glX symbols were
proc-address stubs that could resolve GL entry points but never produce a
context. GLXImpl is the missing sibling of WGLImpl/CGLImpl: the same
window-system-binding pattern, calling the internal MG_Impl::EGLImpl namespace
directly.
The surface covers exactly what GLFW 3.4 resolves via dlsym plus the legacy
visual API: FBConfig enumeration mirrors the two EGLState configs (stencil-8
first so stencil-wanting choosers land on it), glXGetVisualFromFBConfig answers
with the screen's default visual (falling back to any 24-bit TrueColor one),
and glXCreateContextAttribsARB maps the ARB attribs onto EGL context attribs
the way WGL's Ext_CreateContextAttribsARB does - profile mask only emitted for
3.2+ or an explicit profile request, since that bit is what keys MobileGL's
relaxed-semantics compatibility mode. Legacy glXCreateContext/CreateNewContext
hand out 3.3 compatibility contexts, matching wglCreateContext.
Drawables follow the WGL HWND model: the GLXWindow is the X window itself, the
EGL window surface is created lazily on first MakeCurrent and cached per XID,
and the GLX layer owns size discovery per the platform-layer contract - it
pushes changes through EGLImpl::ResizePlatformWindowSurface, polling
XGetGeometry on MakeCurrent and on swaps throttled to 250ms so a fast-swapping
app is not paying a server round trip per frame. libX11 is dlopen'd at runtime
like everywhere else in the tree; Xlib.h is already in every TU via the vulkan
include, so XVisualInfo gets an ABI mirror struct (Xutil.h needs the Bool and
Status macros that Includes.h deliberately pops) and the caller's XFree pairs
with our malloc.
glXGetProcAddress now resolves glX names from the export table before falling
through to the shared GL resolver, which previously returned nullptr for every
glX extension entry point - GLFW requires glXCreateContextAttribsARB and
glXSwapIntervalEXT to arrive that way.
Verified with a smoke test replaying GLFW's exact call sequence (dlsym-only
resolution, manual FBConfig filtering, 3.2 core forward-compatible context,
glXCreateWindow, 60 swapped frames, clean glGetError) on both backends against
the real NVIDIA driver, then with Minecraft 1.21.1, 1.21.4+Fabric+Sodium and
26.2-snapshot-6 reaching in-world rendering on both Espryt and Magma.
Five independent bits of per-draw and per-operation waste in the DirectVulkan
backend, all removing work whose answer was already known.
The per-draw descriptor walk iterated all 256 slots of bindingKinds to find the
one to eight bindings a real GL program declares, because that vector is sized to
the binding cap rather than to the program. Reflection now records the bindings it
actually assigned, and the draw path iterates that. It is built at the end of
ReflectLayout, not where bindingKinds is sized - at that point the vector is only
zero-initialised and the kinds are assigned further down, so a list built there
would be empty. It has to stay ascending: Vulkan consumes pDynamicOffsets in
binding order and the writer pushes them in iteration order, so an unordered list
would silently mis-pair dynamic offsets with their uniform blocks.
Descriptor pools were sized maxSets * the 256-binding cap, declaring 81,920
descriptors per pool and 245,760 across the frames in flight, for sets that hold
what shader reflection found. Sized from eight now; an outlier program is absorbed
by the VK_ERROR_OUT_OF_POOL_MEMORY path that already exists, which works because
pool sizes are aggregate budgets rather than per-set limits.
TrackLiveResource swept the whole live-buffer vector on every insert once it
passed 256 entries, and when the buffers are all live the sweep removes nothing
and the vector grows by one - so creating N live buffers cost about N^2/2
expired() checks. It sweeps on a doubling watermark now, with the same
reclamation semantics.
GenerateMipmap transitioned each destination level individually inside its loop,
but every generated level starts in the same layout and the loop only moves a
level out of TRANSFER_DST after writing it, so the whole range can be prepared in
one barrier - 3(N-1)+1 barrier commands become 2(N-1)+2. Each level is still
transitioned to TRANSFER_SRC before it is read, so the dependency between
consecutive levels is unchanged.
WaitForFrameSerial drained the entire graphics queue, as its own comment admitted.
Every submission records the frame serial it was made under, so it now waits on
the first fence at or past the requested serial. The narrow path deliberately does
not call NotifyDeviceIdle(): that claims every submission has retired, which is
only true after a real drain, so it stays on the fallback.
Verified with an 8213-case A/B (textures, buffers, queries, mipmaps, uniforms and
the whole direct_state_access suite): the Espryt failure list is identical, the
Magma failure list differs by one case, and both crash sets are unchanged on
Magma. That one case, buffer_storage.map_persistent_draw, does not reproduce in
isolation - running the buffer_storage group alone gives byte-identical results on
both builds (the same three failures, not including it), and it reports
NotSupported when run on its own. It is the same ordering-dependent behaviour this
suite shows elsewhere, and the three Espryt crash-set differences are the known
copy_image cluster moving chunk position. Flagging rather than hiding it.
direct_state_access stays at Espryt 370/371 and Magma 371/371; unit tests 421/421.
RenderState kept one version counter for all render state, and DirectVulkan read
it in three places: the pipeline memo key, the SetupDrawSnapshot fast-path guard,
and that guard's store. So glViewport, glScissor, glBlendColor, glStencilMask,
glClearColor, glPolygonOffset, glLineWidth and the point-size family - none of
which can alter a VkPipeline, all of which an application changes between draws -
knocked the next draw off both fast paths and made it rebuild a pipeline lookup
that was already correct.
The counter is now split. m_version still moves on every state change, because
the draw snapshot really does depend on all of it. m_pipelineStateVersion moves
only for the state a backend bakes into a pipeline object, and it is what the
three DirectVulkan sites read.
The exclusion list is the eight VkDynamicState entries PipelineFactory declares
plus the state that is not pipeline state at all (the clear values, hints, the
point-size family, clamp read colour, the primitive restart index). glStencilFunc
is the one setter that had to be split rather than classified: Func is in the
pipeline payload but Ref and ValueMask are dynamic state, so it bumps the
pipeline version only when Func actually changes.
Capabilities are deliberately NOT in the exclusion list even though several look
like dynamic state: GL_FRAMEBUFFER_SRGB feeds the render-pass hash, depth and
stencil test feed drawUsesDepthStencil, and scissor test, blend, cull face,
polygon offset fill, primitive restart, colour logic op and rasterizer discard
all feed the pipeline payload.
Two smaller draw-path wins ride along, both removing work whose answer was
already in hand. UploadAndBindVertexStreams searched all 32 VAO attribute slots
for the SharedPtr matching a binding's buffer key, once per binding per draw -
but VertexInputStateFactory writes bindingBufferKeys[b] and
bindingAttributeLocations[b] from the same loop iteration, one binding per
attribute with no merging, so the attribute at that location IS the buffer, by
construction. UploadAndBindIndexBuffer round-tripped the element-array buffer's
raw pointer back through the GL name table on every indexed draw, costing a map
lookup and an atomic refcount pair, when the binding slot's SharedPtr was already
in scope forty lines above - where a comment says exactly that about the vertex
path.
Behaviour-neutral by construction and verified as such: a 13355-case subset of
GL30-GL45 covering viewport, scissor, blend, stencil, depth, polygon offset,
clear, multisample, cull, logic op, line width and point state, plus the whole
direct_state_access suite, is identical before and after on both backends - in
the failure list and in the crashed-case set. direct_state_access stays at
Espryt 370/371 and Magma 371/371.
Whether a backend can attach a single layer of a texture to a framebuffer was one
Bool, so it could only give the most conservative answer any target needed.
DirectVulkan therefore declined every layer of every target and
direct_state_access.framebuffers_texture_layer_attachment failed with 542
messages across four targets.
The three ways a GL layer maps onto Vulkan are independent capabilities, so the
flag becomes a per-TextureTarget mask. A 2D or 2D multisample array layer IS a
VkImage array layer and needed nothing but the gate opened. A cube map array is
one 2D image with arrayLayers = 6 * cubeCount and CUBE_COMPATIBLE, which is a
shape VkTextureManager simply did not have - it is declined softly when the depth
is not a whole number of cubes or the level is not square, because that function's
Bool return exists for unrepresentable shapes and asserting there would abort on
ordinary input, GL_PROXY_TEXTURE_CUBE_MAP_ARRAY above all. A 3D texture's layer is
a z slice, which needs a 2D-array-compatible image and a per-slice clear, because
vkCmdClearColorImage cannot address a subset of a 3D image's slices - a render
pass whose only content is its LOAD_OP_CLEAR can, since its attachment is a 2D
view over that one slice.
VK_IMAGE_CREATE_2D_ARRAY_COMPATIBLE_BIT is asked for per format and withdrawn per
format, mirroring the MUTABLE_FORMAT pattern already in this file: the capability
is per format+usage, so a single global probe answers a different question than
the one the frontend goes on to ask. Losing it costs per-slice attachment for that
format; failing creation would lose the texture.
Three things found on the way that are not the headline:
glFramebufferTextureLayer, the non-DSA twin, had no gate at all and additionally
refused cube map arrays that GL 4.5 requires it to accept. GL 4.6 core 9.2.8 makes
the two entry points equivalent, so they now decline in the same places - leaving
one ungated is what let an unrepresentable attachment reach the renderer.
ComputeFullMipLevelCount takes max(x, y, z), and for every array shape z is the
layer count rather than a mip-able axis, so a 4x4 array with 192 layers asked for
six mip levels on an image whose legal maximum is three
(VUID-VkImageCreateInfo-mipLevels-00958). Only the image's own extent can bound
it. lavapipe had been letting that through.
A layered GL clear queues layerCount = depth, which is illegal for a
VK_IMAGE_TYPE_3D image (VUID-vkCmdClearColorImage-baseArrayLayer-01472 pins it to
0/1, read as the whole mip level) and the old code passed it straight through.
Takes framebuffers_texture_layer_attachment green on DirectVulkan, so the whole
direct_state_access suite is 371/371 there; Espryt stays 370/371, the remaining
case being the fp64 one it declines by design.
Known and deliberately not fixed here, with a FIXME at the site:
KHR-GL44/45/46.geometry_shader.layered_framebuffer.clear_call_support now fails on
DirectVulkan - a layered clear of a 3D texture reads back zeros. Those cases exist
only in the GL44+ lists, above the 4.0 this backend reports. An A/B of a 6935-case
subset (cube map array, texture storage, framebuffer, 3D, the full DSA suite and
the GL33 texture group) is otherwise clean on both backends: 16 cases fixed and
none broken on Espryt, 15 fixed and those 2 broken on Magma, and zero difference
anywhere at GL 4.0 or below. The FIXME records which causes were already ruled out
by bisection so the next reader does not repeat them.
Vulkan's built-in convention is "provoking vertex first"; GL's default is
LAST_VERTEX_CONVENTION, and GL derives both flat shading and the transform
feedback vertex order from it. DirectVulkan had no way to say so, which is why
direct_state_access.queries_functional failed on a value with nothing in its log
- the primitives came back counted against a strip recorded in the wrong vertex
order.
VK_EXT_provoking_vertex is now enabled when present, and the mode is a hashed
field of the pipeline payload rather than dynamic state, because it is baked into
VkPipelineRasterizationStateCreateInfo: two draws differing only in it must not
collide on one cached VkPipeline, or whichever mode built first would stick for
the rest of the frame. The pNext is chained only when the mode is not Vulkan's
default, so a device without the extension produces a byte-identical
VkGraphicsPipelineCreateInfo to before.
Two carve-outs, both measured rather than reasoned:
A geometry shader already emits its triangles in GL's vertex order, so asking for
LAST rotates them a second time and transform_feedback.geometry reads back the
wrong vertices. The mode is one pipeline bit and the input-assembler path wants
the opposite, so the two cannot both be satisfied: a program that runs a geometry
shader and captures transform feedback keeps Vulkan's own convention. That test
is read off the program's own shader list, not
programObj.rasterizationProducerStage - the latter is filled by the clip-fixup
analysis, which does not run for every program and reads Unknown for exactly the
programs this guard exists to catch. Both halves are link-time facts folded into
programObj.hash, so no pipeline memo can hand back one built for the other mode;
keying on IsTransformFeedbackActive() instead would be a live bug, since neither
memo key moves on glBeginTransformFeedback.
transformFeedbackPreservesProvokingVertex is deliberately not requested. It buys
nothing here - the capture order queries_functional needs comes from
provokingVertexLast alone - and leaving it off keeps
VUID-VkGraphicsPipelineCreateInfo-topology-04884 disarmed, so a TRIANGLE_FAN
pipeline may take LAST on any device.
The blit pipeline routes through the same selector: it has no flat varying and no
capture, but on a device without provokingVertexModePerPipeline a blit left on
FIRST inside a render pass whose draws are LAST is an illegal mix.
Per the POST rule the new extension gets rows for provokingVertexLast and for the
two properties that change what MobileGL can promise.
Fixes queries_functional on Magma (370/371). An A/B over a 976-case transform
feedback / geometry shader / layered rendering subset of GL30-GL45 is otherwise
identical on both backends and additionally takes 14 geometry_shader rendering
and layered_rendering cases from failing to passing on Magma.
glCompressedTexImage2D rejected every internalformat with GL_INVALID_ENUM, so
direct_state_access.textures_get_image threw at its first compressed call and
reported InternalError with nothing in the log at all - the uncompressed half of
the case had already passed.
The compressed bytes are now kept verbatim, in a side-channel beside the texel
shadow rather than in place of it. That placement is the load-bearing decision:
both backends pair MapMipmapData with GetMipmapByteSize while sizing their copy
regions from GetMipmapTexelSize, and DirectGLES additionally divides the byte
size by the texel count to recover bytes-per-texel, so putting 16 bytes where a
4x4 RGBA8 extent says 64 would be an out-of-bounds read on both. The texel
storage therefore stays uncompressed and correctly sized - the image samples as
zeros, which is the same deviation the RGTC/BPTC/ETC2 arms of
ConvertGLEnumToTextureInternalFormat already document - while
glGetCompressedTexImage returns the image *as stored*, which GL 4.6 core 8.11
requires and which no re-encode could satisfy byte for byte. Nothing ever hands
the compressed bytes to GLES or Vulkan, so the shadow is authoritative rather
than potentially stale, which is why the readback never asks a backend.
The accepted set is exactly the RGTC/BPTC/ETC2-EAC formats core GL requires, and
it is deliberately the same set ConvertGLEnumToTextureInternalFormat can back
with uncompressed storage, so the upload can never accept a format whose texel
shadow it cannot allocate. imageSize is checked against the block arithmetic,
which is also what keeps the copy in bounds.
Three things the shape depends on. AllocateStorage clears the compressed tag, so
a glTexImage2D or glTexStorage2D over the level un-compresses it - without that,
textures_compressed_subimage would flip branches and start asking for data
MobileGL cannot produce. GL_TEXTURE_COMPRESSED and
GL_TEXTURE_COMPRESSED_IMAGE_SIZE are answered per level rather than per texture,
because a compressed internalformat handed to glTexImage2D resolves to
uncompressed storage and must keep reading as uncompressed. And
GL_TEXTURE_INTERNAL_FORMAT now reports the compressed token for such a level, or
it would claim GL_RGBA8 while GL_TEXTURE_COMPRESSED said true.
Still rejected on purpose: glCompressedTexImage1D/3D and every
glCompressedTexSubImage*, which caps the blast radius.
Fixes textures_get_image on both backends (Espryt 370/371, Magma 369/371). A/B
over a 1210-case compressed/texture-storage/texture-view/buffer-storage subset of
KHR-GL45 is identical before and after on both backends but for
get_texture_sub_image.errors_test, which stops throwing and fails on a value
instead.
glVertexAttribLFormat validated its arguments and then refused unconditionally
with "64-bit vertex attributes are not supported", so
direct_state_access.vertex_arrays_attribute_format failed every GL_DOUBLE
subcase on both backends - the format never landed, the draw fetched whatever
the attribute held before, and the captured values came back as reinterpreted
garbage.
The attribute is now real state. IsLong is its own bit rather than being
inferred from Float64, because glVertexAttribFormat(GL_DOUBLE) also reads
doubles - it just asks for them converted to float - so the type alone cannot
tell the two apart. It participates in the format comparison, so an L-format
call over a plain one still bumps the version, and glVertexAttribPointer clears
it inside the mutation block so the clear and the bump stay atomic.
GL_VERTEX_ATTRIB_ARRAY_LONG stops being hardcoded false, and the pname is now
accepted by the attribute queries at all.
Support is detected, never assumed. SupportsFloat64VertexAttributes comes from
VkPhysicalDeviceFeatures::shaderFloat64 on DirectVulkan and is false on
DirectGLES - not a driver question there and never will be, since ES has no
GL_DOUBLE vertex format and ESSL has no fp64 type to consume one with. A backend
without it declines in the entry point, with the GL error and a log line naming
the reason, rather than accepting state no draw could honour. Both cases get a
DriverPost row so the loss is named at startup instead of at draw setup.
On DirectVulkan the attribute deliberately does not use VK_FORMAT_R64*_SFLOAT:
those are optional and lavapipe advertises zero features for all four of them.
It is fetched as its 32-bit word pair (R32G32_UINT / R32G32B32A32_UINT) and
bitcast back to double in the shader by a new SPIR-V pass, which is bit-exact
and needs no format capability at all. The pass re-declares the input as uvec2 /
uvec4, demotes the original variable to a Private global and seeds it once at
the top of the entry point, so every existing load keeps its id and its double
type and no other instruction is rewritten. Both halves branch on nothing but
"is this attribute long", so they cannot disagree - and if the pass ever fails,
the assertion fires rather than letting a UINT format sit under a double input.
The pointer types are all created before any variable that names them and the
demoted variable is moved after them, since the types-and-variables section may
not forward-reference a type.
dvec3/dvec4 are declined rather than fetched wrong: six or eight uint32
components have no single VkFormat, and GL spreads such an input over two
attribute locations, which the location-per-index model here does not express.
Fixes vertex_arrays_attribute_format on Magma (369/371). On Espryt it stays
failing, now as a detected and explained decline rather than a blanket refusal.
ES only accepts glDrawBuffers bufs[s] == GL_COLOR_ATTACHMENTs, so a desktop
glDrawBuffer(GL_COLOR_ATTACHMENT3) cannot be expressed directly and DirectGLES
compacts: it physically relocates the draw buffer's image onto backend point 0 so
ES's output-0-to-attachment-0 rule lands on the right image. The clears were
therefore always correct. The read side was not.
GetBackendAttachmentType derived the attachment-to-point map by searching the
draw-buffer array and falling back to the identity point for anything it did not
find. That derivation is not injective against the compaction: after clearing
attachments 0..7 one at a time, every one of them has been relocated onto point 0
in turn, so a later glReadBuffer(GL_COLOR_ATTACHMENT0) - not a draw buffer any
more - takes the identity fallback to point 0 and reads attachment 7's image.
Hence the single mismatch, 0.875 where 0 was expected: 7/8 is attachment 7's clear
colour.
The map is now stored state rather than a re-derivation, and kept a permutation:
a draw buffer takes the point ES forces on it, everything else keeps its identity
point when that point survived, and an attachment evicted from its identity point
is parked on the lowest free one so it stays addressable for glReadBuffer and
blits. With identity draw buffers nothing moves and not one extra GL call is
issued, which is what keeps ordinary rendering untouched.
Two things the permutation depends on. The attachment loop now detaches a colour
point whose frontend owner is empty - SyncAttachmentObject only ever attaches, so
without this a point handed to an empty attachment would still hold the previous
owner's image and hand it back. And QueryReadColorAttachmentInternalFormat asked
GL_COLOR_ATTACHMENT0 for the format it sizes the multisample-resolve scratch
renderbuffer from; it now asks the point the read buffer actually names, since
that is only CA0 when the map happens to be identity.
Fixes framebuffers_read_draw_buffer on Espryt. A 5677-case readback and
framebuffer subset of GL30-33 stays at zero failures on both backends.
VkRenderPassManager kept m_renderbufferResources on FastSTL's open-addressing
UnorderedMap while BlitFramebuffer caches a raw pointer into one of its elements -
ResolveColorBlitBinding stores &rbResource->layout - and then calls
MaterializePendingClearForRenderbuffer, which looks that same resource up again.
FastSTL's operator[] runs its load-factor check before find_key and reallocates
the whole bucket array when occupancy crosses it, so even a plain lookup relocates
every element; erase only tombstones and never lowers the occupancy, so the
doubling keeps firing. After a relocation the cached pointer names freed storage
still holding the pre-clear VK_IMAGE_LAYOUT_UNDEFINED, BlitFramebuffer takes its
"source image layout is undefined" early return, and the blit is silently dropped
- glReadPixels then returns the zero-filled fresh allocation.
That is why the failures looked arbitrary: which iteration breaks is pure
arithmetic on the table's occupancy, and the observed set (GL_R8 at k=0,1,3,7,
GL_R16 at k=6, GL_RG16 at k=4) is exactly the doubling ladder. Padding the map
with unrelated live renderbuffers moves the failures to the positions the model
predicts and every previously failing format then passes, so nothing else hides
behind it.
Reordering the materialize ahead of the resolves - the fix ReadPixels got, see the
note at its call site - does not cover this, because BlitFramebuffer resolves two
bindings and the second resolve still runs after the first pointer is taken. The
depth blit, GetOrCreateRenderPass's depthRenderbufferResource and
ReadDepthStencilPixels cache the same kind of pointer, so the invariant belongs in
the container rather than in a per-call-site ordering rule. m_textureResources was
already node-based for exactly this reason; this is the map that was left behind.
Fixes renderbuffers_storage_multisample on DirectVulkan.
The pipeline object bookkeeping landed already - names, stage slots, queries -
but nothing consumed it. Every draw asked the context for the current program,
got null because a pipeline is used with program zero, and drew nothing;
glCreateShaderProgramv was still a stub returning zero, so
direct_state_access.program_pipelines_functional could not even build its stage
programs and reported InternalError on both backends.
glCreateShaderProgramv is written as the exact call sequence the spec defines it
to be, with one deviation that matters: the link goes straight to
ProgramObject::Link(false) rather than through LinkProgram, because LinkProgram
injects a default fragment shader into a program that has none - correct for a
whole program, wrong for a separable vertex-stage one whose fragment stage comes
from the pipeline. glDetachShader defers removal to the next link, so the program
keeps the shader object it was built from while correctly no longer reporting it
attached. GL_PROGRAM_SEPARABLE joins glProgramParameteri and glGetProgramiv.
Everything downstream of a draw - both backends, the uniform plumbing, the draw
validation - is written against one linked program, so rather than teach all of
it about stages, the pipeline is flattened: GetProgramForDraw() composites the
stage programs' shaders into a single hidden program object and caches it against
a signature of each stage program's lifetime id and link generation, so it is
rebuilt exactly when a stage or a stage's link changes. The composite carries no
GL name - it must not answer glIsProgram, and it must not consume a name the
application could be handed.
Uniform entry points get their own resolver rather than sharing that one:
glUniform* addresses the pipeline's active program, not the composited draw
program. GL_CURRENT_PROGRAM still reads the program in use, which is zero here.
Fixes program_pipelines_functional on both backends.
TextureCubeMapArray was missing from every storage and upload switch in the
DirectGLES texture sync, so a cube map array reached the driver with no storage
at all - and from the glFramebufferTextureLayer branch, so attaching one of its
layers fell through to glFramebufferTexture2D and raised INVALID_ENUM. Every
GL_TEXTURE_CUBE_MAP_ARRAY colour check in
direct_state_access.framebuffers_texture_layer_attachment read nothing.
ES 3.2 has GL_TEXTURE_CUBE_MAP_ARRAY natively and it stores exactly like a 2D
array whose depth is six times the cube count, so each switch gains the case
beside Texture2DArray and nothing else changes. 1D arrays join the layer branch
for the same reason - their backend image is a 2D array.
Per the POST rule the new GLES dependency gets a capability
(SupportsTextureCubeMapArray, ES 3.2 core or EXT/OES_texture_cube_map_array) and
a DriverPost row saying what a user loses without it.
Takes framebuffers_texture_layer_attachment from failing to passing on Espryt. It
still fails on DirectVulkan, which declines a layered attachment outright.
Three DirectVulkan gaps found together.
glBlitFramebuffer's depth/stencil path refused any blit whose source and
destination extents differ, because vkCmdCopyImage cannot resize. vkCmdBlitImage
can, and VK_FILTER_NEAREST is the only filter Vulkan allows for depth/stencil
anyway - which is what the GL front end already requires. A same-size pair keeps
the cheaper copy.
Worse, that refusal and four others were `return`, not `continue`, so a
depth/stencil aspect this backend could not handle abandoned the whole function -
including the colour blit that only starts after the aspect loop. The CTS's
scaling blits therefore lost their colour as well, which is why
direct_state_access.framebuffers_blit failed all three of its checks rather than
one.
VulkanRenderer::GenerateMipmap declined GL_TEXTURE_1D. It needed nothing else:
the blit loop derives every offset from the storage extent, and a 1D texture's is
{width, 1, 1}, which is exactly the y and z offsets a 1D image requires.
Also: IsTimerQueryResultReady now asks the query pool before the frame serial.
The pool polls with VK_QUERY_RESULT_WITH_AVAILABILITY_BIT and is the authority;
the frame serial only advances at Present and neither completion notifier will
mark the current serial done, so a timestamp written and fence-waited inside one
GL frame could never be read back within it.
Takes framebuffers_blit and textures_generate_mipmaps from failing to passing on
DirectVulkan. queries_functional still fails there on a value.
ResolveXfbSymbolType accepted only float, int and uint, and its caller reports
anything it rejects as "Transform feedback varying 'x' is not an output of the
vertex stage" - which is a misleading thing to say about a varying that is right
there in the shader, just declared `double`. Program linkage failed outright.
Doubles are now resolved to the GL_DOUBLE* types, in vector and matrix form, and
the per-element size is computed from an 8-byte component rather than a hardcoded
4 (GL 4.6 core 11.1.2.1), so the byte-based limit checks charge a double what GL
says it costs.
direct_state_access.vertex_arrays_attribute_format stops throwing on both
backends and fails on the captured values instead: the capture layout still owes
the 8-byte alignment doubles require, and neither backend feeds a 64-bit vertex
attribute yet - DirectGLES cannot at all, ESSL having no double.
The compatibility section already said 4.2; the status note at the top of the
README still said 3.3, so the two disagreed depending on how far a reader got.
BindCurrentTextures' program-driven path synced a bound sampler object's
parameters to its backend object and then never put it on the texture unit, so
every sampler object was inert and the driver kept sampling with the texture's
own parameters - direct_state_access.samplers_functional read black where the
sampler's NEAREST filtering should have given red.
The bind alone is a regression, and the CTS says so loudly: a sampler left on a
unit by an earlier draw keeps being applied, and a multisample texture takes no
sampler object at all, so the next draw against one is rejected and all 27
textures_storage_multisample_3d_* cases fail. The sibling path in the same
function had an empty else branch where the unbind belonged; it now unbinds,
making the two symmetric.
Takes samplers_functional from failing to passing on Espryt, with no other case
moving in either direction.
ResolveAttachmentBaseArrayLayer answered zero for everything but a cube map face,
so every blit, copy and glReadPixels against a layered attachment read layer zero
whatever was attached. It reads the attachment's layer now.
A 3D texture needs the other half of the distinction: its image has arrayLayers
== 1 and the GL layer is a z slice, which VkBufferImageCopy will not take as a
base array layer. BlitImageBinding carries it separately as depthOffset, and the
readback copy region uses it as the image offset's z.
Takes textures_copy from failing to passing on DirectVulkan, which is what
glCopyTextureSubImage3D needs to see the slice the CTS attached rather than
slice zero.
Two unrelated texture gaps.
glCopyTextureSubImage1D and 3D validated their arguments and then did nothing:
CopyTexSubImage1D_State and CopyTexSubImage3D_State were empty TODOs and no
backend exposes anything but a 2D blit. But a texture's contents live in its CPU
storage - the backends sync from it - so the copy does not need a blit at all.
CopyReadFramebufferIntoMipmapRegion reads the region out of the read framebuffer
through the existing ReadPixels path, in the destination's own canonical client
layout so the bytes need no second conversion, and writes them straight into the
level. GL 4.6 core 8.6 says the copy ignores pixel-store state and any bound pack
buffer, which the borrowed readback does not, so both are neutralised for the
duration and restored after. A cube map destination addresses its faces as
separate upload targets, so its zoffset picks the target rather than a slice.
ConvertGLEnumToTextureInternalFormat had arms for the six generic compressed
formats and the four RGTC ones, all resolving to uncompressed storage, but none
for BPTC or ETC2/EAC - so glTexImage2D with one of those fourteen enums answered
INVALID_ENUM, which was never a legal reply for formats core GL has required
since 4.2 and 4.3. They follow the same deviation for the same reason: nothing in
this stack can compress them, and uncompressed storage is the trade the RGTC
formats already take.
Takes textures_compressed_subimage from failing to passing on both backends and
textures_copy on Espryt. textures_copy still fails on Magma, where the readback
of a layered attachment does not yet resolve the attached layer.
Every program pipeline entry point was an export stub, and the stub macro's
`return (type)1` made glIsProgramPipeline answer GL_TRUE for anything - including
the names glGenProgramPipelines had never written. All four
direct_state_access.program_pipelines cases failed.
ProgramPipelineObject holds what GL 4.6 core 7.4 says a pipeline is: a program
reference per shader stage, the active program glProgramUniform* addresses, a
validate status and an info log. Its validate status starts false, unlike
ProgramObject's, because a pipeline that has never been validated must report
GL_VALIDATE_STATUS as 0.
The name rules follow the shape queries and transform feedbacks already use, and
which the CTS checks first: glGenProgramPipelines only RESERVES a name and
glIsProgramPipeline answers GL_FALSE for it; the object appears on first bind, or
immediately from glCreateProgramPipelines. Map membership is object existence -
a pipeline, unlike a transform feedback, has no stateful default object zero, so
no everBound flag is needed.
glGet(GL_PROGRAM_PIPELINE_BINDING) reports the real binding now instead of a
hardcoded zero whose comment said the entry points were stubbed.
This is the state half only. program_pipelines_functional needs mixed-stage
rendering - a vertex-only and a fragment-only program drawn together - and stays
failing; glCreateShaderProgramv is deliberately left stubbed until that lands, so
nothing can half-work in between.
Takes program_pipelines_creation, _defaults and _errors from failing to passing
on both backends.
glFramebufferParameteri, glGetFramebufferParameteriv and their two by-name
siblings were all export stubs - the GL_ARB_framebuffer_no_attachments entry
points. The stub raises no error and writes nothing, so
direct_state_access.framebuffers_get_parameter_errors saw GL_NO_ERROR for all
three conditions it checks.
FramebufferObject gains the five DEFAULT_* parameters as real state, initialised
to GL 4.6 core table 23.24 and bumping the object version on a write like the
read buffer does. The getter answers those plus the six derived names -
GL_SAMPLES and GL_SAMPLE_BUFFERS from the attachments' sample counts,
GL_IMPLEMENTATION_COLOR_READ_FORMAT/_TYPE from the read buffer's internal format,
GL_DOUBLEBUFFER true only for the window-system framebuffer, GL_STEREO false
because stereo surfaces are not exposed - which is what glGetIntegerv already
reports for the bound framebuffer.
The pname rules live in ValidateFramebufferParameterPname, and their ORDER is
load-bearing: a name outside the table is INVALID_ENUM, and only a name that IS
in the table but that the default framebuffer cannot answer is INVALID_OPERATION.
Testing the framebuffer kind first would answer INVALID_ENUM for
GL_FRAMEBUFFER_DEFAULT_WIDTH on framebuffer zero, which is exactly the third
thing the case checks. The by-name forms take zero as the default framebuffer,
like the other DSA framebuffer entry points.
Rendering to a framebuffer with no attachments is deliberately NOT enabled by
this: CheckCompleteness still reports INCOMPLETE_MISSING_ATTACHMENT, because no
backend can rasterize one. The state is real and the queries are honest; the
draw path is a separate piece of work.
Takes framebuffers_get_parameter_errors from failing to passing on both backends,
with framebuffers_get_parameters - which passed only because both getters were
stubs leaving the CTS's zero-initialised comparands untouched - still passing.
direct_state_access.framebuffers_texture_attachment threw on both backends, and
three separate things were wrong on the way to a cube map framebuffer.
glTexStorage1D/2D/3D validated their target by converting it to a single
TextureUploadTarget. GL_TEXTURE_CUBE_MAP has no single upload target - it
allocates all six faces - so the conversion produced Unknown and a legal
glTexStorage2D(GL_TEXTURE_CUBE_MAP, ...) was rejected with INVALID_ENUM, which is
where the case threw. The accepted set for these entry points is the dimension's
storage targets, which IsTextureStorageTargetForDimension already spells out, so
that is what they check now.
TextureStorage2D then allocated only the primary upload target, leaving a cube
map with one face out of six - cube-incomplete, so every framebuffer it was
attached to answered GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT. It allocates every
upload target the object has; for every other 2D target that is the same single
target as before.
ResolveRepresentableFramebufferTextureUploadTarget declined every layered target
but 2D array, so glNamedFramebufferTexture on a cube map reported "not
represented by the current framebuffer attachment model". Cube maps, cube map
arrays, 1D arrays, 2D multisample arrays and 3D textures are all the same shape
as the 2D array that already worked - glFramebufferTexture binds the whole
texture and the attachment records a representative upload target - so they are
all handled now. DirectGLES routes a layered attachment to glFramebufferTexture,
which is exactly this.
Takes framebuffers_texture_attachment from failing to passing on both backends.
Every one of the sixty direct_state_access.textures_storage_multisample_2d_* and
_3d_* cases failed on DirectVulkan, for every internal format, with no GL error
anywhere - a pure data mismatch.
The CTS asks for glTextureStorage2DMultisample(tex, samples = 1, ...), which is
legal GL, and MobileGL carried the 1 faithfully through to
VkImageCreateInfo::samples = VK_SAMPLE_COUNT_1_BIT. It then binds that image to
the auxiliary program's sampler2DMS, whose SPIR-V is OpTypeImage with MS = 1.
VUID-RuntimeSpirv-samples-08726 forbids exactly that pairing: an MS access must
come from an image created with more than one sample. The texelFetch therefore
read undefined data - which is why it looked format-independent and raised
nothing.
GL only promises "at least the requested number of samples", so a multisample
texture is now floored at two. GL_TEXTURE_SAMPLES still reports what the
application asked for; that is read off the texture object, not off the image.
The device-capability round below it is bounded at two for the same reason -
letting it land back on one sample would recreate the violation silently for any
format whose only supported count is one.
Takes all 60 textures_storage_multisample_* cases from failing to passing on
DirectVulkan, which goes from 296/371 to 356/371. DirectGLES is untouched.
glGetSamplerParameterfv(sampler, GL_TEXTURE_BORDER_COLOR) raised INVALID_ENUM,
because MobileGL kept the border colour on the texture object and
GetSamplerParam_State had no case for it at all. That is the first thing
direct_state_access.samplers_defaults asks, so the case threw before reaching
any of the defaults it was written to check.
GL 4.6 core table 23.18 lists TEXTURE_BORDER_COLOR as sampler state, so it moves
to SamplerParameters and TextureObjectBase reaches it through the SamplerObject
it already owns - one source of truth, and a sampler object bound over a texture
now supplies its own border colour, which is what GL says should happen. The
texture params version still moves on a write, because the DirectGLES texture
sync memoises on it. glSamplerParameter{fv,Iiv,Iuiv} and their getters read and
write all four components in whichever representation the caller used, and the
three representations are kept in step so any getter has an answer. The bogus
[0,1] and [0,255] range checks are gone: GL clamps a border colour when a
fixed-point format is sampled, it does not reject it.
DirectVulkan's ResolveVkBorderColor now reads the sampler rather than the
texture. DirectGLES gained a glSamplerParameterfv in its sampler sync, and both
that and the pre-existing glTexParameterfv are gated on a new
SupportsTextureBorderClamp capability - ES 3.2 core, or EXT/OES_texture_border_clamp
before it - since without the extension every such call is INVALID_ENUM on the
driver. DriverPost gains the matching row per the POST rule, saying what a user
actually loses when it is missing.
Takes direct_state_access.samplers_defaults from failing to passing on both
backends.
ValidateRenderbufferStorageSamples_State answered INVALID_VALUE for a sample
count above GL_MAX_SAMPLES. GL 4.6 core 9.2.4 reserves INVALID_VALUE for a
negative count: a count that is well formed but larger than the format can
deliver is INVALID_OPERATION, because the argument is fine and the format is
what cannot honour it.
Takes direct_state_access.renderbuffers_storage_multisample_errors from failing
to passing on both backends.
DirectGLES served every multi-slice glGetTexImage from the CPU shadow copy, on
the grounds that its scratch FBO can only expose one layer at a time. But the
shadow only holds what was uploaded, so any slice that was rendered to rather
than written by glTexSubImage came back stale - and a layered framebuffer
produces exactly that.
The scratch FBO can expose one layer at a time repeatedly. The read now attaches
each layer in turn and takes the slice off the GPU, walking the destination over
GL_PACK_SKIP_IMAGES / GL_PACK_IMAGE_HEIGHT itself so each per-slice call packs a
plain 2D image with the same layout StoreWideRowsToClient computes for the whole
stack. The shadow stays as the fallback for the formats a colour attachment
cannot represent at all, and for any slice whose attachment comes back
incomplete.
Takes all 27 remaining direct_state_access.textures_storage_multisample_3d_*
cases from failing to passing on Espryt - they render into a
TEXTURE_2D_MULTISAMPLE_ARRAY one layer per colour attachment and then read the
whole array back. DirectVulkan is untouched.
GetQueryObjectValue implemented GL_QUERY_RESULT_AVAILABLE and GL_QUERY_RESULT and
rejected everything else, so direct_state_access.queries_functional threw on its
very first probe - GL_QUERY_TARGET - and never reached any of the checks it was
written for.
GL_QUERY_TARGET is state the object has carried all along; it just had no case.
GL_QUERY_RESULT_NO_WAIT is GL_QUERY_RESULT with the backend asked not to block,
and it brings a wrinkle the shared getter could not express: when the result has
not landed, GL_ARB_query_buffer_object leaves the destination untouched rather
than writing a placeholder. GetQueryObjectValue now reports "succeeded but
produced no value" through an optional out-parameter, and all five callers - the
four buffer forms and the four client-memory forms - skip the write on it.
The switch is deliberately widened by exactly these two names: its default
INVALID_ENUM is what the GL33 and GL40 query error cases rely on.
queries_functional passes on Espryt. On Magma it stops throwing and fails on a
value instead, which is a separate problem in the query results themselves.
Two reasons a framebuffer's contents came back wrong, both on the read/clear
side rather than the write side.
Stencil, on both backends. The CTS reads stencil with glReadPixels(GL_STENCIL_INDEX,
GL_INT), which is as legal as the unsigned widths, and neither backend accepted
it: DirectGLES's ReadPixelsStencilViaNative rejected every signed type, after
which the call fell through to a native ES read the driver refuses and nothing
was written at all, so the caller kept its zeros; DirectVulkan's pack switch had
no GL_INT case, and of the cases it did have only GL_UNSIGNED_INT sourced the
stencil plane - GL_FLOAT and GL_UNSIGNED_SHORT emitted a depth value, which is
meaningless for a stencil-only image. Both now take the signed and float widths,
and DirectVulkan decides "this is a stencil read" once rather than per type.
DirectGLES also gains the GL_FLOAT_32_UNSIGNED_INT_24_8_REV fallback a
DEPTH32F_STENCIL8 attachment needs, which rejects the 24_8 packed type.
sRGB, on DirectVulkan. Every other write path goes through the UNORM twin view
while GL_FRAMEBUFFER_SRGB is off, storing the raw value GL asked for, but a
deferred clear is materialised with vkCmdClearColorImage - which names the image,
so the driver applied the sRGB transfer function and a clear to 0.25 landed at
0.537. PreCompensateSrgbClearColor hands it the linear colour whose encoding is
the requested value instead. It is a no-op for non-sRGB destinations, for integer
clear encodings, and when GL_FRAMEBUFFER_SRGB is on and GL really does want the
encode.
Takes renderbuffers_storage from failing to passing on both backends, plus
renderbuffers_storage_multisample and framebuffers_blit on Espryt.
NamedFramebufferTextureLayer declined every attachment but layer zero, on both
backends. That was right for DirectVulkan, which maps a GL layer onto a Vulkan
array layer with no notion of a 3D depth slice, but wrong for DirectGLES:
SyncAttachmentObject already routes a layered upload target to
glFramebufferTextureLayer with the attachment's layer passed straight through,
and array storage already carries the real layer count into glTexStorage3D. The
one backend that could render to the layer was being told it could not.
The decision now lives in a DynamicBackendParameters flag, so it is the backend
that answers rather than the entry point guessing. DirectGLES sets it when the
driver resolved glFramebufferTextureLayer; DirectVulkan leaves it false until
VkRenderPassManager tells a depth slice from an array layer.
framebuffers_texture_layer_attachment's colour checks now pass on Espryt for 3D,
2D array and 2D multisample array textures - the case still fails there on cube
map arrays, which DirectGLES gives no storage at all, and on the depth and
stencil halves. No case changes on DirectVulkan, which keeps the old behaviour.
Both direct_state_access.textures_generate_mipmap* cases crashed DirectVulkan.
Two causes, neither of them a broken invariant:
glGenerateMipmap and glGenerateTextureMipmap never checked cube completeness, so
an incomplete cube map went straight to the backend, which asserts that the
texture it is handed is complete. GL 4.6 core 8.14.4 makes that call
INVALID_OPERATION - there is no consistent set of faces to filter down - and both
entry points now say so through a shared check.
VulkanRenderer::GenerateMipmap asserted that the target was one of the four it
implements. 1D, 1D array and cube map array are legal GL and the front end passes
them through, so meeting one is a gap in this backend's coverage; it now logs and
declines, leaving the generated levels unwritten rather than aborting.
textures_generate_mipmap_errors passes on both backends now. textures_generate_mipmaps
stops crashing but still fails: DirectVulkan does not generate the 1D mip chain
the case checks - the frontend's storage allocation gives the levels the right
sizes, which is why the case passes when run on its own, but not the descending
content the full-run state leaves it looking for.
Implementing NamedFramebufferTextureLayer made layered attachments reachable for
the first time, and direct_state_access.framebuffers_texture_layer_attachment
went from Fail to Crash on DirectVulkan. Two separate gaps sat behind it, both
of them asserted on rather than reported:
- The renderer resolves an attachment's GL layer straight onto a Vulkan array
layer. A 3D texture's z-slice therefore lands outside its image, which has one
array layer by construction, and the array texture objects are still the
one-image stubs in TextureObjectStubs.h, so their image has a single layer
whatever GL believes. MaterializePendingClearForTexture tripped over a clear
whose layer span was outside the image it was given.
- A cube map array has no image shape in VkTextureManager at all, so
SyncTextureAndGetDescriptor returns null for it.
NamedFramebufferTextureLayer now answers the full error set for every target and
layer - which is what took the two error cases green - and then declines to
attach anything but layer zero of a non-cube-array texture, through the same
RecordUnsupportedFramebufferTextureAttachmentError the by-target entry point
already uses. Layer zero of the other targets is the plain first-slice
attachment glFramebufferTextureLayer already backs, so it still goes through.
SyncTextureResource's assertion on an unsupported texture shape is also gone: it
is a gap in this backend's coverage, not a broken invariant, and the code below
it already handles the failure by declining the sync. It logs a warning instead.
framebuffers_texture_layer_attachment goes back to Fail on DirectVulkan rather
than Crash; no case changes in either direction beyond that.
Four direct_state_access framebuffer cases failed on one shared cause and three
local ones.
The shared cause: every DSA framebuffer entry point resolved its name through
GetNamedFramebufferObject_State, which rejects zero outright. But zero names the
default framebuffer to these functions, so glGetNamedFramebufferAttachmentParameteriv,
glNamedFramebufferDrawBuffer(s) and glNamedFramebufferReadBuffer answered
INVALID_VALUE for every default-framebuffer query the CTS makes. They now resolve
zero to the default framebuffer object and tell the two kinds apart explicitly,
which is what the accepted-name rules key off anyway.
Attachment queries: the accepted attachment names differ between the default
framebuffer (FRONT/BACK variants, DEPTH, STENCIL) and a framebuffer object
(COLOR_ATTACHMENTi, DEPTH/STENCIL/DEPTH_STENCIL_ATTACHMENT), and a name outside
the relevant list is INVALID_ENUM. Both getters share ResolveAttachmentQueryName
for that, so the by-target form no longer aliases GL_FRONT onto a framebuffer
object's colour attachment 0. The TEXTURE_* parameters are also rejected with
INVALID_ENUM when the attached object is a renderbuffer.
Buffer selection: naming a buffer that belongs to the other kind of framebuffer
is INVALID_OPERATION, not INVALID_ENUM - the enum is accepted, the framebuffer
just has no such buffer. glDrawBuffers additionally rejects the multi-buffer
names (FRONT, LEFT, RIGHT, FRONT_AND_BACK) with INVALID_ENUM on both kinds,
takes BACK only when n is one, and glReadBuffer treats the multi-buffer names as
accepted-but-unselectable. Both colour-attachment range checks now go through
ValidateColorAttachmentInRange instead of comparing against MAX_DRAW_BUFFERS with
an off-by-one.
NamedFramebufferTextureLayer was a stub that reported "not represented by the
current framebuffer attachment model" for every call, even though the attachment
model stores a layer and the by-target glFramebufferTextureLayer already uses it.
It is implemented against the same model, with the per-target layer limits and
the INVALID_OPERATION-for-a-bad-name rule that separates it from
NamedFramebufferTexture. NamedFramebufferTexture itself gained the two checks it
lacked: colour attachment range, and a negative level.
Takes framebuffers_get_attachment_parameters, framebuffers_get_attachment_parameter_errors,
framebuffers_texture_attachment_errors and framebuffers_draw_read_buffers_errors
from failing to passing on both backends.
The fetch script tries git.hit.moe, then the repo.miawa.cn mirror, and only
then Git LFS, but it bailed out of the mirror loop on the first file no
mirror could serve and then pulled the whole case from GitHub. A case whose
mirrors served every file but one paid GitHub's LFS bandwidth for all of
them.
Collect the files that survived every mirror and every retry instead, and
scope the LFS fallback to just those, matching what the local macOS retrace
helper already does.
CopyTextureSubImage1D and 3D were do-nothing stubs and the 2D form checked only
its effective target, so all 28 conditions in
direct_state_access.textures_copy_errors went unreported: level and region
bounds, and every read-framebuffer precondition.
The read-framebuffer half lands in FramebufferImpl as ValidateReadFramebufferForCopy -
incomplete read framebuffer (INVALID_FRAMEBUFFER_OPERATION), a read buffer that
names no attachment, and a multisampled read buffer (both INVALID_OPERATION). It
decides multisampledness by attachment kind rather than by sample count alone,
because a TEXTURE_2D_MULTISAMPLE attachment sets SAMPLE_BUFFERS even when its
sample count is one - which is exactly what the CTS attaches, and what a
renderbuffer-only check would have missed.
The texture half is ValidateCopyTextureSubImage, shared by all three forms; 1D
and 3D also get the effective-target rule their form specifies.
NOTE: the copy itself is still not implemented for 1D and 3D - CopyTexSubImage1D_State
and CopyTexSubImage3D_State remain TODOs and no backend exposes anything but a
2D blit - so direct_state_access.textures_copy stays red. Only the errors are
complete, which is what un-stubbing these two entry points buys; both carry a
comment saying so.
CopyTextureSubImage2DUsesNamedObjectAndRestoresBinding had been passing a
storage-less texture and no read framebuffer, which the new validation correctly
rejects. It now sets up a legal copy, so it still measures the by-name plumbing
it was written for.
Takes direct_state_access.textures_copy_errors from failing to passing on both
backends.
glGetTextureImage resolved a texture by name and went straight to the read,
skipping every object-level rule glGetTexImage enforces through
GetTexImage_State - and on DirectVulkan it skipped the level checks in
CopyTextureImageToClientOrPBO_State as well, because that backend answers
GetTextureImage itself. Fifteen of the sixteen conditions in
direct_state_access.textures_image_query_errors went unreported.
The object-level half of that error set now lives in ValidateTextureImageQuery
and both entry points run it. Three rules are new rather than merely relocated:
- Multisample and buffer textures are not in the accepted target list; neither
has a single image to return.
- The destination-size checks (bufSize, and the span written into a bound pixel
pack buffer) move ahead of the read. They existed, but downstream of it, where
any early bail-out - an unmapped level, a pack step that declines the format -
swallowed them. Both measure the tightly packed span summed over the object's
faces, which is the least a query can produce, so nothing that would have fit
is rejected.
- IsDepthLikeInternalFormat had no case for StencilIndex8, so a colour client
format read back against a stencil-only texture looked like a matching pair.
glGetCompressedTextureImage was a do-nothing stub. It validates the name and the
level, then reports INVALID_OPERATION: no format MobileGL can hold is
compressed, and answering GL_NO_ERROR without writing would hand the caller
stale memory - the same reasoning GetCompressedTexImage_State already follows.
Takes direct_state_access.textures_image_query_errors from failing to passing on
both backends.
TexSubImage1D/2D/3D_State each carried a TODO for the three INVALID_OPERATION
conditions GL 4.6 core 8.5 attaches to sourcing an upload from a bound
PIXEL_UNPACK_BUFFER: the store being mapped, an offset that is not a multiple of
the size of one datum of `type`, and reads that would run past the end of the
store. None of them was checked, so every such call was quietly accepted.
ValidatePixelUnpackBufferSource now covers all three and returns true when no
unpack buffer is bound, so the callers can run it unconditionally. Persistent
mappings stay legal sources, matching what ReadPixels already does on the pack
side. The overrun check measures the tightly packed span, which is the smallest
the unpack can read - pixel store parameters only ever widen it - so it cannot
reject an upload that would have fit.
TextureSubImage2D needed the call of its own: unlike its 1D and 3D siblings it
does not route through TexSubImage2D_State.
Takes direct_state_access.textures_subimage_errors from failing to passing on
both backends.
Two independent gaps in the texture parameter paths, both reported by
direct_state_access:
TexParameterf_State never ran ValidateTextureParameterForTarget. The integer
setter reaches it through TextureParameterObject_State and the scalar float
setter through TextureParameterObjectf_State, but glTexParameterfv and
glTextureParameterfv funnel every non-vector pname straight into
TexParameterf_State - so in float form MobileGL accepted sampler state on a
multisample texture, a mipmapping min filter or a REPEAT wrap on a rectangle
texture, and a negative TEXTURE_BASE_LEVEL/TEXTURE_MAX_LEVEL, all of which the
integer form rejected. It now validates first, passing the same
anisotropy-exempt param the by-object float setter uses so the anisotropy range
check is not run twice.
GL_TEXTURE_COMPRESSED_IMAGE_SIZE answered 0 for every texture. GL 4.6 core 8.11
makes the query INVALID_OPERATION on an image whose internal format is
uncompressed and on any proxy target. TextureInternalFormat has no compressed
enumerator, so that is every texture MobileGL can hold today; the condition is
still written against an IsCompressedTextureFormat predicate so both level
getters answer consistently once compressed formats land, and
GL_TEXTURE_COMPRESSED now reads from the same predicate instead of a hardcoded
false.
Takes textures_parameter_setup_errors and textures_level_parameter_errors from
failing to passing on both backends.
Both AdvertisesVoxyRequiredRenderingExtensions cases pinned TargetGLVersion at
3.3, which was the reported version until V_OpenGL40 joined the advertised
extension lists. The version assertion is incidental to what these cases are
for - Voxy needs the individual ARB extensions, not a version - so it just
tracks the new report instead of holding the old one.
Both backends stopped their advertised version list at V_OpenGL33, so an
application - or the CTS - asking what MobileGL supports was told 3.3 even
though the 4.0 entry points and the KHR-GL40 suite already pass on both.
Adding V_OpenGL40 lets that work be reached through the ordinary version query
instead of only through the individual ARB extension strings.
The 3.3 line is done - GL30 through GL33 conform on both backends - and the
work in flight (GL40, direct state access) is already past it, so the stated
short-term target now reads 4.2 and MG_State/MG_Impl are focused there.
Performance work joins the focus list alongside the two backends.
SamplerParameters defaulted compareFunc to ALWAYS, but GL 4.6 core table 23.18
and GLES 3.2 table 21.16 both say the initial value is LEQUAL - for sampler
objects and for the sampler state a texture object carries alike. Every freshly
created texture and sampler therefore answered GL_ALWAYS to
glGetTextureParameteriv(GL_TEXTURE_COMPARE_FUNC).
The Vulkan backend had been papering over it: ResolveCompareFunc substituted
LESS_EQUAL whenever a depth texture was sampled in compare mode and the func
still read ALWAYS, which fixed the rendering but also made an explicitly
requested GL_ALWAYS unreachable. With the default corrected that special case is
both unnecessary and wrong, so it is gone and the compare op is taken straight
from the sampler.
Takes direct_state_access.textures_defaults from failing to passing on both
backends.
The validation added with the invalidation entry points took the default framebuffer's
buffers to be only FRONT_LEFT, FRONT_RIGHT, BACK_LEFT, BACK_RIGHT, DEPTH and STENCIL, so a
call naming COLOR came back INVALID_ENUM. The by-name forms spell the colour buffer the way
glClearNamedFramebuffer does - COLOR, DEPTH, STENCIL - while the target forms use the
individual left/right tokens, and both spellings arrive at the same validation, so both sets
belong there (GL 4.6 core 17.4.4).
Caught by framebuffers_invalidate_data and framebuffers_invalidate_subdata, which had been
passing while the entry points were stubs doing nothing at all. Those two plus
invalidate_data_and_subdata_errors now pass together on both backends.
glGetTexParameter and its by-name form rejected several parameters GL 4.6 core table 8.20
lists, with INVALID_ENUM as if the application had made them up. GL_DEPTH_STENCIL_TEXTURE_MODE
was the worst of them: the float setter accepted it, validated it and then threw the value
away, the integer setter did not accept it at all, and neither getter could report it - so
the mode could be set and never read back, and setting it through glTextureParameteri was an
error.
It is real state now, defaulting to DEPTH_COMPONENT, set by both setters and readable from
both getters. GL_TEXTURE_LOD_BIAS was in the same position: settable, not gettable.
The by-name getters reach the target-based ones through a temporary binding rather than the
per-object path, so both had to learn these; the per-object path gained the swizzle
components, the target, the image format compatibility type and the texture-view parameters
at the same time, since they were missing there for the same reason.
direct_state_access.textures_get_set_parameter passes on both backends, and textures_defaults
stops raising an internal error and reports an ordinary failure it can be diagnosed from.
glGetQueryBufferObjectiv and its three siblings were stubs. They are the ordinary query
getters with the destination changed from client memory to a buffer object, so everything
about the query itself - the name, whether it is still active, the parameter - is already
answered by the shared GetQueryObjectValue, including the errors it raises.
What was left is the destination: a negative offset is INVALID_VALUE, a name that is not a
buffer object is INVALID_OPERATION, and so is a write that would run past the end of the
buffer. The four differ only in the width they store, so they share one template.
direct_state_access.queries_errors passes on both backends, putting the group at 4 of 5.
queries_functional now reaches further into the test and ends in an unrelated InternalError
rather than a plain failure.
glInvalidateFramebuffer, glInvalidateSubFramebuffer and their two by-name forms were all
stubs, so every call - including the malformed ones - returned quietly with no error.
These four only grant permission to throw the named attachments' contents away, and keeping
them satisfies "the contents become undefined", so the frontend validates the call and
leaves the contents alone. Actually discarding is a bandwidth optimisation that would need a
backend dependency; it can be added later without changing what any of these promise.
The validation is where the real content is. Which tokens name an attachment depends on
which framebuffer is affected: the default framebuffer has buffers (FRONT_LEFT and company)
and a framebuffer object has attachment points, so a token from the wrong set is
INVALID_ENUM. A COLOR_ATTACHMENTm past GL_MAX_COLOR_ATTACHMENTS is different in kind - a
well-formed enum naming a point that does not exist - and is INVALID_OPERATION, which the
existing colour-attachment range validator already expresses. Negative counts and negative
sub-region extents are INVALID_VALUE.
direct_state_access.invalidate_data_and_subdata_errors passes on both backends.
glMapBufferRange and glMapNamedBufferRange rejected an access of zero with INVALID_ENUM.
Zero is a perfectly well-formed bitfield value - it contains no invalid flags - and what it
violates is the separate rule that a mapping has to ask for read or write access, which GL
reports as INVALID_OPERATION. Both callers already checked that rule immediately after, so
the validator was reporting the wrong error for a case its callers were about to handle
correctly.
direct_state_access.buffers_errors passes, which puts the whole buffers group at 4 of 4 on
both backends.
glClearBufferData and friends accepted exactly two argument triples - R8UI with
UNSIGNED_BYTE and R32UI with UNSIGNED_INT, both through RED_INTEGER - and raised
INVALID_ENUM for everything else. That is most of the entry point missing rather than a
narrow gap: GL takes any of the sized formats in the buffer-texture table, which is what an
application clearing an RGBA8 or R32F buffer uses.
The wrong error also hid the checks behind it. A test clearing a mapped buffer, or one
passing a misaligned offset, never reached those rules because the format tuple was rejected
first, so INVALID_ENUM came back where INVALID_OPERATION or INVALID_VALUE was due - the
validation was there and correct all along, just unreachable.
internalformat now goes through the same table the buffer textures use (shared rather than
written out twice, since it is the same list for the same reason), and format and type
through the ordinary pixel format converters. The element size comes from the internal
format, which is what offset and size have to be multiples of. Note that a bad format or
type here is INVALID_VALUE, not INVALID_ENUM (GL 4.6 core 6.3) - the odd one out among the
enum arguments, and what the conformance tests check for.
The pattern is still replicated verbatim, which is correct while the client layout matches
the internal format - every real caller, and every conformance case. When they differ it now
says so instead of quietly writing a differently-sized pattern.
direct_state_access.buffers_clear and buffers_functional pass on both backends;
buffers_errors is down to one unrelated complaint about glMapNamedBufferRange.
The by-name read was a stub, so it left the caller's buffer untouched and a test comparing
it against a reference saw whatever that memory already held. Its by-target sibling
glGetBufferSubData was already implemented, so this is that function with the buffer
resolved by name instead of through a binding: the same non-negative offset and size check,
the same bound-by-the-buffer's-size check, the same refusal to read a buffer mapped without
GL_MAP_PERSISTENT_BIT, and the same SyncGpuWrites before the download so a GPU-side write
that has not landed yet is not missed.
Resolving by name reports INVALID_OPERATION for a name that is not a buffer, which the
by-target form expresses as "target is bound to no buffer object" instead.
direct_state_access.buffers_get_named_buffer_subdata passes on both backends.
glClearBufferiv and glClearBufferuiv flattened their values into the payload's float vector,
and every clear was later written into VkClearColorValue::float32. Vulkan reads that union
according to the destination image's format rather than converting between its members, so
an R8I attachment cleared to -16 received the bit pattern of -16.0f. On top of that,
QueueRenderbufferClear copied only the float vector into the pending clear, so even the
flattened value was dropped and the attachment kept reading zero - which is what the
conformance tests actually observed.
The payload now records which of the three entry points supplied the colour and keeps the
value in that form, and one helper builds the union member the encoding calls for. GL's rule
that a format with no alpha channel reads as one has to be applied in the value's own type,
so the "does this format lack alpha" question is now asked separately from the substitution
and the helper applies it to whichever member is live. glClear is left on the float path
explicitly: ClearFramebufferPayload has no other form.
Takes every integer renderbuffer format in direct_state_access.renderbuffers_storage from
failing to passing on Magma - 115 reported mismatches down to 20, the rest being the stencil
formats Espryt fails too and SRGB8_ALPHA8 - and makes framebuffers_clear pass on both
backends.
glClearNamedFramebufferiv and glClearNamedFramebufferuiv were stubs, so a clear through
them was silently dropped and the attachment kept whatever it held. Their float siblings
were already implemented, which is what made the gap look like a rendering bug rather than
a missing entry point.
Which buffers they accept is narrower than glClearNamedFramebufferfv and differs between
the two: signed values clear COLOR or STENCIL, unsigned only COLOR (GL 4.6 core 17.4.3.1).
Only the colour buffer is indexed, so a stencil clear naming any drawbuffer other than 0 is
INVALID_VALUE rather than merely ignored, and anything else is INVALID_ENUM. Resolving the
framebuffer by name goes through the same helper the float forms use, which is what reports
INVALID_OPERATION for a name that is neither zero nor an existing framebuffer.
Both backends express them the way they already express the float forms: DirectGLES binds
the named framebuffer and forwards to glClearBuffer*, Magma queues the payload against the
named framebuffer rather than the bound one.
direct_state_access.framebuffers_clear_errors passes on both backends, and
framebuffers_clear passes on Espryt. Magma still fails that one, for a separate reason on
the materialization side rather than in these entry points.
Two unit tests asserted behaviour the conformance tests had since contradicted, so they
were testing MobileGL's old answer rather than GL's.
QueryTest expected glIsQuery to report a name straight out of glGenQueries as a query
object. It is not one: GenQueries reserves names, and they "acquire query state only when
they are first used by calling BeginQuery" (GL 4.6 core 4.2.1). The test now checks that a
reserved name reads FALSE, that BeginQuery is what turns it into an object, and that a
sibling name left untouched stays FALSE. A companion case covers the direct state access
half, where glCreateQueries does create the object outright - which is the whole reason the
two entry points both exist.
The DirectGLES binding test built its texture with glGenTextures and glBindTexture and
nothing else, then expected BindCurrentTextures to bind it natively. A texture with no
image is incomplete and samples as (0, 0, 0, 1), which DirectGLES expresses by leaving the
native target unbound, so the setup no longer produced the binding the test then went on to
clear. It now gives the texture a format and a 1x1 level 0 - one level is the entire mip
chain at that size, so it is complete under any filter - and asserts that directly, so a
future completeness change fails on the setup line instead of on the assertion three calls
later.
glTexStorage2DMultisample and glTexStorage3DMultisample forwarded straight to the
glTexImage*Multisample allocation and stopped there. The allocation is indeed the same;
what the storage forms add is that it is final - TEXTURE_IMMUTABLE_FORMAT becomes TRUE and
any later call on that texture is INVALID_OPERATION (GL 4.6 core 8.19). MobileGL left the
texture mutable forever, so it reported TEXTURE_IMMUTABLE_FORMAT as FALSE and accepted
being respecified any number of times, silently discarding storage a test or an
application had already rendered into.
The by-name forms had no validation of their own either. The target forms get their target
checked when the binding is resolved; reached by name there is no binding, so
glTextureStorage2DMultisample took any texture, any extent and any sample count. It now
rejects a target that belongs to the other entry point (INVALID_OPERATION), extents
outside 1..GL_MAX_TEXTURE_SIZE and a depth past GL_MAX_ARRAY_TEXTURE_LAYERS
(INVALID_VALUE), and a sample count above GL_MAX_SAMPLES (INVALID_OPERATION) - measured
against the limit the getter reports rather than the backend parameter it is derived from,
since the frontend raises that number.
glTextureStorage1D/2D/3D gained the same treatment: a target belonging to a different one
of the three is INVALID_OPERATION, a zero extent is INVALID_VALUE (immutable storage
describes a real image, unlike glTexImage*D where an empty level is legal), and a level
count longer than the level-zero size admits is INVALID_OPERATION. Which dimensions take
part in that mip chain is per target: a 1D array keeps its layer count in height, so its
height does not halve.
Takes direct_state_access.textures_storage_multisample_2d_* from 0 to 30 of 30 on Espryt,
and the whole group from 74.93% to 82.48%. Magma still fails them for a separate reason.
glGetTextureParameter* resolve the texture by name and then hand the work to the
target-based getter, which validates the target it was given. For a buffer texture that
is GL_TEXTURE_BUFFER, and the target form correctly calls that an unaccepted token -
INVALID_ENUM.
By name there is no token to blame. The application named an object that carries none of
the sampler or level state the query reports, which is INVALID_OPERATION (GL 4.6 core
8.11). The four by-name getters check the resolved object before delegating, so the error
describes what the caller actually got wrong.
Fixes direct_state_access.textures_parameter_errors on both backends, taking the group to
74.93% on Espryt and 73.32% on Magma.
The Android and Windows paths each have a skill; the desktop Linux one had only
a runner script and a README section, so it was the least discoverable of the
three despite being the one to reach for while iterating - it needs no device
and no GPU, and a single test group takes seconds rather than hours.
Records what the other two skills cannot: that the toolchain has to be GCC 13+
or Clang 20+ (Clang 18 reports __cpp_concepts as 201907L, which switches
libstdc++'s <expected> off and breaks the shader transpiler), that
EGL_PLATFORM=surfaceless is mandatory for DirectGLES and why the symptom points
at the wrong call, and which of this environment's results are MobileGL's own
versus artefacts of software rendering.
Also states the rule the other skills only imply: report Espryt and Magma
separately. They fail different cases, and one combined number hides which
backend a change moved.
glTextureBuffer and glTextureBufferRange took any internal format the texture enum
converter recognised. A buffer texture accepts a much shorter list than a sampled or a
renderable texture does (GL 4.6 core table 8.16), and it cannot be inferred from either,
so a format like GL_RGB8 was accepted and produced a texture nothing could read.
Two error codes were wrong as well. A texture whose effective target is not
GL_TEXTURE_BUFFER is the wrong object rather than the wrong token, so it is
INVALID_OPERATION. And the range form never checked its range against the buffer it was
attaching, so a size past the end of the buffer was accepted and left the texture
addressing memory the buffer does not own.
Fixes direct_state_access.textures_buffer_errors and textures_buffer_range_errors on both
backends.
The DirectGLES capability probe queried GL_TEXTURE_BUFFER_OFFSET_ALIGNMENT with a bare
glGetIntegerv while every other query in the same function goes through glesFuncs. A bare
call resolves to MobileGL's own exported entry point, which answers that pname out of the
capability table this code is in the middle of filling in, so the value read back was the
default it started from and the driver's real alignment never arrived.
The backend therefore advertised an alignment of 1. An application that trusts that -
which is the only thing it can do - passes glTextureBufferRange an offset the ES driver
cannot honour, and the driver produces a texture that reads as zeros with no error
anywhere. The alignment llvmpipe actually wants is 16.
Takes direct_state_access.textures_buffer_* from 3 to 30 of 30 on DirectGLES, and the
whole DSA group from 66.85% to 74.12%. DirectVulkan was unaffected: its alignment comes
from a Vulkan device limit and was already right.
run_cts_local.py and the mobilegl-desktop VK-GL-CTS target were both in the tree
with nothing describing how to reach them, so the only documented ways to run the
suite needed either an Android device or a Windows box with a GPU. The desktop
Linux path needs neither: lavapipe gives DirectVulkan a headless surface and
Mesa's surfaceless EGL gives DirectGLES a context, so a single test group can be
measured in seconds while working on it.
Records the two things that cost time to find. EGL_PLATFORM=surfaceless is
mandatory for DirectGLES - without a /dev/dri node Mesa fails eglInitialize on
the default display, and MobileGL surfaces that as EGL_BAD_ALLOC from
eglCreatePbufferSurface, which points at the wrong call entirely. And
DirectVulkan's default-framebuffer readback returns zeros here exactly as it does
on Adreno, so that defect is MobileGL's and reproducible without a phone.
The direct_state_access reference table is the measured baseline for the fixes in
this branch, so a later change has something to be compared against.
GL_COLOR_ATTACHMENTn is a token for every n up to 31, but only the first
GL_MAX_COLOR_ATTACHMENTS of them name an attachment point of a framebuffer object. The
enum conversion accepted the whole token range, so attaching a renderbuffer or a texture
to a colour attachment past the limit silently succeeded instead of reporting
INVALID_OPERATION, and the attachment landed in a slot nothing else would ever look at.
glBindVertexBuffers and glVertexArrayVertexBuffers take a range of binding points rather
than one index. A range running past the last binding point is INVALID_OPERATION, which
the per-binding validation could not report: it saw one index at a time and reported the
INVALID_VALUE that a single out-of-range index earns. The range is checked up front now,
before any binding point is touched, so a rejected call also leaves none of them changed.
Takes direct_state_access.vertex_arrays_* to 18 of 19 and fixes
direct_state_access.framebuffers_renderbuffer_attachment_errors on both backends.
glCreateTransformFeedbacks, glTransformFeedbackBufferBase, glTransformFeedbackBufferRange
and the three glGetTransformFeedback* queries were all stubs, so a transform feedback
object could only be configured and inspected by binding it first - the exact thing
direct state access exists to avoid. The queries were the worse half: they returned
nothing and raised no error, so an application could not tell that it had learned
nothing.
glCreateTransformFeedbacks creates the objects outright. glGenTransformFeedbacks only
reserves names, and a reserved name becomes an object when it is first bound
(GL 4.6 core 13.2.1); the DSA form has no bind step to create them from.
The queries and the buffer bindings read and write a named object's state. That state
lives in two places: the context keeps one live copy of the capture bindings and the
active/paused flags for whichever object is bound, and every other object's copy sits in
its saved state until a bind swaps it in. The by-name accessors added to the context
resolve that, so a query for the bound object reads the live copy rather than a stale
save.
GL_TRANSFORM_FEEDBACK_BUFFER_START and _SIZE are answered as zero unless the binding was
made by the range form, matching what the buffer object binding points already do.
Takes direct_state_access.xfb_* from 0 to 4 of 5 on both backends; xfb_functional still
fails on the capture itself, which is a separate defect.
The binding-point half of ARB_vertex_attrib_binding was implemented, but nothing
outside it could see the result. glGetIntegerv answered GL_MAX_VERTEX_ATTRIB_BINDINGS,
GL_MAX_VERTEX_ATTRIB_RELATIVE_OFFSET and GL_MAX_VERTEX_ATTRIB_STRIDE with a hardcoded
0 and a comment saying the entry points were stubs, which they no longer are. An
application that sizes its loops off those limits therefore saw none, and every
"bindingindex must be less than MAX_VERTEX_ATTRIB_BINDINGS" check silently accepted
everything because the limit it validated against was not the one it reported.
The indexed getters answer GL_VERTEX_BINDING_{BUFFER,DIVISOR,OFFSET,STRIDE} from the
bound vertex array now, and the non-indexed getter reports them as indexed-only rather
than returning a fabricated 0.
glVertexAttribPointer is defined in terms of the binding model: it also points the
attribute at its own binding point and gives that point the buffer, the pointer as the
offset and the effective (never zero) stride. MobileGL resolved the pointer form
straight into the flat attribute view and left the binding point untouched, so
GL_VERTEX_BINDING_OFFSET read back 0 for every attribute set up the classic way. The
flat view keeps the raw stride, because GL_VERTEX_ATTRIB_ARRAY_STRIDE reports that
argument verbatim, so the binding point is recorded alongside it rather than resolved
from it. glVertexAttribDivisor likewise now moves the binding point's divisor.
The by-name entry points reject vertex array 0. MobileGL keeps a real object at index 0
for the compatibility paths, so the name validation used to let the default vertex array
through a direct-state-access call that has no such thing.
glVertexAttribFormat and friends validated with the pointer-only subset, which reports
GL_BGRA as an out-of-range size instead of applying the BGRA rules, and never saw
relativeoffset at all. They share the full format validation now, which also grew the
GL_UNSIGNED_INT_10F_11F_11F_REV rules - that type has no DataType of its own, so it has
to be recognised before the conversion turns it into Unknown and reports the wrong error.
glVertexAttribLFormat and glVertexArrayAttribLFormat were stubs. They validate their
arguments now and then report that 64-bit vertex attributes are unsupported, which is
honest; silently accepting a format that can never be used is not.
Takes direct_state_access.vertex_arrays_* from 12 to 17 of 19 on both backends.
glGetVertexArrayiv, glGetVertexArrayIndexediv and glGetVertexArrayIndexed64iv
were stubs, so nothing could read a vertex array's state without binding it
first -- the exact thing direct state access exists to avoid.
They read the state the vertex array already holds. Two accessors were needed for
that: the relative offset and the binding points, which are the binding-point
view the flat per-attribute state was resolved from and cannot be reconstructed
from the resolved form.
Note the index means different things by entry point: for the 32-bit indexed
query it is an attribute, but GL_VERTEX_BINDING_OFFSET names a vertex buffer
binding point directly (GL 4.6 core 10.3.1). GL_VERTEX_ATTRIB_ARRAY_LONG is
answered GL_FALSE throughout, which is honest while 64-bit vertex attributes are
unsupported.
Takes direct_state_access.vertex_arrays_* from 8 to 12 of 19 on Espryt.
GL_VERTEX_BINDING_OFFSET still reads back 0: the query is right but the offset is
not reaching the binding point, which is a separate defect further up.
glGenQueries only reserves names; a name becomes a query object when it is first
used with BeginQuery or QueryCounter (GL 4.6 core 4.2.1). MobileGL created the
live object eagerly at glGenQueries time and glIsQuery reported every reserved
name as an object, with a comment noting the shortcut.
The registry already distinguished the two states -- a target of 0 means the name
has never been used -- so glIsQuery now consults it, and a name that came from
glCreateQueries carries a flag saying it is an object regardless.
glCreateQueries itself was a stub. It creates the objects outright with their
target already fixed, which is the whole point of the DSA form: there is no
binding step to infer the target from later.
GetFallbackTexture asserted that the target was 2D or rectangle, so a sampler
whose texture could not be resolved took the process down whenever it was any
other kind. A multisample sampler reaches exactly that path: its texture is
reported incomplete, the resolve falls back, and the assert fires. Sixty
direct_state_access multisample cases died that way, and because the abort kills
the whole process the harness lost the rest of its chunk with them -- one run
needed 63 invocations to get through the suite instead of 3.
The fallback is a single-sampled 2D image, so it genuinely cannot stand in for a
multisample sampler: that descriptor demands a multisample view, and binding this
one is invalid usage rather than a degraded picture. So report that no fallback
exists and let the caller decline the draw. An unbound or incomplete sampler is
an application-level mistake with a defined GL meaning; it is never a reason to
abort.
The cases still fail -- multisample textures are not yet complete enough to
sample -- but they fail as one reported case each.
glTexBufferRange, glTextureBuffer and glTextureBufferRange were all stubs, so a
buffer texture could only ever be attached through glTexBuffer -- by binding, and
always to the whole buffer.
Give the buffer texture the window it is supposed to address. The non-range forms
record it as offset 0 with a whole-buffer sentinel rather than the size the buffer
happens to have, so a later respecify keeps being followed instead of freezing the
texture at yesterday's size. All four entry points now share one attach path,
differing only in how they name the texture: by binding for the target forms, by
name for the DSA ones.
Both backends honour the window: DirectVulkan offsets and clamps the buffer view,
DirectGLES uses glTexBufferRange when the texture names a sub-range and keeps
plain glTexBuffer for the whole-buffer case, which also works on a driver without
the range entry point.
GL_TEXTURE_BUFFER_OFFSET_ALIGNMENT reported 0 with a comment explaining that the
range entry points were stubbed. It now reports what the device actually requires
-- minTexelBufferOffsetAlignment on Vulkan, the driver's own value on GLES -- and
the range entry points enforce it. Zero was never a legal answer; the minimum is
1, and an application that trusted it would have built unaligned offsets.
It was exported as a stub: it logged a warning and returned, leaving the caller's
buffer untouched. Anything reading back through it saw whatever the destination
already held, which for a freshly allocated vector is zeros -- so every
direct_state_access texture test comparing a readback against reference data
failed without a GL error to explain it.
glReadnPixels is glReadPixels with a bound on how much it may write (GL 4.6 core
18.2.8, originally GL_ARB_robustness) and is identical in every other respect, so
it validates and reads through exactly the same path once the destination is
known to be big enough.
Sizing the read honours the GL_PACK_* state: rows are padded to GL_PACK_ALIGNMENT
and laid out GL_PACK_ROW_LENGTH wide, with the skip parameters offsetting the
first texel. The last row is deliberately not padded -- nothing follows it to
align -- which is what makes a tightly-sized destination legal.
The backend already turned a tessellation control/evaluation shader into the
right VkShaderStage, but nothing downstream knew what to do with it: GL_PATCHES
had no topology, so it fell through to the triangle-list default, and the
pipeline carried no tessellation state at all. A GL_PATCHES draw therefore ran
the vertex and fragment stages over raw triangles.
Map GL_PATCHES to VK_PRIMITIVE_TOPOLOGY_PATCH_LIST, carry GL_PATCH_VERTICES into
the pipeline as patchControlPoints (part of the key, since two patch sizes are
two pipelines), attach VkPipelineTessellationStateCreateInfo for a patch topology
only, and enable the tessellationShader device feature.
POST reports the feature, because without it a program with a tessellation stage
cannot build a pipeline at all and GL_PATCHES draws render nothing.
Vulkan restarts only on the fixed all-ones value of the index type, so
GL_PRIMITIVE_RESTART with a glPrimitiveRestartIndex of anything else used to
hard-fail the draw. GL_PRIMITIVE_RESTART_FIXED_INDEX already matches Vulkan and
is untouched.
Rewrite the indices into a transient copy instead, substituting the fixed value
for the application's. An index that already equals the fixed value would then be
indistinguishable from a restart, so it is nudged down by one: it can only be a
real index, since the application's restart index is a different number, and the
vertex it names is outside any well-defined draw -- whereas leaving it alone would
tear the primitive in two.
The element array buffer is rewritten whole rather than only the drawn range,
because an indirect draw's firstIndex lives in GPU memory and cannot be adjusted
from here; every element therefore keeps its position.
A capture is a GPU write like any shader's, so a later CPU read of the buffer has
to wait for it. Only shader storage buffers were flagged, so mapping or reading
back a capture buffer could observe whatever the queue had retired so far.
Nothing needs copying -- the capture writes land in coherent host-visible storage
already -- but coherence only says the writes are visible once they have
happened, which is exactly what MarkGpuWritten arranges through the readback op.
AcquirePersistentMap promises the storage it creates is never recreated, because
the frontend adopts it in place of the shadow and hands out pointers into it.
AcquireStreamedSlice broke that promise: its downgrade path releases the resident
storage unconditionally to avoid keeping a second stale copy, so binding such a
buffer as a vertex or index source freed the memory the application was still
pointing at.
It also fed that draw the wrong bytes. The streaming copy is uploaded from the
shadow, and a persistently mapped buffer can hold bytes the shadow never saw -- a
transform feedback capture writes straight into the resident storage. The next
capture into the same buffer then landed in freshly recreated storage while the
application kept reading the original, which is how the ping-pong in
transform_feedback.draw_xfb_feedbackk_test stalled after its first doubling.
Route a persistently mapped resource to the resident path instead, where its
single piece of storage is bound directly.
GL makes transform feedback results visible to every later command on their own,
with no glMemoryBarrier in between -- unlike shader storage writes. An
application replaying a capture with glDrawTransformFeedback is therefore
entitled to the captured bytes without asking for them, so the barrier the Vulkan
memory model requires has to come from here.
It cannot be recorded where the write happens: the capturing draw runs inside a
render pass that declares no self-dependency. Flag it there instead and emit the
barrier at the next point that could read the buffer -- the following draw's
setup, or a readback -- ending the render pass first, the same shape
glMemoryBarrier already uses.
The destination covers every way a captured buffer comes back: replayed as vertex
attributes or indices, read through a uniform or storage binding, sourced as an
indirect command, copied out, or mapped.
The program cache is content-hash-shared across GL program names, so its key has
to cover everything that changes the modules it stores. The capture layout did
not: XfbCaptureDecoratePass bakes XfbBuffer/XfbStride/Offset into the SPIR-V from
the frontend's layout, none of which is in the SPIR-V being hashed.
Two programs with identical shaders and different glTransformFeedbackVaryings
therefore shared one entry, and the first one linked decided how both captured.
That is precisely what changing the buffer mode does -- the same varyings
recorded with GL_SEPARATE_ATTRIBS instead of GL_INTERLEAVED_ATTRIBS -- so the
separate-attribs pass of transform_feedback.draw_xfb_test replayed a capture that
was still interleaved into buffer 0.
Hash the captured varyings' names, buffer indices and offsets plus the per-buffer
strides, and only for a capturing compile, so no other program changes key.
The GL_UNIFORM interface queries and glGetActiveUniform(s)iv describe the same
set of resources in two spellings, but they were reading it from two different
places: the latter from the frontend reflection, the former forwarded straight
to the backend program.
The backend program is not a source of truth for this. It does not exist at all
for a program whose types its shading language cannot express -- a
double-precision uniform has no ESSL form, so the program never links there --
and the interface queries then described a program with no uniforms, which is
how gpu_shader_fp64.fp64.state_query failed.
Route GL_ACTIVE_RESOURCES / GL_MAX_NAME_LENGTH, the resource index, the resource
name and the resource properties for GL_UNIFORM through the same reflection that
already answers glGetActiveUniformsiv, so the two spellings can no longer
disagree and neither depends on the backend having linked. The props that
reflection does not model (GL_ATOMIC_COUNTER_BUFFER_INDEX and the
GL_REFERENCED_BY_* stage bits) still come from the backend, looked up by the
uniform's name so the two index spaces do not have to agree.
GL_MAX_NAME_LENGTH counts the terminator; the stored maximum does not, as every
other caller of GetUniformMaxLength() already accounted for.
Vulkan's VK_VERTEX_INPUT_RATE_INSTANCE advances an attribute once per instance and has
no way to say anything else, so every non-zero divisor collapsed to 1: an attribute the
application asked to change every three instances changed every one, and
KHR-GL40.draw_indirect.basic-drawArrays-instancing and its elements sibling drew the
wrong colours from instance one onward.
VK_EXT_vertex_attribute_divisor is exactly this state, so it is enabled when the device
has it and the per-binding divisors ride into the pipeline through
VkPipelineVertexInputDivisorStateCreateInfoEXT. Only divisors other than 1 are listed -
1 is what the plain input rate already means - and they join the layout hash, so two
layouts that differ only in a divisor no longer share a pipeline.
POST reports the feature either way, because without it the failure is silent and looks
like a shader bug: the attribute is fetched, just from the wrong instance. The GLES side
gains the two checks this session's other work made load-bearing for the same reason -
glPatchParameteri (without it GL_PATCH_VERTICES stays at the driver's 3 and a patch draw
of any other size renders nothing) and the transform feedback object entry points
(without them a second object cannot open a capture while the first is paused).
KHR-GL40.draw_indirect on Magma: 70/70 but for the arbitrary primitive-restart index,
which Vulkan cannot express at all.
Texture uploads go out on a command buffer of their own the moment they happen, while
glGenerateMipmap records its blit chain into the frame's command buffer, which is not
submitted until the frame ends. So a glTexSubImage2D into a level that was just
generated reached the GPU FIRST and the blits then wrote over it.
KHR-GL40.texture_gather.base-level does exactly that - generates the chain, then writes
the texels it is going to sample into level 1 and points TEXTURE_BASE_LEVEL at it - and
read back the generated content instead of what it had written. The image view, the mip
range and the upload itself were all correct; only their order on the GPU was not.
This is the same hazard the mip-chain-growth recreate above already flushes for, from
the other side: there the recorded work had to reach the GPU before an out-of-band copy
read the image, here before an out-of-band copy writes it. Submitting at the end of the
generation orders every upload that can follow.
The extension and its three entry points are frontend state - no binary format is
exposed on either backend - but only DirectGLES listed it, so on Magma dEQP's loader
still left glProgramParameteri null and KHR-GL40.api.coverage called straight through
the null pointer. The entry point is not core before GL 4.1; this is what exposes it.
Neither target API has GL_TEXTURE_RECTANGLE: ESSL has no rectangle sampler, and
Vulkan's SPIR-V environment does not allow Dim::Rect. Both emulate it on a plain 2D
texture, and the two differ in exactly one way - a rectangle lookup addresses texels
where a 2D one addresses [0,1].
That one difference now lives in one SPIR-V pass, so neither backend has to know about
it: every lookup taking normalized coordinates gets its coordinate divided by the size
the texture reports, and the image type is then rewritten to 2D. Magma had no rectangle
handling at all - it fed Dim::Rect straight to Vulkan, which read the texel coordinates
as normalized and sampled the edge, so all fifteen KHR-GL40.texture_gather.*-2drect
cases came back holding the clear colour.
This replaces the ESSL text rewrite that did the same divide for DirectGLES only. Doing
it in the module instead is both shorter and stricter: the pass resolves an operation's
image type through the sampled-image and pointer wrappers rather than matching a
sampler name in generated source, so it cannot be fooled by an expression where it
expected an identifier, and it needs no help from the frontend reflection to know which
samplers were rectangles.
Still declined, as before: the Dref *sample* forms, whose coordinate carries the compare
value in its last component, and the projective ones, where the divide would have to
happen after the perspective divide. texelFetch is deliberately untouched - integer
texel coordinates mean the same thing on both targets.
KHR-GL40.texture_gather: Magma 66 failures -> 2, Espryt stays at 75/75.
The completeness rule itself is GL's, not a backend's, so it now reads as one question
both backends ask - SamplesAsIncompleteTexture(texture, effective sampler) - and each
answers in whatever way it already expresses "nothing is bound at this sampler".
DirectGLES leaves the native target unbound; Magma has a fallback texture for exactly
that case and now routes an incomplete texture to it.
The fallback's texel had never been written, so it read whatever its freshly allocated
storage held. GL is specific here: an incomplete texture - and a sampler with nothing
bound - reads (0, 0, 0, 1). It says so now, which is what makes
KHR-GL40.texture_gather.incomplete-texture-last-comp (it gathers the alpha) meaningful
rather than accidentally right.
Reading a buffer a compute shader wrote gave zeros: the frontend shadow that MapBuffer
resolves against is only maintained by uploads, and Magma had no path back. Every
KHR-GL40.texture_gather case ends by dispatching a compute shader into an SSBO and
comparing the mapped result, so 66 of 75 failed on it.
Magma needs no readback: EnsureGpuResidentStorage - the same host-visible coherent
adoption the transform feedback capture already uses - makes the shadow BE the memory
the shader writes, so binding a buffer as a shader storage buffer now adopts it. What
coherence does not give is ordering: the writes are visible once they have happened,
and the CPU was reading before the dispatch had retired. The readback op therefore
submits the recorded work and waits.
That exposed a mistake in the frontend flag this rides on: MarkGpuWritten skipped
GPU-resident buffers, reasoning there was no shadow to refresh. True, but the wait is
still needed - "reconcile with the GPU write" is not always "copy it back", and which
of the two it is belongs to the backend. The flag now only says a write is outstanding;
DirectGLES's readback still skips its persistent-mapped buffers when copying.
KHR-GL40.texture_gather on Magma: 66 failures -> 19 (the rest are rectangle textures,
mipmap completeness and tessellation, all still to do). Espryt stays at 75/75.
The indirect draw paths bounded their read out of GL_DRAW_INDIRECT_BUFFER - and took
their default stride - from `sizeof(DrawCmdParam)`, this renderer's own draw-parameter
struct. That is not the command GL defines: DrawCmdParam carries two extra members for
bounding vertex-stream conversion and is 24 bytes, where GL's DrawArraysIndirectCommand
is four uint32.
So every glDrawArraysIndirect against a tightly-sized indirect buffer - which is what an
application writes, and what the CTS writes - failed the range check and drew nothing.
It went unnoticed on the elements side only by coincidence: DrawIndexedCmdParam happens
to be exactly the 20 bytes of DrawElementsIndirectCommand.
Both sizes are now named constants of GL's own layout.
KHR-GL40.draw_indirect on Magma: 21 failures -> 3.
The frontend half of ARB_transform_feedback2 landed for both backends, but Magma's
capture was still written for the one implicit span GL 3.3 has:
- A paused span kept capturing. VK_EXT_transform_feedback's counter buffers already
make consecutive draws append, so pausing is simply "do not wrap this draw" - the
counters keep their values and the next resumed draw carries on where the last
captured one stopped.
- Those counter buffers were context-wide. Transform feedback objects can each hold an
open, paused span at the same time - KHR-GL40.transform_feedback.draw_xfb_test keeps
three - and they were all appending through one set of four slots. Each object now
gets its own group, handed out on first use; past sixteen objects they share group 0,
which only matters for concurrently-paused spans.
- The generation that identifies a span is what a backend keys its append state on, so
it is now part of the per-object state the frontend saves and restores. Without that,
resuming an object that was paused before another one began looked like a new span
and restarted its counters at zero.
GL_PRIMITIVES_GENERATED needed one more thing. It counts what the last vertex
processing stage emitted whether or not anything is being captured, but
VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT only counts what the capture saw - so a
draw made while the span was paused is invisible to it. The frontend now tallies those
draws, and the Vulkan query adds the delta at result time. The correction lives in the
backend that needs it: an ES driver's GL_PRIMITIVES_GENERATED counts them by itself, and
adding it there too would double them.
transform_feedback* on Magma: 4 failures -> 3. Espryt stays at 38/38.
glUniform*d, glUniformMatrix*dv, their glProgramUniform twins and glGetUniformdv were
all stubs - 35 entry points - so a GL 4.0 program's double uniforms could be declared
and located but never set or read. Worse, glGetUniformfv on one did reach the storage:
the generic getter memcpy'd the uniform's declared size into the caller's buffer, so a
4-byte float pointer received 8 bytes. That overrun is what took the process down in
KHR-GL40.gpu_shader_fp64.fp64.state_query.
The upload path is already templated on the component type, so the vector forms are
wiring. A matrix is not: the column stride the linker used for a double matrix is not
the 16 bytes a float one gets. It is not guessed - the slot the uniform was given is
exactly `columns` columns wide, so dividing states the stride the rest of the pipeline
already agreed on, for both the upload and the readback.
The four getters now convert instead of reinterpreting when the uniform holds doubles,
following GL 4.6 core 7.6: round to nearest for the integer queries, and clamp into the
queried type's range so a negative double read through glGetUniformuiv is 0 rather than
its two's complement.
The case still fails one step further on, where it queries the same uniforms through
GL_ARB_program_interface_query: those calls are answered by the backend program, and an
fp64 shader has none - ESSL has no doubles, so it never links. Answering them from the
frontend reflection is a separate change.
GL_PATCH_VERTICES decides how many vertices one tessellation patch consumes, and
glPatchParameteri was a stub - so the value stayed at the driver's default of 3 no
matter what the application asked for. KHR-GL40.texture_gather.gather-tesselation-shader
sets it to 1 and then draws a single patch: with the request dropped the draw had too
few vertices for one patch, produced nothing at all, and the case read back the clear
colour.
The value is context state on both sides and ES 3.2 spells the entry point exactly the
same way, so it is stored in the render state (where glGetIntegerv(GL_PATCH_VERTICES)
now finds it) and forwarded. Validation needs the real bound, so GL_MAX_PATCH_VERTICES
and GL_MAX_TESS_GEN_LEVEL are probed off the host driver alongside the other limits and
answered from there too; the defaults are the GL 4.0 core minimums.
KHR-GL40.texture_gather is now 75/75.
glGenerateMipmap requires the level-0 format to be colour-renderable, and ES has no
colour-renderable three-channel float format at all - so an ES driver rejects
GL_RGB16F and GL_RGB32F where every desktop driver accepts them, and the error was
forwarded to the application. KHR-GL40.texture_gather.plain-gather-float-2d-rgb and
its offset- sibling build their texture that way and fail on the leftover error alone.
The blit-based emulation already used for GL_R11F_G11F_B10F is no help: it renders
level n from level n-1, so it needs exactly the renderability that is missing. But a
format the driver cannot render into is a format nothing can have rendered into
either, which makes the frontend's own copy of the texels authoritative for precisely
these formats. So the chain is box-filtered there and the levels are marked dirty; the
backend sync that follows uploads them like any other texture data.
Deliberately narrow: only the two formats whose texels are a plain float array, and
only when they are what the texture actually holds. Every other format keeps the
driver's behaviour, error included.
A minification filter that reads the mip chain requires every level from the base down
to hold exactly half the previous one's size; a texture that does not is incomplete and
every lookup on it returns (0, 0, 0, 1) (GL 4.6 core 8.17). Nothing checked it.
The ES driver cannot catch this on MobileGL's behalf, which is why it has to be a
frontend rule here: the backend texture is immutable storage allocated from the level
set as it stood, so a level the application later redefined at a different size never
reaches the driver at all, and the ES texture stays complete. That is exactly what
KHR-GL40.texture_gather.incomplete-texture does - it redefines level 1 of a complete
chain as 1x1 - and it read the original contents back.
The check runs where the sampling bindings are established, and an incomplete texture
simply leaves its native target unbound: an unbound ES target samples as (0, 0, 0, 1),
which is the answer GL asks for, with no scratch texture to keep around.
An array texture's layer count is not one of the dimensions that halves, so the
comparison only shrinks the components that belong to the image itself - getting that
wrong turned eight *-2darray cases black.
A rectangle texture is emulated on an ES 2D texture, and LowerRectImagesForEssl
rewrites the image type in the SPIR-V to match. That is exact only where the lookup
addresses texels directly, which is why the pass declined any module containing a
lookup that takes normalized coordinates - the whole KHR-GL40.texture_gather 2drect
set among them.
The missing half is one divide: a rectangle lookup's coordinate is in texels and the
2D lookup it becomes wants [0,1], so the coordinate has to be divided by the texture's
size. It goes in on the ESSL the transpiler produces, next to the LOD-bias emulation
that already rewrites lookup arguments there, and reads the size back with
textureSize() rather than plumbing a uniform down - the emulated texture is a real ES
2D texture, so the shader can ask it directly.
Only the forms whose argument 1 is the bare coordinate are rewritten - texture,
textureOffset and the three textureGather flavours, which covers the Dref gathers too
because those carry the compare value in a separate argument. texelFetch is
deliberately left alone: its coordinates are integer texels on both targets. The
SPIR-V pass keeps declining everything else, so a projective lookup or a Dref sample
(where the compare value rides in coord.z) still refuses the module instead of
producing something subtly wrong.
Which samplers were declared rectangle is no longer visible in the transpiled source -
they are plain sampler2D by then - so the names come from the frontend program's
reflection.
Every texture object started from the shared defaults, which are the 2D ones:
TEXTURE_MIN_FILTER of NEAREST_MIPMAP_LINEAR and TEXTURE_WRAP_S/T of REPEAT. A
rectangle texture has no mip chain at all, so GL gives it a different initial state -
LINEAR and CLAMP_TO_EDGE (GL 4.6 core table 23.15) - and a mipmapped minification
filter is not even a legal value to set on one.
With the 2D default in place a rectangle texture was mipmap-incomplete the moment it
was created, and an application that (correctly) never touches the filters read
(0, 0, 0, 1) out of every lookup. That is what the eleven
KHR-GL40.texture_gather.*-2drect cases saw: they set only the wrap modes, because the
filters are already what a rectangle texture needs.
glProgramParameteri is not core before GL 4.1, so in the 4.0 context the CTS runs it
only exists through GL_ARB_get_program_binary or GL_ARB_separate_shader_objects.
MobileGL advertised neither, so dEQP's loader left the entry point null - and
KHR-GL40.api.coverage, which registers glProgramParameteri from GL 3.2 upwards, called
straight through the null pointer and took the process down.
GL_NUM_PROGRAM_BINARY_FORMATS was already 0, and the extension explicitly allows an
implementation to support no binary format at all; that is the honest state of things
here, since a MobileGL program is a glslang link plus a per-backend translation with no
serialised form. So the extension is advertised for what it really provides:
glProgramParameteri stores GL_PROGRAM_BINARY_RETRIEVABLE_HINT (reported back by
glGetProgramiv alongside a GL_PROGRAM_BINARY_LENGTH of zero), glGetProgramBinary is the
INVALID_OPERATION the spec requires when that length is zero, and glProgramBinary
rejects every format with INVALID_ENUM and leaves the program's LINK_STATUS false.
Applications that ask for a binary get the documented "no formats" answer and fall
back, which is what they already had to do - only now they can ask.
glIsTransformFeedback answered GL_TRUE for any name glGenTransformFeedbacks had handed
out. A generated name is reserved but does not denote an object until the first
glBindTransformFeedback (GL 4.6 core 13.2.1) - the same rule the other object types
follow - and KHR-GL40.api.coverage checks exactly the window in between.
The two questions are now asked separately: whether a name may be bound or deleted
(reserved, which is what the delete and bind paths need) and whether it is an object
(reserved and bound at least once).
GL 4.0 folds ARB_transform_feedback2 and _3 into core, and neither existed:
glGenTransformFeedbacks, glBindTransformFeedback, glDeleteTransformFeedbacks,
glIsTransformFeedback, glPause/ResumeTransformFeedback, the whole
glDrawTransformFeedback family and glBegin/EndQueryIndexed were all stubs, and
gl_NextBuffer / gl_SkipComponents1..4 failed the link as "not an output of the vertex
stage". Seven KHR-GL40.transform_feedback* cases failed on it, three of them by
leaving a capture open at deinit and taking the process down.
Objects. The capture state and the indexed GL_TRANSFORM_FEEDBACK_BUFFER bindings are
object state, but the context keeps one live copy of both, which is what every
existing reader - each backend's per-draw sync, the drawing and getter paths - is
written against. Rather than teach all of them about objects, a bind saves the live
copy into the outgoing object and restores the incoming one's. Object 0 is the
default object and needs no seeding; operator[] materialises the rest on first touch.
Pause. A paused span captures nothing, and three rules key off that: a draw is exempt
from the capture primitive-mode match, it feeds PRIMITIVES_GENERATED but not
TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN, and glUseProgram is allowed again (that last
one was already refused for an active capture, correctly for GL 3.3, which has no
pause).
glDrawTransformFeedback replays the vertices the object captured in its last completed
span, recorded at End. "Has a completed span" is tracked separately from that count,
because a completed empty span draws nothing while an object that never ended one is
INVALID_OPERATION. Drawing from the object whose capture is currently open is
deliberately allowed - feeding a result straight into the next span is the point of
KHR-GL40.transform_feedback.draw_xfb_feedbackk_test.
DirectGLES gets a real driver object per frontend object. That is the only reason the
default one would not do: several objects can be paused at once, and a paused span
lives inside the driver's object. The deferred driver-side Begin (still needed - ES
wants the program current and the buffers bound) now also has to be held back while
the span is paused, or a pause taken before the first draw would open the span on that
draw and subject it to the primitive-mode rule it is exempt from.
Special names. gl_NextBuffer and gl_SkipComponents<n> are consumed during varying
resolution and never become varyings of their own, so they only move where the
following ones land - and stay out of the name list the backend declares on its own
driver. ES cannot express the resulting layout at all: it packs every captured varying
into one gap-free record. So when the layout has holes or spans several buffers,
DirectGLES captures into a scratch buffer bound in place of the application's, and
End distributes the records to the offsets GL asked for. Only the bytes a varying
occupies are written, which is exactly what makes the holes keep the contents the
application left there - the property KHR-GL40.transform_feedback3.skip_components
checks.
glBegin/EndQueryIndexed and glGetQueryIndexediv differ from the plain forms only in the
vertex stream they address, so they validate the index and forward. GL_MAX_VERTEX_STREAMS
stays at 1: multi-stream capture needs ARB_gpu_shader5 stream qualifiers that no ES
driver implements, and the CTS cases that need more than one stream check the limit and
skip.
KHR-GL40.transform_feedback, transform_feedback2 and transform_feedback3: 38/38.
A geometry shader declares the primitive type it consumes, and a draw may only present
a mode that decomposes into it - points for `points`, the three triangle modes for
`triangles`, and so on (GL 4.6 core 11.3.1). Anything else is GL_INVALID_OPERATION.
Nothing checked it, so KHR-GL40.draw_indirect.negative-gshIncompatible-arrays and
-elements drew points through a `layout(triangles) in` shader and got no error.
The program object had no notion of the geometry input primitive at all: glslang knows
it right after the link, so it is read off the geometry intermediate and kept as the
GL enum (this is also what GL_GEOMETRY_INPUT_TYPE would report). Resolved on every
link rather than only when transform feedback captures the stage, since every draw
consults it, and cleared with the rest of the link artifacts.
The check sits on the shared pre-draw gate next to the transform feedback primitive
rule, which is the same shape of constraint. GL_PATCHES is deliberately exempt: it is
the tessellation pipeline's input and has already become the tessellator's output
primitive by the time the geometry stage sees it.
draw_indirect is now at 70/70.
Two classes of draw-time error were never raised, which the KHR-GL40.draw_indirect
negative-* cases check one by one:
- `mode` was passed through unexamined, so glDrawArraysIndirect(GL_FLOAT, ...) reached
the backend instead of raising GL_INVALID_ENUM. The check belongs on the shared
pre-draw gate, so it now covers every draw entry point rather than just the indirect
pair. Nothing that used to render stops rendering: a mode the frontend now rejects is
a mode the backend driver was rejecting anyway, silently.
- The indirect commands read their arguments out of the buffer bound to
GL_DRAW_INDIRECT_BUFFER, and all three of that source's preconditions were unchecked
(GL 4.6 core 10.3.10): a 4-byte-aligned offset, a bound buffer at all, and enough room
left in it for the whole 16- or 20-byte command. glDrawElementsIndirect also never
validated its index type, which is the same accepted set as the rest of the
DrawElements family.
Takes the group from 24 failures to 2 - both of the remaining ones are the geometry
shader input-primitive compatibility rule, which needs reflection the program object
does not keep yet.
Three pieces of queryable buffer state were missing, all of them read by the
KHR-GL40.draw_indirect basic-binding-* and basic-buffer-* cases:
- GL_DRAW_INDIRECT_BUFFER_BINDING had no case in glGetIntegerv, so it raised
GL_INVALID_ENUM and left the caller's variable untouched (the test read back its own
-9999 sentinel). GL_DISPATCH_INDIRECT_BUFFER_BINDING right next to it was already
handled; this is the same two lines against BufferTarget::DrawIndirect. Because
glGetBooleanv/glGetFloatv/glGetDoublev all widen from the integer path, one case
fixes all four getters.
- GL_BUFFER_ACCESS answered 0 for an unmapped buffer. Its initial value is
GL_READ_WRITE and glUnmapBuffer restores it (GL 4.6 core table 6.2); 0 is not a legal
value of that state at all, and the test threw on the unrecognised enum.
- GL_BUFFER_ACCESS_FLAGS was not implemented, so it fell through to the invalid-pname
arm. It is the MapBufferRange bitfield verbatim, which the mapping access flags
already hold in normalised form - glMapBuffer's access enum is converted on the way
in - so it converts straight back out, and reads zero while unmapped.
glDeleteProgram and glDeleteShader are the two entry points in the program/shader name
space where 0 is not "a name GL never handed out" but an explicit no-op: "if program is
zero, it is silently ignored" (GL 4.6 core 7.3, and 7.1 for shaders). Both went through
the shared name validator instead and recorded GL_INVALID_VALUE.
Only tests that never got as far as creating a program noticed, because they still run
their cleanup path: the five KHR-GL40.texture_gather.*-cube-array cases bail out of
Init with "GL_ARB_texture_cube_map_array not supported", then Cleanup deletes its
zero-initialised handles and the leftover error fails the case after the fact - the
downstream-error-misattribution shape. Every array-taking delete already skipped 0.
Buffer contents live in a CPU shadow that every read - MapBuffer, MapBufferRange,
GetBufferSubData, CopyBufferSubData - resolves against, and backend transfer ops only
ever push the shadow outwards. Two paths already knew the GPU can write a buffer on
its own and mirrored the result back by hand (ReadPixels into a pixel-pack buffer,
the transform feedback capture at EndTransformFeedback); a shader storage buffer
written by a draw or a dispatch had no such path at all, so the map handed the
application the bytes from before the dispatch.
Nothing exercised it until now because GL 3.3 has no compute stage. Every
KHR-GL40.texture_gather case ends by dispatching a compute shader that writes its
sampled texel into an SSBO and comparing the mapped result, and all 71 read back the
zero-filled shadow.
Adds the missing direction as a backend op: BufferObject::MarkGpuWritten flags a
buffer the GPU may have moved ahead of the shadow, SyncGpuWrites pulls it back at
every read point, and DirectGLES implements the readback with a plain read map of the
ES buffer. The flag is raised where the storage-buffer points are bound for the
upcoming draw or dispatch, which is the last moment the set of exposed buffers is
known, and cleared by the readback - so a buffer nothing writes costs one bool test
per map. Backends that cannot read their storage back leave the op null and keep
today's behaviour; a GPU-resident (coherent persistent) buffer needs nothing, since
its reads already resolve against the memory the shader wrote.
Drops the texture_gather failures from 71/75 to 25/75 with no crashes left.
BindBufferBase and BindBufferRange bind the buffer to the indexed point AND to the
generic binding point of the same target (GL 4.6 core 6.1.1); only the indexed half
was implemented. Applications lean on the second half constantly, because it is what
makes the set-up idiom work:
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, ssbo);
glBufferData(GL_SHADER_STORAGE_BUFFER, size, nullptr, GL_DYNAMIC_DRAW);
With the generic point left at 0 the glBufferData raised GL_INVALID_OPERATION and
the buffer kept its zero size, so the later glMapBufferRange over it failed the
offset+length bound and returned nullptr. The whole KHR-GL40.texture_gather group
verifies its result through exactly that sequence and dereferences the map's return
value without checking it, so 51 of its 75 cases took the process down with a
SIGSEGV inside the test.
Unbinding propagates the same way: buffer 0 clears both points.
glGetIntegerv(GL_MIN_PROGRAM_TEXTURE_GATHER_OFFSET) and its GL_MAX_ counterpart fell
through to the default arm of the getter and raised GL_INVALID_ENUM, leaving the
caller's variable untouched - KHR-GL40.texture_gather.api-enums read back the
uninitialised 32764 that happened to be on its stack and failed on the error alone.
Both are core state from GL 4.0 (table 23.53) and from ES 3.1 (table 20.40), so the
value is simply the host driver's, probed alongside the other limits in
FillInGLESCapabilities and carried to the getter through DynamicBackendParameters.
The probe result is widened to the -8/+7 core minimums rather than trusted blindly:
a driver that leaves the out-parameter alone (no ES 3.1, or an enum it ignores) would
otherwise hand us a range narrower than GL 4.0 requires MobileGL to advertise, and
the shaders the CTS builds assume the guaranteed range regardless.
Sizing backings by their defined mip level count gave every level-0-only texture a
single-level image, and left growing it to the recreate-and-preserve path: the new
image is created and the old contents are carried over by a vkCmdCopyImage that
PreserveTextureContentsOnRecreate submits on its own command buffer and waits on
straight away.
Whatever the frame has already recorded into the old image has not been submitted
yet at that point, so that copy reads the texture as it stood before this frame's
writes. GenerateMipmap then descends the whole chain from a stale level 0, and the
composite pass that samples it renders a washed-out frame -
minecraft-1.21.4-fabric-iris-iterationt-in-world (Iris's mipmapped colour target,
the one texture in the trace that grows 1 -> 10 levels) came back at ssim 0.5699
against a 0.99 threshold.
This is the hazard the storage-usage upgrade already flushes for before its own
preserve-copy; growing the mip chain is simply the second trigger of that same
recreate, and it was added without the same ordering guarantee. Flush there too,
gated on a texture whose live image really does carry a short chain, so the submit
happens once per texture and only when a recreate is actually coming.
Keeps the single-level backing and its memory saving; ssim goes back to 0.9992.
ctest -L unit had been failing 13 of its 418 cases, all of them tests left asserting
what the code did before a commit that changed it on purpose:
- "restore target GL version to 3.3" put the advertised target back after the
experimental 4.6 run, but the two Voxy sanity tests still demanded 4.6. The
extensions they really care about are all still advertised, so assert 3.3 and drop
the now-meaningless AtExperimentalCTSVersion from their names.
- "support rectangle textures where the emulation is exact" made every desktop-only
target supported - rectangle included, stored as a plain 2D - while the texture
test still expected rectangle to be rejected.
- "keep declared modern GLSL versions strict" changed two things at once: a
normalized legacy directive now carries a marker on its line, so the ten tests
matching "#version 330 core\n" whole no longer match; and a version the
application declared itself is no longer raised to 460, so the sources declaring
330/400 keep their own number and only MobileGL's own normalization is retargeted.
Test expectations follow, rather than the implementation being bent back: each of
the three changes is the intended behaviour and is argued for where it was made. The
retry test now drives the 460 escalation from a legacy "#version 130" source, which
is the only thing that is still rescued, and gained a case pinning the other half of
that contract - an application-declared "#version 330" stays at 330.
418/418 unit tests pass.
GL 3.3 core 4.4.2: deleting a texture whose image is attached to the framebuffer
currently bound acts as if FramebufferTexture* had been called with texture zero for
every attachment point it occupied there. Framebuffers that are not bound keep the
orphaned attachment, so only the bound ones are touched.
MobileGL unbound a deleted texture from every texture unit and image binding but
left framebuffer attachments alone, so the framebuffer went on holding the dead
texture alive as its attachment and reads through it returned that texture's
contents rather than those of whatever the application put in its place - and since
the deleted name usually comes straight back out of the next glGenTextures, the two
are indistinguishable from the outside.
BindCurrentFBO returned early when the framebuffer binding slot's version matched
g_fboBindVersions - but nothing on that path ever writes that entry. Only
ForceBindCurrentFBO stamps it, so the comparison was against an arbitrarily old
snapshot, and any later slot version that happened to land on the same 16-bit value
read as "already bound". The driver was then left on whatever framebuffer it had
last been given.
That is how KHR-GL32.packed_pixels.varied_rectangle.rg8i_format_rg_integer read its
gradient back out of the previous subtest's framebuffer, seeing 18 where 127 was
expected. It only shows up after a few thousand cases have gone by - long enough for
the counter to come back around - which is why it reproduced exactly under one
caselist and not at all in isolation.
Drop the fast path. Skipping redundant work is BindFramebufferId's job: it shadows
the driver's own draw and read bindings and drops the glBindFramebuffer when the
target already holds that id, which is where the cost actually is. What is left here
is one registry lookup.
Takes GL32 to 100% conformance; GL30, GL31 and GL33 stay at 100%.
Reading the stencil half of a packed depth/stencil texture goes through
GL_DEPTH_STENCIL_TEXTURE_MODE, which is ES 3.1 state. On an older driver the pname
would raise GL_INVALID_ENUM and the shader would go on sampling depth bits as if
they were stencil, so decline the emulation instead.
The framebuffer-completeness check scanned every row of the backend's
format-capability cache and called the format renderable if any target said so. That
was already loose, and it broke outright once DirectGLES started widening
three-channel formats so they stay renderable as multisample storage: the caveat
capability recorded for the multisample target made GL_RGB8_SNORM look renderable
everywhere, so an ordinary 2D GL_RGB8_SNORM texture attachment reported
GL_FRAMEBUFFER_COMPLETE while the driver's own framebuffer was
INCOMPLETE_ATTACHMENT.
KHR-GL3x.packed_pixels stopped skipping those formats and read a framebuffer that
could not be read, so all 18 of its rgb8_snorm cases got back an untouched buffer.
Pass the row the attachment actually lives in - the texture's target, or the
renderbuffer row - and consult only that one; a format is still asked about in
general when the caller has no target.
Desktop GL replicates the source sample into every destination sample when the read
framebuffer is single-sampled and the draw framebuffer is not. ES forbids the call
outright - "an INVALID_OPERATION error is generated if SAMPLE_BUFFERS for the draw
framebuffer is greater than zero" - so the blit did nothing at all, and every one of
KHR-GL3x.packed_depth_stencil.blit's replicate iterations verified a destination
that still held its clear values.
Emulate it by drawing a full-screen triangle into the multisample framebuffer: every
pixel is fully covered, so every sample of it receives the same value, which is
precisely the replicate rule. The source rectangle is first copied into a scratch
texture of its own format (both sides single-sampled, which ES does allow), then
depth is written through gl_FragDepth and stencil - which has no shader output on ES
- one bit plane at a time with REPLACE and a discard for the pixels whose source bit
is clear.
The draw runs inside the caller's framebuffer, so every piece of pipeline state it
touches is read back and restored, including the per-draw-buffer colour masks the
non-indexed glColorMask does not cover: the sync layer's shadow of the driver state
has to stay true across this.
Colour replicate is not emulated (it would need a sampler variant per component
type); it now says so instead of failing silently.
A three-channel format widened to four for a multisample target gains an alpha
channel the application never asked for, and it holds whatever the draw that filled
the texture happened to write there. GL says a format without alpha reads back as
1.0, so KHR-GL33.texture_swizzle - which fills such a texture by rendering
vec4(r, g, b, 0.0) and then swizzles red from alpha - read 0 where it expected the
maximum.
Fold ONE into the texture's swizzle for exactly those textures, composed with the
swizzle the application set, so the promotion stays invisible.
GL_RGB16_SNORM widened to GL_RGBA16F to stay renderable as multisample storage, and
a half float's 11-bit mantissa cannot hold a 16-bit signed-normalized channel:
KHR-GL33.texture_swizzle's blue channel came back several units of 32767 away from
the value the reference computes, well outside its one-unit tolerance.
GL_EXT_render_snorm makes the signed-normalized formats colour-renderable on ES, so
widen to GL_RGBA16_SNORM instead wherever it and EXT_texture_norm16 are both
present, and only fall back to the half float otherwise. Threaded through as its own
normalize option so the capability probe and the runtime pick the same format, the
way every other driver-dependent substitution here is decided.
1D, 1D-array and rectangle textures are emulated on ES 2D and 2D-array targets, but
the capability probe kept asking the driver about the desktop-only target itself.
glTexImage2D(GL_TEXTURE_1D, ...) is not something an ES driver has ever accepted, so
those rows of the cache stayed empty - and an empty row reads as "nothing is known",
not as "the format needs help", so no fallback format was ever selected for them.
GL_DEPTH_COMPONENT32 on a 1D texture therefore went to the driver unchanged instead
of as GL_DEPTH_COMPONENT24, and the texture ended up with no storage
(KHR-GL33.texture_swizzle format_idx_65 on both 1D targets read the wrong value for
every pixel).
Probe the ES target the texture will actually live on, while still recording the
capabilities against the target the frontend asked for.
GLES core always encodes a fragment written into an sRGB colour attachment, and
offers no switch to stop it. Desktop GL has one, GL_FRAMEBUFFER_SRGB, and it starts
out disabled - so a GL application that never touches it expects its writes to land
raw. The frontend models exactly that (the capability reads as disabled and
DirectVulkan attaches the UNORM twin to honour it), but DirectGLES was passing the
draw straight to a driver that encodes anyway.
The value therefore came back one conversion short of the reference wherever it was
written and then read again: rendering into an sRGB texture and fetching it in a
shader decodes once but had encoded twice, which is how
KHR-GL32.texture_size_promotion read 0.0142 for GL_SRGB8_ALPHA8 where 0.00111 was
expected.
Detect GL_EXT_sRGB_write_control and sync GL_FRAMEBUFFER_SRGB from the frontend
capability alongside the other enables, starting from the driver's enabled state so
the first sync always pushes the disable down.
GLES has no colour-renderable three-channel format beyond RGB8, so
glTexStorage2DMultisample rejects GL_RGB16 (and the SNORM variants) with
GL_INVALID_ENUM and the texture is left with no storage at all - every draw into it
then hit GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT and every read came back zero.
The existing fallback machinery could not help: it picks one replacement format per
requested format, from the driver's capabilities, and never re-checks that
replacement against the target it is going to be used with. GL_RGB16's fallback is
GL_RGB32F, which is a perfectly legal ES texture format and a perfectly illegal
multisample storage format, and with EXT_texture_norm16 present no fallback was
selected at all.
Add PixelFormatNormalizeOptionBit::NoThreeChannelRenderTarget, applied only to
multisample targets, mapping GL_RGB16 to GL_RGBA32F and the three-channel SNORM
formats to GL_RGBA16F. Widening the channel count is safe precisely there and
nowhere else: a multisample texture can never be uploaded to, only rendered into, so
no transfer path has to expand three-channel client data, and the alpha a draw
writes for a three-channel source is already the 1.0 the frontend format implies.
The capability probe recomputes its fallback per target for the same reason, so the
probed format and the format the texture is actually created with stay in agreement.
ES has no rectangle target and no rectangle sampler, so DirectGLES declared
GL_TEXTURE_RECTANGLE unsupported outright: the texture was never synced or bound,
and SPIRV-Cross refused the shader ("Rectangle textures are not supported on
OpenGL ES") which left the whole program unlinkable.
A rectangle texture is a single-level, clamped 2D texture whose only real
difference is that its lookups take non-normalized coordinates. Where every use
takes *integer* texel coordinates - texelFetch, textureSize - that difference
does not exist at all, and the two are the same thing. So:
- A new SPIR-V pass rewrites Dim::Rect image types to Dim::2D before
transpiling, and restates the rectangle capabilities as Shader. It declines
any module containing a normalized-coordinate lookup rather than emitting
something subtly wrong; SPIRV-Cross then rejects that module exactly as
before, so nothing that used to work changes and nothing new renders wrongly.
- The target maps to GL_TEXTURE_2D for storage, uploads and binding, alongside
the existing 1D and 1D-array emulation.
Fixes KHR-GL31.texture_size_promotion.functional outright, which takes GL31 to
100% conformance. GL32/GL33 advance past their rectangle cases to a separate
GL_RGB16 multisample issue. No regressions across texture_swizzle, shaders30,
texture_lod_*, framebuffer_blit, packed_depth_stencil, transform_feedback,
clip_distance or draw_buffers; DirectVulkan re-verified unaffected.
When a shader stage fails to transpile or compile, SyncToBackend logs it and
carries on, so the program is linked without that stage - or does not link at
all. Use() then issued glUseProgram for it, which is an INVALID_OPERATION for an
unlinked program and, crucially, leaves the *previous* program current. The draw
went ahead and rendered with an entirely unrelated shader.
That is how KHR-GL3x.texture_size_promotion's GL_TEXTURE_RECTANGLE cases
produced 1.0 for a red channel: SPIRV-Cross refuses sampler2DRect for ESSL
("Rectangle textures are not supported on OpenGL ES"), so every rectangle
program was broken, and the draws kept running the previous case's 1D-array
alpha shader - whose alpha is 1.0. Wrong pixels from a shader the app never
bound are far worse to debug than a blank result.
The program now records whether the last sync produced something usable, and
Use() binds 0 rather than the broken program, making the draw a visible no-op.
The redundancy cache tracks whatever was actually bound, so it stays correct
across the switch.
Does not fix the rectangle cases themselves - those need a SPIR-V pass lowering
Dim::Rect to Dim::2D before SPIRV-Cross runs (plus the coordinate divide for
non-texelFetch lookups), alongside mapping the target to GL_TEXTURE_2D.
Desktop GL_TEXTURE_1D/1D_ARRAY are emulated on ES GL_TEXTURE_2D/2D_ARRAY, so one
native binding serves two of a unit's frontend slots. An earlier fix settled the
real-versus-default case; two REAL textures can collide just as easily, and there
the slot iteration order decided it. KHR-GL3x.texture_size_promotion keeps its 1D
source texture and its 2D destination texture bound to the same unit, so the
shader sampled the render target it was drawing into instead of the source.
GL resolves this from the shader's sampler type, so ask the program: the
frontend's uniform reflection still carries the original GLSL type, which maps
straight back to the target the lookup means. Only consulted when a collision
actually happens, so an ordinary unit costs nothing, and the first binding
placed stands when the program gives no answer rather than being overwritten by
whichever slot happens to come last.
Also adds the read-colour clamp that goes with it: GL clamps a glReadPixels from
a fixed-point colour buffer to [0,1] (GL_CLAMP_READ_COLOR defaults to
GL_FIXED_ONLY), which ES has no equivalent for at all - a GL_R16_SNORM target
holding -0.125 read back unclamped. Applied to the wide rows before they are
repacked, for float, half, short and byte reads alike, and deliberately NOT for
glGetTexImage, which reaches the same helper through a scratch framebuffer but
is not subject to read-colour clamping.
texture_size_promotion now clears every 1D case (it stops at the first failure
and has moved on to GL_TEXTURE_RECTANGLE, which DirectGLES does not emulate at
all yet), and KHR-GL33.texture_swizzle's GL_DEPTH_COMPONENT32 1D cases pass.
DirectVulkan re-verified unchanged.
GL_DEPTH_COMPONENT32 has no ES equivalent. The previous commit routed it to
GL_DEPTH_COMPONENT32F, which gives the attachment storage but changes the
encoding: the transfer type has to become GL_FLOAT for ES to accept the store,
and the upload path hands over the caller's fixed-point GL_UNSIGNED_INT bytes
unchanged, so the texels came out as garbage.
GL_DEPTH_COMPONENT24 is the nearest sized ES format that keeps the same
fixed-point encoding, so GL_UNSIGNED_INT still describes the data and no
conversion is needed. Fixes KHR-GL33.texture_swizzle's GL_DEPTH_COMPONENT32
cases on the 2D and 2D-array targets; framebuffer_blit's GL_DEPTH_COMPONENT32
config still passes, since the depth values it compares are exactly
representable in 24 bits.
(The 1D and 1D-array targets still fail, but for the separate desktop-1D-on-ES
emulation reason that also holds back texture_size_promotion.)
ES rejects any blit out of a multisample read framebuffer whose format differs
from the draw framebuffer's. Desktop GL only requires identical formats when
BOTH framebuffers are multisampled - a multisample resolve into a single-sample
target is allowed to convert on the way out, and KHR-GL3x.framebuffer_blit
resolves an R8 multisample texture straight into the RGBA8 default framebuffer.
The forwarded blit failed with GL_INVALID_OPERATION, and since the driver's
error never reaches the frontend error queue the caller saw a successful call
that had written nothing.
Retried in two steps when the first blit fails and the read framebuffer really
is the multisampled one: resolve into a scratch renderbuffer of the source's own
format, then run the caller's blit from there - single-sample on both sides,
which is exactly where ES does allow the conversion. The scratch buffer is
cached and grown on demand, keyed on the source format and dropped with its ES
context. Only reached on the failure path, so an ordinary blit is untouched.
KHR-GL3{0,1,2,3}.framebuffer_blit is now 3/3 on all four versions; DirectVulkan
(lavapipe) re-verified at 3/3 as well.
GL only requires framebuffers whose depth and stencil attachments refer to the
same image; anything else may be answered GL_FRAMEBUFFER_UNSUPPORTED, and both
backends' real targets do exactly that - DirectVulkan cannot form two separate
attachments at all, and the ES drivers behind DirectGLES return UNSUPPORTED for
a separate depth renderbuffer plus stencil renderbuffer.
The frontend already knew how to detect the configuration, but only consulted it
for DirectVulkan. On DirectGLES it answered GL_FRAMEBUFFER_COMPLETE for a
framebuffer the driver had rejected, so every clear and draw against it was
silently dropped and the results read back as zeros - which is what
KHR-GL3x.packed_depth_stencil.verify_mixed_attachments saw. (That test
explicitly tolerates GL_FRAMEBUFFER_UNSUPPORTED; what it cannot survive is being
told the framebuffer works.)
Turned into a backend capability rather than a backend-type check, probed once
at init from a scratch framebuffer the same way the format-capability cache is,
so a driver that does support the configuration keeps using it. Defaults to
supported, leaving any backend that does not set it on the permissive path.
Fixes KHR-GL3{2,3}.packed_depth_stencil.verify_mixed_attachments for both
formats; DirectVulkan re-verified unchanged at 23/25 pass + 2 not-supported.
Legacy GLSL's gl_FragColor goes to every enabled draw buffer (GL 4.6 15.2.3),
but ShaderSourceProcessor lowers it to a single mg_FragColor output, which only
ever reaches draw buffer 0. Everything past the first attachment kept its
pre-draw contents.
Replicated across the enabled draw buffers with copies at the end of main.
Gated on the count so the ordinary single-target shader is byte-for-byte what it
was: the pass is a no-op below two draw buffers, and the count comes from the
frontend draw framebuffer at program-sync time (not from the backend framebuffer
sync, which only runs later in PrepareForDraw - a program compiled against a
stale count would not be relinked until the draw after the one that needed it).
It joins the snorm/unorm clamp masks as framebuffer state the shader is compiled
against, with the same relink-on-change check.
Also advertises GL_ARB_explicit_attrib_location and GL_ARB_texture_multisample,
which DirectGLES implements for every version it advertises but only listed for
DirectVulkan. Both are core from GL 3.2/3.3 on, so an app targeting 3.0/3.1
reaches them only through the extension string - without the former the CTS
picks an entirely different draw_buffers shader, and without the latter
KHR-GL31.texture_size_promotion.functional crashed outright.
KHR-GL3{0,1,2,3}.draw_buffers.draw_buffers_1 now passes on all four versions,
and texture_size_promotion.functional on GL31 downgrades from a crash to a
(still open) comparison failure.
Two harness settings were producing failures that say nothing about the backend:
- dEQP's FboRenderContext picks the first entry of its own depth/stencil format
list, GL_DEPTH32F_STENCIL8, when the config leaves the bit counts DONT_CARE.
framebuffer_blit meanwhile hardcodes GL_DEPTH24_STENCIL8 for its own buffers
as soon as it detects an FBO surface, and then blits depth between the two -
which the spec forbids for mismatched formats, so a conformant driver has no
choice but to fail it. Default to --deqp-gl-config-name=rgba8888d24s8 so the
wrapper framebuffer and the test agree.
- --deqp-watchdog aborts the whole process when one case exceeds a hardcoded 30
seconds (framework/common/tcuApp.hpp). That is not a hang on a CPU rasterizer:
several texture_swizzle cases take ~17s each standalone and cross the limit
once the process is warm, which came back as ten spurious Timeouts. dEQP's own
default is off, and --chunk-timeout is what actually rescues a genuinely
wedged case, so default it off too and leave it selectable.
Three separate holes, all of them silent, that KHR-GL3x.framebuffer_blit walks
straight into because it clears and reads back depth and stencil directly:
- glClearStencil was frontend-only. The value was recorded in render state and
never synced, so the real driver kept its default of 0 and every
glClear(GL_STENCIL_BUFFER_BIT) wrote zeros. glClearColor and glClearDepthf
were already synced right next to it.
- Stencil readback assumed GL_STENCIL_INDEX works. It is not part of core ES
(it needs GL_NV_read_stencil) and a driver without it rejects the read
outright, which left the caller's buffer untouched. Where the attachment is a
combined depth-stencil buffer the packed GL_DEPTH_STENCIL read carries the
same bytes in its low octet, so that is now the fallback; the widening to
GL_UNSIGNED_SHORT/INT moved into the same helper, since even a byte-for-byte
read needs it.
- Depth readback always went through GL_UNSIGNED_INT. A floating-point depth
attachment (GL_DEPTH_COMPONENT32F, GL_DEPTH32F_STENCIL8 - the latter is what
dEQP's own fbo-surface-type wrapper framebuffer picks) rejects that with
GL_INVALID_OPERATION and only reads back as GL_FLOAT. Try both.
And one format gap behind the same test: GL_DEPTH_COMPONENT32 has no ES
equivalent and was being normalized to the *unsized* GL_DEPTH_COMPONENT base
format, which is not a legal glTexStorage/glRenderbufferStorage internal format
there - the attachment ended up with no storage and the framebuffer read back as
incomplete. GL_DEPTH_COMPONENT32F is the sized ES format that keeps the
requested 32-bit depth footprint; the transfer type follows it to GL_FLOAT.
Takes KHR-GL3x.framebuffer_blit from 0/3 to 2/3 (the remaining
multisampled_to_singlesampled_blit_color_config_test is a separate
single-channel MSAA resolve issue). Note that scissor_blit additionally needs
the suite to run with a depth/stencil config the test agrees with
(--deqp-gl-config-name=rgba8888d24s8): under FBO surfaces the test hardcodes
GL_DEPTH24_STENCIL8 for its own buffers while dEQP's wrapper framebuffer
defaults to GL_DEPTH32F_STENCIL8, and blitting depth between mismatched formats
is a spec error that any conformant driver has to report.
ES has no per-texture or per-sampler LOD bias at all - GL_TEXTURE_LOD_BIAS is
desktop only, and Vulkan spells it VkSamplerCreateInfo::mipLodBias, which is why
DirectVulkan already honours it. DirectGLES stored the value in sampler state
and then dropped it, so every lookup sampled at the unbiased level of detail.
The bias now reaches the shader as a uniform: a new SPIRV-Cross post-pass
declares one `uniform highp float mg_lodBias_<sampler>;` per mip-capable sampler
and folds it into the level of detail of every lookup that has somewhere to put
it - appended as the bias argument, added to an existing bias, or added to an
explicit textureLod level (Vulkan applies mipLodBias to explicit-LOD fetches too,
and the CTS reference expects the same). texelFetch/textureGather have no bias
by definition, textureGrad offers no argument to fold one into, and the
array-shadow lookups have no bias overload in GLSL at all, so all of those are
left alone. Draws push the bound texture's (or the bound sampler object's, which
overrides it as in GL) value into the uniform, and only when it changed - a
shader whose samplers all have a zero bias issues no extra call at all.
Fixes KHR-GL3{0,2,3}.texture_lod_bias.texture_lod_bias_all.
BindBufferBase_State stored Range1D(0, bufferObject->GetSize()) as the binding
point's range, so the range reflected whatever size the buffer happened to have
at bind time. Binding an empty buffer and giving it storage afterwards is
ordinary application code - glGenBuffers / glBindBufferBase / glBufferData is
exactly the order KHR-GL3{0,2,3}.clip_distance.coverage uses - and the binding
then stayed frozen at [0, 0).
Every backend consumer reads GetRange() as the range the binding actually
covers, so the stale window meant the capture buffer was bound with
glBindBufferRange(..., 0, 0) instead of glBindBufferBase, transform feedback
captured nothing, and the test read back its pre-draw zeros. The same stale
range also under-counted the CPU-side transform feedback capacity accounting.
GL resolves a whole-buffer binding against the object's size at every use;
only glBindBufferRange pins a fixed window, and the binding point already
tracked which of the two it was for the glGetIntegeri_v START/SIZE queries.
GetRange() now resolves the non-explicit case dynamically.
Fixes KHR-GL3{0,2,3}.clip_distance.coverage on Espryt; transform_feedback stays
21/21 on all four versions, and DirectVulkan (lavapipe) re-verified unaffected.
Desktop GL_TEXTURE_1D/1D_ARRAY have no ES equivalent and are emulated on
GL_TEXTURE_2D/2D_ARRAY, so one native binding serves two frontend slots of the
same texture unit. BindCurrentTextures walked the slots in enum order and let
the last one win, which is wrong as soon as one of an aliased pair holds a real
texture and the other holds the unit's default (name 0) object: the default
would be bound over the real texture and the shader sampled an empty texture,
which GL resolves to opaque black.
The default is only skipped while it has never been given an image, so this
needed nothing more than some earlier test in the same glcts process defining
one on texture name 0 - after which every later case that sampled a 1D texture
returned black. That is the mechanism behind a whole family of failures that
only reproduced when another case ran first: texture_lod_basic.lod_selection,
packed_pixels.varied_rectangle.rgba4_format_bgra, shaders.arrays.{return,
unnamed_parameter}.float_vertex and clip_distance.functional all pass in the
full-suite ordering now.
Resolved with a second pass, mirroring the intent the unbind half of the
function already had ("a default alias must not clear a real binding"): real
textures are placed first, then defaults fill only the native targets nothing
else claimed.
Two leftovers from the capture passthrough, both only observable with a
geometry shader in the pipeline:
- GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN / GL_PRIMITIVES_GENERATED fell back
to the frontend's CPU accounting, which counts the primitives the draw call
assembles and so cannot see a geometry stage's amplification. Both are core ES
query targets (GL_PRIMITIVES_GENERATED from 3.2 on, gated accordingly so an
older driver doesn't get a stray GL_INVALID_ENUM), so they now go straight to
the driver's own counters. Generalized the occlusion-query handle's isOcclusion
flag into the glBeginQuery target it already had to remember for glEndQuery,
which is what tells the result read to use the core 32-bit getter.
- FixupGsStripCaptureOrder rewrites captured strip triangles from Vulkan's
(i, i+1, i+2) order into GL's (i+1, i, i+2). A driver-side capture already
emits GL order, so the rewrite corrupted it - KHR-GL33.transform_feedback
.geometry read back the odd triangle rotated one vertex. Skipped when the
backend owns the capture span.
KHR-GL3{0,1,2,3}.transform_feedback is now 21/21 on Espryt; DirectVulkan
(lavapipe) re-verified at 21/21 for the shared frontend change.
Transform feedback was frontend-only on DirectGLES: glBeginTransformFeedback
just flipped MobileGL's own capture state and the real ES driver was never told
to capture anything, so every capture buffer read back as whatever it held
before the draw (zeros for a fresh glBufferData(NULL)). DirectVulkan drives its
capture from its own draw recording, so the shared GLFunctionsTable had no
entries for the span at all.
Capture now runs on the real driver:
- The backend program declares the capture set with glTransformFeedbackVaryings
before it links. SPIRV-Cross keeps user output names verbatim in the
transpiled ESSL, so the frontend's requested names carry over unchanged.
- New GLFunctionsTable Begin/EndTransformFeedback entries hand the span
boundaries to the backend (null for DirectVulkan, which is unaffected).
- The driver-side begin is deferred to the first draw of the span: ES needs the
capturing program current and the capture buffers bound, and both only become
true once PrepareForDraw has run. A span that never draws never touches the
driver, which is what the GL semantics amount to anyway.
- The end mirrors the captured ranges back into the frontend buffer shadows -
the GPU wrote them behind the frontend's back, so MapBuffer/GetBufferSubData
would otherwise still return the pre-draw bytes.
Takes KHR-GL32.transform_feedback from 13/21 to 19/21; the two remaining
failures are the geometry-amplified primitive queries, which still go through
the frontend's CPU accounting.
DirectGLES never registered BeginOcclusionQuery/EndOcclusionQuery, so
the frontend rejected the occlusion query targets entirely; the CTS
tests that use them (e.g. packed_depth_stencil.verify_partial/mixed_
attachments) left a stray GL_INVALID_ENUM that a later, unrelated
glGetError() check would report as its own failure
("Uploading buffer data failed" at gluDrawUtil.cpp:363).
Occlusion queries are core ES3 (glGenQueries/glBeginQuery(GL_ANY_
SAMPLES_PASSED, ...)/glEndQuery/glGetQueryObjectuiv), unlike the timer
queries which need GL_EXT_disjoint_timer_query, so they're wired up
unconditionally (independent of MOBILEGL_DISABLE_TIMERQUERY) using the
same handle-based GetQueryResult64/DeleteBackendQuery plumbing already
shared with timer queries. GetQueryResult64 now reads the 0/1 result
through the core 32-bit glGetQueryObjectuiv getter for occlusion
handles instead of the timer-only 64-bit GL_EXT_disjoint_timer_query
getter, since a driver can fully support core occlusion queries while
lacking that extension entirely.
Neither ReadPixels nor GetTexImage recognized format=GL_DEPTH_STENCIL
(type GL_UNSIGNED_INT_24_8 / GL_FLOAT_32_UNSIGNED_INT_24_8_REV): it
matched none of the native-passthrough gates nor the color-channel
conversion mapping, so both silently no-op'd (logging a compiled-out
MGLOG_E) and left the caller's buffer untouched. Real GLES/GL drivers
already implement this readback natively, so widen the native-pair
gates to include it.
GetTexImage additionally always attached the source texture to its
scratch FBO as GL_COLOR_ATTACHMENT0, which a depth-stencil texture
cannot be (framebuffer-incomplete) - route it through the existing
EnsureDepthAttachment2D(..., withStencil=true) path instead and skip
the color-only glReadBuffer call for that format.
Fixes KHR-GL3{2,3}.packed_depth_stencil.verify_read_pixels,
verify_get_tex_image, and verify_copy_tex_image (which depends on
GetTexImage internally) for both depth24_stencil8 and
depth32f_stencil8.
Same test-methodology artifact as Magma (dEQP's fbo-surface-type
wrapper FBO being mistaken for the true default framebuffer by
ApiCoverageTestCase's ReadBuffer coverage sub-test) - the waiver's
renderer_list only matched "Magma*", so KHR-GL3{0,1,2}.api.coverage
still reported Fail under the DirectGLES (Espryt) backend. Add
"Espryt*" to the same waiver entry.
MobileGL's sample-mask state is a single 32-bit word (RenderState::
SampleMaskValue) and SampleMaski_State() hard-rejects any maskNumber
other than 0. DirectGLES forwarded the real underlying driver's
GL_MAX_SAMPLE_MASK_WORDS unmodified (NVIDIA's GLES driver reports 2),
so dEQP's per-test-case gluStateReset - which always calls
glSampleMaski up to that reported word count - hit GL_INVALID_VALUE on
word 1 after every single case and aborted the whole glcts process.
Each restart only got through one more case before repeating, which
run_cts_local.py recorded as a wall of per-case crashes (63 in
packed_pixels.rectangle alone) and tripped its "many empty chunks"
abort heuristic partway through the GL32 suite. 1 is the spec-required
minimum and is what MobileGL actually implements, so cap to it instead
of forwarding the raw driver limit.
Our local-only commit f8567f6 for the erase(iterator) bug was never
pushed to MobileGL-Dev/FastSTL and broke CI's submodule checkout
("not our ref"). The fork's own main branch already carries an
equivalent fix (022211c, same root cause) plus a perf improvement on
erase(key) (34f55f9), so switch to that instead of pushing a redundant
duplicate that would diverge from it.
KHR-GL3{0,1,2,3}.api.coverage's ReadBuffer coverage sub-test captures
GL_READ_BUFFER while dEQP's own fbo-surface-type wrapper FBO is bound
(a real, non-zero-named FBO, not framebuffer 0), then later deletes an
unrelated FBO of its own. Per the GL spec, deleting a bound FBO
implicitly rebinds framebuffer target 0 - the true default framebuffer
this time, not the wrapper - and restoring the captured
GL_COLOR_ATTACHMENTn value against it correctly raises GL_INVALID_ENUM
(only FRONT/BACK-style tokens are valid there). This is a spec-correct
response to a --deqp-surface-type=fbo-only test-methodology artifact,
not a MobileGL conformance defect, and cannot occur on a real
window/pbuffer-backed run where framebuffer 0 is genuinely bound
throughout. Add a waiver (dEQP's own mechanism for exactly this kind of
known non-defect) instead of weakening the (correct) validation, and
wire --waiver-file through run_cts_local.py.
ResolveColorBlitBinding cached a RenderbufferResource*/TextureResource*
(trackedLayout) before the pending-clear materialization step ran. For an
attachment that had never been part of any render pass yet (e.g. a
GL_NONE draw buffer slot read back via an explicit glReadBuffer), the
materialize call was the first thing to touch its resource, and creating
that entry in the UnorderedMap (FastSTL, open-addressing) can rehash and
invalidate every previously-taken pointer into the map - including the
one just cached. The read then saw a stale VK_IMAGE_LAYOUT_UNDEFINED and
silently bailed (via a compiled-out MGLOG_E in release builds), leaving
the client buffer untouched. Reordering so the clear is materialized
first, then the binding resolved, guarantees the pointer reflects the
final resource state. Fixes KHR-GL3{0,1,2,3}.draw_buffers.draw_buffers_1.
layout(location=N) out qualifiers are fully supported (glslang parses them,
SPIR-V expresses them natively), but the extension string was never
advertised. KHR-GL3{0,1,2}.draw_buffers.draw_buffers_1 builds its MRT
fragment shader with per-attachment layout(location=i) outputs only when
GL_ARB_explicit_attrib_location is reported or the context is >=3.3; below
that it fell back to a single non-indexed `out vec4`, which only ever
targets location 0, leaving every draw buffer past slot 0 unwritten.
GL_ARB_texture_multisample was implemented (glTexImage2D/3DMultisample,
GL_TEXTURE_2D_MULTISAMPLE) but never listed in BuildAdvertisedExtensions.
dEQP's GL 3.1 context loader only binds non-core-until-3.2 entry points
when the extension string is present, so glTexImage2DMultisample stayed
a null function pointer and KHR-GL31.texture_size_promotion.functional
crashed on the null call. GL 3.2+ contexts treat it as core and were
unaffected.
Real drivers (NVIDIA proprietary Linux) don't implement VK_EXT_headless_surface,
which the pbuffer path required unconditionally, hard-aborting at CreateInstance.
CreateInstance now detects instance-extension support and requests
VK_KHR_xlib_surface instead when headless is unavailable; CreateSurface creates
an unmapped Xlib window purely to obtain a VkSurfaceKHR, then proceeds through
the existing swapchain path unchanged. Shutdown destroys the window it owns.
Lavapipe and other headless-capable ICDs are unaffected.
Vulkan transform feedback captures odd strip triangles as (i, i+2, i+1)
while GL table 10.1 decomposes them as (i+1, i, i+2). When the capture
stage is a triangle-strip geometry shader whose EmitVertex/EndPrimitive
sequence is statically knowable (no emission under control flow), link
time extracts the per-invocation strip lengths from the glslang AST, and
EndTransformFeedback rotates each odd triangle's captured vertex records
into GL order in place (bounded by the binding ranges' whole-triangle
capacity; raw input primitives tracked per Begin/End).
KHR-GL33.transform_feedback.geometry passes - the family is 21/21.
The draw-framebuffer sample resolver only looked at renderbuffer
attachments, so framebuffers with multisample texture attachments
reported GL_SAMPLE_BUFFERS == 0 and callers took single-sampled paths
(the CTS blit helpers read multisampled attachments based on it).
Color blits from a multisampled source now use vkCmdResolveImage (both
blit resolvers carry the image sample count); a buffer named in the blit
mask but absent from either framebuffer skips just that buffer instead
of cancelling the whole blit (GL 4.6 18.3.1); and three-channel color
renderbuffers widen to their RGBA twin exactly like textures, so
renderbuffer<->texture blits of the same GL format see one VkFormat.
framebuffer_blit.multisampled_to_singlesampled_blit_color_config_test
passes - the whole framebuffer_blit family is green.
BlitFramebuffer now serves any GL_COLOR/DEPTH/STENCIL mask combination:
the depth/stencil aspects run as per-aspect image copies before the color
path, renderbuffer attachments materialize their pending clears like
texture ones, and the scissor test clips blit writes (destination rect
intersected, source shrunk proportionally). Depth copies between images
of different depth formats (a D24S8 renderbuffer into a
DEPTH_COMPONENT24 texture riding the D32_SFLOAT fallback) round-trip
through the host with a per-texel re-encode; stencil aspects pass
through raw since every packed format encodes S8. scissor_blit and
packed_depth_stencil.blit.* now pass.
The TF primitive queries now ride VK_QUERY_TYPE_TRANSFORM_FEEDBACK_STREAM_EXT
pools when the device reports transformFeedbackQueries: each captured draw
is wrapped in a slot (shared between both GL targets when active
together), and results sum the (written, needed) pairs -
GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN from the first,
GL_PRIMITIVES_GENERATED from the second. This is exact through geometry
shaders, so KHR-GL33.transform_feedback.query_geometry_* pass; the CPU
accounting delta remains the fallback for backends without the feature.
Pulls the glslang change that downgrades the 'defined in macro
expansion' diagnostic to a portability warning with normal evaluation.
KHR-GL33.shaders.preprocessor.conditional_inclusion.basic_2_* pass; the
whole preprocessor family (482 cases, including every negative
invalid_defined_* case) stays green.
GL_STENCIL_INDEX8 becomes a first-class internal format (VK_FORMAT_S8_UINT
backing, metrics, classifiers, converters), so glRenderbufferStorage
accepts it instead of leaving GL_INVALID_ENUM behind. Framebuffer
completeness now also mirrors the renderer's gate for renderbuffers:
distinct depth/stencil renderbuffer attachments (or a renderbuffer
paired with a texture) report GL_FRAMEBUFFER_UNSUPPORTED - the spec only
requires the same-image case - instead of passing completeness and then
failing at draw/clear (verify_mixed_attachments.* now passes).
GL_SAMPLES_PASSED / GL_ANY_SAMPLES_PASSED(_CONSERVATIVE) now work: every
app draw between Begin/EndQuery is wrapped in a slot of a host-reset
occlusion query pool (precise counts when occlusionQueryPrecise is
granted), and the result flush ends any active render pass before
submitting, waits, sums the slots and recycles them. ANY_* targets
report the boolean form; GL_QUERY_COUNTER_BITS and GL_CURRENT_QUERY
answer for the occlusion targets, and deleting an active query releases
its slot. Draw-time depth/stencil state also honors attachment absence:
a framebuffer without a depth (stencil) attachment behaves as if that
test always passes, even when a packed depth-stencil image is attached
through only one half (verify_partial_attachments.*).
Depth textures previously raw-copied whatever the client handed over
into the Vulkan image, so any client format other than the image's exact
texel layout uploaded garbage (float DEPTH_COMPONENT data read as
16-bit words, GL_TEXTURE_1D/2D alike).
The shadow now has a defined canonical layout - unorm16 for
DEPTH_COMPONENT16, a full-scale unorm32 word for the 24/32-bit fixed
depths, float for DEPTH_COMPONENT32F - produced by the pixel-store
unpack converter (new DepthComponent channel mapping + UNorm32
component). GL_DEPTH_COMPONENT client data may also fill packed
depth-stencil internals (stencil half zero). The Vulkan uploader
converts shadow words to the image texel layout per aspect, and
X8_D24_UNORM falls back to D32_SFLOAT where optimal tiling lacks
support (lavapipe). texture_size_promotion.functional and
packed_depth_stencil.verify_copy_tex_image.* now pass.
GL renders into sRGB color attachments RAW when GL_FRAMEBUFFER_SRGB is
disabled (the core-profile default), but Vulkan sRGB attachments always
encode on write - one decode went missing whenever a rendered-into sRGB
texture was sampled again (multisampled sRGB targets in
texture_size_promotion and texture_swizzle idx27/28 ms cases).
Attachment views (textures and renderbuffers) now reinterpret sRGB
images through their UNORM twin while the capability is off, switching
back when enabled: images get MUTABLE_FORMAT, the attachment-view cache
keys the view format, renderbuffers carry a second view, and the render
pass hash includes the capability state. Sampled views keep decoding.
The VkTextureManager.cpp half of this rides with the next commit.
glReadPixels final conversion honors GL_CLAMP_READ_COLOR (default
GL_FIXED_ONLY): fixed-point normalized color buffers clamp to [0,1] on
read - visible for SNORM attachments, whose negative values previously
leaked through (texture_size_promotion SNORM cases). True float formats
stay unclamped unless the mode is GL_TRUE; GetTexImage is unaffected.
GL_LINES_ADJACENCY / GL_LINE_STRIP_ADJACENCY / GL_TRIANGLES_ADJACENCY /
GL_TRIANGLE_STRIP_ADJACENCY fell through to the TRIANGLE_LIST default,
so adjacency draws assembled garbage. They now map to the matching
*_WITH_ADJACENCY topologies (adjacency vertices are discarded by Vulkan
when no geometry shader is active, matching GL semantics);
KHR-GL33.primitive_restart.restart_mode passes.
glGenSamplers creates the sampler objects themselves (unlike texture and
buffer names), so glIsSampler must answer GL_TRUE before any bind - the
names now get their state vectors at Gen time (KHR-GL33.api.coverage).
glCopyTexImage2D with a combined DEPTH_STENCIL internalformat now
requires both halves in the read framebuffer and reports
GL_INVALID_OPERATION when only the depth or only the stencil attachment
point is populated (packed_depth_stencil.validate_errors.*).
The depth-stencil ReadPixels core (per-aspect copies + CPU repack) is
now shared, and glGetTexImage serves GL_DEPTH_COMPONENT /
GL_DEPTH_STENCIL / GL_STENCIL_INDEX queries of depth textures with it
instead of rejecting every non-color aspect
(packed_depth_stencil.verify_get_tex_image.* now passes).
The packed-type size table listed the D32F+S8 client format at 4 bytes,
so every GL_DEPTH32F_STENCIL8 upload copied only half its client data -
the top half of such textures stayed zero (packed_depth_stencil
verify_read_pixels/clear_buffer.depth32f_stencil8 now pass).
glReadPixels now serves GL_DEPTH_COMPONENT, GL_DEPTH_STENCIL and
GL_STENCIL_INDEX from the read framebuffer's depth/stencil attachment:
per-aspect vkCmdCopyImageToBuffer copies (4-byte-aligned stencil region)
with CPU repacking into GL_FLOAT / GL_UNSIGNED_SHORT / GL_UNSIGNED_INT /
GL_UNSIGNED_INT_24_8 / GL_FLOAT_32_UNSIGNED_INT_24_8_REV /
GL_UNSIGNED_BYTE layouts, honoring pack state and pixel-pack buffers.
GL_DEPTH_STENCIL_ATTACHMENT parameter queries follow the spec's combined
rules: differing depth/stencil attachment images (or a lone half) fail
with GL_INVALID_OPERATION, as does GL_FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE
on the combined name. packed_depth_stencil.verify_parameters.* and
verify_read_pixels.depth24_stencil8 now pass.
glslang parses "packed" and "row_major" as plain identifiers outside a
layout(...) list and accepts the reserved image*Shadow names outright.
A comment/preprocessor-aware pre-scan in the compile path now fails such
shaders with a proper info log, while layout(packed)/layout(row_major)
qualifier lists stay legal (uniform_block family still passes).
KHR-GL31/32/33.CommonBugs.CommonBug_ReservedNames now pass.
Without an explicit size dEQP's FboRenderContext sizes the wrapper FBO
to GL_MAX_RENDERBUFFER_SIZE (16384^2 here) and size-derived test
allocations explode - the multisampled depth blit config test alone
needs a 4 GiB depth texture on such a surface.
glGetFramebufferAttachmentParameteriv (and the DSA variant) now answer
GL_FRAMEBUFFER_ATTACHMENT_RED/GREEN/BLUE/ALPHA/DEPTH/STENCIL_SIZE,
COMPONENT_TYPE and COLOR_ENCODING from the attached image's internal
format, and accept the default-framebuffer attachment names (GL_DEPTH,
GL_STENCIL, GL_FRONT/GL_BACK variants). Querying them with no image
attached reports GL_INVALID_OPERATION per spec instead of
GL_INVALID_ENUM.
VK_VERIFY appended the caller's context format string to the base format
while the context ARGUMENTS expanded before the base arguments, so any
failing VK_VERIFY with context args formatted every conversion from the
wrong slot - the %s for VkResultToString dereferenced an integer arg and
crashed inside the logger. The context line is now its own log call
(XXHASH_VERIFY had the same defect).
vmaCreateImage failure in SyncTextureResource is now a soft failure like
the unsupported-sample-count path: a driver may pass the
vkGetPhysicalDeviceImageFormatProperties pre-check yet still refuse
creation (a 4-sample 16K depth texture on lavapipe is 4 GiB), and a GL
implementation must not abort on that.
glDeleteProgram on the current program now only flags it: the name (and
every glGetProgram* query) stays valid until the program stops being
current, at which point UseProgram frees the slot and releases orphaned
attached shaders. Previously the name died immediately, so a second
glDeleteProgram - as issued by common CTS utility teardown - recorded
GL_INVALID_VALUE that poisoned the next test iteration's build
(KHR-GL33.clip_distance.functional now passes its build phase).
glIsProgram/glIsShader piggyback on the same rule: a flagged name is
still a program/shader while it stays GL-visible, which resolves the
long-standing FIXMEs there.
Pulls the FastSTL fix for erase() iterator advancement: erase loops
(pending-clear GC, render-pass eviction, frame-transient drains) no
longer skip elements or walk past the bucket array. Root cause of the
order-dependent CTS batch segfaults (texture_lod_bias_all,
clip_distance.functional after ReadPixels, batch-order aborts).
glBeginQuery/glEndQuery now accept GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN
and GL_PRIMITIVES_GENERATED. The result comes from CPU-side accounting:
every captured draw adds the primitives it assembles, clamped by the
capture buffers' remaining capacity in whole primitives (a full buffer
stops recording, which is exactly what PRIMITIVES_WRITTEN reports), with
the captured-vertex cursor resetting on glBeginTransformFeedback.
Draws without a geometry stage write exactly what they assemble, so this
is precise for them (KHR-GL33.transform_feedback.query_vertex_* now pass);
geometry amplification is not modelled yet and the query_geometry_*
variants still fail.
Second stage of GL 3.0 transform feedback: captured draws now write real
data.
- Device setup enables the VK_EXT_transform_feedback feature when present
and loads the bind/begin/end entry points.
- Captured draws compile an XfbCapture program variant whose last
vertex-processing stage gets XfbBuffer/XfbStride/Offset decorations from
the program's resolved varyings (a new spirv-opt pass). A captured
gl_Position is mirrored into a dedicated output written before every
OpReturn - or before every OpEmitVertex in a geometry stage - ahead of
the position fixup, so the captured value is the shader's own pre-remap
position.
- DrawArrays/DrawElements wrap the draw in Begin/EndTransformFeedbackEXT;
a small counter buffer resumes the append position across draws within
one glBeginTransformFeedback (fresh Begin starts at the bound offsets).
- Capture targets are promoted to persistently-mapped host-coherent GPU
storage (persistent-map storage now also carries the transform feedback
usage), so MapBuffer/GetBufferSubData read the captured bytes after the
fence wait glEndTransformFeedback now performs.
- Draw-mode/feedback-mode validation defers to the geometry shader's
output primitive when one is present, and glGetBooleanv reports
GL_TRANSFORM_FEEDBACK_ACTIVE/PAUSED so dEQP's per-case state reset can
unwind an active capture.
KHR-GL33: transform_feedback capture_vertex_*/capture_geometry_*/
discard_*/draw_xfb and clip_distance.coverage now pass; queries
(PRIMITIVES_WRITTEN) and gl_ClipDistance capture remain.
First stage of GL 3.0 transform feedback: glTransformFeedbackVaryings /
glGetTransformFeedbackVarying / glBeginTransformFeedback /
glEndTransformFeedback were unimplemented stubs. This adds
- per-program capture state: requested varyings apply on the next link and
resolve against the last vertex-processing stage's linker objects (with
gl_Position/gl_PointSize handled as builtins), failing the link on
unknown or duplicate names or exceeded interleaved/separate limits, with
offsets and strides computed per GL rules;
- context Begin/End state with the GL 3.3 error semantics: invalid
primitive modes, redundant Begin/End, missing program or capture-buffer
bindings, primitive-mode compatibility at draw time, and the
while-active prohibitions on rebinding capture buffers, switching
programs, and relinking the captured program;
- GetProgramiv TRANSFORM_FEEDBACK_* queries and a 4-slot bound on indexed
GL_TRANSFORM_FEEDBACK_BUFFER binding points.
KHR-GL33.transform_feedback api_errors/linking_errors/get_xfb_varying now
pass; GPU-side capture is the remaining stage.
Program entry points answered GL_INVALID_VALUE whenever the name did not
resolve to a program, including names that exist but belong to a shader
object. Programs and shaders share one name space, so the spec (and
KHR-GL33.get_uniform_tests.get_uniform) requires GL_INVALID_OPERATION for
the shader-name case and GL_INVALID_VALUE only for names GL never handed
out, matching the interface-query helper's existing behavior.
Normalization rewrote every desktop core #version below 400 to 330 (and
400+ to 460), and a failed parse was retried at 460. Together these erased
the declared version's rules: KHR-GL33 negative-compile cases (reserved
names, parenthesized layout-qualifier values in a declared-420 shader,
GLSL 4.5 mix() overloads at 330, precise in struct members) all compiled.
Explicitly declared core versions >= 330 now keep their number, and the
460 retry only fires for sources whose directive carries the normalizer's
own legacy marker - i.e. shaders that declared 110-150 (or nothing), which
is the shader-pack compatibility case the retry exists for. Replaces the
narrower arrays-of-arrays special case.
GetComponentSizesForInternalFormat asserted on TextureInternalFormat::Unknown,
which framebuffer-parameter queries legitimately reach for attachments that
have no storage yet (KHR-GL33.packed_depth_stencil.validate_errors.initial_state
aborted there). Answer with all-zero sizes and keep a warning for genuinely
unhandled formats. Also include the image dimensions in the texture
vmaCreateImage failure report.
The GL CTS queries GL_MAX_TRANSFORM_FEEDBACK_BUFFERS and
GL_MAX_VERTEX_STREAMS before checking whether the extension is advertised
and requires no GL error (desktop drivers all accept these enums). Answer
with the separate-attrib capacity and a single vertex stream; the
transform_feedback3 tests then report NotSupported instead of failing on
GL_INVALID_ENUM.
Vulkan has no LINE_LOOP topology and the frontend used to reject the mode
with GL_INVALID_OPERATION, which is itself non-conformant (several KHR-GL33
transform_feedback tests draw line loops and expect no error). DrawArrays,
DrawElements and DrawElementsBaseVertex now rewrite the draw into an
indexed GL_LINE_STRIP whose synthesized uint32 index list revisits the
first vertex, delivered through the client-memory index path (a new
forceClientMemory flag keeps a bound element-array buffer from hijacking
the synthesized pointer). Entry points without the rewrite degrade to an
open line strip instead of a triangle list.
BlitFramebuffer's color path asserted that the read framebuffer's source
attachment is a texture; a renderbuffer source (packed_depth_stencil.blit
color checks) aborted the process. Materialize pending clears through the
renderbuffer path for both source and destination, as ReadPixels already
does.
glRenderbufferStorageMultisample accepts any sample count up to MAX_SAMPLES
(including non-powers-of-two like 3) and promises at-least allocation, but
the renderbuffer path required an exact Vulkan sample-count match and failed
on devices like llvmpipe that expose 1x/4x only. Round the request up to a
power of two and then to the nearest count the device supports for the
format, cached per format so per-draw resolution does not re-query the
physical device.
Un-crashes KHR-GL33.packed_depth_stencil.blit.* (2x/3x MSAA renderbuffers).
UploadDirtyMipLevels used to skip D24S8/D32FS8 textures outright, leaving
glTexImage-supplied depth-stencil data unuploaded (KHR-GL33
texture_repeat_mode depth24_stencil8 and texture_swizzle depth-stencil
cases all sampled zeros). De-interleave the shadow's GL wire format into a
depth plane (X8_D24 word / float) and a stencil byte plane and record one
copy per aspect, with cross-conversion when the device backs the texture
with the other depth-stencil format.
Depth32FStencil8's shadow byte size also claimed 16 bytes/texel while the
stored wire format (GL_FLOAT_32_UNSIGNED_INT_24_8_REV) is 8; that mismatch
truncated every upload of it.
Also route a multisample-texture sample-count request through the device's
supported counts (round up, GL promises at-least semantics).
UploadDirtyMipLevels encoded a texture's GL depth into VkBufferImageCopy
imageExtent.depth with layerCount = 1. For array textures the layers live in
the image's arrayLayers, and extent.depth > 1 is invalid for 2D images - in
practice every layer past the first never received its data.
Route the third dimension into layerCount for 1D/2D/cube array images and
keep imageExtent.depth for genuine 3D images.
Fixes the KHR-GL33.pixelstoragemodes.teximage3d.* failures (110 cases) on
lavapipe.
The legacy-shader retry that retargets a failed parse to #version 460 also
re-legalized multidimensional arrays, which every desktop driver rejects
below 430 and KHR-GL33.shaders.arrays.invalid.* requires to fail. Skip the
retry when the original failure is glslang's arrays-of-arrays error; other
legacy rescues (e.g. layout(binding=...)) keep working.
KHR-GL33.shaders.arrays.invalid.multidimensional_array* now report the
required compile failure (4 cases).
A cached RenderPassEntry bakes renderbuffer clear payloads inline into its
pendingClearAttachments, and that list outlives the clear's consumption at
pass begin (loadOp CLEAR). Every subsequent draw that reused the entry while
its pass was still active replayed the stale clear through
vkCmdClearAttachments, wiping the color and depth of everything drawn so far
in the pass.
Texture-keyed clears already re-checked the clear manager before clearing;
do the same for inline renderbuffer payloads: only clear while the
renderbuffer clear is still actually pending, and take the live payload so a
newer glClear's values win.
On lavapipe this takes KHR-GL33.shaders.fragdepth.* from 0/18 to 18/18; the
same defect hit any renderbuffer-FBO case with several draws per pass.
With no GL_ELEMENT_ARRAY_BUFFER bound, the IndexBufferView byte offset is a
raw client pointer (desktop drivers accept client-memory indices and the GL
CTS relies on this even in core contexts). UploadAndBindIndexBuffer used to
assert-crash the process there; it now snapshots the client index data into
a transient per-frame slice and binds that, matching how client-memory
vertex attributes are already streamed.
Fixes the process abort in KHR-GL33.transform_feedback.capture_* and every
other mustpass case that draws with client-side index arrays.
Local counterpart of run_cts.py for desktop Linux runs: re-invokes glcts
with the not-yet-measured cases after a crash, quarantines timed-out cases
with the dEQP watchdog enabled, and records crashed/hung/unrun lists so a
partial run cannot read as a complete one.
Guard the AImageReader window path behind __ANDROID__ and add a
mobilegl-desktop DEQP target so glcts can run against libMobileGL.so on a
Linux host via pbuffer surfaces (VK_EXT_headless_surface).
- every render pass declared colorAttachmentCount=8 (the full GL draw-buffer
slot span) with trailing VK_ATTACHMENT_UNUSED references, and Adreno
configures its per-pixel render-backend/export path from the DECLARED
count - so every fragment of every pass paid an 8-render-target export
cost; this was the bulk of the 1.5x per-pixel gap against
MobileGlues+ANGLE on the same Qualcomm driver (their subpasses declare
exactly the used span)
- measured on Adreno 650 / MC 26.2 / 1440x3044: total GPU frame time
11.9 -> 7.5 ms (-37%, now below ANGLE's 7.87 ms), the single-quad
swapchain blit pass alone 1.26 -> 0.40 ms, steady in-world FPS 82.8 -> 123
under the standard cooled-start protocol, matching the
MobileGlues+ANGLE+system-Vulkan benchmark of 123.8
- trailing UNUSED references are popped before the subpass is built (the
entry's colorAttachmentCount and every pipeline's colour-blend span follow
it); interior GL_NONE holes keep their slots so fragment-output locations
still line up
- the pipeline-side fragmentOutputMask check downgrades from assert to a
debug log: an output at a location past the trimmed span is discarded,
which is GL's defined behaviour for a draw buffer set to GL_NONE
- viewport, scissor, blend constants, depth bias, line width and the six
stencil parameters were re-emitted unconditionally for EVERY draw (~1500
vkCmdSet* per frame in MC 26.2, where ANGLE emits a handful), costing CPU
record time and GPU command-processor work for values that almost never
change between draws
- a recording-scoped shadow now drops any vkCmdSet* whose values match what
the command buffer already holds; valid because every PipelineFactory
pipeline declares the same eight dynamic states, so set values persist
across those binds
- the shadow resets at every command-buffer (re)begin (dynamic state does
not survive the boundary) and after binding the blit or depth-mipmap
pipelines, whose narrower dynamic sets make the untouched states undefined
and whose raw viewport/scissor writes bypass the shadow
- every non-MSAA texture was allocated with a full mip chain regardless of
how many levels the GL texture actually defines, so MC's 3044x1440 main
colour and depth render targets each carried 12 levels where ANGLE
allocates one; a level-0-only texture now gets a single-level backing and
upgrades to the full chain exactly once when a second level is first
defined, through the existing preserve-copy recreation path
- saves a third of the memory of every mip-less texture and keeps
single-level render targets off the multi-mip image layout entirely, which
also removes the surface the Adreno 650 implicit-LOD overread workaround
(ForceExplicitLod0SamplePass) exists to defend
- measured perf-neutral on Adreno 650 / MC 26.2 (the driver keeps full UBWC
on multi-mip render targets), so this is a memory/robustness fix, not a
speed one
- a draw whose sampled texture needs out-of-pass work (deferred clear
materialization or a sampled-layout transition) used to end the active
render pass - a full-target store+reload on a tiler - even when the only
ordering the work needs is 'before this draw'; MC 26.2 clears an overlay
texture every frame and samples it mid-pass, splitting the main scene pass
once per frame for nothing
- every frame slot now carries a second primary command buffer, submitted
strictly AHEAD of the frame command buffer in the same vkQueueSubmit; when
the open recording has not referenced the image yet (tracked via a
recording-generation stamp on the texture resource, advanced on every
frame-command-buffer begin and stamped at every recorded reference:
attachments at BeginRenderPass/attachment-write, sampled reads per draw,
layout transitions), the clear/transition is recorded there and the active
pass stays open - ANGLE's outside-render-pass command stream, restricted
to the provably reorderable case
- mid-frame flushes and readback submits close and carry the pre stream with
the frame buffer (it must never be submitted later than the recording it
was paired with), retiring both under the same submit index; dropped
recordings (present suspension, swapchain recreation) abandon it
- MaterializePendingClearForTexture's no-active-render-pass assert now
applies only to the frame command buffer, since the pre stream records
while a pass is open on the frame buffer by design
- EGL swap semantics make the presented colour buffer's content undefined at
its next acquire (EGL_BUFFER_DESTROYED, the implementation default) and
every ancillary depth/stencil buffer's content undefined after ANY swap,
yet the default-FBO render pass reloaded both with LOAD_OP_LOAD every
frame; SwapchainObject now tracks per-image content validity (defined when
a pass stores into the attachment, invalidated at present) and the
render-pass manager turns an undefined attachment's tile load into
LOAD_OP_DONT_CARE with initialLayout=UNDEFINED, keyed into both hashes so
the cached LOAD variants cannot be hit by mistake
- the default framebuffer's depth attachment is now attached ON DEMAND: a
draw with depth test and stencil test both disabled (GL: a disabled test
neither reads nor writes its buffer), and no pending depth/stencil clear,
resolves to a depth-less pass flavour, dropping the D24S8 tile load AND
store outright - MC 26.2 renders its GUI into its own FBO and only ever
blits colour to the default framebuffer, so its swapchain pass carried a
full-screen depth round-trip for nothing
- the flavour only escalates: an active depth-full pass absorbs depth-less
draws unchanged, while a depth-using draw against a depth-less pass
resolves to an incompatible entry and splits, its depth loading DONT_CARE
(the content was undefined all along); the depth-less flavour is folded
into ComputeHash and the per-draw fast-path memo so the two flavours can
never alias
- Every storage-capable colour texture was created MUTABLE_FORMAT, and Adreno
gives up bandwidth compression on an image that may be viewed as any format in
its compatibility class. MC's main render target therefore ran uncompressed;
in a fill-bound scene that is the whole frame budget. Measured on Adreno 650,
MC 26.2, same scene and camera, device cooled to 38-40C before each run:
65.3 -> 80.9 fps (+23.9%), GPU busy ~93% in both.
- VK_KHR_image_format_list (enabled when present) fixes it without giving up
mutability: VkImageFormatListCreateInfo names the exact formats a view may
use, so the driver can keep the image compressed. The set must be exhaustive
or the result is undefined - for sampled views it is exactly what
ResolveSampledImageViewFormat can return over the three numeric domains.
- glBindImageTexture may name any compatible format, which cannot be enumerated
ahead of time, so a texture bound to an image unit gets no format list. That
is what VK_IMAGE_USAGE_STORAGE_BIT becoming on-demand is for: it makes
"unmarked" mean "will never receive an arbitrary-format storage view", which
is what makes the list sound. Removing STORAGE is worth nothing on its own
(65.4 fps, measured) - only the mutability bound pays.
- MarkStorageImageTexture runs over every collected image-unit texture before
the probe loop in PrepareStorageImageTextures, because that loop stops at the
first texture needing work and would leave the rest unmarked. The mark makes
NeedsStorageImagePreparation report true, which is what ends the render pass,
so the recreate lands outside it.
- storageUsageResolved separates "not upgraded yet" from "this format can never
carry STORAGE", so a format whose optimalTilingFeatures lack STORAGE_IMAGE
cannot ask for a recreate that will never happen. SyncTexture's cross-draw
early-out also has to break on a pending upgrade or the recreate never runs.
- An upgrade recreates the image and carries its contents forward through
PreserveTextureContentsOnRecreate, which submits its own command buffer and
waits. Whatever the frame already recorded into the old image is still
unsubmitted, so that copy would read pre-frame content and this frame's
rendering into the texture would be lost - exactly the render-target-then-
image-unit case. PrepareStorageImageTextures now flushes first; it takes the
FrameData rather than a command buffer because the flush retires the current
one, and drops the sampled-descriptor-set memo that described it.
description:Bring up, rebuild, deploy, switch backends on, test and debug the "MobileGL + anland + KDE Plasma" stack - a rooted Android tablet whose anland APK embeds the MobileGL server, plus a Droidspaces Arch Linux ARM container running KWin/plasmashell/Chrome through the MobileGL client library and GBM backend - driven from this Windows machine (Git Bash, WSL archlinux, adb). Use it after a phone reboot, when changing server (NDK) or client (container glibc) MobileGL code or anland's KWin backend/patch, when switching Espryt/Magma, when validating zero-copy dma-buf presentation, and when chasing crashes, black windows, noise or software-rendering fallbacks in the Plasma session.
---
# MobileGL on anland KDE Plasma
**Setting this up on a new machine or phone from source: `reproduce.md` next to this file.**
Human-readable runbook (Chinese, same content): `docs/archive/notes/anland/runbook-plasma.md`.
Background: `docs/archive/notes/anland/handoff-legacy-mobilegl-unified.md` (state, commits, known
issues) and `plan-ahb-dmabuf.md` (zero-copy design). All scripts are in `scripts/` next to this file;
parameters are documented at the top of each script and in `scripts/env.sh`.
**The phone may be shared with another session.** Before anything that restarts the app, the daemon,
the container session or replaces a library, run the read-only `scripts/device/status.sh` and make sure
nobody else is mid-test. Use your own `ct.sh` channel names (others use c, c2, c3, xd, xr, gbm).
## Architecture (10 lines)
1. Phone: Lenovo TB321FU (Y700, Adreno 750, Android 15, kernel 6.1), rooted with **KernelSU** (`/data/adb/ksu`, `ksud`; `su -c` works). USB serial `HA27Q3LQ`, or wireless adb `ip:port`.
2. anland APK `com.anland.consumer.mobilegl` (debuggable build of anland branch `legacy-mobilegl-unified`): UI process + private service process `:mobilegl` (`MobileGLWorker`), which loads the **server**`libMobileGL.so` (NDK, from MobileGL `feat/disaggregated`) and listens on abstract socket `@anland-mobilegl` from the first attached window on. One process serves every client (KWin, plasmashell, ksplash, Chrome...), one backend per process. `MobileGLWorker` is a **foreground service** (notification "Linux desktop is running" / action "Stop desktop"), so the server and the session outlive a hidden, locked or swiped-away window.
3. Backend = the app setting "Renderer backend" (Settings > Connection > MobileGL desktop; file `/data/data/<pkg>/files/mobilegl-backend`, default DirectGLES; also `am start ... --es mobilegl_backend X`). Espryt = DirectGLES, Magma = DirectVulkan. The server reads it when its process starts; the app publishes it as `/data/local/tmp/anland-mobilegl/backend` and the session copies that into the container's `/etc/mobilegl/backend` (what every client reports; mismatch = "Hello.backendType disagrees with pinned backend"). `debug.mobilegl.backend`, if someone sets it, still overrides the setting until the next reboot.
4. Experiment display daemon `/data/adb/modules/anland-daemon/display_daemon /data/local/tmp/anland-mobilegl/display.sock` (display/input broker), started by the app (below). The module only starts the original one on `/data/local/tmp/display_daemon.sock` at boot; leave that alone.
5. Container `arch-kde-mgl` (Droidspaces 6.4.5, Arch Linux ARM aarch64 glibc, host net, `rootfs.img` mounted at `/mnt/Droidspaces/arch-kde-mgl` on the Android side). Bind mounts: `/data/local/tmp/anland-mobilegl` -> `/run/anland-mobilegl`, `/data/local/tmp/display_daemon.sock` -> `/run/display.sock`.
6.`desktop-session.service` (User=swung0x48, **enabled**: the container boots straight into it, as the original image does) has drop-in `/etc/systemd/system/desktop-session.service.d/mobilegl.conf` (anland `producers/kde/Arch_v5/desktop-session-mobilegl.conf`, installed + enabled by `sync-build-kwin.sh`): `ExecStartPre=mobilegl-startup.sh wait` (until `@anland-mobilegl` listens in `/proc/net/unix` and the daemon socket exists), `ExecStartPre=+... sync-backend`, `Restart=always`, then `ExecStart=/opt/mobilegl/bin/mobilegl-startup.sh plasma`, which deletes the sticky software-renderer key, sets `MOBILEGL_IPC_PROBE_TIMEOUT_MS=0` session-wide (MobileGL never declines, so glvnd never falls through to another GL stack), writes a user drop-in for `plasma-kwin_wayland.service` (kwin_wayland_wrapper -> `/opt/mobilegl/kwin/bin/kwin_wayland` + `/opt/mobilegl/kwin/lib/libkwin.so.6.7.4`, `ANLAND_MOBILEGL=1`, `MOBILEGL_IPC_SURFACE=server`) and runs `startplasma-wayland`.
7. KWin 6.7.4 with anland's `anland` backend renders straight into the anland Android Surface through the server. While the window is not on screen (consumer disconnected) the backend powers its output down (DPMS Off + RenderLoop inhibited: no compositing, no frame callbacks) and back up when the window returns. Every other GL process is `MOBILEGL_IPC_SURFACE=offscreen` and presents through KWin.
8. Client = `/opt/mobilegl/lib/libMobileGL.so` (glibc aarch64), the container's **system-wide** GL vendor (section below): glvnd EGL vendor `/usr/share/glvnd/egl_vendor.d/10_mobilegl.json` (sorts before the distribution's `50_*.json`, which serves whenever MobileGL declines), GLX vendor `/usr/lib/libGLX_mobilegl.so.0`, settings from `/etc/mobilegl/client.conf` (`MOBILEGL_TRANSPORT=spawn MOBILEGL_IPC_DATA=shm MOBILEGL_IPC_CONTROL=unix:@anland-mobilegl`) + `/etc/mobilegl/backend`. No per-process environment is needed.
9. Zero-copy windows: client `wl_egl_window` frames are server-allocated AHardwareBuffer "shared images" exported as dma-buf fds over `zwp_linux_dmabuf_v1`; KWin imports them with EGL and the server recognises its own buffer by inode. KWin gets a DRM device via `GBM_BACKEND=mobilegl` -> `/usr/lib/gbm/mobilegl_gbm.so` (node is only an identity, never ioctl'd). Without linux-dmabuf the client falls back to wl_shm readback.
10. X11 (`docs/archive/notes/anland/plan-x11-gpu.md`): Xwayland draws with **glamor on MobileGL** (an offscreen session; `GBM_BACKEND=mobilegl` on KWin's render node; window pixmaps are shared images handed to KWin as linux-dmabuf buffers; its `glFlush` publishes them through the server, `/tmp/mgl-xwayland.client.log`); `ANLAND_XWAYLAND_GLAMOR=0` in KWin's drop-in brings back the software `-shm` Xwayland. GLX windows present their frames as shared images through **DRI3 + Present** (log `presented through DRI3+Present shared images`), falling back to readback + MIT-SHM / `xcb_put_image` when the X server has no DRI3 (`MOBILEGL_GLX_PRESENT=readback` forces it per app). Chrome runs ANGLE-GLES over MobileGL EGL with the GPU in its own process (`mobilegl-startup.sh chrome`; `MOBILEGL_CHROME_GPU=in-process` opts out), presenting GBM dma-bufs over linux-dmabuf; after a device loss Chrome restarts that process and repaints.
- Android NDK **27.2.12479018** at `%LOCALAPPDATA%/Android/Sdk/ndk/27.2.12479018` (override `ANDROID_NDK`).
- WSL distro **archlinux** with clang, lld, llvm, cmake, ninja. **lld's major version must equal clang/llvm-libs'** (lld 23 on llvm 22 broke `ld.lld`; working set: all 22.1.6). Then export the sysroot once: `bash scripts/xsysroot/refresh-sysroot.sh` (-> `~/sysroots/arch-kde-mgl` + `~/sysroots/aarch64-arch.cmake`; re-run after container package upgrades, then `xbuild.sh --reconfigure`).
- MobileGL worktree (never build in the shared main checkout): `GIT_LFS_SKIP_SMUDGE=1 git worktree add ...`, `git submodule update --init --recursive`, and copy `3rdparty/glslang/External/spirv-tools` from the main checkout (the submodule leaves it empty).
- anland worktree on `legacy-mobilegl-unified` (KWin patch/backend, startup script, `mobilegl-tools/`).
- Phone side that already exists and is NOT recreated by this skill: the APK (build: anland `consumers/anland_v5/android_consumer/MOBILEGL.md`, gradle `:app:assemblePlainDebug` with `MOBILEGL_DIST`, `-PmobileglApplicationId=com.anland.consumer.mobilegl -PanlandDefaultSocket=/data/local/tmp/anland-mobilegl/display.sock`, from Git Bash with `MSYS_NO_PATHCONV=1`; signed with this machine's `~/.android/debug.keystore`, so `adb install -r` works; reinstalling with another key changes the UID and needs the KernelSU grant again - `mobilegl-tools/ksu_grant.c`), the anland KernelSU modules (`anland-daemon`, `anland-awl`), the container with KWin's configured build tree `/root/mobilegl-build/kwin-6.7.4/build` and pristine `/root/gbm-dev/kwin-a/src`.
- Install the helpers once (container must be running): `bash scripts/push-tools.sh` (device/*.sh -> `/data/local/tmp/anl`, `mgrun` -> container `/usr/local/bin`, `kwprep.sh` -> container `/root`).
Run host scripts from Git Bash as `bash scripts/<x>.sh`. Raw adb with device paths needs `MSYS_NO_PATHCONV=1` and the whole root command in one quoted string: `adb shell 'su -c "..."'`. A device script can also be run without pushing: `MSYS_NO_PATHCONV=1 adb shell 'su -c sh' < scripts/device/status.sh`.
## Starting, idling, stopping - no manual steps
Nothing starts at phone boot (container `run_at_boot=0`). **Opening the app** (launcher icon = `am start -n com.anland.consumer.mobilegl/com.anland.consumer.MainActivity`, no extras; the APK's default socket is the experiment's, `-PanlandDefaultSocket`) calls `MobileGLDesktop.ensureStarted()` - the single trigger - which starts the foreground service and runs the APK's `assets/mobilegl-desktop.sh up` as root (`su`): display daemon if absent (the script leaves the app's cgroup first, so it survives the app) -> backend published -> container started if stopped -> `systemctl start --no-block desktop-session`. The window waits for the daemon socket; the server listens once the Surface is attached; the session's `wait` step then lets Plasma start. A reopen with everything up only reattaches the window. Log: `/data/local/tmp/anland-mobilegl/desktop.log`, logcat `AnlandMobileGL` ("MobileGL desktop up done in N ms").
Hidden window, screen off, lock screen or swiped-away task: server and session stay (foreground service); KWin turns the workspace's DPMS off (logs "viewer gone: output powered down"; windows get xdg_toplevel `suspended`, no frame callbacks) and back on ("viewer back"). Client swaps are paced by frame callbacks (`MG_Impl/EGLImpl/FrameThrottle.h`): interval >= 1 waits like any Wayland EGL; interval 0 (Chrome) drops to ~1 fps once a callback goes unanswered for 200 ms.
Measured 2026-10-03 (Espryt, Chrome on webglsamples aquarium; % of one core, phone = all of Android): open-to-desktop cold 7.5-12.5 s (daemon 0.5, server 1.0, container 1.5, kwin ~5, plasmashell ~6-7), after a phone reboot 8.5 s, reopen of a running desktop 0.2 s; visible phone 249% / kwin 13 / chrome 99 / server 57 / GPU 22%, hidden 54 / 0 / 6 / 2 / 0%, locked 38 / 0 / 8 / 3 / 0%. Stop desktop 1.7 s. Wireless adb does not come back by itself after a reboot (re-enable it on the phone). **Stop desktop** (notification action, or `am startservice -n <pkg>/com.anland.consumer.MobileGLWorker -a com.anland.consumer.mobilegl.STOP`) runs `mobilegl-desktop.sh down` (session + container stopped, daemon stays), closes the windows, ends `:mobilegl`.
```sh
adb connect <ip>:<port> # only for wireless debugging
exportMSYS_NO_PATHCONV=1
adb shell 'su -c "sh /data/local/tmp/anl/bringup.sh"'# after a reboot: wake, dismiss keyguard, open the app, print the timeline
`droidspaces` has no `status` command; `droidspaces show` lists running containers. `device/run-plasma.sh` is a developer restart (session + logind user, then the app), needed after a rebuild because KWin does not reconnect to a new server process. `device/desktop-reset.sh` takes everything down to the post-reboot state. The old manual sequence (setprop, start container, `restart-daemon.sh`, `run-plasma.sh`) is gone; `restart-daemon.sh` remains for daemon debugging only.
`device/swap.sh` overwrites `/data/app/.../lib/arm64/libMobileGL.so` keeping owner/mode/SELinux context (original saved as `/data/local/tmp/anland-mobilegl/libMobileGL.so.orig`); no re-signing. To swap without the session restart: push to `/data/local/tmp/anland-mobilegl/libMobileGL.new.so`, `su -c "sh /data/local/tmp/anl/swap.sh"`, then `run-plasma.sh` anyway (KWin does not reconnect to a new server process).
**Client + GBM backend (container glibc) via WSL cross-build**
```sh
bash scripts/xbuild.sh [--reconfigure]# WSL ~/mgl-xbuild-a64; ~150 s full, seconds incremental
Running clients keep the old inode; restart the client, or `run-plasma.sh` for KWin/plasmashell. Build server and client from the **same commit** (both stamp `git rev-parse HEAD`), else the logs WARN "compatible wire with different build". Fallback in-container build (slow): `scripts/sync-src.sh` + anland `mobilegl-tools/anland-build-client.sh`.
**KWin (anland backend / kwin.patch) - built inside the container**
`sync-build-kwin.sh` (anland `producers/kde/Arch_v5/`) copies only the backend sources, then `ninja -j4`, installs `kwin_wayland`, `libkwin.so.6.7.4`, the patched screencast plugin and `/opt/mobilegl/bin/mobilegl-startup.sh` by atomic rename. `kwprep.sh` is what applies changed `kwin.patch` hunks to core KWin files. Repo-root `libdisplay_producer/` is not synced by either. `kwin-stage.sh` is assembled from those two scripts and was not run as one unit yet.
**Unit tests (no device)** - in WSL (the MSVC build of this branch is broken):
`wsl-all.sh` builds and runs every unit test. Never run DirectVulkan integration tests; never two ctest runs at once.
## Switching backends
In the app: Settings > Connection > MobileGL desktop > Renderer backend, then Stop desktop and open the app. Or `adb shell 'su -c "sh /data/local/tmp/anl/switch.sh DirectGLES"'`, which does the same (saves the setting through the launch extra, STOPs the service, reopens; clears a stale `debug.mobilegl.backend`). No setprop, no file to edit in the container: the session copies the published backend into `/etc/mobilegl/backend` on its next start. `cyc.sh` keeps whatever is set.
## System-wide vendor and fallback
Installed by `xdeploy.sh` (and anland's `anland-build-client.sh`), so any process in the container gets MobileGL through the stock libglvnd/libgbm:
| File | Does |
|---|---|
| `/usr/share/glvnd/egl_vendor.d/10_mobilegl.json` | EGL vendor, asked before the system's `50_*.json` |
| `/opt/mobilegl/share/glvnd/egl_vendor.d/50_mobilegl.json` | same JSON, for per-process forcing with `__EGL_VENDOR_LIBRARY_FILENAMES` |
| `/etc/mobilegl/client.conf` | `KEY=value` defaults for every client (transport, data, endpoint); rewritten on deploy |
| `/etc/mobilegl/backend` | the backend (the session's `sync-backend` step copies the app's choice in); wins over a `MOBILEGL_BACKEND_TYPE` line in client.conf |
| `/etc/environment.d/10-mobilegl.conf` | `__GLX_VENDOR_LIBRARY_NAME=mobilegl`, `GBM_BACKEND=mobilegl` for the systemd user manager (all Plasma services and what they start, terminals included) |
| `/etc/profile.d/mobilegl.sh` | the same two for login shells (`bash -l`, `su -l`, ssh, `droidspaces run bash -l`) |
Precedence is environment > `/etc/mobilegl/backend` (backend only) > client.conf > built-in; `MOBILEGL_CONFIG_FILE` / `MOBILEGL_BACKEND_FILE` name other files (empty = off); a server process never reads them.
**Fallback.** Before bringing anything up the client probes the configured endpoint (one non-blocking connect, `MOBILEGL_IPC_PROBE_TIMEOUT_MS`, default 250, 0 = off; a refused abstract socket answers in ~0.2 ms). With no server: the EGL vendor returns `EGL_NO_DISPLAY` and no devices (glvnd moves on to the other vendor), the GLX vendor fails `__glx_Main` (libGLX falls back to the vendor Xwayland names), and `mobilegl_gbm` fails `create_device` (libgbm falls back to the device's own backend). Client queries (`eglQueryString(EGL_NO_DISPLAY)`, `eglGetProcAddress`) never bring a session up. MobileGL also steps aside for a GBM device another backend created. It no longer steps aside for `LIBGL_ALWAYS_SOFTWARE=1` (918d40dd): that is Mesa's software-renderer selection, not a reason to disable MobileGL's entry points, so a process that sets it (a software Xwayland included) is still served by MobileGL while a server answers. MobileGL never opens an X connection to a display its own process serves (`MG_Util/X11/DisplayGuard.h`; `MOBILEGL_X11_DIAL=0` forbids all), which is what made a glamor Xwayland deadlock on itself before. Show it without stopping the real server:
```sh
sed 's|^MOBILEGL_IPC_CONTROL=.*|MOBILEGL_IPC_CONTROL=unix:@nobody|' /etc/mobilegl/client.conf > /tmp/down.conf
MOBILEGL_CONFIG_FILE=/tmp/down.conf eglinfo -B -p wayland # vendor = the other one, ~0.1 s
```
## Test clients and how to verify
X11 on the GPU has its own driver, `scripts/x11-verify.sh` (preflight, measure before/after with glxgears at `vblank_mode=0` on both GLX paths (presentation throughput only: glxgears is fixed-function GL, which MobileGL does not implement, so its window is black by design), deploy-client/-kwin/-server, check, apps, stop); `scripts/device/x11-measure.sh` refuses to load the phone below 40% battery.
Inside the container as root (`printf '...' | bash scripts/ct.sh <chan>`), `mgrun offscreen <cmd>` runs a client as swung0x48 in the live session through a login shell, with nothing but the surface mode and the log path set (log `/tmp/mgl-run.client.log`, override base with `MGLOG=/tmp/x.log`); a plain `env -i ... bash -l -c '<cmd>'` as swung0x48 gets MobileGL the same way:
sleep 8; grep -a "presented through" /tmp/mgl-run.client.log
```
Chrome: `bash scripts/chrome-launch.sh <tag> [flags]` (log `/tmp/mgl-chrome-<tag>.client.log`, screenshot `ch-<tag>.png`; `CHROME_GPU=in-process` puts the GPU back in the browser, which cannot recover from a device loss).
Video: Chrome decodes in hardware. The GPU process's render node has a VA-API driver in the container (`/usr/lib/dri/msm_drm_drv_video.so`, DroidSpaces' V4L2 driver): Chrome allocates its frames with `vaCreateSurfaces` and exports them (`vaExportSurfaceHandle`, one fd, R8 + GR88 layers) - the driver's own buffers, never GBM ones - and imports each as an NV12 (P010 for 10-bit) EGLImage bound to `GL_TEXTURE_EXTERNAL_OES`. MobileGL takes such a FOREIGN YUV dma-buf through its labelled CPU-copy fallback: the server maps it and copies it into a YUV AHardwareBuffer of its own (`AHardwareBuffer_lockPlanes`) at the reader's first use in each frame (any present, publishing flush or native fence), then converts that into the texture's RGBA level on the GPU (Espryt: `GL_EXT_YUV_target` with the import's colour hints, `MOBILEGL_YUV_DRIVER_CONVERSION=1` for the driver's external sampler; Magma: `VkSamplerYcbcrConversion` on the AHB's external format). A server-allocated NV12/P010 buffer (GBM `gbm_bo_create(NV12)`) needs no copy. `MOBILEGL_CHROME_VIDEO_DECODE=software` in `mobilegl-startup.sh chrome` brings back `--disable-accelerated-video-decode`. Check a video with `scripts/probes/cdpmedia.py <port> <s>` in the container (CDP `Media.enable`: `kVideoDecoderName = VaapiVideoDecoder`, `kIsPlatformVideoDecoder = true`; `CDP_NAVIGATE=<url>` / `CDP_EXPR=<js>`), which needs Chrome started with `--remote-debugging-port` and a non-default `--user-data-dir` (a fresh profile also needs `--password-store=basic`, else page loads wait forever on KWallet), and grep Chrome's log for `exit_code` (a GPU-process restart). `scripts/probes/yuvprobe.c` pixel-checks foreign NV12/P010 imports, their per-frame refresh and a GBM NV12 buffer (`mgrun offscreen mgl-yuvprobe`, exit 0 = pass). KWin's own NV12 path (it splits a YUV buffer into R8 + GR88 imports) is not served: MobileGL imports no single-channel dma-bufs, so KWin advertises no YUV format over linux-dmabuf. A vendor-agnostic replacement driver (MobileVA: VA-API on Android MediaCodec, decoding into server YUV images with no CPU copy, the current driver kept as an A/B fallback) is planned, not built: `docs/MobileVA/plan.md`.
Device loss: `setprop debug.mobilegl.inject_device_lost_pid <pid>` (as root) loses the server session of the client whose pid - as it sees itself in the container - is `<pid>`, once per value, on any backend and whether or not it presents; `debug.mobilegl.inject_device_lost_at N` instead loses whichever session makes the N-th device check (Espryt presents and read-backs only). `bash scripts/chrome-recover.sh <in-process|process> [rounds]` checks that Chrome repaints after such a loss without a restart (battery-guarded, stops below 40%). `bash scripts/kwin-recover.sh [rounds]` loses KWin's own session (default twice) and checks that KWin restarts compositing on a fresh session - same KWin, plasmashell and client processes, the screen still animating (glmark2 on Wayland, glxgears on X11, `CHROME=1` adds Chrome) - which needs the anland backend + kwin.patch reset handling deployed (`kwin-stage.sh build`).
Probes: `scripts/probes/texfmt.c` (texture formats keep alpha?), `dmahash.c` (hash a process's dma-bufs), `yuvprobe.c` (YUV dma-buf sampling, `gcc -O1 /root/yuvprobe.c -o /usr/local/bin/mgl-yuvprobe -lEGL -lGLESv2 -lgbm -lm`), `cdpmedia.py` (which decoder a Chrome page uses); build in the container, e.g. `gcc -O1 /root/texfmt.c -o /usr/local/bin/mgl-texfmt -lEGL`, run with `mgrun offscreen`.
GPU hangs: a shader that never ends stops the WHOLE GPU (every process, MobileGL or not) until the kernel resets it - ~2 s, or ~10 s ("Preemption Fault" in dmesg) when a higher-priority context waits; nothing in user space shortens one stall. What the server does is end the guilty session so it cannot hang the GPU again (Magma: event-bracketed submissions + `mgl-gpu-watch`, budget `MOBILEGL_GPU_HANG_BUDGET_MS` / `debug.mobilegl.gpu_hang_budget_ms`, default 2000; Espryt: the driver's GUILTY reset status, which it reports after the ~2 s reset only). Repro: `scripts/probes/hangclient.c` (Wayland + GL 3.3 core; `-H 60 -N 1 -E 60` hangs every 60 frames, `-H -1` is a frame-gap observer; build with wayland-scanner's xdg-shell files, `-lwayland-client -lwayland-egl -lEGL -lOpenGL -lm`), and `gpuprobe.c` (NDK, plain adb shell; `gpuprobe 30 high` is a high-priority GLES context outside MobileGL - it both measures the device-wide stall and forces the 10 s preemption path).
A result counts only if ALL hold:
- client log says `presented through linux-dmabuf shared images` (not `wl_shm`; `could not be presented through a linux-dmabuf shared image` = fell back);
-`bash scripts/shot.sh g 2 1` gives two different hashes while the client animates (a "fast" score can mean frames are not shown);
-`adb logcat -b crash -d` and `adb logcat -d | grep -E "Fatal signal|Abort message|E MobileGL"` are clean (a frozen window is usually a dead server);
- for plasmashell verdicts, the sticky software renderer is off (below) and `/tmp/mgl-plasmashell.client.log` is freshly written.
Reference (glmark2-es2-wayland 1280x720, zero-copy vs wl_shm): Espryt 187 vs 49, Magma 158 vs 34. Rerun any surprising pass.
## Logs
| What | Where |
|---|---|
| KWin client side | container `/tmp/mobilegl-compositor.client.log` |
| Session wait / backend sync | container `journalctl -u desktop-session --no-pager` ("waiting for the MobileGL server ...") |
`MOBILEGL_LOG_FILE_PATH=/tmp/x.log` produces `/tmp/x.client.log` (and `.server.log` for forwarded lines); files are truncated on open, so one base per process. Client builds log at INFO. Symbolize server frames with `$ANDROID_NDK/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-addr2line.exe -Cfe build-android/libMobileGL.so <offset>`.
## Pitfalls
- **pkill -f self-match**: a `ct.sh`/`droidspaces run bash -lc "<script>"` process carries the script in its argv; `pkill -f chrome` kills the runner. Use `pkill -x`.
- **Root-owned /tmp files**: a log created by a root-run client cannot be reopened by swung0x48 later (no log appears). `rm -f` it as root first.
- **Sticky Plasma software renderer**: after any server crash Plasma writes `[QtQuickRendererSettings] SceneGraphBackend=software` into `~swung0x48/.config/kdeglobals`; plasmashell then renders on the CPU and screenshots still look fine (tray icon "正在使用的软件渲染器"). `mobilegl-startup.sh plasma` deletes it at every session start; within a running session that hit a crash, restart the session (`run-plasma.sh`) before any verdict.
- **Lock screen**: autolock is off (`kscreenlockerrc [Daemon] Autolock=false`); a real lock can only be left by typing the password (logind lock/unlock is not supported for this session type - `device/unlock.sh` is unverified). Test the greeter with `/usr/lib/kscreenlocker_greet --testing` and kill it.
- **CRLF**: `core.autocrlf=true` here; anland's backend sources and `sync-build-kwin.sh` are CRLF in the Windows checkout. Strip CR before anything runs on Linux/Android (`tr -d '\r'`; `ct.sh`, `push-tools.sh`, `kwin-stage.sh` do). This skill has a `.gitattributes` forcing LF.
- **Display daemon**: the app starts it; the session's `wait` step keeps KWin from starting without it (a KWin that cannot reach it logs "failed to connect to display daemon at /run/anland-mobilegl/display.sock" and exits in ~3 s). Anything started from the app's `su` must leave the app's cgroup (`echo $$ > /sys/fs/cgroup/cgroup.procs`) or a force-stop kills it.
- **Device power/connection**: low battery or the screen turning off (30 s default) can kill the SurfaceView - `svc power stayon true`; wireless adb `ip:port` changes per pairing - never pin `-s HA27Q3LQ`, use `ANDROID_SERIAL` if several devices are attached.
- **Container vs phone paths**: container `/tmp` is a different place from the phone's `/data/local/tmp`; move files via `/mnt/Droidspaces/arch-kde-mgl/root` (container `/root`) or `/data/local/tmp/anland-mobilegl` (= container `/run/anland-mobilegl`).
- **Never overwrite a mapped .so in place** (container or APK while running): copy to a new inode and `mv -f`; the APK swap force-stops the app first.
- **Input**: plain `adb shell input` is blocked - use `su -c input`; anland grabs the touchscreen, so if `input tap` does nothing use `input mouse tap x y`. Never `debuggerd -b` a container process (signal 35 kills glibc processes).
- **linux-dmabuf**: the client must use `create` + roundtrip, never `create_immed` (KWin answers a failed immediate import with a protocol error that kills the client).
- **Cross-session bugs**: noise / another window's pixels after some client starts or exits = process-global state in the multi-session server (handles are per client, FBO/VAO per context). Repro with `kscreenlocker_greet --testing`.
-`/etc/environment` leaks the baseline session's driver variables through pam_env (and, via `/usr/lib/environment.d/99-environment.conf`, into the user manager); `mobilegl-startup.sh` unsets them - keep that when editing it.
- **Shared log path**: Xwayland inherits KWin's `MOBILEGL_LOG_FILE_PATH`; any line a process logs opens (truncates) that file. MobileGL stays silent in processes it does not serve, but give any extra client its own `MOBILEGL_LOG_FILE_PATH`.
- **Stale per-process overrides**: an `__EGL_VENDOR_LIBRARY_FILENAMES` / `MOBILEGL_*` left in a unit drop-in or the user manager's environment (`systemctl --user show-environment`) still wins over the system files; `run-plasma.sh` (terminate-user) clears the manager's.
-`handoff` mentions a `kwin_wayland` logging shim and `/tmp/mgl-kwin-<pid>.log`: stale - `/opt/mobilegl/kwin/bin/kwin_wayland` is now the real binary and logs to `/tmp/mobilegl-compositor.client.log`.
## House rules
- Work in a git worktree, never in the shared main checkout or its build dirs; worktrees need `GIT_LFS_SKIP_SMUDGE=1`.
- Commit as `CONTRIBUTING.md` describes (`[Type] (Scope): content`, no trailer), with `git commit --no-verify`.
- Never run the DirectVulkan integration tests (`ctest -E DirectVulkan`).
- No external project names ("Mesa", "Zink", ...) in source code or comments; describe the technique.
- Vendor-agnostic only on the zero-copy path: AHB, EGL_ANDROID_*, VK_ANDROID AHB import, sync_file, dma-buf, libgbm ABI - no kgsl/gralloc layouts/QCOM modifiers.
- Identity caches key on monotonic lifetime ids, never GL names or pointers.
# Reproducing the MobileGL + anland + KDE Plasma setup from source
This is the from-scratch path for a NEW development machine and/or a NEW phone. `SKILL.md` covers the
day-to-day loop once everything below exists. Paths and names that are specific to the original
machine are listed in step 9: set them in `scripts/env.sh` (or the environment) before running anything.
## 0. What you need
**Phone.**
- arm64 Android 11+ with **root**. KernelSU is what this was built on; the scripts only need `su -c`.
- A Vulkan 1.1 driver with `VK_ANDROID_external_memory_android_hardware_buffer` for Magma, and/or GLES 3.2 + EGL_ANDROID_native_fence_sync for Espryt. Nothing vendor-specific is used: no kgsl and no vendor ioctls, only AHardwareBuffer, sync_file and the vendor's public GLES/Vulkan.
- About 20 GB free for the container image.
**Development host.**
- The scripts are written for **Windows + Git Bash** with WSL (`archlinux` distro) for the unit tests and the cross build.
- A Linux host works too: drop the `cygpath`/`MSYS_NO_PATHCONV` handling in `env.sh`, and run the WSL parts natively.
1.**Root** with KernelSU (or another `su` provider).
2.**Droidspaces:** install the module and its app. Containers live under `/data/local/Droidspaces/Containers/<name>/`, and the CLI is `/data/local/Droidspaces/bin/droidspaces`.
- Build it from the anland repo with `build_daemon_android.sh`, then package it with `build_magisk_module.sh`, which uses `magisk_module/`. Flash it and reboot.
- The module's `service.sh` starts the original daemon on `/data/local/tmp/display_daemon.sock`.
- The MobileGL session uses a SECOND instance of the same binary on `/data/local/tmp/anland-mobilegl/display.sock`: see `scripts/device/restart-daemon.sh`, which also chmods the directory and socket.
## 3. The container
Use an **Arch Linux ARM (aarch64, glibc) KDE Plasma Wayland rootfs** that has a systemd `desktop-session.service` running the desktop as a normal user with `PAMName=login`. A ready-made one is the Arch KDE "Wayland" image from Droidspaces RootFS Desktop Builder (see anland `doc/UserManual/anland_guide.md`, "Use prebuilt packages or a RootFS"). Or install `plasma-desktop`, `plasma-workspace`, `libglvnd`, `xorg-xwayland`, `base-devel`, `cmake` and `ninja` on plain Arch Linux ARM and write the service yourself.
Import it into Droidspaces as `arch-kde-mgl`. The default name is in `env.sh`. Reference `container.config` keys:
- **Host networking:** it is required. The MobileGL server listens on the ABSTRACT socket `@anland-mobilegl`, which crosses into the container only when the network namespace is shared.
- **The anland-mobilegl directory:** create `/data/local/tmp/anland-mobilegl` on the phone before the first start, mode 777.
- **Desktop user:** the default is `swung0x48` (`MGL_DESKTOP_USER`). Create your own and point `desktop-session.service` at it.
- **Starting it:** `droidspaces -C .../container.config start`. Android root sees the rootfs at `/mnt/Droidspaces/<name>`, which `CT_ROOT` in `env.sh` relies on.
- **Container tools:** install them with `bash scripts/push-tools.sh` once the container runs.
- **Grant root:** the app needs root for its helper; approve it in KernelSU.
- **Reinstall with another signing key:** that changes the UID, so grant root again (anland `mobilegl-tools/ksu_grant.c` does it from a shell). Don't uninstall to fix a signature clash: that wipes the app data.
- **Later server-only changes:** swap just the library into the installed APK (`scripts/cyc.sh` / `device/swap.sh`) instead of rebuilding the APK.
## 5. MobileGL client in the container
Pick one of these two:
- **Cross build** (fast, from WSL or Linux):
1.`bash scripts/xsysroot/refresh-sysroot.sh` exports the container's headers and libraries to `~/sysroots/arch-kde-mgl` and installs `~/sysroots/aarch64-arch.cmake`. Re-run it after container package upgrades.
2.`bash scripts/xbuild.sh`, then `bash scripts/xdeploy.sh`.
- **Native build in the container** (slow, no WSL): `scripts/sync-src.sh` copies the worktree to `/root/mobilegl-unified-src`, then run anland `producers/kde/Arch_v5/mobilegl-tools/anland-build-client.sh` inside (g++, `-DMOBILEGL_BUILD_DISAGGREGATED=ON`, Release).
Either route installs the same files, renamed into place so running clients keep their old mapping:
| File | What it is |
|---|---|
| `/opt/mobilegl/lib/libMobileGL.so` (+ `libEGL`/`libGL` aliases) | the client library |
| `/usr/share/glvnd/egl_vendor.d/10_mobilegl.json` | system-wide EGL vendor; sorts before the distribution's `50_*.json`, which serves whenever MobileGL declines (no server reachable) |
## 6. KWin 6.7.4 with the anland backend, and Xwayland
The MobileGL session runs a rebuilt KWin from `/opt/mobilegl/kwin`, so the packaged one stays untouched.
1.**Source tree:** in the container, unpack KWin 6.7.4 to `/root/mobilegl-build/kwin-6.7.4`, apply anland `producers/kde/Arch_v5/kwin.patch`, and overlay `producers/kde/anland_backend_Arch_v5/src/backends/anland` into `src/backends/anland`. Also take the current `common/` and `libdisplay_producer/`, as `mobilegl.md` there says.
3. **Build and install:** run `producers/kde/Arch_v5/sync-build-kwin.sh` (`ninja -j4`). It installs `kwin_wayland` + `libkwin.so.6.7.4` into `/opt/mobilegl/kwin/{bin,lib}`, the patched screencast plugin in place (the original is kept beside it), and `/opt/mobilegl/bin/mobilegl-startup.sh`.
- From the dev host, `bash scripts/kwin-stage.sh build` does steps 1–3 incrementally: `kwprep.sh` applies changed patch hunks.
4. **Xwayland:** the anland-patched Xwayland 24.1.13 comes from `producers/kde/Arch_v5/build.sh` (makepkg, as a normal user; it builds the anland KWin and Xwayland packages). A prebuilt anland rootfs already has it.
`mobilegl-startup.sh plasma` writes the user drop-in for `plasma-kwin_wayland.service` (the `/opt/mobilegl/kwin` binary, `ANLAND_MOBILEGL=1`, `MOBILEGL_IPC_SURFACE=server`, `GBM_BACKEND=mobilegl`) and runs `startplasma-wayland`.
## 7. Bring it up
Nothing has to be started by hand, and the order no longer matters: **open the app** (its launcher
icon, or `am start -n com.anland.consumer.mobilegl/com.anland.consumer.MainActivity` with no extras).
That is the single trigger. The app starts its foreground service and runs its bundled
`mobilegl-desktop.sh up` as root, which:
1. starts the display daemon if it is not running;
2. publishes the backend (the app setting "Renderer backend", default DirectGLES) to
`/data/local/tmp/anland-mobilegl/backend`;
3. starts the container if it is stopped;
4. starts `desktop-session`, which waits for the server and the daemon socket, copies the backend into
`/etc/mobilegl/backend` and runs Plasma.
Cold start to a desktop takes about 8-12 s. `scripts/device/bringup.sh` wakes the phone, dismisses the
keyguard, opens the app and prints the timeline. To change the backend, use the app setting and then
Stop desktop and reopen, or run `scripts/device/switch.sh DirectGLES|DirectVulkan`. Do not use
`setprop debug.mobilegl.backend`: it overrides the setting until the next reboot. **Stop desktop**
(the notification action) stops the session and the container.
**After a phone reboot:** wireless adb does not come back by itself. Re-enable it on the phone
(Developer options > Wireless debugging), then `adb connect <ip>:<port>`; the port changes. After
that, open the app as above. See SKILL.md "Starting, idling, stopping".
## 8. Verify
- **Plasma:** it appears in the app. The tray must NOT show the "software renderer" icon; if it does, delete `SceneGraphBackend=software` from the user's `kdeglobals` and restart.
- **Plain shell:** in a plain container shell as the desktop user, `eglinfo -B` names MobileGL, and `glxinfo -B` from a login shell does too.
- **Wayland client:** `timeout 25 mgrun offscreen glmark2-es2-wayland -s 1280x720`. The log must say `presented through linux-dmabuf shared images`.
- **Chrome:** `bash scripts/chrome-launch.sh t1` gives windows with correct icons.
- **Resize and lock:** BACK toggles the extra-keys bar (resize); lock/unlock resumes the desktop. `rtest.sh` in the scratch dir checks both.
- **Unit tests:** `bash scripts/wsl-all.sh` in WSL. NEVER run the DirectVulkan integration tests.
These rules keep the code and its history readable. `scripts/check_text_hygiene.py` checks the ones listed under [Checks](#checks); the rest rely on review.
Commands below use `python3`. On Windows, run `python` instead.
## Commit messages
```
[Type] (Scope): content
- Detail 1
- Detail 2
```
- **Subject only by default.** Add `- ` detail lines only when a detail really needs to be in the commit message. Leave one blank line after the subject. A detail that wraps continues on at most two lines indented by two spaces. No paragraphs and no trailers.
- **Content** after `): ` is at most 72 characters of plain English in the imperative mood ("flush once per frame", not "flushes" or "flushed").
- **Type** is one or more of these, comma-separated (`[Fix, Test]`):
-`Feat`: new behaviour.
-`Fix`: corrects behaviour.
-`Perf`: faster or lighter, same behaviour.
-`Refactor`: restructures code, same behaviour.
-`Test`: tests only.
-`Docs`: documentation and comments only.
-`Build`: CMake, Gradle, toolchains.
-`CI`: workflows and CI scripts.
-`Chore`: housekeeping such as headers, file moves and dead files.
-`Tools`: developer tools under `tools/` and `scripts/`.
- **Scope** is each changed path without a leading `MobileGL/`, cut to its first two parts: `MG_Backend/DirectGLES`, `MG_Test/Wire`, `scripts/ci`, `docs/Disaggregated`.
- A shorter path is used whole: `MG_Backend` for a file directly in a subsystem, `scripts` for a file directly in `scripts/`, and the file name for a file at the repository root (`CMakeLists.txt`).
- A file directly in `MobileGL/` uses `MobileGL`, and a submodule uses its path (`3rdparty/glslang`).
- List every scope the commit touches, comma-separated: `(MG_Backend/DirectGLES, MG_Test/Wire)`.
-`python3 scripts/check_text_hygiene.py scope` prints the scope for the staged changes.
- **No** planning ids (phase or decision codes such as `P13` or `ID-54`), local paths, external project names or non-English text anywhere in the message.
- **Merges** use `[Merge] (area): what the merge takes`. The scope is free text there. Write the subject yourself, since git's default merge message fails the check. The commits a merge brings in are not checked again.
- **Reverts** use `[Fix] (Scope): revert <the reverted subject>`.
Example:
```
[Perf] (MG_Backend/DirectGLES): flush once per frame before the window pass
```
## Comments, strings and documentation
- Code, comments, log strings and commit messages are in English. Documentation may be in Chinese or English.
- A comment states what the code does now and why, in a few lines. Rationale longer than about 15 lines belongs in a design document under `docs/`, with a one-line pointer in the code.
- In code and comments, cite a symbol, file or section, never a line number: `ARCHITECTURE.md §1.3`, `VulkanRenderer::BeginFrame()`. Documents may keep `file:line` citations; the documentation citation check resolves them.
- State the rule, not its history. No "previously" or "after review", and no planning ids in comments, strings, identifiers or file names.
- Plain wording, without ALL-CAPS emphasis.
- In code and comments (C, C++, GLSL, CMake, shell, Python, YAML), do not name external projects such as Mesa, Zink or gallium; describe the technique instead. Naming the driver or hardware where a behaviour was observed (llvmpipe, ANGLE, Adreno, Mali) is fine, and so are file, package and extension names that contain these words (`50_mesa.json`, `GL_MESA_*`).
## Code
- C and C++ sources are formatted with `.clang-format`, using clang-format 23. Install it with `pip install 'clang-format==23.1.3'`. `git clang-format <base>` from the same package formats the lines changed since `<base>`, and `scripts/format_code.sh` formats every file the check covers. The tree is already formatted, so neither touches lines you did not change.
- The commit that reformatted the whole tree is listed in `.git-blame-ignore-revs`. Run `git config blame.ignoreRevsFile .git-blame-ignore-revs` once so that `git blame` looks past it.
-`.inc` files are fragments included in the middle of a `.cpp` file, inside a namespace, or are generated tables. clang-format cannot format them on their own, so the check skips them. Match the indentation of the file that includes them. The check also skips generated files and the GL export table, `MG_Impl/GLImpl/Exporting/Definitions.cpp`.
- Text files are UTF-8.
- These source files under `MobileGL/`, `scripts/` and `tools/` start with the license header:
- C, C++, `.inc`, GLSL, Java and JavaScript modules, with `//` comments;
- Python, shell, CMake modules and `CMakeLists.txt`, with `#` comments.
- Generated files and files under another license are exempt. `python3 scripts/update-source-file-header.py` adds the header.
- Keep files to about 3,000 lines and functions to about 300 lines.
- Modules are layered. A file includes files of its own layer and of the layers below it, never above:
1. the root headers (`Includes.h`, `Config.h`), `MG_Util` and `MG_State`;
2.`MG_Pipe`;
3.`MG_Backend`, `MG_Remote` and `MG_Impl`;
4. the root `.cpp` files and `MG_Gbm`;
5.`MG_Test`, `MG_IntegrationTest` and `MG_Benchmark`.
- Each backend is isolated. Files under `MG_Backend/DirectGLES/` or `MG_Backend/DirectVulkan/` are included only by the same backend, by the registry files directly in `MG_Backend/`, by the root `.cpp` files and by tests.
- Platform-specific APIs stay behind an abstraction, never raw in logic code.
- A fix that restores intended behaviour ships unconditionally. Add a switch only for a real trade-off.
- A cache keyed on object identity uses a monotonic lifetime id, never a GL name or a pointer, because both are recycled.
## What goes in the repository
- Source, tests, build files and maintained documentation.
- Generated code, only together with a CI step that regenerates it and fails on any difference.
- Binary test fixtures.
- Not raw logs, and not working papers such as agent briefs, review rounds and drafts. Keep those outside the repository and commit the conclusion: a design-document change, a `DEBTS.md` entry, a measurement under `docs/Performance`.
-`docs/archive/` keeps the planning notes and agent contracts of the disaggregation work as they were. It is frozen: no file there is added, edited or removed.
## Checks
`python3 scripts/check_text_hygiene.py ci` runs what CI runs. It checks the commits on the branch's own line of history since the check was added. The base branch it compares with is `feat/disaggregated`, which you can change with `TEXT_HYGIENE_BASE_BRANCH`.
These fail on any violation:
- commit messages, as described above;
- a new file under `docs/Disaggregated/notes/` or a new `.log` file (moving a file is not adding it);
- any change under `docs/archive/`;
- a missing or wrong license header;
- a text file that is not UTF-8;
- an external project name in a comment;
- a `file:line` citation in code that points past the end of the file;
- changed lines that clang-format would change (a line moved unchanged, the same commit removing its exact text elsewhere, is not a changed line).
These are ratchets: each total may only go down.
- planning ids in comments;
- comment lines beyond 15 in a block;
- ALL-CAPS comment lines;
- non-English comment lines;
- line-number citations in code.
`python3 scripts/check_text_hygiene.py tree --update-baseline` lowers `scripts/data/text_hygiene_baseline.json` after a cleanup. It never raises it, and CI rejects a raised baseline.
`python3 scripts/check_structure.py ci` checks three more ratchets over the C and C++ sources under `MobileGL/`, against `scripts/data/structure_baseline.json`:
- includes that break the layering or the backend isolation above;
- lines beyond 3,000 in a file;
- lines beyond 300 in a function body.
`python3 scripts/check_structure.py report` lists what each total counts, and `tree --update-baseline` lowers the baseline the same way.
Not checked by CI: the platform, switch and lifetime-id rules, planning ids outside comments, English outside comments, and the regeneration step for generated code.
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.