Android refresh: update PNACH, setup, and library fixes

This commit is contained in:
jpolo1224
2026-06-18 00:56:15 -04:00
parent 1efed5cb12
commit 8f4ffe3684
12 changed files with 739 additions and 81 deletions
+83 -19
View File
@@ -17,6 +17,7 @@
#include "common/ZipHelpers.h"
#include "pcsx2/GS.h"
#include "pcsx2/VMManager.h"
#include "pcsx2/Patch.h"
#include "pcsx2/R5900.h"
#include "PerformanceMetrics.h"
#include "GameList.h"
@@ -338,6 +339,13 @@ Java_kr_co_iefriends_pcsx2_NativeApp_getGameSerial(JNIEnv *env, jclass clazz) {
return env->NewStringUTF(ret.c_str());
}
extern "C"
JNIEXPORT jstring JNICALL
Java_kr_co_iefriends_pcsx2_NativeApp_getGameCRC(JNIEnv *env, jclass clazz) {
std::string ret = StringUtil::StdStringFromFormat("%08X", VMManager::GetCurrentCRC());
return env->NewStringUTF(ret.c_str());
}
// Build version string sourced from BuildVersion::GitRev. Format:
// "GitTagHi.GitTagMid.GitTagLo.ARMSX2Build-SNAPSHOT"
// Used by the setup wizard + in-game overlay to show the build label
@@ -852,6 +860,25 @@ Java_kr_co_iefriends_pcsx2_NativeApp_commitSettings(JNIEnv *env, jclass clazz) {
+EmuConfig.Speedhacks.vuFlagHack);
}
extern "C"
JNIEXPORT jint JNICALL
Java_kr_co_iefriends_pcsx2_NativeApp_reloadPatches(JNIEnv *env, jclass clazz) {
if (!VMManager::HasValidVM())
return static_cast<jint>(Patch::GetActiveCheatsCount());
ScopedVMPause vm_pause;
if (!vm_pause.parked())
{
Console.WriteLn("@@ANDROID_PNACH@@ reload skipped: cpu_not_parked");
return -1;
}
VMManager::ReloadPatches(true, true, true, true);
const u32 active_cheats = Patch::GetActiveCheatsCount();
Console.WriteLnFmt("@@ANDROID_PNACH@@ reload active_cheats={}", active_cheats);
return static_cast<jint>(active_cheats);
}
extern "C"
JNIEXPORT void JNICALL
Java_kr_co_iefriends_pcsx2_NativeApp_renderUpscalemultiplier(JNIEnv *env, jclass clazz,
@@ -2043,17 +2070,15 @@ Java_kr_co_iefriends_pcsx2_NativeApp_runEeSeqTests(JNIEnv*, jclass) { RunEeSeqTe
// list scanner to attach real game IDs to entries (instead of guessing
// from filenames).
//
// Handles three on-disk sector layouts so .iso (DVD-style 2048-byte data
// sectors) and .bin (raw CD-format 2352-byte sectors, typical for older
// CD-ROM-format PS2 games) both work:
// Handles multiple on-disk sector layouts so .iso (DVD-style 2048-byte data
// sectors), .bin/raw CD images, and CHDs all work:
//
// 2048 / 0 plain ISO — every byte is data
// 2352 / 16 Mode 1 raw — 12 byte sync, 4 byte header, 2048 data, 288 ECC
// 2352 / 24 Mode 2 Form 1 raw — 16 sync+header, 8 subheader, 2048 data, 280 ECC
//
// We try them in order and the first one that finds a valid PVD wins.
// CHD / CSO / GZ remain unsupported (they need libchdr / libuu1 / libz);
// those formats fall back to filename parsing on the Kotlin side.
// We try them in order and the first one that finds a valid PVD wins. CSO/ZSO
// and GZ still fall back to filename parsing on the Kotlin side.
//
// fd ownership: consumed (closed via fclose on the wrapping FILE*),
// matching the IsBIOSFromFd contract.
@@ -2067,10 +2092,11 @@ namespace {
using DiscReader = std::function<bool(std::uint32_t lba, std::uint32_t skip, void* buf, std::size_t size)>;
// FILE*-backed reader for plain ISO (2048/0) and raw .bin (2352/16, 2352/24).
static DiscReader MakeFileReader(std::FILE* fp, std::uint32_t sectorSize, std::uint32_t dataOffset)
static DiscReader MakeFileReader(std::FILE* fp, std::uint32_t sectorSize,
std::uint32_t dataOffset, std::uint64_t byteBase = 0)
{
return [fp, sectorSize, dataOffset](std::uint32_t lba, std::uint32_t skip, void* buf, std::size_t size) -> bool {
const std::uint64_t off = static_cast<std::uint64_t>(lba) * sectorSize + dataOffset + skip;
return [fp, sectorSize, dataOffset, byteBase](std::uint32_t lba, std::uint32_t skip, void* buf, std::size_t size) -> bool {
const std::uint64_t off = byteBase + static_cast<std::uint64_t>(lba) * sectorSize + dataOffset + skip;
if (std::fseek(fp, static_cast<long>(off), SEEK_SET) != 0) return false;
return std::fread(buf, 1, size, fp) == size;
};
@@ -2081,11 +2107,11 @@ static DiscReader MakeFileReader(std::FILE* fp, std::uint32_t sectorSize, std::u
// rebuilt cheaply across layout retries).
static DiscReader MakeChdReader(chd_file* chd, std::uint32_t hunkBytes,
std::vector<std::uint8_t>& hunkBuf, std::int64_t& cachedHunk,
std::uint32_t sectorSize, std::uint32_t dataOffset)
std::uint32_t sectorSize, std::uint32_t dataOffset, std::uint64_t byteBase = 0)
{
return [chd, hunkBytes, &hunkBuf, &cachedHunk, sectorSize, dataOffset](
return [chd, hunkBytes, &hunkBuf, &cachedHunk, sectorSize, dataOffset, byteBase](
std::uint32_t lba, std::uint32_t skip, void* buf, std::size_t size) -> bool {
std::uint64_t byte_off = static_cast<std::uint64_t>(lba) * sectorSize + dataOffset + skip;
std::uint64_t byte_off = byteBase + static_cast<std::uint64_t>(lba) * sectorSize + dataOffset + skip;
auto* dst = static_cast<std::uint8_t*>(buf);
std::size_t left = size;
while (left > 0)
@@ -2227,6 +2253,31 @@ static std::string ProbeSerialWithReader(const DiscReader& read)
return std::string(platform) + ":" + serial;
}
template <typename ReaderFactory>
static std::string ProbeSerialWithLeadIns(std::uint32_t sectorSize, const ReaderFactory& makeReader)
{
constexpr std::uint64_t NERO_LEAD_IN_BYTES = 150ull * 2048ull;
const std::uint64_t leadIns[] = {
0,
NERO_LEAD_IN_BYTES,
150ull * static_cast<std::uint64_t>(sectorSize),
};
std::uint64_t lastLeadIn = static_cast<std::uint64_t>(-1);
for (std::uint64_t leadIn : leadIns)
{
if (leadIn == lastLeadIn)
continue;
lastLeadIn = leadIn;
std::string serial = ProbeSerialWithReader(makeReader(leadIn));
if (!serial.empty())
return serial;
}
return {};
}
// Minimal core_file wrapper around an existing FILE*. libchdr only needs
// fsize/fread/fseek/fclose; we hand-roll them to avoid bringing in the
// emulator's heavyweight ChdCoreFileWrapper (which deals with parents and
@@ -2335,9 +2386,11 @@ Java_kr_co_iefriends_pcsx2_NativeApp_getGameSerialFromFd(JNIEnv* env, jclass, ji
// wins.
auto tryLayout = [&](std::uint32_t sectorSize, std::uint32_t dataOffset) {
if (!serial.empty()) return;
cached_hunk = -1; // forget the previous attempt's hunk
auto reader = MakeChdReader(chd, hunk_bytes, hunk_buf, cached_hunk, sectorSize, dataOffset);
serial = ProbeSerialWithReader(reader);
serial = ProbeSerialWithLeadIns(sectorSize, [&](std::uint64_t byteBase) {
cached_hunk = -1; // forget the previous attempt's hunk
return MakeChdReader(chd, hunk_bytes, hunk_buf, cached_hunk,
sectorSize, dataOffset, byteBase);
});
};
tryLayout(unit_bytes, 0);
@@ -2365,10 +2418,21 @@ Java_kr_co_iefriends_pcsx2_NativeApp_getGameSerialFromFd(JNIEnv* env, jclass, ji
// Plain ISO / raw .bin path. .iso files are virtually always
// 2048/0; .bin files are usually 2352/16 (Mode 1 raw); 2352/24
// (Mode 2 Form 1) is rare on PS2 but cheap to try as a last
// resort.
if (serial.empty()) serial = ProbeSerialWithReader(MakeFileReader(fp, 2048, 0));
if (serial.empty()) serial = ProbeSerialWithReader(MakeFileReader(fp, 2352, 16));
if (serial.empty()) serial = ProbeSerialWithReader(MakeFileReader(fp, 2352, 24));
// resort. Try PCSX2's 150-sector/Nero-style lead-in variants too,
// since some CD-format games otherwise hide their PVD from the
// lightweight scanner.
if (serial.empty()) serial = ProbeSerialWithLeadIns(2048, [&](std::uint64_t byteBase) {
return MakeFileReader(fp, 2048, 0, byteBase);
});
if (serial.empty()) serial = ProbeSerialWithLeadIns(2352, [&](std::uint64_t byteBase) {
return MakeFileReader(fp, 2352, 16, byteBase);
});
if (serial.empty()) serial = ProbeSerialWithLeadIns(2352, [&](std::uint64_t byteBase) {
return MakeFileReader(fp, 2352, 24, byteBase);
});
if (serial.empty()) serial = ProbeSerialWithLeadIns(2448, [&](std::uint64_t byteBase) {
return MakeFileReader(fp, 2448, 24, byteBase);
});
std::fclose(fp);
}
+25
View File
@@ -788,8 +788,33 @@ void Patch::UpdateActivePatches(bool reload_enabled_list, bool verbose, bool ver
u32 c_count = 0;
if (EmuConfig.EnableCheats)
{
#if defined(__ANDROID__)
// Android's current PNACH UI imports/executes whole files, but does
// not yet expose PCSX2's per-labelled-cheat picker. Treat labelled
// cheat groups as enabled so imports like "[60 FPS]" actually run.
std::vector<std::string> android_enabled_cheats = s_enabled_cheats;
u32 auto_enabled_groups = 0;
for (const PatchGroup& group : s_cheat_patches)
{
if (group.name.empty())
continue;
if (std::find(android_enabled_cheats.begin(), android_enabled_cheats.end(), group.name) == android_enabled_cheats.end())
{
android_enabled_cheats.emplace_back(group.name);
auto_enabled_groups++;
}
}
if (auto_enabled_groups > 0)
Console.WriteLnFmt("@@ANDROID_PNACH@@ auto_enabled_cheat_groups={}", auto_enabled_groups);
c_count = EnablePatches(&s_cheat_patches, android_enabled_cheats, nullptr);
#else
c_count = EnablePatches(
&s_cheat_patches, s_enabled_cheats, apply_new_patches ? &s_just_enabled_cheats : nullptr);
#endif
}
s_cheats_counts = c_count;
if (c_count > 0)
message.append_format("{}{}", message.empty() ? "" : "\n",
@@ -396,6 +396,7 @@ enum : u32
// Defined below (block-compile helpers) — used by recTranslateOp's COP2 inline path.
static void recEmitInterpInline(u32 op);
static void recEmitDirectInterpCall(u32 op, const void* fn);
static void recEmitVU0FinishForCOP2();
static bool recTranslateOp(u32 op);
@@ -2268,7 +2269,11 @@ static bool recTranslateOp(u32 op)
// but not finish) still need the finish emitted here.
if (rs < 0x10 && !(cpuRegs.code & 1)) // non-interlocked transfer
recEmitVU0FinishForCOP2();
recEmitInterpInline(op);
// Direct dispatch: COP2() just does Int_COP2PrintTable[_Rs_](), so call that
// handler directly (resolved now) instead of routing through the thunk +
// GetInstruction + COP2() at runtime. rs>=0x10 -> COP2_SPECIAL (does its own
// finish); rs in {1,2,5,6} -> QMFC2/CFC2/QMTC2/CTC2. Identical semantics.
recEmitDirectInterpCall(op, reinterpret_cast<const void*>(::Int_COP2PrintTable[rs]));
return true;
// COP2 quadword load/store (VF[rt] ↔ memory). Straight-line, no PC write —
@@ -2276,8 +2281,10 @@ static bool recTranslateOp(u32 op)
// interlock bit (bit 0 is immediate data), and the handler only vu0Sync()s, so
// always force the finish: SQC2 reads a VF; LQC2 for symmetry, matching x86
// recLQC2/recSQC2 which sync/finish via the same analysis.
case OP_LQC2: recEmitVU0FinishForCOP2(); recEmitInterpInline(op); return true;
case OP_SQC2: recEmitVU0FinishForCOP2(); recEmitInterpInline(op); return true;
// LQC2/SQC2 are top-level opcodes whose runtime dispatch resolves straight to the
// LQC2/SQC2 handler, so call it directly (skip the thunk + GetInstruction).
case OP_LQC2: recEmitVU0FinishForCOP2(); recEmitDirectInterpCall(op, reinterpret_cast<const void*>(R5900::GetInstruction(op).interpret)); return true;
case OP_SQC2: recEmitVU0FinishForCOP2(); recEmitDirectInterpCall(op, reinterpret_cast<const void*>(R5900::GetInstruction(op).interpret)); return true;
// CACHE — fully emulated (Cache.cpp doCacheHitOp) but straight-line and writes no
// GPR (only CP0.TagLo for the tag variants). Inline-interpret to keep it in-block;
@@ -2837,6 +2844,20 @@ static void recEmitInterpInline(u32 op)
armEmitCall(reinterpret_cast<const void*>(recInterpInlineThunk));
}
// Like recEmitInterpInline, but calls a compile-time-resolved interpreter handler
// directly instead of routing through recInterpInlineThunk -> GetInstruction(op) ->
// COP2() -> Int_COP2PrintTable[rs] at runtime. Semantically identical (same handler,
// same cpuRegs.code), it just removes that per-op dispatch chain — used for the hot
// COP2 / LQC2 / SQC2 ops. `fn` must be the exact handler the runtime dispatch would
// reach for `op`.
static void recEmitDirectInterpCall(u32 op, const void* fn)
{
armAsm->Mov(RWARG1, op); // w0 = op
armMoveAddressToReg(RSCRATCHADDR, &cpuRegs.code); // x17 = &cpuRegs.code
armAsm->Str(RWARG1, a64::MemOperand(RSCRATCHADDR)); // cpuRegs.code = op
armEmitCall(fn);
}
// VU0 micro/macro finish for COP2 ops — emit x86's mVUFinishVU0
// (microVU_Macro.inl): with the VU0 recompiler enabled, a microVU0 program
// started by VCALLMS runs deferred, so reading VU0 state mid-flight gives a
@@ -23,11 +23,25 @@
#include <cstddef>
// Inline VTLB vmap fast path (finishing the mac backend's reserved-but-unbuilt
// "Phase 2": REVTLBPTR=x21 is already pinned to vtlbdata.vmap in EnterRecompiledCode).
// When on, scalar loads decode the vmap entry inline and do a direct host access for
// RAM pages, falling back to the existing vtlb_memRead C call only for handler/MMIO
// pages — no signal handler, no backpatch (unlike the SIGSEGV-fastmem in arm64/).
// Default OFF until validated on-device. Marker: @@MAC_FASTMEM@@.
#ifndef ARMSX2_MAC_FASTMEM
#define ARMSX2_MAC_FASTMEM 0
#endif
namespace pcsx2_macrec {
namespace a64 = vixl::aarch64;
// VTLB page shift (vtlb.h VTLBVirtual::VTLB_PAGE_BITS == 12). vmap is an array of
// 8-byte VTLBVirtual entries; for guest vaddr v, host = vmap[v>>12].value + v, and
// the access is a handler/MMIO page iff that sum is negative (sign bit set).
static constexpr int MAC_VTLB_PAGE_BITS = 12;
// The effective-address codegen assumes guest GPRs are laid out as 16-byte
// GPR_reg slots starting at the base of cpuRegs (so GPR[n].UL[0] is at n*16).
static_assert(sizeof(GPR_reg) == 16, "GPR_reg must be 128 bits for EE_GPR_OFFSET");
@@ -36,10 +50,34 @@ static_assert(offsetof(cpuRegisters, GPR) == 0, "GPR must be the first member of
// ------------------------------------------------------------------------
void armEmitVtlbRead(u32 bits, bool sign, const a64::Register& dst, const a64::Register& addr)
{
// 32-bit guest address goes in the first argument register.
// 32-bit guest address goes in the first argument register (zero-extended into
// RXARG1/x0, so the 64-bit views below see the full guest vaddr).
if (!addr.W().Is(RWARG1))
armAsm->Mov(RWARG1, addr.W());
#if ARMSX2_MAC_FASTMEM
// Inline vmap fast path: host = vmap[vaddr>>12].value + vaddr; handler iff host<0.
// On a RAM hit, do the direct host load (with the same extension the C path uses)
// and skip the call; otherwise fall through to the vtlb_memRead helper.
// x16/x17 are emit scratch; x0 (RXARG1) keeps the vaddr for the slow path because
// the direct load (which would overwrite dst==x0) only runs after the handler test.
a64::Label fastmem_slow, fastmem_done;
armAsm->Lsr(RXVIXLSCRATCH, RXARG1, MAC_VTLB_PAGE_BITS); // x16 = vaddr >> 12
armAsm->Ldr(RSCRATCHADDR, a64::MemOperand(REVTLBPTR, RXVIXLSCRATCH, a64::LSL, 3)); // x17 = vmap[page].value
armAsm->Add(RSCRATCHADDR, RSCRATCHADDR, RXARG1); // x17 = value + vaddr (host, or <0)
armAsm->Tbnz(RSCRATCHADDR, 63, &fastmem_slow); // negative => handler/MMIO
switch (bits)
{
case 8: sign ? armAsm->Ldrsb(dst.X(), a64::MemOperand(RSCRATCHADDR)) : armAsm->Ldrb(dst.W(), a64::MemOperand(RSCRATCHADDR)); break;
case 16: sign ? armAsm->Ldrsh(dst.X(), a64::MemOperand(RSCRATCHADDR)) : armAsm->Ldrh(dst.W(), a64::MemOperand(RSCRATCHADDR)); break;
case 32: sign ? armAsm->Ldrsw(dst.X(), a64::MemOperand(RSCRATCHADDR)) : armAsm->Ldr(dst.W(), a64::MemOperand(RSCRATCHADDR)); break;
case 64: armAsm->Ldr(dst.X(), a64::MemOperand(RSCRATCHADDR)); break;
jNO_DEFAULT
}
armAsm->B(&fastmem_done);
armAsm->Bind(&fastmem_slow);
#endif
const void* fn;
switch (bits)
{
@@ -62,6 +100,10 @@ void armEmitVtlbRead(u32 bits, bool sign, const a64::Register& dst, const a64::R
case 64: if (!dst.X().Is(RXRET)) armAsm->Mov(dst.X(), RXRET); break;
jNO_DEFAULT
}
#if ARMSX2_MAC_FASTMEM
armAsm->Bind(&fastmem_done);
#endif
}
// ------------------------------------------------------------------------
@@ -77,24 +119,47 @@ void armEmitVtlbWrite(u32 bits, const a64::Register& addr, const a64::Register&
jNO_DEFAULT
}
// vtlb_memWrite<T>(u32 addr, T data): addr -> arg1, data -> arg2. Stage the
// value through the scratch reg first so addr/data may live in any registers
// (including each other's arg reg) without an aliasing hazard.
// vtlb_memWrite<T>(u32 addr, T data): addr -> arg1, data -> arg2. Stage the value
// through VIXLSCRATCH (x16) so addr/data can't alias, and put the address in RWARG1
// (x0 zero-extended) — shared by the inline fast store and the slow C call.
if (bits == 64)
{
armAsm->Mov(RXVIXLSCRATCH, data.X());
if (!addr.W().Is(RWARG1))
armAsm->Mov(RWARG1, addr.W());
armAsm->Mov(RXARG2, RXVIXLSCRATCH);
}
else
{
armAsm->Mov(RWVIXLSCRATCH, data.W());
if (!addr.W().Is(RWARG1))
armAsm->Mov(RWARG1, addr.W());
armAsm->Mov(RWARG2, RWVIXLSCRATCH);
if (!addr.W().Is(RWARG1))
armAsm->Mov(RWARG1, addr.W());
#if ARMSX2_MAC_FASTMEM
// Inline vmap fast path: host = vmap[vaddr>>12].value + vaddr; handler iff host<0.
// x17 holds page→vmap→host in sequence (x16 keeps the staged data). A direct store
// that faults on an SMC-protected code page is handled by the shared page-fault
// handler (mmap_ClearCpuBlock + retry), exactly like a faulting vtlb_memWrite.
a64::Label fastmem_slow, fastmem_done;
armAsm->Lsr(RSCRATCHADDR, RXARG1, MAC_VTLB_PAGE_BITS); // x17 = vaddr >> 12
armAsm->Ldr(RSCRATCHADDR, a64::MemOperand(REVTLBPTR, RSCRATCHADDR, a64::LSL, 3)); // x17 = vmap[page].value
armAsm->Add(RSCRATCHADDR, RSCRATCHADDR, RXARG1); // x17 = value + vaddr (host, or <0)
armAsm->Tbnz(RSCRATCHADDR, 63, &fastmem_slow); // negative => handler/MMIO
switch (bits)
{
case 8: armAsm->Strb(RWVIXLSCRATCH, a64::MemOperand(RSCRATCHADDR)); break;
case 16: armAsm->Strh(RWVIXLSCRATCH, a64::MemOperand(RSCRATCHADDR)); break;
case 32: armAsm->Str(RWVIXLSCRATCH, a64::MemOperand(RSCRATCHADDR)); break;
case 64: armAsm->Str(RXVIXLSCRATCH, a64::MemOperand(RSCRATCHADDR)); break;
jNO_DEFAULT
}
armAsm->B(&fastmem_done);
armAsm->Bind(&fastmem_slow);
#endif
if (bits == 64)
armAsm->Mov(RXARG2, RXVIXLSCRATCH);
else
armAsm->Mov(RWARG2, RWVIXLSCRATCH);
armEmitCall(fn);
#if ARMSX2_MAC_FASTMEM
armAsm->Bind(&fastmem_done);
#endif
}
// ------------------------------------------------------------------------
@@ -103,24 +168,60 @@ void armEmitVtlbReadQuad(const a64::VRegister& dst, const a64::Register& addr)
if (!addr.W().Is(RWARG1))
armAsm->Mov(RWARG1, addr.W());
#if ARMSX2_MAC_FASTMEM
// Inline vmap fast path (addr is already 16-byte aligned by the caller). RAM hit:
// a single 128-bit host load; handler/MMIO falls through to vtlb_memRead128.
a64::Label fastmem_slow, fastmem_done;
armAsm->Lsr(RSCRATCHADDR, RXARG1, MAC_VTLB_PAGE_BITS); // x17 = vaddr >> 12
armAsm->Ldr(RSCRATCHADDR, a64::MemOperand(REVTLBPTR, RSCRATCHADDR, a64::LSL, 3)); // x17 = vmap[page].value
armAsm->Add(RSCRATCHADDR, RSCRATCHADDR, RXARG1); // x17 = host (or <0)
armAsm->Tbnz(RSCRATCHADDR, 63, &fastmem_slow);
armAsm->Ldr(dst.Q(), a64::MemOperand(RSCRATCHADDR)); // direct 128-bit load
armAsm->B(&fastmem_done);
armAsm->Bind(&fastmem_slow);
#endif
// vtlb_memRead128 returns r128 (uint32x4_t) in q0.
armEmitCall(reinterpret_cast<const void*>(&vtlb_memRead128));
if (!dst.Q().Is(RQRET))
armAsm->Mov(dst.Q(), RQRET);
#if ARMSX2_MAC_FASTMEM
armAsm->Bind(&fastmem_done);
#endif
}
// ------------------------------------------------------------------------
void armEmitVtlbWriteQuad(const a64::Register& addr, const a64::VRegister& data)
{
// vtlb_memWrite128(u32 mem, r128 value): mem -> w0, value (uint32x4_t) -> q0.
// Set the vector arg first; it can't alias the GPR address argument.
if (!data.Q().Is(RQRET))
armAsm->Mov(RQRET, data.Q());
// Address into RWARG1 (x0 zero-extended) for both the inline decode and the C call.
// data is a vector reg, so it can't alias the GPR address.
if (!addr.W().Is(RWARG1))
armAsm->Mov(RWARG1, addr.W());
#if ARMSX2_MAC_FASTMEM
// Inline vmap fast path (addr is already 16-byte aligned by the caller). RAM hit:
// a single 128-bit host store (SMC-protected code pages fault → shared handler →
// mmap_ClearCpuBlock + retry, same as vtlb_memWrite128). Else fall through to C.
a64::Label fastmem_slow, fastmem_done;
armAsm->Lsr(RSCRATCHADDR, RXARG1, MAC_VTLB_PAGE_BITS); // x17 = vaddr >> 12
armAsm->Ldr(RSCRATCHADDR, a64::MemOperand(REVTLBPTR, RSCRATCHADDR, a64::LSL, 3)); // x17 = vmap[page].value
armAsm->Add(RSCRATCHADDR, RSCRATCHADDR, RXARG1); // x17 = host (or <0)
armAsm->Tbnz(RSCRATCHADDR, 63, &fastmem_slow);
armAsm->Str(data.Q(), a64::MemOperand(RSCRATCHADDR)); // direct 128-bit store
armAsm->B(&fastmem_done);
armAsm->Bind(&fastmem_slow);
#endif
// vtlb_memWrite128(u32 mem, r128 value): mem -> w0, value (uint32x4_t) -> q0.
if (!data.Q().Is(RQRET))
armAsm->Mov(RQRET, data.Q());
armEmitCall(reinterpret_cast<const void*>(&vtlb_memWrite128));
#if ARMSX2_MAC_FASTMEM
armAsm->Bind(&fastmem_done);
#endif
}
// ========================================================================
@@ -55,6 +55,30 @@ object FilenameParser {
private val serialRegex = Regex("""([A-Za-z]{4})[\s_-]?(\d{3})\.?(\d{2})""")
private val tagsRegex = Regex("""[\[(].*?[\])]""")
private val whitespaceRegex = Regex("""\s+""")
private val nonWordRegex = Regex("""[^a-z0-9]+""")
private data class FilenameAlias(val title: String, val serial: String)
private fun aliasFor(filenameWithoutExt: String): FilenameAlias? {
val normalized = filenameWithoutExt
.lowercase()
.replace(nonWordRegex, " ")
.trim()
// Some PAL DMC2 CHDs are named by disc character rather than serial,
// and their raw-CD layout can be awkward to probe. Keep this narrow so
// broader filename-only games still rely on explicit serial tokens.
if (!normalized.contains("devil may cry 2"))
return null
return when {
normalized.contains("dante") ->
FilenameAlias("Devil May Cry 2 [Dante Disc]", "SLES-82011")
normalized.contains("lucia") ->
FilenameAlias("Devil May Cry 2 [Lucia Disc]", "SLES-82012")
else -> null
}
}
fun parse(filename: String): Pair<String, String?> {
val withoutExt = filename.substringBeforeLast('.')
@@ -62,6 +86,9 @@ object FilenameParser {
val serial = match?.let {
"${it.groupValues[1].uppercase()}-${it.groupValues[2]}${it.groupValues[3]}"
}
if (serial == null) {
aliasFor(withoutExt)?.let { return it.title to it.serial }
}
// Strip the matched serial token + any [region] / (lang) tags so the
// displayed title is the game name rather than the full filename.
var title = withoutExt
+20 -4
View File
@@ -220,10 +220,10 @@ class Main: ComponentActivity() {
// picks at runtime per device. The setup wizard no longer asks; the
// in-game overlay's Renderer tab is where users override (OpenGL /
// Software cycle, plus Mali/Adreno-specific paths once those land).
// `upscale` (1..5) still persists; it's exposed in the in-game
// `upscale` (1.0..5.0) still persists; it's exposed in the in-game
// overlay's Renderer tab.
val renderer = mutableStateOf("auto")
val upscale = mutableStateOf(1)
val upscale = mutableStateOf(1.0f)
/** Active custom Vulkan driver id (matches `CustomDriver.InstalledDriver.id`).
* Null = system Vulkan loader. Set from the setup wizard's driver
@@ -468,7 +468,7 @@ class Main: ComponentActivity() {
* right override tier; null falls back to global. Resolution
* order: per-game JSON overlay global hardcoded defaults. */
private fun applyRendererPrefs() {
NativeApp.renderUpscalemultiplier(upscale.value.toFloat())
NativeApp.renderUpscalemultiplier(upscale.value)
// Pin custom Vulkan driver (if any) BEFORE the renderer write —
// the renderer JNI may trigger MTGS::ApplySettings which can
// re-open the GS device and run Vulkan::LoadVulkanLibrary. The
@@ -502,6 +502,22 @@ class Main: ComponentActivity() {
NativeApp.speedhackLimitermode(if (limit) 0 else 3)
}
private fun readUpscalePref(): Float {
val all = prefs.all
fun coerce(raw: Any?): Float? = when (raw) {
is Float -> raw
is Double -> raw.toFloat()
is Int -> raw.toFloat()
is Long -> raw.toFloat()
is String -> raw.toFloatOrNull()
else -> null
}?.coerceIn(1.0f, 5.0f)
return coerce(all["upscaleFloat"])
?: coerce(all["upscale"])
?: 1.0f
}
/**
* Set the active game path/URI and restart the VM. Used by
* GamesList card taps the URI comes from the user's persisted
@@ -983,7 +999,7 @@ class Main: ComponentActivity() {
}
}
renderer.value = prefs.getString("renderer", "auto") ?: "auto"
upscale.value = prefs.getInt("upscale", 1)
upscale.value = readUpscalePref()
customDriverId.value = prefs.getString("customDriverId", null)?.takeIf { it.isNotEmpty() }
allFilesAccessGranted.value = !needsAllFilesAccess()
surface.value = SurfaceCallbacks(this)
@@ -70,6 +70,10 @@ import kr.co.iefriends.pcsx2.NativeApp
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@@ -164,11 +168,12 @@ object GamesList {
/** Sorted "|" join so ["A","B"] and ["B","A"] produce the same key.
* We dedupe within the user's selection in case they accidentally
* pick the same folder twice. The "|v2" suffix invalidates legacy
* pick the same folder twice. The "|v3" suffix invalidates legacy
* caches that were built before .img/.mdf/.nrg/.dump were probed for
* serials bump again any time the probe coverage changes. */
* serials and before DMC2 Dante/Lucia filename fallback landed bump
* again any time the probe coverage changes. */
private fun cacheKeyForDirs(dirs: List<String>): String =
dirs.toSet().sorted().joinToString("|") + "|v2"
dirs.toSet().sorted().joinToString("|") + "|v3"
@Composable
private fun LibraryScreen(context: Context, romsDirs: List<String>, romsKey: String) {
@@ -942,6 +947,15 @@ object GamesList {
) {
val coverUrl = game.coverUrl
if (coverUrl != null) {
val coverFile = remember(game.serial, game.platform) { coverFileFor(context, game) }
val localCoverReady = remember(coverFile?.absolutePath) {
mutableStateOf(coverFile?.let { it.exists() && it.length() > 0L } == true)
}
LaunchedEffect(coverUrl, coverFile?.absolutePath) {
if (!localCoverReady.value && coverFile != null) {
localCoverReady.value = mirrorCoverToFile(coverUrl, coverFile)
}
}
// SubcomposeAsyncImage so we can render a real fallback
// composable when the cover URL 404s — happens for any
// game the xlenore covers repo doesn't have (obscure
@@ -959,9 +973,10 @@ object GamesList {
GamePlatform.PS2 -> ContentScale.Crop
GamePlatform.PS1 -> ContentScale.Fit
}
val coverModel: Any = if (localCoverReady.value && coverFile != null) coverFile else coverUrl
SubcomposeAsyncImage(
model = ImageRequest.Builder(context)
.data(coverUrl)
.data(coverModel)
.crossfade(true)
.build(),
contentDescription = "${game.title} cover",
@@ -977,6 +992,55 @@ object GamesList {
}
}
private fun coverFileFor(context: Context, game: GameInfo): File? {
val serial = game.serial ?: return null
val coversDir = File(Main.assetCopyRoot(context), "covers")
return File(coversDir, "$serial.jpg")
}
private suspend fun mirrorCoverToFile(url: String, target: File): Boolean = withContext(Dispatchers.IO) {
if (target.exists() && target.length() > 0L)
return@withContext true
val parent = target.parentFile ?: return@withContext false
if (!parent.exists() && !parent.mkdirs())
return@withContext false
val tmp = File(parent, ".${target.name}.${System.nanoTime()}.tmp")
var conn: HttpURLConnection? = null
try {
conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 10_000
readTimeout = 15_000
instanceFollowRedirects = true
setRequestProperty("User-Agent", "ARMSX2 Android")
}
if (conn.responseCode !in 200..299)
return@withContext false
conn.inputStream.use { input ->
tmp.outputStream().use { output ->
input.copyTo(output)
}
}
if (tmp.length() <= 0L)
return@withContext false
if (target.exists() && !target.delete())
return@withContext false
if (!tmp.renameTo(target)) {
tmp.copyTo(target, overwrite = true)
tmp.delete()
}
target.exists() && target.length() > 0L
} catch (_: Exception) {
false
} finally {
tmp.delete()
conn?.disconnect()
}
}
private fun markRecentlyPlayed(game: GameInfo) {
val uri = game.uri.toString()
recentUris.remove(uri)
@@ -768,10 +768,10 @@ object SetupImpl {
contentAlignment = Alignment.Center,
) {
val artRatio = 1440f / 3120f
// Page-1 background: the PS-symbol wallpaper fills the whole
// screen, including the side gutters around the centered portrait
// welcome card in landscape. Only the first setup screen uses it;
// page 2 keeps its original dark dashboard.
if (maxWidth > maxHeight) {
LandscapePowerWelcome(onPower)
return@BoxWithConstraints
}
Image(
painter = painterResource(id = R.drawable.setup_aero_bg),
contentDescription = null,
@@ -805,6 +805,39 @@ object SetupImpl {
}
}
@Composable
private fun LandscapePowerWelcome(onPower: () -> Unit) {
BoxWithConstraints(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopStart,
) {
val frameRatio = 1920f / 1080f
val screenRatio = maxWidth.value / maxHeight.value
val renderedWidth = if (screenRatio > frameRatio) maxWidth else maxHeight * frameRatio
val renderedHeight = if (screenRatio > frameRatio) maxWidth / frameRatio else maxHeight
val renderedX = (maxWidth - renderedWidth) / 2f
val renderedY = (maxHeight - renderedHeight) / 2f
Image(
painter = painterResource(id = R.drawable.setup_welcome_landscape),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier
.offset(
x = renderedX + renderedWidth * (1240f / 1920f),
y = renderedY + renderedHeight * (625f / 1080f),
)
.size(
width = renderedWidth * (260f / 1920f),
height = renderedHeight * (274f / 1080f),
)
.clickable(onClick = onPower),
)
}
}
@Composable
private fun HeroLogo(modifier: Modifier = Modifier) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
@@ -5,6 +5,9 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -13,7 +16,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
@@ -25,30 +32,135 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.documentfile.provider.DocumentFile
import com.armsx2.EmuState
import com.armsx2.Main
import com.armsx2.config.Settings
import com.armsx2.ui.Colors
import com.armsx2.ui.InGameOverlay
import kr.co.iefriends.pcsx2.NativeApp
import java.io.File
private data class PnachGameId(val serial: String, val crc: String) {
val prefix: String get() = "${serial}_${crc}"
}
private val activeGameIdRegex = Regex("""([A-Za-z]{4}-\d{5})\s*\(([0-9A-Fa-f]{8})\)""")
private val serialRegex = Regex("""([A-Za-z]{4})[\s_-]?(\d{3})\.?(\d{2})""")
private val crcRegex = Regex("""(?<![0-9A-Fa-f])([0-9A-Fa-f]{8})(?![0-9A-Fa-f])""")
private val loadableSerialPnachRegex = Regex("""^[A-Z]{4}-\d{5}_[0-9A-F]{8}.*\.pnach$""", RegexOption.IGNORE_CASE)
private val loadableCrcPnachRegex = Regex("""^[0-9A-F]{8}.*\.pnach$""", RegexOption.IGNORE_CASE)
private fun normalizeSerial(value: String?): String? {
if (value.isNullOrBlank()) return null
val match = serialRegex.find(value) ?: return null
return "${match.groupValues[1].uppercase()}-${match.groupValues[2]}${match.groupValues[3]}"
}
private fun activePnachGameId(): PnachGameId? {
val pauseLabel = runCatching { NativeApp.getPauseGameSerial() }.getOrNull().orEmpty()
val currentCrc = runCatching { NativeApp.getGameCRC() }.getOrNull()
?.trim()
?.uppercase()
?.takeIf { crcRegex.matches(it) && it != "00000000" }
activeGameIdRegex.find(pauseLabel)?.let { match ->
val crc = currentCrc ?: match.groupValues[2].uppercase()
if (crc != "00000000")
return PnachGameId(normalizeSerial(match.groupValues[1]) ?: return null, crc)
}
val serial = normalizeSerial(Main.currentGame.value?.serial)
?: normalizeSerial(runCatching { NativeApp.getGameSerial() }.getOrNull())
val crc = currentCrc ?: crcRegex.find(pauseLabel)?.groupValues?.get(1)?.uppercase()
?.takeIf { it != "00000000" }
return if (serial != null && crc != null) PnachGameId(serial, crc) else null
}
private fun safePnachStem(rawName: String): String {
val base = rawName.substringBeforeLast('.')
.replace(Regex("""[^A-Za-z0-9._-]+"""), "_")
.trim('_', '.', '-')
.take(48)
return base.ifEmpty { "manual" }
}
private fun loadablePnachName(fileName: String): Boolean =
loadableSerialPnachRegex.matches(fileName) || loadableCrcPnachRegex.matches(fileName)
private fun importPnachFileName(sourceName: String, gameId: PnachGameId?): String {
val stem = safePnachStem(sourceName)
if (gameId != null) {
return if (stem.startsWith(gameId.prefix, ignoreCase = true))
"$stem.pnach"
else
"${gameId.prefix}_${stem}.pnach"
}
val serial = normalizeSerial(sourceName)
val crc = crcRegex.find(sourceName)?.groupValues?.get(1)?.uppercase()
return when {
serial != null && crc != null -> "${serial}_${crc}_${stem}.pnach"
crc != null -> "${crc}_${stem}.pnach"
sourceName.endsWith(".pnach", ignoreCase = true) -> sourceName
else -> "$stem.pnach"
}
}
private fun manualPnachFileName(title: String, gameId: PnachGameId?): String {
val stem = safePnachStem(title.ifBlank { "manual" })
return if (gameId != null) "${gameId.prefix}_${stem}.pnach" else "$stem.pnach"
}
private fun pnachTargetFile(dir: File, desiredName: String): File {
val normalizedName = if (desiredName.endsWith(".pnach", ignoreCase = true)) desiredName else "$desiredName.pnach"
return File(dir, normalizedName)
}
private fun executablePnachBody(body: String): String =
body.trim().lines()
.mapNotNull { rawLine ->
val line = rawLine.trimEnd()
val trimmed = line.trim()
// Android's importer/entry UI means "run this code". PCSX2 treats
// labelled groups like [60 FPS] as disabled until the label is
// added to Cheats/Enable, so flatten labels into comments and let
// patch= lines auto-activate as unlabelled legacy PNACH commands.
if (trimmed.length > 2 && trimmed.first() == '[' && trimmed.last() == ']')
"// $trimmed"
else
line
}
.joinToString("\n")
.trim()
private fun manualPnachContents(title: String, body: String, gameId: PnachGameId?): String {
val header = buildList {
add("// ARMSX2 manual PNACH")
if (title.isNotBlank()) add("// $title")
if (gameId != null) add("// ${gameId.serial} ${gameId.crc}")
}.joinToString("\n")
val normalizedBody = executablePnachBody(body)
return "$header\n$normalizedBody\n"
}
/**
* Patch / cheat toggles + a PNACH importer.
*
* Widescreen / no-interlacing come from the bundled patch database (just toggle
* them). User cheats are `.pnach` files dropped into <dataRoot>/cheats/ import
* them here, enable "Cheats (PNACH)", and restart the game. The .pnach must be
* named to match the disc (e.g. `SLUS-12345_A1B2C3D4.pnach`) for emucore to pick
* it up. All patch options inject at boot, so changes apply on game restart.
* them here, enable "Cheats (PNACH)", and restart the game. The importer names
* files from the active game when possible so emucore can find them at boot.
*/
@Composable
fun PatchesTab(state: MutableState<Settings>) {
val s = state.value
val context = LocalContext.current
val scroll = remember { ScrollState(0) }
val activeGameId = activePnachGameId()
val cheatsDir = remember { File(Main.assetCopyRoot(context), "cheats").apply { mkdirs() } }
fun listPnach(): List<String> =
@@ -59,8 +171,32 @@ fun PatchesTab(state: MutableState<Settings>) {
?: emptyList()
var pnachFiles: List<String> by remember { mutableStateOf(listPnach()) }
fun refresh() { pnachFiles = listPnach() }
var pnachStatus by remember { mutableStateOf("") }
var showManualDialog by remember { mutableStateOf(false) }
fun apply(updated: Settings) = InGameOverlay.saveSettings(updated)
fun activateCheatsAndReload(): Int {
if (!state.value.enableCheats)
apply(state.value.copy(enableCheats = true))
// The overlay's fast live-settings path intentionally skips patch
// toggles, so push this one directly before reloading PNACH files.
NativeApp.setSetting("EmuCore", "EnableCheats", "bool", "true")
NativeApp.commitSettings()
return NativeApp.reloadPatches()
}
fun pnachResultMessage(action: String, savedName: String, activeCheats: Int): String =
when {
Main.eState.value == EmuState.STOPPED ->
"$action $savedName. Cheats are enabled; start the game to load it."
activeCheats > 0 ->
"$action $savedName. $activeCheats active cheat patch${if (activeCheats == 1) "" else "es"}."
activeCheats == 0 ->
"$action $savedName, but 0 cheats are active. Check PNACH syntax/CRC, then restart."
else ->
"$action $savedName. Reload skipped; restart the game to load it."
}
val importLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
@@ -69,16 +205,54 @@ fun PatchesTab(state: MutableState<Settings>) {
val name = DocumentFile.fromSingleUri(context, uri)?.name
?.takeIf { it.isNotEmpty() }
?: "imported.pnach"
// Force a .pnach extension so it's discoverable by emucore.
val outName = if (name.endsWith(".pnach", ignoreCase = true)) name else "$name.pnach"
runCatching {
context.contentResolver.openInputStream(uri)?.use { ins ->
File(cheatsDir, outName).outputStream().use { outs -> ins.copyTo(outs) }
}
val outName = importPnachFileName(name, activePnachGameId())
val result = runCatching {
val outFile = pnachTargetFile(cheatsDir, outName)
val importedText = context.contentResolver.openInputStream(uri)?.use { ins ->
ins.reader().readText()
} ?: error("could not open selected file")
outFile.writeText(manualPnachContents(name, importedText, activePnachGameId()))
val activeCheats = activateCheatsAndReload()
outFile.name to activeCheats
}
pnachStatus = result.fold(
onSuccess = { (savedName, activeCheats) ->
if (activePnachGameId() != null || loadablePnachName(savedName))
pnachResultMessage("Imported as", savedName, activeCheats)
else
"Imported as $savedName, but it may need SERIAL_CRC naming to load."
},
onFailure = { "Import failed: ${it.message ?: "unknown error"}" },
)
refresh()
}
if (showManualDialog) {
ManualPnachDialog(
gameId = activeGameId,
onDismiss = { showManualDialog = false },
onSave = { title, body ->
val result = runCatching {
val outFile = pnachTargetFile(cheatsDir, manualPnachFileName(title, activePnachGameId()))
outFile.writeText(manualPnachContents(title, body, activePnachGameId()))
val activeCheats = activateCheatsAndReload()
outFile.name to activeCheats
}
pnachStatus = result.fold(
onSuccess = { (savedName, activeCheats) ->
if (activePnachGameId() != null || loadablePnachName(savedName))
pnachResultMessage("Executed", savedName, activeCheats)
else
"Saved $savedName, but it may need SERIAL_CRC naming to load."
},
onFailure = { "Save failed: ${it.message ?: "unknown error"}" },
)
refresh()
showManualDialog = false
},
)
}
Column(
modifier = Modifier
.fillMaxWidth()
@@ -112,21 +286,52 @@ fun PatchesTab(state: MutableState<Settings>) {
modifier = Modifier.padding(top = 6.dp, bottom = 2.dp),
)
Text(
"Name must match the disc, e.g. SLUS-12345_A1B2C3D4.pnach",
activeGameId?.let { "Active game: ${it.serial} / CRC ${it.crc}" }
?: "Start the target game first to auto-name PNACH files.",
color = Color(0xFF8C8C8C),
fontSize = 10.sp,
modifier = Modifier.padding(bottom = 4.dp),
)
Box(
Modifier
.fillMaxWidth()
.height(36.dp)
.background(rowAura())
.clickable { importLauncher.launch(arrayOf("*/*")) }
.padding(horizontal = 8.dp),
contentAlignment = Alignment.CenterStart,
Text(
"Paste/import PCSX2 PNACH patch= lines. Hardcore achievements disables cheats.",
color = Color(0xFF8C8C8C),
fontSize = 10.sp,
modifier = Modifier.padding(bottom = 4.dp),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("+ Import .pnach file", color = Colors.pasx2_blue, fontSize = 13.sp, fontWeight = FontWeight.Bold)
Box(
Modifier
.weight(1f)
.height(36.dp)
.background(rowAura())
.clickable { importLauncher.launch(arrayOf("*/*")) }
.padding(horizontal = 8.dp),
contentAlignment = Alignment.CenterStart,
) {
Text("+ Import .pnach", color = Colors.pasx2_blue, fontSize = 13.sp, fontWeight = FontWeight.Bold)
}
Box(
Modifier
.weight(1f)
.height(36.dp)
.background(rowAura())
.clickable { showManualDialog = true }
.padding(horizontal = 8.dp),
contentAlignment = Alignment.CenterStart,
) {
Text("+ Enter codes", color = Colors.pasx2_blue, fontSize = 13.sp, fontWeight = FontWeight.Bold)
}
}
if (pnachStatus.isNotEmpty()) {
Text(
pnachStatus,
color = Color(0xFFB0B0B0),
fontSize = 10.sp,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp),
)
}
if (pnachFiles.isEmpty()) {
Text(
@@ -169,3 +374,82 @@ fun PatchesTab(state: MutableState<Settings>) {
}
}
}
@Composable
private fun ManualPnachDialog(
gameId: PnachGameId?,
onDismiss: () -> Unit,
onSave: (title: String, body: String) -> Unit,
) {
var title by remember { mutableStateOf("") }
var body by remember { mutableStateOf("") }
fun execute() {
if (body.isNotBlank())
onSave(title, body)
}
val tfColors = TextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedContainerColor = Color(0xFF111111),
unfocusedContainerColor = Color(0xFF111111),
disabledContainerColor = Color(0xFF111111),
focusedLabelColor = Colors.pasx2_blue,
unfocusedLabelColor = Color(0xFFAAAAAA),
focusedIndicatorColor = Colors.pasx2_blue,
unfocusedIndicatorColor = Color(0xFF555555),
cursorColor = Colors.pasx2_blue,
)
AlertDialog(
onDismissRequest = onDismiss,
containerColor = Color(0xFF151515),
title = {
Text("Enter PNACH Codes", color = Color.White, fontWeight = FontWeight.Bold)
},
text = {
Column {
Text(
gameId?.let { "Saving for ${it.serial} / ${it.crc}" }
?: "No active CRC found; start the game first for auto-naming.",
color = Color(0xFFAAAAAA),
fontSize = 11.sp,
modifier = Modifier.padding(bottom = 6.dp),
)
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Name") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
colors = tfColors,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = body,
onValueChange = { body = it },
label = { Text("PNACH patch= lines") },
minLines = 6,
maxLines = 10,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { execute() }),
colors = tfColors,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
enabled = body.isNotBlank(),
onClick = { execute() },
) {
Text("Execute", color = if (body.isNotBlank()) Colors.pasx2_blue else Color(0xFF777777))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color(0xFFCCCCCC))
}
},
)
}
@@ -38,6 +38,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.math.abs
/**
* Renderer section of the in-game settings overlay.
@@ -49,6 +50,23 @@ import java.io.File
* uses a narrow native GS helper so it can visibly apply while a game is live
* without running the full settings commit path.
*/
private data class UpscaleOption(val value: Float, val label: String)
private val UPSCALE_OPTIONS = listOf(
UpscaleOption(1.0f, "Native"),
UpscaleOption(1.25f, "1.25x"),
UpscaleOption(1.5f, "1.5x"),
UpscaleOption(1.75f, "1.75x"),
UpscaleOption(2.0f, "2x"),
UpscaleOption(2.25f, "2.25x"),
UpscaleOption(2.5f, "2.5x"),
UpscaleOption(2.75f, "2.75x"),
UpscaleOption(3.0f, "3x"),
UpscaleOption(3.5f, "3.5x"),
UpscaleOption(4.0f, "4x"),
UpscaleOption(5.0f, "5x"),
)
@Composable
fun RendererTab(state: MutableState<Settings>) {
val s = state.value
@@ -65,17 +83,20 @@ fun RendererTab(state: MutableState<Settings>) {
// from the removed first-run setup renderer page into settings.
RendererBackendSection()
SettingsDivider()
IntSliderRow(
val upscaleIndex = UPSCALE_OPTIONS
.indexOfFirst { abs(it.value - Main.upscale.value) < 0.01f }
.takeIf { it >= 0 } ?: 0
SegmentedGridRow(
label = "Upscale",
value = Main.upscale.value,
min = 1,
max = 5,
valueFormatter = { mult -> "${mult}x ${640 * mult}×${448 * mult}" },
onChange = { mult ->
if (Main.upscale.value != mult) {
options = UPSCALE_OPTIONS.map { it.label },
selectedIndex = upscaleIndex,
columns = 4,
onChange = { index ->
val mult = UPSCALE_OPTIONS[index].value
if (abs(Main.upscale.value - mult) >= 0.01f) {
Main.upscale.value = mult
Main.prefs.edit().putInt("upscale", mult).apply()
NativeApp.renderUpscalemultiplier(mult.toFloat())
Main.prefs.edit().putFloat("upscaleFloat", mult).apply()
NativeApp.renderUpscalemultiplier(mult)
}
},
)
@@ -90,8 +90,10 @@ public class NativeApp {
* the UI layer should flag those.
*/
public static native void commitSettings();
public static native int reloadPatches();
public static native String getGameTitle(String path);
public static native String getGameSerial();
public static native String getGameCRC();
public static native float getFPS();
/** Build version string from BuildVersion::GitRev formatted as
@@ -260,8 +262,8 @@ public class NativeApp {
/**
* Read enough of a PS2 disc image to extract its serial (e.g.
* "SLUS-20312"). Walks the ISO9660 directory to find SYSTEM.CNF and
* parses the BOOT2 line. Only handles 2048-byte-sector ISOs today
* CHD/CSO/etc. return null and the caller falls back to filename
* parses the BOOT2 line. Handles flat ISO/raw-sector images and CHDs;
* CSO/ZSO/GZ still return null and the caller falls back to filename
* parsing. fd is consumed (closed by native).
*/
public static native String getGameSerialFromFd(int fd);
Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB