Skip to content

Version 2.0.0 - #53

Merged
BZLZHH merged 126 commits into
mainfrom
dev
Aug 9, 2026
Merged

Version 2.0.0#53
BZLZHH merged 126 commits into
mainfrom
dev

Conversation

@BZLZHH

@BZLZHH BZLZHH commented Aug 9, 2026

Copy link
Copy Markdown
Member

No description provided.

BZLZHH added 30 commits July 19, 2026 09:33
…ilent index truncation

The CPU base-vertex rebase wrote (index + basevertex) back into the source
index width, truncating it to mod 65536 / mod 256. GL 4.6 sec. 10.5 evaluates
that sum in the full vertex index space, so the common "one large vertex
buffer + 16-bit indices + large basevertex" layout rendered wrong geometry
with no error and no log. The rebased stream is now always widened to
GL_UNSIGNED_INT.

Eight GL 4.6 core entry points were exported no-op stubs, which compiled to an
empty body in release builds: an application calling them lost its geometry and
saw GL_NO_ERROR. glMultiDrawArrays and glMultiDraw{Arrays,Elements}Indirect are
now implemented -- the GLES backend for the latter was already being used
internally by the multiindirect mode but was never exposed -- and the
*IndirectCount pair reports once instead of vanishing silently.

Correctness:
- Widen every CPU rebase to 32-bit indices (multidraw.cpp, drawelements mode).
- Handle GL_PRIMITIVE_RESTART_FIXED_INDEX: the sentinel is not offset by
  basevertex and is re-emitted as the 32-bit sentinel, but only when restart is
  actually enabled -- 0xFFFF is an ordinary vertex index otherwise.
- Fall back from the compute path when restart is enabled, when the primitive
  mode is not separable, or when a sub-draw count is not a whole number of
  primitives; fusing would otherwise join primitives across sub-draws.
- Stop rejecting indices[i] == 0 in the compute path. An element array buffer is
  bound at that point, so zero is a legal byte offset -- and it is the offset of
  the first sub-draw of every arena-style batch, which meant falling back to the
  slowest path on nearly every frame.
- Fall back instead of truncating on a misaligned index offset.
- Hoist the index-type switch out of the per-sub-draw loop, where its default
  branch abandoned the rest of the multi-draw.
- Do not draw a sub-draw whose base vertex cannot be applied; wrong geometry is
  worse than missing geometry.

State and lifetime:
- Restore the indexed SSBO bindings after the memory barrier and before the
  application's draw. They are context state, so the scratch buffers were
  visible to the application's own shader during the draw, and a writable block
  declared on those bindings could corrupt the index buffer being consumed.
- Preserve glBindBufferRange offset/size when restoring instead of widening
  every sub-range binding to the whole buffer.
- Invalidate all cached GL object names when the current EGL context changes.
- prepare_indirect_buffer now returns a status: the map result is checked, the
  tracked capacity is only recorded once the allocation is verified and is
  cleared on failure, and every caller falls back instead of drawing from an
  unwritten command buffer.
- Clamp negative counts before they reach the unsigned command field, where they
  became ~4.29e9-index draws.
- Require a bound element array buffer on the indirect paths.
- Latch compute and native failures so a rejecting driver costs one draw rather
  than a full pipeline rebuild on every call.
- Replace the per-sub-draw glGenBuffers/glDeleteBuffers with one persistent
  scratch buffer; move g_prefix_sum and the shader info log off globals.

Capability selection:
- AND every capability with its resolved entry point. The loader uses a plain
  dlsym, so a driver can advertise GL_EXT_multi_draw_indirect while the symbol
  is absent, which meant a null jump on the first frame.
- Add the missing PreferMultidrawIndirect case, which silently fell into the
  Auto branch and logged the wrong mode.
- Validate Compute against ES 3.1 and the shader storage block limit instead of
  accepting it unconditionally; reduce the compute shader from five shader
  storage blocks to four, the GLES 3.1 guaranteed minimum.
- Add a NativeMultiDraw mode backed by glMultiDrawElementsBaseVertexEXT, which
  was already loaded but had no call site. It is ordered after
  MultidrawIndirect in Auto so devices that already used that path are
  unaffected, and its first call is probed because the extension's multi-draw
  form is conditional on EXT_multi_draw_arrays, which is not tracked.

Diagnostics: the CHECK_GL_ERROR family and LOG_D/LOG_W/LOG_E compile to nothing
in release builds, so every fallback in this file was invisible. Fallbacks now
report once through LOG_W_FORCE, and error probes that drive a fallback decision
read glGetError directly.

Verified by compilation and by host-side tests of the rebase and prefix-sum
search; no runtime or on-device verification was performed.
mg_native_multidraw kept its "already probed" flag in a function-local static
while the matching failure latch lived in a file-scope global that
multidraw_check_context() clears on every context change. Only one of the two
was reset.

On a driver whose glMultiDrawElementsBaseVertexEXT is a stub, that meant: the
first context probed, failed, latched, and correctly fell back; the application
then destroyed and recreated its EGL context; the latch was cleared but the
probe was not re-armed, so every later call issued the stub entry point and
reported success unconditionally. The caller therefore stopped falling back and
the geometry was silently lost for the rest of the process, with no log left --
the one-shot warning had already been spent on the first context.

Both flags are now one native_state_t tri-state, so they cannot drift apart
again.

Also in the native path:
- Fall back to the MultidrawIndirect implementation instead of the unrolled
  loop. init_settings_post already declares MultidrawIndirect to be the next
  rung below Native, and it still folds the batch into one driver call; dropping
  straight to N draw calls threw that away. The handover happens before the
  batch is validated and prepared so the permanent cost is one pass, not two.
- Route glMultiDrawElements in Compute mode through the same path. There is no
  base vertex to rebase on that entry point, so the compute pipeline has nothing
  to do, but folding the batch is still worth doing -- unrolling made Compute
  strictly slower than MultidrawIndirect on devices that support both.
- Reuse a thread_local zero array for the synthesised base vertex parameter
  instead of heap-allocating one on every call.

The fallback graph remains acyclic: compute -> native -> multiindirect ->
indirect -> drawelements.
One "multidrawMode" key decided every multi-draw entry point at once, which
forced combinations that mean nothing. glMultiDrawElements has no base vertex,
so PreferBaseVertex, Compute and DrawElements were three names for one unrolled
loop there -- and dump_settings_string advertised them as three strategies.
glMultiDrawArrays and the two *Indirect entry points ignored the key entirely.

Each entry point that has more than one implementation now gets its own key:

  multidrawModeArrays              glMultiDrawArrays
  multidrawModeElements            glMultiDrawElements
  multidrawModeElementsBaseVertex  glMultiDrawElementsBaseVertex
  multidrawModeArraysIndirect      glMultiDrawArraysIndirect
  multidrawModeElementsIndirect    glMultiDrawElementsIndirect

The values are backend NAMES ("auto", "unroll", "basevertex", "indirect",
"multiindirect", "native", "nativeext", "compute") rather than indices. Names
remove the two problems the shared integer had: a value that is meaningful for
one entry point but not another, and a value space that cannot grow without
repointing something a user already wrote into config.json. Each entry point
declares which backends are a distinct implementation for it, and a value
outside that set is rejected with a message saying why, then treated as absent.

multidrawDisableBackends takes a comma-separated list and clears those backends
everywhere before resolution, so a report of "this backend is broken on this
GPU" can be worked around with one key instead of guessing a strategy per entry
point. A disabled backend is indistinguishable from one the driver lacks, so it
degrades through the same ladder.

glMultiDraw*IndirectCount get no key: they have exactly one implementation.

Also adds GL_EXT_multi_draw_arrays / GL_ANGLE_multi_draw detection. Those entry
points are the exact GLES equivalents of the GL 1.4 core commands -- one driver
call, no command buffer to build -- but the GLES loader does not carry them.
They are resolved from the `gles` library handle rather than through
eglGetProcAddress, because this layer's eglGetProcAddress forwards to
glXGetProcAddress, which resolves against RTLD_DEFAULT and would hand back
MobileGlues' own glMultiDrawArraysEXT -- an alias of glMultiDrawArrays -- and
recurse until the stack ran out.

