14 Commits
Author SHA1 Message Date
jpolo1224 2e65c8b212 PPU: drop the store-conditional failure probe
It did its job -- it is what identified res - rtime == 128 as the constant behind
the Assassin's Creed hang -- and what it costs now is an atomic increment on a
shared static every time any conditional store fails, on every PPU thread. That is
cross-core traffic on the hot path of every guest atomic, for a question that has
been answered.

The measurement it produced is kept in the comment above the fix, which is the part
worth having.
2026-08-19 14:04:44 -04:00
jpolo1224 91952ae4c1 Turnip: stop disabling fp16, add Space/Enter, make the IME actually appear
Three fixes found while testing Assassin's Creed on a Turnip device.

fp16 was off on every Turnip install regardless of driver age. The gate that
re-enables it compares driverVersion against 512.676.53, which is QUALCOMM'S
numbering; is_ADRENO() is true for Turnip as well, and Turnip reports Mesa's
scheme -- 25.99.99 on an 8 Gen 2 -- which packs to a far smaller integer and can
never pass. So the log said "All float16_t arithmetic will be emulated with
float32_t" on the configuration these handhelds actually ship in.

It should not have been gated for Turnip at all. The failure being worked around
is Qualcomm's shader compiler rejecting SPIR-V with float16_t in it; Mesa's is a
different compiler. The version check stays for the proprietary driver, where it
was measured, and Turnip passes on its own account.

The on-screen keyboard raised its bar without raising a keyboard.
showSoftInput(SHOW_IMPLICIT) is a hint the system may decline, and it does for a
fullscreen immersive window like the game surface -- but visible was set either
way, so the extra-keys bar appeared over nothing. It now also drives the IME
through WindowInsetsControllerCompat, which is the supported route once
setDecorFitsSystemWindows(false) is in effect, and it already is. Both are used:
the old call still works on odd IMEs and asking twice costs nothing. hide()
mirrors it.

Space and Enter join the bar as wide keys. They are on the IME too, but the IME
is not reliably what comes up, and Space is the key that opens the debug menu
this was built for -- it should not depend on another keyboard appearing. Enter
was already there as a glyph and read as decoration; it says Enter now.
2026-08-19 13:50:37 -04:00
jpolo1224 ef63026354 Keyboard: add the keys the Android IME does not have
The emulated keyboard reaches games now, and the first thing it was used for was
a game's debug menu -- which opened on Space and then could not be navigated,
because a soft keyboard is built for typing text and has no arrows, no Escape and
no function row. For that job those are not optional extras, they are the whole
interaction.

A row of them floats above the IME whenever the keyboard is up: Esc, Tab, the
four arrows, Enter, and an Fn toggle for F1-F12. It scrolls sideways so nothing
is cut off on a narrow screen, and it appears and disappears with the keyboard,
so there is nothing to place in the touch layout and nothing to find.

This adds to the IME rather than replacing it. Prediction, swipe and non-Latin
input still come from whichever keyboard the user chose; only the keys that
keyboard cannot express come from here.

Taps go through SoftKeyboard.tap, the same paced queue the IME's own keys use, so
a press is held long enough for the guest to sample it. The keys use tap gesture
detection rather than clickable(): this sits beside a focused IME sink, and
anything focusable here can take focus off it and drop the keyboard mid-use.

The arrows are KEYCODE_DPAD_*, which the handler already maps to Qt's arrow
codes, so nothing was needed on the native side.
2026-08-19 13:17:38 -04:00
jpolo1224 da4169148f Diagnostics: name the participants in a guest-side stall
Four probes, all sampled or already on a timer, added while chasing the
Assassin's Creed loading hang. Each one existed because a fact that turned out to
be decisive was unreachable from outside.

sys_event: EVENTSPIN counts sends and receives and prints one line per million of
each. The syscall usage stats already said two calls dominated everything; they
could not say which port, which queue, or whether the receive blocked. It was one
thread posting a zero-data event to its own queue and taking it straight back,
2.2 million times a second -- a yield loop, not a deadlock, which is a different
thing to go looking for.

The SPU half of the stall dump gains res_now/res_moved and the first bytes of the
line an SPU is parked on. The wait loop wakes on either the counter moving or the
data changing, so a sleeping SPU proves the line is static rather than that a
notification was lost. Those are different bugs and the dump could not tell them
apart.

It also gains one kernel's registers and the local store around its pc, matching
what the PPU half has always printed. The SPU's decision to sleep is guest code;
the registers hold what it tested and the local store holds the test.

The PPU half gains the memory behind registers of a spinning thread. dump_all
prints eight bytes, enough to recognise a pointer and not enough to read the
struct behind it -- the field that settled this one was 0x74 bytes past a value
in r5.

One SPU and six pointers, deduplicated and capped: a dozen parked threads each
dragging a hex dump per register buries the report that explains the hang.
2026-08-19 13:09:02 -04:00
jpolo1224 ca3b755fd1 PPU: advance rtime when a conditional store succeeds
ldarx has a fast path: re-reserving the same 128-byte line the thread's last
successful stdcx wrote skips reloading ppu.rtime, on the theory that the hardware
caches the reservation across a chain of stores. The branch is empty, so rtime
keeps the value it had before that store -- and the store left the line's counter
128 higher. Every conditional store after the first one on a line therefore fails
its rtime != (res & -128) test by exactly 128, and keeps failing, because a guest
that retries re-enters the same fast path with the same stale value.

Assassin's Creed never gets past its loading screen because of it. libsre's
cellSpursAddUrgentCommand walks four urgent-command slots inside a single
reservation loop: it stores slot 0 back unchanged to release it, then can never
claim slot 1, and its failure path restarts the whole scan. Measured at 490
million failures on 0x102ed6b8, res-rtime == 128 on every one, data unchanged.

Downstream of that, everything else looked like the bug and was not. The job
chain is valid (workloadId 4), three of its four urgent slots stay empty, no
workload is ever marked ready, all six SPURS SPU kernels sleep correctly on a
control block nothing writes, and the main thread burns 2.2 million syscalls a
second in a yield loop waiting for a load that cannot finish. One missing
increment, five symptoms.

047f71b43 added this fast path for MGS4 along with a "rtime -= 128" here and a
"rtime += 128" in the store; the two cancel, and both were later dropped rather
than corrected, which left the fast path reading a stale value. Advancing rtime
at the store is what makes the empty branch correct: it is already current.
2026-08-19 13:07:41 -04:00
jpolo1224 5c810c72c2 Make raw core overrides visible, and undoable
A raw override recorded in All Core Settings re-pushes at the tail of applyTo,
after the curated store has written the same node. So the recorded value wins
every time and the normal settings screen becomes decorative: it shows the
choice, saves the choice, and the choice is overwritten a moment later with
nothing on screen to say so. config.yml disagrees with the UI and there is no
way in the app to see why, or to undo it -- CoreSettingOverrides.clear() existed
and nothing called it.

That is not hypothetical. A test device carried

  Core@@PPU Decoder = "Recompiler (LLVM)"

which silently defeated every attempt to boot a game on the interpreter,
including a run made specifically to find out whether a hang was a codegen bug.
The run happened, the setting was chosen, and the log recorded the recompiler.

All Core Settings now reads the store alongside the tree: it says how many paths
this scope remembers, marks each row that carries one, and offers Forget per row
and for the scope. Forgetting also restores the node -- the core's default first,
then a curated re-apply on top -- because dropping only the record would leave
the value it wrote still live, which looks like the button doing nothing.

Reset is two taps rather than an AlertDialog: dialogs swallow gamepad keys here,
and this screen opens from the in-game menu with only a controller in hand.

The migration clears the paths a curated screen also writes. Three others --
RSX Profiler, PPU Calling History, Disable SPU GETLLAR Spin Optimization -- have
no curated writer, so they are actively written back to their upstream default
rather than merely forgotten; forgetting alone would remove the record and keep
the effect. All three are instrumentation or debug levers that were never meant
to ship on.

Accurate ZCULL stats is deliberately left alone: no curated writer and no
debugging history, so a value there is most likely a deliberate per-game choice.
It is visible and clearable now, which is the point.

Two earlier migrations already purged diagnostics by name, and both had run on
the device that still had RSX Profiler recorded. Hence a screen rather than a
third list.
2026-08-19 11:15:46 -04:00
jpolo1224 c1781b95cb Connect the keyboard to the emulator
The keyboard setting, the on-screen keyboard hotkey, the touch button and the
IME plumbing all worked. Nothing behind them did, in three separate places:

  - init_kb_handler installed NullKeyboardHandler unconditionally, so cellKb --
    the API games actually read a keyboard through -- reported none attached no
    matter what the UI said.
  - NativeApp.usbKeyboardKey and usbSetKeyboardEnabled were Unsupported.note()
    stubs. Every keystroke went into a no-op that returned false.
  - The setting wrote [USB1] Type = hidkbd, which is PCSX2's emulated USB HID
    keyboard. There is no such device in this core -- "hidkbd" appears nowhere
    in it -- so that write only ever reached Unsupported.note("USB1/Type").

