From 0a56f631f31891ec765861428590c05a9c392bb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:20:55 +0000 Subject: [PATCH 01/11] Pack the FUComplete build as its own Android app, PokeeFUC An Android build directory configured with MHFU_TITLE=FUC now packs PokeeFUC: application id io.github.teamgdb.pokeefuc, PokeeFUC on the launcher, pokeefuc--android-arm64.apk. It installs beside Pokee with its own storage. The Java code keeps one package; build_apk.sh renames the manifest package with aapt2 and packs a copy of the resources with the title's name, and the manifest names the activity in full so it is found under either id. An unknown title is refused. From Yakumo's packer: sources and classes are passed as arrays, the keystore password reaches apksigner through the environment, the APK carries Pokee's, Dear ImGui's, tiny-AES-c's and xxHash's licences, zip leaves out directory records, aapt2 does not crunch the icons again, and ANDROID_BUILD_TOOLS_VERSION / ANDROID_COMPILE_SDK_VERSION pick SDK versions. android_packaging_tests.py runs build_apk.sh against stand-in SDK tools. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Hyrco8tLuU7Jb5hpCUJdH3 --- .github/workflows/tests.yml | 1 + Makefile | 2 + docs/RELEASING.md | 18 +- docs/TESTING.md | 1 + profiles/mhfu/README.md | 2 + .../packaging/android/AndroidManifest.xml | 7 +- profiles/mhfu/packaging/android/README.md | 6 +- profiles/mhfu/packaging/android/build_apk.sh | 80 +++++++-- profiles/mhfu/scripts/release_android.sh | 10 +- .../mhfu/tests/android_packaging_tests.py | 168 ++++++++++++++++++ 10 files changed, 273 insertions(+), 22 deletions(-) create mode 100644 profiles/mhfu/tests/android_packaging_tests.py 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..4db4dbbd1 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: @@ -126,6 +127,7 @@ coverage-tools: python-coverage: "$(COVERAGE_PYTHON)" -m coverage erase "$(COVERAGE_PYTHON)" -m coverage run --parallel-mode profiles/mhfu/tests/tool_security_tests.py + "$(COVERAGE_PYTHON)" -m coverage run --parallel-mode profiles/mhfu/tests/android_packaging_tests.py "$(COVERAGE_PYTHON)" -m coverage run --parallel-mode -m unittest discover -s scripts/ci -p 'test_*.py' "$(COVERAGE_PYTHON)" -m coverage combine --append "$(COVERAGE_PYTHON)" -m coverage json 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/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/README.md b/profiles/mhfu/README.md index 55563e7cb..f5f62d826 100644 --- a/profiles/mhfu/README.md +++ b/profiles/mhfu/README.md @@ -87,6 +87,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. 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) 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) "$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 +105,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 +136,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 +150,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_packaging_tests.py b/profiles/mhfu/tests/android_packaging_tests.py new file mode 100644 index 000000000..5aeb87774 --- /dev/null +++ b/profiles/mhfu/tests/android_packaging_tests.py @@ -0,0 +1,168 @@ +"""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) ;; +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" + (self.sdl / "android-project" / "app" / "src" / "main" / "java").mkdir(parents=True) + (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_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() From 931d8012dbbc92b373d45dabc31950db2e7c68ff Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:20:55 +0000 Subject: [PATCH 02/11] Ask Android for the display rate the Frame rate setting wants On Android 11 and later the app asks for the surface frame rate the Frame rate setting presents at (Match display: the display's own), so a phone that switches between 60, 90 and 120 Hz can show 90 or 120. ANativeWindow_setFrameRate is looked up at run time, so the APK still loads on Android 10, where nothing is asked. MHFU_NO_DISPLAY_RATE_REQUEST leaves the display as it was. The rate comes from pacing::requested_rate, which wanted_rate now uses before its Vsync cap. Ported from Yakumo with its loader tests. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Hyrco8tLuU7Jb5hpCUJdH3 --- docs/SOURCE_PROVENANCE.md | 2 + profiles/mhfu/CMakeLists.txt | 23 ++++- profiles/mhfu/README.md | 2 + profiles/mhfu/host/gpu/frame_pacing.cpp | 20 +++++ profiles/mhfu/host/gpu/frame_pacing.hpp | 10 +++ profiles/mhfu/host/gpu/vulkan_renderer.cpp | 41 +++++---- .../mhfu/host/platform/android_display.cpp | 34 +++++++ .../mhfu/host/platform/android_display.hpp | 12 +++ profiles/mhfu/tests/android_display_tests.cpp | 90 +++++++++++++++++++ profiles/mhfu/tests/interpolation_tests.cpp | 16 ++++ 10 files changed, 228 insertions(+), 22 deletions(-) create mode 100644 profiles/mhfu/host/platform/android_display.cpp create mode 100644 profiles/mhfu/host/platform/android_display.hpp create mode 100644 profiles/mhfu/tests/android_display_tests.cpp diff --git a/docs/SOURCE_PROVENANCE.md b/docs/SOURCE_PROVENANCE.md index 45a13918e..ff8168554 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/profiles/mhfu/CMakeLists.txt b/profiles/mhfu/CMakeLists.txt index d20aab3c9..8bd0493c6 100644 --- a/profiles/mhfu/CMakeLists.txt +++ b/profiles/mhfu/CMakeLists.txt @@ -351,7 +351,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 @@ -954,6 +954,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 f5f62d826..3108fad7a 100644 --- a/profiles/mhfu/README.md +++ b/profiles/mhfu/README.md @@ -963,6 +963,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 | @@ -1052,6 +1053,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 5c0882a2e..c2bf778ec 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 16b00c3de..044e9a1a3 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. @@ -38,6 +42,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/vulkan_renderer.cpp b/profiles/mhfu/host/gpu/vulkan_renderer.cpp index 564a3223c..a8a7d575f 100644 --- a/profiles/mhfu/host/gpu/vulkan_renderer.cpp +++ b/profiles/mhfu/host/gpu/vulkan_renderer.cpp @@ -36,6 +36,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 @@ -2551,6 +2552,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); @@ -3574,6 +3576,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 { @@ -9071,27 +9089,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) @@ -10152,6 +10150,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/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/interpolation_tests.cpp b/profiles/mhfu/tests/interpolation_tests.cpp index 89f28126a..c3af3f03f 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 From d95938a0727a3b58ea712ebcd1f4072a61bbaa4e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:25:31 +0000 Subject: [PATCH 03/11] Leave the packaging tests out of Python coverage They run a shell script, so coverage.py measures nothing in them, and the coverage pipeline test's fixture repository has no copy to run. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Hyrco8tLuU7Jb5hpCUJdH3 --- Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/Makefile b/Makefile index 4db4dbbd1..56a92fa36 100644 --- a/Makefile +++ b/Makefile @@ -127,7 +127,6 @@ coverage-tools: python-coverage: "$(COVERAGE_PYTHON)" -m coverage erase "$(COVERAGE_PYTHON)" -m coverage run --parallel-mode profiles/mhfu/tests/tool_security_tests.py - "$(COVERAGE_PYTHON)" -m coverage run --parallel-mode profiles/mhfu/tests/android_packaging_tests.py "$(COVERAGE_PYTHON)" -m coverage run --parallel-mode -m unittest discover -s scripts/ci -p 'test_*.py' "$(COVERAGE_PYTHON)" -m coverage combine --append "$(COVERAGE_PYTHON)" -m coverage json From cf544355910ce4d510b94fc58efdba04405d7568 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:33:05 +0000 Subject: [PATCH 04/11] Link the overlay test fixtures for Android like game overlays The NDK links shared libraries with --no-undefined, so the two fixture modules failed to link in the Android test build. They leave the framework's symbols to the loader, as the overlay libraries already do with -z undefs. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Hyrco8tLuU7Jb5hpCUJdH3 --- profiles/mhfu/CMakeLists.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/profiles/mhfu/CMakeLists.txt b/profiles/mhfu/CMakeLists.txt index 8bd0493c6..2d2ced0f1 100644 --- a/profiles/mhfu/CMakeLists.txt +++ b/profiles/mhfu/CMakeLists.txt @@ -1234,6 +1234,11 @@ if(NOT WIN32) PREFIX "" LIBRARY_OUTPUT_DIRECTORY "${MHFU_OVERLAY_FIXTURE_DIR}" OUTPUT_NAME "fixture_${mark}") + if(ANDROID) + # The NDK links with --no-undefined; like a game overlay, the + # fixture leaves the framework's symbols to the loader. + target_link_options(${fixture_target} PRIVATE "LINKER:-z,undefs") + endif() endforeach() add_executable(mhfu_overlay_swap_tests tests/overlay_swap_tests.cpp) target_compile_definitions(mhfu_overlay_swap_tests PRIVATE From d38a64a5009ff4509d2aad8136a4f2d37627b5f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:33:44 +0000 Subject: [PATCH 05/11] Let the overlay test fixtures link on macOS too ld64 refuses a bundle with undefined symbols; the fixtures take the framework's from the test executable when loaded, so they link with -undefined dynamic_lookup. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Hyrco8tLuU7Jb5hpCUJdH3 --- profiles/mhfu/CMakeLists.txt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/profiles/mhfu/CMakeLists.txt b/profiles/mhfu/CMakeLists.txt index 2d2ced0f1..91c0736e5 100644 --- a/profiles/mhfu/CMakeLists.txt +++ b/profiles/mhfu/CMakeLists.txt @@ -1234,10 +1234,14 @@ if(NOT WIN32) PREFIX "" LIBRARY_OUTPUT_DIRECTORY "${MHFU_OVERLAY_FIXTURE_DIR}" OUTPUT_NAME "fixture_${mark}") + # The framework's symbols come from the test executable when the + # fixture is loaded. The NDK links with --no-undefined and ld64 + # refuses undefined symbols in a bundle, so both are told to leave + # them to the loader, as ELF does by default. if(ANDROID) - # The NDK links with --no-undefined; like a game overlay, the - # fixture leaves the framework's symbols to the loader. target_link_options(${fixture_target} PRIVATE "LINKER:-z,undefs") + elseif(APPLE) + target_link_options(${fixture_target} PRIVATE "LINKER:-undefined,dynamic_lookup") endif() endforeach() add_executable(mhfu_overlay_swap_tests tests/overlay_swap_tests.cpp) From 674abbd6cd9038bd9d6bace716a8f25d7e08e4eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:39:10 +0000 Subject: [PATCH 06/11] Refuse an empty Java build instead of tripping bash 3.2 macOS's bash 3.2 takes an empty array for an unset variable under set -u, so build_apk.sh stopped with 'class_files[@]: unbound variable' when javac made no classes (the packaging tests' stand-in made none). The script now counts the sources and classes and says which is missing; the stand-in javac writes a class. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Hyrco8tLuU7Jb5hpCUJdH3 --- profiles/mhfu/packaging/android/build_apk.sh | 3 +++ profiles/mhfu/tests/android_packaging_tests.py | 16 ++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/profiles/mhfu/packaging/android/build_apk.sh b/profiles/mhfu/packaging/android/build_apk.sh index 2228f5316..898466f9c 100755 --- a/profiles/mhfu/packaging/android/build_apk.sh +++ b/profiles/mhfu/packaging/android/build_apk.sh @@ -79,11 +79,14 @@ echo "compiling SDL's Java activity and the app's own" 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" \ "${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" \ "${class_files[@]}" diff --git a/profiles/mhfu/tests/android_packaging_tests.py b/profiles/mhfu/tests/android_packaging_tests.py index 5aeb87774..dbf4e036f 100644 --- a/profiles/mhfu/tests/android_packaging_tests.py +++ b/profiles/mhfu/tests/android_packaging_tests.py @@ -36,7 +36,10 @@ 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) ;; + javac) + dir=""; prev="" + for arg in "$@"; do [[ "$prev" == -d ]] && dir="$arg"; prev="$arg"; done + touch "$dir/Stub.class" ;; esac """ @@ -66,7 +69,9 @@ def setUp(self): executable(self.ndk / "toolchains" / "llvm" / "prebuilt" / "linux-x86_64" / "bin" / "llvm-strip", "#!/bin/sh\nexit 0\n") self.sdl = self.root / "sdl" - (self.sdl / "android-project" / "app" / "src" / "main" / "java").mkdir(parents=True) + 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", @@ -153,6 +158,13 @@ def test_bundled_licences_are_packed(self): "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) From 7a0b4e507066c593cf51b31862afce27646bf930 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:16:34 +0000 Subject: [PATCH 07/11] Copy fixture directories to the Android emulator The HUD scale replay and overlay swap tests read fixtures from host paths compiled into them, which do not exist on the emulator. CTest now names both directories through the environment, the tests prefer that value, and the Android runner copies any directory a test's environment names inside the checkout or the build to the device and rewrites the value. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Hyrco8tLuU7Jb5hpCUJdH3 --- profiles/mhfu/CMakeLists.txt | 5 +++- .../mhfu/tests/hud_scale_replay_tests.cpp | 6 ++++- profiles/mhfu/tests/overlay_swap_tests.cpp | 6 ++++- scripts/ci/android_tests.py | 21 +++++++++++++-- scripts/ci/test_android_runner.py | 27 +++++++++++++++++++ 5 files changed, 60 insertions(+), 5 deletions(-) diff --git a/profiles/mhfu/CMakeLists.txt b/profiles/mhfu/CMakeLists.txt index 91c0736e5..137284be5 100644 --- a/profiles/mhfu/CMakeLists.txt +++ b/profiles/mhfu/CMakeLists.txt @@ -566,6 +566,8 @@ target_compile_definitions(mhfu_hud_scale_replay_tests PRIVATE set_target_properties(mhfu_hud_scale_replay_tests PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") if(PSPRECOMP_BUILD_TESTS) add_test(NAME mhfu_hud_scale_replay_tests COMMAND mhfu_hud_scale_replay_tests) + set_tests_properties(mhfu_hud_scale_replay_tests PROPERTIES + ENVIRONMENT "MHFU_HUD_SCALE_FIXTURE_DIR=${MHFU_PROFILE_DIR}/tests/hud_scale_fixtures") endif() # Damage numbers: hitzone colour, the health a hit removes, the watch @@ -1258,7 +1260,8 @@ if(NOT WIN32) set_target_properties(mhfu_overlay_swap_tests PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") if(PSPRECOMP_BUILD_TESTS) add_test(NAME mhfu_overlay_swap_tests COMMAND mhfu_overlay_swap_tests) - set_tests_properties(mhfu_overlay_swap_tests PROPERTIES TIMEOUT 60) + set_tests_properties(mhfu_overlay_swap_tests PROPERTIES TIMEOUT 60 + ENVIRONMENT "MHFU_OVERLAY_FIXTURE_DIR=${MHFU_OVERLAY_FIXTURE_DIR}") endif() endif() set_target_properties(mhfu_kernel_hle_tests PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") diff --git a/profiles/mhfu/tests/hud_scale_replay_tests.cpp b/profiles/mhfu/tests/hud_scale_replay_tests.cpp index a7be98ee2..3435bb605 100644 --- a/profiles/mhfu/tests/hud_scale_replay_tests.cpp +++ b/profiles/mhfu/tests/hud_scale_replay_tests.cpp @@ -285,7 +285,11 @@ int main(int argc, char **argv) { return 0; } test_parse_and_owner(); - const std::filesystem::path dir = MHFU_HUD_SCALE_FIXTURE_DIR; + // CTest names the fixtures through the environment, so a device run can + // point at its own copy; a direct run uses the source tree. + const char *fixture_dir = std::getenv("MHFU_HUD_SCALE_FIXTURE_DIR"); + const std::filesystem::path dir = + fixture_dir != nullptr && *fixture_dir != '\0' ? fixture_dir : MHFU_HUD_SCALE_FIXTURE_DIR; const bool update = [] { const char *value = std::getenv("MHFU_UPDATE_HUD_SCALE_FIXTURES"); return value != nullptr && *value == '1'; diff --git a/profiles/mhfu/tests/overlay_swap_tests.cpp b/profiles/mhfu/tests/overlay_swap_tests.cpp index 3b550fdb2..da24ddd30 100644 --- a/profiles/mhfu/tests/overlay_swap_tests.cpp +++ b/profiles/mhfu/tests/overlay_swap_tests.cpp @@ -23,7 +23,11 @@ void outside_slot(psprecomp::Runtime &, psprecomp::AllegrexContext &ctx) { } void swapped_slot_does_not_run_the_old_body() { - if (setenv("MHFU_OVERLAY_DIR", MHFU_OVERLAY_FIXTURE_DIR, 1) != 0) + // CTest names the fixtures through the environment, so a device run can + // point at its own copy; a direct run uses the build tree. + const char *fixture_dir = std::getenv("MHFU_OVERLAY_FIXTURE_DIR"); + if (fixture_dir == nullptr || *fixture_dir == '\0') fixture_dir = MHFU_OVERLAY_FIXTURE_DIR; + if (setenv("MHFU_OVERLAY_DIR", fixture_dir, 1) != 0) throw std::runtime_error("could not point the overlay loader at the fixtures"); psprecomp::Runtime runtime; diff --git a/scripts/ci/android_tests.py b/scripts/ci/android_tests.py index 915731480..40147cb34 100644 --- a/scripts/ci/android_tests.py +++ b/scripts/ci/android_tests.py @@ -26,11 +26,17 @@ def main(): check=True, timeout=120, capture_output=True, text=True).stdout)["tests"] if not tests: raise RuntimeError("No CTest tests registered") - nids = Path(__file__).resolve().parents[2] / "configs/nids.csv" + source = Path(__file__).resolve().parents[2] + nids = source / "configs/nids.csv" if not nids.is_file(): raise ValueError("Missing public NID registry fixture") binaries = {build / "psp_recomp"} names = set() + remote = "/data/local/tmp/pokee-unit-tests" + # A fixture directory a test names through its environment is a host path; + # the device gets its own copy, and the value is rewritten to point there. + # Only directories in the checkout or the build directory are copied. + data = {} for test in tests: name = test["name"] binary = Path(test["command"][0]).resolve(strict=True) @@ -45,11 +51,17 @@ def main(): key, separator, _ = assignment.partition("=") if not separator or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key): raise ValueError("Invalid CTest environment assignment") + value = assignment[len(key) + 1:] + if Path(value).is_absolute() and Path(value).is_dir(): + directory = Path(value).resolve(strict=True) + if not (directory.is_relative_to(source) or directory.is_relative_to(build)): + raise ValueError("CTest fixture directories must belong to the checkout or the build") + local = data.setdefault(directory, f"{remote}/data/{len(data)}") + assignment = f"{key}={local}" environment.append(assignment) test["remote_environment"] = environment names.add(name) binaries.add(binary) - remote = "/data/local/tmp/pokee-unit-tests" diagnostics = build / "android-results" diagnostics.mkdir(exist_ok=True) suite = ET.Element("testsuite", name="Android native tests", tests=str(len(tests))) @@ -61,6 +73,11 @@ def main(): subprocess.run(["adb", "push", str(binary), remote + "/"], check=True, timeout=120) subprocess.run(["adb", "push", str(stl), remote + "/libc++_shared.so"], check=True, timeout=120) subprocess.run(["adb", "push", str(nids), remote + "/nids.csv"], check=True, timeout=120) + if data: + subprocess.run(["adb", "shell", f"mkdir -p {remote}/data"], check=True, timeout=120) + for directory, local in data.items(): + # adb push copies a directory's contents when the target is new. + subprocess.run(["adb", "push", str(directory), local], check=True, timeout=120) subprocess.run(["adb", "shell", f"chmod 755 {remote}/*tests {remote}/psp_recomp"], check=True, timeout=120) for test in tests: diff --git a/scripts/ci/test_android_runner.py b/scripts/ci/test_android_runner.py index f63a773f7..bede4e014 100644 --- a/scripts/ci/test_android_runner.py +++ b/scripts/ci/test_android_runner.py @@ -123,6 +123,33 @@ def test_shared_binary_variant_preserves_arguments_and_environment(self): self.assertEqual(ET.parse(self.build / 'android-results/test-results.xml') .find('testcase').attrib['name'], 'memory_watch_contracts') + def test_fixture_directories_are_copied_and_renamed(self): + fixtures = self.build / 'bin' / 'fixtures' + fixtures.mkdir(parents=True) + missing = self.build / 'not built' + variant = {'name': 'fixture_tests', 'command': [str(self.build / 'passing_tests')], + 'properties': [{'name': 'ENVIRONMENT', + 'value': [f'FIXTURE_DIR={fixtures}', f'AGAIN_DIR={fixtures}', + f'MISSING_DIR={missing}', 'RELATIVE_DIR=bin']}]} + self.assertEqual(self.run_suite([variant]), 0) + calls = [json.loads(line) for line in self.calls.read_text().splitlines()] + command = next(call[1] for call in calls if call[0] == 'shell' and 'timeout 120' in call[1]) + self.assertIn('FIXTURE_DIR=/data/local/tmp/pokee-unit-tests/data/0', command) + self.assertIn('AGAIN_DIR=/data/local/tmp/pokee-unit-tests/data/0', command) + self.assertIn('RELATIVE_DIR=bin', command) + self.assertIn(str(missing), command) + pushes = [call for call in calls if call[0] == 'push' and call[1] == str(fixtures)] + self.assertEqual(pushes, [['push', str(fixtures), '/data/local/tmp/pokee-unit-tests/data/0']]) + + def test_fixture_directories_outside_the_checkout_are_refused(self): + outside = self.root / 'outside fixtures' + outside.mkdir() + variant = {'name': 'fixture_tests', 'command': [str(self.build / 'passing_tests')], + 'properties': [{'name': 'ENVIRONMENT', 'value': [f'FIXTURE_DIR={outside}']}]} + with self.assertRaises(ValueError): + self.run_suite([variant]) + self.assertFalse(self.calls.exists(), 'Invalid input must not touch the device') + def test_runtime_library_must_have_the_ndk_name(self): wrong = self.root / 'unexpected.so' wrong.write_bytes(b'wrong library') From 7b1357f484436f6a2f8cd389f4762942b4608c0b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:18:12 +0000 Subject: [PATCH 08/11] Expect the resolved fixture path in the Android runner test macOS temporary directories sit behind a symlink, and the runner pushes the resolved directory. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Hyrco8tLuU7Jb5hpCUJdH3 --- scripts/ci/test_android_runner.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/ci/test_android_runner.py b/scripts/ci/test_android_runner.py index bede4e014..fe7fa304a 100644 --- a/scripts/ci/test_android_runner.py +++ b/scripts/ci/test_android_runner.py @@ -138,8 +138,10 @@ def test_fixture_directories_are_copied_and_renamed(self): self.assertIn('AGAIN_DIR=/data/local/tmp/pokee-unit-tests/data/0', command) self.assertIn('RELATIVE_DIR=bin', command) self.assertIn(str(missing), command) - pushes = [call for call in calls if call[0] == 'push' and call[1] == str(fixtures)] - self.assertEqual(pushes, [['push', str(fixtures), '/data/local/tmp/pokee-unit-tests/data/0']]) + # The runner pushes the resolved path; macOS temporary directories are symlinks. + resolved = str(fixtures.resolve()) + pushes = [call for call in calls if call[0] == 'push' and call[1] == resolved] + self.assertEqual(pushes, [['push', resolved, '/data/local/tmp/pokee-unit-tests/data/0']]) def test_fixture_directories_outside_the_checkout_are_refused(self): outside = self.root / 'outside fixtures' From 04872ee3c6c7c2d1a0664d2cd61ea530ffa68164 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:19:23 +0000 Subject: [PATCH 09/11] Keep Clang's coverage flags off a GCC C compiler The coverage build sets only the C++ compiler to Clang, so the KTX2 decoder's zstd C source went to the system cc with Clang-only flags and the build stopped. The flags now apply per language when that language's compiler is Clang. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Hyrco8tLuU7Jb5hpCUJdH3 --- cmake/Coverage.cmake | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmake/Coverage.cmake b/cmake/Coverage.cmake index 50b4d4f07..ac7a1eabf 100644 --- a/cmake/Coverage.cmake +++ b/cmake/Coverage.cmake @@ -8,6 +8,9 @@ if(PSPRECOMP_COVERAGE) if(PSPRECOMP_SANITIZERS) message(FATAL_ERROR "Use separate build directories for coverage and sanitizers") endif() - add_compile_options(-fprofile-instr-generate -fcoverage-mapping -O0 -g) + # The flags are Clang's. A C compiler of another family (third-party C + # sources, such as the KTX2 decoder's zstd) is left uninstrumented. + add_compile_options("$<$:-fprofile-instr-generate;-fcoverage-mapping;-O0;-g>" + "$<$:-fprofile-instr-generate;-fcoverage-mapping;-O0;-g>") add_link_options(-fprofile-instr-generate) endif() From 6123b48195b1126baf086f229e3dd1eefb3517cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:22:26 +0000 Subject: [PATCH 10/11] Put the quest interface at a wide screen's sides Under Fill the 2D interface is pulled into a PSP-shaped box in the middle of the screen, which on a phone leaves wide empty bands beside the clock, the bars, the map and the item bar. "Interface at the edges" (video.hud_edges, MHFU_HUD_EDGES) moves each quest HUD quad out by the gap the fit left: the clock, the bars and the party list to the left, the map and the item bar to the right. The side is judged from the game's own rectangle of each quad, so a bar's end cap stays with its bar, and what sits in the middle stays. Only draws filed by game_task's quest HUD move; menus and names over hunters keep their place. A draw's scissor moves with it, or takes in where its quads landed when they went different ways. It is on by default on Android and off on the desktop. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Hyrco8tLuU7Jb5hpCUJdH3 --- profiles/mhfu/README.md | 3 +- profiles/mhfu/host/gpu/game_hud.cpp | 5 +- profiles/mhfu/host/gpu/hud_scale.hpp | 68 +++++++++++++++++++ profiles/mhfu/host/gpu/vulkan_renderer.cpp | 31 +++++++++ profiles/mhfu/host/settings/settings.cpp | 5 ++ profiles/mhfu/host/settings/settings.hpp | 5 ++ profiles/mhfu/host/ui/menu.cpp | 13 ++++ .../mhfu/tests/settings_defaults_tests.cpp | 47 +++++++++++++ 8 files changed, 175 insertions(+), 2 deletions(-) diff --git a/profiles/mhfu/README.md b/profiles/mhfu/README.md index fd4604ae4..ab57a69ea 100644 --- a/profiles/mhfu/README.md +++ b/profiles/mhfu/README.md @@ -459,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 | | --- | --- | --- | --- | --- | @@ -476,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) | 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 42f7f5be9..8a0f75c13 100644 --- a/profiles/mhfu/host/gpu/vulkan_renderer.cpp +++ b/profiles/mhfu/host/gpu/vulkan_renderer.cpp @@ -8722,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(); @@ -9110,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) { 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/tests/settings_defaults_tests.cpp b/profiles/mhfu/tests/settings_defaults_tests.cpp index 3069e4f9f..ae2ec87ae 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,51 @@ 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); + check(!mhfu::gpu::hud_scale::place_at_edges(short_copy, std::vector{}, 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}; From 6fe0a3ee6eb143198fb949047474db4f78ddb281 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:29:52 +0000 Subject: [PATCH 11/11] Give the edge test's mismatched copy a vertex cppcheck followed place_at_edges into quad_bounds with an empty copy and reported an out-of-bounds read the size guard already prevents. A one-vertex copy keeps the mismatch case. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Hyrco8tLuU7Jb5hpCUJdH3 --- profiles/mhfu/tests/settings_defaults_tests.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/profiles/mhfu/tests/settings_defaults_tests.cpp b/profiles/mhfu/tests/settings_defaults_tests.cpp index ae2ec87ae..f7efd0972 100644 --- a/profiles/mhfu/tests/settings_defaults_tests.cpp +++ b/profiles/mhfu/tests/settings_defaults_tests.cpp @@ -2114,7 +2114,8 @@ void test_hud_scale_draws() { unfitted[0].x == 8.0f, "with no gap nothing moves"); std::vector short_copy = quad(124.0f, 8.0f, 150.0f, 56.0f); - check(!mhfu::gpu::hud_scale::place_at_edges(short_copy, std::vector{}, half_wide).moved, + 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"); }