Nothing reads the new fields yet; multidrawMode still drives behaviour and now
logs a deprecation notice. Emitted GL calls are unchanged.
… keys

The two glMultiDrawElements* dispatchers now read the backend resolved for their
own entry point, and multidrawMode is no longer read at all: multidraw_mode_t
and global_settings.multidraw_mode are gone. A config that still sets the old
key gets one message naming the replacements; the entry points fall back to
auto, which resolves to the same backend the old Auto did.

handle_multidraw_func_name becomes per-name. It used to mangle both names with
the one shared mode; it now looks up the backend of whichever entry point was
asked for, so the symbol glXGetProcAddress hands to the application and the
function the dispatcher would have called can no longer disagree. The suffix
table lives next to the backend table in config/settings.cpp for that reason,
and its default branch returns the plain name -- resolving to the dispatcher --
rather than a symbol that does not exist.

Adds the NativeExt backend for glMultiDrawElements, backed by
glMultiDrawElementsEXT. It has exactly the signature and semantics of the GL 1.4
core command, so it is one driver call with no command buffer to build and no
synthesised base vertex array, which the Native backend needs. Same
probe-and-latch as Native, and the latch is reset on a context change with the
others.

NativeExt is deliberately absent from the glMultiDrawElementsBaseVertex mask:
EXT_multi_draw_arrays carries no base vertex, so there is no such
implementation, and the missing mg_glMultiDrawElementsBaseVertex_nativeext
symbol is unreachable by construction rather than by luck.

dump_settings_string now prints the resolved backend per entry point plus any
disabled backends, replacing the single line that claimed BaseVertex, Compute
and DrawElements were three different strategies for glMultiDrawElements.
glMultiDrawArrays was a hardcoded unrolled loop that consulted no setting at
all, so a batch of 300 sub-draws cost 300 driver calls on every device and in
every mode. It now selects a backend like the other entry points:

  nativeext      one glMultiDrawArraysEXT
  multiindirect  one glMultiDrawArraysIndirectEXT over a built command buffer
  unroll         the previous loop

GLES.glMultiDrawArraysIndirectEXT has been loaded all along and had exactly one
call site, forwarding the application's own buffer inside
glMultiDrawArraysIndirect; it never accelerated glMultiDrawArrays.

The command buffer gets its own scratch buffer and capacity counter.
DrawArraysIndirectCommand is 16 bytes against the element command's 20, and the
counters are in commands rather than bytes, so sharing one buffer would let an
Arrays call "grow" it enough for an Elements call to skip its own resize and
then map past the end of the store.

Guards on the multiindirect path, each falling back to the unrolled loop rather
than reporting an error, because the loop is legal where the indirect draw is
not:
- a vertex array object must be bound, which GLES requires for indirect draws
  and which also implies every enabled array is buffer-backed;
- transform feedback must not be active-and-unpaused;
- first[i] and count[i] must be non-negative. first is a GLuint in the command,
  so a negative value would become a ~4.29e9 vertex offset rather than an error.

Both native backends probe their first call and latch off if the driver rejects
it, like the Elements side.

Also re-arms those latches properly: every entry point that reads one now calls
multidraw_check_context() before the read. The two Elements native entry points
were reading theirs before mg_multidraw_enter() ran the check, so the first call
after a context change saw the previous context's verdict.
… on the GPU

Both entry points drew nothing and logged once. They are GL 4.6 core, and
customGLVersion goes up to 46, so an application is entitled to call them.

The draw count lives in the buffer bound to GL_PARAMETER_BUFFER and is normally
written by the GPU -- that is the entire point of the command. Reading it back
on the CPU would stall the pipeline every frame, and reading it without a fence
would take a stale value and draw the wrong number of commands.

So the compaction happens on the GPU: a compute shader copies maxdrawcount
commands into a scratch buffer and writes instanceCount 0 into every command at
or past the real count. A command with instanceCount 0 draws nothing, so one
glMultiDraw*IndirectEXT over the scratch buffer produces exactly the requested
draws, with no readback and no stall. Where EXT_multi_draw_indirect is missing,
walking the compacted buffer with glDraw*Indirect gives the same result one call
at a time.

Drawing maxdrawcount commands unchanged would not have been a valid shortcut:
the slots past the count hold stale or zeroed commands, and rendering them puts
back exactly the geometry the application just culled.

The count read in the shader is clamped to maxdrawcount. The application
promises count <= maxdrawcount, but the parameter buffer is ordinary memory and
a corrupt value must not make the copy read past the end of the source commands.

gl/buffer.cpp now tracks GL_PARAMETER_BUFFER. binding_target_to_index() did not
know the target, so set_bound_buffer_by_target() discarded the binding and
glBindBuffer forwarded 0x80EE to a driver that does not recognise it. The
binding is recorded and the backing object created, and the target is not
forwarded.

Known limitation: filling the parameter buffer THROUGH the GL_PARAMETER_BUFFER
target still will not work, because glBufferSubData is a direct passthrough in
gl_native.cpp rather than a wrapper here. Fill it as a shader storage buffer or
via GL_COPY_WRITE_BUFFER, which is what a GPU-driven renderer does anyway.

compile_compute_program is now generic. It had grown a lookup of uElementSize
and returned failure when that uniform was absent, which would have rejected
every other shader; the index-fusion path does that lookup itself.
glMultiDrawArraysIndirect and glMultiDrawElementsIndirect never read the keys
added for them. Both resolved a backend, logged it and printed it in the
settings dump, while the entry points themselves still branched on driver
capability alone -- the two keys had no effect on rendering at all.

Findings from the review of the preceding four commits:

find_bound_buffer() takes a *_BINDING query enum, not a target, so
find_bound_buffer(GL_PARAMETER_BUFFER) fell through to default and returned 0
every time. The parameter buffer therefore always looked unbound and BOTH
glMultiDraw*IndirectCount entry points bailed out before the compaction ran:
the shader, the scratch buffer, the barrier and the draw had never executed
once. The lookup now takes GL_PARAMETER_BUFFER_BINDING and find_bound_buffer
knows it.

Deleting a buffer left the parameter buffer slot pointing at it. Ids are
recycled by gen_buffer(), and that slot is the only source of truth for where
the draw count lives -- there is no driver-side binding to cross-check it
against -- so a stale slot could silently designate somebody else's buffer.

The compaction saved GL_SHADER_STORAGE_BUFFER_BINDING *after* the scratch
sizing block had already bound and unbound the scratch buffer, so it recorded 0
and then restored that, clearing the application's binding. Every piece of state
the dispatch takes over is now captured before anything is touched.

The scratch buffer was reused in place, so the next call's compute writes raced
the previous draw's command fetch. It is now re-specified unconditionally, the
same invariant the index fusion path documents and relies on.

Neither the source commands nor the draw count were bounds-checked, and an
out-of-range SSBO access is undefined in GLES rather than an error. maxdrawcount
is allowed to exceed the number of commands actually stored, so both buffers are
now measured before the dispatch. The scratch size is accounted in size_t: the
GLsizei comparison could wrap negative and skip the allocation entirely.

multidrawDisableBackends only applied during resolution. Every runtime fallback
chain in gl/multidraw.cpp branched on the function pointer alone, so disabling a
backend still let a chain hand control straight to it -- which is the one thing
the key exists to prevent. All ten fallback points consult md_backend_disabled()
now, and the two Elements multiindirect implementations also apply the extension
string gate that resolution uses, since they are reachable as fallbacks without
resolution having vetted them.

Smaller ones: ladder exhaustion returned Unroll even for the two *Indirect
entries, whose value sets do not contain it; indirect_arrays skipped the ES 3.1
gate its Elements counterpart applies; on Apple the extension resolution would
have gone through a dlsym pseudo-handle and found MobileGlues' own alias;
compile_compute_program builds two programs but reported both as the fusion
shader through one shared warning latch; a trailing space in
multidrawDisableBackends produced a bogus "not a backend name" line; and the
count clamp in the shader was a no-op whose comment claimed it bounded the
reads.
…ions that provide them

GLES core has no multi-draw command at all. Not even 3.2, which added the
singular glDrawElementsBaseVertex and no multi form. Every batched backend in
this file therefore comes from an extension, so calling one of them "native" and
another "nativeext" claimed a distinction that does not exist -- and the one
named "native" was the extension entry point, not a core one.

  native     -> multibasevertex   glMultiDrawElementsBaseVertexEXT
                                  (GL_EXT/OES_draw_elements_base_vertex)
  nativeext  -> multiarrays       glMultiDraw{Arrays,Elements}EXT
                                  (GL_EXT_multi_draw_arrays / GL_ANGLE_multi_draw)

The real distinction is which extension supplies the entry point and whether its
signature carries a base vertex, which the new names say directly. Ordering the
enum unroll / basevertex / indirect / multiarrays / multibasevertex /
multiindirect also makes the cost visible: the first three issue one driver call
per sub-draw, the last three issue one for the whole batch.

Renames the config values, the md_backend_t enumerators, the mg_* symbol
suffixes glx/lookup.cpp generates, the probe latches, and every comment and log
message that described these as native. native_state_t becomes md_probe_state_t,
since it never had anything to do with nativeness -- it tracks whether the first
call to an extension entry point was accepted.

The values are only reachable through the new per-entry-point keys, which have
not shipped, so nothing in the field refers to the old spellings.
LOAD_EGL was an unsynchronised double-checked initialisation. Both statics were
constant-initialised, so the compiler emitted no guard, and nothing ordered the
write of the function pointer against another thread's read of the flag: two
threads entering any EGL wrapper for the first time could see first == false
with the pointer still null. EGL is explicitly per-thread, so concurrent first
entry is ordinary, not exotic.

The initialiser is now a lambda call. Dynamic initialisation of a function-local
static is thread-safe by the language rule, and the object file confirms the
compiler emits __cxa_guard around it.

Resolution failures were also invisible and then fatal: the warning used LOG_W,
which compiles to nothing in release builds, and the caller went on to jump
through the null pointer regardless. Failures now report through LOG_W_FORCE,
and LOAD_EGL_OR gives the six platform and EXT entry points -- the ones a
backend is genuinely allowed not to export -- a way to fail as EGL_NO_DISPLAY or
EGL_NO_SURFACE with EGL_BAD_PARAMETER instead of crashing.

The trailing semicolon lives inside the macro: the previous shape ended in a
block, so none of the 56 call sites carry one.
Six GL 4.6 enable capabilities that GLES 3.2 core lacks are available on some
devices through an extension that uses the SAME enum value as the desktop one:

  GL_EXT_multisample_compatibility  GL_MULTISAMPLE, GL_SAMPLE_ALPHA_TO_ONE
  GL_EXT_clip_cull_distance         GL_CLIP_DISTANCE0..7
  GL_EXT_depth_clamp                GL_DEPTH_CLAMP
  GL_EXT_sRGB_write_control         GL_FRAMEBUFFER_SRGB
  GL_NV_polygon_mode                GL_POLYGON_OFFSET_LINE / _POINT
  GL_OES_sample_shading             GL_SAMPLE_SHADING before ES 3.2

Because the values match, glEnable currently succeeds on a device that exposes
the extension and fails with GL_INVALID_ENUM on one that does not -- the same
call behaving differently per device, with nothing in the layer able to tell the
two apart. g_gles_caps tracked none of them.

Tracking them is a prerequisite for the enable state table: a capability the
driver really supports must keep being forwarded rather than becoming a value
the layer merely remembers.

GL_EXT_multi_draw_arrays is deliberately not a member here. glext.h defines a
macro of exactly that name, so it cannot be a struct field, and gl/multidraw.cpp
already probes it lazily because it needs the entry points as well as the
string.
…ate table

glEnable, glDisable, glIsEnabled and the indexed forms were bare passthroughs.
GL 4.6 has 28 enable capabilities and 13 of them do not exist in GLES 3.2 core,
so those 13 reached the driver as GL_INVALID_ENUM and were simply lost.

Worse, the five state queries contradicted each other. glIsEnabled owes GL_FALSE
on error, glGetBooleanv/glGetFloatv/glGetIntegerv leave their output untouched
on error, and glGetDoublev was a no-op stub that never wrote params at all -- so
the same capability answered three different things in the same frame, one of
them being whatever was on the caller's stack. glGetError always reports
GL_NO_ERROR, so nothing gave the application a hint.

All of it now goes through gl/enable.cpp. The table records every capability;
whether the driver is also told is a property of the capability rather than an
accident of which enum it happens to recognise:

  14 forwarded always   GLES 3.2 core knows the enum
   7 forwarded if the extension detected in the previous commit is present --
     those use the same enum value as the desktop capability, so a device that
     has the extension keeps getting the real behaviour
   6 recorded only      GL_COLOR_LOGIC_OP, GL_LINE_SMOOTH, GL_POLYGON_SMOOTH,
                        GL_PRIMITIVE_RESTART, GL_PROGRAM_POINT_SIZE,
                        GL_TEXTURE_CUBE_MAP_SEAMLESS

GL_CLIP_DISTANCE0..7 is a contiguous enum range rather than one value and is
held as a bitmask. GL_BLEND is tracked per draw buffer, and setting the scalar
form sets every draw buffer, as the specification requires.

GL_MULTISAMPLE is specified to start enabled, but without
GL_EXT_multisample_compatibility the application cannot turn multisampling off
anyway -- the EGL config decides it -- so claiming GL_TRUE on a single-sampled
framebuffer would just be a lie in the other direction. It is seeded from
GL_SAMPLES and tracked from there, so enable/disable still round-trips.

GL_FRAMEBUFFER_SRGB is recorded and forwarded where
GL_EXT_sRGB_write_control exists. Making it genuinely take effect otherwise
would mean injecting EGL_GL_COLORSPACE_SRGB_KHR at surface creation, which is a
one-time decision that cannot answer a later glEnable; that is deliberately not
done.

No GL error is ever raised for a bad capability or index, because glGetError
must keep answering GL_NO_ERROR. An unusable argument means the call does
nothing and one forced line reaches the log.

glGetDoublev is now implemented rather than stubbed. The ARB spellings of the
indexed forms are kept as aliases: the NATIVE_FUNCTION macro used to generate
them, and glXGetProcAddress is a bare dlsym, so a vanished symbol would have
become a silently null function pointer.
GL 4.6 has two restart features and GLES only has one of them.
GL_PRIMITIVE_RESTART_FIXED_INDEX restarts on the largest value the index type
can hold and forwards unchanged. GL_PRIMITIVE_RESTART restarts on whatever
glPrimitiveRestartIndex chose, and GLES has no equivalent at all -- the enum was
rejected, glPrimitiveRestartIndex was a no-op stub, and a strip that should have
been cut into pieces was drawn as one continuous run across the whole scene.

The custom form is emulated by rewriting the index stream: every index equal to
the application's value becomes 0xFFFFFFFF, the stream is drawn as
GL_UNSIGNED_INT, and the driver's fixed-index restart is switched on for the
duration of the draw and switched back after.

Widening to 32 bits is what makes the substitution unambiguous. An 8- or 16-bit
source cannot contain 0xFFFFFFFF, so no ordinary index can be mistaken for a
restart. A 32-bit source could in principle, but that would mean addressing
vertex 4294967295.

Covered: glDrawElements, glDrawElementsBaseVertex, glDrawElementsInstanced and
the multidraw CPU rebase path. glDrawRangeElementsBaseVertex and the other bare
passthroughs in gl_native.cpp are not, and neither is the compute fusion path,
which now declines any restart mode rather than only the fixed one.

An application that picks exactly the fixed value for its type gets the driver's
own implementation with no rewrite, and an index buffer that cannot be read back
declines the rewrite and draws without the restarts rather than inventing
geometry.

The three GLES.glIsEnabled(GL_PRIMITIVE_RESTART_FIXED_INDEX) queries in
gl/multidraw.cpp now read the virtual table instead. They have to: the custom
form is invisible to the driver, and this emulation toggles the driver's
fixed-index flag behind the application's back, so the driver is no longer a
truthful source for what the application asked for. The CPU rebase also takes
the real restart value rather than assuming the type's fixed sentinel.

The scratch index buffer is dropped when the current context changes, alongside
the multidraw ones.
…nt hook

eglMakeCurrent was a five-line passthrough, so MobileGlues had no way to observe
a context switch at all. Everything GL scopes to a context therefore became a
process-global singleton, and gl/multidraw.cpp had to poll eglGetCurrentContext()
on every draw to approximate the event it could not see.

MGContext is created by eglCreateContext, kept by a reference count instead of
being dropped when eglDestroyContext returns -- GL permits destroying a context
that is still current, so the record has to outlive that call -- and pointed at
by a thread-local current pointer that only eglMakeCurrent writes, matching EGL's
own per-thread scoping. MGShareGroup is created per context and shared with any
context created against it, which is where the objects GL shares across a share
group will live.

Identity is a monotonic id, never the EGLContext pointer. EGLContext is a driver
heap allocation, so destroying one and creating another frequently returns the
same address; the existing polling in gl/multidraw.cpp compares addresses and
therefore reports "same context" for one that never owned the cached objects.

The enable state table from the previous commits is the first resident: it is
per-context by definition, so two contexts no longer share one set of enable
flags. A context this layer never saw created -- the bootstrap probe context, or
one made before the library was loaded -- leaves the current pointer null and
every consumer on its fallback, rather than inventing a record with made-up
attributes.

Everything else is still global. Buffer name mapping, texture metadata,
framebuffer tables, gl_state and the multidraw scratch objects will move in the
last step; until then multiple contexts still share those.

Also fills in the queries the enable table owns: GL_MAX_CLIP_DISTANCES, which
GLES has no answer for and which used to leave the caller's variable
uninitialised, GL_MAX_VIEWPORTS, reported as 1 because viewport arrays are
stubs, and GL_MAX_DRAW_BUFFERS, clamped to what the table can track so an index
the application was told it could use is never rejected.
The desktop context gate required the requested version to EQUAL customGLVersion
and every flag attribute to be EGL_FALSE. Both are stricter than EGL, and both
break the loaders this layer exists to serve: LWJGL and GLFW ask for 3.2 core,
which against the default customGLVersion of 4.0 returned EGL_BAD_MATCH and no
context at all, and GLFW sets GLFW_OPENGL_FORWARD_COMPAT and
GLFW_OPENGL_DEBUG_CONTEXT alongside it. Before the gate existed these attributes
were silently ignored; adding it turned them into a hard failure.

The version test is now "no higher than configured", which is what EGL and GLX
allow -- a context of any version at least as high as the one requested is a
valid answer.

Debug, forward-compatible and no-error are accepted. They describe what the
context promises the application rather than something the backend must
implement: a forward-compatible core context simply must not expose deprecated
functionality, which this layer does not expose anyway, and debug output is a
real GLES 3.2 capability. Robust access is still refused, because
EXT_robustness is a behavioural guarantee about out-of-range accesses and
claiming it unchecked would be a promise the layer cannot keep.

The flags that were accepted are recorded on the context and reported back
through glGetIntegerv(GL_CONTEXT_FLAGS), which returned a hardcoded 0. The
version is reported per context too, so an application that asked for 3.2 is
told 3.2 rather than the configured maximum.
…s, query strings

eglGetProcAddress forwarded every name to glXGetProcAddress, which resolves
against RTLD_DEFAULT. An EGL name could therefore be answered by the system
libEGL before it ever reached this layer, handing the application a driver entry
point that bypasses the context records, the virtual attributes and the surface
bookkeeping. Under ANGLE it is worse than a bypass: the handles that entry point
receives belong to a different EGL implementation than the one holding them.
EGL names now resolve through an explicit table of this layer's own wrappers,
and an EGL name not in it goes to the backend's own eglGetProcAddress rather
than RTLD_DEFAULT. GL names still go through glXGetProcAddress, which applies
the multi-draw backend mangling.

The seven EGL 1.5 sync and image entry points were never exported, so they
always resolved to the driver. They are exported as passthroughs now -- there is
no desktop-versus-ES semantics to translate -- purely so the frontend owns them.

eglQueryString(EGL_EXTENSIONS) wrote into one thread_local string and returned
c_str(). The next query for any display overwrote it in place, which can
reallocate, leaving the caller holding freed memory -- and keeping that pointer
is the documented contract, the string belongs to EGL and lives as long as the
display. It is built once per display and kept. It also advertises
EGL_KHR_get_all_proc_addresses, which is true now and is the flag GLFW and LWJGL
test to decide they need not dlopen a client library of their own.
EGL_CLIENT_APIS reports "OpenGL OpenGL_ES": both are reachable, since an
application that binds the ES API still gets the backend unchanged.

eglGetConfigAttrib stripped the ES renderable bits when adding the desktop one.
That broke the standard "choose configs, then verify each with
eglGetConfigAttrib" loop for any application asking for ES: the config it had
just been handed came back claiming it could not render ES at all. The config
really does both -- desktop GL on it is this layer's virtualisation of the very
ES support being reported -- so the bit is added, not substituted.

destroy_temp_egl_ctx called eglTerminate on EGL_DEFAULT_DISPLAY. EGL does not
reference-count initialisation per caller, so that marked every resource on the
display for destruction, including contexts and surfaces the host process
created before this library was loaded. The probe now gives back only what it
took.

FSR1 latched the display and surface into statics on first use, so after a
rotation or a surface rebuild it queried a destroyed surface every frame and the
render resolution never changed again. It takes both from the swap it is hooked
into.
… context

gl_state was one process-wide struct, allocated once during bootstrap on the
temporary pbuffer context that is destroyed moments later. Every context in the
process shared one current program, one active texture unit and one draw
framebuffer, so a second context silently inherited the first one's idea of
them. It now lives inside MGContext, and eglMakeCurrent repoints the global at
the current context's copy -- every gl_state-> reader keeps working unchanged, it
simply stops being one set of values for the whole process.

gl/multidraw.cpp identified its context by comparing EGLContext pointers. That
was the wrong key: EGLContext is a driver heap allocation, so destroying one and
creating another very often hands back the same address, and the comparison then
reported "same context" for one that had never owned the cached buffers,
programs or probe latches. It compares the monotonic MGContext id now.

The rest of the process-global state -- the buffer name map, VAO tables, texture
metadata, framebuffer tables and the FSR1 objects -- is deliberately still
global. Moving it means changing object lifetime across five files at once, and
with no device to test on that is not a change worth making blind. What is left
is recorded rather than half-done: multiple contexts still share those tables.
Seven defects, five of them introduced by the preceding commits.

The multidraw CPU rebase wrote the 0xFFFFFFFF restart sentinel into its index
stream and drew it as GL_UNSIGNED_INT, but nothing ever turned the driver's
fixed-index restart on. The sentinel was fetched as vertex 4294967295 and every
enabled attribute array was read out of bounds. The previous code read
GLES.glIsEnabled(GL_PRIMITIVE_RESTART_FIXED_INDEX), which could not disagree
with the driver by construction; switching that to the virtual table introduced
the gap. Worse, primitive_restart_index defaults to 0, so an application that
only called glEnable(GL_PRIMITIVE_RESTART) turned every index 0 into the
sentinel. The path now brackets its draws with the driver's flag.

An application whose restart index happens to equal the type's fixed value got
no restart at all: no rewrite is needed then, but GL_PRIMITIVE_RESTART is never
forwarded either -- GLES has no such enum -- so nobody told the driver to
restart. The three single-draw entry points now bracket their ordinary draw with
the driver's fixed-index flag when that is the case.

MGContext was only created on the desktop path, after eglCreateContext returns
early for anything that did not call eglBindAPI(EGL_OPENGL_API). On a host that
never binds the desktop API -- which is the normal launcher bridge -- no context
was ever tracked, g_current_ctx stayed null for the whole process, and
multidraw's context change detection short-circuited permanently: stale buffer
and program names were reused across a destroy-and-recreate. ES contexts are
recorded too now.

gl_state was left pointing into a released MGContext. Nothing repointed it when
a context was made non-current or when the record was erased, and it is written
through on the draw hot path, so the next draw wrote to freed heap. It falls
back to a default instance on both paths.

