6 Commits
Author SHA1 Message Date
jpolo1224 dca685b67d Android: 0.9.4.3 (versionCode 23) 2026-08-23 02:51:07 -04:00
jpolo1224 78650f32d8 Savers: always run gl1_init(), never trust a stale g_started
port_new opened with "if (g_started) return 1". That was defensible while
gl1's state was whatever the previous saver left behind, but nativeInit now
calls gl1_lost() before every create, so gl1 is guaranteed DOWN on entry.
Reporting success there would hand the caller a saver with no shim under it.

So the gl1_lost() added alongside the leak fix did not just close the leak,
it turned that early return from redundant into wrong -- its safety rested
on a call-graph property that same change removed. All six ports now tear
down a stale run through port_free and always run gl1_init(). Each needs a
forward declaration of port_free, which is defined below port_new.

No behaviour change on any path reachable today: port_free always clears
g_started, so the guard never fires. The point is that a saver added later,
or an upstream cleanup that returns early, should fail in its own saver
rather than poison the next one -- the same reason port_free tears gl1 down
unconditionally.

Adapted from the ARMSX2 change (be674a64c5).
2026-08-23 02:44:04 -04:00
jpolo1224 24951bb863 Fix animated background locking users out of the app
Ported from the ARMSX2 fix; ARMSX3 had the same bug, plus a sixth saver.

A saver that dies natively made the app 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.

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 other way, since returning 0 when
initSaver() leaves readyToDraw clear means port_free is never called at all.
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.

Every port now gives gl1 back on every exit, unconditionally, so the rule
holds by construction rather than by arguing about which paths are
reachable. gl1_lost() already existed here but was never called; nativeInit
calls it now as the standing invariant, so a new context cannot inherit old
GL names even if a saver added later forgets.

Containment, independent of the fix: native GL can always find a new way to
die, so 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 -- the
background switches off and a toast names it. A crash or a kill cannot reach
the disarm; that is the entire signal.

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.
2026-08-23 02:15:41 -04:00
jpolo1224 620eaf5066 Oboe: correct a device that opens at the wrong sample rate
The backend asked for the guest's rate with sample rate conversion
disabled, then recorded the rate it ASKED for rather than the one the
stream actually opened at. A device that cannot run at the guest rate
opens at its own, and with conversion off nothing resamples -- so the
emulator feeds samples at one rate into a device consuming them at
another. That is a permanent rate mismatch, not a one-off pitch error:
the gap between what is heard and what is on screen grows for as long as
playback continues, and nothing downstream can see it, because every
later calculation (including the buffering algorithm's idea of how much
audio is queued) uses the rate we requested.

Check the opened rate and, on a mismatch, reopen letting Oboe convert.
Converting is a worse signal path than matching rates outright and a far
better one than not converting at all; Cubeb resamples for the same
reason. If the rate is still wrong after that, say so in the log rather
than drifting in silence.

Refs #87
2026-08-23 01:52:24 -04:00
jpolo1224 5d91f8c56b Say why an ISO failed to boot instead of a bare error
load_iso() cannot report failure: it mounts the virtual device whether or
not the archive actually opened. An image that is unreadable, truncated,
or encrypted without a usable disc key therefore sailed past the mount and
died much further down as a generic 'invalid file or folder', with nothing
in the log distinguishing it from a dozen other causes. A bug report of
that failure carries no information at all.

Check the mount where the reason is still knowable. An image whose
filesystem did not parse now reports whether a disc key was missing or
rejected -- returning decryption_error, and naming both the key locations
searched -- or, failing that, says the image looks truncated. When the
archive is readable but has no EBOOT.BIN at the expected path, log what the
disc root did contain; install discs legitimately lack one, so that stays a
warning rather than a failure.

Also realign BootResult with game_boot_result. It was missing
firmware_version and database_config_missing, so every code from ordinal 10
down was reported as its neighbour -- still_running surfaced as
'AlreadyAdded' -- and the last two had no entry at all, making fromInt
throw rather than return. fromInt is now total.

