mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
Android refresh: update PNACH, setup, and library fixes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user