GL_CONTEXT_FLAGS reported EGL bit values. The two encodings are opposite --
EGL_CONTEXT_OPENGL_DEBUG_BIT_KHR is 0x1 and FORWARD_COMPATIBLE_BIT is 0x2, while
GL_CONTEXT_FLAG_DEBUG_BIT is 0x2 and FORWARD_COMPATIBLE_BIT is 0x1 -- so each
flag was reported as the other, which is precisely the confusion the commit that
added it claimed to prevent. Translated at the source now.

glGetDoublev passed a single stack GLfloat to glGetFloatv. GL_DEPTH_RANGE writes
two and GL_VIEWPORT four, so the driver wrote past it, and the remaining
components were never filled. It sizes the temporary by pname. The stub this
replaced wrote nothing at all, which was useless but memory-safe.

mg_restart_invalidate had no caller despite its own comment saying it is called
on a context change, so gl/restart.cpp's scratch index buffer -- a real driver
name, not a virtual one -- was reused across contexts. It is invalidated with
the multidraw scratch objects.
GL_MAX_VIEWPORTS reports 16 rather than 1. GL 4.6 requires at least that many,
and an application that reads the number and then indexes up to it now gets
consistent answers: GL_SCISSOR_TEST is tracked per viewport, so glEnablei on
viewport 3 is remembered and reported instead of rejected. Only viewport 0
reaches the driver, because glViewportIndexedf and glScissorIndexed are still
stubs -- the state is real, its effect is not, and the log says so once.

EGL_CONTEXT_OPENGL_ROBUST_ACCESS is accepted. It is a guarantee this layer
cannot verify, but refusing it stopped context creation outright for loaders
that ask for it as a matter of course, which is worse than granting something
the driver very likely already provides. The flag is recorded and reported
through GL_CONTEXT_FLAGS like the other two.

eglTerminate is reference counted per display. EGL does not do this itself:
whoever calls it marks every resource on the display for destruction, including
resources another part of the process created. The bootstrap probe registers its
own hold and releases it, so it terminates only a display it actually brought up
-- and an application that calls eglTerminate while the probe still holds the
display no longer tears the probe out from under itself.
The last of the process-wide GL state. A second context inherited the first
one's buffer names, sizes, vertex array objects, bindings, texture objects,
texture units and framebuffer table, because all of it was one set of file-level
containers.

Split the way GL scopes it. Buffer names, their sizes and texture objects are
shared across a share group -- two contexts created against each other really do
see one set of names. Vertex array objects, the current buffer bindings, the
texture units, the active texture unit and the framebuffer table are container
state and belong to one context even inside a share group.

The containers stay private to the file that uses them. Moving them into
MGContext would drag gl/texture.h and gl/framebuffer.h into egl/context.h and
back out again, so each subsystem keeps its own table keyed by id and exposes a
bind hook that eglMakeCurrent calls. A thread_local pointer selects the current
one; std::unordered_map keeps references stable, so those pointers survive
another group being inserted. The ~120 access sites are unchanged, which is the
point -- a file-local macro redirects the old name at the new storage rather than
rewriting every use and risking a typo in one of them.

gl_state is thread_local now too, along with every one of these pointers. EGL
scopes the current context per thread, so two threads each holding one must not
share them. That was already wrong for gl_state after it was moved into
MGContext; it just had no second pointer to be inconsistent with yet.

framebuffers was a plain global that gl/gl.cpp reached by extern, which cannot
work once it is per-context. It goes through mg_framebuffers() instead.
Every name in FSR1_Context is a GL object owned by whichever context created it,
and gl/framebuffer.cpp redirects a bind of framebuffer 0 to g_renderFBO. In a
second context that name refers to nothing, or to an unrelated object the
application created -- so enabling FSR1 and then using two contexts sent the
scene to whatever happened to hold that name.

The values are saved and reloaded on a context switch rather than reached
through a pointer, because they are declared extern and read from several
translation units; swapping the storage would have meant touching every use.

g_dirty, g_resolutionChanged and the pending size are deliberately not swapped:
they describe work queued for the frame in flight, not the context's objects.

This was the last of the process-global GL state.
The library initialises itself from a static constructor, and that
constructor now reaches egl/context.cpp through mg_display_initialised.
g_contexts and g_display_refs were namespace-scope std::unordered_map,
which are dynamically initialised, so whether they existed by then came
down to the order the linker emitted the translation units in.

It did not. init.cpp's constructor ran first, so the first insertion hit
an all-zero map; its zero max_load_factor asked for an infinite bucket
count, __next_prime threw std::overflow_error, and the process aborted
during dlopen -- before main, before any application code. Every host
loading the library would have died on the spot.

Constructing on first use removes the ordering question rather than
relying on it. Access sites are unchanged.

Found by running the branch on a device for the first time.
Running the branch on a Mali-G77 (r32p1) turned up three ways the
multi-draw path drew the wrong thing or nothing at all. All three are
invisible to a build and to host-side logic tests.

glMultiDrawElementsBaseVertexEXT was gated on the resolved symbol alone.
It is not part of EXT/OES_draw_elements_base_vertex by itself: both specs
define the multi-draw form only when EXT_multi_draw_arrays is also
supported, and a driver with base vertex but without multi_draw_arrays is
an ordinary configuration. Android's EGL wrapper resolves the symbol
anyway, and such a driver accepts the call, draws nothing, and raises no
error -- so the probe-and-latch latched Working and every sub-draw was
dropped for the rest of the process. On this device that silently killed
glMultiDrawElements and glMultiDrawElementsBaseVertex under the Auto
setting. The extension string is the only thing that can tell, so it is
now checked, in resolution and in the runtime fallback alike.

GL_PARAMETER_BUFFER reached GLES verbatim from glBufferData and friends,
came back GL_INVALID_ENUM, and left the buffer with no storage --
glMultiDraw*IndirectCount then found a zero-byte parameter buffer and
refused to draw. GLES has no such target, so the calls borrow
GL_COPY_WRITE_BUFFER for their duration and put it back; GLES defines it
as a generic target with no meaning of its own, so nothing observes the
swap. glBufferSubData and glGetBufferParameteriv move out of the native
pass-through list for the same reason.

No multi-draw entry point honoured GL_PRIMITIVE_RESTART. Restart is
per-index state and applies to every sub-draw, but GLES only implements
the fixed-index form and nothing here accounted for either half: a batch
drawn with restart enabled came out with its strips joined end to end.
Backends that hand the application's indices to the driver now defer to
the one that rewrites the stream when a custom index is in use, and
bracket the batch with the driver's fixed-index restart when the chosen
value is the fixed one. The indirect entry points can only do the second
half and say so once.

Verified on device across every backend the driver can reach -- unroll,
basevertex, indirect and compute -- each producing an identical, correct
picture. multiarrays, multibasevertex and multiindirect now correctly
report themselves unsupported here and fall back, so they remain
unexercised on this hardware.
glBufferData passed a bind target to find_bound_buffer, which answers the
*_BINDING query enums instead. A target falls through its switch and comes
back 0 -- a valid buffer name, so nothing complained -- and every size the
process ever recorded landed on buffer 0.

Nothing reads the table today: get_buffer_data_size has no callers, so
this changes no behaviour. It is a trap rather than a bug, and the trap is
the pair of near-identical lookups, so the target-side one now exists
under its own name with both documented as taking the other's enums badly.

glBufferStorage allocates storage the same way and owed the same record;
it never made one.

Introduced by 77b4357 (2025-09-20), unrelated to the multi-draw work.
Testing the entry points next to the multi-draw ones found four more that
were wrong in two already-familiar ways.

glDrawRangeElements, glDrawRangeElementsBaseVertex and
glDrawElementsInstancedBaseVertex were pass-throughs in gl/gl_native.cpp.
They are indexed draws like glDrawElements, but only glDrawElements,
glDrawElementsInstanced and glDrawElementsBaseVertex ever applied
GL_PRIMITIVE_RESTART; on the other three the application's chosen restart
index went to a driver with no such feature and was rasterised as an
ordinary vertex, joining every strip in the batch end to end. Measured on
device, 1152 of 4096 pixels came out wrong on a nine-index draw. They now
use the same mg_draw_elements_restart / fixed-index bracket as their
neighbours, via a scoped guard so an early return cannot leave the
driver's restart enabled behind the application's back.

