SONIC THE HEDGEHOG (2006) renders only its HUD and skybox and flickers
everything else in and out of existence. That is upstream RPCS3 issue
#4122, open since 2018: a PPU/SPU race where an SNR is overwritten while
still non-empty, so the SPU jobs feeding geometry lose their signal.
Upstream never fixed it in code. The fix is elad335's canary patch, which
hooks RPCS3_HLE_LIBRARY:WaitForSPUsToEmptySNRs -- a function that already
exists in the core and has no other user. The patch is not in the official
feed: neither our stored database nor a fresh pull from rpcs3.net carries
a single BLUS30008 entry, jumpf op, or RPCS3_HLE_LIBRARY reference. Desktop
users add it by hand through the Patch Manager's import button, which has
no equivalent here, so on Android the game was simply broken.
Ship it as an asset and merge it into patch.yml at boot, before the core
reads that file and once the config directory is known to exist. Enable it
rather than only listing it: a user who has to find and tick a box before
the game renders has already decided the emulator is broken. Gated on a
stored revision, not run every boot, so turning it off sticks.
Everything ARM64 needs is already in this tree -- the calloc code-cave
registry, the is_faux_function guard that keeps the tail-call return trap
from firing on a patch-point, and the PPU LLVM filter for patched
functions -- so the patch applies here rather than crashing as it once did
on ARM.
Keyed by PPU hash, so it covers only the dump it was built for; other
regions and revisions need their own entry.
get_per_core_usage() fills the per-core vector with zeros and then wraps
the whole Linux /proc/stat body in #ifndef ANDROID, because an app cannot
read the per-cpu lines. The exclusion left the zeros in place, so the
monitor did not go quiet -- it reported an idle machine. On device it
logged "CPU Usage: Total: 0.0%, Cores: 0.0%, 0.0%, ..." while three
emulator threads were pegged at 100%, which hides exactly the class of
problem the monitor exists to surface.
Report process usage from times(), which is POSIX and readable by our own
process, and leave the per-core vector empty so perf_monitor prints no
"Cores:" list rather than a fabricated one.
When the transfer queue was drained the offload thread called
std::this_thread::yield() in a tight loop, which is a sched_yield()
spin rather than a wait. Measured on an Adreno 740 handheld it burned
1142s of CPU over a 1145s session, 85% of it system time, for no work
at all -- a whole core taken from the RSX and SPU threads that need it,
plus the battery and thermal budget that goes with it.
Wait on the queue instead. lf_queue::push notifies on the empty to
non-empty transition so a real job still wakes the thread immediately
and transfer latency is unchanged, and if a push lands between the
emptiness check and the wait the atomic is already non-zero so there is
no lost wakeup. The timeout is only there so the loop can re-check
thread_ctrl::state(): aborting pushes nothing, so an untimed wait would
never return and shutdown would hang instead of spin.
dma_manager::sync() is unaffected -- it only waits while work is
outstanding, which is exactly when the thread is not idle.
Ours was false against upstream's true, so the curated push wrote false over the
node on every boot and every install ran with an accuracy fixup disabled that
RPCS3 ships on. Games that depend on it get NaNs through vector maths, which
shows up as geometry behaving impossibly rather than as any kind of error.
The disagreement was not free either. The node feeds ppu_settings::fixup_vnan,
which is part of the compiled PPU object filename, so flipping it renames every
object and the next boot recompiles all of them. A device here carries both
variants, 76 modules under one key and 58 under the other, from one flip.
Audited the other two settings in that key: Accurate Cache Line Stores and Use
Accurate DFMA both already match upstream. This was the only one adrift.
All three now say that changing them rebuilds compiled PPU code, which is the
part that had no way of being known from the screen.
The unresponsive-stop callback reported that a stop was hanging but not what was
holding it, and that is the one thing that cannot be recovered afterwards: from
outside, a wedged shutdown looks like a sleeping thread with no CPU time and no
reason attached.
Enumerate the SPU threads still registered and log their cpu_flag state. Whether
exit was ever delivered, whether the thread is parked in a wait, or whether it
never left its initial stopped state are three different faults with three
different fixes, and the flags separate them.
This is what exposed the failed savestate: the thread that would not stop was
debris from a save that had already been abandoned, not a shutdown bug.
It was turned on so savestates could save at all, and savestates are no longer a
feature: a PS3 state runs 500MB to 3GB, which fills a phone in a handful of
saves. What is left is the cost, and it is a real one, which is why upstream
defaults it off.
Written as false rather than dropped from the push. It shipped as true for a
while, so installs from that window have it persisted in config.yml and would go
on paying SPU performance for something nothing uses.
Also drops the thumbnail read logging, which did its job: it proved the read
path was never called, because the in-game menu has its own numbered slot list
and only the touch overlay's picker asks for a preview.
Savestates could not work without it. Saving locks every SPU thread into a state
it can be serialised from, and with this off that lock fails on any title with
SPU work running. The save is abandoned with "failed to lock SPU threads
execution", and the SPU it gave up on then never answers the stop request, so
the join thread spins and the app hangs on the next save or load.
The deadlock chased through the SPU shutdown path was this setting all along.
The state on disk looked valid because a previous attempt had written one.
Upstream defaults it off for SPU performance, which suits a desktop where
savestates are optional. Here they are on the in-game menu and the touch
overlay, and a save that wedges the emulator costs more than slightly slower
SPU emulation.
RPCS3's stop watchdog calls on_emulation_stop_no_response once shutting the VM
down has taken about ten seconds. That callback was a no-op here, so the one
thing upstream does about a deadlocked stop, say so, did not happen: the join
thread went on spinning, the overlay sat at its last figure, and the app looked
frozen with nothing in the log to explain it.
Reported rather than aborted. The desktop build offers to terminate but asks
first, and slow is not the same as stuck: a savestate write was measured here
taking 71 seconds legitimately, so killing the process on a timer would trade a
hang for a corrupted save.
The core already renders the finished frame and hands it to take_screenshot
whenever a screenshot is asked for. That override was empty, so the picture was
built and thrown away and every slot tile had nothing to draw.
Cached rather than captured on demand: a save kills the VM, and the point where
the state is known to be on disk is after_kill_callback, by which time there is
no renderer left to ask. So the save requests a frame on its way in, the
override catches whatever the renderer produces, and the capture step writes it
out once the state has landed.
Stored downscaled to a ~320px edge as AX3T + width + height + RGBA, and
re-encoded to PNG on the Kotlin side, which is where a Bitmap can be built from
raw pixels directly. That keeps a compressor out of the core for something only
ever drawn as a tile. A slot with no picture still loads.
Loading a slot closed the game and dropped to the library. BootGame failed in
the same millisecond it was called, without ever reading the file, and the
Emulation Join Thread warnings then ran for seconds after: the previous VM was
still coming down while BootGame was already being asked to bring the next one
up, so it bailed.
boot_current_game_savestate gets away with that sequence because it runs from a
shortcut handler rather than from inside the emulation callback queue. Kill
first and boot from after_kill_callback, which is the shape the save path uses
and the reason that one works.
The boot result is logged too. A bare failure could not tell a corrupt state
from a version mismatch from a path the loader never accepted.
Every slot tile read as empty and Load was disabled on all ten, whatever was on
disk. Occupancy comes from getGamePathSlot, which was a stub returning the empty
string, so saving had been working with no way to see that it had.
Answer it from hasState, which was written for this and had no callers. The
subtitle is the title id rather than the state's path: the picker strips to the
last segment and drops the extension, which would render a real path as
slot0.SAVESTAT.
RetroArch shaders shifted the colours, pink over anything green. The output
image handed to librashader is the swapchain image but the format reported with
it was the source framebuffer's. librashader builds its output view and render
pass from that, so with a BGRA swapchain and an RGBA source it wrote through a
mismatched view and swapped red with blue instead of converting.
Only the shader path was affected because the bilinear and nearest passes reach
the swapchain through vkCmdBlitImage, which converts formats itself. Carry the
swapchain format through to the pass, refreshed per frame so a swapchain rebuilt
at a new format cannot leave a stale value behind.
RPCS3 keeps savestates as a rolling history addressed by age, 1 being the most
recent, and the app offers ten numbered slots. The slot was dropped on save and
read as an age on load, so saving to slot 3 pushed a new newest state and
loading slot 3 fetched the fourth-newest. States appeared to wander between
slots on their own.
The history stays RPCS3's. A slot is now a copy parked under
savestates/<title>/armsx3_slots/, in its own directory because get_savestate_file
derives the next auto id by listing the title's directory and must not be fed
names it never generated. Copy rather than move: the restart after a save boots
from the file the core just wrote.
At 0 the four direction keys meet in the middle, which only reads correctly on
the flat built-in drawables. The bundled skins draw four separate keys and they
came out as one clumped blob.
Migrated once for existing installs: the key is written by the bulk save, so
anyone who had ever opened a touch setting already had 0 stored and would never
have picked up a new default. Only a stored 0 is rewritten.
The button was always there. The LazyColumn above it had no weight, so in a
Column it took the whole remaining height and left nothing for the row below,
putting Back off the bottom of the screen. With a controller you could still
leave; on a touch-only device this was the one screen with no visible way out.
Weight the list so it shares the space instead of consuming it.
No preview rendered for any downloadable skin. The published manifest carries a
trailing comma before two of its closing braces, which org.json rejects, so
parseManifest returned null and fetch fell through to the git tree, which only
ever reported filenames and set previewPath to null for everything. One comma in
a file this app does not own cost the whole index.
Retry the parse with trailing commas dropped, string aware so a name containing
a comma survives, and pair Previews images to skins by name in the tree fallback
so a broken manifest costs detail rather than every thumbnail.
Changing DEFAULT_SKIN_ID moved nobody. resolveRaw only falls back to the default
when nothing was ever stored, and every install from before the change has a
stored value already, so existing users kept the old pad and reported the new
default as not applying.
Rewrite the stored global skin once, and only when it is absent or the explicit
no-skin sentinel. An install pointing at a real skin chose it on purpose. The
flag is set either way so picking the ARMSX2 row afterwards sticks.
The second row read 'Built-in (default)', which was accurate when the built-in
drawables were the default and wrong the moment a bundled pack took over: it
still claimed to be the default while ARMSX3 Textured actually was. Name it
'ARMSX2' for what it is and tag whichever row matches DEFAULT_SKIN_ID, so the
label follows the constant instead of restating it.
Adds two packs from bagas as app assets, ARMSX3 Textured and ARMSX1, and makes
ARMSX3 Textured what a fresh install gets. The order offered is ARMSX3 Textured,
ARMSX2, ARMSX1, NetherSX2, NetherSX2 Old. Their files arrive named plainly
(L1.png, cross.png), which the loader already handles: it strips ic_controller_
and a trailing _button, so they are renamed to the canonical form on the way in
and both packs land on exactly the key set nethersx2 uses.
The default needed untangling first. Null meant the built-in ARMSX2 drawables
AND was what an install with no stored choice got, so the two were the same
thing and nothing had to tell them apart. They are different now. Resolution
moved into one function both callers use, because ensureLoaded and
applyForSerial each resolved separately and disagreeing would have changed the
pad the first time a game started:
nothing stored the default, ARMSX3 Textured
NONE the ARMSX2 row was chosen, so the drawables
an id that skin, or the default if it names one no longer installed
setActive stores NONE at the global tier rather than removing the key. Removing
it used to mean the drawables; with a bundled default it would now mean "never
chose", so picking ARMSX2 would have silently handed back ARMSX3 Textured on the
next launch. Nobody's existing choice moves: only the absence of a stored value
resolves to the default, and that is not written back, so the default can change
again later without pinning every install to today's answer.
Every built-in now shows a preview strip beside its name, matching what the
downloader already does for remote skins. Baked in as preview.png per pack
rather than composed at runtime: the list draws several at once and compositing
ten bitmaps per row per recomposition is not worth it for a picture that never
changes. keyForFilename ignores the file, so it is not counted as a button. The
ARMSX2 look has no asset folder, so its strip is a drawable.
ARMSX3 Dark was offered and is deliberately not included. It ships 17 images
against the other packs' 18, with no start button.
Verified in the APK rather than assumed: four asset packs present, 18 images
each in the two new ones, four asset previews plus the drawable.
It was reachable only from the library drawer, which is a global screen. That
put the moment a core node is actually worth touching, a game that needs one
while that game is loaded, in the one place it could not be set without setting
it for every other game too.
Add it to the Options pane beside All Settings, opening over the paused game
like the other manager screens. Scope and serial come from the overlay's own
scope state, resolved for the running title when the menu opened, so an edit
made mid-session is remembered for that title alone.
The core settings screen recorded every edit into a single global store, so a
node set to get one title running was then set for every title that booted
afterwards, with nothing on screen to say so.
Give that store the two tiers ConfigStore already gives curated settings: a
global set, and a per-game set keyed on the same settingsKey, so both stores
agree on which title is being configured. Replay pushes global first and the
running title's set on top, so a per-game value wins where the two disagree and
every node a title never touched still follows global. With no game loaded the
per-game tier is skipped rather than guessed at, so the last title played cannot
leak into a BIOS boot.
The screen now takes its scope and serial from the caller instead of resolving
them itself: the drawer route is global and would otherwise inherit whatever
scope the last per-game settings visit left behind. It also states which tier
the next edit lands in, since the screen looks identical either way.
Ports ARMSX2's GitHub-release updater. The UI hooks and all seventeen update.*
strings were already here from the UI port; only the implementation was missing,
so both hooks were sitting behind IN_APP_UPDATER doing nothing.
A "Check for updates" panel at the top of the App tab queries the ARMSX3
releases API, semver-compares the tag against the installed build, downloads the
.apk asset with a progress bar into externalCacheDir/updates/, and hands it to
the system package installer through a FileProvider. The user confirms the
install; nothing happens silently. Two opt-in toggles come with it, both default
off: check on launch, which pops a prompt only when something newer exists, and
include nightly builds.
Points at ARMSX2/ARMSX3 releases, not ARMSX2's. Download goes to the app cache
directory, which the OS can evict, and each download clears the folder first, so
it never accumulates.
ONE DELIBERATE DIFFERENCE FROM ARMSX2, and it needs to be understood before any
Play build exists. ARMSX2 keeps this out of its bundle with a src/github versus
src/play flavor split plus a build script that fails closed if
REQUEST_INSTALL_PACKAGES appears in the bundle manifest. ARMSX3 has neither a
Play build nor flavors, so the code, the permission and the provider live in
src/main behind the runtime flag. That is fine today and NOT fine the moment a
Play target appears: Play rejects the permission in the bundle, and a runtime
flag does not remove a permission. Adding a Play build means doing the flavor
split first. The requirement is written at the manifest, at the buildConfigField
and in the file header, because one comment is easy to miss.
Verified in the produced APK rather than assumed: the permission is present, the
provider is registered as com.armsx3.updateprovider, and UpdaterEntry is in
classes18.dex.
Demon's Souls precompiles for 27 minutes and was killed partway through. The
trajectory says why serialising alone could not save it: the process sat between
4.3GB and 5.8GB for the whole run on a 7GB device, so this was sustained
footprint rather than a transient overlap the existing mutex could flatten. It
died when the system wanted memory back, and Zygote logged signal 9 for four
processes at once, so the pressure was not ours alone.
Adds a second tier. Below 2GB free, workers already compile one at a time; below
1GB free, the worker holding that lock now waits for the system to recover before
starting the next module, up to ten seconds, rechecking every 100ms.
The wait happens AFTER taking the serialisation lock deliberately. Waiting first
would have the other worker still allocating, so the wait would be watching
memory it is not allowed to influence.
Safe to wait here because precompilation writes each object to disk and links
none of them: the modules are not mapped into the VM, so is_being_used_in_emulation
is false and no JIT instance is created. Pausing costs time and nothing else.
Bounded rather than indefinite, because failing to compile is worse than
compiling under pressure, and it gives up cleanly if the emulator is stopping.