diff --git a/ARMSX2/app/src/main/cpp/native-lib.cpp b/ARMSX2/app/src/main/cpp/native-lib.cpp index 402b046922..4e42c0f624 100644 --- a/ARMSX2/app/src/main/cpp/native-lib.cpp +++ b/ARMSX2/app/src/main/cpp/native-lib.cpp @@ -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(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(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; // 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(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(lba) * sectorSize + dataOffset + skip; if (std::fseek(fp, static_cast(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& 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(lba) * sectorSize + dataOffset + skip; + std::uint64_t byte_off = byteBase + static_cast(lba) * sectorSize + dataOffset + skip; auto* dst = static_cast(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 +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(sectorSize), + }; + + std::uint64_t lastLeadIn = static_cast(-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); } diff --git a/ARMSX2/app/src/main/cpp/pcsx2/Patch.cpp b/ARMSX2/app/src/main/cpp/pcsx2/Patch.cpp index 05be983331..218a9b0af5 100644 --- a/ARMSX2/app/src/main/cpp/pcsx2/Patch.cpp +++ b/ARMSX2/app/src/main/cpp/pcsx2/Patch.cpp @@ -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 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", diff --git a/ARMSX2/app/src/main/cpp/pcsx2/arm64/mac/aR5900.cpp b/ARMSX2/app/src/main/cpp/pcsx2/arm64/mac/aR5900.cpp index 7767588d5d..304d5fbd32 100644 --- a/ARMSX2/app/src/main/cpp/pcsx2/arm64/mac/aR5900.cpp +++ b/ARMSX2/app/src/main/cpp/pcsx2/arm64/mac/aR5900.cpp @@ -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(::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(R5900::GetInstruction(op).interpret)); return true; + case OP_SQC2: recEmitVU0FinishForCOP2(); recEmitDirectInterpCall(op, reinterpret_cast(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(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 diff --git a/ARMSX2/app/src/main/cpp/pcsx2/arm64/mac/aR5900LoadStore.cpp b/ARMSX2/app/src/main/cpp/pcsx2/arm64/mac/aR5900LoadStore.cpp index c72bae4159..82c96f43a6 100644 --- a/ARMSX2/app/src/main/cpp/pcsx2/arm64/mac/aR5900LoadStore.cpp +++ b/ARMSX2/app/src/main/cpp/pcsx2/arm64/mac/aR5900LoadStore.cpp @@ -23,11 +23,25 @@ #include +// 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(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(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(&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(&vtlb_memWrite128)); + +#if ARMSX2_MAC_FASTMEM + armAsm->Bind(&fastmem_done); +#endif } // ======================================================================== diff --git a/ARMSX2/app/src/main/java/com/armsx2/GameInfo.kt b/ARMSX2/app/src/main/java/com/armsx2/GameInfo.kt index 4437fce10b..ae5b24f855 100644 --- a/ARMSX2/app/src/main/java/com/armsx2/GameInfo.kt +++ b/ARMSX2/app/src/main/java/com/armsx2/GameInfo.kt @@ -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 { 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 diff --git a/ARMSX2/app/src/main/java/com/armsx2/Main.kt b/ARMSX2/app/src/main/java/com/armsx2/Main.kt index 9ca2b4716b..982791b406 100644 --- a/ARMSX2/app/src/main/java/com/armsx2/Main.kt +++ b/ARMSX2/app/src/main/java/com/armsx2/Main.kt @@ -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) diff --git a/ARMSX2/app/src/main/java/com/armsx2/ui/GamesList.kt b/ARMSX2/app/src/main/java/com/armsx2/ui/GamesList.kt index 2dd2929025..5eb146d236 100644 --- a/ARMSX2/app/src/main/java/com/armsx2/ui/GamesList.kt +++ b/ARMSX2/app/src/main/java/com/armsx2/ui/GamesList.kt @@ -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 = - dirs.toSet().sorted().joinToString("|") + "|v2" + dirs.toSet().sorted().joinToString("|") + "|v3" @Composable private fun LibraryScreen(context: Context, romsDirs: List, 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) diff --git a/ARMSX2/app/src/main/java/com/armsx2/ui/SetupImpl.kt b/ARMSX2/app/src/main/java/com/armsx2/ui/SetupImpl.kt index 74391b13b9..0fac79be5e 100644 --- a/ARMSX2/app/src/main/java/com/armsx2/ui/SetupImpl.kt +++ b/ARMSX2/app/src/main/java/com/armsx2/ui/SetupImpl.kt @@ -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) { diff --git a/ARMSX2/app/src/main/java/com/armsx2/ui/settings/PatchesTab.kt b/ARMSX2/app/src/main/java/com/armsx2/ui/settings/PatchesTab.kt index 0a96e69df1..af12997fd6 100644 --- a/ARMSX2/app/src/main/java/com/armsx2/ui/settings/PatchesTab.kt +++ b/ARMSX2/app/src/main/java/com/armsx2/ui/settings/PatchesTab.kt @@ -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("""(? + 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 /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) { 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 = @@ -59,8 +171,32 @@ fun PatchesTab(state: MutableState) { ?: emptyList() var pnachFiles: List 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) { 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) { 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) { } } } + +@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)) + } + }, + ) +} diff --git a/ARMSX2/app/src/main/java/com/armsx2/ui/settings/RendererTab.kt b/ARMSX2/app/src/main/java/com/armsx2/ui/settings/RendererTab.kt index de494311dd..7a37b579d0 100644 --- a/ARMSX2/app/src/main/java/com/armsx2/ui/settings/RendererTab.kt +++ b/ARMSX2/app/src/main/java/com/armsx2/ui/settings/RendererTab.kt @@ -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) { val s = state.value @@ -65,17 +83,20 @@ fun RendererTab(state: MutableState) { // 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) } }, ) diff --git a/ARMSX2/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java b/ARMSX2/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java index 4ec8ad6440..42e5f812e7 100644 --- a/ARMSX2/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java +++ b/ARMSX2/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java @@ -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); diff --git a/ARMSX2/app/src/main/res/drawable-nodpi/setup_welcome_landscape.jpg b/ARMSX2/app/src/main/res/drawable-nodpi/setup_welcome_landscape.jpg new file mode 100644 index 0000000000..a7d9a8dae1 Binary files /dev/null and b/ARMSX2/app/src/main/res/drawable-nodpi/setup_welcome_landscape.jpg differ