glDrawArraysInstancedBaseInstance, glDrawElementsInstancedBaseInstance and
glDrawElementsInstancedBaseVertexBaseInstance were stubs in
gl/gl_stub.cpp: called, they drew nothing at all. GLES has no base
instance in core and no extension for it on the drivers this layer
targets, but baseinstance is 0 in the overwhelming majority of calls, and
with 0 these commands are exactly the ones GLES already implements. They
now forward, and a non-zero base instance is reported once and then
ignored -- the instanced attribute fetch starts at the wrong element,
which is wrong for that case alone and stays visible, where a silently
empty screen was neither.

All six move into gl/drawing.cpp beside the entry points they share
machinery with, and are declared in gl/drawing.h so they keep C linkage.

Verified on device: the ten checks that cover them all pass, including
restart combined with a base vertex and a non-zero base instance still
drawing. The multi-draw, GLSL, enable-table and two-context suites are
unchanged at 31 x 8 backends, 11, 102 and 14.
1.3.5 was tagged VERSION_RELEASE, so every build off this branch reported
itself as a release. It is not one: the branch carries seven defect fixes
verified on exactly one device and one driver, and three multi-draw
backends on it have never executed at all.

VERSION_DEVELOPMENT makes glGetString(GL_VERSION) report "1.3.6·Dev",
which is what a bug report from this build should carry.
Twenty-five commits covering GL 4.6 multi-draw, a virtual enable state
table, per-context state, and the EGL frontend. Merged as one unit rather
than fast-forwarded so the integration point stays revertible.

Multi-draw. All seven GL 4.6 entry points are implemented, including
glMultiDraw*IndirectCount, whose command compaction runs on the GPU rather
than stalling on a readback. Each entry point gets its own backend setting
instead of one shared key, and backends are named after the extensions
that actually provide them -- GLES has no native multi-draw at all, only
EXT, which the old naming implied otherwise. Silent index truncation on a
base vertex past 65535 is fixed by widening the rebased stream to 32 bits.

State. glEnable/glDisable/glIsEnabled now answer from one virtual table
covering all 28 GL 4.6 capabilities, so the five query entry points can no
longer contradict each other on the thirteen GLES lacks.
GL_PRIMITIVE_RESTART with a custom index is emulated by rewriting the
index stream. Buffer names, texture and framebuffer tables, FSR1 objects
and gl_state move per context or per share group, so two contexts stop
sharing what GL says they must not.

EGL. Context attributes real GL loaders send are accepted instead of
rejected, eglGetProcAddress has an explicit name table, sync and image
entry points are exported, and eglTerminate is reference counted.

Verified on device (Mali-G77 MC9, r32p1, Android 14, arm64-v8a): 168
distinct assertions, 385 executions, all passing. The multi-draw suite
runs once per backend and every backend produces a pixel-identical frame.

Known not covered: the multiarrays, multibasevertex and multiindirect
backends have never executed -- this driver has neither extension, and
Auto selects multiindirect on hardware that does. No real application has
run against this. One device, one driver; armeabi-v7a and x86_64 build
clean but were never executed. Version is marked 1.3.6·Dev accordingly.
BZLZHH added 29 commits August 8, 2026 02:34
…ffle

Both defects this branch had to be told about twice were in code that is pure
enough to run on the host, and neither was caught by reading it. These link the
real translation units -- gl/pixel.cpp and gl/framebuffer.cpp, not copies -- so
they cannot drift away from what they check.

The pixel test walks the rebuilt tables and compares 408 row strides against GL
4.6 sec. 8.4.4.1's own formula, k = n*l or (a/s)*ceil(s*n*l/a), across six
format/type pairs, four alignments and seventeen row lengths, plus the enums that
must NOT be in each table and the degenerate alignments widthalign now guards.

The framebuffer test drives glDrawBuffers against a fake driver that records what
is physically attached where, and asks the only question that matters: when the
application draws through slot j, does the texture it named in bufs[j] receive
the pixels? Seven scenarios, including the two regressions this branch shipped --
identity order after a swap, and the single-target shuffle whose destination slot
was a victim rather than a source. Reverting restore_home_attachments to the
first attempt makes scenario 4 fail and the rest pass, which is what makes the
suite worth having.

    sh MobileGlues-cpp/tests/run.sh
Off by default -- MG_EGL_TRACE is 0, and the whole thing compiles out; the
binary carries none of its strings.

It exists because the EGL wrappers are the seam between the host, this layer's
virtual context records and whichever driver is behind them, and nearly
everything that goes wrong there is a sequencing problem: which thread made what
current, which display is still held by whom, whether a handle the driver just
returned is one it handed out before. An argument dump per entry point answers
none of that, so every line carries the calling thread and says what came back.

LOG_I rather than LOG_D so it can be read from a release build without
GLOBAL_DEBUG turning the renderer into a firehose that buries the interesting
lines -- which is exactly what happened while hunting the NeoForge early-window
black screen, and why this got written.
…d of giving up

Xaero's world map rendered black. Its tiles arrive through a pixel unpack buffer,
the conversion path has to read that buffer back to repack the pixels, and
glMapBufferRange with GL_MAP_READ_BIT was refused -- so the whole upload was
dropped and the tile texture was never defined.

A mobile driver routinely refuses a read mapping of a buffer whose usage hint is
one of the _DRAW ones, and _DRAW is the natural hint for a buffer whose only
purpose is uploading. What those drivers do serve is a copy into a buffer asked
for with _READ, which is a buffer this layer can make for itself: allocate one,
glCopyBufferSubData the range across, map that. Only when the copy is refused
too is the upload dropped, and the warning now says which of the two failed.

Both exits -- the finished conversion and the overflow drop -- release the source
through one function, so neither can forget to unmap or to put the copy-write
binding back.

Predates this branch: the drop path came in with 96a26ca, an ancestor of it. The
sixteen commits before this one are unrelated to the black map, and the same
device runs 1.21.4 with Sodium at 92 fps with them in.
… as a pointer

Two things the plugin app needs from this side to tell the truth about ANGLE.

The resolved AngleMode was the only thing exported, and it cannot answer the
question that matters. Under EnableIfPossible on a device the probe rejects it
reads Disabled -- indistinguishable from the user not wanting ANGLE at all, so a
borrow that was silently ignored looked exactly like a borrow that was never
asked for. The raw configured choice and the device verdict ride along now, in
the benchmark JSON and through a mg_angle_in_use() the info query can dlsym; the
loader is the only honest source for the latter, since a system driver that is
itself ANGLE defeats every other test.

And the unrolled glMultiDrawElementsBaseVertex read indices[i] as a client
pointer whenever no element buffer was bound. A sub-draw's indices there is a
buffer offset, and offset zero is a null pointer, so that was a segfault at
address zero -- reachable from the in-process benchmark the moment borrowing
ANGLE started working, which is to say it had never been reachable before. The
binding is what decides which of the two meanings applies, so a zero binding with
offset-shaped indices cannot be repaired here: it says so once and skips the
sub-draw.
… say so

GL_CONTEXT_LOST is not a complaint about the call that returned it. After it,
every query answers zero and every draw is a no-op -- so a batch timed past that
point is not fast, it is fiction, and the backends that check their
preconditions see those zeros and fall back, which this harness wrote down as
"this device cannot do it". The result was a confident ranking assembled out of
nothing: unroll timing 14 microseconds for work it never did, and half the
candidates marked unsupported on hardware that supports them.

Caught after the glFinish that ends a batch, so the check costs nothing in the
timed region, and it names whoever was being measured -- the one clue to why the
driver gave up. On this Mali-G77 under a borrowed ANGLE that is consistently
glMultiDrawElementsBaseVertex/indirect. The whole run is then reported as failed
rather than partially ranked: what was measured before the loss cannot be told
apart from what came after.
…ugh a mapping

A map with GL_MAP_INVALIDATE_BUFFER_BIT states the intent more precisely, and
on a driver that honours it there is nothing to choose between the two. What it
costs is two ways to fail on every draw call: a map that returns null, and an
unmap that reports the contents lost. Either one abandoned the backend for the
rest of the process, and falling back to unrolled draws forever is far worse
than the upload it was avoiding -- a driver declining to hand out a pointer for
a buffer it is still reading is ordinary, not a reason to give up on indirect
drawing.

The staging vector is thread_local for the same reason mg_zero_basevertex is:
nothing in this file takes a lock, and two threads can each have a current
context.
…mb back

A tiler bins every primitive into the tiles it covers, and that list lives in a
per-context heap that grows on demand and then stops. Past that point the job
does not slow down, it fails: Mali answers BASE_JD_EVENT_OUT_OF_MEMORY, the
kernel kills the context, and ANGLE turns the VK_ERROR_DEVICE_LOST into
GL_CONTEXT_LOST. A Mali-G77 lost the context on every single run.

What was pushing it over was the answer to a shaky pass. Frames-per-batch was
multiplied by up to eight per retry, to a ceiling of 64 -- 64 frames of geometry
queued between two glFinish calls, on a scene sized at compile time for no
device in particular.

So the scene size is discovered instead, once per run and never written down.
The run opens at 256 sections; a pass that comes back too noisy doubles it,
because the steadier reading is the one with more real work behind it. The first
lost context ends the climb for good -- the caller comes back in a fresh process
with a ceiling, and this run never reaches for it again. Frames-per-batch is
left wherever the probe calibrated it: it was the one knob that raised peak load
without being subject to any ceiling. At the ceiling the budget grows instead,
which buys rounds, which is more samples under the median at a load the device
has already survived.

Measured on the G77 that used to fail every time: the ladder now climbs to 1024
sections and finishes, and 4096 is fine too. Each entry point reports the scene
its numbers came from, since a function that settles early keeps a smaller one.
robin-map 1.4.1 (Tessil/robin-map @ 91362aa, MIT), copied in as headers the way
include/ankerl was rather than added as a submodule -- it is four headers and no
build system, and the tree already has more submodules than it has dependencies
worth tracking that way. LICENSE alongside them.
There were four: a hand-written open addressing map behind the UnorderedMap
alias, ankerl::unordered_dense, std::unordered_map and khash. Four sets of
iterator-invalidation rules to hold in your head while reading code that mixes
them in one function, and no way to tell whether a container had been chosen or
merely copied from the neighbouring file. All of them are robin_map now, and the
two that were dead afterwards -- the FastSTL submodule and the ankerl header --
are gone with them.

The part that is not a rename: five of these tables hand the address of a mapped
value out and keep using it past a later insert, and robin_map is open
addressing, so growing it moves the elements. Those hold a unique_ptr now, and
the pointee is what stays put:

  * egl.cpp ext_strings -- eglQueryString hands the application a c_str() that
    belongs to the display, and the code says so: it already had this bug once,
    with a thread_local string;
  * buffer.cpp, texture.cpp, framebuffer.cpp per-context and per-group tables --
    a thread_local pointer into an entry is the design, and the ~90 access sites
    read through it rather than looking anything up;
  * framebuffer.cpp's inner table -- get_framebuffer() returns a reference that
    callers hold across further calls that can insert;
  * gl.cpp depth-clear objects -- returns a reference, and drops the lock doing
    it.

They are per context, looked up once at eglMakeCurrent, so the extra allocation
and the indirection cost nothing measurable. egl/context.h explains where the
guarantee now comes from, since it no longer comes from the map.

Two API differences worth knowing when reading the diff. robin_map stores
pair<Key, T> and hands it out const through operator->, so mutating a mapped
value goes through it.value(); and the default allocator on the alias changes
shape to match.

Also fixed while in DSAWrapper.cpp: restoreTemporaryBufferBinding,
restoreTemporaryFramebufferBinding and restoreTemporaryRenderbufferBinding each
had their early return commented out and then dereferenced an end() iterator on
the very next line. The texture one was fixed earlier; these three were missed.
… not prove

Every scenario in this file used one framebuffer name, so the table never grew
and nothing exercised a rehash at all. Sixty-four more names take it through
several, and fbo 7 still has to know its home attachments afterwards.

The comment is explicit that this does not reproduce a reference held across an
insert -- the case the unique_ptr in that table exists for. It was written for
that and does not do it: it passes with the unique_ptr removed, because no path
in framebuffer.cpp keeps a framebuffer_t& while asking for another name. Saying
so in the test is better than leaving a name that claims more than it checks.
Same shape as before -- one map everywhere, open addressing, and the unique_ptr
where a mapped value's address has to survive an insert -- with tsl::robin_map
swapped out for ska::flat_hash_map. The five tables that hold their records by
pointer keep doing so: this map moves its elements when it grows too, so the
reason has not changed.

What does change is how a mapped value is written. robin_map hands the pair out
as const through operator-> and makes you go via value(); flat_hash_map's is
mutable, so those five sites are back to the ordinary it->second and the
comments explaining the detour are gone. The other side of that coin is that
it->first is writable as well, which silently corrupts the table -- nothing does
it, and includes.h now says so.

It is consumed as a submodule of MobileGL-Dev/flat_hash_map rather than copied
in, because the header needed a fix to be usable here at all: its prime list is
a static constexpr size_t[] two thirds of which is above 2^32, and a braced
initialiser makes narrowing an error, so armeabi-v7a and x86 could not include
the header -- never mind instantiate the policy, which flat_hash_map does not
even use. Upstream last moved in 2018, so the fix lives in the fork and the
submodule pins it, instead of a local edit that the next re-vendor would drop.
VERSION_TYPE is not bookkeeping: getter.cpp puts it straight into
glGetString(GL_VERSION), which is what a player reads in F3. It said ·Dev3 while
the plugin around it had already been named Alpha, so the same build described
itself two ways depending on where you looked.

VERSION_DEV_NUMBER stays at 3 behind its #if. It is inert now, and if this ever
goes back to a development series the next one is 4, not 1.
The Alpha cut is a branch, not the end of development; the performance batch
that follows starts a new development series on top of it. Dev numbering
continues from where it left off -- the comment in this file says why 4, not 1.
…o-op binds

The audit's recurring pattern was code asking the driver for values this layer
already knows. This commit builds the supply side: mg_driver_bound_buffer()
returns what GLES.glGetIntegerv would for a buffer target, computed from the
tracked bindings through the same frontend-to-real mapping glBindBuffer uses;
mg_driver_texture_binding*() and mg_driver_active_texture_unit() answer from a
new driver-side shadow in texture_ctx_state_t, written wherever this file
issues GLES.glBindTexture/glActiveTexture.

The texture accessors return bool for a reason: the shadow refuses to answer
for the shared fallback record (a context this layer never saw created), and
while FSR1 is enabled -- ApplyFSR leaves a binding on unit 0 the guard does not
restore, so consumers must keep their glGetIntegerv fallback for the false
case. The gates are documented where they live.

On top of that supply: glBindTexture and glActiveTexture now skip the driver
call when both the frontend object and the driver-side name already match --
the bookkeeping below still runs -- and glCopyTexSubImage2D reads the recorded
internal format instead of a per-call glGetTexLevelParameteriv, falling back to
the driver only for an object it never saw.
glEnable/glDisable forwarded to the driver even when the shadow already held
the requested value; Minecraft toggles BLEND/DEPTH_TEST/CULL_FACE thousands of
times a frame and most of them are repeats. The filter checks the shadow first
and is gated on the context actually being tracked. The 27-entry linear
find_cap scan -- which also sat under every glGetIntegerv default branch --
is now a switch.
…ampler-buffer setup

The glDrawElementsBaseVertex emulation created and destroyed a buffer object on
every draw call and read-mapped the source IBO -- a CPU-GPU sync per draw, times
one draw per chunk section under Sodium. It now uses a persistent scratch IBO
with the same context-change handling as gl/restart.cpp's, and a grow-only
thread_local staging vector in place of the per-call malloc.

