Skip to content

Version 2.0.0 - #41

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

BZLZHH merged 69 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 11, 2026 16:11
…draw keys

The config code was a bag of mutable fields whose setters wrote the file. That
put a full synchronous write to external storage on the main thread behind every
assignment, and forced an `isInitializing` flag to suppress it while loading.
Defaults were spelled out in three places that disagreed with each other, range
checks lived in the UI, and every failure was swallowed by a bare `runCatching {}`.

Model and storage
- MGConfig is an immutable data class. Each enum carries both the integer that
  goes on disk and the label shown in the spinner, and the adapters are built
  from the enums, so the option list and the value space cannot drift apart.
  res/values/array.xml is gone.
- MGConfigCodec owns the key names and the fallback for every field. Decoding
  never throws: a single broken field falls back on its own instead of
  discarding the whole file. Keys the app does not know -- native reads
  hideMGEnvLevel -- survive a save instead of being stripped.
- MGConfigStore is the only code that touches MG/config.json. An edit updates a
  StateFlow at once and is written after a 300 ms debounce on Dispatchers.IO,
  through temp file + fsync + rename so the game cannot read a torn file. It
  writes only when something actually changed, which stops "open the app and
  leave" from rewriting the file, and reports failures instead of hiding them.
- A damaged config is backed up and the user is asked what to do; it is never
  silently replaced by defaults.
- customGLVersion is clamped the way settings.cpp clamps it, so a hand-edited 38
  is no longer rewritten to 0 -- which native reads back as 4.0.

UI
- One-way rendering: render(config) is the only writer of the views, and every
  callback returns early when the incoming value already equals the config.
  That replaces isSpinnerInitialized, revertSpinner() and the post{} timing
  tricks, which could not have worked anyway since AbsSpinner posts its
  selection callback rather than firing it inline.
- Warning dialogs became suspend functions, so "ask, then apply" is a straight
  line instead of a pair of callbacks.
- The GLSL cache size is a slider (off .. 1/16 of RAM) on a quadratic scale, and
  clearing the cache is an explicit button that appears only while the cache is
  off and the file still exists -- not a side effect of assigning -1, which used
  to delete the cache on every load of such a config.

MultiDraw
- multidrawMode is dead: native ignores it and only warns when it is present. It
  is replaced by one key per entry point carrying a backend NAME, plus the global
  multidrawDisableBackends list. The per-entry allowed sets mirror k_md_entries,
  because most backends are not a distinct strategy for most entry points.
- The section is collapsed by default behind a one-line summary; its rows and
  chips are generated from the enums.
- The submodule moves to 55ef3c4, where these keys are read. The two changes
  cannot be separated: the app no longer writes multidrawMode, so on the previous
  native every multidraw choice would silently do nothing.

Adds unit tests for the on-disk contract and for the cases where the store must
not write, and declares kotlinx-coroutines and lifecycle-runtime-ktx explicitly
instead of relying on them arriving transitively through appcompat.
…g keys

MobileGlues renamed the two backends that were called "native" and "nativeext"
after the extensions that actually provide them:

  native     -> multibasevertex   glMultiDrawElementsBaseVertexEXT
  nativeext  -> multiarrays       glMultiDraw{Arrays,Elements}EXT

config.json carries these names rather than indices, so the app has to speak the
new spelling: on the current native every value it used to write is rejected as
an unknown backend name and silently falls back to auto.

Reorders MultidrawBackend to match md_backend_t. The order is not cosmetic -- it
groups the backends that issue one driver call per sub-draw (unroll, basevertex,
indirect) ahead of the ones that issue a single call for the whole batch
(multiarrays, multibasevertex, multiindirect), and it decides both the order of
the chips and the order of the names written to multidrawDisableBackends.

The Chinese labels for these two are dropped rather than retranslated. GLES core
has no multi-draw command at all, not even 3.2, which added only the singular
glDrawElementsBaseVertex; every batched backend here therefore comes from an
extension, and calling one of them "native" claimed a distinction that does not
exist. Both now show their technical name, the way BaseVertex, Indirect and
MultiIndirect already did.

Moves compute to the end of the glMultiDrawElementsBaseVertex list as well: it is
in that entry point's allowed set but not in its auto ladder, so it is reachable
only by an explicit choice and should not sit among the ones auto can pick.

Adds a test pinning the full list of backend names and entry-point keys, so a
later rename on the native side fails the build instead of quietly writing values
the loader throws away.

The submodule moves to 0a3d4f2, where the new names are read.
Kotlin 2.1.10 -> 2.3.20 with the compose compiler plugin, Compose BOM
2026.06.01, activity-compose, lifecycle-runtime-compose, documentfile and
miuix-ui 0.9.0. Drops appcompat/constraintlayout/google-material and
viewBinding, moves kotlinOptions to the compilerOptions DSL, and reduces the
window theme to a plain NoActionBar platform theme now that Compose paints
everything and the Activity owns edge-to-edge.
… stores

MGConfigStore no longer touches java.io.File directly; every read, atomic
write, corrupt-backup, cache measurement and delete goes through the new
MgStorage interface, implemented by DirectMgStorage (all-files access) and
SafMgStorage (DocumentFile/ContentResolver). AuthController owns the auth
method plus its live validity as a StateFlow, so a revoked SAF grant falls
back to the ungranted state instead of failing at write time.

Also adds PluginConfigStore (ui_style, auth_method, saf_tree_uri,
launch_count, donated), the sponsor-prompt modulo rule, the GLSL slider power
curve as pure functions, and DeviceInfoProvider, which takes over the EGL
GPU/GLES query and memory lookup that used to live in MainActivity.
MainActivity degrades to a shell: setContent, edge-to-edge and the
ActivityResult launchers for the three auth routes (all-files access, SAF
tree, legacy runtime permissions). AppController holds every behaviour the
two skins share -- navigation, the suspending confirm() with its 41s/10s
countdowns, the auth and corrupt-config dialog state machines, all setting
actions with their existing guard rails, sponsor prompting, GL info and the
MobileGlues removal flow -- so the skins only decide how to draw it.

Adds the MD3 building blocks (theme with dynamic color, confirm/text dialogs,
preference rows, single-choice dialog) and the strings for navigation, home,
both auth methods, the settings groups, privacy and the sponsor prompt.

Work in progress: the MD3 pages and the Miuix skin AppRoot dispatches to are
not written yet, so the branch does not compile at this commit.
The layouts, the overflow menu and showAppInfoDialog all belonged to the
Activity that no longer exists; two icons still referenced Material theme
attributes that left with appcompat, so resource linking failed on them.
Miuix 0.9.0 requires compileSdk 37, which AGP 8.12.3 does not support, so the
dependency stays on 0.8.8 -- the component API is the same. The artifact is
also named `miuix` at that version, not `miuix-ui`.

activity-compose has to be 1.13+: Miuix hosts its popups through
NavigationBackHandler, which throws unless ComponentActivity provides a
LocalNavigationEventDispatcherOwner. On 1.10 the app died the moment any
dropdown or dialog appeared.
Five pages behind one Scaffold: the minimal home (large wordmark, auth pill,
GPU/GLES/memory, a one-line config summary), the settings page whose plugin
group sits above the permission gate, the info page that now carries the
MobileGlues removal instead of an overflow menu, and the GL-info and privacy
sub-pages. Pages cross-fade with a small directional shift; sub-pages slide in
from the right and take the navigation bar away with them.

The dialog host lives at the skin's root, not inside a page: a confirm, an
auth prompt or the sponsor question may outlive the page that started it.

Seven vector icons are added instead of the material-icons artifact -- the
nav bar, the back arrow, copy, chevron and external-link marks are all this
app needs, and both skins share them.
The same five pages and the same dialogs, expressed the way HyperOS does it:
SmallTitle plus Card for a group, SuperArrow/SuperSwitch/SuperDropdown for the
rows, SuperDialog for everything modal. Picking a value opens a popup anchored
to its row rather than a dialog -- identical logic, idiomatic gesture.

Both skins read the same AppController, so a warning, a countdown or a guard
rail exists exactly once no matter which skin is drawing it.

