diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 859d62f5e..ae3d7e1a8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -56,6 +56,7 @@ jobs: - name: Test archive and overlay tools run: | python3 profiles/mhfu/tests/tool_security_tests.py + python3 profiles/mhfu/tests/android_packaging_tests.py python3 -m unittest discover -s scripts/ci -p 'test_*.py' - name: Run tests diff --git a/Makefile b/Makefile index 28403b6aa..56a92fa36 100644 --- a/Makefile +++ b/Makefile @@ -62,6 +62,7 @@ test: build python-test: $(PYTHON) profiles/mhfu/tests/tool_security_tests.py + $(PYTHON) profiles/mhfu/tests/android_packaging_tests.py $(PYTHON) -m unittest discover -s scripts/ci -p 'test_*.py' java-test: diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 160e9d12c..372f81781 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -326,12 +326,28 @@ cmake --build out/android-app -j 2 The first configure needs an SDL3 built for Android to find; the release script builds the pinned one (`packaging/linux/sources.sh`) into `out/release-android/deps` and points the directory at it. The overlay libraries are reused as they are as long as `include/psprecomp/` has not changed since they were built; the script refuses them otherwise, and when any is missing. +### PokeeFUC + +A build directory configured with `-DMHFU_TITLE=FUC` as well (the FUComplete build; see the [profile README](../profiles/mhfu/README.md#fucomplete-work-in-progress)) packs **PokeeFUC** instead: application id `io.github.teamgdb.pokeefuc`, *PokeeFUC* on the launcher, `pokeefuc--android-arm64.apk`. It is another app, not an update of Pokee, so both install on one phone and neither sees the other's data. The Java code is the same; `build_apk.sh` gives the APK its id with `aapt2 link --rename-manifest-package` and its name with a copy of the resources. The corpus (`generated_fuc/`) and the overlays it compiles (`overlays_fuc/`) are generated on a computer first; the FUComplete steps in the profile README make them. + +```sh +cmake -S . -B out/android-fuc -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$ANDROID_HOME/ndk/28.2.13676358/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-29 -DCMAKE_BUILD_TYPE=Release \ + -DPSPRECOMP_PROFILE=mhfu -DMHFU_TITLE=FUC -DMHFU_ANDROID_APP=ON \ + -DSDL3_DIR=/lib/cmake/SDL3 -DCMAKE_FIND_ROOT_PATH= +cmake --build out/android-fuc -j 2 +profiles/mhfu/scripts/release_android.sh out/android-fuc +``` + +The same key can sign both apps. The APK holds code recompiled from the game: a PokeeFUC APK built from your FUComplete image is for your own devices, not for publishing. + ### What the script does 1. Fetches the pinned SDL3 and the fallback font, checks their SHA-256 and builds SDL3 for Android 10 once. 2. Reconfigures the build directory as a release (`MHFU_RELEASE`, no checkout paths, `android-29`) against that SDL3 and rebuilds `libmain.so` if that changed it. 3. Checks that every overlay library is there and newer than the framework headers. -4. Packs the APK with `packaging/android/build_apk.sh`: the native libraries, SDL's Java activity and the app's own, the font, and under `assets/licenses` the third-party notices, SDL's licence, FFmpeg's LGPL with where its source is, and the font's licence. The version name is `git describe`; the version code is the number of commits, so a later release installs as an update. +4. Packs the APK with `packaging/android/build_apk.sh`: the native libraries, SDL's Java activity and the app's own, the font, and under `assets/licenses` Pokee's licence, the third-party notices, the licences of Dear ImGui, tiny-AES-c and xxHash, SDL's licence, FFmpeg's LGPL with where its source is, and the font's licence. The keystore password reaches `apksigner` through the environment, not its command line. The version name is `git describe`; the version code is the number of commits, so a later release installs as an update. 5. Signs it with the release key, verifies the signature, refuses it if it holds any game file or if a library imports a function Android 10 lacks, and prints its size and SHA-256. ### The release key diff --git a/docs/SOURCE_PROVENANCE.md b/docs/SOURCE_PROVENANCE.md index 78396525b..69552b018 100644 --- a/docs/SOURCE_PROVENANCE.md +++ b/docs/SOURCE_PROVENANCE.md @@ -47,6 +47,8 @@ Freedom Unite has one archive, so this port does not apply the manager's HD sele ## Profile code +The Android display frame rate request (`profiles/mhfu/host/platform/android_display.*`, its test, and `pacing::requested_rate`) and the safer argument, password and licence handling in `packaging/android/build_apk.sh` were ported from Yakumo's Android app at commit `5bacba3`, under the same MIT licence and by the same contributors; names were changed to this profile's. + A profile owns its generated AOT corpus, address-specific lowering, HLE behavior and native fast paths. Those files remain isolated under `profiles/` so they do not become hidden dependencies of the generic framework. ## Analog camera diff --git a/docs/TESTING.md b/docs/TESTING.md index 78f4cc144..691e1498a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -9,6 +9,7 @@ - **Image conversion** (`mhfu_image_conversion_tests`) checks oversized 16-bit PNG conversion requests before allocation, freeing rejected input, and exact grayscale-to-RGBA output. Its allocator hook keeps synthetic oversized tests below one MiB. - **Guest PCM staging** (`mhfu_guest_pcm_tests`) checks stereo/mono decoding, signed sample extrema, exact-end mapped buffers and rejection of overflowing or unmapped requests before allocation. It needs no audio device or game data. - **Archive-tool security regressions** run with `python3 profiles/mhfu/tests/tool_security_tests.py` and in the desktop CI jobs. Synthetic ISO records and overlay names exercise traversal rejection, existing symlink escapes, malformed directory records and cycles, and overlay command argument handling; they use no game data. +- **Android packaging checks** run with `python3 profiles/mhfu/tests/android_packaging_tests.py` and in the Linux and macOS CI jobs. They run `packaging/android/build_apk.sh` against stand-in SDK tools and check which app a build packs (Pokee or PokeeFUC, from `MHFU_TITLE`), that an unknown title is refused, the bundled licences, the SDK version selection, and that the keystore password is passed through the environment. They need no SDK, NDK, game data or device, and do not show that a real APK installs or runs. - **Framework tests** run with `ctest --test-dir out/framework` and need no game data. - **Python lint CI** (`Python lint (Ruff)` in `.github/workflows/tooling.yml`) runs pinned Ruff correctness checks on all first-party Python scripts, tools and tests without game data or native builds. It fails on findings and excludes vendored/generated sources, game directories, overlays and build outputs. The same local command is documented in [CONTRIBUTING.md](../CONTRIBUTING.md#python-tooling); Python formatting is not enforced. - **Unit-test CI** runs the framework and headless profile tests on standard GitHub-hosted Linux, macOS and Windows runners for pull requests, every pushed branch, and manual dispatch. The workflow in `.github/workflows/tests.yml` builds only `psprecomp_test_binaries` in Debug mode, then runs CTest with failure output and a per-test timeout. Failed jobs upload test logs and JUnit results when available. It uses no game data, generated game corpus, overlays or GPU. FFmpeg is disabled to avoid downloading and building application-only audio/video dependencies. The Vulkan descriptor-pool test is excluded from this renderer-disabled configuration. Android CI also cross-compiles the same targets with NDK 28.2 for `arm64-v8a` and `x86_64` at API 29 (Android 10). The x86_64 binaries run through ADB on a hardware-accelerated Android emulator, including the framework's synthetic code-generation checks. The runner deploys the shared C++ runtime, records each test's exit status and logs, applies a per-test timeout, and uploads diagnostics on failure. ARM64 is a compile check only. APK packaging, Java/JNI integration, Vulkan, gameplay and real Android devices still require separate testing, as does Steam Deck gameplay. diff --git a/profiles/mhfu/CMakeLists.txt b/profiles/mhfu/CMakeLists.txt index 98260d61d..b55b3fbf9 100644 --- a/profiles/mhfu/CMakeLists.txt +++ b/profiles/mhfu/CMakeLists.txt @@ -375,7 +375,7 @@ set(MHFU_PROGRAM_SOURCES if(MHFU_ANDROID_APP) add_library(Pokee SHARED ${MHFU_PROGRAM_SOURCES} host/platform/android_app.cpp host/platform/android_jni.cpp host/platform/android_documents.cpp host/platform/android_performance.cpp - host/platform/android_fatal.cpp) + host/platform/android_fatal.cpp host/platform/android_display.cpp) target_compile_definitions(Pokee PRIVATE MHFU_ANDROID_APP=1) set_target_properties(Pokee PROPERTIES OUTPUT_NAME main @@ -1110,6 +1110,27 @@ if(PSPRECOMP_BUILD_TESTS) add_test(NAME mhfu_render_tests COMMAND mhfu_render_tests) endif() +# The Android app's display frame rate request (platform/android_display), +# against a stand-in libandroid.so: it is looked up at run time, so the tests +# need neither the NDK nor a device. One process per loader scenario, since +# the lookup happens once per process. +if(UNIX) + add_executable(mhfu_android_display_tests + tests/android_display_tests.cpp host/platform/android_display.cpp) + target_include_directories(mhfu_android_display_tests PRIVATE "${MHFU_PROFILE_DIR}/host") + target_compile_definitions(mhfu_android_display_tests PRIVATE + dlopen=pokee_test_dlopen dlsym=pokee_test_dlsym) + target_link_libraries(mhfu_android_display_tests PRIVATE ${CMAKE_DL_LIBS}) + set_target_properties(mhfu_android_display_tests PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") + if(PSPRECOMP_BUILD_TESTS) + # Test names must be targets (psprecomp_test_binaries builds them). + foreach(scenario success missing_library missing_symbol rejected disabled) + add_custom_target(mhfu_android_display_${scenario} DEPENDS mhfu_android_display_tests) + add_test(NAME mhfu_android_display_${scenario} COMMAND mhfu_android_display_tests ${scenario}) + endforeach() + endif() +endif() + # Frame interpolation checks: matching draws, cuts, blending, the present # clock and the frame rate governor. add_executable(mhfu_interpolation_tests diff --git a/profiles/mhfu/README.md b/profiles/mhfu/README.md index 098587bb6..ab57a69ea 100644 --- a/profiles/mhfu/README.md +++ b/profiles/mhfu/README.md @@ -86,6 +86,8 @@ What differs from the Freedom Unite build: | Extras (analog camera, hold to gather, aspect ratio, HUD hiding, UI scaling, damage numbers, gather icon, sharpness gauge, empty item slot, give any rarity, lock-on, item box, layered armor) | on | on at FUComplete's own addresses (`host/title.hpp`), except the fast item box, which FUComplete already has | | Mods page | the community's `DATA.BIN` mods | off: FUComplete has its own file replacer (`ms0:/PSP/SAVEDATA/FUCDAT/NATIVEPSP`) | +**On Android** the FUComplete build is a separate app, **PokeeFUC** (`io.github.teamgdb.pokeefuc`), so it installs beside Pokee with its own storage and saves. An Android build directory configured with `-DMHFU_TITLE=FUC` packs it: `release_android.sh` then writes `pokeefuc--android-arm64.apk`. The phone cannot generate the corpus, so generate it on a computer first, from `EBOOT.FUC.ELF` as above, and build any overlay dumps there too; the Android build compiles the `generated_fuc/` and `overlays_fuc/` it finds in the checkout. The app then asks for the same FUComplete image on its first start. Like the Linux copy, the APK holds code recompiled from your own game: keep it to yourself. See [RELEASING.md](../../docs/RELEASING.md#pokeefuc). + The scripts (`generate.sh`, `build_overlays.sh`, `bootstrap_overlays.sh`, `add_overlay.py`) read the title from the build directory they are given; `prepare_game.sh` reads `MHFU_TITLE` from the environment. FUComplete's own loader copies `PRELOAD.BIN` and `MODLOAD.BIN` to `ms0:/PSP/SAVEDATA/FUCDAT` on the first start and runs them from there; that code is not in the corpus and runs in the interpreter. FUComplete boots to its title, menus, character creation and quests on Linux. The extras find the same code as in Freedom Unite at FUComplete's addresses: the executable's code sits a few words or 0x2FC lower (0x2948 higher in places), and game_task, lobby_task and game_sub load lower with their code. Each address has its FUComplete value beside Freedom Unite's, picked by the build's title, and each feature still checks the instructions it relies on before it acts, so a different FUComplete version leaves it off instead of writing to the wrong place. The data it reads moved as well: GameSys at 0x089C7508, the camera object at 0x089C6CB4, the entity manager at 0x09D146E0, and the HUD's textures 0x4400 lower (the item atlas at 0x091830A0). FUComplete's own name plate under the clock is 8 pixels shorter than Freedom Unite's, which UI Scaling takes into account. @@ -457,7 +459,7 @@ The menu always selects with the bottom face button (A on an Xbox pad or a Steam Every change applies at once and is saved to `settings.ini` in the per-user directory, next to the installer's `disc_image`. A setting whose environment variable is set is decided by that variable for the run: the menu shows it greyed with *Set by MHFU_…* and leaves the file's value alone. So the order is: environment variable, then `settings.ini`, then the default. -The Android app starts from other defaults where a phone differs, with the same keys and values: `video.aspect` is `fill` (a phone is wider than the PSP), `video.fullscreen` is on (there is no window) and `input.mouse` is off (a phone has no mouse to capture). Everything else starts as in the table. On Android, *Analog camera* alone decides whether a finger drag turns the camera: *Camera stick* is about a physical stick there. +The Android app starts from other defaults where a phone differs, with the same keys and values: `video.aspect` is `fill` (a phone is wider than the PSP), `video.hud_edges` is on (the quest interface goes out to the screen's sides), `video.fullscreen` is on (there is no window) and `input.mouse` is off (a phone has no mouse to capture). Everything else starts as in the table. On Android, *Analog camera* alone decides whether a finger drag turns the camera: *Camera stick* is about a physical stick there. | Section | Setting | Key in `settings.ini` | Variable | Values | | --- | --- | --- | --- | --- | @@ -474,6 +476,7 @@ The Android app starts from other defaults where a phone differs, with the same | Video | Frame rate | `video.frame_rate` | `MHFU_FRAME_RATE` | `30` (default: the game's own frames, as they are), `45`, `60`, `90`, `120` or `display` (the display's refresh rate): frames in between the game's, with blended movement. See [Frame rate](#frame-rate) | | Video | Lower when behind | `video.frame_rate_auto` | `MHFU_FRAME_RATE_AUTO` | On (default): the frame rate steps down by itself rather than slow the game. Off: the chosen rate stays, and the game may run below full speed | | Video | Hide HUD | | `MHFU_HIDE_HUD` | Off (default) or on, for this run: the game's HUD, name tags and prompts are left out of the picture; menus and dialogs stay. Same as the Hide HUD key. See [Hiding the HUD](#hiding-the-hud) | +| Video | Interface at the edges | `video.hud_edges` | `MHFU_HUD_EDGES` | Under `fill` on a screen wider than the PSP's, the quest interface goes out to the screen's sides: the clock, the bars and the party list to the left, the map and the item bar to the right. Off (the default; on for Android) keeps it in the PSP's proportions in the middle. Menus and names over hunters stay where the game draws them | | Video | UI Scaling | `video.hud.map`, `video.hud.health`, `video.hud.item_bar`, `video.hud.names` | `MHFU_HUD_MAP`, `MHFU_HUD_HEALTH`, `MHFU_HUD_ITEM_BAR`, `MHFU_HUD_NAMES` | 50–150 each, the game's size as a percent; default 100. UI Scaling opens to Map, Status, Items, and Names, each with its own size. See [UI scaling](#ui-scaling) | | Video | Performance | `video.performance` | `MHFU_PERF` | Off, overlay, overlay and log, log only, compact, compact and log. Compact is one line in the bottom-left corner: frames shown, the game's frame rate and its speed, green at full speed, yellow from 90%, red below | | Video | GPU compatibility | `video.gpu_compat` | `MHFU_GPU_COMPAT` | Auto (default: on only when the start-up self-test fails or a pipeline is refused), on or off; applies from the next start. See [GPU compatibility mode](#gpu-compatibility-mode) | @@ -993,6 +996,7 @@ The settings a player needs are in the [in-game menu](#in-game-menu). Environmen | `MHFU_FAST_FORWARD` | `hold` | `toggle` makes the Fast-forward key a switch; `off` (or `0`) makes it do nothing (menu: Fast-forward). See [Fast-forward](#fast-forward) | | `MHFU_FAST_FORWARD_SPEED` | `3` | Times real time while fast-forwarding, 2 to 8 (menu: Fast-forward speed) | | `MHFU_FRAME_RATE` | `30` | `45`, `60`, `90`, `120` or `display`: present frames in between the game's 30 (menu: Frame rate). See [Frame rate](#frame-rate) | +| `MHFU_NO_DISPLAY_RATE_REQUEST` | off | Android: do not ask the system to run the display at the Frame rate setting's rate, as before. See [Frame rate](#frame-rate) | | `MHFU_FRAME_RATE_AUTO` | on | `0` keeps the chosen frame rate even when the game falls behind (menu: Lower when behind) | | `MHFU_NO_MATERIAL_COLOR` | off | Leave unlit geometry without vertex colours white instead of taking the material colour | | `MHFU_NO_LIGHTING` | off | Draw lit geometry with the flat white stand-in used before lighting existed, and without fog, to compare a scene with and without them | @@ -1085,6 +1089,7 @@ The game makes 30 frames a second, and its logic is tied to that rate: animation - **30** shows the game's frames as they are, when the game flips them. This is the picture of earlier versions. - **45**, **60**, **90** and **120** present that many frames a second: 1.5, 2, 3 and 4 per game frame. 45 is half of a 90 Hz screen (the Steam Deck's) and 90 all of it; 120 is for desktop monitors of 120 Hz or more. - **Match display** presents at the display's refresh rate. +- On Android 11 and later the app asks the system to run the display at the chosen rate (Match display: the display's own), so a phone that switches between 60, 90 and 120 Hz can show 90 or 120. It is a wish, not a setting: the system may keep a lower rate to save power or when the phone is hot, and Android 10 has no way to ask. The log says `[render] requested Android surface frame rate`. `MHFU_NO_DISPLAY_RATE_REQUEST=1` leaves the display as it was. - With **Vsync** on, the rate is never above the display's refresh: on a 60 Hz screen 90 and 120 run at 60. The row says so (*now 60*). - If presenting that often would slow the game down, it steps down by itself to the fastest rate that fits (120, 90, 60, 45, 30), and tries a faster one again later once there is time to spare. It also steps down when the display has no image free for the presents (a screen that refreshes slower than the rate, such as a Steam Deck that gamescope switched to 60 Hz): a present waits at most 3 ms for one and is dropped otherwise. The row then shows *90 (running at 60)*. Loading screens and stalls while the game still has idle time do not count against it. **Lower when behind** (Video, on by default) turns this off: the chosen rate stays, capped only by Vsync, and the game may then run below full speed. - The picture comes later than at 30: a frame is shown as it is one game frame after its own moment less the first in-between step, 16.7 ms later than at 30 at 60, 22.2 ms at 45 and 90, 25 ms at 120. The camera is still updated once per game frame. The pad is read when the game asks for it, at every Frame rate: the keyboard, mouse buttons and gamepad as they are then, not as they were at the last flip (issue #8), which takes up to a game frame off the input's own delay. `MHFU_PAD_AT_FLIP=1` reads it at the flip, as before. diff --git a/profiles/mhfu/host/gpu/frame_pacing.cpp b/profiles/mhfu/host/gpu/frame_pacing.cpp index 74a1696c3..88e8f42e3 100644 --- a/profiles/mhfu/host/gpu/frame_pacing.cpp +++ b/profiles/mhfu/host/gpu/frame_pacing.cpp @@ -1,5 +1,7 @@ #include "frame_pacing.hpp" +#include "settings/settings.hpp" + #include #include #include @@ -16,6 +18,24 @@ constexpr std::int64_t kGapFrames = 10; } // namespace +double requested_rate(settings::FrameRate rate, double display_hz) noexcept { + switch (rate) { + case settings::FrameRate::Fps30: + return 30.0; + case settings::FrameRate::Fps45: + return 45.0; + case settings::FrameRate::Fps60: + return 60.0; + case settings::FrameRate::Fps90: + return 90.0; + case settings::FrameRate::Fps120: + return 120.0; + case settings::FrameRate::Display: + return std::isfinite(display_hz) && display_hz >= 1.0 ? std::max(30.0, display_hz) : 60.0; + } + return 30.0; +} + double presents_per_frame(double rate) noexcept { const double presents = rate / 30.0; // A display that reports 59.94 or 89.9 Hz means 2 or 3. diff --git a/profiles/mhfu/host/gpu/frame_pacing.hpp b/profiles/mhfu/host/gpu/frame_pacing.hpp index c632f95fc..aa1794c8a 100644 --- a/profiles/mhfu/host/gpu/frame_pacing.hpp +++ b/profiles/mhfu/host/gpu/frame_pacing.hpp @@ -4,6 +4,10 @@ #include #include +namespace mhfu::settings { +enum class FrameRate; +} + // When frame interpolation presents, what each present shows, and how many // presents a second the machine can afford. Independent of the renderer and // of the clock, so it can be checked on made-up timings. @@ -42,6 +46,12 @@ inline constexpr std::int64_t kGameFrameUs = 2 * 16'683; // own, without interpolation. A display rate outside these joins them. inline constexpr double kRates[] = {30.0, 45.0, 60.0, 90.0, 120.0}; +// The player's Frame rate setting as presents a second, before Vsync or the +// governor caps it: what the Android app asks the display for. Display +// follows the display's refresh rate, or 60 when it reports none; never +// below the game's own 30. +[[nodiscard]] double requested_rate(settings::FrameRate rate, double display_hz) noexcept; + // Presents per game frame at `rate` presents a second: 45 is 1.5, 90 is 3. [[nodiscard]] double presents_per_frame(double rate) noexcept; // Of those, the ones that fall on a frame's own moment and show it as it diff --git a/profiles/mhfu/host/gpu/game_hud.cpp b/profiles/mhfu/host/gpu/game_hud.cpp index 5d95ae9a3..4923f733c 100644 --- a/profiles/mhfu/host/gpu/game_hud.cpp +++ b/profiles/mhfu/host/gpu/game_hud.cpp @@ -928,6 +928,9 @@ void frame(psprecomp::Runtime &runtime, std::uint64_t presented_frames) { // menu with a panel in that piece's corner is resized with it. const settings::Settings::InterfaceScale &hud = settings::current().hud; const bool sized = hud.names != 100u || hud.map != 100u || hud.health != 100u || hud.item_bar != 100u; + // The interface at the screen's sides moves the quest HUD only, so it too + // has to tell the HUD from a menu. + const bool edges = settings::current().hud_edges && settings::current().aspect == settings::Aspect::Fill; if (s.capture.requested) { s.capture.at_flip(s.frame); std::cout << "[capture] tracing the 2D packets of frames " << s.capture.first << "-" @@ -935,7 +938,7 @@ void frame(psprecomp::Runtime &runtime, std::uint64_t presented_frames) { << s.capture.first + 1u << "-" << s.capture.first + CaptureSchedule::kFrames << std::endl; } const bool wanted = - s.hidden || s.trace_first != ~0ull || s.capture.pending() || sized || s.names_wanted || s.cover_wanted; + s.hidden || s.trace_first != ~0ull || s.capture.pending() || sized || edges || s.names_wanted || s.cover_wanted; if (!s.installed && wanted && s.available) { s.installed = true; if (!install_all(runtime, std::make_index_sequence{})) { diff --git a/profiles/mhfu/host/gpu/hud_scale.hpp b/profiles/mhfu/host/gpu/hud_scale.hpp index e8e78ca10..c87a7097b 100644 --- a/profiles/mhfu/host/gpu/hud_scale.hpp +++ b/profiles/mhfu/host/gpu/hud_scale.hpp @@ -239,6 +239,74 @@ struct InterfaceFit { } }; +// Under Fill on a screen wider than the PSP's, the fit leaves the quest +// interface in a PSP-shaped box in the middle. With video.hud_edges each +// piece goes out to its own side of the screen instead, by the gap the fit +// left there: the clock, the bars and the party list to the left, the map +// and the item bar to the right. What sits in the middle, such as a notice +// across the screen, stays. A piece is placed by the game's rectangle of +// each quad, before any interface size, so a bar's end cap past the middle +// stays with its bar. +enum class EdgeSide { Centre, Left, Right }; + +[[nodiscard]] inline EdgeSide edge_side(Rect game) { + const Rect r = normalized(game); + // The map, its marks and the window shown as a monster is found. + if (r.x0 >= kMapLeft - 48.0f && r.y0 >= kMapTop - 8.0f && r.y1 <= kMapBottom + 16.0f) return EdgeSide::Right; + // Health and stamina with their end caps, which reach past the middle. + if (r.x0 >= kVitalLeft - 4.0f && r.y1 <= kVitalBottom + 1.0f) return EdgeSide::Left; + // The item bar, its name, and a bowgun's ammo or a bow's coatings above it. + if (r.x0 >= kItemLeft - 16.0f && r.y0 >= kBowgunTop) return EdgeSide::Right; + const float centre = 0.5f * (r.x0 + r.x1); + if (centre < 176.0f) return EdgeSide::Left; + if (centre > 304.0f) return EdgeSide::Right; + return EdgeSide::Centre; +} + +// How far a piece moves to its side, in the game's 480 pixels: the gap +// between the fitted interface and the screen's edge. 0 when the screen is +// not wider than the PSP's. +[[nodiscard]] inline float edge_gap(InterfaceFit fit) { + return fit.x > 0.0f && fit.x < 1.0f ? InterfaceFit::kCentreX * (1.0f - fit.x) : 0.0f; +} + +// What place_at_edges did. `uniform`: every quad moved by `dx`, so the +// draw's scissor can move with it; otherwise its quads went different ways +// and `bounds` is where they landed. +struct EdgeMove { + bool moved{}; + bool uniform{}; + float dx{}; + Rect bounds{}; +}; + +// Moves each quad of `placed` (six vertices each) to its side, judged by the +// same quad of `unscaled`, the draw as the game made it before the interface +// sizes, in fitted pixels. +template +EdgeMove place_at_edges(std::vector &placed, const std::vector &unscaled, InterfaceFit fit) { + EdgeMove move; + const float gap = edge_gap(fit); + if (gap <= 0.0f || placed.size() != unscaled.size() || placed.size() < 6u) return move; + bool first_quad = true; + move.uniform = true; + for (std::size_t first = 0; first + 6u <= placed.size(); first += 6u) { + const EdgeSide side = edge_side(fit.to_game(quad_bounds(unscaled, first))); + const float dx = side == EdgeSide::Left ? -gap : side == EdgeSide::Right ? gap : 0.0f; + if (first_quad) { + move.dx = dx; + first_quad = false; + } else if (dx != move.dx) { + move.uniform = false; + } + if (dx == 0.0f) continue; + move.moved = true; + for (std::size_t i = 0; i < 6u; ++i) placed[first + i].x += dx; + } + if (move.moved) move.bounds = bounds_of(placed); + return move; +} + // The large area-map tile, (316, 32)-(468, 184). The small window is // travelling_map_window(); it counts only while the quest HUD is up and the // quad samples kAreaMapTexture. The shop and the return-to-town prompt draw diff --git a/profiles/mhfu/host/gpu/vulkan_renderer.cpp b/profiles/mhfu/host/gpu/vulkan_renderer.cpp index 94bc6f203..8a0f75c13 100644 --- a/profiles/mhfu/host/gpu/vulkan_renderer.cpp +++ b/profiles/mhfu/host/gpu/vulkan_renderer.cpp @@ -38,6 +38,7 @@ #include "settings/settings.hpp" #if defined(MHFU_ANDROID_APP) +#include "platform/android_display.hpp" #include "platform/android_fatal.hpp" #include "platform/android_jni.hpp" #endif @@ -2631,6 +2632,7 @@ struct VulkanRenderer::Impl { bool create_overlay(std::string &error); void record_overlay(VkCommandBuffer commands, VkImage destination); void update_display_info(); + void request_display_rate(); // `overwritten`: bit 0, the pass's first draw writes every pixel's colour // and alpha; bit 1, every pixel's depth. void begin_pass(std::uint32_t address, std::uint32_t overwritten = 0u); @@ -3674,6 +3676,22 @@ void VulkanRenderer::Impl::update_display_info() { } display_hz = refresh; perf::set_display_info(present_mode_name(present_mode), swapchain_extent.width, swapchain_extent.height, refresh); + request_display_rate(); +} + +// Asks Android to run the display at the Frame rate setting's rate, so a +// phone that can switch between 60, 90 and 120 Hz shows 90 or 120 presents a +// second instead of holding at its default. A wish, not a mode change: the +// system may keep another rate for power or heat. Android 10 has no way to +// ask, and MHFU_NO_DISPLAY_RATE_REQUEST leaves the display as it was. +void VulkanRenderer::Impl::request_display_rate() { +#if defined(MHFU_ANDROID_APP) + auto *native_window = static_cast( + SDL_GetPointerProperty(SDL_GetWindowProperties(window), SDL_PROP_WINDOW_ANDROID_WINDOW_POINTER, nullptr)); + const auto rate = static_cast(pacing::requested_rate(frame_rate, static_cast(display_hz))); + if (android::request_display_frame_rate(native_window, rate)) + std::cout << "[render] requested Android surface frame rate " << rate << " Hz\n"; +#endif } VkPresentModeKHR VulkanRenderer::Impl::wanted_present_mode() const { @@ -8704,6 +8722,25 @@ void VulkanRenderer::submit(const DrawCall &call, const GuestMemory &memory) { } } } + // video.hud_edges: under Fill on a wide screen, each quest HUD piece goes + // out to its side of the screen (hud_scale::place_at_edges), after its + // size and the sharpness cut. Only game_task's own draws move: a menu, a + // name over a hunter, or a draw before the HUD can be told apart stays. + hud_scale::EdgeMove edge_move; + if (fit.any() && through_quads && !named.is_name && framebuffer_source.target == nullptr && + impl.shows(call.target.color_address) && settings::current().hud_edges) { + const hud::Owner edge_owner = + hud_owner != hud::Owner::Unknown ? hud_owner : hud::owner_of(call.command_address, call.call_return); + if (edge_owner == hud::Owner::QuestHud) { + const std::vector &unscaled = reused_expanded ? *reused : impl.scratch; + std::vector edged = scale_corners ? corner_scaled : unscaled; + edge_move = hud_scale::place_at_edges(edged, unscaled, fit); + if (edge_move.moved) { + corner_scaled = std::move(edged); + scale_corners = true; + } + } + } // Timed by hand: the variables this part sets are used after it, and a // draw that runs out of frame space returns from inside it untimed. const bool split_vertices = perf::split_enabled(); @@ -9092,6 +9129,18 @@ void VulkanRenderer::submit(const DrawCall &call, const GuestMemory &memory) { static_cast(sx1), static_cast(sy1), static_cast(sx2 + 1u), static_cast(sy2 + 1u)}; fit.apply(edges[0], edges[1]); fit.apply(edges[2], edges[3]); + // A draw moved to one side takes its scissor and its map with it; one whose + // quads went different ways has its scissor take in where they landed. + if (edge_move.moved && edge_move.uniform) { + edges[0] += edge_move.dx; + edges[2] += edge_move.dx; + if (map_bounds) { + map_bounds->x0 += edge_move.dx; + map_bounds->x1 += edge_move.dx; + } + } else if (edge_move.moved) { + hud_scale::include_in_scissor(edges[0], edges[1], edges[2], edges[3], edge_move.bounds); + } if (map_bounds) hud_scale::include_in_scissor(edges[0], edges[1], edges[2], edges[3], *map_bounds); if (name_bounds) hud_scale::include_in_scissor(edges[0], edges[1], edges[2], edges[3], *name_bounds); const auto to_pixels = [](float edge, float scale, std::uint32_t size) { @@ -9420,27 +9469,7 @@ std::int64_t to_us(std::chrono::steady_clock::time_point time) { } // namespace double VulkanRenderer::Impl::wanted_rate() const { - double rate = 30.0; - switch (frame_rate) { - case settings::FrameRate::Fps30: - rate = 30.0; - break; - case settings::FrameRate::Fps45: - rate = 45.0; - break; - case settings::FrameRate::Fps60: - rate = 60.0; - break; - case settings::FrameRate::Fps90: - rate = 90.0; - break; - case settings::FrameRate::Fps120: - rate = 120.0; - break; - case settings::FrameRate::Display: - rate = display_hz >= 1.0f ? static_cast(display_hz) : 60.0; - break; - } + double rate = pacing::requested_rate(frame_rate, static_cast(display_hz)); // With vsync the display takes no more than its own rate: more presents // would wait for it with the game's time. if (present_mode == VK_PRESENT_MODE_FIFO_KHR && display_hz >= 1.0f) @@ -10510,6 +10539,7 @@ void VulkanRenderer::set_frame_rate(settings::FrameRate rate) { if (impl.frame_rate == rate) return; impl.frame_rate = rate; impl.governor.set_requested(rate == settings::FrameRate::Fps30 ? 30.0 : impl.wanted_rate()); + impl.request_display_rate(); impl.reset_interpolation(); perf::set_frame_rate_info(rate == settings::FrameRate::Fps30 ? 0.0 : impl.governor.rate(), rate == settings::FrameRate::Fps30 ? 0.0 : impl.governor.requested()); diff --git a/profiles/mhfu/host/platform/android_display.cpp b/profiles/mhfu/host/platform/android_display.cpp new file mode 100644 index 000000000..97157a5b3 --- /dev/null +++ b/profiles/mhfu/host/platform/android_display.cpp @@ -0,0 +1,34 @@ +#include "android_display.hpp" + +#include + +#include +#include +#include + +namespace mhfu::android { +namespace { + +// ANativeWindow_setFrameRate is available from API 30. Resolve it at run +// time so the same APK still loads on Android 10 (API 29). Signature and +// compatibility value follow the Android NDK native_window.h contract. +using SetRate = std::int32_t (*)(ANativeWindow *, float, std::int8_t); + +SetRate load_set_rate() { + void *library = dlopen("libandroid.so", RTLD_NOW | RTLD_LOCAL); + if (library == nullptr) return nullptr; + // Keep the library loaded while its function pointer is in use. + return reinterpret_cast(dlsym(library, "ANativeWindow_setFrameRate")); +} + +} // namespace + +bool request_display_frame_rate(ANativeWindow *window, float rate) { + if (window == nullptr || !std::isfinite(rate) || rate <= 0.0f) return false; + if (std::getenv("MHFU_NO_DISPLAY_RATE_REQUEST") != nullptr) return false; + static const SetRate set_rate = load_set_rate(); + // DEFAULT compatibility is for games, unlike FIXED_SOURCE for video. + return set_rate != nullptr && set_rate(window, rate, 0) == 0; +} + +} // namespace mhfu::android diff --git a/profiles/mhfu/host/platform/android_display.hpp b/profiles/mhfu/host/platform/android_display.hpp new file mode 100644 index 000000000..ba9fdf868 --- /dev/null +++ b/profiles/mhfu/host/platform/android_display.hpp @@ -0,0 +1,12 @@ +#pragma once + +struct ANativeWindow; + +namespace mhfu::android { + +// A preference for this surface, not a system display setting. Android may +// choose a different rate for power, thermal or display-policy reasons. +// Returns false when unavailable (including Android 10) or rejected. +[[nodiscard]] bool request_display_frame_rate(ANativeWindow *window, float rate); + +} // namespace mhfu::android diff --git a/profiles/mhfu/host/settings/settings.cpp b/profiles/mhfu/host/settings/settings.cpp index 883f9383a..09e10a836 100644 --- a/profiles/mhfu/host/settings/settings.cpp +++ b/profiles/mhfu/host/settings/settings.cpp @@ -227,6 +227,10 @@ const std::vector &fields() { [](const Settings &s) { return std::string(s.aspect == Aspect::Stretch ? "0" : "1"); }, nullptr}, {"video.aspect", nullptr, [](Settings &s, const std::string &t) { return kAspects.parse(t, s.aspect); }, [](const Settings &s) { return kAspects.format(s.aspect); }, nullptr}, + {"video.hud_edges", "MHFU_HUD_EDGES", + [](Settings &s, const std::string &t) { return parse_bool(t, s.hud_edges); }, + [](const Settings &s) { return std::string(s.hud_edges ? "1" : "0"); }, + [](Settings &s, const char *t) { s.hud_edges = variable_flag(t); }}, BOOL_FIELD("video.sharp_screen", sharp_screen), BOOL_FIELD("video.sharp_textures", sharp_textures), {"video.texture_pack", "MHFU_TEXTURE_PACK", @@ -1045,6 +1049,7 @@ Settings defaults_for(Platform platform) { Settings values{}; if (platform == Platform::Android) { values.aspect = Aspect::Fill; + values.hud_edges = true; values.fullscreen = true; values.mouse = false; } diff --git a/profiles/mhfu/host/settings/settings.hpp b/profiles/mhfu/host/settings/settings.hpp index 822e0d68f..0a9f82a66 100644 --- a/profiles/mhfu/host/settings/settings.hpp +++ b/profiles/mhfu/host/settings/settings.hpp @@ -94,6 +94,10 @@ struct Settings { std::uint32_t names{100u}; }; InterfaceScale hud{}; + // Under Fill on a screen wider than the PSP's, the quest interface's pieces + // at the screen's sides instead of in a PSP-shaped box in the middle + // (gpu/hud_scale.hpp, place_at_edges). + bool hud_edges{}; // Text std::string font; // the game's text font: path, "#face" for a collection; empty: the default @@ -296,6 +300,7 @@ inline constexpr Platform kPlatform = Platform::Desktop; #endif // The defaults on `platform`: Desktop is Settings{} as declared above; // Android differs in video.aspect (fill: a phone is wider than the PSP), +// video.hud_edges (on: the interface goes out to a phone's wide sides), // video.fullscreen (on: there is no window) and input.mouse (off: a phone // has no mouse to capture, and an emulator's pointer would turn the camera). [[nodiscard]] Settings defaults_for(Platform platform); diff --git a/profiles/mhfu/host/ui/menu.cpp b/profiles/mhfu/host/ui/menu.cpp index 67f7759a4..337f0cd85 100644 --- a/profiles/mhfu/host/ui/menu.cpp +++ b/profiles/mhfu/host/ui/menu.cpp @@ -395,6 +395,18 @@ void Menu::video() { renderer().set_aspect(s.aspect); settings::save(); } + RowOptions edges = options_for("video.hud_edges", + "On a screen wider than the PSP's, the quest interface goes out to the screen's sides: the clock, " + "the bars and the party list to the left, the map and the item bar to the right. Off keeps it in the " + "PSP's proportions in the middle. Menus stay where the game draws them."); + if (s.aspect != settings::Aspect::Fill) { + edges.disabled = true; + edges.note = "Fill only"; + } + if (toggle_row("Interface at the edges", s.hud_edges, edges)) { + s.hud_edges = !s.hud_edges; + settings::save(); + } } if (choice_row("Scaling filter", s.sharp_screen ? "Sharp" : "Smooth", options_for("video.sharp_screen", @@ -638,6 +650,7 @@ void Menu::video() { restore("video.fullscreen", s.fullscreen, d.fullscreen); restore("video.window_scale", s.window_scale, d.window_scale); restore("video.aspect", s.aspect, d.aspect); + restore("video.hud_edges", s.hud_edges, d.hud_edges); restore("video.sharp_screen", s.sharp_screen, d.sharp_screen); restore("video.sharp_textures", s.sharp_textures, d.sharp_textures); restore("video.ui_textures", s.ui_textures, d.ui_textures); diff --git a/profiles/mhfu/packaging/android/AndroidManifest.xml b/profiles/mhfu/packaging/android/AndroidManifest.xml index ebfbb12ef..82bede2df 100644 --- a/profiles/mhfu/packaging/android/AndroidManifest.xml +++ b/profiles/mhfu/packaging/android/AndroidManifest.xml @@ -3,7 +3,10 @@ loads libSDL3.so and libmain.so, the host, which dlopens the overlay libraries from the same directory: extractNativeLibs keeps that directory a real one on the device, which the overlay loader lists. The version name - and code come from the build (build_apk.sh). --> + and code come from the build (build_apk.sh), and so does the application + id: build_apk.sh renames the package to io.github.teamgdb.pokeefuc for a + FUComplete build (MHFU_TITLE=FUC). The activity is named in full, so its + class is found under either id. --> @@ -24,7 +27,7 @@ android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules" android:hardwareAccelerated="true"> - /dev/null | head -1 | tr '[:lower:]' '[:upper:]')" +case "${title:-FU}" in + FU) package=io.github.teamgdb.pokee; label=Pokee ;; + FUC) package=io.github.teamgdb.pokeefuc; label=PokeeFUC ;; + *) echo "error: unknown MHFU_TITLE '$title' in $build_dir" >&2; exit 1 ;; +esac sdk="${ANDROID_HOME:?set ANDROID_HOME to the Android SDK}" -build_tools="$(ls -d "$sdk"/build-tools/* | sort -V | tail -1)" -platform="$(ls -d "$sdk"/platforms/android-* | sort -V | tail -1)" +if [[ -n "${ANDROID_BUILD_TOOLS_VERSION:-}" ]]; then + [[ "$ANDROID_BUILD_TOOLS_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || + { echo 'error: invalid Android build-tools version' >&2; exit 1; } + build_tools="$sdk/build-tools/$ANDROID_BUILD_TOOLS_VERSION" +else + build_tools="$(ls -d "$sdk"/build-tools/* | sort -V | tail -1)" +fi +if [[ -n "${ANDROID_COMPILE_SDK_VERSION:-}" ]]; then + [[ "$ANDROID_COMPILE_SDK_VERSION" =~ ^[0-9]+$ ]] || { echo 'error: invalid Android compile SDK version' >&2; exit 1; } + platform="$sdk/platforms/android-$ANDROID_COMPILE_SDK_VERSION" +else + platform="$(ls -d "$sdk"/platforms/android-* | sort -V | tail -1)" +fi android_jar="$platform/android.jar" +[[ -d "$build_tools" && -f "$android_jar" ]] || { echo 'error: the Android SDK components are not installed' >&2; exit 1; } work="$build_dir/apk" rm -rf "$work" mkdir -p "$work/classes" "$work/dex" "$work/lib/arm64-v8a" echo "compiling SDL's Java activity and the app's own" +# Arrays, not word splitting, so a checkout path with a space still builds. +java_sources=() +while IFS= read -r -d '' file; do java_sources+=("$file"); done < <( + find "$sdl_dir/android-project/app/src/main/java" "$here/java" -name '*.java' -print0) +# (Counted before use: bash 3.2, macOS's, takes an empty array for unset.) +[[ ${#java_sources[@]} -gt 0 ]] || { echo "error: no Java sources under $sdl_dir or $here/java" >&2; exit 1; } javac -nowarn --release 11 -classpath "$android_jar" -d "$work/classes" \ - $(find "$sdl_dir/android-project/app/src/main/java" "$here/java" -name '*.java') 2> "$work/javac.log" + "${java_sources[@]}" 2> "$work/javac.log" min_sdk=29 +class_files=() +while IFS= read -r -d '' file; do class_files+=("$file"); done < <(find "$work/classes" -name '*.class' -print0) +[[ ${#class_files[@]} -gt 0 ]] || { echo "error: javac made no classes; see $work/javac.log" >&2; exit 1; } "$build_tools/d8" --release --min-api "$min_sdk" --lib "$android_jar" --output "$work/dex" \ - $(find "$work/classes" -name '*.class') + "${class_files[@]}" echo "linking resources" -# The licences of what the APK carries: the third-party notices, SDL's and -# FFmpeg's (with where its source is), and the font's when it is packed. +# The licences of what the APK carries: Pokee's own, the third-party notices +# and the licences of the bundled libraries, SDL's and FFmpeg's (with where +# its source is), and the font's when it is packed. mkdir -p "$work/assets/licenses" +cp "$repo/LICENSE" "$work/assets/licenses/Pokee-LICENSE.txt" cp "$here/../THIRD_PARTY_NOTICES.md" "$work/assets/licenses/" +cp "$repo/profiles/mhfu/third_party/imgui/LICENSE.txt" "$work/assets/licenses/DearImGui-LICENSE.txt" +cp "$repo/profiles/mhfu/third_party/tiny_aes/UNLICENSE" "$work/assets/licenses/tiny-AES-c-UNLICENSE.txt" +cp "$repo/profiles/mhfu/third_party/xxhash/LICENSE" "$work/assets/licenses/xxHash-LICENSE.txt" cp "$sdl_dir/LICENSE.txt" "$work/assets/licenses/SDL3-LICENSE.txt" cp "$build_dir"/bin/lib/FFmpeg-COPYING.LGPLv2.1.txt "$build_dir"/bin/lib/FFmpeg-SOURCE.txt "$work/assets/licenses/" if [[ -n "${FONT_DIR:-}" ]]; then @@ -64,15 +108,22 @@ if [[ -n "${FONT_DIR:-}" ]]; then cp "$FONT_DIR/NotoSansCJK-LICENSE.txt" "$work/assets/licenses/NotoSansCJK-OFL.txt" fi assets=(-A "$work/assets") -"$build_tools/aapt2" compile --dir "$here/res" -o "$work/res.zip" +# The resources with the title's name on the launcher. +cp -R "$here/res" "$work/res" +sed "s|[^<]*|$label|" \ + "$here/res/values/strings.xml" > "$work/res/values/strings.xml" +grep -q "$label" "$work/res/values/strings.xml" || + { echo 'error: res/values/strings.xml has no app_name to set' >&2; exit 1; } +# The icons are made already (make_icons.sh); crunching them again only +# changes their bytes. +"$build_tools/aapt2" compile --no-crunch --dir "$work/res" -o "$work/res.zip" # The version: `git describe` of the checkout, and the number of commits as # the code Android compares, so a later build is always an update # (VERSION_NAME and VERSION_CODE override them). -repo="$(cd "$here/../../../.." && pwd)" version_name="${VERSION_NAME:-$(git -C "$repo" describe --tags --always --dirty 2>/dev/null || echo 0.0)}" version_code="${VERSION_CODE:-$(git -C "$repo" rev-list --count HEAD 2>/dev/null || echo 1)}" "$build_tools/aapt2" link -I "$android_jar" --manifest "$here/AndroidManifest.xml" \ - --min-sdk-version "$min_sdk" --target-sdk-version 35 --version-name "$version_name" \ + --rename-manifest-package "$package" --min-sdk-version "$min_sdk" --target-sdk-version 35 --version-name "$version_name" \ --version-code "$version_code" ${DEBUGGABLE:+--debug-mode} "${assets[@]}" -o "$work/unsigned.apk" \ "$work/res.zip" @@ -88,7 +139,9 @@ strip="$(ls -d "$ANDROID_NDK"/toolchains/llvm/prebuilt/*/bin/llvm-strip | head - "$strip" --strip-unneeded "$lib"/*.so # extractNativeLibs is on, so the libraries may be compressed: the package # manager unpacks them at install time. -(cd "$work" && cp dex/classes.dex . && zip -q unsigned.apk classes.dex && zip -q -r -9 unsigned.apk lib) +# Without directory records (-D): apksigner drops them, and the signed APK +# then differs from the unsigned one in more than its signature. +(cd "$work" && cp dex/classes.dex . && zip -q unsigned.apk classes.dex && zip -q -D -r -9 unsigned.apk lib) echo "aligning and signing" keystore="${KEYSTORE:-$build_dir/apk-research.keystore}" @@ -100,6 +153,8 @@ if [[ ! -f "$keystore" ]]; then -keyalg RSA -keysize 2048 -validity 3650 -dname "CN=Pokee research build" > /dev/null 2>&1 fi "$build_tools/zipalign" -f -P 16 4 "$work/unsigned.apk" "$work/aligned.apk" -"$build_tools/apksigner" sign --ks "$keystore" --ks-pass "pass:$password" --key-pass "pass:$password" \ - --ks-key-alias "$alias" --out "$output" "$work/aligned.apk" -echo "wrote $output ($(du -h "$output" | cut -f1), ${#overlays[@]} overlay libraries, version $version_name ($version_code))" +export POKEE_APK_KEY_PASSWORD="$password" +"$build_tools/apksigner" sign --ks "$keystore" --ks-pass env:POKEE_APK_KEY_PASSWORD \ + --key-pass env:POKEE_APK_KEY_PASSWORD --ks-key-alias "$alias" --out "$output" "$work/aligned.apk" +unset POKEE_APK_KEY_PASSWORD password +echo "wrote $output ($label, $package, $(du -h "$output" | cut -f1), ${#overlays[@]} overlay libraries, version $version_name ($version_code))" diff --git a/profiles/mhfu/scripts/release_android.sh b/profiles/mhfu/scripts/release_android.sh index fb682225d..c7944d830 100755 --- a/profiles/mhfu/scripts/release_android.sh +++ b/profiles/mhfu/scripts/release_android.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash -# Package an Android release: a signed APK for arm64-v8a phones. +# Package an Android release: a signed APK for arm64-v8a phones. A build +# configured with -DMHFU_TITLE=FUC packs PokeeFUC (pokeefuc-*.apk, its own +# app beside Pokee); otherwise it is Pokee (pokee-*.apk). # # release_android.sh [--version VERSION] [--jobs N] # @@ -74,7 +76,11 @@ fi # The code Android compares: the number of commits, which only grows along # the history releases are made from. version_code="$(git -C "$repo_dir" rev-list --count HEAD)" -name="pokee-$version-android-arm64" +case "$(cached MHFU_TITLE | tr '[:lower:]' '[:upper:]')" in + ""|FU) name="pokee-$version-android-arm64" ;; + FUC) name="pokeefuc-$version-android-arm64" ;; + *) echo "error: unknown MHFU_TITLE in $build_dir" >&2; exit 1 ;; +esac work="${POKEE_WORK:-$repo_dir/out/release-android}" dist="$work/dist" diff --git a/profiles/mhfu/tests/android_display_tests.cpp b/profiles/mhfu/tests/android_display_tests.cpp new file mode 100644 index 000000000..ac2e80aa1 --- /dev/null +++ b/profiles/mhfu/tests/android_display_tests.cpp @@ -0,0 +1,90 @@ +// Android's surface-rate ABI and graceful fallback, without a device or NDK. +#include "platform/android_display.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +std::string scenario; +unsigned opens{}, lookups{}, requests{}; +ANativeWindow *received_window{}; +float received_rate{}; + +void require(bool condition, const char *message) { + if (!condition) throw std::runtime_error(message); +} + +// Match the system loader's nonthrowing ABI; contract violations fail the test. +void require_loader(bool condition, const char *message) noexcept { + if (condition) return; + std::fprintf(stderr, "%s\n", message); + std::abort(); +} + +std::int32_t set_rate(ANativeWindow *window, float rate, std::int8_t compatibility) noexcept { + require_loader(compatibility == 0, "games use DEFAULT frame-rate compatibility"); + ++requests; + received_window = window; + received_rate = rate; + return scenario == "rejected" ? -22 : 0; +} +} // namespace + +extern "C" void *pokee_test_dlopen(const char *name, int flags) noexcept(noexcept(dlopen(nullptr, 0))) { + require_loader(std::strcmp(name, "libandroid.so") == 0, "load the system Android library"); + require_loader(flags == (RTLD_NOW | RTLD_LOCAL), "keep library symbols local"); + ++opens; + return scenario == "missing_library" ? nullptr : reinterpret_cast(1); +} + +extern "C" void *pokee_test_dlsym(void *library, const char *name) noexcept(noexcept(dlsym(nullptr, ""))) { + require_loader(library == reinterpret_cast(1), "look up in the opened Android library"); + require_loader(std::strcmp(name, "ANativeWindow_setFrameRate") == 0, "resolve the API 30 symbol"); + ++lookups; + return scenario == "missing_symbol" ? nullptr : reinterpret_cast(set_rate); +} + +int main(int argc, char **argv) { + try { + require(argc == 2, "one isolated loader scenario is required"); + scenario = argv[1]; + unsetenv("MHFU_NO_DISPLAY_RATE_REQUEST"); + auto *first = reinterpret_cast(1); + auto *second = reinterpret_cast(2); + using mhfu::android::request_display_frame_rate; + require(!request_display_frame_rate(nullptr, 120.0f), "missing surface is safe"); + for (float invalid : + {0.0f, -1.0f, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) + require(!request_display_frame_rate(first, invalid), "invalid target is rejected"); + require(opens == 0 && requests == 0, "invalid requests never load or call an Android API"); + if (scenario == "disabled") { + setenv("MHFU_NO_DISPLAY_RATE_REQUEST", "1", 1); + require(!request_display_frame_rate(first, 120.0f), "comparison switch restores the old behavior"); + require(opens == 0, "disabled request does not initialize the API"); + unsetenv("MHFU_NO_DISPLAY_RATE_REQUEST"); + } + const bool available = scenario == "success" || scenario == "disabled"; + require(request_display_frame_rate(first, 120.0f) == available, "API failure is nonfatal"); + require(request_display_frame_rate(second, 90.0f) == available, "updated target applies to the new surface"); + require(opens == 1, "resolve the API once per process"); + require(lookups == (scenario == "missing_library" ? 0u : 1u), "missing Android library is safe"); + if (available || scenario == "rejected") { + require(requests == 2 && received_window == second && received_rate == 90.0f, + "pass the exact surface and current target through the NDK ABI"); + } else { + require(requests == 0, "Android 10 without this symbol never calls it"); + } + std::cout << "Android display-rate contracts passed: " << scenario << '\n'; + } catch (const std::exception &error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/profiles/mhfu/tests/android_packaging_tests.py b/profiles/mhfu/tests/android_packaging_tests.py new file mode 100644 index 000000000..dbf4e036f --- /dev/null +++ b/profiles/mhfu/tests/android_packaging_tests.py @@ -0,0 +1,180 @@ +"""build_apk.sh against stand-in SDK tools: which app a build packs, and how +it hands the signing password over. No SDK, NDK, game data or device.""" + +import os +import shutil +import stat +import subprocess +import tempfile +import sys +import unittest +from pathlib import Path + +PROFILE = Path(__file__).resolve().parents[1] +BUILD_APK = PROFILE / "packaging" / "android" / "build_apk.sh" + +# Each stand-in records its arguments, one per line, then does the least its +# real counterpart does that the script depends on: aapt2 compile and link, +# zipalign and apksigner write their output file; apksigner also records the +# password it was given through the environment. +TOOL = """#!/usr/bin/env bash +{{ printf '%s\\n' "$(basename "$0")"; printf '%s\\n' "$@"; printf -- '--\\n'; }} >> "{log}" +out="" +prev="" +for arg in "$@"; do + [[ "$prev" == -o || "$prev" == --out || "$prev" == --output ]] && out="$arg" + prev="$arg" +done +case "$(basename "$0")" in + aapt2) + if [[ "$1" == compile ]]; then + dir=""; prev="" + for arg in "$@"; do [[ "$prev" == --dir ]] && dir="$arg"; prev="$arg"; done + cp "$dir/values/strings.xml" "{root}/strings.xml" + fi + python3 -c 'import sys, zipfile; zipfile.ZipFile(sys.argv[1], "w").close()' "$out" ;; + zipalign) cp "${{@: -2:1}}" "${{@: -1}}" ;; + apksigner) printf '%s\\n' "$POKEE_APK_KEY_PASSWORD" > "{root}/password"; cp "${{@: -1}}" "$out" ;; + d8) mkdir -p "$out"; touch "$out/classes.dex" ;; + javac) + dir=""; prev="" + for arg in "$@"; do [[ "$prev" == -d ]] && dir="$arg"; prev="$arg"; done + touch "$dir/Stub.class" ;; +esac +""" + + +def executable(path, text): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +@unittest.skipIf(sys.platform == "win32", "build_apk.sh runs on Linux and macOS") +class BuildApk(unittest.TestCase): + def setUp(self): + self.root = Path(tempfile.mkdtemp(prefix="pokee-apk-")) + self.addCleanup(shutil.rmtree, self.root) + self.log = self.root / "tools.log" + tool = TOOL.format(log=self.log, root=self.root) + self.sdk = self.root / "sdk" + for name in ("aapt2", "d8", "zipalign", "apksigner"): + executable(self.sdk / "build-tools" / "35.0.0" / name, tool) + (self.sdk / "platforms" / "android-35").mkdir(parents=True) + (self.sdk / "platforms" / "android-35" / "android.jar").write_bytes(b"") + self.bin = self.root / "bin" + executable(self.bin / "javac", tool) + executable(self.bin / "keytool", "#!/bin/sh\nexit 1\n") + self.ndk = self.root / "ndk" + executable(self.ndk / "toolchains" / "llvm" / "prebuilt" / "linux-x86_64" / "bin" / "llvm-strip", + "#!/bin/sh\nexit 0\n") + self.sdl = self.root / "sdl" + java = self.sdl / "android-project" / "app" / "src" / "main" / "java" + java.mkdir(parents=True) + (java / "SDLActivity.java").write_text("class SDLActivity {}\n") + (self.sdl / "LICENSE.txt").write_text("zlib\n") + self.build = self.root / "build dir" + for name in ("bin/libmain.so", "bin/lib/libavcodec.so", "bin/lib/libavutil.so", + "bin/lib/FFmpeg-COPYING.LGPLv2.1.txt", "bin/lib/FFmpeg-SOURCE.txt", + "bin/overlays/libovl_a.so", "bin/overlays/libovl_b.so"): + (self.build / name).parent.mkdir(parents=True, exist_ok=True) + (self.build / name).write_bytes(b"\x7fELF") + (self.root / "libSDL3.so").write_bytes(b"\x7fELF") + (self.root / "key.keystore").write_bytes(b"key") + + def cache(self, title): + lines = ["MHFU_ANDROID_APP:BOOL=ON"] + if title is not None: + lines.append(f"MHFU_TITLE:STRING={title}") + (self.build / "CMakeCache.txt").write_text("\n".join(lines) + "\n") + + def pack(self, title, **extra): + self.cache(title) + env = {k: v for k, v in os.environ.items() if not k.startswith(("KEYSTORE", "KEY_", "ANDROID_"))} + env.update(ANDROID_HOME=str(self.sdk), ANDROID_NDK=str(self.ndk), PATH=f"{self.bin}:{env['PATH']}", + KEYSTORE=str(self.root / "key.keystore"), KEYSTORE_PASS="s3cret pass", KEY_ALIAS="release", + VERSION_NAME="1.0", VERSION_CODE="7") + env.update(extra) + return subprocess.run(["bash", str(BUILD_APK), str(self.build), str(self.sdl), + str(self.root / "libSDL3.so"), str(self.root / "out.apk")], + env=env, capture_output=True, text=True) + + def calls(self, tool): + entries = self.log.read_text().split("--\n") if self.log.exists() else [] + return [e.splitlines()[1:] for e in entries if e.splitlines() and e.splitlines()[0] == tool] + + def link_args(self): + link = [c for c in self.calls("aapt2") if c and c[0] == "link"] + self.assertEqual(len(link), 1) + return link[0] + + def label(self): + return (self.root / "strings.xml").read_text() + + def test_freedom_unite_is_pokee(self): + result = self.pack("FU") + self.assertEqual(result.returncode, 0, result.stderr) + args = self.link_args() + self.assertEqual(args[args.index("--rename-manifest-package") + 1], "io.github.teamgdb.pokee") + self.assertIn('Pokee', self.label()) + self.assertTrue((self.root / "out.apk").is_file()) + + def test_a_build_without_a_title_is_pokee(self): + result = self.pack(None) + self.assertEqual(result.returncode, 0, result.stderr) + args = self.link_args() + self.assertEqual(args[args.index("--rename-manifest-package") + 1], "io.github.teamgdb.pokee") + + def test_fucomplete_is_its_own_app(self): + result = self.pack("fuc") + self.assertEqual(result.returncode, 0, result.stderr) + args = self.link_args() + self.assertEqual(args[args.index("--rename-manifest-package") + 1], "io.github.teamgdb.pokeefuc") + self.assertIn('PokeeFUC', self.label()) + # The source resources keep Pokee's name; only the packed copy changes. + source = (PROFILE / "packaging" / "android" / "res" / "values" / "strings.xml").read_text() + self.assertIn('Pokee', source) + self.assertIn("PokeeFUC, io.github.teamgdb.pokeefuc", result.stdout) + + def test_unknown_title_is_refused(self): + result = self.pack("MHP2G") + self.assertNotEqual(result.returncode, 0) + self.assertIn("unknown MHFU_TITLE", result.stderr) + self.assertFalse((self.root / "out.apk").exists()) + + def test_password_goes_through_the_environment(self): + result = self.pack("FUC") + self.assertEqual(result.returncode, 0, result.stderr) + signing = self.calls("apksigner")[0] + self.assertNotIn("s3cret pass", " ".join(signing)) + self.assertIn("env:POKEE_APK_KEY_PASSWORD", signing) + self.assertEqual((self.root / "password").read_text(), "s3cret pass\n") + + def test_bundled_licences_are_packed(self): + result = self.pack("FUC") + self.assertEqual(result.returncode, 0, result.stderr) + licenses = self.build / "apk" / "assets" / "licenses" + for name in ("Pokee-LICENSE.txt", "THIRD_PARTY_NOTICES.md", "DearImGui-LICENSE.txt", + "tiny-AES-c-UNLICENSE.txt", "xxHash-LICENSE.txt", "SDL3-LICENSE.txt"): + self.assertTrue((licenses / name).is_file(), name) + + def test_a_failed_java_build_is_refused(self): + executable(self.bin / "javac", "#!/bin/sh\nexit 0\n") + result = self.pack("FUC") + self.assertNotEqual(result.returncode, 0) + self.assertIn("javac made no classes", result.stderr) + self.assertFalse((self.root / "out.apk").exists()) + + def test_chosen_sdk_versions(self): + result = self.pack("FU", ANDROID_BUILD_TOOLS_VERSION="35.0.0", ANDROID_COMPILE_SDK_VERSION="35") + self.assertEqual(result.returncode, 0, result.stderr) + missing = self.pack("FU", ANDROID_COMPILE_SDK_VERSION="34") + self.assertNotEqual(missing.returncode, 0) + self.assertIn("not installed", missing.stderr) + invalid = self.pack("FU", ANDROID_BUILD_TOOLS_VERSION="../35") + self.assertNotEqual(invalid.returncode, 0) + self.assertIn("invalid Android build-tools version", invalid.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/profiles/mhfu/tests/interpolation_tests.cpp b/profiles/mhfu/tests/interpolation_tests.cpp index 4161a4a90..1208edfe6 100644 --- a/profiles/mhfu/tests/interpolation_tests.cpp +++ b/profiles/mhfu/tests/interpolation_tests.cpp @@ -6,12 +6,14 @@ // mhfu_interpolation_tests #include "gpu/frame_interpolation.hpp" #include "gpu/frame_pacing.hpp" +#include "settings/settings.hpp" #include #include #include #include #include +#include #include using namespace mhfu::gpu; @@ -395,6 +397,20 @@ void rates() { check(plain_presents_per_frame(60.0) == 1.0 && plain_presents_per_frame(120.0) == 1.0 && plain_presents_per_frame(45.0) == 0.5 && near(static_cast(plain_presents_per_frame(144.0)), 0.2f), "presents that fall on a frame's own moment: every frame, every other one at 45, every fifth at 144"); + + using mhfu::settings::FrameRate; + const FrameRate fixed[] = { + FrameRate::Fps30, FrameRate::Fps45, FrameRate::Fps60, FrameRate::Fps90, FrameRate::Fps120}; + for (int index = 0; index < 5; ++index) + check(requested_rate(fixed[index], 60.0) == kRates[index], + "a fixed Frame rate asks for its own rate, even above a 60 Hz display"); + check(requested_rate(FrameRate::Display, 165.0) == 165.0 && requested_rate(FrameRate::Display, 59.94) == 59.94, + "Display asks for the display's own rate"); + check(requested_rate(FrameRate::Display, 15.0) == 30.0, "never below the game's 30"); + for (double unknown : + {0.0, -1.0, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) + check(requested_rate(FrameRate::Display, unknown) == 60.0, "a display without a known rate counts as 60"); + check(requested_rate(static_cast(99), 120.0) == 30.0, "an unknown setting is the game's 30"); } // Runs the clock over `frames` game frames with the game's code taking diff --git a/profiles/mhfu/tests/settings_defaults_tests.cpp b/profiles/mhfu/tests/settings_defaults_tests.cpp index 3069e4f9f..f7efd0972 100644 --- a/profiles/mhfu/tests/settings_defaults_tests.cpp +++ b/profiles/mhfu/tests/settings_defaults_tests.cpp @@ -35,12 +35,14 @@ void test_desktop_is_the_declared_defaults() { "the desktop defaults are Settings{} as declared"); check(desktop.aspect == Aspect::Original && !desktop.fullscreen && desktop.mouse, "the desktop keeps the original aspect, a window and the mouse"); + check(!desktop.hud_edges, "the desktop keeps the interface in the PSP's proportions"); check(desktop.fast_loading && !desktop.unthrottled, "loads run fast and the game keeps real time otherwise"); } void test_android() { const Settings android = defaults_for(Platform::Android); check(android.aspect == Aspect::Fill, "a phone fills its wide screen"); + check(android.hud_edges, "a phone's interface goes out to its wide screen's sides"); check(android.fullscreen, "a phone is full screen"); check(!android.mouse, "a phone does not capture a mouse"); check(android.analog_camera && android.right_stick == RightStick::Camera, @@ -2071,6 +2073,52 @@ void test_hud_scale_draws() { check(both.x0 == 10.0f && both.y0 == 5.0f && both.x1 == 35.0f && both.y1 == 40.0f, "united holds both rectangles, either way round"); + // The interface at the screen's sides: a half-wide fit leaves a gap of + // 120 game pixels each side. Each piece goes to its own side by the game's + // rectangle, so a bar's end cap past the middle stays with its bar. + using mhfu::gpu::hud_scale::EdgeSide; + using mhfu::gpu::hud_scale::edge_side; + check(edge_side({8.0f, 8.0f, 60.0f, 56.0f}) == EdgeSide::Left, "the quest clock goes left"); + check(edge_side({320.0f, 9.0f, 344.0f, 21.0f}) == EdgeSide::Left, "health's end cap goes left with its bar"); + check(edge_side({316.0f, 32.0f, 468.0f, 184.0f}) == EdgeSide::Right, "the area map goes right"); + check(edge_side({276.0f, 70.0f, 336.0f, 130.0f}) == EdgeSide::Right, + "the window shown as a monster is found goes right with the map"); + check(edge_side({312.0f, 200.0f, 472.0f, 264.0f}) == EdgeSide::Right, "the item bar goes right"); + check(edge_side({312.0f, 184.0f, 472.0f, 200.0f}) == EdgeSide::Right, "a bowgun's ammo bar goes right"); + check(edge_side({8.0f, 230.0f, 48.0f, 262.0f}) == EdgeSide::Left, "a piece in the bottom-left corner goes left"); + check(edge_side({160.0f, 120.0f, 320.0f, 150.0f}) == EdgeSide::Centre, "a notice across the middle stays"); + check(mhfu::gpu::hud_scale::edge_gap(none) == 0.0f && mhfu::gpu::hud_scale::edge_gap(half_wide) == 120.0f && + mhfu::gpu::hud_scale::edge_gap(InterfaceFit{1.0f, 0.5f}) == 0.0f, + "only a screen wider than the PSP's leaves a gap at the sides"); + { + // The draws as fitted: the clock at x 124-150, the map at x 398-474. + std::vector clock = quad(124.0f, 8.0f, 150.0f, 56.0f); + const std::vector clock_game = clock; + const auto left = mhfu::gpu::hud_scale::place_at_edges(clock, clock_game, half_wide); + check(left.moved && left.uniform && left.dx == -120.0f && clock[0].x == 4.0f && clock[2].x == 30.0f && + left.bounds.x0 == 4.0f && left.bounds.x1 == 30.0f, + "a left piece moves by the gap, with its scissor"); + std::vector both = join(quad(124.0f, 8.0f, 150.0f, 56.0f), quad(398.0f, 32.0f, 474.0f, 184.0f)); + const std::vector both_game = both; + // A copy already at another size moves the same way, judged by the game's quads. + both[6].x = 410.0f; + const auto split = mhfu::gpu::hud_scale::place_at_edges(both, both_game, half_wide); + check(split.moved && !split.uniform && both[0].x == 4.0f && both[6].x == 530.0f && both[8].x == 594.0f && + split.bounds.x0 == 4.0f && split.bounds.x1 == 594.0f, + "pieces on both sides go their own ways, and the bounds take in both"); + std::vector middle = quad(200.0f, 120.0f, 280.0f, 150.0f); + const auto stays = mhfu::gpu::hud_scale::place_at_edges(middle, std::vector(middle), half_wide); + check(!stays.moved && middle[0].x == 200.0f, "a draw in the middle stays"); + std::vector unfitted = quad(8.0f, 8.0f, 60.0f, 56.0f); + check(!mhfu::gpu::hud_scale::place_at_edges(unfitted, std::vector(unfitted), none).moved && + unfitted[0].x == 8.0f, + "with no gap nothing moves"); + std::vector short_copy = quad(124.0f, 8.0f, 150.0f, 56.0f); + const std::vector one_vertex(1u, short_copy[0]); + check(!mhfu::gpu::hud_scale::place_at_edges(short_copy, one_vertex, half_wide).moved, + "a draw whose game copy does not match is left alone"); + } + // A name over a hunter shrinks toward the point the game placed it, and // the bounds it took come back for the scissor. const Scales names_half{1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0.5f};