prepareForDraw's sampler-buffer half did three hash lookups per indexed draw --
one of them operator[], which inserts on miss -- and asked the driver for unit
15's texture binding it had itself established. Lookups are find() now, the
binding comes from the tracked state (with the glGetIntegerv fallback the
accessor's contract requires), and the resolved program state is cached behind
an identity check that survives program-name recycling: same map entry, same
address, same name, or it rebuilds.
… driver what we know

Every temporarilyBind/restore pair paid a malloc and a hash insert because the
stack entry was erased the moment it emptied -- and DSA calls do not nest, so
that was every call. Empty entries now keep their vector and its capacity; the
map holds a handful of targets, so nothing grows without bound.

The saved binding comes from the tracked state instead of a glGetIntegerv
round-trip, with the fallback the texture accessor's contract requires.
glBindTextureUnit no longer queries GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS -- a
process constant -- per call, reads the active unit from the shadow, and skips
the glActiveTexture pair when the unit is already current. glBindTextures
(multi_bind) reads the tracked active unit too. GetTexTarget is computed once
per operation and threaded through instead of three times.

mgRejectUnknownTexture fired two log lines per DSA call on a name this layer
never saw; it now latches per name, consistent with the file's WARN_ONCE style.
…atch vectors

The indirect paths asked the driver for GL_ELEMENT_ARRAY_BUFFER_BINDING and
GL_DRAW_INDIRECT_BUFFER_BINDING on every call -- two to five round-trips per
multi-draw -- although buffer.cpp tracks both. Every site that later feeds the
value back to GLES.glBindBuffer now calls mg_driver_bound_buffer() instead,
and each reads it at function entry, before the backends' own GLES-direct
rebinding makes the shadow momentarily stale. Queries for state this layer
does not track stay on the driver.

The per-call std::vector in mg_draw_elements_restart -- value-initialised,
zeroed, then fully overwritten -- and the one in the drawelements backend are
now the same grow-only thread_local scratch the prepare_indirect_buffer path
already used.
…warning

get_framebuffer used operator[] on every glBindFramebuffer, walking the
insertion path even for names it had seen a thousand times; it probes with
find() first now, and the unique_ptr-held records keep their address stability
either way. The unrecorded-slot warning in glDrawBuffers fired every call while
its condition held -- a logcat syscall and an fflush per pass under Iris -- and
now latches like every other warning in this file.
ApplyFSR queried three uniform locations by name every frame; they are fixed at
link and now live in the FSR state. The state guard's eight glGetIntegerv reads
shrink to the ones this layer genuinely does not track, and it now survives
RecreateFSRFBO deleting the framebuffer it had snapshotted: recreation rewrites
the guard's saved names and republishes the tracked draw-FBO field, so the
guard always restores a live name -- the old code restored a deleted one after
any resolution change. The redundant per-frame glViewport is gone with the
cached surface size.
put() rewrote the whole cache file on every insert -- O(n^2) disk writes while
a shader pack loads its hundreds of shaders, felt as world-load stutter. Saves
are amortised now (dirty counter plus a coarse monotonic-clock threshold), the
on-disk format is unchanged, and the crash window is explicitly bounded to the
last few entries -- a cold recompile, not a corrupt cache.

SHA-256 no longer copies the full source into a scratch vector, and the digest
get() computes rides along to put(), so each compile hashes its source once
instead of twice.
… driver

The converted-upload path opened with six glGetIntegerv calls for unpack
pixel-store state and the unpack-buffer binding. The pixel-store shadow that
already carried the GLES-absent parameters now carries the GLES-native unpack
set too, initialised to the spec defaults and written where glPixelStorei
passes through; the buffer binding comes from mg_driver_bound_buffer(). Only
the read side moves -- the restore half still talks to the driver, since it is
driver state being put back. Plain RGBA uploads never entered this path and
are unaffected.
The type and the GL_VERSION branch for it have existed all along; this flips
the switch. The Dev4 performance batch is what graduates.
Beta is a branch now -- the plugin's beta branch pins the commit that flipped
the type, and that is where 2.0.0.Beta lives. This branch keeps developing, so
it keeps saying so.
The frontend entry points are exported, so an internal call to one of them went
through a preemptible PLT slot -- and in a game process the system libGLESv2 is
in the global symbol scope and can win that lookup. The DSA wrappers call the
frontend glBindBuffer and glBindTexture by design; resolved to the driver they
would bypass every bit of state tracking, and nothing would say so. Five such
relocations existed; -Bsymbolic-functions leaves none. The symbols stay
exported, so dlsym and eglGetProcAddress are unaffected.

Attached to the target rather than to the compiler-id block above it, because
that block's condition is `MATCHES "GNU|Clang" AND NOT MATCHES "AppleClang"` --
the second half has no variable and the whole thing has never been true, which
is separately why -O3 and --gc-sections never reached a build. Left as found;
turning those on changes the shipped binary and is not this commit's business.
The harness created its buffers, VAO, textures and framebuffer with GLES.*
directly. That was invisible while the multidraw backends asked the driver for
the current bindings, and became a wrong benchmark the moment they started
reading the frontend's tracked state instead: the tracking had never heard of
these names, so the indirect paths saw "no element buffer bound" and fell back,
and the unroll paths skipped every sub-draw -- glMultiDrawElementsBaseVertex
reported unroll at 30us against basevertex at 3813us and would have been
adopted, a ranking won by drawing nothing.

Every call that establishes tracked state now goes through the frontend. The
backends are still invoked directly: what this harness bypasses is the
dispatcher's choice of backend, never the state machine. Shaders and the
program stay on GLES.* deliberately -- these sources are ESSL and the
frontend's shader path is a desktop-GLSL-to-ESSL translator -- and that is safe
because no backend consults tracked program state.

It also measures the right thing now. The game reaches these entry points
through the frontend, so a frame that pays the frontend's bind path is the
frame being predicted, and every candidate pays it alike.
Whether the tracked state still describes the driver cannot be decided at run
time: the shadow always has an answer and has no way to know it is stale, and
the only thing that could tell it is the driver round-trip these accessors
exist to remove. So the check moves to where it can be paid. Under
GLOBAL_DEBUG both accessors ask the driver as well and log the disagreement
with the target, the tracked name and the driver's; release builds trust the
tracking, unchanged.

This is what the benchmark needed to fail loudly instead of quietly: it would
have printed tracked 0, driver 7 on the first frame rather than surfacing three
calls later as a skipped sub-draw. The texture check covers the active unit
only -- reaching another unit means switching the active one, which is exactly
the disturbance a verification pass must not cause.
…thing else

Two holes the benchmark had, both found by asking what it actually proves.

It never ended a frame. Rendering goes to an FBO, so no swap ever closes the
pass, and a glClear does not close one that is open -- so a whole batch merged
into a single render pass. That is a shape no game produces: the per-pass work
is paid once instead of once per frame, and the tiler bins the batch's entire
geometry into one polygon list. bench_frame_end unbinds the framebuffer, which
is the boundary that cannot be deferred, and flushes. Neither call waits, so the
CPU/GPU overlap the batch timing depends on survives. (The scene ladder's
comment now says its 512-section measurement predates this and that the ceiling
sits higher with per-frame passes.)

It never checked that the backends drew the same picture. Five backends of one
entry point are five implementations of one draw call, so with the camera pinned
they must agree bit for bit -- and when one silently skips work, the ranking is
won by drawing nothing. That is not hypothetical: glMultiDrawElementsBaseVertex
reported unroll at 30us against basevertex at 3813us, and it would have been
adopted. Each candidate now renders one fixed-camera frame at probe time, the
readback is hashed, and a candidate whose hash differs from the reference
(unroll where it exists, the simplest semantics) is named in the log, recorded
in quality.wrongOutput and excluded from the ranking. GL_DITHER goes off with
it: it is the one default-on state that can perturb identical draws into
different bytes.

The readback is a full sync, which is why it happens once per candidate at probe
time and never inside a timed batch.
The type reaches players through glGetString(GL_VERSION), and the release case
is the one the branch chain in getter.cpp has no arm for -- so the string is
just the number, with nothing appended. That is the intent: a release does not
announce that it is one.

VERSION_DEV_NUMBER stays at 4 behind its #if, inert. If a development series
follows this, the next one is 5.
@BZLZHH
BZLZHH merged commit fbc4e41 into main Aug 9, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant