Pause did not stick. The stuck-paused backstop resumes a VM that is paused
with nothing covering the screen, reasoning that such a state can only be a
lost resume. That held while every pause came with a frontend over it --
pauseForOverlay() is what the quick menu, the library and backgrounding all
use. The second screen's Pause tile is the only caller of plain pause(), and
it deliberately leaves the game on screen, so the backstop undid it 700ms
later. That is the "pause immediately unpauses itself" two people reported.
A pause the user asked for is now marked as such and the backstop leaves it
alone; resume() clears the mark.
Tiles were different heights. An active tile appends a state line ("\n❚❚" on
Pause, likewise Fast Forward), and with maxLines alone a tile grew the moment
you used it, so the row went ragged -- which is what made Fast Forward the
one people noticed. Every tile now reserves both lines whether or not it is
showing state, which also scales with the text size instead of a fixed
height, and row children stretch to the tallest so a Button's padding cannot
show as a ragged edge against a TextView's.
On-screen buttons get rapid-fire (#619). Physical buttons have had turbo and
the on-screen ones never did, which is the asymmetry the request was about --
and it is the same asymmetry tap-to-hold had in the other direction. Per
button, off by default, cycled from the editor toolbar. It runs on the macro
Frequency timer rather than a second one of its own, so it inherits the
sampling floor that stops the fastest settings from emitting presses the VM
never samples, and it composes with tap-to-hold: set both and a tap starts
the autofire and the next tap stops it.
Feature requested by shinobumaehara (#619)
connect() resolves the Discord app through
DiscordSocialSdkInit.getEngineActivity() and hands the result straight to
Context.getPackageManager() with no null check. That static was only ever
set by DiscordAuthActivity, which runs during sign-in -- so every launch
with a cached token started the SDK with it null, and :discord died with an
NPE. Android restarted the service, which started again and died again. The
app process never saw any of it, because the protocol is poll-only: the UI
just sat on "Connecting" forever.
Binding the SDK only during sign-in was wrong independently of that. The
SDK's statics are per-process and Android restarts :discord whenever it
likes, so the binding has to be re-established on demand rather than assumed
to survive.
Handing the Activity over is now separate from opening the browser:
DiscordAuthActivity takes an EXTRA_AUTHORIZE flag and always binds, and the
service will not call start() until a binding exists, launching the
invisible Activity itself when there is none.
Follow-up to 7aa4547e60. Four ports -- flux, plasma, solarwinds and
hyperspace -- still opened port_free with "if (!g_started) return;". That is
unreachable today, since the JNI only calls destroy after a successful
create, but unreachability is a property of the current call graph rather
than of the function, and it contradicts the rule the fix established: gl1
is given back on every path out, because whatever it holds belongs to an
EGL context that is about to die. All six are now unconditional.
Same argument one level up. port_new opened with "if (g_started) return 1",
which was defensible when gl1's state was whatever the last saver left, but
nativeInit now calls gl1_lost() first -- so gl1 is guaranteed DOWN on entry
and reporting success there would hand the caller a saver with no shim under
it. A stale run is torn down instead, and gl1_init always runs.
No behaviour change on any path reachable today. The point is that a saver
added later, or an upstream cleanup that returns early, fails in its own
saver instead of poisoning the next one.
The on-screen controls have had tap-to-hold since they existed; physical
buttons always followed the button exactly. A game that wants one held while
another control is worked -- MGS2 holding R1 to aim -- is then unplayable for
anyone who cannot hold two controls at once, which is what the request was
about.
Modelled as a transform on the event stream rather than a branch beside
turbo: a tap becomes a synthetic KeyDown, the next tap a synthetic KeyUp,
and everything between is swallowed. Turbo composes with it for free -- flag
a button both and a tap toggles autofire on and off.
Keyed on the physical code for "is this a fresh press", because ACTION_DOWN
auto-repeats while a key is held and each repeat would otherwise toggle, and
on the PS2 target for "is it latched", so two physical buttons bound to the
same button cannot desync.
The state lives in the companion because the boot path that clears it runs
there -- a latch must not outlive the game it was set in -- while the
dispatch that sets it is an instance method. Changing the setting releases
whatever is held: turning it off for a button that is latched down would
otherwise strand it pressed, with no second tap left to release it.
Stored per action per player like turbo, and off by default.
Feature requested by bobo123g (#612)
A saver that dies natively made ARMSX2 unlaunchable. The choice is a
persisted pref read on the library screen -- the first screen -- so the
crash repeated on every launch and Settings was never reachable to turn it
off. The only escape was clearing app data, which takes memory cards and
save states with it. A user lost their saves that way.
Cause: gl1's state is a file-scope global holding GL object names, and
gl1_init() early-returns on g.ready. Skyrocket and Lattice defer initSaver()
to port_resize, so a create-then-teardown with no surface size left
g_started false and their port_free returned BEFORE gl1_shutdown(); flux,
plasma and solarwinds leaked it the same way when initSaver() left
readyToDraw clear, since returning 0 means port_free is never called. Either
way g.ready stayed set with names from a destroyed EGL context, and the next
saver -- new view, new context -- drew against them. Drivers answer that
with anything from a black screen to a segfault.
Each port now gives gl1 back on every path out, and nativeInit calls
gl1_lost() as the invariant: a new context never inherits old GL names.
Contained separately, because native GL can always find a new way to die:
the setting arms itself with a synchronous commit() before the render thread
starts and disarms when that thread exits in an orderly way. Still armed at
startup means the last run died with a saver up, so the background switches
off and the user is told which one. runCatching was never going to catch a
SIGSEGV.
Also guards Thread.start(): it asks for a 16MB stack (Skyrocket declares a
3MB starmap as a local) and an OutOfMemoryError there is an uncaught throw
on the main thread -- the same lockout with no native crash involved.
It fired on its very first run against an APK that DID contain the library. `unzip -l | grep -q`
under `set -o pipefail` is a false-failure generator: grep exits on the first match, SIGPIPEs
unzip, and pipefail then reports the pipeline as failed. Capture the listing and match it with
`case` instead, which is what the existing notes on this already say to do.
2.6.6.8 shipped without Discord and it was only noticed after publication. The SDK is resolved
from $DISCORD_SDK_DIR at configure time and gated on include/discordpp.h existing, so with the
variable unset the build quietly omits it -- no error, no warning, nothing in the log to read
afterwards. Every check that already runs on these artifacts (both cores, alignment, signing,
package, MANAGE_EXTERNAL_STORAGE) would have caught this class of mistake if one had existed.
Both scripts now verify libdiscord_partner_sdk.so in the output. With DISCORD_SDK_DIR set and
the library absent, that is FATAL -- it means the staged directory was wrong, which is easy to
get wrong given the raw SDK download ships an x86-64 .so and only the .aar carries arm64. With
the variable unset it warns loudly instead of failing, because a Discord-less build is still a
legitimate thing to produce on purpose.
Every Android build has failed since the screensavers landed in 81d0a3e510.
The workflow still reported success, because the Android job is
continue-on-error, so nothing said so and no APK has been uploaded since.
shockwave.cpp asked for <gl/gl.h>. The vendored header is compat/GL/gl.h,
with an uppercase directory. macOS resolves that spelling and the Linux
runner does not, so the build dies on "fatal error: 'gl/gl.h' file not
found".
It is the only miscased include in the target. All 742 includes under
savers/ were checked against real directory entries rather than by testing
whether files exist, since a case-insensitive filesystem answers yes to
both spellings and would have hidden exactly this.
The line is also redundant. shockwave.cpp is never compiled on its own:
unit_shockwave.cpp wraps it in a namespace and already includes <GL/gl.h>
above it, so the corrected line hits the header guard and expands to
nothing. Its five sibling files carry no GL include at all and rely on the
wrapper, so deleting it would match them. These savers are vendored GPL
sources kept close to upstream, so this changes one character instead.
What this does not establish is whether the build then finishes. The error
has masked everything after it for days, so a second one may be waiting.
"I enabled HostFS and it is still off", with the switch sitting on. His game INI
proves it: [Cheats] present but empty, and no [Patches] section at all -- the
enable-list write never ran.
toggleLocalCheat flips the switch optimistically, then bails early when
setBodyEnabled produced an identical body:
if (newBody == target.body) return // pushEnableList never reached
setBodyEnabled only comments or uncomments patch= lines, and community pnach files
ship UNCOMMENTED. So for exactly those files enabling is a no-op on the body, the
early return skips the enable list, and Patch.cpp applies a NAMED group only when
its name is in [Patches]/[Cheats] Enable -- unlabelled groups are the only ones
that auto-enable. Switch on, file already correct, patch never applied, nothing
anywhere saying so.
The two writes are independent. Rewrite the body only when it differs; always push
the enable list.
This is what actually blocked the Persona 3 FES mods. The HostFS loader group could
not be enabled at all, so the mod never loaded, and the texture pack keyed to the
modded font stopped matching as a consequence -- which is how it arrived as "my
texture mods broke".
"I enabled HostFS and it is still off." The Patch Manager reads the toggle back as
on, and the patch loader never sees it.
ReloadEnabledLists reads through LayeredSettingsInterface, which returns the FIRST
NON-EMPTY layer with LAYER_GAME ahead of LAYER_BASE. So the moment a game's INI
carries any [Patches] Enable entry, the base list is invisible. setEnabledPatches
targeted the game INI only when a VM was running -- opened from the library it had
nowhere to go but base, where a shadowing game layer swallowed it. The UI then
reads the value straight back, which is why it looks like it took.
For this reporter that patch is the HostFS loader their Persona 3 FES mod runs on,
so it stayed off through repeated attempts to switch it on, the mod never loaded,
and the texture pack keyed to the modded font kept missing.
Take the serial from the caller -- PatchManagerViewModel already resolves one via
bestSerial() -- and resolve the game INI by the same glob gameIniBeginWriteForSerial
uses. With no VM and no serial, or no INI for that serial yet, it still falls
through to base, which is correct for a game that has no per-game file at all.
This is the "wants a serial/CRC parameter" the old comment left as the real fix.
It was a one-time repair for lists poisoned by patches arming themselves, and it
has done more harm than the thing it repaired. It deleted every [Patches]/[Cheats]
Enable entry, not just the self-armed ones, so anyone who had turned a patch on
deliberately lost it silently on update -- and when the patch in question is the
HostFS loader a mod depends on, the mod stops loading, the texture pack keyed to
the modded data stops matching with it, and the whole thing presents as "my
texture mods broke". That cost eleven builds to chase from the wrong end.
Restricting it to lists that look auto-armed was the first attempt at a fix, and
it was still wrong: the threshold is a guess, and someone with thirty cheats
enabled on one game would have been wiped by it just the same. A narrower
silent-data-loss bug is not a fix.
Removing it outright instead, because its job is finished:
- It is a migration, and it has already run for everyone who updated in the past
month. What remains is a shrinking population and a permanent hazard.
- Nothing re-poisons a list now. 10f4f73fe8 removed the unconditional
syncAllEnableLists on Patch Manager open, stopped EnumeratePnachFiles falling
back to the bundled zip over on-disk files, made delete() drop names from the
enable list, and stopped D-pad Right arming the row under the cursor.
- Anyone still carrying a poisoned list can clear it themselves, because
disabling a patch sticks now. That was the original complaint.
Nothing writes these lists except the player, which is how it should have been.
Both existed to work around the wrong diagnosis. The Persona 3 FES mods were not
failing in the texture cache at all -- a PNACH mod was switched off by the
enable-list purge, and because PNACH mods patch game DATA the texture pack keyed
to the patched font stopped matching as a side effect.
The palette fallback substituted a different colour variant of a glyph when the
exact palette was missing. It raised the hit count but could not be right: the
palette IS the colour, so substituted art carries the wrong shade, and it never
addressed why the hashes moved. Gone; the replacement path behaves exactly as it
always did.
The one-time notice goes too, per jpolo1224: the purge no longer takes deliberate
lists, so nothing needs announcing going forward.
Kept: the diagnostics that actually found this -- files scanned versus indexed
versus shadowed, hit and miss counts, and the per-miss detail naming what the pack
holds for that texture. Those are what turned "mods do not apply" into a
measurement, and they cost nothing when nothing is wrong.
This is what broke Persona 3 FES mods, and it was never the texture cache.
10f4f73fe8 fixed a real bug -- opening the Patch Manager armed every uncommented
group of every on-disk .pnach -- and cleaned up the damage with a one-time purge
of [Patches]/[Cheats] Enable. The purge deleted every entry unconditionally,
including the ones people had turned on deliberately, so updating past 2.6.6
silently switched off everyone's patches and cheats.
It surfaces in the worst possible way. The reporter's mod is a PNACH,
94A82AAA_Modern_Persona_Switch_aem.pnach, and PNACH mods patch game DATA. With it
disabled the font glyphs in GS memory differ, so the texture pack keyed to the
patched font stops matching as well -- and the whole thing reads as "my texture
mods stopped applying". Their logs say it plainly: 18,791 patch lines applied on
2.6.6 against 76 now, same .pnach found in both.
Two changes:
- The purge now only fires on lists carrying the auto-armed signature.
syncAllEnableLists persisted every uncommented group across ~4000 bundled
files, so a poisoned list runs to hundreds of names; a hand-built one is a
handful. At or under 25 entries it is treated as deliberate and left alone.
- A one-time notice for the people v1/v2 already hit. Nothing can give them back
what was deleted, and they have no way to know it happened -- the .pnach is
still on disk and still found in the log, it is simply no longer enabled.
Saying so once is the only repair available.
The immediate fix for anyone affected is to re-enable their patches in Patches &
Cheats; the purge is one-shot and will not take them again.
"My texture pack does nothing" has two failure stages and the log only covered
the first. The indexed count proves the FILES were found and their names parsed;
it says nothing about whether any draw ever asks for one. A pack that indexes
thousands of textures and misses every lookup is a hash problem -- wrong dump
settings, paltex vs CLUT, wrong upscale -- and from outside it looks exactly
like a pack that never loaded at all. Both read as "the mods are not applying".
Count hits and misses in LookupReplacementTexture and summarise one line per
20k lookups. Summarised rather than logged per lookup because this runs per
draw, and log volume on its own is enough to stall the emulator.
Also counts how many misses WOULD have matched with the CLUT hash zeroed. That
separates "the pack does not contain this texture" from "it does, but the
palette hash differs", which is the usual answer for paletted UI art -- fonts
and menu panels, the exact things a Persona 3 FES mod replaces.
Counters reset in ReloadReplacementMap: carried over from a previous game, a
stale hit count reads as a healthy pack.
Also -Wno-missing-braces on the savers target. Welsh's sources initialise nested
aggregates without inner braces throughout, which -Wall diagnoses once per site
per TU: ~5 million lines and an 842 MB build log, which is how it was noticed.
Upstream code we do not restyle, so the diagnostic has nothing to tell us. Purely
a diagnostic flag; no codegen change.
ARMSX2 had Flurry alone. This brings over the Really Slick Screensavers tree
ARMSX3 already runs -- Flux, Plasma, SolarWinds, Hyperspace, Lattice and
Skyrocket -- as one libsavers.so beside libflurry.so.
Terry Welsh's savers, GPL-2.0-OR-LATER, which this tree may take under the "or
later" clause. Nothing here comes from rss-glx: that Linux port is GPL-2.0-only
and cannot go into ARMSX2 at all, which is why Lattice and Skyrocket were
hand-ported from the Windows sources upstream rather than taken from the neutral
versions rss-glx already has. The per-file header is what decides this, not the
repository's LICENSE file; they disagree.
The saver sources build with RS_XSCREENSAVER, selecting their platform-neutral
path, against the GLES2 shim in savers/compat and savers/gl1.c rather than being
rewritten -- so they stay re-pullable and recognisably his code.
Four things the tree carries that are not obvious from the sources:
- The GL thread takes a 16MB stack. Skyrocket's World constructor declares a
1024x1024x3 starmap as a local, and a default stack is a SIGSEGV inside
memset before the first frame draws.
- Each saver is compiled through a *_unit.cpp that wraps it in a namespace.
They all declare draw/idleProc/cleanUp/readyToDraw, because each was built as
its own executable, and two in one .so collide at link. The shared libraries
must be pre-included OUTSIDE the namespace or the declarations never match
their definitions -- which compiles clean and fails at link.
- The lifecycle is serialised by a mutex and a generation token: gl1 keeps its
state in one global, so a view may only free the run it started. Without it
the first selection works and every later one is black.
- gl1_frame_begin() masks alpha off after the first frames. These savers fade
the previous frame rather than clearing, and that fade writes destination
alpha, dragging a composited surface transparent -- which reaches the screen
as full-screen TV static.
Hyperspace runs with dShaders = 0 (its ARB shader path needs objects GLES2 does
not have; upstream exposes this as -shaders 0 and falls back to it itself), and
Skyrocket with dSound = 0 (upstream drives OpenAL and bakes ~7MB of samples into
headers; soundEngine.h is a stub here). Hyperspace and Skyrocket ship no presets
upstream, so neither shows a preset picker -- invented ranges are what made
earlier ports fail to start.
SaverGlView replaces FlurryGlView, which it supersedes: it hosts Flurry and the
Really Slick savers behind one SaverSpec, and keeping both would have declared
FlurryNative twice in one package. Helios is deliberately absent -- it rendered
but animated wrongly and the cause was never found.
Merging preserves authorship in git history, but nobody reads git log to find
out who wrote a file. The tree already had the convention -- "Feature
contributed by misantronic (PR #391)" on exportRecentGamesPublic -- it just was
not applied to the three PRs merged here.
MemoryCardBackup.kt bmdhacks (PR #608)
PerGameOverrides.{h,cpp} bmdhacks (PR #593)
RecentGamesContentProvider.kt misantronic (PR #566)
The provider's line also records that the opt-in gate was added on merge, so the
gate is not mistaken for part of the contribution.
RecentGamesContentProvider (PR #566) ships android:exported="true" with no
android:permission, no readPermission, and no caller check in query(). That is
required for the feature to work -- a signature-level permission would only admit
apps we sign, and the companion app it exists for is third-party -- but as merged
it means every app on the device, holding no permissions at all, can read the
recently-played list: titles, serials, last-played times, and the file URIs,
which carry the user's folder layout and frequently their real name. No prompt,
no way to turn it off.
Gate query() on a preference that defaults to OFF, and surface it in App
settings, matching how the second-screen panel and Discord presence are handled:
anything that exposes data outside the app is the user's decision and starts
disabled.
The flag lives in the same "ARMSX2" SharedPreferences file the provider already
reads for the library itself, so the toggle and the gate are one value rather
than two that can drift. Written with commit() and not apply(): the reader is a
different process and can be queried the moment the switch returns, and apply()
only promises the in-memory value.
Returns an empty cursor rather than null when sharing is off -- null is the
failure signal a ContentResolver caller has to special-case, and "the user has
not enabled this" is a legitimate answer rather than an error.
Turning adaptive pacing on silently turned frame generation off: the OSD read
FPS 60 / LSFG 60 on a 120Hz panel.
The target was captured from Display.getRefreshRate(), which reports the rate
the app's window is being driven at, not what the panel can do. Android leaves a
window at 60 until something asks for more, so the stored target became 60 -- and
the pacer then correctly asked for zero interpolated frames, since a 60fps game
at a 60Hz target already satisfies it (desired_outputs = interval * target = 1.0,
generations = outputs - 1 = 0).
Derive from the highest supported mode at the CURRENT resolution instead, which
is what the switch always claimed to mean. Same resolution filter used elsewhere,
so the target can never imply a mode switch of its own.
Existing installs need the switch toggled off and on to re-capture: the value is
stored, not recomputed.
Also swap the credit-card glyph on the library's Memory Cards row.
Second screen (BrainBeat, NiceRon):
- The panel picked its display by "not DEFAULT_DISPLAY", so launching ARMSX2 on
the second panel put the panel on top of the running game. Anchor on the
display the activity is actually on, and re-pick it on every resume rather
than only on a foreground change.
- Restyle the panel: it inherits the system dialog theme, not the app's, which
is why it looked like a stock Android dialog. Dark ground, rounded tiles, one
accent, painted in code since a Presentation is outside the Compose tree.
- Customisable grid: SecondScreenTiles declares the tiles (stats, actions,
macros, achievements, hide-panel), SecondScreenLayout stores which and in what
order, and App settings edits both plus the column count. Stored by tile id,
not ordinal. Achievement tile shows collection progress plus whatever unlocked
this session -- the snapshot carries no timestamp, so "recent" is the
locked-to-unlocked edge on the panel's own tick.
Library (Isshin, GBSUPREMO):
- Cover region per game, overriding the library-wide choice; "Library" is the
absence of a pin, not a fifth region. Both cover components now subscribe to
the region state -- game.coverUrl resolves it inside a plain getter, which
Compose could not see, so cards kept their old art.
- Memory cards reachable from the long-press menu. The card picker already did
per-game assignment whenever handed a game; only the in-game menu ever handed
it one.
In-game (Sizor, Grayy):
- Quick menu docks left or right. Alignment, slide direction, rounded corners
and inset all move together.
- Stats position (four corners), driving PCSX2's own OsdPerformancePos. Stored
as the core's enum ordinal so there is no translation table to keep in sync.
- Cycle-display-refresh hotkey, using preferredDisplayModeId -- the frame-rate
vote in EmulationSurface is a hint the compositor may ignore, which is right
for latency and useless as a user-facing toggle.
- Analog Sticks section in the quick menu, extracted from PadTab the same way
Gyro and Macros already were.
Also drop the -fexceptions/no-PCH carve-out on GSLsfg.cpp: it existed because
lsfg-vk-android reported failure by throwing, and the Eden port has no throw
sites.
Two reported bugs, unrelated to each other.
RESET (takanome9104, confirmed by lugnel): per-game settings such as affinity and
GS multithreading survived a full reset.
purgeAllSettingsFiles deleted PCSX2-Android.ini and gamesettings/ from
currentInitDataRoot — one root. A device with a configured system directory has
TWO, and on a device that has been moved between them gamesettings/ exists under
both; the surviving copy is re-read on the next launch. Verified on the test
device: gamesettings/ present under BOTH the SD root and app-private storage.
Every known root is purged now. Deleting a file that is already gone is free, so
casting wide costs nothing.
This is the same single-root assumption that hid save states from the library's
long-press menu earlier today. Worth suspecting wherever this codebase resolves
'the' data directory.
The prefs clear also moved from apply() to commit(). restartApp calls
Runtime.exit(0) on the next line, and apply() only guarantees the in-memory
update — its disk write is asynchronous and an abrupt exit can beat it. A reset
that survives the restart is the entire point of the button.
FAST FORWARD (SKrazy on an AYN pad, Shmoda12 on a Thor): Fast Forward (Toggle)
bound to L2/R2 came on for a frame and then reported OFF. It worked as Hold, it
worked on a non-trigger button like R3, and it worked once the pad was switched
to digital triggers.
Those three facts together say it: some pads report a trigger BOTH as an axis and
as a key event, so one pull reaches the hotkey dispatcher twice — once from
sendTrigger, once from the key path. For a HOLD that is harmless, since both
compute the same state from the same edge. For a TOGGLE the first flips it on and
the second immediately flips it back. Digital triggers send only key events, which
is why that setting 'fixed' it.
The axis path now claims the press and the key path skips its own edge. Scoped to
L2/R2 alone so nothing else changes, and cleared on release so the next pull
re-arms. sendTrigger already carried a comment about pads that report triggers
both ways — the hold path had been made safe against them, the hotkey path had
not.
Calum Robinson's Flurry screensaver (2002, BSD-3-clause) as a live library
backdrop, already shipping in ARMSX3. Source port, not the Windows .scr — every
upstream copyright header is intact.
Its own SHARED target rather than folded into the core, for two reasons: it is
BSD next to GPL and that boundary should be visible, and it is plain C from 2002
that wants none of the C++20 the emulator is built with. gl_compat.c answers the
GL 1.x calls the sources make — client-side vertex arrays, a fixed-function
ortho, GL_QUADS — with a GLES2 shader, so the renderer stays unmodified.
★ The integration differs from the brief, which surveyed the refresh-experimental
checkout. That tree has ui/GamesList.kt and no background system, so it needed a
new full-bleed host. This one already has one: Flurry slots in beside XmbGlView
under the same libraryBg == null branch and inherits its fallback — if GL cannot
come up we get LibraryWaveBackground rather than a hole. onRelease stops the
render thread, without which the EGL thread outlives the composition and keeps
drawing to a dead surface.
★ add_dependencies had to move BELOW the emucore target. Stated next to the
flurry target — which is defined earlier in the file — configure fails outright,
because add_dependencies requires a target that already exists. The dependency
itself is required: libflurry.so is loaded by System.loadLibrary and never
linked, so nothing else in the build would make it, and a missing .so stays
invisible until someone switches the background on and the view throws
UnsatisfiedLinkError.
Off by default, and said plainly in the description. It is a particle simulation
rather than a still, and this library shipped a looping video once and lost it in
2.5.9 when the continuous decode turned out to cost real performance. Preset
picker included, with random as the default choice.
A UI preference, so prefs rather than the twelve-site Settings.kt path — it does
not touch the emulator config.
Verified: libflurry.so is packaged and exports all six JNI entry points. NOT yet
run — Flurry's frame cost has never been measured on either project, and Water
spawns nine flurries, so that number is still owed before it is recommended.