Refs #88
2026-08-23 01:47:38 -04:00
jpolo1224 06b33abf27 Send rumble to the controller, not the phone
Rumble always went to the phone's own motor: the vibrator lookup asked
the system service and never considered the connected pad. On a handheld
that is the wrong motor outright, and on a phone-plus-controller setup it
buzzes the device sitting in a dock while the pad in hand stays still.

Prefer the first connected gamepad or joystick reporting a working motor,
falling back to the phone. The target is resolved per state change rather
than cached for the pump's lifetime, so connecting or disconnecting a pad
mid-session moves rumble with it; the motor that was last started is
tracked separately so unplugging mid-rumble cannot leave one buzzing.

Add a 'Vibrate the phone' toggle gating only that fallback, so playing on
a pad need not mean the phone rumbles along with it. Touch haptics stay
on the phone deliberately -- the finger is on the phone's screen.

Closes #89
2026-08-23 01:39:03 -04:00
25 changed files with 521 additions and 58 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
// agree -- an APK that installs below its core's target is a dlopen failure at boot.
minSdk = (project.findProperty("armsx3.minSdk") as String?)?.toInt() ?: 33
targetSdk = 37
versionCode = 22
versionName = "0.9.4.2"
versionCode = 23
versionName = "0.9.4.3"
// ARMSX2's UI reads these. STORAGE_ALL_FILES gates the all-files storage path in
// onboarding; IN_APP_UPDATER gates the in-app GitHub-release updater.
@@ -23,10 +23,21 @@ namespace { bool g_started = false; }
extern "C" {
/* Defined below. port_new tears a stale run down through it rather than trusting
* g_started, so the declaration has to come first. */
void flux_port_free();
/* preset is 1..6, matching the saver's own DEFAULTS1..DEFAULTS6. */
int flux_port_new(int preset)
{
if (g_started) return 1;
/* NOT "if (g_started) return 1": nativeInit calls gl1_lost() before every create, so
* gl1 is guaranteed DOWN on entry now. Reporting success here would hand the caller a
* saver with no shim under it. That guard was only ever safe because gl1 state
* survived between savers -- which is exactly the property gl1_lost() removed, so it
* went from redundant to wrong. Unreachable today (port_free always clears g_started),
* but the rule is that a stale run is torn down and gl1_init() always runs, rather
* than that every caller gets the ordering right. */
if (g_started) flux_port_free();
if (!gl1_init()) return 0;
if (preset < 1 || preset > 6) preset = 1;
@@ -40,7 +51,14 @@ int flux_port_new(int preset)
saver_flux::initSaver();
g_started = saver_flux::readyToDraw != 0;
return g_started ? 1 : 0;
if (!g_started) {
/* Returning 0 means the JNI never calls port_free, so this is the only chance to give
* gl1 back. Leaving it up would strand g.ready with names from a context that is about
* to die, and gl1_init() early-returns on g.ready -- poisoning the NEXT saver. */
gl1_shutdown();
return 0;
}
return 1;
}
void flux_port_resize(int width, int height)
@@ -58,11 +76,20 @@ void flux_port_draw()
void flux_port_free()
{
if (!g_started) return;
saver_flux::cleanUp();
/* NOT "if (!g_started) return": port_new releases gl1 itself on the path where it
* returns 0, so this is unreachable with g_started false today. It is written this
* way so that stays true by construction rather than by that argument -- the rule is
* that gl1 goes back on every exit, in every port, without a caller having to reason
* about which ones can be skipped. */
if (g_started) {
saver_flux::cleanUp();
saver_flux::readyToDraw = 0;
g_started = false;
}
/* Belongs to the EGL context that is about to be destroyed; leaving g.ready set means
* gl1_init() early-returns for the NEXT saver and hands it dead GL names. Idempotent,
* and a no-op if gl1 was never up. */
gl1_shutdown();
saver_flux::readyToDraw = 0;
g_started = false;
}
} /* extern "C" */
@@ -72,7 +72,13 @@
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "Savers", __VA_ARGS__)
#define STACK_DEPTH 32
#define GL1_MAX_LISTS 16
/* Sixteen was not enough for two of the savers, and the failure was silent-ish: glGenLists
* returned 0, every subsequent glNewList/glCallList logged "bad list", and the geometry those
* lists held simply never drew. Lattice asks for 20 in one range (NUMOBJECTS), and Skyrocket
* accumulates 16 across separate calls -- flare 4, smoke 5, and seven singles in world.cpp.
* Names are 1-based, so the old ceiling allowed a longest run of 15 and neither could ever
* succeed. Sixty-four is a slot table of a pointer and two ints apiece: about a kilobyte. */
#define GL1_MAX_LISTS 64
typedef struct {
float pos[3];
@@ -24,10 +24,21 @@ namespace { bool g_started = false; }
extern "C" {
/* Defined below. port_new tears a stale run down through it rather than trusting
* g_started, so the declaration has to come first. */
void hyperspace_port_free();
int hyperspace_port_new(int preset)
{
(void) preset; /* No presets upstream; every knob was a registry value. */
if (g_started) return 1;
/* NOT "if (g_started) return 1": nativeInit calls gl1_lost() before every create, so
* gl1 is guaranteed DOWN on entry now. Reporting success here would hand the caller a
* saver with no shim under it. That guard was only ever safe because gl1 state
* survived between savers -- which is exactly the property gl1_lost() removed, so it
* went from redundant to wrong. Unreachable today (port_free always clears g_started),
* but the rule is that a stale run is torn down and gl1_init() always runs, rather
* than that every caller gets the ordering right. */
if (g_started) hyperspace_port_free();
if (!gl1_init()) return 0;
saver_hyperspace::setDefaults();
@@ -59,11 +70,21 @@ void hyperspace_port_draw()
void hyperspace_port_free()
{
if (!g_started) return;
saver_hyperspace::cleanUp();
/* NOT "if (!g_started) return": port_new sets g_started on every path that returns 1 today, so this
* is defensive -- but the point is that gl1 is released regardless of it. See the note below. */
if (g_started) {
saver_hyperspace::cleanUp();
saver_hyperspace::readyToDraw = 0;
g_started = false;
}
/* gl1_init() ran in port_new, and everything it holds -- the shader program, the vertex
* buffers -- belongs to the EGL context that is about to be destroyed. Returning without
* gl1_shutdown() leaves gl1's g.ready set with GL names from a DEAD context, and gl1_init()
* early-returns on g.ready. The next saver, in a NEW context, would then run against those
* dead names: undefined behaviour that some drivers answer with a segfault rather than a GL
* error, which takes the whole app down. So gl1 is torn down whether or not this saver's own
* init ever got as far as running. gl1_shutdown() is idempotent. */
gl1_shutdown();
saver_hyperspace::readyToDraw = 0;
g_started = false;
}
} /* extern "C" */
@@ -23,9 +23,20 @@ int g_preset = 1;
extern "C" {
/* Defined below. port_new tears a stale run down through it rather than trusting
* g_started, so the declaration has to come first. */
void lattice_port_free();
int lattice_port_new(int preset)
{
if (g_started) return 1;
/* NOT "if (g_started) return 1": nativeInit calls gl1_lost() before every create, so
* gl1 is guaranteed DOWN on entry now. Reporting success here would hand the caller a
* saver with no shim under it. That guard was only ever safe because gl1 state
* survived between savers -- which is exactly the property gl1_lost() removed, so it
* went from redundant to wrong. Unreachable today (port_free always clears g_started),
* but the rule is that a stale run is torn down and gl1_init() always runs, rather
* than that every caller gets the ordering right. */
if (g_started) lattice_port_free();
if (!gl1_init()) return 0;
g_preset = (preset >= 1 && preset <= 6) ? preset : 1;
@@ -53,11 +64,21 @@ void lattice_port_draw()
void lattice_port_free()
{
if (!g_started) return;
saver_lattice::cleanUp();
/* NOT "if (!g_started) return": this saver waits for a surface size before it initialises,
* so it can be created and torn down having never started. See the note below. */
if (g_started) {
saver_lattice::cleanUp();
saver_lattice::readyToDraw = 0;
g_started = false;
}
/* gl1_init() ran in port_new, and everything it holds -- the shader program, the vertex
* buffers -- belongs to the EGL context that is about to be destroyed. Returning without
* gl1_shutdown() leaves gl1's g.ready set with GL names from a DEAD context, and gl1_init()
* early-returns on g.ready. The next saver, in a NEW context, would then run against those
* dead names: undefined behaviour that some drivers answer with a segfault rather than a GL
* error, which takes the whole app down. So gl1 is torn down whether or not this saver's own
* init ever got as far as running. gl1_shutdown() is idempotent. */
gl1_shutdown();
saver_lattice::readyToDraw = 0;
g_started = false;
}
} /* extern "C" */
@@ -16,9 +16,20 @@ namespace { bool g_started = false; }
extern "C" {
/* Defined below. port_new tears a stale run down through it rather than trusting
* g_started, so the declaration has to come first. */
void plasma_port_free();
int plasma_port_new(int preset)
{
if (g_started) return 1;
/* NOT "if (g_started) return 1": nativeInit calls gl1_lost() before every create, so
* gl1 is guaranteed DOWN on entry now. Reporting success here would hand the caller a
* saver with no shim under it. That guard was only ever safe because gl1 state
* survived between savers -- which is exactly the property gl1_lost() removed, so it
* went from redundant to wrong. Unreachable today (port_free always clears g_started),
* but the rule is that a stale run is torn down and gl1_init() always runs, rather
* than that every caller gets the ordering right. */
if (g_started) plasma_port_free();
if (!gl1_init()) return 0;
saver_plasma::setDefaults();
@@ -42,7 +53,14 @@ int plasma_port_new(int preset)
saver_plasma::initSaver();
g_started = saver_plasma::readyToDraw != 0;
return g_started ? 1 : 0;
if (!g_started) {
/* Returning 0 means the JNI never calls port_free, so this is the only chance to give
* gl1 back. Leaving it up would strand g.ready with names from a context that is about
* to die, and gl1_init() early-returns on g.ready -- poisoning the NEXT saver. */
gl1_shutdown();
return 0;
}
return 1;
}
void plasma_port_resize(int width, int height)
@@ -59,11 +77,20 @@ void plasma_port_draw()
void plasma_port_free()
{
if (!g_started) return;
saver_plasma::cleanUp();
/* NOT "if (!g_started) return": port_new releases gl1 itself on the path where it
* returns 0, so this is unreachable with g_started false today. It is written this
* way so that stays true by construction rather than by that argument -- the rule is
* that gl1 goes back on every exit, in every port, without a caller having to reason
* about which ones can be skipped. */
if (g_started) {
saver_plasma::cleanUp();
saver_plasma::readyToDraw = 0;
g_started = false;
}
/* Belongs to the EGL context that is about to be destroyed; leaving g.ready set means
* gl1_init() early-returns for the NEXT saver and hands it dead GL names. Idempotent,
* and a no-op if gl1 was never up. */
gl1_shutdown();
saver_plasma::readyToDraw = 0;
g_started = false;
}
}
@@ -10,6 +10,8 @@
#include <cstddef>
#include <mutex>
#include "gl1.h"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "Savers", __VA_ARGS__)
#define SAVER_DECL(name) \
@@ -82,6 +84,21 @@ Java_com_armsx2_ui_home_SaverNative_nativeInit(JNIEnv *, jobject, jint effect, j
/* A previous saver may still be up if the outgoing view has not torn down yet. */
if (g_active) g_active->destroy();
/* Every nativeInit arrives on a freshly created EGL context -- one view, one render thread,
* one context, one create. So whatever GL names gl1 is still holding belong to a context
* that no longer exists, and gl1_init() early-returns on g.ready, which would hand those
* dead names to the saver we are about to start. gl1_lost() drops them without calling GL
* on them (gl1_shutdown() would try to delete them, in the wrong context).
*
* The ports each give gl1 back on their own failure and teardown paths, so this should
* already be a no-op. It is here because it is the invariant that actually matters -- a
* saver added later that forgets, or an upstream cleanup that returns early, would
* otherwise poison the NEXT saver rather than fail visibly in its own. Drivers answer a
* draw against a dead program name with anything from a black screen to a segfault, and a
* segfault here is unrecoverable: the library is the first screen, so the app would crash
* on every launch. */
gl1_lost();
g_active = &k_savers[effect];
if (!g_active->create(preset)) {
LOGE("%s failed to start (preset %d)", g_active->name, preset);
@@ -26,10 +26,21 @@ bool g_started = false;
extern "C" {
/* Defined below. port_new tears a stale run down through it rather than trusting
* g_started, so the declaration has to come first. */
void skyrocket_port_free();
int skyrocket_port_new(int preset)
{
(void) preset; /* No presets upstream; every knob was a registry value. */
if (g_started) return 1;
/* NOT "if (g_started) return 1": nativeInit calls gl1_lost() before every create, so
* gl1 is guaranteed DOWN on entry now. Reporting success here would hand the caller a
* saver with no shim under it. That guard was only ever safe because gl1 state
* survived between savers -- which is exactly the property gl1_lost() removed, so it
* went from redundant to wrong. Unreachable today (port_free always clears g_started),
* but the rule is that a stale run is torn down and gl1_init() always runs, rather
* than that every caller gets the ordering right. */
if (g_started) skyrocket_port_free();
if (!gl1_init()) return 0;
saver_skyrocket::setDefaults();
@@ -69,11 +80,21 @@ void skyrocket_port_draw()
void skyrocket_port_free()
{
if (!g_started) return;
saver_skyrocket::cleanup();
/* NOT "if (!g_started) return": this saver waits for a surface size before it initialises,
* so it can be created and torn down having never started. See the note below. */
if (g_started) {
saver_skyrocket::cleanup();
saver_skyrocket::readyToDraw = 0;
g_started = false;
}
/* gl1_init() ran in port_new, and everything it holds -- the shader program, the vertex
* buffers -- belongs to the EGL context that is about to be destroyed. Returning without
* gl1_shutdown() leaves gl1's g.ready set with GL names from a DEAD context, and gl1_init()
* early-returns on g.ready. The next saver, in a NEW context, would then run against those
* dead names: undefined behaviour that some drivers answer with a segfault rather than a GL
* error, which takes the whole app down. So gl1 is torn down whether or not this saver's own
* init ever got as far as running. gl1_shutdown() is idempotent. */
gl1_shutdown();
saver_skyrocket::readyToDraw = 0;
g_started = false;
}
} /* extern "C" */
@@ -15,11 +15,22 @@ namespace { bool g_started = false; }
extern "C" {
/* Defined below. port_new tears a stale run down through it rather than trusting
* g_started, so the declaration has to come first. */
void solarwinds_port_free();
/* preset is 1..6 from the UI. Upstream's DEFAULTS1..DEFAULTS6 is a zero-based ENUM here, not
* the 1-based #defines Flux uses, so the UI value is shifted down. */
int solarwinds_port_new(int preset)
{
if (g_started) return 1;
/* NOT "if (g_started) return 1": nativeInit calls gl1_lost() before every create, so
* gl1 is guaranteed DOWN on entry now. Reporting success here would hand the caller a
* saver with no shim under it. That guard was only ever safe because gl1 state
* survived between savers -- which is exactly the property gl1_lost() removed, so it
* went from redundant to wrong. Unreachable today (port_free always clears g_started),
* but the rule is that a stale run is torn down and gl1_init() always runs, rather
* than that every caller gets the ordering right. */
if (g_started) solarwinds_port_free();
if (!gl1_init()) return 0;
if (preset < 1 || preset > 6) preset = 1;
@@ -27,7 +38,14 @@ int solarwinds_port_new(int preset)
saver_solarwinds::initSaver();
g_started = saver_solarwinds::readyToDraw != 0;
return g_started ? 1 : 0;
if (!g_started) {
/* Returning 0 means the JNI never calls port_free, so this is the only chance to give
* gl1 back. Leaving it up would strand g.ready with names from a context that is about
* to die, and gl1_init() early-returns on g.ready -- poisoning the NEXT saver. */
gl1_shutdown();
return 0;
}
return 1;
}
void solarwinds_port_resize(int width, int height)
@@ -45,11 +63,20 @@ void solarwinds_port_draw()
void solarwinds_port_free()
{
if (!g_started) return;
saver_solarwinds::cleanUp();
/* NOT "if (!g_started) return": port_new releases gl1 itself on the path where it
* returns 0, so this is unreachable with g_started false today. It is written this
* way so that stays true by construction rather than by that argument -- the rule is
* that gl1 goes back on every exit, in every port, without a caller having to reason
* about which ones can be skipped. */
if (g_started) {
saver_solarwinds::cleanUp();
saver_solarwinds::readyToDraw = 0;
g_started = false;
}
/* Belongs to the EGL context that is about to be destroyed; leaving g.ready set means
* gl1_init() early-returns for the NEXT saver and hands it dead GL names. Idempotent,
* and a no-op if gl1 was never up. */
gl1_shutdown();
saver_solarwinds::readyToDraw = 0;
g_started = false;
}
}
@@ -906,6 +906,9 @@ val EN: Map<String, String> = mapOf(
"pad.players.help" to "The PS3 has seven controller ports and no multitap, so up to seven pads work with no setup. Connect them before launching — the order they first press a button in is the order they are assigned.",
"pad.rumble.description" to "Master switch for controller rumble and the device's built-in vibration. Turn off to silence all haptics.",
"pad.rumble.label" to "Rumble / Vibration",
"pad.rumblePhone.label" to "Vibrate the phone",
"pad.rumblePhone.description" to
"Use the phone's own motor when no controller has one. Turn this off to keep rumble on the controller only.",
"pad.hapticStrength.description" to "Scales all vibration — controller rumble and on-screen touch haptics alike. Below 100% tames a strong motor; above 100% boosts a weak one.",
"pad.hapticStrength.label" to "Vibration Strength",
"pad.scopeHint.global" to "â—‹ Editing GLOBAL controls (all games).",
@@ -393,6 +393,17 @@ object ControllerMappings {
com.armsx3.NativeApp.sRumbleEnabled = on
}
// Whether the PHONE's motor may be used. Rumble prefers a connected controller's motor and
// falls back to the phone; this gates only that fallback, so playing on a pad need not mean
// the phone buzzes too. Mirrored into NativeApp.sPhoneRumbleEnabled the same way KEY_RUMBLE
// is — live on change and at app start. Default on, so a phone-only player is unaffected.
private const val KEY_RUMBLE_PHONE = "pad.rumble.phone"
fun phoneRumbleEnabled(): Boolean = MainActivityRuntime.prefs.getBoolean(KEY_RUMBLE_PHONE, true)
fun setPhoneRumbleEnabled(on: Boolean) {
MainActivityRuntime.prefs.edit { putBoolean(KEY_RUMBLE_PHONE, on) }
com.armsx3.NativeApp.sPhoneRumbleEnabled = on
}
// Haptic strength: one multiplier scaling ALL vibration — controller rumble AND on-screen
// touch ticks both funnel through NativeApp.rumbleOne. 0..200 % (100 = as the game/UI
// authored it), so it tames a too-strong motor or boosts a weak one. Persisted and mirrored
@@ -2157,6 +2157,7 @@ open class MainActivityRuntime : ComponentActivity() {
startAutosaveIntervalJob()
// Restore the saved rumble master toggle into the native gate (NativeApp.onPadRumble).
NativeApp.sRumbleEnabled = ControllerMappings.rumbleEnabled()
NativeApp.sPhoneRumbleEnabled = ControllerMappings.phoneRumbleEnabled()
// Push the saved haptic strength + achievement-sound volume into their native gates before
// any rumble or unlock sound can fire (both default to 1.0 = as authored until set here).
ControllerMappings.syncHapticIntensity()
@@ -147,6 +147,19 @@ fun HomeScreen(
var showClearRecentsConfirm by remember { mutableStateOf(false) }
// #9 custom library background — inert until the user picks an image.
LaunchedEffect(Unit) { LibraryBackground.ensureLoaded(); CoverArtStyle.load() }
// The animated background switched itself off because the last run died with it on screen
// (LibraryBackground.armSaver). Say so -- silently reverting a setting the user chose reads
// as the setting being broken, and the name tells them which one to avoid.
LaunchedEffect(LibraryBackground.crashedSaver.value) {
LibraryBackground.crashedSaver.value?.let { kind ->
LibraryBackground.crashedSaver.value = null
Toast.makeText(
context,
"Animated background turned off: ${LibraryBackground.saverName(kind)} crashed last time.",
Toast.LENGTH_LONG,
).show()
}
}
val backgroundPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { picked ->
picked?.let { LibraryBackground.set(context, it) }
}
@@ -21,6 +21,12 @@ object LibraryBackground {
private const val PREF_FLURRY_PRESET = "library.background.flurry.preset"
private const val PREF_SAVER_KIND = "library_saver_kind"
private const val PREF_RSS_PRESET = "library_rss_preset"
/**
* Which saver is CURRENTLY running, written synchronously before its GL thread starts and
* cleared when that thread exits in an orderly way. See [armSaver].
*/
private const val PREF_ARMED = "library.saver.armed"
val uri = mutableStateOf<String?>(null)
/**
@@ -60,6 +66,53 @@ object LibraryBackground {
/** Preset for whichever Really Slick saver is selected, 1..6. 99 = pick one each time. */
val rssPreset = mutableStateOf(99)
/**
* Set at startup when the previous run died with a saver on screen. Holds the [saverKind]
* that was running so the library can say which one, and so the user knows their background
* was turned off deliberately rather than forgotten. Read once and cleared by the reader.
*/
val crashedSaver = mutableStateOf<Int?>(null)
/** Display name for a [saverKind], for the message above. Matches the settings list. */
fun saverName(kind: Int): String = when (kind) {
1 -> "Flux"; 2 -> "Plasma"; 3 -> "SolarWinds"
4 -> "Hyperspace"; 5 -> "Lattice"; 6 -> "Skyrocket"
else -> "Flurry"
}
/**
* Crash-loop breaker.
*
* The savers are native GL code, and native GL code can take the process down in ways no
* `runCatching` can see -- a SIGSEGV in a driver, or a hang that Android resolves by killing
* us. Because the choice is persisted and the library is the FIRST screen, a saver that dies
* on startup dies again on every launch: the app never gets far enough for anyone to reach
* Settings and switch it off. The only escape is clearing app data, which on Android takes
* the memory cards and save states with it.
*
* So the setting arms itself before the GL thread starts and disarms when that thread exits
* normally. Finding it still armed at startup means last run ended while a saver was on
* screen -- the background is switched off and the user is told which one did it. The write
* must be commit() rather than apply(): apply() is asynchronous, and the whole point is that
* the process may be about to die.
*/
fun armSaver() {
runCatching {
MainActivityRuntime.prefs.edit().putInt(PREF_ARMED, saverKind.value).commit()
}
}
/**
* Orderly teardown -- the saver ran without taking the process with it. Idempotent.
*
* apply() rather than commit() on purpose, and the asymmetry with [armSaver] is the point:
* this one is not racing the process's death, and if it were lost the cost is a background
* switched off for no reason, which is recoverable from Settings. The arm must never be lost.
*/
fun disarmSaver() {
runCatching { MainActivityRuntime.prefs.edit().remove(PREF_ARMED).apply() }
}
private var loaded = false
fun ensureLoaded() {
@@ -71,6 +124,14 @@ object LibraryBackground {
flurryPreset.value = runCatching { MainActivityRuntime.prefs.getInt(PREF_FLURRY_PRESET, 99) }.getOrDefault(99)
saverKind.value = runCatching { MainActivityRuntime.prefs.getInt(PREF_SAVER_KIND, 0) }.getOrDefault(0)
rssPreset.value = runCatching { MainActivityRuntime.prefs.getInt(PREF_RSS_PRESET, 99) }.getOrDefault(99)
// Still armed = the previous run died with a saver up. Break the loop (see armSaver).
val armed = runCatching { MainActivityRuntime.prefs.getInt(PREF_ARMED, -1) }.getOrDefault(-1)
if (armed >= 0) {
crashedSaver.value = armed
setFlurry(false)
disarmSaver()
}
}
fun setAnimated2D(on: Boolean) {
@@ -97,8 +97,22 @@ class SaverGlView(context: Context, private val spec: SaverSpec) :
}
override fun onSurfaceTextureAvailable(st: SurfaceTexture, w: Int, h: Int) {
thread = RenderThread(st, w, h, spec) { ok -> post { onGlStatus?.invoke(ok) } }
.also { it.start() }
// Arm the crash-loop breaker for as long as native GL code is running on our behalf; the
// thread disarms it when it exits in an orderly way. See LibraryBackground.armSaver.
LibraryBackground.armSaver()
// start() asks for a 16MB stack (see STACK_BYTES) and can throw OutOfMemoryError on a
// constrained device. That would be an uncaught throw on the MAIN thread -- the process
// dies, and since this is the first screen the app would be unlaunchable. Fall back to
// the 2D backdrop instead, the same way an EGL failure does.
thread = runCatching {
RenderThread(st, w, h, spec) { ok -> post { onGlStatus?.invoke(ok) } }
.also { it.start() }
}.getOrElse {
Log.w(TAG, "saver thread failed to start", it)
LibraryBackground.disarmSaver()
onGlStatus?.invoke(false)
null
}
}
override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, w: Int, h: Int) {
@@ -152,9 +166,26 @@ class SaverGlView(context: Context, private val spec: SaverSpec) :
private var saver: Saver? = null
fun resize(w: Int, h: Int) { width = w; height = h; sizeDirty = true }
fun finish() { running = false; runCatching { join(500) } }
fun finish() {
running = false
runCatching { join(500) }
// join() is bounded, so the thread's own finally may not have run yet. We asked it to
// stop and the process is still here, which is all the breaker needs to know.
LibraryBackground.disarmSaver()
}
override fun run() {
// Reaching the end of this function at all -- however the saver did -- means the
// process survived it, which is the only thing the breaker is asking about. A native
// crash or a kill never gets here, and that is what leaves the flag set.
try {
render()
} finally {
LibraryBackground.disarmSaver()
}
}
private fun render() {
if (!initEgl()) { onStatus(false); teardown(); return }
val s = spec.newSaver()
@@ -260,6 +260,16 @@ fun PadTab(state: MutableState<Settings>) {
ControllerMappings.setRumbleEnabled(it)
refreshToken.intValue++
}
// Rumble prefers a connected controller's motor; this gates only the phone fallback,
// so playing on a pad need not mean the phone buzzes along with it.
ToggleRow(
str("pad.rumblePhone.label"),
ControllerMappings.phoneRumbleEnabled(),
description = str("pad.rumblePhone.description"),
) {
ControllerMappings.setPhoneRumbleEnabled(it)
refreshToken.intValue++
}
// Vibration strength: one multiplier over BOTH controller rumble and on-screen touch
// haptics (they share the motor path), so an over-eager motor can be tamed or a weak
// one boosted. 100% = as authored; 0% = off.
@@ -101,6 +101,7 @@ internal val SETTINGS_SEARCH_INDEX: List<SettingsSearchEntry> = listOf(
SettingsSearchEntry("pad.section.playerRumble", true, SettingsCategory.Controls),
SettingsSearchEntry("pad.editing.label", true, SettingsCategory.Controls),
SettingsSearchEntry("pad.rumble.label", true, SettingsCategory.Controls),
SettingsSearchEntry("pad.rumblePhone.label", true, SettingsCategory.Controls),
SettingsSearchEntry("pad.hapticStrength.label", true, SettingsCategory.Controls),
SettingsSearchEntry("pad.pressureAmount.label", true, SettingsCategory.Controls),
SettingsSearchEntry("pad.section.analogSticks", true, SettingsCategory.Controls),
@@ -247,6 +247,13 @@ public final class NativeApp {
/** Master rumble toggle. */
public static volatile boolean sRumbleEnabled = true;
/**
* Whether the PHONE's own motor may be used. A controller's motor is always allowed; this
* only gates the fallback, so a user playing on a pad can stop the phone buzzing in their
* pocket or dock without giving up rumble entirely (issue #89). Default on.
*/
public static volatile boolean sPhoneRumbleEnabled = true;
/** Volume applied to UI sounds played through NativeApp.playSound. */
public static volatile float sSoundVolume = 1.0f;

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