13 Commits
Author SHA1 Message Date
jpolo1224 ab3335b8d6 Make the auto-save options and save-state import do what they say
Auto-save on exit, auto-load on boot and the interval auto-save were ARMSX2 shims
returning false that were never ported, so all three toggles persisted and read
back while doing nothing -- the interval job woke on schedule to call a function
that always failed. They now use a reserved slot above the ten the picker shows,
reusing the numbered-slot path rather than growing a second mechanism.

Import treated getGamePathSlot as a file path, but it answers with the title id:
File(id).exists() was false for every slot, so the first OCCUPIED slot read as
free and the destination resolved against the process working directory. It
copied the file nowhere useful and reported the slot it had not written.
Occupancy now comes from the core and the destination from the real path.

Also says how large a state is before the storage bill arrives, and stops the
interval description promising a pause when a PS3 save is a stop and a reload.
2026-08-10 13:20:38 -04:00
jpolo1224 99b6b47ee1 Look for slot thumbnails where the core actually wrote them
The reader rooted the path at systemDirPosix(), which is null unless a data
folder was explicitly picked, so on a default install it returned before it ever
looked and every slot drew as a blank tile with the thumbnail sitting on disk
beside the state it belongs to. Falls back to getExternalFilesDir, which is what
fs::get_config_dir() resolves to on that install and where the writer put it --
the same fallback inputProfilesDir() makes for the same reason.
2026-08-10 13:03:07 -04:00
jpolo1224 6255ce5840 Run posted main-thread callbacks off the caller's thread
CallFromMainThread without a wake_up is a post: upstream hands the callback to
the GUI thread and returns. This ran it inline instead, under whatever locks the
caller held.

lv2_obj::sleep_unlocked posts one while holding lv2_obj::g_mutex, which is what
the comment on that call site is about. The callback is FinalizeRunRequest, the
wake for a restored savestate, so it took g_mutex against itself and every thread
stopped there: the log reaches Final Thread and goes quiet with the SPUs spinning
and the progress overlay frozen on its last figure. It took out loading a state
and saving one alike, a save being a stop and a restore.

Callers passing wake_up are waiting on completion and still run inline.
2026-08-10 12:52:51 -04:00
jpolo1224 87b0992fdd Revert keying the SPU cache on savestate-compatible mode
The hash it changed is one of several computed over the same function data, and
only this one moved, so the cache rebuild it forced ran through a path whose
other sites disagreed. Loading a state then wedged in Building SPU cache with
nothing compiling.

Reuse of blocks across a change of the setting is still wrong, but it is an
upstream behaviour that predates this and is better addressed by invalidating
the object cache once when the mode changes than by moving one hash out from
under the others.
2026-08-10 12:40:22 -04:00
jpolo1224 2a0c04ade7 Stop treating an already-registered game as a boot failure
already_added reports that the title was already in games.yml, which is the
normal case for anything booted once before, and RPCS3's own front-end passes it
through for that reason. The bridge failed every result that was not NoErrors,
so the boot was abandoned and the user was returned to the library with "Game
failed to start: AlreadyAdded".
2026-08-10 12:38:35 -04:00
jpolo1224 5b8ff01fe2 Route the savestate-compatible setting to the core
The bridge translates each section it knows and returns false for the rest, and
there was no Savestate case, so the write was dropped on the floor. The setting
could not be turned on at all -- not by the default, not from the settings row --
and savestates failed to lock the SPUs while telling the user to enable exactly
the option that was being discarded.
2026-08-10 12:23:55 -04:00
jpolo1224 51d465f193 Key the SPU cache on savestate-compatible mode
The setting changes the code generated for blocking channel reads -- the GPRs
are stored rather than the thread being marked unsavable -- but the cache key
was a hash of the guest code alone, and the compiled object is cached under it.
A block built in one mode was therefore reused unchanged in the other, so
turning the setting on left the old unsavable blocks in place and savestates
went on failing to lock the SPUs with a message telling the user to enable a
setting they had already enabled.

Mixed in only when set, so caches built in the default mode stay valid.
2026-08-10 12:17:53 -04:00
jpolo1224 6cd5b8b986 Turn save states on by default
A save that fails with "missing SPU setting" reads as broken rather than as a
setting waiting to be found, and the setting is not one a player would think to
look for. Upstream defaults it off to protect SPU performance; here the feature
not working at all is the worse trade.

Costs are unchanged and still stated on the switch: it slows the SPUs while it
is on, and a PS3 state runs 500MB to 3GB. Turning it off restores upstream
behaviour and gives the performance back, and that choice is now respected on
every boot rather than overwritten.

Release 0.4.1.
2026-08-10 11:51:09 -04:00
jpolo1224 44f62f310c Let the user turn save states on
Save states could not be taken at all. Saving has to stop every SPU somewhere it
can be serialised from, which is what Compatible Savestate Mode does, and this
port wrote that setting to false on every boot -- so the save failed with
"missing SPU setting" no matter what the user did, and nothing on screen
connected the two.

The reasoning was sound: the mode costs SPU performance, and a PS3 state runs
500MB to 3GB, so a few saves fill a phone. Both of those are costs to disclose,
not reasons to decide for someone. It is now a setting, still off by default, so
nobody pays for a feature they did not ask for and installs from the window when
it was pushed as true are corrected by the same write.

Placed with the SPU rows rather than under a savestate heading, because that is
where the cost lands, and the description says what both costs are before the
switch is touched.
2026-08-10 11:47:41 -04:00
jpolo1224 431b6d0925 Stop a recovered LLVM fatal error from wedging the SPU JIT
run_recoverable_llvm runs code generation on a disposable thread and terminates
it through pthread_exit when LLVM invokes its fatal error handler. bionic does
not force-unwind C++ frames on pthread_exit, so the lock MCJIT holds over the
execution engine is never released and stays held by a thread that no longer
exists.

Every entry point into the engine takes that lock, so the next compile hangs and
so does teardown. One recovered error, and the emulator is finished until it is
killed -- and the error is recovered, which is the point: it is meant to be
survivable.

Their Kotlin log-channel screen is left out; this port has its own.

From MaxsTechReview in PS3Native.
2026-08-10 11:44:11 -04:00
jpolo1224 4a73773cee Bound two unchecked sizes reached from file contents
Two places compute a size from values a file supplies and use it without
checking it is possible.

A SELF or SCE header gives the metadata offset and the header size, and the
buffer between them is sized by subtracting one from the other. Both are
unsigned, so a truncated or malformed dump that puts the offset past the header
end underflows into a near-SIZE_MAX allocation, which fails as an out-of-memory
rather than as the bad file it is. Reject the layout instead.

Texture uploads take the mip levels the guest describes and write them into an
image built from the destination's own dimensions, which can hold fewer. Drop
the levels that do not fit rather than writing past what was allocated.

From MaxsTechReview in PS3Native.
2026-08-10 11:43:05 -04:00
Zulux91 e16f0fcd3d Sample thread CPU time by tid on Android
get_cycles passes the pthread handle to pthread_getcpuclockid, which glibc
answers with an error for a thread that has already exited -- the else branch
below returns the last known value for exactly that case. bionic instead looks
the handle up in its list of live threads and aborts the process when it is not
there.

m_thread is never cleared when a thread ends and the performance overlay samples
every PPU, SPU and RSX thread on a timer, so one finished thread is enough to
take the emulator down with it. Latent here rather than absent: it needs the
overlay on and a thread to have gone.

Record the kernel tid at initialize and build the per-thread clock id from it
the way bionic does once its own lookup succeeds, so clock_gettime simply fails
for a dead thread, which is what the surrounding code already expects. Cleared
at finalize so a thread stops being sampled before it goes away. Other platforms
keep the original path.

Found and fixed by Zulux91 in PS3Native.
2026-08-10 11:42:14 -04:00
jpolo1224 6463d010e9 Ask the game for its content id when installing its licence
A licence installed from a locked game's own menu reported success and left the
game locked. The install writes the file into exdata under its own name, because
a RAP's name is conventionally the content id it unlocks, and that convention is
the whole lookup: the core opens exdata/<content id>.rap and nothing else. A file
saved as "license(1).rap", renamed, or tidied up on the way over therefore lands
where nothing will look for it, and the only feedback is the game asking again.

Where the game is known, ask it. The native path decrypts the EBOOT's
supplemental header to read the content id, which works on a locked game because
that header is not what the licence protects, and names the file correctly
whatever the user's copy is called. Falls back to the name when there is no game
to ask or the header cannot be read.

The package screen keeps the name-based path: a licence installed on its own has
no game to resolve against.
2026-08-10 11:36:42 -04:00
23 changed files with 422 additions and 53 deletions
+7
View File
@@ -545,11 +545,18 @@ class jit_compiler final
// Disk Space left
atomic_t<usz> m_disk_space = umax;
bool m_poisoned = false;
public:
jit_compiler(const std::unordered_map<std::string, u64>& _link, std::string_view _cpu, u32 flags = 0, std::function<u64(const std::string&)> symbols_cement = {}) noexcept;
jit_compiler& operator=(thread_state) noexcept;
~jit_compiler() noexcept;
bool is_poisoned() const noexcept
{
return m_poisoned;
}
// Get LLVM context
auto& get_context()
{
+24 -2
View File
@@ -872,17 +872,26 @@ jit_compiler& jit_compiler::operator=(thread_state s) noexcept
jit_compiler::~jit_compiler() noexcept
{
if (m_poisoned)
{
jit_log.error("Abandoning poisoned LLVM execution engine (leaked to avoid a deadlock in ~MCJIT)");
static_cast<void>(m_engine.release());
static_cast<void>(m_context.release());
}
}
void jit_compiler::add(std::unique_ptr<llvm::Module> _module, const std::string& path)
{
ObjectCache cache{path, this};
m_poisoned = true;
m_engine->setObjectCache(&cache);
const auto ptr = _module.get();
m_engine->addModule(std::move(_module));
m_engine->generateCodeForModule(ptr);
m_engine->setObjectCache(nullptr);
m_poisoned = false;
for (auto& func : ptr->functions())
{
@@ -904,6 +913,7 @@ bool jit_compiler::try_add(std::unique_ptr<llvm::Module> _module, const std::str
m_engine->generateCodeForModule(ptr);
}, error))
{
m_poisoned = true;
return false;
}
@@ -921,8 +931,11 @@ bool jit_compiler::try_add(std::unique_ptr<llvm::Module> _module, const std::str
void jit_compiler::add(std::unique_ptr<llvm::Module> _module)
{
const auto ptr = _module.get();
m_poisoned = true;
m_engine->addModule(std::move(_module));
m_engine->generateCodeForModule(ptr);
m_poisoned = false;
for (auto& func : ptr->functions())
{
@@ -941,6 +954,7 @@ bool jit_compiler::try_add(std::unique_ptr<llvm::Module> _module, std::string& e
m_engine->generateCodeForModule(ptr);
}, error))
{
m_poisoned = true;
return false;
}
@@ -1001,15 +1015,23 @@ void jit_compiler::update_global_mapping(const std::string& name, u64 addr)
void jit_compiler::fin()
{
m_poisoned = true;
m_engine->finalizeObject();
m_poisoned = false;
}
bool jit_compiler::try_fin(std::string& error)
{
return run_recoverable_llvm([&]()
if (!run_recoverable_llvm([&]()
{
m_engine->finalizeObject();
}, error);
}, error))
{
m_poisoned = true;
return false;
}
return true;
}
u64 jit_compiler::get(const std::string& name)
+17 -2
View File
@@ -2844,6 +2844,7 @@ void thread_base::initialize(void (*error_cb)())
[[maybe_unused]] u64 new_tid = 0;
#elif defined(ANDROID)
const u64 new_tid = pthread_self();
m_native_tid = static_cast<u32>(gettid());
#else
const u64 new_tid = reinterpret_cast<u64>(pthread_self());
#endif
@@ -2998,6 +2999,10 @@ u64 thread_base::finalize(thread_state result_state) noexcept
// Avoid race with the destructor
const u64 _self = m_thread;
#ifdef ANDROID
m_native_tid = 0;
#endif
// Set result state (errored or finalized)
m_sync.fetch_op([&](u32& v)
{
@@ -3373,11 +3378,21 @@ u64 thread_base::get_cycles()
clockid_t _clock;
struct timespec thread_time;
#ifdef ANDROID
pthread_t thread_id = handle;
const u32 native_tid = m_native_tid;
if (!handle || !native_tid)
{
return m_cycles;
}
_clock = (~static_cast<clockid_t>(native_tid) << 3) | 6;
if (!clock_gettime(_clock, &thread_time))
#else
pthread_t thread_id = reinterpret_cast<pthread_t>(handle);
#endif
if (!pthread_getcpuclockid(thread_id, &_clock) && !clock_gettime(_clock, &thread_time))
#endif
{
cycles = static_cast<u64>(thread_time.tv_sec) * 1'000'000'000 + thread_time.tv_nsec;
#endif
+4
View File
@@ -139,6 +139,10 @@ private:
// Thread handle (platform-specific)
atomic_t<u64> m_thread{0};
#ifdef ANDROID
atomic_t<u32> m_native_tid{0};
#endif
// Thread cycles
atomic_t<u64> m_cycles{0};
+2 -2
View File
@@ -29,8 +29,8 @@ android {
applicationId = "com.armsx3"
minSdk = 26
targetSdk = 37
versionCode = 6
versionName = "0.4"
versionCode = 7
versionName = "0.4.1"
// 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.
@@ -99,6 +99,17 @@ data class Ps3Settings(
val spuCache: Boolean = true,
val llvmPrecompile: Boolean = true,
val accurateSpuDma: Boolean = false,
/** Locks every SPU thread into a state a savestate can be serialised from.
*
* Savestates cannot be taken without it: the save has to stop each SPU somewhere it can
* be written out, and with this off that fails on any title with SPU work running.
*
* On by default, unlike upstream, so the feature works for someone who never opens
* settings -- a save that fails with "missing SPU setting" reads as broken, not as a
* setting waiting to be found. The costs are real and are stated on the switch: it slows
* the SPUs while it is on, and a PS3 state runs 500MB to 3GB. Turning it off restores
* upstream behaviour and gives the SPU performance back. */
val savestateCompatibleMode: Boolean = true,
val clocksScale: Int = 100,
val resolutionScale: Int = 100,
/** 0 = Disabled. Off by default: mobile drivers routinely lack the MSAA
@@ -949,6 +960,7 @@ data class Settings(
put("PS3/Core", "SPU Cache", "bool", ps3.spuCache.toString())
put("PS3/Core", "LLVM Precompilation", "bool", ps3.llvmPrecompile.toString())
put("PS3/Core", "Accurate SPU DMA", "bool", ps3.accurateSpuDma.toString())
put("Savestate", "Compatible Savestate Mode", "bool", ps3.savestateCompatibleMode.toString())
put("PS3/Core", "Clocks scale", "int", ps3.clocksScale.toString())
// From upscaleFloat, which is the control that exists.
//
@@ -1248,19 +1260,12 @@ data class Settings(
// above it would already be 60; setting it explicitly means the cap does not depend
// on the vblank path holding, which it did not. Enum node, so the value is quoted.
runCatching { net.rpcsx.RPCSX.instance.settingsSet("Video@@Frame limit", "\"60\"") }
// Held at the upstream default, which is off.
//
// Savestates cannot work without it: saving has to lock every SPU thread into a state
// it can be serialised from, and with this off that lock fails on any title with SPU
// work running. It was turned on for exactly that reason and then turned back off,
// because a PS3 savestate runs 500MB to 3GB and the feature was dropped rather than
// ship something that fills a phone in a handful of saves.
//
// Written explicitly rather than left alone: it was pushed as true for a while, so
// installs from that window have true persisted in config.yml and would keep paying
// for it. It costs SPU performance, which is the whole reason upstream defaults it
// off, and nothing here uses what it buys.
runCatching { net.rpcsx.RPCSX.instance.settingsSet("Savestate@@Compatible Savestate Mode", "false") }
// Compatible Savestate Mode is no longer forced off here; applyTo writes it from
// ps3.savestateCompatibleMode above, so the two costs it carries -- SPU performance
// and a 500MB to 3GB state file -- are the user's to accept rather than a decision
// taken for them. Defaulted on so the feature works without hunting for a switch,
// and written every boot either way, so a user who turns it off has that respected
// rather than re-enabled on the next launch.
// Settings a specific title needs in order to run at all, then the user's own core
// edits on top. Order matters: game defaults are a floor, an explicit user choice
@@ -1925,6 +1930,7 @@ data class Settings(
put("ps3SpuCache", ps3.spuCache)
put("ps3LlvmPrecompile", ps3.llvmPrecompile)
put("ps3AccurateSpuDma", ps3.accurateSpuDma)
put("ps3SavestateCompatibleMode", ps3.savestateCompatibleMode)
put("ps3ClocksScale", ps3.clocksScale)
put("ps3ResolutionScale", ps3.resolutionScale)
put("ps3MsaaMode", ps3.msaaMode)
@@ -2259,6 +2265,7 @@ data class Settings(
spuCache = json.optBoolean("ps3SpuCache", def.ps3.spuCache),
llvmPrecompile = json.optBoolean("ps3LlvmPrecompile", def.ps3.llvmPrecompile),
accurateSpuDma = json.optBoolean("ps3AccurateSpuDma", def.ps3.accurateSpuDma),
savestateCompatibleMode = json.optBoolean("ps3SavestateCompatibleMode", def.ps3.savestateCompatibleMode),
clocksScale = json.optInt("ps3ClocksScale", def.ps3.clocksScale),
resolutionScale = json.optInt("ps3ResolutionScale", def.ps3.resolutionScale),
msaaMode = json.optInt("ps3MsaaMode", def.ps3.msaaMode),
@@ -2573,6 +2580,7 @@ data class Settings(
if (current.ps3.spuCache != base.ps3.spuCache) j.put("ps3SpuCache", current.ps3.spuCache)
if (current.ps3.llvmPrecompile != base.ps3.llvmPrecompile) j.put("ps3LlvmPrecompile", current.ps3.llvmPrecompile)
if (current.ps3.accurateSpuDma != base.ps3.accurateSpuDma) j.put("ps3AccurateSpuDma", current.ps3.accurateSpuDma)
if (current.ps3.savestateCompatibleMode != base.ps3.savestateCompatibleMode) j.put("ps3SavestateCompatibleMode", current.ps3.savestateCompatibleMode)
if (current.ps3.clocksScale != base.ps3.clocksScale) j.put("ps3ClocksScale", current.ps3.clocksScale)
if (current.ps3.resolutionScale != base.ps3.resolutionScale) j.put("ps3ResolutionScale", current.ps3.resolutionScale)
if (current.ps3.msaaMode != base.ps3.msaaMode) j.put("ps3MsaaMode", current.ps3.msaaMode)
@@ -2868,6 +2876,7 @@ data class Settings(
spuCache = if (overrides.has("ps3SpuCache")) overrides.getBoolean("ps3SpuCache") else base.ps3.spuCache,
llvmPrecompile = if (overrides.has("ps3LlvmPrecompile")) overrides.getBoolean("ps3LlvmPrecompile") else base.ps3.llvmPrecompile,
accurateSpuDma = if (overrides.has("ps3AccurateSpuDma")) overrides.getBoolean("ps3AccurateSpuDma") else base.ps3.accurateSpuDma,
savestateCompatibleMode = if (overrides.has("ps3SavestateCompatibleMode")) overrides.getBoolean("ps3SavestateCompatibleMode") else base.ps3.savestateCompatibleMode,
clocksScale = if (overrides.has("ps3ClocksScale")) overrides.getInt("ps3ClocksScale") else base.ps3.clocksScale,
resolutionScale = if (overrides.has("ps3ResolutionScale")) overrides.getInt("ps3ResolutionScale") else base.ps3.resolutionScale,
msaaMode = if (overrides.has("ps3MsaaMode")) overrides.getInt("ps3MsaaMode") else base.ps3.msaaMode,
@@ -1,5 +1,8 @@
package com.armsx2.data.library
import android.content.Context
import android.os.ParcelFileDescriptor
import net.rpcsx.ProgressRepository
import net.rpcsx.RPCSX
import java.io.File
@@ -29,6 +32,34 @@ object Licences {
* The extension is written lower-case because that is what unself.cpp looks for when it
* searches exdata for a licence matching a game's content id.
*/
/**
* Install [file] as the licence for the game at [gamePath].
*
* Prefers the content id read out of the game's own EBOOT over the one in the file name.
* The name is only a convention: a licence saved as "license(1).rap", renamed, or handed
* around by someone who tidied it up copies into exdata under a name nothing looks for,
* so the install reports success and the game stays locked -- which is exactly what it
* looks like from the outside, and what it was reported as.
*
* The native path decrypts the EBOOT's supplemental header to read the content id, which
* works on a LOCKED game because that header is not what the licence protects. Falls back
* to the name when there is no game to ask, or when the header cannot be read.
*/
fun installRapForGame(context: Context, file: File, gamePath: String?): Boolean {
if (!gamePath.isNullOrBlank() && RPCSX.initialized) {
val installed = runCatching {
val id = ProgressRepository.create(context, "Installing ${file.name}")
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
RPCSX.instance.installKey(fd.fd, id, gamePath)
}
}.getOrDefault(false)
if (installed) return true
}
return installRap(file)
}
fun installRap(file: File): Boolean = runCatching {
val bytes = file.readBytes()
// Same floor as InstallFileInExData: anything shorter is not a key.
@@ -1563,6 +1563,8 @@ val EN: Map<String, String> = mapOf(
"perf.llvmPrecompile.description" to "Compiles modules ahead of the game running instead of on demand. Longer wait at boot, fewer stutters in play.",
"perf.spuLoopDetection.label" to "SPU Loop Detection",
"perf.spuLoopDetection.description" to "Detects SPU wait loops and yields the thread instead of spinning. Can free CPU time on a handheld; a few games misbehave with it on.",
"perf.savestateCompatible.label" to "Allow save states (Compatible Savestate Mode)",
"perf.savestateCompatible.description" to "Required for save states to work at all: saving has to stop every SPU somewhere it can be written out, and without this that fails on any game with SPU work running. Costs SPU performance while it is on, and each save state is roughly 500 MB to 3 GB, so a few of them will fill your storage.",
"perf.accurateSpuDma.label" to "Accurate SPU DMA",
"perf.accurateSpuDma.description" to "Emulates SPU DMA transfers precisely. Slower, and only needed by a handful of games that corrupt without it.",
"common.auto" to "Auto",
@@ -1679,7 +1681,7 @@ val EN: Map<String, String> = mapOf(
"renderer.upscale.label" to "Upscale",
"renderer.vsync.description" to "Sync presentation to the display refresh — less tearing/smoother, slightly more latency. Restart the game to apply.",
"savestate.autoLoadOnBoot" to "Auto-load last state on boot",
"savestate.autoSaveInterval.description" to "Save automatically while you play, so a crash or a flat battery costs at most this much progress. It writes the same auto-save slot as the option above, so your numbered slots stay yours. Saving pauses the game for a moment, so a short interval is felt — 5 minutes is a good starting point.",
"savestate.autoSaveInterval.description" to "Save automatically while you play, so a crash or a flat battery costs at most this much progress. It writes the same auto-save slot as the option above, so your numbered slots stay yours. Saving a PS3 state stops and reloads the game, which takes several seconds each time — keep the interval long, 15 minutes or more.",
"savestate.autoSaveInterval.every" to "Every %d min",
"savestate.autoSaveInterval.label" to "Auto-save while playing",
"savestate.autoSaveInterval.off" to "Off",
@@ -1690,6 +1692,7 @@ val EN: Map<String, String> = mapOf(
"savestate.backup" to "Backup",
"savestate.import" to "Import",
"savestate.hint" to "Choose a slot. Hold a slot, or use the trash button, to delete it.",
"savestate.sizeWarning" to "Save states are big — usually 25–40 MB each, and it varies by game. Ten slots per game adds up, so keep an eye on storage.",
"savestate.delete.mode" to "Delete a save",
"savestate.delete.modeHint" to "Delete mode: choose a save to delete. Tap the trash button again to cancel.",
"savestate.delete.title" to "Delete save state",
@@ -799,8 +799,10 @@ fun HomeScreen(
}
}
// The game itself is not needed to install the key -- a RAP's filename is the content id
// it unlocks -- so this only has to know that a locked game asked for one.
// The game IS needed, when we have it. A RAP's filename is only conventionally the content
// id it unlocks: one saved as "license(1).rap" or renamed on the way over installs under a
// name nothing looks for, so the install reports success and the game stays locked. Asking
// the game's own EBOOT for the id gets it right whatever the file is called.
if (licenceGame != null) {
// Resolved during composition: str() is @Composable and cannot be called from the
// pick callback, the same reason games.addToHome.unsupported is hoisted above.
@@ -813,8 +815,10 @@ fun HomeScreen(
// key that belongs to THIS game.
extensions = setOf("rap"),
onPick = { file ->
val gamePath = licenceGame?.uri?.path
licenceGame = null
val ok = com.armsx2.data.library.Licences.installRap(file)
val ok = com.armsx2.data.library.Licences
.installRapForGame(context, file, gamePath)
Toast.makeText(context, if (ok) installedMsg else failedMsg, Toast.LENGTH_LONG).show()
// The folder set is unchanged, so only the lock state is stale — nothing else
// would prompt a rescan.
@@ -228,11 +228,15 @@ internal fun importSaveStateToNextFreeSlot(context: android.content.Context, uri
val active = MainActivityRuntime.currentGame.value
if (active == null || active.serial.isNullOrBlank()) return SS_IMPORT_NO_GAME
return runCatching {
val free = (0 until 10).firstOrNull { s ->
val p = NativeApp.getGamePathSlot(s)
p.isNullOrBlank() || !File(p).exists()
} ?: return@runCatching SS_IMPORT_SLOTS_FULL
val destPath = NativeApp.getGamePathSlot(free)?.takeIf(String::isNotBlank) ?: return@runCatching SS_IMPORT_FAILED
// Occupancy comes from the core. getGamePathSlot answers with the TITLE ID, so
// File(it).exists() was false for every slot: the first OCCUPIED slot read as free,
// and the destination built from the same value was a relative name that landed in
// the process working directory. The import then reported the slot it had not
// written, which is worse than failing.
val free = (0 until 10).firstOrNull { !NativeApp.hasStateInSlot(it) }
?: return@runCatching SS_IMPORT_SLOTS_FULL
val destPath = NativeApp.getSlotFilePath(free)?.takeIf(String::isNotBlank)
?: return@runCatching SS_IMPORT_FAILED
val dest = File(destPath)
dest.parentFile?.mkdirs()
val ok = context.contentResolver.openInputStream(uri)?.use { input ->
@@ -150,6 +150,16 @@ fun SaveStatePickerScreen(mode: SaveMode, onBack: () -> Unit) {
fontSize = 12.sp,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
)
// Worth saying before the storage bill arrives rather than after: one state is
// tens of megabytes, it varies with the game, and there are ten slots for each.
if (!deleteMode) {
Text(
str("savestate.sizeWarning"),
color = Color(0xFF9AA0A6),
fontSize = 12.sp,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
)
}
failure?.let { key ->
Text(
str(key),
@@ -319,6 +319,15 @@ fun PerformanceTab(state: MutableState<Settings>) {
description = str("perf.accurateSpuDma.description"),
onChange = { apply(s.copy(ps3 = s.ps3.copy(accurateSpuDma = it))) },
)
SettingsDivider()
// Sits with the SPU rows because that is what it costs, not under a savestate
// heading where it would read as free.
ToggleRow(
label = str("perf.savestateCompatible.label"),
value = s.ps3.savestateCompatibleMode,
description = str("perf.savestateCompatible.description"),
onChange = { apply(s.copy(ps3 = s.ps3.copy(savestateCompatibleMode = it))) },
)
}
SettingsDivider()
// The PS2 speedhacks here (INTC/wait-loop detection, fast CDVD, instant
@@ -53,6 +53,7 @@ internal val SETTINGS_SEARCH_INDEX: List<SettingsSearchEntry> = listOf(
SettingsSearchEntry("perf.llvmPrecompile.label", true, SettingsCategory.Performance),
SettingsSearchEntry("perf.spuLoopDetection.label", true, SettingsCategory.Performance),
SettingsSearchEntry("perf.accurateSpuDma.label", true, SettingsCategory.Performance),
SettingsSearchEntry("perf.savestateCompatible.label", true, SettingsCategory.Performance),
SettingsSearchEntry("perf.spuTuning.title", true, SettingsCategory.Performance),
SettingsSearchEntry("adv.accurateSpuRsv.label", true, SettingsCategory.Performance),
SettingsSearchEntry("adv.accurateCacheLine.label", true, SettingsCategory.Performance),
@@ -129,9 +129,16 @@ public final class NativeApp {
public static String getGamePathSlot(int slot) { return Rpcs3Bridge.gamePathForSlot(slot); }
/** [TODO] Autosave is an ARMSX2 feature layered on PCSX2 savestates. */
public static boolean hasAutosaveState() { Unsupported.note("hasAutosaveState"); return false; }
public static boolean saveAutosaveState() { Unsupported.note("saveAutosaveState"); return false; }
public static boolean loadAutosaveState() { Unsupported.note("loadAutosaveState"); return false; }
/** [MAPPED] Auto-save state, kept in a reserved slot above the ten the picker shows. */
public static boolean hasAutosaveState() { return Rpcs3Bridge.hasAutosaveState(); }
public static boolean saveAutosaveState() { return Rpcs3Bridge.saveAutosaveState(); }
public static boolean loadAutosaveState() { return Rpcs3Bridge.loadAutosaveState(); }
/** [MAPPED] Where a slot's state file lives. Not getGamePathSlot, which answers a title id. */
public static String getSlotFilePath(int slot) { return Rpcs3Bridge.slotFilePath(slot); }
/** [MAPPED] Whether a slot holds a state. Ask this rather than probing a path. */
public static boolean hasStateInSlot(int slot) { return Rpcs3Bridge.hasState(slot); }
public static String getAutosaveGamePath() { Unsupported.note("getAutosaveGamePath"); return ""; }
public static byte[] getAutosaveImage() { Unsupported.note("getAutosaveImage"); return null; }
@@ -190,7 +190,11 @@ object Rpcs3Bridge {
appContext?.let { com.armsx2.Ps3PatchRepo.ensureBundledPatches(it) }
val result = RPCSX.boot(target)
if (result != BootResult.NoErrors) {
// AlreadyAdded is not a failure: it says the title was already in games.yml, which is
// the normal case for anything booted before. RPCS3's own front-end passes it through
// for that reason. Treating it as an error turned a re-boot into "Game failed to
// start: AlreadyAdded" and sent the user back to the library.
if (result != BootResult.NoErrors && result != BootResult.AlreadyAdded) {
lastBootError = result.name
android.util.Log.e("ARMSX3", "boot failed: ${result.name} path=$target")
return false
@@ -552,6 +556,16 @@ object Rpcs3Bridge {
else -> return false
}
// Named for the config node it writes, unlike the PS3/* pseudo-sections above.
// Without this case the key fell through to the else and was dropped, so the
// setting never reached the core: savestates failed to lock the SPUs and told
// the user to enable an option that had no effect however they set it.
"Savestate" -> when (key) {
"Compatible Savestate Mode" ->
Rpcs3Settings.setCompatibleSavestateMode(asBool(value))
else -> return false
}
else -> return false
}
return true
@@ -602,6 +616,44 @@ object Rpcs3Bridge {
fun hasState(slot: Int): Boolean =
runCatching { RPCSX.instance.hasStateInSlot(slot) }.getOrDefault(false)
/**
* The auto-save lives one slot above the ten the picker shows.
*
* Auto-save-on-exit, auto-load-on-boot and the interval auto-save were ARMSX2 shims that
* returned false and were never ported, so all three toggles persisted, read back, and
* did nothing -- the interval job woke on schedule for a function that always failed.
* Nothing bounds a slot number on either side of the JNI, so they reuse the numbered-slot
* path that works rather than growing a second mechanism, and the user's ten stay theirs.
*/
private const val AUTOSAVE_SLOT = 10
@JvmStatic
fun hasAutosaveState(): Boolean = hasState(AUTOSAVE_SLOT)
@JvmStatic
fun saveAutosaveState(): Boolean = saveState(AUTOSAVE_SLOT)
@JvmStatic
fun loadAutosaveState(): Boolean = loadState(AUTOSAVE_SLOT)
/**
* Absolute path a slot's state file would occupy, whether or not one is there.
*
* gamePathForSlot answers with the TITLE ID -- the picker wants it as a subtitle -- so
* anything treating it as a path gets a relative name that resolves against the process
* working directory. Import did exactly that. This is the real location, built the way
* armsx3_slot_dir builds it natively.
*/
@JvmStatic
fun slotFilePath(slot: Int): String? = runCatching {
val title = RPCSX.instance.getTitleId().takeIf { it.isNotEmpty() } ?: return null
val root = com.armsx2.runtime.MainActivityRuntime.systemDirPosix()
?: com.armsx2.runtime.MainActivityRuntime.instance
?.applicationContext?.getExternalFilesDir(null)?.absolutePath
?: return null
java.io.File(root, "config/savestates/$title/armsx3_slots/slot$slot.SAVESTAT.zst").absolutePath
}.getOrNull()
/**
* Occupancy for the slot picker, which treats a non-empty string as "this slot has
* something in it" and shows the last path segment as the tile's subtitle.
@@ -626,7 +678,15 @@ object Rpcs3Bridge {
@JvmStatic
fun thumbnailForSlot(slot: Int): ByteArray? = runCatching {
val title = RPCSX.instance.getTitleId().takeIf { it.isNotEmpty() } ?: return null
val root = com.armsx2.runtime.MainActivityRuntime.systemDirPosix() ?: return null
// systemDirPosix() is null on the DEFAULT install, where no folder was ever picked,
// so this returned no thumbnail at all for most setups -- the tiles read as empty
// while the files sat on disk beside the states they belong to. Same fallback
// inputProfilesDir() uses, and getExternalFilesDir is where the native core roots
// fs::get_config_dir(), which is where it wrote these.
val root = com.armsx2.runtime.MainActivityRuntime.systemDirPosix()
?: com.armsx2.runtime.MainActivityRuntime.instance
?.applicationContext?.getExternalFilesDir(null)?.absolutePath
?: return null
val file = java.io.File(root, "config/savestates/$title/armsx3_slots/slot$slot.thumb")
if (!file.isFile) return null
@@ -253,6 +253,10 @@ object Rpcs3Settings {
fun setSuspendModeSavestates(enabled: Boolean) =
setBool("$SAVESTATE@@Suspend Emulation Savestate Mode", enabled)
/** SPU codegen that keeps the thread state capturable, which savestates require. */
fun setCompatibleSavestateMode(enabled: Boolean) =
setBool("$SAVESTATE@@Compatible Savestate Mode", enabled)
fun setMaxSavestateFiles(count: Int) =
setInt("$SAVESTATE@@Maximum SaveState Files", count.coerceIn(0, 64))
+66 -1
View File
@@ -1767,14 +1767,79 @@ private:
}
} static g_compilationQueue;
// Runs the callbacks Emu posts to the main thread.
//
// CallFromMainThread with no wake_up is a POST, not a call: upstream hands it to the GUI
// thread and returns immediately. Running it inline instead executes it under whatever
// locks the caller happens to hold, and lv2_obj::sleep_unlocked posts one while holding
// lv2_obj::g_mutex -- the comment upstream put on that call site says to run it on the main
// thread for exactly that reason. The callback is FinalizeRunRequest, the wake for a
// restored savestate, so it took g_mutex against itself and every thread stopped there:
// loading a state, and saving one (a save stops and restarts into the state), parked with
// the SPUs spinning and the progress overlay frozen on the figure it last drew.
//
// Callers that pass wake_up are synchronising on completion (BlockingCallFromMainThread
// waits on it), so those keep running inline exactly as before.
static struct main_thread_dispatcher {
std::mutex mutex;
std::condition_variable cv;
std::deque<std::function<void()>> queue;
std::thread worker;
bool started = false;
void post(std::function<void()> cb) {
std::unique_lock lock(mutex);
if (!started) {
started = true;
worker = std::thread([this] { run(); });
worker.detach();
}
queue.push_back(std::move(cb));
cv.notify_one();
}
void run() {
pthread_setname_np(pthread_self(), "Main Callbacks");
for (;;) {
std::function<void()> cb;
{
std::unique_lock lock(mutex);
cv.wait(lock, [this] { return !queue.empty(); });
cb = std::move(queue.front());
queue.pop_front();
}
// One callback throwing must not take the dispatcher with it: everything posted
// after it would be dropped, including the savestate wake.
try {
cb();
} catch (const std::exception &e) {
rpcsx_android.error("Main-thread callback threw: %s", e.what());
} catch (...) {
rpcsx_android.error("Main-thread callback threw an unknown exception");
}
}
}
} g_mainThreadDispatcher;
static void setupCallbacks() {
Emu.SetCallbacks({
.call_from_main_thread =
[](std::function<void()> cb, atomic_t<u32> *wake_up) {
cb();
if (wake_up) {
// The caller is waiting on this one; running it here is what it
// already did and is what BlockingCallFromMainThread expects.
cb();
*wake_up = true;
wake_up->notify_one();
return;
}
g_mainThreadDispatcher.post(std::move(cb));
},
.on_run = [](auto...) {},
.on_pause = [](auto...) {},
+20 -2
View File
@@ -625,9 +625,18 @@ bool SCEDecrypter::LoadHeaders()
bool SCEDecrypter::LoadMetadata(const u8 erk[32], const u8 riv[16])
{
const u64 sce_size = sce_f.size();
const u64 headers_off = u64{sce_hdr.se_meta} + sizeof(sce_hdr) + sizeof(meta_info);
if (sce_hdr.se_hsize > sce_size || headers_off > sce_hdr.se_hsize)
{
self_log.error("Invalid SCE metadata layout (header size=0x%x, metadata offset=0x%x, file size=0x%x)", sce_hdr.se_hsize, sce_hdr.se_meta, sce_size);
return false;
}
aes_context aes;
std::vector<u8> metadata_info(sizeof(meta_info));
std::vector<u8> metadata_headers(sce_hdr.se_hsize - (sizeof(sce_hdr) + sce_hdr.se_meta + sizeof(meta_info)));
std::vector<u8> metadata_headers(sce_hdr.se_hsize - headers_off);
// Locate and read the encrypted metadata info.
sce_f.seek(sce_hdr.se_meta + sizeof(sce_hdr));
@@ -1102,9 +1111,18 @@ const NPD_HEADER* SELFDecrypter::GetNPDHeader() const
bool SELFDecrypter::LoadMetadata(const u8* klic_key)
{
const u64 self_size = self_f.size();
const u64 headers_off = u64{sce_hdr.se_meta} + sizeof(sce_hdr) + sizeof(meta_info);
if (sce_hdr.se_hsize > self_size || headers_off > sce_hdr.se_hsize)
{
self_log.error("Invalid SELF metadata layout (header size=0x%x, metadata offset=0x%x, file size=0x%x)", sce_hdr.se_hsize, sce_hdr.se_meta, self_size);
return false;
}
aes_context aes;
std::vector<u8> metadata_info(sizeof(meta_info));
std::vector<u8> metadata_headers(sce_hdr.se_hsize - (sizeof(sce_hdr) + sce_hdr.se_meta + sizeof(meta_info)));
std::vector<u8> metadata_headers(sce_hdr.se_hsize - headers_off);
// Locate and read the encrypted metadata info.
self_f.seek(sce_hdr.se_meta + sizeof(sce_hdr));
+87 -16
View File
@@ -113,8 +113,38 @@ static spu_program analyse_spu_llvm_program(spu_recompiler_base& compiler, const
return compiler.analyse(ls.data(), program.entry_point);
}
static shared_mutex s_spu_failed_blocks_mutex;
static std::unordered_set<u32> s_spu_failed_blocks;
static bool spu_interpreter_fallback_available()
{
const auto interp = spu_runtime::g_interpreter;
return interp && interp != spu_runtime::g_gateway;
}
static bool spu_block_compile_failed(u32 entry_point)
{
reader_lock lock(s_spu_failed_blocks_mutex);
return s_spu_failed_blocks.find(entry_point) != s_spu_failed_blocks.end();
}
static void spu_mark_block_compile_failed(u32 entry_point)
{
std::lock_guard lock(s_spu_failed_blocks_mutex);
if (s_spu_failed_blocks.emplace(entry_point).second)
{
spu_log.error("SPU block 0x%05x cannot be compiled on this backend, its thread switches to the interpreter", entry_point);
}
}
static spu_function_t compile_spu_llvm_with_retry(std::unique_ptr<spu_recompiler_base>& compiler, const spu_program& program)
{
if (spu_block_compile_failed(program.entry_point))
{
return nullptr;
}
spu_llvm_compile_context context;
{
@@ -126,24 +156,32 @@ static spu_function_t compile_spu_llvm_with_retry(std::unique_ptr<spu_recompiler
}
}
if (context.llvm_error.find(s_spu_llvm_reg_scavenge_error) == std::string::npos)
if (context.llvm_error.empty())
{
if (!context.llvm_error.empty())
{
spu_log.error("LLVM failed to compile SPU block 0x%x: %s", program.entry_point, context.llvm_error);
}
return nullptr;
}
spu_log.warning("LLVM failed to compile SPU block 0x%x with TBL2/TBX2: %s. Retrying without TBL2/TBX2.", program.entry_point, context.llvm_error);
const bool scavenge_failure = context.llvm_error.find(s_spu_llvm_reg_scavenge_error) != std::string::npos;
if (scavenge_failure)
{
spu_log.warning("LLVM failed to compile SPU block 0x%x with TBL2/TBX2: %s. Retrying without TBL2/TBX2.", program.entry_point, context.llvm_error);
}
else
{
spu_log.error("LLVM failed to compile SPU block 0x%x: %s. Discarding the poisoned JIT instance.", program.entry_point, context.llvm_error);
}
// LLVM fatal recovery does not unwind MCJIT state. Abandon the failed
// compiler and retry from a fresh analysis/JIT instance.
static_cast<void>(compiler.release());
compiler = spu_recompiler_base::make_llvm_recompiler();
compiler->init();
if (!scavenge_failure)
{
spu_mark_block_compile_failed(program.entry_point);
return nullptr;
}
const auto retry_program = analyse_spu_llvm_program(*compiler, program);
if (retry_program != program)
@@ -153,20 +191,32 @@ static spu_function_t compile_spu_llvm_with_retry(std::unique_ptr<spu_recompiler
}
spu_llvm_compile_context retry_context;
spu_llvm_compile_scope scope(retry_context, false);
spu_function_t result = nullptr;
const auto result = compiler->compile(spu_program{retry_program});
{
spu_llvm_compile_scope scope(retry_context, false);
result = compiler->compile(spu_program{retry_program});
}
if (result)
{
spu_log.notice("SPU LLVM block 0x%x compiled successfully without TBL2/TBX2.", program.entry_point);
}
else if (!retry_context.llvm_error.empty())
{
spu_log.error("LLVM failed to compile SPU block 0x%x without TBL2/TBX2: %s", program.entry_point, retry_context.llvm_error);
return result;
}
return result;
if (!retry_context.llvm_error.empty())
{
spu_log.error("LLVM failed to compile SPU block 0x%x without TBL2/TBX2: %s. Discarding the poisoned JIT instance.", program.entry_point, retry_context.llvm_error);
static_cast<void>(compiler.release());
compiler = spu_recompiler_base::make_llvm_recompiler();
compiler->init();
spu_mark_block_compile_failed(program.entry_point);
}
return nullptr;
}
#endif
@@ -2213,6 +2263,15 @@ void spu_recompiler_base::dispatch(spu_thread& spu, void*, u8* rip)
return;
}
#ifdef ARCH_ARM64
if (spu_interpreter_fallback_available() && spu_block_compile_failed(spu.pc))
{
spu.interp_fallback = true;
spu_runtime::g_escape(&spu);
return;
}
#endif
#if defined(__APPLE__)
pthread_jit_write_protect_np(false);
#endif
@@ -2225,6 +2284,18 @@ void spu_recompiler_base::dispatch(spu_thread& spu, void*, u8* rip)
if (!func)
{
#ifdef ARCH_ARM64
if (spu_interpreter_fallback_available())
{
#if defined(__APPLE__)
pthread_jit_write_protect_np(true);
#endif
spu.interp_fallback = true;
spu_runtime::g_escape(&spu);
return;
}
#endif
spu_log.fatal("[0x%05x] Compilation failed.", spu.pc);
return;
}
+12
View File
@@ -1633,6 +1633,12 @@ public:
virtual spu_function_t compile(spu_program&& _func) override
{
if (m_jit.is_poisoned())
{
spu_log.error("Refusing to compile SPU block 0x%05x on a poisoned LLVM engine", _func.entry_point);
return nullptr;
}
if (_func.data.empty() && m_interp_magn)
{
return compile_interpreter();
@@ -4101,6 +4107,12 @@ public:
{
using namespace llvm;
if (m_jit.is_poisoned())
{
spu_log.error("Refusing to compile the SPU interpreter on a poisoned LLVM engine");
return nullptr;
}
m_engine->clearAllGlobalMappings();
// Create LLVM module

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