Note that Miuix inverts the Material convention: the page is `surface` and the
cards are `surfaceContainer`. Painting the page with `background` makes the
group cards vanish into it in dark mode.
Until now the permission gate only opened for an authorization this app had
recorded itself. A user who had granted all-files access in system settings --
or who kept the permission across a reinstall -- was told they were not
authorized and had to walk the whole flow again to record something the system
already knew. AuthController now adopts an existing grant on refresh.

Two smaller corrections from the same pass: removing MobileGlues with no
storage attached reported "removal complete" while deleting nothing, and the
home summary read "Disable · Prefer Enabled · 32 MiB" -- three values with no
subject. It now names what each one is and only mentions the GL version when
it has actually been customised.
MGConfigStoreTest builds its store on DirectMgStorage and reads the corrupt
backup by name. New cases pin down the three pieces of logic the UI leans on:
the sponsor prompt's modulo rule (a missed prompt must not shift the
schedule), the SAF folder check (native reads a fixed /sdcard/MG, so only a
folder named MG will do), and the cache slider's scale -- it must never run
backwards, and a value has to survive the round trip through a position or the
knob jumps under the user's finger.
The pointer was still on 0a3d4f2, the multidraw backend rename this branch's
config keys were written against. Everything since -- per-context GL state,
the EGL context table fixes, the multi-draw defects found on device, and the
1.3.6 development bump -- now comes with it.
# Conflicts:
#	MobileGlues
#	app/build.gradle.kts
Dropped "you don't have to take 'we value your privacy' on faith" -- stating
the disclaimer it's refuting was more defensive than reassuring, and the page
already backs up "we value your privacy" with specifics right below it.