All three are inherited from the UI port, which is why the feature looked whole.

The desktop handler is a QObject that installs an event filter on a QWindow, so
none of it survives the port. It does not need to: everything that turns a key
into cellKb data already lives in KeyboardHandlerBase::HandleKey, and a concrete
handler owes it exactly one thing, a populated qt_code -> CELL_KEYC map.
virtual_keyboard_handler builds that map with the Qt key codes as literals, and
translates Android keycodes onto it -- including left/right modifiers, which have
to come back in the native_key encoding get_out_key_code compares against or
every modifier reads as the right-hand one.

The setting now writes Input/Output/Keyboard, which is what the core reads. That
happens once, in Emulator::Load, so it applies on the next boot rather than to a
game already running; the description says so now, because the old comment
claimed a live attach that never existed.

KeyEvent.getUnicodeChar() is carried through as well. cellKb derives its own
character from the raw code plus the live modifier state and does not need it,
but the emulator's own overlay text entry matches on the string.
2026-08-19 10:39:08 -04:00
jpolo1224 fb5045d086 Build: keep the builder's absolute paths out of the binary
__FILE__ expands to whatever path the compiler was handed, and RPCS3 prints source locations in
ensure() failures, fmt::throw_exception and assertions. Every one of those lines therefore carried
the full build directory into EVERY USER'S LOG -- on a developer machine that is a home directory,
and the shipped core contained 2500 copies of one username. It surfaced in a bug report where a
user's log showed somebody else's paths, which is a reasonable thing to be alarmed by.

-ffile-prefix-map rewrites the prefix at compile time, covering both __FILE__ and debug info, so
paths become relative-looking (./rpcs3/Emu/...) -- which is what a log wants to show anyway. No
runtime cost.

Applied before any add_subdirectory so third-party targets built in-tree are covered too: they
account for 32k of the roughly 52k embedded paths. Guarded with check_cxx_compiler_flag so a
toolchain without it still builds.
2026-08-19 09:04:13 -04:00
jpolo1224 4c080066cf Save data: fix archive import always failing, and stop mangling names
Two bugs, both mine, both in the new importer. Reported as "import failed, could not open
the zip".

The archive path could never succeed. These stage functions answer null to mean "no problem,
carry on", and importArchive folded the open into the same expression:

    contentResolver.openInputStream(uri)?.use { stageArchive(...) }
        ?: Outcome(false, "Could not open the selected file")

so a null from stageArchive -- the SUCCESS return -- selected the elvis branch. Every archive
import reported a file it had in fact opened and read as unopenable, and returned before
discover() ever looked at what had been staged. The open is now checked on its own, so the
two cases cannot be confused again; the message was pointing at the wrong thing, which is
what sent the diagnosis into the zip handling.

Separately the file carried six NUL bytes where space character literals were meant:
trimEnd('\0'), it == '\0'. They compile -- '\0' is a valid Char -- so nothing complained, and
grep silently reports nothing for a file it decides is binary, which is why searching for
these came back empty on a file that plainly contained them.

Fixing them corrects the rule rather than restoring the intent, because the intent was wrong.
Spaces are ordinary in a downloaded folder name: "All Pro Football 2K8 roster/" is exactly
what a file manager produces. Stripping them silently renamed the user's folders, and the SAF
walk skipped any entry containing one, so a wrapper folder made its contents invisible. Source
names now reject only separators and control characters.

The destination name is no longer narrowed to a character set either. It comes from the game's
own SAVEDATA_DIRECTORY, and refusing one for holding an unanticipated character would produce
"no save data found" -- the same silent-looking failure this class exists to prevent. Only
separators, control characters and a leading dot are rejected, since those are what can
redirect a write or hide the result.
2026-08-18 18:16:03 -04:00
jpolo1224 044ba9cb03 Save data: import a save or roster from a picked folder or .zip
Android 11 stopped third-party file managers from writing into Android/data, so a user who
downloads a save cannot put it where the emulator reads from -- ZArchiver reports EACCES and
no file manager can do better. Reported against an All Pro Football 2K8 roster on an Ayn Thor
Pro. The platform rule is not ours to fix, but we are the only process that can still write
there and until now offered no way to ask us to, so there was no route in at all.

The destination folder name comes from the save's own PARAM.SFO rather than from what the
user's folder or archive is called. Games enumerate saves by matching dirNamePrefix against
the directory name, so a save under the wrong name produces no error the user ever sees --
the game just reports no save data and offers to start fresh, which reads as "the import did
nothing". The core writes SAVEDATA_DIRECTORY into every PARAM.SFO it saves and reads it back
to populate dirName, so the name that works travels inside the save; a renamed download still
lands correctly. The folder's own name is the fallback, and both go through the same
sanitiser because a value read out of a file is untrusted wherever it came from.

Staging follows TexturePackInstaller: everything is copied to a scratch directory beside the
destination, checked there, and only then renamed in, so a cancelled or failed import cannot
leave a half-written save and cannot destroy one the user already had. Archive entries are
rebuilt from sanitised components and any entry containing a '..' fails the whole archive --
this writes into app-private storage, which includes the native library directory, so a
zip-slip here is code execution rather than untidiness. Extracted sizes are counted while
writing rather than trusted from the entry header.

Two rows because the two pickers are different intents: an archive straight from a download,
or an already-unzipped folder. Both accept being pointed at either the save itself or a
parent holding several, since a user has no reason to know which they picked.

stageArchive/stageTree/commit are deliberately not savedata-specific -- the frame generation
plugin installer needs the same component (pick a file, verify it, atomically place it
somewhere the app owns) and should lift these rather than grow a second copy.
2026-08-18 18:16:03 -04:00
jpolo1224 5dee5bc42e Release: 0.9.1 (versionCode 16) 2026-08-18 18:16:03 -04:00
jpolo1224 1b9ab35ac0 Docs: record the ANGLE prebuilts in the jniLibs table
They are the one entry in that directory tracked in git rather than staged build output,
which is why .gitignore un-ignores them by name and verifyAngleLibs fails the build without
them.
2026-08-18 18:16:03 -04:00
jpolo1224 26c39e1f55 Input: only report player 1 connected until a port is actually driven
0.9 shipped seven permanently-connected pads. Reported against LittleBigPlanet 2, which
reacts to the connected count and behaved as though 4+ controllers were plugged in at all
times.

Claiming all seven ports for the virtual handler is what lets a second controller work at
all -- that was the 0.9 fix and it stays -- but initVirtualPad called Init with
CELL_PAD_STATUS_CONNECTED unconditionally, and "this port exists" and "a controller is
plugged into this port" are not the same statement. cellPad derives now_connect by counting
ports whose status carries the CONNECTED bit, so every game asking how many pads were
attached was told seven.

Port 0 therefore starts CONNECTED and ports 1-6 start 0, with the bit set on a port the
first time real input arrives for it in _rpcsx_overlayPadData. No new event plumbing is
needed: pad_thread::update_pad_states already polls is_connected() against its cached
m_pads_connected -- value-initialised to false, so a port that starts disconnected fires
nothing at boot -- and calls pad_state_notify_state_change on a change, which is what
propagates m_port_status into cellPad's reported_info. Setting the bit is enough for the
existing path to publish it.

