diff --git a/.gitignore b/.gitignore index a3911be3a..43b8a8e3a 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,38 @@ CMakeUserPresets.json .cache/ .lldbinit + +# --- ARMSX3 (Android port) --- +# Gradle / Android Studio build output +android/**/build/ +android/**/.gradle/ +android/**/.cxx/ +android/**/local.properties +android/**/*.iml +.idea/ + +# Built native libraries. libarmsx3-core.so alone is ~98 MB stripped; these are +# build products and must not enter git history. See BUILDING.md. +android/**/jniLibs/**/*.so +android/**/cpp/**/*.so + +# Discord Social SDK is proprietary and has no published license, so it is not +# redistributed here. Fetch it from Discord's developer portal into +# app/libs/ and app/src/main/cpp/discord_sdk/ before building that flavour. +android/**/libs/discord_partner_sdk.aar +android/**/discord_sdk/ + +# Signing material +*.keystore +*.jks +android/**/keystore.properties + +# Secrets kept out of source (see RA user-agent handling) +**/ra_ua_secret.h + +# Third party checkouts. Not submodules, clone these yourself before building. +# librashader https://github.com/SnowflakePowered/librashader +# libadrenotools https://github.com/bylaws/libadrenotools +3rdparty/librashader/ +android/app-upstream/ +android/**/cpp/libadrenotools/ diff --git a/3rdparty/CMakeLists.txt b/3rdparty/CMakeLists.txt index b25afb883..6db9e098e 100644 --- a/3rdparty/CMakeLists.txt +++ b/3rdparty/CMakeLists.txt @@ -112,6 +112,14 @@ if (NOT ANDROID AND NOT APPLE) else() target_link_libraries(3rdparty_opengl INTERFACE OpenGL::GL OpenGL::GLU OpenGL::GLX) endif() +elseif (ANDROID) + # ARMSX3: Android has OpenGL, it just spells it OpenGL ES 3.2. The headers come + # from the NDK sysroot, so nothing needs to be found or added to the include + # path. Nothing is LINKED either, on purpose: rpcs3/Emu/RSX/GL/OpenGL_ES.cpp + # dlopen()s libEGL/libGLESv2 and resolves every entry point by hand, because a + # NEEDED dependency on libGLESv2.so would bind to the system driver forever and + # make ANGLE impossible. libdl comes with the NDK by default. + add_library(3rdparty_opengl INTERFACE) else() add_library(3rdparty_opengl INTERFACE) target_compile_definitions(3rdparty_opengl INTERFACE WITHOUT_OPENGL=1) @@ -237,6 +245,12 @@ if (NOT ANDROID) else() add_library(3rdparty_openal INTERFACE) target_compile_definitions(3rdparty_openal INTERFACE WITHOUT_OPENAL=1) + # ARMSX3: WITHOUT_OPENAL compiles out every OpenAL *call* (cellMic.cpp guards + # all 7 sites) but cellMic.h still includes "alc.h" unconditionally for the + # ALC types in its declarations. Expose the headers so the include resolves; + # nothing links against OpenAL on Android. + target_include_directories(3rdparty_openal INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/OpenAL/openal-soft/include/AL") endif() # FAudio diff --git a/ARMSX3-STATUS.md b/ARMSX3-STATUS.md new file mode 100644 index 000000000..739260b97 --- /dev/null +++ b/ARMSX3-STATUS.md @@ -0,0 +1,155 @@ +# ARMSX3 — bring-up status + +Session of 2026-08-05. Read this first. + +## TL;DR + +The native core builds against **upstream RPCS3**, not RPCSX. Our delta to upstream is +small and entirely guarded, which is the whole point: RPCSX is 1,960 commits and ~16 +months behind upstream and is missing every one of whatcookie's 2026 ARM64 commits. +We get those for free and stay rebasable. + +The Android app shell is forked from `rpcsx-ui-android` into `android/armsx3-app`, +rebranded, re-themed purple, and the ARMSX2 audio layer is ported in. + +--- + +## The single most useful fact + +**The app `dlopen()`s the core and resolves `_rpcsx_*` symbols via `dlsym`.** +`app/src/main/cpp/native-lib.cpp` loads a core `.so` at runtime — that is why +rpcsx-build publishes 7 `-march` variants (armv8-a … armv9.1-a) and the app picks one. + +Our core exports **26/26** of the symbols the shell dlsyms. Verified compatible. +So `librpcsx-android.so` drops into the existing shell unchanged — you do not need +any UI work to get a build that boots games. + +--- + +## Upstream files we modified (keep this list near zero) + +| File | Lines | Why | +|---|---|---| +| `CMakeLists.txt` | 6 | `if(ANDROID)` include ffmpeg.cmake before 3rdparty; add `android/` subdir | +| `rpcs3/Emu/Io/pad_config_types.h` | 3 | `#ifdef __ANDROID__ virtual_pad` enum value | +| `rpcs3/Emu/Io/pad_config_types.cpp` | 3 | matching `fmt_class_string` case — **without this the value serialises as `unknown` and the handler silently never gets selected** | +| `3rdparty/CMakeLists.txt` | 6 | expose OpenAL headers on Android (see below) | +| `rpcs3/util/media_utils.cpp` | 3 | `#include "util/ffmpeg_compat.h"` | + +Everything else is additive: `android/`, `rpcs3/dev/`, `rpcs3/Input/virtual_pad_handler.*`, +`rpcs3/util/ffmpeg_compat.h`. + +## Traps found the hard way (do not re-derive these) + +1. **NDK 29 / clang 21 is required. NDK 28.2 is clang 19.0.1 and will not build this.** + `fmt::throw_exception` is a CTAD struct whose ctor *and* dtor are `[[noreturn]]`, not a + function. clang 19.0.1 fails to propagate that, so every `default:` ending in it trips + `-Werror,-Wreturn-type`. It killed 4 TUs at 2510/3123 — *after* LLVM had fully built. + Do **not** silence it with `-Wno-error=return-type`; the code is correct. + Cheap way to test a toolchain hypothesis without a 30-min rebuild: pull the failing + compile line out of the ninja log, `sed` the compiler+sysroot to the other NDK, strip + `-o/-MD/-MT/-MF`, add `-fsyntax-only`. 30 seconds instead of 30 minutes. + +2. **Never make `android/` the top-level CMake project** (RPCSX does). Upstream assumes + `CMAKE_SOURCE_DIR == repo root` in ≥4 places (`FindWolfSSL.cmake`, `FindZLIB.cmake`, + `3rdparty/protobuf`, `3rdparty/llvm`). Configuring with `-S android` makes them all + resolve to `android/3rdparty/...`. Configure from root; use `android/configure.sh`. + +3. **Upstream pins LLVM 22.x.** RPCSX's prebuilt `llvm-android-arm64-v8a` is 20.1.3 — two + majors behind, unusable (ORC/JIT API breaks). Must build from source. + +4. **Upstream's Android path is half-finished in two places.** Its ffmpeg block is + `if(NOT ANDROID)` but it aliases `3rdparty::ffmpeg` **unguarded**. Its OpenAL branch + defines `WITHOUT_OPENAL=1` but exposes no include dir, while `cellMic.h` includes + `alc.h` unconditionally (the *calls* are guarded — 7 sites in cellMic.cpp — only the + include is not). + +5. **ffmpeg**: upstream targets 7.1+ APIs (`avcodec_get_supported_config`/`AVCodecConfig`, + const `AVChannelLayout*`). The only Android prebuilt in existence is 5.1. Stopgap is + `rpcs3/util/ffmpeg_compat.h`, version-gated so it self-disables. **Real fix: cross-compile + ffmpeg 7.1 for Android, then delete that header and its one `#include`.** + +--- + +## Done + +- `agents/ARMSX3` on upstream RPCS3 `652cf60bf`, branch `armsx3-bringup`, + remotes `upstream`=RPCS3, `rpcsx`=RPCSX. +- Android layer ported: 43/55 of RPCSX's includes resolved untouched, 9 mechanical + remaps, 3 `rx::` calls → `utils::trap()` / `rpcs3::get_version()`. +- `android/configure.sh` — reproducible configure with every cross-compile override + documented inline (`USE_NATIVE_INSTRUCTIONS=OFF` matters: it means `-march=native`, + which probes the *host* Mac). +- App shell forked to `android/armsx3-app`, `applicationId = com.armsx3`, name ARMSX3. +- **Purple Material3 theme** seeded from the logo, both light and dark. +- **Audio ported from ARMSX2 mono**: `MenuSfx.kt`, `LibraryMusic.kt`, `PauseMusic.kt` + + 14 SFX wavs + pause music. `MainActivityRuntime.prefs` → `GeneralSettings.raw` + (new accessor on the same `app_prefs` store); `EmuState` → RPCSX's `EmulatorState`. +- Your track re-encoded 320kbps/7.2MB → 128k/3.6MB as `res/raw/library_music.m4a`. +- `CoverRepository.kt` written for aldostools covers. + +## Covers — measured, not assumed + +`https://raw.githubusercontent.com/aldostools/Resources/main/COV/.JPG` +Flat layout, `TITLE_ID` is exactly what PARAM.SFO gives (`BLUS30443`). Verified 200s. + +- Covers are **260×300, aspect 0.866** — multiMAN style, **not** the ~0.72 PS3 retail + sleeve ratio. Draw at `CoverRepository.COVER_ASPECT` or everything letterboxes. +- **There are no 3D covers in that repo**, so that feature is out, as you said. +- Repo is 3.1 GB — fetch per-title, never clone. `CoverRepository` caches to + `filesDir/covers`, writes a `.miss` marker on 404 so coverless titles aren't + re-requested forever, and downloads via `.part`+rename so an interrupted fetch + can't leave a truncated jpg that looks valid. + +## Not done — and why + +- **Setup/onboarding screen.** rpcsx-ui-android has *none* (no Setup/Onboard/Welcome + files; `startDestination = "games"`, missing firmware is just a nag dialog in + GamesScreen). ARMSX2's `ui/onboarding/` is 1,039 lines and depends on `ArmsBackdrop`, + `ArmsLogo`, `StatusChip`, `padFocusRing`, i18n. + **This is not a rename job**: PS2 BIOS is a ROM file you point at; PS3 firmware is a + PUP you *install* and decrypt into `dev_flash`. `BiosInfo` (ROMVER region byte, packed + version) has no PS3 analogue. The RPCS3 side is `installFw(fd, progressId)` + + `FirmwareRepository{None,Installed,Compiled}` + `utils::get_firmware_version()`. +- Shaders, controller skins, cover UI wiring, perf tab, save-state UI, touch controls + reconcile — all mapped below, none ported yet. +- RetroAchievements — **dropped**, RA has no PS3 support. + +## Port map (ARMSX2 mono → ARMSX3) + +Source root: `agents/armsx2-push-staging/platforms/android/app/src/main/java/com/armsx2` + +| Feature | Files | +|---|---| +| Shaders | `ShaderRepo.kt`, `ShaderParams.kt`, `ui/common/ShaderChainSection.kt`, `ShaderManagerSection.kt`, `ShaderParamsEditor.kt` | +| Controller skins | `ControllerSkinStore.kt`, `SkinRepo.kt`, `ui/settings/SkinsTab.kt` | +| Covers UI | `CoverRegionIndex.kt`, `ui/common/GameCoverArt.kt` | +| Custom drivers | `CustomDriver.kt`, `ui/common/DriverManagerSection.kt` — **reconcile, don't port**: RPCSX already has adrenotools + `GpuDriversScreen` | +| Save states | `ui/saves/SaveManagerScreen.kt`, `SaveStatePicker.kt` (core already has savestates) | +| Perf | `ui/settings/PerformanceTab.kt` | +| Touch | `ui/touch/TouchControls.kt`, `GestureLayer.kt`, `LightgunLayer.kt` — reconcile with RPCSX's `PadOverlay*` (7 files) + `OverlayEditActivity` | +| Theme | `ui/theme/Color.kt`, `Theme.kt`, `Type.kt` | +| Settings search | `ui/settingshub/SettingsSearchOverlay.kt` | + +## Already free — do not rebuild these + +**Core (upstream RPCS3):** Vulkan (29 files), OpenGL (18), `video_renderer{null,opengl,vulkan}`, +save states, and perf knobs incl. **Frame limit** (fps cap), **Sleep Timers Accuracy**, +**Thread Scheduler Mode**, framerate/frametime graphs. + +**RPCSX UI:** touch controls (7 files), **layout editor** (5), adrenotools custom-driver +loading + driver UI (6), PS3 firmware install (5), SAF provider, USB, PPU precompile +service, surface handling. + +## Open items needing you + +1. **Logo** — I can't write an image from chat to disk. Drop the 512×512 at + `~/Downloads/armsx3-logo.png` and it gets wired to launcher icon + onboarding. +2. **`kr.co.iefriends.pcsx2.NativeApp`** is imported by ARMSX2's `config/Settings.kt` — + a *different* rights holder from you. Must be replaced before that file crosses over. + (You confirmed you hold the rest of the ARMSX2 UI rights.) +3. **`Bin2Pbp.7z`** — that's a PS1→PSP EBOOT tool, unrelated to PS3. Left untouched; + assumed an accidental attach. +4. **Licensing**: RPCS3 is **GPL-2.0-only** (stated in its README). ARMSX2 is GPL-3.0. + Incompatible. Since you hold the ARMSX2 UI rights, ARMSX3's UI needs to go out under + GPL-2.0-only to combine with the core. diff --git a/CMakeLists.txt b/CMakeLists.txt index cbb2f66fc..738f18a22 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -102,6 +102,13 @@ if(MSVC) add_compile_options("$<$:/wd4530;/utf-8>") # C++ exception handler used, but unwind semantics are not enabled endif() +# ARMSX3: upstream skips its FFMPEG block on Android (3rdparty/CMakeLists.txt +# `if(NOT ANDROID)`) but still aliases 3rdparty::ffmpeg -> 3rdparty_ffmpeg +# unguarded, so the target has to exist before 3rdparty/ is processed. +if(ANDROID) + include(${CMAKE_CURRENT_SOURCE_DIR}/android/ffmpeg.cmake) +endif() + add_subdirectory(3rdparty) if (DISABLE_LTO) @@ -136,4 +143,10 @@ if(BUILD_RPCS3_TESTS) endif() add_subdirectory(rpcs3) +# ARMSX3: the Android JNI shared library. Kept behind ANDROID so desktop builds +# are byte-for-byte unaffected. +if(ANDROID) + add_subdirectory(android) +endif() + set_directory_properties(PROPERTIES VS_STARTUP_PROJECT rpcs3) diff --git a/README.md b/README.md index 351688be6..b817bec91 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,53 @@ -RPCS3 -===== +ARMSX3 +====== -[![GitHub Actions](https://img.shields.io/github/actions/workflow/status/RPCS3/rpcs3/rpcs3.yml?branch=master&logo=github&label=Actions)](https://github.com/RPCS3/rpcs3/actions/workflows/rpcs3.yml) -[![RPCS3 Discord Server](https://img.shields.io/discord/272035812277878785?color=5865F2&label=RPCS3%20Discord&logo=discord&logoColor=white)](https://discord.gg/rpcs3) +Proof of concept Android port of RPCS3. -The world's first free and open-source PlayStation 3 emulator/debugger, written in C++ for Windows, Linux, macOS and FreeBSD. +This is early work. A game boots and plays, but it is slow and most of it is +untested. It is not a usable emulator yet. -You can find some basic information on our [**website**](https://rpcs3.net/). Game info is being populated on the [**Wiki**](https://wiki.rpcs3.net/). -For discussion about this emulator, PS3 emulation, and game compatibility reports, please visit our [**forums**](https://forums.rpcs3.net) and our [**Discord server**](https://discord.gg/RPCS3). +Status +------ -[**Support the Lead Developers on Patreon**](https://rpcs3.net/patreon) +Skate 3 boots, loads and reaches gameplay at roughly 20 to 30 fps on a +Snapdragon 8 Gen 2. Rendering, audio, touch controls and physical controllers +work. Almost nothing else has been tested. -## Contributing +Differences from upstream RPCS3 +------------------------------- -If you want to help the project but do not code, the best way to help out is to test games and make bug reports. See: -* [Quickstart](https://rpcs3.net/quickstart) +Some of the fixes here are not in upstream and affect any ARM64 build, not only +Android: -If you want to contribute as a developer, please take a look at the following pages: +* Shaders declared runtime sized arrays inside uniform blocks, which requires + VK_EXT_shader_uniform_buffer_unsized_array. Adreno does not support that + extension, so every game pipeline failed to compile and nothing rendered. + Concrete array bounds are emitted when the extension is missing. -* [Coding Style](https://github.com/RPCS3/rpcs3/wiki/Coding-Style) -* [Developer Information](https://github.com/RPCS3/rpcs3/wiki/Developer-Information) +* The ARM64 SPU block verification checksum folded two thirds of every block + through an absolute difference. That collides on the near identical job + binaries an SPU job manager streams through the same local store address, so + a cached block could end up running against another job's code. It sums now. -You should also contact any of the developers in the forums or in the Discord server to learn more about the current state of the emulator. +* Thread affinity was compiled out on Android, and the core had no ARM + big.LITTLE topology, so SPU and RSX threads were never placed on the fast + cores. -### AI Use +* The LLVM JIT target was pinned to cortex-a34, an in order core from 2016. It + detects the host now. -Use of AI tools for research and reverse engineering purposes is permitted. However, contributors are expected to fully own and understand all code they submit. Any communication with the team — including code, code comments, and GitHub comments — must come from the human contributor, not an AI agent acting autonomously. +Building +-------- -We have unfortunately seen a rise in untested and unverified AI-generated slop being submitted to this project. This wastes maintainer time and, in worse cases, such changes get merged and break functionality for all users. Repeated violations will result in a ban from the repository. Please be respectful of everyone's time. +See BUILDING.md. -**Pull requests opened by AI agents or automated tools must include a disclosure in the PR description** stating the scope of AI involvement — which parts were AI-generated and what human testing or review was performed prior to submission. PRs that omit this disclosure may be closed without review. +The Discord Social SDK is proprietary and is not redistributed here. Get it from +Discord's developer portal if you want that feature. -If you are unsure about your work, open a discussion issue to talk it through with the team, or reach out to a maintainer on [Discord](https://discord.gg/RPCS3). +License +------- -## Building +GPL-2.0-only, the same as RPCS3. See LICENSE. Some files may be licensed +differently, check the file headers. -See [BUILDING.md](BUILDING.md) for more information about how to setup an environment to build RPCS3. - -## Running - -Check our friendly [quickstart](https://rpcs3.net/quickstart) guide to make sure your computer meets the minimum system requirements to run RPCS3. - -Don't forget to have your graphics driver up to date and to install the [Visual C++ Redistributable Packages for Visual Studio 2022](https://aka.ms/vs/17/release/VC_redist.x64.exe) if you are a Windows user. - -## License - -Most files are licensed under the terms of GNU GPL-2.0-only License; see LICENSE file for details. Some files may be licensed differently; check appropriate file headers for details. +Based on RPCS3, https://github.com/RPCS3/rpcs3 diff --git a/Utilities/Config.h b/Utilities/Config.h index 8f454e3ad..29e7fb873 100644 --- a/Utilities/Config.h +++ b/Utilities/Config.h @@ -109,6 +109,21 @@ namespace cfg return {}; } + // ARMSX3: expose the numeric range through the base interface. + // _int/_float/uint carry Min/Max as static constexpr TEMPLATE parameters, + // which are unreachable from a _base* -- so a generic settings UI cannot + // tell a slider from a free-text field without these. Mirrors the + // to_string/def_to_string pattern; empty means "not a bounded numeric". + virtual std::string min_to_string() const + { + return {}; + } + + virtual std::string max_to_string() const + { + return {}; + } + // Try to convert from string (optional) virtual bool from_string(std::string_view value, bool dynamic = false); @@ -382,6 +397,16 @@ namespace cfg return std::to_string(def); } + std::string min_to_string() const override + { + return std::to_string(min); + } + + std::string max_to_string() const override + { + return std::to_string(max); + } + bool from_string(std::string_view value, bool /*dynamic*/ = false) override { s64 result; @@ -473,6 +498,16 @@ namespace cfg return "0.0"; } + std::string min_to_string() const override + { + return std::to_string(min); + } + + std::string max_to_string() const override + { + return std::to_string(max); + } + bool from_string(std::string_view value, bool /*dynamic*/ = false) override { f64 result; @@ -560,6 +595,16 @@ namespace cfg return std::to_string(def); } + std::string min_to_string() const override + { + return std::to_string(min); + } + + std::string max_to_string() const override + { + return std::to_string(max); + } + bool from_string(std::string_view value, bool /*dynamic*/ = false) override { u64 result; diff --git a/Utilities/Thread.cpp b/Utilities/Thread.cpp index 59f1cc9da..5bf16d224 100644 --- a/Utilities/Thread.cpp +++ b/Utilities/Thread.cpp @@ -9,6 +9,7 @@ #include "Thread.h" #include "Utilities/JIT.h" #include +#include #ifdef ARCH_ARM64 #include "Emu/CPU/Backends/AArch64/AArch64Signal.h" @@ -3550,11 +3551,79 @@ void thread_ctrl::silent_exit() noexcept std::abort(); } + +#if defined(ARCH_ARM64) && defined(__linux__) +// Per-core capacity from sysfs, read ONCE. +// +// Deliberately plain POSIX I/O rather than fs::file: sysfs nodes report +// st_size == 0, so a size-based read returns nothing, and fs::file raised +// "Unexpected fs::error OK" from whichever thread asked -- which killed the RSX +// thread outright, since get_affinity_mask() runs on it. +static const std::array& get_arm_core_capacities() +{ + static const std::array s_caps = [] + { + std::array caps{}; + + for (u32 core = 0; core < 64u; core++) + { + char path[128]; + std::snprintf(path, sizeof(path), "/sys/devices/system/cpu/cpu%u/cpu_capacity", core); + + const int fd = ::open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) + { + continue; + } + + char buf[32]{}; + const auto got = ::read(fd, buf, sizeof(buf) - 1); + ::close(fd); + + if (got > 0) + { + caps[core] = static_cast(std::atoi(buf)); + } + } + + return caps; + }(); + + return s_caps; +} +#endif + void thread_ctrl::detect_cpu_layout() { if (!g_native_core_layout.compare_and_swap_test(native_core_arrangement::undefined, native_core_arrangement::generic)) return; +#if defined(ARCH_ARM64) && defined(__linux__) + // Heterogeneous if the kernel reports differing per-core capacity. Every + // big.LITTLE/DynamIQ SoC exposes cpu_capacity; a uniform machine reports the + // same value everywhere (or nothing at all), and falls through to generic. + { + const auto& caps = get_arm_core_capacities(); + u32 lowest = umax, highest = 0; + + for (u32 core = 0; core < 64u; core++) + { + if (caps[core]) + { + lowest = std::min(lowest, caps[core]); + highest = std::max(highest, caps[core]); + } + } + + if (highest && lowest != umax && highest > lowest) + { + sig_log.notice("Detected ARM heterogeneous CPU (capacity %u..%u)", lowest, highest); + g_native_core_layout.store(native_core_arrangement::arm_big_little); + return; + } + } +#endif + const auto system_id = utils::get_cpu_brand(); if (system_id.find("Ryzen") != umax) { @@ -3624,6 +3693,58 @@ u64 thread_ctrl::get_affinity_mask(thread_class group) { return all_cores_mask; } + case native_core_arrangement::arm_big_little: + { + // Put the threads that gate the frame -- SPU and RSX -- on the cores + // that can actually keep up, and leave the little cluster for PPU and + // helpers. Without this the OS spreads six SPU threads across a mix of + // big and LITTLE cores, and the slow ones set the pace. + const auto& caps = get_arm_core_capacities(); + u64 fast_mask = 0; + u64 slow_mask = 0; + u32 threshold = 0; + + for (u32 core = 0; core < 64u; core++) + { + threshold = std::max(threshold, caps[core]); + } + + // Anything within 25% of the fastest core counts as "fast", so a + // mid cluster (A715/A710) joins the prime core rather than being + // lumped in with the A510s. + threshold = threshold * 3 / 4; + + for (u32 core = 0; core < 64u; core++) + { + if (~process_affinity_mask & (u64{1} << core)) + { + continue; + } + + const u32 capacity = caps[core]; + + ((capacity && capacity >= threshold) ? fast_mask : slow_mask) |= (u64{1} << core); + } + + if (!fast_mask || !slow_mask) + { + // Degenerate reading -- do not fence anything off. + return all_cores_mask; + } + + switch (group) + { + case thread_class::spu: + case thread_class::rsx: + return fast_mask; + case thread_class::ppu: + // PPU still needs a fast core for the main thread, but letting it + // spill to the little cluster keeps it out of the SPUs' way. + return all_cores_mask; + default: + return slow_mask; + } + } case native_core_arrangement::amd_ccx: { if (thread_count <= 8) @@ -3890,7 +4011,11 @@ void thread_ctrl::set_thread_affinity_mask(u64 mask) thread_affinity_policy_data_t policy = { static_cast(std::countr_zero(mask)) }; thread_port_t mach_thread = pthread_mach_thread_np(pthread_self()); thread_policy_set(mach_thread, THREAD_AFFINITY_POLICY, reinterpret_cast(&policy), !mask ? 0 : 1); -#elif !defined(ANDROID) && (defined(__linux__) || defined(__DragonFly__) || defined(__FreeBSD__)) +// NOTE: Android was excluded here upstream, which made every affinity request a +// silent no-op -- Thread Scheduler Mode looked settable but did nothing. bionic +// provides pthread_setaffinity_np, and RPCSX runs this path on Android without +// the carve-out. Failures are already logged rather than fatal. +#elif (defined(__linux__) || defined(__DragonFly__) || defined(__FreeBSD__)) if (!mask) { // Reset affinity mask @@ -3918,11 +4043,20 @@ void thread_ctrl::set_thread_affinity_mask(u64 mask) } } +#ifdef ANDROID + // bionic has no pthread_setaffinity_np. sched_setaffinity with pid 0 targets + // the calling THREAD on Linux, which is what we want. + if (sched_setaffinity(0, sizeof(cpu_set_t), &cs) != 0) + { + sig_log.error("Failed to set thread affinity 0x%x: errno %d.", mask, errno); + } +#else if (int err = pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cs)) { sig_log.error("Failed to set thread affinity 0x%x: error %d.", mask, err); } #endif +#endif } u64 thread_ctrl::get_thread_affinity_mask() diff --git a/Utilities/Thread.h b/Utilities/Thread.h index 5d6d07c87..639d4f192 100644 --- a/Utilities/Thread.h +++ b/Utilities/Thread.h @@ -13,7 +13,11 @@ enum class native_core_arrangement : u32 undefined, generic, intel_ht, - amd_ccx + amd_ccx, + // ARM heterogeneous (big.LITTLE / DynamIQ). Cores differ in throughput by + // ~3x, so "any core" is not a sane default for a thread that must keep up + // with an emulated SPE. + arm_big_little }; enum class thread_class : u32 diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt new file mode 100644 index 000000000..db4b9ca2c --- /dev/null +++ b/android/CMakeLists.txt @@ -0,0 +1,109 @@ +# ARMSX3: the Android shared library, built on UPSTREAM RPCS3. +# +# NOTE ON LAYOUT (this is the important part): +# RPCSX makes android/ the top-level CMake project and does +# `add_subdirectory(..)` to pull the emulator in. That does not work against +# upstream, because upstream assumes CMAKE_SOURCE_DIR == repo root in at least +# four places -- buildfiles/cmake/FindWolfSSL.cmake, buildfiles/cmake/FindZLIB.cmake, +# 3rdparty/protobuf/CMakeLists.txt and 3rdparty/llvm/CMakeLists.txt all build +# paths as ${CMAKE_SOURCE_DIR}/3rdparty/... . Configuring from android/ makes +# every one of those resolve to android/3rdparty/... and fail. +# +# So here the repo ROOT stays the top-level project and this is an ordinary +# subdirectory, added from the root CMakeLists behind `if(ANDROID)`. Build +# option overrides are passed on the cmake command line (-D...) rather than +# set() here, since they must be visible before add_subdirectory(3rdparty). +# +# Upstream's core target is `rpcs3_emu`. Upstream's rpcs3/CMakeLists.txt already +# guards Qt / rpcs3qt / rpcs3_lib / the rpcs3 executable behind `if (NOT ANDROID)` +# while calling `add_subdirectory(Emu)` unconditionally, so a core-only Android +# configuration is something upstream half-supports already. + +# rpcs3/Input/ is not a library upstream -- its sources are compiled straight +# into the `rpcs3` executable, which lives inside `if (NOT ANDROID)`. So on +# Android nothing builds them, yet rpcs3_emu references pad_thread and +# ps_move_tracker (cellPad's LDD pad API, cellGem's tracker), and the link fails +# with undefined symbols. Build the platform-safe subset here. +# +# Deliberately EXCLUDED, and why: +# evdev_/xinput_/mm_/sdl_* - Linux/Windows/SDL backends, all disabled +# basic_keyboard_/basic_mouse_/ +# keyboard_pad_/raw_mouse_/ +# gui_pad_thread/mouse_gyro_ - Qt-dependent (rpcs3qt is not built here) +# camera_video_sink - Qt multimedia +set(ARMSX3_INPUT_SOURCES + ${CMAKE_SOURCE_DIR}/rpcs3/Input/pad_thread.cpp + ${CMAKE_SOURCE_DIR}/rpcs3/Input/product_info.cpp + ${CMAKE_SOURCE_DIR}/rpcs3/Input/hid_pad_handler.cpp + ${CMAKE_SOURCE_DIR}/rpcs3/Input/ds3_pad_handler.cpp + ${CMAKE_SOURCE_DIR}/rpcs3/Input/ds4_pad_handler.cpp + ${CMAKE_SOURCE_DIR}/rpcs3/Input/dualsense_pad_handler.cpp + ${CMAKE_SOURCE_DIR}/rpcs3/Input/skateboard_pad_handler.cpp + ${CMAKE_SOURCE_DIR}/rpcs3/Input/ps_move_handler.cpp + ${CMAKE_SOURCE_DIR}/rpcs3/Input/ps_move_config.cpp + ${CMAKE_SOURCE_DIR}/rpcs3/Input/ps_move_calibration.cpp + ${CMAKE_SOURCE_DIR}/rpcs3/Input/ps_move_tracker.cpp + # pad_thread references mouse_gyro_handler directly, so it is not optional + # despite the "mouse" name. + ${CMAKE_SOURCE_DIR}/rpcs3/Input/mouse_gyro_handler.cpp + # Ours: on-screen touch controls. + ${CMAKE_SOURCE_DIR}/rpcs3/Input/virtual_pad_handler.cpp +) + +add_library(rpcsx-android SHARED + src/rpcsx-android.cpp + # Carried from RPCSX; no upstream equivalent. + ${CMAKE_SOURCE_DIR}/rpcs3/dev/iso.cpp + ${ARMSX3_INPUT_SOURCES} + # rpcs3::get_version() and friends. Like Input/, this is compiled into the + # `rpcs3` executable upstream, not into rpcs3_emu, so Android never gets it. + ${CMAKE_SOURCE_DIR}/rpcs3/rpcs3_version.cpp +) + +# Ship as libarmsx3-core.so, which is the name the app calls +# System.loadLibrary("armsx3-core") for. Setting OUTPUT_NAME (rather than +# renaming the file after the fact) also fixes the SONAME, so the file name and +# the name the dynamic linker registers it under agree -- a mismatch there works +# by luck for a plain dlopen and stops working the moment anything resolves it +# by soname. +set_target_properties(rpcsx-android PROPERTIES OUTPUT_NAME armsx3-core) + +target_compile_features(rpcsx-android PRIVATE cxx_std_23) + +# The librashader upscaler lives in rpcs3/Emu/RSX/VK/, i.e. it compiles into +# rpcs3_emu -- not into this target -- so the header search path has to be added +# there. This directory is processed after add_subdirectory(rpcs3), so the target +# already exists. librashader_ld.h is a header-only permissive loader that +# dlopen()s librashader.so at runtime; nothing MPL-licensed is linked in. See the +# licensing note at the top of upscalers/librashader_pass.h. +target_include_directories(rpcs3_emu PRIVATE + ${CMAKE_SOURCE_DIR}/3rdparty/librashader/include +) + +target_include_directories(rpcsx-android PRIVATE + ${CMAKE_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/rpcs3 + ${CMAKE_SOURCE_DIR}/3rdparty/librashader/include + # rpcsx-android.cpp pulls in Input/hid_pad_handler.h (DS3/DS4/DualSense + # over USB) and hidapi_libusb.h/libusb.h directly. rpcs3_emu links these + # PRIVATE, so their include dirs do not propagate to us. + ${CMAKE_SOURCE_DIR}/3rdparty/hidapi/hidapi/hidapi + ${CMAKE_SOURCE_DIR}/3rdparty/hidapi/hidapi/libusb + ${CMAKE_SOURCE_DIR}/3rdparty/libusb/libusb/libusb + # Utilities/bin_patch.h (patch_engine) includes util/yaml.hpp, which includes + # yaml-cpp. rpcs3_emu links yaml-cpp PRIVATE, so it does not propagate here. + ${CMAKE_SOURCE_DIR}/3rdparty/yaml-cpp/yaml-cpp/include +) + +target_link_libraries(rpcsx-android + rpcs3_emu + # ps_move_tracker/ps_move_calibration use the Fusion AHRS solver. rpcs3_lib + # links this upstream; we build those Input sources ourselves, so we need it. + 3rdparty::fusion + # The HID pad handlers (DS3/DS4/DualSense/skateboard/PS Move) are ours to + # build here, so hidapi + libusb are ours to link too. + 3rdparty::hidapi + 3rdparty::libusb + android + log +) diff --git a/android/armsx3-app/app/.gitignore b/android/armsx3-app/app/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/android/armsx3-app/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/armsx3-app/app/build.gradle.kts b/android/armsx3-app/app/build.gradle.kts new file mode 100644 index 000000000..2eafe5888 --- /dev/null +++ b/android/armsx3-app/app/build.gradle.kts @@ -0,0 +1,150 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose.compiler) + id("org.jetbrains.kotlin.plugin.serialization") + id("kotlin-parcelize") +} + +android { + namespace = "net.rpcsx" + compileSdk = 36 + ndkVersion = "29.0.13113456" + + defaultConfig { + applicationId = "com.armsx3" + minSdk = 29 + targetSdk = 35 + versionCode = 1 + versionName = "${System.getenv("RX_VERSION") ?: "local"}${if (System.getenv("RX_SHA") != null) "-" + System.getenv("RX_SHA") else ""}" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + ndk { + abiFilters += listOf("arm64-v8a") + } + + buildConfigField("String", "Version", "\"v${versionName}\"") + } + + signingConfigs { + val keystoreAlias = System.getenv("KEYSTORE_ALIAS") ?: "" + val keystorePassword = System.getenv("KEYSTORE_PASSWORD") ?: "" + val keystorePath = System.getenv("KEYSTORE_PATH") ?: "" + + if (keystorePath.isNotEmpty() && file(keystorePath).exists() && file(keystorePath).length() > 0) { + create("custom-key") { + keyAlias = keystoreAlias + keyPassword = keystorePassword + storeFile = file(keystorePath) + storePassword = keystorePassword + } + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + signingConfig = signingConfigs.findByName("custom-key") ?: signingConfigs.getByName("debug") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.30.5" + } + } + + buildFeatures { + viewBinding = true + compose = true + buildConfig = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.15" + } + + packaging { + // This is necessary for libadrenotools custom driver loading + jniLibs.useLegacyPackaging = true + } +} + +// ARMSX3: fail the build if the bundled ANGLE libraries are not there. +// +// This check exists because of a specific, expensive bug in ARMSX2: the repo's +// blanket `*.so` gitignore rule swallowed the ANGLE prebuilts, they never made it +// into release staging, the APK shipped without them, and the core fell back to +// the system GLES driver in complete silence. Users reported "ANGLE is broken" +// and there was nothing in any log to contradict them. +// +// jniLibs/.gitignore now un-ignores the two files by name. This task is the +// second lock: packaging an APK that claims to support ANGLE without shipping +// ANGLE is a build error, not a runtime surprise. The core-side counterpart is +// the loud error in gl::es::egl_initialize() when the override library is +// selected but cannot be dlopen'd. +val verifyAngleLibs by tasks.registering { + val angleLibs = listOf("libEGL_angle.so", "libGLESv2_angle.so") + val jniLibDir = file("src/main/jniLibs/arm64-v8a") + + doLast { + val missing = angleLibs.filter { !File(jniLibDir, it).isFile } + if (missing.isNotEmpty()) { + throw GradleException( + "ANGLE libraries missing from ${'$'}jniLibDir: ${'$'}{missing.joinToString(", ")}.\n" + + "The OpenGL renderer's ANGLE option cannot work without them and would " + + "silently fall back to the system GLES driver.\n" + + "They are tracked in git - check them out, or remove the ANGLE option." + ) + } + + angleLibs.forEach { + logger.lifecycle("ANGLE: packaging ${'$'}it (${'$'}{File(jniLibDir, it).length()} bytes)") + } + } +} + +tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") } + .configureEach { dependsOn(verifyAngleLibs) } + +base.archivesName = "rpcsx" + +dependencies { + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.ui.tooling.preview.android) + val composeBom = platform("androidx.compose:compose-bom:2026.02.01") + implementation(composeBom) + implementation(libs.androidx.material3) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.activity) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + debugImplementation(libs.androidx.ui.tooling) + implementation(libs.kotlinx.serialization.json) + implementation(libs.coil.compose) + implementation(libs.squareup.okhttp3) + implementation(libs.androidx.documentfile) + implementation(libs.materialswitch) +} diff --git a/android/armsx3-app/app/proguard-rules.pro b/android/armsx3-app/app/proguard-rules.pro new file mode 100644 index 000000000..d3c904edf --- /dev/null +++ b/android/armsx3-app/app/proguard-rules.pro @@ -0,0 +1,70 @@ +# ARMSX3 R8 / ProGuard rules. +# +# --------------------------------------------------------------------------- +# JNI callback surface -- DO NOT REMOVE +# --------------------------------------------------------------------------- +# The core (librpcsx-android.so) reaches back into Java by NAME STRING: +# +# rpcsx-android.cpp:603 FindClass("net/rpcsx/ProgressRepository") +# rpcsx-android.cpp:626 FindClass("net/rpcsx/FirmwareRepository") +# rpcsx-android.cpp:644 FindClass("net/rpcsx/GameRepository") +# rpcsx-android.cpp:647 FindClass("net/rpcsx/GameInfo") + GetMethodID ctor +# + NewObject +# +# These classes carry @Keep on some members, but AndroidX's default rule for it +# is -keepclassmembers, which preserves the MEMBERS and still allows R8 to +# RENAME THE CLASS. A renamed class makes FindClass return null, so the app +# works in debug and dies the moment you ship a minified release -- the worst +# possible failure shape. Keep the classes themselves, by name. +-keep class net.rpcsx.ProgressRepository { *; } +-keep class net.rpcsx.FirmwareRepository { *; } +-keep class net.rpcsx.GameRepository { *; } +-keep class net.rpcsx.GameInfo { *; } + +# GameInfo is constructed from native via GetMethodID("", ...) with an +# exact JVM signature, and is also kotlinx-serialized. Keep its shape intact. +-keepclassmembers class net.rpcsx.GameInfo { + (...); + ; +} + +# RPCSX declares the dlsym'd entry points as `external fun`. The default +# android-optimize config keeps native method names, but the DECLARING class +# must survive too or JNI has nothing to bind to. +-keep class net.rpcsx.RPCSX { *; } +-keepclasseswithmembernames,includedescriptorclasses class * { + native ; +} + +# --------------------------------------------------------------------------- +# Serialization +# --------------------------------------------------------------------------- +# kotlinx.serialization generates synthetic $$serializer members reached only +# reflectively. Losing them turns saved library/firmware state into silent load +# failures on the first launch after an update. +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.** +-keepclassmembers @kotlinx.serialization.Serializable class ** { + *** Companion; + *** serializer(...); +} +-keepclasseswithmembers class ** { + kotlinx.serialization.KSerializer serializer(...); +} + +# --------------------------------------------------------------------------- +# OkHttp / Okio (skins, shaders, drivers and covers all ride HttpClient) +# --------------------------------------------------------------------------- +-dontwarn okhttp3.** +-dontwarn okio.** +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** + +# --------------------------------------------------------------------------- +# Diagnostics +# --------------------------------------------------------------------------- +# Keep line numbers so a user-reported release crash is traceable, but hide +# original file names. +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile diff --git a/android/armsx3-app/app/src/androidTest/java/net/rpcsx/ExampleInstrumentedTest.kt b/android/armsx3-app/app/src/androidTest/java/net/rpcsx/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..5d886ee26 --- /dev/null +++ b/android/armsx3-app/app/src/androidTest/java/net/rpcsx/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package net.rpcsx + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("net.rpcsx", appContext.packageName) + } +} \ No newline at end of file diff --git a/android/armsx3-app/app/src/main/AndroidManifest.xml b/android/armsx3-app/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..d06f06e78 --- /dev/null +++ b/android/armsx3-app/app/src/main/AndroidManifest.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/armsx3-app/app/src/main/cpp/CMakeLists.txt b/android/armsx3-app/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 000000000..260bb53f9 --- /dev/null +++ b/android/armsx3-app/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.10) +project("rpcsx-android") + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_POSITION_INDEPENDENT_CODE on) + +if (CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64") + add_subdirectory(libadrenotools) +else() + add_library(adrenotools INTERFACE) +endif() + +add_library(${CMAKE_PROJECT_NAME} SHARED native-lib.cpp) + +target_link_libraries(${CMAKE_PROJECT_NAME} + android + log + adrenotools +) diff --git a/android/armsx3-app/app/src/main/cpp/native-lib.cpp b/android/armsx3-app/app/src/main/cpp/native-lib.cpp new file mode 100644 index 000000000..d64b3c485 --- /dev/null +++ b/android/armsx3-app/app/src/main/cpp/native-lib.cpp @@ -0,0 +1,325 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__aarch64__) +#include +#include +#endif + +struct RPCSXApi { + bool (*overlayPadData)(int digital1, int digital2, int leftStickX, + int leftStickY, int rightStickX, int rightStickY); + bool (*initialize)(std::string_view rootDir, std::string_view user); + bool (*processCompilationQueue)(JNIEnv *env); + bool (*startMainThreadProcessor)(JNIEnv *env); + bool (*collectGameInfo)(JNIEnv *env, std::string_view rootDir, + long progressId); + void (*shutdown)(); + int (*boot)(std::string_view path_); + int (*getState)(); + void (*kill)(); + void (*resume)(); + void (*openHomeMenu)(); + std::string (*getTitleId)(); + bool (*surfaceEvent)(JNIEnv *env, jobject surface, jint event); + bool (*usbDeviceEvent)(int fd, int vendorId, int productId, int event); + bool (*installFw)(JNIEnv *env, int fd, long progressId); + bool (*isInstallableFile)(jint fd); + jstring (*getDirInstallPath)(JNIEnv *env, jint fd); + bool (*install)(JNIEnv *env, int fd, long progressId); + bool (*installKey)(JNIEnv *env, int fd, long progressId, + std::string_view gamePath); + std::string (*systemInfo)(); + void (*loginUser)(std::string_view userId); + std::string (*getUser)(); + std::string (*settingsGet)(std::string_view path); + bool (*settingsSet)(std::string_view path, std::string_view valueString); + std::string (*getVersion)(); + void *(*setCustomDriver)(void *driverHandle); +}; + +struct RPCSXLibrary : RPCSXApi { + void *handle = nullptr; + + RPCSXLibrary() = default; + RPCSXLibrary(const RPCSXLibrary &) = delete; + RPCSXLibrary(RPCSXLibrary &&other) { swap(other); } + RPCSXLibrary &operator=(RPCSXLibrary &&other) { + swap(other); + return *this; + } + ~RPCSXLibrary() { + if (handle) { + ::dlclose(handle); + } + } + + void swap(RPCSXLibrary &other) noexcept { + std::swap(handle, other.handle); + std::swap(static_cast(*this), static_cast(other)); + } + + static std::optional Open(const char *path) { + void *handle = ::dlopen(path, RTLD_LOCAL | RTLD_NOW); + if (handle == nullptr) { + __android_log_print(ANDROID_LOG_ERROR, "RPCSX-UI", + "Failed to open RPCSX library at %s, error %s", path, + ::dlerror()); + return {}; + } + + RPCSXLibrary result; + result.handle = handle; + + // clang-format off + result.overlayPadData = reinterpret_cast(dlsym(handle, "_rpcsx_overlayPadData")); + result.initialize = reinterpret_cast(dlsym(handle, "_rpcsx_initialize")); + result.processCompilationQueue = reinterpret_cast(dlsym(handle, "_rpcsx_processCompilationQueue")); + result.startMainThreadProcessor = reinterpret_cast(dlsym(handle, "_rpcsx_startMainThreadProcessor")); + result.collectGameInfo = reinterpret_cast(dlsym(handle, "_rpcsx_collectGameInfo")); + result.shutdown = reinterpret_cast(dlsym(handle, "_rpcsx_shutdown")); + result.boot = reinterpret_cast(dlsym(handle, "_rpcsx_boot")); + result.getState = reinterpret_cast(dlsym(handle, "_rpcsx_getState")); + result.kill = reinterpret_cast(dlsym(handle, "_rpcsx_kill")); + result.resume = reinterpret_cast(dlsym(handle, "_rpcsx_resume")); + result.openHomeMenu = reinterpret_cast(dlsym(handle, "_rpcsx_openHomeMenu")); + result.getTitleId = reinterpret_cast(dlsym(handle, "_rpcsx_getTitleId")); + result.surfaceEvent = reinterpret_cast(dlsym(handle, "_rpcsx_surfaceEvent")); + result.usbDeviceEvent = reinterpret_cast(dlsym(handle, "_rpcsx_usbDeviceEvent")); + result.installFw = reinterpret_cast(dlsym(handle, "_rpcsx_installFw")); + result.isInstallableFile = reinterpret_cast(dlsym(handle, "_rpcsx_isInstallableFile")); + result.getDirInstallPath = reinterpret_cast(dlsym(handle, "_rpcsx_getDirInstallPath")); + result.install = reinterpret_cast(dlsym(handle, "_rpcsx_install")); + result.installKey = reinterpret_cast(dlsym(handle, "_rpcsx_installKey")); + result.systemInfo = reinterpret_cast(dlsym(handle, "_rpcsx_systemInfo")); + result.loginUser = reinterpret_cast(dlsym(handle, "_rpcsx_loginUser")); + result.getUser = reinterpret_cast(dlsym(handle, "_rpcsx_getUser")); + result.settingsGet = reinterpret_cast(dlsym(handle, "_rpcsx_settingsGet")); + result.settingsSet = reinterpret_cast(dlsym(handle, "_rpcsx_settingsSet")); + result.getVersion = reinterpret_cast(dlsym(handle, "_rpcsx_getVersion")); + result.setCustomDriver = reinterpret_cast(dlsym(handle, "_rpcsx_setCustomDriver")); + // clang-format on + + return result; + } +}; + +static RPCSXLibrary rpcsxLib; + +static std::string unwrap(JNIEnv *env, jstring string) { + auto resultBuffer = env->GetStringUTFChars(string, nullptr); + std::string result(resultBuffer); + env->ReleaseStringUTFChars(string, resultBuffer); + return result; +} +static jstring wrap(JNIEnv *env, const std::string &string) { + return env->NewStringUTF(string.c_str()); +} +static jstring wrap(JNIEnv *env, const char *string) { + return env->NewStringUTF(string); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_net_rpcsx_RPCSX_openLibrary(JNIEnv *env, jobject, jstring path) { + if (auto library = RPCSXLibrary::Open(unwrap(env, path).c_str())) { + rpcsxLib = std::move(*library); + return true; + } + + return false; +} + +extern "C" JNIEXPORT jstring JNICALL +Java_net_rpcsx_RPCSX_getLibraryVersion(JNIEnv *env, jobject, jstring path) { + if (auto library = RPCSXLibrary::Open(unwrap(env, path).c_str())) { + if (auto getVersion = library->getVersion) { + return wrap(env, getVersion()); + } + } + + return {}; +} + +extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_overlayPadData( + JNIEnv *, jobject, jint digital1, jint digital2, jint leftStickX, + jint leftStickY, jint rightStickX, jint rightStickY) { + return rpcsxLib.overlayPadData(digital1, digital2, leftStickX, leftStickY, + rightStickX, rightStickY); +} + +extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_initialize( + JNIEnv *env, jobject, jstring rootDir, jstring user) { + return rpcsxLib.initialize(unwrap(env, rootDir), unwrap(env, user)); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_net_rpcsx_RPCSX_processCompilationQueue(JNIEnv *env, jobject) { + return rpcsxLib.processCompilationQueue(env); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_net_rpcsx_RPCSX_startMainThreadProcessor(JNIEnv *env, jobject) { + return rpcsxLib.startMainThreadProcessor(env); +} + +extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_collectGameInfo( + JNIEnv *env, jobject, jstring jrootDir, jlong progressId) { + return rpcsxLib.collectGameInfo(env, unwrap(env, jrootDir), progressId); +} + +extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_shutdown(JNIEnv *env, + jobject) { + return rpcsxLib.shutdown(); +} + +extern "C" JNIEXPORT jint JNICALL Java_net_rpcsx_RPCSX_boot(JNIEnv *env, + jobject, + jstring jpath) { + return rpcsxLib.boot(unwrap(env, jpath)); +} + +extern "C" JNIEXPORT jint JNICALL Java_net_rpcsx_RPCSX_getState(JNIEnv *env, + jobject) { + return rpcsxLib.getState(); +} + +extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_kill(JNIEnv *env, + jobject) { + return rpcsxLib.kill(); +} + +extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_resume(JNIEnv *env, + jobject) { + return rpcsxLib.resume(); +} + +extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_openHomeMenu(JNIEnv *env, + jobject) { + return rpcsxLib.openHomeMenu(); +} + +extern "C" JNIEXPORT jstring JNICALL +Java_net_rpcsx_RPCSX_getTitleId(JNIEnv *env, jobject) { + return wrap(env, rpcsxLib.getTitleId()); +} + +extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_surfaceEvent( + JNIEnv *env, jobject, jobject surface, jint event) { + return rpcsxLib.surfaceEvent(env, surface, event); +} + +extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_usbDeviceEvent( + JNIEnv *env, jobject, jint fd, jint vendorId, jint productId, jint event) { + return rpcsxLib.usbDeviceEvent(fd, vendorId, productId, event); +} + +extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_installFw( + JNIEnv *env, jobject, jint fd, jlong progressId) { + return rpcsxLib.installFw(env, fd, progressId); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_net_rpcsx_RPCSX_isInstallableFile(JNIEnv *env, jobject, jint fd) { + return rpcsxLib.isInstallableFile(fd); +} + +extern "C" JNIEXPORT jstring JNICALL +Java_net_rpcsx_RPCSX_getDirInstallPath(JNIEnv *env, jobject, jint fd) { + return rpcsxLib.getDirInstallPath(env, fd); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_net_rpcsx_RPCSX_install(JNIEnv *env, jobject, jint fd, jlong progressId) { + return rpcsxLib.install(env, fd, progressId); +} + +extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_installKey( + JNIEnv *env, jobject, jint fd, jlong progressId, jstring gamePath) { + return rpcsxLib.installKey(env, fd, progressId, unwrap(env, gamePath)); +} + +extern "C" JNIEXPORT jstring JNICALL +Java_net_rpcsx_RPCSX_systemInfo(JNIEnv *env, jobject) { + return wrap(env, rpcsxLib.systemInfo()); +} + +extern "C" JNIEXPORT void JNICALL +Java_net_rpcsx_RPCSX_loginUser(JNIEnv *env, jobject, jstring user_id) { + return rpcsxLib.loginUser(unwrap(env, user_id)); +} + +extern "C" JNIEXPORT jstring JNICALL Java_net_rpcsx_RPCSX_getUser(JNIEnv *env, + jobject) { + return wrap(env, rpcsxLib.getUser()); +} + +extern "C" JNIEXPORT jstring JNICALL +Java_net_rpcsx_RPCSX_settingsGet(JNIEnv *env, jobject, jstring jpath) { + return wrap(env, rpcsxLib.settingsGet(unwrap(env, jpath))); +} + +extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_settingsSet( + JNIEnv *env, jobject, jstring jpath, jstring jvalue) { + return rpcsxLib.settingsSet(unwrap(env, jpath), unwrap(env, jvalue)); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_net_rpcsx_RPCSX_supportsCustomDriverLoading(JNIEnv *env, + jobject instance) { + return access("/dev/kgsl-3d0", F_OK) == 0; +} + +extern "C" JNIEXPORT jstring JNICALL +Java_net_rpcsx_RPCSX_getVersion(JNIEnv *env, jobject) { + return wrap(env, rpcsxLib.getVersion()); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_net_rpcsx_RPCSX_setCustomDriver(JNIEnv *env, jobject, jstring jpath, + jstring jlibraryName, jstring jhookDir) { +#ifdef __aarch64__ + if (rpcsxLib.setCustomDriver == nullptr) { + return false; + } + + auto path = unwrap(env, jpath); + void *loader = nullptr; + + if (!path.empty()) { + auto hookDir = unwrap(env, jhookDir); + auto libraryName = unwrap(env, jlibraryName); + __android_log_print(ANDROID_LOG_INFO, "RPCSX-UI", "Loading custom driver %s", + path.c_str()); + + ::dlerror(); + loader = adrenotools_open_libvulkan( + RTLD_NOW, ADRENOTOOLS_DRIVER_CUSTOM, nullptr, (hookDir + "/").c_str(), + (path + "/").c_str(), libraryName.c_str(), nullptr, nullptr); + + if (loader == nullptr) { + __android_log_print(ANDROID_LOG_INFO, "RPCSX-UI", + "Failed to load custom driver at '%s': %s", + path.c_str(), ::dlerror()); + return false; + } + } + + auto prevLoader = rpcsxLib.setCustomDriver(loader); + if (prevLoader != nullptr) { + ::dlclose(prevLoader); + } + + return true; +#else + return false; +#endif // __aarch64__ +} diff --git a/android/armsx3-app/app/src/main/ic_rpcsx-playstore.png b/android/armsx3-app/app/src/main/ic_rpcsx-playstore.png new file mode 100644 index 000000000..62625462d Binary files /dev/null and b/android/armsx3-app/app/src/main/ic_rpcsx-playstore.png differ diff --git a/android/armsx3-app/app/src/main/java/net/rpcsx/AndroidPerformance.kt b/android/armsx3-app/app/src/main/java/net/rpcsx/AndroidPerformance.kt new file mode 100644 index 000000000..dc96131e5 --- /dev/null +++ b/android/armsx3-app/app/src/main/java/net/rpcsx/AndroidPerformance.kt @@ -0,0 +1,136 @@ +package net.rpcsx + +import android.app.Activity +import android.content.Context +import android.os.Build +import android.os.PerformanceHintManager +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import net.rpcsx.utils.GeneralSettings + +/** + * Android-side power/perf controls. + * + * Deliberately scoped to what the CORE does not already provide. RPCS3's own + * config already exposes -- and the generated settings screen already renders -- + * Frame limit (the fps cap), Second Frame Limit, Thread Scheduler Mode, Sleep + * Timers Accuracy, PPU/SPURS thread counts, Vblank Rate and Driver Wake-Up + * Delay. Duplicating those here would create two controls for one value, which + * is the classic "I changed the setting and nothing happened" bug. + * + * What is left is genuinely platform-only: + * - Sustained performance mode: asks the platform for a LOWER but flat clock + * ceiling. Counter-intuitive but usually a win for a long session: it trades + * a hot burst for a frame rate that does not collapse once the device + * thermally throttles. + * - ADPF CPU clock hint: tells the platform how long our frame work actually + * took, so the governor can pick a clock instead of guessing from load. On + * an emulator the guess is usually wrong -- a busy-waiting SPU thread reads + * as 100% load whether or not it needs the clock. + */ +object AndroidPerformance { + private const val SustainedKey = "perf.sustained" + private const val ClockHintKey = "perf.clockHint" + private const val TargetFpsKey = "perf.clockHint.targetFps" + + /** Flat-clock mode. Off by default -- it lowers peak clocks. */ + val sustainedMode = mutableStateOf(false) + + /** + * ADPF. Off by default and EXPERIMENTAL: reporting bad numbers is worse + * than reporting none, because the governor then actively fights you. + */ + val clockHint = mutableStateOf(false) + + /** Target frame rate the hint session is built around. */ + val clockHintTargetFps = mutableIntStateOf(60) + + private var hintSession: PerformanceHintManager.Session? = null + + val isSustainedSupported: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + + val isClockHintSupported: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + fun load() { + if (!GeneralSettings.isInitialized()) return + sustainedMode.value = GeneralSettings.raw.getBoolean(SustainedKey, false) + clockHint.value = GeneralSettings.raw.getBoolean(ClockHintKey, false) + clockHintTargetFps.intValue = GeneralSettings.raw.getInt(TargetFpsKey, 60) + } + + fun setSustained(activity: Activity, enabled: Boolean) { + sustainedMode.value = enabled + GeneralSettings.raw.edit().putBoolean(SustainedKey, enabled).apply() + applySustained(activity) + } + + /** Call from the emulator Activity's onCreate/onResume. */ + fun applySustained(activity: Activity) { + if (!isSustainedSupported) return + runCatching { + activity.window.setSustainedPerformanceMode(sustainedMode.value) + } + } + + fun setClockHint(context: Context, enabled: Boolean) { + clockHint.value = enabled + GeneralSettings.raw.edit().putBoolean(ClockHintKey, enabled).apply() + if (enabled) startHintSession(context) else stopHintSession() + } + + fun setClockHintTargetFps(fps: Int) { + val clamped = fps.coerceIn(15, 240) + clockHintTargetFps.intValue = clamped + GeneralSettings.raw.edit().putInt(TargetFpsKey, clamped).apply() + } + + private fun targetDurationNanos(): Long = + 1_000_000_000L / clockHintTargetFps.intValue.coerceAtLeast(1) + + /** + * Open an ADPF session for the CURRENT thread. + * + * Must be called from the thread whose work we are reporting -- the session + * is bound to specific tids. Calling it from a UI thread and then reporting + * emulator frame times would describe the wrong thread entirely. + */ + fun startHintSession(context: Context) { + if (!isClockHintSupported || !clockHint.value) return + if (hintSession != null) return + + runCatching { + val manager = context.getSystemService(PerformanceHintManager::class.java) + ?: return + hintSession = manager.createHintSession( + intArrayOf(android.os.Process.myTid()), + targetDurationNanos() + ) + } + } + + fun stopHintSession() { + runCatching { hintSession?.close() } + hintSession = null + } + + /** + * Report the wall time one frame's work actually took. + * + * Only meaningful if it reflects real work. Reporting a fabricated or + * frame-pacing-derived number makes the governor's model worse than having + * no hint at all. + */ + fun reportActualFrameTime(nanos: Long) { + val session = hintSession ?: return + if (nanos <= 0) return + runCatching { session.reportActualWorkDuration(nanos) } + } + + /** Retarget an existing session after the user changes the fps goal. */ + fun updateTargetWorkDuration() { + val session = hintSession ?: return + runCatching { session.updateTargetWorkDuration(targetDurationNanos()) } + } +} diff --git a/android/armsx3-app/app/src/main/java/net/rpcsx/AngleConfig.kt b/android/armsx3-app/app/src/main/java/net/rpcsx/AngleConfig.kt new file mode 100644 index 000000000..76a8b5246 --- /dev/null +++ b/android/armsx3-app/app/src/main/java/net/rpcsx/AngleConfig.kt @@ -0,0 +1,78 @@ +package net.rpcsx + +import android.system.Os +import android.util.Log +import java.io.File + +/** + * ARMSX3: selects ANGLE (GLES-on-Vulkan) for the OpenGL renderer. + * + * The whole hook is two environment variables. `gl::es::egl_initialize()` in the + * core reads `ARMSX3_ANGLE_EGL_LIBRARY` with getenv, in this same process, and + * dlopen()s that instead of the system libEGL. It has to be a private soname - + * Android resolves `libEGL.so` and `libGLESv2.so` through the public-library + * namespace, so an APK physically cannot shadow them; that is why the bundled + * files are named libEGL_angle.so / libGLESv2_angle.so. + * + * Must run before the GS thread creates its context, i.e. before a game boots. + * + * On the diagnostics below: ARMSX2 shipped an APK whose ANGLE libraries had been + * eaten by a blanket `*.so` gitignore rule. The core fell back to the system GLES + * driver silently, users reported "ANGLE is broken", and no log said otherwise. + * Three separate things now make that impossible to repeat: jniLibs/.gitignore + * un-ignores the two files by name, the `verifyAngleLibs` Gradle task fails the + * build if they are not in the APK, and both this class and the core log loudly + * when ANGLE was asked for and could not be had. + */ +object AngleConfig { + private const val TAG = "ARMSX3" + private const val EGL_LIB = "libEGL_angle.so" + private const val GLES_LIB = "libGLESv2_angle.so" + + const val ENV_EGL = "ARMSX3_ANGLE_EGL_LIBRARY" + const val ENV_GLES = "ARMSX3_ANGLE_GLES_LIBRARY" + + /** True when both ANGLE libraries are actually present in the APK. */ + fun isAvailable(nativeLibraryDir: String): Boolean = + File(nativeLibraryDir, EGL_LIB).isFile && File(nativeLibraryDir, GLES_LIB).isFile + + /** + * @param enabled the user's "use ANGLE for OpenGL" setting + * @param renderer the selected renderer; ANGLE only affects the OpenGL one + */ + fun apply(nativeLibraryDir: String, enabled: Boolean, renderer: String?) { + val wanted = enabled && renderer.equals("opengl", ignoreCase = true) + + if (!wanted) { + runCatching { Os.unsetenv(ENV_EGL) } + runCatching { Os.unsetenv(ENV_GLES) } + Log.i(TAG, "@@ANGLE@@ off (renderer=$renderer enabled=$enabled)") + return + } + + val egl = File(nativeLibraryDir, EGL_LIB) + val gles = File(nativeLibraryDir, GLES_LIB) + + if (!egl.isFile || !gles.isFile) { + // Distinguish "the user never picked ANGLE" from "the user picked ANGLE + // and the APK does not contain it". Only the second is a bug, and it is + // invisible without this line. + runCatching { Os.unsetenv(ENV_EGL) } + runCatching { Os.unsetenv(ENV_GLES) } + Log.e( + TAG, + "@@ANGLE@@ MISSING_LIBS dir=$nativeLibraryDir egl=${egl.isFile} gles=${gles.isFile} " + + "-> falling back to the system GLES driver" + ) + return + } + + runCatching { + Os.setenv(ENV_EGL, egl.absolutePath, true) + Os.setenv(ENV_GLES, gles.absolutePath, true) + Log.i(TAG, "@@ANGLE@@ enabled egl=${egl.absolutePath}") + }.onFailure { + Log.e(TAG, "@@ANGLE@@ error ${it.javaClass.simpleName}: ${it.message}") + } + } +} diff --git a/android/armsx3-app/app/src/main/java/net/rpcsx/ArmsxLinks.kt b/android/armsx3-app/app/src/main/java/net/rpcsx/ArmsxLinks.kt new file mode 100644 index 000000000..d2537a302 --- /dev/null +++ b/android/armsx3-app/app/src/main/java/net/rpcsx/ArmsxLinks.kt @@ -0,0 +1,21 @@ +package net.rpcsx + +/** + * Canonical ARMSX3 endpoints. + * + * Kept in one place because these are referenced from the drawer, the About / + * What's New screen and the updater. The repo does not exist publicly yet -- + * when it does, nothing else needs touching. + */ +object ArmsxLinks { + /** GitHub org/repo. What's New reads its Releases feed. */ + const val REPO = "https://github.com/ARMSX2/ARMSX3" + + /** Shared with ARMSX2 -- same community. */ + const val DISCORD = "https://discord.gg/2Tynvwhc4A" + + const val WEBSITE = "https://armsx2.net/" + + /** Where "report an issue" should land. */ + const val ISSUES = "$REPO/issues" +} diff --git a/android/armsx3-app/app/src/main/java/net/rpcsx/BootSplashActivity.kt b/android/armsx3-app/app/src/main/java/net/rpcsx/BootSplashActivity.kt new file mode 100644 index 000000000..09d139177 --- /dev/null +++ b/android/armsx3-app/app/src/main/java/net/rpcsx/BootSplashActivity.kt @@ -0,0 +1,210 @@ +package net.rpcsx + +import android.content.Intent +import android.graphics.Matrix +import android.graphics.SurfaceTexture +import android.media.MediaPlayer +import android.net.Uri +import android.os.Bundle +import android.view.Surface +import android.view.TextureView +import android.view.View +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +/** + * Boot splash: plays the bundled ARMSX3 intro video (res/raw/boot_intro.mp4) once per + * process, then hands off to Main. Tapping, the Back button, a hard timeout, and any + * playback error all fall through to the app so a bad codec or slow decode never + * strands the user on a black screen. The splash is opt-out via the "ui.bootLogo" + * preference (App settings, default on) — when disabled it launches Main immediately. + */ +class BootSplashActivity : ComponentActivity() { + private var launchedMain = false + private var rootView: View? = null + private var player: MediaPlayer? = null + private val timeoutRunnable = Runnable { launchMainAndFinish() } + + override fun onCreate(savedInstanceState: Bundle?) { + // The manifest theme (Theme.ARMSX3.Boot) already paints the window black, + // matching the video's black FrameLayout — no per-theme override, so a + // light-mode device never flashes white before the first decoded frame. + super.onCreate(savedInstanceState) + applyImmersiveUi() + + val prefs = getSharedPreferences("app_prefs", MODE_PRIVATE) + val bootLogoEnabled = prefs.getBoolean("ui.bootLogo", true) + if (!bootLogoEnabled || playedThisProcess) { + launchMainAndFinish() + return + } + playedThisProcess = true + + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = launchMainAndFinish() + }) + + setContentView(R.layout.activity_boot_splash) + rootView = findViewById(R.id.boot_splash_root) + val textureView = findViewById(R.id.boot_splash_video) + rootView?.apply { + setOnClickListener { launchMainAndFinish() } + postDelayed(timeoutRunnable, HARD_TIMEOUT_MS) + } + + if (textureView == null) { + launchMainAndFinish() + return + } + + textureView.setOnClickListener { launchMainAndFinish() } + textureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener { + override fun onSurfaceTextureAvailable(st: SurfaceTexture, width: Int, height: Int) { + startPlayback(textureView, Surface(st), width, height) + } + + override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, width: Int, height: Int) { + applyCenterCrop(textureView, width, height) + } + + override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean { + releasePlayer() + return true + } + + override fun onSurfaceTextureUpdated(st: SurfaceTexture) {} + } + } + + private fun startPlayback(view: TextureView, surface: Surface, width: Int, height: Int) { + try { + player = MediaPlayer().apply { + setDataSource( + this@BootSplashActivity, + Uri.parse("android.resource://$packageName/${R.raw.boot_intro}") + ) + setSurface(surface) + isLooping = false + setOnPreparedListener { + applyCenterCrop(view, width, height) + start() + } + setOnCompletionListener { launchMainAndFinish() } + setOnErrorListener { _, _, _ -> + launchMainAndFinish() + true + } + prepareAsync() + } + } catch (_: Exception) { + // Bad codec, missing resource, anything: never strand on black. + launchMainAndFinish() + } + } + + /** + * Scale the video to FILL the view, cropping the overflow. + * + * The intro is 1080x1080. A fit-inside policy (which is all VideoView can + * do) pillarboxes it on every non-square screen - that was the black bars + * either side of the logo. Scaling by the LARGER of the two ratios fills the + * display instead and crops the excess, and since the mark is centred in the + * frame it survives the crop. + */ + private fun applyCenterCrop(view: TextureView, viewWidth: Int, viewHeight: Int) { + val mp = player ?: return + val videoWidth = mp.videoWidth.takeIf { it > 0 } ?: return + val videoHeight = mp.videoHeight.takeIf { it > 0 } ?: return + if (viewWidth <= 0 || viewHeight <= 0) return + + val scale = maxOf( + viewWidth.toFloat() / videoWidth, + viewHeight.toFloat() / videoHeight + ) + + val scaledWidth = videoWidth * scale + val scaledHeight = videoHeight * scale + + // TextureView stretches its content to the view box by default, so the + // matrix is expressed relative to that: undo the stretch, then apply our + // own uniform scale about the centre. + Matrix().apply { + setScale( + scaledWidth / viewWidth, + scaledHeight / viewHeight, + viewWidth / 2f, + viewHeight / 2f + ) + view.setTransform(this) + } + } + + private fun releasePlayer() { + player?.runCatching { + if (isPlaying) stop() + release() + } + player = null + } + + override fun onDestroy() { + releasePlayer() + rootView?.removeCallbacks(timeoutRunnable) + super.onDestroy() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) applyImmersiveUi() + } + + private fun applyImmersiveUi() { + WindowCompat.setDecorFitsSystemWindows(window, false) + WindowInsetsControllerCompat(window, window.decorView).apply { + hide(WindowInsetsCompat.Type.systemBars()) + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + private fun launchMainAndFinish() { + if (launchedMain) return + launchedMain = true + rootView?.removeCallbacks(timeoutRunnable) + val launch = Intent(this, MainActivity::class.java) + intent?.let { source -> + launch.action = source.action + if (source.data != null || source.type != null) launch.setDataAndType(source.data, source.type) + source.categories?.forEach(launch::addCategory) + source.extras?.let(launch::putExtras) + source.clipData?.let(launch::setClipData) + launch.addFlags( + source.flags and ( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + ), + ) + } + launch.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + startActivity(launch) + finish() + // overrideActivityTransition is API 34 (Android 14); on 13 and below it + // throws NoSuchMethodError (crashed the splash on the Retroid). Fall back to + // the deprecated overridePendingTransition there. + if (android.os.Build.VERSION.SDK_INT >= 34) { + overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, 0, 0) + } else { + @Suppress("DEPRECATION") + overridePendingTransition(0, 0) + } + } + + private companion object { + var playedThisProcess = false + const val HARD_TIMEOUT_MS = 6000L + } +} diff --git a/android/armsx3-app/app/src/main/java/net/rpcsx/ControllerSkinStore.kt b/android/armsx3-app/app/src/main/java/net/rpcsx/ControllerSkinStore.kt new file mode 100644 index 000000000..12720a70b --- /dev/null +++ b/android/armsx3-app/app/src/main/java/net/rpcsx/ControllerSkinStore.kt @@ -0,0 +1,393 @@ +package net.rpcsx + +import net.rpcsx.utils.GeneralSettings + +import android.content.Context +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.documentfile.provider.DocumentFile +import java.io.File +import java.util.zip.ZipInputStream + +/** + * Custom on-screen controller skins (v1: visuals only). A skin is a folder (or + * .zip) of `ic_controller_