Reworked the sponsor dialog's Chinese copy: it read like a counter readout
("this is your Nth launch") followed by a stiff disclaimer ("no feature
difference either way") that nobody had asked for. Also switched every
second-person 你 to the more formal 您 across the Chinese strings, matching
the tone the rest of the copy already uses in the warning dialogs.
Opening the settings app was never a launch -- the renderer running inside a
game is. The count now comes from MG/stats.json, which libMobileGlues writes
when a game loads it (submodule moves to feat/launch-count), and this app only
reads it. MGInfoGetter sets MG_SKIP_LAUNCH_COUNT before dlopening the library
for GL info, since that is this app talking to itself.

The prompt's rule changes with the source of the number. Modulo worked while
the app owned the counter and moved it one at a time; a count this app does
not control can jump several launches between two visits and skip straight
past the multiple. It now asks once the count has moved a full interval since
the last time it asked, and records that point the moment the dialog appears
rather than when a button is pressed -- otherwise "maybe later" would be
answered again on the next start.

Reading happens through MgStorage so SAF grants can see the file too, and
only once storage is available: the count lives behind the permission gate,
so the home page re-checks after authorization lands instead of giving up.
This app asks for all-files access, which is a lot to ask before saying
anything about itself. The first launch now shows the privacy policy and does
not proceed until the user agrees; declining exits, since every function here
needs to read and write the MG folder.

The dialog carries the full policy rather than a summary: what the user sees
at the moment of agreeing should be the same words they can go back and read
later, so both it and the privacy page now render one shared list of sections.
It cannot be dismissed by the back key or a tap outside -- neither is an
answer -- and nothing else, the sponsor prompt included, appears until it has
been answered.
They were the only pair on the page without one, which read as leftovers
below "About" rather than a section of their own.
A revoke row in the danger zone releases the storage authorization and the
acceptance of the privacy policy together. No file is touched -- the MG folder
keeps its configuration -- but the policy dialog comes straight back, because
accepting it is the condition for using the app at all.

All-files access is a system permission this app cannot hand back, so revoking
also records that the user asked us to stop using it. Without that, the next
refresh would adopt the still-granted permission again (the path added so a
permission granted in system settings is not asked for twice) and the revoke
would silently undo itself. Going through any grant flow clears the record.
The two rows locked each other out. Revoking left the removal row disabled
because deleting needs the access that was just given up, and removing
revoked the authorization on its way out, leaving nothing for the other row
to do. They were never siblings: deleting the files is revoking plus more.

The danger zone now has a single entry that opens a choice -- revoke only, or
revoke and delete everything -- with the delete option unavailable, and saying
why, when there is no access to delete with. Removal also gives back the
privacy acceptance now, since after it there is nothing left to have consented
to.
Revoking used to drop the user straight back onto the privacy dialog: they
had just taken back their consent, so the app immediately demanded it again.
Both paths now end the same way -- a word of thanks, and exit as the only
button. Removal already worked like that; revoking now joins it, sharing one
farewell dialog that says which of the two just happened.

It is rendered before everything else in the host, or the consent dialog it
just invalidated would cover it.
Deleting /sdcard/MG left a full copy of the user's renderer configuration
sitting in the app's own cache -- MGInfoGetter exports one there every time GL
info is queried -- along with every preference this app stores: the chosen
authorization method, when sponsorship was last mentioned, whether the user
donated. "Delete everything" has to mean it.

Revoking clears the exported copy as well. The files in MG belong to the user
and stay, but that copy is ours, and we just gave up the right to have it.

The UI style is cleared from storage but left alone in memory: the farewell
dialog is on screen at that moment, and repainting it in the other skin would
read as a glitch. The next launch starts on the default.
…em allows

A full reset with all-files access came back authorized. The reset cleared
every preference, all-files access is a system permission this app cannot hand
back, and refresh() would infer authorization from the permission still being
on -- so the next launch adopted it again, and the reset had undone itself.

The fix is to stop inferring. Authorization is now only what this app recorded
the user choosing; without that record there is no access, whatever the system
happens to permit. That also removes the flag added to defend the revoke path,
since there is no longer an inference for it to defend against.

The cost is one round trip: a user who turns the permission on in system
settings by hand is still told they are not authorized, and has to walk the
grant flow -- which returns immediately, because the permission is already on.
Better than a revoke that quietly reverses itself.
Each section was a card with its own heading inside, which read as five
unrelated boxes. Headings now sit above the cards, the way every group on the
settings page already works, so the page scans as one document.
They inherited the group-label colour from the settings page -- the accent in
Material, the muted grey in Miuix -- which reads as "name of a group of
controls". These are headings of a document, so both skins now draw them in
the plain foreground colour, and the group components take the colour as a
parameter instead of hardcoding one.
Follows the renderer inverting its flag (submodule). Counting is now off
unless MG_COUNT_LAUNCH says otherwise, so the launcher environments in the
plugin manifest -- the ones Boat and Pojav hand to the game process -- set it,
and that single case is what a launch means.

MGInfoGetter no longer has to opt out. Loading the library to ask it one
question was never a launch, and now nothing has to remember that.
There are four pages now -- the project's and one per developer -- and putting
four rows in the info page would have made a donation menu out of a section
that is mostly credits. One row opens a picker instead, and the sponsor prompt
routes through the same picker, so "buy them a coffee" leads to the same list
rather than picking a favourite on the user's behalf.

Each entry shows its URL. 爱发电 is reachable on three different domains here
and each page belongs to someone different; the platform name alone would not
tell the user where their money is going.

Choosing a channel from the prompt still ends in the gentle "did you donate?"
question, but backing out of the picker does not -- nothing happened, so there
is nothing to ask about.
"GitHub 发布页" pointed at one of three repositories as though it were the
only one. It is now a source entry that opens the same picker the sponsor
links use, listing the release repo, MobileGlues itself and this plugin.

The about section also gains a page for third-party open source, split by
where each component is used: a reader looking at SPIRV-Cross should be able
to tell it belongs to the renderer the game loads, not to this settings
screen. Every entry opens its own home page rather than reciting a licence
here, where the text could drift out of step with upstream. The plugin README
gains the components the release README does not cover -- Miuix above all,
which is what the second skin is made of.
The info page ends on a wall of faces now -- the contributors of all three
repositories, grouped by repository, each avatar opening its owner's GitHub
profile. It sits below the danger zone because it is the page's sign-off, not
a feature to scroll past.

The list is baked in rather than fetched. Reading it from the GitHub API at
runtime would mean declaring the INTERNET permission, and the privacy page
tells the user there is none and invites them to check -- a credits list is
not worth spending that promise on. tools/fetch_contributors.py regenerates
the names and the avatars when they have moved on; it is run by hand, never
during the build, because a build that needs the network cannot be reproduced
offline. Thirty-three avatars at 96px come to 50 KiB.
BZLZHH added 29 commits August 7, 2026 03:32
It sat among ANGLE and the error mode, which are settings people change; this
one opens a 41-second warning and belongs next to the MultiDraw backends, with
the rest of what you touch only when you know why.
Body text drawn by hand missed the convention the library sets in
DialogContentLayout: one size too small, a shade too grey, and left where
Miuix centres. It now matches -- body1, onSurfaceSecondary, centred -- so the
dialogs stop looking almost-but-not-quite HyperOS.

Bulleted warnings and the privacy policy stay left-aligned: centring a column
of "• ..." is unreadable, whatever the convention says.
Per-context GL state, the EGL and pixel-path audits, and the glslang resource
limits.
…create

「撤销并删除全部文件」used to nuke the whole MG directory. Anything a user
manually kept in there would go with it. Now it only touches the known
file set (config.json, glsl_cache.tmp, stats.json, latest.log, glcalls.txt,
and their transient/backup variants) and removes the MG folder itself only
once it is left empty.
…hmark

Advanced gets a MultiDraw section: one global order for all glMultiDraw entry
points, plus a per-function exception for the cases where one function wants
something different. Rows are reordered by dragging the handle on the right --
the list itself never reshuffles mid-drag, the rows just slide out of the way
and the commit happens once, on release.

"Benchmark and sort" runs the measurement that now lives in the renderer and
offers the result as an order; nothing is written until the user adopts it. The
result carries how many rounds each strategy got and how much it moved between
them, and says so plainly when the device was too busy for the ranking to mean
anything -- an order that is really noise should not look like a measurement.
… slot

Dropping a dragged row played the lift-and-settle animation at the index the row
came from, while the row itself was already at its new one.

Column reuses composables by position, so the animation state behind the raised
look -- the tint and the shadow -- belongs to a slot rather than to a row. Move A
from slot 0 to slot 2 and slot 0 keeps the "raised" value it was left with, so
that is where the fade back plays. Keying each row by its item makes the state
follow the row.

That in turn means onSizeChanged no longer reports: a row whose size did not
change stays silent when it moves, and the height table would keep describing
the old arrangement, throwing off the next drop. Positions are reported through
onGloballyPositioned instead, which fires every frame during a drag, so heights
are only written back when they actually differ.

Also held the reordered list locally until the new order comes back through the
config: the write goes out through a StateFlow and reaches composition a frame or
two later, and in that gap the row would sit at its old index with the offset
already cleared.
…g a shaky one

Follows the renderer's re-measuring: the running dialog now says when a pass is
being repeated with longer batches and how far into it we are, so a progress bar
that restarts reads as a retry rather than a stall.

When four passes still cannot get the spread under 15%, the result is presented
as a question -- how many times it was measured, how far the steadiest of them
still moved, and "adopt anyway" on the button. An order that is really noise
should not be offered in the same words as one that was measured.
…page

Dropped the global benchmark. One order has to serve five entry points whose
absolute costs differ by orders of magnitude, so any weighting produces a
sequence that is optimal for none of them -- and the measurement was per
function all along. "Benchmark every function" now runs once and reports a
separate ranking per function; adopting gives each measured function its own
exception and leaves the global order alone. The global order stays what it is:
the fallback for functions nobody has said anything specific about.

Each function also carries its own quality line -- rounds, spread, and how many
times the renderer had to re-measure that one -- since settling is judged per
function now. Only a function that is still too shaky says so, in its own
section, and "adopt anyway" appears when any of them is.

Exceptions get a restore button, shown when that function's order differs from
what the global order expands to on it.

Page text cut down: the paragraph above the global order is one sentence now,
and the drag hint and the exceptions blurb are gone -- the handle and the switch
already say those. The handle keeps its content description for screen readers.
…driver

The benchmark loads the renderer into this app's process, and ANGLE is not here
-- it ships with the launcher. So on a device set to run ANGLE, the renderer
could not open it, fell back to the system driver, and reported an order
measured on a driver the game never touches. Nothing said so. ANGLE is
GLES-on-Vulkan: which extensions exist, how baseVertex is done, what compute
costs are all different there, so those orders were not merely imprecise, they
were about something else.

Launchers extract their native libraries to disk, an app's linker namespace
permits paths under /data, and ANGLE needs nothing but system libraries -- so
the two .so files can be loaded straight out of an installed launcher. The app
finds the launchers that carry both of them and asks which one to trust, every
time: this loads another application's native code into this process with this
process's permissions, and a launcher can be updated into something else between
one run and the next. The previous choice is only floated to the top of the list
and labelled, never acted on by itself.

GL info goes through the same renderer and had the same problem, so it borrows
too -- but on a button, not on entry. Nobody opening a page to read it expects
foreign native code to be loaded on their behalf; until then the page says
plainly that it is describing the system driver.

Results now carry which driver they came from, and one measured on the wrong
one says so above the ranking and turns "adopt" into "adopt anyway".
…ning

Two moments where the order on file stops describing this device, and neither
said anything.

Switching the ANGLE mode swaps the driver out from under an order that was
measured on the other one. If the user has touched MultiDraw at all -- dragged
it, added an exception, adopted a benchmark -- a snackbar now says so and offers
to run it again. Only then: a default order was never measured, so nothing about
it goes stale. It is a snackbar rather than a dialog because the user is in the
middle of changing settings and should not be stopped.

A device still on the factory order has never been measured at all, so the home
page carries a quiet card saying as much, with one tap to measure. No dismiss
button: adopting a result makes the order non-default and the card removes
itself, which is the honest condition for it to disappear.

The benchmark dialogs move up to the app level -- there are three ways in now
(home card, settings button, snackbar action) and they were mounted inside two
of the pages.
… query

MobileGlues b34e0a0..9f0597f: the six EGL findings from the review -- probe
teardown, context record ownership, create/destroy ordering, display holders,
damage-swap through FSR, and the duplicate context table -- plus
glGetActiveUniformName, whose stub is what left NeoForge's early loading window
black.
MobileGlues 9f0597f..a6bfe0a. The eight sections of the framebuffer + pixel fix
plan, the defects an adversarial review of that work found, host checks for the
two pieces that had to be corrected twice, and one older bug it turned up on the
way: an unpack buffer the driver would not let us map for reading had its whole
upload dropped, which is why Xaero's world map rendered black.
…driver honestly

Borrowing ANGLE did not work, and every symptom traced to one fact: the renderer
reads MG_ANGLE_DIR and config.json only from its static constructor, and once
libmobileglues is in a process it never leaves. /proc/PID/maps shows it still
mapped after the dlclose that was meant to unload it -- the GL thread's
thread_local destructors hold a reference the count never gets back. So the
constructor runs once per process, and the driver chosen by the FIRST query was
frozen for every query after it: a borrow made later had no effect, a system
driver chosen later still reported ANGLE. The three-state MG_ANGLE_DIR contract
was dead on arrival for the same reason -- nothing re-read it.

Queries move into a :mgquery service process that kills itself on unbind, so
'every query starts fresh' stops being a hope and becomes structural. It also
buys isolation, which the very first run demonstrated by crashing the renderer in
there while the UI merely saw a dead binder.

On top of that the app was asserting things it did not know. 'Read through ANGLE'
came from having passed a directory, never from whether ANGLE loaded, so it
claimed ANGLE on a device that had ignored the borrow; the affordance to retry
disappeared at exactly that moment. It now reads the renderer's own answer and
distinguishes borrowed, borrow-ineffective, and system. The benchmark's one
wrong-driver line likewise became three: deliberately measured on the system
driver, the borrow failed to load, and the device cannot use ANGLE at all -- the
last of which is not a warning, since the game will use the system driver too and
the ranking does hold.

Also: a missing enableANGLE key decoded as EnableIfPossible here and
DisableIfPossible in the renderer, so the UI could show a mode the game would
never run. And the binder call had to stop throwing out of async, which
propagates to the parent scope no matter what the await is wrapped in -- the
first native crash in the query process took the whole app down with it.
…g built on it

Also bumps both version numbers to Dev3 -- the renderer builds its own string
from VERSION_DEV_NUMBER, so the two have to move together.
…he context

The renderer discovers how large a scene the device can take by growing it while
the readings are shaky, so it needs somewhere to come back to when the growing
goes too far. It cannot be the renderer itself: what the driver threw away is not
only the GL context but the VkDevice under it, and building another one in the
same process is a gamble. Binding MgQuery again is not -- every bind is a fresh
query process, so a clean start is something the structure provides rather than
something the driver is asked for.

Coming back is a restart, not a continuation. Microseconds scale with the scene,
so numbers from two sizes in one ranking are two rulers measuring one thing; the
run that died is discarded whole. The ceiling lives only for the duration of one
tap. Whether the device was busy or warm moves that line, and recording one bad
moment as a property of the phone would make every later run carry it.

runBench therefore takes the opening size and the ceiling, and the report carries
the size back -- including on the error path, where it is the only thing the
caller can act on.
The benchmark's copy had drifted into explaining mechanism. The context-lost
message spent ninety characters on dead contexts and draws that never happened,
when what the reader needs is that the run was abandoned and the numbers after
that point are not valid. Several others were written in the register of a
changelog rather than a product: strategies "挂了", the default order described
as what "一般来说" is fast, a benchmark you "跑". Trimmed to the fact and its
consequence for the decision in front of the user, in both languages, with the
placeholders and CDATA untouched.

The progress line also gains the case the backoff introduced, ahead of the
attempt counter: after a lost context the previous pass is void, and calling the
next one "attempt 2" would suggest the first still counts.
…ct upload

Brings in the two renderer-side changes the app half of this work depends on:
mg_multidraw_bench_run now takes an opening scene size and a ceiling, and the
MultiDraw indirect commands are staged on the CPU instead of written through a
buffer mapping.
…g left

The cells were a fixed 72dp and FlowRow packs from the start, so everything the
width did not divide evenly into piled up on the right. At 440dpi that is 970px
of room, four columns of 198px, and 178px of empty space against one edge and
none against the other -- close enough to a whole column to read as a mistake
rather than as spacing. How much lands there depends on the density, so it looks
different on every device and wrong on some.

Fitting as many cells of at least that width as the room allows and then sharing
the room among them puts both edges flush at any density, and since every row
uses the same cell the columns still line up when the last row is short.

The sharing is done in whole pixels. A dp cell width is a float, every child
rounds it to pixels on its own, and the columns can add up to a few pixels more
than the row they were meant to fill -- at which point the last one wraps and
leaves exactly the column-wide hole this was meant to remove. Integer division
cannot exceed the row, and the leftover is under one pixel per column.

Checked at 320, 440 and 560 dpi: six, four and three columns, each filling the
width. Wider cells also buy the names a few more characters before the ellipsis.
The renderer now uses tsl::robin_map throughout, in place of the four map
implementations it had accumulated. No plugin-side change is needed: nothing
here names those types.
Renderer side only; nothing here names the type. The third-party list gains
flat_hash_map, pointing at MobileGL-Dev's fork rather than upstream, because
that is the code actually compiled in -- upstream's header cannot be included on
a 32-bit target. Attribution stays with the original author.
The renderer fixed all fifteen findings from the performance audit: binding
queries answered from tracked state instead of driver round-trips, redundancy
filters on the hottest state setters, the per-draw allocations in the
baseVertex emulation and the DSA binding stacks gone, the shader cache's
O(n^2) save amortised. The version follows to 2.0.0.Dev4; nothing on the
plugin side changes behaviour.
…enderer fixes

A bench run took a tap on a button and then a tap on a dialog, which meant
reproducing one from a script involved guessing screen coordinates and failing
whenever the phone was on a different page. MainActivity now consumes an intent
extra and hands it to a headless entry point on the controller:

    adb shell am start -n com.fcl.plugin.mobileglues/.MainActivity \
        --es mg_bench all --es mg_angle borrow

No UI leads to it. It differs from the normal path in one respect -- it does
not raise the ANGLE source dialog, because a script cannot answer one -- and
resolves the borrow to the remembered source, falling back to the system driver
exactly as choosing "use the system driver" does. The extras are cleared off
the Intent once consumed, or a rotation would start a second run.

The renderer side carries the fix for the benchmark it was measuring wrong, and
the linker change that stops this library's internal frontend calls from
resolving into the system driver.
…ult a ranking

Two places the UI said more than the run could back.

The progress copy promised "about 8 seconds". That is the base budget; a run
whose readings will not settle grows the scene and re-measures, and the total is
capped at seventy-five, not eight. It now says the base budget and the worst
case, so a minute-long run is what was described rather than a hang.

A function with exactly one measurable strategy was still presented as a sorted
list with ×1.00 at the top. There is nothing to compare it against and the
multiplier means nothing; both skins now say so under the row.

Also carries the renderer's per-frame pass boundary and output gate.
Three things broke when the window got short, and all three were the same
mistake: a layout written as if height were free.

Dialogs capped their scrolling body at a hardcoded 420dp. In portrait that is
under the available height and nothing shows; in landscape the window is barely
more than that, so the title and the buttons were pushed off screen -- the text
was readable and "confirm" was not reachable. The cap now comes from the actual
window height minus what the title, the buttons and the system bars need, with
a floor so a very long body still gets a screenful. Six sites in Miuix had the
literal; four in Material had no cap at all and relied on AlertDialog's own
sizing.

The home page was a centred Column at fillMaxSize with no scrolling. Its content
height is fixed, portrait fits it, landscape does not -- so both ends were
clipped and unreachable. It scrolls now, and keeps centring only while it fits
(heightIn(min = maxHeight)), so tall screens look exactly as before.

The bottom navigation ate the scarcest axis. Below 480dp of height it moves to a
side rail, vertically centred, in both skins. The test is on height rather than
orientation on purpose: an unfolded foldable is wide but not short, and it should
keep the bottom bar. Responsive.kt holds that decision so the two skins cannot
drift apart on it.
"Benchmark" and "restore default" were TextButtons -- MD3's lowest emphasis,
which renders as bare text with no container and reads as a caption in a list
of captions. They are the only two things on that page you can do, so they get
containers: filled-tonal for the benchmark, outlined for the reset. Side by side
the pair now also carries its own hierarchy, the action above the undo.
On Xiaomi's own system the Miuix skin is what the rest of the phone looks like,
so it is the better first impression. Only the default moves: the stored choice
still wins, including a HyperOS user who picked Material on purpose. That needed
telling "never chose" apart from "chose Material", which the old read could not
do -- ofKey answered Material for both.

Detection is of the system, not the vendor. Build.MANUFACTURER == "Xiaomi" is
the obvious test and it is wrong: a Xiaomi phone flashed with a GSI is still
Xiaomi hardware running stock Android, and Miuix there would look out of place.
The two development phones are exactly that pair -- same vendor, and only the
one running HyperOS reports ro.miui.ui.version.name=V816 and
ro.mi.os.version.name=OS3.0, while the GSI reports neither.

So it goes by what the system itself leaves behind: the miui.os.Build class on
the boot classpath, or either of those two properties. The property read is
reflection into a non-public API, so the whole thing is wrapped -- unreadable
means "no", and the worst outcome is that the default stays Material.
…n take

A benchmark can run for minutes with no touch input at all, which is precisely
what the system reads as "nobody is here" -- and once the display sleeps the run
is no longer measuring the GPU the game will use: composition stops, the
frequency policy changes, and the user comes back to a ranking built under
conditions that never occur in play. The view keeps the screen on while a run is
in flight, released when it ends.

View.keepScreenOn rather than the window flag: no walking up contexts to find
the Activity, and it lapses on its own when the view stops being visible, which
covers leaving the app mid-run. It lives in AppRoot because the run belongs to
the controller and both skins share it -- one implementation, so neither can
forget it.

The copy has to follow. It told the user to keep the screen on, which is now the
app's job, and it promised "about a minute" -- measured budget alone caps at 75
seconds, and scene rebuilds, probes and the output-gate frames sit on top of
that. A run that grew to 1024 sections took two minutes on a Mali-G77. It now
states the base budget and admits an unsteady device can take a couple of
minutes, so a long run reads as expected rather than as a hang.
versionName drops the qualifier to match the renderer, which now reports plain
2.0.0 in GL_VERSION, and versionCode goes to 2000 so the number reads as the
version rather than as one short of it.
@BZLZHH
BZLZHH merged commit f5105e4 into main Aug 9, 2026
1 of 2 checks passed
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