m_player_id is a const set at construction, not by Init, so reading it to pick the initial
status is safe at this point.
2026-08-18 18:16:03 -04:00
jpolo1224 8bc7ca307c Update README.md 2026-08-18 16:30:45 -04:00
27 changed files with 1676 additions and 47 deletions
+23
View File
@@ -12,6 +12,29 @@ project(rpcs3 LANGUAGES C CXX)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
# Keep the builder's absolute paths out of the shipped binary.
#
# __FILE__ expands to whatever path the compiler was handed, and RPCS3 prints source locations in
# ensure() failures, fmt::throw_exception and assertions -- so every one of those lines carried the
# full build directory into EVERY USER'S LOG. On a developer's machine that is a home directory:
# the shipped core contained 2500 copies of one username. Someone else's crash report is not the
# place to publish where we build.
#
# -ffile-prefix-map rewrites the prefix at compile time, covering both __FILE__ (macro-prefix-map)
# and debug info (debug-prefix-map). Paths become relative-looking (./rpcs3/Emu/...), which is what
# a log wants to show anyway. Costs nothing at runtime.
#
# Applied here, before any add_subdirectory, so third-party targets built in-tree are covered too --
# they embed the same root.
if(NOT MSVC)
include(CheckCXXCompilerFlag)
check_cxx_compiler_flag("-ffile-prefix-map=${CMAKE_SOURCE_DIR}=." COMPILER_HAS_FILE_PREFIX_MAP)
if(COMPILER_HAS_FILE_PREFIX_MAP)
add_compile_options("$<$<COMPILE_LANGUAGE:C,CXX>:-ffile-prefix-map=${CMAKE_SOURCE_DIR}=.>")
endif()
endif()
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
if(CMAKE_CXX_COMPILER_VERSION VERSION_LESS 13)
message(FATAL_ERROR "RPCS3 requires at least gcc-13.")
+1 -1
View File
@@ -7,7 +7,7 @@ Uses the latest RPCS3 upstream code (the recent ARM64 improvements included).
Building
--------
Only arm64-v8a is supported. You need the Android SDK with NDK r27 or newer,
arm64-v8a and armv8.2 is supported. You need the Android SDK with NDK r27 or newer,
CMake 3.30 or newer, and a JDK 17. Android Studio ships all of these.
Clone with submodules, then fetch the two third party checkouts that are not
+3
View File
@@ -48,6 +48,9 @@ set(ARMSX3_INPUT_SOURCES
${CMAKE_SOURCE_DIR}/rpcs3/Input/mouse_gyro_handler.cpp
# Ours: on-screen touch controls.
${CMAKE_SOURCE_DIR}/rpcs3/Input/virtual_pad_handler.cpp
# Ours: cellKb fed from the Android IME / a physical keyboard. The desktop
# handler is a QObject and cannot be built here.
${CMAKE_SOURCE_DIR}/rpcs3/Input/virtual_keyboard_handler.cpp
)
add_library(rpcsx-android SHARED
+2 -2
View File
@@ -32,8 +32,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 = 15
versionName = "0.9"
versionCode = 17
versionName = "0.9.2"
// 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.
@@ -24,6 +24,7 @@ struct RPCSXApi {
bool (*overlayPadData)(int port, int digital1, int digital2, int leftStickX,
int leftStickY, int rightStickX, int rightStickY);
bool (*overlayPadPressure)(int port, const int *values, int count);
bool (*keyboardKey)(int androidKeyCode, int unicode, bool pressed, bool repeat);
bool (*initialize)(std::string_view rootDir, std::string_view user);
void (*setSocInfo)(std::string_view socInfo);
bool (*processCompilationQueue)(JNIEnv *env);
@@ -122,6 +123,7 @@ struct RPCSXLibrary : RPCSXApi {
// clang-format off
result.overlayPadData = reinterpret_cast<decltype(overlayPadData)>(dlsym(handle, "_rpcsx_overlayPadData"));
result.overlayPadPressure = reinterpret_cast<decltype(overlayPadPressure)>(dlsym(handle, "_rpcsx_overlayPadPressure"));
result.keyboardKey = reinterpret_cast<decltype(keyboardKey)>(dlsym(handle, "_rpcsx_keyboardKey"));
result.initialize = reinterpret_cast<decltype(initialize)>(dlsym(handle, "_rpcsx_initialize"));
result.setSocInfo = reinterpret_cast<decltype(setSocInfo)>(dlsym(handle, "_rpcsx_setSocInfo"));
result.processCompilationQueue = reinterpret_cast<decltype(processCompilationQueue)>(dlsym(handle, "_rpcsx_processCompilationQueue"));
@@ -263,6 +265,20 @@ extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_overlayPadPressure(
return ok;
}
extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_keyboardKey(
JNIEnv *, jobject, jint androidKeyCode, jint unicode, jboolean pressed,
jboolean repeat) {
// Absent on a core older than this export. Returning false is right either
// way: it means "nothing consumed this key", which is also what an emulator
// with no keyboard attached reports.
if (rpcsxLib.keyboardKey == nullptr) {
return false;
}
return rpcsxLib.keyboardKey(androidKeyCode, unicode, pressed == JNI_TRUE,
repeat == JNI_TRUE);
}
extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_initialize(
JNIEnv *env, jobject, jstring rootDir, jstring user, jstring socInfo) {
// The core is dlopen()ed separately and may not be up yet -- during
@@ -0,0 +1,456 @@
package com.armsx2
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.armsx2.data.library.ParamSfo
import net.rpcsx.RPCSX
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
/**
* Imports PS3 save data into `config/dev_hdd0/home/<user>/savedata/` from a SAF-picked folder or
* archive.
*
* This exists because of a platform rule, not a bug of ours. Android 11 blocks third-party file
* managers from writing into `Android/data/<pkg>/`, so a user who downloads a roster or a save
* cannot put it where the emulator reads from: ZArchiver reports `EACCES (Permission denied)` and
* there is no way round it from outside the app. Reported against All Pro Football 2K8 on an Ayn
* Thor Pro. We are the only process that can still write there, so the copy has to happen in here.
*
* The destination folder name comes from the save's own PARAM.SFO, not from what the user's folder
* or archive happened to be called. That is the whole reliability argument for this class. Games
* enumerate saves by matching `dirNamePrefix` against the directory name (cellSaveData.cpp:543), so
* a save placed under the wrong name is not an error the user ever sees -- the game simply reports
* no save data and offers to start fresh, which looks like the import silently did nothing. The
* core writes SAVEDATA_DIRECTORY into every PARAM.SFO it saves (cellSaveData.cpp:1695) and reads it
* back to populate dirName (cellSaveData.cpp:248), so the correct name travels inside the save.
*
* Follows [TexturePackInstaller] for staging and commit: everything lands in a scratch directory on
* the same filesystem, is validated there, and only then is renamed into place. Nothing half-formed
* is ever visible under `savedata/`, and a failure part-way cannot destroy a save the user already
* had. The pieces here that are not savedata-specific -- [stageArchive], [stageTree], [commit] --
* are what the frame-generation plugin installer needs too (pick a file, verify it, atomically
* place it somewhere the app owns); they are written to be lifted rather than reimplemented.
*/
object SaveDataImporter {
private const val TAG = "SaveDataImporter"
/** Guards against a decompression bomb: real save data is kilobytes to a few megabytes. */
private const val MAX_ENTRY_BYTES = 256L * 1024 * 1024
private const val MAX_TOTAL_BYTES = 1024L * 1024 * 1024
private const val MAX_ENTRIES = 20_000
sealed interface Progress {
data object Scanning : Progress
data class Copying(val done: Int, val total: Int) : Progress
data object Installing : Progress
}
/** One save found in the source, named as it will actually be written. */
data class Imported(val dirName: String, val title: String?, val replaced: Boolean)
data class Outcome(
val ok: Boolean,
val saves: List<Imported> = emptyList(),
val error: String? = null,
)
// ---- entry points ---------------------------------------------------------------------
/**
* Imports from a `.zip` picked with `ActivityResultContracts.OpenDocument`.
*
* Blocking; call from a background dispatcher.
*/
fun importArchive(
context: Context,
uri: Uri,
onProgress: (Progress) -> Unit = {},
isCancelled: () -> Boolean = { false },
): Outcome = runImport(onProgress) { staging ->
// Opened separately rather than with `?.use { } ?: openFailed`. These stages answer null
// to mean "no problem, carry on", so folding them together made the SUCCESS path -- a null
// from stageArchive -- select the elvis branch and report every single archive import as
// "could not open the selected file", while the staged files were discarded unread.
val input = runCatching { context.contentResolver.openInputStream(uri) }.getOrNull()
?: return@runImport Outcome(false, error = "Could not open the selected file")
input.use { stageArchive(it, staging, onProgress, isCancelled) }
}
/**
* Imports from a folder picked with `ActivityResultContracts.OpenDocumentTree`.
*
* Accepts either the save folder itself or a parent holding several, since a user who
* downloaded a pack of rosters has no reason to know which of those they picked.
*/
fun importFolder(
context: Context,
treeUri: Uri,
onProgress: (Progress) -> Unit = {},
isCancelled: () -> Boolean = { false },
): Outcome = runImport(onProgress) { staging ->
val root = DocumentFile.fromTreeUri(context, treeUri)
?: return@runImport Outcome(false, error = "Could not open the selected folder")
stageTree(context, root, staging, onProgress, isCancelled)
}
// ---- shared driver --------------------------------------------------------------------
/**
* Stages, validates, then commits. [stage] does only the copy; it must not touch the live
* savedata directory, which is what makes a cancelled or failed import a no-op.
*/
private fun runImport(
onProgress: (Progress) -> Unit,
stage: (File) -> Outcome?,
): Outcome {
val savedataRoot = savedataRoot() ?: return Outcome(
false,
error = "No user profile yet — boot a game once, then import.",
)
// A sibling of the destination, so the commit below is a rename and not a copy across
// filesystems. Leading dot keeps it out of the way of anything that lists savedata/.
val staging = File(savedataRoot, ".import-tmp")
staging.deleteRecursively()
if (!staging.mkdirs()) {
return Outcome(false, error = "Could not create a staging folder")
}
try {
onProgress(Progress.Scanning)
stage(staging)?.let { return it }
val found = discover(staging)
if (found.isEmpty()) {
return Outcome(
false,
error = "No save data found. A save is a folder containing PARAM.SFO.",
)
}
onProgress(Progress.Installing)
val imported = mutableListOf<Imported>()
for ((staged, dirName) in found) {
val dest = File(savedataRoot, dirName)
val replaced = dest.exists()
if (!commit(staged, dest)) {
return Outcome(
false,
imported,
"Could not write $dirName into the savedata folder",
)
}
imported += Imported(
dirName = dirName,
title = ParamSfo.string(File(dest, "PARAM.SFO"), "TITLE"),
replaced = replaced,
)
}
return Outcome(true, imported)
} catch (e: Exception) {
Log.w(TAG, "import failed: ${e.message}")
return Outcome(false, error = e.message ?: "Import failed")
} finally {
staging.deleteRecursively()
}
}
// ---- discovery and naming --------------------------------------------------------------
/**
* Finds every staged directory holding a PARAM.SFO, paired with the name it must be written
* under. That is the same test the core uses to decide a directory is a save at all: it loads
* `<entry>/PARAM.SFO` per directory when enumerating (cellSaveData.cpp:240).
*
* Searched recursively because the source shape is not ours to dictate -- a user may hand us
* the save, its parent, or an archive that wraps both in a download folder.
*/
private fun discover(staging: File): List<Pair<File, String>> {
val out = mutableListOf<Pair<File, String>>()
fun walk(dir: File, depth: Int) {
if (depth > 6) return
if (File(dir, "PARAM.SFO").isFile) {
resolveDirName(dir)?.let { out += dir to it }
// A save has no nested saves; stopping also stops a PARAM.SFO in a subfolder from
// being imported as a second, bogus save.
return
}
dir.listFiles().orEmpty().filter { it.isDirectory }.forEach { walk(it, depth + 1) }
}
walk(staging, 0)
return out
}
/**
* The directory name to write this save under: PARAM.SFO's SAVEDATA_DIRECTORY when it has one,
* else the folder's own name.
*
* Preferring the SFO is what makes a renamed download still work. Names look like
* `<SERIAL><TAG>` (`BLUS30760SM2011_SAVE`), which is not something a user can be expected to
* reconstruct after their file manager or a zip tool has flattened or renamed a folder.
*
* The fallback is not a formality: a save copied by hand out of another emulator may have had
* its SFO rewritten. Both paths go through [sanitizedDirName] because a value read out of a
* file is untrusted input no matter which file it came from.
*/
private fun resolveDirName(dir: File): String? {
val fromSfo = ParamSfo.string(File(dir, "PARAM.SFO"), "SAVEDATA_DIRECTORY")
return sanitizedDirName(fromSfo) ?: sanitizedDirName(dir.name)
}
/**
* A directory name safe to join onto the savedata root.
*
* Rejects rather than repairs. A name carrying a separator or a `..` is not a name we can
* correct into the user's intent, and quietly writing it somewhere else would be worse than
* saying so: this is the value that decides where the copy lands.
*/
private fun sanitizedDirName(raw: String?): String? {
val name = raw?.trim().orEmpty()
if (name.isEmpty() || name == "." || name == "..") return null
if (name.length > 64) return null
if (name.any { it == '/' || it == '\\' || it < ' ' }) return null
// Deliberately NOT narrowed to a character set. This name comes from the game's own
// SAVEDATA_DIRECTORY, and rejecting one for holding a character we did not anticipate
// would refuse a good save with "no save data found" -- the silent-looking failure this
// whole class exists to avoid. Only separators and control characters can redirect a
// write, and a leading dot would make a directory no file browser shows.
if (name.startsWith('.')) return null
return name
}
// ---- staging: archive ------------------------------------------------------------------
/**
* Extracts [input] into [staging].
*
* Entry paths are rebuilt from sanitized components rather than used as given. A crafted
* `../../lib/foo.so` would otherwise be written wherever the app can reach, and the app can
* reach its own native library directory -- so this is a code-execution path, not a tidiness
* one. Any entry containing a `..` component fails the whole archive: an archive carrying one
* is not an archive to half-extract and then trust.
*/
private fun stageArchive(
input: InputStream,
staging: File,
onProgress: (Progress) -> Unit,
isCancelled: () -> Boolean,
): Outcome? {
val stagingCanonical = staging.canonicalPath + File.separator
var entries = 0
var totalBytes = 0L
var written = 0
ZipInputStream(input.buffered()).use { zip ->
while (true) {
if (isCancelled()) return Outcome(false, error = null)
val entry: ZipEntry = zip.nextEntry ?: break
try {
if (++entries > MAX_ENTRIES) {
return Outcome(false, error = "Archive has too many files")
}
if (entry.isDirectory) continue
val rel = safeRelativePath(entry.name)
?: return Outcome(false, error = "Archive contains an unsafe path")
if (rel.isEmpty() || isJunk(entry.name)) continue
val out = File(staging, rel)
// Belt and braces. safeRelativePath already dropped every `..`, so reaching
// this is a bug in it rather than a crafted archive -- but the cost of the
// check is nothing and the cost of being wrong is arbitrary file write.
if (!out.canonicalPath.startsWith(stagingCanonical)) {
Log.w(TAG, "zip-slip entry rejected: ${entry.name}")
return Outcome(false, error = "Archive contains an unsafe path")
}
out.parentFile?.mkdirs()
var entryBytes = 0L
FileOutputStream(out).use { fos ->
val buf = ByteArray(64 * 1024)
while (true) {
if (isCancelled()) return Outcome(false, error = null)
val n = zip.read(buf)
if (n < 0) break
entryBytes += n
totalBytes += n
// Sizes are checked while writing, not from the entry header: the
// header is attacker-controlled and can simply lie.
if (entryBytes > MAX_ENTRY_BYTES || totalBytes > MAX_TOTAL_BYTES) {
return Outcome(false, error = "Archive is unexpectedly large")
}
fos.write(buf, 0, n)
}
}
written++
if (written % 16 == 0) onProgress(Progress.Copying(written, 0))
} finally {
zip.closeEntry()
}
}
}
if (written == 0) return Outcome(false, error = "Archive was empty")
return null
}
/**
* Rebuilds an entry path from its own components, keeping only the basename of each.
*
* Every component is reduced to its last path-ish token and anything left that is `.` or `..`
* is dropped, so no combination of separators, doubled slashes or backslashes can climb out of
* the staging directory. Depth is capped because the structure a save needs is at most a
* folder and its files.
*/
private fun safeRelativePath(name: String): String? {
val norm = name.replace('\\', '/')
val parts = norm.split('/')
.map { it.trim() }
.filter { it.isNotEmpty() && it != "." }
if (parts.any { it == ".." }) return null
if (parts.isEmpty()) return ""
// Drop leading wrappers so a "Download/BLUS30760SAVE/PARAM.SFO" still stages usefully;
// discover() walks anyway, so this only keeps the tree shallow.
// Control characters only. Stripping spaces here silently renamed the user's folders,
// and a wrapper like "All Pro Football 2K8 roster/" is a completely ordinary thing for
// a file manager to produce.
val kept = parts.takeLast(3).map { part -> part.filterNot { c -> c < ' ' } }
if (kept.any { it.isEmpty() }) return null
return kept.joinToString("/")
}
// ---- staging: folder -------------------------------------------------------------------
/** Copies a picked SAF tree into [staging], mirroring its structure. */
private fun stageTree(
context: Context,
root: DocumentFile,
staging: File,
onProgress: (Progress) -> Unit,
isCancelled: () -> Boolean,
): Outcome? {
var copied = 0
var totalBytes = 0L
fun walk(node: DocumentFile, dest: File, depth: Int): Outcome? {
if (depth > 6) return null
for (child in node.listFiles()) {
if (isCancelled()) return Outcome(false, error = null)
val rawName = child.name ?: continue
// The picker gives us display names, which are not path components; a name with a
// separator in it is malformed and is dropped rather than joined.
if (rawName.any { it == '/' || it == '\\' || it < ' ' }) continue
if (rawName == "." || rawName == "..") continue
if (isJunk(rawName)) continue
if (child.isDirectory) {
val sub = File(dest, rawName)
if (!sub.exists() && !sub.mkdirs()) continue
walk(child, sub, depth + 1)?.let { return it }
continue
}
val out = File(dest, rawName)
out.parentFile?.mkdirs()
context.contentResolver.openInputStream(child.uri)?.use { input ->
FileOutputStream(out).use { fos ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = input.read(buf)
if (n < 0) break
totalBytes += n
if (totalBytes > MAX_TOTAL_BYTES) return@use
fos.write(buf, 0, n)
}
}
}
if (totalBytes > MAX_TOTAL_BYTES) {
return Outcome(false, error = "Folder is unexpectedly large")
}
copied++
if (copied % 16 == 0) onProgress(Progress.Copying(copied, 0))
}
return null
}
// The picked folder may itself be the save, so its own name has to survive into staging or
// the dirName fallback would see the scratch directory instead.
val rootName = root.name?.takeIf { n ->
n.none { it == '/' || it == '\\' || it < ' ' } && n != "." && n != ".."
}
val base = if (rootName != null) File(staging, rootName).also { it.mkdirs() } else staging
walk(root, base, 0)?.let { return it }
if (copied == 0) return Outcome(false, error = "Folder contained no files")
return null
}
// ---- commit ----------------------------------------------------------------------------
/**
* Moves [staged] to [target], keeping any existing save until the new one is in place.
*
* Overwriting matters more here than for a texture pack: the thing being replaced is the
* user's own progress, and a rename that fails half way must leave what they had rather than
* nothing at all.
*/
private fun commit(staged: File, target: File): Boolean {
val backup = File(target.parentFile, "${target.name}.old-import")
backup.deleteRecursively()
target.parentFile?.mkdirs()
val hadPrevious = target.exists()
if (hadPrevious && !target.renameTo(backup)) {
Log.w(TAG, "could not move existing ${target.name} aside")
return false
}
if (!staged.renameTo(target)) {
if (hadPrevious) backup.renameTo(target)
Log.w(TAG, "could not move staged ${target.name} into place")
return false
}
backup.deleteRecursively()
return true
}
// ---- paths -----------------------------------------------------------------------------
/**
* `config/dev_hdd0/home/<user>/savedata`, created if the user directory already exists.
*
* Prefers the logged-in user and falls back to whichever home directory is actually there,
* matching how the trophy browser resolves the same ambiguity: getUser() reaches through JNI
* into the core and answers null before a game has been opened, and refusing to import until
* then would be a confusing rule to explain. Answers null only when there is no user directory
* at all, which is a genuinely fresh install.
*/
private fun savedataRoot(): File? {
val home = File(RPCSX.getHdd0Dir(), "home")
val preferred = runCatching { RPCSX.instance.getUser() }.getOrNull()
?.takeIf { it.isNotBlank() }
val user = preferred
?.let { File(home, it) }
?.takeIf { it.isDirectory }
?: home.listFiles().orEmpty()
.filter { it.isDirectory && it.name.length == 8 && it.name.all(Char::isDigit) }
.minByOrNull { it.name }
?: return null
return File(user, "savedata").also { it.mkdirs() }.takeIf { it.isDirectory }
}
private fun isJunk(name: String): Boolean {
val lower = name.lowercase()
return lower.startsWith("__macosx/") || lower.contains("/__macosx/") ||
lower == "__macosx" || lower.endsWith("/.ds_store") || lower == ".ds_store" ||
lower.endsWith("thumbs.db")
}
}
@@ -73,6 +73,7 @@ object ConfigStore {
// Bumped: the profiler was recorded again during the 0.5 debugging work, after the first
// purge had already marked itself done.
private const val KEY_DIAG_OVERRIDES_PURGED_2 = "config.migrated.diagOverridesPurged2"
private const val KEY_SHADOWING_OVERRIDES_PURGED = "config.migrated.shadowingOverridesPurged"
// Core settings left pinned as raw overrides by the 0.5 debugging sessions.
private const val KEY_TUNING_OVERRIDES_PURGED = "config.migrated.tuningOverridesPurged"
// Per-title Accurate SPU Reservations values left behind by the same debugging.
@@ -522,6 +523,61 @@ object ConfigStore {
MainActivityRuntime.prefs.edit { putBoolean(KEY_DIAG_OVERRIDES_PURGED_2, true) }
}
// Drop raw overrides on nodes a curated settings screen also writes.
//
// These two cannot coexist. Overrides replay at the tail of applyTo, after the curated
// store has written the same node, so the recorded value wins every time and the normal
// screen becomes decorative: it shows the choice, saves the choice, and the choice is
// overwritten a moment later with nothing on screen to say so. A test device carried
// Core@@PPU Decoder = "Recompiler (LLVM)" this way, which silently defeated every
// attempt to boot a game on the interpreter -- including one run specifically to find
// out whether a hang was a codegen bug.
//
// Named rather than derived: the curated set is spread across applyToInner and the
// Rpcs3Bridge routing table, and a wrong automatic answer here would delete real user
// edits. Every path in the first group is reachable from Settings, so nothing is lost --
// the value still applies, it just comes from the screen that shows it.
//
// Video@@Accurate ZCULL stats is deliberately NOT purged: it has no curated writer and
// no debugging history, so a recorded value there is most likely a deliberate per-game
// performance choice. It is visible and clearable in All Core Settings now instead.
//
// The two migrations above purged diagnostics by name and both had already run on the
// device that still had RSX Profiler recorded, which is why All Core Settings now shows
// and clears overrides directly instead of waiting for the next migration.
if (!MainActivityRuntime.prefs.getBoolean(KEY_SHADOWING_OVERRIDES_PURGED, false)) {
runCatching {
CoreSettingOverrides.forgetEverywhere(
"Core@@PPU Decoder",
"Core@@SPU Decoder",
"Core@@SPU XFloat Accuracy",
"Core@@Max SPURS Threads",
"Core@@Precise SPU Verification",
"Core@@PPU Vector NaN Handling",
"Video@@Shader Mode",
"Video@@Multithreaded RSX",
)
// These three have no curated writer, so forgetting alone would leave the
// recorded value sitting in config.yml with nothing to overwrite it -- the
// record would be gone and the effect would remain, which is worse than
// leaving it. Write the core's own default off instead, the way the Vblank
// migration writes 60 rather than deleting.
//
// All three are instrumentation or debug levers, off by default upstream:
// the RSX profiler keeps per-scope timers on the RSX thread and reports every
// 300 frames, PPU calling history records every call, and the GETLLAR spin
// optimization being disabled changes how an SPU waiting on a reservation
// behaves -- which is not something to ship switched off by accident.
CoreSettingOverrides.record(SettingsScope.Global, null, "Video@@RSX Profiler", "false")
CoreSettingOverrides.record(SettingsScope.Global, null, "Core@@PPU Calling History", "false")
CoreSettingOverrides.record(
SettingsScope.Global, null, "Core@@Disable SPU GETLLAR Spin Optimization", "false",
)
}
MainActivityRuntime.prefs.edit { putBoolean(KEY_SHADOWING_OVERRIDES_PURGED, true) }
}
// Move anyone still on the old Approximate xfloat default onto Accurate.
// Approximate corrupted SPU float registers badly enough that a job
// manager built a DMA command out of one; see Settings.spuXFloat. A
@@ -736,12 +736,18 @@ data class Settings(
val memoryCardSlot2Enabled: Boolean = true,
val memoryCardSlot2Filename: String = "mcd002.ps2",
// ---- USB ----
/** USB1/Type = hidkbd attach an emulated USB HID keyboard on USB port 1.
* Needed by games that require a real USB keyboard (EverQuest Online
* Adventures, Konami-keyboard titles). A physical/Bluetooth keyboard's key
* events are forwarded to it (see MainActivityRuntime.dispatchKeyEvent NativeApp.usbKeyboardKey).
* Default off. */
// ---- Keyboard ----
/** Input/Output/Keyboard = Basic serve cellKb from the Android keyboard handler.
* Needed by games that want a keyboard (EverQuest Online Adventures, in-game
* text chat, the debug menus some titles put behind one). Keys come from a
* physical/Bluetooth keyboard (MainActivityRuntime.forwardKeyToUsbKeyboard) or
* from the Android IME the On-Screen Keyboard hotkey raises (SoftKeyboard), and
* reach the core through NativeApp.usbKeyboardKey.
*
* The name is ARMSX2's. RPCS3 has no emulated USB HID keyboard device; it has a
* keyboard handler, which is what this drives.
*
* Read once, in Emulator::Load, so it takes effect on the next boot. Default off. */
val usbKeyboard: Boolean = false,
// ---- EmuCore/CPU/Recompiler — recompiler enables ----
@@ -1275,12 +1281,10 @@ data class Settings(
put("MemoryCards", "Slot1_Filename", "string", memoryCardSlot1Filename.ifEmpty { "mcd001.ps2" })
put("MemoryCards", "Slot2_Enable", "bool", memoryCardSlot2Enabled.toString())
put("MemoryCards", "Slot2_Filename", "string", memoryCardSlot2Filename.ifEmpty { "mcd002.ps2" })
// USB keyboard (#254). Persist [USB1] Type so USBOptions::LoadSave attaches
// the emulated HID keyboard on the next boot (or ApplySettings). The live
// attach/detach on a running VM is done via NativeApp.usbSetKeyboardEnabled
// below (CheckForConfigChanges recreates the device), since a plain
// setSetting write doesn't reattach USB devices on its own.
put("USB1", "Type", "string", if (usbKeyboard) "hidkbd" else "None")
// Keyboard: NOT written here. [USB1] Type = hidkbd is a PCSX2 key -- there is
// no such USB device in RPCS3, so that write only ever reached
// Unsupported.note("USB1/Type"). The PS3 equivalent is the keyboard handler,
// pushed by NativeApp.usbSetKeyboardEnabled below.
// Recompiler enables. Picked up by VMManager::ApplySettings →
// SysCpuProviderPack rebind. Toggling these on a running VM swaps
// the dispatch pointer; existing JIT block caches are flushed by
@@ -1336,10 +1340,8 @@ data class Settings(
NativeApp.osdShowVersion(osdShowVersion)
NativeApp.osdShowSettings(osdShowSettings)
NativeApp.osdShowInputs(osdShowInputs)
// USB keyboard (#254): live attach/detach on the running VM. A plain
// setSetting("USB1","Type",...) write is persisted but doesn't reattach
// USB devices, so drive the device (re)creation explicitly. No-op before
// the VM exists — the persisted Type above handles the cold boot.
// Keyboard handler (#254). Installed by Emulator::Load, so this is a persist,
// not a live attach: a game already running keeps whatever it booted with.
NativeApp.usbSetKeyboardEnabled(0, usbKeyboard)
// Vblank at the PS3's own rate, pushed on every apply rather than left to a
// migration.
@@ -307,6 +307,14 @@ val EN: Map<String, String> = mapOf(
"app.backup.exported" to "Backup saved — %s.",
"app.backup.imported" to "Restored %s. Restarting…",
"app.backup.failed" to "Backup failed: %s",
"app.savedata.import" to "Import save data",
"app.savedata.import.desc" to "Add a PS3 save or roster from a .zip. Android blocks other apps from writing into the emulator's folder, so files have to be brought in from here.",
"app.savedata.importFolder" to "Import save data folder",
"app.savedata.importFolder.desc" to "Pick an unzipped save folder — the one containing PARAM.SFO — or a folder holding several.",
"app.savedata.working" to "Importing…",
"app.savedata.done" to "Imported %s.",
"app.savedata.replaced" to "Imported %s, replacing an existing save.",
"app.savedata.failed" to "Import failed: %s",
"app.clearCache" to "Clear cached data",
"app.clearCache.desc" to "Delete compiled shader caches and cover-art thumbnails. They rebuild automatically.",
"app.clearCache.done" to "Cleared %s of cached data.",
@@ -398,6 +406,11 @@ val EN: Map<String, String> = mapOf(
"core.settings.unavailable" to "The emulator core is not loaded, so its settings cannot be read.",
"core.settings.scope.game" to "Changes are remembered for this game only",
"core.settings.scope.global" to "Changes are remembered for every game",
"core.settings.overrideCount" to "Settings remembered here",
"core.settings.overridden" to "Remembered here",
"core.settings.clearOne" to "Forget",
"core.settings.reset" to "Forget all",
"core.settings.resetConfirm" to "Tap again to forget all",
// PS3 trophies (TrophiesScreen). Numbered placeholders (%1/%2/%3) rather than %d, because
// several of these take more than one number and a translator has to be able to reorder them.
"trophies.title" to "Trophies",
@@ -711,7 +724,7 @@ val EN: Map<String, String> = mapOf(
"network.address" to "Address",
"touch.stateAction.keyboard" to "KBD",
"network.emulateUsbKeyboard" to "Emulate USB Keyboard",
"net.usbKeyboard.description" to "Report a USB keyboard to the game. Needed by titles that require one \u2014 EverQuest Online Adventures, Konami-keyboard games \u2014 and for typing in online chat. A physical or Bluetooth keyboard works once this is on, and the \"On-Screen Keyboard (toggle)\" hotkey raises the Android keyboard over the game without pausing.",
"net.usbKeyboard.description" to "Report a keyboard to the game. Needed by titles that require one \u2014 EverQuest Online Adventures, Konami-keyboard games \u2014 and for typing in online chat. A physical or Bluetooth keyboard works once this is on, and the \"On-Screen Keyboard (toggle)\" hotkey raises the Android keyboard over the game without pausing. Takes effect the next time you start a game.",
"network.ethernetDevice" to "Ethernet Device",
"network.hddImage.dialogHint" to "File name (kept in the data folder) or a full path to an existing image.",
"network.hddImage.fieldLabel" to "HDD image",
@@ -0,0 +1,132 @@
package com.armsx2.input
import android.view.KeyEvent
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.gestures.detectTapGestures
/**
* The keys the Android IME does not have, floated above it.
*
* The emulated keyboard works, but a soft keyboard is built for typing text and has no
* arrows, no Escape and no function row -- so the first thing it was used for, a game's
* debug menu, opened and then could not be navigated. Those keys are not optional extras
* for that job; they are the whole interaction.
*
* This does not replace the IME. Prediction, swipe and non-Latin input all still come from
* whichever keyboard the user has chosen, and this only adds what that keyboard cannot
* express. It appears and disappears with [SoftKeyboard.visible], so there is nothing to
* place in the touch layout and nothing to discover.
*
* Taps go through [SoftKeyboard.tap], the same paced queue the IME's own keys use, so a
* press is held long enough for the guest to sample it (see KEY_STEP_MS -- a press and
* release issued back to back can land entirely between two guest polls and be missed).
*
* Gestures rather than clickable(): this sits next to a focused IME sink, and anything
* focusable here can take focus off it and drop the keyboard mid-use. detectTapGestures
* never touches focus.
*/
private data class ExtraKey(val label: String, val code: Int, val wide: Boolean = false)
private val BASE_KEYS = listOf(
ExtraKey("Esc", KeyEvent.KEYCODE_ESCAPE),
ExtraKey("Tab", KeyEvent.KEYCODE_TAB),
ExtraKey("", KeyEvent.KEYCODE_DPAD_LEFT),
ExtraKey("", KeyEvent.KEYCODE_DPAD_UP),
ExtraKey("", KeyEvent.KEYCODE_DPAD_DOWN),
ExtraKey("", KeyEvent.KEYCODE_DPAD_RIGHT),
// Space and Enter are on the IME too, but the IME is not always the thing that comes up --
// and Space in particular is the key that opens the debug menu this was first used for, so
// it should not depend on another keyboard appearing.
ExtraKey("Space", KeyEvent.KEYCODE_SPACE, wide = true),
ExtraKey("Enter", KeyEvent.KEYCODE_ENTER, wide = true),
)
// KEYCODE_F1..F12 are contiguous, the same way the handler's qt mapping assumes.
private val FN_KEYS = (0..11).map { ExtraKey("F${it + 1}", KeyEvent.KEYCODE_F1 + it) }
@Composable
fun BoxScope.KeyboardExtraKeys() {
if (!SoftKeyboard.visible.value) return
var showFn by remember { mutableStateOf(false) }
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
// imePadding lifts it clear of the keyboard; navigationBarsPadding keeps it off
// the gesture bar on the frames where the IME is animating out.
.imePadding()
.navigationBarsPadding(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 6.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
for (key in if (showFn) FN_KEYS else BASE_KEYS) {
KeyCap(key.label, wide = key.wide) { SoftKeyboard.tap(key.code) }
}
KeyCap(if (showFn) "abc" else "Fn", accent = true) { showFn = !showFn }
}
}
}
@Composable
private fun KeyCap(label: String, accent: Boolean = false, wide: Boolean = false, onTap: () -> Unit) {
Box(
modifier = Modifier
.sizeIn(minWidth = if (wide) 92.dp else 44.dp)
.heightIn(min = 40.dp)
.clip(RoundedCornerShape(8.dp))
.background(
if (accent) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
)
.pointerInput(label) { detectTapGestures { onTap() } }
.padding(horizontal = 10.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Text(
label,
color = if (accent) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
)
}
}
@@ -54,7 +54,27 @@ object SoftKeyboard {
activity.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
view.isFocusableInTouchMode = true
view.requestFocus()
// Two ways of asking, because one of them does not work here.
//
// SHOW_IMPLICIT is a hint, and the system is free to decline it -- which it does for a
// fullscreen immersive window like the game surface. The result was the extra-keys bar
// appearing (it follows [visible]) with no keyboard under it, because visible was set
// whether or not anything came up.
//
// WindowInsetsControllerCompat drives the IME through the insets animation instead,
// which is the supported path once setDecorFitsSystemWindows(false) is in effect --
// and it is, set in MainActivityRuntime. Keep showSoftInput as well: it is what works
// on older/odd IMEs, and asking twice is harmless.
imm(activity)?.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
activity.window?.let { win ->
runCatching {
androidx.core.view.WindowInsetsControllerCompat(win, view)
.show(androidx.core.view.WindowInsetsCompat.Type.ime())
}
}
visible.value = true
}
@@ -66,6 +86,15 @@ object SoftKeyboard {
val view = sink
if (view != null) {
imm(activity)?.hideSoftInputFromWindow(view.windowToken, 0)
// Mirror of show(): whichever route raised it is the one that can lower it.
activity.window?.let { win ->
runCatching {
androidx.core.view.WindowInsetsControllerCompat(win, view)
.hide(androidx.core.view.WindowInsetsCompat.Type.ime())
}
}
view.clearFocus()
}
visible.value = false
@@ -96,22 +125,27 @@ object SoftKeyboard {
*/
private const val KEY_STEP_MS = 24L
private val pending = java.util.concurrent.LinkedBlockingQueue<Pair<Int, Boolean>>()
/** keyCode, the character it produced (0 if none), pressed. */
private data class KeyStep(val keyCode: Int, val unicode: Int, val pressed: Boolean)
private val pending = java.util.concurrent.LinkedBlockingQueue<KeyStep>()
/** Drains [pending] on its own thread: the UI thread must not sleep between key states. */
private val worker: Thread by lazy {
Thread({
while (true) {
val (keyCode, pressed) = pending.take()
runCatching { NativeApp.usbKeyboardKey(0, keyCode, pressed) }
val step = pending.take()
runCatching {
NativeApp.usbKeyboardKey(0, step.keyCode, step.unicode, step.pressed)
}
runCatching { Thread.sleep(KEY_STEP_MS) }
}
}, "usb-kbd-ime").apply { isDaemon = true; start() }
}
private fun enqueue(keyCode: Int, pressed: Boolean) {
private fun enqueue(keyCode: Int, unicode: Int, pressed: Boolean) {
worker // start on first use
pending.put(keyCode to pressed)
pending.put(KeyStep(keyCode, unicode, pressed))
}
/** Send one character as the key-down/key-up pair(s) a real keyboard would produce. */
@@ -129,12 +163,12 @@ object SoftKeyboard {
KeyEvent.ACTION_UP -> false
else -> return
}
enqueue(event.keyCode, pressed)
enqueue(event.keyCode, event.unicodeChar, pressed)
}
internal fun tap(keyCode: Int) {
enqueue(keyCode, true)
enqueue(keyCode, false)
enqueue(keyCode, 0, true)
enqueue(keyCode, 0, false)
}
}
@@ -3346,7 +3346,7 @@ open class MainActivityRuntime : ComponentActivity() {
else -> return false // MULTIPLE etc. — ignore
}
return runCatching {
NativeApp.usbKeyboardKey(0, kc, pressed)
NativeApp.usbKeyboardKey(0, kc, event.unicodeChar, pressed)
}.getOrDefault(false)
}
@@ -1,5 +1,6 @@
package com.armsx2.ui
import com.armsx2.input.KeyboardExtraKeys
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -130,6 +131,12 @@ object WindowImpl {
com.armsx2.ui.touch.TouchControlsOverlay()
}
// The keys the IME does not have (arrows, Esc, Tab, function row), shown only
// while the emulated keyboard is up. Outside the density override above: this
// is normal UI and should scale with the UI scale setting, unlike the touch
// controls, whose size comes from the user's own layout.
KeyboardExtraKeys()
if (showLibrary.value && MainActivityRuntime.eState.value == EmuState.RUNNING && !overlayVisible.value) {
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.56f))) {
com.armsx2.navigation.AppNavigation()
@@ -972,6 +972,62 @@ private fun BackupRestoreRows() {
BackupActionRow("💾", "app.backup.export", "app.backup.export.desc", status, busy, doExport)
BackupActionRow("📥", "app.backup.import", "app.backup.import.desc", "", busy, doImport)
// Save-data import. Sits here rather than in a library screen because it is the same act as
// Restore -- bringing files the app cannot otherwise receive into its own data folder.
//
// It exists because of a platform rule: Android 11 stopped third-party file managers from
// writing into Android/data, so dropping a downloaded roster into savedata/ now fails with
// EACCES no matter which file manager is used. We are the only process that can still write
// there. Reported against All Pro Football 2K8.
//
// Two rows because the two pickers are different intents and a user has whichever they have:
// an archive straight from a download, or an already-unzipped folder.
val onImported = { r: com.armsx2.SaveDataImporter.Outcome ->
busy = false
val names = r.saves.joinToString(", ") { s -> s.title?.takeIf { it.isNotBlank() } ?: s.dirName }
status = when {
r.ok && r.saves.any { it.replaced } -> I18n.get("app.savedata.replaced").replace("%s", names)
r.ok -> I18n.get("app.savedata.done").replace("%s", names)
// A cancelled picker reports no error; saying "failed" at someone who backed out
// themselves is noise.
r.error == null -> ""
else -> I18n.get("app.savedata.failed").replace("%s", r.error)
}
if (status.isNotEmpty()) Toast.makeText(context, status, Toast.LENGTH_LONG).show()
}
val saveArchivePicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
busy = true
status = I18n.get("app.savedata.working")
scope.launch(Dispatchers.IO) {
val r = com.armsx2.SaveDataImporter.importArchive(context, uri)
withContext(Dispatchers.Main) { onImported(r) }
}
}
val saveFolderPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree()
) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
busy = true
status = I18n.get("app.savedata.working")
scope.launch(Dispatchers.IO) {
val r = com.armsx2.SaveDataImporter.importFolder(context, uri)
withContext(Dispatchers.Main) { onImported(r) }
}
}
val doSaveImport = {
if (!busy) saveArchivePicker.launch(arrayOf("application/zip", "application/octet-stream"))
}
val doSaveFolderImport = { if (!busy) saveFolderPicker.launch(null) }
BackupActionRow("🎮", "app.savedata.import", "app.savedata.import.desc", "", busy, doSaveImport)
BackupActionRow(
"📂", "app.savedata.importFolder", "app.savedata.importFolder.desc", "", busy,
doSaveFolderImport,
)
// Factory reset. Sits with Backup/Restore because Export is the thing to do first — the
// prompt says so. Routed through GlobalConfirm rather than a local overlay: this row is
// inside a scrolling tab, so a scrim drawn here would clip to the row's bounds.
@@ -2,6 +2,7 @@ package com.armsx2.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -20,12 +21,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.armsx2.config.ConfigStore
import com.armsx2.config.CoreSettingOverrides
import com.armsx2.config.SettingsScope
import com.armsx2.runtime.MainActivityRuntime
import com.armsx2.i18n.I18n
import com.armsx2.i18n.str
import com.armsx2.ui.common.ArmsBackdrop
@@ -119,6 +123,18 @@ fun CoreSettingsScreen(onBack: () -> Unit, scope: SettingsScope, serial: String?
// Bumped after every write so the tree is re-read and dependent nodes (a value the core
// clamped, say) show what the core actually stored rather than what we sent.
var revision by remember { mutableStateOf(0) }
// What this scope currently remembers. Read alongside the tree so a row can say whether
// its value is a recorded override rather than whatever the curated settings last wrote.
//
// Until this existed there was no way to see, let alone undo, a recorded path: the store
// re-pushes at the tail of applyTo, so an override silently beats every curated screen
// forever, and the only evidence is a config.yml that disagrees with the UI. Two separate
// one-shot migrations had already been written to purge leftovers by name; both had run
// and RSX Profiler was still recorded on a test device.
var overrides by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Reset is two taps rather than an AlertDialog: dialogs swallow gamepad keys here, and
// this screen is reachable from the in-game menu with only a controller in hand.
var confirmingReset by remember { mutableStateOf(false) }
LaunchedEffect(revision) {
val loaded = withContext(Dispatchers.IO) {
@@ -134,14 +150,52 @@ fun CoreSettingsScreen(onBack: () -> Unit, scope: SettingsScope, serial: String?
error = if (it.isEmpty()) I18n.get("core.settings.unavailable") else null
}
loaded.onFailure { error = I18n.get("core.settings.unavailable") }
overrides = runCatching { CoreSettingOverrides.load(scope, serial) }.getOrDefault(emptyMap())
}
// settingsSet takes JSON: bools and numbers bare, enums and strings quoted.
fun encode(type: String, raw: String): String = when (type) {
"bool", "int", "uint", "float" -> raw
else -> JSONObject.quote(raw)
}
/**
* Re-push the curated settings after dropping a record.
*
* Forgetting an override only stops it being replayed; the value it already wrote is still
* live in the core. For a node one of the normal screens also writes, this puts that screen's
* value back immediately. For a node nothing curated owns, the caller restores the core's own
* default first -- otherwise "reset" would leave the value exactly where the override put it
* and look like it did nothing.
*/
fun reapplyCurated() {
runCatching {
ConfigStore.resolveForGame(MainActivityRuntime.currentGame.value?.settingsKey).applyTo()
}
}
fun clearOne(setting: CoreSetting) {
runCatching { CoreSettingOverrides.forget(scope, serial, setting.path) }
runCatching { RPCSX.instance.settingsSet(setting.path, encode(setting.type, setting.default)) }
reapplyCurated()
revision++
}
fun clearAll() {
val cleared = overrides.keys.toSet()
runCatching { CoreSettingOverrides.clear(scope, serial) }
// Defaults first, curated second: applyTo below rewrites every node it owns, so the
// only ones this actually decides are the nodes no curated screen touches.
all.asSequence().filter { it.path in cleared }.forEach {
runCatching { RPCSX.instance.settingsSet(it.path, encode(it.type, it.default)) }
}
reapplyCurated()
confirmingReset = false
revision++
}
fun write(setting: CoreSetting, raw: String) {
// settingsSet takes JSON: bools and numbers bare, enums and strings quoted.
val encoded = when (setting.type) {
"bool", "int", "uint", "float" -> raw
else -> JSONObject.quote(raw)
}
val encoded = encode(setting.type, raw)
runCatching { RPCSX.instance.settingsSet(setting.path, encoded) }
// Remember it, or applyTo will write the curated store back over this node the next
// time any setting changes or the next time a game boots. Into this screen's tier:
@@ -183,6 +237,27 @@ fun CoreSettingsScreen(onBack: () -> Unit, scope: SettingsScope, serial: String?
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
)
if (overrides.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"${str("core.settings.overrideCount")}: ${overrides.size}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
TextButton(onClick = { if (confirmingReset) clearAll() else confirmingReset = true }) {
Text(
if (confirmingReset) str("core.settings.resetConfirm")
else str("core.settings.reset"),
color = MaterialTheme.colorScheme.error,
)
}
}
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
@@ -216,11 +291,35 @@ fun CoreSettingsScreen(onBack: () -> Unit, scope: SettingsScope, serial: String?
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) {
Text(
setting.section,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
setting.section,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Marked per row, not just counted in the header: the point of
// the count is to notice, the point of this is to know WHICH
// node is ignoring the normal settings screens.
if (setting.path in overrides) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
str("core.settings.overridden"),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
TextButton(onClick = { clearOne(setting) }) {
Text(
str("core.settings.clearOne"),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
CoreSettingRow(setting) { write(setting, it) }
}
}
@@ -472,8 +472,29 @@ public final class NativeApp {
public static void usbLightgunButton(int p, int b, boolean pressed) { Unsupported.note("usbLightgunButton"); }
public static void usbSetDeviceType(int port, String type) { Unsupported.note("usbSetDeviceType"); }
public static void usbSetDeviceSubtype(int port, int subtype) { Unsupported.note("usbSetDeviceSubtype"); }
public static void usbSetKeyboardEnabled(int port, boolean e) { Unsupported.note("usbSetKeyboardEnabled"); }
public static boolean usbKeyboardKey(int p, int k, boolean pressed) { Unsupported.note("usbKeyboardKey"); return false; }
/** [MAPPED] Attach or detach the emulated PS3 keyboard (cellKb).
*
* There is no PS3 equivalent of PCSX2's USB HID keyboard device, so the ARMSX2
* name is kept but the thing it drives is RPCS3's keyboard handler: Basic
* installs the Android handler, Null reports nothing attached. Takes effect on
* the next boot, since the handler is created during Emulator::Load.
*
* port is ignored: RPCS3 has one keyboard handler, not one per USB port. */
public static void usbSetKeyboardEnabled(int port, boolean e) {
Rpcs3Bridge.setKeyboardEnabled(e);
}
/** [MAPPED] -> _rpcsx_keyboardKey. port is ignored (single handler). */
public static boolean usbKeyboardKey(int p, int k, boolean pressed) {
return Rpcs3Bridge.keyboardKey(k, 0, pressed);
}
/** [MAPPED] As above, plus the character the key produced (KeyEvent.getUnicodeChar()).
* cellKb derives its own character from the raw code and the live modifier state, so
* this only matters to the emulator's own overlays. */
public static boolean usbKeyboardKey(int p, int k, int unicode, boolean pressed) {
return Rpcs3Bridge.keyboardKey(k, unicode, pressed);
}
public static String usbDeviceTypes() { return ""; }
// ===== PS2-only subsystems: these screens must be REMOVED, not stubbed =====
@@ -1015,6 +1015,27 @@ object Rpcs3Bridge {
}
}
/**
* Attach or detach the emulated PS3 keyboard.
*
* RPCS3 decides this once, in Emulator::Load, from Input/Output@@Keyboard --
* there is no live attach the way a USB device has one. So this writes the
* setting and the next boot picks it up; a running game keeps whatever it
* started with. Flipping it mid-game and expecting cellKb to notice is the one
* thing this cannot do.
*/
@JvmStatic
fun setKeyboardEnabled(enabled: Boolean) {
Rpcs3Settings.setKeyboardHandler(enabled)
}
/** One key transition for cellKb. See RPCSX.keyboardKey. */
@JvmStatic
fun keyboardKey(androidKeyCode: Int, unicode: Int, pressed: Boolean): Boolean =
runCatching {
RPCSX.instance.keyboardKey(androidKeyCode, unicode, pressed, false)
}.getOrDefault(false)
@JvmStatic
fun setPadButton(port: Int, index: Int, range: Int, pressed: Boolean) {
val pad = pads.getOrNull(port) ?: return
@@ -106,6 +106,12 @@ object Rpcs3Settings {
fun setConsoleLanguage(index: Int) = setIndexedEnum("$SYSTEM@@Language", CONSOLE_LANGUAGES, index, 1)
fun setConsoleRegion(index: Int) = setIndexedEnum("$SYSTEM@@License Area", CONSOLE_REGIONS, index, 1)
fun setKeyboardType(index: Int) = setIndexedEnum("$SYSTEM@@Keyboard Type", KEYBOARD_TYPES, index, 0)
/** Which keyboard handler cellKb is served by. "Basic" is the Android handler
* (virtual_keyboard_handler); "Null" reports no keyboard attached, which is the
* default and what every build before this one always used. Read once, during
* Emulator::Load. */
fun setKeyboardHandler(enabled: Boolean) = setEnum("$IO@@Keyboard", if (enabled) "Basic" else "Null")
fun setDateFormat(index: Int) = setIndexedEnum("$SYSTEM@@Date Format", DATE_FORMATS, index, 1)
fun setTimeFormat(index: Int) = setIndexedEnum("$SYSTEM@@Time Format", TIME_FORMATS, index, 1)
fun setEnterButtonAssign(index: Int) =
@@ -111,6 +111,13 @@ class RPCSX {
* (RIGHT, LEFT, UP, DOWN, TRIANGLE, CIRCLE, CROSS, SQUARE, L1, R1, L2, R2),
* each 1..255, or 0 to leave that button digital. */
external fun overlayPadPressure(port: Int, values: IntArray): Boolean
/** One key transition for the emulated PS3 keyboard (cellKb).
*
* [androidKeyCode] is an android.view.KeyEvent keycode and [unicode] is what
* KeyEvent.getUnicodeChar() returned for it, or 0. Returns false when nothing
* consumed the key no game running, the keyboard handler off, or a key the
* PS3 keyboard has no equivalent of. */
external fun keyboardKey(androidKeyCode: Int, unicode: Int, pressed: Boolean, repeat: Boolean): Boolean
external fun collectGameInfo(rootDir: String, progressId: Long): Boolean
external fun systemInfo(): String
external fun settingsGet(path: String): String
@@ -7,3 +7,4 @@ Not built by Gradle. Gradle only builds the JNI glue (`src/main/cpp`).
| `libarmsx3-core.so` | `android/configure.sh` + `ninja rpcsx-android`, then `llvm-strip --strip-unneeded` | The emulator. Needs LLVM and a long build, so it is staged rather than built on every Gradle sync. |
| `librashader.so` | librashader release, arm64-v8a | RetroArch `.slangp` shader chains. MPL-2.0, kept as its own `.so` so nothing MPL links into the GPL-2.0 core. |
| `libc++_shared.so` | NDK 29 sysroot | **`librashader.so` links against it.** Without it librashader fails to `dlopen` and the shader chain silently does nothing — the core and the glue are both `c++_static` and do not need it themselves, which is why its absence is easy to miss. |
| `libEGL_angle.so`, `libGLESv2_angle.so` | ANGLE prebuilts, arm64-v8a (BSD-3-Clause) | The "ANGLE" choice under the OpenGL renderer's GL driver section. `MainActivityRuntime.applyAngleEnv` points `ARMSX2_ANGLE_EGL_LIBRARY` / `ARMSX2_ANGLE_GLES_LIBRARY` at these by absolute path in `nativeLibraryDir`. **Unlike the rest of this table they are tracked in git** — they are redistributable prebuilts, not build output, so `../.gitignore` un-ignores them by name and `verifyAngleLibs` in `app/build.gradle.kts` fails the build if they go missing. |

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