mirror of
https://github.com/ARMSX2/ARMSX3.git
synced 2026-08-24 16:58:52 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e65c8b212 | ||
|
|
91952ae4c1 | ||
|
|
ef63026354 | ||
|
|
da4169148f | ||
|
|
ca3b755fd1 | ||
|
|
5c810c72c2 | ||
|
|
c1781b95cb | ||
|
|
fb5045d086 | ||
|
|
4c080066cf | ||
|
|
044ba9cb03 | ||
|
|
5dee5bc42e | ||
|
|
1b9ab35ac0 | ||
|
|
26c39e1f55 | ||
|
|
8bc7ca307c |
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user