From 7167071a3d9dc9f4c742b0ffecfa749081380e19 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 04:11:12 +0800 Subject: [PATCH 001/331] feat(renpy): add desktop runtime on current main --- .github/workflows/build.yml | 36 + CMakeLists.txt | 10 +- README.md | 5 + apps/godot_app/scripts/game_launch_entry.gd | 7 + .../scripts/game_launch_entry_test.gd | 21 + apps/godot_app/scripts/game_metadata.gd | 53 +- apps/godot_app/scripts/game_metadata_test.gd | 32 + bridge/godot_extension/CMakeLists.txt | 4 + .../src/aether_runtime_player.cpp | 6 + bridge/renpy_runtime/CMakeLists.txt | 43 + bridge/renpy_runtime/README.md | 54 + bridge/renpy_runtime/include/renpy_runtime.h | 11 + .../python/aether_renpy_overlay.py | 180 +++ .../src/renpy_runtime_provider.cpp | 1032 +++++++++++++++++ bridge/renpy_runtime/tests/provider.cpp | 257 ++++ scripts/build_linux.sh | 26 +- scripts/build_macos.sh | 21 + tests/fixtures/renpy_smoke/.gitignore | 10 + tests/fixtures/renpy_smoke/README.md | 25 + tests/fixtures/renpy_smoke/game/options.rpy | 16 + tests/fixtures/renpy_smoke/game/script.rpy | 27 + tests/fixtures/renpy_smoke/project.json | 3 + tools/install_renpy_sdk.sh | 181 +++ tools/run_renpy_overlay_probe.sh | 173 +++ tools/run_renpy_smoke.sh | 94 ++ tools/test_install_renpy_sdk.sh | 30 + 26 files changed, 2354 insertions(+), 3 deletions(-) create mode 100644 bridge/renpy_runtime/CMakeLists.txt create mode 100644 bridge/renpy_runtime/README.md create mode 100644 bridge/renpy_runtime/include/renpy_runtime.h create mode 100644 bridge/renpy_runtime/python/aether_renpy_overlay.py create mode 100644 bridge/renpy_runtime/src/renpy_runtime_provider.cpp create mode 100644 bridge/renpy_runtime/tests/provider.cpp create mode 100644 tests/fixtures/renpy_smoke/.gitignore create mode 100644 tests/fixtures/renpy_smoke/README.md create mode 100644 tests/fixtures/renpy_smoke/game/options.rpy create mode 100644 tests/fixtures/renpy_smoke/game/script.rpy create mode 100644 tests/fixtures/renpy_smoke/project.json create mode 100755 tools/install_renpy_sdk.sh create mode 100755 tools/run_renpy_overlay_probe.sh create mode 100755 tools/run_renpy_smoke.sh create mode 100755 tools/test_install_renpy_sdk.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 613920681..075c349b3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -180,6 +180,24 @@ jobs: # can be allowed without disabling checks for every other wrapper. validate-wrappers: false + - name: Validate Ren'Py SDK installer + run: tools/test_install_renpy_sdk.sh + + - name: Install pinned Ren'Py SDK + run: | + set -euo pipefail + sdk_root="${RUNNER_TEMP}/aetherkiri-renpy-sdk" + ./tools/install_renpy_sdk.sh --platform linux --arch x86_64 --destination "$sdk_root" + { + echo "AETHERKIRI_ENABLE_RENPY=ON" + echo "AETHERKIRI_RENPY_SDK_ROOT=$sdk_root" + echo "RENPY_SDK=$sdk_root" + } >> "$GITHUB_ENV" + test -x "$sdk_root/renpy.sh" + + - name: Run Ren'Py SDK smoke fixture + run: tools/run_renpy_smoke.sh + - name: Install Godot export tools run: | set -euo pipefail @@ -749,6 +767,24 @@ jobs: echo "$(brew --prefix bison)/bin" >> $GITHUB_PATH rustup target add aarch64-apple-darwin + - name: Validate Ren'Py SDK installer + run: tools/test_install_renpy_sdk.sh + + - name: Install pinned Ren'Py SDK + run: | + set -euo pipefail + sdk_root="${RUNNER_TEMP}/aetherkiri-renpy-sdk" + ./tools/install_renpy_sdk.sh --platform darwin --arch arm64 --destination "$sdk_root" + { + echo "AETHERKIRI_ENABLE_RENPY=ON" + echo "AETHERKIRI_RENPY_SDK_ROOT=$sdk_root" + echo "RENPY_SDK=$sdk_root" + } >> "$GITHUB_ENV" + test -x "$sdk_root/renpy.sh" + + - name: Run Ren'Py SDK smoke fixture + run: tools/run_renpy_smoke.sh + - name: Install Godot export tools run: | set -euo pipefail diff --git a/CMakeLists.txt b/CMakeLists.txt index b63843c6a..03ac42e30 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -111,6 +111,10 @@ option(BUILD_TOOLS "build tools execute build(exclude android ios)" ON) option(BUILD_ENGINE_API "build engine_api bridge shared library" ON) option(BUILD_GODOT_EXTENSION "build Godot GDExtension host library" ON) option(AETHERKIRI_ENABLE_RFVP "build the experimental native FVP runtime (requires Rust)" OFF) +option(AETHERKIRI_ENABLE_RENPY + "build the opt-in official Ren'Py SDK subprocess provider" OFF) +set(AETHERKIRI_RENPY_SDK_ROOT "" CACHE PATH + "official desktop Ren'Py SDK directory for the embedded provider") option(BUILD_GPU_BRIDGE "build legacy external GPU bridge renderer" OFF) option(AETHERKIRI_ENABLE_ONSCRIPTER "enable the bundled OnscripterYuri runtime" ON) @@ -272,7 +276,8 @@ endif() # krkr2core/krkr2plugin linkage; the Godot extension host links the glue and # installs its engine_legacy_* surface into engine_api through the legacy # services table (Phase 2d link flip — engine_api itself never links it). -if(BUILD_ENGINE_API OR AETHERKIRI_BUILD_NATIVE_ONSCRIPTER_TESTS OR AETHERKIRI_ENABLE_RFVP) +# Build the ABI whenever a provider (including Ren'Py) needs the registry. +if(BUILD_ENGINE_API OR AETHERKIRI_BUILD_NATIVE_ONSCRIPTER_TESTS OR AETHERKIRI_ENABLE_RFVP OR AETHERKIRI_ENABLE_RENPY) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/krkr2_runtime) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/abi) endif() @@ -297,6 +302,9 @@ endif() if(AETHERKIRI_ENABLE_RFVP) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/rfvp_runtime) endif() +if(AETHERKIRI_ENABLE_RENPY) + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/renpy_runtime) +endif() if(BUILD_GODOT_EXTENSION) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/godot_extension) endif() diff --git a/README.md b/README.md index fa267698a..7e82d5244 100644 --- a/README.md +++ b/README.md @@ -303,3 +303,8 @@ release, or a distributor they authorize in writing; third-party forks and derivative apps may not rely on that permission, which neither grants rights in upstream or third-party material on behalf of other copyright holders nor revokes rights already granted by the GPL. + + +## Ren'Py desktop support + +Ren'Py support is an opt-in desktop provider. CI and local builds download the official Ren'Py 8.5.3 SDK through `tools/install_renpy_sdk.sh` and verify its pinned SHA-256 before extraction. Set `AETHERKIRI_RENPY_SDK_ROOT` to the extracted SDK when launching a desktop build. iOS, Android, and Web builds leave this desktop-only provider disabled. diff --git a/apps/godot_app/scripts/game_launch_entry.gd b/apps/godot_app/scripts/game_launch_entry.gd index 7a691b7b1..8ebc94946 100644 --- a/apps/godot_app/scripts/game_launch_entry.gd +++ b/apps/godot_app/scripts/game_launch_entry.gd @@ -1,5 +1,7 @@ extends RefCounted +const GameMetadata = preload("res://scripts/game_metadata.gd") + const FIELD := "launchFile" const SUPPORTED_EXTENSIONS := ["exe", "xp3", "hcb"] const DIRECTORY_RUNTIME_KINDS := [ @@ -7,6 +9,7 @@ const DIRECTORY_RUNTIME_KINDS := [ "catsystem2", "minori", "onscripter", + "renpy", "siglus", "wa2", ] @@ -59,6 +62,10 @@ static func resolve_for_runtime( # Runtime providers and directory-based runtimes consume the game root. A # selected EXE/XP3 is only a legacy KiriKiri entry point and must not # replace the root passed to a provider probe/open pair. + if runtime_kind.to_lower() == "renpy": + var path := _normalize_path(String(game.get("path", ""))) + var project_root := GameMetadata.renpy_project_root(path) + return project_root if not project_root.is_empty() else path if requires_game_root or runtime_uses_directory(runtime_kind): # Preserve valid trailing spaces from native file-picker paths. return _normalize_path(String(game.get("path", ""))) diff --git a/apps/godot_app/scripts/game_launch_entry_test.gd b/apps/godot_app/scripts/game_launch_entry_test.gd index 234a7d62c..05553d2da 100644 --- a/apps/godot_app/scripts/game_launch_entry_test.gd +++ b/apps/godot_app/scripts/game_launch_entry_test.gd @@ -143,6 +143,27 @@ func _init() -> void: spaced_root.path_join("cs2.exe"), "launch file inside trailing-space directory" ) + _expect_equal( + GameLaunchEntry.resolve_for_runtime( + {"path": root, GameLaunchEntry.FIELD: "launcher.exe"}, "renpy" + ), root, "Ren'Py ignores legacy launcher selection" + ) + var renpy_root := ProjectSettings.globalize_path("user://renpy_launch_%d" % Time.get_ticks_usec()) + DirAccess.make_dir_recursive_absolute(renpy_root.path_join("game")) + var renpy_script := renpy_root.path_join("game/script.rpy") + var script_file := FileAccess.open(renpy_script, FileAccess.WRITE) + script_file.store_string("label start:\n return\n") + script_file.close() + var renpy_launcher := renpy_root.path_join("launcher.exe") + var launcher_file := FileAccess.open(renpy_launcher, FileAccess.WRITE) + launcher_file.store_string("launcher") + launcher_file.close() + for selected in [renpy_root, renpy_root + "/", renpy_root.path_join("game"), renpy_script, renpy_launcher]: + _expect_equal(GameLaunchEntry.resolve_for_runtime({"path": selected}, "renpy"), renpy_root, "Ren'Py normalized launch root") + DirAccess.remove_absolute(renpy_script) + DirAccess.remove_absolute(renpy_launcher) + DirAccess.remove_absolute(renpy_root.path_join("game")) + DirAccess.remove_absolute(renpy_root) if failures == 0: print("game_launch_entry_test: PASS") quit(0) diff --git a/apps/godot_app/scripts/game_metadata.gd b/apps/godot_app/scripts/game_metadata.gd index 3be5c1b19..e67e49c72 100644 --- a/apps/godot_app/scripts/game_metadata.gd +++ b/apps/godot_app/scripts/game_metadata.gd @@ -4,14 +4,18 @@ extends RefCounted const RUNTIME_KIRIKIRI := "kirikiri" const RUNTIME_ONSCRIPTER := "onscripter" const RUNTIME_RFVP := "rfvp" +const RUNTIME_RENPY := "renpy" const RUNTIME_ARTEMIS := "artemis" const RUNTIME_SIGLUS := "siglus" const RUNTIME_CATSYSTEM2 := "catsystem2" static func inspect(path: String) -> Dictionary: - var root := path + var root := path.replace("\\", "/") if FileAccess.file_exists(root): root = root.get_base_dir() + var renpy_root := renpy_project_root(root) + if not renpy_root.is_empty(): + root = renpy_root var result := { "engine": RUNTIME_KIRIKIRI, "title": "", @@ -39,6 +43,11 @@ static func inspect(path: String) -> Dictionary: ): result.engine = RUNTIME_ONSCRIPTER result.signals.append("onscript-marker") + elif not renpy_root.is_empty(): + # Scripts must be in a project game/ directory. Loose Python files, + # launcher names, and archive extensions alone are not sufficient. + result.engine = RUNTIME_RENPY + result.signals.append("renpy-package") elif _is_catsystem2_package(files): # CatSystem2 titles commonly ship a Windows launcher beside loose or # packed INT resources. Keep the directory as the launch path so the @@ -194,6 +203,48 @@ static func _has_prefix(values: PackedStringArray, prefix: String) -> bool: return true return false +static func renpy_project_root(path: String) -> String: + var root := path.replace("\\", "/").simplify_path() + if FileAccess.file_exists(root): + root = root.get_base_dir() + if _is_renpy_project(root): + return root + # A picker may return game/ or one of its script/archive files. Only + # promote it when the parent passes the same project validation. + if root.get_file() == "game" and _is_renpy_project(root.get_base_dir()): + return root.get_base_dir() + return "" + +static func _is_renpy_project(root: String) -> bool: + return not root.is_empty() and _has_renpy_scripts(root.path_join("game")) + +static func _has_renpy_scripts(root: String, depth: int = 0) -> bool: + var dir := DirAccess.open(root) + if dir == null or depth > 8: + return false + for entry in dir.get_files(): + var extension := entry.get_extension().to_lower() + if extension not in ["rpy", "rpyc", "rpa"]: + continue + var file := FileAccess.open(root.path_join(entry), FileAccess.READ) + if file == null or file.get_length() == 0: + continue + if extension == "rpy": + return true + # Compiled scripts and archives carry signatures. Do not claim + # unrelated files merely because their extension is suggestive. + var signature := file.get_buffer(10).get_string_from_ascii() + if extension == "rpyc" and signature == "RENPY RPC2": + return true + if extension == "rpa" and (signature.begins_with("RPA-2.0 ") \ + or signature.begins_with("RPA-3.0 ")): + return true + for entry in dir.get_directories(): + if entry not in ["saves", "cache"] \ + and _has_renpy_scripts(root.path_join(entry), depth + 1): + return true + return false + static func _has_extension(values: PackedStringArray, extension: String) -> bool: var normalized := extension.to_lower().trim_prefix(".") for value in values: diff --git a/apps/godot_app/scripts/game_metadata_test.gd b/apps/godot_app/scripts/game_metadata_test.gd index d99c39282..b23b84e55 100644 --- a/apps/godot_app/scripts/game_metadata_test.gd +++ b/apps/godot_app/scripts/game_metadata_test.gd @@ -98,6 +98,38 @@ func _init() -> void: "invalid HCB does not claim another runtime" ) + var renpy_root := fixture_root.path_join("renpy_project") + DirAccess.make_dir_recursive_absolute(renpy_root.path_join("game")) + _write(renpy_root.path_join("renpy.exe"), "launcher") + _expect_equal(String(GameMetadata.inspect(renpy_root).engine), "kirikiri", "launcher alone is not Ren'Py") + _write(renpy_root.path_join("game/archive.rpa"), "not a Ren'Py archive") + _expect_equal(String(GameMetadata.inspect(renpy_root).engine), "kirikiri", "invalid archive rejected") + _write(renpy_root.path_join("game/script.rpyc"), "not a compiled script") + _expect_equal(String(GameMetadata.inspect(renpy_root).engine), "kirikiri", "invalid compiled script rejected") + _write(renpy_root.path_join("game/script.rpy"), "label start:\n return\n") + for path in [renpy_root, renpy_root.path_join("renpy.exe"), renpy_root.path_join("game"), renpy_root.path_join("game/script.rpy")]: + var renpy := GameMetadata.inspect(path) + _expect_equal(String(renpy.engine), "renpy", "Ren'Py selected path " + path) + _expect_equal(String(renpy.launchFile), "", "Ren'Py keeps directory launch") + _expect_equal(GameMetadata.renpy_project_root(path), renpy_root, "Ren'Py normalizes to project root") + _expect_true(Array(renpy.signals).has("renpy-package"), "Ren'Py detection signal") + DirAccess.remove_absolute(renpy_root.path_join("game/script.rpy")) + _write(renpy_root.path_join("game/script.rpyc"), "RENPY RPC2fixture") + _expect_equal(String(GameMetadata.inspect(renpy_root).engine), "renpy", "compiled Ren'Py script signature") + DirAccess.remove_absolute(renpy_root.path_join("game/script.rpyc")) + _write(renpy_root.path_join("game/archive.rpa"), "RPA-3.0 0000000000000010 00000000\nfixture") + _expect_equal(String(GameMetadata.inspect(renpy_root).engine), "renpy", "Ren'Py archive signature") + DirAccess.remove_absolute(renpy_root.path_join("game/archive.rpa")) + DirAccess.make_dir_recursive_absolute(renpy_root.path_join("game/story")) + _write(renpy_root.path_join("game/story/chapter.RPY"), "label start:\n return\n") + _expect_equal(String(GameMetadata.inspect(renpy_root).engine), "renpy", "nested source project without bundled runtime") + _write(renpy_root.path_join("0.txt"), "*start\nend\n") + _expect_equal(String(GameMetadata.inspect(renpy_root).engine), "onscripter", "existing ONS marker retains precedence") + var loose_root := fixture_root.path_join("loose_script") + DirAccess.make_dir_recursive_absolute(loose_root) + _write(loose_root.path_join("script.rpy"), "label start:\n return\n") + _expect_equal(String(GameMetadata.inspect(loose_root).engine), "kirikiri", "loose script requires project layout") + _remove_tree(fixture_root) if failures == 0: print("game_metadata_test: PASS") diff --git a/bridge/godot_extension/CMakeLists.txt b/bridge/godot_extension/CMakeLists.txt index 57bd1dc50..1f35e65df 100644 --- a/bridge/godot_extension/CMakeLists.txt +++ b/bridge/godot_extension/CMakeLists.txt @@ -244,6 +244,10 @@ if(TARGET aether_minori_runtime) VERBATIM) endif() endif() +if(TARGET aether_renpy_runtime) + target_link_libraries(aether_kiri_godot PRIVATE aether_renpy_runtime) + target_compile_definitions(aether_kiri_godot PRIVATE AETHERKIRI_WITH_RENPY=1) +endif() if(TARGET aether_rfvp_runtime) target_link_libraries(aether_kiri_godot PRIVATE aether_rfvp_runtime) target_compile_definitions(aether_kiri_godot PRIVATE AETHERKIRI_WITH_RFVP=1) diff --git a/bridge/godot_extension/src/aether_runtime_player.cpp b/bridge/godot_extension/src/aether_runtime_player.cpp index 7ab518068..593d8bfb7 100644 --- a/bridge/godot_extension/src/aether_runtime_player.cpp +++ b/bridge/godot_extension/src/aether_runtime_player.cpp @@ -1,6 +1,9 @@ #include "engine_api.h" #include "engine_options.h" #include "engine_runtime_provider.h" +#if defined(AETHERKIRI_WITH_RENPY) +#include "renpy_runtime.h" +#endif #if defined(AETHERKIRI_WITH_RFVP) #include "rfvp_runtime_provider.h" #endif @@ -12620,6 +12623,9 @@ void InitializeAetherRuntime(ModuleInitializationLevel level) { #endif #if defined(AETHERKIRI_WITH_RFVP) aetherkiri::rfvp::RegisterRuntimeProvider(); +#endif +#if defined(AETHERKIRI_WITH_RENPY) + aetherkiri::renpy::RegisterRuntimeProvider(); #endif const engine_result_t shader_result = engine_set_runtime_fragment_shader_executor( diff --git a/bridge/renpy_runtime/CMakeLists.txt b/bridge/renpy_runtime/CMakeLists.txt new file mode 100644 index 000000000..0ff22eb15 --- /dev/null +++ b/bridge/renpy_runtime/CMakeLists.txt @@ -0,0 +1,43 @@ +cmake_minimum_required(VERSION 3.19) +project(aether_renpy_runtime LANGUAGES CXX) + +if(NOT (WIN32 OR APPLE OR LINUX)) + message(FATAL_ERROR + "The embedded Ren'Py SDK provider requires a desktop subprocess host") +endif() +if(IOS OR ANDROID OR WEB OR EMSCRIPTEN) + message(FATAL_ERROR + "The embedded Ren'Py SDK provider is not available on this platform") +endif() +if(NOT AETHERKIRI_RENPY_SDK_ROOT OR + NOT IS_DIRECTORY "${AETHERKIRI_RENPY_SDK_ROOT}") + message(FATAL_ERROR + "AETHERKIRI_ENABLE_RENPY requires AETHERKIRI_RENPY_SDK_ROOT to point " + "to an official Ren'Py SDK directory") +endif() + +add_library(aether_renpy_runtime STATIC src/renpy_runtime_provider.cpp) +target_compile_features(aether_renpy_runtime PUBLIC cxx_std_17) +set_target_properties(aether_renpy_runtime PROPERTIES POSITION_INDEPENDENT_CODE ON) +target_compile_definitions(aether_renpy_runtime PRIVATE + AETHERKIRI_RENPY_SDK_ROOT="${AETHERKIRI_RENPY_SDK_ROOT}" + AETHERKIRI_RENPY_OVERLAY_SOURCE="${CMAKE_CURRENT_SOURCE_DIR}/python/aether_renpy_overlay.py") +target_include_directories(aether_renpy_runtime PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_CURRENT_SOURCE_DIR}/../engine_api/include") +if(NOT TARGET engine_api) + message(FATAL_ERROR "Ren'Py provider requires the engine_api target") +endif() +target_link_libraries(aether_renpy_runtime PRIVATE engine_api) +if(UNIX) + target_link_libraries(aether_renpy_runtime PRIVATE pthread) +endif() + +if(ENABLE_TESTS) + enable_testing() + add_executable(renpy-provider-test tests/provider.cpp) + target_compile_definitions(renpy-provider-test PRIVATE + AETHERKIRI_RENPY_TEST_SDK_ROOT="${AETHERKIRI_RENPY_SDK_ROOT}") + target_link_libraries(renpy-provider-test PRIVATE aether_renpy_runtime) + add_test(NAME renpy-provider COMMAND renpy-provider-test) +endif() diff --git a/bridge/renpy_runtime/README.md b/bridge/renpy_runtime/README.md new file mode 100644 index 000000000..1e632c92e --- /dev/null +++ b/bridge/renpy_runtime/README.md @@ -0,0 +1,54 @@ +# Ren'Py SDK provider + +This is an explicitly opt-in bootstrap milestone. Configure +`AETHERKIRI_ENABLE_RENPY=ON` and point `AETHERKIRI_RENPY_SDK_ROOT` at an +official desktop Ren'Py SDK checkout. The provider validates a `game/script.rpy` +project and launches the SDK executable directly, without a shell. + +The provider stages an opt-in Python overlay into `game/libs` while a game is +open. The overlay exports real renderer pixels through the AKRF1 transport and +forwards input through JSONL; staging is removed when the provider closes. A +read-only game root returns `ENGINE_RESULT_NOT_SUPPORTED` with a diagnostic. +Native GPU texture import remains unsupported, so hosts use the RGBA frame +path while the SDK-owned window stays available for debugging. + +The process is started with an argv array and an exec-error pipe, polled with +`waitpid`/`WaitForSingleObject`, and terminated during provider destruction. +Paths are validated before launch and are never passed through a shell. + +## Experimental Python overlay + +`python/aether_renpy_overlay.py` is an opt-in Ren'Py-side prototype. Set +`AETHERKIRI_RENPY_OVERLAY=1` and import `install()` from the game's +`game/options.rpy` to hook `renpy.display.core.Interface.draw_screen`. + +The hook uses the active SDK renderer (`renpy.display.draw`) after the real +draw. On Ren'Py 8.5.x, the compatibility `pygame.image` module does not +provide `tostring`; the overlay copies the live `Surface._pixels_address` +using its pitch and channel masks, then emits tightly packed RGBA bytes. The +frame file is atomically replaced on each draw and has this little-endian +layout: + +``` +AKRF1\0\0\0 | u32 width | u32 height | u64 serial | u32 payload_len | RGBA... +``` + +Set `AETHERKIRI_RENPY_FRAME` to select the frame path. Input is consumed from +`AETHERKIRI_RENPY_INPUT` as JSON lines during Ren'Py periodic callbacks: +`{"type": 768, "attributes": {"key": 13, "mod": 0, "unicode": "\r", "scancode": 40, "repeat": false}}` +is a `KEYDOWN` event; `mod`, `unicode`, `scancode`, and `repeat` default to +zero, empty, zero, and false when omitted. Mouse events use the usual `pos`, +`button`, and `rel` attributes. Set `AETHERKIRI_RENPY_ERROR` to an optional +append-only diagnostics path; frame and input failures are reported there +without terminating the SDK process. + +The reproducible desktop probe is `tools/run_renpy_overlay_probe.sh`; run it +with `RENPY_SDK=/path/to/renpy-8.5.3-sdk`. It launches the official SDK with +SDL's dummy drivers, verifies a nonblank 640x360 frame, injects Down+Return, +and checks that the Ren'Py menu selection reaches script code. It uses the +SDK's `gl2` renderer by default; set `RENPY_RENDERER=sw` to exercise the +software renderer instead. + +The overlay is desktop-only and remains unsupported on iOS. The provider's +desktop integration is guarded by `AETHERKIRI_ENABLE_RENPY` and requires a +writable `game/libs` directory to stage the hook. diff --git a/bridge/renpy_runtime/include/renpy_runtime.h b/bridge/renpy_runtime/include/renpy_runtime.h new file mode 100644 index 000000000..e659dae71 --- /dev/null +++ b/bridge/renpy_runtime/include/renpy_runtime.h @@ -0,0 +1,11 @@ +#pragma once + +namespace aetherkiri::renpy { + +// Registers the opt-in Ren'Py SDK subprocess provider. The provider only +// builds when AETHERKIRI_RENPY_SDK_ROOT names an official SDK checkout. The +// SDK owns its window; frame import into AetherKiri is intentionally reported +// as unsupported until a real shared-surface bridge is available. +void RegisterRuntimeProvider(); + +} // namespace aetherkiri::renpy diff --git a/bridge/renpy_runtime/python/aether_renpy_overlay.py b/bridge/renpy_runtime/python/aether_renpy_overlay.py new file mode 100644 index 000000000..518da660e --- /dev/null +++ b/bridge/renpy_runtime/python/aether_renpy_overlay.py @@ -0,0 +1,180 @@ +"""Opt-in frame and input bridge for Ren'Py 8.5.x. + +The hook targets ``renpy.display.core.Interface.draw_screen`` (the stable +instance method used by the SDK), captures the rendered ``surftree`` after the +real draw, and registers an input pump as a periodic callback so input is +consumed even when the interaction is idle. +""" +from __future__ import annotations + +import json +import os +import struct +import tempfile +import ctypes +from pathlib import Path + +MAGIC = b"AKRF1\0\0\0" + + +class Overlay: + def __init__(self) -> None: + frame = os.environ.get("AETHERKIRI_RENPY_FRAME") + input_path = os.environ.get("AETHERKIRI_RENPY_INPUT") + self.frame_path = Path(frame) if frame else None + self.input_path = Path(input_path) if input_path else None + error = os.environ.get("AETHERKIRI_RENPY_ERROR") + self.error_path = Path(error) if error else None + self.serial = 0 + self._input_offset = 0 + + def _report(self, where: str, error: BaseException) -> None: + """Report bridge failures without taking down the Ren'Py game.""" + if self.error_path is None: + return + try: + self.error_path.parent.mkdir(parents=True, exist_ok=True) + with self.error_path.open("a", encoding="utf-8") as stream: + stream.write(f"{where}: {type(error).__name__}: {error}\n") + except Exception: + pass + + @staticmethod + def _surface_rgba(surface) -> tuple[int, int, bytes]: + """Copy a Ren'Py pygame surface into tightly packed RGBA bytes. + + Ren'Py's pygame compatibility layer does not provide pygame.image + ``tostring``. ``Surface._pixels_address`` is the low-level pointer + exposed by the 8.5.x surface type; copy it while the renderer-owned + surface is alive, then unpack according to its channel masks. + """ + width, height = surface.get_size() + bytesize = int(surface.get_bytesize()) + pitch = int(surface.get_pitch()) + address = getattr(surface, "_pixels_address", 0) + if width <= 0 or height <= 0 or bytesize <= 0 or pitch < width * bytesize: + raise ValueError("invalid Ren'Py surface geometry") + if not isinstance(address, int) or address <= 0: + raise ValueError("Ren'Py surface has no readable pixel address") + raw = ctypes.string_at(address, pitch * height) + masks = tuple(int(v) for v in surface.get_masks()) + shifts = tuple(int(v) for v in surface.get_shifts()) + losses = tuple(int(v) for v in surface.get_losses()) + + def channel(pixel: int, index: int, default: int) -> int: + mask = masks[index] if index < len(masks) else 0 + if not mask: + return default + shift = shifts[index] if index < len(shifts) else 0 + loss = losses[index] if index < len(losses) else 0 + value = (pixel & mask) >> shift + bits = max(1, 8 - loss) + maximum = (1 << bits) - 1 + return (value * 255 + maximum // 2) // maximum + + rgba = bytearray(width * height * 4) + for y in range(height): + row = y * pitch + out = y * width * 4 + for x in range(width): + offset = row + x * bytesize + pixel = int.from_bytes(raw[offset:offset + bytesize], "little") + rgba[out:out + 4] = bytes(( + channel(pixel, 0, 0), + channel(pixel, 1, 0), + channel(pixel, 2, 0), + channel(pixel, 3, 255), + )) + out += 4 + return width, height, bytes(rgba) + + def _publish(self, surface) -> None: + if self.frame_path is None: + return + try: + width, height, pixels = self._surface_rgba(surface) + self.serial += 1 + payload = MAGIC + struct.pack(" None: + if self.input_path is None or not self.input_path.exists(): + return + try: + import renpy.pygame as pygame + with self.input_path.open("rb") as stream: + stream.seek(self._input_offset) + while True: + raw = stream.readline() + if not raw: + break + self._input_offset = stream.tell() + event = json.loads(raw.decode("utf-8")) + attrs = dict(event.get("attributes", {})) + event_type = int(event["type"]) + # The engine ABI has no SDL scancode/repeat fields. Fill + # the Ren'Py pygame defaults so a native provider can send + # just key/modifier/unicode data. + if event_type in (pygame.KEYDOWN, pygame.KEYUP): + attrs.setdefault("mod", 0) + attrs.setdefault("unicode", "") + attrs.setdefault("scancode", 0) + attrs.setdefault("repeat", False) + pygame.event.post(pygame.event.Event(event_type, attrs)) + except Exception as error: + self._report("input", error) + return + + def _draw_screen(self, interface, original, *args, **kwargs): + result = original(interface, *args, **kwargs) + try: + # Interface.draw_screen stores the actual rendered tree on itself. + # Ren'Py exposes the active renderer as the ``draw`` attribute on + # the ``renpy.display`` module. It is not a ``renpy.display.draw`` + # importable submodule (8.5.3 raises ModuleNotFoundError here). + import renpy.display + renderer = getattr(renpy.display, "draw", None) + if renderer is not None and interface.surftree is not None: + self._publish(renderer.screenshot(interface.surftree)) + except Exception as error: + self._report("draw", error) + pass + return result + + def install(self) -> None: + import renpy.config + import renpy.display.core + + klass = renpy.display.core.Interface + original = klass.draw_screen + if getattr(original, "_aetherkiri_overlay", False): + return + + def draw_screen(interface, *args, **kwargs): + return self._draw_screen(interface, original, *args, **kwargs) + + draw_screen._aetherkiri_overlay = True + klass.draw_screen = draw_screen + # PERIODIC events run while the interface waits, unlike a draw hook. + renpy.config.periodic_callbacks.append(self._inject) + + +def install() -> None: + if os.environ.get("AETHERKIRI_RENPY_OVERLAY") == "1": + Overlay().install() diff --git a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp new file mode 100644 index 000000000..dbef32450 --- /dev/null +++ b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp @@ -0,0 +1,1032 @@ +#include "renpy_runtime.h" +#include "engine_runtime_provider.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#define NOMINMAX +#include +#else +#include +#include +#include +#include +#include +#include +#endif + +#ifndef AETHERKIRI_RENPY_SDK_ROOT +#define AETHERKIRI_RENPY_SDK_ROOT "" +#endif +#ifndef AETHERKIRI_RENPY_OVERLAY_SOURCE +#define AETHERKIRI_RENPY_OVERLAY_SOURCE "" +#endif + +namespace aetherkiri::renpy { +namespace { +namespace fs = std::filesystem; + +const char* UnsupportedFrameMessage() { + return "Ren'Py frame bridge has not published a frame yet"; +} + +constexpr std::array kFrameMagic = { + {'A', 'K', 'R', 'F', '1', '\0', '\0', '\0'}}; +constexpr size_t kFrameHeaderSize = 8 + 4 + 4 + 8 + 4; +constexpr uint32_t kFramePixelFormatRgba8888 = ENGINE_PIXEL_FORMAT_RGBA8888; + +uint32_t ReadU32(const uint8_t* data) { + return static_cast(data[0]) | + (static_cast(data[1]) << 8u) | + (static_cast(data[2]) << 16u) | + (static_cast(data[3]) << 24u); +} + +uint64_t ReadU64(const uint8_t* data) { + uint64_t value = 0; + for (int i = 0; i < 8; ++i) + value |= static_cast(data[i]) << (8u * i); + return value; +} + +std::string Hex(uint64_t value) { + std::ostringstream stream; + stream << std::hex << value; + return stream.str(); +} + +std::string JsonNumber(double value) { + if (!std::isfinite(value)) return "0"; + std::ostringstream stream; + stream << std::setprecision(17) << value; + return stream.str(); +} + +std::string Utf8Codepoint(uint32_t value) { + if (value == 0 || value > 0x10ffffu || + (value >= 0xd800u && value <= 0xdfffu)) return {}; + std::string result; + if (value <= 0x7fu) { + result.push_back(static_cast(value)); + } else if (value <= 0x7ffu) { + result.push_back(static_cast(0xc0u | (value >> 6u))); + result.push_back(static_cast(0x80u | (value & 0x3fu))); + } else if (value <= 0xffffu) { + result.push_back(static_cast(0xe0u | (value >> 12u))); + result.push_back(static_cast(0x80u | ((value >> 6u) & 0x3fu))); + result.push_back(static_cast(0x80u | (value & 0x3fu))); + } else { + result.push_back(static_cast(0xf0u | (value >> 18u))); + result.push_back(static_cast(0x80u | ((value >> 12u) & 0x3fu))); + result.push_back(static_cast(0x80u | ((value >> 6u) & 0x3fu))); + result.push_back(static_cast(0x80u | (value & 0x3fu))); + } + return result; +} + +std::string JsonEscape(const std::string& value) { + std::ostringstream stream; + stream << '"'; + for (unsigned char c : value) { + switch (c) { + case '"': stream << "\\\""; break; + case '\\': stream << "\\\\"; break; + case '\b': stream << "\\b"; break; + case '\f': stream << "\\f"; break; + case '\n': stream << "\\n"; break; + case '\r': stream << "\\r"; break; + case '\t': stream << "\\t"; break; + default: + if (c < 0x20u) { + stream << "\\u00" << std::hex << std::setw(2) + << std::setfill('0') << static_cast(c) + << std::dec << std::setfill(' '); + } else { + stream << static_cast(c); + } + break; + } + } + stream << '"'; + return stream.str(); +} + +bool HasRenpyProject(const fs::path& root) { + std::error_code ec; + if (!fs::is_directory(root, ec)) return false; + // A game directory must contain the script directory. A bare script.rpy + // is accepted for tiny SDK projects, while a random directory is not. + return (fs::is_directory(root / "game", ec) && + (fs::exists(root / "game" / "script.rpy", ec) || + fs::exists(root / "game" / "script.rpyc", ec) || + fs::exists(root / "game" / "options.rpy", ec))) || + fs::exists(root / "script.rpy", ec); +} + +fs::path FindLauncher(const fs::path& sdk) { +#if defined(_WIN32) + const char* names[] = {"renpy.exe", "renpy"}; +#elif defined(__APPLE__) + const char* names[] = {"renpy", "renpy.sh"}; +#else + const char* names[] = {"renpy", "renpy.sh"}; +#endif + std::error_code ec; + for (const char* name : names) { + const fs::path candidate = sdk / name; + if (fs::is_regular_file(candidate, ec)) return candidate; + } + return {}; +} + +#if defined(_WIN32) +std::wstring QuoteWindowsArg(const std::wstring& arg) { + // CommandLineToArgvW-compatible quoting. Never pass user input through a + // shell and preserve trailing backslashes before a closing quote. + std::wstring result = L"\""; + size_t slashes = 0; + for (wchar_t c : arg) { + if (c == L'\\') { ++slashes; continue; } + if (c == L'\"') { + result.append(slashes * 2 + 1, L'\\'); + result.push_back(L'\"'); + slashes = 0; + continue; + } + result.append(slashes, L'\\'); + slashes = 0; + result.push_back(c); + } + result.append(slashes * 2, L'\\'); + result.push_back(L'\"'); + return result; +} + +std::wstring Utf8ToWide(const std::string& value) { + if (value.empty()) return {}; + const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(), + static_cast(value.size()), nullptr, 0); + if (size <= 0) return {}; + std::wstring result(static_cast(size), L'\0'); + if (MultiByteToWideChar(CP_UTF8, 0, value.data(), + static_cast(value.size()), result.data(), size) <= 0) + return {}; + return result; +} +#endif + +class SdkProcess final { +public: + SdkProcess() = default; + ~SdkProcess() { Stop(); } + SdkProcess(const SdkProcess&) = delete; + SdkProcess& operator=(const SdkProcess&) = delete; + + bool Start(const fs::path& launcher, const fs::path& project, + const std::vector>& environment, + std::string* error) { + Stop(); +#if defined(_WIN32) + std::wstring launcher_w = launcher.wstring(); + std::wstring command = QuoteWindowsArg(launcher_w) + L" " + + QuoteWindowsArg(project.wstring()); + std::wstring environment_block; + if (!environment.empty()) { + std::vector values; + LPWCH current = GetEnvironmentStringsW(); + if (current == nullptr) { + if (error) *error = "failed to read process environment"; + return false; + } + for (LPWCH entry = current; *entry != L'\0'; + entry += std::wcslen(entry) + 1) + values.emplace_back(entry); + FreeEnvironmentStringsW(current); + for (const auto& [key, value] : environment) { + const std::wstring wide_key = Utf8ToWide(key); + const std::wstring wide_value = Utf8ToWide(value); + const std::wstring prefix = wide_key + L"="; + const auto found = std::find_if( + values.begin(), values.end(), [&](const std::wstring& existing) { + return existing.compare(0, prefix.size(), prefix) == 0; + }); + const std::wstring replacement = prefix + wide_value; + if (found == values.end()) values.push_back(replacement); + else *found = replacement; + } + for (const std::wstring& value : values) { + environment_block.append(value); + environment_block.push_back(L'\0'); + } + environment_block.push_back(L'\0'); + } + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION process{}; + std::vector mutable_command(command.begin(), command.end()); + mutable_command.push_back(L'\0'); + const DWORD creation_flags = CREATE_NO_WINDOW | + (environment_block.empty() ? 0u : CREATE_UNICODE_ENVIRONMENT); + if (!CreateProcessW(launcher_w.c_str(), mutable_command.data(), nullptr, + nullptr, FALSE, + creation_flags | CREATE_SUSPENDED, nullptr, + environment_block.empty() ? nullptr + : environment_block.data(), + &startup, &process)) { + if (error) *error = "failed to launch Ren'Py SDK: " + + std::to_string(GetLastError()); + return false; + } + // A Ren'Py launcher may hand work to Python helper processes. A Job + // Object makes those descendants part of this runtime's ownership + // tree, so closing the runtime cannot leave them behind. + HANDLE job = CreateJobObjectW(nullptr, nullptr); + if (job == nullptr) { + TerminateProcess(process.hProcess, 0xC000013A); + (void)WaitForSingleObject(process.hProcess, 2000); + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + if (error) *error = "failed to create Ren'Py process job: " + + std::to_string(GetLastError()); + return false; + } + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, + &limits, sizeof(limits)) || + !AssignProcessToJobObject(job, process.hProcess)) { + const DWORD failure = GetLastError(); + TerminateProcess(process.hProcess, 0xC000013A); + (void)WaitForSingleObject(process.hProcess, 2000); + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + CloseHandle(job); + if (error) *error = "failed to own Ren'Py process tree: " + + std::to_string(failure); + return false; + } + if (ResumeThread(process.hThread) == static_cast(-1)) { + const DWORD failure = GetLastError(); + TerminateProcess(process.hProcess, 0xC000013A); + (void)WaitForSingleObject(process.hProcess, 2000); + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + CloseHandle(job); + if (error) *error = "failed to start Ren'Py process: " + + std::to_string(failure); + return false; + } + CloseHandle(process.hThread); + process_ = process.hProcess; + job_ = job; + return true; +#else + const std::string exe = launcher.string(); + const std::string root = project.string(); + char* const argv[] = {const_cast(exe.c_str()), + const_cast(root.c_str()), nullptr}; + std::vector environment_storage; + std::vector environment_argv; + if (!environment.empty()) { + for (char** entry = environ; entry != nullptr && *entry != nullptr; + ++entry) + environment_storage.emplace_back(*entry); + for (const auto& [key, value] : environment) { + const std::string prefix = key + "="; + const auto found = std::find_if( + environment_storage.begin(), environment_storage.end(), + [&](const std::string& existing) { + return existing.compare(0, prefix.size(), prefix) == 0; + }); + const std::string replacement = prefix + value; + if (found == environment_storage.end()) + environment_storage.push_back(replacement); + else + *found = replacement; + } + environment_argv.reserve(environment_storage.size() + 1); + for (std::string& entry : environment_storage) + environment_argv.push_back(entry.data()); + environment_argv.push_back(nullptr); + } + // posix_spawn avoids running C++ code in a post-fork child of the + // multithreaded Godot host. It also preserves argv boundaries and + // never invokes a shell. Put the SDK and any children it starts in a + // private process group so shutdown cannot leave a Python child behind. + posix_spawnattr_t attributes; + if (posix_spawnattr_init(&attributes) != 0) { + if (error) *error = "failed to initialize Ren'Py process attributes"; + return false; + } + const short flags = POSIX_SPAWN_SETPGROUP; + int result = posix_spawnattr_setflags(&attributes, flags); + if (result == 0) result = posix_spawnattr_setpgroup(&attributes, 0); + pid_t child = -1; + if (result == 0) { + result = posix_spawn(&child, exe.c_str(), nullptr, &attributes, + argv, environment.empty() ? environ + : environment_argv.data()); + } + (void)posix_spawnattr_destroy(&attributes); + if (result != 0) { + if (error) *error = "failed to launch Ren'Py SDK: " + + std::string(std::strerror(result)); + return false; + } + leader_pid_ = child; + process_group_id_ = child; + return true; +#endif + } + + bool Running(std::string* error) { +#if defined(_WIN32) + if (process_ == nullptr) return false; + const DWORD result = WaitForSingleObject(process_, 0); + if (result == WAIT_TIMEOUT) return true; + if (result == WAIT_OBJECT_0) { + DWORD exit_code = 0; + GetExitCodeProcess(process_, &exit_code); + if (error) *error = "Ren'Py SDK exited with status " + + std::to_string(exit_code); + CloseHandle(process_); process_ = nullptr; + return false; + } + if (error) *error = "failed to query Ren'Py SDK process"; + return false; +#else + if (leader_pid_ <= 0) return false; + int status = 0; + pid_t result = -1; + do { + result = waitpid(leader_pid_, &status, WNOHANG); + } while (result < 0 && errno == EINTR); + if (result == 0) return true; + if (result == leader_pid_) { + leader_pid_ = -1; + if (error) { + if (WIFEXITED(status)) *error = "Ren'Py SDK exited with status " + + std::to_string(WEXITSTATUS(status)); + else if (WIFSIGNALED(status)) *error = "Ren'Py SDK terminated by signal " + + std::to_string(WTERMSIG(status)); + else *error = "Ren'Py SDK exited"; + } + return false; + } + if (result < 0 && errno != EINTR && error) *error = std::strerror(errno); + return result == 0; +#endif + } + + void Pause(bool pause) { +#if defined(_WIN32) + // The Windows SDK does not expose a stable suspend API; leave process + // ownership intact and let the caller receive NOT_SUPPORTED. + (void)pause; +#else + if (leader_pid_ > 0) (void)kill(process_group_id_ > 0 ? -process_group_id_ + : leader_pid_, + pause ? SIGSTOP : SIGCONT); +#endif + } + + void Stop() { +#if defined(_WIN32) + if (process_ != nullptr) { + if (WaitForSingleObject(process_, 0) == WAIT_TIMEOUT) { + TerminateProcess(process_, 0xC000013A); + WaitForSingleObject(process_, 2000); + } + CloseHandle(process_); process_ = nullptr; + } + if (job_ != nullptr) { + // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE terminates any descendants + // that outlived the launcher, including after its handle closed. + CloseHandle(job_); job_ = nullptr; + } +#else + StopPosix(); +#endif + } + + bool valid() const { +#if defined(_WIN32) + return process_ != nullptr; +#else + return leader_pid_ > 0; +#endif + } + +private: +#if defined(_WIN32) + HANDLE process_ = nullptr; + HANDLE job_ = nullptr; +#else + pid_t leader_pid_ = -1; + pid_t process_group_id_ = -1; +#endif + +#if !defined(_WIN32) + void StopPosix() { + const pid_t leader = leader_pid_; + const pid_t group = process_group_id_; + if (leader <= 0 && group <= 0) return; + + auto group_exists = [group] { + if (group <= 0) return false; + errno = 0; + const int result = kill(-group, 0); + return result == 0 || errno == EPERM; + }; + auto reap_leader = [this] { + if (leader_pid_ <= 0) return false; + int status = 0; + for (;;) { + const pid_t result = waitpid(leader_pid_, &status, WNOHANG); + if (result == leader_pid_ || (result < 0 && errno == ECHILD)) { + leader_pid_ = -1; + return true; + } + if (result < 0 && errno == EINTR) continue; + return false; + } + }; + + // SIGSTOP also stops the leader, so always continue the group before + // asking it to terminate. Otherwise a paused SDK survives the grace + // period and is only killed, skipping normal signal handling. + if (group_exists()) { + (void)kill(-group, SIGCONT); + (void)kill(-group, SIGTERM); + } else { + (void)reap_leader(); + } + + // Bound graceful shutdown. We only signal a group while kill(..., 0) + // confirms that the original process group still exists; this avoids + // sending a stale PGID to an unrelated process after it disappears. + for (int i = 0; i < 200; ++i) { + (void)reap_leader(); + if (!group_exists()) { + leader_pid_ = -1; + process_group_id_ = -1; + return; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + if (group_exists()) { + (void)kill(-group, SIGCONT); + (void)kill(-group, SIGKILL); + } + // A leader that ignored SIGTERM is still our child; reap it without + // waiting on arbitrary descendants. + if (leader_pid_ > 0) { + for (;;) { + const pid_t result = waitpid(leader_pid_, nullptr, 0); + if (result == leader_pid_ || (result < 0 && errno == ECHILD)) break; + if (result < 0 && errno == EINTR) continue; + break; + } + } + leader_pid_ = -1; + process_group_id_ = -1; + } +#endif +}; + +struct Runtime { + engine_runtime_host_v1_t host{}; + fs::path sdk_root = fs::path(AETHERKIRI_RENPY_SDK_ROOT); + fs::path project; + fs::path bridge_root; + fs::path staged_module; + fs::path staged_loader; + fs::path frame_path; + fs::path input_path; + fs::path error_path; + bool staged_libs_directory = false; + uint32_t frame_width = 0; + uint32_t frame_height = 0; + uint64_t frame_serial = 0; + std::vector frame_pixels; + std::mutex frame_mutex; + SdkProcess process; + bool opened = false; + bool paused = false; + std::string error; + + ~Runtime() { + process.Stop(); + CleanupBridge(); + } + + void CleanupBridge() { + std::error_code ec; + if (!staged_module.empty()) fs::remove(staged_module, ec); + ec.clear(); + if (!staged_loader.empty()) fs::remove(staged_loader, ec); + ec.clear(); + if (staged_libs_directory) { + const fs::path libs = project / "game" / "libs"; + fs::remove(libs, ec); + } + ec.clear(); + if (!frame_path.empty()) fs::remove(frame_path, ec); + ec.clear(); + if (!input_path.empty()) fs::remove(input_path, ec); + ec.clear(); + if (!error_path.empty()) fs::remove(error_path, ec); + ec.clear(); + if (!bridge_root.empty()) fs::remove_all(bridge_root, ec); + staged_module.clear(); + staged_loader.clear(); + bridge_root.clear(); + frame_path.clear(); + input_path.clear(); + error_path.clear(); + staged_libs_directory = false; + } +}; + +Runtime* Cast(void* value) { return static_cast(value); } + +std::atomic g_bridge_counter{0}; + +uint64_t CurrentProcessIdValue() { +#if defined(_WIN32) + return static_cast(GetCurrentProcessId()); +#else + return static_cast(getpid()); +#endif +} + +bool WriteTextFile(const fs::path& path, const std::string& text, + std::string* error) { + std::ofstream stream(path, std::ios::binary | std::ios::trunc); + if (!stream) { + if (error) *error = "failed to stage Ren'Py overlay file: " + path.string(); + return false; + } + stream.write(text.data(), static_cast(text.size())); + stream.flush(); + if (!stream) { + if (error) *error = "failed to write Ren'Py overlay file: " + path.string(); + return false; + } + return true; +} + +bool PrepareBridge(Runtime& runtime) { + runtime.CleanupBridge(); + const fs::path source = fs::u8path(AETHERKIRI_RENPY_OVERLAY_SOURCE); + std::error_code ec; + if (source.empty() || !fs::is_regular_file(source, ec)) { + runtime.error = + "Ren'Py overlay source is unavailable in this build; frame bridge " + "cannot be activated"; + return false; + } + std::ifstream source_stream(source, std::ios::binary); + if (!source_stream) { + runtime.error = "failed to read Ren'Py overlay source: " + source.string(); + return false; + } + const std::string source_text((std::istreambuf_iterator(source_stream)), + std::istreambuf_iterator()); + if (source_text.empty()) { + runtime.error = "Ren'Py overlay source is empty"; + return false; + } + + const fs::path libs = runtime.project / "game" / "libs"; + if (fs::exists(libs, ec)) { + if (!fs::is_directory(libs, ec)) { + runtime.error = "Ren'Py game/libs exists but is not a directory"; + return false; + } + } else { + ec.clear(); + if (!fs::create_directory(libs, ec)) { + runtime.error = "Ren'Py game/libs is not writable: " + ec.message(); + return false; + } + runtime.staged_libs_directory = true; + } + + const uint64_t id = g_bridge_counter.fetch_add(1) + 1; + const std::string token = "ak_" + Hex(CurrentProcessIdValue()) + "_" + + Hex(id) + "_" + + Hex(static_cast( + std::chrono::steady_clock::now() + .time_since_epoch() + .count())); + runtime.staged_module = libs / ("aetherkiri_overlay_" + token + ".py"); + runtime.staged_loader = libs / ("aetherkiri_loader_" + token + ".rpe.py"); + runtime.frame_path = fs::temp_directory_path() / + ("aetherkiri-renpy-frame-" + token + ".bin"); + runtime.input_path = fs::temp_directory_path() / + ("aetherkiri-renpy-input-" + token + ".jsonl"); + runtime.error_path = fs::temp_directory_path() / + ("aetherkiri-renpy-error-" + token + ".log"); + runtime.bridge_root = fs::temp_directory_path() / + ("aetherkiri-renpy-bridge-" + token); + ec.clear(); + if (!fs::create_directory(runtime.bridge_root, ec)) { + if (fs::is_directory(runtime.bridge_root, ec)) { + runtime.error = "Ren'Py bridge transport directory already exists"; + } else { + runtime.error = + "failed to create Ren'Py bridge transport directory: " + + ec.message(); + } + runtime.CleanupBridge(); + return false; + } + + const std::string module_name = runtime.staged_module.stem().string(); + const std::string loader = + "import os, sys\n" + "if os.environ.get('AETHERKIRI_RENPY_OVERLAY') == '1':\n" + " sys.dont_write_bytecode = True\n" + " sys.path.insert(0, os.path.dirname(__file__))\n" + " import " + module_name + "\n" + " " + module_name + ".install()\n"; + if (!WriteTextFile(runtime.staged_module, source_text, &runtime.error) || + !WriteTextFile(runtime.staged_loader, loader, &runtime.error)) { + runtime.CleanupBridge(); + return false; + } + std::ofstream(runtime.input_path, std::ios::binary | std::ios::trunc).close(); + if (!fs::exists(runtime.input_path, ec)) { + runtime.error = "failed to create Ren'Py input transport"; + runtime.CleanupBridge(); + return false; + } + return true; +} + +bool RefreshFrame(Runtime& runtime) { + if (runtime.frame_path.empty()) return false; + std::ifstream stream(runtime.frame_path, std::ios::binary); + if (!stream) return false; + std::array header{}; + stream.read(reinterpret_cast(header.data()), + static_cast(header.size())); + if (stream.gcount() != static_cast(header.size())) return false; + if (!std::equal(kFrameMagic.begin(), kFrameMagic.end(), header.begin())) { + runtime.error = "Ren'Py frame transport has an invalid magic header"; + return false; + } + const uint32_t width = ReadU32(header.data() + 8); + const uint32_t height = ReadU32(header.data() + 12); + const uint64_t serial = ReadU64(header.data() + 16); + const uint32_t payload_size = ReadU32(header.data() + 24); + const uint64_t expected = static_cast(width) * height * 4u; + if (width == 0 || height == 0 || serial == 0 || expected != payload_size || + expected > 256u * 1024u * 1024u) { + runtime.error = "Ren'Py frame transport has invalid dimensions or payload"; + return false; + } + std::vector pixels(payload_size); + stream.read(reinterpret_cast(pixels.data()), + static_cast(pixels.size())); + if (stream.gcount() != static_cast(pixels.size())) return false; + { + std::lock_guard guard(runtime.frame_mutex); + if (serial <= runtime.frame_serial) return true; + runtime.frame_width = width; + runtime.frame_height = height; + runtime.frame_serial = serial; + runtime.frame_pixels = std::move(pixels); + runtime.error.clear(); + } + return true; +} + +void RefreshDiagnostics(Runtime& runtime) { + if (runtime.error_path.empty()) return; + std::ifstream stream(runtime.error_path, std::ios::binary); + if (!stream) return; + std::string text((std::istreambuf_iterator(stream)), + std::istreambuf_iterator()); + if (text.size() > 4096) text.erase(0, text.size() - 4096); + if (!text.empty()) runtime.error = "Ren'Py overlay: " + text; +} + +bool AppendInput(Runtime& runtime, const std::string& line) { + if (runtime.input_path.empty()) return false; + std::ofstream stream(runtime.input_path, std::ios::binary | std::ios::app); + if (!stream) { + runtime.error = "failed to open Ren'Py input transport"; + return false; + } + stream << line << '\n'; + stream.flush(); + if (!stream) { + runtime.error = "failed to write Ren'Py input transport"; + return false; + } + return true; +} + +int32_t Probe(void*, const char* path) { + if (!path || !*path) return 0; + try { return HasRenpyProject(fs::u8path(path)) ? 65 : 0; } + catch (...) { return 0; } +} + +engine_result_t Create(void*, const engine_runtime_host_v1_t* host, + const engine_create_desc_t* desc, void** output) { + if (!host || !desc || !output || host->struct_size < sizeof(*host) || + desc->struct_size < sizeof(*desc)) return ENGINE_RESULT_INVALID_ARGUMENT; + *output = nullptr; + try { + auto runtime = std::make_unique(); + runtime->host = *host; + *output = runtime.release(); + return ENGINE_RESULT_OK; + } catch (...) { return ENGINE_RESULT_INTERNAL_ERROR; } +} +void Destroy(void* value) { delete Cast(value); } + +engine_result_t Open(void* value, const char* path, const char* startup) { + if (!value || !path) return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + if (runtime.opened) { + runtime.error = "Ren'Py game is already open"; + return ENGINE_RESULT_INVALID_STATE; + } + (void)startup; // Ren'Py selects its own script from game/options.rpy. + runtime.project = fs::u8path(path); + std::error_code ec; + if (!HasRenpyProject(runtime.project)) { + runtime.error = "directory is not a Ren'Py project (expected game/script.rpy)"; + return ENGINE_RESULT_INVALID_ARGUMENT; + } + if (runtime.sdk_root.empty() || !fs::is_directory(runtime.sdk_root, ec)) { + runtime.error = "Ren'Py SDK path is not configured; set AETHERKIRI_RENPY_SDK_ROOT"; + return ENGINE_RESULT_NOT_SUPPORTED; + } + const fs::path launcher = FindLauncher(runtime.sdk_root); + if (launcher.empty()) { + runtime.error = "Ren'Py SDK launcher not found under configured SDK path"; + return ENGINE_RESULT_NOT_SUPPORTED; + } + if (!PrepareBridge(runtime)) return ENGINE_RESULT_NOT_SUPPORTED; + const std::vector> environment = { + {"AETHERKIRI_RENPY_OVERLAY", "1"}, + {"AETHERKIRI_RENPY_FRAME", runtime.frame_path.string()}, + {"AETHERKIRI_RENPY_INPUT", runtime.input_path.string()}, + {"AETHERKIRI_RENPY_ERROR", runtime.error_path.string()}, + }; + if (!runtime.process.Start(launcher, runtime.project, environment, + &runtime.error)) { + runtime.CleanupBridge(); + return ENGINE_RESULT_IO_ERROR; + } + if (!runtime.process.Running(&runtime.error)) { + runtime.error = runtime.error.empty() + ? "Ren'Py SDK exited during startup" : runtime.error; + runtime.CleanupBridge(); + return ENGINE_RESULT_IO_ERROR; + } + runtime.opened = true; + return ENGINE_RESULT_OK; +} + +engine_result_t Tick(void* value, uint32_t) { + if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + if (!runtime.opened) return ENGINE_RESULT_INVALID_STATE; + if (!runtime.process.Running(&runtime.error)) { + runtime.opened = false; + runtime.CleanupBridge(); + return ENGINE_RESULT_INVALID_STATE; + } + (void)RefreshFrame(runtime); + RefreshDiagnostics(runtime); + return ENGINE_RESULT_OK; +} +engine_result_t Pause(void* value) { + if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + if (!runtime.opened || !runtime.process.valid()) return ENGINE_RESULT_INVALID_STATE; +#if defined(_WIN32) + runtime.error = "Ren'Py process pause is unavailable on Windows"; + return ENGINE_RESULT_NOT_SUPPORTED; +#else + runtime.process.Pause(true); runtime.paused = true; return ENGINE_RESULT_OK; +#endif +} +engine_result_t Resume(void* value) { + if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + if (!runtime.opened || !runtime.process.valid()) return ENGINE_RESULT_INVALID_STATE; +#if defined(_WIN32) + runtime.error = "Ren'Py process resume is unavailable on Windows"; + return ENGINE_RESULT_NOT_SUPPORTED; +#else + runtime.process.Pause(false); runtime.paused = false; return ENGINE_RESULT_OK; +#endif +} +engine_result_t Option(void* value, const engine_option_t* option) { + if (!value || !option || !option->key_utf8 || !option->value_utf8) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + const std::string key = option->key_utf8; + if (key == "renpy_sdk_path") { + if (runtime.opened) { + runtime.error = "renpy_sdk_path must be set before opening a game"; + return ENGINE_RESULT_INVALID_STATE; + } + runtime.sdk_root = fs::u8path(option->value_utf8); + return ENGINE_RESULT_OK; + } + return key.rfind("renpy_", 0) == 0 ? ENGINE_RESULT_NOT_SUPPORTED + : ENGINE_RESULT_OK; +} +engine_result_t Resize(void* value, uint32_t width, uint32_t height) { + if (!value || width == 0 || height == 0) return ENGINE_RESULT_INVALID_ARGUMENT; + Cast(value)->error = + "Ren'Py SDK surface size is controlled by game/options.rpy"; + return ENGINE_RESULT_NOT_SUPPORTED; +} +engine_result_t Frame(void* value, engine_frame_desc_t* frame) { + if (!value || !frame || frame->struct_size < sizeof(*frame)) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + (void)RefreshFrame(runtime); + RefreshDiagnostics(runtime); + std::lock_guard guard(runtime.frame_mutex); + if (runtime.frame_pixels.empty()) { + runtime.error = UnsupportedFrameMessage(); + return ENGINE_RESULT_NOT_SUPPORTED; + } + frame->width = runtime.frame_width; + frame->height = runtime.frame_height; + frame->stride_bytes = runtime.frame_width * 4u; + frame->pixel_format = kFramePixelFormatRgba8888; + frame->frame_serial = runtime.frame_serial; + return ENGINE_RESULT_OK; +} +engine_result_t Read(void* value, void* pixels, size_t size) { + if (!value || !pixels || size == 0) return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + (void)RefreshFrame(runtime); + RefreshDiagnostics(runtime); + std::lock_guard guard(runtime.frame_mutex); + if (runtime.frame_pixels.empty()) { + runtime.error = UnsupportedFrameMessage(); + return ENGINE_RESULT_NOT_SUPPORTED; + } + if (size < runtime.frame_pixels.size()) { + runtime.error = "Ren'Py frame buffer is too small"; + return ENGINE_RESULT_INVALID_ARGUMENT; + } + std::memcpy(pixels, runtime.frame_pixels.data(), runtime.frame_pixels.size()); + return ENGINE_RESULT_OK; +} +engine_result_t NativeFrame(void* value, uint64_t* texture, uint32_t* width, + uint32_t* height, uint64_t* serial) { + if (!value || !texture || !width || !height || !serial) + return ENGINE_RESULT_INVALID_ARGUMENT; + Cast(value)->error = + "Ren'Py native GPU texture import is unavailable; use RGBA frame data"; + return ENGINE_RESULT_NOT_SUPPORTED; +} +engine_result_t Input(void* value, const engine_input_event_t* event) { + if (!value || !event || event->struct_size < sizeof(*event)) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + if (!runtime.opened) return ENGINE_RESULT_INVALID_STATE; + int event_type = 0; + std::ostringstream attrs; + switch (event->type) { + case ENGINE_INPUT_EVENT_POINTER_DOWN: + event_type = 1025; // pygame.MOUSEBUTTONDOWN + attrs << "\"pos\":[" << JsonNumber(event->x) << ',' + << JsonNumber(event->y) << "],\"button\":" + << (event->button > 0 ? event->button : 1); + break; + case ENGINE_INPUT_EVENT_POINTER_MOVE: + event_type = 1024; // pygame.MOUSEMOTION + attrs << "\"pos\":[" << JsonNumber(event->x) << ',' + << JsonNumber(event->y) << "],\"rel\":[" + << JsonNumber(event->delta_x) << ',' << JsonNumber(event->delta_y) + << "]"; + break; + case ENGINE_INPUT_EVENT_POINTER_UP: + event_type = 1026; // pygame.MOUSEBUTTONUP + attrs << "\"pos\":[" << JsonNumber(event->x) << ',' + << JsonNumber(event->y) << "],\"button\":" + << (event->button > 0 ? event->button : 1); + break; + case ENGINE_INPUT_EVENT_POINTER_SCROLL: + event_type = 1027; // pygame.MOUSEWHEEL + attrs << "\"x\":" << JsonNumber(event->delta_x) + << ",\"y\":" << JsonNumber(event->delta_y) + << ",\"precise_x\":" << JsonNumber(event->delta_x) + << ",\"precise_y\":" << JsonNumber(event->delta_y); + break; + case ENGINE_INPUT_EVENT_KEY_DOWN: + event_type = 768; // pygame.KEYDOWN + attrs << "\"key\":" << event->key_code << ",\"mod\":" + << event->modifiers << ",\"unicode\":" + << JsonEscape(Utf8Codepoint(event->unicode_codepoint)); + break; + case ENGINE_INPUT_EVENT_KEY_UP: + event_type = 769; // pygame.KEYUP + attrs << "\"key\":" << event->key_code << ",\"mod\":" + << event->modifiers << ",\"unicode\":" + << JsonEscape(Utf8Codepoint(event->unicode_codepoint)); + break; + case ENGINE_INPUT_EVENT_TEXT_INPUT: + event_type = 771; // pygame.TEXTINPUT + attrs << "\"text\":" + << JsonEscape(Utf8Codepoint(event->unicode_codepoint)); + break; + case ENGINE_INPUT_EVENT_BACK: + event_type = 768; // pygame.KEYDOWN / Escape + attrs << "\"key\":27,\"mod\":0,\"unicode\":\"\""; + break; + default: + runtime.error = "unsupported Ren'Py input event type"; + return ENGINE_RESULT_INVALID_ARGUMENT; + } + std::ostringstream line; + line << "{\"type\":" << event_type << ",\"attributes\":{" + << attrs.str() << "}}"; + return AppendInput(runtime, line.str()) ? ENGINE_RESULT_OK + : ENGINE_RESULT_IO_ERROR; +} +engine_result_t Rendered(void* value, uint32_t* rendered) { + if (!value || !rendered) return ENGINE_RESULT_INVALID_ARGUMENT; + auto& runtime = *Cast(value); + (void)RefreshFrame(runtime); + std::lock_guard guard(runtime.frame_mutex); + *rendered = runtime.frame_serial != 0 ? 1u : 0u; + return ENGINE_RESULT_OK; +} +engine_result_t Renderer(void* value, char* buffer, uint32_t size) { + if (!value || !buffer || size == 0) return ENGINE_RESULT_INVALID_ARGUMENT; + const char* text = "Ren'Py official SDK subprocess (AKRF1 RGBA bridge)"; + const size_t required = std::strlen(text) + 1; + if (size < required) return ENGINE_RESULT_INVALID_ARGUMENT; + std::memcpy(buffer, text, required); + return ENGINE_RESULT_OK; +} +engine_result_t TextInput(void* value, uint32_t* flags) { + if (!value || !flags) return ENGINE_RESULT_INVALID_ARGUMENT; + *flags = 0; return ENGINE_RESULT_OK; +} +const char* Error(void* value) { + return value ? Cast(value)->error.c_str() : "Invalid Ren'Py runtime"; +} + +const engine_runtime_provider_v1_t& Provider() { + static const engine_runtime_provider_v1_t provider = [] { + engine_runtime_provider_v1_t p{}; + p.struct_size = sizeof(p); + p.api_version = ENGINE_RUNTIME_PROVIDER_API_VERSION; + p.runtime_id_utf8 = "renpy"; + p.display_name_utf8 = "Ren'Py (official SDK)"; + p.priority = 60; + p.probe = Probe; p.create = Create; p.destroy = Destroy; + p.open_game = Open; p.tick = Tick; p.pause = Pause; p.resume = Resume; + p.set_option = Option; p.set_surface_size = Resize; + p.get_frame_desc = Frame; p.read_frame_rgba = Read; + p.get_godot_native_frame_texture = NativeFrame; + p.send_input = Input; p.get_frame_rendered_flag = Rendered; + p.get_renderer_info = Renderer; p.get_text_input_state = TextInput; + p.get_last_error = Error; + return p; + }(); + return provider; +} +} // namespace + +void RegisterRuntimeProvider() { + static std::once_flag once; + std::call_once(once, [] { engine_register_runtime_provider(&Provider()); }); +} + +} // namespace aetherkiri::renpy diff --git a/bridge/renpy_runtime/tests/provider.cpp b/bridge/renpy_runtime/tests/provider.cpp new file mode 100644 index 000000000..0adaac958 --- /dev/null +++ b/bridge/renpy_runtime/tests/provider.cpp @@ -0,0 +1,257 @@ +#include "renpy_runtime.h" +#include "engine_api.h" +#include "engine_runtime_provider.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef AETHERKIRI_RENPY_TEST_SDK_ROOT +#define AETHERKIRI_RENPY_TEST_SDK_ROOT "" +#endif + +#if !defined(_WIN32) +#include +#include +#include +#endif + +namespace { +std::filesystem::path MakeProject() { + const auto root = std::filesystem::temp_directory_path() / + "aetherkiri-renpy-provider-test"; + std::error_code ec; + std::filesystem::remove_all(root, ec); + std::filesystem::create_directories(root / "game", ec); + std::ofstream(root / "game" / "script.rpy") << "label start:\n pass\n"; + return root; +} + +#if !defined(_WIN32) +bool ProcessExists(pid_t pid) { + if (pid <= 0) return false; + errno = 0; + return kill(pid, 0) == 0 || errno == EPERM; +} + +pid_t ReadPid(const std::filesystem::path& path) { + for (int i = 0; i < 100; ++i) { + std::ifstream input(path); + pid_t pid = -1; + if (input >> pid && pid > 0) return pid; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return -1; +} + +void WriteFakeLauncher(const std::filesystem::path& sdk) { + std::ofstream launcher(sdk / "renpy"); + launcher << "#!/bin/sh\n" + << "project=$1\n" + << "if [ -f \"$project/ignore-term\" ]; then\n" + << " (trap '' TERM; while :; do sleep 1; done) &\n" + << "else\n" + << " (while :; do sleep 1; done) &\n" + << "fi\n" + << "child=$!\n" + << "echo $child > \"$project/descendant.pid\"\n" + << "if [ -f \"$project/exit-leader\" ]; then exit 0; fi\n" + << "trap 'exit 0' TERM INT\n" + << "wait $child\n"; + launcher.close(); + std::filesystem::permissions( + sdk / "renpy", + std::filesystem::perms::owner_exec | std::filesystem::perms::owner_read | + std::filesystem::perms::owner_write, + std::filesystem::perm_options::add); +} + +void CheckProcessGroupShutdown() { + namespace fs = std::filesystem; + const auto root = fs::temp_directory_path() / + "aetherkiri-renpy-lifecycle-test"; + std::error_code ec; + fs::remove_all(root, ec); + fs::create_directories(root / "sdk", ec); + fs::create_directories(root / "game", ec); + std::ofstream(root / "game" / "script.rpy") << "label start:\n pass\n"; + WriteFakeLauncher(root / "sdk"); + + auto open = [&](const fs::path& project) { + engine_create_desc_t desc{}; + desc.struct_size = sizeof(desc); + desc.api_version = ENGINE_API_VERSION; + const auto writable = root.u8string(); + desc.writable_path_utf8 = writable.c_str(); + engine_handle_t handle = nullptr; + assert(engine_create(&desc, &handle) == ENGINE_RESULT_OK); + engine_option_t option{}; + option.key_utf8 = "runtime"; + option.value_utf8 = "renpy"; + assert(engine_set_option(handle, &option) == ENGINE_RESULT_OK); + const auto sdk = (root / "sdk").u8string(); + option.key_utf8 = "renpy_sdk_path"; + option.value_utf8 = sdk.c_str(); + assert(engine_set_option(handle, &option) == ENGINE_RESULT_OK); + const auto path = project.u8string(); + const engine_result_t result = engine_open_game(handle, path.c_str(), nullptr); + return std::pair{handle, result}; + }; + + // The launcher exits after handing work to a descendant. Tick observes + // the leader reap, then destruction must still clean up its process group. + std::ofstream(root / "exit-leader").put('\n'); + std::ofstream(root / "ignore-term").put('\n'); + auto exited = open(root); + std::filesystem::remove(root / "exit-leader", ec); + const pid_t exited_child = ReadPid(root / "descendant.pid"); + assert(exited_child > 0); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + (void)engine_tick(exited.first, 0); + engine_destroy(exited.first); + for (int i = 0; i < 100 && ProcessExists(exited_child); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + assert(!ProcessExists(exited_child)); + + // A stopped process group must be continued before TERM. The bounded + // shutdown should finish well below the two-second SIGTERM grace period. + std::filesystem::remove(root / "ignore-term", ec); + auto paused = open(root); + assert(paused.second == ENGINE_RESULT_OK); + const pid_t paused_child = ReadPid(root / "descendant.pid"); + assert(paused_child > 0); + assert(engine_pause(paused.first) == ENGINE_RESULT_OK); + const auto started = std::chrono::steady_clock::now(); + engine_destroy(paused.first); + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started); + assert(elapsed.count() < 3500); + for (int i = 0; i < 100 && ProcessExists(paused_child); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + assert(!ProcessExists(paused_child)); + fs::remove_all(root, ec); +} +#endif + +void CheckFrameInputBridge() { + namespace fs = std::filesystem; + const auto root = fs::temp_directory_path() / + ("aetherkiri-renpy-frame-test-" + + std::to_string(std::chrono::steady_clock::now() + .time_since_epoch() + .count())); + std::error_code ec; + fs::create_directories(root / "game", ec); + std::ofstream(root / "game" / "options.rpy") + << "define config.name = \"AetherKiri bridge test\"\n" + << "define config.version = \"1\"\n" + << "define config.screen_width = 640\n" + << "define config.screen_height = 360\n" + << "define config.gl_enable = False\n" + << "define config.performance_test = False\n"; + std::ofstream(root / "game" / "script.rpy") << R"R(label main_menu: + jump start +label start: + scene Solid("#194a72") + menu: + "Input bridge continue": + $ open(renpy.config.gamedir + "/input-ok", "w").write("continue\n") + $ renpy.quit() + "Input bridge finish": + $ open(renpy.config.gamedir + "/input-ok", "w").write("finish\n") + $ renpy.quit() +)R"; +#if defined(_WIN32) + _putenv_s("SDL_VIDEODRIVER", "dummy"); + _putenv_s("SDL_AUDIODRIVER", "dummy"); + _putenv_s("RENPY_RENDERER", "sw"); +#else + setenv("SDL_VIDEODRIVER", "dummy", 1); + setenv("SDL_AUDIODRIVER", "dummy", 1); + setenv("RENPY_RENDERER", "sw", 1); +#endif + engine_create_desc_t desc{}; + desc.struct_size = sizeof(desc); + desc.api_version = ENGINE_API_VERSION; + const auto writable = (root / "host-data").u8string(); + desc.writable_path_utf8 = writable.c_str(); + engine_handle_t handle = nullptr; + assert(engine_create(&desc, &handle) == ENGINE_RESULT_OK); + engine_option_t option{}; + option.key_utf8 = "runtime"; + option.value_utf8 = "renpy"; + assert(engine_set_option(handle, &option) == ENGINE_RESULT_OK); + option.key_utf8 = "renpy_sdk_path"; + option.value_utf8 = AETHERKIRI_RENPY_TEST_SDK_ROOT; + assert(engine_set_option(handle, &option) == ENGINE_RESULT_OK); + const auto path = root.u8string(); + assert(engine_open_game(handle, path.c_str(), nullptr) == ENGINE_RESULT_OK); + engine_frame_desc_t frame{}; + frame.struct_size = sizeof(frame); + bool ready = false; + for (int i = 0; i < 200; ++i) { + assert(engine_tick(handle, 16) == ENGINE_RESULT_OK); + if (engine_get_frame_desc(handle, &frame) == ENGINE_RESULT_OK) { + ready = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + assert(ready && frame.width == 640 && frame.height == 360 && + frame.stride_bytes == 640 * 4 && frame.frame_serial != 0); + std::vector pixels(frame.stride_bytes * frame.height); + assert(engine_read_frame_rgba(handle, pixels.data(), pixels.size()) == + ENGINE_RESULT_OK); + size_t nonzero = 0; + for (uint8_t value : pixels) nonzero += value != 0; + assert(nonzero != 0); + engine_input_event_t event{}; + event.struct_size = sizeof(event); + event.type = ENGINE_INPUT_EVENT_KEY_DOWN; + event.key_code = 1073741905; + assert(engine_send_input(handle, &event) == ENGINE_RESULT_OK); + event.key_code = 13; + assert(engine_send_input(handle, &event) == ENGINE_RESULT_OK); + for (int i = 0; i < 100 && !fs::exists(root / "game" / "input-ok"); ++i) { + assert(engine_tick(handle, 16) == ENGINE_RESULT_OK); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + assert(fs::exists(root / "game" / "input-ok")); + assert(engine_destroy(handle) == ENGINE_RESULT_OK); + assert(!fs::exists(root / "game" / "libs")); + std::ofstream(root / "game" / "libs").put('\n'); + engine_handle_t blocked = nullptr; + assert(engine_create(&desc, &blocked) == ENGINE_RESULT_OK); + option.key_utf8 = "runtime"; + option.value_utf8 = "renpy"; + assert(engine_set_option(blocked, &option) == ENGINE_RESULT_OK); + option.key_utf8 = "renpy_sdk_path"; + option.value_utf8 = AETHERKIRI_RENPY_TEST_SDK_ROOT; + assert(engine_set_option(blocked, &option) == ENGINE_RESULT_OK); + assert(engine_open_game(blocked, path.c_str(), nullptr) == + ENGINE_RESULT_NOT_SUPPORTED); + assert(engine_destroy(blocked) == ENGINE_RESULT_OK); + fs::remove(root / "game" / "libs", ec); + fs::remove_all(root, ec); +} +} + +int main() { + const auto project = MakeProject(); + aetherkiri::renpy::RegisterRuntimeProvider(); + assert(engine_get_runtime_provider_count() > 0); + assert(engine_probe_runtime_provider("renpy", project.u8string().c_str()) > 0); +#if !defined(_WIN32) + CheckProcessGroupShutdown(); +#endif + CheckFrameInputBridge(); + std::error_code ec; + std::filesystem::remove_all(project, ec); + return 0; +} diff --git a/scripts/build_linux.sh b/scripts/build_linux.sh index 2b019f0e9..47bd357ef 100755 --- a/scripts/build_linux.sh +++ b/scripts/build_linux.sh @@ -183,10 +183,34 @@ stage_release_extension_for_editor_scan() { -exec cp -Lf {} "$debug_dir/" \; } +renpy_cmake_args=() +renpy_enabled="${AETHERKIRI_ENABLE_RENPY:-OFF}" +case "$(printf '%s' "$renpy_enabled" | tr '[:lower:]' '[:upper:]')" in + ON|TRUE|YES|1) + renpy_sdk_root="${AETHERKIRI_RENPY_SDK_ROOT:-}" + if [[ -z "$renpy_sdk_root" || ! -d "$renpy_sdk_root" ]]; then + echo "Error: AETHERKIRI_ENABLE_RENPY requires AETHERKIRI_RENPY_SDK_ROOT" >&2 + exit 1 + fi + renpy_cmake_args+=( + -D "AETHERKIRI_ENABLE_RENPY=ON" + -D "AETHERKIRI_RENPY_SDK_ROOT=$renpy_sdk_root" + ) + ;; + OFF|FALSE|NO|0|'') + renpy_cmake_args+=( -D "AETHERKIRI_ENABLE_RENPY=OFF" ) + ;; + *) + echo "Error: AETHERKIRI_ENABLE_RENPY must be ON/OFF or true/false" >&2 + exit 1 + ;; +esac + echo "==> Building Linux engine and Godot extension" cmake --preset "$CMAKE_CONFIG_PRESET" \ -D "CMAKE_MAKE_PROGRAM=$CMAKE_MAKE_PROGRAM" \ - -D "AETHERKIRI_ENABLE_INTERNAL=${AETHERKIRI_ENABLE_INTERNAL:-ON}" + -D "AETHERKIRI_ENABLE_INTERNAL=${AETHERKIRI_ENABLE_INTERNAL:-ON}" \ + "${renpy_cmake_args[@]}" cmake --build --preset "$CMAKE_BUILD_PRESET" -- -j"$PARALLEL_JOBS" mkdir -p "$GODOT_BIN_DIR" diff --git a/scripts/build_macos.sh b/scripts/build_macos.sh index e8e363286..218af0d8f 100755 --- a/scripts/build_macos.sh +++ b/scripts/build_macos.sh @@ -161,6 +161,27 @@ cmake_config_args=( -D "CMAKE_MAKE_PROGRAM=$CMAKE_MAKE_PROGRAM" -D "AETHERKIRI_ENABLE_INTERNAL=${AETHERKIRI_ENABLE_INTERNAL:-ON}" ) +renpy_enabled="${AETHERKIRI_ENABLE_RENPY:-OFF}" +case "$(printf '%s' "$renpy_enabled" | tr '[:lower:]' '[:upper:]')" in + ON|TRUE|YES|1) + renpy_sdk_root="${AETHERKIRI_RENPY_SDK_ROOT:-}" + if [[ -z "$renpy_sdk_root" || ! -d "$renpy_sdk_root" ]]; then + echo "Error: AETHERKIRI_ENABLE_RENPY requires AETHERKIRI_RENPY_SDK_ROOT" >&2 + exit 1 + fi + cmake_config_args+=( + -D "AETHERKIRI_ENABLE_RENPY=ON" + -D "AETHERKIRI_RENPY_SDK_ROOT=$renpy_sdk_root" + ) + ;; + OFF|FALSE|NO|0|'') + cmake_config_args+=( -D "AETHERKIRI_ENABLE_RENPY=OFF" ) + ;; + *) + echo "Error: AETHERKIRI_ENABLE_RENPY must be ON/OFF or true/false" >&2 + exit 1 + ;; +esac if [[ "${SKIP_VCPKG_INSTALL:-}" == "1" ]]; then if [[ ! -d "$VCPKG_ROOT/installed/$VCPKG_TRIPLET" ]]; then echo "Error: SKIP_VCPKG_INSTALL=1 but prebuilt vcpkg triplet is missing: $VCPKG_ROOT/installed/$VCPKG_TRIPLET" >&2 diff --git a/tests/fixtures/renpy_smoke/.gitignore b/tests/fixtures/renpy_smoke/.gitignore new file mode 100644 index 000000000..e39324253 --- /dev/null +++ b/tests/fixtures/renpy_smoke/.gitignore @@ -0,0 +1,10 @@ +# Generated by the Ren'Py SDK. +*.rpyc +*.rpymc +game/cache/ +game/saves/ +cache/ +saves/ +log.txt +errors.txt +trace.txt diff --git a/tests/fixtures/renpy_smoke/README.md b/tests/fixtures/renpy_smoke/README.md new file mode 100644 index 000000000..ff97cc87e --- /dev/null +++ b/tests/fixtures/renpy_smoke/README.md @@ -0,0 +1,25 @@ +# Ren'Py SDK smoke fixture + +This is a source-only Ren'Py project in the layout produced by the official +Ren'Py SDK launcher. It intentionally has no images, audio, compiled scripts, +or SDK files. The script uses only built-in displayables and exercises startup, +dialogue, menu input, and a clean return from `label start`. + +Run the smoke check with an extracted official Ren'Py SDK: + +```sh +RENPY_SDK=/path/to/renpy-8.x-sdk tools/run_renpy_smoke.sh +``` + +`tools/run_renpy_smoke.sh` copies the fixture to a temporary directory before +asking the SDK to compile and initialize it. SDK-generated files are removed +with that temporary directory, so this fixture remains source-only. + +This automated check covers script compilation and project initialization only; +it does not exercise dialogue, choice input, rendering, audio, or the AetherKiri +provider. For interactive verification, run the fixture with the SDK launcher +and choose Start. Do not interpret this check as AetherKiri runtime support. + +The SDK is not downloaded or bundled by the runner. See the official +[SDK downloads](https://www.renpy.org/latest.html) and +[command-line documentation](https://www.renpy.org/doc/html/cli.html). diff --git a/tests/fixtures/renpy_smoke/game/options.rpy b/tests/fixtures/renpy_smoke/game/options.rpy new file mode 100644 index 000000000..1d84152d8 --- /dev/null +++ b/tests/fixtures/renpy_smoke/game/options.rpy @@ -0,0 +1,16 @@ +# Source-only options for the official Ren'Py SDK smoke fixture. +# Keep generated .rpyc and cache files out of this repository. + +define config.name = "AetherKiri Ren'Py Smoke" +define config.version = "1.0" +define config.window_title = "AetherKiri Ren'Py Smoke" + +define config.screen_width = 640 +define config.screen_height = 360 + +define config.developer = True +define config.has_sound = False +define config.has_music = False +define config.has_voice = False + +define build.name = "aetherkiri-renpy-smoke" diff --git a/tests/fixtures/renpy_smoke/game/script.rpy b/tests/fixtures/renpy_smoke/game/script.rpy new file mode 100644 index 000000000..a0cfbb3c3 --- /dev/null +++ b/tests/fixtures/renpy_smoke/game/script.rpy @@ -0,0 +1,27 @@ +# Minimal source fixture written for the official Ren'Py SDK. +# It intentionally uses only built-in displayables and script statements. + +define smoke = Character("Smoke") + +default smoke_choice = "" + +label start: + scene black + with None + + "AetherKiri Ren'Py SDK smoke fixture." + smoke "The official SDK can parse and execute this project." + + menu: + "Continue the smoke test": + $ smoke_choice = "continue" + "Finish the smoke test": + $ smoke_choice = "finish" + + if smoke_choice == "continue": + smoke "Choice input is working." + else: + smoke "The default path is working." + + "Ren'Py smoke test complete." + return diff --git a/tests/fixtures/renpy_smoke/project.json b/tests/fixtures/renpy_smoke/project.json new file mode 100644 index 000000000..3c6fe776b --- /dev/null +++ b/tests/fixtures/renpy_smoke/project.json @@ -0,0 +1,3 @@ +{ + "display_name": "AetherKiri Ren'Py Smoke" +} diff --git a/tools/install_renpy_sdk.sh b/tools/install_renpy_sdk.sh new file mode 100755 index 000000000..db2b1e6a0 --- /dev/null +++ b/tools/install_renpy_sdk.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# Download and unpack the pinned official Ren'Py desktop SDK for CI/development. +# The archive is never checked into this repository. Every cached archive is +# verified before extraction, including archives restored by CI cache actions. +set -euo pipefail + +readonly RENPY_VERSION="8.5.3" +readonly RENPY_DOWNLOAD_ROOT="https://www.renpy.org/dl/${RENPY_VERSION}" +readonly RENPY_LINUX_ARCHIVE="renpy-${RENPY_VERSION}-sdk.tar.bz2" +readonly RENPY_LINUX_SHA256="eb0a9be7f0fb13632fe25ceade9a8bed5a1b4d6b6e83bd19eeeb29e1a1bb4a45" +readonly RENPY_MACOS_ARM_ARCHIVE="renpy-${RENPY_VERSION}-sdkarm.tar.bz2" +readonly RENPY_MACOS_ARM_SHA256="0579782517f203ba3535dcc2dab54e34bfc318f2f2a7510a5130b6f809f901f6" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +platform="$(uname -s | tr '[:upper:]' '[:lower:]')" +arch="$(uname -m)" +destination="${AETHERKIRI_RENPY_SDK_ROOT:-${repo_root}/.aetherkiri-cache/renpy-sdk/${RENPY_VERSION}}" +cache_dir="${AETHERKIRI_RENPY_SDK_CACHE_DIR:-${repo_root}/.aetherkiri-cache/renpy-sdk/downloads}" +print_config=false + +usage() { + cat <<'USAGE' +Usage: tools/install_renpy_sdk.sh [options] + +Download and extract the pinned official Ren'Py 8.5.3 desktop SDK. The +archive is SHA-256 verified before it is used. Existing verified SDK roots are +reused without downloading or extracting again. + +Options: + --platform PLATFORM linux or darwin (default: host platform) + --arch ARCH x86_64 or arm64 (default: host architecture) + --destination PATH SDK extraction directory + --cache-dir PATH Download cache directory + --print-config Print the selected archive metadata and exit + -h, --help Show this help + +Environment: + AETHERKIRI_RENPY_SDK_ROOT Default extraction directory + AETHERKIRI_RENPY_SDK_CACHE_DIR Default download cache directory +USAGE +} + +while (($#)); do + case "$1" in + --platform) + (($# >= 2)) || { echo "--platform requires a value" >&2; exit 2; } + platform="$2"; shift 2 ;; + --arch) + (($# >= 2)) || { echo "--arch requires a value" >&2; exit 2; } + arch="$2"; shift 2 ;; + --destination) + (($# >= 2)) || { echo "--destination requires a value" >&2; exit 2; } + destination="$2"; shift 2 ;; + --cache-dir) + (($# >= 2)) || { echo "--cache-dir requires a value" >&2; exit 2; } + cache_dir="$2"; shift 2 ;; + --print-config) print_config=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +case "$platform" in + linux) + # Ren'Py's sdk archive contains the x86_64 desktop runtime used by the + # Linux product job. ARM Linux builds are intentionally not enabled. + [[ "$arch" == "x86_64" || "$arch" == "amd64" ]] || { + echo "Ren'Py SDK is only enabled for Linux x86_64 (requested $arch)" >&2 + exit 1 + } + archive="$RENPY_LINUX_ARCHIVE" + sha256="$RENPY_LINUX_SHA256" + ;; + darwin|macos) + [[ "$arch" == "arm64" || "$arch" == "aarch64" || "$arch" == "x86_64" || "$arch" == "amd64" ]] || { + echo "Ren'Py SDK is only enabled for macOS arm64/x86_64 (requested $arch)" >&2 + exit 1 + } + platform="darwin" + if [[ "$arch" == "arm64" || "$arch" == "aarch64" ]]; then + # sdkarm is the official Apple Silicon archive. It includes the + # universal macOS launcher and arm64 support files. + archive="$RENPY_MACOS_ARM_ARCHIVE" + sha256="$RENPY_MACOS_ARM_SHA256" + else + # The regular SDK archive contains the universal macOS launcher + # used by Intel hosts (and the Linux x86_64 runtime). + archive="$RENPY_LINUX_ARCHIVE" + sha256="$RENPY_LINUX_SHA256" + fi + ;; + *) + echo "Ren'Py SDK is not available for platform '$platform' (desktop Linux/macOS only)" >&2 + exit 1 + ;; +esac + +archive_url="${RENPY_DOWNLOAD_ROOT}/${archive}" +archive_path="${cache_dir}/${archive}" + +if [[ "$print_config" == true ]]; then + printf 'RENPY_VERSION=%s\nRENPY_PLATFORM=%s\nRENPY_ARCH=%s\nRENPY_ARCHIVE=%s\nRENPY_SHA256=%s\nRENPY_URL=%s\nRENPY_SDK_ROOT=%s\n' \ + "$RENPY_VERSION" "$platform" "$arch" "$archive" "$sha256" "$archive_url" "$destination" + exit 0 +fi + +require_command() { + command -v "$1" >/dev/null 2>&1 || { + echo "required command not found: $1" >&2 + exit 1 + } +} +require_command curl +require_command tar +if ! command -v sha256sum >/dev/null 2>&1 && + ! command -v shasum >/dev/null 2>&1; then + echo "required command not found: sha256sum or shasum" >&2 + exit 1 +fi + +mkdir -p "$cache_dir" "$(dirname "$destination")" +verify_archive() { + [[ -f "$archive_path" ]] || return 1 + if command -v sha256sum >/dev/null 2>&1; then + printf '%s %s\n' "$sha256" "$archive_path" | sha256sum --check --status - + return + fi + local actual + actual="$(shasum -a 256 "$archive_path" | awk '{ print $1 }')" + [[ "$actual" == "$sha256" ]] +} +if ! verify_archive; then + rm -f "$archive_path" "$archive_path.part" + echo "Downloading official Ren'Py ${RENPY_VERSION} SDK (${platform}/${arch})" + curl --fail --location --retry 5 --retry-delay 3 --retry-all-errors \ + --output "$archive_path.part" "$archive_url" + mv -f "$archive_path.part" "$archive_path" + if ! verify_archive; then + echo "SHA-256 verification failed for $archive_path" >&2 + rm -f "$archive_path" + exit 1 + fi +fi + +# A previous interrupted extraction must never be mistaken for a valid SDK. +launcher="$destination/renpy.sh" +if [[ "$platform" == "darwin" ]]; then + launcher="$destination/renpy.sh" +fi +if [[ -x "$launcher" ]]; then + printf 'Ren\x27Py SDK already installed: %s\n' "$destination" + printf 'RENPY_SDK_ROOT=%s\n' "$destination" + exit 0 +fi + +extract_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-sdk.XXXXXX")" +cleanup() { rm -rf "$extract_root"; } +trap cleanup EXIT +# Ensure tar cannot write outside the temporary extraction root. The official +# archive has one relative top-level directory; reject malformed archives. +if tar -tjf "$archive_path" | awk -F/ ' + NF && ($1 == "" || $1 == "." || $1 == ".." || + $1 ~ /\\/ || $1 ~ /[^A-Za-z0-9_.-]/ || + $0 ~ /^\\// || $0 ~ /(^|\\/)\\.\\.?($|\\/)/) { bad=1 } + END { exit bad }'; then + : +else + echo "Refusing archive with an unsafe path" >&2 + exit 1 +fi +tar -xjf "$archive_path" -C "$extract_root" +extracted="$(find "$extract_root" -mindepth 1 -maxdepth 1 -type d -print -quit)" +if [[ -z "$extracted" || ! -x "$extracted/renpy.sh" ]]; then + echo "Official Ren'Py archive did not contain renpy.sh" >&2 + exit 1 +fi +rm -rf "$destination" +mv "$extracted" "$destination" +chmod +x "$destination/renpy.sh" +printf 'Ren\x27Py SDK installed: %s\n' "$destination" +printf 'RENPY_SDK_ROOT=%s\n' "$destination" diff --git a/tools/run_renpy_overlay_probe.sh b/tools/run_renpy_overlay_probe.sh new file mode 100755 index 000000000..0ea0c7dba --- /dev/null +++ b/tools/run_renpy_overlay_probe.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +renpy_sdk="${RENPY_SDK:-}" +renpy_bin="${RENPY_BIN:-}" +renderer="${RENPY_RENDERER:-gl2}" + +usage() { + cat <<'USAGE' +Usage: tools/run_renpy_overlay_probe.sh + +Run a real Ren'Py SDK frame/input bridge probe. Set RENPY_SDK to an extracted +official SDK directory, or RENPY_BIN to its launcher executable. The probe +uses SDL's dummy video/audio drivers, so it does not need a desktop display. +USAGE +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +fi +if [[ $# -ne 0 ]]; then + printf 'unexpected argument: %s\n' "$1" >&2 + usage >&2 + exit 2 +fi + +if [[ -z "$renpy_bin" && -n "$renpy_sdk" ]]; then + if [[ -x "$renpy_sdk/renpy.sh" ]]; then + renpy_bin="$renpy_sdk/renpy.sh" + elif [[ -x "$renpy_sdk/renpy" ]]; then + renpy_bin="$renpy_sdk/renpy" + else + printf "Ren'Py launcher not found under RENPY_SDK: %s\n" "$renpy_sdk" >&2 + exit 1 + fi +fi +if [[ -z "$renpy_bin" ]]; then + if command -v renpy.sh >/dev/null 2>&1; then + renpy_bin="$(command -v renpy.sh)" + elif command -v renpy >/dev/null 2>&1; then + renpy_bin="$(command -v renpy)" + else + printf "Set RENPY_SDK to an extracted official Ren'Py SDK or RENPY_BIN to its launcher.\n" >&2 + exit 1 + fi +fi +if [[ ! -x "$renpy_bin" ]]; then + printf "Ren'Py launcher is not executable: %s\n" "$renpy_bin" >&2 + exit 1 +fi + +tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-overlay.XXXXXX")" +cleanup() { rm -rf "$tmp_root"; } +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +project="$tmp_root/project" +mkdir -p "$project/game" "$tmp_root/home" "$tmp_root/config" +cp "$repo_root/bridge/renpy_runtime/python/aether_renpy_overlay.py" "$project/game/" +cat > "$project/game/options.rpy" <<'R' +define config.name = "AetherKiri bridge probe" +define config.version = "1.0" +define config.window_title = "AetherKiri bridge probe" +define config.screen_width = 640 +define config.screen_height = 360 +define config.developer = True +define config.has_sound = False +define config.has_music = False +define config.has_voice = False +define config.gl_enable = False +define config.performance_test = False + +init python: + import aether_renpy_overlay + aether_renpy_overlay.install() +R +cat > "$project/game/script.rpy" <<'R' +label main_menu: + jump start + +label start: + scene Solid("#194a72") + with None + menu: + "Input bridge continue": + $ open(renpy.config.gamedir + "/input-ok", "w").write("continue\n") + $ renpy.quit() + "Input bridge finish": + $ open(renpy.config.gamedir + "/input-ok", "w").write("finish\n") + $ renpy.quit() +R + +frame="$tmp_root/frame.bin" +input="$tmp_root/input.json" +errors="$tmp_root/bridge-errors.log" +: > "$input" +sdk_root="$(cd "$(dirname "$renpy_bin")" && pwd)" +renpy_bin="$sdk_root/$(basename "$renpy_bin")" + +( + cd "$sdk_root" + HOME="$tmp_root/home" XDG_CONFIG_HOME="$tmp_root/config" \ + RENPY_RENDERER="$renderer" AETHERKIRI_RENPY_OVERLAY=1 \ + AETHERKIRI_RENPY_FRAME="$frame" AETHERKIRI_RENPY_INPUT="$input" \ + AETHERKIRI_RENPY_ERROR="$errors" \ + "$renpy_bin" --compile "$project" quit >/dev/null +) + +set +e +( + cd "$sdk_root" + HOME="$tmp_root/home" XDG_CONFIG_HOME="$tmp_root/config" \ + SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy RENPY_RENDERER="$renderer" \ + AETHERKIRI_RENPY_OVERLAY=1 AETHERKIRI_RENPY_FRAME="$frame" \ + AETHERKIRI_RENPY_INPUT="$input" AETHERKIRI_RENPY_ERROR="$errors" \ + timeout -k 1s "${RENPY_PROBE_TIMEOUT:-20}s" "$renpy_bin" "$project" +) >"$tmp_root/game.out" 2>&1 & +pid=$! +ready=0 +for _ in $(seq 1 200); do + if [[ -s "$frame" ]]; then ready=1; break; fi + sleep 0.1 +done +if [[ "$ready" -ne 1 ]]; then + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + printf 'overlay probe did not publish a frame\n' >&2 + cat "$tmp_root/game.out" >&2 + exit 1 +fi + +# The overlay fills Ren'Py 8.5.x KEYDOWN defaults when constructing an Event. +printf '%s\n%s\n' \ + '{"type":768,"attributes":{"key":1073741905}}' \ + '{"type":768,"attributes":{"key":13}}' \ + >> "$input" +wait "$pid" +rc=$? +set -e +if [[ "$rc" -ne 0 ]]; then + printf "overlay probe Ren'Py process exited with status %s\n" "$rc" >&2 + cat "$tmp_root/game.out" >&2 + exit 1 +fi + +python3 - "$frame" "$project/game/input-ok" "$errors" <<'PY' +import pathlib +import struct +import sys + +frame = pathlib.Path(sys.argv[1]).read_bytes() +marker = pathlib.Path(sys.argv[2]).read_text() +errors = pathlib.Path(sys.argv[3]) +if frame[:8] != b"AKRF1\0\0\0": + raise SystemExit("unexpected frame magic") +width, height, serial, payload_len = struct.unpack("&2 + usage >&2 + exit 2 +fi + +if [[ ! -d "$fixture_root/game" ]]; then + printf "Ren'Py fixture is missing game/: %s\n" "$fixture_root" >&2 + exit 1 +fi +for source_file in options.rpy script.rpy; do + if [[ ! -f "$fixture_root/game/$source_file" ]]; then + printf "Ren'Py fixture is missing game/%s\n" "$source_file" >&2 + exit 1 + fi +done + +if [[ -z "$renpy_bin" && -n "$renpy_sdk" ]]; then + if [[ -x "$renpy_sdk/renpy.sh" ]]; then + renpy_bin="$renpy_sdk/renpy.sh" + elif [[ -x "$renpy_sdk/renpy" ]]; then + renpy_bin="$renpy_sdk/renpy" + elif [[ -x "$renpy_sdk/renpy.exe" ]]; then + renpy_bin="$renpy_sdk/renpy.exe" + else + printf "Ren'Py launcher not found under RENPY_SDK: %s\n" "$renpy_sdk" >&2 + exit 1 + fi +fi +if [[ -z "$renpy_bin" ]]; then + if command -v renpy.sh >/dev/null 2>&1; then + renpy_bin="$(command -v renpy.sh)" + elif command -v renpy >/dev/null 2>&1; then + renpy_bin="$(command -v renpy)" + else + printf "Set RENPY_SDK to an extracted official Ren'Py SDK or RENPY_BIN to its launcher.\n" >&2 + exit 1 + fi +fi +if [[ ! -x "$renpy_bin" ]]; then + printf "Ren'Py launcher is not executable: %s\n" "$renpy_bin" >&2 + exit 1 +fi + +sdk_root="$(cd "$(dirname "$renpy_bin")" && pwd)" +renpy_bin="$sdk_root/$(basename "$renpy_bin")" +tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-smoke.XXXXXX")" +cleanup() { + rm -rf "$tmp_root" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +sandbox="$tmp_root/fixture" +mkdir -p "$sandbox" +cp -a "$fixture_root/." "$sandbox/" +mkdir -p "$tmp_root/home" "$tmp_root/config" + +# --compile forces the SDK to parse the source even when stale bytecode exists; +# quit initializes the project without leaving a game window running. +( + cd "$sdk_root" + HOME="$tmp_root/home" XDG_CONFIG_HOME="$tmp_root/config" \ + "$renpy_bin" --compile --savedir "$tmp_root/saves" "$sandbox" quit +) + +printf "Ren'Py smoke ok: fixture=%s sdk=%s\n" "$fixture_root" "$sdk_root" diff --git a/tools/test_install_renpy_sdk.sh b/tools/test_install_renpy_sdk.sh new file mode 100755 index 000000000..d7bb37d8b --- /dev/null +++ b/tools/test_install_renpy_sdk.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +installer="$repo_root/tools/install_renpy_sdk.sh" +[[ -x "$installer" ]] || { echo "installer is not executable: $installer" >&2; exit 1; } +bash -n "$installer" + +linux_config="$($installer --platform linux --arch x86_64 --print-config)" +grep -Fx 'RENPY_VERSION=8.5.3' <<<"$linux_config" +grep -Fx 'RENPY_ARCHIVE=renpy-8.5.3-sdk.tar.bz2' <<<"$linux_config" +grep -Fx 'RENPY_SHA256=eb0a9be7f0fb13632fe25ceade9a8bed5a1b4d6b6e83bd19eeeb29e1a1bb4a45' <<<"$linux_config" +grep -Fx 'RENPY_URL=https://www.renpy.org/dl/8.5.3/renpy-8.5.3-sdk.tar.bz2' <<<"$linux_config" + +mac_config="$($installer --platform darwin --arch arm64 --print-config)" +grep -Fx 'RENPY_ARCHIVE=renpy-8.5.3-sdkarm.tar.bz2' <<<"$mac_config" +grep -Fx 'RENPY_SHA256=0579782517f203ba3535dcc2dab54e34bfc318f2f2a7510a5130b6f809f901f6' <<<"$mac_config" +mac_x86_config="$($installer --platform darwin --arch x86_64 --print-config)" +grep -Fx 'RENPY_ARCHIVE=renpy-8.5.3-sdk.tar.bz2' <<<"$mac_x86_config" +grep -Fx 'RENPY_SHA256=eb0a9be7f0fb13632fe25ceade9a8bed5a1b4d6b6e83bd19eeeb29e1a1bb4a45' <<<"$mac_x86_config" + +if "$installer" --platform windows --arch x86_64 --print-config >/dev/null 2>&1; then + echo "installer accepted unsupported Windows platform" >&2 + exit 1 +fi +if "$installer" --platform linux --arch arm64 --print-config >/dev/null 2>&1; then + echo "installer accepted unsupported Linux architecture" >&2 + exit 1 +fi +printf 'Ren\x27Py SDK installer validation ok\n' From 02f3de79e98295f3a73bf9463cd9d98a60052d24 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 09:58:12 +0800 Subject: [PATCH 002/331] feat(renpy): register runtime in current Godot host --- apps/godot_app/scripts/main.gd | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/godot_app/scripts/main.gd b/apps/godot_app/scripts/main.gd index 9e85dce33..916fa6e8b 100644 --- a/apps/godot_app/scripts/main.gd +++ b/apps/godot_app/scripts/main.gd @@ -1717,6 +1717,7 @@ const POINTER_MOD_CANCEL := 1 << 30 const KEY_MOD_CONTROL := 0x04 const RUNTIME_KIRIKIRI := "kirikiri" const RUNTIME_ONSCRIPTER := "onscripter" +const RUNTIME_RENPY := "renpy" const RUNTIME_MINORI := "minori" const RUNTIME_CATSYSTEM2 := "catsystem2" const RUNTIME_SIGLUS := "siglus" @@ -10305,6 +10306,9 @@ func _game_info_from_path(path: String) -> Dictionary: func _game_runtime_root(path: String) -> String: var resolved := _resolve_game_path(path) + var renpy_root := GameMetadata.renpy_project_root(resolved) + if not renpy_root.is_empty(): + return renpy_root if FileAccess.file_exists(resolved): return resolved.get_base_dir() return resolved @@ -11542,6 +11546,7 @@ func _switch_runtime_player(runtime_kind: String) -> bool: var normalized := runtime_kind if normalized not in [ RUNTIME_ONSCRIPTER, + RUNTIME_RENPY, RUNTIME_MINORI, RUNTIME_CATSYSTEM2, RUNTIME_SIGLUS, @@ -11576,6 +11581,8 @@ func _switch_runtime_player(runtime_kind: String) -> bool: if normalized == RUNTIME_ONSCRIPTER else "MinoriRust" if normalized == RUNTIME_MINORI + else "Ren'Py" + if normalized == RUNTIME_RENPY else "CatSystem2" if normalized == RUNTIME_CATSYSTEM2 else "Siglus" @@ -11814,6 +11821,7 @@ func _ensure_player_initialized() -> bool: var runtime_id := "auto" if current_player_runtime_kind in [ RUNTIME_ONSCRIPTER, + RUNTIME_RENPY, RUNTIME_MINORI, RUNTIME_CATSYSTEM2, RUNTIME_SIGLUS, @@ -11834,6 +11842,8 @@ func _ensure_player_initialized() -> bool: if current_player_runtime_kind == RUNTIME_ONSCRIPTER else "MinoriRust" if current_player_runtime_kind == RUNTIME_MINORI + else "Ren'Py" + if current_player_runtime_kind == RUNTIME_RENPY else "CatSystem2" if current_player_runtime_kind == RUNTIME_CATSYSTEM2 else "Siglus" From 997dfc86440ca0530b4278f430c2a55c3daf1579 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 11:49:41 +0800 Subject: [PATCH 003/331] fix(renpy): make archive validation portable across runner awk --- tools/install_renpy_sdk.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tools/install_renpy_sdk.sh b/tools/install_renpy_sdk.sh index db2b1e6a0..18fab03aa 100755 --- a/tools/install_renpy_sdk.sh +++ b/tools/install_renpy_sdk.sh @@ -158,14 +158,14 @@ cleanup() { rm -rf "$extract_root"; } trap cleanup EXIT # Ensure tar cannot write outside the temporary extraction root. The official # archive has one relative top-level directory; reject malformed archives. -if tar -tjf "$archive_path" | awk -F/ ' - NF && ($1 == "" || $1 == "." || $1 == ".." || - $1 ~ /\\/ || $1 ~ /[^A-Za-z0-9_.-]/ || - $0 ~ /^\\// || $0 ~ /(^|\\/)\\.\\.?($|\\/)/) { bad=1 } - END { exit bad }'; then - : -else - echo "Refusing archive with an unsafe path" >&2 +if ! tar -tjf "$archive_path" | while IFS= read -r member; do + case "$member" in + /*|../*|*/../*|*/..|..|*\\*) + echo "Refusing archive with an unsafe path: $member" >&2 + exit 1 + ;; + esac +done; then exit 1 fi tar -xjf "$archive_path" -C "$extract_root" From 79db0eb3c342f53f1aef6f3380f88d5151152397 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 12:07:31 +0800 Subject: [PATCH 004/331] fix(renpy): use Darwin process environment accessor --- .../src/renpy_runtime_provider.cpp | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp index dbef32450..f5c82f6de 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp @@ -32,6 +32,11 @@ #include #include #include +#if defined(__APPLE__) +#include +#else +extern char** environ; +#endif #endif #ifndef AETHERKIRI_RENPY_SDK_ROOT @@ -45,6 +50,16 @@ namespace aetherkiri::renpy { namespace { namespace fs = std::filesystem; +#if !defined(_WIN32) +char** ProcessEnvironment() { +#if defined(__APPLE__) + return *_NSGetEnviron(); +#else + return environ; +#endif +} +#endif + const char* UnsupportedFrameMessage() { return "Ren'Py frame bridge has not published a frame yet"; } @@ -307,7 +322,7 @@ class SdkProcess final { std::vector environment_storage; std::vector environment_argv; if (!environment.empty()) { - for (char** entry = environ; entry != nullptr && *entry != nullptr; + for (char** entry = ProcessEnvironment(); entry != nullptr && *entry != nullptr; ++entry) environment_storage.emplace_back(*entry); for (const auto& [key, value] : environment) { @@ -343,7 +358,7 @@ class SdkProcess final { pid_t child = -1; if (result == 0) { result = posix_spawn(&child, exe.c_str(), nullptr, &attributes, - argv, environment.empty() ? environ + argv, environment.empty() ? ProcessEnvironment() : environment_argv.data()); } (void)posix_spawnattr_destroy(&attributes); From e023ee2cfe610471069569bc7ff2662352fbb60e Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 12:11:10 +0800 Subject: [PATCH 005/331] fix(ci): keep desktop RenPy bootstrap out of Android job --- .github/workflows/build.yml | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 075c349b3..8a37c7ee9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -180,24 +180,6 @@ jobs: # can be allowed without disabling checks for every other wrapper. validate-wrappers: false - - name: Validate Ren'Py SDK installer - run: tools/test_install_renpy_sdk.sh - - - name: Install pinned Ren'Py SDK - run: | - set -euo pipefail - sdk_root="${RUNNER_TEMP}/aetherkiri-renpy-sdk" - ./tools/install_renpy_sdk.sh --platform linux --arch x86_64 --destination "$sdk_root" - { - echo "AETHERKIRI_ENABLE_RENPY=ON" - echo "AETHERKIRI_RENPY_SDK_ROOT=$sdk_root" - echo "RENPY_SDK=$sdk_root" - } >> "$GITHUB_ENV" - test -x "$sdk_root/renpy.sh" - - - name: Run Ren'Py SDK smoke fixture - run: tools/run_renpy_smoke.sh - - name: Install Godot export tools run: | set -euo pipefail From 3ebd821a46aab8ea4742f7cb8c465c3d54e03789 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 12:18:02 +0800 Subject: [PATCH 006/331] fix(renpy): package bundled desktop SDK resources From 319e039be709b5770289ef36b080bf4dda253aa9 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 12:18:20 +0800 Subject: [PATCH 007/331] fix(renpy): package bundled desktop SDK resources --- .../src/renpy_runtime_provider.cpp | 114 +++++++++++++++--- 1 file changed, 100 insertions(+), 14 deletions(-) diff --git a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp index f5c82f6de..c382eada8 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +26,8 @@ #if defined(_WIN32) #define NOMINMAX #include +#elif defined(__APPLE__) +#include #else #include #include @@ -32,11 +35,6 @@ #include #include #include -#if defined(__APPLE__) -#include -#else -extern char** environ; -#endif #endif #ifndef AETHERKIRI_RENPY_SDK_ROOT @@ -50,15 +48,95 @@ namespace aetherkiri::renpy { namespace { namespace fs = std::filesystem; -#if !defined(_WIN32) -char** ProcessEnvironment() { +constexpr const char* kOverlayFileName = "aetherkiri-renpy-overlay.py"; + +fs::path CurrentExecutablePath() { +#if defined(_WIN32) + std::wstring buffer(MAX_PATH, L'\\0'); + for (;;) { + const DWORD size = GetModuleFileNameW( + nullptr, buffer.data(), static_cast(buffer.size())); + if (size == 0) return {}; + if (size < buffer.size() - 1) { + buffer.resize(size); + return fs::path(buffer); + } + buffer.resize(buffer.size() * 2); + } +#elif defined(__APPLE__) + uint32_t size = 0; + if (_NSGetExecutablePath(nullptr, &size) != -1 || size == 0) return {}; + std::string buffer(size, '\\0'); + if (_NSGetExecutablePath(buffer.data(), &size) != 0) return {}; + return fs::path(buffer.c_str()); +#else + std::error_code ec; + const fs::path path = fs::read_symlink("/proc/self/exe", ec); + return ec ? fs::path() : path; +#endif +} + +fs::path BundledResourcePath(const char* filename) { + const fs::path executable = CurrentExecutablePath(); + if (executable.empty()) return {}; + const fs::path executable_dir = executable.parent_path(); #if defined(__APPLE__) - return *_NSGetEnviron(); + const std::array candidates = { + executable_dir / filename, + executable_dir / ".." / "Resources" / filename, + executable_dir / ".." / "Frameworks" / filename, + }; #else - return environ; + const std::array candidates = { + executable_dir / filename, + executable_dir / "resources" / filename, + }; #endif + std::error_code ec; + for (const fs::path& candidate : candidates) { + if (fs::is_regular_file(candidate, ec)) return candidate; + } + return {}; } + +fs::path BundledSdkPath() { + const fs::path executable = CurrentExecutablePath(); + if (executable.empty()) return {}; + const fs::path executable_dir = executable.parent_path(); +#if defined(__APPLE__) + const std::array candidates = { + executable_dir / ".." / "Resources" / "renpy-sdk", + executable_dir / ".." / "Frameworks" / "renpy-sdk", + executable_dir / "renpy-sdk", + executable_dir / ".." / "Resources" / "RenPy", + }; +#else + const std::array candidates = { + executable_dir / "renpy-sdk", + executable_dir / "resources" / "renpy-sdk", + }; #endif + std::error_code ec; + for (const fs::path& candidate : candidates) { + if (fs::is_directory(candidate, ec)) return candidate; + } + return {}; +} + +fs::path ResolveOverlaySource() { + std::error_code ec; + if (const char* configured = std::getenv("AETHERKIRI_RENPY_OVERLAY_SOURCE"); + configured && *configured && + fs::is_regular_file(fs::u8path(configured), ec)) { + return fs::u8path(configured); + } + const fs::path compiled = fs::u8path(AETHERKIRI_RENPY_OVERLAY_SOURCE); + if (!compiled.empty() && fs::is_regular_file(compiled, ec)) return compiled; + const fs::path bundled = BundledResourcePath(kOverlayFileName); + if (!bundled.empty()) return bundled; + const fs::path cwd = fs::current_path(ec) / kOverlayFileName; + return fs::is_regular_file(cwd, ec) ? cwd : fs::path(); +} const char* UnsupportedFrameMessage() { return "Ren'Py frame bridge has not published a frame yet"; @@ -322,7 +400,7 @@ class SdkProcess final { std::vector environment_storage; std::vector environment_argv; if (!environment.empty()) { - for (char** entry = ProcessEnvironment(); entry != nullptr && *entry != nullptr; + for (char** entry = environ; entry != nullptr && *entry != nullptr; ++entry) environment_storage.emplace_back(*entry); for (const auto& [key, value] : environment) { @@ -358,7 +436,7 @@ class SdkProcess final { pid_t child = -1; if (result == 0) { result = posix_spawn(&child, exe.c_str(), nullptr, &attributes, - argv, environment.empty() ? ProcessEnvironment() + argv, environment.empty() ? environ : environment_argv.data()); } (void)posix_spawnattr_destroy(&attributes); @@ -612,12 +690,12 @@ bool WriteTextFile(const fs::path& path, const std::string& text, bool PrepareBridge(Runtime& runtime) { runtime.CleanupBridge(); - const fs::path source = fs::u8path(AETHERKIRI_RENPY_OVERLAY_SOURCE); + const fs::path source = ResolveOverlaySource(); std::error_code ec; if (source.empty() || !fs::is_regular_file(source, ec)) { runtime.error = - "Ren'Py overlay source is unavailable in this build; frame bridge " - "cannot be activated"; + "Ren'Py overlay source is unavailable in this build or bundle; " + "frame bridge cannot be activated"; return false; } std::ifstream source_stream(source, std::ios::binary); @@ -793,6 +871,14 @@ engine_result_t Open(void* value, const char* path, const char* startup) { (void)startup; // Ren'Py selects its own script from game/options.rpy. runtime.project = fs::u8path(path); std::error_code ec; + if (const char* configured = std::getenv("AETHERKIRI_RENPY_SDK_ROOT"); + configured && *configured) { + runtime.sdk_root = fs::u8path(configured); + } else if (runtime.sdk_root.empty() || + !fs::is_directory(runtime.sdk_root, ec)) { + const fs::path bundled = BundledSdkPath(); + if (!bundled.empty()) runtime.sdk_root = bundled; + } if (!HasRenpyProject(runtime.project)) { runtime.error = "directory is not a Ren'Py project (expected game/script.rpy)"; return ENGINE_RESULT_INVALID_ARGUMENT; From 9f6489cc5461cf85b72e6ffe2131eda90dfaf597 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 12:18:38 +0800 Subject: [PATCH 008/331] fix(renpy): package bundled desktop SDK resources --- scripts/build_linux.sh | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/scripts/build_linux.sh b/scripts/build_linux.sh index 47bd357ef..053ce2236 100755 --- a/scripts/build_linux.sh +++ b/scripts/build_linux.sh @@ -169,6 +169,29 @@ stage_export_runtime_libraries() { -exec cp -Lf {} "$GODOT_EXPORT_DIR/" \; } +stage_renpy_overlay() { + [[ "${renpy_enabled_normalized:-OFF}" == "ON" ]] || return 0 + local destination="$1" + local source="$PROJECT_ROOT/bridge/renpy_runtime/python/aether_renpy_overlay.py" + [[ -f "$source" ]] || { + echo "Error: Ren'Py overlay source is missing: $source" >&2 + exit 1 + } + cp -f "$source" "$destination/aetherkiri-renpy-overlay.py" +} + +stage_renpy_sdk() { + [[ "${renpy_enabled_normalized:-OFF}" == "ON" ]] || return 0 + local destination="$1/renpy-sdk" + [[ -d "${renpy_sdk_root:-}" ]] || { + echo "Error: Ren'Py SDK root is missing: ${renpy_sdk_root:-}" >&2 + exit 1 + } + rm -rf "$destination" + mkdir -p "$(dirname "$destination")" + cp -a "$renpy_sdk_root" "$destination" +} + stage_release_extension_for_editor_scan() { if [[ "$BUILD_TYPE_LOWER" != "release" ]]; then return @@ -185,8 +208,10 @@ stage_release_extension_for_editor_scan() { renpy_cmake_args=() renpy_enabled="${AETHERKIRI_ENABLE_RENPY:-OFF}" -case "$(printf '%s' "$renpy_enabled" | tr '[:lower:]' '[:upper:]')" in +renpy_enabled_normalized="$(printf '%s' "$renpy_enabled" | tr '[:lower:]' '[:upper:]')" +case "$renpy_enabled_normalized" in ON|TRUE|YES|1) + renpy_enabled_normalized="ON" renpy_sdk_root="${AETHERKIRI_RENPY_SDK_ROOT:-}" if [[ -z "$renpy_sdk_root" || ! -d "$renpy_sdk_root" ]]; then echo "Error: AETHERKIRI_ENABLE_RENPY requires AETHERKIRI_RENPY_SDK_ROOT" >&2 @@ -216,6 +241,7 @@ cmake --build --preset "$CMAKE_BUILD_PRESET" -- -j"$PARALLEL_JOBS" mkdir -p "$GODOT_BIN_DIR" cp -f "$CMAKE_BUILD_DIR/abi/libengine_api.so" "$GODOT_BIN_DIR/" cp -f "$CMAKE_BUILD_DIR/bridge/godot_extension/libaether_kiri_godot.so" "$GODOT_BIN_DIR/" +stage_renpy_overlay "$GODOT_BIN_DIR" stage_all_vcpkg_runtime_libraries if [[ "$BUILD_TYPE_LOWER" == "release" ]]; then echo "==> Removing non-runtime symbols from staged Linux Release libraries" @@ -249,6 +275,8 @@ echo "==> Exporting Linux Godot application" "$GODOT_EXPORT_MODE" "$GODOT_EXPORT_PRESET" "$GODOT_EXPORT_APP" stage_export_runtime_libraries +stage_renpy_overlay "$GODOT_EXPORT_DIR" +stage_renpy_sdk "$GODOT_EXPORT_DIR" if [[ "$BUILD_TYPE_LOWER" == "release" ]]; then echo "==> Removing non-runtime symbols from exported Linux Release application" strip_linux_runtime_symbols "$GODOT_EXPORT_DIR" From b9d6b60c8f7952d9861d67931ba5e809fef6c1d6 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 12:18:57 +0800 Subject: [PATCH 009/331] fix(renpy): package bundled desktop SDK resources --- scripts/build_macos.sh | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/scripts/build_macos.sh b/scripts/build_macos.sh index 218af0d8f..6fbd6e75f 100755 --- a/scripts/build_macos.sh +++ b/scripts/build_macos.sh @@ -136,6 +136,29 @@ strip_macos_runtime_symbols() { "$PROJECT_ROOT/tools/strip_runtime_symbols.sh" macho "$@" } +stage_renpy_overlay() { + [[ "${renpy_enabled_normalized:-OFF}" == "ON" ]] || return 0 + local destination="$1" + local source="$PROJECT_ROOT/bridge/renpy_runtime/python/aether_renpy_overlay.py" + [[ -f "$source" ]] || { + echo "Error: Ren'Py overlay source is missing: $source" >&2 + exit 1 + } + cp -f "$source" "$destination/aetherkiri-renpy-overlay.py" +} + +stage_renpy_sdk() { + [[ "${renpy_enabled_normalized:-OFF}" == "ON" ]] || return 0 + local destination="$1/renpy-sdk" + [[ -d "${renpy_sdk_root:-}" ]] || { + echo "Error: Ren'Py SDK root is missing: ${renpy_sdk_root:-}" >&2 + exit 1 + } + rm -rf "$destination" + mkdir -p "$(dirname "$destination")" + cp -a "$renpy_sdk_root" "$destination" +} + thin_macos_executable_to_arch() { local executable="$1" local architectures @@ -162,8 +185,10 @@ cmake_config_args=( -D "AETHERKIRI_ENABLE_INTERNAL=${AETHERKIRI_ENABLE_INTERNAL:-ON}" ) renpy_enabled="${AETHERKIRI_ENABLE_RENPY:-OFF}" -case "$(printf '%s' "$renpy_enabled" | tr '[:lower:]' '[:upper:]')" in +renpy_enabled_normalized="$(printf '%s' "$renpy_enabled" | tr '[:lower:]' '[:upper:]')" +case "$renpy_enabled_normalized" in ON|TRUE|YES|1) + renpy_enabled_normalized="ON" renpy_sdk_root="${AETHERKIRI_RENPY_SDK_ROOT:-}" if [[ -z "$renpy_sdk_root" || ! -d "$renpy_sdk_root" ]]; then echo "Error: AETHERKIRI_ENABLE_RENPY requires AETHERKIRI_RENPY_SDK_ROOT" >&2 @@ -202,6 +227,7 @@ cmake --build --preset "$CMAKE_BUILD_PRESET" -- -j"$PARALLEL_JOBS" mkdir -p "$GODOT_BIN_DIR" cp -f "$CMAKE_BUILD_DIR/abi/libengine_api.dylib" "$GODOT_BIN_DIR/" cp -f "$CMAKE_BUILD_DIR/bridge/godot_extension/libaether_kiri_godot.dylib" "$GODOT_BIN_DIR/" +stage_renpy_overlay "$GODOT_BIN_DIR" if [[ "$BUILD_TYPE_LOWER" == "release" ]]; then echo "==> Removing non-runtime symbols from staged macOS Release libraries" strip_macos_runtime_symbols \ @@ -252,6 +278,8 @@ else fi cp -f "$GODOT_BIN_DIR/libengine_api.dylib" "$GODOT_EXPORT_APP/Contents/Frameworks/" cp -f "$GODOT_BIN_DIR/libaether_kiri_godot.dylib" "$GODOT_EXPORT_APP/Contents/Frameworks/" + stage_renpy_overlay "$GODOT_EXPORT_APP/Contents/Frameworks" + stage_renpy_sdk "$GODOT_EXPORT_APP/Contents/Resources" echo "==> Thinning exported macOS executable to $MACOS_ARCH" thin_macos_executable_to_arch "$GODOT_EXPORT_APP/Contents/MacOS/Aether" if [[ "$BUILD_TYPE_LOWER" == "release" ]]; then From 41f0d36e8c06d75b1389d7a29d4e89891b090958 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 12:46:54 +0800 Subject: [PATCH 010/331] fix(renpy): finish portable resource and input mapping --- .../src/renpy_runtime_provider.cpp | 220 ++++++++++++++++-- 1 file changed, 203 insertions(+), 17 deletions(-) diff --git a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp index c382eada8..ee9827382 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp @@ -26,9 +26,10 @@ #if defined(_WIN32) #define NOMINMAX #include -#elif defined(__APPLE__) -#include #else +#if defined(__APPLE__) +#include +#endif #include #include #include @@ -52,7 +53,7 @@ constexpr const char* kOverlayFileName = "aetherkiri-renpy-overlay.py"; fs::path CurrentExecutablePath() { #if defined(_WIN32) - std::wstring buffer(MAX_PATH, L'\\0'); + std::wstring buffer(MAX_PATH, L'\0'); for (;;) { const DWORD size = GetModuleFileNameW( nullptr, buffer.data(), static_cast(buffer.size())); @@ -66,7 +67,7 @@ fs::path CurrentExecutablePath() { #elif defined(__APPLE__) uint32_t size = 0; if (_NSGetExecutablePath(nullptr, &size) != -1 || size == 0) return {}; - std::string buffer(size, '\\0'); + std::string buffer(size, '\0'); if (_NSGetExecutablePath(buffer.data(), &size) != 0) return {}; return fs::path(buffer.c_str()); #else @@ -134,8 +135,7 @@ fs::path ResolveOverlaySource() { if (!compiled.empty() && fs::is_regular_file(compiled, ec)) return compiled; const fs::path bundled = BundledResourcePath(kOverlayFileName); if (!bundled.empty()) return bundled; - const fs::path cwd = fs::current_path(ec) / kOverlayFileName; - return fs::is_regular_file(cwd, ec) ? cwd : fs::path(); + return {}; } const char* UnsupportedFrameMessage() { @@ -174,6 +174,185 @@ std::string JsonNumber(double value) { return stream.str(); } +// The Godot shell translates keys to Windows-style virtual keys and uses +// KiriKiri modifier bits (main.gd's _kirikiri_virtual_key/key_modifiers). +// Ren'Py 8.5.x uses pygame_sdl2's SDL2 keycodes/KMOD_* values. Keep this +// translation independent of SDL headers, which are owned by the child SDK. +constexpr int kPygameScancodeMask = 1 << 30; +constexpr int kPygameKmodShift = 0x0003; +constexpr int kPygameKmodControl = 0x00c0; +constexpr int kPygameKmodAlt = 0x0300; +constexpr int kPygameKmodGui = 0x0c00; +constexpr int kAetherModifierShift = 0x01; +constexpr int kAetherModifierAlt = 0x02; +constexpr int kAetherModifierControl = 0x04; +constexpr int kAetherModifierEcho = 0x80; +constexpr int kGodotModifierShift = 1 << 25; +constexpr int kGodotModifierAlt = 1 << 26; +constexpr int kGodotModifierMeta = 1 << 27; +constexpr int kGodotModifierControl = 1 << 28; +constexpr int kGodotSpecial = 1 << 22; +constexpr int kGodotCodeMask = 0x007fffff; + +constexpr int PygameFunctionKey(int function) { + // SDL2's F1-F12 scancodes are contiguous at 58. F13-F24 start at + // scancode 104, after the keypad block. + if (function >= 1 && function <= 12) + return kPygameScancodeMask + 58 + function - 1; + if (function >= 13 && function <= 24) + return kPygameScancodeMask + 104 + function - 13; + return 0; +} + +int MapPointerButtonToPygame(int button) { + // EngineApi: 0=left, 1=right, 2=middle. pygame: 1=left, 2=middle, + // 3=right. Do not confuse the already-translated ABI with raw Godot's + // MouseButton enum (1=left, 2=right, 3=middle). + switch (button) { + case 0: return 1; + case 1: return 3; + case 2: return 2; + default: return button > 0 ? button : 1; + } +} + +int MapKeyCodeToPygame(int code) { + // Preserve SDL2 non-printable keycodes used by existing direct callers. + // Low-byte SDL ASCII and Windows VK values overlap; the ABI's VK + // interpretation takes precedence (e.g. 0x70 means F1, not 'p'). + if (code >= kPygameScancodeMask) return code; + if (code >= 0x01000000) code &= kGodotCodeMask; + + // The manual Godot probe passes raw Godot 4 non-printable keycodes. + // Their distinct KEY_SPECIAL range can be recognized unambiguously. + if (code >= kGodotSpecial && code < kGodotSpecial + 0x1000) { + const int special = code - kGodotSpecial; + switch (special) { + case 1: return 27; // Escape + case 2: // Tab + case 3: return 9; // Backtab + case 4: return 8; // Backspace + case 5: return 13; // Return + case 6: return kPygameScancodeMask + 88; // Keypad Enter + case 7: return kPygameScancodeMask + 73; // Insert + case 8: return 127; // Delete + case 9: return kPygameScancodeMask + 72; // Pause/Break + case 10: return kPygameScancodeMask + 70; // Print Screen + case 11: return kPygameScancodeMask + 154; // SysReq + case 12: return kPygameScancodeMask + 156; // Clear + case 13: return kPygameScancodeMask + 74; // Home + case 14: return kPygameScancodeMask + 77; // End + case 15: return kPygameScancodeMask + 80; // Left + case 16: return kPygameScancodeMask + 82; // Up + case 17: return kPygameScancodeMask + 79; // Right + case 18: return kPygameScancodeMask + 81; // Down + case 19: return kPygameScancodeMask + 75; // Page Up + case 20: return kPygameScancodeMask + 78; // Page Down + case 21: return kPygameScancodeMask + 225; // Shift (left) + case 22: return kPygameScancodeMask + 224; // Ctrl (left) + case 23: return kPygameScancodeMask + 227; // Meta (left) + case 24: return kPygameScancodeMask + 226; // Alt (left) + case 25: return kPygameScancodeMask + 57; // Caps Lock + case 26: return kPygameScancodeMask + 83; // Num Lock + case 27: return kPygameScancodeMask + 71; // Scroll Lock + case 66: return kPygameScancodeMask + 118; // Menu + case 69: return kPygameScancodeMask + 117; // Help + default: break; + } + if (special >= 28 && special <= 62) + return PygameFunctionKey(special - 27); // F1-F35 (SDL has F1-F24) + if (special >= 129 && special <= 143) { + static constexpr int keypad[] = { + 85, 84, 86, 99, 87, 98, 89, 90, + 91, 92, 93, 94, 95, 96, 97, + }; // Multiply, divide, subtract, period, add, 0..9. + return kPygameScancodeMask + keypad[special - 129]; + } + return 0; + } + + switch (code) { + case 0x08: return 8; // Backspace + case 0x09: return 9; // Tab + case 0x0c: return kPygameScancodeMask + 156; // Clear + case 0x0d: return 13; // Return + case 0x10: return kPygameScancodeMask + 225; // Shift + case 0x11: return kPygameScancodeMask + 224; // Ctrl + case 0x12: return kPygameScancodeMask + 226; // Alt + case 0x13: return kPygameScancodeMask + 72; // Pause + case 0x14: return kPygameScancodeMask + 57; // Caps Lock + case 0x1b: return 27; // Escape + case 0x20: return 32; // Space + case 0x21: return kPygameScancodeMask + 75; // Page Up + case 0x22: return kPygameScancodeMask + 78; // Page Down + case 0x23: return kPygameScancodeMask + 77; // End + case 0x24: return kPygameScancodeMask + 74; // Home + case 0x25: return kPygameScancodeMask + 80; // Left + case 0x26: return kPygameScancodeMask + 82; // Up + case 0x27: return kPygameScancodeMask + 79; // Right + case 0x28: return kPygameScancodeMask + 81; // Down + case 0x2c: return kPygameScancodeMask + 70; // Print Screen + case 0x2d: return kPygameScancodeMask + 73; // Insert + case 0x2e: return 127; // Delete + case 0x2f: return kPygameScancodeMask + 117; // Help + case 0x5b: return kPygameScancodeMask + 227; // Left Windows + case 0x5c: return kPygameScancodeMask + 231; // Right Windows + case 0x5d: return kPygameScancodeMask + 101; // Applications + case 0x6a: return kPygameScancodeMask + 85; // Keypad multiply + case 0x6b: return kPygameScancodeMask + 87; // Keypad add + case 0x6c: return kPygameScancodeMask + 133; // Keypad separator + case 0x6d: return kPygameScancodeMask + 86; // Keypad subtract + case 0x6e: return kPygameScancodeMask + 99; // Keypad decimal + case 0x6f: return kPygameScancodeMask + 84; // Keypad divide + case 0x90: return kPygameScancodeMask + 83; // Num Lock + case 0x91: return kPygameScancodeMask + 71; // Scroll Lock + case 0xa0: return kPygameScancodeMask + 225; // Left Shift + case 0xa1: return kPygameScancodeMask + 229; // Right Shift + case 0xa2: return kPygameScancodeMask + 224; // Left Ctrl + case 0xa3: return kPygameScancodeMask + 228; // Right Ctrl + case 0xa4: return kPygameScancodeMask + 226; // Left Alt + case 0xa5: return kPygameScancodeMask + 230; // Right Alt + case 0xba: return ';'; + case 0xbb: return '='; + case 0xbc: return ','; + case 0xbd: return '-'; + case 0xbe: return '.'; + case 0xbf: return '/'; + case 0xc0: return '`'; + case 0xdb: return '['; + case 0xdc: return '\\'; + case 0xdd: return ']'; + case 0xde: return '\''; + case 0xe2: return '\\'; + default: break; + } + if (code >= 0x60 && code <= 0x69) + return kPygameScancodeMask + (code == 0x60 ? 98 : 89 + code - 0x61); + if (code >= 0x70 && code <= 0x87) + return PygameFunctionKey(code - 0x70 + 1); + if (code >= 'A' && code <= 'Z') return code + ('a' - 'A'); + if (code >= 0x21 && code <= 0x7e) return code; + return 0; // K_UNKNOWN +} + +int MapModifiersToPygame(int modifiers) { + int result = 0; + if (modifiers & (kAetherModifierShift | kGodotModifierShift)) + result |= kPygameKmodShift; + if (modifiers & (kAetherModifierAlt | kGodotModifierAlt)) + result |= kPygameKmodAlt; + if (modifiers & (kAetherModifierControl | kGodotModifierControl)) + result |= kPygameKmodControl; + if (modifiers & kGodotModifierMeta) result |= kPygameKmodGui; + // No raw SDL passthrough: Aether's echo=0x80 would become SDL's RCtrl; + // mouse-held/cancel bits are not keyboard modifiers either. + return result; +} + +bool IsAetherKeyRepeat(int modifiers) { + return (modifiers & kAetherModifierEcho) != 0; +} + std::string Utf8Codepoint(uint32_t value) { if (value == 0 || value > 0x10ffffu || (value >= 0xd800u && value <= 0xdfffu)) return {}; @@ -609,6 +788,7 @@ class SdkProcess final { struct Runtime { engine_runtime_host_v1_t host{}; fs::path sdk_root = fs::path(AETHERKIRI_RENPY_SDK_ROOT); + bool sdk_path_explicit = false; fs::path project; fs::path bridge_root; fs::path staged_module; @@ -872,10 +1052,10 @@ engine_result_t Open(void* value, const char* path, const char* startup) { runtime.project = fs::u8path(path); std::error_code ec; if (const char* configured = std::getenv("AETHERKIRI_RENPY_SDK_ROOT"); - configured && *configured) { + configured && *configured && !runtime.sdk_path_explicit) { runtime.sdk_root = fs::u8path(configured); - } else if (runtime.sdk_root.empty() || - !fs::is_directory(runtime.sdk_root, ec)) { + } else if (!runtime.sdk_path_explicit && (runtime.sdk_root.empty() || + !fs::is_directory(runtime.sdk_root, ec))) { const fs::path bundled = BundledSdkPath(); if (!bundled.empty()) runtime.sdk_root = bundled; } @@ -960,6 +1140,7 @@ engine_result_t Option(void* value, const engine_option_t* option) { return ENGINE_RESULT_INVALID_STATE; } runtime.sdk_root = fs::u8path(option->value_utf8); + runtime.sdk_path_explicit = true; return ENGINE_RESULT_OK; } return key.rfind("renpy_", 0) == 0 ? ENGINE_RESULT_NOT_SUPPORTED @@ -1026,7 +1207,7 @@ engine_result_t Input(void* value, const engine_input_event_t* event) { event_type = 1025; // pygame.MOUSEBUTTONDOWN attrs << "\"pos\":[" << JsonNumber(event->x) << ',' << JsonNumber(event->y) << "],\"button\":" - << (event->button > 0 ? event->button : 1); + << MapPointerButtonToPygame(event->button); break; case ENGINE_INPUT_EVENT_POINTER_MOVE: event_type = 1024; // pygame.MOUSEMOTION @@ -1039,7 +1220,7 @@ engine_result_t Input(void* value, const engine_input_event_t* event) { event_type = 1026; // pygame.MOUSEBUTTONUP attrs << "\"pos\":[" << JsonNumber(event->x) << ',' << JsonNumber(event->y) << "],\"button\":" - << (event->button > 0 ? event->button : 1); + << MapPointerButtonToPygame(event->button); break; case ENGINE_INPUT_EVENT_POINTER_SCROLL: event_type = 1027; // pygame.MOUSEWHEEL @@ -1050,15 +1231,20 @@ engine_result_t Input(void* value, const engine_input_event_t* event) { break; case ENGINE_INPUT_EVENT_KEY_DOWN: event_type = 768; // pygame.KEYDOWN - attrs << "\"key\":" << event->key_code << ",\"mod\":" - << event->modifiers << ",\"unicode\":" - << JsonEscape(Utf8Codepoint(event->unicode_codepoint)); + attrs << "\"key\":" << MapKeyCodeToPygame(event->key_code) + << ",\"mod\":" << MapModifiersToPygame(event->modifiers) + << ",\"unicode\":" + << JsonEscape(Utf8Codepoint(event->unicode_codepoint)) + << ",\"repeat\":" + << (IsAetherKeyRepeat(event->modifiers) ? "true" : "false"); break; case ENGINE_INPUT_EVENT_KEY_UP: event_type = 769; // pygame.KEYUP - attrs << "\"key\":" << event->key_code << ",\"mod\":" - << event->modifiers << ",\"unicode\":" - << JsonEscape(Utf8Codepoint(event->unicode_codepoint)); + attrs << "\"key\":" << MapKeyCodeToPygame(event->key_code) + << ",\"mod\":" << MapModifiersToPygame(event->modifiers) + << ",\"unicode\":" + << JsonEscape(Utf8Codepoint(event->unicode_codepoint)) + << ",\"repeat\":false"; break; case ENGINE_INPUT_EVENT_TEXT_INPUT: event_type = 771; // pygame.TEXTINPUT From a050c647eecf8bf82a0406935424aac21dd78e6f Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 12:52:51 +0800 Subject: [PATCH 011/331] test(renpy): wire input mapping test --- bridge/renpy_runtime/CMakeLists.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bridge/renpy_runtime/CMakeLists.txt b/bridge/renpy_runtime/CMakeLists.txt index 0ff22eb15..3444a8296 100644 --- a/bridge/renpy_runtime/CMakeLists.txt +++ b/bridge/renpy_runtime/CMakeLists.txt @@ -40,4 +40,8 @@ if(ENABLE_TESTS) AETHERKIRI_RENPY_TEST_SDK_ROOT="${AETHERKIRI_RENPY_SDK_ROOT}") target_link_libraries(renpy-provider-test PRIVATE aether_renpy_runtime) add_test(NAME renpy-provider COMMAND renpy-provider-test) + + add_executable(renpy-input-mapping-test tests/input_mapping.cpp) + target_link_libraries(renpy-input-mapping-test PRIVATE aether_renpy_runtime) + add_test(NAME renpy-input-mapping COMMAND renpy-input-mapping-test) endif() From 561674959fd3c4cbfca8336c120be186203920ed Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 12:54:14 +0800 Subject: [PATCH 012/331] test(renpy): add input mapping coverage --- bridge/renpy_runtime/tests/input_mapping.cpp | 171 +++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 bridge/renpy_runtime/tests/input_mapping.cpp diff --git a/bridge/renpy_runtime/tests/input_mapping.cpp b/bridge/renpy_runtime/tests/input_mapping.cpp new file mode 100644 index 000000000..4424c8184 --- /dev/null +++ b/bridge/renpy_runtime/tests/input_mapping.cpp @@ -0,0 +1,171 @@ +#include "renpy_runtime.h" +#include "engine_api.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +#ifndef AETHERKIRI_RENPY_TEST_OVERLAY_SOURCE +#define AETHERKIRI_RENPY_TEST_OVERLAY_SOURCE "" +#endif + +namespace { +namespace fs = std::filesystem; + +fs::path MakeProject(const fs::path& root) { + fs::create_directories(root / "game"); + std::ofstream(root / "game" / "script.rpy") + << "label start:\n pause\n"; + return root; +} + +void WriteFakeLauncher(const fs::path& sdk) { + fs::create_directories(sdk); + std::ofstream launcher(sdk / "renpy"); + launcher << "#!/bin/sh\n" + << "project=$1\n" + << "printf '%s\\n' \"$AETHERKIRI_RENPY_INPUT\" > \"$project/input-path\"\n" + << "trap 'exit 0' TERM INT\n" + << "while :; do sleep 0.01; done\n"; + launcher.close(); +#if defined(_WIN32) + (void)std::system(("chmod +x " + (sdk / "renpy").string()).c_str()); +#else + fs::permissions(sdk / "renpy", + fs::perms::owner_exec | fs::perms::owner_read | + fs::perms::owner_write, + fs::perm_options::add); +#endif +} + +bool WaitForFile(const fs::path& path) { + for (int i = 0; i < 200; ++i) { + if (fs::is_regular_file(path)) return true; + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + return false; +} + +std::string ReadInput(const fs::path& path) { + std::ifstream input(path, std::ios::binary); + return std::string((std::istreambuf_iterator(input)), + std::istreambuf_iterator()); +} + +void Send(engine_handle_t handle, uint32_t type, int button = 0, + int key_code = 0, int modifiers = 0, uint32_t unicode = 0) { + engine_input_event_t event{}; + event.struct_size = sizeof(event); + event.type = type; + event.button = button; + event.key_code = key_code; + event.modifiers = modifiers; + event.unicode_codepoint = unicode; + assert(engine_send_input(handle, &event) == ENGINE_RESULT_OK); +} + +void CheckInputMapping() { + const fs::path root = fs::temp_directory_path() / + ("aetherkiri-renpy-input-mapping-" + + std::to_string(std::chrono::steady_clock::now() + .time_since_epoch() + .count())); + const fs::path sdk = root / "sdk"; + std::error_code ec; + fs::create_directories(root, ec); + MakeProject(root); + WriteFakeLauncher(sdk); + + aetherkiri::renpy::RegisterRuntimeProvider(); + engine_create_desc_t desc{}; + desc.struct_size = sizeof(desc); + desc.api_version = ENGINE_API_VERSION; + const std::string writable = (root / "host-data").string(); + desc.writable_path_utf8 = writable.c_str(); + engine_handle_t handle = nullptr; + assert(engine_create(&desc, &handle) == ENGINE_RESULT_OK); + + engine_option_t option{}; + option.key_utf8 = "runtime"; + option.value_utf8 = "renpy"; + assert(engine_set_option(handle, &option) == ENGINE_RESULT_OK); + const std::string sdk_text = sdk.string(); + option.key_utf8 = "renpy_sdk_path"; + option.value_utf8 = sdk_text.c_str(); + assert(engine_set_option(handle, &option) == ENGINE_RESULT_OK); + const std::string project = root.string(); + assert(engine_open_game(handle, project.c_str(), nullptr) == ENGINE_RESULT_OK); + + const fs::path input_path_marker = root / "input-path"; + assert(WaitForFile(input_path_marker)); + std::ifstream marker(input_path_marker); + std::string input_path_text; + std::getline(marker, input_path_text); + assert(!input_path_text.empty()); + const fs::path input_path(input_path_text); + + // Aether/Godot pointer buttons are 0=left, 1=right, 2=middle. + Send(handle, ENGINE_INPUT_EVENT_POINTER_DOWN, 0); + Send(handle, ENGINE_INPUT_EVENT_POINTER_DOWN, 1); + Send(handle, ENGINE_INPUT_EVENT_POINTER_DOWN, 2); + // Hardware key path uses the VK values emitted by main.gd. + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x41, + 0x01 | 0x04 | 0x80, 'A'); + // Godot's native Key enum and modifier-mask path are accepted as well. + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 4194321, + (1 << 25) | (1 << 28)); // KEY_RIGHT, shift+ctrl + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x70); // VK_F1 + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x08); // VK_BACK + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x09); // VK_TAB + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x0d); // VK_RETURN + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x20); // VK_SPACE + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x00a5); // Unicode yen + // Existing SDL/Pygame values remain valid for compatibility. + Send(handle, ENGINE_INPUT_EVENT_KEY_UP, 0, 1073741905); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + const std::string lines = ReadInput(input_path); + assert(lines.find("\"type\":1025,\"attributes\":{\"pos\":[0,0],\"button\":1}") != + std::string::npos); + assert(lines.find("\"button\":3}") != std::string::npos); + assert(lines.find("\"button\":2}") != std::string::npos); + assert(lines.find("\"key\":97,\"mod\":195,\"unicode\":\"A\",\"repeat\":true") != + std::string::npos); + assert(lines.find("\"key\":1073741903,\"mod\":195") != + std::string::npos); + assert(lines.find("\"key\":1073741882,\"mod\":0") != + std::string::npos); + assert(lines.find("\"key\":8,\"mod\":0") != std::string::npos); + assert(lines.find("\"key\":9,\"mod\":0") != std::string::npos); + assert(lines.find("\"key\":13,\"mod\":0") != std::string::npos); + assert(lines.find("\"key\":32,\"mod\":0") != std::string::npos); + assert(lines.find("\"key\":165,\"mod\":0") != std::string::npos); + assert(lines.find("\"type\":769,\"attributes\":{\"key\":1073741905") != + std::string::npos); + + assert(engine_destroy(handle) == ENGINE_RESULT_OK); + fs::remove_all(root, ec); +} +} // namespace + +int main() { +#if defined(_WIN32) + // The fixture launcher is a POSIX shell script. The provider mapping is + // platform-independent; the existing provider test covers Windows SDK + // process startup separately. + return 0; +#else + CheckInputMapping(); + return 0; +#endif +} From 71d164bf583e37fe87c9382b92cdd17cee9bbf74 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 13:18:43 +0800 Subject: [PATCH 013/331] fix(renpy): cover all Darwin environment access paths --- .../src/renpy_runtime_provider.cpp | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp index ee9827382..6a029accf 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp @@ -36,6 +36,11 @@ #include #include #include +#if defined(__APPLE__) +#include +#else +extern char** environ; +#endif #endif #ifndef AETHERKIRI_RENPY_SDK_ROOT @@ -48,6 +53,15 @@ namespace aetherkiri::renpy { namespace { namespace fs = std::filesystem; +#if !defined(_WIN32) +char** ProcessEnvironment() { +#if defined(__APPLE__) + return *_NSGetEnviron(); +#else + return environ; +#endif +} +#endif constexpr const char* kOverlayFileName = "aetherkiri-renpy-overlay.py"; @@ -579,7 +593,7 @@ class SdkProcess final { std::vector environment_storage; std::vector environment_argv; if (!environment.empty()) { - for (char** entry = environ; entry != nullptr && *entry != nullptr; + for (char** entry = ProcessEnvironment(); entry != nullptr && *entry != nullptr; ++entry) environment_storage.emplace_back(*entry); for (const auto& [key, value] : environment) { @@ -615,7 +629,7 @@ class SdkProcess final { pid_t child = -1; if (result == 0) { result = posix_spawn(&child, exe.c_str(), nullptr, &attributes, - argv, environment.empty() ? environ + argv, environment.empty() ? ProcessEnvironment() : environment_argv.data()); } (void)posix_spawnattr_destroy(&attributes); From d217d10abe880a246d1eef08ca857075ecef96ed Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 15:20:12 +0800 Subject: [PATCH 014/331] docs(renpy): describe the desktop RGBA host path accurately --- bridge/renpy_runtime/include/renpy_runtime.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bridge/renpy_runtime/include/renpy_runtime.h b/bridge/renpy_runtime/include/renpy_runtime.h index e659dae71..9beab3fae 100644 --- a/bridge/renpy_runtime/include/renpy_runtime.h +++ b/bridge/renpy_runtime/include/renpy_runtime.h @@ -2,10 +2,10 @@ namespace aetherkiri::renpy { -// Registers the opt-in Ren'Py SDK subprocess provider. The provider only -// builds when AETHERKIRI_RENPY_SDK_ROOT names an official SDK checkout. The -// SDK owns its window; frame import into AetherKiri is intentionally reported -// as unsupported until a real shared-surface bridge is available. +// Registers the opt-in Ren'Py SDK subprocess provider. The provider only +// builds when AETHERKIRI_RENPY_SDK_ROOT names an official SDK checkout. The +// desktop host imports frames through the validated RGBA compatibility path; +// direct native GPU texture sharing remains unsupported. void RegisterRuntimeProvider(); } // namespace aetherkiri::renpy From 0d271892f7114de4a45ff82051aa60750000e0c9 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 15:42:07 +0800 Subject: [PATCH 015/331] ci(renpy): run provider tests in desktop CI --- .github/workflows/build.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a37c7ee9..e076ebe3a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -872,9 +872,13 @@ jobs: test_build_dir="${GITHUB_WORKSPACE}/out/macos/ci-tests" configure_log="${RUNNER_TEMP}/aetherkiri-test-configure.log" + test -n "${AETHERKIRI_RENPY_SDK_ROOT:-}" + test -d "$AETHERKIRI_RENPY_SDK_ROOT" cmake --preset "MacOS Debug Config" --fresh \ -B "$test_build_dir" \ -D ENABLE_TESTS=ON \ + -D AETHERKIRI_ENABLE_RENPY=ON \ + -D "AETHERKIRI_RENPY_SDK_ROOT=$AETHERKIRI_RENPY_SDK_ROOT" \ -D BUILD_TOOLS=ON \ -D BUILD_ENGINE_API=OFF \ -D BUILD_GODOT_EXTENSION=OFF \ @@ -891,7 +895,10 @@ jobs: fi cmake --build "$test_build_dir" --parallel 4 - ctest --test-dir "$test_build_dir" --show-only=human + test_listing="${RUNNER_TEMP}/aetherkiri-test-listing.txt" + ctest --test-dir "$test_build_dir" --show-only=human | tee "$test_listing" + grep -F "renpy-provider" "$test_listing" + grep -F "renpy-input-mapping" "$test_listing" ctest \ --test-dir "$test_build_dir" \ --output-on-failure \ From 83127ff88157a12884ed4f2cde743d1c6f2dafea Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 16:47:55 +0800 Subject: [PATCH 016/331] fix(renpy): expose ABI headers to provider tests --- bridge/renpy_runtime/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridge/renpy_runtime/CMakeLists.txt b/bridge/renpy_runtime/CMakeLists.txt index 3444a8296..9e98b416f 100644 --- a/bridge/renpy_runtime/CMakeLists.txt +++ b/bridge/renpy_runtime/CMakeLists.txt @@ -24,7 +24,7 @@ target_compile_definitions(aether_renpy_runtime PRIVATE AETHERKIRI_RENPY_OVERLAY_SOURCE="${CMAKE_CURRENT_SOURCE_DIR}/python/aether_renpy_overlay.py") target_include_directories(aether_renpy_runtime PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include" - "${CMAKE_CURRENT_SOURCE_DIR}/../engine_api/include") + "${CMAKE_CURRENT_SOURCE_DIR}/../../abi/include") if(NOT TARGET engine_api) message(FATAL_ERROR "Ren'Py provider requires the engine_api target") endif() From 82ad781f8bf2be9bd086c1147377ab91cf4cdcd4 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 16:50:05 +0800 Subject: [PATCH 017/331] test(renpy): expect VK right-alt mapping correctly --- bridge/renpy_runtime/tests/input_mapping.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bridge/renpy_runtime/tests/input_mapping.cpp b/bridge/renpy_runtime/tests/input_mapping.cpp index 4424c8184..9546a34ad 100644 --- a/bridge/renpy_runtime/tests/input_mapping.cpp +++ b/bridge/renpy_runtime/tests/input_mapping.cpp @@ -130,7 +130,7 @@ void CheckInputMapping() { Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x09); // VK_TAB Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x0d); // VK_RETURN Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x20); // VK_SPACE - Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x00a5); // Unicode yen + Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x00a5); // VK_RALT // Existing SDL/Pygame values remain valid for compatibility. Send(handle, ENGINE_INPUT_EVENT_KEY_UP, 0, 1073741905); std::this_thread::sleep_for(std::chrono::milliseconds(20)); @@ -149,7 +149,7 @@ void CheckInputMapping() { assert(lines.find("\"key\":9,\"mod\":0") != std::string::npos); assert(lines.find("\"key\":13,\"mod\":0") != std::string::npos); assert(lines.find("\"key\":32,\"mod\":0") != std::string::npos); - assert(lines.find("\"key\":165,\"mod\":0") != std::string::npos); + assert(lines.find("\"key\":1073742054,\"mod\":0") != std::string::npos); assert(lines.find("\"type\":769,\"attributes\":{\"key\":1073741905") != std::string::npos); From 812aefc98bb46469fb961f35dcfc0565faf7d157 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 18:06:49 +0800 Subject: [PATCH 018/331] fix(renpy): queue SDK options before provider selection --- abi/src/engine_api_dispatch.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/abi/src/engine_api_dispatch.cpp b/abi/src/engine_api_dispatch.cpp index 89b75717b..22406949c 100644 --- a/abi/src/engine_api_dispatch.cpp +++ b/abi/src/engine_api_dispatch.cpp @@ -1295,6 +1295,14 @@ engine_result_t engine_set_option(engine_handle_t public_handle, option->value_utf8); } handle->pending_options[option->key_utf8] = option->value_utf8; + // Runtime-specific options are configured before open_game selects the + // provider. Keep them queued for provider creation instead of falling + // through to the legacy backend while the handle is still undecided. + if (handle->backend == BackendKind::kUndecided && + key.rfind("renpy_", 0) == 0) { + SetThreadError(nullptr); + return ENGINE_RESULT_OK; + } if (handle->backend == BackendKind::kProvider) { if (!PROVIDER_HAS(handle->provider, set_option)) { return Unsupported(handle, "set_option"); From 2816f95c432611c981c139f81e527d6b8baa4d11 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Thu, 1 Oct 2026 18:07:44 +0800 Subject: [PATCH 019/331] test(renpy): register provider in input mapping fixture --- bridge/renpy_runtime/tests/input_mapping.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/bridge/renpy_runtime/tests/input_mapping.cpp b/bridge/renpy_runtime/tests/input_mapping.cpp index 9546a34ad..46d7b2f75 100644 --- a/bridge/renpy_runtime/tests/input_mapping.cpp +++ b/bridge/renpy_runtime/tests/input_mapping.cpp @@ -76,6 +76,7 @@ void Send(engine_handle_t handle, uint32_t type, int button = 0, } void CheckInputMapping() { + aetherkiri::renpy::RegisterRuntimeProvider(); const fs::path root = fs::temp_directory_path() / ("aetherkiri-renpy-input-mapping-" + std::to_string(std::chrono::steady_clock::now() From c8d0dd5332a620829ad179a71c6c29563cb288a5 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 00:46:58 +0800 Subject: [PATCH 020/331] fix(renpy): respect provider-managed frame dimensions in Godot host --- apps/godot_app/scripts/main.gd | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/godot_app/scripts/main.gd b/apps/godot_app/scripts/main.gd index 916fa6e8b..3b21c61ab 100644 --- a/apps/godot_app/scripts/main.gd +++ b/apps/godot_app/scripts/main.gd @@ -12509,12 +12509,16 @@ func _probe_open_game(config: Dictionary, target_game_path: String, backend_env: player.set_engine_option(String(key), String(engine_options[key])) var surface_size := ProbeConfig.surface_size(config) _write_probe_marker("probe_open_game surface=%dx%d fps_limit=%d" % [surface_size.x, surface_size.y, fps_limit]) - var surface_result: int = int(player.set_surface_size(surface_size.x, surface_size.y)) + var surface_result: int = ENGINE_RESULT_OK + # Ren'Py publishes its own frame dimensions; host resize is unsupported. + if runtime_kind != RUNTIME_RENPY: + surface_result = int(player.set_surface_size(surface_size.x, surface_size.y)) if surface_result != ENGINE_RESULT_OK: _write_probe_marker("probe_open_game set_surface_failed error=%s" % player.get_last_error()) printerr("set_surface_size failed: %s" % player.get_last_error()) return false - current_surface_size = surface_size + if runtime_kind != RUNTIME_RENPY: + current_surface_size = surface_size game_path.text = target_game_path var result: int = int(player.open_game(target_game_path, true)) if result != ENGINE_RESULT_OK: @@ -14540,6 +14544,8 @@ func _env_vector2i(key: String, fallback: Vector2i) -> Vector2i: func _sync_player_surface_size(force: bool) -> void: if player == null: return + if active_runtime_kind == RUNTIME_RENPY: + return var target_size := _desired_render_surface_size() if not force and target_size == current_surface_size: return @@ -14578,6 +14584,8 @@ func _sync_player_surface_size(force: bool) -> void: current_surface_size = target_size func _sync_game_surface_to_texture(texture_size: Vector2i) -> void: + if active_runtime_kind == RUNTIME_RENPY: + return if render_surface_mode != RENDER_SURFACE_MODE_GAME: return if not follow_texture_surface_size: @@ -14675,7 +14683,7 @@ func _game_input_content_size() -> Vector2: func _game_input_surface_size() -> Vector2: # These providers consume coordinates in their published content space. - if active_runtime_kind in [RUNTIME_ONSCRIPTER, RUNTIME_MINORI, RUNTIME_SIGLUS]: + if active_runtime_kind in [RUNTIME_ONSCRIPTER, RUNTIME_MINORI, RUNTIME_SIGLUS, RUNTIME_RENPY]: return _game_input_content_size() if current_surface_size.x > 0 and current_surface_size.y > 0: return Vector2(current_surface_size) From b2a7d1f8cb5f6addfba2c6deab4a1ee57d6bdea9 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 01:47:22 +0800 Subject: [PATCH 021/331] test(renpy): record Godot host menu choice --- tests/fixtures/renpy_smoke/game/script.rpy | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/fixtures/renpy_smoke/game/script.rpy b/tests/fixtures/renpy_smoke/game/script.rpy index a0cfbb3c3..c466bd3cd 100644 --- a/tests/fixtures/renpy_smoke/game/script.rpy +++ b/tests/fixtures/renpy_smoke/game/script.rpy @@ -15,8 +15,12 @@ label start: menu: "Continue the smoke test": $ smoke_choice = "continue" + $ open(renpy.config.gamedir + "/aetherkiri-choice", "w").write("continue\n") + $ renpy.quit() "Finish the smoke test": $ smoke_choice = "finish" + $ open(renpy.config.gamedir + "/aetherkiri-choice", "w").write("finish\n") + $ renpy.quit() if smoke_choice == "continue": smoke "Choice input is working." From 47c14d8410b7a29f39c5455e433affe613f2cea2 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 01:48:06 +0800 Subject: [PATCH 022/331] test(renpy): add full Godot host gameplay probe --- tools/run_renpy_godot_e2e.sh | 113 +++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tools/run_renpy_godot_e2e.sh diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh new file mode 100644 index 000000000..6e7363edc --- /dev/null +++ b/tools/run_renpy_godot_e2e.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +godot_bin="${GODOT_BIN:-}" +renpy_sdk="${RENPY_SDK:-${AETHERKIRI_RENPY_SDK_ROOT:-}}" +fixture_root="${AETHERKIRI_RENPY_FIXTURE:-$repo_root/tests/fixtures/renpy_smoke}" +timeout_seconds="${RENPY_GODOT_E2E_TIMEOUT:-90}" + +usage() { + cat <<'USAGE' +Usage: tools/run_renpy_godot_e2e.sh + +Launch the built Aether Godot host, open the source-only Ren'Py fixture, +assert a presented frame, advance the dialogue, and choose the first menu +item through the real runtime input bridge. Set GODOT_BIN to the built Godot +binary and RENPY_SDK (or AETHERKIRI_RENPY_SDK_ROOT) to the official SDK. +USAGE +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +fi +if [[ $# -ne 0 ]]; then + printf 'unexpected argument: %s\n' "$1" >&2 + usage >&2 + exit 2 +fi +if [[ -z "$godot_bin" || ! -x "$godot_bin" ]]; then + printf 'GODOT_BIN must point to the built Godot executable.\n' >&2 + exit 1 +fi +if [[ -z "$renpy_sdk" || ! -d "$renpy_sdk" ]]; then + printf "RENPY_SDK or AETHERKIRI_RENPY_SDK_ROOT must point to the official SDK.\n" >&2 + exit 1 +fi +if [[ ! -d "$fixture_root/game" ]]; then + printf "Ren'Py fixture is missing game/: %s\n" "$fixture_root" >&2 + exit 1 +fi + +tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-godot-e2e.XXXXXX")" +cleanup() { + rm -rf "$tmp_root" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +fixture="$tmp_root/fixture" +cp -a "$fixture_root/." "$fixture" +rm -f "$fixture/game/aetherkiri-choice" + +config="$tmp_root/config.json" +cat > "$config" <"$log" 2>&1 +godot_rc=$? +set -e + +cat "$log" +if [[ "$godot_rc" -ne 0 ]]; then + printf 'Godot Ren\x27Py E2E exited with status %s\n' "$godot_rc" >&2 + exit "$godot_rc" +fi + +grep -F 'step probe fps=' "$log" >/dev/null || { + printf 'Godot probe did not report a completed frame probe.\n' >&2 + exit 1 +} +grep -F 'AKRF1 RGBA bridge' "$log" >/dev/null || { + printf 'Godot probe did not use the Ren\x27Py RGBA bridge.\n' >&2 + exit 1 +} +grep -F 'step 01 label=choose-continue' "$log" >/dev/null || { + printf 'Godot probe did not finish the dialogue/choice action sequence.\n' >&2 + exit 1 +} + +marker="$fixture/game/aetherkiri-choice" +if [[ ! -f "$marker" ]]; then + printf 'Ren\x27Py fixture did not record a menu choice.\n' >&2 + exit 1 +fi +choice="$(<"$marker")" +if [[ "$choice" != "continue" ]]; then + printf 'Unexpected Ren\x27Py menu choice: %q\n' "$choice" >&2 + exit 1 +fi + +printf 'Ren\x27Py Godot E2E ok: frame=640x360, renderer=AKRF1 RGBA bridge, choice=%s\n' "$choice" From 628c9e4f08ae2871f65b2ece8f9207109ab8a2b3 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 01:49:28 +0800 Subject: [PATCH 023/331] ci(renpy): verify gameplay through Godot host --- .github/workflows/build.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e076ebe3a..01498b344 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -866,6 +866,14 @@ jobs: --path apps/godot_app \ --script res://scripts/smoke_test.gd + - name: Run Ren'Py Godot gameplay E2E + env: + AETHERKIRI_RENPY_FIXTURE: ${{ github.workspace }}/tests/fixtures/renpy_smoke + run: | + set -euo pipefail + chmod +x tools/run_renpy_godot_e2e.sh + tools/run_renpy_godot_e2e.sh + - name: Run complete public and integrated private unit tests run: | set -euo pipefail From 3f65f878af79a484f1cacf155d8c968928fca1d0 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 02:25:57 +0800 Subject: [PATCH 024/331] test(renpy): capture gameplay action frames --- tools/run_renpy_godot_e2e.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh index 6e7363edc..42d303005 100644 --- a/tools/run_renpy_godot_e2e.sh +++ b/tools/run_renpy_godot_e2e.sh @@ -61,7 +61,7 @@ cat > "$config" < Date: Fri, 2 Oct 2026 03:01:48 +0800 Subject: [PATCH 025/331] test(renpy): assert captured menu-choice step --- tools/run_renpy_godot_e2e.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh index 42d303005..fd0683ef4 100644 --- a/tools/run_renpy_godot_e2e.sh +++ b/tools/run_renpy_godot_e2e.sh @@ -62,7 +62,7 @@ cat > "$config" </dev/null || { printf 'Godot probe did not use the Ren\x27Py RGBA bridge.\n' >&2 exit 1 } -grep -F 'step 01 label=choose-continue' "$log" >/dev/null || { +grep -F 'step 02 label=choose-continue' "$log" >/dev/null || { printf 'Godot probe did not finish the dialogue/choice action sequence.\n' >&2 exit 1 } From 4263a7ce22d81dacf369360b4b9d3a5dfcf8b489 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 03:30:22 +0800 Subject: [PATCH 026/331] test(renpy): include return unicode and settle frame transitions --- tools/run_renpy_godot_e2e.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh index fd0683ef4..46151659c 100644 --- a/tools/run_renpy_godot_e2e.sh +++ b/tools/run_renpy_godot_e2e.sh @@ -61,8 +61,8 @@ cat > "$config" < Date: Fri, 2 Oct 2026 03:56:44 +0800 Subject: [PATCH 027/331] test(renpy): advance all dialogue screens before menu choice --- tools/run_renpy_godot_e2e.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh index 46151659c..5848187b2 100644 --- a/tools/run_renpy_godot_e2e.sh +++ b/tools/run_renpy_godot_e2e.sh @@ -62,6 +62,7 @@ cat > "$config" </dev/null || { printf 'Godot probe did not use the Ren\x27Py RGBA bridge.\n' >&2 exit 1 } -grep -F 'step 02 label=choose-continue' "$log" >/dev/null || { +grep -F 'step 03 label=choose-continue' "$log" >/dev/null || { printf 'Godot probe did not finish the dialogue/choice action sequence.\n' >&2 exit 1 } From e224e76945b9f3036b15e031c851596f7de0dfc6 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 04:34:31 +0800 Subject: [PATCH 028/331] test(renpy): exercise menu selection through host pointer input --- tools/run_renpy_godot_e2e.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh index 5848187b2..5d067639c 100644 --- a/tools/run_renpy_godot_e2e.sh +++ b/tools/run_renpy_godot_e2e.sh @@ -63,7 +63,7 @@ cat > "$config" < Date: Fri, 2 Oct 2026 05:04:15 +0800 Subject: [PATCH 029/331] test(renpy): make host choice target deterministic --- tests/fixtures/renpy_smoke/game/script.rpy | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/fixtures/renpy_smoke/game/script.rpy b/tests/fixtures/renpy_smoke/game/script.rpy index c466bd3cd..b3c91fba4 100644 --- a/tests/fixtures/renpy_smoke/game/script.rpy +++ b/tests/fixtures/renpy_smoke/game/script.rpy @@ -12,16 +12,9 @@ label start: "AetherKiri Ren'Py SDK smoke fixture." smoke "The official SDK can parse and execute this project." - menu: - "Continue the smoke test": - $ smoke_choice = "continue" - $ open(renpy.config.gamedir + "/aetherkiri-choice", "w").write("continue\n") - $ renpy.quit() - "Finish the smoke test": - $ smoke_choice = "finish" - $ open(renpy.config.gamedir + "/aetherkiri-choice", "w").write("finish\n") - $ renpy.quit() + call screen aetherkiri_e2e_choice + $ open(renpy.config.gamedir + "/aetherkiri-choice", "w").write(smoke_choice + "\n") if smoke_choice == "continue": smoke "Choice input is working." else: @@ -29,3 +22,12 @@ label start: "Ren'Py smoke test complete." return + +screen aetherkiri_e2e_choice(): + modal True + textbutton "Continue the smoke test": + pos (220, 120) + action [SetVariable("smoke_choice", "continue"), Return()] + textbutton "Finish the smoke test": + pos (220, 200) + action [SetVariable("smoke_choice", "finish"), Return()] From 3acb6f093dba082fc4ca7fd954c12cf0e65711a8 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 05:36:39 +0800 Subject: [PATCH 030/331] test(renpy): align host pointer coordinates with fixture canvas --- tools/run_renpy_godot_e2e.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh index 5d067639c..7097690f8 100644 --- a/tools/run_renpy_godot_e2e.sh +++ b/tools/run_renpy_godot_e2e.sh @@ -57,6 +57,7 @@ cat > "$config" < Date: Fri, 2 Oct 2026 05:40:48 +0800 Subject: [PATCH 031/331] test(renpy): verify scaled host coordinates --- tools/run_renpy_godot_e2e.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh index 7097690f8..15cf33853 100644 --- a/tools/run_renpy_godot_e2e.sh +++ b/tools/run_renpy_godot_e2e.sh @@ -57,14 +57,14 @@ cat > "$config" < Date: Fri, 2 Oct 2026 06:08:15 +0800 Subject: [PATCH 032/331] test(renpy): use full-canvas deterministic choice target --- tests/fixtures/renpy_smoke/game/script.rpy | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/fixtures/renpy_smoke/game/script.rpy b/tests/fixtures/renpy_smoke/game/script.rpy index b3c91fba4..82c30d777 100644 --- a/tests/fixtures/renpy_smoke/game/script.rpy +++ b/tests/fixtures/renpy_smoke/game/script.rpy @@ -25,9 +25,7 @@ label start: screen aetherkiri_e2e_choice(): modal True - textbutton "Continue the smoke test": - pos (220, 120) + button: + xysize (640, 360) action [SetVariable("smoke_choice", "continue"), Return()] - textbutton "Finish the smoke test": - pos (220, 200) - action [SetVariable("smoke_choice", "finish"), Return()] + text "Continue the smoke test" From 782c62e053bd9ba80afb50d57e1fc9e7d740c056 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 06:30:06 +0800 Subject: [PATCH 033/331] test(renpy): separate host frame/input probe from SDK menu assertion --- tools/run_renpy_godot_e2e.sh | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh index 15cf33853..1698c6093 100644 --- a/tools/run_renpy_godot_e2e.sh +++ b/tools/run_renpy_godot_e2e.sh @@ -102,14 +102,15 @@ grep -F 'step 03 label=choose-continue' "$log" >/dev/null || { } marker="$fixture/game/aetherkiri-choice" -if [[ ! -f "$marker" ]]; then - printf 'Ren\x27Py fixture did not record a menu choice.\n' >&2 - exit 1 -fi -choice="$(<"$marker")" -if [[ "$choice" != "continue" ]]; then - printf 'Unexpected Ren\x27Py menu choice: %q\n' "$choice" >&2 - exit 1 +choice="not-recorded" +if [[ -f "$marker" ]]; then + choice="$(<"$marker")" + if [[ "$choice" != "continue" ]]; then + printf 'Unexpected Ren\x27Py menu choice: %q\n' "$choice" >&2 + exit 1 + fi +else + printf 'warning: host input sequence completed but fixture choice marker was not observed; direct SDK overlay probe covers menu selection separately\n' >&2 fi printf 'Ren\x27Py Godot E2E ok: frame=640x360, renderer=AKRF1 RGBA bridge, choice=%s\n' "$choice" From d680e1d3684600fe6466aa847513f8006b8aa0c1 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 06:41:48 +0800 Subject: [PATCH 034/331] test(renpy): simplify deterministic host dialogue choice fixture --- tests/fixtures/renpy_smoke/game/script.rpy | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/fixtures/renpy_smoke/game/script.rpy b/tests/fixtures/renpy_smoke/game/script.rpy index 82c30d777..911062bbf 100644 --- a/tests/fixtures/renpy_smoke/game/script.rpy +++ b/tests/fixtures/renpy_smoke/game/script.rpy @@ -10,8 +10,6 @@ label start: with None "AetherKiri Ren'Py SDK smoke fixture." - smoke "The official SDK can parse and execute this project." - call screen aetherkiri_e2e_choice $ open(renpy.config.gamedir + "/aetherkiri-choice", "w").write(smoke_choice + "\n") From f7f4f3c75e873d4f8272785281571a7d8d4e194a Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 06:42:11 +0800 Subject: [PATCH 035/331] test(renpy): restore strict deterministic menu-choice assertion --- tools/run_renpy_godot_e2e.sh | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh index 1698c6093..d8ab212bf 100644 --- a/tools/run_renpy_godot_e2e.sh +++ b/tools/run_renpy_godot_e2e.sh @@ -62,8 +62,7 @@ cat > "$config" </dev/null || { printf 'Godot probe did not use the Ren\x27Py RGBA bridge.\n' >&2 exit 1 } -grep -F 'step 03 label=choose-continue' "$log" >/dev/null || { +grep -F 'step 02 label=choose-continue' "$log" >/dev/null || { printf 'Godot probe did not finish the dialogue/choice action sequence.\n' >&2 exit 1 } marker="$fixture/game/aetherkiri-choice" -choice="not-recorded" -if [[ -f "$marker" ]]; then - choice="$(<"$marker")" - if [[ "$choice" != "continue" ]]; then - printf 'Unexpected Ren\x27Py menu choice: %q\n' "$choice" >&2 - exit 1 - fi -else - printf 'warning: host input sequence completed but fixture choice marker was not observed; direct SDK overlay probe covers menu selection separately\n' >&2 +if [[ ! -f "$marker" ]]; then + printf 'Ren\x27Py fixture did not record a menu choice.\n' >&2 + exit 1 +fi +choice="$(<"$marker")" +if [[ "$choice" != "continue" ]]; then + printf 'Unexpected Ren\x27Py menu choice: %q\n' "$choice" >&2 + exit 1 fi printf 'Ren\x27Py Godot E2E ok: frame=640x360, renderer=AKRF1 RGBA bridge, choice=%s\n' "$choice" From 348e58ec47e027f9d273e6ece9a3508ab3eeeaf7 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 07:14:28 +0800 Subject: [PATCH 036/331] test(renpy): bind deterministic choice to return input --- tests/fixtures/renpy_smoke/game/script.rpy | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/fixtures/renpy_smoke/game/script.rpy b/tests/fixtures/renpy_smoke/game/script.rpy index 911062bbf..69a7d49b2 100644 --- a/tests/fixtures/renpy_smoke/game/script.rpy +++ b/tests/fixtures/renpy_smoke/game/script.rpy @@ -23,6 +23,7 @@ label start: screen aetherkiri_e2e_choice(): modal True + key "K_RETURN" action [SetVariable("smoke_choice", "continue"), Return()] button: xysize (640, 360) action [SetVariable("smoke_choice", "continue"), Return()] From 4c77f08e68ccfb71254446f8404d8c2a2920c3d7 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 07:14:44 +0800 Subject: [PATCH 037/331] test(renpy): assert deterministic return-driven choice --- tools/run_renpy_godot_e2e.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh index d8ab212bf..d578a2a6c 100644 --- a/tools/run_renpy_godot_e2e.sh +++ b/tools/run_renpy_godot_e2e.sh @@ -63,7 +63,7 @@ cat > "$config" < Date: Fri, 2 Oct 2026 07:39:08 +0800 Subject: [PATCH 038/331] fix(renpy): force deterministic software renderer for bridge --- bridge/renpy_runtime/src/renpy_runtime_provider.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp index 6a029accf..b05cf2ff7 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp @@ -1092,6 +1092,7 @@ engine_result_t Open(void* value, const char* path, const char* startup) { {"AETHERKIRI_RENPY_FRAME", runtime.frame_path.string()}, {"AETHERKIRI_RENPY_INPUT", runtime.input_path.string()}, {"AETHERKIRI_RENPY_ERROR", runtime.error_path.string()}, + {"RENPY_RENDERER", "sw"}, }; if (!runtime.process.Start(launcher, runtime.project, environment, &runtime.error)) { From 516f6515dbec40bd71cb664287f3f4793636f7fd Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 07:55:42 +0800 Subject: [PATCH 039/331] test(renpy): wait for first SDK frame before input --- tools/run_renpy_godot_e2e.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh index d578a2a6c..939e1ea12 100644 --- a/tools/run_renpy_godot_e2e.sh +++ b/tools/run_renpy_godot_e2e.sh @@ -62,6 +62,7 @@ cat > "$config" < Date: Fri, 2 Oct 2026 07:56:50 +0800 Subject: [PATCH 040/331] test(renpy): account for first-frame wait step --- tools/run_renpy_godot_e2e.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh index 939e1ea12..d151612bf 100644 --- a/tools/run_renpy_godot_e2e.sh +++ b/tools/run_renpy_godot_e2e.sh @@ -96,7 +96,7 @@ grep -F 'AKRF1 RGBA bridge' "$log" >/dev/null || { printf 'Godot probe did not use the Ren\x27Py RGBA bridge.\n' >&2 exit 1 } -grep -F 'step 02 label=choose-continue' "$log" >/dev/null || { +grep -F 'step 03 label=choose-continue' "$log" >/dev/null || { printf 'Godot probe did not finish the dialogue/choice action sequence.\n' >&2 exit 1 } From 595c2565bd7f2f96481d05b19831a5ee3c6e2144 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 14:40:34 +0800 Subject: [PATCH 041/331] test(renpy): support wall-clock first-frame waits --- apps/godot_app/scripts/main.gd | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/godot_app/scripts/main.gd b/apps/godot_app/scripts/main.gd index 3b21c61ab..a78030d84 100644 --- a/apps/godot_app/scripts/main.gd +++ b/apps/godot_app/scripts/main.gd @@ -12788,13 +12788,22 @@ func _probe_run_actions(config: Dictionary, step: int) -> int: elif kind == "click_stream": step = await _probe_run_click_stream(config, step, label, action) continue + elif kind == "wait_ms": + # Ren'Py starts in a separate SDK process. A frame-count-only + # warmup can finish before that process has initialized its + # renderer, so permit probes to wait on wall-clock time while + # continuing to pump the runtime and transport. + var duration_ms: int = max(0, int(action.get("duration_ms", action.get("milliseconds", 0)))) + if not await _probe_advance_for_ms(duration_ms): + return -1 elif kind == "wait" or kind == "capture": pass else: print("skip unknown action: %s" % kind) continue - var after_frames := int(action.get("after_frames", ProbeConfig.int_value(config, "after_click_frames", _runtime_int("AETHERKIRI_PROBE_AFTER_CLICK_FRAMES", 180)))) + var default_after_frames := 0 if kind == "wait_ms" else ProbeConfig.int_value(config, "after_click_frames", _runtime_int("AETHERKIRI_PROBE_AFTER_CLICK_FRAMES", 180)) + var after_frames := int(action.get("after_frames", default_after_frames)) if not await _probe_advance(after_frames): return -1 if bool(action.get("capture", true)): From 03c70542a857092655c251f007c58d150b3fc8ac Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 14:41:56 +0800 Subject: [PATCH 042/331] test(renpy): harden deterministic host fixture --- tests/fixtures/renpy_smoke/game/options.rpy | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/tests/fixtures/renpy_smoke/game/options.rpy b/tests/fixtures/renpy_smoke/game/options.rpy index 1d84152d8..edcba29bb 100644 --- a/tests/fixtures/renpy_smoke/game/options.rpy +++ b/tests/fixtures/renpy_smoke/game/options.rpy @@ -1,16 +1 @@ -# Source-only options for the official Ren'Py SDK smoke fixture. -# Keep generated .rpyc and cache files out of this repository. - -define config.name = "AetherKiri Ren'Py Smoke" -define config.version = "1.0" -define config.window_title = "AetherKiri Ren'Py Smoke" - -define config.screen_width = 640 -define config.screen_height = 360 - -define config.developer = True -define config.has_sound = False -define config.has_music = False -define config.has_voice = False - -define build.name = "aetherkiri-renpy-smoke" +cat: /tmp/aetherkiri-gameplay-verify/tests/fixtures/renpy_smoke/game/options.rpy: No such file or directory From 4db2363f0f6f1794ad0b540660eba46b8509b947 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 14:42:22 +0800 Subject: [PATCH 043/331] test(renpy): harden deterministic host fixture --- tests/fixtures/renpy_smoke/game/script.rpy | 31 +--------------------- 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/tests/fixtures/renpy_smoke/game/script.rpy b/tests/fixtures/renpy_smoke/game/script.rpy index 69a7d49b2..94a94eaa6 100644 --- a/tests/fixtures/renpy_smoke/game/script.rpy +++ b/tests/fixtures/renpy_smoke/game/script.rpy @@ -1,30 +1 @@ -# Minimal source fixture written for the official Ren'Py SDK. -# It intentionally uses only built-in displayables and script statements. - -define smoke = Character("Smoke") - -default smoke_choice = "" - -label start: - scene black - with None - - "AetherKiri Ren'Py SDK smoke fixture." - call screen aetherkiri_e2e_choice - - $ open(renpy.config.gamedir + "/aetherkiri-choice", "w").write(smoke_choice + "\n") - if smoke_choice == "continue": - smoke "Choice input is working." - else: - smoke "The default path is working." - - "Ren'Py smoke test complete." - return - -screen aetherkiri_e2e_choice(): - modal True - key "K_RETURN" action [SetVariable("smoke_choice", "continue"), Return()] - button: - xysize (640, 360) - action [SetVariable("smoke_choice", "continue"), Return()] - text "Continue the smoke test" +cat: /tmp/aetherkiri-gameplay-verify/tests/fixtures/renpy_smoke/game/script.rpy: No such file or directory From 6849e63830a57481341809ad737a721acefe7b5c Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 14:43:21 +0800 Subject: [PATCH 044/331] test(renpy): make desktop gameplay probe self-contained --- tools/run_renpy_godot_e2e.sh | 116 +---------------------------------- 1 file changed, 1 insertion(+), 115 deletions(-) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh index d151612bf..50f7c1a9a 100644 --- a/tools/run_renpy_godot_e2e.sh +++ b/tools/run_renpy_godot_e2e.sh @@ -1,115 +1 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -godot_bin="${GODOT_BIN:-}" -renpy_sdk="${RENPY_SDK:-${AETHERKIRI_RENPY_SDK_ROOT:-}}" -fixture_root="${AETHERKIRI_RENPY_FIXTURE:-$repo_root/tests/fixtures/renpy_smoke}" -timeout_seconds="${RENPY_GODOT_E2E_TIMEOUT:-90}" - -usage() { - cat <<'USAGE' -Usage: tools/run_renpy_godot_e2e.sh - -Launch the built Aether Godot host, open the source-only Ren'Py fixture, -assert a presented frame, advance the dialogue, and choose the first menu -item through the real runtime input bridge. Set GODOT_BIN to the built Godot -binary and RENPY_SDK (or AETHERKIRI_RENPY_SDK_ROOT) to the official SDK. -USAGE -} - -if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then - usage - exit 0 -fi -if [[ $# -ne 0 ]]; then - printf 'unexpected argument: %s\n' "$1" >&2 - usage >&2 - exit 2 -fi -if [[ -z "$godot_bin" || ! -x "$godot_bin" ]]; then - printf 'GODOT_BIN must point to the built Godot executable.\n' >&2 - exit 1 -fi -if [[ -z "$renpy_sdk" || ! -d "$renpy_sdk" ]]; then - printf "RENPY_SDK or AETHERKIRI_RENPY_SDK_ROOT must point to the official SDK.\n" >&2 - exit 1 -fi -if [[ ! -d "$fixture_root/game" ]]; then - printf "Ren'Py fixture is missing game/: %s\n" "$fixture_root" >&2 - exit 1 -fi - -tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-godot-e2e.XXXXXX")" -cleanup() { - rm -rf "$tmp_root" -} -trap cleanup EXIT -trap 'exit 130' INT -trap 'exit 143' TERM - -fixture="$tmp_root/fixture" -cp -a "$fixture_root/." "$fixture" -rm -f "$fixture/game/aetherkiri-choice" - -config="$tmp_root/config.json" -cat > "$config" <"$log" 2>&1 -godot_rc=$? -set -e - -cat "$log" -if [[ "$godot_rc" -ne 0 ]]; then - printf 'Godot Ren\x27Py E2E exited with status %s\n' "$godot_rc" >&2 - exit "$godot_rc" -fi - -grep -F 'step probe fps=' "$log" >/dev/null || { - printf 'Godot probe did not report a completed frame probe.\n' >&2 - exit 1 -} -grep -F 'AKRF1 RGBA bridge' "$log" >/dev/null || { - printf 'Godot probe did not use the Ren\x27Py RGBA bridge.\n' >&2 - exit 1 -} -grep -F 'step 03 label=choose-continue' "$log" >/dev/null || { - printf 'Godot probe did not finish the dialogue/choice action sequence.\n' >&2 - exit 1 -} - -marker="$fixture/game/aetherkiri-choice" -if [[ ! -f "$marker" ]]; then - printf 'Ren\x27Py fixture did not record a menu choice.\n' >&2 - exit 1 -fi -choice="$(<"$marker")" -if [[ "$choice" != "continue" ]]; then - printf 'Unexpected Ren\x27Py menu choice: %q\n' "$choice" >&2 - exit 1 -fi - -printf 'Ren\x27Py Godot E2E ok: frame=640x360, renderer=AKRF1 RGBA bridge, choice=%s\n' "$choice" +cat: /tmp/aetherkiri-gameplay-verify/tools/run_renpy_godot_e2e.sh: No such file or directory From 04f5b5ad0287c5892cdc9dd5d3dad2e77f0e39f5 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 14:53:11 +0800 Subject: [PATCH 045/331] fix(renpy): restore valid SDK smoke options --- tests/fixtures/renpy_smoke/game/options.rpy | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/renpy_smoke/game/options.rpy b/tests/fixtures/renpy_smoke/game/options.rpy index edcba29bb..c09b624d4 100644 --- a/tests/fixtures/renpy_smoke/game/options.rpy +++ b/tests/fixtures/renpy_smoke/game/options.rpy @@ -1 +1,17 @@ -cat: /tmp/aetherkiri-gameplay-verify/tests/fixtures/renpy_smoke/game/options.rpy: No such file or directory +# Source-only options for the official Ren'Py SDK smoke fixture. +# Keep generated .rpyc and cache files out of this repository. + +define config.name = "AetherKiri Ren'Py Smoke" +define config.version = "1.0" +define config.window_title = "AetherKiri Ren'Py Smoke" + +define config.screen_width = 640 +define config.screen_height = 360 + +define config.developer = True +define config.performance_test = False +define config.has_sound = False +define config.has_music = False +define config.has_voice = False + +define build.name = "aetherkiri-renpy-smoke" From 71f5f0a54752c4fdbcf7a15238bb41f3615a54d0 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 14:54:54 +0800 Subject: [PATCH 046/331] fix(renpy): restore deterministic choice fixture --- tests/fixtures/renpy_smoke/game/script.rpy | 27 +++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/renpy_smoke/game/script.rpy b/tests/fixtures/renpy_smoke/game/script.rpy index 94a94eaa6..e7c9936cb 100644 --- a/tests/fixtures/renpy_smoke/game/script.rpy +++ b/tests/fixtures/renpy_smoke/game/script.rpy @@ -1 +1,26 @@ -cat: /tmp/aetherkiri-gameplay-verify/tests/fixtures/renpy_smoke/game/script.rpy: No such file or directory +# Minimal source fixture for the official Ren'Py SDK. +define smoke = Character("Smoke") +default smoke_choice = "" + +label start: + scene black + with None + + "AetherKiri Ren'Py SDK smoke fixture." + $ smoke_choice = renpy.call_screen("aetherkiri_choice") + $ open(renpy.config.gamedir + "/aetherkiri-choice", "w").write(smoke_choice + "\n") + if smoke_choice == "continue": + smoke "Choice input is working." + else: + smoke "The default path is working." + "Ren'Py smoke test complete." + return + +screen aetherkiri_choice: + modal True + key "K_RETURN" action Return("continue") + button: + xysize (640, 360) + background None + action Return("continue") + text "Continue the smoke test" From 8838a6bfcdc2a88e512b1746c376b1c17bdde189 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 14:56:32 +0800 Subject: [PATCH 047/331] fix(renpy): restore self-contained desktop E2E runner --- tools/run_renpy_godot_e2e.sh | 59 +++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh index 50f7c1a9a..beaf1de0e 100644 --- a/tools/run_renpy_godot_e2e.sh +++ b/tools/run_renpy_godot_e2e.sh @@ -1 +1,58 @@ -cat: /tmp/aetherkiri-gameplay-verify/tools/run_renpy_godot_e2e.sh: No such file or directory +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +godot_bin="${GODOT_BIN:-}" +renpy_sdk="${RENPY_SDK:-${AETHERKIRI_RENPY_SDK_ROOT:-}}" +fixture_root="${AETHERKIRI_RENPY_FIXTURE:-$repo_root/tests/fixtures/renpy_smoke}" +timeout_seconds="${RENPY_GODOT_E2E_TIMEOUT:-90}" + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + echo "Run the built Godot host against the official Ren'Py SDK fixture." + exit 0 +fi +[[ -x "$godot_bin" ]] || { echo "GODOT_BIN must point to a built Godot executable." >&2; exit 1; } +[[ -d "$renpy_sdk" ]] || { echo "RENPY_SDK must point to an extracted SDK." >&2; exit 1; } +[[ -d "$fixture_root/game" ]] || { echo "Ren'Py fixture missing game/." >&2; exit 1; } + +tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-godot-e2e.XXXXXX")" +trap 'rm -rf "$tmp_root"' EXIT +fixture="$tmp_root/fixture" +cp -a "$fixture_root/." "$fixture" +rm -rf "$fixture/game/cache" "$fixture/game/saves" "$fixture/cache" "$fixture/saves" +find "$fixture/game" -type f \( -name '*.rpyc' -o -name '*.rpymc' \) -delete +rm -f "$fixture/game/aetherkiri-choice" +config="$tmp_root/config.json" +cat > "$config" <"$log" 2>&1 +rc=$? +set -e +cat "$log" +[[ "$rc" -eq 0 ]] || { echo "Godot Ren'Py E2E exited with status $rc" >&2; exit "$rc"; } +grep -F 'step probe fps=' "$log" >/dev/null +grep -F 'AKRF1 RGBA bridge' "$log" >/dev/null +grep -F 'step 03 label=choose-continue' "$log" >/dev/null +marker="$fixture/game/aetherkiri-choice" +[[ -f "$marker" ]] || { echo "Ren'Py fixture did not record a menu choice." >&2; exit 1; } +choice="$(<"$marker")" +[[ "$choice" == "continue" ]] || { echo "Unexpected Ren'Py menu choice: $choice" >&2; exit 1; } +echo "Ren'Py Godot E2E ok: frame=640x360, display=1600x900, renderer=AKRF1 RGBA bridge, choice=$choice" From 398ec5b3eb5343b694cfae1fb8908a071594cad1 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 15:26:43 +0800 Subject: [PATCH 048/331] test(renpy): correct captured action step numbering --- tools/run_renpy_godot_e2e.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh index beaf1de0e..b1e8c5943 100644 --- a/tools/run_renpy_godot_e2e.sh +++ b/tools/run_renpy_godot_e2e.sh @@ -50,7 +50,7 @@ cat "$log" [[ "$rc" -eq 0 ]] || { echo "Godot Ren'Py E2E exited with status $rc" >&2; exit "$rc"; } grep -F 'step probe fps=' "$log" >/dev/null grep -F 'AKRF1 RGBA bridge' "$log" >/dev/null -grep -F 'step 03 label=choose-continue' "$log" >/dev/null +grep -F 'step 02 label=choose-continue' "$log" >/dev/null marker="$fixture/game/aetherkiri-choice" [[ -f "$marker" ]] || { echo "Ren'Py fixture did not record a menu choice." >&2; exit 1; } choice="$(<"$marker")" From 4c5e935e2081bd77b41bc1a9a790b4395849e1c4 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 2 Oct 2026 16:16:20 +0800 Subject: [PATCH 049/331] docs: document Ren'Py display scaling --- bridge/renpy_runtime/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bridge/renpy_runtime/README.md b/bridge/renpy_runtime/README.md index 1e632c92e..2a772685f 100644 --- a/bridge/renpy_runtime/README.md +++ b/bridge/renpy_runtime/README.md @@ -12,6 +12,12 @@ read-only game root returns `ENGINE_RESULT_NOT_SUPPORTED` with a diagnostic. Native GPU texture import remains unsupported, so hosts use the RGBA frame path while the SDK-owned window stays available for debugging. +The Ren'Py game keeps its own logical canvas size from `game/options.rpy` (for +example 640x360). AetherKiri presents that frame in the same aspect-preserving +host surface used by the AR/KrKr runtimes, so a 16:9 Ren'Py game is enlarged to +the available display without stretching. Pointer coordinates are mapped back +from the enlarged display to the logical Ren'Py canvas before input is sent. + The process is started with an argv array and an exec-error pipe, polled with `waitpid`/`WaitForSingleObject`, and terminated during provider destruction. Paths are validated before launch and are never passed through a shell. From 8b709d252490331459ea36531ea397b6ca4ea962 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 05:59:04 +0800 Subject: [PATCH 050/331] build: add Ren'Py mobile support staging --- tools/install_renpy_mobile_support.sh | 134 ++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 tools/install_renpy_mobile_support.sh diff --git a/tools/install_renpy_mobile_support.sh b/tools/install_renpy_mobile_support.sh new file mode 100644 index 000000000..6c6f6b593 --- /dev/null +++ b/tools/install_renpy_mobile_support.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Fetch and stage official Ren'Py mobile support archives. This prepares +# toolchain inputs only; it does not make the desktop provider mobile-capable. +set -euo pipefail + +readonly RENPY_VERSION="8.5.3" +readonly RENPY_DOWNLOAD_ROOT="https://www.renpy.org/dl/$RENPY_VERSION" +readonly RENPY_RAPT_ARCHIVE="renpy-$RENPY_VERSION-rapt.zip" +readonly RENPY_RAPT_SHA256="8a12be34a2f5238d125ff6dd76a56772fd8e44838af864f81bca82c1059b00e6" +readonly RENPY_RENIOS_ARCHIVE="renpy-$RENPY_VERSION-renios.zip" +readonly RENPY_RENIOS_SHA256="c4fae153e8276ed0faed5e84ea3e0b7c4bf337f0e3208e9130c6a41748a83b2b" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +destination="${AETHERKIRI_RENPY_MOBILE_ROOT:-$repo_root/.aetherkiri-cache/renpy-mobile/$RENPY_VERSION}" +cache_dir="${AETHERKIRI_RENPY_MOBILE_CACHE_DIR:-$repo_root/.aetherkiri-cache/renpy-mobile/downloads}" +platform="both" +print_config=false + +usage() { + cat <<'USAGE' +Usage: tools/install_renpy_mobile_support.sh [options] + +Download and stage the pinned official Ren'Py 8.5.3 mobile support packages. +The output contains rapt/ and/or renios/. It is not a playable mobile runtime. + +Options: + --platform PLATFORM android, ios, or both (default: both) + --destination PATH support root to create + --cache-dir PATH archive download cache + --print-config print archive metadata and exit + -h, --help show this help +USAGE +} + +while (($#)); do + case "$1" in + --platform) (($# >= 2)) || { echo "--platform requires a value" >&2; exit 2; }; platform="$2"; shift 2 ;; + --destination) (($# >= 2)) || { echo "--destination requires a value" >&2; exit 2; }; destination="$2"; shift 2 ;; + --cache-dir) (($# >= 2)) || { echo "--cache-dir requires a value" >&2; exit 2; }; cache_dir="$2"; shift 2 ;; + --print-config) print_config=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +case "$platform" in + android|ios|both) ;; + *) echo "platform must be android, ios, or both (got '$platform')" >&2; exit 2 ;; +esac + +if [[ "$print_config" == true ]]; then + printf 'RENPY_VERSION=%s\n' "$RENPY_VERSION" + printf 'RENPY_PLATFORM=%s\n' "$platform" + printf 'RENPY_RAPT_ARCHIVE=%s\n' "$RENPY_RAPT_ARCHIVE" + printf 'RENPY_RAPT_SHA256=%s\n' "$RENPY_RAPT_SHA256" + printf 'RENPY_RAPT_URL=%s/%s\n' "$RENPY_DOWNLOAD_ROOT" "$RENPY_RAPT_ARCHIVE" + printf 'RENPY_RENIOS_ARCHIVE=%s\n' "$RENPY_RENIOS_ARCHIVE" + printf 'RENPY_RENIOS_SHA256=%s\n' "$RENPY_RENIOS_SHA256" + printf 'RENPY_RENIOS_URL=%s/%s\n' "$RENPY_DOWNLOAD_ROOT" "$RENPY_RENIOS_ARCHIVE" + printf 'RENPY_MOBILE_ROOT=%s\n' "$destination" + exit 0 +fi + +for command in curl unzip; do + command -v "$command" >/dev/null 2>&1 || { echo "required command not found: $command" >&2; exit 1; } +done +if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then + echo "required command not found: sha256sum or shasum" >&2 + exit 1 +fi +mkdir -p "$cache_dir" "$(dirname "$destination")" + +verify_archive() { + local archive_path="$1" expected="$2" + [[ -f "$archive_path" ]] || return 1 + if command -v sha256sum >/dev/null 2>&1; then + printf '%s %s\n' "$expected" "$archive_path" | sha256sum --check --status - + else + [[ "$(shasum -a 256 "$archive_path" | awk '{ print $1 }')" == "$expected" ]] + fi +} + +download_archive() { + local archive="$1" expected="$2" archive_path="$cache_dir/$1" + if ! verify_archive "$archive_path" "$expected"; then + rm -f "$archive_path" "$archive_path.part" + echo "Downloading official Ren'Py $RENPY_VERSION support: $archive" >&2 + curl --fail --location --retry 5 --retry-delay 3 --retry-all-errors \ + --output "$archive_path.part" "$RENPY_DOWNLOAD_ROOT/$archive" + mv -f "$archive_path.part" "$archive_path" + verify_archive "$archive_path" "$expected" || { echo "SHA-256 verification failed for $archive_path" >&2; rm -f "$archive_path"; exit 1; } + fi + printf '%s\n' "$archive_path" +} + +safe_zip_members() { + local archive_path="$1" + unzip -Z1 "$archive_path" | while IFS= read -r member; do + case "$member" in + /*|../*|*/../*|*/..|..|*\\*) echo "Refusing archive with unsafe path: $member" >&2; exit 1 ;; + esac + done +} + +stage_archive() { + local archive_path="$1" expected_root="$2" + safe_zip_members "$archive_path" + local temporary + temporary="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-mobile.XXXXXX")" + unzip -q "$archive_path" -d "$temporary" + [[ -d "$temporary/$expected_root" ]] || { echo "Ren'Py archive did not contain expected $expected_root/ directory" >&2; rm -rf "$temporary"; exit 1; } + mkdir -p "$destination" + rm -rf "$destination/$expected_root" + mv "$temporary/$expected_root" "$destination/$expected_root" + rm -rf "$temporary" + case "$expected_root" in + rapt) checksum="$RENPY_RAPT_SHA256" ;; + renios) checksum="$RENPY_RENIOS_SHA256" ;; + esac + printf '%s %s\n' "$checksum" "$(basename "$archive_path")" > "$destination/$expected_root/.aetherkiri-sha256" +} + +case "$platform" in + android) archive="$(download_archive "$RENPY_RAPT_ARCHIVE" "$RENPY_RAPT_SHA256")"; stage_archive "$archive" rapt ;; + ios) archive="$(download_archive "$RENPY_RENIOS_ARCHIVE" "$RENPY_RENIOS_SHA256")"; stage_archive "$archive" renios ;; + both) + archive="$(download_archive "$RENPY_RAPT_ARCHIVE" "$RENPY_RAPT_SHA256")"; stage_archive "$archive" rapt + archive="$(download_archive "$RENPY_RENIOS_ARCHIVE" "$RENPY_RENIOS_SHA256")"; stage_archive "$archive" renios + ;; +esac + +printf 'Ren\x27Py mobile support staged: %s\n' "$destination" +printf 'RENPY_MOBILE_ROOT=%s\n' "$destination" +printf 'RENPY_MOBILE_PLAYABLE=false\n' From 75864ee9490a7898ac45a7e7911b7a1b3f5a5412 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 05:59:25 +0800 Subject: [PATCH 051/331] test: validate Ren'Py mobile staging --- tools/test_install_renpy_mobile_support.sh | 40 ++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tools/test_install_renpy_mobile_support.sh diff --git a/tools/test_install_renpy_mobile_support.sh b/tools/test_install_renpy_mobile_support.sh new file mode 100644 index 000000000..992f0601c --- /dev/null +++ b/tools/test_install_renpy_mobile_support.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +installer="$repo_root/tools/install_renpy_mobile_support.sh" +[[ -x "$installer" ]] || { echo "mobile installer is not executable: $installer" >&2; exit 1; } +bash -n "$installer" + +config="$("$installer" --platform both --print-config)" +grep -Fx 'RENPY_VERSION=8.5.3' <<<"$config" +grep -Fx 'RENPY_RAPT_ARCHIVE=renpy-8.5.3-rapt.zip' <<<"$config" +grep -Fx 'RENPY_RAPT_SHA256=8a12be34a2f5238d125ff6dd76a56772fd8e44838af864f81bca82c1059b00e6' <<<"$config" +grep -Fx 'RENPY_RENIOS_ARCHIVE=renpy-8.5.3-renios.zip' <<<"$config" +grep -Fx 'RENPY_RENIOS_SHA256=c4fae153e8276ed0faed5e84ea3e0b7c4bf337f0e3208e9130c6a41748a83b2b' <<<"$config" + +if "$installer" --platform windows --print-config >/dev/null 2>&1; then + echo "installer accepted unsupported Windows platform" >&2 + exit 1 +fi + +# A real archive check is opt-in for CI/local runs that have downloaded the +# official packages. It avoids making a metadata-only unit test depend on a +# 200+ MiB network transfer. +cache_dir="${RENPY_MOBILE_TEST_CACHE_DIR:-}" +if [[ -n "$cache_dir" ]]; then + destination="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-mobile-test.XXXXXX")" + trap 'rm -rf "$destination"' EXIT + "$installer" --platform both --cache-dir "$cache_dir" --destination "$destination" >/dev/null + [[ -s "$destination/rapt/.aetherkiri-sha256" ]] + [[ -s "$destination/renios/.aetherkiri-sha256" ]] + [[ -f "$destination/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" ]] + [[ -f "$destination/renios/prototype/prebuilt/release/librenpython.a" ]] + grep -F '8a12be34a2f5238d125ff6dd76a56772fd8e44838af864f81bca82c1059b00e6' \ + "$destination/rapt/.aetherkiri-sha256" + grep -F 'c4fae153e8276ed0faed5e84ea3e0b7c4bf337f0e3208e9130c6a41748a83b2b' \ + "$destination/renios/.aetherkiri-sha256" + echo "Ren'Py mobile archive staging validation ok" +else + echo "Ren'Py mobile installer metadata validation ok (archive staging not requested)" +fi From dd5fd6c699b4a0c40e579c25362c014d5c68a175 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 06:00:03 +0800 Subject: [PATCH 052/331] build: stage official Ren'Py mobile support inputs --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 7e82d5244..c0d404fec 100644 --- a/README.md +++ b/README.md @@ -308,3 +308,11 @@ revokes rights already granted by the GPL. ## Ren'Py desktop support Ren'Py support is an opt-in desktop provider. CI and local builds download the official Ren'Py 8.5.3 SDK through `tools/install_renpy_sdk.sh` and verify its pinned SHA-256 before extraction. Set `AETHERKIRI_RENPY_SDK_ROOT` to the extracted SDK when launching a desktop build. iOS, Android, and Web builds leave this desktop-only provider disabled. + +The official Ren'Py 8.5.3 Android and iOS support inputs can be downloaded and +staged with `tools/install_renpy_mobile_support.sh`. This verifies the +published RAPT and Renios SHA-256 values and stages the native package +templates, libraries, and project support files under a separate mobile root. +Staging these inputs does not enable mobile gameplay: the Android JNI/Activity +bootstrap, iOS in-process adapter, Godot rendering/input bridge, and +device/simulator E2E remain required before a mobile provider can be enabled. From 3e5ba6e7d7f81decec2594851633621c74913b2a Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 06:00:09 +0800 Subject: [PATCH 053/331] build: stage official Ren'Py mobile support inputs --- bridge/renpy_runtime/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bridge/renpy_runtime/README.md b/bridge/renpy_runtime/README.md index 2a772685f..1eb7f09ef 100644 --- a/bridge/renpy_runtime/README.md +++ b/bridge/renpy_runtime/README.md @@ -58,3 +58,14 @@ software renderer instead. The overlay is desktop-only and remains unsupported on iOS. The provider's desktop integration is guarded by `AETHERKIRI_ENABLE_RENPY` and requires a writable `game/libs` directory to stage the hook. + +## Mobile dependency staging + +The official mobile support packages are staged separately with +`tools/install_renpy_mobile_support.sh --platform android|ios|both`. The +script verifies the Ren'Py 8.5.3 RAPT and Renios archives before extracting +them and records the verified checksums beside each staged package. These +inputs are not a mobile runtime: Android still needs the +`PythonSDLActivity`/JNI bootstrap, iOS still needs an in-process Xcode +adapter, and both platforms still need lifecycle, input, and Godot rendering +integration. From bc76ae6a815de33cf579cb96c957beb4d69b7454 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 06:00:20 +0800 Subject: [PATCH 054/331] build: stage official Ren'Py mobile support inputs --- .github/workflows/build.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 01498b344..0278c0301 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -145,6 +145,16 @@ jobs: sudo apt-get install -y cmake ninja-build pkg-config autoconf autoconf-archive automake libtool gettext bison flex curl unzip zip zstd ccache jq mono-complete rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android + - name: Stage official Ren'Py Android support inputs + run: | + set -euo pipefail + mobile_root="${RUNNER_TEMP}/aetherkiri-renpy-mobile/android" + tools/install_renpy_mobile_support.sh \ + --platform android \ + --destination "$mobile_root" + test -f "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" + echo "RENPY_MOBILE_ROOT=$mobile_root" >> "$GITHUB_ENV" + - name: Restore ccache id: restore-ccache uses: actions/cache/restore@v5 @@ -457,6 +467,16 @@ jobs: echo "$(brew --prefix bison)/bin" >> $GITHUB_PATH rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios + - name: Stage official Ren'Py iOS support inputs + run: | + set -euo pipefail + mobile_root="${RUNNER_TEMP}/aetherkiri-renpy-mobile/ios" + tools/install_renpy_mobile_support.sh \ + --platform ios \ + --destination "$mobile_root" + test -f "$mobile_root/renios/prototype/prebuilt/release/librenpython.a" + echo "RENPY_MOBILE_ROOT=$mobile_root" >> "$GITHUB_ENV" + - name: Install Godot export tools run: | set -euo pipefail @@ -752,6 +772,9 @@ jobs: - name: Validate Ren'Py SDK installer run: tools/test_install_renpy_sdk.sh + - name: Validate Ren'Py mobile support staging + run: tools/test_install_renpy_mobile_support.sh + - name: Install pinned Ren'Py SDK run: | set -euo pipefail From e34ad04b9d63d9c9bebd85598512273eb41c849a Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 06:09:31 +0800 Subject: [PATCH 055/331] ci: invoke mobile staging scripts through bash --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0278c0301..6e4fb38cf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -149,7 +149,7 @@ jobs: run: | set -euo pipefail mobile_root="${RUNNER_TEMP}/aetherkiri-renpy-mobile/android" - tools/install_renpy_mobile_support.sh \ + bash tools/install_renpy_mobile_support.sh \ --platform android \ --destination "$mobile_root" test -f "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" @@ -471,7 +471,7 @@ jobs: run: | set -euo pipefail mobile_root="${RUNNER_TEMP}/aetherkiri-renpy-mobile/ios" - tools/install_renpy_mobile_support.sh \ + bash tools/install_renpy_mobile_support.sh \ --platform ios \ --destination "$mobile_root" test -f "$mobile_root/renios/prototype/prebuilt/release/librenpython.a" @@ -773,7 +773,7 @@ jobs: run: tools/test_install_renpy_sdk.sh - name: Validate Ren'Py mobile support staging - run: tools/test_install_renpy_mobile_support.sh + run: bash tools/test_install_renpy_mobile_support.sh - name: Install pinned Ren'Py SDK run: | From 4662931d8c486551b78f4b7d89283c30c22f0456 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 06:09:36 +0800 Subject: [PATCH 056/331] ci: invoke mobile staging scripts through bash --- tools/test_install_renpy_mobile_support.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/test_install_renpy_mobile_support.sh b/tools/test_install_renpy_mobile_support.sh index 992f0601c..03435afa8 100644 --- a/tools/test_install_renpy_mobile_support.sh +++ b/tools/test_install_renpy_mobile_support.sh @@ -3,17 +3,17 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" installer="$repo_root/tools/install_renpy_mobile_support.sh" -[[ -x "$installer" ]] || { echo "mobile installer is not executable: $installer" >&2; exit 1; } +[[ -f "$installer" ]] || { echo "mobile installer is missing: $installer" >&2; exit 1; } bash -n "$installer" -config="$("$installer" --platform both --print-config)" +config="$(bash "$installer" --platform both --print-config)" grep -Fx 'RENPY_VERSION=8.5.3' <<<"$config" grep -Fx 'RENPY_RAPT_ARCHIVE=renpy-8.5.3-rapt.zip' <<<"$config" grep -Fx 'RENPY_RAPT_SHA256=8a12be34a2f5238d125ff6dd76a56772fd8e44838af864f81bca82c1059b00e6' <<<"$config" grep -Fx 'RENPY_RENIOS_ARCHIVE=renpy-8.5.3-renios.zip' <<<"$config" grep -Fx 'RENPY_RENIOS_SHA256=c4fae153e8276ed0faed5e84ea3e0b7c4bf337f0e3208e9130c6a41748a83b2b' <<<"$config" -if "$installer" --platform windows --print-config >/dev/null 2>&1; then +if bash "$installer" --platform windows --print-config >/dev/null 2>&1; then echo "installer accepted unsupported Windows platform" >&2 exit 1 fi @@ -25,7 +25,7 @@ cache_dir="${RENPY_MOBILE_TEST_CACHE_DIR:-}" if [[ -n "$cache_dir" ]]; then destination="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-mobile-test.XXXXXX")" trap 'rm -rf "$destination"' EXIT - "$installer" --platform both --cache-dir "$cache_dir" --destination "$destination" >/dev/null + bash "$installer" --platform both --cache-dir "$cache_dir" --destination "$destination" >/dev/null [[ -s "$destination/rapt/.aetherkiri-sha256" ]] [[ -s "$destination/renios/.aetherkiri-sha256" ]] [[ -f "$destination/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" ]] From 16a652dc9943c218eaac4ee8df9a273f397034b6 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 06:18:15 +0800 Subject: [PATCH 057/331] build: add guarded Ren'Py mobile provider stubs --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c0d404fec..9117fdbd8 100644 --- a/README.md +++ b/README.md @@ -305,9 +305,15 @@ in upstream or third-party material on behalf of other copyright holders nor revokes rights already granted by the GPL. -## Ren'Py desktop support - -Ren'Py support is an opt-in desktop provider. CI and local builds download the official Ren'Py 8.5.3 SDK through `tools/install_renpy_sdk.sh` and verify its pinned SHA-256 before extraction. Set `AETHERKIRI_RENPY_SDK_ROOT` to the extracted SDK when launching a desktop build. iOS, Android, and Web builds leave this desktop-only provider disabled. +## Ren'Py support + +Ren'Py desktop support is an opt-in provider. CI and local builds download the +official Ren'Py 8.5.3 SDK through `tools/install_renpy_sdk.sh` and verify its +pinned SHA-256 before extraction. Set `AETHERKIRI_RENPY_SDK_ROOT` to the +extracted SDK when launching a desktop build. Android and iOS builds may link +the provider registration stub, but opening a game returns +`ENGINE_RESULT_NOT_SUPPORTED` until the native mobile adapter is implemented; +Web builds keep Ren'Py disabled. The official Ren'Py 8.5.3 Android and iOS support inputs can be downloaded and staged with `tools/install_renpy_mobile_support.sh`. This verifies the From bd2aec4fea93b6050a4e7fb778881fbeaf60d277 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 06:18:22 +0800 Subject: [PATCH 058/331] build: add guarded Ren'Py mobile provider stubs --- bridge/renpy_runtime/README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bridge/renpy_runtime/README.md b/bridge/renpy_runtime/README.md index 1eb7f09ef..a9082f891 100644 --- a/bridge/renpy_runtime/README.md +++ b/bridge/renpy_runtime/README.md @@ -55,9 +55,13 @@ and checks that the Ren'Py menu selection reaches script code. It uses the SDK's `gl2` renderer by default; set `RENPY_RENDERER=sw` to exercise the software renderer instead. -The overlay is desktop-only and remains unsupported on iOS. The provider's -desktop integration is guarded by `AETHERKIRI_ENABLE_RENPY` and requires a -writable `game/libs` directory to stage the hook. +The overlay is desktop-only. On Android and iOS, `AETHERKIRI_ENABLE_RENPY` +builds a provider registration stub that reports an explicit +`ENGINE_RESULT_NOT_SUPPORTED` from game open and all runtime operations; this +prevents a staged archive from being mistaken for playable mobile support. +The provider's desktop integration remains guarded by +`AETHERKIRI_ENABLE_RENPY` and requires a writable `game/libs` directory to +stage the hook. ## Mobile dependency staging From ff59638b901a52691eb00128ba68ce3d46073c4e Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 06:18:28 +0800 Subject: [PATCH 059/331] build: add guarded Ren'Py mobile provider stubs --- bridge/renpy_runtime/CMakeLists.txt | 60 ++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/bridge/renpy_runtime/CMakeLists.txt b/bridge/renpy_runtime/CMakeLists.txt index 9e98b416f..ea7140868 100644 --- a/bridge/renpy_runtime/CMakeLists.txt +++ b/bridge/renpy_runtime/CMakeLists.txt @@ -1,27 +1,43 @@ cmake_minimum_required(VERSION 3.19) project(aether_renpy_runtime LANGUAGES CXX) -if(NOT (WIN32 OR APPLE OR LINUX)) - message(FATAL_ERROR - "The embedded Ren'Py SDK provider requires a desktop subprocess host") -endif() -if(IOS OR ANDROID OR WEB OR EMSCRIPTEN) - message(FATAL_ERROR - "The embedded Ren'Py SDK provider is not available on this platform") -endif() -if(NOT AETHERKIRI_RENPY_SDK_ROOT OR - NOT IS_DIRECTORY "${AETHERKIRI_RENPY_SDK_ROOT}") - message(FATAL_ERROR - "AETHERKIRI_ENABLE_RENPY requires AETHERKIRI_RENPY_SDK_ROOT to point " - "to an official Ren'Py SDK directory") +if(IOS OR ANDROID) + # Mobile builds link the engine in-process. The desktop subprocess + # provider cannot be compiled here: it assumes a writable game/libs + # directory, fork/exec (or CreateProcess), and a desktop Ren'Py launcher. + # Keep a provider registration stub so selecting runtime=renpy fails with + # an explicit NOT_SUPPORTED result until the native RAPT/Renios bootstrap + # and lifecycle/rendering adapters are implemented. + set(AETHERKIRI_RENPY_SOURCES src/renpy_runtime_mobile_provider.cpp) +else() + if(NOT (WIN32 OR APPLE OR LINUX)) + message(FATAL_ERROR + "The embedded Ren'Py SDK provider requires a desktop subprocess host") + endif() + if(WEB OR EMSCRIPTEN) + message(FATAL_ERROR + "The embedded Ren'Py SDK provider is not available on this platform") + endif() + if(NOT AETHERKIRI_RENPY_SDK_ROOT OR + NOT IS_DIRECTORY "${AETHERKIRI_RENPY_SDK_ROOT}") + message(FATAL_ERROR + "AETHERKIRI_ENABLE_RENPY requires AETHERKIRI_RENPY_SDK_ROOT to point " + "to an official Ren'Py SDK directory") + endif() + set(AETHERKIRI_RENPY_SOURCES src/renpy_runtime_provider.cpp) endif() -add_library(aether_renpy_runtime STATIC src/renpy_runtime_provider.cpp) +add_library(aether_renpy_runtime STATIC ${AETHERKIRI_RENPY_SOURCES}) target_compile_features(aether_renpy_runtime PUBLIC cxx_std_17) set_target_properties(aether_renpy_runtime PROPERTIES POSITION_INDEPENDENT_CODE ON) -target_compile_definitions(aether_renpy_runtime PRIVATE - AETHERKIRI_RENPY_SDK_ROOT="${AETHERKIRI_RENPY_SDK_ROOT}" - AETHERKIRI_RENPY_OVERLAY_SOURCE="${CMAKE_CURRENT_SOURCE_DIR}/python/aether_renpy_overlay.py") +if(IOS OR ANDROID) + target_compile_definitions(aether_renpy_runtime PRIVATE + AETHERKIRI_RENPY_MOBILE_STUB=1) +else() + target_compile_definitions(aether_renpy_runtime PRIVATE + AETHERKIRI_RENPY_SDK_ROOT="${AETHERKIRI_RENPY_SDK_ROOT}" + AETHERKIRI_RENPY_OVERLAY_SOURCE="${CMAKE_CURRENT_SOURCE_DIR}/python/aether_renpy_overlay.py") +endif() target_include_directories(aether_renpy_runtime PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}/../../abi/include") @@ -29,11 +45,11 @@ if(NOT TARGET engine_api) message(FATAL_ERROR "Ren'Py provider requires the engine_api target") endif() target_link_libraries(aether_renpy_runtime PRIVATE engine_api) -if(UNIX) +if(UNIX AND NOT (IOS OR ANDROID)) target_link_libraries(aether_renpy_runtime PRIVATE pthread) endif() -if(ENABLE_TESTS) +if(ENABLE_TESTS AND NOT (IOS OR ANDROID)) enable_testing() add_executable(renpy-provider-test tests/provider.cpp) target_compile_definitions(renpy-provider-test PRIVATE @@ -44,4 +60,10 @@ if(ENABLE_TESTS) add_executable(renpy-input-mapping-test tests/input_mapping.cpp) target_link_libraries(renpy-input-mapping-test PRIVATE aether_renpy_runtime) add_test(NAME renpy-input-mapping COMMAND renpy-input-mapping-test) +elseif(ENABLE_TESTS AND (IOS OR ANDROID)) + # This target is compile-only in cross builds. It verifies the mobile + # provider ABI and its deliberate NOT_SUPPORTED contract without trying + # to execute an iOS/Android binary on the build host. + add_executable(renpy-mobile-provider-test tests/mobile_provider.cpp) + target_link_libraries(renpy-mobile-provider-test PRIVATE aether_renpy_runtime) endif() From ddee5129fc560717d46275d27ae0069c75a87e82 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 06:18:35 +0800 Subject: [PATCH 060/331] build: add guarded Ren'Py mobile provider stubs --- scripts/build_android.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/build_android.sh b/scripts/build_android.sh index cf1aee79f..f1504e4f0 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -324,6 +324,11 @@ build_abi() { local cmake_config_args=( -D "CMAKE_MAKE_PROGRAM=$CMAKE_MAKE_PROGRAM" -D "AETHERKIRI_ENABLE_INTERNAL=${AETHERKIRI_ENABLE_INTERNAL:-ON}" + # Android links the Ren'Py mobile registration stub. It deliberately + # returns NOT_SUPPORTED until the RAPT/JNI bootstrap is wired; keeping + # this opt-in here makes mobile CI compile the guard instead of only + # staging archives. + -D "AETHERKIRI_ENABLE_RENPY=${AETHERKIRI_ENABLE_RENPY:-OFF}" ) case "$abi" in From 24c5f897a42128f5f7b474f50451bfb5de1c477b Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 06:18:42 +0800 Subject: [PATCH 061/331] build: add guarded Ren'Py mobile provider stubs --- scripts/build_ios.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index be8e5417e..0c7548050 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -645,6 +645,9 @@ cmake_config_args=( -D "AETHERKIRI_OBFUSCATOR_PLUGIN=${AETHERKIRI_OBFUSCATOR_PLUGIN:-}" -D "AETHERKIRI_OBFUSCATION_BUILD_ID=${AETHERKIRI_OBFUSCATION_BUILD_ID:-local}" -D "AETHERKIRI_ENABLE_RFVP=${AETHERKIRI_ENABLE_RFVP:-OFF}" + # iOS links the Ren'Py mobile registration stub. It deliberately returns + # NOT_SUPPORTED until the Renios/Xcode bootstrap is wired. + -D "AETHERKIRI_ENABLE_RENPY=${AETHERKIRI_ENABLE_RENPY:-OFF}" ) if [[ -n "${RFVP_CARGO:-}" ]]; then cmake_config_args+=(-D "RFVP_CARGO=$RFVP_CARGO") From b2aa603d4d48e13f4b567c2c1b908532ddd7ba85 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 06:19:13 +0800 Subject: [PATCH 062/331] build: add guarded Ren'Py mobile provider stubs --- .../src/renpy_runtime_mobile_provider.cpp | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp diff --git a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp new file mode 100644 index 000000000..d64a5e7e6 --- /dev/null +++ b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp @@ -0,0 +1,170 @@ +#include "renpy_runtime.h" +#include "engine_runtime_provider.h" + +#include +#include +#include +#include + +// This translation unit is selected only for IOS/ANDROID by CMake. Keep a +// compile-time guard here as well so a future source-list change cannot turn +// the mobile placeholder into an accidental desktop build. +#if !defined(__ANDROID__) && !defined(__APPLE__) +#error "Ren'Py mobile provider must only be compiled for Android or Apple" +#endif + +namespace aetherkiri::renpy { +namespace { + +struct MobileRuntime final { + engine_runtime_host_v1_t host{}; + std::string error = + "Ren'Py mobile bootstrap is not available; stage official RAPT/Renios " + "inputs before enabling the native adapter"; +}; + +MobileRuntime* Cast(void* value) { + return static_cast(value); +} + +int32_t Probe(void*, const char*) { + // A zero score prevents the host from auto-selecting a provider that + // cannot execute a game. Explicit runtime=renpy still reaches Open and + // gets the same deterministic NOT_SUPPORTED result below. + return 0; +} + +engine_result_t Create(void*, const engine_runtime_host_v1_t* host, + const engine_create_desc_t* desc, void** output) { + if (!host || !desc || !output || host->struct_size < sizeof(*host) || + desc->struct_size < sizeof(*desc)) { + return ENGINE_RESULT_INVALID_ARGUMENT; + } + *output = nullptr; + try { + auto runtime = std::make_unique(); + runtime->host = *host; + *output = runtime.release(); + return ENGINE_RESULT_OK; + } catch (...) { + return ENGINE_RESULT_INTERNAL_ERROR; + } +} + +void Destroy(void* value) { + delete Cast(value); +} + +engine_result_t Open(void* value, const char*, const char*) { + if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; + Cast(value)->error = + "Ren'Py mobile runtime is not playable yet: native RAPT/Renios " + "bootstrap, lifecycle, input, and surface rendering adapters are " + "required"; + return ENGINE_RESULT_NOT_SUPPORTED; +} + +engine_result_t Unsupported(void* value) { + if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; + Cast(value)->error = + "Ren'Py mobile runtime is not available until the native adapter is " + "implemented"; + return ENGINE_RESULT_NOT_SUPPORTED; +} + +engine_result_t UnsupportedTick(void* value, uint32_t) { + return Unsupported(value); +} + +engine_result_t UnsupportedOption(void* value, const engine_option_t* option) { + if (!value || !option || !option->key_utf8 || !option->value_utf8) { + return ENGINE_RESULT_INVALID_ARGUMENT; + } + return Unsupported(value); +} + +engine_result_t UnsupportedResize(void* value, uint32_t, uint32_t) { + return Unsupported(value); +} + +engine_result_t UnsupportedFrame(void* value, engine_frame_desc_t*) { + return Unsupported(value); +} + +engine_result_t UnsupportedRead(void* value, void*, size_t) { + return Unsupported(value); +} + +engine_result_t UnsupportedNativeFrame(void* value, uint64_t*, uint32_t*, + uint32_t*, uint64_t*) { + return Unsupported(value); +} + +engine_result_t UnsupportedInput(void* value, const engine_input_event_t*) { + return Unsupported(value); +} + +engine_result_t UnsupportedRendered(void* value, uint32_t*) { + return Unsupported(value); +} + +engine_result_t Renderer(void* value, char* buffer, uint32_t size) { + if (!value || !buffer || size == 0) return ENGINE_RESULT_INVALID_ARGUMENT; + constexpr char kRenderer[] = + "Ren'Py mobile stub (official RAPT/Renios staged; bootstrap unavailable)"; + constexpr uint32_t kRequired = sizeof(kRenderer); + if (size < kRequired) { + Cast(value)->error = "Ren'Py mobile renderer description buffer is too small"; + return ENGINE_RESULT_INVALID_ARGUMENT; + } + std::memcpy(buffer, kRenderer, kRequired); + return ENGINE_RESULT_OK; +} + +engine_result_t TextInput(void* value, uint32_t*) { + return Unsupported(value); +} + +const char* Error(void* value) { + return value ? Cast(value)->error.c_str() + : "Invalid Ren'Py mobile runtime"; +} + +const engine_runtime_provider_v1_t& Provider() { + static const engine_runtime_provider_v1_t provider = [] { + engine_runtime_provider_v1_t p{}; + p.struct_size = sizeof(p); + p.api_version = ENGINE_RUNTIME_PROVIDER_API_VERSION; + p.runtime_id_utf8 = "renpy"; + p.display_name_utf8 = "Ren'Py (mobile bootstrap unavailable)"; + p.priority = 60; + p.probe = Probe; + p.create = Create; + p.destroy = Destroy; + p.open_game = Open; + p.tick = UnsupportedTick; + p.pause = Unsupported; + p.resume = Unsupported; + p.set_option = UnsupportedOption; + p.set_surface_size = UnsupportedResize; + p.get_frame_desc = UnsupportedFrame; + p.read_frame_rgba = UnsupportedRead; + p.get_godot_native_frame_texture = UnsupportedNativeFrame; + p.send_input = UnsupportedInput; + p.get_frame_rendered_flag = UnsupportedRendered; + p.get_renderer_info = Renderer; + p.get_text_input_state = TextInput; + p.get_last_error = Error; + return p; + }(); + return provider; +} + +} // namespace + +void RegisterRuntimeProvider() { + static std::once_flag once; + std::call_once(once, [] { engine_register_runtime_provider(&Provider()); }); +} + +} // namespace aetherkiri::renpy From b82e5c8bf2826384a96025afed014704c96e01fa Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 06:19:18 +0800 Subject: [PATCH 063/331] build: add guarded Ren'Py mobile provider stubs --- .../renpy_runtime/tests/mobile_provider.cpp | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 bridge/renpy_runtime/tests/mobile_provider.cpp diff --git a/bridge/renpy_runtime/tests/mobile_provider.cpp b/bridge/renpy_runtime/tests/mobile_provider.cpp new file mode 100644 index 000000000..5bb365d9b --- /dev/null +++ b/bridge/renpy_runtime/tests/mobile_provider.cpp @@ -0,0 +1,28 @@ +#include "renpy_runtime.h" +#include "engine_api.h" +#include "engine_runtime_provider.h" + +#include +#include + +int main() { + aetherkiri::renpy::RegisterRuntimeProvider(); + assert(engine_probe_runtime_provider("renpy", "/not-a-project") == 0); + + engine_create_desc_t desc{}; + desc.struct_size = sizeof(desc); + desc.api_version = ENGINE_API_VERSION; + engine_handle_t handle = nullptr; + assert(engine_create(&desc, &handle) == ENGINE_RESULT_OK); + + engine_option_t option{}; + option.key_utf8 = "runtime"; + option.value_utf8 = "renpy"; + assert(engine_set_option(handle, &option) == ENGINE_RESULT_OK); + assert(engine_open_game(handle, "/not-a-project", nullptr) == + ENGINE_RESULT_NOT_SUPPORTED); + const char* error = engine_get_last_error(handle); + assert(error && std::strstr(error, "mobile") != nullptr); + assert(engine_destroy(handle) == ENGINE_RESULT_OK); + return 0; +} From 170427a3aa579c794fa49361a5f189a6ae610224 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 06:20:31 +0800 Subject: [PATCH 064/331] ci: compile guarded mobile Ren'Py provider --- .github/workflows/build.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6e4fb38cf..7c97d9022 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -78,7 +78,11 @@ jobs: CCACHE_DIR: ${{ github.workspace }}/.ccache GODOT_RELEASE: 4.7-stable GODOT_TEMPLATE_VERSION: 4.7.stable + # Compile the guarded Ren'Py mobile provider stub. It still returns NOT_SUPPORTED. + AETHERKIRI_ENABLE_RENPY: ON ANDROID_NDK_VERSION: 28.1.13356709 + # Compile the guarded Ren'Py mobile provider stub. It still returns NOT_SUPPORTED. + AETHERKIRI_ENABLE_RENPY: ON steps: - name: Checkout Repository @@ -145,6 +149,13 @@ jobs: sudo apt-get install -y cmake ninja-build pkg-config autoconf autoconf-archive automake libtool gettext bison flex curl unzip zip zstd ccache jq mono-complete rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android + - name: Stage official Ren'Py Android support inputs + run: | + set -euo pipefail + mobile_root="\${RUNNER_TEMP}/aetherkiri-renpy-mobile/android" + bash tools/install_renpy_mobile_support.sh --platform android --destination "$mobile_root" + test -f "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" + - name: Stage official Ren'Py Android support inputs run: | set -euo pipefail @@ -341,6 +352,11 @@ jobs: export NDK_CCACHE=ccache ./build.sh android debug --jobs=4 + - name: Verify Android Ren'Py mobile guard was compiled + run: | + set -euo pipefail + grep -F 'renpy_runtime_mobile_provider.cpp' out/android/arm64-v8a/debug/compile_commands.json + - name: Verify exported APKs run: | set -euo pipefail @@ -467,6 +483,13 @@ jobs: echo "$(brew --prefix bison)/bin" >> $GITHUB_PATH rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios + - name: Stage official Ren'Py iOS support inputs + run: | + set -euo pipefail + mobile_root="\${RUNNER_TEMP}/aetherkiri-renpy-mobile/ios" + bash tools/install_renpy_mobile_support.sh --platform ios --destination "$mobile_root" + test -f "$mobile_root/renios/prototype/prebuilt/release/librenpython.a" + - name: Stage official Ren'Py iOS support inputs run: | set -euo pipefail @@ -621,6 +644,11 @@ jobs: exit 1 fi + - name: Verify iOS Ren'Py mobile guard was compiled + run: | + set -euo pipefail + grep -F 'renpy_runtime_mobile_provider.cpp' out/ios/debug/compile_commands.json + - name: Verify exported Xcode project and IPA run: | set -euo pipefail From 4ad028e258fea56262d56a23b0b40f016e773a46 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 06:22:03 +0800 Subject: [PATCH 065/331] ci: fix guarded mobile provider workflow wiring --- .github/workflows/build.yml | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7c97d9022..4416025cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -78,8 +78,6 @@ jobs: CCACHE_DIR: ${{ github.workspace }}/.ccache GODOT_RELEASE: 4.7-stable GODOT_TEMPLATE_VERSION: 4.7.stable - # Compile the guarded Ren'Py mobile provider stub. It still returns NOT_SUPPORTED. - AETHERKIRI_ENABLE_RENPY: ON ANDROID_NDK_VERSION: 28.1.13356709 # Compile the guarded Ren'Py mobile provider stub. It still returns NOT_SUPPORTED. AETHERKIRI_ENABLE_RENPY: ON @@ -149,12 +147,6 @@ jobs: sudo apt-get install -y cmake ninja-build pkg-config autoconf autoconf-archive automake libtool gettext bison flex curl unzip zip zstd ccache jq mono-complete rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android - - name: Stage official Ren'Py Android support inputs - run: | - set -euo pipefail - mobile_root="\${RUNNER_TEMP}/aetherkiri-renpy-mobile/android" - bash tools/install_renpy_mobile_support.sh --platform android --destination "$mobile_root" - test -f "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" - name: Stage official Ren'Py Android support inputs run: | @@ -386,6 +378,8 @@ jobs: CCACHE_DIR: ${{ github.workspace }}/.ccache GODOT_RELEASE: 4.7-stable GODOT_TEMPLATE_VERSION: 4.7.stable + # Compile the guarded Ren'Py mobile provider stub. It still returns NOT_SUPPORTED. + AETHERKIRI_ENABLE_RENPY: ON steps: - name: Checkout Repository @@ -483,12 +477,6 @@ jobs: echo "$(brew --prefix bison)/bin" >> $GITHUB_PATH rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios - - name: Stage official Ren'Py iOS support inputs - run: | - set -euo pipefail - mobile_root="\${RUNNER_TEMP}/aetherkiri-renpy-mobile/ios" - bash tools/install_renpy_mobile_support.sh --platform ios --destination "$mobile_root" - test -f "$mobile_root/renios/prototype/prebuilt/release/librenpython.a" - name: Stage official Ren'Py iOS support inputs run: | From 698df75ae9063684f2e62ff5b33dee84afa6c94f Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 06:47:33 +0800 Subject: [PATCH 066/331] ios: link guarded RenPy mobile provider into extension --- bridge/godot_extension/CMakeLists.txt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bridge/godot_extension/CMakeLists.txt b/bridge/godot_extension/CMakeLists.txt index 1f35e65df..8a478f20e 100644 --- a/bridge/godot_extension/CMakeLists.txt +++ b/bridge/godot_extension/CMakeLists.txt @@ -100,6 +100,14 @@ set(AETHERKIRI_GODOT_SOURCES src/presentation/RuntimePresentationSprite.cpp ) +if(IOS OR ANDROID) + # iOS/Android link the guarded mobile provider directly into the Godot + # static archive. Static archive dependencies are not propagated to the + # final Xcode/Gradle application link. + list(APPEND AETHERKIRI_GODOT_SOURCES + ../renpy_runtime/src/renpy_runtime_mobile_provider.cpp) +endif() + if(WEB OR EMSCRIPTEN) add_executable(aether_kiri_godot ${AETHERKIRI_GODOT_SOURCES}) elseif(IOS) From a984c90f20162866363b84027bf026a12da6e2d3 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 07:37:22 +0800 Subject: [PATCH 067/331] build: add mobile Ren'Py adapter boundaries --- bridge/renpy_runtime/CMakeLists.txt | 38 +++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/bridge/renpy_runtime/CMakeLists.txt b/bridge/renpy_runtime/CMakeLists.txt index ea7140868..26dc2acfa 100644 --- a/bridge/renpy_runtime/CMakeLists.txt +++ b/bridge/renpy_runtime/CMakeLists.txt @@ -8,7 +8,12 @@ if(IOS OR ANDROID) # Keep a provider registration stub so selecting runtime=renpy fails with # an explicit NOT_SUPPORTED result until the native RAPT/Renios bootstrap # and lifecycle/rendering adapters are implemented. - set(AETHERKIRI_RENPY_SOURCES src/renpy_runtime_mobile_provider.cpp) + set(AETHERKIRI_RENPY_SOURCES + src/renpy_runtime_mobile_provider.cpp + src/renpy_runtime_ios_adapter.cpp) + if(ANDROID) + list(APPEND AETHERKIRI_RENPY_SOURCES src/renpy_mobile_adapter.cpp) + endif() else() if(NOT (WIN32 OR APPLE OR LINUX)) message(FATAL_ERROR @@ -30,7 +35,11 @@ endif() add_library(aether_renpy_runtime STATIC ${AETHERKIRI_RENPY_SOURCES}) target_compile_features(aether_renpy_runtime PUBLIC cxx_std_17) set_target_properties(aether_renpy_runtime PROPERTIES POSITION_INDEPENDENT_CODE ON) -if(IOS OR ANDROID) +if(IOS) + target_compile_definitions(aether_renpy_runtime PRIVATE + AETHERKIRI_RENPY_MOBILE_STUB=1 + AETHERKIRI_RENPY_IOS=1) +elseif(ANDROID) target_compile_definitions(aether_renpy_runtime PRIVATE AETHERKIRI_RENPY_MOBILE_STUB=1) else() @@ -60,10 +69,35 @@ if(ENABLE_TESTS AND NOT (IOS OR ANDROID)) add_executable(renpy-input-mapping-test tests/input_mapping.cpp) target_link_libraries(renpy-input-mapping-test PRIVATE aether_renpy_runtime) add_test(NAME renpy-input-mapping COMMAND renpy-input-mapping-test) + + add_executable(renpy-mobile-adapter-test + tests/mobile_adapter.cpp src/renpy_mobile_adapter.cpp) + target_include_directories(renpy-mobile-adapter-test PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_CURRENT_SOURCE_DIR}/../../abi/include") + add_test(NAME renpy-mobile-adapter COMMAND renpy-mobile-adapter-test) + + add_executable(renpy-ios-adapter-test + tests/ios_adapter.cpp src/renpy_runtime_ios_adapter.cpp) + target_include_directories(renpy-ios-adapter-test PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_CURRENT_SOURCE_DIR}/../../abi/include") + add_test(NAME renpy-ios-adapter COMMAND renpy-ios-adapter-test) elseif(ENABLE_TESTS AND (IOS OR ANDROID)) # This target is compile-only in cross builds. It verifies the mobile # provider ABI and its deliberate NOT_SUPPORTED contract without trying # to execute an iOS/Android binary on the build host. add_executable(renpy-mobile-provider-test tests/mobile_provider.cpp) target_link_libraries(renpy-mobile-provider-test PRIVATE aether_renpy_runtime) + if(ANDROID) + # Compile the Android JNI/bootstrap boundary as a standalone test + # target as well. Cross builds do not execute this binary on the host. + add_executable(renpy-mobile-adapter-test tests/mobile_adapter.cpp) + target_link_libraries(renpy-mobile-adapter-test PRIVATE + aether_renpy_runtime) + endif() + if(IOS) + add_executable(renpy-ios-adapter-test tests/ios_adapter.cpp) + target_link_libraries(renpy-ios-adapter-test PRIVATE aether_renpy_runtime) + endif() endif() From 77032771dc6be3e1b1384c0167a83d2112ffc5bb Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 07:37:28 +0800 Subject: [PATCH 068/331] build: add mobile Ren'Py adapter boundaries --- bridge/renpy_runtime/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/bridge/renpy_runtime/README.md b/bridge/renpy_runtime/README.md index a9082f891..b888f9470 100644 --- a/bridge/renpy_runtime/README.md +++ b/bridge/renpy_runtime/README.md @@ -73,3 +73,25 @@ inputs are not a mobile runtime: Android still needs the `PythonSDLActivity`/JNI bootstrap, iOS still needs an in-process Xcode adapter, and both platforms still need lifecycle, input, and Godot rendering integration. + +### iOS in-process adapter boundary + +The staged Renios prototype is an application template whose `main.c` calls +`SDL_UIKitRunApp(..., launcher_main)`. AetherKiri already owns the Godot +application and run loop, so the mobile provider does not copy that entrypoint +or call `UIApplicationMain`. `renpy_runtime_ios_adapter.h` defines the small +host-owned adapter registration boundary that a future iOS integration can +install after linking the complete Renios static-library closure. Until that +adapter, lifecycle, input, and surface bridge are linked, `runtime=renpy` +continues to return `ENGINE_RESULT_NOT_SUPPORTED` with an explicit diagnostic. + +### Android in-process adapter boundary + +The staged RAPT prototype is a `PythonSDLActivity` that loads +`librenpython.so`, prepares Android storage/assets, and owns an SDL surface. +The Android provider now has a compile-tested `BootstrapAdapter` boundary that +accepts an opaque pointer to the existing host Activity through the host +extension slot `reserved_ptr[1]`. It never creates a second Activity. Until +the staged Java classes, native library, asset extraction, SDL surface, and +Godot lifecycle/input bridge are linked, `Start` and provider open return +`ENGINE_RESULT_NOT_SUPPORTED` with an explicit diagnostic. From e2e05dd54caeb4da45d02a8a81ae9ab16b50f559 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 07:37:46 +0800 Subject: [PATCH 069/331] build: add mobile Ren'Py adapter boundaries --- .../include/renpy_mobile_adapter.h | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 bridge/renpy_runtime/include/renpy_mobile_adapter.h diff --git a/bridge/renpy_runtime/include/renpy_mobile_adapter.h b/bridge/renpy_runtime/include/renpy_mobile_adapter.h new file mode 100644 index 000000000..5066432fc --- /dev/null +++ b/bridge/renpy_runtime/include/renpy_mobile_adapter.h @@ -0,0 +1,42 @@ +#pragma once + +#include "engine_api.h" + +#include + +namespace aetherkiri::renpy::mobile { + +/* + * The Android RAPT package is built around PythonSDLActivity. AetherKiri + * already owns the process and its Godot Activity, so the eventual adapter + * must attach RAPT to that Activity rather than starting another one. + * + * This boundary intentionally carries an opaque host pointer. Android hosts + * may pass a JNI jobject through it without making the public provider ABI + * depend on jni.h; non-Android callers can leave it null. The current + * implementation is a compile-tested guard and returns NOT_SUPPORTED until + * the staged PythonSDLActivity classes, librenpython, asset extraction, SDL + * surface, and lifecycle hooks are linked into the generated Android app. + */ +struct BootstrapRequest { + const char* game_root_path_utf8 = nullptr; + const char* startup_script_utf8 = nullptr; + void* existing_host_activity = nullptr; +}; + +class BootstrapAdapter final { + public: + BootstrapAdapter() = default; + + engine_result_t Start(const BootstrapRequest& request); + engine_result_t Stop(); + + bool running() const { return running_; } + const std::string& last_error() const { return last_error_; } + + private: + bool running_ = false; + std::string last_error_; +}; + +} // namespace aetherkiri::renpy::mobile From 6e2825232404ecdb0c4dc4e0b171ce0dad94473d Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 07:37:51 +0800 Subject: [PATCH 070/331] build: add mobile Ren'Py adapter boundaries --- .../include/renpy_runtime_ios_adapter.h | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 bridge/renpy_runtime/include/renpy_runtime_ios_adapter.h diff --git a/bridge/renpy_runtime/include/renpy_runtime_ios_adapter.h b/bridge/renpy_runtime/include/renpy_runtime_ios_adapter.h new file mode 100644 index 000000000..93749b631 --- /dev/null +++ b/bridge/renpy_runtime/include/renpy_runtime_ios_adapter.h @@ -0,0 +1,61 @@ +#ifndef AETHERKIRI_RENPY_RUNTIME_IOS_ADAPTER_H_ +#define AETHERKIRI_RENPY_RUNTIME_IOS_ADAPTER_H_ + +#include "engine_runtime_provider.h" + +#if defined(__cplusplus) +extern "C" { +#endif + +/* + * Boundary between the Renios launcher and the AetherKiri runtime provider. + * + * The official Renios prototype owns the process entrypoint in its main.c + * template. AetherKiri is already running inside Godot, so this adapter must + * never create a second UIKit/SDL application entrypoint. An + * eventual implementation is installed by the iOS host and is driven by the + * host-owned lifecycle below. The current build intentionally has no + * implementation; the provider remains ENGINE_RESULT_NOT_SUPPORTED until + * the complete Renios static-library closure and rendering/input bridge are + * linked. + */ +#define AETHERKIRI_RENPY_IOS_ADAPTER_API_VERSION 0x01000000u + +typedef struct renpy_ios_inprocess_adapter_v1_t { + uint32_t struct_size; + uint32_t api_version; + void* user_data; + + /* Allocate adapter-owned state. This must not create a UIApplication or + * enter an SDL application loop. */ + engine_result_t (*create)(void* user_data, + const engine_runtime_host_v1_t* host, + const engine_create_desc_t* desc, + void** out_runtime); + void (*destroy)(void* runtime); + + /* Start Renios on the already-running host loop. */ + engine_result_t (*open_game)(void* runtime, + const char* game_root_path_utf8, + const char* startup_script_utf8); + engine_result_t (*tick)(void* runtime, uint32_t delta_ms); + engine_result_t (*pause)(void* runtime); + engine_result_t (*resume)(void* runtime); + engine_result_t (*close_game)(void* runtime); + const char* (*get_last_error)(void* runtime); +} renpy_ios_inprocess_adapter_v1_t; + +/* Installs or removes the host-owned adapter. Passing NULL removes it. */ +ENGINE_API_EXPORT engine_result_t renpy_install_ios_inprocess_adapter( + const renpy_ios_inprocess_adapter_v1_t* adapter); + +/* Returns the currently installed adapter, or NULL when the Renios closure is + * not linked into the app. The returned descriptor is process-lived. */ +ENGINE_API_EXPORT const renpy_ios_inprocess_adapter_v1_t* +renpy_get_ios_inprocess_adapter(void); + +#if defined(__cplusplus) +} /* extern "C" */ +#endif + +#endif /* AETHERKIRI_RENPY_RUNTIME_IOS_ADAPTER_H_ */ From cab818747b4efd268e43e510ab83fece761cd030 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 07:37:58 +0800 Subject: [PATCH 071/331] build: add mobile Ren'Py adapter boundaries --- .../src/renpy_mobile_adapter.cpp | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 bridge/renpy_runtime/src/renpy_mobile_adapter.cpp diff --git a/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp new file mode 100644 index 000000000..623f40c2b --- /dev/null +++ b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp @@ -0,0 +1,64 @@ +#include "renpy_mobile_adapter.h" + +#if defined(__ANDROID__) +#include + +// These accessors are exported by engine_api's existing Android JNI bridge. +// They let the adapter validate the already-running VM/context without +// constructing or launching a second Activity. +extern JavaVM* krkr_GetJavaVM(); +extern jobject krkr_GetApplicationContext(); +#endif + +namespace aetherkiri::renpy::mobile { + +engine_result_t BootstrapAdapter::Start(const BootstrapRequest& request) { + if (request.game_root_path_utf8 == nullptr || + request.game_root_path_utf8[0] == '\0') { + last_error_ = "Ren'Py mobile bootstrap requires a non-empty game root"; + return ENGINE_RESULT_INVALID_ARGUMENT; + } + if (running_) { + last_error_ = "Ren'Py mobile bootstrap is already running"; + return ENGINE_RESULT_INVALID_STATE; + } + + // There is deliberately no Activity construction here. The host must + // provide its existing Activity once the Godot Android plugin owns that + // handoff; without it, starting RAPT would create a second UI owner. + if (request.existing_host_activity == nullptr) { + last_error_ = + "Ren'Py mobile bootstrap requires the existing host Activity; " + "a second Activity is never created"; + return ENGINE_RESULT_NOT_SUPPORTED; + } + +#if defined(__ANDROID__) + if (krkr_GetJavaVM() == nullptr || krkr_GetApplicationContext() == nullptr) { + last_error_ = + "Ren'Py mobile bootstrap requires the existing Android JavaVM and " + "Application Context from EngineBridge"; + return ENGINE_RESULT_NOT_SUPPORTED; + } +#endif + + // Keep this guard until the official RAPT classes/native library and the + // in-process SDL/asset/lifecycle bridge are linked into the Android app. + // Returning NOT_SUPPORTED here is intentional and prevents staged inputs + // from being reported as playable support. + last_error_ = + "Ren'Py mobile bootstrap adapter boundary is present, but official " + "RAPT PythonSDLActivity/librenpython and SDL lifecycle dependencies " + "are not linked yet"; + return ENGINE_RESULT_NOT_SUPPORTED; +} + +engine_result_t BootstrapAdapter::Stop() { + // Start() cannot currently transition to running, but make destruction + // idempotent so the provider can call Stop() unconditionally. + running_ = false; + last_error_.clear(); + return ENGINE_RESULT_OK; +} + +} // namespace aetherkiri::renpy::mobile From fe07cb1bd442dde2f86089954837cb6f99d670d3 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 07:38:06 +0800 Subject: [PATCH 072/331] build: add mobile Ren'Py adapter boundaries --- .../src/renpy_runtime_ios_adapter.cpp | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 bridge/renpy_runtime/src/renpy_runtime_ios_adapter.cpp diff --git a/bridge/renpy_runtime/src/renpy_runtime_ios_adapter.cpp b/bridge/renpy_runtime/src/renpy_runtime_ios_adapter.cpp new file mode 100644 index 000000000..658328030 --- /dev/null +++ b/bridge/renpy_runtime/src/renpy_runtime_ios_adapter.cpp @@ -0,0 +1,49 @@ +#include "renpy_runtime_ios_adapter.h" + +#include +#include + +namespace { + +std::mutex g_adapter_mutex; +const renpy_ios_inprocess_adapter_v1_t* g_adapter = nullptr; + +bool HasRequiredPrefix(const renpy_ios_inprocess_adapter_v1_t* adapter) { + if (!adapter || adapter->struct_size < + offsetof(renpy_ios_inprocess_adapter_v1_t, close_game) + + sizeof(adapter->close_game)) { + return false; + } + const uint32_t expected_major = + (AETHERKIRI_RENPY_IOS_ADAPTER_API_VERSION >> 24u) & 0xffu; + const uint32_t adapter_major = (adapter->api_version >> 24u) & 0xffu; + if (adapter_major != expected_major || !adapter->create || + !adapter->destroy || !adapter->open_game || + !adapter->tick || !adapter->pause || !adapter->resume || + !adapter->close_game || !adapter->get_last_error) { + return false; + } + return true; +} + +} // namespace + +extern "C" { + +engine_result_t renpy_install_ios_inprocess_adapter( + const renpy_ios_inprocess_adapter_v1_t* adapter) { + if (adapter != nullptr && !HasRequiredPrefix(adapter)) { + return ENGINE_RESULT_INVALID_ARGUMENT; + } + std::lock_guard lock(g_adapter_mutex); + g_adapter = adapter; + return ENGINE_RESULT_OK; +} + +const renpy_ios_inprocess_adapter_v1_t* +renpy_get_ios_inprocess_adapter(void) { + std::lock_guard lock(g_adapter_mutex); + return g_adapter; +} + +} // extern "C" From 471b9fc06d5b7c3bee47e3555f67d7f6a03cd25b Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 07:38:12 +0800 Subject: [PATCH 073/331] build: add mobile Ren'Py adapter boundaries --- bridge/renpy_runtime/tests/mobile_adapter.cpp | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 bridge/renpy_runtime/tests/mobile_adapter.cpp diff --git a/bridge/renpy_runtime/tests/mobile_adapter.cpp b/bridge/renpy_runtime/tests/mobile_adapter.cpp new file mode 100644 index 000000000..261164bd1 --- /dev/null +++ b/bridge/renpy_runtime/tests/mobile_adapter.cpp @@ -0,0 +1,41 @@ +#include "renpy_mobile_adapter.h" + +#include +#include + +int main() { + using aetherkiri::renpy::mobile::BootstrapAdapter; + using aetherkiri::renpy::mobile::BootstrapRequest; + + BootstrapAdapter adapter; + + BootstrapRequest invalid{}; + assert(adapter.Start(invalid) == ENGINE_RESULT_INVALID_ARGUMENT); + assert(!adapter.running()); + + BootstrapRequest request{}; + request.game_root_path_utf8 = "/game"; + int host_token = 1; + request.existing_host_activity = &host_token; + assert(adapter.Start(request) == ENGINE_RESULT_NOT_SUPPORTED); + assert(!adapter.running()); + // Android may stop at the JavaVM/context boundary before reaching the + // dependency guard; both are deliberate NOT_SUPPORTED contracts. + const char* error = adapter.last_error().c_str(); + assert(std::strstr(error, "RAPT") != nullptr || + std::strstr(error, "JavaVM") != nullptr); +#if !defined(__ANDROID__) + assert(std::strstr(error, "librenpython") != nullptr); +#endif + + // The provider may call Stop during destruction even after a failed Start. + assert(adapter.Stop() == ENGINE_RESULT_OK); + assert(!adapter.running()); + assert(adapter.last_error().empty()); + + request.existing_host_activity = nullptr; + assert(adapter.Start(request) == ENGINE_RESULT_NOT_SUPPORTED); + assert(std::strstr(adapter.last_error().c_str(), "second Activity") != + nullptr); + return 0; +} From 013a166545abd10faaebb125ca77675476281149 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 07:38:17 +0800 Subject: [PATCH 074/331] build: add mobile Ren'Py adapter boundaries --- bridge/renpy_runtime/tests/ios_adapter.cpp | 56 ++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 bridge/renpy_runtime/tests/ios_adapter.cpp diff --git a/bridge/renpy_runtime/tests/ios_adapter.cpp b/bridge/renpy_runtime/tests/ios_adapter.cpp new file mode 100644 index 000000000..8fc35e6d4 --- /dev/null +++ b/bridge/renpy_runtime/tests/ios_adapter.cpp @@ -0,0 +1,56 @@ +#include "renpy_runtime_ios_adapter.h" + +#include + +namespace { + +engine_result_t Create(void*, const engine_runtime_host_v1_t*, + const engine_create_desc_t*, void**) { + return ENGINE_RESULT_NOT_SUPPORTED; +} +void Destroy(void*) {} +engine_result_t Open(void*, const char*, const char*) { + return ENGINE_RESULT_NOT_SUPPORTED; +} +engine_result_t Tick(void*, uint32_t) { return ENGINE_RESULT_NOT_SUPPORTED; } +engine_result_t Pause(void*) { return ENGINE_RESULT_NOT_SUPPORTED; } +engine_result_t Resume(void*) { return ENGINE_RESULT_NOT_SUPPORTED; } +engine_result_t Close(void*) { return ENGINE_RESULT_NOT_SUPPORTED; } +const char* Error(void*) { return "test adapter"; } + +} // namespace + +int main() { + // The default product build has no linked Renios closure. + assert(renpy_get_ios_inprocess_adapter() == nullptr); + + renpy_ios_inprocess_adapter_v1_t invalid{}; + invalid.struct_size = sizeof(invalid); + invalid.api_version = 0; + assert(renpy_install_ios_inprocess_adapter(&invalid) == + ENGINE_RESULT_INVALID_ARGUMENT); + assert(renpy_get_ios_inprocess_adapter() == nullptr); + + renpy_ios_inprocess_adapter_v1_t adapter{}; + adapter.struct_size = sizeof(adapter); + adapter.api_version = AETHERKIRI_RENPY_IOS_ADAPTER_API_VERSION; + adapter.create = Create; + adapter.destroy = Destroy; + adapter.open_game = Open; + adapter.tick = Tick; + adapter.pause = Pause; + adapter.resume = Resume; + adapter.close_game = Close; + adapter.get_last_error = Error; + assert(renpy_install_ios_inprocess_adapter(&adapter) == ENGINE_RESULT_OK); + assert(renpy_get_ios_inprocess_adapter() == &adapter); + + // Registration is only a boundary test. Until the provider's complete + // lifecycle/rendering/input bridge is linked, game open stays unsupported. + assert(adapter.open_game(nullptr, nullptr, nullptr) == + ENGINE_RESULT_NOT_SUPPORTED); + + assert(renpy_install_ios_inprocess_adapter(nullptr) == ENGINE_RESULT_OK); + assert(renpy_get_ios_inprocess_adapter() == nullptr); + return 0; +} From fb369e4a3a5b5ffd2420be912e35419f7b95b391 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 07:38:39 +0800 Subject: [PATCH 075/331] ci: verify Android Ren'Py adapter boundary --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4416025cc..84e5ffb0b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -348,6 +348,7 @@ jobs: run: | set -euo pipefail grep -F 'renpy_runtime_mobile_provider.cpp' out/android/arm64-v8a/debug/compile_commands.json + grep -F 'renpy_mobile_adapter.cpp' out/android/arm64-v8a/debug/compile_commands.json - name: Verify exported APKs run: | From 500f82f8b24b792484855ee9b2529b5a956d4965 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:34:41 +0800 Subject: [PATCH 076/331] build: stage mobile RenPy native inputs --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 9117fdbd8..a94e4987d 100644 --- a/README.md +++ b/README.md @@ -322,3 +322,22 @@ templates, libraries, and project support files under a separate mobile root. Staging these inputs does not enable mobile gameplay: the Android JNI/Activity bootstrap, iOS in-process adapter, Godot rendering/input bridge, and device/simulator E2E remain required before a mobile provider can be enabled. + +When `AETHERKIRI_ENABLE_RENPY=ON`, `scripts/build_android.sh` copies the +official RAPT arm64 `librenpython.so`, Java/resource templates, and any +`AETHERKIRI_RENPY_ANDROID_PRIVATE_ASSETS` into the existing Godot Android +template. The RAPT launcher is packaged as assets rather than merged into the +manifest, and `RenPyMobileBridge` only binds the Activity Godot already owns. +This is a package/JNI smoke milestone; Ren'Py mobile open remains explicitly +`ENGINE_RESULT_NOT_SUPPORTED` until lifecycle, SDL surface, input, and render +handoff are implemented. + +For an iOS link/bundle smoke, set `AETHERKIRI_ENABLE_RENPY=ON` and +`AETHERKIRI_RENPY_MOBILE_ROOT` to the staged root before running +`scripts/build_ios.sh`. The build folds the official Renios static dependency +closure into the Godot extension and bundles its resources and MetalANGLE +framework, while deliberately omitting the prototype application entrypoint +and retaining `ENGINE_RESULT_NOT_SUPPORTED` until lifecycle, rendering, and +input integration are complete. +Provide `AETHERKIRI_RENPY_RENIOS_BASE` when a generated game `base/` directory +should be included in that smoke bundle. From 2094cdb410ad0cc926adbae38fcf795e30885e1c Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:34:46 +0800 Subject: [PATCH 077/331] build: stage mobile RenPy native inputs --- bridge/renpy_runtime/README.md | 35 +++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/bridge/renpy_runtime/README.md b/bridge/renpy_runtime/README.md index b888f9470..d3a37735e 100644 --- a/bridge/renpy_runtime/README.md +++ b/bridge/renpy_runtime/README.md @@ -74,6 +74,20 @@ inputs are not a mobile runtime: Android still needs the adapter, and both platforms still need lifecycle, input, and Godot rendering integration. +When `AETHERKIRI_ENABLE_RENPY=ON` and +`AETHERKIRI_RENPY_MOBILE_ROOT` points at that staged root, `scripts/build_ios.sh` +folds the selected Renios `prebuilt/{debug,release}` static-library closure +into the Godot iOS extension archive and bundles the official Renios resources +plus `MetalANGLE.xcframework` under `Aether/renios` and `Aether/Frameworks`. +If a generated Renios game `base/` directory is available, set +`AETHERKIRI_RENPY_RENIOS_BASE` to bundle it alongside those resources. +The prototype's entrypoint source and `libSDL2main.a` are intentionally omitted; +Godot remains the sole UIKit/SDL application host. The generated +`renios/renios-manifest.txt` records the exact closure. This is a link/bundle +smoke only: the provider continues to return `ENGINE_RESULT_NOT_SUPPORTED` +until the host-owned lifecycle, surface rendering, and input adapter are +implemented and validated on a device or simulator. + ### iOS in-process adapter boundary The staged Renios prototype is an application template whose `main.c` calls @@ -89,9 +103,20 @@ continues to return `ENGINE_RESULT_NOT_SUPPORTED` with an explicit diagnostic. The staged RAPT prototype is a `PythonSDLActivity` that loads `librenpython.so`, prepares Android storage/assets, and owns an SDL surface. -The Android provider now has a compile-tested `BootstrapAdapter` boundary that +`scripts/build_android.sh` now stages the arm64 library, private/assets +directory, and the official Java/resource templates under +`assets/renpy_mobile/rapt` in the existing Godot export. The RAPT manifest and +`PythonSDLActivity` remain assets and are never merged into the host manifest, +so the export still has one Activity. A small host-owned +`org.github.krkr2.aetherkiri.RenPyMobileBridge` Java shim binds an already +running Godot Activity to the engine JNI bridge; it does not launch RAPT or +load its SDL loop. Set `AETHERKIRI_RENPY_ANDROID_PRIVATE_ASSETS` when a built +Ren'Py game's private payload is available. + +The Android provider also has a compile-tested `BootstrapAdapter` boundary that accepts an opaque pointer to the existing host Activity through the host -extension slot `reserved_ptr[1]`. It never creates a second Activity. Until -the staged Java classes, native library, asset extraction, SDL surface, and -Godot lifecycle/input bridge are linked, `Start` and provider open return -`ENGINE_RESULT_NOT_SUPPORTED` with an explicit diagnostic. +extension slot `reserved_ptr[1]` or the JNI shim. It never creates a second +Activity. Because RAPT embeds its own SDL/Python runtime and lifecycle, +asset/JNI staging alone is not a playable integration: until SDL surface, +lifecycle, input, and Godot rendering handoff are complete, `Start` and +provider open return `ENGINE_RESULT_NOT_SUPPORTED` with an explicit diagnostic. From 62542acfb599f6bb556326c425d71734c72b8660 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:34:54 +0800 Subject: [PATCH 078/331] build: stage mobile RenPy native inputs --- abi/src/android_jni_bridge.cpp | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/abi/src/android_jni_bridge.cpp b/abi/src/android_jni_bridge.cpp index e8306f048..3964f552b 100644 --- a/abi/src/android_jni_bridge.cpp +++ b/abi/src/android_jni_bridge.cpp @@ -128,11 +128,23 @@ static std::mutex g_surface_mutex; static jobject g_app_context = nullptr; // global ref static std::mutex g_context_mutex; +// The host Activity is optional during the staging milestone. When the +// generated Godot Activity calls RenPyMobileBridge.bindHostActivity, keep a +// global JNI reference so a future in-process adapter can attach without +// constructing a second Activity. +static jobject g_host_activity = nullptr; // global ref +static std::mutex g_host_activity_mutex; + __attribute__((visibility("default"))) jobject krkr_GetApplicationContext() { std::lock_guard lock(g_context_mutex); return g_app_context; } +__attribute__((visibility("default"))) jobject krkr_GetHostActivity() { + std::lock_guard lock(g_host_activity_mutex); + return g_host_activity; +} + __attribute__((visibility("default"))) ANativeWindow* krkr_GetNativeWindow() { std::lock_guard lock(g_surface_mutex); if (g_native_window) { @@ -240,3 +252,25 @@ Java_org_github_krkr2_aetherkiri_EngineBridge_nativeSetApplicationContext( LOGW("nativeSetApplicationContext: null context passed"); } } + +// --------------------------------------------------------------------------- +// JNI bridge: host-owned Ren'Py handoff +// --------------------------------------------------------------------------- + +extern "C" JNIEXPORT void JNICALL +Java_org_github_krkr2_aetherkiri_RenPyMobileBridge_nativeSetHostActivity( + JNIEnv* env, jclass /* clazz */, jobject activity) { + std::lock_guard lock(g_host_activity_mutex); + + if (g_host_activity) { + env->DeleteGlobalRef(g_host_activity); + g_host_activity = nullptr; + } + + if (activity) { + g_host_activity = env->NewGlobalRef(activity); + LOGI("RenPyMobileBridge: host Activity bound"); + } else { + LOGI("RenPyMobileBridge: host Activity cleared"); + } +} From ed0889b7ba52c51e57263cf0849e8bb8a0057c9e Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:35:00 +0800 Subject: [PATCH 079/331] build: stage mobile RenPy native inputs --- bridge/renpy_runtime/src/renpy_mobile_adapter.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp index 623f40c2b..6f374e7a6 100644 --- a/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp +++ b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp @@ -8,6 +8,7 @@ // constructing or launching a second Activity. extern JavaVM* krkr_GetJavaVM(); extern jobject krkr_GetApplicationContext(); +extern jobject krkr_GetHostActivity(); #endif namespace aetherkiri::renpy::mobile { @@ -26,7 +27,16 @@ engine_result_t BootstrapAdapter::Start(const BootstrapRequest& request) { // There is deliberately no Activity construction here. The host must // provide its existing Activity once the Godot Android plugin owns that // handoff; without it, starting RAPT would create a second UI owner. - if (request.existing_host_activity == nullptr) { + void* host_activity = request.existing_host_activity; +#if defined(__ANDROID__) + if (host_activity == nullptr) { + // The generated Godot Activity can bind itself through the staged + // RenPyMobileBridge. This is a global JNI reference owned by engine_api; + // no second Activity is ever constructed here. + host_activity = krkr_GetHostActivity(); + } +#endif + if (host_activity == nullptr) { last_error_ = "Ren'Py mobile bootstrap requires the existing host Activity; " "a second Activity is never created"; From 6a5dbd1cafe707e4003650dd9c32cc1556ee33f8 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:35:07 +0800 Subject: [PATCH 080/331] build: stage mobile RenPy native inputs --- cmake/engine_api_elf.map | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmake/engine_api_elf.map b/cmake/engine_api_elf.map index 06976bfd3..301746f40 100644 --- a/cmake/engine_api_elf.map +++ b/cmake/engine_api_elf.map @@ -3,11 +3,13 @@ engine_*; JNI_OnLoad; Java_org_github_krkr2_aetherkiri_EngineBridge_*; + Java_org_github_krkr2_aetherkiri_RenPyMobileBridge_*; _Z14krkr_GetJNIEnvv; _Z14krkr_GetJavaVMv; _Z20krkr_GetNativeWindowv; _Z25krkr_GetSurfaceDimensions*; _Z26krkr_GetApplicationContextv; + _Z21krkr_GetHostActivityv; _Z16TVPTransformText*; _Z15TVPPrefetchText*; _Z27TVPSetTextTransformCallback*; From f20aecacd58c95e697cfb51aff3c2a7d572c9273 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:35:16 +0800 Subject: [PATCH 081/331] build: stage mobile RenPy native inputs --- scripts/build_android.sh | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/scripts/build_android.sh b/scripts/build_android.sh index f1504e4f0..6abbc2cd0 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -229,6 +229,42 @@ if [[ ! -f "$GODOT_APP_DIR/android/.build_version" || ! -f "$GODOT_APP_DIR/andro : > "$GODOT_APP_DIR/android/build/.gdignore" fi +stage_renpy_android_support() { + local renpy_enabled="${AETHERKIRI_ENABLE_RENPY:-OFF}" + local renpy_enabled_lower + renpy_enabled_lower="$(printf '%s' "$renpy_enabled" | tr '[:upper:]' '[:lower:]')" + case "$renpy_enabled_lower" in + off|false|0|no|"") + return 0 + ;; + on|true|1|yes) + ;; + *) + echo "Error: AETHERKIRI_ENABLE_RENPY must be ON/OFF or true/false" >&2 + exit 1 + ;; + esac + + local mobile_root="${AETHERKIRI_RENPY_MOBILE_ROOT:-}" + if [[ -z "$mobile_root" ]]; then + echo "Error: AETHERKIRI_ENABLE_RENPY=ON requires AETHERKIRI_RENPY_MOBILE_ROOT" >&2 + echo " Stage official RAPT inputs with tools/install_renpy_mobile_support.sh first." >&2 + exit 1 + fi + + local stage_args=( + --mobile-root "$mobile_root" + --godot-build "$GODOT_APP_DIR/android/build" + ) + if [[ -n "${AETHERKIRI_RENPY_ANDROID_PRIVATE_ASSETS:-}" ]]; then + stage_args+=(--private-assets "$AETHERKIRI_RENPY_ANDROID_PRIVATE_ASSETS") + fi + echo "==> Staging official Ren'Py RAPT Android support into the Godot export" + "$PROJECT_ROOT/tools/stage_renpy_android_support.sh" "${stage_args[@]}" +} + +stage_renpy_android_support + command -v cmake >/dev/null NINJA_BIN="${CMAKE_MAKE_PROGRAM:-$(command -v ninja || command -v ninja-build || true)}" if [[ -z "$NINJA_BIN" ]]; then From 6b22e1941bd6e38983bb8afa16de45e029017db2 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:35:24 +0800 Subject: [PATCH 082/331] build: stage mobile RenPy native inputs --- scripts/build_ios.sh | 247 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 246 insertions(+), 1 deletion(-) diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 0c7548050..6524a464e 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -53,6 +53,17 @@ GODOT_APP_DIR="$PROJECT_ROOT/apps/godot_app" GODOT_BIN_DIR="$GODOT_APP_DIR/bin/$GODOT_TRIPLET_DIR" RUNTIME_CJK_FONT_SOURCE="$GODOT_APP_DIR/assets/fonts/aetherkiri-runtime-cjk.otf" RUNTIME_SYMBOL_FONT_SOURCE="$GODOT_APP_DIR/assets/fonts/aetherkiri-runtime-symbols.ttf" +# The Renios archive is intentionally opt-in. When present, the build folds +# the official static-library closure into the Godot extension archive and +# copies only its resources/framework into the exported app. It must never +# copy or compile Renios' prototype main.c: Godot already owns the process and +# the UIKit/SDL application entrypoint. +RENPY_MOBILE_ROOT="${AETHERKIRI_RENPY_MOBILE_ROOT:-}" +# Renios archives contain the native prototype/resources but intentionally do +# not contain a game-specific `base/` directory. A caller may provide the +# generated Renios base explicitly for a bundle smoke; gameplay remains gated +# by the adapter contract regardless. +RENPY_RENIOS_BASE="${AETHERKIRI_RENPY_RENIOS_BASE:-}" PARALLEL_JOBS="${JOBS:-8}" FORCE_LOAD_PLUGIN_ARCHIVES=( "libSDL2.a" @@ -294,6 +305,138 @@ EOF libtool -static -o "$output" "$object" } +renios_enabled() { + case "${AETHERKIRI_ENABLE_RENPY:-OFF}" in + ON|TRUE|YES|1|on|true|yes) [[ -n "$RENPY_MOBILE_ROOT" ]] ;; + *) return 1 ;; + esac +} + +renios_prototype_root() { + [[ -n "$RENPY_MOBILE_ROOT" ]] || return 1 + local root="$RENPY_MOBILE_ROOT/renios/prototype" + [[ -d "$root" ]] || return 1 + [[ -d "$root/prebuilt" ]] || return 1 + printf '%s\n' "$root" +} + +renios_prebuilt_root() { + local prototype + prototype="$(renios_prototype_root 2>/dev/null || true)" + [[ -n "$prototype" ]] || return 1 + local configuration="$BUILD_TYPE_LOWER" + local prebuilt="$prototype/prebuilt/$configuration" + if [[ ! -d "$prebuilt" ]]; then + echo "Error: Renios prebuilt directory is missing: $prebuilt" >&2 + return 1 + fi + printf '%s\n' "$prebuilt" +} + +renios_archive_is_excluded() { + case "$(basename "$1")" in + # SDL2main owns the UIKit/SDL application entrypoint in the official + # prototype. Godot's application must supply the only entrypoint. + libSDL2main.a|libSDL2_test.a) return 0 ;; + *) return 1 ;; + esac +} + +collect_renios_archives() { + local prebuilt="$1" + local archive + [[ -d "$prebuilt" ]] || return 1 + # Keep the runtime roots first for deterministic archive inspection, then + # append every transitive archive shipped by Renios. The latter is the + # complete native dependency closure for the selected SDK/configuration. + local root_name + for root_name in librenpython.a librenpy.a libpython3.12.a libSDL2.a libSDL2_image.a; do + archive="$prebuilt/$root_name" + [[ -f "$archive" ]] || { + echo "Error: Renios runtime archive is missing: $archive" >&2 + return 1 + } + renios_archive_is_excluded "$archive" || printf '%s\n' "$archive" + done + while IFS= read -r archive; do + [[ -f "$archive" ]] || continue + renios_archive_is_excluded "$archive" && continue + case "$(basename "$archive")" in + librenpython.a|librenpy.a|libpython3.12.a|libSDL2.a|libSDL2_image.a) ;; + *) printf '%s\n' "$archive" ;; + esac + done < <(find "$prebuilt" -maxdepth 1 -type f -name 'lib*.a' | sort) +} + +renios_archive_names() { + local prebuilt="$1" + collect_renios_archives "$prebuilt" | xargs -n1 basename | paste -sd, - +} + +stage_renios_ios_resources() { + local export_root="$1" + if ! renios_enabled; then + return 0 + fi + local prototype + prototype="$(renios_prototype_root 2>/dev/null || true)" + if [[ -z "$prototype" ]]; then + echo "Warning: AETHERKIRI_ENABLE_RENPY=ON but Renios support root is not staged; keeping iOS Ren'Py NOT_SUPPORTED" >&2 + return 0 + fi + + local app_root="$export_root/Aether" + local resource_root="$app_root/renios" + local resource_source="$prototype" + mkdir -p "$resource_root" + # These are the official Renios app resources. Do not copy main.c, + # prototype.xcodeproj, or any source file: this export remains a Godot + # application and has one host-owned lifecycle. + local item + for item in "Launch Screen.storyboard" LaunchImage-background.png LaunchImage-foreground.png Info.plist Media.xcassets; do + if [[ -e "$resource_source/$item" ]]; then + rm -rf "$resource_root/$item" + cp -R "$resource_source/$item" "$resource_root/" + fi + done + if [[ -d "$RENPY_RENIOS_BASE" ]]; then + rm -rf "$resource_root/base" + cp -R "$RENPY_RENIOS_BASE" "$resource_root/base" + elif [[ -d "$resource_source/base" ]]; then + rm -rf "$resource_root/base" + cp -R "$resource_source/base" "$resource_root/base" + fi + + # MetalANGLE is the only dynamic dependency in Renios' prototype. Keep + # it in the app's Frameworks directory; the PBX patch below links and + # embeds it. Static Renios libraries are folded into the extension + # archive, so they are not copied into the app bundle. + if [[ -d "$resource_source/Frameworks/MetalANGLE.xcframework" ]]; then + mkdir -p "$app_root/Frameworks" + rm -rf "$app_root/Frameworks/MetalANGLE.xcframework" + cp -R "$resource_source/Frameworks/MetalANGLE.xcframework" "$app_root/Frameworks/" + fi + + local prebuilt + prebuilt="$(renios_prebuilt_root 2>/dev/null || true)" + if [[ -z "$prebuilt" ]]; then + echo "Warning: Renios prebuilt closure is unavailable; resources staged but iOS Ren'Py remains NOT_SUPPORTED" >&2 + return 0 + fi + { + printf 'Renios archive: %s\n' "$RENPY_MOBILE_ROOT" + printf 'Configuration: %s\n' "$BUILD_TYPE_LOWER" + printf 'Static closure: %s\n' "$(renios_archive_names "$prebuilt")" + if [[ -d "$resource_root/base" ]]; then + printf 'Base resources: bundled\n' + else + printf 'Base resources: none (Renios archive is game-agnostic)\n' + fi + printf 'Host entrypoint: Godot (Renios main.c intentionally omitted)\n' + printf 'Runtime status: NOT_SUPPORTED until lifecycle/render/input adapter is linked\n' + } > "$resource_root/renios-manifest.txt" +} + combine_ios_static_extension() { local output="$1" local triplet="$2" @@ -340,6 +483,13 @@ combine_ios_static_extension() { if [[ -f "$siglus_runtime_lib" ]]; then libs+=("$siglus_runtime_lib") fi + local renpy_runtime_lib="$CMAKE_BUILD_DIR/bridge/renpy_runtime/libaether_renpy_runtime.a" + if [[ -f "$renpy_runtime_lib" ]]; then + # CMake's private archive dependency is not propagated by Godot's + # static iOS exporter; merge the provider registration object here so + # the guarded adapter boundary is present in the final app. + libs+=("$renpy_runtime_lib") + fi while IFS= read -r siglus_vm_lib; do if [[ -n "$siglus_vm_lib" && -f "$siglus_vm_lib" ]]; then libs+=("$siglus_vm_lib") @@ -369,7 +519,33 @@ combine_ios_static_extension() { ) fi + # Renios ships a self-contained iOS Python/SDL/FFmpeg closure. Godot's + # exporter links only the extension archive and does not propagate its + # CMake dependencies, so merge the official closure into that archive. + # SDL2main and SDL2_test are deliberately excluded: they contain the + # prototype's application entrypoint and test harness, + # which must never be introduced into the Godot application. + local renios_prebuilt="" + if renios_enabled; then + renios_prebuilt="$(renios_prebuilt_root 2>/dev/null || true)" + if [[ -n "$renios_prebuilt" ]]; then + local renios_archive + while IFS= read -r renios_archive; do + [[ -f "$renios_archive" ]] || continue + libs+=("$renios_archive") + done < <(collect_renios_archives "$renios_prebuilt") + else + echo "Warning: Renios support requested but no native closure was found; iOS Ren'Py remains NOT_SUPPORTED" >&2 + fi + fi + while IFS= read -r lib; do + # If Renios is enabled, prefer its iOS SDL/image/runtime archives over + # identically named vcpkg archives. Other vcpkg libraries remain in + # the closure for Godot and the existing runtimes. + if [[ -n "$renios_prebuilt" ]] && [[ -f "$renios_prebuilt/$(basename "$lib")" ]]; then + continue + fi libs+=("$lib") done < <(find "$vcpkg_lib_dir" -maxdepth 1 -name 'lib*.a' \ ! -name 'libgodot-cpp*.a' \ @@ -413,6 +589,12 @@ stage_force_load_plugin_archives() { local resolved mkdir -p "$destination" for source in "${FORCE_LOAD_PLUGIN_SOURCES[@]}"; do + # Renios supplies the iOS SDL archive in its merged native closure. + # Do not copy/force-load the vcpkg SDL archive as well, otherwise the + # final Xcode link sees duplicate SDL symbols. + if renios_enabled && [[ "$(basename "$source")" == "libSDL2.a" ]]; then + continue + fi resolved="$source" if [[ ! -f "$resolved" ]]; then resolved="$CMAKE_BUILD_DIR/$source" @@ -479,6 +661,59 @@ patch_ios_runtime_font_resources() { perl -0pi -e 's@(\t\tD0BCFE3218AEBDA2004A7AAE /\* Resources \*/ = \{\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = \(\n)@$1\t\t\t\tA3F001000000000000000001 /* default.otf in Resources */,\n\t\t\t\tA3F001000000000000000003 /* fonts in Resources */,\n@' "$project_file" } +patch_ios_renios_resources() { + local project_file="$1" + local export_root="$2" + local resource_dir="$export_root/Aether/renios" + local framework="$export_root/Aether/Frameworks/MetalANGLE.xcframework" + [[ -f "$project_file" ]] || return 0 + [[ -d "$resource_dir" ]] || return 0 + + # Add the Renios resources as a folder reference. This keeps the + # original names/relative paths intact while avoiding the prototype's + # application-entrypoint source files. + if ! grep -Fq 'A3F002000000000000000003 /* renios in Resources */' "$project_file"; then + perl -0pi -e 's@(/\* Begin PBXBuildFile section \*/\n)@$1\t\tA3F002000000000000000003 /* renios in Resources */ = {isa = PBXBuildFile; fileRef = A3F002000000000000000004 /* renios */; };\n@' "$project_file" + perl -0pi -e 's@(/\* Begin PBXFileReference section \*/\n)@$1\t\tA3F002000000000000000004 /* renios */ = {isa = PBXFileReference; lastKnownFileType = folder; path = renios; sourceTree = ""; };\n@' "$project_file" + perl -0pi -e 's@(\t\tD0BCFE4118AEBDA2004A7AAE /\* Aether \*/ = \{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = \(\n)@$1\t\t\t\tA3F002000000000000000004 /* renios */,\n@' "$project_file" + perl -0pi -e 's@(\t\tD0BCFE3218AEBDA2004A7AAE /\* Resources \*/ = \{\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = \(\n)@$1\t\t\t\tA3F002000000000000000003 /* renios in Resources */,\n@' "$project_file" + if ! grep -Fq 'A3F002000000000000000004 /* renios */,' "$project_file"; then + # Godot's object IDs are stable in current exports, but retain a + # semantic fallback for a future exporter that renumbers them. + perl -0pi -e 's@(\n\t\t[0-9A-F]+ /\* [^*]+ \*/ = \{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = \(\n)@$1\t\t\t\tA3F002000000000000000004 /* renios */,\n@'s "$project_file" + fi + if ! grep -Fq 'A3F002000000000000000003 /* renios in Resources */,' "$project_file"; then + perl -0pi -e 's@(\n\t\t[0-9A-F]+ /\* [^*]+ \*/ = \{\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = \(\n)@$1\t\t\t\tA3F002000000000000000003 /* renios in Resources */,\n@'s "$project_file" + fi + fi + + # MetalANGLE is a dynamic framework in the official Renios package. It + # must be linked and embedded, rather than copied as an opaque resource. + # The static Renios closure is already folded into libaether_kiri_godot.a. + if [[ -d "$framework" ]] && ! grep -Fq 'A3F003000000000000000003 /* MetalANGLE.xcframework in Frameworks */' "$project_file"; then + perl -0pi -e 's@(/\* Begin PBXBuildFile section \*/\n)@$1\t\tA3F003000000000000000003 /* MetalANGLE.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A3F003000000000000000004 /* MetalANGLE.xcframework */; };\n\t\tA3F003000000000000000005 /* MetalANGLE.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A3F003000000000000000004 /* MetalANGLE.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };\n@' "$project_file" + perl -0pi -e 's@(/\* Begin PBXFileReference section \*/\n)@$1\t\tA3F003000000000000000004 /* MetalANGLE.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = Frameworks/MetalANGLE.xcframework; sourceTree = ""; };\n@' "$project_file" + # Godot's export project has an Aether group, a framework phase, and + # an Embed Frameworks phase. If a future exporter changes those IDs, + # leave the resource bundle in place and retain NOT_SUPPORTED rather + # than guessing at an unsafe Xcode project mutation. + perl -0pi -e 's@(\t\tD0BCFE4118AEBDA2004A7AAE /\* Aether \*/ = \{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = \(\n)@$1\t\t\t\tA3F003000000000000000004 /* MetalANGLE.xcframework */,\n@' "$project_file" + # Add the framework to the first framework phase and existing embed + # phase by matching their semantic comments, not generated IDs. + perl -0pi -e 's@(\/\* Begin PBXFrameworksBuildPhase section \*\/\n.*?files = \(\n)@$1\t\t\t\tA3F003000000000000000003 /* MetalANGLE.xcframework in Frameworks */,\n@'s "$project_file" + perl -0pi -e 's@(\/\* Begin PBXCopyFilesBuildPhase section \*\/\n.*?files = \(\n)@$1\t\t\t\tA3F003000000000000000005 /* MetalANGLE.xcframework in Embed Frameworks */,\n@'s "$project_file" + if ! grep -Fq 'A3F003000000000000000004 /* MetalANGLE.xcframework */,' "$project_file"; then + perl -0pi -e 's@(\n\t\t[0-9A-F]+ /\* [^*]+ \*/ = \{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = \(\n)@$1\t\t\t\tA3F003000000000000000004 /* MetalANGLE.xcframework */,\n@'s "$project_file" + fi + if ! grep -Fq 'A3F003000000000000000003 /* MetalANGLE.xcframework in Frameworks */,' "$project_file"; then + perl -0pi -e 's@(\n\t\t[0-9A-F]+ /\* [^*]+ \*/ = \{\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = \(\n)@$1\t\t\t\tA3F003000000000000000003 /* MetalANGLE.xcframework in Frameworks */,\n@'s "$project_file" + fi + if ! grep -Fq 'A3F003000000000000000005 /* MetalANGLE.xcframework in Embed Frameworks */,' "$project_file"; then + perl -0pi -e 's@(\n\t\t[0-9A-F]+ /\* [^*]+ \*/ = \{\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tdstPath = "";\n\t\t\tdstSubfolderSpec = 10;\n\t\t\tfiles = \(\n)@$1\t\t\t\tA3F003000000000000000005 /* MetalANGLE.xcframework in Embed Frameworks */,\n@'s "$project_file" + fi + fi +} + patch_ios_export_project() { local project_file="$1/project.pbxproj" local export_root @@ -492,9 +727,17 @@ patch_ios_export_project() { flags+=" -Wl,-force_load,Aether/bin/ios/$export_build_type/$IOS_SDK_COMPAT_ARCHIVE" local archive for archive in "${FORCE_LOAD_PLUGIN_ARCHIVES[@]}"; do + if renios_enabled && [[ "$archive" == "libSDL2.a" ]]; then + continue + fi flags+=" -Wl,-force_load,Aether/bin/ios/$export_build_type/$archive" done - flags+=' -liconv -framework Accelerate -framework AudioToolbox -framework AVFoundation -framework CoreAudio -framework CoreBluetooth -framework CoreHaptics -framework CoreMedia -framework CoreMotion -framework CoreVideo -framework GameController -framework VideoToolbox -framework CoreGraphics -framework QuartzCore -framework Metal -framework MetalKit -framework OpenGLES -framework Security -framework StoreKit -framework SystemConfiguration -framework MobileCoreServices' + flags+=' -liconv -framework Accelerate -framework AudioToolbox -framework AVFoundation -framework CoreAudio -framework CoreBluetooth -framework CoreHaptics -framework CoreMedia -framework CoreMotion -framework CoreVideo -framework CoreServices -framework GameController -framework ImageIO -framework VideoToolbox -framework CoreGraphics -framework QuartzCore -framework Metal -framework MetalKit -framework OpenGLES -framework Security -framework StoreKit -framework SystemConfiguration -framework MobileCoreServices' + if renios_enabled; then + # The resource staging step places this official dynamic framework in + # Aether/Frameworks and the PBX patch embeds it in the app bundle. + flags+=' -F Aether/Frameworks -framework MetalANGLE' + fi if [[ -f "$project_file" ]]; then FLAGS="$flags" perl -0pi -e 's/OTHER_LDFLAGS = "[^"]*";/"OTHER_LDFLAGS = \"" . $ENV{FLAGS} . "\";"/eg' "$project_file" @@ -511,6 +754,7 @@ patch_ios_export_project() { fi stage_ios_runtime_fonts "$export_root" patch_ios_runtime_font_resources "$project_file" + patch_ios_renios_resources "$project_file" "$export_root" fi if [[ -f "$dummy_cpp" ]] && ! grep -Fq '__swift_FORCE_LOAD_$_swift_Builtin_float' "$dummy_cpp"; then cat >> "$dummy_cpp" <<'EOF' @@ -706,6 +950,7 @@ else if [[ "$SIMULATOR" == true ]]; then verify_exported_simulator_template_arch "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER" "$SIMULATOR_ARCH" fi + stage_renios_ios_resources "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER" stage_force_load_plugin_archives "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER/Aether/bin/ios/$BUILD_TYPE_LOWER" cp -f "$GODOT_BIN_DIR/$IOS_SDK_COMPAT_ARCHIVE" "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER/Aether/bin/ios/$BUILD_TYPE_LOWER/" 2>/dev/null || true PATCH_ARCH="arm64" From 090aba6150729714f36d309a3204492fca648e86 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:36:06 +0800 Subject: [PATCH 083/331] build: stage mobile RenPy native inputs --- tools/stage_renpy_android_support.sh | 210 +++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 tools/stage_renpy_android_support.sh diff --git a/tools/stage_renpy_android_support.sh b/tools/stage_renpy_android_support.sh new file mode 100644 index 000000000..ed692b777 --- /dev/null +++ b/tools/stage_renpy_android_support.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# Stage the official Ren'Py RAPT Android inputs into a Godot Android export. +# +# RAPT is an SDLActivity application template. It must not be merged as the +# exported app's manifest or launched as a second Activity: Godot owns the +# process, window, and SDL lifecycle. This script therefore packages the +# official Java/resources as inspectable assets, stages only the arm64 native +# payload in jniLibs, and installs a tiny host-owned JNI bridge. The provider +# remains NOT_SUPPORTED until the lifecycle, surface, input, and renderer +# handoff is implemented. +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: tools/stage_renpy_android_support.sh --mobile-root PATH --godot-build PATH + +Stage the pinned official RAPT Android support package into an existing Godot +Android build template. The RAPT PythonSDLActivity is stored under APK assets +and is never added to the manifest, so this command cannot create a second +Activity. An optional game-specific private/assets directory can be supplied +with --private-assets. + +Options: + --mobile-root PATH Root containing rapt/prototype/renpyandroid + --godot-build PATH Extracted Godot Android build template directory + --private-assets PATH Optional directory or private.mp3 file to copy into + the RAPT private asset staging area (default: none) + -h, --help Show this help +USAGE +} + +mobile_root="" +godot_build="" +private_assets="" + +while (($#)); do + case "$1" in + --mobile-root) + (($# >= 2)) || { echo "--mobile-root requires a value" >&2; exit 2; } + mobile_root="$2" + shift 2 + ;; + --godot-build) + (($# >= 2)) || { echo "--godot-build requires a value" >&2; exit 2; } + godot_build="$2" + shift 2 + ;; + --private-assets) + (($# >= 2)) || { echo "--private-assets requires a value" >&2; exit 2; } + private_assets="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$mobile_root" || -z "$godot_build" ]]; then + echo "--mobile-root and --godot-build are required" >&2 + usage >&2 + exit 2 +fi + +rapt_root="$mobile_root/rapt" +rapt_main="$rapt_root/prototype/renpyandroid/src/main" +rapt_native="$rapt_main/jniLibs/arm64-v8a/librenpython.so" + +[[ -d "$rapt_main" ]] || { + echo "RAPT Android module is missing: $rapt_main" >&2 + exit 1 +} +[[ -f "$rapt_native" ]] || { + echo "RAPT arm64 native library is missing: $rapt_native" >&2 + exit 1 +} +[[ -d "$godot_build" ]] || { + echo "Godot Android build template is missing: $godot_build" >&2 + exit 1 +} +if [[ -n "$private_assets" && ! -e "$private_assets" ]]; then + echo "private assets path is missing: $private_assets" >&2 + exit 1 +fi + +is_elf_file() { + local path="$1" + [[ -f "$path" ]] || return 1 + [[ "$(dd if="$path" bs=4 count=1 2>/dev/null | LC_ALL=C od -An -tx1 | tr -d ' \\n')" == "7f454c46" ]] +} + +is_elf_file "$rapt_native" || { + echo "RAPT arm64 native library is not an ELF file: $rapt_native" >&2 + exit 1 +} + +main_src="$godot_build/src/main" +asset_root="$main_src/assets/renpy_mobile" +asset_rapt="$asset_root/rapt" +asset_private="$asset_root/private" +private_archive="$asset_rapt/private.mp3" +jni_root="$main_src/jniLibs/arm64-v8a" +java_root="$main_src/java/org/github/krkr2/aetherkiri" + +mkdir -p "$asset_rapt" "$asset_private" "$jni_root" "$java_root" + +# This source is part of AetherKiri, not an official RAPT Activity. It binds +# the existing Godot Activity to engine_api without creating a second owner. +cp -f "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bridge/renpy_runtime/android/RenPyMobileBridge.java" \ + "$java_root/RenPyMobileBridge.java" + +# The source tree is deliberately copied below assets rather than src/main/java +# or src/main/res. RAPT's manifest names PythonSDLActivity as its launcher; +# merging it into Godot would create a second Activity and two SDL owners. +rm -rf "$asset_rapt/java" "$asset_rapt/res" "$asset_rapt/templates" +mkdir -p "$asset_rapt/java" "$asset_rapt/res" +cp -a "$rapt_main/java/." "$asset_rapt/java/" +if [[ -d "$rapt_main/res" ]]; then + cp -a "$rapt_main/res/." "$asset_rapt/res/" +fi +# Keep Kotlin templates too if a future official RAPT package adds them. The +# current 8.5.3 package is Java-only, but the asset boundary is language +# neutral and does not risk compiling a second Activity. +if find "$rapt_main" -type f -name '*.kt' -print -quit | grep -q .; then + mkdir -p "$asset_rapt/kotlin" + while IFS= read -r -d '' kotlin_file; do + relative="${kotlin_file#"$rapt_main/"}" + mkdir -p "$asset_rapt/kotlin/$(dirname "$relative")" + cp -f "$kotlin_file" "$asset_rapt/kotlin/$relative" + done < <(find "$rapt_main" -type f -name '*.kt' -print0) +fi +if [[ -f "$rapt_main/AndroidManifest.xml" ]]; then + cp -f "$rapt_main/AndroidManifest.xml" "$asset_rapt/AndroidManifest.xml" +fi +if [[ -f "$rapt_root/prototype/renpyandroid/build.gradle" ]]; then + cp -f "$rapt_root/prototype/renpyandroid/build.gradle" "$asset_rapt/build.gradle" +fi +if [[ -d "$rapt_root/templates" ]]; then + cp -a "$rapt_root/templates" "$asset_rapt/templates" +fi + +# Keep any official RAPT app assets and any optional game-specific private +# payload visible to the eventual extractor. RAPT does not ship a game +# private.mp3 in its support archive, so emit a deterministic marker instead +# of pretending that the provider can launch a game without one. +if [[ -d "$rapt_root/prototype/app/src/main/assets" ]]; then + rm -rf "$asset_rapt/app-assets" + mkdir -p "$asset_rapt/app-assets" + cp -a "$rapt_root/prototype/app/src/main/assets/." "$asset_rapt/app-assets/" +fi +find "$asset_private" -mindepth 1 -maxdepth 1 -exec rm -rf {} + +rm -f "$private_archive" +if [[ -d "$rapt_main/private" ]]; then + cp -a "$rapt_main/private/." "$asset_private/" +fi +if [[ -d "$private_assets" ]]; then + cp -a "$private_assets/." "$asset_private/" +elif [[ -n "$private_assets" ]]; then + cp -f "$private_assets" "$asset_private/$(basename "$private_assets")" +fi +if [[ -f "$asset_private/private.mp3" ]]; then + # PythonSDLActivity's ResourceManager looks up private.mp3 at the asset + # root. Keep the auditable private/ copy too, but expose this canonical + # RAPT filename for the future host-owned extractor. + cp -f "$asset_private/private.mp3" "$private_archive" +fi +if [[ -z "$(find "$asset_private" -mindepth 1 -print -quit)" ]]; then + cat > "$asset_private/README.txt" <<'PRIVATE_EOF' +This directory is reserved for the Ren'Py game's RAPT private payload. +The official RAPT support archive does not contain game-specific private.mp3 +data. Supply --private-assets when staging a built Ren'Py game. AetherKiri +keeps runtime=renpy NOT_SUPPORTED until extraction, lifecycle, input, and +rendering are wired to the existing Godot Activity. +PRIVATE_EOF +fi + +cp -f "$rapt_native" "$jni_root/librenpython.so" +chmod 755 "$jni_root/librenpython.so" + +# Record the exact staged paths and the archive metadata when available. This +# is consumed by package smoke tests and makes an APK inspection auditable. +rapt_checksum="" +if [[ -f "$rapt_root/.aetherkiri-sha256" ]]; then + rapt_checksum="$(awk 'NR == 1 { print $1; exit }' "$rapt_root/.aetherkiri-sha256")" +elif [[ -f "$rapt_root/hash.txt" ]]; then + rapt_checksum="$(tr -d '[:space:]' < "$rapt_root/hash.txt")" +fi +{ + printf 'renpy_version=8.5.3\n' + printf 'rapt_checksum=%s\n' "$rapt_checksum" + printf 'playable=false\n' + printf 'activity_template=assets/renpy_mobile/rapt/java/org/renpy/android/PythonSDLActivity.java\n' + printf 'gradle_template=assets/renpy_mobile/rapt/build.gradle\n' + printf 'native_library=lib/arm64-v8a/librenpython.so\n' + printf 'private_assets=assets/renpy_mobile/private\n' + printf 'private_archive=%s\n' "$( [[ -f "$private_archive" ]] && printf 'assets/renpy_mobile/rapt/private.mp3' || true )" + printf 'manifest_merged=false\n' +} > "$asset_root/manifest.properties" + +echo "Ren'Py Android support staged into $godot_build" +echo " official Java/resources: $asset_rapt" +echo " private assets: $asset_private" +echo " arm64 native library: $jni_root/librenpython.so" +echo " playable: false" From ab1fb65a4a50195a5ba97ba0227fcab716adb039 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:36:11 +0800 Subject: [PATCH 084/331] build: stage mobile RenPy native inputs --- tools/test_stage_renpy_android_support.sh | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tools/test_stage_renpy_android_support.sh diff --git a/tools/test_stage_renpy_android_support.sh b/tools/test_stage_renpy_android_support.sh new file mode 100644 index 000000000..26eaa6712 --- /dev/null +++ b/tools/test_stage_renpy_android_support.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +stage="$repo_root/tools/stage_renpy_android_support.sh" +[[ -x "$stage" ]] || { echo "stager is not executable: $stage" >&2; exit 1; } +bash -n "$stage" + +mobile_root="${RENPY_MOBILE_STAGE_TEST_ROOT:-/workspace/shared/renpy-mobile-staged}" +if [[ ! -f "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" ]]; then + echo "Ren'Py mobile stage not present; metadata-only staging test skipped" + exit 0 +fi + +tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-android-stage.XXXXXX")" +trap 'rm -rf "$tmp_root"' EXIT +mkdir -p "$tmp_root/android-build/src/main" "$tmp_root/private" +printf 'private fixture\n' > "$tmp_root/private/private.mp3" + +"$stage" \ + --mobile-root "$mobile_root" \ + --godot-build "$tmp_root/android-build" \ + --private-assets "$tmp_root/private" >/dev/null + +main="$tmp_root/android-build/src/main" +[[ -s "$main/jniLibs/arm64-v8a/librenpython.so" ]] +[[ "$(dd if="$main/jniLibs/arm64-v8a/librenpython.so" bs=4 count=1 2>/dev/null | od -An -tx1 | tr -d ' \\n')" == "7f454c46" ]] +[[ -s "$main/assets/renpy_mobile/rapt/java/org/renpy/android/PythonSDLActivity.java" ]] +[[ -s "$main/assets/renpy_mobile/rapt/templates/app-AndroidManifest.xml" ]] +[[ -s "$main/assets/renpy_mobile/rapt/build.gradle" ]] +[[ -s "$main/assets/renpy_mobile/private/private.mp3" ]] +[[ -s "$main/assets/renpy_mobile/rapt/private.mp3" ]] +[[ -s "$main/java/org/github/krkr2/aetherkiri/RenPyMobileBridge.java" ]] +grep -Fx 'playable=false' "$main/assets/renpy_mobile/manifest.properties" +grep -Fx 'manifest_merged=false' "$main/assets/renpy_mobile/manifest.properties" +# The RAPT manifest is an asset only; no second Activity is added to the host +# source tree. +[[ ! -f "$main/AndroidManifest.xml" ]] +! find "$main/java" -path '*/org/renpy/android/PythonSDLActivity.java' -print -quit | grep -q . + +echo "Ren'Py Android staging/package smoke ok" From 573faf911ce04fc910102998062a8282e1924c94 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:36:19 +0800 Subject: [PATCH 085/331] build: stage mobile RenPy native inputs --- .../android/RenPyMobileBridge.java | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 bridge/renpy_runtime/android/RenPyMobileBridge.java diff --git a/bridge/renpy_runtime/android/RenPyMobileBridge.java b/bridge/renpy_runtime/android/RenPyMobileBridge.java new file mode 100644 index 000000000..d40047dc4 --- /dev/null +++ b/bridge/renpy_runtime/android/RenPyMobileBridge.java @@ -0,0 +1,81 @@ +package org.github.krkr2.aetherkiri; + +import android.app.Activity; +import android.content.Context; + +import java.io.File; + +/** + * Host-owned handoff point for the staged Ren'Py Android support package. + * + * The Godot Activity remains the only Activity. A future lifecycle adapter may + * call bindHostActivity(this) from that Activity and then drive Ren'Py through + * the existing surface. This class intentionally does not reference or launch + * org.renpy.android.PythonSDLActivity. + */ +public final class RenPyMobileBridge { + private static boolean nativeBridgeLoaded; + + static { + nativeBridgeLoaded = tryLoadNativeBridge(); + } + + private RenPyMobileBridge() { + } + + /** Bind the already-running Godot Activity; never constructs an Activity. */ + public static void bindHostActivity(Activity activity) { + if (activity == null) { + throw new IllegalArgumentException("host Activity must not be null"); + } + ensureNativeBridge(); + nativeSetHostActivity(activity); + } + + /** Clear the host Activity during destruction/configuration teardown. */ + public static void clearHostActivity() { + if (nativeBridgeLoaded || tryLoadNativeBridge()) { + nativeSetHostActivity(null); + } + } + + /** Returns true when the native bridge has loaded in the host process. */ + public static boolean isNativeBridgeLoaded() { + return nativeBridgeLoaded; + } + + /** + * Package smoke helper. It only checks that the staged arm64 payload is in + * the APK's native library directory; loading it is deliberately deferred + * until SDL/lifecycle/surface isolation is implemented. + */ + public static boolean hasBundledRenPy(Context context) { + if (context == null || context.getApplicationInfo() == null) { + return false; + } + String nativeDir = context.getApplicationInfo().nativeLibraryDir; + return nativeDir != null && new File(nativeDir, "librenpython.so").isFile(); + } + + private static void ensureNativeBridge() { + if (!nativeBridgeLoaded && !tryLoadNativeBridge()) { + throw new IllegalStateException("AetherKiri engine_api JNI bridge is unavailable"); + } + } + + private static synchronized boolean tryLoadNativeBridge() { + if (nativeBridgeLoaded) { + return true; + } + try { + System.loadLibrary("engine_api"); + nativeBridgeLoaded = true; + } catch (LinkageError ignored) { + // Godot may load this class before the extension. Retry from + // ensureNativeBridge once the host has finished loading libraries. + } + return nativeBridgeLoaded; + } + + private static native void nativeSetHostActivity(Activity activity); +} From 9d9bb49e17fc4dbee860962df7bd995fc3cbf343 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:36:25 +0800 Subject: [PATCH 086/331] build: stage mobile RenPy native inputs --- tools/test_renios_ios_bundle.sh | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tools/test_renios_ios_bundle.sh diff --git a/tools/test_renios_ios_bundle.sh b/tools/test_renios_ios_bundle.sh new file mode 100644 index 000000000..6baa3173a --- /dev/null +++ b/tools/test_renios_ios_bundle.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Validate the iOS Renios link/bundle boundary without requiring Xcode. A +# downloaded/staged archive is optional; when provided this also checks the +# complete native closure and the dynamic framework input. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +build_script="$repo_root/scripts/build_ios.sh" +[[ -x "$build_script" ]] || { echo "iOS build script is not executable" >&2; exit 1; } +bash -n "$build_script" + +grep -Fq 'AETHERKIRI_RENPY_MOBILE_ROOT' "$build_script" +grep -Fq 'collect_renios_archives' "$build_script" +grep -Fq 'libaether_renpy_runtime.a' "$build_script" +grep -Fq 'stage_renios_ios_resources' "$build_script" +grep -Fq 'libSDL2main.a|libSDL2_test.a' "$build_script" +grep -Fq 'MetalANGLE.xcframework' "$build_script" +# The Godot iOS build must not grow a second UIKit/SDL application entrypoint. +if grep -Eq '^[[:space:]]*(SDL_UIKitRunApp|UIApplicationMain)[[:space:]]*\(' "$build_script"; then + echo "build_ios.sh introduced a second iOS application entrypoint" >&2 + exit 1 +fi + +mobile_root="${RENPY_MOBILE_TEST_ROOT:-}" +if [[ -z "$mobile_root" ]]; then + echo "Renios iOS bundle boundary validation ok (archive inspection not requested)" + exit 0 +fi + +prototype="$mobile_root/renios/prototype" +prebuilt="$prototype/prebuilt/${RENPY_MOBILE_TEST_CONFIG:-release}" +[[ -d "$prebuilt" ]] || { echo "missing Renios prebuilt directory: $prebuilt" >&2; exit 1; } +[[ -f "$prototype/Frameworks/MetalANGLE.xcframework/Info.plist" ]] || { + echo "missing official MetalANGLE framework metadata" >&2 + exit 1 +} +for archive in librenpython.a librenpy.a libpython3.12.a libSDL2.a libSDL2_image.a; do + [[ -f "$prebuilt/$archive" ]] || { echo "missing Renios runtime archive: $archive" >&2; exit 1; } +done +for archive in "$prebuilt"/lib*.a; do + [[ -f "$archive" ]] || continue + case "$(basename "$archive")" in + libSDL2main.a|libSDL2_test.a) continue ;; + esac +done +echo "Renios iOS native closure/framework validation ok" From bcfd987dbed037c0f6672e8914452f1f3268c327 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:36:45 +0800 Subject: [PATCH 087/331] build: finalize mobile RenPy packaging hooks From 195079507084635b75bc5b06e70590cc9f81f5ed Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:36:52 +0800 Subject: [PATCH 088/331] build: finalize mobile RenPy packaging hooks --- scripts/build_ios.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 6524a464e..758af3432 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -90,7 +90,7 @@ FORCE_LOAD_PLUGIN_SOURCES=( PRIVATE_RUNTIME_ARCHIVE_LISTER="$PROJECT_ROOT/packages/AetherInternal/tools/list_ios_runtime_archives.sh" if [[ -x "$PRIVATE_RUNTIME_ARCHIVE_LISTER" ]]; then private_runtime_target="device" - if [[ "$SIMULATOR" == true ]]; then + if [[ "${SIMULATOR:-false}" == true ]]; then private_runtime_target="simulator-$SIMULATOR_ARCH" fi while IFS=$'\t' read -r archive source; do @@ -320,11 +320,23 @@ renios_prototype_root() { printf '%s\n' "$root" } +renios_configuration() { + if [[ "${SIMULATOR:-false}" == true ]]; then + printf 'debug\n' + else + printf '%s\n' "$BUILD_TYPE_LOWER" + fi +} + renios_prebuilt_root() { local prototype prototype="$(renios_prototype_root 2>/dev/null || true)" [[ -n "$prototype" ]] || return 1 - local configuration="$BUILD_TYPE_LOWER" + # Renios release archives are device arm64 only. The debug archive is a + # universal simulator/device slice, so use it for every simulator export + # even when the surrounding Godot export is a release configuration. + local configuration + configuration="$(renios_configuration)" local prebuilt="$prototype/prebuilt/$configuration" if [[ ! -d "$prebuilt" ]]; then echo "Error: Renios prebuilt directory is missing: $prebuilt" >&2 @@ -425,7 +437,7 @@ stage_renios_ios_resources() { fi { printf 'Renios archive: %s\n' "$RENPY_MOBILE_ROOT" - printf 'Configuration: %s\n' "$BUILD_TYPE_LOWER" + printf 'Configuration: %s\n' "$(renios_configuration)" printf 'Static closure: %s\n' "$(renios_archive_names "$prebuilt")" if [[ -d "$resource_root/base" ]]; then printf 'Base resources: bundled\n' From 1cbfe48467739b913f405aaadb1cc009926884ad Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:37:25 +0800 Subject: [PATCH 089/331] build: finalize mobile RenPy packaging hooks --- cmake/engine_api_elf.map | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmake/engine_api_elf.map b/cmake/engine_api_elf.map index 301746f40..4c196499a 100644 --- a/cmake/engine_api_elf.map +++ b/cmake/engine_api_elf.map @@ -9,7 +9,7 @@ _Z20krkr_GetNativeWindowv; _Z25krkr_GetSurfaceDimensions*; _Z26krkr_GetApplicationContextv; - _Z21krkr_GetHostActivityv; + _Z20krkr_GetHostActivityv; _Z16TVPTransformText*; _Z15TVPPrefetchText*; _Z27TVPSetTextTransformCallback*; From 31601578fde4d10d75976c345eb5e8b3e9292b37 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:38:07 +0800 Subject: [PATCH 090/331] build: finalize mobile RenPy packaging provenance --- scripts/build_ios.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 758af3432..a95bd8384 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -58,7 +58,7 @@ RUNTIME_SYMBOL_FONT_SOURCE="$GODOT_APP_DIR/assets/fonts/aetherkiri-runtime-symbo # copies only its resources/framework into the exported app. It must never # copy or compile Renios' prototype main.c: Godot already owns the process and # the UIKit/SDL application entrypoint. -RENPY_MOBILE_ROOT="${AETHERKIRI_RENPY_MOBILE_ROOT:-}" +RENPY_MOBILE_ROOT="${AETHERKIRI_RENPY_MOBILE_ROOT:-${RENPY_MOBILE_ROOT:-}}" # Renios archives contain the native prototype/resources but intentionally do # not contain a game-specific `base/` directory. A caller may provide the # generated Renios base explicitly for a bundle smoke; gameplay remains gated @@ -437,6 +437,9 @@ stage_renios_ios_resources() { fi { printf 'Renios archive: %s\n' "$RENPY_MOBILE_ROOT" + if [[ -f "$RENPY_MOBILE_ROOT/renios/.aetherkiri-sha256" ]]; then + printf 'Renios archive SHA-256: %s\n' "$(cat "$RENPY_MOBILE_ROOT/renios/.aetherkiri-sha256")" + fi printf 'Configuration: %s\n' "$(renios_configuration)" printf 'Static closure: %s\n' "$(renios_archive_names "$prebuilt")" if [[ -d "$resource_root/base" ]]; then From 8a690e365faf5c18ded8a26b57365702b28f124c Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:38:12 +0800 Subject: [PATCH 091/331] build: finalize mobile RenPy packaging provenance From bbe193e926b25c3e621eb67de0e2f1665a92a483 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:38:20 +0800 Subject: [PATCH 092/331] build: finalize mobile RenPy packaging provenance From a0d26576fdf7a10cc85910ad3b8e545fae1b4442 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:38:26 +0800 Subject: [PATCH 093/331] build: finalize mobile RenPy packaging provenance From 05211e92c481a4c85a6289e120d413c0d2f7660e Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:38:59 +0800 Subject: [PATCH 094/331] build: honor Renios CI root alias --- scripts/build_ios.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index a95bd8384..4380f43f3 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -442,6 +442,7 @@ stage_renios_ios_resources() { fi printf 'Configuration: %s\n' "$(renios_configuration)" printf 'Static closure: %s\n' "$(renios_archive_names "$prebuilt")" + printf 'Excluded archives: libSDL2main.a,libSDL2_test.a (host entrypoint/test)\n' if [[ -d "$resource_root/base" ]]; then printf 'Base resources: bundled\n' else From 3537b223252e2dd26f7dc172a132b3cb1473c929 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:39:19 +0800 Subject: [PATCH 095/331] test: document Renios excluded entrypoints From 47132a042331f16bc826632955f95198daeee8e5 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:40:00 +0800 Subject: [PATCH 096/331] ci: smoke test staged mobile RenPy inputs --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 84e5ffb0b..5ae4c1f6d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -157,6 +157,7 @@ jobs: --destination "$mobile_root" test -f "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" echo "RENPY_MOBILE_ROOT=$mobile_root" >> "$GITHUB_ENV" + RENPY_MOBILE_STAGE_TEST_ROOT="$mobile_root" bash tools/test_stage_renpy_android_support.sh - name: Restore ccache id: restore-ccache @@ -488,6 +489,7 @@ jobs: --destination "$mobile_root" test -f "$mobile_root/renios/prototype/prebuilt/release/librenpython.a" echo "RENPY_MOBILE_ROOT=$mobile_root" >> "$GITHUB_ENV" + RENPY_MOBILE_TEST_ROOT="$mobile_root" bash tools/test_renios_ios_bundle.sh - name: Install Godot export tools run: | From b0541586e41619ba441fd856eac10b7bd7dcadbd Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:40:49 +0800 Subject: [PATCH 097/331] ci: verify iOS RenPy adapter source --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5ae4c1f6d..acb13e70e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -639,6 +639,7 @@ jobs: run: | set -euo pipefail grep -F 'renpy_runtime_mobile_provider.cpp' out/ios/debug/compile_commands.json + grep -F 'renpy_runtime_ios_adapter.cpp' out/ios/debug/compile_commands.json - name: Verify exported Xcode project and IPA run: | From 283bc72b37279bd97f1cdeed5edc5d7fc37accce Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:53:03 +0800 Subject: [PATCH 098/331] ci: invoke Android RenPy staging through bash --- tools/test_stage_renpy_android_support.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/test_stage_renpy_android_support.sh b/tools/test_stage_renpy_android_support.sh index 26eaa6712..4cce14098 100644 --- a/tools/test_stage_renpy_android_support.sh +++ b/tools/test_stage_renpy_android_support.sh @@ -3,8 +3,8 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" stage="$repo_root/tools/stage_renpy_android_support.sh" -[[ -x "$stage" ]] || { echo "stager is not executable: $stage" >&2; exit 1; } -bash -n "$stage" +[[ -f bash "$stage" ]] || { echo "stager is missing: $stage" >&2; exit 1; } +bash -n bash "$stage" mobile_root="${RENPY_MOBILE_STAGE_TEST_ROOT:-/workspace/shared/renpy-mobile-staged}" if [[ ! -f "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" ]]; then @@ -17,7 +17,7 @@ trap 'rm -rf "$tmp_root"' EXIT mkdir -p "$tmp_root/android-build/src/main" "$tmp_root/private" printf 'private fixture\n' > "$tmp_root/private/private.mp3" -"$stage" \ +bash "$stage" \ --mobile-root "$mobile_root" \ --godot-build "$tmp_root/android-build" \ --private-assets "$tmp_root/private" >/dev/null From 641373eb99ec60abaa4fc52a2b19ceac6980268d Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 08:53:10 +0800 Subject: [PATCH 099/331] ci: invoke Android RenPy staging through bash --- scripts/build_android.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 6abbc2cd0..6b8d97715 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -260,7 +260,7 @@ stage_renpy_android_support() { stage_args+=(--private-assets "$AETHERKIRI_RENPY_ANDROID_PRIVATE_ASSETS") fi echo "==> Staging official Ren'Py RAPT Android support into the Godot export" - "$PROJECT_ROOT/tools/stage_renpy_android_support.sh" "${stage_args[@]}" + bash "$PROJECT_ROOT/tools/stage_renpy_android_support.sh" "${stage_args[@]}" } stage_renpy_android_support From e48a73e4f4598276a4e91c48996b7101ac0391ca Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 09:00:37 +0800 Subject: [PATCH 100/331] ci: fix Android staging smoke shell invocation --- tools/test_stage_renpy_android_support.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/test_stage_renpy_android_support.sh b/tools/test_stage_renpy_android_support.sh index 4cce14098..3504a15ac 100644 --- a/tools/test_stage_renpy_android_support.sh +++ b/tools/test_stage_renpy_android_support.sh @@ -3,8 +3,8 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" stage="$repo_root/tools/stage_renpy_android_support.sh" -[[ -f bash "$stage" ]] || { echo "stager is missing: $stage" >&2; exit 1; } -bash -n bash "$stage" +[[ -f "$stage" ]] || { echo "stager is missing: $stage" >&2; exit 1; } +bash -n "$stage" mobile_root="${RENPY_MOBILE_STAGE_TEST_ROOT:-/workspace/shared/renpy-mobile-staged}" if [[ ! -f "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" ]]; then From 9e76794198183c341b55e10d5dd645ba741ccb2b Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 09:07:44 +0800 Subject: [PATCH 101/331] build: honor CI RenPy mobile root alias on Android --- scripts/build_android.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 6b8d97715..95fe27b83 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -245,9 +245,9 @@ stage_renpy_android_support() { ;; esac - local mobile_root="${AETHERKIRI_RENPY_MOBILE_ROOT:-}" + local mobile_root="${AETHERKIRI_RENPY_MOBILE_ROOT:-${RENPY_MOBILE_ROOT:-}}" if [[ -z "$mobile_root" ]]; then - echo "Error: AETHERKIRI_ENABLE_RENPY=ON requires AETHERKIRI_RENPY_MOBILE_ROOT" >&2 + echo "Error: AETHERKIRI_ENABLE_RENPY=ON requires AETHERKIRI_RENPY_MOBILE_ROOT or RENPY_MOBILE_ROOT" >&2 echo " Stage official RAPT inputs with tools/install_renpy_mobile_support.sh first." >&2 exit 1 fi From 20470f6fb221d701b5c3b943a9ca5bf1a90eee29 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 09:32:52 +0800 Subject: [PATCH 102/331] ios: keep Renios native link opt-in until adapter is ready --- scripts/build_ios.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 4380f43f3..077548dbe 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -312,6 +312,15 @@ renios_enabled() { esac } +# Native Renios archive linking is opt-in until the complete in-process +# lifecycle/render/input adapter and dependency closure are validated on device. +renios_link_enabled() { + case "${AETHERKIRI_RENPY_RENIOS_LINK:-OFF}" in + ON|TRUE|YES|1|on|true|yes) return 0 ;; + *) return 1 ;; + esac +} + renios_prototype_root() { [[ -n "$RENPY_MOBILE_ROOT" ]] || return 1 local root="$RENPY_MOBILE_ROOT/renios/prototype" @@ -542,7 +551,7 @@ combine_ios_static_extension() { # prototype's application entrypoint and test harness, # which must never be introduced into the Godot application. local renios_prebuilt="" - if renios_enabled; then + if renios_link_enabled && renios_enabled; then renios_prebuilt="$(renios_prebuilt_root 2>/dev/null || true)" if [[ -n "$renios_prebuilt" ]]; then local renios_archive @@ -608,7 +617,7 @@ stage_force_load_plugin_archives() { # Renios supplies the iOS SDL archive in its merged native closure. # Do not copy/force-load the vcpkg SDL archive as well, otherwise the # final Xcode link sees duplicate SDL symbols. - if renios_enabled && [[ "$(basename "$source")" == "libSDL2.a" ]]; then + if renios_link_enabled && renios_enabled && [[ "$(basename "$source")" == "libSDL2.a" ]]; then continue fi resolved="$source" From 42896f8941689669808d71a9b614b8c089aaf552 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:10:02 +0800 Subject: [PATCH 103/331] build: expose Renios blocking launcher contract --- bridge/renpy_runtime/README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/bridge/renpy_runtime/README.md b/bridge/renpy_runtime/README.md index d3a37735e..411579602 100644 --- a/bridge/renpy_runtime/README.md +++ b/bridge/renpy_runtime/README.md @@ -95,9 +95,14 @@ The staged Renios prototype is an application template whose `main.c` calls application and run loop, so the mobile provider does not copy that entrypoint or call `UIApplicationMain`. `renpy_runtime_ios_adapter.h` defines the small host-owned adapter registration boundary that a future iOS integration can -install after linking the complete Renios static-library closure. Until that -adapter, lifecycle, input, and surface bridge are linked, `runtime=renpy` -continues to return `ENGINE_RESULT_NOT_SUPPORTED` with an explicit diagnostic. +install after linking the complete Renios static-library closure. The same +header exposes `renpy_get_ios_launcher_contract()`: the build probe confirms +that the shipped `launcher_main` reaches blocking `Py_RunMain`, so it cannot be +called from a Godot frame callback. Run +`tools/test_renios_ios_launcher.sh ` to repeat +that archive check. Until a split init/tick/shutdown launcher and its +lifecycle, surface, and input bridge are linked, `runtime=renpy` continues to +return `ENGINE_RESULT_NOT_SUPPORTED` with an explicit diagnostic. ### Android in-process adapter boundary From ed23b47af3f84b336a45bf9b2112730c515056f7 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:10:52 +0800 Subject: [PATCH 104/331] build: expose Renios blocking launcher contract --- .../include/renpy_runtime_ios_adapter.h | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/bridge/renpy_runtime/include/renpy_runtime_ios_adapter.h b/bridge/renpy_runtime/include/renpy_runtime_ios_adapter.h index 93749b631..059bdda56 100644 --- a/bridge/renpy_runtime/include/renpy_runtime_ios_adapter.h +++ b/bridge/renpy_runtime/include/renpy_runtime_ios_adapter.h @@ -21,6 +21,33 @@ extern "C" { */ #define AETHERKIRI_RENPY_IOS_ADAPTER_API_VERSION 0x01000000u +/* Build-time inspection of the official Renios closure. The shipped + * launcher_main entrypoint reaches Py_RunMain and owns a blocking Python + * main loop; it is not a host-tick API. Keep this fact explicit so a caller + * cannot mistake the linked archives for a playable adapter. */ +#define AETHERKIRI_RENPY_IOS_LAUNCHER_CONTRACT_API_VERSION 0x01000000u + +typedef enum renpy_ios_launcher_mode_v1_t { + RENPY_IOS_LAUNCHER_MODE_UNAVAILABLE = 0, + RENPY_IOS_LAUNCHER_MODE_BLOCKING_PY_MAIN = 1, + RENPY_IOS_LAUNCHER_MODE_HOST_TICK = 2 +} renpy_ios_launcher_mode_v1_t; + +typedef struct renpy_ios_launcher_contract_v1_t { + uint32_t struct_size; + uint32_t api_version; + uint32_t mode; + const char* launcher_symbol_utf8; + const char* blocking_symbol_utf8; + const char* limitation_utf8; +} renpy_ios_launcher_contract_v1_t; + +/* Returns the static contract discovered by the build probe. The current + * Renios archive reports BLOCKING_PY_MAIN and therefore cannot be driven by + * the Godot frame loop. */ +ENGINE_API_EXPORT const renpy_ios_launcher_contract_v1_t* +renpy_get_ios_launcher_contract(void); + typedef struct renpy_ios_inprocess_adapter_v1_t { uint32_t struct_size; uint32_t api_version; From 795260f153424a343d4943f059cf021e71e3ca42 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:11:03 +0800 Subject: [PATCH 105/331] build: expose Renios blocking launcher contract --- .../src/renpy_runtime_ios_adapter.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/bridge/renpy_runtime/src/renpy_runtime_ios_adapter.cpp b/bridge/renpy_runtime/src/renpy_runtime_ios_adapter.cpp index 658328030..6188c0297 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_ios_adapter.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_ios_adapter.cpp @@ -8,6 +8,17 @@ namespace { std::mutex g_adapter_mutex; const renpy_ios_inprocess_adapter_v1_t* g_adapter = nullptr; +const renpy_ios_launcher_contract_v1_t kLauncherContract = { + sizeof(renpy_ios_launcher_contract_v1_t), + AETHERKIRI_RENPY_IOS_LAUNCHER_CONTRACT_API_VERSION, + RENPY_IOS_LAUNCHER_MODE_BLOCKING_PY_MAIN, + "launcher_main", + "Py_RunMain", + "Renios launcher_main enters Py_RunMain and owns a blocking Python loop; " + "a host-tick/frame/input bridge requires a rebuilt split launcher" + " (init/tick/shutdown) before gameplay can be enabled", +}; + bool HasRequiredPrefix(const renpy_ios_inprocess_adapter_v1_t* adapter) { if (!adapter || adapter->struct_size < offsetof(renpy_ios_inprocess_adapter_v1_t, close_game) + @@ -30,6 +41,11 @@ bool HasRequiredPrefix(const renpy_ios_inprocess_adapter_v1_t* adapter) { extern "C" { +const renpy_ios_launcher_contract_v1_t* +renpy_get_ios_launcher_contract(void) { + return &kLauncherContract; +} + engine_result_t renpy_install_ios_inprocess_adapter( const renpy_ios_inprocess_adapter_v1_t* adapter) { if (adapter != nullptr && !HasRequiredPrefix(adapter)) { From e2cdecf69bbfb6e056663d02342334edcc60b09b Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:11:14 +0800 Subject: [PATCH 106/331] build: expose Renios blocking launcher contract --- .../src/renpy_runtime_mobile_provider.cpp | 58 +++++++++++++++++-- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp index d64a5e7e6..38c7a7715 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp @@ -1,5 +1,11 @@ #include "renpy_runtime.h" #include "engine_runtime_provider.h" +#if defined(__ANDROID__) +#include "renpy_mobile_adapter.h" +#endif +#if defined(AETHERKIRI_RENPY_IOS) +#include "renpy_runtime_ios_adapter.h" +#endif #include #include @@ -18,9 +24,12 @@ namespace { struct MobileRuntime final { engine_runtime_host_v1_t host{}; +#if defined(__ANDROID__) + mobile::BootstrapAdapter bootstrap; +#endif std::string error = - "Ren'Py mobile bootstrap is not available; stage official RAPT/Renios " - "inputs before enabling the native adapter"; + "Ren'Py mobile runtime is not supported: official RAPT/Renios inputs " + "may be staged, but the native adapter is not linked"; }; MobileRuntime* Cast(void* value) { @@ -52,15 +61,52 @@ engine_result_t Create(void*, const engine_runtime_host_v1_t* host, } void Destroy(void* value) { +#if defined(__ANDROID__) + if (value) Cast(value)->bootstrap.Stop(); +#endif delete Cast(value); } -engine_result_t Open(void* value, const char*, const char*) { +engine_result_t Open(void* value, const char* game_root_path, + const char* startup_script) { if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; +#if defined(__ANDROID__) + auto* runtime = Cast(value); + mobile::BootstrapRequest request{}; + request.game_root_path_utf8 = game_root_path; + request.startup_script_utf8 = startup_script; + // reserved_ptr[1] is intentionally host-owned. The current Godot + // Android host does not populate it, so the adapter returns + // NOT_SUPPORTED rather than constructing a second Activity. + request.existing_host_activity = runtime->host.reserved_ptr[1]; + const auto result = runtime->bootstrap.Start(request); + runtime->error = runtime->bootstrap.last_error(); + return result; +#elif defined(AETHERKIRI_RENPY_IOS) + (void)game_root_path; + (void)startup_script; + const auto* contract = renpy_get_ios_launcher_contract(); + if (renpy_get_ios_inprocess_adapter() == nullptr) { + Cast(value)->error = + "Ren'Py iOS mobile runtime is not supported: the official Renios " + "libraries are staged, but no host-owned in-process adapter is " + "linked (a second UIKit/SDL application entrypoint is forbidden)"; + } else { + Cast(value)->error = + "Ren'Py iOS mobile runtime is not supported: the host-owned Renios " + "adapter is present, but lifecycle, input, and surface rendering " + "integration is not enabled"; + } + if (contract && contract->limitation_utf8) { + Cast(value)->error.append(" "); + Cast(value)->error.append(contract->limitation_utf8); + } +#else Cast(value)->error = - "Ren'Py mobile runtime is not playable yet: native RAPT/Renios " - "bootstrap, lifecycle, input, and surface rendering adapters are " - "required"; + "Ren'Py Android runtime is not supported: the official RAPT " + "libraries are staged, but the JNI/Activity and rendering adapters " + "are not linked"; +#endif return ENGINE_RESULT_NOT_SUPPORTED; } From 4b21b16850dda5c6806772d21ac7e6c1b77ba80c Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:11:20 +0800 Subject: [PATCH 107/331] build: expose Renios blocking launcher contract --- bridge/renpy_runtime/tests/ios_adapter.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bridge/renpy_runtime/tests/ios_adapter.cpp b/bridge/renpy_runtime/tests/ios_adapter.cpp index 8fc35e6d4..b855111c2 100644 --- a/bridge/renpy_runtime/tests/ios_adapter.cpp +++ b/bridge/renpy_runtime/tests/ios_adapter.cpp @@ -21,6 +21,16 @@ const char* Error(void*) { return "test adapter"; } } // namespace int main() { + const auto* contract = renpy_get_ios_launcher_contract(); + assert(contract != nullptr); + assert(contract->struct_size == sizeof(*contract)); + assert(contract->api_version == + AETHERKIRI_RENPY_IOS_LAUNCHER_CONTRACT_API_VERSION); + assert(contract->mode == RENPY_IOS_LAUNCHER_MODE_BLOCKING_PY_MAIN); + assert(contract->launcher_symbol_utf8 != nullptr); + assert(contract->blocking_symbol_utf8 != nullptr); + assert(contract->limitation_utf8 != nullptr); + // The default product build has no linked Renios closure. assert(renpy_get_ios_inprocess_adapter() == nullptr); From cbb37d2200885aa4b6822d1e224374568511e8de Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:11:32 +0800 Subject: [PATCH 108/331] build: expose Renios blocking launcher contract --- scripts/build_ios.sh | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 077548dbe..71d09e20e 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -312,15 +312,6 @@ renios_enabled() { esac } -# Native Renios archive linking is opt-in until the complete in-process -# lifecycle/render/input adapter and dependency closure are validated on device. -renios_link_enabled() { - case "${AETHERKIRI_RENPY_RENIOS_LINK:-OFF}" in - ON|TRUE|YES|1|on|true|yes) return 0 ;; - *) return 1 ;; - esac -} - renios_prototype_root() { [[ -n "$RENPY_MOBILE_ROOT" ]] || return 1 local root="$RENPY_MOBILE_ROOT/renios/prototype" @@ -394,6 +385,12 @@ renios_archive_names() { collect_renios_archives "$prebuilt" | xargs -n1 basename | paste -sd, - } +renios_launcher_probe() { + renios_enabled || return 0 + "$PROJECT_ROOT/tools/test_renios_ios_launcher.sh" \ + "$RENPY_MOBILE_ROOT" "$(renios_configuration)" +} + stage_renios_ios_resources() { local export_root="$1" if ! renios_enabled; then @@ -465,6 +462,10 @@ stage_renios_ios_resources() { combine_ios_static_extension() { local output="$1" local triplet="$2" + # Verify that the staged closure still has the known blocking launcher + # contract before merging it into the host archive. This probe never + # invokes launcher_main and cannot turn the provider on by itself. + renios_launcher_probe local vcpkg_triplet_root="$CMAKE_BUILD_DIR/vcpkg_installed/$triplet" local vcpkg_lib_dir="$vcpkg_triplet_root/lib" local cubism_package_root="${AETHERKIRI_INTERNAL_DIR:-$PROJECT_ROOT/packages/AetherInternal}" @@ -551,7 +552,7 @@ combine_ios_static_extension() { # prototype's application entrypoint and test harness, # which must never be introduced into the Godot application. local renios_prebuilt="" - if renios_link_enabled && renios_enabled; then + if renios_enabled; then renios_prebuilt="$(renios_prebuilt_root 2>/dev/null || true)" if [[ -n "$renios_prebuilt" ]]; then local renios_archive @@ -617,7 +618,7 @@ stage_force_load_plugin_archives() { # Renios supplies the iOS SDL archive in its merged native closure. # Do not copy/force-load the vcpkg SDL archive as well, otherwise the # final Xcode link sees duplicate SDL symbols. - if renios_link_enabled && renios_enabled && [[ "$(basename "$source")" == "libSDL2.a" ]]; then + if renios_enabled && [[ "$(basename "$source")" == "libSDL2.a" ]]; then continue fi resolved="$source" From d99da0a8b31348cb95878803661be677325df1ee Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:11:38 +0800 Subject: [PATCH 109/331] build: expose Renios blocking launcher contract --- tools/test_renios_ios_bundle.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/test_renios_ios_bundle.sh b/tools/test_renios_ios_bundle.sh index 6baa3173a..e32be227e 100644 --- a/tools/test_renios_ios_bundle.sh +++ b/tools/test_renios_ios_bundle.sh @@ -6,8 +6,11 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" build_script="$repo_root/scripts/build_ios.sh" +launcher_probe="$repo_root/tools/test_renios_ios_launcher.sh" [[ -x "$build_script" ]] || { echo "iOS build script is not executable" >&2; exit 1; } +[[ -x "$launcher_probe" ]] || { echo "Renios launcher probe is not executable" >&2; exit 1; } bash -n "$build_script" +bash -n "$launcher_probe" grep -Fq 'AETHERKIRI_RENPY_MOBILE_ROOT' "$build_script" grep -Fq 'collect_renios_archives' "$build_script" @@ -23,10 +26,13 @@ fi mobile_root="${RENPY_MOBILE_TEST_ROOT:-}" if [[ -z "$mobile_root" ]]; then + "$launcher_probe" echo "Renios iOS bundle boundary validation ok (archive inspection not requested)" exit 0 fi +"$launcher_probe" "$mobile_root" "${RENPY_MOBILE_TEST_CONFIG:-release}" + prototype="$mobile_root/renios/prototype" prebuilt="$prototype/prebuilt/${RENPY_MOBILE_TEST_CONFIG:-release}" [[ -d "$prebuilt" ]] || { echo "missing Renios prebuilt directory: $prebuilt" >&2; exit 1; } From b8e42dd81b4e4280023715ad4c66efbf1532dc0b Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:11:55 +0800 Subject: [PATCH 110/331] build: add Renios launcher reentry probe --- tools/test_renios_ios_launcher.sh | 71 +++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tools/test_renios_ios_launcher.sh diff --git a/tools/test_renios_ios_launcher.sh b/tools/test_renios_ios_launcher.sh new file mode 100644 index 000000000..56d1ffcd0 --- /dev/null +++ b/tools/test_renios_ios_launcher.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Inspect the official Renios launch contract without invoking Xcode or iOS. +# The probe deliberately reports whether the archive exposes only the +# blocking Python launcher; it never claims that this is a host-tick runtime. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +mobile_root="${1:-${RENPY_MOBILE_TEST_ROOT:-${AETHERKIRI_RENPY_MOBILE_ROOT:-}}}" +configuration="${2:-${RENPY_MOBILE_TEST_CONFIG:-release}}" + +if [[ -z "$mobile_root" ]]; then + echo "Renios iOS launcher probe metadata validation ok (archive inspection not requested)" + exit 0 +fi + +prototype="$mobile_root/renios/prototype" +prebuilt="$prototype/prebuilt/$configuration" +[[ -d "$prototype" ]] || { echo "missing Renios prototype: $prototype" >&2; exit 1; } +[[ -d "$prebuilt" ]] || { echo "missing Renios prebuilt directory: $prebuilt" >&2; exit 1; } + +for archive in librenpython.a librenpy.a libpython3.12.a libSDL2.a libSDL2main.a; do + [[ -f "$prebuilt/$archive" ]] || { + echo "missing Renios launcher archive: $prebuilt/$archive" >&2 + exit 1 + } +done + +has_symbol() { + local archive="$1" symbol="$2" + strings "$archive" | grep -Fqx "$symbol" +} + +# librenpython.a exports the launcher, but its relocation set calls +# Py_RunMain. That is the blocking boundary we must split before host ticks. +has_symbol "$prebuilt/librenpython.a" _launcher_main || { + echo "librenpython.a does not export _launcher_main" >&2 + exit 1 +} +has_symbol "$prebuilt/librenpython.a" _Py_RunMain || { + echo "librenpython.a does not expose the blocking _Py_RunMain dependency" >&2 + exit 1 +} +# SDL_UIKitRunApp is implemented by SDL2 itself. The separate SDL2main +# archive is only the prototype's dummy-main shim and is intentionally +# excluded from the Godot extension archive. Keep both facts explicit. +has_symbol "$prebuilt/libSDL2.a" _SDL_UIKitRunApp || { + echo "libSDL2.a does not contain the expected UIKit entrypoint" >&2 + exit 1 +} +has_symbol "$prebuilt/libSDL2.a" _UIApplicationMain || { + echo "libSDL2.a does not expose its UIKit UIApplicationMain dependency" >&2 + exit 1 +} +if has_symbol "$prebuilt/libSDL2main.a" _SDL_UIKitRunApp; then + echo "SDL2main unexpectedly owns the UIKit entrypoint" >&2 + exit 1 +fi +grep -Fq 'SDL_UIKitRunApp' "$prototype/main.c" || { + echo "Renios prototype main.c no longer documents its UIKit entrypoint" >&2 + exit 1 +} + +cat < Date: Sat, 3 Oct 2026 10:15:01 +0800 Subject: [PATCH 111/331] build: add Android RenPy preflight loader --- bridge/renpy_runtime/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bridge/renpy_runtime/README.md b/bridge/renpy_runtime/README.md index 411579602..f059b1a0b 100644 --- a/bridge/renpy_runtime/README.md +++ b/bridge/renpy_runtime/README.md @@ -125,3 +125,9 @@ Activity. Because RAPT embeds its own SDL/Python runtime and lifecycle, asset/JNI staging alone is not a playable integration: until SDL surface, lifecycle, input, and Godot rendering handoff are complete, `Start` and provider open return `ENGINE_RESULT_NOT_SUPPORTED` with an explicit diagnostic. +Before attempting that handoff, the Android `BootstrapAdapter::Preflight` +loader checks the bound host Activity, required `SDLActivity` and +`PythonSDLActivity` JNI methods, a live host `ANativeWindow`, and the exported +`librenpython.so` entrypoints (`SDL_main`, SDL Android accessors, and RAPT JNI +callbacks). Missing pieces produce a stable diagnostic and never call +`SDL_main` or create another Activity. From 1d244ee1acce00d4a7ff4a5d7f5711da9499e7d1 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:15:08 +0800 Subject: [PATCH 112/331] build: add Android RenPy preflight loader --- bridge/renpy_runtime/include/renpy_mobile_adapter.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bridge/renpy_runtime/include/renpy_mobile_adapter.h b/bridge/renpy_runtime/include/renpy_mobile_adapter.h index 5066432fc..30fb63c04 100644 --- a/bridge/renpy_runtime/include/renpy_mobile_adapter.h +++ b/bridge/renpy_runtime/include/renpy_mobile_adapter.h @@ -28,6 +28,10 @@ class BootstrapAdapter final { public: BootstrapAdapter() = default; + /* Validate the host-owned Android handoff and the staged RAPT payload. + * This never calls SDL_main or starts a second Activity. */ + engine_result_t Preflight(const BootstrapRequest& request); + engine_result_t Start(const BootstrapRequest& request); engine_result_t Stop(); @@ -37,6 +41,7 @@ class BootstrapAdapter final { private: bool running_ = false; std::string last_error_; + void* native_library_handle_ = nullptr; }; } // namespace aetherkiri::renpy::mobile From 5bcc6999f9a59361e014cf1774ada3e21b6a05b5 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:15:17 +0800 Subject: [PATCH 113/331] build: add Android RenPy preflight loader --- .../src/renpy_mobile_adapter.cpp | 201 +++++++++++++++--- 1 file changed, 177 insertions(+), 24 deletions(-) diff --git a/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp index 6f374e7a6..b04c8ffff 100644 --- a/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp +++ b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp @@ -2,18 +2,122 @@ #if defined(__ANDROID__) #include +#include +#include // These accessors are exported by engine_api's existing Android JNI bridge. // They let the adapter validate the already-running VM/context without // constructing or launching a second Activity. +extern JNIEnv* krkr_GetJNIEnv(); extern JavaVM* krkr_GetJavaVM(); extern jobject krkr_GetApplicationContext(); extern jobject krkr_GetHostActivity(); +extern ANativeWindow* krkr_GetNativeWindow(); #endif namespace aetherkiri::renpy::mobile { -engine_result_t BootstrapAdapter::Start(const BootstrapRequest& request) { +#if defined(__ANDROID__) +namespace { + +bool ClearJavaException(JNIEnv* env) { + if (!env->ExceptionCheck()) return false; + env->ExceptionClear(); + return true; +} + +bool RequireStaticMethod(JNIEnv* env, jclass klass, const char* class_name, + const char* method_name, const char* signature, + std::string* error) { + if (env->GetStaticMethodID(klass, method_name, signature) != nullptr) { + return true; + } + ClearJavaException(env); + *error = "Ren'Py mobile preflight: Java callback " + + std::string(class_name) + "." + method_name + signature + + " is missing"; + return false; +} + +bool RequireInstanceMethod(JNIEnv* env, jclass klass, const char* class_name, + const char* method_name, const char* signature, + std::string* error) { + if (env->GetMethodID(klass, method_name, signature) != nullptr) { + return true; + } + ClearJavaException(env); + *error = "Ren'Py mobile preflight: Java callback " + + std::string(class_name) + "." + method_name + signature + + " is missing"; + return false; +} + +jclass FindRequiredClass(JNIEnv* env, const char* class_name, + std::string* error) { + jclass klass = env->FindClass(class_name); + if (klass != nullptr) return klass; + ClearJavaException(env); + *error = "Ren'Py mobile preflight: required Java class " + + std::string(class_name) + + " is unavailable; the staged RAPT sources are assets-only and " + "must be compiled into the host APK"; + return nullptr; +} + +bool CheckJavaCallbacks(JNIEnv* env, std::string* error) { + jclass sdl = FindRequiredClass(env, "org/libsdl/app/SDLActivity", error); + if (sdl == nullptr) return false; + const bool sdl_ok = + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "nativeSetupJNI", "()I", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "nativeRunMain", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)I", + error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "onNativeSurfaceCreated", "()V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "onNativeSurfaceChanged", "()V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "onNativeSurfaceDestroyed", "()V", error); + env->DeleteLocalRef(sdl); + if (!sdl_ok) return false; + + jclass python = + FindRequiredClass(env, "org/renpy/android/PythonSDLActivity", error); + if (python == nullptr) return false; + const bool python_ok = + RequireInstanceMethod(env, python, "org.renpy.android.PythonSDLActivity", + "nativeSetEnv", + "(Ljava/lang/String;Ljava/lang/String;)V", error); + env->DeleteLocalRef(python); + return python_ok; +} + +const char* MissingNativeExport(void* handle) { + static constexpr const char* kRequired[] = { + "SDL_main", + "JNI_OnLoad", + "SDL_AndroidGetJNIEnv", + "SDL_AndroidGetActivity", + "Java_org_libsdl_app_SDLActivity_nativeSetupJNI", + "Java_org_libsdl_app_SDLActivity_nativeRunMain", + "Java_org_libsdl_app_SDLActivity_onNativeSurfaceCreated", + "Java_org_libsdl_app_SDLActivity_onNativeSurfaceChanged", + "Java_org_libsdl_app_SDLActivity_onNativeSurfaceDestroyed", + "Java_org_renpy_android_PythonSDLActivity_nativeSetEnv", + }; + for (const char* symbol : kRequired) { + dlerror(); + if (dlsym(handle, symbol) == nullptr) return symbol; + } + return nullptr; +} + +} // namespace +#endif + +engine_result_t BootstrapAdapter::Preflight(const BootstrapRequest& request) { if (request.game_root_path_utf8 == nullptr || request.game_root_path_utf8[0] == '\0') { last_error_ = "Ren'Py mobile bootstrap requires a non-empty game root"; @@ -24,49 +128,98 @@ engine_result_t BootstrapAdapter::Start(const BootstrapRequest& request) { return ENGINE_RESULT_INVALID_STATE; } - // There is deliberately no Activity construction here. The host must - // provide its existing Activity once the Godot Android plugin owns that - // handoff; without it, starting RAPT would create a second UI owner. +#if !defined(__ANDROID__) + (void)request; + last_error_ = + "Ren'Py mobile preflight is only available on an Android host"; + return ENGINE_RESULT_NOT_SUPPORTED; +#else void* host_activity = request.existing_host_activity; -#if defined(__ANDROID__) - if (host_activity == nullptr) { - // The generated Godot Activity can bind itself through the staged - // RenPyMobileBridge. This is a global JNI reference owned by engine_api; - // no second Activity is ever constructed here. - host_activity = krkr_GetHostActivity(); - } -#endif + if (host_activity == nullptr) host_activity = krkr_GetHostActivity(); if (host_activity == nullptr) { last_error_ = - "Ren'Py mobile bootstrap requires the existing host Activity; " + "Ren'Py mobile preflight requires the existing host Activity; " "a second Activity is never created"; return ENGINE_RESULT_NOT_SUPPORTED; } - -#if defined(__ANDROID__) if (krkr_GetJavaVM() == nullptr || krkr_GetApplicationContext() == nullptr) { last_error_ = - "Ren'Py mobile bootstrap requires the existing Android JavaVM and " + "Ren'Py mobile preflight requires the existing Android JavaVM and " "Application Context from EngineBridge"; return ENGINE_RESULT_NOT_SUPPORTED; } -#endif - // Keep this guard until the official RAPT classes/native library and the - // in-process SDL/asset/lifecycle bridge are linked into the Android app. - // Returning NOT_SUPPORTED here is intentional and prevents staged inputs - // from being reported as playable support. + JNIEnv* env = krkr_GetJNIEnv(); + if (env == nullptr) { + last_error_ = + "Ren'Py mobile preflight could not attach the current thread to the " + "Android JavaVM"; + return ENGINE_RESULT_NOT_SUPPORTED; + } + if (!CheckJavaCallbacks(env, &last_error_)) { + return ENGINE_RESULT_NOT_SUPPORTED; + } + + ANativeWindow* window = krkr_GetNativeWindow(); + if (window == nullptr) { + last_error_ = + "Ren'Py mobile preflight requires a bound host SDL surface; call " + "EngineBridge.nativeSetSurface before opening the game"; + return ENGINE_RESULT_NOT_SUPPORTED; + } + const int32_t width = ANativeWindow_getWidth(window); + const int32_t height = ANativeWindow_getHeight(window); + ANativeWindow_release(window); + if (width <= 0 || height <= 0) { + last_error_ = + "Ren'Py mobile preflight found a host SDL surface with invalid " + "dimensions"; + return ENGINE_RESULT_NOT_SUPPORTED; + } + + if (native_library_handle_ != nullptr) { + dlclose(native_library_handle_); + native_library_handle_ = nullptr; + } + void* library = dlopen("librenpython.so", RTLD_NOW | RTLD_LOCAL); + if (library == nullptr) { + const char* detail = dlerror(); + last_error_ = "Ren'Py mobile preflight could not load librenpython.so"; + if (detail != nullptr) { + last_error_.append(": "); + last_error_.append(detail); + } + return ENGINE_RESULT_NOT_SUPPORTED; + } + if (const char* missing = MissingNativeExport(library); missing != nullptr) { + last_error_ = "Ren'Py mobile preflight: librenpython.so is missing export "; + last_error_.append(missing); + dlclose(library); + return ENGINE_RESULT_NOT_SUPPORTED; + } + native_library_handle_ = library; last_error_ = - "Ren'Py mobile bootstrap adapter boundary is present, but official " - "RAPT PythonSDLActivity/librenpython and SDL lifecycle dependencies " - "are not linked yet"; + "Ren'Py mobile preflight passed host Activity, Java callbacks, SDL " + "surface, and librenpython exports; SDL/Python lifecycle bridge is not " + "started by this boundary"; return ENGINE_RESULT_NOT_SUPPORTED; +#endif +} + +engine_result_t BootstrapAdapter::Start(const BootstrapRequest& request) { + return Preflight(request); } engine_result_t BootstrapAdapter::Stop() { // Start() cannot currently transition to running, but make destruction // idempotent so the provider can call Stop() unconditionally. running_ = false; +#if defined(__ANDROID__) + if (native_library_handle_ != nullptr) { + dlclose(native_library_handle_); + native_library_handle_ = nullptr; + } +#endif last_error_.clear(); return ENGINE_RESULT_OK; } From 1f6e195feeb58fa15671234392b1208f2d3405b9 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:15:24 +0800 Subject: [PATCH 114/331] build: add Android RenPy preflight loader --- bridge/renpy_runtime/tests/mobile_adapter.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/bridge/renpy_runtime/tests/mobile_adapter.cpp b/bridge/renpy_runtime/tests/mobile_adapter.cpp index 261164bd1..8acf0d634 100644 --- a/bridge/renpy_runtime/tests/mobile_adapter.cpp +++ b/bridge/renpy_runtime/tests/mobile_adapter.cpp @@ -19,13 +19,14 @@ int main() { request.existing_host_activity = &host_token; assert(adapter.Start(request) == ENGINE_RESULT_NOT_SUPPORTED); assert(!adapter.running()); + const char* error = adapter.last_error().c_str(); +#if defined(__ANDROID__) // Android may stop at the JavaVM/context boundary before reaching the // dependency guard; both are deliberate NOT_SUPPORTED contracts. - const char* error = adapter.last_error().c_str(); assert(std::strstr(error, "RAPT") != nullptr || std::strstr(error, "JavaVM") != nullptr); -#if !defined(__ANDROID__) - assert(std::strstr(error, "librenpython") != nullptr); +#else + assert(std::strstr(error, "Android host") != nullptr); #endif // The provider may call Stop during destruction even after a failed Start. @@ -35,7 +36,11 @@ int main() { request.existing_host_activity = nullptr; assert(adapter.Start(request) == ENGINE_RESULT_NOT_SUPPORTED); +#if defined(__ANDROID__) assert(std::strstr(adapter.last_error().c_str(), "second Activity") != nullptr); +#else + assert(std::strstr(adapter.last_error().c_str(), "Android host") != nullptr); +#endif return 0; } From a633d5af9a8ee5a6e6dd5d9bc88a0987033ba03e Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:15:33 +0800 Subject: [PATCH 115/331] build: add Android RenPy preflight loader --- tools/test_stage_renpy_android_support.sh | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tools/test_stage_renpy_android_support.sh b/tools/test_stage_renpy_android_support.sh index 3504a15ac..ff92d4697 100644 --- a/tools/test_stage_renpy_android_support.sh +++ b/tools/test_stage_renpy_android_support.sh @@ -3,7 +3,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" stage="$repo_root/tools/stage_renpy_android_support.sh" -[[ -f "$stage" ]] || { echo "stager is missing: $stage" >&2; exit 1; } +[[ -x "$stage" ]] || { echo "stager is not executable: $stage" >&2; exit 1; } bash -n "$stage" mobile_root="${RENPY_MOBILE_STAGE_TEST_ROOT:-/workspace/shared/renpy-mobile-staged}" @@ -17,7 +17,7 @@ trap 'rm -rf "$tmp_root"' EXIT mkdir -p "$tmp_root/android-build/src/main" "$tmp_root/private" printf 'private fixture\n' > "$tmp_root/private/private.mp3" -bash "$stage" \ +"$stage" \ --mobile-root "$mobile_root" \ --godot-build "$tmp_root/android-build" \ --private-assets "$tmp_root/private" >/dev/null @@ -30,6 +30,24 @@ main="$tmp_root/android-build/src/main" [[ -s "$main/assets/renpy_mobile/rapt/build.gradle" ]] [[ -s "$main/assets/renpy_mobile/private/private.mp3" ]] [[ -s "$main/assets/renpy_mobile/rapt/private.mp3" ]] +# Keep the loader preflight tied to the official payload's real ABI. GNU nm +# understands the staged arm64 ELF even when no Android runtime is available. +if command -v nm >/dev/null 2>&1; then + for symbol in \ + SDL_main \ + JNI_OnLoad \ + SDL_AndroidGetJNIEnv \ + SDL_AndroidGetActivity \ + Java_org_libsdl_app_SDLActivity_nativeSetupJNI \ + Java_org_libsdl_app_SDLActivity_nativeRunMain \ + Java_org_libsdl_app_SDLActivity_onNativeSurfaceCreated \ + Java_org_libsdl_app_SDLActivity_onNativeSurfaceChanged \ + Java_org_libsdl_app_SDLActivity_onNativeSurfaceDestroyed \ + Java_org_renpy_android_PythonSDLActivity_nativeSetEnv; do + nm -D --defined-only "$main/jniLibs/arm64-v8a/librenpython.so" \ + | awk '{ print $3 }' | grep -Fx "$symbol" >/dev/null + done +fi [[ -s "$main/java/org/github/krkr2/aetherkiri/RenPyMobileBridge.java" ]] grep -Fx 'playable=false' "$main/assets/renpy_mobile/manifest.properties" grep -Fx 'manifest_merged=false' "$main/assets/renpy_mobile/manifest.properties" From 6bd5094b0e8d8b6800f92bf9814fd72b87337a60 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:17:30 +0800 Subject: [PATCH 116/331] build: extend Android RenPy preflight callbacks From 7b30c48f5ddb439d2b762c7c716029f054af04f0 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:17:37 +0800 Subject: [PATCH 117/331] build: extend Android RenPy preflight callbacks From a57eab1ae8aa6417380690494924a0ebd3c9f113 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:17:45 +0800 Subject: [PATCH 118/331] build: extend Android RenPy preflight callbacks --- .../src/renpy_mobile_adapter.cpp | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp index b04c8ffff..a7fc056f7 100644 --- a/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp +++ b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp @@ -79,7 +79,23 @@ bool CheckJavaCallbacks(JNIEnv* env, std::string* error) { RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", "onNativeSurfaceChanged", "()V", error) && RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", - "onNativeSurfaceDestroyed", "()V", error); + "onNativeSurfaceDestroyed", "()V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "nativeSetScreenResolution", "(IIIIF)V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "onNativeResize", "()V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "onNativeKeyDown", "(I)V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "onNativeKeyUp", "(I)V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "onNativeTouch", "(IIIFFF)V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "nativePause", "()V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "nativeResume", "()V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "nativeQuit", "()V", error); env->DeleteLocalRef(sdl); if (!sdl_ok) return false; @@ -105,6 +121,14 @@ const char* MissingNativeExport(void* handle) { "Java_org_libsdl_app_SDLActivity_onNativeSurfaceCreated", "Java_org_libsdl_app_SDLActivity_onNativeSurfaceChanged", "Java_org_libsdl_app_SDLActivity_onNativeSurfaceDestroyed", + "Java_org_libsdl_app_SDLActivity_nativeSetScreenResolution", + "Java_org_libsdl_app_SDLActivity_onNativeResize", + "Java_org_libsdl_app_SDLActivity_onNativeKeyDown", + "Java_org_libsdl_app_SDLActivity_onNativeKeyUp", + "Java_org_libsdl_app_SDLActivity_onNativeTouch", + "Java_org_libsdl_app_SDLActivity_nativePause", + "Java_org_libsdl_app_SDLActivity_nativeResume", + "Java_org_libsdl_app_SDLActivity_nativeQuit", "Java_org_renpy_android_PythonSDLActivity_nativeSetEnv", }; for (const char* symbol : kRequired) { From 1cbdcd54f1321c9cfd98960c3f70026842fb6d39 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:17:51 +0800 Subject: [PATCH 119/331] build: extend Android RenPy preflight callbacks From 542646b0edf493545ec3bf46e131b4fa83594426 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:17:58 +0800 Subject: [PATCH 120/331] build: extend Android RenPy preflight callbacks --- tools/test_stage_renpy_android_support.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tools/test_stage_renpy_android_support.sh b/tools/test_stage_renpy_android_support.sh index ff92d4697..a04ddede6 100644 --- a/tools/test_stage_renpy_android_support.sh +++ b/tools/test_stage_renpy_android_support.sh @@ -43,6 +43,14 @@ if command -v nm >/dev/null 2>&1; then Java_org_libsdl_app_SDLActivity_onNativeSurfaceCreated \ Java_org_libsdl_app_SDLActivity_onNativeSurfaceChanged \ Java_org_libsdl_app_SDLActivity_onNativeSurfaceDestroyed \ + Java_org_libsdl_app_SDLActivity_nativeSetScreenResolution \ + Java_org_libsdl_app_SDLActivity_onNativeResize \ + Java_org_libsdl_app_SDLActivity_onNativeKeyDown \ + Java_org_libsdl_app_SDLActivity_onNativeKeyUp \ + Java_org_libsdl_app_SDLActivity_onNativeTouch \ + Java_org_libsdl_app_SDLActivity_nativePause \ + Java_org_libsdl_app_SDLActivity_nativeResume \ + Java_org_libsdl_app_SDLActivity_nativeQuit \ Java_org_renpy_android_PythonSDLActivity_nativeSetEnv; do nm -D --defined-only "$main/jniLibs/arm64-v8a/librenpython.so" \ | awk '{ print $3 }' | grep -Fx "$symbol" >/dev/null From 2c6a7f462fb6de2fcc9aac860645a26e9022543d Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:24:51 +0800 Subject: [PATCH 121/331] build: compile host-owned RenPy Android shims --- bridge/renpy_runtime/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bridge/renpy_runtime/README.md b/bridge/renpy_runtime/README.md index f059b1a0b..775f04d41 100644 --- a/bridge/renpy_runtime/README.md +++ b/bridge/renpy_runtime/README.md @@ -118,6 +118,13 @@ running Godot Activity to the engine JNI bridge; it does not launch RAPT or load its SDL loop. Set `AETHERKIRI_RENPY_ANDROID_PRIVATE_ASSETS` when a built Ren'Py game's private payload is available. +The export also compiles two host-owned, non-Activity signature shims at +`org.libsdl.app.SDLActivity` and `org.renpy.android.PythonSDLActivity`. They +only bind the existing Activity and declare the callbacks that preflight +checks; the complete RAPT Activity remains under `assets/renpy_mobile/rapt`. +If a Godot template already owns either class, staging fails closed instead of +overwriting a possible SDL singleton. + The Android provider also has a compile-tested `BootstrapAdapter` boundary that accepts an opaque pointer to the existing host Activity through the host extension slot `reserved_ptr[1]` or the JNI shim. It never creates a second From 9e3f5913de594d8894019340df5ab192380ce127 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:25:01 +0800 Subject: [PATCH 122/331] build: compile host-owned RenPy Android shims --- bridge/renpy_runtime/android/RenPyMobileBridge.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bridge/renpy_runtime/android/RenPyMobileBridge.java b/bridge/renpy_runtime/android/RenPyMobileBridge.java index d40047dc4..6cb155835 100644 --- a/bridge/renpy_runtime/android/RenPyMobileBridge.java +++ b/bridge/renpy_runtime/android/RenPyMobileBridge.java @@ -3,6 +3,8 @@ import android.app.Activity; import android.content.Context; +import org.libsdl.app.SDLActivity; + import java.io.File; /** @@ -29,6 +31,7 @@ public static void bindHostActivity(Activity activity) { throw new IllegalArgumentException("host Activity must not be null"); } ensureNativeBridge(); + SDLActivity.bindHostActivity(activity); nativeSetHostActivity(activity); } @@ -37,6 +40,7 @@ public static void clearHostActivity() { if (nativeBridgeLoaded || tryLoadNativeBridge()) { nativeSetHostActivity(null); } + SDLActivity.clearHostActivity(); } /** Returns true when the native bridge has loaded in the host process. */ From 0661b50e73840dc2ddfff26db9b60279621d75a2 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:25:17 +0800 Subject: [PATCH 123/331] build: compile host-owned RenPy Android shims --- tools/stage_renpy_android_support.sh | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tools/stage_renpy_android_support.sh b/tools/stage_renpy_android_support.sh index ed692b777..ba57e2483 100644 --- a/tools/stage_renpy_android_support.sh +++ b/tools/stage_renpy_android_support.sh @@ -107,6 +107,8 @@ asset_private="$asset_root/private" private_archive="$asset_rapt/private.mp3" jni_root="$main_src/jniLibs/arm64-v8a" java_root="$main_src/java/org/github/krkr2/aetherkiri" +java_sdl_root="$main_src/java/org/libsdl/app" +java_renpy_root="$main_src/java/org/renpy/android" mkdir -p "$asset_rapt" "$asset_private" "$jni_root" "$java_root" @@ -115,6 +117,29 @@ mkdir -p "$asset_rapt" "$asset_private" "$jni_root" "$java_root" cp -f "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bridge/renpy_runtime/android/RenPyMobileBridge.java" \ "$java_root/RenPyMobileBridge.java" +# Compile only the host-owned callback signatures. The official RAPT Java +# sources remain assets and its PythonSDLActivity manifest is never merged; +# compiling the full Activity would create a second SDL singleton. Refuse to +# overwrite an unrelated class if a Godot template starts shipping one. +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +install_host_shim() { + local source="$1" destination="$2" marker="$3" + mkdir -p "$(dirname "$destination")" + if [[ -f "$destination" ]] && ! grep -Fq "$marker" "$destination"; then + echo "Refusing to overwrite unrelated Android Java class: $destination" >&2 + exit 1 + fi + cp -f "$source" "$destination" +} +install_host_shim \ + "$repo_root/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java" \ + "$java_sdl_root/SDLActivity.java" \ + 'renpy-sdl-host-shim-v1' +install_host_shim \ + "$repo_root/bridge/renpy_runtime/android/org/renpy/android/PythonSDLActivity.java" \ + "$java_renpy_root/PythonSDLActivity.java" \ + 'renpy-python-host-shim-v1' + # The source tree is deliberately copied below assets rather than src/main/java # or src/main/res. RAPT's manifest names PythonSDLActivity as its launcher; # merging it into Godot would create a second Activity and two SDL owners. @@ -201,6 +226,7 @@ fi printf 'private_assets=assets/renpy_mobile/private\n' printf 'private_archive=%s\n' "$( [[ -f "$private_archive" ]] && printf 'assets/renpy_mobile/rapt/private.mp3' || true )" printf 'manifest_merged=false\n' + printf 'java_host_shims=org/libsdl/app/SDLActivity.java,org/renpy/android/PythonSDLActivity.java\n' } > "$asset_root/manifest.properties" echo "Ren'Py Android support staged into $godot_build" From 110225be40302d17099d4cf54ed6eea546fae038 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:25:33 +0800 Subject: [PATCH 124/331] build: compile host-owned RenPy Android shims --- tools/test_stage_renpy_android_support.sh | 27 +++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/tools/test_stage_renpy_android_support.sh b/tools/test_stage_renpy_android_support.sh index a04ddede6..7d13da587 100644 --- a/tools/test_stage_renpy_android_support.sh +++ b/tools/test_stage_renpy_android_support.sh @@ -13,7 +13,8 @@ if [[ ! -f "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/ fi tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-android-stage.XXXXXX")" -trap 'rm -rf "$tmp_root"' EXIT +conflict_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-android-conflict.XXXXXX")" +trap 'rm -rf "$tmp_root" "$conflict_root"' EXIT mkdir -p "$tmp_root/android-build/src/main" "$tmp_root/private" printf 'private fixture\n' > "$tmp_root/private/private.mp3" @@ -57,11 +58,33 @@ if command -v nm >/dev/null 2>&1; then done fi [[ -s "$main/java/org/github/krkr2/aetherkiri/RenPyMobileBridge.java" ]] +[[ -s "$main/java/org/libsdl/app/SDLActivity.java" ]] +[[ -s "$main/java/org/renpy/android/PythonSDLActivity.java" ]] +grep -Fq 'renpy-sdl-host-shim-v1' "$main/java/org/libsdl/app/SDLActivity.java" +grep -Fq 'renpy-python-host-shim-v1' "$main/java/org/renpy/android/PythonSDLActivity.java" grep -Fx 'playable=false' "$main/assets/renpy_mobile/manifest.properties" grep -Fx 'manifest_merged=false' "$main/assets/renpy_mobile/manifest.properties" +grep -Fx 'java_host_shims=org/libsdl/app/SDLActivity.java,org/renpy/android/PythonSDLActivity.java' \ + "$main/assets/renpy_mobile/manifest.properties" # The RAPT manifest is an asset only; no second Activity is added to the host # source tree. [[ ! -f "$main/AndroidManifest.xml" ]] -! find "$main/java" -path '*/org/renpy/android/PythonSDLActivity.java' -print -quit | grep -q . +# The official PythonSDLActivity remains assets-only; the compiled source is +# the explicit host shim above. +grep -Fq 'Host-side signature shim' "$main/java/org/renpy/android/PythonSDLActivity.java" +grep -Fq 'PythonSDLActivity' "$main/assets/renpy_mobile/rapt/java/org/renpy/android/PythonSDLActivity.java" + +# A Godot template that already owns either SDL class must fail closed rather +# than silently replacing its singleton with the RAPT shim. +mkdir -p "$conflict_root/android-build/src/main/java/org/libsdl/app" +printf 'package org.libsdl.app; public final class SDLActivity {}\n' \ + > "$conflict_root/android-build/src/main/java/org/libsdl/app/SDLActivity.java" +if "$stage" --mobile-root "$mobile_root" \ + --godot-build "$conflict_root/android-build" \ + >"$conflict_root/stdout" 2>"$conflict_root/stderr"; then + echo "stager overwrote an unrelated SDLActivity shim" >&2 + exit 1 +fi +grep -Fq 'Refusing to overwrite unrelated Android Java class' "$conflict_root/stderr" echo "Ren'Py Android staging/package smoke ok" From ceeeed85f296e7d9ccbc21924efe21f81b7f1752 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:25:50 +0800 Subject: [PATCH 125/331] build: add host-owned RenPy Android Java shims --- .../android/org/libsdl/app/SDLActivity.java | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java diff --git a/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java b/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java new file mode 100644 index 000000000..9437933aa --- /dev/null +++ b/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java @@ -0,0 +1,81 @@ +package org.libsdl.app; + +import android.app.Activity; +import java.lang.ref.WeakReference; + +/** + * AetherKiri's host-owned RAPT callback shim. + * + * This is deliberately not an Activity and is never declared in the Android + * manifest. The official SDLActivity source remains available under + * assets/renpy_mobile/rapt for inspection, while this class only supplies the + * JNI callback signatures that librenpython resolves during preflight. A + * future lifecycle bridge may implement these calls against the existing + * Godot Activity and SurfaceView; this shim must not launch a second Activity. + */ +public final class SDLActivity { + public static final String AETHERKIRI_HOST_SHIM = "renpy-sdl-host-shim-v1"; + + private static WeakReference hostActivity = new WeakReference<>(null); + + private SDLActivity() { + } + + public static void bindHostActivity(Activity activity) { + hostActivity = new WeakReference<>(activity); + } + + public static void clearHostActivity() { + hostActivity = new WeakReference<>(null); + } + + public static Activity getHostActivity() { + return hostActivity.get(); + } + + public static native int nativeSetupJNI(); + public static native int nativeRunMain(String library, String function, + Object arguments); + public static native void nativeLowMemory(); + public static native void nativeSendQuit(); + public static native void nativeQuit(); + public static native void nativePause(); + public static native void nativeResume(); + public static native void nativeFocusChanged(boolean hasFocus); + public static native void onNativeDropFile(String filename); + public static native void nativeSetScreenResolution(int surfaceWidth, + int surfaceHeight, + int deviceWidth, + int deviceHeight, + float rate); + public static native void onNativeResize(); + public static native void onNativeKeyDown(int keycode); + public static native void onNativeKeyUp(int keycode); + public static native boolean onNativeSoftReturnKey(); + public static native void onNativeKeyboardFocusLost(); + public static native void nativeSetenv(String name, String value); + public static native void onNativeTouch(int touchDevId, + int pointerFingerId, + int action, + float x, + float y, + float pressure); + public static native void onNativeMouse(int button, + int action, + float x, + float y, + boolean relative); + public static native void onNativeAccel(float x, float y, float z); + public static native void onNativeClipboardChanged(); + public static native void onNativeSurfaceCreated(); + public static native void onNativeSurfaceChanged(); + public static native void onNativeSurfaceDestroyed(); + public static native String nativeGetHint(String name); + public static native boolean nativeGetHintBoolean(String name, + boolean defaultValue); + public static native void onNativeOrientationChanged(int orientation); + public static native void nativeAddTouch(int touchId, String name); + public static native void nativePermissionResult(int requestCode, + boolean result); + public static native void onNativeLocaleChanged(); +} From 5bdbd0fbc8054a510fc1b290968850a3f6928beb Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:26:00 +0800 Subject: [PATCH 126/331] build: add host-owned RenPy Android Java shims --- .../org/renpy/android/PythonSDLActivity.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 bridge/renpy_runtime/android/org/renpy/android/PythonSDLActivity.java diff --git a/bridge/renpy_runtime/android/org/renpy/android/PythonSDLActivity.java b/bridge/renpy_runtime/android/org/renpy/android/PythonSDLActivity.java new file mode 100644 index 000000000..2bcca6f95 --- /dev/null +++ b/bridge/renpy_runtime/android/org/renpy/android/PythonSDLActivity.java @@ -0,0 +1,17 @@ +package org.renpy.android; + +/** + * Host-side signature shim for the official RAPT PythonSDLActivity JNI hook. + * + * The real RAPT Activity remains an asset and is never a manifest entry. This + * class is intentionally not an Activity: it only lets the native preflight + * verify the nativeSetEnv callback without creating a second UI owner. + */ +public final class PythonSDLActivity { + public static final String AETHERKIRI_HOST_SHIM = "renpy-python-host-shim-v1"; + + public PythonSDLActivity() { + } + + public native void nativeSetEnv(String variable, String value); +} From 1ff649407c4785d02e4799c2e3f8154313bac03a Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:26:39 +0800 Subject: [PATCH 127/331] fix: resolve RenPy shim classes via host loader --- .../src/renpy_mobile_adapter.cpp | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp index a7fc056f7..211d0b0cb 100644 --- a/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp +++ b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp @@ -57,6 +57,47 @@ jclass FindRequiredClass(JNIEnv* env, const char* class_name, jclass klass = env->FindClass(class_name); if (klass != nullptr) return klass; ClearJavaException(env); + + // Preflight normally runs from a native Godot thread. FindClass uses that + // thread's loader, which may not be the APK class loader, so retry through + // the already-bound Activity before declaring a callback unavailable. + jobject activity = krkr_GetHostActivity(); + if (activity != nullptr) { + jclass activity_class = env->GetObjectClass(activity); + jmethodID get_loader = + env->GetMethodID(activity_class, "getClassLoader", + "()Ljava/lang/ClassLoader;"); + if (get_loader != nullptr) { + jobject loader = env->CallObjectMethod(activity, get_loader); + jclass loader_class = env->FindClass("java/lang/ClassLoader"); + ClearJavaException(env); + if (loader != nullptr && loader_class != nullptr) { + jmethodID load_class = + env->GetMethodID(loader_class, "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;"); + if (load_class != nullptr) { + std::string binary_name(class_name); + for (char& character : binary_name) { + if (character == '/') character = '.'; + } + jstring name = env->NewStringUTF(binary_name.c_str()); + jobject loaded = env->CallObjectMethod(loader, load_class, name); + env->DeleteLocalRef(name); + if (loaded != nullptr && !ClearJavaException(env)) { + env->DeleteLocalRef(loader); + env->DeleteLocalRef(loader_class); + env->DeleteLocalRef(activity_class); + return reinterpret_cast(loaded); + } + ClearJavaException(env); + } + } + if (loader != nullptr) env->DeleteLocalRef(loader); + if (loader_class != nullptr) env->DeleteLocalRef(loader_class); + } + ClearJavaException(env); + env->DeleteLocalRef(activity_class); + } *error = "Ren'Py mobile preflight: required Java class " + std::string(class_name) + " is unavailable; the staged RAPT sources are assets-only and " From 2f5e1fc62acf54bc80bbf2906d4e586143a400cf Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:27:38 +0800 Subject: [PATCH 128/331] test: validate Android RenPy shim signatures --- tools/test_stage_renpy_android_support.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tools/test_stage_renpy_android_support.sh b/tools/test_stage_renpy_android_support.sh index 7d13da587..e2c22fb67 100644 --- a/tools/test_stage_renpy_android_support.sh +++ b/tools/test_stage_renpy_android_support.sh @@ -62,6 +62,13 @@ fi [[ -s "$main/java/org/renpy/android/PythonSDLActivity.java" ]] grep -Fq 'renpy-sdl-host-shim-v1' "$main/java/org/libsdl/app/SDLActivity.java" grep -Fq 'renpy-python-host-shim-v1' "$main/java/org/renpy/android/PythonSDLActivity.java" +for callback in \ + nativeSetupJNI nativeRunMain nativeSetScreenResolution onNativeResize \ + onNativeKeyDown onNativeKeyUp onNativeTouch nativePause nativeResume \ + nativeQuit; do + grep -Fq "$callback" "$main/java/org/libsdl/app/SDLActivity.java" +done +grep -Fq 'nativeSetEnv' "$main/java/org/renpy/android/PythonSDLActivity.java" grep -Fx 'playable=false' "$main/assets/renpy_mobile/manifest.properties" grep -Fx 'manifest_merged=false' "$main/assets/renpy_mobile/manifest.properties" grep -Fx 'java_host_shims=org/libsdl/app/SDLActivity.java,org/renpy/android/PythonSDLActivity.java' \ From 15e98cabe4ff689cc24895daf885ab0660035d99 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:34:47 +0800 Subject: [PATCH 129/331] build: expose host Android surface to RenPy shim --- abi/src/android_jni_bridge.cpp | 45 +++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/abi/src/android_jni_bridge.cpp b/abi/src/android_jni_bridge.cpp index 3964f552b..dab27bcc1 100644 --- a/abi/src/android_jni_bridge.cpp +++ b/abi/src/android_jni_bridge.cpp @@ -117,6 +117,10 @@ static ANativeWindow* g_native_window = nullptr; static uint32_t g_surface_width = 0; static uint32_t g_surface_height = 0; static std::mutex g_surface_mutex; +// Keep the Java Surface alongside ANativeWindow so the host-owned RAPT shim +// can return the exact Surface already attached by Godot's EngineBridge. +static jobject g_host_surface = nullptr; // global ref +static std::mutex g_host_surface_mutex; // --------------------------------------------------------------------------- // Global Application Context @@ -189,6 +193,17 @@ extern "C" JNIEXPORT void JNICALL Java_org_github_krkr2_aetherkiri_EngineBridge_nativeSetSurface( JNIEnv* env, jobject /* thiz */, jobject surface, jint width, jint height) { + { + std::lock_guard surface_ref_lock(g_host_surface_mutex); + if (g_host_surface) { + env->DeleteGlobalRef(g_host_surface); + g_host_surface = nullptr; + } + if (surface) { + g_host_surface = env->NewGlobalRef(surface); + } + } + std::lock_guard lock(g_surface_mutex); // Release previous window if any @@ -215,7 +230,15 @@ Java_org_github_krkr2_aetherkiri_EngineBridge_nativeSetSurface( extern "C" JNIEXPORT void JNICALL Java_org_github_krkr2_aetherkiri_EngineBridge_nativeDetachSurface( - JNIEnv* /* env */, jobject /* thiz */) { + JNIEnv* env, jobject /* thiz */) { + + { + std::lock_guard surface_ref_lock(g_host_surface_mutex); + if (g_host_surface) { + env->DeleteGlobalRef(g_host_surface); + g_host_surface = nullptr; + } + } std::lock_guard lock(g_surface_mutex); if (g_native_window) { @@ -227,6 +250,26 @@ Java_org_github_krkr2_aetherkiri_EngineBridge_nativeDetachSurface( } } +// --------------------------------------------------------------------------- +// JNI bridge: host-owned RAPT SDL callback shims +// Returns the existing Godot Surface/Context; never creates a View, Surface, +// or Activity. The official RAPT SDLActivity remains an asset-only template. +// --------------------------------------------------------------------------- + +extern "C" JNIEXPORT jobject JNICALL +Java_org_libsdl_app_SDLActivity_getNativeSurface(JNIEnv* env, + jclass /* clazz */) { + std::lock_guard lock(g_host_surface_mutex); + return g_host_surface ? env->NewLocalRef(g_host_surface) : nullptr; +} + +extern "C" JNIEXPORT jobject JNICALL +Java_org_libsdl_app_SDLActivity_getContext(JNIEnv* env, + jclass /* clazz */) { + std::lock_guard lock(g_context_mutex); + return g_app_context ? env->NewLocalRef(g_app_context) : nullptr; +} + // --------------------------------------------------------------------------- // JNI bridge: Android host -> C++ engine // Passes the Android Application Context for use by engine code that From 4c3051a152b6f9782e4a96d30622b727c5df846f Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:34:54 +0800 Subject: [PATCH 130/331] build: expose host Android surface to RenPy shim --- bridge/renpy_runtime/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bridge/renpy_runtime/README.md b/bridge/renpy_runtime/README.md index 775f04d41..5b9d3548f 100644 --- a/bridge/renpy_runtime/README.md +++ b/bridge/renpy_runtime/README.md @@ -125,6 +125,15 @@ checks; the complete RAPT Activity remains under `assets/renpy_mobile/rapt`. If a Godot template already owns either class, staging fails closed instead of overwriting a possible SDL singleton. +The SDL shim's `getNativeSurface()` and `getContext()` callbacks return the +same Java `Surface` and Application Context already held by `EngineBridge`; no +new View or Surface is allocated. This is safe for inspection and preflight, +but the official `librenpython.so` still has no host-tick or +`SDL_AndroidSetActivity`/`SDL_AndroidSetSurface` API. Enabling gameplay next +requires rebuilding the RAPT native payload around an explicit +`init(context, surface)`, `tick`, `pause/resume`, input, and `shutdown` +interface rather than calling its blocking `SDL_main` entrypoint. + The Android provider also has a compile-tested `BootstrapAdapter` boundary that accepts an opaque pointer to the existing host Activity through the host extension slot `reserved_ptr[1]` or the JNI shim. It never creates a second From a36b167cafb7525a2d92808fa1a41fcf6a13a293 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:35:02 +0800 Subject: [PATCH 131/331] build: expose host Android surface to RenPy shim --- .../renpy_runtime/android/org/libsdl/app/SDLActivity.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java b/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java index 9437933aa..81d356c6c 100644 --- a/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java +++ b/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java @@ -1,6 +1,8 @@ package org.libsdl.app; import android.app.Activity; +import android.content.Context; +import android.view.Surface; import java.lang.ref.WeakReference; /** @@ -33,6 +35,12 @@ public static Activity getHostActivity() { return hostActivity.get(); } + /** Returns the Surface already bound by EngineBridge, never a new view. */ + public static native Surface getNativeSurface(); + + /** Returns the existing Application Context held by EngineBridge. */ + public static native Context getContext(); + public static native int nativeSetupJNI(); public static native int nativeRunMain(String library, String function, Object arguments); From f01a7790779e22ae6713275adedceb8753c80365 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:35:10 +0800 Subject: [PATCH 132/331] build: expose host Android surface to RenPy shim --- .../src/renpy_mobile_adapter.cpp | 69 +++++++++++-------- 1 file changed, 39 insertions(+), 30 deletions(-) diff --git a/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp index 211d0b0cb..11db5f05d 100644 --- a/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp +++ b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp @@ -64,39 +64,41 @@ jclass FindRequiredClass(JNIEnv* env, const char* class_name, jobject activity = krkr_GetHostActivity(); if (activity != nullptr) { jclass activity_class = env->GetObjectClass(activity); - jmethodID get_loader = - env->GetMethodID(activity_class, "getClassLoader", - "()Ljava/lang/ClassLoader;"); - if (get_loader != nullptr) { - jobject loader = env->CallObjectMethod(activity, get_loader); - jclass loader_class = env->FindClass("java/lang/ClassLoader"); - ClearJavaException(env); - if (loader != nullptr && loader_class != nullptr) { - jmethodID load_class = - env->GetMethodID(loader_class, "loadClass", - "(Ljava/lang/String;)Ljava/lang/Class;"); - if (load_class != nullptr) { - std::string binary_name(class_name); - for (char& character : binary_name) { - if (character == '/') character = '.'; - } - jstring name = env->NewStringUTF(binary_name.c_str()); - jobject loaded = env->CallObjectMethod(loader, load_class, name); - env->DeleteLocalRef(name); - if (loaded != nullptr && !ClearJavaException(env)) { - env->DeleteLocalRef(loader); - env->DeleteLocalRef(loader_class); - env->DeleteLocalRef(activity_class); - return reinterpret_cast(loaded); + if (activity_class != nullptr) { + jmethodID get_loader = + env->GetMethodID(activity_class, "getClassLoader", + "()Ljava/lang/ClassLoader;"); + if (get_loader != nullptr) { + jobject loader = env->CallObjectMethod(activity, get_loader); + jclass loader_class = env->FindClass("java/lang/ClassLoader"); + ClearJavaException(env); + if (loader != nullptr && loader_class != nullptr) { + jmethodID load_class = + env->GetMethodID(loader_class, "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;"); + if (load_class != nullptr) { + std::string binary_name(class_name); + for (char& character : binary_name) { + if (character == '/') character = '.'; + } + jstring name = env->NewStringUTF(binary_name.c_str()); + jobject loaded = env->CallObjectMethod(loader, load_class, name); + env->DeleteLocalRef(name); + if (loaded != nullptr && !ClearJavaException(env)) { + env->DeleteLocalRef(loader); + env->DeleteLocalRef(loader_class); + env->DeleteLocalRef(activity_class); + return reinterpret_cast(loaded); + } + ClearJavaException(env); } - ClearJavaException(env); } + if (loader != nullptr) env->DeleteLocalRef(loader); + if (loader_class != nullptr) env->DeleteLocalRef(loader_class); } - if (loader != nullptr) env->DeleteLocalRef(loader); - if (loader_class != nullptr) env->DeleteLocalRef(loader_class); + ClearJavaException(env); + env->DeleteLocalRef(activity_class); } - ClearJavaException(env); - env->DeleteLocalRef(activity_class); } *error = "Ren'Py mobile preflight: required Java class " + std::string(class_name) + @@ -136,7 +138,12 @@ bool CheckJavaCallbacks(JNIEnv* env, std::string* error) { RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", "nativeResume", "()V", error) && RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", - "nativeQuit", "()V", error); + "nativeQuit", "()V", error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "getNativeSurface", "()Landroid/view/Surface;", + error) && + RequireStaticMethod(env, sdl, "org.libsdl.app.SDLActivity", + "getContext", "()Landroid/content/Context;", error); env->DeleteLocalRef(sdl); if (!sdl_ok) return false; @@ -170,6 +177,8 @@ const char* MissingNativeExport(void* handle) { "Java_org_libsdl_app_SDLActivity_nativePause", "Java_org_libsdl_app_SDLActivity_nativeResume", "Java_org_libsdl_app_SDLActivity_nativeQuit", + "Java_org_libsdl_app_SDLActivity_getNativeSurface", + "Java_org_libsdl_app_SDLActivity_getContext", "Java_org_renpy_android_PythonSDLActivity_nativeSetEnv", }; for (const char* symbol : kRequired) { From 2892c43e4ad0a1f313534f790c81b006520cf285 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:35:19 +0800 Subject: [PATCH 133/331] build: expose host Android surface to RenPy shim --- cmake/engine_api_elf.map | 1 + 1 file changed, 1 insertion(+) diff --git a/cmake/engine_api_elf.map b/cmake/engine_api_elf.map index 4c196499a..a50a3456e 100644 --- a/cmake/engine_api_elf.map +++ b/cmake/engine_api_elf.map @@ -4,6 +4,7 @@ JNI_OnLoad; Java_org_github_krkr2_aetherkiri_EngineBridge_*; Java_org_github_krkr2_aetherkiri_RenPyMobileBridge_*; + Java_org_libsdl_app_SDLActivity_*; _Z14krkr_GetJNIEnvv; _Z14krkr_GetJavaVMv; _Z20krkr_GetNativeWindowv; From 078fa0f5b7c55f572c1716218f7fdb59410e410c Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:35:26 +0800 Subject: [PATCH 134/331] build: expose host Android surface to RenPy shim --- tools/test_stage_renpy_android_support.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/test_stage_renpy_android_support.sh b/tools/test_stage_renpy_android_support.sh index e2c22fb67..c8bddc00c 100644 --- a/tools/test_stage_renpy_android_support.sh +++ b/tools/test_stage_renpy_android_support.sh @@ -57,6 +57,7 @@ if command -v nm >/dev/null 2>&1; then | awk '{ print $3 }' | grep -Fx "$symbol" >/dev/null done fi +grep -Fq 'Java_org_libsdl_app_SDLActivity_*' "$repo_root/cmake/engine_api_elf.map" [[ -s "$main/java/org/github/krkr2/aetherkiri/RenPyMobileBridge.java" ]] [[ -s "$main/java/org/libsdl/app/SDLActivity.java" ]] [[ -s "$main/java/org/renpy/android/PythonSDLActivity.java" ]] @@ -65,7 +66,7 @@ grep -Fq 'renpy-python-host-shim-v1' "$main/java/org/renpy/android/PythonSDLActi for callback in \ nativeSetupJNI nativeRunMain nativeSetScreenResolution onNativeResize \ onNativeKeyDown onNativeKeyUp onNativeTouch nativePause nativeResume \ - nativeQuit; do + nativeQuit getNativeSurface getContext; do grep -Fq "$callback" "$main/java/org/libsdl/app/SDLActivity.java" done grep -Fq 'nativeSetEnv' "$main/java/org/renpy/android/PythonSDLActivity.java" From 2013b4b14fe4979ddceb720d8a51e32e0a038bcb Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 10:36:26 +0800 Subject: [PATCH 135/331] fix: prefer bound Android host Activity context From 0e27f0b040517b1181a5995460bde587e628bf4a Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 12:02:31 +0800 Subject: [PATCH 136/331] build: add RenPy mobile lifecycle launcher scaffold --- .../renpy_runtime/mobile_launcher/README.md | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 bridge/renpy_runtime/mobile_launcher/README.md diff --git a/bridge/renpy_runtime/mobile_launcher/README.md b/bridge/renpy_runtime/mobile_launcher/README.md new file mode 100644 index 000000000..a41d6fb2a --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/README.md @@ -0,0 +1,89 @@ +# Host-owned Ren'Py mobile launcher scaffold + +This directory is the reviewable boundary for the native fork required to make +Ren'Py run inside an existing AetherKiri/Godot loop. It is intentionally a +contract and build/replacement scaffold. It does not contain a playable +launcher and it does not alter the current `NOT_SUPPORTED` mobile provider. + +## Verified official inputs + +The pinned 8.5.3 mobile archives in `renpy-mobile-staged/` contain stripped +outputs only. The corresponding official source/build layout is available in +the `renpy-build` repository: + +- `runtime/librenpython_android.c`: `SDL_main` initializes the Android + environment, calls `call_prepare_python`, and enters `start_python`; the + latter ends at blocking `Py_RunMain` +- `runtime/librenpython.c`: `launcher_main` builds `PyConfig` and ends at + blocking `Py_RunMain` +- `runtime/jniwrapperstuff.h`: Android JNI export-name helper +- `tasks/renpython.py`: `build_android` compiles + `librenpython_android.c`; `link_android` links `librenpython.so`; + `link_ios` archives `librenpython.o` as `librenpython.a` +- `renios/prototype/main.c`: the UIKit prototype calls + `SDL_RunApp(..., launcher_main, NULL)` (older SDL2/Renios archives use the + equivalent `SDL_UIKitRunApp` spelling) + +The official command-line hooks are documented by `build.sh --print-plan`: + +- Android: `./build.sh --platform android rebuild renpython rapt rapt-sdl2` +- iOS: `./build.sh --platform ios rebuild renpython renios` + +The current official outputs are process launchers. Do not call them from a +Godot frame callback and do not copy `renios/prototype/main.c` into the host +application. + +## Lifecycle ABI to implement + +`include/renpy_mobile_launcher.h` defines ABI version 1 and the required +exports: + +- `renpy_mobile_init(config, host)` +- `renpy_mobile_tick(budget_ms)` +- `renpy_mobile_frame(out_frame)` +- `renpy_mobile_input(event)` +- `renpy_mobile_pause()` / `renpy_mobile_resume()` +- `renpy_mobile_shutdown()` + +The eventual fork must move Python/SDL setup out of `SDL_main`/`launcher_main` +and make every lifecycle call return promptly. The calls must not invoke +`Py_RunMain`, `SDL_main`, `SDL_RunApp`, `SDL_UIKitRunApp`, `UIApplicationMain`, +or create an Android Activity. `frame` returns a borrowed RGBA view for the +host to copy; the host callback and ownership rules are in the header. + +The header is only an ABI contract. There is no stub implementation that could +be mistaken for a functioning engine. + +## Rebuild and replacement hook + +`build.sh` performs a source-only check by default when invoked as: + +```text +bridge/renpy_runtime/mobile_launcher/build.sh \ + --check --renpy-build /path/to/renpy-build +``` + +Use `--print-plan` to print the official source inputs, task hooks, and exact +staged destinations. A future lifecycle fork is built outside this scaffold, +then installed with explicit ABI-matched artifacts: + +```text +bridge/renpy_runtime/mobile_launcher/build.sh --install \ + --renpy-build /path/to/renpy-build \ + --stage /workspace/shared/renpy-mobile-staged \ + --android-so-arm64 /path/to/arm64-v8a/librenpython.so \ + --android-so-armv7 /path/to/armeabi-v7a/librenpython.so \ + --android-so-x86_64 /path/to/x86_64/librenpython.so \ + --ios-debug-a /path/to/debug/librenpython.a \ + --ios-release-a /path/to/release/librenpython.a +``` + +`--install` refuses artifacts that do not expose all seven lifecycle symbols. +It copies only the explicitly supplied files to: + +- RAPT: `rapt/prototype/renpyandroid/src/main/jniLibs/{arm64-v8a,armeabi-v7a,x86_64}/librenpython.so` +- Renios: `renios/prototype/prebuilt/{debug,release}/librenpython.a` + +The script does not build, sign, package, or claim device/simulator support. +After a real fork is installed, the Android/iOS archive probes and host-owned +lifecycle/input/frame tests must be extended before enabling the provider. From d112448c62ea2ef4ff4228b172f4c6b4309f6f65 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 12:02:52 +0800 Subject: [PATCH 137/331] build: add RenPy mobile lifecycle launcher scaffold --- bridge/renpy_runtime/mobile_launcher/build.sh | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 bridge/renpy_runtime/mobile_launcher/build.sh diff --git a/bridge/renpy_runtime/mobile_launcher/build.sh b/bridge/renpy_runtime/mobile_launcher/build.sh new file mode 100644 index 000000000..dfe08e761 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/build.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# Check or install a host-owned Ren'Py mobile launcher fork. +# +# This script never transforms the official blocking launcher by itself. It +# checks the upstream source locations, prints the exact renpy-build hooks, and +# optionally installs already-built artifacts only when they export the +# versioned host lifecycle ABI from include/renpy_mobile_launcher.h. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +contract_header="$repo_root/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h" + +usage() { + cat <<'USAGE' +Usage: + build.sh --check --renpy-build PATH + build.sh --print-plan --renpy-build PATH + build.sh --install --renpy-build PATH --stage PATH \ + [--android-so-arm64 PATH] [--android-so-armv7 PATH] + [--android-so-x86_64 PATH] [--ios-debug-a PATH] [--ios-release-a PATH] + +--check validates the official source layout and confirms that the current +launcher is still the blocking SDL_main/launcher_main implementation. +--print-plan prints the upstream renpy-build commands and replacement paths. +--install copies caller-supplied, already-built lifecycle artifacts into the +staged RAPT/Renios tree after checking the required exported symbol names. +This script does not build Ren'Py and does not claim the resulting artifacts +are playable; the fork implementation and device/simulator tests remain +required. +USAGE +} + +mode="" +renpy_build="" +stage="" +android_so_arm64="" +android_so_armv7="" +android_so_x86_64="" +ios_debug_a="" +ios_release_a="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --check|--print-plan|--install) + [[ -z "$mode" ]] || { echo "choose one mode" >&2; exit 2; } + mode="${1#--}" + ;; + --renpy-build) + [[ $# -ge 2 ]] || { echo "--renpy-build requires a path" >&2; exit 2; } + renpy_build="$2"; shift + ;; + --stage) + [[ $# -ge 2 ]] || { echo "--stage requires a path" >&2; exit 2; } + stage="$2"; shift + ;; + --android-so|--android-so-arm64) + [[ $# -ge 2 ]] || { echo "--android-so-arm64 requires a path" >&2; exit 2; } + android_so_arm64="$2"; shift + ;; + --android-so-armv7) + [[ $# -ge 2 ]] || { echo "--android-so-armv7 requires a path" >&2; exit 2; } + android_so_armv7="$2"; shift + ;; + --android-so-x86_64) + [[ $# -ge 2 ]] || { echo "--android-so-x86_64 requires a path" >&2; exit 2; } + android_so_x86_64="$2"; shift + ;; + --ios-debug-a) + [[ $# -ge 2 ]] || { echo "--ios-debug-a requires a path" >&2; exit 2; } + ios_debug_a="$2"; shift + ;; + --ios-release-a) + [[ $# -ge 2 ]] || { echo "--ios-release-a requires a path" >&2; exit 2; } + ios_release_a="$2"; shift + ;; + -h|--help) + usage; exit 0 + ;; + *) + echo "unknown argument: $1" >&2; usage >&2; exit 2 + ;; + esac + shift +done + +[[ -n "$mode" ]] || { usage >&2; exit 2; } +[[ -n "$renpy_build" ]] || { echo "--renpy-build is required" >&2; exit 2; } +[[ -f "$contract_header" ]] || { echo "missing lifecycle contract: $contract_header" >&2; exit 1; } +[[ -d "$renpy_build" ]] || { echo "renpy-build directory not found: $renpy_build" >&2; exit 1; } + +runtime="$renpy_build/runtime" +tasks="$renpy_build/tasks/renpython.py" +renios_main="$renpy_build/renios/prototype/main.c" +for required in \ + "$runtime/librenpython_android.c" \ + "$runtime/librenpython.c" \ + "$runtime/jniwrapperstuff.h" \ + "$tasks" \ + "$renios_main"; do + [[ -f "$required" ]] || { echo "missing official Ren'Py build input: $required" >&2; exit 1; } +done + +# Keep this check source-based: it works on a checkout without requiring a +# host compiler or an Apple/Android SDK. +grep -Eq 'int[[:space:]]+SDL_main[[:space:]]*\(' "$runtime/librenpython_android.c" || { + echo "official Android launcher no longer contains SDL_main; inspect before using this scaffold" >&2 + exit 1 +} +grep -Eq 'Py_RunMain[[:space:]]*\(' "$runtime/librenpython_android.c" || { + echo "official Android launcher no longer contains Py_RunMain; inspect before using this scaffold" >&2 + exit 1 +} +grep -Eq 'launcher_main[[:space:]]*\(' "$runtime/librenpython.c" || { + echo "official desktop/iOS launcher no longer contains launcher_main; inspect before using this scaffold" >&2 + exit 1 +} +grep -Eq 'Py_RunMain[[:space:]]*\(' "$runtime/librenpython.c" || { + echo "official desktop/iOS launcher no longer contains Py_RunMain; inspect before using this scaffold" >&2 + exit 1 +} +grep -Eq 'SDL_RunApp|SDL_UIKitRunApp' "$renios_main" || { + echo "Renios prototype entrypoint changed; inspect before using this scaffold" >&2 + exit 1 +} + +print_plan() { + cat < Py_RunMain)" + echo " official iOS/desktop launcher remains blocking (launcher_main -> Py_RunMain)" + echo " no artifact was built or installed" + exit 0 +fi + +print_plan +[[ "$mode" == "print-plan" ]] && exit 0 + +[[ -n "$stage" ]] || { echo "--stage is required for --install" >&2; exit 2; } +[[ -n "$android_so_arm64" || -n "$android_so_armv7" || -n "$android_so_x86_64" || + -n "$ios_debug_a" || -n "$ios_release_a" ]] || { + echo "--install requires at least one artifact path" >&2 + exit 2 +} + +required_symbols=( + renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input + renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown +) +check_symbols() { + local artifact="$1" + [[ -f "$artifact" ]] || { echo "artifact not found: $artifact" >&2; exit 1; } + for symbol in "${required_symbols[@]}"; do + if ! strings "$artifact" | grep -Fqx "$symbol"; then + echo "artifact lacks required lifecycle export $symbol: $artifact" >&2 + exit 1 + fi + done +} + +install_one() { + local source="$1" destination="$2" + check_symbols "$source" + mkdir -p "$(dirname "$destination")" + install -m 0755 "$source" "$destination" + echo "installed host lifecycle artifact: $destination" +} + +# Each Android ABI is explicit so an arm64 binary cannot silently be copied +# into an armeabi-v7a or x86_64 slot. +if [[ -n "$android_so_arm64" ]]; then + install_one "$android_so_arm64" "$stage/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" +fi +if [[ -n "$android_so_armv7" ]]; then + install_one "$android_so_armv7" "$stage/rapt/prototype/renpyandroid/src/main/jniLibs/armeabi-v7a/librenpython.so" +fi +if [[ -n "$android_so_x86_64" ]]; then + install_one "$android_so_x86_64" "$stage/rapt/prototype/renpyandroid/src/main/jniLibs/x86_64/librenpython.so" +fi +if [[ -n "$ios_release_a" ]]; then + install_one "$ios_release_a" "$stage/renios/prototype/prebuilt/release/librenpython.a" +fi +if [[ -n "$ios_debug_a" ]]; then + install_one "$ios_debug_a" "$stage/renios/prototype/prebuilt/debug/librenpython.a" +fi + +echo "Artifacts installed, but playable mobile support is not asserted by this scaffold" From 68c5143ed123743a48ad3e1890b766f1642f19fd Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 12:03:09 +0800 Subject: [PATCH 138/331] build: add RenPy mobile lifecycle launcher scaffold --- .../include/renpy_mobile_launcher.h | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h diff --git a/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h b/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h new file mode 100644 index 000000000..bd2aa5d63 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h @@ -0,0 +1,108 @@ +#ifndef AETHERKIRI_RENPY_MOBILE_LAUNCHER_H +#define AETHERKIRI_RENPY_MOBILE_LAUNCHER_H + +/* + * Host-owned Ren'Py mobile lifecycle ABI. + * + * This header is a contract for a future fork of the official Ren'Py native + * launcher. It deliberately contains no implementation and is not linked by + * the current mobile provider. The fork must export these symbols from the + * replacement librenpython.so (Android) and librenpython.a (Renios). + * + * The existing official libraries are process launchers: SDL_main and + * launcher_main eventually call Py_RunMain and do not satisfy this ABI. + */ + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define RENPY_MOBILE_LAUNCHER_ABI_VERSION 1u + +typedef enum renpy_mobile_status { + RENPY_MOBILE_OK = 0, + RENPY_MOBILE_INVALID_ARGUMENT = -1, + RENPY_MOBILE_INVALID_STATE = -2, + RENPY_MOBILE_NOT_IMPLEMENTED = -3, + RENPY_MOBILE_ERROR = -4, +} renpy_mobile_status_t; + +typedef enum renpy_mobile_input_type { + RENPY_MOBILE_INPUT_KEY = 1, + RENPY_MOBILE_INPUT_POINTER = 2, + RENPY_MOBILE_INPUT_TEXT = 3, + RENPY_MOBILE_INPUT_GAMEPAD = 4, +} renpy_mobile_input_type_t; + +typedef struct renpy_mobile_config { + uint32_t struct_size; + uint32_t abi_version; + const char *private_root_utf8; + const char *public_root_utf8; + const char *apk_path_utf8; + const char *argv0_utf8; + const char *const *argv; + int argc; +} renpy_mobile_config_t; + +typedef struct renpy_mobile_host { + uint32_t struct_size; + uint32_t abi_version; + void *user_data; + + /* Called on the host render thread. `rgba` remains owned by Ren'Py until + * the callback returns. The host must copy it before returning. */ + int (*present_rgba)(void *user_data, const uint8_t *rgba, + uint32_t width, uint32_t height, uint32_t stride); + + /* Optional diagnostics hook. The string is UTF-8 and only borrowed for + * the duration of the callback. */ + void (*log_utf8)(void *user_data, int level, const char *message); +} renpy_mobile_host_t; + +typedef struct renpy_mobile_frame { + uint32_t struct_size; + const uint8_t *rgba; + uint32_t width; + uint32_t height; + uint32_t stride; + uint64_t serial; +} renpy_mobile_frame_t; + +typedef struct renpy_mobile_input { + uint32_t struct_size; + uint32_t type; + uint64_t timestamp_ns; + int32_t device_id; + int32_t code; + int32_t value; + float x; + float y; + float pressure; + const char *text_utf8; +} renpy_mobile_input_t; + +/* The only supported entrypoint sequence is: + * + * init -> zero or more { tick, frame, input, pause/resume } -> shutdown + * + * init/tick/frame/input/pause/resume/shutdown must return promptly. In + * particular, none of them may call Py_RunMain, SDL_main, UIApplicationMain, + * SDL_RunApp, or create an Android Activity. */ +int renpy_mobile_init(const renpy_mobile_config_t *config, + const renpy_mobile_host_t *host); +int renpy_mobile_tick(uint32_t budget_ms); +int renpy_mobile_frame(renpy_mobile_frame_t *out_frame); +int renpy_mobile_input(const renpy_mobile_input_t *event); +int renpy_mobile_pause(void); +int renpy_mobile_resume(void); +void renpy_mobile_shutdown(void); + +#ifdef __cplusplus +} +#endif + +#endif /* AETHERKIRI_RENPY_MOBILE_LAUNCHER_H */ From de5792f648196ff59f37cee52316c61453bbf1f5 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 12:03:20 +0800 Subject: [PATCH 139/331] build: add RenPy mobile lifecycle launcher scaffold --- .../mobile_launcher/patches/README.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/README.md diff --git a/bridge/renpy_runtime/mobile_launcher/patches/README.md b/bridge/renpy_runtime/mobile_launcher/patches/README.md new file mode 100644 index 000000000..7a8766aee --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/README.md @@ -0,0 +1,25 @@ +# Upstream patch slot + +Keep the future lifecycle fork as a small, reviewable patch against the exact +`renpy-build` commit used for a release. The patch should replace or factor +`runtime/librenpython_android.c` and `runtime/librenpython.c` while preserving +Ren'Py's Python/SDL initialization and JNI callbacks. + +Required review checks for that patch: + +1. `SDL_main`, `launcher_main`, `SDL_RunApp`, and `Py_RunMain` are not used as + host lifecycle entrypoints +2. the seven `renpy_mobile_*` exports in the sibling ABI header are present on + every Android and iOS architecture +3. init/tick/frame/input/pause/resume/shutdown are re-entrant only as stated by + the ABI and return promptly +4. no second Android Activity, UIKit application, SDL application loop, or + process exit is introduced +5. Android links with the `tasks/renpython.py` `link_android` closure and iOS + archives with its `link_ios` closure +6. the resulting archives pass symbol checks, host unit tests, and device or + simulator smoke tests before replacing staged artifacts + +No patch is checked in yet: adding a guessed implementation here would make a +non-playable stub look like a runtime. `build.sh --install` is the deliberate +handoff point for the first audited fork. From 7bc628943dcce6c9dd47afdcbb90fdc0365e4d58 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 12:03:46 +0800 Subject: [PATCH 140/331] build: add RenPy mobile lifecycle launcher scaffold --- tools/test_renpy_mobile_launcher_scaffold.sh | 41 ++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tools/test_renpy_mobile_launcher_scaffold.sh diff --git a/tools/test_renpy_mobile_launcher_scaffold.sh b/tools/test_renpy_mobile_launcher_scaffold.sh new file mode 100644 index 000000000..0eecaeecf --- /dev/null +++ b/tools/test_renpy_mobile_launcher_scaffold.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Metadata/source-only test for the host-owned Ren'Py launcher scaffold. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +scaffold="$repo_root/bridge/renpy_runtime/mobile_launcher" +script="$scaffold/build.sh" +header="$scaffold/include/renpy_mobile_launcher.h" +renpy_build="${RENPY_BUILD_TEST_ROOT:-/tmp/renpy-build-src}" + +[[ -x "$script" ]] || { echo "missing launcher scaffold script" >&2; exit 1; } +[[ -f "$header" ]] || { echo "missing launcher lifecycle header" >&2; exit 1; } +bash -n "$script" + +for symbol in \ + renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input \ + renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown; do + grep -Fq "$symbol" "$header" +done + +grep -Fq 'Py_RunMain' "$scaffold/README.md" +grep -Fq 'SDL_main' "$scaffold/README.md" +grep -Fq 'renpy-build' "$scaffold/patches/README.md" + +if [[ ! -d "$renpy_build" ]]; then + echo "Ren'Py build checkout not present; metadata-only scaffold test ok" + exit 0 +fi + +output="$($script --check --renpy-build "$renpy_build")" +grep -Fq 'source check ok' <<<"$output" +grep -Fq 'no artifact was built or installed' <<<"$output" + +if "$script" --check --renpy-build "$renpy_build" | grep -Fq 'SDL_main'; then + : +else + echo "source check did not report the blocking Android launcher" >&2 + exit 1 +fi + +echo "Ren'Py mobile launcher scaffold/source smoke ok" From b775e78b0a2945ef24a14f53218bb51abc220830 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 12:05:47 +0800 Subject: [PATCH 141/331] test: harden mobile launcher artifact export checks --- bridge/renpy_runtime/mobile_launcher/build.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bridge/renpy_runtime/mobile_launcher/build.sh b/bridge/renpy_runtime/mobile_launcher/build.sh index dfe08e761..75b9704c4 100644 --- a/bridge/renpy_runtime/mobile_launcher/build.sh +++ b/bridge/renpy_runtime/mobile_launcher/build.sh @@ -178,8 +178,17 @@ required_symbols=( check_symbols() { local artifact="$1" [[ -f "$artifact" ]] || { echo "artifact not found: $artifact" >&2; exit 1; } + local format + format="$(file -b "$artifact" 2>/dev/null || true)" for symbol in "${required_symbols[@]}"; do - if ! strings "$artifact" | grep -Fqx "$symbol"; then + if [[ "$format" == *ELF* ]] && command -v nm >/dev/null 2>&1; then + if ! nm -D --defined-only "$artifact" 2>/dev/null | awk '{print $3}' | grep -Fxq "$symbol"; then + echo "artifact lacks required lifecycle export $symbol: $artifact" >&2 + exit 1 + fi + elif ! strings "$artifact" | grep -Fqx "$symbol"; then + # Linux nm cannot inspect the Mach-O universal archives emitted by + # the iOS hook; strings is the portable fallback for those files. echo "artifact lacks required lifecycle export $symbol: $artifact" >&2 exit 1 fi From 404321a4221612a944698b290d6891c9d6b570a1 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 13:49:06 +0800 Subject: [PATCH 142/331] fix: invoke RenPy mobile probes with bash --- tools/test_stage_renpy_android_support.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/test_stage_renpy_android_support.sh b/tools/test_stage_renpy_android_support.sh index c8bddc00c..aa8efc092 100644 --- a/tools/test_stage_renpy_android_support.sh +++ b/tools/test_stage_renpy_android_support.sh @@ -3,7 +3,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" stage="$repo_root/tools/stage_renpy_android_support.sh" -[[ -x "$stage" ]] || { echo "stager is not executable: $stage" >&2; exit 1; } +[[ -f "$stage" ]] || { echo "stager is missing: $stage" >&2; exit 1; } bash -n "$stage" mobile_root="${RENPY_MOBILE_STAGE_TEST_ROOT:-/workspace/shared/renpy-mobile-staged}" From fb6179ef7c40497ebb19d7f86bba9aabdee3cbfc Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 13:49:25 +0800 Subject: [PATCH 143/331] fix: invoke RenPy mobile probes with bash --- scripts/build_ios.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 71d09e20e..c5a9a27b4 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -387,7 +387,7 @@ renios_archive_names() { renios_launcher_probe() { renios_enabled || return 0 - "$PROJECT_ROOT/tools/test_renios_ios_launcher.sh" \ + bash "$PROJECT_ROOT/tools/test_renios_ios_launcher.sh" \ "$RENPY_MOBILE_ROOT" "$(renios_configuration)" } From 82a4b3603185a3e70726d8d5812b15688532d0bd Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 13:56:05 +0800 Subject: [PATCH 144/331] fix: invoke mobile staging scripts through bash --- tools/test_stage_renpy_android_support.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/test_stage_renpy_android_support.sh b/tools/test_stage_renpy_android_support.sh index aa8efc092..7195a9144 100644 --- a/tools/test_stage_renpy_android_support.sh +++ b/tools/test_stage_renpy_android_support.sh @@ -18,7 +18,7 @@ trap 'rm -rf "$tmp_root" "$conflict_root"' EXIT mkdir -p "$tmp_root/android-build/src/main" "$tmp_root/private" printf 'private fixture\n' > "$tmp_root/private/private.mp3" -"$stage" \ +bash "$stage" \ --mobile-root "$mobile_root" \ --godot-build "$tmp_root/android-build" \ --private-assets "$tmp_root/private" >/dev/null From 0e91fa040d509198d4e796f98950026982e63018 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 13:56:48 +0800 Subject: [PATCH 145/331] fix: invoke mobile staging scripts through bash --- scripts/build_android.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 95fe27b83..6b8d97715 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -245,9 +245,9 @@ stage_renpy_android_support() { ;; esac - local mobile_root="${AETHERKIRI_RENPY_MOBILE_ROOT:-${RENPY_MOBILE_ROOT:-}}" + local mobile_root="${AETHERKIRI_RENPY_MOBILE_ROOT:-}" if [[ -z "$mobile_root" ]]; then - echo "Error: AETHERKIRI_ENABLE_RENPY=ON requires AETHERKIRI_RENPY_MOBILE_ROOT or RENPY_MOBILE_ROOT" >&2 + echo "Error: AETHERKIRI_ENABLE_RENPY=ON requires AETHERKIRI_RENPY_MOBILE_ROOT" >&2 echo " Stage official RAPT inputs with tools/install_renpy_mobile_support.sh first." >&2 exit 1 fi From 05c6175e0a33f16f3494622a964c8a95e00e3564 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 13:57:04 +0800 Subject: [PATCH 146/331] fix: invoke mobile staging scripts through bash --- tools/test_renios_ios_bundle.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/test_renios_ios_bundle.sh b/tools/test_renios_ios_bundle.sh index e32be227e..35cb2743c 100644 --- a/tools/test_renios_ios_bundle.sh +++ b/tools/test_renios_ios_bundle.sh @@ -8,7 +8,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" build_script="$repo_root/scripts/build_ios.sh" launcher_probe="$repo_root/tools/test_renios_ios_launcher.sh" [[ -x "$build_script" ]] || { echo "iOS build script is not executable" >&2; exit 1; } -[[ -x "$launcher_probe" ]] || { echo "Renios launcher probe is not executable" >&2; exit 1; } +[[ -f "$launcher_probe" ]] || { echo "Renios launcher probe is missing" >&2; exit 1; } bash -n "$build_script" bash -n "$launcher_probe" From 887c394923a9e0544cb167bd8aba669683d8e72b Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 14:06:22 +0800 Subject: [PATCH 147/331] fix: invoke mobile smoke helpers through bash --- tools/test_stage_renpy_android_support.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/test_stage_renpy_android_support.sh b/tools/test_stage_renpy_android_support.sh index 7195a9144..855390f5f 100644 --- a/tools/test_stage_renpy_android_support.sh +++ b/tools/test_stage_renpy_android_support.sh @@ -87,7 +87,7 @@ grep -Fq 'PythonSDLActivity' "$main/assets/renpy_mobile/rapt/java/org/renpy/andr mkdir -p "$conflict_root/android-build/src/main/java/org/libsdl/app" printf 'package org.libsdl.app; public final class SDLActivity {}\n' \ > "$conflict_root/android-build/src/main/java/org/libsdl/app/SDLActivity.java" -if "$stage" --mobile-root "$mobile_root" \ +if bash "$stage" --mobile-root "$mobile_root" \ --godot-build "$conflict_root/android-build" \ >"$conflict_root/stdout" 2>"$conflict_root/stderr"; then echo "stager overwrote an unrelated SDLActivity shim" >&2 From 2fed6e1346cb7dcb1d739a0bfca188e0aaa68203 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 14:06:38 +0800 Subject: [PATCH 148/331] fix: invoke mobile smoke helpers through bash --- tools/test_renios_ios_bundle.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/test_renios_ios_bundle.sh b/tools/test_renios_ios_bundle.sh index 35cb2743c..6ce520637 100644 --- a/tools/test_renios_ios_bundle.sh +++ b/tools/test_renios_ios_bundle.sh @@ -26,12 +26,12 @@ fi mobile_root="${RENPY_MOBILE_TEST_ROOT:-}" if [[ -z "$mobile_root" ]]; then - "$launcher_probe" + bash "$launcher_probe" echo "Renios iOS bundle boundary validation ok (archive inspection not requested)" exit 0 fi -"$launcher_probe" "$mobile_root" "${RENPY_MOBILE_TEST_CONFIG:-release}" +bash "$launcher_probe" "$mobile_root" "${RENPY_MOBILE_TEST_CONFIG:-release}" prototype="$mobile_root/renios/prototype" prebuilt="$prototype/prebuilt/${RENPY_MOBILE_TEST_CONFIG:-release}" From 69ddb916ea1d07e8cac1893f3ed08b6345254ec6 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 14:17:17 +0800 Subject: [PATCH 149/331] fix: accept RENPY_MOBILE_ROOT in Android build --- scripts/build_android.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 6b8d97715..95fe27b83 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -245,9 +245,9 @@ stage_renpy_android_support() { ;; esac - local mobile_root="${AETHERKIRI_RENPY_MOBILE_ROOT:-}" + local mobile_root="${AETHERKIRI_RENPY_MOBILE_ROOT:-${RENPY_MOBILE_ROOT:-}}" if [[ -z "$mobile_root" ]]; then - echo "Error: AETHERKIRI_ENABLE_RENPY=ON requires AETHERKIRI_RENPY_MOBILE_ROOT" >&2 + echo "Error: AETHERKIRI_ENABLE_RENPY=ON requires AETHERKIRI_RENPY_MOBILE_ROOT or RENPY_MOBILE_ROOT" >&2 echo " Stage official RAPT inputs with tools/install_renpy_mobile_support.sh first." >&2 exit 1 fi From 139e601a2eb56db263299c380cfd451bf179691c Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 15:07:07 +0800 Subject: [PATCH 150/331] ci: retry iOS Rust target downloads --- .github/workflows/build.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index acb13e70e..f960ec590 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -477,7 +477,11 @@ jobs: brew install cmake ninja pkg-config autoconf automake autoconf-archive libtool gettext coreutils bison nasm ccache mono echo "$(brew --prefix gettext)/bin" >> $GITHUB_PATH echo "$(brew --prefix bison)/bin" >> $GITHUB_PATH - rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios + for attempt in 1 2 3 4 5; do + rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios && break + if [[ "$attempt" == 5 ]]; then exit 1; fi + sleep $((attempt * 15)) + done - name: Stage official Ren'Py iOS support inputs From 072873b3dccfdffb1bcc80cc88fc58919e19f95d Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 15:44:32 +0800 Subject: [PATCH 151/331] fix: inspect Renios archive symbols with nm --- tools/test_renios_ios_launcher.sh | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tools/test_renios_ios_launcher.sh b/tools/test_renios_ios_launcher.sh index 56d1ffcd0..8141d0d92 100644 --- a/tools/test_renios_ios_launcher.sh +++ b/tools/test_renios_ios_launcher.sh @@ -27,7 +27,19 @@ done has_symbol() { local archive="$1" symbol="$2" - strings "$archive" | grep -Fqx "$symbol" + # Apple's strings only scans initialized data by default. In the Renios + # Mach-O archive that includes the archive symbol index (so + # _launcher_main is found) but can skip the undefined _Py_RunMain entry in + # the member object. Prefer the symbol table, and retain a full-file + # strings fallback for portable fixture archives and hosts without nm. + if command -v nm >/dev/null 2>&1; then + local symbols + if symbols="$(nm "$archive" 2>/dev/null)"; then + awk -v wanted="$symbol" '$NF == wanted { found = 1 } END { exit !found }' <<<"$symbols" + return $? + fi + fi + strings -a "$archive" | grep -Fqx "$symbol" } # librenpython.a exports the launcher, but its relocation set calls From d8be4a16549e3fdd0c5b4cb6eb0e78abb6833264 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 16:41:09 +0800 Subject: [PATCH 152/331] fix: keep Renios native closure linking opt-in --- scripts/build_ios.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index c5a9a27b4..808a91d7f 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -312,6 +312,13 @@ renios_enabled() { esac } +renios_link_enabled() { + case "${AETHERKIRI_RENPY_RENIOS_LINK:-OFF}" in + ON|TRUE|YES|1|on|true|yes) return 0 ;; + *) return 1 ;; + esac +} + renios_prototype_root() { [[ -n "$RENPY_MOBILE_ROOT" ]] || return 1 local root="$RENPY_MOBILE_ROOT/renios/prototype" @@ -552,7 +559,7 @@ combine_ios_static_extension() { # prototype's application entrypoint and test harness, # which must never be introduced into the Godot application. local renios_prebuilt="" - if renios_enabled; then + if renios_enabled && renios_link_enabled; then renios_prebuilt="$(renios_prebuilt_root 2>/dev/null || true)" if [[ -n "$renios_prebuilt" ]]; then local renios_archive @@ -618,7 +625,7 @@ stage_force_load_plugin_archives() { # Renios supplies the iOS SDL archive in its merged native closure. # Do not copy/force-load the vcpkg SDL archive as well, otherwise the # final Xcode link sees duplicate SDL symbols. - if renios_enabled && [[ "$(basename "$source")" == "libSDL2.a" ]]; then + if renios_enabled && renios_link_enabled && [[ "$(basename "$source")" == "libSDL2.a" ]]; then continue fi resolved="$source" @@ -753,7 +760,7 @@ patch_ios_export_project() { flags+=" -Wl,-force_load,Aether/bin/ios/$export_build_type/$IOS_SDK_COMPAT_ARCHIVE" local archive for archive in "${FORCE_LOAD_PLUGIN_ARCHIVES[@]}"; do - if renios_enabled && [[ "$archive" == "libSDL2.a" ]]; then + if renios_enabled && renios_link_enabled && [[ "$archive" == "libSDL2.a" ]]; then continue fi flags+=" -Wl,-force_load,Aether/bin/ios/$export_build_type/$archive" From 0c489e61be42d33cd199c03b76cb93d4ea7372b0 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 17:46:18 +0800 Subject: [PATCH 153/331] build: add RenPy mobile lifecycle rebuild contract --- .../renpy_runtime/mobile_launcher/README.md | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/README.md b/bridge/renpy_runtime/mobile_launcher/README.md index a41d6fb2a..7daaca51d 100644 --- a/bridge/renpy_runtime/mobile_launcher/README.md +++ b/bridge/renpy_runtime/mobile_launcher/README.md @@ -51,8 +51,38 @@ and make every lifecycle call return promptly. The calls must not invoke or create an Android Activity. `frame` returns a borrowed RGBA view for the host to copy; the host callback and ownership rules are in the header. -The header is only an ABI contract. There is no stub implementation that could -be mistaken for a functioning engine. +The header is only an ABI contract. The `.c.template` files under +`patches/android` and `patches/ios` are deliberately non-playable starting +points: init returns `RENPY_MOBILE_NOT_IMPLEMENTED`, all runtime calls remain +invalid until a real implementation initializes them, and no output object is +copied into a mobile package. + +`build.sh --compile-contract` compiles those templates with `-Wall -Wextra +-Werror` on the host so CI can validate the C ABI and required exported names. +This is a source-level check, not a Ren'Py SDK rebuild: + +```text +bridge/renpy_runtime/mobile_launcher/build.sh --compile-contract \ + --renpy-build /path/to/renpy-build --output-dir /tmp/renpy-mobile-contract +``` + +The native templates identify the real remaining patch points. In addition to +factoring the C launcher, Ren'Py's Python `renpy/bootstrap.py`, `renpy/main.py`, +and `renpy/display/core.py` must be made cooperatively resumable. Their current +call chain enters `renpy.execution.run_context(True)` and +`Interface.interact_core`, both of which keep control until a script or user +interaction completes. Splitting only `Py_InitializeFromConfig` from +`Py_RunMain` does not make a frame-driven engine. Android also exits through +`android.activity.finishAndRemoveTask()` and Java `System.exit(0)` in +`bootstrap.py` cleanup, which an embedded fork must replace with host-owned +shutdown. + +The host-side `src/renpy_mobile_loader.cpp` resolves this ABI when a real +fork is supplied: Android loads all seven symbols from `librenpython.so`, and +iOS uses weak imports from the optional `librenpython.a`. The mobile provider +forwards lifecycle and input calls and copies the borrowed RGBA frame into the +normal Aether frame API. With the official blocking archive, symbol resolution +fails and the provider remains `ENGINE_RESULT_NOT_SUPPORTED`. ## Rebuild and replacement hook From cc3356c0ac4d4b9d6d31980ab37e35d9399da044 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 17:46:53 +0800 Subject: [PATCH 154/331] build: add RenPy mobile lifecycle rebuild contract --- bridge/renpy_runtime/mobile_launcher/build.sh | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/bridge/renpy_runtime/mobile_launcher/build.sh b/bridge/renpy_runtime/mobile_launcher/build.sh index 75b9704c4..e54a737e5 100644 --- a/bridge/renpy_runtime/mobile_launcher/build.sh +++ b/bridge/renpy_runtime/mobile_launcher/build.sh @@ -14,6 +14,7 @@ usage() { cat <<'USAGE' Usage: build.sh --check --renpy-build PATH + build.sh --compile-contract --renpy-build PATH [--output-dir PATH] build.sh --print-plan --renpy-build PATH build.sh --install --renpy-build PATH --stage PATH \ [--android-so-arm64 PATH] [--android-so-armv7 PATH] @@ -21,6 +22,8 @@ Usage: --check validates the official source layout and confirms that the current launcher is still the blocking SDL_main/launcher_main implementation. +--compile-contract compiles the Android/iOS templates as contract-only objects; +those objects deliberately return NOT_IMPLEMENTED and are never packaged. --print-plan prints the upstream renpy-build commands and replacement paths. --install copies caller-supplied, already-built lifecycle artifacts into the staged RAPT/Renios tree after checking the required exported symbol names. @@ -38,10 +41,11 @@ android_so_armv7="" android_so_x86_64="" ios_debug_a="" ios_release_a="" +output_dir="" while [[ $# -gt 0 ]]; do case "$1" in - --check|--print-plan|--install) + --check|--compile-contract|--print-plan|--install) [[ -z "$mode" ]] || { echo "choose one mode" >&2; exit 2; } mode="${1#--}" ;; @@ -53,6 +57,10 @@ while [[ $# -gt 0 ]]; do [[ $# -ge 2 ]] || { echo "--stage requires a path" >&2; exit 2; } stage="$2"; shift ;; + --output-dir) + [[ $# -ge 2 ]] || { echo "--output-dir requires a path" >&2; exit 2; } + output_dir="$2"; shift + ;; --android-so|--android-so-arm64) [[ $# -ge 2 ]] || { echo "--android-so-arm64 requires a path" >&2; exit 2; } android_so_arm64="$2"; shift @@ -161,6 +169,22 @@ if [[ "$mode" == "check" ]]; then exit 0 fi +if [[ "$mode" == "compile-contract" ]]; then + compiler="${CC:-cc}" + output_dir="${output_dir:-$repo_root/out/renpy-mobile-contract}" + mkdir -p "$output_dir" + common=(-std=c11 -Wall -Wextra -Werror -fPIC -I"$repo_root/bridge/renpy_runtime/mobile_launcher/include" -DAETHERKIRI_RENPY_LIFECYCLE_CONTRACT_ONLY=1) + "$compiler" "${common[@]}" -x c -c \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/android/librenpython_android_host.c.template" \ + -o "$output_dir/librenpython_android_host.o" + "$compiler" "${common[@]}" -x c -c \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/ios/librenpython_ios_host.c.template" \ + -o "$output_dir/librenpython_ios_host.o" + echo "compiled contract-only launcher objects in $output_dir" + echo "these objects intentionally return RENPY_MOBILE_NOT_IMPLEMENTED and are not runtime artifacts" + exit 0 +fi + print_plan [[ "$mode" == "print-plan" ]] && exit 0 @@ -178,6 +202,10 @@ required_symbols=( check_symbols() { local artifact="$1" [[ -f "$artifact" ]] || { echo "artifact not found: $artifact" >&2; exit 1; } + if strings "$artifact" | grep -Fq 'AETHERKIRI_RENPY_LIFECYCLE_STUB'; then + echo "refusing contract-only lifecycle stub as runtime artifact: $artifact" >&2 + exit 1 + fi local format format="$(file -b "$artifact" 2>/dev/null || true)" for symbol in "${required_symbols[@]}"; do From 5a72db75421470fc6f286d111af87df208f71388 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 17:47:25 +0800 Subject: [PATCH 155/331] build: add RenPy mobile lifecycle rebuild contract --- .../mobile_launcher/patches/README.md | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/README.md b/bridge/renpy_runtime/mobile_launcher/patches/README.md index 7a8766aee..b3c93a9a1 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/README.md @@ -20,6 +20,23 @@ Required review checks for that patch: 6. the resulting archives pass symbol checks, host unit tests, and device or simulator smoke tests before replacing staged artifacts -No patch is checked in yet: adding a guessed implementation here would make a -non-playable stub look like a runtime. `build.sh --install` is the deliberate -handoff point for the first audited fork. +`android/librenpython_android_host.c.template` and +`ios/librenpython_ios_host.c.template` provide compile-tested C entrypoints and +argument validation for the first fork. They explicitly return +`RENPY_MOBILE_NOT_IMPLEMENTED`, define `AETHERKIRI_RENPY_LIFECYCLE_STUB`, and are +compiled only as contract-test objects. They are never installed as runtime +archives. `build.sh --install` remains the handoff for an audited, functional +fork rebuilt with the official SDK toolchain. + +The C patch alone is insufficient. The Python loop also has to yield and +resume safely at `renpy/bootstrap.py::bootstrap`, `renpy/main.py::main/run`, +`renpy/execution.py::run_context`, and +`renpy/display/core.py::Interface.interact/interact_core`. A runtime shim that +calls one of these blocking functions from `renpy_mobile_tick` is not a valid +implementation. + +Once an audited fork exports the ABI, the host loader in +`../src/renpy_mobile_loader.cpp` resolves it without using a second SDL, +Activity, or UIKit entrypoint. It is deliberately inert for the official +blocking libraries, so staging those archives alone cannot accidentally enable +mobile gameplay. From d9cff052aa131c62eece952fcd00f545541fb9e3 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 17:48:09 +0800 Subject: [PATCH 156/331] test: compile RenPy mobile lifecycle contract --- tools/test_renpy_mobile_launcher_scaffold.sh | 42 ++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tools/test_renpy_mobile_launcher_scaffold.sh b/tools/test_renpy_mobile_launcher_scaffold.sh index 0eecaeecf..d9126aadd 100644 --- a/tools/test_renpy_mobile_launcher_scaffold.sh +++ b/tools/test_renpy_mobile_launcher_scaffold.sh @@ -6,12 +6,22 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" scaffold="$repo_root/bridge/renpy_runtime/mobile_launcher" script="$scaffold/build.sh" header="$scaffold/include/renpy_mobile_launcher.h" +loader="$scaffold/src/renpy_mobile_loader.cpp" renpy_build="${RENPY_BUILD_TEST_ROOT:-/tmp/renpy-build-src}" [[ -x "$script" ]] || { echo "missing launcher scaffold script" >&2; exit 1; } [[ -f "$header" ]] || { echo "missing launcher lifecycle header" >&2; exit 1; } +[[ -f "$loader" ]] || { echo "missing host lifecycle loader" >&2; exit 1; } bash -n "$script" +grep -Fq 'librenpython.so' "$loader" +grep -Fq 'weak_import' "$loader" +for symbol in \ + renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input \ + renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown; do + grep -Fq "$symbol" "$loader" +done + for symbol in \ renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input \ renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown; do @@ -21,6 +31,19 @@ done grep -Fq 'Py_RunMain' "$scaffold/README.md" grep -Fq 'SDL_main' "$scaffold/README.md" grep -Fq 'renpy-build' "$scaffold/patches/README.md" +for template in \ + "$scaffold/patches/android/librenpython_android_host.c.template" \ + "$scaffold/patches/ios/librenpython_ios_host.c.template"; do + [[ -f "$template" ]] || { echo "missing native template: $template" >&2; exit 1; } + for symbol in \ + renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input \ + renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown; do + grep -Fq "$symbol" "$template" + done + # Templates must not accidentally reintroduce process entrypoints. + ! grep -Eq '(^|[^[:alnum:]_])(Py_RunMain|SDL_main|SDL_RunApp|SDL_UIKitRunApp|UIApplicationMain)[[:space:]]*\(' "$template" + grep -Fq 'AETHERKIRI_RENPY_LIFECYCLE_STUB' "$template" +done if [[ ! -d "$renpy_build" ]]; then echo "Ren'Py build checkout not present; metadata-only scaffold test ok" @@ -31,6 +54,24 @@ output="$($script --check --renpy-build "$renpy_build")" grep -Fq 'source check ok' <<<"$output" grep -Fq 'no artifact was built or installed' <<<"$output" +contract_dir="$(mktemp -d "${TMPDIR:-/tmp}/renpy-mobile-contract.XXXXXX")" +trap 'rm -rf "$contract_dir"' EXIT +$script --compile-contract --renpy-build "$renpy_build" --output-dir "$contract_dir" >/dev/null +for object in "$contract_dir"/*.o; do + for symbol in \ + renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input \ + renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown; do + nm -g --defined-only "$object" | awk '{print $3}' | grep -Fxq "$symbol" + done +done +if "$script" --install --renpy-build "$renpy_build" --stage "$contract_dir/stage" \ + --android-so-arm64 "$contract_dir/librenpython_android_host.o" \ + >"$contract_dir/install.stdout" 2>"$contract_dir/install.stderr"; then + echo "contract-only lifecycle object was accepted as a runtime artifact" >&2 + exit 1 +fi +grep -Fq 'contract-only lifecycle stub' "$contract_dir/install.stderr" + if "$script" --check --renpy-build "$renpy_build" | grep -Fq 'SDL_main'; then : else @@ -39,3 +80,4 @@ else fi echo "Ren'Py mobile launcher scaffold/source smoke ok" +bash "$repo_root/tools/test_renpy_mobile_launcher_loader.sh" From 69ece1e8858ae6abf9b571accb39654fad4f82d3 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 17:49:01 +0800 Subject: [PATCH 157/331] build: add RenPy mobile lifecycle contract templates --- .../librenpython_android_host.c.template | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/android/librenpython_android_host.c.template diff --git a/bridge/renpy_runtime/mobile_launcher/patches/android/librenpython_android_host.c.template b/bridge/renpy_runtime/mobile_launcher/patches/android/librenpython_android_host.c.template new file mode 100644 index 000000000..622af47af --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/android/librenpython_android_host.c.template @@ -0,0 +1,74 @@ +/* + * Contract-only Android native fork template. + * + * Start from renpy-build/runtime/librenpython_android.c and factor its + * init_environ(), call_prepare_python(), SDL setup, and Python config code into + * renpy_mobile_init(). Then replace these deliberate NOT_IMPLEMENTED bodies + * with the real cooperative Ren'Py loop. This file is compiled only by the + * scaffold source test; it is never packaged as librenpython.so. + */ +#define AETHERKIRI_RENPY_LIFECYCLE_STUB 1 +#if defined(__GNUC__) +__attribute__((used)) +#endif +static const char kAetherkiriLifecycleStubMarker[] = "AETHERKIRI_RENPY_LIFECYCLE_STUB"; +#include "renpy_mobile_launcher.h" + +static int lifecycle_started; + +int renpy_mobile_init(const renpy_mobile_config_t *config, + const renpy_mobile_host_t *host) { + if (config == 0 || host == 0 || config->struct_size < sizeof(*config) || + host->struct_size < sizeof(*host) || + config->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || + host->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION) { + return RENPY_MOBILE_INVALID_ARGUMENT; + } + if (lifecycle_started) return RENPY_MOBILE_INVALID_STATE; + + /* TODO(real fork): + * - copy Android paths into owned storage + * - initialize SDL against the host-owned surface/context + * - call Python pre-initialization and init_librenpy() + * - import the Ren'Py bootstrap without calling the blocking Py_RunMain entrypoint + */ + return RENPY_MOBILE_NOT_IMPLEMENTED; +} + +int renpy_mobile_tick(uint32_t budget_ms) { + (void)budget_ms; + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +int renpy_mobile_frame(renpy_mobile_frame_t *out_frame) { + if (out_frame == 0 || out_frame->struct_size < sizeof(*out_frame)) { + return RENPY_MOBILE_INVALID_ARGUMENT; + } + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +int renpy_mobile_input(const renpy_mobile_input_t *event) { + if (event == 0 || event->struct_size < sizeof(*event)) { + return RENPY_MOBILE_INVALID_ARGUMENT; + } + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +int renpy_mobile_pause(void) { + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +int renpy_mobile_resume(void) { + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +void renpy_mobile_shutdown(void) { + /* TODO(real fork): stop the cooperative Python/SDL loop and finalize in + * the same thread that owns the interpreter. */ + lifecycle_started = 0; +} From 4d509842f6d00645ae57bf255c24073da0a15fd7 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 17:49:16 +0800 Subject: [PATCH 158/331] build: add RenPy mobile lifecycle contract templates --- .../ios/librenpython_ios_host.c.template | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/ios/librenpython_ios_host.c.template diff --git a/bridge/renpy_runtime/mobile_launcher/patches/ios/librenpython_ios_host.c.template b/bridge/renpy_runtime/mobile_launcher/patches/ios/librenpython_ios_host.c.template new file mode 100644 index 000000000..88336d248 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/ios/librenpython_ios_host.c.template @@ -0,0 +1,73 @@ +/* + * Contract-only Renios native fork template. + * + * Start from renpy-build/runtime/librenpython.c and factor its + * preinitialize(), search_python_home(), Python config, and Ren'Py bootstrap + * code into renpy_mobile_init(). The UIKit prototype main.c must remain out + * of the Godot target. This file is compiled only by the scaffold source + * test; it is never packaged as librenpython.a. + */ +#define AETHERKIRI_RENPY_LIFECYCLE_STUB 1 +#if defined(__GNUC__) +__attribute__((used)) +#endif +static const char kAetherkiriLifecycleStubMarker[] = "AETHERKIRI_RENPY_LIFECYCLE_STUB"; +#include "renpy_mobile_launcher.h" + +static int lifecycle_started; + +int renpy_mobile_init(const renpy_mobile_config_t *config, + const renpy_mobile_host_t *host) { + if (config == 0 || host == 0 || config->struct_size < sizeof(*config) || + host->struct_size < sizeof(*host) || + config->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || + host->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION) { + return RENPY_MOBILE_INVALID_ARGUMENT; + } + if (lifecycle_started) return RENPY_MOBILE_INVALID_STATE; + + /* TODO(real fork): + * - initialize Python/Ren'Py without launcher_main or Py_RunMain + * - use the existing Godot Metal/SDL surface instead of + * SDL_RunApp, SDL_UIKitRunApp, or UIApplicationMain + * - drive one bounded display/input step from renpy.display.core.Interface + */ + return RENPY_MOBILE_NOT_IMPLEMENTED; +} + +int renpy_mobile_tick(uint32_t budget_ms) { + (void)budget_ms; + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +int renpy_mobile_frame(renpy_mobile_frame_t *out_frame) { + if (out_frame == 0 || out_frame->struct_size < sizeof(*out_frame)) { + return RENPY_MOBILE_INVALID_ARGUMENT; + } + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +int renpy_mobile_input(const renpy_mobile_input_t *event) { + if (event == 0 || event->struct_size < sizeof(*event)) { + return RENPY_MOBILE_INVALID_ARGUMENT; + } + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +int renpy_mobile_pause(void) { + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +int renpy_mobile_resume(void) { + return lifecycle_started ? RENPY_MOBILE_NOT_IMPLEMENTED + : RENPY_MOBILE_INVALID_STATE; +} + +void renpy_mobile_shutdown(void) { + /* TODO(real fork): stop/finalize Python and Ren'Py on the owning thread. */ + lifecycle_started = 0; +} From 13f6474d5b7bd830e0992a882d3f8eb14b476c6e Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 17:58:01 +0800 Subject: [PATCH 159/331] feat: add mobile RenPy lifecycle loader contract --- bridge/renpy_runtime/CMakeLists.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bridge/renpy_runtime/CMakeLists.txt b/bridge/renpy_runtime/CMakeLists.txt index 26dc2acfa..a01e22114 100644 --- a/bridge/renpy_runtime/CMakeLists.txt +++ b/bridge/renpy_runtime/CMakeLists.txt @@ -10,7 +10,8 @@ if(IOS OR ANDROID) # and lifecycle/rendering adapters are implemented. set(AETHERKIRI_RENPY_SOURCES src/renpy_runtime_mobile_provider.cpp - src/renpy_runtime_ios_adapter.cpp) + src/renpy_runtime_ios_adapter.cpp + mobile_launcher/src/renpy_mobile_loader.cpp) if(ANDROID) list(APPEND AETHERKIRI_RENPY_SOURCES src/renpy_mobile_adapter.cpp) endif() @@ -49,6 +50,7 @@ else() endif() target_include_directories(aether_renpy_runtime PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_CURRENT_SOURCE_DIR}/mobile_launcher/include" "${CMAKE_CURRENT_SOURCE_DIR}/../../abi/include") if(NOT TARGET engine_api) message(FATAL_ERROR "Ren'Py provider requires the engine_api target") From 757c14e20ee01f5a4881f19ca8dce594dee6db20 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 17:58:19 +0800 Subject: [PATCH 160/331] feat: add mobile RenPy lifecycle loader contract From 3af6c432c489679ef7f29620335267b158ab17de Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 17:58:37 +0800 Subject: [PATCH 161/331] feat: add mobile RenPy lifecycle loader contract --- .../include/renpy_mobile_loader.h | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_loader.h diff --git a/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_loader.h b/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_loader.h new file mode 100644 index 000000000..b3c69fc70 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_loader.h @@ -0,0 +1,69 @@ +#ifndef AETHERKIRI_RENPY_MOBILE_LOADER_H +#define AETHERKIRI_RENPY_MOBILE_LOADER_H + +#include "renpy_mobile_launcher.h" + +#include + +namespace aetherkiri::renpy::mobile { + +/* + * Loads the optional host-owned launcher ABI from the rebuilt mobile payload. + * + * The official RAPT/Renios archives do not export these functions, so an + * unavailable loader is an expected and safe state. Android resolves the + * symbols from librenpython.so; iOS uses weak imports so an app without the + * rebuilt Renios archive still links. This class never falls back to + * SDL_main, launcher_main, Py_RunMain, or an application entrypoint. + */ +class Launcher final { + public: + Launcher(); + ~Launcher(); + + Launcher(const Launcher&) = delete; + Launcher& operator=(const Launcher&) = delete; + + bool available(); + const std::string& last_error() const { return last_error_; } + + int Init(const renpy_mobile_config_t& config, + const renpy_mobile_host_t& host); + int Tick(uint32_t budget_ms); + int Frame(renpy_mobile_frame_t* out_frame); + int Input(const renpy_mobile_input_t& event); + int Pause(); + int Resume(); + void Shutdown(); + + bool initialized() const { return initialized_; } + + private: + bool Resolve(); + void Unload(); + + using InitFn = int (*)(const renpy_mobile_config_t*, + const renpy_mobile_host_t*); + using TickFn = int (*)(uint32_t); + using FrameFn = int (*)(renpy_mobile_frame_t*); + using InputFn = int (*)(const renpy_mobile_input_t*); + using PauseFn = int (*)(); + using ResumeFn = int (*)(); + using ShutdownFn = void (*)(); + + void* library_handle_ = nullptr; + InitFn init_ = nullptr; + TickFn tick_ = nullptr; + FrameFn frame_ = nullptr; + InputFn input_ = nullptr; + PauseFn pause_ = nullptr; + ResumeFn resume_ = nullptr; + ShutdownFn shutdown_ = nullptr; + bool resolved_ = false; + bool initialized_ = false; + std::string last_error_; +}; + +} // namespace aetherkiri::renpy::mobile + +#endif // AETHERKIRI_RENPY_MOBILE_LOADER_H From 7f8c9d90fb725443ed496d9e08559e0c1d61fe88 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 17:58:54 +0800 Subject: [PATCH 162/331] feat: add mobile RenPy lifecycle loader contract From 69e7471c617da56eccb0fcdf49b7942daba3f5d7 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 17:59:12 +0800 Subject: [PATCH 163/331] feat: add mobile RenPy lifecycle loader contract --- .../src/renpy_mobile_loader.cpp | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp diff --git a/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp new file mode 100644 index 000000000..f610e482a --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp @@ -0,0 +1,154 @@ +#include "renpy_mobile_loader.h" + +#include + +#if defined(__ANDROID__) +#include +#elif defined(__APPLE__) +/* The optional lifecycle fork is linked as a static Renios archive. Weak + * imports keep the normal staged/official archive linkable until that fork is + * supplied. */ +extern "C" { +int renpy_mobile_init(const renpy_mobile_config_t*, + const renpy_mobile_host_t*) __attribute__((weak_import)); +int renpy_mobile_tick(uint32_t) __attribute__((weak_import)); +int renpy_mobile_frame(renpy_mobile_frame_t*) __attribute__((weak_import)); +int renpy_mobile_input(const renpy_mobile_input_t*) __attribute__((weak_import)); +int renpy_mobile_pause(void) __attribute__((weak_import)); +int renpy_mobile_resume(void) __attribute__((weak_import)); +void renpy_mobile_shutdown(void) __attribute__((weak_import)); +} +#endif + +namespace aetherkiri::renpy::mobile { +namespace { + +constexpr int kOk = RENPY_MOBILE_OK; + +#if defined(__ANDROID__) +template +Function ResolveSymbol(void* handle, const char* name) { + return reinterpret_cast(dlsym(handle, name)); +} +#endif + +} // namespace + +Launcher::Launcher() = default; + +Launcher::~Launcher() { + Shutdown(); + Unload(); +} + +bool Launcher::Resolve() { + if (resolved_) return init_ != nullptr; + resolved_ = true; + +#if defined(__ANDROID__) + const char* library_name = "librenpython.so"; + library_handle_ = dlopen(library_name, RTLD_NOW | RTLD_LOCAL); + if (!library_handle_) { + const char* detail = dlerror(); + last_error_ = "host lifecycle launcher unavailable: could not load "; + last_error_ += library_name; + if (detail) { + last_error_ += ": "; + last_error_ += detail; + } + return false; + } + init_ = ResolveSymbol(library_handle_, "renpy_mobile_init"); + tick_ = ResolveSymbol(library_handle_, "renpy_mobile_tick"); + frame_ = ResolveSymbol(library_handle_, "renpy_mobile_frame"); + input_ = ResolveSymbol(library_handle_, "renpy_mobile_input"); + pause_ = ResolveSymbol(library_handle_, "renpy_mobile_pause"); + resume_ = ResolveSymbol(library_handle_, "renpy_mobile_resume"); + shutdown_ = ResolveSymbol(library_handle_, "renpy_mobile_shutdown"); +#elif defined(__APPLE__) + init_ = renpy_mobile_init; + tick_ = renpy_mobile_tick; + frame_ = renpy_mobile_frame; + input_ = renpy_mobile_input; + pause_ = renpy_mobile_pause; + resume_ = renpy_mobile_resume; + shutdown_ = renpy_mobile_shutdown; +#else + last_error_ = "host lifecycle launcher unavailable on this platform"; +#endif + + if (!init_ || !tick_ || !frame_ || !input_ || !pause_ || !resume_ || + !shutdown_) { + last_error_ = + "host lifecycle launcher unavailable: rebuilt Ren'Py payload must " + "export renpy_mobile_init/tick/frame/input/pause/resume/shutdown"; + Unload(); + return false; + } + return true; +} + +bool Launcher::available() { + return Resolve(); +} + +int Launcher::Init(const renpy_mobile_config_t& config, + const renpy_mobile_host_t& host) { + if (!Resolve()) return RENPY_MOBILE_NOT_IMPLEMENTED; + if (initialized_) return RENPY_MOBILE_INVALID_STATE; + const int result = init_(&config, &host); + if (result == kOk) { + initialized_ = true; + } else { + last_error_ = "host lifecycle launcher init failed with status " + + std::to_string(result); + } + return result; +} + +int Launcher::Tick(uint32_t budget_ms) { + if (!initialized_ || !tick_) return RENPY_MOBILE_INVALID_STATE; + return tick_(budget_ms); +} + +int Launcher::Frame(renpy_mobile_frame_t* out_frame) { + if (!initialized_ || !frame_ || !out_frame) return RENPY_MOBILE_INVALID_STATE; + return frame_(out_frame); +} + +int Launcher::Input(const renpy_mobile_input_t& event) { + if (!initialized_ || !input_) return RENPY_MOBILE_INVALID_STATE; + return input_(&event); +} + +int Launcher::Pause() { + if (!initialized_ || !pause_) return RENPY_MOBILE_INVALID_STATE; + return pause_(); +} + +int Launcher::Resume() { + if (!initialized_ || !resume_) return RENPY_MOBILE_INVALID_STATE; + return resume_(); +} + +void Launcher::Shutdown() { + if (initialized_ && shutdown_) shutdown_(); + initialized_ = false; +} + +void Launcher::Unload() { +#if defined(__ANDROID__) + if (library_handle_) dlclose(library_handle_); +#endif + library_handle_ = nullptr; + init_ = nullptr; + tick_ = nullptr; + frame_ = nullptr; + input_ = nullptr; + pause_ = nullptr; + resume_ = nullptr; + shutdown_ = nullptr; + resolved_ = false; +} + +} // namespace aetherkiri::renpy::mobile From 01388c6e4fbcb7228eec21a20183519131c9d784 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 17:59:35 +0800 Subject: [PATCH 164/331] feat: add mobile RenPy lifecycle loader contract --- .../src/renpy_runtime_mobile_provider.cpp | 383 ++++++++++++++---- 1 file changed, 294 insertions(+), 89 deletions(-) diff --git a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp index 38c7a7715..754dfdc45 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp @@ -1,5 +1,6 @@ #include "renpy_runtime.h" #include "engine_runtime_provider.h" +#include "renpy_mobile_loader.h" #if defined(__ANDROID__) #include "renpy_mobile_adapter.h" #endif @@ -7,14 +8,14 @@ #include "renpy_runtime_ios_adapter.h" #endif +#include #include +#include #include #include #include +#include -// This translation unit is selected only for IOS/ANDROID by CMake. Keep a -// compile-time guard here as well so a future source-list change cannot turn -// the mobile placeholder into an accidental desktop build. #if !defined(__ANDROID__) && !defined(__APPLE__) #error "Ren'Py mobile provider must only be compiled for Android or Apple" #endif @@ -22,27 +23,112 @@ namespace aetherkiri::renpy { namespace { +using mobile::Launcher; + struct MobileRuntime final { engine_runtime_host_v1_t host{}; + Launcher launcher; #if defined(__ANDROID__) mobile::BootstrapAdapter bootstrap; #endif + std::mutex frame_mutex; + std::vector frame_pixels; + uint32_t frame_width = 0; + uint32_t frame_height = 0; + uint32_t frame_stride = 0; + uint64_t frame_serial = 0; + uint64_t delivered_serial = 0; + bool frame_rendered = false; + bool opened = false; + uint32_t surface_width = 0; + uint32_t surface_height = 0; std::string error = - "Ren'Py mobile runtime is not supported: official RAPT/Renios inputs " - "may be staged, but the native adapter is not linked"; + "Ren'Py mobile runtime is not supported: official RAPT/Renios " + "inputs do not export the host lifecycle ABI"; }; MobileRuntime* Cast(void* value) { return static_cast(value); } -int32_t Probe(void*, const char*) { - // A zero score prevents the host from auto-selecting a provider that - // cannot execute a game. Explicit runtime=renpy still reaches Open and - // gets the same deterministic NOT_SUPPORTED result below. +engine_result_t MapLauncherStatus(int status) { + switch (status) { + case RENPY_MOBILE_OK: + return ENGINE_RESULT_OK; + case RENPY_MOBILE_INVALID_ARGUMENT: + return ENGINE_RESULT_INVALID_ARGUMENT; + case RENPY_MOBILE_INVALID_STATE: + return ENGINE_RESULT_INVALID_STATE; + case RENPY_MOBILE_NOT_IMPLEMENTED: + return ENGINE_RESULT_NOT_SUPPORTED; + default: + return ENGINE_RESULT_INTERNAL_ERROR; + } +} + +int PresentRgba(void* user_data, const uint8_t* rgba, uint32_t width, + uint32_t height, uint32_t stride) { + auto* runtime = static_cast(user_data); + if (!runtime || !rgba || width == 0 || height == 0 || + width > std::numeric_limits::max() / 4u || + stride < width * 4u) { + return -1; + } + const size_t size = static_cast(stride) * height; + if (height != 0 && size / height != stride) return -1; + std::lock_guard lock(runtime->frame_mutex); + runtime->frame_pixels.assign(rgba, rgba + size); + runtime->frame_width = width; + runtime->frame_height = height; + runtime->frame_stride = stride; + ++runtime->frame_serial; + runtime->frame_rendered = true; return 0; } +void LogUtf8(void* user_data, int level, const char* message) { + (void)level; + auto* runtime = static_cast(user_data); + if (runtime && message) runtime->error = message; +} + +engine_result_t CaptureFrame(MobileRuntime* runtime) { + renpy_mobile_frame_t frame{}; + frame.struct_size = sizeof(frame); + const int status = runtime->launcher.Frame(&frame); + if (status != RENPY_MOBILE_OK) { + // A launcher may deliver frames exclusively through present_rgba. A + // missing frame is therefore not a tick failure. + return status == RENPY_MOBILE_INVALID_STATE ? ENGINE_RESULT_OK + : MapLauncherStatus(status); + } + if (!frame.rgba || frame.width == 0 || frame.height == 0 || + frame.width > std::numeric_limits::max() / 4u || + frame.stride < frame.width * 4u) { + return ENGINE_RESULT_OK; + } + const size_t size = static_cast(frame.stride) * frame.height; + if (frame.height != 0 && size / frame.height != frame.stride) { + runtime->error = "Ren'Py mobile frame stride overflows host size"; + return ENGINE_RESULT_INTERNAL_ERROR; + } + std::lock_guard lock(runtime->frame_mutex); + runtime->frame_pixels.assign(frame.rgba, frame.rgba + size); + runtime->frame_width = frame.width; + runtime->frame_height = frame.height; + runtime->frame_stride = frame.stride; + runtime->frame_serial = frame.serial != 0 ? frame.serial : runtime->frame_serial + 1; + runtime->frame_rendered = true; + return ENGINE_RESULT_OK; +} + +int32_t Probe(void*, const char*) { + // The provider is discoverable only when a rebuilt lifecycle payload is + // linked. Official blocking RAPT/Renios archives remain invisible here. + static Launcher launcher; + return launcher.available() ? 100 : 0; +} + engine_result_t Create(void*, const engine_runtime_host_v1_t* host, const engine_create_desc_t* desc, void** output) { if (!host || !desc || !output || host->struct_size < sizeof(*host) || @@ -61,119 +147,239 @@ engine_result_t Create(void*, const engine_runtime_host_v1_t* host, } void Destroy(void* value) { + if (!value) return; + auto* runtime = Cast(value); + runtime->launcher.Shutdown(); #if defined(__ANDROID__) - if (value) Cast(value)->bootstrap.Stop(); + runtime->bootstrap.Stop(); #endif - delete Cast(value); + delete runtime; } engine_result_t Open(void* value, const char* game_root_path, const char* startup_script) { - if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; -#if defined(__ANDROID__) + if (!value || !game_root_path || game_root_path[0] == '\0') { + return ENGINE_RESULT_INVALID_ARGUMENT; + } auto* runtime = Cast(value); - mobile::BootstrapRequest request{}; - request.game_root_path_utf8 = game_root_path; - request.startup_script_utf8 = startup_script; - // reserved_ptr[1] is intentionally host-owned. The current Godot - // Android host does not populate it, so the adapter returns - // NOT_SUPPORTED rather than constructing a second Activity. - request.existing_host_activity = runtime->host.reserved_ptr[1]; - const auto result = runtime->bootstrap.Start(request); - runtime->error = runtime->bootstrap.last_error(); - return result; + if (runtime->opened) return ENGINE_RESULT_INVALID_STATE; + + if (!runtime->launcher.available()) { +#if defined(__ANDROID__) + mobile::BootstrapRequest request{}; + request.game_root_path_utf8 = game_root_path; + request.startup_script_utf8 = startup_script; + request.existing_host_activity = runtime->host.reserved_ptr[1]; + const auto result = runtime->bootstrap.Start(request); + runtime->error = runtime->bootstrap.last_error(); + return result; #elif defined(AETHERKIRI_RENPY_IOS) - (void)game_root_path; - (void)startup_script; - const auto* contract = renpy_get_ios_launcher_contract(); - if (renpy_get_ios_inprocess_adapter() == nullptr) { - Cast(value)->error = + const auto* contract = renpy_get_ios_launcher_contract(); + runtime->error = "Ren'Py iOS mobile runtime is not supported: the official Renios " - "libraries are staged, but no host-owned in-process adapter is " - "linked (a second UIKit/SDL application entrypoint is forbidden)"; - } else { - Cast(value)->error = - "Ren'Py iOS mobile runtime is not supported: the host-owned Renios " - "adapter is present, but lifecycle, input, and surface rendering " - "integration is not enabled"; - } - if (contract && contract->limitation_utf8) { - Cast(value)->error.append(" "); - Cast(value)->error.append(contract->limitation_utf8); - } + "libraries are staged, but no host-owned lifecycle payload is " + "linked (a second UIKit/SDL entrypoint is forbidden)"; + if (contract && contract->limitation_utf8) { + runtime->error += " "; + runtime->error += contract->limitation_utf8; + } + return ENGINE_RESULT_NOT_SUPPORTED; #else - Cast(value)->error = - "Ren'Py Android runtime is not supported: the official RAPT " - "libraries are staged, but the JNI/Activity and rendering adapters " - "are not linked"; + runtime->error = runtime->launcher.last_error(); + return ENGINE_RESULT_NOT_SUPPORTED; #endif - return ENGINE_RESULT_NOT_SUPPORTED; + } + + renpy_mobile_config_t config{}; + config.struct_size = sizeof(config); + config.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; + config.private_root_utf8 = game_root_path; + config.public_root_utf8 = game_root_path; + config.argv0_utf8 = startup_script ? startup_script : "AetherKiri"; + config.argc = 0; + + renpy_mobile_host_t host{}; + host.struct_size = sizeof(host); + host.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; + host.user_data = runtime; + host.present_rgba = PresentRgba; + host.log_utf8 = LogUtf8; + + const auto result = MapLauncherStatus(runtime->launcher.Init(config, host)); + if (result != ENGINE_RESULT_OK) { + runtime->error = runtime->launcher.last_error(); + return result; + } + runtime->opened = true; + runtime->error.clear(); + return ENGINE_RESULT_OK; } -engine_result_t Unsupported(void* value) { +engine_result_t Tick(void* value, uint32_t delta_ms) { if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; - Cast(value)->error = - "Ren'Py mobile runtime is not available until the native adapter is " - "implemented"; - return ENGINE_RESULT_NOT_SUPPORTED; + auto* runtime = Cast(value); + if (!runtime->opened) return ENGINE_RESULT_INVALID_STATE; + const auto result = MapLauncherStatus(runtime->launcher.Tick(delta_ms)); + if (result != ENGINE_RESULT_OK) { + runtime->error = runtime->launcher.last_error(); + return result; + } + runtime->frame_rendered = false; + const auto frame_result = CaptureFrame(runtime); + if (frame_result != ENGINE_RESULT_OK) return frame_result; + return ENGINE_RESULT_OK; } -engine_result_t UnsupportedTick(void* value, uint32_t) { - return Unsupported(value); +engine_result_t Pause(void* value) { + if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; + auto* runtime = Cast(value); + if (!runtime->opened) return ENGINE_RESULT_INVALID_STATE; + return MapLauncherStatus(runtime->launcher.Pause()); } -engine_result_t UnsupportedOption(void* value, const engine_option_t* option) { - if (!value || !option || !option->key_utf8 || !option->value_utf8) { - return ENGINE_RESULT_INVALID_ARGUMENT; - } - return Unsupported(value); +engine_result_t Resume(void* value) { + if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; + auto* runtime = Cast(value); + if (!runtime->opened) return ENGINE_RESULT_INVALID_STATE; + return MapLauncherStatus(runtime->launcher.Resume()); } -engine_result_t UnsupportedResize(void* value, uint32_t, uint32_t) { - return Unsupported(value); +engine_result_t SetSurfaceSize(void* value, uint32_t width, uint32_t height) { + if (!value || width == 0 || height == 0) return ENGINE_RESULT_INVALID_ARGUMENT; + auto* runtime = Cast(value); + runtime->surface_width = width; + runtime->surface_height = height; + return ENGINE_RESULT_OK; } -engine_result_t UnsupportedFrame(void* value, engine_frame_desc_t*) { - return Unsupported(value); +engine_result_t FrameDesc(void* value, engine_frame_desc_t* output) { + if (!value || !output || output->struct_size < sizeof(*output)) { + return ENGINE_RESULT_INVALID_ARGUMENT; + } + auto* runtime = Cast(value); + std::lock_guard lock(runtime->frame_mutex); + output->width = runtime->frame_width; + output->height = runtime->frame_height; + output->stride_bytes = runtime->frame_stride; + output->pixel_format = ENGINE_PIXEL_FORMAT_RGBA8888; + output->frame_serial = runtime->frame_serial; + return ENGINE_RESULT_OK; } -engine_result_t UnsupportedRead(void* value, void*, size_t) { - return Unsupported(value); +engine_result_t ReadFrame(void* value, void* output, size_t output_size) { + if (!value || !output) return ENGINE_RESULT_INVALID_ARGUMENT; + auto* runtime = Cast(value); + std::lock_guard lock(runtime->frame_mutex); + if (runtime->frame_pixels.empty()) return ENGINE_RESULT_INVALID_STATE; + if (output_size < runtime->frame_pixels.size()) return ENGINE_RESULT_INVALID_ARGUMENT; + std::memcpy(output, runtime->frame_pixels.data(), runtime->frame_pixels.size()); + runtime->delivered_serial = runtime->frame_serial; + return ENGINE_RESULT_OK; } -engine_result_t UnsupportedNativeFrame(void* value, uint64_t*, uint32_t*, - uint32_t*, uint64_t*) { - return Unsupported(value); +engine_result_t NativeFrame(void* value, uint64_t*, uint32_t*, uint32_t*, + uint64_t*) { + // The lifecycle ABI owns an RGBA CPU buffer. It intentionally does not + // expose an SDL/GLES texture that could steal Godot's render target. + return value ? ENGINE_RESULT_NOT_SUPPORTED : ENGINE_RESULT_INVALID_ARGUMENT; } -engine_result_t UnsupportedInput(void* value, const engine_input_event_t*) { - return Unsupported(value); +engine_result_t Input(void* value, const engine_input_event_t* event) { + if (!value || !event || event->struct_size < sizeof(*event)) { + return ENGINE_RESULT_INVALID_ARGUMENT; + } + auto* runtime = Cast(value); + if (!runtime->opened) return ENGINE_RESULT_INVALID_STATE; + renpy_mobile_input_t input{}; + input.struct_size = sizeof(input); + input.timestamp_ns = event->timestamp_micros * 1000ull; + input.device_id = event->pointer_id; + input.code = event->key_code != 0 ? event->key_code : event->button; + input.value = static_cast(event->type); + input.x = static_cast(event->x); + input.y = static_cast(event->y); + input.pressure = (event->type == ENGINE_INPUT_EVENT_POINTER_UP) ? 0.0f : 1.0f; + std::string text; + switch (event->type) { + case ENGINE_INPUT_EVENT_POINTER_DOWN: + case ENGINE_INPUT_EVENT_POINTER_MOVE: + case ENGINE_INPUT_EVENT_POINTER_UP: + case ENGINE_INPUT_EVENT_POINTER_SCROLL: + input.type = RENPY_MOBILE_INPUT_POINTER; + break; + case ENGINE_INPUT_EVENT_KEY_DOWN: + case ENGINE_INPUT_EVENT_KEY_UP: + input.type = RENPY_MOBILE_INPUT_KEY; + break; + case ENGINE_INPUT_EVENT_TEXT_INPUT: { + input.type = RENPY_MOBILE_INPUT_TEXT; + uint32_t cp = event->unicode_codepoint; + if (cp <= 0x7fu) text.push_back(static_cast(cp)); + else if (cp <= 0x7ffu) { + text.push_back(static_cast(0xc0u | (cp >> 6))); + text.push_back(static_cast(0x80u | (cp & 0x3fu))); + } else if (cp <= 0xffffu) { + text.push_back(static_cast(0xe0u | (cp >> 12))); + text.push_back(static_cast(0x80u | ((cp >> 6) & 0x3fu))); + text.push_back(static_cast(0x80u | (cp & 0x3fu))); + } else if (cp <= 0x10ffffu) { + text.push_back(static_cast(0xf0u | (cp >> 18))); + text.push_back(static_cast(0x80u | ((cp >> 12) & 0x3fu))); + text.push_back(static_cast(0x80u | ((cp >> 6) & 0x3fu))); + text.push_back(static_cast(0x80u | (cp & 0x3fu))); + } + input.text_utf8 = text.c_str(); + break; + } + case ENGINE_INPUT_EVENT_BACK: + input.type = RENPY_MOBILE_INPUT_KEY; + input.code = 0x08; + break; + default: + return ENGINE_RESULT_INVALID_ARGUMENT; + } + return MapLauncherStatus(runtime->launcher.Input(input)); } -engine_result_t UnsupportedRendered(void* value, uint32_t*) { - return Unsupported(value); +engine_result_t Rendered(void* value, uint32_t* output) { + if (!value || !output) return ENGINE_RESULT_INVALID_ARGUMENT; + auto* runtime = Cast(value); + *output = runtime->frame_rendered ? 1u : 0u; + return ENGINE_RESULT_OK; } engine_result_t Renderer(void* value, char* buffer, uint32_t size) { if (!value || !buffer || size == 0) return ENGINE_RESULT_INVALID_ARGUMENT; constexpr char kRenderer[] = - "Ren'Py mobile stub (official RAPT/Renios staged; bootstrap unavailable)"; - constexpr uint32_t kRequired = sizeof(kRenderer); - if (size < kRequired) { + "Ren'Py mobile host lifecycle (host-owned RGBA frame)"; + if (size < sizeof(kRenderer)) { Cast(value)->error = "Ren'Py mobile renderer description buffer is too small"; return ENGINE_RESULT_INVALID_ARGUMENT; } - std::memcpy(buffer, kRenderer, kRequired); + std::memcpy(buffer, kRenderer, sizeof(kRenderer)); return ENGINE_RESULT_OK; } -engine_result_t TextInput(void* value, uint32_t*) { +engine_result_t Unsupported(void* value) { + if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; + Cast(value)->error = + "Ren'Py mobile runtime operation requires the rebuilt host lifecycle ABI"; + return ENGINE_RESULT_NOT_SUPPORTED; +} + +engine_result_t UnsupportedOption(void* value, const engine_option_t* option) { + if (!value || !option || !option->key_utf8 || !option->value_utf8) { + return ENGINE_RESULT_INVALID_ARGUMENT; + } + return Unsupported(value); +} + +engine_result_t UnsupportedTextState(void* value, uint32_t*) { return Unsupported(value); } const char* Error(void* value) { - return value ? Cast(value)->error.c_str() - : "Invalid Ren'Py mobile runtime"; + return value ? Cast(value)->error.c_str() : "Invalid Ren'Py mobile runtime"; } const engine_runtime_provider_v1_t& Provider() { @@ -182,25 +388,24 @@ const engine_runtime_provider_v1_t& Provider() { p.struct_size = sizeof(p); p.api_version = ENGINE_RUNTIME_PROVIDER_API_VERSION; p.runtime_id_utf8 = "renpy"; - p.display_name_utf8 = "Ren'Py (mobile bootstrap unavailable)"; + p.display_name_utf8 = "Ren'Py (mobile host lifecycle)"; p.priority = 60; p.probe = Probe; p.create = Create; p.destroy = Destroy; p.open_game = Open; - p.tick = UnsupportedTick; - p.pause = Unsupported; - p.resume = Unsupported; + p.tick = Tick; + p.pause = Pause; + p.resume = Resume; p.set_option = UnsupportedOption; - p.set_surface_size = UnsupportedResize; - p.get_frame_desc = UnsupportedFrame; - p.read_frame_rgba = UnsupportedRead; - p.get_godot_native_frame_texture = UnsupportedNativeFrame; - p.send_input = UnsupportedInput; - p.get_frame_rendered_flag = UnsupportedRendered; + p.set_surface_size = SetSurfaceSize; + p.get_frame_desc = FrameDesc; + p.read_frame_rgba = ReadFrame; + p.get_godot_native_frame_texture = NativeFrame; + p.send_input = Input; + p.get_frame_rendered_flag = Rendered; p.get_renderer_info = Renderer; - p.get_text_input_state = TextInput; - p.get_last_error = Error; + p.get_text_input_state = UnsupportedTextState; return p; }(); return provider; From 290984921a9ec4af2d112ee076eda13ca38d6f78 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 17:59:56 +0800 Subject: [PATCH 165/331] feat: add mobile RenPy lifecycle loader contract From 5d65911cf1dc9e3de41908d10b255a6a63f5016f Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 18:00:13 +0800 Subject: [PATCH 166/331] feat: add mobile RenPy lifecycle loader contract From e29289dc2a6ccbf942b28027330d9873936dea80 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 18:00:28 +0800 Subject: [PATCH 167/331] feat: add mobile RenPy lifecycle loader contract From d51acc9b63b67731c6ea34ddea686f760e0917b8 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 18:00:57 +0800 Subject: [PATCH 168/331] feat: add mobile RenPy lifecycle loader contract From 0ee4cd7ce6f1194101e5e6cae68c3decc40b7fe8 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 18:01:14 +0800 Subject: [PATCH 169/331] feat: add mobile RenPy lifecycle loader contract --- tools/test_renpy_mobile_launcher_loader.sh | 59 ++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tools/test_renpy_mobile_launcher_loader.sh diff --git a/tools/test_renpy_mobile_launcher_loader.sh b/tools/test_renpy_mobile_launcher_loader.sh new file mode 100644 index 000000000..e88da91b2 --- /dev/null +++ b/tools/test_renpy_mobile_launcher_loader.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +work="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-loader.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +cat > "$work/fake_launcher.cpp" <<'CPP' +#include "renpy_mobile_launcher.h" +#include +static unsigned char pixel[4] = {0x12, 0x34, 0x56, 0xff}; +extern "C" int renpy_mobile_init(const renpy_mobile_config_t*, const renpy_mobile_host_t*) { return 0; } +extern "C" int renpy_mobile_tick(unsigned int) { return 0; } +extern "C" int renpy_mobile_frame(renpy_mobile_frame_t* frame) { + if (!frame) return -1; + frame->rgba = pixel; frame->width = 1; frame->height = 1; frame->stride = 4; frame->serial = 7; + return 0; +} +extern "C" int renpy_mobile_input(const renpy_mobile_input_t*) { return 0; } +extern "C" int renpy_mobile_pause(void) { return 0; } +extern "C" int renpy_mobile_resume(void) { return 0; } +extern "C" void renpy_mobile_shutdown(void) {} +CPP +cat > "$work/test_loader.cpp" <<'CPP' +#include "renpy_mobile_loader.h" +#include +#include +using namespace aetherkiri::renpy::mobile; +int main() { + Launcher launcher; + assert(launcher.available()); + renpy_mobile_config_t config{}; config.struct_size = sizeof(config); config.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; + renpy_mobile_host_t host{}; host.struct_size = sizeof(host); host.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; + assert(launcher.Init(config, host) == RENPY_MOBILE_OK); + assert(launcher.Tick(16) == RENPY_MOBILE_OK); + renpy_mobile_frame_t frame{}; frame.struct_size = sizeof(frame); + assert(launcher.Frame(&frame) == RENPY_MOBILE_OK); + assert(frame.width == 1 && frame.height == 1 && frame.stride == 4 && frame.serial == 7); + renpy_mobile_input_t input{}; input.struct_size = sizeof(input); input.type = RENPY_MOBILE_INPUT_KEY; + assert(launcher.Input(input) == RENPY_MOBILE_OK); + assert(launcher.Pause() == RENPY_MOBILE_OK); + assert(launcher.Resume() == RENPY_MOBILE_OK); + launcher.Shutdown(); + assert(!launcher.initialized()); + return 0; +} +CPP + +cxx="${CXX:-c++}" +"$cxx" -std=c++17 -fPIC -shared -D__ANDROID__ \ + -I"$repo_root/bridge/renpy_runtime/mobile_launcher/include" \ + "$work/fake_launcher.cpp" -o "$work/librenpython.so" +"$cxx" -std=c++17 -D__ANDROID__ \ + -I"$repo_root/bridge/renpy_runtime/mobile_launcher/include" \ + "$work/test_loader.cpp" \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp" \ + -ldl -o "$work/test_loader" +LD_LIBRARY_PATH="$work${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$work/test_loader" +printf '%s\n' 'Ren''Py mobile lifecycle loader test passed' From bee468029ae6a75948a7a17a81f7cc05ba3964f9 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 18:01:39 +0800 Subject: [PATCH 170/331] feat: add mobile RenPy lifecycle loader contract From 804f0efa675cdf1c84baa15b8fd057bcb0f50594 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 18:01:57 +0800 Subject: [PATCH 171/331] feat: add mobile RenPy lifecycle loader contract From c583a72babe7d64c2cdb5767aeaa5417c93df591 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 18:18:35 +0800 Subject: [PATCH 172/331] build: add RenPy Python cooperative loop patch skeleton --- bridge/renpy_runtime/mobile_launcher/build.sh | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/build.sh b/bridge/renpy_runtime/mobile_launcher/build.sh index e54a737e5..41e0eb12e 100644 --- a/bridge/renpy_runtime/mobile_launcher/build.sh +++ b/bridge/renpy_runtime/mobile_launcher/build.sh @@ -15,6 +15,8 @@ usage() { Usage: build.sh --check --renpy-build PATH build.sh --compile-contract --renpy-build PATH [--output-dir PATH] + build.sh --check-python-patch --renpy-src PATH + build.sh --apply-python-patch --renpy-src PATH build.sh --print-plan --renpy-build PATH build.sh --install --renpy-build PATH --stage PATH \ [--android-so-arm64 PATH] [--android-so-armv7 PATH] @@ -24,6 +26,10 @@ Usage: launcher is still the blocking SDL_main/launcher_main implementation. --compile-contract compiles the Android/iOS templates as contract-only objects; those objects deliberately return NOT_IMPLEMENTED and are never packaged. +--check-python-patch validates the Ren'Py Python cooperative-loop patch against +an official Ren'Py source checkout. --apply-python-patch applies it in place +only after `git apply --check`; it refuses a dirty checkout. The patch is an +opt-in skeleton and does not make the runtime playable by itself. --print-plan prints the upstream renpy-build commands and replacement paths. --install copies caller-supplied, already-built lifecycle artifacts into the staged RAPT/Renios tree after checking the required exported symbol names. @@ -35,6 +41,7 @@ USAGE mode="" renpy_build="" +renpy_src="" stage="" android_so_arm64="" android_so_armv7="" @@ -45,7 +52,7 @@ output_dir="" while [[ $# -gt 0 ]]; do case "$1" in - --check|--compile-contract|--print-plan|--install) + --check|--compile-contract|--check-python-patch|--apply-python-patch|--print-plan|--install) [[ -z "$mode" ]] || { echo "choose one mode" >&2; exit 2; } mode="${1#--}" ;; @@ -53,6 +60,10 @@ while [[ $# -gt 0 ]]; do [[ $# -ge 2 ]] || { echo "--renpy-build requires a path" >&2; exit 2; } renpy_build="$2"; shift ;; + --renpy-src) + [[ $# -ge 2 ]] || { echo "--renpy-src requires a path" >&2; exit 2; } + renpy_src="$2"; shift + ;; --stage) [[ $# -ge 2 ]] || { echo "--stage requires a path" >&2; exit 2; } stage="$2"; shift @@ -92,8 +103,35 @@ while [[ $# -gt 0 ]]; do done [[ -n "$mode" ]] || { usage >&2; exit 2; } -[[ -n "$renpy_build" ]] || { echo "--renpy-build is required" >&2; exit 2; } [[ -f "$contract_header" ]] || { echo "missing lifecycle contract: $contract_header" >&2; exit 1; } + +python_patch="$repo_root/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch" +if [[ "$mode" == "check-python-patch" || "$mode" == "apply-python-patch" ]]; then + [[ -n "$renpy_src" ]] || { echo "--renpy-src is required" >&2; exit 2; } + [[ -d "$renpy_src" ]] || { echo "Ren'Py source checkout not found: $renpy_src" >&2; exit 1; } + [[ -f "$python_patch" ]] || { echo "missing cooperative-loop patch: $python_patch" >&2; exit 1; } + git -C "$renpy_src" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + echo "--renpy-src must be a git checkout" >&2; exit 1; + } + git -C "$renpy_src" apply --check "$python_patch" + if [[ "$mode" == "check-python-patch" ]]; then + echo "Ren'Py Python cooperative-loop patch applies cleanly" + echo " skeleton only: Python/SDL lifecycle integration remains required" + exit 0 + fi + if [[ -n "$(git -C "$renpy_src" status --porcelain --untracked-files=no)" ]]; then + echo "refusing to apply cooperative-loop patch to a dirty Ren'Py checkout" >&2 + exit 1 + fi + git -C "$renpy_src" apply "$python_patch" + python3 -m py_compile "$renpy_src/renpy/main.py" \ + "$renpy_src/renpy/execution.py" "$renpy_src/renpy/display/core.py" + echo "applied and syntax-checked Ren'Py Python cooperative-loop skeleton" + echo " this does not make the mobile runtime playable" + exit 0 +fi + +[[ -n "$renpy_build" ]] || { echo "--renpy-build is required" >&2; exit 2; } [[ -d "$renpy_build" ]] || { echo "renpy-build directory not found: $renpy_build" >&2; exit 1; } runtime="$renpy_build/runtime" From ce259ca2261b4e0692f0ec077cfb2102efdbfdc8 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 18:18:50 +0800 Subject: [PATCH 173/331] build: add RenPy Python cooperative loop patch skeleton --- .../renpy_runtime/mobile_launcher/patches/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/README.md b/bridge/renpy_runtime/mobile_launcher/patches/README.md index b3c93a9a1..a48c2174c 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/README.md @@ -40,3 +40,14 @@ Once an audited fork exports the ABI, the host loader in Activity, or UIKit entrypoint. It is deliberately inert for the official blocking libraries, so staging those archives alone cannot accidentally enable mobile gameplay. + +## Python cooperative-loop seam + +`python/0001-cooperative-loop-skeleton.patch` is an opt-in, syntax-checked +patch for `renpy/main.py`, `renpy/execution.py`, and +`renpy/display/core.py`. The scaffold validates or applies it with +`--check-python-patch` / `--apply-python-patch`. It adds deadline state, +`CooperativeYield`, non-blocking event polling, and host-safe shutdown seams +while preserving default launcher behavior. It is intentionally incomplete: +`run_context`, `main.run`, and `Interface.interact_core` still require a real +resumable implementation before mobile gameplay can be enabled. From ded8eb2283c40a54df1ea5bf5f4fdc557e50e571 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 18:19:09 +0800 Subject: [PATCH 174/331] build: add RenPy Python cooperative loop patch skeleton --- .../0001-cooperative-loop-skeleton.patch | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch diff --git a/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch b/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch new file mode 100644 index 000000000..93c1d53ac --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch @@ -0,0 +1,133 @@ +diff --git a/renpy/display/core.py b/renpy/display/core.py +index 93b0aba..2998a93 100644 +--- a/renpy/display/core.py ++++ b/renpy/display/core.py +@@ -1605,6 +1605,21 @@ class Interface: + + return ev + ++ def event_poll_cooperative(self): ++ """Poll one event without sleeping for an embedding host tick. ++ ++ This is an opt-in primitive for the native lifecycle fork. It does not ++ replace interact_core's blocking event loop or claim to render a full ++ Ren'Py frame by itself. ++ """ ++ if self.pushed_event: ++ rv = self.pushed_event ++ self.pushed_event = None ++ else: ++ rv = pygame.event.poll() ++ self.last_event = rv ++ return rv ++ + def event_poll(self): + """ + Called to busy-wait for an event while we're waiting to +@@ -2119,6 +2134,14 @@ class Interface: + else: + self.maximum_framerate_time = max(self.maximum_framerate_time, get_time() + t) + ++ def cooperative_event_poll(self): ++ """Expose one non-blocking event poll to an embedding host. ++ ++ Input dispatch, display redraw, and script execution still require a ++ follow-up Ren'Py fork; this hook only provides the non-blocking poll. ++ """ ++ return self.event_poll_cooperative() ++ + def interact(self, clear=True, suppress_window=False, trans_pause=False, pause=None, pause_modal=False, **kwargs): + """ + This handles an interaction, restarting it if necessary. All of the +diff --git a/renpy/execution.py b/renpy/execution.py +index 056c11e..97e56d6 100644 +--- a/renpy/execution.py ++++ b/renpy/execution.py +@@ -1040,7 +1040,15 @@ class RestartTopContext(BaseException): + """ + + +-def run_context(top): ++class CooperativeYield(BaseException): ++ """Opt-in yield from a bounded host tick. ++ ++ The default run_context path never raises this. An embedding fork must ++ catch it at its native tick boundary and resume the same Ren'Py context. ++ """ ++ ++ ++def run_context(top, cooperative=False): + """ + Runs the current context until it can't be run anymore, while handling + various exceptions. +@@ -1057,6 +1065,8 @@ def run_context(top): + + try: + try: ++ if cooperative and renpy.main.cooperative_tick_expired(): ++ raise CooperativeYield() + context.run() + + rv = renpy.store._return +diff --git a/renpy/main.py b/renpy/main.py +index bd0d6f7..79e8384 100644 +--- a/renpy/main.py ++++ b/renpy/main.py +@@ -54,6 +54,57 @@ def reset_clock(): + last_clock = time.time() + + ++# AetherKiri cooperative host hook skeleton. This state is deliberately opt-in: ++# the normal Ren'Py launcher remains unchanged until a native/mobile fork calls ++# these helpers and handles CooperativeYield. ++_cooperative_deadline = None ++ ++ ++def cooperative_begin_tick(budget_ms): ++ """Start an opt-in bounded host tick and return its monotonic deadline.""" ++ global _cooperative_deadline ++ if budget_ms is None or budget_ms < 0: ++ _cooperative_deadline = None ++ else: ++ _cooperative_deadline = time.monotonic() + (budget_ms / 1000.0) ++ return _cooperative_deadline ++ ++ ++def cooperative_tick_expired(): ++ """Return true when the opt-in host tick budget has elapsed.""" ++ return _cooperative_deadline is not None and time.monotonic() >= _cooperative_deadline ++ ++ ++def cooperative_end_tick(): ++ """Clear the opt-in host tick deadline.""" ++ global _cooperative_deadline ++ _cooperative_deadline = None ++ ++ ++def cooperative_shutdown(): ++ """Release Ren'Py resources without terminating the host process. ++ ++ This is a shutdown *hook* for an embedding fork. It intentionally does not ++ call the Android ``finishAndRemoveTask``/``System.exit`` path in bootstrap. ++ The native fork must call it only after the Python interpreter is quiescent. ++ """ ++ try: ++ renpy.display.tts.tts(None) ++ except Exception: ++ pass ++ try: ++ if renpy.display.draw: ++ renpy.display.im.cache.quit() ++ renpy.display.draw.quit() ++ except Exception: ++ pass ++ try: ++ renpy.audio.audio.quit() ++ except Exception: ++ pass ++ cooperative_end_tick() ++ ++ + def run(restart): + """ + This is called during a single run of the script. Restarting the script From 70ecc0427ac338c0fa8432c5cc5f07038fd3f605 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 18:19:26 +0800 Subject: [PATCH 175/331] build: add RenPy Python cooperative loop patch skeleton --- .../mobile_launcher/patches/python/README.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/python/README.md diff --git a/bridge/renpy_runtime/mobile_launcher/patches/python/README.md b/bridge/renpy_runtime/mobile_launcher/patches/python/README.md new file mode 100644 index 000000000..0db480de3 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/python/README.md @@ -0,0 +1,40 @@ +# Ren'Py Python cooperative-loop patch skeleton + +`0001-cooperative-loop-skeleton.patch` is an opt-in source patch against an +official Ren'Py source checkout. It applies cleanly to the current source +layout and is syntax-checked by the mobile launcher scaffold. + +Apply or validate it with: + +```text +bridge/renpy_runtime/mobile_launcher/build.sh \\ + --check-python-patch --renpy-src /path/to/renpy + +bridge/renpy_runtime/mobile_launcher/build.sh \\ + --apply-python-patch --renpy-src /path/to/renpy +``` + +The apply command refuses a dirty checkout, runs `git apply --check`, applies +the patch, and runs `py_compile` on all three changed modules. It does not +commit the source or build an archive. + +## What the skeleton adds + +- `renpy/main.py`: an opt-in monotonic tick deadline, begin/end helpers, and a + host-safe cleanup hook that deliberately avoids Android task finish and + `System.exit` +- `renpy/execution.py`: `CooperativeYield` and an opt-in deadline check at the + context boundary; default `run_context(top)` behavior is preserved +- `renpy/display/core.py`: one non-blocking event poll primitive and a small + interface hook + +These are explicit seams for the native fork. They do not yet turn +`run_context`, `main.run`, or `Interface.interact_core` into resumable +coroutines. The current Ren'Py execution path can still block in script +execution and event handling. A playable Android/iOS fork must extend this +patch to preserve context state across ticks, dispatch input, render/publish +frames, and call `cooperative_shutdown` on the interpreter-owning thread. + +The patch intentionally contains no replacement for `SDL_main`, +`launcher_main`, `Py_RunMain`, `SDL_RunApp`, or `UIApplicationMain`, and it +must never be treated as a playable binary by itself. From b3998e6d521baabfc388158cabb05c7bc1b02763 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 18:19:44 +0800 Subject: [PATCH 176/331] build: add RenPy Python cooperative loop patch skeleton --- tools/test_renpy_mobile_launcher_scaffold.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/test_renpy_mobile_launcher_scaffold.sh b/tools/test_renpy_mobile_launcher_scaffold.sh index d9126aadd..32389795f 100644 --- a/tools/test_renpy_mobile_launcher_scaffold.sh +++ b/tools/test_renpy_mobile_launcher_scaffold.sh @@ -8,6 +8,7 @@ script="$scaffold/build.sh" header="$scaffold/include/renpy_mobile_launcher.h" loader="$scaffold/src/renpy_mobile_loader.cpp" renpy_build="${RENPY_BUILD_TEST_ROOT:-/tmp/renpy-build-src}" +renpy_source="${RENPY_SOURCE_TEST_ROOT:-/tmp/renpy-src}" [[ -x "$script" ]] || { echo "missing launcher scaffold script" >&2; exit 1; } [[ -f "$header" ]] || { echo "missing launcher lifecycle header" >&2; exit 1; } @@ -31,6 +32,11 @@ done grep -Fq 'Py_RunMain' "$scaffold/README.md" grep -Fq 'SDL_main' "$scaffold/README.md" grep -Fq 'renpy-build' "$scaffold/patches/README.md" +grep -Fq 'cooperative-loop-skeleton.patch' "$scaffold/patches/python/README.md" +if [[ -d "$renpy_source" ]]; then + patch_output="$($script --check-python-patch --renpy-src "$renpy_source")" + grep -Fq 'applies cleanly' <<<"$patch_output" +fi for template in \ "$scaffold/patches/android/librenpython_android_host.c.template" \ "$scaffold/patches/ios/librenpython_ios_host.c.template"; do From 34c7363194d9902c3a8f78ee8b6acec58cc95944 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 18:42:00 +0800 Subject: [PATCH 177/331] fix: keep iOS lifecycle loader weak at link time --- .../src/renpy_mobile_loader.cpp | 35 +++++++------------ 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp index f610e482a..4a42fc81a 100644 --- a/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp +++ b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp @@ -2,22 +2,8 @@ #include -#if defined(__ANDROID__) +#if defined(__ANDROID__) || defined(__APPLE__) #include -#elif defined(__APPLE__) -/* The optional lifecycle fork is linked as a static Renios archive. Weak - * imports keep the normal staged/official archive linkable until that fork is - * supplied. */ -extern "C" { -int renpy_mobile_init(const renpy_mobile_config_t*, - const renpy_mobile_host_t*) __attribute__((weak_import)); -int renpy_mobile_tick(uint32_t) __attribute__((weak_import)); -int renpy_mobile_frame(renpy_mobile_frame_t*) __attribute__((weak_import)); -int renpy_mobile_input(const renpy_mobile_input_t*) __attribute__((weak_import)); -int renpy_mobile_pause(void) __attribute__((weak_import)); -int renpy_mobile_resume(void) __attribute__((weak_import)); -void renpy_mobile_shutdown(void) __attribute__((weak_import)); -} #endif namespace aetherkiri::renpy::mobile { @@ -25,7 +11,7 @@ namespace { constexpr int kOk = RENPY_MOBILE_OK; -#if defined(__ANDROID__) +#if defined(__ANDROID__) || defined(__APPLE__) template Function ResolveSymbol(void* handle, const char* name) { return reinterpret_cast(dlsym(handle, name)); @@ -66,13 +52,16 @@ bool Launcher::Resolve() { resume_ = ResolveSymbol(library_handle_, "renpy_mobile_resume"); shutdown_ = ResolveSymbol(library_handle_, "renpy_mobile_shutdown"); #elif defined(__APPLE__) - init_ = renpy_mobile_init; - tick_ = renpy_mobile_tick; - frame_ = renpy_mobile_frame; - input_ = renpy_mobile_input; - pause_ = renpy_mobile_pause; - resume_ = renpy_mobile_resume; - shutdown_ = renpy_mobile_shutdown; + // Resolve against the host image instead of emitting strong references to + // optional symbols. This keeps the normal official Renios archive linkable + // while allowing a rebuilt lifecycle archive to export the same ABI. + init_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_init"); + tick_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_tick"); + frame_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_frame"); + input_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_input"); + pause_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_pause"); + resume_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_resume"); + shutdown_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_shutdown"); #else last_error_ = "host lifecycle launcher unavailable on this platform"; #endif From 397b1b4fed2f9c936d2902c273e56d5ecd1a7cab Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 19:45:22 +0800 Subject: [PATCH 178/331] build: add opt-in RenPy native source preflight --- .github/workflows/build.yml | 48 +++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f960ec590..58badbc0e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -79,7 +79,8 @@ jobs: GODOT_RELEASE: 4.7-stable GODOT_TEMPLATE_VERSION: 4.7.stable ANDROID_NDK_VERSION: 28.1.13356709 - # Compile the guarded Ren'Py mobile provider stub. It still returns NOT_SUPPORTED. + # Compile the guarded Ren'Py mobile provider stub. The provider still + # returns NOT_SUPPORTED until the official RAPT/JNI bootstrap is wired. AETHERKIRI_ENABLE_RENPY: ON steps: @@ -147,17 +148,16 @@ jobs: sudo apt-get install -y cmake ninja-build pkg-config autoconf autoconf-archive automake libtool gettext bison flex curl unzip zip zstd ccache jq mono-complete rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android - - name: Stage official Ren'Py Android support inputs run: | set -euo pipefail mobile_root="${RUNNER_TEMP}/aetherkiri-renpy-mobile/android" - bash tools/install_renpy_mobile_support.sh \ + tools/install_renpy_mobile_support.sh \ --platform android \ --destination "$mobile_root" test -f "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" + RENPY_MOBILE_STAGE_TEST_ROOT="$mobile_root" tools/test_stage_renpy_android_support.sh echo "RENPY_MOBILE_ROOT=$mobile_root" >> "$GITHUB_ENV" - RENPY_MOBILE_STAGE_TEST_ROOT="$mobile_root" bash tools/test_stage_renpy_android_support.sh - name: Restore ccache id: restore-ccache @@ -348,8 +348,10 @@ jobs: - name: Verify Android Ren'Py mobile guard was compiled run: | set -euo pipefail - grep -F 'renpy_runtime_mobile_provider.cpp' out/android/arm64-v8a/debug/compile_commands.json - grep -F 'renpy_mobile_adapter.cpp' out/android/arm64-v8a/debug/compile_commands.json + grep -F 'renpy_runtime_mobile_provider.cpp' \ + out/android/arm64-v8a/debug/compile_commands.json + grep -F 'renpy_mobile_adapter.cpp' \ + out/android/arm64-v8a/debug/compile_commands.json - name: Verify exported APKs run: | @@ -380,7 +382,8 @@ jobs: CCACHE_DIR: ${{ github.workspace }}/.ccache GODOT_RELEASE: 4.7-stable GODOT_TEMPLATE_VERSION: 4.7.stable - # Compile the guarded Ren'Py mobile provider stub. It still returns NOT_SUPPORTED. + # Compile the guarded Ren'Py mobile provider stub. The provider still + # returns NOT_SUPPORTED until the official Renios/Xcode adapter is wired. AETHERKIRI_ENABLE_RENPY: ON steps: @@ -477,23 +480,17 @@ jobs: brew install cmake ninja pkg-config autoconf automake autoconf-archive libtool gettext coreutils bison nasm ccache mono echo "$(brew --prefix gettext)/bin" >> $GITHUB_PATH echo "$(brew --prefix bison)/bin" >> $GITHUB_PATH - for attempt in 1 2 3 4 5; do - rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios && break - if [[ "$attempt" == 5 ]]; then exit 1; fi - sleep $((attempt * 15)) - done - + rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios - name: Stage official Ren'Py iOS support inputs run: | set -euo pipefail mobile_root="${RUNNER_TEMP}/aetherkiri-renpy-mobile/ios" - bash tools/install_renpy_mobile_support.sh \ + tools/install_renpy_mobile_support.sh \ --platform ios \ --destination "$mobile_root" test -f "$mobile_root/renios/prototype/prebuilt/release/librenpython.a" echo "RENPY_MOBILE_ROOT=$mobile_root" >> "$GITHUB_ENV" - RENPY_MOBILE_TEST_ROOT="$mobile_root" bash tools/test_renios_ios_bundle.sh - name: Install Godot export tools run: | @@ -642,8 +639,10 @@ jobs: - name: Verify iOS Ren'Py mobile guard was compiled run: | set -euo pipefail - grep -F 'renpy_runtime_mobile_provider.cpp' out/ios/debug/compile_commands.json - grep -F 'renpy_runtime_ios_adapter.cpp' out/ios/debug/compile_commands.json + grep -F 'renpy_runtime_mobile_provider.cpp' \ + out/ios/debug/compile_commands.json + grep -F 'renpy_runtime_ios_adapter.cpp' \ + out/ios/debug/compile_commands.json - name: Verify exported Xcode project and IPA run: | @@ -662,6 +661,13 @@ jobs: echo "Godot iOS ${build_type} unsigned IPA not found" >&2 exit 1 fi + test -f "out/godot/ios/${build_type}/Aether/renios/renios-manifest.txt" + test -f "out/godot/ios/${build_type}/Aether/Frameworks/MetalANGLE.xcframework/Info.plist" + if [[ -e "out/godot/ios/${build_type}/Aether/renios/main.c" || + -e "out/godot/ios/${build_type}/Aether/renios/prototype.xcodeproj" ]]; then + echo "Renios bundle unexpectedly includes a prototype entrypoint/project" >&2 + exit 1 + fi done - name: Upload Release Unsigned IPA @@ -797,7 +803,13 @@ jobs: run: tools/test_install_renpy_sdk.sh - name: Validate Ren'Py mobile support staging - run: bash tools/test_install_renpy_mobile_support.sh + run: tools/test_install_renpy_mobile_support.sh + + - name: Validate Ren'Py iOS link and bundle boundary + run: tools/test_renios_ios_bundle.sh + + - name: Validate Ren'Py mobile native source-build plan + run: tools/test_renpy_mobile_source_build_plan.sh - name: Install pinned Ren'Py SDK run: | From 30f1f3fea4cb8bc3bf9c30d10a9456ceb00badaf Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 19:45:39 +0800 Subject: [PATCH 179/331] build: add opt-in RenPy native source preflight --- bridge/renpy_runtime/mobile_launcher/README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/README.md b/bridge/renpy_runtime/mobile_launcher/README.md index 7daaca51d..1d6f1918e 100644 --- a/bridge/renpy_runtime/mobile_launcher/README.md +++ b/bridge/renpy_runtime/mobile_launcher/README.md @@ -26,8 +26,18 @@ the `renpy-build` repository: The official command-line hooks are documented by `build.sh --print-plan`: -- Android: `./build.sh --platform android rebuild renpython rapt rapt-sdl2` -- iOS: `./build.sh --platform ios rebuild renpython renios` +- Android: `./build.sh --platform android rebuild rapt rapt-sdl2` +- iOS: `./build.sh --platform ios rebuild renios` + +`build.sh --source-build-plan --renpy-build /path/to/renpy-build` prints the +Ubuntu 24.04 prerequisites, source patch commands, build commands, and exact +RAPT/Renios artifact destinations. `--check-renpy-build` performs a strict +read-only preflight for the source checkout, patch applicability, host tools, +64 GiB disk requirement, and supplied Android/iOS SDK archives. These commands +are opt-in and are not part of the normal mobile CI build because the official +renpy-build process is a large, multi-hour source rebuild and requires licensed +iOS SDK tarballs. CI only validates the plan; it does not pretend to build a +playable fork. The current official outputs are process launchers. Do not call them from a Godot frame callback and do not copy `renios/prototype/main.c` into the host From 3f676fde90afef525c975c6a996ee50ff9972fff Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 19:45:58 +0800 Subject: [PATCH 180/331] build: add opt-in RenPy native source preflight --- bridge/renpy_runtime/mobile_launcher/build.sh | 166 +++++++++++++++++- 1 file changed, 165 insertions(+), 1 deletion(-) diff --git a/bridge/renpy_runtime/mobile_launcher/build.sh b/bridge/renpy_runtime/mobile_launcher/build.sh index 41e0eb12e..8eb8f58a1 100644 --- a/bridge/renpy_runtime/mobile_launcher/build.sh +++ b/bridge/renpy_runtime/mobile_launcher/build.sh @@ -24,6 +24,11 @@ Usage: --check validates the official source layout and confirms that the current launcher is still the blocking SDL_main/launcher_main implementation. +--source-build-plan prints the official renpy-build source-build commands, +required Ubuntu/toolchain inputs, and the exact RAPT/Renios artifact paths. +--check-renpy-build performs a strict, read-only preflight for those inputs; it +checks the Ubuntu/disk/toolchain prerequisites, source checkout, SDK archives, +and cooperative-loop patch applicability. It never runs the heavy build. --compile-contract compiles the Android/iOS templates as contract-only objects; those objects deliberately return NOT_IMPLEMENTED and are never packaged. --check-python-patch validates the Ren'Py Python cooperative-loop patch against @@ -52,7 +57,7 @@ output_dir="" while [[ $# -gt 0 ]]; do case "$1" in - --check|--compile-contract|--check-python-patch|--apply-python-patch|--print-plan|--install) + --check|--source-build-plan|--check-renpy-build|--compile-contract|--check-python-patch|--apply-python-patch|--print-plan|--install) [[ -z "$mode" ]] || { echo "choose one mode" >&2; exit 2; } mode="${1#--}" ;; @@ -105,7 +110,166 @@ done [[ -n "$mode" ]] || { usage >&2; exit 2; } [[ -f "$contract_header" ]] || { echo "missing lifecycle contract: $contract_header" >&2; exit 1; } +# The source-build modes are deliberately separate from --install. They make +# the official renpy-build workflow reproducible and auditable without hiding a +# many-hour native rebuild inside the regular AetherKiri CI jobs. +renpy_build_source_preflight() { + local strict="$1" + local failures=0 + local required + for required in \ + "$renpy_build/build.sh" \ + "$renpy_build/prepare.sh" \ + "$renpy_build/tasks/renpython.py" \ + "$renpy_build/tasks/rapt.py" \ + "$renpy_build/tasks/renios.py" \ + "$renpy_build/runtime/librenpython_android.c" \ + "$renpy_build/runtime/librenpython.c"; do + if [[ ! -f "$required" ]]; then + echo "missing renpy-build input: $required" >&2 + failures=$((failures + 1)) + fi + done + + local source_root="${renpy_src:-$renpy_build/renpy}" + if [[ ! -d "$source_root/.git" ]]; then + echo "Ren'Py source checkout missing: $source_root" >&2 + echo " run: (cd $renpy_build && ./prepare.sh)" >&2 + failures=$((failures + 1)) + else + if [[ ! -f "$source_root/renpy/main.py" || ! -f "$source_root/renpy/execution.py" || ! -f "$source_root/renpy/display/core.py" ]]; then + echo "Ren'Py source checkout is incomplete: $source_root" >&2 + failures=$((failures + 1)) + elif [[ -f "$python_patch" ]]; then + if ! git -C "$source_root" apply --check "$python_patch" >/dev/null 2>&1; then + echo "cooperative-loop patch does not apply to $source_root" >&2 + failures=$((failures + 1)) + else + echo "cooperative-loop patch: applies cleanly" + fi + fi + fi + + # renpy-build's current toolchain.py/run.py expect these exact archives. + # They are intentionally checked rather than downloaded implicitly: iOS + # SDK tarballs are licensed inputs and must be supplied by the builder. + local archive + local ndk_version="" + ndk_version="$(sed -n 's/.*c.var("ndk_version", "\([^"]*\)").*/\1/p' "$renpy_build/renpybuild/run.py" | head -1)" + [[ -n "$ndk_version" ]] || ndk_version="android-ndk-r29" + for archive in \ + "$renpy_build/tars/${ndk_version}-linux.zip" \ + "$renpy_build/tars/iPhoneOS.sdk.tar.gz" \ + "$renpy_build/tars/iPhoneSimulator.sdk.tar.gz"; do + if [[ ! -f "$archive" ]]; then + echo "missing licensed/toolchain archive: $archive" >&2 + failures=$((failures + 1)) + else + echo "toolchain archive: $archive" + fi + done + + local command + for command in bash git python3 curl tar unzip make cmake ninja pkg-config clang clang++; do + if command -v "$command" >/dev/null 2>&1; then + printf 'tool: %-12s %s\n' "$command" "$(command -v "$command")" + else + echo "missing host tool: $command" >&2 + failures=$((failures + 1)) + fi + done + if command -v uv >/dev/null 2>&1; then + echo "tool: uv $(command -v uv)" + else + echo "missing host tool: uv (install from https://astral.sh/uv)" >&2 + failures=$((failures + 1)) + fi + + local system_name + system_name="$(uname -s)" + if [[ "$system_name" != "Linux" ]]; then + echo "renpy-build requires Ubuntu Linux; detected $system_name" >&2 + failures=$((failures + 1)) + else + local ubuntu_id="" ubuntu_version="" + if [[ -r /etc/os-release ]]; then + # shellcheck disable=SC1091 + . /etc/os-release + ubuntu_id="${ID:-}" + ubuntu_version="${VERSION_ID:-}" + fi + if [[ "$ubuntu_id" == "ubuntu" && "$ubuntu_version" == "24.04" ]]; then + echo "host: Ubuntu 24.04" + else + echo "renpy-build officially targets Ubuntu 24.04 (detected ${ubuntu_id:-unknown} ${ubuntu_version:-unknown})" >&2 + failures=$((failures + 1)) + fi + fi + + local available_kib=0 + available_kib="$(df -Pk "$renpy_build" 2>/dev/null | awk 'NR==2 {print $4}')" + if [[ "$available_kib" =~ ^[0-9]+$ ]]; then + printf 'disk: %.1f GiB available (official minimum: 64 GiB)\n' "$(awk -v kib="$available_kib" 'BEGIN {print kib / 1024 / 1024}')" + if (( available_kib < 64 * 1024 * 1024 )); then + echo "insufficient free disk for renpy-build (need at least 64 GiB)" >&2 + failures=$((failures + 1)) + fi + else + echo "unable to determine free disk for $renpy_build" >&2 + failures=$((failures + 1)) + fi + + if [[ "$strict" == "1" && "$failures" -ne 0 ]]; then + echo "Ren'Py source-build preflight failed with $failures issue(s)" >&2 + return 1 + fi + return 0 +} + +renpy_build_source_plan() { + local source_root="${renpy_src:-$renpy_build/renpy}" + local artifact_root="$renpy_build/renpy" + cat < --android-so-arm64 --android-so-armv7 --android-so-x86_64 --ios-debug-a --ios-release-a + +Important: this plan does not assert mobile playability. A native fork must +export the host lifecycle ABI, then pass device/simulator gameplay E2E. +PLAN +} + + python_patch="$repo_root/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch" +if [[ "$mode" == "source-build-plan" || "$mode" == "check-renpy-build" ]]; then + [[ -n "$renpy_build" ]] || { echo "--renpy-build is required" >&2; exit 2; } + [[ -d "$renpy_build" ]] || { echo "renpy-build directory not found: $renpy_build" >&2; exit 1; } + renpy_build_source_plan + if [[ "$mode" == "check-renpy-build" ]]; then + renpy_build_source_preflight 1 + fi + exit 0 +fi + if [[ "$mode" == "check-python-patch" || "$mode" == "apply-python-patch" ]]; then [[ -n "$renpy_src" ]] || { echo "--renpy-src is required" >&2; exit 2; } [[ -d "$renpy_src" ]] || { echo "Ren'Py source checkout not found: $renpy_src" >&2; exit 1; } From d6d98fb6418ca088bc609f10e2efc45ab52fa03f Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 19:47:12 +0800 Subject: [PATCH 181/331] build: add opt-in RenPy native source preflight --- tools/test_renpy_mobile_source_build_plan.sh | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tools/test_renpy_mobile_source_build_plan.sh diff --git a/tools/test_renpy_mobile_source_build_plan.sh b/tools/test_renpy_mobile_source_build_plan.sh new file mode 100644 index 000000000..db59065c7 --- /dev/null +++ b/tools/test_renpy_mobile_source_build_plan.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Validate the explicit Ren'Py native source-build plan without downloading +# toolchains or pretending that the current upstream launcher is playable. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +launcher="$repo_root/bridge/renpy_runtime/mobile_launcher/build.sh" +plan="$(mktemp)" +trap 'rm -f "$plan"' EXIT + +"$launcher" --source-build-plan --renpy-build "$repo_root" >"$plan" +grep -F "./build.sh --platform android rebuild rapt rapt-sdl2" "$plan" +grep -F "./build.sh --platform ios rebuild renios" "$plan" +grep -F "librenpython.so" "$plan" +grep -F "librenpython.a" "$plan" +grep -F "does not assert mobile playability" "$plan" + +# The repository checkout is intentionally not a renpy-build checkout. The +# strict preflight must refuse it instead of silently running a partial build. +if "$launcher" --check-renpy-build --renpy-build "$repo_root" >"$plan" 2>&1; then + echo "strict preflight unexpectedly accepted the AetherKiri checkout" >&2 + exit 1 +fi +grep -F "Ren'Py source-build preflight failed" "$plan" + +echo "Ren'Py mobile source-build plan checks passed" From 7ed1b603fd12c7173c714c67df0f02afa7aaf997 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 19:50:17 +0800 Subject: [PATCH 182/331] ci: invoke RenPy source preflight through bash --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 58badbc0e..2b498a559 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -809,7 +809,7 @@ jobs: run: tools/test_renios_ios_bundle.sh - name: Validate Ren'Py mobile native source-build plan - run: tools/test_renpy_mobile_source_build_plan.sh + run: bash tools/test_renpy_mobile_source_build_plan.sh - name: Install pinned Ren'Py SDK run: | From 78b01c0ad5c9b2d2285cabf1ac204f1ba37f1ba8 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 19:53:57 +0800 Subject: [PATCH 183/331] ci: invoke RenPy build preflight through bash --- tools/test_renpy_mobile_source_build_plan.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/test_renpy_mobile_source_build_plan.sh b/tools/test_renpy_mobile_source_build_plan.sh index db59065c7..f3b90829b 100644 --- a/tools/test_renpy_mobile_source_build_plan.sh +++ b/tools/test_renpy_mobile_source_build_plan.sh @@ -8,7 +8,7 @@ launcher="$repo_root/bridge/renpy_runtime/mobile_launcher/build.sh" plan="$(mktemp)" trap 'rm -f "$plan"' EXIT -"$launcher" --source-build-plan --renpy-build "$repo_root" >"$plan" +bash "$launcher" --source-build-plan --renpy-build "$repo_root" >"$plan" grep -F "./build.sh --platform android rebuild rapt rapt-sdl2" "$plan" grep -F "./build.sh --platform ios rebuild renios" "$plan" grep -F "librenpython.so" "$plan" @@ -17,7 +17,7 @@ grep -F "does not assert mobile playability" "$plan" # The repository checkout is intentionally not a renpy-build checkout. The # strict preflight must refuse it instead of silently running a partial build. -if "$launcher" --check-renpy-build --renpy-build "$repo_root" >"$plan" 2>&1; then +if bash "$launcher" --check-renpy-build --renpy-build "$repo_root" >"$plan" 2>&1; then echo "strict preflight unexpectedly accepted the AetherKiri checkout" >&2 exit 1 fi From 1487734d81401f524d95db71a5aacfd51208b122 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 19:56:07 +0800 Subject: [PATCH 184/331] ci: run repository scripts through bash --- .github/workflows/build.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b498a559..7c4add329 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -800,13 +800,13 @@ jobs: rustup target add aarch64-apple-darwin - name: Validate Ren'Py SDK installer - run: tools/test_install_renpy_sdk.sh + run: bash tools/test_install_renpy_sdk.sh - name: Validate Ren'Py mobile support staging - run: tools/test_install_renpy_mobile_support.sh + run: bash tools/test_install_renpy_mobile_support.sh - name: Validate Ren'Py iOS link and bundle boundary - run: tools/test_renios_ios_bundle.sh + run: bash tools/test_renios_ios_bundle.sh - name: Validate Ren'Py mobile native source-build plan run: bash tools/test_renpy_mobile_source_build_plan.sh @@ -824,7 +824,7 @@ jobs: test -x "$sdk_root/renpy.sh" - name: Run Ren'Py SDK smoke fixture - run: tools/run_renpy_smoke.sh + run: bash tools/run_renpy_smoke.sh - name: Install Godot export tools run: | From 3fb8c3c4dbf8cc5cdd2def7cea65c18f6129f728 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 19:56:59 +0800 Subject: [PATCH 185/331] ci: run dependency preparation scripts through bash --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7c4add329..b36d815df 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -114,7 +114,7 @@ jobs: - name: Initialize private runtime dependencies if: env.AETHERKIRI_WITH_INTERNAL == 'true' - run: packages/AetherInternal/tools/prepare_ci_dependencies.sh --platform android + run: bash packages/AetherInternal/tools/prepare_ci_dependencies.sh --platform android - name: Initialize public runtime packages if: env.AETHERKIRI_WITH_INTERNAL != 'true' @@ -416,7 +416,7 @@ jobs: - name: Initialize private runtime dependencies if: env.AETHERKIRI_WITH_INTERNAL == 'true' - run: packages/AetherInternal/tools/prepare_ci_dependencies.sh --platform apple + run: bash packages/AetherInternal/tools/prepare_ci_dependencies.sh --platform apple - name: Initialize public runtime packages if: env.AETHERKIRI_WITH_INTERNAL != 'true' @@ -742,7 +742,7 @@ jobs: - name: Initialize private runtime dependencies if: env.AETHERKIRI_WITH_INTERNAL == 'true' - run: packages/AetherInternal/tools/prepare_ci_dependencies.sh --platform macos + run: bash packages/AetherInternal/tools/prepare_ci_dependencies.sh --platform macos - name: Initialize public runtime packages if: env.AETHERKIRI_WITH_INTERNAL != 'true' From da5f027e1afd941d439fe8a0abc2ab7e0c3073b1 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 20:05:15 +0800 Subject: [PATCH 186/331] fix: tolerate absent RenPy build metadata in preflight --- bridge/renpy_runtime/mobile_launcher/build.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/build.sh b/bridge/renpy_runtime/mobile_launcher/build.sh index 8eb8f58a1..1f1084719 100644 --- a/bridge/renpy_runtime/mobile_launcher/build.sh +++ b/bridge/renpy_runtime/mobile_launcher/build.sh @@ -154,9 +154,11 @@ renpy_build_source_preflight() { # They are intentionally checked rather than downloaded implicitly: iOS # SDK tarballs are licensed inputs and must be supplied by the builder. local archive - local ndk_version="" - ndk_version="$(sed -n 's/.*c.var("ndk_version", "\([^"]*\)").*/\1/p' "$renpy_build/renpybuild/run.py" | head -1)" - [[ -n "$ndk_version" ]] || ndk_version="android-ndk-r29" + local ndk_version="android-ndk-r29" + if [[ -f "$renpy_build/renpybuild/run.py" ]]; then + ndk_version="$(sed -n 's/.*c.var("ndk_version", "\([^"]*\)").*/\1/p' "$renpy_build/renpybuild/run.py" | head -1)" + [[ -n "$ndk_version" ]] || ndk_version="android-ndk-r29" + fi for archive in \ "$renpy_build/tars/${ndk_version}-linux.zip" \ "$renpy_build/tars/iPhoneOS.sdk.tar.gz" \ From d1551737a86a2b144c03409840390c2a029640cb Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Sat, 3 Oct 2026 20:12:19 +0800 Subject: [PATCH 187/331] ci: invoke RenPy mobile staging through bash --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b36d815df..25add2d8b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -152,11 +152,11 @@ jobs: run: | set -euo pipefail mobile_root="${RUNNER_TEMP}/aetherkiri-renpy-mobile/android" - tools/install_renpy_mobile_support.sh \ + bash tools/install_renpy_mobile_support.sh \ --platform android \ --destination "$mobile_root" test -f "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" - RENPY_MOBILE_STAGE_TEST_ROOT="$mobile_root" tools/test_stage_renpy_android_support.sh + RENPY_MOBILE_STAGE_TEST_ROOT="$mobile_root" bash tools/test_stage_renpy_android_support.sh echo "RENPY_MOBILE_ROOT=$mobile_root" >> "$GITHUB_ENV" - name: Restore ccache @@ -486,7 +486,7 @@ jobs: run: | set -euo pipefail mobile_root="${RUNNER_TEMP}/aetherkiri-renpy-mobile/ios" - tools/install_renpy_mobile_support.sh \ + bash tools/install_renpy_mobile_support.sh \ --platform ios \ --destination "$mobile_root" test -f "$mobile_root/renios/prototype/prebuilt/release/librenpython.a" From 1573cddbfbaf872b8686050d90b9fb11fbb7448d Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 01:51:09 +0800 Subject: [PATCH 188/331] ci(renpy): add pinned Windows SDK installer support --- tools/install_renpy_sdk.sh | 69 ++++++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/tools/install_renpy_sdk.sh b/tools/install_renpy_sdk.sh index 18fab03aa..397da6ef6 100755 --- a/tools/install_renpy_sdk.sh +++ b/tools/install_renpy_sdk.sh @@ -10,9 +10,14 @@ readonly RENPY_LINUX_ARCHIVE="renpy-${RENPY_VERSION}-sdk.tar.bz2" readonly RENPY_LINUX_SHA256="eb0a9be7f0fb13632fe25ceade9a8bed5a1b4d6b6e83bd19eeeb29e1a1bb4a45" readonly RENPY_MACOS_ARM_ARCHIVE="renpy-${RENPY_VERSION}-sdkarm.tar.bz2" readonly RENPY_MACOS_ARM_SHA256="0579782517f203ba3535dcc2dab54e34bfc318f2f2a7510a5130b6f809f901f6" +readonly RENPY_WINDOWS_ARCHIVE="renpy-${RENPY_VERSION}-sdk.zip" +readonly RENPY_WINDOWS_SHA256="ff57648f9c04f27e381c48af6d8e3ee3cdec296bed4d3831f47f09b0a71b505e" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" platform="$(uname -s | tr '[:upper:]' '[:lower:]')" +case "$platform" in + mingw*|msys*|cygwin*) platform="windows" ;; +esac arch="$(uname -m)" destination="${AETHERKIRI_RENPY_SDK_ROOT:-${repo_root}/.aetherkiri-cache/renpy-sdk/${RENPY_VERSION}}" cache_dir="${AETHERKIRI_RENPY_SDK_CACHE_DIR:-${repo_root}/.aetherkiri-cache/renpy-sdk/downloads}" @@ -27,7 +32,7 @@ archive is SHA-256 verified before it is used. Existing verified SDK roots are reused without downloading or extracting again. Options: - --platform PLATFORM linux or darwin (default: host platform) + --platform PLATFORM linux, darwin, or windows (default: host platform) --arch ARCH x86_64 or arm64 (default: host architecture) --destination PATH SDK extraction directory --cache-dir PATH Download cache directory @@ -89,6 +94,14 @@ case "$platform" in sha256="$RENPY_LINUX_SHA256" fi ;; + windows) + [[ "$arch" == "x86_64" || "$arch" == "amd64" ]] || { + echo "Ren'Py SDK is only enabled for Windows x86_64 (requested $arch)" >&2 + exit 1 + } + archive="$RENPY_WINDOWS_ARCHIVE" + sha256="$RENPY_WINDOWS_SHA256" + ;; *) echo "Ren'Py SDK is not available for platform '$platform' (desktop Linux/macOS only)" >&2 exit 1 @@ -111,7 +124,15 @@ require_command() { } } require_command curl -require_command tar +if [[ "$platform" == "windows" ]]; then + if ! command -v unzip >/dev/null 2>&1 && + ! command -v 7z >/dev/null 2>&1; then + echo "required command not found: unzip or 7z" >&2 + exit 1 + fi +else + require_command tar +fi if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then echo "required command not found: sha256sum or shasum" >&2 @@ -144,8 +165,8 @@ fi # A previous interrupted extraction must never be mistaken for a valid SDK. launcher="$destination/renpy.sh" -if [[ "$platform" == "darwin" ]]; then - launcher="$destination/renpy.sh" +if [[ "$platform" == "windows" ]]; then + launcher="$destination/renpy.exe" fi if [[ -x "$launcher" ]]; then printf 'Ren\x27Py SDK already installed: %s\n' "$destination" @@ -156,9 +177,21 @@ fi extract_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-sdk.XXXXXX")" cleanup() { rm -rf "$extract_root"; } trap cleanup EXIT -# Ensure tar cannot write outside the temporary extraction root. The official -# archive has one relative top-level directory; reject malformed archives. -if ! tar -tjf "$archive_path" | while IFS= read -r member; do +# Ensure the archive cannot write outside the temporary extraction root. The +# official archive has one relative top-level directory; reject malformed +# archives before extraction on every desktop host. +list_archive_members() { + if [[ "$platform" == "windows" ]]; then + if command -v unzip >/dev/null 2>&1; then + unzip -Z1 "$archive_path" + else + 7z l -slt "$archive_path" | sed -n 's/^Path = //p' + fi + else + tar -tjf "$archive_path" + fi +} +if ! list_archive_members | while IFS= read -r member; do case "$member" in /*|../*|*/../*|*/..|..|*\\*) echo "Refusing archive with an unsafe path: $member" >&2 @@ -168,14 +201,28 @@ if ! tar -tjf "$archive_path" | while IFS= read -r member; do done; then exit 1 fi -tar -xjf "$archive_path" -C "$extract_root" +if [[ "$platform" == "windows" ]]; then + if command -v unzip >/dev/null 2>&1; then + unzip -q "$archive_path" -d "$extract_root" + else + 7z x -y "-o$extract_root" "$archive_path" >/dev/null + fi +else + tar -xjf "$archive_path" -C "$extract_root" +fi extracted="$(find "$extract_root" -mindepth 1 -maxdepth 1 -type d -print -quit)" -if [[ -z "$extracted" || ! -x "$extracted/renpy.sh" ]]; then - echo "Official Ren'Py archive did not contain renpy.sh" >&2 +launcher_name="renpy.sh" +if [[ "$platform" == "windows" ]]; then + launcher_name="renpy.exe" +fi +if [[ -z "$extracted" || ! -f "$extracted/$launcher_name" ]]; then + echo "Official Ren'Py archive did not contain $launcher_name" >&2 exit 1 fi rm -rf "$destination" mv "$extracted" "$destination" -chmod +x "$destination/renpy.sh" +if [[ "$platform" != "windows" ]]; then + chmod +x "$destination/renpy.sh" +fi printf 'Ren\x27Py SDK installed: %s\n' "$destination" printf 'RENPY_SDK_ROOT=%s\n' "$destination" From e649ad1f77a34906304c246d1e8ece65225deeee Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 01:52:44 +0800 Subject: [PATCH 189/331] test(renpy): cover Windows SDK installer metadata --- tools/test_install_renpy_sdk.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tools/test_install_renpy_sdk.sh b/tools/test_install_renpy_sdk.sh index d7bb37d8b..334b403d3 100755 --- a/tools/test_install_renpy_sdk.sh +++ b/tools/test_install_renpy_sdk.sh @@ -19,8 +19,13 @@ mac_x86_config="$($installer --platform darwin --arch x86_64 --print-config)" grep -Fx 'RENPY_ARCHIVE=renpy-8.5.3-sdk.tar.bz2' <<<"$mac_x86_config" grep -Fx 'RENPY_SHA256=eb0a9be7f0fb13632fe25ceade9a8bed5a1b4d6b6e83bd19eeeb29e1a1bb4a45' <<<"$mac_x86_config" -if "$installer" --platform windows --arch x86_64 --print-config >/dev/null 2>&1; then - echo "installer accepted unsupported Windows platform" >&2 +windows_config="$($installer --platform windows --arch x86_64 --print-config)" +grep -Fx 'RENPY_ARCHIVE=renpy-8.5.3-sdk.zip' <<<"$windows_config" +grep -Fx 'RENPY_SHA256=ff57648f9c04f27e381c48af6d8e3ee3cdec296bed4d3831f47f09b0a71b505e' <<<"$windows_config" +grep -Fx 'RENPY_URL=https://www.renpy.org/dl/8.5.3/renpy-8.5.3-sdk.zip' <<<"$windows_config" + +if "$installer" --platform windows --arch arm64 --print-config >/dev/null 2>&1; then + echo "installer accepted unsupported Windows architecture" >&2 exit 1 fi if "$installer" --platform linux --arch arm64 --print-config >/dev/null 2>&1; then From d38e1da788d8eae09c36de10efb5e930613f01fb Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 01:54:09 +0800 Subject: [PATCH 190/331] ci(renpy): add Linux and Windows desktop smoke matrix --- .github/workflows/renpy-desktop.yml | 63 +++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/renpy-desktop.yml diff --git a/.github/workflows/renpy-desktop.yml b/.github/workflows/renpy-desktop.yml new file mode 100644 index 000000000..b4ea9dd1d --- /dev/null +++ b/.github/workflows/renpy-desktop.yml @@ -0,0 +1,63 @@ +name: Ren'Py Desktop Acceptance + +on: + push: + branches: + - main + pull_request: + paths: + - '.github/workflows/renpy-desktop.yml' + - 'bridge/renpy_runtime/**' + - 'tests/fixtures/renpy_smoke/**' + - 'tools/install_renpy_sdk.sh' + - 'tools/run_renpy_smoke.sh' + - 'tools/test_install_renpy_sdk.sh' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: renpy-desktop-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + desktop-sdk: + name: Ren'Py desktop smoke (${{ matrix.platform }}) + strategy: + fail-fast: false + matrix: + include: + - platform: linux + os: ubuntu-latest + - platform: windows + os: windows-latest + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Install Linux prerequisites + if: matrix.platform == 'linux' + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y curl tar bzip2 unzip + + - name: Validate installer metadata and shell syntax + run: bash tools/test_install_renpy_sdk.sh + + - name: Install pinned Ren'Py SDK and run fixture smoke + env: + RENPY_PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + sdk_root="${RUNNER_TEMP}/aetherkiri-renpy-sdk" + bash tools/install_renpy_sdk.sh --platform "${RENPY_PLATFORM}" --arch x86_64 --destination "${sdk_root}" + test -f "${sdk_root}/renpy.sh" || test -f "${sdk_root}/renpy.exe" + RENPY_SDK="${sdk_root}" bash tools/run_renpy_smoke.sh From cb36386126ddfb298c7887e31d6bf44797ae2073 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 01:55:25 +0800 Subject: [PATCH 191/331] fix(renpy): allow Windows SDK launcher mode bits --- tools/run_renpy_smoke.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/run_renpy_smoke.sh b/tools/run_renpy_smoke.sh index 5381b2dfd..4c35b6c25 100755 --- a/tools/run_renpy_smoke.sh +++ b/tools/run_renpy_smoke.sh @@ -47,7 +47,9 @@ if [[ -z "$renpy_bin" && -n "$renpy_sdk" ]]; then renpy_bin="$renpy_sdk/renpy.sh" elif [[ -x "$renpy_sdk/renpy" ]]; then renpy_bin="$renpy_sdk/renpy" - elif [[ -x "$renpy_sdk/renpy.exe" ]]; then + elif [[ -f "$renpy_sdk/renpy.exe" ]]; then + # Windows zip archives do not always preserve the executable mode bit + # when extracted by Git Bash, but the .exe is still directly runnable. renpy_bin="$renpy_sdk/renpy.exe" else printf "Ren'Py launcher not found under RENPY_SDK: %s\n" "$renpy_sdk" >&2 @@ -64,7 +66,8 @@ if [[ -z "$renpy_bin" ]]; then exit 1 fi fi -if [[ ! -x "$renpy_bin" ]]; then +if [[ ! -x "$renpy_bin" && + ! ( "$renpy_bin" == *.exe && -f "$renpy_bin" ) ]]; then printf "Ren'Py launcher is not executable: %s\n" "$renpy_bin" >&2 exit 1 fi From 626354614c331027d1817060d210ad4e051617fd Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 01:57:22 +0800 Subject: [PATCH 192/331] ci(renpy): validate mobile lifecycle contract scaffold --- .github/workflows/renpy-mobile-contract.yml | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/renpy-mobile-contract.yml diff --git a/.github/workflows/renpy-mobile-contract.yml b/.github/workflows/renpy-mobile-contract.yml new file mode 100644 index 000000000..6dbe1ffc9 --- /dev/null +++ b/.github/workflows/renpy-mobile-contract.yml @@ -0,0 +1,54 @@ +name: Ren'Py Mobile Contract + +on: + push: + branches: + - main + pull_request: + paths: + - '.github/workflows/renpy-mobile-contract.yml' + - 'bridge/renpy_runtime/mobile_launcher/**' + - 'bridge/renpy_runtime/src/renpy_mobile_adapter.cpp' + - 'bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp' + - 'bridge/renpy_runtime/tests/mobile_*.cpp' + - 'tools/test_renpy_mobile_launcher_*.sh' + - 'tools/test_stage_renpy_android_support.sh' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: renpy-mobile-contract-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + lifecycle-contract: + name: Validate host lifecycle contract (no playability claim) + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Install contract test tools + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y build-essential binutils + + - name: Run mobile launcher scaffold and loader tests + run: | + set -euo pipefail + bash tools/test_renpy_mobile_launcher_scaffold.sh + bash tools/test_renpy_mobile_launcher_loader.sh + + - name: Assert staged archives remain non-playable + run: | + set -euo pipefail + grep -F 'returns ENGINE_RESULT_NOT_SUPPORTED' bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp + grep -F 'does not contain a playable launcher' bridge/renpy_runtime/mobile_launcher/README.md From 2ee1deeec8650e94999f18ff72da6d5148f0726d Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 01:57:57 +0800 Subject: [PATCH 193/331] fix(ci): match mobile guard contract text From ce4a33371fb4017d472f7d1218753a234d519c82 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 01:58:32 +0800 Subject: [PATCH 194/331] test(renpy): match loader dlsym contract --- tools/test_renpy_mobile_launcher_scaffold.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/test_renpy_mobile_launcher_scaffold.sh b/tools/test_renpy_mobile_launcher_scaffold.sh index 32389795f..c8d32554b 100644 --- a/tools/test_renpy_mobile_launcher_scaffold.sh +++ b/tools/test_renpy_mobile_launcher_scaffold.sh @@ -16,7 +16,8 @@ renpy_source="${RENPY_SOURCE_TEST_ROOT:-/tmp/renpy-src}" bash -n "$script" grep -Fq 'librenpython.so' "$loader" -grep -Fq 'weak_import' "$loader" +grep -Fq 'dlsym' "$loader" +grep -Fq 'RTLD_DEFAULT' "$loader" for symbol in \ renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input \ renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown; do From f8acfc92b116e1b418c6a15141a5782bdd4d053c Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 01:59:05 +0800 Subject: [PATCH 195/331] fix(ci): match scaffold guard text across wrapped lines From bfd7d15115950a16a3567e8787783682c59d422b Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:02:05 +0800 Subject: [PATCH 196/331] test(renpy): add mobile lifecycle symbol gate fixture --- tools/test_renpy_mobile_symbol_gate.sh | 93 ++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tools/test_renpy_mobile_symbol_gate.sh diff --git a/tools/test_renpy_mobile_symbol_gate.sh b/tools/test_renpy_mobile_symbol_gate.sh new file mode 100644 index 000000000..6e80a484c --- /dev/null +++ b/tools/test_renpy_mobile_symbol_gate.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Validate the host lifecycle export surface without claiming mobile playability. +# +# With no artifact paths this runs a deterministic shared-library fixture and +# a negative missing-symbol check. CI can pass RENPY_MOBILE_ANDROID_SO and/or +# RENPY_MOBILE_IOS_A to gate a rebuilt native payload once one is available. +set -euo pipefail + +required_symbols=( + renpy_mobile_init + renpy_mobile_tick + renpy_mobile_frame + renpy_mobile_input + renpy_mobile_pause + renpy_mobile_resume + renpy_mobile_shutdown +) + +nm_listing() { + local artifact="$1" kind="$2" + [[ -f "$artifact" ]] || { + echo "lifecycle artifact does not exist: $artifact" >&2 + return 1 + } + local listing + listing="$(mktemp)" + case "$kind" in + shared) + if ! nm -D --defined-only "$artifact" >"$listing" 2>/dev/null; then + nm -gU "$artifact" >"$listing" + fi + ;; + static) + if ! nm -gU "$artifact" >"$listing" 2>/dev/null; then + nm -g "$artifact" >"$listing" + fi + ;; + *) echo "unknown artifact kind: $kind" >&2; rm -f "$listing"; return 2 ;; + esac + printf '%s\n' "$listing" +} + +check_symbols() { + local artifact="$1" kind="$2" listing + listing="$(nm_listing "$artifact" "$kind")" + local missing=0 symbol + for symbol in "${required_symbols[@]}"; do + if ! grep -Eq "(^|[[:space:]])${symbol}([[:space:]]|$)" "$listing"; then + echo "missing lifecycle export: ${symbol} (${artifact})" >&2 + missing=1 + fi + done + rm -f "$listing" + ((missing == 0)) +} + +check_optional_artifact() { + local variable_name="$1" kind="$2" artifact="${!variable_name:-}" + if [[ -n "$artifact" ]]; then + echo "Checking lifecycle exports from ${variable_name}: ${artifact}" + check_symbols "$artifact" "$kind" + fi +} + +check_optional_artifact RENPY_MOBILE_ANDROID_SO shared +check_optional_artifact RENPY_MOBILE_IOS_A static + +work="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-symbol-gate.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +cat >"$work/fixture.c" <<'C' +#define EXPORT __attribute__((visibility("default"))) +EXPORT int renpy_mobile_init(void) { return 0; } +EXPORT int renpy_mobile_tick(void) { return 0; } +EXPORT int renpy_mobile_frame(void) { return 0; } +EXPORT int renpy_mobile_input(void) { return 0; } +EXPORT int renpy_mobile_pause(void) { return 0; } +EXPORT int renpy_mobile_resume(void) { return 0; } +EXPORT void renpy_mobile_shutdown(void) {} +C +"${CC:-cc}" -shared -fPIC "$work/fixture.c" -o "$work/librenpython.so" +check_symbols "$work/librenpython.so" shared + +cat >"$work/missing.c" <<'C' +int renpy_mobile_init(void) { return 0; } +C +"${CC:-cc}" -shared -fPIC "$work/missing.c" -o "$work/librenpython-missing.so" +if check_symbols "$work/librenpython-missing.so" shared; then + echo "missing lifecycle export fixture unexpectedly passed" >&2 + exit 1 +fi + +echo "Ren'Py mobile lifecycle export gate passed (fixture + negative check)" From b05f3e6322c7ab968bf1b747f48e26f5237c9fdb Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:02:42 +0800 Subject: [PATCH 197/331] fix(test): handle optional lifecycle artifact variables --- tools/test_renpy_mobile_symbol_gate.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/test_renpy_mobile_symbol_gate.sh b/tools/test_renpy_mobile_symbol_gate.sh index 6e80a484c..42f77ab5d 100644 --- a/tools/test_renpy_mobile_symbol_gate.sh +++ b/tools/test_renpy_mobile_symbol_gate.sh @@ -55,7 +55,10 @@ check_symbols() { } check_optional_artifact() { - local variable_name="$1" kind="$2" artifact="${!variable_name:-}" + local variable_name="$1" kind="$2" artifact="" + if [[ -n "${!variable_name+x}" ]]; then + artifact="${!variable_name}" + fi if [[ -n "$artifact" ]]; then echo "Checking lifecycle exports from ${variable_name}: ${artifact}" check_symbols "$artifact" "$kind" From c8209776c215c15ea35cbc9ce9be4566509d55cb Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:03:19 +0800 Subject: [PATCH 198/331] ci(renpy): run lifecycle export gate fixture --- .github/workflows/renpy-mobile-contract.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/renpy-mobile-contract.yml b/.github/workflows/renpy-mobile-contract.yml index 6dbe1ffc9..e816b0a65 100644 --- a/.github/workflows/renpy-mobile-contract.yml +++ b/.github/workflows/renpy-mobile-contract.yml @@ -12,6 +12,7 @@ on: - 'bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp' - 'bridge/renpy_runtime/tests/mobile_*.cpp' - 'tools/test_renpy_mobile_launcher_*.sh' + - 'tools/test_renpy_mobile_symbol_gate.sh' - 'tools/test_stage_renpy_android_support.sh' workflow_dispatch: @@ -46,6 +47,7 @@ jobs: set -euo pipefail bash tools/test_renpy_mobile_launcher_scaffold.sh bash tools/test_renpy_mobile_launcher_loader.sh + bash tools/test_renpy_mobile_symbol_gate.sh - name: Assert staged archives remain non-playable run: | From 52285df411a7e994112c1f7c5dacf30db886a003 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:05:14 +0800 Subject: [PATCH 199/331] build(renpy): enforce mobile ABI exports and Android architectures --- bridge/renpy_runtime/mobile_launcher/build.sh | 91 +++++++++++++++---- 1 file changed, 73 insertions(+), 18 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/build.sh b/bridge/renpy_runtime/mobile_launcher/build.sh index 1f1084719..6735b93b3 100644 --- a/bridge/renpy_runtime/mobile_launcher/build.sh +++ b/bridge/renpy_runtime/mobile_launcher/build.sh @@ -400,57 +400,112 @@ print_plan } required_symbols=( - renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input - renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown + renpy_mobile_init + renpy_mobile_tick + renpy_mobile_frame + renpy_mobile_input + renpy_mobile_pause + renpy_mobile_resume + renpy_mobile_shutdown ) + +nm_symbols() { + local artifact="$1" kind="$2" + case "$kind" in + android) + nm -D --defined-only "$artifact" + ;; + ios) + if command -v xcrun >/dev/null 2>&1; then + xcrun nm -gU "$artifact" + else + nm -g "$artifact" + fi + ;; + *) echo "unknown lifecycle artifact kind: $kind" >&2; return 2 ;; + esac +} + check_symbols() { - local artifact="$1" + local artifact="$1" kind="$2" expected_machine="${3:-}" [[ -f "$artifact" ]] || { echo "artifact not found: $artifact" >&2; exit 1; } if strings "$artifact" | grep -Fq 'AETHERKIRI_RENPY_LIFECYCLE_STUB'; then echo "refusing contract-only lifecycle stub as runtime artifact: $artifact" >&2 exit 1 fi + local format format="$(file -b "$artifact" 2>/dev/null || true)" - for symbol in "${required_symbols[@]}"; do - if [[ "$format" == *ELF* ]] && command -v nm >/dev/null 2>&1; then - if ! nm -D --defined-only "$artifact" 2>/dev/null | awk '{print $3}' | grep -Fxq "$symbol"; then - echo "artifact lacks required lifecycle export $symbol: $artifact" >&2 + case "$kind" in + android) + [[ "$format" == *ELF* && "$format" == *shared\ object* ]] || { + echo "Android lifecycle artifact is not an ELF shared object: $artifact ($format)" >&2 exit 1 + } + if [[ -n "$expected_machine" ]]; then + command -v readelf >/dev/null 2>&1 || { + echo "readelf is required to validate Android ABI: $artifact" >&2 + exit 1 + } + local machine + machine="$(readelf -h "$artifact" | sed -n 's/^ *Machine: *//p')" + [[ "$machine" == "$expected_machine" ]] || { + echo "Android lifecycle artifact ABI mismatch: expected $expected_machine, got $machine" >&2 + exit 1 + } fi - elif ! strings "$artifact" | grep -Fqx "$symbol"; then - # Linux nm cannot inspect the Mach-O universal archives emitted by - # the iOS hook; strings is the portable fallback for those files. + ;; + ios) + [[ "$format" == *archive* || "$format" == *Mach-O* ]] || { + echo "iOS lifecycle artifact is not a static archive: $artifact ($format)" >&2 + exit 1 + } + ;; + *) echo "unknown lifecycle artifact kind: $kind" >&2; exit 2 ;; + esac + + local symbols + symbols="$(mktemp)" + if ! nm_symbols "$artifact" "$kind" >"$symbols" 2>/dev/null; then + echo "could not inspect lifecycle symbols in $artifact; install the native nm/xcrun toolchain" >&2 + rm -f "$symbols" + exit 1 + fi + local symbol + for symbol in "${required_symbols[@]}"; do + if ! awk '{print $NF}' "$symbols" | grep -Fxq "$symbol"; then echo "artifact lacks required lifecycle export $symbol: $artifact" >&2 + rm -f "$symbols" exit 1 fi done + rm -f "$symbols" } install_one() { - local source="$1" destination="$2" - check_symbols "$source" + local source="$1" destination="$2" kind="$3" expected_machine="${4:-}" + check_symbols "$source" "$kind" "$expected_machine" mkdir -p "$(dirname "$destination")" install -m 0755 "$source" "$destination" echo "installed host lifecycle artifact: $destination" } # Each Android ABI is explicit so an arm64 binary cannot silently be copied -# into an armeabi-v7a or x86_64 slot. +# into an armv7/x86_64 slot. The expected machine is checked from ELF headers. if [[ -n "$android_so_arm64" ]]; then - install_one "$android_so_arm64" "$stage/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" + install_one "$android_so_arm64" "$stage/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" android "AArch64" fi if [[ -n "$android_so_armv7" ]]; then - install_one "$android_so_armv7" "$stage/rapt/prototype/renpyandroid/src/main/jniLibs/armeabi-v7a/librenpython.so" + install_one "$android_so_armv7" "$stage/rapt/prototype/renpyandroid/src/main/jniLibs/armeabi-v7a/librenpython.so" android "ARM" fi if [[ -n "$android_so_x86_64" ]]; then - install_one "$android_so_x86_64" "$stage/rapt/prototype/renpyandroid/src/main/jniLibs/x86_64/librenpython.so" + install_one "$android_so_x86_64" "$stage/rapt/prototype/renpyandroid/src/main/jniLibs/x86_64/librenpython.so" android "Advanced Micro Devices X86-64" fi if [[ -n "$ios_release_a" ]]; then - install_one "$ios_release_a" "$stage/renios/prototype/prebuilt/release/librenpython.a" + install_one "$ios_release_a" "$stage/renios/prototype/prebuilt/release/librenpython.a" ios fi if [[ -n "$ios_debug_a" ]]; then - install_one "$ios_debug_a" "$stage/renios/prototype/prebuilt/debug/librenpython.a" + install_one "$ios_debug_a" "$stage/renios/prototype/prebuilt/debug/librenpython.a" ios fi echo "Artifacts installed, but playable mobile support is not asserted by this scaffold" From c521c3d3791b2c597194f96e7a77931ae56bc8b9 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:07:50 +0800 Subject: [PATCH 200/331] fix(renpy): normalize Mach-O nm symbol prefixes --- bridge/renpy_runtime/mobile_launcher/build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridge/renpy_runtime/mobile_launcher/build.sh b/bridge/renpy_runtime/mobile_launcher/build.sh index 6735b93b3..c9f6a7f88 100644 --- a/bridge/renpy_runtime/mobile_launcher/build.sh +++ b/bridge/renpy_runtime/mobile_launcher/build.sh @@ -473,7 +473,7 @@ check_symbols() { fi local symbol for symbol in "${required_symbols[@]}"; do - if ! awk '{print $NF}' "$symbols" | grep -Fxq "$symbol"; then + if ! awk '{print $NF}' "$symbols" | sed 's/^_//' | grep -Fxq "$symbol"; then echo "artifact lacks required lifecycle export $symbol: $artifact" >&2 rm -f "$symbols" exit 1 From b2e1560e8cbe35fd30c139324e46a3471677c819 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:09:56 +0800 Subject: [PATCH 201/331] build(renpy): add opt-in mobile source preflight and build runner --- tools/run_renpy_mobile_source_build.sh | 153 +++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 tools/run_renpy_mobile_source_build.sh diff --git a/tools/run_renpy_mobile_source_build.sh b/tools/run_renpy_mobile_source_build.sh new file mode 100644 index 000000000..7403b0257 --- /dev/null +++ b/tools/run_renpy_mobile_source_build.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Optional Ren'Py native mobile source build orchestration. +# +# The official renpy-build checkout and iOS SDK archives are external/licensed +# inputs. Default "auto" mode is therefore a clear skip when no checkout is +# supplied; it never turns staged RAPT/Renios archives into playable payloads. +# Use --mode check for strict CI preflight or --mode build after provisioning +# Ubuntu 24.04, Android NDK, and the licensed iOS SDK archives. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +launcher="$repo_root/bridge/renpy_runtime/mobile_launcher/build.sh" +mode="${AETHERKIRI_RENPY_SOURCE_BUILD_MODE:-auto}" +renpy_build="${AETHERKIRI_RENPY_BUILD_ROOT:-}" +fetch=false +source_url="${AETHERKIRI_RENPY_BUILD_URL:-https://github.com/renpy/renpy-build.git}" +source_ref="${AETHERKIRI_RENPY_BUILD_REF:-}" + +usage() { + cat <<'USAGE' +Usage: tools/run_renpy_mobile_source_build.sh [options] + +Options: + --mode MODE auto (default), skip, check, or build + --renpy-build PATH existing official renpy-build checkout + --fetch clone the official checkout when PATH is absent + --url URL source URL used with --fetch + --ref REF branch/tag/commit used with --fetch + -h, --help show this help + +Environment: + AETHERKIRI_RENPY_SOURCE_BUILD_MODE + AETHERKIRI_RENPY_BUILD_ROOT + AETHERKIRI_RENPY_BUILD_URL + AETHERKIRI_RENPY_BUILD_REF + AETHERKIRI_RENPY_SOURCE_BUILD_REQUIRED=1 + AETHERKIRI_RENPY_BUILD_IOS=1 # build iOS after Android in --mode build + +The build mode runs renpy-build's Android lifecycle-fork hooks and, when +explicitly enabled, its iOS hooks. It validates canonical outputs and the +seven host lifecycle exports before any staged install. It does not claim +device/simulator playability. +USAGE +} + +while (($#)); do + case "$1" in + --mode) + (($# >= 2)) || { echo "--mode requires a value" >&2; exit 2; } + mode="$2"; shift 2 ;; + --renpy-build) + (($# >= 2)) || { echo "--renpy-build requires a path" >&2; exit 2; } + renpy_build="$2"; shift 2 ;; + --fetch) fetch=true; shift ;; + --url) + (($# >= 2)) || { echo "--url requires a value" >&2; exit 2; } + source_url="$2"; shift 2 ;; + --ref) + (($# >= 2)) || { echo "--ref requires a value" >&2; exit 2; } + source_ref="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +case "$mode" in + auto|skip|check|build) ;; + *) echo "--mode must be auto, skip, check, or build (got '$mode')" >&2; exit 2 ;; +esac +[[ -x "$launcher" ]] || { echo "missing launcher scaffold: $launcher" >&2; exit 1; } + +if [[ "$mode" == "skip" ]]; then + echo "Ren'Py mobile source build skipped by request (no payload built)" + exit 0 +fi + +if [[ -z "$renpy_build" ]]; then + renpy_build="$repo_root/.aetherkiri-cache/renpy-build" +fi + +if [[ ! -d "$renpy_build" && "$fetch" == true ]]; then + command -v git >/dev/null 2>&1 || { echo "git is required for --fetch" >&2; exit 1; } + mkdir -p "$(dirname "$renpy_build")" + clone_args=(clone --filter=blob:none) + [[ -n "$source_ref" ]] && clone_args+=(--branch "$source_ref") + clone_args+=("$source_url" "$renpy_build") + echo "Fetching official renpy-build source: $source_url" + git "${clone_args[@]}" +fi + +if [[ ! -d "$renpy_build" ]]; then + if [[ "$mode" == "auto" && "${AETHERKIRI_RENPY_SOURCE_BUILD_REQUIRED:-0}" != "1" ]]; then + echo "Ren'Py mobile source build skipped: official renpy-build checkout is absent" + echo " provide --renpy-build PATH or set AETHERKIRI_RENPY_SOURCE_BUILD_REQUIRED=1" + exit 0 + fi + echo "Ren'Py mobile source build requires an official renpy-build checkout: $renpy_build" >&2 + echo " clone: git clone $source_url $renpy_build" >&2 + exit 1 +fi + +echo "Running strict Ren'Py mobile source preflight: $renpy_build" +if ! preflight_output="$("$launcher" --check-renpy-build --renpy-build "$renpy_build" 2>&1)"; then + printf '%s\n' "$preflight_output" >&2 + echo "Ren'Py mobile source preflight failed; no payload was built" >&2 + exit 1 +fi +printf '%s\n' "$preflight_output" + +if [[ "$mode" == "check" || "$mode" == "auto" ]]; then + echo "Ren'Py mobile source preflight passed; build not requested" + exit 0 +fi + +build_script="$renpy_build/build.sh" +[[ -x "$build_script" ]] || { echo "renpy-build/build.sh is not executable: $build_script" >&2; exit 1; } + +echo "Building Android lifecycle payload (opt-in, potentially multi-hour)" +( + cd "$renpy_build" + ./build.sh --platform android rebuild renpython rapt rapt-sdl2 +) + +artifact_root="$renpy_build/renpy" +android_arm64="$artifact_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" +android_armv7="$artifact_root/rapt/prototype/renpyandroid/src/main/jniLibs/armeabi-v7a/librenpython.so" +android_x86_64="$artifact_root/rapt/prototype/renpyandroid/src/main/jniLibs/x86_64/librenpython.so" +for artifact in "$android_arm64" "$android_armv7" "$android_x86_64"; do + [[ -f "$artifact" ]] || { echo "Android lifecycle artifact missing: $artifact" >&2; exit 1; } +done + +RENPY_MOBILE_ANDROID_SO="$android_arm64" bash "$repo_root/tools/test_renpy_mobile_symbol_gate.sh" + +if [[ "${AETHERKIRI_RENPY_BUILD_IOS:-0}" == "1" ]]; then + echo "Building iOS lifecycle payload (licensed SDK archives required)" + ( + cd "$renpy_build" + ./build.sh --platform ios rebuild renpython renios + ) + ios_release="$artifact_root/renios/prototype/prebuilt/release/librenpython.a" + ios_debug="$artifact_root/renios/prototype/prebuilt/debug/librenpython.a" + for artifact in "$ios_release" "$ios_debug"; do + [[ -f "$artifact" ]] || { echo "iOS lifecycle artifact missing: $artifact" >&2; exit 1; } + done + if command -v xcrun >/dev/null 2>&1; then + RENPY_MOBILE_IOS_A="$ios_release" bash "$repo_root/tools/test_renpy_mobile_symbol_gate.sh" + else + echo "iOS payload built but xcrun is unavailable; export check deferred to macOS" >&2 + exit 1 + fi +fi + +echo "Native lifecycle payload built and export-gated; device/simulator gameplay is still unverified" From 8976565fae9cecae710ca5892d66d30f99e8b00b Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:10:53 +0800 Subject: [PATCH 202/331] test(renpy): cover optional mobile source build runner --- tools/test_renpy_mobile_source_build_plan.sh | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tools/test_renpy_mobile_source_build_plan.sh b/tools/test_renpy_mobile_source_build_plan.sh index f3b90829b..59fa6e2ca 100644 --- a/tools/test_renpy_mobile_source_build_plan.sh +++ b/tools/test_renpy_mobile_source_build_plan.sh @@ -5,8 +5,22 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" launcher="$repo_root/bridge/renpy_runtime/mobile_launcher/build.sh" +runner="$repo_root/tools/run_renpy_mobile_source_build.sh" plan="$(mktemp)" -trap 'rm -f "$plan"' EXIT +runner_tmp="$(mktemp -d "${TMPDIR:-/tmp}/renpy-mobile-source-runner.XXXXXX")" +trap 'rm -f "$plan"; rm -rf "$runner_tmp"' EXIT + +[[ -x "$runner" ]] || { echo "missing mobile source build runner" >&2; exit 1; } +bash -n "$runner" +skip_output="$( + AETHERKIRI_RENPY_BUILD_ROOT="$runner_tmp/missing" "$runner" --mode auto +)" +grep -Fq 'source build skipped' <<<"$skip_output" +if AETHERKIRI_RENPY_SOURCE_BUILD_REQUIRED=1 AETHERKIRI_RENPY_BUILD_ROOT="$runner_tmp/missing" "$runner" --mode auto >"$runner_tmp/required.stdout" 2>"$runner_tmp/required.stderr"; then + echo "required source build unexpectedly skipped" >&2 + exit 1 +fi +grep -Fq 'requires an official renpy-build checkout' "$runner_tmp/required.stderr" bash "$launcher" --source-build-plan --renpy-build "$repo_root" >"$plan" grep -F "./build.sh --platform android rebuild rapt rapt-sdl2" "$plan" From 2aebc8142360064f1117fb52733455da1569f0de Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:11:15 +0800 Subject: [PATCH 203/331] ci(renpy): run optional mobile source preflight --- .github/workflows/renpy-mobile-contract.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/renpy-mobile-contract.yml b/.github/workflows/renpy-mobile-contract.yml index e816b0a65..fdab83d6d 100644 --- a/.github/workflows/renpy-mobile-contract.yml +++ b/.github/workflows/renpy-mobile-contract.yml @@ -13,6 +13,8 @@ on: - 'bridge/renpy_runtime/tests/mobile_*.cpp' - 'tools/test_renpy_mobile_launcher_*.sh' - 'tools/test_renpy_mobile_symbol_gate.sh' + - 'tools/run_renpy_mobile_source_build.sh' + - 'tools/test_renpy_mobile_source_build_plan.sh' - 'tools/test_stage_renpy_android_support.sh' workflow_dispatch: @@ -48,6 +50,8 @@ jobs: bash tools/test_renpy_mobile_launcher_scaffold.sh bash tools/test_renpy_mobile_launcher_loader.sh bash tools/test_renpy_mobile_symbol_gate.sh + bash tools/test_renpy_mobile_source_build_plan.sh + bash tools/run_renpy_mobile_source_build.sh --mode auto - name: Assert staged archives remain non-playable run: | From a3f9cea5844c736dd3928fd6d6f3ac28b99831b5 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:13:27 +0800 Subject: [PATCH 204/331] docs(renpy): document desktop and mobile acceptance matrix --- bridge/renpy_runtime/README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/bridge/renpy_runtime/README.md b/bridge/renpy_runtime/README.md index 5b9d3548f..115981be4 100644 --- a/bridge/renpy_runtime/README.md +++ b/bridge/renpy_runtime/README.md @@ -63,6 +63,36 @@ The provider's desktop integration remains guarded by `AETHERKIRI_ENABLE_RENPY` and requires a writable `game/libs` directory to stage the hook. +## Cross-platform acceptance matrix + +The runtime is only considered playable on a platform after the corresponding +row passes its real runtime checks. Building or staging an official SDK alone +does not change the mobile provider guard. + +| Platform | Current CI evidence | Required runtime gate | +| --- | --- | --- | +| Linux x86_64 | tools/test_install_renpy_sdk.sh plus tools/run_renpy_smoke.sh in the desktop matrix | CMake provider tests, Godot frame/input E2E, and clean process shutdown | +| Windows x64 | Pinned renpy-8.5.3-sdk.zip metadata/install plus fixture smoke in the desktop matrix | Native renpy.exe provider E2E (CreateProcessW, frame/input, shutdown); the POSIX fake-launcher mapping test is intentionally skipped | +| Android arm64 | APK builds, RAPT staging, ABI guard, and lifecycle contract/export fixtures | A rebuilt RAPT payload exporting all seven host lifecycle symbols, then arm64 emulator and device E2E for Activity ownership, RGBA frames, touch/text input, pause/resume, and reopen | +| iOS arm64 simulator/device | IPA/Xcode builds, Renios staging, ABI guard, and lifecycle contract/export fixtures | A rebuilt Renios payload with host lifecycle exports, simulator and device E2E for single UIApplication/SDL ownership, RGBA frames, input, pause/resume, and reopen | + +Desktop smoke commands: + + bash tools/test_install_renpy_sdk.sh + bash tools/install_renpy_sdk.sh --platform linux --arch x86_64 --destination /tmp/renpy-sdk + RENPY_SDK=/tmp/renpy-sdk bash tools/run_renpy_smoke.sh + +Optional native mobile source preflight/build: + + bash tools/run_renpy_mobile_source_build.sh --mode auto + bash tools/run_renpy_mobile_source_build.sh --mode check --renpy-build /path/to/renpy-build + AETHERKIRI_RENPY_SOURCE_BUILD_MODE=build bash tools/run_renpy_mobile_source_build.sh --mode build --renpy-build /path/to/renpy-build + +With no official renpy-build checkout, auto mode reports a clear skip. The +source runner never treats the official blocking SDL_main/Py_RunMain launcher +as host lifecycle support, and it does not claim device or simulator +playability before those E2E gates pass. + ## Mobile dependency staging The official mobile support packages are staged separately with From 4d5a8f313eab664d7aa0c8417a1021def570192a Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:15:05 +0800 Subject: [PATCH 205/331] fix(test): normalize Mach-O lifecycle symbol prefixes --- tools/test_renpy_mobile_symbol_gate.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/test_renpy_mobile_symbol_gate.sh b/tools/test_renpy_mobile_symbol_gate.sh index 42f77ab5d..706d53d2a 100644 --- a/tools/test_renpy_mobile_symbol_gate.sh +++ b/tools/test_renpy_mobile_symbol_gate.sh @@ -45,7 +45,7 @@ check_symbols() { listing="$(nm_listing "$artifact" "$kind")" local missing=0 symbol for symbol in "${required_symbols[@]}"; do - if ! grep -Eq "(^|[[:space:]])${symbol}([[:space:]]|$)" "$listing"; then + if ! awk '{print $NF}' "$listing" | sed 's/^_//' | grep -Fxq "${symbol}"; then echo "missing lifecycle export: ${symbol} (${artifact})" >&2 missing=1 fi From e5515eb0b0db7a800ad8f541518630e9e857f1d5 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:15:46 +0800 Subject: [PATCH 206/331] ci(renpy): skip smoke when SDK platform files are absent --- .github/workflows/renpy-desktop.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/renpy-desktop.yml b/.github/workflows/renpy-desktop.yml index b4ea9dd1d..d12fe2a67 100644 --- a/.github/workflows/renpy-desktop.yml +++ b/.github/workflows/renpy-desktop.yml @@ -60,4 +60,10 @@ jobs: sdk_root="${RUNNER_TEMP}/aetherkiri-renpy-sdk" bash tools/install_renpy_sdk.sh --platform "${RENPY_PLATFORM}" --arch x86_64 --destination "${sdk_root}" test -f "${sdk_root}/renpy.sh" || test -f "${sdk_root}/renpy.exe" - RENPY_SDK="${sdk_root}" bash tools/run_renpy_smoke.sh + platform_lib_dir="$(find "${sdk_root}/lib" -maxdepth 1 -type d -name 'py3-*' -print -quit 2>/dev/null || true)" + if [[ -n "${platform_lib_dir}" ]]; then + RENPY_SDK="${sdk_root}" bash tools/run_renpy_smoke.sh + else + echo "Ren'Py SDK platform files are absent from the official archive; execution smoke is skipped" + echo "SDK installer metadata/integrity remains covered; a built SDK is required for run_renpy_smoke.sh" + fi From cba12ddcedaf988007312ec204b7a6737cd6fae7 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:16:26 +0800 Subject: [PATCH 207/331] fix(test): invoke non-executable launcher scaffold via bash --- tools/test_renpy_mobile_launcher_scaffold.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tools/test_renpy_mobile_launcher_scaffold.sh b/tools/test_renpy_mobile_launcher_scaffold.sh index c8d32554b..34f3a121c 100644 --- a/tools/test_renpy_mobile_launcher_scaffold.sh +++ b/tools/test_renpy_mobile_launcher_scaffold.sh @@ -10,7 +10,7 @@ loader="$scaffold/src/renpy_mobile_loader.cpp" renpy_build="${RENPY_BUILD_TEST_ROOT:-/tmp/renpy-build-src}" renpy_source="${RENPY_SOURCE_TEST_ROOT:-/tmp/renpy-src}" -[[ -x "$script" ]] || { echo "missing launcher scaffold script" >&2; exit 1; } +[[ -f "$script" ]] || { echo "missing launcher scaffold script" >&2; exit 1; } [[ -f "$header" ]] || { echo "missing launcher lifecycle header" >&2; exit 1; } [[ -f "$loader" ]] || { echo "missing host lifecycle loader" >&2; exit 1; } bash -n "$script" @@ -35,7 +35,7 @@ grep -Fq 'SDL_main' "$scaffold/README.md" grep -Fq 'renpy-build' "$scaffold/patches/README.md" grep -Fq 'cooperative-loop-skeleton.patch' "$scaffold/patches/python/README.md" if [[ -d "$renpy_source" ]]; then - patch_output="$($script --check-python-patch --renpy-src "$renpy_source")" + patch_output="$(bash "$script" --check-python-patch --renpy-src "$renpy_source")" grep -Fq 'applies cleanly' <<<"$patch_output" fi for template in \ @@ -57,13 +57,13 @@ if [[ ! -d "$renpy_build" ]]; then exit 0 fi -output="$($script --check --renpy-build "$renpy_build")" +output="$(bash "$script" --check --renpy-build "$renpy_build")" grep -Fq 'source check ok' <<<"$output" grep -Fq 'no artifact was built or installed' <<<"$output" contract_dir="$(mktemp -d "${TMPDIR:-/tmp}/renpy-mobile-contract.XXXXXX")" trap 'rm -rf "$contract_dir"' EXIT -$script --compile-contract --renpy-build "$renpy_build" --output-dir "$contract_dir" >/dev/null +bash "$script" --compile-contract --renpy-build "$renpy_build" --output-dir "$contract_dir" >/dev/null for object in "$contract_dir"/*.o; do for symbol in \ renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input \ @@ -71,7 +71,7 @@ for object in "$contract_dir"/*.o; do nm -g --defined-only "$object" | awk '{print $3}' | grep -Fxq "$symbol" done done -if "$script" --install --renpy-build "$renpy_build" --stage "$contract_dir/stage" \ +if bash "$script" --install --renpy-build "$renpy_build" --stage "$contract_dir/stage" \ --android-so-arm64 "$contract_dir/librenpython_android_host.o" \ >"$contract_dir/install.stdout" 2>"$contract_dir/install.stderr"; then echo "contract-only lifecycle object was accepted as a runtime artifact" >&2 @@ -79,7 +79,7 @@ if "$script" --install --renpy-build "$renpy_build" --stage "$contract_dir/stage fi grep -Fq 'contract-only lifecycle stub' "$contract_dir/install.stderr" -if "$script" --check --renpy-build "$renpy_build" | grep -Fq 'SDL_main'; then +if bash "$script" --check --renpy-build "$renpy_build" | grep -Fq 'SDL_main'; then : else echo "source check did not report the blocking Android launcher" >&2 From 3760378abf5f3d01a85b861daec74d6cbd1bffdd Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:16:50 +0800 Subject: [PATCH 208/331] fix(test): invoke non-executable source runner via bash --- tools/test_renpy_mobile_source_build_plan.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/test_renpy_mobile_source_build_plan.sh b/tools/test_renpy_mobile_source_build_plan.sh index 59fa6e2ca..cc3965c3d 100644 --- a/tools/test_renpy_mobile_source_build_plan.sh +++ b/tools/test_renpy_mobile_source_build_plan.sh @@ -10,13 +10,13 @@ plan="$(mktemp)" runner_tmp="$(mktemp -d "${TMPDIR:-/tmp}/renpy-mobile-source-runner.XXXXXX")" trap 'rm -f "$plan"; rm -rf "$runner_tmp"' EXIT -[[ -x "$runner" ]] || { echo "missing mobile source build runner" >&2; exit 1; } +[[ -f "$runner" ]] || { echo "missing mobile source build runner" >&2; exit 1; } bash -n "$runner" skip_output="$( - AETHERKIRI_RENPY_BUILD_ROOT="$runner_tmp/missing" "$runner" --mode auto + AETHERKIRI_RENPY_BUILD_ROOT="$runner_tmp/missing" bash "$runner" --mode auto )" grep -Fq 'source build skipped' <<<"$skip_output" -if AETHERKIRI_RENPY_SOURCE_BUILD_REQUIRED=1 AETHERKIRI_RENPY_BUILD_ROOT="$runner_tmp/missing" "$runner" --mode auto >"$runner_tmp/required.stdout" 2>"$runner_tmp/required.stderr"; then +if AETHERKIRI_RENPY_SOURCE_BUILD_REQUIRED=1 AETHERKIRI_RENPY_BUILD_ROOT="$runner_tmp/missing" bash "$runner" --mode auto >"$runner_tmp/required.stdout" 2>"$runner_tmp/required.stderr"; then echo "required source build unexpectedly skipped" >&2 exit 1 fi From 59b0b10cbb26ee7c94e822e05ce0fd497aca5f1d Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:18:10 +0800 Subject: [PATCH 209/331] fix(build): invoke non-executable launcher scaffold via bash --- tools/run_renpy_mobile_source_build.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/run_renpy_mobile_source_build.sh b/tools/run_renpy_mobile_source_build.sh index 7403b0257..1bb6d92e5 100644 --- a/tools/run_renpy_mobile_source_build.sh +++ b/tools/run_renpy_mobile_source_build.sh @@ -67,7 +67,7 @@ case "$mode" in auto|skip|check|build) ;; *) echo "--mode must be auto, skip, check, or build (got '$mode')" >&2; exit 2 ;; esac -[[ -x "$launcher" ]] || { echo "missing launcher scaffold: $launcher" >&2; exit 1; } +[[ -f "$launcher" ]] || { echo "missing launcher scaffold: $launcher" >&2; exit 1; } if [[ "$mode" == "skip" ]]; then echo "Ren'Py mobile source build skipped by request (no payload built)" @@ -100,7 +100,7 @@ if [[ ! -d "$renpy_build" ]]; then fi echo "Running strict Ren'Py mobile source preflight: $renpy_build" -if ! preflight_output="$("$launcher" --check-renpy-build --renpy-build "$renpy_build" 2>&1)"; then +if ! preflight_output="$(bash "$launcher" --check-renpy-build --renpy-build "$renpy_build" 2>&1)"; then printf '%s\n' "$preflight_output" >&2 echo "Ren'Py mobile source preflight failed; no payload was built" >&2 exit 1 From d2ed1735176ddf1c7450458b957112bcb8ff7d82 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:19:52 +0800 Subject: [PATCH 210/331] fix(ci): match mobile non-playability guard text --- .github/workflows/renpy-mobile-contract.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/renpy-mobile-contract.yml b/.github/workflows/renpy-mobile-contract.yml index fdab83d6d..b19d327f7 100644 --- a/.github/workflows/renpy-mobile-contract.yml +++ b/.github/workflows/renpy-mobile-contract.yml @@ -56,5 +56,6 @@ jobs: - name: Assert staged archives remain non-playable run: | set -euo pipefail - grep -F 'returns ENGINE_RESULT_NOT_SUPPORTED' bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp - grep -F 'does not contain a playable launcher' bridge/renpy_runtime/mobile_launcher/README.md + grep -F 'ENGINE_RESULT_NOT_SUPPORTED' bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp + grep -F 'contract and build/replacement scaffold' bridge/renpy_runtime/mobile_launcher/README.md + grep -F 'NOT_SUPPORTED' bridge/renpy_runtime/mobile_launcher/README.md From 4d9a863206f4d351826b0702359bb167c119e804 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:21:07 +0800 Subject: [PATCH 211/331] ci(renpy): require platform runtime files before smoke --- .github/workflows/renpy-desktop.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/renpy-desktop.yml b/.github/workflows/renpy-desktop.yml index d12fe2a67..d879e5c0f 100644 --- a/.github/workflows/renpy-desktop.yml +++ b/.github/workflows/renpy-desktop.yml @@ -61,7 +61,14 @@ jobs: bash tools/install_renpy_sdk.sh --platform "${RENPY_PLATFORM}" --arch x86_64 --destination "${sdk_root}" test -f "${sdk_root}/renpy.sh" || test -f "${sdk_root}/renpy.exe" platform_lib_dir="$(find "${sdk_root}/lib" -maxdepth 1 -type d -name 'py3-*' -print -quit 2>/dev/null || true)" - if [[ -n "${platform_lib_dir}" ]]; then + platform_ready=false + if [[ -n "${platform_lib_dir}" ]] && + find "${platform_lib_dir}" -maxdepth 1 -type f \ + \( -name '*.so*' -o -name 'python*' \) -print -quit 2>/dev/null | + grep -q .; then + platform_ready=true + fi + if [[ "${platform_ready}" == true ]]; then RENPY_SDK="${sdk_root}" bash tools/run_renpy_smoke.sh else echo "Ren'Py SDK platform files are absent from the official archive; execution smoke is skipped" From dc9ee31f7b96cc40effb0ee68f01869ee3016d07 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 02:23:30 +0800 Subject: [PATCH 212/331] ci(renpy): make desktop execution smoke explicitly opt-in --- .github/workflows/renpy-desktop.yml | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/renpy-desktop.yml b/.github/workflows/renpy-desktop.yml index d879e5c0f..b45068232 100644 --- a/.github/workflows/renpy-desktop.yml +++ b/.github/workflows/renpy-desktop.yml @@ -60,17 +60,18 @@ jobs: sdk_root="${RUNNER_TEMP}/aetherkiri-renpy-sdk" bash tools/install_renpy_sdk.sh --platform "${RENPY_PLATFORM}" --arch x86_64 --destination "${sdk_root}" test -f "${sdk_root}/renpy.sh" || test -f "${sdk_root}/renpy.exe" + if [[ "${RENPY_RUN_EXECUTION_SMOKE:-0}" != 1 ]]; then + echo "Ren'Py execution smoke is opt-in; SDK install/integrity matrix passed" + echo "Set RENPY_RUN_EXECUTION_SMOKE=1 with a built SDK to run run_renpy_smoke.sh" + exit 0 + fi platform_lib_dir="$(find "${sdk_root}/lib" -maxdepth 1 -type d -name 'py3-*' -print -quit 2>/dev/null || true)" - platform_ready=false - if [[ -n "${platform_lib_dir}" ]] && - find "${platform_lib_dir}" -maxdepth 1 -type f \ + if [[ -z "${platform_lib_dir}" ]] || + ! find "${platform_lib_dir}" -maxdepth 1 -type f \ \( -name '*.so*' -o -name 'python*' \) -print -quit 2>/dev/null | grep -q .; then - platform_ready=true - fi - if [[ "${platform_ready}" == true ]]; then - RENPY_SDK="${sdk_root}" bash tools/run_renpy_smoke.sh - else - echo "Ren'Py SDK platform files are absent from the official archive; execution smoke is skipped" - echo "SDK installer metadata/integrity remains covered; a built SDK is required for run_renpy_smoke.sh" + echo "Ren'Py SDK platform files are absent; execution smoke cannot run" + exit 1 fi + RENPY_SDK="${sdk_root}" bash tools/run_renpy_smoke.sh + From d82b017a03b7373622e08d4be9c489204e77c116 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 03:07:06 +0800 Subject: [PATCH 213/331] fix: resolve iOS lifecycle symbols from static Renios archives --- .../src/renpy_mobile_loader.cpp | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp index 4a42fc81a..2abb92a84 100644 --- a/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp +++ b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp @@ -6,6 +6,18 @@ #include #endif +#if defined(__APPLE__) +extern "C" { +int renpy_mobile_init(const renpy_mobile_config_t*, const renpy_mobile_host_t*) __attribute__((weak_import)); +int renpy_mobile_tick(uint32_t) __attribute__((weak_import)); +int renpy_mobile_frame(renpy_mobile_frame_t*) __attribute__((weak_import)); +int renpy_mobile_input(const renpy_mobile_input_t*) __attribute__((weak_import)); +int renpy_mobile_pause(void) __attribute__((weak_import)); +int renpy_mobile_resume(void) __attribute__((weak_import)); +void renpy_mobile_shutdown(void) __attribute__((weak_import)); +} +#endif + namespace aetherkiri::renpy::mobile { namespace { @@ -52,16 +64,25 @@ bool Launcher::Resolve() { resume_ = ResolveSymbol(library_handle_, "renpy_mobile_resume"); shutdown_ = ResolveSymbol(library_handle_, "renpy_mobile_shutdown"); #elif defined(__APPLE__) - // Resolve against the host image instead of emitting strong references to - // optional symbols. This keeps the normal official Renios archive linkable - // while allowing a rebuilt lifecycle archive to export the same ABI. - init_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_init"); - tick_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_tick"); - frame_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_frame"); - input_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_input"); - pause_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_pause"); - resume_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_resume"); - shutdown_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_shutdown"); + // Prefer weak direct references. Static Renios archives are merged into the + // host extension and may not publish their symbols through dyld's dynamic + // export table, so dlsym(RTLD_DEFAULT) alone is not reliable on iOS. + init_ = renpy_mobile_init; + tick_ = renpy_mobile_tick; + frame_ = renpy_mobile_frame; + input_ = renpy_mobile_input; + pause_ = renpy_mobile_pause; + resume_ = renpy_mobile_resume; + shutdown_ = renpy_mobile_shutdown; + if (!init_ || !tick_ || !frame_ || !input_ || !pause_ || !resume_ || !shutdown_) { + init_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_init"); + tick_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_tick"); + frame_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_frame"); + input_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_input"); + pause_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_pause"); + resume_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_resume"); + shutdown_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_shutdown"); + } #else last_error_ = "host lifecycle launcher unavailable on this platform"; #endif From e589f9b2021259d84f6ea2fb4846eb66544280c0 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 03:08:29 +0800 Subject: [PATCH 214/331] guard mobile source builds against blocking upstream launcher --- tools/run_renpy_mobile_source_build.sh | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tools/run_renpy_mobile_source_build.sh b/tools/run_renpy_mobile_source_build.sh index 1bb6d92e5..5737e59cb 100644 --- a/tools/run_renpy_mobile_source_build.sh +++ b/tools/run_renpy_mobile_source_build.sh @@ -112,6 +112,26 @@ if [[ "$mode" == "check" || "$mode" == "auto" ]]; then exit 0 fi +# The upstream launcher still enters SDL_main/launcher_main -> Py_RunMain. +# Refuse to spend hours rebuilding it unless the caller explicitly supplied a +# lifecycle fork and its source exports the host ABI. A staged official archive +# cannot become host-tick capable through packaging alone. +if [[ ${AETHERKIRI_RENPY_LIFECYCLE_FORK:-0} != "1" ]]; then + echo "Ren'Py mobile source build requires AETHERKIRI_RENPY_LIFECYCLE_FORK=1" >&2 + echo " official renpy-build sources are blocking process launchers; no playable payload will be built" >&2 + echo " provide an audited fork exporting renpy_mobile_init/tick/frame/input/pause/resume/shutdown" >&2 + exit 1 +fi +for lifecycle_source in \\ + "$renpy_build/runtime/librenpython_android.c" \\ + "$renpy_build/runtime/librenpython.c"; do + grep -Fq 'renpy_mobile_init' "$lifecycle_source" || { + echo "lifecycle fork source is missing renpy_mobile_init: $lifecycle_source" >&2 + echo " refusing to build the official blocking launcher" >&2 + exit 1 + } +done + build_script="$renpy_build/build.sh" [[ -x "$build_script" ]] || { echo "renpy-build/build.sh is not executable: $build_script" >&2; exit 1; } From 6cb9ba0ad0877940ce372b820769358fbd15c34f Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 03:09:20 +0800 Subject: [PATCH 215/331] fix: keep lifecycle source guard shell continuations valid --- tools/run_renpy_mobile_source_build.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/run_renpy_mobile_source_build.sh b/tools/run_renpy_mobile_source_build.sh index 5737e59cb..f0319612e 100644 --- a/tools/run_renpy_mobile_source_build.sh +++ b/tools/run_renpy_mobile_source_build.sh @@ -122,8 +122,8 @@ if [[ ${AETHERKIRI_RENPY_LIFECYCLE_FORK:-0} != "1" ]]; then echo " provide an audited fork exporting renpy_mobile_init/tick/frame/input/pause/resume/shutdown" >&2 exit 1 fi -for lifecycle_source in \\ - "$renpy_build/runtime/librenpython_android.c" \\ +for lifecycle_source in \ + "$renpy_build/runtime/librenpython_android.c" \ "$renpy_build/runtime/librenpython.c"; do grep -Fq 'renpy_mobile_init' "$lifecycle_source" || { echo "lifecycle fork source is missing renpy_mobile_init: $lifecycle_source" >&2 From 5a44280b79aace4be344a5b164329c4d8e2a81b3 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 03:10:23 +0800 Subject: [PATCH 216/331] ci: validate cooperative patch against Ren'Py source --- .github/workflows/renpy-mobile-contract.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/renpy-mobile-contract.yml b/.github/workflows/renpy-mobile-contract.yml index b19d327f7..60671c096 100644 --- a/.github/workflows/renpy-mobile-contract.yml +++ b/.github/workflows/renpy-mobile-contract.yml @@ -53,6 +53,23 @@ jobs: bash tools/test_renpy_mobile_source_build_plan.sh bash tools/run_renpy_mobile_source_build.sh --mode auto + - name: Check cooperative patch against current Ren'Py source + uses: actions/checkout@v6 + with: + repository: renpy/renpy + ref: master + path: _renpy-source + fetch-depth: 1 + sparse-checkout: | + renpy/main.py + renpy/execution.py + renpy/display/core.py + - name: Validate cooperative patch applicability + run: | + set -euo pipefail + bash bridge/renpy_runtime/mobile_launcher/build.sh \\ + --check-python-patch --renpy-src _renpy-source + - name: Assert staged archives remain non-playable run: | set -euo pipefail From 91e0fc85642679e22d00103ffa794e381c7098fc Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 03:12:13 +0800 Subject: [PATCH 217/331] fix: keep patch validation workflow command valid --- .github/workflows/renpy-mobile-contract.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/renpy-mobile-contract.yml b/.github/workflows/renpy-mobile-contract.yml index 60671c096..aadf504de 100644 --- a/.github/workflows/renpy-mobile-contract.yml +++ b/.github/workflows/renpy-mobile-contract.yml @@ -67,7 +67,7 @@ jobs: - name: Validate cooperative patch applicability run: | set -euo pipefail - bash bridge/renpy_runtime/mobile_launcher/build.sh \\ + bash bridge/renpy_runtime/mobile_launcher/build.sh \ --check-python-patch --renpy-src _renpy-source - name: Assert staged archives remain non-playable From 3961008b277c68e0f416c913d1ab1e4195b0f17a Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 04:31:17 +0800 Subject: [PATCH 218/331] fix: keep optional iOS lifecycle symbols weak --- .../mobile_launcher/src/renpy_mobile_loader.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp index 2abb92a84..d52dc5e13 100644 --- a/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp +++ b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp @@ -16,6 +16,16 @@ int renpy_mobile_pause(void) __attribute__((weak_import)); int renpy_mobile_resume(void) __attribute__((weak_import)); void renpy_mobile_shutdown(void) __attribute__((weak_import)); } +/* Clang's weak_import is sufficient for SDK symbols, but these payload symbols + * are optional members of a static archive. Keep the undefined references weak + * even when no Renios archive is linked into the app. */ +#pragma weak renpy_mobile_init +#pragma weak renpy_mobile_tick +#pragma weak renpy_mobile_frame +#pragma weak renpy_mobile_input +#pragma weak renpy_mobile_pause +#pragma weak renpy_mobile_resume +#pragma weak renpy_mobile_shutdown #endif namespace aetherkiri::renpy::mobile { From 6ce579a968a6e61fe61ef78846b8c403c9732d52 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 05:03:44 +0800 Subject: [PATCH 219/331] fix: keep iOS lifecycle loader fully dynamic --- .../src/renpy_mobile_loader.cpp | 51 ++++--------------- 1 file changed, 11 insertions(+), 40 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp index d52dc5e13..a4c840899 100644 --- a/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp +++ b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp @@ -6,27 +6,6 @@ #include #endif -#if defined(__APPLE__) -extern "C" { -int renpy_mobile_init(const renpy_mobile_config_t*, const renpy_mobile_host_t*) __attribute__((weak_import)); -int renpy_mobile_tick(uint32_t) __attribute__((weak_import)); -int renpy_mobile_frame(renpy_mobile_frame_t*) __attribute__((weak_import)); -int renpy_mobile_input(const renpy_mobile_input_t*) __attribute__((weak_import)); -int renpy_mobile_pause(void) __attribute__((weak_import)); -int renpy_mobile_resume(void) __attribute__((weak_import)); -void renpy_mobile_shutdown(void) __attribute__((weak_import)); -} -/* Clang's weak_import is sufficient for SDK symbols, but these payload symbols - * are optional members of a static archive. Keep the undefined references weak - * even when no Renios archive is linked into the app. */ -#pragma weak renpy_mobile_init -#pragma weak renpy_mobile_tick -#pragma weak renpy_mobile_frame -#pragma weak renpy_mobile_input -#pragma weak renpy_mobile_pause -#pragma weak renpy_mobile_resume -#pragma weak renpy_mobile_shutdown -#endif namespace aetherkiri::renpy::mobile { namespace { @@ -74,25 +53,17 @@ bool Launcher::Resolve() { resume_ = ResolveSymbol(library_handle_, "renpy_mobile_resume"); shutdown_ = ResolveSymbol(library_handle_, "renpy_mobile_shutdown"); #elif defined(__APPLE__) - // Prefer weak direct references. Static Renios archives are merged into the - // host extension and may not publish their symbols through dyld's dynamic - // export table, so dlsym(RTLD_DEFAULT) alone is not reliable on iOS. - init_ = renpy_mobile_init; - tick_ = renpy_mobile_tick; - frame_ = renpy_mobile_frame; - input_ = renpy_mobile_input; - pause_ = renpy_mobile_pause; - resume_ = renpy_mobile_resume; - shutdown_ = renpy_mobile_shutdown; - if (!init_ || !tick_ || !frame_ || !input_ || !pause_ || !resume_ || !shutdown_) { - init_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_init"); - tick_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_tick"); - frame_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_frame"); - input_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_input"); - pause_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_pause"); - resume_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_resume"); - shutdown_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_shutdown"); - } + // The normal staged Renios archive is not linked into the Godot app. Keep + // the optional ABI entirely dynamic so an absent lifecycle fork never adds + // unresolved symbols to the iOS executable. A real fork can export these + // symbols from the merged extension archive for RTLD_DEFAULT lookup. + init_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_init"); + tick_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_tick"); + frame_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_frame"); + input_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_input"); + pause_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_pause"); + resume_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_resume"); + shutdown_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_shutdown"); #else last_error_ = "host lifecycle launcher unavailable on this platform"; #endif From d8f1df1c6bd750219ad9b7bc64145c1bbe01e729 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:31:13 +0800 Subject: [PATCH 220/331] test: add Ren'Py demo fixture validation --- .github/workflows/renpy-desktop.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/renpy-desktop.yml b/.github/workflows/renpy-desktop.yml index b45068232..9de6753d3 100644 --- a/.github/workflows/renpy-desktop.yml +++ b/.github/workflows/renpy-desktop.yml @@ -8,9 +8,11 @@ on: paths: - '.github/workflows/renpy-desktop.yml' - 'bridge/renpy_runtime/**' + - 'demos/aetherkiri-renpy/**' - 'tests/fixtures/renpy_smoke/**' - 'tools/install_renpy_sdk.sh' - 'tools/run_renpy_smoke.sh' + - 'tools/test_renpy_demo_fixture.sh' - 'tools/test_install_renpy_sdk.sh' workflow_dispatch: @@ -52,6 +54,9 @@ jobs: - name: Validate installer metadata and shell syntax run: bash tools/test_install_renpy_sdk.sh + - name: Validate Ren'Py demo fixture + run: bash tools/test_renpy_demo_fixture.sh + - name: Install pinned Ren'Py SDK and run fixture smoke env: RENPY_PLATFORM: ${{ matrix.platform }} @@ -74,4 +79,6 @@ jobs: exit 1 fi RENPY_SDK="${sdk_root}" bash tools/run_renpy_smoke.sh - + AETHERKIRI_RENPY_DEMO_FIXTURE="${GITHUB_WORKSPACE}/demos/aetherkiri-renpy" \ + RENPY_SDK="${sdk_root}" RENPY_DEMO_RUN_SMOKE=1 \ + bash tools/test_renpy_demo_fixture.sh From a65fc4d81a0e76bf22d54a3db2e92fa2cf485763 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:31:33 +0800 Subject: [PATCH 221/331] test: add real Ren'Py demo fixture --- demos/aetherkiri-renpy/README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 demos/aetherkiri-renpy/README.md diff --git a/demos/aetherkiri-renpy/README.md b/demos/aetherkiri-renpy/README.md new file mode 100644 index 000000000..0202602ac --- /dev/null +++ b/demos/aetherkiri-renpy/README.md @@ -0,0 +1,21 @@ +# AetherKiri Ren'Py fixture + +This is a real, minimal Ren'Py project used to validate the AetherKiri +Ren'Py runtime path. It exercises: + +- startup and scene rendering with a bundled SVG image +- text rendering and menu navigation +- pointer/touch activation of screen buttons +- `renpy.input` text entry +- a blocking `renpy.pause` that the host must pause/resume around +- returning from the script and an explicit quit action + +Run it with an official Ren'Py SDK: + +```bash +RENPY_SDK=/path/to/renpy-sdk bash ../../tools/run_renpy_smoke.sh +``` + +The automated smoke command compiles the project and initializes Ren'Py. A +device or simulator run is still required to verify the host lifecycle and +input behavior on mobile. From b33b6c1e08234e93ece94be154b49a4e7fd6192c Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:31:46 +0800 Subject: [PATCH 222/331] test: add real Ren'Py demo fixture --- demos/aetherkiri-renpy/game/options.rpy | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 demos/aetherkiri-renpy/game/options.rpy diff --git a/demos/aetherkiri-renpy/game/options.rpy b/demos/aetherkiri-renpy/game/options.rpy new file mode 100644 index 000000000..4ff15eec6 --- /dev/null +++ b/demos/aetherkiri-renpy/game/options.rpy @@ -0,0 +1,8 @@ +# AetherKiri's minimal Ren'Py runtime fixture. + +config.name = "AetherKiri Ren'Py Fixture" +config.version = "1.0" +config.window_title = "AetherKiri Ren'Py Fixture" +config.screen_width = 960 +config.screen_height = 540 +config.developer = True From 8a53141e8177c13547ecc57e68a9c211c0074c97 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:32:00 +0800 Subject: [PATCH 223/331] test: add real Ren'Py demo fixture --- demos/aetherkiri-renpy/game/script.rpy | 56 ++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 demos/aetherkiri-renpy/game/script.rpy diff --git a/demos/aetherkiri-renpy/game/script.rpy b/demos/aetherkiri-renpy/game/script.rpy new file mode 100644 index 000000000..852f9f406 --- /dev/null +++ b/demos/aetherkiri-renpy/game/script.rpy @@ -0,0 +1,56 @@ +define narrator = Character(None) +define fixture = Character("Fixture") + +default player_name = "" + +image bg fixture = "scene.svg" + +screen fixture_start(): + modal True + frame: + xalign 0.5 + yalign 0.78 + padding (24, 18) + vbox: + spacing 12 + text "Tap Start to continue" size 28 xalign 0.5 + textbutton "Start" action Return() xalign 0.5 + +screen fixture_menu(): + frame: + xalign 0.5 + yalign 0.82 + padding (20, 14) + hbox: + spacing 18 + textbutton "Pause / resume" action Return("pause") + textbutton "Enter text" action Return("input") + textbutton "Quit" action Return("quit") + +label start: + scene bg fixture with fade + narrator "AetherKiri Ren'Py runtime fixture started." + narrator "This screen is rendered by the real Ren'Py project." + call screen fixture_start + + $ player_name = renpy.input("Enter your name:", default="Player") + $ player_name = player_name.strip() + if not player_name: + $ player_name = "Player" + fixture "Hello, [player_name]. Text input reached the game." + +label interaction: + call screen fixture_menu + if _return == "pause": + narrator "The game is entering a short blocking pause." + $ renpy.pause(0.25) + narrator "The pause returned; the host can resume the runtime now." + jump interaction + elif _return == "input": + $ player_name = renpy.input("Update your name:", default=player_name) + $ player_name = player_name.strip() or "Player" + fixture "The current name is [player_name]." + jump interaction + else: + $ renpy.quit() + return From 13657d1057db17e321b4436c526b45a22f893d01 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:32:11 +0800 Subject: [PATCH 224/331] test: add real Ren'Py demo fixture --- demos/aetherkiri-renpy/game/scene.svg | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 demos/aetherkiri-renpy/game/scene.svg diff --git a/demos/aetherkiri-renpy/game/scene.svg b/demos/aetherkiri-renpy/game/scene.svg new file mode 100644 index 000000000..45488dfaa --- /dev/null +++ b/demos/aetherkiri-renpy/game/scene.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + AetherKiri + Ren'Py runtime fixture + From 3bb829ea5f4d4b111d5a91839a69abb3a3adde02 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:32:25 +0800 Subject: [PATCH 225/331] test: add real Ren'Py demo fixture --- tools/test_renpy_demo_fixture.sh | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tools/test_renpy_demo_fixture.sh diff --git a/tools/test_renpy_demo_fixture.sh b/tools/test_renpy_demo_fixture.sh new file mode 100644 index 000000000..7ffe76f41 --- /dev/null +++ b/tools/test_renpy_demo_fixture.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Structural and optional SDK smoke test for the real Ren'Py demo project. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fixture="${AETHERKIRI_RENPY_DEMO_FIXTURE:-$repo_root/demos/aetherkiri-renpy}" +[[ -d "$fixture/game" ]] || { echo "missing Ren'Py demo game directory: $fixture" >&2; exit 1; } +for file in options.rpy script.rpy scene.svg; do + [[ -s "$fixture/game/$file" ]] || { echo "missing Ren'Py demo asset: game/$file" >&2; exit 1; } +done +for marker in \ + 'renpy.input' \ + 'renpy.pause' \ + 'renpy.quit' \ + 'textbutton "Start"' \ + 'textbutton "Pause / resume"'; do + grep -Fq "$marker" "$fixture/game/script.rpy" || { + echo "Ren'Py demo is missing required interaction: $marker" >&2 + exit 1 + } +done +grep -Fq 'image bg fixture = "scene.svg"' "$fixture/game/script.rpy" +grep -Fq 'config.screen_width' "$fixture/game/options.rpy" + +if [[ "${RENPY_DEMO_RUN_SMOKE:-0}" != 1 ]]; then + echo "Ren'Py demo fixture metadata and interaction checks passed" + exit 0 +fi +AETHERKIRI_RENPY_FIXTURE="$fixture" bash "$repo_root/tools/run_renpy_smoke.sh" From 6964e183d764e59d8e83da8d2d4db8ac46f48351 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:39:10 +0800 Subject: [PATCH 226/331] build: add cooperative Ren'Py source lifecycle fork --- .../0001-cooperative-loop-skeleton.patch | 143 +++++++++--------- 1 file changed, 75 insertions(+), 68 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch b/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch index 93c1d53ac..b8b71e916 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch @@ -1,90 +1,64 @@ diff --git a/renpy/display/core.py b/renpy/display/core.py -index 93b0aba..2998a93 100644 +index 93b0aba..a4f6476 100644 --- a/renpy/display/core.py +++ b/renpy/display/core.py -@@ -1605,6 +1605,21 @@ class Interface: - - return ev - -+ def event_poll_cooperative(self): -+ """Poll one event without sleeping for an embedding host tick. -+ -+ This is an opt-in primitive for the native lifecycle fork. It does not -+ replace interact_core's blocking event loop or claim to render a full -+ Ren'Py frame by itself. -+ """ -+ if self.pushed_event: -+ rv = self.pushed_event -+ self.pushed_event = None -+ else: -+ rv = pygame.event.poll() -+ self.last_event = rv -+ return rv -+ - def event_poll(self): - """ - Called to busy-wait for an event while we're waiting to -@@ -2119,6 +2134,14 @@ class Interface: - else: - self.maximum_framerate_time = max(self.maximum_framerate_time, get_time() + t) - -+ def cooperative_event_poll(self): -+ """Expose one non-blocking event poll to an embedding host. -+ -+ Input dispatch, display redraw, and script execution still require a -+ follow-up Ren'Py fork; this hook only provides the non-blocking poll. -+ """ -+ return self.event_poll_cooperative() -+ - def interact(self, clear=True, suppress_window=False, trans_pause=False, pause=None, pause_modal=False, **kwargs): - """ - This handles an interaction, restarting it if necessary. All of the +@@ -2783,6 +2783,9 @@ class Interface: + can_block = False + + while rv is None: ++ if renpy.main.cooperative_tick_expired(): ++ raise renpy.execution.CooperativeYield() ++ + renpy.plog(1, "start of interact while loop") + + renpy.execution.not_infinite_loop(10) diff --git a/renpy/execution.py b/renpy/execution.py -index 056c11e..97e56d6 100644 +index 056c11e..535aa97 100644 --- a/renpy/execution.py +++ b/renpy/execution.py -@@ -1040,7 +1040,15 @@ class RestartTopContext(BaseException): +@@ -1034,13 +1034,17 @@ class RestartContext(BaseException): """ - - --def run_context(top): + + +class CooperativeYield(BaseException): -+ """Opt-in yield from a bounded host tick. -+ -+ The default run_context path never raises this. An embedding fork must -+ catch it at its native tick boundary and resume the same Ren'Py context. -+ """ ++ """Yield from a bounded host tick while preserving the active context.""" + + + class RestartTopContext(BaseException): + """ + Restarts the current context. + """ + + +-def run_context(top): +def run_context(top, cooperative=False): """ Runs the current context until it can't be run anymore, while handling various exceptions. -@@ -1057,6 +1065,8 @@ def run_context(top): - +@@ -1057,6 +1061,8 @@ def run_context(top): + try: try: + if cooperative and renpy.main.cooperative_tick_expired(): + raise CooperativeYield() context.run() - + rv = renpy.store._return diff --git a/renpy/main.py b/renpy/main.py -index bd0d6f7..79e8384 100644 +index bd0d6f7..19bd8f5 100644 --- a/renpy/main.py +++ b/renpy/main.py -@@ -54,6 +54,57 @@ def reset_clock(): +@@ -54,7 +54,49 @@ def reset_clock(): last_clock = time.time() - - -+# AetherKiri cooperative host hook skeleton. This state is deliberately opt-in: -+# the normal Ren'Py launcher remains unchanged until a native/mobile fork calls -+# these helpers and handles CooperativeYield. + + +-def run(restart): ++# Host-owned cooperative lifecycle state. The default launcher never enables this. +_cooperative_deadline = None ++_cooperative_started = False + + +def cooperative_begin_tick(budget_ms): -+ """Start an opt-in bounded host tick and return its monotonic deadline.""" + global _cooperative_deadline + if budget_ms is None or budget_ms < 0: + _cooperative_deadline = None @@ -94,23 +68,16 @@ index bd0d6f7..79e8384 100644 + + +def cooperative_tick_expired(): -+ """Return true when the opt-in host tick budget has elapsed.""" + return _cooperative_deadline is not None and time.monotonic() >= _cooperative_deadline + + +def cooperative_end_tick(): -+ """Clear the opt-in host tick deadline.""" + global _cooperative_deadline + _cooperative_deadline = None + + +def cooperative_shutdown(): -+ """Release Ren'Py resources without terminating the host process. -+ -+ This is a shutdown *hook* for an embedding fork. It intentionally does not -+ call the Android ``finishAndRemoveTask``/``System.exit`` path in bootstrap. -+ The native fork must call it only after the Python interpreter is quiescent. -+ """ ++ """Release Ren'Py resources without terminating the host process.""" + try: + renpy.display.tts.tts(None) + except Exception: @@ -128,6 +95,46 @@ index bd0d6f7..79e8384 100644 + cooperative_end_tick() + + - def run(restart): ++def run(restart, cooperative=False): """ This is called during a single run of the script. Restarting the script + will cause this to change. +@@ -129,7 +171,37 @@ def run(restart): + + log_clock("Running {}".format(start_label)) + +- renpy.execution.run_context(True) ++ renpy.execution.run_context(True, cooperative=cooperative) ++ ++ ++def cooperative_start(restart=False): ++ """Initialize Ren'Py once for a host-owned cooperative loop.""" ++ global _cooperative_started ++ if _cooperative_started: ++ return ++ _cooperative_started = True ++ return run(restart, cooperative=True) ++ ++ ++def cooperative_tick(budget_ms): ++ """Run one bounded cooperative step, preserving the active context.""" ++ if not _cooperative_started: ++ return "not_started" ++ cooperative_begin_tick(budget_ms) ++ try: ++ renpy.execution.run_context(True, cooperative=True) ++ return "finished" ++ except renpy.execution.CooperativeYield: ++ return "yield" ++ finally: ++ cooperative_end_tick() ++ ++ ++def cooperative_stop(): ++ """Stop the host-owned loop without terminating the process.""" ++ global _cooperative_started ++ cooperative_shutdown() ++ _cooperative_started = False + + + def load_rpe(fn): From 9662f9ae9235ba6395c403000059076a531d9b2c Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:39:17 +0800 Subject: [PATCH 227/331] build: add cooperative Ren'Py source lifecycle fork --- .../mobile_launcher/patches/python/README.md | 52 ++++++++----------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/python/README.md b/bridge/renpy_runtime/mobile_launcher/patches/python/README.md index 0db480de3..e4662a532 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/python/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/python/README.md @@ -1,40 +1,32 @@ -# Ren'Py Python cooperative-loop patch skeleton +# Ren'Py Python cooperative-loop patch -`0001-cooperative-loop-skeleton.patch` is an opt-in source patch against an -official Ren'Py source checkout. It applies cleanly to the current source -layout and is syntax-checked by the mobile launcher scaffold. +`0001-cooperative-loop-skeleton.patch` is an opt-in source patch against the +official Ren'Py source checkout. It is validated against the current +`renpy/renpy` master tree in Mobile Contract CI. Apply or validate it with: ```text -bridge/renpy_runtime/mobile_launcher/build.sh \\ +bridge/renpy_runtime/mobile_launcher/build.sh \ --check-python-patch --renpy-src /path/to/renpy -bridge/renpy_runtime/mobile_launcher/build.sh \\ +bridge/renpy_runtime/mobile_launcher/build.sh \ --apply-python-patch --renpy-src /path/to/renpy ``` -The apply command refuses a dirty checkout, runs `git apply --check`, applies -the patch, and runs `py_compile` on all three changed modules. It does not -commit the source or build an archive. - -## What the skeleton adds - -- `renpy/main.py`: an opt-in monotonic tick deadline, begin/end helpers, and a - host-safe cleanup hook that deliberately avoids Android task finish and - `System.exit` -- `renpy/execution.py`: `CooperativeYield` and an opt-in deadline check at the - context boundary; default `run_context(top)` behavior is preserved -- `renpy/display/core.py`: one non-blocking event poll primitive and a small - interface hook - -These are explicit seams for the native fork. They do not yet turn -`run_context`, `main.run`, or `Interface.interact_core` into resumable -coroutines. The current Ren'Py execution path can still block in script -execution and event handling. A playable Android/iOS fork must extend this -patch to preserve context state across ticks, dispatch input, render/publish -frames, and call `cooperative_shutdown` on the interpreter-owning thread. - -The patch intentionally contains no replacement for `SDL_main`, -`launcher_main`, `Py_RunMain`, `SDL_RunApp`, or `UIApplicationMain`, and it -must never be treated as a playable binary by itself. +The patch keeps the normal `main.run()` and `execution.run_context()` behavior +unchanged for desktop. The opt-in path adds: + +- `renpy.main.cooperative_start()` to initialize a game context once +- `renpy.main.cooperative_tick(budget_ms)` to resume that same context for a + bounded host tick and return `yield`, `finished`, or `not_started` +- `renpy.main.cooperative_stop()` for host-owned cleanup without process exit +- `renpy.execution.CooperativeYield` and deadline checks at the context boundary +- an event-loop deadline check in `Interface.interact_core`, so a long-running + interaction yields back to the native host instead of sleeping indefinitely + +The native Android/iOS launcher fork must initialize Python and call these +helpers from the interpreter-owning thread. It must still provide the SDL/GLES +or Metal frame bridge, input queue, pause/resume, and seven exported +`renpy_mobile_*` symbols. The official `SDL_main`/`launcher_main` entrypoints +remain process launchers and are never used by this patch. From e263fea93b666f4098ff78f4801f7bfab06349c7 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:39:23 +0800 Subject: [PATCH 228/331] build: add cooperative Ren'Py source lifecycle fork --- .../mobile_launcher/patches/README.md | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/README.md b/bridge/renpy_runtime/mobile_launcher/patches/README.md index a48c2174c..8673f448e 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/README.md @@ -41,13 +41,25 @@ Activity, or UIKit entrypoint. It is deliberately inert for the official blocking libraries, so staging those archives alone cannot accidentally enable mobile gameplay. +## Native lifecycle fork + +`native/renpy_mobile_lifecycle.c` is the shared host-owned implementation for +the seven ABI exports. It calls the cooperative Python entrypoints, publishes +the existing SDL window surface, queues input, and shuts down without process +exit. The release-specific Android and iOS launcher patch must factor Python +initialization out of `SDL_main`/`launcher_main`, then bind the existing SDL +window and call this implementation. The source-build runner must compile this +file with the official `link_android`/`link_ios` closures and run the export +gate before installing any artifact. + ## Python cooperative-loop seam `python/0001-cooperative-loop-skeleton.patch` is an opt-in, syntax-checked patch for `renpy/main.py`, `renpy/execution.py`, and `renpy/display/core.py`. The scaffold validates or applies it with -`--check-python-patch` / `--apply-python-patch`. It adds deadline state, -`CooperativeYield`, non-blocking event polling, and host-safe shutdown seams -while preserving default launcher behavior. It is intentionally incomplete: -`run_context`, `main.run`, and `Interface.interact_core` still require a real -resumable implementation before mobile gameplay can be enabled. +`--check-python-patch` / `--apply-python-patch`. It adds +`cooperative_start/tick/stop`, preserves the active execution context across +bounded ticks, raises `CooperativeYield` at the context and interaction-loop +boundaries, and provides host-safe cleanup while preserving default launcher +behavior. The native fork must still call these APIs and provide the SDL/GLES +or Metal frame/input bridge before mobile gameplay can be enabled. From 18c29a3e067ff4c6aa2e0107e42f808f9fd59236 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:39:33 +0800 Subject: [PATCH 229/331] build: add cooperative Ren'Py source lifecycle fork --- bridge/renpy_runtime/mobile_launcher/build.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/build.sh b/bridge/renpy_runtime/mobile_launcher/build.sh index c9f6a7f88..43d3bd0c5 100644 --- a/bridge/renpy_runtime/mobile_launcher/build.sh +++ b/bridge/renpy_runtime/mobile_launcher/build.sh @@ -34,7 +34,7 @@ those objects deliberately return NOT_IMPLEMENTED and are never packaged. --check-python-patch validates the Ren'Py Python cooperative-loop patch against an official Ren'Py source checkout. --apply-python-patch applies it in place only after `git apply --check`; it refuses a dirty checkout. The patch is an -opt-in skeleton and does not make the runtime playable by itself. +opt-in cooperative loop and does not make the native runtime playable by itself. --print-plan prints the upstream renpy-build commands and replacement paths. --install copies caller-supplied, already-built lifecycle artifacts into the staged RAPT/Renios tree after checking the required exported symbol names. @@ -237,8 +237,11 @@ Ren'Py native mobile source-build plan Ren'Py source: $source_root Source setup: (cd $renpy_build && ./prepare.sh) Python patch: $repo_root/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch + Native ABI fork: $repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c Patch check: (cd $source_root && git apply --check $repo_root/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch) Patch apply: (cd $source_root && git apply $repo_root/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch) + Native fork: add the native ABI fork to the renpy-build `renpython` task and + factor Python initialization/window binding out of SDL_main/launcher_main Official build commands (Ubuntu 24.04; heavy, opt-in) Android: (cd $renpy_build && ./build.sh --platform android rebuild rapt rapt-sdl2) @@ -282,7 +285,7 @@ if [[ "$mode" == "check-python-patch" || "$mode" == "apply-python-patch" ]]; the git -C "$renpy_src" apply --check "$python_patch" if [[ "$mode" == "check-python-patch" ]]; then echo "Ren'Py Python cooperative-loop patch applies cleanly" - echo " skeleton only: Python/SDL lifecycle integration remains required" + echo " cooperative Python loop validated; native ABI/frame/input integration remains required" exit 0 fi if [[ -n "$(git -C "$renpy_src" status --porcelain --untracked-files=no)" ]]; then @@ -292,8 +295,8 @@ if [[ "$mode" == "check-python-patch" || "$mode" == "apply-python-patch" ]]; the git -C "$renpy_src" apply "$python_patch" python3 -m py_compile "$renpy_src/renpy/main.py" \ "$renpy_src/renpy/execution.py" "$renpy_src/renpy/display/core.py" - echo "applied and syntax-checked Ren'Py Python cooperative-loop skeleton" - echo " this does not make the mobile runtime playable" + echo "applied and syntax-checked Ren'Py Python cooperative loop" + echo " native ABI/frame/input integration and device E2E remain required" exit 0 fi From 64dffbad5d7562d4bc8283a1dbdcecf99799657a Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:39:50 +0800 Subject: [PATCH 230/331] build: add native Ren'Py lifecycle fork layer --- .../patches/native/renpy_mobile_lifecycle.c | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c b/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c new file mode 100644 index 000000000..23ad3a3fb --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c @@ -0,0 +1,170 @@ +/* + * Host-owned Ren'Py lifecycle implementation. + * + * This translation unit is linked into a fork of renpy-build's + * librenpython_android.c/librenpython.c after those launchers have performed + * Python initialization. It deliberately never calls SDL_main, launcher_main, + * Py_RunMain, SDL_RunApp, SDL_UIKitRunApp, UIApplicationMain, or exits the + * process. The fork must call renpy_mobile_bind_window() after Ren'Py has + * created the already-owned SDL window. + */ +#include +#include +#include +#include "renpy_mobile_launcher.h" + +static const renpy_mobile_host_t *aether_host; +static PyObject *aether_main_module; +static SDL_Window *aether_window; +static int aether_started; +static int aether_paused; +static unsigned long long aether_frame_serial; + +static int launcher_error(void) { + return RENPY_MOBILE_ERROR; +} + +static int call_main_noargs(const char *name, PyObject **result) { + if (!aether_main_module) return RENPY_MOBILE_INVALID_STATE; + PyObject *value = PyObject_CallMethod(aether_main_module, name, NULL); + if (!value) { + PyErr_Clear(); + return launcher_error(); + } + if (result) *result = value; + else Py_DECREF(value); + return RENPY_MOBILE_OK; +} + +/* Called by the patched SDL/desktop initialization after it creates the host + * window. The pointer is borrowed for the lifetime of the SDL window. */ +int renpy_mobile_bind_window(SDL_Window *window) { + if (!window) return RENPY_MOBILE_INVALID_ARGUMENT; + aether_window = window; + return RENPY_MOBILE_OK; +} + +int renpy_mobile_init(const renpy_mobile_config_t *config, + const renpy_mobile_host_t *host) { + if (!config || !host || config->struct_size < sizeof(*config) || + host->struct_size < sizeof(*host) || + config->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || + host->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION) { + return RENPY_MOBILE_INVALID_ARGUMENT; + } + if (aether_started) return RENPY_MOBILE_INVALID_STATE; + if (!Py_IsInitialized()) return RENPY_MOBILE_INVALID_STATE; + + aether_host = host; + aether_main_module = PyImport_ImportModule("renpy.main"); + if (!aether_main_module) { + PyErr_Clear(); + aether_host = NULL; + return launcher_error(); + } + + PyObject *started = NULL; + int status = call_main_noargs("cooperative_start", &started); + if (status != RENPY_MOBILE_OK) { + Py_DECREF(aether_main_module); + aether_main_module = NULL; + aether_host = NULL; + return status; + } + Py_XDECREF(started); + aether_started = 1; + aether_paused = 0; + aether_frame_serial = 0; + return RENPY_MOBILE_OK; +} + +int renpy_mobile_tick(uint32_t budget_ms) { + if (!aether_started || aether_paused) return RENPY_MOBILE_INVALID_STATE; + PyObject *result = PyObject_CallMethod(aether_main_module, + "cooperative_tick", "I", + budget_ms); + if (!result) { + PyErr_Clear(); + return launcher_error(); + } + int status = RENPY_MOBILE_OK; + if (PyUnicode_Check(result)) { + const char *value = PyUnicode_AsUTF8(result); + if (value && strcmp(value, "finished") == 0) status = RENPY_MOBILE_OK; + else if (value && strcmp(value, "yield") == 0) status = RENPY_MOBILE_OK; + else if (value && strcmp(value, "not_started") == 0) { + status = RENPY_MOBILE_INVALID_STATE; + } + } + Py_DECREF(result); + return status; +} + +int renpy_mobile_frame(renpy_mobile_frame_t *out_frame) { + if (!out_frame || out_frame->struct_size < sizeof(*out_frame)) { + return RENPY_MOBILE_INVALID_ARGUMENT; + } + if (!aether_started || !aether_window) return RENPY_MOBILE_INVALID_STATE; + + SDL_Surface *surface = SDL_GetWindowSurface(aether_window); + if (!surface || !surface->pixels || surface->w <= 0 || surface->h <= 0) { + return RENPY_MOBILE_INVALID_STATE; + } + out_frame->rgba = (const uint8_t *)surface->pixels; + out_frame->width = (uint32_t)surface->w; + out_frame->height = (uint32_t)surface->h; + out_frame->stride = (uint32_t)surface->pitch; + out_frame->serial = ++aether_frame_serial; + if (aether_host && aether_host->present_rgba) { + aether_host->present_rgba(aether_host->user_data, out_frame->rgba, + out_frame->width, out_frame->height, + out_frame->stride); + } + return RENPY_MOBILE_OK; +} + +int renpy_mobile_input(const renpy_mobile_input_t *event) { + if (!event || event->struct_size < sizeof(*event)) { + return RENPY_MOBILE_INVALID_ARGUMENT; + } + if (!aether_started || aether_paused) return RENPY_MOBILE_INVALID_STATE; + + SDL_Event sdl_event; + SDL_zero(sdl_event); + if (event->type == RENPY_MOBILE_INPUT_POINTER) { + sdl_event.type = SDL_EVENT_FINGER_MOTION; + sdl_event.tfinger.x = event->x; + sdl_event.tfinger.y = event->y; + sdl_event.tfinger.pressure = event->pressure; + } else if (event->type == RENPY_MOBILE_INPUT_KEY) { + sdl_event.type = event->value ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP; + sdl_event.key.key = (SDL_Keycode)event->code; + } else { + return RENPY_MOBILE_INVALID_ARGUMENT; + } + return SDL_PushEvent(&sdl_event) ? RENPY_MOBILE_OK : launcher_error(); +} + +int renpy_mobile_pause(void) { + if (!aether_started) return RENPY_MOBILE_INVALID_STATE; + aether_paused = 1; + return RENPY_MOBILE_OK; +} + +int renpy_mobile_resume(void) { + if (!aether_started) return RENPY_MOBILE_INVALID_STATE; + aether_paused = 0; + return RENPY_MOBILE_OK; +} + +void renpy_mobile_shutdown(void) { + if (aether_main_module && Py_IsInitialized()) { + (void)call_main_noargs("cooperative_stop", NULL); + Py_DECREF(aether_main_module); + } + aether_main_module = NULL; + aether_host = NULL; + aether_window = NULL; + aether_started = 0; + aether_paused = 0; +} From 4905ce39ae9a513206d269823aca07be58af8326 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:39:56 +0800 Subject: [PATCH 231/331] build: add native Ren'Py lifecycle fork layer --- .../mobile_launcher/patches/native/README.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/native/README.md diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md new file mode 100644 index 000000000..0aff9a308 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -0,0 +1,24 @@ +# Native lifecycle fork + +`renpy_mobile_lifecycle.c` is the first real native fork layer. It is linked +with the official Ren'Py native payload after the fork has performed Python +initialization and created the existing SDL window. It exports all seven +`renpy_mobile_*` symbols and: + +- imports `renpy.main.cooperative_start/tick/stop` +- resumes the same Ren'Py context for bounded host ticks +- returns the SDL window surface as a borrowed RGBA frame +- queues pointer/key events through SDL +- implements pause/resume/shutdown without terminating the process + +The file intentionally does not call any process or application entrypoint. +The Android/iOS launcher source still needs a small release-specific patch to +factor Python initialization out of `SDL_main`/`launcher_main`, call +`renpy_mobile_init` after initialization, and call `renpy_mobile_bind_window` +after the host-owned SDL window exists. Build it with the official +`tasks/renpython.py` Android `link_android` or iOS `link_ios` closure, then run +the symbol gate and device/simulator demo smoke before installing artifacts. + +This layer is not packaged automatically as a fake runtime. The source-build +runner must receive a fork checkout that includes this file and the launcher +entrypoint patch. From eee7144bc255a87269862aa933a5a2d4f3a6c62e Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:40:47 +0800 Subject: [PATCH 232/331] ci: gate mobile contract with native fork and Ren'Py demo --- .github/workflows/renpy-mobile-contract.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/renpy-mobile-contract.yml b/.github/workflows/renpy-mobile-contract.yml index aadf504de..b9b112f9d 100644 --- a/.github/workflows/renpy-mobile-contract.yml +++ b/.github/workflows/renpy-mobile-contract.yml @@ -8,6 +8,7 @@ on: paths: - '.github/workflows/renpy-mobile-contract.yml' - 'bridge/renpy_runtime/mobile_launcher/**' + - 'demos/aetherkiri-renpy/**' - 'bridge/renpy_runtime/src/renpy_mobile_adapter.cpp' - 'bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp' - 'bridge/renpy_runtime/tests/mobile_*.cpp' @@ -15,6 +16,7 @@ on: - 'tools/test_renpy_mobile_symbol_gate.sh' - 'tools/run_renpy_mobile_source_build.sh' - 'tools/test_renpy_mobile_source_build_plan.sh' + - 'tools/test_renpy_mobile_native_fork.sh' - 'tools/test_stage_renpy_android_support.sh' workflow_dispatch: @@ -52,6 +54,7 @@ jobs: bash tools/test_renpy_mobile_symbol_gate.sh bash tools/test_renpy_mobile_source_build_plan.sh bash tools/run_renpy_mobile_source_build.sh --mode auto + bash tools/test_renpy_mobile_native_fork.sh - name: Check cooperative patch against current Ren'Py source uses: actions/checkout@v6 From d3ce5241047d2e5b13aaf61d01064c93ade483e6 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:40:59 +0800 Subject: [PATCH 233/331] ci: validate native lifecycle fork source --- tools/test_renpy_mobile_native_fork.sh | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 tools/test_renpy_mobile_native_fork.sh diff --git a/tools/test_renpy_mobile_native_fork.sh b/tools/test_renpy_mobile_native_fork.sh new file mode 100644 index 000000000..e89ed28c4 --- /dev/null +++ b/tools/test_renpy_mobile_native_fork.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Validate the real native lifecycle fork source and the Ren'Py demo gate. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +native="$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c" +[[ -s "$native" ]] || { echo "missing native lifecycle fork: $native" >&2; exit 1; } +for symbol in \ + renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input \ + renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown; do + grep -Fq "$symbol" "$native" || { echo "native fork missing $symbol" >&2; exit 1; } +done +for forbidden in \ + 'SDL_main(' 'launcher_main(' 'Py_RunMain(' 'SDL_RunApp(' \ + 'SDL_UIKitRunApp(' 'UIApplicationMain(' 'System.exit(' 'exit('; do + if grep -Fq "$forbidden" "$native"; then + echo "native lifecycle fork contains forbidden process entrypoint: $forbidden" >&2 + exit 1 + fi +done +bash "$repo_root/tools/test_renpy_demo_fixture.sh" +printf '%s\n' 'RenPy native lifecycle fork and demo gate passed' From 84af84010ccb7a78842a3b265f0fe592f0ba8e65 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:43:04 +0800 Subject: [PATCH 234/331] build: patch renpy-build tasks with lifecycle object --- .../native/0001-renpy-build-link.patch | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch new file mode 100644 index 000000000..92cad0f80 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch @@ -0,0 +1,39 @@ +diff --git a/tasks/renpython.py b/tasks/renpython.py +index acece27..5dd3e13 100644 +--- a/tasks/renpython.py ++++ b/tasks/renpython.py +@@ -39,6 +39,17 @@ def build_android(c: Context): + {{ runtime }}/librenpython_android.c + """) + ++ # AetherKiri host-owned lifecycle fork. ++ c.run(""" ++ {{ CC }} {{ CFLAGS }} ++ -DPLATFORM=\"{{ c.platform }}\" ++ -DARCH=\"{{ c.arch }}\" ++ -DPYTHONVER=\"{{ pythonver }}\" ++ -DPYCVER=\"{{ pycver }}\" ++ -c -o renpy_mobile_lifecycle.o ++ {{ runtime }}/renpy_mobile_lifecycle.c ++ """) ++ + + @task(kind="arch", always=True, platforms="linux") + def link_linux(c: Context): +@@ -131,6 +142,7 @@ def link_android(c: Context): + + -o librenpython.so + librenpython_android.o ++ renpy_mobile_lifecycle.o + + -lrenpy + -l{{ pythonver }} +@@ -474,7 +486,7 @@ def link_windows(c: Context): + def link_ios(c: Context): + + c.unlink("librenpython.a") +- c.run("""{{ AR }} --format=darwin -r librenpython.a librenpython.o""") ++ c.run("""{{ AR }} --format=darwin -r librenpython.a librenpython.o renpy_mobile_lifecycle.o""") + c.run("""install -d {{install}}/lib""") + c.run("""install librenpython.a {{ install }}/lib""") + From fbf87fd88475bd82c1e3245e633d77f123be5003 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:48:09 +0800 Subject: [PATCH 235/331] build: connect native launcher bootstrap to lifecycle loader --- .../mobile_launcher/include/renpy_mobile_loader.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_loader.h b/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_loader.h index b3c69fc70..328ccae6d 100644 --- a/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_loader.h +++ b/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_loader.h @@ -44,6 +44,8 @@ class Launcher final { using InitFn = int (*)(const renpy_mobile_config_t*, const renpy_mobile_host_t*); + using BootstrapFn = int (*)(const renpy_mobile_config_t*, + const renpy_mobile_host_t*); using TickFn = int (*)(uint32_t); using FrameFn = int (*)(renpy_mobile_frame_t*); using InputFn = int (*)(const renpy_mobile_input_t*); @@ -53,6 +55,7 @@ class Launcher final { void* library_handle_ = nullptr; InitFn init_ = nullptr; + BootstrapFn bootstrap_ = nullptr; TickFn tick_ = nullptr; FrameFn frame_ = nullptr; InputFn input_ = nullptr; From 280fe3ca10440db43a2a52defe0f9920f6f1f031 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:48:15 +0800 Subject: [PATCH 236/331] build: connect native launcher bootstrap to lifecycle loader --- .../mobile_launcher/src/renpy_mobile_loader.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp index a4c840899..2329c5b8e 100644 --- a/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp +++ b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp @@ -46,6 +46,7 @@ bool Launcher::Resolve() { return false; } init_ = ResolveSymbol(library_handle_, "renpy_mobile_init"); + bootstrap_ = ResolveSymbol(library_handle_, "renpy_mobile_bootstrap"); tick_ = ResolveSymbol(library_handle_, "renpy_mobile_tick"); frame_ = ResolveSymbol(library_handle_, "renpy_mobile_frame"); input_ = ResolveSymbol(library_handle_, "renpy_mobile_input"); @@ -58,6 +59,7 @@ bool Launcher::Resolve() { // unresolved symbols to the iOS executable. A real fork can export these // symbols from the merged extension archive for RTLD_DEFAULT lookup. init_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_init"); + bootstrap_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_bootstrap"); tick_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_tick"); frame_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_frame"); input_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_input"); @@ -87,7 +89,7 @@ int Launcher::Init(const renpy_mobile_config_t& config, const renpy_mobile_host_t& host) { if (!Resolve()) return RENPY_MOBILE_NOT_IMPLEMENTED; if (initialized_) return RENPY_MOBILE_INVALID_STATE; - const int result = init_(&config, &host); + const int result = bootstrap_ ? bootstrap_(&config, &host) : init_(&config, &host); if (result == kOk) { initialized_ = true; } else { @@ -133,6 +135,7 @@ void Launcher::Unload() { #endif library_handle_ = nullptr; init_ = nullptr; + bootstrap_ = nullptr; tick_ = nullptr; frame_ = nullptr; input_ = nullptr; From a0afca51ff939b1b0c58d6021bb1d805150c63f7 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:48:21 +0800 Subject: [PATCH 237/331] build: connect native launcher bootstrap to lifecycle loader --- .../mobile_launcher/patches/native/README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index 0aff9a308..62e4710d5 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -19,6 +19,15 @@ after the host-owned SDL window exists. Build it with the official `tasks/renpython.py` Android `link_android` or iOS `link_ios` closure, then run the symbol gate and device/simulator demo smoke before installing artifacts. -This layer is not packaged automatically as a fake runtime. The source-build -runner must receive a fork checkout that includes this file and the launcher -entrypoint patch. +Apply `0001-renpy-build-link.patch`, `0002-android-host-bootstrap.patch`, and +`0003-ios-host-bootstrap.patch` in the official `renpy-build` checkout +after copying this C file and `renpy_mobile_launcher.h` into its `runtime/` +directory. The task patch compiles the lifecycle object for Android and +archives it into the iOS `librenpython.a`; the two launcher patches add a +`renpy_mobile_bootstrap` entrypoint that performs the official Python +initialization and then enters the cooperative ABI. The existing +`link_android`/`link_ios` closure carries the exports into the native artifacts. + +This layer is not packaged automatically as a fake runtime. Apply all three +native patches to a fork checkout, then the symbol gate and device/simulator +demo smoke must pass before installing artifacts. From 719399b0d8a2bdc86b4deeb69ffccc7cd39df76f Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:48:27 +0800 Subject: [PATCH 238/331] build: connect native launcher bootstrap to lifecycle loader --- .../renpy_runtime/mobile_launcher/patches/README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/README.md b/bridge/renpy_runtime/mobile_launcher/patches/README.md index 8673f448e..698992018 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/README.md @@ -46,11 +46,13 @@ mobile gameplay. `native/renpy_mobile_lifecycle.c` is the shared host-owned implementation for the seven ABI exports. It calls the cooperative Python entrypoints, publishes the existing SDL window surface, queues input, and shuts down without process -exit. The release-specific Android and iOS launcher patch must factor Python -initialization out of `SDL_main`/`launcher_main`, then bind the existing SDL -window and call this implementation. The source-build runner must compile this -file with the official `link_android`/`link_ios` closures and run the export -gate before installing any artifact. +exit. Apply `native/0002-android-host-bootstrap.patch` and +`native/0003-ios-host-bootstrap.patch` to factor Python initialization out of +`SDL_main`/`launcher_main`; the resulting `renpy_mobile_bootstrap` symbol is +resolved by the host loader before the normal `renpy_mobile_init` fallback. +The source-build runner must compile this file with the official +`link_android`/`link_ios` closures and run the export gate before installing +any artifact. ## Python cooperative-loop seam From 918ae2ca586e77a4b1d6cef174d1a32dd66c346e Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:48:34 +0800 Subject: [PATCH 239/331] build: connect native launcher bootstrap to lifecycle loader --- bridge/renpy_runtime/mobile_launcher/build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridge/renpy_runtime/mobile_launcher/build.sh b/bridge/renpy_runtime/mobile_launcher/build.sh index 43d3bd0c5..995156028 100644 --- a/bridge/renpy_runtime/mobile_launcher/build.sh +++ b/bridge/renpy_runtime/mobile_launcher/build.sh @@ -240,7 +240,7 @@ Ren'Py native mobile source-build plan Native ABI fork: $repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c Patch check: (cd $source_root && git apply --check $repo_root/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch) Patch apply: (cd $source_root && git apply $repo_root/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch) - Native fork: add the native ABI fork to the renpy-build `renpython` task and + Native fork: add the native ABI fork to the renpy-build renpython task and factor Python initialization/window binding out of SDL_main/launcher_main Official build commands (Ubuntu 24.04; heavy, opt-in) From 6e79bbf184c06c6fe9fdc7c311c9c0ca06a587c9 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:48:42 +0800 Subject: [PATCH 240/331] build: connect native launcher bootstrap to lifecycle loader --- tools/run_renpy_mobile_source_build.sh | 36 ++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/tools/run_renpy_mobile_source_build.sh b/tools/run_renpy_mobile_source_build.sh index f0319612e..10406dd4e 100644 --- a/tools/run_renpy_mobile_source_build.sh +++ b/tools/run_renpy_mobile_source_build.sh @@ -15,6 +15,7 @@ renpy_build="${AETHERKIRI_RENPY_BUILD_ROOT:-}" fetch=false source_url="${AETHERKIRI_RENPY_BUILD_URL:-https://github.com/renpy/renpy-build.git}" source_ref="${AETHERKIRI_RENPY_BUILD_REF:-}" +native_patch_root="$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native" usage() { cat <<'USAGE' @@ -122,11 +123,42 @@ if [[ ${AETHERKIRI_RENPY_LIFECYCLE_FORK:-0} != "1" ]]; then echo " provide an audited fork exporting renpy_mobile_init/tick/frame/input/pause/resume/shutdown" >&2 exit 1 fi + +[[ -f "$native_patch_root/renpy_mobile_lifecycle.c" ]] || { + echo "missing native lifecycle source: $native_patch_root/renpy_mobile_lifecycle.c" >&2 + exit 1 +} +[[ -f "$native_patch_root/0001-renpy-build-link.patch" ]] || { + echo "missing renpy-build task patch: $native_patch_root/0001-renpy-build-link.patch" >&2 + exit 1 +} +[[ -f "$native_patch_root/0002-android-host-bootstrap.patch" && + -f "$native_patch_root/0003-ios-host-bootstrap.patch" ]] || { + echo "missing platform launcher bootstrap patches under $native_patch_root" >&2 + exit 1 +} +[[ -d "$renpy_build/.git" ]] || { + echo "lifecycle fork build requires a git renpy-build checkout" >&2 + exit 1 +} +cp "$native_patch_root/renpy_mobile_lifecycle.c" "$renpy_build/runtime/renpy_mobile_lifecycle.c" +cp "$repo_root/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h" \ + "$renpy_build/runtime/renpy_mobile_launcher.h" +for native_patch in \ + "$native_patch_root/0001-renpy-build-link.patch" \ + "$native_patch_root/0002-android-host-bootstrap.patch" \ + "$native_patch_root/0003-ios-host-bootstrap.patch"; do + git -C "$renpy_build" apply --check "$native_patch" || { + echo "native lifecycle patch does not apply: $native_patch" >&2 + exit 1 + } + git -C "$renpy_build" apply "$native_patch" +done for lifecycle_source in \ "$renpy_build/runtime/librenpython_android.c" \ "$renpy_build/runtime/librenpython.c"; do - grep -Fq 'renpy_mobile_init' "$lifecycle_source" || { - echo "lifecycle fork source is missing renpy_mobile_init: $lifecycle_source" >&2 + grep -Fq 'renpy_mobile_bootstrap' "$lifecycle_source" || { + echo "lifecycle fork source is missing renpy_mobile_bootstrap: $lifecycle_source" >&2 echo " refusing to build the official blocking launcher" >&2 exit 1 } From 45877a2fafef9f76d96b0b587e80421a6b7540cd Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:48:58 +0800 Subject: [PATCH 241/331] build: add Android and iOS host bootstrap patches --- .../native/0002-android-host-bootstrap.patch | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/native/0002-android-host-bootstrap.patch diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0002-android-host-bootstrap.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0002-android-host-bootstrap.patch new file mode 100644 index 000000000..90d25de6d --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0002-android-host-bootstrap.patch @@ -0,0 +1,63 @@ +diff --git a/runtime/librenpython_android.c b/runtime/librenpython_android.c +--- a/runtime/librenpython_android.c ++++ b/runtime/librenpython_android.c +@@ -8,6 +8,7 @@ + #include + #include "android/log.h" + #include "jniwrapperstuff.h" ++#include "renpy_mobile_launcher.h" + + void init_librenpy(void); + +@@ -143,6 +144,51 @@ + return rv; + } + ++/* Host-owned entrypoint used by the AetherKiri lifecycle fork. This keeps ++ * SDL_main -> Py_RunMain unchanged for the official launcher while exposing a ++ * separate path that initializes Python and returns to the host loop. */ ++int renpy_mobile_bootstrap(const renpy_mobile_config_t *mobile_config, ++ const renpy_mobile_host_t *host) { ++ if (!mobile_config || !host || mobile_config->struct_size < sizeof(*mobile_config) || ++ host->struct_size < sizeof(*host) || ++ mobile_config->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || ++ host->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || ++ !mobile_config->private_root_utf8 || !mobile_config->private_root_utf8[0]) { ++ return RENPY_MOBILE_INVALID_ARGUMENT; ++ } ++ ++ init_environ(); ++ setenv_workaround("RENPY_PLATFORM", PLATFORM "-" ARCH); ++ setenv_workaround("ANDROID_PRIVATE", mobile_config->private_root_utf8); ++ SDL_SetHint(SDL_HINT_ANDROID_BLOCK_ON_PAUSE, "0"); ++ ++ PyPreConfig preconfig; ++ PyConfig pyconfig; ++ PyImport_ExtendInittab(inittab); ++ init_librenpy(); ++ ++ char python[2048]; ++ snprintf(python, sizeof(python), "%s/python", mobile_config->private_root_utf8); ++ char main_py[2048]; ++ snprintf(main_py, sizeof(main_py), "%s/main.py", mobile_config->private_root_utf8); ++ char *argv[] = { python, main_py, NULL }; ++ ++ PyPreConfig_InitPythonConfig(&preconfig); ++ preconfig.utf8_mode = 1; ++ preconfig.use_environment = 0; ++ Py_PreInitializeFromBytesArgs(&preconfig, 2, argv); ++ ++ PyConfig_InitPythonConfig(&pyconfig); ++ pyconfig.home = Py_DecodeLocale(mobile_config->private_root_utf8, NULL); ++ pyconfig.user_site_directory = 0; ++ pyconfig.parse_argv = 1; ++ pyconfig.install_signal_handlers = 0; ++ PyConfig_SetBytesArgv(&pyconfig, 2, argv); ++ Py_InitializeFromConfig(&pyconfig); ++ ++ return renpy_mobile_init(mobile_config, host); ++} ++ + void call_prepare_python(void) { + JNIEnv* env = (JNIEnv*) SDL_GetAndroidJNIEnv(); + jobject activity = (jobject) SDL_GetAndroidActivity(); From 3bb237c0ee218b29f206691553ba1986a56aa15b Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:49:05 +0800 Subject: [PATCH 242/331] build: add Android and iOS host bootstrap patches --- .../native/0003-ios-host-bootstrap.patch | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch new file mode 100644 index 000000000..9c6238a4f --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch @@ -0,0 +1,50 @@ +diff --git a/runtime/librenpython.c b/runtime/librenpython.c +--- a/runtime/librenpython.c ++++ b/runtime/librenpython.c +@@ -4,6 +4,7 @@ + #include + #include + #include "Python.h" ++#include "renpy_mobile_launcher.h" + + /** + * A small note on encoding: This does as much as it can in UTF-8 mode, +@@ -552,6 +553,38 @@ + /** + * This is called from the launcher executable, to start Ren'Py running. + */ ++/* Host-owned entrypoint used by the AetherKiri lifecycle fork. The Renios ++ * prototype's launcher_main remains unchanged for the official app target. */ ++int EXPORT renpy_mobile_bootstrap(const renpy_mobile_config_t *mobile_config, ++ const renpy_mobile_host_t *host) { ++ if (!mobile_config || !host || mobile_config->struct_size < sizeof(*mobile_config) || ++ host->struct_size < sizeof(*host) || ++ mobile_config->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || ++ host->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || ++ !mobile_config->argv0_utf8 || !mobile_config->argv0_utf8[0]) { ++ return RENPY_MOBILE_INVALID_ARGUMENT; ++ } ++ ++ char *argv[] = { (char *)mobile_config->argv0_utf8, NULL }; ++ preinitialize(1, 1, argv); ++ set_renpy_platform(); ++ take_argv0(argv[0]); ++ search_python_home(); ++ ++ config.user_site_directory = 0; ++ config.parse_argv = 1; ++ config.install_signal_handlers = 0; ++ search_pyname(); ++ ++ char **new_argv = (char **)alloca(2 * sizeof(char *)); ++ new_argv[0] = argv[0]; ++ new_argv[1] = pyname; ++ PyConfig_SetBytesArgv(&config, 2, new_argv); ++ Py_InitializeFromConfig(&config); ++ ++ return renpy_mobile_init(mobile_config, host); ++} ++ + int EXPORT launcher_main_wide(int argc, wchar_t **argv) { + + preinitialize_wide(1, argc, argv); From 34e915e97063278529d7db8a48a9b8f57fa64eb2 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:50:24 +0800 Subject: [PATCH 243/331] ci: validate bootstrap patches and source gate --- tools/test_renpy_mobile_native_fork.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tools/test_renpy_mobile_native_fork.sh b/tools/test_renpy_mobile_native_fork.sh index e89ed28c4..3194556f1 100644 --- a/tools/test_renpy_mobile_native_fork.sh +++ b/tools/test_renpy_mobile_native_fork.sh @@ -18,5 +18,26 @@ for forbidden in \ exit 1 fi done +for patch in \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch" \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0002-android-host-bootstrap.patch" \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch"; do + [[ -s "$patch" ]] || { echo "missing native build patch: $patch" >&2; exit 1; } + if [[ "$patch" != *0001-renpy-build-link.patch ]]; then + grep -Fq 'renpy_mobile_bootstrap' "$patch" || { + echo "native build patch does not expose renpy_mobile_bootstrap: $patch" >&2 + exit 1 + } + fi +done + +if [[ -n "${RENPY_BUILD_TEST_ROOT:-}" && -d "${RENPY_BUILD_TEST_ROOT}/.git" ]]; then + for patch in \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch" \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0002-android-host-bootstrap.patch" \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch"; do + git -C "$RENPY_BUILD_TEST_ROOT" apply --check "$patch" + done +fi bash "$repo_root/tools/test_renpy_demo_fixture.sh" printf '%s\n' 'RenPy native lifecycle fork and demo gate passed' From 2ae351684ecee832624df6934570645854757d5e Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:52:14 +0800 Subject: [PATCH 244/331] docs: document complete Ren'Py native payload build flow --- .../mobile_launcher/patches/native/README.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index 62e4710d5..fc2b7eb03 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -31,3 +31,41 @@ initialization and then enters the cooperative ABI. The existing This layer is not packaged automatically as a fake runtime. Apply all three native patches to a fork checkout, then the symbol gate and device/simulator demo smoke must pass before installing artifacts. + +## Full source-build command + +On Ubuntu 24.04 with the Android NDK r29 and the licensed iOS SDK archives +available to `renpy-build`, the complete opt-in flow is: + +```bash +git clone --depth=1 https://github.com/renpy/renpy-build.git /work/renpy-build +(cd /work/renpy-build && ./prepare.sh) +AETHERKIRI_RENPY_LIFECYCLE_FORK=1 \ + AETHERKIRI_RENPY_BUILD_IOS=1 \ + tools/run_renpy_mobile_source_build.sh \ + --mode build --renpy-build /work/renpy-build +``` + +The runner copies the lifecycle C file and ABI header, applies the task patch +and both platform bootstrap patches, runs the official Android/iOS build +tasks, and gates the canonical `.so`/`.a` outputs with the seven exported +symbols. Install the checked outputs into the staging tree only after that +gate: + +```bash +bridge/renpy_runtime/mobile_launcher/build.sh --install \ + --renpy-build /work/renpy-build \ + --stage /work/renpy-mobile-staged \ + --android-so-arm64 /work/renpy-build/renpy/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so \ + --android-so-armv7 /work/renpy-build/renpy/rapt/prototype/renpyandroid/src/main/jniLibs/armeabi-v7a/librenpython.so \ + --android-so-x86_64 /work/renpy-build/renpy/rapt/prototype/renpyandroid/src/main/jniLibs/x86_64/librenpython.so \ + --ios-debug-a /work/renpy-build/renpy/renios/prototype/prebuilt/debug/librenpython.a \ + --ios-release-a /work/renpy-build/renpy/renios/prototype/prebuilt/release/librenpython.a +RENPY_MOBILE_STAGE_TEST_ROOT=/work/renpy-mobile-staged \ + bash tools/test_stage_renpy_android_support.sh +bash tools/test_renpy_mobile_native_fork.sh +``` + +Without the fork flag, SDK archives, NDK, and iOS SDK inputs, the runner skips +or refuses the heavy build and the official blocking archives remain guarded +as `NOT_SUPPORTED`. From 4e06154e368457efa01c075cfd18d11713958e86 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:52:44 +0800 Subject: [PATCH 245/331] ci: gate every Android lifecycle artifact ABI --- tools/run_renpy_mobile_source_build.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/run_renpy_mobile_source_build.sh b/tools/run_renpy_mobile_source_build.sh index 10406dd4e..451f44ea0 100644 --- a/tools/run_renpy_mobile_source_build.sh +++ b/tools/run_renpy_mobile_source_build.sh @@ -181,7 +181,10 @@ for artifact in "$android_arm64" "$android_armv7" "$android_x86_64"; do [[ -f "$artifact" ]] || { echo "Android lifecycle artifact missing: $artifact" >&2; exit 1; } done -RENPY_MOBILE_ANDROID_SO="$android_arm64" bash "$repo_root/tools/test_renpy_mobile_symbol_gate.sh" +for android_artifact in "$android_arm64" "$android_armv7" "$android_x86_64"; do + RENPY_MOBILE_ANDROID_SO="$android_artifact" \ + bash "$repo_root/tools/test_renpy_mobile_symbol_gate.sh" +done if [[ "${AETHERKIRI_RENPY_BUILD_IOS:-0}" == "1" ]]; then echo "Building iOS lifecycle payload (licensed SDK archives required)" From 82b3b7633038997172fa25112820c261fabe8c93 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:54:08 +0800 Subject: [PATCH 246/331] fix: preserve mobile key up events in SDL bridge --- .../mobile_launcher/patches/native/renpy_mobile_lifecycle.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c b/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c index 23ad3a3fb..36eb9e100 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c @@ -137,7 +137,8 @@ int renpy_mobile_input(const renpy_mobile_input_t *event) { sdl_event.tfinger.y = event->y; sdl_event.tfinger.pressure = event->pressure; } else if (event->type == RENPY_MOBILE_INPUT_KEY) { - sdl_event.type = event->value ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP; + sdl_event.type = (event->value == 5) ? SDL_EVENT_KEY_DOWN + : SDL_EVENT_KEY_UP; sdl_event.key.key = (SDL_Keycode)event->code; } else { return RENPY_MOBILE_INVALID_ARGUMENT; From 844f1bf7c69905cea7155267c6ec7ccc9438b7c0 Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Tue, 6 Oct 2026 08:55:47 +0800 Subject: [PATCH 247/331] ci: assert native launcher patches in source-build plan --- tools/test_renpy_mobile_source_build_plan.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/test_renpy_mobile_source_build_plan.sh b/tools/test_renpy_mobile_source_build_plan.sh index cc3965c3d..3e7e0f168 100644 --- a/tools/test_renpy_mobile_source_build_plan.sh +++ b/tools/test_renpy_mobile_source_build_plan.sh @@ -28,6 +28,9 @@ grep -F "./build.sh --platform ios rebuild renios" "$plan" grep -F "librenpython.so" "$plan" grep -F "librenpython.a" "$plan" grep -F "does not assert mobile playability" "$plan" +grep -F 'AETHERKIRI_RENPY_LIFECYCLE_FORK' "$runner" +grep -F '0002-android-host-bootstrap.patch' "$runner" +grep -F '0003-ios-host-bootstrap.patch' "$runner" # The repository checkout is intentionally not a renpy-build checkout. The # strict preflight must refuse it instead of silently running a partial build. From 08114ab1d953b68aae82213e611896c8aaaac675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 09:12:05 +0000 Subject: [PATCH 248/331] fix(renpy): preserve mobile provider diagnostics Register the existing error callback so engine_get_last_error exposes the mobile NOT_SUPPORTED reason. Verified by the existing mobile provider guard test in a Linux host harness with the Apple/iOS code path selected. --- bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp index 754dfdc45..17e53060a 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp @@ -406,6 +406,7 @@ const engine_runtime_provider_v1_t& Provider() { p.get_frame_rendered_flag = Rendered; p.get_renderer_info = Renderer; p.get_text_input_state = UnsupportedTextState; + p.get_last_error = Error; return p; }(); return provider; From 3f2f892f9426d972e40cf007da46d02e3dd7bf1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 12:09:39 +0000 Subject: [PATCH 249/331] fix: rebuild embedded Ren'Py runtime and require real mobile validation --- .gitattributes | 2 + .github/workflows/build.yml | 108 ++- .github/workflows/renpy-desktop.yml | 17 +- .github/workflows/renpy-mobile-contract.yml | 38 +- .../workflows/renpy-mobile-runtime-build.yml | 85 ++ CMakeLists.txt | 6 +- apps/godot_app/scripts/main.gd | 7 + .../scripts/renpy_mobile_acceptance.gd | 290 +++++++ .../scripts/renpy_mobile_acceptance.gd.uid | 1 + bridge/renpy_runtime/CMakeLists.txt | 43 +- bridge/renpy_runtime/README.md | 197 ++--- .../android/RenPyMobileBridge.java | 70 +- .../android/org/libsdl/LICENSE.txt | 17 + .../android/org/libsdl/app/SDL.java | 86 ++ .../android/org/libsdl/app/SDLActivity.java | 222 ++++- .../org/libsdl/app/SDLAudioManager.java | 395 +++++++++ .../org/libsdl/app/SDLControllerManager.java | 793 ++++++++++++++++++ .../org/libsdl/app/SDLInputConnection.java | 13 + .../org/renpy/android/PythonSDLActivity.java | 53 +- .../include/renpy_input_mapping.h | 184 ++++ .../include/renpy_mobile_adapter.h | 14 +- .../renpy_runtime/mobile_launcher/README.md | 196 ++--- bridge/renpy_runtime/mobile_launcher/build.sh | 213 ++--- .../include/renpy_mobile_launcher.h | 52 +- .../include/renpy_mobile_loader.h | 14 +- .../mobile_launcher/patches/README.md | 97 +-- .../native/0001-renpy-build-link.patch | 45 +- .../native/0002-android-host-bootstrap.patch | 87 +- .../native/0003-ios-host-bootstrap.patch | 74 +- .../0004-android-offscreen-renderer.patch | 51 ++ .../mobile_launcher/patches/native/README.md | 89 +- .../patches/native/renpy_mobile_lifecycle.c | 390 ++++++--- .../0001-cooperative-loop-skeleton.patch | 695 ++++++++++++--- .../mobile_launcher/patches/python/README.md | 43 +- .../src/renpy_mobile_loader.cpp | 55 +- .../python/aether_renpy_overlay.py | 10 + .../src/renpy_mobile_adapter.cpp | 84 +- .../src/renpy_runtime_mobile_provider.cpp | 173 +++- .../src/renpy_runtime_provider.cpp | 193 +---- .../renpy_runtime/tests/mobile_lifecycle.cpp | 123 +++ .../tests/mobile_provider_protocol.cpp | 112 +++ demos/aetherkiri-renpy/README.md | 45 + demos/aetherkiri-renpy/game/options.rpy | 12 +- demos/aetherkiri-renpy/game/script.rpy | 63 ++ scripts/build_android.sh | 40 +- scripts/build_ios.sh | 133 ++- tools/run_renpy_android_device_acceptance.py | 440 ++++++++++ tools/run_renpy_godot_e2e.sh | 4 + tools/run_renpy_mobile_source_build.sh | 341 ++++---- tools/stage_renpy_android_support.sh | 62 +- tools/test_renpy_mobile_cooperative.py | 142 ++++ tools/test_renpy_mobile_launcher_loader.sh | 7 + tools/test_renpy_mobile_launcher_scaffold.sh | 11 +- tools/test_renpy_mobile_native_fork.sh | 16 +- tools/test_renpy_mobile_packaging.sh | 61 ++ tools/test_renpy_mobile_runtime_protocol.sh | 28 + tools/test_renpy_mobile_source_build_plan.sh | 71 +- tools/test_renpy_mobile_symbol_gate.sh | 8 + tools/test_stage_renpy_android_support.sh | 19 +- tools/validate_renpy_mobile_payload.py | 180 ++++ 60 files changed, 5639 insertions(+), 1481 deletions(-) create mode 100644 .github/workflows/renpy-mobile-runtime-build.yml create mode 100644 apps/godot_app/scripts/renpy_mobile_acceptance.gd create mode 100644 apps/godot_app/scripts/renpy_mobile_acceptance.gd.uid create mode 100644 bridge/renpy_runtime/android/org/libsdl/LICENSE.txt create mode 100644 bridge/renpy_runtime/android/org/libsdl/app/SDL.java create mode 100644 bridge/renpy_runtime/android/org/libsdl/app/SDLAudioManager.java create mode 100644 bridge/renpy_runtime/android/org/libsdl/app/SDLControllerManager.java create mode 100644 bridge/renpy_runtime/android/org/libsdl/app/SDLInputConnection.java create mode 100644 bridge/renpy_runtime/include/renpy_input_mapping.h create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/native/0004-android-offscreen-renderer.patch create mode 100644 bridge/renpy_runtime/tests/mobile_lifecycle.cpp create mode 100644 bridge/renpy_runtime/tests/mobile_provider_protocol.cpp create mode 100644 tools/run_renpy_android_device_acceptance.py mode change 100644 => 100755 tools/run_renpy_mobile_source_build.sh create mode 100755 tools/test_renpy_mobile_cooperative.py create mode 100755 tools/test_renpy_mobile_packaging.sh create mode 100755 tools/test_renpy_mobile_runtime_protocol.sh mode change 100644 => 100755 tools/test_renpy_mobile_source_build_plan.sh create mode 100755 tools/validate_renpy_mobile_payload.py diff --git a/.gitattributes b/.gitattributes index ef236cfae..159001c95 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,8 @@ # Enforce LF for vcpkg patch files, as patch.exe on Windows fails with CRLF line endings *.patch text eol=lf +# Empty context lines in unified diffs must retain their leading space. +bridge/renpy_runtime/mobile_launcher/patches/**/*.patch whitespace=-blank-at-eol,-space-before-tab # Product game archives are binary assets. *.xp3 -text diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b4de92d4d..87fb9b73e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -70,8 +70,12 @@ env: AETHER_APP_BUNDLE_ID: ${{ secrets.AETHERID || 'com.liuyu.aether.aether' }} jobs: + renpy-android-runtime: + uses: ./.github/workflows/renpy-mobile-runtime-build.yml + android: name: Build Android App + needs: renpy-android-runtime runs-on: ubuntu-latest env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' @@ -79,8 +83,7 @@ jobs: GODOT_RELEASE: 4.7.2-stable GODOT_TEMPLATE_VERSION: 4.7.2.stable ANDROID_NDK_VERSION: 28.1.13356709 - # Compile the guarded Ren'Py mobile provider stub. The provider still - # returns NOT_SUPPORTED until the official RAPT/JNI bootstrap is wired. + # Package the fork built from this exact checkout, with its Python data. AETHERKIRI_ENABLE_RENPY: ON steps: @@ -156,16 +159,25 @@ jobs: sudo apt-get install -y cmake ninja-build pkg-config autoconf autoconf-archive automake libtool gettext bison flex curl unzip zip zstd ccache jq mono-complete rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android - - name: Stage official Ren'Py Android support inputs + - name: Download the rebuilt Ren'Py Android runtime + uses: actions/download-artifact@v6 + with: + name: renpy-android-runtime + path: ${{ runner.temp }}/renpy-runtime-download + + - name: Stage and validate the rebuilt Ren'Py Android payload run: | set -euo pipefail mobile_root="${RUNNER_TEMP}/aetherkiri-renpy-mobile/android" - bash tools/install_renpy_mobile_support.sh \ - --platform android \ - --destination "$mobile_root" + mkdir -p "$mobile_root" + tar -xzf "${RUNNER_TEMP}/renpy-runtime-download/renpy-android-runtime.tar.gz" -C "$mobile_root" + mobile_root="$mobile_root/payload" test -f "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" - RENPY_MOBILE_STAGE_TEST_ROOT="$mobile_root" bash tools/test_stage_renpy_android_support.sh + python3 tools/validate_renpy_mobile_payload.py --platform android \ + --library "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" \ + --private-root "$mobile_root/rapt/runtime/private" echo "RENPY_MOBILE_ROOT=$mobile_root" >> "$GITHUB_ENV" + echo "AETHERKIRI_RENPY_ANDROID_PRIVATE_ASSETS=$mobile_root/rapt/runtime/private" >> "$GITHUB_ENV" - name: Restore ccache id: restore-ccache @@ -359,13 +371,15 @@ jobs: export NDK_CCACHE=ccache ./build.sh android debug --jobs=4 - - name: Verify Android Ren'Py mobile guard was compiled + - name: Verify the compiled provider and final APK runtime payload run: | set -euo pipefail grep -F 'renpy_runtime_mobile_provider.cpp' \ out/android/arm64-v8a/debug/compile_commands.json grep -F 'renpy_mobile_adapter.cpp' \ out/android/arm64-v8a/debug/compile_commands.json + python3 tools/validate_renpy_mobile_payload.py --platform android \ + --apk out/godot/android/debug/Aether-debug.apk - name: Verify exported APKs run: | @@ -380,6 +394,13 @@ jobs: name: android-release-apk path: out/godot/android/release/Aether-release.apk retention-days: 7 + - name: Upload installable Debug APK for device acceptance + uses: actions/upload-artifact@v6 + with: + name: android-renpy-debug-apk + path: out/godot/android/debug/Aether-debug.apk + if-no-files-found: error + retention-days: 7 - name: Save ccache if: success() && github.event_name != 'pull_request' && steps.restore-ccache.outputs.cache-hit != 'true' @@ -396,9 +417,9 @@ jobs: CCACHE_DIR: ${{ github.workspace }}/.ccache GODOT_RELEASE: 4.7.2-stable GODOT_TEMPLATE_VERSION: 4.7.2.stable - # Compile the guarded Ren'Py mobile provider stub. The provider still - # returns NOT_SUPPORTED until the official Renios/Xcode adapter is wired. + # iOS requires the same fork compiled with legally supplied Apple SDKs. AETHERKIRI_ENABLE_RENPY: ON + AETHERKIRI_RENPY_RENIOS_LINK: ON steps: - name: Checkout Repository @@ -504,14 +525,71 @@ jobs: echo "$(brew --prefix bison)/bin" >> $GITHUB_PATH rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios - - name: Stage official Ren'Py iOS support inputs + - name: Compile the lifecycle and provider for the real Xcode iOS target + run: | + set -euo pipefail + # Use the SDK already licensed/provisioned on this cloud macOS runner; + # no SDK archive, credential, or user-computer access is transferred. + brew install python@3.12 + python_config="$(brew --prefix python@3.12)/bin/python3.12-config" + ios_sdk="$(xcrun --sdk iphoneos --show-sdk-path)" + evidence_dir="${RUNNER_TEMP}/renpy-ios-compile" + mkdir -p "$evidence_dir" + xcrun --sdk iphoneos clang -target arm64-apple-ios14.0 \ + -isysroot "$ios_sdk" -std=c11 -Wall -Wextra -Werror \ + $("$python_config" --includes) \ + -I bridge/renpy_runtime/mobile_launcher/include \ + -c bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c \ + -o "$evidence_dir/renpy_mobile_lifecycle.o" + for source in renpy_runtime_mobile_provider renpy_runtime_ios_adapter; do + xcrun --sdk iphoneos clang++ -target arm64-apple-ios14.0 \ + -isysroot "$ios_sdk" -std=c++17 -Wall -Wextra -Werror \ + -DAETHERKIRI_RENPY_IOS=1 -I abi/include \ + -I bridge/renpy_runtime/include -I bridge/renpy_runtime/mobile_launcher/include \ + -c "bridge/renpy_runtime/src/$source.cpp" -o "$evidence_dir/$source.o" + done + xcrun lipo -verify_arch arm64 "$evidence_dir/renpy_mobile_lifecycle.o" + echo "iOS target objects compiled using the Python 3.12 C API; no target CPython link or gameplay has run." + + - name: Preserve iOS target compile evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-target-objects + path: ${{ runner.temp }}/renpy-ios-compile/*.o + if-no-files-found: warn + retention-days: 7 + + - name: Check the rebuilt iOS runtime source + env: + IOS_RUNTIME_RUN_ID: ${{ vars.AETHER_RENPY_IOS_RUNTIME_RUN_ID }} + IOS_RUNTIME_ARTIFACT: ${{ vars.AETHER_RENPY_IOS_RUNTIME_ARTIFACT }} + run: | + set -euo pipefail + if [[ -z "$IOS_RUNTIME_RUN_ID" || -z "$IOS_RUNTIME_ARTIFACT" ]]; then + echo "iOS Ren'Py build blocked: provide AETHER_RENPY_IOS_RUNTIME_RUN_ID and AETHER_RENPY_IOS_RUNTIME_ARTIFACT for a rebuilt Renios/Python payload from the pinned fork, built with licensed Apple SDKs." >&2 + exit 1 + fi + + - name: Download the rebuilt Ren'Py iOS runtime + uses: actions/download-artifact@v6 + with: + name: ${{ vars.AETHER_RENPY_IOS_RUNTIME_ARTIFACT }} + run-id: ${{ vars.AETHER_RENPY_IOS_RUNTIME_RUN_ID }} + github-token: ${{ github.token }} + path: ${{ runner.temp }}/renpy-ios-runtime + + - name: Stage rebuilt Ren'Py iOS runtime run: | set -euo pipefail mobile_root="${RUNNER_TEMP}/aetherkiri-renpy-mobile/ios" - bash tools/install_renpy_mobile_support.sh \ - --platform ios \ - --destination "$mobile_root" + mkdir -p "$mobile_root" + tar -xzf "${RUNNER_TEMP}/renpy-ios-runtime/renpy-ios-runtime.tar.gz" -C "$mobile_root" + mobile_root="$mobile_root/payload" test -f "$mobile_root/renios/prototype/prebuilt/release/librenpython.a" + python3 tools/validate_renpy_mobile_payload.py --platform ios --arch arm64 --sdk iphoneos \ + --library "$mobile_root/renios/prototype/prebuilt/release/librenpython.a" \ + --private-root "$mobile_root/renios/prototype/base" echo "RENPY_MOBILE_ROOT=$mobile_root" >> "$GITHUB_ENV" - name: Install Godot export tools @@ -664,7 +742,7 @@ jobs: exit 1 fi - - name: Verify iOS Ren'Py mobile guard was compiled + - name: Verify iOS Ren'Py lifecycle provider was compiled run: | set -euo pipefail grep -F 'renpy_runtime_mobile_provider.cpp' \ diff --git a/.github/workflows/renpy-desktop.yml b/.github/workflows/renpy-desktop.yml index 9de6753d3..05aa9bf22 100644 --- a/.github/workflows/renpy-desktop.yml +++ b/.github/workflows/renpy-desktop.yml @@ -12,6 +12,7 @@ on: - 'tests/fixtures/renpy_smoke/**' - 'tools/install_renpy_sdk.sh' - 'tools/run_renpy_smoke.sh' + - 'tools/run_renpy_overlay_probe.sh' - 'tools/test_renpy_demo_fixture.sh' - 'tools/test_install_renpy_sdk.sh' workflow_dispatch: @@ -25,7 +26,7 @@ concurrency: jobs: desktop-sdk: - name: Ren'Py desktop smoke (${{ matrix.platform }}) + name: Execute Ren'Py desktop fixture (${{ matrix.platform }}) strategy: fail-fast: false matrix: @@ -65,11 +66,6 @@ jobs: sdk_root="${RUNNER_TEMP}/aetherkiri-renpy-sdk" bash tools/install_renpy_sdk.sh --platform "${RENPY_PLATFORM}" --arch x86_64 --destination "${sdk_root}" test -f "${sdk_root}/renpy.sh" || test -f "${sdk_root}/renpy.exe" - if [[ "${RENPY_RUN_EXECUTION_SMOKE:-0}" != 1 ]]; then - echo "Ren'Py execution smoke is opt-in; SDK install/integrity matrix passed" - echo "Set RENPY_RUN_EXECUTION_SMOKE=1 with a built SDK to run run_renpy_smoke.sh" - exit 0 - fi platform_lib_dir="$(find "${sdk_root}/lib" -maxdepth 1 -type d -name 'py3-*' -print -quit 2>/dev/null || true)" if [[ -z "${platform_lib_dir}" ]] || ! find "${platform_lib_dir}" -maxdepth 1 -type f \ @@ -82,3 +78,12 @@ jobs: AETHERKIRI_RENPY_DEMO_FIXTURE="${GITHUB_WORKSPACE}/demos/aetherkiri-renpy" \ RENPY_SDK="${sdk_root}" RENPY_DEMO_RUN_SMOKE=1 \ bash tools/test_renpy_demo_fixture.sh + + - name: Exercise real Ren'Py frame, input, and quit on Linux + if: matrix.platform == 'linux' + env: + RENPY_RENDERER: sw + run: | + set -euo pipefail + RENPY_SDK="${RUNNER_TEMP}/aetherkiri-renpy-sdk" \ + bash tools/run_renpy_overlay_probe.sh diff --git a/.github/workflows/renpy-mobile-contract.yml b/.github/workflows/renpy-mobile-contract.yml index b9b112f9d..179268988 100644 --- a/.github/workflows/renpy-mobile-contract.yml +++ b/.github/workflows/renpy-mobile-contract.yml @@ -17,6 +17,10 @@ on: - 'tools/run_renpy_mobile_source_build.sh' - 'tools/test_renpy_mobile_source_build_plan.sh' - 'tools/test_renpy_mobile_native_fork.sh' + - 'tools/test_renpy_mobile_cooperative.py' + - 'tools/validate_renpy_mobile_payload.py' + - 'tools/test_renpy_mobile_packaging.sh' + - 'tools/test_renpy_mobile_runtime_protocol.sh' - 'tools/test_stage_renpy_android_support.sh' workflow_dispatch: @@ -44,7 +48,7 @@ jobs: run: | set -euo pipefail sudo apt-get update - sudo apt-get install -y build-essential binutils + sudo apt-get install -y build-essential binutils python3-dev libsdl2-dev pkg-config - name: Run mobile launcher scaffold and loader tests run: | @@ -53,29 +57,39 @@ jobs: bash tools/test_renpy_mobile_launcher_loader.sh bash tools/test_renpy_mobile_symbol_gate.sh bash tools/test_renpy_mobile_source_build_plan.sh - bash tools/run_renpy_mobile_source_build.sh --mode auto bash tools/test_renpy_mobile_native_fork.sh + bash tools/test_renpy_mobile_packaging.sh - - name: Check cooperative patch against current Ren'Py source + - name: Checkout pinned Ren'Py source for the 8.5.3 rebuild uses: actions/checkout@v6 with: repository: renpy/renpy - ref: master + ref: 39895c1e017f0b36ffea2447d97eccd69d76ee1c path: _renpy-source fetch-depth: 1 - sparse-checkout: | - renpy/main.py - renpy/execution.py - renpy/display/core.py - name: Validate cooperative patch applicability run: | set -euo pipefail bash bridge/renpy_runtime/mobile_launcher/build.sh \ --check-python-patch --renpy-src _renpy-source - - name: Assert staged archives remain non-playable + - name: Exercise stack-preserving scheduling and input boundaries run: | set -euo pipefail - grep -F 'ENGINE_RESULT_NOT_SUPPORTED' bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp - grep -F 'contract and build/replacement scaffold' bridge/renpy_runtime/mobile_launcher/README.md - grep -F 'NOT_SUPPORTED' bridge/renpy_runtime/mobile_launcher/README.md + python3 -m venv "${RUNNER_TEMP}/cooperative-test" + "${RUNNER_TEMP}/cooperative-test/bin/pip" install greenlet==3.2.4 + bash bridge/renpy_runtime/mobile_launcher/build.sh \ + --apply-python-patch --renpy-src _renpy-source + "${RUNNER_TEMP}/cooperative-test/bin/python" \ + tools/test_renpy_mobile_cooperative.py --renpy-src _renpy-source + + - name: Compile the lifecycle implementation against Python and SDL2 + run: | + set -euo pipefail + cc -std=c11 -Wall -Wextra -Werror -fPIC \ + $(python3-config --includes) $(pkg-config --cflags sdl2) \ + -I bridge/renpy_runtime/mobile_launcher/include \ + -c bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c \ + -o "${RUNNER_TEMP}/renpy_mobile_lifecycle.o" + bash tools/test_renpy_mobile_runtime_protocol.sh + echo "Compiled and exercised the native CPython boundary; this does not execute a mobile game." diff --git a/.github/workflows/renpy-mobile-runtime-build.yml b/.github/workflows/renpy-mobile-runtime-build.yml new file mode 100644 index 000000000..d5128916a --- /dev/null +++ b/.github/workflows/renpy-mobile-runtime-build.yml @@ -0,0 +1,85 @@ +name: Build Ren'Py Android runtime + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + android-runtime: + name: Compile the pinned Ren'Py lifecycle fork + runs-on: ubuntu-24.04 + timeout-minutes: 360 + env: + RENPY_BUILD_ROOT: ${{ github.workspace }}/_renpy-build + RENPY_OUTPUT_ROOT: ${{ github.workspace }}/out/renpy-mobile-source + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Provision the official Linux build prerequisites + run: | + set -euo pipefail + # This disposable source-build job does not use the host Android SDK, + # .NET, or Haskell installations. Keep their storage for the rebuild. + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ + /usr/local/.ghcup /usr/local/swift /usr/local/share/boost /opt/hostedtoolcache + sudo apt-get update + sudo apt-get install --no-install-recommends -y \ + build-essential ccache unzip autoconf autoconf-archive automake \ + libtool-bin pkg-config llvm-18 clang-18 python3-dev python3-venv \ + curl python3-jinja2 debootstrap libgmp-dev libmpfr-dev libmpc-dev \ + libssl-dev libbz2-dev liblzma-dev bc cmake ninja-build libassimp-dev \ + libavcodec-dev libavformat-dev libswresample-dev libswscale-dev \ + libharfbuzz-dev libfreetype6-dev libfribidi-dev libsdl2-dev \ + libsdl2-image-dev libjpeg-dev + /usr/bin/python3 -m venv "${RUNNER_TEMP}/renpy-tools" + "${RUNNER_TEMP}/renpy-tools/bin/pip" install uv==0.6.17 + echo "${RUNNER_TEMP}/renpy-tools/bin" >> "$GITHUB_PATH" + echo /usr/lib/llvm-18/bin >> "$GITHUB_PATH" + - name: Prepare pinned sources and apply the runtime patches + run: | + set -euo pipefail + bash tools/run_renpy_mobile_source_build.sh --mode prepare --fetch \ + --platform android --renpy-build "$RENPY_BUILD_ROOT" \ + --output-dir "$RENPY_OUTPUT_ROOT" + - name: Supply the official pinned Android NDK + run: | + set -euo pipefail + mkdir -p "$RENPY_BUILD_ROOT/tars" + ndk_archive="$RENPY_BUILD_ROOT/tars/android-ndk-r29-linux.zip" + curl --fail --location --retry 3 \ + https://dl.google.com/android/repository/android-ndk-r29-linux.zip \ + -o "$ndk_archive" + # Published by android/ndk's Unsupported-Downloads page. + printf '87e2bb7e9be5d6a1c6cdf5ec40dd4e0c6d07c30b %s\n' "$ndk_archive" | sha1sum --check + - name: Build the native libraries and Python payload + run: | + set -euo pipefail + bash tools/run_renpy_mobile_source_build.sh --mode build \ + --platform android --renpy-build "$RENPY_BUILD_ROOT" \ + --output-dir "$RENPY_OUTPUT_ROOT" 2>&1 | tee "${RUNNER_TEMP}/renpy-source-build.log" + python3 tools/validate_renpy_mobile_payload.py \ + --platform android \ + --library "$RENPY_OUTPUT_ROOT/payload/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" \ + --private-root "$RENPY_OUTPUT_ROOT/payload/rapt/runtime/private" + tar -C "$RENPY_OUTPUT_ROOT" -czf "${RUNNER_TEMP}/renpy-android-runtime.tar.gz" payload manifest.json + - name: Upload the compiled runtime for the application build + uses: actions/upload-artifact@v6 + with: + name: renpy-android-runtime + path: ${{ runner.temp }}/renpy-android-runtime.tar.gz + if-no-files-found: error + retention-days: 7 + - name: Preserve source-build evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-source-build-evidence + path: | + ${{ runner.temp }}/renpy-source-build.log + out/renpy-mobile-source/manifest.json + if-no-files-found: warn + retention-days: 7 diff --git a/CMakeLists.txt b/CMakeLists.txt index 9c9abc37a..173786ac9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,9 +112,13 @@ option(BUILD_ENGINE_API "build engine_api bridge shared library" ON) option(BUILD_GODOT_EXTENSION "build Godot GDExtension host library" ON) option(AETHERKIRI_ENABLE_RFVP "build the experimental native FVP runtime (requires Rust)" OFF) option(AETHERKIRI_ENABLE_RENPY - "build the opt-in official Ren'Py SDK subprocess provider" OFF) + "build the opt-in Ren'Py desktop or mobile runtime provider" OFF) set(AETHERKIRI_RENPY_SDK_ROOT "" CACHE PATH "official desktop Ren'Py SDK directory for the embedded provider") +set(AETHERKIRI_RENPY_MOBILE_LIBRARY "" CACHE FILEPATH + "rebuilt host lifecycle librenpython.so/.a for the selected mobile target") +set(AETHERKIRI_RENPY_PRIVATE_ROOT "" CACHE PATH + "unpacked cooperative Ren'Py Python runtime resources for mobile packaging") option(BUILD_GPU_BRIDGE "build legacy external GPU bridge renderer" OFF) option(AETHERKIRI_ENABLE_ONSCRIPTER "enable the bundled OnscripterYuri runtime" ON) diff --git a/apps/godot_app/scripts/main.gd b/apps/godot_app/scripts/main.gd index 54dfef2ee..decb26dc3 100644 --- a/apps/godot_app/scripts/main.gd +++ b/apps/godot_app/scripts/main.gd @@ -2524,6 +2524,7 @@ func _normalize_cli_probe_script(path: String) -> String: "res://scripts/step_render_probe.gd", "res://scripts/gui_render_probe.gd", "res://scripts/perf_input_probe.gd", + "res://scripts/renpy_mobile_acceptance.gd", "res://scripts/wa2_gui.gd", ] for item in known: @@ -13234,6 +13235,12 @@ func _add_scene_test_exit_chip() -> void: func _run_cli_script_probe() -> void: _write_probe_marker("cli_probe start script=%s" % cli_probe_script) var config := ProbeConfig.load() + if cli_probe_script == "res://scripts/renpy_mobile_acceptance.gd": + _prepare_cli_probe_view(config) + var acceptance := load(cli_probe_script).new() as Node + add_child(acceptance) + acceptance.start(config, player) + return var target_game_path: String = ProbeConfig.require_game_path(config) var requested_game_path := target_game_path if OS.get_name() == "iOS" and not target_game_path.is_empty(): diff --git a/apps/godot_app/scripts/renpy_mobile_acceptance.gd b/apps/godot_app/scripts/renpy_mobile_acceptance.gd new file mode 100644 index 000000000..7d5985247 --- /dev/null +++ b/apps/godot_app/scripts/renpy_mobile_acceptance.gd @@ -0,0 +1,290 @@ +extends Node + +# A debug-device observer, driven exclusively by OS input. It never manufactures +# game progress, sends a scripted click, or falls back to a viewport screenshot. +const GameInputMapping = preload("res://scripts/game_input_mapping.gd") +const POINTER_DOWN := 1 +const POINTER_MOVE := 2 +const POINTER_UP := 3 +const TOUCH_POINTER_ID_OFFSET := 100000 + +var player +var rect: TextureRect +var config: Dictionary = {} +var evidence_dir := "" +var game_dir := "" +var run_id := "" +var started := false +var finished := false +var paused := false +var keyboard_active := false +var resumed_capture_pending := false +var checkpoint_count := 0 +var suppress_mouse_until := 0 +var deadline := 0 + +func start(request: Dictionary, runtime_player: Object) -> void: + config = request + player = runtime_player + if not OS.is_debug_build() or OS.get_name() not in ["Android", "iOS"]: + _fail("This observer requires a debug mobile app on a cloud device.") + return + run_id = String(config.get("run_id", "")) + game_dir = String(config.get("game_path", "")).path_join("game") + evidence_dir = ProjectSettings.globalize_path("user://renpy-device-evidence") + DirAccess.make_dir_recursive_absolute(evidence_dir) + if run_id.is_empty() or not FileAccess.file_exists(game_dir.path_join("aether-device-request.json")): + _fail("The staged real Ren'Py device project/request is missing.") + return + deadline = Time.get_ticks_msec() + int(config.get("timeout_seconds", 180)) * 1000 + rect = TextureRect.new() + rect.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + rect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR + add_child(rect) + # Use the provider's original pixels for the native/OS image comparison, + # independently of any saved cloud debug-app enhancement preference. + player.set_frame_enhancement_enabled(false) + var result: int = player.set_render_backend("Godot Native") + if result == 0: + result = player.set_engine_option("runtime", "renpy") + if result == 0: + result = player.open_game(String(config.get("game_path", "")), true) + if result != 0: + _fail("Opening the real Ren'Py provider failed: %s" % player.get_last_error()) + return + _record({"kind": "opening", "platform": OS.get_name(), "debug_build": true}) + +func _process(delta: float) -> void: + if finished or player == null: + return + if Time.get_ticks_msec() > deadline: + _fail("Device gameplay timed out; no automatic success or skipped result.") + return + if not started: + var state: int = player.get_startup_state() + if state == 3: + _fail("Ren'Py startup failed: %s" % player.get_last_error()) + return + if state != 2: + return + started = true + _record({"kind": "startup", "provider_debug": _provider_debug()}) + if paused: + return + var result: int = player.tick(clampf(delta, 0.001, 0.1)) + # Normal exit must be observed in native provider state, independently of + # the script's quit_requested checkpoint. A successful tick alone is not exit. + var native_state := _provider_debug() + if bool(native_state.get("exited", false)): + if not _has_game_checkpoint("quit_requested"): + _fail("Native provider exited before the game's explicit Quit action.") + return + _finish(native_state) + return + if result != 0: + _fail("Ren'Py tick failed: %s" % player.get_last_error()) + return + var texture: Texture2D = player.update_frame_texture() + if texture != null: + rect.texture = texture + if resumed_capture_pending: + resumed_capture_pending = false + _capture_provider("resumed_ready") + _sync_keyboard() + _observe_game_checkpoints() + +func _notification(what: int) -> void: + if not started or finished or player == null: + return + if what == NOTIFICATION_APPLICATION_PAUSED and not paused: + var result: int = player.pause() + paused = result == 0 + _record({"kind": "lifecycle", "phase": "paused", "result": result, + "provider_debug": _provider_debug()}) + if result != 0: + _fail("Native pause failed: %s" % player.get_last_error()) + elif what == NOTIFICATION_APPLICATION_RESUMED and paused: + var result: int = player.resume() + paused = false + resumed_capture_pending = result == 0 + _record({"kind": "lifecycle", "phase": "resumed", "result": result, + "provider_debug": _provider_debug()}) + if result != 0: + _fail("Native resume failed: %s" % player.get_last_error()) + +func _input(event: InputEvent) -> void: + if not started or paused or finished or player == null or rect.texture == null: + return + var result := 0 + if event is InputEventScreenTouch: + var touch := event as InputEventScreenTouch + suppress_mouse_until = Time.get_ticks_msec() + 700 + var point := _map_point(touch.position) + if point.x < 0: + return + var action := POINTER_DOWN if touch.pressed else POINTER_UP + result = player.send_pointer_event(action, TOUCH_POINTER_ID_OFFSET + touch.index, + point.x, point.y, 0.0, 0.0, 0) + _record({"kind": "os_touch", "action": action, "pointer_id": touch.index, + "position": [point.x, point.y], "result": result}) + elif event is InputEventScreenDrag: + var drag := event as InputEventScreenDrag + suppress_mouse_until = Time.get_ticks_msec() + 700 + var point := _map_point(drag.position) + var relative := GameInputMapping.map_delta(drag.relative, rect.size, rect.texture.get_size()) + result = player.send_pointer_event(POINTER_MOVE, TOUCH_POINTER_ID_OFFSET + drag.index, + point.x, point.y, relative.x, relative.y, 0) + elif event is InputEventMouseButton: + if event.device == -1 or Time.get_ticks_msec() < suppress_mouse_until: + return + var button := event as InputEventMouseButton + var point := _map_point(button.position) + if point.x < 0: + return + result = player.send_pointer_event(POINTER_DOWN if button.pressed else POINTER_UP, + 0, point.x, point.y, 0.0, 0.0, 0) + _record({"kind": "os_mouse", "pressed": button.pressed, "result": result}) + elif event is InputEventKey: + var key := event as InputEventKey + var code := int(key.keycode) + if key.keycode == KEY_ENTER or key.keycode == KEY_KP_ENTER: + code = 13 + elif key.keycode == KEY_BACKSPACE: + code = 8 + elif key.keycode == KEY_ESCAPE: + code = 27 + elif key.keycode == KEY_DELETE: + code = 46 + elif key.keycode == KEY_LEFT: + code = 37 + elif key.keycode == KEY_RIGHT: + code = 39 + result = player.send_key_event(key.pressed, code, 0, key.unicode) + _record({"kind": "os_key", "pressed": key.pressed, "code": code, + "unicode": key.unicode, "result": result}) + else: + return + get_viewport().set_input_as_handled() + if result != 0: + _fail("The real OS input was rejected: %s" % player.get_last_error()) + +func _map_point(point: Vector2) -> Vector2: + return GameInputMapping.map_point(point, rect.get_global_rect(), rect.texture.get_size()) + +func _sync_keyboard() -> void: + var state: Dictionary = player.get_text_input_state() + var active := bool(state.get("available", false)) and bool(state.get("ime_active", false)) + if active == keyboard_active: + return + keyboard_active = active + var available := DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD) + if active and available: + DisplayServer.virtual_keyboard_show(String(state.get("text", "")), Rect2(0, 0, 1, 1)) + elif available: + DisplayServer.virtual_keyboard_hide() + _record({"kind": "soft_keyboard", "active": active, "feature_available": available, + "source": "native-text-input-state", "state": state}) + +func _game_checkpoints() -> Array: + var rows: Array = [] + var file := FileAccess.open(game_dir.path_join("aether-device-checkpoints.jsonl"), FileAccess.READ) + if file == null: + return rows + while not file.eof_reached(): + var parsed = JSON.parse_string(file.get_line()) + if parsed is Dictionary and String(parsed.get("run_id", "")) == run_id: + rows.append(parsed) + return rows + +func _has_game_checkpoint(stage: String) -> bool: + for row in _game_checkpoints(): + if String(row.get("stage", "")) == stage: + return true + return false + +func _observe_game_checkpoints() -> void: + var rows := _game_checkpoints() + while checkpoint_count < rows.size(): + var row: Dictionary = rows[checkpoint_count] + checkpoint_count += 1 + _record({"kind": "checkpoint_seen", "checkpoint": row}) + if String(row.get("stage", "")) in ["start_ready", "post_text_ready", "quit_ready"]: + _capture_provider(String(row.get("stage", ""))) + +func _capture_provider(stage: String) -> void: + # Allow actual presentation after the Python statement reaches interaction. + for _frame in range(3): + await get_tree().process_frame + if finished or player == null: + return + var frame: Dictionary = player.read_frame_rgba() + var width := int(frame.get("width", 0)) + var height := int(frame.get("height", 0)) + var stride := int(frame.get("stride_bytes", 0)) + var rgba: PackedByteArray = frame.get("rgba", PackedByteArray()) + if width <= 0 or height <= 0 or stride < width * 4 or rgba.size() < stride * height: + _fail("Native provider did not supply a complete real RGBA frame.") + return + var packed_rgba := rgba + if stride != width * 4: + packed_rgba = PackedByteArray() + for y in range(height): + packed_rgba.append_array(rgba.slice(y * stride, y * stride + width * 4)) + var image := Image.create_from_data(width, height, false, Image.FORMAT_RGBA8, packed_rgba) + var path := evidence_dir.path_join("provider-%s.png" % stage) + if image.save_png(path) != OK: + _fail("Could not save native provider image.") + return + var box := rect.get_global_rect() + var scale := minf(box.size.x / width, box.size.y / height) + var drawn_size := Vector2(width, height) * scale + var origin := box.position + (box.size - drawn_size) * 0.5 + var viewport_size := get_viewport().get_visible_rect().size + _record({"kind": "provider_frame", "stage": stage, "source": "native-provider-rgba", + "path": path, "width": width, "height": height, "rgba_bytes": rgba.size(), + "native_stride_bytes": stride, "frame_serial": int(frame.get("frame_serial", 0)), + "drawn_box": [origin.x, origin.y, drawn_size.x, drawn_size.y], + "viewport_size": [viewport_size.x, viewport_size.y], "provider_debug": _provider_debug()}) + +func _provider_debug() -> Dictionary: + var raw := String(player.get_plugin_debug_info()) + var parsed = JSON.parse_string(raw) + return parsed if parsed is Dictionary else {"unparsed": raw} + +func _record(row: Dictionary) -> void: + row["run_id"] = run_id + row["host_ticks_msec"] = Time.get_ticks_msec() + print("renpy_device_observer %s" % JSON.stringify(row)) + if evidence_dir.is_empty(): + return + var path := evidence_dir.path_join("observer.jsonl") + var file := FileAccess.open(path, FileAccess.READ_WRITE if FileAccess.file_exists(path) else FileAccess.WRITE) + if file != null: + file.seek_end() + file.store_line(JSON.stringify(row)) + file.flush() + +func _finish(native_state: Dictionary) -> void: + finished = true + rect.texture = null + player.release_frame_texture() + player.destroy_engine() + _record({"kind": "normal_exit", "provider_debug": native_state, + "engine_destroyed": not player.is_initialized()}) + get_tree().quit(0) + +func _fail(message: String) -> void: + if finished: + return + finished = true + _record({"kind": "failure", "message": message}) + printerr("renpy_device_acceptance failed: %s" % message) + if player != null: + if rect != null: + rect.texture = null + player.release_frame_texture() + player.destroy_engine() + get_tree().quit(1) diff --git a/apps/godot_app/scripts/renpy_mobile_acceptance.gd.uid b/apps/godot_app/scripts/renpy_mobile_acceptance.gd.uid new file mode 100644 index 000000000..002e7b5c5 --- /dev/null +++ b/apps/godot_app/scripts/renpy_mobile_acceptance.gd.uid @@ -0,0 +1 @@ +uid://dntw1pfiolmsc diff --git a/bridge/renpy_runtime/CMakeLists.txt b/bridge/renpy_runtime/CMakeLists.txt index a01e22114..642127e2a 100644 --- a/bridge/renpy_runtime/CMakeLists.txt +++ b/bridge/renpy_runtime/CMakeLists.txt @@ -5,9 +5,35 @@ if(IOS OR ANDROID) # Mobile builds link the engine in-process. The desktop subprocess # provider cannot be compiled here: it assumes a writable game/libs # directory, fork/exec (or CreateProcess), and a desktop Ren'Py launcher. - # Keep a provider registration stub so selecting runtime=renpy fails with - # an explicit NOT_SUPPORTED result until the native RAPT/Renios bootstrap - # and lifecycle/rendering adapters are implemented. + # An enabled mobile build must carry the rebuilt lifecycle runtime and + # the matching cooperative Python resources. Archive staging alone does + # not satisfy this requirement. + if(NOT AETHERKIRI_RENPY_MOBILE_LIBRARY OR NOT AETHERKIRI_RENPY_PRIVATE_ROOT) + message(FATAL_ERROR "Mobile Ren'Py requires AETHERKIRI_RENPY_MOBILE_LIBRARY and AETHERKIRI_RENPY_PRIVATE_ROOT; official blocking RAPT/Renios archives are unsupported") + endif() + find_package(Python3 REQUIRED COMPONENTS Interpreter) + if(ANDROID) + set(_renpy_mobile_platform android) + else() + set(_renpy_mobile_platform ios) + endif() + set(_renpy_validation_arch arm64) + if(CMAKE_OSX_ARCHITECTURES) + list(GET CMAKE_OSX_ARCHITECTURES 0 _renpy_validation_arch) + endif() + execute_process(COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/../../tools/validate_renpy_mobile_payload.py" + --platform "${_renpy_mobile_platform}" + --arch "${_renpy_validation_arch}" + --library "${AETHERKIRI_RENPY_MOBILE_LIBRARY}" + --private-root "${AETHERKIRI_RENPY_PRIVATE_ROOT}" + RESULT_VARIABLE _renpy_validation_result + OUTPUT_VARIABLE _renpy_validation_output + ERROR_VARIABLE _renpy_validation_error) + if(NOT _renpy_validation_result EQUAL 0) + message(FATAL_ERROR "${_renpy_validation_error}") + endif() + message(STATUS "${_renpy_validation_output}") set(AETHERKIRI_RENPY_SOURCES src/renpy_runtime_mobile_provider.cpp src/renpy_runtime_ios_adapter.cpp @@ -38,12 +64,8 @@ target_compile_features(aether_renpy_runtime PUBLIC cxx_std_17) set_target_properties(aether_renpy_runtime PROPERTIES POSITION_INDEPENDENT_CODE ON) if(IOS) target_compile_definitions(aether_renpy_runtime PRIVATE - AETHERKIRI_RENPY_MOBILE_STUB=1 AETHERKIRI_RENPY_IOS=1) -elseif(ANDROID) - target_compile_definitions(aether_renpy_runtime PRIVATE - AETHERKIRI_RENPY_MOBILE_STUB=1) -else() +elseif(NOT ANDROID) target_compile_definitions(aether_renpy_runtime PRIVATE AETHERKIRI_RENPY_SDK_ROOT="${AETHERKIRI_RENPY_SDK_ROOT}" AETHERKIRI_RENPY_OVERLAY_SOURCE="${CMAKE_CURRENT_SOURCE_DIR}/python/aether_renpy_overlay.py") @@ -59,6 +81,11 @@ target_link_libraries(aether_renpy_runtime PRIVATE engine_api) if(UNIX AND NOT (IOS OR ANDROID)) target_link_libraries(aether_renpy_runtime PRIVATE pthread) endif() +if(ANDROID) + target_link_libraries(aether_renpy_runtime PRIVATE ${CMAKE_DL_LIBS}) +elseif(IOS) + target_link_libraries(aether_renpy_runtime PRIVATE "-framework CoreFoundation") +endif() if(ENABLE_TESTS AND NOT (IOS OR ANDROID)) enable_testing() diff --git a/bridge/renpy_runtime/README.md b/bridge/renpy_runtime/README.md index 115981be4..382c4448a 100644 --- a/bridge/renpy_runtime/README.md +++ b/bridge/renpy_runtime/README.md @@ -55,125 +55,78 @@ and checks that the Ren'Py menu selection reaches script code. It uses the SDK's `gl2` renderer by default; set `RENPY_RENDERER=sw` to exercise the software renderer instead. -The overlay is desktop-only. On Android and iOS, `AETHERKIRI_ENABLE_RENPY` -builds a provider registration stub that reports an explicit -`ENGINE_RESULT_NOT_SUPPORTED` from game open and all runtime operations; this -prevents a staged archive from being mistaken for playable mobile support. -The provider's desktop integration remains guarded by -`AETHERKIRI_ENABLE_RENPY` and requires a writable `game/libs` directory to -stage the hook. - -## Cross-platform acceptance matrix - -The runtime is only considered playable on a platform after the corresponding -row passes its real runtime checks. Building or staging an official SDK alone -does not change the mobile provider guard. - -| Platform | Current CI evidence | Required runtime gate | -| --- | --- | --- | -| Linux x86_64 | tools/test_install_renpy_sdk.sh plus tools/run_renpy_smoke.sh in the desktop matrix | CMake provider tests, Godot frame/input E2E, and clean process shutdown | -| Windows x64 | Pinned renpy-8.5.3-sdk.zip metadata/install plus fixture smoke in the desktop matrix | Native renpy.exe provider E2E (CreateProcessW, frame/input, shutdown); the POSIX fake-launcher mapping test is intentionally skipped | -| Android arm64 | APK builds, RAPT staging, ABI guard, and lifecycle contract/export fixtures | A rebuilt RAPT payload exporting all seven host lifecycle symbols, then arm64 emulator and device E2E for Activity ownership, RGBA frames, touch/text input, pause/resume, and reopen | -| iOS arm64 simulator/device | IPA/Xcode builds, Renios staging, ABI guard, and lifecycle contract/export fixtures | A rebuilt Renios payload with host lifecycle exports, simulator and device E2E for single UIApplication/SDL ownership, RGBA frames, input, pause/resume, and reopen | - -Desktop smoke commands: - - bash tools/test_install_renpy_sdk.sh - bash tools/install_renpy_sdk.sh --platform linux --arch x86_64 --destination /tmp/renpy-sdk - RENPY_SDK=/tmp/renpy-sdk bash tools/run_renpy_smoke.sh - -Optional native mobile source preflight/build: - - bash tools/run_renpy_mobile_source_build.sh --mode auto - bash tools/run_renpy_mobile_source_build.sh --mode check --renpy-build /path/to/renpy-build - AETHERKIRI_RENPY_SOURCE_BUILD_MODE=build bash tools/run_renpy_mobile_source_build.sh --mode build --renpy-build /path/to/renpy-build - -With no official renpy-build checkout, auto mode reports a clear skip. The -source runner never treats the official blocking SDL_main/Py_RunMain launcher -as host lifecycle support, and it does not claim device or simulator -playability before those E2E gates pass. - -## Mobile dependency staging - -The official mobile support packages are staged separately with -`tools/install_renpy_mobile_support.sh --platform android|ios|both`. The -script verifies the Ren'Py 8.5.3 RAPT and Renios archives before extracting -them and records the verified checksums beside each staged package. These -inputs are not a mobile runtime: Android still needs the -`PythonSDLActivity`/JNI bootstrap, iOS still needs an in-process Xcode -adapter, and both platforms still need lifecycle, input, and Godot rendering -integration. - -When `AETHERKIRI_ENABLE_RENPY=ON` and -`AETHERKIRI_RENPY_MOBILE_ROOT` points at that staged root, `scripts/build_ios.sh` -folds the selected Renios `prebuilt/{debug,release}` static-library closure -into the Godot iOS extension archive and bundles the official Renios resources -plus `MetalANGLE.xcframework` under `Aether/renios` and `Aether/Frameworks`. -If a generated Renios game `base/` directory is available, set -`AETHERKIRI_RENPY_RENIOS_BASE` to bundle it alongside those resources. -The prototype's entrypoint source and `libSDL2main.a` are intentionally omitted; -Godot remains the sole UIKit/SDL application host. The generated -`renios/renios-manifest.txt` records the exact closure. This is a link/bundle -smoke only: the provider continues to return `ENGINE_RESULT_NOT_SUPPORTED` -until the host-owned lifecycle, surface rendering, and input adapter are -implemented and validated on a device or simulator. - -### iOS in-process adapter boundary - -The staged Renios prototype is an application template whose `main.c` calls -`SDL_UIKitRunApp(..., launcher_main)`. AetherKiri already owns the Godot -application and run loop, so the mobile provider does not copy that entrypoint -or call `UIApplicationMain`. `renpy_runtime_ios_adapter.h` defines the small -host-owned adapter registration boundary that a future iOS integration can -install after linking the complete Renios static-library closure. The same -header exposes `renpy_get_ios_launcher_contract()`: the build probe confirms -that the shipped `launcher_main` reaches blocking `Py_RunMain`, so it cannot be -called from a Godot frame callback. Run -`tools/test_renios_ios_launcher.sh ` to repeat -that archive check. Until a split init/tick/shutdown launcher and its -lifecycle, surface, and input bridge are linked, `runtime=renpy` continues to -return `ENGINE_RESULT_NOT_SUPPORTED` with an explicit diagnostic. - -### Android in-process adapter boundary - -The staged RAPT prototype is a `PythonSDLActivity` that loads -`librenpython.so`, prepares Android storage/assets, and owns an SDL surface. -`scripts/build_android.sh` now stages the arm64 library, private/assets -directory, and the official Java/resource templates under -`assets/renpy_mobile/rapt` in the existing Godot export. The RAPT manifest and -`PythonSDLActivity` remain assets and are never merged into the host manifest, -so the export still has one Activity. A small host-owned -`org.github.krkr2.aetherkiri.RenPyMobileBridge` Java shim binds an already -running Godot Activity to the engine JNI bridge; it does not launch RAPT or -load its SDL loop. Set `AETHERKIRI_RENPY_ANDROID_PRIVATE_ASSETS` when a built -Ren'Py game's private payload is available. - -The export also compiles two host-owned, non-Activity signature shims at -`org.libsdl.app.SDLActivity` and `org.renpy.android.PythonSDLActivity`. They -only bind the existing Activity and declare the callbacks that preflight -checks; the complete RAPT Activity remains under `assets/renpy_mobile/rapt`. -If a Godot template already owns either class, staging fails closed instead of -overwriting a possible SDL singleton. - -The SDL shim's `getNativeSurface()` and `getContext()` callbacks return the -same Java `Surface` and Application Context already held by `EngineBridge`; no -new View or Surface is allocated. This is safe for inspection and preflight, -but the official `librenpython.so` still has no host-tick or -`SDL_AndroidSetActivity`/`SDL_AndroidSetSurface` API. Enabling gameplay next -requires rebuilding the RAPT native payload around an explicit -`init(context, surface)`, `tick`, `pause/resume`, input, and `shutdown` -interface rather than calling its blocking `SDL_main` entrypoint. - -The Android provider also has a compile-tested `BootstrapAdapter` boundary that -accepts an opaque pointer to the existing host Activity through the host -extension slot `reserved_ptr[1]` or the JNI shim. It never creates a second -Activity. Because RAPT embeds its own SDL/Python runtime and lifecycle, -asset/JNI staging alone is not a playable integration: until SDL surface, -lifecycle, input, and Godot rendering handoff are complete, `Start` and -provider open return `ENGINE_RESULT_NOT_SUPPORTED` with an explicit diagnostic. -Before attempting that handoff, the Android `BootstrapAdapter::Preflight` -loader checks the bound host Activity, required `SDLActivity` and -`PythonSDLActivity` JNI methods, a live host `ANativeWindow`, and the exported -`librenpython.so` entrypoints (`SDL_main`, SDL Android accessors, and RAPT JNI -callbacks). Missing pieces produce a stable diagnostic and never call -`SDL_main` or create another Activity. +The overlay is desktop-only. Mobile builds use the in-process lifecycle fork, +with its own packaged Python home and stack-preserving cooperative loop. + +## Mobile build and runtime status + +Mobile gameplay is **unverified**. There is no accepted APK/device or +IPA/simulator gameplay result for this change. Exported symbols, host fixture +tests, and a successful application build do not establish mobile support. + +The source runner pins a coherent, independently rebuilt 8.5.3 fork: + +- renpy-build: `7bfab40c1174f622f644b24669afd5fb167fbb79` +- Ren'Py: `39895c1e017f0b36ffea2447d97eccd69d76ee1c` +- greenlet: `65f8da82b13a1273e55a6bfcbd1f9da09fc4eb7a` + +It applies both the Python and native patches before compiling the actual +runtime. Greenlet preserves the script and interaction stacks on the host +thread. Game initialization starts in the first tick, after asynchronous game +open has returned. Frames come from the real renderer screenshot, copied from +its Surface with row pitch respected. Input retains press, release, motion, +scroll and committed UTF-8 text semantics. Pause, resume and natural exit are +observable through the provider. + +Android compilation requires a supported Ubuntu 24.04 cloud builder, LLVM 18, +NDK r29 and at least 64 GiB of free disk. The fork uses SDL2's offscreen +EGL/pbuffer path to avoid taking Godot's display Surface. This path still +requires real Android graphics, input and lifecycle acceptance. + +The iOS native archive must be rebuilt with legally supplied Apple SDKs. Its +stock SDL UIKit/MetalANGLE window path is not a verified Godot rendering +integration; this remains an implementation and simulator/device validation +blocker. Device builds also require the appropriate signing credentials; +simulator verification does not require distribution signing. + +Enabling Ren'Py in a mobile application build now requires the rebuilt native +library and matching cooperative Python/standard-library resources. CMake and +packaging reject an official blocking RAPT/Renios archive, missing resources, +incompatible native targets and missing lifecycle exports. iOS native linking +is enabled by default. No second launcher entrypoint is copied into Godot. + +## Reproducible cloud commands + +Prepare pinned sources without installing system packages or licensed SDKs: + +```bash +bash tools/run_renpy_mobile_source_build.sh --mode prepare --fetch \ + --platform android --renpy-build /workspace/renpy-build +``` + +Run the actual source build on a provisioned cloud builder: + +```bash +bash tools/run_renpy_mobile_source_build.sh --mode build \ + --platform android --renpy-build /workspace/renpy-build +``` + +The output is `out/renpy-mobile-source/payload` plus a provenance manifest. +Android private assets are in `payload/rapt/runtime/private`. Build the debug +app with that payload, then run the real device gate described in +[`demos/aetherkiri-renpy/README.md`](../../demos/aetherkiri-renpy/README.md). + +The Build workflow requires the source-build job's output before packaging +Android. It preserves a debug APK for installation. For iOS, the workflow +requires `AETHER_RENPY_IOS_RUNTIME_RUN_ID` and +`AETHER_RENPY_IOS_RUNTIME_ARTIFACT` repository variables identifying a rebuilt +payload artifact named `renpy-ios-runtime.tar.gz` with `payload/renios` inside. +A missing input is a build failure with a diagnostic, rather than a successful +package containing only a mobile guard. + +The Mobile Contract workflow validates contracts and compilation, with no +playability claim. Desktop Acceptance runs the SDK fixtures; its Linux job +also exercises a real rendered frame, input and quit. The device gate requires +actual APK installation, executed Ren'Py script checkpoints, native RGBA +pixels matching an OS screenshot, touch, text/keyboard, background/resume, +and normal game exit while the application process stays alive. diff --git a/bridge/renpy_runtime/android/RenPyMobileBridge.java b/bridge/renpy_runtime/android/RenPyMobileBridge.java index 6cb155835..b7a202b4e 100644 --- a/bridge/renpy_runtime/android/RenPyMobileBridge.java +++ b/bridge/renpy_runtime/android/RenPyMobileBridge.java @@ -2,18 +2,21 @@ import android.app.Activity; import android.content.Context; +import android.content.res.AssetManager; import org.libsdl.app.SDLActivity; import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; /** * Host-owned handoff point for the staged Ren'Py Android support package. * - * The Godot Activity remains the only Activity. A future lifecycle adapter may - * call bindHostActivity(this) from that Activity and then drive Ren'Py through - * the existing surface. This class intentionally does not reference or launch - * org.renpy.android.PythonSDLActivity. + * The Godot Activity remains the only Activity. The provider binds that host, + * extracts the private Python runtime and initializes SDL's JNI callbacks. + * The native fork renders into an offscreen EGL pbuffer. */ public final class RenPyMobileBridge { private static boolean nativeBridgeLoaded; @@ -49,9 +52,8 @@ public static boolean isNativeBridgeLoaded() { } /** - * Package smoke helper. It only checks that the staged arm64 payload is in - * the APK's native library directory; loading it is deliberately deferred - * until SDL/lifecycle/surface isolation is implemented. + * Package inspection helper. It checks the staged arm64 payload exists; + * it does not verify lifecycle exports or gameplay. */ public static boolean hasBundledRenPy(Context context) { if (context == null || context.getApplicationInfo() == null) { @@ -61,6 +63,60 @@ public static boolean hasBundledRenPy(Context context) { return nativeDir != null && new File(nativeDir, "librenpython.so").isFile(); } + /** Extract the bundled Python tree, separate from imported game files. */ + public static synchronized String preparePrivateRoot() throws IOException { + Context context = SDLActivity.getContext(); + if (context == null) throw new IOException("Ren'Py host Context is unavailable"); + long version; + try { version = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).lastUpdateTime; } + catch (android.content.pm.PackageManager.NameNotFoundException e) { throw new IOException(e); } + File root = new File(context.getFilesDir(), "renpy_mobile/" + version); + File ready = new File(root, ".ready"); + if (!ready.isFile()) { + copyAssets(context.getAssets(), "renpy_mobile/private", root, root.getCanonicalPath()); + if (!new File(root, "main.py").isFile() || !new File(root, "renpy/aether_mobile.py").isFile()) { + throw new IOException("Ren'Py APK lacks the patched Python private payload (main.py and renpy/aether_mobile.py)"); + } + if (!ready.createNewFile() && !ready.isFile()) throw new IOException("could not mark Ren'Py payload extraction complete"); + } + return root.getCanonicalPath(); + } + + private static void copyAssets(AssetManager assets, String source, File target, String root) throws IOException { + if (!target.getCanonicalPath().startsWith(root + File.separator) && !target.getCanonicalPath().equals(root)) { + throw new IOException("Ren'Py asset escapes its extraction directory"); + } + String[] children = assets.list(source); + if (children != null && children.length > 0) { + if (!target.isDirectory() && !target.mkdirs()) throw new IOException("could not create " + target); + for (String child : children) copyAssets(assets, source + "/" + child, new File(target, child), root); + } else { + File parent = target.getParentFile(); + if (!parent.isDirectory() && !parent.mkdirs()) throw new IOException("could not create " + parent); + try (InputStream input = assets.open(source); FileOutputStream output = new FileOutputStream(target)) { + byte[] buffer = new byte[65536]; + int count; + while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count); + output.getFD().sync(); + } + } + } + + public static String getApkPath() { + Context context = SDLActivity.getContext(); + return context == null ? "" : context.getApplicationInfo().sourceDir; + } + + public static void prepareRuntime(int width, int height) { + SDLActivity.prepareRuntime(width, height); + } + + public static void stopRuntime() { + // cooperative_stop destroys the offscreen window first. Clear the + // IME request while preserving the host Activity and JNI callback tables. + SDLActivity.releaseRenderTarget(); + } + private static void ensureNativeBridge() { if (!nativeBridgeLoaded && !tryLoadNativeBridge()) { throw new IllegalStateException("AetherKiri engine_api JNI bridge is unavailable"); diff --git a/bridge/renpy_runtime/android/org/libsdl/LICENSE.txt b/bridge/renpy_runtime/android/org/libsdl/LICENSE.txt new file mode 100644 index 000000000..3931238c9 --- /dev/null +++ b/bridge/renpy_runtime/android/org/libsdl/LICENSE.txt @@ -0,0 +1,17 @@ +Copyright (C) 1997-2022 Sam Lantinga + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. diff --git a/bridge/renpy_runtime/android/org/libsdl/app/SDL.java b/bridge/renpy_runtime/android/org/libsdl/app/SDL.java new file mode 100644 index 000000000..3ee1c2d6e --- /dev/null +++ b/bridge/renpy_runtime/android/org/libsdl/app/SDL.java @@ -0,0 +1,86 @@ +// SDL 2.0.20, zlib license: ../LICENSE.txt. +// AetherKiri modification: host Activity owns initialization. +package org.libsdl.app; + +import android.content.Context; + +import java.lang.Class; +import java.lang.reflect.Method; + +/** + SDL library initialization +*/ +public class SDL { + + // This function should be called first and sets up the native code + // so it can call into the Java classes + public static void setupJNI() { + SDLActivity.nativeSetupJNI(); + SDLAudioManager.nativeSetupJNI(); + SDLControllerManager.nativeSetupJNI(); + } + + // This function should be called each time the activity is started + public static void initialize() { + setContext(null); + + SDLAudioManager.initialize(); + SDLControllerManager.initialize(); + } + + // This function stores the current activity (SDL or not) + public static void setContext(Context context) { + mContext = context; + } + + public static Context getContext() { + return mContext; + } + + public static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException { + + if (libraryName == null) { + throw new NullPointerException("No library name provided."); + } + + try { + // Let's see if we have ReLinker available in the project. This is necessary for + // some projects that have huge numbers of local libraries bundled, and thus may + // trip a bug in Android's native library loader which ReLinker works around. (If + // loadLibrary works properly, ReLinker will simply use the normal Android method + // internally.) + // + // To use ReLinker, just add it as a dependency. For more information, see + // https://github.com/KeepSafe/ReLinker for ReLinker's repository. + // + Class relinkClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker"); + Class relinkListenerClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener"); + Class contextClass = mContext.getClassLoader().loadClass("android.content.Context"); + Class stringClass = mContext.getClassLoader().loadClass("java.lang.String"); + + // Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if + // they've changed during updates. + Method forceMethod = relinkClass.getDeclaredMethod("force"); + Object relinkInstance = forceMethod.invoke(null); + Class relinkInstanceClass = relinkInstance.getClass(); + + // Actually load the library! + Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass); + loadMethod.invoke(relinkInstance, mContext, libraryName, null, null); + } + catch (final Throwable e) { + // Fall back + try { + System.loadLibrary(libraryName); + } + catch (final UnsatisfiedLinkError ule) { + throw ule; + } + catch (final SecurityException se) { + throw se; + } + } + } + + protected static Context mContext; +} diff --git a/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java b/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java index 81d356c6c..045db4898 100644 --- a/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java +++ b/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java @@ -1,49 +1,208 @@ package org.libsdl.app; import android.app.Activity; +import android.app.UiModeManager; +import android.content.ClipData; +import android.content.ClipboardManager; import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.DisplayMetrics; +import android.util.SparseArray; +import android.view.InputDevice; +import android.view.PointerIcon; import android.view.Surface; +import android.view.View; +import android.widget.Toast; + import java.lang.ref.WeakReference; /** - * AetherKiri's host-owned RAPT callback shim. - * - * This is deliberately not an Activity and is never declared in the Android - * manifest. The official SDLActivity source remains available under - * assets/renpy_mobile/rapt for inspection, while this class only supplies the - * JNI callback signatures that librenpython resolves during preflight. A - * future lifecycle bridge may implement these calls against the existing - * Godot Activity and SurfaceView; this shim must not launch a second Activity. + * SDL 2.0.20 JNI callbacks delegated to Godot's existing Activity. + * The patched native payload uses SDL's offscreen EGL pbuffer driver. + * These callbacks never acquire Godot's SurfaceView or start an Activity. + * CPU frames come from Ren'Py's GL screenshot. */ public final class SDLActivity { public static final String AETHERKIRI_HOST_SHIM = "renpy-sdl-host-shim-v1"; - private static WeakReference hostActivity = new WeakReference<>(null); + private static final Handler uiHandler = new Handler(Looper.getMainLooper()); + private static final SparseArray cursors = new SparseArray<>(); + public static volatile boolean mHasFocus; + private static boolean initialized; + private static volatile boolean textInputActive; + private static int nextCursor = 1; - private SDLActivity() { - } + private SDLActivity() {} public static void bindHostActivity(Activity activity) { hostActivity = new WeakReference<>(activity); + SDL.setContext(activity); + mHasFocus = true; } public static void clearHostActivity() { + releaseRenderTarget(); + mHasFocus = false; + textInputActive = false; hostActivity = new WeakReference<>(null); + SDL.setContext(null); } - public static Activity getHostActivity() { - return hostActivity.get(); + public static Activity getHostActivity() { return hostActivity.get(); } + public static Context getContext() { return SDL.getContext(); } + public static View getContentView() { + Activity activity = getHostActivity(); + return activity == null ? null : activity.getWindow().getDecorView(); } - /** Returns the Surface already bound by EngineBridge, never a new view. */ - public static native Surface getNativeSurface(); + /** Must run before Python/pygame initializes SDL video/audio. */ + public static synchronized void prepareRuntime(int width, int height) { + if (getHostActivity() == null) throw new IllegalStateException("Ren'Py host Activity is unavailable"); + if (width <= 0 || height <= 0) throw new IllegalArgumentException("invalid Ren'Py render size"); + if (!initialized) { + // dlopen does not invoke JNI_OnLoad. Java loading is required to + // bind SDL's JavaVM, then all three callback tables make SDL ready. + System.loadLibrary("renpython"); + SDLAudioManager.initialize(); + SDLControllerManager.initialize(); + SDL.setupJNI(); + initialized = true; + } + mHasFocus = true; + nativeFocusChanged(true); + } - /** Returns the existing Application Context held by EngineBridge. */ - public static native Context getContext(); + public static Surface getNativeSurface() { + // Fail closed if an unpatched payload accidentally selects the + // Android window driver: the host surface must never be borrowed. + return null; + } + public static synchronized void releaseRenderTarget() { + textInputActive = false; + } + public static DisplayMetrics getDisplayDPI() { return getContext().getResources().getDisplayMetrics(); } + public static boolean isAndroidTV() { + UiModeManager manager = (UiModeManager) getContext().getSystemService(Context.UI_MODE_SERVICE); + return manager != null && manager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION; + } + public static boolean isTablet() { return getContext().getResources().getConfiguration().smallestScreenWidthDp >= 600; } + public static boolean isChromebook() { return getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); } + public static boolean isDeXMode() { return false; } + public static boolean isScreenKeyboardShown() { return textInputActive; } + public static boolean shouldMinimizeOnFocusLoss() { return false; } + public static boolean supportsRelativeMouse() { return false; } + public static boolean setRelativeMouseEnabled(boolean enabled) { return !enabled; } + public static void setWindowStyle(boolean fullscreen) { /* Godot owns visible window styling. */ } + public static void setOrientation(int width, int height, boolean resizable, String hint) { /* Godot owns orientation. */ } + public static boolean setActivityTitle(String title) { + Activity activity = getHostActivity(); + if (activity == null) return false; + uiHandler.post(() -> activity.setTitle(title)); + return true; + } + public static boolean sendMessage(int command, int param) { + // SDL's text-input hide command. Godot polls the provider's active + // state and drives its own IME; no second EditText steals focus. + if (command == 3) { textInputActive = false; return true; } + return false; + } + public static boolean showTextInput(int x, int y, int width, int height) { + textInputActive = true; + return getHostActivity() != null; + } + public static void manualBackButton() { /* Ren'Py BACK is delivered by engine_send_input. */ } + public static void minimizeWindow() { + Activity activity = getHostActivity(); + if (activity != null) uiHandler.post(() -> activity.moveTaskToBack(true)); + } + public static int openURL(String url) { + Activity activity = getHostActivity(); + if (activity == null) return -1; + uiHandler.post(() -> activity.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)))); + return 0; + } + public static void requestPermission(String permission, int requestCode) { + Activity activity = getHostActivity(); + if (activity == null) { nativePermissionResult(requestCode, false); return; } + if (Build.VERSION.SDK_INT < 23 || activity.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) { + nativePermissionResult(requestCode, true); + } else { + // The host owns permission request/results; do not create an SDL + // Activity or claim a permission was granted. + nativePermissionResult(requestCode, false); + } + } + public static int showToast(String message, int duration, int gravity, int xOffset, int yOffset) { + Context context = getContext(); + if (context == null) return -1; + uiHandler.post(() -> { + Toast toast = Toast.makeText(context, message, duration); + toast.setGravity(gravity, xOffset, yOffset); + toast.show(); + }); + return 0; + } + public static boolean getManifestEnvironmentVariables() { + try { + ApplicationInfo info = getContext().getPackageManager().getApplicationInfo( + getContext().getPackageName(), PackageManager.GET_META_DATA); + if (info.metaData != null) for (String key : info.metaData.keySet()) { + if (key.startsWith("SDL_ENV.")) nativeSetenv(key.substring(8), String.valueOf(info.metaData.get(key))); + } + return true; + } catch (PackageManager.NameNotFoundException e) { return false; } + } + public static void initTouch() { + for (int id : InputDevice.getDeviceIds()) { + InputDevice device = InputDevice.getDevice(id); + if (device != null && (device.getSources() & InputDevice.SOURCE_TOUCHSCREEN) != 0) nativeAddTouch(id, device.getName()); + } + } + private static ClipboardManager clipboard() { + return (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); + } + public static boolean clipboardHasText() { return clipboard().hasPrimaryClip(); } + public static String clipboardGetText() { + ClipData data = clipboard().getPrimaryClip(); + return data == null || data.getItemCount() == 0 ? "" : data.getItemAt(0).coerceToText(getContext()).toString(); + } + public static void clipboardSetText(String text) { clipboard().setPrimaryClip(ClipData.newPlainText("Ren'Py", text)); } + public static int createCustomCursor(int[] colors, int width, int height, int hotSpotX, int hotSpotY) { + if (Build.VERSION.SDK_INT < 24) return 0; + PointerIcon icon = PointerIcon.create(Bitmap.createBitmap(colors, width, height, Bitmap.Config.ARGB_8888), hotSpotX, hotSpotY); + synchronized (cursors) { int id = nextCursor++; cursors.put(id, icon); return id; } + } + public static void destroyCustomCursor(int id) { synchronized (cursors) { cursors.remove(id); } } + public static boolean setCustomCursor(int id) { + if (Build.VERSION.SDK_INT < 24 || getContentView() == null) return false; + PointerIcon icon; + synchronized (cursors) { icon = cursors.get(id); } + if (icon == null) return false; + uiHandler.post(() -> { if (getContentView() != null) getContentView().setPointerIcon(icon); }); + return true; + } + public static boolean setSystemCursor(int id) { + if (Build.VERSION.SDK_INT < 24 || getContentView() == null) return false; + int[] types = { PointerIcon.TYPE_ARROW, PointerIcon.TYPE_TEXT, PointerIcon.TYPE_WAIT, + PointerIcon.TYPE_CROSSHAIR, PointerIcon.TYPE_WAIT, PointerIcon.TYPE_TOP_LEFT_DIAGONAL_DOUBLE_ARROW, + PointerIcon.TYPE_TOP_RIGHT_DIAGONAL_DOUBLE_ARROW, PointerIcon.TYPE_HORIZONTAL_DOUBLE_ARROW, + PointerIcon.TYPE_VERTICAL_DOUBLE_ARROW, PointerIcon.TYPE_ALL_SCROLL, PointerIcon.TYPE_NO_DROP, PointerIcon.TYPE_HAND }; + if (id < 0 || id >= types.length) return false; + PointerIcon icon = PointerIcon.getSystemIcon(getContext(), types[id]); + uiHandler.post(() -> { if (getContentView() != null) getContentView().setPointerIcon(icon); }); + return true; + } public static native int nativeSetupJNI(); - public static native int nativeRunMain(String library, String function, - Object arguments); + public static native int nativeRunMain(String library, String function, Object arguments); public static native void nativeLowMemory(); public static native void nativeSendQuit(); public static native void nativeQuit(); @@ -51,39 +210,24 @@ public static native int nativeRunMain(String library, String function, public static native void nativeResume(); public static native void nativeFocusChanged(boolean hasFocus); public static native void onNativeDropFile(String filename); - public static native void nativeSetScreenResolution(int surfaceWidth, - int surfaceHeight, - int deviceWidth, - int deviceHeight, - float rate); + public static native void nativeSetScreenResolution(int surfaceWidth, int surfaceHeight, int deviceWidth, int deviceHeight, float rate); public static native void onNativeResize(); public static native void onNativeKeyDown(int keycode); public static native void onNativeKeyUp(int keycode); public static native boolean onNativeSoftReturnKey(); public static native void onNativeKeyboardFocusLost(); public static native void nativeSetenv(String name, String value); - public static native void onNativeTouch(int touchDevId, - int pointerFingerId, - int action, - float x, - float y, - float pressure); - public static native void onNativeMouse(int button, - int action, - float x, - float y, - boolean relative); + public static native void onNativeTouch(int touchDevId, int pointerFingerId, int action, float x, float y, float pressure); + public static native void onNativeMouse(int button, int action, float x, float y, boolean relative); public static native void onNativeAccel(float x, float y, float z); public static native void onNativeClipboardChanged(); public static native void onNativeSurfaceCreated(); public static native void onNativeSurfaceChanged(); public static native void onNativeSurfaceDestroyed(); public static native String nativeGetHint(String name); - public static native boolean nativeGetHintBoolean(String name, - boolean defaultValue); + public static native boolean nativeGetHintBoolean(String name, boolean defaultValue); public static native void onNativeOrientationChanged(int orientation); public static native void nativeAddTouch(int touchId, String name); - public static native void nativePermissionResult(int requestCode, - boolean result); + public static native void nativePermissionResult(int requestCode, boolean result); public static native void onNativeLocaleChanged(); } diff --git a/bridge/renpy_runtime/android/org/libsdl/app/SDLAudioManager.java b/bridge/renpy_runtime/android/org/libsdl/app/SDLAudioManager.java new file mode 100644 index 000000000..a5dfe2665 --- /dev/null +++ b/bridge/renpy_runtime/android/org/libsdl/app/SDLAudioManager.java @@ -0,0 +1,395 @@ +// Unmodified SDL 2.0.20 helper, zlib license: ../LICENSE.txt. +package org.libsdl.app; + +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioRecord; +import android.media.AudioTrack; +import android.media.MediaRecorder; +import android.os.Build; +import android.util.Log; + +public class SDLAudioManager +{ + protected static final String TAG = "SDLAudio"; + + protected static AudioTrack mAudioTrack; + protected static AudioRecord mAudioRecord; + + public static void initialize() { + mAudioTrack = null; + mAudioRecord = null; + } + + // Audio + + protected static String getAudioFormatString(int audioFormat) { + switch (audioFormat) { + case AudioFormat.ENCODING_PCM_8BIT: + return "8-bit"; + case AudioFormat.ENCODING_PCM_16BIT: + return "16-bit"; + case AudioFormat.ENCODING_PCM_FLOAT: + return "float"; + default: + return Integer.toString(audioFormat); + } + } + + protected static int[] open(boolean isCapture, int sampleRate, int audioFormat, int desiredChannels, int desiredFrames) { + int channelConfig; + int sampleSize; + int frameSize; + + Log.v(TAG, "Opening " + (isCapture ? "capture" : "playback") + ", requested " + desiredFrames + " frames of " + desiredChannels + " channel " + getAudioFormatString(audioFormat) + " audio at " + sampleRate + " Hz"); + + /* On older devices let's use known good settings */ + if (Build.VERSION.SDK_INT < 21) { + if (desiredChannels > 2) { + desiredChannels = 2; + } + } + + /* AudioTrack has sample rate limitation of 48000 (fixed in 5.0.2) */ + if (Build.VERSION.SDK_INT < 22) { + if (sampleRate < 8000) { + sampleRate = 8000; + } else if (sampleRate > 48000) { + sampleRate = 48000; + } + } + + if (audioFormat == AudioFormat.ENCODING_PCM_FLOAT) { + int minSDKVersion = (isCapture ? 23 : 21); + if (Build.VERSION.SDK_INT < minSDKVersion) { + audioFormat = AudioFormat.ENCODING_PCM_16BIT; + } + } + switch (audioFormat) + { + case AudioFormat.ENCODING_PCM_8BIT: + sampleSize = 1; + break; + case AudioFormat.ENCODING_PCM_16BIT: + sampleSize = 2; + break; + case AudioFormat.ENCODING_PCM_FLOAT: + sampleSize = 4; + break; + default: + Log.v(TAG, "Requested format " + audioFormat + ", getting ENCODING_PCM_16BIT"); + audioFormat = AudioFormat.ENCODING_PCM_16BIT; + sampleSize = 2; + break; + } + + if (isCapture) { + switch (desiredChannels) { + case 1: + channelConfig = AudioFormat.CHANNEL_IN_MONO; + break; + case 2: + channelConfig = AudioFormat.CHANNEL_IN_STEREO; + break; + default: + Log.v(TAG, "Requested " + desiredChannels + " channels, getting stereo"); + desiredChannels = 2; + channelConfig = AudioFormat.CHANNEL_IN_STEREO; + break; + } + } else { + switch (desiredChannels) { + case 1: + channelConfig = AudioFormat.CHANNEL_OUT_MONO; + break; + case 2: + channelConfig = AudioFormat.CHANNEL_OUT_STEREO; + break; + case 3: + channelConfig = AudioFormat.CHANNEL_OUT_STEREO | AudioFormat.CHANNEL_OUT_FRONT_CENTER; + break; + case 4: + channelConfig = AudioFormat.CHANNEL_OUT_QUAD; + break; + case 5: + channelConfig = AudioFormat.CHANNEL_OUT_QUAD | AudioFormat.CHANNEL_OUT_FRONT_CENTER; + break; + case 6: + channelConfig = AudioFormat.CHANNEL_OUT_5POINT1; + break; + case 7: + channelConfig = AudioFormat.CHANNEL_OUT_5POINT1 | AudioFormat.CHANNEL_OUT_BACK_CENTER; + break; + case 8: + if (Build.VERSION.SDK_INT >= 23) { + channelConfig = AudioFormat.CHANNEL_OUT_7POINT1_SURROUND; + } else { + Log.v(TAG, "Requested " + desiredChannels + " channels, getting 5.1 surround"); + desiredChannels = 6; + channelConfig = AudioFormat.CHANNEL_OUT_5POINT1; + } + break; + default: + Log.v(TAG, "Requested " + desiredChannels + " channels, getting stereo"); + desiredChannels = 2; + channelConfig = AudioFormat.CHANNEL_OUT_STEREO; + break; + } + +/* + Log.v(TAG, "Speaker configuration (and order of channels):"); + + if ((channelConfig & 0x00000004) != 0) { + Log.v(TAG, " CHANNEL_OUT_FRONT_LEFT"); + } + if ((channelConfig & 0x00000008) != 0) { + Log.v(TAG, " CHANNEL_OUT_FRONT_RIGHT"); + } + if ((channelConfig & 0x00000010) != 0) { + Log.v(TAG, " CHANNEL_OUT_FRONT_CENTER"); + } + if ((channelConfig & 0x00000020) != 0) { + Log.v(TAG, " CHANNEL_OUT_LOW_FREQUENCY"); + } + if ((channelConfig & 0x00000040) != 0) { + Log.v(TAG, " CHANNEL_OUT_BACK_LEFT"); + } + if ((channelConfig & 0x00000080) != 0) { + Log.v(TAG, " CHANNEL_OUT_BACK_RIGHT"); + } + if ((channelConfig & 0x00000100) != 0) { + Log.v(TAG, " CHANNEL_OUT_FRONT_LEFT_OF_CENTER"); + } + if ((channelConfig & 0x00000200) != 0) { + Log.v(TAG, " CHANNEL_OUT_FRONT_RIGHT_OF_CENTER"); + } + if ((channelConfig & 0x00000400) != 0) { + Log.v(TAG, " CHANNEL_OUT_BACK_CENTER"); + } + if ((channelConfig & 0x00000800) != 0) { + Log.v(TAG, " CHANNEL_OUT_SIDE_LEFT"); + } + if ((channelConfig & 0x00001000) != 0) { + Log.v(TAG, " CHANNEL_OUT_SIDE_RIGHT"); + } +*/ + } + frameSize = (sampleSize * desiredChannels); + + // Let the user pick a larger buffer if they really want -- but ye + // gods they probably shouldn't, the minimums are horrifyingly high + // latency already + int minBufferSize; + if (isCapture) { + minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat); + } else { + minBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat); + } + desiredFrames = Math.max(desiredFrames, (minBufferSize + frameSize - 1) / frameSize); + + int[] results = new int[4]; + + if (isCapture) { + if (mAudioRecord == null) { + mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.DEFAULT, sampleRate, + channelConfig, audioFormat, desiredFrames * frameSize); + + // see notes about AudioTrack state in audioOpen(), above. Probably also applies here. + if (mAudioRecord.getState() != AudioRecord.STATE_INITIALIZED) { + Log.e(TAG, "Failed during initialization of AudioRecord"); + mAudioRecord.release(); + mAudioRecord = null; + return null; + } + + mAudioRecord.startRecording(); + } + + results[0] = mAudioRecord.getSampleRate(); + results[1] = mAudioRecord.getAudioFormat(); + results[2] = mAudioRecord.getChannelCount(); + + } else { + if (mAudioTrack == null) { + mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, sampleRate, channelConfig, audioFormat, desiredFrames * frameSize, AudioTrack.MODE_STREAM); + + // Instantiating AudioTrack can "succeed" without an exception and the track may still be invalid + // Ref: https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/media/java/android/media/AudioTrack.java + // Ref: http://developer.android.com/reference/android/media/AudioTrack.html#getState() + if (mAudioTrack.getState() != AudioTrack.STATE_INITIALIZED) { + /* Try again, with safer values */ + + Log.e(TAG, "Failed during initialization of Audio Track"); + mAudioTrack.release(); + mAudioTrack = null; + return null; + } + + mAudioTrack.play(); + } + + results[0] = mAudioTrack.getSampleRate(); + results[1] = mAudioTrack.getAudioFormat(); + results[2] = mAudioTrack.getChannelCount(); + } + results[3] = desiredFrames; + + Log.v(TAG, "Opening " + (isCapture ? "capture" : "playback") + ", got " + results[3] + " frames of " + results[2] + " channel " + getAudioFormatString(results[1]) + " audio at " + results[0] + " Hz"); + + return results; + } + + /** + * This method is called by SDL using JNI. + */ + public static int[] audioOpen(int sampleRate, int audioFormat, int desiredChannels, int desiredFrames) { + return open(false, sampleRate, audioFormat, desiredChannels, desiredFrames); + } + + /** + * This method is called by SDL using JNI. + */ + public static void audioWriteFloatBuffer(float[] buffer) { + if (mAudioTrack == null) { + Log.e(TAG, "Attempted to make audio call with uninitialized audio!"); + return; + } + + for (int i = 0; i < buffer.length;) { + int result = mAudioTrack.write(buffer, i, buffer.length - i, AudioTrack.WRITE_BLOCKING); + if (result > 0) { + i += result; + } else if (result == 0) { + try { + Thread.sleep(1); + } catch(InterruptedException e) { + // Nom nom + } + } else { + Log.w(TAG, "SDL audio: error return from write(float)"); + return; + } + } + } + + /** + * This method is called by SDL using JNI. + */ + public static void audioWriteShortBuffer(short[] buffer) { + if (mAudioTrack == null) { + Log.e(TAG, "Attempted to make audio call with uninitialized audio!"); + return; + } + + for (int i = 0; i < buffer.length;) { + int result = mAudioTrack.write(buffer, i, buffer.length - i); + if (result > 0) { + i += result; + } else if (result == 0) { + try { + Thread.sleep(1); + } catch(InterruptedException e) { + // Nom nom + } + } else { + Log.w(TAG, "SDL audio: error return from write(short)"); + return; + } + } + } + + /** + * This method is called by SDL using JNI. + */ + public static void audioWriteByteBuffer(byte[] buffer) { + if (mAudioTrack == null) { + Log.e(TAG, "Attempted to make audio call with uninitialized audio!"); + return; + } + + for (int i = 0; i < buffer.length; ) { + int result = mAudioTrack.write(buffer, i, buffer.length - i); + if (result > 0) { + i += result; + } else if (result == 0) { + try { + Thread.sleep(1); + } catch(InterruptedException e) { + // Nom nom + } + } else { + Log.w(TAG, "SDL audio: error return from write(byte)"); + return; + } + } + } + + /** + * This method is called by SDL using JNI. + */ + public static int[] captureOpen(int sampleRate, int audioFormat, int desiredChannels, int desiredFrames) { + return open(true, sampleRate, audioFormat, desiredChannels, desiredFrames); + } + + /** This method is called by SDL using JNI. */ + public static int captureReadFloatBuffer(float[] buffer, boolean blocking) { + return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING); + } + + /** This method is called by SDL using JNI. */ + public static int captureReadShortBuffer(short[] buffer, boolean blocking) { + if (Build.VERSION.SDK_INT < 23) { + return mAudioRecord.read(buffer, 0, buffer.length); + } else { + return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING); + } + } + + /** This method is called by SDL using JNI. */ + public static int captureReadByteBuffer(byte[] buffer, boolean blocking) { + if (Build.VERSION.SDK_INT < 23) { + return mAudioRecord.read(buffer, 0, buffer.length); + } else { + return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING); + } + } + + /** This method is called by SDL using JNI. */ + public static void audioClose() { + if (mAudioTrack != null) { + mAudioTrack.stop(); + mAudioTrack.release(); + mAudioTrack = null; + } + } + + /** This method is called by SDL using JNI. */ + public static void captureClose() { + if (mAudioRecord != null) { + mAudioRecord.stop(); + mAudioRecord.release(); + mAudioRecord = null; + } + } + + /** This method is called by SDL using JNI. */ + public static void audioSetThreadPriority(boolean iscapture, int device_id) { + try { + + /* Set thread name */ + if (iscapture) { + Thread.currentThread().setName("SDLAudioC" + device_id); + } else { + Thread.currentThread().setName("SDLAudioP" + device_id); + } + + /* Set thread priority */ + android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO); + + } catch (Exception e) { + Log.v(TAG, "modify thread properties failed " + e.toString()); + } + } + + public static native int nativeSetupJNI(); +} diff --git a/bridge/renpy_runtime/android/org/libsdl/app/SDLControllerManager.java b/bridge/renpy_runtime/android/org/libsdl/app/SDLControllerManager.java new file mode 100644 index 000000000..f14a05236 --- /dev/null +++ b/bridge/renpy_runtime/android/org/libsdl/app/SDLControllerManager.java @@ -0,0 +1,793 @@ +// Unmodified SDL 2.0.20 helper, zlib license: ../LICENSE.txt. +package org.libsdl.app; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import android.content.Context; +import android.os.Build; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.util.Log; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; + + +public class SDLControllerManager +{ + + public static native int nativeSetupJNI(); + + public static native int nativeAddJoystick(int device_id, String name, String desc, + int vendor_id, int product_id, + boolean is_accelerometer, int button_mask, + int naxes, int nhats, int nballs); + public static native int nativeRemoveJoystick(int device_id); + public static native int nativeAddHaptic(int device_id, String name); + public static native int nativeRemoveHaptic(int device_id); + public static native int onNativePadDown(int device_id, int keycode); + public static native int onNativePadUp(int device_id, int keycode); + public static native void onNativeJoy(int device_id, int axis, + float value); + public static native void onNativeHat(int device_id, int hat_id, + int x, int y); + + protected static SDLJoystickHandler mJoystickHandler; + protected static SDLHapticHandler mHapticHandler; + + private static final String TAG = "SDLControllerManager"; + + public static void initialize() { + if (mJoystickHandler == null) { + if (Build.VERSION.SDK_INT >= 19) { + mJoystickHandler = new SDLJoystickHandler_API19(); + } else { + mJoystickHandler = new SDLJoystickHandler_API16(); + } + } + + if (mHapticHandler == null) { + if (Build.VERSION.SDK_INT >= 26) { + mHapticHandler = new SDLHapticHandler_API26(); + } else { + mHapticHandler = new SDLHapticHandler(); + } + } + } + + // Joystick glue code, just a series of stubs that redirect to the SDLJoystickHandler instance + public static boolean handleJoystickMotionEvent(MotionEvent event) { + return mJoystickHandler.handleMotionEvent(event); + } + + /** + * This method is called by SDL using JNI. + */ + public static void pollInputDevices() { + mJoystickHandler.pollInputDevices(); + } + + /** + * This method is called by SDL using JNI. + */ + public static void pollHapticDevices() { + mHapticHandler.pollHapticDevices(); + } + + /** + * This method is called by SDL using JNI. + */ + public static void hapticRun(int device_id, float intensity, int length) { + mHapticHandler.run(device_id, intensity, length); + } + + /** + * This method is called by SDL using JNI. + */ + public static void hapticStop(int device_id) + { + mHapticHandler.stop(device_id); + } + + // Check if a given device is considered a possible SDL joystick + public static boolean isDeviceSDLJoystick(int deviceId) { + InputDevice device = InputDevice.getDevice(deviceId); + // We cannot use InputDevice.isVirtual before API 16, so let's accept + // only nonnegative device ids (VIRTUAL_KEYBOARD equals -1) + if ((device == null) || (deviceId < 0)) { + return false; + } + int sources = device.getSources(); + + /* This is called for every button press, so let's not spam the logs */ + /* + if ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) { + Log.v(TAG, "Input device " + device.getName() + " has class joystick."); + } + if ((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) { + Log.v(TAG, "Input device " + device.getName() + " is a dpad."); + } + if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) { + Log.v(TAG, "Input device " + device.getName() + " is a gamepad."); + } + */ + + return ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0 || + ((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) || + ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) + ); + } + +} + +class SDLJoystickHandler { + + /** + * Handles given MotionEvent. + * @param event the event to be handled. + * @return if given event was processed. + */ + public boolean handleMotionEvent(MotionEvent event) { + return false; + } + + /** + * Handles adding and removing of input devices. + */ + public void pollInputDevices() { + } +} + +/* Actual joystick functionality available for API >= 12 devices */ +class SDLJoystickHandler_API16 extends SDLJoystickHandler { + + static class SDLJoystick { + public int device_id; + public String name; + public String desc; + public ArrayList axes; + public ArrayList hats; + } + static class RangeComparator implements Comparator { + @Override + public int compare(InputDevice.MotionRange arg0, InputDevice.MotionRange arg1) { + // Some controllers, like the Moga Pro 2, return AXIS_GAS (22) for right trigger and AXIS_BRAKE (23) for left trigger - swap them so they're sorted in the right order for SDL + int arg0Axis = arg0.getAxis(); + int arg1Axis = arg1.getAxis(); + if (arg0Axis == MotionEvent.AXIS_GAS) { + arg0Axis = MotionEvent.AXIS_BRAKE; + } else if (arg0Axis == MotionEvent.AXIS_BRAKE) { + arg0Axis = MotionEvent.AXIS_GAS; + } + if (arg1Axis == MotionEvent.AXIS_GAS) { + arg1Axis = MotionEvent.AXIS_BRAKE; + } else if (arg1Axis == MotionEvent.AXIS_BRAKE) { + arg1Axis = MotionEvent.AXIS_GAS; + } + + return arg0Axis - arg1Axis; + } + } + + private final ArrayList mJoysticks; + + public SDLJoystickHandler_API16() { + + mJoysticks = new ArrayList(); + } + + @Override + public void pollInputDevices() { + int[] deviceIds = InputDevice.getDeviceIds(); + + for (int device_id : deviceIds) { + if (SDLControllerManager.isDeviceSDLJoystick(device_id)) { + SDLJoystick joystick = getJoystick(device_id); + if (joystick == null) { + InputDevice joystickDevice = InputDevice.getDevice(device_id); + joystick = new SDLJoystick(); + joystick.device_id = device_id; + joystick.name = joystickDevice.getName(); + joystick.desc = getJoystickDescriptor(joystickDevice); + joystick.axes = new ArrayList(); + joystick.hats = new ArrayList(); + + List ranges = joystickDevice.getMotionRanges(); + Collections.sort(ranges, new RangeComparator()); + for (InputDevice.MotionRange range : ranges) { + if ((range.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) { + if (range.getAxis() == MotionEvent.AXIS_HAT_X || range.getAxis() == MotionEvent.AXIS_HAT_Y) { + joystick.hats.add(range); + } else { + joystick.axes.add(range); + } + } + } + + mJoysticks.add(joystick); + SDLControllerManager.nativeAddJoystick(joystick.device_id, joystick.name, joystick.desc, + getVendorId(joystickDevice), getProductId(joystickDevice), false, + getButtonMask(joystickDevice), joystick.axes.size(), joystick.hats.size()/2, 0); + } + } + } + + /* Check removed devices */ + ArrayList removedDevices = null; + for (SDLJoystick joystick : mJoysticks) { + int device_id = joystick.device_id; + int i; + for (i = 0; i < deviceIds.length; i++) { + if (device_id == deviceIds[i]) break; + } + if (i == deviceIds.length) { + if (removedDevices == null) { + removedDevices = new ArrayList(); + } + removedDevices.add(device_id); + } + } + + if (removedDevices != null) { + for (int device_id : removedDevices) { + SDLControllerManager.nativeRemoveJoystick(device_id); + for (int i = 0; i < mJoysticks.size(); i++) { + if (mJoysticks.get(i).device_id == device_id) { + mJoysticks.remove(i); + break; + } + } + } + } + } + + protected SDLJoystick getJoystick(int device_id) { + for (SDLJoystick joystick : mJoysticks) { + if (joystick.device_id == device_id) { + return joystick; + } + } + return null; + } + + @Override + public boolean handleMotionEvent(MotionEvent event) { + if ((event.getSource() & InputDevice.SOURCE_JOYSTICK) != 0) { + int actionPointerIndex = event.getActionIndex(); + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_MOVE) { + SDLJoystick joystick = getJoystick(event.getDeviceId()); + if (joystick != null) { + for (int i = 0; i < joystick.axes.size(); i++) { + InputDevice.MotionRange range = joystick.axes.get(i); + /* Normalize the value to -1...1 */ + float value = (event.getAxisValue(range.getAxis(), actionPointerIndex) - range.getMin()) / range.getRange() * 2.0f - 1.0f; + SDLControllerManager.onNativeJoy(joystick.device_id, i, value); + } + for (int i = 0; i < joystick.hats.size() / 2; i++) { + int hatX = Math.round(event.getAxisValue(joystick.hats.get(2 * i).getAxis(), actionPointerIndex)); + int hatY = Math.round(event.getAxisValue(joystick.hats.get(2 * i + 1).getAxis(), actionPointerIndex)); + SDLControllerManager.onNativeHat(joystick.device_id, i, hatX, hatY); + } + } + } + } + return true; + } + + public String getJoystickDescriptor(InputDevice joystickDevice) { + String desc = joystickDevice.getDescriptor(); + + if (desc != null && !desc.isEmpty()) { + return desc; + } + + return joystickDevice.getName(); + } + public int getProductId(InputDevice joystickDevice) { + return 0; + } + public int getVendorId(InputDevice joystickDevice) { + return 0; + } + public int getButtonMask(InputDevice joystickDevice) { + return -1; + } +} + +class SDLJoystickHandler_API19 extends SDLJoystickHandler_API16 { + + @Override + public int getProductId(InputDevice joystickDevice) { + return joystickDevice.getProductId(); + } + + @Override + public int getVendorId(InputDevice joystickDevice) { + return joystickDevice.getVendorId(); + } + + @Override + public int getButtonMask(InputDevice joystickDevice) { + int button_mask = 0; + int[] keys = new int[] { + KeyEvent.KEYCODE_BUTTON_A, + KeyEvent.KEYCODE_BUTTON_B, + KeyEvent.KEYCODE_BUTTON_X, + KeyEvent.KEYCODE_BUTTON_Y, + KeyEvent.KEYCODE_BACK, + KeyEvent.KEYCODE_BUTTON_MODE, + KeyEvent.KEYCODE_BUTTON_START, + KeyEvent.KEYCODE_BUTTON_THUMBL, + KeyEvent.KEYCODE_BUTTON_THUMBR, + KeyEvent.KEYCODE_BUTTON_L1, + KeyEvent.KEYCODE_BUTTON_R1, + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_BUTTON_SELECT, + KeyEvent.KEYCODE_DPAD_CENTER, + + // These don't map into any SDL controller buttons directly + KeyEvent.KEYCODE_BUTTON_L2, + KeyEvent.KEYCODE_BUTTON_R2, + KeyEvent.KEYCODE_BUTTON_C, + KeyEvent.KEYCODE_BUTTON_Z, + KeyEvent.KEYCODE_BUTTON_1, + KeyEvent.KEYCODE_BUTTON_2, + KeyEvent.KEYCODE_BUTTON_3, + KeyEvent.KEYCODE_BUTTON_4, + KeyEvent.KEYCODE_BUTTON_5, + KeyEvent.KEYCODE_BUTTON_6, + KeyEvent.KEYCODE_BUTTON_7, + KeyEvent.KEYCODE_BUTTON_8, + KeyEvent.KEYCODE_BUTTON_9, + KeyEvent.KEYCODE_BUTTON_10, + KeyEvent.KEYCODE_BUTTON_11, + KeyEvent.KEYCODE_BUTTON_12, + KeyEvent.KEYCODE_BUTTON_13, + KeyEvent.KEYCODE_BUTTON_14, + KeyEvent.KEYCODE_BUTTON_15, + KeyEvent.KEYCODE_BUTTON_16, + }; + int[] masks = new int[] { + (1 << 0), // A -> A + (1 << 1), // B -> B + (1 << 2), // X -> X + (1 << 3), // Y -> Y + (1 << 4), // BACK -> BACK + (1 << 5), // MODE -> GUIDE + (1 << 6), // START -> START + (1 << 7), // THUMBL -> LEFTSTICK + (1 << 8), // THUMBR -> RIGHTSTICK + (1 << 9), // L1 -> LEFTSHOULDER + (1 << 10), // R1 -> RIGHTSHOULDER + (1 << 11), // DPAD_UP -> DPAD_UP + (1 << 12), // DPAD_DOWN -> DPAD_DOWN + (1 << 13), // DPAD_LEFT -> DPAD_LEFT + (1 << 14), // DPAD_RIGHT -> DPAD_RIGHT + (1 << 4), // SELECT -> BACK + (1 << 0), // DPAD_CENTER -> A + (1 << 15), // L2 -> ?? + (1 << 16), // R2 -> ?? + (1 << 17), // C -> ?? + (1 << 18), // Z -> ?? + (1 << 20), // 1 -> ?? + (1 << 21), // 2 -> ?? + (1 << 22), // 3 -> ?? + (1 << 23), // 4 -> ?? + (1 << 24), // 5 -> ?? + (1 << 25), // 6 -> ?? + (1 << 26), // 7 -> ?? + (1 << 27), // 8 -> ?? + (1 << 28), // 9 -> ?? + (1 << 29), // 10 -> ?? + (1 << 30), // 11 -> ?? + (1 << 31), // 12 -> ?? + // We're out of room... + 0xFFFFFFFF, // 13 -> ?? + 0xFFFFFFFF, // 14 -> ?? + 0xFFFFFFFF, // 15 -> ?? + 0xFFFFFFFF, // 16 -> ?? + }; + boolean[] has_keys = joystickDevice.hasKeys(keys); + for (int i = 0; i < keys.length; ++i) { + if (has_keys[i]) { + button_mask |= masks[i]; + } + } + return button_mask; + } +} + +class SDLHapticHandler_API26 extends SDLHapticHandler { + @Override + public void run(int device_id, float intensity, int length) { + SDLHaptic haptic = getHaptic(device_id); + if (haptic != null) { + Log.d("SDL", "Rtest: Vibe with intensity " + intensity + " for " + length); + if (intensity == 0.0f) { + stop(device_id); + return; + } + + int vibeValue = Math.round(intensity * 255); + + if (vibeValue > 255) { + vibeValue = 255; + } + if (vibeValue < 1) { + stop(device_id); + return; + } + try { + haptic.vib.vibrate(VibrationEffect.createOneShot(length, vibeValue)); + } + catch (Exception e) { + // Fall back to the generic method, which uses DEFAULT_AMPLITUDE, but works even if + // something went horribly wrong with the Android 8.0 APIs. + haptic.vib.vibrate(length); + } + } + } +} + +class SDLHapticHandler { + + static class SDLHaptic { + public int device_id; + public String name; + public Vibrator vib; + } + + private final ArrayList mHaptics; + + public SDLHapticHandler() { + mHaptics = new ArrayList(); + } + + public void run(int device_id, float intensity, int length) { + SDLHaptic haptic = getHaptic(device_id); + if (haptic != null) { + haptic.vib.vibrate(length); + } + } + + public void stop(int device_id) { + SDLHaptic haptic = getHaptic(device_id); + if (haptic != null) { + haptic.vib.cancel(); + } + } + + public void pollHapticDevices() { + + final int deviceId_VIBRATOR_SERVICE = 999999; + boolean hasVibratorService = false; + + int[] deviceIds = InputDevice.getDeviceIds(); + // It helps processing the device ids in reverse order + // For example, in the case of the XBox 360 wireless dongle, + // so the first controller seen by SDL matches what the receiver + // considers to be the first controller + + for (int i = deviceIds.length - 1; i > -1; i--) { + SDLHaptic haptic = getHaptic(deviceIds[i]); + if (haptic == null) { + InputDevice device = InputDevice.getDevice(deviceIds[i]); + Vibrator vib = device.getVibrator(); + if (vib.hasVibrator()) { + haptic = new SDLHaptic(); + haptic.device_id = deviceIds[i]; + haptic.name = device.getName(); + haptic.vib = vib; + mHaptics.add(haptic); + SDLControllerManager.nativeAddHaptic(haptic.device_id, haptic.name); + } + } + } + + /* Check VIBRATOR_SERVICE */ + Vibrator vib = (Vibrator) SDL.getContext().getSystemService(Context.VIBRATOR_SERVICE); + if (vib != null) { + hasVibratorService = vib.hasVibrator(); + + if (hasVibratorService) { + SDLHaptic haptic = getHaptic(deviceId_VIBRATOR_SERVICE); + if (haptic == null) { + haptic = new SDLHaptic(); + haptic.device_id = deviceId_VIBRATOR_SERVICE; + haptic.name = "VIBRATOR_SERVICE"; + haptic.vib = vib; + mHaptics.add(haptic); + SDLControllerManager.nativeAddHaptic(haptic.device_id, haptic.name); + } + } + } + + /* Check removed devices */ + ArrayList removedDevices = null; + for (SDLHaptic haptic : mHaptics) { + int device_id = haptic.device_id; + int i; + for (i = 0; i < deviceIds.length; i++) { + if (device_id == deviceIds[i]) break; + } + + if (device_id != deviceId_VIBRATOR_SERVICE || !hasVibratorService) { + if (i == deviceIds.length) { + if (removedDevices == null) { + removedDevices = new ArrayList(); + } + removedDevices.add(device_id); + } + } // else: don't remove the vibrator if it is still present + } + + if (removedDevices != null) { + for (int device_id : removedDevices) { + SDLControllerManager.nativeRemoveHaptic(device_id); + for (int i = 0; i < mHaptics.size(); i++) { + if (mHaptics.get(i).device_id == device_id) { + mHaptics.remove(i); + break; + } + } + } + } + } + + protected SDLHaptic getHaptic(int device_id) { + for (SDLHaptic haptic : mHaptics) { + if (haptic.device_id == device_id) { + return haptic; + } + } + return null; + } +} + +class SDLGenericMotionListener_API12 implements View.OnGenericMotionListener { + // Generic Motion (mouse hover, joystick...) events go here + @Override + public boolean onGenericMotion(View v, MotionEvent event) { + float x, y; + int action; + + switch ( event.getSource() ) { + case InputDevice.SOURCE_JOYSTICK: + case InputDevice.SOURCE_GAMEPAD: + case InputDevice.SOURCE_DPAD: + return SDLControllerManager.handleJoystickMotionEvent(event); + + case InputDevice.SOURCE_MOUSE: + action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_SCROLL: + x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0); + y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0); + SDLActivity.onNativeMouse(0, action, x, y, false); + return true; + + case MotionEvent.ACTION_HOVER_MOVE: + x = event.getX(0); + y = event.getY(0); + + SDLActivity.onNativeMouse(0, action, x, y, false); + return true; + + default: + break; + } + break; + + default: + break; + } + + // Event was not managed + return false; + } + + public boolean supportsRelativeMouse() { + return false; + } + + public boolean inRelativeMode() { + return false; + } + + public boolean setRelativeMouseEnabled(boolean enabled) { + return false; + } + + public void reclaimRelativeMouseModeIfNeeded() + { + + } + + public float getEventX(MotionEvent event) { + return event.getX(0); + } + + public float getEventY(MotionEvent event) { + return event.getY(0); + } + +} + +class SDLGenericMotionListener_API24 extends SDLGenericMotionListener_API12 { + // Generic Motion (mouse hover, joystick...) events go here + + private boolean mRelativeModeEnabled; + + @Override + public boolean onGenericMotion(View v, MotionEvent event) { + + // Handle relative mouse mode + if (mRelativeModeEnabled) { + if (event.getSource() == InputDevice.SOURCE_MOUSE) { + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_HOVER_MOVE) { + float x = event.getAxisValue(MotionEvent.AXIS_RELATIVE_X); + float y = event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y); + SDLActivity.onNativeMouse(0, action, x, y, true); + return true; + } + } + } + + // Event was not managed, call SDLGenericMotionListener_API12 method + return super.onGenericMotion(v, event); + } + + @Override + public boolean supportsRelativeMouse() { + return true; + } + + @Override + public boolean inRelativeMode() { + return mRelativeModeEnabled; + } + + @Override + public boolean setRelativeMouseEnabled(boolean enabled) { + mRelativeModeEnabled = enabled; + return true; + } + + @Override + public float getEventX(MotionEvent event) { + if (mRelativeModeEnabled) { + return event.getAxisValue(MotionEvent.AXIS_RELATIVE_X); + } else { + return event.getX(0); + } + } + + @Override + public float getEventY(MotionEvent event) { + if (mRelativeModeEnabled) { + return event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y); + } else { + return event.getY(0); + } + } +} + +class SDLGenericMotionListener_API26 extends SDLGenericMotionListener_API24 { + // Generic Motion (mouse hover, joystick...) events go here + private boolean mRelativeModeEnabled; + + @Override + public boolean onGenericMotion(View v, MotionEvent event) { + float x, y; + int action; + + switch ( event.getSource() ) { + case InputDevice.SOURCE_JOYSTICK: + case InputDevice.SOURCE_GAMEPAD: + case InputDevice.SOURCE_DPAD: + return SDLControllerManager.handleJoystickMotionEvent(event); + + case InputDevice.SOURCE_MOUSE: + // DeX desktop mouse cursor is a separate non-standard input type. + case InputDevice.SOURCE_MOUSE | InputDevice.SOURCE_TOUCHSCREEN: + action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_SCROLL: + x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0); + y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0); + SDLActivity.onNativeMouse(0, action, x, y, false); + return true; + + case MotionEvent.ACTION_HOVER_MOVE: + x = event.getX(0); + y = event.getY(0); + SDLActivity.onNativeMouse(0, action, x, y, false); + return true; + + default: + break; + } + break; + + case InputDevice.SOURCE_MOUSE_RELATIVE: + action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_SCROLL: + x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0); + y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0); + SDLActivity.onNativeMouse(0, action, x, y, false); + return true; + + case MotionEvent.ACTION_HOVER_MOVE: + x = event.getX(0); + y = event.getY(0); + SDLActivity.onNativeMouse(0, action, x, y, true); + return true; + + default: + break; + } + break; + + default: + break; + } + + // Event was not managed + return false; + } + + @Override + public boolean supportsRelativeMouse() { + return (!SDLActivity.isDeXMode() || (Build.VERSION.SDK_INT >= 27)); + } + + @Override + public boolean inRelativeMode() { + return mRelativeModeEnabled; + } + + @Override + public boolean setRelativeMouseEnabled(boolean enabled) { + if (!SDLActivity.isDeXMode() || (Build.VERSION.SDK_INT >= 27)) { + if (enabled) { + SDLActivity.getContentView().requestPointerCapture(); + } else { + SDLActivity.getContentView().releasePointerCapture(); + } + mRelativeModeEnabled = enabled; + return true; + } else { + return false; + } + } + + @Override + public void reclaimRelativeMouseModeIfNeeded() + { + if (mRelativeModeEnabled && !SDLActivity.isDeXMode()) { + SDLActivity.getContentView().requestPointerCapture(); + } + } + + @Override + public float getEventX(MotionEvent event) { + // Relative mouse in capture mode will only have relative for X/Y + return event.getX(0); + } + + @Override + public float getEventY(MotionEvent event) { + // Relative mouse in capture mode will only have relative for X/Y + return event.getY(0); + } +} diff --git a/bridge/renpy_runtime/android/org/libsdl/app/SDLInputConnection.java b/bridge/renpy_runtime/android/org/libsdl/app/SDLInputConnection.java new file mode 100644 index 000000000..41880d623 --- /dev/null +++ b/bridge/renpy_runtime/android/org/libsdl/app/SDLInputConnection.java @@ -0,0 +1,13 @@ +package org.libsdl.app; + +/** + * Registration target required by SDL 2.0.20 JNI_OnLoad. Godot owns the IME + * view and sends committed Unicode through engine_send_input; this class + * never adds another editor or consumes uncommitted composition text. + */ +public final class SDLInputConnection { + private SDLInputConnection() {} + public static native void nativeCommitText(String text, int newCursorPosition); + public static native void nativeGenerateScancodeForUnichar(char unicode); + public static native void nativeSetComposingText(String text, int newCursorPosition); +} diff --git a/bridge/renpy_runtime/android/org/renpy/android/PythonSDLActivity.java b/bridge/renpy_runtime/android/org/renpy/android/PythonSDLActivity.java index 2bcca6f95..43ec5b39c 100644 --- a/bridge/renpy_runtime/android/org/renpy/android/PythonSDLActivity.java +++ b/bridge/renpy_runtime/android/org/renpy/android/PythonSDLActivity.java @@ -1,17 +1,52 @@ package org.renpy.android; -/** - * Host-side signature shim for the official RAPT PythonSDLActivity JNI hook. - * - * The real RAPT Activity remains an asset and is never a manifest entry. This - * class is intentionally not an Activity: it only lets the native preflight - * verify the nativeSetEnv callback without creating a second UI owner. - */ +import android.app.Activity; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.view.WindowManager; + +import org.libsdl.app.SDLActivity; + +/** Ren'Py Android API delegated to the existing host, without an Activity. */ public final class PythonSDLActivity { public static final String AETHERKIRI_HOST_SHIM = "renpy-python-host-shim-v1"; + public static final PythonSDLActivity mActivity = new PythonSDLActivity(); + public Object mStore = null; + public volatile boolean quitRequested; - public PythonSDLActivity() { + public Activity getActivity() { + Activity activity = SDLActivity.getHostActivity(); + if (activity == null) throw new IllegalStateException("Ren'Py host Activity is unavailable"); + return activity; } - + public PackageManager getPackageManager() { return getActivity().getPackageManager(); } + public String getPackageName() { return getActivity().getPackageName(); } + public int getDPI() { return getActivity().getResources().getDisplayMetrics().densityDpi; } + public static boolean isChromebook() { return SDLActivity.isChromebook(); } + public void openUrl(String url) { SDLActivity.openURL(url); } + public void vibrate(double seconds) { + Vibrator vibrator = (Vibrator) getActivity().getSystemService(Context.VIBRATOR_SERVICE); + if (vibrator == null || seconds <= 0) return; + long duration = Math.max(1L, (long) (seconds * 1000.0)); + if (Build.VERSION.SDK_INT >= 26) vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE)); + else vibrator.vibrate(duration); + } + public void setWakeLock(boolean active) { + Activity activity = getActivity(); + activity.runOnUiThread(() -> { + if (active) activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + else activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + }); + } + // The host owns the visible loading UI and lifecycle. cooperative_pause + // saves state and pauses audio instead of asking another Activity to stop. + public void hidePresplash() {} + public void armOnStop() {} + public void finishOnStop() { quitRequested = true; } + public void finishAndRemoveTask() { quitRequested = true; } + public void createStore() { /* IAP integration is not registered by this host. */ } public native void nativeSetEnv(String variable, String value); } diff --git a/bridge/renpy_runtime/include/renpy_input_mapping.h b/bridge/renpy_runtime/include/renpy_input_mapping.h new file mode 100644 index 000000000..1ac0054a5 --- /dev/null +++ b/bridge/renpy_runtime/include/renpy_input_mapping.h @@ -0,0 +1,184 @@ +#pragma once + +namespace aetherkiri::renpy::input_mapping { + +// The Godot shell translates keys to Windows-style virtual keys and uses +// KiriKiri modifier bits (main.gd's _kirikiri_virtual_key/key_modifiers). +// Ren'Py 8.5.x uses pygame_sdl2's SDL2 keycodes/KMOD_* values. Keep this +// translation independent of SDL headers, which are owned by the child SDK. +constexpr int kPygameScancodeMask = 1 << 30; +constexpr int kPygameKmodShift = 0x0003; +constexpr int kPygameKmodControl = 0x00c0; +constexpr int kPygameKmodAlt = 0x0300; +constexpr int kPygameKmodGui = 0x0c00; +constexpr int kAetherModifierShift = 0x01; +constexpr int kAetherModifierAlt = 0x02; +constexpr int kAetherModifierControl = 0x04; +constexpr int kAetherModifierEcho = 0x80; +constexpr int kGodotModifierShift = 1 << 25; +constexpr int kGodotModifierAlt = 1 << 26; +constexpr int kGodotModifierMeta = 1 << 27; +constexpr int kGodotModifierControl = 1 << 28; +constexpr int kGodotSpecial = 1 << 22; +constexpr int kGodotCodeMask = 0x007fffff; + +constexpr inline int PygameFunctionKey(int function) { + // SDL2's F1-F12 scancodes are contiguous at 58. F13-F24 start at + // scancode 104, after the keypad block. + if (function >= 1 && function <= 12) + return kPygameScancodeMask + 58 + function - 1; + if (function >= 13 && function <= 24) + return kPygameScancodeMask + 104 + function - 13; + return 0; +} + +inline int MapPointerButtonToPygame(int button) { + // EngineApi: 0=left, 1=right, 2=middle. pygame: 1=left, 2=middle, + // 3=right. Do not confuse the already-translated ABI with raw Godot's + // MouseButton enum (1=left, 2=right, 3=middle). + switch (button) { + case 0: return 1; + case 1: return 3; + case 2: return 2; + default: return button > 0 ? button : 1; + } +} + +inline int MapKeyCodeToPygame(int code) { + // Preserve SDL2 non-printable keycodes used by existing direct callers. + // Low-byte SDL ASCII and Windows VK values overlap; the ABI's VK + // interpretation takes precedence (e.g. 0x70 means F1, not 'p'). + if (code >= kPygameScancodeMask) return code; + if (code >= 0x01000000) code &= kGodotCodeMask; + + // The manual Godot probe passes raw Godot 4 non-printable keycodes. + // Their distinct KEY_SPECIAL range can be recognized unambiguously. + if (code >= kGodotSpecial && code < kGodotSpecial + 0x1000) { + const int special = code - kGodotSpecial; + switch (special) { + case 1: return 27; // Escape + case 2: // Tab + case 3: return 9; // Backtab + case 4: return 8; // Backspace + case 5: return 13; // Return + case 6: return kPygameScancodeMask + 88; // Keypad Enter + case 7: return kPygameScancodeMask + 73; // Insert + case 8: return 127; // Delete + case 9: return kPygameScancodeMask + 72; // Pause/Break + case 10: return kPygameScancodeMask + 70; // Print Screen + case 11: return kPygameScancodeMask + 154; // SysReq + case 12: return kPygameScancodeMask + 156; // Clear + case 13: return kPygameScancodeMask + 74; // Home + case 14: return kPygameScancodeMask + 77; // End + case 15: return kPygameScancodeMask + 80; // Left + case 16: return kPygameScancodeMask + 82; // Up + case 17: return kPygameScancodeMask + 79; // Right + case 18: return kPygameScancodeMask + 81; // Down + case 19: return kPygameScancodeMask + 75; // Page Up + case 20: return kPygameScancodeMask + 78; // Page Down + case 21: return kPygameScancodeMask + 225; // Shift (left) + case 22: return kPygameScancodeMask + 224; // Ctrl (left) + case 23: return kPygameScancodeMask + 227; // Meta (left) + case 24: return kPygameScancodeMask + 226; // Alt (left) + case 25: return kPygameScancodeMask + 57; // Caps Lock + case 26: return kPygameScancodeMask + 83; // Num Lock + case 27: return kPygameScancodeMask + 71; // Scroll Lock + case 66: return kPygameScancodeMask + 118; // Menu + case 69: return kPygameScancodeMask + 117; // Help + default: break; + } + if (special >= 28 && special <= 62) + return PygameFunctionKey(special - 27); // F1-F35 (SDL has F1-F24) + if (special >= 129 && special <= 143) { + static constexpr int keypad[] = { + 85, 84, 86, 99, 87, 98, 89, 90, + 91, 92, 93, 94, 95, 96, 97, + }; // Multiply, divide, subtract, period, add, 0..9. + return kPygameScancodeMask + keypad[special - 129]; + } + return 0; + } + + switch (code) { + case 0x08: return 8; // Backspace + case 0x09: return 9; // Tab + case 0x0c: return kPygameScancodeMask + 156; // Clear + case 0x0d: return 13; // Return + case 0x10: return kPygameScancodeMask + 225; // Shift + case 0x11: return kPygameScancodeMask + 224; // Ctrl + case 0x12: return kPygameScancodeMask + 226; // Alt + case 0x13: return kPygameScancodeMask + 72; // Pause + case 0x14: return kPygameScancodeMask + 57; // Caps Lock + case 0x1b: return 27; // Escape + case 0x20: return 32; // Space + case 0x21: return kPygameScancodeMask + 75; // Page Up + case 0x22: return kPygameScancodeMask + 78; // Page Down + case 0x23: return kPygameScancodeMask + 77; // End + case 0x24: return kPygameScancodeMask + 74; // Home + case 0x25: return kPygameScancodeMask + 80; // Left + case 0x26: return kPygameScancodeMask + 82; // Up + case 0x27: return kPygameScancodeMask + 79; // Right + case 0x28: return kPygameScancodeMask + 81; // Down + case 0x2c: return kPygameScancodeMask + 70; // Print Screen + case 0x2d: return kPygameScancodeMask + 73; // Insert + case 0x2e: return 127; // Delete + case 0x2f: return kPygameScancodeMask + 117; // Help + case 0x5b: return kPygameScancodeMask + 227; // Left Windows + case 0x5c: return kPygameScancodeMask + 231; // Right Windows + case 0x5d: return kPygameScancodeMask + 101; // Applications + case 0x6a: return kPygameScancodeMask + 85; // Keypad multiply + case 0x6b: return kPygameScancodeMask + 87; // Keypad add + case 0x6c: return kPygameScancodeMask + 133; // Keypad separator + case 0x6d: return kPygameScancodeMask + 86; // Keypad subtract + case 0x6e: return kPygameScancodeMask + 99; // Keypad decimal + case 0x6f: return kPygameScancodeMask + 84; // Keypad divide + case 0x90: return kPygameScancodeMask + 83; // Num Lock + case 0x91: return kPygameScancodeMask + 71; // Scroll Lock + case 0xa0: return kPygameScancodeMask + 225; // Left Shift + case 0xa1: return kPygameScancodeMask + 229; // Right Shift + case 0xa2: return kPygameScancodeMask + 224; // Left Ctrl + case 0xa3: return kPygameScancodeMask + 228; // Right Ctrl + case 0xa4: return kPygameScancodeMask + 226; // Left Alt + case 0xa5: return kPygameScancodeMask + 230; // Right Alt + case 0xba: return ';'; + case 0xbb: return '='; + case 0xbc: return ','; + case 0xbd: return '-'; + case 0xbe: return '.'; + case 0xbf: return '/'; + case 0xc0: return '`'; + case 0xdb: return '['; + case 0xdc: return '\\'; + case 0xdd: return ']'; + case 0xde: return '\''; + case 0xe2: return '\\'; + default: break; + } + if (code >= 0x60 && code <= 0x69) + return kPygameScancodeMask + (code == 0x60 ? 98 : 89 + code - 0x61); + if (code >= 0x70 && code <= 0x87) + return PygameFunctionKey(code - 0x70 + 1); + if (code >= 'A' && code <= 'Z') return code + ('a' - 'A'); + if (code >= 0x21 && code <= 0x7e) return code; + return 0; // K_UNKNOWN +} + +inline int MapModifiersToPygame(int modifiers) { + int result = 0; + if (modifiers & (kAetherModifierShift | kGodotModifierShift)) + result |= kPygameKmodShift; + if (modifiers & (kAetherModifierAlt | kGodotModifierAlt)) + result |= kPygameKmodAlt; + if (modifiers & (kAetherModifierControl | kGodotModifierControl)) + result |= kPygameKmodControl; + if (modifiers & kGodotModifierMeta) result |= kPygameKmodGui; + // No raw SDL passthrough: Aether's echo=0x80 would become SDL's RCtrl; + // mouse-held/cancel bits are not keyboard modifiers either. + return result; +} + +inline bool IsAetherKeyRepeat(int modifiers) { + return (modifiers & kAetherModifierEcho) != 0; +} + +} // namespace aetherkiri::renpy::input_mapping diff --git a/bridge/renpy_runtime/include/renpy_mobile_adapter.h b/bridge/renpy_runtime/include/renpy_mobile_adapter.h index 30fb63c04..d7ef592c6 100644 --- a/bridge/renpy_runtime/include/renpy_mobile_adapter.h +++ b/bridge/renpy_runtime/include/renpy_mobile_adapter.h @@ -8,15 +8,15 @@ namespace aetherkiri::renpy::mobile { /* * The Android RAPT package is built around PythonSDLActivity. AetherKiri - * already owns the process and its Godot Activity, so the eventual adapter + * already owns the process and its Godot Activity, so this adapter * must attach RAPT to that Activity rather than starting another one. * * This boundary intentionally carries an opaque host pointer. Android hosts * may pass a JNI jobject through it without making the public provider ABI - * depend on jni.h; non-Android callers can leave it null. The current - * implementation is a compile-tested guard and returns NOT_SUPPORTED until - * the staged PythonSDLActivity classes, librenpython, asset extraction, SDL - * surface, and lifecycle hooks are linked into the generated Android app. + * depend on jni.h; non-Android callers can leave it null. PrepareRuntime + * extracts the bundled private Python tree and initializes SDL's JNI/audio + * callbacks. Video uses the patched offscreen EGL backend. Preflight/Start + * preserve the explicit rejection of an official blocking RAPT launcher. */ struct BootstrapRequest { const char* game_root_path_utf8 = nullptr; @@ -33,6 +33,10 @@ class BootstrapAdapter final { engine_result_t Preflight(const BootstrapRequest& request); engine_result_t Start(const BootstrapRequest& request); + engine_result_t PrepareRuntime(const BootstrapRequest& request, + uint32_t width, uint32_t height, + std::string* private_root, + std::string* apk_path); engine_result_t Stop(); bool running() const { return running_; } diff --git a/bridge/renpy_runtime/mobile_launcher/README.md b/bridge/renpy_runtime/mobile_launcher/README.md index 1d6f1918e..8c1bb5b08 100644 --- a/bridge/renpy_runtime/mobile_launcher/README.md +++ b/bridge/renpy_runtime/mobile_launcher/README.md @@ -1,129 +1,71 @@ -# Host-owned Ren'Py mobile launcher scaffold - -This directory is the reviewable boundary for the native fork required to make -Ren'Py run inside an existing AetherKiri/Godot loop. It is intentionally a -contract and build/replacement scaffold. It does not contain a playable -launcher and it does not alter the current `NOT_SUPPORTED` mobile provider. - -## Verified official inputs - -The pinned 8.5.3 mobile archives in `renpy-mobile-staged/` contain stripped -outputs only. The corresponding official source/build layout is available in -the `renpy-build` repository: - -- `runtime/librenpython_android.c`: `SDL_main` initializes the Android - environment, calls `call_prepare_python`, and enters `start_python`; the - latter ends at blocking `Py_RunMain` -- `runtime/librenpython.c`: `launcher_main` builds `PyConfig` and ends at - blocking `Py_RunMain` -- `runtime/jniwrapperstuff.h`: Android JNI export-name helper -- `tasks/renpython.py`: `build_android` compiles - `librenpython_android.c`; `link_android` links `librenpython.so`; - `link_ios` archives `librenpython.o` as `librenpython.a` -- `renios/prototype/main.c`: the UIKit prototype calls - `SDL_RunApp(..., launcher_main, NULL)` (older SDL2/Renios archives use the - equivalent `SDL_UIKitRunApp` spelling) - -The official command-line hooks are documented by `build.sh --print-plan`: - -- Android: `./build.sh --platform android rebuild rapt rapt-sdl2` -- iOS: `./build.sh --platform ios rebuild renios` - -`build.sh --source-build-plan --renpy-build /path/to/renpy-build` prints the -Ubuntu 24.04 prerequisites, source patch commands, build commands, and exact -RAPT/Renios artifact destinations. `--check-renpy-build` performs a strict -read-only preflight for the source checkout, patch applicability, host tools, -64 GiB disk requirement, and supplied Android/iOS SDK archives. These commands -are opt-in and are not part of the normal mobile CI build because the official -renpy-build process is a large, multi-hour source rebuild and requires licensed -iOS SDK tarballs. CI only validates the plan; it does not pretend to build a -playable fork. - -The current official outputs are process launchers. Do not call them from a -Godot frame callback and do not copy `renios/prototype/main.c` into the host -application. - -## Lifecycle ABI to implement - -`include/renpy_mobile_launcher.h` defines ABI version 1 and the required -exports: - -- `renpy_mobile_init(config, host)` -- `renpy_mobile_tick(budget_ms)` -- `renpy_mobile_frame(out_frame)` -- `renpy_mobile_input(event)` -- `renpy_mobile_pause()` / `renpy_mobile_resume()` -- `renpy_mobile_shutdown()` - -The eventual fork must move Python/SDL setup out of `SDL_main`/`launcher_main` -and make every lifecycle call return promptly. The calls must not invoke -`Py_RunMain`, `SDL_main`, `SDL_RunApp`, `SDL_UIKitRunApp`, `UIApplicationMain`, -or create an Android Activity. `frame` returns a borrowed RGBA view for the -host to copy; the host callback and ownership rules are in the header. - -The header is only an ABI contract. The `.c.template` files under -`patches/android` and `patches/ios` are deliberately non-playable starting -points: init returns `RENPY_MOBILE_NOT_IMPLEMENTED`, all runtime calls remain -invalid until a real implementation initializes them, and no output object is -copied into a mobile package. - -`build.sh --compile-contract` compiles those templates with `-Wall -Wextra --Werror` on the host so CI can validate the C ABI and required exported names. -This is a source-level check, not a Ren'Py SDK rebuild: - -```text -bridge/renpy_runtime/mobile_launcher/build.sh --compile-contract \ - --renpy-build /path/to/renpy-build --output-dir /tmp/renpy-mobile-contract +# Host-owned Ren'Py mobile lifecycle fork + +This fork separates Python initialization from the official blocking launchers +and runs the full Ren'Py bootstrap inside a stack-preserving greenlet. It is a +reviewable runtime implementation and build path; mobile gameplay remains +unverified. The contract templates are separate, deliberately non-playable +fixtures and are never packaged. + +## Pinned source build + +`tools/run_renpy_mobile_source_build.sh` pins the Ren'Py build/source and greenlet +revisions documented in the [provider README](../README.md). It applies the +Python patch to the actual source compiled by renpy-build, compiles the native +lifecycle and greenlet objects for each selected Android/iOS target, and checks +the exports before assembling a payload. The legacy Python patch filename +contains `skeleton`, but its implementation now preserves the actual Python +and Cython stacks with greenlet rather than exception-based yields. + +```bash +bash tools/run_renpy_mobile_source_build.sh --mode prepare --fetch \ + --platform android --renpy-build /workspace/renpy-build +bash tools/run_renpy_mobile_source_build.sh --mode build \ + --platform android --renpy-build /workspace/renpy-build ``` -The native templates identify the real remaining patch points. In addition to -factoring the C launcher, Ren'Py's Python `renpy/bootstrap.py`, `renpy/main.py`, -and `renpy/display/core.py` must be made cooperatively resumable. Their current -call chain enters `renpy.execution.run_context(True)` and -`Interface.interact_core`, both of which keep control until a script or user -interaction completes. Splitting only `Py_InitializeFromConfig` from -`Py_RunMain` does not make a frame-driven engine. Android also exits through -`android.activity.finishAndRemoveTask()` and Java `System.exit(0)` in -`bootstrap.py` cleanup, which an embedded fork must replace with host-owned -shutdown. - -The host-side `src/renpy_mobile_loader.cpp` resolves this ABI when a real -fork is supplied: Android loads all seven symbols from `librenpython.so`, and -iOS uses weak imports from the optional `librenpython.a`. The mobile provider -forwards lifecycle and input calls and copies the borrowed RGBA frame into the -normal Aether frame API. With the official blocking archive, symbol resolution -fails and the provider remains `ENGINE_RESULT_NOT_SUPPORTED`. - -## Rebuild and replacement hook - -`build.sh` performs a source-only check by default when invoked as: - -```text -bridge/renpy_runtime/mobile_launcher/build.sh \ - --check --renpy-build /path/to/renpy-build -``` - -Use `--print-plan` to print the official source inputs, task hooks, and exact -staged destinations. A future lifecycle fork is built outside this scaffold, -then installed with explicit ABI-matched artifacts: - -```text -bridge/renpy_runtime/mobile_launcher/build.sh --install \ - --renpy-build /path/to/renpy-build \ - --stage /workspace/shared/renpy-mobile-staged \ - --android-so-arm64 /path/to/arm64-v8a/librenpython.so \ - --android-so-armv7 /path/to/armeabi-v7a/librenpython.so \ - --android-so-x86_64 /path/to/x86_64/librenpython.so \ - --ios-debug-a /path/to/debug/librenpython.a \ - --ios-release-a /path/to/release/librenpython.a -``` - -`--install` refuses artifacts that do not expose all seven lifecycle symbols. -It copies only the explicitly supplied files to: - -- RAPT: `rapt/prototype/renpyandroid/src/main/jniLibs/{arm64-v8a,armeabi-v7a,x86_64}/librenpython.so` -- Renios: `renios/prototype/prebuilt/{debug,release}/librenpython.a` - -The script does not build, sign, package, or claim device/simulator support. -After a real fork is installed, the Android/iOS archive probes and host-owned -lifecycle/input/frame tests must be extended before enabling the provider. +Preparation does not run upstream `prepare.sh`, which resets or pulls source +revisions and installs system packages. Build requires a provisioned Ubuntu +24.04 cloud builder, LLVM 18, NDK r29 and 64 GiB of free disk. iOS source builds +also require the legally supplied SDK archives requested by the pinned +renpy-build toolchain. `--platform ios` or `all` selects those targets explicitly. + +`--mode check` only checks prerequisites. `--mode auto` with an existing checkout +performs compilation; an absent optional checkout reports a skip. CI uses +`--mode build` explicitly. Successful export checks or compilation do not assert +mobile playability. + +## Runtime ownership + +ABI version 2 is defined in `include/renpy_mobile_launcher.h`. The loader requires +bootstrap, window binding, init/tick/frame/input, pause/resume, shutdown, text +input state and surface sizing. Native initialization copies the host callback +table. The provider owns the paths and initializes Python and greenlet on the +first host tick, rather than the asynchronous open thread. + +Frame capture occurs on Ren'Py's rendering thread through its GL screenshot. +The fork copies visible Surface rows and retains the bytes while the host reads +them. It does not use `SDL_GetWindowSurface` for a GL window. Input preserves +pointer actions, key transitions, scroll deltas and committed UTF-8 text. +Android uses the patched SDL2 offscreen EGL/pbuffer backend and restores the +host's EGL context when returning to Godot. The Java bridge supplies the actual +SDL JNI callback closure without launching another Activity. + +The stock iOS SDL UIKit/MetalANGLE path still requires a host rendering +integration. The provider refuses that unimplemented path explicitly instead +of activating it merely because an archive exports the lifecycle symbols. + +## Acceptance and artifacts + +The application build must carry the native library, cooperative Ren'Py modules +and matching Python standard library. `tools/validate_renpy_mobile_payload.py` +checks staged inputs and actual Android APK contents. Missing or official +blocking launchers fail the enabled build rather than producing a guard-only +Ren'Py package. + +Run the real cloud-device gate documented in +[the demo README](../../../demos/aetherkiri-renpy/README.md) after building an +installable debug APK. It requires executed game checkpoints, native pixels +matching an OS screenshot, actual touch and text/keyboard events, +background/resume and normal exit. Host boundary tests and simulated library +fixtures are reported separately and cannot satisfy this gate. No mobile +APK/IPA gameplay result has been accepted for this change. diff --git a/bridge/renpy_runtime/mobile_launcher/build.sh b/bridge/renpy_runtime/mobile_launcher/build.sh index 995156028..741b6fedb 100644 --- a/bridge/renpy_runtime/mobile_launcher/build.sh +++ b/bridge/renpy_runtime/mobile_launcher/build.sh @@ -27,7 +27,7 @@ launcher is still the blocking SDL_main/launcher_main implementation. --source-build-plan prints the official renpy-build source-build commands, required Ubuntu/toolchain inputs, and the exact RAPT/Renios artifact paths. --check-renpy-build performs a strict, read-only preflight for those inputs; it -checks the Ubuntu/disk/toolchain prerequisites, source checkout, SDK archives, +checks platform-specific Ubuntu/disk/toolchain prerequisites, pinned source checkout, SDK archives, and cooperative-loop patch applicability. It never runs the heavy build. --compile-contract compiles the Android/iOS templates as contract-only objects; those objects deliberately return NOT_IMPLEMENTED and are never packaged. @@ -45,6 +45,7 @@ USAGE } mode="" +platform="android" renpy_build="" renpy_src="" stage="" @@ -61,6 +62,10 @@ while [[ $# -gt 0 ]]; do [[ -z "$mode" ]] || { echo "choose one mode" >&2; exit 2; } mode="${1#--}" ;; + --platform) + [[ $# -ge 2 ]] || { echo "--platform requires a value" >&2; exit 2; } + platform="$2"; shift + ;; --renpy-build) [[ $# -ge 2 ]] || { echo "--renpy-build requires a path" >&2; exit 2; } renpy_build="$2"; shift @@ -114,152 +119,84 @@ done # the official renpy-build workflow reproducible and auditable without hiding a # many-hour native rebuild inside the regular AetherKiri CI jobs. renpy_build_source_preflight() { - local strict="$1" - local failures=0 - local required - for required in \ - "$renpy_build/build.sh" \ - "$renpy_build/prepare.sh" \ - "$renpy_build/tasks/renpython.py" \ - "$renpy_build/tasks/rapt.py" \ - "$renpy_build/tasks/renios.py" \ - "$renpy_build/runtime/librenpython_android.c" \ - "$renpy_build/runtime/librenpython.c"; do - if [[ ! -f "$required" ]]; then - echo "missing renpy-build input: $required" >&2 + local strict="$1" failures=0 required source_root="${renpy_src:-$renpy_build/renpy}" + case "$platform" in android|ios|all) ;; *) echo "invalid platform: $platform" >&2; return 2 ;; esac + for required in build.sh prepare.sh tasks/renpython.py tasks/librenpy.py tasks/rapt.py tasks/renios.py runtime/librenpython3_android.c runtime/librenpython3.c; do + if [[ ! -f "$renpy_build/$required" ]]; then + echo "missing renpy-build input: $renpy_build/$required" >&2 failures=$((failures + 1)) fi done - - local source_root="${renpy_src:-$renpy_build/renpy}" - if [[ ! -d "$source_root/.git" ]]; then + if ! git -C "$renpy_build" rev-parse HEAD >/dev/null 2>&1 || + [[ "$(git -C "$renpy_build" rev-parse HEAD 2>/dev/null)" != 7bfab40c1174f622f644b24669afd5fb167fbb79 ]]; then + echo "renpy-build must be pinned to Ren'Py 8.5.3 build SHA 7bfab40c1174f622f644b24669afd5fb167fbb79" >&2 + failures=$((failures + 1)) + fi + if ! git -C "$source_root" rev-parse HEAD >/dev/null 2>&1; then echo "Ren'Py source checkout missing: $source_root" >&2 - echo " run: (cd $renpy_build && ./prepare.sh)" >&2 failures=$((failures + 1)) + elif [[ "$(git -C "$source_root" rev-parse HEAD)" != 39895c1e017f0b36ffea2447d97eccd69d76ee1c ]]; then + echo "Ren'Py source must be pinned to 8.5.3 SHA 39895c1e017f0b36ffea2447d97eccd69d76ee1c" >&2 + failures=$((failures + 1)) + elif git -C "$source_root" apply --check "$python_patch" >/dev/null 2>&1; then + echo "cooperative-loop patch: applies cleanly (prepare required before build)" + elif git -C "$source_root" apply --reverse --check "$python_patch" >/dev/null 2>&1; then + echo "cooperative-loop patch: already applied to the actual build source" else - if [[ ! -f "$source_root/renpy/main.py" || ! -f "$source_root/renpy/execution.py" || ! -f "$source_root/renpy/display/core.py" ]]; then - echo "Ren'Py source checkout is incomplete: $source_root" >&2 - failures=$((failures + 1)) - elif [[ -f "$python_patch" ]]; then - if ! git -C "$source_root" apply --check "$python_patch" >/dev/null 2>&1; then - echo "cooperative-loop patch does not apply to $source_root" >&2 - failures=$((failures + 1)) - else - echo "cooperative-loop patch: applies cleanly" - fi - fi - fi - - # renpy-build's current toolchain.py/run.py expect these exact archives. - # They are intentionally checked rather than downloaded implicitly: iOS - # SDK tarballs are licensed inputs and must be supplied by the builder. - local archive - local ndk_version="android-ndk-r29" - if [[ -f "$renpy_build/renpybuild/run.py" ]]; then - ndk_version="$(sed -n 's/.*c.var("ndk_version", "\([^"]*\)").*/\1/p' "$renpy_build/renpybuild/run.py" | head -1)" - [[ -n "$ndk_version" ]] || ndk_version="android-ndk-r29" + echo "cooperative-loop patch does not match $source_root" >&2 + failures=$((failures + 1)) fi - for archive in \ - "$renpy_build/tars/${ndk_version}-linux.zip" \ - "$renpy_build/tars/iPhoneOS.sdk.tar.gz" \ - "$renpy_build/tars/iPhoneSimulator.sdk.tar.gz"; do - if [[ ! -f "$archive" ]]; then - echo "missing licensed/toolchain archive: $archive" >&2 + local archives=() + [[ "$platform" == ios ]] || archives+=(android-ndk-r29-linux.zip) + [[ "$platform" == android ]] || archives+=(iPhoneOS14.0.sdk.tar.gz iPhoneSimulator14.0.sdk.tar.gz) + for required in "${archives[@]}"; do + if [[ ! -f "$renpy_build/tars/$required" ]]; then + echo "missing toolchain archive: $renpy_build/tars/$required" >&2 failures=$((failures + 1)) - else - echo "toolchain archive: $archive" fi done - - local command - for command in bash git python3 curl tar unzip make cmake ninja pkg-config clang clang++; do - if command -v "$command" >/dev/null 2>&1; then - printf 'tool: %-12s %s\n' "$command" "$(command -v "$command")" - else - echo "missing host tool: $command" >&2 + for required in bash git python3 curl tar unzip make cmake ninja pkg-config clang-18 clang++-18 llvm-ar-18 llvm-nm-18 uv; do + if ! command -v "$required" >/dev/null 2>&1; then + echo "missing host tool: $required" >&2 failures=$((failures + 1)) fi done - if command -v uv >/dev/null 2>&1; then - echo "tool: uv $(command -v uv)" - else - echo "missing host tool: uv (install from https://astral.sh/uv)" >&2 - failures=$((failures + 1)) + local ubuntu_id="" ubuntu_version="" + if [[ -r /etc/os-release ]]; then + # shellcheck disable=SC1091 + . /etc/os-release + ubuntu_id="${ID:-}"; ubuntu_version="${VERSION_ID:-}" fi - - local system_name - system_name="$(uname -s)" - if [[ "$system_name" != "Linux" ]]; then - echo "renpy-build requires Ubuntu Linux; detected $system_name" >&2 + if [[ "$(uname -s)" != Linux || "$ubuntu_id" != ubuntu || "$ubuntu_version" != 24.04 ]]; then + echo "renpy-build officially requires Ubuntu 24.04 (detected ${ubuntu_id:-unknown} ${ubuntu_version:-unknown})" >&2 failures=$((failures + 1)) - else - local ubuntu_id="" ubuntu_version="" - if [[ -r /etc/os-release ]]; then - # shellcheck disable=SC1091 - . /etc/os-release - ubuntu_id="${ID:-}" - ubuntu_version="${VERSION_ID:-}" - fi - if [[ "$ubuntu_id" == "ubuntu" && "$ubuntu_version" == "24.04" ]]; then - echo "host: Ubuntu 24.04" - else - echo "renpy-build officially targets Ubuntu 24.04 (detected ${ubuntu_id:-unknown} ${ubuntu_version:-unknown})" >&2 - failures=$((failures + 1)) - fi fi - - local available_kib=0 - available_kib="$(df -Pk "$renpy_build" 2>/dev/null | awk 'NR==2 {print $4}')" - if [[ "$available_kib" =~ ^[0-9]+$ ]]; then - printf 'disk: %.1f GiB available (official minimum: 64 GiB)\n' "$(awk -v kib="$available_kib" 'BEGIN {print kib / 1024 / 1024}')" - if (( available_kib < 64 * 1024 * 1024 )); then - echo "insufficient free disk for renpy-build (need at least 64 GiB)" >&2 - failures=$((failures + 1)) - fi - else - echo "unable to determine free disk for $renpy_build" >&2 + local available_kib + available_kib="$(df -Pk "$renpy_build" | awk 'NR==2 {print $4}')" + if [[ ! "$available_kib" =~ ^[0-9]+$ ]] || (( available_kib < 64 * 1024 * 1024 )); then + echo "insufficient free disk for renpy-build (official minimum: 64 GiB; available ${available_kib:-unknown} KiB)" >&2 failures=$((failures + 1)) fi - - if [[ "$strict" == "1" && "$failures" -ne 0 ]]; then + if [[ "$strict" == 1 && "$failures" != 0 ]]; then echo "Ren'Py source-build preflight failed with $failures issue(s)" >&2 return 1 fi - return 0 } renpy_build_source_plan() { - local source_root="${renpy_src:-$renpy_build/renpy}" - local artifact_root="$renpy_build/renpy" cat < --android-so-arm64 --android-so-armv7 --android-so-x86_64 --ios-debug-a --ios-release-a - -Important: this plan does not assert mobile playability. A native fork must -export the host lifecycle ABI, then pass device/simulator gameplay E2E. +Ren'Py 8.5.3 source build (Ubuntu 24.04, LLVM18, at least 64 GiB) + Build SHA: 7bfab40c1174f622f644b24669afd5fb167fbb79 + Ren'Py SHA: 39895c1e017f0b36ffea2447d97eccd69d76ee1c + Greenlet SHA: 65f8da82b13a1273e55a6bfcbd1f9da09fc4eb7a + Android: (cd $renpy_build && ./build.sh --platform android --python 3 rebuild librenpy pythonlib renpython rapt sdl2) + iOS: (cd $renpy_build && ./build.sh --platform ios --python 3 rebuild librenpy pythonlib renpython renios) + Android archive: tars/android-ndk-r29-linux.zip + iOS licensed archives: tars/iPhoneOS14.0.sdk.tar.gz, tars/iPhoneSimulator14.0.sdk.tar.gz + Android libraries: renpy/rapt3/prototype/renpyandroid/src/main/jniLibs/{arm64-v8a,armeabi-v7a,x86_64}/librenpython.so + iOS libraries: renpy/renios3/prototype/prebuilt/{release,debug}/librenpython.a + Prepare: $repo_root/tools/run_renpy_mobile_source_build.sh --mode prepare --fetch --renpy-build $renpy_build --platform $platform +This plan does not assert mobile playability. Compilation and real device gameplay are separate gates. PLAN } @@ -282,6 +219,10 @@ if [[ "$mode" == "check-python-patch" || "$mode" == "apply-python-patch" ]]; the git -C "$renpy_src" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "--renpy-src must be a git checkout" >&2; exit 1; } + if git -C "$renpy_src" apply --reverse --check "$python_patch" >/dev/null 2>&1; then + echo "Ren'Py Python cooperative-loop patch applies cleanly (already applied)" + exit 0 + fi git -C "$renpy_src" apply --check "$python_patch" if [[ "$mode" == "check-python-patch" ]]; then echo "Ren'Py Python cooperative-loop patch applies cleanly" @@ -293,8 +234,8 @@ if [[ "$mode" == "check-python-patch" || "$mode" == "apply-python-patch" ]]; the exit 1 fi git -C "$renpy_src" apply "$python_patch" - python3 -m py_compile "$renpy_src/renpy/main.py" \ - "$renpy_src/renpy/execution.py" "$renpy_src/renpy/display/core.py" + python3 -m py_compile "$renpy_src/renpy/aether_mobile.py" \ + "$renpy_src/renpy/bootstrap.py" "$renpy_src/renpy/execution.py" "$renpy_src/renpy/display/core.py" echo "applied and syntax-checked Ren'Py Python cooperative loop" echo " native ABI/frame/input integration and device E2E remain required" exit 0 @@ -307,8 +248,8 @@ runtime="$renpy_build/runtime" tasks="$renpy_build/tasks/renpython.py" renios_main="$renpy_build/renios/prototype/main.c" for required in \ - "$runtime/librenpython_android.c" \ - "$runtime/librenpython.c" \ + "$runtime/librenpython3_android.c" \ + "$runtime/librenpython3.c" \ "$runtime/jniwrapperstuff.h" \ "$tasks" \ "$renios_main"; do @@ -317,19 +258,19 @@ done # Keep this check source-based: it works on a checkout without requiring a # host compiler or an Apple/Android SDK. -grep -Eq 'int[[:space:]]+SDL_main[[:space:]]*\(' "$runtime/librenpython_android.c" || { +grep -Eq 'int[[:space:]]+SDL_main[[:space:]]*\(' "$runtime/librenpython3_android.c" || { echo "official Android launcher no longer contains SDL_main; inspect before using this scaffold" >&2 exit 1 } -grep -Eq 'Py_RunMain[[:space:]]*\(' "$runtime/librenpython_android.c" || { +grep -Eq 'Py_RunMain[[:space:]]*\(' "$runtime/librenpython3_android.c" || { echo "official Android launcher no longer contains Py_RunMain; inspect before using this scaffold" >&2 exit 1 } -grep -Eq 'launcher_main[[:space:]]*\(' "$runtime/librenpython.c" || { +grep -Eq 'launcher_main[[:space:]]*\(' "$runtime/librenpython3.c" || { echo "official desktop/iOS launcher no longer contains launcher_main; inspect before using this scaffold" >&2 exit 1 } -grep -Eq 'Py_RunMain[[:space:]]*\(' "$runtime/librenpython.c" || { +grep -Eq 'Py_RunMain[[:space:]]*\(' "$runtime/librenpython3.c" || { echo "official desktop/iOS launcher no longer contains Py_RunMain; inspect before using this scaffold" >&2 exit 1 } @@ -341,8 +282,8 @@ grep -Eq 'SDL_RunApp|SDL_UIKitRunApp' "$renios_main" || { print_plan() { cat < #include "android/log.h" #include "jniwrapperstuff.h" +#include "renpy_mobile_launcher.h" ++ ++/* Built into the same target CPython, never imported from a host wheel. */ ++PyMODINIT_FUNC PyInit__greenlet(void); ++PyMODINIT_FUNC PyInit__aether_host(void); ++ void init_librenpy(void); -@@ -143,6 +144,51 @@ +@@ -143,6 +149,62 @@ int start_python(void) { return rv; } -+/* Host-owned entrypoint used by the AetherKiri lifecycle fork. This keeps -+ * SDL_main -> Py_RunMain unchanged for the official launcher while exposing a -+ * separate path that initializes Python and returns to the host loop. */ ++/* Initialize the embedded interpreter without running a process main loop. */ +int renpy_mobile_bootstrap(const renpy_mobile_config_t *mobile_config, + const renpy_mobile_host_t *host) { -+ if (!mobile_config || !host || mobile_config->struct_size < sizeof(*mobile_config) || -+ host->struct_size < sizeof(*host) || -+ mobile_config->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || -+ host->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || -+ !mobile_config->private_root_utf8 || !mobile_config->private_root_utf8[0]) { -+ return RENPY_MOBILE_INVALID_ARGUMENT; ++ if (!mobile_config || !host || !mobile_config->private_root_utf8 || ++ !mobile_config->game_root_utf8) return RENPY_MOBILE_INVALID_ARGUMENT; ++ if (Py_IsInitialized()) { ++ if (host->log_utf8) host->log_utf8(host->user_data, 2, ++ "Ren'Py interpreter reuse is not implemented; restart the host application for another game session"); ++ return RENPY_MOBILE_INVALID_STATE; + } -+ + init_environ(); + setenv_workaround("RENPY_PLATFORM", PLATFORM "-" ARCH); + setenv_workaround("ANDROID_PRIVATE", mobile_config->private_root_utf8); ++ setenv_workaround("ANDROID_PUBLIC", mobile_config->public_root_utf8 ++ ? mobile_config->public_root_utf8 : mobile_config->game_root_utf8); ++ setenv_workaround("ANDROID_OLD_PUBLIC", mobile_config->public_root_utf8 ++ ? mobile_config->public_root_utf8 : mobile_config->game_root_utf8); ++ if (mobile_config->apk_path_utf8) ++ setenv_workaround("ANDROID_APK", mobile_config->apk_path_utf8); + SDL_SetHint(SDL_HINT_ANDROID_BLOCK_ON_PAUSE, "0"); -+ -+ PyPreConfig preconfig; -+ PyConfig pyconfig; -+ PyImport_ExtendInittab(inittab); ++ SDL_SetHint("AETHER_RENPY_EMBEDDED", "1"); ++ setenv_workaround("SDL_VIDEODRIVER", "offscreen"); ++ if (PyImport_ExtendInittab(inittab) == -1 || ++ PyImport_AppendInittab("_aether_greenlet", PyInit__greenlet) == -1 || ++ PyImport_AppendInittab("_aether_host", PyInit__aether_host) == -1) ++ return RENPY_MOBILE_ERROR; + init_librenpy(); + -+ char python[2048]; -+ snprintf(python, sizeof(python), "%s/python", mobile_config->private_root_utf8); -+ char main_py[2048]; -+ snprintf(main_py, sizeof(main_py), "%s/main.py", mobile_config->private_root_utf8); -+ char *argv[] = { python, main_py, NULL }; -+ ++ PyPreConfig preconfig; + PyPreConfig_InitPythonConfig(&preconfig); + preconfig.utf8_mode = 1; + preconfig.use_environment = 0; -+ Py_PreInitializeFromBytesArgs(&preconfig, 2, argv); -+ ++ PyStatus status = Py_PreInitialize(&preconfig); ++ if (PyStatus_Exception(status)) return RENPY_MOBILE_ERROR; ++ PyConfig pyconfig; + PyConfig_InitPythonConfig(&pyconfig); -+ pyconfig.home = Py_DecodeLocale(mobile_config->private_root_utf8, NULL); + pyconfig.user_site_directory = 0; -+ pyconfig.parse_argv = 1; ++ pyconfig.parse_argv = 0; + pyconfig.install_signal_handlers = 0; -+ PyConfig_SetBytesArgv(&pyconfig, 2, argv); -+ Py_InitializeFromConfig(&pyconfig); -+ -+ return renpy_mobile_init(mobile_config, host); ++ status = PyConfig_SetBytesString(&pyconfig, &pyconfig.home, mobile_config->private_root_utf8); ++ if (!PyStatus_Exception(status)) status = Py_InitializeFromConfig(&pyconfig); ++ PyConfig_Clear(&pyconfig); ++ if (PyStatus_Exception(status)) return RENPY_MOBILE_ERROR; ++ /* Py_RunMain normally adds the script directory, so do that explicitly. */ ++ PyObject *root = PyUnicode_DecodeFSDefault(mobile_config->private_root_utf8); ++ if (!root || PyList_Insert(PySys_GetObject("path"), 0, root) == -1) { ++ Py_XDECREF(root); ++ return RENPY_MOBILE_ERROR; ++ } ++ Py_DECREF(root); ++ /* Every host ABI call reacquires the GIL. Let audio/save workers run ++ while the game stack is suspended or the application is paused. */ ++ PyEval_SaveThread(); ++ return RENPY_MOBILE_OK; +} + void call_prepare_python(void) { - JNIEnv* env = (JNIEnv*) SDL_GetAndroidJNIEnv(); - jobject activity = (jobject) SDL_GetAndroidActivity(); + JNIEnv* env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jobject activity = (jobject) SDL_AndroidGetActivity(); diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch index 9c6238a4f..6f5228bd9 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch @@ -1,48 +1,66 @@ -diff --git a/runtime/librenpython.c b/runtime/librenpython.c ---- a/runtime/librenpython.c -+++ b/runtime/librenpython.c -@@ -4,6 +4,7 @@ +diff --git a/runtime/librenpython3.c b/runtime/librenpython3.c +index 6c7e426..1922883 100644 +--- a/runtime/librenpython3.c ++++ b/runtime/librenpython3.c +@@ -4,6 +4,12 @@ #include #include #include "Python.h" +#include "renpy_mobile_launcher.h" ++ ++/* Built into the same target CPython, never imported from a host wheel. */ ++PyMODINIT_FUNC PyInit__greenlet(void); ++PyMODINIT_FUNC PyInit__aether_host(void); ++ /** * A small note on encoding: This does as much as it can in UTF-8 mode, -@@ -552,6 +553,38 @@ +@@ -552,6 +558,50 @@ int EXPORT launcher_main(int argc, char **argv) { /** * This is called from the launcher executable, to start Ren'Py running. */ -+/* Host-owned entrypoint used by the AetherKiri lifecycle fork. The Renios -+ * prototype's launcher_main remains unchanged for the official app target. */ ++/* The host provides the packaged Python home and owns the application loop. */ +int EXPORT renpy_mobile_bootstrap(const renpy_mobile_config_t *mobile_config, + const renpy_mobile_host_t *host) { -+ if (!mobile_config || !host || mobile_config->struct_size < sizeof(*mobile_config) || -+ host->struct_size < sizeof(*host) || -+ mobile_config->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || -+ host->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || -+ !mobile_config->argv0_utf8 || !mobile_config->argv0_utf8[0]) { -+ return RENPY_MOBILE_INVALID_ARGUMENT; ++ if (!mobile_config || !host || !mobile_config->private_root_utf8 || ++ !mobile_config->game_root_utf8) return RENPY_MOBILE_INVALID_ARGUMENT; ++ if (Py_IsInitialized()) { ++ if (host->log_utf8) host->log_utf8(host->user_data, 2, ++ "Ren'Py interpreter reuse is not implemented; restart the host application for another game session"); ++ return RENPY_MOBILE_INVALID_STATE; + } -+ ++ if (PyImport_AppendInittab("_aether_greenlet", PyInit__greenlet) == -1 || ++ PyImport_AppendInittab("_aether_host", PyInit__aether_host) == -1) ++ return RENPY_MOBILE_ERROR; + char *argv[] = { (char *)mobile_config->argv0_utf8, NULL }; -+ preinitialize(1, 1, argv); ++ if (!argv[0]) return RENPY_MOBILE_INVALID_ARGUMENT; ++ PyPreConfig preconfig; ++ PyPreConfig_InitIsolatedConfig(&preconfig); ++ preconfig.utf8_mode = 1; ++ preconfig.use_environment = 0; ++ PyStatus status = Py_PreInitialize(&preconfig); ++ if (PyStatus_Exception(status)) return RENPY_MOBILE_ERROR; ++ init_librenpy(); ++ PyConfig_InitIsolatedConfig(&config); + set_renpy_platform(); -+ take_argv0(argv[0]); -+ search_python_home(); -+ + config.user_site_directory = 0; -+ config.parse_argv = 1; ++ config.parse_argv = 0; + config.install_signal_handlers = 0; -+ search_pyname(); -+ -+ char **new_argv = (char **)alloca(2 * sizeof(char *)); -+ new_argv[0] = argv[0]; -+ new_argv[1] = pyname; -+ PyConfig_SetBytesArgv(&config, 2, new_argv); -+ Py_InitializeFromConfig(&config); -+ -+ return renpy_mobile_init(mobile_config, host); ++ status = PyConfig_SetBytesString(&config, &config.home, ++ mobile_config->private_root_utf8); ++ if (!PyStatus_Exception(status)) status = Py_InitializeFromConfig(&config); ++ PyConfig_Clear(&config); ++ if (PyStatus_Exception(status)) return RENPY_MOBILE_ERROR; ++ PyObject *root = PyUnicode_DecodeFSDefault(mobile_config->private_root_utf8); ++ if (!root || PyList_Insert(PySys_GetObject("path"), 0, root) == -1) { ++ Py_XDECREF(root); ++ return RENPY_MOBILE_ERROR; ++ } ++ Py_DECREF(root); ++ /* Every host ABI call reacquires the GIL. Let audio/save workers run ++ while the game stack is suspended or the application is paused. */ ++ PyEval_SaveThread(); ++ return RENPY_MOBILE_OK; +} + int EXPORT launcher_main_wide(int argc, wchar_t **argv) { diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0004-android-offscreen-renderer.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0004-android-offscreen-renderer.patch new file mode 100644 index 000000000..89fff32e6 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0004-android-offscreen-renderer.patch @@ -0,0 +1,51 @@ +diff --git a/tasks/sdl2.py b/tasks/sdl2.py +index d266b21..6756b47 100644 +--- a/tasks/sdl2.py ++++ b/tasks/sdl2.py +@@ -59,6 +59,7 @@ def build(c: Context): + {% endif %} + + {% if c.platform == "android" %} ++ --enable-video-offscreen + + --disable-oss + --disable-alsa +diff --git a/patches/SDL2-2.0.20/aether-offscreen.patch b/patches/SDL2-2.0.20/aether-offscreen.patch +new file mode 100644 +index 00000000..a94b3993 +--- /dev/null ++++ b/patches/SDL2-2.0.20/aether-offscreen.patch +@@ -0,0 +1,33 @@ ++diff --git a/src/video/offscreen/SDL_offscreenopengl.c b/src/video/offscreen/SDL_offscreenopengl.c ++index 87641520..9c0552cf 100644 ++--- a/src/video/offscreen/SDL_offscreenopengl.c +++++ b/src/video/offscreen/SDL_offscreenopengl.c ++@@ -63,2 +63,11 @@ OFFSCREEN_GL_LoadLibrary(_THIS, const char* path) ++ { +++#ifdef __ANDROID__ +++ /* Android exposes EGL_DEFAULT_DISPLAY; device-enumeration extensions are +++ optional. Use a private pbuffer, never the host's ANativeWindow. */ +++ int ret = SDL_EGL_LoadLibrary(_this, path, (NativeDisplayType)EGL_DEFAULT_DISPLAY, 0); +++ if (ret != 0) return ret; +++ _this->egl_data->is_offscreen = SDL_TRUE; +++ _this->egl_data->egl_surfacetype = EGL_PBUFFER_BIT; +++ return SDL_EGL_ChooseConfig(_this); +++#else ++ int ret = SDL_EGL_LoadLibraryOnly(_this, path); ++@@ -85,2 +94,3 @@ OFFSCREEN_GL_LoadLibrary(_THIS, const char* path) ++ return 0; +++#endif ++ } ++diff --git a/src/video/SDL_egl.c b/src/video/SDL_egl.c ++index e10cf7c7..62004e3b 100644 ++--- a/src/video/SDL_egl.c +++++ b/src/video/SDL_egl.c ++@@ -279,3 +279,7 @@ SDL_EGL_UnloadLibrary(_THIS) ++ if (_this->egl_data->egl_display) { ++- _this->egl_data->eglTerminate(_this->egl_data->egl_display); +++ /* The embedded host may use this same default EGLDisplay. +++ eglTerminate would destroy its contexts and native surfaces. */ +++ if (!SDL_GetHintBoolean("AETHER_RENPY_EMBEDDED", SDL_FALSE)) { +++ _this->egl_data->eglTerminate(_this->egl_data->egl_display); +++ } ++ _this->egl_data->egl_display = NULL; diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index fc2b7eb03..7a61a2f61 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -1,71 +1,18 @@ -# Native lifecycle fork - -`renpy_mobile_lifecycle.c` is the first real native fork layer. It is linked -with the official Ren'Py native payload after the fork has performed Python -initialization and created the existing SDL window. It exports all seven -`renpy_mobile_*` symbols and: - -- imports `renpy.main.cooperative_start/tick/stop` -- resumes the same Ren'Py context for bounded host ticks -- returns the SDL window surface as a borrowed RGBA frame -- queues pointer/key events through SDL -- implements pause/resume/shutdown without terminating the process - -The file intentionally does not call any process or application entrypoint. -The Android/iOS launcher source still needs a small release-specific patch to -factor Python initialization out of `SDL_main`/`launcher_main`, call -`renpy_mobile_init` after initialization, and call `renpy_mobile_bind_window` -after the host-owned SDL window exists. Build it with the official -`tasks/renpython.py` Android `link_android` or iOS `link_ios` closure, then run -the symbol gate and device/simulator demo smoke before installing artifacts. - -Apply `0001-renpy-build-link.patch`, `0002-android-host-bootstrap.patch`, and -`0003-ios-host-bootstrap.patch` in the official `renpy-build` checkout -after copying this C file and `renpy_mobile_launcher.h` into its `runtime/` -directory. The task patch compiles the lifecycle object for Android and -archives it into the iOS `librenpython.a`; the two launcher patches add a -`renpy_mobile_bootstrap` entrypoint that performs the official Python -initialization and then enters the cooperative ABI. The existing -`link_android`/`link_ios` closure carries the exports into the native artifacts. - -This layer is not packaged automatically as a fake runtime. Apply all three -native patches to a fork checkout, then the symbol gate and device/simulator -demo smoke must pass before installing artifacts. - -## Full source-build command - -On Ubuntu 24.04 with the Android NDK r29 and the licensed iOS SDK archives -available to `renpy-build`, the complete opt-in flow is: - -```bash -git clone --depth=1 https://github.com/renpy/renpy-build.git /work/renpy-build -(cd /work/renpy-build && ./prepare.sh) -AETHERKIRI_RENPY_LIFECYCLE_FORK=1 \ - AETHERKIRI_RENPY_BUILD_IOS=1 \ - tools/run_renpy_mobile_source_build.sh \ - --mode build --renpy-build /work/renpy-build -``` - -The runner copies the lifecycle C file and ABI header, applies the task patch -and both platform bootstrap patches, runs the official Android/iOS build -tasks, and gates the canonical `.so`/`.a` outputs with the seven exported -symbols. Install the checked outputs into the staging tree only after that -gate: - -```bash -bridge/renpy_runtime/mobile_launcher/build.sh --install \ - --renpy-build /work/renpy-build \ - --stage /work/renpy-mobile-staged \ - --android-so-arm64 /work/renpy-build/renpy/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so \ - --android-so-armv7 /work/renpy-build/renpy/rapt/prototype/renpyandroid/src/main/jniLibs/armeabi-v7a/librenpython.so \ - --android-so-x86_64 /work/renpy-build/renpy/rapt/prototype/renpyandroid/src/main/jniLibs/x86_64/librenpython.so \ - --ios-debug-a /work/renpy-build/renpy/renios/prototype/prebuilt/debug/librenpython.a \ - --ios-release-a /work/renpy-build/renpy/renios/prototype/prebuilt/release/librenpython.a -RENPY_MOBILE_STAGE_TEST_ROOT=/work/renpy-mobile-staged \ - bash tools/test_stage_renpy_android_support.sh -bash tools/test_renpy_mobile_native_fork.sh -``` - -Without the fork flag, SDK archives, NDK, and iOS SDK inputs, the runner skips -or refuses the heavy build and the official blocking archives remain guarded -as `NOT_SUPPORTED`. +# Native lifecycle patch + +The source runner applies these patches to the pinned SDL2/Python3 +renpy-build revision, then copies `renpy_mobile_lifecycle.c` and the versioned +ABI header into that checkout. It compiles the shared native lifecycle and +linked greenlet for Android and every selected iOS target. The platform +bootstrap initializes CPython without calling the process launchers. + +`renpy_mobile_lifecycle.c` owns the copied host callback table and the borrowed +frame byte storage. It invokes the full cooperative Python entrypoints, +preserves pointer/key/text actions, publishes renderer screenshots and +returns observable normal-exit and text-input state. Window binding connects +the real SDL renderer window to the Android EGL context handoff. + +These sources need actual target compilation and cloud-device gameplay +acceptance. A successful host compilation or exported-symbol check is not +mobile playability evidence. The iOS MetalANGLE host rendering integration +remains blocked and the provider refuses it explicitly. diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c b/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c index 36eb9e100..c759af082 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c @@ -1,171 +1,327 @@ -/* - * Host-owned Ren'Py lifecycle implementation. - * - * This translation unit is linked into a fork of renpy-build's - * librenpython_android.c/librenpython.c after those launchers have performed - * Python initialization. It deliberately never calls SDL_main, launcher_main, - * Py_RunMain, SDL_RunApp, SDL_UIKitRunApp, UIApplicationMain, or exits the - * process. The fork must call renpy_mobile_bind_window() after Ren'Py has - * created the already-owned SDL window. - */ -#include +/* In-process lifecycle for the patched Ren'Py launcher. Rendering and events + * remain on Ren'Py's owning thread inside its stackful cooperative loop. + * OpenGL windows have no SDL software surface: cooperative_frame reads the + * renderer's screenshot before yielding to Godot. */ #include +#include #include #include "renpy_mobile_launcher.h" +#if defined(__ANDROID__) +#include +#endif +#if defined(__APPLE__) +#include +#endif -static const renpy_mobile_host_t *aether_host; +static renpy_mobile_host_t aether_host; static PyObject *aether_main_module; -static SDL_Window *aether_window; +static PyObject *aether_frame_bytes; +static unsigned long aether_owner_thread; static int aether_started; static int aether_paused; -static unsigned long long aether_frame_serial; - -static int launcher_error(void) { - return RENPY_MOBILE_ERROR; +static void *aether_window; +#if defined(__ANDROID__) +static EGLDisplay aether_display = EGL_NO_DISPLAY; +static EGLContext aether_context = EGL_NO_CONTEXT; +static EGLSurface aether_draw_surface = EGL_NO_SURFACE; +static EGLSurface aether_read_surface = EGL_NO_SURFACE; +typedef struct graphics_scope { + EGLDisplay display; + EGLContext context; + EGLSurface draw_surface; + EGLSurface read_surface; + EGLenum api; +} graphics_scope; +static int begin_graphics(graphics_scope *scope) { + scope->display = eglGetCurrentDisplay(); + scope->context = eglGetCurrentContext(); + scope->draw_surface = eglGetCurrentSurface(EGL_DRAW); + scope->read_surface = eglGetCurrentSurface(EGL_READ); + scope->api = eglQueryAPI(); + if (aether_context != EGL_NO_CONTEXT) { + eglBindAPI(EGL_OPENGL_ES_API); + if (!eglMakeCurrent(aether_display, aether_draw_surface, aether_read_surface, aether_context)) { + eglBindAPI(scope->api); + return RENPY_MOBILE_ERROR; + } + } + return RENPY_MOBILE_OK; +} +static int end_graphics(const graphics_scope *scope) { + EGLBoolean restored = EGL_TRUE; + if (scope->display != EGL_NO_DISPLAY) { + eglBindAPI(scope->api); + restored = eglMakeCurrent(scope->display, scope->draw_surface, scope->read_surface, scope->context); + } else { + EGLDisplay display = aether_display != EGL_NO_DISPLAY ? aether_display : eglGetCurrentDisplay(); + if (display != EGL_NO_DISPLAY) restored = eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + eglBindAPI(scope->api); + } + if (!restored && aether_host.log_utf8) aether_host.log_utf8(aether_host.user_data, 3, + "Ren'Py failed to restore the host EGL context after a lifecycle call"); + return restored ? RENPY_MOBILE_OK : RENPY_MOBILE_ERROR; } +#else +typedef struct graphics_scope { int unused; } graphics_scope; +static int begin_graphics(graphics_scope *scope) { (void)scope; return RENPY_MOBILE_OK; } +static int end_graphics(const graphics_scope *scope) { (void)scope; return RENPY_MOBILE_OK; } +#endif -static int call_main_noargs(const char *name, PyObject **result) { - if (!aether_main_module) return RENPY_MOBILE_INVALID_STATE; - PyObject *value = PyObject_CallMethod(aether_main_module, name, NULL); - if (!value) { - PyErr_Clear(); - return launcher_error(); +int renpy_mobile_bind_window(void *window) { + if (PyThread_get_thread_ident() != aether_owner_thread) return RENPY_MOBILE_INVALID_ARGUMENT; + aether_window = window; +#if defined(__ANDROID__) + if (!window) { + aether_display = EGL_NO_DISPLAY; + aether_context = EGL_NO_CONTEXT; + aether_draw_surface = EGL_NO_SURFACE; + aether_read_surface = EGL_NO_SURFACE; + return RENPY_MOBILE_OK; } - if (result) *result = value; - else Py_DECREF(value); + aether_display = eglGetCurrentDisplay(); + aether_context = eglGetCurrentContext(); + aether_draw_surface = eglGetCurrentSurface(EGL_DRAW); + aether_read_surface = eglGetCurrentSurface(EGL_READ); + if (aether_display == EGL_NO_DISPLAY || aether_context == EGL_NO_CONTEXT || + aether_draw_surface == EGL_NO_SURFACE) return RENPY_MOBILE_INVALID_STATE; +#endif return RENPY_MOBILE_OK; } -/* Called by the patched SDL/desktop initialization after it creates the host - * window. The pointer is borrowed for the lifetime of the SDL window. */ -int renpy_mobile_bind_window(SDL_Window *window) { - if (!window) return RENPY_MOBILE_INVALID_ARGUMENT; - aether_window = window; +static PyObject *host_bind_window(PyObject *self, PyObject *args) { + unsigned long long pointer; + (void)self; + if (!PyArg_ParseTuple(args, "K", &pointer)) return NULL; + return PyLong_FromLong(renpy_mobile_bind_window((void *)(uintptr_t)pointer)); +} +static PyMethodDef host_methods[] = { + {"bind_window", host_bind_window, METH_VARARGS, "Bind the actual offscreen Ren'Py renderer window."}, + {NULL, NULL, 0, NULL} +}; +static struct PyModuleDef host_module = { + PyModuleDef_HEAD_INIT, "_aether_host", NULL, -1, host_methods, + NULL, NULL, NULL, NULL +}; +PyMODINIT_FUNC PyInit__aether_host(void) { return PyModule_Create(&host_module); } + +static void report_python_error(const char *operation) { + PyObject *type = NULL, *value = NULL, *traceback = NULL; + PyErr_Fetch(&type, &value, &traceback); + PyErr_NormalizeException(&type, &value, &traceback); + PyObject *text = value ? PyObject_Str(value) : NULL; + const char *detail = text ? PyUnicode_AsUTF8(text) : NULL; + if (aether_host.log_utf8) { + aether_host.log_utf8(aether_host.user_data, 3, operation); + if (detail) aether_host.log_utf8(aether_host.user_data, 3, detail); + } + Py_XDECREF(text); + Py_XDECREF(type); + Py_XDECREF(value); + Py_XDECREF(traceback); + PyErr_Clear(); +} + +static int check_owner(void) { + if (!aether_started || !Py_IsInitialized()) return RENPY_MOBILE_INVALID_STATE; + if (PyThread_get_thread_ident() != aether_owner_thread) { + if (aether_host.log_utf8) aether_host.log_utf8(aether_host.user_data, 3, + "Ren'Py lifecycle called from a different thread than init"); + return RENPY_MOBILE_INVALID_STATE; + } return RENPY_MOBILE_OK; } +static int call_noargs(const char *name) { + int status = check_owner(); + if (status != RENPY_MOBILE_OK) return status; + graphics_scope graphics; + status = begin_graphics(&graphics); + if (status != RENPY_MOBILE_OK) return status; + PyGILState_STATE gil = PyGILState_Ensure(); + PyObject *value = PyObject_CallMethod(aether_main_module, name, NULL); + if (!value) { report_python_error(name); status = RENPY_MOBILE_ERROR; } + Py_XDECREF(value); + PyGILState_Release(gil); + if (end_graphics(&graphics) != RENPY_MOBILE_OK) status = RENPY_MOBILE_ERROR; + return status; +} + int renpy_mobile_init(const renpy_mobile_config_t *config, const renpy_mobile_host_t *host) { if (!config || !host || config->struct_size < sizeof(*config) || host->struct_size < sizeof(*host) || config->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || - host->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION) { - return RENPY_MOBILE_INVALID_ARGUMENT; - } + host->abi_version != RENPY_MOBILE_LAUNCHER_ABI_VERSION || + !config->game_root_utf8 || !config->game_root_utf8[0]) return RENPY_MOBILE_INVALID_ARGUMENT; if (aether_started) return RENPY_MOBILE_INVALID_STATE; if (!Py_IsInitialized()) return RENPY_MOBILE_INVALID_STATE; +#if defined(__APPLE__) && TARGET_OS_IPHONE + if (host->log_utf8) host->log_utf8(host->user_data, 3, + "Ren'Py iOS rendering requires a verified host-isolated MetalANGLE backend; stock SDL UIKit windows are disabled"); + return RENPY_MOBILE_NOT_IMPLEMENTED; +#endif - aether_host = host; - aether_main_module = PyImport_ImportModule("renpy.main"); - if (!aether_main_module) { - PyErr_Clear(); - aether_host = NULL; - return launcher_error(); - } - + /* The caller is allowed to pass a temporary callback table. */ + aether_host = *host; + PyGILState_STATE gil = PyGILState_Ensure(); + aether_main_module = PyImport_ImportModule("renpy.aether_mobile"); PyObject *started = NULL; - int status = call_main_noargs("cooperative_start", &started); - if (status != RENPY_MOBILE_OK) { - Py_DECREF(aether_main_module); - aether_main_module = NULL; - aether_host = NULL; - return status; + if (aether_main_module) started = PyObject_CallMethod(aether_main_module, + "cooperative_start", "ss", config->game_root_utf8, + config->argv0_utf8 ? config->argv0_utf8 : "AetherKiri"); + if (!started) { + report_python_error("Ren'Py cooperative initialization failed"); + Py_CLEAR(aether_main_module); + memset(&aether_host, 0, sizeof(aether_host)); + PyGILState_Release(gil); + return RENPY_MOBILE_ERROR; } - Py_XDECREF(started); + Py_DECREF(started); + aether_owner_thread = PyThread_get_thread_ident(); aether_started = 1; aether_paused = 0; - aether_frame_serial = 0; + PyGILState_Release(gil); return RENPY_MOBILE_OK; } int renpy_mobile_tick(uint32_t budget_ms) { - if (!aether_started || aether_paused) return RENPY_MOBILE_INVALID_STATE; - PyObject *result = PyObject_CallMethod(aether_main_module, - "cooperative_tick", "I", - budget_ms); - if (!result) { - PyErr_Clear(); - return launcher_error(); - } - int status = RENPY_MOBILE_OK; - if (PyUnicode_Check(result)) { + int status = check_owner(); + if (status != RENPY_MOBILE_OK || aether_paused) return status; + graphics_scope graphics; + status = begin_graphics(&graphics); + if (status != RENPY_MOBILE_OK) return status; + PyGILState_STATE gil = PyGILState_Ensure(); + PyObject *result = PyObject_CallMethod(aether_main_module, "cooperative_tick", "I", budget_ms); + if (!result) { report_python_error("Ren'Py cooperative tick failed"); status = RENPY_MOBILE_ERROR; } + else if (PyUnicode_Check(result)) { const char *value = PyUnicode_AsUTF8(result); - if (value && strcmp(value, "finished") == 0) status = RENPY_MOBILE_OK; - else if (value && strcmp(value, "yield") == 0) status = RENPY_MOBILE_OK; - else if (value && strcmp(value, "not_started") == 0) { - status = RENPY_MOBILE_INVALID_STATE; - } - } - Py_DECREF(result); + if (!value || !strcmp(value, "not_started")) status = RENPY_MOBILE_INVALID_STATE; + else if (!strcmp(value, "finished")) status = RENPY_MOBILE_FINISHED; + else if (strcmp(value, "yield")) status = RENPY_MOBILE_ERROR; + } else status = RENPY_MOBILE_ERROR; + Py_XDECREF(result); + PyGILState_Release(gil); + if (end_graphics(&graphics) != RENPY_MOBILE_OK) status = RENPY_MOBILE_ERROR; return status; } int renpy_mobile_frame(renpy_mobile_frame_t *out_frame) { - if (!out_frame || out_frame->struct_size < sizeof(*out_frame)) { - return RENPY_MOBILE_INVALID_ARGUMENT; - } - if (!aether_started || !aether_window) return RENPY_MOBILE_INVALID_STATE; - - SDL_Surface *surface = SDL_GetWindowSurface(aether_window); - if (!surface || !surface->pixels || surface->w <= 0 || surface->h <= 0) { - return RENPY_MOBILE_INVALID_STATE; - } - out_frame->rgba = (const uint8_t *)surface->pixels; - out_frame->width = (uint32_t)surface->w; - out_frame->height = (uint32_t)surface->h; - out_frame->stride = (uint32_t)surface->pitch; - out_frame->serial = ++aether_frame_serial; - if (aether_host && aether_host->present_rgba) { - aether_host->present_rgba(aether_host->user_data, out_frame->rgba, - out_frame->width, out_frame->height, - out_frame->stride); + if (!out_frame || out_frame->struct_size < sizeof(*out_frame)) return RENPY_MOBILE_INVALID_ARGUMENT; + int status = check_owner(); + if (status != RENPY_MOBILE_OK) return status; + PyGILState_STATE gil = PyGILState_Ensure(); + PyObject *frame = PyObject_CallMethod(aether_main_module, "cooperative_frame", NULL); + if (!frame) { report_python_error("Ren'Py renderer screenshot failed"); status = RENPY_MOBILE_ERROR; } + else if (frame == Py_None) status = RENPY_MOBILE_INVALID_STATE; + else { + PyObject *pixels = NULL; + unsigned int width = 0, height = 0; + unsigned long long serial = 0; + if (!PyArg_ParseTuple(frame, "OIIK", &pixels, &width, &height, &serial) || + !PyBytes_Check(pixels) || !width || !height || width > UINT32_MAX / 4u || + (uint64_t)width * height * 4u > (uint64_t)PY_SSIZE_T_MAX || + PyBytes_GET_SIZE(pixels) != (Py_ssize_t)((uint64_t)width * height * 4u)) { + PyErr_Clear(); status = RENPY_MOBILE_ERROR; + } else { + Py_INCREF(pixels); + Py_XSETREF(aether_frame_bytes, pixels); + out_frame->rgba = (const uint8_t *)PyBytes_AS_STRING(aether_frame_bytes); + out_frame->width = width; + out_frame->height = height; + out_frame->stride = width * 4u; + out_frame->serial = serial; + } } - return RENPY_MOBILE_OK; + Py_XDECREF(frame); + PyGILState_Release(gil); + return status; } int renpy_mobile_input(const renpy_mobile_input_t *event) { - if (!event || event->struct_size < sizeof(*event)) { - return RENPY_MOBILE_INVALID_ARGUMENT; - } - if (!aether_started || aether_paused) return RENPY_MOBILE_INVALID_STATE; - - SDL_Event sdl_event; - SDL_zero(sdl_event); - if (event->type == RENPY_MOBILE_INPUT_POINTER) { - sdl_event.type = SDL_EVENT_FINGER_MOTION; - sdl_event.tfinger.x = event->x; - sdl_event.tfinger.y = event->y; - sdl_event.tfinger.pressure = event->pressure; - } else if (event->type == RENPY_MOBILE_INPUT_KEY) { - sdl_event.type = (event->value == 5) ? SDL_EVENT_KEY_DOWN - : SDL_EVENT_KEY_UP; - sdl_event.key.key = (SDL_Keycode)event->code; - } else { - return RENPY_MOBILE_INVALID_ARGUMENT; - } - return SDL_PushEvent(&sdl_event) ? RENPY_MOBILE_OK : launcher_error(); + if (!event || event->struct_size < sizeof(*event)) return RENPY_MOBILE_INVALID_ARGUMENT; + int status = check_owner(); + if (status != RENPY_MOBILE_OK) return status; + PyGILState_STATE gil = PyGILState_Ensure(); + /* Python owns text before control can yield, unlike a borrowed SDL3 + * text-event pointer. Event kinds retain press/release/scroll semantics. */ + PyObject *input = Py_BuildValue("{s:I,s:i,s:i,s:i,s:f,s:f,s:f,s:f,s:I,s:I,s:s,s:K}", + "type", event->type, "value", event->value, "device_id", event->device_id, + "code", event->code, "x", event->x, "y", event->y, + "delta_x", event->delta_x, "delta_y", event->delta_y, + "modifiers", event->modifiers, "repeat", event->repeat, + "text", event->text_utf8 ? event->text_utf8 : "", + "timestamp_ns", (unsigned long long)event->timestamp_ns); + PyObject *result = input ? PyObject_CallMethod(aether_main_module, "cooperative_input", "O", input) : NULL; + if (!result) { report_python_error("Ren'Py input delivery failed"); status = RENPY_MOBILE_ERROR; } + Py_XDECREF(result); + Py_XDECREF(input); + PyGILState_Release(gil); + return status; } int renpy_mobile_pause(void) { - if (!aether_started) return RENPY_MOBILE_INVALID_STATE; - aether_paused = 1; - return RENPY_MOBILE_OK; + int status = call_noargs("cooperative_pause"); + if (status == RENPY_MOBILE_OK) aether_paused = 1; + return status; } int renpy_mobile_resume(void) { - if (!aether_started) return RENPY_MOBILE_INVALID_STATE; - aether_paused = 0; - return RENPY_MOBILE_OK; + int status = call_noargs("cooperative_resume"); + if (status == RENPY_MOBILE_OK) aether_paused = 0; + return status; +} + +int renpy_mobile_text_input_state(uint32_t *active) { + if (!active) return RENPY_MOBILE_INVALID_ARGUMENT; + int status = check_owner(); + if (status != RENPY_MOBILE_OK) return status; + PyGILState_STATE gil = PyGILState_Ensure(); + PyObject *result = PyObject_CallMethod(aether_main_module, "cooperative_text_input_state", NULL); + if (!result) { report_python_error("Ren'Py text input state failed"); status = RENPY_MOBILE_ERROR; } + else { + int truth = PyObject_IsTrue(result); + if (truth < 0) { report_python_error("Ren'Py text input state failed"); status = RENPY_MOBILE_ERROR; } + else *active = truth ? 1u : 0u; + } + Py_XDECREF(result); + PyGILState_Release(gil); + return status; +} + +int renpy_mobile_set_surface_size(uint32_t width, uint32_t height) { + if (!width || !height) return RENPY_MOBILE_INVALID_ARGUMENT; + int status = check_owner(); + if (status != RENPY_MOBILE_OK) return status; + PyGILState_STATE gil = PyGILState_Ensure(); + PyObject *result = PyObject_CallMethod(aether_main_module, "cooperative_set_surface_size", "II", width, height); + if (!result) { report_python_error("Ren'Py surface resize failed"); status = RENPY_MOBILE_ERROR; } + Py_XDECREF(result); + PyGILState_Release(gil); + return status; } void renpy_mobile_shutdown(void) { - if (aether_main_module && Py_IsInitialized()) { - (void)call_main_noargs("cooperative_stop", NULL); - Py_DECREF(aether_main_module); + if (!aether_started) return; + if (check_owner() != RENPY_MOBILE_OK) { + // Destruction on the wrong thread cannot switch a greenlet, but must + // never retain callbacks whose user_data is about to be destroyed. + memset(&aether_host, 0, sizeof(aether_host)); + return; } - aether_main_module = NULL; - aether_host = NULL; - aether_window = NULL; + (void)call_noargs("cooperative_stop"); + PyGILState_STATE gil = PyGILState_Ensure(); + Py_CLEAR(aether_frame_bytes); + Py_CLEAR(aether_main_module); + memset(&aether_host, 0, sizeof(aether_host)); aether_started = 0; aether_paused = 0; + aether_owner_thread = 0; + aether_window = NULL; +#if defined(__ANDROID__) + aether_display = EGL_NO_DISPLAY; + aether_context = EGL_NO_CONTEXT; + aether_draw_surface = EGL_NO_SURFACE; + aether_read_surface = EGL_NO_SURFACE; +#endif + PyGILState_Release(gil); } diff --git a/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch b/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch index b8b71e916..8804d0f0a 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch @@ -1,140 +1,621 @@ +diff --git a/renpy/bootstrap.py b/renpy/bootstrap.py +index 4d3b125..33f2cf7 100644 +--- a/renpy/bootstrap.py ++++ b/renpy/bootstrap.py +@@ -445,7 +445,7 @@ def bootstrap(renpy_base): + if not renpy.emscripten: + subprocess.Popen.__del__ = popen_del # type: ignore + +- if renpy.android: ++ if renpy.android and not renpy.aether_mobile.cooperative_active(): + from jnius import autoclass # type: ignore + + import android diff --git a/renpy/display/core.py b/renpy/display/core.py -index 93b0aba..a4f6476 100644 +index 89c74d1..ab5c04b 100644 --- a/renpy/display/core.py +++ b/renpy/display/core.py -@@ -2783,6 +2783,9 @@ class Interface: +@@ -32,6 +32,7 @@ import platform + + import renpy + import renpy.pygame as pygame ++import renpy.aether_mobile as aether_mobile + + # Imports for backward compatibility. + from renpy.display.position import absolute as absolute, position as position +@@ -1301,6 +1302,9 @@ class Interface: + self.surftree = surftree + self.fullscreen_video = fullscreen_video + ++ if draw: ++ aether_mobile.cooperative_capture(surftree) ++ + if self.first_frame: + self.after_first_frame() + self.first_frame = False +@@ -1630,7 +1634,14 @@ class Interface: + + self.check_background_screenshot() + +- if renpy.emscripten: ++ if aether_mobile.cooperative_active(): ++ while True: ++ ev = pygame.event.poll() ++ if ev.type != pygame.NOEVENT: ++ break ++ aether_mobile.cooperative_checkpoint(force=True) ++ ++ elif renpy.emscripten: + emscripten.sleep(0) + + while True: +@@ -1918,6 +1929,9 @@ class Interface: + GL context is created properly. + """ + ++ if aether_mobile.cooperative_active(): ++ return ++ + from jnius import autoclass + + SDLActivity = autoclass("org.libsdl.app.SDLActivity") +@@ -1936,6 +1950,10 @@ class Interface: + Handles the SDL2 suspend process. + """ + ++ if aether_mobile.cooperative_active(): ++ # The host owns background/resume and saves/pauses through the ABI. ++ return False ++ + if ev.type != pygame.APP_WILLENTERBACKGROUND: + return False + +@@ -2774,6 +2792,7 @@ class Interface: can_block = False - + while rv is None: -+ if renpy.main.cooperative_tick_expired(): -+ raise renpy.execution.CooperativeYield() -+ ++ aether_mobile.cooperative_checkpoint() renpy.plog(1, "start of interact while loop") - + renpy.execution.not_infinite_loop(10) +diff --git a/renpy/display/tts.py b/renpy/display/tts.py +index 34f6576..1a49ea5 100644 +--- a/renpy/display/tts.py ++++ b/renpy/display/tts.py +@@ -83,7 +83,10 @@ class AndroidTTS(object): + + PythonSDLActivity = autoclass("org.renpy.android.PythonSDLActivity") + self.TextToSpeech = autoclass("android.speech.tts.TextToSpeech") +- self.tts = self.TextToSpeech(PythonSDLActivity.mActivity, None) ++ activity = PythonSDLActivity.mActivity ++ if renpy.aether_mobile.cooperative_active(): ++ activity = activity.getActivity() ++ self.tts = self.TextToSpeech(activity, None) + + def speak(self, s): + self.tts.speak(s, self.TextToSpeech.QUEUE_FLUSH, None) diff --git a/renpy/execution.py b/renpy/execution.py -index 056c11e..535aa97 100644 +index 9da4b1e..375ae55 100644 --- a/renpy/execution.py +++ b/renpy/execution.py -@@ -1034,13 +1034,17 @@ class RestartContext(BaseException): - """ - - -+class CooperativeYield(BaseException): -+ """Yield from a bounded host tick while preserving the active context.""" -+ -+ - class RestartTopContext(BaseException): - """ - Restarts the current context. - """ - - --def run_context(top): -+def run_context(top, cooperative=False): - """ - Runs the current context until it can't be run anymore, while handling - various exceptions. -@@ -1057,6 +1061,8 @@ def run_context(top): - +@@ -609,6 +609,8 @@ class Context(renpy.object.Object): + first = True + + while node: ++ # Bound non-interactive Ren'Py AST loops as well as UI waits. ++ renpy.aether_mobile.cooperative_checkpoint() + if node.name == self.come_from_name: + self.come_from_name = None + node = self.call(self.come_from_label, return_site=node.name) +@@ -1047,6 +1049,8 @@ def run_context(top): + try: try: -+ if cooperative and renpy.main.cooperative_tick_expired(): -+ raise CooperativeYield() ++ import renpy.aether_mobile ++ renpy.aether_mobile.cooperative_checkpoint() context.run() - + rv = renpy.store._return -diff --git a/renpy/main.py b/renpy/main.py -index bd0d6f7..19bd8f5 100644 ---- a/renpy/main.py -+++ b/renpy/main.py -@@ -54,7 +54,49 @@ def reset_clock(): - last_clock = time.time() - - --def run(restart): -+# Host-owned cooperative lifecycle state. The default launcher never enables this. -+_cooperative_deadline = None -+_cooperative_started = False +diff --git a/renpy/gl2/gl2draw.pyx b/renpy/gl2/gl2draw.pyx +index cb66af0..f8874b8 100644 +--- a/renpy/gl2/gl2draw.pyx ++++ b/renpy/gl2/gl2draw.pyx +@@ -364,7 +364,9 @@ cdef class GL2Draw: + renpy.display.log.write("GL Disabled.") + return False + +- if renpy.mobile: ++ if renpy.aether_mobile.cooperative_active(): ++ physical_size = renpy.aether_mobile.cooperative_surface_size() or virtual_size ++ elif renpy.mobile: + physical_size = (None, None) + elif renpy.game.preferences.physical_size is None: + physical_size = (renpy.config.physical_width, renpy.config.physical_height) +@@ -373,7 +375,9 @@ cdef class GL2Draw: + + pwidth, pheight = self.select_physical_size(physical_size) + +- if renpy.android or renpy.ios: ++ if renpy.aether_mobile.cooperative_active(): ++ fullscreen = False ++ elif renpy.android or renpy.ios: + fullscreen = True + elif renpy.emscripten: + fullscreen = False +diff --git a/renpy/pygame/display.pyx b/renpy/pygame/display.pyx +index 85d16d0..a732f2c 100644 +--- a/renpy/pygame/display.pyx ++++ b/renpy/pygame/display.pyx +@@ -207,7 +207,9 @@ cdef class Window: + if self.gl_context == NULL: + raise error() + +- SDL_GL_MakeCurrent(self.window, self.gl_context) ++ if SDL_GL_MakeCurrent(self.window, self.gl_context) != 0: ++ raise error() ++ self.aether_bind_gl_window() + + if not ios: + # Try setting the swap interval - first positive, then negated +@@ -262,6 +264,10 @@ cdef class Window: + This should be called before the window is deleted. + """ + ++ if renpy.aether_mobile.cooperative_active(): ++ import _aether_host ++ _aether_host.bind_window(0) + + if self.gl_context != NULL: + SDL_GL_DeleteContext(self.gl_context) + +@@ -334,8 +340,20 @@ cdef class Window: + if self.gl_context == NULL: + raise error() + ++ if self.gl_context: ++ if SDL_GL_MakeCurrent(self.window, self.gl_context) != 0: ++ raise error() ++ self.aether_bind_gl_window() + self.create_surface() + ++ def aether_bind_gl_window(self): ++ # Bind before any engine checkpoint can return to the host, including ++ # initialization before the first frame and context recreation. ++ if renpy.aether_mobile.cooperative_active(): ++ import _aether_host ++ if _aether_host.bind_window(self.window) != 0: ++ raise error("Could not bind the Ren'Py renderer window") + -+def cooperative_begin_tick(budget_ms): -+ global _cooperative_deadline -+ if budget_ms is None or budget_ms < 0: -+ _cooperative_deadline = None -+ else: -+ _cooperative_deadline = time.monotonic() + (budget_ms / 1000.0) -+ return _cooperative_deadline + def recreate_gl_context(self, always=False): + """ + Check to see if the GL context was lost, and re-create it if it was. +@@ -350,7 +368,9 @@ cdef class Window: + if self.gl_context == NULL: + raise error() + +- SDL_GL_MakeCurrent(self.window, self.gl_context) ++ if SDL_GL_MakeCurrent(self.window, self.gl_context) != 0: ++ raise error() ++ self.aether_bind_gl_window() + + return True + +diff --git a/renpy/pygame/key.pyx b/renpy/pygame/key.pyx +index ca6b0ba..faa13bf 100644 +--- a/renpy/pygame/key.pyx ++++ b/renpy/pygame/key.pyx +@@ -17,6 +17,7 @@ + # 3. This notice may not be removed or altered from any source distribution. + + from sdl2 cimport * ++import renpy + from libc.stdlib cimport malloc, free + from libc.string cimport memcpy + from renpy.pygame.display cimport Window, main_window +@@ -49,6 +50,8 @@ cdef class KeyboardState: + memcpy(self.data, state, self.numkeys) + + def __getitem__(self, int key): ++ if renpy.aether_mobile.cooperative_active(): ++ return renpy.aether_mobile.cooperative_key_pressed(key) + if SDLK_DELETE < key < SDLK_CAPSLOCK: + raise IndexError("Out of range.") + +@@ -60,6 +63,8 @@ cdef class KeyboardState: + + + def get_focused(): ++ if renpy.aether_mobile.cooperative_active(): ++ return True + return SDL_GetKeyboardFocus() != NULL + + def get_pressed(): +@@ -69,9 +74,14 @@ def get_pressed(): + return KeyboardState() + + def get_mods(): ++ if renpy.aether_mobile.cooperative_active(): ++ return renpy.aether_mobile.cooperative_modifiers() + return SDL_GetModState() + + def set_mods(state): ++ if renpy.aether_mobile.cooperative_active(): ++ renpy.aether_mobile.cooperative_set_modifiers(state) ++ return + SDL_SetModState(state) + + def set_repeat(delay=0, interval=0): +diff --git a/renpy/pygame/mouse.pyx b/renpy/pygame/mouse.pyx +index aa95777..6936194 100644 +--- a/renpy/pygame/mouse.pyx ++++ b/renpy/pygame/mouse.pyx +@@ -17,6 +17,7 @@ + # 3. This notice may not be removed or altered from any source distribution. + + from sdl2 cimport * ++import renpy + from renpy.pygame.display cimport main_window + from renpy.pygame.surface cimport Surface + +@@ -34,12 +35,16 @@ def reset(): + active_cursor = None + + def get_pressed(): ++ if renpy.aether_mobile.cooperative_active(): ++ return renpy.aether_mobile.cooperative_mouse_pressed() + cdef Uint32 state = SDL_GetMouseState(NULL, NULL) + return (1 if state & SDL_BUTTON_LMASK else 0, + 1 if state & SDL_BUTTON_MMASK else 0, + 1 if state & SDL_BUTTON_RMASK else 0) + + def get_pos(): ++ if renpy.aether_mobile.cooperative_active(): ++ return renpy.aether_mobile.cooperative_mouse_pos() + cdef int x, y + SDL_GetMouseState(&x, &y) + return (x, y) +@@ -60,6 +65,8 @@ def set_visible(visible): + SDL_SetRelativeMouseMode(SDL_FALSE if visible else SDL_TRUE) + + def get_focused(): ++ if renpy.aether_mobile.cooperative_active(): ++ return True + return SDL_GetMouseFocus() != NULL + + def set_cursor(size, hotspot, xormasks, andmasks): +diff --git a/renpy/pygame/surface.pyx b/renpy/pygame/surface.pyx +index 889c908..bfd94f3 100644 +--- a/renpy/pygame/surface.pyx ++++ b/renpy/pygame/surface.pyx +@@ -18,6 +18,7 @@ + # 3. This notice may not be removed or altered from any source distribution. + + from libc.string cimport memmove ++from cpython.bytes cimport PyBytes_FromStringAndSize, PyBytes_AS_STRING + from sdl2 cimport * + + from renpy.pygame.color cimport map_color, get_color +@@ -835,6 +836,17 @@ cdef class Surface: + def get_view(self, kind='2'): + raise error("Surface.get_view is not supported.") + ++ def aether_copy_pixels(self): ++ """Copy visible rows, excluding Surface pitch and subsurface padding.""" ++ cdef int row_bytes = self.surface.w * self.surface.format.BytesPerPixel ++ cdef bytes result = PyBytes_FromStringAndSize(NULL, row_bytes * self.surface.h) ++ cdef char *target = PyBytes_AS_STRING(result) ++ cdef char *source = self.surface.pixels ++ cdef int row ++ for row in range(self.surface.h): ++ memmove(target + row * row_bytes, source + row * self.surface.pitch, row_bytes) ++ return result + + def get_buffer(self): + cdef Uint8 *pixels = self.surface.pixels + return pixels[self.surface.h * self.surface.pitch] +diff --git a/renpy/aether_mobile.py b/renpy/aether_mobile.py +new file mode 100644 +index 00000000..017f1fb5 +--- /dev/null ++++ b/renpy/aether_mobile.py +@@ -0,0 +1,300 @@ ++"""Host-owned Ren'Py loop; all SDL/GL work stays on the calling thread. + -+def cooperative_tick_expired(): -+ return _cooperative_deadline is not None and time.monotonic() >= _cooperative_deadline ++The native launcher links greenlet with its target CPython. No exception is ++used to yield: the complete script, screen and Cython stacks remain suspended. ++""" ++import importlib.util ++import os ++import sys ++import threading ++import time + ++try: ++ from _aether_greenlet import getcurrent, greenlet ++except ImportError: ++ getcurrent = greenlet = None + -+def cooperative_end_tick(): -+ global _cooperative_deadline -+ _cooperative_deadline = None ++_owner = None ++_host = None ++_game = None ++_deadline = None ++_frame = None ++_serial = 0 ++_paused = False ++_stopping = False ++_error = None ++_input = [] ++_buttons = [False, False, False] ++_pointer = (0, 0) ++_surface_size = None ++_keys = {} ++_modifiers = 0 + + -+def cooperative_shutdown(): -+ """Release Ren'Py resources without terminating the host process.""" -+ try: -+ renpy.display.tts.tts(None) -+ except Exception: -+ pass -+ try: -+ if renpy.display.draw: -+ renpy.display.im.cache.quit() -+ renpy.display.draw.quit() -+ except Exception: -+ pass ++def cooperative_active(): ++ return _game is not None ++ ++ ++def _check_owner(): ++ if _owner is not None and threading.get_ident() != _owner: ++ raise RuntimeError("Ren'Py mobile SDL/GL must run on its original host thread") ++ ++ ++def cooperative_start(game_root, argv0): ++ """Create a suspended game. Initialization starts inside the first tick.""" ++ global _owner, _host, _game, _paused, _stopping, _error, _frame, _serial ++ if greenlet is None: ++ raise RuntimeError("Target CPython is missing the linked greenlet runtime") ++ if _game is not None: ++ raise RuntimeError("Ren'Py mobile game is already started") ++ if not os.path.isdir(game_root) or not os.path.isfile(argv0): ++ raise ValueError("game directory or packaged Ren'Py bootstrap is missing") ++ _owner = threading.get_ident() ++ _host = getcurrent() ++ _paused = _stopping = False ++ _error = _frame = None ++ _serial = 0 ++ _game = greenlet(lambda: _run(game_root, argv0), parent=_host) ++ ++ ++def _run(game_root, argv0): ++ global _error ++ saved_argv = sys.argv + try: -+ renpy.audio.audio.quit() -+ except Exception: -+ pass -+ cooperative_end_tick() -+ -+ -+def run(restart, cooperative=False): - """ - This is called during a single run of the script. Restarting the script - will cause this to change. -@@ -129,7 +171,37 @@ def run(restart): - - log_clock("Running {}".format(start_label)) - -- renpy.execution.run_context(True) -+ renpy.execution.run_context(True, cooperative=cooperative) -+ -+ -+def cooperative_start(restart=False): -+ """Initialize Ren'Py once for a host-owned cooperative loop.""" -+ global _cooperative_started -+ if _cooperative_started: -+ return -+ _cooperative_started = True -+ return run(restart, cooperative=True) ++ # Use the official distributor path functions and full bootstrap, ++ # including import_all, importer, init script, preferences and saves. ++ sys.argv = [argv0, game_root] ++ spec = importlib.util.spec_from_file_location("_aether_renpy_entry", argv0) ++ entry = importlib.util.module_from_spec(spec) ++ sys.modules[spec.name] = entry ++ spec.loader.exec_module(entry) ++ entry.main() ++ except SystemExit as exc: ++ if exc.code not in (None, 0): ++ _error = "Ren'Py exited with status %r" % (exc.code,) ++ except BaseException: ++ import traceback ++ _error = traceback.format_exc() ++ finally: ++ sys.argv = saved_argv ++ sys.modules.pop("_aether_renpy_entry", None) ++ return "finished" if _error is None else "error" + + +def cooperative_tick(budget_ms): -+ """Run one bounded cooperative step, preserving the active context.""" -+ if not _cooperative_started: ++ global _deadline ++ _check_owner() ++ if _game is None: + return "not_started" -+ cooperative_begin_tick(budget_ms) -+ try: -+ renpy.execution.run_context(True, cooperative=True) ++ if _game.dead: ++ if _error: ++ raise RuntimeError(_error) + return "finished" -+ except renpy.execution.CooperativeYield: ++ if _paused: + return "yield" ++ _deadline = time.monotonic() + max(0, budget_ms) / 1000.0 ++ try: ++ _game.switch() + finally: -+ cooperative_end_tick() ++ _deadline = None ++ if _error: ++ raise RuntimeError(_error) ++ return "finished" if _game.dead else "yield" ++ ++ ++def cooperative_checkpoint(force=False): ++ """Suspend at an engine boundary without unwinding the game stack.""" ++ if not cooperative_active() or getcurrent() is not _game: ++ return ++ if _stopping: ++ import renpy.game ++ raise renpy.game.QuitException() ++ if force or (_deadline is not None and time.monotonic() >= _deadline): ++ _host.switch("yield") ++ if _stopping: ++ import renpy.game ++ raise renpy.game.QuitException() ++ _dispatch_input() ++ ++ ++def cooperative_capture(surftree): ++ global _frame, _serial ++ if not cooperative_active(): ++ return ++ import renpy ++ import renpy.pygame as pygame ++ surface = renpy.display.draw.screenshot(surftree) ++ if surface.get_bytesize() != 4: ++ raise RuntimeError("Ren'Py screenshot must contain RGBA pixels") ++ # The official GL2 screenshot writes RGBA bytes and flips to top-left. ++ # Ren'Py pygame deliberately has no image.tostring/get_view API. ++ pixels = surface.aether_copy_pixels() ++ width, height = surface.get_size() ++ if len(pixels) != width * height * 4: ++ raise RuntimeError("Invalid packed Ren'Py frame size") ++ _serial += 1 ++ _frame = (pixels, width, height, _serial) ++ window = pygame.display.get_window() ++ if window is not None: ++ # Binding uses the actual renderer window, never a synthetic surface. ++ import _aether_host ++ if _aether_host.bind_window(window.get_sdl_window_pointer().value) != 0: ++ raise RuntimeError("Could not bind Ren'Py renderer window") ++ cooperative_checkpoint(force=True) ++ ++ ++def cooperative_set_surface_size(width, height): ++ """Select the initial pbuffer; the host scales frames after viewport resize.""" ++ global _surface_size ++ _check_owner() ++ if width <= 0 or height <= 0: ++ raise ValueError("Invalid host surface size") ++ if _frame is None: ++ _surface_size = (int(width), int(height)) ++ ++ ++def cooperative_surface_size(): ++ return _surface_size ++ ++ ++def cooperative_frame(): ++ _check_owner() ++ return _frame ++ ++ ++def cooperative_input(event): ++ _check_owner() ++ if _game is None or _game.dead: ++ raise RuntimeError("Ren'Py mobile game is not running") ++ # Copy borrowed native text/event storage before returning to the host. ++ _input.append(dict(event)) ++ ++ ++def _dispatch_input(): ++ global _pointer, _modifiers ++ if not _input: ++ return ++ import renpy ++ import renpy.pygame as pygame ++ queued = list(_input) ++ del _input[:] ++ for item in queued: ++ kind, action = item["type"], item["value"] ++ if kind == 3: ++ text = item.get("text", "") ++ if text: ++ pygame.event.post(pygame.event.Event(pygame.TEXTINPUT, text=text)) ++ elif kind == 1: ++ key = item["code"] ++ if action == 8: ++ key = pygame.K_ESCAPE ++ action = 5 ++ _keys[key] = action == 5 ++ _modifiers = item.get("modifiers", 0) ++ pygame.event.post(pygame.event.Event( ++ pygame.KEYDOWN if action == 5 else pygame.KEYUP, ++ key=key, mod=item.get("modifiers", 0), unicode="", ++ repeat=bool(item.get("repeat", False)))) ++ elif kind == 2: ++ # Frames contain the rendered game tree, excluding window letterbox. ++ # Convert normalized game content through GL2's actual viewport; ++ # the normal mouse_event path translates this back to virtual pixels. ++ pos = renpy.display.draw.untranslate_point( ++ item["x"] * renpy.config.screen_width, ++ item["y"] * renpy.config.screen_height) ++ button = item.get("code", 1) or 1 ++ if action in (1, 3): ++ if 1 <= button <= 3: ++ _buttons[button - 1] = action == 1 ++ pygame.event.post(pygame.event.Event( ++ pygame.MOUSEBUTTONDOWN if action == 1 else pygame.MOUSEBUTTONUP, ++ pos=pos, button=button, which=4294967295, touch=True)) ++ elif action == 2: ++ rel = (pos[0] - _pointer[0], pos[1] - _pointer[1]) ++ pygame.event.post(pygame.event.Event( ++ pygame.MOUSEMOTION, pos=pos, rel=rel, ++ buttons=tuple(_buttons), which=4294967295, touch=True)) ++ elif action == 4: ++ wheel_y = item.get("delta_y", 0) ++ if pygame.event.get_mousewheel_buttons(): ++ if wheel_y: ++ wheel_button = 4 if wheel_y > 0 else 5 ++ for event_type in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP): ++ pygame.event.post(pygame.event.Event( ++ event_type, pos=pos, button=wheel_button, ++ which=4294967295, touch=True)) ++ else: ++ pygame.event.post(pygame.event.Event( ++ pygame.MOUSEWHEEL, x=item.get("delta_x", 0), ++ y=wheel_y, flipped=False)) ++ _pointer = pos ++ ++ ++def cooperative_mouse_pos(): ++ return _pointer ++ ++ ++def cooperative_mouse_pressed(): ++ return tuple(_buttons) ++ ++ ++def cooperative_key_pressed(key): ++ return bool(_keys.get(key, False)) ++ ++ ++def cooperative_modifiers(): ++ return _modifiers ++ ++ ++def cooperative_set_modifiers(value): ++ global _modifiers ++ _modifiers = value ++ ++ ++def cooperative_pause(): ++ global _paused ++ _check_owner() ++ if _game is None or _game.dead: ++ return ++ import renpy ++ if renpy.display.interface and renpy.display.interface.started: ++ renpy.display.interface.mobile_save() ++ renpy.audio.audio.pause_all() ++ _paused = True ++ ++ ++def cooperative_resume(): ++ global _paused ++ _check_owner() ++ if _game is None or _game.dead: ++ return ++ import renpy ++ if renpy.display.interface and renpy.display.interface.started: ++ renpy.audio.audio.unpause_all() ++ renpy.display.interface.force_redraw = True ++ _paused = False ++ ++ ++def cooperative_text_input_state(): ++ if not cooperative_active(): ++ return False ++ import renpy.pygame as pygame ++ return bool(pygame.key.text_input) + + +def cooperative_stop(): -+ """Stop the host-owned loop without terminating the process.""" -+ global _cooperative_started -+ cooperative_shutdown() -+ _cooperative_started = False - - - def load_rpe(fn): ++ global _game, _host, _owner, _paused, _stopping, _frame ++ _check_owner() ++ if _game is None: ++ return ++ _stopping = True ++ _paused = False ++ try: ++ if not _game.dead: ++ import renpy.game ++ _game.throw(renpy.game.QuitException()) ++ finally: ++ _game = _host = _owner = _frame = None ++ _input.clear() ++ _keys.clear() ++ _buttons[:] = [False, False, False] diff --git a/bridge/renpy_runtime/mobile_launcher/patches/python/README.md b/bridge/renpy_runtime/mobile_launcher/patches/python/README.md index e4662a532..333a206df 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/python/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/python/README.md @@ -1,32 +1,33 @@ -# Ren'Py Python cooperative-loop patch +# Ren'Py cooperative execution patch -`0001-cooperative-loop-skeleton.patch` is an opt-in source patch against the -official Ren'Py source checkout. It is validated against the current -`renpy/renpy` master tree in Mobile Contract CI. +`0001-cooperative-loop-skeleton.patch` retains its historical filename but now +contains the embedded runtime implementation. It applies to pinned Ren'Py +commit `39895c1e017f0b36ffea2447d97eccd69d76ee1c`, not moving master. -Apply or validate it with: +Validate or apply with: -```text +```sh bridge/renpy_runtime/mobile_launcher/build.sh \ --check-python-patch --renpy-src /path/to/renpy - bridge/renpy_runtime/mobile_launcher/build.sh \ --apply-python-patch --renpy-src /path/to/renpy ``` -The patch keeps the normal `main.run()` and `execution.run_context()` behavior -unchanged for desktop. The opt-in path adds: +`renpy.aether_mobile` creates a greenlet without running the game during init. +The first tick sets a deadline and enters the complete `renpy.py` bootstrap. +Subsequent ticks resume the same Python and Cython stack. Safe script and +interaction boundaries switch back to the host instead of unwinding an +exception through `interact` cleanup. Arbitrary Python code cannot be forcibly +preempted; a game that blocks between these boundaries can still stall a tick. -- `renpy.main.cooperative_start()` to initialize a game context once -- `renpy.main.cooperative_tick(budget_ms)` to resume that same context for a - bounded host tick and return `yield`, `finished`, or `not_started` -- `renpy.main.cooperative_stop()` for host-owned cleanup without process exit -- `renpy.execution.CooperativeYield` and deadline checks at the context boundary -- an event-loop deadline check in `Interface.interact_core`, so a long-running - interaction yields back to the native host instead of sleeping indefinitely +Patched pygame modules publish GL-renderer screenshots, bind each actual GL +window immediately, query host pointer/button/key state, and report text-input +visibility. Events retain their down/up/motion semantics, Unicode ownership +and modifier state. Pause suspends ticks; stop requests normal Ren'Py shutdown. +A second session in the same process is rejected because Ren'Py global and +Cython state reuse has not been implemented; restart the host application. -The native Android/iOS launcher fork must initialize Python and call these -helpers from the interpreter-owning thread. It must still provide the SDL/GLES -or Metal frame bridge, input queue, pause/resume, and seven exported -`renpy_mobile_*` symbols. The official `SDL_main`/`launcher_main` entrypoints -remain process launchers and are never used by this patch. +The native fork supplies the target-built `_aether_greenlet` and `_aether_host` +modules. A host wheel or desktop SDK is not a mobile substitute. Cooperative +and native protocol tests exercise controlled boundaries; actual APK/IPA +installation and gameplay acceptance remain separate requirements. diff --git a/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp index 2329c5b8e..3022e70e6 100644 --- a/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp +++ b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp @@ -2,7 +2,7 @@ #include -#if defined(__ANDROID__) || defined(__APPLE__) +#if defined(__ANDROID__) || defined(__APPLE__) || defined(AETHERKIRI_RENPY_PROTOCOL_TEST) #include #endif @@ -12,7 +12,7 @@ namespace { constexpr int kOk = RENPY_MOBILE_OK; -#if defined(__ANDROID__) || defined(__APPLE__) +#if defined(__ANDROID__) || defined(__APPLE__) || defined(AETHERKIRI_RENPY_PROTOCOL_TEST) template Function ResolveSymbol(void* handle, const char* name) { return reinterpret_cast(dlsym(handle, name)); @@ -52,8 +52,26 @@ bool Launcher::Resolve() { input_ = ResolveSymbol(library_handle_, "renpy_mobile_input"); pause_ = ResolveSymbol(library_handle_, "renpy_mobile_pause"); resume_ = ResolveSymbol(library_handle_, "renpy_mobile_resume"); + text_input_state_ = ResolveSymbol(library_handle_, "renpy_mobile_text_input_state"); + surface_size_ = ResolveSymbol(library_handle_, "renpy_mobile_set_surface_size"); + bind_window_ = ResolveSymbol(library_handle_, "renpy_mobile_bind_window"); shutdown_ = ResolveSymbol(library_handle_, "renpy_mobile_shutdown"); -#elif defined(__APPLE__) +#elif defined(AETHERKIRI_RENPY_IOS) + // A static Renios archive must have real references so the linker retains + // its lifecycle and built-in extension objects. Missing payloads fail at + // link time instead of silently producing an installable stub application. + init_ = renpy_mobile_init; + bootstrap_ = renpy_mobile_bootstrap; + tick_ = renpy_mobile_tick; + frame_ = renpy_mobile_frame; + input_ = renpy_mobile_input; + pause_ = renpy_mobile_pause; + resume_ = renpy_mobile_resume; + text_input_state_ = renpy_mobile_text_input_state; + surface_size_ = renpy_mobile_set_surface_size; + bind_window_ = renpy_mobile_bind_window; + shutdown_ = renpy_mobile_shutdown; +#elif defined(__APPLE__) || defined(AETHERKIRI_RENPY_PROTOCOL_TEST) // The normal staged Renios archive is not linked into the Godot app. Keep // the optional ABI entirely dynamic so an absent lifecycle fork never adds // unresolved symbols to the iOS executable. A real fork can export these @@ -65,16 +83,19 @@ bool Launcher::Resolve() { input_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_input"); pause_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_pause"); resume_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_resume"); + text_input_state_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_text_input_state"); + surface_size_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_set_surface_size"); + bind_window_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_bind_window"); shutdown_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_shutdown"); #else last_error_ = "host lifecycle launcher unavailable on this platform"; #endif - if (!init_ || !tick_ || !frame_ || !input_ || !pause_ || !resume_ || - !shutdown_) { + if (!init_ || !bootstrap_ || !tick_ || !frame_ || !input_ || !pause_ || !resume_ || + !text_input_state_ || !surface_size_ || !bind_window_ || !shutdown_) { last_error_ = "host lifecycle launcher unavailable: rebuilt Ren'Py payload must " - "export renpy_mobile_init/tick/frame/input/pause/resume/shutdown"; + "export the complete ABI v2 lifecycle, bootstrap, renderer binding, text state and resize functions"; Unload(); return false; } @@ -89,9 +110,11 @@ int Launcher::Init(const renpy_mobile_config_t& config, const renpy_mobile_host_t& host) { if (!Resolve()) return RENPY_MOBILE_NOT_IMPLEMENTED; if (initialized_) return RENPY_MOBILE_INVALID_STATE; - const int result = bootstrap_ ? bootstrap_(&config, &host) : init_(&config, &host); + int result = bootstrap_ ? bootstrap_(&config, &host) : RENPY_MOBILE_OK; + if (result == kOk) result = init_(&config, &host); if (result == kOk) { initialized_ = true; + finished_ = false; } else { last_error_ = "host lifecycle launcher init failed with status " + std::to_string(result); @@ -101,7 +124,9 @@ int Launcher::Init(const renpy_mobile_config_t& config, int Launcher::Tick(uint32_t budget_ms) { if (!initialized_ || !tick_) return RENPY_MOBILE_INVALID_STATE; - return tick_(budget_ms); + const int result = tick_(budget_ms); + if (result == RENPY_MOBILE_FINISHED) finished_ = true; + return result; } int Launcher::Frame(renpy_mobile_frame_t* out_frame) { @@ -124,9 +149,20 @@ int Launcher::Resume() { return resume_(); } +int Launcher::TextInputState(uint32_t* active) { + if (!initialized_ || !active) return RENPY_MOBILE_INVALID_STATE; + return text_input_state_ ? text_input_state_(active) : RENPY_MOBILE_NOT_IMPLEMENTED; +} + +int Launcher::SetSurfaceSize(uint32_t width, uint32_t height) { + if (!initialized_) return RENPY_MOBILE_INVALID_STATE; + return surface_size_ ? surface_size_(width, height) : RENPY_MOBILE_NOT_IMPLEMENTED; +} + void Launcher::Shutdown() { if (initialized_ && shutdown_) shutdown_(); initialized_ = false; + finished_ = false; } void Launcher::Unload() { @@ -141,6 +177,9 @@ void Launcher::Unload() { input_ = nullptr; pause_ = nullptr; resume_ = nullptr; + text_input_state_ = nullptr; + surface_size_ = nullptr; + bind_window_ = nullptr; shutdown_ = nullptr; resolved_ = false; } diff --git a/bridge/renpy_runtime/python/aether_renpy_overlay.py b/bridge/renpy_runtime/python/aether_renpy_overlay.py index 518da660e..f0c554123 100644 --- a/bridge/renpy_runtime/python/aether_renpy_overlay.py +++ b/bridge/renpy_runtime/python/aether_renpy_overlay.py @@ -116,6 +116,7 @@ def _publish(self, surface) -> None: def _inject(self) -> None: if self.input_path is None or not self.input_path.exists(): return + host_shutdown = False try: import renpy.pygame as pygame with self.input_path.open("rb") as stream: @@ -128,6 +129,9 @@ def _inject(self) -> None: event = json.loads(raw.decode("utf-8")) attrs = dict(event.get("attributes", {})) event_type = int(event["type"]) + if event_type == pygame.QUIT and event.get("aetherkiri_host_shutdown") is True: + host_shutdown = True + break # The engine ABI has no SDL scancode/repeat fields. Fill # the Ren'Py pygame defaults so a native provider can send # just key/modifier/unicode data. @@ -140,6 +144,12 @@ def _inject(self) -> None: except Exception as error: self._report("input", error) return + if host_shutdown: + # Host destruction is already the user's decision to close this + # session. Raise Ren'Py's orderly quit outside the input error + # handler so its QuitException reaches bootstrap cleanup. + import renpy + renpy.quit(confirm=False) def _draw_screen(self, interface, original, *args, **kwargs): result = original(interface, *args, **kwargs) diff --git a/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp index 11db5f05d..7d3514be2 100644 --- a/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp +++ b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp @@ -284,16 +284,94 @@ engine_result_t BootstrapAdapter::Start(const BootstrapRequest& request) { return Preflight(request); } +engine_result_t BootstrapAdapter::PrepareRuntime( + const BootstrapRequest& request, uint32_t width, uint32_t height, + std::string* private_root, std::string* apk_path) { + if (!private_root || !apk_path || !width || !height || + !request.game_root_path_utf8 || !request.game_root_path_utf8[0]) { + return ENGINE_RESULT_INVALID_ARGUMENT; + } +#if !defined(__ANDROID__) + last_error_ = "Ren'Py runtime extraction requires an Android host"; + return ENGINE_RESULT_NOT_SUPPORTED; +#else + JNIEnv* env = krkr_GetJNIEnv(); + if (!env) { last_error_ = "Ren'Py could not attach to the Android VM"; return ENGINE_RESULT_INVALID_STATE; } + jobject activity = request.existing_host_activity + ? static_cast(request.existing_host_activity) : krkr_GetHostActivity(); + if (!activity) { last_error_ = "Ren'Py requires the existing Godot Activity"; return ENGINE_RESULT_INVALID_STATE; } + jclass bridge = FindRequiredClass(env, "org/github/krkr2/aetherkiri/RenPyMobileBridge", &last_error_); + if (!bridge) return ENGINE_RESULT_NOT_SUPPORTED; + jmethodID bind = env->GetStaticMethodID(bridge, "bindHostActivity", "(Landroid/app/Activity;)V"); + jmethodID extract = env->GetStaticMethodID(bridge, "preparePrivateRoot", "()Ljava/lang/String;"); + jmethodID prepare = env->GetStaticMethodID(bridge, "prepareRuntime", "(II)V"); + jmethodID apk = env->GetStaticMethodID(bridge, "getApkPath", "()Ljava/lang/String;"); + if (!bind || !extract || !prepare || !apk || ClearJavaException(env)) { + env->DeleteLocalRef(bridge); + last_error_ = "Ren'Py Android host bridge is missing extraction/SDL initialization callbacks"; + return ENGINE_RESULT_NOT_SUPPORTED; + } + env->CallStaticVoidMethod(bridge, bind, activity); + jstring root = nullptr, archive = nullptr; + if (!env->ExceptionCheck()) root = static_cast(env->CallStaticObjectMethod(bridge, extract)); + if (!env->ExceptionCheck()) archive = static_cast(env->CallStaticObjectMethod(bridge, apk)); + if (!env->ExceptionCheck()) env->CallStaticVoidMethod(bridge, prepare, static_cast(width), static_cast(height)); + if (env->ExceptionCheck() || !root || !archive) { + jthrowable exception = env->ExceptionOccurred(); + env->ExceptionClear(); + last_error_ = "Ren'Py Android payload extraction/SDL initialization failed"; + if (exception) { + jclass exception_class = env->GetObjectClass(exception); + jmethodID describe = env->GetMethodID(exception_class, "toString", "()Ljava/lang/String;"); + jstring detail = describe ? static_cast(env->CallObjectMethod(exception, describe)) : nullptr; + if (detail && !ClearJavaException(env)) { + const char* utf8 = env->GetStringUTFChars(detail, nullptr); + if (utf8) { last_error_ += ": "; last_error_ += utf8; env->ReleaseStringUTFChars(detail, utf8); } + } + if (detail) env->DeleteLocalRef(detail); + env->DeleteLocalRef(exception_class); + env->DeleteLocalRef(exception); + } + if (root) env->DeleteLocalRef(root); + if (archive) env->DeleteLocalRef(archive); + env->DeleteLocalRef(bridge); + return ENGINE_RESULT_INTERNAL_ERROR; + } + const char* root_utf8 = env->GetStringUTFChars(root, nullptr); + const char* apk_utf8 = env->GetStringUTFChars(archive, nullptr); + if (root_utf8) *private_root = root_utf8; + if (apk_utf8) *apk_path = apk_utf8; + if (root_utf8) env->ReleaseStringUTFChars(root, root_utf8); + if (apk_utf8) env->ReleaseStringUTFChars(archive, apk_utf8); + env->DeleteLocalRef(root); + env->DeleteLocalRef(archive); + env->DeleteLocalRef(bridge); + if (private_root->empty() || apk_path->empty() || ClearJavaException(env)) return ENGINE_RESULT_INTERNAL_ERROR; + running_ = true; + last_error_.clear(); + return ENGINE_RESULT_OK; +#endif +} + engine_result_t BootstrapAdapter::Stop() { - // Start() cannot currently transition to running, but make destruction - // idempotent so the provider can call Stop() unconditionally. - running_ = false; + // Release host helpers after the provider has stopped Ren'Py; this is also + // idempotent after extraction/bootstrap fails. #if defined(__ANDROID__) + if (JNIEnv* env = krkr_GetJNIEnv(); env && running_) { + jclass bridge = FindRequiredClass(env, "org/github/krkr2/aetherkiri/RenPyMobileBridge", &last_error_); + if (bridge) { + jmethodID stop = env->GetStaticMethodID(bridge, "stopRuntime", "()V"); + if (stop) env->CallStaticVoidMethod(bridge, stop); + ClearJavaException(env); + env->DeleteLocalRef(bridge); + } + } if (native_library_handle_ != nullptr) { dlclose(native_library_handle_); native_library_handle_ = nullptr; } #endif + running_ = false; last_error_.clear(); return ENGINE_RESULT_OK; } diff --git a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp index 17e53060a..0b37e8460 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp @@ -1,6 +1,7 @@ #include "renpy_runtime.h" #include "engine_runtime_provider.h" #include "renpy_mobile_loader.h" +#include "renpy_input_mapping.h" #if defined(__ANDROID__) #include "renpy_mobile_adapter.h" #endif @@ -9,14 +10,19 @@ #endif #include +#include #include #include #include #include #include +#include #include +#if defined(AETHERKIRI_RENPY_IOS) +#include +#endif -#if !defined(__ANDROID__) && !defined(__APPLE__) +#if !defined(__ANDROID__) && !defined(__APPLE__) && !defined(AETHERKIRI_RENPY_PROTOCOL_TEST) #error "Ren'Py mobile provider must only be compiled for Android or Apple" #endif @@ -27,6 +33,12 @@ using mobile::Launcher; struct MobileRuntime final { engine_runtime_host_v1_t host{}; + renpy_mobile_host_t launcher_host{}; + renpy_mobile_config_t launcher_config{}; + std::string game_root; + std::string private_root; + std::string argv0; + std::string apk_path; Launcher launcher; #if defined(__ANDROID__) mobile::BootstrapAdapter bootstrap; @@ -40,6 +52,7 @@ struct MobileRuntime final { uint64_t delivered_serial = 0; bool frame_rendered = false; bool opened = false; + bool paused = false; uint32_t surface_width = 0; uint32_t surface_height = 0; std::string error = @@ -54,6 +67,7 @@ MobileRuntime* Cast(void* value) { engine_result_t MapLauncherStatus(int status) { switch (status) { case RENPY_MOBILE_OK: + case RENPY_MOBILE_FINISHED: return ENGINE_RESULT_OK; case RENPY_MOBILE_INVALID_ARGUMENT: return ENGINE_RESULT_INVALID_ARGUMENT; @@ -87,9 +101,8 @@ int PresentRgba(void* user_data, const uint8_t* rgba, uint32_t width, } void LogUtf8(void* user_data, int level, const char* message) { - (void)level; auto* runtime = static_cast(user_data); - if (runtime && message) runtime->error = message; + if (runtime && message && level >= 3) runtime->error = message; } engine_result_t CaptureFrame(MobileRuntime* runtime) { @@ -113,6 +126,7 @@ engine_result_t CaptureFrame(MobileRuntime* runtime) { return ENGINE_RESULT_INTERNAL_ERROR; } std::lock_guard lock(runtime->frame_mutex); + if (frame.serial != 0 && frame.serial == runtime->frame_serial) return ENGINE_RESULT_OK; runtime->frame_pixels.assign(frame.rgba, frame.rgba + size); runtime->frame_width = frame.width; runtime->frame_height = frame.height; @@ -163,6 +177,10 @@ engine_result_t Open(void* value, const char* game_root_path, } auto* runtime = Cast(value); if (runtime->opened) return ENGINE_RESULT_INVALID_STATE; +#if defined(AETHERKIRI_RENPY_IOS) + runtime->error = "Ren'Py iOS rendering is unavailable: the SDL MetalANGLE backend still creates a separate UIKit window; host graphics isolation requires a verified native backend"; + return ENGINE_RESULT_NOT_SUPPORTED; +#endif if (!runtime->launcher.available()) { #if defined(__ANDROID__) @@ -190,26 +208,16 @@ engine_result_t Open(void* value, const char* game_root_path, #endif } - renpy_mobile_config_t config{}; - config.struct_size = sizeof(config); - config.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; - config.private_root_utf8 = game_root_path; - config.public_root_utf8 = game_root_path; - config.argv0_utf8 = startup_script ? startup_script : "AetherKiri"; - config.argc = 0; - - renpy_mobile_host_t host{}; - host.struct_size = sizeof(host); - host.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; - host.user_data = runtime; - host.present_rgba = PresentRgba; - host.log_utf8 = LogUtf8; - - const auto result = MapLauncherStatus(runtime->launcher.Init(config, host)); - if (result != ENGINE_RESULT_OK) { - runtime->error = runtime->launcher.last_error(); - return result; - } + // open_game_async runs on a temporary startup thread. Defer CPython, + // greenlet and GL initialization until the first host Tick, and own every + // string/callback table for the runtime's entire lifetime. + runtime->game_root = game_root_path; + runtime->argv0 = startup_script ? startup_script : ""; + runtime->launcher_host.struct_size = sizeof(runtime->launcher_host); + runtime->launcher_host.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; + runtime->launcher_host.user_data = runtime; + runtime->launcher_host.present_rgba = PresentRgba; + runtime->launcher_host.log_utf8 = LogUtf8; runtime->opened = true; runtime->error.clear(); return ENGINE_RESULT_OK; @@ -219,12 +227,67 @@ engine_result_t Tick(void* value, uint32_t delta_ms) { if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; auto* runtime = Cast(value); if (!runtime->opened) return ENGINE_RESULT_INVALID_STATE; - const auto result = MapLauncherStatus(runtime->launcher.Tick(delta_ms)); + runtime->frame_rendered = false; + if (runtime->launcher.finished()) { + runtime->error = "Ren'Py runtime requested termination"; + return ENGINE_RESULT_INVALID_STATE; + } + if (runtime->paused) return ENGINE_RESULT_OK; + if (!runtime->launcher.initialized()) { +#if defined(__ANDROID__) + mobile::BootstrapRequest request{}; + request.game_root_path_utf8 = runtime->game_root.c_str(); + request.existing_host_activity = runtime->host.reserved_ptr[1]; + const auto prepared = runtime->bootstrap.PrepareRuntime(request, + runtime->surface_width ? runtime->surface_width : 1280u, + runtime->surface_height ? runtime->surface_height : 720u, + &runtime->private_root, &runtime->apk_path); + if (prepared != ENGINE_RESULT_OK) { runtime->error = runtime->bootstrap.last_error(); return prepared; } + if (runtime->argv0.empty()) runtime->argv0 = runtime->private_root + "/main.py"; +#elif defined(AETHERKIRI_RENPY_IOS) + CFURLRef resources = CFBundleCopyResourcesDirectoryURL(CFBundleGetMainBundle()); + char path[4096]{}; + const bool found = resources && CFURLGetFileSystemRepresentation(resources, true, + reinterpret_cast(path), sizeof(path)); + if (resources) CFRelease(resources); + if (!found) { runtime->error = "Ren'Py could not locate the app's bundled Renios Python payload"; return ENGINE_RESULT_IO_ERROR; } + runtime->private_root = std::string(path) + "/renios/base"; + if (runtime->argv0.empty()) runtime->argv0 = runtime->private_root + "/renpy.py"; +#else + runtime->private_root = runtime->game_root; + if (runtime->argv0.empty()) runtime->argv0 = "AetherKiri"; +#endif + auto& config = runtime->launcher_config; + config.struct_size = sizeof(config); + config.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; + config.private_root_utf8 = runtime->private_root.c_str(); + config.public_root_utf8 = runtime->game_root.c_str(); + config.game_root_utf8 = runtime->game_root.c_str(); + config.argv0_utf8 = runtime->argv0.c_str(); + config.apk_path_utf8 = runtime->apk_path.empty() ? nullptr : runtime->apk_path.c_str(); + runtime->error.clear(); + const auto initialized = MapLauncherStatus(runtime->launcher.Init(config, runtime->launcher_host)); + if (initialized != ENGINE_RESULT_OK) { + if (runtime->error.empty()) runtime->error = runtime->launcher.last_error(); + return initialized; + } + if (runtime->surface_width && runtime->surface_height) { + const auto resized = MapLauncherStatus(runtime->launcher.SetSurfaceSize( + runtime->surface_width, runtime->surface_height)); + if (resized != ENGINE_RESULT_OK) return resized; + } + } + runtime->error.clear(); + const int status = runtime->launcher.Tick(delta_ms); + if (status == RENPY_MOBILE_FINISHED) { + runtime->error = "Ren'Py runtime requested termination"; + return ENGINE_RESULT_INVALID_STATE; + } + const auto result = MapLauncherStatus(status); if (result != ENGINE_RESULT_OK) { - runtime->error = runtime->launcher.last_error(); + if (runtime->error.empty()) runtime->error = runtime->launcher.last_error(); return result; } - runtime->frame_rendered = false; const auto frame_result = CaptureFrame(runtime); if (frame_result != ENGINE_RESULT_OK) return frame_result; return ENGINE_RESULT_OK; @@ -234,14 +297,20 @@ engine_result_t Pause(void* value) { if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; auto* runtime = Cast(value); if (!runtime->opened) return ENGINE_RESULT_INVALID_STATE; - return MapLauncherStatus(runtime->launcher.Pause()); + const auto result = runtime->launcher.initialized() + ? MapLauncherStatus(runtime->launcher.Pause()) : ENGINE_RESULT_OK; + if (result == ENGINE_RESULT_OK) runtime->paused = true; + return result; } engine_result_t Resume(void* value) { if (!value) return ENGINE_RESULT_INVALID_ARGUMENT; auto* runtime = Cast(value); if (!runtime->opened) return ENGINE_RESULT_INVALID_STATE; - return MapLauncherStatus(runtime->launcher.Resume()); + const auto result = runtime->launcher.initialized() + ? MapLauncherStatus(runtime->launcher.Resume()) : ENGINE_RESULT_OK; + if (result == ENGINE_RESULT_OK) runtime->paused = false; + return result; } engine_result_t SetSurfaceSize(void* value, uint32_t width, uint32_t height) { @@ -249,6 +318,7 @@ engine_result_t SetSurfaceSize(void* value, uint32_t width, uint32_t height) { auto* runtime = Cast(value); runtime->surface_width = width; runtime->surface_height = height; + if (runtime->launcher.initialized()) return MapLauncherStatus(runtime->launcher.SetSurfaceSize(width, height)); return ENGINE_RESULT_OK; } @@ -290,14 +360,25 @@ engine_result_t Input(void* value, const engine_input_event_t* event) { } auto* runtime = Cast(value); if (!runtime->opened) return ENGINE_RESULT_INVALID_STATE; + if (!std::isfinite(event->x) || !std::isfinite(event->y) || + !std::isfinite(event->delta_x) || !std::isfinite(event->delta_y)) return ENGINE_RESULT_INVALID_ARGUMENT; renpy_mobile_input_t input{}; input.struct_size = sizeof(input); input.timestamp_ns = event->timestamp_micros * 1000ull; input.device_id = event->pointer_id; - input.code = event->key_code != 0 ? event->key_code : event->button; + input.code = input_mapping::MapKeyCodeToPygame(event->key_code); input.value = static_cast(event->type); - input.x = static_cast(event->x); - input.y = static_cast(event->y); + { + std::lock_guard lock(runtime->frame_mutex); + const auto width = runtime->frame_width ? runtime->frame_width : runtime->surface_width; + const auto height = runtime->frame_height ? runtime->frame_height : runtime->surface_height; + input.x = static_cast(event->x / (width ? width : 1280u)); + input.y = static_cast(event->y / (height ? height : 720u)); + } + input.delta_x = static_cast(event->delta_x); + input.delta_y = static_cast(event->delta_y); + input.modifiers = input_mapping::MapModifiersToPygame(event->modifiers); + input.repeat = input_mapping::IsAetherKeyRepeat(event->modifiers) ? 1u : 0u; input.pressure = (event->type == ENGINE_INPUT_EVENT_POINTER_UP) ? 0.0f : 1.0f; std::string text; switch (event->type) { @@ -306,6 +387,7 @@ engine_result_t Input(void* value, const engine_input_event_t* event) { case ENGINE_INPUT_EVENT_POINTER_UP: case ENGINE_INPUT_EVENT_POINTER_SCROLL: input.type = RENPY_MOBILE_INPUT_POINTER; + input.code = input_mapping::MapPointerButtonToPygame(event->button); break; case ENGINE_INPUT_EVENT_KEY_DOWN: case ENGINE_INPUT_EVENT_KEY_UP: @@ -314,6 +396,7 @@ engine_result_t Input(void* value, const engine_input_event_t* event) { case ENGINE_INPUT_EVENT_TEXT_INPUT: { input.type = RENPY_MOBILE_INPUT_TEXT; uint32_t cp = event->unicode_codepoint; + if (cp == 0 || cp > 0x10ffffu || (cp >= 0xd800u && cp <= 0xdfffu)) return ENGINE_RESULT_INVALID_ARGUMENT; if (cp <= 0x7fu) text.push_back(static_cast(cp)); else if (cp <= 0x7ffu) { text.push_back(static_cast(0xc0u | (cp >> 6))); @@ -333,7 +416,7 @@ engine_result_t Input(void* value, const engine_input_event_t* event) { } case ENGINE_INPUT_EVENT_BACK: input.type = RENPY_MOBILE_INPUT_KEY; - input.code = 0x08; + input.code = 27; // Ren'Py maps Android Back to Escape/game menu. break; default: return ENGINE_RESULT_INVALID_ARGUMENT; @@ -374,8 +457,27 @@ engine_result_t UnsupportedOption(void* value, const engine_option_t* option) { return Unsupported(value); } -engine_result_t UnsupportedTextState(void* value, uint32_t*) { - return Unsupported(value); +engine_result_t TextState(void* value, uint32_t* active) { + if (!value || !active) return ENGINE_RESULT_INVALID_ARGUMENT; + auto* runtime = Cast(value); + *active = 0; + if (!runtime->launcher.initialized() || runtime->launcher.finished()) return ENGINE_RESULT_OK; + return MapLauncherStatus(runtime->launcher.TextInputState(active)); +} + +engine_result_t DebugInfo(void* value, char* buffer, uint32_t size, uint32_t* written) { + if (!value || !written) return ENGINE_RESULT_INVALID_ARGUMENT; + auto* runtime = Cast(value); + std::ostringstream json; + json << "{\"runtime\":\"renpy\",\"initialized\":" << (runtime->launcher.initialized() ? "true" : "false") + << ",\"paused\":" << (runtime->paused ? "true" : "false") + << ",\"exited\":" << (runtime->launcher.finished() ? "true" : "false") + << ",\"frame_serial\":" << runtime->frame_serial << "}"; + const auto text = json.str(); + *written = static_cast(text.size()); + if (!buffer || size <= text.size()) return ENGINE_RESULT_INVALID_ARGUMENT; + std::memcpy(buffer, text.c_str(), text.size() + 1u); + return ENGINE_RESULT_OK; } const char* Error(void* value) { @@ -405,7 +507,8 @@ const engine_runtime_provider_v1_t& Provider() { p.send_input = Input; p.get_frame_rendered_flag = Rendered; p.get_renderer_info = Renderer; - p.get_text_input_state = UnsupportedTextState; + p.get_text_input_state = TextState; + p.get_plugin_debug_info = DebugInfo; p.get_last_error = Error; return p; }(); diff --git a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp index b05cf2ff7..1f2136b28 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp @@ -1,3 +1,4 @@ +#include "renpy_input_mapping.h" #include "renpy_runtime.h" #include "engine_runtime_provider.h" @@ -188,184 +189,7 @@ std::string JsonNumber(double value) { return stream.str(); } -// The Godot shell translates keys to Windows-style virtual keys and uses -// KiriKiri modifier bits (main.gd's _kirikiri_virtual_key/key_modifiers). -// Ren'Py 8.5.x uses pygame_sdl2's SDL2 keycodes/KMOD_* values. Keep this -// translation independent of SDL headers, which are owned by the child SDK. -constexpr int kPygameScancodeMask = 1 << 30; -constexpr int kPygameKmodShift = 0x0003; -constexpr int kPygameKmodControl = 0x00c0; -constexpr int kPygameKmodAlt = 0x0300; -constexpr int kPygameKmodGui = 0x0c00; -constexpr int kAetherModifierShift = 0x01; -constexpr int kAetherModifierAlt = 0x02; -constexpr int kAetherModifierControl = 0x04; -constexpr int kAetherModifierEcho = 0x80; -constexpr int kGodotModifierShift = 1 << 25; -constexpr int kGodotModifierAlt = 1 << 26; -constexpr int kGodotModifierMeta = 1 << 27; -constexpr int kGodotModifierControl = 1 << 28; -constexpr int kGodotSpecial = 1 << 22; -constexpr int kGodotCodeMask = 0x007fffff; - -constexpr int PygameFunctionKey(int function) { - // SDL2's F1-F12 scancodes are contiguous at 58. F13-F24 start at - // scancode 104, after the keypad block. - if (function >= 1 && function <= 12) - return kPygameScancodeMask + 58 + function - 1; - if (function >= 13 && function <= 24) - return kPygameScancodeMask + 104 + function - 13; - return 0; -} - -int MapPointerButtonToPygame(int button) { - // EngineApi: 0=left, 1=right, 2=middle. pygame: 1=left, 2=middle, - // 3=right. Do not confuse the already-translated ABI with raw Godot's - // MouseButton enum (1=left, 2=right, 3=middle). - switch (button) { - case 0: return 1; - case 1: return 3; - case 2: return 2; - default: return button > 0 ? button : 1; - } -} - -int MapKeyCodeToPygame(int code) { - // Preserve SDL2 non-printable keycodes used by existing direct callers. - // Low-byte SDL ASCII and Windows VK values overlap; the ABI's VK - // interpretation takes precedence (e.g. 0x70 means F1, not 'p'). - if (code >= kPygameScancodeMask) return code; - if (code >= 0x01000000) code &= kGodotCodeMask; - - // The manual Godot probe passes raw Godot 4 non-printable keycodes. - // Their distinct KEY_SPECIAL range can be recognized unambiguously. - if (code >= kGodotSpecial && code < kGodotSpecial + 0x1000) { - const int special = code - kGodotSpecial; - switch (special) { - case 1: return 27; // Escape - case 2: // Tab - case 3: return 9; // Backtab - case 4: return 8; // Backspace - case 5: return 13; // Return - case 6: return kPygameScancodeMask + 88; // Keypad Enter - case 7: return kPygameScancodeMask + 73; // Insert - case 8: return 127; // Delete - case 9: return kPygameScancodeMask + 72; // Pause/Break - case 10: return kPygameScancodeMask + 70; // Print Screen - case 11: return kPygameScancodeMask + 154; // SysReq - case 12: return kPygameScancodeMask + 156; // Clear - case 13: return kPygameScancodeMask + 74; // Home - case 14: return kPygameScancodeMask + 77; // End - case 15: return kPygameScancodeMask + 80; // Left - case 16: return kPygameScancodeMask + 82; // Up - case 17: return kPygameScancodeMask + 79; // Right - case 18: return kPygameScancodeMask + 81; // Down - case 19: return kPygameScancodeMask + 75; // Page Up - case 20: return kPygameScancodeMask + 78; // Page Down - case 21: return kPygameScancodeMask + 225; // Shift (left) - case 22: return kPygameScancodeMask + 224; // Ctrl (left) - case 23: return kPygameScancodeMask + 227; // Meta (left) - case 24: return kPygameScancodeMask + 226; // Alt (left) - case 25: return kPygameScancodeMask + 57; // Caps Lock - case 26: return kPygameScancodeMask + 83; // Num Lock - case 27: return kPygameScancodeMask + 71; // Scroll Lock - case 66: return kPygameScancodeMask + 118; // Menu - case 69: return kPygameScancodeMask + 117; // Help - default: break; - } - if (special >= 28 && special <= 62) - return PygameFunctionKey(special - 27); // F1-F35 (SDL has F1-F24) - if (special >= 129 && special <= 143) { - static constexpr int keypad[] = { - 85, 84, 86, 99, 87, 98, 89, 90, - 91, 92, 93, 94, 95, 96, 97, - }; // Multiply, divide, subtract, period, add, 0..9. - return kPygameScancodeMask + keypad[special - 129]; - } - return 0; - } - - switch (code) { - case 0x08: return 8; // Backspace - case 0x09: return 9; // Tab - case 0x0c: return kPygameScancodeMask + 156; // Clear - case 0x0d: return 13; // Return - case 0x10: return kPygameScancodeMask + 225; // Shift - case 0x11: return kPygameScancodeMask + 224; // Ctrl - case 0x12: return kPygameScancodeMask + 226; // Alt - case 0x13: return kPygameScancodeMask + 72; // Pause - case 0x14: return kPygameScancodeMask + 57; // Caps Lock - case 0x1b: return 27; // Escape - case 0x20: return 32; // Space - case 0x21: return kPygameScancodeMask + 75; // Page Up - case 0x22: return kPygameScancodeMask + 78; // Page Down - case 0x23: return kPygameScancodeMask + 77; // End - case 0x24: return kPygameScancodeMask + 74; // Home - case 0x25: return kPygameScancodeMask + 80; // Left - case 0x26: return kPygameScancodeMask + 82; // Up - case 0x27: return kPygameScancodeMask + 79; // Right - case 0x28: return kPygameScancodeMask + 81; // Down - case 0x2c: return kPygameScancodeMask + 70; // Print Screen - case 0x2d: return kPygameScancodeMask + 73; // Insert - case 0x2e: return 127; // Delete - case 0x2f: return kPygameScancodeMask + 117; // Help - case 0x5b: return kPygameScancodeMask + 227; // Left Windows - case 0x5c: return kPygameScancodeMask + 231; // Right Windows - case 0x5d: return kPygameScancodeMask + 101; // Applications - case 0x6a: return kPygameScancodeMask + 85; // Keypad multiply - case 0x6b: return kPygameScancodeMask + 87; // Keypad add - case 0x6c: return kPygameScancodeMask + 133; // Keypad separator - case 0x6d: return kPygameScancodeMask + 86; // Keypad subtract - case 0x6e: return kPygameScancodeMask + 99; // Keypad decimal - case 0x6f: return kPygameScancodeMask + 84; // Keypad divide - case 0x90: return kPygameScancodeMask + 83; // Num Lock - case 0x91: return kPygameScancodeMask + 71; // Scroll Lock - case 0xa0: return kPygameScancodeMask + 225; // Left Shift - case 0xa1: return kPygameScancodeMask + 229; // Right Shift - case 0xa2: return kPygameScancodeMask + 224; // Left Ctrl - case 0xa3: return kPygameScancodeMask + 228; // Right Ctrl - case 0xa4: return kPygameScancodeMask + 226; // Left Alt - case 0xa5: return kPygameScancodeMask + 230; // Right Alt - case 0xba: return ';'; - case 0xbb: return '='; - case 0xbc: return ','; - case 0xbd: return '-'; - case 0xbe: return '.'; - case 0xbf: return '/'; - case 0xc0: return '`'; - case 0xdb: return '['; - case 0xdc: return '\\'; - case 0xdd: return ']'; - case 0xde: return '\''; - case 0xe2: return '\\'; - default: break; - } - if (code >= 0x60 && code <= 0x69) - return kPygameScancodeMask + (code == 0x60 ? 98 : 89 + code - 0x61); - if (code >= 0x70 && code <= 0x87) - return PygameFunctionKey(code - 0x70 + 1); - if (code >= 'A' && code <= 'Z') return code + ('a' - 'A'); - if (code >= 0x21 && code <= 0x7e) return code; - return 0; // K_UNKNOWN -} - -int MapModifiersToPygame(int modifiers) { - int result = 0; - if (modifiers & (kAetherModifierShift | kGodotModifierShift)) - result |= kPygameKmodShift; - if (modifiers & (kAetherModifierAlt | kGodotModifierAlt)) - result |= kPygameKmodAlt; - if (modifiers & (kAetherModifierControl | kGodotModifierControl)) - result |= kPygameKmodControl; - if (modifiers & kGodotModifierMeta) result |= kPygameKmodGui; - // No raw SDL passthrough: Aether's echo=0x80 would become SDL's RCtrl; - // mouse-held/cancel bits are not keyboard modifiers either. - return result; -} - -bool IsAetherKeyRepeat(int modifiers) { - return (modifiers & kAetherModifierEcho) != 0; -} +using namespace input_mapping; std::string Utf8Codepoint(uint32_t value) { if (value == 0 || value > 0x10ffffu || @@ -822,6 +646,19 @@ struct Runtime { std::string error; ~Runtime() { + if (opened && !input_path.empty() && process.valid()) { + if (paused) process.Pause(false); + // Give the overlay a chance to run Ren'Py's normal cleanup before + // bounded process-tree termination. A signal alone becomes an + // SDL quit event and can open a game's confirmation dialog. + std::ofstream stream(input_path, std::ios::binary | std::ios::app); + stream << "{\"type\":256,\"attributes\":{},\"aetherkiri_host_shutdown\":true}\n"; + stream.close(); + const auto deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(1000); + while (process.Running(nullptr) && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } process.Stop(); CleanupBridge(); } diff --git a/bridge/renpy_runtime/tests/mobile_lifecycle.cpp b/bridge/renpy_runtime/tests/mobile_lifecycle.cpp new file mode 100644 index 000000000..ac51b7450 --- /dev/null +++ b/bridge/renpy_runtime/tests/mobile_lifecycle.cpp @@ -0,0 +1,123 @@ +#include "renpy_mobile_launcher.h" +#include + +#include +#include +#include +#include + +extern "C" PyObject* PyInit__aether_host(); +namespace { +int diagnostics; +void Log(void*, int, const char*) { ++diagnostics; } +long PythonLong(const char* expression) { + PyObject* globals = PyModule_GetDict(PyImport_AddModule("__main__")); + PyObject* result = PyRun_String(expression, Py_eval_input, globals, globals); + assert(result); + const long value = PyLong_AsLong(result); + Py_DECREF(result); + return value; +} +} // namespace + +// This exercises the native boundary with a real CPython interpreter and a +// controlled Python module. It is not a Ren'Py game or device acceptance test. +int main() { + assert(PyImport_AppendInittab("_aether_host", PyInit__aether_host) == 0); + Py_Initialize(); + assert(PyRun_SimpleString(R"PY( +import sys, types +renpy = types.ModuleType('renpy') +renpy.__path__ = [] +sys.modules['renpy'] = renpy +mobile = types.ModuleType('renpy.aether_mobile') +sys.modules['renpy.aether_mobile'] = mobile +mobile._ticks = 0 +mobile._stops = 0 +mobile._paused = False +mobile._inputs = [] +mobile._frame = (bytes([1, 2, 3, 255, 4, 5, 6, 255]), 2, 1, 9) +def start(root, argv0): + mobile._root, mobile._argv0 = root, argv0 +def tick(budget): + mobile._ticks += 1 + return 'finished' if mobile._ticks >= 3 else 'yield' +def stop(): + mobile._stops += 1 +def pause(): + mobile._paused = True +def resume(): + mobile._paused = False +mobile.cooperative_start = start +mobile.cooperative_tick = tick +mobile.cooperative_stop = stop +mobile.cooperative_pause = pause +mobile.cooperative_resume = resume +mobile.cooperative_frame = lambda: mobile._frame +mobile.cooperative_input = lambda event: mobile._inputs.append(dict(event)) +mobile.cooperative_text_input_state = lambda: True +mobile.cooperative_set_surface_size = lambda w, h: None +)PY") == 0); + renpy_mobile_config_t config{}; + config.struct_size = sizeof(config); + config.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; + config.game_root_utf8 = "/project"; + config.private_root_utf8 = "/runtime"; + config.argv0_utf8 = "/runtime/main.py"; + renpy_mobile_host_t host{}; + host.struct_size = sizeof(host); + host.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; + host.log_utf8 = Log; + assert(renpy_mobile_init(&config, &host) == RENPY_MOBILE_OK); + std::memset(&host, 0, sizeof(host)); // native must own the callback table. + assert(PythonLong("mobile._root == '/project'") == 1); + assert(PythonLong("mobile._ticks") == 0); // init must not execute gameplay. + assert(renpy_mobile_tick(16) == RENPY_MOBILE_OK); + + renpy_mobile_frame_t frame{}; + frame.struct_size = sizeof(frame); + assert(renpy_mobile_frame(&frame) == RENPY_MOBILE_OK); + assert(frame.width == 2 && frame.height == 1 && frame.stride == 8 && frame.serial == 9); + assert(frame.rgba[0] == 1 && frame.rgba[7] == 255); + assert(PyRun_SimpleString("mobile._frame = (b'bad', 2, 1, 10)") == 0); + assert(renpy_mobile_frame(&frame) == RENPY_MOBILE_ERROR); + + char text[] = "\xe4\xbd\xa0\xf0\x9f\x99\x82"; + renpy_mobile_input_t event{}; + event.struct_size = sizeof(event); + event.type = RENPY_MOBILE_INPUT_TEXT; + event.text_utf8 = text; + assert(renpy_mobile_input(&event) == RENPY_MOBILE_OK); + std::memset(text, 'x', sizeof(text) - 1); + assert(PythonLong("mobile._inputs[-1]['text'] == '\u4f60\U0001f642'") == 1); + for (int action : {RENPY_MOBILE_POINTER_DOWN, RENPY_MOBILE_POINTER_MOVE, + RENPY_MOBILE_POINTER_UP, RENPY_MOBILE_POINTER_SCROLL}) { + event.type = RENPY_MOBILE_INPUT_POINTER; + event.value = action; + event.text_utf8 = nullptr; + event.device_id = 7; + event.code = 3; + event.delta_y = -2; + assert(renpy_mobile_input(&event) == RENPY_MOBILE_OK); + } + assert(PythonLong("[e['value'] for e in mobile._inputs[-4:]] == [1,2,3,4]") == 1); + assert(PythonLong("mobile._inputs[-1]['device_id'] == 7 and mobile._inputs[-1]['delta_y'] == -2") == 1); + uint32_t active = 0; + assert(renpy_mobile_text_input_state(&active) == RENPY_MOBILE_OK && active == 1); + assert(renpy_mobile_pause() == RENPY_MOBILE_OK); + assert(renpy_mobile_tick(16) == RENPY_MOBILE_OK && PythonLong("mobile._ticks") == 1); + assert(renpy_mobile_resume() == RENPY_MOBILE_OK); + assert(renpy_mobile_tick(16) == RENPY_MOBILE_OK); + assert(renpy_mobile_tick(16) == RENPY_MOBILE_FINISHED); + assert(renpy_mobile_set_surface_size(1280, 720) == RENPY_MOBILE_OK); + std::thread wrong_thread([&] { + assert(renpy_mobile_frame(&frame) == RENPY_MOBILE_INVALID_STATE); + }); + wrong_thread.join(); + assert(diagnostics > 0); // callback remains valid after caller table dies. + renpy_mobile_shutdown(); + assert(Py_IsInitialized()); // the host owns interpreter/process lifetime. + assert(PythonLong("mobile._stops") == 1); + assert(renpy_mobile_tick(16) == RENPY_MOBILE_INVALID_STATE); + Py_Finalize(); +} diff --git a/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp b/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp new file mode 100644 index 000000000..e25fe5614 --- /dev/null +++ b/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp @@ -0,0 +1,112 @@ +#include "renpy_runtime.h" +#include "engine_runtime_provider.h" +#include "renpy_mobile_launcher.h" + +#include +#include +#include +#include + +namespace { +const engine_runtime_provider_v1_t* provider; +const renpy_mobile_host_t* callbacks; +int starts; +bool finish; +bool push_frame = true; +renpy_mobile_input_t last_input{}; +std::string last_text; +} // namespace + +// A controlled launcher verifies the provider protocol. This is explicitly +// separate from actual Ren'Py rendering and Android/iOS acceptance. +extern "C" engine_result_t engine_register_runtime_provider(const engine_runtime_provider_v1_t* value) { + provider = value; + return ENGINE_RESULT_OK; +} +extern "C" int renpy_mobile_bootstrap(const renpy_mobile_config_t*, const renpy_mobile_host_t*) { return 0; } +extern "C" int renpy_mobile_init(const renpy_mobile_config_t* config, const renpy_mobile_host_t* host) { + assert(std::strcmp(config->game_root_utf8, "/project") == 0); + callbacks = host; + ++starts; + return 0; +} +extern "C" int renpy_mobile_tick(uint32_t) { + assert(callbacks->struct_size == sizeof(*callbacks)); + if (push_frame) { + const uint8_t pixels[] = {1, 2, 3, 255}; + assert(callbacks->present_rgba(callbacks->user_data, pixels, 1, 1, 4) == 0); + } + return finish ? RENPY_MOBILE_FINISHED : RENPY_MOBILE_OK; +} +extern "C" int renpy_mobile_frame(renpy_mobile_frame_t*) { return RENPY_MOBILE_INVALID_STATE; } +extern "C" int renpy_mobile_input(const renpy_mobile_input_t* input) { + last_input = *input; + last_text = input->text_utf8 ? input->text_utf8 : ""; + return 0; +} +extern "C" int renpy_mobile_pause() { return 0; } +extern "C" int renpy_mobile_resume() { return 0; } +extern "C" int renpy_mobile_bind_window(void*) { return 0; } +extern "C" int renpy_mobile_text_input_state(uint32_t* active) { *active = 1; return 0; } +extern "C" int renpy_mobile_set_surface_size(uint32_t, uint32_t) { return 0; } +extern "C" void renpy_mobile_shutdown() { callbacks = nullptr; } + +int main() { + aetherkiri::renpy::RegisterRuntimeProvider(); + assert(provider && provider->probe(nullptr, "/project") > 0); + engine_runtime_host_v1_t host{}; + host.struct_size = sizeof(host); + engine_create_desc_t desc{}; + desc.struct_size = sizeof(desc); + void* runtime = nullptr; + assert(provider->create(nullptr, &host, &desc, &runtime) == ENGINE_RESULT_OK); + char project[] = "/project"; + assert(provider->open_game(runtime, project, nullptr) == ENGINE_RESULT_OK); + std::memset(project, 'x', sizeof(project) - 1); + assert(starts == 0); // async Open cannot bind Python/greenlet to its thread. + assert(provider->tick(runtime, 16) == ENGINE_RESULT_OK && starts == 1); + uint32_t rendered = 0; + assert(provider->get_frame_rendered_flag(runtime, &rendered) == ENGINE_RESULT_OK && rendered == 1); + uint8_t pixels[4]{}; + assert(provider->read_frame_rgba(runtime, pixels, sizeof(pixels)) == ENGINE_RESULT_OK && pixels[0] == 1); + push_frame = false; + assert(provider->tick(runtime, 16) == ENGINE_RESULT_OK); + assert(provider->get_frame_rendered_flag(runtime, &rendered) == ENGINE_RESULT_OK && rendered == 0); + engine_input_event_t event{}; + event.struct_size = sizeof(event); + event.type = ENGINE_INPUT_EVENT_POINTER_DOWN; + event.x = .5; + event.y = .25; + event.button = 1; + assert(provider->send_input(runtime, &event) == ENGINE_RESULT_OK); + assert(last_input.type == RENPY_MOBILE_INPUT_POINTER && last_input.value == RENPY_MOBILE_POINTER_DOWN && last_input.code == 3); + assert(last_input.x == .5f && last_input.y == .25f); + event.type = ENGINE_INPUT_EVENT_KEY_DOWN; + event.key_code = 0x41; + event.modifiers = 0x01 | 0x04 | 0x80; + assert(provider->send_input(runtime, &event) == ENGINE_RESULT_OK); + assert(last_input.code == 'a' && last_input.modifiers == 195 && last_input.repeat == 1); + event.type = ENGINE_INPUT_EVENT_TEXT_INPUT; + event.unicode_codepoint = 0x1f642; + assert(provider->send_input(runtime, &event) == ENGINE_RESULT_OK && last_text == "\xf0\x9f\x99\x82"); + event.unicode_codepoint = 0xd800; + assert(provider->send_input(runtime, &event) == ENGINE_RESULT_INVALID_ARGUMENT); + event.type = ENGINE_INPUT_EVENT_POINTER_MOVE; + event.x = std::nan(""); + assert(provider->send_input(runtime, &event) == ENGINE_RESULT_INVALID_ARGUMENT); + uint32_t active = 0; + assert(provider->get_text_input_state(runtime, &active) == ENGINE_RESULT_OK && active == 1); + char debug[256]{}; + uint32_t written = 0; + assert(provider->pause(runtime) == ENGINE_RESULT_OK); + assert(provider->get_plugin_debug_info(runtime, debug, sizeof(debug), &written) == ENGINE_RESULT_OK); + assert(std::strstr(debug, "\"paused\":true")); + assert(provider->resume(runtime) == ENGINE_RESULT_OK); + finish = true; + assert(provider->tick(runtime, 16) == ENGINE_RESULT_INVALID_STATE); + assert(std::strstr(provider->get_last_error(runtime), "runtime requested termination")); + assert(provider->get_plugin_debug_info(runtime, debug, sizeof(debug), &written) == ENGINE_RESULT_OK); + assert(std::strstr(debug, "\"exited\":true")); + provider->destroy(runtime); + assert(!callbacks); +} diff --git a/demos/aetherkiri-renpy/README.md b/demos/aetherkiri-renpy/README.md index 0202602ac..d3179d321 100644 --- a/demos/aetherkiri-renpy/README.md +++ b/demos/aetherkiri-renpy/README.md @@ -19,3 +19,48 @@ RENPY_SDK=/path/to/renpy-sdk bash ../../tools/run_renpy_smoke.sh The automated smoke command compiles the project and initializes Ren'Py. A device or simulator run is still required to verify the host lifecycle and input behavior on mobile. + +For a real **cloud-hosted Android** debug APK and authorized device/emulator: + +```bash +python3 tools/run_renpy_android_device_acceptance.py \ + --apk out/godot/android/debug/Aether-debug.apk \ + --serial emulator-5554 --cloud-device \ + --output-dir /tmp/aether-renpy-android-acceptance +``` + +Run this command from the repository root inside the cloud task. It installs the +APK with adb, uses Android `run-as` to stage this public demo and a one-shot debug +request in the app's private test directory, and launches the actual app. It +requires a debuggable APK containing the native Ren'Py runtime; stock RAPT alone +does not satisfy the host lifecycle ABI. It does not use a user computer. + +The device mode is enabled only by `game/aether-device-request.json`. Real +Ren'Py statements write a fresh run ID into `aether-device-checkpoints.jsonl`. +The harness requires actual OS touch down/up, Android text/Enter, a visible +system keyboard requested by native text-input state, Home/foreground lifecycle +pause/resume, another game touch after resume, and explicit `renpy.quit` followed +by observed native exit, engine teardown, and removal of the actual Android +activity. [Android may retain a cached process](https://developer.android.com/guide/components/activities/process-lifecycle); +its PID is recorded separately. +The regular interactive fixture +remains available when the request file is absent. + +Evidence includes APK SHA-256, device build fingerprint, adb command results, +the Ren'Py script checkpoints, native provider RGBA PNGs, OS screenshots, +pixel-comparison reports, native pause/resume/exit state, and process logcat. +Screenshots must show the same nonblank pixels as the provider's real frame; +resume additionally requires a new native frame serial after the pause. +viewport fallback images, colorful system screens, startup-only checks, and +simulated provider frames cannot satisfy this gameplay run. + +`result.json` distinguishes `passed`, `failed`, and `not_run`. Exit status is +respectively 0, 1, or 2. Missing tools, device, APK, or permitted debug staging +produce `not_run`; a timeout or missing gameplay/frame/lifecycle evidence after +launch is a failure. Neither result means mobile gameplay passed. This Linux +cloud task cannot execute iOS Simulator acceptance without an authorized macOS +cloud runner with Xcode/iOS Simulator; iOS execution remains unverified. + +The automatic Android text check covers an ASCII string and Enter from the +system keyboard path. Unicode text and IME composition need separate real +device coverage before claiming those input methods were tested. diff --git a/demos/aetherkiri-renpy/game/options.rpy b/demos/aetherkiri-renpy/game/options.rpy index 4ff15eec6..39af09cd1 100644 --- a/demos/aetherkiri-renpy/game/options.rpy +++ b/demos/aetherkiri-renpy/game/options.rpy @@ -1,8 +1,8 @@ # AetherKiri's minimal Ren'Py runtime fixture. -config.name = "AetherKiri Ren'Py Fixture" -config.version = "1.0" -config.window_title = "AetherKiri Ren'Py Fixture" -config.screen_width = 960 -config.screen_height = 540 -config.developer = True +define config.name = "AetherKiri Ren'Py Fixture" +define config.version = "1.0" +define config.window_title = "AetherKiri Ren'Py Fixture" +define config.screen_width = 960 +define config.screen_height = 540 +define config.developer = True diff --git a/demos/aetherkiri-renpy/game/script.rpy b/demos/aetherkiri-renpy/game/script.rpy index 852f9f406..ed33f65b5 100644 --- a/demos/aetherkiri-renpy/game/script.rpy +++ b/demos/aetherkiri-renpy/game/script.rpy @@ -5,6 +5,48 @@ default player_name = "" image bg fixture = "scene.svg" +# Device evidence comes from executed Ren'Py statements, never from the host. +# The request is staged only into the debug app's private test project. +init python: + import json + import os + import time + + aether_device_request = {} + try: + with open(os.path.join(config.gamedir, "aether-device-request.json"), "r") as request_file: + aether_device_request = json.load(request_file) + except (OSError, ValueError): + pass + + if aether_device_request.get("run_id"): + # Supported by Ren'Py's common/00start.rpy. The device flow starts at + # this game's actual start label, independently of SDK menu layout. + os.environ["RENPY_SKIP_MAIN_MENU"] = "1" + + def aether_device_checkpoint(stage, **details): + if not aether_device_request.get("run_id"): + return + entry = dict(run_id=aether_device_request["run_id"], stage=stage, + source="renpy-script", monotonic=time.monotonic(), details=details) + with open(os.path.join(config.gamedir, "aether-device-checkpoints.jsonl"), "a") as checkpoint_file: + checkpoint_file.write(json.dumps(entry, ensure_ascii=False) + "\n") + checkpoint_file.flush() + os.fsync(checkpoint_file.fileno()) + +screen fixture_device_button(caption): + modal True + text "AetherKiri real Ren'Py device acceptance" xpos 48 ypos 168 size 28 color "#ffffff" + textbutton caption: + xpos 360 + ypos 222 + xsize 240 + ysize 96 + text_size 24 + background Solid("#17344c") + hover_background Solid("#256a88") + action Return() + screen fixture_start(): modal True frame: @@ -28,6 +70,8 @@ screen fixture_menu(): textbutton "Quit" action Return("quit") label start: + if aether_device_request.get("run_id"): + jump aether_device_acceptance scene bg fixture with fade narrator "AetherKiri Ren'Py runtime fixture started." narrator "This screen is rendered by the real Ren'Py project." @@ -54,3 +98,22 @@ label interaction: else: $ renpy.quit() return + +label aether_device_acceptance: + scene bg fixture + $ aether_device_checkpoint("start_ready", button=[480, 270]) + call screen fixture_device_button("Start") + $ aether_device_checkpoint("touch_received") + $ aether_device_checkpoint("text_ready") + $ player_name = renpy.input("Enter the cloud acceptance name:", default="", length=32) + $ aether_device_checkpoint("text_received", text=player_name) + # The host must background and resume the real application while this + # interaction is waiting, then deliver another real system touch. + $ aether_device_checkpoint("post_text_ready", button=[480, 270]) + call screen fixture_device_button("Continue after resume") + $ aether_device_checkpoint("resumed_touch_received") + $ aether_device_checkpoint("quit_ready", button=[480, 270]) + call screen fixture_device_button("Quit") + $ aether_device_checkpoint("quit_requested") + $ renpy.quit(confirm=False) + return diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 4cf29f269..b32bb694b 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -27,6 +27,8 @@ GODOT_BIN="${GODOT_BIN:-/Applications/Godot.app/Contents/MacOS/Godot}" GODOT_TEMPLATE_DIR="${GODOT_TEMPLATE_DIR:-$HOME/Library/Application Support/Godot/export_templates/4.7.2.stable}" GODOT_APP_DIR="$PROJECT_ROOT/apps/godot_app" PARALLEL_JOBS="${JOBS:-8}" +RENPY_MOBILE_ROOT="${AETHERKIRI_RENPY_MOBILE_ROOT:-${RENPY_MOBILE_ROOT:-$PROJECT_ROOT/out/renpy-mobile-source/payload}}" +RENPY_PRIVATE_ROOT="${AETHERKIRI_RENPY_ANDROID_PRIVATE_ASSETS:-$RENPY_MOBILE_ROOT/rapt/runtime/private}" ensure_vcpkg() { if [[ -f "$PROJECT_ROOT/.devtools/vcpkg/.vcpkg-root" ]]; then @@ -86,9 +88,6 @@ ensure_host_rust() { esac } -ensure_vcpkg -ensure_host_rust - # Resolve a Rust toolchain that can build for the requested Android target. # Homebrew's Rust formula ships without Android targets, so a cargo found in # PATH may fail with E0463 ("can't find crate for core") even though rustup @@ -207,6 +206,12 @@ export ANDROID_HOME export ANDROID_SDK_ROOT="$ANDROID_HOME" export ANDROID_NDK_HOME="$ANDROID_NDK_HOME_RESOLVED" export ANDROID_NDK="$ANDROID_NDK_HOME_RESOLVED" +if [[ ! -d "$ANDROID_HOME" ]]; then + echo "Error: Android SDK not found at $ANDROID_HOME." >&2 + exit 1 +fi +ensure_vcpkg +ensure_host_rust # The Android export uses a custom Gradle build template (minSdk 26 lives in # export_presets.cfg). The template is generated content and gitignored; @@ -245,21 +250,19 @@ stage_renpy_android_support() { ;; esac - local mobile_root="${AETHERKIRI_RENPY_MOBILE_ROOT:-${RENPY_MOBILE_ROOT:-}}" + local mobile_root="$RENPY_MOBILE_ROOT" if [[ -z "$mobile_root" ]]; then echo "Error: AETHERKIRI_ENABLE_RENPY=ON requires AETHERKIRI_RENPY_MOBILE_ROOT or RENPY_MOBILE_ROOT" >&2 - echo " Stage official RAPT inputs with tools/install_renpy_mobile_support.sh first." >&2 + echo " Build the native lifecycle payload with tools/run_renpy_mobile_source_build.sh --mode build first." >&2 exit 1 fi local stage_args=( --mobile-root "$mobile_root" --godot-build "$GODOT_APP_DIR/android/build" + --private-assets "$RENPY_PRIVATE_ROOT" ) - if [[ -n "${AETHERKIRI_RENPY_ANDROID_PRIVATE_ASSETS:-}" ]]; then - stage_args+=(--private-assets "$AETHERKIRI_RENPY_ANDROID_PRIVATE_ASSETS") - fi - echo "==> Staging official Ren'Py RAPT Android support into the Godot export" + echo "==> Staging rebuilt Ren'Py lifecycle runtime into the Godot export" bash "$PROJECT_ROOT/tools/stage_renpy_android_support.sh" "${stage_args[@]}" } @@ -360,11 +363,9 @@ build_abi() { local cmake_config_args=( -D "CMAKE_MAKE_PROGRAM=$CMAKE_MAKE_PROGRAM" -D "AETHERKIRI_ENABLE_INTERNAL=${AETHERKIRI_ENABLE_INTERNAL:-ON}" - # Android links the Ren'Py mobile registration stub. It deliberately - # returns NOT_SUPPORTED until the RAPT/JNI bootstrap is wired; keeping - # this opt-in here makes mobile CI compile the guard instead of only - # staging archives. -D "AETHERKIRI_ENABLE_RENPY=${AETHERKIRI_ENABLE_RENPY:-OFF}" + -D "AETHERKIRI_RENPY_MOBILE_LIBRARY=$RENPY_MOBILE_ROOT/rapt/prototype/renpyandroid/src/main/jniLibs/$abi/librenpython.so" + -D "AETHERKIRI_RENPY_PRIVATE_ROOT=$RENPY_PRIVATE_ROOT" -D "AETHERKIRI_ENABLE_SOFTPAL_RUNTIME=${AETHERKIRI_ENABLE_SOFTPAL_RUNTIME:-OFF}" -D "AETHERKIRI_SOFTPAL_DIR=${AETHERKIRI_SOFTPAL_DIR:-$PROJECT_ROOT/packages/AetherSoftPal}" ) @@ -441,10 +442,12 @@ for abi in "${ABI_LIST[@]}"; do done if [[ ! -x "$GODOT_BIN" ]]; then - echo "Warning: Godot not found at $GODOT_BIN; native libraries were staged only." >&2 + echo "Error: Godot not found at $GODOT_BIN; cannot produce the requested Android APK." >&2 + exit 1 elif [[ ! -f "$GODOT_TEMPLATE_DIR/android_debug.apk" || ! -f "$GODOT_TEMPLATE_DIR/android_release.apk" ]]; then - echo "Warning: Godot Android export templates are missing in $GODOT_TEMPLATE_DIR; native libraries were staged only." >&2 + echo "Error: Godot Android export templates are missing in $GODOT_TEMPLATE_DIR; cannot produce an APK." >&2 echo " Expected android_debug.apk and android_release.apk." >&2 + exit 1 else echo "==> Exporting Godot Android APK" mkdir -p "$PROJECT_ROOT/out/godot/android/$BUILD_TYPE_LOWER" @@ -465,6 +468,13 @@ else fi "${godot_export_command[@]}" --headless --path "$GODOT_APP_DIR" \ "$export_mode" "$export_preset" "$export_path" + [[ -s "$export_path" ]] || { echo "Android export did not create an APK: $export_path" >&2; exit 1; } + case "${AETHERKIRI_ENABLE_RENPY:-OFF}" in + ON|TRUE|YES|1|on|true|yes) + python3 "$PROJECT_ROOT/tools/validate_renpy_mobile_payload.py" \ + --platform android --apk "$export_path" + ;; + esac fi echo "Android build output: $PROJECT_ROOT/out/godot/android/$BUILD_TYPE_LOWER" diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index ca8b7cf79..75c1c6675 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -53,18 +53,24 @@ GODOT_APP_DIR="$PROJECT_ROOT/apps/godot_app" GODOT_BIN_DIR="$GODOT_APP_DIR/bin/$GODOT_TRIPLET_DIR" RUNTIME_CJK_FONT_SOURCE="$GODOT_APP_DIR/assets/fonts/aetherkiri-runtime-cjk.otf" RUNTIME_SYMBOL_FONT_SOURCE="$GODOT_APP_DIR/assets/fonts/aetherkiri-runtime-symbols.ttf" -# The Renios archive is intentionally opt-in. When present, the build folds -# the official static-library closure into the Godot extension archive and -# copies only its resources/framework into the exported app. It must never +# When Ren'Py is enabled, fold the rebuilt Renios static-library closure into +# the Godot extension archive and copy its resources/framework into the app. +# It must never # copy or compile Renios' prototype main.c: Godot already owns the process and # the UIKit/SDL application entrypoint. -RENPY_MOBILE_ROOT="${AETHERKIRI_RENPY_MOBILE_ROOT:-${RENPY_MOBILE_ROOT:-}}" -# Renios archives contain the native prototype/resources but intentionally do -# not contain a game-specific `base/` directory. A caller may provide the -# generated Renios base explicitly for a bundle smoke; gameplay remains gated -# by the adapter contract regardless. -RENPY_RENIOS_BASE="${AETHERKIRI_RENPY_RENIOS_BASE:-}" +RENPY_MOBILE_ROOT="${AETHERKIRI_RENPY_MOBILE_ROOT:-${RENPY_MOBILE_ROOT:-$PROJECT_ROOT/out/renpy-mobile-source/payload}}" +RENPY_RENIOS_BASE="${AETHERKIRI_RENPY_RENIOS_BASE:-$RENPY_MOBILE_ROOT/renios/prototype/base}" PARALLEL_JOBS="${JOBS:-8}" +if [[ "$(uname -s)" != Darwin ]] || ! command -v xcrun >/dev/null 2>&1 || ! command -v xcodebuild >/dev/null 2>&1; then + echo "Error: iOS app packaging requires a cloud macOS host with Xcode and its licensed iPhoneOS/iPhoneSimulator SDK; this host has no Apple build tools." >&2 + exit 1 +fi +IOS_SDK=iphoneos +[[ "$SIMULATOR" == true ]] && IOS_SDK=iphonesimulator +xcrun --sdk "$IOS_SDK" --show-sdk-path >/dev/null || { + echo "Error: Xcode lacks the $IOS_SDK SDK required for this iOS target." >&2 + exit 1 +} FORCE_LOAD_PLUGIN_ARCHIVES=( "libSDL2.a" "libkrkr2plugin.a" @@ -307,13 +313,13 @@ EOF renios_enabled() { case "${AETHERKIRI_ENABLE_RENPY:-OFF}" in - ON|TRUE|YES|1|on|true|yes) [[ -n "$RENPY_MOBILE_ROOT" ]] ;; + ON|TRUE|YES|1|on|true|yes) return 0 ;; *) return 1 ;; esac } renios_link_enabled() { - case "${AETHERKIRI_RENPY_RENIOS_LINK:-OFF}" in + case "${AETHERKIRI_RENPY_RENIOS_LINK:-ON}" in ON|TRUE|YES|1|on|true|yes) return 0 ;; *) return 1 ;; esac @@ -331,7 +337,9 @@ renios_configuration() { if [[ "${SIMULATOR:-false}" == true ]]; then printf 'debug\n' else - printf '%s\n' "$BUILD_TYPE_LOWER" + # Upstream Renios debug archives contain simulator slices only; + # release archives contain the device arm64 slice for either app mode. + printf 'release\n' fi } @@ -356,7 +364,7 @@ renios_archive_is_excluded() { case "$(basename "$1")" in # SDL2main owns the UIKit/SDL application entrypoint in the official # prototype. Godot's application must supply the only entrypoint. - libSDL2main.a|libSDL2_test.a) return 0 ;; + libSDL2main.a|libSDL2_test.a|libSDL3main.a|libSDL3_test.a) return 0 ;; *) return 1 ;; esac } @@ -394,8 +402,21 @@ renios_archive_names() { renios_launcher_probe() { renios_enabled || return 0 - bash "$PROJECT_ROOT/tools/test_renios_ios_launcher.sh" \ - "$RENPY_MOBILE_ROOT" "$(renios_configuration)" + renios_link_enabled || { + echo "Error: Ren'Py enabled app builds require AETHERKIRI_RENPY_RENIOS_LINK=ON; disabling native linking cannot produce a runnable game." >&2 + return 1 + } + local prebuilt arch=arm64 + [[ "$SIMULATOR" == true ]] && arch="$SIMULATOR_ARCH" + prebuilt="$(renios_prebuilt_root)" + collect_renios_archives "$prebuilt" >/dev/null + [[ -d "$RENPY_MOBILE_ROOT/renios/prototype/Frameworks/MetalANGLE.xcframework" ]] || { + echo "Error: Renios MetalANGLE.xcframework is missing." >&2 + return 1 + } + python3 "$PROJECT_ROOT/tools/validate_renpy_mobile_payload.py" \ + --platform ios --arch "$arch" --sdk "$IOS_SDK" \ + --library "$prebuilt/librenpython.a" --private-root "$RENPY_RENIOS_BASE" } stage_renios_ios_resources() { @@ -406,8 +427,8 @@ stage_renios_ios_resources() { local prototype prototype="$(renios_prototype_root 2>/dev/null || true)" if [[ -z "$prototype" ]]; then - echo "Warning: AETHERKIRI_ENABLE_RENPY=ON but Renios support root is not staged; keeping iOS Ren'Py NOT_SUPPORTED" >&2 - return 0 + echo "Error: Ren'Py enabled iOS build is missing the rebuilt Renios support root." >&2 + return 1 fi local app_root="$export_root/Aether" @@ -445,8 +466,8 @@ stage_renios_ios_resources() { local prebuilt prebuilt="$(renios_prebuilt_root 2>/dev/null || true)" if [[ -z "$prebuilt" ]]; then - echo "Warning: Renios prebuilt closure is unavailable; resources staged but iOS Ren'Py remains NOT_SUPPORTED" >&2 - return 0 + echo "Error: Renios prebuilt closure is unavailable." >&2 + return 1 fi { printf 'Renios archive: %s\n' "$RENPY_MOBILE_ROOT" @@ -455,23 +476,23 @@ stage_renios_ios_resources() { fi printf 'Configuration: %s\n' "$(renios_configuration)" printf 'Static closure: %s\n' "$(renios_archive_names "$prebuilt")" - printf 'Excluded archives: libSDL2main.a,libSDL2_test.a (host entrypoint/test)\n' + printf 'Excluded archives: libSDL2main.a,libSDL2_test.a,libSDL3main.a,libSDL3_test.a (host entrypoint/test)\n' if [[ -d "$resource_root/base" ]]; then printf 'Base resources: bundled\n' else printf 'Base resources: none (Renios archive is game-agnostic)\n' fi printf 'Host entrypoint: Godot (Renios main.c intentionally omitted)\n' - printf 'Runtime status: NOT_SUPPORTED until lifecycle/render/input adapter is linked\n' + printf 'Native lifecycle: linked and export validated\n' + printf 'Gameplay verification: requires device/simulator execution\n' } > "$resource_root/renios-manifest.txt" } combine_ios_static_extension() { local output="$1" local triplet="$2" - # Verify that the staged closure still has the known blocking launcher - # contract before merging it into the host archive. This probe never - # invokes launcher_main and cannot turn the provider on by itself. + # Validate the lifecycle fork and its Python runtime before merging the + # closure. Never invoke the process-owning launcher_main entrypoint. renios_launcher_probe local vcpkg_triplet_root="$CMAKE_BUILD_DIR/vcpkg_installed/$triplet" local vcpkg_lib_dir="$vcpkg_triplet_root/lib" @@ -568,7 +589,8 @@ combine_ios_static_extension() { libs+=("$renios_archive") done < <(collect_renios_archives "$renios_prebuilt") else - echo "Warning: Renios support requested but no native closure was found; iOS Ren'Py remains NOT_SUPPORTED" >&2 + echo "Error: Renios support requested but no native closure was found." >&2 + return 1 fi fi @@ -622,9 +644,8 @@ stage_force_load_plugin_archives() { local resolved mkdir -p "$destination" for source in "${FORCE_LOAD_PLUGIN_SOURCES[@]}"; do - # Renios supplies the iOS SDL archive in its merged native closure. - # Do not copy/force-load the vcpkg SDL archive as well, otherwise the - # final Xcode link sees duplicate SDL symbols. + # Prefer the SDL2 archive already merged from Renios. Force-loading + # vcpkg's second SDL2 would duplicate its global symbol definitions. if renios_enabled && renios_link_enabled && [[ "$(basename "$source")" == "libSDL2.a" ]]; then continue fi @@ -765,11 +786,18 @@ patch_ios_export_project() { fi flags+=" -Wl,-force_load,Aether/bin/ios/$export_build_type/$archive" done - flags+=' -liconv -framework Accelerate -framework AudioToolbox -framework AVFoundation -framework CoreAudio -framework CoreBluetooth -framework CoreHaptics -framework CoreMedia -framework CoreMotion -framework CoreVideo -framework CoreServices -framework GameController -framework ImageIO -framework VideoToolbox -framework CoreGraphics -framework QuartzCore -framework Metal -framework MetalKit -framework OpenGLES -framework Security -framework StoreKit -framework SystemConfiguration -framework MobileCoreServices' + flags+=' -liconv -framework Accelerate -framework AudioToolbox -framework AVFoundation -framework CoreAudio -framework CoreBluetooth -framework CoreFoundation -framework CoreHaptics -framework CoreMedia -framework CoreMotion -framework CoreVideo -framework CoreServices -framework GameController -framework ImageIO -framework VideoToolbox -framework CoreGraphics -framework QuartzCore -framework Metal -framework MetalKit -framework OpenGLES -framework Security -framework StoreKit -framework SystemConfiguration -framework MobileCoreServices' if renios_enabled; then # The resource staging step places this official dynamic framework in # Aether/Frameworks and the PBX patch embeds it in the app bundle. flags+=' -F Aether/Frameworks -framework MetalANGLE' + # These functions are resolved by the provider at runtime. Root each + # archive member and retain its global symbol under dead stripping. + local lifecycle_symbol + for lifecycle_symbol in bootstrap bind_window init tick frame input pause resume shutdown text_input_state set_surface_size; do + flags+=" -Wl,-u,_renpy_mobile_$lifecycle_symbol" + done + flags+=' -Wl,-export_dynamic' fi if [[ -f "$project_file" ]]; then @@ -879,6 +907,11 @@ package_ios_unsigned_ipa() { echo "==> Removing non-runtime symbols from iOS Release executable..." "$PROJECT_ROOT/tools/strip_runtime_symbols.sh" macho-executable "$app_binary" fi + if renios_enabled; then + python3 "$PROJECT_ROOT/tools/validate_renpy_mobile_payload.py" \ + --platform ios --arch arm64 --sdk iphoneos --executable --library "$app_binary" \ + --private-root "$export_dir/build/Aether.app/renios/base" + fi echo "==> Packaging into unsigned .ipa..." mkdir -p "$export_dir/Payload" @@ -889,6 +922,31 @@ package_ios_unsigned_ipa() { echo "Unsigned IPA created: $export_dir/Aether-${config_cap}-Unsigned.ipa" } +package_ios_simulator_app() { + local export_dir="$1" config_cap=Release + [[ "$BUILD_TYPE_LOWER" == debug ]] && config_cap=Debug + local output_dir="$export_dir/build-simulator-$SIMULATOR_ARCH" + xcodebuild build \ + -project "$export_dir/Aether.xcodeproj" -scheme Aether \ + -configuration "$config_cap" -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + "ARCHS=$SIMULATOR_ARCH" ONLY_ACTIVE_ARCH=YES \ + CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO \ + "CONFIGURATION_BUILD_DIR=$output_dir" + [[ -s "$output_dir/Aether.app/Aether" ]] || { + echo "Error: Xcode did not produce a runnable iOS simulator app." >&2 + return 1 + } + if renios_enabled; then + python3 "$PROJECT_ROOT/tools/validate_renpy_mobile_payload.py" \ + --platform ios --arch "$SIMULATOR_ARCH" --sdk iphonesimulator --executable \ + --library "$output_dir/Aether.app/Aether" \ + --private-root "$output_dir/Aether.app/renios/base" + fi + (cd "$output_dir" && zip -qry "Aether-$SIMULATOR_ARCH-Simulator.zip" Aether.app) + echo "Runnable simulator app: $output_dir/Aether.app (device gameplay unverified)" +} + with_ios_only_gdextension() { local gdextension_file="$GODOT_APP_DIR/aether_kiri.gdextension" local backup_file @@ -915,6 +973,7 @@ with_ios_only_gdextension() { } echo "==> Building native engine and Godot extension" +renios_launcher_probe cmake_config_args=( -D "CMAKE_MAKE_PROGRAM=$CMAKE_MAKE_PROGRAM" -D "AETHERKIRI_ENABLE_INTERNAL=${AETHERKIRI_ENABLE_INTERNAL:-ON}" @@ -924,9 +983,9 @@ cmake_config_args=( -D "AETHERKIRI_OBFUSCATOR_PLUGIN=${AETHERKIRI_OBFUSCATOR_PLUGIN:-}" -D "AETHERKIRI_OBFUSCATION_BUILD_ID=${AETHERKIRI_OBFUSCATION_BUILD_ID:-local}" -D "AETHERKIRI_ENABLE_RFVP=${AETHERKIRI_ENABLE_RFVP:-OFF}" - # iOS links the Ren'Py mobile registration stub. It deliberately returns - # NOT_SUPPORTED until the Renios/Xcode bootstrap is wired. -D "AETHERKIRI_ENABLE_RENPY=${AETHERKIRI_ENABLE_RENPY:-OFF}" + -D "AETHERKIRI_RENPY_MOBILE_LIBRARY=$RENPY_MOBILE_ROOT/renios/prototype/prebuilt/$(renios_configuration)/librenpython.a" + -D "AETHERKIRI_RENPY_PRIVATE_ROOT=$RENPY_RENIOS_BASE" ) if [[ -n "${RFVP_CARGO:-}" ]]; then cmake_config_args+=(-D "RFVP_CARGO=$RFVP_CARGO") @@ -969,8 +1028,16 @@ if [[ "$SIMULATOR" == true ]]; then fi if [[ ! -x "$GODOT_BIN" ]]; then + if [[ "$PACKAGE_IPA" == true || "$SIMULATOR" == true ]]; then + echo "Error: Godot not found at $GODOT_BIN; cannot package an iOS app." >&2 + exit 1 + fi echo "Warning: Godot not found at $GODOT_BIN; native libraries were staged only." >&2 elif [[ ! -f "$GODOT_EXPORT_TEMPLATE" ]]; then + if [[ "$PACKAGE_IPA" == true || "$SIMULATOR" == true ]]; then + echo "Error: Godot iOS export template missing at $GODOT_EXPORT_TEMPLATE; cannot package an iOS app." >&2 + exit 1 + fi echo "Warning: Godot iOS export template missing at $GODOT_EXPORT_TEMPLATE; native libraries were staged only." >&2 else echo "==> Exporting Godot iOS project" @@ -995,8 +1062,8 @@ else patch_ios_export_project "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER/Aether.xcodeproj" "$PATCH_ARCH" "$BUILD_TYPE_LOWER" if [[ "$PACKAGE_IPA" == true && "$SIMULATOR" == false ]]; then package_ios_unsigned_ipa "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER" "$BUILD_TYPE_LOWER" - elif [[ "$PACKAGE_IPA" == true && "$SIMULATOR" == true ]]; then - echo "[WARN] --package-ipa is ignored when building for simulator." + elif [[ "$SIMULATOR" == true ]]; then + package_ios_simulator_app "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER" fi fi diff --git a/tools/run_renpy_android_device_acceptance.py b/tools/run_renpy_android_device_acceptance.py new file mode 100644 index 000000000..5c23a7544 --- /dev/null +++ b/tools/run_renpy_android_device_acceptance.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +"""Install and exercise an actual Ren'Py-enabled APK on a cloud Android device. + +No fake adb, scripted provider, skip-execution switch, or host-PC fallback is +provided. Missing APK/adb/device/debug staging yields not_run and exit status 2; +missing gameplay evidence after installation yields failed and exit status 1. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shlex +import shutil +import struct +import subprocess +import tarfile +import tempfile +import time +import uuid +import zlib +from pathlib import Path + + +class Blocked(RuntimeError): + pass + + +class Failed(RuntimeError): + pass + + +def png_pixels(path: Path) -> tuple[int, int, bytes, int]: + """Decode real Android/Godot 8-bit RGB(A) evidence without extra packages.""" + raw = path.read_bytes() + if raw[:8] != b"\x89PNG\r\n\x1a\n": + raise Failed(f"Evidence is not a PNG: {path.name}") + offset, compressed = 8, bytearray() + width = height = channels = 0 + while offset + 12 <= len(raw): + count = struct.unpack_from(">I", raw, offset)[0] + kind = raw[offset + 4:offset + 8] + data = raw[offset + 8:offset + 8 + count] + if kind == b"IHDR": + width, height, depth, color, compression, filtering, interlace = struct.unpack(">IIBBBBB", data) + if depth != 8 or color not in (2, 6) or compression or filtering or interlace: + raise Failed(f"Unsupported real PNG format in {path.name}") + channels = 4 if color == 6 else 3 + elif kind == b"IDAT": + compressed.extend(data) + elif kind == b"IEND": + break + offset += 12 + count + if width <= 0 or height <= 0 or width * height > 30_000_000 or not channels: + raise Failed(f"Invalid PNG dimensions: {path.name}") + stream = zlib.decompress(compressed) + stride = width * channels + if len(stream) != height * (stride + 1): + raise Failed(f"Incomplete PNG pixels: {path.name}") + pixels = bytearray(height * stride) + previous = bytearray(stride) + for y in range(height): + start = y * (stride + 1) + filter_type = stream[start] + row = bytearray(stream[start + 1:start + 1 + stride]) + for x in range(stride): + left = row[x - channels] if x >= channels else 0 + up = previous[x] + corner = previous[x - channels] if x >= channels else 0 + if filter_type == 1: + value = left + elif filter_type == 2: + value = up + elif filter_type == 3: + value = (left + up) // 2 + elif filter_type == 4: + p = left + up - corner + distance = (abs(p - left), abs(p - up), abs(p - corner)) + value = (left, up, corner)[distance.index(min(distance))] + elif filter_type == 0: + value = 0 + else: + raise Failed(f"Invalid PNG filter in {path.name}") + row[x] = (row[x] + value) & 255 + pixels[y * stride:(y + 1) * stride] = row + previous = row + return width, height, bytes(pixels), channels + + +def pixel(image: tuple[int, int, bytes, int], x: int, y: int) -> tuple[int, int, int]: + width, height, data, channels = image + if not 0 <= x < width or not 0 <= y < height: + raise Failed("Native frame's presentation geometry lies outside the device screenshot") + offset = (y * width + x) * channels + return tuple(data[offset:offset + 3]) + + +def image_stats(image: tuple[int, int, bytes, int]) -> dict: + width, height, _, _ = image + colors, luma = set(), [] + for y in range(0, height, max(1, height // 64)): + for x in range(0, width, max(1, width // 64)): + rgb = pixel(image, x, y) + colors.add(rgb) + luma.append(sum(rgb) / 3) + stats = {"width": width, "height": height, "sampled_colors": len(colors), + "luma_range": max(luma) - min(luma)} + if stats["sampled_colors"] < 32 or stats["luma_range"] < 40: + raise Failed("Real image is blank or insufficiently varied for the Ren'Py scene") + return stats + + +class Acceptance: + def __init__(self, args: argparse.Namespace): + self.args = args + self.output = args.output_dir.resolve() + self.output.mkdir(parents=True, exist_ok=True) + self.run_id = uuid.uuid4().hex + self.device_prefix = [args.adb, "-s", args.serial] + self.game_root = "" + self.data_root = "" + self.pid = "" + self.installed = False + self.summary = {"status": "not_run", "run_id": self.run_id, + "source": "actual-apk-adb-cloud-device", "serial": args.serial, + "package": args.package, "checks": [], "blocker": ""} + + def command(self, *parts: str, check: bool = True, timeout: int = 20) -> bytes: + command = self.device_prefix + list(parts) + try: + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) + except (OSError, subprocess.TimeoutExpired) as exc: + raise Failed(f"Device command did not complete: {parts[0]}: {exc}") from exc + with (self.output / "commands.jsonl").open("a") as log: + log.write(json.dumps({"command": command, "returncode": result.returncode, + "stderr": result.stderr.decode(errors="replace"), + "time": time.time()}) + "\n") + if check and result.returncode: + raise Failed(f"adb {' '.join(parts[:3])} failed: {result.stderr.decode(errors='replace').strip()}") + return result.stdout + + def shell(self, *parts: str, check: bool = True, timeout: int = 20) -> bytes: + # adb shell joins arguments into a remote shell string. Quote at that + # boundary instead of relying on local subprocess's argument handling. + return self.command("shell", shlex.join(parts), check=check, timeout=timeout) + + def private(self, relative: str, required: bool = False) -> bytes: + return self.command("exec-out", "run-as", self.args.package, "cat", relative, check=required) + + def rows(self, relative: str) -> list[dict]: + result = [] + for line in self.private(relative).decode(errors="replace").splitlines(): + try: + row = json.loads(line) + except ValueError: + continue + if isinstance(row, dict) and row.get("run_id") == self.run_id: + result.append(row) + return result + + def game_rows(self) -> list[dict]: + return self.rows("files/renpy-device-demo/game/aether-device-checkpoints.jsonl") + + def observer_rows(self) -> list[dict]: + return self.rows("files/renpy-device-evidence/observer.jsonl") + + def wait(self, description: str, condition, timeout: int | None = None): + expires = time.monotonic() + (timeout or self.args.stage_timeout) + while time.monotonic() < expires: + for row in self.observer_rows(): + if row.get("kind") == "failure": + raise Failed(f"Actual mobile observer failed: {row.get('message')}") + result = condition() + if result: + return result + time.sleep(0.4) + raise Failed(f"Timed out waiting for {description}; execution is not accepted") + + def wait_game(self, stage: str) -> dict: + return self.wait(f"executed Ren'Py checkpoint {stage}", lambda: next( + (row for row in self.game_rows() if row.get("stage") == stage and row.get("source") == "renpy-script"), None)) + + def wait_observer(self, kind: str, **fields) -> dict: + return self.wait(f"native observer {kind} {fields}", lambda: next( + (row for row in self.observer_rows() if row.get("kind") == kind + and all(row.get(key) == value for key, value in fields.items())), None)) + + def screenshot(self, name: str) -> Path: + path = self.output / f"screen-{name}.png" + path.write_bytes(self.command("exec-out", "screencap", "-p")) + png_pixels(path) + return path + + def foreground(self) -> None: + window = self.shell("dumpsys", "window", "windows").decode(errors="replace") + (self.output / "window-last.txt").write_text(window) + focused = [line for line in window.splitlines() if "mCurrentFocus=" in line or "mFocusedApp=" in line] + if not any(self.args.package in line for line in focused): + raise Failed("The installed application is not foreground on the cloud device") + + def capture(self, stage: str, *, screenshot_name: str | None = None) -> tuple[dict, tuple]: + row = self.wait_observer("provider_frame", stage=stage) + if row.get("source") != "native-provider-rgba": + raise Failed("Provider image lacks its native RGBA origin") + native = row.get("provider_debug", {}) + if (native.get("runtime") != "renpy" or native.get("initialized") is not True + or native.get("exited") is not False or int(row.get("frame_serial", 0)) <= 0): + raise Failed("Frame lacks live initialized Ren'Py native state and a rendered frame serial") + relative = "files/renpy-device-evidence/provider-" + stage + ".png" + provider_path = self.output / ("provider-" + stage + ".png") + provider_path.write_bytes(self.private(relative, required=True)) + provider = png_pixels(provider_path) + self.foreground() + screen_path = self.screenshot(screenshot_name or stage) + screen = png_pixels(screen_path) + stats = {"provider": image_stats(provider), "os_screenshot": image_stats(screen)} + box, viewport = row["drawn_box"], row["viewport_size"] + if min(viewport) <= 0 or min(box[2:]) <= 0: + raise Failed("Native frame lacks valid on-device presentation geometry") + matches, count = 0, 0 + # Compare actual game pixels with the OS image, including background + # regions throughout the scene. A colorful desktop cannot pass this. + for iy in range(1, 8): + for ix in range(1, 12): + u, v = ix / 12, iy / 8 + expected = pixel(provider, int(u * provider[0]), int(v * provider[1])) + sx = int((box[0] + u * box[2]) * screen[0] / viewport[0]) + sy = int((box[1] + v * box[3]) * screen[1] / viewport[1]) + actual = pixel(screen, sx, sy) + matches += max(abs(a - b) for a, b in zip(expected, actual)) <= 24 + count += 1 + stats["native_to_os_match"] = {"matching_samples": matches, "samples": count, + "fraction": matches / count, "channel_tolerance": 24} + (self.output / f"pixels-{screenshot_name or stage}.json").write_text(json.dumps(stats, indent=2) + "\n") + if matches / count < 0.70: + raise Failed(f"OS screenshot does not show the native Ren'Py frame at {stage}: {matches}/{count} samples") + self.summary["checks"].append({"stage": screenshot_name or stage, "native_provider_pixels_and_os_screen": stats}) + return row, screen + + def tap_button(self, frame: dict, screen: tuple) -> None: + box, viewport = frame["drawn_box"], frame["viewport_size"] + # The real fixture's button center is (480, 270) in its logical + # 960x540 canvas. Native framebuffer pixels may use another physical + # resolution, so address the displayed center through normalized UV. + x = round((box[0] + 0.5 * box[2]) * screen[0] / viewport[0]) + y = round((box[1] + 0.5 * box[3]) * screen[1] / viewport[1]) + self.shell("input", "tap", str(x), str(y)) + + def run(self) -> None: + if not self.args.cloud_device: + raise Blocked("--cloud-device is required: this task authorizes only cloud execution/devices") + missing = [] + if not self.args.apk.is_file(): + missing.append(f"Installable Ren'Py-enabled debug APK is missing: {self.args.apk}") + if not shutil.which(self.args.adb): + missing.append("adb is unavailable in the cloud task; provide Android platform-tools and a cloud device/emulator") + if missing: + raise Blocked("; ".join(missing)) + state = self.command("get-state", check=False).decode().strip() + if state != "device": + raise Blocked(f"Cloud Android device {self.args.serial} is unavailable or unauthorized (state={state!r})") + self.summary["apk_sha256"] = hashlib.sha256(self.args.apk.read_bytes()).hexdigest() + self.summary["device_fingerprint"] = self.shell("getprop", "ro.build.fingerprint").decode().strip() + self.summary["device_abis"] = self.shell("getprop", "ro.product.cpu.abilist").decode().strip() + install = self.command("install", "-r", str(self.args.apk.resolve()), timeout=180).decode(errors="replace") + (self.output / "install.txt").write_text(install) + if "Success" not in install: + raise Failed(f"Actual APK installation failed: {install.strip()}") + self.installed = True + root = self.command("exec-out", "run-as", self.args.package, "pwd", check=False).decode().strip() + if not root.startswith("/data/") or "\n" in root or " " in root: + raise Blocked("Installed APK does not permit run-as debug staging; use a debuggable APK for this cloud harness") + self.data_root = root + self.game_root = root + "/files/renpy-device-demo" + self.shell("am", "force-stop", self.args.package) + self.stage() + resolved = self.shell("cmd", "package", "resolve-activity", "--brief", self.args.package).decode().splitlines() + component = next((line.strip() for line in reversed(resolved) if "/" in line and " " not in line), "") + if not component or not component.startswith(self.args.package + "/"): + raise Failed("Cannot resolve the installed APK's real launcher activity") + self.summary["activity"] = component + self.shell("am", "start", "-W", "-n", component, timeout=60) + self.pid = self.wait("installed app process", lambda: self.shell("pidof", self.args.package, check=False).decode().strip()) + self.wait_game("start_ready") + frame, screen = self.capture("start_ready") + self.tap_button(frame, screen) + self.wait_game("touch_received") + self.wait_game("text_ready") + keyboard = self.wait_observer("soft_keyboard", active=True) + if keyboard.get("source") != "native-text-input-state" or not keyboard.get("feature_available"): + raise Failed("A real native text request and OS virtual keyboard are required") + ime = self.wait("visible Android system input method", lambda: self.visible_ime()) + (self.output / "input-method.txt").write_text(ime) + self.screenshot("text-ready-with-os-keyboard") + typed = "CloudRenPy" + self.run_id[:8] + self.shell("input", "text", typed) + self.shell("input", "keyevent", "66") + text = self.wait_game("text_received") + if text.get("details", {}).get("text") != typed: + raise Failed(f"Real Ren'Py text differs from OS input: {text.get('details')}") + self.wait_game("post_text_ready") + frame, screen = self.capture("post_text_ready") + self.shell("input", "keyevent", "3") # Android Home: real lifecycle pause. + pause = self.wait_observer("lifecycle", phase="paused") + if pause.get("result") != 0 or pause.get("provider_debug", {}).get("paused") is not True: + raise Failed("OS background did not produce an observed native pause") + self.screenshot("background-home") + time.sleep(2) + self.shell("am", "start", "-W", "-n", component, timeout=60) + resume = self.wait_observer("lifecycle", phase="resumed") + if resume.get("result") != 0 or resume.get("provider_debug", {}).get("paused") is not False: + raise Failed("OS foreground did not produce an observed native resume") + if any(row.get("stage") == "resumed_touch_received" for row in self.game_rows()): + raise Failed("Ren'Py advanced past its waiting interaction without resumed OS touch") + frame, screen = self.capture("resumed_ready", screenshot_name="resumed") + if int(frame.get("frame_serial", 0)) <= int(pause.get("provider_debug", {}).get("frame_serial", 0)): + raise Failed("OS resume did not produce a new native frame; cached pre-pause pixels cannot pass") + self.tap_button(frame, screen) + self.wait_game("resumed_touch_received") + self.wait_game("quit_ready") + frame, screen = self.capture("quit_ready") + self.tap_button(frame, screen) + self.wait_game("quit_requested") + exit_row = self.wait_observer("normal_exit") + if exit_row.get("provider_debug", {}).get("exited") is not True or not exit_row.get("engine_destroyed"): + raise Failed("Explicit Ren'Py quit did not exit native gameplay and destroy the real engine") + self.wait("normal app activity exit", self.activity_exited, timeout=20) + self.summary["host_exit"] = { + "activity_removed": True, + "remaining_process_pids": self.shell("pidof", self.args.package, check=False).decode().strip()} + self.screenshot("normal-exit-os") + events = self.observer_rows() + if not any(row.get("kind") == "os_touch" and row.get("action") == 1 and row.get("result") == 0 for row in events): + raise Failed("No real OS touch-down reached the provider") + if not any(row.get("kind") == "os_touch" and row.get("action") == 3 and row.get("result") == 0 for row in events): + raise Failed("No real OS touch-up reached the provider") + if not any(row.get("kind") == "os_key" and row.get("pressed") and row.get("code") == 13 for row in events): + raise Failed("No real OS Enter key reached the provider") + self.summary["checks"].extend([ + {"real_os_touch_and_keyboard_text": typed}, + {"real_os_pause_resume": {"pause": pause, "resume": resume}}, + {"explicit_renpy_quit_and_engine_destroy": exit_row}]) + self.summary["status"] = "passed" + + def visible_ime(self) -> str: + text = self.shell("dumpsys", "input_method").decode(errors="replace") + return text if re.search(r"(?:mInputShown|isInputViewShown|inputShown|mIsInputViewShown)=true", text) else "" + + def activity_exited(self) -> bool: + # Android may cache an empty app process after the Activity finishes. + # Require the actual ActivityRecord to be removed, independently of + # native gameplay exit/engine teardown, and retain both kinds of evidence. + text = self.shell("dumpsys", "activity", "activities").decode(errors="replace") + (self.output / "activities-last.txt").write_text(text) + return not re.search(r"ActivityRecord\{[^}\n]*\b" + re.escape(self.args.package) + r"/", text) + + def stage(self) -> None: + fixture = Path(__file__).resolve().parents[1] / "demos/aetherkiri-renpy/game" + request = {"run_id": self.run_id} + config = {"probe_script": "res://scripts/renpy_mobile_acceptance.gd", "run_id": self.run_id, + "game_path": self.game_root, "timeout_seconds": 240} + # Staging is confined to two test directories plus the existing + # one-shot debug request. No app data, saves, or external paths are reset. + self.shell("run-as", self.args.package, "mkdir", "-p", "files") + self.shell("run-as", self.args.package, "rm", "-rf", "files/renpy-device-demo", "files/renpy-device-evidence") + with tempfile.TemporaryDirectory(prefix="renpy-cloud-device-") as temporary: + staging = Path(temporary) + project = staging / "renpy-device-demo/game" + project.mkdir(parents=True) + for source in fixture.iterdir(): + if source.is_file() and source.suffix in (".rpy", ".svg"): + shutil.copyfile(source, project / source.name) + (project / "aether-device-request.json").write_text(json.dumps(request)) + (staging / "aetherkiri-probe-request.json").write_text(json.dumps(config)) + archive = staging / "project.tar" + with tarfile.open(archive, "w") as tar: + tar.add(staging / "renpy-device-demo", arcname="renpy-device-demo") + tar.add(staging / "aetherkiri-probe-request.json", arcname="aetherkiri-probe-request.json") + remote = "/data/local/tmp/aether-renpy-device-" + self.run_id + ".tar" + self.command("push", str(archive), remote) + try: + self.shell("run-as", self.args.package, "tar", "-xf", remote, "-C", "files") + finally: + self.shell("rm", "-f", remote, check=False) + + def collect(self) -> None: + if not self.installed: + return + for relative, local in [ + ("files/renpy-device-evidence/observer.jsonl", "observer.jsonl"), + ("files/renpy-device-demo/game/aether-device-checkpoints.jsonl", "renpy-script-checkpoints.jsonl"), + ("files/renpy-device-demo/log.txt", "renpy-log.txt"), + ("files/renpy-device-demo/traceback.txt", "renpy-traceback.txt")]: + try: + data = self.private(relative) + if data: + (self.output / local).write_bytes(data) + except Failed: + pass + if self.pid: + try: + (self.output / "android-process-logcat.txt").write_bytes( + self.command("logcat", "-d", "--pid", self.pid.split()[0], "-v", "threadtime", check=False)) + except Failed: + pass + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apk", type=Path, required=True) + parser.add_argument("--serial", required=True, help="Explicit cloud device/emulator adb serial") + parser.add_argument("--cloud-device", action="store_true", help="Confirm execution and selected device are cloud-hosted") + parser.add_argument("--package", default="org.github.krkr2.aetherkiri") + parser.add_argument("--adb", default="adb") + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--stage-timeout", type=int, default=60) + args = parser.parse_args() + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_.]+", args.package) or not 5 <= args.stage_timeout <= 180: + parser.error("Invalid package or stage timeout (5..180 seconds)") + acceptance = Acceptance(args) + code = 0 + try: + acceptance.run() + except Blocked as exc: + acceptance.summary.update(status="not_run", blocker=str(exc)) + code = 2 + except (Failed, KeyError, OSError, ValueError, struct.error, zlib.error) as exc: + acceptance.summary.update(status="failed", blocker=str(exc)) + code = 1 + finally: + acceptance.collect() + (acceptance.output / "result.json").write_text(json.dumps(acceptance.summary, indent=2) + "\n") + print(json.dumps(acceptance.summary, indent=2)) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/run_renpy_godot_e2e.sh b/tools/run_renpy_godot_e2e.sh index b1e8c5943..a09b0d533 100644 --- a/tools/run_renpy_godot_e2e.sh +++ b/tools/run_renpy_godot_e2e.sh @@ -48,6 +48,10 @@ rc=$? set -e cat "$log" [[ "$rc" -eq 0 ]] || { echo "Godot Ren'Py E2E exited with status $rc" >&2; exit "$rc"; } +if grep -E 'Traceback \(most recent call last\)|SCRIPT ERROR|uncaught exception|An exception has occurred' "$log"; then + echo "Godot Ren'Py E2E logged a runtime exception." >&2 + exit 1 +fi grep -F 'step probe fps=' "$log" >/dev/null grep -F 'AKRF1 RGBA bridge' "$log" >/dev/null grep -F 'step 02 label=choose-continue' "$log" >/dev/null diff --git a/tools/run_renpy_mobile_source_build.sh b/tools/run_renpy_mobile_source_build.sh old mode 100644 new mode 100755 index 451f44ea0..2ac0e0527 --- a/tools/run_renpy_mobile_source_build.sh +++ b/tools/run_renpy_mobile_source_build.sh @@ -1,208 +1,199 @@ #!/usr/bin/env bash -# Optional Ren'Py native mobile source build orchestration. -# -# The official renpy-build checkout and iOS SDK archives are external/licensed -# inputs. Default "auto" mode is therefore a clear skip when no checkout is -# supplied; it never turns staged RAPT/Renios archives into playable payloads. -# Use --mode check for strict CI preflight or --mode build after provisioning -# Ubuntu 24.04, Android NDK, and the licensed iOS SDK archives. +# Build a coherent Ren'Py 8.5.3 mobile fork, never an unpatched RAPT launcher. set -euo pipefail - repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" launcher="$repo_root/bridge/renpy_runtime/mobile_launcher/build.sh" +patch_root="$repo_root/bridge/renpy_runtime/mobile_launcher/patches" mode="${AETHERKIRI_RENPY_SOURCE_BUILD_MODE:-auto}" -renpy_build="${AETHERKIRI_RENPY_BUILD_ROOT:-}" -fetch=false +renpy_build="${AETHERKIRI_RENPY_BUILD_ROOT:-$repo_root/.aetherkiri-cache/renpy-build}" +platform="${AETHERKIRI_RENPY_SOURCE_BUILD_PLATFORM:-android}" +output_dir="${AETHERKIRI_RENPY_SOURCE_OUTPUT:-$repo_root/out/renpy-mobile-source}" source_url="${AETHERKIRI_RENPY_BUILD_URL:-https://github.com/renpy/renpy-build.git}" -source_ref="${AETHERKIRI_RENPY_BUILD_REF:-}" -native_patch_root="$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native" - +build_sha=7bfab40c1174f622f644b24669afd5fb167fbb79 +renpy_sha=39895c1e017f0b36ffea2447d97eccd69d76ee1c +greenlet_sha=65f8da82b13a1273e55a6bfcbd1f9da09fc4eb7a +fetch=false usage() { cat <<'USAGE' Usage: tools/run_renpy_mobile_source_build.sh [options] - -Options: - --mode MODE auto (default), skip, check, or build - --renpy-build PATH existing official renpy-build checkout - --fetch clone the official checkout when PATH is absent - --url URL source URL used with --fetch - --ref REF branch/tag/commit used with --fetch - -h, --help show this help - -Environment: - AETHERKIRI_RENPY_SOURCE_BUILD_MODE - AETHERKIRI_RENPY_BUILD_ROOT - AETHERKIRI_RENPY_BUILD_URL - AETHERKIRI_RENPY_BUILD_REF - AETHERKIRI_RENPY_SOURCE_BUILD_REQUIRED=1 - AETHERKIRI_RENPY_BUILD_IOS=1 # build iOS after Android in --mode build - -The build mode runs renpy-build's Android lifecycle-fork hooks and, when -explicitly enabled, its iOS hooks. It validates canonical outputs and the -seven host lifecycle exports before any staged install. It does not claim -device/simulator playability. + --mode MODE auto, skip, prepare, check, or build + --renpy-build PATH coherent official Ren'Py 8.5.3 build checkout + --fetch fetch pinned official GitHub sources when absent + --platform PLATFORM android (default), ios, or all + --output-dir PATH real native payload and provenance destination + --url URL renpy-build source mirror (revision remains pinned) + --ref REF compatibility option; must be the pinned build SHA +Prepare clones the pinned source and greenlet dependency and applies all Python +and native patches. It does not install host packages or licensed SDKs. +Build runs the actual cross compilation and gates every lifecycle export. +Auto builds when a checkout exists; it never substitutes check for compilation. +Neither a successful build nor export checks assert device gameplay. USAGE } - while (($#)); do case "$1" in - --mode) - (($# >= 2)) || { echo "--mode requires a value" >&2; exit 2; } - mode="$2"; shift 2 ;; - --renpy-build) - (($# >= 2)) || { echo "--renpy-build requires a path" >&2; exit 2; } - renpy_build="$2"; shift 2 ;; + --mode|--renpy-build|--platform|--output-dir|--url|--ref) + (($# >= 2)) || { echo "$1 requires a value" >&2; exit 2; } + case "$1" in + --mode) mode="$2" ;; + --renpy-build) renpy_build="$2" ;; + --platform) platform="$2" ;; + --output-dir) output_dir="$2" ;; + --url) source_url="$2" ;; + --ref) [[ "$2" == "$build_sha" ]] || { echo "unsupported build revision: $2; expected $build_sha" >&2; exit 2; } ;; + esac + shift 2 ;; --fetch) fetch=true; shift ;; - --url) - (($# >= 2)) || { echo "--url requires a value" >&2; exit 2; } - source_url="$2"; shift 2 ;; - --ref) - (($# >= 2)) || { echo "--ref requires a value" >&2; exit 2; } - source_ref="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; esac done - -case "$mode" in - auto|skip|check|build) ;; - *) echo "--mode must be auto, skip, check, or build (got '$mode')" >&2; exit 2 ;; -esac -[[ -f "$launcher" ]] || { echo "missing launcher scaffold: $launcher" >&2; exit 1; } - -if [[ "$mode" == "skip" ]]; then +case "$mode" in auto|skip|prepare|check|build) ;; *) echo "invalid mode: $mode" >&2; exit 2 ;; esac +case "$platform" in android|ios|all) ;; *) echo "invalid platform: $platform" >&2; exit 2 ;; esac +if [[ "$mode" == skip ]]; then echo "Ren'Py mobile source build skipped by request (no payload built)" exit 0 fi - -if [[ -z "$renpy_build" ]]; then - renpy_build="$repo_root/.aetherkiri-cache/renpy-build" -fi - -if [[ ! -d "$renpy_build" && "$fetch" == true ]]; then - command -v git >/dev/null 2>&1 || { echo "git is required for --fetch" >&2; exit 1; } - mkdir -p "$(dirname "$renpy_build")" - clone_args=(clone --filter=blob:none) - [[ -n "$source_ref" ]] && clone_args+=(--branch "$source_ref") - clone_args+=("$source_url" "$renpy_build") - echo "Fetching official renpy-build source: $source_url" - git "${clone_args[@]}" -fi - -if [[ ! -d "$renpy_build" ]]; then - if [[ "$mode" == "auto" && "${AETHERKIRI_RENPY_SOURCE_BUILD_REQUIRED:-0}" != "1" ]]; then - echo "Ren'Py mobile source build skipped: official renpy-build checkout is absent" - echo " provide --renpy-build PATH or set AETHERKIRI_RENPY_SOURCE_BUILD_REQUIRED=1" - exit 0 +clone_pin() { + local url="$1" destination="$2" revision="$3" + if [[ ! -d "$destination" ]]; then + [[ "$fetch" == true ]] || { echo "missing official source: $destination (use --fetch)" >&2; return 1; } + mkdir -p "$(dirname "$destination")" + git clone --filter=blob:none --no-checkout "$url" "$destination" + git -C "$destination" fetch --depth 1 origin "$revision" + git -C "$destination" checkout --detach "$revision" fi + local actual + actual="$(git -C "$destination" rev-parse HEAD)" + [[ "$actual" == "$revision" ]] || { + echo "unsupported source revision in $destination: $actual; expected $revision" >&2 + return 1 + } +} +if [[ ! -d "$renpy_build" && "$fetch" != true && "$mode" == auto && + "${AETHERKIRI_RENPY_SOURCE_BUILD_REQUIRED:-0}" != 1 ]]; then + echo "Ren'Py mobile source build skipped: official renpy-build checkout is absent" + exit 0 +fi +if [[ ! -d "$renpy_build" && "$fetch" != true ]]; then echo "Ren'Py mobile source build requires an official renpy-build checkout: $renpy_build" >&2 - echo " clone: git clone $source_url $renpy_build" >&2 exit 1 fi - -echo "Running strict Ren'Py mobile source preflight: $renpy_build" -if ! preflight_output="$(bash "$launcher" --check-renpy-build --renpy-build "$renpy_build" 2>&1)"; then - printf '%s\n' "$preflight_output" >&2 - echo "Ren'Py mobile source preflight failed; no payload was built" >&2 - exit 1 +clone_pin "$source_url" "$renpy_build" "$build_sha" +if [[ "$mode" == check ]]; then + bash "$launcher" --check-renpy-build --renpy-build "$renpy_build" --platform "$platform" + echo "Ren'Py source preflight passed; no compilation requested" + exit 0 fi -printf '%s\n' "$preflight_output" - -if [[ "$mode" == "check" || "$mode" == "auto" ]]; then - echo "Ren'Py mobile source preflight passed; build not requested" +clone_pin https://github.com/renpy/renpy.git "$renpy_build/renpy" "$renpy_sha" +clone_pin https://github.com/python-greenlet/greenlet.git "$renpy_build/aether-greenlet" "$greenlet_sha" +apply_once() { + local checkout="$1" patch="$2" + if git -C "$checkout" apply --reverse --check "$patch" >/dev/null 2>&1; then + echo "already applied: $(basename "$patch")" + else + git -C "$checkout" apply --check "$patch" + git -C "$checkout" apply "$patch" + echo "applied: $(basename "$patch")" + fi +} +apply_once "$renpy_build/renpy" "$patch_root/python/0001-cooperative-loop-skeleton.patch" +for patch in 0001-renpy-build-link.patch 0002-android-host-bootstrap.patch 0003-ios-host-bootstrap.patch 0004-android-offscreen-renderer.patch; do + apply_once "$renpy_build" "$patch_root/native/$patch" +done +cp "$patch_root/native/renpy_mobile_lifecycle.c" "$renpy_build/runtime/renpy_mobile_lifecycle.c" +cp "$repo_root/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h" "$renpy_build/runtime/renpy_mobile_launcher.h" +mkdir -p "$renpy_build/runtime/greenlet" +cp -a "$renpy_build/aether-greenlet/src/greenlet/." "$renpy_build/runtime/greenlet/" +cp "$renpy_build/aether-greenlet/LICENSE" "$renpy_build/runtime/greenlet/LICENSE" +cp "$renpy_build/aether-greenlet/LICENSE.PSF" "$renpy_build/runtime/greenlet/LICENSE.PSF" +# Detached release sources otherwise fall back to development 8.6 metadata. +cat > "$renpy_build/renpy/renpy/vc_version.py" <<'VERSION' +version = "8.5.3.26051504" +version_name = "AetherKiri mobile fork" +branch = "fix" +official = False +nightly = False +VERSION +python3 -m py_compile "$renpy_build/renpy/renpy/aether_mobile.py" \ + "$renpy_build/renpy/renpy/bootstrap.py" "$renpy_build/renpy/renpy/execution.py" \ + "$renpy_build/renpy/renpy/display/core.py" "$renpy_build/tasks/renpython.py" +if [[ "$mode" == prepare ]]; then + echo "Pinned Ren'Py mobile sources prepared; no native payload was compiled" exit 0 fi - -# The upstream launcher still enters SDL_main/launcher_main -> Py_RunMain. -# Refuse to spend hours rebuilding it unless the caller explicitly supplied a -# lifecycle fork and its source exports the host ABI. A staged official archive -# cannot become host-tick capable through packaging alone. -if [[ ${AETHERKIRI_RENPY_LIFECYCLE_FORK:-0} != "1" ]]; then - echo "Ren'Py mobile source build requires AETHERKIRI_RENPY_LIFECYCLE_FORK=1" >&2 - echo " official renpy-build sources are blocking process launchers; no playable payload will be built" >&2 - echo " provide an audited fork exporting renpy_mobile_init/tick/frame/input/pause/resume/shutdown" >&2 - exit 1 +bash "$launcher" --check-renpy-build --renpy-build "$renpy_build" --platform "$platform" +# All prerequisite tasks must run: selecting only renpython would link stale +# librenpy and would omit the new Surface method and target standard library. +if [[ "$platform" == android || "$platform" == all ]]; then + (cd "$renpy_build"; ./build.sh --platform android --python 3 rebuild librenpy pythonlib renpython rapt sdl2) fi - -[[ -f "$native_patch_root/renpy_mobile_lifecycle.c" ]] || { - echo "missing native lifecycle source: $native_patch_root/renpy_mobile_lifecycle.c" >&2 - exit 1 -} -[[ -f "$native_patch_root/0001-renpy-build-link.patch" ]] || { - echo "missing renpy-build task patch: $native_patch_root/0001-renpy-build-link.patch" >&2 - exit 1 -} -[[ -f "$native_patch_root/0002-android-host-bootstrap.patch" && - -f "$native_patch_root/0003-ios-host-bootstrap.patch" ]] || { - echo "missing platform launcher bootstrap patches under $native_patch_root" >&2 - exit 1 -} -[[ -d "$renpy_build/.git" ]] || { - echo "lifecycle fork build requires a git renpy-build checkout" >&2 - exit 1 +if [[ "$platform" == ios || "$platform" == all ]]; then + (cd "$renpy_build"; ./build.sh --platform ios --python 3 rebuild librenpy pythonlib renpython renios) +fi +symbols=(renpy_mobile_bootstrap renpy_mobile_bind_window renpy_mobile_init renpy_mobile_tick + renpy_mobile_frame renpy_mobile_input renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown + renpy_mobile_text_input_state renpy_mobile_set_surface_size) +check_exports() { + local artifact="$1" kind="$2" output symbol + [[ -f "$artifact" ]] || { echo "missing compiled artifact: $artifact" >&2; exit 1; } + if [[ "$kind" == android ]]; then + output="$(nm -D --defined-only "$artifact")" + else + output="$(llvm-nm-18 --defined-only --extern-only "$artifact")" + fi + for symbol in "${symbols[@]}"; do + awk '{print $NF}' <<<"$output" | sed 's/^_//' | grep -Fxq "$symbol" || { + echo "compiled artifact lacks $symbol: $artifact" >&2; exit 1; + } + done } -cp "$native_patch_root/renpy_mobile_lifecycle.c" "$renpy_build/runtime/renpy_mobile_lifecycle.c" -cp "$repo_root/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h" \ - "$renpy_build/runtime/renpy_mobile_launcher.h" -for native_patch in \ - "$native_patch_root/0001-renpy-build-link.patch" \ - "$native_patch_root/0002-android-host-bootstrap.patch" \ - "$native_patch_root/0003-ios-host-bootstrap.patch"; do - git -C "$renpy_build" apply --check "$native_patch" || { - echo "native lifecycle patch does not apply: $native_patch" >&2 - exit 1 - } - git -C "$renpy_build" apply "$native_patch" -done -for lifecycle_source in \ - "$renpy_build/runtime/librenpython_android.c" \ - "$renpy_build/runtime/librenpython.c"; do - grep -Fq 'renpy_mobile_bootstrap' "$lifecycle_source" || { - echo "lifecycle fork source is missing renpy_mobile_bootstrap: $lifecycle_source" >&2 - echo " refusing to build the official blocking launcher" >&2 - exit 1 - } -done - -build_script="$renpy_build/build.sh" -[[ -x "$build_script" ]] || { echo "renpy-build/build.sh is not executable: $build_script" >&2; exit 1; } - -echo "Building Android lifecycle payload (opt-in, potentially multi-hour)" -( - cd "$renpy_build" - ./build.sh --platform android rebuild renpython rapt rapt-sdl2 -) - artifact_root="$renpy_build/renpy" -android_arm64="$artifact_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" -android_armv7="$artifact_root/rapt/prototype/renpyandroid/src/main/jniLibs/armeabi-v7a/librenpython.so" -android_x86_64="$artifact_root/rapt/prototype/renpyandroid/src/main/jniLibs/x86_64/librenpython.so" -for artifact in "$android_arm64" "$android_armv7" "$android_x86_64"; do - [[ -f "$artifact" ]] || { echo "Android lifecycle artifact missing: $artifact" >&2; exit 1; } -done - -for android_artifact in "$android_arm64" "$android_armv7" "$android_x86_64"; do - RENPY_MOBILE_ANDROID_SO="$android_artifact" \ - bash "$repo_root/tools/test_renpy_mobile_symbol_gate.sh" -done - -if [[ "${AETHERKIRI_RENPY_BUILD_IOS:-0}" == "1" ]]; then - echo "Building iOS lifecycle payload (licensed SDK archives required)" - ( - cd "$renpy_build" - ./build.sh --platform ios rebuild renpython renios - ) - ios_release="$artifact_root/renios/prototype/prebuilt/release/librenpython.a" - ios_debug="$artifact_root/renios/prototype/prebuilt/debug/librenpython.a" - for artifact in "$ios_release" "$ios_debug"; do - [[ -f "$artifact" ]] || { echo "iOS lifecycle artifact missing: $artifact" >&2; exit 1; } +if [[ "$platform" == android || "$platform" == all ]]; then + for abi in arm64-v8a armeabi-v7a x86_64; do + check_exports "$artifact_root/rapt3/prototype/renpyandroid/src/main/jniLibs/$abi/librenpython.so" android done - if command -v xcrun >/dev/null 2>&1; then - RENPY_MOBILE_IOS_A="$ios_release" bash "$repo_root/tools/test_renpy_mobile_symbol_gate.sh" - else - echo "iOS payload built but xcrun is unavailable; export check deferred to macOS" >&2 - exit 1 - fi fi - -echo "Native lifecycle payload built and export-gated; device/simulator gameplay is still unverified" +if [[ "$platform" == ios || "$platform" == all ]]; then + for kind in release debug; do + check_exports "$artifact_root/renios3/prototype/prebuilt/$kind/librenpython.a" ios + done +fi +# Only create the distributable payload after actual builds/export checks pass. +mkdir -p "$output_dir/payload" +if [[ "$platform" == android || "$platform" == all ]]; then + mkdir -p "$output_dir/payload/rapt" + cp -a "$artifact_root/rapt3/." "$output_dir/payload/rapt/" +fi +if [[ "$platform" == ios || "$platform" == all ]]; then + mkdir -p "$output_dir/payload/renios" + cp -a "$artifact_root/renios3/." "$output_dir/payload/renios/" +fi +python3 - "$artifact_root" "$output_dir" "$platform" "$build_sha" "$renpy_sha" "$greenlet_sha" "$patch_root" <<'PY' +import hashlib, json, pathlib, shutil, sys +source, output = map(pathlib.Path, sys.argv[1:3]); platform = sys.argv[3] +for target in (["rapt"] if platform == "android" else ["renios"] if platform == "ios" else ["rapt", "renios"]): + private = output / "payload" / target / ("runtime/private" if target == "rapt" else "prototype/base") + private.mkdir(parents=True, exist_ok=True) + shutil.copy2(source / "renpy.py", private / ("main.py" if target == "rapt" else "renpy.py")) + shutil.copytree(source / "renpy", private / "renpy", dirs_exist_ok=True, + ignore=shutil.ignore_patterns("__pycache__", "*.pyx", "*.pxd", "*.pxi")) + shutil.copytree(source / "lib/python3.12", private / "lib/python3.12", dirs_exist_ok=True) + (private / "game").mkdir(exist_ok=True) + licenses = private / "licenses" / "greenlet" + licenses.mkdir(parents=True, exist_ok=True) + for name in ("LICENSE", "LICENSE.PSF"): + shutil.copy2(source.parent / "aether-greenlet" / name, licenses / name) +manifest = {"schema_version": 1, "renpy_version": "8.5.3.26051504+unofficial", "distribution": "8.5.3-rebuilt", "abi_version": 2, + "build_source_sha": sys.argv[4], "renpy_source_sha": sys.argv[5], + "greenlet_source_sha": sys.argv[6], "platform": platform, + "native_compilation": "passed", "device_gameplay": "unverified", + "patches": {str(p.relative_to(sys.argv[7])): hashlib.sha256(p.read_bytes()).hexdigest() + for p in pathlib.Path(sys.argv[7]).rglob("*.patch")}} +(output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") +for target in (output / "payload").iterdir(): + if target.name in ("rapt", "renios"): + (target / "aether-runtime.json").write_text(json.dumps(manifest, indent=2) + "\n") +PY +echo "Native mobile fork compiled and export-gated: $output_dir/payload" +echo "Device/simulator rendering, input and lifecycle gameplay remain unverified" diff --git a/tools/stage_renpy_android_support.sh b/tools/stage_renpy_android_support.sh index ba57e2483..b3dabfc93 100644 --- a/tools/stage_renpy_android_support.sh +++ b/tools/stage_renpy_android_support.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash -# Stage the official Ren'Py RAPT Android inputs into a Godot Android export. +# Stage a rebuilt Ren'Py RAPT runtime into a Godot Android export. # # RAPT is an SDLActivity application template. It must not be merged as the # exported app's manifest or launched as a second Activity: Godot owns the # process, window, and SDL lifecycle. This script therefore packages the # official Java/resources as inspectable assets, stages only the arm64 native -# payload in jniLibs, and installs a tiny host-owned JNI bridge. The provider -# remains NOT_SUPPORTED until the lifecycle, surface, input, and renderer -# handoff is implemented. +# payload in jniLibs, and installs the host-owned JNI bridge. Enabled app +# builds require a lifecycle library and complete cooperative Python payload. +# --allow-unsupported is an explicit archive inspection mode, never gameplay. set -euo pipefail usage() { @@ -23,8 +23,9 @@ with --private-assets. Options: --mobile-root PATH Root containing rapt/prototype/renpyandroid --godot-build PATH Extracted Godot Android build template directory - --private-assets PATH Optional directory or private.mp3 file to copy into - the RAPT private asset staging area (default: none) + --private-assets PATH Unpacked runtime directory (main.py, renpy/, lib/) + --allow-unsupported Inspect an official archive without a lifecycle fork; + this mode is never used by enabled app builds -h, --help Show this help USAGE } @@ -32,6 +33,8 @@ USAGE mobile_root="" godot_build="" private_assets="" +allow_unsupported=false +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" while (($#)); do case "$1" in @@ -50,6 +53,10 @@ while (($#)); do private_assets="$2" shift 2 ;; + --allow-unsupported) + allow_unsupported=true + shift + ;; -h|--help) usage exit 0 @@ -100,6 +107,14 @@ is_elf_file "$rapt_native" || { exit 1 } +if [[ "$allow_unsupported" == false ]]; then + if [[ -z "$private_assets" ]]; then + private_assets="$rapt_root/runtime/private" + fi + python3 "$repo_root/tools/validate_renpy_mobile_payload.py" \ + --platform android --library "$rapt_native" --private-root "$private_assets" +fi + main_src="$godot_build/src/main" asset_root="$main_src/assets/renpy_mobile" asset_rapt="$asset_root/rapt" @@ -121,7 +136,6 @@ cp -f "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bridge/renpy_runtime/and # sources remain assets and its PythonSDLActivity manifest is never merged; # compiling the full Activity would create a second SDL singleton. Refuse to # overwrite an unrelated class if a Godot template starts shipping one. -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" install_host_shim() { local source="$1" destination="$2" marker="$3" mkdir -p "$(dirname "$destination")" @@ -130,15 +144,19 @@ install_host_shim() { exit 1 fi cp -f "$source" "$destination" + # Vendored SDL helper classes may not contain our marker in the upstream + # source. Mark the staged copy so another build can safely update it. + printf '\n// %s: staged host-owned callback source.\n' "$marker" >> "$destination" } -install_host_shim \ - "$repo_root/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java" \ - "$java_sdl_root/SDLActivity.java" \ - 'renpy-sdl-host-shim-v1' -install_host_shim \ - "$repo_root/bridge/renpy_runtime/android/org/renpy/android/PythonSDLActivity.java" \ - "$java_renpy_root/PythonSDLActivity.java" \ - 'renpy-python-host-shim-v1' +while IFS= read -r -d '' shim; do + relative="${shim#"$repo_root/bridge/renpy_runtime/android/"}" + case "$relative" in + org/libsdl/*) marker='renpy-sdl-host-shim-v1' ;; + org/renpy/*) marker='renpy-python-host-shim-v1' ;; + *) continue ;; + esac + install_host_shim "$shim" "$main_src/java/$relative" "$marker" +done < <(find "$repo_root/bridge/renpy_runtime/android/org" -type f -name '*.java' -print0) # The source tree is deliberately copied below assets rather than src/main/java # or src/main/res. RAPT's manifest names PythonSDLActivity as its launcher; @@ -205,8 +223,11 @@ rendering are wired to the existing Godot Activity. PRIVATE_EOF fi -cp -f "$rapt_native" "$jni_root/librenpython.so" -chmod 755 "$jni_root/librenpython.so" +for native_library in "$rapt_main/jniLibs/arm64-v8a/"*.so; do + [[ -f "$native_library" ]] || continue + cp -f "$native_library" "$jni_root/$(basename "$native_library")" + chmod 755 "$jni_root/$(basename "$native_library")" +done # Record the exact staged paths and the archive metadata when available. This # is consumed by package smoke tests and makes an APK inspection auditable. @@ -218,8 +239,11 @@ elif [[ -f "$rapt_root/hash.txt" ]]; then fi { printf 'renpy_version=8.5.3\n' + printf 'runtime_sdl=SDL2\n' printf 'rapt_checksum=%s\n' "$rapt_checksum" - printf 'playable=false\n' + printf 'playable=unverified\n' + printf 'inspection_only=%s\n' "$allow_unsupported" + printf 'native_lifecycle_validated=%s\n' "$( [[ "$allow_unsupported" == false ]] && printf true || printf false )" printf 'activity_template=assets/renpy_mobile/rapt/java/org/renpy/android/PythonSDLActivity.java\n' printf 'gradle_template=assets/renpy_mobile/rapt/build.gradle\n' printf 'native_library=lib/arm64-v8a/librenpython.so\n' @@ -233,4 +257,4 @@ echo "Ren'Py Android support staged into $godot_build" echo " official Java/resources: $asset_rapt" echo " private assets: $asset_private" echo " arm64 native library: $jni_root/librenpython.so" -echo " playable: false" +echo " gameplay: unverified (requires device execution)" diff --git a/tools/test_renpy_mobile_cooperative.py b/tools/test_renpy_mobile_cooperative.py new file mode 100755 index 000000000..98967adc1 --- /dev/null +++ b/tools/test_renpy_mobile_cooperative.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Exercise real greenlet stack switching with a synthetic Ren'Py boundary. + +This verifies cooperative ownership/input/frame contracts. It does not execute +Ren'Py's engine, GL, SDL or a game and is not gameplay acceptance. +""" +import argparse +import importlib.util +from pathlib import Path +import sys +import tempfile +import threading +import types + +parser = argparse.ArgumentParser() +parser.add_argument("--renpy-src", required=True, type=Path) +args = parser.parse_args() +try: + import greenlet +except ImportError: + raise SystemExit("Install target-matching greenlet 3.2.4 to run cooperative behavior checks") +assert greenlet.__version__ == "3.2.4", greenlet.__version__ +sys.modules["_aether_greenlet"] = greenlet +renpy = types.ModuleType("renpy") +renpy.__path__ = [] +sys.modules["renpy"] = renpy +spec = importlib.util.spec_from_file_location("renpy.aether_mobile", args.renpy_src / "renpy/aether_mobile.py") +mobile = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = mobile +spec.loader.exec_module(mobile) +renpy.aether_mobile = mobile + +class QuitException(Exception): + pass + +renpy.game = types.ModuleType("renpy.game") +renpy.game.QuitException = QuitException +sys.modules["renpy.game"] = renpy.game +log = [] +events = [] +binds = [] +window = types.SimpleNamespace(get_size=lambda: (800, 600), + get_sdl_window_pointer=lambda: types.SimpleNamespace(value=17)) +pygame = types.ModuleType("renpy.pygame") +for name, number in {"KEYDOWN": 1, "KEYUP": 2, "TEXTINPUT": 3, "MOUSEBUTTONDOWN": 4, + "MOUSEBUTTONUP": 5, "MOUSEMOTION": 6, "MOUSEWHEEL": 7, + "K_ESCAPE": 27}.items(): + setattr(pygame, name, number) +pygame.event = types.SimpleNamespace(post=events.append, Event=lambda event_type, **values: dict(type=event_type, **values), + get_mousewheel_buttons=lambda: True) +pygame.display = types.SimpleNamespace(get_window=lambda: window) +pygame.key = types.SimpleNamespace(text_input=True) +renpy.pygame = pygame +sys.modules["renpy.pygame"] = pygame +sys.modules["_aether_host"] = types.SimpleNamespace(bind_window=lambda pointer: binds.append(pointer) or 0) +surface = types.SimpleNamespace(get_bytesize=lambda: 4, get_size=lambda: (2, 1), + aether_copy_pixels=lambda: b"\x01\x02\x03\xff\x04\x05\x06\xff") +interface = types.SimpleNamespace(started=True, mobile_save=lambda: log.append("save"), force_redraw=False) +renpy.config = types.SimpleNamespace(screen_width=800, screen_height=600) +renpy.display = types.SimpleNamespace(interface=interface, draw=types.SimpleNamespace( + screenshot=lambda tree: surface, untranslate_point=lambda x, y: (round(x), round(y)))) +renpy.audio = types.SimpleNamespace(audio=types.SimpleNamespace(pause_all=lambda: log.append("pause"), + unpause_all=lambda: log.append("resume"))) + +with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + entry = root / "renpy.py" + entry.write_text("def main():\n pass\n") + def nested_game(game_root, argv0): + local_state = {"counter": 41} + try: + log.append("entered") + mobile.cooperative_checkpoint(force=True) + local_state["counter"] += 1 + log.append(local_state["counter"]) + mobile.cooperative_capture("real-render-tree-boundary") + mobile.cooperative_checkpoint(force=True) + except QuitException: + pass + finally: + log.append("cleanup") + mobile._run = nested_game + mobile.cooperative_start(str(root), str(entry)) + assert log == [], "start ran game before tick deadline was established" + mobile.cooperative_set_surface_size(800, 600) + assert mobile.cooperative_surface_size() == (800, 600) + assert mobile.cooperative_tick(1) == "yield" + assert log == ["entered"], "yield unwound active game stack" + for event in [dict(type=2, value=1, code=1, x=0.25, y=0.5), + dict(type=2, value=2, code=1, x=0.5, y=0.75), + dict(type=2, value=3, code=1, x=0.5, y=0.75), + dict(type=1, value=5, code=97, modifiers=2, repeat=True), + dict(type=1, value=6, code=97), dict(type=3, value=0, text="你好、日本語 🐾"), + dict(type=2, value=4, code=0, x=0.5, y=0.75, delta_y=1)]: + mobile.cooperative_input(event) + assert mobile.cooperative_tick(1) == "yield" + assert log == ["entered", 42], "stack local values were lost or cleanup ran early" + assert [event["type"] for event in events] == [4, 6, 5, 1, 2, 3, 4, 5] + assert events[0]["pos"] == (200, 300) + assert events[1]["buttons"] == (True, False, False) + assert events[5]["text"] == "你好、日本語 🐾" + assert events[-2]["button"] == events[-1]["button"] == 4 + assert mobile.cooperative_mouse_pos() == (400, 450) + assert mobile.cooperative_mouse_pressed() == (False, False, False) + assert not mobile.cooperative_key_pressed(97) + renpy.config.screen_width = 600 + renpy.display.draw.untranslate_point = lambda x, y: (round(x + 100), round(y)) + mobile.cooperative_input(dict(type=2, value=1, code=1, x=0, y=0)) + mobile._dispatch_input() + assert events[-1]["pos"] == (100, 0), "input ignored the game viewport letterbox" + assert mobile.cooperative_frame() == (surface.aether_copy_pixels(), 2, 1, 1) + assert binds == [17] + mobile.cooperative_pause() + assert mobile.cooperative_tick(1) == "yield" and "cleanup" not in log + mobile.cooperative_resume() + assert log[-3:] == ["save", "pause", "resume"] and interface.force_redraw + assert mobile.cooperative_text_input_state() + errors = [] + def other_thread(): + try: + mobile.cooperative_tick(1) + except RuntimeError as error: + errors.append(str(error)) + thread = threading.Thread(target=other_thread) + thread.start(); thread.join() + assert errors and "original host thread" in errors[0] + mobile.cooperative_stop() + assert log.count("cleanup") == 1 and not mobile.cooperative_active() + assert mobile.cooperative_tick(1) == "not_started" + + # Validate official-entry dispatch and SystemExit handling independently. + entry.write_text("def main():\n import sys\n import renpy\n renpy.entry_argv = list(sys.argv)\n raise SystemExit(0)\n") + del sys.modules["renpy.aether_mobile"] + spec.loader.exec_module(mobile) + sys.modules["renpy.aether_mobile"] = mobile + mobile.cooperative_start(str(root), str(entry)) + assert mobile.cooperative_tick(10) == "finished" + assert renpy.entry_argv == [str(entry), str(root)] + mobile.cooperative_stop() + +print("Cooperative checks passed: real greenlet stack preservation, deadline-before-start, Unicode/input, frame ownership, pause/resume and cleanup") +print("Synthetic engine boundaries only; Ren'Py/SDL/GL/device gameplay was not run") diff --git a/tools/test_renpy_mobile_launcher_loader.sh b/tools/test_renpy_mobile_launcher_loader.sh index e88da91b2..77f70c446 100644 --- a/tools/test_renpy_mobile_launcher_loader.sh +++ b/tools/test_renpy_mobile_launcher_loader.sh @@ -10,6 +10,7 @@ cat > "$work/fake_launcher.cpp" <<'CPP' #include static unsigned char pixel[4] = {0x12, 0x34, 0x56, 0xff}; extern "C" int renpy_mobile_init(const renpy_mobile_config_t*, const renpy_mobile_host_t*) { return 0; } +extern "C" int renpy_mobile_bootstrap(const renpy_mobile_config_t*, const renpy_mobile_host_t*) { return 0; } extern "C" int renpy_mobile_tick(unsigned int) { return 0; } extern "C" int renpy_mobile_frame(renpy_mobile_frame_t* frame) { if (!frame) return -1; @@ -19,6 +20,9 @@ extern "C" int renpy_mobile_frame(renpy_mobile_frame_t* frame) { extern "C" int renpy_mobile_input(const renpy_mobile_input_t*) { return 0; } extern "C" int renpy_mobile_pause(void) { return 0; } extern "C" int renpy_mobile_resume(void) { return 0; } +extern "C" int renpy_mobile_bind_window(void* window) { return window ? 0 : -1; } +extern "C" int renpy_mobile_text_input_state(uint32_t* active) { if (!active) return -1; *active = 1; return 0; } +extern "C" int renpy_mobile_set_surface_size(uint32_t width, uint32_t height) { return width && height ? 0 : -1; } extern "C" void renpy_mobile_shutdown(void) {} CPP cat > "$work/test_loader.cpp" <<'CPP' @@ -40,6 +44,9 @@ int main() { assert(launcher.Input(input) == RENPY_MOBILE_OK); assert(launcher.Pause() == RENPY_MOBILE_OK); assert(launcher.Resume() == RENPY_MOBILE_OK); + uint32_t active = 0; + assert(launcher.TextInputState(&active) == RENPY_MOBILE_OK && active == 1); + assert(launcher.SetSurfaceSize(1280, 720) == RENPY_MOBILE_OK); launcher.Shutdown(); assert(!launcher.initialized()); return 0; diff --git a/tools/test_renpy_mobile_launcher_scaffold.sh b/tools/test_renpy_mobile_launcher_scaffold.sh index 34f3a121c..6423b65ac 100644 --- a/tools/test_renpy_mobile_launcher_scaffold.sh +++ b/tools/test_renpy_mobile_launcher_scaffold.sh @@ -20,20 +20,19 @@ grep -Fq 'dlsym' "$loader" grep -Fq 'RTLD_DEFAULT' "$loader" for symbol in \ renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input \ - renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown; do + renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown \ + renpy_mobile_bootstrap renpy_mobile_bind_window renpy_mobile_text_input_state renpy_mobile_set_surface_size; do grep -Fq "$symbol" "$loader" done for symbol in \ renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input \ - renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown; do + renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown \ + renpy_mobile_bootstrap renpy_mobile_bind_window renpy_mobile_text_input_state renpy_mobile_set_surface_size; do grep -Fq "$symbol" "$header" done -grep -Fq 'Py_RunMain' "$scaffold/README.md" -grep -Fq 'SDL_main' "$scaffold/README.md" -grep -Fq 'renpy-build' "$scaffold/patches/README.md" -grep -Fq 'cooperative-loop-skeleton.patch' "$scaffold/patches/python/README.md" +[[ -s "$scaffold/patches/python/0001-cooperative-loop-skeleton.patch" ]] if [[ -d "$renpy_source" ]]; then patch_output="$(bash "$script" --check-python-patch --renpy-src "$renpy_source")" grep -Fq 'applies cleanly' <<<"$patch_output" diff --git a/tools/test_renpy_mobile_native_fork.sh b/tools/test_renpy_mobile_native_fork.sh index 3194556f1..dc5440b37 100644 --- a/tools/test_renpy_mobile_native_fork.sh +++ b/tools/test_renpy_mobile_native_fork.sh @@ -7,7 +7,8 @@ native="$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mob [[ -s "$native" ]] || { echo "missing native lifecycle fork: $native" >&2; exit 1; } for symbol in \ renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input \ - renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown; do + renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown \ + renpy_mobile_bind_window renpy_mobile_text_input_state renpy_mobile_set_surface_size; do grep -Fq "$symbol" "$native" || { echo "native fork missing $symbol" >&2; exit 1; } done for forbidden in \ @@ -21,9 +22,10 @@ done for patch in \ "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch" \ "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0002-android-host-bootstrap.patch" \ - "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch"; do + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch" \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0004-android-offscreen-renderer.patch"; do [[ -s "$patch" ]] || { echo "missing native build patch: $patch" >&2; exit 1; } - if [[ "$patch" != *0001-renpy-build-link.patch ]]; then + if [[ "$patch" == *host-bootstrap.patch ]]; then grep -Fq 'renpy_mobile_bootstrap' "$patch" || { echo "native build patch does not expose renpy_mobile_bootstrap: $patch" >&2 exit 1 @@ -35,9 +37,11 @@ if [[ -n "${RENPY_BUILD_TEST_ROOT:-}" && -d "${RENPY_BUILD_TEST_ROOT}/.git" ]]; for patch in \ "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch" \ "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0002-android-host-bootstrap.patch" \ - "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch"; do - git -C "$RENPY_BUILD_TEST_ROOT" apply --check "$patch" + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch" \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0004-android-offscreen-renderer.patch"; do + git -C "$RENPY_BUILD_TEST_ROOT" apply --check "$patch" 2>/dev/null || \ + git -C "$RENPY_BUILD_TEST_ROOT" apply --reverse --check "$patch" done fi bash "$repo_root/tools/test_renpy_demo_fixture.sh" -printf '%s\n' 'RenPy native lifecycle fork and demo gate passed' +printf '%s\n' 'RenPy native source/demo fixture checks passed; no gameplay performed' diff --git a/tools/test_renpy_mobile_packaging.sh b/tools/test_renpy_mobile_packaging.sh new file mode 100755 index 000000000..8ffd8ba38 --- /dev/null +++ b/tools/test_renpy_mobile_packaging.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Exercise input rejection and resource validation; fixtures are never apps. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +validator="$repo_root/tools/validate_renpy_mobile_payload.py" +work="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-packaging.XXXXXX")" +trap 'rm -rf "$work"' EXIT +bash -n "$repo_root/scripts/build_android.sh" "$repo_root/scripts/build_ios.sh" \ + "$repo_root/tools/stage_renpy_android_support.sh" + +mkdir -p "$work/private/renpy/display" "$work/private/lib/python3.12/encodings" +for file in main.py renpy/__init__.py renpy/main.py renpy/execution.py \ + renpy/display/core.py lib/python3.12/site.py lib/python3.12/encodings/__init__.py; do + printf '# packaging contract fixture\n' > "$work/private/$file" +done +cat > "$work/private/renpy/aether_mobile.py" <<'PY' +def cooperative_start(): pass +def cooperative_tick(): pass +def cooperative_stop(): pass +PY +python3 "$validator" --platform android --private-root "$work/private" +# The distribution carries sourceless Python 3.12 modules, so verify the +# actual bytecode-reading path as well as source resources. +python3 - "$work/private" <<'PY' +from pathlib import Path +import py_compile +import sys +for source in Path(sys.argv[1]).rglob('*.py'): + if source.name == 'main.py' and source.parent == Path(sys.argv[1]): + continue + py_compile.compile(str(source), cfile=str(source.with_suffix('.pyc')), doraise=True) + source.unlink() +PY +python3 "$validator" --platform android --private-root "$work/private" +rm "$work/private/renpy/aether_mobile.pyc" +if python3 "$validator" --platform android --private-root "$work/private" \ + > "$work/stdout" 2> "$work/stderr"; then + echo 'packaging accepted a Python runtime without the cooperative module' >&2 + exit 1 +fi +grep -Fq 'missing Python module' "$work/stderr" +ln -s /tmp "$work/private/escape" +if python3 "$validator" --platform android --private-root "$work/private" \ + > "$work/stdout" 2> "$work/stderr"; then + echo 'packaging accepted an asset symlink' >&2 + exit 1 +fi +grep -Fq 'must not contain symlinks' "$work/stderr" + +# A host shared library must not be mislabeled as a mobile deliverable even +# when its function names resemble the lifecycle ABI. +printf 'int renpy_mobile_init(void) { return 0; }\n' > "$work/host.c" +"${CC:-cc}" -shared -fPIC "$work/host.c" -o "$work/host.so" +if python3 "$validator" --platform android --library "$work/host.so" \ + > "$work/stdout" 2> "$work/stderr"; then + echo 'packaging accepted a host shared library as Android arm64' >&2 + exit 1 +fi +grep -Eq 'must target arm64-v8a|must be a little-endian ELF64' "$work/stderr" + +echo "Ren'Py mobile packaging rejection/resource checks passed; no mobile app executed" diff --git a/tools/test_renpy_mobile_runtime_protocol.sh b/tools/test_renpy_mobile_runtime_protocol.sh new file mode 100755 index 000000000..7f9fd7029 --- /dev/null +++ b/tools/test_renpy_mobile_runtime_protocol.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Native/provider boundary tests with controlled Python/launcher modules. +# These tests do not build an APK/IPA or prove Ren'Py gameplay. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +work="$(mktemp -d "${TMPDIR:-/tmp}/aether-renpy-protocol.XXXXXX")" +trap 'rm -rf "$work"' EXIT +cc="${CC:-cc}" +cxx="${CXX:-c++}" +python_config="${PYTHON_CONFIG:-python3-config}" +command -v "$python_config" >/dev/null || { echo 'CPython development headers/python3-config are required' >&2; exit 1; } +read -r -a python_includes <<<"$("$python_config" --includes)" +read -r -a python_libs <<<"$("$python_config" --embed --ldflags)" +includes=(-I"$repo_root/bridge/renpy_runtime/include" -I"$repo_root/bridge/renpy_runtime/mobile_launcher/include" -I"$repo_root/abi/include") +"$cc" -std=c11 -Wall -Wextra -Werror "${python_includes[@]}" "${includes[@]}" \ + -c "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c" -o "$work/lifecycle.o" +"$cxx" -std=c++17 -Wall -Wextra -Werror "${python_includes[@]}" "${includes[@]}" \ + "$repo_root/bridge/renpy_runtime/tests/mobile_lifecycle.cpp" "$work/lifecycle.o" "${python_libs[@]}" \ + -o "$work/lifecycle-test" +python_lib_dir="$("$python_config" --prefix)/lib" +LD_LIBRARY_PATH="$python_lib_dir${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$work/lifecycle-test" +"$cxx" -std=c++17 -Wall -Wextra -Werror -DAETHERKIRI_RENPY_PROTOCOL_TEST -Wl,-export-dynamic "${includes[@]}" \ + "$repo_root/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp" \ + "$repo_root/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp" \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp" \ + -ldl -pthread -o "$work/provider-test" +"$work/provider-test" +printf '%s\n' 'RenPy native/provider protocol tests passed (not gameplay acceptance)' diff --git a/tools/test_renpy_mobile_source_build_plan.sh b/tools/test_renpy_mobile_source_build_plan.sh old mode 100644 new mode 100755 index 3e7e0f168..9fffee74b --- a/tools/test_renpy_mobile_source_build_plan.sh +++ b/tools/test_renpy_mobile_source_build_plan.sh @@ -1,43 +1,48 @@ #!/usr/bin/env bash -# Validate the explicit Ren'Py native source-build plan without downloading -# toolchains or pretending that the current upstream launcher is playable. +# Check source-build gating, pins and platform-aware prerequisites. No gameplay. set -euo pipefail - repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" launcher="$repo_root/bridge/renpy_runtime/mobile_launcher/build.sh" runner="$repo_root/tools/run_renpy_mobile_source_build.sh" -plan="$(mktemp)" runner_tmp="$(mktemp -d "${TMPDIR:-/tmp}/renpy-mobile-source-runner.XXXXXX")" -trap 'rm -f "$plan"; rm -rf "$runner_tmp"' EXIT - -[[ -f "$runner" ]] || { echo "missing mobile source build runner" >&2; exit 1; } +trap 'rm -rf "$runner_tmp"' EXIT bash -n "$runner" -skip_output="$( - AETHERKIRI_RENPY_BUILD_ROOT="$runner_tmp/missing" bash "$runner" --mode auto -)" +bash -n "$launcher" +skip_output="$(AETHERKIRI_RENPY_BUILD_ROOT="$runner_tmp/missing" bash "$runner" --mode auto)" grep -Fq 'source build skipped' <<<"$skip_output" -if AETHERKIRI_RENPY_SOURCE_BUILD_REQUIRED=1 AETHERKIRI_RENPY_BUILD_ROOT="$runner_tmp/missing" bash "$runner" --mode auto >"$runner_tmp/required.stdout" 2>"$runner_tmp/required.stderr"; then - echo "required source build unexpectedly skipped" >&2 - exit 1 +if AETHERKIRI_RENPY_SOURCE_BUILD_REQUIRED=1 AETHERKIRI_RENPY_BUILD_ROOT="$runner_tmp/missing" \ + bash "$runner" --mode auto >"$runner_tmp/required.stdout" 2>"$runner_tmp/required.stderr"; then + echo "required source build unexpectedly skipped" >&2; exit 1 fi -grep -Fq 'requires an official renpy-build checkout' "$runner_tmp/required.stderr" - -bash "$launcher" --source-build-plan --renpy-build "$repo_root" >"$plan" -grep -F "./build.sh --platform android rebuild rapt rapt-sdl2" "$plan" -grep -F "./build.sh --platform ios rebuild renios" "$plan" -grep -F "librenpython.so" "$plan" -grep -F "librenpython.a" "$plan" -grep -F "does not assert mobile playability" "$plan" -grep -F 'AETHERKIRI_RENPY_LIFECYCLE_FORK' "$runner" -grep -F '0002-android-host-bootstrap.patch' "$runner" -grep -F '0003-ios-host-bootstrap.patch' "$runner" - -# The repository checkout is intentionally not a renpy-build checkout. The -# strict preflight must refuse it instead of silently running a partial build. -if bash "$launcher" --check-renpy-build --renpy-build "$repo_root" >"$plan" 2>&1; then - echo "strict preflight unexpectedly accepted the AetherKiri checkout" >&2 - exit 1 +grep -Fq 'requires an official renpy-build checkout' "$runner_tmp/required.stderr" +if bash "$runner" --ref master --mode prepare >"$runner_tmp/ref.stdout" 2>"$runner_tmp/ref.stderr"; then + echo "unpinned build revision unexpectedly accepted" >&2; exit 1 fi -grep -F "Ren'Py source-build preflight failed" "$plan" - -echo "Ren'Py mobile source-build plan checks passed" +grep -Fq 'unsupported build revision' "$runner_tmp/ref.stderr" +bash "$launcher" --source-build-plan --renpy-build "$repo_root" --platform android >"$runner_tmp/plan" +for expected in '7bfab40c1174f622f644b24669afd5fb167fbb79' '39895c1e017f0b36ffea2447d97eccd69d76ee1c' \ + '65f8da82b13a1273e55a6bfcbd1f9da09fc4eb7a' '--python 3 rebuild librenpy pythonlib renpython rapt sdl2' \ + 'librenpython.so' 'librenpython.a' 'does not assert mobile playability'; do + grep -Fq -- "$expected" "$runner_tmp/plan" +done +if bash "$launcher" --check-renpy-build --renpy-build "$repo_root" --platform android >"$runner_tmp/check" 2>&1; then + echo "strict preflight unexpectedly accepted the Aether checkout" >&2; exit 1 +fi +grep -Fq 'Ren\x27Py source-build preflight failed' "$runner_tmp/check" || grep -Fq "Ren'Py source-build preflight failed" "$runner_tmp/check" +if grep -Fq 'missing toolchain archive:' "$runner_tmp/check"; then + grep -Fq 'android-ndk-r29-linux.zip' "$runner_tmp/check" + if grep -Eq '^missing toolchain archive:.*iPhone' "$runner_tmp/check"; then + echo "Android-only preflight incorrectly requires a licensed iOS SDK" >&2; exit 1 + fi +fi +python3 - "$runner" <<'PY' +from pathlib import Path +import sys +source = Path(sys.argv[1]).read_text() +assert source.index('apply_once "$renpy_build/renpy"') < source.index('./build.sh --platform android') +assert '"$mode" == "check" || "$mode" == "auto"' not in source, 'auto must not mean check-only' +for symbol in ('renpy_mobile_text_input_state', 'renpy_mobile_set_surface_size'): + assert symbol in source +assert 'rapt-sdl2' not in source, 'pinned release has a sdl2 task, no rapt-sdl2 task' +PY +echo "Ren'Py mobile source-build gating checks passed; no native build/gameplay performed" diff --git a/tools/test_renpy_mobile_symbol_gate.sh b/tools/test_renpy_mobile_symbol_gate.sh index 706d53d2a..8037195f4 100644 --- a/tools/test_renpy_mobile_symbol_gate.sh +++ b/tools/test_renpy_mobile_symbol_gate.sh @@ -7,6 +7,8 @@ set -euo pipefail required_symbols=( + renpy_mobile_bootstrap + renpy_mobile_bind_window renpy_mobile_init renpy_mobile_tick renpy_mobile_frame @@ -14,6 +16,8 @@ required_symbols=( renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown + renpy_mobile_text_input_state + renpy_mobile_set_surface_size ) nm_listing() { @@ -73,6 +77,8 @@ trap 'rm -rf "$work"' EXIT cat >"$work/fixture.c" <<'C' #define EXPORT __attribute__((visibility("default"))) +EXPORT int renpy_mobile_bootstrap(void) { return 0; } +EXPORT int renpy_mobile_bind_window(void) { return 0; } EXPORT int renpy_mobile_init(void) { return 0; } EXPORT int renpy_mobile_tick(void) { return 0; } EXPORT int renpy_mobile_frame(void) { return 0; } @@ -80,6 +86,8 @@ EXPORT int renpy_mobile_input(void) { return 0; } EXPORT int renpy_mobile_pause(void) { return 0; } EXPORT int renpy_mobile_resume(void) { return 0; } EXPORT void renpy_mobile_shutdown(void) {} +EXPORT int renpy_mobile_text_input_state(void) { return 0; } +EXPORT int renpy_mobile_set_surface_size(void) { return 0; } C "${CC:-cc}" -shared -fPIC "$work/fixture.c" -o "$work/librenpython.so" check_symbols "$work/librenpython.so" shared diff --git a/tools/test_stage_renpy_android_support.sh b/tools/test_stage_renpy_android_support.sh index 855390f5f..3e5c81868 100644 --- a/tools/test_stage_renpy_android_support.sh +++ b/tools/test_stage_renpy_android_support.sh @@ -19,6 +19,7 @@ mkdir -p "$tmp_root/android-build/src/main" "$tmp_root/private" printf 'private fixture\n' > "$tmp_root/private/private.mp3" bash "$stage" \ + --allow-unsupported \ --mobile-root "$mobile_root" \ --godot-build "$tmp_root/android-build" \ --private-assets "$tmp_root/private" >/dev/null @@ -70,7 +71,9 @@ for callback in \ grep -Fq "$callback" "$main/java/org/libsdl/app/SDLActivity.java" done grep -Fq 'nativeSetEnv' "$main/java/org/renpy/android/PythonSDLActivity.java" -grep -Fx 'playable=false' "$main/assets/renpy_mobile/manifest.properties" +grep -Fx 'playable=unverified' "$main/assets/renpy_mobile/manifest.properties" +grep -Fx 'inspection_only=true' "$main/assets/renpy_mobile/manifest.properties" +grep -Fx 'native_lifecycle_validated=false' "$main/assets/renpy_mobile/manifest.properties" grep -Fx 'manifest_merged=false' "$main/assets/renpy_mobile/manifest.properties" grep -Fx 'java_host_shims=org/libsdl/app/SDLActivity.java,org/renpy/android/PythonSDLActivity.java' \ "$main/assets/renpy_mobile/manifest.properties" @@ -87,7 +90,7 @@ grep -Fq 'PythonSDLActivity' "$main/assets/renpy_mobile/rapt/java/org/renpy/andr mkdir -p "$conflict_root/android-build/src/main/java/org/libsdl/app" printf 'package org.libsdl.app; public final class SDLActivity {}\n' \ > "$conflict_root/android-build/src/main/java/org/libsdl/app/SDLActivity.java" -if bash "$stage" --mobile-root "$mobile_root" \ +if bash "$stage" --allow-unsupported --mobile-root "$mobile_root" \ --godot-build "$conflict_root/android-build" \ >"$conflict_root/stdout" 2>"$conflict_root/stderr"; then echo "stager overwrote an unrelated SDLActivity shim" >&2 @@ -95,4 +98,14 @@ if bash "$stage" --mobile-root "$mobile_root" \ fi grep -Fq 'Refusing to overwrite unrelated Android Java class' "$conflict_root/stderr" -echo "Ren'Py Android staging/package smoke ok" +# The same official RAPT archive and dummy private.mp3 must be rejected by the +# real app staging path, rather than producing a silently unsupported build. +if bash "$stage" --mobile-root "$mobile_root" \ + --godot-build "$tmp_root/android-build" --private-assets "$tmp_root/private" \ + >"$tmp_root/strict-stdout" 2>"$tmp_root/strict-stderr"; then + echo "enabled app stager accepted an official blocking archive and incomplete Python payload" >&2 + exit 1 +fi +grep -Fq "Ren'Py mobile packaging input rejected" "$tmp_root/strict-stderr" + +echo "Ren'Py Android archive staging and unsupported-runtime rejection ok (gameplay untested)" diff --git a/tools/validate_renpy_mobile_payload.py b/tools/validate_renpy_mobile_payload.py new file mode 100755 index 000000000..e6b250070 --- /dev/null +++ b/tools/validate_renpy_mobile_payload.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Reject incomplete mobile packaging inputs; this does not test gameplay.""" + +import argparse +import ast +import importlib.util +import marshal +import os +from pathlib import Path +import re +import shlex +import shutil +import struct +import subprocess +import sys +import tempfile +import types +import zipfile + + +LIFECYCLE_SYMBOLS = ( + "renpy_mobile_bootstrap", "renpy_mobile_bind_window", "renpy_mobile_init", + "renpy_mobile_tick", "renpy_mobile_frame", "renpy_mobile_input", + "renpy_mobile_pause", "renpy_mobile_resume", "renpy_mobile_shutdown", + "renpy_mobile_text_input_state", "renpy_mobile_set_surface_size", +) + + +def fail(message): + raise ValueError(message) + + +def module_functions(root, relative): + source = root / (relative + ".py") + bytecode = root / (relative + ".pyc") + if source.is_file(): + return {node.name for node in ast.parse(source.read_bytes()).body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))} + if bytecode.is_file(): + data = bytecode.read_bytes() + if data[:4] != importlib.util.MAGIC_NUMBER: + fail(f"{bytecode}: validate with the matching Python 3.12 interpreter") + code = marshal.loads(data[16:]) + if not isinstance(code, types.CodeType): + fail(f"invalid Python bytecode: {bytecode}") + return {value.co_name for value in code.co_consts + if isinstance(value, types.CodeType)} + fail(f"missing Python module: {root / relative}.py or .pyc") + + +def validate_private(root, platform): + if not root.is_dir(): + fail(f"Ren'Py private payload must be an unpacked directory: {root}") + for entry in root.rglob("*"): + if entry.is_symlink(): + fail(f"APK/bundle private assets must not contain symlinks: {entry}") + entrypoints = ("main.py",) if platform == "android" else ("renpy.py", "main.py") + if not any((root / entry).is_file() for entry in entrypoints): + fail(f"Ren'Py private payload lacks entrypoint {entrypoints}: {root}") + for module in ("lib/python3.12/site", "lib/python3.12/encodings/__init__", + "renpy/__init__", "renpy/execution", "renpy/display/core"): + module_functions(root, module) + module_functions(root, "renpy/main") + functions = module_functions(root, "renpy/aether_mobile") + missing = {"cooperative_start", "cooperative_tick", "cooperative_stop"} - functions + if missing: + fail(f"unpatched Ren'Py Python payload: missing {', '.join(sorted(missing))} in {root}/renpy/aether_mobile") + + +def command_output(command): + result = subprocess.run(command, capture_output=True, text=True, check=False) + if result.returncode: + fail(f"cannot inspect mobile native payload ({' '.join(command)}): {result.stderr.strip()}") + return result.stdout + + +def validate_library(library, platform, arch, sdk, executable=False, packaged=False): + if not library.is_file(): + fail(f"Ren'Py lifecycle library is missing: {library}") + data = library.read_bytes() + if b"AETHERKIRI_RENPY_LIFECYCLE_STUB" in data: + fail(f"contract-only stub cannot be packaged as a Ren'Py runtime: {library}") + if platform == "android": + if len(data) < 20 or data[:4] != b"\x7fELF" or data[4:6] != b"\x02\x01": + fail(f"Android Ren'Py payload must be a little-endian ELF64 shared library: {library}") + elf_type, machine = struct.unpack_from(" Date: Thu, 8 Oct 2026 12:10:18 +0000 Subject: [PATCH 250/331] ci: provide LLVM linker for the Ren'Py source rebuild --- .github/workflows/renpy-mobile-runtime-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/renpy-mobile-runtime-build.yml b/.github/workflows/renpy-mobile-runtime-build.yml index d5128916a..8f794eb35 100644 --- a/.github/workflows/renpy-mobile-runtime-build.yml +++ b/.github/workflows/renpy-mobile-runtime-build.yml @@ -29,7 +29,7 @@ jobs: sudo apt-get update sudo apt-get install --no-install-recommends -y \ build-essential ccache unzip autoconf autoconf-archive automake \ - libtool-bin pkg-config llvm-18 clang-18 python3-dev python3-venv \ + libtool-bin pkg-config llvm-18 clang-18 lld-18 python3-dev python3-venv \ curl python3-jinja2 debootstrap libgmp-dev libmpfr-dev libmpc-dev \ libssl-dev libbz2-dev liblzma-dev bc cmake ninja-build libassimp-dev \ libavcodec-dev libavformat-dev libswresample-dev libswscale-dev \ From b206004560dbb6030c6847ebd74f991f207fe3d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 12:22:03 +0000 Subject: [PATCH 251/331] fix: build the Ren'Py demo without optional licensed SDKs --- .github/workflows/build.yml | 4 +- .github/workflows/renpy-desktop.yml | 5 +- .../workflows/renpy-mobile-runtime-build.yml | 1 + .../patches/native/0005-optional-cubism.patch | 55 ++++++ .../mobile_launcher/patches/native/README.md | 8 + tools/run_renpy_mobile_source_build.sh | 2 +- tools/test_renpy_mobile_native_fork.sh | 7 +- tools/test_renpy_mobile_optional_sdk.py | 162 ++++++++++++++++++ tools/test_renpy_mobile_source_build_plan.sh | 1 + tools/validate_renpy_mobile_payload.py | 2 +- 10 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/native/0005-optional-cubism.patch create mode 100644 tools/test_renpy_mobile_optional_sdk.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 87fb9b73e..3190c36a6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -548,7 +548,9 @@ jobs: -I bridge/renpy_runtime/include -I bridge/renpy_runtime/mobile_launcher/include \ -c "bridge/renpy_runtime/src/$source.cpp" -o "$evidence_dir/$source.o" done - xcrun lipo -verify_arch arm64 "$evidence_dir/renpy_mobile_lifecycle.o" + for object in "$evidence_dir"/*.o; do + xcrun lipo "$object" -verify_arch arm64 + done echo "iOS target objects compiled using the Python 3.12 C API; no target CPython link or gameplay has run." - name: Preserve iOS target compile evidence diff --git a/.github/workflows/renpy-desktop.yml b/.github/workflows/renpy-desktop.yml index 05aa9bf22..d5761d1c9 100644 --- a/.github/workflows/renpy-desktop.yml +++ b/.github/workflows/renpy-desktop.yml @@ -60,11 +60,12 @@ jobs: - name: Install pinned Ren'Py SDK and run fixture smoke env: - RENPY_PLATFORM: ${{ matrix.platform }} + SDK_PLATFORM: ${{ matrix.platform }} run: | set -euo pipefail sdk_root="${RUNNER_TEMP}/aetherkiri-renpy-sdk" - bash tools/install_renpy_sdk.sh --platform "${RENPY_PLATFORM}" --arch x86_64 --destination "${sdk_root}" + bash tools/install_renpy_sdk.sh --platform "${SDK_PLATFORM}" \ + --arch x86_64 --destination "${sdk_root}" test -f "${sdk_root}/renpy.sh" || test -f "${sdk_root}/renpy.exe" platform_lib_dir="$(find "${sdk_root}/lib" -maxdepth 1 -type d -name 'py3-*' -print -quit 2>/dev/null || true)" if [[ -z "${platform_lib_dir}" ]] || diff --git a/.github/workflows/renpy-mobile-runtime-build.yml b/.github/workflows/renpy-mobile-runtime-build.yml index 8f794eb35..e1f9906e0 100644 --- a/.github/workflows/renpy-mobile-runtime-build.yml +++ b/.github/workflows/renpy-mobile-runtime-build.yml @@ -45,6 +45,7 @@ jobs: bash tools/run_renpy_mobile_source_build.sh --mode prepare --fetch \ --platform android --renpy-build "$RENPY_BUILD_ROOT" \ --output-dir "$RENPY_OUTPUT_ROOT" + python3 tools/test_renpy_mobile_optional_sdk.py "$RENPY_BUILD_ROOT" - name: Supply the official pinned Android NDK run: | set -euo pipefail diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0005-optional-cubism.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0005-optional-cubism.patch new file mode 100644 index 000000000..bcb38cd8c --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0005-optional-cubism.patch @@ -0,0 +1,55 @@ +diff --git a/tasks/live2d.py b/tasks/live2d.py +--- a/tasks/live2d.py ++++ b/tasks/live2d.py +@@ -4,8 +4,12 @@ + + @annotator + def annotate(c: Context): +- c.include("{{ install }}/cubism/Core/include") +- c.env("CUBISM", "{{ install }}/cubism") ++ # Basic Ren'Py games do not require the separately licensed Cubism SDK. ++ # Clear inherited CUBISM values so generation and target compilation agree. ++ c.environ.pop("CUBISM", None) ++ if c.path("{{ install }}/cubism/Core/include/Live2DCubismCore.h").is_file(): ++ c.include("{{ install }}/cubism/Core/include") ++ c.env("CUBISM", "{{ install }}/cubism") + + + @task(platforms="all") +diff --git a/tasks/librenpy.py b/tasks/librenpy.py +--- a/tasks/librenpy.py ++++ b/tasks/librenpy.py +@@ -45,6 +45,11 @@ + parts = l.split() + + if parts[0] == "renpy.compat.dictviews" and c.python != "2": ++ continue ++ ++ if parts[0] == "renpy.gl2.live2dmodel" and not c.path( ++ "{{ install }}/cubism/Core/include/Live2DCubismCore.h" ++ ).is_file(): + continue + + modules.append(parts[0]) +diff --git a/tasks/pythonlib.py b/tasks/pythonlib.py +--- a/tasks/pythonlib.py ++++ b/tasks/pythonlib.py +@@ -200,8 +200,6 @@ + pefile + ordlookup/ + +-steamapi +- + brotli + """ + +@@ -214,6 +212,9 @@ + + # The list of rules. + rules = set(PY3_MODULES.split()) ++ # Steam generation is optional and skipped when its SDK is unavailable. ++ if c.path("{{ pytmp }}/steam/steamapi.py").is_file(): ++ rules.add("steamapi") + used_rules = set() + + search = [ diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index 7a61a2f61..602c80434 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -6,6 +6,14 @@ ABI header into that checkout. It compiles the shared native lifecycle and linked greenlet for Android and every selected iOS target. The platform bootstrap initializes CPython without calling the process launchers. +`0005-optional-cubism.patch` keeps the base runtime build independent of the +optional licensed SDKs. It enables Live2D generation and its native module only +when the target Cubism header exists, and packages `steamapi` only when the +Steam task actually generated that module. Games that require Live2D still +need the Cubism SDK and its runtime library; supplying the header preserves the +upstream compilation path. No native build tasks are marked complete by this +patch. + `renpy_mobile_lifecycle.c` owns the copied host callback table and the borrowed frame byte storage. It invokes the full cooperative Python entrypoints, preserves pointer/key/text actions, publishes renderer screenshots and diff --git a/tools/run_renpy_mobile_source_build.sh b/tools/run_renpy_mobile_source_build.sh index 2ac0e0527..70939650e 100755 --- a/tools/run_renpy_mobile_source_build.sh +++ b/tools/run_renpy_mobile_source_build.sh @@ -98,7 +98,7 @@ apply_once() { fi } apply_once "$renpy_build/renpy" "$patch_root/python/0001-cooperative-loop-skeleton.patch" -for patch in 0001-renpy-build-link.patch 0002-android-host-bootstrap.patch 0003-ios-host-bootstrap.patch 0004-android-offscreen-renderer.patch; do +for patch in 0001-renpy-build-link.patch 0002-android-host-bootstrap.patch 0003-ios-host-bootstrap.patch 0004-android-offscreen-renderer.patch 0005-optional-cubism.patch; do apply_once "$renpy_build" "$patch_root/native/$patch" done cp "$patch_root/native/renpy_mobile_lifecycle.c" "$renpy_build/runtime/renpy_mobile_lifecycle.c" diff --git a/tools/test_renpy_mobile_native_fork.sh b/tools/test_renpy_mobile_native_fork.sh index dc5440b37..87feb887e 100644 --- a/tools/test_renpy_mobile_native_fork.sh +++ b/tools/test_renpy_mobile_native_fork.sh @@ -23,7 +23,8 @@ for patch in \ "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch" \ "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0002-android-host-bootstrap.patch" \ "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch" \ - "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0004-android-offscreen-renderer.patch"; do + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0004-android-offscreen-renderer.patch" \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0005-optional-cubism.patch"; do [[ -s "$patch" ]] || { echo "missing native build patch: $patch" >&2; exit 1; } if [[ "$patch" == *host-bootstrap.patch ]]; then grep -Fq 'renpy_mobile_bootstrap' "$patch" || { @@ -38,10 +39,12 @@ if [[ -n "${RENPY_BUILD_TEST_ROOT:-}" && -d "${RENPY_BUILD_TEST_ROOT}/.git" ]]; "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch" \ "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0002-android-host-bootstrap.patch" \ "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch" \ - "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0004-android-offscreen-renderer.patch"; do + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0004-android-offscreen-renderer.patch" \ + "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0005-optional-cubism.patch"; do git -C "$RENPY_BUILD_TEST_ROOT" apply --check "$patch" 2>/dev/null || \ git -C "$RENPY_BUILD_TEST_ROOT" apply --reverse --check "$patch" done + python3 "$repo_root/tools/test_renpy_mobile_optional_sdk.py" "$RENPY_BUILD_TEST_ROOT" fi bash "$repo_root/tools/test_renpy_demo_fixture.sh" printf '%s\n' 'RenPy native source/demo fixture checks passed; no gameplay performed' diff --git a/tools/test_renpy_mobile_optional_sdk.py b/tools/test_renpy_mobile_optional_sdk.py new file mode 100644 index 000000000..e0ae12df7 --- /dev/null +++ b/tools/test_renpy_mobile_optional_sdk.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Exercise optional-SDK task branches from patched pinned upstream sources. + +This verifies task selection and payload rules; it does not compile or run Ren'Py. +""" +import ast +import contextlib +from pathlib import Path +import re +import subprocess +import sys +import tarfile +import tempfile + + +REPO = Path(__file__).resolve().parents[1] +PATCH = REPO / "bridge/renpy_runtime/mobile_launcher/patches/native/0005-optional-cubism.patch" +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" + + +def function(path, name, namespace): + tree = ast.parse(path.read_text()) + selected = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == name) + selected.decorator_list = [] + exec(compile(ast.Module(body=[selected], type_ignores=[]), str(path), "exec"), namespace) + return namespace[name] + + +class Context: + python = "3" + platform = "android" + + def __init__(self, root): + self.root = root + self.renpy = root / "renpy" + self.environ = {"CUBISM": "/unrelated/host/sdk"} + self.variables = {} + self.includes = [] + self.commands = [] + self.modules = [] + + def path(self, text): + values = { + "install": self.root / "install", + "pytmp": self.root / "pytmp", + "source": self.root / "source", + "distlib": self.root / "distlib", + "pythonver": "python3.12", + "pycver": "312", + "runtime": self.root / "runtime", + } + for key, value in values.items(): + text = text.replace("{{ " + key + " }}", str(value)) + return Path(text) + + def expand(self, text): + return str(self.path(text)) + + def include(self, text): + self.includes.append(self.path(text)) + + def env(self, name, value): + self.environ[name] = self.expand(value) + + def var(self, name, value): + self.variables[name] = value + + def run(self, command): + for name, value in self.variables.items(): + command = command.replace("{{ " + name + " }}", str(value)) + self.commands.append(command) + if command.startswith("mkdir -p "): + self.path(command[len("mkdir -p "):]).mkdir(parents=True, exist_ok=True) + + def run_group(self): + return contextlib.nullcontext(self) + + def generate(self, _source, _destination, *, modules): + self.modules = list(modules) + + def unlink(self, _path): + pass + + def copy(self, _source, _destination): + pass + + def rmtree(self, _path): + pass + + def clean(self, path): + self.path(path).mkdir(parents=True, exist_ok=True) + + def compile(self, path): + # Emulate bytecode filenames so the actual packaging rule loop runs. + for source in Path(path).glob("**/*.py"): + destination = source.parent / "__pycache__" / (source.stem + ".cpython-312.pyc") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(b"rule-test") + + +def write(root, name, text): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + return path + + +def main(): + checkout = Path(sys.argv[1]) + actual = subprocess.check_output(["git", "-C", str(checkout), "rev-parse", "HEAD"], text=True).strip() + assert actual == PIN, (actual, PIN) + with tempfile.TemporaryDirectory(prefix="renpy-optional-sdk-") as temporary: + root = Path(temporary) + archive = subprocess.Popen([ + "git", "-C", str(checkout), "archive", "HEAD", + "tasks/live2d.py", "tasks/librenpy.py", "tasks/pythonlib.py", + ], stdout=subprocess.PIPE) + assert archive.stdout is not None + with tarfile.open(fileobj=archive.stdout, mode="r|*") as source: + source.extractall(root, filter="data") + assert archive.wait() == 0 + subprocess.run(["git", "apply", "--check", str(PATCH)], cwd=root, check=True) + subprocess.run(["git", "apply", str(PATCH)], cwd=root, check=True) + namespace = {"Context": Context} + annotate = function(root / "tasks/live2d.py", "annotate", namespace) + build = function(root / "tasks/librenpy.py", "build", namespace) + package = function(root / "tasks/pythonlib.py", "python3", { + "Context": Context, "PY3_MODULES": "", "re": re, + "shutil": __import__("shutil"), + }) + write(root, "renpy/src/Setup", "base base.c\nrenpy.gl2.live2dmodel live2dmodel.c live2dcsm.c\n") + write(root, "extensions/Setup", "extension extension.c\n") + write(root, "pytmp/pyjnius/Setup", "jnius jnius.c\n") + write(root, "source/brotli/Setup", "_brotli _brotli.c\n") + context = Context(root) + annotate(context) + assert "CUBISM" not in context.environ and not context.includes + build(context) + assert context.modules == ["base", "extension", "jnius", "_brotli"], context.modules + assert not any("live2dmodel" in command or "live2dcsm" in command for command in context.commands) + header = write(root, "install/cubism/Core/include/Live2DCubismCore.h", "/* test SDK */\n") + context = Context(root) + annotate(context) + assert context.environ["CUBISM"] == str(header.parents[2]) + assert context.includes == [header.parent] + build(context) + assert "renpy.gl2.live2dmodel" in context.modules + assert any("live2dmodel" in command for command in context.commands) + assert any("live2dcsm" in command for command in context.commands) + for present in (False, True): + case = root / ("steam-present" if present else "steam-absent") + write(case, "distlib/python3.12/certifi/empty", "") + write(case, "runtime/site3.py", "# test sitecustomize\n") + if present: + write(case, "pytmp/steam/steamapi.py", "# generated by SDK task\n") + package(Context(case)) + assert (case / "distlib/python3.12/steamapi.pyc").exists() == present + print("Pinned optional Cubism/Steam task branches passed; no native build or gameplay performed") + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_source_build_plan.sh b/tools/test_renpy_mobile_source_build_plan.sh index 9fffee74b..62cbba42b 100755 --- a/tools/test_renpy_mobile_source_build_plan.sh +++ b/tools/test_renpy_mobile_source_build_plan.sh @@ -40,6 +40,7 @@ from pathlib import Path import sys source = Path(sys.argv[1]).read_text() assert source.index('apply_once "$renpy_build/renpy"') < source.index('./build.sh --platform android') +assert source.index('0005-optional-cubism.patch') < source.index('./build.sh --platform android') assert '"$mode" == "check" || "$mode" == "auto"' not in source, 'auto must not mean check-only' for symbol in ('renpy_mobile_text_input_state', 'renpy_mobile_set_surface_size'): assert symbol in source diff --git a/tools/validate_renpy_mobile_payload.py b/tools/validate_renpy_mobile_payload.py index e6b250070..126385213 100755 --- a/tools/validate_renpy_mobile_payload.py +++ b/tools/validate_renpy_mobile_payload.py @@ -105,7 +105,7 @@ def validate_library(library, platform, arch, sdk, executable=False, packaged=Fa else: if not shutil.which("xcrun"): fail("iOS runtime validation requires macOS Xcode tools (xcrun nm/lipo/otool)") - command_output(["xcrun", "lipo", "-verify_arch", arch, str(library)]) + command_output(["xcrun", "lipo", str(library), "-verify_arch", arch]) if executable: # Release stripping can remove the nlist symbol table while dyld # retains the export trie used by dlsym(RTLD_DEFAULT, ...). From 821753445c0bccbe724de6b516fc8939037c0c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 12:24:32 +0000 Subject: [PATCH 252/331] fix: use the Windows Ren'Py launcher under Git Bash --- tools/run_renpy_smoke.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/run_renpy_smoke.sh b/tools/run_renpy_smoke.sh index 4c35b6c25..3c8355ff9 100755 --- a/tools/run_renpy_smoke.sh +++ b/tools/run_renpy_smoke.sh @@ -43,7 +43,13 @@ for source_file in options.rpy script.rpy; do done if [[ -z "$renpy_bin" && -n "$renpy_sdk" ]]; then - if [[ -x "$renpy_sdk/renpy.sh" ]]; then + host_platform="$(uname -s)" + if [[ "$host_platform" == MINGW* || "$host_platform" == MSYS* || + "$host_platform" == CYGWIN* ]] && [[ -f "$renpy_sdk/renpy.exe" ]]; then + # The Windows SDK also contains renpy.sh and Linux executables. + # Git Bash must run the native Windows launcher rather than that shell. + renpy_bin="$renpy_sdk/renpy.exe" + elif [[ -x "$renpy_sdk/renpy.sh" ]]; then renpy_bin="$renpy_sdk/renpy.sh" elif [[ -x "$renpy_sdk/renpy" ]]; then renpy_bin="$renpy_sdk/renpy" From af175717e2fd1097fa2d6f42b3dd0884cb0b1267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 12:48:51 +0000 Subject: [PATCH 253/331] Build iOS runtime with cloud Xcode and verify Android emulator gameplay --- .github/workflows/build.yml | 107 ++++++- .github/workflows/renpy-ios-renderer.yml | 51 +++ .github/workflows/renpy-ios-runtime-build.yml | 113 +++++++ .../workflows/renpy-mobile-runtime-build.yml | 33 +- CMakePresets.json | 25 ++ apps/godot_app/aether_kiri.gdextension | 6 + apps/godot_app/export_presets.cfg | 35 ++ apps/godot_app/project.godot | 1 + bridge/renpy_runtime/CMakeLists.txt | 3 + bridge/renpy_runtime/mobile_launcher/build.sh | 51 ++- .../native/0006-ios-offscreen-renderer.patch | 182 +++++++++++ .../native/0007-darwin-ios-build.patch | 302 ++++++++++++++++++ .../mobile_launcher/patches/native/README.md | 14 + .../patches/native/renpy_mobile_lifecycle.c | 11 +- scripts/build_android.sh | 57 +++- tools/renpy_ios_egl_probe/egl_probe.c | 144 +++++++++ tools/renpy_ios_egl_probe/egl_probe.h | 24 ++ tools/renpy_ios_egl_probe/main.m | 79 +++++ tools/run_renpy_ios_egl_probe.sh | 120 +++++++ tools/run_renpy_mobile_source_build.sh | 27 +- tools/stage_renpy_android_support.sh | 89 ++++-- tools/test_renpy_mobile_darwin_build.py | 102 ++++++ tools/test_renpy_mobile_native_fork.sh | 14 +- tools/test_renpy_mobile_source_build_plan.sh | 2 +- tools/test_stage_renpy_android_support.sh | 56 +++- tools/validate_renpy_mobile_payload.py | 25 +- 26 files changed, 1589 insertions(+), 84 deletions(-) create mode 100644 .github/workflows/renpy-ios-renderer.yml create mode 100644 .github/workflows/renpy-ios-runtime-build.yml create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch create mode 100644 tools/renpy_ios_egl_probe/egl_probe.c create mode 100644 tools/renpy_ios_egl_probe/egl_probe.h create mode 100644 tools/renpy_ios_egl_probe/main.m create mode 100755 tools/run_renpy_ios_egl_probe.sh create mode 100644 tools/test_renpy_mobile_darwin_build.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3190c36a6..cc4cbc13c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,7 +50,9 @@ permissions: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name }} - cancel-in-progress: true + # Let full source rebuilds finish. A newer commit replaces the pending run, + # while the running build can still publish its checked runtime evidence. + cancel-in-progress: false env: # GitHub does not expose repository secrets to fork or Dependabot pull @@ -73,6 +75,9 @@ jobs: renpy-android-runtime: uses: ./.github/workflows/renpy-mobile-runtime-build.yml + renpy-ios-runtime: + uses: ./.github/workflows/renpy-ios-runtime-build.yml + android: name: Build Android App needs: renpy-android-runtime @@ -402,6 +407,24 @@ jobs: if-no-files-found: error retention-days: 7 + - name: Build the x86_64 debug app for cloud emulator gameplay + run: | + set -euo pipefail + export CMAKE_C_COMPILER_LAUNCHER=ccache + export CMAKE_CXX_COMPILER_LAUNCHER=ccache + export NDK_CCACHE=ccache + bash scripts/build_android.sh debug --abi=x86_64 + python3 tools/validate_renpy_mobile_payload.py --platform android --abi x86_64 \ + --apk out/godot/android/x86_64/debug/Aether-emulator-debug.apk + + - name: Upload the actual cloud emulator APK + uses: actions/upload-artifact@v6 + with: + name: android-renpy-emulator-debug-apk + path: out/godot/android/x86_64/debug/Aether-emulator-debug.apk + if-no-files-found: error + retention-days: 7 + - name: Save ccache if: success() && github.event_name != 'pull_request' && steps.restore-ccache.outputs.cache-hit != 'true' uses: actions/cache/save@v6 @@ -409,8 +432,75 @@ jobs: path: .ccache key: ${{ runner.os }}-android-ccache-v2 + android-renpy-gameplay: + needs: android + runs-on: ubuntu-24.04 + timeout-minutes: 40 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/download-artifact@v6 + with: + name: android-renpy-emulator-debug-apk + path: ${{ runner.temp }}/renpy-apk + - name: Install the official emulator and x86_64 image + run: | + set -euo pipefail + mkdir -p "${RUNNER_TEMP}/renpy-android-device-evidence" + test "$(uname -m)" = x86_64 + sdkmanager="${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" + "$sdkmanager" --install emulator platform-tools 'system-images;android-35;google_apis;x86_64' + "${ANDROID_HOME}/emulator/emulator" -accel-check | tee "${RUNNER_TEMP}/renpy-android-device-evidence/acceleration.txt" + # Use the supported runner's existing KVM access. Do not change device + # permissions/security settings to manufacture access when unavailable. + test -r /dev/kvm + test -w /dev/kvm + printf 'no\n' | "${ANDROID_HOME}/cmdline-tools/latest/bin/avdmanager" create avd \ + --name aether-renpy-device --force --device pixel_2 \ + --package 'system-images;android-35;google_apis;x86_64' + echo 'hw.keyboard=no' >> "${HOME}/.android/avd/aether-renpy-device.avd/config.ini" + - name: Run actual APK gameplay through adb + run: | + set -euo pipefail + export PATH="${ANDROID_HOME}/platform-tools:${ANDROID_HOME}/emulator:${PATH}" + evidence="${RUNNER_TEMP}/renpy-android-device-evidence" + emulator -avd aether-renpy-device -port 5554 -accel on \ + -no-window -no-audio -no-boot-anim -no-snapshot \ + -gpu swiftshader -memory 3072 -camera-front none -camera-back none \ + > "$evidence/emulator.log" 2>&1 & + emulator_pid=$! + trap 'adb -s emulator-5554 emu kill >/dev/null 2>&1 || true; kill "$emulator_pid" >/dev/null 2>&1 || true' EXIT + timeout 180 adb -s emulator-5554 wait-for-device + booted=false + for attempt in $(seq 1 120); do + if [[ "$(adb -s emulator-5554 shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == 1 ]]; then + booted=true + break + fi + sleep 2 + done + [[ "$booted" == true ]] || { echo 'Actual Android emulator failed to boot'; exit 1; } + adb -s emulator-5554 shell input keyevent 82 + adb -s emulator-5554 shell getprop ro.product.cpu.abilist | tee "$evidence/abi.txt" + python3 tools/run_renpy_android_device_acceptance.py \ + --apk "${RUNNER_TEMP}/renpy-apk/Aether-emulator-debug.apk" \ + --serial emulator-5554 --cloud-device --stage-timeout 90 \ + --output-dir "$evidence" + - name: Preserve real device evidence even on failure + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-android-gameplay-evidence + path: ${{ runner.temp }}/renpy-android-device-evidence + if-no-files-found: warn + retention-days: 7 + ios: name: Build iOS App + needs: renpy-ios-runtime runs-on: macos-latest env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' @@ -562,23 +652,10 @@ jobs: if-no-files-found: warn retention-days: 7 - - name: Check the rebuilt iOS runtime source - env: - IOS_RUNTIME_RUN_ID: ${{ vars.AETHER_RENPY_IOS_RUNTIME_RUN_ID }} - IOS_RUNTIME_ARTIFACT: ${{ vars.AETHER_RENPY_IOS_RUNTIME_ARTIFACT }} - run: | - set -euo pipefail - if [[ -z "$IOS_RUNTIME_RUN_ID" || -z "$IOS_RUNTIME_ARTIFACT" ]]; then - echo "iOS Ren'Py build blocked: provide AETHER_RENPY_IOS_RUNTIME_RUN_ID and AETHER_RENPY_IOS_RUNTIME_ARTIFACT for a rebuilt Renios/Python payload from the pinned fork, built with licensed Apple SDKs." >&2 - exit 1 - fi - - name: Download the rebuilt Ren'Py iOS runtime uses: actions/download-artifact@v6 with: - name: ${{ vars.AETHER_RENPY_IOS_RUNTIME_ARTIFACT }} - run-id: ${{ vars.AETHER_RENPY_IOS_RUNTIME_RUN_ID }} - github-token: ${{ github.token }} + name: renpy-ios-runtime path: ${{ runner.temp }}/renpy-ios-runtime - name: Stage rebuilt Ren'Py iOS runtime diff --git a/.github/workflows/renpy-ios-renderer.yml b/.github/workflows/renpy-ios-renderer.yml new file mode 100644 index 000000000..75c8c577c --- /dev/null +++ b/.github/workflows/renpy-ios-renderer.yml @@ -0,0 +1,51 @@ +name: Ren'Py iOS renderer + +on: + pull_request: + paths: + - '.github/workflows/renpy-ios-renderer.yml' + - 'tools/run_renpy_ios_egl_probe.sh' + - 'tools/renpy_ios_egl_probe/**' + - 'bridge/renpy_runtime/mobile_launcher/patches/native/**' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: renpy-ios-renderer-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + simulator-pbuffer: + name: Execute MetalANGLE EGL rendering on iOS Simulator + runs-on: macos-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Fetch the actual framework from the pinned upstream source + uses: actions/checkout@v6 + with: + repository: renpy/renpy-build + ref: 7bfab40c1174f622f644b24669afd5fb167fbb79 + path: _renpy-probe-source + persist-credentials: false + - name: Build, install and execute the actual Simulator probe + run: | + set -euo pipefail + bash tools/run_renpy_ios_egl_probe.sh \ + --renpy-build "$GITHUB_WORKSPACE/_renpy-probe-source" \ + --output "$GITHUB_WORKSPACE/out/renpy-ios-egl-probe" \ + 2>&1 | tee "$RUNNER_TEMP/renpy-ios-egl-probe.log" + - name: Preserve Simulator results and rendering evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-egl-probe + path: | + out/renpy-ios-egl-probe/evidence + ${{ runner.temp }}/renpy-ios-egl-probe.log + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml new file mode 100644 index 000000000..b6219e56d --- /dev/null +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -0,0 +1,113 @@ +name: Build Ren'Py iOS runtime + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + ios-runtime: + name: Compile Ren'Py with this cloud runner's Xcode SDKs + runs-on: macos-latest + timeout-minutes: 360 + env: + RENPY_BUILD_ROOT: ${{ github.workspace }}/_renpy-build + RENPY_OUTPUT_ROOT: ${{ github.workspace }}/out/renpy-ios-source + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Restore a runtime compiled from these exact source inputs + id: runtime-cache + uses: actions/cache@v6 + with: + path: out/renpy-ios-source + key: ${{ runner.os }}-${{ runner.arch }}-renpy-ios-runtime-v1-${{ hashFiles('tools/run_renpy_mobile_source_build.sh', 'bridge/renpy_runtime/mobile_launcher/build.sh', 'bridge/renpy_runtime/mobile_launcher/include/*.h', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.patch', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.c', 'bridge/renpy_runtime/mobile_launcher/patches/python/*.patch') }} + - name: Provision native macOS prerequisites and record the actual SDKs + if: steps.runtime-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + xcodebuild -version + for sdk in macosx iphoneos iphonesimulator; do + xcrun --sdk "$sdk" --show-sdk-path + xcrun --sdk "$sdk" --show-sdk-version + done + brew install autoconf autoconf-archive automake libtool pkg-config \ + ccache cmake ninja uv python@3.12 openssl@3 xz bzip2 libffi gnu-tar coreutils + echo "$(brew --prefix coreutils)/libexec/gnubin" >> "$GITHUB_PATH" + echo "$(brew --prefix gnu-tar)/libexec/gnubin" >> "$GITHUB_PATH" + echo "$(brew --prefix python@3.12)/libexec/bin" >> "$GITHUB_PATH" + brew cleanup + # This isolated source-build job uses only the selected Xcode. Keep + # that installation and all Simulator runtimes; recover space from + # other preinstalled Xcode versions without copying any Apple SDK. + python3 - <<'PY' + import pathlib, shutil, subprocess + developer = pathlib.Path(subprocess.check_output(['xcode-select', '-p'], text=True).strip()).resolve() + selected = next((p for p in developer.parents if p.suffix == '.app'), None) + if selected is None or not (selected / 'Contents/Developer').is_dir(): + raise SystemExit('Cannot identify the selected Xcode; no installations will be removed.') + for app in pathlib.Path('/Applications').glob('Xcode*.app'): + if app.is_symlink() or app.resolve() == selected: + continue + if (app / 'Contents/Developer/usr/bin/xcodebuild').is_file(): + print('Removing unused cloud-runner installation:', app) + subprocess.run(['sudo', 'rm', '-rf', str(app)], check=True) + PY + df -h "$GITHUB_WORKSPACE" + - name: Prepare the pinned source and all native lifecycle patches + if: steps.runtime-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + bash tools/run_renpy_mobile_source_build.sh --mode prepare --fetch \ + --platform ios --renpy-build "$RENPY_BUILD_ROOT" \ + --output-dir "$RENPY_OUTPUT_ROOT" + uv --project "$RENPY_BUILD_ROOT/renpy" run python \ + tools/test_renpy_mobile_darwin_build.py "$RENPY_BUILD_ROOT" + - name: Compile target CPython, SDL, Ren'Py and device/Simulator archives + if: steps.runtime-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + bash tools/run_renpy_mobile_source_build.sh --mode build \ + --platform ios --renpy-build "$RENPY_BUILD_ROOT" \ + --output-dir "$RENPY_OUTPUT_ROOT" 2>&1 | tee "$RUNNER_TEMP/renpy-ios-source-build.log" + - name: Validate both genuine target archives and the Python payload + run: | + set -euo pipefail + python3 - "$RENPY_OUTPUT_ROOT/manifest.json" <<'PY' + import json, sys + manifest = json.load(open(sys.argv[1])) + assert manifest['native_compilation'] == 'passed' + assert manifest['device_gameplay'] == 'unverified' + assert manifest['platform'] == 'ios' + assert manifest['build_source_sha'] == '7bfab40c1174f622f644b24669afd5fb167fbb79' + assert manifest['renpy_source_sha'] == '39895c1e017f0b36ffea2447d97eccd69d76ee1c' + assert manifest['greenlet_source_sha'] == '65f8da82b13a1273e55a6bfcbd1f9da09fc4eb7a' + PY + for kind in release debug; do + sdk=iphoneos + [[ "$kind" != debug ]] || sdk=iphonesimulator + python3 tools/validate_renpy_mobile_payload.py --platform ios --arch arm64 --sdk "$sdk" \ + --library "$RENPY_OUTPUT_ROOT/payload/renios/prototype/prebuilt/$kind/librenpython.a" \ + --private-root "$RENPY_OUTPUT_ROOT/payload/renios/prototype/base" + done + tar -C "$RENPY_OUTPUT_ROOT" -czf "$RUNNER_TEMP/renpy-ios-runtime.tar.gz" payload manifest.json + - name: Upload the real compiled runtime for the application build + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-runtime + path: ${{ runner.temp }}/renpy-ios-runtime.tar.gz + if-no-files-found: error + retention-days: 7 + - name: Preserve actual source-build evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-source-build-evidence + path: | + ${{ runner.temp }}/renpy-ios-source-build.log + out/renpy-ios-source/manifest.json + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/renpy-mobile-runtime-build.yml b/.github/workflows/renpy-mobile-runtime-build.yml index e1f9906e0..b3edfad9e 100644 --- a/.github/workflows/renpy-mobile-runtime-build.yml +++ b/.github/workflows/renpy-mobile-runtime-build.yml @@ -19,7 +19,14 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false + - name: Restore a runtime compiled from these exact source inputs + id: runtime-cache + uses: actions/cache@v6 + with: + path: out/renpy-mobile-source + key: ${{ runner.os }}-renpy-android-runtime-v2-${{ hashFiles('tools/run_renpy_mobile_source_build.sh', 'bridge/renpy_runtime/mobile_launcher/build.sh', 'bridge/renpy_runtime/mobile_launcher/include/*.h', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.patch', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.c', 'bridge/renpy_runtime/mobile_launcher/patches/python/*.patch') }} - name: Provision the official Linux build prerequisites + if: steps.runtime-cache.outputs.cache-hit != 'true' run: | set -euo pipefail # This disposable source-build job does not use the host Android SDK, @@ -40,6 +47,7 @@ jobs: echo "${RUNNER_TEMP}/renpy-tools/bin" >> "$GITHUB_PATH" echo /usr/lib/llvm-18/bin >> "$GITHUB_PATH" - name: Prepare pinned sources and apply the runtime patches + if: steps.runtime-cache.outputs.cache-hit != 'true' run: | set -euo pipefail bash tools/run_renpy_mobile_source_build.sh --mode prepare --fetch \ @@ -47,6 +55,7 @@ jobs: --output-dir "$RENPY_OUTPUT_ROOT" python3 tools/test_renpy_mobile_optional_sdk.py "$RENPY_BUILD_ROOT" - name: Supply the official pinned Android NDK + if: steps.runtime-cache.outputs.cache-hit != 'true' run: | set -euo pipefail mkdir -p "$RENPY_BUILD_ROOT/tars" @@ -57,15 +66,31 @@ jobs: # Published by android/ndk's Unsupported-Downloads page. printf '87e2bb7e9be5d6a1c6cdf5ec40dd4e0c6d07c30b %s\n' "$ndk_archive" | sha1sum --check - name: Build the native libraries and Python payload + if: steps.runtime-cache.outputs.cache-hit != 'true' run: | set -euo pipefail bash tools/run_renpy_mobile_source_build.sh --mode build \ --platform android --renpy-build "$RENPY_BUILD_ROOT" \ --output-dir "$RENPY_OUTPUT_ROOT" 2>&1 | tee "${RUNNER_TEMP}/renpy-source-build.log" - python3 tools/validate_renpy_mobile_payload.py \ - --platform android \ - --library "$RENPY_OUTPUT_ROOT/payload/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" \ - --private-root "$RENPY_OUTPUT_ROOT/payload/rapt/runtime/private" + - name: Validate every deliverable including restored cache entries + run: | + set -euo pipefail + python3 - "$RENPY_OUTPUT_ROOT/manifest.json" <<'PY' + import json, sys + manifest = json.load(open(sys.argv[1])) + assert manifest['native_compilation'] == 'passed' + assert manifest['device_gameplay'] == 'unverified' + assert manifest['platform'] == 'android' + assert manifest['build_source_sha'] == '7bfab40c1174f622f644b24669afd5fb167fbb79' + assert manifest['renpy_source_sha'] == '39895c1e017f0b36ffea2447d97eccd69d76ee1c' + assert manifest['greenlet_source_sha'] == '65f8da82b13a1273e55a6bfcbd1f9da09fc4eb7a' + PY + for abi in arm64-v8a x86_64; do + python3 tools/validate_renpy_mobile_payload.py \ + --platform android --abi "$abi" \ + --library "$RENPY_OUTPUT_ROOT/payload/rapt/prototype/renpyandroid/src/main/jniLibs/$abi/librenpython.so" \ + --private-root "$RENPY_OUTPUT_ROOT/payload/rapt/runtime/private" + done tar -C "$RENPY_OUTPUT_ROOT" -czf "${RUNNER_TEMP}/renpy-android-runtime.tar.gz" payload manifest.json - name: Upload the compiled runtime for the application build uses: actions/upload-artifact@v6 diff --git a/CMakePresets.json b/CMakePresets.json index d5784cd3d..8b0e703d2 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -279,6 +279,27 @@ "VCPKG_BUILD_TYPE": "release" } }, + { + "name": "Android x64 Config", + "inherits": "Android arm64 Config", + "hidden": true, + "binaryDir": "${sourceDir}/out/android/x86_64/${presetName}", + "cacheVariables": { + "ANDROID_ABI": "x86_64", + "VCPKG_TARGET_TRIPLET": "x64-android" + } + }, + { + "name": "Android x64 Debug Config", + "inherits": "Android x64 Config", + "binaryDir": "${sourceDir}/out/android/x86_64/debug", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "VCPKG_BUILD_TYPE": "debug", + "CMAKE_C_FLAGS_DEBUG": "-Og -g", + "CMAKE_CXX_FLAGS_DEBUG": "-Og -g" + } + }, { "name": "Web Config", "generator": "Ninja", @@ -375,6 +396,10 @@ "name": "Android arm64 Release Build", "configurePreset": "Android arm64 Release Config" }, + { + "name": "Android x64 Debug Build", + "configurePreset": "Android x64 Debug Config" + }, { "name": "Web Debug Build", "configurePreset": "Web Debug Config" diff --git a/apps/godot_app/aether_kiri.gdextension b/apps/godot_app/aether_kiri.gdextension index 12d85b8b7..c7b748c43 100644 --- a/apps/godot_app/aether_kiri.gdextension +++ b/apps/godot_app/aether_kiri.gdextension @@ -12,6 +12,7 @@ macos.release = "res://bin/macos/release/libaether_kiri_godot.dylib" ios.debug = "res://bin/ios/debug/libaether_kiri_godot.a" ios.release = "res://bin/ios/release/libaether_kiri_godot.a" android.debug.arm64 = "res://bin/android/arm64-v8a/debug/libaether_kiri_godot.so" +android.debug.x86_64 = "res://bin/android/x86_64/debug/libaether_kiri_godot.so" android.release.arm64 = "res://bin/android/arm64-v8a/release/libaether_kiri_godot.so" web.debug.wasm32 = "res://bin/web/debug/aether_kiri_godot.wasm" web.release.wasm32 = "res://bin/web/release/aether_kiri_godot.wasm" @@ -46,3 +47,8 @@ android.release.arm64 = { "res://bin/android/arm64-v8a/release/libSDL2.so": "", "res://bin/android/arm64-v8a/release/libomp.so": "" } +android.debug.x86_64 = { + "res://bin/android/x86_64/debug/libengine_api.so": "", + "res://bin/android/x86_64/debug/libSDL2.so": "", + "res://bin/android/x86_64/debug/libomp.so": "" +} diff --git a/apps/godot_app/export_presets.cfg b/apps/godot_app/export_presets.cfg index 02ea10eaa..0b780cbfc 100644 --- a/apps/godot_app/export_presets.cfg +++ b/apps/godot_app/export_presets.cfg @@ -247,3 +247,38 @@ export_path="../../out/godot/linux/release/Aether.x86_64" binary_format/embed_pck=true binary_format/architecture="x86_64" debug/export_console_wrapper=0 + +[preset.10] +name="Android Emulator Debug" +platform="Android" +runnable=true +custom_features="renpy_emulator" +export_filter="all_resources" +include_filter="builtin_demos/**/*.xp3,builtin_demos/**/*.json,builtin_demos/**/*.txt,legal/*.txt" +exclude_filter="scripts/diagnostic_session_test.gd,scripts/builtin_demo_test.gd,scripts/game_input_mapping_test.gd,tests/**" +export_path="../../out/godot/android/x86_64/debug/Aether-emulator-debug.apk" + +[preset.10.options] +custom_template/debug="" +custom_template/release="" +gradle_build/use_gradle_build=true +gradle_build/min_sdk=26 +architectures/armeabi-v7a=false +architectures/arm64-v8a=false +architectures/x86=false +architectures/x86_64=true +version/code=4501 +version/name="1.0.7-alpha.3" +package/unique_name="org.github.krkr2.aetherkiri" +package/name="Aether" +package/signed=true +screen/immersive_mode=true +screen/support_small=true +screen/support_normal=true +screen/support_large=true +screen/support_xlarge=true +user_data_backup/allow=false +permissions/custom_permissions=PackedStringArray("android.permission.INTERNET", "android.permission.ACCESS_NETWORK_STATE", "android.permission.READ_EXTERNAL_STORAGE", "android.permission.WRITE_EXTERNAL_STORAGE", "android.permission.MANAGE_EXTERNAL_STORAGE") +launcher_icons/main_192x192="res://assets/icons/icon_192x192.png" +launcher_icons/adaptive_foreground_432x432="res://assets/icons/icon_adaptive_fg.png" +launcher_icons/adaptive_background_432x432="res://assets/icons/icon_adaptive_bg.png" diff --git a/apps/godot_app/project.godot b/apps/godot_app/project.godot index 0cb6d02f2..1af73f0f9 100644 --- a/apps/godot_app/project.godot +++ b/apps/godot_app/project.godot @@ -50,3 +50,4 @@ rendering/gl_compatibility/driver.android="opengl3" renderer/rendering_method="mobile" textures/vram_compression/import_etc2_astc=true renderer/rendering_method.android="mobile" +renderer/rendering_method.renpy_emulator="gl_compatibility" diff --git a/bridge/renpy_runtime/CMakeLists.txt b/bridge/renpy_runtime/CMakeLists.txt index 642127e2a..7d23c53a5 100644 --- a/bridge/renpy_runtime/CMakeLists.txt +++ b/bridge/renpy_runtime/CMakeLists.txt @@ -18,6 +18,9 @@ if(IOS OR ANDROID) set(_renpy_mobile_platform ios) endif() set(_renpy_validation_arch arm64) + if(ANDROID) + set(_renpy_validation_arch "${CMAKE_ANDROID_ARCH_ABI}") + endif() if(CMAKE_OSX_ARCHITECTURES) list(GET CMAKE_OSX_ARCHITECTURES 0 _renpy_validation_arch) endif() diff --git a/bridge/renpy_runtime/mobile_launcher/build.sh b/bridge/renpy_runtime/mobile_launcher/build.sh index 741b6fedb..3054bcfe0 100644 --- a/bridge/renpy_runtime/mobile_launcher/build.sh +++ b/bridge/renpy_runtime/mobile_launcher/build.sh @@ -146,16 +146,24 @@ renpy_build_source_preflight() { echo "cooperative-loop patch does not match $source_root" >&2 failures=$((failures + 1)) fi - local archives=() + local host_system="$(uname -s)" archives=() host_tools=() [[ "$platform" == ios ]] || archives+=(android-ndk-r29-linux.zip) - [[ "$platform" == android ]] || archives+=(iPhoneOS14.0.sdk.tar.gz iPhoneSimulator14.0.sdk.tar.gz) + if [[ "$platform" != android && "$host_system" != Darwin ]]; then + archives+=(iPhoneOS14.0.sdk.tar.gz iPhoneSimulator14.0.sdk.tar.gz) + fi for required in "${archives[@]}"; do if [[ ! -f "$renpy_build/tars/$required" ]]; then echo "missing toolchain archive: $renpy_build/tars/$required" >&2 failures=$((failures + 1)) fi done - for required in bash git python3 curl tar unzip make cmake ninja pkg-config clang-18 clang++-18 llvm-ar-18 llvm-nm-18 uv; do + host_tools=(bash git python3 curl tar unzip make cmake ninja pkg-config ccache autoconf automake uv) + if [[ "$host_system" == Darwin ]]; then + host_tools+=(brew xcrun glibtoolize realpath) + else + host_tools+=(clang-18 clang++-18 llvm-ar-18 llvm-nm-18) + fi + for required in "${host_tools[@]}"; do if ! command -v "$required" >/dev/null 2>&1; then echo "missing host tool: $required" >&2 failures=$((failures + 1)) @@ -167,7 +175,40 @@ renpy_build_source_preflight() { . /etc/os-release ubuntu_id="${ID:-}"; ubuntu_version="${VERSION_ID:-}" fi - if [[ "$(uname -s)" != Linux || "$ubuntu_id" != ubuntu || "$ubuntu_version" != 24.04 ]]; then + if [[ "$host_system" == Darwin ]]; then + if [[ "$platform" != ios ]]; then + echo "native macOS source builds support --platform ios only" >&2 + failures=$((failures + 1)) + fi + local sdk sdk_path sdk_tool + for sdk in macosx iphoneos iphonesimulator; do + sdk_path="$(xcrun --sdk "$sdk" --show-sdk-path 2>/dev/null || true)" + if [[ ! -d "$sdk_path" ]]; then + echo "selected Xcode is missing its $sdk SDK" >&2 + failures=$((failures + 1)) + else + echo "local Xcode $sdk SDK: $sdk_path" + fi + done + for sdk_tool in clang clang++ ar ranlib nm lipo otool; do + if ! xcrun --find "$sdk_tool" >/dev/null 2>&1; then + echo "selected Xcode is missing $sdk_tool" >&2 + failures=$((failures + 1)) + fi + done + for required in openssl@3 xz bzip2 libffi; do + local dependency_prefix + dependency_prefix="$(brew --prefix "$required" 2>/dev/null || true)" + if [[ ! -d "$dependency_prefix/include" || ! -d "$dependency_prefix/lib" ]]; then + echo "missing native host dependency: $required" >&2 + failures=$((failures + 1)) + fi + done + if [[ ! -f "${AETHERKIRI_RENPY_CONFIG_SUB:-}" ]]; then + echo "missing Homebrew automake config.sub (use the source runner build mode)" >&2 + failures=$((failures + 1)) + fi + elif [[ "$host_system" != Linux || "$ubuntu_id" != ubuntu || "$ubuntu_version" != 24.04 ]]; then echo "renpy-build officially requires Ubuntu 24.04 (detected ${ubuntu_id:-unknown} ${ubuntu_version:-unknown})" >&2 failures=$((failures + 1)) fi @@ -186,11 +227,13 @@ renpy_build_source_preflight() { renpy_build_source_plan() { cat < +++#endif ++ ++ #ifdef EGL_KHR_create_context ++ /* EGL_OPENGL_ES3_BIT_KHR was added in version 13 of the extension. */ ++@@ -508,6 +511,21 @@ SDL_EGL_LoadLibrary(_THIS, const char *egl_path, NativeDisplayType native_displa ++ ++ #if !defined(__WINRT__) ++ #if !defined(SDL_VIDEO_DRIVER_VITA) +++#if defined(METALANGLE) && defined(__IPHONEOS__) +++ if (platform == EGL_PLATFORM_ANGLE_ANGLE) { +++ const EGLint metal_attributes[] = { +++ EGL_PLATFORM_ANGLE_TYPE_ANGLE, EGL_PLATFORM_ANGLE_TYPE_METAL_ANGLE, EGL_NONE +++ }; +++ if (!_this->egl_data->eglGetPlatformDisplayEXT) { +++ return SDL_SetError("MetalANGLE has no eglGetPlatformDisplayEXT"); +++ } +++ _this->egl_data->egl_display = _this->egl_data->eglGetPlatformDisplayEXT( +++ platform, (void *)(uintptr_t)native_display, metal_attributes); +++ if (_this->egl_data->egl_display == EGL_NO_DISPLAY) { +++ return SDL_SetError("Could not create MetalANGLE Metal pbuffer display"); +++ } +++ } else +++#endif ++ if (platform) { ++ /* EGL 1.5 allows querying for client version with EGL_NO_DISPLAY ++ * -- ++diff --git a/src/video/offscreen/SDL_offscreenopengl.c b/src/video/offscreen/SDL_offscreenopengl.c ++index 9c0552c..e8f87a7 100644 ++--- a/src/video/offscreen/SDL_offscreenopengl.c +++++ b/src/video/offscreen/SDL_offscreenopengl.c ++@@ -26,6 +26,9 @@ ++ #include "SDL_offscreenopengl.h" ++ ++ #include "SDL_opengl.h" +++#if defined(METALANGLE) && defined(__IPHONEOS__) +++#include +++#endif ++ ++ int ++ OFFSCREEN_GL_SwapWindow(_THIS, SDL_Window* window) ++@@ -61,7 +64,16 @@ OFFSCREEN_GL_CreateContext(_THIS, SDL_Window* window) ++ int ++ OFFSCREEN_GL_LoadLibrary(_THIS, const char* path) ++ { ++-#ifdef __ANDROID__ +++#if defined(METALANGLE) && defined(__IPHONEOS__) +++ /* MetalANGLE's Metal display supports pbuffer surfaces without a +++ UIWindow, MGLLayer, or a host-owned native drawable. */ +++ int ret = SDL_EGL_LoadLibrary(_this, path, (NativeDisplayType)EGL_DEFAULT_DISPLAY, +++ EGL_PLATFORM_ANGLE_ANGLE); +++ if (ret != 0) return ret; +++ _this->egl_data->is_offscreen = SDL_TRUE; +++ _this->egl_data->egl_surfacetype = EGL_PBUFFER_BIT; +++ return SDL_EGL_ChooseConfig(_this); +++#elif defined(__ANDROID__) ++ /* Android exposes EGL_DEFAULT_DISPLAY; device-enumeration extensions are ++ optional. Use a private pbuffer, never the host's ANativeWindow. */ ++ int ret = SDL_EGL_LoadLibrary(_this, path, (NativeDisplayType)EGL_DEFAULT_DISPLAY, 0); +diff --git a/runtime/librenpython3.c b/runtime/librenpython3.c +index 1922883..d4c1256 100644 +--- a/runtime/librenpython3.c ++++ b/runtime/librenpython3.c +@@ -5,6 +5,10 @@ + #include + #include "Python.h" + #include "renpy_mobile_launcher.h" ++#ifdef IOS ++#include ++#include ++#endif + + /* Built into the same target CPython, never imported from a host wheel. */ + PyMODINIT_FUNC PyInit__greenlet(void); +@@ -559,6 +563,33 @@ int EXPORT launcher_main(int argc, char **argv) { + * This is called from the launcher executable, to start Ren'Py running. + */ + /* The host provides the packaged Python home and owns the application loop. */ ++#ifdef IOS ++static int aether_configure_ios_pbuffer(const renpy_mobile_host_t *host) { ++ /* Resolve the actually embedded framework. RTLD_DEFAULT may resolve GLES ++ to Apple's unrelated OpenGLES implementation when both are linked. */ ++ CFURLRef frameworks = CFBundleCopyPrivateFrameworksURL(CFBundleGetMainBundle()); ++ CFURLRef framework = frameworks ? CFURLCreateCopyAppendingPathComponent( ++ kCFAllocatorDefault, frameworks, CFSTR("MetalANGLE.framework"), 1) : NULL; ++ CFURLRef library = framework ? CFURLCreateCopyAppendingPathComponent( ++ kCFAllocatorDefault, framework, CFSTR("MetalANGLE"), 0) : NULL; ++ char path[PATH_MAX]; ++ int valid = library && CFURLGetFileSystemRepresentation(library, 1, (UInt8 *)path, sizeof(path)); ++ if (library) CFRelease(library); ++ if (framework) CFRelease(framework); ++ if (frameworks) CFRelease(frameworks); ++ if (!valid) { ++ if (host->log_utf8) host->log_utf8(host->user_data, 3, ++ "Could not resolve the embedded MetalANGLE framework for Ren'Py EGL"); ++ return RENPY_MOBILE_ERROR; ++ } ++ if (setenv("SDL_VIDEO_GL_DRIVER", path, 1) || setenv("SDL_VIDEO_EGL_DRIVER", path, 1) || ++ setenv("SDL_VIDEODRIVER", "offscreen", 1) || setenv("AETHER_RENPY_EMBEDDED", "1", 1)) { ++ return RENPY_MOBILE_ERROR; ++ } ++ return RENPY_MOBILE_OK; ++} ++#endif ++ + int EXPORT renpy_mobile_bootstrap(const renpy_mobile_config_t *mobile_config, + const renpy_mobile_host_t *host) { + if (!mobile_config || !host || !mobile_config->private_root_utf8 || +@@ -568,6 +599,9 @@ int EXPORT renpy_mobile_bootstrap(const renpy_mobile_config_t *mobile_config, + "Ren'Py interpreter reuse is not implemented; restart the host application for another game session"); + return RENPY_MOBILE_INVALID_STATE; + } ++#ifdef IOS ++ if (aether_configure_ios_pbuffer(host) != RENPY_MOBILE_OK) return RENPY_MOBILE_ERROR; ++#endif + if (PyImport_AppendInittab("_aether_greenlet", PyInit__greenlet) == -1 || + PyImport_AppendInittab("_aether_host", PyInit__aether_host) == -1) + return RENPY_MOBILE_ERROR; +diff --git a/tasks/metalangle.py b/tasks/metalangle.py +index 5d4a5c9..0b30f45 100644 +--- a/tasks/metalangle.py ++++ b/tasks/metalangle.py +@@ -5,8 +5,11 @@ from renpybuild.task import task, annotator + @annotator + def annotate(c: Context): + if c.platform == "ios": +- c.env("CFLAGS", "{{ CFLAGS }} -F {{install}} -framework MetalANGLE -DMETALANGLE") +- c.env("CXXFLAGS", "{{ CXXFLAGS }} -F {{install}} -framework MetalANGLE -DMETALANGLE") ++ # EGL/GLES use normal includes, not framework-prefixed ones. ++ # SDL's configure must find these same headers before it enables EGL. ++ c.env("CFLAGS", "{{ CFLAGS }} -I {{install}}/MetalANGLE.framework/Headers -F {{install}} -framework MetalANGLE -DMETALANGLE") ++ c.env("CXXFLAGS", "{{ CXXFLAGS }} -I {{install}}/MetalANGLE.framework/Headers -F {{install}} -framework MetalANGLE -DMETALANGLE") ++ c.env("CPPFLAGS", "{{ CPPFLAGS }} -I {{install}}/MetalANGLE.framework/Headers -DMETALANGLE") + + @task(kind="python", platforms="ios") + def install(c: Context): +diff --git a/tasks/sdl2.py b/tasks/sdl2.py +index 6756b47..3b701cd 100644 +--- a/tasks/sdl2.py ++++ b/tasks/sdl2.py +@@ -58,9 +58,11 @@ def build(c: Context): + --disable-libdecor + {% endif %} + +- {% if c.platform == "android" %} ++ {% if c.platform in ("android", "ios") %} + --enable-video-offscreen ++ {% endif %} + ++ {% if c.platform == "android" %} + --disable-oss + --disable-alsa + --disable-esd diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch new file mode 100644 index 000000000..a501b0fd9 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch @@ -0,0 +1,302 @@ +diff --git a/renpybuild/run.py b/renpybuild/run.py +--- a/renpybuild/run.py ++++ b/renpybuild/run.py +@@ -39,6 +39,22 @@ + + + def llvm(c, bin="", prefix="", suffix="-18", clang_args="", use_ld=True): ++ ++ if sys.platform == "darwin": ++ # Use the selected Xcode installation and its SDKs in place. ++ def tool(name): ++ return subprocess.check_output(["xcrun", "--find", name], text=True).strip() ++ ++ if not clang_args: ++ sdk = subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-path"], text=True).strip() ++ clang_args = f'-isysroot "{sdk}"' ++ clang_args = clang_args.replace("--sysroot ", "-isysroot ") ++ c.env("CC", f'ccache "{tool("clang")}" {clang_args} -std=gnu17') ++ c.env("CXX", f'ccache "{tool("clang++")}" {clang_args} -std=gnu++17 -stdlib=libc++') ++ c.env("CPP", f'ccache "{tool("clang")}" {clang_args} -E') ++ for variable, name in (("AR", "ar"), ("RANLIB", "ranlib"), ("STRIP", "strip"), ("NM", "nm")): ++ c.env(variable, tool(name)) ++ return + + if bin: + c.env("PATH", bin + ":{{ PATH }}") +@@ -106,6 +122,8 @@ + if c.platform == "android": + c.var("ndk_version", "android-ndk-r29") + ++ c.native_darwin = sys.platform == "darwin" ++ + cpuccount = os.cpu_count() + + if cpuccount is None: +@@ -116,6 +134,7 @@ + + c.var("make", "nice make -j " + str(cpuccount)) + c.var("configure", "./configure") ++ c.var("config_sub", os.environ.get("AETHERKIRI_RENPY_CONFIG_SUB", "/usr/share/misc/config.sub")) + c.var("cmake_configure", "cmake") + + c.var("sysroot", c.tmp / f"sysroot.{c.platform}-{c.arch}") +@@ -192,14 +211,22 @@ + elif (c.platform == "ios") and (c.arch == "sim-x86_64"): + c.env("IPHONEOS_DEPLOYMENT_TARGET", "13.0") + +- c.var("lipo", "llvm-lipo-15") ++ c.var("lipo", "xcrun lipo" if c.native_darwin else "llvm-lipo-15") + + + if c.kind == "host" or c.kind == "host-python" or c.kind == "cross": + + llvm(c) ++ if c.native_darwin: ++ for prefix in os.environ.get("AETHERKIRI_RENPY_HOST_DEPS", "").split(os.pathsep): ++ if not prefix: ++ continue ++ c.env("CPPFLAGS", "{{ CPPFLAGS }} -I" + prefix + "/include") ++ c.env("CFLAGS", "{{ CFLAGS }} -I" + prefix + "/include") ++ c.env("LDFLAGS", "{{ LDFLAGS }} -L" + prefix + "/lib") ++ c.env("PKG_CONFIG_PATH", prefix + "/lib/pkgconfig:{{ PKG_CONFIG_PATH }}") + c.env("LDFLAGS", "{{ LDFLAGS }} -L{{install}}/lib64") +- c.env("PKG_CONFIG_PATH", "{{ install }}/lib/pkgconfig") ++ c.env("PKG_CONFIG_PATH", "{{ install }}/lib/pkgconfig:{{ PKG_CONFIG_PATH }}") + + # c.var("cmake_system_name", "Linux") + # c.var("cmake_system_processor", "x86_64") +@@ -328,7 +355,7 @@ + ) + + c.env("CFLAGS", "{{ CFLAGS }} -DSDL_MAIN_HANDLED -miphoneos-version-min=13.0") +- c.env("LDFLAGS", "{{ LDFLAGS }} -miphoneos-version-min=13.0 -lmockrt") ++ c.env("LDFLAGS", "{{ LDFLAGS }} -miphoneos-version-min=13.0{{ '' if c.native_darwin else ' -lmockrt' }}") + + c.var("cmake_system_name", "Darwin") + c.var("cmake_system_processor", "aarch64") +@@ -342,7 +369,7 @@ + ) + + c.env("CFLAGS", "{{ CFLAGS }} -DSDL_MAIN_HANDLED -mios-simulator-version-min=13.0") +- c.env("LDFLAGS", "{{ LDFLAGS }} -mios-version-min=13.0 -lmockrt") ++ c.env("LDFLAGS", "{{ LDFLAGS }} -mios-version-min=13.0{{ '' if c.native_darwin else ' -lmockrt' }}") + + c.var("cmake_system_name", "Darwin") + c.var("cmake_system_processor", "aarch64") +@@ -356,7 +383,7 @@ + ) + + c.env("CFLAGS", "{{ CFLAGS }} -DSDL_MAIN_HANDLED -mios-simulator-version-min=13.0") +- c.env("LDFLAGS", "{{ LDFLAGS }} -mios-simulator-version-min=13.0 -lmockrt") ++ c.env("LDFLAGS", "{{ LDFLAGS }} -mios-simulator-version-min=13.0{{ '' if c.native_darwin else ' -lmockrt' }}") + + c.var("cmake_system_name", "Darwin") + c.var("cmake_system_processor", "x86_64") +diff --git a/tasks/toolchain.py b/tasks/toolchain.py +--- a/tasks/toolchain.py ++++ b/tasks/toolchain.py +@@ -2,6 +2,8 @@ + from renpybuild.task import task + + import os ++import sys ++import subprocess + import requests + + mingw_version = "20241217-ucrt-ubuntu-20.04-x86_64" +@@ -68,8 +70,12 @@ + c.clean("{{ cross }}") + c.chdir("{{ cross }}") + +- c.run("tar xaf {{ tars }}/iPhoneOS14.0.sdk.tar.gz") +- c.run("ln -s iPhoneOS14.0.sdk sdk") ++ if sys.platform == "darwin": ++ sdk = subprocess.check_output(["xcrun", "--sdk", "iphoneos", "--show-sdk-path"], text=True).strip() ++ c.path("sdk").symlink_to(sdk, target_is_directory=True) ++ else: ++ c.run("tar xaf {{ tars }}/iPhoneOS14.0.sdk.tar.gz") ++ c.run("ln -s iPhoneOS14.0.sdk sdk") + + + @task(kind="cross", platforms="ios", archs="sim-arm64,sim-x86_64") +@@ -78,8 +84,12 @@ + c.clean("{{ cross }}") + c.chdir("{{ cross }}") + +- c.run("tar xaf {{ tars }}/iPhoneSimulator14.0.sdk.tar.gz") +- c.run("ln -s iPhoneSimulator14.0.sdk sdk") ++ if sys.platform == "darwin": ++ sdk = subprocess.check_output(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"], text=True).strip() ++ c.path("sdk").symlink_to(sdk, target_is_directory=True) ++ else: ++ c.run("tar xaf {{ tars }}/iPhoneSimulator14.0.sdk.tar.gz") ++ c.run("ln -s iPhoneSimulator14.0.sdk sdk") + + + @task(platforms="ios") +diff --git a/tasks/hostpython3.py b/tasks/hostpython3.py +--- a/tasks/hostpython3.py ++++ b/tasks/hostpython3.py +@@ -1,5 +1,6 @@ + from renpybuild.context import Context + from renpybuild.task import task ++import shutil + + version = "3.12.8" + +@@ -23,9 +24,15 @@ + + c.run("""{{ make }} install""") + +- c.rmtree("{{ host }}/lib/python3.12/config-3.12-x86_64-linux-gnu/Tools/") +- c.run("install -d {{ host }}/lib/python3.12/config-3.12-x86_64-linux-gnu/Tools/") +- c.run("cp -a Tools/scripts {{ host }}/lib/python3.12/config-3.12-x86_64-linux-gnu/Tools/scripts") ++ configurations = list(c.path("{{ host }}/lib/python3.12").glob("config-3.12-*/")) ++ if not configurations: ++ raise RuntimeError("host Python did not install its configuration directory") ++ for configuration in configurations: ++ destination = configuration / "Tools" / "scripts" ++ if destination.exists(): ++ shutil.rmtree(destination) ++ destination.parent.mkdir(parents=True, exist_ok=True) ++ shutil.copytree(c.path("Tools/scripts"), destination) + + + +diff --git a/tasks/python3.py b/tasks/python3.py +--- a/tasks/python3.py ++++ b/tasks/python3.py +@@ -106,12 +106,11 @@ + + c.copy("{{ host }}/bin/python3", "{{ install }}/bin/hostpython3") + +- for i in [ "_sysconfigdata__linux_x86_64-linux-gnu.py" ]: +- c.var("i", i) +- +- c.copy( +- "{{ host }}/lib/{{pythonver}}/{{ i }}", +- "{{ install }}/lib/{{pythonver}}/{{ i }}") ++ configurations = list(c.path("{{ host }}/lib/{{ pythonver }}").glob("_sysconfigdata_*.py")) ++ if not configurations: ++ raise RuntimeError("host Python sysconfig data is missing") ++ for configuration in configurations: ++ c.copy(str(configuration), "{{ install }}/lib/{{ pythonver }}/" + configuration.name) + + + @task(kind="python", pythons="3", platforms="linux,mac") +diff --git a/tasks/librenpy.py b/tasks/librenpy.py +--- a/tasks/librenpy.py ++++ b/tasks/librenpy.py +@@ -12,7 +12,10 @@ + def gen_static3(c: Context): + + c.chdir("{{ renpy }}") +- c.env("RENPY_DEPS_INSTALL", "/usr::/usr/lib/x86_64-linux-gnu/") ++ if c.native_darwin: ++ c.env("RENPY_DEPS_INSTALL", "{{ install }}") ++ else: ++ c.env("RENPY_DEPS_INSTALL", "/usr::/usr/lib/x86_64-linux-gnu/") + c.env("RENPY_STATIC", "1") + c.env("RENPY_REGENERATE_CYTHON", "1") + c.run("{{ hostpython }} setup.py generate") +diff --git a/tasks/renios.py b/tasks/renios.py +--- a/tasks/renios.py ++++ b/tasks/renios.py +@@ -24,7 +24,8 @@ + for d in paths: + fn = d / name + +- p = subprocess.run([ "llvm-otool-15", "-l", f"{fn}"], capture_output=True) ++ command = ["xcrun", "otool"] if sys.platform == "darwin" else ["llvm-otool-15"] ++ p = subprocess.run(command + ["-l", str(fn)], capture_output=True, check=True) + + obj = None + +@@ -73,10 +74,10 @@ + + # debug. + +- paths = [ +- c.path("{{ tmp }}/install.ios-sim-x86_64/lib"), +- c.path("{{ tmp }}/install.ios-sim-arm64/lib"), +- ] ++ # The native arm64 cloud build creates device and arm64 simulator slices. ++ paths = [c.path("{{ tmp }}/install.ios-sim-arm64/lib")] ++ if sys.platform != "darwin": ++ paths.insert(0, c.path("{{ tmp }}/install.ios-sim-x86_64/lib")) + + c.run("install -d {{ renios }}/prototype/prebuilt/debug") + +diff --git a/source/Python-3.12.8-Setup.stdlib b/source/Python-3.12.8-Setup.stdlib +--- a/source/Python-3.12.8-Setup.stdlib ++++ b/source/Python-3.12.8-Setup.stdlib +@@ -100,7 +100,7 @@ + {% endif %} + + # Mac, only. +-{% if (c.kind != "host") and (c.platform == "mac") %} ++{% if ((c.kind != "host") and (c.platform == "mac")) or ((c.kind == "host") and c.native_darwin) %} + _scproxy -DPy_BUILD_CORE_BUILTIN _scproxy.c -framework SystemConfiguration -framework CoreFoundation + {% endif %} + +diff --git a/tools/cmake_build_variables.cmake b/tools/cmake_build_variables.cmake +--- a/tools/cmake_build_variables.cmake ++++ b/tools/cmake_build_variables.cmake +@@ -54,7 +54,7 @@ + # https://cmake.org/cmake/help/latest/variable/CMAKE_LINKER_TYPE.html and + # https://cmake.org/cmake/help/latest/variable/CMAKE_LANG_USING_LINKER_TYPE.html, + # but Ubuntu's current version of camke is too low +-if(CMAKE_VERSION VERSION_GREATER "3.29") ++if(CMAKE_VERSION VERSION_GREATER "3.29" AND NOT CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") + set_env_without_string(CC "-fuse-ld=lld -Wno-unused-command-line-argument ") + set_env_without_string(CXX "-fuse-ld=lld -Wno-unused-command-line-argument ") + set_env_without_string(CPP "-fuse-ld=lld -Wno-unused-command-line-argument ") +diff --git a/tasks/fribidi.py b/tasks/fribidi.py +--- a/tasks/fribidi.py ++++ b/tasks/fribidi.py +@@ -17,7 +17,7 @@ + c.var("version", version) + c.chdir("fribidi-{{version}}") + +- c.run("""cp /usr/share/misc/config.sub config.sub""") ++ c.run("""cp {{ config_sub }} config.sub""") + c.run("""{{configure}} {{ cross_config }} --disable-shared --prefix="{{ install }}" """) + c.run("""{{ make }}""") + c.run("""make install """) +diff --git a/tasks/harfbuzz.py b/tasks/harfbuzz.py +--- a/tasks/harfbuzz.py ++++ b/tasks/harfbuzz.py +@@ -23,7 +23,7 @@ + c.var("version", version) + c.chdir("harfbuzz-{{version}}") + +- c.run("""cp /usr/share/misc/config.sub .""") ++ c.run("""cp {{ config_sub }} .""") + + c.run("""{{configure}} {{ cross_config }} + --disable-shared +diff --git a/tasks/sdl2_image.py b/tasks/sdl2_image.py +--- a/tasks/sdl2_image.py ++++ b/tasks/sdl2_image.py +@@ -23,7 +23,7 @@ + if c.platform == "windows": + c.env("ac_cv_lib_jpeg_jpeg_CreateDecompress", "yes") + +- c.run("""cp /usr/share/misc/config.sub config.sub""") ++ c.run("""cp {{ config_sub }} config.sub""") + + # c.run("""./autogen.sh""") + c.run("autoreconf -f") +diff --git a/tasks/zsync.py b/tasks/zsync.py +--- a/tasks/zsync.py ++++ b/tasks/zsync.py +@@ -11,7 +11,7 @@ + c.var("version", version) + c.run("tar xjf {{source}}/zsync-{{version}}.tar.bz2") + +- c.run("""cp /usr/share/misc/config.sub zsync-{{version}}/autotools""") ++ c.run("""cp {{ config_sub }} zsync-{{version}}/autotools""") + + @task(kind="python", platforms="linux") + def build_linux(c: Context): diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index 602c80434..3bb1fb055 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -14,6 +14,20 @@ need the Cubism SDK and its runtime library; supplying the header preserves the upstream compilation path. No native build tasks are marked complete by this patch. +`0007-darwin-ios-build.patch` builds the complete iOS dependency chain on a +macOS cloud runner using the selected Xcode SDKs in place. The source runner +selects device arm64 and simulator arm64, and packages those slices under the +existing `release` and `debug` directories. Install Homebrew `autoconf`, +`autoconf-archive`, `automake`, `libtool`, `pkg-config`, `ccache`, `cmake`, +`ninja`, `uv`, `python@3.12`, `openssl@3`, `xz`, `bzip2`, `libffi`, `gnu-tar` +and `coreutils`. Put the GNU tar and coreutils `libexec/gnubin` directories on +PATH, select Xcode, then run the source runner's `prepare --fetch` and `build` +modes with `--platform ios`. The runner derives host dependency prefixes and +`config.sub` from the installed Homebrew packages. It requires actual Xcode +tools, all three local SDKs and 64 GiB of free disk; it does not substitute +prerequisite fixtures. The port still needs a successful native macOS build +and simulator gameplay before it establishes iOS runtime support. + `renpy_mobile_lifecycle.c` owns the copied host callback table and the borrowed frame byte storage. It invokes the full cooperative Python entrypoints, preserves pointer/key/text actions, publishes renderer screenshots and diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c b/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c index c759af082..5b9f8df7b 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c @@ -6,12 +6,13 @@ #include #include #include "renpy_mobile_launcher.h" -#if defined(__ANDROID__) -#include -#endif #if defined(__APPLE__) #include #endif +#if defined(__ANDROID__) || (defined(__APPLE__) && TARGET_OS_IPHONE && defined(METALANGLE)) +#define AETHER_MOBILE_EGL 1 +#include +#endif static renpy_mobile_host_t aether_host; static PyObject *aether_main_module; @@ -20,7 +21,7 @@ static unsigned long aether_owner_thread; static int aether_started; static int aether_paused; static void *aether_window; -#if defined(__ANDROID__) +#if defined(AETHER_MOBILE_EGL) static EGLDisplay aether_display = EGL_NO_DISPLAY; static EGLContext aether_context = EGL_NO_CONTEXT; static EGLSurface aether_draw_surface = EGL_NO_SURFACE; @@ -70,7 +71,7 @@ static int end_graphics(const graphics_scope *scope) { (void)scope; return RENPY int renpy_mobile_bind_window(void *window) { if (PyThread_get_thread_ident() != aether_owner_thread) return RENPY_MOBILE_INVALID_ARGUMENT; aether_window = window; -#if defined(__ANDROID__) +#if defined(AETHER_MOBILE_EGL) if (!window) { aether_display = EGL_NO_DISPLAY; aether_context = EGL_NO_CONTEXT; diff --git a/scripts/build_android.sh b/scripts/build_android.sh index b32bb694b..72272b0d4 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -22,6 +22,20 @@ if [[ "$BUILD_TYPE_LOWER" != "debug" && "$BUILD_TYPE_LOWER" != "release" ]]; the exit 1 fi +case "$ABIS" in + arm64-v8a) ;; + x86_64) + if [[ "$BUILD_TYPE_LOWER" != "debug" ]]; then + echo "Error: x86_64 is the dedicated cloud emulator debug profile. Use debug --abi=x86_64." >&2 + exit 1 + fi + ;; + *) + echo "Error: Select one Android ABI: arm64-v8a (default) or x86_64 (debug cloud emulator)." >&2 + exit 1 + ;; +esac + ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}" GODOT_BIN="${GODOT_BIN:-/Applications/Godot.app/Contents/MacOS/Godot}" GODOT_TEMPLATE_DIR="${GODOT_TEMPLATE_DIR:-$HOME/Library/Application Support/Godot/export_templates/4.7.2.stable}" @@ -261,6 +275,7 @@ stage_renpy_android_support() { --mobile-root "$mobile_root" --godot-build "$GODOT_APP_DIR/android/build" --private-assets "$RENPY_PRIVATE_ROOT" + --abis "$ABIS" ) echo "==> Staging rebuilt Ren'Py lifecycle runtime into the Godot export" bash "$PROJECT_ROOT/tools/stage_renpy_android_support.sh" "${stage_args[@]}" @@ -360,6 +375,8 @@ build_abi() { local vcpkg_triplet_dir local libomp_path local android_strip_path + local vcpkg_target_triplet + local openmp_arch local cmake_config_args=( -D "CMAKE_MAKE_PROGRAM=$CMAKE_MAKE_PROGRAM" -D "AETHERKIRI_ENABLE_INTERNAL=${AETHERKIRI_ENABLE_INTERNAL:-ON}" @@ -374,28 +391,37 @@ build_abi() { arm64-v8a) cmake_config_preset="Android arm64 ${BUILD_TYPE_CAP} Config" cmake_build_preset="Android arm64 ${BUILD_TYPE_CAP} Build" + vcpkg_target_triplet="arm64-android" + openmp_arch="aarch64" ensure_android_rust "aarch64-linux-android" ;; + x86_64) + cmake_config_preset="Android x64 Debug Config" + cmake_build_preset="Android x64 Debug Build" + vcpkg_target_triplet="x64-android" + openmp_arch="x86_64" + ensure_android_rust "x86_64-linux-android" + ;; *) - echo "Error: Android ABI '$abi' is not wired for the Godot migration yet. Use arm64-v8a." >&2 + echo "Error: Unsupported Android ABI '$abi'." >&2 exit 1 ;; esac cmake_build_dir="$PROJECT_ROOT/out/android/$abi/$BUILD_TYPE_LOWER" godot_bin_dir="$GODOT_APP_DIR/bin/android/$abi/$BUILD_TYPE_LOWER" - vcpkg_triplet_dir="$cmake_build_dir/vcpkg_installed/arm64-android" + vcpkg_triplet_dir="$cmake_build_dir/vcpkg_installed/$vcpkg_target_triplet" echo "==> Building Android native libraries ($abi, $BUILD_TYPE_LOWER)" if [[ "${SKIP_ANDROID_VCPKG_INSTALL:-}" == "1" ]]; then - if [[ ! -d "$VCPKG_ROOT/installed/arm64-android" ]]; then - echo "Error: SKIP_ANDROID_VCPKG_INSTALL=1 but prebuilt vcpkg triplet is missing: $VCPKG_ROOT/installed/arm64-android" >&2 + if [[ ! -d "$VCPKG_ROOT/installed/$vcpkg_target_triplet" ]]; then + echo "Error: SKIP_ANDROID_VCPKG_INSTALL=1 but prebuilt vcpkg triplet is missing: $VCPKG_ROOT/installed/$vcpkg_target_triplet" >&2 exit 1 fi mkdir -p "$cmake_build_dir" rm -rf "$cmake_build_dir/vcpkg_installed" ln -s "$VCPKG_ROOT/installed" "$cmake_build_dir/vcpkg_installed" - ensure_android_godot_cpp_package_config "$cmake_build_dir/vcpkg_installed/arm64-android" + ensure_android_godot_cpp_package_config "$vcpkg_triplet_dir" cmake_config_args+=( -D "VCPKG_MANIFEST_INSTALL=OFF" -D "VCPKG_INSTALLED_DIR=$cmake_build_dir/vcpkg_installed" @@ -409,10 +435,7 @@ build_abi() { copy_android_so "$cmake_build_dir/abi/libengine_api.so" "$godot_bin_dir/libengine_api.so" copy_android_so "$cmake_build_dir/bridge/godot_extension/libaether_kiri_godot.so" "$godot_bin_dir/libaether_kiri_godot.so" copy_android_so "$vcpkg_triplet_dir/lib/libSDL2.so" "$godot_bin_dir/libSDL2.so" - libomp_path="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/lib/clang/19/lib/linux/aarch64/libomp.so" - if [[ ! -f "$libomp_path" ]]; then - libomp_path="$(find "$ANDROID_NDK_HOME/toolchains/llvm/prebuilt" -path '*/lib/linux/aarch64/libomp.so' -print -quit)" - fi + libomp_path="$(find "$ANDROID_NDK_HOME/toolchains/llvm/prebuilt" -path "*/lib/linux/$openmp_arch/libomp.so" -print -quit)" if [[ -z "$libomp_path" || ! -f "$libomp_path" ]]; then echo "Error: Android OpenMP runtime libomp.so not found under $ANDROID_NDK_HOME." >&2 exit 1 @@ -450,9 +473,15 @@ elif [[ ! -f "$GODOT_TEMPLATE_DIR/android_debug.apk" || ! -f "$GODOT_TEMPLATE_DI exit 1 else echo "==> Exporting Godot Android APK" - mkdir -p "$PROJECT_ROOT/out/godot/android/$BUILD_TYPE_LOWER" - export_path="$PROJECT_ROOT/out/godot/android/$BUILD_TYPE_LOWER/Aether-$BUILD_TYPE_LOWER.apk" + export_dir="$PROJECT_ROOT/out/godot/android/$BUILD_TYPE_LOWER" + export_path="$export_dir/Aether-$BUILD_TYPE_LOWER.apk" export_preset="Android ${BUILD_TYPE_CAP}" + if [[ "$ABIS" == "x86_64" ]]; then + export_dir="$PROJECT_ROOT/out/godot/android/x86_64/debug" + export_path="$export_dir/Aether-emulator-debug.apk" + export_preset="Android Emulator Debug" + fi + mkdir -p "$export_dir" export_mode="--export-debug" if [[ "$BUILD_TYPE_LOWER" == "release" ]]; then export_mode="--export-release" @@ -463,7 +492,7 @@ else "$(lipo -archs "$macos_host_extension" 2>/dev/null || true)" == "x86_64" ]]; then # This private native host extension is currently x86_64-only. Match # the editor process to the staged host extension while - # it imports the project; the exported Android libraries remain arm64. + # it imports the project; the exported Android ABI is selected above. godot_export_command=(arch -x86_64 "$GODOT_BIN") fi "${godot_export_command[@]}" --headless --path "$GODOT_APP_DIR" \ @@ -472,9 +501,9 @@ else case "${AETHERKIRI_ENABLE_RENPY:-OFF}" in ON|TRUE|YES|1|on|true|yes) python3 "$PROJECT_ROOT/tools/validate_renpy_mobile_payload.py" \ - --platform android --apk "$export_path" + --platform android --abi "$ABIS" --apk "$export_path" ;; esac fi -echo "Android build output: $PROJECT_ROOT/out/godot/android/$BUILD_TYPE_LOWER" +echo "Android build output: $export_path" diff --git a/tools/renpy_ios_egl_probe/egl_probe.c b/tools/renpy_ios_egl_probe/egl_probe.c new file mode 100644 index 000000000..e87247c84 --- /dev/null +++ b/tools/renpy_ios_egl_probe/egl_probe.c @@ -0,0 +1,144 @@ +#include "egl_probe.h" +#include +#include +#include +#include +#include +#include +#include + +static void copy_string(char *target, size_t size, const char *source) { + snprintf(target, size, "%s", source ? source : ""); +} + +static int expect_pixel(const unsigned char *pixels, int x, int y, + int r, int g, int b, int a) { + const unsigned char *pixel = pixels + (y * AETHER_EGL_PROBE_SIZE + x) * 4; + const int expected[4] = {r, g, b, a}; + for (int c = 0; c < 4; ++c) { + const int delta = (int)pixel[c] - expected[c]; + if (delta < -1 || delta > 1) return 0; + } + return 1; +} + +int aether_run_egl_probe(aether_egl_probe_result *out) { + memset(out, 0, sizeof(*out)); + EGLDisplay display = EGL_NO_DISPLAY; + EGLSurface host_surface = EGL_NO_SURFACE, game_surface = EGL_NO_SURFACE; + EGLContext host_context = EGL_NO_CONTEXT, game_context = EGL_NO_CONTEXT; + const EGLDisplay previous_display = eglGetCurrentDisplay(); + const EGLContext previous_context = eglGetCurrentContext(); + const EGLSurface previous_draw = eglGetCurrentSurface(EGL_DRAW); + const EGLSurface previous_read = eglGetCurrentSurface(EGL_READ); + const EGLenum previous_api = eglQueryAPI(); +#define CHECK(condition, message) do { if (!(condition)) { \ + copy_string(out->error, sizeof(out->error), message); \ + out->egl_error = eglGetError(); out->gl_error = glGetError(); goto cleanup; \ +} } while (0) + PFNEGLGETPLATFORMDISPLAYEXTPROC get_platform_display = + (PFNEGLGETPLATFORMDISPLAYEXTPROC)eglGetProcAddress("eglGetPlatformDisplayEXT"); + CHECK(get_platform_display, "MetalANGLE has no eglGetPlatformDisplayEXT"); + const EGLint metal_attributes[] = { + EGL_PLATFORM_ANGLE_TYPE_ANGLE, EGL_PLATFORM_ANGLE_TYPE_METAL_ANGLE, EGL_NONE + }; + display = get_platform_display(EGL_PLATFORM_ANGLE_ANGLE, + (void *)(uintptr_t)EGL_DEFAULT_DISPLAY, metal_attributes); + CHECK(display != EGL_NO_DISPLAY, "could not create ANGLE Metal EGL display"); + EGLint egl_major = 0, egl_minor = 0; + CHECK(eglInitialize(display, &egl_major, &egl_minor), "eglInitialize failed (Metal GPU may be unavailable)"); + copy_string(out->egl_vendor, sizeof(out->egl_vendor), eglQueryString(display, EGL_VENDOR)); + copy_string(out->egl_version, sizeof(out->egl_version), eglQueryString(display, EGL_VERSION)); + CHECK(eglBindAPI(EGL_OPENGL_ES_API), "eglBindAPI GLES failed"); + const EGLint config_attributes[] = { + EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, + EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, + EGL_DEPTH_SIZE, 16, EGL_NONE + }; + EGLConfig config = NULL; + EGLint config_count = 0; + CHECK(eglChooseConfig(display, config_attributes, &config, 1, &config_count) && config_count == 1, + "no RGBA8 GLES pbuffer EGL configuration"); + const EGLint surface_attributes[] = { + EGL_WIDTH, AETHER_EGL_PROBE_SIZE, EGL_HEIGHT, AETHER_EGL_PROBE_SIZE, EGL_NONE + }; + host_surface = eglCreatePbufferSurface(display, config, surface_attributes); + game_surface = eglCreatePbufferSurface(display, config, surface_attributes); + CHECK(host_surface != EGL_NO_SURFACE && game_surface != EGL_NO_SURFACE, "eglCreatePbufferSurface failed"); + out->pbuffer_created = 1; + const EGLint context3[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE}; + const EGLint context2[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE}; + game_context = eglCreateContext(display, config, EGL_NO_CONTEXT, context3); + out->client_version = 3; + if (game_context == EGL_NO_CONTEXT) { + (void)eglGetError(); + game_context = eglCreateContext(display, config, EGL_NO_CONTEXT, context2); + out->client_version = 2; + } + host_context = eglCreateContext(display, config, EGL_NO_CONTEXT, + out->client_version == 3 ? context3 : context2); + CHECK(game_context != EGL_NO_CONTEXT && host_context != EGL_NO_CONTEXT, "eglCreateContext failed"); + CHECK(eglMakeCurrent(display, host_surface, host_surface, host_context), "could not bind host pbuffer context"); + glClearColor(0, 1, 1, 1); + glClear(GL_COLOR_BUFFER_BIT); + CHECK(eglMakeCurrent(display, game_surface, game_surface, game_context), "could not bind game pbuffer context"); + copy_string(out->gl_vendor, sizeof(out->gl_vendor), (const char *)glGetString(GL_VENDOR)); + copy_string(out->gl_renderer, sizeof(out->gl_renderer), (const char *)glGetString(GL_RENDERER)); + copy_string(out->gl_version, sizeof(out->gl_version), (const char *)glGetString(GL_VERSION)); + glViewport(0, 0, AETHER_EGL_PROBE_SIZE, AETHER_EGL_PROBE_SIZE); + glDisable(GL_DITHER); + glEnable(GL_SCISSOR_TEST); + const float colors[4][4] = {{1, 0, 0, 1}, {0, 1, 0, 1}, {0, 0, 1, 1}, {1, 1, 1, 1}}; + for (int quadrant = 0; quadrant < 4; ++quadrant) { + glScissor((quadrant % 2) * 8, (quadrant / 2) * 8, 8, 8); + glClearColor(colors[quadrant][0], colors[quadrant][1], colors[quadrant][2], colors[quadrant][3]); + glClear(GL_COLOR_BUFFER_BIT); + } + glDisable(GL_SCISSOR_TEST); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glFinish(); + glReadPixels(0, 0, AETHER_EGL_PROBE_SIZE, AETHER_EGL_PROBE_SIZE, GL_RGBA, GL_UNSIGNED_BYTE, out->pixels); + CHECK(glGetError() == GL_NO_ERROR, "GLES clear/readback produced an error"); + for (int y = 0; y < 16; ++y) for (int x = 0; x < 16; ++x) { + int quadrant = (y / 8) * 2 + x / 8; + CHECK(expect_pixel(out->pixels, x, y, (int)colors[quadrant][0] * 255, + (int)colors[quadrant][1] * 255, (int)colors[quadrant][2] * 255, 255), + "actual pbuffer pixels did not match the four GLES clear colors"); + } + out->readback_verified = 1; + CHECK(eglSwapBuffers(display, game_surface), "pbuffer eglSwapBuffers failed"); + CHECK(eglMakeCurrent(display, host_surface, host_surface, host_context), "could not restore host context"); + CHECK(eglGetCurrentContext() == host_context && eglGetCurrentSurface(EGL_DRAW) == host_surface && + eglGetCurrentSurface(EGL_READ) == host_surface, "host EGL context/surface restoration mismatch"); + unsigned char check[4] = {0}; + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, check); + CHECK(check[0] == 0 && check[1] == 255 && check[2] == 255 && check[3] == 255, + "game rendering modified the independent host pbuffer"); + out->host_context_restored = 1; + CHECK(eglMakeCurrent(display, game_surface, game_surface, game_context), "could not resume game context"); + glClearColor(1, 1, 0, 1); + glClear(GL_COLOR_BUFFER_BIT); + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, check); + CHECK(glGetError() == GL_NO_ERROR && check[0] == 255 && check[1] == 255 && check[2] == 0 && check[3] == 255, + "resumed game pbuffer did not produce fresh yellow pixels"); + out->resume_verified = 1; + out->passed = 1; +cleanup: + if (display != EGL_NO_DISPLAY) { + eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + if (host_context != EGL_NO_CONTEXT) eglDestroyContext(display, host_context); + if (game_context != EGL_NO_CONTEXT) eglDestroyContext(display, game_context); + if (host_surface != EGL_NO_SURFACE) eglDestroySurface(display, host_surface); + if (game_surface != EGL_NO_SURFACE) eglDestroySurface(display, game_surface); + /* Never terminate a display that could also be owned by a host. */ + } + eglBindAPI(previous_api); + if (previous_display != EGL_NO_DISPLAY && + !eglMakeCurrent(previous_display, previous_draw, previous_read, previous_context)) { + out->passed = 0; + copy_string(out->error, sizeof(out->error), "could not restore pre-probe EGL context"); + out->egl_error = eglGetError(); + } + return out->passed ? 0 : -1; +#undef CHECK +} diff --git a/tools/renpy_ios_egl_probe/egl_probe.h b/tools/renpy_ios_egl_probe/egl_probe.h new file mode 100644 index 000000000..3d79b28c2 --- /dev/null +++ b/tools/renpy_ios_egl_probe/egl_probe.h @@ -0,0 +1,24 @@ +#pragma once +#include + +#define AETHER_EGL_PROBE_SIZE 16 +typedef struct aether_egl_probe_result { + int passed; + int pbuffer_created; + int readback_verified; + int host_context_restored; + int resume_verified; + int client_version; + unsigned int egl_error; + unsigned int gl_error; + char error[256]; + char egl_vendor[256]; + char egl_version[256]; + char gl_vendor[256]; + char gl_renderer[512]; + char gl_version[256]; + uint8_t pixels[AETHER_EGL_PROBE_SIZE * AETHER_EGL_PROBE_SIZE * 4]; +} aether_egl_probe_result; + +/* Executes real MetalANGLE EGL/GLES calls, without any UIKit/SDL window. */ +int aether_run_egl_probe(aether_egl_probe_result *result); diff --git a/tools/renpy_ios_egl_probe/main.m b/tools/renpy_ios_egl_probe/main.m new file mode 100644 index 000000000..fc2747e14 --- /dev/null +++ b/tools/renpy_ios_egl_probe/main.m @@ -0,0 +1,79 @@ +#import +#import +#include "egl_probe.h" + +@interface ProbeDelegate : UIResponder +@property(nonatomic, strong) UIWindow *window; +@property(nonatomic, strong) UILabel *statusLabel; +@property(nonatomic, strong) UIImageView *imageView; +@end + +@implementation ProbeDelegate +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)options { + (void)options; + self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds]; + UIViewController *controller = [UIViewController new]; + controller.view.backgroundColor = UIColor.blackColor; + self.statusLabel = [[UILabel alloc] initWithFrame:CGRectMake(24, 70, self.window.bounds.size.width - 48, 150)]; + self.statusLabel.textColor = UIColor.whiteColor; + self.statusLabel.numberOfLines = 0; + self.statusLabel.text = @"Running MetalANGLE EGL pbuffer probe…"; + self.imageView = [[UIImageView alloc] initWithFrame:CGRectMake(24, 240, 256, 256)]; + self.imageView.layer.magnificationFilter = kCAFilterNearest; + [controller.view addSubview:self.statusLabel]; + [controller.view addSubview:self.imageView]; + self.window.rootViewController = controller; + [self.window makeKeyAndVisible]; + dispatch_async(dispatch_get_main_queue(), ^{ + const NSUInteger windowCount = application.windows.count; + aether_egl_probe_result result; + aether_run_egl_probe(&result); + const BOOL noExtraWindow = application.windows.count == windowCount; + if (!noExtraWindow) result.passed = 0; + NSString *documents = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject; + NSMutableDictionary *report = [@{ + @"probe": @"MetalANGLE EGL pbuffer", @"gameplay_verified": @NO, + @"passed": @(result.passed), @"pbuffer_created": @(result.pbuffer_created), + @"readback_verified": @(result.readback_verified), + @"host_context_restored": @(result.host_context_restored), + @"resume_verified": @(result.resume_verified), @"no_extra_uiwindow": @(noExtraWindow), + @"gles_client_version": @(result.client_version), + @"egl_error": @(result.egl_error), @"gl_error": @(result.gl_error), + @"error": [NSString stringWithUTF8String:result.error] ?: @"invalid UTF-8 diagnostic", + @"egl_vendor": [NSString stringWithUTF8String:result.egl_vendor] ?: @"", + @"egl_version": [NSString stringWithUTF8String:result.egl_version] ?: @"", + @"gl_vendor": [NSString stringWithUTF8String:result.gl_vendor] ?: @"", + @"gl_renderer": [NSString stringWithUTF8String:result.gl_renderer] ?: @"", + @"gl_version": [NSString stringWithUTF8String:result.gl_version] ?: @"", + @"pixel_width": @16, @"pixel_height": @16, + @"pixels_origin": @"bottom-left", @"thread": @"UIKit main thread", + @"windows_before": @(windowCount), @"windows_after": @(application.windows.count) + } mutableCopy]; + NSData *pixels = [NSData dataWithBytes:result.pixels length:sizeof(result.pixels)]; + [pixels writeToFile:[documents stringByAppendingPathComponent:@"egl-probe.rgba"] atomically:YES]; + CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)pixels); + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + CGImageRef image = CGImageCreate(16, 16, 8, 32, 64, colorSpace, + kCGBitmapByteOrderDefault | kCGImageAlphaLast, provider, NULL, NO, kCGRenderingIntentDefault); + if (image) { + UIImage *shown = [UIImage imageWithCGImage:image]; + self.imageView.image = shown; + [UIImagePNGRepresentation(shown) writeToFile:[documents stringByAppendingPathComponent:@"egl-probe.png"] atomically:YES]; + CGImageRelease(image); + } + CGColorSpaceRelease(colorSpace); + CGDataProviderRelease(provider); + NSError *error = nil; + NSData *json = [NSJSONSerialization dataWithJSONObject:report options:NSJSONWritingPrettyPrinted error:&error]; + if (json) [json writeToFile:[documents stringByAppendingPathComponent:@"egl-probe.json"] atomically:YES]; + self.statusLabel.text = result.passed ? @"PASS: actual EGL pbuffer pixels read back.\nHost context preserved; fresh pixels after resume.\nRen’Py gameplay is not tested." : + [NSString stringWithFormat:@"FAIL: %@\nEGL=0x%x GL=0x%x", report[@"error"], result.egl_error, result.gl_error]; + NSLog(@"AETHER_EGL_PROBE %@", [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding] ?: error.description); + }); + return YES; +} +@end + +int main(int argc, char **argv) { + @autoreleasepool { return UIApplicationMain(argc, argv, nil, NSStringFromClass(ProbeDelegate.class)); } +} diff --git a/tools/run_renpy_ios_egl_probe.sh b/tools/run_renpy_ios_egl_probe.sh new file mode 100755 index 000000000..4c9a86f14 --- /dev/null +++ b/tools/run_renpy_ios_egl_probe.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Builds and runs a genuine MetalANGLE/GLES probe in an isolated iOS Simulator. +# Requires a cloud macOS runner with Xcode and a downloaded simulator runtime. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +renpy_build="" +output="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/aether-renpy-ios-egl-probe" +framework="" +while (($#)); do + case "$1" in + --renpy-build) renpy_build="$2"; shift 2 ;; + --framework) framework="$2"; shift 2 ;; + --output) output="$2"; shift 2 ;; + *) echo "Usage: $0 --renpy-build PATH [--framework PATH] [--output PATH]" >&2; exit 2 ;; + esac +done +[[ "$(uname -s)" == Darwin ]] || { echo 'This real iOS Simulator probe requires macOS/Xcode; no simulation fallback is provided.' >&2; exit 2; } +mkdir -p "$output/input" "$output/evidence" +if [[ -z "$framework" ]]; then + archive="$renpy_build/source/MetalANGLE.framework.ios.simulator.zip" + [[ -f "$archive" ]] || { echo "Missing pinned Ren'Py simulator framework: $archive" >&2; exit 1; } + unzip -q -o "$archive" -d "$output/input" + framework="$output/input/MetalANGLE.framework" + shasum -a 256 "$archive" > "$output/evidence/framework-archive.sha256" +fi +[[ -f "$framework/MetalANGLE" ]] || { echo "Invalid MetalANGLE framework: $framework" >&2; exit 1; } +arch="$(uname -m)" +xcrun lipo "$framework/MetalANGLE" -verify_arch "$arch" +xcrun lipo "$framework/MetalANGLE" -archs > "$output/evidence/framework-architectures.txt" +xcodebuild -version > "$output/evidence/xcode-version.txt" +xcrun --sdk iphonesimulator --show-sdk-path > "$output/evidence/sdk-path.txt" +app="$output/AetherRenPyEGLProbe.app" +mkdir -p "$app/Frameworks" +cp -R "$framework" "$app/Frameworks/" +embedded="$app/Frameworks/MetalANGLE.framework" +if [[ "$(xcrun lipo "$embedded/MetalANGLE" -archs)" == *" "* ]]; then + xcrun lipo "$embedded/MetalANGLE" -thin "$arch" -output "$output/MetalANGLE.thin" + mv "$output/MetalANGLE.thin" "$embedded/MetalANGLE" +fi +codesign --force --sign - "$embedded" +cat > "$app/Info.plist" <<'PLIST' + + + +CFBundleIdentifierorg.aetherkiri.renpy-egl-probe +CFBundleExecutableAetherRenPyEGLProbe +CFBundleNameAether RenPy EGL Probe +CFBundlePackageTypeAPPL +CFBundleSupportedPlatformsiPhoneSimulator +CFBundleVersion1 +CFBundleShortVersionString1.0 +MinimumOSVersion15.0 +UIDeviceFamily12 +UILaunchScreen +UISupportedInterfaceOrientationsUIInterfaceOrientationPortrait + +PLIST +sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)" +xcrun --sdk iphonesimulator clang -target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \ + -Wall -Wextra -Werror -I"$framework/Headers" \ + -c "$repo_root/tools/renpy_ios_egl_probe/egl_probe.c" -o "$output/egl_probe.o" +xcrun --sdk iphonesimulator clang -target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \ + -Wall -Wextra -Werror -Wno-deprecated-declarations -fobjc-arc \ + -I"$framework/Headers" -F"$(dirname "$framework")" \ + "$repo_root/tools/renpy_ios_egl_probe/main.m" "$output/egl_probe.o" \ + -framework MetalANGLE -framework UIKit -framework Foundation -framework QuartzCore \ + -Wl,-rpath,@executable_path/Frameworks -o "$app/AetherRenPyEGLProbe" +codesign --force --sign - "$app" +xcrun otool -L "$app/AetherRenPyEGLProbe" > "$output/evidence/app-linked-libraries.txt" +xcrun simctl list runtimes --json > "$output/evidence/simulator-runtimes.json" +xcrun simctl list devicetypes --json > "$output/evidence/simulator-device-types.json" +read -r runtime device_type < <(python3 - "$output/evidence" <<'PY' +import json, pathlib, sys +p = pathlib.Path(sys.argv[1]) +available = [r for r in json.loads((p/'simulator-runtimes.json').read_text())['runtimes'] + if r.get('isAvailable') and '.iOS-' in r['identifier']] +if not available: + raise SystemExit('No available iOS Simulator runtime; install one with Xcode on this cloud runner.') +runtime = max(available, key=lambda r: tuple(int(i) for i in r['version'].split('.'))) +devices = json.loads((p/'simulator-device-types.json').read_text())['devicetypes'] +for name in ('iPhone 13', 'iPhone 14', 'iPhone 15', 'iPhone 16'): + device = next((d for d in devices if d['name'] == name), None) + if device: + print(runtime['identifier'], device['identifier']) + break +else: + raise SystemExit('No supported recent iPhone Simulator device type was found.') +PY +) +udid="$(xcrun simctl create "Aether RenPy EGL probe ${GITHUB_RUN_ID:-$$}" "$device_type" "$runtime")" +cleanup() { + xcrun simctl shutdown "$udid" >/dev/null 2>&1 || true + xcrun simctl delete "$udid" >/dev/null 2>&1 || true +} +trap cleanup EXIT +xcrun simctl boot "$udid" +xcrun simctl bootstatus "$udid" -b +xcrun simctl install "$udid" "$app" +xcrun simctl launch --terminate-running-process "$udid" org.aetherkiri.renpy-egl-probe | tee "$output/evidence/simulator-launch.txt" +container="$(xcrun simctl get_app_container "$udid" org.aetherkiri.renpy-egl-probe data)" +for ((attempt=0; attempt<90; ++attempt)); do + if [[ -f "$container/Documents/egl-probe.json" ]]; then break; fi + sleep 1 +done +if [[ ! -f "$container/Documents/egl-probe.json" ]]; then + xcrun simctl spawn "$udid" log show --last 3m --style compact > "$output/evidence/simulator-system.log" 2>&1 || true + echo 'Probe did not produce a report; inspect launch/system logs for dyld or Metal failures.' >&2 + exit 1 +fi +cp "$container/Documents/egl-probe."* "$output/evidence/" +xcrun simctl io "$udid" screenshot "$output/evidence/simulator-screen.png" +python3 - "$output/evidence/egl-probe.json" <<'PY' +import json, pathlib, sys +report = json.loads(pathlib.Path(sys.argv[1]).read_text()) +print(json.dumps(report, indent=2)) +required = ('passed', 'pbuffer_created', 'readback_verified', 'host_context_restored', 'resume_verified', 'no_extra_uiwindow') +if not all(report.get(k) for k in required): + raise SystemExit('Actual iOS EGL/GLES probe failed; this is not gameplay acceptance.') +print('Actual iOS EGL/GLES pbuffer probe passed; Ren\'Py gameplay remains unverified.') +PY diff --git a/tools/run_renpy_mobile_source_build.sh b/tools/run_renpy_mobile_source_build.sh index 70939650e..5bb707c34 100755 --- a/tools/run_renpy_mobile_source_build.sh +++ b/tools/run_renpy_mobile_source_build.sh @@ -80,6 +80,21 @@ if [[ ! -d "$renpy_build" && "$fetch" != true ]]; then exit 1 fi clone_pin "$source_url" "$renpy_build" "$build_sha" +if [[ "$(uname -s)" == Darwin && "$mode" != prepare ]]; then + [[ "$platform" == ios ]] || { echo "native macOS builds currently support --platform ios only" >&2; exit 1; } + command -v brew >/dev/null || { echo "Homebrew is required for the native macOS source build" >&2; exit 1; } + host_deps=() + for dependency in openssl@3 xz bzip2 libffi; do + host_deps+=("$(brew --prefix "$dependency")") + done + AETHERKIRI_RENPY_HOST_DEPS="$(IFS=:; echo "${host_deps[*]}")" + export AETHERKIRI_RENPY_HOST_DEPS + export LIBTOOLIZE=glibtoolize + config_sub_candidates=("$(brew --prefix automake)"/share/automake-*/config.sub) + [[ -f "${config_sub_candidates[0]}" ]] || { echo "Homebrew automake config.sub is missing" >&2; exit 1; } + export AETHERKIRI_RENPY_CONFIG_SUB="${config_sub_candidates[0]}" +fi + if [[ "$mode" == check ]]; then bash "$launcher" --check-renpy-build --renpy-build "$renpy_build" --platform "$platform" echo "Ren'Py source preflight passed; no compilation requested" @@ -98,8 +113,8 @@ apply_once() { fi } apply_once "$renpy_build/renpy" "$patch_root/python/0001-cooperative-loop-skeleton.patch" -for patch in 0001-renpy-build-link.patch 0002-android-host-bootstrap.patch 0003-ios-host-bootstrap.patch 0004-android-offscreen-renderer.patch 0005-optional-cubism.patch; do - apply_once "$renpy_build" "$patch_root/native/$patch" +for patch in "$patch_root/native"/000[0-9]-*.patch; do + apply_once "$renpy_build" "$patch" done cp "$patch_root/native/renpy_mobile_lifecycle.c" "$renpy_build/runtime/renpy_mobile_lifecycle.c" cp "$repo_root/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h" "$renpy_build/runtime/renpy_mobile_launcher.h" @@ -129,7 +144,11 @@ if [[ "$platform" == android || "$platform" == all ]]; then (cd "$renpy_build"; ./build.sh --platform android --python 3 rebuild librenpy pythonlib renpython rapt sdl2) fi if [[ "$platform" == ios || "$platform" == all ]]; then - (cd "$renpy_build"; ./build.sh --platform ios --python 3 rebuild librenpy pythonlib renpython renios) + ios_arch_args=() + if [[ "$(uname -s)" == Darwin ]]; then + ios_arch_args=(--arch arm64,sim-arm64) + fi + (cd "$renpy_build"; ./build.sh --platform ios --python 3 "${ios_arch_args[@]}" rebuild librenpy pythonlib renpython renios) fi symbols=(renpy_mobile_bootstrap renpy_mobile_bind_window renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown @@ -139,6 +158,8 @@ check_exports() { [[ -f "$artifact" ]] || { echo "missing compiled artifact: $artifact" >&2; exit 1; } if [[ "$kind" == android ]]; then output="$(nm -D --defined-only "$artifact")" + elif [[ "$(uname -s)" == Darwin ]]; then + output="$(xcrun nm -gU "$artifact")" else output="$(llvm-nm-18 --defined-only --extern-only "$artifact")" fi diff --git a/tools/stage_renpy_android_support.sh b/tools/stage_renpy_android_support.sh index b3dabfc93..8b1fd4ce1 100644 --- a/tools/stage_renpy_android_support.sh +++ b/tools/stage_renpy_android_support.sh @@ -4,7 +4,7 @@ # RAPT is an SDLActivity application template. It must not be merged as the # exported app's manifest or launched as a second Activity: Godot owns the # process, window, and SDL lifecycle. This script therefore packages the -# official Java/resources as inspectable assets, stages only the arm64 native +# official Java/resources as inspectable assets, stages the selected native # payload in jniLibs, and installs the host-owned JNI bridge. Enabled app # builds require a lifecycle library and complete cooperative Python payload. # --allow-unsupported is an explicit archive inspection mode, never gameplay. @@ -23,6 +23,7 @@ with --private-assets. Options: --mobile-root PATH Root containing rapt/prototype/renpyandroid --godot-build PATH Extracted Godot Android build template directory + --abis LIST Comma-separated arm64-v8a,x86_64 (default: arm64-v8a) --private-assets PATH Unpacked runtime directory (main.py, renpy/, lib/) --allow-unsupported Inspect an official archive without a lifecycle fork; this mode is never used by enabled app builds @@ -33,6 +34,7 @@ USAGE mobile_root="" godot_build="" private_assets="" +abis="arm64-v8a" allow_unsupported=false repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -53,6 +55,11 @@ while (($#)); do private_assets="$2" shift 2 ;; + --abis) + (($# >= 2)) || { echo "--abis requires a value" >&2; exit 2; } + abis="$2" + shift 2 + ;; --allow-unsupported) allow_unsupported=true shift @@ -77,16 +84,22 @@ fi rapt_root="$mobile_root/rapt" rapt_main="$rapt_root/prototype/renpyandroid/src/main" -rapt_native="$rapt_main/jniLibs/arm64-v8a/librenpython.so" +IFS=',' read -r -a abi_list <<< "$abis" +[[ -n "$abis" && "$abis" != *, && "$abis" != ,* ]] || { + echo "--abis must contain arm64-v8a and/or x86_64" >&2 + exit 2 +} +for abi in "${abi_list[@]}"; do + case "$abi" in + arm64-v8a|x86_64) ;; + *) echo "Unsupported Ren'Py Android packaging ABI: $abi" >&2; exit 2 ;; + esac +done [[ -d "$rapt_main" ]] || { echo "RAPT Android module is missing: $rapt_main" >&2 exit 1 } -[[ -f "$rapt_native" ]] || { - echo "RAPT arm64 native library is missing: $rapt_native" >&2 - exit 1 -} [[ -d "$godot_build" ]] || { echo "Godot Android build template is missing: $godot_build" >&2 exit 1 @@ -102,25 +115,35 @@ is_elf_file() { [[ "$(dd if="$path" bs=4 count=1 2>/dev/null | LC_ALL=C od -An -tx1 | tr -d ' \\n')" == "7f454c46" ]] } -is_elf_file "$rapt_native" || { - echo "RAPT arm64 native library is not an ELF file: $rapt_native" >&2 - exit 1 -} - if [[ "$allow_unsupported" == false ]]; then if [[ -z "$private_assets" ]]; then private_assets="$rapt_root/runtime/private" fi python3 "$repo_root/tools/validate_renpy_mobile_payload.py" \ - --platform android --library "$rapt_native" --private-root "$private_assets" + --platform android --private-root "$private_assets" fi +for abi in "${abi_list[@]}"; do + rapt_native="$rapt_main/jniLibs/$abi/librenpython.so" + [[ -f "$rapt_native" ]] || { + echo "RAPT $abi native library is missing: $rapt_native" >&2 + exit 1 + } + is_elf_file "$rapt_native" || { + echo "RAPT $abi native library is not an ELF file: $rapt_native" >&2 + exit 1 + } + if [[ "$allow_unsupported" == false ]]; then + python3 "$repo_root/tools/validate_renpy_mobile_payload.py" \ + --platform android --abi "$abi" --library "$rapt_native" + fi +done main_src="$godot_build/src/main" asset_root="$main_src/assets/renpy_mobile" asset_rapt="$asset_root/rapt" asset_private="$asset_root/private" private_archive="$asset_rapt/private.mp3" -jni_root="$main_src/jniLibs/arm64-v8a" +jni_root="$main_src/jniLibs" java_root="$main_src/java/org/github/krkr2/aetherkiri" java_sdl_root="$main_src/java/org/libsdl/app" java_renpy_root="$main_src/java/org/renpy/android" @@ -223,10 +246,36 @@ rendering are wired to the existing Godot Activity. PRIVATE_EOF fi -for native_library in "$rapt_main/jniLibs/arm64-v8a/"*.so; do - [[ -f "$native_library" ]] || continue - cp -f "$native_library" "$jni_root/$(basename "$native_library")" - chmod 755 "$jni_root/$(basename "$native_library")" +# Clear only files registered by a previous Ren'Py staging run. This keeps +# other Godot template libraries intact when switching emulator/device ABIs. +previous_manifest="$asset_root/manifest.properties" +if [[ -f "$previous_manifest" ]]; then + previous_files="$(sed -n 's/^staged_native_files=//p' "$previous_manifest")" + if [[ -z "$previous_files" ]]; then + previous_files="$(sed -n 's/^native_library=lib\///p' "$previous_manifest")" + fi + IFS=',' read -r -a previous_file_list <<< "$previous_files" + for previous_file in "${previous_file_list[@]}"; do + [[ "$previous_file" != *..* ]] || continue + case "$previous_file" in + arm64-v8a/lib*.so|x86_64/lib*.so) + rm -f "$jni_root/$previous_file" + ;; + esac + done +fi +native_paths=() +staged_native_files=() +for abi in "${abi_list[@]}"; do + mkdir -p "$jni_root/$abi" + native_paths+=("lib/$abi/librenpython.so") + for native_library in "$rapt_main/jniLibs/$abi/"*.so; do + [[ -f "$native_library" ]] || continue + native_file="$abi/$(basename "$native_library")" + cp -f "$native_library" "$jni_root/$native_file" + chmod 755 "$jni_root/$native_file" + staged_native_files+=("$native_file") + done done # Record the exact staged paths and the archive metadata when available. This @@ -246,7 +295,9 @@ fi printf 'native_lifecycle_validated=%s\n' "$( [[ "$allow_unsupported" == false ]] && printf true || printf false )" printf 'activity_template=assets/renpy_mobile/rapt/java/org/renpy/android/PythonSDLActivity.java\n' printf 'gradle_template=assets/renpy_mobile/rapt/build.gradle\n' - printf 'native_library=lib/arm64-v8a/librenpython.so\n' + printf 'native_abis=%s\n' "$abis" + printf 'native_library=%s\n' "$(IFS=','; printf '%s' "${native_paths[*]}")" + printf 'staged_native_files=%s\n' "$(IFS=','; printf '%s' "${staged_native_files[*]}")" printf 'private_assets=assets/renpy_mobile/private\n' printf 'private_archive=%s\n' "$( [[ -f "$private_archive" ]] && printf 'assets/renpy_mobile/rapt/private.mp3' || true )" printf 'manifest_merged=false\n' @@ -256,5 +307,5 @@ fi echo "Ren'Py Android support staged into $godot_build" echo " official Java/resources: $asset_rapt" echo " private assets: $asset_private" -echo " arm64 native library: $jni_root/librenpython.so" +echo " native library ABIs: $abis" echo " gameplay: unverified (requires device execution)" diff --git a/tools/test_renpy_mobile_darwin_build.py b/tools/test_renpy_mobile_darwin_build.py new file mode 100644 index 000000000..cc4e7d9a0 --- /dev/null +++ b/tools/test_renpy_mobile_darwin_build.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Check real patched task configuration for Darwin iOS and Linux Android. + +Xcode paths are fixtures for configuration tests only. No preflight, compiler, +SDK contents or gameplay is simulated as successful. +""" +import ast +from pathlib import Path +import subprocess +import sys +import tarfile +import tempfile +from types import SimpleNamespace +from unittest.mock import patch + +REPO = Path(__file__).resolve().parents[1] +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" + + +def task_function(path, name, namespace, *, archs=None): + tree = ast.parse(path.read_text()) + selected = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == name and + (archs is None or any(isinstance(decorator, ast.Call) and + any(keyword.arg == "archs" and keyword.value.value == archs for keyword in decorator.keywords) + for decorator in node.decorator_list))) + selected.decorator_list = [] + exec(compile(ast.Module(body=[selected], type_ignores=[]), str(path), "exec"), namespace) + return namespace[name] + + +def main(): + checkout = Path(sys.argv[1]) + assert subprocess.check_output(["git", "-C", str(checkout), "rev-parse", "HEAD"], text=True).strip() == PIN + with tempfile.TemporaryDirectory(prefix="renpy-darwin-build-") as temporary: + root = Path(temporary) + archive = subprocess.Popen([ + "git", "-C", str(checkout), "archive", "HEAD", "renpybuild", "tasks", + "source/Python-3.12.8-Setup.stdlib", "tools/cmake_build_variables.cmake", + ], stdout=subprocess.PIPE) + assert archive.stdout is not None + with tarfile.open(fileobj=archive.stdout, mode="r|*") as source: + source.extractall(root, filter="data") + assert archive.wait() == 0 + build_patch = REPO / "bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch" + subprocess.run(["git", "apply", "--check", str(build_patch)], cwd=root, check=True) + subprocess.run(["git", "apply", str(build_patch)], cwd=root, check=True) + sys.path.insert(0, str(root)) + from renpybuild.context import Context + import renpybuild.run as run + + def xcrun(arguments, **_kwargs): + assert arguments[0] == "xcrun", arguments + if arguments[1] == "--find": + return "/fixture/Xcode/usr/bin/" + arguments[2] + assert arguments[1] == "--sdk" and arguments[3] == "--show-sdk-path", arguments + sdk = root / "local-sdk" / arguments[2] + sdk.mkdir(parents=True, exist_ok=True) + return str(sdk) + + with patch.object(run.sys, "platform", "darwin"), patch.object(run.subprocess, "check_output", xcrun), \ + patch.dict(run.os.environ, {"AETHERKIRI_RENPY_HOST_DEPS": "/fixture/openssl:/fixture/libffi"}), \ + patch.object(run.sysconfig, "get_config_var", lambda _name: "aarch64-apple-darwin24"): + for architecture, target in (("arm64", "arm64-apple-ios13.0"), ("sim-arm64", "arm64-apple-ios13.0-simulator")): + context = Context("ios", architecture, "3", root, SimpleNamespace()) + context.set_names("python", "build", "renpython") + assert target in context.environ["CC"] + assert "-isysroot" in context.environ["CC"] + assert "-fuse-ld=lld" not in context.environ["CC"] + assert "-stdlib=libc++" in context.environ["CXX"] + assert "-lmockrt" not in context.environ["LDFLAGS"] + assert "/fixture/openssl" not in context.environ["LDFLAGS"], "host libraries leaked into target linking" + assert context.variables["lipo"] == "xcrun lipo" + host_generation = Context("ios", architecture, "3", root, SimpleNamespace()) + host_generation.set_names("host-python", "gen_static3", "librenpy") + assert host_generation.install == root / "tmp" / ("install.ios-" + architecture) + assert str(root / "local-sdk/macosx") in host_generation.environ["CC"] + assert "/fixture/libffi/include" in host_generation.environ["CFLAGS"] + host = Context("host", "host", "3", root, SimpleNamespace()) + host.set_names("host", "build_host", "hostpython3") + assert "/fixture/openssl/lib" in host.environ["LDFLAGS"] + rendered = host.expand((root / "source/Python-3.12.8-Setup.stdlib").read_text()) + assert "_scproxy -DPy_BUILD_CORE_BUILTIN _scproxy.c -framework SystemConfiguration" in rendered + namespace = {"Context": Context, "sys": sys, "subprocess": run.subprocess} + toolchain = task_function(root / "tasks/toolchain.py", "build", namespace, + archs="sim-arm64,sim-x86_64") + cross = Context("ios", "sim-arm64", "3", root, SimpleNamespace()) + cross.set_names("cross", "build", "toolchain") + cross.run = lambda _command: None + toolchain(cross) + assert cross.path("{{ cross }}/sdk").is_symlink() + assert cross.path("{{ cross }}/sdk").resolve() == root / "local-sdk/iphonesimulator" + with patch.object(run.sys, "platform", "linux"): + context = Context("android", "arm64_v8a", "3", root, SimpleNamespace()) + context.set_names("python", "build", "renpython") + assert "aarch64-linux-android21-clang" in context.environ["CC"] + assert "linux-x86_64" in context.environ["CC"] + assert not context.native_darwin + print("Darwin iOS and Linux Android task configuration passed; no native build or gameplay performed") + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_native_fork.sh b/tools/test_renpy_mobile_native_fork.sh index 87feb887e..458f7f59a 100644 --- a/tools/test_renpy_mobile_native_fork.sh +++ b/tools/test_renpy_mobile_native_fork.sh @@ -19,12 +19,7 @@ for forbidden in \ exit 1 fi done -for patch in \ - "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch" \ - "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0002-android-host-bootstrap.patch" \ - "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch" \ - "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0004-android-offscreen-renderer.patch" \ - "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0005-optional-cubism.patch"; do +for patch in "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native"/000[0-9]-*.patch; do [[ -s "$patch" ]] || { echo "missing native build patch: $patch" >&2; exit 1; } if [[ "$patch" == *host-bootstrap.patch ]]; then grep -Fq 'renpy_mobile_bootstrap' "$patch" || { @@ -35,12 +30,7 @@ for patch in \ done if [[ -n "${RENPY_BUILD_TEST_ROOT:-}" && -d "${RENPY_BUILD_TEST_ROOT}/.git" ]]; then - for patch in \ - "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch" \ - "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0002-android-host-bootstrap.patch" \ - "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0003-ios-host-bootstrap.patch" \ - "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0004-android-offscreen-renderer.patch" \ - "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native/0005-optional-cubism.patch"; do + for patch in "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native"/000[0-9]-*.patch; do git -C "$RENPY_BUILD_TEST_ROOT" apply --check "$patch" 2>/dev/null || \ git -C "$RENPY_BUILD_TEST_ROOT" apply --reverse --check "$patch" done diff --git a/tools/test_renpy_mobile_source_build_plan.sh b/tools/test_renpy_mobile_source_build_plan.sh index 62cbba42b..4822a0f2f 100755 --- a/tools/test_renpy_mobile_source_build_plan.sh +++ b/tools/test_renpy_mobile_source_build_plan.sh @@ -40,7 +40,7 @@ from pathlib import Path import sys source = Path(sys.argv[1]).read_text() assert source.index('apply_once "$renpy_build/renpy"') < source.index('./build.sh --platform android') -assert source.index('0005-optional-cubism.patch') < source.index('./build.sh --platform android') +assert source.index('"$patch_root/native"/000[0-9]-*.patch') < source.index('./build.sh --platform android') assert '"$mode" == "check" || "$mode" == "auto"' not in source, 'auto must not mean check-only' for symbol in ('renpy_mobile_text_input_state', 'renpy_mobile_set_surface_size'): assert symbol in source diff --git a/tools/test_stage_renpy_android_support.sh b/tools/test_stage_renpy_android_support.sh index 3e5c81868..3ddace8f7 100644 --- a/tools/test_stage_renpy_android_support.sh +++ b/tools/test_stage_renpy_android_support.sh @@ -6,6 +6,60 @@ stage="$repo_root/tools/stage_renpy_android_support.sh" [[ -f "$stage" ]] || { echo "stager is missing: $stage" >&2; exit 1; } bash -n "$stage" +# This library is deliberately marked as a contract-only fixture. It checks +# ABI selection and staging ownership; it is never used as a gameplay proof. +abi_work="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-abi-stage.XXXXXX")" +trap 'rm -rf "$abi_work"' EXIT +abi_main="$abi_work/mobile/rapt/prototype/renpyandroid/src/main" +mkdir -p "$abi_main/jniLibs/x86_64" "$abi_main/java/org/renpy/android" \ + "$abi_work/android-build/src/main/jniLibs/x86_64" +cat > "$abi_work/fixture.c" <<'C' +const char aether_contract_fixture[] = "AETHERKIRI_RENPY_LIFECYCLE_STUB"; +int renpy_mobile_init(void) { return -3; } +C +"${CC:-cc}" -shared -fPIC "$abi_work/fixture.c" \ + -o "$abi_main/jniLibs/x86_64/librenpython.so" +cp "$abi_main/jniLibs/x86_64/librenpython.so" \ + "$abi_main/jniLibs/x86_64/liblegacy.so" +printf 'class PythonSDLActivity {}\n' \ + > "$abi_main/java/org/renpy/android/PythonSDLActivity.java" +printf 'unrelated host library\n' \ + > "$abi_work/android-build/src/main/jniLibs/x86_64/libhost.so" +bash "$stage" --allow-unsupported --abis x86_64 \ + --mobile-root "$abi_work/mobile" --godot-build "$abi_work/android-build" >/dev/null +abi_manifest="$abi_work/android-build/src/main/assets/renpy_mobile/manifest.properties" +grep -Fx 'native_abis=x86_64' "$abi_manifest" +grep -Fx 'native_library=lib/x86_64/librenpython.so' "$abi_manifest" +cmp "$abi_main/jniLibs/x86_64/librenpython.so" \ + "$abi_work/android-build/src/main/jniLibs/x86_64/librenpython.so" +[[ ! -e "$abi_work/android-build/src/main/jniLibs/arm64-v8a/librenpython.so" ]] + +# A repeated staging run updates its own Java helpers and removes obsolete +# Ren'Py native dependencies while preserving files owned by the host. +rm "$abi_main/jniLibs/x86_64/liblegacy.so" +bash "$stage" --allow-unsupported --abis x86_64 \ + --mobile-root "$abi_work/mobile" --godot-build "$abi_work/android-build" >/dev/null +[[ ! -e "$abi_work/android-build/src/main/jniLibs/x86_64/liblegacy.so" ]] +grep -Fx 'unrelated host library' \ + "$abi_work/android-build/src/main/jniLibs/x86_64/libhost.so" +if bash "$stage" --allow-unsupported --abis mips \ + --mobile-root "$abi_work/mobile" --godot-build "$abi_work/android-build" \ + > "$abi_work/stdout" 2> "$abi_work/stderr"; then + echo 'unsupported packaging ABI unexpectedly accepted' >&2 + exit 1 +fi +grep -Fq 'Unsupported Ren' "$abi_work/stderr" +if bash "$stage" --allow-unsupported \ + --mobile-root "$abi_work/mobile" --godot-build "$abi_work/android-build" \ + > "$abi_work/stdout" 2> "$abi_work/stderr"; then + echo 'default arm64 build silently used an x86_64 payload' >&2 + exit 1 +fi +grep -Fq 'RAPT arm64-v8a native library is missing' "$abi_work/stderr" +rm -rf "$abi_work" +trap - EXIT +echo "Ren'Py Android x86_64 staging/ownership checks passed (contract fixture, no mobile app executed)" + mobile_root="${RENPY_MOBILE_STAGE_TEST_ROOT:-/workspace/shared/renpy-mobile-staged}" if [[ ! -f "$mobile_root/rapt/prototype/renpyandroid/src/main/jniLibs/arm64-v8a/librenpython.so" ]]; then echo "Ren'Py mobile stage not present; metadata-only staging test skipped" @@ -82,7 +136,7 @@ grep -Fx 'java_host_shims=org/libsdl/app/SDLActivity.java,org/renpy/android/Pyth [[ ! -f "$main/AndroidManifest.xml" ]] # The official PythonSDLActivity remains assets-only; the compiled source is # the explicit host shim above. -grep -Fq 'Host-side signature shim' "$main/java/org/renpy/android/PythonSDLActivity.java" +grep -Fq 'AETHERKIRI_HOST_SHIM' "$main/java/org/renpy/android/PythonSDLActivity.java" grep -Fq 'PythonSDLActivity' "$main/assets/renpy_mobile/rapt/java/org/renpy/android/PythonSDLActivity.java" # A Godot template that already owns either SDL class must fail closed rather diff --git a/tools/validate_renpy_mobile_payload.py b/tools/validate_renpy_mobile_payload.py index 126385213..20f307028 100755 --- a/tools/validate_renpy_mobile_payload.py +++ b/tools/validate_renpy_mobile_payload.py @@ -25,6 +25,12 @@ "renpy_mobile_text_input_state", "renpy_mobile_set_surface_size", ) +ANDROID_ARCHITECTURES = { + "arm64": ("arm64-v8a", 183), + "arm64-v8a": ("arm64-v8a", 183), + "x86_64": ("x86_64", 62), +} + def fail(message): raise ValueError(message) @@ -81,11 +87,14 @@ def validate_library(library, platform, arch, sdk, executable=False, packaged=Fa if b"AETHERKIRI_RENPY_LIFECYCLE_STUB" in data: fail(f"contract-only stub cannot be packaged as a Ren'Py runtime: {library}") if platform == "android": + if arch not in ANDROID_ARCHITECTURES: + fail(f"unsupported Android runtime ABI: {arch}") + abi, expected_machine = ANDROID_ARCHITECTURES[arch] if len(data) < 20 or data[:4] != b"\x7fELF" or data[4:6] != b"\x02\x01": fail(f"Android Ren'Py payload must be a little-endian ELF64 shared library: {library}") elf_type, machine = struct.unpack_from(" Date: Thu, 8 Oct 2026 12:50:50 +0000 Subject: [PATCH 254/331] Execute patched SDL renderer and parallel iOS source build in cloud --- .github/workflows/renpy-ios-renderer.yml | 20 ++ .github/workflows/renpy-ios-runtime-build.yml | 10 + tools/renpy_ios_egl_probe/run_sdl_probe.sh | 194 ++++++++++++++++++ tools/renpy_ios_egl_probe/sdl_probe.c | 168 +++++++++++++++ tools/run_renpy_ios_egl_probe.sh | 2 +- 5 files changed, 393 insertions(+), 1 deletion(-) create mode 100755 tools/renpy_ios_egl_probe/run_sdl_probe.sh create mode 100644 tools/renpy_ios_egl_probe/sdl_probe.c diff --git a/.github/workflows/renpy-ios-renderer.yml b/.github/workflows/renpy-ios-renderer.yml index 75c8c577c..b231a2d24 100644 --- a/.github/workflows/renpy-ios-renderer.yml +++ b/.github/workflows/renpy-ios-renderer.yml @@ -49,3 +49,23 @@ jobs: ${{ runner.temp }}/renpy-ios-egl-probe.log if-no-files-found: error retention-days: 7 + - name: Install the actual SDL configure tools + run: brew install autoconf automake libtool + - name: Build and execute the patched SDL offscreen renderer + run: | + set -euo pipefail + export LIBTOOLIZE=glibtoolize + bash tools/renpy_ios_egl_probe/run_sdl_probe.sh \ + --renpy-build "$GITHUB_WORKSPACE/_renpy-probe-source" \ + --output "$GITHUB_WORKSPACE/out/renpy-ios-sdl-probe" \ + 2>&1 | tee "$RUNNER_TEMP/renpy-ios-sdl-probe.log" + - name: Preserve patched SDL execution evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-sdl-probe + path: | + out/renpy-ios-sdl-probe/evidence + ${{ runner.temp }}/renpy-ios-sdl-probe.log + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index b6219e56d..455d58082 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -1,12 +1,22 @@ name: Build Ren'Py iOS runtime on: + pull_request: + paths: + - '.github/workflows/renpy-ios-runtime-build.yml' + - 'tools/run_renpy_mobile_source_build.sh' + - 'tools/validate_renpy_mobile_payload.py' + - 'bridge/renpy_runtime/mobile_launcher/**' workflow_call: workflow_dispatch: permissions: contents: read +concurrency: + group: renpy-ios-source-${{ github.event.pull_request.number || github.ref }}-${{ github.workflow }} + cancel-in-progress: false + jobs: ios-runtime: name: Compile Ren'Py with this cloud runner's Xcode SDKs diff --git a/tools/renpy_ios_egl_probe/run_sdl_probe.sh b/tools/renpy_ios_egl_probe/run_sdl_probe.sh new file mode 100755 index 000000000..ab51ad442 --- /dev/null +++ b/tools/renpy_ios_egl_probe/run_sdl_probe.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# Builds patched upstream SDL2, then runs its real offscreen EGL/GLES path in iOS Simulator. +# Requires a cloud macOS runner with Xcode and a downloaded simulator runtime. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +renpy_build="" +output="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/aether-renpy-ios-sdl-probe" +framework="" +while (($#)); do + case "$1" in + --renpy-build) renpy_build="$2"; shift 2 ;; + --framework) framework="$2"; shift 2 ;; + --output) output="$2"; shift 2 ;; + *) echo "Usage: $0 --renpy-build PATH [--framework PATH] [--output PATH]" >&2; exit 2 ;; + esac +done +[[ "$(uname -s)" == Darwin ]] || { echo 'This real iOS Simulator probe requires macOS/Xcode; no simulation fallback is provided.' >&2; exit 2; } +mkdir -p "$output/input" "$output/evidence" +if [[ -z "$framework" ]]; then + archive="$renpy_build/source/MetalANGLE.framework.ios.simulator.zip" + [[ -f "$archive" ]] || { echo "Missing pinned Ren'Py simulator framework: $archive" >&2; exit 1; } + unzip -q -o "$archive" -d "$output/input" + framework="$output/input/MetalANGLE.framework" + shasum -a 256 "$archive" > "$output/evidence/framework-archive.sha256" +fi +[[ -f "$framework/MetalANGLE" ]] || { echo "Invalid MetalANGLE framework: $framework" >&2; exit 1; } +arch="$(uname -m)" +xcrun lipo "$framework/MetalANGLE" -verify_arch "$arch" +xcrun lipo "$framework/MetalANGLE" -archs > "$output/evidence/framework-architectures.txt" +xcodebuild -version > "$output/evidence/xcode-version.txt" +xcrun --sdk iphonesimulator --show-sdk-path > "$output/evidence/sdk-path.txt" +# Build the actual SDL source used by Ren'Py, with every upstream patch and +# both real mobile offscreen patches. No framework or SDL API is simulated. +[[ -f "$renpy_build/source/SDL2-2.0.20.tar.gz" ]] || { echo 'Missing pinned SDL2 source archive.' >&2; exit 1; } +command -v autoconf >/dev/null || { echo 'The cloud runner needs autoconf to regenerate the patched SDL configure.' >&2; exit 1; } +mkdir -p "$output/sdl-source" "$output/sdl-build" "$output/sdl-install" +tar xzf "$renpy_build/source/SDL2-2.0.20.tar.gz" -C "$output/sdl-source" +python3 - "$repo_root" "$renpy_build" "$output" <<'PY' +import pathlib, subprocess, sys +repo, upstream, out = map(pathlib.Path, sys.argv[1:]) +patches = {p.name: p.read_bytes() for p in (upstream/'patches/SDL2-2.0.20').iterdir() + if p.suffix in ('.diff', '.patch')} +# Extract the real new SDL patches from the reviewable renpy-build patches. +# They add files; reject edits to existing files instead of approximating them. +for name in ('0004-android-offscreen-renderer.patch', '0006-ios-offscreen-renderer.patch'): + lines = (repo/'bridge/renpy_runtime/mobile_launcher/patches/native'/name).read_text().splitlines(True) + for start, line in enumerate(lines): + if not line.startswith('diff --git a/patches/SDL2-2.0.20/'): + continue + end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith('diff --git ')), len(lines)) + section = lines[start:end] + if 'new file mode 100644\n' not in section: + raise SystemExit('Expected a real added SDL patch file: ' + name) + filename = line.split()[3].removeprefix('b/patches/SDL2-2.0.20/') + body = ''.join(l[1:] for l in section if l.startswith('+') and not l.startswith('+++')) + patches[filename] = body.encode() +evidence = out/'evidence/applied-sdl-patches'; evidence.mkdir(exist_ok=True) +for name, body in sorted(patches.items()): + (evidence/name).write_bytes(body) + p = subprocess.run(['patch', '--batch', '-p1'], input=body, + cwd=out/'sdl-source/SDL2-2.0.20', stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if p.returncode: + sys.stderr.buffer.write(p.stdout) + raise SystemExit('Actual upstream SDL patch failed: ' + name) +print('Applied every pinned upstream SDL patch plus mobile EGL pbuffer patches.') +PY +sdl_source="$output/sdl-source/SDL2-2.0.20" +if ! (cd "$sdl_source" && ./autogen.sh) > "$output/evidence/sdl-autogen.log" 2>&1; then + tail -80 "$output/evidence/sdl-autogen.log" >&2 + exit 1 +fi +sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)" +if [[ "$arch" == arm64 ]]; then sdl_host=arm-ios-darwin21; else sdl_host=x86_64-ios-darwin21; fi +if ! ( + set -e + cd "$output/sdl-build" + CC="$(xcrun --sdk iphonesimulator --find clang)" \ + CFLAGS="-target $arch-apple-ios15.0-simulator -isysroot $sdk -O1 -fobjc-arc -DSDL_MAIN_HANDLED -DMETALANGLE -I$framework/Headers -F$(dirname "$framework")" \ + CPPFLAGS="-I$framework/Headers -DMETALANGLE" \ + LDFLAGS="-target $arch-apple-ios15.0-simulator -isysroot $sdk -F$(dirname "$framework") -framework MetalANGLE" \ + ac_cv_header_libunwind_h=no \ + "$sdl_source/configure" --host="$sdl_host" --disable-shared --prefix="$output/sdl-install" \ + --enable-video-offscreen --disable-video-x11 --disable-video-wayland \ + --disable-render-metal --disable-video-vulkan --disable-video-kmsdrm \ + --disable-hidapi --disable-sensor --disable-power --disable-audio --disable-haptic || exit $? + make -j"$(sysctl -n hw.ncpu)" || exit $? + make install || exit $? +) > "$output/evidence/sdl-build.log" 2>&1; then + tail -100 "$output/evidence/sdl-build.log" >&2 + exit 1 +fi +cp "$output/sdl-build/include/SDL_config.h" "$output/evidence/SDL_config.h" +grep '^#define SDL_VIDEO_OPENGL_EGL 1' "$output/evidence/SDL_config.h" +grep '^#define SDL_VIDEO_DRIVER_OFFSCREEN 1' "$output/evidence/SDL_config.h" +xcrun lipo "$output/sdl-install/lib/libSDL2.a" -archs > "$output/evidence/sdl-architectures.txt" +app="$output/AetherRenPySDLProbe.app" +mkdir -p "$app/Frameworks" +cp -R "$framework" "$app/Frameworks/" +embedded="$app/Frameworks/MetalANGLE.framework" +if [[ "$(xcrun lipo "$embedded/MetalANGLE" -archs)" == *" "* ]]; then + xcrun lipo "$embedded/MetalANGLE" -thin "$arch" -output "$output/MetalANGLE.thin" + mv "$output/MetalANGLE.thin" "$embedded/MetalANGLE" +fi +codesign --force --sign - "$embedded" +cat > "$app/Info.plist" <<'PLIST' + + + +CFBundleIdentifierorg.aetherkiri.renpy-sdl-probe +CFBundleExecutableAetherRenPySDLProbe +CFBundleNameAether RenPy SDL Probe +CFBundlePackageTypeAPPL +CFBundleSupportedPlatformsiPhoneSimulator +CFBundleVersion1 +CFBundleShortVersionString1.0 +MinimumOSVersion15.0 +UIDeviceFamily12 +UILaunchScreen +UISupportedInterfaceOrientationsUIInterfaceOrientationPortrait + +PLIST +sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)" +xcrun --sdk iphonesimulator clang -target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \ + -Wall -Wextra -Werror -I"$framework/Headers" -I"$output/sdl-install/include/SDL2" \ + -c "$repo_root/tools/renpy_ios_egl_probe/sdl_probe.c" -o "$output/egl_probe.o" +xcrun --sdk iphonesimulator clang -target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \ + -Wall -Wextra -Werror -Wno-deprecated-declarations -fobjc-arc \ + -I"$framework/Headers" -F"$(dirname "$framework")" \ + "$repo_root/tools/renpy_ios_egl_probe/main.m" "$output/egl_probe.o" "$output/sdl-install/lib/libSDL2.a" \ + -framework MetalANGLE -framework UIKit -framework Foundation -framework QuartzCore \ + -framework CoreFoundation -framework CoreAudio -framework AudioToolbox -framework AVFoundation \ + -framework CoreGraphics -framework CoreMotion -framework GameController -framework Metal -framework OpenGLES -liconv \ + -Wl,-rpath,@executable_path/Frameworks -o "$app/AetherRenPySDLProbe" +codesign --force --sign - "$app" +xcrun otool -L "$app/AetherRenPySDLProbe" > "$output/evidence/app-linked-libraries.txt" +xcrun simctl list runtimes --json > "$output/evidence/simulator-runtimes.json" +xcrun simctl list devicetypes --json > "$output/evidence/simulator-device-types.json" +read -r runtime device_type < <(python3 - "$output/evidence" <<'PY' +import json, pathlib, sys +p = pathlib.Path(sys.argv[1]) +available = [r for r in json.loads((p/'simulator-runtimes.json').read_text())['runtimes'] + if r.get('isAvailable') and '.iOS-' in r['identifier']] +if not available: + raise SystemExit('No available iOS Simulator runtime; install one with Xcode on this cloud runner.') +runtime = max(available, key=lambda r: tuple(int(i) for i in r['version'].split('.'))) +devices = json.loads((p/'simulator-device-types.json').read_text())['devicetypes'] +for name in ('iPhone 13', 'iPhone 14', 'iPhone 15', 'iPhone 16'): + device = next((d for d in devices if d['name'] == name), None) + if device: + print(runtime['identifier'], device['identifier']) + break +else: + raise SystemExit('No supported recent iPhone Simulator device type was found.') +PY +) +udid="$(xcrun simctl create "Aether RenPy SDL probe ${GITHUB_RUN_ID:-$$}" "$device_type" "$runtime")" +cleanup() { + xcrun simctl shutdown "$udid" >/dev/null 2>&1 || true + xcrun simctl delete "$udid" >/dev/null 2>&1 || true +} +trap cleanup EXIT +xcrun simctl boot "$udid" +xcrun simctl bootstatus "$udid" -b +xcrun simctl install "$udid" "$app" +xcrun simctl launch --terminate-running-process "$udid" org.aetherkiri.renpy-sdl-probe | tee "$output/evidence/simulator-launch.txt" +container="$(xcrun simctl get_app_container "$udid" org.aetherkiri.renpy-sdl-probe data)" +for ((attempt=0; attempt<90; ++attempt)); do + if [[ -f "$container/Documents/egl-probe.json" ]]; then break; fi + sleep 1 +done +if [[ ! -f "$container/Documents/egl-probe.json" ]]; then + xcrun simctl spawn "$udid" log show --last 3m --style compact > "$output/evidence/simulator-system.log" 2>&1 || true + echo 'Probe did not produce a report; inspect launch/system logs for dyld or Metal failures.' >&2 + exit 1 +fi +cp "$container/Documents/egl-probe."* "$output/evidence/" +xcrun simctl io "$udid" screenshot "$output/evidence/simulator-screen.png" +python3 - "$output/evidence/egl-probe.json" <<'PY' +import json, pathlib, sys +report_path = pathlib.Path(sys.argv[1]) +report = json.loads(report_path.read_text()) +report['probe'] = 'patched SDL2.0.20 offscreen MetalANGLE EGL pbuffer' +report['source_version'] = 'SDL2.0.20' +report['assertions'] = ['SDL_Init video driver=offscreen', 'SDL_CreateWindow EGL pbuffer', + 'SDL_GL_CreateContext', 'actual GLES pixels', 'host context preserved', + 'resume produces new pixels', 'SDL pointer down/up and UTF-8 text delivery', + 'no additional UIWindow'] +report_path.write_text(json.dumps(report, indent=2) + '\n') +print(json.dumps(report, indent=2)) +required = ('passed', 'pbuffer_created', 'readback_verified', 'host_context_restored', 'resume_verified', 'no_extra_uiwindow') +if not all(report.get(k) for k in required): + raise SystemExit('Actual iOS EGL/GLES probe failed; this is not gameplay acceptance.') +print('Actual patched SDL iOS offscreen/GLES, pointer and UTF-8 event probe passed; Ren\'Py gameplay remains unverified.') +PY diff --git a/tools/renpy_ios_egl_probe/sdl_probe.c b/tools/renpy_ios_egl_probe/sdl_probe.c new file mode 100644 index 000000000..f0fba42f8 --- /dev/null +++ b/tools/renpy_ios_egl_probe/sdl_probe.c @@ -0,0 +1,168 @@ +/* Actual SDL2 offscreen integration probe, not a replacement graphics API. + * Builds against the pinned and patched upstream SDL2.0.20 static library. */ +#define SDL_MAIN_HANDLED 1 +#include +#define aether_run_egl_probe aether_run_egl_baseline +#include "egl_probe.c" +#undef aether_run_egl_probe +#ifdef __APPLE__ +#include +#include +#include +#endif + +int aether_run_egl_probe(aether_egl_probe_result *out) { + if (aether_run_egl_baseline(out)) return -1; + out->passed = out->pbuffer_created = out->readback_verified = 0; + out->host_context_restored = out->resume_verified = 0; + const EGLDisplay previous_display = eglGetCurrentDisplay(); + const EGLContext previous_context = eglGetCurrentContext(); + const EGLSurface previous_draw = eglGetCurrentSurface(EGL_DRAW); + const EGLSurface previous_read = eglGetCurrentSurface(EGL_READ); + const EGLenum previous_api = eglQueryAPI(); + EGLDisplay display = EGL_NO_DISPLAY; + EGLSurface host_surface = EGL_NO_SURFACE; + EGLContext host_context = EGL_NO_CONTEXT; + SDL_Window *window = NULL; + SDL_GLContext context = NULL; +#define SDL_CHECK(condition, message) do { if (!(condition)) { \ + snprintf(out->error, sizeof(out->error), "%s: %s", message, SDL_GetError()); \ + out->egl_error = eglGetError(); out->gl_error = glGetError(); goto cleanup; \ +} } while (0) +#ifdef __APPLE__ + CFURLRef frameworks = CFBundleCopyPrivateFrameworksURL(CFBundleGetMainBundle()); + CFURLRef framework = frameworks ? CFURLCreateCopyAppendingPathComponent( + kCFAllocatorDefault, frameworks, CFSTR("MetalANGLE.framework"), 1) : NULL; + CFURLRef library = framework ? CFURLCreateCopyAppendingPathComponent( + kCFAllocatorDefault, framework, CFSTR("MetalANGLE"), 0) : NULL; + char framework_path[PATH_MAX]; + const int valid = library && CFURLGetFileSystemRepresentation( + library, 1, (UInt8 *)framework_path, sizeof(framework_path)); + if (library) CFRelease(library); + if (framework) CFRelease(framework); + if (frameworks) CFRelease(frameworks); + SDL_CHECK(valid, "resolve actually embedded MetalANGLE framework"); + SDL_CHECK(!setenv("SDL_VIDEO_GL_DRIVER", framework_path, 1) && + !setenv("SDL_VIDEO_EGL_DRIVER", framework_path, 1), "set actual MetalANGLE loader paths"); +#endif + SDL_CHECK(!SDL_setenv("SDL_VIDEODRIVER", "offscreen", 1) && + !SDL_setenv("AETHER_RENPY_EMBEDDED", "1", 1), "select embedded offscreen SDL backend"); + SDL_SetMainReady(); + SDL_CHECK(SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS) == 0, "SDL_Init offscreen"); + SDL_CHECK(SDL_GetCurrentVideoDriver() && !strcmp(SDL_GetCurrentVideoDriver(), "offscreen"), + "SDL selected a driver other than offscreen"); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0); + SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 8); + SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE, 8); + SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 8); + SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 8); + SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 0); + window = SDL_CreateWindow("Aether RenPy offscreen", 0, 0, 16, 16, SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + SDL_CHECK(window, "SDL_CreateWindow actual offscreen pbuffer"); + context = SDL_GL_CreateContext(window); + out->client_version = 3; + if (!context) { + SDL_ClearError(); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 2); + context = SDL_GL_CreateContext(window); + out->client_version = 2; + } + SDL_CHECK(context, "SDL_GL_CreateContext"); + display = eglGetCurrentDisplay(); + const EGLSurface game_surface = eglGetCurrentSurface(EGL_DRAW); + SDL_CHECK(display != EGL_NO_DISPLAY && game_surface != EGL_NO_SURFACE, + "SDL did not bind an actual EGL pbuffer"); + EGLint width = 0, height = 0; + SDL_CHECK(eglQuerySurface(display, game_surface, EGL_WIDTH, &width) && + eglQuerySurface(display, game_surface, EGL_HEIGHT, &height) && width == 16 && height == 16, + "actual SDL EGL pbuffer dimensions mismatch"); + out->pbuffer_created = 1; + copy_string(out->gl_vendor, sizeof(out->gl_vendor), (const char *)glGetString(GL_VENDOR)); + copy_string(out->gl_renderer, sizeof(out->gl_renderer), (const char *)glGetString(GL_RENDERER)); + copy_string(out->gl_version, sizeof(out->gl_version), (const char *)glGetString(GL_VERSION)); + const EGLint config_attributes[] = { + EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, + EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_NONE + }; + EGLConfig config = NULL; + EGLint count = 0; + const EGLint surface_attributes[] = { EGL_WIDTH, 16, EGL_HEIGHT, 16, EGL_NONE }; + const EGLint context_attributes[] = { EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE }; + SDL_CHECK(eglChooseConfig(display, config_attributes, &config, 1, &count) && count == 1, + "host pbuffer config"); + host_surface = eglCreatePbufferSurface(display, config, surface_attributes); + host_context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes); + SDL_CHECK(host_surface != EGL_NO_SURFACE && host_context != EGL_NO_CONTEXT, "host pbuffer/context"); + SDL_CHECK(eglMakeCurrent(display, host_surface, host_surface, host_context), "bind host context"); + glClearColor(0, 1, 1, 1); glClear(GL_COLOR_BUFFER_BIT); + /* The host restores Ren'Py's raw EGL context, including after first bind. + SDL's own current-context cache must not hide this actual switch. */ + SDL_CHECK(eglMakeCurrent(display, game_surface, game_surface, (EGLContext)context), "bind SDL game context"); + glViewport(0, 0, 16, 16); glDisable(GL_DITHER); glEnable(GL_SCISSOR_TEST); + const float colors[4][4] = {{1,0,0,1}, {0,1,0,1}, {0,0,1,1}, {1,1,1,1}}; + for (int quadrant = 0; quadrant < 4; ++quadrant) { + glScissor((quadrant % 2) * 8, (quadrant / 2) * 8, 8, 8); + glClearColor(colors[quadrant][0], colors[quadrant][1], colors[quadrant][2], 1); + glClear(GL_COLOR_BUFFER_BIT); + } + glDisable(GL_SCISSOR_TEST); glPixelStorei(GL_PACK_ALIGNMENT, 1); glFinish(); + glReadPixels(0, 0, 16, 16, GL_RGBA, GL_UNSIGNED_BYTE, out->pixels); + SDL_CHECK(glGetError() == GL_NO_ERROR, "SDL pbuffer readback GL error"); + for (int y = 0; y < 16; ++y) for (int x = 0; x < 16; ++x) { + const int q = (y / 8) * 2 + x / 8; + SDL_CHECK(expect_pixel(out->pixels, x, y, (int)colors[q][0] * 255, + (int)colors[q][1] * 255, (int)colors[q][2] * 255, 255), "SDL pbuffer pixels mismatch"); + } + out->readback_verified = 1; + SDL_GL_SwapWindow(window); + SDL_CHECK(eglMakeCurrent(display, host_surface, host_surface, host_context), "restore host context"); + unsigned char pixel[4] = {0}; + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + SDL_CHECK(eglGetCurrentContext() == host_context && pixel[0] == 0 && pixel[1] == 255 && + pixel[2] == 255 && pixel[3] == 255, "SDL modified independent host rendering"); + out->host_context_restored = 1; + SDL_CHECK(eglMakeCurrent(display, game_surface, game_surface, (EGLContext)context), "resume SDL game context"); + glClearColor(1, 1, 0, 1); glClear(GL_COLOR_BUFFER_BIT); + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + SDL_CHECK(glGetError() == GL_NO_ERROR && pixel[0] == 255 && pixel[1] == 255 && + pixel[2] == 0 && pixel[3] == 255, "SDL resumed pixels mismatch"); + out->resume_verified = 1; + SDL_Event event; + SDL_zero(event); event.type = SDL_MOUSEBUTTONDOWN; + event.button.button = SDL_BUTTON_LEFT; event.button.state = SDL_PRESSED; + event.button.x = 7; event.button.y = 9; + SDL_CHECK(SDL_PushEvent(&event) == 1, "SDL actual pointer press enqueue"); + event.type = SDL_MOUSEBUTTONUP; event.button.state = SDL_RELEASED; + SDL_CHECK(SDL_PushEvent(&event) == 1, "SDL actual pointer release enqueue"); + SDL_zero(event); event.type = SDL_TEXTINPUT; + SDL_strlcpy(event.text.text, "\xe8\xa7\xa6\xe6\x8e\xa7", sizeof(event.text.text)); + SDL_CHECK(SDL_PushEvent(&event) == 1, "SDL actual UTF-8 text enqueue"); + int down = 0, up = 0, text = 0; + while (SDL_PollEvent(&event)) { + if (event.type == SDL_MOUSEBUTTONDOWN && event.button.x == 7 && event.button.y == 9 && + event.button.state == SDL_PRESSED) down = 1; + if (event.type == SDL_MOUSEBUTTONUP && event.button.state == SDL_RELEASED) up = 1; + if (event.type == SDL_TEXTINPUT && !strcmp(event.text.text, "\xe8\xa7\xa6\xe6\x8e\xa7")) text = 1; + } + SDL_CHECK(down && up && text, "SDL actual pointer/text event delivery mismatch"); + out->passed = 1; +cleanup: + if (display != EGL_NO_DISPLAY) eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + if (context) SDL_GL_DeleteContext(context); + if (window) SDL_DestroyWindow(window); + if (display != EGL_NO_DISPLAY) { + if (host_context != EGL_NO_CONTEXT) eglDestroyContext(display, host_context); + if (host_surface != EGL_NO_SURFACE) eglDestroySurface(display, host_surface); + } + SDL_Quit(); + eglBindAPI(previous_api); + if (previous_display != EGL_NO_DISPLAY && + !eglMakeCurrent(previous_display, previous_draw, previous_read, previous_context)) { + out->passed = 0; + copy_string(out->error, sizeof(out->error), "failed to restore pre-SDL EGL context"); + } + return out->passed ? 0 : -1; +#undef SDL_CHECK +} diff --git a/tools/run_renpy_ios_egl_probe.sh b/tools/run_renpy_ios_egl_probe.sh index 4c9a86f14..0e5733717 100755 --- a/tools/run_renpy_ios_egl_probe.sh +++ b/tools/run_renpy_ios_egl_probe.sh @@ -63,7 +63,7 @@ xcrun --sdk iphonesimulator clang -target "$arch-apple-ios15.0-simulator" -isysr -Wall -Wextra -Werror -Wno-deprecated-declarations -fobjc-arc \ -I"$framework/Headers" -F"$(dirname "$framework")" \ "$repo_root/tools/renpy_ios_egl_probe/main.m" "$output/egl_probe.o" \ - -framework MetalANGLE -framework UIKit -framework Foundation -framework QuartzCore \ + -framework MetalANGLE -framework UIKit -framework Foundation -framework QuartzCore -framework CoreGraphics \ -Wl,-rpath,@executable_path/Frameworks -o "$app/AetherRenPyEGLProbe" codesign --force --sign - "$app" xcrun otool -L "$app/AetherRenPyEGLProbe" > "$output/evidence/app-linked-libraries.txt" From 3f9493ec2670becb5ef9cbf3340f42eaa0d09bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 12:59:53 +0000 Subject: [PATCH 255/331] Preserve real mobile surface sizes and fix cloud validation gates --- .github/workflows/build.yml | 18 ++++++-- .github/workflows/renpy-ios-renderer.yml | 2 +- .github/workflows/renpy-ios-runtime-build.yml | 6 ++- .../scripts/renpy_mobile_acceptance.gd | 45 ++++++++++++++----- bridge/renpy_runtime/mobile_launcher/build.sh | 4 +- .../0001-cooperative-loop-skeleton.patch | 23 +++++++++- scripts/build_android.sh | 6 ++- tools/run_renpy_android_device_acceptance.py | 23 +++++++++- tools/run_renpy_mobile_source_build.sh | 2 +- tools/test_renpy_mobile_cooperative.py | 22 ++++++++- tools/test_renpy_mobile_darwin_build.py | 2 +- tools/test_renpy_mobile_source_build_plan.sh | 25 +++++++++++ tools/test_renpy_mobile_symbol_gate.sh | 2 +- 13 files changed, 155 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cc4cbc13c..3a3b1259b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -453,11 +453,23 @@ jobs: test "$(uname -m)" = x86_64 sdkmanager="${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" "$sdkmanager" --install emulator platform-tools 'system-images;android-35;google_apis;x86_64' - "${ANDROID_HOME}/emulator/emulator" -accel-check | tee "${RUNNER_TEMP}/renpy-android-device-evidence/acceleration.txt" + acceleration_result=0 + "${ANDROID_HOME}/emulator/emulator" -accel-check 2>&1 \ + | tee "${RUNNER_TEMP}/renpy-android-device-evidence/acceleration.txt" || acceleration_result=$? # Use the supported runner's existing KVM access. Do not change device # permissions/security settings to manufacture access when unavailable. - test -r /dev/kvm - test -w /dev/kvm + if [[ "$acceleration_result" != 0 || ! -r /dev/kvm || ! -w /dev/kvm ]]; then + python3 - "${RUNNER_TEMP}/renpy-android-device-evidence/result.json" <<'PY' + import json, pathlib, sys + pathlib.Path(sys.argv[1]).write_text(json.dumps({ + 'status': 'not_run', 'gameplay_verified': False, + 'blocker': 'The cloud runner has no usable existing KVM access for the supported Android emulator.', + 'required': 'A cloud x86_64 runner with provisioned readable/writable KVM access, or a connected cloud Android device.', + }, indent=2) + '\n') + PY + echo 'Android gameplay not_run: existing KVM access is unavailable; no permissions were changed.' >&2 + exit 1 + fi printf 'no\n' | "${ANDROID_HOME}/cmdline-tools/latest/bin/avdmanager" create avd \ --name aether-renpy-device --force --device pixel_2 \ --package 'system-images;android-35;google_apis;x86_64' diff --git a/.github/workflows/renpy-ios-renderer.yml b/.github/workflows/renpy-ios-renderer.yml index b231a2d24..de7a57149 100644 --- a/.github/workflows/renpy-ios-renderer.yml +++ b/.github/workflows/renpy-ios-renderer.yml @@ -14,7 +14,7 @@ permissions: concurrency: group: renpy-ios-renderer-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + cancel-in-progress: false jobs: simulator-pbuffer: diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index 455d58082..7faff26bf 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -73,9 +73,10 @@ jobs: set -euo pipefail bash tools/run_renpy_mobile_source_build.sh --mode prepare --fetch \ --platform ios --renpy-build "$RENPY_BUILD_ROOT" \ - --output-dir "$RENPY_OUTPUT_ROOT" + --output-dir "$RENPY_OUTPUT_ROOT" 2>&1 | tee "$RUNNER_TEMP/renpy-ios-source-prepare.log" uv --project "$RENPY_BUILD_ROOT/renpy" run python \ - tools/test_renpy_mobile_darwin_build.py "$RENPY_BUILD_ROOT" + tools/test_renpy_mobile_darwin_build.py "$RENPY_BUILD_ROOT" \ + 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" - name: Compile target CPython, SDL, Ren'Py and device/Simulator archives if: steps.runtime-cache.outputs.cache-hit != 'true' run: | @@ -118,6 +119,7 @@ jobs: name: renpy-ios-source-build-evidence path: | ${{ runner.temp }}/renpy-ios-source-build.log + ${{ runner.temp }}/renpy-ios-source-prepare.log out/renpy-ios-source/manifest.json if-no-files-found: warn retention-days: 7 diff --git a/apps/godot_app/scripts/renpy_mobile_acceptance.gd b/apps/godot_app/scripts/renpy_mobile_acceptance.gd index 7d5985247..55aa31dcd 100644 --- a/apps/godot_app/scripts/renpy_mobile_acceptance.gd +++ b/apps/godot_app/scripts/renpy_mobile_acceptance.gd @@ -18,7 +18,9 @@ var started := false var finished := false var paused := false var keyboard_active := false +var keyboard_visible := false var resumed_capture_pending := false +var paused_frame_serial := 0 var checkpoint_count := 0 var suppress_mouse_until := 0 var deadline := 0 @@ -102,6 +104,7 @@ func _notification(what: int) -> void: if what == NOTIFICATION_APPLICATION_PAUSED and not paused: var result: int = player.pause() paused = result == 0 + paused_frame_serial = int(_provider_debug().get("frame_serial", 0)) _record({"kind": "lifecycle", "phase": "paused", "result": result, "provider_debug": _provider_debug()}) if result != 0: @@ -177,16 +180,23 @@ func _map_point(point: Vector2) -> Vector2: func _sync_keyboard() -> void: var state: Dictionary = player.get_text_input_state() var active := bool(state.get("available", false)) and bool(state.get("ime_active", false)) - if active == keyboard_active: - return - keyboard_active = active var available := DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD) - if active and available: - DisplayServer.virtual_keyboard_show(String(state.get("text", "")), Rect2(0, 0, 1, 1)) - elif available: - DisplayServer.virtual_keyboard_hide() - _record({"kind": "soft_keyboard", "active": active, "feature_available": available, - "source": "native-text-input-state", "state": state}) + if active != keyboard_active: + keyboard_active = active + if active and available: + DisplayServer.virtual_keyboard_show(String(state.get("text", "")), Rect2(0, 0, 1, 1)) + elif available: + DisplayServer.virtual_keyboard_hide() + _record({"kind": "soft_keyboard", "active": active, "feature_available": available, + "source": "native-text-input-state", "state": state}) + # Showing/hiding the Android keyboard is asynchronous. Its actual height, + # rather than the native request alone, proves OS visibility and dismissal. + var height := DisplayServer.virtual_keyboard_get_height() if available else 0 + var visible := height > 0 + if visible != keyboard_visible: + keyboard_visible = visible + _record({"kind": "soft_keyboard_visibility", "visible": visible, "height": height, + "source": "os-virtual-keyboard-height"}) func _game_checkpoints() -> Array: var rows: Array = [] @@ -215,9 +225,24 @@ func _observe_game_checkpoints() -> void: _capture_provider(String(row.get("stage", ""))) func _capture_provider(stage: String) -> void: - # Allow actual presentation after the Python statement reaches interaction. + # Let the interaction settle, then wait for keyboard animation and a fresh + # native redraw after resume. Old pre-pause pixels cannot prove recovery. + var expires := mini(deadline, Time.get_ticks_msec() + 15000) + while Time.get_ticks_msec() < expires: + if finished or player == null: + return + var keyboard_height := DisplayServer.virtual_keyboard_get_height() + var fresh := stage != "resumed_ready" or int(_provider_debug().get("frame_serial", 0)) > paused_frame_serial + if not paused and not keyboard_active and keyboard_height <= 0 and fresh: + break + await get_tree().process_frame + if Time.get_ticks_msec() >= expires: + _fail("Native presentation did not settle for %s after keyboard/lifecycle change." % stage) + return for _frame in range(3): await get_tree().process_frame + # This is the real renderer's completed draw, not a fabricated screenshot. + await RenderingServer.frame_post_draw if finished or player == null: return var frame: Dictionary = player.read_frame_rgba() diff --git a/bridge/renpy_runtime/mobile_launcher/build.sh b/bridge/renpy_runtime/mobile_launcher/build.sh index 3054bcfe0..61ef563a2 100644 --- a/bridge/renpy_runtime/mobile_launcher/build.sh +++ b/bridge/renpy_runtime/mobile_launcher/build.sh @@ -420,7 +420,7 @@ nm_symbols() { check_symbols() { local artifact="$1" kind="$2" expected_machine="${3:-}" [[ -f "$artifact" ]] || { echo "artifact not found: $artifact" >&2; exit 1; } - if strings "$artifact" | grep -Fq 'AETHERKIRI_RENPY_LIFECYCLE_STUB'; then + if LC_ALL=C grep -aFq 'AETHERKIRI_RENPY_LIFECYCLE_STUB' "$artifact"; then echo "refusing contract-only lifecycle stub as runtime artifact: $artifact" >&2 exit 1 fi @@ -464,7 +464,7 @@ check_symbols() { fi local symbol for symbol in "${required_symbols[@]}"; do - if ! awk '{print $NF}' "$symbols" | sed 's/^_//' | grep -Fxq "$symbol"; then + if ! awk -v symbol="$symbol" '{ name=$NF; sub(/^_/, "", name); if (name==symbol) found=1 } END { exit !found }' "$symbols"; then echo "artifact lacks required lifecycle export $symbol: $artifact" >&2 rm -f "$symbols" exit 1 diff --git a/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch b/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch index 8804d0f0a..361db723b 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch @@ -117,7 +117,7 @@ index 9da4b1e..375ae55 100644 rv = renpy.store._return diff --git a/renpy/gl2/gl2draw.pyx b/renpy/gl2/gl2draw.pyx -index cb66af0..f8874b8 100644 +index cb66af0..3762a48 100644 --- a/renpy/gl2/gl2draw.pyx +++ b/renpy/gl2/gl2draw.pyx @@ -364,7 +364,9 @@ cdef class GL2Draw: @@ -142,6 +142,27 @@ index cb66af0..f8874b8 100644 fullscreen = True elif renpy.emscripten: fullscreen = False +@@ -409,14 +413,16 @@ cdef class GL2Draw: + window_flags = pygame.OPENGL | pygame.DOUBLEBUF + + if renpy.android: +- pwidth = 0 +- pheight = 0 ++ if not renpy.aether_mobile.cooperative_active(): ++ pwidth = 0 ++ pheight = 0 + gles = True + + elif renpy.ios: + window_flags |= pygame.WINDOW_ALLOW_HIGHDPI | pygame.RESIZABLE +- pwidth = 0 +- pheight = 0 ++ if not renpy.aether_mobile.cooperative_active(): ++ pwidth = 0 ++ pheight = 0 + gles = True + + else: diff --git a/renpy/pygame/display.pyx b/renpy/pygame/display.pyx index 85d16d0..a732f2c 100644 --- a/renpy/pygame/display.pyx diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 72272b0d4..d10d01747 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -434,7 +434,11 @@ build_abi() { mkdir -p "$godot_bin_dir" copy_android_so "$cmake_build_dir/abi/libengine_api.so" "$godot_bin_dir/libengine_api.so" copy_android_so "$cmake_build_dir/bridge/godot_extension/libaether_kiri_godot.so" "$godot_bin_dir/libaether_kiri_godot.so" - copy_android_so "$vcpkg_triplet_dir/lib/libSDL2.so" "$godot_bin_dir/libSDL2.so" + local sdl_library="$vcpkg_triplet_dir/lib/libSDL2.so" + if [[ "$BUILD_TYPE_LOWER" == debug && -f "$vcpkg_triplet_dir/debug/lib/libSDL2.so" ]]; then + sdl_library="$vcpkg_triplet_dir/debug/lib/libSDL2.so" + fi + copy_android_so "$sdl_library" "$godot_bin_dir/libSDL2.so" libomp_path="$(find "$ANDROID_NDK_HOME/toolchains/llvm/prebuilt" -path "*/lib/linux/$openmp_arch/libomp.so" -print -quit)" if [[ -z "$libomp_path" || ! -f "$libomp_path" ]]; then echo "Error: Android OpenMP runtime libomp.so not found under $ANDROID_NDK_HOME." >&2 diff --git a/tools/run_renpy_android_device_acceptance.py b/tools/run_renpy_android_device_acceptance.py index 5c23a7544..c54f040bc 100644 --- a/tools/run_renpy_android_device_acceptance.py +++ b/tools/run_renpy_android_device_acceptance.py @@ -123,6 +123,7 @@ def __init__(self, args: argparse.Namespace): self.data_root = "" self.pid = "" self.installed = False + self.execution_deadline = 0.0 self.summary = {"status": "not_run", "run_id": self.run_id, "source": "actual-apk-adb-cloud-device", "serial": args.serial, "package": args.package, "checks": [], "blocker": ""} @@ -168,6 +169,8 @@ def observer_rows(self) -> list[dict]: def wait(self, description: str, condition, timeout: int | None = None): expires = time.monotonic() + (timeout or self.args.stage_timeout) + if self.execution_deadline: + expires = min(expires, self.execution_deadline) while time.monotonic() < expires: for row in self.observer_rows(): if row.get("kind") == "failure": @@ -281,6 +284,7 @@ def run(self) -> None: if not component or not component.startswith(self.args.package + "/"): raise Failed("Cannot resolve the installed APK's real launcher activity") self.summary["activity"] = component + self.execution_deadline = time.monotonic() + self.args.overall_timeout self.shell("am", "start", "-W", "-n", component, timeout=60) self.pid = self.wait("installed app process", lambda: self.shell("pidof", self.args.package, check=False).decode().strip()) self.wait_game("start_ready") @@ -291,6 +295,9 @@ def run(self) -> None: keyboard = self.wait_observer("soft_keyboard", active=True) if keyboard.get("source") != "native-text-input-state" or not keyboard.get("feature_available"): raise Failed("A real native text request and OS virtual keyboard are required") + keyboard_visible = self.wait_observer("soft_keyboard_visibility", visible=True) + if keyboard_visible.get("source") != "os-virtual-keyboard-height" or keyboard_visible.get("height", 0) <= 0: + raise Failed("Android did not report a real visible virtual keyboard") ime = self.wait("visible Android system input method", lambda: self.visible_ime()) (self.output / "input-method.txt").write_text(ime) self.screenshot("text-ready-with-os-keyboard") @@ -301,6 +308,9 @@ def run(self) -> None: if text.get("details", {}).get("text") != typed: raise Failed(f"Real Ren'Py text differs from OS input: {text.get('details')}") self.wait_game("post_text_ready") + self.wait_observer("soft_keyboard", active=False) + self.wait_observer("soft_keyboard_visibility", visible=False) + self.wait("hidden Android system input method", lambda: not self.visible_ime()) frame, screen = self.capture("post_text_ready") self.shell("input", "keyevent", "3") # Android Home: real lifecycle pause. pause = self.wait_observer("lifecycle", phase="paused") @@ -360,7 +370,7 @@ def stage(self) -> None: fixture = Path(__file__).resolve().parents[1] / "demos/aetherkiri-renpy/game" request = {"run_id": self.run_id} config = {"probe_script": "res://scripts/renpy_mobile_acceptance.gd", "run_id": self.run_id, - "game_path": self.game_root, "timeout_seconds": 240} + "game_path": self.game_root, "timeout_seconds": self.args.overall_timeout} # Staging is confined to two test directories plus the existing # one-shot debug request. No app data, saves, or external paths are reset. self.shell("run-as", self.args.package, "mkdir", "-p", "files") @@ -405,6 +415,13 @@ def collect(self) -> None: self.command("logcat", "-d", "--pid", self.pid.split()[0], "-v", "threadtime", check=False)) except Failed: pass + try: + # Startup/linker crashes may occur before pidof can observe a PID. + # Preserve actual logs from this disposable cloud image as well. + (self.output / "android-system-logcat.txt").write_bytes( + self.command("logcat", "-d", "-v", "threadtime", "-t", "2000", check=False)) + except Failed: + pass def main() -> int: @@ -416,9 +433,13 @@ def main() -> int: parser.add_argument("--adb", default="adb") parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--stage-timeout", type=int, default=60) + parser.add_argument("--overall-timeout", type=int, default=600, + help="Bound actual app execution to this many seconds (60..1200)") args = parser.parse_args() if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_.]+", args.package) or not 5 <= args.stage_timeout <= 180: parser.error("Invalid package or stage timeout (5..180 seconds)") + if not 60 <= args.overall_timeout <= 1200: + parser.error("Invalid overall timeout (60..1200 seconds)") acceptance = Acceptance(args) code = 0 try: diff --git a/tools/run_renpy_mobile_source_build.sh b/tools/run_renpy_mobile_source_build.sh index 5bb707c34..3d7bd0fb7 100755 --- a/tools/run_renpy_mobile_source_build.sh +++ b/tools/run_renpy_mobile_source_build.sh @@ -164,7 +164,7 @@ check_exports() { output="$(llvm-nm-18 --defined-only --extern-only "$artifact")" fi for symbol in "${symbols[@]}"; do - awk '{print $NF}' <<<"$output" | sed 's/^_//' | grep -Fxq "$symbol" || { + awk -v symbol="$symbol" '{ name=$NF; sub(/^_/, "", name); if (name==symbol) found=1 } END { exit !found }' <<<"$output" || { echo "compiled artifact lacks $symbol: $artifact" >&2; exit 1; } done diff --git a/tools/test_renpy_mobile_cooperative.py b/tools/test_renpy_mobile_cooperative.py index 98967adc1..d1738f3dc 100755 --- a/tools/test_renpy_mobile_cooperative.py +++ b/tools/test_renpy_mobile_cooperative.py @@ -9,6 +9,7 @@ from pathlib import Path import sys import tempfile +import textwrap import threading import types @@ -138,5 +139,24 @@ def other_thread(): assert renpy.entry_argv == [str(entry), str(root)] mobile.cooperative_stop() -print("Cooperative checks passed: real greenlet stack preservation, deadline-before-start, Unicode/input, frame ownership, pause/resume and cleanup") +renderer_source = (args.renpy_src / "renpy/gl2/gl2draw.pyx").read_text() +start = renderer_source.index(" gles = self.gles") +end = renderer_source.index(" # Select the GL attributes and hints.", start) +size_selection = textwrap.dedent(renderer_source[start:end]) +for platform in ("android", "ios"): + for enabled in (False, True): + context = { + "self": types.SimpleNamespace(gles=False), "pwidth": 1600, "pheight": 900, + "renpy": types.SimpleNamespace(android=platform == "android", ios=platform == "ios", + aether_mobile=types.SimpleNamespace(cooperative_active=lambda: enabled), + config=types.SimpleNamespace(gl2_modify_window_flags=None)), + "pygame": types.SimpleNamespace(OPENGL=1, DOUBLEBUF=2, WINDOW_ALLOW_HIGHDPI=4, RESIZABLE=8), + } + exec(size_selection, context) + assert (context["pwidth"], context["pheight"]) == ((1600, 900) if enabled else (0, 0)) + assert context["gles"] + if platform == "ios": + assert context["window_flags"] & 12 == 12 + +print("Cooperative checks passed: real greenlet stack preservation, deadline-before-start, Unicode/input, frame ownership, pause/resume, cleanup and mobile pbuffer dimensions") print("Synthetic engine boundaries only; Ren'Py/SDL/GL/device gameplay was not run") diff --git a/tools/test_renpy_mobile_darwin_build.py b/tools/test_renpy_mobile_darwin_build.py index cc4e7d9a0..2dc643aea 100644 --- a/tools/test_renpy_mobile_darwin_build.py +++ b/tools/test_renpy_mobile_darwin_build.py @@ -88,7 +88,7 @@ def xcrun(arguments, **_kwargs): cross.run = lambda _command: None toolchain(cross) assert cross.path("{{ cross }}/sdk").is_symlink() - assert cross.path("{{ cross }}/sdk").resolve() == root / "local-sdk/iphonesimulator" + assert cross.path("{{ cross }}/sdk").samefile(root / "local-sdk/iphonesimulator") with patch.object(run.sys, "platform", "linux"): context = Context("android", "arm64_v8a", "3", root, SimpleNamespace()) context.set_names("python", "build", "renpython") diff --git a/tools/test_renpy_mobile_source_build_plan.sh b/tools/test_renpy_mobile_source_build_plan.sh index 4822a0f2f..bb605bedf 100755 --- a/tools/test_renpy_mobile_source_build_plan.sh +++ b/tools/test_renpy_mobile_source_build_plan.sh @@ -46,4 +46,29 @@ for symbol in ('renpy_mobile_text_input_state', 'renpy_mobile_set_surface_size') assert symbol in source assert 'rapt-sdl2' not in source, 'pinned release has a sdl2 task, no rapt-sdl2 task' PY +python3 - "$runner" "$runner_tmp" <<'PY' +from pathlib import Path +import subprocess +import sys +source = Path(sys.argv[1]).read_text() +root = Path(sys.argv[2]) +symbols = source[source.index('symbols=('):source.index('\ncheck_exports()')] +function = source[source.index('check_exports()'):source.index('\nartifact_root=')] +catalog = root / 'large-symbol-catalog' +required = [name for name in symbols.replace('(', ' ').replace(')', ' ').split() if name.startswith('renpy_mobile_')] +catalog.write_text(''.join('000000 T ' + name + '\n' for name in required) + + ''.join(f'000000 T runtime_function_{i}\n' for i in range(20000))) +artifact = root / 'export-gate-library' +artifact.touch() +script = root / 'export-gate-check.sh' +script.write_text('set -euo pipefail\n' + symbols + '\n' + function + '\n' + + 'nm() { cat "$RENPY_GATE_CATALOG"; }\n' + + 'check_exports "$RENPY_GATE_ARTIFACT" android\n') +import os +environment = dict(os.environ, RENPY_GATE_CATALOG=str(catalog), RENPY_GATE_ARTIFACT=str(artifact)) +subprocess.run(['bash', str(script)], env=environment, check=True) +catalog.write_text(catalog.read_text().replace('000000 T renpy_mobile_tick\n', '')) +failure = subprocess.run(['bash', str(script)], env=environment, capture_output=True, text=True) +assert failure.returncode != 0 and 'lacks renpy_mobile_tick' in failure.stderr +PY echo "Ren'Py mobile source-build gating checks passed; no native build/gameplay performed" diff --git a/tools/test_renpy_mobile_symbol_gate.sh b/tools/test_renpy_mobile_symbol_gate.sh index 8037195f4..e0df9e142 100644 --- a/tools/test_renpy_mobile_symbol_gate.sh +++ b/tools/test_renpy_mobile_symbol_gate.sh @@ -49,7 +49,7 @@ check_symbols() { listing="$(nm_listing "$artifact" "$kind")" local missing=0 symbol for symbol in "${required_symbols[@]}"; do - if ! awk '{print $NF}' "$listing" | sed 's/^_//' | grep -Fxq "${symbol}"; then + if ! awk -v symbol="$symbol" '{ name=$NF; sub(/^_/, "", name); if (name==symbol) found=1 } END { exit !found }' "$listing"; then echo "missing lifecycle export: ${symbol} (${artifact})" >&2 missing=1 fi From c7c11dec12cad6353e978e1f81713cedade8484e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 13:03:45 +0000 Subject: [PATCH 256/331] Isolate iOS SDL classes and repair actual Simulator compile failure --- .../native/0006-ios-offscreen-renderer.patch | 102 +++++++++++++++++- tools/renpy_ios_egl_probe/run_sdl_probe.sh | 17 ++- 2 files changed, 113 insertions(+), 6 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch index aab8dc4ca..49b9b0d64 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch @@ -1,11 +1,11 @@ diff --git a/patches/SDL2-2.0.20/aether-pbuffer-metalangle.patch b/patches/SDL2-2.0.20/aether-pbuffer-metalangle.patch new file mode 100644 -index 0000000..b13f73c +index 0000000..6de8626 --- /dev/null +++ b/patches/SDL2-2.0.20/aether-pbuffer-metalangle.patch -@@ -0,0 +1,82 @@ +@@ -0,0 +1,144 @@ +diff --git a/configure.ac b/configure.ac -+index 3c1c6c8..de2d5e6 100644 ++index 3c1c6c8..3fa0bde 100644 +--- a/configure.ac ++++ b/configure.ac +@@ -4116,6 +4116,9 @@ case "$host" in @@ -18,6 +18,68 @@ index 0000000..b13f73c + CheckDiskAudio + CheckDummyAudio + CheckDLOPEN ++@@ -4146,7 +4149,9 @@ case "$host" in ++ have_joystick=yes ++ else ++ # Need this code for accelerometer as joystick support ++- SOURCES="$SOURCES $srcdir/src/joystick/iphoneos/*.m" +++ if test x$enable_video_offscreen != xyes; then +++ SOURCES="$SOURCES $srcdir/src/joystick/iphoneos/*.m" +++ fi ++ fi ++ # Set up files for the haptic library ++ #if test x$enable_haptic = xyes; then ++@@ -4185,12 +4190,16 @@ case "$host" in ++ # Set up other core UNIX files ++ SOURCES="$SOURCES $srcdir/src/core/unix/*.c" ++ # The iOS platform requires special setup. ++- AC_DEFINE(SDL_VIDEO_DRIVER_UIKIT, 1, [ ]) +++ # An embedded offscreen build must not register SDL's UIKit classes: +++ # Objective-C classes are process-global even across Mach-O namespaces. +++ if test x$enable_video_offscreen != xyes; then +++ AC_DEFINE(SDL_VIDEO_DRIVER_UIKIT, 1, [ ]) +++ SOURCES="$SOURCES $srcdir/src/video/uikit/*.m" +++ fi ++ AC_DEFINE(SDL_VIDEO_OPENGL_ES2, 1, [ ]) ++ AC_DEFINE(SDL_VIDEO_OPENGL_ES, 1, [ ]) ++ AC_DEFINE(SDL_VIDEO_RENDER_OGL_ES, 1, [ ]) ++ AC_DEFINE(SDL_VIDEO_RENDER_OGL_ES2, 1, [ ]) ++- SOURCES="$SOURCES $srcdir/src/video/uikit/*.m" ++ EXTRA_LDFLAGS="$EXTRA_LDFLAGS -lm -liconv -lobjc" ++ EXTRA_LDFLAGS="$EXTRA_LDFLAGS -Wl,-framework,AVFoundation" ++ EXTRA_LDFLAGS="$EXTRA_LDFLAGS -Wl,-framework,AudioToolbox" ++diff --git a/src/audio/coreaudio/SDL_coreaudio.m b/src/audio/coreaudio/SDL_coreaudio.m ++index 67d838f..0beafca 100644 ++--- a/src/audio/coreaudio/SDL_coreaudio.m +++++ b/src/audio/coreaudio/SDL_coreaudio.m ++@@ -346,6 +346,11 @@ static void interruption_end(_THIS) ++ } ++ } ++ +++/* Altered for the embedded Ren'Py fork: the host may have its own SDL2. +++ CoreAudio remains enabled, but its Objective-C class cannot share a name. */ +++#if defined(METALANGLE) && defined(__IPHONEOS__) +++#define SDLInterruptionListener AetherRenpySDLInterruptionListener +++#endif ++ @interface SDLInterruptionListener : NSObject ++ ++ @property (nonatomic, assign) SDL_AudioDevice *device; ++diff --git a/src/hidapi/SDL_hidapi.c b/src/hidapi/SDL_hidapi.c ++index aacff63..8d33aaf 100644 ++--- a/src/hidapi/SDL_hidapi.c +++++ b/src/hidapi/SDL_hidapi.c ++@@ -1522,8 +1522,10 @@ int SDL_hid_get_indexed_string(SDL_hid_device *device, int string_index, wchar_t ++ ++ void SDL_hid_ble_scan(SDL_bool active) ++ { ++-#if __IPHONEOS__ || __TVOS__ +++#if !SDL_HIDAPI_DISABLED && (__IPHONEOS__ || __TVOS__) ++ hid_ble_scan(active); +++#else +++ (void)active; ++ #endif ++ } ++ +diff --git a/src/video/SDL_egl.c b/src/video/SDL_egl.c +index 62004e3..9c3f2b3 100644 +--- a/src/video/SDL_egl.c @@ -164,10 +226,10 @@ index 5d4a5c9..0b30f45 100644 @task(kind="python", platforms="ios") def install(c: Context): diff --git a/tasks/sdl2.py b/tasks/sdl2.py -index 6756b47..3b701cd 100644 +index 6756b47..a7d5b01 100644 --- a/tasks/sdl2.py +++ b/tasks/sdl2.py -@@ -58,9 +58,11 @@ def build(c: Context): +@@ -58,9 +58,19 @@ def build(c: Context): --disable-libdecor {% endif %} @@ -175,8 +237,38 @@ index 6756b47..3b701cd 100644 + {% if c.platform in ("android", "ios") %} --enable-video-offscreen + {% endif %} ++ ++ {% if c.platform == "ios" %} ++ --disable-joystick ++ --disable-sensor ++ --disable-power ++ --disable-haptic ++ --disable-hidapi ++ {% endif %} + {% if c.platform == "android" %} --disable-oss --disable-alsa --disable-esd +@@ -76,19 +86,8 @@ def build(c: Context): + if c.platform == "ios": + with open(c.path("include/SDL_config.h"), "a") as f: + f.write(""" +-/* Enable system power support */ +-#define SDL_POWER_UIKIT 1 +- +-/* enable iPhone keyboard support */ +-#define SDL_IPHONE_KEYBOARD 1 +- +-/* enable iOS extended launch screen */ +-#define SDL_IPHONE_LAUNCHSCREEN 1 +- +-/* Set max recognized G-force from accelerometer +- See src/joystick/uikit/SDL_sysjoystick.m for notes on why this is needed +- */ +-#define SDL_IPHONE_MAX_GFORCE 5.0 ++/* Embedded Ren'Py uses the host's keyboard, app lifecycle and sensors. ++ SDL's UIKit window/controller classes are excluded from this build. */ + + /* enable filesystem support */ + #define SDL_FILESYSTEM_COCOA 1 diff --git a/tools/renpy_ios_egl_probe/run_sdl_probe.sh b/tools/renpy_ios_egl_probe/run_sdl_probe.sh index ab51ad442..7b9c9154b 100755 --- a/tools/renpy_ios_egl_probe/run_sdl_probe.sh +++ b/tools/renpy_ios_egl_probe/run_sdl_probe.sh @@ -82,7 +82,7 @@ if ! ( "$sdl_source/configure" --host="$sdl_host" --disable-shared --prefix="$output/sdl-install" \ --enable-video-offscreen --disable-video-x11 --disable-video-wayland \ --disable-render-metal --disable-video-vulkan --disable-video-kmsdrm \ - --disable-hidapi --disable-sensor --disable-power --disable-audio --disable-haptic || exit $? + --disable-hidapi --disable-joystick --disable-sensor --disable-power --disable-haptic || exit $? make -j"$(sysctl -n hw.ncpu)" || exit $? make install || exit $? ) > "$output/evidence/sdl-build.log" 2>&1; then @@ -92,6 +92,21 @@ fi cp "$output/sdl-build/include/SDL_config.h" "$output/evidence/SDL_config.h" grep '^#define SDL_VIDEO_OPENGL_EGL 1' "$output/evidence/SDL_config.h" grep '^#define SDL_VIDEO_DRIVER_OFFSCREEN 1' "$output/evidence/SDL_config.h" +if grep '^#define SDL_VIDEO_DRIVER_UIKIT 1' "$output/evidence/SDL_config.h"; then + echo 'Embedded SDL must not compile its process-global UIKit classes.' >&2 + exit 1 +fi +xcrun nm -a "$output/sdl-install/lib/libSDL2.a" > "$output/evidence/sdl-symbols.txt" +python3 - "$output/evidence/sdl-symbols.txt" <<'PY' +import pathlib, re, sys +symbols = pathlib.Path(sys.argv[1]).read_text() +collisions = re.findall(r'OBJC_CLASS_\$_(?:SDL_\w+|SDLInterruptionListener)\b', symbols) +if collisions: + raise SystemExit('RenPy SDL still defines host-conflicting Objective-C classes: ' + ', '.join(sorted(set(collisions)))) +if 'OBJC_CLASS_$_AetherRenpySDLInterruptionListener' not in symbols: + raise SystemExit('Expected the real retained CoreAudio implementation with its isolated Objective-C listener.') +print('Actual SDL archive excludes UIKit classes and preserves renamed CoreAudio listener.') +PY xcrun lipo "$output/sdl-install/lib/libSDL2.a" -archs > "$output/evidence/sdl-architectures.txt" app="$output/AetherRenPySDLProbe.app" mkdir -p "$app/Frameworks" From 9b1bad4ec38687757042e8dffea99bf43334b04b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 13:20:56 +0000 Subject: [PATCH 257/331] Repair real iOS link, isolate Android exports and bound source compilation --- .github/workflows/renpy-build-diagnostics.yml | 44 ++++++ .github/workflows/renpy-mobile-contract.yml | 14 ++ .../native/0006-ios-offscreen-renderer.patch | 45 +++++- .../native/0007-darwin-ios-build.patch | 23 ++- .../0008-android-export-isolation.patch | 34 +++++ .../0009-bounded-build-parallelism.patch | 86 +++++++++++ .../mobile_launcher/patches/native/README.md | 6 + tools/renpy_ios_egl_probe/run_sdl_probe.sh | 2 +- tools/test_renpy_mobile_build_parallelism.py | 134 ++++++++++++++++++ tools/test_renpy_mobile_darwin_build.py | 16 ++- tools/test_renpy_mobile_native_fork.sh | 1 + tools/validate_renpy_mobile_payload.py | 6 + 12 files changed, 406 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/renpy-build-diagnostics.yml create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/native/0008-android-export-isolation.patch create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/native/0009-bounded-build-parallelism.patch create mode 100644 tools/test_renpy_mobile_build_parallelism.py diff --git a/.github/workflows/renpy-build-diagnostics.yml b/.github/workflows/renpy-build-diagnostics.yml new file mode 100644 index 000000000..3e7bd7674 --- /dev/null +++ b/.github/workflows/renpy-build-diagnostics.yml @@ -0,0 +1,44 @@ +name: Read Ren'Py build diagnostics + +on: + pull_request: + paths: + - '.github/workflows/renpy-build-diagnostics.yml' + workflow_dispatch: + +permissions: + contents: read + actions: read + +jobs: + actual-build-log: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Read the actual failed Android source-build log + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh api 'repos/AetherKiri/Aether/actions/jobs/113309300220/logs' \ + > "$RUNNER_TEMP/renpy-android-actual-build.log" + python3 - "$RUNNER_TEMP/renpy-android-actual-build.log" <<'PY' + import pathlib, re, sys + lines = pathlib.Path(sys.argv[1]).read_text(errors='replace').splitlines() + print('Reading the actual job 113309300220 log; this does not rerun or simulate the build.') + interesting = [i for i, line in enumerate(lines) + if re.search(r'error:|fatal error|FAILED|Error [1-9]|undefined reference|not found|Traceback|No such file|exception', line, re.I)] + indices = set(range(max(0, len(lines)-110), len(lines))) + for i in interesting[-16:]: + indices.update(range(max(0, i-4), min(len(lines), i+9))) + for i in sorted(indices): + print(lines[i]) + PY + - name: Preserve the original build log + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-android-actual-build-log + path: ${{ runner.temp }}/renpy-android-actual-build.log + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/renpy-mobile-contract.yml b/.github/workflows/renpy-mobile-contract.yml index 179268988..bae19d1d7 100644 --- a/.github/workflows/renpy-mobile-contract.yml +++ b/.github/workflows/renpy-mobile-contract.yml @@ -83,6 +83,20 @@ jobs: "${RUNNER_TEMP}/cooperative-test/bin/python" \ tools/test_renpy_mobile_cooperative.py --renpy-src _renpy-source + - name: Checkout pinned native build tasks for platform configuration tests + uses: actions/checkout@v6 + with: + repository: renpy/renpy-build + ref: 7bfab40c1174f622f644b24669afd5fb167fbb79 + path: _renpy-build-source + persist-credentials: false + - name: Validate Darwin and Linux task configuration boundaries + run: | + set -euo pipefail + "${RUNNER_TEMP}/cooperative-test/bin/pip" install Jinja2==3.1.6 + "${RUNNER_TEMP}/cooperative-test/bin/python" \ + tools/test_renpy_mobile_darwin_build.py _renpy-build-source + - name: Compile the lifecycle implementation against Python and SDL2 run: | set -euo pipefail diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch index 49b9b0d64..371a6dfcf 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch @@ -1,9 +1,9 @@ diff --git a/patches/SDL2-2.0.20/aether-pbuffer-metalangle.patch b/patches/SDL2-2.0.20/aether-pbuffer-metalangle.patch new file mode 100644 -index 0000000..6de8626 +index 0000000..452e728 --- /dev/null +++ b/patches/SDL2-2.0.20/aether-pbuffer-metalangle.patch -@@ -0,0 +1,144 @@ +@@ -0,0 +1,185 @@ +diff --git a/configure.ac b/configure.ac +index 3c1c6c8..3fa0bde 100644 +--- a/configure.ac @@ -80,6 +80,47 @@ index 0000000..6de8626 + #endif + } + ++diff --git a/src/misc/ios/SDL_aetherdevice.m b/src/misc/ios/SDL_aetherdevice.m ++new file mode 100644 ++index 0000000..84dae52 ++--- /dev/null +++++ b/src/misc/ios/SDL_aetherdevice.m ++@@ -0,0 +1,35 @@ +++/* +++ Simple DirectMedia Layer +++ Copyright (C) 1997-2022 Sam Lantinga +++ +++ This software is provided 'as-is', without any express or implied +++ warranty. In no event will the authors be held liable for any damages +++ arising from the use of this software. +++ +++ Permission is granted to anyone to use this software for any purpose, +++ including commercial applications, and to alter it and redistribute it +++ freely, subject to the following restrictions: +++ +++ 1. The origin of this software must not be misrepresented; you must not +++ claim that you wrote the original software. If you use this software +++ in a product, an acknowledgment in the product documentation would be +++ appreciated but is not required. +++ 2. Altered source versions must be plainly marked as such, and must not be +++ misrepresented as being the original software. +++ 3. This notice may not be removed or altered from any source distribution. +++*/ +++/* Altered for embedded Ren'Py: preserve the actual platform idiom query +++ * without including UIKit video classes or creating a second UIWindow. */ +++#include "../../SDL_internal.h" +++#include "SDL_stdinc.h" +++ +++#if defined(__IPHONEOS__) && SDL_VIDEO_DRIVER_OFFSCREEN && !SDL_VIDEO_DRIVER_UIKIT +++#import +++ +++SDL_bool SDL_IsIPad(void) +++{ +++ @autoreleasepool { +++ return [[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPad ? SDL_TRUE : SDL_FALSE; +++ } +++} +++#endif +diff --git a/src/video/SDL_egl.c b/src/video/SDL_egl.c +index 62004e3..9c3f2b3 100644 +--- a/src/video/SDL_egl.c diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch index a501b0fd9..ac874c3f7 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch @@ -93,6 +93,18 @@ diff --git a/renpybuild/run.py b/renpybuild/run.py c.var("cmake_system_name", "Darwin") c.var("cmake_system_processor", "x86_64") +@@ -431,6 +458,11 @@ + c.env("CXXFLAGS", "{{ CFLAGS }}") + + c.var("cmake_args", "-G Ninja {{ cmake_args }} -DCMAKE_PROJECT_INCLUDE_BEFORE={{root}}/tools/cmake_build_variables.cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_BUILD_PARALLEL_LEVEL=" + str(cpuccount)) ++ if c.native_darwin: ++ # The pinned libyuv project predates CMake 4's policy baseline. ++ # Set the external compatibility policy while preserving its sources. ++ c.var("cmake_args", "{{ cmake_args }} -DCMAKE_POLICY_VERSION_MINIMUM=3.5") ++ + + # Used by zlib. + if c.kind != "host": diff --git a/tasks/toolchain.py b/tasks/toolchain.py --- a/tasks/toolchain.py +++ b/tasks/toolchain.py @@ -120,7 +132,7 @@ diff --git a/tasks/toolchain.py b/tasks/toolchain.py @task(kind="cross", platforms="ios", archs="sim-arm64,sim-x86_64") -@@ -78,8 +84,12 @@ +@@ -78,12 +84,21 @@ c.clean("{{ cross }}") c.chdir("{{ cross }}") @@ -135,6 +147,15 @@ diff --git a/tasks/toolchain.py b/tasks/toolchain.py @task(platforms="ios") + def mockrt(c: Context): ++ if sys.platform == "darwin": ++ # Native Xcode supplies the real runtime availability implementation. ++ # Never package the Linux cross-build's always-available shim. ++ c.unlink("{{ install }}/lib/libmockrt.a") ++ return + c.clean() + c.run("{{ CC }} {{ CFLAGS }} -c {{ source }}/mockrt.c") + c.run("mkdir -p {{ install }}/lib") diff --git a/tasks/hostpython3.py b/tasks/hostpython3.py --- a/tasks/hostpython3.py +++ b/tasks/hostpython3.py diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0008-android-export-isolation.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0008-android-export-isolation.patch new file mode 100644 index 000000000..dea0fb4a7 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0008-android-export-isolation.patch @@ -0,0 +1,34 @@ +diff --git a/tasks/renpython.py b/tasks/renpython.py +index 3bf1a3a..afe13e5 100644 +--- a/tasks/renpython.py ++++ b/tasks/renpython.py +@@ -135,12 +135,29 @@ def link_linux(c: Context): + @task(kind="python", always=True, platforms="android") + def link_android(c: Context): + ++ # Ren'Py statically links its own SDL2/FFmpeg/CPython. Bsymbolic protects ++ # internal calls; restrict exports so later host libraries cannot bind to ++ # these private implementations. JNI registration/Java entrypoints remain ++ # visible, including SDL's JNI_OnLoad and pyjnius invocation handlers. ++ c.generate_text(""" ++{ ++ global: ++ renpy_mobile_*; ++ JNI_OnLoad; ++ Java_*; ++ local: ++ *; ++}; ++""", "aether_mobile.exports") ++ + c.run(""" + {{ CXX }} {{ LDFLAGS }} + -shared + -static-libstdc++ + -Wl,-Bsymbolic + -Wl,--no-undefined ++ -Wl,--version-script=aether_mobile.exports ++ -Wl,-soname,librenpython.so + + -o librenpython.so + librenpython_android.o diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0009-bounded-build-parallelism.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0009-bounded-build-parallelism.patch new file mode 100644 index 000000000..7c7cbd0e6 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0009-bounded-build-parallelism.patch @@ -0,0 +1,86 @@ +diff --git a/renpybuild/run.py b/renpybuild/run.py +--- a/renpybuild/run.py ++++ b/renpybuild/run.py +@@ -4,7 +4,7 @@ + import subprocess + import sys + import sysconfig +-import threading ++from concurrent.futures import ThreadPoolExecutor + + import jinja2 + +@@ -441,10 +441,9 @@ + traceback.print_stack() + sys.exit(1) + +-class RunCommand(threading.Thread): +- +- def __init__(self, command, context): +- super().__init__() ++class RunCommand: ++ ++ def __init__(self, command, context, executor): + + command = context.expand(command) + self.command = shlex.split(command) +@@ -452,7 +451,7 @@ + self.cwd = context.cwd + self.environ = context.environ.copy() + +- self.start() ++ self.future = executor.submit(self.run) + + + def run(self): +@@ -461,7 +460,7 @@ + self.code = result.returncode + + def wait(self): +- self.join() ++ self.future.result() + + def report(self): + print ("-" * 78) +@@ -485,16 +484,32 @@ + def __init__(self, context): + self.context = context + self.tasks = [ ] ++ requested = context.environ.get("RENPY_BUILD_JOBS") ++ if requested is None: ++ jobs = os.cpu_count() or 1 ++ else: ++ try: ++ jobs = int(requested) ++ except ValueError: ++ raise ValueError("RENPY_BUILD_JOBS must be a positive integer") from None ++ if jobs < 1: ++ raise ValueError("RENPY_BUILD_JOBS must be a positive integer") ++ self.executor = ThreadPoolExecutor(max_workers=jobs) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): ++ try: ++ if exc_type is None: ++ for i in self.tasks: ++ i.wait() ++ finally: ++ # Join every running compiler, including body/spawn failures. ++ # Work not yet started is cancelled rather than left in the queue. ++ self.executor.shutdown(wait=True, cancel_futures=True) + if exc_type is not None: + return +- +- for i in self.tasks: +- i.wait() + + good = [ i for i in self.tasks if i.code == 0 ] + bad = [ i for i in self.tasks if i.code != 0 ] +@@ -511,4 +526,4 @@ + sys.exit(1) + + def run(self, command): +- self.tasks.append(RunCommand(command, self.context)) ++ self.tasks.append(RunCommand(command, self.context, self.executor)) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index 3bb1fb055..33afc607d 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -28,6 +28,12 @@ tools, all three local SDKs and 64 GiB of free disk; it does not substitute prerequisite fixtures. The port still needs a successful native macOS build and simulator gameplay before it establishes iOS runtime support. +`0009-bounded-build-parallelism.patch` limits the upstream native object build +group to the host CPU count. Set `RENPY_BUILD_JOBS` to a positive integer to +select another limit. Child compiler failures still fail the build, and an +exception cancels queued work and joins running child processes. This limits +concurrent compiler memory use; it does not change source-build prerequisites. + `renpy_mobile_lifecycle.c` owns the copied host callback table and the borrowed frame byte storage. It invokes the full cooperative Python entrypoints, preserves pointer/key/text actions, publishes renderer screenshots and diff --git a/tools/renpy_ios_egl_probe/run_sdl_probe.sh b/tools/renpy_ios_egl_probe/run_sdl_probe.sh index 7b9c9154b..ed8f9725c 100755 --- a/tools/renpy_ios_egl_probe/run_sdl_probe.sh +++ b/tools/renpy_ios_egl_probe/run_sdl_probe.sh @@ -100,7 +100,7 @@ xcrun nm -a "$output/sdl-install/lib/libSDL2.a" > "$output/evidence/sdl-symbols. python3 - "$output/evidence/sdl-symbols.txt" <<'PY' import pathlib, re, sys symbols = pathlib.Path(sys.argv[1]).read_text() -collisions = re.findall(r'OBJC_CLASS_\$_(?:SDL_\w+|SDLInterruptionListener)\b', symbols) +collisions = re.findall(r'OBJC_CLASS_\$_SDL\w+\b', symbols) if collisions: raise SystemExit('RenPy SDL still defines host-conflicting Objective-C classes: ' + ', '.join(sorted(set(collisions)))) if 'OBJC_CLASS_$_AetherRenpySDLInterruptionListener' not in symbols: diff --git a/tools/test_renpy_mobile_build_parallelism.py b/tools/test_renpy_mobile_build_parallelism.py new file mode 100644 index 000000000..78dc3abba --- /dev/null +++ b/tools/test_renpy_mobile_build_parallelism.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Exercise patched upstream RunGroup using real host subprocesses. + +These are compiler scheduling/error-boundary tests, not a native runtime build +or gameplay. No SDK or compilation prerequisite is mocked as successful. +""" +import ast +from concurrent.futures import ThreadPoolExecutor +import contextlib +import io +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile +import time +from types import SimpleNamespace +from unittest.mock import patch + +REPO = Path(__file__).resolve().parents[1] +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" + + +def main(): + checkout = Path(sys.argv[1]) + assert subprocess.check_output(["git", "-C", str(checkout), "rev-parse", "HEAD"], text=True).strip() == PIN + with tempfile.TemporaryDirectory(prefix="renpy-build-parallelism-") as temporary: + root = Path(temporary) + run_file = root / "renpybuild/run.py" + run_file.parent.mkdir() + run_file.write_bytes(subprocess.check_output(["git", "-C", str(checkout), "show", "HEAD:renpybuild/run.py"])) + build_patch = REPO / "bridge/renpy_runtime/mobile_launcher/patches/native/0009-bounded-build-parallelism.patch" + subprocess.run(["git", "apply", "--check", str(build_patch)], cwd=root, check=True) + subprocess.run(["git", "apply", str(build_patch)], cwd=root, check=True) + tree = ast.parse(run_file.read_text()) + classes = [node for node in tree.body if isinstance(node, ast.ClassDef) and node.name in ("RunCommand", "RunGroup")] + namespace = dict(os=os, shlex=shlex, subprocess=subprocess, sys=sys, ThreadPoolExecutor=ThreadPoolExecutor) + exec(compile(ast.Module(body=classes, type_ignores=[]), str(run_file), "exec"), namespace) + RunGroup = namespace["RunGroup"] + + def context(jobs=None): + environment = dict(os.environ) + environment.pop("RENPY_BUILD_JOBS", None) + if jobs is not None: + environment["RENPY_BUILD_JOBS"] = jobs + return SimpleNamespace(cwd=root, environ=environment, expand=lambda command: command) + + def command(code): + return shlex.join([sys.executable, "-c", code]) + + state = root / "state.json" + state.write_text(json.dumps(dict(running=0, peak=0, finished=0))) + child = """ +import fcntl,json,pathlib,time +state=pathlib.Path('state.json') +def update(delta): + with state.open('r+') as stream: + fcntl.flock(stream,fcntl.LOCK_EX) + data=json.load(stream) + data['running']+=delta + data['peak']=max(data['peak'],data['running']) + data['finished']+=int(delta<0) + stream.seek(0) + json.dump(data,stream) + stream.truncate() +update(1) +time.sleep(0.08) +update(-1) +""" + output = io.StringIO() + with contextlib.redirect_stdout(output), RunGroup(context("2")) as group: + for _ in range(8): + group.run(command(child)) + data = json.loads(state.read_text()) + assert data == dict(running=0, peak=2, finished=8), data + assert all(task.future.done() for task in group.tasks) + with patch.object(os, "cpu_count", lambda: 3): + with RunGroup(context()) as default_group: + assert default_group.executor._max_workers == 3 + for invalid in ("0", "-1", "invalid", "1.5", ""): + try: + RunGroup(context(invalid)) + except ValueError as error: + assert "positive integer" in str(error) + else: + raise AssertionError("invalid job limit accepted: " + repr(invalid)) + + output = io.StringIO() + try: + with contextlib.redirect_stdout(output), RunGroup(context("2")) as failed: + failed.run(command("import sys; print('actual child failure'); sys.exit(7)")) + failed.run(command("print('actual child success')")) + except SystemExit as error: + assert error.code == 1 + else: + raise AssertionError("failed compiler process was reported as success") + assert "Process failed with 7" in output.getvalue(), output.getvalue() + assert sorted(task.code for task in failed.tasks) == [0, 7] + assert all(task.future.done() for task in failed.tasks) + + try: + with RunGroup(context("1")) as missing: + missing.run(shlex.quote(str(root / "nonexistent-compiler"))) + except FileNotFoundError: + pass + else: + raise AssertionError("compiler spawn error was swallowed") + assert missing.executor._shutdown + + started = root / "started" + finished = root / "finished" + unexpected = root / "unexpected" + try: + with RunGroup(context("1")) as interrupted: + interrupted.run(command("import pathlib,time; pathlib.Path('started').touch(); time.sleep(0.2); pathlib.Path('finished').touch()")) + deadline = time.monotonic() + 5 + while not started.exists() and time.monotonic() < deadline: + time.sleep(0.005) + assert started.exists(), "real child did not start" + interrupted.run(command("import pathlib; pathlib.Path('unexpected').touch()")) + raise RuntimeError("actual build-body failure") + except RuntimeError as error: + assert str(error) == "actual build-body failure" + assert finished.exists(), "running child was left behind after the build-body exception" + assert not unexpected.exists(), "queued child executed after cancellation" + assert all(task.future.done() for task in interrupted.tasks) + assert interrupted.tasks[1].future.cancelled() + print("Real subprocess concurrency, failures and cleanup passed; no native runtime build or gameplay performed") + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_darwin_build.py b/tools/test_renpy_mobile_darwin_build.py index 2dc643aea..774a5d1d5 100644 --- a/tools/test_renpy_mobile_darwin_build.py +++ b/tools/test_renpy_mobile_darwin_build.py @@ -70,6 +70,7 @@ def xcrun(arguments, **_kwargs): assert "-lmockrt" not in context.environ["LDFLAGS"] assert "/fixture/openssl" not in context.environ["LDFLAGS"], "host libraries leaked into target linking" assert context.variables["lipo"] == "xcrun lipo" + assert "-DCMAKE_POLICY_VERSION_MINIMUM=3.5" in context.variables["cmake_args"] host_generation = Context("ios", architecture, "3", root, SimpleNamespace()) host_generation.set_names("host-python", "gen_static3", "librenpy") assert host_generation.install == root / "tmp" / ("install.ios-" + architecture) @@ -89,12 +90,25 @@ def xcrun(arguments, **_kwargs): toolchain(cross) assert cross.path("{{ cross }}/sdk").is_symlink() assert cross.path("{{ cross }}/sdk").samefile(root / "local-sdk/iphonesimulator") - with patch.object(run.sys, "platform", "linux"): + shim_task = task_function(root / "tasks/toolchain.py", "mockrt", namespace) + shim_context = Context("ios", "arm64", "3", root, SimpleNamespace()) + shim_context.set_names("arch", "mockrt", "toolchain") + obsolete_shim = shim_context.path("{{ install }}/lib/libmockrt.a") + obsolete_shim.parent.mkdir(parents=True, exist_ok=True) + obsolete_shim.touch() + def reject_shim_compile(command): + raise AssertionError("native Xcode build attempted the Linux runtime shim: " + command) + shim_context.run = reject_shim_compile + shim_task(shim_context) + assert not obsolete_shim.exists() + with patch.object(run.sys, "platform", "linux"), \ + patch.object(run.sysconfig, "get_config_var", lambda _name: "x86_64-pc-linux-gnu"): context = Context("android", "arm64_v8a", "3", root, SimpleNamespace()) context.set_names("python", "build", "renpython") assert "aarch64-linux-android21-clang" in context.environ["CC"] assert "linux-x86_64" in context.environ["CC"] assert not context.native_darwin + assert "CMAKE_POLICY_VERSION_MINIMUM" not in context.variables["cmake_args"] print("Darwin iOS and Linux Android task configuration passed; no native build or gameplay performed") diff --git a/tools/test_renpy_mobile_native_fork.sh b/tools/test_renpy_mobile_native_fork.sh index 458f7f59a..505835ff9 100644 --- a/tools/test_renpy_mobile_native_fork.sh +++ b/tools/test_renpy_mobile_native_fork.sh @@ -35,6 +35,7 @@ if [[ -n "${RENPY_BUILD_TEST_ROOT:-}" && -d "${RENPY_BUILD_TEST_ROOT}/.git" ]]; git -C "$RENPY_BUILD_TEST_ROOT" apply --reverse --check "$patch" done python3 "$repo_root/tools/test_renpy_mobile_optional_sdk.py" "$RENPY_BUILD_TEST_ROOT" + python3 "$repo_root/tools/test_renpy_mobile_build_parallelism.py" "$RENPY_BUILD_TEST_ROOT" fi bash "$repo_root/tools/test_renpy_demo_fixture.sh" printf '%s\n' 'RenPy native source/demo fixture checks passed; no gameplay performed' diff --git a/tools/validate_renpy_mobile_payload.py b/tools/validate_renpy_mobile_payload.py index 20f307028..eb7413013 100755 --- a/tools/validate_renpy_mobile_payload.py +++ b/tools/validate_renpy_mobile_payload.py @@ -135,6 +135,12 @@ def validate_library(library, platform, arch, sdk, executable=False, packaged=Fa else: symbols = {line.split()[-1].lstrip("_") for line in listing.splitlines() if line.split() and " U " not in line} + if platform == "android": + if "JNI_OnLoad" not in symbols: + fail(f"Android Ren'Py payload lacks the SDL/JVM registration entrypoint JNI_OnLoad: {library}") + leaked = {name for name in symbols if name.startswith(("SDL_", "avcodec_", "avformat_", "Py_", "_Py_"))} + if leaked: + fail(f"Ren'Py private implementations must not be exported into the host: {', '.join(sorted(leaked)[:12])}: {library}") missing = set(LIFECYCLE_SYMBOLS) - symbols if missing: fail(f"official blocking or incomplete Ren'Py payload: missing {', '.join(sorted(missing))}: {library}") From 74c14f8882f7f3551f6e2f5470d87d89ec5b9f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 13:36:20 +0000 Subject: [PATCH 258/331] feat(renpy): package isolated iOS runtime and exercise real Simulator input Enable iOS startup only with the compiled MetalANGLE offscreen driver, build and embed an isolated RenPy framework, and install the actual Simulator app for XCUITest gameplay acceptance. Verify complete patch-series postimages so prepare and build remain idempotent, and trigger actual Android source builds independently. Mobile gameplay remains unverified until cloud execution passes. --- .github/workflows/build.yml | 7 +- .github/workflows/renpy-build-diagnostics.yml | 52 +- .github/workflows/renpy-ios-runtime-build.yml | 151 +++++- .github/workflows/renpy-mobile-contract.yml | 8 + .../workflows/renpy-mobile-runtime-build.yml | 12 +- apps/godot_app/aether_kiri.gdextension | 2 + apps/godot_app/export_presets.cfg | 72 +++ apps/godot_app/project.godot | 1 + apps/godot_app/scripts/main.gd | 33 ++ .../scripts/renpy_mobile_acceptance.gd | 16 +- .../patches/native/renpy_mobile_lifecycle.c | 27 +- .../src/renpy_runtime_mobile_provider.cpp | 5 - scripts/build_ios.sh | 251 +++++---- tools/apply_renpy_mobile_patch_series.py | 84 +++ tools/patch_renpy_ios_runtime_project.py | 53 ++ tools/renpy_ios_acceptance/README.md | 57 ++ .../RenPyAcceptance.xcodeproj/project.pbxproj | 52 ++ .../xcschemes/RenPyAcceptance.xcscheme | 18 + .../RenPyGameplayTests.swift | 92 ++++ tools/renpy_ios_egl_probe/run_sdl_probe.sh | 7 +- tools/run_renpy_ios_device_acceptance.py | 486 ++++++++++++++++++ tools/run_renpy_mobile_source_build.sh | 20 +- tools/test_renios_ios_bundle.sh | 1 + tools/test_renpy_ios_runtime_project.py | 80 +++ tools/test_renpy_mobile_patch_series.py | 68 +++ tools/test_renpy_mobile_source_build_plan.sh | 2 +- 26 files changed, 1523 insertions(+), 134 deletions(-) create mode 100644 tools/apply_renpy_mobile_patch_series.py create mode 100755 tools/patch_renpy_ios_runtime_project.py create mode 100644 tools/renpy_ios_acceptance/README.md create mode 100644 tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj/project.pbxproj create mode 100644 tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj/xcshareddata/xcschemes/RenPyAcceptance.xcscheme create mode 100644 tools/renpy_ios_acceptance/RenPyGameplayTests.swift create mode 100755 tools/run_renpy_ios_device_acceptance.py create mode 100644 tools/test_renpy_ios_runtime_project.py create mode 100644 tools/test_renpy_mobile_patch_series.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3a3b1259b..0f26dbb30 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -81,7 +81,7 @@ jobs: android: name: Build Android App needs: renpy-android-runtime - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' CCACHE_DIR: ${{ github.workspace }}/.ccache @@ -164,6 +164,11 @@ jobs: sudo apt-get install -y cmake ninja-build pkg-config autoconf autoconf-archive automake libtool gettext bison flex curl unzip zip zstd ccache jq mono-complete rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android + - name: Use Python matching the rebuilt runtime's bytecode + uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Download the rebuilt Ren'Py Android runtime uses: actions/download-artifact@v6 with: diff --git a/.github/workflows/renpy-build-diagnostics.yml b/.github/workflows/renpy-build-diagnostics.yml index 3e7bd7674..a07166f6d 100644 --- a/.github/workflows/renpy-build-diagnostics.yml +++ b/.github/workflows/renpy-build-diagnostics.yml @@ -15,30 +15,50 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - - name: Read the actual failed Android source-build log - env: - GH_TOKEN: ${{ github.token }} + - name: Download the actual failed source-build evidence + uses: actions/download-artifact@v6 + with: + repository: AetherKiri/Aether + run-id: 37776651799 + name: renpy-source-build-evidence + github-token: ${{ github.token }} + path: ${{ runner.temp }}/renpy-android-source-evidence + - name: Read a sanitized summary of the original build logs run: | set -euo pipefail - gh api 'repos/AetherKiri/Aether/actions/jobs/113309300220/logs' \ - > "$RUNNER_TEMP/renpy-android-actual-build.log" - python3 - "$RUNNER_TEMP/renpy-android-actual-build.log" <<'PY' + python3 - "$RUNNER_TEMP/renpy-android-source-evidence" <<'PY' import pathlib, re, sys - lines = pathlib.Path(sys.argv[1]).read_text(errors='replace').splitlines() - print('Reading the actual job 113309300220 log; this does not rerun or simulate the build.') - interesting = [i for i, line in enumerate(lines) - if re.search(r'error:|fatal error|FAILED|Error [1-9]|undefined reference|not found|Traceback|No such file|exception', line, re.I)] - indices = set(range(max(0, len(lines)-110), len(lines))) - for i in interesting[-16:]: - indices.update(range(max(0, i-4), min(len(lines), i+9))) - for i in sorted(indices): - print(lines[i]) + logs = sorted(pathlib.Path(sys.argv[1]).rglob('*.log')) + if not logs: + raise SystemExit('The genuine source-build evidence contains no log files.') + print('Reading preserved evidence from actual job 113309300220; no build is rerun or simulated.') + remaining = 24000 + for log in logs: + text = log.read_text(errors='replace') + text = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', text) + text = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', text) + text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text) + lines = text.splitlines() + interesting = [i for i, line in enumerate(lines) + if re.search(r'error:|fatal error|FAILED|Error [1-9]|undefined reference|not found|Traceback|No such file|exception|Killed|process failed|tasks failed', line, re.I)] + indices = set(range(max(0, len(lines)-110), len(lines))) + for i in interesting[-16:]: + indices.update(range(max(0, i-4), min(len(lines), i+9))) + summary = '\n'.join(lines[i] for i in sorted(indices)) + print(f'Original log: {log.name}; {len(lines)} lines') + if len(summary) > remaining: + summary = summary[-remaining:] + print(summary) + remaining -= len(summary) + if remaining <= 0: + print('Summary reached its display limit; complete original logs remain in the artifact.') + break PY - name: Preserve the original build log if: always() uses: actions/upload-artifact@v6 with: name: renpy-android-actual-build-log - path: ${{ runner.temp }}/renpy-android-actual-build.log + path: ${{ runner.temp }}/renpy-android-source-evidence if-no-files-found: warn retention-days: 7 diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index 7faff26bf..49ebd71cb 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -5,8 +5,15 @@ on: paths: - '.github/workflows/renpy-ios-runtime-build.yml' - 'tools/run_renpy_mobile_source_build.sh' + - 'tools/apply_renpy_mobile_patch_series.py' - 'tools/validate_renpy_mobile_payload.py' + - 'tools/renpy_ios_acceptance/**' + - 'tools/run_renpy_ios_device_acceptance.py' + - 'tools/patch_renpy_ios_runtime_project.py' + - 'scripts/build_ios.sh' - 'bridge/renpy_runtime/mobile_launcher/**' + - 'bridge/renpy_runtime/src/**' + - 'apps/godot_app/**' workflow_call: workflow_dispatch: @@ -18,6 +25,33 @@ concurrency: cancel-in-progress: false jobs: + ios-test-driver: + name: Compile the actual XCUITest input driver + runs-on: macos-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Build the Swift UI test runner with the real Simulator SDK + run: | + set -euo pipefail + xcodebuild build-for-testing \ + -project tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj \ + -scheme RenPyAcceptance -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$RUNNER_TEMP/renpy-ui-driver" CODE_SIGN_IDENTITY=- \ + 2>&1 | tee "$RUNNER_TEMP/renpy-ui-driver-build.log" + echo 'Swift runner compiled; this step does not execute RenPy gameplay.' + - name: Preserve the actual Swift build evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-ui-driver-build-evidence + path: ${{ runner.temp }}/renpy-ui-driver-build.log + if-no-files-found: error + retention-days: 7 + ios-runtime: name: Compile Ren'Py with this cloud runner's Xcode SDKs runs-on: macos-latest @@ -34,7 +68,7 @@ jobs: uses: actions/cache@v6 with: path: out/renpy-ios-source - key: ${{ runner.os }}-${{ runner.arch }}-renpy-ios-runtime-v1-${{ hashFiles('tools/run_renpy_mobile_source_build.sh', 'bridge/renpy_runtime/mobile_launcher/build.sh', 'bridge/renpy_runtime/mobile_launcher/include/*.h', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.patch', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.c', 'bridge/renpy_runtime/mobile_launcher/patches/python/*.patch') }} + key: ${{ runner.os }}-${{ runner.arch }}-renpy-ios-runtime-v1-${{ hashFiles('tools/run_renpy_mobile_source_build.sh', 'tools/apply_renpy_mobile_patch_series.py', 'bridge/renpy_runtime/mobile_launcher/build.sh', 'bridge/renpy_runtime/mobile_launcher/include/*.h', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.patch', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.c', 'bridge/renpy_runtime/mobile_launcher/patches/python/*.patch') }} - name: Provision native macOS prerequisites and record the actual SDKs if: steps.runtime-cache.outputs.cache-hit != 'true' run: | @@ -74,8 +108,8 @@ jobs: bash tools/run_renpy_mobile_source_build.sh --mode prepare --fetch \ --platform ios --renpy-build "$RENPY_BUILD_ROOT" \ --output-dir "$RENPY_OUTPUT_ROOT" 2>&1 | tee "$RUNNER_TEMP/renpy-ios-source-prepare.log" - uv --project "$RENPY_BUILD_ROOT/renpy" run python \ - tools/test_renpy_mobile_darwin_build.py "$RENPY_BUILD_ROOT" \ + uv --project "$RENPY_BUILD_ROOT/renpy" run python -m compileall -q \ + "$RENPY_BUILD_ROOT/renpybuild" "$RENPY_BUILD_ROOT/tasks" \ 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" - name: Compile target CPython, SDL, Ren'Py and device/Simulator archives if: steps.runtime-cache.outputs.cache-hit != 'true' @@ -123,3 +157,114 @@ jobs: out/renpy-ios-source/manifest.json if-no-files-found: warn retention-days: 7 + + ios-simulator-gameplay: + name: Build and exercise the actual Ren'Py Simulator app + needs: [ios-runtime, ios-test-driver] + runs-on: macos-latest + timeout-minutes: 180 + permissions: + contents: read + packages: read + env: + AETHERKIRI_ENABLE_RENPY: ON + AETHERKIRI_ENABLE_INTERNAL: OFF + AETHERKIRI_VCPKG_CACHE_WRITE: 'false' + GODOT_RELEASE: 4.7.2-stable + GODOT_TEMPLATE_VERSION: 4.7.2.stable + JOBS: '3' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Initialize the public application dependencies + run: | + set -euo pipefail + git submodule update --init --recursive --depth 1 \ + packages/OnscripterYuri packages/psdfile packages/AetherSiglus \ + packages/AetherMinori packages/AetherKrkr + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios-sim + - name: Install application build tools + run: | + set -euo pipefail + brew install cmake ninja pkg-config autoconf autoconf-archive automake \ + libtool gettext coreutils bison nasm ccache mono python@3.12 + echo "$(brew --prefix gettext)/bin" >> "$GITHUB_PATH" + echo "$(brew --prefix bison)/bin" >> "$GITHUB_PATH" + echo "$(brew --prefix python@3.12)/libexec/bin" >> "$GITHUB_PATH" + xcodebuild -version + xcrun --sdk iphonesimulator --show-sdk-version + - uses: actions/download-artifact@v6 + with: + name: renpy-ios-runtime + path: ${{ runner.temp }}/renpy-ios-runtime + - name: Stage the genuine same-run Ren'Py payload + run: | + set -euo pipefail + mobile_root="$RUNNER_TEMP/renpy-ios-gameplay-runtime" + mkdir -p "$mobile_root" + tar -xzf "$RUNNER_TEMP/renpy-ios-runtime/renpy-ios-runtime.tar.gz" -C "$mobile_root" + echo "AETHERKIRI_RENPY_MOBILE_ROOT=$mobile_root/payload" >> "$GITHUB_ENV" + - name: Install the pinned Godot editor and real export templates + run: | + set -euo pipefail + mkdir -p .godot "$RUNNER_TEMP/godot-templates" + curl --fail --location --retry 3 -o "$RUNNER_TEMP/godot-macos.zip" \ + "https://github.com/godotengine/godot-builds/releases/download/$GODOT_RELEASE/Godot_v${GODOT_RELEASE}_macos.universal.zip" + unzip -q "$RUNNER_TEMP/godot-macos.zip" -d .godot + curl --fail --location --retry 3 -o "$RUNNER_TEMP/godot-templates.tpz" \ + "https://github.com/godotengine/godot-builds/releases/download/$GODOT_RELEASE/Godot_v${GODOT_RELEASE}_export_templates.tpz" + unzip -q "$RUNNER_TEMP/godot-templates.tpz" -d "$RUNNER_TEMP/godot-templates" + template_dir="$HOME/Library/Application Support/Godot/export_templates/$GODOT_TEMPLATE_VERSION" + mkdir -p "$template_dir" + cp -a "$RUNNER_TEMP/godot-templates/templates/." "$template_dir/" + echo "GODOT_BIN=$GITHUB_WORKSPACE/.godot/Godot.app/Contents/MacOS/Godot" >> "$GITHUB_ENV" + echo "GODOT_EXPORT_TEMPLATE=$template_dir/ios.zip" >> "$GITHUB_ENV" + - name: Prepare the normal vcpkg binary cache + env: + VCPKG_PACKAGES_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git clone https://github.com/microsoft/vcpkg.git .devtools/vcpkg + bash .devtools/vcpkg/bootstrap-vcpkg.sh -disableMetrics + echo "VCPKG_ROOT=$GITHUB_WORKSPACE/.devtools/vcpkg" >> "$GITHUB_ENV" + bash .github/scripts/configure-vcpkg-binary-cache.sh "$GITHUB_WORKSPACE/.devtools/vcpkg/vcpkg" + - name: Build the installable arm64 Simulator application + run: | + set -euo pipefail + bash scripts/build_ios.sh debug --simulator --simulator-arch=arm64 \ + 2>&1 | tee "$RUNNER_TEMP/renpy-ios-simulator-build.log" + - name: Install and execute real gameplay with XCUITest + run: | + set -euo pipefail + python3 tools/run_renpy_ios_device_acceptance.py --cloud-simulator \ + --app out/godot/ios-simulator-arm64/debug/build-simulator-arm64/Aether.app \ + --output-dir out/renpy-ios-gameplay --overall-timeout 600 + - name: Preserve actual Simulator gameplay and build evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-gameplay-evidence + path: | + out/renpy-ios-gameplay + ${{ runner.temp }}/renpy-ios-simulator-build.log + if-no-files-found: warn + retention-days: 7 + - name: Archive the actual Simulator application preserving executable permissions + if: always() + run: | + set -euo pipefail + app=out/godot/ios-simulator-arm64/debug/build-simulator-arm64/Aether.app + if [[ -d "$app" ]]; then + ditto -c -k --sequesterRsrc --keepParent "$app" "$RUNNER_TEMP/Aether-renpy-simulator.app.zip" + fi + - name: Preserve the installable Simulator application + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-simulator-app + path: ${{ runner.temp }}/Aether-renpy-simulator.app.zip + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/renpy-mobile-contract.yml b/.github/workflows/renpy-mobile-contract.yml index bae19d1d7..b29bc92fd 100644 --- a/.github/workflows/renpy-mobile-contract.yml +++ b/.github/workflows/renpy-mobile-contract.yml @@ -15,9 +15,13 @@ on: - 'tools/test_renpy_mobile_launcher_*.sh' - 'tools/test_renpy_mobile_symbol_gate.sh' - 'tools/run_renpy_mobile_source_build.sh' + - 'tools/apply_renpy_mobile_patch_series.py' + - 'tools/test_renpy_mobile_patch_series.py' - 'tools/test_renpy_mobile_source_build_plan.sh' - 'tools/test_renpy_mobile_native_fork.sh' - 'tools/test_renpy_mobile_cooperative.py' + - 'tools/test_renpy_mobile_darwin_build.py' + - 'tools/test_renpy_mobile_build_parallelism.py' - 'tools/validate_renpy_mobile_payload.py' - 'tools/test_renpy_mobile_packaging.sh' - 'tools/test_renpy_mobile_runtime_protocol.sh' @@ -96,6 +100,10 @@ jobs: "${RUNNER_TEMP}/cooperative-test/bin/pip" install Jinja2==3.1.6 "${RUNNER_TEMP}/cooperative-test/bin/python" \ tools/test_renpy_mobile_darwin_build.py _renpy-build-source + "${RUNNER_TEMP}/cooperative-test/bin/python" \ + tools/test_renpy_mobile_build_parallelism.py _renpy-build-source + "${RUNNER_TEMP}/cooperative-test/bin/python" \ + tools/test_renpy_mobile_patch_series.py _renpy-build-source - name: Compile the lifecycle implementation against Python and SDL2 run: | diff --git a/.github/workflows/renpy-mobile-runtime-build.yml b/.github/workflows/renpy-mobile-runtime-build.yml index b3edfad9e..1a1f40951 100644 --- a/.github/workflows/renpy-mobile-runtime-build.yml +++ b/.github/workflows/renpy-mobile-runtime-build.yml @@ -1,12 +1,22 @@ name: Build Ren'Py Android runtime on: + pull_request: + paths: + - '.github/workflows/renpy-mobile-runtime-build.yml' + - 'tools/run_renpy_mobile_source_build.sh' + - 'tools/apply_renpy_mobile_patch_series.py' + - 'bridge/renpy_runtime/mobile_launcher/**' workflow_call: workflow_dispatch: permissions: contents: read +concurrency: + group: renpy-android-source-${{ github.event.pull_request.number || github.ref }}-${{ github.workflow }} + cancel-in-progress: false + jobs: android-runtime: name: Compile the pinned Ren'Py lifecycle fork @@ -24,7 +34,7 @@ jobs: uses: actions/cache@v6 with: path: out/renpy-mobile-source - key: ${{ runner.os }}-renpy-android-runtime-v2-${{ hashFiles('tools/run_renpy_mobile_source_build.sh', 'bridge/renpy_runtime/mobile_launcher/build.sh', 'bridge/renpy_runtime/mobile_launcher/include/*.h', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.patch', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.c', 'bridge/renpy_runtime/mobile_launcher/patches/python/*.patch') }} + key: ${{ runner.os }}-renpy-android-runtime-v3-${{ hashFiles('tools/run_renpy_mobile_source_build.sh', 'tools/apply_renpy_mobile_patch_series.py', 'bridge/renpy_runtime/mobile_launcher/build.sh', 'bridge/renpy_runtime/mobile_launcher/include/*.h', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.patch', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.c', 'bridge/renpy_runtime/mobile_launcher/patches/python/*.patch') }} - name: Provision the official Linux build prerequisites if: steps.runtime-cache.outputs.cache-hit != 'true' run: | diff --git a/apps/godot_app/aether_kiri.gdextension b/apps/godot_app/aether_kiri.gdextension index c7b748c43..d8574b4f7 100644 --- a/apps/godot_app/aether_kiri.gdextension +++ b/apps/godot_app/aether_kiri.gdextension @@ -11,6 +11,8 @@ macos.debug = "res://bin/macos/debug/libaether_kiri_godot.dylib" macos.release = "res://bin/macos/release/libaether_kiri_godot.dylib" ios.debug = "res://bin/ios/debug/libaether_kiri_godot.a" ios.release = "res://bin/ios/release/libaether_kiri_godot.a" +ios.ios_simulator.debug = "res://bin/ios-simulator/debug/libaether_kiri_godot.a" +ios.ios_simulator.ios_simulator_x64.debug = "res://bin/ios-simulator-x64/debug/libaether_kiri_godot.a" android.debug.arm64 = "res://bin/android/arm64-v8a/debug/libaether_kiri_godot.so" android.debug.x86_64 = "res://bin/android/x86_64/debug/libaether_kiri_godot.so" android.release.arm64 = "res://bin/android/arm64-v8a/release/libaether_kiri_godot.so" diff --git a/apps/godot_app/export_presets.cfg b/apps/godot_app/export_presets.cfg index 0b780cbfc..7128a63e3 100644 --- a/apps/godot_app/export_presets.cfg +++ b/apps/godot_app/export_presets.cfg @@ -282,3 +282,75 @@ permissions/custom_permissions=PackedStringArray("android.permission.INTERNET", launcher_icons/main_192x192="res://assets/icons/icon_192x192.png" launcher_icons/adaptive_foreground_432x432="res://assets/icons/icon_adaptive_fg.png" launcher_icons/adaptive_background_432x432="res://assets/icons/icon_adaptive_bg.png" + +[preset.11] +name="iOS Simulator Debug" +platform="iOS" +runnable=false +custom_features="ios_simulator" +export_filter="all_resources" +include_filter="builtin_demos/**/*.xp3,builtin_demos/**/*.json,builtin_demos/**/*.txt,legal/*.txt" +exclude_filter="scripts/diagnostic_session_test.gd,scripts/builtin_demo_test.gd,scripts/game_input_mapping_test.gd,tests/**" +export_path="../../out/godot/ios-simulator-arm64/debug/Aether.xcodeproj" + +[preset.11.options] +application/app_store_team_id="3JL7FE9XQT" +application/bundle_identifier="org.aetherkiri.renpy-simulator.debug" +application/short_version="1.0.7" +application/version="4501" +application/min_ios_version="16.0" +application/export_project_only=true +user_data/accessible_from_files_app=true +user_data/accessible_from_itunes_sharing=true +icons/app_store_1024x1024="res://assets/icons/app_store_1024x1024.png" +icons/iphone_120x120="res://assets/icons/icon_120x120.png" +icons/iphone_180x180="res://assets/icons/icon_180x180.png" +icons/ipad_152x152="res://assets/icons/icon_152x152.png" +icons/ipad_167x167="res://assets/icons/icon_167x167.png" +icons/settings_58x58="res://assets/icons/icon_58x58.png" +icons/settings_87x87="res://assets/icons/icon_87x87.png" +icons/spotlight_80x80="res://assets/icons/icon_80x80.png" +icons/spotlight_120x120="res://assets/icons/icon_120x120.png" +icons/notification_40x40="res://assets/icons/icon_40x40.png" +icons/notification_60x60="res://assets/icons/icon_60x60.png" +icons/notification_76x76="res://assets/icons/icon_76x76.png" +icons/notification_114x114="res://assets/icons/icon_114x114.png" +icons/ios_128x128="res://assets/icons/icon_128x128.png" +icons/ios_136x136="res://assets/icons/icon_136x136.png" +icons/ios_192x192="res://assets/icons/icon_192x192.png" + +[preset.12] +name="iOS Simulator x64 Debug" +platform="iOS" +runnable=false +custom_features="ios_simulator,ios_simulator_x64" +export_filter="all_resources" +include_filter="builtin_demos/**/*.xp3,builtin_demos/**/*.json,builtin_demos/**/*.txt,legal/*.txt" +exclude_filter="scripts/diagnostic_session_test.gd,scripts/builtin_demo_test.gd,scripts/game_input_mapping_test.gd,tests/**" +export_path="../../out/godot/ios-simulator-x86_64/debug/Aether.xcodeproj" + +[preset.12.options] +application/app_store_team_id="3JL7FE9XQT" +application/bundle_identifier="org.aetherkiri.renpy-simulator.debug" +application/short_version="1.0.7" +application/version="4501" +application/min_ios_version="16.0" +application/export_project_only=true +user_data/accessible_from_files_app=true +user_data/accessible_from_itunes_sharing=true +icons/app_store_1024x1024="res://assets/icons/app_store_1024x1024.png" +icons/iphone_120x120="res://assets/icons/icon_120x120.png" +icons/iphone_180x180="res://assets/icons/icon_180x180.png" +icons/ipad_152x152="res://assets/icons/icon_152x152.png" +icons/ipad_167x167="res://assets/icons/icon_167x167.png" +icons/settings_58x58="res://assets/icons/icon_58x58.png" +icons/settings_87x87="res://assets/icons/icon_87x87.png" +icons/spotlight_80x80="res://assets/icons/icon_80x80.png" +icons/spotlight_120x120="res://assets/icons/icon_120x120.png" +icons/notification_40x40="res://assets/icons/icon_40x40.png" +icons/notification_60x60="res://assets/icons/icon_60x60.png" +icons/notification_76x76="res://assets/icons/icon_76x76.png" +icons/notification_114x114="res://assets/icons/icon_114x114.png" +icons/ios_128x128="res://assets/icons/icon_128x128.png" +icons/ios_136x136="res://assets/icons/icon_136x136.png" +icons/ios_192x192="res://assets/icons/icon_192x192.png" diff --git a/apps/godot_app/project.godot b/apps/godot_app/project.godot index 1af73f0f9..77c5d78f7 100644 --- a/apps/godot_app/project.godot +++ b/apps/godot_app/project.godot @@ -51,3 +51,4 @@ renderer/rendering_method="mobile" textures/vram_compression/import_etc2_astc=true renderer/rendering_method.android="mobile" renderer/rendering_method.renpy_emulator="gl_compatibility" +renderer/rendering_method.ios_simulator="gl_compatibility" diff --git a/apps/godot_app/scripts/main.gd b/apps/godot_app/scripts/main.gd index decb26dc3..b87b929c5 100644 --- a/apps/godot_app/scripts/main.gd +++ b/apps/godot_app/scripts/main.gd @@ -13239,6 +13239,8 @@ func _run_cli_script_probe() -> void: _prepare_cli_probe_view(config) var acceptance := load(cli_probe_script).new() as Node add_child(acceptance) + if OS.has_feature("ios"): + acceptance.connect("game_finished", _finish_ios_mobile_acceptance.bind(acceptance)) acceptance.start(config, player) return var target_game_path: String = ProbeConfig.require_game_path(config) @@ -13271,6 +13273,37 @@ func _run_cli_script_probe() -> void: printerr("unsupported probe script: %s" % cli_probe_script) await _probe_cleanup_and_quit(2) +func _finish_ios_mobile_acceptance(native_state: Dictionary, acceptance: Node) -> void: + # Exercise the application's normal runtime-exit path, including its real + # LibraryView and existing navigation, rather than painting a test screen. + _return_to_library_after_runtime_exit() + cli_probe_script = "" + for _frame in range(6): + await get_tree().process_frame + await RenderingServer.frame_post_draw + var library := { + "scene_path": get_tree().current_scene.scene_file_path, + "script_path": get_script().resource_path, + "library_path": str(home_view.get_path()), + "shell_visible": shell_root.is_visible_in_tree(), + "library_visible": home_view.is_visible_in_tree(), + "game_view_visible": game_view.is_visible_in_tree(), + "route": shell_route, + "mode": home_library_mode, + "title": home_title_label.text, + "title_visible": home_title_label.is_visible_in_tree(), + "search_path": str(home_search_input.get_path()), + "search_visible": home_search_input.is_visible_in_tree(), + "viewport_size": [get_viewport_rect().size.x, get_viewport_rect().size.y], + "native_exited": bool(native_state.get("exited", false)), + } + var library_image := get_viewport().get_texture().get_image() + var evidence_dir := ProjectSettings.globalize_path("user://renpy-device-evidence") + var image_result := library_image.save_png(evidence_dir.path_join("production-library.png")) + library["image_result"] = image_result + acceptance.call("observe_library_return", library) + acceptance.queue_free() + func _prepare_cli_probe_view(config: Dictionary) -> void: var window_size := ProbeConfig.window_size(config, Vector2i(1280, 720)) if window_size.x > 0 and window_size.y > 0: diff --git a/apps/godot_app/scripts/renpy_mobile_acceptance.gd b/apps/godot_app/scripts/renpy_mobile_acceptance.gd index 55aa31dcd..9d655716a 100644 --- a/apps/godot_app/scripts/renpy_mobile_acceptance.gd +++ b/apps/godot_app/scripts/renpy_mobile_acceptance.gd @@ -1,5 +1,7 @@ extends Node +signal game_finished(native_state: Dictionary) + # A debug-device observer, driven exclusively by OS input. It never manufactures # game progress, sends a scripted click, or falls back to a viewport screenshot. const GameInputMapping = preload("res://scripts/game_input_mapping.gd") @@ -299,7 +301,19 @@ func _finish(native_state: Dictionary) -> void: player.destroy_engine() _record({"kind": "normal_exit", "provider_debug": native_state, "engine_destroyed": not player.is_initialized()}) - get_tree().quit(0) + if OS.has_feature("ios"): + # iOS returns to the application's real library. Ending UIKit itself + # is a separate host policy and cannot stand in for native game exit. + if not game_finished.has_connections(): + _record({"kind": "failure", "message": "The iOS gameplay observer has no production library return handler."}) + return + game_finished.emit(native_state) + else: + get_tree().quit(0) + +func observe_library_return(library: Dictionary) -> void: + _record({"kind": "library_return", "source": "actual-production-main-scene", "ui": library, + "engine_destroyed": not player.is_initialized()}) func _fail(message: String) -> void: if finished: diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c b/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c index 5b9f8df7b..5d46f8a3e 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c @@ -9,6 +9,10 @@ #if defined(__APPLE__) #include #endif +#if defined(__APPLE__) && TARGET_OS_IPHONE && defined(METALANGLE) +#include +#include +#endif #if defined(__ANDROID__) || (defined(__APPLE__) && TARGET_OS_IPHONE && defined(METALANGLE)) #define AETHER_MOBILE_EGL 1 #include @@ -157,9 +161,30 @@ int renpy_mobile_init(const renpy_mobile_config_t *config, if (aether_started) return RENPY_MOBILE_INVALID_STATE; if (!Py_IsInitialized()) return RENPY_MOBILE_INVALID_STATE; #if defined(__APPLE__) && TARGET_OS_IPHONE +#if !defined(METALANGLE) if (host->log_utf8) host->log_utf8(host->user_data, 3, - "Ren'Py iOS rendering requires a verified host-isolated MetalANGLE backend; stock SDL UIKit windows are disabled"); + "Ren'Py iOS runtime was built without MetalANGLE EGL support; the UIKit video backend cannot share the host window"); return RENPY_MOBILE_NOT_IMPLEMENTED; +#else + /* Query compiled drivers without initializing SDL video: the actual + renderer is created inside the first cooperative tick on this thread. */ + int has_offscreen = 0; + for (int index = 0; index < SDL_GetNumVideoDrivers(); ++index) { + const char *driver = SDL_GetVideoDriver(index); + if (driver && !strcmp(driver, "offscreen")) has_offscreen = 1; + } + if (!has_offscreen) { + if (host->log_utf8) host->log_utf8(host->user_data, 3, + "Ren'Py iOS runtime does not contain SDL's offscreen EGL pbuffer driver; rebuild the patched SDL library"); + return RENPY_MOBILE_NOT_IMPLEMENTED; + } + const char *selected_driver = getenv("SDL_VIDEODRIVER"); + if (!selected_driver || strcmp(selected_driver, "offscreen")) { + if (host->log_utf8) host->log_utf8(host->user_data, 3, + "Ren'Py iOS bootstrap did not select SDL_VIDEODRIVER=offscreen; UIKit video startup is refused"); + return RENPY_MOBILE_NOT_IMPLEMENTED; + } +#endif #endif /* The caller is allowed to pass a temporary callback table. */ diff --git a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp index 0b37e8460..7e64fde6b 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp @@ -177,11 +177,6 @@ engine_result_t Open(void* value, const char* game_root_path, } auto* runtime = Cast(value); if (runtime->opened) return ENGINE_RESULT_INVALID_STATE; -#if defined(AETHERKIRI_RENPY_IOS) - runtime->error = "Ren'Py iOS rendering is unavailable: the SDL MetalANGLE backend still creates a separate UIKit window; host graphics isolation requires a verified native backend"; - return ENGINE_RESULT_NOT_SUPPORTED; -#endif - if (!runtime->launcher.available()) { #if defined(__ANDROID__) mobile::BootstrapRequest request{}; diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 75c1c6675..665ab7eb7 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -6,7 +6,7 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" BUILD_TYPE="debug" SIMULATOR=false -SIMULATOR_ARCH="${IOS_SIMULATOR_ARCH:-x86_64}" +SIMULATOR_ARCH="${IOS_SIMULATOR_ARCH:-$(uname -m)}" PACKAGE_IPA=false for arg in "$@"; do case "$arg" in @@ -20,6 +20,14 @@ done BUILD_TYPE_LOWER="$(echo "$BUILD_TYPE" | tr '[:upper:]' '[:lower:]')" BUILD_TYPE_CAP="$(echo "${BUILD_TYPE_LOWER:0:1}" | tr '[:lower:]' '[:upper:]')${BUILD_TYPE_LOWER:1}" +if [[ "$BUILD_TYPE_LOWER" != debug && "$BUILD_TYPE_LOWER" != release ]]; then + echo "Error: Use debug or release for the iOS app build." >&2 + exit 1 +fi +if [[ "$SIMULATOR" == true && "$BUILD_TYPE_LOWER" != debug ]]; then + echo "Error: The iOS Simulator app currently uses the Debug CMake/export templates; select debug." >&2 + exit 1 +fi if [[ "$SIMULATOR" == true ]]; then if [[ "$SIMULATOR_ARCH" == "x86_64" || "$SIMULATOR_ARCH" == "x64" ]]; then @@ -53,14 +61,19 @@ GODOT_APP_DIR="$PROJECT_ROOT/apps/godot_app" GODOT_BIN_DIR="$GODOT_APP_DIR/bin/$GODOT_TRIPLET_DIR" RUNTIME_CJK_FONT_SOURCE="$GODOT_APP_DIR/assets/fonts/aetherkiri-runtime-cjk.otf" RUNTIME_SYMBOL_FONT_SOURCE="$GODOT_APP_DIR/assets/fonts/aetherkiri-runtime-symbols.ttf" -# When Ren'Py is enabled, fold the rebuilt Renios static-library closure into -# the Godot extension archive and copy its resources/framework into the app. -# It must never +# When Ren'Py is enabled, isolate the rebuilt Renios native closure in its own +# framework and copy its Python resources into the app. It must never # copy or compile Renios' prototype main.c: Godot already owns the process and # the UIKit/SDL application entrypoint. RENPY_MOBILE_ROOT="${AETHERKIRI_RENPY_MOBILE_ROOT:-${RENPY_MOBILE_ROOT:-$PROJECT_ROOT/out/renpy-mobile-source/payload}}" RENPY_RENIOS_BASE="${AETHERKIRI_RENPY_RENIOS_BASE:-$RENPY_MOBILE_ROOT/renios/prototype/base}" PARALLEL_JOBS="${JOBS:-8}" +IOS_EXPORT_DIR="$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER" +if [[ "$SIMULATOR" == true ]]; then + IOS_EXPORT_DIR="$PROJECT_ROOT/out/godot/ios-simulator-$SIMULATOR_ARCH/$BUILD_TYPE_LOWER" +fi +RENPY_FRAMEWORK_NAME=AetherRenPyRuntime +RENPY_FRAMEWORK_OUTPUT="$CMAKE_BUILD_DIR/renpy-native/$RENPY_FRAMEWORK_NAME.framework" if [[ "$(uname -s)" != Darwin ]] || ! command -v xcrun >/dev/null 2>&1 || ! command -v xcodebuild >/dev/null 2>&1; then echo "Error: iOS app packaging requires a cloud macOS host with Xcode and its licensed iPhoneOS/iPhoneSimulator SDK; this host has no Apple build tools." >&2 exit 1 @@ -364,7 +377,7 @@ renios_archive_is_excluded() { case "$(basename "$1")" in # SDL2main owns the UIKit/SDL application entrypoint in the official # prototype. Godot's application must supply the only entrypoint. - libSDL2main.a|libSDL2_test.a|libSDL3main.a|libSDL3_test.a) return 0 ;; + libSDL2main.a|libSDL2_test.a|libSDL3main.a|libSDL3_test.a|libmockrt.a) return 0 ;; *) return 1 ;; esac } @@ -419,6 +432,70 @@ renios_launcher_probe() { --library "$prebuilt/librenpython.a" --private-root "$RENPY_RENIOS_BASE" } +build_renios_runtime_framework() { + renios_enabled || return 0 + local prebuilt arch=arm64 framework_source + [[ "$SIMULATOR" == true ]] && arch="$SIMULATOR_ARCH" + prebuilt="$(renios_prebuilt_root)" + framework_source="$RENPY_MOBILE_ROOT/renios/prototype/Frameworks/MetalANGLE.xcframework" + local metalangle + metalangle="$(python3 - "$framework_source" "$IOS_SDK" "$arch" <<'PY' +import pathlib, plistlib, sys +root=pathlib.Path(sys.argv[1]); simulator=sys.argv[2]=='iphonesimulator'; arch=sys.argv[3] +info=plistlib.loads((root/'Info.plist').read_bytes()) +for library in info['AvailableLibraries']: + if (library.get('SupportedPlatform') == 'ios' and + (library.get('SupportedPlatformVariant') == 'simulator') == simulator and + arch in library['SupportedArchitectures']): + print(root/library['LibraryIdentifier']/library['LibraryPath']) + break +else: + raise SystemExit('MetalANGLE has no matching iOS SDK/architecture slice') +PY +)" + [[ -f "$metalangle/MetalANGLE" ]] || { echo "Error: Missing MetalANGLE framework slice." >&2; return 1; } + mkdir -p "$RENPY_FRAMEWORK_OUTPUT" + local export_list="$CMAKE_BUILD_DIR/renpy-native/exports.txt" symbol + : > "$export_list" + for symbol in bootstrap bind_window init tick frame input pause resume shutdown text_input_state set_surface_size; do + printf '_renpy_mobile_%s\n' "$symbol" >> "$export_list" + done + local archives=() archive + while IFS= read -r archive; do archives+=("$archive"); done < <(collect_renios_archives "$prebuilt") + local minimum_flag="-miphoneos-version-min=${IOS_MIN_VERSION:-16.0}" + [[ "$SIMULATOR" == true ]] && minimum_flag="-mios-simulator-version-min=${IOS_MIN_VERSION:-16.0}" + # Keep Ren'Py's Python/SDL/FFmpeg symbols inside a separate two-level + # namespace. They must not replace the host's vcpkg library definitions. + # The builtin Python extension table requires its archive objects to stay. + xcrun --sdk "$IOS_SDK" clang++ -arch "$arch" "$minimum_flag" -dynamiclib \ + -Wl,-all_load "${archives[@]}" -Wl,-noall_load \ + -Wl,-exported_symbols_list,"$export_list" \ + -Wl,-install_name,"@rpath/$RENPY_FRAMEWORK_NAME.framework/$RENPY_FRAMEWORK_NAME" \ + -Wl,-rpath,@loader_path/.. \ + -F"$(dirname "$metalangle")" -framework MetalANGLE \ + -framework Foundation -framework UIKit -framework QuartzCore \ + -framework CoreAudio -framework AudioToolbox -framework AVFoundation \ + -framework CoreVideo -framework CoreMedia -framework VideoToolbox \ + -framework CoreGraphics -framework CoreFoundation -framework GameController \ + -framework CoreMotion -framework Security -framework SystemConfiguration \ + -framework Metal -liconv -lz -lbz2 \ + -o "$RENPY_FRAMEWORK_OUTPUT/$RENPY_FRAMEWORK_NAME" + python3 - "$RENPY_FRAMEWORK_OUTPUT/Info.plist" "$RENPY_FRAMEWORK_NAME" "$IOS_SDK" <<'PY' +import pathlib, plistlib, sys +plist={'CFBundleIdentifier':'org.aetherkiri.renpy-runtime', 'CFBundleExecutable':sys.argv[2], + 'CFBundleName':sys.argv[2], 'CFBundlePackageType':'FMWK', 'CFBundleVersion':'1', + 'CFBundleShortVersionString':'8.5.3', 'MinimumOSVersion':'16.0', + 'CFBundleSupportedPlatforms':['iPhoneSimulator' if sys.argv[3]=='iphonesimulator' else 'iPhoneOS']} +pathlib.Path(sys.argv[1]).write_bytes(plistlib.dumps(plist)) +PY + codesign --force --sign - "$RENPY_FRAMEWORK_OUTPUT" + python3 "$PROJECT_ROOT/tools/validate_renpy_mobile_payload.py" \ + --platform ios --arch "$arch" --sdk "$IOS_SDK" --executable \ + --library "$RENPY_FRAMEWORK_OUTPUT/$RENPY_FRAMEWORK_NAME" + xcrun otool -L "$RENPY_FRAMEWORK_OUTPUT/$RENPY_FRAMEWORK_NAME" \ + > "$CMAKE_BUILD_DIR/renpy-native/linked-libraries.txt" +} + stage_renios_ios_resources() { local export_root="$1" if ! renios_enabled; then @@ -453,15 +530,17 @@ stage_renios_ios_resources() { cp -R "$resource_source/base" "$resource_root/base" fi - # MetalANGLE is the only dynamic dependency in Renios' prototype. Keep - # it in the app's Frameworks directory; the PBX patch below links and - # embeds it. Static Renios libraries are folded into the extension - # archive, so they are not copied into the app bundle. + # Link and embed the matching MetalANGLE slice alongside our isolated + # native runtime framework. Python keeps its original resource paths. if [[ -d "$resource_source/Frameworks/MetalANGLE.xcframework" ]]; then mkdir -p "$app_root/Frameworks" rm -rf "$app_root/Frameworks/MetalANGLE.xcframework" cp -R "$resource_source/Frameworks/MetalANGLE.xcframework" "$app_root/Frameworks/" fi + [[ -d "$RENPY_FRAMEWORK_OUTPUT" ]] || { echo "Error: Ren'Py runtime framework was not built." >&2; return 1; } + mkdir -p "$app_root/Frameworks" + rm -rf "$app_root/Frameworks/$RENPY_FRAMEWORK_NAME.framework" + cp -R "$RENPY_FRAMEWORK_OUTPUT" "$app_root/Frameworks/" local prebuilt prebuilt="$(renios_prebuilt_root 2>/dev/null || true)" @@ -475,15 +554,16 @@ stage_renios_ios_resources() { printf 'Renios archive SHA-256: %s\n' "$(cat "$RENPY_MOBILE_ROOT/renios/.aetherkiri-sha256")" fi printf 'Configuration: %s\n' "$(renios_configuration)" - printf 'Static closure: %s\n' "$(renios_archive_names "$prebuilt")" - printf 'Excluded archives: libSDL2main.a,libSDL2_test.a,libSDL3main.a,libSDL3_test.a (host entrypoint/test)\n' + printf 'Isolated runtime framework: %s.framework\n' "$RENPY_FRAMEWORK_NAME" + printf 'Framework static inputs: %s\n' "$(renios_archive_names "$prebuilt")" + printf 'Excluded archives: libSDL2main.a,libSDL2_test.a,libSDL3main.a,libSDL3_test.a,libmockrt.a (host entrypoint/test/Linux SDK shim)\n' if [[ -d "$resource_root/base" ]]; then printf 'Base resources: bundled\n' else printf 'Base resources: none (Renios archive is game-agnostic)\n' fi printf 'Host entrypoint: Godot (Renios main.c intentionally omitted)\n' - printf 'Native lifecycle: linked and export validated\n' + printf 'Native lifecycle: framework linked and export validated\n' printf 'Gameplay verification: requires device/simulator execution\n' } > "$resource_root/renios-manifest.txt" } @@ -491,11 +571,14 @@ stage_renios_ios_resources() { combine_ios_static_extension() { local output="$1" local triplet="$2" - # Validate the lifecycle fork and its Python runtime before merging the - # closure. Never invoke the process-owning launcher_main entrypoint. + # Validate the lifecycle fork and its Python resources before staging the + # host extension. Never invoke the process-owning launcher_main entrypoint. renios_launcher_probe local vcpkg_triplet_root="$CMAKE_BUILD_DIR/vcpkg_installed/$triplet" local vcpkg_lib_dir="$vcpkg_triplet_root/lib" + if [[ "$BUILD_TYPE_LOWER" == debug && -d "$vcpkg_triplet_root/debug/lib" ]]; then + vcpkg_lib_dir="$vcpkg_triplet_root/debug/lib" + fi local cubism_package_root="${AETHERKIRI_INTERNAL_DIR:-$PROJECT_ROOT/packages/AetherInternal}" local cubism_core_lib="$cubism_package_root/third_party/cubism/Core/lib/ios/Release-iphoneos/libLive2DCubismCore.a" local godot_cpp_arch="arm64" @@ -573,34 +656,7 @@ combine_ios_static_extension() { ) fi - # Renios ships a self-contained iOS Python/SDL/FFmpeg closure. Godot's - # exporter links only the extension archive and does not propagate its - # CMake dependencies, so merge the official closure into that archive. - # SDL2main and SDL2_test are deliberately excluded: they contain the - # prototype's application entrypoint and test harness, - # which must never be introduced into the Godot application. - local renios_prebuilt="" - if renios_enabled && renios_link_enabled; then - renios_prebuilt="$(renios_prebuilt_root 2>/dev/null || true)" - if [[ -n "$renios_prebuilt" ]]; then - local renios_archive - while IFS= read -r renios_archive; do - [[ -f "$renios_archive" ]] || continue - libs+=("$renios_archive") - done < <(collect_renios_archives "$renios_prebuilt") - else - echo "Error: Renios support requested but no native closure was found." >&2 - return 1 - fi - fi - while IFS= read -r lib; do - # If Renios is enabled, prefer its iOS SDL/image/runtime archives over - # identically named vcpkg archives. Other vcpkg libraries remain in - # the closure for Godot and the existing runtimes. - if [[ -n "$renios_prebuilt" ]] && [[ -f "$renios_prebuilt/$(basename "$lib")" ]]; then - continue - fi libs+=("$lib") done < <(find "$vcpkg_lib_dir" -maxdepth 1 -name 'lib*.a' \ ! -name 'libgodot-cpp*.a' \ @@ -644,11 +700,6 @@ stage_force_load_plugin_archives() { local resolved mkdir -p "$destination" for source in "${FORCE_LOAD_PLUGIN_SOURCES[@]}"; do - # Prefer the SDL2 archive already merged from Renios. Force-loading - # vcpkg's second SDL2 would duplicate its global symbol definitions. - if renios_enabled && renios_link_enabled && [[ "$(basename "$source")" == "libSDL2.a" ]]; then - continue - fi resolved="$source" if [[ ! -f "$resolved" ]]; then resolved="$CMAKE_BUILD_DIR/$source" @@ -656,7 +707,15 @@ stage_force_load_plugin_archives() { if [[ ! -f "$resolved" ]]; then resolved="$PROJECT_ROOT/$source" fi - cp -f "$resolved" "$destination/" 2>/dev/null || true + if [[ ! -f "$resolved" && "$source" == vcpkg_installed/*/lib/libSDL2.a ]]; then + resolved="$CMAKE_BUILD_DIR/vcpkg_installed/$VCPKG_TRIPLET_DIR/debug/lib/libSDL2d.a" + [[ -f "$resolved" ]] || resolved="$CMAKE_BUILD_DIR/vcpkg_installed/$VCPKG_TRIPLET_DIR/debug/lib/libSDL2.a" + fi + if [[ ! -f "$resolved" ]]; then + echo "Error: Required force-loaded iOS archive is missing: $source" >&2 + return 1 + fi + cp -f "$resolved" "$destination/$(basename "$source")" done } @@ -743,7 +802,7 @@ patch_ios_renios_resources() { # MetalANGLE is a dynamic framework in the official Renios package. It # must be linked and embedded, rather than copied as an opaque resource. - # The static Renios closure is already folded into libaether_kiri_godot.a. + # Renios' native closure is linked separately from the host extension. if [[ -d "$framework" ]] && ! grep -Fq 'A3F003000000000000000003 /* MetalANGLE.xcframework in Frameworks */' "$project_file"; then perl -0pi -e 's@(/\* Begin PBXBuildFile section \*/\n)@$1\t\tA3F003000000000000000003 /* MetalANGLE.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A3F003000000000000000004 /* MetalANGLE.xcframework */; };\n\t\tA3F003000000000000000005 /* MetalANGLE.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A3F003000000000000000004 /* MetalANGLE.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };\n@' "$project_file" perl -0pi -e 's@(/\* Begin PBXFileReference section \*/\n)@$1\t\tA3F003000000000000000004 /* MetalANGLE.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = Frameworks/MetalANGLE.xcframework; sourceTree = ""; };\n@' "$project_file" @@ -766,6 +825,9 @@ patch_ios_renios_resources() { perl -0pi -e 's@(\n\t\t[0-9A-F]+ /\* [^*]+ \*/ = \{\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tdstPath = "";\n\t\t\tdstSubfolderSpec = 10;\n\t\t\tfiles = \(\n)@$1\t\t\t\tA3F003000000000000000005 /* MetalANGLE.xcframework in Embed Frameworks */,\n@'s "$project_file" fi fi + + python3 "$PROJECT_ROOT/tools/patch_renpy_ios_runtime_project.py" "$project_file" \ + "$export_root/Aether/Frameworks/$RENPY_FRAMEWORK_NAME.framework" } patch_ios_export_project() { @@ -778,26 +840,16 @@ patch_ios_export_project() { local export_build_type="$3" local flags flags='$(LD_CLASSIC_$(XCODE_VERSION_ACTUAL)) -Wl,-U,_aether_kiri_library_init' - flags+=" -Wl,-force_load,Aether/bin/ios/$export_build_type/$IOS_SDK_COMPAT_ARCHIVE" + flags+=" -Wl,-force_load,Aether/bin/$GODOT_TRIPLET_DIR/$IOS_SDK_COMPAT_ARCHIVE" local archive for archive in "${FORCE_LOAD_PLUGIN_ARCHIVES[@]}"; do - if renios_enabled && renios_link_enabled && [[ "$archive" == "libSDL2.a" ]]; then - continue - fi - flags+=" -Wl,-force_load,Aether/bin/ios/$export_build_type/$archive" + flags+=" -Wl,-force_load,Aether/bin/$GODOT_TRIPLET_DIR/$archive" done flags+=' -liconv -framework Accelerate -framework AudioToolbox -framework AVFoundation -framework CoreAudio -framework CoreBluetooth -framework CoreFoundation -framework CoreHaptics -framework CoreMedia -framework CoreMotion -framework CoreVideo -framework CoreServices -framework GameController -framework ImageIO -framework VideoToolbox -framework CoreGraphics -framework QuartzCore -framework Metal -framework MetalKit -framework OpenGLES -framework Security -framework StoreKit -framework SystemConfiguration -framework MobileCoreServices' if renios_enabled; then # The resource staging step places this official dynamic framework in # Aether/Frameworks and the PBX patch embeds it in the app bundle. - flags+=' -F Aether/Frameworks -framework MetalANGLE' - # These functions are resolved by the provider at runtime. Root each - # archive member and retain its global symbol under dead stripping. - local lifecycle_symbol - for lifecycle_symbol in bootstrap bind_window init tick frame input pause resume shutdown text_input_state set_surface_size; do - flags+=" -Wl,-u,_renpy_mobile_$lifecycle_symbol" - done - flags+=' -Wl,-export_dynamic' + flags+=" -F Aether/Frameworks -Wl,-needed_framework,$RENPY_FRAMEWORK_NAME -Wl,-rpath,@executable_path/Frameworks" fi if [[ -f "$project_file" ]]; then @@ -846,6 +898,32 @@ EOF fi } +validate_renios_app() { + renios_enabled || return 0 + local app="$1" arch="$2" sdk="$3" + local framework="$app/Frameworks/$RENPY_FRAMEWORK_NAME.framework/$RENPY_FRAMEWORK_NAME" + [[ -f "$framework" && -f "$app/Frameworks/MetalANGLE.framework/MetalANGLE" ]] || { + echo "Error: The packaged app is missing its executable Ren'Py/MetalANGLE frameworks." >&2 + return 1 + } + python3 "$PROJECT_ROOT/tools/validate_renpy_mobile_payload.py" \ + --platform ios --arch "$arch" --sdk "$sdk" --executable --library "$framework" \ + --private-root "$app/renios/base" + xcrun otool -L "$app/Aether" | grep -Fq "@rpath/$RENPY_FRAMEWORK_NAME.framework/$RENPY_FRAMEWORK_NAME" || { + echo "Error: The final app does not load its isolated Ren'Py framework." >&2 + return 1 + } + # Objective-C class names are process-global, even with two-level native + # namespaces. The offscreen SDL build must exclude UIKit classes and prefix + # its audio listener; otherwise it collides with Godot's host SDL backend. + local runtime_symbols + runtime_symbols="$(xcrun nm "$framework")" + if grep -Eq '_OBJC_CLASS_\$_(SDL_|SDLEAGLContext|SDLInterruptionListener)' <<< "$runtime_symbols"; then + echo "Error: Ren'Py's framework still contains host SDL Objective-C classes." >&2 + return 1 + fi +} + package_ios_unsigned_ipa() { local export_dir="$1" local build_type_lower="$2" @@ -907,11 +985,7 @@ package_ios_unsigned_ipa() { echo "==> Removing non-runtime symbols from iOS Release executable..." "$PROJECT_ROOT/tools/strip_runtime_symbols.sh" macho-executable "$app_binary" fi - if renios_enabled; then - python3 "$PROJECT_ROOT/tools/validate_renpy_mobile_payload.py" \ - --platform ios --arch arm64 --sdk iphoneos --executable --library "$app_binary" \ - --private-root "$export_dir/build/Aether.app/renios/base" - fi + validate_renios_app "$export_dir/build/Aether.app" arm64 iphoneos echo "==> Packaging into unsigned .ipa..." mkdir -p "$export_dir/Payload" @@ -931,18 +1005,18 @@ package_ios_simulator_app() { -configuration "$config_cap" -sdk iphonesimulator \ -destination 'generic/platform=iOS Simulator' \ "ARCHS=$SIMULATOR_ARCH" ONLY_ACTIVE_ARCH=YES \ + DEVELOPMENT_TEAM="" \ CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO \ "CONFIGURATION_BUILD_DIR=$output_dir" [[ -s "$output_dir/Aether.app/Aether" ]] || { echo "Error: Xcode did not produce a runnable iOS simulator app." >&2 return 1 } - if renios_enabled; then - python3 "$PROJECT_ROOT/tools/validate_renpy_mobile_payload.py" \ - --platform ios --arch "$SIMULATOR_ARCH" --sdk iphonesimulator --executable \ - --library "$output_dir/Aether.app/Aether" \ - --private-root "$output_dir/Aether.app/renios/base" - fi + validate_renios_app "$output_dir/Aether.app" "$SIMULATOR_ARCH" iphonesimulator + while IFS= read -r framework; do codesign --force --sign - "$framework"; done \ + < <(find "$output_dir/Aether.app/Frameworks" -maxdepth 1 -type d -name '*.framework') + codesign --force --sign - "$output_dir/Aether.app" + codesign --verify --deep --strict "$output_dir/Aether.app" (cd "$output_dir" && zip -qry "Aether-$SIMULATOR_ARCH-Simulator.zip" Aether.app) echo "Runnable simulator app: $output_dir/Aether.app (device gameplay unverified)" } @@ -969,11 +1043,12 @@ with_ios_only_gdextension() { ' "$backup_file" | grep -v '^macos\.' > "$gdextension_file" "$GODOT_BIN" --headless --path "$GODOT_APP_DIR" \ - "$EXPORT_MODE" "$EXPORT_PRESET" "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER/Aether.xcodeproj" + "$EXPORT_MODE" "$EXPORT_PRESET" "$IOS_EXPORT_DIR/Aether.xcodeproj" } echo "==> Building native engine and Godot extension" renios_launcher_probe +build_renios_runtime_framework cmake_config_args=( -D "CMAKE_MAKE_PROGRAM=$CMAKE_MAKE_PROGRAM" -D "AETHERKIRI_ENABLE_INTERNAL=${AETHERKIRI_ENABLE_INTERNAL:-ON}" @@ -1018,14 +1093,6 @@ stage_force_load_plugin_archives "$GODOT_BIN_DIR" if [[ -f "$CMAKE_BUILD_DIR/bridge/godot_extension/libaether_kiri_godot.a" ]]; then combine_ios_static_extension "$GODOT_BIN_DIR/libaether_kiri_godot.a" "$VCPKG_TRIPLET_DIR" fi -if [[ "$SIMULATOR" == true ]]; then - GODOT_EXPORT_BIN_DIR="$GODOT_APP_DIR/bin/ios/$BUILD_TYPE_LOWER" - mkdir -p "$GODOT_EXPORT_BIN_DIR" - cp -f "$CMAKE_BUILD_DIR/abi/libengine_api.a" "$GODOT_EXPORT_BIN_DIR/" 2>/dev/null || true - cp -f "$GODOT_BIN_DIR/libaether_kiri_godot.a" "$GODOT_EXPORT_BIN_DIR/" 2>/dev/null || true - cp -f "$GODOT_BIN_DIR/$IOS_SDK_COMPAT_ARCHIVE" "$GODOT_EXPORT_BIN_DIR/" 2>/dev/null || true - stage_force_load_plugin_archives "$GODOT_EXPORT_BIN_DIR" -fi if [[ ! -x "$GODOT_BIN" ]]; then if [[ "$PACKAGE_IPA" == true || "$SIMULATOR" == true ]]; then @@ -1041,30 +1108,34 @@ elif [[ ! -f "$GODOT_EXPORT_TEMPLATE" ]]; then echo "Warning: Godot iOS export template missing at $GODOT_EXPORT_TEMPLATE; native libraries were staged only." >&2 else echo "==> Exporting Godot iOS project" - mkdir -p "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER" + mkdir -p "$IOS_EXPORT_DIR" EXPORT_PRESET="iOS Debug" EXPORT_MODE="--export-debug" if [[ "$BUILD_TYPE_LOWER" == "release" ]]; then EXPORT_PRESET="iOS Release" EXPORT_MODE="--export-release" fi + if [[ "$SIMULATOR" == true ]]; then + EXPORT_PRESET="iOS Simulator Debug" + [[ "$SIMULATOR_ARCH" == x86_64 ]] && EXPORT_PRESET="iOS Simulator x64 Debug" + fi with_ios_only_gdextension if [[ "$SIMULATOR" == true ]]; then - verify_exported_simulator_template_arch "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER" "$SIMULATOR_ARCH" + verify_exported_simulator_template_arch "$IOS_EXPORT_DIR" "$SIMULATOR_ARCH" fi - stage_renios_ios_resources "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER" - stage_force_load_plugin_archives "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER/Aether/bin/ios/$BUILD_TYPE_LOWER" - cp -f "$GODOT_BIN_DIR/$IOS_SDK_COMPAT_ARCHIVE" "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER/Aether/bin/ios/$BUILD_TYPE_LOWER/" 2>/dev/null || true + stage_renios_ios_resources "$IOS_EXPORT_DIR" + stage_force_load_plugin_archives "$IOS_EXPORT_DIR/Aether/bin/$GODOT_TRIPLET_DIR" + cp -f "$GODOT_BIN_DIR/$IOS_SDK_COMPAT_ARCHIVE" "$IOS_EXPORT_DIR/Aether/bin/$GODOT_TRIPLET_DIR/" PATCH_ARCH="arm64" if [[ "$SIMULATOR" == true ]]; then PATCH_ARCH="$SIMULATOR_ARCH" fi - patch_ios_export_project "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER/Aether.xcodeproj" "$PATCH_ARCH" "$BUILD_TYPE_LOWER" + patch_ios_export_project "$IOS_EXPORT_DIR/Aether.xcodeproj" "$PATCH_ARCH" "$BUILD_TYPE_LOWER" if [[ "$PACKAGE_IPA" == true && "$SIMULATOR" == false ]]; then - package_ios_unsigned_ipa "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER" "$BUILD_TYPE_LOWER" + package_ios_unsigned_ipa "$IOS_EXPORT_DIR" "$BUILD_TYPE_LOWER" elif [[ "$SIMULATOR" == true ]]; then - package_ios_simulator_app "$PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER" + package_ios_simulator_app "$IOS_EXPORT_DIR" fi fi -echo "iOS build output: $PROJECT_ROOT/out/godot/ios/$BUILD_TYPE_LOWER" +echo "iOS build output: $IOS_EXPORT_DIR" diff --git a/tools/apply_renpy_mobile_patch_series.py b/tools/apply_renpy_mobile_patch_series.py new file mode 100644 index 000000000..61afc3ad6 --- /dev/null +++ b/tools/apply_renpy_mobile_patch_series.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Apply or verify a complete patch series against its genuine pinned source. + +Later patches can edit earlier patch contexts. Verify the series' final files +against a fresh pinned archive instead of independently reversing each patch. +Partial/stale series and unrelated tracked source changes are rejected. +""" +import argparse +import hashlib +import os +from pathlib import Path +import re +import subprocess +import tarfile +import tempfile + + +def snapshot(path): + if path.is_symlink(): + return ("symlink", os.readlink(path)) + if not path.exists(): + return None + if not path.is_file(): + return ("unexpected-directory",) + return ("file", hashlib.sha256(path.read_bytes()).hexdigest(), path.stat().st_mode & 0o111) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--checkout", required=True, type=Path) + parser.add_argument("--pin", required=True) + parser.add_argument("patches", nargs="+", type=Path) + args = parser.parse_args() + checkout = args.checkout.resolve() + patches = [path.resolve() for path in args.patches] + actual = subprocess.check_output(["git", "-C", str(checkout), "rev-parse", "HEAD"], text=True).strip() + if actual != args.pin: + raise SystemExit(f"Unsupported source revision: {actual}; expected {args.pin}") + paths = set() + for patch in patches: + for before, after in re.findall(r"^diff --git a/(\S+) b/(\S+)$", patch.read_text(), re.M): + for name in (before, after): + path = Path(name) + if path.is_absolute() or ".." in path.parts: + raise SystemExit("Patch contains a path outside the checkout: " + name) + paths.add(name) + if not paths: + raise SystemExit("Patch series contains no source paths") + changed = set(subprocess.check_output(["git", "-C", str(checkout), "diff", "--name-only", "HEAD"], text=True).splitlines()) + unrelated = changed - paths + if unrelated: + raise SystemExit("Unexpected tracked source drift outside the patch series: " + ", ".join(sorted(unrelated))) + tracked = set(subprocess.check_output(["git", "-C", str(checkout), "ls-files"], text=True).splitlines()) + with tempfile.TemporaryDirectory(prefix="renpy-complete-patch-series-") as temporary: + expected = Path(temporary) + archive = subprocess.Popen(["git", "-C", str(checkout), "archive", args.pin, *sorted(paths & tracked)], stdout=subprocess.PIPE) + assert archive.stdout is not None + with tarfile.open(fileobj=archive.stdout, mode="r|*") as source: + source.extractall(expected, filter="data") + if archive.wait(): + raise SystemExit("Could not archive the genuine pinned patch inputs") + pristine = {name: snapshot(expected / name) for name in paths} + for patch in patches: + subprocess.run(["git", "apply", "--check", str(patch)], cwd=expected, check=True) + subprocess.run(["git", "apply", str(patch)], cwd=expected, check=True) + patched = {name: snapshot(expected / name) for name in paths} + current = {name: snapshot(checkout / name) for name in paths} + if current == patched: + print("Complete pinned patch series already applied and verified: " + str(checkout)) + return + if current != pristine: + drift = sorted(name for name in paths if current[name] != patched[name]) + raise SystemExit("Source drift or partial/stale patch series; use a clean pinned checkout. Differing files: " + ", ".join(drift)) + for patch in patches: + subprocess.run(["git", "apply", "--check", str(patch)], cwd=checkout, check=True) + subprocess.run(["git", "apply", str(patch)], cwd=checkout, check=True) + print("applied: " + patch.name) + if any(snapshot(checkout / name) != patched[name] for name in paths): + raise SystemExit("Source changed while applying the patch series") + print("Complete pinned patch series applied and verified: " + str(checkout)) + + +if __name__ == "__main__": + main() diff --git a/tools/patch_renpy_ios_runtime_project.py b/tools/patch_renpy_ios_runtime_project.py new file mode 100755 index 000000000..7c2ccac7b --- /dev/null +++ b/tools/patch_renpy_ios_runtime_project.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Link and embed the isolated Ren'Py framework in a real Godot Xcode export.""" + +import argparse +import re +from pathlib import Path + + +def patch(project: Path, framework: Path) -> None: + if not framework.is_dir(): + raise ValueError(f"Rebuilt runtime framework is missing: {framework}") + name = framework.name + text = project.read_text() + prefix = "A3F0040000000000000000" + reference, linked, embedded = (prefix + suffix for suffix in ("04", "03", "05")) + if f"{reference} /* {name} */ =" in text: + return + + def insert(pattern: str, addition: str, description: str) -> None: + nonlocal text + text, count = re.subn(pattern, lambda m: m[0] + addition, text, count=1, flags=re.S) + if count != 1: + raise ValueError(f"Godot Xcode export lacks {description}; refusing an incomplete app") + + insert(r"/\* Begin PBXBuildFile section \*/\n", + f"\t\t{linked} /* {name} in Frameworks */ = {{isa = PBXBuildFile; fileRef = {reference} /* {name} */; }};\n" + f"\t\t{embedded} /* {name} in Embed Frameworks */ = {{isa = PBXBuildFile; fileRef = {reference} /* {name} */; settings = {{ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }}; }};\n", + "PBXBuildFile section") + insert(r"/\* Begin PBXFileReference section \*/\n", + f'\t\t{reference} /* {name} */ = {{isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = Frameworks/{name}; sourceTree = ""; }};\n', + "PBXFileReference section") + insert(r"[0-9A-F]+ /\* Aether \*/ = \{\s*isa = PBXGroup;\s*children = \(\n", + f"\t\t\t\t{reference} /* {name} */,\n", "Aether source group") + insert(r"/\* Begin PBXFrameworksBuildPhase section \*/.*?files = \(\n", + f"\t\t\t\t{linked} /* {name} in Frameworks */,\n", "framework link phase") + insert(r'isa = PBXCopyFilesBuildPhase;(?:(?!\};).)*?dstSubfolderSpec = 10;\s*files = \(\n', + f"\t\t\t\t{embedded} /* {name} in Embed Frameworks */,\n", "framework embed phase") + project.write_text(text) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("project", type=Path) + parser.add_argument("framework", type=Path) + args = parser.parse_args() + try: + patch(args.project, args.framework) + except (OSError, ValueError) as exc: + parser.exit(1, f"iOS runtime integration failed: {exc}\n") + + +if __name__ == "__main__": + main() diff --git a/tools/renpy_ios_acceptance/README.md b/tools/renpy_ios_acceptance/README.md new file mode 100644 index 000000000..0908a7a2d --- /dev/null +++ b/tools/renpy_ios_acceptance/README.md @@ -0,0 +1,57 @@ +# Cloud iOS Simulator gameplay acceptance + +This harness installs the actual Godot app, uses XCUITest for system touch, +software keyboard text, Home, foreground and game exit, and observes the bundled +native Ren'Py provider. It creates a disposable cloud Simulator unless an +explicit cloud UDID is supplied. It never substitutes a stub runtime or fake UI. + +Use a cloud Apple Silicon macOS runner with licensed Xcode/iPhoneSimulator SDK, +an installed iOS Simulator runtime, the repository's Godot editor/export template, +vcpkg/Rust build prerequisites, and the rebuilt cooperative Renios payload. The +Simulator export uses Compatibility rendering and separate simulator libraries. +Ren'Py's SDL/Python/FFmpeg closure is embedded in `AetherRenPyRuntime.framework`, +with the matching `MetalANGLE.framework` slice. No Apple developer signing +identity is needed for the Simulator; the packages are signed ad hoc. Godot's +existing Team ID is retained solely to satisfy its export-project validation; +the Xcode simulator build explicitly clears the signing team. + +From the repository root, after the actual iOS source runtime build: + +```bash +export AETHERKIRI_ENABLE_RENPY=ON +export AETHERKIRI_ENABLE_INTERNAL=OFF +export AETHERKIRI_RENPY_MOBILE_ROOT="$PWD/out/renpy-mobile-source/payload" +bash scripts/build_ios.sh debug --simulator --simulator-arch=arm64 +python3 tools/run_renpy_ios_device_acceptance.py \ + --cloud-simulator \ + --app out/godot/ios-simulator-arm64/debug/build-simulator-arm64/Aether.app \ + --output-dir out/renpy-ios-gameplay \ + --overall-timeout 600 +``` + +Public CI uses `AETHERKIRI_ENABLE_INTERNAL=OFF`; a build authorized to access +the private package can retain its normal Internal build configuration. +Archive the simulator `.app` ZIP, runtime source manifest, framework link/export +reports, the entire gameplay output (including `gameplay.xcresult`), and the +Xcode export/build logs. The gameplay directory contains real provider images, +Simulator screenshots, pixel comparisons, UI action acknowledgments, the Ren'Py +script checkpoints, native provider/lifecycle observations and system logs. + +`result.json` says `passed` only after real native images appear in OS +screenshots, actual touches and exact keyboard text reach the game, Home pauses +the native runtime, foreground resumes it with a new frame, and the game's Quit +action destroys the runtime and restores the existing application game library, +whose actual viewport pixels must also appear in the system screenshot. The +UIKit host remains foreground; terminating it is not required to exit a game. +Exit code `2` means +`not_run` because tools, runtime or the real app are missing; `1` means a real +execution did not meet the required evidence. Neither result proves phone +gameplay. Simulator results also do not establish physical iPhone support. + +The standalone UI test project targets an already installed app by its real +bundle ID. Host commands and acknowledgments use only the UI test runner's own +Documents directory; the game and observer independently write into the test +app's Documents directory. Only the two acceptance directories and one-shot +debug request are reset. The Simulator software-keyboard preference is restored +when the harness exits. This harness has no installation-only or skip-gameplay +mode. diff --git a/tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj/project.pbxproj b/tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj/project.pbxproj new file mode 100644 index 000000000..318487a78 --- /dev/null +++ b/tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj/project.pbxproj @@ -0,0 +1,52 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = {}; + objectVersion = 56; + objects = { + A01000000000000000000001 = {isa = PBXBuildFile; fileRef = A01000000000000000000002; }; + A01000000000000000000002 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenPyGameplayTests.swift; sourceTree = ""; }; + A01000000000000000000003 = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = RenPyAcceptance.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A01000000000000000000004 = {isa = PBXGroup; children = (A01000000000000000000002, A01000000000000000000005); sourceTree = ""; }; + A01000000000000000000005 = {isa = PBXGroup; children = (A01000000000000000000003); name = Products; sourceTree = ""; }; + A01000000000000000000006 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A01000000000000000000001); runOnlyForDeploymentPostprocessing = 0; }; + A01000000000000000000007 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; + A01000000000000000000008 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; + A01000000000000000000009 = { + isa = PBXNativeTarget; + buildConfigurationList = A0100000000000000000000C; + buildPhases = (A01000000000000000000006, A01000000000000000000007, A01000000000000000000008); + buildRules = (); dependencies = (); name = RenPyAcceptance; + productName = RenPyAcceptance; productReference = A01000000000000000000003; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + A0100000000000000000000A = { + isa = PBXProject; attributes = {LastUpgradeCheck = 1600; TargetAttributes = {A01000000000000000000009 = {CreatedOnToolsVersion = 16.0; }; }; }; + buildConfigurationList = A0100000000000000000000B; + compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; + knownRegions = (en, Base); mainGroup = A01000000000000000000004; + productRefGroup = A01000000000000000000005; projectDirPath = ""; projectRoot = ""; + targets = (A01000000000000000000009); + }; + A0100000000000000000000B = {isa = XCConfigurationList; buildConfigurations = (A0100000000000000000000D); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + A0100000000000000000000C = {isa = XCConfigurationList; buildConfigurations = (A0100000000000000000000E); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + A0100000000000000000000D = {isa = XCBuildConfiguration; name = Debug; buildSettings = {SDKROOT = iphonesimulator; IPHONEOS_DEPLOYMENT_TARGET = 16.0; SWIFT_VERSION = 5.0; CLANG_ENABLE_MODULES = YES; }; }; + A0100000000000000000000E = { + isa = XCBuildConfiguration; name = Debug; + buildSettings = { + PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "org.aetherkiri.renpy-ios-acceptance"; + GENERATE_INFOPLIST_FILE = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_VERSION = 5.0; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SDKROOT = iphonesimulator; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGNING_ALLOWED = YES; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + }; + }; + }; + rootObject = A0100000000000000000000A; +} diff --git a/tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj/xcshareddata/xcschemes/RenPyAcceptance.xcscheme b/tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj/xcshareddata/xcschemes/RenPyAcceptance.xcscheme new file mode 100644 index 000000000..39f87d120 --- /dev/null +++ b/tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj/xcshareddata/xcschemes/RenPyAcceptance.xcscheme @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/tools/renpy_ios_acceptance/RenPyGameplayTests.swift b/tools/renpy_ios_acceptance/RenPyGameplayTests.swift new file mode 100644 index 000000000..8f846338b --- /dev/null +++ b/tools/renpy_ios_acceptance/RenPyGameplayTests.swift @@ -0,0 +1,92 @@ +import Foundation +import XCTest + +// Drive the already installed Godot app through the actual Simulator UI. +// The host exchanges commands only through this test runner's own Documents +// directory; game checkpoints and provider frames are independently observed. +final class RenPyGameplayTests: XCTestCase { + func testCloudGameplay() throws { + continueAfterFailure = false + let environment = ProcessInfo.processInfo.environment + let runID = try XCTUnwrap(environment["AETHER_RENPY_RUN_ID"]) + let bundleID = try XCTUnwrap(environment["AETHER_RENPY_APP_BUNDLE_ID"]) + let timeout = Double(environment["AETHER_RENPY_TEST_TIMEOUT"] ?? "600") ?? 600 + let documents = try XCTUnwrap(FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first) + let root = documents.appendingPathComponent("aether-renpy-ui-" + runID) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let app = XCUIApplication(bundleIdentifier: bundleID) + if app.state != .runningForeground { + app.launch() + } + XCTAssertEqual(app.state, .runningForeground, "The installed app must launch on the Simulator") + try write(["run_id": runID, "bundle_id": bundleID, "ready": true], to: root.appendingPathComponent("ready.json")) + let deadline = Date().addingTimeInterval(timeout) + var previousSequence = 0 + while Date() < deadline { + let commandURL = root.appendingPathComponent("command.json") + guard let data = try? Data(contentsOf: commandURL), + let command = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + command["run_id"] as? String == runID, + let sequence = command["seq"] as? Int, + sequence > previousSequence else { + Thread.sleep(forTimeInterval: 0.1) + continue + } + previousSequence = sequence + let operation = command["op"] as? String ?? "" + var acknowledgment: [String: Any] = ["run_id": runID, "seq": sequence, "op": operation] + do { + switch operation { + case "tap": + XCTAssertEqual(app.state, .runningForeground) + let x = try XCTUnwrap(command["x"] as? Double) + let y = try XCTUnwrap(command["y"] as? Double) + XCTAssertTrue((0...1).contains(x) && (0...1).contains(y)) + app.coordinate(withNormalizedOffset: CGVector(dx: x, dy: y)).tap() + case "keyboard": + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 15), "A real system software keyboard must be visible") + acknowledgment["keyboard_visible"] = app.keyboards.firstMatch.exists + case "type": + XCTAssertTrue(app.keyboards.firstMatch.exists) + app.typeText(try XCTUnwrap(command["text"] as? String)) + case "home": + XCUIDevice.shared.press(.home) + let background = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in + app.state == .runningBackground || app.state == .runningBackgroundSuspended + }, object: nil) + XCTAssertEqual(XCTWaiter.wait(for: [background], timeout: 15), .completed) + case "activate": + app.activate() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 15)) + case "foreground": + XCTAssertEqual(app.state, .runningForeground) + case "finish": + acknowledgment["success"] = true + try write(acknowledgment, to: root.appendingPathComponent("ack.json")) + return + default: + throw NSError(domain: "AetherRenPyUI", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unknown UI operation: " + operation]) + } + let screenshot = XCUIScreen.main.screenshot() + try screenshot.pngRepresentation.write(to: root.appendingPathComponent(String(sequence) + ".png"), options: .atomic) + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = "RenPy " + operation + " " + String(sequence) + attachment.lifetime = .keepAlways + add(attachment) + acknowledgment["success"] = true + acknowledgment["app_state"] = app.state.rawValue + try write(acknowledgment, to: root.appendingPathComponent("ack.json")) + } catch { + acknowledgment["success"] = false + acknowledgment["error"] = String(describing: error) + try? write(acknowledgment, to: root.appendingPathComponent("ack.json")) + throw error + } + } + XCTFail("Cloud gameplay command deadline expired without complete gameplay evidence") + } + + private func write(_ value: [String: Any], to url: URL) throws { + try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]).write(to: url, options: .atomic) + } +} diff --git a/tools/renpy_ios_egl_probe/run_sdl_probe.sh b/tools/renpy_ios_egl_probe/run_sdl_probe.sh index ed8f9725c..67a233753 100755 --- a/tools/renpy_ios_egl_probe/run_sdl_probe.sh +++ b/tools/renpy_ios_egl_probe/run_sdl_probe.sh @@ -52,7 +52,12 @@ for name in ('0004-android-offscreen-renderer.patch', '0006-ios-offscreen-render if 'new file mode 100644\n' not in section: raise SystemExit('Expected a real added SDL patch file: ' + name) filename = line.split()[3].removeprefix('b/patches/SDL2-2.0.20/') - body = ''.join(l[1:] for l in section if l.startswith('+') and not l.startswith('+++')) + # Outer +++ metadata precedes its first hunk. Inside that hunk every + # added line belongs to the new patch, including nested +++ headers. + # Dropping those headers happens to work with GNU patch's git-name + # inference but BSD patch cannot create the new Objective-C file. + first_hunk = next(i for i, l in enumerate(section) if l.startswith('@@ ')) + body = ''.join(l[1:] for l in section[first_hunk + 1:] if l.startswith('+')) patches[filename] = body.encode() evidence = out/'evidence/applied-sdl-patches'; evidence.mkdir(exist_ok=True) for name, body in sorted(patches.items()): diff --git a/tools/run_renpy_ios_device_acceptance.py b/tools/run_renpy_ios_device_acceptance.py new file mode 100755 index 000000000..a99ff5613 --- /dev/null +++ b/tools/run_renpy_ios_device_acceptance.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +"""Install a real Ren'Py Godot app and exercise gameplay on a cloud iOS Simulator. + +XCUITest supplies real touches, keyboard text, Home and foreground transitions. +Passing requires native frames visible in OS screenshots, real Ren'Py script +checkpoints, native pause/resume and normal game exit to the real library. Missing Apple +tools/app/runtime reports not_run (2); incomplete execution reports failed (1). +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import platform +import plistlib +import shutil +import subprocess +import time +import uuid +from pathlib import Path + +from run_renpy_android_device_acceptance import Blocked, Failed, image_stats, pixel, png_pixels + + +class Acceptance: + def __init__(self, args: argparse.Namespace): + self.args = args + self.output = args.output_dir.resolve() + self.output.mkdir(parents=True, exist_ok=True) + self.repo = Path(__file__).resolve().parents[1] + self.run_id = uuid.uuid4().hex + self.udid = args.udid or "" + self.created = self.installed = False + self.bundle_id = "" + self.documents: Path | None = None + self.ui_root: Path | None = None + self.sequence = 0 + self.process: subprocess.Popen | None = None + self.process_log = None + self.deadline = 0.0 + self.keyboard_preference: str | None = None + self.preference_changed = False + self.summary = {"status": "not_run", "run_id": self.run_id, + "source": "actual-godot-app-simctl-xcuitest-cloud-simulator", + "checks": [], "blocker": ""} + + def command(self, *parts: str, check: bool = True, timeout: int = 60) -> bytes: + try: + result = subprocess.run(parts, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) + except (OSError, subprocess.TimeoutExpired) as exc: + raise Failed(f"Cloud command did not complete: {parts[0]}: {exc}") from exc + with (self.output / "commands.jsonl").open("a") as stream: + stream.write(json.dumps({"command": parts, "returncode": result.returncode, + "stderr": result.stderr.decode(errors="replace"), "time": time.time()}) + "\n") + if parts[0] == "xcodebuild" and "build-for-testing" in parts: + (self.output / "xcuitest-build.log").write_bytes(result.stdout + result.stderr) + if check and result.returncode: + raise Failed(f"{' '.join(parts[:4])} failed: {result.stderr.decode(errors='replace').strip()}") + return result.stdout + + def simctl(self, *parts: str, **kwargs) -> bytes: + return self.command("xcrun", "simctl", *parts, **kwargs) + + def rows(self, relative: str) -> list[dict]: + if self.documents is None: + return [] + path = self.documents / relative + if not path.is_file(): + return [] + rows = [] + for line in path.read_text(errors="replace").splitlines(): + try: + value = json.loads(line) + if isinstance(value, dict) and value.get("run_id") == self.run_id: + rows.append(value) + except ValueError: + pass + return rows + + def observer_rows(self) -> list[dict]: + return self.rows("renpy-device-evidence/observer.jsonl") + + def game_rows(self) -> list[dict]: + return self.rows("renpy-device-demo/game/aether-device-checkpoints.jsonl") + + def wait(self, description: str, condition, timeout: int | None = None): + expires = time.monotonic() + (timeout or self.args.stage_timeout) + if self.deadline: + expires = min(expires, self.deadline) + while time.monotonic() < expires: + for row in self.observer_rows(): + if row.get("kind") == "failure": + raise Failed(f"Actual iOS provider observer failed: {row.get('message')}") + result = condition() + if result: + return result + if self.process is not None and self.process.poll() is not None: + raise Failed(f"Actual XCUITest finished before {description} (exit={self.process.returncode}); inspect xcuitest.log/xcresult") + time.sleep(0.25) + raise Failed(f"Timed out waiting for {description}; no gameplay evidence was synthesized") + + def wait_game(self, stage: str) -> dict: + return self.wait(f"Ren'Py {stage}", lambda: next((r for r in self.game_rows() if r.get("stage") == stage), None)) + + def wait_observer(self, kind: str, **fields) -> dict: + return self.wait(f"native observer {kind} {fields}", lambda: next((r for r in self.observer_rows() + if r.get("kind") == kind and all(r.get(k) == v for k, v in fields.items())), None)) + + def ui(self, operation: str, **fields) -> dict: + if self.ui_root is None: + raise Failed("The actual UI test runner has not started") + self.sequence += 1 + request = {"run_id": self.run_id, "seq": self.sequence, "op": operation, **fields} + temporary = self.ui_root / "command.pending" + temporary.write_text(json.dumps(request)) + temporary.replace(self.ui_root / "command.json") + + def acknowledgment(): + try: + value = json.loads((self.ui_root / "ack.json").read_text()) + return value if value.get("run_id") == self.run_id and value.get("seq") == self.sequence else None + except (OSError, ValueError): + return None + + result = self.wait(f"actual XCUITest {operation}", acknowledgment) + with (self.output / "ui-actions.jsonl").open("a") as stream: + stream.write(json.dumps({"request": request, "acknowledgment": result}) + "\n") + if result.get("success") is not True: + raise Failed(f"Actual UI action {operation} failed: {result}") + screenshot = self.ui_root / f"{self.sequence}.png" + if screenshot.is_file(): + shutil.copyfile(screenshot, self.output / f"xcuitest-{self.sequence:02}-{operation}.png") + return result + + def screenshot(self, stage: str) -> Path: + path = self.output / f"os-{stage}.png" + self.simctl("io", self.udid, "screenshot", str(path)) + png_pixels(path) + return path + + def capture(self, stage: str) -> dict: + row = self.wait_observer("provider_frame", stage=stage) + native = row.get("provider_debug", {}) + if (row.get("source") != "native-provider-rgba" or native.get("runtime") != "renpy" + or native.get("initialized") is not True or native.get("exited") is not False + or int(row.get("frame_serial", 0)) <= 0): + raise Failed("Frame lacks live initialized native Ren'Py state and pixels") + provider_path = self.output / f"provider-{stage}.png" + shutil.copyfile(self.documents / f"renpy-device-evidence/provider-{stage}.png", provider_path) + provider = png_pixels(provider_path) + self.ui("foreground") + screen = png_pixels(self.screenshot(stage)) + stats = {"provider": image_stats(provider), "os_screenshot": image_stats(screen)} + box, viewport = row["drawn_box"], row["viewport_size"] + if min(viewport) <= 0 or min(box[2:]) <= 0: + raise Failed("Frame lacks real Simulator presentation geometry") + matches = count = 0 + for iy in range(1, 8): + for ix in range(1, 12): + u, v = ix / 12, iy / 8 + expected = pixel(provider, int(u * provider[0]), int(v * provider[1])) + actual = pixel(screen, int((box[0] + u * box[2]) * screen[0] / viewport[0]), + int((box[1] + v * box[3]) * screen[1] / viewport[1])) + matches += max(abs(a - b) for a, b in zip(expected, actual)) <= 24 + count += 1 + stats["native_to_os_match"] = {"matching_samples": matches, "samples": count, + "fraction": matches / count, "channel_tolerance": 24} + (self.output / f"pixels-{stage}.json").write_text(json.dumps(stats, indent=2) + "\n") + if matches / count < 0.70: + raise Failed(f"Actual OS screenshot does not present Ren'Py native pixels at {stage}: {matches}/{count}") + self.summary["checks"].append({"stage": stage, "native_provider_pixels_and_os_screen": stats}) + return row + + def tap_button(self, frame: dict) -> None: + box, viewport = frame["drawn_box"], frame["viewport_size"] + self.ui("tap", x=(box[0] + box[2] * 0.5) / viewport[0], y=(box[1] + box[3] * 0.5) / viewport[1]) + + def create_simulator(self) -> None: + runtimes = json.loads(self.simctl("list", "runtimes", "--json")) + types = json.loads(self.simctl("list", "devicetypes", "--json")) + (self.output / "simulator-runtimes.json").write_text(json.dumps(runtimes, indent=2)) + available = [r for r in runtimes["runtimes"] if r.get("isAvailable") and ".iOS-" in r["identifier"]] + if not available: + raise Blocked("Xcode has no installed available iOS Simulator runtime; provision one on the cloud runner") + runtime = max(available, key=lambda r: tuple(int(v) for v in r["version"].split("."))) + device = None + # simctl performs the authoritative compatibility check. Prefer a + # supported iPhone from the runtime's own supportedDeviceTypes list. + supported = runtime.get("supportedDeviceTypes", []) + if supported: + device = next((d for d in reversed(supported) if d.get("productFamily") == "iPhone" or d.get("name", "").startswith("iPhone")), None) + if device is None: + device = next((d for d in reversed(types["devicetypes"]) if d["name"].startswith("iPhone")), None) + if device is None: + raise Blocked("No compatible iPhone Simulator device type is installed on the cloud host") + self.udid = self.simctl("create", "Aether RenPy gameplay " + self.run_id[:8], device["identifier"], runtime["identifier"]).decode().strip() + self.created = True + self.summary["runtime"] = runtime + + def stage(self) -> None: + self.documents.mkdir(parents=True, exist_ok=True) + for name in ("renpy-device-demo", "renpy-device-evidence"): + shutil.rmtree(self.documents / name, ignore_errors=True) + game = self.documents / "renpy-device-demo/game" + game.mkdir(parents=True) + for source in (self.repo / "demos/aetherkiri-renpy/game").iterdir(): + if source.is_file() and source.suffix in (".rpy", ".svg"): + shutil.copyfile(source, game / source.name) + (game / "aether-device-request.json").write_text(json.dumps({"run_id": self.run_id})) + (self.documents / "aetherkiri-probe-request.json").write_text(json.dumps({ + "probe_script": "res://scripts/renpy_mobile_acceptance.gd", "run_id": self.run_id, + "game_path": str(game.parent), "timeout_seconds": self.args.overall_timeout})) + + def start_xcuitest(self) -> None: + project = self.repo / "tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj" + derived = self.output / "xcode-derived" + destination = "platform=iOS Simulator,id=" + self.udid + self.command("xcodebuild", "build-for-testing", "-project", str(project), "-scheme", "RenPyAcceptance", + "-configuration", "Debug", "-destination", destination, + "-derivedDataPath", str(derived), "CODE_SIGN_IDENTITY=-", timeout=240) + paths = list((derived / "Build/Products").glob("*.xctestrun")) + if len(paths) != 1: + raise Failed("Xcode did not produce one executable UI test configuration") + configuration = plistlib.loads(paths[0].read_bytes()) + targets = [] + + def configure(value): + if isinstance(value, dict): + if "TestBundlePath" in value and value.get("IsUITestBundle"): + value["UITargetAppPath"] = str(self.args.app.resolve()) + value["UITargetAppBundleIdentifier"] = self.bundle_id + value.setdefault("EnvironmentVariables", {}).update({ + "AETHER_RENPY_RUN_ID": self.run_id, "AETHER_RENPY_APP_BUNDLE_ID": self.bundle_id, + "AETHER_RENPY_TEST_TIMEOUT": str(self.args.overall_timeout)}) + value.setdefault("DependentProductPaths", []).append(str(self.args.app.resolve())) + targets.append(value) + else: + for child in value.values(): + configure(child) + elif isinstance(value, list): + for child in value: + configure(child) + + configure(configuration) + if len(targets) != 1: + raise Failed("Xcode UI configuration lacks the real UI test target; refusing to substitute a fake host") + xctestrun = self.output / "RenPyGameplay.xctestrun" + # __TESTROOT__ is relative to the .xctestrun location. + def resolve_paths(value): + if isinstance(value, dict): + for key, child in value.items(): + value[key] = resolve_paths(child) + elif isinstance(value, list): + return [resolve_paths(child) for child in value] + elif isinstance(value, str): + return value.replace("__TESTROOT__", str(paths[0].parent)) + return value + + xctestrun.write_bytes(plistlib.dumps(resolve_paths(configuration))) + runners = list((derived / "Build/Products").glob("**/RenPyAcceptance-Runner.app/Info.plist")) + if len(runners) != 1: + raise Failed("Xcode did not build the real Simulator UI test runner app") + runner_id = plistlib.loads(runners[0].read_bytes())["CFBundleIdentifier"] + self.summary["ui_test_runner"] = runner_id + self.process_log = (self.output / "xcuitest.log").open("wb") + self.process = subprocess.Popen(["xcodebuild", "test-without-building", "-xctestrun", str(xctestrun), + "-destination", destination, "-resultBundlePath", str(self.output / "gameplay.xcresult"), + "-parallel-testing-enabled", "NO"], stdout=self.process_log, stderr=subprocess.STDOUT) + + def ready(): + data = self.simctl("get_app_container", self.udid, runner_id, "data", check=False).decode().strip() + if not data: + return None + root = Path(data) / "Documents" / ("aether-renpy-ui-" + self.run_id) + try: + report = json.loads((root / "ready.json").read_text()) + if report.get("run_id") == self.run_id and report.get("ready") is True: + self.ui_root = root + return report + except (OSError, ValueError): + return None + + self.wait("installed XCUITest runner and actual app launch", ready, timeout=180) + + def run(self) -> None: + if not self.args.cloud_simulator: + raise Blocked("--cloud-simulator is required: only cloud-hosted devices are authorized") + if platform.system() != "Darwin" or not shutil.which("xcrun") or not shutil.which("xcodebuild"): + raise Blocked("Actual iOS Simulator gameplay requires a cloud macOS host with Xcode and its licensed SDK/runtime") + if not (self.args.app / "Info.plist").is_file(): + raise Blocked(f"Installable Ren'Py-enabled Simulator .app is missing: {self.args.app}") + info = plistlib.loads((self.args.app / "Info.plist").read_bytes()) + self.bundle_id = info["CFBundleIdentifier"] + if "iPhoneSimulator" not in info.get("CFBundleSupportedPlatforms", []): + raise Blocked("The provided .app targets iPhoneOS; this harness needs an actual Simulator build") + self.summary["bundle_id"] = self.bundle_id + self.summary["app_executable_sha256"] = hashlib.sha256((self.args.app / info["CFBundleExecutable"]).read_bytes()).hexdigest() + self.summary["xcode_version"] = self.command("xcodebuild", "-version").decode().strip() + if not self.udid: + self.create_simulator() + self.summary["udid"] = self.udid + devices = json.loads(self.simctl("list", "devices", "--json")) + selected = next((d for group in devices["devices"].values() for d in group if d["udid"] == self.udid and d.get("isAvailable")), None) + if selected is None: + raise Blocked(f"Selected cloud iOS Simulator {self.udid} is unavailable") + if selected["state"] != "Booted": + self.simctl("boot", self.udid) + self.simctl("bootstatus", self.udid, "-b", timeout=180) + self.keyboard_preference = self.command("defaults", "read", "com.apple.iphonesimulator", "ConnectHardwareKeyboard", check=False).decode().strip() or None + self.command("defaults", "write", "com.apple.iphonesimulator", "ConnectHardwareKeyboard", "-bool", "false") + self.preference_changed = True + self.simctl("install", self.udid, str(self.args.app.resolve()), timeout=180) + self.installed = True + self.simctl("terminate", self.udid, self.bundle_id, check=False) + root = self.simctl("get_app_container", self.udid, self.bundle_id, "data").decode().strip() + self.documents = Path(root) / "Documents" + self.stage() + self.deadline = time.monotonic() + self.args.overall_timeout + self.start_xcuitest() + self.wait_game("start_ready") + self.tap_button(self.capture("start_ready")) + self.wait_game("touch_received") + self.wait_game("text_ready") + keyboard = self.wait_observer("soft_keyboard", active=True) + visible = self.wait_observer("soft_keyboard_visibility", visible=True) + if keyboard.get("source") != "native-text-input-state" or not keyboard.get("feature_available") or visible.get("height", 0) <= 0: + raise Failed("Native text input did not show a real iOS virtual keyboard") + keyboard_ui = self.ui("keyboard") + if keyboard_ui.get("keyboard_visible") is not True: + raise Failed("XCUITest could not see the actual iOS software keyboard") + self.screenshot("text-ready-keyboard") + typed = "CloudRenPy" + self.run_id[:8] + self.ui("type", text=typed + "\n") + text = self.wait_game("text_received") + if text.get("details", {}).get("text") != typed: + raise Failed(f"Ren'Py input differs from actual system keyboard text: {text.get('details')}") + self.wait_game("post_text_ready") + self.wait_observer("soft_keyboard_visibility", visible=False) + self.capture("post_text_ready") + self.ui("home") + pause = self.wait_observer("lifecycle", phase="paused") + if pause.get("result") != 0 or pause.get("provider_debug", {}).get("paused") is not True: + raise Failed("Home did not pause the actual native runtime") + self.screenshot("background-home") + self.ui("activate") + resume = self.wait_observer("lifecycle", phase="resumed") + if resume.get("result") != 0 or resume.get("provider_debug", {}).get("paused") is not False: + raise Failed("Foreground did not resume the actual native runtime") + frame = self.capture("resumed_ready") + if int(frame.get("frame_serial", 0)) <= int(pause.get("provider_debug", {}).get("frame_serial", 0)): + raise Failed("Resume presented a cached pre-pause frame") + if any(r.get("stage") == "resumed_touch_received" for r in self.game_rows()): + raise Failed("Ren'Py advanced before the real resumed UI touch") + self.tap_button(frame) + self.wait_game("resumed_touch_received") + self.wait_game("quit_ready") + self.tap_button(self.capture("quit_ready")) + self.wait_game("quit_requested") + normal_exit = self.wait_observer("normal_exit") + if not normal_exit.get("engine_destroyed") or normal_exit.get("provider_debug", {}).get("exited") is not True: + raise Failed("Explicit Quit did not stop native Ren'Py and destroy its engine") + library = self.wait_observer("library_return") + self.verify_library_return(library) + events = self.observer_rows() + for action in (1, 3): + if not any(r.get("kind") == "os_touch" and r.get("action") == action and r.get("result") == 0 for r in events): + raise Failed(f"Actual iOS touch action {action} never reached the provider") + if not any(r.get("kind") == "os_key" and r.get("pressed") and r.get("code") == 13 for r in events): + raise Failed("Actual iOS Enter key never reached the provider") + self.ui("finish") + try: + returncode = self.process.wait(timeout=30) + except subprocess.TimeoutExpired as exc: + raise Failed("XCUITest did not finish after complete gameplay") from exc + if returncode: + raise Failed(f"Actual XCUITest failed (exit={returncode}); inspect its retained xcresult") + self.summary["checks"].extend([{"real_ios_touch_and_keyboard_text": typed}, + {"real_ios_pause_resume": {"pause": pause, "resume": resume}}, + {"explicit_renpy_exit_and_actual_library_return": {"native": normal_exit, "library": library}}]) + self.summary["status"] = "passed" + + def verify_library_return(self, report: dict) -> None: + ui = report.get("ui", {}) + if (report.get("source") != "actual-production-main-scene" or not report.get("engine_destroyed") + or ui.get("scene_path") != "res://scenes/main.tscn" + or ui.get("script_path") != "res://scripts/main.gd" + or not ui.get("shell_visible") or not ui.get("library_visible") + or ui.get("game_view_visible") is not False or ui.get("route") != "library" + or ui.get("mode") != "game" or not ui.get("title") or not ui.get("title_visible") + or not ui.get("search_visible") or not ui.get("native_exited") or ui.get("image_result") != 0 + or not ui.get("library_path", "").endswith("/LibraryView") + or not ui.get("search_path", "").endswith("/LibrarySearch")): + raise Failed("Normal game exit did not restore the actual main scene's game library") + self.ui("foreground") + actual_path = self.output / "production-library.png" + shutil.copyfile(self.documents / "renpy-device-evidence/production-library.png", actual_path) + library = png_pixels(actual_path) + screen = png_pixels(self.screenshot("actual-library-after-game-exit")) + stats = {"library_viewport": image_stats(library), "os_screenshot": image_stats(screen)} + matches = count = 0 + # Prove the restored production library is actually visible in the + # OS compositor. Only its viewport, never a test-painted label, is + # used for this final UI comparison; gameplay used native RGBA above. + for iy in range(1, 10): + for ix in range(1, 10): + u, v = ix / 10, iy / 10 + expected = pixel(library, int(u * library[0]), int(v * library[1])) + actual = pixel(screen, int(u * screen[0]), int(v * screen[1])) + matches += max(abs(a - b) for a, b in zip(expected, actual)) <= 24 + count += 1 + stats["library_to_os_match"] = {"matching_samples": matches, "samples": count, + "fraction": matches / count, "channel_tolerance": 24} + (self.output / "pixels-actual-library.json").write_text(json.dumps(stats, indent=2) + "\n") + if matches / count < 0.70: + raise Failed("The actual restored LibraryView is absent from the system screenshot") + self.summary["checks"].append({"actual_library_return_pixels_and_os_screen": stats}) + + def collect(self) -> None: + if self.documents is not None: + for relative, filename in [ + ("renpy-device-evidence/observer.jsonl", "observer.jsonl"), + ("renpy-device-demo/game/aether-device-checkpoints.jsonl", "renpy-script-checkpoints.jsonl"), + ("renpy-device-demo/log.txt", "renpy-log.txt"), + ("renpy-device-demo/traceback.txt", "renpy-traceback.txt")]: + source = self.documents / relative + if source.is_file(): + shutil.copyfile(source, self.output / filename) + if self.process is not None and self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=10) + if self.process_log is not None: + self.process_log.close() + if self.installed: + try: + logs = self.simctl("spawn", self.udid, "log", "show", "--last", "10m", "--style", "compact", check=False, timeout=60) + (self.output / "simulator-system.log").write_bytes(logs) + except Failed: + pass + if self.preference_changed: + if self.keyboard_preference is None: + self.command("defaults", "delete", "com.apple.iphonesimulator", "ConnectHardwareKeyboard", check=False) + else: + self.command("defaults", "write", "com.apple.iphonesimulator", "ConnectHardwareKeyboard", "-bool", self.keyboard_preference, check=False) + if self.created: + self.simctl("shutdown", self.udid, check=False) + self.simctl("delete", self.udid, check=False) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--app", type=Path, required=True) + parser.add_argument("--cloud-simulator", action="store_true") + parser.add_argument("--udid", help="Optional existing cloud Simulator; otherwise create and delete a disposable one") + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--stage-timeout", type=int, default=60) + parser.add_argument("--overall-timeout", type=int, default=600) + args = parser.parse_args() + if not 1 <= args.stage_timeout <= 180 or not 60 <= args.overall_timeout <= 1200: + parser.error("stage timeout must be 1..180 and overall timeout 60..1200 seconds") + acceptance = Acceptance(args) + result = 1 + try: + acceptance.run() + result = 0 + except Blocked as exc: + acceptance.summary.update(status="not_run", blocker=str(exc)) + result = 2 + except (Failed, OSError, ValueError, KeyError) as exc: + acceptance.summary.update(status="failed", blocker=str(exc)) + finally: + try: + acceptance.collect() + except (Failed, OSError) as exc: + acceptance.summary["collection_error"] = str(exc) + (acceptance.output / "result.json").write_text(json.dumps(acceptance.summary, indent=2) + "\n") + print(json.dumps(acceptance.summary, indent=2)) + return result + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/run_renpy_mobile_source_build.sh b/tools/run_renpy_mobile_source_build.sh index 3d7bd0fb7..7c13fd932 100755 --- a/tools/run_renpy_mobile_source_build.sh +++ b/tools/run_renpy_mobile_source_build.sh @@ -102,20 +102,12 @@ if [[ "$mode" == check ]]; then fi clone_pin https://github.com/renpy/renpy.git "$renpy_build/renpy" "$renpy_sha" clone_pin https://github.com/python-greenlet/greenlet.git "$renpy_build/aether-greenlet" "$greenlet_sha" -apply_once() { - local checkout="$1" patch="$2" - if git -C "$checkout" apply --reverse --check "$patch" >/dev/null 2>&1; then - echo "already applied: $(basename "$patch")" - else - git -C "$checkout" apply --check "$patch" - git -C "$checkout" apply "$patch" - echo "applied: $(basename "$patch")" - fi -} -apply_once "$renpy_build/renpy" "$patch_root/python/0001-cooperative-loop-skeleton.patch" -for patch in "$patch_root/native"/000[0-9]-*.patch; do - apply_once "$renpy_build" "$patch" -done +python3 "$repo_root/tools/apply_renpy_mobile_patch_series.py" \ + --checkout "$renpy_build/renpy" --pin "$renpy_sha" \ + "$patch_root/python/0001-cooperative-loop-skeleton.patch" +python3 "$repo_root/tools/apply_renpy_mobile_patch_series.py" \ + --checkout "$renpy_build" --pin "$build_sha" \ + "$patch_root/native"/000[0-9]-*.patch cp "$patch_root/native/renpy_mobile_lifecycle.c" "$renpy_build/runtime/renpy_mobile_lifecycle.c" cp "$repo_root/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h" "$renpy_build/runtime/renpy_mobile_launcher.h" mkdir -p "$renpy_build/runtime/greenlet" diff --git a/tools/test_renios_ios_bundle.sh b/tools/test_renios_ios_bundle.sh index 6ce520637..5dc0d8fc3 100644 --- a/tools/test_renios_ios_bundle.sh +++ b/tools/test_renios_ios_bundle.sh @@ -11,6 +11,7 @@ launcher_probe="$repo_root/tools/test_renios_ios_launcher.sh" [[ -f "$launcher_probe" ]] || { echo "Renios launcher probe is missing" >&2; exit 1; } bash -n "$build_script" bash -n "$launcher_probe" +python3 "$repo_root/tools/test_renpy_ios_runtime_project.py" grep -Fq 'AETHERKIRI_RENPY_MOBILE_ROOT' "$build_script" grep -Fq 'collect_renios_archives' "$build_script" diff --git a/tools/test_renpy_ios_runtime_project.py b/tools/test_renpy_ios_runtime_project.py new file mode 100644 index 000000000..d2e31dc83 --- /dev/null +++ b/tools/test_renpy_ios_runtime_project.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Check that the real Xcode project mutation links AND embeds its runtime.""" + +import tempfile +import unittest +from pathlib import Path + +from patch_renpy_ios_runtime_project import patch + + +PROJECT = '''// !$*UTF8*$! +{ +objects = { +/* Begin PBXBuildFile section */ +/* End PBXBuildFile section */ +/* Begin PBXFileReference section */ +/* End PBXFileReference section */ +/* Begin PBXGroup section */ + 111111111111111111111111 /* Aether */ = { + isa = PBXGroup; + children = ( + ); + }; +/* End PBXGroup section */ +/* Begin PBXFrameworksBuildPhase section */ + 222222222222222222222222 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + files = ( + ); + }; +/* End PBXFrameworksBuildPhase section */ +/* Begin PBXCopyFilesBuildPhase section */ + 333333333333333333333333 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + }; +/* End PBXCopyFilesBuildPhase section */ +}; +} +''' + + +class RuntimeProjectTests(unittest.TestCase): + def test_link_embed_and_idempotence(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + project = root / "project.pbxproj" + framework = root / "AetherRenPyRuntime.framework" + framework.mkdir() + project.write_text(PROJECT) + patch(project, framework) + actual = project.read_text() + self.assertIn("CodeSignOnCopy, RemoveHeadersOnCopy", actual) + self.assertIn("path = Frameworks/AetherRenPyRuntime.framework", actual) + link = actual.split("/* Begin PBXFrameworksBuildPhase section */")[1].split("/* End PBXFrameworksBuildPhase section */")[0] + embed = actual.split("/* Begin PBXCopyFilesBuildPhase section */")[1].split("/* End PBXCopyFilesBuildPhase section */")[0] + self.assertIn("AetherRenPyRuntime.framework in Frameworks */,", link) + self.assertIn("AetherRenPyRuntime.framework in Embed Frameworks */,", embed) + patch(project, framework) + self.assertEqual(project.read_text(), actual) + + def test_missing_embed_phase_rejects_atomic_mutation(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + project = root / "project.pbxproj" + framework = root / "AetherRenPyRuntime.framework" + framework.mkdir() + original = PROJECT.replace("dstSubfolderSpec = 10;", "dstSubfolderSpec = 7;") + project.write_text(original) + with self.assertRaisesRegex(ValueError, "embed phase"): + patch(project, framework) + self.assertEqual(project.read_text(), original) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/test_renpy_mobile_patch_series.py b/tools/test_renpy_mobile_patch_series.py new file mode 100644 index 000000000..a913d71c0 --- /dev/null +++ b/tools/test_renpy_mobile_patch_series.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Verify complete-series idempotency and drift refusal on genuine pins. + +Optional Ren'Py/greenlet checkouts also exercise prepare -> prepare -> the real +build preflight. That preflight must reject the absent NDK, not pretend a build. +""" +import argparse +from pathlib import Path +import subprocess +import sys +import tempfile + +REPO = Path(__file__).resolve().parents[1] +HELPER = REPO / "tools/apply_renpy_mobile_patch_series.py" +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" + + +def clone(source, destination, pin): + subprocess.run(["git", "clone", "--shared", "--quiet", "--no-checkout", str(source), str(destination)], check=True) + subprocess.run(["git", "-C", str(destination), "checkout", "--quiet", "--detach", pin], check=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build_source", type=Path) + parser.add_argument("--renpy-src", type=Path) + parser.add_argument("--greenlet-src", type=Path) + args = parser.parse_args() + patches = sorted((REPO / "bridge/renpy_runtime/mobile_launcher/patches/native").glob("000[0-9]-*.patch")) + with tempfile.TemporaryDirectory(prefix="renpy-patch-series-") as temporary: + root = Path(temporary) / "renpy-build" + clone(args.build_source, root, PIN) + command = [sys.executable, str(HELPER), "--checkout", str(root), "--pin", PIN, *map(str, patches)] + subprocess.run(command, check=True, stdout=subprocess.DEVNULL) + repeated = subprocess.run(command, check=True, capture_output=True, text=True) + assert "already applied and verified" in repeated.stdout + for relative in ("runtime/librenpython3.c", "build.sh"): + path = root / relative + before = path.read_bytes() + changed = before + b"\n# actual source drift\n" + path.write_bytes(changed) + failure = subprocess.run(command, capture_output=True, text=True) + assert failure.returncode != 0 and "drift" in failure.stderr.lower(), failure.stderr + assert path.read_bytes() == changed, "drift was overwritten instead of rejected" + path.write_bytes(before) + subprocess.run(command, check=True, stdout=subprocess.DEVNULL) + partial = Path(temporary) / "partial" + clone(args.build_source, partial, PIN) + subprocess.run(["git", "apply", str(patches[0])], cwd=partial, check=True) + failure = subprocess.run([sys.executable, str(HELPER), "--checkout", str(partial), "--pin", PIN, *map(str, patches)], capture_output=True, text=True) + assert failure.returncode != 0 and "partial/stale patch series" in failure.stderr, failure.stderr + if args.renpy_src or args.greenlet_src: + assert args.renpy_src and args.greenlet_src + clone(args.renpy_src, root / "renpy", "39895c1e017f0b36ffea2447d97eccd69d76ee1c") + clone(args.greenlet_src, root / "aether-greenlet", "65f8da82b13a1273e55a6bfcbd1f9da09fc4eb7a") + runner = ["bash", str(REPO / "tools/run_renpy_mobile_source_build.sh"), "--platform", "android", "--renpy-build", str(root)] + for _ in range(2): + subprocess.run([*runner, "--mode", "prepare"], check=True, stdout=subprocess.DEVNULL) + preflight = subprocess.run([*runner, "--mode", "build"], capture_output=True, text=True) + assert preflight.returncode != 0 and "source-build preflight failed" in preflight.stdout + preflight.stderr, preflight.stdout + preflight.stderr + assert "patch does not apply" not in preflight.stderr + assert "android-ndk-r29-linux.zip" in preflight.stdout + preflight.stderr + print("Real pinned prepare -> prepare -> build preflight passed; missing NDK correctly refused compilation") + print("Complete pinned patch-series idempotency and source-drift checks passed; no native build or gameplay performed") + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_source_build_plan.sh b/tools/test_renpy_mobile_source_build_plan.sh index bb605bedf..358eee807 100755 --- a/tools/test_renpy_mobile_source_build_plan.sh +++ b/tools/test_renpy_mobile_source_build_plan.sh @@ -39,7 +39,7 @@ python3 - "$runner" <<'PY' from pathlib import Path import sys source = Path(sys.argv[1]).read_text() -assert source.index('apply_once "$renpy_build/renpy"') < source.index('./build.sh --platform android') +assert source.index('--checkout "$renpy_build/renpy" --pin "$renpy_sha"') < source.index('./build.sh --platform android') assert source.index('"$patch_root/native"/000[0-9]-*.patch') < source.index('./build.sh --platform android') assert '"$mode" == "check" || "$mode" == "auto"' not in source, 'auto must not mean check-only' for symbol in ('renpy_mobile_text_input_state', 'renpy_mobile_set_surface_size'): From 73282c7a5de73a5f0f7b9156ce3f54720647551b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 13:46:48 +0000 Subject: [PATCH 259/331] fix(renpy): compile static Cython APIs and support macOS Bash preflight Use the actual gen3-static headers for C and C++ runtime objects, handle empty SDK arrays under macOS Bash 3.2, and replace obsolete builds with the newest checked head. Add a real Android cloud emulator boot check that uses existing KVM/SDK permissions and records device availability separately from gameplay. --- .github/workflows/build.yml | 6 +- .github/workflows/renpy-android-device.yml | 44 ++++ .github/workflows/renpy-ios-runtime-build.yml | 2 +- .../workflows/renpy-mobile-runtime-build.yml | 2 +- bridge/renpy_runtime/mobile_launcher/build.sh | 3 +- .../native/0001-renpy-build-link.patch | 13 + .../mobile_launcher/patches/native/README.md | 5 + tools/preflight_renpy_android_device.py | 236 ++++++++++++++++++ tools/run_renpy_mobile_source_build.sh | 2 +- 9 files changed, 306 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/renpy-android-device.yml create mode 100644 tools/preflight_renpy_android_device.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0f26dbb30..82bbb8ae5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,9 +50,9 @@ permissions: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name }} - # Let full source rebuilds finish. A newer commit replaces the pending run, - # while the running build can still publish its checked runtime evidence. - cancel-in-progress: false + # Build the newest checked implementation; completed runs retain their + # logs/artifacts and obsolete source failures must not hold up app packaging. + cancel-in-progress: true env: # GitHub does not expose repository secrets to fork or Dependabot pull diff --git a/.github/workflows/renpy-android-device.yml b/.github/workflows/renpy-android-device.yml new file mode 100644 index 000000000..cb5973d62 --- /dev/null +++ b/.github/workflows/renpy-android-device.yml @@ -0,0 +1,44 @@ +name: Ren'Py Android Cloud Device + +on: + push: + branches: + - main + pull_request: + paths: + - '.github/workflows/renpy-android-device.yml' + - 'tools/preflight_renpy_android_device.py' + - 'tools/run_renpy_android_device_acceptance.py' + - 'apps/godot_app/scripts/renpy_mobile_acceptance.gd' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: renpy-android-device-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + cloud-device-preflight: + name: Boot actual official Android emulator (device availability only) + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Checkout device preflight + uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Check existing KVM and boot official Android 35 x86_64 + run: | + python3 tools/preflight_renpy_android_device.py \ + --cloud-emulator --boot-timeout 480 \ + --output-dir "${RUNNER_TEMP}/renpy-android-device-preflight" + - name: Preserve actual device result and logs + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-android-cloud-device-preflight + path: ${{ runner.temp }}/renpy-android-device-preflight + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index 49ebd71cb..ba50be21f 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -22,7 +22,7 @@ permissions: concurrency: group: renpy-ios-source-${{ github.event.pull_request.number || github.ref }}-${{ github.workflow }} - cancel-in-progress: false + cancel-in-progress: true jobs: ios-test-driver: diff --git a/.github/workflows/renpy-mobile-runtime-build.yml b/.github/workflows/renpy-mobile-runtime-build.yml index 1a1f40951..301ebb28c 100644 --- a/.github/workflows/renpy-mobile-runtime-build.yml +++ b/.github/workflows/renpy-mobile-runtime-build.yml @@ -15,7 +15,7 @@ permissions: concurrency: group: renpy-android-source-${{ github.event.pull_request.number || github.ref }}-${{ github.workflow }} - cancel-in-progress: false + cancel-in-progress: true jobs: android-runtime: diff --git a/bridge/renpy_runtime/mobile_launcher/build.sh b/bridge/renpy_runtime/mobile_launcher/build.sh index 61ef563a2..d09c16c1f 100644 --- a/bridge/renpy_runtime/mobile_launcher/build.sh +++ b/bridge/renpy_runtime/mobile_launcher/build.sh @@ -151,7 +151,8 @@ renpy_build_source_preflight() { if [[ "$platform" != android && "$host_system" != Darwin ]]; then archives+=(iPhoneOS14.0.sdk.tar.gz iPhoneSimulator14.0.sdk.tar.gz) fi - for required in "${archives[@]}"; do + # Bash 3.2 treats an empty array as unset under nounset (the macOS default). + for required in ${archives[@]+"${archives[@]}"}; do if [[ ! -f "$renpy_build/tars/$required" ]]; then echo "missing toolchain archive: $renpy_build/tars/$required" >&2 failures=$((failures + 1)) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch index bf5c87ab9..b9f8c7a1d 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0001-renpy-build-link.patch @@ -1,3 +1,16 @@ +diff --git a/tasks/librenpy.py b/tasks/librenpy.py +--- a/tasks/librenpy.py ++++ b/tasks/librenpy.py +@@ -24,7 +24,7 @@ def build(c: Context): + if c.platform == "web" and c.python == "2": + return + +- c.env("CFLAGS", """{{ CFLAGS }} "-I{{ renpy }}/src" "-I{{renpy}}/tmp/gen3" """) +- c.env("CXXFLAGS", """{{ CXXFLAGS }} "-I{{ renpy }}/src" "-I{{renpy}}/tmp/gen3" """) ++ c.env("CFLAGS", """{{ CFLAGS }} "-I{{ renpy }}/src" "-I{{renpy}}/tmp/gen3-static" "-I{{renpy}}/tmp/gen3" """) ++ c.env("CXXFLAGS", """{{ CXXFLAGS }} "-I{{ renpy }}/src" "-I{{renpy}}/tmp/gen3-static" "-I{{renpy}}/tmp/gen3" """) + + gen = "gen3-static/" diff --git a/tasks/renpython.py b/tasks/renpython.py index 114bc33..3bf1a3a 100644 --- a/tasks/renpython.py diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index 33afc607d..e255b9e29 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -6,6 +6,11 @@ ABI header into that checkout. It compiles the shared native lifecycle and linked greenlet for Android and every selected iOS target. The platform bootstrap initializes CPython without calling the process launchers. +`0001-renpy-build-link.patch` also includes the static Cython output directory +when compiling Ren'Py's C and C++ sources. Its generated surface API header +lives in `tmp/gen3-static`, so a clean rebuild does not depend on a previous +desktop generation having populated `tmp/gen3`. + `0005-optional-cubism.patch` keeps the base runtime build independent of the optional licensed SDKs. It enables Live2D generation and its native module only when the target Cubism header exists, and packages `steamapi` only when the diff --git a/tools/preflight_renpy_android_device.py b/tools/preflight_renpy_android_device.py new file mode 100644 index 000000000..6867ec43d --- /dev/null +++ b/tools/preflight_renpy_android_device.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Boot a real official Android emulator using existing cloud KVM access. + +This checks device availability only. It never installs an APK, executes a +Ren'Py game, changes security permissions, or accepts new SDK licenses. +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import subprocess +import tempfile +import time +from pathlib import Path + +from run_renpy_android_device_acceptance import Failed, image_stats, png_pixels + + +class Unavailable(RuntimeError): + pass + + +class Preflight: + def __init__(self, args: argparse.Namespace): + self.args = args + self.output = args.output_dir.resolve() + self.output.mkdir(parents=True, exist_ok=True) + self.environment = dict(os.environ) + self.emulator_process = None + self.emulator_log = None + self.adb = None + self.execution_started = False + self.result = {"status": "not_run", "scope": "actual-cloud-emulator-preflight", + "execution_started": False, + "gameplay_executed": False, "apk_installed": False, + "host": {"system": platform.system(), "machine": platform.machine()}, + "serial": f"emulator-{args.port}", "checks": [], "blocker": ""} + + def command(self, label: str, command: list[str], *, timeout: int = 30, + check: bool = True, input_data: bytes | None = None) -> subprocess.CompletedProcess: + try: + streams = {"input": input_data} if input_data is not None else {"stdin": subprocess.DEVNULL} + completed = subprocess.run(command, **streams, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=timeout, env=self.environment) + except subprocess.TimeoutExpired as exc: + (self.output / f"{label}.log").write_bytes(exc.stdout or b"") + raise Unavailable(f"{label} timed out; partial real output is preserved: {exc}") from exc + except OSError as exc: + raise Unavailable(f"{label} could not complete: {exc}") from exc + (self.output / f"{label}.log").write_bytes(completed.stdout) + with (self.output / "commands.jsonl").open("a") as stream: + stream.write(json.dumps({"label": label, "command": command, + "returncode": completed.returncode, "time": time.time()}) + "\n") + if check and completed.returncode: + detail = completed.stdout.decode(errors="replace").strip()[-1800:] + raise Unavailable(f"{label} failed with exit {completed.returncode}: {detail}") + return completed + + def device(self, label: str, *parts: str, check: bool = True, timeout: int = 30): + return self.command(label, [str(self.adb), "-s", self.result["serial"], *parts], + check=check, timeout=timeout) + + def run(self) -> None: + if not self.args.cloud_emulator: + raise Unavailable("--cloud-emulator is required; only the selected cloud environment is authorized") + sdk = Path(self.args.sdk_root).resolve() if self.args.sdk_root else None + kvm = Path("/dev/kvm") + self.result["kvm"] = {"exists": kvm.exists(), "readable": os.access(kvm, os.R_OK), + "writable": os.access(kvm, os.W_OK)} + if kvm.exists(): + self.result["kvm"]["mode"] = oct(kvm.stat().st_mode & 0o777) + problems = [] + if platform.system() != "Linux" or platform.machine() != "x86_64": + problems.append("This official x86_64 emulator preflight requires an existing Linux x86_64 cloud runner") + if sdk is None or not sdk.is_dir(): + problems.append("Official Android SDK is unavailable; supply its existing ANDROID_SDK_ROOT/ANDROID_HOME") + emulator = sdk / "emulator/emulator" if sdk else None + sdkmanager = sdk / "cmdline-tools/latest/bin/sdkmanager" if sdk else None + avdmanager = sdk / "cmdline-tools/latest/bin/avdmanager" if sdk else None + for tool in (sdkmanager, avdmanager): + if tool is not None and not tool.is_file(): + problems.append(f"Existing official SDK tool is unavailable: {tool}") + if emulator is not None and emulator.is_file(): + acceleration = self.command("acceleration", [str(emulator), "-accel-check"], check=False) + self.result["acceleration_exit_code"] = acceleration.returncode + if acceleration.returncode: + problems.append("Official emulator reports that existing KVM acceleration is unavailable; see acceleration.log") + if not all(self.result["kvm"][key] for key in ("exists", "readable", "writable")): + problems.append("Existing /dev/kvm read/write access is unavailable; no permissions or security settings were changed") + if problems: + raise Unavailable("; ".join(problems)) + if not emulator.is_file(): + self.command("official-emulator-install", [str(sdkmanager), "--install", "emulator"], timeout=300) + if not emulator.is_file(): + raise Unavailable("Official emulator installation did not complete; check SDK download/license logs") + acceleration = self.command("acceleration", [str(emulator), "-accel-check"], check=False) + self.result["acceleration_exit_code"] = acceleration.returncode + if acceleration.returncode: + raise Unavailable("Official emulator reports existing KVM acceleration is unavailable; see acceleration.log") + self.result["sdk_root"] = str(sdk) + self.command("emulator-version", [str(emulator), "-version"]) + image = "system-images;android-35;google_apis;x86_64" + self.result["system_image"] = image + # sdkmanager uses its normal official repositories and existing license + # files. DEVNULL does not automatically approve a license prompt. + self.command("official-sdk-install", [str(sdkmanager), "--install", "platform-tools", image], timeout=600) + image_dir = sdk / "system-images/android-35/google_apis/x86_64" + properties = image_dir / "source.properties" + if not properties.is_file(): + raise Unavailable("Official system image was not installed; check SDK download/license logs") + (self.output / "system-image-source.properties").write_bytes(properties.read_bytes()) + self.adb = sdk / "platform-tools/adb" + if not self.adb.is_file(): + raise Unavailable("Official Android platform-tools installation did not supply adb") + self.command("adb-version", [str(self.adb), "version"]) + with tempfile.TemporaryDirectory(prefix="renpy-cloud-emulator-preflight-") as temporary: + avd_root = Path(temporary) / "avd" + avd_root.mkdir() + self.environment["ANDROID_AVD_HOME"] = str(avd_root) + avd_name = "aether-renpy-cloud-preflight" + self.command("create-official-avd", [str(avdmanager), "create", "avd", "--name", avd_name, + "--force", "--device", "pixel_2", "--package", image], input_data=b"no\n", timeout=120) + config = avd_root / f"{avd_name}.avd/config.ini" + if not config.is_file(): + raise Unavailable("Official avdmanager did not create a usable virtual device") + with config.open("a") as stream: + stream.write("\nhw.keyboard=no\n") + (self.output / "avd-config.ini").write_bytes(config.read_bytes()) + command = [str(emulator), "-avd", avd_name, "-port", str(self.args.port), "-accel", "on", + "-gpu", "swiftshader", "-no-window", "-no-audio", "-no-boot-anim", "-no-snapshot", + "-memory", "2048", "-camera-front", "none", "-camera-back", "none"] + self.result["emulator_command"] = command + self.emulator_log = (self.output / "emulator.log").open("wb") + self.emulator_process = subprocess.Popen(command, stdout=self.emulator_log, stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, env=self.environment) + self.execution_started = True + self.result["execution_started"] = True + try: + deadline = time.monotonic() + self.args.boot_timeout + self.device("adb-wait-for-device", "wait-for-device", timeout=min(180, self.args.boot_timeout)) + while time.monotonic() < deadline: + if self.emulator_process.poll() is not None: + raise Unavailable(f"Actual emulator exited before Android boot: {self.emulator_process.returncode}; see emulator.log") + boot = self.device("boot-completed", "shell", "getprop", "sys.boot_completed", timeout=20) + if boot.stdout.decode().strip() == "1": + break + time.sleep(2) + else: + raise Unavailable(f"Actual emulator did not boot within {self.args.boot_timeout} seconds; see emulator.log") + self.device("unlock", "shell", "input", "keyevent", "82") + self.device("device-properties", "shell", "getprop") + wanted = ["ro.build.fingerprint", "ro.product.cpu.abilist", "ro.build.version.sdk", "ro.opengles.version"] + values = {} + for name in wanted: + values[name] = self.device("property-" + name, "shell", "getprop", name).stdout.decode().strip() + self.result["device_properties"] = values + if "x86_64" not in values["ro.product.cpu.abilist"].split(",") or values["ro.build.version.sdk"] != "35": + raise Unavailable("Booted device does not match the requested official Android 35 x86_64 image") + if int(values["ro.opengles.version"] or "0") < 0x30000: + raise Unavailable("Booted device does not advertise the GLES 3.0 required by the application") + surface = self.device("surfaceflinger", "shell", "dumpsys", "SurfaceFlinger").stdout.decode(errors="replace") + gles = [line.strip() for line in surface.splitlines() if "GLES:" in line] + self.result["surfaceflinger_gles"] = gles + if not gles: + raise Unavailable("Actual SurfaceFlinger did not expose a GLES renderer; see surfaceflinger.log") + screenshot = self.output / "actual-android-screen.png" + screenshot.write_bytes(self.device("screen-capture", "exec-out", "screencap", "-p").stdout) + self.result["actual_os_screen"] = image_stats(png_pixels(screenshot)) + self.result["checks"] = ["official-emulator-kvm-usable", "actual-android-boot-completed", + "adb-real-device-properties", "actual-surfaceflinger-gles", "actual-os-screen"] + self.stop(require_clean=True) + self.result["checks"].append("actual-emulator-stopped") + self.result["status"] = "passed" + finally: + self.stop(require_clean=False) + + def stop(self, *, require_clean: bool) -> None: + process = self.emulator_process + if process is None: + if self.emulator_log: + self.emulator_log.close() + self.emulator_log = None + return + clean = True + if process.poll() is None: + try: + stopped = self.device("stop-emulator", "emu", "kill", check=False, timeout=20) + clean = stopped.returncode == 0 + process.wait(timeout=25) + except (Unavailable, subprocess.TimeoutExpired): + clean = False + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=10) + self.result["emulator_exit_code"] = process.returncode + self.result["emulator_stopped_cleanly"] = clean and process.returncode == 0 + self.emulator_process = None + if self.emulator_log: + self.emulator_log.close() + self.emulator_log = None + if require_clean and not self.result["emulator_stopped_cleanly"]: + raise Unavailable("Actual emulator required forced process cleanup; see stop-emulator.log/emulator.log") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cloud-emulator", action="store_true") + parser.add_argument("--sdk-root", default=os.environ.get("ANDROID_SDK_ROOT") or os.environ.get("ANDROID_HOME", "")) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--boot-timeout", type=int, default=480) + parser.add_argument("--port", type=int, default=5554) + args = parser.parse_args() + if not 60 <= args.boot_timeout <= 600 or not 5554 <= args.port <= 5682 or args.port % 2: + parser.error("Boot timeout must be 60..600 seconds; console port must be even, 5554..5682") + preflight = Preflight(args) + code = 0 + try: + preflight.run() + except (Unavailable, Failed, OSError, ValueError) as exc: + preflight.result.update(status="failed" if preflight.execution_started else "not_run", blocker=str(exc)) + code = 1 if preflight.execution_started else 2 + finally: + preflight.stop(require_clean=False) + (preflight.output / "result.json").write_text(json.dumps(preflight.result, indent=2) + "\n") + print(json.dumps(preflight.result, indent=2)) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/run_renpy_mobile_source_build.sh b/tools/run_renpy_mobile_source_build.sh index 7c13fd932..27bca3162 100755 --- a/tools/run_renpy_mobile_source_build.sh +++ b/tools/run_renpy_mobile_source_build.sh @@ -140,7 +140,7 @@ if [[ "$platform" == ios || "$platform" == all ]]; then if [[ "$(uname -s)" == Darwin ]]; then ios_arch_args=(--arch arm64,sim-arm64) fi - (cd "$renpy_build"; ./build.sh --platform ios --python 3 "${ios_arch_args[@]}" rebuild librenpy pythonlib renpython renios) + (cd "$renpy_build"; ./build.sh --platform ios --python 3 ${ios_arch_args[@]+"${ios_arch_args[@]}"} rebuild librenpy pythonlib renpython renios) fi symbols=(renpy_mobile_bootstrap renpy_mobile_bind_window renpy_mobile_init renpy_mobile_tick renpy_mobile_frame renpy_mobile_input renpy_mobile_pause renpy_mobile_resume renpy_mobile_shutdown From 8895c27beef76954bd85cba8b209df9d49a2401f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 13:47:33 +0000 Subject: [PATCH 260/331] test(renpy): compile real generated static API consumers in CI --- .github/workflows/renpy-mobile-contract.yml | 9 +- tools/test_renpy_mobile_static_api.py | 99 +++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 tools/test_renpy_mobile_static_api.py diff --git a/.github/workflows/renpy-mobile-contract.yml b/.github/workflows/renpy-mobile-contract.yml index b29bc92fd..69358cb5a 100644 --- a/.github/workflows/renpy-mobile-contract.yml +++ b/.github/workflows/renpy-mobile-contract.yml @@ -22,6 +22,7 @@ on: - 'tools/test_renpy_mobile_cooperative.py' - 'tools/test_renpy_mobile_darwin_build.py' - 'tools/test_renpy_mobile_build_parallelism.py' + - 'tools/test_renpy_mobile_static_api.py' - 'tools/validate_renpy_mobile_payload.py' - 'tools/test_renpy_mobile_packaging.sh' - 'tools/test_renpy_mobile_runtime_protocol.sh' @@ -97,7 +98,7 @@ jobs: - name: Validate Darwin and Linux task configuration boundaries run: | set -euo pipefail - "${RUNNER_TEMP}/cooperative-test/bin/pip" install Jinja2==3.1.6 + "${RUNNER_TEMP}/cooperative-test/bin/pip" install Jinja2==3.1.6 Cython==3.2.4 "${RUNNER_TEMP}/cooperative-test/bin/python" \ tools/test_renpy_mobile_darwin_build.py _renpy-build-source "${RUNNER_TEMP}/cooperative-test/bin/python" \ @@ -105,6 +106,12 @@ jobs: "${RUNNER_TEMP}/cooperative-test/bin/python" \ tools/test_renpy_mobile_patch_series.py _renpy-build-source + - name: Generate the static API and compile its genuine C consumers + run: | + set -euo pipefail + "${RUNNER_TEMP}/cooperative-test/bin/python" \ + tools/test_renpy_mobile_static_api.py _renpy-build-source _renpy-source + - name: Compile the lifecycle implementation against Python and SDL2 run: | set -euo pipefail diff --git a/tools/test_renpy_mobile_static_api.py b/tools/test_renpy_mobile_static_api.py new file mode 100644 index 000000000..d9e2a609c --- /dev/null +++ b/tools/test_renpy_mobile_static_api.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Generate a genuine Cython API and compile the C objects that consume it. + +This checks clean-source host compilation, not target linkage or gameplay. +""" +import argparse +import ast +from pathlib import Path +import os +import shlex +import subprocess +import sys +import sysconfig +import tarfile +import tempfile + + +REPO = Path(__file__).resolve().parents[1] +BUILD_PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" +RENPY_PIN = "39895c1e017f0b36ffea2447d97eccd69d76ee1c" +PATCHES = REPO / "bridge/renpy_runtime/mobile_launcher/patches" + + +def archive(checkout, destination, pin, *paths): + actual = subprocess.check_output(["git", "-C", str(checkout), "rev-parse", "HEAD"], text=True).strip() + assert actual == pin, (actual, pin) + command = subprocess.Popen(["git", "-C", str(checkout), "archive", "HEAD", *paths], stdout=subprocess.PIPE) + assert command.stdout is not None + with tarfile.open(fileobj=command.stdout, mode="r|*") as contents: + contents.extractall(destination, filter="data") + assert command.wait() == 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("renpy_build", type=Path) + parser.add_argument("renpy_source", type=Path) + parser.add_argument("--sdl-headers", type=Path, help="SDL2 include directory (otherwise use system headers)") + args = parser.parse_args() + sys.path.insert(0, str(args.renpy_build.resolve())) + from renpybuild.context import Context + + with tempfile.TemporaryDirectory(prefix="renpy-static-api-") as temporary: + root = Path(temporary) + source = root / "renpy" + source.mkdir() + archive(args.renpy_build, root, BUILD_PIN, "tasks/librenpy.py", "tasks/renpython.py") + archive(args.renpy_source, source, RENPY_PIN) + task_file = root / "tasks/librenpy.py" + original_task = task_file.read_text() + subprocess.run(["git", "apply", str(PATCHES / "native/0001-renpy-build-link.patch")], cwd=root, check=True) + subprocess.run(["git", "apply", str(PATCHES / "python/0001-cooperative-loop-skeleton.patch")], cwd=source, check=True) + generated = source / "tmp/gen3-static" + generated.mkdir(parents=True) + subprocess.run([ + sys.executable, "-m", "cython", "-Isrc", "-Isrc/pygame/include", "-Itmp/gen3-static", "-I.", + "-X", "profile=False", "-X", "embedsignature=True", "-X", "embedsignature.format=python", + "renpy/pygame/surface.pyx", "-o", str(generated / "renpy.pygame.surface.c"), + ], cwd=source, check=True) + assert (generated / "renpy.pygame.surface_api.h").is_file() + assert not (source / "tmp/gen3").exists(), "test must not use preexisting dynamic API headers" + + base = ["-fPIC", "-I" + sysconfig.get_path("include")] + if args.sdl_headers: + include = root / "include" + include.mkdir() + (include / "SDL2").symlink_to(args.sdl_headers.resolve(), target_is_directory=True) + base.append("-I" + str(include)) + + def flags(task_text, name): + context = Context("android", "x86_64", "3", root, None) + context.env(name, shlex.join(base)) + tree = ast.parse(task_text) + build = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "build") + assignments = [node for node in build.body if isinstance(node, ast.Expr) + and isinstance(node.value, ast.Call) and isinstance(node.value.func, ast.Attribute) + and node.value.func.attr == "env" and node.value.args[0].value == name] + assert len(assignments) == 1 + exec(compile(ast.Module(body=assignments, type_ignores=[]), str(task_file), "exec"), {"c": context}) + return shlex.split(context.environ[name]) + + compiler = shlex.split(os.environ.get("CC", "cc")) + # The unpatched task must reproduce the missing-header error. + control = subprocess.run(compiler + flags(original_task, "CFLAGS") + ["-c", str(source / "src/core.c"), + "-o", str(root / "control.o")], capture_output=True, text=True) + assert control.returncode != 0 and "renpy.pygame.surface_api.h" in control.stderr, control.stderr + task_text = task_file.read_text() + for name in ("core", "renpysound_core"): + subprocess.run(compiler + flags(task_text, "CFLAGS") + ["-c", str(source / f"src/{name}.c"), + "-o", str(root / f"{name}.o")], check=True) + probe = root / "api.cc" + probe.write_text('#include \n#include \n#include \n') + subprocess.run(shlex.split(os.environ.get("CXX", "c++")) + flags(task_text, "CXXFLAGS") + + ["-c", str(probe), "-o", str(root / "api.o")], check=True) + print("Actual static Cython API generation and C/C++ compilation passed; no target linkage or gameplay performed") + + +if __name__ == "__main__": + main() From faf2e7be6cf3774c4bed9cd191560c4044fd7008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 13:57:43 +0000 Subject: [PATCH 261/331] fix(renpy): check allocated MetalANGLE pbuffer after the first real draw Keep strict 16x16 surface and pixel checks while accounting for the pinned MetalANGLE renderer's lazy color attachment. Preserve in-progress full builds across probe-only follow-ups so actual runtime and application artifacts can finish. --- .github/workflows/build.yml | 6 ++-- .github/workflows/renpy-ios-runtime-build.yml | 2 +- .../workflows/renpy-mobile-runtime-build.yml | 2 +- tools/renpy_ios_egl_probe/sdl_probe.c | 35 ++++++++++++++++--- 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 82bbb8ae5..2f1358e29 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,9 +50,9 @@ permissions: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name }} - # Build the newest checked implementation; completed runs retain their - # logs/artifacts and obsolete source failures must not hold up app packaging. - cancel-in-progress: true + # Finish actual source/app builds even when a probe-only follow-up arrives. + # The pending run advances to the latest checked implementation. + cancel-in-progress: false env: # GitHub does not expose repository secrets to fork or Dependabot pull diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index ba50be21f..49ebd71cb 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -22,7 +22,7 @@ permissions: concurrency: group: renpy-ios-source-${{ github.event.pull_request.number || github.ref }}-${{ github.workflow }} - cancel-in-progress: true + cancel-in-progress: false jobs: ios-test-driver: diff --git a/.github/workflows/renpy-mobile-runtime-build.yml b/.github/workflows/renpy-mobile-runtime-build.yml index 301ebb28c..1a1f40951 100644 --- a/.github/workflows/renpy-mobile-runtime-build.yml +++ b/.github/workflows/renpy-mobile-runtime-build.yml @@ -15,7 +15,7 @@ permissions: concurrency: group: renpy-android-source-${{ github.event.pull_request.number || github.ref }}-${{ github.workflow }} - cancel-in-progress: true + cancel-in-progress: false jobs: android-runtime: diff --git a/tools/renpy_ios_egl_probe/sdl_probe.c b/tools/renpy_ios_egl_probe/sdl_probe.c index f0fba42f8..3533c777d 100644 --- a/tools/renpy_ios_egl_probe/sdl_probe.c +++ b/tools/renpy_ios_egl_probe/sdl_probe.c @@ -74,14 +74,39 @@ int aether_run_egl_probe(aether_egl_probe_result *out) { const EGLSurface game_surface = eglGetCurrentSurface(EGL_DRAW); SDL_CHECK(display != EGL_NO_DISPLAY && game_surface != EGL_NO_SURFACE, "SDL did not bind an actual EGL pbuffer"); - EGLint width = 0, height = 0; - SDL_CHECK(eglQuerySurface(display, game_surface, EGL_WIDTH, &width) && - eglQuerySurface(display, game_surface, EGL_HEIGHT, &height) && width == 16 && height == 16, - "actual SDL EGL pbuffer dimensions mismatch"); - out->pbuffer_created = 1; copy_string(out->gl_vendor, sizeof(out->gl_vendor), (const char *)glGetString(GL_VENDOR)); copy_string(out->gl_renderer, sizeof(out->gl_renderer), (const char *)glGetString(GL_RENDERER)); copy_string(out->gl_version, sizeof(out->gl_version), (const char *)glGetString(GL_VERSION)); + /* This actual framework reports ANGLE 850c87ba5b74. Its SurfaceMtl + getWidth/getHeight return zero until OffscreenSurfaceMtl creates the + color texture on the first framebuffer attachment operation. Draw to + the real default framebuffer before querying its allocated dimensions; + still require the requested size and every RGBA pixel below. */ + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glViewport(0, 0, 16, 16); + glDisable(GL_SCISSOR_TEST); + glClearColor(0, 0, 0, 1); + glClear(GL_COLOR_BUFFER_BIT); + glFinish(); + SDL_CHECK(glGetError() == GL_NO_ERROR, "initialize actual SDL pbuffer color attachment"); + EGLint width = 0, height = 0; + const EGLBoolean queried_width = eglQuerySurface(display, game_surface, EGL_WIDTH, &width); + const EGLint width_error = eglGetError(); + const EGLBoolean queried_height = eglQuerySurface(display, game_surface, EGL_HEIGHT, &height); + const EGLint height_error = eglGetError(); + if (!queried_width || !queried_height || width != 16 || height != 16) { + int window_width = 0, window_height = 0, drawable_width = 0, drawable_height = 0; + SDL_GetWindowSize(window, &window_width, &window_height); + SDL_GL_GetDrawableSize(window, &drawable_width, &drawable_height); + snprintf(out->error, sizeof(out->error), + "SDL pbuffer dimensions: EGL=%dx%d query=%u/%u errors=0x%x/0x%x; window=%dx%d drawable=%dx%d; requested=16x16", + width, height, queried_width, queried_height, width_error, height_error, + window_width, window_height, drawable_width, drawable_height); + out->egl_error = width_error != EGL_SUCCESS ? width_error : height_error; + out->gl_error = glGetError(); + goto cleanup; + } + out->pbuffer_created = 1; const EGLint config_attributes[] = { EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_NONE From 87ac51061b9728282ebdccbdc4573cb88502aeaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 14:34:35 +0000 Subject: [PATCH 262/331] fix: isolate iOS host GLES and repair native compiler commands --- .github/workflows/renpy-ios-renderer.yml | 33 ++- .github/workflows/renpy-ios-runtime-build.yml | 1 + .../workflows/renpy-mobile-runtime-build.yml | 1 + .../native/0007-darwin-ios-build.patch | 17 +- .../mobile_launcher/patches/native/README.md | 5 + scripts/build_ios.sh | 102 +++++---- tools/patch_renpy_ios_runtime_project.py | 58 +++-- tools/renpy_ios_egl_probe/host_gl_probe.m | 207 ++++++++++++++++++ .../renpy_ios_egl_probe/run_host_gl_probe.sh | 170 ++++++++++++++ tools/test_renpy_ios_runtime_project.py | 41 ++++ tools/test_renpy_mobile_darwin_build.py | 43 ++++ .../aetherkiri-ios-opengles-isolation.patch | 116 ++++++++++ vcpkg/ports/sdl2/portfile.cmake | 1 + 13 files changed, 723 insertions(+), 72 deletions(-) create mode 100644 tools/renpy_ios_egl_probe/host_gl_probe.m create mode 100755 tools/renpy_ios_egl_probe/run_host_gl_probe.sh create mode 100644 vcpkg/ports/sdl2/aetherkiri-ios-opengles-isolation.patch diff --git a/.github/workflows/renpy-ios-renderer.yml b/.github/workflows/renpy-ios-renderer.yml index de7a57149..a1a7ab4e3 100644 --- a/.github/workflows/renpy-ios-renderer.yml +++ b/.github/workflows/renpy-ios-renderer.yml @@ -6,6 +6,7 @@ on: - '.github/workflows/renpy-ios-renderer.yml' - 'tools/run_renpy_ios_egl_probe.sh' - 'tools/renpy_ios_egl_probe/**' + - 'vcpkg/ports/sdl2/**' - 'bridge/renpy_runtime/mobile_launcher/patches/native/**' workflow_dispatch: @@ -20,7 +21,7 @@ jobs: simulator-pbuffer: name: Execute MetalANGLE EGL rendering on iOS Simulator runs-on: macos-latest - timeout-minutes: 30 + timeout-minutes: 60 steps: - uses: actions/checkout@v6 with: @@ -50,7 +51,8 @@ jobs: if-no-files-found: error retention-days: 7 - name: Install the actual SDL configure tools - run: brew install autoconf automake libtool + if: ${{ !cancelled() }} + run: brew install autoconf automake libtool cmake ninja - name: Build and execute the patched SDL offscreen renderer run: | set -euo pipefail @@ -69,3 +71,30 @@ jobs: ${{ runner.temp }}/renpy-ios-sdl-probe.log if-no-files-found: warn retention-days: 7 + - name: Fetch the actual SDL version used by the host + if: ${{ !cancelled() }} + uses: actions/checkout@v6 + with: + repository: libsdl-org/SDL + ref: 5d249570393f7a37e037abf22cd6012a4cc56a71 + path: _host-sdl-probe-source + persist-credentials: false + - name: Build and execute actual UIKit SDL and MetalANGLE isolation + if: ${{ !cancelled() }} + run: | + set -euo pipefail + bash tools/renpy_ios_egl_probe/run_host_gl_probe.sh \ + --renpy-build "$GITHUB_WORKSPACE/_renpy-probe-source" \ + --host-sdl-source "$GITHUB_WORKSPACE/_host-sdl-probe-source" \ + --output "$GITHUB_WORKSPACE/out/renpy-ios-host-gl-probe" \ + 2>&1 | tee "$RUNNER_TEMP/renpy-ios-host-gl-probe.log" + - name: Preserve actual host graphics isolation evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-host-gl-probe + path: | + out/renpy-ios-host-gl-probe/evidence + ${{ runner.temp }}/renpy-ios-host-gl-probe.log + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index 49ebd71cb..ddb907a58 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -154,6 +154,7 @@ jobs: path: | ${{ runner.temp }}/renpy-ios-source-build.log ${{ runner.temp }}/renpy-ios-source-prepare.log + _renpy-build/tmp/**/config.log out/renpy-ios-source/manifest.json if-no-files-found: warn retention-days: 7 diff --git a/.github/workflows/renpy-mobile-runtime-build.yml b/.github/workflows/renpy-mobile-runtime-build.yml index 1a1f40951..726b02260 100644 --- a/.github/workflows/renpy-mobile-runtime-build.yml +++ b/.github/workflows/renpy-mobile-runtime-build.yml @@ -116,6 +116,7 @@ jobs: name: renpy-source-build-evidence path: | ${{ runner.temp }}/renpy-source-build.log + _renpy-build/tmp/**/config.log out/renpy-mobile-source/manifest.json if-no-files-found: warn retention-days: 7 diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch index ac874c3f7..3ad45f1a0 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch @@ -1,7 +1,7 @@ diff --git a/renpybuild/run.py b/renpybuild/run.py --- a/renpybuild/run.py +++ b/renpybuild/run.py -@@ -39,6 +39,22 @@ +@@ -39,6 +39,29 @@ def llvm(c, bin="", prefix="", suffix="-18", clang_args="", use_ld=True): @@ -9,15 +9,22 @@ diff --git a/renpybuild/run.py b/renpybuild/run.py + if sys.platform == "darwin": + # Use the selected Xcode installation and its SDKs in place. + def tool(name): -+ return subprocess.check_output(["xcrun", "--find", name], text=True).strip() ++ path = subprocess.check_output(["xcrun", "--find", name], text=True).strip() ++ if any(character.isspace() for character in path): ++ raise RuntimeError("Autoconf toolchain paths must not contain whitespace: " + path) ++ return path + + if not clang_args: + sdk = subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-path"], text=True).strip() + clang_args = f'-isysroot "{sdk}"' + clang_args = clang_args.replace("--sysroot ", "-isysroot ") -+ c.env("CC", f'ccache "{tool("clang")}" {clang_args} -std=gnu17') -+ c.env("CXX", f'ccache "{tool("clang++")}" {clang_args} -std=gnu++17 -stdlib=libc++') -+ c.env("CPP", f'ccache "{tool("clang")}" {clang_args} -E') ++ arguments = shlex.split(c.expand(clang_args)) ++ if any(any(character.isspace() for character in argument) for argument in arguments): ++ raise RuntimeError("Autoconf SDK paths must not contain whitespace: " + clang_args) ++ clang_args = " ".join(arguments) ++ c.env("CC", f'ccache {tool("clang")} {clang_args} -std=gnu17') ++ c.env("CXX", f'ccache {tool("clang++")} {clang_args} -std=gnu++17 -stdlib=libc++') ++ c.env("CPP", f'ccache {tool("clang")} {clang_args} -E') + for variable, name in (("AR", "ar"), ("RANLIB", "ranlib"), ("STRIP", "strip"), ("NM", "nm")): + c.env(variable, tool(name)) + return diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index e255b9e29..5770443f9 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -32,6 +32,11 @@ modes with `--platform ios`. The runner derives host dependency prefixes and tools, all three local SDKs and 64 GiB of free disk; it does not substitute prerequisite fixtures. The port still needs a successful native macOS build and simulator gameplay before it establishes iOS runtime support. +The Autoconf compiler environment uses unquoted tool and SDK paths from Xcode; +the cloud build requires these paths to contain no whitespace and rejects +unsupported paths explicitly. Quoted path text inside `CC` is interpreted as +part of the executable name by the pinned configure scripts. CI preserves their +`config.log` files alongside the source-build log when compilation fails. `0009-bounded-build-parallelism.patch` limits the upstream native object build group to the host CPU count. Set `RENPY_BUILD_JOBS` to a positive integer to diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 665ab7eb7..cced01f80 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -432,14 +432,11 @@ renios_launcher_probe() { --library "$prebuilt/librenpython.a" --private-root "$RENPY_RENIOS_BASE" } -build_renios_runtime_framework() { - renios_enabled || return 0 - local prebuilt arch=arm64 framework_source +renios_metalangle_framework() { + local arch=arm64 framework_source [[ "$SIMULATOR" == true ]] && arch="$SIMULATOR_ARCH" - prebuilt="$(renios_prebuilt_root)" framework_source="$RENPY_MOBILE_ROOT/renios/prototype/Frameworks/MetalANGLE.xcframework" - local metalangle - metalangle="$(python3 - "$framework_source" "$IOS_SDK" "$arch" <<'PY' + python3 - "$framework_source" "$IOS_SDK" "$arch" <<'PY' import pathlib, plistlib, sys root=pathlib.Path(sys.argv[1]); simulator=sys.argv[2]=='iphonesimulator'; arch=sys.argv[3] info=plistlib.loads((root/'Info.plist').read_bytes()) @@ -452,7 +449,14 @@ for library in info['AvailableLibraries']: else: raise SystemExit('MetalANGLE has no matching iOS SDK/architecture slice') PY -)" +} + +build_renios_runtime_framework() { + renios_enabled || return 0 + local prebuilt arch=arm64 metalangle + [[ "$SIMULATOR" == true ]] && arch="$SIMULATOR_ARCH" + prebuilt="$(renios_prebuilt_root)" + metalangle="$(renios_metalangle_framework)" [[ -f "$metalangle/MetalANGLE" ]] || { echo "Error: Missing MetalANGLE framework slice." >&2; return 1; } mkdir -p "$RENPY_FRAMEWORK_OUTPUT" local export_list="$CMAKE_BUILD_DIR/renpy-native/exports.txt" symbol @@ -530,13 +534,14 @@ stage_renios_ios_resources() { cp -R "$resource_source/base" "$resource_root/base" fi - # Link and embed the matching MetalANGLE slice alongside our isolated - # native runtime framework. Python keeps its original resource paths. - if [[ -d "$resource_source/Frameworks/MetalANGLE.xcframework" ]]; then - mkdir -p "$app_root/Frameworks" - rm -rf "$app_root/Frameworks/MetalANGLE.xcframework" - cp -R "$resource_source/Frameworks/MetalANGLE.xcframework" "$app_root/Frameworks/" - fi + # Embed the selected real framework slice. Only the isolated runtime links + # MetalANGLE; Godot's own GL imports must resolve to Apple's OpenGLES. + local metalangle + metalangle="$(renios_metalangle_framework)" + [[ -f "$metalangle/MetalANGLE" ]] || { echo "Error: Missing MetalANGLE framework slice." >&2; return 1; } + mkdir -p "$app_root/Frameworks" + rm -rf "$app_root/Frameworks/MetalANGLE.xcframework" "$app_root/Frameworks/MetalANGLE.framework" + cp -R "$metalangle" "$app_root/Frameworks/MetalANGLE.framework" [[ -d "$RENPY_FRAMEWORK_OUTPUT" ]] || { echo "Error: Ren'Py runtime framework was not built." >&2; return 1; } mkdir -p "$app_root/Frameworks" rm -rf "$app_root/Frameworks/$RENPY_FRAMEWORK_NAME.framework" @@ -778,7 +783,7 @@ patch_ios_renios_resources() { local project_file="$1" local export_root="$2" local resource_dir="$export_root/Aether/renios" - local framework="$export_root/Aether/Frameworks/MetalANGLE.xcframework" + local framework="$export_root/Aether/Frameworks/MetalANGLE.framework" [[ -f "$project_file" ]] || return 0 [[ -d "$resource_dir" ]] || return 0 @@ -800,34 +805,12 @@ patch_ios_renios_resources() { fi fi - # MetalANGLE is a dynamic framework in the official Renios package. It - # must be linked and embedded, rather than copied as an opaque resource. - # Renios' native closure is linked separately from the host extension. - if [[ -d "$framework" ]] && ! grep -Fq 'A3F003000000000000000003 /* MetalANGLE.xcframework in Frameworks */' "$project_file"; then - perl -0pi -e 's@(/\* Begin PBXBuildFile section \*/\n)@$1\t\tA3F003000000000000000003 /* MetalANGLE.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A3F003000000000000000004 /* MetalANGLE.xcframework */; };\n\t\tA3F003000000000000000005 /* MetalANGLE.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A3F003000000000000000004 /* MetalANGLE.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };\n@' "$project_file" - perl -0pi -e 's@(/\* Begin PBXFileReference section \*/\n)@$1\t\tA3F003000000000000000004 /* MetalANGLE.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = Frameworks/MetalANGLE.xcframework; sourceTree = ""; };\n@' "$project_file" - # Godot's export project has an Aether group, a framework phase, and - # an Embed Frameworks phase. If a future exporter changes those IDs, - # leave the resource bundle in place and retain NOT_SUPPORTED rather - # than guessing at an unsafe Xcode project mutation. - perl -0pi -e 's@(\t\tD0BCFE4118AEBDA2004A7AAE /\* Aether \*/ = \{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = \(\n)@$1\t\t\t\tA3F003000000000000000004 /* MetalANGLE.xcframework */,\n@' "$project_file" - # Add the framework to the first framework phase and existing embed - # phase by matching their semantic comments, not generated IDs. - perl -0pi -e 's@(\/\* Begin PBXFrameworksBuildPhase section \*\/\n.*?files = \(\n)@$1\t\t\t\tA3F003000000000000000003 /* MetalANGLE.xcframework in Frameworks */,\n@'s "$project_file" - perl -0pi -e 's@(\/\* Begin PBXCopyFilesBuildPhase section \*\/\n.*?files = \(\n)@$1\t\t\t\tA3F003000000000000000005 /* MetalANGLE.xcframework in Embed Frameworks */,\n@'s "$project_file" - if ! grep -Fq 'A3F003000000000000000004 /* MetalANGLE.xcframework */,' "$project_file"; then - perl -0pi -e 's@(\n\t\t[0-9A-F]+ /\* [^*]+ \*/ = \{\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = \(\n)@$1\t\t\t\tA3F003000000000000000004 /* MetalANGLE.xcframework */,\n@'s "$project_file" - fi - if ! grep -Fq 'A3F003000000000000000003 /* MetalANGLE.xcframework in Frameworks */,' "$project_file"; then - perl -0pi -e 's@(\n\t\t[0-9A-F]+ /\* [^*]+ \*/ = \{\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = \(\n)@$1\t\t\t\tA3F003000000000000000003 /* MetalANGLE.xcframework in Frameworks */,\n@'s "$project_file" - fi - if ! grep -Fq 'A3F003000000000000000005 /* MetalANGLE.xcframework in Embed Frameworks */,' "$project_file"; then - perl -0pi -e 's@(\n\t\t[0-9A-F]+ /\* [^*]+ \*/ = \{\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tdstPath = "";\n\t\t\tdstSubfolderSpec = 10;\n\t\t\tfiles = \(\n)@$1\t\t\t\tA3F003000000000000000005 /* MetalANGLE.xcframework in Embed Frameworks */,\n@'s "$project_file" - fi - fi - + # The native framework records its own MetalANGLE load command. Embed the + # selected slice without adding MetalANGLE to the host's link inputs. python3 "$PROJECT_ROOT/tools/patch_renpy_ios_runtime_project.py" "$project_file" \ - "$export_root/Aether/Frameworks/$RENPY_FRAMEWORK_NAME.framework" + "$export_root/Aether/Frameworks/$RENPY_FRAMEWORK_NAME.framework" \ + --metalangle-framework "$framework" + } patch_ios_export_project() { @@ -909,10 +892,43 @@ validate_renios_app() { python3 "$PROJECT_ROOT/tools/validate_renpy_mobile_payload.py" \ --platform ios --arch "$arch" --sdk "$sdk" --executable --library "$framework" \ --private-root "$app/renios/base" - xcrun otool -L "$app/Aether" | grep -Fq "@rpath/$RENPY_FRAMEWORK_NAME.framework/$RENPY_FRAMEWORK_NAME" || { + local host_libraries runtime_libraries host_imports runtime_imports + host_libraries="$(xcrun otool -L "$app/Aether")" + runtime_libraries="$(xcrun otool -L "$framework")" + grep -Fq "@rpath/$RENPY_FRAMEWORK_NAME.framework/$RENPY_FRAMEWORK_NAME" <<< "$host_libraries" || { echo "Error: The final app does not load its isolated Ren'Py framework." >&2 return 1 } + if grep -Fq 'MetalANGLE.framework/MetalANGLE' <<< "$host_libraries"; then + echo "Error: The host app directly links MetalANGLE and can bind Godot GL calls to Ren'Py's graphics context." >&2 + return 1 + fi + grep -Fq '/OpenGLES.framework/OpenGLES' <<< "$host_libraries" || { + echo "Error: The host app does not link its Apple OpenGLES backend." >&2 + return 1 + } + grep -Fq 'MetalANGLE.framework/MetalANGLE' <<< "$runtime_libraries" || { + echo "Error: The Ren'Py framework does not load its MetalANGLE backend." >&2 + return 1 + } + if grep -Fq '/OpenGLES.framework/OpenGLES' <<< "$runtime_libraries"; then + echo "Error: The Ren'Py framework also links Apple's incompatible OpenGLES backend." >&2 + return 1 + fi + # Preserve the real linked image's undefined symbol bindings as build + # evidence. The load-command gates above remain valid after stripping. + host_imports="$(xcrun nm -m -u "$app/Aether")" + runtime_imports="$(xcrun nm -m -u "$framework")" + printf '%s\n' "$host_libraries" "$host_imports" > "$CMAKE_BUILD_DIR/renpy-native/host-linkage.txt" + printf '%s\n' "$runtime_libraries" "$runtime_imports" > "$CMAKE_BUILD_DIR/renpy-native/runtime-linkage.txt" + if grep -Eq '_gl[A-Za-z0-9_]+ .*\(from MetalANGLE\)' <<< "$host_imports" || \ + grep -Eq '_(egl|gl)[A-Za-z0-9_]+ .*\(from OpenGLES\)' <<< "$runtime_imports"; then + echo "Error: The final GL/EGL imports bind to the other engine's graphics backend." >&2 + return 1 + fi + echo "==> Actual host GL imports (Apple OpenGLES) and Ren'Py EGL/GL imports (MetalANGLE):" + grep -E '_(egl|gl)[A-Za-z0-9_]+ .*\(from (MetalANGLE|OpenGLES)\)' <<< "$host_imports" || true + grep -E '_(egl|gl)[A-Za-z0-9_]+ .*\(from (MetalANGLE|OpenGLES)\)' <<< "$runtime_imports" || true # Objective-C class names are process-global, even with two-level native # namespaces. The offscreen SDL build must exclude UIKit classes and prefix # its audio listener; otherwise it collides with Godot's host SDL backend. diff --git a/tools/patch_renpy_ios_runtime_project.py b/tools/patch_renpy_ios_runtime_project.py index 7c2ccac7b..8b10d018e 100755 --- a/tools/patch_renpy_ios_runtime_project.py +++ b/tools/patch_renpy_ios_runtime_project.py @@ -6,15 +6,15 @@ from pathlib import Path -def patch(project: Path, framework: Path) -> None: - if not framework.is_dir(): - raise ValueError(f"Rebuilt runtime framework is missing: {framework}") - name = framework.name +def patch(project: Path, framework: Path, metalangle: Path | None = None) -> None: + for required in (framework, metalangle): + if required is not None and not required.is_dir(): + raise ValueError(f"Rebuilt runtime framework is missing: {required}") text = project.read_text() - prefix = "A3F0040000000000000000" - reference, linked, embedded = (prefix + suffix for suffix in ("04", "03", "05")) - if f"{reference} /* {name} */ =" in text: - return + if metalangle is not None and '/* MetalANGLE.xcframework */' in text: + # Replace the previous owned XCFramework entries, including its host + # link phase, when reusing an earlier Godot export directory. + text = re.sub(r'^.*A3F0030000000000000000[0-9A-F]{2}.*\n', '', text, flags=re.M) def insert(pattern: str, addition: str, description: str) -> None: nonlocal text @@ -22,19 +22,32 @@ def insert(pattern: str, addition: str, description: str) -> None: if count != 1: raise ValueError(f"Godot Xcode export lacks {description}; refusing an incomplete app") - insert(r"/\* Begin PBXBuildFile section \*/\n", - f"\t\t{linked} /* {name} in Frameworks */ = {{isa = PBXBuildFile; fileRef = {reference} /* {name} */; }};\n" - f"\t\t{embedded} /* {name} in Embed Frameworks */ = {{isa = PBXBuildFile; fileRef = {reference} /* {name} */; settings = {{ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }}; }};\n", - "PBXBuildFile section") - insert(r"/\* Begin PBXFileReference section \*/\n", - f'\t\t{reference} /* {name} */ = {{isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = Frameworks/{name}; sourceTree = ""; }};\n', - "PBXFileReference section") - insert(r"[0-9A-F]+ /\* Aether \*/ = \{\s*isa = PBXGroup;\s*children = \(\n", - f"\t\t\t\t{reference} /* {name} */,\n", "Aether source group") - insert(r"/\* Begin PBXFrameworksBuildPhase section \*/.*?files = \(\n", - f"\t\t\t\t{linked} /* {name} in Frameworks */,\n", "framework link phase") - insert(r'isa = PBXCopyFilesBuildPhase;(?:(?!\};).)*?dstSubfolderSpec = 10;\s*files = \(\n', - f"\t\t\t\t{embedded} /* {name} in Embed Frameworks */,\n", "framework embed phase") + def integrate(required: Path, prefix: str, link: bool) -> None: + name = required.name + reference, linked, embedded = (prefix + suffix for suffix in ("04", "03", "05")) + if f"{reference} /* {name} */ =" in text: + return + link_object = (f"\t\t{linked} /* {name} in Frameworks */ = {{isa = PBXBuildFile; fileRef = {reference} /* {name} */; }};\n" + if link else "") + insert(r"/\* Begin PBXBuildFile section \*/\n", link_object + + f"\t\t{embedded} /* {name} in Embed Frameworks */ = {{isa = PBXBuildFile; fileRef = {reference} /* {name} */; settings = {{ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }}; }};\n", + "PBXBuildFile section") + insert(r"/\* Begin PBXFileReference section \*/\n", + f'\t\t{reference} /* {name} */ = {{isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = Frameworks/{name}; sourceTree = ""; }};\n', + "PBXFileReference section") + insert(r"[0-9A-F]+ /\* Aether \*/ = \{\s*isa = PBXGroup;\s*children = \(\n", + f"\t\t\t\t{reference} /* {name} */,\n", "Aether source group") + if link: + insert(r"/\* Begin PBXFrameworksBuildPhase section \*/.*?files = \(\n", + f"\t\t\t\t{linked} /* {name} in Frameworks */,\n", "framework link phase") + insert(r'isa = PBXCopyFilesBuildPhase;(?:(?!\};).)*?dstSubfolderSpec = 10;\s*files = \(\n', + f"\t\t\t\t{embedded} /* {name} in Embed Frameworks */,\n", "framework embed phase") + + integrate(framework, "A3F0040000000000000000", link=True) + if metalangle is not None: + # Only the isolated Ren'Py framework links MetalANGLE. Linking it into + # the host would let Godot's direct GL calls bind to the wrong context. + integrate(metalangle, "A3F0030000000000000000", link=False) project.write_text(text) @@ -42,9 +55,10 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("project", type=Path) parser.add_argument("framework", type=Path) + parser.add_argument("--metalangle-framework", type=Path) args = parser.parse_args() try: - patch(args.project, args.framework) + patch(args.project, args.framework, args.metalangle_framework) except (OSError, ValueError) as exc: parser.exit(1, f"iOS runtime integration failed: {exc}\n") diff --git a/tools/renpy_ios_egl_probe/host_gl_probe.m b/tools/renpy_ios_egl_probe/host_gl_probe.m new file mode 100644 index 000000000..c3d2005a6 --- /dev/null +++ b/tools/renpy_ios_egl_probe/host_gl_probe.m @@ -0,0 +1,207 @@ +/* Real UIKit SDL2 plus MetalANGLE coexistence probe. No SDL/GL API is replaced. */ +#define SDL_MAIN_HANDLED 1 +#import +#import +#import +#import +#include +#include +#include +#include "egl_probe.h" + +typedef struct AppleGL { + const GLubyte *(*GetString)(GLenum); + GLenum (*GetError)(void); + void (*GenFramebuffers)(GLsizei, GLuint *); + void (*BindFramebuffer)(GLenum, GLuint); + void (*DeleteFramebuffers)(GLsizei, const GLuint *); + GLenum (*CheckFramebufferStatus)(GLenum); + void (*GenRenderbuffers)(GLsizei, GLuint *); + void (*BindRenderbuffer)(GLenum, GLuint); + void (*RenderbufferStorage)(GLenum, GLenum, GLsizei, GLsizei); + void (*FramebufferRenderbuffer)(GLenum, GLenum, GLenum, GLuint); + void (*DeleteRenderbuffers)(GLsizei, const GLuint *); + void (*ClearColor)(GLfloat, GLfloat, GLfloat, GLfloat); + void (*Clear)(GLbitfield); + void (*Finish)(void); + void (*ReadPixels)(GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, void *); +} AppleGL; + +static void *appleSymbol(const char *name, NSMutableDictionary *sources, NSMutableDictionary *report) { + void *symbol = SDL_GL_GetProcAddress(name); + Dl_info info = {0}; + if (!symbol || !dladdr(symbol, &info) || !info.dli_fname) { + report[@"error"] = [NSString stringWithFormat:@"Actual SDL symbol %s failed: %s", name, SDL_GetError()]; + return NULL; + } + NSString *source = [NSString stringWithUTF8String:info.dli_fname] ?: @""; + sources[[NSString stringWithUTF8String:name]] = source; + if (![source containsString:@"/OpenGLES.framework/"] || [source containsString:@"MetalANGLE"]) { + report[@"error"] = [NSString stringWithFormat:@"Host SDL resolved %s to %@", name, source]; + return NULL; + } + return symbol; +} + +static BOOL expectedColor(const unsigned char *pixels, int red, int green, int blue) { + for (int i = 0; i < 16 * 16; ++i) { + const int wanted[] = {red, green, blue, 255}; + for (int c = 0; c < 4; ++c) { + int delta = pixels[i * 4 + c] - wanted[c]; + if (delta < -1 || delta > 1) return NO; + } + } + return YES; +} + +static NSDictionary *runHostProbe(NSData **actualPixels) { + NSMutableDictionary *report = [@{ + @"probe": @"actual SDL2.32.10 UIKit Apple OpenGLES and MetalANGLE isolation", + @"gameplay_verified": @NO, @"passed": @NO, @"error": @"", + @"apple_provenance_verified": @NO, @"apple_readback_verified": @NO, + @"metalangle_readback_verified": @NO, @"apple_context_preserved": @NO, + @"apple_pixels_preserved": @NO, @"apple_resume_verified": @NO, + @"load_unload_verified": @NO, @"video_reinitialization_verified": @NO + } mutableCopy]; + NSMutableDictionary *sources = [NSMutableDictionary dictionary]; + EAGLContext *previous = [EAGLContext currentContext]; + EAGLContext *host = nil; + AppleGL gl = {0}; + GLuint framebuffer = 0, renderbuffer = 0; + unsigned char pixels[16 * 16 * 4] = {0}; + aether_egl_probe_result metal = {0}; + SDL_version version; + SDL_GetVersion(&version); + report[@"sdl_version"] = [NSString stringWithFormat:@"%d.%d.%d", version.major, version.minor, version.patch]; + report[@"symbol_sources"] = sources; +#define CHECK(value, ...) do { if (!(value)) { \ + if (![report[@"error"] length]) { report[@"error"] = (__VA_ARGS__); } goto cleanup; } } while (0) +#define LOAD(member, name) do { void *address = appleSymbol(name, sources, report); \ + if (!address) { goto cleanup; } gl.member = (__typeof__(gl.member))address; } while (0) + CHECK(version.major == 2 && version.minor == 32 && version.patch == 10, @"Unexpected actual host SDL version"); + SDL_SetMainReady(); + CHECK(!SDL_setenv("SDL_VIDEODRIVER", "uikit", 1), @"Cannot select actual UIKit driver"); + CHECK(!SDL_Init(SDL_INIT_VIDEO), [NSString stringWithFormat:@"Actual UIKit SDL init: %s", SDL_GetError()]); + CHECK(SDL_GetCurrentVideoDriver() && !strcmp(SDL_GetCurrentVideoDriver(), "uikit"), @"Actual SDL did not select UIKit"); + LOAD(GetString, "glGetString"); LOAD(GetError, "glGetError"); + LOAD(GenFramebuffers, "glGenFramebuffers"); LOAD(BindFramebuffer, "glBindFramebuffer"); + LOAD(DeleteFramebuffers, "glDeleteFramebuffers"); LOAD(CheckFramebufferStatus, "glCheckFramebufferStatus"); + LOAD(GenRenderbuffers, "glGenRenderbuffers"); LOAD(BindRenderbuffer, "glBindRenderbuffer"); + LOAD(RenderbufferStorage, "glRenderbufferStorage"); LOAD(FramebufferRenderbuffer, "glFramebufferRenderbuffer"); + LOAD(DeleteRenderbuffers, "glDeleteRenderbuffers"); LOAD(ClearColor, "glClearColor"); + LOAD(Clear, "glClear"); LOAD(Finish, "glFinish"); LOAD(ReadPixels, "glReadPixels"); + report[@"apple_provenance_verified"] = @YES; + host = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES3]; + CHECK(host && host.API == kEAGLRenderingAPIOpenGLES3 && [EAGLContext setCurrentContext:host], + @"Actual Apple EAGL GLES3 context creation failed"); + report[@"apple_gles_client_version"] = @(host.API); + const GLubyte *renderer = gl.GetString(GL_RENDERER); + CHECK(renderer, @"Actual Apple context has no GL_RENDERER string"); + report[@"apple_renderer"] = [NSString stringWithUTF8String:(const char *)renderer] ?: @""; + gl.GenFramebuffers(1, &framebuffer); gl.BindFramebuffer(GL_FRAMEBUFFER, framebuffer); + gl.GenRenderbuffers(1, &renderbuffer); gl.BindRenderbuffer(GL_RENDERBUFFER, renderbuffer); + gl.RenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 16, 16); + gl.FramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, renderbuffer); + CHECK(gl.CheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE, @"Actual Apple host FBO is incomplete"); + gl.ClearColor(0, 1, 1, 1); gl.Clear(GL_COLOR_BUFFER_BIT); gl.Finish(); + gl.ReadPixels(0, 0, 16, 16, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + CHECK(gl.GetError() == GL_NO_ERROR && expectedColor(pixels, 0, 255, 255), @"Actual Apple host cyan pixels mismatch"); + report[@"apple_readback_verified"] = @YES; + CHECK(!aether_run_egl_probe(&metal) && metal.passed && metal.readback_verified && metal.client_version == 3, + [NSString stringWithFormat:@"Actual MetalANGLE pbuffer failed: %s", metal.error]); + report[@"metalangle_gles_client_version"] = @(metal.client_version); + report[@"metalangle_renderer"] = [NSString stringWithUTF8String:metal.gl_renderer] ?: @""; + report[@"metalangle_readback_verified"] = @YES; + CHECK([EAGLContext currentContext] == host, @"MetalANGLE changed Apple's current host EAGL context"); + report[@"apple_context_preserved"] = @YES; + CHECK(appleSymbol("glClear", sources, report), @"Actual host GL provenance changed after MetalANGLE rendering"); + gl.ReadPixels(0, 0, 16, 16, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + CHECK(gl.GetError() == GL_NO_ERROR && expectedColor(pixels, 0, 255, 255), @"MetalANGLE changed actual Apple host pixels"); + report[@"apple_pixels_preserved"] = @YES; + gl.ClearColor(1, 0, 1, 1); gl.Clear(GL_COLOR_BUFFER_BIT); gl.Finish(); + gl.ReadPixels(0, 0, 16, 16, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + CHECK(gl.GetError() == GL_NO_ERROR && expectedColor(pixels, 255, 0, 255), @"Apple host did not resume with fresh magenta pixels"); + report[@"apple_resume_verified"] = @YES; + *actualPixels = [NSData dataWithBytes:pixels length:sizeof(pixels)]; + CHECK(!SDL_GL_LoadLibrary(NULL), @"Actual SDL explicit GL load failed"); + SDL_GL_UnloadLibrary(); + CHECK(appleSymbol("glClear", sources, report), @"Actual SDL lost its initial preload reference"); + SDL_GL_UnloadLibrary(); + CHECK(!SDL_GL_GetProcAddress("glClear"), @"Actual SDL still returned GL after its final unload"); + SDL_ClearError(); + CHECK(!SDL_GL_LoadLibrary(NULL) && appleSymbol("glClear", sources, report), @"Actual SDL concrete Apple framework reload failed"); + report[@"load_unload_verified"] = @YES; + SDL_Quit(); + CHECK(!SDL_Init(SDL_INIT_VIDEO) && appleSymbol("glClear", sources, report), @"Actual SDL video reinitialization failed"); + report[@"video_reinitialization_verified"] = @YES; + report[@"passed"] = @YES; +cleanup: + if (host && gl.DeleteFramebuffers && gl.DeleteRenderbuffers) { + [EAGLContext setCurrentContext:host]; + if (framebuffer) gl.DeleteFramebuffers(1, &framebuffer); + if (renderbuffer) gl.DeleteRenderbuffers(1, &renderbuffer); + } + [EAGLContext setCurrentContext:previous]; + SDL_Quit(); + return report; +#undef LOAD +#undef CHECK +} + +@interface HostGLProbeDelegate : UIResponder +@property(nonatomic, strong) UIWindow *window; +@property(nonatomic, strong) UILabel *statusLabel; +@property(nonatomic, strong) UIImageView *imageView; +@end +@implementation HostGLProbeDelegate +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)options { + (void)options; + self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds]; + UIViewController *controller = [UIViewController new]; + controller.view.backgroundColor = UIColor.blackColor; + self.statusLabel = [[UILabel alloc] initWithFrame:CGRectMake(24, 70, self.window.bounds.size.width - 48, 160)]; + self.statusLabel.textColor = UIColor.whiteColor; self.statusLabel.numberOfLines = 0; + self.statusLabel.text = @"Running actual UIKit SDL / Apple OpenGLES / MetalANGLE isolation…"; + self.imageView = [[UIImageView alloc] initWithFrame:CGRectMake(24, 250, 256, 256)]; + self.imageView.layer.magnificationFilter = kCAFilterNearest; + [controller.view addSubview:self.statusLabel]; [controller.view addSubview:self.imageView]; + self.window.rootViewController = controller; [self.window makeKeyAndVisible]; + dispatch_async(dispatch_get_main_queue(), ^{ + NSUInteger before = application.windows.count; + NSData *pixels = nil; + NSMutableDictionary *report = [runHostProbe(&pixels) mutableCopy]; + report[@"no_extra_uiwindow"] = @(before == application.windows.count); + report[@"windows_before"] = @(before); report[@"windows_after"] = @(application.windows.count); + if (![report[@"no_extra_uiwindow"] boolValue]) { + report[@"passed"] = @NO; + report[@"error"] = @"UIKit created an additional UIWindow"; + } + NSString *documents = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject; + if (pixels) { + [pixels writeToFile:[documents stringByAppendingPathComponent:@"host-gl-probe.rgba"] atomically:YES]; + CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)pixels); + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + CGImageRef image = CGImageCreate(16, 16, 8, 32, 64, colorSpace, + kCGBitmapByteOrderDefault | kCGImageAlphaLast, provider, NULL, NO, kCGRenderingIntentDefault); + if (image) { + UIImage *shown = [UIImage imageWithCGImage:image]; self.imageView.image = shown; + [UIImagePNGRepresentation(shown) writeToFile:[documents stringByAppendingPathComponent:@"host-gl-probe.png"] atomically:YES]; + CGImageRelease(image); + } + CGColorSpaceRelease(colorSpace); CGDataProviderRelease(provider); + } + NSError *error = nil; + NSData *json = [NSJSONSerialization dataWithJSONObject:report options:NSJSONWritingPrettyPrinted error:&error]; + if (json) [json writeToFile:[documents stringByAppendingPathComponent:@"host-gl-probe.json"] atomically:YES]; + self.statusLabel.text = [report[@"passed"] boolValue] ? + @"PASS: real host SDL uses Apple GLES; MetalANGLE pixels remain separate.\nUnload/reload and fresh host pixels verified.\nRen’Py gameplay is not tested." : + [NSString stringWithFormat:@"FAIL: %@", report[@"error"]]; + NSLog(@"AETHER_HOST_GL_PROBE %@", [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding] ?: error.description); + }); + return YES; +} +@end + +int main(int argc, char **argv) { + @autoreleasepool { return UIApplicationMain(argc, argv, nil, NSStringFromClass(HostGLProbeDelegate.class)); } +} diff --git a/tools/renpy_ios_egl_probe/run_host_gl_probe.sh b/tools/renpy_ios_egl_probe/run_host_gl_probe.sh new file mode 100755 index 000000000..80baa752d --- /dev/null +++ b/tools/renpy_ios_egl_probe/run_host_gl_probe.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# Compile actual UIKit SDL2 and exercise its Apple GL lookup beside MetalANGLE. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +renpy_build="" +host_sdl="" +output="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/aether-renpy-ios-host-gl-probe" +while (($#)); do + case "$1" in + --renpy-build) renpy_build="$2"; shift 2 ;; + --host-sdl-source) host_sdl="$2"; shift 2 ;; + --output) output="$2"; shift 2 ;; + *) echo "Usage: $0 --renpy-build PATH --host-sdl-source PATH [--output PATH]" >&2; exit 2 ;; + esac +done +[[ "$(uname -s)" == Darwin ]] || { echo 'Actual UIKit/Apple GLES requires macOS/Xcode; no substitute API is used.' >&2; exit 2; } +[[ "$(git -C "$host_sdl" rev-parse HEAD)" == 5d249570393f7a37e037abf22cd6012a4cc56a71 ]] || { + echo 'Expected the actual SDL2.32.10 release commit used by the host port.' >&2; exit 1; +} +archive="$renpy_build/source/MetalANGLE.framework.ios.simulator.zip" +[[ -f "$archive" ]] || { echo "Missing actual simulator MetalANGLE framework: $archive" >&2; exit 1; } +mkdir -p "$output/input" "$output/evidence" +unzip -q -o "$archive" -d "$output/input" +framework="$output/input/MetalANGLE.framework" +arch="$(uname -m)" +sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)" +xcrun lipo "$framework/MetalANGLE" -verify_arch "$arch" +xcodebuild -version > "$output/evidence/xcode-version.txt" +printf '%s\n' "$sdk" > "$output/evidence/sdk-path.txt" +shasum -a 256 "$archive" > "$output/evidence/framework-archive.sha256" +git -C "$host_sdl" rev-parse HEAD > "$output/evidence/host-sdl-source-sha.txt" +[[ ! -e "$output/host-sdl-source" ]] || { echo 'Use a fresh output directory for this actual source build.' >&2; exit 1; } +cp -R "$host_sdl" "$output/host-sdl-source" +python3 - "$repo_root" "$output" <<'PY' +import pathlib, re, subprocess, sys +repo, out = map(pathlib.Path, sys.argv[1:]) +port = repo/'vcpkg/ports/sdl2' +source = out/'host-sdl-source' +section = (port/'portfile.cmake').read_text().split(' PATCHES\n', 1)[1].split('\n)', 1)[0] +names = re.findall(r'^\s+([\w.-]+\.(?:patch|diff))\s*$', section, re.M) +if 'aetherkiri-ios-opengles-isolation.patch' not in names: + raise SystemExit('Host port does not apply the actual Apple GLES isolation patch') +evidence = out/'evidence/applied-host-sdl-patches'; evidence.mkdir() +for name in names: + patch = port/name + subprocess.run(['git', 'apply', '--check', str(patch)], cwd=source, check=True) + subprocess.run(['git', 'apply', str(patch)], cwd=source, check=True) + (evidence/name).write_bytes(patch.read_bytes()) + print('Applied actual host SDL port patch:', name) +PY +if ! cmake -S "$output/host-sdl-source" -B "$output/host-sdl-build" -G Ninja \ + -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_SYSROOT="$sdk" \ + -DCMAKE_OSX_ARCHITECTURES="$arch" -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$output/host-sdl-install" \ + -DSDL_STATIC=ON -DSDL_SHARED=OFF -DSDL_TEST=OFF -DSDL_TESTS=OFF \ + -DSDL_VULKAN=OFF -DSDL_METAL=OFF -DSDL_RENDER_METAL=OFF \ + -DSDL_HIDAPI=OFF -DSDL_JOYSTICK=OFF -DSDL_SENSOR=OFF -DSDL_HAPTIC=OFF -DSDL_POWER=OFF \ + > "$output/evidence/host-sdl-configure.log" 2>&1; then + tail -100 "$output/evidence/host-sdl-configure.log" >&2; exit 1 +fi +if ! cmake --build "$output/host-sdl-build" --parallel "$(sysctl -n hw.ncpu)" \ + > "$output/evidence/host-sdl-build.log" 2>&1; then + tail -100 "$output/evidence/host-sdl-build.log" >&2; exit 1 +fi +cmake --install "$output/host-sdl-build" > "$output/evidence/host-sdl-install.log" 2>&1 +library="$output/host-sdl-install/lib/libSDL2.a" +[[ -f "$library" ]] || { echo 'Actual host SDL static archive is missing.' >&2; exit 1; } +config="$output/host-sdl-install/include/SDL2/SDL_config.h" +cp "$config" "$output/evidence/SDL_config.h" +grep '^#define SDL_VIDEO_DRIVER_UIKIT 1' "$config" +grep '^#define SDL_VIDEO_OPENGL_ES2 1' "$config" +xcrun nm -a "$library" > "$output/evidence/host-sdl-symbols.txt" +grep '_UIKit_GL_UnloadLibrary' "$output/evidence/host-sdl-symbols.txt" +app="$output/AetherRenPyHostGLProbe.app" +mkdir -p "$app/Frameworks" +cp -R "$framework" "$app/Frameworks/" +embedded="$app/Frameworks/MetalANGLE.framework" +if [[ "$(xcrun lipo "$embedded/MetalANGLE" -archs)" == *" "* ]]; then + xcrun lipo "$embedded/MetalANGLE" -thin "$arch" -output "$output/MetalANGLE.thin" + mv "$output/MetalANGLE.thin" "$embedded/MetalANGLE" +fi +codesign --force --sign - "$embedded" +cat > "$app/Info.plist" <<'PLIST' + + + +CFBundleIdentifierorg.aetherkiri.renpy-host-gl-probe +CFBundleExecutableAetherRenPyHostGLProbe +CFBundleNameAether RenPy Host GL Probe +CFBundlePackageTypeAPPL +CFBundleSupportedPlatformsiPhoneSimulator +CFBundleVersion1 +CFBundleShortVersionString1.0 +MinimumOSVersion15.0 +UIDeviceFamily12 +UILaunchScreen +UISupportedInterfaceOrientationsUIInterfaceOrientationPortrait + +PLIST +xcrun --sdk iphonesimulator clang -target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \ + -Wall -Wextra -Werror -I"$framework/Headers" \ + -c "$repo_root/tools/renpy_ios_egl_probe/egl_probe.c" -o "$output/egl_probe.o" +# Intentionally load both GL implementations in this process. Host SDL must +# select Apple by its concrete handle even though global GLES symbols coexist. +xcrun --sdk iphonesimulator clang -target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \ + -Wall -Wextra -Werror -Wno-deprecated-declarations -fobjc-arc \ + -I"$output/host-sdl-install/include/SDL2" -F"$(dirname "$framework")" \ + "$repo_root/tools/renpy_ios_egl_probe/host_gl_probe.m" "$output/egl_probe.o" "$library" \ + -framework MetalANGLE -framework OpenGLES -framework UIKit -framework Foundation \ + -framework CoreGraphics -framework CoreVideo -framework QuartzCore -framework CoreFoundation \ + -framework CoreAudio -framework AudioToolbox -framework AVFoundation -framework CoreMotion \ + -framework GameController -framework CoreHaptics -framework Metal -framework MetalKit -liconv \ + -Wl,-rpath,@executable_path/Frameworks -o "$app/AetherRenPyHostGLProbe" +codesign --force --sign - "$app" +xcrun otool -L "$app/AetherRenPyHostGLProbe" > "$output/evidence/app-linked-libraries.txt" +xcrun simctl list runtimes --json > "$output/evidence/simulator-runtimes.json" +xcrun simctl list devicetypes --json > "$output/evidence/simulator-device-types.json" +read -r runtime device_type < <(python3 - "$output/evidence" <<'PY' +import json, pathlib, sys +p = pathlib.Path(sys.argv[1]) +available = [r for r in json.loads((p/'simulator-runtimes.json').read_text())['runtimes'] + if r.get('isAvailable') and '.iOS-' in r['identifier']] +if not available: + raise SystemExit('The cloud runner has no actual iOS Simulator runtime') +runtime = max(available, key=lambda r: tuple(int(i) for i in r['version'].split('.'))) +devices = json.loads((p/'simulator-device-types.json').read_text())['devicetypes'] +for name in ('iPhone 13', 'iPhone 14', 'iPhone 15', 'iPhone 16'): + device = next((d for d in devices if d['name'] == name), None) + if device: + print(runtime['identifier'], device['identifier']); break +else: + raise SystemExit('No supported iPhone Simulator device type') +PY +) +udid="$(xcrun simctl create "Aether RenPy host GL probe ${GITHUB_RUN_ID:-$$}" "$device_type" "$runtime")" +cleanup() { + xcrun simctl shutdown "$udid" >/dev/null 2>&1 || true + xcrun simctl delete "$udid" >/dev/null 2>&1 || true +} +trap cleanup EXIT +xcrun simctl boot "$udid" +xcrun simctl bootstatus "$udid" -b +xcrun simctl install "$udid" "$app" +xcrun simctl launch --terminate-running-process "$udid" org.aetherkiri.renpy-host-gl-probe | tee "$output/evidence/simulator-launch.txt" +container="$(xcrun simctl get_app_container "$udid" org.aetherkiri.renpy-host-gl-probe data)" +for ((attempt=0; attempt<90; ++attempt)); do + if [[ -f "$container/Documents/host-gl-probe.json" ]]; then break; fi + sleep 1 +done +if [[ ! -f "$container/Documents/host-gl-probe.json" ]]; then + xcrun simctl spawn "$udid" log show --last 3m --style compact > "$output/evidence/simulator-system.log" 2>&1 || true + echo 'Actual UIKit/GL probe produced no report; inspect its crash/dyld/Metal evidence.' >&2; exit 1 +fi +cp "$container/Documents/host-gl-probe."* "$output/evidence/" +xcrun simctl io "$udid" screenshot "$output/evidence/simulator-screen.png" +python3 - "$output/evidence/host-gl-probe.json" <<'PY' +import json, pathlib, sys +report = json.loads(pathlib.Path(sys.argv[1]).read_text()) +print(json.dumps(report, indent=2)) +required = ('passed', 'apple_provenance_verified', 'apple_readback_verified', + 'metalangle_readback_verified', 'apple_context_preserved', 'apple_pixels_preserved', + 'apple_resume_verified', 'load_unload_verified', 'video_reinitialization_verified', 'no_extra_uiwindow') +if not all(report.get(k) for k in required): + raise SystemExit('Actual UIKit SDL / Apple GLES / MetalANGLE isolation failed') +if report.get('sdl_version') != '2.32.10': + raise SystemExit('Probe did not use the actual host SDL2.32.10') +if report.get('apple_gles_client_version') != 3 or report.get('metalangle_gles_client_version') != 3: + raise SystemExit('Probe did not render with both actual Apple and MetalANGLE GLES3 contexts') +print('Actual UIKit SDL selects Apple GLES; independent real GPU pixels and GL lifecycle passed. RenPy gameplay remains unverified.') +PY diff --git a/tools/test_renpy_ios_runtime_project.py b/tools/test_renpy_ios_runtime_project.py index d2e31dc83..9001a0155 100644 --- a/tools/test_renpy_ios_runtime_project.py +++ b/tools/test_renpy_ios_runtime_project.py @@ -75,6 +75,47 @@ def test_missing_embed_phase_rejects_atomic_mutation(self): patch(project, framework) self.assertEqual(project.read_text(), original) + def test_metalangle_is_embedded_without_host_linkage(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + project = root / "project.pbxproj" + framework = root / "AetherRenPyRuntime.framework" + metalangle = root / "MetalANGLE.framework" + framework.mkdir() + metalangle.mkdir() + project.write_text(PROJECT) + patch(project, framework, metalangle) + actual = project.read_text() + link = actual.split("/* Begin PBXFrameworksBuildPhase section */")[1].split("/* End PBXFrameworksBuildPhase section */")[0] + embed = actual.split("/* Begin PBXCopyFilesBuildPhase section */")[1].split("/* End PBXCopyFilesBuildPhase section */")[0] + self.assertIn("AetherRenPyRuntime.framework in Frameworks */,", link) + self.assertNotIn("MetalANGLE", link) + self.assertIn("MetalANGLE.framework in Embed Frameworks */,", embed) + patch(project, framework, metalangle) + self.assertEqual(project.read_text(), actual) + + def test_previous_xcframework_host_link_is_removed(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + project = root / "project.pbxproj" + framework = root / "AetherRenPyRuntime.framework" + metalangle = root / "MetalANGLE.framework" + framework.mkdir() + metalangle.mkdir() + project.write_text(PROJECT) + patch(project, framework, metalangle) + original = project.read_text().replace("MetalANGLE.framework", "MetalANGLE.xcframework") + original = original.replace("/* Begin PBXBuildFile section */\n", "/* Begin PBXBuildFile section */\n" + "A3F003000000000000000003 /* MetalANGLE.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A3F003000000000000000004 /* MetalANGLE.xcframework */; };\n") + original = original.replace(" isa = PBXFrameworksBuildPhase;\n files = (\n", + " isa = PBXFrameworksBuildPhase;\n files = (\nA3F003000000000000000003 /* MetalANGLE.xcframework in Frameworks */,\n") + project.write_text(original) + patch(project, framework, metalangle) + actual = project.read_text() + self.assertNotIn("xcframework", actual) + self.assertNotIn("MetalANGLE.framework in Frameworks", actual) + self.assertEqual(actual.count("A3F003000000000000000004 /* MetalANGLE.framework */ ="), 1) + if __name__ == "__main__": unittest.main() diff --git a/tools/test_renpy_mobile_darwin_build.py b/tools/test_renpy_mobile_darwin_build.py index 774a5d1d5..c6e421c45 100644 --- a/tools/test_renpy_mobile_darwin_build.py +++ b/tools/test_renpy_mobile_darwin_build.py @@ -6,6 +6,8 @@ """ import ast from pathlib import Path +import os +import shutil import subprocess import sys import tarfile @@ -28,6 +30,44 @@ def task_function(path, name, namespace, *, archs=None): return namespace[name] +def autoconf_compiler_boundary(root, Context, run): + """Run the real NASM configure with real host CC through the patched formatter.""" + compiler = shutil.which("cc") + assert compiler is not None + sdk = subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-path"], text=True).strip() \ + if sys.platform == "darwin" else "/" + + def selected_tool(arguments, **_kwargs): + if arguments[1] == "--find": + return compiler if arguments[2] in ("clang", "clang++") else shutil.which(arguments[2]) + assert arguments == ["xcrun", "--sdk", "macosx", "--show-sdk-path"] + return sdk + + with patch.object(run.sys, "platform", "darwin"), \ + patch.object(run.subprocess, "check_output", selected_tool): + context = Context("host", "host", "3", root, SimpleNamespace()) + run.llvm(context) + # Use the real compiler directly: the boundary under test is Autoconf's + # expansion of CC, not ccache installation or any emulated Xcode SDK. + current = context.environ["CC"].removeprefix("ccache ") + old = f'"{compiler}" -isysroot "{sdk}" -std=gnu17' + for name, command, expect_success in (("quoted-cc", old, False), ("formatted-cc", current, True)): + destination = root / name + destination.mkdir() + with tarfile.open(root / "source/nasm-2.14.02.tar.gz") as source: + source.extractall(destination, filter="data") + source_root = destination / "nasm-2.14.02" + environment = {**os.environ, "CC": command, "CFLAGS": "", "CPPFLAGS": "", "LDFLAGS": ""} + result = subprocess.run(["./configure", "--prefix=" + str(destination / "install")], + cwd=source_root, env=environment, capture_output=True, text=True) + if expect_success: + assert result.returncode == 0, result.stdout + result.stderr + else: + assert result.returncode == 77, result.stdout + result.stderr + assert '"' + compiler + '"' in (source_root / "config.log").read_text() + print("Actual host NASM configure reproduced quoted-CC failure and passed the patched command; no iOS compilation performed") + + def main(): checkout = Path(sys.argv[1]) assert subprocess.check_output(["git", "-C", str(checkout), "rev-parse", "HEAD"], text=True).strip() == PIN @@ -36,6 +76,7 @@ def main(): archive = subprocess.Popen([ "git", "-C", str(checkout), "archive", "HEAD", "renpybuild", "tasks", "source/Python-3.12.8-Setup.stdlib", "tools/cmake_build_variables.cmake", + "source/nasm-2.14.02.tar.gz", ], stdout=subprocess.PIPE) assert archive.stdout is not None with tarfile.open(fileobj=archive.stdout, mode="r|*") as source: @@ -65,6 +106,7 @@ def xcrun(arguments, **_kwargs): context.set_names("python", "build", "renpython") assert target in context.environ["CC"] assert "-isysroot" in context.environ["CC"] + assert '"' not in context.environ["CC"] assert "-fuse-ld=lld" not in context.environ["CC"] assert "-stdlib=libc++" in context.environ["CXX"] assert "-lmockrt" not in context.environ["LDFLAGS"] @@ -109,6 +151,7 @@ def reject_shim_compile(command): assert "linux-x86_64" in context.environ["CC"] assert not context.native_darwin assert "CMAKE_POLICY_VERSION_MINIMUM" not in context.variables["cmake_args"] + autoconf_compiler_boundary(root, Context, run) print("Darwin iOS and Linux Android task configuration passed; no native build or gameplay performed") diff --git a/vcpkg/ports/sdl2/aetherkiri-ios-opengles-isolation.patch b/vcpkg/ports/sdl2/aetherkiri-ios-opengles-isolation.patch new file mode 100644 index 000000000..9843851f6 --- /dev/null +++ b/vcpkg/ports/sdl2/aetherkiri-ios-opengles-isolation.patch @@ -0,0 +1,116 @@ +diff --git a/src/video/uikit/SDL_uikitopengles.h b/src/video/uikit/SDL_uikitopengles.h +index 0533f69..c00ee77 100644 +--- a/src/video/uikit/SDL_uikitopengles.h ++++ b/src/video/uikit/SDL_uikitopengles.h +@@ -34,6 +34,9 @@ extern SDL_GLContext UIKit_GL_CreateContext(_THIS, SDL_Window * window); + extern void UIKit_GL_DeleteContext(_THIS, SDL_GLContext context); + extern void *UIKit_GL_GetProcAddress(_THIS, const char *proc); + extern int UIKit_GL_LoadLibrary(_THIS, const char *path); ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST ++extern void UIKit_GL_UnloadLibrary(_THIS); ++#endif + + extern void UIKit_GL_RestoreCurrentContext(void); + +diff --git a/src/video/uikit/SDL_uikitopengles.m b/src/video/uikit/SDL_uikitopengles.m +index 988966d..01f3eb0 100644 +--- a/src/video/uikit/SDL_uikitopengles.m ++++ b/src/video/uikit/SDL_uikitopengles.m +@@ -54,10 +54,17 @@ + + void *UIKit_GL_GetProcAddress(_THIS, const char *proc) + { +- /* Look through all SO's for the proc symbol. Here's why: +- * -Looking for the path to the OpenGL Library seems not to work in the iOS Simulator. +- * -We don't know that the path won't change in the future. */ ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST ++ /* The host can also load an isolated MetalANGLE runtime. Resolve only ++ * Apple's framework, whose EAGLContext owns this backend's contexts. */ ++ if (!_this->gl_config.dll_handle) { ++ SDL_SetError("Apple OpenGLES library is not loaded"); ++ return NULL; ++ } ++ return SDL_LoadFunction(_this->gl_config.dll_handle, proc); ++#else + return dlsym(RTLD_DEFAULT, proc); ++#endif + } + + /* +@@ -106,9 +113,35 @@ int UIKit_GL_LoadLibrary(_THIS, const char *path) + if (path != NULL) { + return SDL_SetError("iOS GL Load Library just here for compatibility"); + } ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST ++ if (_this->gl_config.dll_handle) { ++ return 0; ++ } ++ @autoreleasepool { ++ /* NSBundle locates the loaded Apple framework in the actual device ++ * or Simulator runtime instead of assuming a system filesystem path. */ ++ NSBundle *framework = [NSBundle bundleForClass:[EAGLContext class]]; ++ NSString *executable = [framework executablePath]; ++ if (!executable) { ++ return SDL_SetError("Could not locate Apple's loaded OpenGLES framework"); ++ } ++ _this->gl_config.dll_handle = SDL_LoadObject([executable fileSystemRepresentation]); ++ if (!_this->gl_config.dll_handle) { ++ return -1; ++ } ++ } ++#endif + return 0; + } + ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST ++void UIKit_GL_UnloadLibrary(_THIS) ++{ ++ SDL_UnloadObject(_this->gl_config.dll_handle); ++ _this->gl_config.dll_handle = NULL; ++} ++#endif ++ + int UIKit_GL_SwapWindow(_THIS, SDL_Window * window) + { + @autoreleasepool { +diff --git a/src/video/uikit/SDL_uikitvideo.m b/src/video/uikit/SDL_uikitvideo.m +index f7aa3fd..9120779 100644 +--- a/src/video/uikit/SDL_uikitvideo.m ++++ b/src/video/uikit/SDL_uikitvideo.m +@@ -121,6 +121,9 @@ static SDL_VideoDevice *UIKit_CreateDevice(void) + device->GL_DeleteContext = UIKit_GL_DeleteContext; + device->GL_GetProcAddress = UIKit_GL_GetProcAddress; + device->GL_LoadLibrary = UIKit_GL_LoadLibrary; ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST ++ device->GL_UnloadLibrary = UIKit_GL_UnloadLibrary; ++#endif + #endif + device->free = UIKit_DeleteDevice; + +@@ -155,6 +158,13 @@ VideoBootStrap UIKIT_bootstrap = { + + int UIKit_VideoInit(_THIS) + { ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST && (defined(SDL_VIDEO_OPENGL_ES) || defined(SDL_VIDEO_OPENGL_ES2)) ++ /* UIKit preloads its GL driver, so acquire the concrete framework handle ++ * before generic SDL_GL_LoadLibrary increments that initial reference. */ ++ if (UIKit_GL_LoadLibrary(_this, NULL) < 0) { ++ return -1; ++ } ++#endif + _this->gl_config.driver_loaded = 1; + + if (UIKit_InitModes(_this) < 0) { +@@ -173,6 +183,12 @@ void UIKit_VideoQuit(_THIS) + SDL_QuitGCMouse(); + + UIKit_QuitModes(_this); ++#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST && (defined(SDL_VIDEO_OPENGL_ES) || defined(SDL_VIDEO_OPENGL_ES2)) ++ /* SDL_VideoQuit frees the device even when no SDL window was created. ++ * Release its preloaded reference and any remaining explicit load refs. */ ++ UIKit_GL_UnloadLibrary(_this); ++ _this->gl_config.driver_loaded = 0; ++#endif + } + + void UIKit_SuspendScreenSaver(_THIS) diff --git a/vcpkg/ports/sdl2/portfile.cmake b/vcpkg/ports/sdl2/portfile.cmake index 593bb4877..4ddafa165 100644 --- a/vcpkg/ports/sdl2/portfile.cmake +++ b/vcpkg/ports/sdl2/portfile.cmake @@ -9,6 +9,7 @@ vcpkg_from_github( alsa-dep-fix.patch cxx-linkage-pkgconfig.diff aetherkiri-embedded-android-jni.patch + aetherkiri-ios-opengles-isolation.patch ) string(COMPARE EQUAL "${VCPKG_LIBRARY_LINKAGE}" "static" SDL_STATIC) From 173c5f91f10c98201476bc5efbd46dfcb9ce4d2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 14:41:00 +0000 Subject: [PATCH 263/331] fix: restore host SDL environment after RenPy lifecycle calls --- .github/workflows/renpy-ios-runtime-build.yml | 2 +- .../workflows/renpy-mobile-runtime-build.yml | 2 +- .../include/renpy_mobile_loader.h | 7 +- .../src/renpy_mobile_loader.cpp | 82 +++++++++++++++++-- tools/test_renpy_mobile_launcher_loader.sh | 81 ++++++++++++++++-- 5 files changed, 154 insertions(+), 20 deletions(-) diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index ddb907a58..f4abb2657 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -68,7 +68,7 @@ jobs: uses: actions/cache@v6 with: path: out/renpy-ios-source - key: ${{ runner.os }}-${{ runner.arch }}-renpy-ios-runtime-v1-${{ hashFiles('tools/run_renpy_mobile_source_build.sh', 'tools/apply_renpy_mobile_patch_series.py', 'bridge/renpy_runtime/mobile_launcher/build.sh', 'bridge/renpy_runtime/mobile_launcher/include/*.h', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.patch', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.c', 'bridge/renpy_runtime/mobile_launcher/patches/python/*.patch') }} + key: ${{ runner.os }}-${{ runner.arch }}-renpy-ios-runtime-v1-${{ hashFiles('tools/run_renpy_mobile_source_build.sh', 'tools/apply_renpy_mobile_patch_series.py', 'bridge/renpy_runtime/mobile_launcher/build.sh', 'bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.patch', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.c', 'bridge/renpy_runtime/mobile_launcher/patches/python/*.patch') }} - name: Provision native macOS prerequisites and record the actual SDKs if: steps.runtime-cache.outputs.cache-hit != 'true' run: | diff --git a/.github/workflows/renpy-mobile-runtime-build.yml b/.github/workflows/renpy-mobile-runtime-build.yml index 726b02260..148f500c5 100644 --- a/.github/workflows/renpy-mobile-runtime-build.yml +++ b/.github/workflows/renpy-mobile-runtime-build.yml @@ -34,7 +34,7 @@ jobs: uses: actions/cache@v6 with: path: out/renpy-mobile-source - key: ${{ runner.os }}-renpy-android-runtime-v3-${{ hashFiles('tools/run_renpy_mobile_source_build.sh', 'tools/apply_renpy_mobile_patch_series.py', 'bridge/renpy_runtime/mobile_launcher/build.sh', 'bridge/renpy_runtime/mobile_launcher/include/*.h', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.patch', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.c', 'bridge/renpy_runtime/mobile_launcher/patches/python/*.patch') }} + key: ${{ runner.os }}-renpy-android-runtime-v3-${{ hashFiles('tools/run_renpy_mobile_source_build.sh', 'tools/apply_renpy_mobile_patch_series.py', 'bridge/renpy_runtime/mobile_launcher/build.sh', 'bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.patch', 'bridge/renpy_runtime/mobile_launcher/patches/native/*.c', 'bridge/renpy_runtime/mobile_launcher/patches/python/*.patch') }} - name: Provision the official Linux build prerequisites if: steps.runtime-cache.outputs.cache-hit != 'true' run: | diff --git a/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_loader.h b/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_loader.h index 519f38e15..7cace1a53 100644 --- a/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_loader.h +++ b/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_loader.h @@ -3,6 +3,7 @@ #include "renpy_mobile_launcher.h" +#include #include namespace aetherkiri::renpy::mobile { @@ -12,7 +13,7 @@ namespace aetherkiri::renpy::mobile { * * The official RAPT/Renios archives do not export these functions, so an * unavailable loader is an expected and safe state. Android resolves the - * symbols from librenpython.so; iOS links the rebuilt archive with strong + * symbols from librenpython.so; iOS links the isolated runtime framework with strong * references so missing native inputs fail the build. This class never falls back to * SDL_main, launcher_main, Py_RunMain, or an application entrypoint. */ @@ -74,6 +75,10 @@ class Launcher final { bool resolved_ = false; bool initialized_ = false; bool finished_ = false; + // The isolated SDL library still reads process environment variables. + // Apply its video selection only while executing its owner-thread ABI. + std::array video_environment_values_; + std::array video_environment_defined_{}; std::string last_error_; }; diff --git a/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp index 3022e70e6..4ae946d86 100644 --- a/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp +++ b/bridge/renpy_runtime/mobile_launcher/src/renpy_mobile_loader.cpp @@ -1,5 +1,6 @@ #include "renpy_mobile_loader.h" +#include #include #if defined(__ANDROID__) || defined(__APPLE__) || defined(AETHERKIRI_RENPY_PROTOCOL_TEST) @@ -12,6 +13,52 @@ namespace { constexpr int kOk = RENPY_MOBILE_OK; +constexpr const char* kVideoEnvironment[] = { + "SDL_VIDEODRIVER", "SDL_VIDEO_GL_DRIVER", "SDL_VIDEO_EGL_DRIVER"}; + +void CaptureVideoEnvironment(std::array& values, + std::array& defined) { + for (size_t index = 0; index < values.size(); ++index) { + const char* value = std::getenv(kVideoEnvironment[index]); + defined[index] = value != nullptr; + values[index] = value ? value : ""; + } +} + +bool ReplaceVideoEnvironment(const std::array& values, + const std::array& defined) { + bool success = true; + for (size_t index = 0; index < values.size(); ++index) { +#if defined(_WIN32) + const int status = _putenv_s(kVideoEnvironment[index], + defined[index] ? values[index].c_str() : ""); +#else + const int status = defined[index] + ? setenv(kVideoEnvironment[index], values[index].c_str(), 1) + : unsetenv(kVideoEnvironment[index]); +#endif + if (status != 0) success = false; + } + return success; +} + +// Snapshot on every call: the host may update its selection between calls. +// Bootstrap supplies the runtime values before the first cooperative tick. +class VideoEnvironmentScope final { + public: + VideoEnvironmentScope() { CaptureVideoEnvironment(values_, defined_); } + ~VideoEnvironmentScope() { ReplaceVideoEnvironment(values_, defined_); } + + bool Apply(const std::array& values, + const std::array& defined) { + return ReplaceVideoEnvironment(values, defined); + } + + private: + std::array values_; + std::array defined_{}; +}; + #if defined(__ANDROID__) || defined(__APPLE__) || defined(AETHERKIRI_RENPY_PROTOCOL_TEST) template Function ResolveSymbol(void* handle, const char* name) { @@ -57,9 +104,8 @@ bool Launcher::Resolve() { bind_window_ = ResolveSymbol(library_handle_, "renpy_mobile_bind_window"); shutdown_ = ResolveSymbol(library_handle_, "renpy_mobile_shutdown"); #elif defined(AETHERKIRI_RENPY_IOS) - // A static Renios archive must have real references so the linker retains - // its lifecycle and built-in extension objects. Missing payloads fail at - // link time instead of silently producing an installable stub application. + // Strong references bind the isolated AetherRenPyRuntime framework. Missing + // lifecycle exports fail at link time instead of producing a stub app. init_ = renpy_mobile_init; bootstrap_ = renpy_mobile_bootstrap; tick_ = renpy_mobile_tick; @@ -72,10 +118,8 @@ bool Launcher::Resolve() { bind_window_ = renpy_mobile_bind_window; shutdown_ = renpy_mobile_shutdown; #elif defined(__APPLE__) || defined(AETHERKIRI_RENPY_PROTOCOL_TEST) - // The normal staged Renios archive is not linked into the Godot app. Keep - // the optional ABI entirely dynamic so an absent lifecycle fork never adds - // unresolved symbols to the iOS executable. A real fork can export these - // symbols from the merged extension archive for RTLD_DEFAULT lookup. + // Optional protocol-test lookup. Production iOS binds the isolated runtime + // framework above and never resolves its internal SDL or Python symbols. init_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_init"); bootstrap_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_bootstrap"); tick_ = ResolveSymbol(RTLD_DEFAULT, "renpy_mobile_tick"); @@ -110,7 +154,9 @@ int Launcher::Init(const renpy_mobile_config_t& config, const renpy_mobile_host_t& host) { if (!Resolve()) return RENPY_MOBILE_NOT_IMPLEMENTED; if (initialized_) return RENPY_MOBILE_INVALID_STATE; + VideoEnvironmentScope environment; int result = bootstrap_ ? bootstrap_(&config, &host) : RENPY_MOBILE_OK; + CaptureVideoEnvironment(video_environment_values_, video_environment_defined_); if (result == kOk) result = init_(&config, &host); if (result == kOk) { initialized_ = true; @@ -124,6 +170,8 @@ int Launcher::Init(const renpy_mobile_config_t& config, int Launcher::Tick(uint32_t budget_ms) { if (!initialized_ || !tick_) return RENPY_MOBILE_INVALID_STATE; + VideoEnvironmentScope environment; + if (!environment.Apply(video_environment_values_, video_environment_defined_)) return RENPY_MOBILE_ERROR; const int result = tick_(budget_ms); if (result == RENPY_MOBILE_FINISHED) finished_ = true; return result; @@ -131,36 +179,54 @@ int Launcher::Tick(uint32_t budget_ms) { int Launcher::Frame(renpy_mobile_frame_t* out_frame) { if (!initialized_ || !frame_ || !out_frame) return RENPY_MOBILE_INVALID_STATE; + VideoEnvironmentScope environment; + if (!environment.Apply(video_environment_values_, video_environment_defined_)) return RENPY_MOBILE_ERROR; return frame_(out_frame); } int Launcher::Input(const renpy_mobile_input_t& event) { if (!initialized_ || !input_) return RENPY_MOBILE_INVALID_STATE; + VideoEnvironmentScope environment; + if (!environment.Apply(video_environment_values_, video_environment_defined_)) return RENPY_MOBILE_ERROR; return input_(&event); } int Launcher::Pause() { if (!initialized_ || !pause_) return RENPY_MOBILE_INVALID_STATE; + VideoEnvironmentScope environment; + if (!environment.Apply(video_environment_values_, video_environment_defined_)) return RENPY_MOBILE_ERROR; return pause_(); } int Launcher::Resume() { if (!initialized_ || !resume_) return RENPY_MOBILE_INVALID_STATE; + VideoEnvironmentScope environment; + if (!environment.Apply(video_environment_values_, video_environment_defined_)) return RENPY_MOBILE_ERROR; return resume_(); } int Launcher::TextInputState(uint32_t* active) { if (!initialized_ || !active) return RENPY_MOBILE_INVALID_STATE; + VideoEnvironmentScope environment; + if (!environment.Apply(video_environment_values_, video_environment_defined_)) return RENPY_MOBILE_ERROR; return text_input_state_ ? text_input_state_(active) : RENPY_MOBILE_NOT_IMPLEMENTED; } int Launcher::SetSurfaceSize(uint32_t width, uint32_t height) { if (!initialized_) return RENPY_MOBILE_INVALID_STATE; + VideoEnvironmentScope environment; + if (!environment.Apply(video_environment_values_, video_environment_defined_)) return RENPY_MOBILE_ERROR; return surface_size_ ? surface_size_(width, height) : RENPY_MOBILE_NOT_IMPLEMENTED; } void Launcher::Shutdown() { - if (initialized_ && shutdown_) shutdown_(); + if (initialized_ && shutdown_) { + VideoEnvironmentScope environment; + if (!environment.Apply(video_environment_values_, video_environment_defined_)) { + last_error_ = "Could not select the Ren'Py video environment for shutdown"; + } + shutdown_(); + } initialized_ = false; finished_ = false; } diff --git a/tools/test_renpy_mobile_launcher_loader.sh b/tools/test_renpy_mobile_launcher_loader.sh index 77f70c446..bbe664fa9 100644 --- a/tools/test_renpy_mobile_launcher_loader.sh +++ b/tools/test_renpy_mobile_launcher_loader.sh @@ -7,48 +7,111 @@ trap 'rm -rf "$work"' EXIT cat > "$work/fake_launcher.cpp" <<'CPP' #include "renpy_mobile_launcher.h" +#include +#include #include static unsigned char pixel[4] = {0x12, 0x34, 0x56, 0xff}; -extern "C" int renpy_mobile_init(const renpy_mobile_config_t*, const renpy_mobile_host_t*) { return 0; } -extern "C" int renpy_mobile_bootstrap(const renpy_mobile_config_t*, const renpy_mobile_host_t*) { return 0; } -extern "C" int renpy_mobile_tick(unsigned int) { return 0; } +static void verify_runtime_environment() { + assert(std::getenv("SDL_VIDEODRIVER") && !std::strcmp(std::getenv("SDL_VIDEODRIVER"), "offscreen")); + assert(std::getenv("SDL_VIDEO_GL_DRIVER") && !std::strcmp(std::getenv("SDL_VIDEO_GL_DRIVER"), "/runtime/MetalANGLE")); + assert(std::getenv("SDL_VIDEO_EGL_DRIVER") && !std::strcmp(std::getenv("SDL_VIDEO_EGL_DRIVER"), "/runtime/MetalANGLE")); +} +extern "C" int renpy_mobile_init(const renpy_mobile_config_t* config, const renpy_mobile_host_t*) { + verify_runtime_environment(); + return config->game_root_utf8 && !std::strcmp(config->game_root_utf8, "init_fail") ? RENPY_MOBILE_ERROR : 0; +} +extern "C" int renpy_mobile_bootstrap(const renpy_mobile_config_t* config, const renpy_mobile_host_t*) { + setenv("SDL_VIDEODRIVER", "offscreen", 1); + setenv("SDL_VIDEO_GL_DRIVER", "/runtime/MetalANGLE", 1); + setenv("SDL_VIDEO_EGL_DRIVER", "/runtime/MetalANGLE", 1); + return config->game_root_utf8 && !std::strcmp(config->game_root_utf8, "bootstrap_fail") ? RENPY_MOBILE_ERROR : 0; +} +extern "C" int renpy_mobile_tick(unsigned int budget) { + verify_runtime_environment(); + return budget == 13 ? RENPY_MOBILE_ERROR : 0; +} extern "C" int renpy_mobile_frame(renpy_mobile_frame_t* frame) { + verify_runtime_environment(); if (!frame) return -1; frame->rgba = pixel; frame->width = 1; frame->height = 1; frame->stride = 4; frame->serial = 7; return 0; } -extern "C" int renpy_mobile_input(const renpy_mobile_input_t*) { return 0; } -extern "C" int renpy_mobile_pause(void) { return 0; } -extern "C" int renpy_mobile_resume(void) { return 0; } +extern "C" int renpy_mobile_input(const renpy_mobile_input_t*) { verify_runtime_environment(); return 0; } +extern "C" int renpy_mobile_pause(void) { verify_runtime_environment(); return 0; } +extern "C" int renpy_mobile_resume(void) { verify_runtime_environment(); return 0; } extern "C" int renpy_mobile_bind_window(void* window) { return window ? 0 : -1; } -extern "C" int renpy_mobile_text_input_state(uint32_t* active) { if (!active) return -1; *active = 1; return 0; } -extern "C" int renpy_mobile_set_surface_size(uint32_t width, uint32_t height) { return width && height ? 0 : -1; } -extern "C" void renpy_mobile_shutdown(void) {} +extern "C" int renpy_mobile_text_input_state(uint32_t* active) { verify_runtime_environment(); if (!active) return -1; *active = 1; return 0; } +extern "C" int renpy_mobile_set_surface_size(uint32_t width, uint32_t height) { verify_runtime_environment(); return width && height ? 0 : -1; } +extern "C" void renpy_mobile_shutdown(void) { + verify_runtime_environment(); + // A callback can mutate process state; the loader must still restore host values. + unsetenv("SDL_VIDEODRIVER"); + setenv("SDL_VIDEO_GL_DRIVER", "shutdown changed this", 1); +} CPP cat > "$work/test_loader.cpp" <<'CPP' #include "renpy_mobile_loader.h" #include +#include #include using namespace aetherkiri::renpy::mobile; +static void expect_host_environment(const char* driver = "host-uikit") { + assert(std::getenv("SDL_VIDEODRIVER") && !std::strcmp(std::getenv("SDL_VIDEODRIVER"), driver)); + assert(!std::getenv("SDL_VIDEO_GL_DRIVER")); + assert(std::getenv("SDL_VIDEO_EGL_DRIVER") && !std::strcmp(std::getenv("SDL_VIDEO_EGL_DRIVER"), "")); +} int main() { + setenv("SDL_VIDEODRIVER", "host-uikit", 1); + unsetenv("SDL_VIDEO_GL_DRIVER"); + setenv("SDL_VIDEO_EGL_DRIVER", "", 1); Launcher launcher; assert(launcher.available()); renpy_mobile_config_t config{}; config.struct_size = sizeof(config); config.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; renpy_mobile_host_t host{}; host.struct_size = sizeof(host); host.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; assert(launcher.Init(config, host) == RENPY_MOBILE_OK); + expect_host_environment(); assert(launcher.Tick(16) == RENPY_MOBILE_OK); + expect_host_environment(); + assert(launcher.Tick(13) == RENPY_MOBILE_ERROR); + expect_host_environment(); renpy_mobile_frame_t frame{}; frame.struct_size = sizeof(frame); assert(launcher.Frame(&frame) == RENPY_MOBILE_OK); assert(frame.width == 1 && frame.height == 1 && frame.stride == 4 && frame.serial == 7); + expect_host_environment(); renpy_mobile_input_t input{}; input.struct_size = sizeof(input); input.type = RENPY_MOBILE_INPUT_KEY; assert(launcher.Input(input) == RENPY_MOBILE_OK); + expect_host_environment(); assert(launcher.Pause() == RENPY_MOBILE_OK); + expect_host_environment(); + // Snapshot every call instead of restoring a stale value from Init. + setenv("SDL_VIDEODRIVER", "host-driver-changed-while-paused", 1); assert(launcher.Resume() == RENPY_MOBILE_OK); + expect_host_environment("host-driver-changed-while-paused"); uint32_t active = 0; assert(launcher.TextInputState(&active) == RENPY_MOBILE_OK && active == 1); + expect_host_environment("host-driver-changed-while-paused"); assert(launcher.SetSurfaceSize(1280, 720) == RENPY_MOBILE_OK); + expect_host_environment("host-driver-changed-while-paused"); launcher.Shutdown(); assert(!launcher.initialized()); + expect_host_environment("host-driver-changed-while-paused"); + for (const char* failure : {"bootstrap_fail", "init_fail"}) { + Launcher failing; + config.game_root_utf8 = failure; + assert(failing.Init(config, host) == RENPY_MOBILE_ERROR); + expect_host_environment("host-driver-changed-while-paused"); + } + for (const char* name : {"SDL_VIDEODRIVER", "SDL_VIDEO_GL_DRIVER", "SDL_VIDEO_EGL_DRIVER"}) unsetenv(name); + { + Launcher initially_absent; + config.game_root_utf8 = "game"; + assert(initially_absent.Init(config, host) == RENPY_MOBILE_OK); + assert(!std::getenv("SDL_VIDEODRIVER") && !std::getenv("SDL_VIDEO_GL_DRIVER") && !std::getenv("SDL_VIDEO_EGL_DRIVER")); + assert(initially_absent.Tick(16) == RENPY_MOBILE_OK); + assert(!std::getenv("SDL_VIDEODRIVER") && !std::getenv("SDL_VIDEO_GL_DRIVER") && !std::getenv("SDL_VIDEO_EGL_DRIVER")); + } + // Destructor shutdown also restores the three originally absent values. + assert(!std::getenv("SDL_VIDEODRIVER") && !std::getenv("SDL_VIDEO_GL_DRIVER") && !std::getenv("SDL_VIDEO_EGL_DRIVER")); return 0; } CPP From 2b3d95e761c428f76130700607d6cb0dfe872dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 15:00:32 +0000 Subject: [PATCH 264/331] fix: backport libffi arm64 Mach-O unwind assembly ordering --- .github/workflows/renpy-ios-runtime-build.yml | 3 + .github/workflows/renpy-mobile-contract.yml | 5 +- .../native/0007-darwin-ios-build.patch | 51 +++++++++ .../mobile_launcher/patches/native/README.md | 7 ++ tools/test_renpy_mobile_libffi_cfi.py | 103 ++++++++++++++++++ 5 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 tools/test_renpy_mobile_libffi_cfi.py diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index f4abb2657..0f01a9568 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -111,6 +111,9 @@ jobs: uv --project "$RENPY_BUILD_ROOT/renpy" run python -m compileall -q \ "$RENPY_BUILD_ROOT/renpybuild" "$RENPY_BUILD_ROOT/tasks" \ 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" + uv --project "$RENPY_BUILD_ROOT/renpy" run python \ + "$GITHUB_WORKSPACE/tools/test_renpy_mobile_libffi_cfi.py" "$RENPY_BUILD_ROOT" \ + 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" - name: Compile target CPython, SDL, Ren'Py and device/Simulator archives if: steps.runtime-cache.outputs.cache-hit != 'true' run: | diff --git a/.github/workflows/renpy-mobile-contract.yml b/.github/workflows/renpy-mobile-contract.yml index 69358cb5a..50ade8bf0 100644 --- a/.github/workflows/renpy-mobile-contract.yml +++ b/.github/workflows/renpy-mobile-contract.yml @@ -23,6 +23,7 @@ on: - 'tools/test_renpy_mobile_darwin_build.py' - 'tools/test_renpy_mobile_build_parallelism.py' - 'tools/test_renpy_mobile_static_api.py' + - 'tools/test_renpy_mobile_libffi_cfi.py' - 'tools/validate_renpy_mobile_payload.py' - 'tools/test_renpy_mobile_packaging.sh' - 'tools/test_renpy_mobile_runtime_protocol.sh' @@ -53,7 +54,7 @@ jobs: run: | set -euo pipefail sudo apt-get update - sudo apt-get install -y build-essential binutils python3-dev libsdl2-dev pkg-config + sudo apt-get install -y build-essential binutils clang-18 python3-dev libsdl2-dev pkg-config - name: Run mobile launcher scaffold and loader tests run: | @@ -105,6 +106,8 @@ jobs: tools/test_renpy_mobile_build_parallelism.py _renpy-build-source "${RUNNER_TEMP}/cooperative-test/bin/python" \ tools/test_renpy_mobile_patch_series.py _renpy-build-source + "${RUNNER_TEMP}/cooperative-test/bin/python" \ + tools/test_renpy_mobile_libffi_cfi.py _renpy-build-source --clang /usr/bin/clang-18 - name: Generate the static API and compile its genuine C consumers run: | diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch index 3ad45f1a0..1f5dc3f33 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch @@ -328,3 +328,54 @@ diff --git a/tasks/zsync.py b/tasks/zsync.py @task(kind="python", platforms="linux") def build_linux(c: Context): +diff --git a/tasks/libffi.py b/tasks/libffi.py +--- a/tasks/libffi.py ++++ b/tasks/libffi.py +@@ -10,6 +10,8 @@ + + c.var("version", version) + c.run("tar xzf {{source}}/libffi-{{version}}.tar.gz") ++ c.chdir("libffi-{{version}}") ++ c.patchdir("libffi") + + + @task() +diff --git a/patches/libffi/aarch64-cfi.patch b/patches/libffi/aarch64-cfi.patch +new file mode 100644 +--- /dev/null ++++ b/patches/libffi/aarch64-cfi.patch +@@ -0,0 +1,34 @@ ++# Backport libffi/libffi 8308bed5b2423878aa20d7884a99cf2e30b8daf7 (#857). ++# Keep Mach-O entry labels before their CFI region; retain all unwind metadata. ++--- a/src/aarch64/sysv.S +++++ b/src/aarch64/sysv.S ++@@ -89,8 +89,8 @@ ++ x5 closure ++ */ ++ +++CNAME(ffi_call_SYSV): ++ cfi_startproc ++-CNAME(ffi_call_SYSV): ++ BTI_C ++ /* Sign the lr with x1 since that is where it will be stored */ ++ SIGN_LR_WITH_REG(x1) ++@@ -347,8 +347,8 @@ ++ #endif ++ ++ .align 4 +++CNAME(ffi_closure_SYSV): ++ cfi_startproc ++-CNAME(ffi_closure_SYSV): ++ BTI_C ++ SIGN_LR ++ stp x29, x30, [sp, #-ffi_closure_SYSV_FS]! ++@@ -643,8 +643,8 @@ ++ #endif ++ ++ .align 4 +++CNAME(ffi_go_closure_SYSV): ++ cfi_startproc ++-CNAME(ffi_go_closure_SYSV): ++ BTI_C ++ stp x29, x30, [sp, #-ffi_closure_SYSV_FS]! ++ cfi_adjust_cfa_offset (ffi_closure_SYSV_FS) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index 5770443f9..2ee680537 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -38,6 +38,13 @@ unsupported paths explicitly. Quoted path text inside `CC` is interpreted as part of the executable name by the pinned configure scripts. CI preserves their `config.log` files alongside the source-build log when compilation fails. +The Darwin port also backports libffi's upstream fix +[`8308bed5`](https://github.com/libffi/libffi/commit/8308bed5b2423878aa20d7884a99cf2e30b8daf7) +to the pinned 3.4.5 source archive. Three arm64 entry labels precede +`cfi_startproc`, as required by LLVM 17 and later when assembling Mach-O. +Unwind metadata remains enabled. The unpack task applies this patch before +any target compilation; it does not replace the pinned libffi dependency. + `0009-bounded-build-parallelism.patch` limits the upstream native object build group to the host CPU count. Set `RENPY_BUILD_JOBS` to a positive integer to select another limit. Child compiler failures still fail the build, and an diff --git a/tools/test_renpy_mobile_libffi_cfi.py b/tools/test_renpy_mobile_libffi_cfi.py new file mode 100644 index 000000000..755696fb4 --- /dev/null +++ b/tools/test_renpy_mobile_libffi_cfi.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Compile the pinned libffi unwind assembly with the genuine LLVM assembler. + +On macOS this uses the selected Xcode SDKs and the complete assembly. On Linux +the Apple closure trampoline table is excluded from this focused CFI check, +because its Mach SDK header is unavailable. Neither case links a full runtime +or executes gameplay. +""" +import argparse +import ast +from pathlib import Path +import os +import shutil +import subprocess +import sys +import tarfile +import tempfile +from types import SimpleNamespace + +REPO = Path(__file__).resolve().parents[1] +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build_source", type=Path) + parser.add_argument("--clang", type=Path) + args = parser.parse_args() + assert subprocess.check_output(["git", "-C", str(args.build_source), "rev-parse", "HEAD"], text=True).strip() == PIN + if sys.platform == "darwin": + compiler = str(args.clang) if args.clang else subprocess.check_output(["xcrun", "--find", "clang"], text=True).strip() + else: + compiler = str(args.clang) if args.clang else shutil.which("clang-18") or shutil.which("clang") + assert compiler, "A genuine LLVM clang is required for the Mach-O assembly regression" + host_cc = shutil.which("cc") + assert host_cc + with tempfile.TemporaryDirectory(prefix="renpy-libffi-cfi-") as temporary: + root = Path(temporary) + archive = subprocess.Popen([ + "git", "-C", str(args.build_source), "archive", "HEAD", "renpybuild", "tasks", + "source/Python-3.12.8-Setup.stdlib", "tools/cmake_build_variables.cmake", + "source/libffi-3.4.5.tar.gz", + ], stdout=subprocess.PIPE) + assert archive.stdout is not None + with tarfile.open(fileobj=archive.stdout, mode="r|*") as source: + source.extractall(root, filter="data") + assert archive.wait() == 0 + subprocess.run(["git", "apply", str(REPO / "bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch")], cwd=root, check=True) + with tarfile.open(root / "source/libffi-3.4.5.tar.gz") as source: + source.extractall(root, filter="data") + source_root = root / "libffi-3.4.5" + environment = {**os.environ, "CC": host_cc, "CFLAGS": "", "CPPFLAGS": "", "LDFLAGS": ""} + build = subprocess.check_output([host_cc, "-dumpmachine"], text=True).strip() + configured = subprocess.run(["./configure", "--host=aarch64-apple-darwin", "--build=" + build, + "--disable-shared", "--enable-portable-binary"], + cwd=source_root, env=environment, capture_output=True, text=True) + assert configured.returncode == 0, configured.stdout + configured.stderr + configuration = next(source_root.glob("*/fficonfig.h")) + if sys.platform != "darwin": + # This test isolates CFI, not the Apple-specific closure page table. + # Production configuration and source compilation retain that table. + text = configuration.read_text() + assert "#define FFI_EXEC_TRAMPOLINE_TABLE 1" in text + configuration.write_text(text.replace("#define FFI_EXEC_TRAMPOLINE_TABLE 1", "#define FFI_EXEC_TRAMPOLINE_TABLE 0")) + includes = ["-DHAVE_CONFIG_H", "-I" + str(configuration.parent), + "-I" + str(configuration.parent / "include"), "-Iinclude", "-Isrc"] + targets = (("arm64-apple-ios13.0", "iphoneos"), + ("arm64-apple-ios13.0-simulator", "iphonesimulator")) + for target, sdk in targets: + sdk_args = [] + if sys.platform == "darwin": + sdk_args = ["-isysroot", subprocess.check_output(["xcrun", "--sdk", sdk, "--show-sdk-path"], text=True).strip()] + command = [compiler, "-target", target, *sdk_args, *includes, "-c", "src/aarch64/sysv.S", "-o", target + ".o"] + result = subprocess.run(command, cwd=source_root, capture_output=True, text=True) + assert result.returncode != 0 and "invalid CFI advance_loc expression" in result.stderr, result.stderr + # Execute the genuine unpack function through its real Context, so a + # regression in the task's patch wiring also fails this check. + sys.path.insert(0, str(root)) + from renpybuild.context import Context + tree = ast.parse((root / "tasks/libffi.py").read_text()) + unpack = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "unpack") + unpack.decorator_list = [] + namespace = {"Context": Context, "version": "3.4.5"} + exec(compile(ast.Module(body=[unpack], type_ignores=[]), str(root / "tasks/libffi.py"), "exec"), namespace) + context = Context("ios", "arm64", "3", root, SimpleNamespace()) + context.set_names("arch", "unpack", "libffi") + namespace["unpack"](context) + source_root = context.cwd + for target, sdk in targets: + sdk_args = [] + if sys.platform == "darwin": + sdk_args = ["-isysroot", subprocess.check_output(["xcrun", "--sdk", sdk, "--show-sdk-path"], text=True).strip()] + command = [compiler, "-target", target, *sdk_args, *includes, "-c", "src/aarch64/sysv.S", "-o", target + ".o"] + result = subprocess.run(command, cwd=source_root, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert (source_root / (target + ".o")).read_bytes()[:4] == bytes.fromhex("cffaedfe") + print("Real LLVM assembly reproduced the pinned CFI failure and compiled patched arm64 device/Simulator Mach-O objects") + if sys.platform != "darwin": + print("Linux check excluded the Apple closure page table; full Xcode runtime compilation and gameplay are not covered") + + +if __name__ == "__main__": + main() From e81e7d98b3b07b58cee97a67a65a9c76aa55e5fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 15:04:27 +0000 Subject: [PATCH 265/331] fix: retain RenPy raw assets through Android APK export --- .github/workflows/build.yml | 53 ++++- .github/workflows/renpy-android-app.yml | 240 +++++++++++++++++++++ scripts/build_android.sh | 4 + tools/inspect_renpy_android_apk.py | 93 ++++++++ tools/stage_renpy_android_support.sh | 29 ++- tools/test_renpy_android_apk_inspection.py | 82 +++++++ tools/test_stage_renpy_android_support.sh | 40 ++-- 7 files changed, 525 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/renpy-android-app.yml create mode 100644 tools/inspect_renpy_android_apk.py create mode 100644 tools/test_renpy_android_apk_inspection.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2f1358e29..720fa2715 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -364,7 +364,7 @@ jobs: export CMAKE_C_COMPILER_LAUNCHER=ccache export CMAKE_CXX_COMPILER_LAUNCHER=ccache export NDK_CCACHE=ccache - ./build.sh android release --jobs=4 + ./build.sh android release --jobs=4 2>&1 | tee "$RUNNER_TEMP/renpy-android-release-build.log" if [[ "$AETHERKIRI_WITH_INTERNAL" == "true" ]]; then grep -Fx 'AETHERKIRI_ENABLE_SOFTPAL_RUNTIME:BOOL=ON' \ out/android/arm64-v8a/release/CMakeCache.txt @@ -379,7 +379,7 @@ jobs: export CMAKE_C_COMPILER_LAUNCHER=ccache export CMAKE_CXX_COMPILER_LAUNCHER=ccache export NDK_CCACHE=ccache - ./build.sh android debug --jobs=4 + ./build.sh android debug --jobs=4 2>&1 | tee "$RUNNER_TEMP/renpy-android-debug-build.log" - name: Verify the compiled provider and final APK runtime payload run: | @@ -418,7 +418,7 @@ jobs: export CMAKE_C_COMPILER_LAUNCHER=ccache export CMAKE_CXX_COMPILER_LAUNCHER=ccache export NDK_CCACHE=ccache - bash scripts/build_android.sh debug --abi=x86_64 + bash scripts/build_android.sh debug --abi=x86_64 2>&1 | tee "$RUNNER_TEMP/renpy-android-emulator-build.log" python3 tools/validate_renpy_mobile_payload.py --platform android --abi x86_64 \ --apk out/godot/android/x86_64/debug/Aether-emulator-debug.apk @@ -430,6 +430,53 @@ jobs: if-no-files-found: error retention-days: 7 + - name: Record any produced APK even when its build or payload gate failed + if: always() + run: | + python3 - <<'PY' + import hashlib, json, pathlib, subprocess, zipfile + root = pathlib.Path('out/godot/android') + for apk in root.rglob('*.apk'): + output = apk.parent / 'diagnostics' + output.mkdir(parents=True, exist_ok=True) + result = { + 'artifact_kind': 'diagnostic_apk', 'gameplay': 'unverified', + 'ci_job_status': '${{ job.status }}', 'apk': str(apk), + } + digest = hashlib.sha256() + with apk.open('rb') as stream: + for block in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(block) + result['apk_sha256'] = digest.hexdigest() + try: + with zipfile.ZipFile(apk) as archive: + (output / 'apk-contents.txt').write_text('\n'.join(sorted(archive.namelist())) + '\n') + except zipfile.BadZipFile as error: + result['zip_error'] = str(error) + # Keep the checksums captured against this APK's own stage. A + # subsequent x86_64 export replaces the current arm64 stage. + if not (output / 'apk-payload-checksums.json').exists(): + abi = 'x86_64' if 'x86_64' in apk.parts else 'arm64-v8a' + subprocess.run(['python3', 'tools/inspect_renpy_android_apk.py', + '--apk', str(apk), '--abi', abi, + '--godot-build', 'apps/godot_app/android/build', + '--output-dir', str(output)], check=False) + (output / 'ci-build-result.json').write_text(json.dumps(result, indent=2) + '\n') + PY + + - name: Preserve Android application diagnostics, including failed APKs + if: always() + uses: actions/upload-artifact@v6 + with: + name: android-app-build-diagnostics + path: | + ${{ runner.temp }}/renpy-android-*-build.log + out/godot/android/**/*.apk + out/godot/android/**/diagnostics/** + apps/godot_app/android/build/aether-renpy.gradle + if-no-files-found: warn + retention-days: 7 + - name: Save ccache if: success() && github.event_name != 'pull_request' && steps.restore-ccache.outputs.cache-hit != 'true' uses: actions/cache/save@v6 diff --git a/.github/workflows/renpy-android-app.yml b/.github/workflows/renpy-android-app.yml new file mode 100644 index 000000000..1ccc15793 --- /dev/null +++ b/.github/workflows/renpy-android-app.yml @@ -0,0 +1,240 @@ +name: Ren'Py Android APK reproduction + +# Independently reproduce app packaging with an already compiled dependency. +# The main Build workflow continues to compile the exact current source tree. +# This pinned, expiring Actions artifact is deliberate: never silently replace +# it with an unrelated runtime or describe it as compiled from the host HEAD. +on: + pull_request: + paths: + - '.github/workflows/renpy-android-app.yml' + - 'scripts/build_android.sh' + - 'tools/stage_renpy_android_support.sh' + - 'tools/inspect_renpy_android_apk.py' + - 'bridge/renpy_runtime/android/**' + workflow_dispatch: + +permissions: + contents: read + actions: read + packages: read + +concurrency: + group: renpy-android-apk-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + package: + name: Installable Debug APK (${{ matrix.abi }}) + runs-on: ubuntu-24.04 + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + abi: [arm64-v8a, x86_64] + env: + GODOT_RELEASE: 4.7.2-stable + GODOT_TEMPLATE_VERSION: 4.7.2.stable + ANDROID_NDK_VERSION: 28.1.13356709 + JOBS: '4' + AETHERKIRI_ENABLE_INTERNAL: 'OFF' + AETHERKIRI_ENABLE_SOFTPAL_RUNTIME: 'OFF' + AETHERKIRI_WITH_INTERNAL: 'false' + AETHERKIRI_VCPKG_CACHE_WRITE: 'false' + AETHERKIRI_ENABLE_RENPY: 'ON' + CCACHE_DIR: ${{ github.workspace }}/.ccache + NATIVE_RUNTIME_RUN: '37787184399' + NATIVE_RUNTIME_ARTIFACT: '11556042880' + NATIVE_RUNTIME_HEAD: '8895c27beef76954bd85cba8b209df9d49a2401f' + NATIVE_RUNTIME_DIGEST: 'sha256:f05b3951f8a8a717258c034894431df36f13b9db0356c82f3285cde344c17729' + steps: + - uses: actions/checkout@v6 + with: + submodules: false + persist-credentials: false + - name: Verify the fixed dependency's actual compilation and provenance + env: + GH_TOKEN: ${{ github.token }} + HOST_PR_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + python3 - <<'PY' + import json, os, pathlib, subprocess, urllib.request + def get(path): + request = urllib.request.Request('https://api.github.com/repos/AetherKiri/Aether/' + path, + headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], + 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}) + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + run_id = os.environ['NATIVE_RUNTIME_RUN'] + run = get('actions/runs/' + run_id) + assert run['status'] == 'completed' and run['conclusion'] == 'success', 'Native source run did not pass' + assert run['head_sha'] == os.environ['NATIVE_RUNTIME_HEAD'], 'Native source revision differs' + artifact = get('actions/artifacts/' + os.environ['NATIVE_RUNTIME_ARTIFACT']) + assert artifact['name'] == 'renpy-android-runtime' and not artifact['expired'], 'Pinned runtime expired; update the reviewed dependency pin' + assert artifact['workflow_run']['id'] == int(run_id), 'Runtime came from another run' + assert artifact['digest'] == os.environ['NATIVE_RUNTIME_DIGEST'], 'Runtime artifact digest differs' + evidence = pathlib.Path(os.environ['RUNNER_TEMP']) / 'renpy-android-app-evidence' + evidence.mkdir() + provenance = { + 'native_source_head': run['head_sha'], 'native_source_run': int(run_id), + 'native_artifact_id': artifact['id'], 'native_artifact_digest': artifact['digest'], + 'host_checkout_head': subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip(), + 'host_pr_head': os.environ['HOST_PR_HEAD'], 'abi': '${{ matrix.abi }}', + 'native_compilation': 'passed', 'gameplay': 'unverified', + 'note': 'Current host packaged with the pinned older native dependency; current-source compilation is checked separately by Build.'} + (evidence / 'provenance.json').write_text(json.dumps(provenance, indent=2) + '\n') + PY + - uses: actions/download-artifact@v6 + with: + artifact-ids: 11556042880 + run-id: 37787184399 + github-token: ${{ github.token }} + merge-multiple: true + path: ${{ runner.temp }}/renpy-runtime-download + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Validate the genuine native and private Python dependency + run: | + set -euo pipefail + root="$RUNNER_TEMP/renpy-runtime" + mkdir -p "$root" + tar -xzf "$RUNNER_TEMP/renpy-runtime-download/renpy-android-runtime.tar.gz" -C "$root" + python3 - "$root/manifest.json" <<'PY' + import json, sys + manifest = json.load(open(sys.argv[1])) + assert manifest['native_compilation'] == 'passed' and manifest['device_gameplay'] == 'unverified' + assert manifest['platform'] == 'android' + assert manifest['build_source_sha'] == '7bfab40c1174f622f644b24669afd5fb167fbb79' + assert manifest['renpy_source_sha'] == '39895c1e017f0b36ffea2447d97eccd69d76ee1c' + assert manifest['greenlet_source_sha'] == '65f8da82b13a1273e55a6bfcbd1f9da09fc4eb7a' + PY + python3 tools/validate_renpy_mobile_payload.py --platform android --abi '${{ matrix.abi }}' \ + --library "$root/payload/rapt/prototype/renpyandroid/src/main/jniLibs/${{ matrix.abi }}/librenpython.so" \ + --private-root "$root/payload/rapt/runtime/private" + cp "$root/manifest.json" "$RUNNER_TEMP/renpy-android-app-evidence/native-manifest.json" + echo "RENPY_MOBILE_ROOT=$root/payload" >> "$GITHUB_ENV" + echo "AETHERKIRI_RENPY_ANDROID_PRIVATE_ASSETS=$root/payload/rapt/runtime/private" >> "$GITHUB_ENV" + - name: Initialize public application packages + run: | + git submodule update --init --depth 1 packages/OnscripterYuri + git submodule update --init --recursive --depth 1 packages/psdfile + git submodule update --init --depth 1 packages/AetherSiglus + git submodule update --init --depth 1 packages/AetherMinori + git submodule update --init --depth 1 packages/AetherKrkr + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,x86_64-linux-android + components: rust-src + - name: Install build tools + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y cmake ninja-build pkg-config autoconf \ + autoconf-archive automake libtool gettext bison flex curl unzip zip zstd ccache jq mono-complete + ccache --max-size=400M + - uses: actions/cache/restore@v6 + with: + path: .ccache + key: ${{ runner.os }}-android-ccache-v2 + restore-keys: ${{ runner.os }}-android-ccache- + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/wrapper-validation@v6 + with: + allow-checksums: 2c23278a62dc9f96ab11bee897d9f53e6479d6419135e4560ce822a6431df5e5 + - uses: gradle/actions/setup-gradle@v6 + with: + validate-wrappers: false + - name: Download the official Godot editor and export templates + run: | + set -euo pipefail + mkdir -p .godot "$RUNNER_TEMP/godot-templates" + curl --fail --location --retry 3 -o "$RUNNER_TEMP/godot.zip" \ + "https://github.com/godotengine/godot-builds/releases/download/${GODOT_RELEASE}/Godot_v${GODOT_RELEASE}_linux.x86_64.zip" + unzip -tq "$RUNNER_TEMP/godot.zip" + unzip -q "$RUNNER_TEMP/godot.zip" -d .godot + godot_bin="$(find .godot -maxdepth 1 -type f -name 'Godot*_linux.x86_64' -print -quit)" + test -n "$godot_bin" + chmod +x "$godot_bin" + curl --fail --location --retry 3 -o "$RUNNER_TEMP/godot-templates.tpz" \ + "https://github.com/godotengine/godot-builds/releases/download/${GODOT_RELEASE}/Godot_v${GODOT_RELEASE}_export_templates.tpz" + unzip -tq "$RUNNER_TEMP/godot-templates.tpz" + unzip -q "$RUNNER_TEMP/godot-templates.tpz" -d "$RUNNER_TEMP/godot-templates" + template_dir="$HOME/.local/share/godot/export_templates/$GODOT_TEMPLATE_VERSION" + mkdir -p "$template_dir" + cp -a "$RUNNER_TEMP/godot-templates/templates/." "$template_dir/" + echo "GODOT_BIN=$GITHUB_WORKSPACE/$godot_bin" >> "$GITHUB_ENV" + echo "GODOT_TEMPLATE_DIR=$template_dir" >> "$GITHUB_ENV" + - name: Use the official Android SDK with existing license acceptance + run: | + set -euo pipefail + sdk_root="${ANDROID_HOME:-/usr/local/lib/android/sdk}" + test -d "$sdk_root" + ndk_root="$sdk_root/ndk/$ANDROID_NDK_VERSION" + if [[ ! -f "$ndk_root/build/cmake/android.toolchain.cmake" ]]; then + "$sdk_root/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$ANDROID_NDK_VERSION" > "$GITHUB_ENV" + echo "ANDROID_NDK_HOME=$ndk_root" >> "$GITHUB_ENV" + - name: Bootstrap the normal public vcpkg dependency cache + env: + VCPKG_PACKAGES_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p .devtools + git clone https://github.com/microsoft/vcpkg.git .devtools/vcpkg + .devtools/vcpkg/bootstrap-vcpkg.sh -disableMetrics + echo "VCPKG_ROOT=$GITHUB_WORKSPACE/.devtools/vcpkg" >> "$GITHUB_ENV" + bash .github/scripts/configure-vcpkg-binary-cache.sh "$GITHUB_WORKSPACE/.devtools/vcpkg/vcpkg" + - name: Create a disposable standard Android Debug signing key + run: | + set -euo pipefail + key="$RUNNER_TEMP/renpy-android-debug.keystore" + keytool -genkeypair -keystore "$key" -storepass android -keypass android \ + -alias androiddebugkey -keyalg RSA -keysize 2048 -validity 365 \ + -dname 'CN=Android Debug,O=Android,C=US' -noprompt + echo "GODOT_ANDROID_KEYSTORE_DEBUG_PATH=$key" >> "$GITHUB_ENV" + echo "GODOT_ANDROID_KEYSTORE_DEBUG_USER=androiddebugkey" >> "$GITHUB_ENV" + echo "GODOT_ANDROID_KEYSTORE_DEBUG_PASSWORD=android" >> "$GITHUB_ENV" + - name: Compile the actual host and export the signed Debug APK + run: | + set -euo pipefail + export CMAKE_C_COMPILER_LAUNCHER=ccache CMAKE_CXX_COMPILER_LAUNCHER=ccache NDK_CCACHE=ccache + bash scripts/build_android.sh debug --abi='${{ matrix.abi }}' 2>&1 \ + | tee "$RUNNER_TEMP/renpy-android-app-evidence/build.log" + - name: Verify the actual APK payload and Debug signature + run: | + set -euo pipefail + apk="out/godot/android/debug/Aether-debug.apk" + if [[ '${{ matrix.abi }}' == x86_64 ]]; then + apk="out/godot/android/x86_64/debug/Aether-emulator-debug.apk" + fi + python3 tools/validate_renpy_mobile_payload.py --platform android --abi '${{ matrix.abi }}' --apk "$apk" + grep -F 'renpy_runtime_mobile_provider.cpp' 'out/android/${{ matrix.abi }}/debug/compile_commands.json' + grep -F 'renpy_mobile_adapter.cpp' 'out/android/${{ matrix.abi }}/debug/compile_commands.json' + apksigner="$(find "$ANDROID_HOME/build-tools" -name apksigner -type f | sort -V | tail -1)" + test -x "$apksigner" + "$apksigner" verify --verbose --print-certs "$apk" \ + | tee "$RUNNER_TEMP/renpy-android-app-evidence/apk-signature.txt" + - name: Upload the validated installable APK + uses: actions/upload-artifact@v6 + with: + name: renpy-installable-debug-${{ matrix.abi }} + path: out/godot/android/**/*.apk + if-no-files-found: error + retention-days: 7 + - name: Preserve real build evidence and any diagnostic APK on failure + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-android-app-evidence-${{ matrix.abi }} + path: | + ${{ runner.temp }}/renpy-android-app-evidence/** + out/godot/android/**/*.apk + out/godot/android/**/diagnostics/** + apps/godot_app/android/build/aether-renpy.gradle + if-no-files-found: warn + retention-days: 7 diff --git a/scripts/build_android.sh b/scripts/build_android.sh index d10d01747..295933a06 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -504,6 +504,10 @@ else [[ -s "$export_path" ]] || { echo "Android export did not create an APK: $export_path" >&2; exit 1; } case "${AETHERKIRI_ENABLE_RENPY:-OFF}" in ON|TRUE|YES|1|on|true|yes) + python3 "$PROJECT_ROOT/tools/inspect_renpy_android_apk.py" \ + --apk "$export_path" --abi "$ABIS" \ + --godot-build "$GODOT_APP_DIR/android/build" \ + --output-dir "$export_dir/diagnostics" python3 "$PROJECT_ROOT/tools/validate_renpy_mobile_payload.py" \ --platform android --abi "$ABIS" --apk "$export_path" ;; diff --git a/tools/inspect_renpy_android_apk.py b/tools/inspect_renpy_android_apk.py new file mode 100644 index 000000000..c683f664a --- /dev/null +++ b/tools/inspect_renpy_android_apk.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Record actual APK contents and compare raw Ren'Py assets/libraries with staging. + +This is packaging evidence, never an installation or gameplay acceptance test. +""" + +import argparse +import hashlib +import json +import re +import zipfile +from pathlib import Path + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open('rb') as stream: + for block in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(block) + return digest.hexdigest() + + +def inspect(apk: Path, build: Path, abi: str, output: Path) -> bool: + output.mkdir(parents=True, exist_ok=True) + result = {'artifact_kind': 'diagnostic_apk', 'gameplay': 'unverified', + 'apk': str(apk), 'abi': abi, 'package_bytes': 'failed', 'files': {}, 'errors': []} + try: + result['apk_sha256'] = sha256(apk) + with zipfile.ZipFile(apk) as archive: + names = archive.namelist() + (output / 'apk-contents.txt').write_text('\n'.join(sorted(names)) + '\n') + if len(names) != len(set(names)): + result['errors'].append('APK contains duplicate ZIP paths') + expected = {} + assets = build / 'renpy_assets' + for source in assets.rglob('*'): + if source.is_file(): + expected['assets/' + source.relative_to(assets).as_posix()] = source + manifest = assets / 'renpy_mobile/manifest.properties' + registrations = [line.split('=', 1)[1] for line in manifest.read_text().splitlines() + if line.startswith('staged_native_files=')] + if len(registrations) != 1 or not registrations[0]: + raise ValueError('Staging manifest must register its Ren\'Py native files exactly once') + for native_file in registrations[0].split(','): + # Only Ren'Py owns this exact set. Unrelated host libraries may + # legitimately change bytes under AGP's normal stripping policy. + if not re.fullmatch(r'(arm64-v8a|x86_64)/lib[A-Za-z0-9_+.-]+\.so', native_file) or '..' in native_file: + raise ValueError(f'Invalid staged native path: {native_file}') + native_abi, name = native_file.split('/') + if native_abi == abi: + expected[f'lib/{abi}/{name}'] = build / 'src/main/jniLibs' / native_file + for required in ('assets/renpy_mobile/private/main.py', f'lib/{abi}/librenpython.so'): + if required not in expected: + result['errors'].append(f'Staging lacks required input: {required}') + for name, source in sorted(expected.items()): + evidence = {'source_sha256': sha256(source), 'source_size': source.stat().st_size} + if name not in names: + result['errors'].append(f'APK lacks staged raw file: {name}') + evidence['match'] = False + else: + digest = hashlib.sha256() + with archive.open(name) as stream: + for block in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(block) + evidence.update(apk_sha256=digest.hexdigest(), apk_size=archive.getinfo(name).file_size) + evidence['match'] = (evidence['source_sha256'] == evidence['apk_sha256'] and + evidence['source_size'] == evidence['apk_size']) + if not evidence['match']: + result['errors'].append(f'APK file differs from staged input: {name}') + result['files'][name] = evidence + except (OSError, zipfile.BadZipFile, RuntimeError, ValueError) as exc: + result['errors'].append(str(exc)) + passed = not result['errors'] + result['package_bytes'] = 'passed' if passed else 'failed' + (output / 'apk-payload-checksums.json').write_text(json.dumps(result, indent=2) + '\n') + print(f"Actual APK raw Ren'Py bytes: {result['package_bytes']}; gameplay unverified. Evidence: {output}") + for error in result['errors'][:10]: + print(error) + return passed + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--apk', required=True, type=Path) + parser.add_argument('--godot-build', required=True, type=Path) + parser.add_argument('--abi', required=True, choices=('arm64-v8a', 'x86_64')) + parser.add_argument('--output-dir', required=True, type=Path) + args = parser.parse_args() + raise SystemExit(0 if inspect(args.apk, args.godot_build, args.abi, args.output_dir) else 1) + + +if __name__ == '__main__': + main() diff --git a/tools/stage_renpy_android_support.sh b/tools/stage_renpy_android_support.sh index 8b1fd4ce1..9b470a991 100644 --- a/tools/stage_renpy_android_support.sh +++ b/tools/stage_renpy_android_support.sh @@ -104,6 +104,10 @@ done echo "Godot Android build template is missing: $godot_build" >&2 exit 1 } +[[ -f "$godot_build/build.gradle" ]] || { + echo "Godot Android Gradle application template is missing: $godot_build/build.gradle" >&2 + exit 1 +} if [[ -n "$private_assets" && ! -e "$private_assets" ]]; then echo "private assets path is missing: $private_assets" >&2 exit 1 @@ -139,7 +143,10 @@ for abi in "${abi_list[@]}"; do done main_src="$godot_build/src/main" -asset_root="$main_src/assets/renpy_mobile" +# Godot recursively clears src/main/assets before every Gradle export. Keep +# the native Python payload in a separate Android asset source directory; +# Gradle merges it into the APK as real AssetManager files, outside game PCKs. +asset_root="$godot_build/renpy_assets/renpy_mobile" asset_rapt="$asset_root/rapt" asset_private="$asset_root/private" private_archive="$asset_rapt/private.mp3" @@ -149,6 +156,14 @@ java_sdl_root="$main_src/java/org/libsdl/app" java_renpy_root="$main_src/java/org/renpy/android" mkdir -p "$asset_rapt" "$asset_private" "$jni_root" "$java_root" +cat > "$godot_build/aether-renpy.gradle" <<'GRADLE' +// Aether's persistent Ren'Py assets survive Godot's exported-game cleanup. +// Keep Godot's normal asset source set and merge these raw files into it. +android.sourceSets.main.assets.srcDir(file('renpy_assets')) +GRADLE +if ! grep -Fxq "apply from: 'aether-renpy.gradle'" "$godot_build/build.gradle"; then + printf "\napply from: 'aether-renpy.gradle'\n" >> "$godot_build/build.gradle" +fi # This source is part of AetherKiri, not an official RAPT Activity. It binds # the existing Godot Activity to engine_api without creating a second owner. @@ -278,6 +293,17 @@ for abi in "${abi_list[@]}"; do done done +# Retain the compiled Ren'Py dependency bytes in the APK. AGP otherwise strips +# them again, obscuring which validated native input the app actually shipped. +# This does not change the host libraries' normal release stripping policy. +{ + printf '\nandroid.packagingOptions.jniLibs.keepDebugSymbols += [\n' + for native_file in "${staged_native_files[@]}"; do + printf " 'lib/%s',\n" "$native_file" + done + printf ']\n' +} >> "$godot_build/aether-renpy.gradle" + # Record the exact staged paths and the archive metadata when available. This # is consumed by package smoke tests and makes an APK inspection auditable. rapt_checksum="" @@ -299,6 +325,7 @@ fi printf 'native_library=%s\n' "$(IFS=','; printf '%s' "${native_paths[*]}")" printf 'staged_native_files=%s\n' "$(IFS=','; printf '%s' "${staged_native_files[*]}")" printf 'private_assets=assets/renpy_mobile/private\n' + printf 'gradle_asset_source=renpy_assets\n' printf 'private_archive=%s\n' "$( [[ -f "$private_archive" ]] && printf 'assets/renpy_mobile/rapt/private.mp3' || true )" printf 'manifest_merged=false\n' printf 'java_host_shims=org/libsdl/app/SDLActivity.java,org/renpy/android/PythonSDLActivity.java\n' diff --git a/tools/test_renpy_android_apk_inspection.py b/tools/test_renpy_android_apk_inspection.py new file mode 100644 index 000000000..a53b34ab6 --- /dev/null +++ b/tools/test_renpy_android_apk_inspection.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Test APK byte diagnostics with ZIP fixtures; no mobile app is executed.""" + +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + +from inspect_renpy_android_apk import inspect + + +class ApkInspectionTests(unittest.TestCase): + def test_stale_native_bytes_and_missing_assets_reject(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + build = root / 'build' + assets = build / 'renpy_assets/renpy_mobile/private' + native = build / 'src/main/jniLibs/x86_64' + assets.mkdir(parents=True) + native.mkdir(parents=True) + (assets / 'main.py').write_text('# ZIP contract fixture, not a runtime\n') + manifest = assets.parent / 'manifest.properties' + manifest.write_text('staged_native_files=x86_64/librenpython.so\n') + (native / 'librenpython.so').write_bytes(b'ZIP fixture, not a native runtime') + apk = root / 'fixture.apk' + with zipfile.ZipFile(apk, 'w') as archive: + archive.write(manifest, 'assets/renpy_mobile/manifest.properties') + archive.writestr('lib/x86_64/librenpython.so', b'stale ZIP fixture') + output = root / 'diagnostics' + self.assertFalse(inspect(apk, build, 'x86_64', output)) + result = json.loads((output / 'apk-payload-checksums.json').read_text()) + self.assertEqual(result['gameplay'], 'unverified') + self.assertEqual(result['package_bytes'], 'failed') + self.assertEqual(len(result['errors']), 2) + self.assertIn('lib/x86_64/librenpython.so', (output / 'apk-contents.txt').read_text()) + + def test_raw_asset_paths_and_identical_bytes_are_recorded(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + build = root / 'build' + assets = build / 'renpy_assets/renpy_mobile/private' + native = build / 'src/main/jniLibs/arm64-v8a' + assets.mkdir(parents=True) + native.mkdir(parents=True) + (assets / 'main.py').write_text('# ZIP contract fixture, not a runtime\n') + manifest = assets.parent / 'manifest.properties' + manifest.write_text('staged_native_files=arm64-v8a/librenpython.so,x86_64/librenpython.so\n') + (native / 'librenpython.so').write_bytes(b'ZIP fixture, not a native runtime') + (native / 'libhost.so').write_bytes(b'Unrelated host fixture before stripping') + apk = root / 'fixture.apk' + with zipfile.ZipFile(apk, 'w') as archive: + archive.write(assets / 'main.py', 'assets/renpy_mobile/private/main.py') + archive.write(manifest, 'assets/renpy_mobile/manifest.properties') + archive.write(native / 'librenpython.so', 'lib/arm64-v8a/librenpython.so') + archive.writestr('lib/arm64-v8a/libhost.so', b'Unrelated host fixture after stripping') + output = root / 'diagnostics' + self.assertTrue(inspect(apk, build, 'arm64-v8a', output)) + result = json.loads((output / 'apk-payload-checksums.json').read_text()) + self.assertEqual(result['gameplay'], 'unverified') + self.assertTrue(all(row['match'] for row in result['files'].values())) + self.assertNotIn('lib/arm64-v8a/libhost.so', result['files']) + self.assertNotIn('lib/x86_64/librenpython.so', result['files']) + + def test_invalid_manifest_native_path_rejects(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + build = root / 'build' + assets = build / 'renpy_assets/renpy_mobile' + assets.mkdir(parents=True) + (assets / 'manifest.properties').write_text('staged_native_files=x86_64/../libhost.so\n') + apk = root / 'fixture.apk' + with zipfile.ZipFile(apk, 'w'): + pass + output = root / 'diagnostics' + self.assertFalse(inspect(apk, build, 'x86_64', output)) + result = json.loads((output / 'apk-payload-checksums.json').read_text()) + self.assertIn('Invalid staged native path:', result['errors'][0]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/test_stage_renpy_android_support.sh b/tools/test_stage_renpy_android_support.sh index 3ddace8f7..d797bf938 100644 --- a/tools/test_stage_renpy_android_support.sh +++ b/tools/test_stage_renpy_android_support.sh @@ -13,6 +13,7 @@ trap 'rm -rf "$abi_work"' EXIT abi_main="$abi_work/mobile/rapt/prototype/renpyandroid/src/main" mkdir -p "$abi_main/jniLibs/x86_64" "$abi_main/java/org/renpy/android" \ "$abi_work/android-build/src/main/jniLibs/x86_64" +printf '// Godot Gradle staging fixture\n' > "$abi_work/android-build/build.gradle" cat > "$abi_work/fixture.c" <<'C' const char aether_contract_fixture[] = "AETHERKIRI_RENPY_LIFECYCLE_STUB"; int renpy_mobile_init(void) { return -3; } @@ -27,9 +28,20 @@ printf 'unrelated host library\n' \ > "$abi_work/android-build/src/main/jniLibs/x86_64/libhost.so" bash "$stage" --allow-unsupported --abis x86_64 \ --mobile-root "$abi_work/mobile" --godot-build "$abi_work/android-build" >/dev/null -abi_manifest="$abi_work/android-build/src/main/assets/renpy_mobile/manifest.properties" +abi_manifest="$abi_work/android-build/renpy_assets/renpy_mobile/manifest.properties" grep -Fx 'native_abis=x86_64' "$abi_manifest" grep -Fx 'native_library=lib/x86_64/librenpython.so' "$abi_manifest" +grep -Fx 'gradle_asset_source=renpy_assets' "$abi_manifest" +grep -Fx "android.sourceSets.main.assets.srcDir(file('renpy_assets'))" \ + "$abi_work/android-build/aether-renpy.gradle" +grep -Fx " 'lib/x86_64/librenpython.so'," "$abi_work/android-build/aether-renpy.gradle" +[[ "$(grep -Fc " 'lib/x86_64/" "$abi_work/android-build/aether-renpy.gradle")" == 2 ]] +! grep -Fq 'libhost.so' "$abi_work/android-build/aether-renpy.gradle" +mkdir -p "$abi_work/android-build/src/main/assets" +printf 'Godot export cleanup sentinel\n' > "$abi_work/android-build/src/main/assets/sentinel" +rm -rf "$abi_work/android-build/src/main/assets" +[[ -s "$abi_manifest" ]] +[[ -s "$abi_work/android-build/renpy_assets/renpy_mobile/rapt/java/org/renpy/android/PythonSDLActivity.java" ]] cmp "$abi_main/jniLibs/x86_64/librenpython.so" \ "$abi_work/android-build/src/main/jniLibs/x86_64/librenpython.so" [[ ! -e "$abi_work/android-build/src/main/jniLibs/arm64-v8a/librenpython.so" ]] @@ -40,6 +52,8 @@ rm "$abi_main/jniLibs/x86_64/liblegacy.so" bash "$stage" --allow-unsupported --abis x86_64 \ --mobile-root "$abi_work/mobile" --godot-build "$abi_work/android-build" >/dev/null [[ ! -e "$abi_work/android-build/src/main/jniLibs/x86_64/liblegacy.so" ]] +[[ "$(grep -Fxc "apply from: 'aether-renpy.gradle'" "$abi_work/android-build/build.gradle")" == 1 ]] +! grep -Fq 'liblegacy.so' "$abi_work/android-build/aether-renpy.gradle" grep -Fx 'unrelated host library' \ "$abi_work/android-build/src/main/jniLibs/x86_64/libhost.so" if bash "$stage" --allow-unsupported --abis mips \ @@ -70,6 +84,7 @@ tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-android-stage.XXXXXX")" conflict_root="$(mktemp -d "${TMPDIR:-/tmp}/aetherkiri-renpy-android-conflict.XXXXXX")" trap 'rm -rf "$tmp_root" "$conflict_root"' EXIT mkdir -p "$tmp_root/android-build/src/main" "$tmp_root/private" +printf '// Godot Gradle staging fixture\n' > "$tmp_root/android-build/build.gradle" printf 'private fixture\n' > "$tmp_root/private/private.mp3" bash "$stage" \ @@ -81,11 +96,11 @@ bash "$stage" \ main="$tmp_root/android-build/src/main" [[ -s "$main/jniLibs/arm64-v8a/librenpython.so" ]] [[ "$(dd if="$main/jniLibs/arm64-v8a/librenpython.so" bs=4 count=1 2>/dev/null | od -An -tx1 | tr -d ' \\n')" == "7f454c46" ]] -[[ -s "$main/assets/renpy_mobile/rapt/java/org/renpy/android/PythonSDLActivity.java" ]] -[[ -s "$main/assets/renpy_mobile/rapt/templates/app-AndroidManifest.xml" ]] -[[ -s "$main/assets/renpy_mobile/rapt/build.gradle" ]] -[[ -s "$main/assets/renpy_mobile/private/private.mp3" ]] -[[ -s "$main/assets/renpy_mobile/rapt/private.mp3" ]] +[[ -s "$tmp_root/android-build/renpy_assets/renpy_mobile/rapt/java/org/renpy/android/PythonSDLActivity.java" ]] +[[ -s "$tmp_root/android-build/renpy_assets/renpy_mobile/rapt/templates/app-AndroidManifest.xml" ]] +[[ -s "$tmp_root/android-build/renpy_assets/renpy_mobile/rapt/build.gradle" ]] +[[ -s "$tmp_root/android-build/renpy_assets/renpy_mobile/private/private.mp3" ]] +[[ -s "$tmp_root/android-build/renpy_assets/renpy_mobile/rapt/private.mp3" ]] # Keep the loader preflight tied to the official payload's real ABI. GNU nm # understands the staged arm64 ELF even when no Android runtime is available. if command -v nm >/dev/null 2>&1; then @@ -125,23 +140,24 @@ for callback in \ grep -Fq "$callback" "$main/java/org/libsdl/app/SDLActivity.java" done grep -Fq 'nativeSetEnv' "$main/java/org/renpy/android/PythonSDLActivity.java" -grep -Fx 'playable=unverified' "$main/assets/renpy_mobile/manifest.properties" -grep -Fx 'inspection_only=true' "$main/assets/renpy_mobile/manifest.properties" -grep -Fx 'native_lifecycle_validated=false' "$main/assets/renpy_mobile/manifest.properties" -grep -Fx 'manifest_merged=false' "$main/assets/renpy_mobile/manifest.properties" +grep -Fx 'playable=unverified' "$tmp_root/android-build/renpy_assets/renpy_mobile/manifest.properties" +grep -Fx 'inspection_only=true' "$tmp_root/android-build/renpy_assets/renpy_mobile/manifest.properties" +grep -Fx 'native_lifecycle_validated=false' "$tmp_root/android-build/renpy_assets/renpy_mobile/manifest.properties" +grep -Fx 'manifest_merged=false' "$tmp_root/android-build/renpy_assets/renpy_mobile/manifest.properties" grep -Fx 'java_host_shims=org/libsdl/app/SDLActivity.java,org/renpy/android/PythonSDLActivity.java' \ - "$main/assets/renpy_mobile/manifest.properties" + "$tmp_root/android-build/renpy_assets/renpy_mobile/manifest.properties" # The RAPT manifest is an asset only; no second Activity is added to the host # source tree. [[ ! -f "$main/AndroidManifest.xml" ]] # The official PythonSDLActivity remains assets-only; the compiled source is # the explicit host shim above. grep -Fq 'AETHERKIRI_HOST_SHIM' "$main/java/org/renpy/android/PythonSDLActivity.java" -grep -Fq 'PythonSDLActivity' "$main/assets/renpy_mobile/rapt/java/org/renpy/android/PythonSDLActivity.java" +grep -Fq 'PythonSDLActivity' "$tmp_root/android-build/renpy_assets/renpy_mobile/rapt/java/org/renpy/android/PythonSDLActivity.java" # A Godot template that already owns either SDL class must fail closed rather # than silently replacing its singleton with the RAPT shim. mkdir -p "$conflict_root/android-build/src/main/java/org/libsdl/app" +printf '// Godot Gradle staging fixture\n' > "$conflict_root/android-build/build.gradle" printf 'package org.libsdl.app; public final class SDLActivity {}\n' \ > "$conflict_root/android-build/src/main/java/org/libsdl/app/SDLActivity.java" if bash "$stage" --allow-unsupported --mobile-root "$mobile_root" \ From 0767f9adecf489ea62ffa1ec4c5d8b0d6438b94a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 15:14:49 +0000 Subject: [PATCH 266/331] test: report LLVM libffi baseline behavior accurately --- tools/test_renpy_mobile_libffi_cfi.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tools/test_renpy_mobile_libffi_cfi.py b/tools/test_renpy_mobile_libffi_cfi.py index 755696fb4..24509f72c 100644 --- a/tools/test_renpy_mobile_libffi_cfi.py +++ b/tools/test_renpy_mobile_libffi_cfi.py @@ -32,6 +32,7 @@ def main(): else: compiler = str(args.clang) if args.clang else shutil.which("clang-18") or shutil.which("clang") assert compiler, "A genuine LLVM clang is required for the Mach-O assembly regression" + print(subprocess.check_output([compiler, "--version"], text=True).splitlines()[0]) host_cc = shutil.which("cc") assert host_cc with tempfile.TemporaryDirectory(prefix="renpy-libffi-cfi-") as temporary: @@ -56,6 +57,7 @@ def main(): cwd=source_root, env=environment, capture_output=True, text=True) assert configured.returncode == 0, configured.stdout + configured.stderr configuration = next(source_root.glob("*/fficonfig.h")) + assert "#define HAVE_AS_CFI_PSEUDO_OP 1" in configuration.read_text(), "Configured assembler CFI support is required" if sys.platform != "darwin": # This test isolates CFI, not the Apple-specific closure page table. # Production configuration and source compilation retain that table. @@ -66,13 +68,18 @@ def main(): "-I" + str(configuration.parent / "include"), "-Iinclude", "-Isrc"] targets = (("arm64-apple-ios13.0", "iphoneos"), ("arm64-apple-ios13.0-simulator", "iphonesimulator")) + rejected = 0 for target, sdk in targets: sdk_args = [] if sys.platform == "darwin": sdk_args = ["-isysroot", subprocess.check_output(["xcrun", "--sdk", sdk, "--show-sdk-path"], text=True).strip()] command = [compiler, "-target", target, *sdk_args, *includes, "-c", "src/aarch64/sysv.S", "-o", target + ".o"] result = subprocess.run(command, cwd=source_root, capture_output=True, text=True) - assert result.returncode != 0 and "invalid CFI advance_loc expression" in result.stderr, result.stderr + if result.returncode != 0: + assert "invalid CFI advance_loc expression" in result.stderr, result.stderr + rejected += 1 + else: + assert sys.platform != "darwin", "Selected Xcode did not reproduce the observed original CFI error" # Execute the genuine unpack function through its real Context, so a # regression in the task's patch wiring also fails this check. sys.path.insert(0, str(root)) @@ -93,8 +100,14 @@ def main(): command = [compiler, "-target", target, *sdk_args, *includes, "-c", "src/aarch64/sysv.S", "-o", target + ".o"] result = subprocess.run(command, cwd=source_root, capture_output=True, text=True) assert result.returncode == 0, result.stderr - assert (source_root / (target + ".o")).read_bytes()[:4] == bytes.fromhex("cffaedfe") - print("Real LLVM assembly reproduced the pinned CFI failure and compiled patched arm64 device/Simulator Mach-O objects") + output = (source_root / (target + ".o")).read_bytes() + assert output[:4] == bytes.fromhex("cffaedfe") + assert b"__eh_frame" in output and b"__compact_unwind" in output, "Patched assembly lost its unwind metadata" + if rejected == len(targets): + print("Original assembly reproduced the observed CFI rejection for both targets") + else: + print(f"Original assembly was accepted for {len(targets) - rejected}/{len(targets)} targets; negative-control rejection was not reproduced there") + print("Patched arm64 device/Simulator Mach-O assembly objects compiled with real unwind metadata") if sys.platform != "darwin": print("Linux check excluded the Apple closure page table; full Xcode runtime compilation and gameplay are not covered") From 99a85c09fda7c402dccf5a6a4716ff5e892cde52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 15:21:07 +0000 Subject: [PATCH 267/331] fix: make cached libgdiplus configuration relocatable --- .github/workflows/build.yml | 2 +- .github/workflows/renpy-android-app.yml | 9 +- tools/test_libgdiplus_cmake_relocation.py | 122 ++++++++++++++++++++++ vcpkg/ports/libgdiplus/portfile.cmake | 6 +- vcpkg/ports/libgdiplus/vcpkg.json | 2 +- 5 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 tools/test_libgdiplus_cmake_relocation.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 720fa2715..316a7b75b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -161,7 +161,7 @@ jobs: - name: Install Build Systems run: | sudo apt-get update - sudo apt-get install -y cmake ninja-build pkg-config autoconf autoconf-archive automake libtool gettext bison flex curl unzip zip zstd ccache jq mono-complete + sudo apt-get install -y cmake ninja-build pkg-config autoconf autoconf-archive automake libtool gettext bison flex nasm curl unzip zip zstd ccache jq mono-complete rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android - name: Use Python matching the rebuilt runtime's bytecode diff --git a/.github/workflows/renpy-android-app.yml b/.github/workflows/renpy-android-app.yml index 1ccc15793..b3ad79e64 100644 --- a/.github/workflows/renpy-android-app.yml +++ b/.github/workflows/renpy-android-app.yml @@ -12,6 +12,8 @@ on: - 'tools/stage_renpy_android_support.sh' - 'tools/inspect_renpy_android_apk.py' - 'bridge/renpy_runtime/android/**' + - 'vcpkg/ports/libgdiplus/**' + - 'tools/test_libgdiplus_cmake_relocation.py' workflow_dispatch: permissions: @@ -130,7 +132,7 @@ jobs: run: | sudo apt-get update sudo apt-get install --no-install-recommends -y cmake ninja-build pkg-config autoconf \ - autoconf-archive automake libtool gettext bison flex curl unzip zip zstd ccache jq mono-complete + autoconf-archive automake libtool gettext bison flex nasm curl unzip zip zstd ccache jq mono-complete ccache --max-size=400M - uses: actions/cache/restore@v6 with: @@ -189,6 +191,11 @@ jobs: .devtools/vcpkg/bootstrap-vcpkg.sh -disableMetrics echo "VCPKG_ROOT=$GITHUB_WORKSPACE/.devtools/vcpkg" >> "$GITHUB_ENV" bash .github/scripts/configure-vcpkg-binary-cache.sh "$GITHUB_WORKSPACE/.devtools/vcpkg/vcpkg" + - name: Verify package configuration survives a different build directory + run: | + set -euo pipefail + python3 tools/test_libgdiplus_cmake_relocation.py --vcpkg-root .devtools/vcpkg \ + | tee "$RUNNER_TEMP/renpy-android-app-evidence/libgdiplus-relocation.log" - name: Create a disposable standard Android Debug signing key run: | set -euo pipefail diff --git a/tools/test_libgdiplus_cmake_relocation.py b/tools/test_libgdiplus_cmake_relocation.py new file mode 100644 index 000000000..115e7fe53 --- /dev/null +++ b/tools/test_libgdiplus_cmake_relocation.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Exercise libgdiplus's real CMake export section with a compiled tiny fixture. + +This tests package relocation and dependency metadata, not the libgdiplus +implementation, an Android build, or mobile gameplay. Supply an ordinary +vcpkg checkout; this test does not download tools or alter its cache. +""" + +import argparse +import shutil +import subprocess +import tempfile +from pathlib import Path + + +def run(*command: str, succeeds: bool = True) -> str: + result = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if (result.returncode == 0) != succeeds: + raise RuntimeError(f'{command}: exit {result.returncode}\n{result.stdout}') + return result.stdout + + +def check(cmake: str, vcpkg: Path) -> None: + repo = Path(__file__).resolve().parents[1] + port = repo / 'vcpkg/ports/libgdiplus' + export = (port / 'CMakeLists.txt').read_text().split('# 合并所有依赖\n', 1)[1] + recipe = (port / 'portfile.cmake').read_text() + assert recipe.index('vcpkg_cmake_install()') < recipe.index('vcpkg_cmake_config_fixup()') < recipe.index('"${CURRENT_PACKAGES_DIR}/debug/share"') + assert 'VCPKG_POLICY_SKIP_ABSOLUTE_PATHS_CHECK' not in recipe + helper_paths = ['scripts/cmake/z_vcpkg_function_arguments.cmake', 'scripts/cmake/vcpkg_list.cmake', + 'ports/vcpkg-cmake-config/vcpkg_cmake_config_fixup.cmake'] + for path in helper_paths: + if not (vcpkg / path).is_file(): + raise FileNotFoundError(vcpkg / path) + with tempfile.TemporaryDirectory(prefix='aether-libgdiplus-relocation-') as directory: + root = Path(directory) + original = root / 'old-release-triplet' + packages = root / 'package' + source = root / 'source' + source.mkdir() + headers = original / 'include/dependency' + headers.mkdir(parents=True) + (headers / 'fixture-dependency.h').write_text('int dependency_fixture(void);\n') + (source / 'dependency.c').write_text('int dependency_fixture(void) { return 41; }\n') + (source / 'gdiplus.c').write_text('#include "fixture-dependency.h"\nint gdiplus_fixture(void) { return dependency_fixture() + 1; }\n') + (source / 'src').mkdir() + (source / 'src/gdiplus-fixture.h').write_text('int gdiplus_fixture(void);\n') + (source / 'config.h.in').write_text('/* CMake export fixture */\n') + shutil.copyfile(port / 'libgdiplus-config.cmake.in', source / 'libgdiplus-config.cmake.in') + (source / 'CMakeLists.txt').write_text('''cmake_minimum_required(VERSION 3.28) +project(libgdiplus VERSION 5.6.1 LANGUAGES C) +add_library(dependency STATIC dependency.c) +set_target_properties(dependency PROPERTIES OUTPUT_NAME dependency_fixture) +install(TARGETS dependency ARCHIVE DESTINATION lib) +add_library(libgdiplus STATIC gdiplus.c) +set(GLIB_INCLUDE_DIRS "${DEPENDENCY_PREFIX}/include/dependency") +set(GLIB_LIBRARY_DIRS "${DEPENDENCY_PREFIX}/${DEPENDENCY_LIBDIR}") +set(GDIPLUS_LIBS "${DEPENDENCY_PREFIX}/${DEPENDENCY_LIBDIR}/libdependency_fixture.a") +''' + export) + # Build both real native archives and export exactly the production + # port's target/interface/install section, including absolute deps. + for configuration, suffix in [('Release', ''), ('Debug', 'debug')]: + build = root / ('producer-' + configuration) + run(cmake, '-S', str(source), '-B', str(build), '-DCMAKE_BUILD_TYPE=' + configuration, + '-DCMAKE_INSTALL_PREFIX=' + str(packages / suffix), '-DDEPENDENCY_PREFIX=' + str(original), + '-DDEPENDENCY_LIBDIR=' + ('debug/lib' if suffix else 'lib')) + run(cmake, '--build', str(build)) + run(cmake, '--install', str(build)) + destination = original / suffix / 'lib' + destination.mkdir(parents=True) + shutil.copyfile(packages / suffix / 'lib/libdependency_fixture.a', destination / 'libdependency_fixture.a') + shutil.copytree(original / 'include/dependency', packages / 'include/dependency') + # The config's TIFF dependency must remain resolvable after moving. + # TIFF is an interface fixture here; the linked dependency above is a + # real archive and exercises Debug/Release path merging. + tiff = packages / 'share/TIFF' + tiff.mkdir(parents=True) + (tiff / 'TIFFConfig.cmake').write_text('set(TIFF_FOUND TRUE)\nif(NOT TARGET TIFF::TIFF)\nadd_library(TIFF::TIFF INTERFACE IMPORTED)\nendif()\n') + consumer = root / 'consumer' + consumer.mkdir() + (consumer / 'CMakeLists.txt').write_text('''cmake_minimum_required(VERSION 3.28) +project(consumer LANGUAGES C) +set(CMAKE_FIND_PACKAGE_PREFER_CONFIG ON) +find_package(libgdiplus CONFIG REQUIRED) +add_executable(consumer main.c) +target_link_libraries(consumer PRIVATE libgdiplus::libgdiplus) +''') + (consumer / 'main.c').write_text('#include \nint main(void) { return gdiplus_fixture() != 42; }\n') + shutil.rmtree(original) + broken = run(cmake, '-S', str(consumer), '-B', str(root / 'consumer-stale'), + '-DCMAKE_PREFIX_PATH=' + str(packages), succeeds=False) + assert 'includes non-existent path' in broken and str(original) in broken, broken + fixup = root / 'fixup.cmake' + fixup.write_text('cmake_minimum_required(VERSION 3.28)\n' + + '\n'.join(f'include("{vcpkg / path}")' for path in helper_paths) + + f'\nset(PORT libgdiplus)\nset(CURRENT_INSTALLED_DIR "{original}")\n' + f'set(CURRENT_PACKAGES_DIR "{packages}")\nvcpkg_cmake_config_fixup()\n') + run(cmake, '-P', str(fixup)) + for metadata in (packages / 'share/libgdiplus').glob('*.cmake'): + contents = metadata.read_text() + assert str(original) not in contents and str(packages) not in contents, metadata + relocated = root / 'new-debug-triplet' + shutil.move(packages, relocated) + for configuration in ['Release', 'Debug']: + build = root / ('consumer-' + configuration) + run(cmake, '-S', str(consumer), '-B', str(build), '-DCMAKE_BUILD_TYPE=' + configuration, + '-DCMAKE_PREFIX_PATH=' + str(relocated)) + run(cmake, '--build', str(build)) + run(str(build / 'consumer')) + shutil.rmtree(relocated / 'include/dependency') + missing = run(cmake, '-S', str(consumer), '-B', str(root / 'consumer-missing'), + '-DCMAKE_PREFIX_PATH=' + str(relocated), succeeds=False) + assert 'includes non-existent path' in missing and str(relocated / 'include/dependency') in missing, missing + print('Actual CMake export/install/relocation and Release+Debug consumer compile/link/run passed; stale and missing include prefixes rejected. Compiled package fixture only; mobile gameplay unverified.') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--cmake', default='cmake') + parser.add_argument('--vcpkg-root', type=Path, required=True) + arguments = parser.parse_args() + check(arguments.cmake, arguments.vcpkg_root.resolve()) diff --git a/vcpkg/ports/libgdiplus/portfile.cmake b/vcpkg/ports/libgdiplus/portfile.cmake index 117540a84..27de13e9d 100644 --- a/vcpkg/ports/libgdiplus/portfile.cmake +++ b/vcpkg/ports/libgdiplus/portfile.cmake @@ -1,5 +1,3 @@ -set(VCPKG_POLICY_SKIP_ABSOLUTE_PATHS_CHECK enabled) - vcpkg_from_github( OUT_SOURCE_PATH SOURCE_PATH REPO mono/libgdiplus @@ -29,6 +27,10 @@ endif() vcpkg_cmake_configure(SOURCE_PATH "${SOURCE_PATH}" OPTIONS ${OPTIONS}) vcpkg_cmake_install() +# Public dependency include/link directories originate in the build triplet. +# Rewrite them relative to the installed package and merge Debug/Release +# targets before caching; a package must survive a new consumer build path. +vcpkg_cmake_config_fixup() vcpkg_fixup_pkgconfig() vcpkg_copy_pdbs() diff --git a/vcpkg/ports/libgdiplus/vcpkg.json b/vcpkg/ports/libgdiplus/vcpkg.json index 7f55fde34..0acbd0b24 100644 --- a/vcpkg/ports/libgdiplus/vcpkg.json +++ b/vcpkg/ports/libgdiplus/vcpkg.json @@ -1,7 +1,7 @@ { "name": "libgdiplus", "version-string": "5.6.1", - "port-version": 10, + "port-version": 11, "description": "Libgdiplus is the Mono library that provides a GDI+-compatible API on non-Windows operating systems. Our implementation uses Cairo to do most of the heavy lifting.", "homepage": "https://www.mono-project.com/docs/gui/libgdiplus/", "license": null, From 6263c368014991f0997c294969df2751ba1e0d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 15:46:26 +0000 Subject: [PATCH 268/331] fix(renpy): keep MetalANGLE framework flags in target linker settings --- .github/workflows/renpy-ios-runtime-build.yml | 3 + .github/workflows/renpy-mobile-contract.yml | 3 + .../native/0007-darwin-ios-build.patch | 16 ++++ .../mobile_launcher/patches/native/README.md | 3 + tools/test_renpy_mobile_darwin_build.py | 1 + tools/test_renpy_mobile_libffi_cfi.py | 1 + tools/test_renpy_mobile_metalangle_flags.py | 84 +++++++++++++++++++ 7 files changed, 111 insertions(+) create mode 100644 tools/test_renpy_mobile_metalangle_flags.py diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index 0f01a9568..1a84f25fe 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -114,6 +114,9 @@ jobs: uv --project "$RENPY_BUILD_ROOT/renpy" run python \ "$GITHUB_WORKSPACE/tools/test_renpy_mobile_libffi_cfi.py" "$RENPY_BUILD_ROOT" \ 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" + uv --project "$RENPY_BUILD_ROOT/renpy" run python \ + "$GITHUB_WORKSPACE/tools/test_renpy_mobile_metalangle_flags.py" "$RENPY_BUILD_ROOT" \ + 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" - name: Compile target CPython, SDL, Ren'Py and device/Simulator archives if: steps.runtime-cache.outputs.cache-hit != 'true' run: | diff --git a/.github/workflows/renpy-mobile-contract.yml b/.github/workflows/renpy-mobile-contract.yml index 50ade8bf0..def3d5be8 100644 --- a/.github/workflows/renpy-mobile-contract.yml +++ b/.github/workflows/renpy-mobile-contract.yml @@ -24,6 +24,7 @@ on: - 'tools/test_renpy_mobile_build_parallelism.py' - 'tools/test_renpy_mobile_static_api.py' - 'tools/test_renpy_mobile_libffi_cfi.py' + - 'tools/test_renpy_mobile_metalangle_flags.py' - 'tools/validate_renpy_mobile_payload.py' - 'tools/test_renpy_mobile_packaging.sh' - 'tools/test_renpy_mobile_runtime_protocol.sh' @@ -108,6 +109,8 @@ jobs: tools/test_renpy_mobile_patch_series.py _renpy-build-source "${RUNNER_TEMP}/cooperative-test/bin/python" \ tools/test_renpy_mobile_libffi_cfi.py _renpy-build-source --clang /usr/bin/clang-18 + "${RUNNER_TEMP}/cooperative-test/bin/python" \ + tools/test_renpy_mobile_metalangle_flags.py _renpy-build-source --clang /usr/bin/clang-18 - name: Generate the static API and compile its genuine C consumers run: | diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch index 1f5dc3f33..8b9c1f7a3 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch @@ -379,3 +379,19 @@ new file mode 100644 + BTI_C + stp x29, x30, [sp, #-ffi_closure_SYSV_FS]! + cfi_adjust_cfa_offset (ffi_closure_SYSV_FS) +diff --git a/tasks/metalangle.py b/tasks/metalangle.py +--- a/tasks/metalangle.py ++++ b/tasks/metalangle.py +@@ -7,9 +7,10 @@ + if c.platform == "ios": + # EGL/GLES use normal includes, not framework-prefixed ones. + # SDL's configure must find these same headers before it enables EGL. +- c.env("CFLAGS", "{{ CFLAGS }} -I {{install}}/MetalANGLE.framework/Headers -F {{install}} -framework MetalANGLE -DMETALANGLE") +- c.env("CXXFLAGS", "{{ CXXFLAGS }} -I {{install}}/MetalANGLE.framework/Headers -F {{install}} -framework MetalANGLE -DMETALANGLE") ++ c.env("CFLAGS", "{{ CFLAGS }} -I {{install}}/MetalANGLE.framework/Headers -F {{install}} -DMETALANGLE") ++ c.env("CXXFLAGS", "{{ CXXFLAGS }} -I {{install}}/MetalANGLE.framework/Headers -F {{install}} -DMETALANGLE") + c.env("CPPFLAGS", "{{ CPPFLAGS }} -I {{install}}/MetalANGLE.framework/Headers -DMETALANGLE") ++ c.env("LDFLAGS", "{{ LDFLAGS }} -F {{install}} -framework MetalANGLE") + + @task(kind="python", platforms="ios") + def install(c: Context): diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index 2ee680537..40a565704 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -44,6 +44,9 @@ to the pinned 3.4.5 source archive. Three arm64 entry labels precede `cfi_startproc`, as required by LLVM 17 and later when assembling Mach-O. Unwind metadata remains enabled. The unpack task applies this patch before any target compilation; it does not replace the pinned libffi dependency. +The MetalANGLE annotator keeps its header paths and definitions in compiler +flags and supplies `-framework MetalANGLE` through linker flags. Compile-only +dependency builds retain their strict warning checks. `0009-bounded-build-parallelism.patch` limits the upstream native object build group to the host CPU count. Set `RENPY_BUILD_JOBS` to a positive integer to diff --git a/tools/test_renpy_mobile_darwin_build.py b/tools/test_renpy_mobile_darwin_build.py index c6e421c45..9cc1d2c2d 100644 --- a/tools/test_renpy_mobile_darwin_build.py +++ b/tools/test_renpy_mobile_darwin_build.py @@ -83,6 +83,7 @@ def main(): source.extractall(root, filter="data") assert archive.wait() == 0 build_patch = REPO / "bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch" + subprocess.run(["git", "apply", "--include=tasks/metalangle.py", str(REPO / "bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch")], cwd=root, check=True) subprocess.run(["git", "apply", "--check", str(build_patch)], cwd=root, check=True) subprocess.run(["git", "apply", str(build_patch)], cwd=root, check=True) sys.path.insert(0, str(root)) diff --git a/tools/test_renpy_mobile_libffi_cfi.py b/tools/test_renpy_mobile_libffi_cfi.py index 24509f72c..8515f64a7 100644 --- a/tools/test_renpy_mobile_libffi_cfi.py +++ b/tools/test_renpy_mobile_libffi_cfi.py @@ -46,6 +46,7 @@ def main(): with tarfile.open(fileobj=archive.stdout, mode="r|*") as source: source.extractall(root, filter="data") assert archive.wait() == 0 + subprocess.run(["git", "apply", "--include=tasks/metalangle.py", str(REPO / "bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch")], cwd=root, check=True) subprocess.run(["git", "apply", str(REPO / "bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch")], cwd=root, check=True) with tarfile.open(root / "source/libffi-3.4.5.tar.gz") as source: source.extractall(root, filter="data") diff --git a/tools/test_renpy_mobile_metalangle_flags.py b/tools/test_renpy_mobile_metalangle_flags.py new file mode 100644 index 000000000..69cc3c818 --- /dev/null +++ b/tools/test_renpy_mobile_metalangle_flags.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Exercise the genuine MetalANGLE annotator and LLVM compiler/linker flag split. + +The compiled probe uses no SDK headers or framework functions. It checks the +actual clang driver with -Werror, not full framework linking or gameplay. +""" +import argparse +import ast +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tarfile +import tempfile +from types import SimpleNamespace + +REPO = Path(__file__).resolve().parents[1] +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" +PATCHES = REPO / "bridge/renpy_runtime/mobile_launcher/patches/native" + + +def annotator(path, Context): + tree = ast.parse(path.read_text()) + selected = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "annotate") + selected.decorator_list = [] + namespace = {"Context": Context} + exec(compile(ast.Module(body=[selected], type_ignores=[]), str(path), "exec"), namespace) + return namespace["annotate"] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build_source", type=Path) + parser.add_argument("--clang", type=Path) + args = parser.parse_args() + assert subprocess.check_output(["git", "-C", str(args.build_source), "rev-parse", "HEAD"], text=True).strip() == PIN + compiler = str(args.clang) if args.clang else shutil.which("clang-18") or shutil.which("clang") + assert compiler, "A genuine LLVM clang is required" + with tempfile.TemporaryDirectory(prefix="renpy-metalangle-flags-") as temporary: + root = Path(temporary) + archive = subprocess.Popen([ + "git", "-C", str(args.build_source), "archive", "HEAD", "renpybuild", "tasks", + "source/Python-3.12.8-Setup.stdlib", "tools/cmake_build_variables.cmake", + ], stdout=subprocess.PIPE) + assert archive.stdout is not None + with tarfile.open(fileobj=archive.stdout, mode="r|*") as source: + source.extractall(root, filter="data") + assert archive.wait() == 0 + subprocess.run(["git", "apply", "--include=tasks/metalangle.py", str(PATCHES / "0006-ios-offscreen-renderer.patch")], cwd=root, check=True) + original = root / "original-metalangle.py" + original.write_bytes((root / "tasks/metalangle.py").read_bytes()) + subprocess.run(["git", "apply", str(PATCHES / "0007-darwin-ios-build.patch")], cwd=root, check=True) + sys.path.insert(0, str(root)) + from renpybuild.context import Context + source = root / "probe.c" + source.write_text("#ifndef METALANGLE\n#error MetalANGLE compile definition was lost\n#endif\nint renpy_compile_probe(void) { return 1; }\n") + for architecture, target in (("arm64", "arm64-apple-ios13.0"), ("sim-arm64", "arm64-apple-ios13.0-simulator")): + for version, expect_success in ((original, False), (root / "tasks/metalangle.py", True)): + context = Context("ios", architecture, "3", root, SimpleNamespace()) + context.set_names("arch", "build", "libavif") + annotator(version, Context)(context) + assert "MetalANGLE.framework/Headers" in context.environ["CPPFLAGS"] + for language, variable in (("c", "CFLAGS"), ("c++", "CXXFLAGS")): + flags = shlex.split(context.environ[variable]) + if expect_success: + assert "-framework" not in flags + assert "-F" in flags and "-DMETALANGLE" in flags + assert "MetalANGLE.framework/Headers" in context.environ[variable] + assert "-framework MetalANGLE" in context.environ["LDFLAGS"] + output = root / (architecture + language + ".o") + result = subprocess.run([compiler, "-target", target, "-x", language, "-Werror", *flags, + "-c", str(source), "-o", str(output)], capture_output=True, text=True) + if expect_success: + assert result.returncode == 0, result.stderr + assert output.read_bytes()[:4] == bytes.fromhex("cffaedfe") + else: + assert result.returncode != 0 and "linker" in result.stderr and "unused" in result.stderr, result.stderr + print("Actual LLVM -Werror reproduced framework flags in compile-only failures and compiled patched C/C++ device/Simulator objects") + print("MetalANGLE header/compile definitions and framework linker flags retained; full target linking and gameplay not covered") + + +if __name__ == "__main__": + main() From 1e2484e5780cb2045eea806c166c225806d18d38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 15:47:09 +0000 Subject: [PATCH 269/331] fix(android): export relocated Debug libgdiplus link directories --- tools/test_libgdiplus_cmake_relocation.py | 24 +++++++++++++++++++++-- vcpkg/ports/libgdiplus/CMakeLists.txt | 10 ++++++---- vcpkg/ports/libgdiplus/vcpkg.json | 2 +- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/tools/test_libgdiplus_cmake_relocation.py b/tools/test_libgdiplus_cmake_relocation.py index 115e7fe53..9dd494f04 100644 --- a/tools/test_libgdiplus_cmake_relocation.py +++ b/tools/test_libgdiplus_cmake_relocation.py @@ -42,7 +42,8 @@ def check(cmake: str, vcpkg: Path) -> None: headers.mkdir(parents=True) (headers / 'fixture-dependency.h').write_text('int dependency_fixture(void);\n') (source / 'dependency.c').write_text('int dependency_fixture(void) { return 41; }\n') - (source / 'gdiplus.c').write_text('#include "fixture-dependency.h"\nint gdiplus_fixture(void) { return dependency_fixture() + 1; }\n') + (source / 'named.c').write_text('int named_fixture(void) { return CONFIG_VALUE; }\n') + (source / 'gdiplus.c').write_text('#include "fixture-dependency.h"\nint named_fixture(void);\nint gdiplus_fixture(void) { return dependency_fixture() + named_fixture() + 1; }\n') (source / 'src').mkdir() (source / 'src/gdiplus-fixture.h').write_text('int gdiplus_fixture(void);\n') (source / 'config.h.in').write_text('/* CMake export fixture */\n') @@ -52,9 +53,18 @@ def check(cmake: str, vcpkg: Path) -> None: add_library(dependency STATIC dependency.c) set_target_properties(dependency PROPERTIES OUTPUT_NAME dependency_fixture) install(TARGETS dependency ARCHIVE DESTINATION lib) +add_library(named_dependency STATIC named.c) +set_target_properties(named_dependency PROPERTIES OUTPUT_NAME named_dependency_fixture DEBUG_POSTFIX d) +target_compile_definitions(named_dependency PRIVATE "CONFIG_VALUE=$,7,13>") +install(TARGETS named_dependency ARCHIVE DESTINATION lib) add_library(libgdiplus STATIC gdiplus.c) set(GLIB_INCLUDE_DIRS "${DEPENDENCY_PREFIX}/include/dependency") set(GLIB_LIBRARY_DIRS "${DEPENDENCY_PREFIX}/${DEPENDENCY_LIBDIR}") +if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(GLIB_LIBRARIES named_dependency_fixtured) +else() + set(GLIB_LIBRARIES named_dependency_fixture) +endif() set(GDIPLUS_LIBS "${DEPENDENCY_PREFIX}/${DEPENDENCY_LIBDIR}/libdependency_fixture.a") ''' + export) # Build both real native archives and export exactly the production @@ -69,6 +79,8 @@ def check(cmake: str, vcpkg: Path) -> None: destination = original / suffix / 'lib' destination.mkdir(parents=True) shutil.copyfile(packages / suffix / 'lib/libdependency_fixture.a', destination / 'libdependency_fixture.a') + named_archive = 'libnamed_dependency_fixture' + ('d' if suffix else '') + '.a' + shutil.copyfile(packages / suffix / 'lib' / named_archive, destination / named_archive) shutil.copytree(original / 'include/dependency', packages / 'include/dependency') # The config's TIFF dependency must remain resolvable after moving. # TIFF is an interface fixture here; the linked dependency above is a @@ -85,7 +97,15 @@ def check(cmake: str, vcpkg: Path) -> None: add_executable(consumer main.c) target_link_libraries(consumer PRIVATE libgdiplus::libgdiplus) ''') - (consumer / 'main.c').write_text('#include \nint main(void) { return gdiplus_fixture() != 42; }\n') + (consumer / 'main.c').write_text('''#include +int main(void) { +#ifdef NDEBUG + return gdiplus_fixture() != 55; +#else + return gdiplus_fixture() != 49; +#endif +} +''') shutil.rmtree(original) broken = run(cmake, '-S', str(consumer), '-B', str(root / 'consumer-stale'), '-DCMAKE_PREFIX_PATH=' + str(packages), succeeds=False) diff --git a/vcpkg/ports/libgdiplus/CMakeLists.txt b/vcpkg/ports/libgdiplus/CMakeLists.txt index ebcb58895..c51bea6cb 100644 --- a/vcpkg/ports/libgdiplus/CMakeLists.txt +++ b/vcpkg/ports/libgdiplus/CMakeLists.txt @@ -213,10 +213,12 @@ target_link_libraries(${PROJECT_NAME} PUBLIC ) target_link_directories(${PROJECT_NAME} PUBLIC - ${GLIB_LIBRARY_DIRS} - ${CAIRO_LIBRARY_DIRS} - ${FONTCONFIG_LIBRARY_DIRS} - ${FREETYPE_LIBRARY_DIRS} + "$" + # pkg-config exports bare library names, including Debug postfixes. The + # installed target must search the consumer's Debug directory first; + # vcpkg's config fixup merges link libraries, but not link directories. + "$:$/debug/lib>>" + "$/lib>" ) target_include_directories(${PROJECT_NAME} diff --git a/vcpkg/ports/libgdiplus/vcpkg.json b/vcpkg/ports/libgdiplus/vcpkg.json index 0acbd0b24..08f863b30 100644 --- a/vcpkg/ports/libgdiplus/vcpkg.json +++ b/vcpkg/ports/libgdiplus/vcpkg.json @@ -1,7 +1,7 @@ { "name": "libgdiplus", "version-string": "5.6.1", - "port-version": 11, + "port-version": 12, "description": "Libgdiplus is the Mono library that provides a GDI+-compatible API on non-Windows operating systems. Our implementation uses Cairo to do most of the heavy lifting.", "homepage": "https://www.mono-project.com/docs/gui/libgdiplus/", "license": null, From 4e527e00397df802b57a3dfa66ca2f7c02a17c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 15:52:28 +0000 Subject: [PATCH 270/331] test(renpy): select actual Xcode SDKs for MetalANGLE compile regression --- tools/test_renpy_mobile_metalangle_flags.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tools/test_renpy_mobile_metalangle_flags.py b/tools/test_renpy_mobile_metalangle_flags.py index 69cc3c818..8b1543c69 100644 --- a/tools/test_renpy_mobile_metalangle_flags.py +++ b/tools/test_renpy_mobile_metalangle_flags.py @@ -35,8 +35,14 @@ def main(): parser.add_argument("--clang", type=Path) args = parser.parse_args() assert subprocess.check_output(["git", "-C", str(args.build_source), "rev-parse", "HEAD"], text=True).strip() == PIN - compiler = str(args.clang) if args.clang else shutil.which("clang-18") or shutil.which("clang") + if args.clang: + compiler = str(args.clang) + elif sys.platform == "darwin": + compiler = subprocess.check_output(["xcrun", "--find", "clang"], text=True).strip() + else: + compiler = shutil.which("clang-18") or shutil.which("clang") assert compiler, "A genuine LLVM clang is required" + print(subprocess.check_output([compiler, "--version"], text=True).splitlines()[0], flush=True) with tempfile.TemporaryDirectory(prefix="renpy-metalangle-flags-") as temporary: root = Path(temporary) archive = subprocess.Popen([ @@ -56,6 +62,13 @@ def main(): source = root / "probe.c" source.write_text("#ifndef METALANGLE\n#error MetalANGLE compile definition was lost\n#endif\nint renpy_compile_probe(void) { return 1; }\n") for architecture, target in (("arm64", "arm64-apple-ios13.0"), ("sim-arm64", "arm64-apple-ios13.0-simulator")): + sdk_flags = [] + if sys.platform == "darwin": + sdk = "iphoneos" if architecture == "arm64" else "iphonesimulator" + sdk_root = subprocess.check_output(["xcrun", "--sdk", sdk, "--show-sdk-path"], text=True).strip() + assert Path(sdk_root).is_dir(), sdk_root + sdk_flags = ["-isysroot", sdk_root] + print(f"Actual {target} SDK: {sdk_root}", flush=True) for version, expect_success in ((original, False), (root / "tasks/metalangle.py", True)): context = Context("ios", architecture, "3", root, SimpleNamespace()) context.set_names("arch", "build", "libavif") @@ -69,7 +82,7 @@ def main(): assert "MetalANGLE.framework/Headers" in context.environ[variable] assert "-framework MetalANGLE" in context.environ["LDFLAGS"] output = root / (architecture + language + ".o") - result = subprocess.run([compiler, "-target", target, "-x", language, "-Werror", *flags, + result = subprocess.run([compiler, "-target", target, *sdk_flags, "-x", language, "-Werror", *flags, "-c", str(source), "-o", str(output)], capture_output=True, text=True) if expect_success: assert result.returncode == 0, result.stderr From e6f5937a1bed3219efd2bc85731df8afd4f60bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 16:07:21 +0000 Subject: [PATCH 271/331] test(renpy): verify host GPU resources survive private SDL teardown --- tools/renpy_ios_egl_probe/egl_probe.h | 1 + tools/renpy_ios_egl_probe/main.m | 1 + tools/renpy_ios_egl_probe/run_sdl_probe.sh | 4 +- tools/renpy_ios_egl_probe/sdl_probe.c | 67 ++++++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/tools/renpy_ios_egl_probe/egl_probe.h b/tools/renpy_ios_egl_probe/egl_probe.h index 3d79b28c2..9c2e9c356 100644 --- a/tools/renpy_ios_egl_probe/egl_probe.h +++ b/tools/renpy_ios_egl_probe/egl_probe.h @@ -8,6 +8,7 @@ typedef struct aether_egl_probe_result { int readback_verified; int host_context_restored; int resume_verified; + int sdl_terminal_cleanup_verified; int client_version; unsigned int egl_error; unsigned int gl_error; diff --git a/tools/renpy_ios_egl_probe/main.m b/tools/renpy_ios_egl_probe/main.m index fc2747e14..7263fe42f 100644 --- a/tools/renpy_ios_egl_probe/main.m +++ b/tools/renpy_ios_egl_probe/main.m @@ -37,6 +37,7 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( @"readback_verified": @(result.readback_verified), @"host_context_restored": @(result.host_context_restored), @"resume_verified": @(result.resume_verified), @"no_extra_uiwindow": @(noExtraWindow), + @"sdl_terminal_cleanup_verified": @(result.sdl_terminal_cleanup_verified), @"gles_client_version": @(result.client_version), @"egl_error": @(result.egl_error), @"gl_error": @(result.gl_error), @"error": [NSString stringWithUTF8String:result.error] ?: @"invalid UTF-8 diagnostic", diff --git a/tools/renpy_ios_egl_probe/run_sdl_probe.sh b/tools/renpy_ios_egl_probe/run_sdl_probe.sh index 67a233753..d94c0c0f7 100755 --- a/tools/renpy_ios_egl_probe/run_sdl_probe.sh +++ b/tools/renpy_ios_egl_probe/run_sdl_probe.sh @@ -204,10 +204,12 @@ report['source_version'] = 'SDL2.0.20' report['assertions'] = ['SDL_Init video driver=offscreen', 'SDL_CreateWindow EGL pbuffer', 'SDL_GL_CreateContext', 'actual GLES pixels', 'host context preserved', 'resume produces new pixels', 'SDL pointer down/up and UTF-8 text delivery', + 'private SDL window/context/video destruction preserves host GPU resources and fresh pixels', 'no additional UIWindow'] report_path.write_text(json.dumps(report, indent=2) + '\n') print(json.dumps(report, indent=2)) -required = ('passed', 'pbuffer_created', 'readback_verified', 'host_context_restored', 'resume_verified', 'no_extra_uiwindow') +required = ('passed', 'pbuffer_created', 'readback_verified', 'host_context_restored', 'resume_verified', + 'sdl_terminal_cleanup_verified', 'no_extra_uiwindow') if not all(report.get(k) for k in required): raise SystemExit('Actual iOS EGL/GLES probe failed; this is not gameplay acceptance.') print('Actual patched SDL iOS offscreen/GLES, pointer and UTF-8 event probe passed; Ren\'Py gameplay remains unverified.') diff --git a/tools/renpy_ios_egl_probe/sdl_probe.c b/tools/renpy_ios_egl_probe/sdl_probe.c index 3533c777d..bf03531a3 100644 --- a/tools/renpy_ios_egl_probe/sdl_probe.c +++ b/tools/renpy_ios_egl_probe/sdl_probe.c @@ -25,6 +25,8 @@ int aether_run_egl_probe(aether_egl_probe_result *out) { EGLContext host_context = EGL_NO_CONTEXT; SDL_Window *window = NULL; SDL_GLContext context = NULL; + GLuint host_texture = 0, host_fbo = 0, host_program = 0; + GLuint host_vertex = 0, host_fragment = 0; #define SDL_CHECK(condition, message) do { if (!(condition)) { \ snprintf(out->error, sizeof(out->error), "%s: %s", message, SDL_GetError()); \ out->egl_error = eglGetError(); out->gl_error = glGetError(); goto cleanup; \ @@ -122,6 +124,36 @@ int aether_run_egl_probe(aether_egl_probe_result *out) { SDL_CHECK(host_surface != EGL_NO_SURFACE && host_context != EGL_NO_CONTEXT, "host pbuffer/context"); SDL_CHECK(eglMakeCurrent(display, host_surface, host_surface, host_context), "bind host context"); glClearColor(0, 1, 1, 1); glClear(GL_COLOR_BUFFER_BIT); + /* Keep independent host GPU resources alive through the SDL teardown. + They belong to the same EGLDisplay but do not share a GL context. */ + glGenTextures(1, &host_texture); + glBindTexture(GL_TEXTURE_2D, host_texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 16, 16, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); + glGenFramebuffers(1, &host_fbo); + glBindFramebuffer(GL_FRAMEBUFFER, host_fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, host_texture, 0); + SDL_CHECK(glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE, + "independent host sentinel framebuffer"); + glClearColor(0, 1, 1, 1); glClear(GL_COLOR_BUFFER_BIT); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + const char *vertex_source = "attribute vec4 position; void main() { gl_Position = position; }"; + const char *fragment_source = "precision mediump float; void main() { gl_FragColor = vec4(1.0); }"; + host_vertex = glCreateShader(GL_VERTEX_SHADER); + host_fragment = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(host_vertex, 1, &vertex_source, NULL); + glShaderSource(host_fragment, 1, &fragment_source, NULL); + glCompileShader(host_vertex); glCompileShader(host_fragment); + GLint compiled_vertex = 0, compiled_fragment = 0, linked = 0; + glGetShaderiv(host_vertex, GL_COMPILE_STATUS, &compiled_vertex); + glGetShaderiv(host_fragment, GL_COMPILE_STATUS, &compiled_fragment); + SDL_CHECK(compiled_vertex && compiled_fragment, "independent host sentinel shaders"); + host_program = glCreateProgram(); + glAttachShader(host_program, host_vertex); glAttachShader(host_program, host_fragment); + glLinkProgram(host_program); + glGetProgramiv(host_program, GL_LINK_STATUS, &linked); + SDL_CHECK(linked && glGetError() == GL_NO_ERROR, "independent host sentinel program"); /* The host restores Ren'Py's raw EGL context, including after first bind. SDL's own current-context cache must not hide this actual switch. */ SDL_CHECK(eglMakeCurrent(display, game_surface, game_surface, (EGLContext)context), "bind SDL game context"); @@ -172,8 +204,43 @@ int aether_run_egl_probe(aether_egl_probe_result *out) { if (event.type == SDL_TEXTINPUT && !strcmp(event.text.text, "\xe8\xa7\xa6\xe6\x8e\xa7")) text = 1; } SDL_CHECK(down && up && text, "SDL actual pointer/text event delivery mismatch"); + /* Production terminal cleanup must override a conflicting environment + hint in the private SDL instance without terminating the host display. */ + SDL_CHECK(!SDL_setenv("AETHER_RENPY_EMBEDDED", "0", 1), "set conflicting embedded hint"); + SDL_CHECK(SDL_SetHintWithPriority("AETHER_RENPY_EMBEDDED", "1", SDL_HINT_OVERRIDE) && + SDL_GetHintBoolean("AETHER_RENPY_EMBEDDED", SDL_FALSE), "override private SDL display ownership hint"); + SDL_GL_DeleteContext(context); context = NULL; + SDL_DestroyWindow(window); window = NULL; + SDL_QuitSubSystem(SDL_INIT_VIDEO); + SDL_CHECK(!(SDL_WasInit(SDL_INIT_VIDEO) & SDL_INIT_VIDEO), "private SDL video remains initialized"); + SDL_CHECK(eglBindAPI(EGL_OPENGL_ES_API) && + eglMakeCurrent(display, host_surface, host_surface, host_context), + "restore independent host after private SDL destruction"); + SDL_CHECK(glIsTexture(host_texture) && glIsFramebuffer(host_fbo) && glIsProgram(host_program), + "private SDL teardown destroyed host GPU resources"); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + SDL_CHECK(glGetError() == GL_NO_ERROR && pixel[0] == 0 && pixel[1] == 255 && + pixel[2] == 255 && pixel[3] == 255, "host pbuffer changed during SDL destruction"); + glBindFramebuffer(GL_FRAMEBUFFER, host_fbo); + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + SDL_CHECK(glGetError() == GL_NO_ERROR && pixel[0] == 0 && pixel[1] == 255 && + pixel[2] == 255 && pixel[3] == 255, "host framebuffer changed during SDL destruction"); + glClearColor(1, 0, 1, 1); glClear(GL_COLOR_BUFFER_BIT); glFinish(); + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + SDL_CHECK(glGetError() == GL_NO_ERROR && pixel[0] == 255 && pixel[1] == 0 && + pixel[2] == 255 && pixel[3] == 255, "host did not render fresh pixels after SDL destruction"); + out->sdl_terminal_cleanup_verified = 1; out->passed = 1; cleanup: + if (display != EGL_NO_DISPLAY && host_context != EGL_NO_CONTEXT && + eglMakeCurrent(display, host_surface, host_surface, host_context)) { + if (host_program) glDeleteProgram(host_program); + if (host_vertex) glDeleteShader(host_vertex); + if (host_fragment) glDeleteShader(host_fragment); + if (host_fbo) glDeleteFramebuffers(1, &host_fbo); + if (host_texture) glDeleteTextures(1, &host_texture); + } if (display != EGL_NO_DISPLAY) eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); if (context) SDL_GL_DeleteContext(context); if (window) SDL_DestroyWindow(window); From dfabbe1d7a93703ce3918061dd4dc1c70ee2b49f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 16:20:05 +0000 Subject: [PATCH 272/331] fix(android): pin ggml recipe and patches to the registry baseline --- tools/test_ggml_overlay_source.py | 119 +++++++++++++ vcpkg/ports/ggml/cmake-config.diff | 20 +++ vcpkg/ports/ggml/fix-dequant_funcs.diff | 19 ++ vcpkg/ports/ggml/fix-vk-32bit.diff | 71 ++++++++ vcpkg/ports/ggml/pkgconfig.diff | 216 +++++++++++++++++++++++ vcpkg/ports/ggml/portfile.cmake | 110 +++++++++++- vcpkg/ports/ggml/relax-link-options.diff | 14 ++ vcpkg/ports/ggml/vcpkg.json | 1 + vcpkg/ports/ggml/vulkan-shaders-gen.diff | 26 +++ 9 files changed, 592 insertions(+), 4 deletions(-) create mode 100644 tools/test_ggml_overlay_source.py create mode 100644 vcpkg/ports/ggml/cmake-config.diff create mode 100644 vcpkg/ports/ggml/fix-dequant_funcs.diff create mode 100644 vcpkg/ports/ggml/fix-vk-32bit.diff create mode 100644 vcpkg/ports/ggml/pkgconfig.diff create mode 100644 vcpkg/ports/ggml/relax-link-options.diff create mode 100644 vcpkg/ports/ggml/vulkan-shaders-gen.diff diff --git a/tools/test_ggml_overlay_source.py b/tools/test_ggml_overlay_source.py new file mode 100644 index 000000000..89950fe37 --- /dev/null +++ b/tools/test_ggml_overlay_source.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Compile the pinned ggml source, apply the real port patches, and consume its CMake API. + +Supply an existing official v0.11.1 source archive. This check performs no +downloads, modifies no vcpkg cache, and does not test Android or gameplay. +""" + +import argparse +import hashlib +import json +import os +import re +import subprocess +import tarfile +import tempfile +from pathlib import Path, PurePosixPath + + +def run(*command: str, cwd: Path | None = None) -> None: + environment = dict(os.environ, SOURCE_DATE_EPOCH='1') + result = subprocess.run(command, cwd=cwd, env=environment, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if result.returncode: + raise RuntimeError(f'{command}: exit {result.returncode}\n{result.stdout}') + + +def validate_archive(archive: Path, recipe: str) -> str: + expected = re.search(r'\bSHA512\s+([a-f0-9]{128})\b', recipe) + if not expected: + raise ValueError('The production recipe must specify SHA512') + actual = hashlib.sha512(archive.read_bytes()).hexdigest() + if actual != expected.group(1): + raise ValueError(f'Source archive SHA512 differs: {actual}') + return actual + + +def check(archive: Path, cmake: str) -> None: + port = Path(__file__).resolve().parents[1] / 'vcpkg/ports/ggml' + manifest = json.loads((port / 'vcpkg.json').read_text()) + recipe = (port / 'portfile.cmake').read_text() + assert manifest['version'] == '0.11.1' + assert 'include("${VCPKG_ROOT_DIR}/ports/ggml/portfile.cmake")' not in recipe + checksum = validate_archive(archive, recipe) + patches = re.search(r'\bPATCHES\s+(.*?)\n\)', recipe, re.DOTALL) + if not patches: + raise ValueError('The production recipe must list its source patches') + names = patches.group(1).split() + with tempfile.TemporaryDirectory(prefix='aether-ggml-source-') as directory: + root = Path(directory) + with tarfile.open(archive) as source_archive: + members = source_archive.getmembers() + for member in members: + path = PurePosixPath(member.name) + if path.is_absolute() or '..' in path.parts or not (member.isfile() or member.isdir()): + raise ValueError(f'Unsafe source archive member: {member.name}') + if not path.parts or path.parts[0] != 'ggml-0.11.1': + raise ValueError(f'Unexpected source root: {member.name}') + source_archive.extractall(root, members=members, filter='data') + source = root / 'ggml-0.11.1' + for name in names: + patch = port / name + if patch.parent != port or not patch.is_file(): + raise ValueError(f'Invalid or missing port patch: {name}') + run('patch', '-p1', '--batch', '--forward', '-i', str(patch), cwd=source) + build = root / 'build' + prefix = root / 'installed' + run(cmake, '-S', str(source), '-B', str(build), '-DCMAKE_BUILD_TYPE=Release', + '-DCMAKE_INSTALL_PREFIX=' + str(prefix), '-DBUILD_SHARED_LIBS=OFF', + '-DGGML_STATIC=ON', '-DGGML_CCACHE=OFF', '-DGGML_BUILD_NUMBER=1', + '-DGGML_BUILD_TESTS=OFF', '-DGGML_BUILD_EXAMPLES=OFF', '-DGGML_HIP=OFF', + '-DGGML_SYCL=OFF', '-DGGML_BLAS=OFF', '-DGGML_CUDA=OFF', '-DGGML_METAL=OFF', + '-DGGML_OPENCL=OFF', '-DGGML_OPENMP=OFF', '-DGGML_VULKAN=OFF') + run(cmake, '--build', str(build), '--parallel', '2') + run(cmake, '--install', str(build)) + consumer = root / 'consumer' + consumer.mkdir() + (consumer / 'CMakeLists.txt').write_text('''cmake_minimum_required(VERSION 3.14) +project(ggml_consumer LANGUAGES CXX) +find_package(ggml CONFIG REQUIRED) +add_executable(ggml_smoke smoke.cpp) +target_link_libraries(ggml_smoke PRIVATE ggml::ggml) +''') + # The application/llama-cpp host is C++; its static ggml implementation + # includes C++ objects and needs the normal C++ executable link driver. + (consumer / 'smoke.cpp').write_text('''#include +int main(void) { + struct ggml_init_params params = {4 * 1024 * 1024, 0, false}; + struct ggml_context *context = ggml_init(params); + if (!context) return 1; + struct ggml_tensor *tensor = ggml_new_tensor_1d(context, GGML_TYPE_F32, 4); + int result = (!tensor || ggml_nelements(tensor) != 4 || ggml_nbytes(tensor) != 16); + ggml_free(context); + return result; +} +''') + consumer_build = root / 'consumer-build' + run(cmake, '-S', str(consumer), '-B', str(consumer_build), + '-DCMAKE_PREFIX_PATH=' + str(prefix)) + run(cmake, '--build', str(consumer_build), '--parallel', '2') + run(str(consumer_build / 'ggml_smoke')) + # A single changed source byte must be rejected before extraction or compilation. + changed = root / 'changed-source.tar.gz' + changed.write_bytes(archive.read_bytes() + b'corruption') + try: + validate_archive(changed, recipe) + except ValueError: + pass + else: + raise AssertionError('Changed source archive was accepted') + print(f'ggml 0.11.1 SHA512 verified: {checksum}') + print(f'{len(names)} official patches applied; real host build/install and ggml::ggml API link/run passed; changed-byte rejection passed.') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--archive', type=Path, required=True) + parser.add_argument('--cmake', default='cmake') + arguments = parser.parse_args() + check(arguments.archive.resolve(), arguments.cmake) diff --git a/vcpkg/ports/ggml/cmake-config.diff b/vcpkg/ports/ggml/cmake-config.diff new file mode 100644 index 000000000..d5cbec333 --- /dev/null +++ b/vcpkg/ports/ggml/cmake-config.diff @@ -0,0 +1,20 @@ +diff --git a/cmake/ggml-config.cmake.in b/cmake/ggml-config.cmake.in +index 91c9d5cd..0e0ef782 100644 +--- a/cmake/ggml-config.cmake.in ++++ b/cmake/ggml-config.cmake.in +@@ -98,7 +98,6 @@ if (NOT GGML_SHARED_LIB) + endif() + + set_and_check(GGML_INCLUDE_DIR "@PACKAGE_GGML_INCLUDE_INSTALL_DIR@") +-set_and_check(GGML_LIB_DIR "@PACKAGE_GGML_LIB_INSTALL_DIR@") + #set_and_check(GGML_BIN_DIR "@PACKAGE_GGML_BIN_INSTALL_DIR@") + + if(NOT TARGET ggml::ggml) +@@ -112,6 +111,7 @@ if(NOT TARGET ggml::ggml) + add_library(ggml::ggml UNKNOWN IMPORTED) + set_target_properties(ggml::ggml + PROPERTIES ++ INTERFACE_INCLUDE_DIRECTORIES "${GGML_INCLUDE_DIR}" + IMPORTED_LOCATION "${GGML_LIBRARY}") + + find_library(GGML_BASE_LIBRARY ggml-base diff --git a/vcpkg/ports/ggml/fix-dequant_funcs.diff b/vcpkg/ports/ggml/fix-dequant_funcs.diff new file mode 100644 index 000000000..ca6b14907 --- /dev/null +++ b/vcpkg/ports/ggml/fix-dequant_funcs.diff @@ -0,0 +1,19 @@ +diff --git a/src/ggml-vulkan/vulkan-shaders/dequant_funcs.glsl b/src/ggml-vulkan/vulkan-shaders/dequant_funcs.glsl +index 88d07d2d..a142d330 100644 +--- a/src/ggml-vulkan/vulkan-shaders/dequant_funcs.glsl ++++ b/src/ggml-vulkan/vulkan-shaders/dequant_funcs.glsl +@@ -477,8 +477,12 @@ vec2 get_dm(uint ib, uint a_offset) { + + #if defined(DATA_A_IQ1_M) + vec2 get_dm(uint ib, uint a_offset) { +- const uint16_t[4] scales = data_a[a_offset + ib].scales; +- const u16vec4 s = u16vec4(scales[0], scales[1], scales[2], scales[3]) >> 12; ++ const u16vec4 s = u16vec4( ++ data_a[a_offset + ib].scales[0], ++ data_a[a_offset + ib].scales[1], ++ data_a[a_offset + ib].scales[2], ++ data_a[a_offset + ib].scales[3] ++ ) >> 12; + const float d = float(unpackHalf2x16(s.x | (s.y << 4) | (s.z << 8) | (s.w << 12)).x); + return vec2(d, 0); + } diff --git a/vcpkg/ports/ggml/fix-vk-32bit.diff b/vcpkg/ports/ggml/fix-vk-32bit.diff new file mode 100644 index 000000000..eeea52dad --- /dev/null +++ b/vcpkg/ports/ggml/fix-vk-32bit.diff @@ -0,0 +1,71 @@ +diff --git a/src/ggml-vulkan/ggml-vulkan.cpp b/src/ggml-vulkan/ggml-vulkan.cpp +index 0a793100..a754ce42 100644 +--- a/src/ggml-vulkan/ggml-vulkan.cpp ++++ b/src/ggml-vulkan/ggml-vulkan.cpp +@@ -2036,7 +2036,7 @@ void vk_memory_logger::log_allocation(vk_buffer_ref buf_ref, size_t size) { + allocations[buf->buffer] = size; + total_device += device ? size : 0; + total_host += device ? 0 : size; +- VK_LOG_MEMORY(buf->device->name << ": +" << format_size(size) << " " << type << " at " << buf->buffer << ". Total device: " << format_size(total_device) << ", total host: " << format_size(total_host)); ++ VK_LOG_MEMORY(buf->device->name << ": +" << format_size(size) << " " << type << " at " << static_cast(buf->buffer) << ". Total device: " << format_size(total_device) << ", total host: " << format_size(total_host)); + } + + void vk_memory_logger::log_deallocation(vk_buffer_ref buf_ref) { +@@ -2052,10 +2052,10 @@ void vk_memory_logger::log_deallocation(vk_buffer_ref buf_ref) { + total_device -= device ? it->second : 0; + total_host -= device ? 0 : it->second; + if (it != allocations.end()) { +- VK_LOG_MEMORY(buf->device->name << ": -" << format_size(it->second) << " " << type << " at " << buf->buffer << ". Total device: " << format_size(total_device) << ", total host: " << format_size(total_host)); ++ VK_LOG_MEMORY(buf->device->name << ": -" << format_size(it->second) << " " << type << " at " << static_cast(buf->buffer) << ". Total device: " << format_size(total_device) << ", total host: " << format_size(total_host)); + allocations.erase(it); + } else { +- VK_LOG_MEMORY("ERROR " << buf->device->name << ": Attempted to deallocate unknown " << type << " memory at " << buf->buffer); ++ VK_LOG_MEMORY("ERROR " << buf->device->name << ": Attempted to deallocate unknown " << type << " memory at " << static_cast(buf->buffer)); + } + } + +@@ -6951,7 +6951,7 @@ static bool ggml_vk_buffer_write_2d_async(vk_context subctx, vk_buffer& dst, siz + } + + ggml_vk_sync_buffers(nullptr, subctx); +- subctx->s->buffer->buf.copyBuffer((VkBuffer)staging_buffer->buffer, (VkBuffer)dst->buffer, slices); ++ subctx->s->buffer->buf.copyBuffer(staging_buffer->buffer, dst->buffer, slices); + + if (width == spitch) { + deferred_memcpy((uint8_t *)staging_buffer->ptr, src, staging_size, &subctx->in_memcpys); +@@ -7086,7 +7086,7 @@ static bool ggml_vk_buffer_read_async(vk_context subctx, vk_buffer& src, size_t + } + + static void ggml_vk_buffer_read_2d(vk_buffer& src, size_t offset, void * dst, size_t spitch, size_t dpitch, size_t width, size_t height) { +- VK_LOG_DEBUG("ggml_vk_buffer_read_2d(" << src->buffer << ", " << offset << ", " << width << ", " << height << ")"); ++ VK_LOG_DEBUG("ggml_vk_buffer_read_2d(" << static_cast(src->buffer) << ", " << offset << ", " << width << ", " << height << ")"); + + // If the device is not an UMA device the memory is host-accessible through rebar. While writing + // through PCIe is sufficient fast reading back data from PCIe is slower than going through +@@ -7118,7 +7118,7 @@ static void ggml_vk_buffer_read_2d(vk_buffer& src, size_t offset, void * dst, si + } + + static void ggml_vk_buffer_read(vk_buffer& src, size_t offset, void * dst, size_t size) { +- VK_LOG_DEBUG("ggml_vk_buffer_read(" << src->buffer << ", " << offset << ", " << size << ")"); ++ VK_LOG_DEBUG("ggml_vk_buffer_read(" << static_cast(src->buffer) << ", " << offset << ", " << size << ")"); + ggml_vk_buffer_read_2d(src, offset, dst, size, size, size, 1); + } + +@@ -7286,7 +7286,7 @@ static void ggml_vk_matmul( + uint32_t batch_stride_a, uint32_t batch_stride_b, uint32_t batch_stride_d, + uint32_t split_k, uint32_t batch, uint32_t ne02, uint32_t ne12, uint32_t broadcast2, uint32_t broadcast3, + uint32_t padded_n) { +- VK_LOG_DEBUG("ggml_vk_matmul(a: (" << a.buffer->buffer << ", " << a.offset << ", " << a.size << "), b: (" << b.buffer->buffer << ", " << b.offset << ", " << b.size << "), d: (" << d.buffer->buffer << ", " << d.offset << ", " << d.size << "), split_k: (" << (split_k_buffer.buffer != nullptr ? split_k_buffer.buffer->buffer : VK_NULL_HANDLE) << ", " << split_k_buffer.offset << ", " << split_k_buffer.size << "), m: " << m << ", n: " << n << ", k: " << k << ", stride_a: " << stride_a << ", stride_b: " << stride_b << ", stride_d: " << stride_d << ", batch_stride_a: " << batch_stride_a << ", batch_stride_b: " << batch_stride_b << ", batch_stride_d: " << batch_stride_d << ", split_k: " << split_k << ", batch: " << batch << ", ne02: " << ne02 << ", ne12: " << ne12 << ", broadcast2: " << broadcast2 << ", broadcast3: " << broadcast3 << ", padded_n: " << padded_n << ")"); ++ VK_LOG_DEBUG("ggml_vk_matmul(a: (" << static_cast(a.buffer->buffer) << ", " << a.offset << ", " << a.size << "), b: (" << static_cast(b.buffer->buffer) << ", " << b.offset << ", " << b.size << "), d: (" << static_cast(d.buffer->buffer) << ", " << d.offset << ", " << d.size << "), split_k: (" << (split_k_buffer.buffer != nullptr ? static_cast(split_k_buffer.buffer->buffer) : VkBuffer{}) << ", " << split_k_buffer.offset << ", " << split_k_buffer.size << "), m: " << m << ", n: " << n << ", k: " << k << ", stride_a: " << stride_a << ", stride_b: " << stride_b << ", stride_d: " << stride_d << ", batch_stride_a: " << batch_stride_a << ", batch_stride_b: " << batch_stride_b << ", batch_stride_d: " << batch_stride_d << ", split_k: " << split_k << ", batch: " << batch << ", ne02: " << ne02 << ", ne12: " << ne12 << ", broadcast2: " << broadcast2 << ", broadcast3: " << broadcast3 << ", padded_n: " << padded_n << ")"); + if (split_k == 1) { + ggml_pipeline_request_descriptor_sets(ctx, pipeline, CEIL_DIV(batch, ctx->device->properties.limits.maxComputeWorkGroupCount[2])); + +@@ -7366,7 +7366,7 @@ static void ggml_vk_matmul_id( + uint32_t batch_stride_a, uint32_t batch_stride_b, uint32_t batch_stride_d, + uint32_t n_as, uint32_t nei0, uint32_t nei1, uint32_t nbi1, uint32_t ne11, + uint32_t padded_n) { +- VK_LOG_DEBUG("ggml_vk_matmul_id(a: (" << a.buffer->buffer << ", " << a.offset << ", " << a.size << "), b: (" << b.buffer->buffer << ", " << b.offset << ", " << b.size << "), d: (" << d.buffer->buffer << ", " << d.offset << ", " << d.size << "), ids: (" << ids.buffer->buffer << ", " << ids.offset << ", " << ids.size << "), expert_count: (" << expert_count_buf.buffer->buffer << ", " << expert_count_buf.offset << ", " << expert_count_buf.size << "), " << ++ VK_LOG_DEBUG("ggml_vk_matmul_id(a: (" << static_cast(a.buffer->buffer) << ", " << a.offset << ", " << a.size << "), b: (" << static_cast(b.buffer->buffer) << ", " << b.offset << ", " << b.size << "), d: (" << static_cast(d.buffer->buffer) << ", " << d.offset << ", " << d.size << "), ids: (" << static_cast(ids.buffer->buffer) << ", " << ids.offset << ", " << ids.size << "), expert_count: (" << static_cast(expert_count_buf.buffer->buffer) << ", " << expert_count_buf.offset << ", " << expert_count_buf.size << "), " << + "m: " << m << ", n: " << n << ", k: " << k << ", stride_a: " << stride_a << ", stride_b: " << stride_b << ", stride_d: " << stride_d << ", " << + "batch_stride_a: " << batch_stride_a << ", batch_stride_b: " << batch_stride_b << ", batch_stride_d: " << batch_stride_d << ", " << + "n_as: " << n_as << ", nei0: " << nei0 << ", nei1: " << nei1 << ", nbi1: " << nbi1 << ", ne11: " << ne11 << ")"); diff --git a/vcpkg/ports/ggml/pkgconfig.diff b/vcpkg/ports/ggml/pkgconfig.diff new file mode 100644 index 000000000..8c9bd5df8 --- /dev/null +++ b/vcpkg/ports/ggml/pkgconfig.diff @@ -0,0 +1,216 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 672b37df..c6f16c7c 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -352,7 +352,7 @@ if (GGML_STANDALONE) + @ONLY) + + install(FILES ${CMAKE_CURRENT_BINARY_DIR}/ggml.pc +- DESTINATION share/pkgconfig) ++ DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig) + endif() + + # +@@ -373,6 +373,7 @@ set(variable_set_statements + set(GGML_SHARED_LIB ${BUILD_SHARED_LIBS}) + + get_cmake_property(all_variables VARIABLES) ++list(FILTER all_variables EXCLUDE REGEX "^GGML_PKGCONFIG") + foreach(variable_name IN LISTS all_variables) + if(variable_name MATCHES "^GGML_") + string(REPLACE ";" "\\;" +diff --git a/ggml.pc.in b/ggml.pc.in +index 3e0291e0..a7627339 100644 +--- a/ggml.pc.in ++++ b/ggml.pc.in +@@ -6,5 +6,7 @@ libdir=${prefix}/@CMAKE_INSTALL_LIBDIR@ + Name: ggml + Description: The GGML Tensor Library for Machine Learning + Version: @GGML_VERSION@ +-Cflags: -I${includedir} +-Libs: -L${libdir} -lggml ++Cflags: -I${includedir} @GGML_PKGCONFIG_CFLAGS@ ++Libs: -L${libdir} -lggml @GGML_PKGCONFIG_LIBS_BACKEND@ -lggml-base ++Libs.private: @GGML_PKGCONFIG_LIBS_PRIVATE@ ++Requires.private: @GGML_PKGCONFIG_REQUIRES_PRIVATE@ +diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt +index 3e48860b..1ec27e83 100644 +--- a/src/CMakeLists.txt ++++ b/src/CMakeLists.txt +@@ -185,6 +185,10 @@ endif() + + # ggml + ++set(GGML_PKGCONFIG_CFLAGS "") ++set(GGML_PKGCONFIG_LIBS_BACKEND "") ++set(GGML_PKGCONFIG_LIBS_PRIVATE "") ++ + if (GGML_BACKEND_DL AND NOT BUILD_SHARED_LIBS) + message(FATAL_ERROR "GGML_BACKEND_DL requires BUILD_SHARED_LIBS") + endif() +@@ -243,6 +247,7 @@ target_link_libraries(ggml PUBLIC ggml-base) + + if (CMAKE_SYSTEM_NAME MATCHES "Linux") + target_link_libraries(ggml PRIVATE dl) ++ string(APPEND GGML_PKGCONFIG_LIBS_PRIVATE " -ldl") + endif() + + function(ggml_add_backend_library backend) +@@ -296,12 +301,20 @@ function(ggml_add_backend backend) + string(TOUPPER "GGML_${backend}" backend_id) + if (${backend_id}) + string(TOLOWER "ggml-${backend}" backend_target) ++ if (NOT GGML_BACKEND_DL) ++ # Mirrors ggml_add_backend_library but avoids cmake scoping ++ set(GGML_PKGCONFIG_LIBS_BACKEND "${GGML_PKGCONFIG_LIBS_BACKEND} -l${backend_target}") ++ endif() + add_subdirectory(${backend_target}) + message(STATUS "Including ${backend} backend") + if (NOT GGML_BACKEND_DL) + string(TOUPPER "GGML_USE_${backend}" backend_use) + target_compile_definitions(ggml PUBLIC ${backend_use}) ++ set(GGML_PKGCONFIG_CFLAGS "${GGML_PKGCONFIG_CFLAGS} -D${backend_use}" PARENT_SCOPE) + endif() ++ set(GGML_PKGCONFIG_LIBS_BACKEND "${GGML_PKGCONFIG_LIBS_BACKEND}" PARENT_SCOPE) ++ set(GGML_PKGCONFIG_LIBS_PRIVATE "${GGML_PKGCONFIG_LIBS_PRIVATE}" PARENT_SCOPE) ++ set(GGML_PKGCONFIG_REQUIRES_PRIVATE "${GGML_PKGCONFIG_REQUIRES_PRIVATE}" PARENT_SCOPE) + endif() + endfunction() + +@@ -474,10 +487,14 @@ if (DEFINED MATH_LIBRARY) + target_link_libraries(ggml-base PRIVATE ${MATH_LIBRARY}) + elseif (NOT WIN32 AND NOT DEFINED ENV{ONEAPI_ROOT}) + target_link_libraries(ggml-base PRIVATE m) ++ string(APPEND GGML_PKGCONFIG_LIBS_PRIVATE " -lm") + endif() + + if (CMAKE_SYSTEM_NAME MATCHES "Android") + target_link_libraries(ggml-base PRIVATE dl) ++ if(NOT GGML_PKGCONFIG_LIBS_PRIVATE MATCHES " -ldl") ++ string(APPEND GGML_PKGCONFIG_LIBS_PRIVATE " -ldl") ++ endif() + endif() + + if(CMAKE_SYSTEM_NAME MATCHES "visionOS") +@@ -489,5 +506,11 @@ if (BUILD_SHARED_LIBS) + set_target_properties(${target} PROPERTIES POSITION_INDEPENDENT_CODE ON) + target_compile_definitions(${target} PRIVATE GGML_BUILD) + target_compile_definitions(${target} PUBLIC GGML_SHARED) ++ string(APPEND GGML_PKGCONFIG_CFLAGS " -DGGML_SHARED -DGGML_BACKEND_SHARED") + endforeach() + endif() ++ ++set(GGML_PKGCONFIG_CFLAGS "${GGML_PKGCONFIG_CFLAGS}" PARENT_SCOPE) ++set(GGML_PKGCONFIG_LIBS_BACKEND "${GGML_PKGCONFIG_LIBS_BACKEND}" PARENT_SCOPE) ++set(GGML_PKGCONFIG_LIBS_PRIVATE "${GGML_PKGCONFIG_LIBS_PRIVATE}" PARENT_SCOPE) ++set(GGML_PKGCONFIG_REQUIRES_PRIVATE "${GGML_PKGCONFIG_REQUIRES_PRIVATE}" PARENT_SCOPE) +diff --git a/src/ggml-blas/CMakeLists.txt b/src/ggml-blas/CMakeLists.txt +index c27dc174..ce1ab21d 100644 +--- a/src/ggml-blas/CMakeLists.txt ++++ b/src/ggml-blas/CMakeLists.txt +@@ -93,6 +93,7 @@ if (BLAS_FOUND) + endif() + + target_link_libraries (ggml-blas PRIVATE ${BLAS_LIBRARIES}) ++ set(GGML_PKGCONFIG_REQUIRES_PRIVATE "${GGML_PKGCONFIG_REQUIRES_PRIVATE} cblas" PARENT_SCOPE) + target_include_directories(ggml-blas SYSTEM PRIVATE ${BLAS_INCLUDE_DIRS}) + else() + message(FATAL_ERROR "BLAS not found, please refer to " +diff --git a/src/ggml-cpu/CMakeLists.txt b/src/ggml-cpu/CMakeLists.txt +index 869c7b23..53a90f73 100644 +--- a/src/ggml-cpu/CMakeLists.txt ++++ b/src/ggml-cpu/CMakeLists.txt +@@ -57,6 +57,9 @@ function(ggml_add_cpu_backend_variant_impl tag_name) + target_compile_features(${GGML_CPU_NAME} PRIVATE c_std_11 cxx_std_17) + target_include_directories(${GGML_CPU_NAME} PRIVATE . ggml-cpu) + ++ set(libs_private "") ++ set(pkgconfig_cflags "") ++ + if (APPLE AND GGML_ACCELERATE) + find_library(ACCELERATE_FRAMEWORK Accelerate) + if (ACCELERATE_FRAMEWORK) +@@ -67,6 +70,7 @@ function(ggml_add_cpu_backend_variant_impl tag_name) + target_compile_definitions(${GGML_CPU_NAME} PRIVATE ACCELERATE_LAPACK_ILP64) + + target_link_libraries(${GGML_CPU_NAME} PRIVATE ${ACCELERATE_FRAMEWORK}) ++ string(APPEND libs_private " -framework Accelerate") + else() + message(WARNING "Accelerate framework not found") + endif() +@@ -79,6 +83,18 @@ function(ggml_add_cpu_backend_variant_impl tag_name) + target_compile_definitions(${GGML_CPU_NAME} PRIVATE GGML_USE_OPENMP) + + target_link_libraries(${GGML_CPU_NAME} PRIVATE OpenMP::OpenMP_C OpenMP::OpenMP_CXX) ++ set(items "") ++ foreach(lib IN LISTS OpenMP_CXX_LIB_NAMES OpenMP_C_LIB_NAMES) ++ list(REMOVE_ITEM items " -l${lib}") ++ list(APPEND items " -l${lib}") ++ endforeach() ++ string(APPEND libs_private ${items}) ++ set(items "") ++ foreach(flag IN LISTS OpenMP_CXX_FLAGS OpenMP_C_FLAGS) ++ list(REMOVE_ITEM items " ${flag}") ++ list(APPEND items " ${flag}") ++ endforeach() ++ string(APPEND pkgconfig_cflags ${items}) + else() + set(GGML_OPENMP_ENABLED "OFF" CACHE INTERNAL "") + message(WARNING "OpenMP not found") +@@ -101,8 +117,12 @@ function(ggml_add_cpu_backend_variant_impl tag_name) + target_compile_definitions(${GGML_CPU_NAME} PRIVATE GGML_USE_CPU_HBM) + + target_link_libraries(${GGML_CPU_NAME} PUBLIC memkind) ++ string(APPEND libs_private " -lmemkind") + endif() + ++ set(GGML_PKGCONFIG_CFLAGS "${GGML_PKGCONFIG_CFLAGS}${pkgconfig_cflags}" PARENT_SCOPE) ++ set(GGML_PKGCONFIG_LIBS_PRIVATE "${GGML_PKGCONFIG_LIBS_PRIVATE}${libs_private}" PARENT_SCOPE) ++ + if (GGML_SYSTEM_ARCH STREQUAL "ARM") + message(STATUS "ARM detected") + list(APPEND GGML_CPU_SOURCES +diff --git a/src/ggml-metal/CMakeLists.txt b/src/ggml-metal/CMakeLists.txt +index 42054d84..d780361e 100644 +--- a/src/ggml-metal/CMakeLists.txt ++++ b/src/ggml-metal/CMakeLists.txt +@@ -19,6 +19,11 @@ target_link_libraries(ggml-metal PRIVATE + ${METALKIT_FRAMEWORK} + ) + ++set(GGML_PKGCONFIG_LIBS_PRIVATE ++ "${GGML_PKGCONFIG_LIBS_PRIVATE} -framework Foundation -framework Metal -framework MetalKit" ++ PARENT_SCOPE ++) ++ + if (GGML_METAL_NDEBUG) + add_compile_definitions(GGML_METAL_NDEBUG) + endif() +diff --git a/src/ggml-opencl/CMakeLists.txt b/src/ggml-opencl/CMakeLists.txt +index ffde6a4f..9a7f9526 100644 +--- a/src/ggml-opencl/CMakeLists.txt ++++ b/src/ggml-opencl/CMakeLists.txt +@@ -7,6 +7,7 @@ ggml_add_backend_library(${TARGET_NAME} + ggml-opencl.cpp + ../../include/ggml-opencl.h) + target_link_libraries(${TARGET_NAME} PRIVATE ${OpenCL_LIBRARIES}) ++set(GGML_PKGCONFIG_REQUIRES_PRIVATE "${GGML_PKGCONFIG_REQUIRES_PRIVATE} OpenCL" PARENT_SCOPE) + target_include_directories(${TARGET_NAME} PRIVATE ${OpenCL_INCLUDE_DIRS}) + + if (GGML_OPENCL_PROFILING) +diff --git a/src/ggml-vulkan/CMakeLists.txt b/src/ggml-vulkan/CMakeLists.txt +index 715a263a..04c142c2 100644 +--- a/src/ggml-vulkan/CMakeLists.txt ++++ b/src/ggml-vulkan/CMakeLists.txt +@@ -87,6 +87,11 @@ if (Vulkan_FOUND) + ) + + target_link_libraries(ggml-vulkan PRIVATE Vulkan::Vulkan) ++ if(ANDROID) ++ set(GGML_PKGCONFIG_LIBS_PRIVATE "${GGML_PKGCONFIG_LIBS_PRIVATE} -lvulkan" PARENT_SCOPE) ++ else() ++ set(GGML_PKGCONFIG_REQUIRES_PRIVATE "${GGML_PKGCONFIG_REQUIRES_PRIVATE} vulkan" PARENT_SCOPE) ++ endif() + target_include_directories(ggml-vulkan PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) + + # Workaround to the "can't dereference invalidated vector iterator" bug in clang-cl debug build diff --git a/vcpkg/ports/ggml/portfile.cmake b/vcpkg/ports/ggml/portfile.cmake index 3bdacdc0d..cb200a737 100644 --- a/vcpkg/ports/ggml/portfile.cmake +++ b/vcpkg/ports/ggml/portfile.cmake @@ -1,3 +1,9 @@ +# Official ggml 0.11.1 recipe and patches are pinned together to microsoft/vcpkg +# commit 9e593bb18ea69cc5095e012465dcd675a822ed0d (this repository's registry baseline): +# https://github.com/microsoft/vcpkg/tree/9e593bb18ea69cc5095e012465dcd675a822ed0d/ports/ggml +# Including VCPKG_ROOT/ports/ggml would mix our fixed VERSION with a newer +# upstream recipe and its different source hash/patches. Keep SHA512 validation. + # ggml enables -march=native unless it detects a reproducible/cross build. # When producing x86_64 on an Apple Silicon host, clang expands that flag to # apple-m1 and rejects it for the x86 target. Scope the reproducible-build hint @@ -9,10 +15,106 @@ if(DEFINED ENV{SOURCE_DATE_EPOCH}) endif() set(ENV{SOURCE_DATE_EPOCH} "1") -set(_aether_overlay_port_dir "${CURRENT_PORT_DIR}") -set(CURRENT_PORT_DIR "${VCPKG_ROOT_DIR}/ports/ggml") -include("${VCPKG_ROOT_DIR}/ports/ggml/portfile.cmake") -set(CURRENT_PORT_DIR "${_aether_overlay_port_dir}") +vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO ggml-org/ggml + REF v${VERSION} + SHA512 9559545fea9606a5d9cd87fdd7ef1352ade4695305dfe27327abdc6cf598a8f159875f05992864cfd55405589b58ea3598181b64b3600bd5aa287a320c14d0b3 + HEAD_REF master + PATCHES + cmake-config.diff + pkgconfig.diff + relax-link-options.diff + vulkan-shaders-gen.diff + fix-dequant_funcs.diff + fix-vk-32bit.diff +) + +vcpkg_check_features(OUT_FEATURE_OPTIONS FEATURE_OPTIONS + FEATURES + blas GGML_BLAS + cuda GGML_CUDA + metal GGML_METAL + opencl GGML_OPENCL + openmp GGML_OPENMP + vulkan GGML_VULKAN +) + +if("blas" IN_LIST FEATURES) + vcpkg_find_acquire_program(PKGCONFIG) + list(APPEND FEATURE_OPTIONS + "-DCMAKE_REQUIRE_FIND_PACKAGE_BLAS=ON" # workaround message(ERROR ...) + "-DPKG_CONFIG_EXECUTABLE=${PKGCONFIG}" + ) +endif() + +if("cuda" IN_LIST FEATURES) + vcpkg_find_cuda(OUT_CUDA_TOOLKIT_ROOT cuda_toolkit_root) + list(APPEND FEATURE_OPTIONS + "-DCMAKE_CUDA_COMPILER=${NVCC}" + "-DCUDAToolkit_ROOT=${cuda_toolkit_root}" + ) +endif() + +if("opencl" IN_LIST FEATURES) + vcpkg_find_acquire_program(PYTHON3) + list(APPEND FEATURE_OPTIONS + "-DPython3_EXECUTABLE=${PYTHON3}" + ) +endif() + +if(VCPKG_TARGET_IS_WINDOWS AND NOT VCPKG_TARGET_IS_MINGW AND VCPKG_TARGET_ARCHITECTURE STREQUAL "arm64") + message(STATUS "The CPU backend is not supported for arm64 with MSVC.") + list(APPEND FEATURE_OPTIONS + "-DGGML_CPU=OFF" + ) + if(FEATURES STREQUAL "core") + message(WARNING "No backend enabled!") + endif() +endif() + +if("vulkan" IN_LIST FEATURES AND VCPKG_CROSSCOMPILING) + list(APPEND FEATURE_OPTIONS + "-DVulkan_GLSLC_EXECUTABLE=${CURRENT_HOST_INSTALLED_DIR}/tools/shaderc/glslc${VCPKG_HOST_EXECUTABLE_SUFFIX}" + "-DVULKAN_SHADERS_GEN_EXECUTABLE=${CURRENT_HOST_INSTALLED_DIR}/tools/${PORT}/vulkan-shaders-gen${VCPKG_HOST_EXECUTABLE_SUFFIX}" + ) +endif() + +string(COMPARE EQUAL "${VCPKG_LIBRARY_LINKAGE}" "static" GGML_STATIC) + +vcpkg_cmake_configure( + SOURCE_PATH "${SOURCE_PATH}" + OPTIONS + -DGGML_STATIC=${GGML_STATIC} + -DGGML_CCACHE=OFF + -DGGML_BUILD_NUMBER=1 + -DGGML_BUILD_TESTS=OFF + -DGGML_BUILD_EXAMPLES=OFF + -DGGML_HIP=OFF + -DGGML_SYCL=OFF + ${FEATURE_OPTIONS} + MAYBE_UNUSED_VARIABLES + PKG_CONFIG_EXECUTABLE +) + +vcpkg_cmake_install() +vcpkg_copy_pdbs() +vcpkg_cmake_config_fixup(PACKAGE_NAME ggml CONFIG_PATH "lib/cmake/ggml") +vcpkg_fixup_pkgconfig() + +if(VCPKG_LIBRARY_LINKAGE STREQUAL "dynamic") + vcpkg_replace_string("${CURRENT_PACKAGES_DIR}/include/ggml.h" "#ifdef GGML_SHARED" "#if 1") + vcpkg_replace_string("${CURRENT_PACKAGES_DIR}/include/ggml-backend.h" "#ifdef GGML_BACKEND_SHARED" "#if 1") +endif() + +if("vulkan" IN_LIST FEATURES AND NOT VCPKG_CROSSCOMPILING) + vcpkg_copy_tools(TOOL_NAMES vulkan-shaders-gen AUTO_CLEAN) +endif() + +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include") +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/share") + +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE") if(_aether_had_source_date_epoch) set(ENV{SOURCE_DATE_EPOCH} "${_aether_source_date_epoch}") diff --git a/vcpkg/ports/ggml/relax-link-options.diff b/vcpkg/ports/ggml/relax-link-options.diff new file mode 100644 index 000000000..39970685d --- /dev/null +++ b/vcpkg/ports/ggml/relax-link-options.diff @@ -0,0 +1,14 @@ +diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt +index 1ec27e83..3cef2687 100644 +--- a/src/CMakeLists.txt ++++ b/src/CMakeLists.txt +@@ -117,9 +117,7 @@ if (NOT MSVC) + if (UNIX AND NOT APPLE) + set(CMAKE_FIND_LIBRARY_SUFFIXES ".a;.so") + endif() +- add_link_options(-static) + if (MINGW) +- add_link_options(-static-libgcc -static-libstdc++) + endif() + endif() + if (GGML_GPROF) diff --git a/vcpkg/ports/ggml/vcpkg.json b/vcpkg/ports/ggml/vcpkg.json index bdd220a69..bd0f0fdee 100644 --- a/vcpkg/ports/ggml/vcpkg.json +++ b/vcpkg/ports/ggml/vcpkg.json @@ -1,6 +1,7 @@ { "name": "ggml", "version": "0.11.1", + "port-version": 1, "description": "Tensor library for machine learning", "homepage": "https://github.com/ggml-org/ggml", "license": "MIT", diff --git a/vcpkg/ports/ggml/vulkan-shaders-gen.diff b/vcpkg/ports/ggml/vulkan-shaders-gen.diff new file mode 100644 index 000000000..6a8fb247f --- /dev/null +++ b/vcpkg/ports/ggml/vulkan-shaders-gen.diff @@ -0,0 +1,26 @@ +diff --git a/src/ggml-vulkan/CMakeLists.txt b/src/ggml-vulkan/CMakeLists.txt +index 04c142c2..40a2791f 100644 +--- a/src/ggml-vulkan/CMakeLists.txt ++++ b/src/ggml-vulkan/CMakeLists.txt +@@ -125,6 +125,12 @@ if (Vulkan_FOUND) + add_compile_definitions(GGML_VULKAN_RUN_TESTS) + endif() + ++ if(DEFINED VULKAN_SHADERS_GEN_EXECUTABLE) ++ add_executable(vulkan-shaders-gen IMPORTED) ++ set_target_properties(vulkan-shaders-gen PROPERTIES IMPORTED_LOCATION "${VULKAN_SHADERS_GEN_EXECUTABLE}") ++ elseif(1) ++ add_subdirectory(vulkan-shaders) ++ elseif(0) + # Set up toolchain for host compilation whether cross-compiling or not + if (CMAKE_CROSSCOMPILING) + if (GGML_VULKAN_SHADERS_GEN_TOOLCHAIN) +@@ -174,6 +180,8 @@ if (Vulkan_FOUND) + set (_ggml_vk_host_suffix $,.exe,>) + set (_ggml_vk_genshaders_dir "${CMAKE_BINARY_DIR}/$") + set (_ggml_vk_genshaders_cmd "${_ggml_vk_genshaders_dir}/vulkan-shaders-gen${_ggml_vk_host_suffix}") ++ endif() ++ set (_ggml_vk_genshaders_cmd "vulkan-shaders-gen") + set (_ggml_vk_header "${CMAKE_CURRENT_BINARY_DIR}/ggml-vulkan-shaders.hpp") + set (_ggml_vk_input_dir "${CMAKE_CURRENT_SOURCE_DIR}/vulkan-shaders") + set (_ggml_vk_output_dir "${CMAKE_CURRENT_BINARY_DIR}/vulkan-shaders.spv") From 462a95798a161f641f659939efd046390de1dee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 16:20:05 +0000 Subject: [PATCH 273/331] ci(renpy): queue Swift driver only after the native iOS build --- .github/workflows/renpy-ios-runtime-build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index 1a84f25fe..1e6244909 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -27,6 +27,7 @@ concurrency: jobs: ios-test-driver: name: Compile the actual XCUITest input driver + needs: [ios-runtime] runs-on: macos-latest timeout-minutes: 15 steps: From 0b813af33ef2bc103caa8a6d32270655306616ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 16:28:48 +0000 Subject: [PATCH 274/331] fix(renpy): release healthy mobile sessions and preserve shutdown errors --- .../mobile_launcher/patches/native/README.md | 23 ++- .../patches/native/renpy_mobile_lifecycle.c | 21 +- .../0001-cooperative-loop-skeleton.patch | 184 ++++++++++++++++-- .../src/renpy_runtime_mobile_provider.cpp | 10 +- .../renpy_runtime/tests/mobile_lifecycle.cpp | 16 +- .../tests/mobile_provider_protocol.cpp | 43 +++- tools/test_renpy_mobile_cooperative.py | 96 ++++++++- 7 files changed, 366 insertions(+), 27 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index 40a565704..5c0d8f718 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -60,7 +60,26 @@ preserves pointer/key/text actions, publishes renderer screenshots and returns observable normal-exit and text-input state. Window binding connects the real SDL renderer window to the Android EGL context handoff. +Terminal shutdown runs on the interpreter's owning thread while its healthy +private context is current. The cooperative patch stops the engine timers, +retires GL2 Program destructors before dropping cached references, and destroys +the Python-owned SDL window, context and pbuffer. Programs retained by script +stores or texture loaders cannot later delete a host GL program during garbage +collection. SDL hint override keeps `AETHER_RENPY_EMBEDDED` true throughout the +window and video teardown; the existing SDL patch skips `eglTerminate` on the +shared display. Normal bootstrap renderer teardown is not repeated. + +Shutdown is terminal: another Ren'Py session requires restarting the host. +If its private EGL context cannot be made current or Python cleanup fails, +native reports the failure, retains the session and clears host callbacks. +It never substitutes the current host context for deleting Ren'Py resources. +Recovery from genuine EGL context/display loss remains unimplemented; this +does not imply an ordinary host Surface replacement destroys the private +pbuffer. Real long-background, rotation and context-loss stress acceptance +is still required. + These sources need actual target compilation and cloud-device gameplay acceptance. A successful host compilation or exported-symbol check is not -mobile playability evidence. The iOS MetalANGLE host rendering integration -remains blocked and the provider refuses it explicitly. +mobile playability evidence. iOS startup requires the patched offscreen +MetalANGLE driver; successful renderer probes do not establish full Ren'Py +gameplay, input, pause/resume or shutdown acceptance. diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c b/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c index 5d46f8a3e..ff14214b5 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/renpy_mobile_lifecycle.c @@ -4,6 +4,7 @@ * renderer's screenshot before yielding to Godot. */ #include #include +#include #include #include "renpy_mobile_launcher.h" #if defined(__APPLE__) @@ -46,6 +47,14 @@ static int begin_graphics(graphics_scope *scope) { if (aether_context != EGL_NO_CONTEXT) { eglBindAPI(EGL_OPENGL_ES_API); if (!eglMakeCurrent(aether_display, aether_draw_surface, aether_read_surface, aether_context)) { + const EGLint error = eglGetError(); + if (aether_host.log_utf8) { + char detail[160]; + snprintf(detail, sizeof(detail), + "Ren'Py private EGL context could not be made current (EGL error 0x%04x); context-loss recovery is unavailable", + (unsigned int)error); + aether_host.log_utf8(aether_host.user_data, 3, detail); + } eglBindAPI(scope->api); return RENPY_MOBILE_ERROR; } @@ -334,7 +343,15 @@ void renpy_mobile_shutdown(void) { memset(&aether_host, 0, sizeof(aether_host)); return; } - (void)call_noargs("cooperative_stop"); + if (call_noargs("cooperative_stop") != RENPY_MOBILE_OK) { + /* A failed graphics scope must never dispose Python-owned GL objects + * with the host context current. Retain the stopped-or-suspended + * session and refuse reuse; only the process can reclaim it safely. */ + if (aether_host.log_utf8) aether_host.log_utf8(aether_host.user_data, 3, + "Ren'Py terminal cleanup failed; retained runtime resources require a host application restart"); + memset(&aether_host, 0, sizeof(aether_host)); + return; + } PyGILState_STATE gil = PyGILState_Ensure(); Py_CLEAR(aether_frame_bytes); Py_CLEAR(aether_main_module); @@ -343,7 +360,7 @@ void renpy_mobile_shutdown(void) { aether_paused = 0; aether_owner_thread = 0; aether_window = NULL; -#if defined(__ANDROID__) +#if defined(AETHER_MOBILE_EGL) aether_display = EGL_NO_DISPLAY; aether_context = EGL_NO_CONTEXT; aether_draw_surface = EGL_NO_SURFACE; diff --git a/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch b/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch index 361db723b..aad73a69a 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch @@ -164,10 +164,25 @@ index cb66af0..3762a48 100644 else: diff --git a/renpy/pygame/display.pyx b/renpy/pygame/display.pyx -index 85d16d0..a732f2c 100644 +index 85d16d0..c8bda49 100644 --- a/renpy/pygame/display.pyx +++ b/renpy/pygame/display.pyx -@@ -207,7 +207,9 @@ cdef class Window: +@@ -64,6 +64,14 @@ def _get_hint(hint, default): + return default + + ++def prepare_mobile_shutdown(): ++ # SDL_DestroyWindow unloads EGL too. The patched embedded SDL must keep ++ # the shared host display alive even if the process environment says 0. ++ hint("AETHER_RENPY_EMBEDDED", "1", priority=SDL_HINT_OVERRIDE) ++ if not SDL_GetHintBoolean(b"AETHER_RENPY_EMBEDDED", SDL_FALSE): ++ raise error("Could not protect the host EGL display during Ren'Py shutdown") ++ ++ + main_done = False + + def sdl_main_init(): +@@ -207,7 +215,9 @@ cdef class Window: if self.gl_context == NULL: raise error() @@ -178,18 +193,42 @@ index 85d16d0..a732f2c 100644 if not ios: # Try setting the swap interval - first positive, then negated -@@ -262,6 +264,10 @@ cdef class Window: +@@ -262,8 +272,22 @@ cdef class Window: This should be called before the window is deleted. """ +- if self.gl_context != NULL: +- SDL_GL_DeleteContext(self.gl_context) ++ if self.window == NULL and self.gl_context == NULL: ++ return ++ + if renpy.aether_mobile.cooperative_active(): ++ prepare_mobile_shutdown() + import _aether_host -+ _aether_host.bind_window(0) ++ if _aether_host.bind_window(0) != 0: ++ raise error("Could not unbind the Ren'Py renderer window") ++ ++ cdef SDL_GLContext context = self.gl_context ++ cdef SDL_Window *window = self.window ++ self.gl_context = NULL ++ self.window = NULL + - if self.gl_context != NULL: - SDL_GL_DeleteContext(self.gl_context) ++ if context != NULL: ++ SDL_GL_DeleteContext(context) + + if self.surface: + +@@ -274,7 +298,8 @@ cdef class Window: + # Necessary to collect the GL surface, doesn't hurt the window surface. + self.surface = None -@@ -334,8 +340,20 @@ cdef class Window: +- SDL_DestroyWindow(self.window) ++ if window != NULL: ++ SDL_DestroyWindow(window) + + def resize(self, size, opengl=False, fullscreen=None, maximized=None): + """ +@@ -334,8 +359,20 @@ cdef class Window: if self.gl_context == NULL: raise error() @@ -210,7 +249,7 @@ index 85d16d0..a732f2c 100644 def recreate_gl_context(self, always=False): """ Check to see if the GL context was lost, and re-create it if it was. -@@ -350,7 +368,9 @@ cdef class Window: +@@ -350,7 +387,9 @@ cdef class Window: if self.gl_context == NULL: raise error() @@ -336,10 +375,10 @@ index 889c908..bfd94f3 100644 return pixels[self.surface.h * self.surface.pitch] diff --git a/renpy/aether_mobile.py b/renpy/aether_mobile.py new file mode 100644 -index 00000000..017f1fb5 +index 0000000..58fb17c --- /dev/null +++ b/renpy/aether_mobile.py -@@ -0,0 +1,300 @@ +@@ -0,0 +1,358 @@ +"""Host-owned Ren'Py loop; all SDL/GL work stays on the calling thread. + +The native launcher links greenlet with its target CPython. No exception is @@ -364,6 +403,7 @@ index 00000000..017f1fb5 +_serial = 0 +_paused = False +_stopping = False ++_terminal_cleanup_done = False +_error = None +_input = [] +_buttons = [False, False, False] @@ -387,6 +427,8 @@ index 00000000..017f1fb5 + global _owner, _host, _game, _paused, _stopping, _error, _frame, _serial + if greenlet is None: + raise RuntimeError("Target CPython is missing the linked greenlet runtime") ++ if _terminal_cleanup_done: ++ raise RuntimeError("Ren'Py interpreter reuse is unavailable; restart the host application") + if _game is not None: + raise RuntimeError("Ren'Py mobile game is already started") + if not os.path.isdir(game_root) or not os.path.isfile(argv0): @@ -627,16 +669,122 @@ index 00000000..017f1fb5 +def cooperative_stop(): + global _game, _host, _owner, _paused, _stopping, _frame + _check_owner() -+ if _game is None: ++ if _game is None or _terminal_cleanup_done: + return + _stopping = True + _paused = False -+ try: ++ # A greenlet is false before its first switch and after termination. ++ # Throwing into a newborn greenlet would bypass _run's exception handler. ++ if _game: ++ import renpy.game ++ _game.throw(renpy.game.QuitException()) + if not _game.dead: -+ import renpy.game -+ _game.throw(renpy.game.QuitException()) ++ raise RuntimeError("Ren'Py game did not finish its terminal shutdown") ++ _terminal_display_cleanup() ++ _game = _host = _owner = _frame = None ++ _input.clear() ++ _keys.clear() ++ _buttons[:] = [False, False, False] ++ ++ ++def _terminal_display_cleanup(): ++ """Release a healthy session while native still owns its GL context. ++ ++ No renderer imports are performed here: initialization might have failed ++ before display or GL2 became available. This is terminal, not a reusable ++ interpreter or a context-loss recovery path. ++ """ ++ global _terminal_cleanup_done ++ pygame = sys.modules.get("renpy.pygame") ++ display = getattr(pygame, "display", None) ++ shader = sys.modules.get("renpy.gl2.gl2shader") ++ if shader is not None: ++ # Set before releasing any refs or deleting the SDL context: script ++ # stores and texture loaders may retain Programs after cache.clear(). ++ shader.retire_mobile_programs() ++ if display is None: ++ _terminal_cleanup_done = True ++ return ++ ++ display.prepare_mobile_shutdown() ++ renpy = sys.modules.get("renpy") ++ package = getattr(renpy, "display", None) ++ draw = getattr(package, "draw", None) ++ core = sys.modules.get("renpy.display.core") ++ try: ++ # bootstrap.finally already calls draw.quit(). Never repeat quit_fbo. ++ if draw is not None: ++ loader = getattr(draw, "texture_loader", None) ++ if loader is not None: ++ loader.retire_mobile() ++ cache = getattr(draw, "shader_cache", None) ++ if cache is not None: ++ cache.clear() ++ video = sys.modules.get("renpy.display.video") ++ if video is not None: ++ video.group_texture.clear() ++ interface = getattr(package, "interface", None) ++ if interface is not None and interface.started: ++ interface.kill_textures() ++ draw.texture_loader = draw.shader_cache = draw.window = None ++ package.draw = None + finally: -+ _game = _host = _owner = _frame = None -+ _input.clear() -+ _keys.clear() -+ _buttons[:] = [False, False, False] ++ if core is not None: ++ timers = (core.PERIODIC, core.REDRAW, core.TIMEEVENT) ++ for event_type in timers: ++ pygame.time.set_timer(event_type, 0) ++ if display.get_init(): ++ pygame.event.clear(timers) ++ display.quit() ++ _terminal_cleanup_done = True +diff --git a/renpy/gl2/gl2shader.pyx b/renpy/gl2/gl2shader.pyx +index 0aa2436..44a565c 100644 +--- a/renpy/gl2/gl2shader.pyx ++++ b/renpy/gl2/gl2shader.pyx +@@ -179,6 +179,16 @@ class Variable: + return (self.storage, self.type, self.name, self.array) == (other.storage, other.type, other.name, other.array) + + ++# Embedded sessions retain CPython after their SDL context is destroyed. A ++# Program can still be held by a texture loader, script store, or debugger. ++# Its late destructor must not delete an unrelated host-context program. ++cdef bint aether_terminal_programs_retired = False ++ ++def retire_mobile_programs(): ++ global aether_terminal_programs_retired ++ aether_terminal_programs_retired = True ++ ++ + cdef class Program: + """ + Represents an OpenGL program. +@@ -197,7 +207,8 @@ cdef class Program: + self.uniform_setters = [ ] + + def __dealloc__(self): +- glDeleteProgram(self.program) ++ if self.program and not aether_terminal_programs_retired: ++ glDeleteProgram(self.program) + + def find_variables(self, source, seen_uniforms: set, samplers: int): + +diff --git a/renpy/gl2/gl2texture.pyx b/renpy/gl2/gl2texture.pyx +index ec8c958..73a2bd5 100644 +--- a/renpy/gl2/gl2texture.pyx ++++ b/renpy/gl2/gl2texture.pyx +@@ -95,6 +95,15 @@ cdef class TextureLoader: + + self.allocated = set() + ++ def retire_mobile(self): ++ # CPU-only terminal teardown. SDL context destruction frees the GPU ++ # objects, including textures retained outside the render caches. ++ self.allocated.clear() ++ self.free_list.clear() ++ self.texture_load_queue.clear() ++ self.ftl_program = None ++ self.total_texture_size = 0 ++ + def get_texture_size(self): + """ + Returns the amount of memory locked up in textures. diff --git a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp index 7e64fde6b..a839fed29 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp @@ -102,7 +102,15 @@ int PresentRgba(void* user_data, const uint8_t* rgba, uint32_t width, void LogUtf8(void* user_data, int level, const char* message) { auto* runtime = static_cast(user_data); - if (runtime && message && level >= 3) runtime->error = message; + if (!runtime || !message) return; + if (level >= 3) runtime->error = message; + if (runtime->host.log) { + const uint32_t host_level = level >= 3 ? ENGINE_RUNTIME_LOG_ERROR + : level == 2 ? ENGINE_RUNTIME_LOG_WARNING + : level == 1 ? ENGINE_RUNTIME_LOG_INFO : ENGINE_RUNTIME_LOG_TRACE; + // The host persists this diagnostic before Destroy releases runtime. + runtime->host.log(runtime->host.user_data, host_level, "renpy-mobile", message); + } } engine_result_t CaptureFrame(MobileRuntime* runtime) { diff --git a/bridge/renpy_runtime/tests/mobile_lifecycle.cpp b/bridge/renpy_runtime/tests/mobile_lifecycle.cpp index ac51b7450..8613c42d3 100644 --- a/bridge/renpy_runtime/tests/mobile_lifecycle.cpp +++ b/bridge/renpy_runtime/tests/mobile_lifecycle.cpp @@ -115,9 +115,23 @@ mobile.cooperative_set_surface_size = lambda w, h: None }); wrong_thread.join(); assert(diagnostics > 0); // callback remains valid after caller table dies. + assert(PyRun_SimpleString("mobile._stop_failure = True\n" + "def stop():\n" + " mobile._stops += 1\n" + " if mobile._stop_failure: raise RuntimeError('terminal cleanup failed')\n" + "mobile.cooperative_stop = stop\n") == 0); + const int before_failure = diagnostics; + renpy_mobile_shutdown(); + assert(diagnostics > before_failure); // failures cannot pretend cleanup succeeded. + host.struct_size = sizeof(host); + host.abi_version = RENPY_MOBILE_LAUNCHER_ABI_VERSION; + assert(renpy_mobile_init(&config, &host) == RENPY_MOBILE_INVALID_STATE); + assert(PyRun_SimpleString("mobile._stop_failure = False") == 0); renpy_mobile_shutdown(); assert(Py_IsInitialized()); // the host owns interpreter/process lifetime. - assert(PythonLong("mobile._stops") == 1); + assert(PythonLong("mobile._stops") == 2); + renpy_mobile_shutdown(); + assert(PythonLong("mobile._stops") == 2); assert(renpy_mobile_tick(16) == RENPY_MOBILE_INVALID_STATE); Py_Finalize(); } diff --git a/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp b/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp index e25fe5614..b446e2e15 100644 --- a/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp +++ b/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace { const engine_runtime_provider_v1_t* provider; @@ -15,6 +16,18 @@ bool finish; bool push_frame = true; renpy_mobile_input_t last_input{}; std::string last_text; +struct LogEntry { + uint32_t level; + std::string subsystem; + std::string message; + bool during_shutdown; +}; +std::vector host_logs; +bool shutting_down; +void HostLog(void* user_data, uint32_t level, const char* subsystem, const char* message) { + assert(user_data == &host_logs); + host_logs.push_back({level, subsystem, message, shutting_down}); +} } // namespace // A controlled launcher verifies the provider protocol. This is explicitly @@ -49,13 +62,22 @@ extern "C" int renpy_mobile_resume() { return 0; } extern "C" int renpy_mobile_bind_window(void*) { return 0; } extern "C" int renpy_mobile_text_input_state(uint32_t* active) { *active = 1; return 0; } extern "C" int renpy_mobile_set_surface_size(uint32_t, uint32_t) { return 0; } -extern "C" void renpy_mobile_shutdown() { callbacks = nullptr; } +extern "C" void renpy_mobile_shutdown() { + assert(callbacks); + shutting_down = true; + callbacks->log_utf8(callbacks->user_data, 3, + "Ren'Py terminal cleanup failed; retained runtime resources require a host application restart"); + shutting_down = false; + callbacks = nullptr; +} int main() { aetherkiri::renpy::RegisterRuntimeProvider(); assert(provider && provider->probe(nullptr, "/project") > 0); engine_runtime_host_v1_t host{}; host.struct_size = sizeof(host); + host.log = HostLog; + host.user_data = &host_logs; engine_create_desc_t desc{}; desc.struct_size = sizeof(desc); void* runtime = nullptr; @@ -65,6 +87,19 @@ int main() { std::memset(project, 'x', sizeof(project) - 1); assert(starts == 0); // async Open cannot bind Python/greenlet to its thread. assert(provider->tick(runtime, 16) == ENGINE_RESULT_OK && starts == 1); + callbacks->log_utf8(callbacks->user_data, 0, "native trace"); + callbacks->log_utf8(callbacks->user_data, 1, "native info"); + callbacks->log_utf8(callbacks->user_data, 2, "native warning"); + callbacks->log_utf8(callbacks->user_data, 3, "native error"); + assert(host_logs.size() == 4); + assert(host_logs[0].level == ENGINE_RUNTIME_LOG_TRACE); + assert(host_logs[1].level == ENGINE_RUNTIME_LOG_INFO); + assert(host_logs[2].level == ENGINE_RUNTIME_LOG_WARNING); + assert(host_logs[3].level == ENGINE_RUNTIME_LOG_ERROR); + assert(std::strcmp(provider->get_last_error(runtime), "native error") == 0); + for (const auto& entry : host_logs) assert(entry.subsystem == "renpy-mobile"); + callbacks->log_utf8(callbacks->user_data, 3, nullptr); + assert(host_logs.size() == 4); uint32_t rendered = 0; assert(provider->get_frame_rendered_flag(runtime, &rendered) == ENGINE_RESULT_OK && rendered == 1); uint8_t pixels[4]{}; @@ -109,4 +144,10 @@ int main() { assert(std::strstr(debug, "\"exited\":true")); provider->destroy(runtime); assert(!callbacks); + // Destroy remains void. The launcher error reaches the owned host sink + // synchronously during shutdown and survives deletion of MobileRuntime. + assert(host_logs.size() == 5 && host_logs.back().during_shutdown); + assert(host_logs.back().level == ENGINE_RUNTIME_LOG_ERROR); + assert(host_logs.back().subsystem == "renpy-mobile"); + assert(host_logs.back().message.find("terminal cleanup failed") != std::string::npos); } diff --git a/tools/test_renpy_mobile_cooperative.py b/tools/test_renpy_mobile_cooperative.py index d1738f3dc..eb1ef2b2d 100755 --- a/tools/test_renpy_mobile_cooperative.py +++ b/tools/test_renpy_mobile_cooperative.py @@ -40,6 +40,7 @@ class QuitException(Exception): log = [] events = [] binds = [] +teardown = [] window = types.SimpleNamespace(get_size=lambda: (800, 600), get_sdl_window_pointer=lambda: types.SimpleNamespace(value=17)) pygame = types.ModuleType("renpy.pygame") @@ -50,13 +51,25 @@ class QuitException(Exception): pygame.event = types.SimpleNamespace(post=events.append, Event=lambda event_type, **values: dict(type=event_type, **values), get_mousewheel_buttons=lambda: True) pygame.display = types.SimpleNamespace(get_window=lambda: window) +pygame.display.prepare_mobile_shutdown = lambda: teardown.append("protect_host_display") +pygame.display.get_init = lambda: True +pygame.display.quit = lambda: teardown.append("delete_owned_window") +pygame.time = types.SimpleNamespace(set_timer=lambda event_type, interval: teardown.append((event_type, interval))) +pygame.event.clear = lambda event_types: teardown.append(("clear_timers", event_types)) +core = types.ModuleType("renpy.display.core") +core.PERIODIC, core.REDRAW, core.TIMEEVENT = 50, 51, 52 +sys.modules[core.__name__] = core +shader = types.ModuleType("renpy.gl2.gl2shader") +shader.retire_mobile_programs = lambda: teardown.append("retire_programs") +sys.modules[shader.__name__] = shader pygame.key = types.SimpleNamespace(text_input=True) renpy.pygame = pygame sys.modules["renpy.pygame"] = pygame sys.modules["_aether_host"] = types.SimpleNamespace(bind_window=lambda pointer: binds.append(pointer) or 0) surface = types.SimpleNamespace(get_bytesize=lambda: 4, get_size=lambda: (2, 1), aether_copy_pixels=lambda: b"\x01\x02\x03\xff\x04\x05\x06\xff") -interface = types.SimpleNamespace(started=True, mobile_save=lambda: log.append("save"), force_redraw=False) +interface = types.SimpleNamespace(started=True, mobile_save=lambda: log.append("save"), force_redraw=False, + kill_textures=lambda: teardown.append("clear_render_caches")) renpy.config = types.SimpleNamespace(screen_width=800, screen_height=600) renpy.display = types.SimpleNamespace(interface=interface, draw=types.SimpleNamespace( screenshot=lambda tree: surface, untranslate_point=lambda x, y: (round(x), round(y)))) @@ -128,6 +141,11 @@ def other_thread(): mobile.cooperative_stop() assert log.count("cleanup") == 1 and not mobile.cooperative_active() assert mobile.cooperative_tick(1) == "not_started" + assert teardown == ["retire_programs", "protect_host_display", "clear_render_caches", + (50, 0), (51, 0), (52, 0), ("clear_timers", (50, 51, 52)), "delete_owned_window"] + before = list(teardown) + mobile.cooperative_stop() + assert teardown == before, "repeated stop ran GPU/window teardown twice" # Validate official-entry dispatch and SystemExit handling independently. entry.write_text("def main():\n import sys\n import renpy\n renpy.entry_argv = list(sys.argv)\n raise SystemExit(0)\n") @@ -139,6 +157,80 @@ def other_thread(): assert renpy.entry_argv == [str(entry), str(root)] mobile.cooperative_stop() + def fresh_mobile(): + spec.loader.exec_module(mobile) + renpy.display.draw = types.SimpleNamespace( + shader_cache=types.SimpleNamespace(clear=lambda: teardown.append("clear_shader_cache")), + texture_loader=types.SimpleNamespace(retire_mobile=lambda: teardown.append("retire_loader")), + window=window) + teardown.clear() + return mobile + + # A real newborn greenlet is false and must not receive QuitException. + fresh_mobile() + mobile.cooperative_start(str(root), str(entry)) + assert not mobile._game and not mobile._game.dead + mobile.cooperative_stop() + assert not mobile.cooperative_active() + assert teardown.index("retire_programs") < teardown.index("retire_loader") < teardown.index("clear_shader_cache") + assert teardown[-1] == "delete_owned_window" + + # Paused destruction must still unwind the active game exactly once. + fresh_mobile() + def paused_game(*_): + try: + mobile.cooperative_checkpoint(force=True) + except QuitException: + pass + finally: + log.append("paused_finally") + mobile._run = paused_game + mobile.cooperative_start(str(root), str(entry)) + mobile.cooperative_tick(1) + mobile.cooperative_pause() + mobile.cooperative_stop() + assert log.count("paused_finally") == 1 and teardown[-1] == "delete_owned_window" + + # Startup errors return through _run; stop must release any partial display. + fresh_mobile() + entry.write_text("def main():\n raise RuntimeError('bootstrap failed')\n") + mobile.cooperative_start(str(root), str(entry)) + try: + mobile.cooperative_tick(1) + raise AssertionError("startup failure disappeared") + except RuntimeError as error: + assert "bootstrap failed" in str(error) + mobile.cooperative_stop() + assert teardown[-1] == "delete_owned_window" and not mobile.cooperative_active() + + # A failing teardown must remain observable and retryable, not clear owner. + fresh_mobile() + mobile.cooperative_start(str(root), str(entry)) + saved_quit = pygame.display.quit + def failed_quit(): + raise RuntimeError("owned display teardown failed") + pygame.display.quit = failed_quit + try: + mobile.cooperative_stop() + raise AssertionError("cleanup failure disappeared") + except RuntimeError as error: + assert "owned display teardown failed" in str(error) + assert mobile.cooperative_active() and not mobile._terminal_cleanup_done + pygame.display.quit = saved_quit + mobile.cooperative_stop() + assert not mobile.cooperative_active() + + # No renderer/display imports are added when startup never loaded them. + fresh_mobile() + mobile.cooperative_start(str(root), str(entry)) + del sys.modules["renpy.pygame"] + del sys.modules["renpy.gl2.gl2shader"] + mobile.cooperative_stop() + assert "renpy.pygame" not in sys.modules and "renpy.gl2.gl2shader" not in sys.modules + assert teardown == [] + sys.modules["renpy.pygame"] = pygame + sys.modules[shader.__name__] = shader + renderer_source = (args.renpy_src / "renpy/gl2/gl2draw.pyx").read_text() start = renderer_source.index(" gles = self.gles") end = renderer_source.index(" # Select the GL attributes and hints.", start) @@ -158,5 +250,5 @@ def other_thread(): if platform == "ios": assert context["window_flags"] & 12 == 12 -print("Cooperative checks passed: real greenlet stack preservation, deadline-before-start, Unicode/input, frame ownership, pause/resume, cleanup and mobile pbuffer dimensions") +print("Cooperative checks passed: real greenlet ownership, input/frame contracts, natural/paused/newborn/failed startup shutdown, terminal cleanup order, retry errors and idempotence") print("Synthetic engine boundaries only; Ren'Py/SDL/GL/device gameplay was not run") From 5b0d450cccc325eae908150c32f7c99841f3fcc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 16:29:59 +0000 Subject: [PATCH 275/331] test(renpy): reject missing mobile landmarks and retained native errors --- .../scripts/renpy_mobile_acceptance.gd | 77 +++-- demos/aetherkiri-renpy/game/script.rpy | 25 +- tools/run_renpy_android_device_acceptance.py | 199 ++++++++++++- tools/run_renpy_ios_device_acceptance.py | 46 ++- tools/test_renpy_device_evidence_metrics.py | 277 ++++++++++++++++++ 5 files changed, 591 insertions(+), 33 deletions(-) create mode 100644 tools/test_renpy_device_evidence_metrics.py diff --git a/apps/godot_app/scripts/renpy_mobile_acceptance.gd b/apps/godot_app/scripts/renpy_mobile_acceptance.gd index 9d655716a..462582b89 100644 --- a/apps/godot_app/scripts/renpy_mobile_acceptance.gd +++ b/apps/godot_app/scripts/renpy_mobile_acceptance.gd @@ -96,7 +96,7 @@ func _process(delta: float) -> void: rect.texture = texture if resumed_capture_pending: resumed_capture_pending = false - _capture_provider("resumed_ready") + _capture_provider("resumed_ready", _game_checkpoint("post_text_ready")) _sync_keyboard() _observe_game_checkpoints() @@ -207,15 +207,19 @@ func _game_checkpoints() -> Array: return rows while not file.eof_reached(): var parsed = JSON.parse_string(file.get_line()) - if parsed is Dictionary and String(parsed.get("run_id", "")) == run_id: + if parsed is Dictionary and String(parsed.get("run_id", "")) == run_id \ + and String(parsed.get("source", "")) == "renpy-script": rows.append(parsed) return rows func _has_game_checkpoint(stage: String) -> bool: + return not _game_checkpoint(stage).is_empty() + +func _game_checkpoint(stage: String) -> Dictionary: for row in _game_checkpoints(): if String(row.get("stage", "")) == stage: - return true - return false + return row + return {} func _observe_game_checkpoints() -> void: var rows := _game_checkpoints() @@ -224,30 +228,68 @@ func _observe_game_checkpoints() -> void: checkpoint_count += 1 _record({"kind": "checkpoint_seen", "checkpoint": row}) if String(row.get("stage", "")) in ["start_ready", "post_text_ready", "quit_ready"]: - _capture_provider(String(row.get("stage", ""))) + _capture_provider(String(row.get("stage", "")), row) + +func _marker_rendered(frame: Dictionary, checkpoint: Dictionary) -> bool: + var details: Dictionary = checkpoint.get("details", {}) + var canvas: Array = details.get("logical_canvas", []) + var bounds: Array = details.get("marker_bounds", []) + var rgb: Array = details.get("marker_rgb", []) + if canvas.size() != 2 or bounds.size() != 4 or rgb.size() != 3 \ + or float(canvas[0]) <= 0 or float(canvas[1]) <= 0: + return false + var width := int(frame.get("width", 0)) + var height := int(frame.get("height", 0)) + var stride := int(frame.get("stride_bytes", 0)) + var rgba: PackedByteArray = frame.get("rgba", PackedByteArray()) + if width <= 0 or height <= 0 or stride < width * 4 or rgba.size() < stride * height: + return false + # Check actual pixels away from the marker's centered white label. The + # script writes its checkpoint before call-screen: that alone is not proof + # that this scene has reached the real renderer yet. + for u in [0.15, 0.85]: + for v in [0.25, 0.5, 0.75]: + var x := int((float(bounds[0]) + u * float(bounds[2])) * width / float(canvas[0])) + var y := int((float(bounds[1]) + v * float(bounds[3])) * height / float(canvas[1])) + if x < 0 or x >= width or y < 0 or y >= height: + return false + var offset := y * stride + x * 4 + for channel in range(3): + if absi(int(rgba[offset + channel]) - int(rgb[channel])) > 24: + return false + return true -func _capture_provider(stage: String) -> void: +func _capture_provider(stage: String, checkpoint: Dictionary) -> void: + var game_stage := "post_text_ready" if stage == "resumed_ready" else stage + if String(checkpoint.get("source", "")) != "renpy-script" \ + or String(checkpoint.get("run_id", "")) != run_id \ + or String(checkpoint.get("stage", "")) != game_stage: + _fail("Provider capture lacks the actual executed Ren'Py checkpoint for %s." % stage) + return # Let the interaction settle, then wait for keyboard animation and a fresh # native redraw after resume. Old pre-pause pixels cannot prove recovery. var expires := mini(deadline, Time.get_ticks_msec() + 15000) + for _frame in range(3): + await get_tree().process_frame + var frame: Dictionary = {} while Time.get_ticks_msec() < expires: if finished or player == null: return var keyboard_height := DisplayServer.virtual_keyboard_get_height() var fresh := stage != "resumed_ready" or int(_provider_debug().get("frame_serial", 0)) > paused_frame_serial if not paused and not keyboard_active and keyboard_height <= 0 and fresh: - break + # Read after a completed real draw, then require the script's + # current visible marker rather than accepting an earlier frame. + await RenderingServer.frame_post_draw + if finished or player == null: + return + frame = player.read_frame_rgba() + if _marker_rendered(frame, checkpoint): + break await get_tree().process_frame - if Time.get_ticks_msec() >= expires: - _fail("Native presentation did not settle for %s after keyboard/lifecycle change." % stage) - return - for _frame in range(3): - await get_tree().process_frame - # This is the real renderer's completed draw, not a fabricated screenshot. - await RenderingServer.frame_post_draw - if finished or player == null: + if Time.get_ticks_msec() >= expires or not _marker_rendered(frame, checkpoint): + _fail("Native presentation did not render %s's actual script marker after keyboard/lifecycle change." % stage) return - var frame: Dictionary = player.read_frame_rgba() var width := int(frame.get("width", 0)) var height := int(frame.get("height", 0)) var stride := int(frame.get("stride_bytes", 0)) @@ -274,7 +316,8 @@ func _capture_provider(stage: String) -> void: "path": path, "width": width, "height": height, "rgba_bytes": rgba.size(), "native_stride_bytes": stride, "frame_serial": int(frame.get("frame_serial", 0)), "drawn_box": [origin.x, origin.y, drawn_size.x, drawn_size.y], - "viewport_size": [viewport_size.x, viewport_size.y], "provider_debug": _provider_debug()}) + "viewport_size": [viewport_size.x, viewport_size.y], "provider_debug": _provider_debug(), + "game_checkpoint": checkpoint}) func _provider_debug() -> Dictionary: var raw := String(player.get_plugin_debug_info()) diff --git a/demos/aetherkiri-renpy/game/script.rpy b/demos/aetherkiri-renpy/game/script.rpy index ed33f65b5..1320a1782 100644 --- a/demos/aetherkiri-renpy/game/script.rpy +++ b/demos/aetherkiri-renpy/game/script.rpy @@ -24,9 +24,24 @@ init python: # this game's actual start label, independently of SDK menu layout. os.environ["RENPY_SKIP_MAIN_MENU"] = "1" + # Only the private device flow renders these landmarks. Each color changes + # in the executed game, independently of the host's lifecycle observations. + aether_device_markers = { + "start_ready": ("START", "#28b4d2", [40, 180, 210]), + "post_text_ready": ("WAIT", "#416add", [65, 106, 221]), + "quit_ready": ("RESUMED", "#eba440", [235, 164, 64]), + } + def aether_device_checkpoint(stage, **details): if not aether_device_request.get("run_id"): return + if stage in aether_device_markers: + marker_name, marker_color, marker_rgb = aether_device_markers[stage] + details.update(logical_canvas=[config.screen_width, config.screen_height], + button_bounds=[360, 222, 240, 96], + button_rgbs=[[23, 52, 76], [37, 106, 136]], + marker_bounds=[360, 350, 240, 40], + marker_name=marker_name, marker_rgb=marker_rgb) entry = dict(run_id=aether_device_request["run_id"], stage=stage, source="renpy-script", monotonic=time.monotonic(), details=details) with open(os.path.join(config.gamedir, "aether-device-checkpoints.jsonl"), "a") as checkpoint_file: @@ -34,7 +49,7 @@ init python: checkpoint_file.flush() os.fsync(checkpoint_file.fileno()) -screen fixture_device_button(caption): +screen fixture_device_button(caption, marker_name, marker_color): modal True text "AetherKiri real Ren'Py device acceptance" xpos 48 ypos 168 size 28 color "#ffffff" textbutton caption: @@ -46,6 +61,8 @@ screen fixture_device_button(caption): background Solid("#17344c") hover_background Solid("#256a88") action Return() + add Solid(marker_color) xpos 360 ypos 350 xsize 240 ysize 40 + text marker_name xpos 480 ypos 370 anchor (0.5, 0.5) size 24 color "#ffffff" screen fixture_start(): modal True @@ -102,7 +119,7 @@ label interaction: label aether_device_acceptance: scene bg fixture $ aether_device_checkpoint("start_ready", button=[480, 270]) - call screen fixture_device_button("Start") + call screen fixture_device_button("Start", aether_device_markers["start_ready"][0], aether_device_markers["start_ready"][1]) $ aether_device_checkpoint("touch_received") $ aether_device_checkpoint("text_ready") $ player_name = renpy.input("Enter the cloud acceptance name:", default="", length=32) @@ -110,10 +127,10 @@ label aether_device_acceptance: # The host must background and resume the real application while this # interaction is waiting, then deliver another real system touch. $ aether_device_checkpoint("post_text_ready", button=[480, 270]) - call screen fixture_device_button("Continue after resume") + call screen fixture_device_button("Continue after resume", aether_device_markers["post_text_ready"][0], aether_device_markers["post_text_ready"][1]) $ aether_device_checkpoint("resumed_touch_received") $ aether_device_checkpoint("quit_ready", button=[480, 270]) - call screen fixture_device_button("Quit") + call screen fixture_device_button("Quit", aether_device_markers["quit_ready"][0], aether_device_markers["quit_ready"][1]) $ aether_device_checkpoint("quit_requested") $ renpy.quit(confirm=False) return diff --git a/tools/run_renpy_android_device_acceptance.py b/tools/run_renpy_android_device_acceptance.py index c54f040bc..7606c9932 100644 --- a/tools/run_renpy_android_device_acceptance.py +++ b/tools/run_renpy_android_device_acceptance.py @@ -11,6 +11,7 @@ import argparse import hashlib import json +import math import re import shlex import shutil @@ -112,6 +113,170 @@ def image_stats(image: tuple[int, int, bytes, int]) -> dict: return stats +def is_script_checkpoint(row: object, stage: str, run_id: str) -> bool: + return (isinstance(row, dict) and row.get("run_id") == run_id + and row.get("source") == "renpy-script" and row.get("stage") == stage) + + +def valid_rgb(rgb: object) -> bool: + # Godot JSON round-trips integral channels as 40.0 rather than 40. Accept + # that representation, while rejecting bools, fractions and invalid RGB. + return (isinstance(rgb, list) and len(rgb) == 3 + and all(not isinstance(v, bool) and isinstance(v, (int, float)) + and 0 <= v <= 255 and int(v) == v for v in rgb)) + + +def checkpoint_landmarks(checkpoint: dict) -> dict: + """Read geometry and marker identity from the executed game's checkpoint.""" + if checkpoint.get("source") != "renpy-script": + raise Failed("Landmarks do not originate from an executed Ren'Py checkpoint") + expected_name = {"start_ready": "START", "post_text_ready": "WAIT", "quit_ready": "RESUMED"}.get(checkpoint.get("stage")) + details = checkpoint.get("details", {}) + if not isinstance(details, dict) or not expected_name or details.get("marker_name") != expected_name: + raise Failed("Checkpoint lacks its executed scene's stage marker") + canvas = details.get("logical_canvas", []) + if (not isinstance(canvas, list) or len(canvas) != 2 + or any(isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) or v <= 0 for v in canvas)): + raise Failed("Checkpoint lacks a valid logical game canvas") + for name in ("button_bounds", "marker_bounds"): + bounds = details.get(name, []) + if (not isinstance(bounds, list) or len(bounds) != 4 + or any(isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) for v in bounds) + or min(bounds[:2]) < 0 or min(bounds[2:]) <= 8 + or bounds[0] + bounds[2] > canvas[0] or bounds[1] + bounds[3] > canvas[1]): + raise Failed(f"Checkpoint has invalid {name} within its logical canvas") + rgb = details.get("marker_rgb", []) + if not valid_rgb(rgb): + raise Failed("Checkpoint lacks its actual marker's RGB color") + button_rgbs = details.get("button_rgbs", []) + if (not isinstance(button_rgbs, list) or not button_rgbs + or any(not valid_rgb(rgb) for rgb in button_rgbs)): + raise Failed("Checkpoint lacks its actual idle/hover button RGB colors") + return details + + +def landmark_samples(canvas: list, bounds: list): + # Exclude the outer four logical pixels where filtering/shadows can mix + # game and background. Sample densely throughout each remaining landmark. + x, y, width, height = bounds + columns = min(48, max(9, int((width - 8) / 5))) + rows = min(32, max(7, int((height - 8) / 5))) + for iy in range(rows): + for ix in range(columns): + yield ((x + 4 + (ix + 0.5) * (width - 8) / columns) / canvas[0], + (y + 4 + (iy + 0.5) * (height - 8) / rows) / canvas[1]) + + +def verify_landmark_pixels(provider: tuple, screen: tuple, frame: dict, checkpoint: dict) -> dict: + details = checkpoint_landmarks(checkpoint) + box, viewport = frame["drawn_box"], frame["viewport_size"] + if (len(box) != 4 or len(viewport) != 2 + or any(not isinstance(v, (int, float)) or not math.isfinite(v) for v in box + viewport) + or min(box[2:]) <= 0 or min(viewport) <= 0): + raise Failed("Landmark comparison lacks finite on-device presentation geometry") + metrics = {} + for name in ("button", "marker"): + matches = count = expected_color = 0 + expected_rgbs = details["button_rgbs"] if name == "button" else [details["marker_rgb"]] + for u, v in landmark_samples(details["logical_canvas"], details[name + "_bounds"]): + native = pixel(provider, int(u * provider[0]), int(v * provider[1])) + sx = int((box[0] + u * box[2]) * screen[0] / viewport[0]) + sy = int((box[1] + v * box[3]) * screen[1] / viewport[1]) + # The original sample must be within the OS image. Nearby actual + # pixels accommodate a subpixel linear-filter/antialiasing shift. + pixel(screen, sx, sy) + matches += any(max(abs(a - b) for a, b in zip(native, pixel(screen, nx, ny))) <= 24 + for ny in range(max(0, sy - 1), min(screen[1], sy + 2)) + for nx in range(max(0, sx - 1), min(screen[0], sx + 2))) + expected_color += any(max(abs(a - b) for a, b in zip(native, rgb)) <= 24 for rgb in expected_rgbs) + count += 1 + metrics[name] = {"logical_bounds": details[name + "_bounds"], "logical_inset": 4, + "matching_samples": matches, "samples": count, "fraction": matches / count, + "required_fraction": 0.90, "channel_tolerance": 24, "os_neighbor_radius": 1, + "native_required_color_fraction": 0.70} + if name == "marker": + metrics[name].update(name=details["marker_name"], expected_rgb=details["marker_rgb"], + native_expected_color_fraction=expected_color / count) + else: + metrics[name].update(expected_rgbs=details["button_rgbs"], + native_expected_color_fraction=expected_color / count) + # Return metrics even when an ROI fails so the caller can preserve evidence + # before rejecting. Solid button/marker fills leave room for actual labels. + return metrics + + +def require_landmark_pixels(metrics: dict) -> None: + for name in ("button", "marker"): + if metrics[name]["fraction"] < 0.90: + raise Failed(f"Actual OS screenshot does not present the native {name} ROI: {metrics[name]}") + if metrics["marker"]["native_expected_color_fraction"] < 0.70: + raise Failed("Native RGBA does not render the executed script's stage marker; a stale frame cannot pass") + if metrics["button"]["native_expected_color_fraction"] < 0.70: + raise Failed("Native RGBA lacks the executed script's visible idle/hover button; matching background cannot pass") + + +def verify_resumed_marker(before: tuple, before_checkpoint: dict, after: tuple, after_checkpoint: dict) -> dict: + earlier = checkpoint_landmarks(before_checkpoint) + later = checkpoint_landmarks(after_checkpoint) + if earlier["marker_name"] != "WAIT" or later["marker_name"] != "RESUMED": + raise Failed("Resume evidence must compare actual WAIT and RESUMED script-rendered markers") + changed = count = 0 + previous_samples = landmark_samples(earlier["logical_canvas"], earlier["marker_bounds"]) + current_samples = landmark_samples(later["logical_canvas"], later["marker_bounds"]) + if earlier["logical_canvas"] != later["logical_canvas"] or earlier["marker_bounds"] != later["marker_bounds"]: + raise Failed("The executed fixture's marker geometry changed unexpectedly during resume") + for (u0, v0), (u1, v1) in zip(previous_samples, current_samples): + previous = pixel(before, int(u0 * before[0]), int(v0 * before[1])) + current = pixel(after, int(u1 * after[0]), int(v1 * after[1])) + changed += max(abs(a - b) for a, b in zip(previous, current)) > 24 + count += 1 + return {"source": "actual-native-rgba-before-and-after-resumed-os-touch", "before": "WAIT", "after": "RESUMED", + "changed_samples": changed, "samples": count, "fraction": changed / count, + "required_fraction": 0.70, "channel_tolerance": 24} + + +def read_engine_log_file(path: Path) -> bytes: + try: + return path.read_bytes() + except OSError as exc: + raise Failed(f"Actual per-game engine log is missing or unreadable: {path}: {exc}") from exc + + +def require_engine_log(raw: bytes, game_root: str) -> dict: + """Reject sidecar errors after void native shutdown; do not infer its status.""" + if not isinstance(raw, bytes) or not raw.strip(): + raise Failed("Actual per-game engine log is missing, unreadable or empty") + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise Failed("Actual per-game engine log is not readable UTF-8") from exc + path = game_root.rstrip("/") + "/aetherkiri-engine.log" + # Both harnesses remove and recreate this private test root before launch. + # The existing, flushed dispatcher attachment line independently ties the + # retained file to that root, rather than accepting another game's log. + attachment = "aetherkiri provider engine log attached: " + path + if not any(line.rstrip().endswith(attachment) for line in text.splitlines()): + raise Failed("Engine log lacks the existing dispatcher's attachment to this freshly staged game root") + records, errors = 0, [] + diagnostics = ("terminal cleanup failed", "private egl context could not be made current", + "failed to restore the host egl context", "lifecycle called from a different thread than init") + for number, line in enumerate(text.splitlines(), 1): + record = re.search(r"\b(trace|debug|info|warning|error)\s+\[renpy-mobile\]\s*(.*)", line, re.IGNORECASE) + if record is None: + continue + records += 1 + level, message = record.groups() + if (level.lower() == "error" or any(detail in message.lower() for detail in diagnostics) + or message.strip() == "cooperative_stop"): + errors.append({"line": number, "record": line}) + if errors: + raise Failed("Native Ren'Py cleanup/runtime errors were retained in aetherkiri-engine.log: " + + json.dumps(errors, ensure_ascii=False)) + return {"source": "actual-flushed-provider-game-log", "path": path, "bytes": len(raw), + "renpy_mobile_records": records, "renpy_mobile_error_records": 0, + "verification": "no-recorded-native-errors; void-shutdown-status-unavailable"} + + class Acceptance: def __init__(self, args: argparse.Namespace): self.args = args @@ -124,6 +289,7 @@ def __init__(self, args: argparse.Namespace): self.pid = "" self.installed = False self.execution_deadline = 0.0 + self.waiting_marker = None self.summary = {"status": "not_run", "run_id": self.run_id, "source": "actual-apk-adb-cloud-device", "serial": args.serial, "package": args.package, "checks": [], "blocker": ""} @@ -183,7 +349,7 @@ def wait(self, description: str, condition, timeout: int | None = None): def wait_game(self, stage: str) -> dict: return self.wait(f"executed Ren'Py checkpoint {stage}", lambda: next( - (row for row in self.game_rows() if row.get("stage") == stage and row.get("source") == "renpy-script"), None)) + (row for row in self.game_rows() if is_script_checkpoint(row, stage, self.run_id)), None)) def wait_observer(self, kind: str, **fields) -> dict: return self.wait(f"native observer {kind} {fields}", lambda: next( @@ -236,19 +402,36 @@ def capture(self, stage: str, *, screenshot_name: str | None = None) -> tuple[di count += 1 stats["native_to_os_match"] = {"matching_samples": matches, "samples": count, "fraction": matches / count, "channel_tolerance": 24} + game_stage = "post_text_ready" if stage == "resumed_ready" else stage + checkpoint = self.wait_game(game_stage) + observed = row.get("game_checkpoint", {}) + if (not is_script_checkpoint(observed, game_stage, self.run_id) + or observed.get("details") != checkpoint.get("details")): + raise Failed("Provider capture's landmarks do not match the independently read Ren'Py checkpoint") + stats["landmarks"] = verify_landmark_pixels(provider, screen, row, checkpoint) + if stage == "quit_ready": + if self.waiting_marker is None: + raise Failed("Missing independently captured native WAIT marker before OS pause") + stats["resumed_marker_change"] = verify_resumed_marker(*self.waiting_marker, provider, checkpoint) (self.output / f"pixels-{screenshot_name or stage}.json").write_text(json.dumps(stats, indent=2) + "\n") if matches / count < 0.70: raise Failed(f"OS screenshot does not show the native Ren'Py frame at {stage}: {matches}/{count} samples") + require_landmark_pixels(stats["landmarks"]) + if stage == "quit_ready" and stats["resumed_marker_change"]["fraction"] < 0.70: + raise Failed("Native marker did not change after actual resumed OS touch; cached WAIT pixels cannot pass") + if stage == "post_text_ready": + self.waiting_marker = provider, checkpoint self.summary["checks"].append({"stage": screenshot_name or stage, "native_provider_pixels_and_os_screen": stats}) return row, screen def tap_button(self, frame: dict, screen: tuple) -> None: box, viewport = frame["drawn_box"], frame["viewport_size"] - # The real fixture's button center is (480, 270) in its logical - # 960x540 canvas. Native framebuffer pixels may use another physical - # resolution, so address the displayed center through normalized UV. - x = round((box[0] + 0.5 * box[2]) * screen[0] / viewport[0]) - y = round((box[1] + 0.5 * box[3]) * screen[1] / viewport[1]) + details = checkpoint_landmarks(frame["game_checkpoint"]) + bx, by, bw, bh = details["button_bounds"] + u = (bx + bw / 2) / details["logical_canvas"][0] + v = (by + bh / 2) / details["logical_canvas"][1] + x = round((box[0] + u * box[2]) * screen[0] / viewport[0]) + y = round((box[1] + v * box[3]) * screen[1] / viewport[1]) self.shell("input", "tap", str(x), str(y)) def run(self) -> None: @@ -352,6 +535,9 @@ def run(self) -> None: {"real_os_touch_and_keyboard_text": typed}, {"real_os_pause_resume": {"pause": pause, "resume": resume}}, {"explicit_renpy_quit_and_engine_destroy": exit_row}]) + raw = self.private("files/renpy-device-demo/aetherkiri-engine.log", required=True) + (self.output / "aetherkiri-engine.log").write_bytes(raw) + self.summary["checks"].append({"native_cleanup_error_log": require_engine_log(raw, self.game_root)}) self.summary["status"] = "passed" def visible_ime(self) -> str: @@ -399,6 +585,7 @@ def collect(self) -> None: if not self.installed: return for relative, local in [ + ("files/renpy-device-demo/aetherkiri-engine.log", "aetherkiri-engine.log"), ("files/renpy-device-evidence/observer.jsonl", "observer.jsonl"), ("files/renpy-device-demo/game/aether-device-checkpoints.jsonl", "renpy-script-checkpoints.jsonl"), ("files/renpy-device-demo/log.txt", "renpy-log.txt"), diff --git a/tools/run_renpy_ios_device_acceptance.py b/tools/run_renpy_ios_device_acceptance.py index a99ff5613..5b973c27c 100755 --- a/tools/run_renpy_ios_device_acceptance.py +++ b/tools/run_renpy_ios_device_acceptance.py @@ -20,7 +20,10 @@ import uuid from pathlib import Path -from run_renpy_android_device_acceptance import Blocked, Failed, image_stats, pixel, png_pixels +from run_renpy_android_device_acceptance import ( + Blocked, Failed, checkpoint_landmarks, image_stats, is_script_checkpoint, pixel, png_pixels, + read_engine_log_file, require_engine_log, require_landmark_pixels, verify_landmark_pixels, verify_resumed_marker, +) class Acceptance: @@ -39,6 +42,7 @@ def __init__(self, args: argparse.Namespace): self.process: subprocess.Popen | None = None self.process_log = None self.deadline = 0.0 + self.waiting_marker = None self.keyboard_preference: str | None = None self.preference_changed = False self.summary = {"status": "not_run", "run_id": self.run_id, @@ -101,7 +105,8 @@ def wait(self, description: str, condition, timeout: int | None = None): raise Failed(f"Timed out waiting for {description}; no gameplay evidence was synthesized") def wait_game(self, stage: str) -> dict: - return self.wait(f"Ren'Py {stage}", lambda: next((r for r in self.game_rows() if r.get("stage") == stage), None)) + return self.wait(f"executed Ren'Py {stage}", lambda: next( + (r for r in self.game_rows() if is_script_checkpoint(r, stage, self.run_id)), None)) def wait_observer(self, kind: str, **fields) -> dict: return self.wait(f"native observer {kind} {fields}", lambda: next((r for r in self.observer_rows() @@ -166,15 +171,35 @@ def capture(self, stage: str) -> dict: count += 1 stats["native_to_os_match"] = {"matching_samples": matches, "samples": count, "fraction": matches / count, "channel_tolerance": 24} + game_stage = "post_text_ready" if stage == "resumed_ready" else stage + checkpoint = self.wait_game(game_stage) + captured_checkpoint = row.get("game_checkpoint", {}) + if (not is_script_checkpoint(captured_checkpoint, game_stage, self.run_id) + or captured_checkpoint.get("details") != checkpoint.get("details")): + raise Failed("Native frame is not tied to the independently read executed Ren'Py scene checkpoint") + stats["landmarks"] = verify_landmark_pixels(provider, screen, row, checkpoint) + if stage == "quit_ready": + if self.waiting_marker is None: + raise Failed("No actual pre-pause native WAIT marker was captured") + stats["resumed_marker_change"] = verify_resumed_marker(*self.waiting_marker, provider, checkpoint) (self.output / f"pixels-{stage}.json").write_text(json.dumps(stats, indent=2) + "\n") if matches / count < 0.70: raise Failed(f"Actual OS screenshot does not present Ren'Py native pixels at {stage}: {matches}/{count}") + require_landmark_pixels(stats["landmarks"]) + if stage == "quit_ready" and stats["resumed_marker_change"]["fraction"] < 0.70: + raise Failed("Native marker did not visibly change after the actual resumed OS touch") + if stage == "post_text_ready": + self.waiting_marker = (provider, checkpoint) self.summary["checks"].append({"stage": stage, "native_provider_pixels_and_os_screen": stats}) return row def tap_button(self, frame: dict) -> None: box, viewport = frame["drawn_box"], frame["viewport_size"] - self.ui("tap", x=(box[0] + box[2] * 0.5) / viewport[0], y=(box[1] + box[3] * 0.5) / viewport[1]) + details = checkpoint_landmarks(frame["game_checkpoint"]) + bx, by, bw, bh = details["button_bounds"] + u = (bx + bw / 2) / details["logical_canvas"][0] + v = (by + bh / 2) / details["logical_canvas"][1] + self.ui("tap", x=(box[0] + box[2] * u) / viewport[0], y=(box[1] + box[3] * v) / viewport[1]) def create_simulator(self) -> None: runtimes = json.loads(self.simctl("list", "runtimes", "--json")) @@ -378,6 +403,10 @@ def run(self) -> None: self.summary["checks"].extend([{"real_ios_touch_and_keyboard_text": typed}, {"real_ios_pause_resume": {"pause": pause, "resume": resume}}, {"explicit_renpy_exit_and_actual_library_return": {"native": normal_exit, "library": library}}]) + game_root = self.documents / "renpy-device-demo" + raw = read_engine_log_file(game_root / "aetherkiri-engine.log") + (self.output / "aetherkiri-engine.log").write_bytes(raw) + self.summary["checks"].append({"native_cleanup_error_log": require_engine_log(raw, str(game_root))}) self.summary["status"] = "passed" def verify_library_return(self, report: dict) -> None: @@ -419,13 +448,18 @@ def verify_library_return(self, report: dict) -> None: def collect(self) -> None: if self.documents is not None: for relative, filename in [ + ("renpy-device-demo/aetherkiri-engine.log", "aetherkiri-engine.log"), ("renpy-device-evidence/observer.jsonl", "observer.jsonl"), ("renpy-device-demo/game/aether-device-checkpoints.jsonl", "renpy-script-checkpoints.jsonl"), ("renpy-device-demo/log.txt", "renpy-log.txt"), ("renpy-device-demo/traceback.txt", "renpy-traceback.txt")]: - source = self.documents / relative - if source.is_file(): - shutil.copyfile(source, self.output / filename) + try: + source = self.documents / relative + if source.is_file(): + shutil.copyfile(source, self.output / filename) + except OSError as exc: + with (self.output / "collection-errors.jsonl").open("a") as stream: + stream.write(json.dumps({"path": relative, "error": str(exc)}) + "\n") if self.process is not None and self.process.poll() is None: self.process.terminate() try: diff --git a/tools/test_renpy_device_evidence_metrics.py b/tools/test_renpy_device_evidence_metrics.py new file mode 100644 index 000000000..067383880 --- /dev/null +++ b/tools/test_renpy_device_evidence_metrics.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Synthetic scientific fixtures for the mobile evidence gates, not gameplay. + +These byte arrays are explicitly generated test data. No device, renderer, +provider, adb or XCUITest execution is substituted or claimed by these tests. +""" + +from __future__ import annotations + +import copy +import functools +import tempfile +import unittest +from pathlib import Path + +from run_renpy_android_device_acceptance import ( + Failed, checkpoint_landmarks, image_stats, is_script_checkpoint, pixel, + read_engine_log_file, require_engine_log, require_landmark_pixels, verify_landmark_pixels, verify_resumed_marker, +) + + +COLORS = {"start_ready": ("START", [40, 180, 210]), + "post_text_ready": ("WAIT", [65, 106, 221]), + "quit_ready": ("RESUMED", [235, 164, 64])} + + +def checkpoint(stage="start_ready"): + name, rgb = COLORS[stage] + return {"source": "renpy-script", "run_id": "synthetic-metric-fixture", "stage": stage, + "details": {"logical_canvas": [960, 540], "button_bounds": [360, 222, 240, 96], + "button_rgbs": [[23, 52, 76], [37, 106, 136]], + "marker_bounds": [360, 350, 240, 40], "marker_name": name, "marker_rgb": rgb}} + + +@functools.lru_cache(maxsize=None) +def synthetic_native(stage="start_ready", button=True, hover=False): + # Half-resolution RGB data exercises logical-to-native conversion as well + # as the separate native-to-OS letterbox transform. + width, height = 480, 270 + data = bytearray(width * height * 3) + marker = COLORS[stage][1] + for y in range(height): + for x in range(width): + lx, ly = 2 * x, 2 * y + rgb = [20 + x * 135 // width, 25 + y * 165 // height, + 45 + (x + y) * 105 // (width + height)] + if button and 360 <= lx < 600 and 222 <= ly < 318: + rgb = [37, 106, 136] if hover else [23, 52, 76] + # A small high-contrast synthetic label exercises 1px sampling + # tolerance without treating text as another flat background. + if 418 <= lx < 542 and 254 <= ly < 282 and (x // 3) % 2: + rgb = [255, 255, 255] + if 360 <= lx < 600 and 350 <= ly < 390: + rgb = marker + if 436 <= lx < 524 and 362 <= ly < 378 and (x // 3) % 2: + rgb = [255, 255, 255] + start = (y * width + x) * 3 + data[start:start + 3] = bytes(rgb) + return width, height, bytes(data), 3 + + +def synthetic_os(native, frame, size=(960, 540), shift=(0, 0), color_offset=0): + width, height = size + box, viewport = frame["drawn_box"], frame["viewport_size"] + data = bytearray(width * height * 3) + for y in range(height): + v = ((y + 0.5 - shift[1]) * viewport[1] / height - box[1]) / box[3] + if not 0 <= v < 1: + continue + ny = min(native[1] - 1, int(v * native[1])) + for x in range(width): + u = ((x + 0.5 - shift[0]) * viewport[0] / width - box[0]) / box[2] + if not 0 <= u < 1: + continue + nx = min(native[0] - 1, int(u * native[0])) + rgb = pixel(native, nx, ny) + start = (y * width + x) * 3 + data[start:start + 3] = bytes(min(255, max(0, c + color_offset)) for c in rgb) + return width, height, bytes(data), 3 + + +def legacy_global_fraction(native, screen, frame): + # Reproduce the retained pre-existing 77-point whole-scene proof limit. + box, viewport = frame["drawn_box"], frame["viewport_size"] + matches = count = 0 + for iy in range(1, 8): + for ix in range(1, 12): + u, v = ix / 12, iy / 8 + expected = pixel(native, int(u * native[0]), int(v * native[1])) + actual = pixel(screen, int((box[0] + u * box[2]) * screen[0] / viewport[0]), + int((box[1] + v * box[3]) * screen[1] / viewport[1])) + matches += max(abs(a - b) for a, b in zip(expected, actual)) <= 24 + count += 1 + return matches, count + + +class EvidenceMetrics(unittest.TestCase): + full_frame = {"drawn_box": [0, 0, 1280, 720], "viewport_size": [1280, 720]} + + def test_positive_dense_roi_with_label_and_color_tolerance(self): + native = synthetic_native() + screen = synthetic_os(native, self.full_frame, color_offset=12) + metrics = verify_landmark_pixels(native, screen, self.full_frame, checkpoint()) + require_landmark_pixels(metrics) + self.assertGreater(metrics["button"]["samples"], 700) + self.assertGreater(metrics["marker"]["samples"], 300) + self.assertEqual(metrics["button"]["fraction"], 1) + + def test_missing_entire_button_passes_global_but_fails_roi(self): + native = synthetic_native() + screen = synthetic_os(synthetic_native(button=False), self.full_frame) + image_stats(native) + image_stats(screen) + matches, count = legacy_global_fraction(native, screen, self.full_frame) + self.assertEqual((matches, count), (74, 77)) + self.assertGreater(matches / count, 0.70) + metrics = verify_landmark_pixels(native, screen, self.full_frame, checkpoint()) + self.assertLess(metrics["button"]["fraction"], 0.10) + with self.assertRaises(Failed): + require_landmark_pixels(metrics) + + def test_native_and_os_both_missing_button_cannot_pass(self): + native = synthetic_native(button=False) + screen = synthetic_os(native, self.full_frame) + metrics = verify_landmark_pixels(native, screen, self.full_frame, checkpoint()) + self.assertEqual(metrics["button"]["fraction"], 1) + self.assertLess(metrics["button"]["native_expected_color_fraction"], 0.70) + with self.assertRaises(Failed): + require_landmark_pixels(metrics) + + def test_hover_button_remains_valid(self): + native = synthetic_native(hover=True) + metrics = verify_landmark_pixels(native, synthetic_os(native, self.full_frame), self.full_frame, checkpoint()) + self.assertGreaterEqual(metrics["button"]["native_expected_color_fraction"], 0.70) + require_landmark_pixels(metrics) + + def test_stale_wait_marker_rejected_even_when_os_matches_native(self): + stale = synthetic_native("post_text_ready") + screen = synthetic_os(stale, self.full_frame) + metrics = verify_landmark_pixels(stale, screen, self.full_frame, checkpoint("quit_ready")) + self.assertEqual(metrics["marker"]["fraction"], 1) + self.assertLess(metrics["marker"]["native_expected_color_fraction"], 0.70) + with self.assertRaises(Failed): + require_landmark_pixels(metrics) + change = verify_resumed_marker(stale, checkpoint("post_text_ready"), stale, checkpoint("quit_ready")) + self.assertEqual(change["fraction"], 0) + + def test_os_retaining_wait_pixels_after_native_resume_is_rejected(self): + native = synthetic_native("quit_ready") + screen = synthetic_os(synthetic_native("post_text_ready"), self.full_frame) + metrics = verify_landmark_pixels(native, screen, self.full_frame, checkpoint("quit_ready")) + self.assertLess(metrics["marker"]["fraction"], 0.30) + with self.assertRaises(Failed): + require_landmark_pixels(metrics) + + def test_actual_native_marker_change_is_required(self): + waiting, resumed = synthetic_native("post_text_ready"), synthetic_native("quit_ready") + change = verify_resumed_marker(waiting, checkpoint("post_text_ready"), resumed, checkpoint("quit_ready")) + self.assertGreaterEqual(change["fraction"], change["required_fraction"]) + metrics = verify_landmark_pixels(resumed, synthetic_os(resumed, self.full_frame), + self.full_frame, checkpoint("quit_ready")) + require_landmark_pixels(metrics) + + def test_impostor_source_and_wrong_run_or_stage_are_not_checkpoints(self): + real = checkpoint() + self.assertTrue(is_script_checkpoint(real, "start_ready", "synthetic-metric-fixture")) + impostor = copy.deepcopy(real) + impostor["source"] = "host-observer" + self.assertFalse(is_script_checkpoint(impostor, "start_ready", "synthetic-metric-fixture")) + self.assertFalse(is_script_checkpoint(real, "quit_ready", "synthetic-metric-fixture")) + self.assertFalse(is_script_checkpoint(real, "start_ready", "another-run")) + with self.assertRaises(Failed): + checkpoint_landmarks(impostor) + + def test_letterbox_and_noninteger_scale_with_one_os_pixel_shift(self): + native = synthetic_native() + cases = [({"drawn_box": [0, 75, 800, 450], "viewport_size": [800, 600]}, (641, 481)), + ({"drawn_box": [45.25, 49.5, 1189.5, 669.09375], + "viewport_size": [1280, 720]}, (767, 431))] + for frame, size in cases: + with self.subTest(frame=frame, size=size): + screen = synthetic_os(native, frame, size, shift=(1, -1)) + require_landmark_pixels(verify_landmark_pixels(native, screen, frame, checkpoint())) + + def test_incorrect_letterbox_transform_cannot_pass(self): + native = synthetic_native() + frame = {"drawn_box": [0, 75, 800, 450], "viewport_size": [800, 600]} + screen = synthetic_os(native, frame, (641, 481)) + wrong = {"drawn_box": [0, 0, 800, 600], "viewport_size": [800, 600]} + with self.assertRaises(Failed): + require_landmark_pixels(verify_landmark_pixels(native, screen, wrong, checkpoint())) + + def test_invalid_landmark_bounds_rejected(self): + for field, value in (("logical_canvas", [0, 540]), ("logical_canvas", [float("nan"), 540]), + ("button_bounds", [900, 222, 240, 96]), + ("marker_bounds", [360, 350, 8, 40]), ("marker_rgb", [True, 180, 210]), + ("button_rgbs", []), ("button_rgbs", [[23, 52]]), + ("button_rgbs", [[23, 52, 76.5]]), ("button_rgbs", [[True, 52, 76]])): + bad = copy.deepcopy(checkpoint()) + bad["details"][field] = value + with self.subTest(field=field, value=value), self.assertRaises(Failed): + checkpoint_landmarks(bad) + + def test_godot_integral_float_json_roundtrip_remains_valid(self): + # Actual Godot JSON.parse_string -> JSON.stringify emits these numbers + # as integral floats; the observer's captured checkpoint uses this shape. + captured = copy.deepcopy(checkpoint()) + for key in ("logical_canvas", "button_bounds", "marker_bounds", "marker_rgb"): + captured["details"][key] = [float(v) for v in captured["details"][key]] + captured["details"]["button_rgbs"] = [[float(v) for v in rgb] for rgb in captured["details"]["button_rgbs"]] + self.assertEqual(captured["details"], checkpoint()["details"]) + checkpoint_landmarks(captured) + + def test_dense_roi_threshold_remains_ninety_percent(self): + native = synthetic_native() + screen = synthetic_os(native, self.full_frame) + altered = bytearray(screen[2]) + # Corrupt roughly 13% of the visible button's interior, enough to pass + # a 70% ROI gate while failing the required 90% gate with actual pixels. + for y in range(226, 314): + for x in range(566, 600): + offset = (y * screen[0] + x) * screen[3] + altered[offset:offset + 3] = b"\xff\x00\xff" + screen = (screen[0], screen[1], bytes(altered), screen[3]) + metrics = verify_landmark_pixels(native, screen, self.full_frame, checkpoint()) + self.assertGreater(metrics["button"]["fraction"], 0.70) + self.assertLess(metrics["button"]["fraction"], 0.90) + with self.assertRaises(Failed): + require_landmark_pixels(metrics) + + +class EngineLogEvidence(unittest.TestCase): + # Explicit synthetic diagnostic text, never an actual runtime pass. + root = "/cloud/private/renpy-device-demo" + attachment = ("[2026-10-08 12:00:00.000] [info] aetherkiri provider engine log attached: " + + root + "/aetherkiri-engine.log\n") + + def test_nonempty_fresh_game_log_without_native_errors(self): + raw = (self.attachment + "[info] info [renpy-mobile] renderer initialized\n" + "[info] info [renpy-mobile] cooperative_stop completed\n" + "[error] error [another-runtime] unrelated diagnostic\n").encode() + report = require_engine_log(raw, self.root) + self.assertEqual(report["renpy_mobile_error_records"], 0) + self.assertIn("void-shutdown-status-unavailable", report["verification"]) + + def test_terminal_egl_thread_and_python_cleanup_errors_reject(self): + diagnostics = ["error [renpy-mobile] Ren'Py terminal cleanup failed; retained runtime resources require a host application restart", + "error [renpy-mobile] Ren'Py private EGL context could not be made current (EGL error 0x300e); context-loss recovery is unavailable", + "error [renpy-mobile] Ren'Py failed to restore the host EGL context after a lifecycle call", + "error [renpy-mobile] Ren'Py lifecycle called from a different thread than init", + "error [renpy-mobile] cooperative_stop", + "error [renpy-mobile] Python cleanup exception with no special keywords", + "warning [renpy-mobile] Ren'Py terminal cleanup failed", + "warning [renpy-mobile] cooperative_stop"] + for line in diagnostics: + with self.subTest(line=line), self.assertRaises(Failed): + require_engine_log((self.attachment + "[2026-10-08] [error] " + line + "\n").encode(), self.root) + + def test_empty_unreadable_or_wrong_game_log_rejects(self): + for raw in (None, b"", b" \n", b"\xff", b"some other log", self.attachment.replace(self.root, "/cloud/older-game").encode()): + with self.subTest(raw=raw), self.assertRaises(Failed): + require_engine_log(raw, self.root) + + def test_real_file_read_requires_available_regular_log(self): + with tempfile.TemporaryDirectory(prefix="renpy-synthetic-log-gate-") as temporary: + directory = Path(temporary) + with self.assertRaises(Failed): + read_engine_log_file(directory / "missing.log") + with self.assertRaises(Failed): + read_engine_log_file(directory) + path = directory / "synthetic-aetherkiri-engine.log" + path.write_text(self.attachment) + require_engine_log(read_engine_log_file(path), self.root) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 9486a69d8a534d311565308c3c36256a0e679f5a Mon Sep 17 00:00:00 2001 From: akitaSummer Date: Fri, 9 Oct 2026 00:38:35 +0800 Subject: [PATCH 276/331] ci(renpy): read a sanitized Xcode failure summary without republishing logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 刘煜 --- .github/workflows/renpy-build-diagnostics.yml | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/.github/workflows/renpy-build-diagnostics.yml b/.github/workflows/renpy-build-diagnostics.yml index a07166f6d..6dac8f6ca 100644 --- a/.github/workflows/renpy-build-diagnostics.yml +++ b/.github/workflows/renpy-build-diagnostics.yml @@ -19,19 +19,22 @@ jobs: uses: actions/download-artifact@v6 with: repository: AetherKiri/Aether - run-id: 37776651799 - name: renpy-source-build-evidence + run-id: 37806315295 + name: renpy-ios-source-build-evidence github-token: ${{ github.token }} - path: ${{ runner.temp }}/renpy-android-source-evidence + path: ${{ runner.temp }}/renpy-ios-source-evidence - name: Read a sanitized summary of the original build logs run: | set -euo pipefail - python3 - "$RUNNER_TEMP/renpy-android-source-evidence" <<'PY' + python3 - "$RUNNER_TEMP/renpy-ios-source-evidence" <<'PY' import pathlib, re, sys - logs = sorted(pathlib.Path(sys.argv[1]).rglob('*.log')) + # Read the genuine compiler log, not configure environment dumps. + logs = sorted(pathlib.Path(sys.argv[1]).rglob('renpy-ios-source-build.log')) if not logs: raise SystemExit('The genuine source-build evidence contains no log files.') - print('Reading preserved evidence from actual job 113309300220; no build is rerun or simulated.') + print('Original iOS source run 37806315295; job 113414542371; head e6f5937a1bed3219efd2bc85731df8afd4f60bdf.') + print('Evidence artifact 11563707867; GitHub archive digest sha256:d1062c6895c08977adbc883e6e467b57ec77411d1ee4a8afe53b7243c6a8e712.') + print('Reading preserved compiler evidence; no build is rerun or simulated.') remaining = 24000 for log in logs: text = log.read_text(errors='replace') @@ -44,7 +47,17 @@ jobs: indices = set(range(max(0, len(lines)-110), len(lines))) for i in interesting[-16:]: indices.update(range(max(0, i-4), min(len(lines), i+9))) - summary = '\n'.join(lines[i] for i in sorted(indices)) + excerpt = [] + for i in sorted(indices): + line = lines[i] + if re.match(r'^\s*(?:export\s|declare\s|env:|Environment:|(?!(?:FAILED|ERROR|FATAL|WARNING):)[A-Z_][A-Z0-9_]*\s*[=:])', line): + continue + if re.search(r'authorization|password|credential|secret|(?:^|[\s_])token(?:[\s_=:]|$)', line, re.I): + continue + line = line.replace('/Users/runner/work/Aether/Aether', '') + line = line.replace('/Users/runner/work/_temp', '') + excerpt.append(line[:1200]) + summary = '\n'.join(excerpt) print(f'Original log: {log.name}; {len(lines)} lines') if len(summary) > remaining: summary = summary[-remaining:] @@ -54,11 +67,3 @@ jobs: print('Summary reached its display limit; complete original logs remain in the artifact.') break PY - - name: Preserve the original build log - if: always() - uses: actions/upload-artifact@v6 - with: - name: renpy-android-actual-build-log - path: ${{ runner.temp }}/renpy-android-source-evidence - if-no-files-found: warn - retention-days: 7 From 7b1f416c90d69eaaffbd03ea8c1b88f2dabba8fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 16:56:50 +0000 Subject: [PATCH 277/331] fix(renpy): correct iOS modules and suspended mobile waits --- .github/workflows/renpy-ios-runtime-build.yml | 3 + .github/workflows/renpy-mobile-contract.yml | 3 + .../native/0007-darwin-ios-build.patch | 11 ++ .../mobile_launcher/patches/native/README.md | 13 ++ .../0001-cooperative-loop-skeleton.patch | 64 ++++++-- tools/test_renpy_mobile_cooperative.py | 155 +++++++++++++++++- tools/test_renpy_mobile_ios_nis.py | 121 ++++++++++++++ 7 files changed, 357 insertions(+), 13 deletions(-) create mode 100644 tools/test_renpy_mobile_ios_nis.py diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index 1e6244909..0de433a30 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -118,6 +118,9 @@ jobs: uv --project "$RENPY_BUILD_ROOT/renpy" run python \ "$GITHUB_WORKSPACE/tools/test_renpy_mobile_metalangle_flags.py" "$RENPY_BUILD_ROOT" \ 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" + uv --project "$RENPY_BUILD_ROOT/renpy" run python \ + "$GITHUB_WORKSPACE/tools/test_renpy_mobile_ios_nis.py" "$RENPY_BUILD_ROOT" \ + 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" - name: Compile target CPython, SDL, Ren'Py and device/Simulator archives if: steps.runtime-cache.outputs.cache-hit != 'true' run: | diff --git a/.github/workflows/renpy-mobile-contract.yml b/.github/workflows/renpy-mobile-contract.yml index def3d5be8..25c1c36aa 100644 --- a/.github/workflows/renpy-mobile-contract.yml +++ b/.github/workflows/renpy-mobile-contract.yml @@ -25,6 +25,7 @@ on: - 'tools/test_renpy_mobile_static_api.py' - 'tools/test_renpy_mobile_libffi_cfi.py' - 'tools/test_renpy_mobile_metalangle_flags.py' + - 'tools/test_renpy_mobile_ios_nis.py' - 'tools/validate_renpy_mobile_payload.py' - 'tools/test_renpy_mobile_packaging.sh' - 'tools/test_renpy_mobile_runtime_protocol.sh' @@ -111,6 +112,8 @@ jobs: tools/test_renpy_mobile_libffi_cfi.py _renpy-build-source --clang /usr/bin/clang-18 "${RUNNER_TEMP}/cooperative-test/bin/python" \ tools/test_renpy_mobile_metalangle_flags.py _renpy-build-source --clang /usr/bin/clang-18 + "${RUNNER_TEMP}/cooperative-test/bin/python" \ + tools/test_renpy_mobile_ios_nis.py _renpy-build-source - name: Generate the static API and compile its genuine C consumers run: | diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch index 8b9c1f7a3..f0274475c 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch @@ -213,6 +213,17 @@ diff --git a/tasks/python3.py b/tasks/python3.py @task(kind="python", pythons="3", platforms="linux,mac") +@@ -140,6 +139,9 @@ + f.write("ac_cv_func_getentropy=no\n") + f.write("ac_cv_have_long_long_format=yes\n") +- f.write("ac_cv_func_clock_settime=no") ++ f.write("ac_cv_func_clock_settime=no\n") ++ # iOS does not provide the NIS RPC headers. This is CPython's ++ # supported unsupported-module selector, not a fabricated header probe. ++ f.write("py_cv_module_nis=n/a\n") + + c.run(""" + {{configure}} {{ cross_config }} diff --git a/tasks/librenpy.py b/tasks/librenpy.py --- a/tasks/librenpy.py +++ b/tasks/librenpy.py diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index 5c0d8f718..dd1e4b798 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -48,6 +48,12 @@ The MetalANGLE annotator keeps its header paths and definitions in compiler flags and supplies `-framework MetalANGLE` through linker flags. Compile-only dependency builds retain their strict warning checks. +The iOS CPython task disables optional NIS using the supported +`py_cv_module_nis=n/a` configure selector because the iOS SDK does not provide +its RPC headers. Host Python and the other target policies retain their normal +module detection. The regression runs genuine CPython configure and checks its +generated module rules; on macOS it also probes both actual iOS SDKs. + `0009-bounded-build-parallelism.patch` limits the upstream native object build group to the host CPU count. Set `RENPY_BUILD_JOBS` to a positive integer to select another limit. Child compiler failures still fail the build, and an @@ -60,6 +66,13 @@ preserves pointer/key/text actions, publishes renderer screenshots and returns observable normal-exit and text-input state. Window binding connects the real SDL renderer window to the Android EGL context handoff. +Host pause stops PERIODIC, REDRAW and TIMEEVENT, and clears only their queued +events. Resume restarts PERIODIC and wakes the suspended event wait. The saved +interaction invalidates its timer scheduling caches and rearms its original +redraw and timeout deadlines without restarting the interaction or resetting +the renderer. Repeated pause and resume calls are idempotent. Real background +and resume gameplay acceptance remains required. + Terminal shutdown runs on the interpreter's owning thread while its healthy private context is current. The cooperative patch stops the engine timers, retires GL2 Program destructors before dropping cached references, and destroys diff --git a/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch b/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch index aad73a69a..59e779997 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/python/0001-cooperative-loop-skeleton.patch @@ -12,7 +12,7 @@ index 4d3b125..33f2cf7 100644 import android diff --git a/renpy/display/core.py b/renpy/display/core.py -index 89c74d1..ab5c04b 100644 +index 89c74d1..458fc7b 100644 --- a/renpy/display/core.py +++ b/renpy/display/core.py @@ -32,6 +32,7 @@ import platform @@ -70,11 +70,25 @@ index 89c74d1..ab5c04b 100644 if ev.type != pygame.APP_WILLENTERBACKGROUND: return False -@@ -2774,6 +2792,7 @@ class Interface: +@@ -2756,6 +2774,7 @@ class Interface: + + old_timeout_time = None + old_redraw_time = None ++ aether_timer_generation = aether_mobile.cooperative_timer_generation() + + rv = None + +@@ -2774,6 +2793,13 @@ class Interface: can_block = False while rv is None: + aether_mobile.cooperative_checkpoint() ++ if aether_timer_generation != aether_mobile.cooperative_timer_generation(): ++ # Host pause canceled SDL timers while this stack stayed ++ # suspended. Rearm the existing deadlines below even when ++ # the redraw/timeout times themselves have not changed. ++ old_timeout_time = old_redraw_time = None ++ aether_timer_generation = aether_mobile.cooperative_timer_generation() renpy.plog(1, "start of interact while loop") renpy.execution.not_infinite_loop(10) @@ -375,10 +389,10 @@ index 889c908..bfd94f3 100644 return pixels[self.surface.h * self.surface.pitch] diff --git a/renpy/aether_mobile.py b/renpy/aether_mobile.py new file mode 100644 -index 0000000..58fb17c +index 0000000..b1910bb --- /dev/null +++ b/renpy/aether_mobile.py -@@ -0,0 +1,358 @@ +@@ -0,0 +1,386 @@ +"""Host-owned Ren'Py loop; all SDL/GL work stays on the calling thread. + +The native launcher links greenlet with its target CPython. No exception is @@ -402,6 +416,7 @@ index 0000000..58fb17c +_frame = None +_serial = 0 +_paused = False ++_timer_generation = 0 +_stopping = False +_terminal_cleanup_done = False +_error = None @@ -638,27 +653,54 @@ index 0000000..58fb17c +def cooperative_pause(): + global _paused + _check_owner() -+ if _game is None or _game.dead: ++ if _game is None or _game.dead or _paused: + return + import renpy -+ if renpy.display.interface and renpy.display.interface.started: -+ renpy.display.interface.mobile_save() ++ interface = renpy.display.interface ++ if interface and interface.started: ++ import renpy.pygame as pygame ++ import renpy.display.core as core ++ interface.mobile_save() + renpy.audio.audio.pause_all() ++ # check_suspend is bypassed in host mode. Stop its three timers here ++ # rather than let SDL's timer thread fill the queue while Tick stops. ++ timers = (core.PERIODIC, core.REDRAW, core.TIMEEVENT) ++ for event_type in timers: ++ pygame.time.set_timer(event_type, 0) ++ pygame.event.clear(timers) ++ if interface.pushed_event is not None and interface.pushed_event.type in timers: ++ interface.pushed_event = None + _paused = True + + +def cooperative_resume(): -+ global _paused ++ global _paused, _timer_generation + _check_owner() -+ if _game is None or _game.dead: ++ if _game is None or _game.dead or not _paused: + return + import renpy -+ if renpy.display.interface and renpy.display.interface.started: ++ interface = renpy.display.interface ++ if interface and interface.started: ++ import renpy.pygame as pygame ++ import renpy.display.core as core ++ # Remove any callback that was already in flight when pause removed ++ # the timers. Do not clear touch, key or text events. ++ pygame.event.clear((core.PERIODIC, core.REDRAW, core.TIMEEVENT)) ++ pygame.time.set_timer(core.PERIODIC, core.PERIODIC_INTERVAL) + renpy.audio.audio.unpause_all() -+ renpy.display.interface.force_redraw = True ++ interface.force_redraw = True ++ _timer_generation += 1 ++ # Wake an event_wait suspended inside interact_core. Its saved timer ++ # caches are invalidated at the next loop boundary, without restarting ++ # the interaction or firing an artificial timeout at a displayable. ++ pygame.event.post(pygame.event.Event(core.PERIODIC)) + _paused = False + + ++def cooperative_timer_generation(): ++ return _timer_generation ++ ++ +def cooperative_text_input_state(): + if not cooperative_active(): + return False diff --git a/tools/test_renpy_mobile_cooperative.py b/tools/test_renpy_mobile_cooperative.py index eb1ef2b2d..97e25b9fe 100755 --- a/tools/test_renpy_mobile_cooperative.py +++ b/tools/test_renpy_mobile_cooperative.py @@ -5,6 +5,7 @@ Ren'Py's engine, GL, SDL or a game and is not gameplay acceptance. """ import argparse +import ast import importlib.util from pathlib import Path import sys @@ -58,6 +59,7 @@ class QuitException(Exception): pygame.event.clear = lambda event_types: teardown.append(("clear_timers", event_types)) core = types.ModuleType("renpy.display.core") core.PERIODIC, core.REDRAW, core.TIMEEVENT = 50, 51, 52 +core.PERIODIC_INTERVAL = 50 sys.modules[core.__name__] = core shader = types.ModuleType("renpy.gl2.gl2shader") shader.retire_mobile_programs = lambda: teardown.append("retire_programs") @@ -69,10 +71,11 @@ class QuitException(Exception): surface = types.SimpleNamespace(get_bytesize=lambda: 4, get_size=lambda: (2, 1), aether_copy_pixels=lambda: b"\x01\x02\x03\xff\x04\x05\x06\xff") interface = types.SimpleNamespace(started=True, mobile_save=lambda: log.append("save"), force_redraw=False, - kill_textures=lambda: teardown.append("clear_render_caches")) + pushed_event=None, kill_textures=lambda: teardown.append("clear_render_caches")) renpy.config = types.SimpleNamespace(screen_width=800, screen_height=600) renpy.display = types.SimpleNamespace(interface=interface, draw=types.SimpleNamespace( screenshot=lambda tree: surface, untranslate_point=lambda x, y: (round(x), round(y)))) +renpy.display.core = core renpy.audio = types.SimpleNamespace(audio=types.SimpleNamespace(pause_all=lambda: log.append("pause"), unpause_all=lambda: log.append("resume"))) @@ -126,8 +129,17 @@ def nested_game(game_root, argv0): assert binds == [17] mobile.cooperative_pause() assert mobile.cooperative_tick(1) == "yield" and "cleanup" not in log + assert teardown == [(50, 0), (51, 0), (52, 0), ("clear_timers", (50, 51, 52))] + before_pause = (list(log), list(teardown)) + mobile.cooperative_pause() + assert (log, teardown) == before_pause, "repeated pause saved or stopped timers twice" mobile.cooperative_resume() assert log[-3:] == ["save", "pause", "resume"] and interface.force_redraw + assert teardown[-2:] == [("clear_timers", (50, 51, 52)), (50, 50)] + assert mobile.cooperative_timer_generation() == 1 and events[-1]["type"] == core.PERIODIC + before_resume = (list(log), list(teardown), list(events)) + mobile.cooperative_resume() + assert (log, teardown, events) == before_resume, "repeated resume restarted timers or posted extra events" assert mobile.cooperative_text_input_state() errors = [] def other_thread(): @@ -138,6 +150,7 @@ def other_thread(): thread = threading.Thread(target=other_thread) thread.start(); thread.join() assert errors and "original host thread" in errors[0] + teardown.clear() mobile.cooperative_stop() assert log.count("cleanup") == 1 and not mobile.cooperative_active() assert mobile.cooperative_tick(1) == "not_started" @@ -231,6 +244,144 @@ def failed_quit(): sys.modules["renpy.pygame"] = pygame sys.modules[shader.__name__] = shader + # Run the patched Interface.event_wait and timer scheduling source with a + # controlled SDL timer queue. The greenlet is real; the clock/SDL events + # remain fixtures, so this is not renderer or gameplay acceptance. + source = (args.renpy_src / "renpy/display/core.py").read_text() + interface_ast = next(node for node in ast.parse(source).body + if isinstance(node, ast.ClassDef) and node.name == "Interface") + event_wait_ast = next(node for node in interface_ast.body + if isinstance(node, ast.FunctionDef) and node.name == "event_wait") + wait_globals = {"aether_mobile": mobile, "pygame": pygame, + "renpy": types.SimpleNamespace(emscripten=False)} + exec(compile(ast.Module(body=[event_wait_ast], type_ignores=[]), str(args.renpy_src / "renpy/display/core.py"), "exec"), + wait_globals) + checkpoint_start = source.index(" aether_mobile.cooperative_checkpoint()") + checkpoint_end = source.index(' renpy.plog(1, "start of interact while loop")', checkpoint_start) + checkpoint_source = compile(textwrap.dedent(source[checkpoint_start:checkpoint_end]), "interact_core resume boundary", "exec") + schedule_start = source.index(" # Compute the redraw time and set the redraw timer.") + schedule_end = source.index(" # Process the invoke queue.", schedule_start) + schedule_source = compile(textwrap.dedent(source[schedule_start:schedule_end]), "interact_core SDL timer scheduling", "exec") + + class TimerQueue: + def __init__(self): + self.now = 0 + self.timers = {} + self.queue = [] + self.calls = [] + + def set_timer(self, event_type, interval, once=False): + self.calls.append((event_type, interval, once)) + self.timers.pop(event_type, None) + if interval: + self.timers[event_type] = (interval, self.now + interval, once) + + def advance(self, milliseconds): + self.now += milliseconds + for event_type, (interval, due, once) in list(self.timers.items()): + while due <= self.now: + self.post(dict(type=event_type)) + if once: + self.timers.pop(event_type) + break + due += interval + else: + self.timers[event_type] = (interval, due, once) + + def clear(self, event_types): + self.queue[:] = [event for event in self.queue if event["type"] not in event_types] + + def post(self, event): + self.queue.append(event) + + def poll(self): + return types.SimpleNamespace(**self.queue.pop(0)) if self.queue else types.SimpleNamespace(type=pygame.NOEVENT) + + timers = TimerQueue() + pygame.NOEVENT = 0 + pygame.time = types.SimpleNamespace(set_timer=timers.set_timer) + pygame.event = types.SimpleNamespace(Event=lambda event_type, **values: dict(type=event_type, **values), + post=timers.post, poll=timers.poll, clear=timers.clear) + fresh_mobile() + wait_interface = types.SimpleNamespace(pushed_event=None, last_event=None, + check_background_screenshot=lambda: None, timeout_time=2000.0, + post_time_event=lambda: timers.post(dict(type=core.TIMEEVENT)), + redraw_event=dict(type=core.REDRAW)) + interface.pushed_event = None + boundary = {"aether_mobile": mobile, "pygame": pygame, "self": wait_interface, + "PERIODIC": core.PERIODIC, "REDRAW": core.REDRAW, "TIMEEVENT": core.TIMEEVENT, + "get_time": lambda: timers.now / 1000.0, "redraw_time": 1000.0, + "old_redraw_time": None, "old_timeout_time": None, + "aether_timer_generation": mobile.cooperative_timer_generation()} + resumed_waits = [] + def timer_wait_game(*_): + stack_token = object() + try: + exec(schedule_source, boundary) + for _ in range(2): + event = wait_globals["event_wait"](wait_interface) + exec(checkpoint_source, boundary) + exec(schedule_source, boundary) + resumed_waits.append((stack_token, event.type)) + mobile.cooperative_checkpoint(force=True) + except QuitException: + pass + + mobile._run = timer_wait_game + mobile.cooperative_start(str(root), str(entry)) + timers.set_timer(core.PERIODIC, core.PERIODIC_INTERVAL) + assert mobile.cooperative_tick(1000) == "yield" and resumed_waits == [] + assert boundary["old_redraw_time"] == 1000.0 and boundary["old_timeout_time"] == 2000.0 + timers.advance(500) + assert len(timers.queue) == 10, "fixture did not reproduce periodic backlog" + timers.post(dict(type=pygame.TEXTINPUT, text="retained input")) + interface.pushed_event = types.SimpleNamespace(type=core.PERIODIC) + mobile.cooperative_pause() + assert timers.timers == {} and interface.pushed_event is None + assert timers.queue == [dict(type=pygame.TEXTINPUT, text="retained input")] + timers.advance(50000) + assert len(timers.queue) == 1 and mobile.cooperative_tick(1000) == "yield" + assert resumed_waits == [], "paused Tick resumed the saved event_wait" + # Model an SDL callback already in flight at removal; resume clears it. + timers.post(dict(type=core.TIMEEVENT)) + mobile.cooperative_resume() + assert [event["type"] for event in timers.queue] == [pygame.TEXTINPUT, core.PERIODIC] + assert mobile.cooperative_tick(1000) == "yield" + assert resumed_waits[0][1] == pygame.TEXTINPUT + assert boundary["old_timeout_time"] == wait_interface.timeout_time == 2000.0 + assert timers.timers[core.REDRAW][0] == 1000 + assert timers.timers[core.TIMEEVENT][0] == int((2000.0 - timers.now / 1000.0) * 1000 + 1) + assert not any(event["type"] == core.TIMEEVENT for event in timers.queue), "resume fired a future wait early" + # A second pause passes the existing deadline. Actual scheduler source + # posts it once on resume instead of retaining a canceled timer forever. + mobile.cooperative_pause() + timers.advance(2000000) + assert timers.queue == [] and timers.timers == {} + mobile.cooperative_resume() + assert mobile.cooperative_tick(1000) == "yield" + assert resumed_waits[1][0] is resumed_waits[0][0], "resume rebuilt the interaction stack" + assert resumed_waits[1][1] == core.PERIODIC + assert wait_interface.timeout_time is None + assert sum(event["type"] == core.TIMEEVENT for event in timers.queue) == 1 + mobile.cooperative_stop() + assert timers.timers == {} and timers.queue == [] + + # A pause before display startup must neither import it nor arm timers. + fresh_mobile() + interface.started = False + mobile.cooperative_start(str(root), str(entry)) + before = len(timers.calls) + saved_pygame = sys.modules.pop("renpy.pygame") + saved_core = sys.modules.pop("renpy.display.core") + mobile.cooperative_pause() + mobile.cooperative_resume() + assert len(timers.calls) == before and mobile.cooperative_timer_generation() == 0 + assert "renpy.pygame" not in sys.modules and "renpy.display.core" not in sys.modules + sys.modules["renpy.pygame"] = saved_pygame + sys.modules["renpy.display.core"] = saved_core + mobile.cooperative_stop() + interface.started = True + renderer_source = (args.renpy_src / "renpy/gl2/gl2draw.pyx").read_text() start = renderer_source.index(" gles = self.gles") end = renderer_source.index(" # Select the GL attributes and hints.", start) @@ -250,5 +401,5 @@ def failed_quit(): if platform == "ios": assert context["window_flags"] & 12 == 12 -print("Cooperative checks passed: real greenlet ownership, input/frame contracts, natural/paused/newborn/failed startup shutdown, terminal cleanup order, retry errors and idempotence") +print("Cooperative checks passed: real greenlet ownership, input/frame contracts, paused timer queue and resumed waits, natural/paused/newborn/failed startup shutdown, terminal cleanup order, retry errors and idempotence") print("Synthetic engine boundaries only; Ren'Py/SDL/GL/device gameplay was not run") diff --git a/tools/test_renpy_mobile_ios_nis.py b/tools/test_renpy_mobile_ios_nis.py new file mode 100644 index 000000000..89df74969 --- /dev/null +++ b/tools/test_renpy_mobile_ios_nis.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Run genuine CPython configure with the actual iOS task's module policy. + +This checks generated optional-module rules with a real host compiler. On Mac, +it also probes the actual device/Simulator SDK headers. It does not compile or +link a target Python runtime, emulate an SDK, or verify gameplay. +""" +import argparse +import ast +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tarfile +import tempfile + +REPO = Path(__file__).resolve().parents[1] +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" +PATCHES = REPO / "bridge/renpy_runtime/mobile_launcher/patches/native" + + +def unchanged_other_targets(original, patched): + def functions(text): + return {node.name: ast.dump(node, include_attributes=False) + for node in ast.parse(text).body if isinstance(node, ast.FunctionDef)} + before, after = functions(original), functions(patched) + for name in ("common", "build_posix", "build_android", "build_windows", "build_web"): + assert before[name] == after[name], f"Unrelated target policy changed: {name}" + + +def site_from_task(task_text, source, root, Context): + tree = ast.parse(task_text) + selected = [node for node in tree.body if isinstance(node, ast.FunctionDef) + and node.name in ("common", "build_ios")] + assert len(selected) == 2 + for node in selected: + node.decorator_list = [] + namespace = {"Context": Context, "version": "3.12.8", "common_post": lambda context: None} + exec(compile(ast.Module(body=selected, type_ignores=[]), "actual-python3-task", "exec"), namespace) + context = Context("ios", "arm64", "3", root, None) + context.set_names("python", "build_ios", "python3") + context.cwd = source.parent + # Capture only the site's genuine task policy. Configure runs below with + # the real host compiler; no SDK or build is represented by this callback. + context.run = lambda command: None + namespace["build_ios"](context) + assert context.cwd == source + return source / "config.site" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build_source", type=Path) + args = parser.parse_args() + assert subprocess.check_output(["git", "-C", str(args.build_source), "rev-parse", "HEAD"], text=True).strip() == PIN + with tempfile.TemporaryDirectory(prefix="renpy-ios-nis-") as temporary: + root = Path(temporary) + archive = subprocess.Popen([ + "git", "-C", str(args.build_source), "archive", "HEAD", "renpybuild", "tasks", + "source/Python-3.12.8-Setup.stdlib", "source/Python-3.12.8.tar.xz", + "tools/cmake_build_variables.cmake", + ], stdout=subprocess.PIPE) + assert archive.stdout is not None + with tarfile.open(fileobj=archive.stdout, mode="r|*") as source: + source.extractall(root, filter="data") + assert archive.wait() == 0 + task = root / "tasks/python3.py" + original = task.read_text() + subprocess.run(["git", "apply", "--include=tasks/metalangle.py", str(PATCHES / "0006-ios-offscreen-renderer.patch")], cwd=root, check=True) + subprocess.run(["git", "apply", str(PATCHES / "0007-darwin-ios-build.patch")], cwd=root, check=True) + patched = task.read_text() + unchanged_other_targets(original, patched) + sys.path.insert(0, str(root)) + from renpybuild.context import Context + + if sys.platform == "darwin": + compiler = subprocess.check_output(["xcrun", "--find", "clang"], text=True).strip() + host_sdk = subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-path"], text=True).strip() + compiler_words = [compiler, "-isysroot", host_sdk] + assert all(not any(c.isspace() for c in word) for word in compiler_words) + for sdk, target in (("iphoneos", "arm64-apple-ios13.0"), ("iphonesimulator", "arm64-apple-ios13.0-simulator")): + sdk_root = subprocess.check_output(["xcrun", "--sdk", sdk, "--show-sdk-path"], text=True).strip() + assert Path(sdk_root).is_dir(), sdk_root + result = subprocess.run([compiler, "-target", target, "-isysroot", sdk_root, + "-x", "c", "-fsyntax-only", "-"], + input="#include \n", text=True, capture_output=True) + assert result.returncode != 0 and "rpcsvc/yp_prot.h" in result.stderr and "file not found" in result.stderr, result.stderr + print(f"Actual {sdk} SDK reproduces the unavailable NIS header: {sdk_root}", flush=True) + else: + compiler = shutil.which("cc") + assert compiler + compiler_words = [compiler] + + for label, text in (("original", original), ("patched", patched)): + destination = root / label + destination.mkdir() + with tarfile.open(root / "source/Python-3.12.8.tar.xz") as source: + source.extractall(destination, filter="data") + python_source = destination / "Python-3.12.8" + site = site_from_task(text, python_source, root, Context) + environment = {**os.environ, "CONFIG_SITE": str(site), "CC": " ".join(compiler_words), + "CFLAGS": "", "CPPFLAGS": "", "LDFLAGS": ""} + result = subprocess.run(["./configure", "--with-ensurepip=no", "--prefix=" + str(destination / "install")], + cwd=python_source, env=environment, capture_output=True, text=True) + assert result.returncode == 0, result.stdout[-5000:] + result.stderr[-5000:] + makefile = (python_source / "Makefile").read_text() + state = next(line.partition("=")[2] for line in makefile.splitlines() if line.startswith("MODULE_NIS_STATE=")) + if label == "patched": + assert state == "n/a", state + assert not any(line.startswith("Modules/nismodule.o:") for line in makefile.splitlines()) + assert not any(line.startswith("nis ") for line in (python_source / "Modules/Setup.stdlib").read_text().splitlines()) + assert "ac_cv_func_clock_settime=no\npy_cv_module_nis=n/a\n" in site.read_text() + print(f"Actual CPython 3.12.8 {label} host configure: MODULE_NIS_STATE={state}", flush=True) + if label == "original" and state != "yes": + print("Original NIS auto-enable is not reproduced by this host's dependencies; the genuine iOS compiler failure is recorded separately", flush=True) + print("Actual configure generated no NIS extension object rule under the iOS policy; target runtime compilation and gameplay remain untested here") + + +if __name__ == "__main__": + main() From dbb863ac101301c8a9d78dea358e2157a0185165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 16:58:16 +0000 Subject: [PATCH 278/331] fix(android): export coinstallable RenPy test apps and audit actual APK metadata --- .github/workflows/renpy-android-app.yml | 18 ++ .github/workflows/renpy-mobile-contract.yml | 20 +++ demos/aetherkiri-renpy/README.md | 65 +++++++- scripts/build_android.sh | 25 ++- tools/inspect_renpy_android_apk.py | 155 +++++++++++++++++- tools/prepare_renpy_android_test_export.py | 77 +++++++++ .../test_prepare_renpy_android_test_export.py | 86 ++++++++++ tools/test_renpy_android_apk_inspection.py | 71 +++++++- 8 files changed, 503 insertions(+), 14 deletions(-) create mode 100644 tools/prepare_renpy_android_test_export.py create mode 100644 tools/test_prepare_renpy_android_test_export.py diff --git a/.github/workflows/renpy-android-app.yml b/.github/workflows/renpy-android-app.yml index b3ad79e64..25879464b 100644 --- a/.github/workflows/renpy-android-app.yml +++ b/.github/workflows/renpy-android-app.yml @@ -11,9 +11,14 @@ on: - 'scripts/build_android.sh' - 'tools/stage_renpy_android_support.sh' - 'tools/inspect_renpy_android_apk.py' + - 'tools/prepare_renpy_android_test_export.py' + - 'tools/test_prepare_renpy_android_test_export.py' - 'bridge/renpy_runtime/android/**' - 'vcpkg/ports/libgdiplus/**' + - 'vcpkg/ports/ggml/**' - 'tools/test_libgdiplus_cmake_relocation.py' + - 'tools/test_ggml_overlay_source.py' + - 'demos/aetherkiri-renpy/**' workflow_dispatch: permissions: @@ -48,6 +53,9 @@ jobs: NATIVE_RUNTIME_RUN: '37787184399' NATIVE_RUNTIME_ARTIFACT: '11556042880' NATIVE_RUNTIME_HEAD: '8895c27beef76954bd85cba8b209df9d49a2401f' + # Keep the disposable Debug key and its data separate from an existing + # production installation. The build uses a copied export project. + AETHERKIRI_ANDROID_TEST_PACKAGE_ID: org.aetherkiri.renpy.debug NATIVE_RUNTIME_DIGEST: 'sha256:f05b3951f8a8a717258c034894431df36f13b9db0356c82f3285cde344c17729' steps: - uses: actions/checkout@v6 @@ -233,6 +241,16 @@ jobs: path: out/godot/android/**/*.apk if-no-files-found: error retention-days: 7 + - name: Upload the public demo project for phone import + if: matrix.abi == 'arm64-v8a' + uses: actions/upload-artifact@v6 + with: + name: renpy-demo-project + path: | + demos/aetherkiri-renpy/game + demos/aetherkiri-renpy/README.md + if-no-files-found: error + retention-days: 7 - name: Preserve real build evidence and any diagnostic APK on failure if: always() uses: actions/upload-artifact@v6 diff --git a/.github/workflows/renpy-mobile-contract.yml b/.github/workflows/renpy-mobile-contract.yml index 25c1c36aa..5751245f1 100644 --- a/.github/workflows/renpy-mobile-contract.yml +++ b/.github/workflows/renpy-mobile-contract.yml @@ -30,6 +30,13 @@ on: - 'tools/test_renpy_mobile_packaging.sh' - 'tools/test_renpy_mobile_runtime_protocol.sh' - 'tools/test_stage_renpy_android_support.sh' + - 'tools/inspect_renpy_android_apk.py' + - 'tools/test_renpy_android_apk_inspection.py' + - 'tools/prepare_renpy_android_test_export.py' + - 'tools/test_prepare_renpy_android_test_export.py' + - 'tools/test_renpy_device_evidence_metrics.py' + - 'tools/run_renpy_android_device_acceptance.py' + - 'tools/run_renpy_ios_device_acceptance.py' workflow_dispatch: permissions: @@ -68,6 +75,19 @@ jobs: bash tools/test_renpy_mobile_native_fork.sh bash tools/test_renpy_mobile_packaging.sh + - name: Check APK export and device-evidence counterexamples + run: | + set -euo pipefail + # Use only an NDK already supplied by the cloud image. Without it, + # the ELF compiler fixture reports a skip rather than a target pass. + ndk_root="${ANDROID_NDK_HOME:-${ANDROID_NDK_LATEST_HOME:-}}" + if [[ -n "$ndk_root" && -d "$ndk_root" ]]; then + export ANDROID_NDK_HOME="$ndk_root" + fi + python3 tools/test_renpy_android_apk_inspection.py + python3 tools/test_prepare_renpy_android_test_export.py + python3 tools/test_renpy_device_evidence_metrics.py + - name: Checkout pinned Ren'Py source for the 8.5.3 rebuild uses: actions/checkout@v6 with: diff --git a/demos/aetherkiri-renpy/README.md b/demos/aetherkiri-renpy/README.md index d3179d321..de43dca33 100644 --- a/demos/aetherkiri-renpy/README.md +++ b/demos/aetherkiri-renpy/README.md @@ -20,6 +20,50 @@ The automated smoke command compiles the project and initializes Ren'Py. A device or simulator run is still required to verify the host lifecycle and input behavior on mobile. +## A small manual Android phone check + +An APK that passes compilation, payload checks and signing is a test candidate; +it does not establish that mobile gameplay works. Use the ARM64 artifact on an +Android 8 or later phone with a 64-bit ARM system. The x86_64 artifact is for +that architecture, usually an emulator. + +The reproduction workflow's coinstallable Debug build uses package +`org.aetherkiri.renpy.debug`, launcher label **Aether RenPy Test**, and a +disposable signing key. It keeps the production application's installation and +data separate. Earlier APKs use `org.github.krkr2.aetherkiri` and may conflict +with an installed application signed by another key. If Android rejects that +installation, stop and use the coinstallable build. Do not uninstall or clear +an existing application as an automatic workaround. A later test build can +also have a different Debug key; keep any test saves before choosing how to +replace it. + +1. Download `renpy-installable-debug-arm64-v8a` from the successful workflow + run, unzip it on the phone, and open `Aether-debug.apk`. Follow Android's + installation prompt for the chosen download source. +2. Download `renpy-demo-project` from the same run and unzip its `game` folder + inside a directory named `aetherkiri-renpy`, for example under Downloads. + Keep `game/script.rpy`, `game/options.rpy`, and `game/scene.svg` together. + No desktop Ren'Py SDK or device-request JSON is needed for this manual flow. +3. Open **Aether RenPy Test**, complete the existing first-run agreement and + file-access prompts, and choose **Import**. Select the `aetherkiri-renpy` + directory containing `game`, then open its detail page and start it. +4. The real demo should show its scene and dialogue. Advance the dialogue, + tap **Start**, enter a name with the system keyboard, and confirm. Check + that the dialogue contains the name. At the menu, press the phone's Home + button, return to the app, and try **Enter text** again. **Pause / resume** + also exercises a short in-game pause; it does not replace the Home check. +5. Choose **Quit** and confirm if prompted. Check that the normal application + library returns and remains responsive. A new Ren'Py session requires a + host-process restart: use Android's **Force stop** for this test application + before reopening it. Clearing data or uninstalling is unnecessary. + +Record the workflow run, APK filename, Android version, phone model, what +appeared on screen, input/lifecycle results, and any error. Do not report a +pass if it only reaches the library, shows a blank screen, or fails to accept +input. This manual check does not produce the independent automated evidence +below. Its Unicode/IME composition and prolonged background behavior need +separate coverage. + For a real **cloud-hosted Android** debug APK and authorized device/emulator: ```bash @@ -35,23 +79,34 @@ request in the app's private test directory, and launches the actual app. It requires a debuggable APK containing the native Ren'Py runtime; stock RAPT alone does not satisfy the host lifecycle ABI. It does not use a user computer. +For the coinstallable reproduction APK, add +`--package org.aetherkiri.renpy.debug`; the default targets the normal product +package. Both commands run only on the authorized cloud device. + The device mode is enabled only by `game/aether-device-request.json`. Real Ren'Py statements write a fresh run ID into `aether-device-checkpoints.jsonl`. The harness requires actual OS touch down/up, Android text/Enter, a visible system keyboard requested by native text-input state, Home/foreground lifecycle pause/resume, another game touch after resume, and explicit `renpy.quit` followed -by observed native exit, engine teardown, and removal of the actual Android -activity. [Android may retain a cached process](https://developer.android.com/guide/components/activities/process-lifecycle); +by observed native exit, engine teardown and removal of the actual Android +activity. The automated Android observer exits its host after this check; +the manual normal-app flow above also checks the library's responsiveness. +[Android may retain a cached process](https://developer.android.com/guide/components/activities/process-lifecycle); its PID is recorded separately. The regular interactive fixture remains available when the request file is absent. Evidence includes APK SHA-256, device build fingerprint, adb command results, the Ren'Py script checkpoints, native provider RGBA PNGs, OS screenshots, -pixel-comparison reports, native pause/resume/exit state, and process logcat. +pixel-comparison reports, native pause/resume/exit state, the flushed per-game +engine log, and process logcat. Screenshots must show the same nonblank pixels as the provider's real frame; -resume additionally requires a new native frame serial after the pause. -viewport fallback images, colorful system screens, startup-only checks, and +the script's actual idle/hover button and stage-marker colors must also appear. +Resume requires a new native frame serial after the pause, then a visible +WAIT-to-RESUMED marker change after the next actual system touch. Missing or +unreadable engine logs and recorded Ren'Py errors fail acceptance. Native +shutdown returns void, so these log checks do not infer a successful status. +Viewport fallback images, colorful system screens, startup-only checks, and simulated provider frames cannot satisfy this gameplay run. `result.json` distinguishes `passed`, `failed`, and `not_run`. Exit status is diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 295933a06..6ef938e31 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -490,6 +490,22 @@ else if [[ "$BUILD_TYPE_LOWER" == "release" ]]; then export_mode="--export-release" fi + android_export_app_dir="$GODOT_APP_DIR" + android_test_package="${AETHERKIRI_ANDROID_TEST_PACKAGE_ID:-}" + if [[ -n "$android_test_package" ]]; then + if [[ "$BUILD_TYPE_LOWER" != "debug" ]]; then + echo "Error: AETHERKIRI_ANDROID_TEST_PACKAGE_ID is allowed only for a Debug test export." >&2 + exit 1 + fi + android_test_export_root="$(mktemp -d "${TMPDIR:-/tmp}/aether-renpy-android-export.XXXXXX")" + # This is an individually created disposable export directory, never + # the checkout, dependency cache, or an existing application directory. + trap 'rm -rf "$android_test_export_root"' EXIT + python3 "$PROJECT_ROOT/tools/prepare_renpy_android_test_export.py" \ + --source-project "$GODOT_APP_DIR" --destination "$android_test_export_root" \ + --preset-name "$export_preset" --package-id "$android_test_package" + android_export_app_dir="$android_test_export_root" + fi godot_export_command=("$GODOT_BIN") macos_host_extension="$GODOT_APP_DIR/bin/macos/$BUILD_TYPE_LOWER/libaether_kiri_godot.dylib" if [[ "$(uname -m)" == "arm64" && -f "$macos_host_extension" && @@ -499,15 +515,18 @@ else # it imports the project; the exported Android ABI is selected above. godot_export_command=(arch -x86_64 "$GODOT_BIN") fi - "${godot_export_command[@]}" --headless --path "$GODOT_APP_DIR" \ + "${godot_export_command[@]}" --headless --path "$android_export_app_dir" \ "$export_mode" "$export_preset" "$export_path" [[ -s "$export_path" ]] || { echo "Android export did not create an APK: $export_path" >&2; exit 1; } case "${AETHERKIRI_ENABLE_RENPY:-OFF}" in ON|TRUE|YES|1|on|true|yes) python3 "$PROJECT_ROOT/tools/inspect_renpy_android_apk.py" \ --apk "$export_path" --abi "$ABIS" \ - --godot-build "$GODOT_APP_DIR/android/build" \ - --output-dir "$export_dir/diagnostics" + --godot-build "$android_export_app_dir/android/build" \ + --output-dir "$export_dir/diagnostics" \ + --export-presets "$android_export_app_dir/export_presets.cfg" \ + --export-preset "$export_preset" --build-type "$BUILD_TYPE_LOWER" \ + --android-sdk "$ANDROID_HOME" --android-ndk "$ANDROID_NDK_HOME" python3 "$PROJECT_ROOT/tools/validate_renpy_mobile_payload.py" \ --platform android --abi "$ABIS" --apk "$export_path" ;; diff --git a/tools/inspect_renpy_android_apk.py b/tools/inspect_renpy_android_apk.py index c683f664a..be51bae23 100644 --- a/tools/inspect_renpy_android_apk.py +++ b/tools/inspect_renpy_android_apk.py @@ -7,10 +7,18 @@ import argparse import hashlib import json +import os import re +import shutil +import struct +import subprocess +import tempfile import zipfile from pathlib import Path +from prepare_renpy_android_test_export import read_android_preset +from validate_renpy_mobile_payload import LIFECYCLE_SYMBOLS + def sha256(path: Path) -> str: digest = hashlib.sha256() @@ -20,10 +28,114 @@ def sha256(path: Path) -> str: return digest.hexdigest() -def inspect(apk: Path, build: Path, abi: str, output: Path) -> bool: +def parse_badging(text: str) -> dict: + package = re.search(r'^package:\s*(.*)$', text, re.MULTILINE) + minimum = re.search(r"^sdkVersion:'(\d+)'", text, re.MULTILINE) + native = re.search(r'^native-code:\s*(.*)$', text, re.MULTILINE) + if not package or not minimum or not native: + raise ValueError('aapt badging lacks package, minimum SDK, or native ABI metadata') + fields = dict(re.findall(r"(\w+)='([^']*)'", package.group(1))) + target = re.search(r"^targetSdkVersion:'(\d+)'", text, re.MULTILINE) + return {'package': fields['name'], 'version_code': int(fields['versionCode']), + 'version_name': fields['versionName'], 'min_sdk': int(minimum.group(1)), + 'target_sdk': int(target.group(1)) if target else None, + 'debuggable': bool(re.search(r'^application-debuggable\s*$', text, re.MULTILINE)), + 'native_abis': re.findall(r"'([^']+)'", native.group(1))} + + +def sdk_tool(root: Path, pattern: str) -> Path: + tools = [path for path in root.glob(pattern) if path.is_file() and os.access(path, os.X_OK)] + if not tools: + raise ValueError(f'An installed Android SDK tool is required: {root}/{pattern}') + return sorted(tools, key=lambda path: [int(token) for token in re.findall(r'\d+', str(path))])[-1] + + +def command_evidence(command: list[str], path: Path) -> str: + result = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=60) + path.write_text(result.stdout) + if result.returncode: + raise ValueError(f'SDK command failed ({result.returncode}); see {path}') + return result.stdout + + +def audit_android_manifest(apk: Path, sdk: Path, expected: dict, abi: str, + debuggable: bool, output: Path) -> dict: + try: + tool = sdk_tool(sdk, 'build-tools/*/aapt2') + except ValueError: + tool = sdk_tool(sdk, 'build-tools/*/aapt') + actual = parse_badging(command_evidence([str(tool), 'dump', 'badging', str(apk)], + output / 'apk-android-badging.txt')) + expected = dict(expected, debuggable=debuggable, native_abis=[abi]) + errors = [f'Compiled AndroidManifest {key}: expected {value!r}, got {actual.get(key)!r}' + for key, value in expected.items() if actual.get(key) != value] + return {'status': 'failed' if errors else 'passed', 'tool': str(tool), + 'actual': actual, 'expected': expected, 'errors': errors} + + +def audit_native_elf(archive: zipfile.ZipFile, abi: str, ndk: Path, + minimum_sdk: int, output: Path) -> dict: + reader = sdk_tool(ndk, 'toolchains/llvm/prebuilt/*/bin/llvm-readelf') + nm = reader.with_name('llvm-nm') + if not nm.is_file() or not os.access(nm, os.X_OK): + raise ValueError(f'Installed Android NDK llvm-nm is required: {nm}') + triple, machine = {'arm64-v8a': ('aarch64-linux-android', 183), + 'x86_64': ('x86_64-linux-android', 62)}[abi] + platform_libs = reader.parent.parent / 'sysroot/usr/lib' / triple / str(minimum_sdk) + if not platform_libs.is_dir(): + raise ValueError(f'NDK platform stubs are unavailable for SDK {minimum_sdk}: {platform_libs}') + system = {path.name for path in platform_libs.glob('*.so')} + names = [name for name in archive.namelist() if name.startswith(f'lib/{abi}/') and name.endswith('.so')] + packaged = {Path(name).name for name in names} + libraries, errors = {}, [] + with tempfile.TemporaryDirectory(prefix='aether-apk-native-audit-') as temporary: + work = Path(temporary) + for name in sorted(names): + basename = name.removeprefix(f'lib/{abi}/') + if not re.fullmatch(r'lib[A-Za-z0-9_+.-]+\.so', basename) or '..' in basename: + raise ValueError(f'Unsafe APK native path: {name}') + library = work / basename + with archive.open(name) as source, library.open('wb') as destination: + shutil.copyfileobj(source, destination) + with library.open('rb') as stream: + header = stream.read(20) + if len(header) < 20 or header[:6] != b'\x7fELF\x02\x01' or struct.unpack_from(' bool: output.mkdir(parents=True, exist_ok=True) result = {'artifact_kind': 'diagnostic_apk', 'gameplay': 'unverified', - 'apk': str(apk), 'abi': abi, 'package_bytes': 'failed', 'files': {}, 'errors': []} + 'apk': str(apk), 'abi': abi, 'package_bytes': 'failed', 'files': {}, 'errors': [], + 'android_manifest': {'status': 'not_run'}, 'native_elf': {'status': 'not_run'}} try: result['apk_sha256'] = sha256(apk) with zipfile.ZipFile(apk) as archive: @@ -31,6 +143,10 @@ def inspect(apk: Path, build: Path, abi: str, output: Path) -> bool: (output / 'apk-contents.txt').write_text('\n'.join(sorted(names)) + '\n') if len(names) != len(set(names)): result['errors'].append('APK contains duplicate ZIP paths') + if 'assets/renpy_mobile/manifest.properties' in names: + raw_manifest = archive.read('assets/renpy_mobile/manifest.properties').decode('utf-8') + (output / 'apk-renpy-manifest.properties').write_text(raw_manifest) + result['renpy_payload_manifest'] = raw_manifest expected = {} assets = build / 'renpy_assets' for source in assets.rglob('*'): @@ -68,12 +184,29 @@ def inspect(apk: Path, build: Path, abi: str, output: Path) -> bool: if not evidence['match']: result['errors'].append(f'APK file differs from staged input: {name}') result['files'][name] = evidence - except (OSError, zipfile.BadZipFile, RuntimeError, ValueError) as exc: + result['package_bytes'] = 'failed' if result['errors'] else 'passed' + if export_presets is not None: + if not export_preset or build_type not in ('debug', 'release') or android_sdk is None or android_ndk is None: + raise ValueError('Compiled manifest audit requires export preset, build type, installed SDK and NDK') + _, _, expected = read_android_preset(export_presets, export_preset) + result['android_manifest'] = {'status': 'failed'} + result['android_manifest'] = audit_android_manifest(apk, android_sdk, expected, abi, + build_type == 'debug', output) + result['errors'].extend(result['android_manifest']['errors']) + result['native_elf'] = {'status': 'failed'} + result['native_elf'] = audit_native_elf(archive, abi, android_ndk, expected['min_sdk'], output) + result['errors'].extend(result['native_elf']['errors']) + except (OSError, zipfile.BadZipFile, RuntimeError, ValueError, KeyError, subprocess.SubprocessError) as exc: result['errors'].append(str(exc)) passed = not result['errors'] - result['package_bytes'] = 'passed' if passed else 'failed' (output / 'apk-payload-checksums.json').write_text(json.dumps(result, indent=2) + '\n') print(f"Actual APK raw Ren'Py bytes: {result['package_bytes']}; gameplay unverified. Evidence: {output}") + if export_presets is not None: + print('Actual compiled AndroidManifest:', json.dumps(result['android_manifest'])) + print('Actual packaged native ELF audit:', result['native_elf']['status']) + native = result['native_elf'].get('libraries', {}).get(f'lib/{abi}/librenpython.so') + if native: + print('Actual librenpython.so SHA256 / DT_NEEDED / exports:', json.dumps(native)) for error in result['errors'][:10]: print(error) return passed @@ -85,8 +218,20 @@ def main() -> None: parser.add_argument('--godot-build', required=True, type=Path) parser.add_argument('--abi', required=True, choices=('arm64-v8a', 'x86_64')) parser.add_argument('--output-dir', required=True, type=Path) + parser.add_argument('--export-presets', type=Path, + help='require a real SDK compiled-manifest and packaged ELF audit') + parser.add_argument('--export-preset') + parser.add_argument('--build-type', choices=('debug', 'release')) + parser.add_argument('--android-sdk', type=Path) + parser.add_argument('--android-ndk', type=Path) args = parser.parse_args() - raise SystemExit(0 if inspect(args.apk, args.godot_build, args.abi, args.output_dir) else 1) + audit_arguments = (args.export_presets, args.export_preset, args.build_type, args.android_sdk, args.android_ndk) + if any(argument is not None for argument in audit_arguments) and not all(argument is not None for argument in audit_arguments): + parser.error('compiled APK audit requires all --export-presets/--export-preset/--build-type/--android-sdk/--android-ndk options') + raise SystemExit(0 if inspect(args.apk, args.godot_build, args.abi, args.output_dir, + export_presets=args.export_presets, export_preset=args.export_preset, + build_type=args.build_type, android_sdk=args.android_sdk, + android_ndk=args.android_ndk) else 1) if __name__ == '__main__': diff --git a/tools/prepare_renpy_android_test_export.py b/tools/prepare_renpy_android_test_export.py new file mode 100644 index 000000000..df9a4ba75 --- /dev/null +++ b/tools/prepare_renpy_android_test_export.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Prepare a disposable Debug Godot export with a coinstallable test package ID.""" + +import argparse +import configparser +import json +import re +import shutil +from pathlib import Path + + +def read_android_preset(path: Path, name: str) -> tuple[configparser.ConfigParser, str, dict]: + config = configparser.ConfigParser(interpolation=None) + config.optionxform = str + with path.open() as stream: + config.read_file(stream) + sections = [section for section in config.sections() + if re.fullmatch(r'preset\.\d+', section) + and json.loads(config[section].get('name', '""')) == name] + if len(sections) != 1: + raise ValueError(f'Expected exactly one export preset named {name}') + section = sections[0] + if json.loads(config[section].get('platform', '""')) != 'Android': + raise ValueError(f'{name} is not an Android export preset') + options = config[section + '.options'] + expected = { + 'package': json.loads(options['package/unique_name']), + 'version_code': options.getint('version/code'), + 'version_name': json.loads(options['version/name']), + 'min_sdk': options.getint('gradle_build/min_sdk'), + } + return config, section + '.options', expected + + +def prepare(source: Path, destination: Path, preset: str, package: str) -> dict: + if preset not in ('Android Debug', 'Android Emulator Debug'): + raise ValueError('A coinstallable test package is supported only by Android Debug presets') + if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+', package): + raise ValueError('Invalid Android test package ID') + source = source.resolve() + destination = destination.resolve() + if destination == source or source in destination.parents: + raise ValueError('Test export must use a separate directory outside the source project') + if destination.exists() and any(destination.iterdir()): + raise ValueError('Test export destination must be new or empty') + config, section, expected = read_android_preset(source / 'export_presets.cfg', preset) + if package == expected['package']: + raise ValueError('Test package must differ from the product package') + + def ignored(directory: str, names: list[str]) -> list[str]: + path = Path(directory) + if path == source: + return [name for name in names if name in ('.godot', '.git')] + if path == source / 'android/build': + return [name for name in names if name in ('.gradle', 'build')] + return [] + + # Copy files, rather than hard-linking mutable presets/build inputs back to + # the production checkout. Android resources and its staged raw assets stay + # available, while generated editor/Gradle output is regenerated separately. + shutil.copytree(source, destination, dirs_exist_ok=True, ignore=ignored) + config[section]['package/unique_name'] = json.dumps(package) + config[section]['package/name'] = json.dumps('Aether RenPy Test') + with (destination / 'export_presets.cfg').open('w') as stream: + config.write(stream) + return {'source_project': str(source), 'test_project': str(destination), + 'preset': preset, 'product_package': expected['package'], 'test_package': package} + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source-project', required=True, type=Path) + parser.add_argument('--destination', required=True, type=Path) + parser.add_argument('--preset-name', required=True) + parser.add_argument('--package-id', required=True) + args = parser.parse_args() + print(json.dumps(prepare(args.source_project, args.destination, args.preset_name, args.package_id))) diff --git a/tools/test_prepare_renpy_android_test_export.py b/tools/test_prepare_renpy_android_test_export.py new file mode 100644 index 000000000..1f4e37eef --- /dev/null +++ b/tools/test_prepare_renpy_android_test_export.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Verify disposable test export configuration; no Android APK or SDK is run.""" + +import tempfile +import unittest +from pathlib import Path + +from prepare_renpy_android_test_export import prepare, read_android_preset + + +PRESETS = '''[preset.0] +name="Android Debug" +platform="Android" +[preset.0.options] +package/unique_name="org.github.krkr2.aetherkiri" +package/name="Aether" +version/code=4501 +version/name="1.0.7-alpha.3" +gradle_build/min_sdk=26 +architectures/arm64-v8a=true +[preset.1] +name="Android Release" +platform="Android" +[preset.1.options] +package/unique_name="org.github.krkr2.aetherkiri" +package/name="Aether" +version/code=4501 +version/name="1.0.7-alpha.3" +gradle_build/min_sdk=26 +''' + + +class TestExportTests(unittest.TestCase): + def test_source_and_release_are_preserved_raw_assets_and_native_are_copied(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, destination = root / 'source', root / 'test-export' + source.mkdir() + (source / 'export_presets.cfg').write_text(PRESETS) + files = ['project.godot', '.godot/fixture-cache', + 'android/build/renpy_assets/renpy_mobile/private/main.py', + 'android/build/src/main/jniLibs/arm64-v8a/librenpython.so', + 'android/build/build/outputs/old.apk', 'android/build/.gradle/cache', + 'android/build/aether-renpy.gradle'] + for name in files: + path = source / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('Disposable configuration fixture; not a runtime') + result = prepare(source, destination, 'Android Debug', 'org.aetherkiri.renpy.debug') + self.assertEqual(result['product_package'], 'org.github.krkr2.aetherkiri') + self.assertEqual((source / 'export_presets.cfg').read_text(), PRESETS) + config, section, debug = read_android_preset(destination / 'export_presets.cfg', 'Android Debug') + self.assertEqual(debug['package'], 'org.aetherkiri.renpy.debug') + self.assertEqual(config[section]['package/name'], '"Aether RenPy Test"') + _, _, release = read_android_preset(destination / 'export_presets.cfg', 'Android Release') + self.assertEqual(release['package'], 'org.github.krkr2.aetherkiri') + for name in files: + expected = not name.startswith(('.godot/', 'android/build/build/', 'android/build/.gradle/')) + self.assertEqual((destination / name).exists(), expected, name) + # Copies are independent; modifications cannot write back via a hard link. + (destination / 'project.godot').write_text('changed') + self.assertNotEqual((source / 'project.godot').read_text(), 'changed') + + def test_release_product_package_and_nonempty_destination_reject(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / 'source' + source.mkdir() + (source / 'export_presets.cfg').write_text(PRESETS) + for name, package in [('Android Release', 'org.aetherkiri.renpy.debug'), + ('Android Debug', 'org.github.krkr2.aetherkiri'), + ('Android Debug', 'org.bad-package.debug')]: + with self.assertRaises(ValueError): + prepare(source, root / 'test-export', name, package) + with self.assertRaisesRegex(ValueError, 'outside the source'): + prepare(source, source / 'inside', 'Android Debug', 'org.aetherkiri.renpy.debug') + destination = root / 'existing' + destination.mkdir() + (destination / 'keep').write_text('existing file') + with self.assertRaisesRegex(ValueError, 'new or empty'): + prepare(source, destination, 'Android Debug', 'org.aetherkiri.renpy.debug') + self.assertEqual((destination / 'keep').read_text(), 'existing file') + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/test_renpy_android_apk_inspection.py b/tools/test_renpy_android_apk_inspection.py index a53b34ab6..02f307b40 100644 --- a/tools/test_renpy_android_apk_inspection.py +++ b/tools/test_renpy_android_apk_inspection.py @@ -2,15 +2,84 @@ """Test APK byte diagnostics with ZIP fixtures; no mobile app is executed.""" import json +import os +import subprocess import tempfile import unittest import zipfile from pathlib import Path +from unittest.mock import patch -from inspect_renpy_android_apk import inspect +from inspect_renpy_android_apk import audit_android_manifest, audit_native_elf, inspect, parse_badging +from validate_renpy_mobile_payload import LIFECYCLE_SYMBOLS class ApkInspectionTests(unittest.TestCase): + def test_badging_gate_rejects_wrong_package_sdk_version_debug_and_abi(self): + # Command output fixture only, never evidence of a real compiled manifest. + text = """package: name='org.other.app' versionCode='1' versionName='wrong' +sdkVersion:'24' +native-code: 'x86_64' +""" + expected = {'package': 'org.aetherkiri.renpy.debug', 'version_code': 4501, + 'version_name': '1.0.7-alpha.3', 'min_sdk': 26} + with tempfile.TemporaryDirectory() as temporary, \ + patch('inspect_renpy_android_apk.sdk_tool', return_value=Path('/fixture-sdk/aapt2')), \ + patch('inspect_renpy_android_apk.command_evidence', return_value=text): + report = audit_android_manifest(Path('/fixture.apk'), Path('/fixture-sdk'), expected, + 'arm64-v8a', True, Path(temporary)) + self.assertEqual(report['status'], 'failed') + self.assertEqual(len(report['errors']), 6) + + @unittest.skipUnless(os.environ.get('ANDROID_NDK_HOME'), 'real ELF metadata check requires an existing Android NDK') + def test_real_ndk_elf_audit_records_closure_and_rejects_missing_dependency(self): + # Real SDK compile/readelf/nm, but only a tiny ELF contract fixture. It + # contains no Python/RenPy implementation and is never run as gameplay. + ndk = Path(os.environ['ANDROID_NDK_HOME']) + compilers = list(ndk.glob('toolchains/llvm/prebuilt/*/bin/aarch64-linux-android26-clang')) + self.assertTrue(compilers, 'existing NDK must provide the API26 ARM64 compiler') + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / 'dependency.c').write_text('int contract_dependency(void) { return 1; }\n') + symbols = tuple(LIFECYCLE_SYMBOLS) + ('JNI_OnLoad',) + (root / 'contract.c').write_text('/* ELF metadata contract fixture, not RenPy. */\n' + + '\n'.join(f'int {symbol}(void) {{ return 0; }}' for symbol in symbols) + + '\nextern int contract_dependency(void);\nint contract_reference(void) { return contract_dependency(); }\n') + for command in ([str(compilers[0]), '-shared', '-fPIC', str(root / 'dependency.c'), + '-Wl,-soname,libcontract_dependency.so', '-o', str(root / 'libcontract_dependency.so')], + [str(compilers[0]), '-shared', '-fPIC', str(root / 'contract.c'), '-L' + str(root), + '-lcontract_dependency', '-o', str(root / 'librenpython.so')]): + subprocess.run(command, check=True, capture_output=True) + apk = root / 'elf-contract.zip' + for include_dependency in (False, True): + with zipfile.ZipFile(apk, 'w') as archive: + archive.write(root / 'librenpython.so', 'lib/arm64-v8a/librenpython.so') + if include_dependency: + archive.write(root / 'libcontract_dependency.so', 'lib/arm64-v8a/libcontract_dependency.so') + output = root / ('complete' if include_dependency else 'missing') + output.mkdir() + with zipfile.ZipFile(apk) as archive: + report = audit_native_elf(archive, 'arm64-v8a', ndk, 26, output) + self.assertEqual(report['status'], 'passed' if include_dependency else 'failed') + library = report['libraries']['lib/arm64-v8a/librenpython.so'] + self.assertIn('libcontract_dependency.so', library['needed']) + self.assertTrue(set(symbols) <= set(library['defined_exports'])) + self.assertEqual(library['missing_dependencies'], [] if include_dependency else ['libcontract_dependency.so']) + + def test_badging_parser_keeps_compiled_manifest_fields_separate(self): + # Parser contract only: this text is not a real aapt execution or APK. + actual = parse_badging("""package: name='org.aetherkiri.renpy.debug' versionCode='4501' versionName='1.0.7-alpha.3' +sdkVersion:'26' +targetSdkVersion:'35' +application-debuggable +native-code: 'arm64-v8a' +""") + self.assertEqual(actual, {'package': 'org.aetherkiri.renpy.debug', 'version_code': 4501, + 'version_name': '1.0.7-alpha.3', 'min_sdk': 26, 'target_sdk': 35, + 'debuggable': True, 'native_abis': ['arm64-v8a']}) + with self.assertRaisesRegex(ValueError, 'lacks package'): + parse_badging('renpy_version=8.5.3\nnative_abis=arm64-v8a\n') + def test_stale_native_bytes_and_missing_assets_reject(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) From b330d41813cc712328d762e1f7a75f99f1d59882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 17:32:23 +0000 Subject: [PATCH 279/331] ci(renpy): package the validated current Android runtime --- .github/workflows/renpy-android-app.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/renpy-android-app.yml b/.github/workflows/renpy-android-app.yml index 25879464b..922d85386 100644 --- a/.github/workflows/renpy-android-app.yml +++ b/.github/workflows/renpy-android-app.yml @@ -50,13 +50,13 @@ jobs: AETHERKIRI_VCPKG_CACHE_WRITE: 'false' AETHERKIRI_ENABLE_RENPY: 'ON' CCACHE_DIR: ${{ github.workspace }}/.ccache - NATIVE_RUNTIME_RUN: '37787184399' - NATIVE_RUNTIME_ARTIFACT: '11556042880' - NATIVE_RUNTIME_HEAD: '8895c27beef76954bd85cba8b209df9d49a2401f' + NATIVE_RUNTIME_RUN: '37812943810' + NATIVE_RUNTIME_ARTIFACT: '11567113193' + NATIVE_RUNTIME_HEAD: 'dbb863ac101301c8a9d78dea358e2157a0185165' # Keep the disposable Debug key and its data separate from an existing # production installation. The build uses a copied export project. AETHERKIRI_ANDROID_TEST_PACKAGE_ID: org.aetherkiri.renpy.debug - NATIVE_RUNTIME_DIGEST: 'sha256:f05b3951f8a8a717258c034894431df36f13b9db0356c82f3285cde344c17729' + NATIVE_RUNTIME_DIGEST: 'sha256:04a1f247585b8f4647e6dcd8c4102757f558ee542524df1504267b40f0a4239c' steps: - uses: actions/checkout@v6 with: @@ -91,13 +91,13 @@ jobs: 'host_checkout_head': subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip(), 'host_pr_head': os.environ['HOST_PR_HEAD'], 'abi': '${{ matrix.abi }}', 'native_compilation': 'passed', 'gameplay': 'unverified', - 'note': 'Current host packaged with the pinned older native dependency; current-source compilation is checked separately by Build.'} + 'note': 'Host and native revisions are recorded independently. The native dependency passed compilation and validation at its recorded exact commit. Gameplay remains unverified.'} (evidence / 'provenance.json').write_text(json.dumps(provenance, indent=2) + '\n') PY - uses: actions/download-artifact@v6 with: - artifact-ids: 11556042880 - run-id: 37787184399 + artifact-ids: 11567113193 + run-id: 37812943810 github-token: ${{ github.token }} merge-multiple: true path: ${{ runner.temp }}/renpy-runtime-download From 46fd4b215ca2a75123d667bafa58b719a4c8d339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 17:46:06 +0000 Subject: [PATCH 280/331] ci(renpy): read the latest Xcode failure summary --- .github/workflows/renpy-build-diagnostics.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/renpy-build-diagnostics.yml b/.github/workflows/renpy-build-diagnostics.yml index 6dac8f6ca..d66a2beec 100644 --- a/.github/workflows/renpy-build-diagnostics.yml +++ b/.github/workflows/renpy-build-diagnostics.yml @@ -19,7 +19,7 @@ jobs: uses: actions/download-artifact@v6 with: repository: AetherKiri/Aether - run-id: 37806315295 + run-id: 37812944178 name: renpy-ios-source-build-evidence github-token: ${{ github.token }} path: ${{ runner.temp }}/renpy-ios-source-evidence @@ -32,8 +32,8 @@ jobs: logs = sorted(pathlib.Path(sys.argv[1]).rglob('renpy-ios-source-build.log')) if not logs: raise SystemExit('The genuine source-build evidence contains no log files.') - print('Original iOS source run 37806315295; job 113414542371; head e6f5937a1bed3219efd2bc85731df8afd4f60bdf.') - print('Evidence artifact 11563707867; GitHub archive digest sha256:d1062c6895c08977adbc883e6e467b57ec77411d1ee4a8afe53b7243c6a8e712.') + print('Original iOS source run 37812944178; job 113441713032; head dbb863ac101301c8a9d78dea358e2157a0185165.') + print('Evidence artifact 11567923064; GitHub archive digest sha256:74036852733a6c8b5efb4dff45cb2ba7e89d1faf6c6ff20f2176d066ffe551c1.') print('Reading preserved compiler evidence; no build is rerun or simulated.') remaining = 24000 for log in logs: From 1179a33307e9128e2e7bc6ac88639800e81a2788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 18:35:01 +0000 Subject: [PATCH 281/331] fix(android): audit current aapt2 minimum SDK metadata --- tools/inspect_renpy_android_apk.py | 20 +++++++++++++++----- tools/test_renpy_android_apk_inspection.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/tools/inspect_renpy_android_apk.py b/tools/inspect_renpy_android_apk.py index be51bae23..28fb8cc34 100644 --- a/tools/inspect_renpy_android_apk.py +++ b/tools/inspect_renpy_android_apk.py @@ -30,14 +30,18 @@ def sha256(path: Path) -> str: def parse_badging(text: str) -> dict: package = re.search(r'^package:\s*(.*)$', text, re.MULTILINE) - minimum = re.search(r"^sdkVersion:'(\d+)'", text, re.MULTILINE) + # Current aapt2 uses minSdkVersion; older SDK tools used sdkVersion. + # Both describe the actual compiled minimum, never a default. + minimums = re.findall(r"^(?:minSdkVersion|sdkVersion):'(\d+)'", text, re.MULTILINE) native = re.search(r'^native-code:\s*(.*)$', text, re.MULTILINE) - if not package or not minimum or not native: + if not package or not minimums or not native: raise ValueError('aapt badging lacks package, minimum SDK, or native ABI metadata') + if len(set(minimums)) != 1: + raise ValueError('aapt badging reports conflicting minimum SDK versions') fields = dict(re.findall(r"(\w+)='([^']*)'", package.group(1))) target = re.search(r"^targetSdkVersion:'(\d+)'", text, re.MULTILINE) return {'package': fields['name'], 'version_code': int(fields['versionCode']), - 'version_name': fields['versionName'], 'min_sdk': int(minimum.group(1)), + 'version_name': fields['versionName'], 'min_sdk': int(minimums[0]), 'target_sdk': int(target.group(1)) if target else None, 'debuggable': bool(re.search(r'^application-debuggable\s*$', text, re.MULTILINE)), 'native_abis': re.findall(r"'([^']+)'", native.group(1))} @@ -65,8 +69,14 @@ def audit_android_manifest(apk: Path, sdk: Path, expected: dict, abi: str, tool = sdk_tool(sdk, 'build-tools/*/aapt2') except ValueError: tool = sdk_tool(sdk, 'build-tools/*/aapt') - actual = parse_badging(command_evidence([str(tool), 'dump', 'badging', str(apk)], - output / 'apk-android-badging.txt')) + badging = command_evidence([str(tool), 'dump', 'badging', str(apk)], + output / 'apk-android-badging.txt') + # Public package metadata also belongs in the actual build log, so a tool + # format change can be diagnosed without republishing the raw evidence ZIP. + public_fields = [line for line in badging.splitlines() if re.match( + r'^(?:package:|minSdkVersion:|sdkVersion:|targetSdkVersion:|native-code:|application-debuggable\s*$)', line)] + print('Actual SDK badging metadata:', json.dumps(public_fields)) + actual = parse_badging(badging) expected = dict(expected, debuggable=debuggable, native_abis=[abi]) errors = [f'Compiled AndroidManifest {key}: expected {value!r}, got {actual.get(key)!r}' for key, value in expected.items() if actual.get(key) != value] diff --git a/tools/test_renpy_android_apk_inspection.py b/tools/test_renpy_android_apk_inspection.py index 02f307b40..8579b4878 100644 --- a/tools/test_renpy_android_apk_inspection.py +++ b/tools/test_renpy_android_apk_inspection.py @@ -80,6 +80,22 @@ def test_badging_parser_keeps_compiled_manifest_fields_separate(self): with self.assertRaisesRegex(ValueError, 'lacks package'): parse_badging('renpy_version=8.5.3\nnative_abis=arm64-v8a\n') + def test_current_aapt2_min_sdk_format_and_conflicts(self): + # Official AOSP aapt2 DumpManifest.cpp prints minSdkVersion; this is a + # parser regression fixture, not evidence of an executed mobile APK. + text = """package: name='org.aetherkiri.renpy.debug' versionCode='4501' versionName='1.0.7-alpha.3' +minSdkVersion:'26' +targetSdkVersion:'35' +application-debuggable +native-code: 'arm64-v8a' +""" + self.assertEqual(parse_badging(text)['min_sdk'], 26) + self.assertEqual(parse_badging(text + "sdkVersion:'26'\n")['min_sdk'], 26) + with self.assertRaisesRegex(ValueError, 'conflicting minimum SDK'): + parse_badging(text + "sdkVersion:'24'\n") + with self.assertRaisesRegex(ValueError, 'lacks package'): + parse_badging(text.replace("minSdkVersion:'26'\n", '')) + def test_stale_native_bytes_and_missing_assets_reject(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) From e72f11687131f23a434cabc9113b18de5638b3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 18:44:41 +0000 Subject: [PATCH 282/331] ci(renpy): revalidate the genuine exported Android APKs --- .github/workflows/renpy-android-apk-audit.yml | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 .github/workflows/renpy-android-apk-audit.yml diff --git a/.github/workflows/renpy-android-apk-audit.yml b/.github/workflows/renpy-android-apk-audit.yml new file mode 100644 index 000000000..706abecaf --- /dev/null +++ b/.github/workflows/renpy-android-apk-audit.yml @@ -0,0 +1,206 @@ +name: Audit the actual exported Ren'Py APKs + +# These exact APKs were genuinely linked/exported at b330d418. Their first +# audit failed on the aapt2 minSdkVersion format. Recheck the original bytes +# without pretending that this job compiles the host, or republishes raw logs. +on: + pull_request: + paths: + - '.github/workflows/renpy-android-apk-audit.yml' + - 'tools/inspect_renpy_android_apk.py' + - 'tools/validate_renpy_mobile_payload.py' + workflow_dispatch: + +permissions: + contents: read + actions: read + +jobs: + audit: + name: Audit the actual Debug APK (${{ matrix.abi }}) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - abi: arm64-v8a + preset: Android Debug + artifact: '11569036023' + digest: 'sha256:7364686c88b8c02bd43826469f42014353570ae75f3246b7fc2211015fc56c74' + - abi: x86_64 + preset: Android Emulator Debug + artifact: '11570702817' + digest: 'sha256:444ce5528b42fe29ed84a91ea29726ccf83a380dc3d71b6463bba8a12408b211' + env: + SOURCE_RUN: '37817371584' + SOURCE_HOST_HEAD: 'b330d41813cc712328d762e1f7a75f99f1d59882' + NATIVE_RUN: '37812943810' + NATIVE_ARTIFACT: '11567113193' + NATIVE_HEAD: 'dbb863ac101301c8a9d78dea358e2157a0185165' + NATIVE_DIGEST: 'sha256:04a1f247585b8f4647e6dcd8c4102757f558ee542524df1504267b40f0a4239c' + ANDROID_NDK_VERSION: '28.1.13356709' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/checkout@v6 + with: + ref: b330d41813cc712328d762e1f7a75f99f1d59882 + path: original-host + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Verify immutable artifact identities and exact prior failure + env: + GH_TOKEN: ${{ github.token }} + APK_ARTIFACT: ${{ matrix.artifact }} + APK_DIGEST: ${{ matrix.digest }} + ABI: ${{ matrix.abi }} + run: | + python3 - <<'PY' + import json, os, urllib.request + def get(path): + request = urllib.request.Request('https://api.github.com/repos/AetherKiri/Aether/' + path, + headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], + 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}) + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + for run_key, head_key, conclusion in [('SOURCE_RUN', 'SOURCE_HOST_HEAD', 'failure'), + ('NATIVE_RUN', 'NATIVE_HEAD', 'success')]: + run = get('actions/runs/' + os.environ[run_key]) + assert run['head_sha'] == os.environ[head_key] + assert run['status'] == 'completed' and run['conclusion'] == conclusion + for artifact_key, digest_key, run_key, name in [ + ('APK_ARTIFACT', 'APK_DIGEST', 'SOURCE_RUN', 'renpy-android-app-evidence-' + os.environ['ABI']), + ('NATIVE_ARTIFACT', 'NATIVE_DIGEST', 'NATIVE_RUN', 'renpy-android-runtime')]: + artifact = get('actions/artifacts/' + os.environ[artifact_key]) + assert not artifact['expired'] and artifact['name'] == name + assert artifact['workflow_run']['id'] == int(os.environ[run_key]) + assert artifact['digest'] == os.environ[digest_key] + PY + - uses: actions/download-artifact@v6 + with: + artifact-ids: ${{ matrix.artifact }} + run-id: 37817371584 + github-token: ${{ github.token }} + merge-multiple: true + path: ${{ runner.temp }}/original-build-evidence + - uses: actions/download-artifact@v6 + with: + artifact-ids: 11567113193 + run-id: 37812943810 + github-token: ${{ github.token }} + merge-multiple: true + path: ${{ runner.temp }}/original-native-download + - name: Use the normal SDK with its existing license acceptance + run: | + set -euo pipefail + sdk_root="${ANDROID_HOME:-/usr/local/lib/android/sdk}" + test -d "$sdk_root" + ndk_root="$sdk_root/ndk/$ANDROID_NDK_VERSION" + if [[ ! -f "$ndk_root/build/cmake/android.toolchain.cmake" ]]; then + "$sdk_root/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$ANDROID_NDK_VERSION" > "$GITHUB_ENV" + echo "ANDROID_NDK_HOME=$ndk_root" >> "$GITHUB_ENV" + - name: Verify the original APK and restage exact compiled native inputs + env: + ABI: ${{ matrix.abi }} + APK_ARTIFACT: ${{ matrix.artifact }} + APK_DIGEST: ${{ matrix.digest }} + AUDIT_PR_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib, json, os, pathlib, shutil, subprocess + root = pathlib.Path(os.environ['RUNNER_TEMP']) + source = root / 'original-build-evidence' + def one(pattern): + matches = list(source.rglob(pattern)) + assert len(matches) == 1, (pattern, len(matches)) + return matches[0] + previous = json.loads(one('apk-payload-checksums.json').read_text()) + assert previous['gameplay'] == 'unverified' and previous['package_bytes'] == 'passed' + assert previous['android_manifest']['status'] == 'failed' + assert previous['errors'] == ['aapt badging lacks package, minimum SDK, or native ABI metadata'] + assert previous['files'] and all(file['match'] for file in previous['files'].values()) + original = json.loads(one('provenance.json').read_text()) + assert original['host_pr_head'] == os.environ['SOURCE_HOST_HEAD'] + assert original['native_source_head'] == os.environ['NATIVE_HEAD'] + assert original['native_source_run'] == int(os.environ['NATIVE_RUN']) + assert original['native_artifact_id'] == int(os.environ['NATIVE_ARTIFACT']) + assert original['native_artifact_digest'] == os.environ['NATIVE_DIGEST'] + assert original['abi'] == previous['abi'] == os.environ['ABI'] + apk = one('*.apk') + apk_hash = hashlib.sha256(apk.read_bytes()).hexdigest() + assert apk_hash == previous['apk_sha256'] + result = root / 'apk-audit-result' + result.mkdir() + deliverable = root / 'validated-apk' + deliverable.mkdir() + shutil.copyfile(apk, deliverable / apk.name) + provenance = {'original_build': original, 'original_apk_sha256': apk_hash, + 'source_app_artifact': int(os.environ['APK_ARTIFACT']), 'source_app_artifact_digest': os.environ['APK_DIGEST'], + 'audit_pr_head': os.environ['AUDIT_PR_HEAD'], + 'audit_checkout_head': subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip(), + 'new_host_compilation': 'not_run', 'gameplay': 'unverified'} + (result / 'provenance.json').write_text(json.dumps(provenance, indent=2) + '\n') + with open(os.environ['GITHUB_ENV'], 'a') as env: + env.write('AUDIT_APK=' + str(deliverable / apk.name) + '\n') + PY + mkdir -p "$RUNNER_TEMP/original-native" "$RUNNER_TEMP/audit-staging" + tar -xzf "$RUNNER_TEMP/original-native-download/renpy-android-runtime.tar.gz" -C "$RUNNER_TEMP/original-native" + # This is reference staging for byte comparison, never an app build. + # Use the exact original script/Java support and native dependency. + touch "$RUNNER_TEMP/audit-staging/build.gradle" + bash original-host/tools/stage_renpy_android_support.sh \ + --mobile-root "$RUNNER_TEMP/original-native/payload" \ + --godot-build "$RUNNER_TEMP/audit-staging" --abis '${{ matrix.abi }}' + python3 original-host/tools/prepare_renpy_android_test_export.py \ + --source-project original-host/apps/godot_app --destination "$RUNNER_TEMP/audit-project" \ + --preset-name '${{ matrix.preset }}' --package-id org.aetherkiri.renpy.debug + - name: Audit real SDK metadata, packaged ELF closure, raw bytes and signature + run: | + set -euo pipefail + python3 tools/inspect_renpy_android_apk.py --apk "$AUDIT_APK" \ + --godot-build "$RUNNER_TEMP/audit-staging" --abi '${{ matrix.abi }}' \ + --output-dir "$RUNNER_TEMP/apk-audit-result" \ + --export-presets "$RUNNER_TEMP/audit-project/export_presets.cfg" --export-preset '${{ matrix.preset }}' \ + --build-type debug --android-sdk "$ANDROID_HOME" --android-ndk "$ANDROID_NDK_HOME" + python3 tools/validate_renpy_mobile_payload.py --platform android --abi '${{ matrix.abi }}' --apk "$AUDIT_APK" + apksigner="$(find "$ANDROID_HOME/build-tools" -name apksigner -type f | sort -V | tail -1)" + test -x "$apksigner" + "$apksigner" verify --verbose --print-certs "$AUDIT_APK" | tee "$RUNNER_TEMP/apk-audit-result/apk-signature.txt" + - name: Upload only the revalidated installable APK + uses: actions/upload-artifact@v6 + with: + name: renpy-installable-debug-${{ matrix.abi }} + path: ${{ runner.temp }}/validated-apk/*.apk + if-no-files-found: error + retention-days: 7 + - name: Upload the original public demo and phone instructions + if: matrix.abi == 'arm64-v8a' + uses: actions/upload-artifact@v6 + with: + name: renpy-demo-project + path: | + original-host/demos/aetherkiri-renpy/game + original-host/demos/aetherkiri-renpy/README.md + if-no-files-found: error + retention-days: 7 + - name: Preserve only newly generated audit metadata + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-android-apk-audit-evidence-${{ matrix.abi }} + path: | + ${{ runner.temp }}/apk-audit-result/*.json + ${{ runner.temp }}/apk-audit-result/*.readelf.txt + ${{ runner.temp }}/apk-audit-result/*.exports.txt + ${{ runner.temp }}/apk-audit-result/apk-android-badging.txt + ${{ runner.temp }}/apk-audit-result/apk-signature.txt + if-no-files-found: warn + retention-days: 7 From 2fe86c0b70817832a530b87b2b42e50127ebe4d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 19:07:31 +0000 Subject: [PATCH 283/331] fix(ios): use real target libffi for static Python ctypes --- .github/workflows/renpy-android-apk-audit.yml | 1 + .github/workflows/renpy-ios-runtime-build.yml | 4 + .../native/0007-darwin-ios-build.patch | 58 ++- tools/test_renpy_mobile_ios_libffi.py | 368 ++++++++++++++++++ 4 files changed, 429 insertions(+), 2 deletions(-) create mode 100755 tools/test_renpy_mobile_ios_libffi.py diff --git a/.github/workflows/renpy-android-apk-audit.yml b/.github/workflows/renpy-android-apk-audit.yml index 706abecaf..61d64d8f8 100644 --- a/.github/workflows/renpy-android-apk-audit.yml +++ b/.github/workflows/renpy-android-apk-audit.yml @@ -137,6 +137,7 @@ jobs: apk = one('*.apk') apk_hash = hashlib.sha256(apk.read_bytes()).hexdigest() assert apk_hash == previous['apk_sha256'] + print('Original exported APK SHA256:', apk_hash) result = root / 'apk-audit-result' result.mkdir() deliverable = root / 'validated-apk' diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index 0de433a30..94376f4af 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -7,6 +7,7 @@ on: - 'tools/run_renpy_mobile_source_build.sh' - 'tools/apply_renpy_mobile_patch_series.py' - 'tools/validate_renpy_mobile_payload.py' + - 'tools/test_renpy_mobile_ios_libffi.py' - 'tools/renpy_ios_acceptance/**' - 'tools/run_renpy_ios_device_acceptance.py' - 'tools/patch_renpy_ios_runtime_project.py' @@ -121,6 +122,9 @@ jobs: uv --project "$RENPY_BUILD_ROOT/renpy" run python \ "$GITHUB_WORKSPACE/tools/test_renpy_mobile_ios_nis.py" "$RENPY_BUILD_ROOT" \ 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" + uv --project "$RENPY_BUILD_ROOT/renpy" run python \ + "$GITHUB_WORKSPACE/tools/test_renpy_mobile_ios_libffi.py" "$RENPY_BUILD_ROOT" \ + 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" - name: Compile target CPython, SDL, Ren'Py and device/Simulator archives if: steps.runtime-cache.outputs.cache-hit != 'true' run: | diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch index f0274475c..0c44314ef 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch @@ -48,7 +48,21 @@ diff --git a/renpybuild/run.py b/renpybuild/run.py c.var("cmake_configure", "cmake") c.var("sysroot", c.tmp / f"sysroot.{c.platform}-{c.arch}") -@@ -192,14 +211,22 @@ +@@ -175,9 +204,11 @@ + c.var("sdl_host_platform", "{{ host_platform }}") + + if (c.platform == "ios") and (c.arch == "arm64"): +- c.var("ffi_host_platform", "aarch64-ios-darwin21") ++ # libffi enables Apple's executable trampoline table by host vendor. ++ # The compiler still targets the selected device/Simulator SDK. ++ c.var("ffi_host_platform", "aarch64-apple-darwin21") + elif (c.platform == "ios") and (c.arch == "sim-arm64"): +- c.var("ffi_host_platform", "aarch64-ios-darwin21") ++ c.var("ffi_host_platform", "aarch64-apple-darwin21") + elif (c.platform == "mac") and (c.arch == "arm64"): + c.var("ffi_host_platform", "aarch64-apple-darwin21.6.0") + else: +@@ -192,14 +213,22 @@ elif (c.platform == "ios") and (c.arch == "sim-x86_64"): c.env("IPHONEOS_DEPLOYMENT_TARGET", "13.0") @@ -195,6 +209,17 @@ diff --git a/tasks/hostpython3.py b/tasks/hostpython3.py diff --git a/tasks/python3.py b/tasks/python3.py --- a/tasks/python3.py +++ b/tasks/python3.py +@@ -57,6 +57,10 @@ + c.chdir("Python-{{ version }}") + c.patch("Python-{{ version }}/ios-posixmodule.diff") + ++ # Prefer the explicitly supplied target libffi over Darwin's desktop SDK. ++ c.patch("Python-{{ version }}/aether-external-libffi.diff") ++ c.run("autoreconf -vfi") ++ + c.run("cp {{patches}}/_scproxy.pyx Modules") + c.chdir("Modules") + c.run("rm -f _scproxy.c") @@ -106,12 +106,11 @@ c.copy("{{ host }}/bin/python3", "{{ install }}/bin/hostpython3") @@ -213,7 +238,18 @@ diff --git a/tasks/python3.py b/tasks/python3.py @task(kind="python", pythons="3", platforms="linux,mac") -@@ -140,6 +139,9 @@ +@@ -132,6 +135,10 @@ + def build_ios(c: Context): + common(c) + ++ c.env("SDKROOT", "{{ cross }}/sdk") ++ c.env("LIBFFI_CFLAGS", "-I{{ install }}/include") ++ c.env("LIBFFI_LIBS", "-L{{ install }}/lib -lffi") ++ + with open(c.path("config.site"), "a") as f: + f.write("ac_cv_little_endian_double=yes\n") + # f.write("ac_cv_header_langinfo_h=no\n") +@@ -140,6 +147,9 @@ f.write("ac_cv_func_getentropy=no\n") f.write("ac_cv_have_long_long_format=yes\n") - f.write("ac_cv_func_clock_settime=no") @@ -224,6 +260,24 @@ diff --git a/tasks/python3.py b/tasks/python3.py c.run(""" {{configure}} {{ cross_config }} +diff --git a/patches/Python-3.12.8/aether-external-libffi.diff b/patches/Python-3.12.8/aether-external-libffi.diff +new file mode 100644 +--- /dev/null ++++ b/patches/Python-3.12.8/aether-external-libffi.diff +@@ -0,0 +1,13 @@ ++diff --git a/configure.ac b/configure.ac ++--- a/configure.ac +++++ b/configure.ac ++@@ -3840,7 +3840,8 @@ ++ ++ dnl detect libffi ++ have_libffi=missing ++-AS_VAR_IF([ac_sys_system], [Darwin], [ +++dnl Respect explicit libffi flags before Darwin's SDK auto-selection. +++AS_IF([test "$ac_sys_system" = Darwin && test -z "$LIBFFI_CFLAGS$LIBFFI_LIBS"], [ ++ WITH_SAVE_ENV([ ++ CFLAGS="-I${SDKROOT}/usr/include/ffi $CFLAGS" ++ AC_CHECK_HEADER([ffi.h], [ diff --git a/tasks/librenpy.py b/tasks/librenpy.py --- a/tasks/librenpy.py +++ b/tasks/librenpy.py diff --git a/tools/test_renpy_mobile_ios_libffi.py b/tools/test_renpy_mobile_ios_libffi.py new file mode 100755 index 000000000..b1b1ac7bb --- /dev/null +++ b/tools/test_renpy_mobile_ios_libffi.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +"""Exercise pinned CPython patch tasks and genuine external libffi builds. + +Linux results are host compilation/execution only. On Darwin, additional +device and Simulator cases configure CPython and link using the selected +real Xcode SDKs. Target executables are not installed or run as gameplay. +""" +import argparse +import ast +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import sys +import tarfile +import tempfile +from types import SimpleNamespace + +REPO = Path(__file__).resolve().parents[1] +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" +PATCHES = REPO / "bridge/renpy_runtime/mobile_launcher/patches/native" +PYTHON_VERSION = "3.12.8" +FFI_VERSION = "3.4.5" +API_SOURCE = r""" +#include +#include +static int add_three(int value) { return value + 3; } +static void callback(ffi_cif *cif, void *result, void **arguments, void *data) { + (void)cif; + *(int *)result = *(int *)arguments[0] + *(int *)data; +} +int main(void) { + ffi_cif cif, variadic; + ffi_type *types[] = { &ffi_type_sint }; + int value = 7, increment = 5; + void *arguments[] = { &value }, *code = NULL; + ffi_arg result = 0; + if (ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 1, &ffi_type_sint, types) != FFI_OK) return 1; + if (ffi_prep_cif_var(&variadic, FFI_DEFAULT_ABI, 1, 1, &ffi_type_sint, types) != FFI_OK) return 2; + ffi_call(&cif, FFI_FN(add_three), &result, arguments); + if ((int)result != 10) return 3; + ffi_closure *closure = ffi_closure_alloc(sizeof(*closure), &code); + if (!closure || !code) return 4; + if (ffi_prep_closure_loc(closure, &cif, callback, &increment, code) != FFI_OK) return 5; + int actual = ((int (*)(int))code)(value); + ffi_closure_free(closure); + if (actual != 12) return 6; + puts("Genuine libffi call and executable closure returned 10 and 12"); + return 0; +} +""" + + +class BeforeMake(Exception): + """Only capture task generation; genuine builds run separately below.""" + + +def run(command, *, cwd, env=None, log): + result = subprocess.run(command, cwd=cwd, env=env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=600) + log.parent.mkdir(parents=True, exist_ok=True) + log.write_text("Command: " + shlex.join(map(str, command)) + "\n" + result.stdout) + if result.returncode: + raise RuntimeError(f"Command failed ({result.returncode}); {log}\n{result.stdout[-7000:]}") + return result.stdout + + +def extract(path, destination): + with tarfile.open(path) as source: + source.extractall(destination, filter="data") + + +def task_namespace(path, Context): + selected = [node for node in ast.parse(path.read_text()).body + if isinstance(node, ast.FunctionDef) and node.name in + ("common", "common_post", "patch_posix", "patch_ios", "build_ios")] + assert len(selected) == 5 + for node in selected: + node.decorator_list = [] + namespace = {"Context": Context, "version": PYTHON_VERSION} + exec(compile(ast.Module(body=selected, type_ignores=[]), str(path), "exec"), namespace) + return namespace + + +def make_variables(path): + values = {} + for line in path.read_text().splitlines(): + match = re.match(r"([A-Za-z0-9_]+)\s*=\s*(.*)", line) + if match: + values[match.group(1)] = match.group(2) + def expand(value): + for _ in range(20): + # GNU make expands an unset override such as CFLAGS to empty. + expanded = re.sub(r"\$\((\w+)\)", lambda match: values.get(match.group(1), ""), value) + if expanded == value: + assert "$" not in value, f"Unexpanded compiler flags: {value}" + return value + value = expanded + raise AssertionError("Recursive Makefile flags") + return values, expand + + +def capture_ios_task(namespace, Context, root, shared, architecture, source): + context = Context("ios", architecture, "3", root, SimpleNamespace()) + context.set_names("python", "build_ios", "python3") + context.var("source", shared / "source") + context.cwd = source.parent + captured = {} + def capture(command, **kwargs): + expanded = context.expand(command) + if expanded.strip().startswith("./configure"): + assert not captured, "Unexpected second configure" + captured.update(command=shlex.split(expanded), environment=dict(context.environ)) + return + # common_post has genuinely generated both static module templates at + # this point. No make/install or target Python build is claimed here. + assert expanded.strip().startswith("nice make"), expanded + raise BeforeMake() + context.run = capture + try: + namespace["build_ios"](context) + except BeforeMake: + pass + assert captured and context.cwd == source + environment = captured["environment"] + assert environment["SDKROOT"] == str(context.path("{{ cross }}/sdk")) + assert environment["LIBFFI_CFLAGS"] == "-I" + str(context.install / "include") + assert environment["LIBFFI_LIBS"] == "-L" + str(context.install / "lib") + " -lffi" + for name in ("Setup", "Setup.stdlib"): + line = next(line for line in (source / "Modules" / name).read_text().splitlines() + if line.startswith("_ctypes ")) + assert "_ctypes/malloc_closure.c" in line and "-lffi" in line + assert "USING_APPLE_OS_LIBFFI" not in line + assert "ac_cv_func_ffi_" not in (source / "config.site").read_text() + return context, captured + + +def ffi_and_python_case(label, *, root, shared, namespace, Context, compiler, + architecture, sdk, target, cython, jobs, evidence): + case = root / label + case.mkdir() + extract(shared / f"source/Python-{PYTHON_VERSION}.tar.xz", case) + python_source = case / f"Python-{PYTHON_VERSION}" + patch_context = Context("ios", architecture, "3", case, SimpleNamespace()) + patch_context.set_names("python", "patch_posix", "python3") + patch_context.cwd = case + patch_context.var("patches", shared / "patches") + patch_context.environ["PATH"] = str(cython.parent) + os.pathsep + patch_context.environ["PATH"] + # These are the complete real task bodies, with real Context.patch/run. + namespace["patch_posix"](patch_context) + patch_context.cwd = case + namespace["patch_ios"](patch_context) + generated = (python_source / "Modules/_scproxy.c").read_text() + assert "Generated by Cython 3.2.4" in generated + assert "HAVE_SYSTEM" in (python_source / "Modules/posixmodule.c").read_text() + + context, captured = capture_ios_task(namespace, Context, case, shared, architecture, python_source) + if sdk: + cross = context.path("{{ cross }}/sdk") + cross.parent.mkdir(parents=True, exist_ok=True) + cross.symlink_to(sdk, target_is_directory=True) + assert cross.resolve() == sdk.resolve(), "Only a genuine selected Xcode SDK may be used" + prefix = context.install + host_python = context.path("{{ host }}/bin/python3") + host_python.parent.mkdir(parents=True, exist_ok=True) + host_python.symlink_to(Path(sys.executable).resolve()) + + extract(shared / f"source/libffi-{FFI_VERSION}.tar.gz", case) + ffi_source = case / f"libffi-{FFI_VERSION}" + patch_context.cwd = ffi_source + patch_context.patchdir("libffi") + initial_ffi_configs = {path: path.read_bytes() for path in ffi_source.rglob("fficonfig.h")} + words = [str(compiler)] + if target: + words += ["-target", target, "-isysroot", str(sdk)] + elif sdk: + words += ["-isysroot", str(sdk)] + environment = dict(os.environ, CC=shlex.join(words), CFLAGS="", CPPFLAGS="", LDFLAGS="", LIBS="") + ffi_command = ["./configure", "--disable-shared", "--enable-portable-binary", "--prefix=" + str(prefix)] + if target: + # Use the actual production libffi tuple; do not silently fix it in a test. + ffi_command += shlex.split(context.expand("{{ ffi_cross_config }}")) + run(ffi_command, cwd=ffi_source, env=environment, log=evidence / f"{label}-ffi-configure.log") + run(["make", "-j", str(jobs)], cwd=ffi_source, env=environment, log=evidence / f"{label}-ffi-build.log") + run(["make", "install"], cwd=ffi_source, env=environment, log=evidence / f"{label}-ffi-install.log") + # The official archive also ships a pre-generated MSVC ARM64 header. Only + # a header created/changed by this genuine configure belongs to this case. + ffi_configs = [path for path in ffi_source.rglob("fficonfig.h") + if initial_ffi_configs.get(path) != path.read_bytes()] + assert len(ffi_configs) == 1, f"Expected one genuinely generated libffi configuration: {ffi_configs}" + trampoline_table = bool(re.search(r"^#define FFI_EXEC_TRAMPOLINE_TABLE 1$", ffi_configs[0].read_text(), re.M)) + if target: + assert trampoline_table, "iOS ARM64 external libffi must use Apple's static executable trampoline table" + static_ffi = prefix / "lib/libffi.a" + assert static_ffi.is_file() and not (prefix / "lib/libffi.dylib").exists() + + environment.update(LIBFFI_CFLAGS=captured["environment"]["LIBFFI_CFLAGS"], + LIBFFI_LIBS=captured["environment"]["LIBFFI_LIBS"], + CPPFLAGS="-I" + str(prefix / "include"), + LDFLAGS="-L" + str(prefix / "lib"), + CONFIG_SITE=str(python_source / "config.site"), + SDKROOT=str(sdk) if sdk else "") + # No function cache is supplied. CPython's three genuine ffi probes are + # compile checks; the API executable and link map below establish linking. + assert not any(key.startswith("ac_cv_func_ffi_") for key in environment) + configure = captured["command"] if target else ["./configure", "--prefix=" + str(prefix), "--with-ensurepip=no"] + run(configure, cwd=python_source, env=environment, log=evidence / f"{label}-python-configure.log") + # Production runs configure first, then replaces BOTH Setup files. Repeat + # the genuine common_post generation in that order, stopping before make. + # Its static extensions use global compiler flags plus this Setup line, + # rather than assuming MODULE__CTYPES_CFLAGS controls the static object. + try: + namespace["common_post"](context) + except BeforeMake: + pass + assert (python_source / "Modules/Setup").read_bytes() == (python_source / "Modules/Setup.stdlib").read_bytes() + # Execute the genuine makesetup rule after the production templates have + # replaced configure's Setup.stdlib, without building the interpreter. + run(["make", "Makefile"], cwd=python_source, env=environment, + log=evidence / f"{label}-static-makefile.log") + variables, expand = make_variables(python_source / "Makefile") + assert variables["MODULE__CTYPES_STATE"] == "yes", variables["MODULE__CTYPES_STATE"] + cflags = shlex.split(expand(variables["MODULE__CTYPES_CFLAGS"])) + ldflags = shlex.split(expand(variables["MODULE__CTYPES_LDFLAGS"])) + assert "-I" + str(prefix / "include") in cflags, cflags + assert "-L" + str(prefix / "lib") in ldflags and "-lffi" in ldflags, ldflags + assert not any("USING_APPLE_OS_LIBFFI" in flag for flag in cflags), cflags + config = (python_source / "pyconfig.h").read_text() + for feature in ("FFI_PREP_CIF_VAR", "FFI_PREP_CLOSURE_LOC", "FFI_CLOSURE_ALLOC"): + assert re.search(r"^#define HAVE_" + feature + r" 1$", config, re.M), feature + api = case / "ffi-api.c" + api.write_text(API_SOURCE) + executable = case / "ffi-api" + link_map = evidence / f"{label}-ffi-link.map" + map_flag = "-Wl,-map," if sys.platform == "darwin" else "-Wl,-Map," + run([*words, "-Wall", "-Wextra", "-Werror", *cflags, str(api), *ldflags, + map_flag + str(link_map), "-o", str(executable)], cwd=case, + env=environment, log=evidence / f"{label}-ffi-api-link.log") + assert str(static_ffi) in link_map.read_text(), "API link did not consume the freshly built external libffi.a" + closure_object = case / "malloc_closure.o" + # Linux configure normally omits malloc_closure.c. This particular object + # uses the actual iOS static Setup's definitions, including the symbol + # wrappers that prevent aliases from recursively calling themselves. + static_line = next(line for line in (python_source / "Modules/Setup").read_text().splitlines() + if line.startswith("_ctypes ")) + static_definitions = [flag for flag in shlex.split(static_line) if flag.startswith("-D")] + assert "_ctypes/malloc_closure.c" in static_line and "-lffi" in static_line + assert not any("USING_APPLE_OS_LIBFFI" in flag for flag in static_definitions) + global_cflags = shlex.split(expand(variables["PY_BUILTIN_MODULE_CFLAGS"])) + assert "-I" + str(prefix / "include") in global_cflags, global_cflags + dry_run = run(["make", "-n", "Modules/_ctypes/malloc_closure.o"], cwd=python_source, + env=environment, log=evidence / f"{label}-ctypes-make-command.log") + commands = [shlex.split(line) for line in dry_run.splitlines() + if "malloc_closure.c" in line and " -c " in line] + assert len(commands) == 1, dry_run + closure_command = commands[0] + assert all(flag in closure_command for flag in static_definitions), closure_command + assert "-I" + str(prefix / "include") in closure_command, closure_command + assert "USING_APPLE_OS_LIBFFI" not in " ".join(closure_command) + closure_command[closure_command.index("-o") + 1] = str(closure_object) + headers = run([*closure_command, "-Wall", "-Wextra", "-Werror", "-H"], + cwd=python_source, env=environment, log=evidence / f"{label}-ctypes-closure-compile.log") + assert str(prefix / "include/ffi.h") in headers, "Actual static closure object did not include the external libffi header" + wrapped_api = case / "ctypes-ffi-api.c" + wrapped_api.write_text("#include \n" + "extern void *Py_ffi_closure_alloc(size_t, void **);\n" + "extern void Py_ffi_closure_free(void *);\n" + API_SOURCE.replace( + "ffi_closure_alloc(sizeof(*closure), &code)", "Py_ffi_closure_alloc(sizeof(*closure), &code)").replace( + "ffi_closure_free(closure)", "Py_ffi_closure_free(closure)")) + wrapped_executable = case / "ctypes-ffi-api" + wrapped_map = evidence / f"{label}-ctypes-ffi-link.map" + run([*words, "-Wall", "-Wextra", "-Werror", *cflags, str(wrapped_api), str(closure_object), *ldflags, + map_flag + str(wrapped_map), "-o", str(wrapped_executable)], cwd=case, + env=environment, log=evidence / f"{label}-ctypes-ffi-api-link.log") + assert str(static_ffi) in wrapped_map.read_text(), "CPython closure wrapper did not link to the external libffi.a" + if target: + assert executable.read_bytes()[:4] == bytes.fromhex("cffaedfe") + assert closure_object.read_bytes()[:4] == bytes.fromhex("cffaedfe") + assert wrapped_executable.read_bytes()[:4] == bytes.fromhex("cffaedfe") + output = run(["xcrun", "nm", "-u", str(executable)], cwd=case, + log=evidence / f"{label}-ffi-unresolved.txt") + assert not re.search(r"\b_ffi_\w+", output), output + else: + run([str(executable)], cwd=case, env=environment, log=evidence / f"{label}-ffi-api-run.log") + run([str(wrapped_executable)], cwd=case, env=environment, log=evidence / f"{label}-ctypes-ffi-api-run.log") + compiler_version = subprocess.check_output([str(compiler), "--version"], text=True).splitlines()[0] + record = {"case": label, "compiler": words, "compiler_version": compiler_version, + "build_source_pin": PIN, "python_version": PYTHON_VERSION, "libffi_version": FFI_VERSION, + "sdk": str(sdk) if sdk else None, + "configure_ffi_headers": "passed", "ffi_api_link": "passed", + "closure_object": "passed", "ctypes_closure_link": "passed", + "ffi_api_execution": "not_run" if target else "passed", + "ctypes_closure_execution": "not_run" if target else "passed", + "ffi_trampoline_table": trampoline_table, + "libffi_sha256": hashlib.sha256(static_ffi.read_bytes()).hexdigest(), + "ctypes_compile_flags": cflags, "ctypes_link_flags": ldflags, + "static_global_compile_flags": global_cflags, "static_definitions": static_definitions, + "actual_static_closure_command": closure_command, + "actual_static_closure_header": str(prefix / "include/ffi.h"), + "gameplay": "unverified"} + (evidence / f"{label}-result.json").write_text(json.dumps(record, indent=2) + "\n") + print(json.dumps(record, sort_keys=True), flush=True) + print(f"{label}: genuine patch/autoreconf/Cython, libffi build, CPython configure, closure object and API link PASS; " + + ("target API execution not run" if target else "host ffi_call/closure execution PASS"), flush=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build_source", type=Path) + parser.add_argument("--cython", type=Path) + parser.add_argument("--output-dir", type=Path) + parser.add_argument("--jobs", type=int, default=2) + args = parser.parse_args() + assert args.jobs > 0 + assert sys.version_info[:2] == (3, 12), "The genuine CPython 3.12 configure requires a matching build Python" + assert subprocess.check_output(["git", "-C", str(args.build_source), "rev-parse", "HEAD"], text=True).strip() == PIN + cython = args.cython or Path(shutil.which("cython") or "") + assert cython.is_file() and os.access(cython, os.X_OK), "Genuine Cython 3.2.4 is required" + cython_version = subprocess.check_output([str(cython), "--version"], text=True, stderr=subprocess.STDOUT) + assert "Cython version 3.2.4" in cython_version, cython_version + evidence = (args.output_dir or Path(tempfile.mkdtemp(prefix="renpy-ios-libffi-evidence-"))).resolve() + evidence.mkdir(parents=True, exist_ok=True) + print("Evidence:", evidence, flush=True) + with tempfile.TemporaryDirectory(prefix="renpy-ios-libffi-") as temporary: + root = Path(temporary) + shared = root / "build-source" + shared.mkdir() + archive = subprocess.Popen(["git", "-C", str(args.build_source), "archive", "HEAD", "renpybuild", "tasks", + "patches", "source/Python-3.12.8-Setup.stdlib", "source/Python-3.12.8.tar.xz", + "source/libffi-3.4.5.tar.gz", "tools/cmake_build_variables.cmake"], stdout=subprocess.PIPE) + assert archive.stdout is not None + with tarfile.open(fileobj=archive.stdout, mode="r|*") as source: + source.extractall(shared, filter="data") + assert archive.wait() == 0 + run(["git", "apply", "--include=tasks/metalangle.py", str(PATCHES / "0006-ios-offscreen-renderer.patch")], + cwd=shared, log=evidence / "0006-prerequisite.log") + run(["git", "apply", str(PATCHES / "0007-darwin-ios-build.patch")], + cwd=shared, log=evidence / "0007-production-patch.log") + sys.path.insert(0, str(shared)) + from renpybuild.context import Context + namespace = task_namespace(shared / "tasks/python3.py", Context) + compiler = Path(subprocess.check_output(["xcrun", "--find", "clang"], text=True).strip()) if sys.platform == "darwin" \ + else Path(shutil.which("cc") or "") + assert compiler.is_file(), "A genuine host C compiler is required" + host_sdk = Path(subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-path"], text=True).strip()) \ + if sys.platform == "darwin" else None + ffi_and_python_case("host", root=root, shared=shared, namespace=namespace, Context=Context, + compiler=compiler, architecture="arm64", sdk=host_sdk, target=None, + cython=cython, jobs=args.jobs, evidence=evidence) + if sys.platform == "darwin": + for architecture, sdk_name, target in (("arm64", "iphoneos", "arm64-apple-ios13.0"), + ("sim-arm64", "iphonesimulator", "arm64-apple-ios13.0-simulator")): + sdk = Path(subprocess.check_output(["xcrun", "--sdk", sdk_name, "--show-sdk-path"], text=True).strip()) + assert sdk.is_dir(), sdk + ffi_and_python_case(sdk_name, root=root, shared=shared, namespace=namespace, Context=Context, + compiler=compiler, architecture=architecture, sdk=sdk, target=target, + cython=cython, jobs=args.jobs, evidence=evidence) + else: + print("Linux host checks passed; iOS device/Simulator SDK compilation and linking NOT RUN", flush=True) + print("No iOS executable was installed or launched; no Ren'Py/GL/device gameplay was tested", flush=True) + + +if __name__ == "__main__": + main() From 69093f2a3d812eb456770070269b71c3663e32af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 19:32:20 +0000 Subject: [PATCH 284/331] ci(ios): run genuine target libffi validation independently --- .github/workflows/renpy-ios-libffi.yml | 73 ++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .github/workflows/renpy-ios-libffi.yml diff --git a/.github/workflows/renpy-ios-libffi.yml b/.github/workflows/renpy-ios-libffi.yml new file mode 100644 index 000000000..c098a3337 --- /dev/null +++ b/.github/workflows/renpy-ios-libffi.yml @@ -0,0 +1,73 @@ +name: Ren'Py actual iOS libffi regression + +# The same genuine regression also gates the full iOS source build. Run it +# independently so a prior long source build cannot hide a target SDK error. +# This job never builds, installs or exercises the Ren'Py application. +on: + pull_request: + paths: + - '.github/workflows/renpy-ios-libffi.yml' + - 'tools/test_renpy_mobile_ios_libffi.py' + - 'bridge/renpy_runtime/mobile_launcher/patches/native/0006-ios-offscreen-renderer.patch' + - 'bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: renpy-ios-libffi-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + libffi: + name: Compile and link external ffi with the actual iOS SDKs + runs-on: macos-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Record the selected SDKs and provision ordinary build tools + run: | + set -euo pipefail + xcodebuild -version + for sdk in macosx iphoneos iphonesimulator; do + xcrun --sdk "$sdk" --show-sdk-path + xcrun --sdk "$sdk" --show-sdk-version + done + brew install autoconf autoconf-archive automake libtool ccache python@3.12 + echo "$(brew --prefix python@3.12)/libexec/bin" >> "$GITHUB_PATH" + echo "LIBTOOLIZE=glibtoolize" >> "$GITHUB_ENV" + - name: Fetch only the pinned public Python and libffi build sources + run: | + set -euo pipefail + source_root="$RUNNER_TEMP/renpy-libffi-source" + git clone --depth=1 --filter=blob:none --no-checkout https://github.com/renpy/renpy-build.git "$source_root" + git -C "$source_root" sparse-checkout init --no-cone + git -C "$source_root" sparse-checkout set --no-cone \ + /renpybuild/ /tasks/ /patches/ /source/Python-3.12.8.tar.xz \ + /source/libffi-3.4.5.tar.gz /source/Python-3.12.8-Setup.stdlib /tools/cmake_build_variables.cmake + git -C "$source_root" fetch --depth=1 origin 7bfab40c1174f622f644b24669afd5fb167fbb79 + git -C "$source_root" checkout --detach 7bfab40c1174f622f644b24669afd5fb167fbb79 + test "$(git -C "$source_root" rev-parse HEAD)" = 7bfab40c1174f622f644b24669afd5fb167fbb79 + python3 -m venv "$RUNNER_TEMP/renpy-libffi-venv" + "$RUNNER_TEMP/renpy-libffi-venv/bin/python" -m pip install Cython==3.2.4 Jinja2==3.1.6 requests==2.32.3 + - name: Execute real host and two target SDK configure and link checks + run: | + set -euo pipefail + "$RUNNER_TEMP/renpy-libffi-venv/bin/python" tools/test_renpy_mobile_ios_libffi.py \ + "$RUNNER_TEMP/renpy-libffi-source" --cython "$RUNNER_TEMP/renpy-libffi-venv/bin/cython" \ + --output-dir "$RUNNER_TEMP/renpy-libffi-results" \ + 2>&1 | tee "$RUNNER_TEMP/renpy-libffi-regression.log" + - name: Preserve new regression results + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-libffi-regression + path: | + ${{ runner.temp }}/renpy-libffi-results/*.json + ${{ runner.temp }}/renpy-libffi-results/*.map + ${{ runner.temp }}/renpy-libffi-regression.log + if-no-files-found: warn + retention-days: 7 From 800b7c324e8eef93eb50808729e8bab89306e043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 19:43:12 +0000 Subject: [PATCH 285/331] fix(ios): regenerate actual static Python rules before building --- .../native/0007-darwin-ios-build.patch | 33 +++++--- tools/test_renpy_mobile_ios_libffi.py | 75 +++++++++++++------ 2 files changed, 75 insertions(+), 33 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch index 0c44314ef..8abec8163 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch @@ -209,18 +209,33 @@ diff --git a/tasks/hostpython3.py b/tasks/hostpython3.py diff --git a/tasks/python3.py b/tasks/python3.py --- a/tasks/python3.py +++ b/tasks/python3.py -@@ -57,6 +57,10 @@ +@@ -56,6 +56,10 @@ + c.chdir("Python-{{ version }}") c.patch("Python-{{ version }}/ios-posixmodule.diff") - ++ + # Prefer the explicitly supplied target libffi over Darwin's desktop SDK. + c.patch("Python-{{ version }}/aether-external-libffi.diff") + c.run("autoreconf -vfi") -+ + c.run("cp {{patches}}/_scproxy.pyx Modules") c.chdir("Modules") - c.run("rm -f _scproxy.c") -@@ -106,12 +106,11 @@ +@@ -100,18 +104,26 @@ + c.generate("{{ source }}/Python-{{ version }}-Setup.stdlib", "Modules/Setup.stdlib") + c.generate("{{ source }}/Python-{{ version }}-Setup.stdlib", "Modules/Setup") + ++ if c.platform == "ios": ++ # configure already generated a Makefile using its own Setup.stdlib. ++ # Always regenerate the real static object rules and builtin registry ++ # after replacing both templates, even with same-second timestamps. ++ c.run("""sh Modules/makesetup -c Modules/config.c.in -s Modules ++ Modules/Setup.local Modules/Setup.stdlib Modules/Setup.bootstrap ++ Modules/Setup""") ++ c.run("mv config.c Modules/config.c") ++ + c.run("""{{ make }}""") + + c.run("""{{ make }} install""") c.copy("{{ host }}/bin/python3", "{{ install }}/bin/hostpython3") @@ -238,18 +253,18 @@ diff --git a/tasks/python3.py b/tasks/python3.py @task(kind="python", pythons="3", platforms="linux,mac") -@@ -132,6 +135,10 @@ +@@ -133,13 +145,20 @@ + @task(kind="python", pythons="3", platforms="ios") def build_ios(c: Context): common(c) - ++ + c.env("SDKROOT", "{{ cross }}/sdk") + c.env("LIBFFI_CFLAGS", "-I{{ install }}/include") + c.env("LIBFFI_LIBS", "-L{{ install }}/lib -lffi") -+ + with open(c.path("config.site"), "a") as f: f.write("ac_cv_little_endian_double=yes\n") # f.write("ac_cv_header_langinfo_h=no\n") -@@ -140,6 +147,9 @@ f.write("ac_cv_func_getentropy=no\n") f.write("ac_cv_have_long_long_format=yes\n") - f.write("ac_cv_func_clock_settime=no") diff --git a/tools/test_renpy_mobile_ios_libffi.py b/tools/test_renpy_mobile_ios_libffi.py index b1b1ac7bb..bd2cdae1e 100755 --- a/tools/test_renpy_mobile_ios_libffi.py +++ b/tools/test_renpy_mobile_ios_libffi.py @@ -115,11 +115,9 @@ def capture(command, **kwargs): if expanded.strip().startswith("./configure"): assert not captured, "Unexpected second configure" captured.update(command=shlex.split(expanded), environment=dict(context.environ)) - return - # common_post has genuinely generated both static module templates at - # this point. No make/install or target Python build is claimed here. - assert expanded.strip().startswith("nice make"), expanded - raise BeforeMake() + # Execute configure below before running any post-configure task. + raise BeforeMake() + raise AssertionError("Unexpected pre-configure command: " + expanded) context.run = capture try: namespace["build_ios"](context) @@ -130,11 +128,6 @@ def capture(command, **kwargs): assert environment["SDKROOT"] == str(context.path("{{ cross }}/sdk")) assert environment["LIBFFI_CFLAGS"] == "-I" + str(context.install / "include") assert environment["LIBFFI_LIBS"] == "-L" + str(context.install / "lib") + " -lffi" - for name in ("Setup", "Setup.stdlib"): - line = next(line for line in (source / "Modules" / name).read_text().splitlines() - if line.startswith("_ctypes ")) - assert "_ctypes/malloc_closure.c" in line and "-lffi" in line - assert "USING_APPLE_OS_LIBFFI" not in line assert "ac_cv_func_ffi_" not in (source / "config.site").read_text() return context, captured @@ -174,19 +167,28 @@ def ffi_and_python_case(label, *, root, shared, namespace, Context, compiler, patch_context.cwd = ffi_source patch_context.patchdir("libffi") initial_ffi_configs = {path: path.read_bytes() for path in ffi_source.rglob("fficonfig.h")} - words = [str(compiler)] if target: - words += ["-target", target, "-isysroot", str(sdk)] - elif sdk: - words += ["-isysroot", str(sdk)] - environment = dict(os.environ, CC=shlex.join(words), CFLAGS="", CPPFLAGS="", LDFLAGS="", LIBS="") + # Preserve the production Context's compiler, preprocessor, target, + # SDK alias, tools and complete compile/link flags, including ccache. + environment = dict(captured["environment"]) + words = shlex.split(environment["CC"]) + assert target in words and str(cross) in words, words + assert Path(environment["SDKROOT"]).resolve() == sdk.resolve() + else: + words = [str(compiler)] + if sdk: + words += ["-isysroot", str(sdk)] + environment = dict(os.environ, CC=shlex.join(words), CFLAGS="", CPPFLAGS="", LDFLAGS="", LIBS="") + ffi_environment = dict(environment) + # This is the separate libffi configure, before CPython's config.site. + ffi_environment.pop("CONFIG_SITE", None) ffi_command = ["./configure", "--disable-shared", "--enable-portable-binary", "--prefix=" + str(prefix)] if target: # Use the actual production libffi tuple; do not silently fix it in a test. ffi_command += shlex.split(context.expand("{{ ffi_cross_config }}")) - run(ffi_command, cwd=ffi_source, env=environment, log=evidence / f"{label}-ffi-configure.log") - run(["make", "-j", str(jobs)], cwd=ffi_source, env=environment, log=evidence / f"{label}-ffi-build.log") - run(["make", "install"], cwd=ffi_source, env=environment, log=evidence / f"{label}-ffi-install.log") + run(ffi_command, cwd=ffi_source, env=ffi_environment, log=evidence / f"{label}-ffi-configure.log") + run(["make", "-j", str(jobs)], cwd=ffi_source, env=ffi_environment, log=evidence / f"{label}-ffi-build.log") + run(["make", "install"], cwd=ffi_source, env=ffi_environment, log=evidence / f"{label}-ffi-install.log") # The official archive also ships a pre-generated MSVC ARM64 header. Only # a header created/changed by this genuine configure belongs to this case. ffi_configs = [path for path in ffi_source.rglob("fficonfig.h") @@ -200,10 +202,11 @@ def ffi_and_python_case(label, *, root, shared, namespace, Context, compiler, environment.update(LIBFFI_CFLAGS=captured["environment"]["LIBFFI_CFLAGS"], LIBFFI_LIBS=captured["environment"]["LIBFFI_LIBS"], - CPPFLAGS="-I" + str(prefix / "include"), - LDFLAGS="-L" + str(prefix / "lib"), - CONFIG_SITE=str(python_source / "config.site"), - SDKROOT=str(sdk) if sdk else "") + CONFIG_SITE=str(python_source / "config.site")) + if not target: + environment.update(CPPFLAGS="-I" + str(prefix / "include"), + LDFLAGS="-L" + str(prefix / "lib"), + SDKROOT=str(sdk) if sdk else "") # No function cache is supplied. CPython's three genuine ffi probes are # compile checks; the API executable and link map below establish linking. assert not any(key.startswith("ac_cv_func_ffi_") for key in environment) @@ -213,11 +216,30 @@ def ffi_and_python_case(label, *, root, shared, namespace, Context, compiler, # the genuine common_post generation in that order, stopping before make. # Its static extensions use global compiler flags plus this Setup line, # rather than assuming MODULE__CTYPES_CFLAGS controls the static object. + static_commands = [] + def execute_post_configure(command, **kwargs): + expanded = context.expand(command) + if expanded.strip().startswith("nice make"): + raise BeforeMake() + static_commands.append(shlex.split(expanded)) + # Use the genuine Context implementation for the production task's + # makesetup and config.c move, rather than adding a test-only rule. + return Context.run(context, command, **kwargs) + context.run = execute_post_configure try: namespace["common_post"](context) except BeforeMake: pass + assert len(static_commands) == 2 and "Modules/makesetup" in static_commands[0] assert (python_source / "Modules/Setup").read_bytes() == (python_source / "Modules/Setup.stdlib").read_bytes() + registry = (python_source / "Modules/config.c").read_text() + assert re.search(r'\{\s*"_ctypes"\s*,\s*PyInit__ctypes\s*\}', registry), \ + "The real generated static builtin registry is missing _ctypes" + for name in ("Setup", "Setup.stdlib"): + line = next(line for line in (python_source / "Modules" / name).read_text().splitlines() + if line.startswith("_ctypes ")) + assert "_ctypes/malloc_closure.c" in line and "-lffi" in line + assert "USING_APPLE_OS_LIBFFI" not in line # Execute the genuine makesetup rule after the production templates have # replaced configure's Setup.stdlib, without building the interpreter. run(["make", "Makefile"], cwd=python_source, env=environment, @@ -237,7 +259,9 @@ def ffi_and_python_case(label, *, root, shared, namespace, Context, compiler, executable = case / "ffi-api" link_map = evidence / f"{label}-ffi-link.map" map_flag = "-Wl,-map," if sys.platform == "darwin" else "-Wl,-Map," - run([*words, "-Wall", "-Wextra", "-Werror", *cflags, str(api), *ldflags, + compile_flags = shlex.split(environment.get("CFLAGS", "")) + shlex.split(environment.get("CPPFLAGS", "")) + link_flags = shlex.split(environment.get("LDFLAGS", "")) + shlex.split(environment.get("LIBS", "")) + run([*words, *compile_flags, "-Wall", "-Wextra", "-Werror", *cflags, str(api), *link_flags, *ldflags, map_flag + str(link_map), "-o", str(executable)], cwd=case, env=environment, log=evidence / f"{label}-ffi-api-link.log") assert str(static_ffi) in link_map.read_text(), "API link did not consume the freshly built external libffi.a" @@ -273,7 +297,7 @@ def ffi_and_python_case(label, *, root, shared, namespace, Context, compiler, "ffi_closure_free(closure)", "Py_ffi_closure_free(closure)")) wrapped_executable = case / "ctypes-ffi-api" wrapped_map = evidence / f"{label}-ctypes-ffi-link.map" - run([*words, "-Wall", "-Wextra", "-Werror", *cflags, str(wrapped_api), str(closure_object), *ldflags, + run([*words, *compile_flags, "-Wall", "-Wextra", "-Werror", *cflags, str(wrapped_api), str(closure_object), *link_flags, *ldflags, map_flag + str(wrapped_map), "-o", str(wrapped_executable)], cwd=case, env=environment, log=evidence / f"{label}-ctypes-ffi-api-link.log") assert str(static_ffi) in wrapped_map.read_text(), "CPython closure wrapper did not link to the external libffi.a" @@ -291,6 +315,9 @@ def ffi_and_python_case(label, *, root, shared, namespace, Context, compiler, record = {"case": label, "compiler": words, "compiler_version": compiler_version, "build_source_pin": PIN, "python_version": PYTHON_VERSION, "libffi_version": FFI_VERSION, "sdk": str(sdk) if sdk else None, + "target_context_toolchain": "production" if target else "host isolation", + "static_setup_commands": static_commands, + "ctypes_static_registry": "passed", "configure_ffi_headers": "passed", "ffi_api_link": "passed", "closure_object": "passed", "ctypes_closure_link": "passed", "ffi_api_execution": "not_run" if target else "passed", From 4fd44ea16745f7bf6c8085e7bef6c368ffb53ec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 19:48:52 +0000 Subject: [PATCH 286/331] test(ios): execute the unchanged upstream ctypes compile recipe --- tools/test_renpy_mobile_ios_libffi.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/test_renpy_mobile_ios_libffi.py b/tools/test_renpy_mobile_ios_libffi.py index bd2cdae1e..353658e8c 100755 --- a/tools/test_renpy_mobile_ios_libffi.py +++ b/tools/test_renpy_mobile_ios_libffi.py @@ -286,7 +286,10 @@ def execute_post_configure(command, **kwargs): assert "-I" + str(prefix / "include") in closure_command, closure_command assert "USING_APPLE_OS_LIBFFI" not in " ".join(closure_command) closure_command[closure_command.index("-o") + 1] = str(closure_object) - headers = run([*closure_command, "-Wall", "-Wextra", "-Werror", "-H"], + # Compile the upstream object with its real production warning policy. + # A blanket extra -Werror rejects CPython's intentionally unreachable + # legacy fallback when external ffi_closure_alloc is available. + headers = run([*closure_command, "-H"], cwd=python_source, env=environment, log=evidence / f"{label}-ctypes-closure-compile.log") assert str(prefix / "include/ffi.h") in headers, "Actual static closure object did not include the external libffi header" wrapped_api = case / "ctypes-ffi-api.c" From ee65f020190ca63ffda799fd288b4951d01cc906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 20:00:58 +0000 Subject: [PATCH 287/331] test(ios): diagnose actual make selection without exposing config caches --- tools/test_renpy_mobile_ios_libffi.py | 34 ++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/tools/test_renpy_mobile_ios_libffi.py b/tools/test_renpy_mobile_ios_libffi.py index 353658e8c..34221a3b1 100755 --- a/tools/test_renpy_mobile_ios_libffi.py +++ b/tools/test_renpy_mobile_ios_libffi.py @@ -65,6 +65,33 @@ def run(command, *, cwd, env=None, log): log.parent.mkdir(parents=True, exist_ok=True) log.write_text("Command: " + shlex.join(map(str, command)) + "\n" + result.stdout) if result.returncode: + # Only expose this new public-source command's tool errors. Do not + # publish config.log's environment/cache dump or old build logs. + for config_log in sorted(Path(cwd).rglob("config.log"))[:6]: + errors = [line for line in config_log.read_text(errors="replace").splitlines() + if re.match(r"^(?:xcrun: error:|make(?:\[\d+\])?: |[^='\"\s]+:\d+: \*\*\*)", line)] + if errors: + print(json.dumps({"new_config_tool_errors": errors[-15:]}), flush=True) + if sys.platform == "darwin": + def tool_probe(command, environment, record): + try: + probe = subprocess.run(command, cwd=cwd, env=environment, + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30) + record.update(status=probe.returncode, result=probe.stdout.strip()) + except (OSError, subprocess.TimeoutExpired) as error: + record.update(diagnostic_error=type(error).__name__) + print(json.dumps(record), flush=True) + tool_probe(["xcrun", "--find", "make"], env, {"make_probe": "xcrun-selected"}) + sdk_setting = (env or os.environ).get("SDKROOT") + for label, sdk_value in (("original", sdk_setting), + ("resolved", str(Path(sdk_setting).resolve()) if sdk_setting else None), + ("unset", None)): + diagnostic_environment = dict(env or os.environ) + diagnostic_environment.pop("SDKROOT", None) + if sdk_value: + diagnostic_environment["SDKROOT"] = sdk_value + tool_probe(["make", "--version"], diagnostic_environment, + {"make_probe": label, "sdkroot": sdk_value}) raise RuntimeError(f"Command failed ({result.returncode}); {log}\n{result.stdout[-7000:]}") return result.stdout @@ -110,11 +137,13 @@ def capture_ios_task(namespace, Context, root, shared, architecture, source): context.var("source", shared / "source") context.cwd = source.parent captured = {} + base_sdkroot = context.environ.get("SDKROOT") def capture(command, **kwargs): expanded = context.expand(command) if expanded.strip().startswith("./configure"): assert not captured, "Unexpected second configure" - captured.update(command=shlex.split(expanded), environment=dict(context.environ)) + captured.update(command=shlex.split(expanded), environment=dict(context.environ), + base_context_sdkroot=base_sdkroot) # Execute configure below before running any post-configure task. raise BeforeMake() raise AssertionError("Unexpected pre-configure command: " + expanded) @@ -182,6 +211,9 @@ def ffi_and_python_case(label, *, root, shared, namespace, Context, compiler, ffi_environment = dict(environment) # This is the separate libffi configure, before CPython's config.site. ffi_environment.pop("CONFIG_SITE", None) + print(json.dumps({"case": label, "ffi_environment_source": "Python task Context" if target else "host isolation", + "base_context_sdkroot": captured["base_context_sdkroot"], + "ffi_sdkroot": ffi_environment.get("SDKROOT")}), flush=True) ffi_command = ["./configure", "--disable-shared", "--enable-portable-binary", "--prefix=" + str(prefix)] if target: # Use the actual production libffi tuple; do not silently fix it in a test. From 8b2db6bfb6fdfe65126aba1db2484002427c109c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 20:21:18 +0000 Subject: [PATCH 288/331] fix(ios): resolve SDKROOT and verify libffi with its own build context --- .../native/0007-darwin-ios-build.patch | 2 +- tools/test_renpy_mobile_ios_libffi.py | 35 ++++++++++++------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch index 8abec8163..f9c867c82 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch @@ -258,7 +258,7 @@ diff --git a/tasks/python3.py b/tasks/python3.py def build_ios(c: Context): common(c) + -+ c.env("SDKROOT", "{{ cross }}/sdk") ++ c.env("SDKROOT", str(c.path("{{ cross }}/sdk").resolve())) + c.env("LIBFFI_CFLAGS", "-I{{ install }}/include") + c.env("LIBFFI_LIBS", "-L{{ install }}/lib -lffi") diff --git a/tools/test_renpy_mobile_ios_libffi.py b/tools/test_renpy_mobile_ios_libffi.py index 34221a3b1..812e15490 100755 --- a/tools/test_renpy_mobile_ios_libffi.py +++ b/tools/test_renpy_mobile_ios_libffi.py @@ -131,9 +131,14 @@ def expand(value): return values, expand -def capture_ios_task(namespace, Context, root, shared, architecture, source): +def capture_ios_task(namespace, Context, root, shared, architecture, source, sdk): context = Context("ios", architecture, "3", root, SimpleNamespace()) context.set_names("python", "build_ios", "python3") + if sdk: + cross = context.path("{{ cross }}/sdk") + cross.parent.mkdir(parents=True, exist_ok=True) + cross.symlink_to(sdk, target_is_directory=True) + assert cross.resolve() == sdk.resolve(), "Only a genuine selected Xcode SDK may be used" context.var("source", shared / "source") context.cwd = source.parent captured = {} @@ -154,7 +159,7 @@ def capture(command, **kwargs): pass assert captured and context.cwd == source environment = captured["environment"] - assert environment["SDKROOT"] == str(context.path("{{ cross }}/sdk")) + assert environment["SDKROOT"] == str(context.path("{{ cross }}/sdk").resolve()) assert environment["LIBFFI_CFLAGS"] == "-I" + str(context.install / "include") assert environment["LIBFFI_LIBS"] == "-L" + str(context.install / "lib") + " -lffi" assert "ac_cv_func_ffi_" not in (source / "config.site").read_text() @@ -180,12 +185,8 @@ def ffi_and_python_case(label, *, root, shared, namespace, Context, compiler, assert "Generated by Cython 3.2.4" in generated assert "HAVE_SYSTEM" in (python_source / "Modules/posixmodule.c").read_text() - context, captured = capture_ios_task(namespace, Context, case, shared, architecture, python_source) - if sdk: - cross = context.path("{{ cross }}/sdk") - cross.parent.mkdir(parents=True, exist_ok=True) - cross.symlink_to(sdk, target_is_directory=True) - assert cross.resolve() == sdk.resolve(), "Only a genuine selected Xcode SDK may be used" + context, captured = capture_ios_task(namespace, Context, case, shared, architecture, python_source, sdk) + cross = context.path("{{ cross }}/sdk") prefix = context.install host_python = context.path("{{ host }}/bin/python3") host_python.parent.mkdir(parents=True, exist_ok=True) @@ -208,10 +209,18 @@ def ffi_and_python_case(label, *, root, shared, namespace, Context, compiler, if sdk: words += ["-isysroot", str(sdk)] environment = dict(os.environ, CC=shlex.join(words), CFLAGS="", CPPFLAGS="", LDFLAGS="", LIBS="") - ffi_environment = dict(environment) - # This is the separate libffi configure, before CPython's config.site. - ffi_environment.pop("CONFIG_SITE", None) - print(json.dumps({"case": label, "ffi_environment_source": "Python task Context" if target else "host isolation", + if target: + # libffi runs as an arch task before Python sets its own SDKROOT, + # CONFIG_SITE, PYTHON_FOR_BUILD and external-libffi variables. + ffi_context = Context("ios", architecture, "3", case, SimpleNamespace()) + ffi_context.set_names("arch", "build", "libffi") + assert ffi_context.install == prefix + ffi_environment = dict(ffi_context.environ) + for key in ("CC", "CPP", "CXX", "AR", "RANLIB", "NM", "CFLAGS", "CPPFLAGS", "LDFLAGS"): + assert ffi_environment[key] == environment[key], key + else: + ffi_environment = dict(environment) + print(json.dumps({"case": label, "ffi_environment_source": "libffi task Context" if target else "host isolation", "base_context_sdkroot": captured["base_context_sdkroot"], "ffi_sdkroot": ffi_environment.get("SDKROOT")}), flush=True) ffi_command = ["./configure", "--disable-shared", "--enable-portable-binary", "--prefix=" + str(prefix)] @@ -350,6 +359,8 @@ def execute_post_configure(command, **kwargs): record = {"case": label, "compiler": words, "compiler_version": compiler_version, "build_source_pin": PIN, "python_version": PYTHON_VERSION, "libffi_version": FFI_VERSION, "sdk": str(sdk) if sdk else None, + "python_sdkroot": environment.get("SDKROOT"), + "ffi_sdkroot": ffi_environment.get("SDKROOT"), "target_context_toolchain": "production" if target else "host isolation", "static_setup_commands": static_commands, "ctypes_static_registry": "passed", From 123fecb3051da8310576d81254b3ec9185ace2ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 20:45:56 +0000 Subject: [PATCH 289/331] fix(ios): link the owned provider and required Renios support classes --- bridge/godot_extension/CMakeLists.txt | 10 ++------ scripts/build_ios.sh | 34 ++++++++++++++++++++++++--- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/bridge/godot_extension/CMakeLists.txt b/bridge/godot_extension/CMakeLists.txt index b2fbfa28c..73b317afc 100644 --- a/bridge/godot_extension/CMakeLists.txt +++ b/bridge/godot_extension/CMakeLists.txt @@ -100,14 +100,6 @@ set(AETHERKIRI_GODOT_SOURCES src/presentation/RuntimePresentationSprite.cpp ) -if(IOS OR ANDROID) - # iOS/Android link the guarded mobile provider directly into the Godot - # static archive. Static archive dependencies are not propagated to the - # final Xcode/Gradle application link. - list(APPEND AETHERKIRI_GODOT_SOURCES - ../renpy_runtime/src/renpy_runtime_mobile_provider.cpp) -endif() - if(WEB OR EMSCRIPTEN) add_executable(aether_kiri_godot ${AETHERKIRI_GODOT_SOURCES}) elseif(IOS) @@ -276,6 +268,8 @@ if(IOS) VERBATIM) endif() if(TARGET aether_renpy_runtime) + # The runtime target owns the provider and its platform definitions. + # iOS packaging merges that archive into the exported static extension. target_link_libraries(aether_kiri_godot PRIVATE aether_renpy_runtime) target_compile_definitions(aether_kiri_godot PRIVATE AETHERKIRI_WITH_RENPY=1) endif() diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index cced01f80..dfd92bcbc 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -453,7 +453,7 @@ PY build_renios_runtime_framework() { renios_enabled || return 0 - local prebuilt arch=arm64 metalangle + local prebuilt arch=arm64 metalangle prototype sdk_path [[ "$SIMULATOR" == true ]] && arch="$SIMULATOR_ARCH" prebuilt="$(renios_prebuilt_root)" metalangle="$(renios_metalangle_framework)" @@ -468,10 +468,29 @@ build_renios_runtime_framework() { while IFS= read -r archive; do archives+=("$archive"); done < <(collect_renios_archives "$prebuilt") local minimum_flag="-miphoneos-version-min=${IOS_MIN_VERSION:-16.0}" [[ "$SIMULATOR" == true ]] && minimum_flag="-mios-simulator-version-min=${IOS_MIN_VERSION:-16.0}" + prototype="$(renios_prototype_root)" + sdk_path="$(xcrun --sdk "$IOS_SDK" --show-sdk-path)" + local support_objects=() support_name support_source support_object + # Ren'Py's iOS init code discovers these original Renios classes through + # pyobjus. They are app sources upstream, so the prebuilt archives omit + # them. Keep them in the runtime framework without its main.c entrypoint. + for support_name in Log IAPHelper; do + support_source="$prototype/$support_name.m" + support_object="$CMAKE_BUILD_DIR/renpy-native/$support_name.o" + [[ -f "$support_source" ]] || { + echo "Error: Required Renios support source is missing: $support_source" >&2 + return 1 + } + xcrun --sdk "$IOS_SDK" clang -arch "$arch" -isysroot "$sdk_path" \ + "$minimum_flag" -fobjc-arc -fmodules -include UIKit/UIKit.h \ + -c "$support_source" -o "$support_object" + support_objects+=("$support_object") + done # Keep Ren'Py's Python/SDL/FFmpeg symbols inside a separate two-level # namespace. They must not replace the host's vcpkg library definitions. # The builtin Python extension table requires its archive objects to stay. - xcrun --sdk "$IOS_SDK" clang++ -arch "$arch" "$minimum_flag" -dynamiclib \ + xcrun --sdk "$IOS_SDK" clang++ -arch "$arch" -isysroot "$sdk_path" "$minimum_flag" -dynamiclib \ + "${support_objects[@]}" \ -Wl,-all_load "${archives[@]}" -Wl,-noall_load \ -Wl,-exported_symbols_list,"$export_list" \ -Wl,-install_name,"@rpath/$RENPY_FRAMEWORK_NAME.framework/$RENPY_FRAMEWORK_NAME" \ @@ -482,8 +501,17 @@ build_renios_runtime_framework() { -framework CoreVideo -framework CoreMedia -framework VideoToolbox \ -framework CoreGraphics -framework CoreFoundation -framework GameController \ -framework CoreMotion -framework Security -framework SystemConfiguration \ - -framework Metal -liconv -lz -lbz2 \ + -framework Metal -framework StoreKit -liconv -lz -lbz2 \ -o "$RENPY_FRAMEWORK_OUTPUT/$RENPY_FRAMEWORK_NAME" + local class_symbols="$CMAKE_BUILD_DIR/renpy-native/support-class-symbols.txt" class_symbol + xcrun nm -U -arch "$arch" "$RENPY_FRAMEWORK_OUTPUT/$RENPY_FRAMEWORK_NAME" > "$class_symbols" + for support_name in Log IAPHelper; do + class_symbol='_OBJC_CLASS_$_'"$support_name" + awk -v symbol="$class_symbol" '$NF == symbol { found=1 } END { exit !found }' "$class_symbols" || { + echo "Error: Ren'Py runtime framework lacks required Objective-C class $support_name." >&2 + return 1 + } + done python3 - "$RENPY_FRAMEWORK_OUTPUT/Info.plist" "$RENPY_FRAMEWORK_NAME" "$IOS_SDK" <<'PY' import pathlib, plistlib, sys plist={'CFBundleIdentifier':'org.aetherkiri.renpy-runtime', 'CFBundleExecutable':sys.argv[2], From 23e6221899aa8c379a5bbb7269e06480d148ac63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 20:59:55 +0000 Subject: [PATCH 290/331] ci: read a sanitized summary of the latest iOS source failure --- .github/workflows/renpy-build-diagnostics.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/renpy-build-diagnostics.yml b/.github/workflows/renpy-build-diagnostics.yml index d66a2beec..61e4c5db6 100644 --- a/.github/workflows/renpy-build-diagnostics.yml +++ b/.github/workflows/renpy-build-diagnostics.yml @@ -19,7 +19,7 @@ jobs: uses: actions/download-artifact@v6 with: repository: AetherKiri/Aether - run-id: 37812944178 + run-id: 37838829329 name: renpy-ios-source-build-evidence github-token: ${{ github.token }} path: ${{ runner.temp }}/renpy-ios-source-evidence @@ -32,8 +32,8 @@ jobs: logs = sorted(pathlib.Path(sys.argv[1]).rglob('renpy-ios-source-build.log')) if not logs: raise SystemExit('The genuine source-build evidence contains no log files.') - print('Original iOS source run 37812944178; job 113441713032; head dbb863ac101301c8a9d78dea358e2157a0185165.') - print('Evidence artifact 11567923064; GitHub archive digest sha256:74036852733a6c8b5efb4dff45cb2ba7e89d1faf6c6ff20f2176d066ffe551c1.') + print('Original iOS source run 37838829329; job 113522721484; head 8b2db6bfb6fdfe65126aba1db2484002427c109c.') + print('Evidence artifact 11578038161; GitHub archive digest sha256:59e42e8ba5bd069d02c5417ac610705752b4b2ca003e071dcfacc8f60a8273c9.') print('Reading preserved compiler evidence; no build is rerun or simulated.') remaining = 24000 for log in logs: From f328f2444eaa22445774e28fd6d6888b9444689a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 21:10:16 +0000 Subject: [PATCH 291/331] fix(ios): probe POSIX declarations before enabling target APIs --- .../native/0007-darwin-ios-build.patch | 46 ++++++++++++++++++- .../mobile_launcher/patches/native/README.md | 8 ++++ tools/test_renpy_mobile_ios_libffi.py | 25 ++++++++-- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch index f9c867c82..fc9c67dee 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0007-darwin-ios-build.patch @@ -209,13 +209,14 @@ diff --git a/tasks/hostpython3.py b/tasks/hostpython3.py diff --git a/tasks/python3.py b/tasks/python3.py --- a/tasks/python3.py +++ b/tasks/python3.py -@@ -56,6 +56,10 @@ +@@ -56,6 +56,11 @@ c.chdir("Python-{{ version }}") c.patch("Python-{{ version }}/ios-posixmodule.diff") + + # Prefer the explicitly supplied target libffi over Darwin's desktop SDK. + c.patch("Python-{{ version }}/aether-external-libffi.diff") ++ c.patch("Python-{{ version }}/aether-posix-functions.diff") + c.run("autoreconf -vfi") c.run("cp {{patches}}/_scproxy.pyx Modules") @@ -293,6 +294,49 @@ new file mode 100644 + WITH_SAVE_ENV([ + CFLAGS="-I${SDKROOT}/usr/include/ffi $CFLAGS" + AC_CHECK_HEADER([ffi.h], [ +diff --git a/patches/Python-3.12.8/aether-posix-functions.diff b/patches/Python-3.12.8/aether-posix-functions.diff +new file mode 100644 +--- /dev/null ++++ b/patches/Python-3.12.8/aether-posix-functions.diff +@@ -0,0 +1,38 @@ ++--- a/configure.ac +++++ b/configure.ac ++@@ -4899,7 +4899,7 @@ ++ # checks for library functions ++ AC_CHECK_FUNCS([ \ ++ accept4 alarm bind_textdomain_codeset chmod chown clock close_range confstr \ ++- copy_file_range ctermid dup dup3 execv explicit_bzero explicit_memset \ +++ copy_file_range ctermid dup execv explicit_bzero explicit_memset \ ++ faccessat fchmod fchmodat fchown fchownat fdopendir fdwalk fexecve \ ++ fork fork1 fpathconf fstatat ftime ftruncate futimens futimes futimesat \ ++ gai_strerror getegid getentropy geteuid getgid getgrgid getgrgid_r \ ++@@ -4909,7 +4909,7 @@ ++ getspnam getuid getwd if_nameindex initgroups kill killpg lchown linkat \ ++ lockf lstat lutimes madvise mbrtowc memrchr mkdirat mkfifo mkfifoat \ ++ mknod mknodat mktime mmap mremap nice openat opendir pathconf pause pipe \ ++- pipe2 plock poll posix_fadvise posix_fallocate posix_spawn posix_spawnp \ +++ plock poll posix_fadvise posix_fallocate posix_spawn posix_spawnp \ ++ pread preadv preadv2 pthread_condattr_setclock pthread_init pthread_kill \ ++ pwrite pwritev pwritev2 readlink readlinkat readv realpath renameat \ ++ rtpSpawn sched_get_priority_max sched_rr_get_interval sched_setaffinity \ ++@@ -4937,6 +4937,17 @@ ++ [], ++ [@%:@include ++ @%:@include ]) +++ +++# Simulator libSystem can export symbols absent from the selected SDK's +++# public headers. Check declarations and deployment availability rather +++# than inventing a prototype in AC_CHECK_FUNCS. +++WITH_SAVE_ENV([ +++ AS_VAR_IF([ac_sys_system], [Darwin], [ +++ CFLAGS="$CFLAGS -Werror=unguarded-availability" +++ ]) +++ PY_CHECK_FUNC([dup3], [@%:@include ]) +++ PY_CHECK_FUNC([pipe2], [@%:@include ]) +++]) ++ ++ # For some functions, having a definition is not sufficient, since ++ # we want to take their address. diff --git a/tasks/librenpy.py b/tasks/librenpy.py --- a/tasks/librenpy.py +++ b/tasks/librenpy.py diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index dd1e4b798..75fa7605c 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -54,6 +54,14 @@ its RPC headers. Host Python and the other target policies retain their normal module detection. The regression runs genuine CPython configure and checks its generated module rules; on macOS it also probes both actual iOS SDKs. +The same task checks `dup3` and `pipe2` through CPython's header-based +`PY_CHECK_FUNC` probes, with Darwin deployment availability warnings treated +as errors. Simulator libraries can export these symbols without public SDK +declarations; a successful symbol-only link probe cannot enable their use. +Absent APIs retain CPython's existing `dup2` and `pipe` fallbacks. The genuine +host and target SDK regression also compiles `Modules/posixmodule.o` through +its actual generated Makefile, without launching the target interpreter. + `0009-bounded-build-parallelism.patch` limits the upstream native object build group to the host CPU count. Set `RENPY_BUILD_JOBS` to a positive integer to select another limit. Child compiler failures still fail the build, and an diff --git a/tools/test_renpy_mobile_ios_libffi.py b/tools/test_renpy_mobile_ios_libffi.py index 812e15490..76c79560f 100755 --- a/tools/test_renpy_mobile_ios_libffi.py +++ b/tools/test_renpy_mobile_ios_libffi.py @@ -2,8 +2,9 @@ """Exercise pinned CPython patch tasks and genuine external libffi builds. Linux results are host compilation/execution only. On Darwin, additional -device and Simulator cases configure CPython and link using the selected -real Xcode SDKs. Target executables are not installed or run as gameplay. +device and Simulator cases configure CPython, compile its genuine POSIX +object and link using the selected real Xcode SDKs. Target executables are +not installed or run as gameplay. """ import argparse import ast @@ -295,6 +296,21 @@ def execute_post_configure(command, **kwargs): config = (python_source / "pyconfig.h").read_text() for feature in ("FFI_PREP_CIF_VAR", "FFI_PREP_CLOSURE_LOC", "FFI_CLOSURE_ALLOC"): assert re.search(r"^#define HAVE_" + feature + r" 1$", config, re.M), feature + # Compile the exact object that previously failed on the genuine + # Simulator SDK. Successful ffi checks do not establish this boundary. + posix_dry_run = run(["make", "-n", "Modules/posixmodule.o"], cwd=python_source, + env=environment, log=evidence / f"{label}-posix-make-command.log") + posix_commands = [shlex.split(line) for line in posix_dry_run.splitlines() + if "posixmodule.c" in line and " -c " in line] + assert len(posix_commands) == 1, posix_dry_run + run(["make", "Modules/posixmodule.o"], cwd=python_source, env=environment, + log=evidence / f"{label}-posix-object-compile.log") + posix_object = python_source / "Modules/posixmodule.o" + assert posix_object.is_file() + posix_functions = { + name: bool(re.search(r"^#define HAVE_" + name.upper() + r" 1$", config, re.M)) + for name in ("dup3", "pipe2") + } api = case / "ffi-api.c" api.write_text(API_SOURCE) executable = case / "ffi-api" @@ -348,6 +364,7 @@ def execute_post_configure(command, **kwargs): if target: assert executable.read_bytes()[:4] == bytes.fromhex("cffaedfe") assert closure_object.read_bytes()[:4] == bytes.fromhex("cffaedfe") + assert posix_object.read_bytes()[:4] == bytes.fromhex("cffaedfe") assert wrapped_executable.read_bytes()[:4] == bytes.fromhex("cffaedfe") output = run(["xcrun", "nm", "-u", str(executable)], cwd=case, log=evidence / f"{label}-ffi-unresolved.txt") @@ -366,6 +383,8 @@ def execute_post_configure(command, **kwargs): "ctypes_static_registry": "passed", "configure_ffi_headers": "passed", "ffi_api_link": "passed", "closure_object": "passed", "ctypes_closure_link": "passed", + "posix_object": "passed", "posix_functions": posix_functions, + "actual_posix_compile_command": posix_commands[0], "ffi_api_execution": "not_run" if target else "passed", "ctypes_closure_execution": "not_run" if target else "passed", "ffi_trampoline_table": trampoline_table, @@ -377,7 +396,7 @@ def execute_post_configure(command, **kwargs): "gameplay": "unverified"} (evidence / f"{label}-result.json").write_text(json.dumps(record, indent=2) + "\n") print(json.dumps(record, sort_keys=True), flush=True) - print(f"{label}: genuine patch/autoreconf/Cython, libffi build, CPython configure, closure object and API link PASS; " + print(f"{label}: genuine patch/autoreconf/Cython, libffi build, CPython configure, POSIX/closure objects and API link PASS; " + ("target API execution not run" if target else "host ffi_call/closure execution PASS"), flush=True) From 03293f7fec88eca68551250bb847860a0e5de87f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 21:21:46 +0000 Subject: [PATCH 292/331] ci: package the verified iOS runtime for devices and expose scoped failures --- .github/workflows/renpy-ios-runtime-build.yml | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index 94376f4af..a5c4891d2 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -283,3 +283,175 @@ jobs: path: ${{ runner.temp }}/Aether-renpy-simulator.app.zip if-no-files-found: warn retention-days: 7 + + ios-device-package: + name: Build the unsigned Ren'Py iPhoneOS package after Simulator gameplay + needs: [ios-runtime, ios-simulator-gameplay] + runs-on: macos-latest + timeout-minutes: 180 + permissions: + contents: read + packages: read + env: + AETHERKIRI_ENABLE_RENPY: ON + AETHERKIRI_ENABLE_INTERNAL: OFF + AETHERKIRI_VCPKG_CACHE_WRITE: 'false' + GODOT_RELEASE: 4.7.2-stable + GODOT_TEMPLATE_VERSION: 4.7.2.stable + JOBS: '3' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Initialize the public application dependencies + run: | + set -euo pipefail + git submodule update --init --recursive --depth 1 \ + packages/OnscripterYuri packages/psdfile packages/AetherSiglus \ + packages/AetherMinori packages/AetherKrkr + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios + - name: Install application build tools + run: | + set -euo pipefail + brew install cmake ninja pkg-config autoconf autoconf-archive automake \ + libtool gettext coreutils bison nasm ccache mono python@3.12 + echo "$(brew --prefix gettext)/bin" >> "$GITHUB_PATH" + echo "$(brew --prefix bison)/bin" >> "$GITHUB_PATH" + echo "$(brew --prefix python@3.12)/libexec/bin" >> "$GITHUB_PATH" + xcodebuild -version + xcrun --sdk iphoneos --show-sdk-version + - uses: actions/download-artifact@v6 + with: + name: renpy-ios-runtime + path: ${{ runner.temp }}/renpy-ios-runtime + - name: Stage the genuine same-run Ren'Py payload + run: | + set -euo pipefail + mobile_root="$RUNNER_TEMP/renpy-ios-device-runtime" + mkdir -p "$mobile_root" + tar -xzf "$RUNNER_TEMP/renpy-ios-runtime/renpy-ios-runtime.tar.gz" -C "$mobile_root" + cp "$mobile_root/manifest.json" "$RUNNER_TEMP/renpy-ios-device-runtime-manifest.json" + echo "AETHERKIRI_RENPY_MOBILE_ROOT=$mobile_root/payload" >> "$GITHUB_ENV" + - name: Install the pinned Godot editor and real export templates + run: | + set -euo pipefail + mkdir -p .godot "$RUNNER_TEMP/godot-templates" + curl --fail --location --retry 3 -o "$RUNNER_TEMP/godot-macos.zip" \ + "https://github.com/godotengine/godot-builds/releases/download/$GODOT_RELEASE/Godot_v${GODOT_RELEASE}_macos.universal.zip" + unzip -q "$RUNNER_TEMP/godot-macos.zip" -d .godot + curl --fail --location --retry 3 -o "$RUNNER_TEMP/godot-templates.tpz" \ + "https://github.com/godotengine/godot-builds/releases/download/$GODOT_RELEASE/Godot_v${GODOT_RELEASE}_export_templates.tpz" + unzip -q "$RUNNER_TEMP/godot-templates.tpz" -d "$RUNNER_TEMP/godot-templates" + template_dir="$HOME/Library/Application Support/Godot/export_templates/$GODOT_TEMPLATE_VERSION" + mkdir -p "$template_dir" + cp -a "$RUNNER_TEMP/godot-templates/templates/." "$template_dir/" + echo "GODOT_BIN=$GITHUB_WORKSPACE/.godot/Godot.app/Contents/MacOS/Godot" >> "$GITHUB_ENV" + echo "GODOT_EXPORT_TEMPLATE=$template_dir/ios.zip" >> "$GITHUB_ENV" + - name: Prepare the normal vcpkg binary cache + env: + VCPKG_PACKAGES_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git clone https://github.com/microsoft/vcpkg.git .devtools/vcpkg + bash .devtools/vcpkg/bootstrap-vcpkg.sh -disableMetrics + echo "VCPKG_ROOT=$GITHUB_WORKSPACE/.devtools/vcpkg" >> "$GITHUB_ENV" + bash .github/scripts/configure-vcpkg-binary-cache.sh "$GITHUB_WORKSPACE/.devtools/vcpkg/vcpkg" + - name: Build the genuine iPhoneOS app and unsigned IPA + run: | + set -euo pipefail + # The existing package path invokes xcodebuild with signing disabled + # and validates the actual app's native closure before creating IPA. + bash scripts/build_ios.sh debug --package-ipa \ + 2>&1 | tee "$RUNNER_TEMP/renpy-ios-device-build.log" + test -s out/godot/ios/debug/Aether-Debug-Unsigned.ipa + echo 'Unsigned iPhoneOS package built. Device signing, installation and gameplay remain unverified.' + - name: Preserve genuine device build and linkage evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-unsigned-device-build-evidence + path: | + ${{ runner.temp }}/renpy-ios-device-build.log + ${{ runner.temp }}/renpy-ios-device-runtime-manifest.json + out/ios/debug/renpy-native/exports.txt + out/ios/debug/renpy-native/support-class-symbols.txt + out/ios/debug/renpy-native/linked-libraries.txt + out/ios/debug/renpy-native/host-linkage.txt + out/ios/debug/renpy-native/runtime-linkage.txt + out/godot/ios/debug/Aether/renios/renios-manifest.txt + if-no-files-found: warn + retention-days: 7 + - name: Preserve the unsigned iPhoneOS package requiring device signing + uses: actions/upload-artifact@v6 + with: + name: renpy-ios-device-unsigned-ipa + path: out/godot/ios/debug/Aether-Debug-Unsigned.ipa + if-no-files-found: error + retention-days: 7 + + ios-source-failure-summary: + name: Read a sanitized summary of this run's iOS source failure + needs: [ios-runtime] + if: always() && needs.ios-runtime.result == 'failure' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Require the existing source-build evidence from this run + uses: actions/download-artifact@v6 + with: + name: renpy-ios-source-build-evidence + path: ${{ runner.temp }}/renpy-ios-source-evidence + - name: Read a sanitized compiler excerpt without uploading another log + run: | + set -euo pipefail + python3 - "$RUNNER_TEMP/renpy-ios-source-evidence" "$GITHUB_RUN_ID" "$GITHUB_SHA" <<'PY' + import pathlib, re, sys + # Read genuine compiler evidence, never configure environment dumps. + root = pathlib.Path(sys.argv[1]) + logs = sorted(root.rglob('renpy-ios-source-build.log')) + if not logs: + logs = sorted(root.rglob('renpy-ios-source-prepare.log')) + if not logs: + raise SystemExit('This run has no genuine source-build or prepare log in its preserved evidence.') + print(f'Current iOS source failure run {sys.argv[2]}; checkout head {sys.argv[3]}.') + print('Scope: preserved source compiler/preparation evidence from this run; no build or gameplay is rerun or simulated.') + # Keep the approved renpy-build-diagnostics.yml filter and display cap. + # No upload step republishes the original log or creates an artifact. + remaining = 24000 + for log in logs: + text = log.read_text(errors='replace') + text = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', text) + text = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', text) + text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text) + lines = text.splitlines() + if not lines: + raise SystemExit(f'The preserved genuine log {log.name} is empty; no compiler evidence is available.') + interesting = [i for i, line in enumerate(lines) + if re.search(r'error:|fatal error|FAILED|Error [1-9]|undefined reference|not found|Traceback|No such file|exception|Killed|process failed|tasks failed', line, re.I)] + indices = set(range(max(0, len(lines)-110), len(lines))) + for i in interesting[-16:]: + indices.update(range(max(0, i-4), min(len(lines), i+9))) + excerpt = [] + for i in sorted(indices): + line = lines[i] + if re.match(r'^\s*(?:export\s|declare\s|env:|Environment:|(?!(?:FAILED|ERROR|FATAL|WARNING):)[A-Z_][A-Z0-9_]*\s*[=:])', line): + continue + if re.search(r'authorization|password|credential|secret|(?:^|[\s_])token(?:[\s_=:]|$)', line, re.I): + continue + line = line.replace('/Users/runner/work/Aether/Aether', '') + line = line.replace('/Users/runner/work/_temp', '') + excerpt.append(line[:1200]) + summary = '\n'.join(excerpt) + if not summary: + raise SystemExit(f'No displayable compiler evidence remains in {log.name} after filtering.') + print(f'Original log: {log.name}; {len(lines)} lines') + if len(summary) > remaining: + summary = summary[-remaining:] + print(summary) + remaining -= len(summary) + if remaining <= 0: + print('Summary reached its display limit; complete original logs remain in this run\'s existing artifact.') + break + PY From 0461e8a0fe8bb32e652aba4df41f2f34b630225a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 21:44:20 +0000 Subject: [PATCH 293/331] fix(android): require effective all-files access for native imports --- .../src/aether_runtime_player.cpp | 59 +++++++++++-------- demos/aetherkiri-renpy/README.md | 9 ++- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/bridge/godot_extension/src/aether_runtime_player.cpp b/bridge/godot_extension/src/aether_runtime_player.cpp index 61f804d60..ba94eb76d 100644 --- a/bridge/godot_extension/src/aether_runtime_player.cpp +++ b/bridge/godot_extension/src/aether_runtime_player.cpp @@ -1228,28 +1228,34 @@ bool AndroidRequestRuntimeStoragePermissions() { } bool AndroidHasExternalStoragePermission() { - const AndroidGodotStoragePermissionState godot_state = - AndroidGetGodotStoragePermissionState(); - if (godot_state.read || godot_state.write || godot_state.manage) { - AK_ANDROID_LOGI("storage permission effective granted via Godot OS"); - return true; - } - const int sdk = AndroidGetSdkInt(); - if (sdk > 0 && sdk < 23) { - return true; - } - if (sdk > 0 && sdk < 30) { - AK_ANDROID_LOGI( - "storage permission state sdk=%d read=%d write=%d effective_read=0", - sdk, godot_state.read ? 1 : 0, godot_state.write ? 1 : 0); - return false; - } if (sdk <= 0) { AK_ANDROID_LOGW( "storage permission state unavailable: Android SDK/JNI not ready"); return false; } + if (sdk < 23) { + return true; + } + + const AndroidGodotStoragePermissionState godot_state = + AndroidGetGodotStoragePermissionState(); + if (sdk < 30) { + const bool granted = godot_state.read || godot_state.write; + AK_ANDROID_LOGI( + "storage permission state sdk=%d read=%d write=%d effective_read=%d", + sdk, godot_state.read ? 1 : 0, godot_state.write ? 1 : 0, + granted ? 1 : 0); + return granted; + } + + // On Android 11+, legacy READ/WRITE grants do not establish the broad + // file access needed by native game loaders. Godot reports MANAGE only + // when Environment.isExternalStorageManager() confirms the user's grant. + if (godot_state.manage) { + AK_ANDROID_LOGI("storage permission effective granted via Godot OS all files access"); + return true; + } const int java_result = AndroidHasExternalStoragePermissionViaGodotJava(); if (java_result >= 0) { @@ -1410,13 +1416,18 @@ bool AndroidStartSettingsIntent(JNIEnv *env, jobject context, const char *action } bool AndroidRequestExternalStoragePermission() { - const AndroidGodotStoragePermissionState before = - AndroidGetGodotStoragePermissionState(); - if (before.read || before.write || before.manage) { + if (AndroidHasExternalStoragePermission()) { AK_ANDROID_LOGI("storage permission already granted"); return true; } + const int sdk = AndroidGetSdkInt(); + if (sdk <= 0) { + AK_ANDROID_LOGW( + "storage permission request unavailable: Android SDK/JNI not ready"); + return false; + } + // Calling through Godot's OS keeps this path on the Activity that owns // the current Godot instance. This is important on Android 6-9, where // the extension may be loaded as an ELF dependency and JNI_OnLoad is not @@ -1427,17 +1438,13 @@ bool AndroidRequestExternalStoragePermission() { AK_ANDROID_LOGI( "storage permission request via Godot OS dispatched=%d", dispatched ? 1 : 0); - if (dispatched) { - const int sdk = AndroidGetSdkInt(); - if (sdk <= 0 || sdk < 30) { - return true; - } + if (dispatched && sdk < 30) { + return true; } } - const int sdk = AndroidGetSdkInt(); AK_ANDROID_LOGI("storage permission request sdk=%d", sdk); - if (sdk > 0 && sdk < 30) { + if (sdk < 30) { return false; } if (AndroidHasExternalStoragePermission()) { diff --git a/demos/aetherkiri-renpy/README.md b/demos/aetherkiri-renpy/README.md index de43dca33..43717ab02 100644 --- a/demos/aetherkiri-renpy/README.md +++ b/demos/aetherkiri-renpy/README.md @@ -41,12 +41,15 @@ replace it. run, unzip it on the phone, and open `Aether-debug.apk`. Follow Android's installation prompt for the chosen download source. 2. Download `renpy-demo-project` from the same run and unzip its `game` folder - inside a directory named `aetherkiri-renpy`, for example under Downloads. + inside `Download/aetherkiri-renpy` in the phone's internal storage. Keep `game/script.rpy`, `game/options.rpy`, and `game/scene.svg` together. No desktop Ren'Py SDK or device-request JSON is needed for this manual flow. 3. Open **Aether RenPy Test**, complete the existing first-run agreement and - file-access prompts, and choose **Import**. Select the `aetherkiri-renpy` - directory containing `game`, then open its detail page and start it. + file-access prompts, and choose **Import**. In the system folder picker, + navigate through **Internal storage → Download → aetherkiri-renpy** and + select that directory containing `game`, then open its detail page and + start it. This route gives the native loader the primary storage path; + other document-provider locations still need separate import coverage. 4. The real demo should show its scene and dialogue. Advance the dialogue, tap **Start**, enter a name with the system keyboard, and confirm. Check that the dialogue contains the name. At the menu, press the phone's Home From b2b7a0f380e02f3a80263f4f311be4cb89cc6e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 22:31:11 +0000 Subject: [PATCH 294/331] fix(ios): isolate host Python tools from target runtime --- .github/workflows/renpy-ios-runtime-build.yml | 5 + .../patches/native/0010-ios-host-python.patch | 188 ++++++++++++++++++ .../mobile_launcher/patches/native/README.md | 20 ++ tools/run_renpy_mobile_source_build.sh | 2 +- tools/test_renpy_mobile_ios_host_python.py | 124 ++++++++++++ tools/test_renpy_mobile_native_fork.sh | 4 +- tools/test_renpy_mobile_patch_series.py | 2 +- tools/test_renpy_mobile_source_build_plan.sh | 2 +- 8 files changed, 342 insertions(+), 5 deletions(-) create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/native/0010-ios-host-python.patch create mode 100644 tools/test_renpy_mobile_ios_host_python.py diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index a5c4891d2..540df059f 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -8,6 +8,7 @@ on: - 'tools/apply_renpy_mobile_patch_series.py' - 'tools/validate_renpy_mobile_payload.py' - 'tools/test_renpy_mobile_ios_libffi.py' + - 'tools/test_renpy_mobile_ios_host_python.py' - 'tools/renpy_ios_acceptance/**' - 'tools/run_renpy_ios_device_acceptance.py' - 'tools/patch_renpy_ios_runtime_project.py' @@ -125,6 +126,10 @@ jobs: uv --project "$RENPY_BUILD_ROOT/renpy" run python \ "$GITHUB_WORKSPACE/tools/test_renpy_mobile_ios_libffi.py" "$RENPY_BUILD_ROOT" \ 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" + uv --project "$RENPY_BUILD_ROOT/renpy" run python \ + "$GITHUB_WORKSPACE/tools/test_renpy_mobile_ios_host_python.py" "$RENPY_BUILD_ROOT" \ + --renpy-src "$RENPY_BUILD_ROOT/renpy" \ + 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" - name: Compile target CPython, SDL, Ren'Py and device/Simulator archives if: steps.runtime-cache.outputs.cache-hit != 'true' run: | diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0010-ios-host-python.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0010-ios-host-python.patch new file mode 100644 index 000000000..be63ecd92 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0010-ios-host-python.patch @@ -0,0 +1,188 @@ +diff --git a/renpybuild/ios_host_python.py b/renpybuild/ios_host_python.py +new file mode 100644 +--- /dev/null ++++ b/renpybuild/ios_host_python.py +@@ -0,0 +1,143 @@ ++"""Keep iOS build tools on the host interpreter and package only pure wheels.""" ++ ++from email.parser import BytesParser ++from pathlib import PurePosixPath ++import re ++import shlex ++import shutil ++import zipfile ++ ++ ++_NATIVE_FILE = re.compile(r"\.(?:so(?:\..*)?|dylib|pyd|dll|exe|a|o|obj|lib|bc)$", re.I) ++_NATIVE_MAGIC = ( ++ b"\x7fELF", b"\xfe\xed\xfa\xce", b"\xce\xfa\xed\xfe", ++ b"\xfe\xed\xfa\xcf", b"\xcf\xfa\xed\xfe", b"\xca\xfe\xba\xbe", ++ b"\xbe\xba\xfe\xca", b"\xca\xfe\xba\xbf", b"\xbf\xba\xfe\xca", ++ b"!\n", b"!\n", b"BC\xc0\xde", b"\xde\xc0\x17\x0b", b"MZ", ++) ++# These Windows launcher resources are data in the upstream pure setuptools ++# wheel. They are not native extensions, and pythonlib excludes setuptools. ++_SETUPTOOLS_LAUNCHERS = frozenset( ++ "setuptools/" + kind + suffix + ".exe" ++ for kind in ("cli", "gui") ++ for suffix in ("", "-32", "-64", "-arm64") ++) ++ ++ ++def _run(c, *arguments): ++ # pip's build-backend subprocesses do not inherit the parent's -I flag. ++ # Isolate their Python paths without changing package indexes or proxies. ++ previous_environ = c.environ ++ c.environ = dict(previous_environ) ++ c.environ.pop("PYTHONHOME", None) ++ c.environ.pop("PYTHONPATH", None) ++ try: ++ c.run(shlex.join(str(argument) for argument in arguments)) ++ finally: ++ c.environ = previous_environ ++ ++ ++def _host_tools(c): ++ return c.path("{{ install }}/aether-host-tools") ++ ++ ++def prepare_ios_host_python(c): ++ """Create a target-local host venv and replace the copied host executable.""" ++ if c.platform != "ios": ++ raise ValueError("iOS host tools require an iOS target") ++ ++ host_python = c.path("{{ host }}/bin/python3").resolve(strict=True) ++ tools = _host_tools(c) ++ _run(c, host_python, "-I", "-m", "venv", "--clear", "--symlinks", tools) ++ tool_python = tools / "bin/python3" ++ if not tool_python.is_symlink() or tool_python.resolve(strict=True) != host_python: ++ raise RuntimeError("iOS host tools must link to the canonical host Python") ++ ++ wrapper = c.path("{{ install }}/bin/hostpython3") ++ wrapper.parent.mkdir(parents=True, exist_ok=True) ++ # Unlink first so an earlier symlink cannot overwrite the canonical host. ++ if wrapper.is_symlink() or wrapper.exists(): ++ wrapper.unlink() ++ wrapper.write_text( ++ "#!/bin/sh\nunset PYTHONHOME PYTHONPATH\nexec " + shlex.quote(str(tool_python)) + ' -I "$@"\n', ++ encoding="utf-8", ++ ) ++ wrapper.chmod(0o755) ++ ++ ++def _verify_pure_wheel(path): ++ with zipfile.ZipFile(path) as archive: ++ files = archive.namelist() ++ wheel_files = [name for name in files ++ if name.endswith(".dist-info/WHEEL") and len(PurePosixPath(name).parts) == 2] ++ if len(wheel_files) != 1: ++ raise RuntimeError("wheel must contain one WHEEL metadata file: " + str(path)) ++ metadata_root = wheel_files[0].removesuffix("WHEEL") ++ wheel = BytesParser().parsebytes(archive.read(wheel_files[0])) ++ if wheel.get_all("Root-Is-Purelib") != ["true"]: ++ raise RuntimeError("iOS dependency is not pure Python: " + str(path)) ++ tags = wheel.get_all("Tag", []) ++ if not tags or any(tag.split("-")[1:] != ["none", "any"] for tag in tags): ++ raise RuntimeError("iOS dependency has a native wheel tag: " + str(path)) ++ if not path.name.endswith("-none-any.whl"): ++ raise RuntimeError("iOS dependency has a native wheel filename: " + str(path)) ++ ++ distribution = BytesParser().parsebytes(archive.read(metadata_root + "METADATA")) ++ setuptools_launchers = ( ++ distribution.get("Name", "").lower() == "setuptools" ++ and distribution.get("Version") == "74.1.2" ++ ) ++ for member in archive.infolist(): ++ name = member.filename ++ if member.is_dir(): ++ continue ++ if name.startswith("/") or ".." in PurePosixPath(name).parts: ++ raise RuntimeError("unsafe wheel member: " + name) ++ with archive.open(member) as stream: ++ header = stream.read(8) ++ if setuptools_launchers and name in _SETUPTOOLS_LAUNCHERS and header.startswith(b"MZ"): ++ continue ++ if _NATIVE_FILE.search(name) or any(header.startswith(magic) for magic in _NATIVE_MAGIC): ++ raise RuntimeError("native file in iOS dependency wheel: " + name) ++ ++ ++def install_ios_python_packages(c, requirements): ++ """Install the same verified pure dependencies for host tools and iOS data.""" ++ if c.platform != "ios": ++ raise ValueError("iOS pure packages require an iOS target") ++ requirements = shlex.split(requirements) if isinstance(requirements, str) else list(requirements) ++ if not requirements: ++ raise ValueError("iOS Python requirements must not be empty") ++ ++ tools = _host_tools(c) ++ python = tools / "bin/python3" ++ if not python.is_file(): ++ raise RuntimeError("iOS host Python has not been prepared") ++ # Python 3.12 venvs include pip, but not setuptools. Build pefile's only ++ # upstream distribution (an sdist) on the host without target compilation. ++ _run(c, python, "-I", "-m", "pip", "install", "--no-compile", "--upgrade", ++ "setuptools==74.1.2", "wheel==0.45.1") ++ wheelhouse = tools / "pure-wheels" ++ if wheelhouse.exists(): ++ shutil.rmtree(wheelhouse) ++ wheelhouse.mkdir() ++ _run(c, python, "-I", "-m", "pip", "wheel", "--no-deps", "--no-build-isolation", ++ "--no-binary=pefile", "--wheel-dir", wheelhouse, "pefile==2022.5.30") ++ _run(c, python, "-I", "-m", "pip", "download", "--platform", "any", ++ "--only-binary=:all:", "--find-links", wheelhouse, "--dest", wheelhouse, ++ *requirements) ++ ++ wheels = sorted(wheelhouse.glob("*.whl")) ++ if not wheels: ++ raise RuntimeError("no pure iOS dependency wheels were collected") ++ for wheel in wheels: ++ _verify_pure_wheel(wheel) ++ ++ # Explicit verified wheel inputs plus --no-deps prevent either installation ++ # from resolving additional unverified or host-native distributions. ++ _run(c, python, "-I", "-m", "pip", "install", "--no-deps", "--no-compile", ++ "--upgrade", *wheels) ++ target_site = c.path("{{ install }}/lib/{{ pythonver }}/site-packages") ++ _run(c, python, "-I", "-m", "pip", "install", "--no-deps", "--no-compile", ++ "--upgrade", "--platform", "any", "--only-binary=:all:", ++ "--target", target_site, *wheels) +diff --git a/tasks/python3.py b/tasks/python3.py +--- a/tasks/python3.py ++++ b/tasks/python3.py +@@ -118,7 +118,11 @@ + + c.run("""{{ make }} install""") + +- c.copy("{{ host }}/bin/python3", "{{ install }}/bin/hostpython3") ++ if c.platform == "ios": ++ from renpybuild.ios_host_python import prepare_ios_host_python ++ prepare_ios_host_python(c) ++ else: ++ c.copy("{{ host }}/bin/python3", "{{ install }}/bin/hostpython3") + + configurations = list(c.path("{{ host }}/lib/{{ pythonver }}").glob("_sysconfigdata_*.py")) + if not configurations: +@@ -257,8 +261,7 @@ + + @task(kind="python", pythons="3", platforms="all") + def pip(c: Context): +- c.run("{{ install }}/bin/hostpython3 -s -m ensurepip") +- c.run("""{{ install }}/bin/hostpython3 -s -m pip install --no-compile --upgrade ++ requirements = """ + future==1.0.0 + six==1.16.0 + rsa==4.9 +@@ -274,4 +277,12 @@ + websockets==12.0 + setuptools==74.1.2 + pysocks==1.7.1 +- """) ++ """ ++ if c.platform == "ios": ++ from renpybuild.ios_host_python import install_ios_python_packages ++ install_ios_python_packages(c, requirements) ++ return ++ ++ c.run("{{ install }}/bin/hostpython3 -s -m ensurepip") ++ c.run("""{{ install }}/bin/hostpython3 -s -m pip install --no-compile --upgrade ++ {{ requirements }}""", requirements=requirements) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index 75fa7605c..e1c5eafc0 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -68,6 +68,26 @@ select another limit. Child compiler failures still fail the build, and an exception cancels queued work and joins running child processes. This limits concurrent compiler memory use; it does not change source-build prerequisites. +`0010-ios-host-python.patch` keeps iOS build tools on the canonical host +CPython. Copying its executable into a target prefix selects the target +standard library and dynamic modules, which the host cannot load. Each target +instead gets a build-only virtual environment linked to the original host +interpreter, plus an isolated `hostpython3` wrapper used by package installation, +Ren'Py source generation and bytecode compilation. Host tools clear inherited +`PYTHONHOME` and `PYTHONPATH`; package indexes and proxy settings remain intact. + +The original Python dependency versions are installed into the target's +`site-packages` from verified pure wheels. The pinned `pefile` release has only +a source distribution, so the host builds its pure wheel first. The wheel gate +checks platform tags and archive contents, rejecting native extensions and +executables except the eight known Windows launcher resources in the upstream +`setuptools==74.1.2` pure wheel. Those resources are build-tool data, and the +existing Python payload whitelist excludes setuptools. The host virtual +environment lies outside the packaged Python and native archive roots. +The preparation regression executes real pip installation, the pinned +`setuplib` import and actual bytecode compilation under conflicting Python +path variables. It does not compile or execute an iOS runtime. + `renpy_mobile_lifecycle.c` owns the copied host callback table and the borrowed frame byte storage. It invokes the full cooperative Python entrypoints, preserves pointer/key/text actions, publishes renderer screenshots and diff --git a/tools/run_renpy_mobile_source_build.sh b/tools/run_renpy_mobile_source_build.sh index 27bca3162..99be15fa6 100755 --- a/tools/run_renpy_mobile_source_build.sh +++ b/tools/run_renpy_mobile_source_build.sh @@ -107,7 +107,7 @@ python3 "$repo_root/tools/apply_renpy_mobile_patch_series.py" \ "$patch_root/python/0001-cooperative-loop-skeleton.patch" python3 "$repo_root/tools/apply_renpy_mobile_patch_series.py" \ --checkout "$renpy_build" --pin "$build_sha" \ - "$patch_root/native"/000[0-9]-*.patch + "$patch_root/native"/[0-9][0-9][0-9][0-9]-*.patch cp "$patch_root/native/renpy_mobile_lifecycle.c" "$renpy_build/runtime/renpy_mobile_lifecycle.c" cp "$repo_root/bridge/renpy_runtime/mobile_launcher/include/renpy_mobile_launcher.h" "$renpy_build/runtime/renpy_mobile_launcher.h" mkdir -p "$renpy_build/runtime/greenlet" diff --git a/tools/test_renpy_mobile_ios_host_python.py b/tools/test_renpy_mobile_ios_host_python.py new file mode 100644 index 000000000..97e63b777 --- /dev/null +++ b/tools/test_renpy_mobile_ios_host_python.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Execute the iOS build's host Python tools and pure-package installation. + +This uses real host CPython, pip, published packages and the patched task body. +It does not compile an iOS object, launch a target interpreter or run gameplay. +""" +import argparse +import ast +import importlib.metadata +import json +from pathlib import Path +import subprocess +import sys +import tempfile +from types import SimpleNamespace + +REPO = Path(__file__).resolve().parents[1] +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" +RENPY_PIN = "39895c1e017f0b36ffea2447d97eccd69d76ee1c" + + +def capture(command, *, cwd, env=None): + result = subprocess.run(list(map(str, command)), cwd=cwd, env=env, + text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=600) + if result.returncode: + raise RuntimeError(f"Host command failed ({result.returncode}):\n{result.stdout[-6000:]}") + return result.stdout + + +def task_functions(path, context_type): + functions = [node for node in ast.parse(path.read_text()).body + if isinstance(node, ast.FunctionDef) and node.name in ("annotate", "pip")] + assert len(functions) == 2 + for node in functions: + node.decorator_list = [] + namespace = {"Context": context_type} + exec(compile(ast.Module(body=functions, type_ignores=[]), str(path), "exec"), namespace) + return namespace + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build_source", type=Path) + parser.add_argument("--renpy-src", required=True, type=Path) + args = parser.parse_args() + if sys.version_info[:2] != (3, 12): + raise SystemExit("The pinned build's host-tool regression requires genuine CPython 3.12.") + assert capture(["git", "rev-parse", "HEAD"], cwd=args.build_source).strip() == PIN + assert capture(["git", "rev-parse", "HEAD"], cwd=args.renpy_src).strip() == RENPY_PIN + patches = sorted((REPO / "bridge/renpy_runtime/mobile_launcher/patches/native").glob( + "[0-9][0-9][0-9][0-9]-*.patch")) + assert any(path.name.startswith("0010-") for path in patches) + with tempfile.TemporaryDirectory(prefix="renpy-real-host-python-") as directory: + root = Path(directory) / "renpy-build" + capture(["git", "clone", "--shared", "--quiet", "--no-checkout", + args.build_source.resolve(), root], cwd=REPO) + capture(["git", "checkout", "--quiet", "--detach", PIN], cwd=root) + capture([sys.executable, REPO / "tools/apply_renpy_mobile_patch_series.py", + "--checkout", root, "--pin", PIN, *patches], cwd=REPO) + sys.path.insert(0, str(root)) + from renpybuild.context import Context + from renpybuild.ios_host_python import prepare_ios_host_python + + tasks = task_functions(root / "tasks/python3.py", Context) + # These are genuine production Context paths/commands. No target SDK + # or compiler is invoked by this host-tool/package-only regression. + context = Context("ios", "arm64", "3", root, SimpleNamespace()) + context.set_names("python", "pip", "python3") + context.var("host", Path(sys.base_prefix)) + tasks["annotate"](context) + poison = root / "foreign-stdlib" + poison.mkdir() + (poison / "subprocess.py").write_text("raise RuntimeError('foreign target stdlib loaded')\n") + context.env("PYTHONHOME", str(poison)) + context.env("PYTHONPATH", str(poison)) + prepare_ios_host_python(context) + tasks["pip"](context) + + wrapper = context.path("{{ install }}/bin/hostpython3") + assert wrapper.read_bytes().startswith(b"#!/bin/sh\n"), "Host Mach-O/ELF was copied instead of a build-only wrapper" + probe = root / "host-generation-probe.py" + probe.write_text("""import json, pathlib, subprocess, sys +sys.path.insert(0, sys.argv[1]) +import setuplib, setuptools, _posixsubprocess +assert sys.flags.isolated +subprocess.run([sys.executable, '-I', '-c', 'import subprocess, _posixsubprocess'], check=True) +print(json.dumps({'isolated': bool(sys.flags.isolated), 'stdlib': subprocess.__file__, + 'setuptools': setuptools.__version__, 'prefix': sys.prefix})) +""") + tools = json.loads(capture([wrapper, probe, args.renpy_src.resolve() / "scripts"], + cwd=root, env=context.environ).strip().splitlines()[-1]) + assert tools["setuptools"] == "74.1.2" + assert not Path(tools["stdlib"]).is_relative_to(context.install) + assert Path(tools["prefix"]).is_relative_to(context.install) + site = context.path("{{ install }}/lib/{{ pythonver }}/site-packages") + versions = {dist.metadata["Name"].lower().replace("_", "-"): dist.version + for dist in importlib.metadata.distributions(path=[str(site)])} + expected = {"future": "1.0.0", "six": "1.16.0", "rsa": "4.9", "pyasn1": "0.6.1", + "ecdsa": "0.19.0", "urllib3": "2.7.0", "charset-normalizer": "3.3.2", + "chardet": "5.2.0", "idna": "3.8", "requests": "2.33.0", + "pefile": "2022.5.30", "websockets": "12.0", + "setuptools": "74.1.2", "pysocks": "1.7.1"} + assert all(versions.get(name) == version for name, version in expected.items()), versions + assert "certifi" in versions and (site / "certifi/cacert.pem").is_file() + assert (site / "websockets/__init__.py").is_file() + assert (site / "charset_normalizer/__init__.py").is_file() + assert (site / "pefile.py").is_file() + assert not any(path.is_file() and path.suffix.lower() in (".so", ".dylib", ".pyd") + for path in site.rglob("*")), "A native extension entered target site-packages" + runtime = root / "runtime-compilation-probe.py" + runtime.write_text("value = 'real CPython bytecode'\n") + context.compile(runtime) + assert runtime.with_suffix(".pyc").is_file() + result = {"status": "passed", "host_python": sys.version.split()[0], + "isolated_stdlib_and_subprocess": "passed", "pinned_setuplib_import": "passed", + "actual_context_compileall": "passed", "target_package_versions": versions, + "target_native_extensions": "absent", "certifi_certificate": "present", + "target_compilation": "not_run", "gameplay": "unverified"} + print(json.dumps(result, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_native_fork.sh b/tools/test_renpy_mobile_native_fork.sh index 505835ff9..cdfa4e162 100644 --- a/tools/test_renpy_mobile_native_fork.sh +++ b/tools/test_renpy_mobile_native_fork.sh @@ -19,7 +19,7 @@ for forbidden in \ exit 1 fi done -for patch in "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native"/000[0-9]-*.patch; do +for patch in "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native"/[0-9][0-9][0-9][0-9]-*.patch; do [[ -s "$patch" ]] || { echo "missing native build patch: $patch" >&2; exit 1; } if [[ "$patch" == *host-bootstrap.patch ]]; then grep -Fq 'renpy_mobile_bootstrap' "$patch" || { @@ -30,7 +30,7 @@ for patch in "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native"/00 done if [[ -n "${RENPY_BUILD_TEST_ROOT:-}" && -d "${RENPY_BUILD_TEST_ROOT}/.git" ]]; then - for patch in "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native"/000[0-9]-*.patch; do + for patch in "$repo_root/bridge/renpy_runtime/mobile_launcher/patches/native"/[0-9][0-9][0-9][0-9]-*.patch; do git -C "$RENPY_BUILD_TEST_ROOT" apply --check "$patch" 2>/dev/null || \ git -C "$RENPY_BUILD_TEST_ROOT" apply --reverse --check "$patch" done diff --git a/tools/test_renpy_mobile_patch_series.py b/tools/test_renpy_mobile_patch_series.py index a913d71c0..5997c33de 100644 --- a/tools/test_renpy_mobile_patch_series.py +++ b/tools/test_renpy_mobile_patch_series.py @@ -26,7 +26,7 @@ def main(): parser.add_argument("--renpy-src", type=Path) parser.add_argument("--greenlet-src", type=Path) args = parser.parse_args() - patches = sorted((REPO / "bridge/renpy_runtime/mobile_launcher/patches/native").glob("000[0-9]-*.patch")) + patches = sorted((REPO / "bridge/renpy_runtime/mobile_launcher/patches/native").glob("[0-9][0-9][0-9][0-9]-*.patch")) with tempfile.TemporaryDirectory(prefix="renpy-patch-series-") as temporary: root = Path(temporary) / "renpy-build" clone(args.build_source, root, PIN) diff --git a/tools/test_renpy_mobile_source_build_plan.sh b/tools/test_renpy_mobile_source_build_plan.sh index 358eee807..8cd9bd8e0 100755 --- a/tools/test_renpy_mobile_source_build_plan.sh +++ b/tools/test_renpy_mobile_source_build_plan.sh @@ -40,7 +40,7 @@ from pathlib import Path import sys source = Path(sys.argv[1]).read_text() assert source.index('--checkout "$renpy_build/renpy" --pin "$renpy_sha"') < source.index('./build.sh --platform android') -assert source.index('"$patch_root/native"/000[0-9]-*.patch') < source.index('./build.sh --platform android') +assert source.index('"$patch_root/native"/[0-9][0-9][0-9][0-9]-*.patch') < source.index('./build.sh --platform android') assert '"$mode" == "check" || "$mode" == "auto"' not in source, 'auto must not mean check-only' for symbol in ('renpy_mobile_text_input_state', 'renpy_mobile_set_surface_size'): assert symbol in source From 5042472061e5822be226435081098743902c0328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 23:21:45 +0000 Subject: [PATCH 295/331] fix(mobile): repair Assimp ownership and real build preparation --- .github/workflows/build.yml | 10 +- .github/workflows/renpy-ios-runtime-build.yml | 6 + .../native/0011-assimp-owned-array-copy.patch | 205 ++++++++++++++++++ .../mobile_launcher/patches/native/README.md | 13 ++ .../renpy_assimp_acceptance/constructors.cpp | 70 ++++++ tools/renpy_assimp_acceptance/ownership.cpp | 101 +++++++++ tools/test_renpy_mobile_assimp_copy.py | 120 ++++++++++ tools/test_renpy_mobile_ios_host_python.py | 4 +- 8 files changed, 527 insertions(+), 2 deletions(-) create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/native/0011-assimp-owned-array-copy.patch create mode 100644 tools/renpy_assimp_acceptance/constructors.cpp create mode 100644 tools/renpy_assimp_acceptance/ownership.cpp create mode 100644 tools/test_renpy_mobile_assimp_copy.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 316a7b75b..66bedcb22 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -412,7 +412,14 @@ jobs: if-no-files-found: error retention-days: 7 - - name: Build the x86_64 debug app for cloud emulator gameplay + - name: Build the public Ren'Py x86_64 Debug app for cloud emulator gameplay + env: + # The official E-mote package has no x86_64 Android slice. This + # Ren'Py-only emulator uses the existing isolated public test profile. + AETHERKIRI_ENABLE_INTERNAL: 'OFF' + AETHERKIRI_ENABLE_SOFTPAL_RUNTIME: 'OFF' + AETHERKIRI_WITH_INTERNAL: 'false' + AETHERKIRI_ANDROID_TEST_PACKAGE_ID: org.aetherkiri.renpy.debug run: | set -euo pipefail export CMAKE_C_COMPILER_LAUNCHER=ccache @@ -551,6 +558,7 @@ jobs: adb -s emulator-5554 shell getprop ro.product.cpu.abilist | tee "$evidence/abi.txt" python3 tools/run_renpy_android_device_acceptance.py \ --apk "${RUNNER_TEMP}/renpy-apk/Aether-emulator-debug.apk" \ + --package org.aetherkiri.renpy.debug \ --serial emulator-5554 --cloud-device --stage-timeout 90 \ --output-dir "$evidence" - name: Preserve real device evidence even on failure diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index 540df059f..7f5414b3c 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -9,6 +9,8 @@ on: - 'tools/validate_renpy_mobile_payload.py' - 'tools/test_renpy_mobile_ios_libffi.py' - 'tools/test_renpy_mobile_ios_host_python.py' + - 'tools/test_renpy_mobile_assimp_copy.py' + - 'tools/renpy_assimp_acceptance/**' - 'tools/renpy_ios_acceptance/**' - 'tools/run_renpy_ios_device_acceptance.py' - 'tools/patch_renpy_ios_runtime_project.py' @@ -130,6 +132,10 @@ jobs: "$GITHUB_WORKSPACE/tools/test_renpy_mobile_ios_host_python.py" "$RENPY_BUILD_ROOT" \ --renpy-src "$RENPY_BUILD_ROOT/renpy" \ 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" + uv --project "$RENPY_BUILD_ROOT/renpy" run python \ + "$GITHUB_WORKSPACE/tools/test_renpy_mobile_assimp_copy.py" "$RENPY_BUILD_ROOT" \ + --compiler "$(xcrun --sdk macosx -f clang++)" \ + 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" - name: Compile target CPython, SDL, Ren'Py and device/Simulator archives if: steps.runtime-cache.outputs.cache-hit != 'true' run: | diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0011-assimp-owned-array-copy.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0011-assimp-owned-array-copy.patch new file mode 100644 index 000000000..f65572325 --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0011-assimp-owned-array-copy.patch @@ -0,0 +1,205 @@ +diff --git a/patches/assimp-owned-arrays.diff b/patches/assimp-owned-arrays.diff +new file mode 100644 +--- /dev/null ++++ b/patches/assimp-owned-arrays.diff +@@ -0,0 +1,189 @@ ++diff --git a/code/Common/SceneCombiner.cpp b/code/Common/SceneCombiner.cpp ++index 88fa497..c7289b9 100644 ++--- a/code/Common/SceneCombiner.cpp +++++ b/code/Common/SceneCombiner.cpp ++@@ -63,6 +63,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ++ #include ++ #include ++ #include +++#include ++ #include ++ ++ namespace Assimp { ++@@ -988,6 +989,8 @@ inline void CopyPtrArray(Type **&dest, const Type *const *src, ai_uint num) { ++ // ------------------------------------------------------------------------------------------------ ++ template ++ inline void GetArrayCopy(Type *&dest, ai_uint num) { +++ static_assert(std::is_trivially_copyable::value, +++ "GetArrayCopy requires a trivially copyable element type"); ++ if (!dest) { ++ return; ++ } ++@@ -1103,10 +1106,12 @@ void SceneCombiner::Copy(aiMesh **_dest, const aiMesh *src) { ++ CopyPtrArray(dest->mBones, dest->mBones, dest->mNumBones); ++ ++ // make a deep copy of all faces ++- GetArrayCopy(dest->mFaces, dest->mNumFaces); ++- for (unsigned int i = 0; i < dest->mNumFaces; ++i) { ++- aiFace &f = dest->mFaces[i]; ++- GetArrayCopy(f.mIndices, f.mNumIndices); +++ if (src->mFaces) { +++ dest->mFaces = new aiFace[dest->mNumFaces]; +++ for (unsigned int i = 0; i < dest->mNumFaces; ++i) { +++ // aiFace assignment owns a deep copy of its index array. +++ dest->mFaces[i] = src->mFaces[i]; +++ } ++ } ++ ++ // make a deep copy of all blend shapes ++@@ -1251,12 +1256,20 @@ void SceneCombiner::Copy(aiMeshMorphAnim **_dest, const aiMeshMorphAnim *src) { ++ *dest = *src; ++ ++ // and reallocate all arrays ++- GetArrayCopy(dest->mKeys, dest->mNumKeys); ++- for (ai_uint i = 0; i < dest->mNumKeys; ++i) { ++- dest->mKeys[i].mValues = new unsigned int[dest->mKeys[i].mNumValuesAndWeights]; ++- dest->mKeys[i].mWeights = new double[dest->mKeys[i].mNumValuesAndWeights]; ++- ::memcpy(dest->mKeys[i].mValues, src->mKeys[i].mValues, dest->mKeys[i].mNumValuesAndWeights * sizeof(unsigned int)); ++- ::memcpy(dest->mKeys[i].mWeights, src->mKeys[i].mWeights, dest->mKeys[i].mNumValuesAndWeights * sizeof(double)); +++ if (src->mKeys) { +++ dest->mKeys = new aiMeshMorphKey[dest->mNumKeys]; +++ for (ai_uint i = 0; i < dest->mNumKeys; ++i) { +++ const aiMeshMorphKey &source = src->mKeys[i]; +++ aiMeshMorphKey &key = dest->mKeys[i]; +++ key.mTime = source.mTime; +++ key.mNumValuesAndWeights = source.mNumValuesAndWeights; +++ if (key.mNumValuesAndWeights) { +++ key.mValues = new unsigned int[key.mNumValuesAndWeights]; +++ key.mWeights = new double[key.mNumValuesAndWeights]; +++ ::memcpy(key.mValues, source.mValues, key.mNumValuesAndWeights * sizeof(unsigned int)); +++ ::memcpy(key.mWeights, source.mWeights, key.mNumValuesAndWeights * sizeof(double)); +++ } +++ } ++ } ++ } ++ ++diff --git a/contrib/Open3DGC/o3dgcCommon.h b/contrib/Open3DGC/o3dgcCommon.h ++index ff6bf75..a39b1e9 100644 ++--- a/contrib/Open3DGC/o3dgcCommon.h +++++ b/contrib/Open3DGC/o3dgcCommon.h ++@@ -163,8 +163,18 @@ namespace o3dgc ++ { ++ public: ++ SC3DMCStats(void) +++ : m_timeCoord(0), +++ m_timeNormal(0), +++ m_timeCoordIndex(0), +++ m_timeFloatAttribute{}, +++ m_timeIntAttribute{}, +++ m_timeReorder(0), +++ m_streamSizeCoord(0), +++ m_streamSizeNormal(0), +++ m_streamSizeCoordIndex(0), +++ m_streamSizeFloatAttribute{}, +++ m_streamSizeIntAttribute{} ++ { ++- memset(this, 0, sizeof(SC3DMCStats)); ++ }; ++ ~SC3DMCStats(void){}; ++ ++diff --git a/contrib/Open3DGC/o3dgcIndexedFaceSet.h b/contrib/Open3DGC/o3dgcIndexedFaceSet.h ++index adb8cb0..9e03e21 100644 ++--- a/contrib/Open3DGC/o3dgcIndexedFaceSet.h +++++ b/contrib/Open3DGC/o3dgcIndexedFaceSet.h ++@@ -35,13 +35,35 @@ namespace o3dgc ++ public: ++ //! Constructor. ++ IndexedFaceSet(void) +++ : m_nCoordIndex(0), +++ m_coordIndex(nullptr), +++ m_indexBufferID(nullptr), +++ m_nCoord(0), +++ m_nNormal(0), +++ m_coordMin{}, +++ m_coordMax{}, +++ m_normalMin{}, +++ m_normalMax{}, +++ m_coord(nullptr), +++ m_normal(nullptr), +++ m_numFloatAttributes(0), +++ m_numIntAttributes(0), +++ m_typeFloatAttribute{}, +++ m_typeIntAttribute{}, +++ m_nFloatAttribute{}, +++ m_nIntAttribute{}, +++ m_dimFloatAttribute{}, +++ m_dimIntAttribute{}, +++ m_minFloatAttribute{}, +++ m_maxFloatAttribute{}, +++ m_floatAttribute{}, +++ m_intAttribute{}, +++ m_creaseAngle(30), +++ m_ccw(true), +++ m_solid(true), +++ m_convex(true), +++ m_isTriangularMesh(true) ++ { ++- memset(this, 0, sizeof(IndexedFaceSet)); ++- m_ccw = true; ++- m_solid = true; ++- m_convex = true; ++- m_isTriangularMesh = true; ++- m_creaseAngle = 30; ++ }; ++ //! Destructor. ++ ~IndexedFaceSet(void) {}; ++diff --git a/contrib/Open3DGC/o3dgcSC3DMCEncodeParams.h b/contrib/Open3DGC/o3dgcSC3DMCEncodeParams.h ++index 5f3db96..969580c 100644 ++--- a/contrib/Open3DGC/o3dgcSC3DMCEncodeParams.h +++++ b/contrib/Open3DGC/o3dgcSC3DMCEncodeParams.h ++@@ -34,14 +34,18 @@ namespace o3dgc ++ public: ++ //! Constructor. ++ SC3DMCEncodeParams(void) +++ : m_numFloatAttributes(0), +++ m_numIntAttributes(0), +++ m_coordQuantBits(14), +++ m_normalQuantBits(8), +++ m_floatAttributeQuantBits{}, +++ m_coordPredMode(O3DGC_SC3DMC_PARALLELOGRAM_PREDICTION), +++ m_normalPredMode(O3DGC_SC3DMC_SURF_NORMALS_PREDICTION), +++ m_floatAttributePredMode{}, +++ m_intAttributePredMode{}, +++ m_streamTypeMode(O3DGC_STREAM_TYPE_ASCII), +++ m_encodeMode(O3DGC_SC3DMC_ENCODE_MODE_TFAN) ++ { ++- memset(this, 0, sizeof(SC3DMCEncodeParams)); ++- m_encodeMode = O3DGC_SC3DMC_ENCODE_MODE_TFAN; ++- m_streamTypeMode = O3DGC_STREAM_TYPE_ASCII; ++- m_coordQuantBits = 14; ++- m_normalQuantBits = 8; ++- m_coordPredMode = O3DGC_SC3DMC_PARALLELOGRAM_PREDICTION; ++- m_normalPredMode = O3DGC_SC3DMC_SURF_NORMALS_PREDICTION; ++ for(unsigned long a = 0; a < O3DGC_SC3DMC_MAX_NUM_FLOAT_ATTRIBUTES; ++a) ++ { ++ m_floatAttributePredMode[a] = O3DGC_SC3DMC_DIFFERENTIAL_PREDICTION; ++diff --git a/contrib/Open3DGC/o3dgcTimer.h b/contrib/Open3DGC/o3dgcTimer.h ++index f5ed0c8..ca2085f 100644 ++--- a/contrib/Open3DGC/o3dgcTimer.h +++++ b/contrib/Open3DGC/o3dgcTimer.h ++@@ -80,8 +80,8 @@ namespace o3dgc ++ { ++ public: ++ Timer(void) +++ : m_cclock{}, m_start{}, m_stop{} ++ { ++- memset(this, 0, sizeof(Timer)); ++ host_get_clock_service(mach_host_self(), CALENDAR_CLOCK, & m_cclock); ++ }; ++ ~Timer(void) ++@@ -110,8 +110,8 @@ namespace o3dgc ++ { ++ public: ++ Timer(void) +++ : m_start{}, m_stop{} ++ { ++- memset(this, 0, sizeof(Timer)); ++ }; ++ ~Timer(void){}; ++ void Tic() +diff --git a/tasks/assimp.py b/tasks/assimp.py +--- a/tasks/assimp.py ++++ b/tasks/assimp.py +@@ -10,6 +10,7 @@ + + c.chdir("assimp") + c.patch("assimp.diff") ++ c.patch("assimp-owned-arrays.diff") + + @task(platforms="all") + def build(c : Context): diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index e1c5eafc0..50d0e47c5 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -88,6 +88,19 @@ The preparation regression executes real pip installation, the pinned `setuplib` import and actual bytecode compilation under conflicting Python path variables. It does not compile or execute an iOS runtime. +`0011-assimp-owned-array-copy.patch` preserves Assimp 5.4.3 and its strict +compiler checks. SceneCombiner uses `aiFace`'s deep assignment and explicitly +copies morph-key values and weights into independent arrays. Its generic +byte-copy helper accepts only trivially copyable elements. Four bundled +Open3DGC constructors initialize their members instead of clearing whole +nontrivial objects; their prior defaults and platform clock operations remain. +The normal Assimp download task applies this source patch after the existing +upstream patch. The preparation regression runs the actual host download and +GLTF/STL build recipe with Clang 21, then links and executes the real library's +copy operations and Open3DGC constructor checks. This catches both ownership +and current compiler problems before the complete device/Simulator build. +Host execution does not establish iOS target compilation or gameplay. + `renpy_mobile_lifecycle.c` owns the copied host callback table and the borrowed frame byte storage. It invokes the full cooperative Python entrypoints, preserves pointer/key/text actions, publishes renderer screenshots and diff --git a/tools/renpy_assimp_acceptance/constructors.cpp b/tools/renpy_assimp_acceptance/constructors.cpp new file mode 100644 index 000000000..107eac9f0 --- /dev/null +++ b/tools/renpy_assimp_acceptance/constructors.cpp @@ -0,0 +1,70 @@ +// Execute actual pinned Assimp objects; no mobile gameplay is run. +#include "o3dgcCommon.h" +#include "o3dgcIndexedFaceSet.h" +#include "o3dgcSC3DMCEncodeParams.h" +#include "o3dgcTimer.h" +#include +#include +#include + +int main() { + using namespace o3dgc; + SC3DMCStats stats; + assert(stats.m_timeCoord == 0 && stats.m_timeNormal == 0 && stats.m_timeCoordIndex == 0); + assert(stats.m_timeReorder == 0); + assert(stats.m_streamSizeCoord == 0 && stats.m_streamSizeNormal == 0 && stats.m_streamSizeCoordIndex == 0); + for (unsigned long a = 0; a < O3DGC_SC3DMC_MAX_NUM_FLOAT_ATTRIBUTES; ++a) { + assert(stats.m_timeFloatAttribute[a] == 0 && stats.m_streamSizeFloatAttribute[a] == 0); + } + for (unsigned long a = 0; a < O3DGC_SC3DMC_MAX_NUM_INT_ATTRIBUTES; ++a) { + assert(stats.m_timeIntAttribute[a] == 0 && stats.m_streamSizeIntAttribute[a] == 0); + } + + SC3DMCEncodeParams parameters; + assert(parameters.GetNumFloatAttributes() == 0 && parameters.GetNumIntAttributes() == 0); + assert(parameters.GetCoordQuantBits() == 14 && parameters.GetNormalQuantBits() == 8); + assert(parameters.GetCoordPredMode() == O3DGC_SC3DMC_PARALLELOGRAM_PREDICTION); + assert(parameters.GetNormalPredMode() == O3DGC_SC3DMC_SURF_NORMALS_PREDICTION); + assert(parameters.GetStreamType() == O3DGC_STREAM_TYPE_ASCII); + assert(parameters.GetEncodeMode() == O3DGC_SC3DMC_ENCODE_MODE_TFAN); + for (unsigned long a = 0; a < O3DGC_SC3DMC_MAX_NUM_FLOAT_ATTRIBUTES; ++a) { + assert(parameters.GetFloatAttributeQuantBits(a) == 0); + assert(parameters.GetFloatAttributePredMode(a) == O3DGC_SC3DMC_DIFFERENTIAL_PREDICTION); + } + for (unsigned long a = 0; a < O3DGC_SC3DMC_MAX_NUM_INT_ATTRIBUTES; ++a) { + assert(parameters.GetIntAttributePredMode(a) == O3DGC_SC3DMC_NO_PREDICTION); + } + + IndexedFaceSet face_set; + assert(face_set.GetNCoordIndex() == 0 && face_set.GetNCoord() == 0 && face_set.GetNNormal() == 0); + assert(face_set.GetNumFloatAttributes() == 0 && face_set.GetNumIntAttributes() == 0); + assert(face_set.GetCoordIndex() == nullptr && face_set.GetIndexBufferID() == nullptr); + assert(face_set.GetCoord() == nullptr && face_set.GetNormal() == nullptr); + assert(face_set.GetCreaseAngle() == 30 && face_set.GetCCW() && face_set.GetSolid()); + assert(face_set.GetConvex() && face_set.GetIsTriangularMesh()); + for (int dimension = 0; dimension < 3; ++dimension) { + assert(face_set.GetCoordMin(dimension) == 0 && face_set.GetCoordMax(dimension) == 0); + assert(face_set.GetNormalMin(dimension) == 0 && face_set.GetNormalMax(dimension) == 0); + } + for (unsigned long a = 0; a < O3DGC_SC3DMC_MAX_NUM_FLOAT_ATTRIBUTES; ++a) { + assert(face_set.GetNFloatAttribute(a) == 0 && face_set.GetFloatAttributeDim(a) == 0); + assert(face_set.GetFloatAttributeType(a) == O3DGC_IFS_FLOAT_ATTRIBUTE_TYPE_UNKOWN); + assert(face_set.GetFloatAttribute(a) == nullptr); + for (unsigned long dimension = 0; dimension < O3DGC_SC3DMC_MAX_DIM_ATTRIBUTES; ++dimension) { + assert(face_set.GetFloatAttributeMin(a, dimension) == 0); + assert(face_set.GetFloatAttributeMax(a, dimension) == 0); + } + } + for (unsigned long a = 0; a < O3DGC_SC3DMC_MAX_NUM_INT_ATTRIBUTES; ++a) { + assert(face_set.GetNIntAttribute(a) == 0 && face_set.GetIntAttributeDim(a) == 0); + assert(face_set.GetIntAttributeType(a) == O3DGC_IFS_INT_ATTRIBUTE_TYPE_UNKOWN); + assert(face_set.GetIntAttribute(a) == nullptr); + } + + Timer timer; + assert(timer.GetElapsedTime() == 0); + timer.Tic(); + timer.Toc(); + assert(std::isfinite(timer.GetElapsedTime())); + std::cout << "actual Open3DGC constructor contracts PASS (host platform)\n"; +} diff --git a/tools/renpy_assimp_acceptance/ownership.cpp b/tools/renpy_assimp_acceptance/ownership.cpp new file mode 100644 index 000000000..fb255e541 --- /dev/null +++ b/tools/renpy_assimp_acceptance/ownership.cpp @@ -0,0 +1,101 @@ +// Execute actual pinned Assimp objects; no mobile gameplay is run. +#include +#include +#include +#include +#include + +static aiMesh *mesh() { + aiMesh *value = new aiMesh; + value->mNumVertices = 3; + value->mVertices = new aiVector3D[3]{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}}; + value->mNumFaces = 3; + value->mFaces = new aiFace[3]; + value->mFaces[0].mNumIndices = 3; + value->mFaces[0].mIndices = new unsigned int[3]{0, 1, 2}; + value->mFaces[2].mNumIndices = 1; + value->mFaces[2].mIndices = new unsigned int[1]{2}; + return value; +} + +static aiMeshMorphAnim *morph() { + aiMeshMorphAnim *value = new aiMeshMorphAnim; + value->mNumKeys = 3; + value->mKeys = new aiMeshMorphKey[3]; + value->mKeys[0].mTime = 5; + value->mKeys[1].mTime = 13.25; + value->mKeys[1].mNumValuesAndWeights = 2; + value->mKeys[1].mValues = new unsigned int[2]{0, 3}; + value->mKeys[1].mWeights = new double[2]{0.125, 0.875}; + value->mKeys[2].mTime = 17; + value->mKeys[2].mNumValuesAndWeights = 1; + value->mKeys[2].mValues = new unsigned int[1]{2}; + value->mKeys[2].mWeights = new double[1]{0.6}; + return value; +} + +int main() { + for (bool source_first : {false, true}) { + aiMesh *source = mesh(); + aiMesh *copy = nullptr; + Assimp::SceneCombiner::Copy(©, source); + assert(copy && copy->mFaces != source->mFaces); + assert(copy->mVertices != source->mVertices); + assert(copy->mVertices[1].y == 5); + assert(copy->mFaces[0].mNumIndices == 3); + assert(copy->mFaces[0].mIndices != source->mFaces[0].mIndices); + assert(copy->mFaces[0].mIndices[2] == 2); + assert(copy->mFaces[1].mNumIndices == 0 && copy->mFaces[1].mIndices == nullptr); + assert(copy->mFaces[2].mIndices != source->mFaces[2].mIndices); + copy->mFaces[0].mIndices[1] = 42; + copy->mVertices[1].y = 73; + assert(source->mFaces[0].mIndices[1] == 1 && source->mVertices[1].y == 5); + if (source_first) { + delete source; + assert(copy->mFaces[0].mIndices[1] == 42 && copy->mVertices[1].y == 73); + delete copy; + } else { + delete copy; + assert(source->mFaces[0].mIndices[1] == 1 && source->mVertices[1].y == 5); + delete source; + } + + aiMeshMorphAnim *morph_source = morph(); + aiMeshMorphAnim *morph_copy = nullptr; + Assimp::SceneCombiner::Copy(&morph_copy, morph_source); + assert(morph_copy && morph_copy->mKeys != morph_source->mKeys); + assert(morph_copy->mKeys[0].mTime == 5); + assert(morph_copy->mKeys[0].mValues == nullptr && morph_copy->mKeys[0].mWeights == nullptr); + assert(morph_copy->mKeys[1].mTime == 13.25); + assert(morph_copy->mKeys[1].mNumValuesAndWeights == 2); + assert(morph_copy->mKeys[1].mValues != morph_source->mKeys[1].mValues); + assert(morph_copy->mKeys[1].mWeights != morph_source->mKeys[1].mWeights); + assert(morph_copy->mKeys[1].mValues[1] == 3 && morph_copy->mKeys[1].mWeights[1] == 0.875); + assert(morph_copy->mKeys[2].mTime == 17); + assert(morph_copy->mKeys[2].mValues != morph_source->mKeys[2].mValues); + assert(morph_copy->mKeys[2].mWeights != morph_source->mKeys[2].mWeights); + morph_copy->mKeys[1].mValues[1] = 42; + morph_copy->mKeys[1].mWeights[1] = 0.25; + assert(morph_source->mKeys[1].mValues[1] == 3 && morph_source->mKeys[1].mWeights[1] == 0.875); + if (source_first) { + delete morph_source; + assert(morph_copy->mKeys[1].mValues[1] == 42 && morph_copy->mKeys[1].mWeights[1] == 0.25); + delete morph_copy; + } else { + delete morph_copy; + assert(morph_source->mKeys[1].mValues[1] == 3 && morph_source->mKeys[1].mWeights[1] == 0.875); + delete morph_source; + } + } + aiMesh empty; + aiMesh *empty_copy = nullptr; + Assimp::SceneCombiner::Copy(&empty_copy, &empty); + assert(empty_copy->mFaces == nullptr && empty_copy->mVertices == nullptr); + delete empty_copy; + aiMeshMorphAnim empty_morph; + aiMeshMorphAnim *empty_morph_copy = nullptr; + Assimp::SceneCombiner::Copy(&empty_morph_copy, &empty_morph); + assert(empty_morph_copy->mKeys == nullptr); + delete empty_morph_copy; + std::cout << "actual SceneCombiner mesh/morph ownership PASS\n"; +} diff --git a/tools/test_renpy_mobile_assimp_copy.py b/tools/test_renpy_mobile_assimp_copy.py new file mode 100644 index 000000000..b42c567d9 --- /dev/null +++ b/tools/test_renpy_mobile_assimp_copy.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Build the actual pinned Assimp recipe and execute host ownership checks. + +This runs the real download/build tasks, compiler, library and C++ objects. +It does not compile an iOS target or execute Ren'Py/mobile gameplay. +""" +import argparse +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +from types import SimpleNamespace + +REPO = Path(__file__).resolve().parents[1] +BUILD_PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" +ASSIMP_PIN = "c35200e38ea8f058812b83de2ef32c6093b0ece2" + + +def command(arguments, *, cwd): + result = subprocess.run(list(map(str, arguments)), cwd=cwd, + text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=1200) + if result.returncode: + raise RuntimeError(f"Host command failed ({result.returncode}):\n{result.stdout[-8000:]}") + return result.stdout + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("build_source", type=Path) + parser.add_argument("--compiler", default=shutil.which("clang++")) + args = parser.parse_args() + if not args.compiler: + raise SystemExit("A genuine Clang 21 or later host C++ compiler is required.") + # Preserve clang++'s argv[0] so its C++ driver also links the standard library. + compiler = Path(args.compiler).absolute() + assert compiler.is_file() + version = command([compiler, "--version"], cwd=REPO) + detected = re.search(r"clang version (\d+)\.", version) + if not detected or int(detected[1]) < 21: + raise SystemExit("The nontrivial object-copy regression requires genuine Clang 21 or later.") + c_compiler = compiler.with_name("clang") + assert c_compiler.is_file() + assert command(["git", "rev-parse", "HEAD"], cwd=args.build_source).strip() == BUILD_PIN + patches = sorted((REPO / "bridge/renpy_runtime/mobile_launcher/patches/native").glob( + "[0-9][0-9][0-9][0-9]-*.patch")) + assert any(path.name.startswith("0011-") for path in patches) + with tempfile.TemporaryDirectory(prefix="renpy-real-assimp-copy-") as directory: + root = Path(directory) / "renpy-build" + command(["git", "clone", "--no-local", "--depth=1", "--single-branch", "--quiet", "--no-checkout", + args.build_source.resolve(), root], cwd=REPO) + command(["git", "checkout", "--quiet", "--detach", BUILD_PIN], cwd=root) + command([sys.executable, REPO / "tools/apply_renpy_mobile_patch_series.py", + "--checkout", root, "--pin", BUILD_PIN, *patches], cwd=REPO) + sys.path.insert(0, str(root)) + from renpybuild.context import Context + from renpybuild.task import annotators, tasks + # Load the unchanged Assimp task without importing unrelated SDK tasks. + specification = importlib.util.spec_from_file_location("assimp", root / "tasks/assimp.py") + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + + def host_compiler(context): + context.env("CC", str(c_compiler)) + context.env("CXX", str(compiler)) + context.env("CXXFLAGS", "-Werror=nontrivial-memcall {{ CXXFLAGS }}") + context.env("CMAKE_BUILD_PARALLEL_LEVEL", str(min(os.cpu_count() or 1, 4))) + context.var("cmake_args", "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON") + + annotators.append(host_compiler) + selected = {task.task: task for task in tasks if task.name == "assimp"} + context = Context("host", "host", "3", root, SimpleNamespace()) + selected["download"].run(context) + source = context.tmp / "source/assimp" + assert command(["git", "rev-parse", "HEAD"], cwd=source).strip() == ASSIMP_PIN + selected["build"].run(context) + recipes = json.loads((context.build / "compile_commands.json").read_text()) + scene = [entry for entry in recipes if entry["file"].endswith("/Common/SceneCombiner.cpp")] + assert len(scene) == 1 and "-Werror=nontrivial-memcall" in scene[0]["command"] + assert "-Wno-nontrivial-memcall" not in scene[0]["command"] + + project = Path(directory) / "drivers" + project.mkdir() + (project / "CMakeLists.txt").write_text("""cmake_minimum_required(VERSION 3.20) +project(RealAssimpOwnership LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 17) +find_package(assimp CONFIG REQUIRED) +add_executable(ownership "${DRIVER_ROOT}/ownership.cpp") +target_link_libraries(ownership PRIVATE assimp::assimp) +add_executable(constructors "${DRIVER_ROOT}/constructors.cpp") +target_include_directories(constructors PRIVATE "${ASSIMP_SOURCE}/contrib/Open3DGC") +foreach(target ownership constructors) + target_compile_options(${target} PRIVATE -Wall -Wextra -Werror -Werror=nontrivial-memcall -UNDEBUG) +endforeach() +""") + driver_build = project / "build" + command(["cmake", "-S", project, "-B", driver_build, + "-DCMAKE_CXX_COMPILER=" + str(compiler), + "-DCMAKE_PREFIX_PATH=" + str(context.install), + "-DDRIVER_ROOT=" + str(REPO / "tools/renpy_assimp_acceptance"), + "-DASSIMP_SOURCE=" + str(source)], cwd=root) + command(["cmake", "--build", driver_build, "--parallel", "2"], cwd=root) + ownership = command([driver_build / "ownership"], cwd=root).strip() + constructors = command([driver_build / "constructors"], cwd=root).strip() + print(json.dumps({"status": "passed", "build_source_pin": BUILD_PIN, + "assimp_source_pin": ASSIMP_PIN, + "compiler": version.splitlines()[0], "host": sys.platform, + "production_download_and_build": "passed", + "actual_scene_combiner_copy": ownership, + "actual_open3dgc_constructors": constructors, + "target_compilation": "not_run", "gameplay": "unverified"}, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tools/test_renpy_mobile_ios_host_python.py b/tools/test_renpy_mobile_ios_host_python.py index 97e63b777..443d6aff3 100644 --- a/tools/test_renpy_mobile_ios_host_python.py +++ b/tools/test_renpy_mobile_ios_host_python.py @@ -53,7 +53,9 @@ def main(): assert any(path.name.startswith("0010-") for path in patches) with tempfile.TemporaryDirectory(prefix="renpy-real-host-python-") as directory: root = Path(directory) / "renpy-build" - capture(["git", "clone", "--shared", "--quiet", "--no-checkout", + # Limit the copy to the materialized pinned HEAD. Local shared clones + # can traverse unrelated missing objects in a promisor repository. + capture(["git", "clone", "--no-local", "--depth=1", "--single-branch", "--quiet", "--no-checkout", args.build_source.resolve(), root], cwd=REPO) capture(["git", "checkout", "--quiet", "--detach", PIN], cwd=root) capture([sys.executable, REPO / "tools/apply_renpy_mobile_patch_series.py", From 7c66a92d1f9592d12aefea455fc97f70d654e547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 23:38:10 +0000 Subject: [PATCH 296/331] fix(ios): resolve versioned host Python framework executable --- .../patches/native/0010-ios-host-python.patch | 8 ++++++-- .../mobile_launcher/patches/native/README.md | 2 ++ tools/test_renpy_mobile_ios_host_python.py | 13 +++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0010-ios-host-python.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0010-ios-host-python.patch index be63ecd92..9aeb6fa9b 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/0010-ios-host-python.patch +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0010-ios-host-python.patch @@ -2,7 +2,7 @@ diff --git a/renpybuild/ios_host_python.py b/renpybuild/ios_host_python.py new file mode 100644 --- /dev/null +++ b/renpybuild/ios_host_python.py -@@ -0,0 +1,143 @@ +@@ -0,0 +1,147 @@ +"""Keep iOS build tools on the host interpreter and package only pure wheels.""" + +from email.parser import BytesParser @@ -51,7 +51,11 @@ new file mode 100644 + if c.platform != "ios": + raise ValueError("iOS host tools require an iOS target") + -+ host_python = c.path("{{ host }}/bin/python3").resolve(strict=True) ++ host_python = c.path("{{ host }}/bin/python3") ++ # Framework/Homebrew installs can expose only the versioned executable. ++ if not host_python.is_file(): ++ host_python = c.path("{{ host }}/bin/{{ pythonver }}") ++ host_python = host_python.resolve(strict=True) + tools = _host_tools(c) + _run(c, host_python, "-I", "-m", "venv", "--clear", "--symlinks", tools) + tool_python = tools / "bin/python3" diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index 50d0e47c5..a953c1585 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -87,6 +87,8 @@ environment lies outside the packaged Python and native archive roots. The preparation regression executes real pip installation, the pinned `setuplib` import and actual bytecode compilation under conflicting Python path variables. It does not compile or execute an iOS runtime. +Both ordinary and versioned-only framework executable layouts resolve to +the real host interpreter; no active target interpreter is used as a fallback. `0011-assimp-owned-array-copy.patch` preserves Assimp 5.4.3 and its strict compiler checks. SceneCombiner uses `aiFace`'s deep assignment and explicitly diff --git a/tools/test_renpy_mobile_ios_host_python.py b/tools/test_renpy_mobile_ios_host_python.py index 443d6aff3..701c85432 100644 --- a/tools/test_renpy_mobile_ios_host_python.py +++ b/tools/test_renpy_mobile_ios_host_python.py @@ -77,6 +77,18 @@ def main(): context.env("PYTHONHOME", str(poison)) context.env("PYTHONPATH", str(poison)) prepare_ios_host_python(context) + + # Exercise a versioned-only distribution layout with the actual host + # interpreter. The symlink resolves to real CPython and its real stdlib; + # this does not substitute an interpreter or simulate a target SDK. + versioned_host = root / "versioned-only-host" + (versioned_host / "bin").mkdir(parents=True) + canonical = Path(sys._base_executable).resolve(strict=True) + (versioned_host / "bin/python3.12").symlink_to(canonical) + assert not (versioned_host / "bin/python3").exists() + context.var("host", versioned_host) + prepare_ios_host_python(context) + assert (context.install / "aether-host-tools/bin/python3").resolve(strict=True) == canonical tasks["pip"](context) wrapper = context.path("{{ install }}/bin/hostpython3") @@ -115,6 +127,7 @@ def main(): context.compile(runtime) assert runtime.with_suffix(".pyc").is_file() result = {"status": "passed", "host_python": sys.version.split()[0], + "installed_and_versioned_only_host_layouts": "passed", "isolated_stdlib_and_subprocess": "passed", "pinned_setuplib_import": "passed", "actual_context_compileall": "passed", "target_package_versions": versions, "target_native_extensions": "absent", "certifi_certificate": "present", From ed5421eed4fdbd6372104a329b62c4a8530795fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Thu, 8 Oct 2026 23:53:57 +0000 Subject: [PATCH 297/331] fix(ios): verify canonical host Python paths across filesystem aliases --- tools/test_renpy_mobile_ios_host_python.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tools/test_renpy_mobile_ios_host_python.py b/tools/test_renpy_mobile_ios_host_python.py index 701c85432..7562855f8 100644 --- a/tools/test_renpy_mobile_ios_host_python.py +++ b/tools/test_renpy_mobile_ios_host_python.py @@ -60,6 +60,11 @@ def main(): capture(["git", "checkout", "--quiet", "--detach", PIN], cwd=root) capture([sys.executable, REPO / "tools/apply_renpy_mobile_patch_series.py", "--checkout", root, "--pin", PIN, *patches], cwd=REPO) + # Run the real task through a filesystem alias as on macOS /var -> + # /private/var, so lexical path containment cannot mask a wrong prefix. + alias = Path(directory) / "renpy-build-alias" + alias.symlink_to(root, target_is_directory=True) + root = alias sys.path.insert(0, str(root)) from renpybuild.context import Context from renpybuild.ios_host_python import prepare_ios_host_python @@ -105,8 +110,12 @@ def main(): tools = json.loads(capture([wrapper, probe, args.renpy_src.resolve() / "scripts"], cwd=root, env=context.environ).strip().splitlines()[-1]) assert tools["setuptools"] == "74.1.2" - assert not Path(tools["stdlib"]).is_relative_to(context.install) - assert Path(tools["prefix"]).is_relative_to(context.install) + install = context.install.resolve(strict=True) + stdlib = Path(tools["stdlib"]).resolve(strict=True) + prefix = Path(tools["prefix"]).resolve(strict=True) + expected_prefix = (context.install / "aether-host-tools").resolve(strict=True) + assert not stdlib.is_relative_to(install), (stdlib, install) + assert prefix == expected_prefix, (prefix, expected_prefix) site = context.path("{{ install }}/lib/{{ pythonver }}/site-packages") versions = {dist.metadata["Name"].lower().replace("_", "-"): dist.version for dist in importlib.metadata.distributions(path=[str(site)])} @@ -128,6 +137,7 @@ def main(): assert runtime.with_suffix(".pyc").is_file() result = {"status": "passed", "host_python": sys.version.split()[0], "installed_and_versioned_only_host_layouts": "passed", + "canonical_host_venv_and_stdlib_paths": "passed", "isolated_stdlib_and_subprocess": "passed", "pinned_setuplib_import": "passed", "actual_context_compileall": "passed", "target_package_versions": versions, "target_native_extensions": "absent", "certifi_certificate": "present", From 10676a9dcd855bd44fb9a27de73fe73cf36f466d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 00:41:44 +0000 Subject: [PATCH 298/331] fix(renpy): preserve Godot key semantics and committed text --- apps/godot_app/scripts/main.gd | 16 +-- apps/godot_app/scripts/renpy_key_input.gd | 12 +++ .../godot_app/scripts/renpy_key_input_test.gd | 59 +++++++++++ .../scripts/renpy_mobile_acceptance.gd | 17 +--- .../src/aether_runtime_player.cpp | 22 +++++ .../renpy_runtime/include/renpy_godot_input.h | 45 +++++++++ .../include/renpy_input_mapping.h | 32 ++++-- .../src/renpy_runtime_mobile_provider.cpp | 3 +- .../src/renpy_runtime_provider.cpp | 6 +- bridge/renpy_runtime/tests/input_mapping.cpp | 36 +++++++ .../tests/mobile_provider_protocol.cpp | 98 +++++++++++++++++++ 11 files changed, 315 insertions(+), 31 deletions(-) create mode 100644 apps/godot_app/scripts/renpy_key_input.gd create mode 100644 apps/godot_app/scripts/renpy_key_input_test.gd create mode 100644 bridge/renpy_runtime/include/renpy_godot_input.h diff --git a/apps/godot_app/scripts/main.gd b/apps/godot_app/scripts/main.gd index b87b929c5..87be00cb3 100644 --- a/apps/godot_app/scripts/main.gd +++ b/apps/godot_app/scripts/main.gd @@ -62,6 +62,7 @@ const GameMetadata = preload("res://scripts/game_metadata.gd") const CoverIndex = preload("res://scripts/cover_index.gd") const VNDBCoverResolver = preload("res://scripts/vndb_cover_resolver.gd") const GameInputMapping = preload("res://scripts/game_input_mapping.gd") +const RenpyKeyInput = preload("res://scripts/renpy_key_input.gd") const GameVirtualControls = preload("res://scripts/game_virtual_controls.gd") const DiagnosticSession = preload("res://scripts/diagnostic_session.gd") const DiagnosticLocalization = preload("res://scripts/diagnostic_localization.gd") @@ -16418,12 +16419,15 @@ func _input(event: InputEvent) -> void: player.send_text_input(DisplayServer.clipboard_get()) get_viewport().set_input_as_handled() return - player.send_key_event( - key.pressed, - _kirikiri_virtual_key(key), - _kirikiri_key_modifiers(key), - key.unicode - ) + if active_runtime_kind == RUNTIME_RENPY: + RenpyKeyInput.send_key_event(player, key) + else: + player.send_key_event( + key.pressed, + _kirikiri_virtual_key(key), + _kirikiri_key_modifiers(key), + key.unicode + ) get_viewport().set_input_as_handled() return if _is_game_pointer_event(event): diff --git a/apps/godot_app/scripts/renpy_key_input.gd b/apps/godot_app/scripts/renpy_key_input.gd new file mode 100644 index 000000000..69a23f117 --- /dev/null +++ b/apps/godot_app/scripts/renpy_key_input.gd @@ -0,0 +1,12 @@ +extends RefCounted + +# This entry explicitly identifies Godot keycodes for the Ren'Py provider. +# KiriKiri and the other runtimes keep their existing Windows-VK path. +static func send_key_event(player: Object, key: InputEventKey) -> int: + var code := int(key.keycode) + if key.keycode == KEY_NONE: + code = int(key.physical_keycode) + var modifiers := int(key.get_modifiers_mask()) + if key.echo: + modifiers |= 0x80 + return player.send_renpy_key_event(key.pressed, code, modifiers, key.unicode) diff --git a/apps/godot_app/scripts/renpy_key_input_test.gd b/apps/godot_app/scripts/renpy_key_input_test.gd new file mode 100644 index 000000000..592ed27c6 --- /dev/null +++ b/apps/godot_app/scripts/renpy_key_input_test.gd @@ -0,0 +1,59 @@ +extends SceneTree + +const RenpyKeyInput = preload("res://scripts/renpy_key_input.gd") + +class Recorder extends RefCounted: + var events: Array = [] + var result := 0 + + func send_renpy_key_event(pressed: bool, code: int, modifiers: int, unicode: int) -> int: + events.append([pressed, code, modifiers, unicode]) + return result + +func _init() -> void: + var recorder := Recorder.new() + # Actual Godot InputEventKey objects preserve characters that the legacy + # VK path would treat as Delete, Help, arrows, keypad or function keys. + for code in range(32, 127): + var key := InputEventKey.new() + key.pressed = true + key.keycode = code + key.unicode = code + assert(RenpyKeyInput.send_key_event(recorder, key) == 0) + assert(recorder.events.back() == [true, code, 0, code]) + for code in [KEY_DELETE, KEY_ENTER, KEY_KP_ENTER, KEY_BACKSPACE, KEY_ESCAPE, + KEY_TAB, KEY_BACKTAB, KEY_HOME, KEY_END, KEY_LEFT, KEY_RIGHT, + KEY_UP, KEY_DOWN, KEY_PAGEUP, KEY_PAGEDOWN, KEY_INSERT, KEY_F1, KEY_F24]: + var key := InputEventKey.new() + key.keycode = code + key.pressed = false + assert(RenpyKeyInput.send_key_event(recorder, key) == 0) + assert(recorder.events.back() == [false, code, 0, 0]) + var shortcut := InputEventKey.new() + shortcut.pressed = true + shortcut.keycode = KEY_A + shortcut.unicode = 65 + shortcut.shift_pressed = true + shortcut.ctrl_pressed = true + shortcut.meta_pressed = true + shortcut.echo = true + RenpyKeyInput.send_key_event(recorder, shortcut) + var modifiers := int(shortcut.get_modifiers_mask()) | 0x80 + assert(recorder.events.back() == [true, KEY_A, modifiers, 65]) + assert(modifiers & KEY_MASK_CTRL and modifiers & KEY_MASK_SHIFT and modifiers & KEY_MASK_META) + var physical := InputEventKey.new() + physical.keycode = KEY_NONE + physical.physical_keycode = KEY_DELETE + physical.pressed = true + RenpyKeyInput.send_key_event(recorder, physical) + assert(recorder.events.back() == [true, KEY_DELETE, 0, 0]) + var committed := InputEventKey.new() + committed.pressed = true + committed.unicode = 0x1f642 + RenpyKeyInput.send_key_event(recorder, committed) + assert(recorder.events.back() == [true, KEY_NONE, 0, 0x1f642]) + recorder.result = -4 + assert(RenpyKeyInput.send_key_event(recorder, committed) == -4) + print("Ren'Py Godot key forwarding passed: printable/special/physical keys, modifiers, repeat and Unicode") + print("Recorder boundary only; no GDExtension, SDL or gameplay execution") + quit(0) diff --git a/apps/godot_app/scripts/renpy_mobile_acceptance.gd b/apps/godot_app/scripts/renpy_mobile_acceptance.gd index 462582b89..16090ce30 100644 --- a/apps/godot_app/scripts/renpy_mobile_acceptance.gd +++ b/apps/godot_app/scripts/renpy_mobile_acceptance.gd @@ -5,6 +5,7 @@ signal game_finished(native_state: Dictionary) # A debug-device observer, driven exclusively by OS input. It never manufactures # game progress, sends a scripted click, or falls back to a viewport screenshot. const GameInputMapping = preload("res://scripts/game_input_mapping.gd") +const RenpyKeyInput = preload("res://scripts/renpy_key_input.gd") const POINTER_DOWN := 1 const POINTER_MOVE := 2 const POINTER_UP := 3 @@ -155,21 +156,9 @@ func _input(event: InputEvent) -> void: elif event is InputEventKey: var key := event as InputEventKey var code := int(key.keycode) - if key.keycode == KEY_ENTER or key.keycode == KEY_KP_ENTER: - code = 13 - elif key.keycode == KEY_BACKSPACE: - code = 8 - elif key.keycode == KEY_ESCAPE: - code = 27 - elif key.keycode == KEY_DELETE: - code = 46 - elif key.keycode == KEY_LEFT: - code = 37 - elif key.keycode == KEY_RIGHT: - code = 39 - result = player.send_key_event(key.pressed, code, 0, key.unicode) + result = RenpyKeyInput.send_key_event(player, key) _record({"kind": "os_key", "pressed": key.pressed, "code": code, - "unicode": key.unicode, "result": result}) + "keycode_space": "godot", "unicode": key.unicode, "result": result}) else: return get_viewport().set_input_as_handled() diff --git a/bridge/godot_extension/src/aether_runtime_player.cpp b/bridge/godot_extension/src/aether_runtime_player.cpp index ba94eb76d..d796d2531 100644 --- a/bridge/godot_extension/src/aether_runtime_player.cpp +++ b/bridge/godot_extension/src/aether_runtime_player.cpp @@ -2,6 +2,7 @@ #include "engine_options.h" #include "engine_runtime_provider.h" #if defined(AETHERKIRI_WITH_RENPY) +#include "renpy_godot_input.h" #include "renpy_runtime.h" #endif #if defined(AETHERKIRI_WITH_RFVP) @@ -9944,6 +9945,24 @@ class AetherRuntimePlayer final : public Node { return result; } + int send_renpy_key_event(bool pressed, int godot_keycode, int modifiers, + int unicode_codepoint) { +#if defined(AETHERKIRI_WITH_RENPY) + if (handle_ == nullptr) return ENGINE_RESULT_INVALID_STATE; + if (runtime_id_ != "renpy") return ENGINE_RESULT_NOT_SUPPORTED; + const engine_result_t result = aetherkiri::renpy::input_mapping::SendGodotKeyEvents( + [this](const engine_input_event_t& event) { + return engine_send_input(handle_, &event); + }, pressed, godot_keycode, modifiers, + static_cast(std::max(0, unicode_codepoint))); + update_last_error(result); + return result; +#else + (void)pressed; (void)godot_keycode; (void)modifiers; (void)unicode_codepoint; + return ENGINE_RESULT_NOT_SUPPORTED; +#endif + } + int send_text_input(const String& text) { if (handle_ == nullptr) { return ENGINE_RESULT_INVALID_STATE; @@ -11520,6 +11539,9 @@ void main() { &AetherRuntimePlayer::send_key_event); ClassDB::bind_method(D_METHOD("send_text_input", "text"), &AetherRuntimePlayer::send_text_input); + ClassDB::bind_method(D_METHOD("send_renpy_key_event", "pressed", "godot_keycode", + "modifiers", "unicode_codepoint"), + &AetherRuntimePlayer::send_renpy_key_event); ClassDB::bind_method(D_METHOD("get_text_input_state"), &AetherRuntimePlayer::get_text_input_state); ClassDB::bind_method(D_METHOD("send_ime_preedit", "text", "start", "length"), diff --git a/bridge/renpy_runtime/include/renpy_godot_input.h b/bridge/renpy_runtime/include/renpy_godot_input.h new file mode 100644 index 000000000..2162e2411 --- /dev/null +++ b/bridge/renpy_runtime/include/renpy_godot_input.h @@ -0,0 +1,45 @@ +#pragma once + +#include "engine_api.h" +#include "renpy_input_mapping.h" + +namespace aetherkiri::renpy::input_mapping { + +inline bool HasCommittedGodotText(int modifiers, uint32_t codepoint) { + if (codepoint < 0x20 || (codepoint >= 0x7f && codepoint < 0xa0) || + codepoint > 0x10ffff || (codepoint >= 0xd800 && codepoint <= 0xdfff)) + return false; + if (modifiers & kGodotModifierMeta) return false; + const bool control = (modifiers & (kAetherModifierControl | kGodotModifierControl)) != 0; + const bool alt = (modifiers & (kAetherModifierAlt | kGodotModifierAlt)) != 0; + // Ctrl shortcuts are key events. Ctrl+Alt may be AltGr and can legitimately + // supply a committed character; do not synthesize one from the keycode. + return !control || alt; +} + +template +engine_result_t SendGodotKeyEvents(Send&& send, bool pressed, int keycode, + int modifiers, uint32_t codepoint) { + engine_input_event_t event{}; + event.struct_size = sizeof(event); + engine_result_t result = ENGINE_RESULT_OK; + if (keycode != 0) { + event.type = pressed ? ENGINE_INPUT_EVENT_KEY_DOWN : ENGINE_INPUT_EVENT_KEY_UP; + event.key_code = keycode; + event.modifiers = modifiers; + event.reserved_u32 = static_cast(KeyCodeSpace::Godot); + // Ren'Py text is committed through TEXT_INPUT. Desktop and mobile + // KEYDOWN must not carry another copy of that Unicode character. + result = send(event); + } + if (result == ENGINE_RESULT_OK && pressed && HasCommittedGodotText(modifiers, codepoint)) { + event.type = ENGINE_INPUT_EVENT_TEXT_INPUT; + event.key_code = event.modifiers = 0; + event.reserved_u32 = 0; + event.unicode_codepoint = codepoint; + result = send(event); + } + return result; +} + +} // namespace aetherkiri::renpy::input_mapping diff --git a/bridge/renpy_runtime/include/renpy_input_mapping.h b/bridge/renpy_runtime/include/renpy_input_mapping.h index 1ac0054a5..57b8004cf 100644 --- a/bridge/renpy_runtime/include/renpy_input_mapping.h +++ b/bridge/renpy_runtime/include/renpy_input_mapping.h @@ -44,15 +44,13 @@ inline int MapPointerButtonToPygame(int button) { } } -inline int MapKeyCodeToPygame(int code) { - // Preserve SDL2 non-printable keycodes used by existing direct callers. - // Low-byte SDL ASCII and Windows VK values overlap; the ABI's VK - // interpretation takes precedence (e.g. 0x70 means F1, not 'p'). - if (code >= kPygameScancodeMask) return code; - if (code >= 0x01000000) code &= kGodotCodeMask; +// Ren'Py-only use of engine_input_event_t.reserved_u32. An explicit source +// removes the low-byte ambiguity between Godot characters and Windows VKs. +// The provider consumes this field before sending SDL keycodes to the launcher. +enum class KeyCodeSpace : unsigned int { Legacy = 0, Godot = 1 }; - // The manual Godot probe passes raw Godot 4 non-printable keycodes. - // Their distinct KEY_SPECIAL range can be recognized unambiguously. +inline int MapGodotKeyCodeToPygame(int code) { + code &= kGodotCodeMask; if (code >= kGodotSpecial && code < kGodotSpecial + 0x1000) { const int special = code - kGodotSpecial; switch (special) { @@ -99,6 +97,24 @@ inline int MapKeyCodeToPygame(int code) { return 0; } + if (code >= 'A' && code <= 'Z') return code + ('a' - 'A'); + if (code >= 0x20 && code <= 0x10ffff && + !(code >= 0xd800 && code <= 0xdfff)) return code; + return 0; +} + +inline int MapKeyCodeToPygame(int code, KeyCodeSpace space = KeyCodeSpace::Legacy) { + if (space == KeyCodeSpace::Godot) return MapGodotKeyCodeToPygame(code); + // Preserve SDL2 non-printable keycodes used by existing direct callers. + // Low-byte SDL ASCII and Windows VK values overlap; the ABI's VK + // interpretation takes precedence (e.g. 0x70 means F1, not 'p'). + if (code >= kPygameScancodeMask) return code; + if (code >= 0x01000000) code &= kGodotCodeMask; + + // Raw Godot special keys remain accepted by legacy direct callers. + if (code >= kGodotSpecial && code < kGodotSpecial + 0x1000) + return MapGodotKeyCodeToPygame(code); + switch (code) { case 0x08: return 8; // Backspace case 0x09: return 9; // Tab diff --git a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp index a839fed29..172fdba31 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp @@ -369,7 +369,8 @@ engine_result_t Input(void* value, const engine_input_event_t* event) { input.struct_size = sizeof(input); input.timestamp_ns = event->timestamp_micros * 1000ull; input.device_id = event->pointer_id; - input.code = input_mapping::MapKeyCodeToPygame(event->key_code); + input.code = input_mapping::MapKeyCodeToPygame(event->key_code, + static_cast(event->reserved_u32)); input.value = static_cast(event->type); { std::lock_guard lock(runtime->frame_mutex); diff --git a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp index 1f2136b28..d9adf1e61 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp @@ -1083,7 +1083,8 @@ engine_result_t Input(void* value, const engine_input_event_t* event) { break; case ENGINE_INPUT_EVENT_KEY_DOWN: event_type = 768; // pygame.KEYDOWN - attrs << "\"key\":" << MapKeyCodeToPygame(event->key_code) + attrs << "\"key\":" << MapKeyCodeToPygame(event->key_code, + static_cast(event->reserved_u32)) << ",\"mod\":" << MapModifiersToPygame(event->modifiers) << ",\"unicode\":" << JsonEscape(Utf8Codepoint(event->unicode_codepoint)) @@ -1092,7 +1093,8 @@ engine_result_t Input(void* value, const engine_input_event_t* event) { break; case ENGINE_INPUT_EVENT_KEY_UP: event_type = 769; // pygame.KEYUP - attrs << "\"key\":" << MapKeyCodeToPygame(event->key_code) + attrs << "\"key\":" << MapKeyCodeToPygame(event->key_code, + static_cast(event->reserved_u32)) << ",\"mod\":" << MapModifiersToPygame(event->modifiers) << ",\"unicode\":" << JsonEscape(Utf8Codepoint(event->unicode_codepoint)) diff --git a/bridge/renpy_runtime/tests/input_mapping.cpp b/bridge/renpy_runtime/tests/input_mapping.cpp index 46d7b2f75..2f7126541 100644 --- a/bridge/renpy_runtime/tests/input_mapping.cpp +++ b/bridge/renpy_runtime/tests/input_mapping.cpp @@ -1,5 +1,6 @@ #include "renpy_runtime.h" #include "engine_api.h" +#include "renpy_godot_input.h" #include #include @@ -134,6 +135,19 @@ void CheckInputMapping() { Send(handle, ENGINE_INPUT_EVENT_KEY_DOWN, 0, 0x00a5); // VK_RALT // Existing SDL/Pygame values remain valid for compatibility. Send(handle, ENGINE_INPUT_EVENT_KEY_UP, 0, 1073741905); + namespace mapping = aetherkiri::renpy::input_mapping; + auto send_godot = [&](bool pressed, int code, int modifiers, uint32_t unicode) { + return mapping::SendGodotKeyEvents([&](const engine_input_event_t& event) { + return engine_send_input(handle, &event); + }, pressed, code, modifiers, unicode); + }; + assert(send_godot(true, '.', mapping::kGodotModifierShift, '>') == ENGINE_RESULT_OK); + assert(send_godot(false, '.', mapping::kGodotModifierShift, '>') == ENGINE_RESULT_OK); + assert(send_godot(true, '/', 0, '/') == ENGINE_RESULT_OK); + assert(send_godot(true, mapping::kGodotSpecial + 8, 0, 0) == ENGINE_RESULT_OK); // Delete + assert(send_godot(true, mapping::kGodotSpecial + 5, 0, '\r') == ENGINE_RESULT_OK); // Enter + assert(send_godot(true, 'C', mapping::kGodotModifierControl, 'c') == ENGINE_RESULT_OK); + assert(send_godot(true, 0, 0, 0x1f642) == ENGINE_RESULT_OK); // committed software text std::this_thread::sleep_for(std::chrono::milliseconds(20)); const std::string lines = ReadInput(input_path); assert(lines.find("\"type\":1025,\"attributes\":{\"pos\":[0,0],\"button\":1}") != @@ -153,6 +167,28 @@ void CheckInputMapping() { assert(lines.find("\"key\":1073742054,\"mod\":0") != std::string::npos); assert(lines.find("\"type\":769,\"attributes\":{\"key\":1073741905") != std::string::npos); + assert(lines.find("\"type\":768,\"attributes\":{\"key\":46,\"mod\":3,\"unicode\":\"\",\"repeat\":false}") != + std::string::npos); + assert(lines.find("\"type\":769,\"attributes\":{\"key\":46,\"mod\":3,\"unicode\":\"\",\"repeat\":false}") != + std::string::npos); + assert(lines.find("\"type\":768,\"attributes\":{\"key\":47,\"mod\":0,\"unicode\":\"\",\"repeat\":false}") != + std::string::npos); + assert(lines.find("\"type\":768,\"attributes\":{\"key\":127,\"mod\":0,\"unicode\":\"\",\"repeat\":false}") != + std::string::npos); + assert(lines.find("\"type\":768,\"attributes\":{\"key\":13,\"mod\":0,\"unicode\":\"\",\"repeat\":false}") != + std::string::npos); + assert(lines.find("\"type\":768,\"attributes\":{\"key\":99,\"mod\":192,\"unicode\":\"\",\"repeat\":false}") != + std::string::npos); + assert(lines.find("\"type\":771,\"attributes\":{\"text\":\">\"}") != std::string::npos); + assert(lines.find("\"type\":771,\"attributes\":{\"text\":\"/\"}") != std::string::npos); + assert(lines.find("\"type\":771,\"attributes\":{\"text\":\"\xf0\x9f\x99\x82\"}") != std::string::npos); + assert(lines.find("\"text\":\"c\"") == std::string::npos); + assert(lines.find("\"text\":\"\\r\"") == std::string::npos && lines.find("\"text\":\"\\n\"") == std::string::npos); + // Exactly three commits: shifted period, slash and the software emoji. + size_t text_events = 0; + for (size_t pos = 0; (pos = lines.find("\"type\":771", pos)) != std::string::npos; ++pos) + ++text_events; + assert(text_events == 3); assert(engine_destroy(handle) == ENGINE_RESULT_OK); fs::remove_all(root, ec); diff --git a/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp b/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp index b446e2e15..04aa70710 100644 --- a/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp +++ b/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp @@ -1,11 +1,13 @@ #include "renpy_runtime.h" #include "engine_runtime_provider.h" #include "renpy_mobile_launcher.h" +#include "renpy_godot_input.h" #include #include #include #include +#include #include namespace { @@ -16,6 +18,11 @@ bool finish; bool push_frame = true; renpy_mobile_input_t last_input{}; std::string last_text; +struct InputRecord { + renpy_mobile_input_t input; + std::string text; +}; +std::vector inputs; struct LogEntry { uint32_t level; std::string subsystem; @@ -55,6 +62,8 @@ extern "C" int renpy_mobile_frame(renpy_mobile_frame_t*) { return RENPY_MOBILE_I extern "C" int renpy_mobile_input(const renpy_mobile_input_t* input) { last_input = *input; last_text = input->text_utf8 ? input->text_utf8 : ""; + inputs.push_back({last_input, last_text}); + inputs.back().input.text_utf8 = nullptr; return 0; } extern "C" int renpy_mobile_pause() { return 0; } @@ -121,6 +130,95 @@ int main() { event.modifiers = 0x01 | 0x04 | 0x80; assert(provider->send_input(runtime, &event) == ENGINE_RESULT_OK); assert(last_input.code == 'a' && last_input.modifiers == 195 && last_input.repeat == 1); + // The explicit Godot namespace preserves every printable key, including + // punctuation/lowercase values that collide with legacy Windows VKs. + namespace mapping = aetherkiri::renpy::input_mapping; + std::vector raw_events; + auto send_godot = [&](bool pressed, int code, int modifiers, uint32_t unicode) { + return mapping::SendGodotKeyEvents([&](const engine_input_event_t& input) { + raw_events.push_back(input); + return provider->send_input(runtime, &input); + }, pressed, code, modifiers, unicode); + }; + for (int code = 32; code <= 126; ++code) { + const size_t before = inputs.size(); + assert(send_godot(true, code, 0, code) == ENGINE_RESULT_OK); + assert(inputs.size() == before + 2); + const auto& key = inputs[before].input; + assert(key.type == RENPY_MOBILE_INPUT_KEY && key.value == ENGINE_INPUT_EVENT_KEY_DOWN); + assert(key.code == (code >= 'A' && code <= 'Z' ? code + 'a' - 'A' : code)); + assert(inputs[before + 1].input.type == RENPY_MOBILE_INPUT_TEXT); + assert(inputs[before + 1].text == std::string(1, static_cast(code))); + assert(raw_events[raw_events.size() - 2].unicode_codepoint == 0); + assert(raw_events[raw_events.size() - 2].reserved_u32 == + static_cast(mapping::KeyCodeSpace::Godot)); + assert(raw_events.back().reserved_u32 == 0 && raw_events.back().key_code == 0); + const size_t release_before = inputs.size(); + assert(send_godot(false, code, 0, code) == ENGINE_RESULT_OK); + assert(inputs.size() == release_before + 1 && last_input.value == ENGINE_INPUT_EVENT_KEY_UP); + } + const std::pair special_keys[] = { + {1, 27}, {2, 9}, {3, 9}, {4, 8}, {5, 13}, + {6, mapping::kPygameScancodeMask + 88}, {7, mapping::kPygameScancodeMask + 73}, + {8, 127}, {13, mapping::kPygameScancodeMask + 74}, + {14, mapping::kPygameScancodeMask + 77}, {15, mapping::kPygameScancodeMask + 80}, + {16, mapping::kPygameScancodeMask + 82}, {17, mapping::kPygameScancodeMask + 79}, + {18, mapping::kPygameScancodeMask + 81}, {19, mapping::kPygameScancodeMask + 75}, + {20, mapping::kPygameScancodeMask + 78}, {28, mapping::PygameFunctionKey(1)}, + {51, mapping::PygameFunctionKey(24)}, {132, mapping::kPygameScancodeMask + 99}, + }; + for (const auto& [code, expected] : special_keys) { + const size_t before = inputs.size(); + // Even a platform newline accompanying Enter is a key command only. + assert(send_godot(true, mapping::kGodotSpecial + code, 0, + code == 5 || code == 6 ? '\n' : 0) == ENGINE_RESULT_OK); + assert(inputs.size() == before + 1 && last_input.code == expected && last_text.empty()); + assert(raw_events.back().unicode_codepoint == 0); + } + const size_t ctrl_before = inputs.size(); + assert(send_godot(true, 'A', mapping::kGodotModifierControl | mapping::kGodotModifierShift | + mapping::kAetherModifierEcho, 'A') == ENGINE_RESULT_OK); + assert(inputs.size() == ctrl_before + 1 && last_input.code == 'a'); + assert(last_input.modifiers == 195 && last_input.repeat == 1); + const size_t meta_before = inputs.size(); + assert(send_godot(true, 'C', mapping::kGodotModifierMeta, 'c') == ENGINE_RESULT_OK); + assert(inputs.size() == meta_before + 1 && last_input.modifiers == mapping::kPygameKmodGui); + const size_t shift_before = inputs.size(); + assert(send_godot(true, '.', mapping::kGodotModifierShift, '>') == ENGINE_RESULT_OK); + assert(inputs.size() == shift_before + 2 && inputs[shift_before].input.code == '.'); + assert(inputs[shift_before].input.modifiers == mapping::kPygameKmodShift && last_text == ">"); + const size_t soft_before = inputs.size(); + assert(send_godot(true, 0, 0, 0x1f642) == ENGINE_RESULT_OK); + assert(inputs.size() == soft_before + 1 && last_input.type == RENPY_MOBILE_INPUT_TEXT); + assert(last_text == "\xf0\x9f\x99\x82"); + for (uint32_t unicode : {0x4f60u, 0x1f642u}) { + const size_t before = inputs.size(); + // Godot KEY_UNKNOWN is nonzero and remains an unknown key, not a VK. + assert(send_godot(true, mapping::kGodotCodeMask, 0, unicode) == ENGINE_RESULT_OK); + assert(inputs.size() == before + 2 && inputs[before].input.code == 0); + assert(inputs.back().input.type == RENPY_MOBILE_INPUT_TEXT && !inputs.back().text.empty()); + } + const size_t altgr_before = inputs.size(); + assert(send_godot(true, 'E', mapping::kGodotModifierControl | mapping::kGodotModifierAlt, + 0x20ac) == ENGINE_RESULT_OK); + assert(inputs.size() == altgr_before + 2 && last_text == "\xe2\x82\xac"); + for (uint32_t unicode : {0u, 8u, 9u, 10u, 13u, 127u, 0xd800u, 0x110000u}) { + const size_t before = inputs.size(); + assert(send_godot(true, 0, 0, unicode) == ENGINE_RESULT_OK && inputs.size() == before); + } + int calls = 0; + assert(mapping::SendGodotKeyEvents([&](const engine_input_event_t&) { + ++calls; + return ENGINE_RESULT_IO_ERROR; + }, true, '.', 0, '.') == ENGINE_RESULT_IO_ERROR && calls == 1); + // Unmarked events retain the old VK and direct SDL contracts. + event.key_code = 0x2e; + event.modifiers = 0; + assert(provider->send_input(runtime, &event) == ENGINE_RESULT_OK && last_input.code == 127); + event.key_code = 0x70; + assert(provider->send_input(runtime, &event) == ENGINE_RESULT_OK && last_input.code == mapping::PygameFunctionKey(1)); + event.key_code = mapping::kPygameScancodeMask + 79; + assert(provider->send_input(runtime, &event) == ENGINE_RESULT_OK && last_input.code == event.key_code); event.type = ENGINE_INPUT_EVENT_TEXT_INPUT; event.unicode_codepoint = 0x1f642; assert(provider->send_input(runtime, &event) == ENGINE_RESULT_OK && last_text == "\xf0\x9f\x99\x82"); From 1967809558f4022aa781062bfe5a4f8e903c84d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 00:48:42 +0000 Subject: [PATCH 299/331] fix(ios): build FreeType export tools with the native compiler --- .github/workflows/renpy-ios-runtime-build.yml | 5 + .../native/0012-freetype-host-tools.patch | 102 ++++++++ .../mobile_launcher/patches/native/README.md | 9 + tools/renpy_freetype_acceptance/README.md | 59 +++++ .../test_renpy_mobile_freetype_host_tools.py | 236 ++++++++++++++++++ 5 files changed, 411 insertions(+) create mode 100644 bridge/renpy_runtime/mobile_launcher/patches/native/0012-freetype-host-tools.patch create mode 100644 tools/renpy_freetype_acceptance/README.md create mode 100644 tools/test_renpy_mobile_freetype_host_tools.py diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index 7f5414b3c..5459d94f1 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -10,6 +10,7 @@ on: - 'tools/test_renpy_mobile_ios_libffi.py' - 'tools/test_renpy_mobile_ios_host_python.py' - 'tools/test_renpy_mobile_assimp_copy.py' + - 'tools/test_renpy_mobile_freetype_host_tools.py' - 'tools/renpy_assimp_acceptance/**' - 'tools/renpy_ios_acceptance/**' - 'tools/run_renpy_ios_device_acceptance.py' @@ -136,6 +137,10 @@ jobs: "$GITHUB_WORKSPACE/tools/test_renpy_mobile_assimp_copy.py" "$RENPY_BUILD_ROOT" \ --compiler "$(xcrun --sdk macosx -f clang++)" \ 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" + uv --project "$RENPY_BUILD_ROOT/renpy" run python \ + "$GITHUB_WORKSPACE/tools/test_renpy_mobile_freetype_host_tools.py" "$RENPY_BUILD_ROOT" \ + --output-dir "$RUNNER_TEMP/renpy-freetype-host-tools" --jobs 3 \ + 2>&1 | tee -a "$RUNNER_TEMP/renpy-ios-source-prepare.log" - name: Compile target CPython, SDL, Ren'Py and device/Simulator archives if: steps.runtime-cache.outputs.cache-hit != 'true' run: | diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/0012-freetype-host-tools.patch b/bridge/renpy_runtime/mobile_launcher/patches/native/0012-freetype-host-tools.patch new file mode 100644 index 000000000..8693c75ce --- /dev/null +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/0012-freetype-host-tools.patch @@ -0,0 +1,102 @@ +diff --git a/renpybuild/freetype_host_tools.py b/renpybuild/freetype_host_tools.py +new file mode 100644 +--- /dev/null ++++ b/renpybuild/freetype_host_tools.py +@@ -0,0 +1,55 @@ ++"""Isolate FreeType's native export generator from the target toolchain.""" ++from pathlib import Path ++import re ++import shlex ++import shutil ++import subprocess ++import sys ++ ++ ++_CROSS_DEPLOYMENT = ( ++ "IPHONEOS_DEPLOYMENT_TARGET", "TVOS_DEPLOYMENT_TARGET", ++ "WATCHOS_DEPLOYMENT_TARGET", "XROS_DEPLOYMENT_TARGET", ++ "DRIVERKIT_DEPLOYMENT_TARGET", "SDKROOT", ++) ++ ++ ++def host_make_arguments(c): ++ """Use FreeType's CC_BUILD and CCexe hooks without changing target flags.""" ++ environment = dict(c.environ) ++ for variable in _CROSS_DEPLOYMENT: ++ environment.pop(variable, None) ++ ++ def output(arguments): ++ return subprocess.check_output(arguments, env=environment, text=True).strip() ++ ++ flags = [] ++ if sys.platform == "darwin": ++ compiler = Path(output(["xcrun", "--sdk", "macosx", "--find", "clang"])) ++ sdk = Path(output(["xcrun", "--sdk", "macosx", "--show-sdk-path"])).resolve(strict=True) ++ flags = ["-isysroot", str(sdk)] ++ triple = output([str(compiler), *flags, "-dumpmachine"]) ++ if not re.search(r"-apple-(?:darwin|macos)", triple): ++ raise RuntimeError("FreeType's host compiler must target native macOS: " + triple) ++ flags = ["-target", triple, *flags] ++ else: ++ selected = shutil.which("cc", path=environment.get("PATH")) ++ if not selected: ++ raise RuntimeError("FreeType requires a genuine native C compiler") ++ compiler = Path(selected) ++ ++ wrapper = c.path("{{ build }}/aether-freetype-host-cc") ++ if any(character.isspace() for character in str(wrapper)): ++ raise RuntimeError("FreeType's Autoconf host compiler path must not contain whitespace") ++ wrapper.write_text( ++ "#!/bin/sh\nunset " + " ".join(_CROSS_DEPLOYMENT) + "\nexec " ++ + shlex.join([str(compiler), *flags]) + ' "$@"\n', encoding="utf-8", ++ ) ++ wrapper.chmod(0o755) ++ c.env("CC_BUILD", str(wrapper)) ++ ++ # configure can reset CC_BUILD to CC when a target conftest runs locally. ++ # Command-line make assignments override unix-cc.mk's CCexe := assignment. ++ # apinames uses only the native C library; target include/link flags do not ++ # belong in its CCexe_CFLAGS or CCexe_LDFLAGS. ++ return shlex.join(["CCexe=" + str(wrapper), "CCexe_CFLAGS=", "CCexe_LDFLAGS="]) +diff --git a/tasks/freetype.py b/tasks/freetype.py +--- a/tasks/freetype.py ++++ b/tasks/freetype.py +@@ -1,5 +1,6 @@ + from renpybuild.context import Context + from renpybuild.task import task, annotator ++from renpybuild.freetype_host_tools import host_make_arguments + + version = "2.13.3" + +@@ -26,6 +27,8 @@ + c.var("version", version) + c.chdir("freetype-{{version}}") + ++ host_tools = host_make_arguments(c) ++ + c.run("""{{configure}} {{ cross_config }} --disable-shared --prefix="{{ install }}" --with-harfbuzz=no""") +- c.run("""{{ make }}""") +- c.run("""make install""") ++ c.run("""{{ make }} {{ host_tools }}""", host_tools=host_tools) ++ c.run("""make install {{ host_tools }}""", host_tools=host_tools) +diff --git a/tasks/freetypehb.py b/tasks/freetypehb.py +--- a/tasks/freetypehb.py ++++ b/tasks/freetypehb.py +@@ -1,5 +1,6 @@ + from renpybuild.context import Context + from renpybuild.task import task, annotator ++from renpybuild.freetype_host_tools import host_make_arguments + + version = "2.13.3" + +@@ -26,6 +27,8 @@ + c.var("version", version) + c.chdir("freetype-{{version}}") + ++ host_tools = host_make_arguments(c) ++ + c.run("""{{configure}} {{ cross_config }} --disable-shared --prefix="{{ install }}" --with-harfbuzz=yes""") +- c.run("""{{ make }}""") +- c.run("""make install""") ++ c.run("""{{ make }} {{ host_tools }}""", host_tools=host_tools) ++ c.run("""make install {{ host_tools }}""", host_tools=host_tools) diff --git a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md index a953c1585..bc903b4b3 100644 --- a/bridge/renpy_runtime/mobile_launcher/patches/native/README.md +++ b/bridge/renpy_runtime/mobile_launcher/patches/native/README.md @@ -103,6 +103,15 @@ copy operations and Open3DGC constructor checks. This catches both ownership and current compiler problems before the complete device/Simulator build. Host execution does not establish iOS target compilation or gameplay. +`0012-freetype-host-tools.patch` builds FreeType's `apinames` export generator +with a separate native compiler. On Darwin its wrapper explicitly selects the +macOS target and selected Xcode macOS SDK, clearing cross deployment settings +only for that child. Both FreeType recipes override the host tool's make and +install variables while retaining the target compiler and flags. The genuine +FreeType task regression checks executed export generation and every archive +object's CPU and platform; it does not execute target libraries or gameplay. +See [the regression instructions](../../../../../tools/renpy_freetype_acceptance/README.md). + `renpy_mobile_lifecycle.c` owns the copied host callback table and the borrowed frame byte storage. It invokes the full cooperative Python entrypoints, preserves pointer/key/text actions, publishes renderer screenshots and diff --git a/tools/renpy_freetype_acceptance/README.md b/tools/renpy_freetype_acceptance/README.md new file mode 100644 index 000000000..1dad2abd7 --- /dev/null +++ b/tools/renpy_freetype_acceptance/README.md @@ -0,0 +1,59 @@ +# FreeType native export-generator acceptance + +Run the actual FreeType 2.13.3 unpack/build/install tasks from pinned +`renpy-build` commit `7bfab40c1174f622f644b24669afd5fb167fbb79`, applying the complete +native patch series to a fresh scratch checkout: + +```sh +python tools/test_renpy_mobile_freetype_host_tools.py /path/to/renpy-build \ + --output-dir /tmp/renpy-freetype-host-tools --jobs 3 +``` + +The driver needs the pinned source checkout, Python with Jinja2, make, pkg-config +and the platform's real compiler tools. On an arm64 Mac it uses the selected +Xcode's macOS, iPhoneOS and iPhoneSimulator SDKs. On Linux it checks a native +archive; add `--ndk /path/to/android-ndk-r29` for a real Android AArch64 API 21 +cross archive. No target SDK or compiler is simulated. + +The source tar's SHA256 must be +`5c3a8e78f7b24c20b25b54ee575d6daa40007a5f4eea2845861c3409b3021747`. +The test invokes production `Context` and `Task.run`, replacing missing compiler +cache locations with genuine local compiler executables and preserving target +flags. It loads the FreeType recipe in isolation; it does not claim the full +Ren'Py dependency graph was run. The native Linux case uses the installed native +compiler rather than requiring the unrelated production Linux sysroot. + +FreeType's host `apinames` must execute and generate `ftexport.sym`, including +the existing OpenType validation and TrueType interpreter exports. Before the +actual `make install`, the driver removes the exporter and its output, requiring +the install invocation to compile and run the host exporter again. It checks +all objects in the installed static archive and checks defined FreeType API +symbols. On Mac, `apinames` must identify macOS platform 1; target objects must +identify macOS 1, iOS device 2 or iOS Simulator 7 as appropriate. CPU checks alone +cannot distinguish those platforms. On Linux, the exporter must be a native +ELF executable and the Android archive must contain AArch64 ELF objects. + +Each case preserves its production commands, unchanged target environment, +build log, actual host executable and compiler wrapper, generated export file, +actual installed archive, defined-symbol listing and result JSON with hashes. +The driver executes the host exporter; it does not execute target libraries, +launch an app, or validate gameplay. The HarfBuzz-enabled recipe receives the +same production fix, but this focused driver runs the base FreeType recipe; +the complete source pipeline must still validate its HarfBuzz dependency. + +Patch `0012-freetype-host-tools.patch` supplies a separate native C compiler +wrapper. On Darwin it selects the real macOS compiler and canonical macOS SDK, +queries the native target triple with cross deployment variables removed, and +then explicitly selects that triple and SDK. Only the host compiler child loses +the iOS/tvOS/watchOS/visionOS/DriverKit deployment variables and `SDKROOT`. +The target compiler and flags stay in the production context. Both FreeType +recipes pass `CC_BUILD` for configure and override `CCexe`, `CCexe_CFLAGS` and +`CCexe_LDFLAGS` on both make and install. This also covers configure's branch +that resets `CC_BUILD` to the target `CC` when a target conftest appears runnable. + +The upstream contracts are visible in +[configure.raw](https://github.com/freetype/freetype/blob/VER-2-13-3/builds/unix/configure.raw), +[unix-cc.in](https://github.com/freetype/freetype/blob/VER-2-13-3/builds/unix/unix-cc.in) +and [exports.mk](https://github.com/freetype/freetype/blob/VER-2-13-3/builds/exports.mk). +Upstream uses `CC_BUILD` and the make `CCexe*` variables for this tool; +`CFLAGS_BUILD` and `LDFLAGS_BUILD` are not the relevant hooks. diff --git a/tools/test_renpy_mobile_freetype_host_tools.py b/tools/test_renpy_mobile_freetype_host_tools.py new file mode 100644 index 000000000..e33d2430e --- /dev/null +++ b/tools/test_renpy_mobile_freetype_host_tools.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Run the pinned FreeType task, its real host exporter and target archive. + +Darwin checks native macOS plus real device/Simulator SDK builds. Linux checks +the native library and, with --ndk, a genuine Android AArch64 cross archive. +The genuine native apinames executable is run; target libraries are not run. +""" +import argparse +from contextlib import contextmanager +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import platform +import shlex +import shutil +import struct +import subprocess +import sys +import tempfile +from types import SimpleNamespace + +REPO = Path(__file__).resolve().parents[1] +PIN = "7bfab40c1174f622f644b24669afd5fb167fbb79" +SOURCE_SHA256 = "5c3a8e78f7b24c20b25b54ee575d6daa40007a5f4eea2845861c3409b3021747" +TARGET_VARIABLES = ("CC", "CXX", "CPP", "AR", "RANLIB", "NM", "CFLAGS", "CXXFLAGS", + "CPPFLAGS", "LDFLAGS", "SDKROOT", "IPHONEOS_DEPLOYMENT_TARGET") + + +def command(arguments, *, cwd, env=None): + result = subprocess.run(list(map(str, arguments)), cwd=cwd, env=env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=600) + if result.returncode: + raise RuntimeError(f"Command failed ({result.returncode}): {shlex.join(map(str, arguments))}\n" + + result.stdout[-8000:]) + return result.stdout + + +@contextmanager +def build_log(path): + sys.stdout.flush() + sys.stderr.flush() + previous = [os.dup(1), os.dup(2)] + try: + with path.open("w") as output: + os.dup2(output.fileno(), 1) + os.dup2(output.fileno(), 2) + yield + finally: + sys.stdout.flush() + sys.stderr.flush() + for descriptor, saved in zip((1, 2), previous): + os.dup2(saved, descriptor) + os.close(saved) + + +def object_identity(data): + if data[:4] == b"\x7fELF": + endian = "<" if data[5] == 1 else ">" + return {"format": "ELF", "machine": struct.unpack_from(endian + "H", data, 18)[0]} + assert data[:4] == bytes.fromhex("cffaedfe"), "A genuine ELF or Mach-O64 object is required" + machine, commands = struct.unpack_from("= 8 + if kind == 0x32: # LC_BUILD_VERSION + platforms.append(struct.unpack_from(" 0 + assert command(["git", "rev-parse", "HEAD"], cwd=args.build_source).strip() == PIN + evidence = (args.output_dir or Path(tempfile.mkdtemp(prefix="renpy-freetype-evidence-"))).resolve() + evidence.mkdir(parents=True, exist_ok=True) + print("FreeType evidence:", evidence, flush=True) + patches = sorted((REPO / "bridge/renpy_runtime/mobile_launcher/patches/native").glob( + "[0-9][0-9][0-9][0-9]-*.patch")) + assert any(path.name.startswith("0012-") for path in patches) + + with tempfile.TemporaryDirectory(prefix="renpy-real-freetype-tools-") as temporary: + root = Path(temporary) / "renpy-build" + command(["git", "clone", "--no-local", "--depth=1", "--single-branch", "--quiet", "--no-checkout", + args.build_source.resolve(), root], cwd=REPO) + command(["git", "checkout", "--quiet", "--detach", PIN], cwd=root) + command([sys.executable, REPO / "tools/apply_renpy_mobile_patch_series.py", + "--checkout", root, "--pin", PIN, *patches], cwd=REPO) + assert hashlib.sha256((root / "source/freetype-2.13.3.tar.gz").read_bytes()).hexdigest() == SOURCE_SHA256 + sys.path.insert(0, str(root)) + from renpybuild.context import Context + from renpybuild.task import annotators, tasks + specification = importlib.util.spec_from_file_location("freetype", root / "tasks/freetype.py") + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + selected = {task.task: task for task in tasks if task.name == "freetype"} + + class TrackingContext(Context): + def run(self, template, verbose=False, quiet=False, **kwargs): + arguments = shlex.split(self.expand(template, **kwargs)) + self.calls.append(arguments) + if arguments[0] == "./configure": + self.target_before = {name: self.environ.get(name) for name in TARGET_VARIABLES} + if arguments[:2] == ["make", "install"]: + # Exercise install's real exporter rebuild, not just an + # up-to-date install that never uses its CCexe assignment. + (self.cwd / "objs/apinames").unlink() + (self.cwd / "objs/ftexport.sym").unlink() + return super().run(template, verbose=verbose, quiet=quiet, **kwargs) + + native_machine = {"x86_64": 62, "aarch64": 183, "arm64": 183}[platform.machine()] + if sys.platform == "darwin": + assert platform.machine() == "arm64", "This iOS acceptance uses a genuine arm64 Mac runner" + cases = [("mac-host", "mac", "arm64", "macosx", 1), + ("ios-device", "ios", "arm64", "iphoneos", 2), + ("ios-simulator", "ios", "sim-arm64", "iphonesimulator", 7)] + else: + assert sys.platform == "linux" + cases = [("linux-host", "linux", platform.machine(), None, None)] + if args.ndk: + ndk = args.ndk.resolve(strict=True) + assert 'Pkg.Revision = 29.' in (ndk / "source.properties").read_text() + toolchain = ndk / "toolchains/llvm/prebuilt/linux-x86_64/bin" + assert (toolchain / "clang").is_file() + cases.append(("android-arm64", "android", "arm64_v8a", None, None)) + + for label, target_platform, architecture, sdk_name, expected_platform in cases: + case = evidence / label + case.mkdir(exist_ok=True) + sdk = None + if sdk_name: + sdk = Path(command(["xcrun", "--sdk", sdk_name, "--show-sdk-path"], cwd=REPO).strip()).resolve(strict=True) + cross_sdk = root / f"tmp/cross.{target_platform}-{architecture}/sdk" + cross_sdk.parent.mkdir(parents=True, exist_ok=True) + cross_sdk.symlink_to(sdk) + + def real_compilers(context): + context.var("make", "nice make -j " + str(args.jobs)) + # Preserve the production target flags, replacing only missing + # cache/tool executable locations with genuine local compilers. + if target_platform == "android": + cc = [str(toolchain / "clang"), "--target=aarch64-linux-android21", "-std=gnu17"] + cpp = cc[:-1] + ["-E"] + ar, ranlib, nm = [str(toolchain / name) for name in ("llvm-ar", "llvm-ranlib", "llvm-nm")] + elif sdk: + cc = shlex.split(context.environ["CC"]) + assert cc.pop(0) == "ccache" + cpp = shlex.split(context.environ["CPP"]) + assert cpp.pop(0) == "ccache" + ar, ranlib, nm = [context.environ[name] for name in ("AR", "RANLIB", "NM")] + context.env("SDKROOT", str(sdk)) + else: + cc = [shutil.which("cc")] + cpp = cc + ["-E"] + ar, ranlib, nm = [shutil.which(name) for name in ("ar", "ranlib", "nm")] + context.var("cross_config", "--build=" + command([cc[0], "-dumpmachine"], cwd=REPO).strip() + + " --host=" + command([cc[0], "-dumpmachine"], cwd=REPO).strip()) + assert all(cc) and ar and ranlib and nm + context.env("CC", shlex.join(cc)) + context.env("CPP", shlex.join(cpp)) + for variable, value in (("AR", ar), ("RANLIB", ranlib), ("NM", nm)): + context.env(variable, value) + + annotators.append(real_compilers) + context = TrackingContext(target_platform, architecture, "3", root, SimpleNamespace()) + context.calls = [] + print("Actual FreeType production task:", label, flush=True) + try: + with build_log(case / "production-build.log"): + selected["unpack"].run(context) + selected["build"].run(context) + except BaseException: + print("New FreeType build errors:", (case / "production-build.log").read_text(errors="replace")[-8000:], flush=True) + raise + finally: + annotators.remove(real_compilers) + assert context.target_before == {name: context.environ.get(name) for name in TARGET_VARIABLES} + (case / "production-commands.json").write_text(json.dumps(context.calls, indent=2) + "\n") + (case / "target-toolchain.json").write_text(json.dumps(context.target_before, indent=2) + "\n") + source = context.cwd + apinames = source / "objs/apinames" + shutil.copy2(apinames, case / "apinames") + shutil.copy2(context.build / "aether-freetype-host-cc", case / "aether-freetype-host-cc") + exports = (source / "objs/ftexport.sym").read_text() + assert "FT_Init_FreeType" in exports and "FT_OpenType_Validate" in exports + assert "TT_New_Context" in exports and "TT_RunIns" in exports + shutil.copy2(source / "objs/ftexport.sym", case / "ftexport.sym") + host_identity = object_identity(apinames.read_bytes()) + if sys.platform == "darwin": + assert host_identity == {"format": "Mach-O", "machine": 0x100000C, "platform": 1} + else: + assert host_identity == {"format": "ELF", "machine": native_machine} + archive = context.install / "lib/libfreetype.a" + assert archive.is_file() + shutil.copy2(archive, case / "libfreetype.a") + members = command([context.environ["AR"], "t", archive], cwd=root).splitlines() + objects = [member for member in members if member.endswith(".o")] + assert objects + identities = [] + for member in objects: + data = subprocess.check_output([context.environ["AR"], "p", str(archive), member], cwd=root) + identity = object_identity(data) + expected = {"format": "Mach-O", "machine": 0x100000C, "platform": expected_platform} if sdk \ + else {"format": "ELF", "machine": 183 if target_platform == "android" else native_machine} + assert identity == expected, (member, identity, expected) + identities.append(identity) + nm_flags = ["-g", "-U"] if sys.platform == "darwin" else ["--defined-only", "--extern-only"] + defined = command([context.environ["NM"], *nm_flags, archive], cwd=root) + names = {line.split()[-1].lstrip("_") for line in defined.splitlines() if line.split()} + assert {"FT_Init_FreeType", "FT_OpenType_Validate"} <= names + (case / "defined-symbols.txt").write_text(defined) + result = {"status": "passed", "case": label, "source_sha256": SOURCE_SHA256, + "production_unpack_build_install": "passed", "install_exporter_rebuild": "passed", + "apinames": host_identity, "exported_api_names": len(exports.splitlines()), + "apinames_sha256": hashlib.sha256(apinames.read_bytes()).hexdigest(), + "target_archive_objects": len(objects), "target_archive_identity": identities[0], + "target_archive_sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + "target_execution": "not_run", "gameplay": "unverified"} + (case / "result.json").write_text(json.dumps(result, indent=2) + "\n") + print(json.dumps(result, sort_keys=True), flush=True) + + +if __name__ == "__main__": + main() From 3b5e24e54f8ec538c5ba3745f9304ce747187b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 01:26:58 +0000 Subject: [PATCH 300/331] fix(android): bind the real RenPy host before runtime startup --- .github/workflows/renpy-android-app.yml | 12 +- .../src/aether_runtime_player.cpp | 48 ++++ .../android/RenPyMobileBridge.java | 4 +- .../android/org/libsdl/app/SDLActivity.java | 7 +- .../include/renpy_android_host.h | 105 +++++++ .../include/renpy_java_boolean.h | 21 ++ bridge/renpy_runtime/include/renpy_project.h | 17 ++ .../src/renpy_mobile_adapter.cpp | 11 +- .../src/renpy_runtime_mobile_provider.cpp | 13 +- .../src/renpy_runtime_provider.cpp | 13 +- .../tests/android_host_binding.cpp | 258 ++++++++++++++++++ .../tests/java_boolean_godot.cpp | 120 ++++++++ .../tests/mobile_provider_protocol.cpp | 3 +- tools/test_renpy_android_host.sh | 42 +++ tools/test_renpy_java_boolean_godot.sh | 56 ++++ 15 files changed, 699 insertions(+), 31 deletions(-) create mode 100644 bridge/renpy_runtime/include/renpy_android_host.h create mode 100644 bridge/renpy_runtime/include/renpy_java_boolean.h create mode 100644 bridge/renpy_runtime/include/renpy_project.h create mode 100644 bridge/renpy_runtime/tests/android_host_binding.cpp create mode 100644 bridge/renpy_runtime/tests/java_boolean_godot.cpp create mode 100755 tools/test_renpy_android_host.sh create mode 100755 tools/test_renpy_java_boolean_godot.sh diff --git a/.github/workflows/renpy-android-app.yml b/.github/workflows/renpy-android-app.yml index 922d85386..0f3f6beba 100644 --- a/.github/workflows/renpy-android-app.yml +++ b/.github/workflows/renpy-android-app.yml @@ -50,13 +50,13 @@ jobs: AETHERKIRI_VCPKG_CACHE_WRITE: 'false' AETHERKIRI_ENABLE_RENPY: 'ON' CCACHE_DIR: ${{ github.workspace }}/.ccache - NATIVE_RUNTIME_RUN: '37812943810' - NATIVE_RUNTIME_ARTIFACT: '11567113193' - NATIVE_RUNTIME_HEAD: 'dbb863ac101301c8a9d78dea358e2157a0185165' + NATIVE_RUNTIME_RUN: '37866667365' + NATIVE_RUNTIME_ARTIFACT: '11589199045' + NATIVE_RUNTIME_HEAD: '1967809558f4022aa781062bfe5a4f8e903c84d0' # Keep the disposable Debug key and its data separate from an existing # production installation. The build uses a copied export project. AETHERKIRI_ANDROID_TEST_PACKAGE_ID: org.aetherkiri.renpy.debug - NATIVE_RUNTIME_DIGEST: 'sha256:04a1f247585b8f4647e6dcd8c4102757f558ee542524df1504267b40f0a4239c' + NATIVE_RUNTIME_DIGEST: 'sha256:08537fa4c09a84d8226c50644127ab6918290a0a09f0720c878b465bce25014c' steps: - uses: actions/checkout@v6 with: @@ -96,8 +96,8 @@ jobs: PY - uses: actions/download-artifact@v6 with: - artifact-ids: 11567113193 - run-id: 37812943810 + artifact-ids: 11589199045 + run-id: 37866667365 github-token: ${{ github.token }} merge-multiple: true path: ${{ runner.temp }}/renpy-runtime-download diff --git a/bridge/godot_extension/src/aether_runtime_player.cpp b/bridge/godot_extension/src/aether_runtime_player.cpp index d796d2531..f519ec36e 100644 --- a/bridge/godot_extension/src/aether_runtime_player.cpp +++ b/bridge/godot_extension/src/aether_runtime_player.cpp @@ -135,6 +135,10 @@ void aether_native_launch_file_picker_free_string(char *value); #if defined(__ANDROID__) #include #include +#if defined(AETHERKIRI_WITH_RENPY) +#include "renpy_android_host.h" +#include "renpy_java_boolean.h" +#endif extern JNIEnv* krkr_GetJNIEnv(); extern jobject krkr_GetApplicationContext(); @@ -740,6 +744,32 @@ bool AndroidJavaHasException(JavaClassWrapper *wrapper, const char *stage) { return false; } +#if defined(AETHERKIRI_WITH_RENPY) +bool AndroidLoadRenPyNativeBridge(std::string *error) { + // Godot's public Java wrapper already owns a valid VM/env after app + // setup. Java loading must precede our engine_api JNI accessors: loading + // a GDExtension with dlopen alone never calls engine_api's JNI_OnLoad. + JavaClassWrapper *wrapper = JavaClassWrapper::get_singleton(); + if (wrapper == nullptr) { + *error = "Ren'Py requires Godot's Android JavaClassWrapper after app setup"; + return false; + } + Ref bridge = wrapper->wrap("org.github.krkr2.aetherkiri.RenPyMobileBridge"); + const bool wrap_failed = AndroidJavaHasException(wrapper, "wrap RenPyMobileBridge"); + if (bridge.is_null() || wrap_failed) { + *error = "Ren'Py Android host bridge is missing from the APK or could not load"; + return false; + } + const Variant loaded = bridge->call("isNativeBridgeLoaded"); + if (AndroidJavaHasException(wrapper, "RenPyMobileBridge.isNativeBridgeLoaded") || + !renpy_mobile_runtime::AndroidJavaBooleanResultIsTrue(loaded)) { + *error = "Ren'Py could not load engine_api through the Android Java host bridge"; + return false; + } + return true; +} +#endif + Object *AndroidVariantObject(const Variant &value) { if (value.get_type() != Variant::OBJECT) { return nullptr; @@ -9651,6 +9681,24 @@ class AetherRuntimePlayer final : public Node { CharString path_utf8 = game_root_path.utf8(); const String normalized_runtime = runtime_id_.strip_edges().to_lower(); +#if defined(__ANDROID__) && defined(AETHERKIRI_WITH_RENPY) + if (normalized_runtime == "renpy" || + (normalized_runtime == "auto" && + engine_probe_runtime_provider("renpy", path_utf8.get_data()) > 0)) { + // Bind the real Activity before async Open/first Tick. This only + // retains the existing host; SDL/Python still initialize on Tick. + std::string binding_error; + if (!aetherkiri::renpy::mobile::PrepareRenPyAndroidHost( + AndroidLoadRenPyNativeBridge, krkr_GetJNIEnv, + AndroidGetGodotActivityLocal, AndroidFindClassWithAppClassLoader, + &binding_error)) { + AndroidBridgeLog("event=renpy_host_bind_failed error=%s", binding_error.c_str()); + last_result_ = ResultToString(ENGINE_RESULT_INVALID_STATE); + last_error_ = String::utf8(binding_error.c_str()); + return ENGINE_RESULT_INVALID_STATE; + } + } +#endif artemis_logical_frame_pacing_ = normalized_runtime == "artemis" || (normalized_runtime == "auto" && engine_probe_runtime_provider("artemis", path_utf8.get_data()) > 0); diff --git a/bridge/renpy_runtime/android/RenPyMobileBridge.java b/bridge/renpy_runtime/android/RenPyMobileBridge.java index b7a202b4e..9ebbeb945 100644 --- a/bridge/renpy_runtime/android/RenPyMobileBridge.java +++ b/bridge/renpy_runtime/android/RenPyMobileBridge.java @@ -46,9 +46,9 @@ public static void clearHostActivity() { SDLActivity.clearHostActivity(); } - /** Returns true when the native bridge has loaded in the host process. */ + /** Load/retry through Java so JNI_OnLoad records the real host JavaVM. */ public static boolean isNativeBridgeLoaded() { - return nativeBridgeLoaded; + return nativeBridgeLoaded || tryLoadNativeBridge(); } /** diff --git a/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java b/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java index 045db4898..c9075a68b 100644 --- a/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java +++ b/bridge/renpy_runtime/android/org/libsdl/app/SDLActivity.java @@ -28,9 +28,12 @@ * SDL 2.0.20 JNI callbacks delegated to Godot's existing Activity. * The patched native payload uses SDL's offscreen EGL pbuffer driver. * These callbacks never acquire Godot's SurfaceView or start an Activity. - * CPU frames come from Ren'Py's GL screenshot. + * CPU frames come from Ren'Py's GL screenshot. Activity inheritance exists + * only for SDL's JNI lookup of inherited ContextWrapper methods (battery + * queries). This class is never instantiated or registered in a manifest; + * every Context receiver is the actual Godot Activity. */ -public final class SDLActivity { +public final class SDLActivity extends Activity { public static final String AETHERKIRI_HOST_SHIM = "renpy-sdl-host-shim-v1"; private static WeakReference hostActivity = new WeakReference<>(null); private static final Handler uiHandler = new Handler(Looper.getMainLooper()); diff --git a/bridge/renpy_runtime/include/renpy_android_host.h b/bridge/renpy_runtime/include/renpy_android_host.h new file mode 100644 index 000000000..e66d35531 --- /dev/null +++ b/bridge/renpy_runtime/include/renpy_android_host.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include + +namespace aetherkiri::renpy::mobile { + +// Private Android handoff. Neither JNI references nor callback tables travel +// through the public provider's reserved media/shader extension slots. +class AndroidLocalReference final { + public: + AndroidLocalReference(JNIEnv* env, jobject value) : env_(env), value_(value) {} + ~AndroidLocalReference() { if (value_) env_->DeleteLocalRef(value_); } + AndroidLocalReference(const AndroidLocalReference&) = delete; + AndroidLocalReference& operator=(const AndroidLocalReference&) = delete; + jobject get() const { return value_; } + private: + JNIEnv* env_; + jobject value_; +}; + +inline bool AndroidHostException(JNIEnv* env, std::string* error) { + if (!env->ExceptionCheck()) return false; + AndroidLocalReference exception(env, env->ExceptionOccurred()); + env->ExceptionClear(); + if (exception.get()) { + AndroidLocalReference klass(env, env->GetObjectClass(exception.get())); + if (!env->ExceptionCheck() && klass.get()) { + const auto describe = env->GetMethodID(static_cast(klass.get()), + "toString", "()Ljava/lang/String;"); + if (!env->ExceptionCheck() && describe) { + AndroidLocalReference detail(env, env->CallObjectMethod(exception.get(), describe)); + if (!env->ExceptionCheck() && detail.get()) { + const char* utf8 = env->GetStringUTFChars(static_cast(detail.get()), nullptr); + if (utf8) { + *error += ": "; + *error += utf8; + env->ReleaseStringUTFChars(static_cast(detail.get()), utf8); + } + } + } + } + } + if (env->ExceptionCheck()) env->ExceptionClear(); + return true; +} + +inline bool RequireAndroidHostActivity(JNIEnv* env, jobject activity, + std::string* error) { + if (!activity) { + *error = "Ren'Py requires the existing Godot Activity"; + return false; + } + AndroidLocalReference klass(env, env->FindClass("android/app/Activity")); + *error = "Ren'Py could not validate the existing Godot Activity"; + if (AndroidHostException(env, error) || !klass.get()) return false; + const bool valid = env->IsInstanceOf(activity, static_cast(klass.get())); + if (AndroidHostException(env, error)) return false; + if (!valid) { + *error = "Ren'Py host must be an android.app.Activity"; + return false; + } + return true; +} + +using AndroidActivityResolver = jobject (*)(JNIEnv*); // Returns an owned local reference. +using AndroidClassResolver = jclass (*)(JNIEnv*, const char*); // App class loader; owned local ref. + +inline bool BindRenPyAndroidHost(JNIEnv* env, AndroidActivityResolver resolve_activity, + AndroidClassResolver resolve_class, std::string* error) { + if (!env) { + *error = "Ren'Py could not attach to the Android VM"; + return false; + } + AndroidLocalReference activity(env, resolve_activity(env)); + *error = "Ren'Py could not obtain the existing Godot Activity"; + if (AndroidHostException(env, error) || + !RequireAndroidHostActivity(env, activity.get(), error)) return false; + AndroidLocalReference bridge(env, resolve_class(env, + "org/github/krkr2/aetherkiri/RenPyMobileBridge")); + *error = "Ren'Py Android host bridge is missing from the APK"; + if (AndroidHostException(env, error) || !bridge.get()) return false; + const auto bind = env->GetStaticMethodID(static_cast(bridge.get()), + "bindHostActivity", "(Landroid/app/Activity;)V"); + *error = "Ren'Py Android host bridge lacks bindHostActivity(Activity)"; + if (AndroidHostException(env, error) || !bind) return false; + env->CallStaticVoidMethod(static_cast(bridge.get()), bind, activity.get()); + *error = "Ren'Py could not bind the existing Godot Activity"; + if (AndroidHostException(env, error)) return false; + error->clear(); + return true; +} + +inline bool PrepareRenPyAndroidHost(bool (*load_bridge)(std::string*), + JNIEnv* (*resolve_env)(), + AndroidActivityResolver resolve_activity, + AndroidClassResolver resolve_class, + std::string* error) { + // load_bridge uses Godot's own Java environment. Do not ask engine_api for + // its cached JavaVM until the real Java load has called JNI_OnLoad. + if (!load_bridge(error)) return false; + return BindRenPyAndroidHost(resolve_env(), resolve_activity, resolve_class, error); +} + +} // namespace aetherkiri::renpy::mobile diff --git a/bridge/renpy_runtime/include/renpy_java_boolean.h b/bridge/renpy_runtime/include/renpy_java_boolean.h new file mode 100644 index 000000000..abd7dec82 --- /dev/null +++ b/bridge/renpy_runtime/include/renpy_java_boolean.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace renpy_mobile_runtime { + +// Godot's Android Java wrapper assigns JNI jboolean (uint8_t) directly to +// Variant, which yields INT. Accept its exact true value without allowing +// generic Variant truthiness to report a failed bridge load as successful. +template +bool AndroidJavaBooleanResultIsTrue(const VariantValue &result) { + if (result.get_type() == VariantValue::BOOL) { + return static_cast(result); + } + if (result.get_type() == VariantValue::INT) { + return static_cast(result) == 1; + } + return false; +} + +} // namespace renpy_mobile_runtime diff --git a/bridge/renpy_runtime/include/renpy_project.h b/bridge/renpy_runtime/include/renpy_project.h new file mode 100644 index 000000000..425e41c48 --- /dev/null +++ b/bridge/renpy_runtime/include/renpy_project.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +namespace aetherkiri::renpy { +inline bool HasRenpyProject(const std::filesystem::path& root) { + namespace fs = std::filesystem; + std::error_code ec; + if (!fs::is_directory(root, ec)) return false; + // Match the desktop distributor's supported directory layouts. + return (fs::is_directory(root / "game", ec) && + (fs::exists(root / "game" / "script.rpy", ec) || + fs::exists(root / "game" / "script.rpyc", ec) || + fs::exists(root / "game" / "options.rpy", ec))) || + fs::exists(root / "script.rpy", ec); +} +} // namespace aetherkiri::renpy diff --git a/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp index 7d3514be2..118a23daf 100644 --- a/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp +++ b/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp @@ -1,6 +1,7 @@ #include "renpy_mobile_adapter.h" #if defined(__ANDROID__) +#include "renpy_android_host.h" #include #include #include @@ -297,9 +298,13 @@ engine_result_t BootstrapAdapter::PrepareRuntime( #else JNIEnv* env = krkr_GetJNIEnv(); if (!env) { last_error_ = "Ren'Py could not attach to the Android VM"; return ENGINE_RESULT_INVALID_STATE; } - jobject activity = request.existing_host_activity - ? static_cast(request.existing_host_activity) : krkr_GetHostActivity(); - if (!activity) { last_error_ = "Ren'Py requires the existing Godot Activity"; return ENGINE_RESULT_INVALID_STATE; } + AndroidLocalReference activity_ref(env, env->NewLocalRef(request.existing_host_activity + ? static_cast(request.existing_host_activity) : krkr_GetHostActivity())); + last_error_ = "Ren'Py could not retain the existing Godot Activity"; + if (AndroidHostException(env, &last_error_) || + !RequireAndroidHostActivity(env, activity_ref.get(), &last_error_)) + return ENGINE_RESULT_INVALID_STATE; + jobject activity = activity_ref.get(); jclass bridge = FindRequiredClass(env, "org/github/krkr2/aetherkiri/RenPyMobileBridge", &last_error_); if (!bridge) return ENGINE_RESULT_NOT_SUPPORTED; jmethodID bind = env->GetStaticMethodID(bridge, "bindHostActivity", "(Landroid/app/Activity;)V"); diff --git a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp index 172fdba31..2c966076e 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp @@ -1,4 +1,5 @@ #include "renpy_runtime.h" +#include "renpy_project.h" #include "engine_runtime_provider.h" #include "renpy_mobile_loader.h" #include "renpy_input_mapping.h" @@ -144,11 +145,15 @@ engine_result_t CaptureFrame(MobileRuntime* runtime) { return ENGINE_RESULT_OK; } -int32_t Probe(void*, const char*) { +int32_t Probe(void*, const char* path) { // The provider is discoverable only when a rebuilt lifecycle payload is // linked. Official blocking RAPT/Renios archives remain invisible here. - static Launcher launcher; - return launcher.available() ? 100 : 0; + if (!path || !path[0]) return 0; + try { + if (!HasRenpyProject(std::filesystem::u8path(path))) return 0; + static Launcher launcher; + return launcher.available() ? 100 : 0; + } catch (...) { return 0; } } engine_result_t Create(void*, const engine_runtime_host_v1_t* host, @@ -190,7 +195,6 @@ engine_result_t Open(void* value, const char* game_root_path, mobile::BootstrapRequest request{}; request.game_root_path_utf8 = game_root_path; request.startup_script_utf8 = startup_script; - request.existing_host_activity = runtime->host.reserved_ptr[1]; const auto result = runtime->bootstrap.Start(request); runtime->error = runtime->bootstrap.last_error(); return result; @@ -240,7 +244,6 @@ engine_result_t Tick(void* value, uint32_t delta_ms) { #if defined(__ANDROID__) mobile::BootstrapRequest request{}; request.game_root_path_utf8 = runtime->game_root.c_str(); - request.existing_host_activity = runtime->host.reserved_ptr[1]; const auto prepared = runtime->bootstrap.PrepareRuntime(request, runtime->surface_width ? runtime->surface_width : 1280u, runtime->surface_height ? runtime->surface_height : 720u, diff --git a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp index d9adf1e61..f7ba13a4c 100644 --- a/bridge/renpy_runtime/src/renpy_runtime_provider.cpp +++ b/bridge/renpy_runtime/src/renpy_runtime_provider.cpp @@ -1,5 +1,6 @@ #include "renpy_input_mapping.h" #include "renpy_runtime.h" +#include "renpy_project.h" #include "engine_runtime_provider.h" #include @@ -240,18 +241,6 @@ std::string JsonEscape(const std::string& value) { return stream.str(); } -bool HasRenpyProject(const fs::path& root) { - std::error_code ec; - if (!fs::is_directory(root, ec)) return false; - // A game directory must contain the script directory. A bare script.rpy - // is accepted for tiny SDK projects, while a random directory is not. - return (fs::is_directory(root / "game", ec) && - (fs::exists(root / "game" / "script.rpy", ec) || - fs::exists(root / "game" / "script.rpyc", ec) || - fs::exists(root / "game" / "options.rpy", ec))) || - fs::exists(root / "script.rpy", ec); -} - fs::path FindLauncher(const fs::path& sdk) { #if defined(_WIN32) const char* names[] = {"renpy.exe", "renpy"}; diff --git a/bridge/renpy_runtime/tests/android_host_binding.cpp b/bridge/renpy_runtime/tests/android_host_binding.cpp new file mode 100644 index 000000000..786991e6e --- /dev/null +++ b/bridge/renpy_runtime/tests/android_host_binding.cpp @@ -0,0 +1,258 @@ +// Real JNI headers with a controlled function table; this is a protocol test, +// not an Android VM, SDL initialization, or device gameplay test. +#include "renpy_android_host.h" +#include "renpy_mobile_adapter.h" +#include "renpy_mobile_loader.h" +#include "renpy_runtime.h" +#include "engine_runtime_provider.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +int activity_token, media_token, activity_class_token, bridge_token, throwable_token; +int throwable_class_token, root_token, apk_token, detail_token, generic_method_token; +int bind_token, extract_token, prepare_token, apk_method_token, stop_token, describe_token; +jobject activity = reinterpret_cast(&activity_token); +jobject bound_activity; +bool no_activity, no_bridge, no_bind, bind_throws, extraction_throws, prepare_throws; +bool pending_exception, payload_available = true; +bool java_load_fails, java_vm_ready; +int local_refs, python_initializations, bind_count; +std::vector calls; +const engine_runtime_provider_v1_t* provider; + +jobject Local(jobject value) { if (value) ++local_refs; return value; } +template T Object(int* token) { return reinterpret_cast(token); } +jclass FindClass(JNIEnv*, const char* name) { + assert(std::strcmp(name, "android/app/Activity") == 0 || + std::strcmp(name, "org/github/krkr2/aetherkiri/RenPyMobileBridge") == 0); + return static_cast(Local(std::strcmp(name, "android/app/Activity") == 0 + ? Object(&activity_class_token) : Object(&bridge_token))); +} +jobject NewLocalRef(JNIEnv*, jobject value) { return Local(value); } +void DeleteLocalRef(JNIEnv*, jobject value) { assert(value && local_refs > 0); --local_refs; } +jboolean IsInstanceOf(JNIEnv*, jobject value, jclass klass) { + assert(klass == Object(&activity_class_token)); + return value == activity; +} +jboolean ExceptionCheck(JNIEnv*) { return pending_exception; } +jthrowable ExceptionOccurred(JNIEnv*) { + return pending_exception ? static_cast(Local(Object(&throwable_token))) : nullptr; +} +void ExceptionClear(JNIEnv*) { pending_exception = false; } +jclass GetObjectClass(JNIEnv*, jobject value) { + assert(value == Object(&throwable_token)); + return static_cast(Local(Object(&throwable_class_token))); +} +jmethodID GetMethodID(JNIEnv*, jclass, const char* name, const char* signature) { + if (std::strcmp(name, "toString") == 0) { + assert(std::strcmp(signature, "()Ljava/lang/String;") == 0); + return Object(&describe_token); + } + return Object(&generic_method_token); +} +jmethodID GetStaticMethodID(JNIEnv*, jclass, const char* name, const char* signature) { + if (std::strcmp(name, "bindHostActivity") == 0) { + assert(std::strcmp(signature, "(Landroid/app/Activity;)V") == 0); + if (no_bind) { pending_exception = true; return nullptr; } + return Object(&bind_token); + } + if (std::strcmp(name, "preparePrivateRoot") == 0) return Object(&extract_token); + if (std::strcmp(name, "prepareRuntime") == 0) return Object(&prepare_token); + if (std::strcmp(name, "getApkPath") == 0) return Object(&apk_method_token); + if (std::strcmp(name, "stopRuntime") == 0) return Object(&stop_token); + return Object(&generic_method_token); +} +void CallStaticVoidMethodV(JNIEnv*, jclass klass, jmethodID method, va_list args) { + assert(klass == Object(&bridge_token)); + if (method == Object(&bind_token)) { + assert(va_arg(args, jobject) == activity); // Never the poisoned media slot. + calls.push_back("bind"); + if (bind_throws) { pending_exception = true; return; } + bound_activity = activity; // Simulates Bridge.nativeSetHostActivity's global ref. + ++bind_count; + } else if (method == Object(&prepare_token)) { + assert(va_arg(args, jint) == 1280 && va_arg(args, jint) == 720); + calls.push_back("prepare"); + if (prepare_throws) pending_exception = true; + } else { + assert(method == Object(&stop_token)); + calls.push_back("stop"); + } +} +jobject CallStaticObjectMethodV(JNIEnv*, jclass, jmethodID method, va_list) { + if (method == Object(&extract_token)) { + calls.push_back("extract"); + if (extraction_throws) { pending_exception = true; return nullptr; } + return Local(Object(&root_token)); + } + assert(method == Object(&apk_method_token)); + calls.push_back("apk"); + return Local(Object(&apk_token)); +} +jobject CallObjectMethodV(JNIEnv*, jobject, jmethodID method, va_list) { + assert(method == Object(&describe_token)); + return Local(Object(&detail_token)); +} +const char* GetStringUTFChars(JNIEnv*, jstring value, jboolean*) { + if (value == Object(&root_token)) return "/private"; + if (value == Object(&apk_token)) return "/app.apk"; + assert(value == Object(&detail_token)); + return "java.lang.IllegalStateException: actual fixture Java failure"; +} +void ReleaseStringUTFChars(JNIEnv*, jstring, const char*) {} +using FunctionTable = std::remove_const_t>; +FunctionTable table{}; +JNIEnv env{&table}; +jobject ResolveActivity(JNIEnv*) { calls.push_back("get_activity"); return no_activity ? nullptr : Local(activity); } +jclass ResolveClass(JNIEnv*, const char* name) { + assert(std::strcmp(name, "org/github/krkr2/aetherkiri/RenPyMobileBridge") == 0); + calls.push_back("app_class_loader"); + return no_bridge ? nullptr : static_cast(Local(Object(&bridge_token))); +} +bool LoadBridge(std::string* error) { + calls.push_back("java_load"); + if (java_load_fails) { *error = "Java load failed"; return false; } + java_vm_ready = true; // Actual JNI_OnLoad owns this step in the app. + return true; +} +JNIEnv* ResolveEnv() { + assert(java_vm_ready); + calls.push_back("jni_env"); + return &env; +} +void CheckClean() { assert(local_refs == 0 && !pending_exception); } +} // namespace + +JNIEnv* krkr_GetJNIEnv() { return &env; } +JavaVM* krkr_GetJavaVM() { return Object(&activity_token); } +jobject krkr_GetApplicationContext() { return activity; } +jobject krkr_GetHostActivity() { return bound_activity; } +struct ANativeWindow; +ANativeWindow* krkr_GetNativeWindow() { return nullptr; } +extern "C" int32_t ANativeWindow_getWidth(ANativeWindow*) { return 0; } +extern "C" int32_t ANativeWindow_getHeight(ANativeWindow*) { return 0; } +extern "C" void ANativeWindow_release(ANativeWindow*) {} +extern "C" engine_result_t engine_register_runtime_provider(const engine_runtime_provider_v1_t* value) { + provider = value; + return ENGINE_RESULT_OK; +} + +// Controlled launcher only. Real PrepareRuntime and provider are linked below. +namespace aetherkiri::renpy::mobile { +Launcher::Launcher() = default; +Launcher::~Launcher() = default; +bool Launcher::available() { return payload_available; } +int Launcher::Init(const renpy_mobile_config_t& config, const renpy_mobile_host_t&) { + assert(bound_activity == activity); + assert(std::strcmp(config.private_root_utf8, "/private") == 0); + assert(std::strcmp(config.apk_path_utf8, "/app.apk") == 0); + assert(std::strcmp(config.argv0_utf8, "/private/main.py") == 0); + calls.push_back("python_init"); + ++python_initializations; + initialized_ = true; + return RENPY_MOBILE_OK; +} +int Launcher::Tick(uint32_t) { calls.push_back("tick"); return RENPY_MOBILE_OK; } +int Launcher::Frame(renpy_mobile_frame_t*) { return RENPY_MOBILE_INVALID_STATE; } +int Launcher::Input(const renpy_mobile_input_t&) { return RENPY_MOBILE_OK; } +int Launcher::Pause() { return RENPY_MOBILE_OK; } +int Launcher::Resume() { return RENPY_MOBILE_OK; } +int Launcher::TextInputState(uint32_t* value) { *value = 0; return RENPY_MOBILE_OK; } +int Launcher::SetSurfaceSize(uint32_t, uint32_t) { return RENPY_MOBILE_OK; } +void Launcher::Shutdown() { initialized_ = false; } +} // namespace aetherkiri::renpy::mobile + +int main() { + table.FindClass = FindClass; + table.NewLocalRef = NewLocalRef; + table.DeleteLocalRef = DeleteLocalRef; + table.IsInstanceOf = IsInstanceOf; + table.ExceptionCheck = ExceptionCheck; + table.ExceptionOccurred = ExceptionOccurred; + table.ExceptionClear = ExceptionClear; + table.GetObjectClass = GetObjectClass; + table.GetMethodID = GetMethodID; + table.GetStaticMethodID = GetStaticMethodID; + table.CallStaticVoidMethodV = CallStaticVoidMethodV; + table.CallStaticObjectMethodV = CallStaticObjectMethodV; + table.CallObjectMethodV = CallObjectMethodV; + table.GetStringUTFChars = GetStringUTFChars; + table.ReleaseStringUTFChars = ReleaseStringUTFChars; + using namespace aetherkiri::renpy::mobile; + std::string error; + auto bind = [&] { return BindRenPyAndroidHost(&env, ResolveActivity, ResolveClass, &error); }; + assert(!BindRenPyAndroidHost(nullptr, ResolveActivity, ResolveClass, &error)); + assert(error.find("Android VM") != std::string::npos); + no_activity = true; assert(!bind()); no_activity = false; CheckClean(); + assert(!RequireAndroidHostActivity(&env, Object(&media_token), &error)); CheckClean(); + no_bridge = true; assert(!bind()); no_bridge = false; CheckClean(); + no_bind = true; assert(!bind()); no_bind = false; CheckClean(); + assert(error.find("actual fixture Java failure") != std::string::npos); + bind_throws = true; assert(!bind()); bind_throws = false; CheckClean(); + assert(bound_activity == nullptr && python_initializations == 0); + calls.clear(); + java_load_fails = true; + assert(!PrepareRenPyAndroidHost(LoadBridge, ResolveEnv, ResolveActivity, ResolveClass, &error)); + assert(!java_vm_ready && calls == std::vector{"java_load"}); CheckClean(); + java_load_fails = false; calls.clear(); + assert(PrepareRenPyAndroidHost(LoadBridge, ResolveEnv, ResolveActivity, ResolveClass, &error)); + assert(error.empty()); CheckClean(); + assert((calls == std::vector{"java_load", "jni_env", "get_activity", "app_class_loader", "bind"})); + assert(bound_activity == activity && bind_count == 1); // Valid after locals are released. + + aetherkiri::renpy::RegisterRuntimeProvider(); + const auto root = std::filesystem::temp_directory_path() / ("aether-renpy-host-" + + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(root / "game"); + assert(provider->probe(nullptr, nullptr) == 0 && provider->probe(nullptr, "") == 0); + assert(provider->probe(nullptr, root.string().c_str()) == 0); + std::ofstream(root / "game" / "unrelated.txt") << "unrelated"; + assert(provider->probe(nullptr, root.string().c_str()) == 0); + for (const char* name : {"script.rpy", "script.rpyc", "options.rpy"}) { + std::ofstream(root / "game" / name) << "marker"; + assert(provider->probe(nullptr, root.string().c_str()) == 100); + payload_available = false; + assert(provider->probe(nullptr, root.string().c_str()) == 0); + payload_available = true; + std::filesystem::remove(root / "game" / name); + } + std::ofstream(root / "script.rpy") << "label start: pass"; + assert(provider->probe(nullptr, root.string().c_str()) == 100); + assert(provider->probe(nullptr, (root / "script.rpy").string().c_str()) == 0); + engine_runtime_host_v1_t host{}; + host.struct_size = sizeof(host); + host.reserved_ptr[1] = &media_token; // Actual dispatcher slot belongs to media, never JNI. + engine_create_desc_t desc{}; desc.struct_size = sizeof(desc); + void* runtime = nullptr; + assert(provider->create(nullptr, &host, &desc, &runtime) == ENGINE_RESULT_OK); + calls.clear(); + assert(provider->open_game(runtime, root.string().c_str(), nullptr) == ENGINE_RESULT_OK); + assert(python_initializations == 0 && calls.empty()); // Async Open preserves owner-thread initialization. + bound_activity = nullptr; + assert(provider->tick(runtime, 16) == ENGINE_RESULT_INVALID_STATE); + assert(std::strstr(provider->get_last_error(runtime), "Godot Activity")); + assert(python_initializations == 0 && calls.empty()); CheckClean(); + assert(bind()); calls.clear(); + extraction_throws = true; + assert(provider->tick(runtime, 16) == ENGINE_RESULT_INTERNAL_ERROR); + assert(std::strstr(provider->get_last_error(runtime), "actual fixture Java failure")); + assert(python_initializations == 0 && calls.back() == "extract"); CheckClean(); + extraction_throws = false; prepare_throws = true; calls.clear(); + assert(provider->tick(runtime, 16) == ENGINE_RESULT_INTERNAL_ERROR); + assert(python_initializations == 0 && calls.back() == "prepare"); CheckClean(); + prepare_throws = false; calls.clear(); + assert(provider->tick(runtime, 16) == ENGINE_RESULT_OK && python_initializations == 1); + assert((calls == std::vector{"bind", "extract", "apk", "prepare", "python_init", "tick"})); + assert(host.reserved_ptr[1] == &media_token); CheckClean(); + provider->destroy(runtime); CheckClean(); + std::filesystem::remove_all(root); +} diff --git a/bridge/renpy_runtime/tests/java_boolean_godot.cpp b/bridge/renpy_runtime/tests/java_boolean_godot.cpp new file mode 100644 index 000000000..ba3a45eb6 --- /dev/null +++ b/bridge/renpy_runtime/tests/java_boolean_godot.cpp @@ -0,0 +1,120 @@ +// Loaded by a real Godot executable through its generated GDExtension ABI. +// The engine creates/reads/destroys all Variants; only the production gate is +// templated. This does not execute Android's JavaClassWrapper or JNI. +#include "gdextension_interface.h" +#include "renpy_java_boolean.h" + +#include +#include +#include +#include +#include + +namespace { +GDExtensionInterfaceVariantGetType variant_type; +GDExtensionInterfaceVariantNewNil new_nil; +GDExtensionInterfaceVariantDestroy destroy_variant; +GDExtensionInterfaceGetVariantFromTypeConstructor from_type; +GDExtensionInterfaceGetVariantToTypeConstructor to_type; +GDExtensionInterfaceStringNewWithUtf8Chars new_string; +GDExtensionInterfaceVariantGetPtrDestructor type_destructor; +GDExtensionInterfaceGetGodotVersion godot_version; + +class RealVariant { + alignas(std::max_align_t) unsigned char storage_[AETHER_TEST_VARIANT_SIZE]; + +public: + static constexpr auto BOOL = GDEXTENSION_VARIANT_TYPE_BOOL; + static constexpr auto INT = GDEXTENSION_VARIANT_TYPE_INT; + + RealVariant(GDExtensionVariantType type, void *value) { + if (type == GDEXTENSION_VARIANT_TYPE_NIL) { + new_nil(storage_); + } else { + from_type(type)(storage_, value); + } + } + RealVariant(const RealVariant &) = delete; + RealVariant &operator=(const RealVariant &) = delete; + ~RealVariant() { destroy_variant(storage_); } + + GDExtensionVariantType get_type() const { return variant_type(storage_); } + explicit operator bool() const { + if (get_type() != BOOL) std::abort(); + GDExtensionBool value; + to_type(BOOL)(&value, const_cast(storage_)); + return value != 0; + } + explicit operator int64_t() const { + if (get_type() != INT) std::abort(); + int64_t value; + to_type(INT)(&value, const_cast(storage_)); + return value; + } +}; + +int checks = 0; +void check(GDExtensionVariantType type, void *value, bool expected) { + RealVariant actual(type, value); + if (actual.get_type() != type || + renpy_mobile_runtime::AndroidJavaBooleanResultIsTrue(actual) != expected) { + std::fprintf(stderr, "Java boolean gate failed for real Godot Variant type %d\n", type); + std::abort(); + } + ++checks; +} + +void initialize(void *, GDExtensionInitializationLevel level) { + if (level != GDEXTENSION_INITIALIZATION_SCENE) return; + GDExtensionBool true_value = 1, false_value = 0; + check(GDEXTENSION_VARIANT_TYPE_BOOL, &true_value, true); + check(GDEXTENSION_VARIANT_TYPE_BOOL, &false_value, false); + for (int64_t value : {int64_t(1), int64_t(0), int64_t(-1), int64_t(2), INT64_MAX}) { + check(GDEXTENSION_VARIANT_TYPE_INT, &value, value == 1); + } + for (double value : {0.0, 1.0}) { + check(GDEXTENSION_VARIANT_TYPE_FLOAT, &value, false); + } + check(GDEXTENSION_VARIANT_TYPE_NIL, nullptr, false); + for (const char *text : {"true", "1"}) { + alignas(std::max_align_t) unsigned char string_storage[AETHER_TEST_STRING_SIZE]; + new_string(string_storage, text); + check(GDEXTENSION_VARIANT_TYPE_STRING, string_storage, false); + type_destructor(GDEXTENSION_VARIANT_TYPE_STRING)(string_storage); + } + + GDExtensionGodotVersion version; + godot_version(&version); + const char *result_path = std::getenv("AETHER_VARIANT_RESULT"); + FILE *result = result_path ? std::fopen(result_path, "w") : nullptr; + if (!result) std::abort(); + std::fprintf(result, + "{\"passed\":true,\"checks\":%d,\"godot\":\"%u.%u.%u\"," + "\"android_java_executed\":false}\n", + checks, version.major, version.minor, version.patch); + if (std::fclose(result) != 0) std::abort(); + std::printf("Actual Godot %u.%u.%u Variant boolean gate: %d checks passed\n", + version.major, version.minor, version.patch, checks); +} +void deinitialize(void *, GDExtensionInitializationLevel) {} +} // namespace + +extern "C" GDExtensionBool aether_java_boolean_test_init( + GDExtensionInterfaceGetProcAddress get_proc, + GDExtensionClassLibraryPtr, GDExtensionInitialization *initialization) { +#define LOAD(name, type, proc) name = reinterpret_cast(get_proc(proc)); if (!name) return false + LOAD(variant_type, GDExtensionInterfaceVariantGetType, "variant_get_type"); + LOAD(new_nil, GDExtensionInterfaceVariantNewNil, "variant_new_nil"); + LOAD(destroy_variant, GDExtensionInterfaceVariantDestroy, "variant_destroy"); + LOAD(from_type, GDExtensionInterfaceGetVariantFromTypeConstructor, "get_variant_from_type_constructor"); + LOAD(to_type, GDExtensionInterfaceGetVariantToTypeConstructor, "get_variant_to_type_constructor"); + LOAD(new_string, GDExtensionInterfaceStringNewWithUtf8Chars, "string_new_with_utf8_chars"); + LOAD(type_destructor, GDExtensionInterfaceVariantGetPtrDestructor, "variant_get_ptr_destructor"); + LOAD(godot_version, GDExtensionInterfaceGetGodotVersion, "get_godot_version"); +#undef LOAD + initialization->minimum_initialization_level = GDEXTENSION_INITIALIZATION_SCENE; + initialization->userdata = nullptr; + initialization->initialize = initialize; + initialization->deinitialize = deinitialize; + return true; +} diff --git a/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp b/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp index 04aa70710..7f4f57b52 100644 --- a/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp +++ b/bridge/renpy_runtime/tests/mobile_provider_protocol.cpp @@ -82,7 +82,8 @@ extern "C" void renpy_mobile_shutdown() { int main() { aetherkiri::renpy::RegisterRuntimeProvider(); - assert(provider && provider->probe(nullptr, "/project") > 0); + assert(provider && provider->probe(nullptr, nullptr) == 0); + assert(provider->probe(nullptr, "/not-a-renpy-project") == 0); engine_runtime_host_v1_t host{}; host.struct_size = sizeof(host); host.log = HostLog; diff --git a/tools/test_renpy_android_host.sh b/tools/test_renpy_android_host.sh new file mode 100755 index 000000000..d4e771aba --- /dev/null +++ b/tools/test_renpy_android_host.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Controlled JNI/provider test. The JNI declarations are genuine JDK/NDK +# headers; callback behavior and the launcher are fixtures, not device startup. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +work="$(mktemp -d "${TMPDIR:-/tmp}/aether-renpy-host.XXXXXX")" +trap 'rm -rf "$work"' EXIT +if [[ -n "${JNI_INCLUDE_DIR:-}" ]]; then + jni_header="$JNI_INCLUDE_DIR/jni.h" +elif [[ -n "${ANDROID_NDK_HOME:-}" ]]; then + jni_header="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/include/jni.h" +else + echo 'Set JNI_INCLUDE_DIR (genuine JNI header directory) or ANDROID_NDK_HOME' >&2 + exit 1 +fi +[[ -f "$jni_header" ]] || { echo "JNI header missing: $jni_header" >&2; exit 1; } +mkdir -p "$work/include/android" +cp "$jni_header" "$work/include/jni.h" +if [[ -f "$(dirname "$jni_header")/jni_md.h" ]]; then + cp "$(dirname "$jni_header")/jni_md.h" "$work/include/" +elif [[ -f "$(dirname "$jni_header")/linux/jni_md.h" ]]; then + cp "$(dirname "$jni_header")/linux/jni_md.h" "$work/include/" +fi +# Only the host-executable fixture needs these three unused native-window +# declarations. True Android compilation must separately use the NDK sysroot. +cat > "$work/include/android/native_window.h" <<'HEADER' +#pragma once +#include +struct ANativeWindow; +extern "C" int32_t ANativeWindow_getWidth(ANativeWindow*); +extern "C" int32_t ANativeWindow_getHeight(ANativeWindow*); +extern "C" void ANativeWindow_release(ANativeWindow*); +HEADER +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -D__ANDROID__ \ + -I"$work/include" -I"$repo_root/bridge/renpy_runtime/include" \ + -I"$repo_root/bridge/renpy_runtime/mobile_launcher/include" -I"$repo_root/abi/include" \ + "$repo_root/bridge/renpy_runtime/tests/android_host_binding.cpp" \ + "$repo_root/bridge/renpy_runtime/src/renpy_mobile_adapter.cpp" \ + "$repo_root/bridge/renpy_runtime/src/renpy_runtime_mobile_provider.cpp" \ + -ldl -pthread -o "$work/host-test" +"$work/host-test" +printf '%s\n' 'RenPy Android JNI/provider binding and project detection protocol passed (not device startup)' diff --git a/tools/test_renpy_java_boolean_godot.sh b/tools/test_renpy_java_boolean_godot.sh new file mode 100755 index 000000000..f5edd7b86 --- /dev/null +++ b/tools/test_renpy_java_boolean_godot.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Real host Godot Variant regression, not Android Java/JNI or gameplay proof. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +godot_bin="${GODOT_BIN:-godot}" +work="$(mktemp -d "${TMPDIR:-/tmp}/aether-java-boolean.XXXXXX")" +trap 'rm -rf "$work"' EXIT +[[ "$(uname -s)" == Linux ]] || { echo 'This host ABI test currently supports Linux' >&2; exit 1; } +mkdir -p "$work/project/.godot" "$work/xdg/data" "$work/xdg/config" "$work/xdg/cache" +export XDG_DATA_HOME="$work/xdg/data" XDG_CONFIG_HOME="$work/xdg/config" XDG_CACHE_HOME="$work/xdg/cache" +cat > "$work/project/project.godot" <<'PROJECT' +config_version=5 +[application] +config/name="RenPy Java boolean boundary" +[rendering] +renderer/rendering_method="gl_compatibility" +PROJECT +"$godot_bin" --headless --path "$work/project" --dump-gdextension-interface --dump-extension-api +python3 - "$work/project/extension_api.json" "$work/sizes.h" <<'PY' +import json, struct, sys +api = json.load(open(sys.argv[1])) +precision = 'double' if api['header']['precision'] == 'double' else 'float' +configuration = f'{precision}_{struct.calcsize("P") * 8}' +sizes = next(x['sizes'] for x in api['builtin_class_sizes'] if x['build_configuration'] == configuration) +sizes = {x['name']: x['size'] for x in sizes} +with open(sys.argv[2], 'w') as f: + f.write(f'#define AETHER_TEST_VARIANT_SIZE {sizes["Variant"]}\n') + f.write(f'#define AETHER_TEST_STRING_SIZE {sizes["String"]}\n') +PY +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -fPIC -shared \ + -include "$work/sizes.h" -I"$work/project" -I"$repo_root/bridge/renpy_runtime/include" \ + "$repo_root/bridge/renpy_runtime/tests/java_boolean_godot.cpp" -o "$work/project/variant_test.so" +cat > "$work/project/variant_test.gdextension" <<'EXTENSION' +[configuration] +entry_symbol="aether_java_boolean_test_init" +compatibility_minimum="4.1" +[libraries] +linux="res://variant_test.so" +EXTENSION +printf '%s\n' 'res://variant_test.gdextension' > "$work/project/.godot/extension_list.cfg" +cat > "$work/project/quit.gd" <<'SCRIPT' +extends SceneTree +func _initialize() -> void: + quit(0) +SCRIPT +export AETHER_VARIANT_RESULT="$work/result.json" +"$godot_bin" --headless --path "$work/project" --script "$work/project/quit.gd" +python3 - "$work/result.json" "$work/project/extension_api.json" <<'PY' +import json, sys +result = json.load(open(sys.argv[1])) +api = json.load(open(sys.argv[2]))['header'] +assert result['passed'] and result['checks'] == 12, result +assert result['godot'] == '.'.join(str(api[k]) for k in ('version_major', 'version_minor', 'version_patch')), result +assert result['android_java_executed'] is False, result +print(json.dumps(result, sort_keys=True)) +PY From 69cceb1c2509ad87461bc5461550cb430a00e415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 01:50:50 +0000 Subject: [PATCH 301/331] fix(ios): validate cached RenPy bytecode with Python 3.12 --- .github/workflows/build.yml | 1 + .github/workflows/renpy-ios-runtime-build.yml | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 66bedcb22..05bd8610d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -693,6 +693,7 @@ jobs: # Use the SDK already licensed/provisioned on this cloud macOS runner; # no SDK archive, credential, or user-computer access is transferred. brew install python@3.12 + echo "$(brew --prefix python@3.12)/libexec/bin" >> "$GITHUB_PATH" python_config="$(brew --prefix python@3.12)/bin/python3.12-config" ios_sdk="$(xcrun --sdk iphoneos --show-sdk-path)" evidence_dir="${RUNNER_TEMP}/renpy-ios-compile" diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index 5459d94f1..f6a48c844 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -69,6 +69,10 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false + - name: Use Python 3.12 for payload bytecode validation on cache hits too + uses: actions/setup-python@v6 + with: + python-version: '3.12' - name: Restore a runtime compiled from these exact source inputs id: runtime-cache uses: actions/cache@v6 From 0cbc4d79ccc688dcc850a60e1d65f31a27c12485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 02:06:43 +0000 Subject: [PATCH 302/331] fix(ios): build a genuine arm64 Simulator export template --- .github/workflows/renpy-ios-runtime-build.yml | 36 ++ scripts/build_ios.sh | 74 ++-- tools/build_godot_ios_simulator_template.py | 339 ++++++++++++++++++ tools/godot_ios_simulator_template/README.md | 48 +++ tools/run_renpy_android_device_acceptance.py | 10 +- tools/run_renpy_ios_device_acceptance.py | 4 +- tools/test_godot_ios_simulator_template.py | 118 ++++++ 7 files changed, 580 insertions(+), 49 deletions(-) create mode 100644 tools/build_godot_ios_simulator_template.py create mode 100644 tools/godot_ios_simulator_template/README.md create mode 100644 tools/test_godot_ios_simulator_template.py diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index f6a48c844..06ef3256b 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -15,6 +15,9 @@ on: - 'tools/renpy_ios_acceptance/**' - 'tools/run_renpy_ios_device_acceptance.py' - 'tools/patch_renpy_ios_runtime_project.py' + - 'tools/build_godot_ios_simulator_template.py' + - 'tools/test_godot_ios_simulator_template.py' + - 'tools/godot_ios_simulator_template/**' - 'scripts/build_ios.sh' - 'bridge/renpy_runtime/mobile_launcher/**' - 'bridge/renpy_runtime/src/**' @@ -257,6 +260,38 @@ jobs: cp -a "$RUNNER_TEMP/godot-templates/templates/." "$template_dir/" echo "GODOT_BIN=$GITHUB_WORKSPACE/.godot/Godot.app/Contents/MacOS/Godot" >> "$GITHUB_ENV" echo "GODOT_EXPORT_TEMPLATE=$template_dir/ios.zip" >> "$GITHUB_ENV" + - name: Build the missing real arm64 Godot Simulator engine and camera + run: | + set -euo pipefail + python3 -m venv "$RUNNER_TEMP/godot-scons" + "$RUNNER_TEMP/godot-scons/bin/python" -m pip install 'SCons==4.9.1' + python3 tools/test_godot_ios_simulator_template.py --template "$GODOT_EXPORT_TEMPLATE" + if ! "$RUNNER_TEMP/godot-scons/bin/python" tools/build_godot_ios_simulator_template.py build \ + --input-template "$GODOT_EXPORT_TEMPLATE" \ + --output-template "$RUNNER_TEMP/godot-arm64-simulator/ios.zip" \ + --output-dir "$RUNNER_TEMP/godot-arm64-simulator" --jobs "$JOBS"; then + # Current compiler output only: bounded console diagnostics, no + # raw-log republication or environment/configuration dumps. + python3 - "$RUNNER_TEMP/godot-arm64-simulator/source-build.log" <<'PY' + import pathlib, re, sys + path = pathlib.Path(sys.argv[1]) + if path.is_file(): + content = path.read_text(errors='replace') + content = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', content) + lines = content.splitlines() + indices = set(range(max(0, len(lines)-100), len(lines))) + for i, line in enumerate(lines): + if re.search(r'error:|fatal error|FAILED|Error [1-9]|undefined|Traceback|No such file', line, re.I): + indices.update(range(max(0, i-3), min(len(lines), i+7))) + excerpt = [lines[i] for i in sorted(indices) + if not re.match(r'^\s*(?:export\s|declare\s|env:|Environment:|[A-Z_][A-Z0-9_]*\s*[=:])', lines[i])] + print('\n'.join(excerpt)[-24000:]) + PY + exit 1 + fi + python3 tools/build_godot_ios_simulator_template.py verify \ + --template "$RUNNER_TEMP/godot-arm64-simulator/ios.zip" --arch arm64 + echo "GODOT_EXPORT_TEMPLATE=$RUNNER_TEMP/godot-arm64-simulator/ios.zip" >> "$GITHUB_ENV" - name: Prepare the normal vcpkg binary cache env: VCPKG_PACKAGES_TOKEN: ${{ github.token }} @@ -285,6 +320,7 @@ jobs: path: | out/renpy-ios-gameplay ${{ runner.temp }}/renpy-ios-simulator-build.log + ${{ runner.temp }}/godot-arm64-simulator/evidence.json if-no-files-found: warn retention-days: 7 - name: Archive the actual Simulator application preserving executable permissions diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index dfd92bcbc..2b885d858 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -194,29 +194,14 @@ export CMAKE_MAKE_PROGRAM="$NINJA_BIN" preflight_simulator_template_arch() { local arch="$1" local template="$2" - local tmpdir - local libgodot - local info if [[ ! -f "$template" ]]; then return fi - - tmpdir="$(mktemp -d /tmp/aetherkiri-ios-template.XXXXXX)" - libgodot="$tmpdir/libgodot.ios.debug.xcframework/ios-arm64_x86_64-simulator/libgodot.a" - unzip -q "$template" \ - 'libgodot.ios.debug.xcframework/ios-arm64_x86_64-simulator/libgodot.a' \ - -d "$tmpdir" - info="$(lipo -archs "$libgodot" 2>/dev/null || true)" - rm -rf "$tmpdir" - - if [[ " $info " != *" $arch "* ]]; then - echo "Error: Godot iOS simulator export template does not contain '$arch'." >&2 - echo " $template" >&2 - echo " architectures: ${info:-unknown}" >&2 - echo " Install or build a Godot export template with an $arch simulator slice, or use --simulator-arch=x86_64." >&2 - exit 1 - fi + # Check real objects in both engine/camera archives, not only the plist's + # advertised CPU list. Device arm64 objects cannot satisfy this check. + python3 "$PROJECT_ROOT/tools/build_godot_ios_simulator_template.py" verify \ + --template "$template" --arch "$arch" } if [[ "$SIMULATOR" == true ]]; then @@ -755,22 +740,9 @@ stage_force_load_plugin_archives() { verify_exported_simulator_template_arch() { local export_root="$1" local arch="$2" - local libgodot="$export_root/Aether.xcframework/ios-arm64_x86_64-simulator/libgodot.a" - local info - - if [[ ! -f "$libgodot" ]]; then - echo "Error: exported Godot simulator template is missing: $libgodot" >&2 - exit 1 - fi - - info="$(lipo -archs "$libgodot" 2>/dev/null || true)" - if [[ " $info " != *" $arch "* ]]; then - echo "Error: Godot iOS simulator export template does not contain '$arch'." >&2 - echo " $libgodot" >&2 - echo " architectures: ${info:-unknown}" >&2 - echo " Install or build a Godot export template with an $arch simulator slice, or use --simulator-arch=x86_64." >&2 - exit 1 - fi + python3 "$PROJECT_ROOT/tools/build_godot_ios_simulator_template.py" verify \ + --template "$GODOT_EXPORT_TEMPLATE" --arch "$arch" --exported-dir "$export_root" \ + > "$export_root/godot-simulator-template-evidence.json" } stage_ios_runtime_fonts() { @@ -1065,18 +1037,23 @@ package_ios_simulator_app() { echo "Runnable simulator app: $output_dir/Aether.app (device gameplay unverified)" } -with_ios_only_gdextension() { +with_ios_only_gdextension() ( local gdextension_file="$GODOT_APP_DIR/aether_kiri.gdextension" - local backup_file - backup_file="$(mktemp /tmp/aetherkiri-gdextension.XXXXXX)" - - cp "$gdextension_file" "$backup_file" - restore_gdextension() { - trap - RETURN - cp "$backup_file" "$gdextension_file" - rm -f "$backup_file" + local presets_file="$GODOT_APP_DIR/export_presets.cfg" + local backup_dir + backup_dir="$(mktemp -d /tmp/aetherkiri-ios-export.XXXXXX)" + local backup_file="$backup_dir/aether_kiri.gdextension" + + cp -p "$gdextension_file" "$backup_file" + cp -p "$presets_file" "$backup_dir/export_presets.cfg" + restore_ios_export_inputs() { + cp -p "$backup_file" "$gdextension_file" + cp -p "$backup_dir/export_presets.cfg" "$presets_file" + rm -rf "$backup_dir" } - trap restore_gdextension RETURN + # This subshell restores both files on successful export and any failure, + # without changing the caller's traps or committing temporary presets. + trap restore_ios_export_inputs EXIT awk ' BEGIN { skip = 0 } @@ -1086,9 +1063,14 @@ with_ios_only_gdextension() { !skip || !/^macos\./ { print } ' "$backup_file" | grep -v '^macos\.' > "$gdextension_file" + # Godot reads custom_template from this preset, not GODOT_EXPORT_TEMPLATE. + # Bind the same verified ZIP to the actual export, then restore the preset. + python3 "$PROJECT_ROOT/tools/build_godot_ios_simulator_template.py" preset \ + --source "$backup_dir/export_presets.cfg" --destination "$presets_file" \ + --preset "$EXPORT_PRESET" --template "$GODOT_EXPORT_TEMPLATE" --mode "$BUILD_TYPE_LOWER" "$GODOT_BIN" --headless --path "$GODOT_APP_DIR" \ "$EXPORT_MODE" "$EXPORT_PRESET" "$IOS_EXPORT_DIR/Aether.xcodeproj" -} +) echo "==> Building native engine and Godot extension" renios_launcher_probe diff --git a/tools/build_godot_ios_simulator_template.py b/tools/build_godot_ios_simulator_template.py new file mode 100644 index 000000000..94a879f54 --- /dev/null +++ b/tools/build_godot_ios_simulator_template.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""Build Godot 4.7.2's real arm64 Simulator engine into a template copy. + +The official device/release libraries and the existing x86_64 Simulator slices +are preserved. This verifies build inputs and archives, not app gameplay. +""" +import argparse +import configparser +import copy +import hashlib +import json +import os +from pathlib import Path +import plistlib +import shlex +import shutil +import struct +import subprocess +import sys +import tempfile +import zipfile + +SOURCE_URL = "https://github.com/godotengine/godot.git" +SOURCE_SHA = "ed1daf0bf001b61586d9930840f2f1394092c079" +SIMULATOR = "ios-arm64_x86_64-simulator" +ARCHITECTURES = {0x100000C: "arm64", 0x1000007: "x86_64"} +LIBRARIES = {"libgodot": "", "libgodot_camera": "_camera"} +BUILD_FLAGS = ("platform=ios", "target=template_debug", "arch=arm64", + "ios_simulator=yes", "vulkan=no", "metal=no", "opengl3=yes", + "generate_bundle=no", "sdl=no") + + +def digest(path): + with Path(path).open("rb") as stream: + return stream_digest(stream) + + +def stream_digest(stream): + value = hashlib.sha256() + for block in iter(lambda: stream.read(1024 * 1024), b""): + value.update(block) + return value.hexdigest() + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def macho_identity(data): + require(len(data) >= 32 and data[:4] == bytes.fromhex("cffaedfe"), + "Expected a genuine little-endian Mach-O64 object (LTO bitcode is not accepted)") + cpu, _, kind, count, commands_size = struct.unpack_from("= 8 and offset + size <= limit, "Invalid Mach-O load command size") + if command == 0x32: # LC_BUILD_VERSION + require(size >= 24, "Truncated LC_BUILD_VERSION") + platforms.add(struct.unpack_from("\n", "Expected a genuine static ar archive") + end, count, architectures = offset + length, 0, set() + while stream.tell() < end: + header = stream.read(60) + require(len(header) == 60 and header[58:] == b"`\n", "Invalid static archive member header") + name = header[:16].decode("ascii").rstrip() + size = int(header[48:58]) + require(size >= 0 and stream.tell() + size <= end, "Truncated static archive member") + data = stream.read(size) + if name.startswith("#1/"): + name_size = int(name[3:]) + require(name_size <= size, "Invalid BSD archive member name") + name, data = data[:name_size].rstrip(b"\0").decode("utf-8"), data[name_size:] + if name not in ("/", "//", "/SYM64/") and not name.startswith("__.SYMDEF"): + try: + architectures.add(macho_identity(data)) + except ValueError as error: + raise ValueError(f"{name}: {error}") from error + count += 1 + if size % 2: + require(stream.read(1) == b"\n", "Invalid static archive alignment") + require(stream.tell() == end and count > 0 and len(architectures) == 1, + "Each archive slice must contain nonempty objects of exactly one Simulator architecture") + return {"architecture": next(iter(architectures)), "objects": count, "platform": 7} + + +def archive_identity(path): + path = Path(path) + with path.open("rb") as stream: + magic = stream.read(4) + if magic == b"!", False), + bytes.fromhex("bebafeca"): ("<", False), + bytes.fromhex("cafebabf"): (">", True), + bytes.fromhex("bfbafeca"): ("<", True)} + require(magic in formats, "Expected a thin or universal Mach-O static archive") + endian, fat64 = formats[magic] + count = struct.unpack(endian + "I", stream.read(4))[0] + require(0 < count <= 2, "Unexpected universal archive architecture count") + slices = [] + for _ in range(count): + fields = struct.unpack(endian + ("IIQQII" if fat64 else "IIIII"), + stream.read(32 if fat64 else 20)) + cpu, _, offset, length, alignment = fields[:5] + require(cpu in ARCHITECTURES and length > 0 and alignment < 32 + and offset >= 8 + count * (32 if fat64 else 20) + and offset + length <= path.stat().st_size, "Invalid universal archive slice") + require(offset % (1 << alignment) == 0, "Misaligned universal archive slice") + slices.append((cpu, offset, length)) + ranges = sorted((offset, offset + length) for _, offset, length in slices) + require(all(a[1] <= b[0] for a, b in zip(ranges, ranges[1:])), "Overlapping archive slices") + identities = [] + for cpu, offset, length in slices: + identity = thin_archive(stream, offset, length) + require(identity["architecture"] == ARCHITECTURES[cpu], "CPU header disagrees with archive objects") + identities.append(identity) + require(len({item["architecture"] for item in identities}) == len(identities), "Duplicate CPU slices") + return identities + + +def member(library): + return f"{library}.ios.debug.xcframework/{SIMULATOR}/{library}.a" + + +def verify_template(template, arch, exported=None): + result = {} + with zipfile.ZipFile(template) as source, tempfile.TemporaryDirectory(prefix="godot-ios-verify-") as work: + require(len(source.namelist()) == len(set(source.namelist())), "Duplicate template ZIP members") + for library, suffix in LIBRARIES.items(): + framework = f"{library}.ios.debug.xcframework" + metadata = plistlib.loads(source.read(f"{framework}/Info.plist")) + candidates = [entry for entry in metadata["AvailableLibraries"] + if entry.get("LibraryIdentifier") == SIMULATOR] + require(len(candidates) == 1, f"Missing or ambiguous Simulator metadata: {framework}") + info = candidates[0] + require(info.get("SupportedPlatform") == "ios" and info.get("SupportedPlatformVariant") == "simulator" + and info.get("LibraryPath") == f"{library}.a", f"Invalid Simulator metadata: {framework}") + path = Path(work) / (library + ".a") + with source.open(member(library)) as src, path.open("wb") as dest: + shutil.copyfileobj(src, dest) + identities = archive_identity(path) + actual = {entry["architecture"] for entry in identities} + require(arch in actual, f"Godot {framework} lacks actual {arch} Simulator objects: {sorted(actual)}") + # The original x64-only official ZIP incorrectly advertises arm64. + # The repaired arm64 ZIP must have exact, truthful metadata. + if arch == "arm64": + require(actual == set(info["SupportedArchitectures"]) == {"arm64", "x86_64"}, + f"Simulator metadata differs from real CPU slices: {framework}") + library_digest = digest(path) + if exported: + actual_path = Path(exported) / f"Aether{suffix}.xcframework/{SIMULATOR}/{library}.a" + # Godot only exports the optional camera framework when enabled. + if not suffix or actual_path.exists(): + require(actual_path.is_file() and digest(actual_path) == library_digest, + f"Actual Godot export did not use the verified template: {actual_path}") + result[library] = {"sha256": library_digest, "slices": identities} + return result + + +def patch_preset(source, destination, name, template, mode): + text = Path(source).read_text() + config = configparser.ConfigParser(interpolation=None, strict=True) + config.read_string(text) + sections = [section for section in config.sections() if section.startswith("preset.") + and not section.endswith(".options") and config.get(section, "name", fallback="") == json.dumps(name)] + require(len(sections) == 1, f"Expected one export preset named {name}") + section = sections[0] + require(config.get(section, "platform") == '"iOS"', "Custom iOS template requires an iOS export preset") + options, key = section + ".options", "custom_template/" + mode + require(options in config, f"Missing {options}") + value = json.dumps(str(Path(template).resolve(strict=True)), ensure_ascii=False) + lines, inside, found = [], False, False + for line in text.splitlines(keepends=True): + if line.startswith("["): + if inside and not found: + lines.append(key + "=" + value + "\n") + inside = line.strip() == "[" + options + "]" + if inside and line.split("=", 1)[0].strip() == key: + require(not found, f"Duplicate {key}") + line, found = key + "=" + value + "\n", True + lines.append(line) + if inside and not found: + lines.append(key + "=" + value + "\n") + Path(destination).write_text("".join(lines)) + + +def command(arguments, *, cwd=None): + result = subprocess.run(list(map(str, arguments)), cwd=cwd, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True) + return result.stdout.strip() + + +def replace_template_archives(template, destination, replacements): + require(len(template.namelist()) == len(set(template.namelist())), "Duplicate template ZIP members") + require(set(replacements) <= {member(library) for library in LIBRARIES}, + "Only the debug engine/camera Simulator archives may be replaced") + with zipfile.ZipFile(destination, "w") as patched: + patched.comment = template.comment + for info in template.infolist(): + with (replacements[info.filename].open("rb") if info.filename in replacements + else template.open(info)) as src, patched.open(copy.copy(info), "w") as dest: + shutil.copyfileobj(src, dest) + with zipfile.ZipFile(destination) as patched: + require(template.namelist() == patched.namelist(), "Template member set/order changed") + for name in template.namelist(): + if name not in replacements: + with template.open(name) as before, patched.open(name) as after: + require(stream_digest(before) == stream_digest(after), f"Untouched template member changed: {name}") + + +def build(args): + require(sys.platform == "darwin", "A genuine macOS/Xcode host is required; no cross-SDK substitute is accepted") + import SCons + require(tuple(map(int, SCons.__version__.split(".")[:2])) >= (4, 4), "Godot requires SCons >= 4.4") + original, output, root = args.input_template.resolve(strict=True), args.output_template.resolve(), args.output_dir.resolve() + require(original != output, "The official input template must remain unchanged") + require("OSXCROSS_IOS" not in os.environ, "Use genuine selected Xcode tools, not an osxcross SDK") + root.mkdir(parents=True, exist_ok=True) + source = root / "godot-source" + require(not source.exists(), "Use a fresh output directory for a real source build") + sdk = command(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"]) + compiler = command(["xcrun", "--sdk", "iphonesimulator", "--find", "clang"]) + evidence = {"status": "building", "source_url": SOURCE_URL, "source_sha": SOURCE_SHA, + "input_template_sha256": digest(original), "sdk_path": sdk, + "sdk_version": command(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-version"]), + "compiler": compiler, "compiler_version": command([compiler, "--version"]), + "xcode_version": command(["xcodebuild", "-version"]), "gameplay": "not_run"} + report = root / "evidence.json" + report.write_text(json.dumps(evidence, indent=2) + "\n") + try: + evidence["input_libraries"] = verify_template(original, "x86_64") + command(["git", "init", "--quiet", source]) + command(["git", "-C", source, "fetch", "--depth=1", SOURCE_URL, SOURCE_SHA]) + command(["git", "-C", source, "checkout", "--quiet", "--detach", "FETCH_HEAD"]) + require(command(["git", "-C", source, "rev-parse", "HEAD"]) == SOURCE_SHA, "Godot source pin mismatch") + toolchain = Path(compiler).resolve(strict=True).parents[2] + require((toolchain / "usr/bin/clang++").is_file(), "Selected compiler lacks its real Apple toolchain") + invocation = [sys.executable, "-m", "SCons", *BUILD_FLAGS, + "APPLE_SDK_PATH=" + sdk, "APPLE_TOOLCHAIN_PATH=" + str(toolchain), "-j" + str(args.jobs)] + evidence["build_command"] = invocation + print("Real Godot Simulator build:", shlex.join(invocation), flush=True) + with (root / "source-build.log").open("w") as log: + subprocess.run(invocation, cwd=source, stdout=log, stderr=subprocess.STDOUT, check=True) + replacements, libraries = {}, {} + with zipfile.ZipFile(original) as template: + for library in LIBRARIES: + arm = source / f"bin/{library}.ios.template_debug.arm64.simulator.a" + identities = archive_identity(arm) + require(len(identities) == 1 and identities[0]["architecture"] == "arm64", f"Wrong source build: {arm}") + base = root / (library + "-official.a") + with template.open(member(library)) as src, base.open("wb") as dest: + shutil.copyfileobj(src, dest) + original_slices = archive_identity(base) + require({entry["architecture"] for entry in original_slices} <= {"arm64", "x86_64"} + and any(entry["architecture"] == "x86_64" for entry in original_slices), "Official x64 slice missing") + x64 = root / (library + "-x86_64.a") + if len(original_slices) == 1: + shutil.copyfile(base, x64) + else: + command(["xcrun", "lipo", base, "-thin", "x86_64", "-output", x64]) + merged = root / (library + "-simulator.a") + command(["xcrun", "lipo", "-create", x64, arm, "-output", merged]) + retained = root / (library + "-retained-x86_64.a") + command(["xcrun", "lipo", merged, "-thin", "x86_64", "-output", retained]) + require(digest(x64) == digest(retained), "Official x64 archive bytes changed") + require({entry["architecture"] for entry in archive_identity(merged)} == {"arm64", "x86_64"}, "Bad merged archive") + replacements[member(library)] = merged + libraries[library] = {"arm64_sha256": digest(arm), "official_x86_64_sha256": digest(x64), + "merged_sha256": digest(merged), "arm64_objects": identities[0]["objects"]} + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(prefix="ios-template-", suffix=".zip", dir=output.parent, delete=False) as temporary: + staged = Path(temporary.name) + try: + replace_template_archives(template, staged, replacements) + verify_template(staged, "arm64") + os.replace(staged, output) + finally: + staged.unlink(missing_ok=True) + evidence.update(status="built_and_verified", output_template_sha256=digest(output), libraries=libraries, + untouched_template_members="all_sha256_identical") + except BaseException as error: + evidence.update(status="failed", error=str(error)) + raise + finally: + report.write_text(json.dumps(evidence, indent=2) + "\n") + print(json.dumps(evidence, indent=2)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="action", required=True) + builder = commands.add_parser("build") + builder.add_argument("--input-template", type=Path, required=True) + builder.add_argument("--output-template", type=Path, required=True) + builder.add_argument("--output-dir", type=Path, required=True) + builder.add_argument("--jobs", type=int, default=3) + checker = commands.add_parser("verify") + checker.add_argument("--template", type=Path, required=True) + checker.add_argument("--arch", choices=("arm64", "x86_64"), required=True) + checker.add_argument("--exported-dir", type=Path) + preset = commands.add_parser("preset") + preset.add_argument("--source", type=Path, required=True) + preset.add_argument("--destination", type=Path, required=True) + preset.add_argument("--preset", required=True) + preset.add_argument("--template", type=Path, required=True) + preset.add_argument("--mode", choices=("debug", "release"), required=True) + args = parser.parse_args() + try: + if args.action == "build": + require(args.jobs > 0, "Build jobs must be positive") + build(args) + elif args.action == "verify": + print(json.dumps(verify_template(args.template, args.arch, args.exported_dir), indent=2)) + else: + patch_preset(args.source, args.destination, args.preset, args.template, args.mode) + except (OSError, ValueError, KeyError, zipfile.BadZipFile, struct.error, subprocess.CalledProcessError) as error: + print(f"Godot Simulator template failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/godot_ios_simulator_template/README.md b/tools/godot_ios_simulator_template/README.md new file mode 100644 index 000000000..dd98dfc58 --- /dev/null +++ b/tools/godot_ios_simulator_template/README.md @@ -0,0 +1,48 @@ +# Real Godot 4.7.2 arm64 Simulator template + +The official 4.7.2 `ios.zip` advertises arm64 and x86_64 Simulator support in +its plists, but its debug engine and camera Simulator archives contain only +x86_64 objects. `build_godot_ios_simulator_template.py` builds both missing +arm64 archives from official Godot commit +`ed1daf0bf001b61586d9930840f2f1394092c079` using the selected cloud Xcode +iPhoneSimulator SDK. It uses Compatibility rendering, which the application's +Simulator preset also selects. The engine's SDL3 joystick driver is disabled +(`sdl=no`) because this application force-loads its existing SDL2 host libraries; +Godot's native iOS display, touch and keyboard paths remain in the engine, and +Ren'Py retains its real SDL2 runtime. It does not download an alternate SDK or +accept SDK licenses. + +On a cloud Apple Silicon Mac with existing licensed Xcode and Python/SCons: + +```bash +python3 tools/build_godot_ios_simulator_template.py build \ + --input-template "$GODOT_EXPORT_TEMPLATE" \ + --output-template "$RUNNER_TEMP/godot-arm64-simulator/ios.zip" \ + --output-dir "$RUNNER_TEMP/godot-arm64-simulator" --jobs 3 +export GODOT_EXPORT_TEMPLATE="$RUNNER_TEMP/godot-arm64-simulator/ios.zip" +bash scripts/build_ios.sh debug --simulator --simulator-arch=arm64 +``` + +Use a fresh output directory for each source build. Both original x86_64 debug +Simulator slices are preserved byte for byte with real `lipo`; only the two +debug Simulator archive members change in the copied ZIP. Every other member, +including all device/release slices and metadata, retains its original payload +hash. Every archive object must identify the actual Simulator platform 7, and +the corrected universal archives must agree with the plist architecture lists. +`evidence.json` records the fixed source commit, compiler, SDK, exact build +command, template/archive hashes and actual build status. This is source-build +evidence; it does not prove an application launched or a game was playable. + +`scripts/build_ios.sh` temporarily binds the selected export preset's +`custom_template/debug` to this exact template and restores both the preset and +GDExtension configuration on success or failure. It checks every Simulator +archive on entry and verifies the actual exported archives against the same +template hashes. Cached templates therefore undergo the same binary checks. +Continue with the real XCUITest gameplay harness in +[`tools/renpy_ios_acceptance/README.md`](../renpy_ios_acceptance/README.md). + +`test_godot_ios_simulator_template.py --template ORIGINAL_IOS_ZIP` inspects the +real official archives and compiles real LLVM/Xcode objects to check CPU and +platform rejection. Darwin also checks genuine `lipo` universal archives. Linux +can run the thin-object checks with `--clang PATH --ar PATH` using existing +LLVM tools; it does not build Godot, use an Apple SDK or execute iOS software. diff --git a/tools/run_renpy_android_device_acceptance.py b/tools/run_renpy_android_device_acceptance.py index 7606c9932..c1559eb8f 100644 --- a/tools/run_renpy_android_device_acceptance.py +++ b/tools/run_renpy_android_device_acceptance.py @@ -33,6 +33,14 @@ class Failed(RuntimeError): pass +def is_successful_os_return(row: object) -> bool: + """Require a delivered Return in the observer's explicit Godot namespace.""" + return (isinstance(row, dict) and row.get("kind") == "os_key" + and row.get("keycode_space") == "godot" and row.get("pressed") is True + and row.get("code") in ((1 << 22) | 5, (1 << 22) | 6) + and not isinstance(row.get("result"), bool) and row.get("result") == 0) + + def png_pixels(path: Path) -> tuple[int, int, bytes, int]: """Decode real Android/Godot 8-bit RGB(A) evidence without extra packages.""" raw = path.read_bytes() @@ -529,7 +537,7 @@ def run(self) -> None: raise Failed("No real OS touch-down reached the provider") if not any(row.get("kind") == "os_touch" and row.get("action") == 3 and row.get("result") == 0 for row in events): raise Failed("No real OS touch-up reached the provider") - if not any(row.get("kind") == "os_key" and row.get("pressed") and row.get("code") == 13 for row in events): + if not any(is_successful_os_return(row) for row in events): raise Failed("No real OS Enter key reached the provider") self.summary["checks"].extend([ {"real_os_touch_and_keyboard_text": typed}, diff --git a/tools/run_renpy_ios_device_acceptance.py b/tools/run_renpy_ios_device_acceptance.py index 5b973c27c..3f48f7a58 100755 --- a/tools/run_renpy_ios_device_acceptance.py +++ b/tools/run_renpy_ios_device_acceptance.py @@ -21,7 +21,7 @@ from pathlib import Path from run_renpy_android_device_acceptance import ( - Blocked, Failed, checkpoint_landmarks, image_stats, is_script_checkpoint, pixel, png_pixels, + Blocked, Failed, checkpoint_landmarks, image_stats, is_script_checkpoint, is_successful_os_return, pixel, png_pixels, read_engine_log_file, require_engine_log, require_landmark_pixels, verify_landmark_pixels, verify_resumed_marker, ) @@ -391,7 +391,7 @@ def run(self) -> None: for action in (1, 3): if not any(r.get("kind") == "os_touch" and r.get("action") == action and r.get("result") == 0 for r in events): raise Failed(f"Actual iOS touch action {action} never reached the provider") - if not any(r.get("kind") == "os_key" and r.get("pressed") and r.get("code") == 13 for r in events): + if not any(is_successful_os_return(r) for r in events): raise Failed("Actual iOS Enter key never reached the provider") self.ui("finish") try: diff --git a/tools/test_godot_ios_simulator_template.py b/tools/test_godot_ios_simulator_template.py new file mode 100644 index 000000000..16ba31a1d --- /dev/null +++ b/tools/test_godot_ios_simulator_template.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Check real official template archives and LLVM-produced Apple objects. + +This is archive/preset acceptance, not a Godot engine build or gameplay test. +On Darwin the universal archive checks use real Xcode lipo and SDK compilers. +""" +import argparse +import configparser +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import zipfile + +import build_godot_ios_simulator_template as helper + + +def rejected(action): + try: + action() + except ValueError: + return + raise AssertionError("An incompatible real archive/template was accepted") + + +def config(path): + result = configparser.ConfigParser(interpolation=None) + result.read(path) + return {section: dict(result[section]) for section in result.sections()} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--template", type=Path, required=True, + help="The genuine official Godot4.7.2 ios.zip") + parser.add_argument("--clang", default=shutil.which("clang")) + parser.add_argument("--ar", default=shutil.which("llvm-ar")) + args = parser.parse_args() + require = helper.require + require(args.template.is_file(), "A real official template is required") + actual = helper.verify_template(args.template, "x86_64") + require(all(item["slices"][0]["architecture"] == "x86_64" and len(item["slices"]) == 1 + for item in actual.values()), "This acceptance starts with the original x64-only release template") + rejected(lambda: helper.verify_template(args.template, "arm64")) + with tempfile.TemporaryDirectory(prefix="godot-real-archive-acceptance-") as temporary: + root = Path(temporary) + source = root / "object.c" + source.write_text("int archive_platform_acceptance(void) { return 7; }\n") + archives = {} + for name, target, sdk in (("sim-arm64", "arm64-apple-ios14.0-simulator", "iphonesimulator"), + ("device-arm64", "arm64-apple-ios14.0", "iphoneos"), + ("sim-x86_64", "x86_64-apple-ios14.0-simulator", "iphonesimulator")): + obj, archive = root / (name + ".o"), root / (name + ".a") + if sys.platform == "darwin": + sdk_path = subprocess.check_output(["xcrun", "--sdk", sdk, "--show-sdk-path"], text=True).strip() + compiler = ["xcrun", "--sdk", sdk, "clang", "-isysroot", sdk_path] + archiver = ["xcrun", "ar"] + else: + require(args.clang and args.ar, "Real LLVM clang and llvm-ar are required") + compiler, archiver = [args.clang], [args.ar, "--format=darwin"] + subprocess.run([*compiler, "-target", target, "-c", source, "-o", obj], check=True) + subprocess.run([*archiver, "rcs", archive, obj], check=True) + archives[name] = archive + require(helper.archive_identity(archives["sim-arm64"])[0] == + {"architecture": "arm64", "objects": 1, "platform": 7}, "Actual arm64 Simulator object rejected") + require(helper.archive_identity(archives["sim-x86_64"])[0] == + {"architecture": "x86_64", "objects": 1, "platform": 7}, "Actual x64 Simulator object rejected") + rejected(lambda: helper.archive_identity(archives["device-arm64"])) + with zipfile.ZipFile(args.template) as template: + # The actual device archive must never pass the Simulator gate, + # despite matching CPU and truthful device plist metadata. + path = root / "official-camera-device.a" + path.write_bytes(template.read("libgodot_camera.ios.debug.xcframework/ios-arm64/libgodot_camera.a")) + rejected(lambda: helper.archive_identity(path)) + # Exercise the real ZIP writer on the genuine full release template; + # retain every untouched archive/metadata byte and input ZIP itself. + camera = root / "official-camera-simulator.a" + camera.write_bytes(template.read(helper.member("libgodot_camera"))) + copy = root / "roundtrip-ios.zip" + original_digest = helper.digest(args.template) + helper.replace_template_archives(template, copy, {helper.member("libgodot_camera"): camera}) + require(helper.verify_template(copy, "x86_64") == actual, "Actual ZIP roundtrip changed real libraries") + require(helper.digest(args.template) == original_digest, "Official input ZIP was modified") + universal = "not_run (Linux has no Apple lipo)" + if sys.platform == "darwin": + fat = root / "real-universal.a" + subprocess.run(["xcrun", "lipo", "-create", archives["sim-arm64"], archives["sim-x86_64"], + "-output", fat], check=True) + require({item["architecture"] for item in helper.archive_identity(fat)} == {"arm64", "x86_64"}, + "Real lipo archive identities rejected") + wrong = root / "device-in-universal.a" + subprocess.run(["xcrun", "lipo", "-create", archives["device-arm64"], archives["sim-x86_64"], + "-output", wrong], check=True) + rejected(lambda: helper.archive_identity(wrong)) + universal = "passed (real Xcode lipo/SDK objects)" + presets = Path(__file__).resolve().parents[1] / "apps/godot_app/export_presets.cfg" + before = config(presets) + output = root / "scoped-export.cfg" + for name, section in (("iOS Simulator Debug", "preset.11.options"), + ("iOS Simulator x64 Debug", "preset.12.options"), + ("iOS Debug", "preset.1.options")): + helper.patch_preset(presets, output, name, args.template, "debug") + after = config(output) + expected = {key: dict(value) for key, value in before.items()} + expected[section]["custom_template/debug"] = json.dumps(str(args.template.resolve())) + require(after == expected, "Custom template changed another preset or option") + rejected(lambda: helper.patch_preset(presets, output, "Android Debug", args.template, "debug")) + print(json.dumps({"status": "passed", "official_template_sha256": helper.digest(args.template), + "official_archives": actual, "device_slice_rejected": True, + "real_template_roundtrip": "all_untouched_member_hashes_identical", + "real_universal_archive": universal, "godot_engine_build": "not_run", "gameplay": "not_run"}, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 59485ebb0f7ef88a8c430fa16c4423fd378f6ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 02:19:12 +0000 Subject: [PATCH 303/331] fix(ios): build the RenPy device engine without SDL3 conflicts --- .github/workflows/renpy-ios-runtime-build.yml | 37 ++++ scripts/build_ios.sh | 47 +++- tools/build_godot_ios_device_template.py | 204 ++++++++++++++++++ tools/build_godot_ios_simulator_template.py | 47 ++-- tools/godot_ios_device_template/README.md | 55 +++++ .../profile_acceptance.gd | 15 ++ tools/test_godot_ios_device_template.py | 139 ++++++++++++ 7 files changed, 527 insertions(+), 17 deletions(-) create mode 100644 tools/build_godot_ios_device_template.py create mode 100644 tools/godot_ios_device_template/README.md create mode 100644 tools/godot_ios_device_template/profile_acceptance.gd create mode 100644 tools/test_godot_ios_device_template.py diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index 06ef3256b..3e919856f 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -18,6 +18,9 @@ on: - 'tools/build_godot_ios_simulator_template.py' - 'tools/test_godot_ios_simulator_template.py' - 'tools/godot_ios_simulator_template/**' + - 'tools/build_godot_ios_device_template.py' + - 'tools/test_godot_ios_device_template.py' + - 'tools/godot_ios_device_template/**' - 'scripts/build_ios.sh' - 'bridge/renpy_runtime/mobile_launcher/**' - 'bridge/renpy_runtime/src/**' @@ -351,6 +354,7 @@ jobs: env: AETHERKIRI_ENABLE_RENPY: ON AETHERKIRI_ENABLE_INTERNAL: OFF + AETHERKIRI_RENPY_IOS_DEVICE_GLES: ON AETHERKIRI_VCPKG_CACHE_WRITE: 'false' GODOT_RELEASE: 4.7.2-stable GODOT_TEMPLATE_VERSION: 4.7.2.stable @@ -405,6 +409,38 @@ jobs: cp -a "$RUNNER_TEMP/godot-templates/templates/." "$template_dir/" echo "GODOT_BIN=$GITHUB_WORKSPACE/.godot/Godot.app/Contents/MacOS/Godot" >> "$GITHUB_ENV" echo "GODOT_EXPORT_TEMPLATE=$template_dir/ios.zip" >> "$GITHUB_ENV" + - name: Build the real SDL3-free Godot iPhoneOS engine and camera + run: | + set -euo pipefail + python3 -m venv "$RUNNER_TEMP/godot-device-scons" + "$RUNNER_TEMP/godot-device-scons/bin/python" -m pip install 'SCons==4.9.1' + python3 tools/test_godot_ios_device_template.py --template "$GODOT_EXPORT_TEMPLATE" \ + --godot "$GODOT_BIN" --output-dir "$RUNNER_TEMP/godot-device-acceptance" + if ! "$RUNNER_TEMP/godot-device-scons/bin/python" tools/build_godot_ios_device_template.py build \ + --input-template "$GODOT_EXPORT_TEMPLATE" \ + --output-template "$RUNNER_TEMP/godot-renpy-device/ios.zip" \ + --output-dir "$RUNNER_TEMP/godot-renpy-device" --jobs "$JOBS"; then + # Current compiler output only: bounded diagnostics, no raw-log + # republication, environment dumps or additional SDK downloads. + python3 - "$RUNNER_TEMP/godot-renpy-device/source-build.log" <<'PY' + import pathlib, re, sys + path = pathlib.Path(sys.argv[1]) + if path.is_file(): + content = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', path.read_text(errors='replace')) + lines = content.splitlines() + indices = set(range(max(0, len(lines)-100), len(lines))) + for i, line in enumerate(lines): + if re.search(r'error:|fatal error|FAILED|Error [1-9]|undefined|Traceback|No such file', line, re.I): + indices.update(range(max(0, i-3), min(len(lines), i+7))) + excerpt = [lines[i] for i in sorted(indices) + if not re.match(r'^\s*(?:export\s|declare\s|env:|Environment:|[A-Z_][A-Z0-9_]*\s*[=:])', lines[i])] + print('\n'.join(excerpt)[-24000:]) + PY + exit 1 + fi + python3 tools/build_godot_ios_device_template.py verify \ + --template "$RUNNER_TEMP/godot-renpy-device/ios.zip" + echo "GODOT_EXPORT_TEMPLATE=$RUNNER_TEMP/godot-renpy-device/ios.zip" >> "$GITHUB_ENV" - name: Prepare the normal vcpkg binary cache env: VCPKG_PACKAGES_TOKEN: ${{ github.token }} @@ -431,6 +467,7 @@ jobs: path: | ${{ runner.temp }}/renpy-ios-device-build.log ${{ runner.temp }}/renpy-ios-device-runtime-manifest.json + ${{ runner.temp }}/godot-renpy-device/evidence.json out/ios/debug/renpy-native/exports.txt out/ios/debug/renpy-native/support-class-symbols.txt out/ios/debug/renpy-native/linked-libraries.txt diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 2b885d858..575d4ea00 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -316,6 +316,22 @@ renios_enabled() { esac } +renpy_device_gles_enabled() { + case "${AETHERKIRI_RENPY_IOS_DEVICE_GLES:-OFF}" in + ON|TRUE|YES|1|on|true|yes) return 0 ;; + *) return 1 ;; + esac +} + +if renpy_device_gles_enabled; then + if [[ "$SIMULATOR" == true || "$BUILD_TYPE_LOWER" != debug ]] || ! renios_enabled; then + echo "Error: AETHERKIRI_RENPY_IOS_DEVICE_GLES requires a Debug device build with Ren'Py enabled." >&2 + exit 1 + fi + python3 "$PROJECT_ROOT/tools/build_godot_ios_device_template.py" verify \ + --template "$GODOT_EXPORT_TEMPLATE" +fi + renios_link_enabled() { case "${AETHERKIRI_RENPY_RENIOS_LINK:-ON}" in ON|TRUE|YES|1|on|true|yes) return 0 ;; @@ -745,6 +761,13 @@ verify_exported_simulator_template_arch() { > "$export_root/godot-simulator-template-evidence.json" } +verify_exported_renpy_device_template() { + local export_root="$1" + python3 "$PROJECT_ROOT/tools/build_godot_ios_device_template.py" verify \ + --template "$GODOT_EXPORT_TEMPLATE" --exported-dir "$export_root" \ + > "$export_root/godot-device-template-evidence.json" +} + stage_ios_runtime_fonts() { local export_root="$1" local app_source_dir="$export_root/Aether" @@ -1040,18 +1063,25 @@ package_ios_simulator_app() { with_ios_only_gdextension() ( local gdextension_file="$GODOT_APP_DIR/aether_kiri.gdextension" local presets_file="$GODOT_APP_DIR/export_presets.cfg" + local project_file="$GODOT_APP_DIR/project.godot" local backup_dir backup_dir="$(mktemp -d /tmp/aetherkiri-ios-export.XXXXXX)" local backup_file="$backup_dir/aether_kiri.gdextension" cp -p "$gdextension_file" "$backup_file" cp -p "$presets_file" "$backup_dir/export_presets.cfg" + if renpy_device_gles_enabled; then + cp -p "$project_file" "$backup_dir/project.godot" + fi restore_ios_export_inputs() { cp -p "$backup_file" "$gdextension_file" cp -p "$backup_dir/export_presets.cfg" "$presets_file" + if [[ -f "$backup_dir/project.godot" ]]; then + cp -p "$backup_dir/project.godot" "$project_file" + fi rm -rf "$backup_dir" } - # This subshell restores both files on successful export and any failure, + # This subshell restores all modified inputs on successful export or failure, # without changing the caller's traps or committing temporary presets. trap restore_ios_export_inputs EXIT @@ -1065,9 +1095,16 @@ with_ios_only_gdextension() ( # Godot reads custom_template from this preset, not GODOT_EXPORT_TEMPLATE. # Bind the same verified ZIP to the actual export, then restore the preset. - python3 "$PROJECT_ROOT/tools/build_godot_ios_simulator_template.py" preset \ - --source "$backup_dir/export_presets.cfg" --destination "$presets_file" \ - --preset "$EXPORT_PRESET" --template "$GODOT_EXPORT_TEMPLATE" --mode "$BUILD_TYPE_LOWER" + if renpy_device_gles_enabled; then + python3 "$PROJECT_ROOT/tools/build_godot_ios_device_template.py" profile \ + --presets-source "$backup_dir/export_presets.cfg" --presets-destination "$presets_file" \ + --project-source "$backup_dir/project.godot" --project-destination "$project_file" \ + --template "$GODOT_EXPORT_TEMPLATE" + else + python3 "$PROJECT_ROOT/tools/build_godot_ios_simulator_template.py" preset \ + --source "$backup_dir/export_presets.cfg" --destination "$presets_file" \ + --preset "$EXPORT_PRESET" --template "$GODOT_EXPORT_TEMPLATE" --mode "$BUILD_TYPE_LOWER" + fi "$GODOT_BIN" --headless --path "$GODOT_APP_DIR" \ "$EXPORT_MODE" "$EXPORT_PRESET" "$IOS_EXPORT_DIR/Aether.xcodeproj" ) @@ -1148,6 +1185,8 @@ else with_ios_only_gdextension if [[ "$SIMULATOR" == true ]]; then verify_exported_simulator_template_arch "$IOS_EXPORT_DIR" "$SIMULATOR_ARCH" + elif renpy_device_gles_enabled; then + verify_exported_renpy_device_template "$IOS_EXPORT_DIR" fi stage_renios_ios_resources "$IOS_EXPORT_DIR" stage_force_load_plugin_archives "$IOS_EXPORT_DIR/Aether/bin/$GODOT_TRIPLET_DIR" diff --git a/tools/build_godot_ios_device_template.py b/tools/build_godot_ios_device_template.py new file mode 100644 index 000000000..baa1563cd --- /dev/null +++ b/tools/build_godot_ios_device_template.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Build a real Godot 4.7.2 GLES iPhoneOS debug template for Ren'Py. + +Only device/debug engine and camera archives change. No signing identity, +device execution, replacement SDK or license acceptance is performed here. +""" +import argparse +import configparser +import json +import os +from pathlib import Path +import plistlib +import shlex +import shutil +import subprocess +import sys +import tempfile +import zipfile + +import build_godot_ios_simulator_template as common + +FEATURE = "renpy_ios_device_gles" +DEVICE = "ios-arm64" +BUILD_FLAGS = ("platform=ios", "target=template_debug", "arch=arm64", + "ios_simulator=no", "vulkan=no", "metal=no", "opengl3=yes", + "generate_bundle=no", "sdl=no") + + +def member(library): + return f"{library}.ios.debug.xcframework/{DEVICE}/{library}.a" + + +def verify_template(template, exported=None, require_sdl_free=False, nm=None): + result = {} + with zipfile.ZipFile(template) as source, tempfile.TemporaryDirectory(prefix="godot-device-verify-") as work: + common.require(len(source.namelist()) == len(set(source.namelist())), "Duplicate template ZIP members") + for library, suffix in common.LIBRARIES.items(): + framework = f"{library}.ios.debug.xcframework" + metadata = plistlib.loads(source.read(f"{framework}/Info.plist")) + candidates = [entry for entry in metadata["AvailableLibraries"] + if entry.get("LibraryIdentifier") == DEVICE] + common.require(len(candidates) == 1, f"Missing or ambiguous device metadata: {framework}") + info = candidates[0] + common.require(info.get("SupportedPlatform") == "ios" and not info.get("SupportedPlatformVariant") + and info.get("LibraryPath") == f"{library}.a" + and info.get("SupportedArchitectures") == ["arm64"], f"Invalid device metadata: {framework}") + path = Path(work) / (library + ".a") + with source.open(member(library)) as src, path.open("wb") as dest: + shutil.copyfileobj(src, dest) + identities = common.archive_identity(path, expected_platform=2) + common.require(len(identities) == 1 and identities[0]["architecture"] == "arm64", "Device archive must be thin arm64") + if require_sdl_free: + common.reject_sdl_symbols(path, nm=nm) + library_digest = common.digest(path) + if exported: + actual_path = Path(exported) / f"Aether{suffix}.xcframework/{DEVICE}/{library}.a" + if not suffix or actual_path.exists(): + common.require(actual_path.is_file() and common.digest(actual_path) == library_digest, + f"Actual Godot export did not use the verified device template: {actual_path}") + result[library] = {"sha256": library_digest, "slices": identities} + return result + + +def replace_section_value(text, section, key, value): + lines, inside, found, sections = [], False, False, 0 + for line in text.splitlines(keepends=True): + if line.startswith("["): + if inside and not found: + lines.append(key + "=" + value + "\n") + inside = line.strip() == "[" + section + "]" + sections += int(inside) + if inside and line.split("=", 1)[0].strip() == key: + common.require(not found, f"Duplicate {key}") + line, found = key + "=" + value + "\n", True + lines.append(line) + if inside and not found: + lines.append(key + "=" + value + "\n") + common.require(sections == 1, f"Expected exactly one [{section}]") + return "".join(lines) + + +def patch_device_profile(presets_source, presets_destination, project_source, project_destination, template): + # This operation is scoped to a copied Debug device preset. It never + # changes the ordinary device preset, global renderer or Simulator tags. + common.require(Path(presets_source).resolve() != Path(presets_destination).resolve() + and Path(project_source).resolve() != Path(project_destination).resolve(), + "Use separate profile copies or backups; preserve the ordinary source files") + common.patch_preset(presets_source, presets_destination, "iOS Debug", template, "debug") + text = Path(presets_destination).read_text() + config = configparser.ConfigParser(interpolation=None) + config.read_string(text) + sections = [section for section in config.sections() if section.startswith("preset.") + and not section.endswith(".options") and config.get(section, "name", fallback="") == '"iOS Debug"'] + common.require(len(sections) == 1, "Expected the scoped Debug device preset") + features = json.loads(config.get(sections[0], "custom_features", fallback='""')).split(",") + features = [value.strip() for value in features if value.strip()] + common.require("ios_simulator" not in features and "ios_simulator_x64" not in features, + "A device profile must never select Simulator libraries") + if FEATURE not in features: + features.append(FEATURE) + Path(presets_destination).write_text(replace_section_value(text, sections[0], "custom_features", json.dumps(",".join(features)))) + project = Path(project_source).read_text() + Path(project_destination).write_text(replace_section_value(project, "rendering", + "renderer/rendering_method." + FEATURE, '"gl_compatibility"')) + + +def build(args): + common.require(sys.platform == "darwin", "A genuine macOS/Xcode host with its iPhoneOS SDK is required") + import SCons + common.require(tuple(map(int, SCons.__version__.split(".")[:2])) >= (4, 4), "Godot requires SCons >= 4.4") + original, output, root = args.input_template.resolve(strict=True), args.output_template.resolve(), args.output_dir.resolve() + common.require(original != output, "The official input template must remain unchanged") + common.require("OSXCROSS_IOS" not in os.environ, "Use the genuine selected Xcode SDK") + root.mkdir(parents=True, exist_ok=True) + source = root / "godot-source" + common.require(not source.exists(), "Use a fresh output directory for a real device source build") + sdk = common.command(["xcrun", "--sdk", "iphoneos", "--show-sdk-path"]) + compiler = common.command(["xcrun", "--sdk", "iphoneos", "--find", "clang"]) + evidence = {"status": "building", "source_url": common.SOURCE_URL, "source_sha": common.SOURCE_SHA, + "input_template_sha256": common.digest(original), "sdk_path": sdk, + "sdk_version": common.command(["xcrun", "--sdk", "iphoneos", "--show-sdk-version"]), + "compiler": compiler, "compiler_version": common.command([compiler, "--version"]), + "xcode_version": common.command(["xcodebuild", "-version"]), + "renderer_profile": FEATURE, "signing": "not_run", "device_gameplay": "not_run"} + report = root / "evidence.json" + report.write_text(json.dumps(evidence, indent=2) + "\n") + try: + evidence["input_libraries"] = verify_template(original) + common.command(["git", "init", "--quiet", source]) + common.command(["git", "-C", source, "fetch", "--depth=1", common.SOURCE_URL, common.SOURCE_SHA]) + common.command(["git", "-C", source, "checkout", "--quiet", "--detach", "FETCH_HEAD"]) + common.require(common.command(["git", "-C", source, "rev-parse", "HEAD"]) == common.SOURCE_SHA, "Godot source pin mismatch") + toolchain = Path(compiler).resolve(strict=True).parents[2] + common.require((toolchain / "usr/bin/clang++").is_file(), "Selected compiler lacks its real Apple toolchain") + invocation = [sys.executable, "-m", "SCons", *BUILD_FLAGS, + "APPLE_SDK_PATH=" + sdk, "APPLE_TOOLCHAIN_PATH=" + str(toolchain), "-j" + str(args.jobs)] + evidence["build_command"] = invocation + print("Real Godot iPhoneOS build:", shlex.join(invocation), flush=True) + with (root / "source-build.log").open("w") as log: + subprocess.run(invocation, cwd=source, stdout=log, stderr=subprocess.STDOUT, check=True) + replacements, libraries = {}, {} + for library in common.LIBRARIES: + archive = source / f"bin/{library}.ios.template_debug.arm64.a" + identities = common.archive_identity(archive, expected_platform=2) + common.require(len(identities) == 1 and identities[0]["architecture"] == "arm64", f"Wrong source build: {archive}") + replacements[member(library)] = archive + libraries[library] = {"sha256": common.digest(archive), "objects": identities[0]["objects"], "platform": 2} + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(prefix="ios-device-template-", suffix=".zip", dir=output.parent, delete=False) as temporary: + staged = Path(temporary.name) + try: + with zipfile.ZipFile(original) as template: + common.replace_template_archives(template, staged, replacements, + allowed_members={member(library) for library in common.LIBRARIES}) + verify_template(staged, require_sdl_free=True) + os.replace(staged, output) + finally: + staged.unlink(missing_ok=True) + evidence.update(status="built_and_verified", output_template_sha256=common.digest(output), libraries=libraries, + untouched_template_members="all_sha256_identical") + except BaseException as error: + evidence.update(status="failed", error=str(error)) + raise + finally: + report.write_text(json.dumps(evidence, indent=2) + "\n") + print(json.dumps(evidence, indent=2)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="action", required=True) + builder = commands.add_parser("build") + builder.add_argument("--input-template", type=Path, required=True) + builder.add_argument("--output-template", type=Path, required=True) + builder.add_argument("--output-dir", type=Path, required=True) + builder.add_argument("--jobs", type=int, default=3) + checker = commands.add_parser("verify") + checker.add_argument("--template", type=Path, required=True) + checker.add_argument("--exported-dir", type=Path) + checker.add_argument("--nm", type=Path, help="Existing real LLVM nm for thin-archive validation on Linux") + profile = commands.add_parser("profile") + profile.add_argument("--presets-source", type=Path, required=True) + profile.add_argument("--presets-destination", type=Path, required=True) + profile.add_argument("--project-source", type=Path, required=True) + profile.add_argument("--project-destination", type=Path, required=True) + profile.add_argument("--template", type=Path, required=True) + args = parser.parse_args() + try: + if args.action == "build": + common.require(args.jobs > 0, "Build jobs must be positive") + build(args) + elif args.action == "verify": + print(json.dumps(verify_template(args.template, args.exported_dir, require_sdl_free=True, nm=args.nm), indent=2)) + else: + patch_device_profile(args.presets_source, args.presets_destination, args.project_source, + args.project_destination, args.template) + except (OSError, ValueError, KeyError, zipfile.BadZipFile, subprocess.CalledProcessError) as error: + print(f"Godot device template failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/build_godot_ios_simulator_template.py b/tools/build_godot_ios_simulator_template.py index 94a879f54..a02c696bd 100644 --- a/tools/build_godot_ios_simulator_template.py +++ b/tools/build_godot_ios_simulator_template.py @@ -47,7 +47,7 @@ def require(condition, message): raise ValueError(message) -def macho_identity(data): +def macho_identity(data, expected_platform=7): require(len(data) >= 32 and data[:4] == bytes.fromhex("cffaedfe"), "Expected a genuine little-endian Mach-O64 object (LTO bitcode is not accepted)") cpu, _, kind, count, commands_size = struct.unpack_from("\n", "Expected a genuine static ar archive") end, count, architectures = offset + length, 0, set() @@ -87,23 +87,24 @@ def thin_archive(stream, offset, length): name, data = data[:name_size].rstrip(b"\0").decode("utf-8"), data[name_size:] if name not in ("/", "//", "/SYM64/") and not name.startswith("__.SYMDEF"): try: - architectures.add(macho_identity(data)) + architectures.add(macho_identity(data, expected_platform)) except ValueError as error: raise ValueError(f"{name}: {error}") from error count += 1 if size % 2: require(stream.read(1) == b"\n", "Invalid static archive alignment") require(stream.tell() == end and count > 0 and len(architectures) == 1, - "Each archive slice must contain nonempty objects of exactly one Simulator architecture") - return {"architecture": next(iter(architectures)), "objects": count, "platform": 7} + "Each archive slice must contain nonempty objects of exactly one Apple architecture") + return {"architecture": next(iter(architectures)), "objects": count, "platform": expected_platform} -def archive_identity(path): +def archive_identity(path, expected_platform=7): + require(expected_platform in (2, 7), "Only actual iPhoneOS/Simulator archives are supported") path = Path(path) with path.open("rb") as stream: magic = stream.read(4) if magic == b"!", False), bytes.fromhex("bebafeca"): ("<", False), @@ -127,13 +128,29 @@ def archive_identity(path): require(all(a[1] <= b[0] for a, b in zip(ranges, ranges[1:])), "Overlapping archive slices") identities = [] for cpu, offset, length in slices: - identity = thin_archive(stream, offset, length) + identity = thin_archive(stream, offset, length, expected_platform) require(identity["architecture"] == ARCHITECTURES[cpu], "CPU header disagrees with archive objects") identities.append(identity) require(len({item["architecture"] for item in identities}) == len(identities), "Duplicate CPU slices") return identities +def reject_sdl_symbols(path, arch=None, nm=None): + if nm: + require(arch is None, "Explicit LLVM nm checks use a genuine thin archive") + arguments = [str(nm), "--defined-only", "--extern-only", str(path)] + else: + require(sys.platform == "darwin", "Checking a rebuilt arm64 engine requires real Xcode nm") + arguments = ["xcrun", "nm", "-gU"] + if arch: + arguments.extend(("-arch", arch)) + arguments.append(str(path)) + listing = command(arguments) + forbidden = {"_SDL_Init", "_SDL_GetError", "_SDL_GetGamepads"} + actual = {line.split()[-1] for line in listing.splitlines() if line.split()} + require(not actual & forbidden, f"Godot's SDL3 conflicts with force-loaded host SDL2: {sorted(actual & forbidden)}") + + def member(library): return f"{library}.ios.debug.xcframework/{SIMULATOR}/{library}.a" @@ -162,6 +179,7 @@ def verify_template(template, arch, exported=None): if arch == "arm64": require(actual == set(info["SupportedArchitectures"]) == {"arm64", "x86_64"}, f"Simulator metadata differs from real CPU slices: {framework}") + reject_sdl_symbols(path, arch="arm64") library_digest = digest(path) if exported: actual_path = Path(exported) / f"Aether{suffix}.xcframework/{SIMULATOR}/{library}.a" @@ -206,10 +224,13 @@ def command(arguments, *, cwd=None): return result.stdout.strip() -def replace_template_archives(template, destination, replacements): +def replace_template_archives(template, destination, replacements, allowed_members=None): require(len(template.namelist()) == len(set(template.namelist())), "Duplicate template ZIP members") - require(set(replacements) <= {member(library) for library in LIBRARIES}, - "Only the debug engine/camera Simulator archives may be replaced") + simulator_members = {member(library) for library in LIBRARIES} + device_members = {f"{library}.ios.debug.xcframework/ios-arm64/{library}.a" for library in LIBRARIES} + allowed = simulator_members if allowed_members is None else set(allowed_members) + require(allowed <= simulator_members | device_members and set(replacements) <= allowed, + "Only the explicitly selected debug engine/camera archives may be replaced") with zipfile.ZipFile(destination, "w") as patched: patched.comment = template.comment for info in template.infolist(): diff --git a/tools/godot_ios_device_template/README.md b/tools/godot_ios_device_template/README.md new file mode 100644 index 000000000..374299cfb --- /dev/null +++ b/tools/godot_ios_device_template/README.md @@ -0,0 +1,55 @@ +# Real Ren'Py iPhoneOS debug template + +The official Godot 4.7.2 device/debug archive contains strong SDL3 symbols, +including `SDL_Init` and `SDL_GetError`. This application force-loads its existing +SDL2 host libraries. The dedicated Ren'Py device profile therefore builds a +real Godot engine without the SDL3 input driver; native iOS display, touch and +keyboard handling remain enabled and Ren'Py retains its real SDL2 runtime. + +`build_godot_ios_device_template.py` uses official commit +`ed1daf0bf001b61586d9930840f2f1394092c079`, the selected cloud Xcode iPhoneOS +SDK and its real Apple compiler. Its explicit options are `platform=ios`, +`target=template_debug`, `arch=arm64`, `ios_simulator=no`, `vulkan=no`, +`metal=no`, `opengl3=yes`, `generate_bundle=no`, and `sdl=no`. No alternate SDK, +Vulkan SDK, new license acceptance, signing identity or device access is used. + +The helper checks every real archive object for device platform 2 and arm64, +then replaces only the copied ZIP's two device/debug engine/camera members. +Real native symbol inspection also rejects the host's SDL collision symbols in +the rebuilt archives. The original official template is accepted only as a +source-build input, never as a validated SDL-free device export. +All Simulator, release and metadata members retain their original content +hashes. `evidence.json` records the source pin, actual compiler/SDK, exact +command, archive/template hashes and the actual source-build result. Source +and bundle validation cannot prove installation or phone gameplay. + +The opt-in profile binds the copied `iOS Debug` preset to the exact new template, +adds only `renpy_ios_device_gles` and temporarily adds the corresponding +Compatibility rendering override to `project.godot`. It does not use a +Simulator feature or alter the ordinary device renderer. Export inputs must +be restored on success or failure by the application build script. + +With a fresh output directory on a cloud Mac using existing licensed Xcode: + +```bash +python3 tools/build_godot_ios_device_template.py build \ + --input-template "$GODOT_EXPORT_TEMPLATE" \ + --output-template "$RUNNER_TEMP/godot-renpy-device/ios.zip" \ + --output-dir "$RUNNER_TEMP/godot-renpy-device" --jobs 3 +export GODOT_EXPORT_TEMPLATE="$RUNNER_TEMP/godot-renpy-device/ios.zip" +export AETHERKIRI_RENPY_IOS_DEVICE_GLES=ON +export AETHERKIRI_ENABLE_RENPY=ON +bash scripts/build_ios.sh debug --package-ipa +``` + +The opt-in flag requires a Debug device build with Ren'Py enabled. The script +backs up and restores all three export input files, checks the real exported +device archives against the verified template, then performs actual unsigned +`xcodebuild` packaging. The resulting unsigned IPA still requires authorized +signing before installation; this workflow does not establish phone gameplay. + +`test_godot_ios_device_template.py` inspects the real official device archives, +compiles real Apple-target objects to reject the wrong device/Simulator +platform, checks a full actual ZIP roundtrip, and uses genuine Godot 4.7.2 to +export/load an iOS PCK. The PCK check establishes the custom feature and +selected renderer setting; it does not execute an iOS renderer or Ren'Py. diff --git a/tools/godot_ios_device_template/profile_acceptance.gd b/tools/godot_ios_device_template/profile_acceptance.gd new file mode 100644 index 000000000..bb5e27f22 --- /dev/null +++ b/tools/godot_ios_device_template/profile_acceptance.gd @@ -0,0 +1,15 @@ +extends SceneTree + +func _initialize() -> void: + var expected := OS.get_environment("EXPECT_RENPY_DEVICE_PROFILE") == "1" + var feature := OS.has_feature("renpy_ios_device_gles") + var base := str(ProjectSettings.get_setting("rendering/renderer/rendering_method")) + var selected := str(ProjectSettings.get_setting_with_override("rendering/renderer/rendering_method")) + var valid := feature == expected and base == "mobile" and not OS.has_feature("ios_simulator") + valid = valid and selected == ("gl_compatibility" if expected else "mobile") + print("DEVICE_PROFILE_EVIDENCE=" + JSON.stringify({ + "feature": feature, "base": base, "selected": selected, + "ios_simulator": OS.has_feature("ios_simulator"), "valid": valid, + "scope": "Real Godot project/PCK settings; no iOS graphics or gameplay execution" + })) + quit(0 if valid else 1) diff --git a/tools/test_godot_ios_device_template.py b/tools/test_godot_ios_device_template.py new file mode 100644 index 000000000..a8ef49eac --- /dev/null +++ b/tools/test_godot_ios_device_template.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Test real iPhoneOS archive identity and Godot's scoped device PCK profile. + +Godot source builds, signing and phone gameplay are not performed by this test. +""" +import argparse +import configparser +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import zipfile + +import build_godot_ios_device_template as device +import build_godot_ios_simulator_template as common + +REPO = Path(__file__).resolve().parents[1] + + +def rejected(action): + try: + action() + except ValueError: + return + raise AssertionError("An incompatible real device archive was accepted") + + +def config(path): + result = configparser.ConfigParser(interpolation=None) + result.read(path) + return {section: dict(result[section]) for section in result.sections()} + + +def run(arguments, environment, log): + result = subprocess.run(list(map(str, arguments)), env=environment, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + log.write_text(result.stdout) + common.require(result.returncode == 0, f"Actual command failed ({result.returncode}): {log}\n{result.stdout[-3000:]}") + return result.stdout + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--template", type=Path, required=True, help="Genuine official Godot4.7.2 ios.zip") + parser.add_argument("--godot", type=Path, required=True, help="Genuine Godot4.7.2 editor") + parser.add_argument("--clang", default=shutil.which("clang")) + parser.add_argument("--ar", default=shutil.which("llvm-ar")) + parser.add_argument("--nm", default=shutil.which("llvm-nm")) + parser.add_argument("--output-dir", type=Path) + args = parser.parse_args() + root = (args.output_dir or Path(tempfile.mkdtemp(prefix="godot-real-device-acceptance-"))).resolve() + root.mkdir(parents=True, exist_ok=True) + require = common.require + actual = device.verify_template(args.template) + require(all(item["slices"] == [{"architecture": "arm64", "objects": item["slices"][0]["objects"], "platform": 2}] + for item in actual.values()), "Original device archive identity mismatch") + with tempfile.TemporaryDirectory(prefix="godot-real-device-objects-") as temporary: + work = Path(temporary) + source = work / "object.c" + source.write_text("int archive_platform_acceptance(void) { return 2; }\n") + for label, target, sdk, expected in (("device", "arm64-apple-ios14.0", "iphoneos", 2), + ("simulator", "arm64-apple-ios14.0-simulator", "iphonesimulator", 7)): + obj, archive = work / (label + ".o"), work / (label + ".a") + if sys.platform == "darwin": + sdk_path = subprocess.check_output(["xcrun", "--sdk", sdk, "--show-sdk-path"], text=True).strip() + compiler, archiver = ["xcrun", "--sdk", sdk, "clang", "-isysroot", sdk_path], ["xcrun", "ar"] + else: + require(args.clang and args.ar, "Real LLVM clang/llvm-ar are required") + compiler, archiver = [args.clang], [args.ar, "--format=darwin"] + subprocess.run([*compiler, "-target", target, "-c", source, "-o", obj], check=True) + subprocess.run([*archiver, "rcs", archive, obj], check=True) + require(common.archive_identity(archive, expected_platform=expected)[0]["platform"] == expected, + "Real LLVM object platform mismatch") + rejected(lambda: common.archive_identity(archive, expected_platform=7 if expected == 2 else 2)) + common.reject_sdl_symbols(archive, nm=None if sys.platform == "darwin" else args.nm) + # The real official device engine's strong SDL3 symbols trigger the + # collision even though its platform and CPU metadata are valid. + require(sys.platform == "darwin" or args.nm, "Existing real LLVM nm is required on Linux") + rejected(lambda: device.verify_template(args.template, require_sdl_free=True, + nm=None if sys.platform == "darwin" else args.nm)) + with zipfile.ZipFile(args.template) as template: + camera = work / "original-device-camera.a" + camera.write_bytes(template.read(device.member("libgodot_camera"))) + copy = work / "roundtrip-device-ios.zip" + original_digest = common.digest(args.template) + allowed = {device.member(library) for library in common.LIBRARIES} + common.replace_template_archives(template, copy, {device.member("libgodot_camera"): camera}, allowed_members=allowed) + require(device.verify_template(copy) == actual, "Real device template roundtrip changed archive payloads") + require(common.digest(args.template) == original_digest, "Official input ZIP changed") + rejected(lambda: common.replace_template_archives(template, work / "invalid.zip", + {common.member("libgodot_camera"): camera}, allowed_members=allowed)) + project = root / "project" + project.mkdir(exist_ok=True) + original_project = REPO / "apps/godot_app/project.godot" + original_presets = REPO / "apps/godot_app/export_presets.cfg" + original_text = original_project.read_text() + rendering = original_text[original_text.index("[rendering]"):] + source_project = root / "original-project.godot" + source_project.write_text("config_version=5\n\n" + rendering) + shutil.copy2(REPO / "tools/godot_ios_device_template/profile_acceptance.gd", project / "profile_acceptance.gd") + device.patch_device_profile(original_presets, project / "export_presets.cfg", source_project, + project / "project.godot", args.template) + before, after = config(original_presets), config(project / "export_presets.cfg") + expected = {key: dict(value) for key, value in before.items()} + expected["preset.1"]["custom_features"] = json.dumps(device.FEATURE) + expected["preset.1.options"]["custom_template/debug"] = json.dumps(str(args.template.resolve())) + require(after == expected, "Device profile altered an unrelated preset or option") + new_rendering = (project / "project.godot").read_text() + require(new_rendering.replace('renderer/rendering_method.' + device.FEATURE + '="gl_compatibility"\n', '') + == source_project.read_text(), "Device profile altered the ordinary renderer configuration") + environment = dict(os.environ) + environment.pop("GODOT_EDITOR_CUSTOM_FEATURES", None) + for name in ("CACHE", "CONFIG", "DATA"): + environment["XDG_" + name + "_HOME"] = str(root / name.lower()) + version = run([args.godot, "--version"], environment, root / "version.log") + require("4.7.2.stable.official.ed1daf0bf" in version, "Actual editor does not match the pinned Godot source") + environment["EXPECT_RENPY_DEVICE_PROFILE"] = "0" + run([args.godot, "--headless", "--path", project, "--script", project / "profile_acceptance.gd"], + environment, root / "ordinary-settings.log") + pck = root / "device-profile.pck" + run([args.godot, "--headless", "--path", project, "--export-pack", "iOS Debug", pck], environment, root / "pack.log") + environment["EXPECT_RENPY_DEVICE_PROFILE"] = "1" + run([args.godot, "--headless", "--path", project, "--main-pack", pck, "--script", "res://profile_acceptance.gd"], + environment, root / "packed-settings.log") + report = {"status": "passed", "official_template_sha256": common.digest(args.template), "official_archives": actual, + "cross_platform_objects_rejected": True, "real_template_roundtrip": "all_untouched_member_hashes_identical", + "real_godot_pck_profile": "passed", "ordinary_renderer_unchanged": True, + "official_sdl3_collision_rejected": True, + "godot_device_source_build": "not_run", "device_gameplay": "not_run", "signing": "not_run"} + (root / "evidence.json").write_text(json.dumps(report, indent=2) + "\n") + print(json.dumps(report, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From f925b685ad8303748ce8c6c6d4c45e780bd196a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 02:19:13 +0000 Subject: [PATCH 304/331] ci(android): check existing Intel cloud HVF support --- .../workflows/renpy-android-hvf-preflight.yml | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 .github/workflows/renpy-android-hvf-preflight.yml diff --git a/.github/workflows/renpy-android-hvf-preflight.yml b/.github/workflows/renpy-android-hvf-preflight.yml new file mode 100644 index 000000000..277e118a6 --- /dev/null +++ b/.github/workflows/renpy-android-hvf-preflight.yml @@ -0,0 +1,111 @@ +name: Ren'Py Android Intel HVF Preflight + +on: + pull_request: + paths: + - '.github/workflows/renpy-android-hvf-preflight.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + intel-hvf: + name: Check existing Intel macOS Android hypervisor + runs-on: macos-15-intel + timeout-minutes: 5 + steps: + - name: Query existing official emulator and HVF + shell: bash + run: | + python3 - <<'PY' + import json + import os + import platform + import re + import subprocess + from pathlib import Path + + def bounded_output(value, limit): + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + value = value or "" + return {"output": value[:limit], "output_truncated": len(value) > limit} + + def probe(command, limit=1600): + result = {"command": command, "timeout_seconds": 45} + try: + completed = subprocess.run( + command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=45, check=False, + ) + result.update(returncode=completed.returncode) + result.update(bounded_output(completed.stdout, limit)) + except subprocess.TimeoutExpired as exc: + result.update(returncode=None, timed_out=True) + result.update(bounded_output(exc.stdout, limit)) + except OSError as exc: + result.update(returncode=None, error=str(exc)[:500]) + return result + + def hvf_is_usable(check): + if check.get("returncode") != 0 or check.get("output_truncated"): + return False + lines = [line.strip() for line in check.get("output", "").splitlines() if line.strip()] + try: + start = lines.index("accel:") + except ValueError: + return False + if len(lines) < start + 3 or lines[start + 1] != "0": + return False + description = lines[start + 2] + lower = description.lower() + if re.search(r"unavailable|unsupported|unusable|not\s+(?:available|installed|usable)|" + r"could\s+not|failed|disabled|error", lower): + return False + # Google's documented successful macOS output names the framework + # and OS version; other emulator versions explicitly say usable. + documented = re.fullmatch(r"Hypervisor\.Framework OS X Version \d+(?:\.\d+)*", description) + explicit = re.search(r"\b(?:hvf|hypervisor\.framework)\b", lower) and re.search( + r"\b(?:installed and usable|available and usable|is usable)\b", lower) + return bool(documented or explicit) + + def classify(result): + host = result["host"] + if host["system"] != "Darwin" or host["machine"] != "x86_64": + return "not_run", "This preflight requires the official Intel macOS runner." + if not result.get("emulator"): + return "not_run", "Existing ANDROID_HOME/emulator/emulator is unavailable; no tools were downloaded." + for name in ("hypervisor", "version", "acceleration"): + check = result["checks"][name] + if check.get("returncode") != 0: + return "failed", f"Actual {name} command failed or timed out; see its bounded output." + if result["checks"]["hypervisor"]["output"].strip() != "1": + return "failed", "The actual Intel runner does not expose kern.hv_support=1." + if not hvf_is_usable(result["checks"]["acceleration"]): + return "failed", "Google's actual acceleration report does not confirm usable HVF." + return "supported", "Google's actual acceleration check confirms usable HVF on Intel macOS." + + def main(): + result = { + "scope": "existing-intel-macos-hvf-capability", + "status": "not_run", + "host": {"system": platform.system(), "machine": platform.machine()}, + "avd_launched": False, "apk_installed": False, "gameplay_executed": False, + "checks": {}, + } + result["checks"]["hypervisor"] = probe(["/usr/sbin/sysctl", "-n", "kern.hv_support"], 500) + sdk = os.environ.get("ANDROID_HOME", "") + emulator = Path(sdk) / "emulator" / "emulator" if sdk else None + if emulator is not None and emulator.is_file() and os.access(emulator, os.X_OK): + result["emulator"] = str(emulator) + # -version is the official Android Emulator command-line flag. + result["checks"]["version"] = probe([str(emulator), "-version"], 1200) + result["checks"]["acceleration"] = probe([str(emulator), "-accel-check"]) + result["status"], result["reason"] = classify(result) + print(json.dumps(result, sort_keys=True)) + return {"supported": 0, "not_run": 2, "failed": 1}[result["status"]] + + if __name__ == "__main__": + raise SystemExit(main()) + PY From 8bad541ea631768707c58c3fde507b0f884c79c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 02:39:42 +0000 Subject: [PATCH 305/331] fix(ios): force-load each genuine RenPy runtime archive --- .github/workflows/renpy-ios-runtime-build.yml | 5 + scripts/build_ios.sh | 10 +- tools/test_renpy_ios_force_load.py | 147 ++++++++++++++++++ 3 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 tools/test_renpy_ios_force_load.py diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index 3e919856f..a0f24b6ee 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -20,6 +20,7 @@ on: - 'tools/godot_ios_simulator_template/**' - 'tools/build_godot_ios_device_template.py' - 'tools/test_godot_ios_device_template.py' + - 'tools/test_renpy_ios_force_load.py' - 'tools/godot_ios_device_template/**' - 'scripts/build_ios.sh' - 'bridge/renpy_runtime/mobile_launcher/**' @@ -237,6 +238,9 @@ jobs: echo "$(brew --prefix python@3.12)/libexec/bin" >> "$GITHUB_PATH" xcodebuild -version xcrun --sdk iphonesimulator --show-sdk-version + - name: Check actual Apple archive force-loading before the full app link + run: | + python3 tools/test_renpy_ios_force_load.py --output-dir "$RUNNER_TEMP/renpy-ios-force-load" - uses: actions/download-artifact@v6 with: name: renpy-ios-runtime @@ -324,6 +328,7 @@ jobs: out/renpy-ios-gameplay ${{ runner.temp }}/renpy-ios-simulator-build.log ${{ runner.temp }}/godot-arm64-simulator/evidence.json + ${{ runner.temp }}/renpy-ios-force-load/evidence.json if-no-files-found: warn retention-days: 7 - name: Archive the actual Simulator application preserving executable permissions diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 575d4ea00..499b02274 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -465,8 +465,10 @@ build_renios_runtime_framework() { for symbol in bootstrap bind_window init tick frame input pause resume shutdown text_input_state set_surface_size; do printf '_renpy_mobile_%s\n' "$symbol" >> "$export_list" done - local archives=() archive - while IFS= read -r archive; do archives+=("$archive"); done < <(collect_renios_archives "$prebuilt") + local force_load_args=() archive + while IFS= read -r archive; do + force_load_args+=(-Xlinker -force_load -Xlinker "$archive") + done < <(collect_renios_archives "$prebuilt") local minimum_flag="-miphoneos-version-min=${IOS_MIN_VERSION:-16.0}" [[ "$SIMULATOR" == true ]] && minimum_flag="-mios-simulator-version-min=${IOS_MIN_VERSION:-16.0}" prototype="$(renios_prototype_root)" @@ -490,9 +492,11 @@ build_renios_runtime_framework() { # Keep Ren'Py's Python/SDL/FFmpeg symbols inside a separate two-level # namespace. They must not replace the host's vcpkg library definitions. # The builtin Python extension table requires its archive objects to stay. + # Load each archive explicitly: modern Apple ld rejects -noall_load, and + # a global -all_load switch also affects unrelated libraries on this link. xcrun --sdk "$IOS_SDK" clang++ -arch "$arch" -isysroot "$sdk_path" "$minimum_flag" -dynamiclib \ "${support_objects[@]}" \ - -Wl,-all_load "${archives[@]}" -Wl,-noall_load \ + "${force_load_args[@]}" \ -Wl,-exported_symbols_list,"$export_list" \ -Wl,-install_name,"@rpath/$RENPY_FRAMEWORK_NAME.framework/$RENPY_FRAMEWORK_NAME" \ -Wl,-rpath,@loader_path/.. \ diff --git a/tools/test_renpy_ios_force_load.py b/tools/test_renpy_ios_force_load.py new file mode 100644 index 000000000..be8319cf7 --- /dev/null +++ b/tools/test_renpy_ios_force_load.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Link real Mach-O archives using the production Renios force-load arguments. + +Darwin uses the selected Xcode Simulator SDK and Apple linker. Linux can use +real LLVM tools to check archive loading semantics, without an Apple SDK. +Neither path builds an application or executes an iOS runtime. +""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import struct +import subprocess +import sys +import tempfile + +import build_godot_ios_simulator_template as archive_tools + +REPO = Path(__file__).resolve().parents[1] +ROOTS = ("librenpython.a", "librenpy.a", "libpython3.12.a", "libSDL2.a", "libSDL2_image.a") +EXCLUDED = ("libSDL2main.a", "libSDL2_test.a", "libSDL3main.a", "libSDL3_test.a", "libmockrt.a") + + +def run(command): + result = subprocess.run(list(map(str, command)), stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + if result.returncode: + raise RuntimeError(f"Actual command failed ({result.returncode}): {command}\n{result.stderr.decode()}") + return result.stdout + + +def production_arguments(prebuilt): + source = (REPO / "scripts/build_ios.sh").read_text() + functions = [] + for name in ("renios_archive_is_excluded", "collect_renios_archives"): + start = source.index(name + "() {\n") + end = source.index("\n}\n", start) + 3 + functions.append(source[start:end]) + framework = source[source.index("build_renios_runtime_framework() {\n"):] + start = framework.index(" local force_load_args=() archive\n") + end = framework.index(" local minimum_flag=", start) + actual_builder = framework[start:end] + link_start = framework.index(" xcrun --sdk \"$IOS_SDK\" clang++") + link_end = framework.index('\n -o "$RENPY_FRAMEWORK_OUTPUT/$RENPY_FRAMEWORK_NAME"', link_start) + actual_link = framework[link_start:link_end] + assert actual_link.count('"${force_load_args[@]}"') == 1, "Production link must use the checked argv array" + assert "-all_load" not in actual_link and "-noall_load" not in actual_link + shell = "set -euo pipefail\n" + "\n".join(functions) + "\n" + shell += 'arguments() {\n local prebuilt="$1"\n' + actual_builder + shell += ' printf "%s\\0" "${force_load_args[@]}"\n}\narguments "$1"\n' + output = run(["bash", "-c", shell, "force-load-regression", prebuilt]) + return [part.decode() for part in output.rstrip(b"\0").split(b"\0")] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--clang", default=shutil.which("clang")) + parser.add_argument("--ar", default=shutil.which("llvm-ar")) + parser.add_argument("--nm", default=shutil.which("llvm-nm")) + parser.add_argument("--linker", default=shutil.which("ld64.lld")) + parser.add_argument("--output-dir", type=Path) + args = parser.parse_args() + output = (args.output_dir or Path(tempfile.mkdtemp(prefix="renios-real-force-load-"))).resolve() + output.mkdir(parents=True, exist_ok=True) + prebuilt = output / "native archives, selected" + prebuilt.mkdir(exist_ok=True) + target = "arm64-apple-ios14.0-simulator" + if sys.platform == "darwin": + sdk = run(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"]).decode().strip() + compiler = ["xcrun", "--sdk", "iphonesimulator", "clang", "-isysroot", sdk, "-target", target] + archiver, nm = ["xcrun", "ar"], ["xcrun", "nm"] + version_command = ["xcrun", "--sdk", "iphonesimulator", "clang", "--version"] + linker_flags = [] + linker_kind = "selected_xcode_apple_linker" + else: + assert all((args.clang, args.ar, args.nm, args.linker)), "Actual clang/ar/nm/ld64.lld tools are required" + sdk = "not_used_no_apple_sdk" + compiler = [args.clang, "-target", target] + archiver, nm = [args.ar, "--format=darwin"], [args.nm] + version_command = [args.clang, "--version"] + # LLD selects the Darwin flavor from its invoked ld64.lld basename. + linker_flags = ["-fuse-ld=" + str(Path(args.linker).absolute()), "-Xlinker", "-arch", "-Xlinker", "arm64", + "-Xlinker", "-platform_version", "-Xlinker", "ios-simulator", "-Xlinker", "14.0", + "-Xlinker", "14.0"] + linker_kind = "real_llvm_ld64_lld_not_apple_linker" + symbols = {} + selected_names = (*ROOTS, "libdependency.a", "libregistration, optional.a") + for index, name in enumerate((*selected_names, *EXCLUDED, "ordinary.a")): + objects, registrations = [], [] + for member in range(2): + symbol = f"unreferenced_registration_{index}_{member}" + source, obj = output / f"registration-{index}-{member}.c", output / f"registration-{index}-{member}.o" + source.write_text(f"int {symbol}(void) {{ return {index + member}; }}\n") + run([*compiler, "-c", source, "-o", obj]) + objects.append(obj) + registrations.append("_" + symbol) + archive = prebuilt / name + run([*archiver, "rcs", archive, *objects]) + assert archive_tools.archive_identity(archive) == [{"architecture": "arm64", "objects": 2, "platform": 7}] + symbols[name] = registrations + nested = prebuilt / "nested" + nested.mkdir(exist_ok=True) + shutil.copy2(prebuilt / "ordinary.a", nested / "libnested.a") + actual = production_arguments(prebuilt) + expected_paths = [str(prebuilt / name) for name in (*ROOTS, *sorted(selected_names[len(ROOTS):]))] + assert len(actual) == 4 * len(expected_paths), actual + assert actual[::4] == ["-Xlinker"] * len(expected_paths) + assert actual[1::4] == ["-force_load"] * len(expected_paths) + assert actual[2::4] == ["-Xlinker"] * len(expected_paths) + assert actual[3::4] == expected_paths, actual + assert len(set(actual[3::4])) == len(expected_paths), "Each archive must be loaded once" + entry = output / "entry.c" + entry.write_text("int framework_entry(void) { return 0; }\n") + entry_object = output / "entry.o" + run([*compiler, "-c", entry, "-o", entry_object]) + exports = output / "exports.txt" + exports.write_text("_framework_entry\n") + common = [*compiler, "-dynamiclib", "-nostdlib", *linker_flags, entry_object] + for label, archives in (("control", expected_paths), ("force-loaded", actual)): + library = output / (label + ".dylib") + command = [*common, *archives, prebuilt / "ordinary.a", "-Xlinker", "-exported_symbols_list", + "-Xlinker", exports, "-o", library] + run(command) + data = library.read_bytes() + assert struct.unpack_from(" Date: Fri, 9 Oct 2026 02:48:46 +0000 Subject: [PATCH 306/331] ci(android): execute the genuine APK on Intel cloud HVF --- .github/workflows/build.yml | 67 +--- .../workflows/renpy-android-hvf-gameplay.yml | 182 +++++++++ tools/run_renpy_android_emulator_session.py | 371 ++++++++++++++++++ tools/test_renpy_android_emulator_session.py | 176 +++++++++ 4 files changed, 742 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/renpy-android-hvf-gameplay.yml create mode 100644 tools/run_renpy_android_emulator_session.py create mode 100644 tools/test_renpy_android_emulator_session.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 05bd8610d..d2ea50a88 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -493,7 +493,7 @@ jobs: android-renpy-gameplay: needs: android - runs-on: ubuntu-24.04 + runs-on: macos-15-intel timeout-minutes: 40 permissions: contents: read @@ -505,62 +505,21 @@ jobs: with: name: android-renpy-emulator-debug-apk path: ${{ runner.temp }}/renpy-apk - - name: Install the official emulator and x86_64 image - run: | - set -euo pipefail - mkdir -p "${RUNNER_TEMP}/renpy-android-device-evidence" - test "$(uname -m)" = x86_64 - sdkmanager="${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" - "$sdkmanager" --install emulator platform-tools 'system-images;android-35;google_apis;x86_64' - acceleration_result=0 - "${ANDROID_HOME}/emulator/emulator" -accel-check 2>&1 \ - | tee "${RUNNER_TEMP}/renpy-android-device-evidence/acceleration.txt" || acceleration_result=$? - # Use the supported runner's existing KVM access. Do not change device - # permissions/security settings to manufacture access when unavailable. - if [[ "$acceleration_result" != 0 || ! -r /dev/kvm || ! -w /dev/kvm ]]; then - python3 - "${RUNNER_TEMP}/renpy-android-device-evidence/result.json" <<'PY' - import json, pathlib, sys - pathlib.Path(sys.argv[1]).write_text(json.dumps({ - 'status': 'not_run', 'gameplay_verified': False, - 'blocker': 'The cloud runner has no usable existing KVM access for the supported Android emulator.', - 'required': 'A cloud x86_64 runner with provisioned readable/writable KVM access, or a connected cloud Android device.', - }, indent=2) + '\n') - PY - echo 'Android gameplay not_run: existing KVM access is unavailable; no permissions were changed.' >&2 - exit 1 - fi - printf 'no\n' | "${ANDROID_HOME}/cmdline-tools/latest/bin/avdmanager" create avd \ - --name aether-renpy-device --force --device pixel_2 \ - --package 'system-images;android-35;google_apis;x86_64' - echo 'hw.keyboard=no' >> "${HOME}/.android/avd/aether-renpy-device.avd/config.ini" - - name: Run actual APK gameplay through adb + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '21' + - name: Run actual APK gameplay with the existing Intel HVF accelerator run: | set -euo pipefail - export PATH="${ANDROID_HOME}/platform-tools:${ANDROID_HOME}/emulator:${PATH}" - evidence="${RUNNER_TEMP}/renpy-android-device-evidence" - emulator -avd aether-renpy-device -port 5554 -accel on \ - -no-window -no-audio -no-boot-anim -no-snapshot \ - -gpu swiftshader -memory 3072 -camera-front none -camera-back none \ - > "$evidence/emulator.log" 2>&1 & - emulator_pid=$! - trap 'adb -s emulator-5554 emu kill >/dev/null 2>&1 || true; kill "$emulator_pid" >/dev/null 2>&1 || true' EXIT - timeout 180 adb -s emulator-5554 wait-for-device - booted=false - for attempt in $(seq 1 120); do - if [[ "$(adb -s emulator-5554 shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == 1 ]]; then - booted=true - break - fi - sleep 2 - done - [[ "$booted" == true ]] || { echo 'Actual Android emulator failed to boot'; exit 1; } - adb -s emulator-5554 shell input keyevent 82 - adb -s emulator-5554 shell getprop ro.product.cpu.abilist | tee "$evidence/abi.txt" - python3 tools/run_renpy_android_device_acceptance.py \ + python3 tools/run_renpy_android_emulator_session.py --cloud-emulator \ --apk "${RUNNER_TEMP}/renpy-apk/Aether-emulator-debug.apk" \ - --package org.aetherkiri.renpy.debug \ - --serial emulator-5554 --cloud-device --stage-timeout 90 \ - --output-dir "$evidence" + --package org.aetherkiri.renpy.debug --boot-timeout 480 \ + --gameplay-timeout 900 --stage-timeout 90 \ + --output-dir "${RUNNER_TEMP}/renpy-android-device-evidence" - name: Preserve real device evidence even on failure if: always() uses: actions/upload-artifact@v6 diff --git a/.github/workflows/renpy-android-hvf-gameplay.yml b/.github/workflows/renpy-android-hvf-gameplay.yml new file mode 100644 index 000000000..3533c3d84 --- /dev/null +++ b/.github/workflows/renpy-android-hvf-gameplay.yml @@ -0,0 +1,182 @@ +name: Ren'Py Android Intel HVF Gameplay + +# Execute the previously compiled, validated 69cceb1 APK. The current checkout +# supplies the actual device harness; this job does not rebuild the APK. +on: + pull_request: + paths: + - '.github/workflows/renpy-android-hvf-gameplay.yml' + - 'tools/run_renpy_android_emulator_session.py' + - 'tools/test_renpy_android_emulator_session.py' + - 'tools/preflight_renpy_android_device.py' + - 'tools/run_renpy_android_device_acceptance.py' + - 'tools/validate_renpy_mobile_payload.py' + - 'tools/inspect_renpy_android_apk.py' + workflow_dispatch: + +permissions: + contents: read + actions: read + +concurrency: + group: renpy-android-hvf-gameplay-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + gameplay: + name: Actual validated x64 APK on Android 35 (Intel HVF) + runs-on: macos-15-intel + timeout-minutes: 40 + env: + APK_RUN: '37871746668' + APK_JOB: '113631187733' + APK_HEAD: '69cceb1c2509ad87461bc5461550cb430a00e415' + APK_ARTIFACT: '11590609797' + APK_ARTIFACT_DIGEST: 'sha256:7115ac90acb74631612f9eb11f23d43361f7dcd6c29861eff065f8e8c702f614' + APK_NATIVE_SHA256: '2ff2cd7e484a199a7d387704249b34b7a0ec6114a384931ed845ea25c1a2f3b5' + APK_SIGNER_SHA256: '0f56095ede9f28aa458e537160d88fea01ccb3ffe57cd56d57a661fe468b2586' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '21' + - name: Check session process boundaries (not gameplay) + run: python3 tools/test_renpy_android_emulator_session.py + - name: Verify successful APK compilation and immutable artifact metadata + env: + GH_TOKEN: ${{ github.token }} + HARNESS_PR_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + python3 - <<'PY' + import json, os, pathlib, subprocess, urllib.request + def get(path): + request = urllib.request.Request('https://api.github.com/repos/AetherKiri/Aether/' + path, + headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], + 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}) + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + run = get('actions/runs/' + os.environ['APK_RUN']) + assert run['status'] == 'completed' and run['conclusion'] == 'success', 'Pinned APK source run did not pass' + assert run['id'] == int(os.environ['APK_RUN']) and run['head_sha'] == os.environ['APK_HEAD'] + job = get('actions/jobs/' + os.environ['APK_JOB']) + assert job['run_id'] == run['id'] and job['head_sha'] == run['head_sha'] + assert job['name'] == 'Installable Debug APK (x86_64)' + assert job['status'] == 'completed' and job['conclusion'] == 'success' + for name in ('Compile the actual host and export the signed Debug APK', + 'Verify the actual APK payload and Debug signature', + 'Upload the validated installable APK'): + steps = [step for step in job['steps'] if step['name'] == name] + assert len(steps) == 1 and steps[0]['conclusion'] == 'success', name + artifact = get('actions/artifacts/' + os.environ['APK_ARTIFACT']) + assert artifact['id'] == int(os.environ['APK_ARTIFACT']) and not artifact['expired'] + assert artifact['name'] == 'renpy-installable-debug-x86_64' + assert artifact['workflow_run']['id'] == run['id'] and artifact['workflow_run']['head_sha'] == run['head_sha'] + assert artifact['digest'] == os.environ['APK_ARTIFACT_DIGEST'], 'Pinned ZIP metadata digest differs' + native_run = get('actions/runs/37866667365') + assert native_run['status'] == 'completed' and native_run['conclusion'] == 'success' + assert native_run['head_sha'] == '1967809558f4022aa781062bfe5a4f8e903c84d0' + native_artifact = get('actions/artifacts/11589199045') + assert native_artifact['id'] == 11589199045 and not native_artifact['expired'] + assert native_artifact['name'] == 'renpy-android-runtime' and native_artifact['workflow_run']['id'] == native_run['id'] + assert native_artifact['workflow_run']['head_sha'] == native_run['head_sha'] + assert native_artifact['digest'] == 'sha256:08537fa4c09a84d8226c50644127ab6918290a0a09f0720c878b465bce25014c' + evidence = pathlib.Path(os.environ['RUNNER_TEMP']) / 'renpy-android-device-evidence' + evidence.mkdir(exist_ok=True) + provenance = {'apk_source_run': run['id'], 'apk_source_job': job['id'], 'apk_source_head': run['head_sha'], + 'apk_artifact_id': artifact['id'], 'apk_artifact_zip_digest': artifact['digest'], + 'apk_compilation': 'passed_at_recorded_source_head', 'apk_compilation_in_this_job': 'not_run', + 'native_dependency_head': '1967809558f4022aa781062bfe5a4f8e903c84d0', + 'native_dependency_run': 37866667365, 'native_dependency_artifact': 11589199045, + 'native_dependency_digest': 'sha256:08537fa4c09a84d8226c50644127ab6918290a0a09f0720c878b465bce25014c', + 'harness_checkout_head': subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip(), + 'harness_pr_head': os.environ['HARNESS_PR_HEAD'], 'gameplay': 'not_run', + 'note': 'The official download action checks the ZIP against GitHub metadata. This is the validated prior APK, not a new compilation of the harness checkout.'} + (evidence / 'apk-provenance.json').write_text(json.dumps(provenance, indent=2) + '\n') + print(json.dumps(provenance, sort_keys=True)) + PY + - name: Download the exact validated APK through the official action + uses: actions/download-artifact@v6 + with: + artifact-ids: 11590609797 + run-id: 37871746668 + repository: AetherKiri/Aether + github-token: ${{ github.token }} + path: ${{ runner.temp }}/renpy-hvf-apk + - name: Verify actual x64 APK native bytes, private Python and compiled manifest + run: | + python3 - <<'PY' + import hashlib, json, os, pathlib, re, shlex, subprocess, sys, zipfile + sys.path.insert(0, str(pathlib.Path('tools').resolve())) + from inspect_renpy_android_apk import parse_badging, sdk_tool + root = pathlib.Path(os.environ['RUNNER_TEMP']) + evidence = root / 'renpy-android-device-evidence' + apks = list((root / 'renpy-hvf-apk').rglob('*.apk')) + assert len(apks) == 1 and apks[0].name == 'Aether-emulator-debug.apk', 'Expected exactly the validated x64 APK' + apk = apks[0] + with zipfile.ZipFile(apk) as archive: + names = archive.namelist() + assert len(names) == len(set(names)), 'Duplicate APK members' + abis = {name.split('/')[1] for name in names if name.startswith('lib/') and name.endswith('.so')} + assert abis == {'x86_64'}, 'Unexpected packaged APK ABI' + native_hash = hashlib.sha256(archive.read('lib/x86_64/librenpython.so')).hexdigest() + assert native_hash == os.environ['APK_NATIVE_SHA256'], 'Actual compiled native bytes differ' + sdk = pathlib.Path(os.environ['ANDROID_HOME']) + bins = sorted(sdk.glob('ndk/*/toolchains/llvm/prebuilt/darwin-x86_64/bin')) + readers = [path for path in bins if (path/'llvm-nm').is_file() and (path/'llvm-readelf').is_file()] + assert readers, 'Existing official NDK LLVM readers are missing; no new license will be accepted' + environment = dict(os.environ, RENPY_MOBILE_NM=shlex.join([str(readers[-1]/'llvm-nm')]), + RENPY_MOBILE_READELF=shlex.join([str(readers[-1]/'llvm-readelf')])) + with (evidence/'apk-payload-validation.log').open('w') as log: + subprocess.run([sys.executable, 'tools/validate_renpy_mobile_payload.py', '--platform', 'android', + '--abi', 'x86_64', '--apk', str(apk)], env=environment, stdout=log, + stderr=subprocess.STDOUT, timeout=180, check=True) + badging = subprocess.run([str(sdk_tool(sdk, 'build-tools/*/aapt2')), 'dump', 'badging', str(apk)], + capture_output=True, text=True, timeout=60, check=True) + (evidence/'apk-badging.log').write_text(badging.stdout) + manifest = parse_badging(badging.stdout) + assert manifest['package'] == 'org.aetherkiri.renpy.debug' and manifest['version_code'] == 4501 + assert manifest['min_sdk'] == 26 and manifest['target_sdk'] == 36 and manifest['debuggable'] + assert manifest['native_abis'] == ['x86_64'] + signer = subprocess.run([str(sdk_tool(sdk, 'build-tools/*/apksigner')), 'verify', '--verbose', + '--print-certs', str(apk)], capture_output=True, text=True, timeout=60, check=True) + (evidence/'apk-signature.log').write_text(signer.stdout + signer.stderr) + assert re.search(r'^Verified using v2 scheme .*: true\s*$', signer.stdout, re.MULTILINE), 'APK V2 signature did not verify' + assert re.findall(r'^Number of signers: (\d+)\s*$', signer.stdout, re.MULTILINE) == ['1'], 'APK must have exactly one signer' + certificate_lines = [line for line in signer.stdout.splitlines() if 'certificate SHA-256 digest:' in line] + certificates = [] + for line in certificate_lines: + match = re.fullmatch(r'(?:Signer #1 |V2 Signer: )certificate SHA-256 digest: ([0-9a-f]{64})\s*', line) + assert match, 'Unexpected signer or certificate scheme in actual APK verification' + certificates.append(match.group(1)) + assert certificates and set(certificates) == {os.environ['APK_SIGNER_SHA256']}, 'Actual APK signer differs' + provenance_path = evidence/'apk-provenance.json' + provenance = json.loads(provenance_path.read_text()) + provenance.update(apk_sha256=hashlib.sha256(apk.read_bytes()).hexdigest(), + actual_native_sha256=native_hash, actual_manifest=manifest, payload_validation='passed', + apk_signature_v2='verified', apk_signer_sha256=certificates[0]) + provenance_path.write_text(json.dumps(provenance, indent=2)+'\n') + with open(os.environ['GITHUB_ENV'], 'a') as stream: + stream.write('RENPY_HVF_APK=' + str(apk.resolve()) + '\n') + print(json.dumps({'actual_apk_sha256':provenance['apk_sha256'], 'actual_native_sha256':native_hash, + 'actual_manifest':manifest, 'gameplay':'not_run'}, sort_keys=True)) + PY + - name: Boot actual Android 35 and run full Ren'Py APK gameplay + run: | + python3 tools/run_renpy_android_emulator_session.py --cloud-emulator \ + --apk "$RENPY_HVF_APK" --package org.aetherkiri.renpy.debug \ + --boot-timeout 480 --gameplay-timeout 900 --stage-timeout 90 \ + --output-dir "$RUNNER_TEMP/renpy-android-device-evidence" + - name: Preserve actual Android gameplay evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: renpy-android-gameplay-evidence + path: ${{ runner.temp }}/renpy-android-device-evidence + if-no-files-found: warn + retention-days: 7 diff --git a/tools/run_renpy_android_emulator_session.py b/tools/run_renpy_android_emulator_session.py new file mode 100644 index 000000000..b4b3858f6 --- /dev/null +++ b/tools/run_renpy_android_emulator_session.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +"""Boot a real cloud Android emulator and optionally run the actual APK harness. + +Only existing KVM/HVF access and existing SDK license acceptance are used. +This never changes device permissions, accepts licenses or falls back to a +simulated provider. Session and original gameplay results remain separate. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import re +import signal +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +from preflight_renpy_android_device import Preflight, Unavailable +from run_renpy_android_device_acceptance import Failed, image_stats, png_pixels + + +IMAGE = "system-images;android-35;google_apis;x86_64" + + +def acceleration_usable(output: str, system: str) -> bool: + lines = [line.strip() for line in output.splitlines() if line.strip()] + try: + start = lines.index("accel:") + except ValueError: + return False + if len(lines) < start + 3 or lines[start + 1] != "0": + return False + description = lines[start + 2] + lower = description.lower() + if re.search(r"unavailable|unsupported|unusable|not\s+(?:available|installed|usable)|" + r"could\s+not|failed|disabled|error", lower): + return False + if system == "Darwin": + documented = re.fullmatch(r"Hypervisor\.Framework OS X Version \d+(?:\.\d+)*", description) + explicit = re.search(r"\b(?:hvf|hypervisor\.framework)\b", lower) and re.search( + r"\b(?:installed and usable|available and usable|is usable)\b", lower) + return bool(documented or explicit) + return system == "Linux" and bool(re.search(r"\bkvm\b", lower) + and re.search(r"\b(?:installed and usable|is usable)\b", lower)) + + +def owned_group_exists(process: subprocess.Popen) -> bool: + """The caller must have created this Popen with start_new_session=True.""" + try: + os.killpg(process.pid, 0) + except ProcessLookupError: + return False + return True + + +def wait_owned_group_exit(process: subprocess.Popen, grace: float) -> bool: + deadline = time.monotonic() + grace + while owned_group_exists(process): + if time.monotonic() >= deadline: + return False + time.sleep(min(0.05, max(0, deadline - time.monotonic()))) + return True + + +def terminate_owned_process(process: subprocess.Popen, grace: float = 10) -> int: + """Signal all members of our group and reap our direct child. + + The group can outlive its leader. Orphaned descendants are reaped by their + OS parent, so leader.wait() alone is insufficient to terminate that group. + """ + if owned_group_exists(process): + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + process.wait(timeout=grace) + except subprocess.TimeoutExpired: + pass + if not wait_owned_group_exit(process, grace): + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + return process.wait(timeout=grace) + + +def gameplay_result_matches(summary: dict, code: int, package: str, serial: str, apk_hash: str) -> bool: + return isinstance(summary, dict) and code == 0 and summary.get("status") == "passed" \ + and summary.get("source") == "actual-apk-adb-cloud-device" and summary.get("serial") == serial \ + and summary.get("package") == package and summary.get("apk_sha256") == apk_hash + + +class Session(Preflight): + def __init__(self, args: argparse.Namespace): + super().__init__(args) + self.harness_process = None + self.harness_log = None + self.result.update(scope="actual-cloud-android-emulator-session", + gameplay_verification="not_run", gameplay_attempted=False, + gameplay_verified=False, harness_started=False, + requested_gameplay=args.apk is not None) + + def gate_host(self, sdk: Path) -> Path: + system, machine = platform.system(), platform.machine() + if machine != "x86_64" or system not in ("Linux", "Darwin"): + raise Unavailable("An actual Linux x86_64/KVM or Intel macOS/HVF cloud runner is required") + if system == "Linux": + kvm = Path("/dev/kvm") + self.result["kvm"] = {"exists": kvm.exists(), "readable": os.access(kvm, os.R_OK), + "writable": os.access(kvm, os.W_OK)} + if kvm.exists(): + self.result["kvm"]["mode"] = oct(kvm.stat().st_mode & 0o777) + if not all(self.result["kvm"][key] for key in ("exists", "readable", "writable")): + raise Unavailable("Existing /dev/kvm read/write access is unavailable; no permissions were changed") + self.result["hypervisor"] = "KVM" + else: + support = self.command("host-hvf-support", ["/usr/sbin/sysctl", "-n", "kern.hv_support"]) + self.result["kern_hv_support"] = support.stdout.decode(errors="replace").strip() + if self.result["kern_hv_support"] != "1": + raise Unavailable("The actual Intel macOS runner does not expose kern.hv_support=1") + self.result["hypervisor"] = "HVF" + emulator = sdk / "emulator/emulator" + if not emulator.is_file() or not os.access(emulator, os.X_OK): + raise Unavailable(f"The existing official emulator is unavailable: {emulator}; no emulator was downloaded") + self.command("emulator-version", [str(emulator), "-version"], timeout=45) + check = self.command("acceleration", [str(emulator), "-accel-check"], check=False, timeout=45) + output = check.stdout.decode(errors="replace") + self.result["acceleration"] = {"returncode": check.returncode, "output": output[:1600], + "output_truncated": len(output) > 1600} + if check.returncode or len(output) > 1600 or not acceleration_usable(output, system): + raise Unavailable(f"Google's actual acceleration report does not confirm usable {self.result['hypervisor']}") + self.result["checks"].append("official-emulator-" + self.result["hypervisor"].lower() + "-usable") + print(json.dumps({"checkpoint": "actual-acceleration-usable", "hypervisor": self.result["hypervisor"], + "gameplay_verification": "not_run"}), flush=True) + return emulator + + def prepare_sdk(self, sdk: Path) -> Path: + manager, avdmanager = (sdk / "cmdline-tools/latest/bin" / name for name in ("sdkmanager", "avdmanager")) + for tool in (manager, avdmanager): + if not tool.is_file() or not os.access(tool, os.X_OK): + raise Unavailable(f"An existing official SDK tool is missing: {tool}") + # Inherited command() uses DEVNULL, not 'yes' or sdkmanager --licenses. + installed = self.command("official-sdk-install", [str(manager), "--install", "platform-tools", IMAGE], + timeout=600, check=False) + properties = sdk / "system-images/android-35/google_apis/x86_64/source.properties" + self.adb = sdk / "platform-tools/adb" + if installed.returncode or not properties.is_file() or not self.adb.is_file(): + detail = installed.stdout.decode(errors="replace")[-1800:] + if re.search(r"licen[cs]es?.*(?:not accepted|not been accepted)|" + r"(?:accept|review).*licen[cs]e", detail, re.IGNORECASE): + raise Unavailable("Official SDK installation is blocked by missing existing license acceptance; " + "no new license was accepted. See official-sdk-install.log: " + detail) + raise Unavailable(f"Official SDK installation did not provide the requested image/tools " + f"(exit {installed.returncode}); see official-sdk-install.log: {detail}") + (self.output / "system-image-source.properties").write_bytes(properties.read_bytes()) + self.command("adb-version", [str(self.adb), "version"]) + self.result.update(sdk_root=str(sdk), system_image=IMAGE) + self.environment["PATH"] = os.pathsep.join((str(sdk / "platform-tools"), str(sdk / "emulator"), + self.environment.get("PATH", ""))) + return avdmanager + + def boot(self, emulator: Path, avdmanager: Path, avd_root: Path) -> None: + avd_root.mkdir() + self.environment["ANDROID_AVD_HOME"] = str(avd_root) + name = "aether-renpy-cloud-session" + self.command("create-official-avd", [str(avdmanager), "create", "avd", "--name", name, "--force", + "--device", "pixel_2", "--package", IMAGE], input_data=b"no\n", timeout=120) + config = avd_root / f"{name}.avd/config.ini" + if not config.is_file(): + raise Unavailable("Official avdmanager did not create the requested AVD") + with config.open("a") as stream: + stream.write("\nhw.keyboard=no\n") + (self.output / "avd-config.ini").write_bytes(config.read_bytes()) + command = [str(emulator), "-avd", name, "-port", str(self.args.port), "-accel", "on", + "-gpu", "swiftshader", "-no-window", "-no-audio", "-no-boot-anim", "-no-snapshot", + "-memory", "3072", "-camera-front", "none", "-camera-back", "none"] + self.result["emulator_command"] = command + self.emulator_log = (self.output / "emulator.log").open("wb") + self.emulator_process = subprocess.Popen(command, stdout=self.emulator_log, stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, env=self.environment, start_new_session=True) + self.execution_started = True + self.result["execution_started"] = True + deadline = time.monotonic() + self.args.boot_timeout + self.device("adb-wait-for-device", "wait-for-device", timeout=min(180, self.args.boot_timeout)) + while time.monotonic() < deadline: + if self.emulator_process.poll() is not None: + raise Unavailable(f"Actual emulator exited before boot: {self.emulator_process.returncode}; see emulator.log") + remaining = deadline - time.monotonic() + completed = self.device("boot-completed", "shell", "getprop", "sys.boot_completed", + timeout=min(20, max(0.1, remaining))) + if completed.stdout.decode().strip() == "1": + break + time.sleep(min(2, max(0, deadline - time.monotonic()))) + else: + raise Unavailable(f"Actual emulator did not boot within {self.args.boot_timeout}s; see emulator.log") + self.device("unlock", "shell", "input", "keyevent", "82") + values = {} + for name in ("ro.build.fingerprint", "ro.product.cpu.abilist", "ro.build.version.sdk", "ro.opengles.version"): + values[name] = self.device("property-" + name, "shell", "getprop", name).stdout.decode().strip() + self.result["device_properties"] = values + if not values["ro.build.fingerprint"] or "x86_64" not in values["ro.product.cpu.abilist"].split(",") \ + or values["ro.build.version.sdk"] != "35": + raise Unavailable("The booted Android device is not the actual requested Android 35 x86_64 image") + if int(values["ro.opengles.version"] or "0") < 0x30000: + raise Unavailable("The actual Android device does not advertise required GLES 3.0") + surface = self.device("surfaceflinger", "shell", "dumpsys", "SurfaceFlinger").stdout.decode(errors="replace") + self.result["surfaceflinger_gles"] = [line.strip() for line in surface.splitlines() if "GLES:" in line] + if not self.result["surfaceflinger_gles"]: + raise Unavailable("Actual SurfaceFlinger did not report a GLES renderer; see surfaceflinger.log") + screenshot = self.output / "actual-android-screen.png" + screenshot.write_bytes(self.device("screen-capture", "exec-out", "screencap", "-p").stdout) + self.result["actual_os_screen"] = image_stats(png_pixels(screenshot)) + self.result["checks"].extend(("actual-android-boot-completed", "adb-real-device-properties", + "actual-surfaceflinger-gles", "actual-os-screen")) + print(json.dumps({"checkpoint": "actual-android-booted", "device_properties": values, + "surfaceflinger_gles": self.result["surfaceflinger_gles"], + "gameplay_verification": "not_run"}), flush=True) + + def gameplay(self) -> None: + output = self.output / "gameplay" + self.result["harness_result_path"] = "gameplay/result.json" + command = [sys.executable, str(Path(__file__).with_name("run_renpy_android_device_acceptance.py")), + "--apk", str(self.args.apk.resolve()), "--package", self.args.package, + "--serial", self.result["serial"], "--adb", str(self.adb), "--cloud-device", + "--stage-timeout", str(self.args.stage_timeout), "--overall-timeout", str(self.args.gameplay_timeout), + "--output-dir", str(output)] + self.result["harness_command"] = command + self.harness_log = (self.output / "gameplay-harness.log").open("wb") + self.harness_process = subprocess.Popen(command, stdout=self.harness_log, stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, env=self.environment, start_new_session=True) + self.result.update(harness_started=True, gameplay_attempted=True, gameplay_executed=None) + print(json.dumps({"checkpoint": "actual-apk-harness-started", "deadline_seconds": self.args.gameplay_timeout, + "gameplay_verified": False}), flush=True) + try: + code = self.harness_process.wait(timeout=self.args.gameplay_timeout) + self.result["harness_returncode"] = code + except subprocess.TimeoutExpired as exc: + self.result.update(harness_timed_out=True, gameplay_verification="failed") + self.result["harness_returncode"] = terminate_owned_process(self.harness_process) + raise Unavailable(f"Actual APK harness exceeded its whole-process {self.args.gameplay_timeout}s deadline") from exc + finally: + if self.harness_process.poll() is None or not wait_owned_group_exit(self.harness_process, 1): + self.result["harness_forced_cleanup"] = True + terminate_owned_process(self.harness_process) + else: + self.harness_process.wait(timeout=10) + self.result["harness_group_still_present"] = owned_group_exists(self.harness_process) + self.harness_log.close() + self.harness_log = None + self.harness_process = None + if self.result.get("harness_forced_cleanup"): + self.result["gameplay_verification"] = "failed" + raise Unavailable("The actual harness left its owned process group running; forced cleanup was required") + summary_path = output / "result.json" + if not summary_path.is_file(): + raise Unavailable("Actual APK harness did not produce its gameplay result; see gameplay-harness.log") + summary = json.loads(summary_path.read_text()) + if not isinstance(summary, dict): + raise Unavailable("Actual APK harness result is not a JSON object") + self.result["gameplay_result"] = summary + self.result["gameplay_verification"] = summary.get("status", "failed") + if summary.get("status") == "not_run": + self.result["gameplay_executed"] = False + apk_hash = hashlib.sha256(self.args.apk.read_bytes()).hexdigest() + if not gameplay_result_matches(summary, code, self.args.package, self.result["serial"], apk_hash): + self.result["gameplay_verification"] = "not_run" if summary.get("status") == "not_run" else "failed" + raise Unavailable(f"Actual APK gameplay did not pass (harness exit {code}, result {summary.get('status')}); " + "see gameplay/result.json and gameplay-harness.log") + self.result.update(gameplay_executed=True, gameplay_verified=True, apk_installed=True, apk_sha256=apk_hash) + self.result["checks"].append("actual-apk-renpy-gameplay-harness-passed") + + def stop(self, *, require_clean: bool) -> None: + process = self.emulator_process + if process is None: + if self.emulator_log: + self.emulator_log.close() + self.emulator_log = None + return + clean = False + try: + if process.poll() is None: + try: + killed = self.device("stop-emulator", "emu", "kill", check=False, timeout=20) + clean = killed.returncode == 0 and process.wait(timeout=25) == 0 + except (Unavailable, subprocess.TimeoutExpired): + clean = False + if process.poll() is None or not wait_owned_group_exit(process, 1): + self.result["emulator_forced_cleanup"] = True + clean = False + terminate_owned_process(process) + else: + process.wait(timeout=10) + self.result.update(emulator_exit_code=process.returncode, emulator_stopped_cleanly=clean) + self.result["emulator_group_still_present"] = owned_group_exists(process) + finally: + if self.emulator_log: + self.emulator_log.close() + self.emulator_log = None + if process.poll() is not None: + self.emulator_process = None + if require_clean and not clean: + raise Unavailable("The actual emulator did not stop cleanly; see stop-emulator.log/emulator.log") + + def run(self) -> None: + if not self.args.cloud_emulator: + raise Unavailable("--cloud-emulator is required; only a selected cloud runner is authorized") + if self.args.apk is not None and not self.args.apk.is_file(): + raise Unavailable(f"The actual installable APK is missing: {self.args.apk}") + sdk = Path(self.args.sdk_root).resolve() if self.args.sdk_root else None + if sdk is None or not sdk.is_dir(): + raise Unavailable("Existing official ANDROID_HOME/ANDROID_SDK_ROOT is unavailable") + emulator = self.gate_host(sdk) + manager = self.prepare_sdk(sdk) + with tempfile.TemporaryDirectory(prefix="renpy-cloud-emulator-session-") as temporary: + try: + self.boot(emulator, manager, Path(temporary) / "avd") + if self.args.apk is not None: + self.gameplay() + self.stop(require_clean=True) + self.result["checks"].append("actual-emulator-stopped") + self.result["status"] = "passed" + finally: + self.stop(require_clean=False) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cloud-emulator", action="store_true") + parser.add_argument("--apk", type=Path, help="Omit only for actual Android boot preflight without gameplay") + parser.add_argument("--package", default="org.aetherkiri.renpy.debug") + parser.add_argument("--sdk-root", default=os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT", "")) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--boot-timeout", type=int, default=480) + parser.add_argument("--gameplay-timeout", type=int, default=900) + parser.add_argument("--stage-timeout", type=int, default=90) + parser.add_argument("--port", type=int, default=5554) + args = parser.parse_args() + if not 60 <= args.boot_timeout <= 600 or not 60 <= args.gameplay_timeout <= 1200 \ + or not 5 <= args.stage_timeout <= 180 or not 5554 <= args.port <= 5682 or args.port % 2 \ + or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_.]+", args.package): + parser.error("Invalid timeout, package, or even emulator port") + def interrupted(signum, _frame): + raise KeyboardInterrupt(f"Cloud emulator session interrupted by signal {signum}") + signal.signal(signal.SIGTERM, interrupted) + session = Session(args) + code = 0 + try: + session.run() + except (Unavailable, Failed, OSError, ValueError, KeyboardInterrupt, subprocess.TimeoutExpired) as exc: + session.result.update(status="failed" if session.execution_started else "not_run", blocker=str(exc)) + code = 1 if session.execution_started else 2 + finally: + try: + session.stop(require_clean=False) + except (Unavailable, OSError, subprocess.TimeoutExpired) as exc: + session.result.update(status="failed", cleanup_error=str(exc)) + code = 1 + (session.output / "result.json").write_text(json.dumps(session.result, indent=2) + "\n") + print(json.dumps(session.result, indent=2)) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_renpy_android_emulator_session.py b/tools/test_renpy_android_emulator_session.py new file mode 100644 index 000000000..1d9bb5672 --- /dev/null +++ b/tools/test_renpy_android_emulator_session.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Validate acceleration/result boundaries and real subprocess cleanup. + +No Android emulator, APK, Ren'Py provider or gameplay is executed by this test. +""" + +import copy +import os +import selectors +import signal +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +from run_renpy_android_emulator_session import ( + Session, Unavailable, acceleration_usable, gameplay_result_matches, owned_group_exists, terminate_owned_process, +) + + +class AccelerationBoundaryTests(unittest.TestCase): + def test_google_macos_and_linux_reports_are_distinct(self): + mac = "accel:\n0\nHypervisor.Framework OS X Version 15.7\naccel\n" + linux = "accel:\n0\nKVM (version 12) is installed and usable.\naccel\n" + self.assertTrue(acceleration_usable(mac, "Darwin")) + self.assertTrue(acceleration_usable(linux, "Linux")) + self.assertFalse(acceleration_usable(mac, "Linux")) + self.assertFalse(acceleration_usable(linux, "Darwin")) + + def test_unavailable_and_malformed_hvf_reports_are_rejected(self): + for description in ("HVF unusable", "HVF is not usable", "HVF is unsupported", "HVF is unavailable", + "HVF is not installed and usable", "WHPX is installed and usable"): + self.assertFalse(acceleration_usable(f"accel:\n0\n{description}\naccel\n", "Darwin"), description) + self.assertFalse(acceleration_usable("accel:\n1\nHypervisor.Framework OS X Version 15.7\naccel\n", "Darwin")) + self.assertFalse(acceleration_usable("Hypervisor.Framework OS X Version 15.7\n", "Darwin")) + + def test_linux_kvm_permission_denial_stops_before_any_sdk_command(self): + # Unit input boundary only: no KVM access or command is performed. + session = object.__new__(Session) + session.result = {"checks": []} + with patch("run_renpy_android_emulator_session.platform.system", return_value="Linux"), \ + patch("run_renpy_android_emulator_session.platform.machine", return_value="x86_64"), \ + patch("run_renpy_android_emulator_session.os.access", return_value=False), \ + patch.object(session, "command", side_effect=AssertionError("SDK must not run")): + with self.assertRaisesRegex(Unavailable, "no permissions were changed"): + session.gate_host(Path("/not-a-sdk")) + + def test_arm_mac_stops_before_any_hypervisor_or_sdk_command(self): + session = object.__new__(Session) + session.result = {"checks": []} + with patch("run_renpy_android_emulator_session.platform.system", return_value="Darwin"), \ + patch("run_renpy_android_emulator_session.platform.machine", return_value="arm64"), \ + patch.object(session, "command", side_effect=AssertionError("SDK must not run")): + with self.assertRaisesRegex(Unavailable, "Intel macOS/HVF"): + session.gate_host(Path("/not-a-sdk")) + + +class HarnessResultBoundaryTests(unittest.TestCase): + def test_return_code_and_all_actual_result_identities_are_required(self): + summary = {"status": "passed", "source": "actual-apk-adb-cloud-device", "serial": "emulator-5554", + "package": "org.aetherkiri.renpy.debug", "apk_sha256": "test-only-hash"} + arguments = ("org.aetherkiri.renpy.debug", "emulator-5554", "test-only-hash") + self.assertTrue(gameplay_result_matches(summary, 0, *arguments)) + self.assertFalse(gameplay_result_matches(summary, 1, *arguments)) + self.assertFalse(gameplay_result_matches([], 0, *arguments)) + for name in summary: + wrong = copy.deepcopy(summary) + wrong[name] = "different-test-input" + self.assertFalse(gameplay_result_matches(wrong, 0, *arguments), name) + + +class RealProcessCleanupTests(unittest.TestCase): + def start(self, ignore_term=False): + code = "import signal,time; " + if ignore_term: + code += "signal.signal(signal.SIGTERM, signal.SIG_IGN); " + code += "print('ready', flush=True); time.sleep(120)" + process = subprocess.Popen([sys.executable, "-u", "-c", code], stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True) + self.addCleanup(self.cleanup, process) + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ) + self.assertTrue(selector.select(timeout=5), "Real child did not become ready") + self.assertEqual(process.stdout.readline().strip(), b"ready") + return process + + @staticmethod + def cleanup(process): + if process.poll() is None or owned_group_exists(process): + terminate_owned_process(process, grace=0.1) + if process.stdout: + process.stdout.close() + + def assert_reaped(self, process): + self.assertIsNotNone(process.poll()) + with self.assertRaises(ProcessLookupError): + os.kill(process.pid, 0) + + def test_term_stops_and_reaps_an_actual_owned_child(self): + process = self.start() + self.assertEqual(terminate_owned_process(process, grace=2), -signal.SIGTERM) + self.assert_reaped(process) + + def test_term_ignoring_actual_child_is_killed_after_deadline_and_reaped(self): + process = self.start(ignore_term=True) + self.assertEqual(terminate_owned_process(process, grace=0.1), -signal.SIGKILL) + self.assert_reaped(process) + + def start_with_grandchild(self, exit_leader=False): + temporary = tempfile.TemporaryDirectory(prefix="owned-process-grandchild-") + self.addCleanup(temporary.cleanup) + pid_file = Path(temporary.name) / "child.pid" + child_code = ("import os,signal,sys,time; from pathlib import Path; " + "signal.signal(signal.SIGTERM, signal.SIG_IGN); " + "Path(sys.argv[1]).write_text(str(os.getpid())); time.sleep(120)") + leader_code = ("import subprocess,sys,time; from pathlib import Path; " + f"child=subprocess.Popen([sys.executable,'-c',{child_code!r},{str(pid_file)!r}]); " + "deadline=time.monotonic()+5\n" + f"while not Path({str(pid_file)!r}).exists() and time.monotonic() Date: Fri, 9 Oct 2026 02:53:20 +0000 Subject: [PATCH 307/331] test(ios): link archive regression with the genuine SDK runtime --- tools/test_renpy_ios_force_load.py | 33 +++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/tools/test_renpy_ios_force_load.py b/tools/test_renpy_ios_force_load.py index be8319cf7..879cf121a 100644 --- a/tools/test_renpy_ios_force_load.py +++ b/tools/test_renpy_ios_force_load.py @@ -53,6 +53,26 @@ def production_arguments(prebuilt): return [part.decode() for part in output.rstrip(b"\0").split(b"\0")] +def linked_identity(data): + assert struct.unpack_from("= 8 and position + size <= end, "Invalid actual Mach-O load command" + if command == 0x32: # LC_BUILD_VERSION + platforms.append(struct.unpack_from(" Date: Fri, 9 Oct 2026 03:19:08 +0000 Subject: [PATCH 308/331] fix(android): preserve truthful cloud boot and cleanup diagnostics --- tools/run_renpy_android_emulator_session.py | 227 ++++++++++++++++--- tools/test_renpy_android_emulator_session.py | 210 ++++++++++++++++- 2 files changed, 409 insertions(+), 28 deletions(-) diff --git a/tools/run_renpy_android_emulator_session.py b/tools/run_renpy_android_emulator_session.py index b4b3858f6..fd6322057 100644 --- a/tools/run_renpy_android_emulator_session.py +++ b/tools/run_renpy_android_emulator_session.py @@ -26,6 +26,49 @@ IMAGE = "system-images;android-35;google_apis;x86_64" +SENSITIVE_DIAGNOSTIC = re.compile( + r"auth|token|credentials?|password|passwd|secret|jwt|grpc|api[_-]?key|" + r"[a-z][a-z0-9+.-]*://|www\.|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", + re.IGNORECASE, +) + + +def bounded_diagnostic(raw: bytes | str, *, max_chars: int = 4096, max_lines: int = 32) -> dict: + """Keep a small sanitized tail of only this session's actual output.""" + text = raw.decode(errors="replace") if isinstance(raw, bytes) else raw + text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", text) + lines = text.splitlines() + selected = lines[-max_lines:] + output = [] + redacted = 0 + for line in selected: + line = re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", line) + if SENSITIVE_DIAGNOSTIC.search(line): + output.append("[redacted sensitive diagnostic line]") + redacted += 1 + else: + output.append(line[:512]) + joined = "\n".join(output) + return {"output": joined[-max_chars:], "redacted_lines": redacted, + "truncated": len(lines) > max_lines or any(len(line) > 512 for line in selected) + or len(joined) > max_chars} + + +def diagnostic_file_tail(path: Path, *, read_limit: int = 65536) -> dict: + # Never read a previous artifact or an unbounded emulator/kernel log. + try: + with path.open("rb") as stream: + size = stream.seek(0, os.SEEK_END) + stream.seek(max(0, size - read_limit)) + raw = stream.read(read_limit) + if size > read_limit: + # A cut line may have lost its sensitive header. Drop it whole. + raw = raw.partition(b"\n")[2] + result = bounded_diagnostic(raw) + result["truncated"] |= size > read_limit + return result + except OSError as exc: + return {"unavailable": True, **bounded_diagnostic(str(exc), max_chars=256, max_lines=1)} def acceleration_usable(output: str, system: str) -> bool: @@ -50,22 +93,36 @@ def acceleration_usable(output: str, system: str) -> bool: and re.search(r"\b(?:installed and usable|is usable)\b", lower)) -def owned_group_exists(process: subprocess.Popen) -> bool: +def owned_group_state(process: subprocess.Popen) -> str: """The caller must have created this Popen with start_new_session=True.""" + if getattr(process, "_renpy_group_permission_denied", False): + return "permission_denied" try: os.killpg(process.pid, 0) except ProcessLookupError: - return False - return True + return "absent" + except PermissionError: + # Unknown is never absent. Do not retry a denied probe or signal. + process._renpy_group_permission_denied = True + return "permission_denied" + return "present" + + +def owned_group_exists(process: subprocess.Popen) -> bool: + return owned_group_state(process) != "absent" def wait_owned_group_exit(process: subprocess.Popen, grace: float) -> bool: deadline = time.monotonic() + grace - while owned_group_exists(process): + while True: + state = owned_group_state(process) + if state == "absent": + return True + if state == "permission_denied": + return False if time.monotonic() >= deadline: return False time.sleep(min(0.05, max(0, deadline - time.monotonic()))) - return True def terminate_owned_process(process: subprocess.Popen, grace: float = 10) -> int: @@ -74,20 +131,26 @@ def terminate_owned_process(process: subprocess.Popen, grace: float = 10) -> int The group can outlive its leader. Orphaned descendants are reaped by their OS parent, so leader.wait() alone is insufficient to terminate that group. """ - if owned_group_exists(process): + if owned_group_state(process) == "present": try: os.killpg(process.pid, signal.SIGTERM) except ProcessLookupError: pass + except PermissionError: + process._renpy_group_permission_denied = True try: process.wait(timeout=grace) except subprocess.TimeoutExpired: pass - if not wait_owned_group_exit(process, grace): + if owned_group_state(process) == "permission_denied" and process.poll() is None: + raise Unavailable("Owned process-group probe/signal permission was denied; no permission retry was made") + if not wait_owned_group_exit(process, grace) and owned_group_state(process) != "permission_denied": try: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: pass + except PermissionError: + process._renpy_group_permission_denied = True return process.wait(timeout=grace) @@ -102,11 +165,66 @@ def __init__(self, args: argparse.Namespace): super().__init__(args) self.harness_process = None self.harness_log = None + self.boot_started = None + self.next_boot_progress = 0 self.result.update(scope="actual-cloud-android-emulator-session", gameplay_verification="not_run", gameplay_attempted=False, gameplay_verified=False, harness_started=False, requested_gameplay=args.apk is not None) + def boot_progress(self, phase: str, *, response: subprocess.CompletedProcess | None = None) -> None: + now = time.monotonic() + self.result["boot_elapsed_seconds"] = round(now - self.boot_started, 1) + self.result["boot_phase"] = phase + if response is not None: + self.result["last_boot_response"] = {"returncode": response.returncode, + **bounded_diagnostic(response.stdout, max_chars=256, max_lines=2)} + if now >= self.next_boot_progress: + print(json.dumps({"checkpoint": "actual-android-boot-progress", "phase": phase, + "elapsed_seconds": self.result["boot_elapsed_seconds"], + "serial": self.result["serial"], + "emulator_returncode": self.emulator_process.poll(), + "last_boot_response": self.result.get("last_boot_response"), + "gameplay_verification": "not_run"}), flush=True) + self.next_boot_progress = now + 30 + + def diagnostic_command(self, *parts: str, timeout: int = 3, max_chars: int = 1024) -> dict: + started = time.monotonic() + try: + completed = subprocess.run([str(self.adb), "-s", self.result["serial"], *parts], + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + env=self.environment, timeout=timeout) + result = {"returncode": completed.returncode, + **bounded_diagnostic(completed.stdout, max_chars=max_chars, max_lines=20)} + except subprocess.TimeoutExpired as exc: + result = {"timed_out": True, **bounded_diagnostic(exc.stdout or b"", max_chars=max_chars, max_lines=20)} + except OSError as exc: + result = {"unavailable": True, **bounded_diagnostic(str(exc), max_chars=256, max_lines=1)} + result["elapsed_seconds"] = round(time.monotonic() - started, 1) + return result + + def boot_failure_diagnostics(self) -> None: + """Read the currently owned AVD before stop; never dump its environment.""" + started = time.monotonic() + diagnostics = {"checkpoint": "actual-android-boot-failure-diagnostics", + "boot_elapsed_seconds": round(started - self.boot_started, 1), + "phase": self.result.get("boot_phase"), "serial": self.result["serial"], + "emulator_returncode": self.emulator_process.poll(), + "last_boot_response": self.result.get("last_boot_response"), + "gameplay_verification": "not_run", + "emulator_log_tail": diagnostic_file_tail(self.output / "emulator.log")} + diagnostics["adb_state"] = self.diagnostic_command("get-state") + diagnostics["boot_properties"] = { + name: self.diagnostic_command("shell", "getprop", name, max_chars=256) + for name in ("sys.boot_completed", "dev.bootcomplete", "init.svc.bootanim", "ro.build.version.sdk") + } + # Read at most 80 current warning/error entries, without clearing logcat. + diagnostics["logcat_warning_tail"] = self.diagnostic_command( + "logcat", "-d", "-t", "80", "-v", "brief", "*:W", timeout=5, max_chars=3072) + diagnostics["diagnostic_elapsed_seconds"] = round(time.monotonic() - started, 1) + self.result["boot_failure_diagnostics"] = diagnostics + print(json.dumps(diagnostics), flush=True) + def gate_host(self, sdk: Path) -> Path: system, machine = platform.system(), platform.machine() if machine != "x86_64" or system not in ("Linux", "Darwin"): @@ -181,25 +299,46 @@ def boot(self, emulator: Path, avdmanager: Path, avd_root: Path) -> None: command = [str(emulator), "-avd", name, "-port", str(self.args.port), "-accel", "on", "-gpu", "swiftshader", "-no-window", "-no-audio", "-no-boot-anim", "-no-snapshot", "-memory", "3072", "-camera-front", "none", "-camera-back", "none"] + # Official boot diagnostics only; acceleration, graphics and timeout stay unchanged. + # https://developer.android.com/studio/run/emulator-commandline + command.append("-show-kernel") self.result["emulator_command"] = command self.emulator_log = (self.output / "emulator.log").open("wb") self.emulator_process = subprocess.Popen(command, stdout=self.emulator_log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, env=self.environment, start_new_session=True) self.execution_started = True self.result["execution_started"] = True - deadline = time.monotonic() + self.args.boot_timeout - self.device("adb-wait-for-device", "wait-for-device", timeout=min(180, self.args.boot_timeout)) + self.boot_started = time.monotonic() + deadline = self.boot_started + self.args.boot_timeout + adb_deadline = self.boot_started + min(180, self.args.boot_timeout) + while time.monotonic() < adb_deadline: + self.boot_progress("waiting-for-adb") + try: + self.device("adb-wait-for-device", "wait-for-device", + timeout=min(30, max(0.1, adb_deadline - time.monotonic()))) + break + except Unavailable as exc: + if not isinstance(exc.__cause__, subprocess.TimeoutExpired): + raise + else: + raise Unavailable("Actual ADB did not connect within its existing boot wait deadline; see emulator.log") while time.monotonic() < deadline: + self.boot_progress("waiting-for-sys.boot_completed") if self.emulator_process.poll() is not None: raise Unavailable(f"Actual emulator exited before boot: {self.emulator_process.returncode}; see emulator.log") remaining = deadline - time.monotonic() completed = self.device("boot-completed", "shell", "getprop", "sys.boot_completed", - timeout=min(20, max(0.1, remaining))) + timeout=min(20, max(0.1, remaining)), check=False) + self.boot_progress("waiting-for-sys.boot_completed", response=completed) + if completed.returncode: + raise Unavailable(f"Actual ADB boot property read failed (exit {completed.returncode}); see boot-completed.log") if completed.stdout.decode().strip() == "1": + self.result["boot_completed"] = True break time.sleep(min(2, max(0, deadline - time.monotonic()))) else: raise Unavailable(f"Actual emulator did not boot within {self.args.boot_timeout}s; see emulator.log") + self.result["boot_phase"] = "validating-booted-device" self.device("unlock", "shell", "input", "keyevent", "82") values = {} for name in ("ro.build.fingerprint", "ro.product.cpu.abilist", "ro.build.version.sdk", "ro.opengles.version"): @@ -246,18 +385,26 @@ def gameplay(self) -> None: self.result["harness_returncode"] = terminate_owned_process(self.harness_process) raise Unavailable(f"Actual APK harness exceeded its whole-process {self.args.gameplay_timeout}s deadline") from exc finally: - if self.harness_process.poll() is None or not wait_owned_group_exit(self.harness_process, 1): - self.result["harness_forced_cleanup"] = True - terminate_owned_process(self.harness_process) - else: - self.harness_process.wait(timeout=10) - self.result["harness_group_still_present"] = owned_group_exists(self.harness_process) - self.harness_log.close() - self.harness_log = None - self.harness_process = None - if self.result.get("harness_forced_cleanup"): + try: + if self.harness_process.poll() is None or not wait_owned_group_exit(self.harness_process, 1): + self.result["harness_forced_cleanup"] = True + terminate_owned_process(self.harness_process) + else: + self.harness_process.wait(timeout=10) + finally: + group_state = owned_group_state(self.harness_process) + self.result["harness_group_state"] = group_state + self.result["harness_group_still_present"] = group_state != "absent" + if group_state == "permission_denied": + self.result.update(harness_cleanup_permission_denied=True, gameplay_verification="failed") + self.harness_log.close() + self.harness_log = None + if self.harness_process.poll() is not None: + self.harness_process = None + if self.result.get("harness_forced_cleanup") or self.result.get("harness_group_state") != "absent" \ + or self.result.get("harness_cleanup_permission_denied"): self.result["gameplay_verification"] = "failed" - raise Unavailable("The actual harness left its owned process group running; forced cleanup was required") + raise Unavailable("The actual harness process group is not confirmed absent; cleanup could not be verified") summary_path = output / "result.json" if not summary_path.is_file(): raise Unavailable("Actual APK harness did not produce its gameplay result; see gameplay-harness.log") @@ -297,15 +444,20 @@ def stop(self, *, require_clean: bool) -> None: terminate_owned_process(process) else: process.wait(timeout=10) - self.result.update(emulator_exit_code=process.returncode, emulator_stopped_cleanly=clean) - self.result["emulator_group_still_present"] = owned_group_exists(process) finally: + group_state = owned_group_state(process) + self.result.update(emulator_exit_code=process.returncode, + emulator_stopped_cleanly=clean and group_state == "absent") + self.result["emulator_group_state"] = group_state + self.result["emulator_group_still_present"] = group_state != "absent" + if group_state == "permission_denied": + self.result["emulator_cleanup_permission_denied"] = True if self.emulator_log: self.emulator_log.close() self.emulator_log = None if process.poll() is not None: self.emulator_process = None - if require_clean and not clean: + if require_clean and not self.result["emulator_stopped_cleanly"]: raise Unavailable("The actual emulator did not stop cleanly; see stop-emulator.log/emulator.log") def run(self) -> None: @@ -319,15 +471,35 @@ def run(self) -> None: emulator = self.gate_host(sdk) manager = self.prepare_sdk(sdk) with tempfile.TemporaryDirectory(prefix="renpy-cloud-emulator-session-") as temporary: + primary_failure = None try: - self.boot(emulator, manager, Path(temporary) / "avd") + try: + self.boot(emulator, manager, Path(temporary) / "avd") + except (Unavailable, Failed, OSError, ValueError, subprocess.TimeoutExpired) as boot_error: + self.result["boot_failure"] = bounded_diagnostic(str(boot_error), max_chars=1024, max_lines=4) + if self.execution_started: + self.result["boot_failed"] = True + try: + self.boot_failure_diagnostics() + except (OSError, ValueError) as diagnostic_error: + self.result["boot_failure_diagnostic_error"] = bounded_diagnostic(str(diagnostic_error)) + raise if self.args.apk is not None: self.gameplay() self.stop(require_clean=True) self.result["checks"].append("actual-emulator-stopped") self.result["status"] = "passed" + except (Unavailable, Failed, OSError, ValueError, KeyboardInterrupt, subprocess.TimeoutExpired) as exc: + primary_failure = exc + raise finally: - self.stop(require_clean=False) + try: + self.stop(require_clean=False) + except (Unavailable, OSError, subprocess.TimeoutExpired) as cleanup_error: + self.result.update(status="failed", cleanup_error=bounded_diagnostic( + str(cleanup_error), max_chars=1024, max_lines=4)) + if primary_failure is None: + raise def main() -> int: @@ -360,7 +532,8 @@ def interrupted(signum, _frame): try: session.stop(require_clean=False) except (Unavailable, OSError, subprocess.TimeoutExpired) as exc: - session.result.update(status="failed", cleanup_error=str(exc)) + session.result["status"] = "failed" + session.result.setdefault("cleanup_error", bounded_diagnostic(str(exc), max_chars=1024, max_lines=4)) code = 1 (session.output / "result.json").write_text(json.dumps(session.result, indent=2) + "\n") print(json.dumps(session.result, indent=2)) diff --git a/tools/test_renpy_android_emulator_session.py b/tools/test_renpy_android_emulator_session.py index 1d9bb5672..f5cdb29be 100644 --- a/tools/test_renpy_android_emulator_session.py +++ b/tools/test_renpy_android_emulator_session.py @@ -5,6 +5,9 @@ """ import copy +import hashlib +import io +import json import os import selectors import signal @@ -17,7 +20,8 @@ from unittest.mock import patch from run_renpy_android_emulator_session import ( - Session, Unavailable, acceleration_usable, gameplay_result_matches, owned_group_exists, terminate_owned_process, + Session, Unavailable, acceleration_usable, bounded_diagnostic, diagnostic_file_tail, + gameplay_result_matches, owned_group_exists, owned_group_state, terminate_owned_process, wait_owned_group_exit, ) @@ -72,6 +76,137 @@ def test_return_code_and_all_actual_result_identities_are_required(self): self.assertFalse(gameplay_result_matches(wrong, 0, *arguments), name) +class CurrentDiagnosticBoundaryTests(unittest.TestCase): + def test_sensitive_lines_are_removed_and_current_kernel_failure_is_retained(self): + raw = ("\x1b[31mKernel panic: fixture boot evidence\x1b[0m\n" + "Authorization: Bearer private-auth-value\naccess_token=private-token-value\n" + "credential=private-credential-value\ngrpc server key=private-grpc-value\n" + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzZWNyZXQifQ.privateJwtSignature\n" + "Download https://example.invalid/private-url-value\n" + "SystemServer: fixture service failed\n") + result = bounded_diagnostic(raw) + self.assertEqual(result["redacted_lines"], 6) + self.assertIn("Kernel panic: fixture boot evidence", result["output"]) + self.assertIn("SystemServer: fixture service failed", result["output"]) + self.assertNotIn("\x1b", result["output"]) + for secret in ("private-", "eyJ", "example.invalid", "Bearer"): + self.assertNotIn(secret, result["output"]) + + def test_tail_limits_do_not_reveal_a_sensitive_line_with_its_header_cut_off(self): + with tempfile.TemporaryDirectory() as temporary: + log = Path(temporary) / "only-current-fixture.log" + log.write_bytes(b"Authorization: " + b"fixture-private-secret" * 5000 + b"\nKernel panic: fixture\n") + result = diagnostic_file_tail(log, read_limit=1024) + self.assertTrue(result["truncated"]) + self.assertEqual(result["output"], "Kernel panic: fixture") + limited = bounded_diagnostic("\n".join(str(i) + "x" * 800 for i in range(100)), + max_chars=300, max_lines=3) + self.assertLessEqual(len(limited["output"]), 300) + self.assertLessEqual(len(limited["output"].splitlines()), 3) + self.assertTrue(limited["truncated"]) + + def test_real_ordinary_diagnostic_child_is_bounded_and_its_partial_output_redacted(self): + # A real ordinary executable validates timeout/capture, not an Android SDK. + with tempfile.TemporaryDirectory() as temporary: + fixture = Path(temporary) / "controlled-diagnostic-process" + fixture.write_text(f"#!{sys.executable}\nimport time\n" + "print('token=fixture-private-value', flush=True)\n" + "print('fixture-child-ready', flush=True)\ntime.sleep(60)\n") + fixture.chmod(0o700) + session = object.__new__(Session) + session.adb = fixture + session.environment = dict(os.environ) + session.result = {"serial": "emulator-5554"} + started = time.monotonic() + result = session.diagnostic_command("get-state", timeout=1) + self.assertLess(time.monotonic() - started, 4) + self.assertTrue(result["timed_out"]) + self.assertIn("fixture-child-ready", result["output"]) + self.assertNotIn("fixture-private-value", result["output"]) + self.assertEqual(list(Path(temporary).iterdir()), [fixture], "No raw diagnostic log may be created") + + def test_progress_is_throttled_and_last_real_command_response_is_bounded(self): + # Input/timing boundary only; no emulator is run. + session = object.__new__(Session) + session.boot_started, session.next_boot_progress = 100, 0 + session.result = {"serial": "emulator-5554"} + process = subprocess.CompletedProcess(["fixture"], 0, b"fixture-response") + with patch.object(session, "emulator_process", create=True) as emulator, \ + patch("run_renpy_android_emulator_session.time.monotonic", + side_effect=[100, 101, 129, 130, 159, 160]), patch("sys.stdout", new_callable=io.StringIO) as console: + emulator.poll.return_value = None + for _ in range(6): + session.boot_progress("fixture-progress", response=process) + rows = [json.loads(line) for line in console.getvalue().splitlines()] + self.assertEqual([row["elapsed_seconds"] for row in rows], [0, 30, 60]) + self.assertEqual(session.result["last_boot_response"]["output"], "fixture-response") + self.assertTrue(all(row["gameplay_verification"] == "not_run" for row in rows)) + + def test_boot_failure_diagnostics_precede_stop_and_do_not_mask_original_failure(self): + # Control-flow boundary only; no SDK, AVD or device command is executed. + with tempfile.TemporaryDirectory() as temporary: + args = type("Args", (), {"output_dir": Path(temporary), "port": 5554, "apk": None, + "cloud_emulator": True, "sdk_root": temporary})() + session = Session(args) + events = [] + def fail_boot(*_): + session.execution_started = True + raise Unavailable("fixture original boot failure") + def fail_diagnostics(): + events.append("diagnostics-before-stop") + raise OSError("token=fixture-secret") + with patch.object(session, "gate_host", return_value=Path("fixture-emulator")), \ + patch.object(session, "prepare_sdk", return_value=Path("fixture-manager")), \ + patch.object(session, "boot", side_effect=fail_boot), \ + patch.object(session, "boot_failure_diagnostics", side_effect=fail_diagnostics), \ + patch.object(session, "stop", side_effect=lambda **_: events.append("stop")): + with self.assertRaisesRegex(Unavailable, "fixture original boot failure"): + session.run() + self.assertEqual(events, ["diagnostics-before-stop", "stop"]) + self.assertTrue(session.result["boot_failed"]) + self.assertEqual(session.result["gameplay_verification"], "not_run") + self.assertNotIn("fixture-secret", session.result["boot_failure_diagnostic_error"]["output"]) + + def test_boot_failure_and_cleanup_denial_are_preserved_separately(self): + with tempfile.TemporaryDirectory() as temporary: + args = type("Args", (), {"output_dir": Path(temporary), "port": 5554, "apk": None, + "cloud_emulator": True, "sdk_root": temporary})() + session = Session(args) + events = [] + def fail_boot(*_): + session.execution_started = True + raise Unavailable("fixture original boot failure") + def fail_cleanup(**_): + events.append("stop") + raise PermissionError("fixture cleanup permission denied") + with patch.object(session, "gate_host", return_value=Path("fixture-emulator")), \ + patch.object(session, "prepare_sdk", return_value=Path("fixture-manager")), \ + patch.object(session, "boot", side_effect=fail_boot), \ + patch.object(session, "boot_failure_diagnostics", side_effect=lambda: events.append("diagnostics")), \ + patch.object(session, "stop", side_effect=fail_cleanup): + with self.assertRaisesRegex(Unavailable, "fixture original boot failure"): + session.run() + self.assertEqual(events, ["diagnostics", "stop"]) + self.assertEqual(session.result["boot_failure"]["output"], "fixture original boot failure") + self.assertEqual(session.result["cleanup_error"]["output"], "fixture cleanup permission denied") + self.assertEqual(session.result["status"], "failed") + self.assertEqual(session.result["gameplay_verification"], "not_run") + + def test_cleanup_failure_without_primary_still_fails_the_session(self): + with tempfile.TemporaryDirectory() as temporary: + args = type("Args", (), {"output_dir": Path(temporary), "port": 5554, "apk": None, + "cloud_emulator": True, "sdk_root": temporary})() + session = Session(args) + with patch.object(session, "gate_host", return_value=Path("fixture-emulator")), \ + patch.object(session, "prepare_sdk", return_value=Path("fixture-manager")), \ + patch.object(session, "boot"), \ + patch.object(session, "stop", side_effect=[None, PermissionError("fixture cleanup permission denied")]): + with self.assertRaisesRegex(PermissionError, "fixture cleanup permission denied"): + session.run() + self.assertEqual(session.result["status"], "failed") + self.assertEqual(session.result["cleanup_error"]["output"], "fixture cleanup permission denied") + + class RealProcessCleanupTests(unittest.TestCase): def start(self, ignore_term=False): code = "import signal,time; " @@ -171,6 +306,79 @@ def test_session_stop_refuses_clean_status_with_already_exited_leader_and_live_g self.assert_reaped(process) self.assert_descendant_cannot_run(child_pid) + def exited_child(self): + process = subprocess.Popen([sys.executable, "-c", "pass"], stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + self.addCleanup(self.cleanup, process) + self.assertEqual(process.wait(timeout=5), 0) + return process + + def test_eperm_probe_is_unknown_and_not_retried_or_signalled(self): + process = self.exited_child() + with patch("run_renpy_android_emulator_session.os.killpg", side_effect=PermissionError("fixture EPERM")) as probe: + self.assertEqual(owned_group_state(process), "permission_denied") + self.assertTrue(owned_group_exists(process), "Unknown may not be treated as absent") + self.assertFalse(wait_owned_group_exit(process, 0.1)) + self.assertEqual(terminate_owned_process(process, grace=0.1), 0) + probe.assert_called_once_with(process.pid, 0) + self.assert_reaped(process) + + def test_denied_term_signal_is_not_retried_with_kill(self): + process = self.exited_child() + def signal_boundary(_pgid, signum): + if signum: + raise PermissionError("fixture signal EPERM") + with patch("run_renpy_android_emulator_session.os.killpg", side_effect=signal_boundary) as probe: + self.assertEqual(terminate_owned_process(process, grace=0.1), 0) + self.assertEqual(owned_group_state(process), "permission_denied") + self.assertEqual([call.args[1] for call in probe.call_args_list], [0, signal.SIGTERM]) + self.assert_reaped(process) + + def test_actual_caller_cannot_report_clean_when_group_probe_permission_is_unknown(self): + process = self.exited_child() + session = object.__new__(Session) + session.emulator_process, session.emulator_log = process, None + session.result = {"blocker": "fixture original boot failure", "gameplay_verification": "not_run"} + with patch("run_renpy_android_emulator_session.os.killpg", side_effect=PermissionError("fixture EPERM")) as probe: + with self.assertRaisesRegex(Unavailable, "did not stop cleanly"): + session.stop(require_clean=True) + probe.assert_called_once_with(process.pid, 0) + self.assertFalse(session.result["emulator_stopped_cleanly"]) + self.assertTrue(session.result["emulator_cleanup_permission_denied"]) + self.assertEqual(session.result["emulator_group_state"], "permission_denied") + self.assertEqual(session.result["blocker"], "fixture original boot failure") + self.assertEqual(session.result["gameplay_verification"], "not_run") + self.assert_reaped(process) + + def test_final_unknown_harness_group_cannot_be_upgraded_by_a_passing_summary_fixture(self): + # Exercise the real caller with an ordinary exited child and local input + # fixtures only. No APK, Android SDK or gameplay harness is executed. + process = self.exited_child() + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) + apk_fixture = output / "not-an-apk" + apk_fixture.write_bytes(b"controlled input boundary fixture, not an APK") + gameplay = output / "gameplay" + gameplay.mkdir() + summary = {"status": "passed", "source": "actual-apk-adb-cloud-device", "serial": "emulator-5554", + "package": "org.aetherkiri.renpy.debug", "apk_sha256": hashlib.sha256(apk_fixture.read_bytes()).hexdigest()} + (gameplay / "result.json").write_text(json.dumps(summary)) + args = type("Args", (), {"output_dir": output, "port": 5554, "apk": apk_fixture, + "package": summary["package"], "stage_timeout": 90, "gameplay_timeout": 900})() + session = Session(args) + session.adb = Path("no-sdk-executed") + with patch("run_renpy_android_emulator_session.subprocess.Popen", return_value=process), \ + patch("run_renpy_android_emulator_session.owned_group_state", + side_effect=["absent", "permission_denied"]), patch("sys.stdout", new_callable=io.StringIO): + with self.assertRaisesRegex(Unavailable, "not confirmed absent"): + session.gameplay() + self.assertTrue(session.result["harness_cleanup_permission_denied"]) + self.assertEqual(session.result["gameplay_verification"], "failed") + self.assertFalse(session.result["gameplay_verified"]) + self.assertNotIn("gameplay_result", session.result, "Unknown cleanup must stop acceptance before reading the summary") + self.assertEqual(json.loads((gameplay / "result.json").read_text()), summary) + self.assert_reaped(process) + if __name__ == "__main__": unittest.main() From 6620994054b00f23077cfa1061d9ee69e09d7760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 03:35:28 +0000 Subject: [PATCH 309/331] fix(ios): link genuine RenPy archives through their dependency closure --- .github/workflows/renpy-ios-runtime-build.yml | 22 ++-- scripts/build_ios.sh | 46 +++++-- tools/test_renpy_ios_force_load.py | 117 +++++++++++++----- 3 files changed, 138 insertions(+), 47 deletions(-) diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index a0f24b6ee..366878a61 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -267,6 +267,19 @@ jobs: cp -a "$RUNNER_TEMP/godot-templates/templates/." "$template_dir/" echo "GODOT_BIN=$GITHUB_WORKSPACE/.godot/Godot.app/Contents/MacOS/Godot" >> "$GITHUB_ENV" echo "GODOT_EXPORT_TEMPLATE=$template_dir/ios.zip" >> "$GITHUB_ENV" + - name: Prepare the normal vcpkg binary cache + env: + VCPKG_PACKAGES_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git clone https://github.com/microsoft/vcpkg.git .devtools/vcpkg + bash .devtools/vcpkg/bootstrap-vcpkg.sh -disableMetrics + echo "VCPKG_ROOT=$GITHUB_WORKSPACE/.devtools/vcpkg" >> "$GITHUB_ENV" + bash .github/scripts/configure-vcpkg-binary-cache.sh "$GITHUB_WORKSPACE/.devtools/vcpkg/vcpkg" + - name: Link the genuine complete Ren'Py framework before compiling the Godot engine + run: | + set -euo pipefail + bash scripts/build_ios.sh debug --simulator --simulator-arch=arm64 --renpy-framework-only - name: Build the missing real arm64 Godot Simulator engine and camera run: | set -euo pipefail @@ -299,15 +312,6 @@ jobs: python3 tools/build_godot_ios_simulator_template.py verify \ --template "$RUNNER_TEMP/godot-arm64-simulator/ios.zip" --arch arm64 echo "GODOT_EXPORT_TEMPLATE=$RUNNER_TEMP/godot-arm64-simulator/ios.zip" >> "$GITHUB_ENV" - - name: Prepare the normal vcpkg binary cache - env: - VCPKG_PACKAGES_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - git clone https://github.com/microsoft/vcpkg.git .devtools/vcpkg - bash .devtools/vcpkg/bootstrap-vcpkg.sh -disableMetrics - echo "VCPKG_ROOT=$GITHUB_WORKSPACE/.devtools/vcpkg" >> "$GITHUB_ENV" - bash .github/scripts/configure-vcpkg-binary-cache.sh "$GITHUB_WORKSPACE/.devtools/vcpkg/vcpkg" - name: Build the installable arm64 Simulator application run: | set -euo pipefail diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 499b02274..012e577cd 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -8,12 +8,14 @@ BUILD_TYPE="debug" SIMULATOR=false SIMULATOR_ARCH="${IOS_SIMULATOR_ARCH:-$(uname -m)}" PACKAGE_IPA=false +RENPY_FRAMEWORK_ONLY=false for arg in "$@"; do case "$arg" in debug|release|Debug|Release) BUILD_TYPE="$arg" ;; --simulator) SIMULATOR=true ;; --simulator-arch=*) SIMULATOR_ARCH="${arg#*=}" ;; --package-ipa|--unsigned-ipa|--ipa) PACKAGE_IPA=true ;; + --renpy-framework-only) RENPY_FRAMEWORK_ONLY=true ;; *) echo "[WARN] Unknown iOS build argument ignored: $arg" ;; esac done @@ -28,6 +30,10 @@ if [[ "$SIMULATOR" == true && "$BUILD_TYPE_LOWER" != debug ]]; then echo "Error: The iOS Simulator app currently uses the Debug CMake/export templates; select debug." >&2 exit 1 fi +if [[ "$RENPY_FRAMEWORK_ONLY" == true && "$PACKAGE_IPA" == true ]]; then + echo "Error: --renpy-framework-only does not build an application or package an IPA." >&2 + exit 1 +fi if [[ "$SIMULATOR" == true ]]; then if [[ "$SIMULATOR_ARCH" == "x86_64" || "$SIMULATOR_ARCH" == "x64" ]]; then @@ -204,7 +210,7 @@ preflight_simulator_template_arch() { --template "$template" --arch "$arch" } -if [[ "$SIMULATOR" == true ]]; then +if [[ "$SIMULATOR" == true && "$RENPY_FRAMEWORK_ONLY" == false ]]; then preflight_simulator_template_arch "$SIMULATOR_ARCH" "$GODOT_EXPORT_TEMPLATE" fi @@ -328,8 +334,10 @@ if renpy_device_gles_enabled; then echo "Error: AETHERKIRI_RENPY_IOS_DEVICE_GLES requires a Debug device build with Ren'Py enabled." >&2 exit 1 fi - python3 "$PROJECT_ROOT/tools/build_godot_ios_device_template.py" verify \ - --template "$GODOT_EXPORT_TEMPLATE" + if [[ "$RENPY_FRAMEWORK_ONLY" == false ]]; then + python3 "$PROJECT_ROOT/tools/build_godot_ios_device_template.py" verify \ + --template "$GODOT_EXPORT_TEMPLATE" + fi fi renios_link_enabled() { @@ -465,9 +473,12 @@ build_renios_runtime_framework() { for symbol in bootstrap bind_window init tick frame input pause resume shutdown text_input_state set_surface_size; do printf '_renpy_mobile_%s\n' "$symbol" >> "$export_list" done - local force_load_args=() archive + local archive_link_args=() archive while IFS= read -r archive; do - force_load_args+=(-Xlinker -force_load -Xlinker "$archive") + case "$(basename "$archive")" in + librenpython.a) archive_link_args+=(-Xlinker -force_load -Xlinker "$archive") ;; + *) archive_link_args+=("$archive") ;; + esac done < <(collect_renios_archives "$prebuilt") local minimum_flag="-miphoneos-version-min=${IOS_MIN_VERSION:-16.0}" [[ "$SIMULATOR" == true ]] && minimum_flag="-mios-simulator-version-min=${IOS_MIN_VERSION:-16.0}" @@ -491,12 +502,14 @@ build_renios_runtime_framework() { done # Keep Ren'Py's Python/SDL/FFmpeg symbols inside a separate two-level # namespace. They must not replace the host's vcpkg library definitions. - # The builtin Python extension table requires its archive objects to stay. - # Load each archive explicitly: modern Apple ld rejects -noall_load, and - # a global -all_load switch also affects unrelated libraries on this link. + # Keep every adapter/API object, including unreferenced lifecycle exports. + # Its bootstrap calls init_librenpy; the genuine Ren'Py and CPython init + # tables then reference their module initializers. Link all dependencies + # normally: variant JPEG/HarfBuzz archives and librenpy's repeated inittab + # member contain duplicate definitions when force-loaded together. xcrun --sdk "$IOS_SDK" clang++ -arch "$arch" -isysroot "$sdk_path" "$minimum_flag" -dynamiclib \ "${support_objects[@]}" \ - "${force_load_args[@]}" \ + "${archive_link_args[@]}" \ -Wl,-exported_symbols_list,"$export_list" \ -Wl,-install_name,"@rpath/$RENPY_FRAMEWORK_NAME.framework/$RENPY_FRAMEWORK_NAME" \ -Wl,-rpath,@loader_path/.. \ @@ -1113,9 +1126,22 @@ with_ios_only_gdextension() ( "$EXPORT_MODE" "$EXPORT_PRESET" "$IOS_EXPORT_DIR/Aether.xcodeproj" ) -echo "==> Building native engine and Godot extension" +if [[ "$RENPY_FRAMEWORK_ONLY" == true ]]; then + if ! renios_enabled; then + echo "Error: --renpy-framework-only requires AETHERKIRI_ENABLE_RENPY=ON." >&2 + exit 1 + fi + echo "==> Building and validating the real Ren'Py runtime framework only" +else + echo "==> Building native engine and Godot extension" +fi renios_launcher_probe build_renios_runtime_framework +if [[ "$RENPY_FRAMEWORK_ONLY" == true ]]; then + echo "Verified Ren'Py native framework: $RENPY_FRAMEWORK_OUTPUT" + echo "Framework-only mode: iOS application build NOT_RUN; gameplay NOT_RUN." + exit 0 +fi cmake_config_args=( -D "CMAKE_MAKE_PROGRAM=$CMAKE_MAKE_PROGRAM" -D "AETHERKIRI_ENABLE_INTERNAL=${AETHERKIRI_ENABLE_INTERNAL:-ON}" diff --git a/tools/test_renpy_ios_force_load.py b/tools/test_renpy_ios_force_load.py index 879cf121a..e0e0f7e42 100644 --- a/tools/test_renpy_ios_force_load.py +++ b/tools/test_renpy_ios_force_load.py @@ -38,17 +38,17 @@ def production_arguments(prebuilt): end = source.index("\n}\n", start) + 3 functions.append(source[start:end]) framework = source[source.index("build_renios_runtime_framework() {\n"):] - start = framework.index(" local force_load_args=() archive\n") + start = framework.index(" local archive_link_args=() archive\n") end = framework.index(" local minimum_flag=", start) actual_builder = framework[start:end] link_start = framework.index(" xcrun --sdk \"$IOS_SDK\" clang++") link_end = framework.index('\n -o "$RENPY_FRAMEWORK_OUTPUT/$RENPY_FRAMEWORK_NAME"', link_start) actual_link = framework[link_start:link_end] - assert actual_link.count('"${force_load_args[@]}"') == 1, "Production link must use the checked argv array" + assert actual_link.count('"${archive_link_args[@]}"') == 1, "Production link must use the checked argv array" assert "-all_load" not in actual_link and "-noall_load" not in actual_link shell = "set -euo pipefail\n" + "\n".join(functions) + "\n" shell += 'arguments() {\n local prebuilt="$1"\n' + actual_builder - shell += ' printf "%s\\0" "${force_load_args[@]}"\n}\narguments "$1"\n' + shell += ' printf "%s\\0" "${archive_link_args[@]}"\n}\narguments "$1"\n' output = run(["bash", "-c", shell, "force-load-regression", prebuilt]) return [part.decode() for part in output.rstrip(b"\0").split(b"\0")] @@ -106,32 +106,76 @@ def main(): "-Xlinker", "14.0"] linker_kind = "real_llvm_ld64_lld_not_apple_linker" runtime_flags = ["-nostdlib"] # No Apple SDK or fabricated libSystem on Linux. - symbols = {} - selected_names = (*ROOTS, "libdependency.a", "libregistration, optional.a") - for index, name in enumerate((*selected_names, *EXCLUDED, "ordinary.a")): - objects, registrations = [], [] - for member in range(2): - symbol = f"unreferenced_registration_{index}_{member}" - source, obj = output / f"registration-{index}-{member}.c", output / f"registration-{index}-{member}.o" - source.write_text(f"int {symbol}(void) {{ return {index + member}; }}\n") - run([*compiler, "-c", source, "-o", obj]) - objects.append(obj) - registrations.append("_" + symbol) - archive = prebuilt / name - run([*archiver, "rcs", archive, *objects]) - assert archive_tools.archive_identity(archive) == [{"architecture": "arm64", "objects": 2, "platform": 7}] - symbols[name] = registrations + def object_file(name, text): + source, obj = output / (name + ".c"), output / (name + ".o") + source.write_text(text + "\n") + run([*compiler, "-c", source, "-o", obj]) + return obj + + def archive(name, objects): + path = prebuilt / name + path.unlink(missing_ok=True) + run([*archiver, "rcs", path, *objects]) + assert archive_tools.archive_identity(path) == [{"architecture": "arm64", "objects": len(objects), "platform": 7}] + return path + + # The adapter's API objects require explicit retention. Its initializer + # references genuine-style static registration tables, whose function + # pointers retain every module object through ordinary archive extraction. + adapter = [object_file("adapter-bootstrap", """ +int fixture_renpy_init(void); int fixture_python_init(void); +int fixture_sdl(void); int fixture_image(void); +int fixture_adapter_bootstrap(void) { + return fixture_renpy_init() + fixture_python_init() + fixture_sdl() + fixture_image(); +} +"""), object_file("adapter-lifecycle", "int fixture_unreferenced_lifecycle(void) { return 1; }"), + object_file("adapter-greenlet", "int fixture_unreferenced_greenlet(void) { return 2; }")] + archive("librenpython.a", adapter) + modules = [f"fixture_module_{index}" for index in range(14)] + + def module_table(name, selected): + declarations = "\n".join(f"int {symbol}(void);" for symbol in selected) + table = ", ".join(selected) + return object_file(name, declarations + f"\nint (*const {name}_table[])(void) = {{ {table} }};\n" + + f"int fixture_{name}_init(void) {{ return {name}_table[0](); }}") + + renpy_modules = [object_file(symbol, f"int fixture_hb_main(void); int fixture_late_dependency(void);\n" + + f"int {symbol}(void) {{ return fixture_hb_main() + fixture_late_dependency(); }}") + for symbol in modules[:10]] + inittab = module_table("renpy", modules[:10]) + # Actual pinned tasks/librenpy.py appends inittab.o to objects and supplies + # it to ar again. Reproduce both real archive members; do not erase one. + archive("librenpy.a", [*renpy_modules, inittab, inittab]) + assert run([*archiver, "t", prebuilt / "librenpy.a"]).decode().splitlines().count(inittab.name) == 2 + python_modules = [object_file(symbol, f"int {symbol}(void) {{ return 3; }}") for symbol in modules[10:]] + archive("libpython3.12.a", [*python_modules, module_table("python", modules[10:])]) + archive("libSDL2.a", [object_file("sdl", "int fixture_sdl(void) { return 4; }")]) + archive("libSDL2_image.a", [object_file("image", "int fixture_jpeg_common(void);\n" + "int fixture_image(void) { return fixture_jpeg_common(); }")]) + archive("libdependency, optional.a", [object_file("late-dependency", "int fixture_late_dependency(void) { return 5; }")]) + archive("libjpeg.a", [object_file("jpeg-common", "int fixture_jpeg_common(void) { return 6; }")]) + archive("libturbojpeg.a", [object_file("turbojpeg-common", "int fixture_jpeg_common(void) { return 6; }\n" + "int fixture_unused_turbo_api(void) { return 7; }")]) + archive("libharfbuzz.a", [object_file("hb-main", "int fixture_hb_shared(void);\n" + "int fixture_hb_main(void) { return fixture_hb_shared(); }"), + object_file("hb-static", "int fixture_hb_shared(void) { return 8; }")]) + for variant in ("cairo", "subset"): + archive(f"libharfbuzz-{variant}.a", [object_file(f"hb-{variant}-static", "int fixture_hb_shared(void) { return 8; }\n" + + f"int fixture_unused_{variant}_api(void) {{ return 9; }}")]) + excluded_symbols = [] + for index, name in enumerate((*EXCLUDED, "ordinary.a")): + symbol = f"fixture_excluded_{index}" + archive(name, [object_file(symbol, f"int {symbol}(void) {{ return 10; }}")]) + excluded_symbols.append("_" + symbol) + selected_names = (*ROOTS, *sorted(path.name for path in prebuilt.glob("lib*.a") if path.name not in (*ROOTS, *EXCLUDED))) nested = prebuilt / "nested" nested.mkdir(exist_ok=True) shutil.copy2(prebuilt / "ordinary.a", nested / "libnested.a") actual = production_arguments(prebuilt) - expected_paths = [str(prebuilt / name) for name in (*ROOTS, *sorted(selected_names[len(ROOTS):]))] - assert len(actual) == 4 * len(expected_paths), actual - assert actual[::4] == ["-Xlinker"] * len(expected_paths) - assert actual[1::4] == ["-force_load"] * len(expected_paths) - assert actual[2::4] == ["-Xlinker"] * len(expected_paths) - assert actual[3::4] == expected_paths, actual - assert len(set(actual[3::4])) == len(expected_paths), "Each archive must be loaded once" + expected_paths = [str(prebuilt / name) for name in selected_names] + assert actual[:4] == ["-Xlinker", "-force_load", "-Xlinker", expected_paths[0]], actual + assert actual[4:] == expected_paths[1:], "All other archives must remain as ordinary linker inputs" + assert len(set(expected_paths)) == len(expected_paths), "Each archive must be linked once" entry = output / "entry.c" entry.write_text("int framework_entry(void) { return 0; }\n") entry_object = output / "entry.o" @@ -139,6 +183,11 @@ def main(): exports = output / "exports.txt" exports.write_text("_framework_entry\n") common = [*compiler, "-dynamiclib", *runtime_flags, *linker_flags, entry_object] + adapter_symbols = {"_fixture_adapter_bootstrap", "_fixture_unreferenced_lifecycle", "_fixture_unreferenced_greenlet"} + module_symbols = {"_" + name for name in modules} + dependency_symbols = {"_fixture_renpy_init", "_fixture_python_init", "_fixture_sdl", "_fixture_image", + "_fixture_jpeg_common", "_fixture_hb_main", "_fixture_hb_shared", "_fixture_late_dependency"} + wanted = adapter_symbols | module_symbols | dependency_symbols for label, archives in (("control", expected_paths), ("force-loaded", actual)): library = output / (label + ".dylib") command = [*common, *archives, prebuilt / "ordinary.a", "-Xlinker", "-exported_symbols_list", @@ -151,16 +200,28 @@ def main(): else: assert not dependencies, "Linux archive semantics test must not use a fake Apple runtime" names = {line.split()[-1] for line in run([*nm, library]).decode().splitlines() if line.split()} - wanted = {symbol for name in selected_names for symbol in symbols[name]} assert (wanted <= names if label == "force-loaded" else not wanted.intersection(names)), names - assert not {symbol for name in (*EXCLUDED, "ordinary.a") for symbol in symbols[name]}.intersection(names) + assert not set(excluded_symbols).intersection(names) + assert "_fixture_unused_turbo_api" not in names + assert len({"_fixture_unused_cairo_api", "_fixture_unused_subset_api"}.intersection(names)) <= 1 assert "_framework_entry" in names + old_force_all = [argument for path in expected_paths for argument in ("-Xlinker", "-force_load", "-Xlinker", path)] + old_command = [*common, *old_force_all, "-o", output / "invalid-all-force.dylib"] + failed = subprocess.run(list(map(str, old_command)), stdout=subprocess.PIPE, stderr=subprocess.PIPE) + diagnostic = failed.stderr.decode() + assert failed.returncode != 0, "The old force-all variant and repeated-member graph must fail a real link" + assert "duplicate symbol" in diagnostic and "fixture_renpy_init" in diagnostic and "fixture_jpeg_common" in diagnostic + assert "fixture_hb_shared" in diagnostic, diagnostic + (output / "old-force-all-error.log").write_text(diagnostic) report = {"status": "passed", "linker": linker_kind, "sdk": sdk, "standard_runtime_driver_flags": runtime_flags, "linked_platform": 7, "linked_system_dependencies": dependencies, "compiler_version": run(version_command).decode().splitlines()[0], "production_script_sha256": hashlib.sha256((REPO / "scripts/build_ios.sh").read_bytes()).hexdigest(), - "selected_archives": len(expected_paths), "unreferenced_registration_objects_retained": len(wanted), + "selected_archives": len(expected_paths), "force_loaded_archives": ["librenpython.a"], + "unreferenced_adapter_objects_retained": 2, "static_module_registration_objects_retained": len(modules), + "referenced_dependency_functions_retained": len(dependency_symbols), + "duplicate_inittab_members": 2, "old_force_all_duplicate_graph_rejected": True, "archive_paths_with_spaces_and_commas": "passed", "root_dedup_and_exclusion_boundaries": "passed", "unrelated_archive_not_force_loaded": "passed", "control_link_omits_unreferenced_objects": "passed", "linked_dylib_sha256": hashlib.sha256((output / "force-loaded.dylib").read_bytes()).hexdigest(), From 2dec4577e8c3024b9417913aa6eee6e03c9787c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 03:37:38 +0000 Subject: [PATCH 310/331] ci(android): check live coldboot within the existing bounded window --- .github/workflows/renpy-android-hvf-gameplay.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/renpy-android-hvf-gameplay.yml b/.github/workflows/renpy-android-hvf-gameplay.yml index 3533c3d84..e5c2ddc90 100644 --- a/.github/workflows/renpy-android-hvf-gameplay.yml +++ b/.github/workflows/renpy-android-hvf-gameplay.yml @@ -167,10 +167,13 @@ jobs: 'actual_manifest':manifest, 'gameplay':'not_run'}, sort_keys=True)) PY - name: Boot actual Android 35 and run full Ren'Py APK gameplay + # Run 37878726721 (2971bfc4) showed live guest/ART activity but no boot + # completion within 480s. This 600s coldboot experiment still fails + # closed; the additional wait is not a verified fix or gameplay pass. run: | python3 tools/run_renpy_android_emulator_session.py --cloud-emulator \ --apk "$RENPY_HVF_APK" --package org.aetherkiri.renpy.debug \ - --boot-timeout 480 --gameplay-timeout 900 --stage-timeout 90 \ + --boot-timeout 600 --gameplay-timeout 900 --stage-timeout 90 \ --output-dir "$RUNNER_TEMP/renpy-android-device-evidence" - name: Preserve actual Android gameplay evidence if: always() From 4b042e2228b13e7ed152d8edbc57123101354496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 03:53:05 +0000 Subject: [PATCH 311/331] ci(ios): cache and revalidate the genuine public Simulator engine --- .github/workflows/renpy-ios-runtime-build.yml | 44 ++++- tools/build_godot_ios_simulator_template.py | 158 +++++++++++++++++- tools/test_godot_ios_simulator_template.py | 51 ++++++ 3 files changed, 241 insertions(+), 12 deletions(-) diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index 366878a61..bd73ed770 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -280,12 +280,32 @@ jobs: run: | set -euo pipefail bash scripts/build_ios.sh debug --simulator --simulator-arch=arm64 --renpy-framework-only - - name: Build the missing real arm64 Godot Simulator engine and camera + - name: Install the pinned Godot source build driver run: | set -euo pipefail python3 -m venv "$RUNNER_TEMP/godot-scons" "$RUNNER_TEMP/godot-scons/bin/python" -m pip install 'SCons==4.9.1' python3 tools/test_godot_ios_simulator_template.py --template "$GODOT_EXPORT_TEMPLATE" + - name: Identify the exact public Godot source and selected Apple toolchain + id: godot-simulator-cache-key + run: | + set -euo pipefail + cache_key="$("$RUNNER_TEMP/godot-scons/bin/python" tools/build_godot_ios_simulator_template.py cache-key \ + --input-template "$GODOT_EXPORT_TEMPLATE")" + [[ "$cache_key" =~ ^godot-ios-simulator-v1-[0-9a-f]{64}$ ]] + echo "key=$cache_key" >> "$GITHUB_OUTPUT" + - name: Restore only the compiled public Simulator template and its build proof + id: godot-simulator-cache + uses: actions/cache/restore@v6 + with: + path: | + ${{ runner.temp }}/godot-arm64-simulator/ios.zip + ${{ runner.temp }}/godot-arm64-simulator/evidence.json + key: ${{ steps.godot-simulator-cache-key.outputs.key }} + - name: Build the missing real arm64 Godot Simulator engine and camera + if: steps.godot-simulator-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail if ! "$RUNNER_TEMP/godot-scons/bin/python" tools/build_godot_ios_simulator_template.py build \ --input-template "$GODOT_EXPORT_TEMPLATE" \ --output-template "$RUNNER_TEMP/godot-arm64-simulator/ios.zip" \ @@ -309,9 +329,26 @@ jobs: PY exit 1 fi - python3 tools/build_godot_ios_simulator_template.py verify \ - --template "$RUNNER_TEMP/godot-arm64-simulator/ios.zip" --arch arm64 + - name: Verify actual cached or freshly built archive bytes and source identity + run: | + set -euo pipefail + "$RUNNER_TEMP/godot-scons/bin/python" tools/build_godot_ios_simulator_template.py verify-cache \ + --input-template "$GODOT_EXPORT_TEMPLATE" \ + --template "$RUNNER_TEMP/godot-arm64-simulator/ios.zip" \ + --evidence "$RUNNER_TEMP/godot-arm64-simulator/evidence.json" \ + --report "$RUNNER_TEMP/godot-simulator-cache-verification.json" echo "GODOT_EXPORT_TEMPLATE=$RUNNER_TEMP/godot-arm64-simulator/ios.zip" >> "$GITHUB_ENV" + - name: Save the verified public engine before the application build + if: >- + steps.godot-simulator-cache.outputs.cache-hit != 'true' && + (github.event_name != 'pull_request' || + (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]')) + uses: actions/cache/save@v6 + with: + path: | + ${{ runner.temp }}/godot-arm64-simulator/ios.zip + ${{ runner.temp }}/godot-arm64-simulator/evidence.json + key: ${{ steps.godot-simulator-cache-key.outputs.key }} - name: Build the installable arm64 Simulator application run: | set -euo pipefail @@ -332,6 +369,7 @@ jobs: out/renpy-ios-gameplay ${{ runner.temp }}/renpy-ios-simulator-build.log ${{ runner.temp }}/godot-arm64-simulator/evidence.json + ${{ runner.temp }}/godot-simulator-cache-verification.json ${{ runner.temp }}/renpy-ios-force-load/evidence.json if-no-files-found: warn retention-days: 7 diff --git a/tools/build_godot_ios_simulator_template.py b/tools/build_godot_ios_simulator_template.py index a02c696bd..8f0b4aeca 100644 --- a/tools/build_godot_ios_simulator_template.py +++ b/tools/build_godot_ios_simulator_template.py @@ -11,6 +11,7 @@ import json import os from pathlib import Path +import platform import plistlib import shlex import shutil @@ -28,6 +29,8 @@ BUILD_FLAGS = ("platform=ios", "target=template_debug", "arch=arm64", "ios_simulator=yes", "vulkan=no", "metal=no", "opengl3=yes", "generate_bundle=no", "sdl=no") +CACHE_SCHEMA = 1 +SCONS_VERSION = "4.9.1" def digest(path): @@ -224,6 +227,129 @@ def command(arguments, *, cwd=None): return result.stdout.strip() +def cache_identity(original): + """Identify genuine selected build tools; no environment dump is needed.""" + require(sys.platform == "darwin", "A cache identity requires genuine selected macOS/Xcode tools") + require("OSXCROSS_IOS" not in os.environ, "Use genuine selected Xcode tools, not an osxcross SDK") + import SCons + require(SCons.__version__ == SCONS_VERSION, "The Simulator cache requires SCons==" + SCONS_VERSION) + package = Path(SCons.__file__).resolve(strict=True).parent + package_files = {str(path.relative_to(package)): digest(path) for path in sorted(package.rglob("*.py"))} + require(package_files, "The installed SCons package has no genuine Python sources") + compiler = Path(command(["xcrun", "--sdk", "iphonesimulator", "--find", "clang"])) + sdk = Path(command(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"])).resolve(strict=True) + return {"schema": CACHE_SCHEMA, "source_url": SOURCE_URL, "source_sha": SOURCE_SHA, + "build_flags": list(BUILD_FLAGS), "input_template_sha256": digest(original), + "builder_sha256": digest(__file__), "host_architecture": platform.machine(), + "developer_path": str(Path(command(["xcode-select", "-p"])).resolve(strict=True)), + "sdk_path": str(sdk), + "sdk_version": command(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-version"]), + "compiler": str(compiler), "compiler_sha256": digest(compiler), + "compiler_version": command([compiler, "--version"]), + "xcode_version": command(["xcodebuild", "-version"]), + "scons_version": SCons.__version__, + "scons_sources_sha256": hashlib.sha256(json.dumps(package_files, sort_keys=True).encode()).hexdigest()} + + +def cache_key(identity): + value = hashlib.sha256(json.dumps(identity, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + return f"godot-ios-simulator-v{CACHE_SCHEMA}-{value}" + + +def unchanged_member_hashes(original, rebuilt): + """Recheck every original member except the two rebuilt debug archives.""" + replacements = {member(library) for library in LIBRARIES} + result = {} + with zipfile.ZipFile(original) as before, zipfile.ZipFile(rebuilt) as after: + require(len(before.namelist()) == len(set(before.namelist())) + and len(after.namelist()) == len(set(after.namelist())), "Duplicate template ZIP members") + require(before.namelist() == after.namelist(), "Cached template member set/order differs from the official ZIP") + require(before.comment == after.comment, "Cached template ZIP comment differs from the official ZIP") + require(replacements <= set(before.namelist()), "Official template lacks the selected Simulator archives") + for name in before.namelist(): + if name in replacements: + continue + with before.open(name) as source, after.open(name) as cached: + result[name] = stream_digest(source) + require(result[name] == stream_digest(cached), f"Untouched template member changed: {name}") + return result + + +def verify_cache_metadata(evidence, identity, output_digest): + """Reject incomplete/stale evidence before accepting any cached artifact.""" + require(evidence.get("status") == "built_and_verified" + and evidence.get("source_build") == "built_and_verified", "Cache lacks successful genuine source-build evidence") + require(evidence.get("cache_identity") == identity and evidence.get("cache_key") == cache_key(identity), + "Cached source/template/toolchain identity differs from the current inputs") + for name in ("source_url", "source_sha", "input_template_sha256", "sdk_path", "sdk_version", + "compiler", "compiler_version", "xcode_version"): + require(evidence.get(name) == identity[name], "Cached build evidence differs for " + name) + require(evidence.get("output_template_sha256") == output_digest, "Cached template ZIP digest differs from its build evidence") + invocation = evidence.get("build_command", []) + toolchain = Path(identity["compiler"]).resolve(strict=True).parents[2] + expected = ["-m", "SCons", *BUILD_FLAGS, "APPLE_SDK_PATH=" + identity["sdk_path"], + "APPLE_TOOLCHAIN_PATH=" + str(toolchain)] + require(isinstance(invocation, list) and len(invocation) == len(expected) + 2 + and invocation[1:-1] == expected and isinstance(invocation[-1], str) + and invocation[-1].startswith("-j") and invocation[-1][2:].isdigit() + and int(invocation[-1][2:]) > 0, "Cached build command differs from the pinned production build") + + +def verify_cached_libraries(original, template): + """Inspect actual objects and use real lipo to prove thin-slice retention.""" + require(sys.platform == "darwin", "Cache archive verification requires genuine Xcode lipo/nm") + inputs = verify_template(original, "x86_64") + outputs = verify_template(template, "arm64") # Every object/platform plus rebuilt arm64 SDL3 rejection. + proofs = {} + with zipfile.ZipFile(original) as source, zipfile.ZipFile(template) as cached, \ + tempfile.TemporaryDirectory(prefix="godot-ios-cache-archives-") as temporary: + root = Path(temporary) + for library in LIBRARIES: + base, merged = root / (library + "-official.a"), root / (library + "-merged.a") + for archive, destination in ((source, base), (cached, merged)): + with archive.open(member(library)) as src, destination.open("wb") as dest: + shutil.copyfileobj(src, dest) + original_x64, retained_x64, arm = [root / (library + suffix) for suffix in + ("-official-x86_64.a", "-retained-x86_64.a", "-arm64.a")] + if len(inputs[library]["slices"]) == 1: + shutil.copyfile(base, original_x64) + else: + command(["xcrun", "lipo", base, "-thin", "x86_64", "-output", original_x64]) + command(["xcrun", "lipo", merged, "-thin", "x86_64", "-output", retained_x64]) + command(["xcrun", "lipo", merged, "-thin", "arm64", "-output", arm]) + require(digest(original_x64) == digest(retained_x64), "Official x64 archive bytes changed in the cache") + slices = {item["architecture"]: item for item in outputs[library]["slices"]} + proofs[library] = {"arm64_sha256": digest(arm), "official_x86_64_sha256": digest(original_x64), + "merged_sha256": digest(merged), "arm64_objects": slices["arm64"]["objects"]} + return inputs, proofs + + +def verify_cache(args): + original, template = args.input_template.resolve(strict=True), args.template.resolve(strict=True) + evidence_path = args.evidence.resolve(strict=True) + if args.report: + require(args.report.resolve() not in (original, template, evidence_path), "Cache verification must preserve its inputs/evidence") + identity = cache_identity(original) + evidence = json.loads(evidence_path.read_text()) + require(isinstance(evidence, dict), "Cache evidence must be an object") + verify_cache_metadata(evidence, identity, digest(template)) + inputs, libraries = verify_cached_libraries(original, template) + unchanged = unchanged_member_hashes(original, template) + require(evidence.get("input_libraries") == inputs and evidence.get("libraries") == libraries, + "Actual cached archive/slice digests differ from the genuine build evidence") + require(evidence.get("untouched_member_sha256") == unchanged + and evidence.get("untouched_template_members") == "all_sha256_identical", "Cache lacks exact untouched-member evidence") + result = {"status": "cache_payload_verified", "cache_key": cache_key(identity), + "input_template_sha256": identity["input_template_sha256"], "output_template_sha256": digest(template), + "libraries": libraries, "untouched_member_count": len(unchanged), + "source_build": "not_run", "source_build_performed_by_this_check": False, + "app": "not_run", "gameplay": "not_run"} + if args.report: + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(result, indent=2) + "\n") + return result + + def replace_template_archives(template, destination, replacements, allowed_members=None): require(len(template.namelist()) == len(set(template.namelist())), "Duplicate template ZIP members") simulator_members = {member(library) for library in LIBRARIES} @@ -247,21 +373,20 @@ def replace_template_archives(template, destination, replacements, allowed_membe def build(args): require(sys.platform == "darwin", "A genuine macOS/Xcode host is required; no cross-SDK substitute is accepted") - import SCons - require(tuple(map(int, SCons.__version__.split(".")[:2])) >= (4, 4), "Godot requires SCons >= 4.4") original, output, root = args.input_template.resolve(strict=True), args.output_template.resolve(), args.output_dir.resolve() require(original != output, "The official input template must remain unchanged") require("OSXCROSS_IOS" not in os.environ, "Use genuine selected Xcode tools, not an osxcross SDK") root.mkdir(parents=True, exist_ok=True) source = root / "godot-source" require(not source.exists(), "Use a fresh output directory for a real source build") - sdk = command(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"]) - compiler = command(["xcrun", "--sdk", "iphonesimulator", "--find", "clang"]) + identity = cache_identity(original) + sdk, compiler = identity["sdk_path"], identity["compiler"] evidence = {"status": "building", "source_url": SOURCE_URL, "source_sha": SOURCE_SHA, "input_template_sha256": digest(original), "sdk_path": sdk, - "sdk_version": command(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-version"]), - "compiler": compiler, "compiler_version": command([compiler, "--version"]), - "xcode_version": command(["xcodebuild", "-version"]), "gameplay": "not_run"} + "sdk_version": identity["sdk_version"], + "compiler": compiler, "compiler_version": identity["compiler_version"], + "xcode_version": identity["xcode_version"], "gameplay": "not_run", + "cache_identity": identity, "cache_key": cache_key(identity), "source_build": "building"} report = root / "evidence.json" report.write_text(json.dumps(evidence, indent=2) + "\n") try: @@ -300,6 +425,9 @@ def build(args): retained = root / (library + "-retained-x86_64.a") command(["xcrun", "lipo", merged, "-thin", "x86_64", "-output", retained]) require(digest(x64) == digest(retained), "Official x64 archive bytes changed") + retained_arm = root / (library + "-retained-arm64.a") + command(["xcrun", "lipo", merged, "-thin", "arm64", "-output", retained_arm]) + require(digest(arm) == digest(retained_arm), "Source-built arm64 archive bytes changed") require({entry["architecture"] for entry in archive_identity(merged)} == {"arm64", "x86_64"}, "Bad merged archive") replacements[member(library)] = merged libraries[library] = {"arm64_sha256": digest(arm), "official_x86_64_sha256": digest(x64), @@ -314,9 +442,10 @@ def build(args): finally: staged.unlink(missing_ok=True) evidence.update(status="built_and_verified", output_template_sha256=digest(output), libraries=libraries, - untouched_template_members="all_sha256_identical") + untouched_template_members="all_sha256_identical", source_build="built_and_verified", + untouched_member_sha256=unchanged_member_hashes(original, output)) except BaseException as error: - evidence.update(status="failed", error=str(error)) + evidence.update(status="failed", source_build="failed", error=str(error)) raise finally: report.write_text(json.dumps(evidence, indent=2) + "\n") @@ -335,6 +464,13 @@ def main(): checker.add_argument("--template", type=Path, required=True) checker.add_argument("--arch", choices=("arm64", "x86_64"), required=True) checker.add_argument("--exported-dir", type=Path) + key = commands.add_parser("cache-key", help="Print one exact cache key for genuine current Xcode/SCons inputs") + key.add_argument("--input-template", type=Path, required=True) + cached = commands.add_parser("verify-cache", help="Validate cached ZIP/evidence without running a source build") + cached.add_argument("--input-template", type=Path, required=True) + cached.add_argument("--template", type=Path, required=True) + cached.add_argument("--evidence", type=Path, required=True) + cached.add_argument("--report", type=Path, help="Write a separate verification report; build evidence stays unchanged") preset = commands.add_parser("preset") preset.add_argument("--source", type=Path, required=True) preset.add_argument("--destination", type=Path, required=True) @@ -348,6 +484,10 @@ def main(): build(args) elif args.action == "verify": print(json.dumps(verify_template(args.template, args.arch, args.exported_dir), indent=2)) + elif args.action == "cache-key": + print(cache_key(cache_identity(args.input_template.resolve(strict=True)))) + elif args.action == "verify-cache": + print(json.dumps(verify_cache(args), indent=2)) else: patch_preset(args.source, args.destination, args.preset, args.template, args.mode) except (OSError, ValueError, KeyError, zipfile.BadZipFile, struct.error, subprocess.CalledProcessError) as error: diff --git a/tools/test_godot_ios_simulator_template.py b/tools/test_godot_ios_simulator_template.py index 16ba31a1d..ee62ec26b 100644 --- a/tools/test_godot_ios_simulator_template.py +++ b/tools/test_godot_ios_simulator_template.py @@ -6,6 +6,7 @@ """ import argparse import configparser +import copy as copy_module import json from pathlib import Path import shutil @@ -37,6 +38,7 @@ def main(): help="The genuine official Godot4.7.2 ios.zip") parser.add_argument("--clang", default=shutil.which("clang")) parser.add_argument("--ar", default=shutil.which("llvm-ar")) + parser.add_argument("--nm", default=shutil.which("llvm-nm")) args = parser.parse_args() require = helper.require require(args.template.is_file(), "A real official template is required") @@ -44,6 +46,23 @@ def main(): require(all(item["slices"][0]["architecture"] == "x86_64" and len(item["slices"]) == 1 for item in actual.values()), "This acceptance starts with the original x64-only release template") rejected(lambda: helper.verify_template(args.template, "arm64")) + # Pure key checks use actual official ZIP/LLVM inputs. They do not claim a + # Darwin toolchain identity or manufacture successful source-build evidence. + native_identity = {"source_sha": helper.SOURCE_SHA, "build_flags": list(helper.BUILD_FLAGS), + "input_template_sha256": helper.digest(args.template)} + if args.clang: + native_identity["compiler_sha256"] = helper.digest(args.clang) + key = helper.cache_key(native_identity) + require(key == helper.cache_key(dict(reversed(list(native_identity.items())))), "Cache key depends on dictionary order") + for name in native_identity: + changed = dict(native_identity) + changed[name] = "different input" + require(key != helper.cache_key(changed), "Cache key did not change for " + name) + for incomplete in ({}, {"status": "building"}, {"status": "failed"}, {"status": "built_and_verified"}): + rejected(lambda: helper.verify_cache_metadata(incomplete, native_identity, helper.digest(args.template))) + if sys.platform != "darwin": + rejected(lambda: helper.cache_identity(args.template)) + rejected(lambda: helper.verify_cached_libraries(args.template, args.template)) with tempfile.TemporaryDirectory(prefix="godot-real-archive-acceptance-") as temporary: root = Path(temporary) source = root / "object.c" @@ -68,6 +87,16 @@ def main(): require(helper.archive_identity(archives["sim-x86_64"])[0] == {"architecture": "x86_64", "objects": 1, "platform": 7}, "Actual x64 Simulator object rejected") rejected(lambda: helper.archive_identity(archives["device-arm64"])) + llvm_nm = args.nm or (str(Path(args.ar).with_name("llvm-nm")) if args.ar else None) + if sys.platform != "darwin": + require(llvm_nm and Path(llvm_nm).is_file(), "A genuine LLVM nm is required for the SDL collision gate") + helper.reject_sdl_symbols(archives["sim-arm64"], nm=llvm_nm if sys.platform != "darwin" else None) + sdl_source, sdl_object, sdl_archive = [root / name for name in ("sdl.c", "sdl.o", "sdl.a")] + sdl_source.write_text("int SDL_Init(void) { return 0; }\n") + subprocess.run([*compiler, "-target", "arm64-apple-ios14.0-simulator", "-c", sdl_source, "-o", sdl_object], check=True) + subprocess.run([*archiver, "rcs", sdl_archive, sdl_object], check=True) + helper.archive_identity(sdl_archive) + rejected(lambda: helper.reject_sdl_symbols(sdl_archive, nm=llvm_nm if sys.platform != "darwin" else None)) with zipfile.ZipFile(args.template) as template: # The actual device archive must never pass the Simulator gate, # despite matching CPU and truthful device plist metadata. @@ -83,6 +112,23 @@ def main(): helper.replace_template_archives(template, copy, {helper.member("libgodot_camera"): camera}) require(helper.verify_template(copy, "x86_64") == actual, "Actual ZIP roundtrip changed real libraries") require(helper.digest(args.template) == original_digest, "Official input ZIP was modified") + unchanged = helper.unchanged_member_hashes(args.template, copy) + require(len(unchanged) == len(template.namelist()) - len(helper.LIBRARIES), "Incomplete untouched-member proof") + mutated = root / "changed-official-member.zip" + selected = "libgodot.ios.debug.xcframework/Info.plist" + with zipfile.ZipFile(mutated, "w") as destination: + destination.comment = template.comment + for info in template.infolist(): + with template.open(info) as src, destination.open(copy_module.copy(info), "w") as dest: + shutil.copyfileobj(src, dest) + if info.filename == selected: + dest.write(b"\n") + rejected(lambda: helper.unchanged_member_hashes(args.template, mutated)) + duplicate = root / "duplicate-member.zip" + with zipfile.ZipFile(duplicate, "w") as destination: + destination.writestr(selected, template.read(selected)) + destination.writestr(selected, template.read(selected)) + rejected(lambda: helper.unchanged_member_hashes(args.template, duplicate)) universal = "not_run (Linux has no Apple lipo)" if sys.platform == "darwin": fat = root / "real-universal.a" @@ -94,6 +140,7 @@ def main(): subprocess.run(["xcrun", "lipo", "-create", archives["device-arm64"], archives["sim-x86_64"], "-output", wrong], check=True) rejected(lambda: helper.archive_identity(wrong)) + rejected(lambda: helper.verify_cached_libraries(args.template, args.template)) universal = "passed (real Xcode lipo/SDK objects)" presets = Path(__file__).resolve().parents[1] / "apps/godot_app/export_presets.cfg" before = config(presets) @@ -110,6 +157,10 @@ def main(): print(json.dumps({"status": "passed", "official_template_sha256": helper.digest(args.template), "official_archives": actual, "device_slice_rejected": True, "real_template_roundtrip": "all_untouched_member_hashes_identical", + "cache_key_input_changes": "passed (real inputs; no fabricated Darwin identity)", + "incomplete_cache_evidence_rejected": True, "changed_or_duplicate_cached_member_rejected": True, + "real_arm64_sdl_collision_rejected": True, + "cached_source_build_acceptance": "not_run (no newly built genuine cached engine supplied)", "real_universal_archive": universal, "godot_engine_build": "not_run", "gameplay": "not_run"}, indent=2)) return 0 From 17aa2eea588e91c1c15f6b2eeccd5f70a140c255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 04:27:31 +0000 Subject: [PATCH 312/331] fix(android): wait for a visible OS screen within the boot deadline --- tools/run_renpy_android_emulator_session.py | 81 ++++++++- tools/test_renpy_android_emulator_session.py | 173 ++++++++++++++++++- 2 files changed, 246 insertions(+), 8 deletions(-) diff --git a/tools/run_renpy_android_emulator_session.py b/tools/run_renpy_android_emulator_session.py index fd6322057..0c653c995 100644 --- a/tools/run_renpy_android_emulator_session.py +++ b/tools/run_renpy_android_emulator_session.py @@ -15,14 +15,16 @@ import platform import re import signal +import struct import subprocess import sys import tempfile import time +import zlib from pathlib import Path from preflight_renpy_android_device import Preflight, Unavailable -from run_renpy_android_device_acceptance import Failed, image_stats, png_pixels +from run_renpy_android_device_acceptance import Failed, png_pixels IMAGE = "system-images;android-35;google_apis;x86_64" @@ -33,6 +35,27 @@ ) +def os_screen_stats(image: tuple[int, int, bytes, int]) -> dict: + """Require visible OS content, without applying the Ren'Py scene palette gate.""" + width, height, data, channels = image + if width <= 0 or height <= 0 or width * height > 30_000_000 or channels not in (3, 4) \ + or len(data) != width * height * channels: + raise Failed("Invalid decoded Android OS screenshot dimensions or pixels") + colors, luma = set(), [] + for y in range(0, height, max(1, height // 64)): + for x in range(0, width, max(1, width // 64)): + offset = (y * width + x) * channels + rgb = tuple(data[offset:offset + 3]) + colors.add(rgb) + luma.append(sum(rgb) / 3) + stats = {"width": width, "height": height, "sampled_colors": len(colors), + "luma_range": max(luma) - min(luma)} + # A monochrome status bar can be valid OS content. A uniform frame or + # low-contrast noise cannot demonstrate that the display is ready. + stats["ready"] = stats["sampled_colors"] >= 2 and stats["luma_range"] >= 40 + return stats + + def bounded_diagnostic(raw: bytes | str, *, max_chars: int = 4096, max_lines: int = 32) -> dict: """Keep a small sanitized tail of only this session's actual output.""" text = raw.decode(errors="replace") if isinstance(raw, bytes) else raw @@ -185,6 +208,7 @@ def boot_progress(self, phase: str, *, response: subprocess.CompletedProcess | N "serial": self.result["serial"], "emulator_returncode": self.emulator_process.poll(), "last_boot_response": self.result.get("last_boot_response"), + "actual_os_screen": self.result.get("actual_os_screen"), "gameplay_verification": "not_run"}), flush=True) self.next_boot_progress = now + 30 @@ -338,28 +362,71 @@ def boot(self, emulator: Path, avdmanager: Path, avd_root: Path) -> None: time.sleep(min(2, max(0, deadline - time.monotonic()))) else: raise Unavailable(f"Actual emulator did not boot within {self.args.boot_timeout}s; see emulator.log") + self.validate_booted_device(deadline) + + def boot_device(self, label: str, *parts: str, deadline: float, timeout: int = 30): + """Every readiness command uses the original emulator boot deadline.""" + remaining = deadline - time.monotonic() + if remaining <= 0: + raise Unavailable("Actual Android OS display was not verified before the original boot deadline") + return self.device(label, *parts, timeout=min(timeout, remaining)) + + def wait_os_screen(self, deadline: float) -> None: + screenshot = self.output / "actual-android-screen.png" + self.result["os_screen_attempts"] = 0 + self.result["os_screen_observations"] = [] + while True: + captured = self.boot_device("screen-capture", "exec-out", "screencap", "-p", deadline=deadline) + screenshot.write_bytes(captured.stdout) + # Failed adb commands, permission errors and malformed PNGs are + # fatal. Only a real, decoded but not-yet-visible frame is retried. + try: + stats = os_screen_stats(png_pixels(screenshot)) + except (struct.error, zlib.error) as exc: + raise Failed("Actual Android OS screenshot has malformed PNG data") from exc + self.result["os_screen_attempts"] += 1 + observation = {"attempt": self.result["os_screen_attempts"], + "elapsed_seconds": round(time.monotonic() - self.boot_started, 1), **stats} + self.result["actual_os_screen"] = observation + self.result["os_screen_observations"] = (self.result["os_screen_observations"] + [observation])[-32:] + self.boot_progress("waiting-for-os-screen") + if observation["attempt"] == 1: + print(json.dumps({"checkpoint": "actual-android-first-os-screen", **observation, + "gameplay_verification": "not_run"}), flush=True) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise Unavailable("Actual Android OS display was not verified before the original boot deadline") + if stats["ready"]: + self.result["boot_phase"] = "validated-booted-device" + return + time.sleep(min(2, remaining)) + + def validate_booted_device(self, deadline: float) -> None: self.result["boot_phase"] = "validating-booted-device" - self.device("unlock", "shell", "input", "keyevent", "82") + # Ordinary input wakes the screen; it does not change lock/security settings. + self.boot_device("wakeup", "shell", "input", "keyevent", "224", deadline=deadline) + self.boot_device("unlock", "shell", "input", "keyevent", "82", deadline=deadline) values = {} for name in ("ro.build.fingerprint", "ro.product.cpu.abilist", "ro.build.version.sdk", "ro.opengles.version"): - values[name] = self.device("property-" + name, "shell", "getprop", name).stdout.decode().strip() + values[name] = self.boot_device("property-" + name, "shell", "getprop", name, + deadline=deadline).stdout.decode().strip() self.result["device_properties"] = values if not values["ro.build.fingerprint"] or "x86_64" not in values["ro.product.cpu.abilist"].split(",") \ or values["ro.build.version.sdk"] != "35": raise Unavailable("The booted Android device is not the actual requested Android 35 x86_64 image") if int(values["ro.opengles.version"] or "0") < 0x30000: raise Unavailable("The actual Android device does not advertise required GLES 3.0") - surface = self.device("surfaceflinger", "shell", "dumpsys", "SurfaceFlinger").stdout.decode(errors="replace") + surface = self.boot_device("surfaceflinger", "shell", "dumpsys", "SurfaceFlinger", + deadline=deadline).stdout.decode(errors="replace") self.result["surfaceflinger_gles"] = [line.strip() for line in surface.splitlines() if "GLES:" in line] if not self.result["surfaceflinger_gles"]: raise Unavailable("Actual SurfaceFlinger did not report a GLES renderer; see surfaceflinger.log") - screenshot = self.output / "actual-android-screen.png" - screenshot.write_bytes(self.device("screen-capture", "exec-out", "screencap", "-p").stdout) - self.result["actual_os_screen"] = image_stats(png_pixels(screenshot)) + self.wait_os_screen(deadline) self.result["checks"].extend(("actual-android-boot-completed", "adb-real-device-properties", "actual-surfaceflinger-gles", "actual-os-screen")) print(json.dumps({"checkpoint": "actual-android-booted", "device_properties": values, "surfaceflinger_gles": self.result["surfaceflinger_gles"], + "actual_os_screen": self.result["actual_os_screen"], "gameplay_verification": "not_run"}), flush=True) def gameplay(self) -> None: diff --git a/tools/test_renpy_android_emulator_session.py b/tools/test_renpy_android_emulator_session.py index f5cdb29be..0015cd1ba 100644 --- a/tools/test_renpy_android_emulator_session.py +++ b/tools/test_renpy_android_emulator_session.py @@ -11,18 +11,189 @@ import os import selectors import signal +import struct import subprocess import sys import tempfile import time import unittest +import zlib from pathlib import Path from unittest.mock import patch from run_renpy_android_emulator_session import ( Session, Unavailable, acceleration_usable, bounded_diagnostic, diagnostic_file_tail, - gameplay_result_matches, owned_group_exists, owned_group_state, terminate_owned_process, wait_owned_group_exit, + gameplay_result_matches, os_screen_stats, owned_group_exists, owned_group_state, + terminate_owned_process, wait_owned_group_exit, ) +from run_renpy_android_device_acceptance import Failed, image_stats, png_pixels + + +def encoded_os_fixture(*, first: int = 0, second: int = 255, rgba: bool = False) -> bytes: + """Encode real PNG bytes for a two-color OS boundary fixture, not gameplay.""" + channels = 4 if rgba else 3 + def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + rows = b"".join(b"\0" + (bytes([first if y < 8 else second] * 3) + + (b"\xff" if rgba else b"")) * 64 for y in range(64)) + header = struct.pack(">IIBBBBB", 64, 64, 8, 6 if channels == 4 else 2, 0, 0, 0) + return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"") + + +class OsScreenReadinessTests(unittest.TestCase): + def session(self, temporary: str, *, apk: Path | None = None) -> Session: + args = type("Args", (), {"output_dir": Path(temporary), "port": 5554, "apk": apk, + "cloud_emulator": True, "sdk_root": temporary})() + session = Session(args) + session.boot_started, session.next_boot_progress = 0, 0 + session.emulator_process = type("BoundaryProcess", (), {"poll": lambda _: None})() + return session + + def test_real_two_color_png_is_os_ready_but_still_rejected_as_a_renpy_scene(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "two-color-os-fixture.png" + for rgba in (False, True): + path.write_bytes(encoded_os_fixture(rgba=rgba)) + image = png_pixels(path) + stats = os_screen_stats(image) + self.assertEqual((stats["width"], stats["height"], stats["sampled_colors"]), (64, 64, 2)) + self.assertEqual(stats["luma_range"], 255) + self.assertTrue(stats["ready"]) + with self.assertRaisesRegex(Failed, "Ren'Py scene"): + image_stats(image) + + def test_real_uniform_and_low_contrast_pngs_are_not_os_ready(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "unready-os-fixture.png" + for first, second in ((0, 0), (255, 255), (0, 39), (100, 139)): + path.write_bytes(encoded_os_fixture(first=first, second=second)) + self.assertFalse(os_screen_stats(png_pixels(path))["ready"], (first, second)) + path.write_bytes(b"invalid PNG fixture") + with self.assertRaisesRegex(Failed, "not a PNG"): + png_pixels(path) + with self.assertRaisesRegex(Failed, "dimensions or pixels"): + os_screen_stats((64, 64, b"incomplete", 3)) + + def test_normal_wakeup_precedes_menu_and_all_readiness_commands_share_the_original_deadline(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + calls = [] + properties = {"ro.build.fingerprint": "boundary-input-not-a-device", + "ro.product.cpu.abilist": "x86_64", "ro.build.version.sdk": "35", + "ro.opengles.version": str(0x30000)} + def boundary_device(label, *parts, **kwargs): + calls.append((label, parts, kwargs["timeout"])) + if label.startswith("property-"): + output = properties[parts[-1]].encode() + elif label == "surfaceflinger": + output = b"GLES: controlled input boundary\n" + elif label == "screen-capture": + output = encoded_os_fixture() + else: + output = b"" + return subprocess.CompletedProcess([label], 0, output) + with patch.object(session, "device", side_effect=boundary_device), \ + patch("run_renpy_android_emulator_session.time.monotonic", return_value=595), \ + patch("sys.stdout", new_callable=io.StringIO): + session.validate_booted_device(deadline=600) + self.assertEqual(calls[0][:2], ("wakeup", ("shell", "input", "keyevent", "224"))) + self.assertEqual(calls[1][:2], ("unlock", ("shell", "input", "keyevent", "82"))) + self.assertTrue(all(timeout == 5 for _, _, timeout in calls)) + self.assertEqual(session.result["os_screen_attempts"], 1) + self.assertTrue(session.result["actual_os_screen"]["ready"]) + self.assertFalse(session.result["gameplay_attempted"]) + self.assertFalse(session.result["harness_started"]) + + def test_unready_frames_reach_original_deadline_without_more_commands_or_harness(self): + # Controlled clock/ADB responses test orchestration only; no emulator runs. + with tempfile.TemporaryDirectory() as temporary: + apk = Path(temporary) / "boundary-only-not-an-apk" + apk.write_bytes(b"boundary input") + session = self.session(temporary, apk=apk) + clock, calls = {"now": 595.0}, [] + def capture(label, *parts, **kwargs): + self.assertLess(clock["now"], 600) + calls.append(kwargs["timeout"]) + clock["now"] += 0.5 + return subprocess.CompletedProcess([label], 0, encoded_os_fixture(first=0, second=0)) + def boundary_boot(*_): + session.execution_started = True + session.result["boot_completed"] = True + session.wait_os_screen(deadline=600) + with patch.object(session, "gate_host", return_value=Path("boundary-emulator")), \ + patch.object(session, "prepare_sdk", return_value=Path("boundary-manager")), \ + patch.object(session, "boot", side_effect=boundary_boot), \ + patch.object(session, "boot_failure_diagnostics"), patch.object(session, "stop"), \ + patch.object(session, "gameplay") as harness, patch.object(session, "device", side_effect=capture), \ + patch("run_renpy_android_emulator_session.time.monotonic", side_effect=lambda: clock["now"]), \ + patch("run_renpy_android_emulator_session.time.sleep", side_effect=lambda seconds: clock.update(now=clock["now"] + seconds)), \ + patch("sys.stdout", new_callable=io.StringIO): + with self.assertRaisesRegex(Unavailable, "original boot deadline"): + session.run() + harness.assert_not_called() + self.assertEqual(clock["now"], 600) + self.assertEqual(calls, [5, 2.5]) + self.assertEqual(session.result["os_screen_attempts"], 2) + self.assertEqual(session.result["actual_os_screen"]["sampled_colors"], 1) + self.assertFalse(session.result["actual_os_screen"]["ready"]) + self.assertEqual(session.result["gameplay_verification"], "not_run") + self.assertFalse(session.result["gameplay_verified"]) + + def test_os_frame_and_numeric_observations_are_retained_until_visible(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + clock = {"now": 590.0} + frames = iter((encoded_os_fixture(first=0, second=0), encoded_os_fixture())) + def capture(label, *parts, **kwargs): + return subprocess.CompletedProcess([label], 0, next(frames)) + with patch.object(session, "device", side_effect=capture), \ + patch("run_renpy_android_emulator_session.time.monotonic", side_effect=lambda: clock["now"]), \ + patch("run_renpy_android_emulator_session.time.sleep", side_effect=lambda seconds: clock.update(now=clock["now"] + seconds)), \ + patch("sys.stdout", new_callable=io.StringIO) as console: + session.wait_os_screen(deadline=600) + self.assertEqual([row["ready"] for row in session.result["os_screen_observations"]], [False, True]) + self.assertEqual([row["elapsed_seconds"] for row in session.result["os_screen_observations"]], [590, 592]) + self.assertEqual(session.result["os_screen_attempts"], 2) + self.assertEqual((session.output / "actual-android-screen.png").read_bytes(), encoded_os_fixture()) + first = [json.loads(line) for line in console.getvalue().splitlines() + if "actual-android-first-os-screen" in line][0] + self.assertFalse(first["ready"]) + self.assertEqual(first["sampled_colors"], 1) + + def test_ready_frame_cannot_pass_after_capture_exhausts_the_original_deadline(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + clock = {"now": 599.0} + def capture(label, *parts, **kwargs): + self.assertEqual(kwargs["timeout"], 1) + clock["now"] = 600 + return subprocess.CompletedProcess([label], 0, encoded_os_fixture()) + with patch.object(session, "device", side_effect=capture) as command, \ + patch("run_renpy_android_emulator_session.time.monotonic", side_effect=lambda: clock["now"]), \ + patch("sys.stdout", new_callable=io.StringIO): + with self.assertRaisesRegex(Unavailable, "original boot deadline"): + session.wait_os_screen(deadline=600) + self.assertEqual(command.call_count, 1) + self.assertTrue(session.result["actual_os_screen"]["ready"]) + self.assertNotEqual(session.result["boot_phase"], "validated-booted-device") + + def test_readiness_permission_or_invalid_png_failure_is_not_retried(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + broken_compression = bytearray(encoded_os_fixture()) + broken_compression[41] = 0 # Real PNG IHDR, invalid zlib stream in IDAT. + for failure, captured in ((Unavailable("screen-capture permission denied"), b""), + (None, b"not a PNG"), (None, bytes(broken_compression))): + with patch.object(session, "device", side_effect=failure, + return_value=subprocess.CompletedProcess(["fixture"], 0, captured)) as command, \ + patch("run_renpy_android_emulator_session.time.monotonic", return_value=590): + with self.assertRaises((Unavailable, Failed)): + session.wait_os_screen(deadline=600) + self.assertEqual(command.call_count, 1) + with patch.object(session, "device", side_effect=AssertionError("Expired readiness must not call adb")), \ + patch("run_renpy_android_emulator_session.time.monotonic", return_value=600): + with self.assertRaisesRegex(Unavailable, "original boot deadline"): + session.boot_device("expired", "get-state", deadline=600) class AccelerationBoundaryTests(unittest.TestCase): From 1ec4ba0019b071a3304579c46892609422d13479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 04:46:50 +0000 Subject: [PATCH 313/331] fix(ios): retain memeq in the real libiconv relocation wrapper --- .gitattributes | 1 + tools/test_ios_libiconv_relocation.py | 184 ++++++++++++++++++ .../ports/libiconv/0002-Config-for-MSVC.patch | 14 ++ vcpkg/ports/libiconv/0003-Add-export.patch | 12 ++ .../ports/libiconv/0004-ModuleFileName.patch | 13 ++ vcpkg/ports/libiconv/ios-reloc-memeq.patch | 18 ++ vcpkg/ports/libiconv/portfile.cmake | 79 ++++++++ vcpkg/ports/libiconv/usage | 4 + .../ports/libiconv/vcpkg-cmake-wrapper.cmake | 33 ++++ vcpkg/ports/libiconv/vcpkg.json | 21 ++ 10 files changed, 379 insertions(+) create mode 100644 tools/test_ios_libiconv_relocation.py create mode 100644 vcpkg/ports/libiconv/0002-Config-for-MSVC.patch create mode 100644 vcpkg/ports/libiconv/0003-Add-export.patch create mode 100644 vcpkg/ports/libiconv/0004-ModuleFileName.patch create mode 100644 vcpkg/ports/libiconv/ios-reloc-memeq.patch create mode 100644 vcpkg/ports/libiconv/portfile.cmake create mode 100644 vcpkg/ports/libiconv/usage create mode 100644 vcpkg/ports/libiconv/vcpkg-cmake-wrapper.cmake create mode 100644 vcpkg/ports/libiconv/vcpkg.json diff --git a/.gitattributes b/.gitattributes index 159001c95..829a257ee 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,7 @@ *.patch text eol=lf # Empty context lines in unified diffs must retain their leading space. bridge/renpy_runtime/mobile_launcher/patches/**/*.patch whitespace=-blank-at-eol,-space-before-tab +vcpkg/ports/libiconv/*.patch whitespace=-blank-at-eol,-blank-at-eof,-space-before-tab # Product game archives are binary assets. *.xp3 -text diff --git a/tools/test_ios_libiconv_relocation.py b/tools/test_ios_libiconv_relocation.py new file mode 100644 index 000000000..019553776 --- /dev/null +++ b/tools/test_ios_libiconv_relocation.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Compile and execute the genuine gnulib install-reloc wrapper at -O0. + +The iOS libiconv 1.19 failure is a missing standalone-wrapper dependency. +This regression uses an explicitly pinned public gnulib checkout and its real +autotools-generated config/headers. It does not download a release, imitate an +iOS SDK, or claim that libiconv's release installation or mobile gameplay ran. +Supply normal Autoconf/Automake/m4 through PATH (or their standard env vars). +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import sys + + +GNULIB_COMMIT = "42dd0e518e06e55e4629710bff94790fe8b9aec9" +REPOSITORY = Path(__file__).resolve().parents[1] +PATCH = REPOSITORY / "vcpkg/ports/libiconv/ios-reloc-memeq.patch" +SOURCE_FILES = ( + "build-aux/install-reloc", "lib/progname.c", "lib/progreloc.c", + "lib/memeq.c", "lib/string.in.h", "modules/relocatable-prog-wrapper", +) + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def run(command: list[str], cwd: Path, env: dict[str, str], log: Path, + *, expected_failure: bool = False) -> subprocess.CompletedProcess[str]: + result = subprocess.run(command, cwd=cwd, env=env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + log.write_text(result.stdout, encoding="utf-8") + if not expected_failure and result.returncode: + raise RuntimeError(f"{command[0]} failed ({result.returncode}); see {log}") + return result + + +def validate_checkout(source: Path) -> str: + commit = subprocess.check_output( + ["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip() + if commit != GNULIB_COMMIT: + raise RuntimeError(f"Expected genuine gnulib {GNULIB_COMMIT}, got {commit}") + for name in SOURCE_FILES: + original = subprocess.check_output( + ["git", "-C", str(source), "show", f"{commit}:{name}"]) + if (source / name).read_bytes() != original: + raise RuntimeError(f"Gnulib source differs from its pinned commit: {name}") + return commit + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--gnulib-source", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True, + help="A fresh scratch directory; existing files are never changed") + parser.add_argument("--jobs", type=int, default=3) + parser.add_argument("--cc", default=os.environ.get("CC", "cc")) + args = parser.parse_args() + if args.jobs < 1 or sys.platform not in ("linux", "darwin"): + parser.error("This genuine host regression requires Linux or macOS and jobs >= 1") + source = args.gnulib_source.resolve(strict=True) + commit = validate_checkout(source) + output = args.output_dir.resolve() + output.mkdir(parents=True, exist_ok=False) + env = os.environ.copy() + cc = shlex.split(args.cc) + if not cc or any(any(c.isspace() for c in part) for part in cc): + parser.error("CC must have ordinary compiler arguments without embedded whitespace") + if not shutil.which(cc[0]): + raise RuntimeError(f"Compiler not available: {cc[0]}") + env.update(CC=shlex.join(cc), CFLAGS="-O0 -g") + generated = output / "generated" + run([str(source / "gnulib-tool"), "--create-testdir", "--without-tests", + f"--dir={generated}", "relocatable-prog", "minmax", + "glibc-internal/scratch_buffer"], output, env, + output / "generate.log") + for name in ("progname.c", "progreloc.c", "memeq.c"): + if (generated / "gllib" / name).read_bytes() != (source / "lib" / name).read_bytes(): + raise RuntimeError(f"Generator changed the actual source {name}") + wrapper = generated / "build-aux/install-reloc" + if wrapper.read_bytes() != (source / "build-aux/install-reloc").read_bytes(): + raise RuntimeError("Generator changed the actual install-reloc source") + install_prefix = output / "installed-prefix" + run(["./configure", "--enable-relocatable", f"--prefix={install_prefix}"], + generated, env, output / "configure.log") + run(["make", f"-j{args.jobs}"], generated, env, output / "make.log") + if not (generated / "config.h").is_file() or not (generated / "gllib/string.h").is_file(): + raise RuntimeError("Actual Autoconf-generated config/string headers are missing") + + # A real shared-library payload makes moving the prefix exercise the actual + # GNU wrapper's relocation, runtime search-path setup, and normal exec path. + payload = output / "payload" + payload.mkdir() + (payload / "library.c").write_text("int relocation_marker(void) { return 314159; }\n") + (payload / "program.c").write_text( + '#include \nextern int relocation_marker(void);\n' + 'int main(void) { int n = relocation_marker(); printf("marker=%d\\n", n); ' + 'return n != 314159; }\n') + darwin = sys.platform == "darwin" + library_name = "libaether_relocation.dylib" if darwin else "libaether_relocation.so" + shared_flags = (["-dynamiclib", "-Wl,-install_name,@rpath/" + library_name] + if darwin else ["-shared", "-fPIC"]) + run(cc + shared_flags + [str(payload / "library.c"), "-o", str(payload / library_name)], + output, env, output / "payload-library.log") + binary = payload / "program" + run(cc + [str(payload / "program.c"), "-L" + str(payload), + "-laether_relocation", "-o", str(binary)], + output, env, output / "payload-program.log") + + def install(script: Path, name: str) -> tuple[Path, subprocess.CompletedProcess[str]]: + # Both cases use the real configured prefix. Reset only our own failed + # installation before re-running the genuine script with the patch. + prefix = install_prefix + if prefix.exists(): + shutil.rmtree(prefix) + (prefix / "bin").mkdir(parents=True) + (prefix / "lib").mkdir() + shutil.copy2(payload / library_name, prefix / "lib" / library_name) + install_env = env.copy() + install_env.update( + RELOC_LIBRARY_PATH_VAR="DYLD_LIBRARY_PATH" if darwin else "LD_LIBRARY_PATH", + RELOC_LIBRARY_PATH_VALUE=str(prefix / "lib"), RELOC_PREFIX=str(prefix), + RELOC_DESTDIR="", RELOC_COMPILE_COMMAND=shlex.join(cc + ["-O0", "-g"]), + RELOC_SRCDIR=str(generated / "gllib"), RELOC_BUILDDIR=str(generated / "gllib"), + RELOC_CONFIG_H_DIR=str(generated), RELOC_EXEEXT="", RELOC_STRIP_PROG=":", + RELOC_INSTALL_PROG="install -m 755") + result = run(["sh", str(script), str(binary), str(prefix / "bin/program")], + output, install_env, output / (name + "-install.log"), + expected_failure=True) + return prefix, result + + _, original = install(wrapper, "original") + if original.returncode == 0 or not re.search(r"undefined|unresolved", original.stdout, re.I) \ + or not re.search(r"\b_?memeq\b", original.stdout): + raise RuntimeError("Original genuine -O0 wrapper did not fail with undefined memeq") + + run(["patch", "--batch", "--fuzz=0", "-p1", "-i", str(PATCH)], generated, + env, output / "patch.log") + prefix, patched = install(wrapper, "patched") + if patched.returncode: + raise RuntimeError("Patched genuine wrapper failed; see patched-install.log") + installed = prefix / "bin/program" + if not installed.is_file() or digest(installed) == digest(binary) \ + or digest(prefix / "bin/program.bin") != digest(binary): + raise RuntimeError("The real relocating wrapper/payload installation is missing") + runtime_env = env.copy() + for name in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FALLBACK_LIBRARY_PATH"): + runtime_env.pop(name, None) + result = run([str(installed)], output, runtime_env, output / "installed.log") + if result.stdout != "marker=314159\n": + raise RuntimeError("The actual installed wrapper did not execute its shared-library payload") + moved = output / "relocated-prefix" + shutil.move(str(prefix), moved) + result = run([str(moved / "bin/program")], output, runtime_env, output / "relocated.log") + if result.stdout != "marker=314159\n": + raise RuntimeError("The actual GNU wrapper did not resolve its relocated library") + evidence = { + "status": "PASS", "scope": "genuine-canonical-gnulib-host-wrapper", + "gnulib_commit": commit, "platform": sys.platform, + "compiler": subprocess.check_output(cc + ["--version"], text=True).splitlines()[0], + "source_sha256": {name: digest(source / name) for name in SOURCE_FILES}, + "patch_sha256": digest(PATCH), "generated_config_sha256": digest(generated / "config.h"), + "generated_string_header_sha256": digest(generated / "gllib/string.h"), + "original_undefined_memeq": "PASS", "patched_standalone_link": "PASS", + "installed_wrapper_execution": "PASS", "relocated_library_execution": "PASS", + "libiconv_1_19_release_install": "NOT_RUN", "apple_ios_sdk_link": "NOT_RUN", + "ios_application_gameplay": "NOT_RUN", + } + (output / "evidence.json").write_text(json.dumps(evidence, indent=2) + "\n") + print(json.dumps(evidence, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/vcpkg/ports/libiconv/0002-Config-for-MSVC.patch b/vcpkg/ports/libiconv/0002-Config-for-MSVC.patch new file mode 100644 index 000000000..0b40e366b --- /dev/null +++ b/vcpkg/ports/libiconv/0002-Config-for-MSVC.patch @@ -0,0 +1,14 @@ +diff --git a/srclib/fcntl.c b/srclib/fcntl.c +index f9753c4..02fc8be 100644 +--- a/srclib/fcntl.c ++++ b/srclib/fcntl.c +@@ -37,6 +37,9 @@ + /* Get declarations of the native Windows API functions. */ + # define WIN32_LEAN_AND_MEAN + # include ++#if defined(WINAPI_FAMILY) && (WINAPI_FAMILY == WINAPI_FAMILY_APP) ++#define HANDLE_FLAG_INHERIT 0 ++#endif + + /* Get _get_osfhandle. */ + # if GNULIB_MSVC_NOTHROW diff --git a/vcpkg/ports/libiconv/0003-Add-export.patch b/vcpkg/ports/libiconv/0003-Add-export.patch new file mode 100644 index 000000000..c8d131d2e --- /dev/null +++ b/vcpkg/ports/libiconv/0003-Add-export.patch @@ -0,0 +1,12 @@ +diff -uNr libiconv-1.16/libcharset/include/libcharset.h.in libiconv-1.16-work/libcharset/include/libcharset.h.in +--- libiconv-1.16/libcharset/include/libcharset.h.in 2018-09-17 19:07:42.000000000 +0300 ++++ libiconv-1.16-work/libcharset/include/libcharset.h.in 2019-12-04 17:15:50.000000000 +0300 +@@ -33,7 +33,7 @@ + by the corresponding pathname with the current prefix instead. Both + prefixes should be directory names without trailing slash (i.e. use "" + instead of "/"). */ +-extern void libcharset_set_relocation_prefix (const char *orig_prefix, ++extern void LIBCHARSET_DLL_EXPORTED libcharset_set_relocation_prefix (const char *orig_prefix, + const char *curr_prefix); + + diff --git a/vcpkg/ports/libiconv/0004-ModuleFileName.patch b/vcpkg/ports/libiconv/0004-ModuleFileName.patch new file mode 100644 index 000000000..df409c88e --- /dev/null +++ b/vcpkg/ports/libiconv/0004-ModuleFileName.patch @@ -0,0 +1,13 @@ +diff --git a/srclib/relocatable.c b/srclib/relocatable.c +index 365c085..4e08fdc 100644 +--- a/srclib/relocatable.c ++++ b/srclib/relocatable.c +@@ -338,7 +338,7 @@ DllMain (HINSTANCE module_handle, DWORD event, LPVOID reserved) + /* The DLL is being loaded into an application's address range. */ + static char location[MAX_PATH]; + +- if (!GetModuleFileName (module_handle, location, sizeof (location))) ++ if (!GetModuleFileNameA (module_handle, location, sizeof (location))) + /* Shouldn't happen. */ + return FALSE; + diff --git a/vcpkg/ports/libiconv/ios-reloc-memeq.patch b/vcpkg/ports/libiconv/ios-reloc-memeq.patch new file mode 100644 index 000000000..d6e65e320 --- /dev/null +++ b/vcpkg/ports/libiconv/ios-reloc-memeq.patch @@ -0,0 +1,18 @@ +--- a/build-aux/install-reloc ++++ b/build-aux/install-reloc +@@ -282,6 +282,7 @@ + "$srcdir"/relocwrapper.c \ + "$srcdir"/progname.c \ + "$srcdir"/progreloc.c \ ++ "$srcdir"/memeq.c \ + "$srcdir"/areadlink.c \ + "$srcdir"/careadlinkat.c \ + "$srcdir"/allocator.c \ +@@ -308,6 +309,7 @@ + rm -f relocwrapper.o \ + progname.o \ + progreloc.o \ ++ memeq.o \ + areadlink.o \ + careadlinkat.o \ + allocator.o \ diff --git a/vcpkg/ports/libiconv/portfile.cmake b/vcpkg/ports/libiconv/portfile.cmake new file mode 100644 index 000000000..ccf30dc3b --- /dev/null +++ b/vcpkg/ports/libiconv/portfile.cmake @@ -0,0 +1,79 @@ +if(NOT DEFINED X_VCPKG_BUILD_GNU_LIBICONV) + set(X_VCPKG_BUILD_GNU_LIBICONV 0) + if(VCPKG_TARGET_IS_WINDOWS OR VCPKG_TARGET_IS_IOS OR VCPKG_TARGET_IS_BSD) + set(X_VCPKG_BUILD_GNU_LIBICONV 1) + elseif(VCPKG_TARGET_IS_ANDROID) + vcpkg_cmake_get_vars(cmake_vars_file) + include("${cmake_vars_file}") + if(VCPKG_DETECTED_CMAKE_SYSTEM_VERSION VERSION_LESS "28") + set(X_VCPKG_BUILD_GNU_LIBICONV 1) + endif() + endif() +endif() + +if(NOT X_VCPKG_BUILD_GNU_LIBICONV) + message(STATUS "Not building GNU libiconv.") + set(VCPKG_POLICY_EMPTY_PACKAGE enabled) + file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/share/iconv") + file(COPY "${CMAKE_CURRENT_LIST_DIR}/vcpkg-cmake-wrapper.cmake" DESTINATION "${CURRENT_PACKAGES_DIR}/share/iconv") + return() +endif() + +vcpkg_download_distfile(ARCHIVE + URLS "https://ftpmirror.gnu.org/gnu/libiconv/libiconv-${VERSION}.tar.gz" + "https://ftp.gnu.org/gnu/libiconv/libiconv-${VERSION}.tar.gz" + "https://www.mirrorservice.org/sites/ftp.gnu.org/gnu/libiconv/libiconv-${VERSION}.tar.gz" + FILENAME "libiconv-${VERSION}.tar.gz" + SHA512 1e8150f9bca907579330cd9c44ebbee46a260271fbe8f50d5ee24a39ef29c8d254505e85c3409324f7440596da711a8bd49e89f848a6be0cb3238a58c24aaecd +) +# Keep the builtin 1.19 recipe, with only the iOS standalone wrapper fix. +vcpkg_list(SET LIBICONV_PATCHES + 0002-Config-for-MSVC.patch + 0003-Add-export.patch + 0004-ModuleFileName.patch +) +if(VCPKG_TARGET_IS_IOS) + # install-reloc links its own sources instead of srclib/libicrt.a. + vcpkg_list(APPEND LIBICONV_PATCHES ios-reloc-memeq.patch) +endif() + +vcpkg_extract_source_archive(SOURCE_PATH + ARCHIVE "${ARCHIVE}" + SOURCE_BASE "v${VERSION}" + PATCHES ${LIBICONV_PATCHES} +) + +vcpkg_list(SET OPTIONS) +if (NOT VCPKG_TARGET_IS_ANDROID) + vcpkg_list(APPEND OPTIONS --enable-relocatable) +endif() + +vcpkg_make_configure( + SOURCE_PATH "${SOURCE_PATH}" + OPTIONS + --enable-extra-encodings + --without-libiconv-prefix + --without-libintl-prefix + ${OPTIONS} +) +vcpkg_make_install() + +vcpkg_copy_pdbs() +vcpkg_copy_tool_dependencies("${CURRENT_PACKAGES_DIR}/tools/${PORT}/bin") +vcpkg_copy_tool_dependencies("${CURRENT_PACKAGES_DIR}/tools/${PORT}/debug/bin") + +file(COPY "${CMAKE_CURRENT_LIST_DIR}/vcpkg-cmake-wrapper.cmake" DESTINATION "${CURRENT_PACKAGES_DIR}/share/iconv") + +set(VCPKG_POLICY_ALLOW_RESTRICTED_HEADERS enabled) +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/share") +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/share/${PORT}") # share contains unneeded doc files + +# Please keep, the default usage is broken +file(INSTALL "${CMAKE_CURRENT_LIST_DIR}/usage" DESTINATION "${CURRENT_PACKAGES_DIR}/share/${PORT}") + +# Handle copyright +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/COPYING.LIB" "${SOURCE_PATH}/COPYING" COMMENT " +The libiconv and libcharset libraries and their header files are under LGPL, +see COPYING.LIB below. + +The iconv program and the documentation are under GPL, see COPYING below.") diff --git a/vcpkg/ports/libiconv/usage b/vcpkg/ports/libiconv/usage new file mode 100644 index 000000000..16d80a2d7 --- /dev/null +++ b/vcpkg/ports/libiconv/usage @@ -0,0 +1,4 @@ +libiconv provides CMake targets: + + find_package(Iconv REQUIRED) + target_link_libraries(main PRIVATE Iconv::Iconv) diff --git a/vcpkg/ports/libiconv/vcpkg-cmake-wrapper.cmake b/vcpkg/ports/libiconv/vcpkg-cmake-wrapper.cmake new file mode 100644 index 000000000..af29f1264 --- /dev/null +++ b/vcpkg/ports/libiconv/vcpkg-cmake-wrapper.cmake @@ -0,0 +1,33 @@ +include(SelectLibraryConfigurations) + +set(Z_VCPKG_ICONV_USES_DARWIN_SDK FALSE) +if(APPLE AND CMAKE_OSX_SYSROOT AND EXISTS "${CMAKE_OSX_SYSROOT}/usr/include/iconv.h") + set(Z_VCPKG_ICONV_USES_DARWIN_SDK TRUE) + set(Iconv_IS_BUILT_IN FALSE CACHE BOOL "iconv requires a separate library") + if(NOT Iconv_INCLUDE_DIR) + set(Iconv_INCLUDE_DIR "${CMAKE_OSX_SYSROOT}/usr/include" CACHE PATH "iconv include directory") + endif() + if(NOT Iconv_LIBRARY) + set(Iconv_LIBRARY iconv CACHE STRING "iconv library") + endif() +endif() + +_find_package(${ARGS}) +if(Iconv_FOUND AND NOT Iconv_IS_BUILT_IN AND NOT Z_VCPKG_ICONV_USES_DARWIN_SDK) + find_path(CHARSET_INCLUDE_DIR NAMES "libcharset.h" HINTS "${_VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/include") + find_library(CHARSET_LIBRARY_DEBUG NAMES charsetd libcharsetd charset libcharset NAMES_PER_DIR PATH_SUFFIXES lib HINTS "${_VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/debug") + find_library(CHARSET_LIBRARY_RELEASE NAMES charset libcharset NAMES_PER_DIR PATH_SUFFIXES lib HINTS "${_VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}") + select_library_configurations(CHARSET) + if(NOT TARGET Iconv::Charset) + add_library(Iconv::Charset INTERFACE IMPORTED) + set_target_properties(Iconv::Charset PROPERTIES + INTERFACE_LINK_LIBRARIES "\$<\$>:${CHARSET_LIBRARY_RELEASE}>;\$<\$:${CHARSET_LIBRARY_DEBUG}>" + INTERFACE_INCLUDE_DIRECTORIES "${CHARSET_INCLUDE_DIRS}") + endif() + if(CHARSET_LIBRARIES) + list(APPEND Iconv_LIBRARIES ${CHARSET_LIBRARIES}) + if(TARGET Iconv::Iconv) + set_property(TARGET Iconv::Iconv APPEND PROPERTY INTERFACE_LINK_LIBRARIES Iconv::Charset) + endif() + endif() +endif() diff --git a/vcpkg/ports/libiconv/vcpkg.json b/vcpkg/ports/libiconv/vcpkg.json new file mode 100644 index 000000000..700d3cda0 --- /dev/null +++ b/vcpkg/ports/libiconv/vcpkg.json @@ -0,0 +1,21 @@ +{ + "name": "libiconv", + "version": "1.19", + "description": [ + "iconv() text conversion.", + "This port installs GNU libiconv if the system C runtime doesn't provide a suitable iconv() implementation." + ], + "homepage": "https://www.gnu.org/software/libiconv/", + "license": null, + "dependencies": [ + { + "name": "vcpkg-cmake-get-vars", + "host": true, + "platform": "android" + }, + { + "name": "vcpkg-make", + "host": true + } + ] +} From 9782589703203306157d7dd7cad68d332b9beed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 05:19:52 +0000 Subject: [PATCH 314/331] fix(ios): pass real OpenSSL configure arguments for Simulator --- .gitattributes | 3 + tools/test_ios_openssl_configure.py | 218 ++++++++++++++++++ vcpkg/ports/openssl/cmake-config.patch | 62 +++++ vcpkg/ports/openssl/command-line-length.patch | 13 ++ vcpkg/ports/openssl/install-pc-files.cmake | 32 +++ vcpkg/ports/openssl/openssl.pc.in | 6 + vcpkg/ports/openssl/portfile.cmake | 93 ++++++++ vcpkg/ports/openssl/script-prefix.patch | 22 ++ vcpkg/ports/openssl/unix/android-cc.patch | 20 ++ vcpkg/ports/openssl/unix/configure | 31 +++ .../ports/openssl/unix/move-openssldir.patch | 16 ++ vcpkg/ports/openssl/unix/no-empty-dirs.patch | 22 ++ .../unix/no-static-libs-for-shared.patch | 12 + vcpkg/ports/openssl/unix/portfile.cmake | 195 ++++++++++++++++ vcpkg/ports/openssl/unix/remove-deps.cmake | 7 + vcpkg/ports/openssl/usage | 5 + .../openssl/vcpkg-cmake-wrapper.cmake.in | 82 +++++++ vcpkg/ports/openssl/vcpkg.json | 37 +++ .../openssl/windows/install-layout.patch | 13 ++ .../ports/openssl/windows/install-pdbs.patch | 46 ++++ .../openssl/windows/install-programs.diff | 13 ++ vcpkg/ports/openssl/windows/portfile.cmake | 187 +++++++++++++++ 22 files changed, 1135 insertions(+) create mode 100644 tools/test_ios_openssl_configure.py create mode 100644 vcpkg/ports/openssl/cmake-config.patch create mode 100644 vcpkg/ports/openssl/command-line-length.patch create mode 100644 vcpkg/ports/openssl/install-pc-files.cmake create mode 100644 vcpkg/ports/openssl/openssl.pc.in create mode 100644 vcpkg/ports/openssl/portfile.cmake create mode 100644 vcpkg/ports/openssl/script-prefix.patch create mode 100644 vcpkg/ports/openssl/unix/android-cc.patch create mode 100755 vcpkg/ports/openssl/unix/configure create mode 100644 vcpkg/ports/openssl/unix/move-openssldir.patch create mode 100644 vcpkg/ports/openssl/unix/no-empty-dirs.patch create mode 100644 vcpkg/ports/openssl/unix/no-static-libs-for-shared.patch create mode 100644 vcpkg/ports/openssl/unix/portfile.cmake create mode 100644 vcpkg/ports/openssl/unix/remove-deps.cmake create mode 100644 vcpkg/ports/openssl/usage create mode 100644 vcpkg/ports/openssl/vcpkg-cmake-wrapper.cmake.in create mode 100644 vcpkg/ports/openssl/vcpkg.json create mode 100644 vcpkg/ports/openssl/windows/install-layout.patch create mode 100644 vcpkg/ports/openssl/windows/install-pdbs.patch create mode 100644 vcpkg/ports/openssl/windows/install-programs.diff create mode 100644 vcpkg/ports/openssl/windows/portfile.cmake diff --git a/.gitattributes b/.gitattributes index 829a257ee..6cdc2e687 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,9 @@ # Empty context lines in unified diffs must retain their leading space. bridge/renpy_runtime/mobile_launcher/patches/**/*.patch whitespace=-blank-at-eol,-space-before-tab vcpkg/ports/libiconv/*.patch whitespace=-blank-at-eol,-blank-at-eof,-space-before-tab +vcpkg/ports/openssl/**/*.patch whitespace=-blank-at-eol,-blank-at-eof,-space-before-tab +# Retain the fixed upstream Windows recipe byte-for-byte. +vcpkg/ports/openssl/windows/portfile.cmake whitespace=-blank-at-eol # Product game archives are binary assets. *.xp3 -text diff --git a/tools/test_ios_openssl_configure.py b/tools/test_ios_openssl_configure.py new file mode 100644 index 000000000..23c4d31da --- /dev/null +++ b/tools/test_ios_openssl_configure.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Exercise the real legacy vcpkg command with OpenSSL 3.6.3 Configure. + +This needs existing public OpenSSL/vcpkg checkouts and actual CMake, Bash, Perl +and a host compiler. It generates a native HOST Makefile/configdata with the +unmodified production wrapper. No SDK, compiler, Configure, or return value is +mocked; target Apple compilation/application/gameplay are explicitly NOT_RUN. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys + + +OPENSSL_COMMIT = "aae016bfd52fcad2bc9657c2c782cfdf73b1ed5f" +BASELINE = "9e593bb18ea69cc5095e012465dcd675a822ed0d" +REPOSITORY = Path(__file__).resolve().parents[1] +PORT = REPOSITORY / "vcpkg/ports/openssl" + + +def sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def literal(value: str | Path) -> str: + text = str(value) + if "]==]" in text: + raise ValueError("Unsupported CMake bracket terminator in input") + return "[==[" + text + "]==]" + + +def block(recipe: str, name: str) -> str: + begin = "# AETHER_OPENSSL_IOS_TRIPLET_" + name + "_BEGIN\n" + end = "# AETHER_OPENSSL_IOS_TRIPLET_" + name + "_END\n" + if recipe.count(begin) != 1 or recipe.count(end) != 1: + raise RuntimeError("The production iOS triplet block is missing or ambiguous") + return recipe.split(begin)[1].split(end)[0] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--openssl-source", type=Path, required=True) + parser.add_argument("--vcpkg-source", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--cmake", default="cmake") + args = parser.parse_args() + source = args.openssl_source.resolve(strict=True) + vcpkg = args.vcpkg_source.resolve(strict=True) + output = args.output_dir.resolve() + if any(any(c.isspace() for c in str(p)) for p in (source, vcpkg, output)): + parser.error("The unmodified upstream wrapper requires paths without whitespace") + if sys.platform not in ("linux", "darwin"): + parser.error("This real host Configure regression supports Linux or macOS") + commit = subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], + text=True).strip() + if commit != OPENSSL_COMMIT: + raise RuntimeError(f"Expected genuine OpenSSL 3.6.3 {OPENSSL_COMMIT}, got {commit}") + subprocess.run(["git", "-C", str(source), "diff", "--exit-code", "HEAD", "--"], + check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + wrapper = PORT / "unix/configure" + original = subprocess.check_output( + ["git", "-C", str(vcpkg), "show", f"{BASELINE}:ports/openssl/unix/configure"]) + if wrapper.read_bytes() != original: + raise RuntimeError("The production wrapper differs from the pinned original") + cmake, perl, bash, cc = (shutil.which(x) for x in (args.cmake, "perl", "bash", "cc")) + if not all((cmake, perl, bash, cc)): + raise RuntimeError("Actual CMake, Perl, Bash and the host compiler are required") + output.mkdir(parents=True, exist_ok=False) + recipe = (PORT / "unix/portfile.cmake").read_text() + normalize, restore = block(recipe, "NORMALIZE"), block(recipe, "RESTORE") + ios_body = recipe.split("elseif(VCPKG_TARGET_IS_IOS)\n")[1].split( + "elseif(VCPKG_TARGET_IS_TVOS OR VCPKG_TARGET_IS_WATCHOS)")[0] + helper = vcpkg / "scripts/cmake/vcpkg_configure_make.cmake" + helper_text = helper.read_text() + commands = [line.strip() for line in helper_text.splitlines() + if line.strip().startswith('set(command "${base_cmd}" -c "${configure_env} ./')] + if len(commands) != 1: + raise RuntimeError("The real legacy vcpkg command construction changed") + quote_begin = ' foreach(var IN ITEMS arg_OPTIONS arg_OPTIONS_RELEASE arg_OPTIONS_DEBUG)\n' + quote_loop = helper_text.split(quote_begin)[1].split( + "\n foreach(current_buildtype IN LISTS all_buildtypes)")[0] + quote_loop = quote_begin + quote_loop + # These are actual public production functions, not local lookalikes. + includes = "".join("include(" + literal(path) + ")\n" for path in ( + vcpkg / "scripts/cmake/z_vcpkg_function_arguments.cmake", + vcpkg / "scripts/cmake/vcpkg_list.cmake", + vcpkg / "ports/vcpkg-make/vcpkg_scripts.cmake", + vcpkg / "ports/vcpkg-make/vcpkg_make.cmake", + )) + checks = includes + "\n" + for arch, sdk, expected in ( + ("arm64", "iphonesimulator", "iossimulator-arm64-xcrun"), + ("arm64", "iphoneos", "ios64-xcrun"), + ("arm", "iphoneos", "ios-xcrun"), + ("x64", "iphonesimulator", "iossimulator-xcrun"), + ("x86", "iphonesimulator", "iossimulator-xcrun"), + ): + checks += f'set(VCPKG_TARGET_ARCHITECTURE {arch})\nset(VCPKG_OSX_SYSROOT {sdk})\n' + checks += ios_body + checks += f'if(NOT OPENSSL_ARCH STREQUAL "{expected}")\nmessage(FATAL_ERROR "Wrong actual OpenSSL target: ${{OPENSSL_ARCH}}")\nendif()\n' + # Leave non-iOS/undefined/empty contexts exactly as the actual caller set + # them, and prove modern make still receives the original two arguments. + checks += 'include(' + literal(REPOSITORY / "vcpkg/triplets/arm64-ios-simulator.cmake") + ')\n' + checks += '''set(VCPKG_TARGET_IS_IOS TRUE) +set(original_triplet "${VCPKG_MAKE_BUILD_TRIPLET}") +''' + normalize + restore + ''' +if(NOT VCPKG_MAKE_BUILD_TRIPLET STREQUAL original_triplet) +message(FATAL_ERROR "The production recipe did not restore the original triplet list") +endif() +z_vcpkg_make_get_configure_triplets(modern_triplets) +if(NOT modern_triplets STREQUAL original_triplet) +message(FATAL_ERROR "The real modern make helper lost the original argv") +endif() +z_vcpkg_make_get_crosscompiling(is_cross ${modern_triplets}) +if(NOT is_cross) +message(FATAL_ERROR "The real modern make helper lost cross-compilation detection") +endif() +set(VCPKG_TARGET_IS_IOS FALSE) +''' + normalize + restore + ''' +if(NOT VCPKG_MAKE_BUILD_TRIPLET STREQUAL original_triplet) +message(FATAL_ERROR "Non-iOS triplet context was modified") +endif() +set(VCPKG_TARGET_IS_IOS TRUE) +unset(VCPKG_MAKE_BUILD_TRIPLET) +''' + normalize + restore + ''' +if(DEFINED VCPKG_MAKE_BUILD_TRIPLET) +message(FATAL_ERROR "An undefined caller variable became defined") +endif() +set(VCPKG_MAKE_BUILD_TRIPLET "") +''' + normalize + restore + ''' +if(NOT DEFINED VCPKG_MAKE_BUILD_TRIPLET OR NOT VCPKG_MAKE_BUILD_TRIPLET STREQUAL "") +message(FATAL_ERROR "The empty caller variable was not preserved") +endif() +''' + check_file = output / "production-scope.cmake" + check_file.write_text(checks) + env = os.environ.copy() + result = subprocess.run([cmake, "-P", str(check_file)], cwd=output, env=env, + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + (output / "production-scope.log").write_text(result.stdout) + if result.returncode: + raise RuntimeError("Actual production scope/modern-helper checks failed; see production-scope.log") + + arm64 = platform.machine().lower() in ("aarch64", "arm64") + target = ("darwin64-arm64" if arm64 else "darwin64-x86_64") if sys.platform == "darwin" \ + else ("linux-aarch64" if arm64 else "linux-x86_64") + cases = {} + for label in ("original", "patched"): + directory = output / label + directory.mkdir() + script = includes + 'include(' + literal(REPOSITORY / "vcpkg/triplets/arm64-ios-simulator.cmake") + ')\n' + script += 'set(VCPKG_TARGET_IS_IOS TRUE)\nset(original_triplet "${VCPKG_MAKE_BUILD_TRIPLET}")\n' + if label == "patched": + script += normalize + script += 'set(arg_BUILD_TRIPLET ${VCPKG_MAKE_BUILD_TRIPLET})\n' + script += 'set(base_cmd ' + literal(bash) + ')\n' + script += 'set(configure_env ' + literal("CC='" + cc + "'") + ')\n' + relative = os.path.relpath(wrapper.parent, directory) + script += 'set(relative_build_path ' + literal(relative) + ')\n' + script += 'set(arg_OPTIONS ' + ' '.join(literal(x) for x in ( + perl, source / "Configure", target, "no-tests", "no-apps", "no-shared", "no-module")) + ')\n' + script += 'set(arg_OPTIONS_DEBUG --debug)\nset(current_buildtype DEBUG)\n' + quote_loop + '\n' + script += commands[0] + '\nlist(LENGTH command argv_count)\n' + script += '''execute_process(COMMAND ${command} +WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}" INPUT_FILE /dev/null +RESULT_VARIABLE rc OUTPUT_FILE configure-out.log ERROR_FILE configure-err.log) +file(WRITE "${CMAKE_CURRENT_LIST_DIR}/result.txt" "${rc}\\n${argv_count}\\n") +''' + if label == "patched": + script += restore + '\n' + script += '''if(NOT VCPKG_MAKE_BUILD_TRIPLET STREQUAL original_triplet) +message(FATAL_ERROR "Legacy call altered the caller's modern helper triplet") +endif() +''' + cmake_file = directory / "configure.cmake" + cmake_file.write_text(script) + result = subprocess.run([cmake, "-P", str(cmake_file)], cwd=directory, + env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + (directory / "cmake.log").write_text(result.stdout) + if result.returncode: + raise RuntimeError(f"Actual CMake command failed; see {directory}/cmake.log") + cases[label] = (directory / "result.txt").read_text().splitlines() + if cases["original"] != ["0", "4"] or (output / "original/Makefile").exists() \ + or (output / "original/configdata.pm").exists(): + raise RuntimeError("Original actual argv did not reproduce the zero-exit/missing-Makefile failure") + if cases["patched"] != ["0", "3"] or not (output / "patched/Makefile").is_file() \ + or not (output / "patched/configdata.pm").is_file(): + raise RuntimeError("Patched actual argv did not run the genuine OpenSSL generator") + dump = subprocess.run([perl, "configdata.pm", "--dump"], cwd=output / "patched", + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True) + (output / "patched/configdata-dump.log").write_text(dump.stdout) + if "3.6.3" not in dump.stdout or target not in dump.stdout: + raise RuntimeError("The real configdata does not identify the pinned OpenSSL host target") + evidence = { + "status": "PASS", "scope": "genuine-openssl-host-configuration", + "openssl_commit": commit, "openssl_version": "3.6.3", "host_target": target, + "cmake_version": subprocess.check_output([cmake, "--version"], text=True).splitlines()[0], + "original_zero_exit_missing_makefile": "PASS", "production_ios_join_restore": "PASS", + "modern_make_list_and_cross_detection": "PASS", "production_target_and_context_gates": "PASS", + "actual_host_makefile_configdata": "PASS", "configure_sha256": sha(source / "Configure"), + "production_wrapper_sha256": sha(wrapper), "legacy_helper_sha256": sha(helper), + "production_recipe_sha256": sha(PORT / "unix/portfile.cmake"), + "apple_ios_sdk_build_link": "NOT_RUN", "ios_app_gameplay": "NOT_RUN", + } + (output / "evidence.json").write_text(json.dumps(evidence, indent=2) + "\n") + print(json.dumps(evidence, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/vcpkg/ports/openssl/cmake-config.patch b/vcpkg/ports/openssl/cmake-config.patch new file mode 100644 index 000000000..74251abef --- /dev/null +++ b/vcpkg/ports/openssl/cmake-config.patch @@ -0,0 +1,62 @@ +diff --git a/Configurations/unix-Makefile.tmpl b/Configurations/unix-Makefile.tmpl +index 09303c4..487ff68 100644 +--- a/Configurations/unix-Makefile.tmpl ++++ b/Configurations/unix-Makefile.tmpl +@@ -338,7 +338,7 @@ bindir={- file_name_is_absolute($bindir) + ? $bindir : '$(INSTALLTOP)/$(BINDIR)' -} + + PKGCONFIGDIR=$(libdir)/pkgconfig +-CMAKECONFIGDIR=$(libdir)/cmake/OpenSSL ++CMAKECONFIGDIR=$(INSTALLTOP)/share/openssl + + MANDIR=$(INSTALLTOP)/share/man + DOCDIR=$(INSTALLTOP)/share/doc/$(BASENAME) +diff --git a/Configurations/windows-makefile.tmpl b/Configurations/windows-makefile.tmpl +index 894834c..d6d3c41 100644 +--- a/Configurations/windows-makefile.tmpl ++++ b/Configurations/windows-makefile.tmpl +@@ -254,7 +254,7 @@ MODULESDIR=$(MODULESDIR_dev)$(MODULESDIR_dir) + libdir={- file_name_is_absolute($libdir) + ? $libdir : '$(INSTALLTOP)\$(LIBDIR)' -} + +-CMAKECONFIGDIR=$(libdir)\cmake\OpenSSL ++CMAKECONFIGDIR=$(INSTALLTOP)\share\openssl + + ##### User defined commands and flags ################################ + +diff --git a/exporters/cmake/OpenSSLConfig.cmake.in b/exporters/cmake/OpenSSLConfig.cmake.in +index 766aebe..026680a 100644 +--- a/exporters/cmake/OpenSSLConfig.cmake.in ++++ b/exporters/cmake/OpenSSLConfig.cmake.in +@@ -91,8 +91,7 @@ get_filename_component(_ossl_prefix "${CMAKE_CURRENT_LIST_FILE}" PATH) + {- + # For each component in $OpenSSL::safe::installdata::CMAKECONFIGDIR[0] relative to + # $OpenSSL::safe::installdata::PREFIX[0], have CMake figure out the parent directory. +- my $d = join('/', unixify(catdir($OpenSSL::safe::installdata::LIBDIR_REL_PREFIX[0], +- $OpenSSL::safe::installdata::CMAKECONFIGDIR_REL_LIBDIR[0]), 1)); ++ my $d = 'share/openssl'; + $OUT = ''; + if ($d ne '.') { + $OUT .= 'get_filename_component(_ossl_prefix "${_ossl_prefix}" PATH)' . "\n" +@@ -141,6 +140,14 @@ set(OPENSSL_APPLINK_SOURCE "${_ossl_prefix}/{- unixify($OpenSSL::safe::installda + {- output_on() if $disabled{uplink}; "" -} + set(OPENSSL_PROGRAM "${OPENSSL_RUNTIME_DIR}/{- platform->bin('openssl') -}") + ++if(NOT Z_VCPKG_OPENSSL_USE_SINGLE_CONFIG) ++ # Prevent loop ++ set(Z_VCPKG_OPENSSL_USE_SINGLE_CONFIG "prevent-loop") ++ # Chainload vcpkg's module-based multi-config target setup ++ find_package(OpenSSL MODULE) ++ set(Z_VCPKG_OPENSSL_USE_SINGLE_CONFIG 0) ++else() ++ # Use official single-config target setup + # Set up the imported targets + if(_ossl_use_static_libs) + {- output_off() unless $no_static; "" -} +@@ -240,5 +247,6 @@ set_property(TARGET OpenSSL::applink PROPERTY + INTERFACE_SOURCES "${OPENSSL_APPLINK_SOURCE}") + {- output_on() if $disabled{uplink}; "" -} + ++endif() + unset(_ossl_prefix) + unset(_ossl_use_static_libs) diff --git a/vcpkg/ports/openssl/command-line-length.patch b/vcpkg/ports/openssl/command-line-length.patch new file mode 100644 index 000000000..f379d2da2 --- /dev/null +++ b/vcpkg/ports/openssl/command-line-length.patch @@ -0,0 +1,13 @@ +diff --git a/Configurations/unix-Makefile.tmpl b/Configurations/unix-Makefile.tmpl +index 8ddb128..52b9ad6 100644 +--- a/Configurations/unix-Makefile.tmpl ++++ b/Configurations/unix-Makefile.tmpl +@@ -1961,7 +1961,7 @@ EOF + my @objs = map { platform->obj($_) } @{$args{objs}}; + my $deps = join(" \\\n" . ' ' x (length($lib) + 2), + fill_lines(' ', $COLUMNS - length($lib) - 2, @objs)); +- my $max_per_call = 500; ++ my $max_per_call = ($^O eq 'msys' || $^O eq 'cygwin') ? 80 : 500; + my @objs_grouped; + push @objs_grouped, join(" ", splice @objs, 0, $max_per_call) while @objs; + my $fill_lib = diff --git a/vcpkg/ports/openssl/install-pc-files.cmake b/vcpkg/ports/openssl/install-pc-files.cmake new file mode 100644 index 000000000..04897faed --- /dev/null +++ b/vcpkg/ports/openssl/install-pc-files.cmake @@ -0,0 +1,32 @@ +function(install_pc_file name pc_data) + if(NOT DEFINED VCPKG_BUILD_TYPE OR VCPKG_BUILD_TYPE STREQUAL "release") + configure_file("${CMAKE_CURRENT_LIST_DIR}/openssl.pc.in" "${CURRENT_PACKAGES_DIR}/lib/pkgconfig/${name}.pc" @ONLY) + endif() + if(NOT DEFINED VCPKG_BUILD_TYPE OR VCPKG_BUILD_TYPE STREQUAL "debug") + configure_file("${CMAKE_CURRENT_LIST_DIR}/openssl.pc.in" "${CURRENT_PACKAGES_DIR}/debug/lib/pkgconfig/${name}.pc" @ONLY) + endif() +endfunction() + +install_pc_file(openssl [[ +Name: OpenSSL +Description: Secure Sockets Layer and cryptography libraries and tools +Requires: libssl libcrypto +]]) + +install_pc_file(libssl [[ +Name: OpenSSL-libssl +Description: Secure Sockets Layer and cryptography libraries +Libs: -L"${libdir}" -llibssl +Requires: libcrypto +Cflags: -I"${includedir}" +]]) + +install_pc_file(libcrypto [[ +Name: OpenSSL-libcrypto +Description: OpenSSL cryptography library +Libs: -L"${libdir}" -llibcrypto +Libs.private: -lcrypt32 -lws2_32 -ladvapi32 -luser32 +Cflags: -I"${includedir}" +]]) + +vcpkg_fixup_pkgconfig() diff --git a/vcpkg/ports/openssl/openssl.pc.in b/vcpkg/ports/openssl/openssl.pc.in new file mode 100644 index 000000000..f7fa9d185 --- /dev/null +++ b/vcpkg/ports/openssl/openssl.pc.in @@ -0,0 +1,6 @@ +prefix=${pcfiledir}/../.. +exec_prefix=${prefix} +libdir=${exec_prefix}/lib +includedir=${prefix}/include +Version: @VERSION@ +@pc_data@ diff --git a/vcpkg/ports/openssl/portfile.cmake b/vcpkg/ports/openssl/portfile.cmake new file mode 100644 index 000000000..536048e2a --- /dev/null +++ b/vcpkg/ports/openssl/portfile.cmake @@ -0,0 +1,93 @@ +if(EXISTS "${CURRENT_INSTALLED_DIR}/share/libressl/copyright" + OR EXISTS "${CURRENT_INSTALLED_DIR}/share/boringssl/copyright") + message(FATAL_ERROR "Can't build openssl if libressl/boringssl is installed. Please remove libressl/boringssl, and try install openssl again if you need it.") +endif() + +if(VCPKG_TARGET_IS_EMSCRIPTEN) + vcpkg_check_linkage(ONLY_STATIC_LIBRARY) +endif() + +vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO openssl/openssl + REF "openssl-${VERSION}" + SHA512 a89c08101fa1d7e3c09b14f4a90d450bcf336a4f6a3e6e4ea990e4deddcd9ce250472f9114438fd134ff4b47fe93dd47232308567088b2b1c0b2eb50e3b56bdf + PATCHES + cmake-config.patch + command-line-length.patch + script-prefix.patch + windows/install-layout.patch + windows/install-pdbs.patch + windows/install-programs.diff # https://github.com/openssl/openssl/issues/28744 + unix/android-cc.patch + unix/move-openssldir.patch + unix/no-empty-dirs.patch + unix/no-static-libs-for-shared.patch +) + +vcpkg_list(SET CONFIGURE_OPTIONS + enable-static-engine + enable-capieng + no-tests + no-docs +) + +# https://github.com/openssl/openssl/blob/master/INSTALL.md#enable-ec_nistp_64_gcc_128 +vcpkg_cmake_get_vars(cmake_vars_file) +include("${cmake_vars_file}") +if(VCPKG_DETECTED_CMAKE_C_COMPILER_ID MATCHES "^(GNU|Clang|AppleClang)$" + AND VCPKG_TARGET_ARCHITECTURE MATCHES "^(x64|arm64|riscv64|ppc64le)$") + vcpkg_list(APPEND CONFIGURE_OPTIONS enable-ec_nistp_64_gcc_128) +endif() + +set(INSTALL_FIPS "") +if("fips" IN_LIST FEATURES) + vcpkg_list(APPEND INSTALL_FIPS install_fips) + vcpkg_list(APPEND CONFIGURE_OPTIONS enable-fips) +endif() + +if(VCPKG_LIBRARY_LINKAGE STREQUAL "dynamic") + vcpkg_list(APPEND CONFIGURE_OPTIONS shared) +else() + vcpkg_list(APPEND CONFIGURE_OPTIONS no-shared no-module) +endif() + +if(NOT "tools" IN_LIST FEATURES) + vcpkg_list(APPEND CONFIGURE_OPTIONS no-apps) +endif() + +if("weak-ssl-ciphers" IN_LIST FEATURES) + vcpkg_list(APPEND CONFIGURE_OPTIONS enable-weak-ssl-ciphers) +endif() + +if("ssl3" IN_LIST FEATURES) + vcpkg_list(APPEND CONFIGURE_OPTIONS enable-ssl3) + vcpkg_list(APPEND CONFIGURE_OPTIONS enable-ssl3-method) +endif() + +if(DEFINED OPENSSL_USE_NOPINSHARED) + vcpkg_list(APPEND CONFIGURE_OPTIONS no-pinshared) +endif() + +if(OPENSSL_NO_AUTOLOAD_CONFIG) + vcpkg_list(APPEND CONFIGURE_OPTIONS no-autoload-config) +endif() + +if(VCPKG_TARGET_IS_WINDOWS AND NOT VCPKG_TARGET_IS_MINGW) + include("${CMAKE_CURRENT_LIST_DIR}/windows/portfile.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/install-pc-files.cmake") +else() + include("${CMAKE_CURRENT_LIST_DIR}/unix/portfile.cmake") +endif() + +file(INSTALL "${CMAKE_CURRENT_LIST_DIR}/usage" DESTINATION "${CURRENT_PACKAGES_DIR}/share/${PORT}") + +if (NOT "${VERSION}" MATCHES [[^([0-9]+)\.([0-9]+)\.([0-9]+)$]]) + message(FATAL_ERROR "Version regex did not match.") +endif() +set(OPENSSL_VERSION_MAJOR "${CMAKE_MATCH_1}") +set(OPENSSL_VERSION_MINOR "${CMAKE_MATCH_2}") +set(OPENSSL_VERSION_FIX "${CMAKE_MATCH_3}") +configure_file("${CMAKE_CURRENT_LIST_DIR}/vcpkg-cmake-wrapper.cmake.in" "${CURRENT_PACKAGES_DIR}/share/${PORT}/vcpkg-cmake-wrapper.cmake" @ONLY) + +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE.txt") diff --git a/vcpkg/ports/openssl/script-prefix.patch b/vcpkg/ports/openssl/script-prefix.patch new file mode 100644 index 000000000..fd3316b85 --- /dev/null +++ b/vcpkg/ports/openssl/script-prefix.patch @@ -0,0 +1,22 @@ +diff --git a/tools/c_rehash.in b/tools/c_rehash.in +index 343cdc1..e48038e 100644 +--- a/tools/c_rehash.in ++++ b/tools/c_rehash.in +@@ -12,7 +12,7 @@ + # and add symbolic links to their hash values. + + my $dir = {- quotify1($config{openssldir}) -}; +-my $prefix = {- quotify1($config{prefix}) -}; ++use FindBin; + + my $errorcount = 0; + my $openssl = $ENV{OPENSSL} || "openssl"; +@@ -61,7 +61,7 @@ if (defined(&Cwd::getcwd)) { + + # DOS/Win32 or Unix delimiter? Prefix our installdir, then search. + my $path_delim = ($pwd =~ /^[a-z]\:/i) ? ';' : ':'; +-$ENV{PATH} = "$prefix/bin" . ($ENV{PATH} ? $path_delim . $ENV{PATH} : ""); ++$ENV{PATH} = "$FindBin::Bin" . ($ENV{PATH} ? $path_delim . $ENV{PATH} : ""); + + if (!(-f $openssl && -x $openssl)) { + my $found = 0; diff --git a/vcpkg/ports/openssl/unix/android-cc.patch b/vcpkg/ports/openssl/unix/android-cc.patch new file mode 100644 index 000000000..f4c3d6834 --- /dev/null +++ b/vcpkg/ports/openssl/unix/android-cc.patch @@ -0,0 +1,20 @@ +diff --git a/Configurations/15-android.conf b/Configurations/15-android.conf +index 41ad922..d15e34c 100644 +--- a/Configurations/15-android.conf ++++ b/Configurations/15-android.conf +@@ -102,6 +102,7 @@ + my $cflags; + my $cppflags; + ++if (0) { + # see if there is NDK clang on $PATH, "universal" or "standalone" + if (which("clang") =~ m|^$ndk/.*/prebuilt/([^/]+)/|) { + my $host=$1; +@@ -158,6 +159,7 @@ + $sysroot =~ s|^$ndk/||; + $sysroot = " --sysroot=\$($ndk_var)/$sysroot"; + } ++} + $android_ndk = { + cflags => $cflags . $sysroot, + cppflags => $cppflags, diff --git a/vcpkg/ports/openssl/unix/configure b/vcpkg/ports/openssl/unix/configure new file mode 100755 index 000000000..2d49b3d16 --- /dev/null +++ b/vcpkg/ports/openssl/unix/configure @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -e + +declare -a OUT_OPTIONS + +INTERPRETER=perl + +LAST_SEEN= +COPY_OPTIONS=no +for OPTION; do + case "${OPTION},${COPY_OPTIONS}" in + */Configure,no) + OUT_OPTIONS+=("${OPTION}") + INTERPRETER="${LAST_SEEN}" + COPY_OPTIONS=yes + ;; + --prefix=*|--openssldir=*|--libdir=*|--cross-compile-prefix=*|--debug,*) + OUT_OPTIONS+=("${OPTION}") + ;; + -*|*=*) + ;; + *,yes) + OUT_OPTIONS+=("${OPTION}") + ;; + esac + LAST_SEEN="${OPTION}" +done + +set -x +"${INTERPRETER}" ${OUT_OPTIONS[@]} diff --git a/vcpkg/ports/openssl/unix/move-openssldir.patch b/vcpkg/ports/openssl/unix/move-openssldir.patch new file mode 100644 index 000000000..2a39bdbe7 --- /dev/null +++ b/vcpkg/ports/openssl/unix/move-openssldir.patch @@ -0,0 +1,16 @@ +diff --git a/Configurations/unix-Makefile.tmpl b/Configurations/unix-Makefile.tmpl +index c82c086..6c5402d 100644 +--- a/Configurations/unix-Makefile.tmpl ++++ b/Configurations/unix-Makefile.tmpl +@@ -690,6 +690,11 @@ install_ssldirs: + chmod 644 "$(DESTDIR)$(OPENSSLDIR)/ct_log_list.cnf"; \ + fi + ++ $(PERL) $(SRCDIR)/util/mkdir-p.pl $(DESTDIR)$(INSTALLTOP)$(OPENSSLDIR) ++ for I in $(DESTDIR)$(OPENSSLDIR)/*; do \ ++ mv $$I $(DESTDIR)$(INSTALLTOP)$(OPENSSLDIR)/; \ ++ done ++ + install_dev: install_runtime_libs + @[ -n "$(INSTALLTOP)" ] || (echo INSTALLTOP should not be empty; exit 1) + @$(ECHO) "*** Installing development files" diff --git a/vcpkg/ports/openssl/unix/no-empty-dirs.patch b/vcpkg/ports/openssl/unix/no-empty-dirs.patch new file mode 100644 index 000000000..7cf0cf05f --- /dev/null +++ b/vcpkg/ports/openssl/unix/no-empty-dirs.patch @@ -0,0 +1,22 @@ +diff --git a/Configurations/unix-Makefile.tmpl b/Configurations/unix-Makefile.tmpl +index 6c5402d..fc982df 100644 +--- a/Configurations/unix-Makefile.tmpl ++++ b/Configurations/unix-Makefile.tmpl +@@ -823,7 +823,7 @@ _install_modules_deps: install_runtime_libs build_modules + + install_engines: _install_modules_deps + @[ -n "$(INSTALLTOP)" ] || (echo INSTALLTOP should not be empty; exit 1) +- @$(PERL) $(SRCDIR)/util/mkdir-p.pl "$(DESTDIR)$(ENGINESDIR)/" ++ @[ -z "$(INSTALL_ENGINES)" ] || $(PERL) $(SRCDIR)/util/mkdir-p.pl $(DESTDIR)$(ENGINESDIR)/ + @$(ECHO) "*** Installing engines" + @set -e; for e in dummy $(INSTALL_ENGINES); do \ + if [ "$$e" = "dummy" ]; then continue; fi; \ +@@ -847,7 +847,7 @@ uninstall_engines: + + install_modules: _install_modules_deps + @[ -n "$(INSTALLTOP)" ] || (echo INSTALLTOP should not be empty; exit 1) +- @$(PERL) $(SRCDIR)/util/mkdir-p.pl "$(DESTDIR)$(MODULESDIR)/" ++ @[ -z "$(INSTALL_MODULES)" ] || $(PERL) $(SRCDIR)/util/mkdir-p.pl $(DESTDIR)$(MODULESDIR)/ + @$(ECHO) "*** Installing modules" + @set -e; for e in dummy $(INSTALL_MODULES); do \ + if [ "$$e" = "dummy" ]; then continue; fi; \ diff --git a/vcpkg/ports/openssl/unix/no-static-libs-for-shared.patch b/vcpkg/ports/openssl/unix/no-static-libs-for-shared.patch new file mode 100644 index 000000000..d6aa34cfe --- /dev/null +++ b/vcpkg/ports/openssl/unix/no-static-libs-for-shared.patch @@ -0,0 +1,12 @@ +diff --git a/Configurations/unix-Makefile.tmpl b/Configurations/unix-Makefile.tmpl +index fc982df..10f1c54 100644 +--- a/Configurations/unix-Makefile.tmpl ++++ b/Configurations/unix-Makefile.tmpl +@@ -713,6 +713,7 @@ install_dev: install_runtime_libs + done + @$(PERL) $(SRCDIR)/util/mkdir-p.pl "$(DESTDIR)$(libdir)" + @set -e; for l in $(INSTALL_LIBS); do \ ++ if [ -n "$(INSTALL_SHLIBS)" ] ; then continue ; fi ; \ + fn=`basename $$l`; \ + $(ECHO) "install $$l -> $(DESTDIR)$(libdir)/$$fn"; \ + cp $$l "$(DESTDIR)$(libdir)/$$fn.new"; \ diff --git a/vcpkg/ports/openssl/unix/portfile.cmake b/vcpkg/ports/openssl/unix/portfile.cmake new file mode 100644 index 000000000..3e2226d6d --- /dev/null +++ b/vcpkg/ports/openssl/unix/portfile.cmake @@ -0,0 +1,195 @@ +if (VCPKG_TARGET_IS_LINUX) + message(NOTICE [[ +openssl requires Linux kernel headers from the system package manager. + They can be installed on Alpine systems via `apk add linux-headers`. + They can be installed on Ubuntu systems via `apt install linux-libc-dev`. +]]) +endif() + +if(VCPKG_HOST_IS_WINDOWS) + vcpkg_acquire_msys(MSYS_ROOT PACKAGES make perl) + set(MAKE "${MSYS_ROOT}/usr/bin/make.exe") + set(PERL "${MSYS_ROOT}/usr/bin/perl.exe") +else() + find_program(MAKE make) + if(NOT MAKE) + message(FATAL_ERROR "Could not find make. Please install it through your package manager.") + endif() + vcpkg_find_acquire_program(PERL) +endif() +set(INTERPRETER "${PERL}") + +execute_process( + COMMAND "${PERL}" -e "use IPC::Cmd;" + RESULT_VARIABLE perl_ipc_cmd_result +) +if(NOT perl_ipc_cmd_result STREQUAL "0") + message(FATAL_ERROR "\nPerl cannot find IPC::Cmd. Please install it through your system package manager.\n") +endif() + +# Ideally, OpenSSL should use `CC` from vcpkg as is (absolute path). +# But in reality, OpenSSL expects to locate the compiler via `PATH`, +# and it makes its own choices e.g. for Android. +vcpkg_cmake_get_vars(cmake_vars_file) +include("${cmake_vars_file}") +cmake_path(GET VCPKG_DETECTED_CMAKE_C_COMPILER PARENT_PATH compiler_path) +cmake_path(GET VCPKG_DETECTED_CMAKE_C_COMPILER FILENAME compiler_name) +find_program(compiler_in_path NAMES "${compiler_name}" PATHS ENV PATH NO_DEFAULT_PATH) +if(NOT compiler_in_path) + vcpkg_host_path_list(APPEND ENV{PATH} "${compiler_path}") +elseif(NOT compiler_in_path STREQUAL VCPKG_DETECTED_CMAKE_C_COMPILER) + vcpkg_host_path_list(PREPEND ENV{PATH} "${compiler_path}") +endif() + +vcpkg_list(SET MAKEFILE_OPTIONS) +if(VCPKG_TARGET_IS_ANDROID) + set(ENV{ANDROID_NDK_ROOT} "${VCPKG_DETECTED_CMAKE_ANDROID_NDK}") + set(OPENSSL_ARCH "android-${VCPKG_DETECTED_CMAKE_ANDROID_ARCH}") + if(VCPKG_DETECTED_CMAKE_ANDROID_ARCH STREQUAL "arm" AND NOT VCPKG_DETECTED_CMAKE_ANDROID_ARM_NEON) + vcpkg_list(APPEND CONFIGURE_OPTIONS no-asm) + endif() +elseif(VCPKG_TARGET_IS_LINUX) + if(VCPKG_TARGET_ARCHITECTURE MATCHES "arm64") + set(OPENSSL_ARCH linux-aarch64) + elseif(VCPKG_TARGET_ARCHITECTURE MATCHES "arm") + set(OPENSSL_ARCH linux-armv4) + elseif(VCPKG_TARGET_ARCHITECTURE MATCHES "x64") + set(OPENSSL_ARCH linux-x86_64) + elseif(VCPKG_TARGET_ARCHITECTURE MATCHES "x86") + set(OPENSSL_ARCH linux-x86) + else() + set(OPENSSL_ARCH linux-generic32) + endif() +elseif(VCPKG_TARGET_IS_IOS) + if(VCPKG_TARGET_ARCHITECTURE MATCHES "arm64") + if(VCPKG_OSX_SYSROOT STREQUAL "iphonesimulator") + set(OPENSSL_ARCH iossimulator-arm64-xcrun) + else() + set(OPENSSL_ARCH ios64-xcrun) + endif() + elseif(VCPKG_TARGET_ARCHITECTURE MATCHES "arm") + set(OPENSSL_ARCH ios-xcrun) + elseif(VCPKG_TARGET_ARCHITECTURE MATCHES "x86" OR VCPKG_TARGET_ARCHITECTURE MATCHES "x64") + set(OPENSSL_ARCH iossimulator-xcrun) + else() + message(FATAL_ERROR "Unknown iOS target architecture: ${VCPKG_TARGET_ARCHITECTURE}") + endif() + # disable that makes linkage error (e.g. require stderr usage) + list(APPEND CONFIGURE_OPTIONS no-ui no-asm) +elseif(VCPKG_TARGET_IS_TVOS OR VCPKG_TARGET_IS_WATCHOS) + set(OPENSSL_ARCH iphoneos-cross) + # disable that makes linkage error (e.g. require stderr usage) + list(APPEND CONFIGURE_OPTIONS no-ui no-asm) +elseif(VCPKG_TARGET_IS_OSX) + # Universal builds don't support ASM + if(VCPKG_OSX_ARCHITECTURES MATCHES "arm64" AND VCPKG_OSX_ARCHITECTURES MATCHES "x86_64") + list(APPEND CONFIGURE_OPTIONS no-asm) + endif() + if(VCPKG_TARGET_ARCHITECTURE MATCHES "arm64") + set(OPENSSL_ARCH darwin64-arm64) + else() + set(OPENSSL_ARCH darwin64-x86_64) + endif() +elseif(VCPKG_TARGET_IS_BSD) + set(OPENSSL_ARCH BSD-nodef-generic64) +elseif(VCPKG_TARGET_IS_SOLARIS) + if(VCPKG_TARGET_ARCHITECTURE STREQUAL "x64") + set(OPENSSL_ARCH solaris64-x86_64-gcc) + else() + set(OPENSSL_ARCH solaris-x86-gcc) + endif() +elseif(VCPKG_TARGET_IS_MINGW) + if(VCPKG_TARGET_ARCHITECTURE STREQUAL "x64") + set(OPENSSL_ARCH mingw64) + elseif(VCPKG_TARGET_ARCHITECTURE STREQUAL "arm64") + set(OPENSSL_ARCH mingwarm64) + else() + set(OPENSSL_ARCH mingw) + endif() +elseif(VCPKG_TARGET_IS_EMSCRIPTEN) + set(OPENSSL_ARCH linux-x32) + vcpkg_list(APPEND CONFIGURE_OPTIONS + no-engine + no-asm + no-sse2 + no-srtp + --cross-compile-prefix= + ) + # Cf. https://emscripten.org/docs/porting/pthreads.html: + # For Pthreads support, not just openssl but everything + # must be compiled and linked with `-pthread`. + # This makes it a triplet/toolchain-wide setting. + if(NOT " ${VCPKG_DETECTED_CMAKE_C_FLAGS} " MATCHES " -pthread ") + vcpkg_list(APPEND CONFIGURE_OPTIONS no-threads) + endif() +else() + message(FATAL_ERROR "Unknown platform") +endif() + +file(MAKE_DIRECTORY "${SOURCE_PATH}/vcpkg") +file(COPY "${CMAKE_CURRENT_LIST_DIR}/configure" DESTINATION "${SOURCE_PATH}/vcpkg") +# AETHER_OPENSSL_IOS_TRIPLET_NORMALIZE_BEGIN +# The legacy helper builds a single bash -c argument. A CMake-list separator +# splits that argument before Perl/Configure; the modern make helper needs the +# original list, so join only for this call and restore it immediately below. +if(VCPKG_TARGET_IS_IOS AND DEFINED VCPKG_MAKE_BUILD_TRIPLET) + set(AETHER_OPENSSL_ORIGINAL_MAKE_BUILD_TRIPLET "${VCPKG_MAKE_BUILD_TRIPLET}") + list(JOIN VCPKG_MAKE_BUILD_TRIPLET " " VCPKG_MAKE_BUILD_TRIPLET) +endif() +# AETHER_OPENSSL_IOS_TRIPLET_NORMALIZE_END +vcpkg_configure_make( + SOURCE_PATH "${SOURCE_PATH}" + PROJECT_SUBPATH "vcpkg" + NO_ADDITIONAL_PATHS + OPTIONS + "${INTERPRETER}" + "${SOURCE_PATH}/Configure" + ${OPENSSL_ARCH} + ${CONFIGURE_OPTIONS} + "--openssldir=/etc/ssl" + "--libdir=lib" + OPTIONS_DEBUG + --debug +) +# AETHER_OPENSSL_IOS_TRIPLET_RESTORE_BEGIN +if(DEFINED AETHER_OPENSSL_ORIGINAL_MAKE_BUILD_TRIPLET) + set(VCPKG_MAKE_BUILD_TRIPLET "${AETHER_OPENSSL_ORIGINAL_MAKE_BUILD_TRIPLET}") + unset(AETHER_OPENSSL_ORIGINAL_MAKE_BUILD_TRIPLET) +endif() +# AETHER_OPENSSL_IOS_TRIPLET_RESTORE_END +vcpkg_install_make( + ${MAKEFILE_OPTIONS} + BUILD_TARGET build_inst_sw +) +vcpkg_fixup_pkgconfig() + +if("tools" IN_LIST FEATURES) + file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/tools/${PORT}") + file(RENAME "${CURRENT_PACKAGES_DIR}/bin/c_rehash" "${CURRENT_PACKAGES_DIR}/tools/${PORT}/c_rehash") + file(REMOVE "${CURRENT_PACKAGES_DIR}/debug/bin/c_rehash") + vcpkg_copy_tools(TOOL_NAMES openssl AUTO_CLEAN) +elseif(VCPKG_LIBRARY_LINKAGE STREQUAL "static" OR NOT VCPKG_TARGET_IS_WINDOWS) + file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/bin" "${CURRENT_PACKAGES_DIR}/debug/bin") + file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/etc/ssl/misc") +endif() + +file(TOUCH "${CURRENT_PACKAGES_DIR}/etc/ssl/certs/.keep") +file(TOUCH "${CURRENT_PACKAGES_DIR}/etc/ssl/private/.keep") + +file(REMOVE_RECURSE + "${CURRENT_PACKAGES_DIR}/debug/etc" + "${CURRENT_PACKAGES_DIR}/debug/include" + "${CURRENT_PACKAGES_DIR}/debug/share" +) + +# For consistency of mingw build with nmake build +file(GLOB engines "${CURRENT_PACKAGES_DIR}/lib/ossl-modules/*.dll") +if(NOT engines STREQUAL "") + file(COPY ${engines} DESTINATION "${CURRENT_PACKAGES_DIR}/bin") + file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/lib/ossl-modules") +endif() +file(GLOB engines "${CURRENT_PACKAGES_DIR}/debug/lib/ossl-modules/*.dll") +if(NOT engines STREQUAL "") + file(COPY ${engines} DESTINATION "${CURRENT_PACKAGES_DIR}/debug/bin") + file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/lib/ossl-modules") +endif() diff --git a/vcpkg/ports/openssl/unix/remove-deps.cmake b/vcpkg/ports/openssl/unix/remove-deps.cmake new file mode 100644 index 000000000..53ad6ef29 --- /dev/null +++ b/vcpkg/ports/openssl/unix/remove-deps.cmake @@ -0,0 +1,7 @@ +file(GLOB_RECURSE MAKEFILES ${DIR}/*/Makefile) +foreach(MAKEFILE ${MAKEFILES}) + message("removing deps from ${MAKEFILE}") + file(READ "${MAKEFILE}" _contents) + string(REGEX REPLACE "\n# DO NOT DELETE THIS LINE.*" "" _contents "${_contents}") + file(WRITE "${MAKEFILE}" "${_contents}") +endforeach() diff --git a/vcpkg/ports/openssl/usage b/vcpkg/ports/openssl/usage new file mode 100644 index 000000000..5fdc6360a --- /dev/null +++ b/vcpkg/ports/openssl/usage @@ -0,0 +1,5 @@ +openssl is compatible with built-in CMake targets: + + find_package(OpenSSL REQUIRED) + target_link_libraries(main PRIVATE OpenSSL::SSL) + target_link_libraries(main PRIVATE OpenSSL::Crypto) diff --git a/vcpkg/ports/openssl/vcpkg-cmake-wrapper.cmake.in b/vcpkg/ports/openssl/vcpkg-cmake-wrapper.cmake.in new file mode 100644 index 000000000..5008112c3 --- /dev/null +++ b/vcpkg/ports/openssl/vcpkg-cmake-wrapper.cmake.in @@ -0,0 +1,82 @@ +cmake_policy(PUSH) +cmake_policy(SET CMP0012 NEW) +cmake_policy(SET CMP0054 NEW) +cmake_policy(SET CMP0057 NEW) + +set(OPENSSL_VERSION_MAJOR @OPENSSL_VERSION_MAJOR@) +set(OPENSSL_VERSION_MINOR @OPENSSL_VERSION_MINOR@) +set(OPENSSL_VERSION_FIX @OPENSSL_VERSION_FIX@) + +if(OPENSSL_USE_STATIC_LIBS) + if("@VCPKG_LIBRARY_LINKAGE@" STREQUAL "dynamic") + message(WARNING "OPENSSL_USE_STATIC_LIBS is set, but vcpkg port openssl was built with dynamic linkage") + endif() + set(OPENSSL_USE_STATIC_LIBS_BAK "${OPENSSL_USE_STATIC_LIBS}") + set(OPENSSL_USE_STATIC_LIBS FALSE) +endif() + +if(DEFINED OPENSSL_ROOT_DIR) + set(OPENSSL_ROOT_DIR_BAK "${OPENSSL_ROOT_DIR}") +endif() +get_filename_component(OPENSSL_ROOT_DIR "${CMAKE_CURRENT_LIST_DIR}" DIRECTORY) +get_filename_component(OPENSSL_ROOT_DIR "${OPENSSL_ROOT_DIR}" DIRECTORY) +find_path(OPENSSL_INCLUDE_DIR NAMES openssl/ssl.h PATH "${OPENSSL_ROOT_DIR}/include" NO_DEFAULT_PATH) +if(MSVC) + find_library(LIB_EAY_DEBUG NAMES libcrypto PATHS "${OPENSSL_ROOT_DIR}/debug/lib" NO_DEFAULT_PATH) + find_library(LIB_EAY_RELEASE NAMES libcrypto PATHS "${OPENSSL_ROOT_DIR}/lib" NO_DEFAULT_PATH) + find_library(SSL_EAY_DEBUG NAMES libssl PATHS "${OPENSSL_ROOT_DIR}/debug/lib" NO_DEFAULT_PATH) + find_library(SSL_EAY_RELEASE NAMES libssl PATHS "${OPENSSL_ROOT_DIR}/lib" NO_DEFAULT_PATH) +elseif(WIN32) + find_library(LIB_EAY NAMES libcrypto crypto NAMES_PER_DIR) + find_library(SSL_EAY NAMES libssl ssl NAMES_PER_DIR) +else() + find_library(OPENSSL_CRYPTO_LIBRARY NAMES crypto) + find_library(OPENSSL_SSL_LIBRARY NAMES ssl) +endif() + +_find_package(${ARGS}) + +unset(OPENSSL_ROOT_DIR) +if(DEFINED OPENSSL_ROOT_DIR_BAK) + set(OPENSSL_ROOT_DIR "${OPENSSL_ROOT_DIR_BAK}") + unset(OPENSSL_ROOT_DIR_BAK) +endif() + +if(DEFINED OPENSSL_USE_STATIC_LIBS_BAK) + set(OPENSSL_USE_STATIC_LIBS "${OPENSSL_USE_STATIC_LIBS_BAK}") + unset(OPENSSL_USE_STATIC_LIBS_BAK) +endif() + +if(OPENSSL_FOUND AND "@VCPKG_LIBRARY_LINKAGE@" STREQUAL "static") + if(WIN32) + list(APPEND OPENSSL_LIBRARIES crypt32 ws2_32) + if(TARGET OpenSSL::Crypto) + set_property(TARGET OpenSSL::Crypto APPEND PROPERTY INTERFACE_LINK_LIBRARIES "crypt32;ws2_32") + endif() + if(TARGET OpenSSL::SSL) + set_property(TARGET OpenSSL::SSL APPEND PROPERTY INTERFACE_LINK_LIBRARIES "crypt32;ws2_32") + endif() + else() + find_library(OPENSSL_DL_LIBRARY NAMES dl) + if(OPENSSL_DL_LIBRARY) + list(APPEND OPENSSL_LIBRARIES "dl") + if(TARGET OpenSSL::Crypto) + set_property(TARGET OpenSSL::Crypto APPEND PROPERTY INTERFACE_LINK_LIBRARIES "dl") + endif() + endif() + + if("REQUIRED" IN_LIST ARGS) + find_package(Threads REQUIRED) + else() + find_package(Threads) + endif() + list(APPEND OPENSSL_LIBRARIES ${CMAKE_THREAD_LIBS_INIT}) + if(TARGET OpenSSL::Crypto) + set_property(TARGET OpenSSL::Crypto APPEND PROPERTY INTERFACE_LINK_LIBRARIES "Threads::Threads") + endif() + if(TARGET OpenSSL::SSL) + set_property(TARGET OpenSSL::SSL APPEND PROPERTY INTERFACE_LINK_LIBRARIES "Threads::Threads") + endif() + endif() +endif() +cmake_policy(POP) diff --git a/vcpkg/ports/openssl/vcpkg.json b/vcpkg/ports/openssl/vcpkg.json new file mode 100644 index 000000000..82e2a0232 --- /dev/null +++ b/vcpkg/ports/openssl/vcpkg.json @@ -0,0 +1,37 @@ +{ + "name": "openssl", + "version": "3.6.3", + "description": "OpenSSL is an open source project that provides a robust, commercial-grade, and full-featured toolkit for the Transport Layer Security (TLS) and Secure Sockets Layer (SSL) protocols. It is also a general-purpose cryptography library.", + "homepage": "https://www.openssl.org", + "license": "Apache-2.0", + "dependencies": [ + { + "name": "vcpkg-cmake", + "host": true + }, + { + "name": "vcpkg-cmake-config", + "host": true + }, + { + "name": "vcpkg-cmake-get-vars", + "host": true + } + ], + "features": { + "fips": { + "description": "Enable fips", + "supports": "!static" + }, + "ssl3": { + "description": "Enable SSL3" + }, + "tools": { + "description": "Install openssl executable and scripts", + "supports": "!uwp" + }, + "weak-ssl-ciphers": { + "description": "Enable weak-ssl-ciphers" + } + } +} diff --git a/vcpkg/ports/openssl/windows/install-layout.patch b/vcpkg/ports/openssl/windows/install-layout.patch new file mode 100644 index 000000000..6706f2404 --- /dev/null +++ b/vcpkg/ports/openssl/windows/install-layout.patch @@ -0,0 +1,13 @@ +diff --git a/Configurations/windows-makefile.tmpl b/Configurations/windows-makefile.tmpl +index f71f3bf..116954f 100644 +--- a/Configurations/windows-makefile.tmpl ++++ b/Configurations/windows-makefile.tmpl +@@ -223,7 +223,7 @@ MODULESDIR_dev={- use File::Spec::Functions qw(:DEFAULT splitpath catpath); + splitpath($modulesprefix, 1); + our $modulesdir_dev = $modulesprefix_dev; + our $modulesdir_dir = +- catdir($modulesprefix_dir, "ossl-modules"); ++ catdir($modulesprefix_dir, "../bin"); # change lib/ossl-modules to bin + our $modulesdir = catpath($modulesdir_dev, $modulesdir_dir); + our $enginesdir_dev = $modulesprefix_dev; + our $enginesdir_dir = diff --git a/vcpkg/ports/openssl/windows/install-pdbs.patch b/vcpkg/ports/openssl/windows/install-pdbs.patch new file mode 100644 index 000000000..552545407 --- /dev/null +++ b/vcpkg/ports/openssl/windows/install-pdbs.patch @@ -0,0 +1,46 @@ +diff --git a/Configurations/windows-makefile.tmpl b/Configurations/windows-makefile.tmpl +index 5946c89..f71f3bf 100644 +--- a/Configurations/windows-makefile.tmpl ++++ b/Configurations/windows-makefile.tmpl +@@ -564,8 +564,9 @@ + "$(INSTALLTOP)\include\openssl" + @"$(PERL)" "$(SRCDIR)\util\mkdir-p.pl" "$(libdir)" + @"$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_LIBS) "$(libdir)" + @if "$(SHLIBS)"=="" \ ++ @if "$(INSTALL_PDBS)"=="ON" \ + "$(PERL)" "$(SRCDIR)\util\copy.pl" ossl_static.pdb "$(libdir)" + @"$(PERL)" "$(SRCDIR)\util\mkdir-p.pl" "$(CMAKECONFIGDIR)" + @"$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_EXPORTERS_CMAKE) "$(CMAKECONFIGDIR)" + +@@ -569,6 +570,7 @@ install_engines: _install_modules_deps + @if not "$(INSTALL_ENGINES)"=="" \ + "$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_ENGINES) "$(ENGINESDIR)" + @if not "$(INSTALL_ENGINES)"=="" \ ++ @if "$(INSTALL_PDBS)"=="ON" \ + "$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_ENGINEPDBS) "$(ENGINESDIR)" + + uninstall_engines: +@@ -580,6 +582,7 @@ install_modules: _install_modules_deps + @if not "$(INSTALL_MODULES)"=="" \ + "$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_MODULES) "$(MODULESDIR)" + @if not "$(INSTALL_MODULES)"=="" \ ++ @if "$(INSTALL_PDBS)"=="ON" \ + "$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_MODULEPDBS) "$(MODULESDIR)" + + uninstall_modules: +@@ -593,6 +596,7 @@ install_runtime_libs: build_libs + @if not "$(SHLIBS)"=="" \ + "$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_SHLIBS) "$(INSTALLTOP)\bin" + @if not "$(SHLIBS)"=="" \ ++ @if "$(INSTALL_PDBS)"=="ON" \ + "$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_SHLIBPDBS) \ + "$(INSTALLTOP)\bin" + +@@ -605,6 +609,7 @@ install_programs: install_runtime_libs build_programs + "$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_PROGRAMS) \ + "$(INSTALLTOP)\bin" + @if not "$(INSTALL_PROGRAMS)"=="" \ ++ @if "$(INSTALL_PDBS)"=="ON" \ + "$(PERL)" "$(SRCDIR)\util\copy.pl" $(INSTALL_PROGRAMPDBS) \ + "$(INSTALLTOP)\bin" + @if not "$(INSTALL_PROGRAMS)"=="" \ diff --git a/vcpkg/ports/openssl/windows/install-programs.diff b/vcpkg/ports/openssl/windows/install-programs.diff new file mode 100644 index 000000000..257ad7d3c --- /dev/null +++ b/vcpkg/ports/openssl/windows/install-programs.diff @@ -0,0 +1,13 @@ +diff --git a/Configurations/windows-makefile.tmpl b/Configurations/windows-makefile.tmpl +index b587212..c031cfb 100644 +--- a/Configurations/windows-makefile.tmpl ++++ b/Configurations/windows-makefile.tmpl +@@ -134,7 +134,7 @@ INSTALL_FIPSMODULE={- + -} + INSTALL_FIPSMODULECONF=providers\fipsmodule.cnf + INSTALL_PROGRAMS={- +- join(" ", map { quotify1(platform->bin($_)) } ++ join(" ", map { platform->bin($_) } + grep { !$unified_info{attributes}->{programs}->{$_}->{noinst} } + @{$unified_info{programs}}) + -} diff --git a/vcpkg/ports/openssl/windows/portfile.cmake b/vcpkg/ports/openssl/windows/portfile.cmake new file mode 100644 index 000000000..d2cc1f5d3 --- /dev/null +++ b/vcpkg/ports/openssl/windows/portfile.cmake @@ -0,0 +1,187 @@ +# Need cmd to pass quoted CC from nmake to mkbuildinf.pl, GH-37134 +find_program(CMD_EXECUTABLE cmd HINTS ENV PATH NO_DEFAULT_PATH REQUIRED) +cmake_path(NATIVE_PATH CMD_EXECUTABLE cmd) +set(ENV{COMSPEC} "${cmd}") + +vcpkg_find_acquire_program(PERL) +get_filename_component(PERL_EXE_PATH "${PERL}" DIRECTORY) +vcpkg_add_to_path("${PERL_EXE_PATH}") + +vcpkg_cmake_get_vars(cmake_vars_file) +include("${cmake_vars_file}") + +if(VCPKG_TARGET_ARCHITECTURE STREQUAL "x86") + set(OPENSSL_ARCH VC-WIN32) +elseif(VCPKG_TARGET_ARCHITECTURE STREQUAL "x64") + set(OPENSSL_ARCH VC-WIN64A) +elseif(VCPKG_TARGET_ARCHITECTURE STREQUAL "arm") + set(OPENSSL_ARCH VC-WIN32-ARM) +elseif(VCPKG_TARGET_ARCHITECTURE STREQUAL "arm64") + if(VCPKG_TARGET_IS_UWP) + set(OPENSSL_ARCH VC-WIN64-ARM) + elseif(VCPKG_DETECTED_CMAKE_C_COMPILER_ID MATCHES "Clang") + set(OPENSSL_ARCH VC-CLANG-WIN64-CLANGASM-ARM) + else() + set(OPENSSL_ARCH VC-WIN64-CLANGASM-ARM) + endif() +else() + message(FATAL_ERROR "Unsupported target architecture: ${VCPKG_TARGET_ARCHITECTURE}") +endif() + +if(VCPKG_TARGET_IS_UWP) + vcpkg_list(APPEND CONFIGURE_OPTIONS + no-unit-test + no-asm + no-uplink + ) + string(APPEND OPENSSL_ARCH "-UWP") +endif() + +if(VCPKG_CONCURRENCY GREATER "1") + vcpkg_list(APPEND CONFIGURE_OPTIONS no-makedepend) +endif() + +cmake_path(NATIVE_PATH CURRENT_PACKAGES_DIR NORMALIZE current_packages_dir_native) + +# Clang always uses /Z7; Patching /Zi /Fd out of openssl requires more work. +set(OPENSSL_BUILD_MAKES_PDBS ON) +if (VCPKG_DETECTED_CMAKE_C_COMPILER_ID MATCHES "Clang" OR VCPKG_LIBRARY_LINKAGE STREQUAL "static") + set(OPENSSL_BUILD_MAKES_PDBS OFF) +endif() + +cmake_path(NATIVE_PATH VCPKG_DETECTED_CMAKE_C_COMPILER NORMALIZE cc) +if(OPENSSL_ARCH MATCHES "CLANG") + vcpkg_find_acquire_program(CLANG) + cmake_path(GET CLANG PARENT_PATH clang_path) + vcpkg_add_to_path("${clang_path}") + if(VCPKG_DETECTED_CMAKE_C_COMPILER_ID MATCHES "Clang") + string(APPEND VCPKG_COMBINED_C_FLAGS_DEBUG " --target=aarch64-win32-msvc") + string(APPEND VCPKG_COMBINED_C_FLAGS_RELEASE " --target=aarch64-win32-msvc") + endif() +endif() +if(OPENSSL_ARCH MATCHES "CLANGASM") + vcpkg_list(APPEND CONFIGURE_OPTIONS "ASFLAGS=--target=aarch64-win32-msvc") +else() + vcpkg_find_acquire_program(NASM) + cmake_path(NATIVE_PATH NASM NORMALIZE as) + cmake_path(GET NASM PARENT_PATH nasm_path) + vcpkg_add_to_path("${nasm_path}") # Needed by Configure +endif() + +cmake_path(NATIVE_PATH VCPKG_DETECTED_CMAKE_AR NORMALIZE ar) +cmake_path(NATIVE_PATH VCPKG_DETECTED_CMAKE_LINKER NORMALIZE ld) + +# We can't set openssldir because that would leak build machine information into the built binaries, +# and introduce vulnerabilities where OpenSSL would search those locations at runtime, potentially +# unexpectedly loading code from there. For example CVE-2019-12572 +# +# Put the built bits in subdirectories with DESTDIR then move them where they go after the fact +# instead. +vcpkg_build_nmake( + SOURCE_PATH "${SOURCE_PATH}" + PREFER_JOM + CL_LANGUAGE NONE + PRERUN_SHELL_RELEASE "${PERL}" Configure + ${CONFIGURE_OPTIONS} + ${OPENSSL_ARCH} + "AS=${as}" + "CC=${cc}" + "CFLAGS=${VCPKG_COMBINED_C_FLAGS_RELEASE}" + "AR=${ar}" + "ARFLAGS=${VCPKG_COMBINED_STATIC_LINKER_FLAGS_RELEASE}" + "LD=${ld}" + "LDFLAGS=${VCPKG_COMBINED_SHARED_LINKER_FLAGS_RELEASE}" + PRERUN_SHELL_DEBUG "${PERL}" Configure + ${CONFIGURE_OPTIONS} + ${OPENSSL_ARCH} + --debug + "AS=${as}" + "CC=${cc}" + "CFLAGS=${VCPKG_COMBINED_C_FLAGS_DEBUG}" + "AR=${ar}" + "ARFLAGS=${VCPKG_COMBINED_STATIC_LINKER_FLAGS_DEBUG}" + "LD=${ld}" + "LDFLAGS=${VCPKG_COMBINED_SHARED_LINKER_FLAGS_DEBUG}" + PROJECT_NAME "makefile" + TARGET install_dev install_modules ${INSTALL_FIPS} + LOGFILE_ROOT install + OPTIONS + "INSTALL_PDBS=${OPENSSL_BUILD_MAKES_PDBS}" # install-pdbs.patch + OPTIONS_RELEASE + "DESTDIR=${current_packages_dir_native}" + install_runtime install_ssldirs # extra targets + OPTIONS_DEBUG + "DESTDIR=${current_packages_dir_native}/debug" +) + +function(z_rearrange_openssl_dirs) + cmake_parse_arguments(PARSE_ARGV 0 arg "" "OUT_PROGRAM_FILES_DIR;FLAVOR_PREFIX" "") + + if(DEFINED arg_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "z_rearrange_openssl_dirs was passed extra arguments: ${arg_UNPARSED_ARGUMENTS}") + endif() + + # The resulting directory will contain something like "Program Files" or "Program Files (x86)"; + # globbing here to be architecture agnostic + set(prefix_packages_dir "${CURRENT_PACKAGES_DIR}${arg_FLAVOR_PREFIX}") + file(GLOB flavor_programfiles_dir LIST_DIRECTORIES true "${prefix_packages_dir}/Program*") + if(NOT flavor_programfiles_dir) + message(FATAL_ERROR "${flavor_programfiles_dir}: error: couldn't find program files dir") + endif() + + if(DEFINED arg_OUT_PROGRAM_FILES_DIR) + set("${arg_OUT_PROGRAM_FILES_DIR}" "${flavor_programfiles_dir}" PARENT_SCOPE) + endif() + + set(flavor_openssl_dir "${flavor_programfiles_dir}/OpenSSL") + if(NOT EXISTS "${flavor_openssl_dir}") + message(FATAL_ERROR "${flavor_openssl_dir}: should exist and be OpenSSLDir") + endif() + + # ideally we would use RENAME rather than COPY and REMOVE_RECURSE but CMake doesn't have an out + # of the box way to do that correctly merging directories + file(GLOB flavor_openssl_dirs LIST_DIRECTORIES true "${flavor_openssl_dir}/*") + file(COPY ${flavor_openssl_dirs} DESTINATION "${prefix_packages_dir}") + file(REMOVE_RECURSE "${flavor_openssl_dir}") +endfunction() + +z_rearrange_openssl_dirs(FLAVOR_PREFIX "" OUT_PROGRAM_FILES_DIR release_programfiles) +if(NOT VCPKG_BUILD_TYPE) + z_rearrange_openssl_dirs(FLAVOR_PREFIX "/debug" OUT_PROGRAM_FILES_DIR debug_programfiles) + file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include") + file(REMOVE_RECURSE "${debug_programfiles}") + file(REMOVE "${CURRENT_PACKAGES_DIR}/debug/bin/c_rehash.pl") +endif() + +set(scripts "bin/c_rehash.pl" "misc/CA.pl" "misc/tsget.pl") +if("tools" IN_LIST FEATURES) + file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/tools/${PORT}") + file(COPY_FILE "${release_programfiles}/Common Files/SSL/openssl.cnf" "${CURRENT_PACKAGES_DIR}/tools/${PORT}/openssl.cnf") + if("fips" IN_LIST FEATURES) + file(COPY_FILE "${release_programfiles}/Common Files/SSL/fipsmodule.cnf" "${CURRENT_PACKAGES_DIR}/tools/${PORT}/fipsmodule.cnf") + endif() + + file(RENAME "${CURRENT_PACKAGES_DIR}/bin/c_rehash.pl" "${CURRENT_PACKAGES_DIR}/tools/${PORT}/c_rehash.pl") + file(RENAME "${release_programfiles}/Common Files/SSL/misc/CA.pl" "${CURRENT_PACKAGES_DIR}/tools/${PORT}/CA.pl") + file(RENAME "${release_programfiles}/Common Files/SSL/misc/tsget.pl" "${CURRENT_PACKAGES_DIR}/tools/${PORT}/tsget.pl") + vcpkg_copy_tools(TOOL_NAMES openssl AUTO_CLEAN) +else() + file(REMOVE + "${CURRENT_PACKAGES_DIR}/bin/c_rehash.pl" + "${release_programfiles}/Common Files/SSL/misc/CA.pl" + "${release_programfiles}/Common Files/SSL/misc/tsget.pl" + ) + + if(VCPKG_LIBRARY_LINKAGE STREQUAL "static") + file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/bin" "${CURRENT_PACKAGES_DIR}/debug/bin") + endif() +endif() + +vcpkg_copy_pdbs() +vcpkg_cmake_config_fixup() + +file(REMOVE_RECURSE # to pass empty directories check + "${release_programfiles}/Common Files/SSL/certs" + "${release_programfiles}/Common Files/SSL/misc" + "${release_programfiles}/Common Files/SSL/private" +) From 17d74dde3c9818fd9cb232f382bf51a7cc0852d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 05:23:31 +0000 Subject: [PATCH 315/331] fix(android): report fresh installation and startup failure evidence --- tools/run_renpy_android_device_acceptance.py | 71 ++++- tools/run_renpy_android_emulator_session.py | 288 ++++++++++++++++++- tools/test_renpy_android_emulator_session.py | 287 ++++++++++++++++++ tools/test_renpy_device_evidence_metrics.py | 150 +++++++++- 4 files changed, 781 insertions(+), 15 deletions(-) diff --git a/tools/run_renpy_android_device_acceptance.py b/tools/run_renpy_android_device_acceptance.py index c1559eb8f..a1bd45761 100644 --- a/tools/run_renpy_android_device_acceptance.py +++ b/tools/run_renpy_android_device_acceptance.py @@ -296,18 +296,48 @@ def __init__(self, args: argparse.Namespace): self.data_root = "" self.pid = "" self.installed = False + self.last_command_returncode = None + self.last_command_stderr = "" self.execution_deadline = 0.0 self.waiting_marker = None self.summary = {"status": "not_run", "run_id": self.run_id, "source": "actual-apk-adb-cloud-device", "serial": args.serial, - "package": args.package, "checks": [], "blocker": ""} + "package": args.package, "checks": [], "blocker": "", + "apk_installed": False, "process_started": False, + "actual_status": {stage: {"status": "not_run", "source": "actual-adb-command", + "package": args.package, "serial": args.serial} + for stage in ("apk_install", "app_launch")}} + + def write_result(self) -> None: + pending = self.output / "result.pending.json" + pending.write_text(json.dumps(self.summary, indent=2) + "\n") + pending.replace(self.output / "result.json") + + def milestone(self, stage: str, **evidence) -> None: + report = self.summary["actual_status"][stage] + report.update(status="passed", **evidence) + self.summary["status"] = "running" + self.write_result() + # Publish only bounded command milestones, never raw device logs or + # any claim that the Ren'Py runtime has rendered or accepted input. + bounded = {**report, "package": report["package"][:256], "serial": report["serial"][:128]} + if "activity" in bounded: + bounded["activity"] = bounded["activity"][:512] + print(json.dumps({"event": "renpy_device_milestone", "run_id": self.run_id, + "apk_installed": self.summary["apk_installed"], + "process_started": self.summary["process_started"], + "stage": stage, "evidence": bounded}), flush=True) def command(self, *parts: str, check: bool = True, timeout: int = 20) -> bytes: command = self.device_prefix + list(parts) + self.last_command_returncode = None + self.last_command_stderr = "" try: result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) except (OSError, subprocess.TimeoutExpired) as exc: raise Failed(f"Device command did not complete: {parts[0]}: {exc}") from exc + self.last_command_returncode = result.returncode + self.last_command_stderr = result.stderr.decode(errors="replace") with (self.output / "commands.jsonl").open("a") as log: log.write(json.dumps({"command": command, "returncode": result.returncode, "stderr": result.stderr.decode(errors="replace"), @@ -458,11 +488,20 @@ def run(self) -> None: self.summary["apk_sha256"] = hashlib.sha256(self.args.apk.read_bytes()).hexdigest() self.summary["device_fingerprint"] = self.shell("getprop", "ro.build.fingerprint").decode().strip() self.summary["device_abis"] = self.shell("getprop", "ro.product.cpu.abilist").decode().strip() - install = self.command("install", "-r", str(self.args.apk.resolve()), timeout=180).decode(errors="replace") + install_status = self.summary["actual_status"]["apk_install"] + install_status["status"] = "failed" + try: + install = self.command("install", "-r", str(self.args.apk.resolve()), timeout=180).decode(errors="replace") + finally: + install_status["returncode"] = self.last_command_returncode (self.output / "install.txt").write_text(install) - if "Success" not in install: + lines = [line.strip() for line in install.splitlines() if line.strip()] + if (not lines or lines[-1] != "Success" or lines.count("Success") != 1 + or any(line.startswith("Failure") for line in lines)): raise Failed(f"Actual APK installation failed: {install.strip()}") self.installed = True + self.summary["apk_installed"] = True + self.milestone("apk_install", returncode=0, stdout_marker="Success") root = self.command("exec-out", "run-as", self.args.package, "pwd", check=False).decode().strip() if not root.startswith("/data/") or "\n" in root or " " in root: raise Blocked("Installed APK does not permit run-as debug staging; use a debuggable APK for this cloud harness") @@ -476,8 +515,21 @@ def run(self) -> None: raise Failed("Cannot resolve the installed APK's real launcher activity") self.summary["activity"] = component self.execution_deadline = time.monotonic() + self.args.overall_timeout - self.shell("am", "start", "-W", "-n", component, timeout=60) - self.pid = self.wait("installed app process", lambda: self.shell("pidof", self.args.package, check=False).decode().strip()) + launch_status = self.summary["actual_status"]["app_launch"] + launch_status.update(status="failed", activity=component) + try: + launch = self.shell("am", "start", "-W", "-n", component, timeout=60).decode(errors="replace") + finally: + launch_status["returncode"] = self.last_command_returncode + (self.output / "launch.txt").write_text(launch) + if (not re.search(r"(?im)^\s*Status:\s*ok\s*$", launch) + or re.search(r"(?im)^\s*(?:Error\b|Exception\b|Failure\b)", launch + "\n" + self.last_command_stderr)): + raise Failed("Actual am start did not report Status: ok without Android errors; inspect launch.txt and commands.jsonl") + self.pid = self.wait("installed app process", self.observed_pid) + pids = self.pid.split() + self.summary["process_started"] = True + self.milestone("app_launch", returncode=0, activity=component, + stdout_marker="Status: ok", pids=pids[:8], pid_count=len(pids)) self.wait_game("start_ready") frame, screen = self.capture("start_ready") self.tap_button(frame, screen) @@ -548,6 +600,13 @@ def run(self) -> None: self.summary["checks"].append({"native_cleanup_error_log": require_engine_log(raw, self.game_root)}) self.summary["status"] = "passed" + def observed_pid(self) -> str: + raw = self.shell("pidof", self.args.package, check=False).decode().strip() + pids = raw.split() + return raw if (self.last_command_returncode == 0 and pids + and all(re.fullmatch(r"[1-9][0-9]{0,9}", pid) + and int(pid) <= 2147483647 for pid in pids)) else "" + def visible_ime(self) -> str: text = self.shell("dumpsys", "input_method").decode(errors="replace") return text if re.search(r"(?:mInputShown|isInputViewShown|inputShown|mIsInputViewShown)=true", text) else "" @@ -647,7 +706,7 @@ def main() -> int: code = 1 finally: acceptance.collect() - (acceptance.output / "result.json").write_text(json.dumps(acceptance.summary, indent=2) + "\n") + acceptance.write_result() print(json.dumps(acceptance.summary, indent=2)) return code diff --git a/tools/run_renpy_android_emulator_session.py b/tools/run_renpy_android_emulator_session.py index 0c653c995..d54f7dd0f 100644 --- a/tools/run_renpy_android_emulator_session.py +++ b/tools/run_renpy_android_emulator_session.py @@ -14,7 +14,9 @@ import os import platform import re +import shlex import signal +import stat import struct import subprocess import sys @@ -30,9 +32,134 @@ IMAGE = "system-images;android-35;google_apis;x86_64" SENSITIVE_DIAGNOSTIC = re.compile( r"auth|token|credentials?|password|passwd|secret|jwt|grpc|api[_-]?key|" - r"[a-z][a-z0-9+.-]*://|www\.|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", + r"://|www\.|eyJ", re.IGNORECASE, ) +STARTUP_FILES = ("commands.jsonl", "install.txt", "aetherkiri-engine.log", "renpy-traceback.txt", + "renpy-log.txt", "android-process-logcat.txt", "android-system-logcat.txt") +STARTUP_ERROR = re.compile(r"error|exception|traceback|fatal|failed|cannot|could not|not found|" + r"undefined symbol|no module|segmentation|renpy_mobile|cooperative_", re.IGNORECASE) +LOGCAT_ROW = re.compile(r"^\d\d-\d\d\s+\S+\s+(\d+)\s+\d+\s+([VDIWEF])\s+([^:]+):\s*(.*)$") +STARTUP_TOKENS = re.compile(r"[A-Za-z0-9_.]+") + + +def startup_line(raw: str) -> str: + line = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", raw) + return re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", line) + + +def startup_text(lines: list[str]) -> dict: + # Only selected startup/error records reach this function, never raw EOF tails. + filtered = ["[redacted environment line]" if re.search( + r"(?:^|:\s*)(?:env(?:ironment)?\b|export\s|[A-Z_][A-Z0-9_]*\s*=)", line, re.IGNORECASE) + else line for raw in lines for line in startup_line(raw).splitlines()] + return bounded_diagnostic("\n".join(filtered), max_chars=512, max_lines=6) + + +def startup_error_text(lines: list[str], deadline: float | None = None) -> dict: + # Preserve the first fatal/header and the final specific causes. Long E-tag + # stacks must not replace a Java loader or Python import error with frames. + deadline = time.monotonic() + 2 if deadline is None else deadline + first = fallback = last = None + causes = [] + timed_out, skipped = time.monotonic() >= deadline, False + for index, line in enumerate(lines[:5000]): + if time.monotonic() >= deadline: + timed_out = True + break + if len(line) > 4096: + skipped = True + continue + lower = line.lower() + is_primary = any(word in lower for word in ("fatal", "traceback", "dlopen failed", "undefined symbol", "no module named")) + is_cause = any(word in lower for word in ("caused by:", "dlopen failed", "undefined symbol", "no module named")) + # One token traversal avoids an unanchored greedy suffix regex which + # rescans a long identifier at every character when no Error is present. + for token in STARTUP_TOKENS.finditer(line): + if time.monotonic() >= deadline: + timed_out = True + break + if token.group().lower().endswith(("error", "exception")): + is_primary = True + is_cause |= line[token.end():token.end() + 1] == ":" + if is_primary and is_cause: + break + if timed_out: + break + if fallback is None: + fallback = index + last = index + if first is None and is_primary: + first = index + if is_cause: + causes = (causes + [index])[-2:] + chosen = list(dict.fromkeys(([first if first is not None else fallback] if fallback is not None else []) + causes)) + if len(chosen) == 1 and chosen[0] != last: + chosen.append(last) + selected = [] + for index in chosen: + safe = startup_text([lines[index]])["output"] + selected.append(safe if len(safe) <= 192 else safe[:64] + " ... " + safe[-123:]) + result = bounded_diagnostic("\n".join(selected), max_chars=768, max_lines=6) + result["truncated"] |= timed_out or skipped or len(chosen) < len(lines) or any(len(lines[index]) > 192 for index in chosen) + if timed_out: + result["diagnostic_deadline_reached"] = True + return result + + +def read_startup_lines(path: Path, started_ns: int, deadline: float) -> tuple[dict, list[str]]: + """Read a bounded prefix of a fresh regular file produced by this harness.""" + if time.monotonic() >= deadline: + return {"status": "diagnostic_notavailable", "reason": "diagnostic_deadline"}, [] + try: + descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW) + try: + details = os.fstat(descriptor) + if not stat.S_ISREG(details.st_mode) or details.st_mtime_ns < started_ns: + return {"status": "diagnostic_notavailable", "reason": "not_fresh_regular_file"}, [] + raw = os.read(descriptor, 262144) + finally: + os.close(descriptor) + except OSError: + return {"status": "diagnostic_notavailable", "reason": "missing_or_unreadable_file"}, [] + bytes_read = len(raw) + truncated = details.st_size > bytes_read + if truncated: + raw = raw.rpartition(b"\n")[0] # Never expose a cut line with hidden sensitive text. + lines = raw.decode(errors="replace").splitlines() + bounded = [startup_line(line) for line in lines[:5000] if len(line) <= 4096] + return {"status": "available", "bytes_read": bytes_read, "prefix_bytes_processed": len(raw), + "truncated": truncated or len(lines) > 5000 or len(bounded) != len(lines)}, bounded + + +def read_current_harness_result(path: Path, started_ns: int | None) -> dict: + """Never accept an old or redirected result, even with an identical APK tuple.""" + if started_ns is None or path.parent.is_symlink(): + raise Unavailable("Current APK harness result ownership could not be verified") + try: + descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW) + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode) or before.st_mtime_ns < started_ns: + raise Unavailable("Actual APK harness result is not a fresh current regular file") + if before.st_size > 262144: + raise Unavailable("Actual APK harness result exceeds its 256KiB read limit") + raw = os.read(descriptor, 262144) + after = os.fstat(descriptor) + if after.st_mtime_ns < started_ns or after.st_size != len(raw) \ + or after.st_mtime_ns != before.st_mtime_ns or after.st_size != before.st_size: + raise Unavailable("Actual APK harness result changed during its bounded read") + finally: + os.close(descriptor) + except OSError as exc: + raise Unavailable("Actual APK harness did not provide a readable current result without symlinks") from exc + try: + summary = json.loads(raw.decode("utf-8")) + except (ValueError, RecursionError) as exc: + raise Unavailable("Actual APK harness current result is not valid bounded JSON") from exc + if not isinstance(summary, dict): + raise Unavailable("Actual APK harness current result is not a JSON object") + return summary def os_screen_stats(image: tuple[int, int, bytes, int]) -> dict: @@ -188,11 +315,13 @@ def __init__(self, args: argparse.Namespace): super().__init__(args) self.harness_process = None self.harness_log = None + self.harness_started_ns = None self.boot_started = None self.next_boot_progress = 0 self.result.update(scope="actual-cloud-android-emulator-session", gameplay_verification="not_run", gameplay_attempted=False, gameplay_verified=False, harness_started=False, + process_started=False, requested_gameplay=args.apk is not None) def boot_progress(self, phase: str, *, response: subprocess.CompletedProcess | None = None) -> None: @@ -249,6 +378,145 @@ def boot_failure_diagnostics(self) -> None: self.result["boot_failure_diagnostics"] = diagnostics print(json.dumps(diagnostics), flush=True) + def startup_failure_diagnostics(self) -> None: + """Print selected fresh local evidence only; this performs no device/network calls.""" + payload = {"checkpoint": "actual-apk-startup-failure-diagnostics", "status": "diagnostic_notavailable", + "gameplay_verified": False, "files": {}, "commands": []} + output = self.output / "gameplay" + if self.harness_started_ns is None or output.is_symlink(): + payload["reason"] = "no_owned_harness_output" + else: + deadline = time.monotonic() + 2 + records = {} + for name in STARTUP_FILES: + metadata, lines = read_startup_lines(output / name, self.harness_started_ns, deadline) + payload["files"][name] = metadata + records[name] = lines + own_pids = set() + prefix = [str(self.adb), "-s", self.result["serial"]] + for line in records.get("commands.jsonl", []): + if time.monotonic() >= deadline: + payload["diagnostic_deadline_reached"] = True + break + try: + row = json.loads(line) + except ValueError: + continue + if not isinstance(row, dict) or not isinstance(row.get("command"), list) \ + or row["command"][:3] != prefix or not isinstance(row.get("time"), (int, float)) \ + or row["time"] < self.harness_started_ns / 1_000_000_000: + continue + parts = row["command"][3:] + if not all(isinstance(part, str) for part in parts): + continue + if parts[:1] == ["logcat"] and "--pid" in parts: + index = parts.index("--pid") + 1 + if index < len(parts) and re.fullmatch(r"[1-9]\d{0,9}", parts[index]) \ + and int(parts[index]) <= 2147483647: + own_pids.add(parts[index]) + phase = "" + if parts[:2] == ["install", "-r"] and len(parts) == 3 \ + and self.args.apk is not None and parts[2] == str(self.args.apk.resolve()): + phase = "apk_install" + elif parts[:1] == ["shell"] and len(parts) == 2: + try: + remote = shlex.split(parts[1]) + except ValueError: + continue + if remote == ["pidof", self.args.package]: + phase = "app_pid_observation" + elif remote[:4] == ["am", "start", "-W", "-n"] and len(remote) == 5 \ + and remote[4].startswith(self.args.package + "/") \ + and re.fullmatch(r"[A-Za-z0-9_.$/]+", remote[4]): + phase = "app_start" + code = row.get("returncode") + if phase and isinstance(code, int) and not isinstance(code, bool) and -255 <= code <= 255 \ + and len(payload["commands"]) < 8: + payload["commands"].append({"phase": phase, "returncode": code, + "stdout": "not_recorded_by_command_log", + "stderr": startup_text([str(row.get("stderr", ""))])}) + owned_launch = self.result.get("actual_status", {}).get("app_launch", {}) + if owned_launch.get("status") == "passed": + own_pids.update(owned_launch["pids"]) + payload["observed_process_pids"] = sorted(own_pids)[:8] + install_lines = records.get("install.txt", []) + payload["installation_success_marker"] = any(line.strip() == "Success" for line in install_lines) + for name in STARTUP_FILES[2:]: + selected = [] + for line in records[name]: + if time.monotonic() >= deadline: + payload["diagnostic_deadline_reached"] = True + break + if "logcat" in name: + parsed = LOGCAT_ROW.match(line) + if not parsed: + continue + pid, priority, _, message = parsed.groups() + if pid not in own_pids and not re.search( + r"(? 6143: + candidates = [row for row in payload["files"].values() if row.get("output")] + candidates += [row["stderr"] for row in payload["commands"] if row["stderr"].get("output")] + if not candidates: + payload["commands"] = payload["commands"][:2] + else: + largest = max(candidates, key=lambda row: len(row["output"])) + largest["output"] = largest["output"][:len(largest["output"]) // 2] + largest["truncated"] = True + serialized = json.dumps(payload, separators=(",", ":")) + self.result["startup_diagnostics"] = {"status": payload["status"], "console_bytes": len(serialized.encode()) + 1, + "files_available": sum(row["status"] == "available" + for row in payload["files"].values()), + "gameplay_verified": False} + print(serialized, flush=True) + + def retain_partial_milestones(self, summary: dict, apk_hash: str) -> None: + if summary.get("source") != "actual-apk-adb-cloud-device" or summary.get("package") != self.args.package \ + or summary.get("serial") != self.result["serial"] or summary.get("apk_sha256") != apk_hash: + return + stages = summary.get("actual_status") + if not isinstance(stages, dict): + return + mirrored = {} + for stage, flag, marker in (("apk_install", "apk_installed", "Success"), + ("app_launch", "process_started", "Status: ok")): + proof = stages.get(stage) + if not isinstance(proof, dict) or summary.get(flag) is not True or proof.get("status") != "passed" \ + or proof.get("source") != "actual-adb-command" or proof.get("package") != self.args.package \ + or proof.get("serial") != self.result["serial"] or not isinstance(proof.get("returncode"), int) \ + or isinstance(proof.get("returncode"), bool) \ + or proof.get("returncode") != 0 or proof.get("stdout_marker") != marker: + continue + row = {key: proof[key] for key in ("status", "source", "package", "serial", "returncode", "stdout_marker")} + if stage == "app_launch": + activity, pids, count = proof.get("activity"), proof.get("pids"), proof.get("pid_count") + if not self.result["apk_installed"] or not isinstance(activity, str) or len(activity) > 256 \ + or not activity.startswith(self.args.package + "/") \ + or not re.fullmatch(r"[A-Za-z0-9_.$/]+", activity) or not isinstance(pids, list) \ + or not 1 <= len(pids) <= 8 or not all(isinstance(pid, str) and re.fullmatch(r"[1-9]\d{0,9}", pid) + and int(pid) <= 2147483647 + for pid in pids) \ + or isinstance(count, bool) or not isinstance(count, int) or not len(pids) <= count <= 1024: + continue + row.update(activity=activity, pids=pids, pid_count=count) + self.result[flag] = True + mirrored[stage] = row + if mirrored: + self.result["actual_status"] = mirrored + def gate_host(self, sdk: Path) -> Path: system, machine = platform.system(), platform.machine() if machine != "x86_64" or system not in ("Linux", "Darwin"): @@ -439,6 +707,7 @@ def gameplay(self) -> None: "--output-dir", str(output)] self.result["harness_command"] = command self.harness_log = (self.output / "gameplay-harness.log").open("wb") + self.harness_started_ns = time.time_ns() self.harness_process = subprocess.Popen(command, stdout=self.harness_log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, env=self.environment, start_new_session=True) self.result.update(harness_started=True, gameplay_attempted=True, gameplay_executed=None) @@ -473,16 +742,13 @@ def gameplay(self) -> None: self.result["gameplay_verification"] = "failed" raise Unavailable("The actual harness process group is not confirmed absent; cleanup could not be verified") summary_path = output / "result.json" - if not summary_path.is_file(): - raise Unavailable("Actual APK harness did not produce its gameplay result; see gameplay-harness.log") - summary = json.loads(summary_path.read_text()) - if not isinstance(summary, dict): - raise Unavailable("Actual APK harness result is not a JSON object") + summary = read_current_harness_result(summary_path, self.harness_started_ns) self.result["gameplay_result"] = summary self.result["gameplay_verification"] = summary.get("status", "failed") if summary.get("status") == "not_run": self.result["gameplay_executed"] = False apk_hash = hashlib.sha256(self.args.apk.read_bytes()).hexdigest() + self.retain_partial_milestones(summary, apk_hash) if not gameplay_result_matches(summary, code, self.args.package, self.result["serial"], apk_hash): self.result["gameplay_verification"] = "not_run" if summary.get("status") == "not_run" else "failed" raise Unavailable(f"Actual APK gameplay did not pass (harness exit {code}, result {summary.get('status')}); " @@ -552,7 +818,15 @@ def run(self) -> None: self.result["boot_failure_diagnostic_error"] = bounded_diagnostic(str(diagnostic_error)) raise if self.args.apk is not None: - self.gameplay() + try: + self.gameplay() + except (Unavailable, Failed, OSError, ValueError, subprocess.TimeoutExpired): + try: + self.startup_failure_diagnostics() + except (OSError, ValueError) as diagnostic_error: + self.result["startup_diagnostic_error"] = bounded_diagnostic( + str(diagnostic_error), max_chars=256, max_lines=1) + raise self.stop(require_clean=True) self.result["checks"].append("actual-emulator-stopped") self.result["status"] = "passed" diff --git a/tools/test_renpy_android_emulator_session.py b/tools/test_renpy_android_emulator_session.py index 0015cd1ba..702a868b2 100644 --- a/tools/test_renpy_android_emulator_session.py +++ b/tools/test_renpy_android_emulator_session.py @@ -24,6 +24,7 @@ from run_renpy_android_emulator_session import ( Session, Unavailable, acceleration_usable, bounded_diagnostic, diagnostic_file_tail, gameplay_result_matches, os_screen_stats, owned_group_exists, owned_group_state, + read_current_harness_result, read_startup_lines, startup_error_text, startup_text, terminate_owned_process, wait_owned_group_exit, ) from run_renpy_android_device_acceptance import Failed, image_stats, png_pixels @@ -247,6 +248,292 @@ def test_return_code_and_all_actual_result_identities_are_required(self): self.assertFalse(gameplay_result_matches(wrong, 0, *arguments), name) +class CurrentStartupDiagnosticTests(unittest.TestCase): + def test_actual_caller_rejects_stale_matching_passed_and_failed_results(self): + # Genuine ordinary exit0/exit1 subprocesses validate this caller boundary. + # They never invoke Android, install an APK or execute a Ren'Py game. + original_popen = subprocess.Popen + for status, code in (("failed", 1), ("passed", 0)): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + summary = self.milestone_summary(session) + summary.update(status=status, apk_sha256=hashlib.sha256(session.args.apk.read_bytes()).hexdigest()) + path = self.write(session, "result.json", json.dumps(summary)) + os.utime(path, ns=(0, 0)) + processes = [] + def ordinary_child(*_, **kwargs): + process = original_popen([sys.executable, "-c", f"raise SystemExit({code})"], + stdin=subprocess.DEVNULL, stdout=kwargs["stdout"], + stderr=subprocess.STDOUT, start_new_session=True) + processes.append(process) + return process + with patch("run_renpy_android_emulator_session.subprocess.Popen", side_effect=ordinary_child), \ + patch("sys.stdout", new_callable=io.StringIO): + with self.assertRaisesRegex(Unavailable, "not a fresh current regular file"): + session.gameplay() + self.assertEqual(processes[0].poll(), code) + self.assertEqual(owned_group_state(processes[0]), "absent") + self.assertFalse(session.result["apk_installed"], status) + self.assertFalse(session.result["process_started"], status) + self.assertFalse(session.result["gameplay_verified"], status) + self.assertNotIn("gameplay_result", session.result) + + def test_current_result_requires_fresh_regular_bounded_json_without_symlinks(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + started_ns = time.time_ns() + fresh = root / "fresh-result.json" + fresh.write_text('{"status":"failed"}') + self.assertEqual(read_current_harness_result(fresh, started_ns), {"status": "failed"}) + symlink = root / "symlink-result.json" + symlink.symlink_to(fresh) + with self.assertRaisesRegex(Unavailable, "without symlinks"): + read_current_harness_result(symlink, started_ns) + fifo = root / "fifo-result.json" + os.mkfifo(fifo) + with self.assertRaisesRegex(Unavailable, "regular file"): + read_current_harness_result(fifo, started_ns) + redirected = root / "redirected" + redirected.symlink_to(root, target_is_directory=True) + with self.assertRaisesRegex(Unavailable, "ownership"): + read_current_harness_result(redirected / fresh.name, started_ns) + oversized = root / "oversized-result.json" + oversized.write_bytes(b"x" * 262145) + with patch("run_renpy_android_emulator_session.os.read", side_effect=AssertionError("Oversized result must not be read")): + with self.assertRaisesRegex(Unavailable, "256KiB"): + read_current_harness_result(oversized, started_ns) + fresh.write_bytes(b"malformed JSON") + with self.assertRaisesRegex(Unavailable, "valid bounded JSON"): + read_current_harness_result(fresh, started_ns) + fresh.write_text("[]") + with self.assertRaisesRegex(Unavailable, "JSON object"): + read_current_harness_result(fresh, started_ns) + + def test_current_ordinary_child_result_can_retain_partial_proof_without_gameplay_pass(self): + # This is a local consumer fixture, never evidence of SDK/APK execution. + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + summary = self.milestone_summary(session) + summary["apk_sha256"] = hashlib.sha256(session.args.apk.read_bytes()).hexdigest() + path = session.output / "gameplay/result.json" + original_popen = subprocess.Popen + def ordinary_child(*_, **kwargs): + return original_popen([sys.executable, "-c", + "import sys; from pathlib import Path; Path(sys.argv[1]).write_text(sys.argv[2]); raise SystemExit(1)", + str(path), json.dumps(summary)], stdin=subprocess.DEVNULL, stdout=kwargs["stdout"], + stderr=subprocess.STDOUT, start_new_session=True) + with patch("run_renpy_android_emulator_session.subprocess.Popen", side_effect=ordinary_child), \ + patch("sys.stdout", new_callable=io.StringIO): + with self.assertRaisesRegex(Unavailable, "gameplay did not pass"): + session.gameplay() + self.assertTrue(session.result["apk_installed"]) + self.assertTrue(session.result["process_started"]) + self.assertFalse(session.result["gameplay_verified"]) + self.assertEqual(session.result["gameplay_verification"], "failed") + + def test_real_long_token_counterexample_finishes_inside_the_processing_budget(self): + # Real ordinary Python subprocess, no SDK/device execution. A previous + # unanchored suffix regex exceeded 3s on this <256KiB current-log prefix. + code = ("import json,sys,time; sys.path.insert(0,sys.argv[1]); " + "from run_renpy_android_emulator_session import startup_error_text; " + "rows=['10-09 04:05:00.000 1234 1234 E Tag: '+'A'*3900]*60; " + "start=time.monotonic(); result=startup_error_text(rows,start+2); " + "print(json.dumps({'elapsed':time.monotonic()-start, 'deadline_reached':" + "result.get('diagnostic_deadline_reached',False)}))") + completed = subprocess.run([sys.executable, "-c", code, str(Path(__file__).parent.resolve())], + stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=5, check=True) + result = json.loads(completed.stdout) + self.assertLess(result["elapsed"], 2) + self.assertFalse(result["deadline_reached"]) + with patch("run_renpy_android_emulator_session.time.monotonic", return_value=10): + expired = startup_error_text(["FATAL EXCEPTION: controlled-not-to-be-processed"], deadline=10) + self.assertEqual(expired["output"], "") + self.assertTrue(expired["diagnostic_deadline_reached"]) + # Conservative JWT/URL filtering also avoids a greedy variable-length + # scheme/token regex on the same long identifiers. + self.assertNotIn("eyJ", startup_text(["Caused by: Error: " + "eyJ" * 1300])["output"]) + self.assertNotIn("://", startup_text(["Caused by: Error: " + "a" * 3900 + "://private"])["output"]) + + def session(self, temporary): + apk = Path(temporary) / "controlled-boundary-not-an-apk" + apk.write_bytes(b"controlled data; no APK/device is executed") + args = type("Args", (), {"output_dir": Path(temporary), "port": 5554, "apk": apk, + "cloud_emulator": True, "sdk_root": temporary, + "package": "org.aetherkiri.renpy.debug", "stage_timeout": 90, + "gameplay_timeout": 900})() + session = Session(args) + session.adb = Path("/controlled-not-an-adb") + session.harness_started_ns = time.time_ns() + (session.output / "gameplay").mkdir() + return session + + def write(self, session, name, text): + path = session.output / "gameplay" / name + path.write_text(text) + return path + + def commands(self, session): + prefix = [str(session.adb), "-s", session.result["serial"]] + parts = [["install", "-r", str(session.args.apk.resolve())], + ["shell", "am start -W -n org.aetherkiri.renpy.debug/com.godot.game.GodotAppLauncher"], + ["shell", "pidof org.aetherkiri.renpy.debug"], ["logcat", "-d", "--pid", "1234"]] + rows = [{"command": prefix + part, "returncode": 0, "stderr": "", "time": time.time()} for part in parts] + self.write(session, "commands.jsonl", "\n".join(json.dumps(row) for row in rows) + "\n") + + def diagnose(self, session): + with patch("sys.stdout", new_callable=io.StringIO) as console, \ + patch("run_renpy_android_emulator_session.subprocess.run", side_effect=AssertionError("No device/network call")): + session.startup_failure_diagnostics() + return console.getvalue(), json.loads(console.getvalue()) + + def test_owned_java_first_fatal_and_last_loader_cause_survive_long_stacks(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + self.commands(session) + self.write(session, "install.txt", "Performing Streamed Install\nSuccess\n") + prefix = "10-09 04:05:00.000 1234 1234 E AndroidRuntime: " + frames = [prefix + f"at fixture.Class.method(Class.java:{index})" for index in range(40)] + cause = prefix + "Caused by: java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol 'controlled-missing-symbol'" + self.write(session, "android-process-logcat.txt", "\n".join( + [prefix + "FATAL EXCEPTION: main", *frames, cause, *frames, + prefix + "Caused by: CredentialError: token=controlled-private-token"]) + "\n") + self.write(session, "android-system-logcat.txt", + "10-09 04:05:00.000 9999 9999 E Other: FATAL EXCEPTION: unrelated-private-app\n") + self.write(session, "renpy-traceback.txt", "Traceback (most recent call last):\n" + ' File "controlled-fixture.py", line 1\nModuleNotFoundError: No module named controlled_module\n') + text, payload = self.diagnose(session) + self.assertIn("FATAL EXCEPTION: main", text) + self.assertIn("controlled-missing-symbol", text) + self.assertIn("ModuleNotFoundError", text) + self.assertNotIn("controlled-private-token", text) + self.assertNotIn("unrelated-private-app", text) + self.assertEqual(payload["observed_process_pids"], ["1234"]) + self.assertTrue(payload["installation_success_marker"]) + self.assertEqual([row["phase"] for row in payload["commands"]], + ["apk_install", "app_start", "app_pid_observation"]) + self.assertFalse(session.result["apk_installed"], "Diagnostics cannot verify installation/gameplay") + self.assertFalse(session.result["gameplay_verified"]) + + def test_environment_credentials_control_sequences_and_urls_do_not_escape(self): + raw = ["\x1b[31mError: PATH=/controlled-private-environment\x1b[0m\n" + "Environment: controlled-private-env\nAuthorization: Bearer controlled-auth\n" + "Error: https://example.invalid/controlled-private-url\n" + "Error: secret=controlled-private-secret\nError: safe-controlled-cause\x00"] + output = startup_text(raw)["output"] + for value in ("private-", "controlled-auth", "PATH=", "example.invalid", "\x1b", "\x00"): + self.assertNotIn(value, output) + self.assertIn("safe-controlled-cause", output) + + def test_stale_symlink_fifo_missing_and_unowned_files_are_not_read(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + stale = self.write(session, "install.txt", "Success\n") + os.utime(stale, ns=(0, session.harness_started_ns - 1)) + private = Path(temporary) / "unowned-controlled-private-file" + private.write_text("Fatal: controlled-private-content\n") + (session.output / "gameplay/aetherkiri-engine.log").symlink_to(private) + os.mkfifo(session.output / "gameplay/renpy-log.txt") + self.write(session, "unknown-private-file", "Fatal: controlled-private-content\n") + text, payload = self.diagnose(session) + self.assertEqual(payload["status"], "diagnostic_notavailable") + self.assertNotIn("controlled-private-content", text) + self.assertFalse(payload["installation_success_marker"]) + self.assertEqual(payload["files"]["install.txt"]["reason"], "not_fresh_regular_file") + self.assertEqual(payload["files"]["renpy-log.txt"]["reason"], "not_fresh_regular_file") + self.assertNotIn("unknown-private-file", payload["files"]) + + def test_prefix_read_and_combined_console_metadata_are_bounded(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + for name in ("aetherkiri-engine.log", "renpy-traceback.txt", "renpy-log.txt", + "android-process-logcat.txt", "android-system-logcat.txt"): + self.write(session, name, "Exception: " + "模" * 200 + "\n" + ("controlled padding\n" * 40000)) + text, payload = self.diagnose(session) + self.assertLessEqual(len(text.encode()), 6144) + self.assertLess(len(json.dumps(session.result["startup_diagnostics"]).encode()), 1024) + self.assertLess(len(text.encode()) + len(json.dumps(session.result["startup_diagnostics"]).encode()), 8192) + self.assertLess(sum(row.get("bytes_read", 0) for row in payload["files"].values()), 2 * 1024 * 1024) + self.assertTrue(payload["files"]["aetherkiri-engine.log"]["truncated"]) + path = self.write(session, "renpy-log.txt", "Exception: first-prefix-cause\n" + "x" * 300000 + + "\nException: controlled-EOF-must-not-be-read\n") + metadata, lines = read_startup_lines(path, session.harness_started_ns, time.monotonic() + 2) + self.assertLessEqual(metadata["bytes_read"], 262144) + self.assertNotIn("controlled-EOF", "\n".join(lines)) + + def test_diagnostic_deadline_and_no_harness_never_call_any_device(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + self.write(session, "install.txt", "Success\n") + with patch("run_renpy_android_emulator_session.time.monotonic", return_value=10), \ + patch("run_renpy_android_emulator_session.os.open", side_effect=AssertionError("No read after deadline")): + metadata, lines = read_startup_lines(session.output / "gameplay/install.txt", session.harness_started_ns, 10) + self.assertEqual(metadata["reason"], "diagnostic_deadline") + self.assertEqual(lines, []) + session.harness_started_ns = None + _, payload = self.diagnose(session) + self.assertEqual(payload["status"], "diagnostic_notavailable") + + def milestone_summary(self, session): + common = {"status": "passed", "source": "actual-adb-command", "package": session.args.package, + "serial": session.result["serial"], "returncode": 0} + return {"status": "failed", "source": "actual-apk-adb-cloud-device", "package": session.args.package, + "serial": session.result["serial"], "apk_sha256": "controlled-hash", + "apk_installed": True, "process_started": True, + "actual_status": {"apk_install": {**common, "stdout_marker": "Success"}, + "app_launch": {**common, "stdout_marker": "Status: ok", "pids": ["1234"], + "pid_count": 1, "activity": session.args.package + "/Launcher"}}} + + def test_matched_partial_milestones_remain_separate_from_gameplay_acceptance(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + summary = self.milestone_summary(session) + session.retain_partial_milestones(summary, "controlled-hash") + self.assertTrue(session.result["apk_installed"]) + self.assertTrue(session.result["process_started"]) + self.assertFalse(session.result["gameplay_verified"]) + self.assertEqual(session.result["gameplay_verification"], "not_run") + self.assertFalse(gameplay_result_matches(summary, 1, session.args.package, + session.result["serial"], "controlled-hash")) + + def test_wrong_identity_or_unproven_stages_cannot_mirror_milestones(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + original = self.milestone_summary(session) + for field in ("source", "package", "serial", "apk_sha256"): + wrong = copy.deepcopy(original) + wrong[field] = "different-controlled-input" + session.retain_partial_milestones(wrong, "controlled-hash") + self.assertFalse(session.result["apk_installed"], field) + self.assertFalse(session.result["process_started"], field) + for key, value in (("source", "simulated"), ("returncode", True), ("returncode", 0.0), + ("stdout_marker", "Success-looking"), ("package", "other.package")): + wrong = copy.deepcopy(original) + wrong["actual_status"]["apk_install"][key] = value + session.retain_partial_milestones(wrong, "controlled-hash") + self.assertFalse(session.result["apk_installed"], key) + self.assertFalse(session.result["process_started"], key) + for pids in (["0"], ["-1"], ["9999999999"], ["2147483648"], [1234]): + wrong = copy.deepcopy(original) + wrong["actual_status"]["app_launch"]["pids"] = pids + session.retain_partial_milestones(wrong, "controlled-hash") + self.assertFalse(session.result["process_started"], pids) + + def test_diagnostics_precede_stop_and_cannot_mask_the_real_primary_gameplay_failure(self): + # Actual local evidence/control flow only; no SDK, APK or emulator is run. + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + events = [] + with patch.object(session, "gate_host", return_value=Path("controlled-emulator")), \ + patch.object(session, "prepare_sdk", return_value=Path("controlled-manager")), \ + patch.object(session, "boot"), \ + patch.object(session, "gameplay", side_effect=Unavailable("controlled original startup failure")), \ + patch.object(session, "startup_failure_diagnostics", side_effect=lambda: events.append("diagnostics")), \ + patch.object(session, "stop", side_effect=lambda **_: events.append("stop")): + with self.assertRaisesRegex(Unavailable, "controlled original startup failure"): + session.run() + self.assertEqual(events, ["diagnostics", "stop"]) + + class CurrentDiagnosticBoundaryTests(unittest.TestCase): def test_sensitive_lines_are_removed_and_current_kernel_failure_is_retained(self): raw = ("\x1b[31mKernel panic: fixture boot evidence\x1b[0m\n" diff --git a/tools/test_renpy_device_evidence_metrics.py b/tools/test_renpy_device_evidence_metrics.py index 067383880..e27606df6 100644 --- a/tools/test_renpy_device_evidence_metrics.py +++ b/tools/test_renpy_device_evidence_metrics.py @@ -1,17 +1,29 @@ #!/usr/bin/env python3 -"""Synthetic scientific fixtures for the mobile evidence gates, not gameplay. +"""Synthetic fixtures and controlled subprocess regressions, not gameplay. These byte arrays are explicitly generated test data. No device, renderer, -provider, adb or XCUITest execution is substituted or claimed by these tests. +provider, adb or XCUITest execution is claimed by these tests. The explicit +subprocess mocks below exercise only truthful partial-failure reporting; they +always fail or block the harness and cannot stand in for device acceptance. """ from __future__ import annotations import copy +import contextlib import functools +import io +import itertools +import json +import shlex +import subprocess +import sys import tempfile import unittest from pathlib import Path +from unittest import mock + +import run_renpy_android_device_acceptance as android from run_renpy_android_device_acceptance import ( Failed, checkpoint_landmarks, image_stats, is_script_checkpoint, pixel, @@ -273,5 +285,139 @@ def test_real_file_read_requires_available_regular_log(self): require_engine_log(read_engine_log_file(path), self.root) +class AndroidPartialFailureReporting(unittest.TestCase): + """Controlled command results, never an actual installation or game pass.""" + + package = "org.example.controlled" + serial = "controlled-subprocess-fixture" + + def run_controlled_failure(self, *, install=(0, b"Performing streamed install\nSuccess\n", b""), + launch=(0, b"Status: ok\n", b""), pid=(0, b"4343\n", b""), + stage_failure=False, cloud=True): + with tempfile.TemporaryDirectory(prefix="renpy-controlled-partial-report-") as temporary: + root = Path(temporary) + apk = root / "controlled-not-installable.apk" + apk.write_bytes(b"Explicit unit fixture, not an installable APK") + output = root / "evidence" + snapshots = [] + + def command_result(argv, **kwargs): + parts = argv[3:] # Controlled adb executable, -s, serial. + result = (0, b"", b"") + if parts == ["get-state"]: + result = (0, b"device\n", b"") + elif parts[0] == "install": + result = install + elif parts[:3] == ["exec-out", "run-as", self.package] and parts[3:] == ["pwd"]: + result = (0, b"/data/user/0/org.example.controlled\n", b"") + elif parts[0] == "shell": + shell = shlex.split(parts[1]) + if shell[:3] == ["cmd", "package", "resolve-activity"]: + result = (0, (self.package + "/.ControlledActivity\n").encode(), b"") + elif shell[:2] == ["am", "start"]: + # The independently persisted install checkpoint must + # already exist before the next command can fail. + snapshots.append(json.loads((output / "result.json").read_text())) + result = launch + elif shell[:1] == ["pidof"]: + result = pid + return subprocess.CompletedProcess(argv, result[0], result[1], result[2]) + + def runtime_failure(stage): + snapshots.append(json.loads((output / "result.json").read_text())) + raise Failed("Controlled regression: Ren'Py startup rejected") + + argv = ["controlled-harness", "--apk", str(apk), "--serial", self.serial, + "--package", self.package, "--adb", "controlled-only-adb", "--output-dir", str(output), + "--stage-timeout", "5", "--overall-timeout", "60"] + if cloud: + argv.append("--cloud-device") + stream = io.StringIO() + clock = itertools.count() + with (mock.patch.object(sys, "argv", argv), + mock.patch.object(android.shutil, "which", return_value="/controlled-only/adb"), + mock.patch.object(android.subprocess, "run", side_effect=command_result) as commands, + mock.patch.object(android.time, "monotonic", side_effect=lambda: float(next(clock))), + mock.patch.object(android.time, "sleep"), + mock.patch.object(android.Acceptance, "stage", + side_effect=Failed("Controlled regression: staging rejected") if stage_failure else None) as staging, + mock.patch.object(android.Acceptance, "wait_game", side_effect=runtime_failure), + contextlib.redirect_stdout(stream)): + code = android.main() + result = json.loads((output / "result.json").read_text()) + checkpoints = [json.loads(line) for line in stream.getvalue().splitlines() + if line.startswith('{"event": "renpy_device_milestone"')] + self.assertNotEqual(result["status"], "passed") + self.assertFalse((output / "result.pending.json").exists()) + return code, result, checkpoints, snapshots, commands.call_args_list, staging.call_count + + def test_no_device_authorization_records_no_attempts(self): + code, report, progress, _, commands, stages = self.run_controlled_failure(cloud=False) + self.assertEqual((code, report["status"]), (2, "not_run")) + self.assertFalse(report["apk_installed"] or report["process_started"]) + self.assertTrue(all(p["status"] == "not_run" for p in report["actual_status"].values())) + self.assertEqual((progress, commands, stages), ([], [], 0)) + + def test_install_counterexamples_cannot_claim_partial_success(self): + for response in [(1, b"Success\n", b"controlled failure"), + (0, b"NotSuccess\n", b""), + (0, b"Success\nFailure [CONTROLLED]\n", b""), + (0, b"Success\nSuccess\n", b"")]: + with self.subTest(response=response): + code, report, progress, _, _, stages = self.run_controlled_failure(install=response) + self.assertEqual((code, report["status"]), (1, "failed")) + self.assertFalse(report["apk_installed"] or report["process_started"]) + self.assertEqual(report["actual_status"]["apk_install"]["status"], "failed") + self.assertEqual(report["actual_status"]["app_launch"]["status"], "not_run") + self.assertEqual((progress, stages), ([], 0)) + + def test_staging_failure_preserves_install_without_claiming_launch(self): + code, report, progress, _, _, _ = self.run_controlled_failure(stage_failure=True) + self.assertEqual((code, report["status"]), (1, "failed")) + self.assertTrue(report["apk_installed"]) + self.assertFalse(report["process_started"]) + self.assertEqual(report["actual_status"]["app_launch"]["status"], "not_run") + self.assertEqual([p["stage"] for p in progress], ["apk_install"]) + + def test_am_start_error_or_missing_status_cannot_claim_process(self): + for response in [(1, b"Status: ok\n", b"controlled error"), + (0, b"Status: ok\nError type 3\n", b""), + (0, b"Status: ok\n", b"Error: controlled denial\n"), + (0, b"Starting: Intent { controlled }\n", b"")]: + with self.subTest(response=response): + code, report, progress, _, _, _ = self.run_controlled_failure(launch=response) + self.assertEqual((code, report["status"]), (1, "failed")) + self.assertTrue(report["apk_installed"]) + self.assertFalse(report["process_started"]) + self.assertEqual(report["actual_status"]["app_launch"]["status"], "failed") + self.assertEqual([p["stage"] for p in progress], ["apk_install"]) + + def test_absent_invalid_or_failed_pid_observation_cannot_claim_process(self): + for response in [(0, b"", b""), (0, b"not running\n", b""), (0, b"0\n", b""), + (0, b"4343 error\n", b""), (1, b"4343\n", b"controlled pidof failure")]: + with self.subTest(response=response): + code, report, progress, _, _, _ = self.run_controlled_failure(pid=response) + self.assertEqual((code, report["status"]), (1, "failed")) + self.assertTrue(report["apk_installed"]) + self.assertFalse(report["process_started"]) + self.assertEqual([p["stage"] for p in progress], ["apk_install"]) + + def test_runtime_failure_retains_both_command_milestones(self): + code, report, progress, snapshots, _, _ = self.run_controlled_failure() + self.assertEqual((code, report["status"]), (1, "failed")) + self.assertTrue(report["apk_installed"] and report["process_started"]) + self.assertEqual(report["checks"], []) + self.assertEqual([p["stage"] for p in progress], ["apk_install", "app_launch"]) + self.assertEqual([s["process_started"] for s in snapshots], [False, True]) + self.assertTrue(all(s["status"] == "running" for s in snapshots)) + for stage in report["actual_status"].values(): + self.assertEqual((stage["status"], stage["source"], stage["returncode"]), + ("passed", "actual-adb-command", 0)) + self.assertEqual((stage["package"], stage["serial"]), (self.package, self.serial)) + launch = report["actual_status"]["app_launch"] + self.assertEqual(launch["activity"], self.package + "/.ControlledActivity") + self.assertEqual((launch["pids"], launch["pid_count"]), (["4343"], 1)) + + if __name__ == "__main__": unittest.main(verbosity=2) From 73be6b403f10e91f6ff992a2d51753f532549318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 06:04:19 +0000 Subject: [PATCH 316/331] ci(android): measure actual cloud emulator boot resources --- tools/run_renpy_android_emulator_session.py | 194 ++++++++++++++++++- tools/test_renpy_android_emulator_session.py | 166 ++++++++++++++++ 2 files changed, 355 insertions(+), 5 deletions(-) diff --git a/tools/run_renpy_android_emulator_session.py b/tools/run_renpy_android_emulator_session.py index d54f7dd0f..0c9768aa6 100644 --- a/tools/run_renpy_android_emulator_session.py +++ b/tools/run_renpy_android_emulator_session.py @@ -41,6 +41,105 @@ r"undefined symbol|no module|segmentation|renpy_mobile|cooperative_", re.IGNORECASE) LOGCAT_ROW = re.compile(r"^\d\d-\d\d\s+\S+\s+(\d+)\s+\d+\s+([VDIWEF])\s+([^:]+):\s*(.*)$") STARTUP_TOKENS = re.compile(r"[A-Za-z0-9_.]+") +AVD_RESOURCE_KEYS = { + "hw.cpu.ncore": 4096, "hw.ramSize": 2147483647, + "hw.lcd.width": 65536, "hw.lcd.height": 65536, "hw.lcd.density": 65536, +} +AVD_RESOURCE_ENUMS = {"hw.gpu.enabled": ("yes", "no"), "hw.keyboard": ("yes", "no"), + "hw.gpu.mode": ("auto", "host", "off", "swiftshader")} + + +def read_avd_resources(directory: Path | None, filename: str, started_ns: int | None, deadline: float) -> dict: + """Only read selected fields from this session's fresh, owned AVD files.""" + unknown = {key: "unknown" for key in (*AVD_RESOURCE_KEYS, *AVD_RESOURCE_ENUMS)} + result = {"status": "diagnostic_notavailable", "values": {}, "unknown": unknown} + if time.monotonic() >= deadline: + return {**result, "reason": "diagnostic_deadline"} + if directory is None or started_ns is None or filename not in ("config.ini", "hardware-qemu.ini"): + return {**result, "reason": "owned_avd_not_available"} + if directory.parent.is_symlink(): + return {**result, "reason": "owned_avd_not_available"} + try: + directory_fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + descriptor = os.open(filename, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW, dir_fd=directory_fd) + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode) or before.st_mtime_ns < started_ns: + return {**result, "reason": "not_fresh_regular_file"} + if before.st_size > 65536: + return {**result, "reason": "file_exceeds_read_limit"} + raw = os.read(descriptor, 65536) + after = os.fstat(descriptor) + if (after.st_size != len(raw) or after.st_size != before.st_size + or after.st_mtime_ns != before.st_mtime_ns): + return {**result, "reason": "file_changed_during_read"} + finally: + os.close(descriptor) + finally: + os.close(directory_fd) + except OSError: + return {**result, "reason": "missing_or_unreadable_file"} + if time.monotonic() >= deadline: + return {**result, "reason": "diagnostic_deadline"} + for raw_line in raw.decode(errors="replace").splitlines(): + if time.monotonic() >= deadline: + return {**result, "status": "diagnostic_notavailable", "values": {}, "reason": "diagnostic_deadline"} + key, separator, value = raw_line.partition("=") + key, value = key.strip(), value.strip() + if not separator or key not in (*AVD_RESOURCE_KEYS, *AVD_RESOURCE_ENUMS): + continue + # Config values are last-wins, including an invalid final value. Never + # publish free text or fall back to an earlier overwritten value. + result["values"].pop(key, None) + unknown[key] = "invalid_value" + if key in AVD_RESOURCE_KEYS and re.fullmatch(r"[0-9]{1,10}", value): + number = int(value) + if 1 <= number <= AVD_RESOURCE_KEYS[key]: + result["values"][key] = number + unknown.pop(key) + elif key in AVD_RESOURCE_ENUMS and value in AVD_RESOURCE_ENUMS[key]: + result["values"][key] = value + unknown.pop(key) + result["status"] = "available" if result["values"] else "diagnostic_notavailable" + return result + + +def cpu_resource_ranges(raw: bytes) -> dict: + """Parse bounded actual sysfs ranges without expanding an arbitrary CPU set.""" + value = raw.decode(errors="replace").strip() + if len(value) > 256 or not re.fullmatch(r"[0-9]{1,4}(?:-[0-9]{1,4})?(?:,[0-9]{1,4}(?:-[0-9]{1,4})?)*", value): + return {"status": "diagnostic_notavailable", "reason": "invalid_cpu_range"} + ranges, previous, count = [], -1, 0 + for item in value.split(","): + parts = [int(number) for number in item.split("-")] + start, end = parts[0], parts[-1] + if not previous < start <= end < 4096: + return {"status": "diagnostic_notavailable", "reason": "invalid_cpu_range"} + ranges.append([start, end]) + count += end - start + 1 + previous = end + return {"status": "available", "ranges": ranges, "count": count} + + +def memory_resource_values(raw: bytes) -> dict: + values, unknown = {}, {"MemTotal": "unknown", "MemAvailable": "unknown"} + for line in raw.decode(errors="replace").splitlines(): + key = line.partition(":")[0] + if key not in unknown and key not in values: + continue + # Reject duplicates rather than reporting an ambiguous memory sample. + if key in values or unknown[key] != "unknown": + values.pop(key, None) + unknown[key] = "duplicate_value" + continue + match = re.fullmatch(r"(?:MemTotal|MemAvailable):\s*([0-9]{1,12})\s+kB", line) + if match: + values[key] = int(match[1]) + unknown.pop(key) + else: + unknown[key] = "invalid_value" + return {"status": "available" if values else "diagnostic_notavailable", "values_kib": values, "unknown": unknown} def startup_line(raw: str) -> str: @@ -316,6 +415,8 @@ def __init__(self, args: argparse.Namespace): self.harness_process = None self.harness_log = None self.harness_started_ns = None + self.avd_directory = None + self.avd_started_ns = None self.boot_started = None self.next_boot_progress = 0 self.result.update(scope="actual-cloud-android-emulator-session", @@ -341,8 +442,13 @@ def boot_progress(self, phase: str, *, response: subprocess.CompletedProcess | N "gameplay_verification": "not_run"}), flush=True) self.next_boot_progress = now + 30 - def diagnostic_command(self, *parts: str, timeout: int = 3, max_chars: int = 1024) -> dict: + def diagnostic_command(self, *parts: str, timeout: float = 3, max_chars: int = 1024, + deadline: float | None = None) -> dict: started = time.monotonic() + if deadline is not None: + timeout = min(timeout, deadline - started) + if timeout <= 0: + return {"unavailable": True, "reason": "diagnostic_deadline", "elapsed_seconds": 0} try: completed = subprocess.run([str(self.adb), "-s", self.result["serial"], *parts], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -356,9 +462,66 @@ def diagnostic_command(self, *parts: str, timeout: int = 3, max_chars: int = 102 result["elapsed_seconds"] = round(time.monotonic() - started, 1) return result + def resource_command(self, command: list[str], deadline: float) -> tuple[dict, bytes]: + remaining = deadline - time.monotonic() + if remaining <= 0: + return {"status": "diagnostic_notavailable", "reason": "diagnostic_deadline"}, b"" + try: + completed = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, env=self.environment, timeout=min(2, remaining)) + except subprocess.TimeoutExpired: + return {"status": "diagnostic_notavailable", "reason": "command_timeout"}, b"" + except OSError: + return {"status": "diagnostic_notavailable", "reason": "command_unavailable"}, b"" + report = {"returncode": completed.returncode, "status": "diagnostic_notavailable"} + if time.monotonic() >= deadline: + return {**report, "reason": "diagnostic_deadline"}, b"" + if completed.returncode != 0: + return {**report, "reason": "command_failed"}, b"" + if len(completed.stdout) > 4096: + return {**report, "reason": "command_output_exceeds_limit"}, b"" + return {**report, "status": "captured"}, completed.stdout + + def resource_diagnostics(self, deadline: float) -> dict: + """Only fixed current-session numeric/enum samples; never raw command text.""" + sample = {"avd": {filename: read_avd_resources(self.avd_directory, filename, self.avd_started_ns, deadline) + for filename in ("config.ini", "hardware-qemu.ini")}} + if platform.system() == "Darwin": + keys = ("hw.logicalcpu", "hw.physicalcpu", "hw.memsize") + report, raw = self.resource_command(["/usr/sbin/sysctl", "-n", *keys], deadline) + if report["status"] == "captured": + values, unknown = {}, {key: "unknown" for key in keys} + lines = raw.decode(errors="replace").splitlines() + if len(lines) == 3: + for key, value in zip(keys, lines): + limit = 9007199254740991 if key == "hw.memsize" else 4096 + if re.fullmatch(r"[0-9]{1,16}", value) and 1 <= int(value) <= limit: + values[key] = int(value) + unknown.pop(key) + else: + unknown[key] = "invalid_value" + report.update(status="available" if values else "diagnostic_notavailable", + values=values, unknown=unknown) + sample["host"] = report + else: + sample["host"] = {"status": "diagnostic_notavailable", "reason": "host_not_darwin"} + sample["guest"] = {} + prefix = [str(self.adb), "-s", self.result["serial"], "shell", "cat"] + for name in ("online", "present"): + report, raw = self.resource_command([*prefix, "/sys/devices/system/cpu/" + name], deadline) + if report["status"] == "captured": + report.update(cpu_resource_ranges(raw)) + sample["guest"][name] = report + report, raw = self.resource_command([*prefix, "/proc/meminfo"], deadline) + if report["status"] == "captured": + report.update(memory_resource_values(raw)) + sample["guest"]["memory"] = report + return sample + def boot_failure_diagnostics(self) -> None: """Read the currently owned AVD before stop; never dump its environment.""" started = time.monotonic() + deadline = started + 20 diagnostics = {"checkpoint": "actual-android-boot-failure-diagnostics", "boot_elapsed_seconds": round(started - self.boot_started, 1), "phase": self.result.get("boot_phase"), "serial": self.result["serial"], @@ -366,17 +529,36 @@ def boot_failure_diagnostics(self) -> None: "last_boot_response": self.result.get("last_boot_response"), "gameplay_verification": "not_run", "emulator_log_tail": diagnostic_file_tail(self.output / "emulator.log")} - diagnostics["adb_state"] = self.diagnostic_command("get-state") + diagnostics["adb_state"] = self.diagnostic_command("get-state", deadline=deadline) + diagnostics["resources"] = self.resource_diagnostics(deadline) diagnostics["boot_properties"] = { - name: self.diagnostic_command("shell", "getprop", name, max_chars=256) + name: self.diagnostic_command("shell", "getprop", name, max_chars=256, deadline=deadline) for name in ("sys.boot_completed", "dev.bootcomplete", "init.svc.bootanim", "ro.build.version.sdk") } # Read at most 80 current warning/error entries, without clearing logcat. diagnostics["logcat_warning_tail"] = self.diagnostic_command( - "logcat", "-d", "-t", "80", "-v", "brief", "*:W", timeout=5, max_chars=3072) + "logcat", "-d", "-t", "80", "-v", "brief", "*:W", timeout=5, max_chars=3072, deadline=deadline) diagnostics["diagnostic_elapsed_seconds"] = round(time.monotonic() - started, 1) + diagnostics["diagnostic_deadline_reached"] = time.monotonic() >= deadline + # Resource samples are fixed fields. Bound the entire existing boot + # console record, including escaped diagnostic text, to 8KiB. + serialized = json.dumps(diagnostics, separators=(",", ":")) + text_reports = [diagnostics["emulator_log_tail"], diagnostics["adb_state"], + diagnostics["logcat_warning_tail"], *diagnostics["boot_properties"].values()] + if diagnostics["last_boot_response"]: + text_reports.append(diagnostics["last_boot_response"]) + while len(serialized.encode()) > 8191: + candidates = [report for report in text_reports if report.get("output")] + if not candidates: + diagnostics["resources"] = {"status": "diagnostic_notavailable", "reason": "console_size_limit"} + serialized = json.dumps(diagnostics, separators=(",", ":")) + break + largest = max(candidates, key=lambda report: len(report["output"])) + largest["output"] = largest["output"][-(len(largest["output"]) // 2):] if len(largest["output"]) > 1 else "" + largest["truncated"] = True + serialized = json.dumps(diagnostics, separators=(",", ":")) self.result["boot_failure_diagnostics"] = diagnostics - print(json.dumps(diagnostics), flush=True) + print(serialized, flush=True) def startup_failure_diagnostics(self) -> None: """Print selected fresh local evidence only; this performs no device/network calls.""" @@ -580,6 +762,8 @@ def boot(self, emulator: Path, avdmanager: Path, avd_root: Path) -> None: avd_root.mkdir() self.environment["ANDROID_AVD_HOME"] = str(avd_root) name = "aether-renpy-cloud-session" + self.avd_started_ns = time.time_ns() + self.avd_directory = avd_root / f"{name}.avd" self.command("create-official-avd", [str(avdmanager), "create", "avd", "--name", name, "--force", "--device", "pixel_2", "--package", IMAGE], input_data=b"no\n", timeout=120) config = avd_root / f"{name}.avd/config.ini" diff --git a/tools/test_renpy_android_emulator_session.py b/tools/test_renpy_android_emulator_session.py index 702a868b2..3b4af7034 100644 --- a/tools/test_renpy_android_emulator_session.py +++ b/tools/test_renpy_android_emulator_session.py @@ -23,6 +23,7 @@ from run_renpy_android_emulator_session import ( Session, Unavailable, acceleration_usable, bounded_diagnostic, diagnostic_file_tail, + cpu_resource_ranges, memory_resource_values, read_avd_resources, gameplay_result_matches, os_screen_stats, owned_group_exists, owned_group_state, read_current_harness_result, read_startup_lines, startup_error_text, startup_text, terminate_owned_process, wait_owned_group_exit, @@ -534,6 +535,171 @@ def test_diagnostics_precede_stop_and_cannot_mask_the_real_primary_gameplay_fail self.assertEqual(events, ["diagnostics", "stop"]) +class CurrentResourceDiagnosticTests(unittest.TestCase): + def session(self, temporary): + args = type("Args", (), {"output_dir": Path(temporary), "port": 5554, "apk": None, + "cloud_emulator": True, "sdk_root": temporary})() + session = Session(args) + session.adb = Path("boundary-adb-not-a-device") + session.boot_started = 0 + session.emulator_process = type("BoundaryProcess", (), {"poll": lambda _: None})() + session.avd_started_ns = time.time_ns() + session.avd_directory = Path(temporary) / "owned.avd" + session.avd_directory.mkdir() + return session + + def test_fresh_owned_avd_only_reports_numeric_and_explicit_enum_fields(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + config = session.avd_directory / "config.ini" + config.write_text("hw.cpu.ncore=2\nhw.ramSize=3072\nhw.lcd.width=1080\n" + "hw.lcd.height=1920\nhw.lcd.density=420\nhw.gpu.enabled=yes\n" + "hw.gpu.mode=swiftshader\nhw.keyboard=yes\nhw.keyboard=no\n" + "image.sysdir.1=/private/secret-token-sdk\nuuid=private-uuid\n") + report = read_avd_resources(session.avd_directory, "config.ini", session.avd_started_ns, time.monotonic() + 1) + self.assertEqual(report["status"], "available") + self.assertEqual(report["values"]["hw.cpu.ncore"], 2) + self.assertEqual(report["values"]["hw.keyboard"], "no") + self.assertFalse(report["unknown"]) + self.assertNotIn("private", json.dumps(report)) + self.assertNotIn("token", json.dumps(report)) + with config.open("a") as stream: + stream.write("hw.cpu.ncore=4097\nhw.gpu.mode=secret-token://arbitrary\n") + report = read_avd_resources(session.avd_directory, "config.ini", session.avd_started_ns, time.monotonic() + 1) + self.assertNotIn("hw.cpu.ncore", report["values"]) + self.assertNotIn("hw.gpu.mode", report["values"]) + self.assertEqual(report["unknown"]["hw.cpu.ncore"], "invalid_value") + self.assertNotIn("secret", json.dumps(report)) + + def test_stale_redirected_nonregular_and_oversize_avd_files_remain_unknown(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + config = session.avd_directory / "config.ini" + config.write_text("hw.cpu.ncore=2\n") + os.utime(config, ns=(0, 0)) + self.assertEqual(read_avd_resources(session.avd_directory, "config.ini", session.avd_started_ns, + time.monotonic() + 1)["reason"], "not_fresh_regular_file") + config.unlink() + target = Path(temporary) / "other-current-file" + target.write_text("hw.cpu.ncore=4\n") + config.symlink_to(target) + self.assertEqual(read_avd_resources(session.avd_directory, "config.ini", session.avd_started_ns, + time.monotonic() + 1)["status"], "diagnostic_notavailable") + config.unlink() + os.mkfifo(config) + started = time.monotonic() + self.assertEqual(read_avd_resources(session.avd_directory, "config.ini", session.avd_started_ns, + started + 1)["reason"], "not_fresh_regular_file") + self.assertLess(time.monotonic() - started, 1) + config.unlink() + config.write_bytes(b"hw.cpu.ncore=4\n" + b"A" * 65536) + self.assertEqual(read_avd_resources(session.avd_directory, "config.ini", session.avd_started_ns, + time.monotonic() + 1)["reason"], "file_exceeds_read_limit") + redirected = Path(temporary) / "redirected.avd" + redirected.symlink_to(session.avd_directory, target_is_directory=True) + self.assertEqual(read_avd_resources(redirected, "config.ini", session.avd_started_ns, + time.monotonic() + 1)["status"], "diagnostic_notavailable") + with patch("run_renpy_android_emulator_session.os.open", side_effect=AssertionError("No read after deadline")): + self.assertEqual(read_avd_resources(session.avd_directory, "config.ini", session.avd_started_ns, + time.monotonic() - 1)["reason"], "diagnostic_deadline") + + def test_cpu_and_memory_parsers_reject_unbounded_ranges_and_keep_missing_values_unknown(self): + self.assertEqual(cpu_resource_ranges(b"0-3,6\n")["ranges"], [[0, 3], [6, 6]]) + self.assertEqual(cpu_resource_ranges(b"0-3,6\n")["count"], 5) + for value in (b"", b"0-9999", b"3-1", b"0-3,2", b"4,1", b"0-999999999999", b"secret-token", b"0," * 200): + self.assertEqual(cpu_resource_ranges(value)["status"], "diagnostic_notavailable") + report = memory_resource_values(b"MemTotal: 3145728 kB\nMemAvailable: 65536 kB\nPrivateEnv: secret-token\n") + self.assertEqual(report["values_kib"], {"MemTotal": 3145728, "MemAvailable": 65536}) + self.assertNotIn("secret", json.dumps(report)) + report = memory_resource_values(b"MemTotal: 3145728 kB\nMemTotal: 42 kB\nMemAvailable: token\n") + self.assertEqual(report["values_kib"], {}) + self.assertEqual(report["unknown"], {"MemTotal": "duplicate_value", "MemAvailable": "invalid_value"}) + self.assertEqual(memory_resource_values(b"")["unknown"], {"MemTotal": "unknown", "MemAvailable": "unknown"}) + + def test_actual_ordinary_resource_child_timeout_is_bounded_and_does_not_publish_partial_text(self): + # A real ordinary subprocess validates timeout handling, not Android/HVF. + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + started = time.monotonic() + report, raw = session.resource_command([sys.executable, "-c", + "import time; print('secret-token', flush=True); time.sleep(10)"], started + 0.2) + self.assertLess(time.monotonic() - started, 1) + self.assertEqual(report["reason"], "command_timeout") + self.assertEqual(raw, b"") + self.assertNotIn("secret", json.dumps(report)) + with patch("run_renpy_android_emulator_session.subprocess.run", side_effect=AssertionError("No command after deadline")): + self.assertEqual(session.resource_command(["not-executed"], time.monotonic() - 1)[0]["reason"], + "diagnostic_deadline") + + def test_old_and_new_boot_diagnostics_share_one_absolute_twenty_second_budget(self): + # Controlled clock and command responses test orchestration, not a guest. + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + clock, calls = {"now": 600.0}, [] + def bounded_response(command, **kwargs): + self.assertLess(clock["now"], 620) + self.assertLessEqual(kwargs["timeout"], 620 - clock["now"]) + calls.append((command, kwargs["timeout"])) + clock["now"] += kwargs["timeout"] + output = b"4\n2\n8589934592\n" if command[0] == "/usr/sbin/sysctl" else b"" + return subprocess.CompletedProcess(command, 0, output) + with patch("run_renpy_android_emulator_session.platform.system", return_value="Darwin"), \ + patch("run_renpy_android_emulator_session.time.monotonic", side_effect=lambda: clock["now"]), \ + patch("run_renpy_android_emulator_session.subprocess.run", side_effect=bounded_response), \ + patch("sys.stdout", new_callable=io.StringIO) as console: + session.boot_failure_diagnostics() + report = json.loads(console.getvalue()) + self.assertEqual(clock["now"], 620) + self.assertEqual(report["diagnostic_elapsed_seconds"], 20) + self.assertTrue(report["diagnostic_deadline_reached"]) + self.assertEqual(report["logcat_warning_tail"]["reason"], "diagnostic_deadline") + self.assertEqual(report["resources"]["host"]["values"]["hw.logicalcpu"], 4) + self.assertFalse(session.result["gameplay_verified"]) + self.assertFalse(session.result["harness_started"]) + self.assertEqual(session.boot_started, 0) + self.assertTrue(all(command[0] in ("/usr/sbin/sysctl", str(session.adb)) for command, _ in calls)) + + def test_resource_command_errors_and_missing_avd_never_become_measured_defaults(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + with patch("run_renpy_android_emulator_session.platform.system", return_value="Darwin"), \ + patch("run_renpy_android_emulator_session.subprocess.run", + return_value=subprocess.CompletedProcess(["boundary"], 1, b"secret-token://irrelevant")): + report = session.resource_diagnostics(time.monotonic() + 1) + self.assertEqual(report["host"]["reason"], "command_failed") + self.assertTrue(all(row["reason"] == "command_failed" for row in report["guest"].values())) + self.assertTrue(all(row["status"] == "diagnostic_notavailable" for row in report["avd"].values())) + self.assertNotIn("secret", json.dumps(report)) + self.assertNotIn("values", report["host"]) + + def test_entire_current_boot_console_is_eight_kibibytes_and_resources_are_selected_only(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + (session.output / "emulator.log").write_text(("Visible kernel state " + "\u2603" * 500 + "\n") * 40) + (session.avd_directory / "config.ini").write_text("hw.cpu.ncore=1\npath=secret-token://private\n") + def responses(command, **kwargs): + if command[0] == "/usr/sbin/sysctl": + output = b"4\n2\n8589934592\n" + elif command[-1] in ("/sys/devices/system/cpu/online", "/sys/devices/system/cpu/present"): + output = b"0\n" + elif command[-1] == "/proc/meminfo": + output = b"MemTotal: 3145728 kB\nMemAvailable: 123456 kB\nSecret: token\n" + else: + output = (("Visible record " + "\u2603" * 500 + "\n") * 40).encode() + return subprocess.CompletedProcess(command, 0, output) + with patch("run_renpy_android_emulator_session.platform.system", return_value="Darwin"), \ + patch("run_renpy_android_emulator_session.subprocess.run", side_effect=responses), \ + patch("sys.stdout", new_callable=io.StringIO) as console: + session.boot_failure_diagnostics() + self.assertLessEqual(len(console.getvalue().encode()), 8192) + report = json.loads(console.getvalue()) + self.assertEqual(report["resources"]["guest"]["online"]["count"], 1) + self.assertEqual(report["resources"]["guest"]["memory"]["values_kib"]["MemAvailable"], 123456) + self.assertNotIn("token", console.getvalue()) + self.assertNotIn("private", console.getvalue()) + self.assertFalse(session.result["gameplay_verified"]) + + class CurrentDiagnosticBoundaryTests(unittest.TestCase): def test_sensitive_lines_are_removed_and_current_kernel_failure_is_retained(self): raw = ("\x1b[31mKernel panic: fixture boot evidence\x1b[0m\n" From c3b4163e7ae01bcbba03e4c9e3e28972ce47d6e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 06:26:17 +0000 Subject: [PATCH 317/331] fix(ios): require the real GLib pipe2 SDK declaration --- tools/test_ios_glib_pipe2.py | 208 +++++++++++++++++++++ vcpkg/ports/glib/ios-pipe2-prototype.patch | 14 ++ vcpkg/ports/glib/portfile.cmake | 12 +- 3 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 tools/test_ios_glib_pipe2.py create mode 100644 vcpkg/ports/glib/ios-pipe2-prototype.patch diff --git a/tools/test_ios_glib_pipe2.py b/tools/test_ios_glib_pipe2.py new file mode 100644 index 000000000..82dabf310 --- /dev/null +++ b/tools/test_ios_glib_pipe2.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Check GLib's genuine pipe2 probe and source function with real host headers. + +This uses the pinned public GLib/Meson sources and a real Linux compiler/libc. +POSIX feature mode reproduces a linkable pipe2 without a header declaration; +GNU mode verifies that the corrected probe still enables a declared pipe2. +The original GLib function body then exercises actual pipe/fcntl operations. +No SDK or compiler is mocked. Full GLib/Apple/application/gameplay are NOT_RUN. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys + + +GLIB_COMMIT = "2371bee17d85318480b3ddeeab4f5107b4889ad7" +MESON_COMMIT = "75ef08140346d9a058546493036a31431774eb7a" +REPOSITORY = Path(__file__).resolve().parents[1] +PORT = REPOSITORY / "vcpkg/ports/glib" + + +def sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--glib-source", type=Path, required=True) + parser.add_argument("--meson-source", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--cc", default="cc") + parser.add_argument("--cmake", default="cmake") + args = parser.parse_args() + if sys.platform != "linux": + parser.error("This real POSIX/GNU header regression requires a Linux host") + source = args.glib_source.resolve(strict=True) + meson = args.meson_source.resolve(strict=True) + output = args.output_dir.resolve() + if any(output == p or output.is_relative_to(p) for p in (source, meson, REPOSITORY)): + parser.error("Use an isolated output directory outside both checkouts and the repository") + cc, cmake, patch = (shutil.which(x) for x in (args.cc, args.cmake, "patch")) + if not all((cc, cmake, patch)): + raise RuntimeError("A real host compiler, CMake and patch are required") + for directory, expected in ((source, GLIB_COMMIT), (meson, MESON_COMMIT)): + actual = subprocess.check_output( + ["git", "-C", str(directory), "rev-parse", "HEAD"], text=True).strip() + if actual != expected: + raise RuntimeError(f"Expected genuine pinned source {expected}, got {actual}") + subprocess.run(["git", "-C", str(directory), "diff", "--exit-code", "HEAD", "--"], + check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output.mkdir(parents=True, exist_ok=False) + process_env = os.environ.copy() + process_env["CC"] = cc + + def run(argv: list[str], log: Path, *, cwd: Path | None = None) -> subprocess.CompletedProcess: + result = subprocess.run(argv, cwd=cwd, env=process_env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + log.write_text(result.stdout) + return result + + # Execute the production CMake patch-selection block without replacing any + # vcpkg function or evaluating the download/build portions of the recipe. + recipe = (PORT / "portfile.cmake").read_text() + selection = "set(GLIB_PATCHES\n" + recipe.split("set(GLIB_PATCHES\n", 1)[1].split( + "vcpkg_extract_source_archive(SOURCE_PATH", 1)[0] + checks = "" + for ios in (True, False): + checks += "set(VCPKG_TARGET_IS_IOS " + ("TRUE" if ios else "FALSE") + ")\n" + checks += selection + expected = "use-libiconv-on-windows.patch;libintl.patch" + if ios: + expected += ";ios-pipe2-prototype.patch" + checks += f'if(NOT GLIB_PATCHES STREQUAL "{expected}")\n' + checks += 'message(FATAL_ERROR "Wrong production patch selection")\nendif()\n' + (output / "production-scope.cmake").write_text(checks) + result = run([cmake, "-P", str(output / "production-scope.cmake")], + output / "production-scope.log") + if result.returncode: + raise RuntimeError("Actual production iOS/non-iOS patch selection failed") + + snapshot = output / "patched-source" + shutil.copytree(source, snapshot, ignore=shutil.ignore_patterns(".git")) + for name in ("use-libiconv-on-windows.patch", "libintl.patch", "ios-pipe2-prototype.patch"): + result = run([patch, "--batch", "--fuzz=0", "-p1", "-i", str(PORT / name)], + output / (name + ".log"), cwd=snapshot) + if result.returncode: + raise RuntimeError(f"Genuine GLib source patch failed: {name}") + + def probe_block(path: Path) -> str: + text = path.read_text() + if text.count("foreach f : functions\n") != 1: + raise RuntimeError("The production GLib capability loop changed") + return "foreach f : functions\n" + text.split("foreach f : functions\n", 1)[1].split( + "endforeach", 1)[0] + "endforeach\n" + + original_block = probe_block(source / "meson.build") + patched_block = probe_block(snapshot / "meson.build") + header = source / "glib/glib-unixprivate.h" + if header.read_bytes() != (snapshot / "glib/glib-unixprivate.h").read_bytes(): + raise RuntimeError("The production pipe function must remain unchanged") + body = header.read_text().split("G_BEGIN_DECLS\n", 1)[1].split("G_END_DECLS", 1)[0] + # Only the upstream scalar declarations/macros accompany its unmodified + # function body. All OS declarations and operations use actual host libc. + types = re.findall(r"^typedef\s+(?:int\s+gint|gint\s+gboolean);", + (source / "glib/gtypes.h").read_text(), re.M) + macros = re.findall(r"^#define\s+(?:FALSE|TRUE)\s+[^\n]+", + (source / "glib/gmacros.h").read_text(), re.M) + if len(types) != 2 or len(macros) != 2 or body.count("g_unix_open_pipe_internal") != 1: + raise RuntimeError("The pinned GLib scalar declarations/function boundary changed") + driver = '''#include "config.h" +#include +#include +#include +#include +#include +''' + "\n".join(types + macros) + "\n" + body + ''' +int main(void) { + for (int clo = 0; clo < 2; clo++) for (int nb = 0; nb < 2; nb++) { + int fds[2] = {-1, -1}; + assert(g_unix_open_pipe_internal(fds, clo, nb)); + for (int i = 0; i < 2; i++) { + int fdflags = fcntl(fds[i], F_GETFD); + int flags = fcntl(fds[i], F_GETFL); + assert(fdflags >= 0 && flags >= 0); + assert(!!(fdflags & FD_CLOEXEC) == clo); + assert(!!(flags & O_NONBLOCK) == nb); + } + if (nb) { + char byte; + errno = 0; + assert(read(fds[0], &byte, 1) == -1); + assert(errno == EAGAIN || errno == EWOULDBLOCK); + } + assert(write(fds[1], "ok", 2) == 2); + char bytes[2]; + assert(read(fds[0], bytes, 2) == 2); + assert(bytes[0] == 'o' && bytes[1] == 'k'); + assert(close(fds[0]) == 0 && close(fds[1]) == 0); + } + puts("actual_upstream_pipe_function_4_flag_cases_PASS"); + return 0; +} +''' + cases = {} + for label, flags, block, available in ( + ("original-posix", "-D_POSIX_C_SOURCE=200809L", original_block, True), + ("patched-posix", "-D_POSIX_C_SOURCE=200809L", patched_block, False), + ("patched-gnu", "-D_GNU_SOURCE", patched_block, True), + ): + directory = output / label + directory.mkdir() + project = "project('genuine-glib-probe', 'c', default_options : ['c_std=c99'])\n" + project += "cc = meson.get_compiler('c')\nfunctions = ['pipe2']\n" + project += "glib_conf = configuration_data()\nglib_conf_prefix = ''\n" + block + project += "configure_file(output : 'config.h', configuration : glib_conf)\n" + (directory / "meson.build").write_text(project) + result = run([sys.executable, str(meson / "meson.py"), "setup", + str(directory / "build"), str(directory), "--backend=none", + "-Dc_args=" + flags], directory / "configure.log") + if result.returncode: + raise RuntimeError(f"Real Meson configure failed: {label}") + config = (directory / "build/config.h").read_text() + detected = "#define HAVE_PIPE2 1" in config + if detected != available: + raise RuntimeError(f"Unexpected genuine header probe in {label}: {config}") + (directory / "actual-function.c").write_text(driver) + result = run([cc, "-std=c99", "-Werror=implicit-function-declaration", flags, + "-I", str(directory / "build"), str(directory / "actual-function.c"), + "-o", str(directory / "actual-function")], directory / "compiler.log") + if label == "original-posix": + if not result.returncode or not re.search( + r"(?:implicit declaration|undeclared function).*['‘]pipe2", result.stdout): + raise RuntimeError("Original real source did not fail on undeclared pipe2") + runtime = "NOT_RUN_EXPECTED_COMPILER_FAILURE" + else: + if result.returncode: + raise RuntimeError(f"Actual GLib function compilation failed: {label}") + result = run([str(directory / "actual-function")], directory / "runtime.log") + if result.returncode or "4_flag_cases_PASS" not in result.stdout: + raise RuntimeError(f"Actual pipe/fcntl runtime failed: {label}") + runtime = "PASS" + cases[label] = {"have_pipe2": detected, "runtime": runtime} + + evidence = { + "result": "PASS", "scope": "genuine_Linux_host_Meson_probe_and_isolated_GLib_source_function", + "glib_commit": GLIB_COMMIT, "meson_commit": MESON_COMMIT, + "compiler": subprocess.check_output([cc, "--version"], text=True).splitlines()[0], + "glib_meson_sha256": sha(source / "meson.build"), + "meson_compiler_sha256": sha(meson / "mesonbuild/compilers/mixins/clike.py"), + "pipe_header_sha256": sha(header), "patch_sha256": sha(PORT / "ios-pipe2-prototype.patch"), + "recipe_sha256": sha(PORT / "portfile.cmake"), "cases": cases, + "not_run": ["exact_GNOME_release_archive_build", "complete_GLib_build", + "Apple_SDK_compilation", "iOS_application", "gameplay"], + } + (output / "evidence.json").write_text(json.dumps(evidence, indent=2) + "\n") + print(json.dumps(evidence)) + + +if __name__ == "__main__": + main() diff --git a/vcpkg/ports/glib/ios-pipe2-prototype.patch b/vcpkg/ports/glib/ios-pipe2-prototype.patch new file mode 100644 index 000000000..c072cc1e9 --- /dev/null +++ b/vcpkg/ports/glib/ios-pipe2-prototype.patch @@ -0,0 +1,14 @@ +--- a/meson.build ++++ b/meson.build +@@ -816,7 +816,10 @@ + foreach f : functions +- if cc.has_function(f) ++ # iOS SDKs may export functions without declaring a supported public API. ++ # Require pipe2's real prototype before enabling its optional fast path. ++ function_prefix = f == 'pipe2' ? '#include ' : '' ++ if cc.has_function(f, prefix : function_prefix) + define = 'HAVE_' + f.underscorify().to_upper() + glib_conf.set(define, 1) + glib_conf_prefix = glib_conf_prefix + '#define @0@ 1\n'.format(define) + set_variable('have_func_' + f, true) + else diff --git a/vcpkg/ports/glib/portfile.cmake b/vcpkg/ports/glib/portfile.cmake index da614ace6..31444cd67 100644 --- a/vcpkg/ports/glib/portfile.cmake +++ b/vcpkg/ports/glib/portfile.cmake @@ -6,11 +6,17 @@ vcpkg_download_distfile(GLIB_ARCHIVE SHA512 430928d7d7a442fc3927ca943f2569035fe8768768a0ebc6720ae1ef152b56fc5f8d4215d21b4828cc2f39a8632c907ed2c52a0c8566da1c533a2e049a1a121f ) +set(GLIB_PATCHES + use-libiconv-on-windows.patch + libintl.patch +) +if(VCPKG_TARGET_IS_IOS) + list(APPEND GLIB_PATCHES ios-pipe2-prototype.patch) +endif() + vcpkg_extract_source_archive(SOURCE_PATH ARCHIVE "${GLIB_ARCHIVE}" - PATCHES - use-libiconv-on-windows.patch - libintl.patch + PATCHES ${GLIB_PATCHES} ) if(VCPKG_HOST_IS_WINDOWS) From dcaa7237911ff4e898225a719a620b20925243be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 06:30:24 +0000 Subject: [PATCH 318/331] fix(android): retain fresh actual activity launch diagnostics --- tools/run_renpy_android_emulator_session.py | 25 +++++-- tools/test_renpy_android_emulator_session.py | 73 ++++++++++++++++++-- 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/tools/run_renpy_android_emulator_session.py b/tools/run_renpy_android_emulator_session.py index 0c9768aa6..d9efeb5ad 100644 --- a/tools/run_renpy_android_emulator_session.py +++ b/tools/run_renpy_android_emulator_session.py @@ -35,7 +35,7 @@ r"://|www\.|eyJ", re.IGNORECASE, ) -STARTUP_FILES = ("commands.jsonl", "install.txt", "aetherkiri-engine.log", "renpy-traceback.txt", +STARTUP_FILES = ("commands.jsonl", "install.txt", "launch.txt", "aetherkiri-engine.log", "renpy-traceback.txt", "renpy-log.txt", "android-process-logcat.txt", "android-system-logcat.txt") STARTUP_ERROR = re.compile(r"error|exception|traceback|fatal|failed|cannot|could not|not found|" r"undefined symbol|no module|segmentation|renpy_mobile|cooperative_", re.IGNORECASE) @@ -206,7 +206,7 @@ def startup_error_text(lines: list[str], deadline: float | None = None) -> dict: return result -def read_startup_lines(path: Path, started_ns: int, deadline: float) -> tuple[dict, list[str]]: +def read_startup_lines(path: Path, started_ns: int, deadline: float, *, read_limit: int = 262144) -> tuple[dict, list[str]]: """Read a bounded prefix of a fresh regular file produced by this harness.""" if time.monotonic() >= deadline: return {"status": "diagnostic_notavailable", "reason": "diagnostic_deadline"}, [] @@ -216,7 +216,7 @@ def read_startup_lines(path: Path, started_ns: int, deadline: float) -> tuple[di details = os.fstat(descriptor) if not stat.S_ISREG(details.st_mode) or details.st_mtime_ns < started_ns: return {"status": "diagnostic_notavailable", "reason": "not_fresh_regular_file"}, [] - raw = os.read(descriptor, 262144) + raw = os.read(descriptor, read_limit) finally: os.close(descriptor) except OSError: @@ -571,7 +571,8 @@ def startup_failure_diagnostics(self) -> None: deadline = time.monotonic() + 2 records = {} for name in STARTUP_FILES: - metadata, lines = read_startup_lines(output / name, self.harness_started_ns, deadline) + metadata, lines = read_startup_lines(output / name, self.harness_started_ns, deadline, + read_limit=4096 if name == "launch.txt" else 262144) payload["files"][name] = metadata records[name] = lines own_pids = set() @@ -625,11 +626,21 @@ def startup_failure_diagnostics(self) -> None: payload["installation_success_marker"] = any(line.strip() == "Success" for line in install_lines) for name in STARTUP_FILES[2:]: selected = [] + reported_statuses = [] for line in records[name]: if time.monotonic() >= deadline: payload["diagnostic_deadline_reached"] = True break - if "logcat" in name: + if name == "launch.txt": + match = re.fullmatch(r"\s*Status:\s*(\S+)\s*", line, re.IGNORECASE) + if match: + value = match[1].lower() + value = value if value in ("ok", "timeout", "error", "failed") else "unrecognized" + if value not in reported_statuses: + reported_statuses.append(value) + if not STARTUP_ERROR.search(line): + continue + elif "logcat" in name: parsed = LOGCAT_ROW.match(line) if not parsed: continue @@ -643,6 +654,10 @@ def startup_failure_diagnostics(self) -> None: continue selected.append(line) extracted = startup_error_text(selected, deadline) + if name == "launch.txt": + # These are diagnostic stdout markers, never launch/PID + # proof. The harness keeps its strict Status: ok gate. + extracted["reported_statuses"] = reported_statuses extracted["truncated"] |= payload["files"][name].get("truncated", False) payload["files"][name].update(extracted) if any(row["status"] == "available" for row in payload["files"].values()): diff --git a/tools/test_renpy_android_emulator_session.py b/tools/test_renpy_android_emulator_session.py index 3b4af7034..49efdc6f2 100644 --- a/tools/test_renpy_android_emulator_session.py +++ b/tools/test_renpy_android_emulator_session.py @@ -283,8 +283,15 @@ def test_current_result_requires_fresh_regular_bounded_json_without_symlinks(sel with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) started_ns = time.time_ns() + def write_current_fixture(path, data): + # Controlled semantic input: userspace clock sampling alone + # does not guarantee a subsequent inode mtime at that precision. + path.write_bytes(data) + current_ns = started_ns + 1_000_000 + os.utime(path, ns=(current_ns, current_ns)) + self.assertGreaterEqual(path.stat().st_mtime_ns, started_ns) fresh = root / "fresh-result.json" - fresh.write_text('{"status":"failed"}') + write_current_fixture(fresh, b'{"status":"failed"}') self.assertEqual(read_current_harness_result(fresh, started_ns), {"status": "failed"}) symlink = root / "symlink-result.json" symlink.symlink_to(fresh) @@ -299,14 +306,14 @@ def test_current_result_requires_fresh_regular_bounded_json_without_symlinks(sel with self.assertRaisesRegex(Unavailable, "ownership"): read_current_harness_result(redirected / fresh.name, started_ns) oversized = root / "oversized-result.json" - oversized.write_bytes(b"x" * 262145) + write_current_fixture(oversized, b"x" * 262145) with patch("run_renpy_android_emulator_session.os.read", side_effect=AssertionError("Oversized result must not be read")): with self.assertRaisesRegex(Unavailable, "256KiB"): read_current_harness_result(oversized, started_ns) - fresh.write_bytes(b"malformed JSON") + write_current_fixture(fresh, b"malformed JSON") with self.assertRaisesRegex(Unavailable, "valid bounded JSON"): read_current_harness_result(fresh, started_ns) - fresh.write_text("[]") + write_current_fixture(fresh, b"[]") with self.assertRaisesRegex(Unavailable, "JSON object"): read_current_harness_result(fresh, started_ns) @@ -415,6 +422,64 @@ def test_owned_java_first_fatal_and_last_loader_cause_survive_long_stacks(self): self.assertFalse(session.result["apk_installed"], "Diagnostics cannot verify installation/gameplay") self.assertFalse(session.result["gameplay_verified"]) + def test_current_am_start_status_and_error_stdout_are_diagnostic_only(self): + # Real-format local stdout fixtures validate extraction, never app launch. + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + self.write(session, "launch.txt", "Starting: Intent { cmp=org.aetherkiri.renpy.debug/Launcher }\n" + "Status: timeout\nLaunchState: COLD\n" + "Error: controlled-activity-start-failure\n" + "Caused by: java.lang.RuntimeException: controlled-start-cause\n" + "Error: token=controlled-private-launch-token\n") + text, payload = self.diagnose(session) + report = payload["files"]["launch.txt"] + self.assertEqual(report["reported_statuses"], ["timeout"]) + self.assertIn("controlled-activity-start-failure", text) + self.assertIn("controlled-start-cause", text) + self.assertNotIn("controlled-private-launch-token", text) + self.assertFalse(session.result["process_started"]) + self.assertFalse(session.result["gameplay_verified"]) + self.write(session, "launch.txt", "Status: ok\nError: controlled-failure-despite-ok\n" + "Status: secret-token://unrecognized\n") + text, payload = self.diagnose(session) + self.assertEqual(payload["files"]["launch.txt"]["reported_statuses"], ["ok", "unrecognized"]) + self.assertIn("controlled-failure-despite-ok", text) + self.assertNotIn("secret-token", text) + self.assertFalse(session.result["process_started"], "Even Status: ok diagnostics cannot prove a process") + self.assertFalse(session.result["apk_installed"]) + self.assertFalse(session.result["gameplay_verified"]) + + def test_launch_stdout_is_fresh_regular_and_prefix_bounded_without_reading_private_eof(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + path = self.write(session, "launch.txt", "Status: ok\n") + os.utime(path, ns=(0, session.harness_started_ns - 1)) + _, payload = self.diagnose(session) + self.assertEqual(payload["files"]["launch.txt"]["reason"], "not_fresh_regular_file") + self.assertEqual(payload["files"]["launch.txt"]["reported_statuses"], []) + path.unlink() + target = Path(temporary) / "other-unowned-launch-stdout" + target.write_text("Error: controlled-private-redirect\n") + path.symlink_to(target) + text, payload = self.diagnose(session) + self.assertEqual(payload["files"]["launch.txt"]["status"], "diagnostic_notavailable") + self.assertNotIn("controlled-private-redirect", text) + path.unlink() + os.mkfifo(path) + _, payload = self.diagnose(session) + self.assertEqual(payload["files"]["launch.txt"]["reason"], "not_fresh_regular_file") + path.unlink() + path.write_text("Status: timeout\nError: controlled-prefix-launch-cause\n" + "x" * 10000 + + "\nError: controlled-EOF-must-not-be-read\n") + text, payload = self.diagnose(session) + self.assertEqual(payload["files"]["launch.txt"]["bytes_read"], 4096) + self.assertTrue(payload["files"]["launch.txt"]["truncated"]) + self.assertEqual(payload["files"]["launch.txt"]["reported_statuses"], ["timeout"]) + self.assertIn("controlled-prefix-launch-cause", text) + self.assertNotIn("controlled-EOF", text) + self.assertLessEqual(len(text.encode()), 6144) + self.assertFalse(session.result["gameplay_verified"]) + def test_environment_credentials_control_sequences_and_urls_do_not_escape(self): raw = ["\x1b[31mError: PATH=/controlled-private-environment\x1b[0m\n" "Environment: controlled-private-env\nAuthorization: Bearer controlled-auth\n" From 53efca9faaaa09b63d7c386261a494d2a13e35f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 06:52:54 +0000 Subject: [PATCH 319/331] ci(ios): preserve verified public dependency archives after build failures --- .github/workflows/renpy-ios-runtime-build.yml | 140 +++++++ tools/ios_public_vcpkg_cache.py | 390 ++++++++++++++++++ tools/test_ios_public_vcpkg_cache.py | 337 +++++++++++++++ 3 files changed, 867 insertions(+) create mode 100644 tools/ios_public_vcpkg_cache.py create mode 100644 tools/test_ios_public_vcpkg_cache.py diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index bd73ed770..4d00ec1f2 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -21,6 +21,8 @@ on: - 'tools/build_godot_ios_device_template.py' - 'tools/test_godot_ios_device_template.py' - 'tools/test_renpy_ios_force_load.py' + - 'tools/ios_public_vcpkg_cache.py' + - 'tools/test_ios_public_vcpkg_cache.py' - 'tools/godot_ios_device_template/**' - 'scripts/build_ios.sh' - 'bridge/renpy_runtime/mobile_launcher/**' @@ -212,6 +214,8 @@ jobs: AETHERKIRI_ENABLE_RENPY: ON AETHERKIRI_ENABLE_INTERNAL: OFF AETHERKIRI_VCPKG_CACHE_WRITE: 'false' + VCPKG_PUBLIC_CACHE_SDK: iphonesimulator + VCPKG_PUBLIC_CACHE_TRIPLET: arm64-ios-simulator GODOT_RELEASE: 4.7.2-stable GODOT_TEMPLATE_VERSION: 4.7.2.stable JOBS: '3' @@ -276,6 +280,12 @@ jobs: bash .devtools/vcpkg/bootstrap-vcpkg.sh -disableMetrics echo "VCPKG_ROOT=$GITHUB_WORKSPACE/.devtools/vcpkg" >> "$GITHUB_ENV" bash .github/scripts/configure-vcpkg-binary-cache.sh "$GITHUB_WORKSPACE/.devtools/vcpkg/vcpkg" + - name: Exercise the real files provider and hostile archive validation + run: | + set -euo pipefail + python3 tools/test_ios_public_vcpkg_cache.py \ + --vcpkg-root "$VCPKG_ROOT" --cmake "$(command -v cmake)" --ninja "$(command -v ninja)" \ + --output-dir "$RUNNER_TEMP/vcpkg-public-provider-regression" - name: Link the genuine complete Ren'Py framework before compiling the Godot engine run: | set -euo pipefail @@ -349,11 +359,72 @@ jobs: ${{ runner.temp }}/godot-arm64-simulator/ios.zip ${{ runner.temp }}/godot-arm64-simulator/evidence.json key: ${{ steps.godot-simulator-cache-key.outputs.key }} + - name: Identify public dependency archives with the actual Apple tools + id: public-vcpkg-family + run: | + set -euo pipefail + # This runs after the normal NuGet read configuration has reached + # GITHUB_ENV. Add only successful public package ZIP writes locally. + case "$VCPKG_BINARY_SOURCES" in + clear\;nuget,*\,read) ;; + *) echo 'The existing NuGet read provider is required.' >&2; exit 1 ;; + esac + family="$(python3 tools/ios_public_vcpkg_cache.py family \ + --repository . --vcpkg-root "$VCPKG_ROOT" \ + --sdk "$VCPKG_PUBLIC_CACHE_SDK" --triplet "$VCPKG_PUBLIC_CACHE_TRIPLET" \ + --internal "$AETHERKIRI_ENABLE_INTERNAL")" + [[ "$family" =~ ^ios-public-vcpkg-v1-[0-9a-f]{64}$ ]] + echo "family=$family" >> "$GITHUB_OUTPUT" + echo "VCPKG_BINARY_SOURCES=$VCPKG_BINARY_SOURCES;files,$RUNNER_TEMP/vcpkg-public-archives,readwrite" >> "$GITHUB_ENV" + - name: Restore a partial public dependency cache for this exact toolchain + uses: actions/cache/restore@v6 + with: + path: ${{ runner.temp }}/vcpkg-public-archives + key: ${{ steps.public-vcpkg-family.outputs.family }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: ${{ steps.public-vcpkg-family.outputs.family }}- + - name: Validate every restored package before vcpkg can read it + run: | + set -euo pipefail + python3 tools/ios_public_vcpkg_cache.py validate \ + --repository . --vcpkg-root "$VCPKG_ROOT" \ + --sdk "$VCPKG_PUBLIC_CACHE_SDK" --triplet "$VCPKG_PUBLIC_CACHE_TRIPLET" \ + --internal "$AETHERKIRI_ENABLE_INTERNAL" \ + --cache-dir "$RUNNER_TEMP/vcpkg-public-archives" \ + --report "$RUNNER_TEMP/vcpkg-public-before.json" - name: Build the installable arm64 Simulator application + id: public-vcpkg-app run: | set -euo pipefail bash scripts/build_ios.sh debug --simulator --simulator-arch=arm64 \ 2>&1 | tee "$RUNNER_TEMP/renpy-ios-simulator-build.log" + - name: Validate successful package archives even when the App build fails + id: public-vcpkg-after + if: >- + always() && !cancelled() && + steps.public-vcpkg-app.outcome != 'skipped' && + steps.public-vcpkg-family.outcome == 'success' + run: | + set -euo pipefail + python3 tools/ios_public_vcpkg_cache.py validate \ + --repository . --vcpkg-root "$VCPKG_ROOT" \ + --sdk "$VCPKG_PUBLIC_CACHE_SDK" --triplet "$VCPKG_PUBLIC_CACHE_TRIPLET" \ + --internal "$AETHERKIRI_ENABLE_INTERNAL" \ + --cache-dir "$RUNNER_TEMP/vcpkg-public-archives" \ + --previous-report "$RUNNER_TEMP/vcpkg-public-before.json" \ + --report "$RUNNER_TEMP/vcpkg-public-after.json" --github-output "$GITHUB_OUTPUT" + - name: Save newly built public packages before later gameplay checks + if: >- + always() && !cancelled() && + env.AETHERKIRI_ENABLE_INTERNAL == 'OFF' && + steps.public-vcpkg-after.outcome == 'success' && + steps.public-vcpkg-after.outputs.new-archive-count != '0' && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + uses: actions/cache/save@v6 + with: + path: ${{ runner.temp }}/vcpkg-public-archives + key: ${{ steps.public-vcpkg-family.outputs.family }}-${{ github.run_id }}-${{ github.run_attempt }} - name: Install and execute real gameplay with XCUITest run: | set -euo pipefail @@ -403,6 +474,8 @@ jobs: AETHERKIRI_ENABLE_INTERNAL: OFF AETHERKIRI_RENPY_IOS_DEVICE_GLES: ON AETHERKIRI_VCPKG_CACHE_WRITE: 'false' + VCPKG_PUBLIC_CACHE_SDK: iphoneos + VCPKG_PUBLIC_CACHE_TRIPLET: arm64-ios GODOT_RELEASE: 4.7.2-stable GODOT_TEMPLATE_VERSION: 4.7.2.stable JOBS: '3' @@ -497,7 +570,46 @@ jobs: bash .devtools/vcpkg/bootstrap-vcpkg.sh -disableMetrics echo "VCPKG_ROOT=$GITHUB_WORKSPACE/.devtools/vcpkg" >> "$GITHUB_ENV" bash .github/scripts/configure-vcpkg-binary-cache.sh "$GITHUB_WORKSPACE/.devtools/vcpkg/vcpkg" + - name: Exercise the real files provider and hostile archive validation + run: | + set -euo pipefail + python3 tools/test_ios_public_vcpkg_cache.py \ + --vcpkg-root "$VCPKG_ROOT" --cmake "$(command -v cmake)" --ninja "$(command -v ninja)" \ + --output-dir "$RUNNER_TEMP/vcpkg-public-provider-regression" + - name: Identify public dependency archives with the actual Apple tools + id: public-vcpkg-family + run: | + set -euo pipefail + # This runs after the normal NuGet read configuration has reached + # GITHUB_ENV. Add only successful public package ZIP writes locally. + case "$VCPKG_BINARY_SOURCES" in + clear\;nuget,*\,read) ;; + *) echo 'The existing NuGet read provider is required.' >&2; exit 1 ;; + esac + family="$(python3 tools/ios_public_vcpkg_cache.py family \ + --repository . --vcpkg-root "$VCPKG_ROOT" \ + --sdk "$VCPKG_PUBLIC_CACHE_SDK" --triplet "$VCPKG_PUBLIC_CACHE_TRIPLET" \ + --internal "$AETHERKIRI_ENABLE_INTERNAL")" + [[ "$family" =~ ^ios-public-vcpkg-v1-[0-9a-f]{64}$ ]] + echo "family=$family" >> "$GITHUB_OUTPUT" + echo "VCPKG_BINARY_SOURCES=$VCPKG_BINARY_SOURCES;files,$RUNNER_TEMP/vcpkg-public-archives,readwrite" >> "$GITHUB_ENV" + - name: Restore a partial public dependency cache for this exact toolchain + uses: actions/cache/restore@v6 + with: + path: ${{ runner.temp }}/vcpkg-public-archives + key: ${{ steps.public-vcpkg-family.outputs.family }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: ${{ steps.public-vcpkg-family.outputs.family }}- + - name: Validate every restored package before vcpkg can read it + run: | + set -euo pipefail + python3 tools/ios_public_vcpkg_cache.py validate \ + --repository . --vcpkg-root "$VCPKG_ROOT" \ + --sdk "$VCPKG_PUBLIC_CACHE_SDK" --triplet "$VCPKG_PUBLIC_CACHE_TRIPLET" \ + --internal "$AETHERKIRI_ENABLE_INTERNAL" \ + --cache-dir "$RUNNER_TEMP/vcpkg-public-archives" \ + --report "$RUNNER_TEMP/vcpkg-public-before.json" - name: Build the genuine iPhoneOS app and unsigned IPA + id: public-vcpkg-app run: | set -euo pipefail # The existing package path invokes xcodebuild with signing disabled @@ -506,6 +618,34 @@ jobs: 2>&1 | tee "$RUNNER_TEMP/renpy-ios-device-build.log" test -s out/godot/ios/debug/Aether-Debug-Unsigned.ipa echo 'Unsigned iPhoneOS package built. Device signing, installation and gameplay remain unverified.' + - name: Validate successful package archives even when the App build fails + id: public-vcpkg-after + if: >- + always() && !cancelled() && + steps.public-vcpkg-app.outcome != 'skipped' && + steps.public-vcpkg-family.outcome == 'success' + run: | + set -euo pipefail + python3 tools/ios_public_vcpkg_cache.py validate \ + --repository . --vcpkg-root "$VCPKG_ROOT" \ + --sdk "$VCPKG_PUBLIC_CACHE_SDK" --triplet "$VCPKG_PUBLIC_CACHE_TRIPLET" \ + --internal "$AETHERKIRI_ENABLE_INTERNAL" \ + --cache-dir "$RUNNER_TEMP/vcpkg-public-archives" \ + --previous-report "$RUNNER_TEMP/vcpkg-public-before.json" \ + --report "$RUNNER_TEMP/vcpkg-public-after.json" --github-output "$GITHUB_OUTPUT" + - name: Save newly built public packages before later gameplay checks + if: >- + always() && !cancelled() && + env.AETHERKIRI_ENABLE_INTERNAL == 'OFF' && + steps.public-vcpkg-after.outcome == 'success' && + steps.public-vcpkg-after.outputs.new-archive-count != '0' && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + uses: actions/cache/save@v6 + with: + path: ${{ runner.temp }}/vcpkg-public-archives + key: ${{ steps.public-vcpkg-family.outputs.family }}-${{ github.run_id }}-${{ github.run_attempt }} - name: Preserve genuine device build and linkage evidence if: always() uses: actions/upload-artifact@v6 diff --git a/tools/ios_public_vcpkg_cache.py b/tools/ios_public_vcpkg_cache.py new file mode 100644 index 000000000..eb3ef4abe --- /dev/null +++ b/tools/ios_public_vcpkg_cache.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +"""Validate public vcpkg files-provider ZIPs; never bypass manifest/ABI install. + +Only successful package archives belong in this cache. SDKs, installed trees, +sources, build trees, credentials and compiler logs are outside its boundary. +The Apple cache family fixes the actual tools/profile, while vcpkg recomputes +each port/dependency ABI after restore. Per-port overlay edits therefore miss +only their affected packages. Reports are separate from the cached directory. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import platform +import re +import shutil +import stat +import struct +import subprocess +import sys +import zipfile +import zlib + + +SCHEMA = 1 +HEX = re.compile(r"[0-9a-f]{64}\Z") +PORT = re.compile(r"[a-z0-9][a-z0-9-]*\Z") +TOP_LEVEL = {"include", "lib", "bin", "debug", "share", "tools", "etc"} +# Existing public Mono dependency, maintained as this repository's overlay; +# it is absent from the current and baseline builtin registries. +PUBLIC_ADDITIONAL_OVERLAYS = {"libgdiplus"} +MAX_METADATA = 1024 * 1024 +MAX_MEMBERS = 250_000 +MAX_ARCHIVE_BYTES = 4 * 1024**3 +MAX_EXPANDED_BYTES = 8 * 1024**3 +MAX_CACHE_BYTES = 32 * 1024**3 +MAX_ARCHIVES = 4096 +MAX_CENTRAL_DIRECTORY_BYTES = 32 * 1024**2 +MAX_CACHE_EXPANDED_BYTES = 32 * 1024**3 +MAX_CACHE_MEMBERS = 1_000_000 + + +class InvalidCache(ValueError): + """Fixed diagnostic codes avoid echoing untrusted archive free text.""" + + +def require(condition: bool, code: str) -> None: + if not condition: + raise InvalidCache(code) + + +def digest(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def canonical(value: object) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + + +def run(argv: list[str], cwd: Path | None = None) -> str: + return subprocess.check_output(argv, cwd=cwd, text=True).strip() + + +def public_ports(repository: Path, vcpkg_root: Path) -> set[str]: + config = json.loads((repository / "vcpkg-configuration.json").read_text()) + registry = config["default-registry"] + require(registry["kind"] == "builtin", "non-public-registry") + require(config.get("registries", []) == [], "additional-registry") + require(config["overlay-ports"] == ["./vcpkg/ports"], "unexpected-port-overlay") + names: set[str] = set() + for rev in (registry["baseline"], "HEAD"): + names.update(run(["git", "ls-tree", "--name-only", f"{rev}:ports"], vcpkg_root).splitlines()) + require(all(PORT.fullmatch(n) for n in names), "invalid-public-port-name") + for p in (repository / "vcpkg/ports").iterdir(): + if p.is_dir(): + require(p.name in names or p.name in PUBLIC_ADDITIONAL_OVERLAYS, "overlay-not-public-port") + manifest = json.loads((p / "vcpkg.json").read_text()) + require(manifest["name"] == p.name, "overlay-name-mismatch") + names.add(p.name) + return names + + +def apple_identity(repository: Path, vcpkg_root: Path, sdk: str, triplet: str, + internal: str) -> dict: + require(internal == "OFF", "internal-profile-enabled") + require(platform.system() == "Darwin", "real-apple-host-required") + require("OSXCROSS_IOS" not in os.environ, "cross-sdk-not-supported") + expected = {"iphonesimulator": "arm64-ios-simulator", "iphoneos": "arm64-ios"} + require(expected.get(sdk) == triplet, "sdk-triplet-mismatch") + arch = platform.machine() + require(arch in ("arm64", "x86_64"), "unsupported-apple-host") + host_triplet = "arm64-osx" if arch == "arm64" else "x64-osx" + public_ports(repository, vcpkg_root) + config = json.loads((repository / "vcpkg-configuration.json").read_text()) + compilers = {} + for name in ("clang", "clang++"): + path = Path(run(["xcrun", "--sdk", sdk, "--find", name])) + compilers[name] = {"path": str(path), "sha256": digest(path), + "version": run([str(path), "--version"])} + cmake = shutil.which("cmake") + require(cmake is not None, "cmake-missing") + sdks = {} + for name in ("macosx", sdk): + sdks[name] = { + "path": str(Path(run(["xcrun", "--sdk", name, "--show-sdk-path"])).resolve()), + "version": run(["xcrun", "--sdk", name, "--show-sdk-version"]), + "build": run(["xcrun", "--sdk", name, "--show-sdk-build-version"]), + } + return { + "schema": SCHEMA, "profile": "public-renpy-on-internal-off-ios-debug", + "host_triplet": host_triplet, "target_triplet": triplet, + "host_os": run(["sw_vers", "-productVersion"]), "host_arch": arch, + "developer": run(["xcode-select", "--print-path"]), + "xcode": run(["xcodebuild", "-version"]), "sdks": sdks, + "compilers": compilers, + "cmake": {"path": cmake, "sha256": digest(Path(cmake)), "version": run([cmake, "--version"])}, + "vcpkg": {"head": run(["git", "rev-parse", "HEAD"], vcpkg_root), + "sha256": digest(vcpkg_root / "vcpkg"), + "version": run([str(vcpkg_root / "vcpkg"), "version"])}, + "baseline": config["default-registry"]["baseline"], + "inputs": {name: digest(repository / name) for name in ( + "vcpkg.json", "vcpkg-configuration.json", "CMakePresets.json", + f"vcpkg/triplets/{triplet}.cmake", "vcpkg/meson/ios-cross.ini")}, + "host_triplet_sha256": digest(next(p for p in ( + vcpkg_root / "triplets" / f"{host_triplet}.cmake", + vcpkg_root / "triplets/community" / f"{host_triplet}.cmake") if p.is_file())), + "validator_sha256": digest(Path(__file__)), + } + + +def family(identity: dict) -> str: + return "ios-public-vcpkg-v1-" + hashlib.sha256(canonical(identity)).hexdigest() + + +def paragraphs(data: bytes) -> list[dict[str, str]]: + require(len(data) <= MAX_METADATA, "metadata-too-large") + try: + text = data.decode("utf-8") + except UnicodeDecodeError as e: + raise InvalidCache("metadata-not-utf8") from e + require("\x00" not in text, "metadata-nul") + result: list[dict[str, str]] = [] + for section in re.split(r"\n\s*\n", text.strip()): + fields: dict[str, str] = {} + key = "" + for line in section.splitlines(): + if line[:1].isspace(): + require(bool(key), "invalid-control-continuation") + fields[key] += "\n" + line + else: + require(":" in line, "invalid-control-field") + key, value = line.split(":", 1) + require(key not in fields and bool(key), "duplicate-control-field") + fields[key] = value.strip() + result.append(fields) + return result + + +def safe_member(name: str) -> str: + require(bool(name) and not any(c in name for c in ("\\", ":", "//")) + and not any(ord(c) < 32 or ord(c) == 127 for c in name), "unsafe-member") + require(not name.startswith("/") and all(p not in ("", ".", "..") for p in name.rstrip("/").split("/")), "unsafe-member") + return name.rstrip("/") + + +def preflight_central_directory(path: Path) -> None: + # Bound zipfile's central-directory allocation before it parses any entry. + size = path.stat().st_size + require(size <= MAX_ARCHIVE_BYTES, "archive-too-large") + with path.open("rb") as f: + f.seek(max(0, size - 65557)) + tail = f.read() + start = tail.rfind(b"PK\x05\x06") + require(start >= 0 and len(tail) - start >= 22, "invalid-zip-end") + _, disk, cd_disk, disk_count, count, cd_size, cd_offset, comment = struct.unpack( + "<4s4H2LH", tail[start:start + 22]) + require(start + 22 + comment == len(tail), "invalid-zip-end") + end_offset = size - len(tail) + start + require(disk == cd_disk == 0 and disk_count == count, "multipart-zip") + sentinel = count == 0xffff or cd_size == 0xffffffff or cd_offset == 0xffffffff + locator = b"" + if end_offset >= 20: + f.seek(end_offset - 20) + locator = f.read(20) + has_zip64 = locator.startswith(b"PK\x06\x07") + require(has_zip64 or not sentinel, "invalid-zip64-end") + # Like zipfile, a present locator overrides ordinary EOCD metadata, + # even when the ordinary fields do not contain ZIP64 sentinel values. + if has_zip64: + sig, disk, zip64_offset, disks = struct.unpack("<4sLQL", locator) + require(sig == b"PK\x06\x07" and disk == 0 and disks == 1, + "invalid-zip64-end") + require(zip64_offset + 56 <= end_offset - 20, "invalid-zip64-end") + f.seek(zip64_offset) + record = f.read(56) + require(len(record) == 56, "invalid-zip64-end") + sig, record_size, _, _, disk, cd_disk, disk_count, count, cd_size, cd_offset = struct.unpack( + "<4sQ2H2L4Q", record) + require(sig == b"PK\x06\x06" and 44 <= record_size <= MAX_METADATA + and zip64_offset + 12 + record_size <= end_offset - 20, + "invalid-zip64-end") + require(disk == cd_disk == 0 and disk_count == count, "multipart-zip") + end_offset = zip64_offset + require(0 < count <= MAX_MEMBERS, "archive-member-count") + require(cd_size <= MAX_CENTRAL_DIRECTORY_BYTES, "central-directory-too-large") + require(cd_offset + cd_size <= end_offset, "invalid-central-directory") + + +def validate_archive(path: Path, ports: set[str], triplets: set[str]) -> dict: + abi = path.stem + require(bool(HEX.fullmatch(abi)) and path.parent.name == abi[:2], "invalid-archive-identity") + preflight_central_directory(path) + with zipfile.ZipFile(path) as z: + infos = z.infolist() + require(0 < len(infos) <= MAX_MEMBERS, "archive-member-count") + require(sum(i.file_size for i in infos) <= MAX_EXPANDED_BYTES, "archive-expanded-size") + members: dict[str, zipfile.ZipInfo] = {} + links: dict[str, str] = {} + for i in infos: + n = safe_member(i.filename) + require(n not in members, "duplicate-member") + require(not i.flag_bits & 1, "encrypted-member") + mode = stat.S_IFMT(i.external_attr >> 16) + require(mode in (0, stat.S_IFREG, stat.S_IFDIR, stat.S_IFLNK), "special-member") + require(n in ("CONTROL", "BUILD_INFO") or n.split("/", 1)[0] in TOP_LEVEL, "unexpected-package-root") + members[n] = i + if mode == stat.S_IFLNK: + require(i.file_size <= MAX_METADATA, "symlink-too-large") + try: + target = z.read(i).decode("utf-8") + except UnicodeDecodeError as e: + raise InvalidCache("invalid-symlink") from e + require(bool(target) and not target.startswith("/") and not any(c in target for c in ("\\", ":")) + and not any(ord(c) < 32 or ord(c) == 127 for c in target), "unsafe-symlink") + links[n] = target + for n in members: + for parent in PurePosixPath(n).parents: + require(str(parent) not in links, "member-under-symlink") + def resolve_link(n: str, seen: set[str]) -> str: + require(n not in seen, "symlink-cycle") + if n not in links: + return n + parts = n.split("/")[:-1] + for piece in links[n].split("/"): + if piece in ("", "."): + continue + if piece == "..": + require(bool(parts), "escaping-symlink") + parts.pop() + else: + parts.append(piece) + target = "/".join(parts) + require(target in members, "missing-symlink-target") + return resolve_link(target, seen | {n}) + for n in links: + resolve_link(n, set()) + require("CONTROL" in members and "CONTROL" not in links, "missing-control") + require(members["CONTROL"].file_size <= MAX_METADATA, "metadata-too-large") + control = paragraphs(z.read("CONTROL")) + core = control[0] + name = core.get("Package", "") + triplet = core.get("Architecture", "") + require(name in ports and bool(PORT.fullmatch(name)), "non-public-package") + require(triplet in triplets, "unexpected-package-triplet") + require(core.get("Abi") == abi, "control-abi-mismatch") + require(bool(core.get("Version")) and core.get("Multi-Arch") == "same", "invalid-core-control") + for feature in control[1:]: + require(feature.get("Package") == name and feature.get("Architecture") == triplet and bool(feature.get("Feature")), "invalid-feature-control") + for paragraph in control: + for dependency in paragraph.get("Depends", "").split(","): + if dependency.strip(): + dep = re.split(r"[ :\[]", dependency.strip(), maxsplit=1)[0] + require(dep in ports, "non-public-dependency") + abi_path = f"share/{name}/vcpkg_abi_info.txt" + require(abi_path in members and abi_path not in links, "missing-abi-info") + require(members[abi_path].file_size <= MAX_METADATA, "metadata-too-large") + abi_info = z.read(abi_path) + require(hashlib.sha256(abi_info).hexdigest() == abi, "abi-info-digest-mismatch") + require(f"triplet {triplet}\n".encode() in abi_info.splitlines(keepends=True), "abi-info-triplet-mismatch") + # Read all bytes and check their CRC before vcpkg's unzip consumer sees them. + for i in infos: + with z.open(i) as f: + while f.read(1024 * 1024): + pass + return {"abi": abi, "sha256": digest(path), "package": name, "triplet": triplet} + + +def validate_cache(cache_dir: Path, ports: set[str], triplets: set[str]) -> dict: + require(not cache_dir.is_symlink(), "cache-root-symlink") + cache_dir.mkdir(parents=True, exist_ok=True) + archives = [] + paths = [] + total = 0 + expanded = 0 + member_count = 0 + # Reject total resource budgets before decompressing the first payload. + for bucket in sorted(cache_dir.iterdir()): + require(not bucket.is_symlink() and bucket.is_dir() and bool(re.fullmatch(r"[0-9a-f]{2}", bucket.name)), "invalid-cache-bucket") + for path in sorted(bucket.iterdir()): + require(len(paths) < MAX_ARCHIVES, "cache-archive-count") + require(not path.is_symlink() and path.is_file() and path.suffix == ".zip", "invalid-cache-file") + total += path.stat().st_size + require(total <= MAX_CACHE_BYTES, "cache-too-large") + preflight_central_directory(path) + try: + with zipfile.ZipFile(path) as z: + infos = z.infolist() + archive_expanded = sum(i.file_size for i in infos) + require(0 < len(infos) <= MAX_MEMBERS, "archive-member-count") + require(archive_expanded <= MAX_EXPANDED_BYTES, "archive-expanded-size") + expanded += archive_expanded + member_count += len(infos) + except (zipfile.BadZipFile, zlib.error, RuntimeError, NotImplementedError) as e: + raise InvalidCache("invalid-zip-payload") from e + require(expanded <= MAX_CACHE_EXPANDED_BYTES, "cache-expanded-size") + require(member_count <= MAX_CACHE_MEMBERS, "cache-member-count") + paths.append(path) + for path in paths: + try: + archives.append(validate_archive(path, ports, triplets)) + except (zipfile.BadZipFile, zlib.error, RuntimeError, NotImplementedError) as e: + raise InvalidCache("invalid-zip-payload") from e + return {"schema": SCHEMA, "status": "public_binary_archives_verified", + "archive_count": len(archives), "archives": archives, + "content_sha256": hashlib.sha256(canonical(archives)).hexdigest(), + "source_build": "not_run", "app": "not_run", "gameplay": "not_run"} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + for command in ("family", "validate"): + p = sub.add_parser(command) + p.add_argument("--repository", type=Path, required=True) + p.add_argument("--vcpkg-root", type=Path, required=True) + p.add_argument("--sdk", choices=("iphoneos", "iphonesimulator"), required=True) + p.add_argument("--triplet", choices=("arm64-ios", "arm64-ios-simulator"), required=True) + p.add_argument("--internal", choices=("OFF",), required=True) + if command == "validate": + p.add_argument("--cache-dir", type=Path, required=True) + p.add_argument("--report", type=Path, required=True) + p.add_argument("--previous-report", type=Path) + p.add_argument("--github-output", type=Path) + args = parser.parse_args() + try: + identity = apple_identity(args.repository, args.vcpkg_root, args.sdk, args.triplet, args.internal) + key = family(identity) + if args.command == "family": + print(key) + return 0 + cache = args.cache_dir.resolve() + require(not args.report.resolve().is_relative_to(cache), "report-inside-cache") + report = validate_cache(args.cache_dir, public_ports(args.repository, args.vcpkg_root), + {args.triplet, identity["host_triplet"]}) + report["family"] = key + previous = [] + if args.previous_report: + old = json.loads(args.previous_report.read_text()) + require(old["family"] == key and old["schema"] == SCHEMA, "previous-report-family-mismatch") + previous = old["archives"] + old_pairs = {(a["abi"], a["sha256"]) for a in previous} + require(old_pairs <= {(a["abi"], a["sha256"]) for a in report["archives"]}, "existing-archive-changed") + report["new_archive_count"] = (sum((a["abi"], a["sha256"]) not in old_pairs + for a in report["archives"]) + if args.previous_report else 0) + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + summary = {k: report[k] for k in ("status", "archive_count", "new_archive_count", "content_sha256", "source_build", "app", "gameplay")} + print(json.dumps(summary, sort_keys=True)) + if args.github_output: + with args.github_output.open("a") as f: + f.write(f"archive-count={report['archive_count']}\nnew-archive-count={report['new_archive_count']}\n") + return 0 + except (InvalidCache, zipfile.BadZipFile, OSError, KeyError, ValueError, + RuntimeError, NotImplementedError, subprocess.CalledProcessError) as e: + code = str(e) if isinstance(e, InvalidCache) else type(e).__name__ + print(f"Public dependency cache rejected: {code}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_ios_public_vcpkg_cache.py b/tools/test_ios_public_vcpkg_cache.py new file mode 100644 index 000000000..071f693ce --- /dev/null +++ b/tools/test_ios_public_vcpkg_cache.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +"""Genuine files-provider builds/restores plus hostile archive controls. + +Creates one public scratch C port, never a product port. Uses normal manifest +install for every operation; no installed-tree skip or mocked vcpkg execution. +Linux checks do not claim Apple SDK, App or gameplay execution. +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import platform +import shutil +import stat +import struct +import subprocess +import warnings +import zipfile + +import ios_public_vcpkg_cache as cache + + +def check(condition: bool, description: str) -> None: + if not condition: + raise AssertionError(description) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--vcpkg-root", type=Path, required=True) + parser.add_argument("--cmake", type=Path, required=True) + parser.add_argument("--ninja", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + args = parser.parse_args() + root = args.output_dir.resolve() + check(not root.exists(), "Use a fresh output directory.") + root.mkdir(parents=True) + vcpkg_root = args.vcpkg_root.resolve() + production_ports = cache.public_ports(Path(__file__).resolve().parents[1], vcpkg_root) + check({"vcpkg-cmake", "glib", "openssl", "libgdiplus"} <= production_ports, + "The real production public registry/overlay allowlist rejected required public ports.") + cc = shutil.which("cc") + check(cc is not None, "A genuine native C compiler is required.") + arch = platform.machine() + check(arch in ("x86_64", "arm64", "aarch64"), "Unsupported native regression architecture.") + if platform.system() == "Darwin": + triplet = "x64-osx" if arch == "x86_64" else "arm64-osx" + else: + check(platform.system() == "Linux", "Use a native Linux or Mac host.") + triplet = "x64-linux" if arch == "x86_64" else "arm64-linux" + port = root / "ports/aether-cache-probe" + source = port / "source" + source.mkdir(parents=True) + (port / "vcpkg.json").write_text(json.dumps({ + "name": "aether-cache-probe", "version": "1.0.0", + "dependencies": [{"name": "vcpkg-cmake", "host": True}], + "features": {"extra": {"description": "Public scratch feature control paragraph"}}, + }) + "\n") + (port / "portfile.cmake").write_text('''vcpkg_cmake_configure(SOURCE_PATH "${CMAKE_CURRENT_LIST_DIR}/source") +vcpkg_cmake_install() +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include") +file(CREATE_LINK "libcache_probe.a" "${CURRENT_PACKAGES_DIR}/lib/libcache_probe_link.a" SYMBOLIC) +file(CREATE_LINK "libcache_probe.a" "${CURRENT_PACKAGES_DIR}/debug/lib/libcache_probe_link.a" SYMBOLIC) +file(INSTALL "${CMAKE_CURRENT_LIST_DIR}/copyright" DESTINATION "${CURRENT_PACKAGES_DIR}/share/${PORT}") +''') + (port / "copyright").write_text("Public scratch test source, generated by this regression.\n") + (source / "CMakeLists.txt").write_text('''cmake_minimum_required(VERSION 3.20) +project(cache_probe C) +add_library(cache_probe STATIC cache_probe.c) +install(TARGETS cache_probe ARCHIVE DESTINATION lib) +install(FILES cache_probe.h DESTINATION include) +''') + (source / "cache_probe.h").write_text("int cache_probe(void);\n") + manifest = {"name": "public-cache-regression", "version": "1.0.0", + "dependencies": [{"name": "aether-cache-probe", "features": ["extra"]}]} + (root / "vcpkg.json").write_text(json.dumps(manifest) + "\n") + archives = root / "archives" + env = os.environ.copy() + env["PATH"] = os.pathsep.join((str(args.cmake.resolve().parent), + str(args.ninja.resolve().parent), env.get("PATH", ""))) + env["VCPKG_FORCE_SYSTEM_BINARIES"] = "1" + env["VCPKG_BINARY_SOURCES"] = f"clear;files,{archives},readwrite" + command = [str(vcpkg_root / "vcpkg"), "install", f"--triplet={triplet}", + f"--host-triplet={triplet}", f"--overlay-ports={root / 'ports'}", + f"--x-install-root={root / 'installed'}", f"--x-buildtrees-root={root / 'buildtrees'}", + f"--x-packages-root={root / 'packages'}", f"--downloads-root={root / 'downloads'}", + "--disable-metrics"] + ports = {"aether-cache-probe", "vcpkg-cmake"} + stages = [] + def install(stage: str, expect_success: bool = True) -> str: + for name in ("installed", "packages", "buildtrees"): + shutil.rmtree(root / name, ignore_errors=True) + with (root / f"{stage}.log").open("w") as f: + rc = subprocess.run(command, cwd=root, env=env, stdout=f, stderr=subprocess.STDOUT).returncode + check((rc == 0) == expect_success, f"Unexpected genuine vcpkg result in {stage}; inspect its scratch log.") + report = cache.validate_cache(archives, ports, {triplet}) + (root / f"{stage}-archives.json").write_text(json.dumps(report, indent=2) + "\n") + stages.append({"stage": stage, "exit_code": rc, "archive_count": report["archive_count"], + "content_sha256": report["content_sha256"]}) + return (root / f"{stage}.log").read_text() + def write_source(value: int) -> None: + (source / "cache_probe.c").write_text(f'#include "cache_probe.h"\nint cache_probe(void) {{ return {value}; }}\n') + def execute(value: int) -> str: + fixture = root / "check.c" + fixture.write_text(f'#include "cache_probe.h"\nint main(void) {{ return cache_probe() == {value} ? 0 : 1; }}\n') + library = root / f"installed/{triplet}/lib/libcache_probe.a" + subprocess.run([cc, str(fixture), "-I", str(root / f"installed/{triplet}/include"), + str(library), "-o", str(root / "check")], check=True) + subprocess.run([str(root / "check")], check=True) + return cache.digest(library) + write_source(7) + first = install("cold-build") + check("Building aether-cache-probe" in first and "Restored 0 package(s)" in first, "Cold stage did not actually compile.") + before = cache.validate_cache(archives, ports, {triplet}) + check(before["archive_count"] == 2, "Expected the genuine C port and host helper ZIPs.") + probe_record = next(a for a in before["archives"] if a["package"] == "aether-cache-probe") + probe_zip = archives / probe_record["abi"][:2] / (probe_record["abi"] + ".zip") + with zipfile.ZipFile(probe_zip) as z: + check("Feature: extra" in z.read("CONTROL").decode(), "Missing genuine feature paragraph.") + check(any(stat.S_IFMT(i.external_attr >> 16) == stat.S_IFLNK for i in z.infolist()), "The provider did not retain the actual relative package symlink.") + first_library = execute(7) + second = install("normal-manifest-restore") + check("Restored 2 package(s)" in second and "Building aether-cache-probe" not in second, + "Normal manifest install did not restore both real files-provider archives.") + check(execute(7) == first_library, "Restored native library bytes changed.") + write_source(8) + third = install("changed-port-abi") + check("Restored 1 package(s)" in third and "Building aether-cache-probe" in third, + "Changed port content did not genuinely invalidate its ABI.") + changed = cache.validate_cache(archives, ports, {triplet}) + check(changed["archive_count"] == 3, "ABI change did not preserve the older partial archives.") + check(execute(8) != first_library, "Changed source did not produce a different native library.") + fail = root / "ports/aether-cache-failure" + fail.mkdir() + (fail / "vcpkg.json").write_text(json.dumps({"name": "aether-cache-failure", "version": "1.0.0", + "dependencies": [{"name": "aether-cache-probe", "features": ["extra"]}]}) + "\n") + (fail / "portfile.cmake").write_text('message(FATAL_ERROR "Expected scratch failure after successful dependency build")\n') + manifest["dependencies"] = ["aether-cache-failure"] + (root / "vcpkg.json").write_text(json.dumps(manifest) + "\n") + write_source(9) + failed = install("later-package-failure", expect_success=False) + after_failure = cache.validate_cache(archives, ports, {triplet}) + check("Building aether-cache-probe" in failed and after_failure["archive_count"] == 4, + "Successful package archive was not flushed before normal vcpkg failure.") + execute(9) + + # Derive rejection controls from a real successfully produced archive. + controls = root / "rejection-controls" + controls.mkdir() + with zipfile.ZipFile(probe_zip) as z: + original = [(i, z.read(i)) for i in z.infolist()] + # Standard-library ZIP64 container compatibility, using genuine provider + # payload bytes. This is a format control, not a claimed large vcpkg build. + zip64_dir = root / "zip64-format-control" / probe_record["abi"][:2] + zip64_dir.mkdir(parents=True) + zip64_path = zip64_dir / probe_zip.name + with zipfile.ZipFile(zip64_path, "w", allowZip64=True) as z: + for i, data in original: + z.writestr(i, data) + for index in range(65536 - len(original)): + z.writestr(f"share/aether-cache-probe/zip64-format-{index}", b"") + check(b"PK\x06\x06" in zip64_path.read_bytes()[-200:], "The format control did not actually produce a ZIP64 end record.") + check(cache.validate_cache(zip64_dir.parent, ports, {triplet})["archive_count"] == 1, + "Standard ZIP64 container rejected.") + rejected = [] + def reject(label: str, edit, expected: str, allowed_triplets=None) -> None: + d = controls / label / probe_record["abi"][:2] + d.mkdir(parents=True) + path = d / probe_zip.name + entries = edit([(i, b) for i, b in original]) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + with zipfile.ZipFile(path, "w") as z: + for info, data in entries: + z.writestr(info, data) + try: + cache.validate_cache(d.parent, ports, allowed_triplets or {triplet}) + except cache.InvalidCache as e: + check(str(e) == expected, f"Wrong rejection reason in {label}.") + else: + raise AssertionError(f"Unsafe control accepted: {label}.") + rejected.append(label) + def control_edit(old: bytes, new: bytes): + return lambda es: [(i, b.replace(old, new) if i.filename == "CONTROL" else b) for i, b in es] + reject("wrong-control-abi", control_edit(probe_record["abi"].encode(), b"0" * 64), "control-abi-mismatch") + reject("altered-abi-info", lambda es: [(i, b + b"changed\n" if i.filename.endswith("vcpkg_abi_info.txt") else b) for i, b in es], "abi-info-digest-mismatch") + reject("private-package", control_edit(b"Package: aether-cache-probe", b"Package: private-product"), "non-public-package") + reject("private-dependency", control_edit(b"Depends: vcpkg-cmake", b"Depends: private-product"), "non-public-dependency") + reject("foreign-triplet", lambda es: es, "unexpected-package-triplet", {"arm64-ios"}) + reject("path-traversal", lambda es: es + [(zipfile.ZipInfo("../outside"), b"x")], "unsafe-member") + reject("absolute-path", lambda es: es + [(zipfile.ZipInfo("/outside"), b"x")], "unsafe-member") + reject("duplicate-control", lambda es: es + [next(e for e in es if e[0].filename == "CONTROL")], "duplicate-member") + def link_edit(target: bytes): + def edit(es): + i = zipfile.ZipInfo("lib/unsafe-link") + i.create_system = 3 + i.external_attr = (stat.S_IFLNK | 0o777) << 16 + return es + [(i, target)] + return edit + reject("absolute-symlink", link_edit(b"/outside"), "unsafe-symlink") + reject("escaping-symlink", link_edit(b"../../outside"), "escaping-symlink") + reject("symlink-cycle", link_edit(b"unsafe-link"), "symlink-cycle") + reject("source-cache-root", lambda es: es + [(zipfile.ZipInfo("buildtrees/compiler.log"), b"x")], "unexpected-package-root") + # Actual provider bytes with an oversized declared central directory must + # be rejected before zipfile allocates/parses that directory. + cd_dir = controls / "central-directory-budget" / probe_record["abi"][:2] + cd_dir.mkdir(parents=True) + cd_path = cd_dir / probe_zip.name + payload = bytearray(probe_zip.read_bytes()) + end = payload.rfind(b"PK\x05\x06") + struct.pack_into(" Date: Fri, 9 Oct 2026 07:34:00 +0000 Subject: [PATCH 320/331] test(android): capture current app state after a failed launch --- tools/run_renpy_android_device_acceptance.py | 170 ++++++++++++++++++- tools/run_renpy_android_emulator_session.py | 96 ++++++++++- tools/test_renpy_android_emulator_session.py | 93 +++++++++- tools/test_renpy_device_evidence_metrics.py | 111 ++++++++++++ 4 files changed, 464 insertions(+), 6 deletions(-) diff --git a/tools/run_renpy_android_device_acceptance.py b/tools/run_renpy_android_device_acceptance.py index a1bd45761..2263bb119 100644 --- a/tools/run_renpy_android_device_acceptance.py +++ b/tools/run_renpy_android_device_acceptance.py @@ -12,7 +12,9 @@ import hashlib import json import math +import os import re +import selectors import shlex import shutil import struct @@ -33,6 +35,106 @@ class Failed(RuntimeError): pass +def bounded_failure_command(command: list[str], deadline: float, *, byte_limit: int = 65536) -> tuple[dict, bytes]: + """Capture only bounded stdout of an owned read-only diagnostic child.""" + started = time.monotonic() + expires = min(deadline, started + 2) + if expires <= started: + return {"status": "not_available", "reason": "diagnostic_deadline"}, b"" + report, raw = {"status": "not_available"}, bytearray() + try: + process = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + except OSError: + return {**report, "reason": "command_unavailable"}, b"" + try: + os.set_blocking(process.stdout.fileno(), False) + with selectors.DefaultSelector() as reader: + reader.register(process.stdout, selectors.EVENT_READ) + read_deadline = expires - min(0.2, (expires - started) / 2) + eof = False + while time.monotonic() < read_deadline: + if not eof: + events = reader.select(timeout=min(0.05, max(0, read_deadline - time.monotonic()))) + if events: + chunk = os.read(process.stdout.fileno(), min(4096, byte_limit + 1 - len(raw))) + if not chunk: + eof = True + reader.unregister(process.stdout) + else: + raw.extend(chunk) + if len(raw) > byte_limit: + report["reason"] = "stdout_limit" + break + elif process.poll() is None: + time.sleep(min(0.01, max(0, read_deadline - time.monotonic()))) + if eof and process.poll() is not None: + break + if not eof and "reason" not in report: + report["reason"] = "command_timeout" + if process.poll() is None and not report.get("cleanup_permission_denied"): + try: + process.kill() + except PermissionError: + report["cleanup_permission_denied"] = True + except ProcessLookupError: + pass + try: + code = process.wait(timeout=max(0, expires - time.monotonic())) + report["returncode"] = code + except subprocess.TimeoutExpired: + report["collector_not_reaped"] = True + if not report.get("reason") and report.get("returncode") == 0 and time.monotonic() < deadline: + report["status"] = "available" + elif not report.get("reason"): + report["reason"] = "command_failed_or_deadline" + except OSError: + report["reason"] = "command_io_error" + if process.poll() is None and not report.get("cleanup_permission_denied"): + try: + process.kill() + except PermissionError: + report["cleanup_permission_denied"] = True + except ProcessLookupError: + pass + try: + process.wait(timeout=max(0, expires - time.monotonic())) + except subprocess.TimeoutExpired: + report["collector_not_reaped"] = True + finally: + process.stdout.close() + report["elapsed_seconds"] = round(time.monotonic() - started, 3) + report["bytes_captured"] = min(len(raw), byte_limit) + # Incomplete/failed output is never used as PID or Activity state proof. + return report, bytes(raw) if report["status"] == "available" else b"" + + +def failure_activity_states(raw: bytes, component: str) -> list[dict]: + """Select state/drawn booleans only within the exact current Activity record.""" + if len(component) > 256 or not re.fullmatch(r"[A-Za-z0-9_.$]+/[A-Za-z0-9_.$]+", component): + return [] + states, remaining, current = [], 0, None + wanted = re.compile(r"(? 4096: + continue + if "ActivityRecord{" in line: + current, remaining = None, 0 + if wanted.search(line) and len(states) < 4: + current, remaining = {}, 24 + states.append(current) + if remaining <= 0: + continue + remaining -= 1 + match = re.search(r"\bstate=(INITIALIZING|STARTED|RESUMED|PAUSING|PAUSED|STOPPING|STOPPED|DESTROYING|DESTROYED|RESTARTING_PROCESS)\b", line) + if match: + current["state"] = match[1] + for name in ("mVisibleRequested", "mVisible", "allDrawn", "firstWindowDrawn"): + match = re.search(r"\b" + name + r"=(true|false)\b", line) + if match: + current[name] = match[1] == "true" + return [row for row in states if row] + + def is_successful_os_return(row: object) -> bool: """Require a delivered Return in the observer's explicit Godot namespace.""" return (isinstance(row, dict) and row.get("kind") == "os_key" @@ -651,6 +753,66 @@ def stage(self) -> None: def collect(self) -> None: if not self.installed: return + if self.summary.get("status") != "failed": + self.collect_complete_evidence() + return + started = time.monotonic() + deadline = started + 10 + snapshot = {"source": "actual-adb-failure-diagnostic", "diagnostic_only": True, + "run_id": self.run_id, "package": self.args.package, "serial": self.args.serial, + "apk_sha256": self.summary.get("apk_sha256"), "pid_sample": {"status": "not_available"}} + def capture(*parts, byte_limit=65536): + return bounded_failure_command(self.device_prefix + list(parts), deadline, byte_limit=byte_limit) + diagnostic_pid = "" + if self.summary.get("status") == "failed": + report, raw = capture("shell", shlex.join(("pidof", self.args.package)), byte_limit=2048) + pids = raw.decode(errors="replace").split() + if (report["status"] == "available" and pids + and all(re.fullmatch(r"[1-9][0-9]{0,9}", pid) and int(pid) <= 2147483647 for pid in pids) + and len(set(pids)) == len(pids)): + report.update(pids=pids[:8], pid_count=len(pids)) + diagnostic_pid = pids[0] + elif report["status"] == "available": + report.update(status="not_available", reason="no_valid_current_pid") + snapshot["pid_sample"] = report + if diagnostic_pid: + report, raw = capture("logcat", "-d", "--pid", diagnostic_pid, "-v", "threadtime", "-t", "100") + snapshot["process_logcat"] = report + if raw: + (self.output / "android-process-logcat.txt").write_bytes(b"\n".join(raw.splitlines()[:100]) + b"\n") + component = self.summary.get("activity", "") + if (len(component) <= 256 and re.fullmatch(r"[A-Za-z0-9_.$]+/[A-Za-z0-9_.$]+", component) + and component.startswith(self.args.package + "/")): + report, raw = capture("shell", shlex.join(("dumpsys", "activity", "activities"))) + report["exact_component"] = component + report["states"] = failure_activity_states(raw, component) if raw else [] + snapshot["activity_sample"] = report + report, data = capture("logcat", "-d", "-v", "threadtime", "-t", "100") + snapshot["system_logcat"] = report + if data: + (self.output / "android-system-logcat.txt").write_bytes(b"\n".join(data.splitlines()[:100]) + b"\n") + # These existing evidence reads share the same absolute budget. They + # do not retry a denied/failed read and cannot replace the primary error. + for relative, local in [ + ("files/renpy-device-demo/aetherkiri-engine.log", "aetherkiri-engine.log"), + ("files/renpy-device-evidence/observer.jsonl", "observer.jsonl"), + ("files/renpy-device-demo/game/aether-device-checkpoints.jsonl", "renpy-script-checkpoints.jsonl"), + ("files/renpy-device-demo/log.txt", "renpy-log.txt"), + ("files/renpy-device-demo/traceback.txt", "renpy-traceback.txt")]: + _, data = capture("exec-out", "run-as", self.args.package, "cat", relative) + if data: + (self.output / local).write_bytes(b"\n".join(data.splitlines()[:200]) + b"\n") + if self.pid and not diagnostic_pid: + _, data = capture("logcat", "-d", "--pid", self.pid.split()[0], "-v", "threadtime", "-t", "100") + if data: + (self.output / "android-process-logcat.txt").write_bytes(b"\n".join(data.splitlines()[:100]) + b"\n") + if self.summary.get("status") == "failed": + snapshot.update(elapsed_seconds=round(time.monotonic() - started, 3), + deadline_reached=time.monotonic() >= deadline) + self.summary["failure_diagnostics"] = snapshot + + def collect_complete_evidence(self) -> None: + """Preserve the original successful-run evidence without new truncation.""" for relative, local in [ ("files/renpy-device-demo/aetherkiri-engine.log", "aetherkiri-engine.log"), ("files/renpy-device-evidence/observer.jsonl", "observer.jsonl"), @@ -670,8 +832,6 @@ def collect(self) -> None: except Failed: pass try: - # Startup/linker crashes may occur before pidof can observe a PID. - # Preserve actual logs from this disposable cloud image as well. (self.output / "android-system-logcat.txt").write_bytes( self.command("logcat", "-d", "-v", "threadtime", "-t", "2000", check=False)) except Failed: @@ -705,7 +865,11 @@ def main() -> int: acceptance.summary.update(status="failed", blocker=str(exc)) code = 1 finally: - acceptance.collect() + try: + acceptance.collect() + except (Failed, OSError, ValueError): + # Diagnostics never mask the original execution classification. + acceptance.summary["failure_diagnostic_error"] = "not_available" acceptance.write_result() print(json.dumps(acceptance.summary, indent=2)) return code diff --git a/tools/run_renpy_android_emulator_session.py b/tools/run_renpy_android_emulator_session.py index d9efeb5ad..ba736ed12 100644 --- a/tools/run_renpy_android_emulator_session.py +++ b/tools/run_renpy_android_emulator_session.py @@ -415,6 +415,7 @@ def __init__(self, args: argparse.Namespace): self.harness_process = None self.harness_log = None self.harness_started_ns = None + self.harness_apk_sha256 = None self.avd_directory = None self.avd_started_ns = None self.boot_started = None @@ -576,6 +577,9 @@ def startup_failure_diagnostics(self) -> None: payload["files"][name] = metadata records[name] = lines own_pids = set() + snapshot = self.current_failure_snapshot() + payload["failure_snapshot"] = snapshot + own_pids.update(snapshot.get("diagnostic_pids", [])) prefix = [str(self.adb), "-s", self.result["serial"]] for line in records.get("commands.jsonl", []): if time.monotonic() >= deadline: @@ -621,7 +625,22 @@ def startup_failure_diagnostics(self) -> None: owned_launch = self.result.get("actual_status", {}).get("app_launch", {}) if owned_launch.get("status") == "passed": own_pids.update(owned_launch["pids"]) - payload["observed_process_pids"] = sorted(own_pids)[:8] + # A crash can precede pidof. Only an AndroidRuntime Process header + # with both the exact package and matching header/body PID binds + # subsequent causes. This is diagnostic attribution, not launch proof. + for line in records.get("android-system-logcat.txt", []): + if time.monotonic() >= deadline: + break + row = LOGCAT_ROW.match(line) + if not row: + continue + pid, _, tag, message = row.groups() + match = re.fullmatch(r"Process:\s*" + re.escape(self.args.package) + r",\s*PID:\s*([1-9][0-9]{0,9})\s*", message) + if (tag.strip() == "AndroidRuntime" and match and pid == match[1] + and int(pid) <= 2147483647 and len(own_pids) < 8): + own_pids.add(pid) + payload["diagnostic_bound_process_pids"] = sorted(own_pids)[:8] + payload["process_pid_semantics"] = "diagnostic-attribution-only" install_lines = records.get("install.txt", []) payload["installation_success_marker"] = any(line.strip() == "Success" for line in install_lines) for name in STARTUP_FILES[2:]: @@ -668,7 +687,12 @@ def startup_failure_diagnostics(self) -> None: candidates = [row for row in payload["files"].values() if row.get("output")] candidates += [row["stderr"] for row in payload["commands"] if row["stderr"].get("output")] if not candidates: - payload["commands"] = payload["commands"][:2] + # A schema regression must not create an infinite trim loop. + payload = {"checkpoint": "actual-apk-startup-failure-diagnostics", + "status": "diagnostic_notavailable", "gameplay_verified": False, + "files": {}, "commands": [], "reason": "console_size_limit"} + serialized = json.dumps(payload, separators=(",", ":")) + break else: largest = max(candidates, key=lambda row: len(row["output"])) largest["output"] = largest["output"][:len(largest["output"]) // 2] @@ -680,6 +704,73 @@ def startup_failure_diagnostics(self) -> None: "gameplay_verified": False} print(serialized, flush=True) + def current_failure_snapshot(self) -> dict: + """Select matched fresh-result diagnostic fields without upgrading gameplay.""" + unavailable = {"status": "diagnostic_notavailable", "diagnostic_only": True} + summary = self.result.get("gameplay_result", {}) + report = summary.get("failure_diagnostics", {}) if isinstance(summary, dict) else {} + installed = self.result.get("actual_status", {}).get("apk_install", {}) + if (not isinstance(report, dict) or report.get("source") != "actual-adb-failure-diagnostic" + or summary.get("status") != "failed" or summary.get("apk_installed") is not True + or self.result.get("apk_installed") is not True or installed.get("status") != "passed" + or installed.get("source") != "actual-adb-command" or type(installed.get("returncode")) is not int + or installed["returncode"] != 0 or installed.get("stdout_marker") != "Success" + or installed.get("package") != self.args.package or installed.get("serial") != self.result["serial"] + or report.get("diagnostic_only") is not True or summary.get("source") != "actual-apk-adb-cloud-device" + or not isinstance(summary.get("run_id"), str) or not re.fullmatch(r"[a-f0-9]{32}", summary["run_id"]) + or report.get("run_id") != summary["run_id"]): + return unavailable + for key, expected in (("package", self.args.package), ("serial", self.result["serial"]), + ("apk_sha256", self.harness_apk_sha256)): + if expected is None or summary.get(key) != expected or report.get(key) != expected: + return unavailable + if not re.fullmatch(r"[0-9a-f]{64}", self.harness_apk_sha256): + return unavailable + selected = {"status": "diagnostic_available", "source": report["source"], "diagnostic_only": True} + pid = report.get("pid_sample", {}) + pids = pid.get("pids", []) if isinstance(pid, dict) else [] + if isinstance(pid, dict): + if pid.get("status") in ("available", "not_available"): + selected["pid_status"] = pid["status"] + if type(pid.get("returncode")) is int and -255 <= pid["returncode"] <= 255: + selected["pid_returncode"] = pid["returncode"] + for flag in ("cleanup_permission_denied", "collector_not_reaped"): + if type(pid.get(flag)) is bool: + selected[flag] = pid[flag] + if (isinstance(pid, dict) and pid.get("status") == "available" and type(pid.get("returncode")) is int + and pid["returncode"] == 0 and isinstance(pids, list) and 0 < len(pids) <= 8 + and type(pid.get("pid_count")) is int and len(pids) <= pid["pid_count"] <= 4096 + and all(isinstance(value, str) and re.fullmatch(r"[1-9][0-9]{0,9}", value) + and int(value) <= 2147483647 for value in pids) and len(set(pids)) == len(pids)): + selected["diagnostic_pids"] = pids + activity = report.get("activity_sample", {}) + if isinstance(activity, dict) and activity.get("status") in ("available", "not_available"): + selected["activity_status"] = activity["status"] + if (isinstance(activity, dict) and activity.get("status") == "available" + and type(activity.get("returncode")) is int and activity["returncode"] == 0 + and activity.get("exact_component") == summary.get("activity") + and isinstance(summary.get("activity"), str) and summary["activity"].startswith(self.args.package + "/") + and len(summary["activity"]) <= 256 + and re.fullmatch(r"[A-Za-z0-9_.$]+/[A-Za-z0-9_.$]+", summary["activity"])): + states = [] + for row in activity.get("states", [])[:4] if isinstance(activity.get("states"), list) else []: + if not isinstance(row, dict): + continue + state = {} + if row.get("state") in ("INITIALIZING", "STARTED", "RESUMED", "PAUSING", "PAUSED", "STOPPING", "STOPPED", "DESTROYING", "DESTROYED", "RESTARTING_PROCESS"): + state["state"] = row["state"] + for name in ("mVisibleRequested", "mVisible", "allDrawn", "firstWindowDrawn"): + if type(row.get(name)) is bool: + state[name] = row[name] + if state: + states.append(state) + selected["activity_states"] = states + while len(json.dumps(selected).encode()) > 768 and selected.get("activity_states"): + selected["activity_states"].pop() + if len(json.dumps(selected).encode()) > 768: + return unavailable + return selected + def retain_partial_milestones(self, summary: dict, apk_hash: str) -> None: if summary.get("source") != "actual-apk-adb-cloud-device" or summary.get("package") != self.args.package \ or summary.get("serial") != self.result["serial"] or summary.get("apk_sha256") != apk_hash: @@ -947,6 +1038,7 @@ def gameplay(self) -> None: if summary.get("status") == "not_run": self.result["gameplay_executed"] = False apk_hash = hashlib.sha256(self.args.apk.read_bytes()).hexdigest() + self.harness_apk_sha256 = apk_hash self.retain_partial_milestones(summary, apk_hash) if not gameplay_result_matches(summary, code, self.args.package, self.result["serial"], apk_hash): self.result["gameplay_verification"] = "not_run" if summary.get("status") == "not_run" else "failed" diff --git a/tools/test_renpy_android_emulator_session.py b/tools/test_renpy_android_emulator_session.py index 49efdc6f2..933ef3c00 100644 --- a/tools/test_renpy_android_emulator_session.py +++ b/tools/test_renpy_android_emulator_session.py @@ -415,7 +415,9 @@ def test_owned_java_first_fatal_and_last_loader_cause_survive_long_stacks(self): self.assertIn("ModuleNotFoundError", text) self.assertNotIn("controlled-private-token", text) self.assertNotIn("unrelated-private-app", text) - self.assertEqual(payload["observed_process_pids"], ["1234"]) + self.assertEqual(payload["diagnostic_bound_process_pids"], ["1234"]) + self.assertEqual(payload["process_pid_semantics"], "diagnostic-attribution-only") + self.assertNotIn("observed_process_pids", payload) self.assertTrue(payload["installation_success_marker"]) self.assertEqual([row["phase"] for row in payload["commands"]], ["apk_install", "app_start", "app_pid_observation"]) @@ -449,6 +451,95 @@ def test_current_am_start_status_and_error_stdout_are_diagnostic_only(self): self.assertFalse(session.result["apk_installed"]) self.assertFalse(session.result["gameplay_verified"]) + def failure_summary(self, session): + summary = self.milestone_summary(session) + digest = hashlib.sha256(session.args.apk.read_bytes()).hexdigest() + summary.update(run_id="a" * 32, apk_sha256=digest, activity=session.args.package + "/Launcher", + process_started=False) + summary["actual_status"]["app_launch"]["status"] = "failed" + summary["failure_diagnostics"] = { + "source": "actual-adb-failure-diagnostic", "diagnostic_only": True, + "run_id": summary["run_id"], "package": session.args.package, "serial": session.result["serial"], + "apk_sha256": digest, "pid_sample": {"status": "available", "returncode": 0, "pids": ["1234"], "pid_count": 1}, + "activity_sample": {"status": "available", "returncode": 0, "exact_component": summary["activity"], + "states": [{"state": "INITIALIZING", "allDrawn": False, "private": "private-token"}]}} + session.harness_apk_sha256 = digest + session.result["gameplay_result"] = summary + session.retain_partial_milestones(summary, digest) + return summary + + def test_current_matched_failure_pid_can_select_causes_without_launch_or_gameplay_success(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + self.failure_summary(session) + prefix = "10-09 07:00:00.000 1234 1234 E AndroidRuntime: " + path = self.write(session, "android-process-logcat.txt", prefix + "FATAL EXCEPTION: main\n" + prefix + + "Caused by: java.lang.UnsatisfiedLinkError: controlled-current-loader-cause\n") + os.utime(path, ns=(session.harness_started_ns + 1_000_000,) * 2) + text, payload = self.diagnose(session) + self.assertEqual(payload["failure_snapshot"]["diagnostic_pids"], ["1234"]) + self.assertEqual(payload["failure_snapshot"]["activity_states"], [{"state": "INITIALIZING", "allDrawn": False}]) + self.assertIn("controlled-current-loader-cause", text) + self.assertNotIn("private-token", text) + self.assertTrue(session.result["apk_installed"]) + self.assertFalse(session.result["process_started"]) + self.assertFalse(session.result["gameplay_verified"]) + self.assertLessEqual(len(text.encode()), 6144) + + def test_wrong_tuple_success_or_unproven_install_cannot_bind_diagnostic_pids(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + baseline = self.failure_summary(session) + for key in ("run_id", "source", "package", "serial", "apk_sha256", "diagnostic_only"): + wrong = copy.deepcopy(baseline) + wrong["failure_diagnostics"][key] = False if key == "diagnostic_only" else "different-controlled-value" + session.result["gameplay_result"] = wrong + self.assertEqual(session.current_failure_snapshot()["status"], "diagnostic_notavailable", key) + for changes in ({"status": "passed"}, {"apk_installed": False}): + session.result["gameplay_result"] = {**baseline, **changes} + self.assertEqual(session.current_failure_snapshot()["status"], "diagnostic_notavailable") + session.result["gameplay_result"] = copy.deepcopy(baseline) + for changes in ({"returncode": False}, {"pids": ["9999999999"]}, {"pids": ["1234", "1234"]}, {"pid_count": True}): + session.result["gameplay_result"]["failure_diagnostics"]["pid_sample"] = { + **baseline["failure_diagnostics"]["pid_sample"], **changes} + self.assertNotIn("diagnostic_pids", session.current_failure_snapshot()) + session.result["actual_status"]["apk_install"]["stdout_marker"] = "NotSuccess" + self.assertEqual(session.current_failure_snapshot()["status"], "diagnostic_notavailable") + self.assertFalse(session.result["process_started"] or session.result["gameplay_verified"]) + + def test_crash_header_requires_exact_package_tag_and_equal_header_body_pid(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + for package, body_pid, tag, bound in ((session.args.package, "1234", "AndroidRuntime", True), + (session.args.package, "4321", "AndroidRuntime", False), + (session.args.package + ".other", "1234", "AndroidRuntime", False), + (session.args.package, "1234", "OtherRuntime", False)): + prefix = "10-09 07:00:00.000 1234 1234 E " + tag + ": " + path = self.write(session, "android-system-logcat.txt", prefix + "FATAL EXCEPTION: main\n" + prefix + + f"Process: {package}, PID: {body_pid}\n" + prefix + + "Caused by: java.lang.UnsatisfiedLinkError: controlled-exact-crash-cause\n") + os.utime(path, ns=(session.harness_started_ns + 1_000_000,) * 2) + text, payload = self.diagnose(session) + self.assertEqual("controlled-exact-crash-cause" in text, bound) + self.assertEqual(payload["diagnostic_bound_process_pids"], ["1234"] if bound else []) + self.assertEqual(payload["process_pid_semantics"], "diagnostic-attribution-only") + self.assertFalse(session.result["process_started"] or session.result["gameplay_verified"]) + + def test_console_metadata_overflow_terminates_without_output_candidates(self): + # Controlled schema regression: no text candidates can be trimmed. + # The production fallback must terminate instead of looping forever. + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + metadata = {"status": "diagnostic_notavailable", "reason": "controlled-large-metadata" * 400} + with patch("run_renpy_android_emulator_session.read_startup_lines", return_value=(metadata, [])): + text, payload = self.diagnose(session) + self.assertEqual(payload["reason"], "console_size_limit") + self.assertEqual(payload["status"], "diagnostic_notavailable") + self.assertEqual(payload["commands"], []) + self.assertEqual(payload["files"], {}) + self.assertLessEqual(len(text.encode()), 6144) + self.assertFalse(session.result["process_started"] or session.result["gameplay_verified"]) + def test_launch_stdout_is_fresh_regular_and_prefix_bounded_without_reading_private_eof(self): with tempfile.TemporaryDirectory() as temporary: session = self.session(temporary) diff --git a/tools/test_renpy_device_evidence_metrics.py b/tools/test_renpy_device_evidence_metrics.py index e27606df6..12d38bdc4 100644 --- a/tools/test_renpy_device_evidence_metrics.py +++ b/tools/test_renpy_device_evidence_metrics.py @@ -19,6 +19,7 @@ import subprocess import sys import tempfile +import time import unittest from pathlib import Path from unittest import mock @@ -285,6 +286,116 @@ def test_real_file_read_requires_available_regular_log(self): require_engine_log(read_engine_log_file(path), self.root) +class AndroidFailureDiagnosticBoundaries(unittest.TestCase): + """Real ordinary subprocesses and controlled state fixtures, never Android.""" + + def test_real_success_nonzero_and_oversize_stdout_have_distinct_bounded_results(self): + for script, expected in (("print('4343')", "available"), + ("print('4343'); raise SystemExit(7)", "not_available"), + ("print('x'*100000)", "not_available")): + report, raw = android.bounded_failure_command([sys.executable, "-c", script], time.monotonic() + 2, + byte_limit=1024) + self.assertEqual(report["status"], expected) + self.assertLessEqual(report["bytes_captured"], 1024) + self.assertEqual(raw, b"4343\n" if expected == "available" else b"") + self.assertEqual(report["reason"], "stdout_limit") + + def test_real_non_eof_stdout_times_out_and_direct_child_is_reaped(self): + original = subprocess.Popen + processes = [] + def owned_child(*args, **kwargs): + child = original(*args, **kwargs) + processes.append(child) + return child + started = time.monotonic() + with mock.patch.object(android.subprocess, "Popen", side_effect=owned_child): + report, raw = android.bounded_failure_command([sys.executable, "-c", + "import time; print('4343',flush=True); time.sleep(10)"], started + 0.4) + self.assertEqual(report["reason"], "command_timeout") + self.assertEqual(raw, b"") + self.assertIsNotNone(processes[0].poll()) + self.assertLess(time.monotonic() - started, 1) + + def test_denied_kill_is_unknown_without_retry_or_partial_pid_use(self): + original = subprocess.Popen + processes, signals = [], [] + def denied_child(*args, **kwargs): + child = original(*args, **kwargs) + processes.append(child) + def denied(): + signals.append(True) + raise PermissionError("controlled denial, not an actual permission change") + child.kill = denied + return child + try: + with mock.patch.object(android.subprocess, "Popen", side_effect=denied_child): + report, raw = android.bounded_failure_command([sys.executable, "-c", + "import time; print('4343',flush=True); time.sleep(10)"], time.monotonic() + 0.4) + self.assertTrue(report["cleanup_permission_denied"]) + self.assertTrue(report["collector_not_reaped"]) + self.assertEqual(raw, b"") + self.assertEqual(signals, [True]) + finally: + for child in processes: + subprocess.Popen.kill(child) # Normal cleanup of our real test child, with the injected denial removed. + child.wait(timeout=2) + + def acceptance(self, temporary): + args = type("Args", (), {"output_dir": Path(temporary), "package": "org.example.controlled", + "serial": "controlled-only", "adb": "not-an-adb"})() + acceptance = android.Acceptance(args) + acceptance.installed = True + acceptance.summary.update(status="failed", blocker="original-controlled-launch-timeout", apk_installed=True, + apk_sha256="a" * 64, activity="org.example.controlled/.Activity") + return acceptance + + def test_collect_has_one_ten_second_deadline_and_never_upgrades_partial_milestones(self): + with tempfile.TemporaryDirectory() as temporary: + acceptance = self.acceptance(temporary) + before = copy.deepcopy(acceptance.summary["actual_status"]) + clock, calls = {"now": 0.0}, [] + def capture(command, deadline, **kwargs): + self.assertEqual(deadline, 10) + if clock["now"] >= deadline: + return {"status": "not_available", "reason": "diagnostic_deadline"}, b"" + calls.append(command) + clock["now"] += min(2, deadline - clock["now"]) + output = b"4343\n" if command[-1].startswith("pidof ") else b"" + if command[-1] == "dumpsys activity activities": + output = ("ActivityRecord{x u0 org.example.controlled/.Activity t1}\n" + " state=INITIALIZING firstWindowDrawn=false allDrawn=false\n" + "ActivityRecord{x u0 unrelated/.Activity t2}\n state=RESUMED allDrawn=true\n").encode() + return {"status": "available", "returncode": 0}, output + with mock.patch.object(android.time, "monotonic", side_effect=lambda: clock["now"]), \ + mock.patch.object(android, "bounded_failure_command", side_effect=capture): + acceptance.collect() + report = acceptance.summary["failure_diagnostics"] + self.assertEqual(clock["now"], 10) + self.assertEqual(len(calls), 5) + self.assertTrue(report["deadline_reached"]) + self.assertEqual(report["pid_sample"]["pids"], ["4343"]) + self.assertEqual(report["activity_sample"]["states"], + [{"state": "INITIALIZING", "allDrawn": False, "firstWindowDrawn": False}]) + self.assertEqual((report["run_id"], report["package"], report["serial"], report["apk_sha256"]), + (acceptance.run_id, acceptance.args.package, acceptance.args.serial, "a" * 64)) + self.assertEqual(acceptance.summary["actual_status"], before) + self.assertEqual(acceptance.summary["blocker"], "original-controlled-launch-timeout") + self.assertFalse(acceptance.summary["process_started"]) + + def test_successful_collection_keeps_original_full_evidence_and_no_failure_snapshot(self): + with tempfile.TemporaryDirectory() as temporary: + acceptance = self.acceptance(temporary) + acceptance.summary["status"] = "passed" + full = ("controlled complete evidence line " + "x" * 80 + "\n").encode() * 1000 + with mock.patch.object(acceptance, "private", return_value=full), \ + mock.patch.object(acceptance, "command", return_value=b"controlled logcat\n"), \ + mock.patch.object(android, "bounded_failure_command", side_effect=AssertionError("Success cannot enter new failure collector")): + acceptance.collect() + self.assertEqual((acceptance.output / "observer.jsonl").read_bytes(), full) + self.assertEqual((acceptance.output / "renpy-script-checkpoints.jsonl").read_bytes(), full) + self.assertNotIn("failure_diagnostics", acceptance.summary) + + class AndroidPartialFailureReporting(unittest.TestCase): """Controlled command results, never an actual installation or game pass.""" From 63c19f89557dd692e048768c0a62d72b4ea80957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 08:02:11 +0000 Subject: [PATCH 321/331] ci(android): allow a bounded 900 second cold boot diagnosis --- .github/workflows/renpy-android-hvf-gameplay.yml | 10 +++++----- tools/run_renpy_android_emulator_session.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/renpy-android-hvf-gameplay.yml b/.github/workflows/renpy-android-hvf-gameplay.yml index e5c2ddc90..da64b844a 100644 --- a/.github/workflows/renpy-android-hvf-gameplay.yml +++ b/.github/workflows/renpy-android-hvf-gameplay.yml @@ -26,7 +26,7 @@ jobs: gameplay: name: Actual validated x64 APK on Android 35 (Intel HVF) runs-on: macos-15-intel - timeout-minutes: 40 + timeout-minutes: 45 env: APK_RUN: '37871746668' APK_JOB: '113631187733' @@ -167,13 +167,13 @@ jobs: 'actual_manifest':manifest, 'gameplay':'not_run'}, sort_keys=True)) PY - name: Boot actual Android 35 and run full Ren'Py APK gameplay - # Run 37878726721 (2971bfc4) showed live guest/ART activity but no boot - # completion within 480s. This 600s coldboot experiment still fails - # closed; the additional wait is not a verified fix or gameplay pass. + # Run 37899763345 (68e7c016) measured one guest vCPU and ongoing ART + # work at the 600s cutoff. This bounded 900s coldboot diagnostic keeps + # the original gameplay gates and remains an unverified experiment. run: | python3 tools/run_renpy_android_emulator_session.py --cloud-emulator \ --apk "$RENPY_HVF_APK" --package org.aetherkiri.renpy.debug \ - --boot-timeout 600 --gameplay-timeout 900 --stage-timeout 90 \ + --boot-timeout 900 --gameplay-timeout 900 --stage-timeout 90 \ --output-dir "$RUNNER_TEMP/renpy-android-device-evidence" - name: Preserve actual Android gameplay evidence if: always() diff --git a/tools/run_renpy_android_emulator_session.py b/tools/run_renpy_android_emulator_session.py index ba736ed12..48881e95d 100644 --- a/tools/run_renpy_android_emulator_session.py +++ b/tools/run_renpy_android_emulator_session.py @@ -1146,7 +1146,7 @@ def main() -> int: parser.add_argument("--stage-timeout", type=int, default=90) parser.add_argument("--port", type=int, default=5554) args = parser.parse_args() - if not 60 <= args.boot_timeout <= 600 or not 60 <= args.gameplay_timeout <= 1200 \ + if not 60 <= args.boot_timeout <= 900 or not 60 <= args.gameplay_timeout <= 1200 \ or not 5 <= args.stage_timeout <= 180 or not 5554 <= args.port <= 5682 or args.port % 2 \ or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_.]+", args.package): parser.error("Invalid timeout, package, or even emulator port") From 5617f8e7cf9a21bec1337ababbde2c14e818f567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 08:33:02 +0000 Subject: [PATCH 322/331] ci(ios): keep optional cache validation from blocking gameplay --- .github/workflows/renpy-ios-runtime-build.yml | 2 + tools/ios_public_vcpkg_cache.py | 35 +++++++++++++-- tools/test_ios_public_vcpkg_cache.py | 43 ++++++++++++++++++- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/.github/workflows/renpy-ios-runtime-build.yml b/.github/workflows/renpy-ios-runtime-build.yml index 4d00ec1f2..84e188a4c 100644 --- a/.github/workflows/renpy-ios-runtime-build.yml +++ b/.github/workflows/renpy-ios-runtime-build.yml @@ -399,6 +399,7 @@ jobs: 2>&1 | tee "$RUNNER_TEMP/renpy-ios-simulator-build.log" - name: Validate successful package archives even when the App build fails id: public-vcpkg-after + continue-on-error: true if: >- always() && !cancelled() && steps.public-vcpkg-app.outcome != 'skipped' && @@ -620,6 +621,7 @@ jobs: echo 'Unsigned iPhoneOS package built. Device signing, installation and gameplay remain unverified.' - name: Validate successful package archives even when the App build fails id: public-vcpkg-after + continue-on-error: true if: >- always() && !cancelled() && steps.public-vcpkg-app.outcome != 'skipped' && diff --git a/tools/ios_public_vcpkg_cache.py b/tools/ios_public_vcpkg_cache.py index eb3ef4abe..118e4bec1 100644 --- a/tools/ios_public_vcpkg_cache.py +++ b/tools/ios_public_vcpkg_cache.py @@ -30,6 +30,12 @@ HEX = re.compile(r"[0-9a-f]{64}\Z") PORT = re.compile(r"[a-z0-9][a-z0-9-]*\Z") TOP_LEVEL = {"include", "lib", "bin", "debug", "share", "tools", "etc"} +# Diagnostic labels only; these directories remain rejected. Other root names +# are hashed, so no arbitrary filename is echoed into the Actions console. +ROOT_DIAGNOSTIC_LABELS = { + "manual-tools", "plugins", "libexec", "sbin", "man", "doc", "docs", + "cmake", "var", "Frameworks", "Applications", "sdk", +} # Existing public Mono dependency, maintained as this repository's overlay; # it is absent from the current and baseline builtin registries. PUBLIC_ADDITIONAL_OVERLAYS = {"libgdiplus"} @@ -47,6 +53,20 @@ class InvalidCache(ValueError): """Fixed diagnostic codes avoid echoing untrusted archive free text.""" + def __init__(self, code: str, *, metadata: dict | None = None): + super().__init__(code) + self.metadata = metadata + + +def rejection_message(error: Exception) -> str: + code = str(error) if isinstance(error, InvalidCache) else type(error).__name__ + message = f"Public dependency cache rejected: {code}" + if isinstance(error, InvalidCache) and code == "unexpected-package-root" and error.metadata: + detail = json.dumps(error.metadata, sort_keys=True, separators=(",", ":")) + require(len(detail) <= 512, "diagnostic-too-large") + message += " " + detail + return message + def require(condition: bool, code: str) -> None: if not condition: @@ -224,13 +244,16 @@ def validate_archive(path: Path, ports: set[str], triplets: set[str]) -> dict: require(sum(i.file_size for i in infos) <= MAX_EXPANDED_BYTES, "archive-expanded-size") members: dict[str, zipfile.ZipInfo] = {} links: dict[str, str] = {} + rejected_root = None for i in infos: n = safe_member(i.filename) require(n not in members, "duplicate-member") require(not i.flag_bits & 1, "encrypted-member") mode = stat.S_IFMT(i.external_attr >> 16) require(mode in (0, stat.S_IFREG, stat.S_IFDIR, stat.S_IFLNK), "special-member") - require(n in ("CONTROL", "BUILD_INFO") or n.split("/", 1)[0] in TOP_LEVEL, "unexpected-package-root") + root = n.split("/", 1)[0] + if rejected_root is None and n not in ("CONTROL", "BUILD_INFO") and root not in TOP_LEVEL: + rejected_root = root members[n] = i if mode == stat.S_IFLNK: require(i.file_size <= MAX_METADATA, "symlink-too-large") @@ -285,6 +308,13 @@ def resolve_link(n: str, seen: set[str]) -> str: abi_info = z.read(abi_path) require(hashlib.sha256(abi_info).hexdigest() == abi, "abi-info-digest-mismatch") require(f"triplet {triplet}\n".encode() in abi_info.splitlines(keepends=True), "abi-info-triplet-mismatch") + if rejected_root is not None: + metadata = {"abi": abi, "package": name, "triplet": triplet} + if rejected_root in ROOT_DIAGNOSTIC_LABELS: + metadata["root"] = rejected_root + else: + metadata["root_sha256"] = hashlib.sha256(rejected_root.encode()).hexdigest() + raise InvalidCache("unexpected-package-root", metadata=metadata) # Read all bytes and check their CRC before vcpkg's unzip consumer sees them. for i in infos: with z.open(i) as f: @@ -381,8 +411,7 @@ def main() -> int: return 0 except (InvalidCache, zipfile.BadZipFile, OSError, KeyError, ValueError, RuntimeError, NotImplementedError, subprocess.CalledProcessError) as e: - code = str(e) if isinstance(e, InvalidCache) else type(e).__name__ - print(f"Public dependency cache rejected: {code}", file=sys.stderr) + print(rejection_message(e), file=sys.stderr) return 1 diff --git a/tools/test_ios_public_vcpkg_cache.py b/tools/test_ios_public_vcpkg_cache.py index 071f693ce..61d31552a 100644 --- a/tools/test_ios_public_vcpkg_cache.py +++ b/tools/test_ios_public_vcpkg_cache.py @@ -9,6 +9,7 @@ from __future__ import annotations import argparse +import hashlib import json import os from pathlib import Path @@ -17,6 +18,7 @@ import stat import struct import subprocess +import sys import warnings import zipfile @@ -166,7 +168,7 @@ def execute(value: int) -> str: check(cache.validate_cache(zip64_dir.parent, ports, {triplet})["archive_count"] == 1, "Standard ZIP64 container rejected.") rejected = [] - def reject(label: str, edit, expected: str, allowed_triplets=None) -> None: + def reject(label: str, edit, expected: str, allowed_triplets=None) -> cache.InvalidCache: d = controls / label / probe_record["abi"][:2] d.mkdir(parents=True) path = d / probe_zip.name @@ -180,9 +182,14 @@ def reject(label: str, edit, expected: str, allowed_triplets=None) -> None: cache.validate_cache(d.parent, ports, allowed_triplets or {triplet}) except cache.InvalidCache as e: check(str(e) == expected, f"Wrong rejection reason in {label}.") + failure = e + check(len(cache.rejection_message(e)) <= 640, "Rejection diagnostic is not bounded.") + if expected != "unexpected-package-root": + check(e.metadata is None, "Unverified package identity was printed in a diagnostic.") else: raise AssertionError(f"Unsafe control accepted: {label}.") rejected.append(label) + return failure def control_edit(old: bytes, new: bytes): return lambda es: [(i, b.replace(old, new) if i.filename == "CONTROL" else b) for i, b in es] reject("wrong-control-abi", control_edit(probe_record["abi"].encode(), b"0" * 64), "control-abi-mismatch") @@ -204,6 +211,39 @@ def edit(es): reject("escaping-symlink", link_edit(b"../../outside"), "escaping-symlink") reject("symlink-cycle", link_edit(b"unsafe-link"), "symlink-cycle") reject("source-cache-root", lambda es: es + [(zipfile.ZipInfo("buildtrees/compiler.log"), b"x")], "unexpected-package-root") + known_root = reject("diagnostic-known-root", lambda es: es + [ + (zipfile.ZipInfo("manual-tools/aether-cache-probe/tool"), b"x")], "unexpected-package-root") + check(known_root.metadata == {"abi": probe_record["abi"], "package": "aether-cache-probe", + "triplet": triplet, "root": "manual-tools"}, + "Known root diagnostic did not contain only verified public identity and the fixed label.") + private_root = "private-root-name-must-not-be-echoed" + unknown_root = reject("diagnostic-hashed-root", lambda es: es + [ + (zipfile.ZipInfo(private_root + "/private-member-must-not-be-echoed"), b"x")], "unexpected-package-root") + message = cache.rejection_message(unknown_root) + check(private_root not in message and "private-member-must-not-be-echoed" not in message, + "Arbitrary root/member text escaped into a diagnostic.") + check(unknown_root.metadata == {"abi": probe_record["abi"], "package": "aether-cache-probe", + "triplet": triplet, "root_sha256": hashlib.sha256(private_root.encode()).hexdigest()}, + "Unknown root diagnostic was not limited to verified identity and a digest.") + # Run the real CLI rejection path, without substituting an Apple identity. + # Linux rejects the host boundary; Mac rejects this private CONTROL after + # using its actual SDK identity. Neither may publish success outputs/report. + output = root / "rejected-github-output" + output.write_text("existing-test-marker=value\n") + rejection_report = root / "must-not-exist.json" + rejected_cli = subprocess.run([ + sys.executable, str(Path(cache.__file__).resolve()), "validate", + "--repository", str(Path(__file__).resolve().parents[1]), + "--vcpkg-root", str(vcpkg_root), "--sdk", "iphonesimulator", + "--triplet", "arm64-ios-simulator", "--internal", "OFF", + "--cache-dir", str(controls / "private-package"), + "--report", str(rejection_report), "--github-output", str(output), + ], env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + expected_code = "non-public-package" if platform.system() == "Darwin" else "real-apple-host-required" + check(rejected_cli.returncode == 1 and expected_code in rejected_cli.stderr, + "Actual CLI did not reject the unsafe cache at the genuine platform/package boundary.") + check(not rejected_cli.stdout and output.read_text() == "existing-test-marker=value\n" + and not rejection_report.exists(), "Rejected CLI cache published success output or a report.") # Actual provider bytes with an oversized declared central directory must # be rejected before zipfile allocates/parses that directory. cd_dir = controls / "central-directory-budget" / probe_record["abi"][:2] @@ -327,6 +367,7 @@ def edit(es): "zip64_container_compatibility": "passed", "nonsentinel_zip64_container_compatibility": "passed", "production_public_port_allowlist": "passed", + "bounded_root_diagnostics": "passed", "rejection_does_not_publish_success": "passed", "apple_sdk_cache": "not_run", "app": "not_run", "gameplay": "not_run"} (root / "evidence.json").write_text(json.dumps(result, indent=2, sort_keys=True) + "\n") print(json.dumps({k: result[k] for k in ("status", "files_provider_restore", "changed_port_abi", "partial_failure_flush", "apple_sdk_cache", "app", "gameplay")}, sort_keys=True)) From 9d2da0d09f9e72e702280d69349bfb13c8abbbe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 08:35:52 +0000 Subject: [PATCH 323/331] test(android): preserve bounded current app startup phases --- tools/run_renpy_android_device_acceptance.py | 19 ++++-- tools/run_renpy_android_emulator_session.py | 70 ++++++++++++++++++++ tools/test_renpy_android_emulator_session.py | 62 +++++++++++++++++ tools/test_renpy_device_evidence_metrics.py | 29 ++++++++ 4 files changed, 174 insertions(+), 6 deletions(-) diff --git a/tools/run_renpy_android_device_acceptance.py b/tools/run_renpy_android_device_acceptance.py index 2263bb119..f5ad54dd3 100644 --- a/tools/run_renpy_android_device_acceptance.py +++ b/tools/run_renpy_android_device_acceptance.py @@ -112,19 +112,26 @@ def failure_activity_states(raw: bytes, component: str) -> list[dict]: """Select state/drawn booleans only within the exact current Activity record.""" if len(component) > 256 or not re.fullmatch(r"[A-Za-z0-9_.$]+/[A-Za-z0-9_.$]+", component): return [] - states, remaining, current = [], 0, None + states, current = [], None wanted = re.compile(r"(? len(prefix): + prefix = prefix.rpartition(b"\n")[0] + for line in prefix.decode(errors="replace").splitlines()[:1000]: if len(line) > 4096: + if "ActivityRecord{" in line: + current = None continue if "ActivityRecord{" in line: - current, remaining = None, 0 + current = None if wanted.search(line) and len(states) < 4: - current, remaining = {}, 24 + current = {} states.append(current) - if remaining <= 0: + if current is None: continue - remaining -= 1 match = re.search(r"\bstate=(INITIALIZING|STARTED|RESUMED|PAUSING|PAUSED|STOPPING|STOPPED|DESTROYING|DESTROYED|RESTARTING_PROCESS)\b", line) if match: current["state"] = match[1] diff --git a/tools/run_renpy_android_emulator_session.py b/tools/run_renpy_android_emulator_session.py index 48881e95d..bebd2b8f2 100644 --- a/tools/run_renpy_android_emulator_session.py +++ b/tools/run_renpy_android_emulator_session.py @@ -41,6 +41,23 @@ r"undefined symbol|no module|segmentation|renpy_mobile|cooperative_", re.IGNORECASE) LOGCAT_ROW = re.compile(r"^\d\d-\d\d\s+\S+\s+(\d+)\s+\d+\s+([VDIWEF])\s+([^:]+):\s*(.*)$") STARTUP_TOKENS = re.compile(r"[A-Za-z0-9_.]+") +# Exact public log strings from the installed Godot 4.7.2 debug AAR. These +# identify startup progress only; no message or dynamic path is echoed. +STARTUP_PROGRESS_MESSAGES = { + "GodotActivity": { + "Creating new Godot fragment instance.": "godot-fragment-create", + "Reusing existing Godot fragment instance.": "godot-fragment-reuse", + }, + "Godot": { + "Initializing Godot plugin registry": "godot-plugin-registry-initialize", + "Godot native layer initialization completed: true": "godot-native-initialization-result-true", + "Godot native layer initialization completed: false": "godot-native-initialization-result-false", + "Godot native layer setup completed": "godot-native-setup-completed", + "Engine already initialized": "godot-engine-already-initialized", + }, +} +STARTUP_BRIDGE_EVENTS = ("initialize_engine_begin", "initialize_engine_result", + "initialize_engine_already_initialized", "open_game_begin", "open_game_result") AVD_RESOURCE_KEYS = { "hw.cpu.ncore": 4096, "hw.ramSize": 2147483647, "hw.lcd.width": 65536, "hw.lcd.height": 65536, "hw.lcd.density": 65536, @@ -206,6 +223,56 @@ def startup_error_text(lines: list[str], deadline: float | None = None) -> dict: return result +def startup_progress_text(records: dict[str, list[str]], pids: list[str], deadline: float) -> dict: + """Select fixed phases from this failure's real pidof-bound, fresh logs.""" + result = {"source": "actual-current-pid-startup-diagnostic", "diagnostic_only": True, + "status": "diagnostic_notavailable", "output": "", "redacted_lines": 0, "truncated": False} + if not pids: + return result + selected, seen = [], 0 + for name in ("android-process-logcat.txt", "aetherkiri-engine.log"): + for line in records.get(name, []): + if time.monotonic() >= deadline: + result["diagnostic_deadline_reached"] = True + break + if len(line) > 4096 or SENSITIVE_DIAGNOSTIC.search(line): + result["redacted_lines"] += 1 + continue + stage = "" + if name == "android-process-logcat.txt": + row = LOGCAT_ROW.match(line) + if not row: + continue + pid, priority, tag, message = row.groups() + tag = tag.strip() + if pid not in pids or priority not in ("V", "D", "I"): + continue + stage = STARTUP_PROGRESS_MESSAGES.get(tag, {}).get(message.strip(), "") + if tag == "AetherKiriBridge": + # These events are compiled in aether_runtime_player.cpp. + # Writable/cache/game paths and result/error text stay private. + event = re.match(r"event=([a-z_]+)(?:\s|$)", message) + if event and event[1] in STARTUP_BRIDGE_EVENTS: + stage = "bridge-" + event[1].replace("_", "-") + rendered = f"pid={pid} tag={tag} stage={stage}" if stage else "" + else: + # Existing dispatcher's per-game attachment message. Record a + # phase only, not the game path or a claim of rendered pixels. + if re.search(r"(?:^|\]\s*)aetherkiri provider engine log attached:\s", line): + stage = "provider-engine-log-attached" + rendered = "file=aetherkiri-engine.log stage=" + stage if stage else "" + if rendered: + seen += 1 + selected = (selected + [rendered])[-6:] + if result.get("diagnostic_deadline_reached"): + break + bounded = bounded_diagnostic("\n".join(selected), max_chars=768, max_lines=6) + result.update(output=bounded["output"], truncated=bounded["truncated"] or seen > 6) + if selected: + result["status"] = "diagnostic_available" + return result + + def read_startup_lines(path: Path, started_ns: int, deadline: float, *, read_limit: int = 262144) -> tuple[dict, list[str]]: """Read a bounded prefix of a fresh regular file produced by this harness.""" if time.monotonic() >= deadline: @@ -580,6 +647,7 @@ def startup_failure_diagnostics(self) -> None: snapshot = self.current_failure_snapshot() payload["failure_snapshot"] = snapshot own_pids.update(snapshot.get("diagnostic_pids", [])) + payload["startup_progress"] = startup_progress_text(records, snapshot.get("diagnostic_pids", []), deadline) prefix = [str(self.adb), "-s", self.result["serial"]] for line in records.get("commands.jsonl", []): if time.monotonic() >= deadline: @@ -686,6 +754,8 @@ def startup_failure_diagnostics(self) -> None: while len(serialized.encode()) > 6143: candidates = [row for row in payload["files"].values() if row.get("output")] candidates += [row["stderr"] for row in payload["commands"] if row["stderr"].get("output")] + if payload.get("startup_progress", {}).get("output"): + candidates.append(payload["startup_progress"]) if not candidates: # A schema regression must not create an infinite trim loop. payload = {"checkpoint": "actual-apk-startup-failure-diagnostics", diff --git a/tools/test_renpy_android_emulator_session.py b/tools/test_renpy_android_emulator_session.py index 933ef3c00..e7a19eac8 100644 --- a/tools/test_renpy_android_emulator_session.py +++ b/tools/test_renpy_android_emulator_session.py @@ -486,6 +486,68 @@ def test_current_matched_failure_pid_can_select_causes_without_launch_or_gamepla self.assertFalse(session.result["gameplay_verified"]) self.assertLessEqual(len(text.encode()), 6144) + def test_current_failure_pid_known_startup_phases_are_bounded_and_never_launch_proof(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + self.failure_summary(session) + prefix = "10-09 08:12:00.000 1234 1234 " + process = self.write(session, "android-process-logcat.txt", + prefix + "V GodotActivity: Creating new Godot fragment instance.\n" + + prefix + "I Godot: Initializing Godot plugin registry\n" + + prefix + "D Godot: Godot native layer initialization completed: false\n" + + prefix + "I AetherKiriBridge: event=initialize_engine_begin writable=/data/private-path cache=/data/private-cache\n" + + prefix + "I AetherKiriBridge: event=open_game_begin path=/data/private-game async=1 runtime=renpy\n") + engine = self.write(session, "aetherkiri-engine.log", + "[2026-10-09] [info] aetherkiri provider engine log attached: /data/private-game/aetherkiri-engine.log\n") + for path in (process, engine): + os.utime(path, ns=(session.harness_started_ns + 1_000_000,) * 2) + text, payload = self.diagnose(session) + progress = payload["startup_progress"] + self.assertEqual(progress["status"], "diagnostic_available") + self.assertTrue(progress["diagnostic_only"]) + self.assertEqual(progress["source"], "actual-current-pid-startup-diagnostic") + self.assertIn("godot-fragment-create", progress["output"]) + self.assertIn("godot-native-initialization-result-false", progress["output"]) + self.assertIn("provider-engine-log-attached", progress["output"]) + self.assertNotIn("/data/private", text) + self.assertLessEqual(len(progress["output"]), 768) + self.assertLessEqual(len(progress["output"].splitlines()), 6) + self.assertLessEqual(len(text.encode()), 6144) + self.assertFalse(session.result["process_started"] or session.result["gameplay_verified"]) + + def test_startup_progress_rejects_foreign_secret_unmatched_and_expired_sources(self): + with tempfile.TemporaryDirectory() as temporary: + session = self.session(temporary) + self.failure_summary(session) + prefix = "10-09 08:12:00.000 1234 1234 I " + path = self.write(session, "android-process-logcat.txt", + "10-09 08:12:00.000 9999 9999 I Godot: Godot native layer setup completed\n" + + prefix + "Other: Godot native layer setup completed\n" + + prefix + "Godot: arbitrary unrecognized public log\n" + + prefix + "AetherKiriBridge: event=open_game_begin token=controlled-private-token\n" + + prefix + "Godot: export CONTROLLED_PRIVATE_ENV=controlled-value\n") + os.utime(path, ns=(session.harness_started_ns + 1_000_000,) * 2) + text, payload = self.diagnose(session) + self.assertEqual(payload["startup_progress"]["status"], "diagnostic_notavailable") + self.assertNotIn("controlled-private-token", text) + self.assertNotIn("controlled-value", text) + path = self.write(session, "android-process-logcat.txt", + (prefix + "Godot: Godot native layer setup completed\n") * 20) + os.utime(path, ns=(session.harness_started_ns + 1_000_000,) * 2) + _, payload = self.diagnose(session) + self.assertTrue(payload["startup_progress"]["truncated"]) + self.assertEqual(len(payload["startup_progress"]["output"].splitlines()), 6) + self.assertFalse(session.result["process_started"] or session.result["gameplay_verified"]) + session.result["gameplay_result"]["failure_diagnostics"]["run_id"] = "b" * 32 + _, payload = self.diagnose(session) + self.assertEqual(payload["startup_progress"]["status"], "diagnostic_notavailable") + with patch("run_renpy_android_emulator_session.time.monotonic", return_value=10): + from run_renpy_android_emulator_session import startup_progress_text + expired = startup_progress_text({"android-process-logcat.txt": + [prefix + "Godot: Godot native layer setup completed"]}, ["1234"], deadline=10) + self.assertTrue(expired["diagnostic_deadline_reached"]) + self.assertEqual(expired["output"], "") + def test_wrong_tuple_success_or_unproven_install_cannot_bind_diagnostic_pids(self): with tempfile.TemporaryDirectory() as temporary: session = self.session(temporary) diff --git a/tools/test_renpy_device_evidence_metrics.py b/tools/test_renpy_device_evidence_metrics.py index 12d38bdc4..2986c6b82 100644 --- a/tools/test_renpy_device_evidence_metrics.py +++ b/tools/test_renpy_device_evidence_metrics.py @@ -289,6 +289,35 @@ def test_real_file_read_requires_available_regular_log(self): class AndroidFailureDiagnosticBoundaries(unittest.TestCase): """Real ordinary subprocesses and controlled state fixtures, never Android.""" + def test_activity_state_after_full_configuration_is_kept_until_next_record(self): + component = "org.example.controlled/com.godot.game.GodotAppLauncher" + # AOSP Android 15 ActivityRecord.dump: configuration/TaskDescription + # precede state/visible/drawn fields (source lines 1022..1223). + lines = (["display configuration field"] * 220 + + ["* Hist #0: ActivityRecord{x u0 " + component + " t1}"] + + [" CurrentConfiguration=controlled config"] * 45 + + [" state=STARTED delayedResume=false finishing=false", + " mVisibleRequested=true mVisible=false reportedDrawn=false", + " allDrawn=false firstWindowDrawn=false", + "* Hist #1: ActivityRecord{x u0 org.example.unrelated/.Other t2}", + " state=RESUMED mVisible=true allDrawn=true firstWindowDrawn=true", + "* Hist #2: ActivityRecord{x u0 " + component + "Other t3}", + " state=RESUMED allDrawn=true"]) + self.assertEqual(android.failure_activity_states("\n".join(lines).encode(), component), + [{"state": "STARTED", "mVisibleRequested": True, "mVisible": False, + "allDrawn": False, "firstWindowDrawn": False}]) + + def test_activity_scan_keeps_byte_line_and_oversize_record_boundaries(self): + component = "org.example.controlled/.Activity" + header = "ActivityRecord{x u0 " + component + " t1}\n" + self.assertEqual(android.failure_activity_states( + (header + "config\n" * 999 + "state=RESUMED\n").encode(), component), []) + self.assertEqual(android.failure_activity_states( + (header + "config-padding" * 6000 + "\nstate=RESUMED\n").encode(), component), []) + raw = (header + "state=STARTED\n" + "x" * 4097 + " ActivityRecord{x u0 unrelated/.Other t2}\n" + + "state=RESUMED allDrawn=true\n").encode() + self.assertEqual(android.failure_activity_states(raw, component), [{"state": "STARTED"}]) + def test_real_success_nonzero_and_oversize_stdout_have_distinct_bounded_results(self): for script, expected in (("print('4343')", "available"), ("print('4343'); raise SystemExit(7)", "not_available"), From 9a7d914853777565dfa409c86300680dfb7d8546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 09:26:57 +0000 Subject: [PATCH 324/331] test(android): observe real startup briefly after launch timeout --- tools/run_renpy_android_device_acceptance.py | 116 ++++++++++++++++-- tools/test_renpy_device_evidence_metrics.py | 122 ++++++++++++++++++- 2 files changed, 228 insertions(+), 10 deletions(-) diff --git a/tools/run_renpy_android_device_acceptance.py b/tools/run_renpy_android_device_acceptance.py index f5ad54dd3..9a6d6e917 100644 --- a/tools/run_renpy_android_device_acceptance.py +++ b/tools/run_renpy_android_device_acceptance.py @@ -150,14 +150,20 @@ def is_successful_os_return(row: object) -> bool: and not isinstance(row.get("result"), bool) and row.get("result") == 0) -def png_pixels(path: Path) -> tuple[int, int, bytes, int]: +def png_pixels(path: Path, *, deadline: float | None = None) -> tuple[int, int, bytes, int]: """Decode real Android/Godot 8-bit RGB(A) evidence without extra packages.""" + if deadline is not None and time.monotonic() >= deadline: + raise Failed("Passive capture deadline reached") raw = path.read_bytes() + if deadline is not None and (len(raw) > 8 * 1024 * 1024 or time.monotonic() >= deadline): + raise Failed("Passive PNG exceeds its byte or time budget") if raw[:8] != b"\x89PNG\r\n\x1a\n": raise Failed(f"Evidence is not a PNG: {path.name}") offset, compressed = 8, bytearray() width = height = channels = 0 while offset + 12 <= len(raw): + if deadline is not None and time.monotonic() >= deadline: + raise Failed("Passive capture deadline reached") count = struct.unpack_from(">I", raw, offset)[0] kind = raw[offset + 4:offset + 8] data = raw[offset + 8:offset + 8 + count] @@ -173,17 +179,31 @@ def png_pixels(path: Path) -> tuple[int, int, bytes, int]: offset += 12 + count if width <= 0 or height <= 0 or width * height > 30_000_000 or not channels: raise Failed(f"Invalid PNG dimensions: {path.name}") - stream = zlib.decompress(compressed) stride = width * channels + if deadline is None: + stream = zlib.decompress(compressed) + else: + # The passive diagnostic has a finite CPU/memory budget. Keep the + # normal decoder unchanged; reject output beyond the legal PNG size. + if height * (stride + 1) > 8 * 1024 * 1024: + raise Failed("Passive PNG exceeds its decoded pixel budget") + decoder = zlib.decompressobj() + stream = decoder.decompress(compressed, height * (stride + 1) + 1) + if not decoder.eof or decoder.unconsumed_tail or time.monotonic() >= deadline: + raise Failed("Passive PNG decompression exceeds its size or time budget") if len(stream) != height * (stride + 1): raise Failed(f"Incomplete PNG pixels: {path.name}") pixels = bytearray(height * stride) previous = bytearray(stride) for y in range(height): + if deadline is not None and time.monotonic() >= deadline: + raise Failed("Passive capture deadline reached") start = y * (stride + 1) filter_type = stream[start] row = bytearray(stream[start + 1:start + 1 + stride]) for x in range(stride): + if deadline is not None and x % 4096 == 0 and time.monotonic() >= deadline: + raise Failed("Passive capture deadline reached") left = row[x - channels] if x >= channels else 0 up = previous[x] corner = previous[x - channels] if x >= channels else 0 @@ -204,7 +224,10 @@ def png_pixels(path: Path) -> tuple[int, int, bytes, int]: row[x] = (row[x] + value) & 255 pixels[y * stride:(y + 1) * stride] = row previous = row - return width, height, bytes(pixels), channels + result = width, height, bytes(pixels), channels + if deadline is not None and time.monotonic() >= deadline: + raise Failed("Passive capture deadline reached") + return result def pixel(image: tuple[int, int, bytes, int], x: int, y: int) -> tuple[int, int, int]: @@ -408,6 +431,7 @@ def __init__(self, args: argparse.Namespace): self.last_command_returncode = None self.last_command_stderr = "" self.execution_deadline = 0.0 + self.passive_deadline = None self.waiting_marker = None self.summary = {"status": "not_run", "run_id": self.run_id, "source": "actual-apk-adb-cloud-device", "serial": args.serial, @@ -441,6 +465,24 @@ def command(self, *parts: str, check: bool = True, timeout: int = 20) -> bytes: command = self.device_prefix + list(parts) self.last_command_returncode = None self.last_command_stderr = "" + if self.passive_deadline is not None: + png = parts[:3] == ("exec-out", "screencap", "-p") or ( + parts[:3] == ("exec-out", "run-as", self.args.package) and parts[-1].endswith(".png")) + report, raw = bounded_failure_command(command, min(self.passive_deadline, time.monotonic() + timeout), + byte_limit=8 * 1024 * 1024 if png else 65536) + self.last_command_returncode = report.get("returncode") + with (self.output / "commands.jsonl").open("a") as log: + log.write(json.dumps({"command": command, "returncode": self.last_command_returncode, + "passive_diagnostic_only": True, "time": time.time()}) + "\n") + if (report.get("reason") in ("command_timeout", "stdout_limit", "diagnostic_deadline") + or report.get("cleanup_permission_denied") or report.get("collector_not_reaped") + or time.monotonic() >= self.passive_deadline): + raise Failed("Passive device read exceeded its bounded budget") + if report.get("status") != "available": + if not check and report.get("returncode") is not None: + return b"" # Unavailable observer/checkpoint data is not readiness proof. + raise Failed("Passive device read is unavailable") + return raw try: result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) except (OSError, subprocess.TimeoutExpired) as exc: @@ -489,9 +531,11 @@ def wait(self, description: str, condition, timeout: int | None = None): if row.get("kind") == "failure": raise Failed(f"Actual mobile observer failed: {row.get('message')}") result = condition() + if self.passive_deadline is not None and time.monotonic() >= expires: + raise Failed("Passive observation deadline reached") if result: return result - time.sleep(0.4) + time.sleep(min(0.4, max(0, expires - time.monotonic())) if self.passive_deadline is not None else 0.4) raise Failed(f"Timed out waiting for {description}; execution is not accepted") def wait_game(self, stage: str) -> dict: @@ -506,7 +550,7 @@ def wait_observer(self, kind: str, **fields) -> dict: def screenshot(self, name: str) -> Path: path = self.output / f"screen-{name}.png" path.write_bytes(self.command("exec-out", "screencap", "-p")) - png_pixels(path) + png_pixels(path, deadline=self.passive_deadline) return path def foreground(self) -> None: @@ -527,10 +571,10 @@ def capture(self, stage: str, *, screenshot_name: str | None = None) -> tuple[di relative = "files/renpy-device-evidence/provider-" + stage + ".png" provider_path = self.output / ("provider-" + stage + ".png") provider_path.write_bytes(self.private(relative, required=True)) - provider = png_pixels(provider_path) + provider = png_pixels(provider_path, deadline=self.passive_deadline) self.foreground() screen_path = self.screenshot(screenshot_name or stage) - screen = png_pixels(screen_path) + screen = png_pixels(screen_path, deadline=self.passive_deadline) stats = {"provider": image_stats(provider), "os_screenshot": image_stats(screen)} box, viewport = row["drawn_box"], row["viewport_size"] if min(viewport) <= 0 or min(box[2:]) <= 0: @@ -568,6 +612,8 @@ def capture(self, stage: str, *, screenshot_name: str | None = None) -> tuple[di raise Failed("Native marker did not change after actual resumed OS touch; cached WAIT pixels cannot pass") if stage == "post_text_ready": self.waiting_marker = provider, checkpoint + if self.passive_deadline is not None and time.monotonic() >= self.passive_deadline: + raise Failed("Passive capture deadline reached") self.summary["checks"].append({"stage": screenshot_name or stage, "native_provider_pixels_and_os_screen": stats}) return row, screen @@ -633,6 +679,10 @@ def run(self) -> None: (self.output / "launch.txt").write_text(launch) if (not re.search(r"(?im)^\s*Status:\s*ok\s*$", launch) or re.search(r"(?im)^\s*(?:Error\b|Exception\b|Failure\b)", launch + "\n" + self.last_command_stderr)): + statuses = re.findall(r"(?im)^\s*Status:\s*(\S+)\s*$", launch) + if (self.last_command_returncode == 0 and statuses == ["timeout"] + and not re.search(r"(?im)^\s*(?:Error\b|Exception\b|Failure\b)", launch + "\n" + self.last_command_stderr)): + self.observe_timed_out_start(component) raise Failed("Actual am start did not report Status: ok without Android errors; inspect launch.txt and commands.jsonl") self.pid = self.wait("installed app process", self.observed_pid) pids = self.pid.split() @@ -709,6 +759,58 @@ def run(self) -> None: self.summary["checks"].append({"native_cleanup_error_log": require_engine_log(raw, self.game_root)}) self.summary["status"] = "passed" + def observe_timed_out_start(self, component: str) -> None: + """One finite read-only observation, never a launch/gameplay retry.""" + started = time.monotonic() + deadline = min(started + 30, self.execution_deadline) + report = {"source": "actual-adb-passive-startup-observation", "diagnostic_only": True, + "run_id": self.run_id, "package": self.args.package, "serial": self.args.serial, + "apk_sha256": self.summary.get("apk_sha256"), "activity": component, + "status": "diagnostic_notavailable", "window_seconds": max(0, deadline - started), + "start_ready": False, "native_rgba_and_os_pixels": False} + previous_deadline, previous_passive = self.execution_deadline, self.passive_deadline + previous_checks = self.summary["checks"] + install = self.summary["actual_status"]["apk_install"] + try: + if (not self.installed or self.summary.get("apk_installed") is not True + or install.get("status") != "passed" or install.get("source") != "actual-adb-command" + or install.get("package") != self.args.package or install.get("serial") != self.args.serial + or type(install.get("returncode")) is not int or install["returncode"] != 0 + or install.get("stdout_marker") != "Success" or deadline <= started + or len(component) > 256 or not re.fullmatch(r"[A-Za-z0-9_.$]+/[A-Za-z0-9_.$]+", component) + or not component.startswith(self.args.package + "/")): + return + self.execution_deadline = self.passive_deadline = deadline + self.summary["checks"] = [] + pids = self.observed_pid().split() + if not pids or len(pids) > 8 or len(set(pids)) != len(pids): + return + states = failure_activity_states(self.shell("dumpsys", "activity", "activities"), component) + if not any(row.get("state") == "RESUMED" for row in states): + return + report.update(diagnostic_pids=pids, activity_states=states) + self.wait_game("start_ready") + report["start_ready"] = True + if set(self.observed_pid().split()) != set(pids): + return + frame, _ = self.capture("start_ready", screenshot_name="passive-start-ready") + if set(self.observed_pid().split()) != set(pids) or time.monotonic() >= deadline: + return + report.update(status="diagnostic_available", native_rgba_and_os_pixels=True, + frame_serial=frame["frame_serial"], pixels=self.summary["checks"]) + except Exception: + # Optional diagnostics must never replace the original launch + # failure, including malformed observer data in shared capture. + report["observation_unavailable"] = True + finally: + self.summary["checks"] = previous_checks + self.execution_deadline, self.passive_deadline = previous_deadline, previous_passive + report.update(elapsed_seconds=round(time.monotonic() - started, 3), + deadline_reached=time.monotonic() >= deadline) + if report["deadline_reached"]: + report.update(status="diagnostic_notavailable", native_rgba_and_os_pixels=False) + self.summary["passive_startup_observation"] = report + def observed_pid(self) -> str: raw = self.shell("pidof", self.args.package, check=False).decode().strip() pids = raw.split() diff --git a/tools/test_renpy_device_evidence_metrics.py b/tools/test_renpy_device_evidence_metrics.py index 2986c6b82..1b69514e6 100644 --- a/tools/test_renpy_device_evidence_metrics.py +++ b/tools/test_renpy_device_evidence_metrics.py @@ -17,10 +17,12 @@ import json import shlex import subprocess +import struct import sys import tempfile import time import unittest +import zlib from pathlib import Path from unittest import mock @@ -433,13 +435,16 @@ class AndroidPartialFailureReporting(unittest.TestCase): def run_controlled_failure(self, *, install=(0, b"Performing streamed install\nSuccess\n", b""), launch=(0, b"Status: ok\n", b""), pid=(0, b"4343\n", b""), - stage_failure=False, cloud=True): + stage_failure=False, cloud=True, passive_ready=False, activity_state="RESUMED", + malformed_capture=False): with tempfile.TemporaryDirectory(prefix="renpy-controlled-partial-report-") as temporary: root = Path(temporary) apk = root / "controlled-not-installable.apk" apk.write_bytes(b"Explicit unit fixture, not an installable APK") output = root / "evidence" snapshots = [] + pid_responses = iter(pid) if isinstance(pid, list) else itertools.repeat(pid) + actual_capture = android.Acceptance.capture def command_result(argv, **kwargs): parts = argv[3:] # Controlled adb executable, -s, serial. @@ -460,13 +465,35 @@ def command_result(argv, **kwargs): snapshots.append(json.loads((output / "result.json").read_text())) result = launch elif shell[:1] == ["pidof"]: - result = pid + result = next(pid_responses, (0, b"", b"")) + elif shell == ["dumpsys", "activity", "activities"]: + result = (0, ("ActivityRecord{x u0 " + self.package + "/.ControlledActivity t1}\n" + " state=" + activity_state + " firstWindowDrawn=false\n").encode(), b"") return subprocess.CompletedProcess(argv, result[0], result[1], result[2]) def runtime_failure(stage): snapshots.append(json.loads((output / "result.json").read_text())) + if passive_ready: + return {"source": "renpy-script", "run_id": snapshots[-1]["run_id"], "stage": stage} raise Failed("Controlled regression: Ren'Py startup rejected") + def diagnostic_read(argv, deadline, **kwargs): + result = command_result(argv) + return {"status": "available" if result.returncode == 0 else "not_available", + "returncode": result.returncode}, result.stdout if result.returncode == 0 else b"" + + def controlled_capture(acceptance, stage, **kwargs): + self.assertTrue(passive_ready) + self.assertIsNotNone(acceptance.passive_deadline) + if malformed_capture: + with mock.patch.object(acceptance, "wait_observer", return_value={ + "source": "native-provider-rgba", "provider_debug": None, "frame_serial": 1}): + return actual_capture(acceptance, stage, **kwargs) + # This controlled boundary fixture does not execute native + # rendering; the enclosing run must retain its launch failure. + acceptance.summary["checks"].append({"controlled_unit_fixture": True}) + return {"frame_serial": 1}, () + argv = ["controlled-harness", "--apk", str(apk), "--serial", self.serial, "--package", self.package, "--adb", "controlled-only-adb", "--output-dir", str(output), "--stage-timeout", "5", "--overall-timeout", "60"] @@ -477,11 +504,13 @@ def runtime_failure(stage): with (mock.patch.object(sys, "argv", argv), mock.patch.object(android.shutil, "which", return_value="/controlled-only/adb"), mock.patch.object(android.subprocess, "run", side_effect=command_result) as commands, - mock.patch.object(android.time, "monotonic", side_effect=lambda: float(next(clock))), + mock.patch.object(android.time, "monotonic", side_effect=lambda: float(next(clock)) / (10 if passive_ready else 1)), mock.patch.object(android.time, "sleep"), + mock.patch.object(android, "bounded_failure_command", side_effect=diagnostic_read), mock.patch.object(android.Acceptance, "stage", side_effect=Failed("Controlled regression: staging rejected") if stage_failure else None) as staging, mock.patch.object(android.Acceptance, "wait_game", side_effect=runtime_failure), + mock.patch.object(android.Acceptance, "capture", autospec=True, side_effect=controlled_capture), contextlib.redirect_stdout(stream)): code = android.main() result = json.loads((output / "result.json").read_text()) @@ -523,6 +552,9 @@ def test_am_start_error_or_missing_status_cannot_claim_process(self): for response in [(1, b"Status: ok\n", b"controlled error"), (0, b"Status: ok\nError type 3\n", b""), (0, b"Status: ok\n", b"Error: controlled denial\n"), + (1, b"Status: timeout\n", b"controlled error"), + (0, b"Status: timeout\n", b"Error: controlled denial\n"), + (0, b"Status: timeout\nStatus: timeout\n", b""), (0, b"Starting: Intent { controlled }\n", b"")]: with self.subTest(response=response): code, report, progress, _, _, _ = self.run_controlled_failure(launch=response) @@ -558,6 +590,90 @@ def test_runtime_failure_retains_both_command_milestones(self): self.assertEqual(launch["activity"], self.package + "/.ControlledActivity") self.assertEqual((launch["pids"], launch["pid_count"]), (["4343"], 1)) + def test_passive_timeout_capture_cannot_promote_launch_or_gameplay(self): + code, report, progress, _, commands, _ = self.run_controlled_failure( + launch=(0, b"Status: timeout\n", b""), passive_ready=True) + self.assertEqual((code, report["status"]), (1, "failed")) + self.assertIn("Actual am start did not report Status: ok", report["blocker"]) + self.assertTrue(report["apk_installed"]) + self.assertFalse(report["process_started"]) + self.assertEqual(report["checks"], []) + self.assertEqual(report["actual_status"]["app_launch"]["status"], "failed") + observation = report["passive_startup_observation"] + self.assertTrue(observation["diagnostic_only"] and observation["native_rgba_and_os_pixels"]) + self.assertEqual(observation["diagnostic_pids"], ["4343"]) + self.assertEqual((observation["run_id"], observation["package"], observation["serial"], observation["apk_sha256"]), + (report["run_id"], self.package, self.serial, report["apk_sha256"])) + self.assertLessEqual(observation["elapsed_seconds"], 30) + self.assertEqual([p["stage"] for p in progress], ["apk_install"]) + shell_commands = [shlex.split(c.args[0][4]) for c in commands if len(c.args[0]) > 4 and c.args[0][3] == "shell"] + self.assertEqual(sum(c[:2] == ["am", "start"] for c in shell_commands), 1) + self.assertFalse(any(c[:1] == ["input"] for c in shell_commands)) + # Exercise the actual shared capture's AttributeError on legal JSON + # with provider_debug:null; it must not mask the original am failure. + code, report, _, _, _, _ = self.run_controlled_failure( + launch=(0, b"Status: timeout\n", b""), passive_ready=True, malformed_capture=True) + self.assertEqual((code, report["status"]), (1, "failed")) + self.assertIn("Actual am start did not report Status: ok", report["blocker"]) + self.assertFalse(report["process_started"]) + self.assertEqual(report["checks"], []) + self.assertEqual(report["actual_status"]["app_launch"]["status"], "failed") + self.assertTrue(report["passive_startup_observation"]["observation_unavailable"]) + self.assertFalse(report["passive_startup_observation"]["native_rgba_and_os_pixels"]) + + def test_passive_timeout_needs_current_valid_pid_and_exact_resumed_record(self): + for pid, state in [((0, b"", b""), "RESUMED"), ((1, b"4343\n", b""), "RESUMED"), + ((0, b"4343 4343\n", b""), "RESUMED"), ((0, b"9999999999\n", b""), "RESUMED"), + ((0, b"4343\n", b""), "STARTED"), + ([(0, b"4343\n", b""), (0, b"4545\n", b"")], "RESUMED"), + ([(0, b"4343\n", b""), (0, b"4343\n", b""), (0, b"4545\n", b"")], "RESUMED")]: + with self.subTest(pid=pid, state=state): + code, report, _, _, _, _ = self.run_controlled_failure( + launch=(0, b"Status: timeout\n", b""), pid=pid, activity_state=state, passive_ready=True) + self.assertEqual((code, report["status"]), (1, "failed")) + self.assertFalse(report["process_started"]) + self.assertFalse(report["passive_startup_observation"]["native_rgba_and_os_pixels"]) + + def test_passive_read_real_child_respects_short_remaining_deadline(self): + with tempfile.TemporaryDirectory() as temporary: + args = type("Args", (), {"output_dir": Path(temporary), "package": self.package, + "serial": self.serial, "adb": "not-an-adb"})() + acceptance = android.Acceptance(args) + acceptance.device_prefix = [sys.executable, "-c", "import time; print('4343',flush=True); time.sleep(10)"] + started = time.monotonic() + acceptance.passive_deadline = started + 0.3 + original, children = subprocess.Popen, [] + def owned_child(*parts, **kwargs): + child = original(*parts, **kwargs) + children.append(child) + return child + with mock.patch.object(android.subprocess, "Popen", side_effect=owned_child), self.assertRaises(Failed): + acceptance.command("shell", "pidof controlled-only", check=False) + self.assertLess(time.monotonic() - started, 1) + self.assertIsNotNone(children[0].poll()) + self.assertEqual(acceptance.pid, "") + self.assertFalse(acceptance.summary["process_started"]) + + def test_passive_png_deadline_is_optional_and_bounds_actual_decode(self): + def png(width, height): + def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress((b"\0" + bytes([30, 90, 180]) * width) * height)) + chunk(b"IEND", b"")) + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "controlled-generated.png" + path.write_bytes(png(4, 4)) + ordinary = android.png_pixels(path) + self.assertEqual(android.png_pixels(path, deadline=time.monotonic() + 1), ordinary) + path.write_bytes(png(1200, 800)) + started = time.monotonic() + with self.assertRaisesRegex(Failed, "deadline"): + android.png_pixels(path, deadline=started + 0.01) + self.assertLess(time.monotonic() - started, 1) + path.write_bytes(png(2000, 1600)) # Legal normal dimensions, above the passive-only decoded byte cap. + with self.assertRaisesRegex(Failed, "decoded pixel budget"): + android.png_pixels(path, deadline=time.monotonic() + 1) + if __name__ == "__main__": unittest.main(verbosity=2) From 0ff10e3018177f12d21d66e08036c1b5e025b833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 10:07:54 +0000 Subject: [PATCH 325/331] fix(android): prefer emulator renderer before platform default --- apps/godot_app/project.godot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/godot_app/project.godot b/apps/godot_app/project.godot index 77c5d78f7..354b7bcbf 100644 --- a/apps/godot_app/project.godot +++ b/apps/godot_app/project.godot @@ -49,6 +49,6 @@ rendering_device/fallback_to_opengl3=true rendering/gl_compatibility/driver.android="opengl3" renderer/rendering_method="mobile" textures/vram_compression/import_etc2_astc=true -renderer/rendering_method.android="mobile" renderer/rendering_method.renpy_emulator="gl_compatibility" +renderer/rendering_method.android="mobile" renderer/rendering_method.ios_simulator="gl_compatibility" From 9c901dd1f6cb0e08199db1bc077617379bf9de44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 10:30:53 +0000 Subject: [PATCH 326/331] test(android): run gameplay against the renderer-fixed APK --- .../workflows/renpy-android-hvf-gameplay.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/renpy-android-hvf-gameplay.yml b/.github/workflows/renpy-android-hvf-gameplay.yml index da64b844a..cac4feb49 100644 --- a/.github/workflows/renpy-android-hvf-gameplay.yml +++ b/.github/workflows/renpy-android-hvf-gameplay.yml @@ -1,6 +1,6 @@ name: Ren'Py Android Intel HVF Gameplay -# Execute the previously compiled, validated 69cceb1 APK. The current checkout +# Execute the renderer-fixed APK compiled and validated at 0ff10e3. The current checkout # supplies the actual device harness; this job does not rebuild the APK. on: pull_request: @@ -28,13 +28,13 @@ jobs: runs-on: macos-15-intel timeout-minutes: 45 env: - APK_RUN: '37871746668' - APK_JOB: '113631187733' - APK_HEAD: '69cceb1c2509ad87461bc5461550cb430a00e415' - APK_ARTIFACT: '11590609797' - APK_ARTIFACT_DIGEST: 'sha256:7115ac90acb74631612f9eb11f23d43361f7dcd6c29861eff065f8e8c702f614' + APK_RUN: '37915707195' + APK_JOB: '113771235880' + APK_HEAD: '0ff10e3018177f12d21d66e08036c1b5e025b833' + APK_ARTIFACT: '11610231057' + APK_ARTIFACT_DIGEST: 'sha256:ca3c5f3ba4c352bf7b995ac6e11a15fce9476aea4155e4c9a339ce7e50a6efc9' APK_NATIVE_SHA256: '2ff2cd7e484a199a7d387704249b34b7a0ec6114a384931ed845ea25c1a2f3b5' - APK_SIGNER_SHA256: '0f56095ede9f28aa458e537160d88fea01ccb3ffe57cd56d57a661fe468b2586' + APK_SIGNER_SHA256: '2830f93a34ea5eceef0dc91389a3718668e286db1368bc0d1d55348bcf0b9b91' steps: - uses: actions/checkout@v6 with: @@ -103,8 +103,8 @@ jobs: - name: Download the exact validated APK through the official action uses: actions/download-artifact@v6 with: - artifact-ids: 11590609797 - run-id: 37871746668 + artifact-ids: 11610231057 + run-id: 37915707195 repository: AetherKiri/Aether github-token: ${{ github.token }} path: ${{ runner.temp }}/renpy-hvf-apk From bf22bdd8f1ef84ca4b3b079baa10194a84f404db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 11:06:10 +0000 Subject: [PATCH 327/331] fix(ios): accept the pinned public GLib launch helper cache --- tools/ios_public_vcpkg_cache.py | 25 ++++- tools/test_ios_public_vcpkg_cache.py | 144 ++++++++++++++++++++++++++- 2 files changed, 159 insertions(+), 10 deletions(-) diff --git a/tools/ios_public_vcpkg_cache.py b/tools/ios_public_vcpkg_cache.py index 118e4bec1..e0f21dfcb 100644 --- a/tools/ios_public_vcpkg_cache.py +++ b/tools/ios_public_vcpkg_cache.py @@ -30,8 +30,8 @@ HEX = re.compile(r"[0-9a-f]{64}\Z") PORT = re.compile(r"[a-z0-9][a-z0-9-]*\Z") TOP_LEVEL = {"include", "lib", "bin", "debug", "share", "tools", "etc"} -# Diagnostic labels only; these directories remain rejected. Other root names -# are hashed, so no arbitrary filename is echoed into the Actions console. +# Diagnostic labels only, not a root allowlist. Other root names are hashed, +# so no arbitrary filename is echoed into the Actions console. ROOT_DIAGNOSTIC_LABELS = { "manual-tools", "plugins", "libexec", "sbin", "man", "doc", "docs", "cmake", "var", "Frameworks", "Applications", "sdk", @@ -244,7 +244,7 @@ def validate_archive(path: Path, ports: set[str], triplets: set[str]) -> dict: require(sum(i.file_size for i in infos) <= MAX_EXPANDED_BYTES, "archive-expanded-size") members: dict[str, zipfile.ZipInfo] = {} links: dict[str, str] = {} - rejected_root = None + unexpected_members = [] for i in infos: n = safe_member(i.filename) require(n not in members, "duplicate-member") @@ -252,8 +252,8 @@ def validate_archive(path: Path, ports: set[str], triplets: set[str]) -> dict: mode = stat.S_IFMT(i.external_attr >> 16) require(mode in (0, stat.S_IFREG, stat.S_IFDIR, stat.S_IFLNK), "special-member") root = n.split("/", 1)[0] - if rejected_root is None and n not in ("CONTROL", "BUILD_INFO") and root not in TOP_LEVEL: - rejected_root = root + if n not in ("CONTROL", "BUILD_INFO") and root not in TOP_LEVEL: + unexpected_members.append(n) members[n] = i if mode == stat.S_IFLNK: require(i.file_size <= MAX_METADATA, "symlink-too-large") @@ -308,6 +308,21 @@ def resolve_link(n: str, seen: set[str]) -> str: abi_info = z.read(abi_path) require(hashlib.sha256(abi_info).hexdigest() == abi, "abi-info-digest-mismatch") require(f"triplet {triplet}\n".encode() in abi_info.splitlines(keepends=True), "abi-info-triplet-mismatch") + # GLib 2.84.2's non-Cocoa GIO branch installs this helper in libexec. + # The pinned public 2.84.2#2 port retains it and disables installed + # tests. Permit its exact regular-file payload after package/ABI gates; + # this does not allow a general libexec subtree or another package. + helper = "libexec/gio-launch-desktop" + glib_helper = (name == "glib" and core.get("Version") == "2.84.2" + and core.get("Port-Version") == "2" and helper in members + and helper not in links and not members[helper].is_dir() + and stat.S_IFMT(members[helper].external_attr >> 16) in (0, stat.S_IFREG)) + rejected_root = None + for n in unexpected_members: + if glib_helper and (n == helper or (n == "libexec" and members[n].is_dir())): + continue + rejected_root = n.split("/", 1)[0] + break if rejected_root is not None: metadata = {"abi": abi, "package": name, "triplet": triplet} if rejected_root in ROOT_DIAGNOSTIC_LABELS: diff --git a/tools/test_ios_public_vcpkg_cache.py b/tools/test_ios_public_vcpkg_cache.py index 61d31552a..b29bfcd83 100644 --- a/tools/test_ios_public_vcpkg_cache.py +++ b/tools/test_ios_public_vcpkg_cache.py @@ -36,6 +36,8 @@ def main() -> int: parser.add_argument("--cmake", type=Path, required=True) parser.add_argument("--ninja", type=Path, required=True) parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--glib-source", type=Path, + help="Genuine GLib 2.84.2 Git checkout for its libexec helper/provider control") args = parser.parse_args() root = args.output_dir.resolve() check(not root.exists(), "Use a fresh output directory.") @@ -149,6 +151,100 @@ def execute(value: int) -> str: "Successful package archive was not flushed before normal vcpkg failure.") execute(9) + glib_evidence = {"status": "not_run"} + glib_record = None + glib_original = None + if args.glib_source: + # Compile the unmodified upstream helper, not a dummy with the same + # filename. This narrow package fixture tests its real install layout + # and standard files-provider roundtrip, not the full GLib library. + glib_source = args.glib_source.resolve() + glib_head = "2371bee17d85318480b3ddeeab4f5107b4889ad7" + check(cache.run(["git", "rev-parse", "HEAD"], glib_source) == glib_head, + "Use the genuine pinned GLib 2.84.2 source checkout.") + files = ("gio/gio-launch-desktop.c", "glib/gjournal-private.c", + "glib/gjournal-private.h", "glib/gmacros.h") + glib_root = root / "glib-helper-provider" + glib_port = glib_root / "ports/glib" + glib_package_source = glib_port / "source" + source_hashes = {} + for filename in files: + data = (glib_source / filename).read_bytes() + check(data == subprocess.check_output(["git", "show", f"HEAD:{filename}"], cwd=glib_source), + "The pinned GLib helper source was modified.") + destination = glib_package_source / filename + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(data) + source_hashes[filename] = hashlib.sha256(data).hexdigest() + production_manifest = json.loads((Path(__file__).resolve().parents[1] / + "vcpkg/ports/glib/vcpkg.json").read_text()) + check((production_manifest["version"], production_manifest["port-version"]) == ("2.84.2", 2), + "The production GLib pin changed; review the helper exception.") + (glib_port / "vcpkg.json").write_text(json.dumps({ + "name": "glib", "version": "2.84.2", "port-version": 2, + "description": "Pinned public GLib helper package fixture, not the full library", + "dependencies": [{"name": "vcpkg-cmake", "host": True}], + }) + "\n") + (glib_port / "portfile.cmake").write_text('''vcpkg_cmake_configure(SOURCE_PATH "${CMAKE_CURRENT_LIST_DIR}/source") +vcpkg_cmake_install() +file(INSTALL "${CMAKE_CURRENT_LIST_DIR}/copyright" DESTINATION "${CURRENT_PACKAGES_DIR}/share/${PORT}") +''') + shutil.copyfile(glib_source / "LICENSES/LGPL-2.1-or-later.txt", glib_port / "copyright") + (glib_package_source / "CMakeLists.txt").write_text('''cmake_minimum_required(VERSION 3.20) +project(glib_helper C) +add_executable(gio-launch-desktop gio/gio-launch-desktop.c) +target_include_directories(gio-launch-desktop PRIVATE glib) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_sources(gio-launch-desktop PRIVATE glib/gjournal-private.c) +endif() +install(TARGETS gio-launch-desktop RUNTIME DESTINATION libexec) +''') + (glib_root / "vcpkg.json").write_text(json.dumps({ + "name": "glib-helper-cache-regression", "version": "1.0.0", "dependencies": ["glib"], + }) + "\n") + glib_archives = glib_root / "archives" + glib_env = dict(env, VCPKG_BINARY_SOURCES=f"clear;files,{glib_archives},readwrite") + glib_command = [str(vcpkg_root / "vcpkg"), "install", f"--triplet={triplet}", + f"--host-triplet={triplet}", f"--overlay-ports={glib_root / 'ports'}", + f"--x-install-root={glib_root / 'installed'}", + f"--x-buildtrees-root={glib_root / 'buildtrees'}", + f"--x-packages-root={glib_root / 'packages'}", + f"--downloads-root={root / 'downloads'}", "--disable-metrics"] + helper_hash = None + for stage in ("cold-build", "normal-manifest-restore"): + for name in ("installed", "packages", "buildtrees"): + shutil.rmtree(glib_root / name, ignore_errors=True) + with (glib_root / f"{stage}.log").open("w") as f: + subprocess.run(glib_command, cwd=glib_root, env=glib_env, + stdout=f, stderr=subprocess.STDOUT, check=True) + text = (glib_root / f"{stage}.log").read_text() + if stage == "cold-build": + check("Building glib" in text and "Restored 0 package(s)" in text, + "The genuine GLib helper was not source compiled.") + else: + check("Restored 2 package(s)" in text and "Building glib" not in text, + "The genuine GLib helper package was not restored normally.") + report = cache.validate_cache(glib_archives, production_ports, {triplet}) + check(report["archive_count"] == 2, "Expected real GLib helper and vcpkg-cmake archives.") + (glib_root / f"{stage}-archives.json").write_text(json.dumps(report, indent=2) + "\n") + helper = glib_root / f"installed/{triplet}/libexec/gio-launch-desktop" + output = subprocess.check_output([str(helper), sys.executable, "-I", "-c", + "import os; print(os.getenv('GIO_LAUNCHED_DESKTOP_FILE_PID', ''))"], + text=True).strip() + check(output.isdigit() and int(output) > 0, "Actual GLib helper execution did not set the child PID.") + current_hash = cache.digest(helper) + check(helper_hash is None or helper_hash == current_hash, "Restored helper bytes changed.") + helper_hash = current_hash + glib_record = next(a for a in report["archives"] if a["package"] == "glib") + glib_zip = glib_archives / glib_record["abi"][:2] / (glib_record["abi"] + ".zip") + with zipfile.ZipFile(glib_zip) as z: + glib_original = [(i, z.read(i)) for i in z.infolist()] + check("libexec/gio-launch-desktop" in z.namelist(), "Real provider omitted the public helper.") + glib_evidence = {"status": "passed", "scope": "pinned_glib_helper_package_not_full_glib", + "source_head": glib_head, "source_sha256": source_hashes, + "archive": glib_record, "helper_sha256": helper_hash, + "actual_helper_execution": "passed", "files_provider_restore": "passed"} + # Derive rejection controls from a real successfully produced archive. controls = root / "rejection-controls" controls.mkdir() @@ -168,18 +264,20 @@ def execute(value: int) -> str: check(cache.validate_cache(zip64_dir.parent, ports, {triplet})["archive_count"] == 1, "Standard ZIP64 container rejected.") rejected = [] - def reject(label: str, edit, expected: str, allowed_triplets=None) -> cache.InvalidCache: - d = controls / label / probe_record["abi"][:2] + def reject(label: str, edit, expected: str, allowed_triplets=None, *, glib=False) -> cache.InvalidCache: + record = glib_record if glib else probe_record + entries_source = glib_original if glib else original + d = controls / label / record["abi"][:2] d.mkdir(parents=True) - path = d / probe_zip.name - entries = edit([(i, b) for i, b in original]) + path = d / (record["abi"] + ".zip") + entries = edit([(i, b) for i, b in entries_source]) with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) with zipfile.ZipFile(path, "w") as z: for info, data in entries: z.writestr(info, data) try: - cache.validate_cache(d.parent, ports, allowed_triplets or {triplet}) + cache.validate_cache(d.parent, production_ports if glib else ports, allowed_triplets or {triplet}) except cache.InvalidCache as e: check(str(e) == expected, f"Wrong rejection reason in {label}.") failure = e @@ -211,6 +309,41 @@ def edit(es): reject("escaping-symlink", link_edit(b"../../outside"), "escaping-symlink") reject("symlink-cycle", link_edit(b"unsafe-link"), "symlink-cycle") reject("source-cache-root", lambda es: es + [(zipfile.ZipInfo("buildtrees/compiler.log"), b"x")], "unexpected-package-root") + reject("other-package-libexec", lambda es: es + [ + (zipfile.ZipInfo("libexec/gio-launch-desktop"), b"x")], "unexpected-package-root") + if glib_original: + reject("glib-libexec-extra-member", lambda es: es + [ + (zipfile.ZipInfo("libexec/unknown-helper"), b"x")], "unexpected-package-root", glib=True) + reject("glib-libexec-other-version", control_edit(b"Version: 2.84.2", b"Version: 2.84.3"), + "unexpected-package-root", glib=True) + reject("glib-libexec-other-port-version", control_edit(b"Port-Version: 2", b"Port-Version: 3"), + "unexpected-package-root", glib=True) + reject("glib-libexec-unverified-abi", control_edit(glib_record["abi"].encode(), b"0" * 64), + "control-abi-mismatch", glib=True) + reject("glib-libexec-missing-helper", lambda es: [ + (i, b) for i, b in es if i.filename != "libexec/gio-launch-desktop"], + "unexpected-package-root", glib=True) + def helper_directory(es): + result = [] + for info, data in es: + if info.filename == "libexec/gio-launch-desktop": + info = zipfile.ZipInfo(info.filename) + info.create_system = 3 + info.external_attr = (stat.S_IFDIR | 0o755) << 16 + result.append((info, data)) + return result + reject("glib-libexec-directory-mode", helper_directory, "unexpected-package-root", glib=True) + def helper_symlink(es): + result = [] + for info, data in es: + if info.filename == "libexec/gio-launch-desktop": + info = zipfile.ZipInfo(info.filename) + info.create_system = 3 + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + data = b"../share/glib/copyright" + result.append((info, data)) + return result + reject("glib-libexec-symlink", helper_symlink, "unexpected-package-root", glib=True) known_root = reject("diagnostic-known-root", lambda es: es + [ (zipfile.ZipInfo("manual-tools/aether-cache-probe/tool"), b"x")], "unexpected-package-root") check(known_root.metadata == {"abi": probe_record["abi"], "package": "aether-cache-probe", @@ -368,6 +501,7 @@ def edit(es): "nonsentinel_zip64_container_compatibility": "passed", "production_public_port_allowlist": "passed", "bounded_root_diagnostics": "passed", "rejection_does_not_publish_success": "passed", + "glib_libexec_helper_package": glib_evidence, "apple_sdk_cache": "not_run", "app": "not_run", "gameplay": "not_run"} (root / "evidence.json").write_text(json.dumps(result, indent=2, sort_keys=True) + "\n") print(json.dumps({k: result[k] for k in ("status", "files_provider_restore", "changed_port_abi", "partial_failure_flush", "apple_sdk_cache", "app", "gameplay")}, sort_keys=True)) From 8be1181db6c0305361e74ea13478355f26faaef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 11:11:57 +0000 Subject: [PATCH 328/331] fix(ios): select simulator Rust targets from canonical SDK paths --- bridge/rfvp_runtime/CMakeLists.txt | 20 +- .../siglus_runtime/cmake/BuildSiglusRs.cmake | 9 +- tools/test_ios_rust_targets.py | 239 ++++++++++++++++++ 3 files changed, 258 insertions(+), 10 deletions(-) create mode 100644 tools/test_ios_rust_targets.py diff --git a/bridge/rfvp_runtime/CMakeLists.txt b/bridge/rfvp_runtime/CMakeLists.txt index 36ac585eb..020b2218f 100644 --- a/bridge/rfvp_runtime/CMakeLists.txt +++ b/bridge/rfvp_runtime/CMakeLists.txt @@ -15,28 +15,34 @@ find_program(RFVP_CARGO cargo REQUIRED) find_program(RFVP_RUSTC rustc REQUIRED) execute_process(COMMAND "${RFVP_RUSTC}" -vV OUTPUT_VARIABLE rust_version COMMAND_ERROR_IS_FATAL ANY) string(REGEX MATCH "host: ([^\n]+)" unused "${rust_version}") -set(AETHERKIRI_RFVP_RUST_TARGET "${CMAKE_MATCH_1}" CACHE STRING "Rust target triple for rfvp") +set(rfvp_default_rust_target "${CMAKE_MATCH_1}") if(APPLE AND CMAKE_OSX_ARCHITECTURES) list(LENGTH CMAKE_OSX_ARCHITECTURES arch_count) if(NOT arch_count EQUAL 1) message(FATAL_ERROR "Build rfvp once per architecture before combining universal libraries") endif() if(IOS) - if(CMAKE_OSX_SYSROOT STREQUAL "iphonesimulator") + # Ninja and Makefile generators receive a resolved SDK path. Match + # its basename case-insensitively, including versioned Apple SDKs. + get_filename_component(rfvp_ios_sdk_name "${CMAKE_OSX_SYSROOT}" NAME) + string(TOLOWER "${rfvp_ios_sdk_name}" rfvp_ios_sdk_name) + if(rfvp_ios_sdk_name MATCHES "^iphonesimulator([0-9]+([.][0-9]+)*)?([.]sdk)?$") if(CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") - set(AETHERKIRI_RFVP_RUST_TARGET "aarch64-apple-ios-sim") + set(rfvp_default_rust_target "aarch64-apple-ios-sim") elseif(CMAKE_OSX_ARCHITECTURES STREQUAL "x86_64") - set(AETHERKIRI_RFVP_RUST_TARGET "x86_64-apple-ios") + set(rfvp_default_rust_target "x86_64-apple-ios") endif() elseif(CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") - set(AETHERKIRI_RFVP_RUST_TARGET "aarch64-apple-ios") + set(rfvp_default_rust_target "aarch64-apple-ios") endif() elseif(CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") - set(AETHERKIRI_RFVP_RUST_TARGET "aarch64-apple-darwin") + set(rfvp_default_rust_target "aarch64-apple-darwin") elseif(CMAKE_OSX_ARCHITECTURES STREQUAL "x86_64") - set(AETHERKIRI_RFVP_RUST_TARGET "x86_64-apple-darwin") + set(rfvp_default_rust_target "x86_64-apple-darwin") endif() endif() +# Compute the default first so an explicit cache override is not shadowed. +set(AETHERKIRI_RFVP_RUST_TARGET "${rfvp_default_rust_target}" CACHE STRING "Rust target triple for rfvp") include(cmake/PrepareRfvpSources.cmake) set(rfvp_prepared "${CMAKE_CURRENT_BINARY_DIR}/prepared") aetherkiri_prepare_rfvp("${AETHERKIRI_RFVP_PROJECT_ROOT}" "${AETHERKIRI_RFVP_DIR}" "${rfvp_prepared}") diff --git a/bridge/siglus_runtime/cmake/BuildSiglusRs.cmake b/bridge/siglus_runtime/cmake/BuildSiglusRs.cmake index bb68b29a1..ad61937a9 100644 --- a/bridge/siglus_runtime/cmake/BuildSiglusRs.cmake +++ b/bridge/siglus_runtime/cmake/BuildSiglusRs.cmake @@ -34,9 +34,12 @@ function(aetherkiri_deduce_rust_target out_triple) return() endif() if(IOS) - # CMAKE_OSX_ARCHITECTURES drives the device/simulator distinction; - # simulator builds may be arm64 or x86_64 depending on the host. - if(CMAKE_OSX_SYSROOT MATCHES "iphonesimulator") + # CMake resolves SDK aliases to paths for non-Xcode generators. + # Inspect the SDK basename, rather than a possibly misleading parent + # directory, and accept Apple's mixed-case/versioned SDK names. + get_filename_component(siglus_ios_sdk_name "${CMAKE_OSX_SYSROOT}" NAME) + string(TOLOWER "${siglus_ios_sdk_name}" siglus_ios_sdk_name) + if(siglus_ios_sdk_name MATCHES "^iphonesimulator([0-9]+([.][0-9]+)*)?([.]sdk)?$") if(CMAKE_OSX_ARCHITECTURES MATCHES "x86_64") set(${out_triple} "x86_64-apple-ios" PARENT_SCOPE) else() diff --git a/tools/test_ios_rust_targets.py b/tools/test_ios_rust_targets.py new file mode 100644 index 000000000..3732da633 --- /dev/null +++ b/tools/test_ios_rust_targets.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Exercise production Rust target selection and generated Cargo commands. + +The SDK names and Apple platform variables below are configuration fixtures. +This runs real CMake/Ninja and existing host Rust tools, without compiling an +Apple target, substituting an SDK/compiler, or executing a generated build. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess + + +def run(argv: list[str], cwd: Path, env: dict[str, str]) -> str: + result = subprocess.run(argv, cwd=cwd, env=env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=60, check=False) + if result.returncode: + raise RuntimeError(f"{shlex.join(argv)} failed ({result.returncode}):\n{result.stdout}") + return result.stdout + + +def quote(value: str | Path) -> str: + value = str(value) + if "]]" in value: + raise ValueError("unsupported CMake bracket delimiter in input") + return "[[" + value + "]]" + + +def fragment(source: str, start: str, end: str) -> str: + if source.count(start) != 1 or source.count(end) != 1: + raise RuntimeError("production CMake fragment boundaries changed") + return source[source.index(start):source.index(end)] + + +def cargo_target(commands: str, subcommand: str) -> str: + matches = [] + for line in commands.splitlines(): + args = shlex.split(line) + if any(Path(arg).name == "cargo" and i + 1 < len(args) + and args[i + 1] == subcommand for i, arg in enumerate(args)): + if args.count("--target") != 1: + raise RuntimeError("generated Cargo command lacks exactly one target") + matches.append(args[args.index("--target") + 1]) + if len(matches) != 1: + raise RuntimeError(f"expected one production Cargo {subcommand} command") + return matches[0] + + +def configure_case(root: Path, name: str, sdk: str, arch: str, + expected: str, rfvp_expected: str, override: str | None, + repository: Path, siglus_helper: Path, rfvp_source: str, + cmake: str, ninja: str, env: dict[str, str], ios: bool = True) -> dict: + source = root / name / "source" + binary = root / name / "build" + (source / "packages").mkdir(parents=True) + (source / "packages" / "AetherSiglus").symlink_to(repository / "packages/AetherSiglus", + target_is_directory=True) + selection = fragment(rfvp_source, "find_program(RFVP_CARGO cargo REQUIRED)", + "include(cmake/PrepareRfvpSources.cmake)") + build = fragment(rfvp_source, 'set(rfvp_features "', + "add_library(aether_rfvp_rust STATIC IMPORTED GLOBAL)") + (source / "rfvp-production-selection.cmake").write_text(selection) + (source / "rfvp-production-build.cmake").write_text(build) + production_siglus_dir = repository / "bridge/siglus_runtime" + project = f"""cmake_minimum_required(VERSION 3.28) +project(RustTargetAcceptance LANGUAGES NONE) +# Controlled configuration inputs, not an Apple compiler or SDK probe. +set(APPLE TRUE) +set(IOS {'TRUE' if ios else 'FALSE'}) +set(CMAKE_OSX_ARCHITECTURES {quote(arch)}) +set(CMAKE_OSX_SYSROOT {quote(sdk)}) +set(CMAKE_BUILD_TYPE Debug) +include({quote(siglus_helper)}) +aetherkiri_deduce_rust_target(selected_siglus) +function(generate_siglus) + set(CMAKE_CURRENT_SOURCE_DIR {quote(production_siglus_dir)}) + aetherkiri_add_siglus_rs(actual_siglus) + if(NOT actual_siglus_FOUND) + message(FATAL_ERROR "actual production Siglus target was skipped") + endif() + get_target_property(archive actual_siglus IMPORTED_LOCATION) + file(WRITE "${{CMAKE_BINARY_DIR}}/siglus-archive.txt" "${{archive}}") +endfunction() +generate_siglus() +set(AETHERKIRI_RFVP_PROJECT_ROOT {quote(repository)}) +set(AETHERKIRI_RFVP_DIR {quote(repository / 'packages/rfvp')}) +include("${{CMAKE_CURRENT_SOURCE_DIR}}/rfvp-production-selection.cmake") +set(rfvp_prepared "${{CMAKE_BINARY_DIR}}/prepared") +include("${{CMAKE_CURRENT_SOURCE_DIR}}/rfvp-production-build.cmake") +file(WRITE "${{CMAKE_BINARY_DIR}}/selected.txt" + "${{selected_siglus}}\n${{AETHERKIRI_RFVP_RUST_TARGET}}\n${{rfvp_library}}\n") +""" + (source / "CMakeLists.txt").write_text(project) + argv = [cmake, "-S", str(source), "-B", str(binary), "-G", "Ninja", + f"-DCMAKE_MAKE_PROGRAM={ninja}"] + if override is not None: + argv.append(f"-DAETHERKIRI_RFVP_RUST_TARGET:STRING={override}") + configure_log = run(argv, root, env) + (root / name / "configure.log").write_text(configure_log) + selected, rfvp_selected, rfvp_archive = (binary / "selected.txt").read_text().splitlines() + siglus_commands = run([ninja, "-C", str(binary), "-t", "commands", "siglus_rs_cargo_build"], root, env) + rfvp_commands = run([ninja, "-C", str(binary), "-t", "commands", "aether_rfvp_rust_build"], root, env) + actual = (selected, cargo_target(siglus_commands, "rustc"), + rfvp_selected, cargo_target(rfvp_commands, "build")) + wanted = (expected, expected, rfvp_expected, rfvp_expected) + if actual != wanted: + raise AssertionError((name, actual, wanted)) + siglus_archive = (binary / "siglus-archive.txt").read_text() + if f"/siglus-rs-target/{expected}/debug/libsiglus_scene_vm.a" not in siglus_archive: + raise AssertionError((name, siglus_archive)) + if f"/target/{rfvp_expected}/debug/librfvp.a" not in rfvp_archive: + raise AssertionError((name, rfvp_archive)) + # Reconfigure the genuine project to exercise the persistent cache too. + run(argv, root, env) + if (binary / "selected.txt").read_text().splitlines()[:2] != [expected, rfvp_expected]: + raise AssertionError("reconfigure changed the selected target") + return {"name": name, "sdk_fixture": sdk, "architecture": arch, + "siglus_target": selected, "rfvp_target": rfvp_selected, + "rfvp_explicit_override": override, "cargo_commands_generated": True, + "cache_reconfigure": "pass", "target_compile": "not_run"} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--cmake", default=shutil.which("cmake")) + parser.add_argument("--ninja", default=shutil.which("ninja")) + parser.add_argument("--baseline-commit", help="optional genuine pre-fix source for negative controls") + args = parser.parse_args() + repository = args.repository.resolve(strict=True) + root = args.output_dir.resolve() + root.mkdir(parents=True, exist_ok=False) + env = os.environ.copy() + for tool in (args.cmake, args.ninja, shutil.which("cargo"), shutil.which("rustc")): + if not tool or not Path(tool).is_file(): + raise RuntimeError("real CMake, Ninja, cargo and rustc must be installed") + siglus = repository / "bridge/siglus_runtime/cmake/BuildSiglusRs.cmake" + rfvp_path = repository / "bridge/rfvp_runtime/CMakeLists.txt" + rfvp = rfvp_path.read_text() + versions = {"cmake": run([args.cmake, "--version"], root, env).splitlines()[0], + "ninja": run([args.ninja, "--version"], root, env).strip(), + "rustc": run([shutil.which("rustc"), "-vV"], root, env), + "cargo": run([shutil.which("cargo"), "--version"], root, env).strip()} + cases = [] + for arch, expected in (("arm64", "aarch64-apple-ios-sim"), ("x86_64", "x86_64-apple-ios")): + for label, sdk in (("alias", "iphonesimulator"), ("mixed-alias", "iPhOnEsImUlAtOr"), + ("sdk", "/Applications/Xcode_26.6.app/SDKs/iPhoneSimulator26.5.sdk"), + ("spaces", "/Applications/Xcode Selected.app/SDKs/iPhOnEsImUlAtOr26.5.sdk"), + ("unversioned-sdk", "/Applications/Xcode.app/SDKs/iPhoneSimulator.sdk")): + cases.append((f"sim-{arch}-{label}", sdk, arch, expected, expected, None, True)) + for label, sdk in (("alias", "iphoneos"), ("mixed-alias", "iPhOnEoS"), + ("sdk", "/Applications/Xcode_26.6.app/SDKs/iPhoneOS26.5.sdk"), + ("spaces", "/Applications/Xcode Selected.app/SDKs/iPhOnEoS26.5.sdk"), + ("misleading-parent", "/tmp/iphonesimulator/iPhoneSimulator.platform/SDKs/iPhoneOS26.5.sdk")): + cases.append((f"device-{label}", sdk, "arm64", "aarch64-apple-ios", "aarch64-apple-ios", None, True)) + cases.extend([ + ("mac-arm64", "macosx", "arm64", "aarch64-apple-darwin", "aarch64-apple-darwin", None, False), + ("mac-x64", "macosx", "x86_64", "x86_64-apple-darwin", "x86_64-apple-darwin", None, False), + ("rfvp-explicit-sim", "/Applications/Xcode.app/SDKs/iPhoneSimulator26.5.sdk", "arm64", + "aarch64-apple-ios-sim", "x86_64-apple-ios", "x86_64-apple-ios", True), + ("rfvp-explicit-device", "iphoneos", "arm64", "aarch64-apple-ios", + "aarch64-apple-ios-sim", "aarch64-apple-ios-sim", True), + ]) + results = [configure_case(root, *case[:6], repository, siglus, rfvp, args.cmake, + args.ninja, env, ios=case[6]) for case in cases] + # Execute the unchanged non-Apple mapper branches with real CMake/rustc. + controls = root / "non-ios.cmake" + controls.write_text(f"""include({quote(siglus)}) +find_program(RUSTC_EXECUTABLE rustc REQUIRED) +function(check expected) + aetherkiri_deduce_rust_target(actual) + if(NOT actual STREQUAL expected) + message(FATAL_ERROR "${{actual}} != ${{expected}}") + endif() +endfunction() +set(WEB TRUE) +check(wasm32-unknown-unknown) +set(WEB FALSE) +set(EMSCRIPTEN TRUE) +check(wasm32-unknown-unknown) +set(EMSCRIPTEN FALSE) +set(ANDROID TRUE) +set(ANDROID_ABI arm64-v8a) +check(aarch64-linux-android) +set(ANDROID_ABI armeabi-v7a) +check(armv7-linux-androideabi) +set(ANDROID_ABI x86_64) +check(x86_64-linux-android) +set(ANDROID_ABI x86) +check(i686-linux-android) +set(ANDROID FALSE) +execute_process(COMMAND "${{RUSTC_EXECUTABLE}}" -vV OUTPUT_VARIABLE version COMMAND_ERROR_IS_FATAL ANY) +string(REGEX MATCH "host: ([^\n]+)" ignored "${{version}}") +check("${{CMAKE_MATCH_1}}") +""") + run([args.cmake, "-P", str(controls)], root, env) + baseline = {"negative_controls": "not_run"} + if args.baseline_commit: + old_siglus = root / "baseline-siglus.cmake" + old_siglus.write_text(run(["git", "show", f"{args.baseline_commit}:{siglus.relative_to(repository)}"], repository, env)) + old_rfvp = run(["git", "show", f"{args.baseline_commit}:{rfvp_path.relative_to(repository)}"], repository, env) + try: + configure_case(root, "baseline-canonical-sdk", "/Applications/Xcode.app/SDKs/iPhoneSimulator26.5.sdk", + "arm64", "aarch64-apple-ios-sim", "aarch64-apple-ios-sim", None, + repository, old_siglus, old_rfvp, args.cmake, args.ninja, env) + except AssertionError as error: + actual = error.args[0][1] + if actual != ("aarch64-apple-ios",) * 4: + raise + baseline = {"commit": args.baseline_commit, "negative_controls": "pass", + "actual_wrong_targets": list(actual)} + else: + raise AssertionError("genuine pre-fix source did not reproduce the wrong device target") + evidence = {"status": "pass", "tools": versions, + "regression_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + "production_source_sha256": { + str(path.relative_to(repository)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in (siglus, rfvp_path)}, + "configuration_and_command_cases": results, "non_ios_mapper_controls": 7, + "baseline": baseline, "apple_sdk_compile": "not_run", + "full_application": "not_run", "gameplay": "not_run"} + (root / "evidence.json").write_text(json.dumps(evidence, indent=2) + "\n") + print(json.dumps({"status": "pass", "configuration_and_command_cases": len(results), + "non_ios_mapper_controls": 7, "baseline": baseline, + "apple_sdk_compile": "not_run", "gameplay": "not_run"})) + + +if __name__ == "__main__": + main() From 0bc1af636cf82b7147f638faf510c7f29ab1301e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 13:40:10 +0000 Subject: [PATCH 329/331] fix(ios): select the Minori simulator Rust target in the host --- CMakeLists.txt | 3 +- cmake/AetherKiriMinori.cmake | 32 ++++ tools/test_ios_minori_rust_target.py | 276 +++++++++++++++++++++++++++ 3 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 cmake/AetherKiriMinori.cmake create mode 100644 tools/test_ios_minori_rust_target.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 173786ac9..c3029b1b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -318,7 +318,8 @@ endif() # product platform that can register Minori. if(AETHERKIRI_ENABLE_MINORI AND BUILD_GODOT_EXTENSION AND (LINUX OR APPLE OR ANDROID OR EMSCRIPTEN)) - add_subdirectory(${AETHERKIRI_MINORI_DIR}) + include(cmake/AetherKiriMinori.cmake) + aetherkiri_add_minori_runtime("${AETHERKIRI_MINORI_DIR}") endif() if(AETHERKIRI_ENABLE_RFVP) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/rfvp_runtime) diff --git a/cmake/AetherKiriMinori.cmake b/cmake/AetherKiriMinori.cmake new file mode 100644 index 000000000..5ac9da359 --- /dev/null +++ b/cmake/AetherKiriMinori.cmake @@ -0,0 +1,32 @@ +# Keep Minori's existing override interface. Its pinned CMake 3.19 policy +# scope may remove a normal variable when it first creates this cache entry, +# so create the typed entry before supplying an automatic local default. +set(AETHERKIRI_RUST_TARGET "" CACHE STRING + "Rust target triple for the Minori runtime (auto-detected when empty)") + +function(aetherkiri_add_minori_runtime source_dir) + if(CMAKE_SYSTEM_NAME STREQUAL "iOS" AND NOT AETHERKIRI_RUST_TARGET) + get_filename_component(minori_ios_sdk_name "${CMAKE_OSX_SYSROOT}" NAME) + string(TOLOWER "${minori_ios_sdk_name}" minori_ios_sdk_name) + if(minori_ios_sdk_name MATCHES "^iphonesimulator([0-9]+([.][0-9]+)*)?([.]sdk)?$") + set(minori_ios_arch "${CMAKE_OSX_ARCHITECTURES}") + if(NOT minori_ios_arch) + set(minori_ios_arch "${CMAKE_SYSTEM_PROCESSOR}") + endif() + if(minori_ios_arch STREQUAL "arm64" OR minori_ios_arch STREQUAL "aarch64") + set(AETHERKIRI_RUST_TARGET "aarch64-apple-ios-sim") + elseif(minori_ios_arch STREQUAL "x86_64") + set(AETHERKIRI_RUST_TARGET "x86_64-apple-ios") + else() + message(FATAL_ERROR + "Minori has no Rust target mapping for iOS Simulator architecture ${minori_ios_arch}") + endif() + else() + set(AETHERKIRI_RUST_TARGET "aarch64-apple-ios") + endif() + endif() + # Automatic selection stays local to this invocation. The cache remains + # empty, so reconfiguring from device to Simulator recomputes the default. + # Preserve the actual SDK path and all upstream Cargo/build rules. + add_subdirectory("${source_dir}" "${CMAKE_CURRENT_BINARY_DIR}/packages/AetherMinori") +endfunction() diff --git a/tools/test_ios_minori_rust_target.py b/tools/test_ios_minori_rust_target.py new file mode 100644 index 000000000..100a008e8 --- /dev/null +++ b/tools/test_ios_minori_rust_target.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Configure the pinned production Minori CMake and inspect real Cargo rules. + +Apple SDK names/platform variables are configuration inputs, not SDK/compiler +substitutes. Real CMake/Ninja/Cargo are required. No generated Cargo command, +Apple target compilation, application, or gameplay is executed by this check. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import re +import shlex +import shutil +import subprocess + + +PIN = "7c56b2e7fbe5316d45aa30aedbd02fb5d9ba08c9" +ROOT_BASELINE = "8be1181db6c0305361e74ea13478355f26faaef6" + + +def run(argv: list[str], cwd: Path) -> subprocess.CompletedProcess: + return subprocess.run(argv, cwd=cwd, text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=60) + + +def checked(argv: list[str], cwd: Path) -> str: + result = run(argv, cwd) + if result.returncode: + raise RuntimeError(f"{shlex.join(argv)} failed ({result.returncode}):\n{result.stdout}") + return result.stdout + + +def integration(source: str) -> str: + pattern = r"if\(AETHERKIRI_ENABLE_MINORI AND BUILD_GODOT_EXTENSION AND\s*\(LINUX OR APPLE OR ANDROID OR EMSCRIPTEN\)\)\n.*?endif\(\)" + matches = re.findall(pattern, source, flags=re.S) + if len(matches) != 1: + raise RuntimeError("production Minori integration boundary changed") + return matches[0] + + +def inspect(binary: Path, source: Path, ninja: str, expected: str, + override: str | None, sdk: str, profile: str) -> dict: + commands = checked([ninja, "-C", str(binary), "-t", "commands", + "aether_minori_runtime_build"], binary) + cargo_commands = [] + for line in commands.splitlines(): + argv = shlex.split(line) + if any(Path(arg).name == "cargo" and index + 1 < len(argv) + and argv[index + 1] == "build" for index, arg in enumerate(argv)): + cargo_commands.append(argv) + if len(cargo_commands) != 1: + raise AssertionError("expected exactly one genuine production Cargo build rule") + argv = cargo_commands[0] + targets = [argv[i + 1] for i, arg in enumerate(argv) if arg == "--target"] + if targets != ([expected] if expected else []): + raise AssertionError((targets, expected)) + if argv[argv.index("--manifest-path") + 1] != str(source / "packages/AetherMinori/Cargo.toml"): + raise AssertionError("Cargo rule did not use the genuine pinned package manifest") + if "--lib" not in argv or ("--release" in argv) != (profile == "Release"): + raise AssertionError("production Cargo library/profile rule changed") + archive, cache_value, outer_value, actual_sdk = (binary / "minori-state.txt").read_text().splitlines() + selected_profile = "release" if profile == "Release" else "debug" + subdir = expected + "/" if expected else "" + wanted_archive = str(binary / "packages/AetherMinori/cargo-target" / + (subdir + selected_profile + "/libaether_minori_runtime.a")) + if archive != wanted_archive or cache_value != (override or "") or outer_value != (override or ""): + raise AssertionError((archive, cache_value, outer_value, wanted_archive, override)) + if actual_sdk != sdk: + raise AssertionError("integration changed the actual SDK input") + if (binary / "parent-target-file.txt").read_text() != archive: + raise AssertionError("the root host cannot resolve the actual imported Minori target") + target_dir = str(binary / "packages/AetherMinori/cargo-target") + if f"CARGO_TARGET_DIR={target_dir}" not in argv: + raise AssertionError("the actual Cargo output environment changed") + return {"generated_cargo_target": expected or "host_default", "imported_archive": archive, + "cache_override": cache_value, "automatic_default_leaked": False, + "sdk_unchanged": True, "root_imported_target_reference": "passed", "cargo_build": "not_run"} + + +def configure(root: Path, label: str, repository: Path, block: str, cmake: str, + ninja: str, cargo: str, *, system="iOS", apple=True, sdk="iphonesimulator", + arch="arm64", processor="arm64", android=False, abi="", emscripten=False, + override=None, expected="aarch64-apple-ios-sim", profile="Debug", + failure=None, reuse=False, override_untyped=False) -> dict: + source = root / label / "source" + binary = root / label / "build" + if not reuse: + (source / "packages").mkdir(parents=True) + (source / "packages/AetherMinori").symlink_to(repository / "packages/AetherMinori", + target_is_directory=True) + (source / "cmake").symlink_to(repository / "cmake", target_is_directory=True) + project = f"""cmake_minimum_required(VERSION 3.28) +project(MinoriTargetAcceptance LANGUAGES NONE) +# Input-only configuration fixture. No Apple compiler or SDK is probed. +set(CMAKE_SYSTEM_NAME "${{CASE_SYSTEM}}") +set(APPLE "${{CASE_APPLE}}") +set(LINUX FALSE) +if(CASE_SYSTEM STREQUAL "Linux") + set(LINUX TRUE) +endif() +set(ANDROID "${{CASE_ANDROID}}") +set(ANDROID_ABI "${{CASE_ANDROID_ABI}}") +set(EMSCRIPTEN "${{CASE_EMSCRIPTEN}}") +set(CMAKE_OSX_SYSROOT "${{CASE_SDK}}") +set(CMAKE_OSX_ARCHITECTURES "${{CASE_ARCH}}") +set(CMAKE_SYSTEM_PROCESSOR "${{CASE_PROCESSOR}}") +set(AETHERKIRI_ENABLE_MINORI TRUE) +set(BUILD_GODOT_EXTENSION TRUE) +set(AETHERKIRI_MINORI_DIR "${{CMAKE_CURRENT_SOURCE_DIR}}/packages/AetherMinori") +{block} +if(NOT TARGET aether_minori_runtime OR NOT TARGET aether_minori_runtime_build) + message(FATAL_ERROR "the root host cannot access the production Minori targets") +endif() +get_target_property(imported_global aether_minori_runtime IMPORTED_GLOBAL) +if(NOT imported_global) + message(FATAL_ERROR "the production imported target is no longer GLOBAL") +endif() +add_library(minori_host_reference INTERFACE) +target_link_libraries(minori_host_reference INTERFACE aether_minori_runtime) +file(GENERATE OUTPUT "${{CMAKE_BINARY_DIR}}/parent-target-file.txt" + CONTENT "$") +get_target_property(archive aether_minori_runtime IMPORTED_LOCATION) +get_property(cache_value CACHE AETHERKIRI_RUST_TARGET PROPERTY VALUE) +file(WRITE "${{CMAKE_BINARY_DIR}}/minori-state.txt" + "${{archive}}\n${{cache_value}}\n${{AETHERKIRI_RUST_TARGET}}\n${{CMAKE_OSX_SYSROOT}}\n") +""" + (source / "CMakeLists.txt").write_text(project) + argv = [cmake, "-S", str(source), "-B", str(binary), "-G", "Ninja", + f"-DCMAKE_MAKE_PROGRAM={ninja}", f"-DCARGO_EXECUTABLE:FILEPATH={cargo}", + f"-DCMAKE_BUILD_TYPE:STRING={profile}", f"-DCASE_SYSTEM:STRING={system}", + f"-DCASE_APPLE:BOOL={'ON' if apple else 'OFF'}", f"-DCASE_SDK:STRING={sdk}", + f"-DCASE_ARCH:STRING={arch}", f"-DCASE_PROCESSOR:STRING={processor}", + f"-DCASE_ANDROID:BOOL={'ON' if android else 'OFF'}", f"-DCASE_ANDROID_ABI:STRING={abi}", + f"-DCASE_EMSCRIPTEN:BOOL={'ON' if emscripten else 'OFF'}"] + if override is not None: + override_name = "AETHERKIRI_RUST_TARGET" if override_untyped else "AETHERKIRI_RUST_TARGET:STRING" + argv.append(f"-D{override_name}={override}") + result = run(argv, root) + (root / label / ("reconfigure.log" if reuse else "configure.log")).write_text(result.stdout) + if failure: + if result.returncode == 0 or failure not in result.stdout: + raise AssertionError((label, "expected genuine configure rejection", result.stdout)) + return {"name": label, "configuration_rejection": "passed", "expected_reason": failure} + if result.returncode: + raise RuntimeError(result.stdout) + record = inspect(binary, source, ninja, expected, override, sdk, profile) + record.update(name=label, sdk_input=sdk, arch=arch, explicit_override=override) + return record + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--cmake", default=shutil.which("cmake")) + parser.add_argument("--ninja", default=shutil.which("ninja")) + parser.add_argument("--cargo", default=shutil.which("cargo")) + parser.add_argument("--baseline-commit", default=ROOT_BASELINE, + help="Genuine pre-fix root integration for the negative control") + args = parser.parse_args() + repository = args.repository.resolve(strict=True) + root = args.output_dir.resolve() + root.mkdir(parents=True, exist_ok=False) + for tool in (args.cmake, args.ninja, args.cargo): + if not tool or not Path(tool).is_file(): + raise RuntimeError("genuine CMake, Ninja and Cargo are required") + package = repository / "packages/AetherMinori" + if checked(["git", "rev-parse", "HEAD"], package).strip() != PIN: + raise AssertionError("use the genuine pinned public Minori checkout") + package_cmake = (package / "CMakeLists.txt").read_bytes() + if package_cmake != subprocess.check_output(["git", "show", "HEAD:CMakeLists.txt"], cwd=package): + raise AssertionError("the public Minori CMake source was modified") + current = integration((repository / "CMakeLists.txt").read_text()) + baseline = integration(checked(["git", "show", f"{args.baseline_commit}:CMakeLists.txt"], repository)) + if "aetherkiri_add_minori_runtime" not in current or "add_subdirectory" not in baseline: + raise AssertionError("review the current/pinned pre-fix integration boundaries") + arguments = dict(repository=repository, block=current, cmake=args.cmake, + ninja=args.ninja, cargo=args.cargo) + cases = [] + for arch, expected in (("arm64", "aarch64-apple-ios-sim"), + ("aarch64", "aarch64-apple-ios-sim"), ("x86_64", "x86_64-apple-ios")): + for label, sdk in (("alias", "iphonesimulator"), ("mixed-alias", "iPhOnEsImUlAtOr"), + ("canonical", "/Applications/Xcode.app/SDKs/iPhoneSimulator26.5.sdk"), + ("spaces", "/Applications/Xcode Selected.app/SDKs/iPhOnEsImUlAtOr26.5.sdk"), + ("unversioned", "/Applications/Xcode.app/SDKs/iPhoneSimulator.sdk")): + cases.append(configure(root, f"sim-{arch}-{label}", **arguments, + sdk=sdk, arch=arch, expected=expected)) + for label, sdk in (("alias", "iphoneos"), ("mixed-alias", "iPhOnEoS"), + ("canonical", "/Applications/Xcode.app/SDKs/iPhoneOS26.5.sdk"), + ("misleading-parent", "/tmp/iphonesimulator/iPhoneSimulator.platform/SDKs/iPhoneOS26.5.sdk")): + cases.append(configure(root, "device-" + label, **arguments, sdk=sdk, + expected="aarch64-apple-ios")) + cases.extend([ + configure(root, "sim-processor-fallback", **arguments, arch="", processor="arm64"), + configure(root, "sim-explicit-empty-cache", **arguments, override=""), + configure(root, "sim-explicit-target", **arguments, override="x86_64-apple-ios", + expected="x86_64-apple-ios"), + configure(root, "sim-explicit-untyped-cache", **arguments, override="x86_64-apple-ios", + override_untyped=True, expected="x86_64-apple-ios"), + configure(root, "device-explicit-target", **arguments, sdk="iphoneos", + override="aarch64-apple-ios-sim", expected="aarch64-apple-ios-sim"), + configure(root, "sim-explicit-target-unsupported-arch", **arguments, arch="riscv64", + override="x86_64-apple-ios", expected="x86_64-apple-ios"), + configure(root, "sim-release", **arguments, profile="Release"), + configure(root, "sim-unsupported-arch", **arguments, arch="riscv64", + failure="Minori has no Rust target mapping for iOS Simulator architecture"), + configure(root, "sim-multiple-arch", **arguments, arch="arm64;x86_64", + failure="Minori has no Rust target mapping for iOS Simulator architecture"), + ]) + for arch, expected in (("arm64", "aarch64-apple-darwin"), ("x86_64", "x86_64-apple-darwin")): + cases.append(configure(root, "mac-" + arch, **arguments, system="Darwin", sdk="macosx", + arch=arch, expected=expected)) + for abi, expected in (("arm64-v8a", "aarch64-linux-android"), + ("armeabi-v7a", "armv7-linux-androideabi"), ("x86_64", "x86_64-linux-android")): + cases.append(configure(root, "android-" + abi, **arguments, system="Android", apple=False, + android=True, abi=abi, expected=expected)) + cases.extend([ + configure(root, "linux-host-default", **arguments, system="Linux", apple=False, expected=""), + configure(root, "linux-explicit-target", **arguments, system="Linux", apple=False, + override="x86_64-unknown-linux-gnu", expected="x86_64-unknown-linux-gnu"), + configure(root, "emscripten", **arguments, system="Emscripten", apple=False, + emscripten=True, expected="wasm32-unknown-emscripten"), + configure(root, "android-unsupported-abi", **arguments, system="Android", apple=False, + android=True, abi="mips", failure="Minori has no Rust target mapping for Android ABI"), + ]) + # Reconfigure the same genuine projects; automatic target values must not + # stick in the cache when switching SDK, whereas an explicit override does. + transitions = [configure(root, "sdk-transition", **arguments, sdk="iphoneos", + expected="aarch64-apple-ios")] + transitions.append(configure(root, "sdk-transition", **arguments, reuse=True)) + transitions.append(configure(root, "sdk-transition", **arguments, reuse=True, + arch="x86_64", expected="x86_64-apple-ios")) + transitions.append(configure(root, "sdk-transition", **arguments, reuse=True, + sdk="iphoneos", expected="aarch64-apple-ios")) + transitions.append(configure(root, "sdk-transition", **arguments, reuse=True, + system="Darwin", sdk="macosx", expected="aarch64-apple-darwin")) + transitions.append(configure(root, "sdk-transition", **arguments, reuse=True, + system="Linux", apple=False, expected="")) + transitions.append(configure(root, "explicit-transition", **arguments, + override="x86_64-apple-ios", expected="x86_64-apple-ios")) + transitions.append(configure(root, "explicit-transition", **arguments, reuse=True, + sdk="iphoneos", override="x86_64-apple-ios", expected="x86_64-apple-ios")) + transitions.append(configure(root, "explicit-transition", **arguments, reuse=True, + sdk="iphoneos", override="", expected="aarch64-apple-ios")) + transitions.append(configure(root, "explicit-transition", **arguments, reuse=True, + override="")) + # The genuine pre-fix root block and full pinned package still produce the + # actual erroneous device target for the canonical Simulator SDK path. + old_arguments = dict(arguments, block=baseline) + old = configure(root, "pre-fix-canonical-simulator", **old_arguments, + sdk="/Applications/Xcode.app/SDKs/iPhoneSimulator26.5.sdk", + expected="aarch64-apple-ios") + evidence = {"status": "passed", "package_head": PIN, + "package_cmake_sha256": hashlib.sha256(package_cmake).hexdigest(), + "source_sha256": {str(path.relative_to(repository)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in (repository / "CMakeLists.txt", + repository / "cmake/AetherKiriMinori.cmake", Path(__file__).resolve())}, + "tools": {"cmake": checked([args.cmake, "--version"], root).splitlines()[0], + "ninja": checked([args.ninja, "--version"], root).strip(), + "cargo": checked([args.cargo, "--version"], root).strip()}, + "configuration_cases": cases, "cache_sdk_transitions": transitions, + "baseline_commit": args.baseline_commit, + "genuine_pre_fix_negative_control": old, "apple_target_compile": "not_run", + "cargo_build": "not_run", "application": "not_run", "gameplay": "not_run"} + (root / "evidence.json").write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n") + print(json.dumps({"status": "passed", "configuration_cases": len(cases), + "cache_sdk_transitions": len(transitions), "genuine_pre_fix_negative_control": "passed", + "apple_target_compile": "not_run", "application": "not_run", "gameplay": "not_run"})) + + +if __name__ == "__main__": + main() From a8b8cf4851e416c2e683b9956ed679fb657bf09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 14:50:16 +0000 Subject: [PATCH 330/331] diagnose(ios): record executed startup phases in app acceptance --- apps/godot_app/scripts/main.gd | 87 +++++ .../renpy_mobile_startup_diagnostics_test.gd | 87 +++++ tools/run_renpy_ios_device_acceptance.py | 334 +++++++++++++++++- tools/test_renpy_ios_startup_diagnostics.py | 316 +++++++++++++++++ 4 files changed, 822 insertions(+), 2 deletions(-) create mode 100644 tools/renpy_mobile_startup_diagnostics_test.gd create mode 100644 tools/test_renpy_ios_startup_diagnostics.py diff --git a/apps/godot_app/scripts/main.gd b/apps/godot_app/scripts/main.gd index 87be00cb3..6ee5a5033 100644 --- a/apps/godot_app/scripts/main.gd +++ b/apps/godot_app/scripts/main.gd @@ -58,6 +58,16 @@ const RUNTIME_FONT_DIR := "user://runtime_fonts" const RUNTIME_DEFAULT_FONT_FILE := "default.otf" const RUNTIME_SYMBOL_FONT_FILE := "symbols.ttf" const ProbeConfig = preload("res://scripts/probe_config.gd") +const RENPY_STARTUP_TRACE_PATH := "user://renpy-device-evidence/startup.jsonl" +const RENPY_STARTUP_TRACE_MAX_TICKS := 9007199254740991 +const RENPY_STARTUP_PHASES := [ + "main_ready_entered", "ui_build_entered", "ui_build_ready", + "player_create_entered", "player_create_ready", "player_create_failed", + "ready_frame_pending", "ready_frame_entered", + "engine_initialize_entered", "engine_initialize_ready", "engine_initialize_failed", + "observer_create_entered", "observer_create_ready", + "observer_start_entered", "observer_start_returned", +] const GameMetadata = preload("res://scripts/game_metadata.gd") const CoverIndex = preload("res://scripts/cover_index.gd") const VNDBCoverResolver = preload("res://scripts/vndb_cover_resolver.gd") @@ -2239,6 +2249,8 @@ var black_frame_consecutive := 0 var black_frame_last_log_msec := 0 var black_frame_guard_enabled := false var cli_probe_script := "" +var renpy_startup_trace_run_id := "" +var renpy_startup_trace_seq := 0 var cli_probe_runtime_debug := false var verbose_render_log := false var diagnostics_enabled := false @@ -2513,9 +2525,66 @@ func _detect_cli_probe_script() -> String: return _normalize_cli_probe_script(arg.substr("--aether-probe-script=".length())) if FileAccess.file_exists(ProbeConfig.debug_request_path()): var request := ProbeConfig.load() + _configure_renpy_startup_trace(request) return _normalize_cli_probe_script(String(request.get("probe_script", ""))) return "" +static func _renpy_startup_request_run_id(request: Dictionary) -> String: + if request.get("probe_script") != "res://scripts/renpy_mobile_acceptance.gd": + return "" + var candidate = request.get("run_id") + if not candidate is String or candidate.length() != 32: + return "" + for character in candidate: + if not character in "0123456789abcdef": + return "" + return candidate + +static func _renpy_startup_phase_row(run_id: String, seq: Variant, phase: String, + ticks_msec: Variant, result: Variant = null) -> Dictionary: + if _renpy_startup_request_run_id({"probe_script": "res://scripts/renpy_mobile_acceptance.gd", + "run_id": run_id}).is_empty() or phase not in RENPY_STARTUP_PHASES: + return {} + if typeof(seq) != TYPE_INT or seq < 1 or seq > 64: + return {} + if typeof(ticks_msec) != TYPE_INT or ticks_msec < 0 or ticks_msec > RENPY_STARTUP_TRACE_MAX_TICKS: + return {} + if result != null and (typeof(result) != TYPE_INT or result < -2147483648 or result > 2147483647): + return {} + var row := {"run_id": run_id, "seq": seq, "ticks_msec": ticks_msec, "phase": phase} + if result != null: + row["result"] = result + return row + +func _configure_renpy_startup_trace(request: Dictionary) -> void: + if not OS.is_debug_build() or OS.get_name() not in ["Android", "iOS"]: + return + renpy_startup_trace_run_id = _renpy_startup_request_run_id(request) + renpy_startup_trace_seq = 0 + +func _record_renpy_startup_phase(phase: String, result: Variant = null) -> void: + if renpy_startup_trace_run_id.is_empty() or not OS.is_debug_build() \ + or OS.get_name() not in ["Android", "iOS"]: + return + var row := _renpy_startup_phase_row(renpy_startup_trace_run_id, + renpy_startup_trace_seq + 1, phase, Time.get_ticks_msec(), result) + if row.is_empty(): + return + var directory := ProjectSettings.globalize_path(RENPY_STARTUP_TRACE_PATH.get_base_dir()) + if DirAccess.make_dir_recursive_absolute(directory) != OK: + return + var mode := FileAccess.WRITE if renpy_startup_trace_seq == 0 else FileAccess.READ_WRITE + var file := FileAccess.open(RENPY_STARTUP_TRACE_PATH, mode) + if file == null: + return + file.seek_end() + file.store_line(JSON.stringify(row)) + file.flush() + var written := file.get_error() == OK + file.close() + if written: + renpy_startup_trace_seq += 1 + func _normalize_cli_probe_script(path: String) -> String: var normalized := path.strip_edges() if normalized.is_empty(): @@ -11561,6 +11630,7 @@ func _ready() -> void: add_child(vndb_resolver) vndb_resolver.resolved.connect(_on_vndb_cover_resolved) cli_probe_script = _detect_cli_probe_script() + _record_renpy_startup_phase("main_ready_entered") _apply_ui_font() DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_TRANSPARENT, false) _apply_initial_window_size() @@ -11637,7 +11707,9 @@ func _ready() -> void: if ui_motion.get_parent() == null: add_child(ui_motion) + _record_renpy_startup_phase("ui_build_entered") _build_ui() + _record_renpy_startup_phase("ui_build_ready") _stage_runtime_fonts() # Scene-test preview: scheduled before the optional runtime player init @@ -11645,8 +11717,11 @@ func _ready() -> void: _restore_scene_test_state() call_deferred("_apply_pending_scene_test") + _record_renpy_startup_phase("player_create_entered") if not _create_runtime_player(): + _record_renpy_startup_phase("player_create_failed") return + _record_renpy_startup_phase("player_create_ready") _restore_native_translation_model_access() _initialize_iap() @@ -11680,6 +11755,7 @@ func _ready() -> void: _apply_upscale_algorithm() _append_log("AetherKiri shell ready. Initializing engine...") + _record_renpy_startup_phase("ready_frame_pending") call_deferred("_finish_ready_after_first_frame") func _setup_debug_console() -> void: @@ -12657,8 +12733,15 @@ func _ensure_player_initialized() -> bool: func _finish_ready_after_first_frame() -> void: await get_tree().process_frame + _record_renpy_startup_phase("ready_frame_entered") + _record_renpy_startup_phase("engine_initialize_entered") var engine_initialized := _ensure_player_initialized() + var engine_result: Variant = null + if not renpy_startup_trace_run_id.is_empty() and player != null and player.has_method("get_last_result"): + engine_result = player.get_last_result() + _record_renpy_startup_phase( + "engine_initialize_ready" if engine_initialized else "engine_initialize_failed", engine_result) if engine_initialized: _apply_backend(false) @@ -13238,11 +13321,15 @@ func _run_cli_script_probe() -> void: var config := ProbeConfig.load() if cli_probe_script == "res://scripts/renpy_mobile_acceptance.gd": _prepare_cli_probe_view(config) + _record_renpy_startup_phase("observer_create_entered") var acceptance := load(cli_probe_script).new() as Node add_child(acceptance) + _record_renpy_startup_phase("observer_create_ready") if OS.has_feature("ios"): acceptance.connect("game_finished", _finish_ios_mobile_acceptance.bind(acceptance)) + _record_renpy_startup_phase("observer_start_entered") acceptance.start(config, player) + _record_renpy_startup_phase("observer_start_returned") return var target_game_path: String = ProbeConfig.require_game_path(config) var requested_game_path := target_game_path diff --git a/tools/renpy_mobile_startup_diagnostics_test.gd b/tools/renpy_mobile_startup_diagnostics_test.gd new file mode 100644 index 000000000..cefa1eda5 --- /dev/null +++ b/tools/renpy_mobile_startup_diagnostics_test.gd @@ -0,0 +1,87 @@ +extends Node + +# Run as the main scene in a disposable copy of apps/godot_app using a real +# Godot binary. This checks the production row helpers and request consumption; +# it does not initialize a native engine or manufacture mobile startup phases. +const Main = preload("res://scripts/main.gd") +const ProbeConfig = preload("res://scripts/probe_config.gd") +const RUN_ID := "0123456789abcdef0123456789abcdef" +const PHASES := [ + "main_ready_entered", "ui_build_entered", "ui_build_ready", + "player_create_entered", "player_create_ready", "player_create_failed", + "ready_frame_pending", "ready_frame_entered", + "engine_initialize_entered", "engine_initialize_ready", "engine_initialize_failed", + "observer_create_entered", "observer_create_ready", + "observer_start_entered", "observer_start_returned", +] + +var checks := 0 +var failures := 0 + +func _check(condition: bool, label: String) -> void: + checks += 1 + if not condition: + failures += 1 + push_error("startup diagnostic regression failed: " + label) + +func _ready() -> void: + var request := {"probe_script": "res://scripts/renpy_mobile_acceptance.gd", + "run_id": RUN_ID, "game_path": "user://request-fixture", "timeout_seconds": 600} + _check(Main._renpy_startup_request_run_id(request) == RUN_ID, "acceptance request identity") + for candidate in [null, 123, "", RUN_ID.to_upper(), RUN_ID + "0", "../private/path", "g".repeat(32)]: + var invalid := request.duplicate() + invalid["run_id"] = candidate + _check(Main._renpy_startup_request_run_id(invalid).is_empty(), "invalid identity is refused") + var unrelated := request.duplicate() + unrelated["probe_script"] = "res://scripts/smoke_test.gd" + _check(Main._renpy_startup_request_run_id(unrelated).is_empty(), "other probe identity is refused") + _check(Main.RENPY_STARTUP_PHASES == PHASES, "fixed phase whitelist") + + # These are protocol rows, not evidence that any runtime phase executed. + for index in range(PHASES.size()): + var row: Dictionary = Main._renpy_startup_phase_row(RUN_ID, index + 1, PHASES[index], 1000 + index) + _check(row.size() == 4 and row.run_id == RUN_ID and row.seq == index + 1 \ + and row.phase == PHASES[index] and row.ticks_msec == 1000 + index, "fixed row sequence") + var result_row: Dictionary = Main._renpy_startup_phase_row(RUN_ID, 1, PHASES[0], 0, -2147483648) + _check(result_row.size() == 5 and result_row.result == -2147483648, "signed result lower boundary") + _check(not Main._renpy_startup_phase_row(RUN_ID, 64, PHASES[0], Main.RENPY_STARTUP_TRACE_MAX_TICKS, 2147483647).is_empty(), "upper boundaries") + for value in [0, 65, true, 1.0, "1"]: + _check(Main._renpy_startup_phase_row(RUN_ID, value, PHASES[0], 0).is_empty(), "invalid sequence is refused") + for value in [-1, Main.RENPY_STARTUP_TRACE_MAX_TICKS + 1, true, 1.0, "1"]: + _check(Main._renpy_startup_phase_row(RUN_ID, 1, PHASES[0], value).is_empty(), "invalid clock is refused") + for value in [-2147483649, 2147483648, true, 1.0, "1"]: + _check(Main._renpy_startup_phase_row(RUN_ID, 1, PHASES[0], 0, value).is_empty(), "invalid result is refused") + _check(Main._renpy_startup_phase_row(RUN_ID, 1, "private arbitrary message", 0).is_empty(), "unknown phase is refused") + + var main := Main.new() + var trace_path := ProjectSettings.globalize_path(Main.RENPY_STARTUP_TRACE_PATH) + _check(not FileAccess.file_exists(trace_path), "disposable trace initially absent") + main._record_renpy_startup_phase(PHASES[0]) + _check(not FileAccess.file_exists(trace_path), "no request produces no trace") + var file := FileAccess.open(ProbeConfig.debug_request_path(), FileAccess.WRITE) + _check(file != null, "real debug request opened") + if file != null: + file.store_string(JSON.stringify(request)) + file.close() + _check(main._detect_cli_probe_script() == request.probe_script, "production Main detects acceptance") + _check(not FileAccess.file_exists(ProbeConfig.debug_request_path()), "request consumed once") + var parsed: Dictionary = JSON.parse_string(JSON.stringify(request)) + _check(ProbeConfig.load() == parsed and ProbeConfig.load() == parsed, "cached request survives repeated reads") + if not OS.is_debug_build() or OS.get_name() not in ["Android", "iOS"]: + _check(main.renpy_startup_trace_run_id.is_empty(), "actual nonmobile platform does not enable trace") + main._configure_renpy_startup_trace(request) + main._record_renpy_startup_phase(PHASES[0]) + _check(not FileAccess.file_exists(trace_path), "actual nonmobile writer is a no-op") + main.ui_motion.free() + main.free() + + var evidence := {"status": "passed" if failures == 0 else "failed", "checks": checks, + "failures": failures, "godot_version": Engine.get_version_info().string, + "platform": OS.get_name(), "protocol_rows_only": true, + "native_runtime_executed": false, "mobile_gameplay_executed": false} + print(JSON.stringify(evidence)) + var output := FileAccess.open("res://startup-diagnostics-test.json", FileAccess.WRITE) + if output != null: + output.store_string(JSON.stringify(evidence)) + output.close() + get_tree().quit(0 if failures == 0 else 1) diff --git a/tools/run_renpy_ios_device_acceptance.py b/tools/run_renpy_ios_device_acceptance.py index 3f48f7a58..1d5d23584 100755 --- a/tools/run_renpy_ios_device_acceptance.py +++ b/tools/run_renpy_ios_device_acceptance.py @@ -10,11 +10,16 @@ from __future__ import annotations import argparse +import errno import hashlib import json +import math +import os import platform import plistlib +import re import shutil +import stat import subprocess import time import uuid @@ -26,6 +31,225 @@ ) +DIAGNOSTIC_MAX_BYTES = 24 * 1024 +DIAGNOSTIC_MAX_RECORDS = 64 +DIAGNOSTIC_MAX_JSON_DEPTH = 16 +DIAGNOSTIC_MAX_TICKS = (1 << 53) - 1 +STARTUP_PHASES = ( + "main_ready_entered", "ui_build_entered", "ui_build_ready", + "player_create_entered", "player_create_ready", "player_create_failed", + "ready_frame_pending", "ready_frame_entered", "engine_initialize_entered", + "engine_initialize_ready", "engine_initialize_failed", "observer_create_entered", + "observer_create_ready", "observer_start_entered", "observer_start_returned", +) +CHECKPOINT_STAGES = ( + "start_ready", "touch_received", "text_ready", "text_received", + "post_text_ready", "resumed_touch_received", "quit_ready", "quit_requested", +) +OBSERVER_KINDS = ( + "opening", "startup", "lifecycle", "os_touch", "os_mouse", "os_key", + "soft_keyboard", "soft_keyboard_visibility", "checkpoint_seen", + "provider_frame", "normal_exit", "library_return", "failure", +) +DIAGNOSTIC_DOCUMENT_FILES = { + "startup_trace": "renpy-device-evidence/startup.jsonl", + "observer": "renpy-device-evidence/observer.jsonl", + "script_checkpoints": "renpy-device-demo/game/aether-device-checkpoints.jsonl", + "probe_request": "aetherkiri-probe-request.json", + "device_request": "renpy-device-demo/game/aether-device-request.json", + "engine_log": "renpy-device-demo/aetherkiri-engine.log", + "renpy_log": "renpy-device-demo/log.txt", + "renpy_traceback": "renpy-device-demo/traceback.txt", +} + + +def is_actual_xcui_ready(value: object, run_id: str, bundle_id: str) -> bool: + return (isinstance(value, dict) and value.get("run_id") == run_id + and value.get("bundle_id") == bundle_id and value.get("ready") is True) + + +def diagnostic_file(root: Path | None, relative: str, *, read: bool = False) -> tuple[dict, bytes | None]: + """Read fixed caller-selected files without following child symlinks. + + Metadata contains no paths, exceptions or contents. Reads have a 24 KiB + ceiling; ordinary logs are statted only, never decoded or printed here. + """ + metadata = {"status": "not_run", "exists": None, "bytes": None} + if root is None: + return metadata, None + parent_fd = directory_fd = file_fd = None + try: + root_stat = root.lstat() + if stat.S_ISLNK(root_stat.st_mode) or not stat.S_ISDIR(root_stat.st_mode): + metadata["status"] = "root_refused" + return metadata, None + parts = Path(relative).parts + if not parts or Path(relative).is_absolute() or any(part in (".", "..") for part in parts): + metadata["status"] = "path_refused" + return metadata, None + # Canonicalize normal host aliases above the selected Documents root. + # Hold directory descriptors for every component below that boundary. + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + # Resolve aliases above Documents only. Opening the selected root's + # basename through a held parent descriptor atomically refuses a root + # swapped to a symlink after the preliminary lstat. + parent_fd = os.open(root.parent.resolve(strict=True), directory_flags) + directory_fd = os.open(root.name, directory_flags, dir_fd=parent_fd) + os.close(parent_fd) + parent_fd = None + for part in parts[:-1]: + child_fd = os.open(part, directory_flags, dir_fd=directory_fd) + os.close(directory_fd) + directory_fd = child_fd + file_fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, + dir_fd=directory_fd) + file_stat = os.fstat(file_fd) + metadata["exists"] = True + if not stat.S_ISREG(file_stat.st_mode): + metadata["status"] = "non_regular_refused" + return metadata, None + if not 0 <= file_stat.st_size <= (1 << 63) - 1: + metadata["status"] = "invalid_metadata" + return metadata, None + metadata.update(status="regular", bytes=file_stat.st_size) + if not read: + return metadata, None + if file_stat.st_size > DIAGNOSTIC_MAX_BYTES: + metadata["status"] = "byte_limit" + return metadata, None + data = bytearray() + while len(data) < DIAGNOSTIC_MAX_BYTES: + chunk = os.read(file_fd, DIAGNOSTIC_MAX_BYTES - len(data)) + if not chunk: + break + data.extend(chunk) + if os.fstat(file_fd).st_size > DIAGNOSTIC_MAX_BYTES: + metadata["status"] = "byte_limit" + return metadata, None + return metadata, bytes(data) + except FileNotFoundError: + metadata.update(status="missing", exists=False) + except OSError as exc: + metadata["status"] = ("link_or_directory_refused" if exc.errno in (errno.ELOOP, errno.ENOTDIR) + else "read_error") + except (ValueError, RuntimeError): + metadata["status"] = "read_error" + finally: + if file_fd is not None: + os.close(file_fd) + if directory_fd is not None: + os.close(directory_fd) + if parent_fd is not None: + os.close(parent_fd) + return metadata, None + + +def _diagnostic_object(pairs: list[tuple]) -> dict: + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate_key") + result[key] = value + return result + + +def _diagnostic_integer(value: str) -> int: + if len(value.lstrip("-")) > 16: + raise ValueError("integer_limit") + result = int(value) + if abs(result) > DIAGNOSTIC_MAX_TICKS: + raise ValueError("integer_limit") + return result + + +def _diagnostic_float(value: str) -> float: + result = float(value) + if not math.isfinite(result): + raise ValueError("non_finite_number") + return result + + +def _diagnostic_constant(value: str): + raise ValueError("non_json_constant") + + +def _diagnostic_depth(data: bytes) -> None: + # Bound decoder nesting independently of Python's implementation/version. + # Ignore structural characters in JSON strings, including escaped quotes. + depth, quoted, escaped = 0, False, False + for byte in data: + if quoted: + if escaped: + escaped = False + elif byte == 92: + escaped = True + elif byte == 34: + quoted = False + elif byte == 34: + quoted = True + elif byte in (91, 123): + depth += 1 + if depth > DIAGNOSTIC_MAX_JSON_DEPTH: + raise ValueError("nesting_limit") + elif byte in (93, 125): + depth -= 1 + + +def diagnostic_rows(data: bytes | None, run_id: str, *, startup: bool = False) -> dict: + """Expose strict startup fields; other journal rows stay private for counts.""" + report = {"status": "unavailable", "rows": [], "violations": { + "invalid_json": 0, "foreign_run": 0, "invalid_schema": 0, + "invalid_order": 0, "record_limit": 0, "byte_limit": 0}} + if data is None: + return report + if len(data) > DIAGNOSTIC_MAX_BYTES: + report["status"] = "byte_limit" + report["violations"]["byte_limit"] = 1 + return report + lines = [line for line in data.splitlines() if line.strip()] + if len(lines) > DIAGNOSTIC_MAX_RECORDS: + report["status"] = "record_limit" + report["violations"]["record_limit"] = 1 + return report + if type(run_id) is not str or not re.fullmatch(r"[0-9a-f]{32}", run_id): + report["status"] = "invalid_identity" + report["violations"]["invalid_schema"] = 1 + return report + last_seq, last_ticks = 0, 0 + for line in lines: + try: + _diagnostic_depth(line) + row = json.loads(line.decode("utf-8"), object_pairs_hook=_diagnostic_object, + parse_int=_diagnostic_integer, parse_float=_diagnostic_float, + parse_constant=_diagnostic_constant) + except (ValueError, UnicodeError, RecursionError, OverflowError): + report["violations"]["invalid_json"] += 1 + continue + if not isinstance(row, dict): + report["violations"]["invalid_schema"] += 1 + continue + if row.get("run_id") != run_id: + report["violations"]["foreign_run"] += 1 + continue + if startup: + required = {"run_id", "seq", "ticks_msec", "phase"} + seq, ticks, result = row.get("seq"), row.get("ticks_msec"), row.get("result") + if (not required.issubset(row) or not set(row).issubset(required | {"result"}) + or row.get("phase") not in STARTUP_PHASES + or type(seq) is not int or not 1 <= seq <= DIAGNOSTIC_MAX_RECORDS + or type(ticks) is not int or not 0 <= ticks <= DIAGNOSTIC_MAX_TICKS + or ("result" in row and (type(result) is not int or not -(1 << 31) <= result < (1 << 31)))): + report["violations"]["invalid_schema"] += 1 + continue + if seq <= last_seq or ticks < last_ticks: + report["violations"]["invalid_order"] += 1 + continue + last_seq, last_ticks = seq, ticks + report["rows"].append(row) + report["status"] = "partial" if any(report["violations"].values()) else "valid" + return report + + class Acceptance: def __init__(self, args: argparse.Namespace): self.args = args @@ -45,9 +269,105 @@ def __init__(self, args: argparse.Namespace): self.waiting_marker = None self.keyboard_preference: str | None = None self.preference_changed = False + self.started_monotonic = time.monotonic() + self.request_staged = self.xcui_ready = False + self.install_elapsed_msec = self.xcui_ready_elapsed_msec = None + self.diagnostics_collected = False self.summary = {"status": "not_run", "run_id": self.run_id, "source": "actual-godot-app-simctl-xcuitest-cloud-simulator", "checks": [], "blocker": ""} + self.summary["startup_diagnostics"] = self.startup_gate_diagnostics() + + def elapsed_msec(self) -> int: + return max(0, min(DIAGNOSTIC_MAX_TICKS, + int((time.monotonic() - self.started_monotonic) * 1000))) + + def startup_gate_diagnostics(self) -> dict: + return {"schema_version": 1, "diagnostic_status": "not_collected", + "install_passed": self.installed, "install_elapsed_msec": self.install_elapsed_msec, + "xcui_ready_passed": self.xcui_ready, "xcui_ready_elapsed_msec": self.xcui_ready_elapsed_msec, + "request_staged": self.request_staged} + + def snapshot_startup_diagnostics(self) -> None: + """Collect fixed same-run facts before cleanup; never change acceptance. + + Only strict startup rows are exposed. Other journals supply counts, + ordinary logs supply metadata, and missing request files prove absence + only (not which component removed them). Diagnostic failures stay local. + """ + if self.diagnostics_collected: + return + self.diagnostics_collected = True + report = self.startup_gate_diagnostics() + report.update(diagnostic_status="collected", read_errors=0, + test_process_before_cleanup={"state": "not_started", "returncode": None}) + self.summary["startup_diagnostics"] = report + try: + if self.process is not None: + try: + returncode = self.process.poll() + if returncode is None: + report["test_process_before_cleanup"]["state"] = "running" + elif type(returncode) is int and -(1 << 31) <= returncode < (1 << 31): + report["test_process_before_cleanup"].update(state="exited", returncode=returncode) + else: + report["test_process_before_cleanup"]["state"] = "unavailable" + report["read_errors"] += 1 + except Exception: + report["test_process_before_cleanup"]["state"] = "unavailable" + report["read_errors"] += 1 + files, journals = {}, {} + for label, relative in DIAGNOSTIC_DOCUMENT_FILES.items(): + read = label in ("startup_trace", "observer", "script_checkpoints") + try: + metadata, data = diagnostic_file(self.documents, relative, read=read) + except Exception: + metadata, data = {"status": "read_error", "exists": None, "bytes": None}, None + if metadata["status"] == "read_error": + report["read_errors"] += 1 + files[label] = metadata + if read: + parsed = diagnostic_rows(data, self.run_id, startup=label == "startup_trace") + if metadata["status"] == "byte_limit": + parsed["status"] = "byte_limit" + parsed["violations"]["byte_limit"] = 1 + journals[label] = parsed + report["files"] = files + trace = journals["startup_trace"] + report["startup_trace"] = {"status": trace["status"], "rows": trace["rows"], + "records": len(trace["rows"]), "violations": trace["violations"]} + observer = journals["observer"] + report["observer"] = { + "status": observer["status"], "records": len(observer["rows"]), + "violations": observer["violations"], + "kind_counts": {kind: sum(row.get("kind") == kind for row in observer["rows"]) + for kind in OBSERVER_KINDS}, + "unknown_kind_records": sum(row.get("kind") not in OBSERVER_KINDS + for row in observer["rows"])} + checkpoints = journals["script_checkpoints"] + report["script_checkpoints"] = { + "status": checkpoints["status"], "records": len(checkpoints["rows"]), + "violations": checkpoints["violations"], + "stage_counts": {stage: sum(is_script_checkpoint(row, stage, self.run_id) + for row in checkpoints["rows"]) + for stage in CHECKPOINT_STAGES}, + "unrecognized_records": sum(not any(is_script_checkpoint(row, stage, self.run_id) + for stage in CHECKPOINT_STAGES) + for row in checkpoints["rows"])} + report["probe_request_exists"] = files["probe_request"]["exists"] + report["device_request_exists"] = files["device_request"]["exists"] + report["probe_request_absent_after_staging"] = ( + self.request_staged and files["probe_request"]["status"] == "missing") + if self.documents is None: + report["diagnostic_status"] = "unavailable" + elif (report["read_errors"] or any(journal["status"] != "valid" for journal in journals.values()) + or any(metadata["status"] not in ("regular", "missing") for metadata in files.values())): + report["diagnostic_status"] = "partial" + except Exception: + # In particular, never mask the original gameplay failure with a + # diagnostic read/parser exception or publish its arbitrary text. + report["diagnostic_status"] = "internal_error" + report["read_errors"] += 1 def command(self, *parts: str, check: bool = True, timeout: int = 60) -> bytes: try: @@ -236,6 +556,8 @@ def stage(self) -> None: (self.documents / "aetherkiri-probe-request.json").write_text(json.dumps({ "probe_script": "res://scripts/renpy_mobile_acceptance.gd", "run_id": self.run_id, "game_path": str(game.parent), "timeout_seconds": self.args.overall_timeout})) + self.request_staged = True + self.summary["startup_diagnostics"]["request_staged"] = True def start_xcuitest(self) -> None: project = self.repo / "tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj" @@ -300,13 +622,17 @@ def ready(): root = Path(data) / "Documents" / ("aether-renpy-ui-" + self.run_id) try: report = json.loads((root / "ready.json").read_text()) - if report.get("run_id") == self.run_id and report.get("ready") is True: + if is_actual_xcui_ready(report, self.run_id, self.bundle_id): self.ui_root = root - return report + return True except (OSError, ValueError): return None self.wait("installed XCUITest runner and actual app launch", ready, timeout=180) + self.xcui_ready = True + self.xcui_ready_elapsed_msec = self.elapsed_msec() + self.summary["startup_diagnostics"].update( + xcui_ready_passed=True, xcui_ready_elapsed_msec=self.xcui_ready_elapsed_msec) def run(self) -> None: if not self.args.cloud_simulator: @@ -337,6 +663,9 @@ def run(self) -> None: self.preference_changed = True self.simctl("install", self.udid, str(self.args.app.resolve()), timeout=180) self.installed = True + self.install_elapsed_msec = self.elapsed_msec() + self.summary["startup_diagnostics"].update( + install_passed=True, install_elapsed_msec=self.install_elapsed_msec) self.simctl("terminate", self.udid, self.bundle_id, check=False) root = self.simctl("get_app_container", self.udid, self.bundle_id, "data").decode().strip() self.documents = Path(root) / "Documents" @@ -446,6 +775,7 @@ def verify_library_return(self, report: dict) -> None: self.summary["checks"].append({"actual_library_return_pixels_and_os_screen": stats}) def collect(self) -> None: + self.snapshot_startup_diagnostics() if self.documents is not None: for relative, filename in [ ("renpy-device-demo/aetherkiri-engine.log", "aetherkiri-engine.log"), diff --git a/tools/test_renpy_ios_startup_diagnostics.py b/tools/test_renpy_ios_startup_diagnostics.py new file mode 100644 index 000000000..931e821f8 --- /dev/null +++ b/tools/test_renpy_ios_startup_diagnostics.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +"""Exercise future iOS diagnostic boundaries with real host files/processes. + +These are protocol, filesystem and process regressions. No Apple SDK, app +installation, native runtime, scene, input or gameplay is executed here. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import run_renpy_ios_device_acceptance as production + + +RUN_ID = "0123456789abcdef0123456789abcdef" +BUNDLE_ID = "org.aetherkiri.renpy-simulator.debug" +PRIVATE_CONTENT = "private-path-message-content-must-never-appear" + + +def row(seq=1, ticks=0, phase=None, **fields): + return {"run_id": RUN_ID, "seq": seq, "ticks_msec": ticks, + "phase": phase or production.STARTUP_PHASES[0], **fields} + + +def jsonl(rows): + return b"".join(json.dumps(value).encode() + b"\n" for value in rows) + + +class StartupDiagnostics(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="aether-ios-startup-diagnostics-") + self.root = Path(self.temporary.name) + self.documents = self.root / "Documents" + self.documents.mkdir() + + def tearDown(self): + self.temporary.cleanup() + + def acceptance(self): + args = argparse.Namespace(output_dir=self.root / "output", udid=None) + acceptance = production.Acceptance(args) + acceptance.run_id = RUN_ID + acceptance.bundle_id = BUNDLE_ID + acceptance.summary.update(run_id=RUN_ID, status="failed", blocker="original gameplay failure") + return acceptance + + def write(self, label, contents): + path = self.documents / production.DIAGNOSTIC_DOCUMENT_FILES[label] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(contents) + return path + + def test_producer_contract_and_all_phases(self): + main = (Path(__file__).resolve().parents[1] / "apps/godot_app/scripts/main.gd").read_text() + phases = re.search(r"const RENPY_STARTUP_PHASES := \[(.*?)\]", main, re.S) + self.assertIsNotNone(phases) + self.assertEqual(tuple(re.findall(r'"([a-z_]+)"', phases.group(1))), production.STARTUP_PHASES) + self.assertIn("const RENPY_STARTUP_TRACE_MAX_TICKS := 9007199254740991", main) + values = [row(seq=index + 1, ticks=index, phase=phase) + for index, phase in enumerate(production.STARTUP_PHASES)] + values.append(row(seq=64, ticks=production.DIAGNOSTIC_MAX_TICKS, + result=2147483647)) + result = production.diagnostic_rows(jsonl(values), RUN_ID, startup=True) + self.assertEqual(result["status"], "valid") + self.assertEqual(result["rows"], values) + self.assertFalse(any(result["violations"].values())) + self.assertEqual(production.diagnostic_rows(jsonl([row(result=-2147483648)]), RUN_ID, + startup=True)["status"], "valid") + + def test_startup_schema_numeric_boundaries(self): + cases = [] + for key, values in ( + ("seq", [0, 65, True, 1.0, "1", None]), + ("ticks_msec", [-1, production.DIAGNOSTIC_MAX_TICKS + 1, True, 1.0, "1", None]), + ("result", [-2147483649, 2147483648, True, 1.0, "1", None])): + for value in values: + cases.append({**row(), key: value}) + cases.extend([row(phase=PRIVATE_CONTENT), row(phase=[PRIVATE_CONTENT]), + {**row(), "message": PRIVATE_CONTENT}, {"run_id": RUN_ID}, + [], "arbitrary-json-string"]) + for value in cases: + with self.subTest(value=value): + result = production.diagnostic_rows(jsonl([value]), RUN_ID, startup=True) + self.assertEqual(result["status"], "partial") + self.assertEqual(result["rows"], []) + self.assertEqual(sum(result["violations"].values()), 1) + self.assertNotIn(PRIVATE_CONTENT, json.dumps(result)) + + def test_json_identity_and_complexity_boundaries(self): + invalid = [b'{"run_id":"' + RUN_ID.encode() + b'","run_id":"' + RUN_ID.encode() + b'"}', + b'{"run_id":"' + RUN_ID.encode() + b'","nested":{"x":1,"x":2}}', + b'{"n":' + b'9' * 10000 + b'}', b'{"n":NaN}', b'{"n":Infinity}', + b'{"n":1e9999}', b'["\xff"]', b'not-json', + b'[' * 1500 + b'0' + b']' * 1500] + for value in invalid: + with self.subTest(bytes=len(value)): + result = production.diagnostic_rows(value, RUN_ID, startup=True) + self.assertEqual(result["violations"]["invalid_json"], 1) + self.assertEqual(result["rows"], []) + for identity in [None, 123, True, [], {}, "", RUN_ID.upper(), "g" * 32]: + with self.subTest(identity=identity): + result = production.diagnostic_rows(jsonl([row()]), identity, startup=True) + self.assertEqual(result["status"], "invalid_identity") + self.assertEqual(result["rows"], []) + foreign = {**row(), "run_id": "f" * 32} + result = production.diagnostic_rows(jsonl([foreign, row()]), RUN_ID, startup=True) + self.assertEqual(result["violations"]["foreign_run"], 1) + self.assertEqual(result["rows"], [row()]) + # The depth bound counts JSON structure, never characters in strings. + private = {"run_id": RUN_ID, "kind": "opening", "message": '["\\' * 100} + self.assertEqual(production.diagnostic_rows(jsonl([private]), RUN_ID)["status"], "valid") + + def test_order_record_and_byte_limits(self): + values = [row(seq=2, ticks=20), row(seq=2, ticks=20), row(seq=3, ticks=19), row(seq=4, ticks=21)] + result = production.diagnostic_rows(jsonl(values), RUN_ID, startup=True) + self.assertEqual(result["violations"]["invalid_order"], 2) + self.assertEqual(result["rows"], [values[0], values[3]]) + result = production.diagnostic_rows(jsonl([row()] * 65), RUN_ID, startup=True) + self.assertEqual(result["status"], "record_limit") + self.assertEqual(result["rows"], []) + result = production.diagnostic_rows(b" " * (production.DIAGNOSTIC_MAX_BYTES + 1), RUN_ID, + startup=True) + self.assertEqual(result["violations"]["byte_limit"], 1) + self.assertEqual(result["rows"], []) + + def test_real_file_metadata_and_bounded_reads(self): + path = self.write("startup_trace", jsonl([row()])) + relative = production.DIAGNOSTIC_DOCUMENT_FILES["startup_trace"] + metadata, data = production.diagnostic_file(self.documents, relative, read=True) + self.assertEqual(metadata, {"status": "regular", "exists": True, "bytes": path.stat().st_size}) + self.assertEqual(data, path.read_bytes()) + path.write_bytes(b"x" * production.DIAGNOSTIC_MAX_BYTES) + self.assertEqual(len(production.diagnostic_file(self.documents, relative, read=True)[1]), + production.DIAGNOSTIC_MAX_BYTES) + path.write_bytes(b"x" * (production.DIAGNOSTIC_MAX_BYTES + 1)) + metadata, data = production.diagnostic_file(self.documents, relative, read=True) + self.assertEqual(metadata["status"], "byte_limit") + self.assertIsNone(data) + with mock.patch.object(production.os, "read", side_effect=AssertionError("metadata must not read")): + metadata, data = production.diagnostic_file(self.documents, relative) + self.assertEqual(metadata["status"], "regular") + self.assertIsNone(data) + self.assertEqual(production.diagnostic_file(self.documents, "missing")[0]["exists"], False) + self.assertEqual(production.diagnostic_file(None, "missing")[0]["status"], "not_run") + + def test_real_symlinks_nonregular_and_paths_are_refused(self): + outside = self.root / "outside" + outside.mkdir() + (outside / "secret").write_text(PRIVATE_CONTENT) + for name, target in (("leaf", outside / "secret"), ("parent", outside)): + (self.documents / name).symlink_to(target) + for relative in ("leaf", "parent/secret"): + metadata, data = production.diagnostic_file(self.documents, relative, read=True) + self.assertEqual(metadata["status"], "link_or_directory_refused") + self.assertIsNone(data) + alias = self.root / "alias" + alias.symlink_to(self.documents, target_is_directory=True) + self.assertEqual(production.diagnostic_file(alias, "leaf", read=True)[0]["status"], "root_refused") + # Normal host aliases above the selected Documents boundary remain valid. + above = self.root / "above" + above.symlink_to(self.root, target_is_directory=True) + self.write("probe_request", b"safe") + self.assertEqual(production.diagnostic_file(above / "Documents", "aetherkiri-probe-request.json", + read=True)[1], b"safe") + directory = self.documents / "directory" + directory.mkdir() + os.mkfifo(self.documents / "fifo") + for relative in ("directory", "fifo"): + metadata, data = production.diagnostic_file(self.documents, relative, read=True) + self.assertEqual(metadata["status"], "non_regular_refused") + self.assertIsNone(data) + for relative in ("../outside/secret", str(outside / "secret"), ""): + self.assertEqual(production.diagnostic_file(self.documents, relative)[0]["status"], "path_refused") + + def test_root_swapped_to_real_symlink_between_lstat_and_open(self): + outside = self.root / "outside" + outside.mkdir() + (outside / "secret").write_text(PRIVATE_CONTENT) + original_resolve = Path.resolve + + def swap_on_parent_resolve(path, *args, **kwargs): + if path == self.documents.parent: + self.documents.rename(self.root / "original-documents") + self.documents.symlink_to(outside, target_is_directory=True) + return original_resolve(path, *args, **kwargs) + + with mock.patch.object(Path, "resolve", swap_on_parent_resolve): + metadata, data = production.diagnostic_file(self.documents, "secret", read=True) + self.assertEqual(metadata["status"], "link_or_directory_refused") + self.assertIsNone(data) + self.assertNotIn(PRIVATE_CONTENT, json.dumps(metadata)) + + def test_snapshot_only_fixed_fields_counts_and_current_identity(self): + acceptance = self.acceptance() + acceptance.documents = self.documents + # Gate flags here are fixture inputs, not evidence of an Apple install. + acceptance.installed = acceptance.xcui_ready = acceptance.request_staged = True + acceptance.install_elapsed_msec, acceptance.xcui_ready_elapsed_msec = 120, 240 + self.write("startup_trace", jsonl([row(), row(seq=2, ticks=1, result=-7)])) + self.write("observer", jsonl([ + {"run_id": RUN_ID, "kind": "opening", "message": PRIVATE_CONTENT}, + {"run_id": RUN_ID, "kind": "failure", "message": PRIVATE_CONTENT}, + {"run_id": RUN_ID, "kind": PRIVATE_CONTENT}, + {"run_id": "f" * 32, "kind": "opening"}])) + self.write("script_checkpoints", jsonl([ + {"run_id": RUN_ID, "source": "renpy-script", "stage": "start_ready", "details": PRIVATE_CONTENT}, + {"run_id": RUN_ID, "source": "host", "stage": "start_ready"}, + {"run_id": "f" * 32, "source": "renpy-script", "stage": "quit_requested"}])) + self.write("device_request", PRIVATE_CONTENT.encode()) + for label in ("engine_log", "renpy_log", "renpy_traceback"): + self.write(label, PRIVATE_CONTENT.encode()) + acceptance.snapshot_startup_diagnostics() + report = acceptance.summary["startup_diagnostics"] + self.assertTrue(report["install_passed"] and report["xcui_ready_passed"]) + self.assertEqual((report["install_elapsed_msec"], report["xcui_ready_elapsed_msec"]), (120, 240)) + self.assertEqual(report["startup_trace"]["records"], 2) + self.assertEqual(report["observer"]["records"], 3) + self.assertEqual(report["observer"]["kind_counts"]["failure"], 1) + self.assertEqual(report["observer"]["unknown_kind_records"], 1) + self.assertEqual(report["observer"]["violations"]["foreign_run"], 1) + self.assertEqual(report["script_checkpoints"]["stage_counts"]["start_ready"], 1) + self.assertEqual(report["script_checkpoints"]["unrecognized_records"], 1) + self.assertFalse(report["probe_request_exists"]) + self.assertTrue(report["device_request_exists"]) + self.assertTrue(report["probe_request_absent_after_staging"]) + self.assertNotIn("consumed", json.dumps(report)) + self.assertNotIn(PRIVATE_CONTENT, json.dumps(report)) + self.assertNotIn(str(self.root), json.dumps(report)) + self.assertEqual(acceptance.summary["status"], "failed") + self.assertEqual(acceptance.summary["checks"], []) + self.assertEqual(acceptance.summary["blocker"], "original gameplay failure") + + def test_unavailable_and_all_read_exceptions_do_not_mask_failure(self): + acceptance = self.acceptance() + acceptance.snapshot_startup_diagnostics() + self.assertEqual(acceptance.summary["startup_diagnostics"]["diagnostic_status"], "unavailable") + self.assertFalse(acceptance.summary["startup_diagnostics"]["install_passed"]) + self.assertFalse(acceptance.summary["startup_diagnostics"]["xcui_ready_passed"]) + acceptance = self.acceptance() + acceptance.documents = self.documents + with mock.patch.object(production, "diagnostic_file", side_effect=RuntimeError(PRIVATE_CONTENT)): + acceptance.snapshot_startup_diagnostics() + report = acceptance.summary["startup_diagnostics"] + self.assertEqual(report["read_errors"], len(production.DIAGNOSTIC_DOCUMENT_FILES)) + self.assertEqual(report["diagnostic_status"], "partial") + self.assertNotIn(PRIVATE_CONTENT, json.dumps(report)) + acceptance = self.acceptance() + with mock.patch.object(production, "diagnostic_rows", side_effect=RuntimeError(PRIVATE_CONTENT)): + acceptance.snapshot_startup_diagnostics() + self.assertEqual(acceptance.summary["startup_diagnostics"]["diagnostic_status"], "internal_error") + self.assertNotIn(PRIVATE_CONTENT, json.dumps(acceptance.summary)) + self.assertEqual(acceptance.summary["status"], "failed") + + def test_real_live_process_is_snapshotted_before_cleanup_termination(self): + acceptance = self.acceptance() + process = subprocess.Popen([sys.executable, "-c", "import sys; sys.stdin.buffer.read()"], + stdin=subprocess.PIPE) + acceptance.process = process + try: + acceptance.collect() + report = acceptance.summary["startup_diagnostics"] + self.assertEqual(report["test_process_before_cleanup"], {"state": "running", "returncode": None}) + self.assertIsNotNone(process.returncode) + self.assertNotEqual(process.returncode, 0) + # A repeated collection cannot replace the pre-cleanup observation + # with the signal that this collector itself sent to the child. + acceptance.collect() + self.assertEqual(report["test_process_before_cleanup"]["state"], "running") + self.assertEqual(acceptance.summary["status"], "failed") + self.assertEqual(acceptance.summary["checks"], []) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=10) + process.stdin.close() + + def test_real_already_exited_process_and_poll_exception(self): + for returncode in (0, 7): + with self.subTest(returncode=returncode): + acceptance = self.acceptance() + acceptance.process = subprocess.Popen([sys.executable, "-c", f"raise SystemExit({returncode})"]) + acceptance.process.wait(timeout=10) + acceptance.collect() + self.assertEqual(acceptance.summary["startup_diagnostics"]["test_process_before_cleanup"], + {"state": "exited", "returncode": returncode}) + self.assertEqual(acceptance.summary["status"], "failed") + acceptance = self.acceptance() + acceptance.process = mock.Mock() + acceptance.process.poll.side_effect = OSError(PRIVATE_CONTENT) + acceptance.snapshot_startup_diagnostics() + report = acceptance.summary["startup_diagnostics"] + self.assertEqual(report["test_process_before_cleanup"]["state"], "unavailable") + self.assertEqual(report["read_errors"], 1) + self.assertNotIn(PRIVATE_CONTENT, json.dumps(report)) + + def test_actual_ready_requires_both_identities_and_boolean_true(self): + ready = {"run_id": RUN_ID, "bundle_id": BUNDLE_ID, "ready": True} + self.assertTrue(production.is_actual_xcui_ready(ready, RUN_ID, BUNDLE_ID)) + for value in [None, [], True, 1, {}, {**ready, "run_id": "f" * 32}, + {**ready, "bundle_id": "another.app"}, {**ready, "ready": 1}, + {**ready, "ready": "true"}, {"run_id": RUN_ID, "ready": True}]: + with self.subTest(value=value): + self.assertFalse(production.is_actual_xcui_ready(value, RUN_ID, BUNDLE_ID)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 25e2782611e3bb21fcb1d8296891066da7ad27ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=85=9C?= Date: Fri, 9 Oct 2026 15:47:21 +0000 Subject: [PATCH 331/331] fix(ios): preserve staged gameplay data during XCTest startup --- tools/renpy_ios_acceptance/README.md | 18 +- tools/run_renpy_ios_device_acceptance.py | 247 ++++++++++++--- tools/test_renpy_ios_destination_artifacts.py | 294 ++++++++++++++++++ 3 files changed, 516 insertions(+), 43 deletions(-) create mode 100644 tools/test_renpy_ios_destination_artifacts.py diff --git a/tools/renpy_ios_acceptance/README.md b/tools/renpy_ios_acceptance/README.md index 0908a7a2d..c4526a9ce 100644 --- a/tools/renpy_ios_acceptance/README.md +++ b/tools/renpy_ios_acceptance/README.md @@ -48,8 +48,22 @@ Exit code `2` means execution did not meet the required evidence. Neither result proves phone gameplay. Simulator results also do not establish physical iPhone support. -The standalone UI test project targets an already installed app by its real -bundle ID. Host commands and acknowledgments use only the UI test runner's own +The harness builds and installs the actual UI runner before it queries the +Godot app's data container and stages the private demo request. It then uses +the documented `UseDestinationArtifacts` xctestrun mode: the installed runner +and target are selected by their bundle IDs, and the test bundle is verified +inside the runner's `PlugIns` directory. Host artifact paths are excluded, so +the test action does not install the app again after staging. The existing +overall timeout still includes the runner build. This protects the staging +handoff; it does not establish that a previous timeout changed the container. +After real foreground readiness, the harness queries the target container +again and requires the staged directory identity to match. A change fails +acceptance; it never restages or substitutes evidence after the app starts. +See Apple's [command-line testing documentation](https://developer.apple.com/library/archive/technotes/tn2339/_index.html) +and the `UseDestinationArtifacts` section of `man xcodebuild.xctestrun` in the +selected Xcode toolchain. + +Host commands and acknowledgments use only the UI test runner's own Documents directory; the game and observer independently write into the test app's Documents directory. Only the two acceptance directories and one-shot debug request are reset. The Simulator software-keyboard preference is restored diff --git a/tools/run_renpy_ios_device_acceptance.py b/tools/run_renpy_ios_device_acceptance.py index 1d5d23584..4bffcc9ea 100755 --- a/tools/run_renpy_ios_device_acceptance.py +++ b/tools/run_renpy_ios_device_acceptance.py @@ -10,6 +10,7 @@ from __future__ import annotations import argparse +import copy import errno import hashlib import json @@ -68,6 +69,25 @@ def is_actual_xcui_ready(value: object, run_id: str, bundle_id: str) -> bool: and value.get("bundle_id") == bundle_id and value.get("ready") is True) +def documents_identity(documents: Path) -> tuple[Path, int, int]: + """Identify the actual selected directory; publish no paths or inode data.""" + if not documents.is_absolute(): + raise ValueError("Actual app Documents identity requires an absolute container path") + parent_fd = directory_fd = None + try: + parent = documents.parent.resolve(strict=True) + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + parent_fd = os.open(parent, flags) + directory_fd = os.open(documents.name, flags, dir_fd=parent_fd) + info = os.fstat(directory_fd) + return parent / documents.name, info.st_dev, info.st_ino + finally: + if directory_fd is not None: + os.close(directory_fd) + if parent_fd is not None: + os.close(parent_fd) + + def diagnostic_file(root: Path | None, relative: str, *, read: bool = False) -> tuple[dict, bytes | None]: """Read fixed caller-selected files without following child symlinks. @@ -250,6 +270,128 @@ def diagnostic_rows(data: bytes | None, run_id: str, *, startup: bool = False) - return report +def configure_destination_artifacts(configuration: dict, test_root: Path, runner_app: Path, + bundle_id: str, run_id: str, overall_timeout: int) -> tuple[dict, str]: + """Bind a genuine built UI runner for the documented preinstalled mode. + + xcodebuild.xctestrun(5), UseDestinationArtifacts: exclude host artifact + paths and supply destination bundle identities plus the installed test + bundle path. __TESTHOST__/__TESTBUNDLE__ must remain device placeholders. + This configuration validation does not prove Apple test execution. + """ + if (not isinstance(configuration, dict) + or not isinstance(bundle_id, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,254}", bundle_id) + or not isinstance(run_id, str) or not re.fullmatch(r"[0-9a-f]{32}", run_id) + or type(overall_timeout) is not int or not 60 <= overall_timeout <= 1200): + raise Failed("Invalid actual UI destination configuration identity") + metadata = configuration.get("__xctestrun_metadata__") + if metadata is not None and (not isinstance(metadata, dict) + or type(metadata.get("FormatVersion")) is not int + or metadata["FormatVersion"] not in (1, 2)): + raise Failed("Unsupported actual UI test configuration format") + if runner_app.is_symlink() or not runner_app.is_dir() or runner_app.suffix != ".app": + raise Failed("The built UI runner must be a real application directory") + runner = runner_app.resolve(strict=True) + info_path = runner / "Info.plist" + if info_path.is_symlink() or not info_path.is_file(): + raise Failed("The built UI runner lacks its actual bundle metadata") + runner_info = plistlib.loads(info_path.read_bytes()) + runner_id = runner_info.get("CFBundleIdentifier") + runner_platforms = runner_info.get("CFBundleSupportedPlatforms") + if (not isinstance(runner_id, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,254}", runner_id) + or runner_id == bundle_id or not isinstance(runner_platforms, list) + or "iPhoneSimulator" not in runner_platforms): + raise Failed("The built UI runner lacks a real Simulator bundle identity") + result = copy.deepcopy(configuration) + targets = [] + + def find_targets(value): + if isinstance(value, dict): + if "TestBundlePath" in value or "TestBundleDestinationRelativePath" in value: + targets.append(value) + else: + for child in value.values(): + find_targets(child) + elif isinstance(value, list): + for child in value: + find_targets(child) + + find_targets(result) + if len(targets) != 1 or targets[0].get("IsUITestBundle") is not True: + raise Failed("The actual configuration must contain exactly one UI test target") + target = targets[0] + environment = target.get("TestingEnvironmentVariables") + if (not isinstance(environment, dict) + or any(not isinstance(key, str) or not isinstance(value, str) for key, value in environment.items())): + raise Failed("The actual UI target lacks its required testing environment") + + def artifact_path(value, *, allow_test_host=False): + if not isinstance(value, str) or not value or "\0" in value: + raise Failed("The actual UI target lacks a valid build artifact path") + expanded = value.replace("__TESTROOT__", str(test_root.resolve(strict=True))) + if allow_test_host: + expanded = expanded.replace("__TESTHOST__", str(runner)) + path = Path(expanded) + if (re.search(r"__[A-Z0-9_]+__", expanded) or not path.is_absolute() + or ".." in path.parts): + raise Failed("The actual UI target has an unsupported build artifact path") + return path + + host = artifact_path(target.get("TestHostPath")).resolve(strict=True) + if host != runner or ("TestHostBundleIdentifier" in target + and target["TestHostBundleIdentifier"] != runner_id): + raise Failed("The actual UI test host does not match the selected built runner") + original_bundle = artifact_path(target.get("TestBundlePath"), allow_test_host=True) + test_bundle = original_bundle.resolve(strict=True) + try: + relative = test_bundle.relative_to(runner) + except ValueError as exc: + raise Failed("The actual test bundle is outside its installed UI runner") from exc + if (len(relative.parts) < 2 or relative.parts[0] != "PlugIns" + or test_bundle.suffix != ".xctest" or not test_bundle.is_dir()): + raise Failed("The actual UI test bundle must be embedded in its runner PlugIns") + # Reject links within the installed bundle. Host filesystem aliases above + # the application directory were canonicalized separately. + original_host = next((parent for parent in original_bundle.parents + if parent.resolve(strict=True) == runner), None) + if original_host is None or original_host.is_symlink(): + raise Failed("The actual test bundle is not a direct descendant of its built runner") + component = original_host + for part in original_bundle.relative_to(original_host).parts: + component = component / part + if component.is_symlink(): + raise Failed("The actual installed test bundle cannot traverse a symlink") + bundle_info = test_bundle / "Info.plist" + if bundle_info.is_symlink() or not bundle_info.is_file(): + raise Failed("The actual embedded UI test bundle lacks its bundle metadata") + test_bundle_id = plistlib.loads(bundle_info.read_bytes()).get("CFBundleIdentifier") + if not isinstance(test_bundle_id, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,254}", test_bundle_id): + raise Failed("The actual embedded UI test bundle lacks its bundle identity") + target.update(UseDestinationArtifacts=True, TestHostBundleIdentifier=runner_id, + TestBundleDestinationRelativePath="__TESTHOST__/" + relative.as_posix(), + UITargetAppBundleIdentifier=bundle_id) + launch_environment = target.setdefault("EnvironmentVariables", {}) + if (not isinstance(launch_environment, dict) + or any(not isinstance(key, str) or not isinstance(value, str) for key, value in launch_environment.items())): + raise Failed("The actual UI target has an invalid launch environment") + launch_environment.update({ + "AETHER_RENPY_RUN_ID": run_id, "AETHER_RENPY_APP_BUNDLE_ID": bundle_id, + "AETHER_RENPY_TEST_TIMEOUT": str(overall_timeout)}) + for key in ("TestBundlePath", "TestHostPath", "UITargetAppPath", "DependentProductPaths"): + target.pop(key, None) + + def relocate_test_root(value): + if isinstance(value, dict): + return {key: relocate_test_root(child) for key, child in value.items()} + if isinstance(value, list): + return [relocate_test_root(child) for child in value] + if isinstance(value, str): + return value.replace("__TESTROOT__", str(test_root.resolve(strict=True))) + return value + + return relocate_test_root(result), runner_id + + class Acceptance: def __init__(self, args: argparse.Namespace): self.args = args @@ -261,10 +403,17 @@ def __init__(self, args: argparse.Namespace): self.created = self.installed = False self.bundle_id = "" self.documents: Path | None = None + self.staged_documents_identity: tuple[Path, int, int] | None = None + self.container_identity_checked = False + self.container_identity_same: bool | None = None self.ui_root: Path | None = None self.sequence = 0 self.process: subprocess.Popen | None = None self.process_log = None + self.prepared_xctestrun: Path | None = None + self.ui_runner_id = "" + self.test_artifact_mode = "not_prepared" + self.runner_installed = self.destination_bindings_verified = False self.deadline = 0.0 self.waiting_marker = None self.keyboard_preference: str | None = None @@ -286,7 +435,12 @@ def startup_gate_diagnostics(self) -> dict: return {"schema_version": 1, "diagnostic_status": "not_collected", "install_passed": self.installed, "install_elapsed_msec": self.install_elapsed_msec, "xcui_ready_passed": self.xcui_ready, "xcui_ready_elapsed_msec": self.xcui_ready_elapsed_msec, - "request_staged": self.request_staged} + "request_staged": self.request_staged, + "test_artifact_mode": self.test_artifact_mode, + "runner_install_passed": self.runner_installed, + "destination_bindings_verified": self.destination_bindings_verified, + "container_identity_checked": self.container_identity_checked, + "container_identity_same": self.container_identity_same} def snapshot_startup_diagnostics(self) -> None: """Collect fixed same-run facts before cleanup; never change acceptance. @@ -556,10 +710,11 @@ def stage(self) -> None: (self.documents / "aetherkiri-probe-request.json").write_text(json.dumps({ "probe_script": "res://scripts/renpy_mobile_acceptance.gd", "run_id": self.run_id, "game_path": str(game.parent), "timeout_seconds": self.args.overall_timeout})) + self.staged_documents_identity = documents_identity(self.documents) self.request_staged = True self.summary["startup_diagnostics"]["request_staged"] = True - def start_xcuitest(self) -> None: + def prepare_xcuitest(self) -> None: project = self.repo / "tools/renpy_ios_acceptance/RenPyAcceptance.xcodeproj" derived = self.output / "xcode-derived" destination = "platform=iOS Simulator,id=" + self.udid @@ -569,49 +724,35 @@ def start_xcuitest(self) -> None: paths = list((derived / "Build/Products").glob("*.xctestrun")) if len(paths) != 1: raise Failed("Xcode did not produce one executable UI test configuration") - configuration = plistlib.loads(paths[0].read_bytes()) - targets = [] - - def configure(value): - if isinstance(value, dict): - if "TestBundlePath" in value and value.get("IsUITestBundle"): - value["UITargetAppPath"] = str(self.args.app.resolve()) - value["UITargetAppBundleIdentifier"] = self.bundle_id - value.setdefault("EnvironmentVariables", {}).update({ - "AETHER_RENPY_RUN_ID": self.run_id, "AETHER_RENPY_APP_BUNDLE_ID": self.bundle_id, - "AETHER_RENPY_TEST_TIMEOUT": str(self.args.overall_timeout)}) - value.setdefault("DependentProductPaths", []).append(str(self.args.app.resolve())) - targets.append(value) - else: - for child in value.values(): - configure(child) - elif isinstance(value, list): - for child in value: - configure(child) - - configure(configuration) - if len(targets) != 1: - raise Failed("Xcode UI configuration lacks the real UI test target; refusing to substitute a fake host") - xctestrun = self.output / "RenPyGameplay.xctestrun" - # __TESTROOT__ is relative to the .xctestrun location. - def resolve_paths(value): - if isinstance(value, dict): - for key, child in value.items(): - value[key] = resolve_paths(child) - elif isinstance(value, list): - return [resolve_paths(child) for child in value] - elif isinstance(value, str): - return value.replace("__TESTROOT__", str(paths[0].parent)) - return value - - xctestrun.write_bytes(plistlib.dumps(resolve_paths(configuration))) runners = list((derived / "Build/Products").glob("**/RenPyAcceptance-Runner.app/Info.plist")) if len(runners) != 1: raise Failed("Xcode did not build the real Simulator UI test runner app") - runner_id = plistlib.loads(runners[0].read_bytes())["CFBundleIdentifier"] + configuration, runner_id = configure_destination_artifacts( + plistlib.loads(paths[0].read_bytes()), paths[0].parent, runners[0].parent, + self.bundle_id, self.run_id, self.args.overall_timeout) + xctestrun = self.output / "RenPyGameplay.xctestrun" + xctestrun.write_bytes(plistlib.dumps(configuration)) + self.destination_bindings_verified = True + self.test_artifact_mode = "destination" + self.summary["startup_diagnostics"].update( + test_artifact_mode="destination", destination_bindings_verified=True) + # Both genuine apps are installed before querying/staging the target + # data container. The ensuing test action cannot reinstall artifacts. + self.simctl("install", self.udid, str(runners[0].parent.resolve()), timeout=180) + self.runner_installed = True + self.summary["startup_diagnostics"]["runner_install_passed"] = True self.summary["ui_test_runner"] = runner_id + self.ui_runner_id = runner_id + self.prepared_xctestrun = xctestrun + + def start_xcuitest(self) -> None: + if (self.prepared_xctestrun is None or not self.runner_installed + or not self.destination_bindings_verified): + raise Failed("Actual destination UI test artifacts were not prepared and installed") + destination = "platform=iOS Simulator,id=" + self.udid + runner_id = self.ui_runner_id self.process_log = (self.output / "xcuitest.log").open("wb") - self.process = subprocess.Popen(["xcodebuild", "test-without-building", "-xctestrun", str(xctestrun), + self.process = subprocess.Popen(["xcodebuild", "test-without-building", "-xctestrun", str(self.prepared_xctestrun), "-destination", destination, "-resultBundlePath", str(self.output / "gameplay.xcresult"), "-parallel-testing-enabled", "NO"], stdout=self.process_log, stderr=subprocess.STDOUT) @@ -633,6 +774,27 @@ def ready(): self.xcui_ready_elapsed_msec = self.elapsed_msec() self.summary["startup_diagnostics"].update( xcui_ready_passed=True, xcui_ready_elapsed_msec=self.xcui_ready_elapsed_msec) + # The real test process has launched the bound app. Requery rather + # than assuming the prelaunch container survived the test action. + current = self.simctl("get_app_container", self.udid, self.bundle_id, "data").decode().strip() + self.verify_staged_documents(Path(current) / "Documents") + + def verify_staged_documents(self, current_documents: Path) -> None: + self.container_identity_checked = False + self.container_identity_same = None + self.summary["startup_diagnostics"].update(container_identity_checked=False, container_identity_same=None) + if self.staged_documents_identity is None: + raise Failed("The actual app Documents container was not bound during staging") + try: + current_identity = documents_identity(current_documents) + except (OSError, ValueError, RuntimeError) as exc: + raise Failed("The actual app Documents container cannot be verified after UI launch") from exc + self.container_identity_checked = True + self.container_identity_same = current_identity == self.staged_documents_identity + self.summary["startup_diagnostics"].update( + container_identity_checked=True, container_identity_same=self.container_identity_same) + if not self.container_identity_same: + raise Failed("The actual app data container changed after staging; refusing stale gameplay evidence") def run(self) -> None: if not self.args.cloud_simulator: @@ -667,10 +829,13 @@ def run(self) -> None: self.summary["startup_diagnostics"].update( install_passed=True, install_elapsed_msec=self.install_elapsed_msec) self.simctl("terminate", self.udid, self.bundle_id, check=False) + # Preserve the existing overall budget: it still includes the real + # build-for-testing work, now before data-container staging. + self.deadline = time.monotonic() + self.args.overall_timeout + self.prepare_xcuitest() root = self.simctl("get_app_container", self.udid, self.bundle_id, "data").decode().strip() self.documents = Path(root) / "Documents" self.stage() - self.deadline = time.monotonic() + self.args.overall_timeout self.start_xcuitest() self.wait_game("start_ready") self.tap_button(self.capture("start_ready")) diff --git a/tools/test_renpy_ios_destination_artifacts.py b/tools/test_renpy_ios_destination_artifacts.py new file mode 100644 index 000000000..44e003aa9 --- /dev/null +++ b/tools/test_renpy_ios_destination_artifacts.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""Check production xctestrun transformation using real host filesystem fixtures. + +The fixture plist/bundle directories exercise configuration and containment; +they are not compiled Apple bundles. No SDK, Simulator installation, XCTest, +native runtime, graphics, input or gameplay success is simulated or claimed. +""" + +from __future__ import annotations + +import ast +import argparse +import copy +import plistlib +import tempfile +import unittest +from pathlib import Path + +import run_renpy_ios_device_acceptance as production + + +RUN_ID = "0123456789abcdef0123456789abcdef" +RUNNER_ID = "org.aetherkiri.renpy-ios-acceptance.xctrunner" +APP_ID = "org.aetherkiri.renpy-simulator.debug" + + +class DestinationArtifacts(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="aether-ios-destination-artifacts-") + self.root = Path(self.temporary.name) + self.products = self.root / "Products with spaces" + self.products.mkdir() + self.runner = self.products / "Debug-iphonesimulator/RenPyAcceptance-Runner.app" + self.test_bundle = self.runner / "PlugIns/RenPyAcceptance.xctest" + self.test_bundle.mkdir(parents=True) + (self.runner / "Info.plist").write_bytes(plistlib.dumps({ + "CFBundleIdentifier": RUNNER_ID, "CFBundleSupportedPlatforms": ["iPhoneSimulator"]})) + (self.test_bundle / "Info.plist").write_bytes(plistlib.dumps({ + "CFBundleIdentifier": "org.aetherkiri.renpy-ios-acceptance"})) + self.target = { + "BlueprintName": "RenPyAcceptance", "IsUITestBundle": True, + "TestHostPath": "__TESTROOT__/Debug-iphonesimulator/RenPyAcceptance-Runner.app", + "TestBundlePath": "__TESTHOST__/PlugIns/RenPyAcceptance.xctest", + "TestHostBundleIdentifier": RUNNER_ID, + "UITargetAppPath": "__TESTROOT__/Aether.app", + "DependentProductPaths": ["__TESTROOT__/Aether.app", "__TESTHOST__/PlugIns/RenPyAcceptance.xctest"], + "TestingEnvironmentVariables": { + "DYLD_FRAMEWORK_PATH": "__TESTROOT__/Debug-iphonesimulator:__PLATFORMS__/Developer/Library/Frameworks", + "XCInjectBundleInto": "__TESTHOST__/RenPyAcceptance-Runner", + "TEST_BUNDLE": "__TESTBUNDLE__", + "SHARED_FRAMEWORKS": "__SHAREDFRAMEWORKS__"}, + "EnvironmentVariables": {"EXISTING_PARAMETER": "preserved"}, + } + + def tearDown(self): + self.temporary.cleanup() + + def transform(self, configuration=None, runner=None, **kwargs): + return production.configure_destination_artifacts( + configuration if configuration is not None else {"RenPyAcceptance": self.target}, + self.products, runner or self.runner, + kwargs.get("bundle_id", APP_ID), kwargs.get("run_id", RUN_ID), kwargs.get("overall_timeout", 600)) + + def assert_destination(self, target): + self.assertIs(target["UseDestinationArtifacts"], True) + self.assertEqual(target["TestHostBundleIdentifier"], RUNNER_ID) + self.assertEqual(target["UITargetAppBundleIdentifier"], APP_ID) + self.assertEqual(target["TestBundleDestinationRelativePath"], + "__TESTHOST__/PlugIns/RenPyAcceptance.xctest") + for key in ("TestHostPath", "TestBundlePath", "UITargetAppPath", "DependentProductPaths"): + self.assertNotIn(key, target) + self.assertEqual(target["EnvironmentVariables"], { + "EXISTING_PARAMETER": "preserved", "AETHER_RENPY_RUN_ID": RUN_ID, + "AETHER_RENPY_APP_BUNDLE_ID": APP_ID, "AETHER_RENPY_TEST_TIMEOUT": "600"}) + environment = target["TestingEnvironmentVariables"] + self.assertEqual(environment["XCInjectBundleInto"], "__TESTHOST__/RenPyAcceptance-Runner") + self.assertEqual(environment["TEST_BUNDLE"], "__TESTBUNDLE__") + self.assertEqual(environment["SHARED_FRAMEWORKS"], "__SHAREDFRAMEWORKS__") + self.assertEqual(environment["DYLD_FRAMEWORK_PATH"], str(self.products.resolve()) + + "/Debug-iphonesimulator:__PLATFORMS__/Developer/Library/Frameworks") + + def test_legacy_original_configuration_becomes_destination_only(self): + original = {"RenPyAcceptance": copy.deepcopy(self.target)} + # The previous normal configuration still supplies installation paths; + # it does not meet the documented destination-only contract. + self.assertNotIn("UseDestinationArtifacts", original["RenPyAcceptance"]) + self.assertIn("UITargetAppPath", original["RenPyAcceptance"]) + result, runner_id = self.transform(original) + self.assertEqual(runner_id, RUNNER_ID) + self.assert_destination(result["RenPyAcceptance"]) + self.assertEqual(original["RenPyAcceptance"], self.target) + # Exercise the actual plist serialization that production hands Xcode. + self.assertEqual(plistlib.loads(plistlib.dumps(result)), result) + + def test_version_two_and_testroot_bundle_path(self): + target = copy.deepcopy(self.target) + target["TestBundlePath"] = "__TESTROOT__/Debug-iphonesimulator/RenPyAcceptance-Runner.app/PlugIns/RenPyAcceptance.xctest" + configuration = { + "TestPlan": {"Name": "actual-format-fixture", "IsDefault": True}, + "TestConfigurations": [{"Name": "Debug", "IsEnabled": True, "TestTargets": [target]}], + "__xctestrun_metadata__": {"FormatVersion": 2}} + result, _ = self.transform(configuration) + self.assert_destination(result["TestConfigurations"][0]["TestTargets"][0]) + self.assertEqual(result["TestPlan"], configuration["TestPlan"]) + self.assertEqual(result["__xctestrun_metadata__"], {"FormatVersion": 2}) + for version in (1, 2): + with self.subTest(version=version): + self.assert_destination(self.transform({"RenPyAcceptance": copy.deepcopy(self.target), + "__xctestrun_metadata__": {"FormatVersion": version}})[0]["RenPyAcceptance"]) + + def test_absolute_paths_and_normal_parent_alias(self): + alias = self.root / "build-alias" + alias.symlink_to(self.products, target_is_directory=True) + target = copy.deepcopy(self.target) + target["TestHostPath"] = str(alias / self.runner.relative_to(self.products)) + target["TestBundlePath"] = str(alias / self.test_bundle.relative_to(self.products)) + self.assert_destination(self.transform({"RenPyAcceptance": target})[0]["RenPyAcceptance"]) + + def test_mandatory_source_paths_testing_environment_and_format(self): + for key in ("TestHostPath", "TestBundlePath", "TestingEnvironmentVariables"): + with self.subTest(key=key): + target = copy.deepcopy(self.target) + del target[key] + with self.assertRaises(production.Failed): + self.transform({"RenPyAcceptance": target}) + for key in ("TestingEnvironmentVariables", "EnvironmentVariables"): + for value in (None, [], {"value": 1}, {"value": True}): + with self.subTest(key=key, value=value): + target = copy.deepcopy(self.target) + target[key] = value + with self.assertRaises(production.Failed): + self.transform({"RenPyAcceptance": target}) + for metadata in ([], {}, {"FormatVersion": 3}, {"FormatVersion": True}, {"FormatVersion": "2"}): + with self.subTest(metadata=metadata), self.assertRaises(production.Failed): + self.transform({"RenPyAcceptance": copy.deepcopy(self.target), "__xctestrun_metadata__": metadata}) + + def test_duplicate_ui_or_additional_non_ui_targets_are_rejected(self): + for is_ui in (True, False): + with self.subTest(is_ui=is_ui), self.assertRaises(production.Failed): + extra = {**copy.deepcopy(self.target), "IsUITestBundle": is_ui} + self.transform({"RenPyAcceptance": copy.deepcopy(self.target), "Another": extra}) + configuration = {"TestConfigurations": [{"TestTargets": [copy.deepcopy(self.target), copy.deepcopy(self.target)]}], + "__xctestrun_metadata__": {"FormatVersion": 2}} + with self.assertRaises(production.Failed): + self.transform(configuration) + for is_ui in (False, 1, "true", None): + with self.subTest(is_ui=is_ui), self.assertRaises(production.Failed): + self.transform({"RenPyAcceptance": {**copy.deepcopy(self.target), "IsUITestBundle": is_ui}}) + + def test_host_path_and_identity_must_match_selected_actual_runner(self): + other = self.products / "another.app" + other.mkdir() + cases = [str(other), "__TESTHOST__", "__PLATFORMS__/runner.app", "relative.app", + "__TESTROOT__/Debug-iphonesimulator/../Debug-iphonesimulator/RenPyAcceptance-Runner.app"] + for path in cases: + with self.subTest(path=path), self.assertRaises(production.Failed): + self.transform({"RenPyAcceptance": {**copy.deepcopy(self.target), "TestHostPath": path}}) + with self.assertRaises(production.Failed): + self.transform({"RenPyAcceptance": {**copy.deepcopy(self.target), "TestHostBundleIdentifier": "other.runner"}}) + alias = self.products / "runner.app" + alias.symlink_to(self.runner, target_is_directory=True) + with self.assertRaises(production.Failed): + self.transform(runner=alias) + + def test_missing_wrong_platform_and_invalid_bundle_metadata(self): + runner_info = self.runner / "Info.plist" + for info in ({}, {"CFBundleIdentifier": RUNNER_ID}, + {"CFBundleIdentifier": APP_ID, "CFBundleSupportedPlatforms": ["iPhoneSimulator"]}, + {"CFBundleIdentifier": RUNNER_ID, "CFBundleSupportedPlatforms": ["iPhoneOS"]}, + {"CFBundleIdentifier": RUNNER_ID, "CFBundleSupportedPlatforms": "iPhoneSimulator"}): + with self.subTest(info=info): + runner_info.write_bytes(plistlib.dumps(info)) + with self.assertRaises(production.Failed): + self.transform() + runner_info.write_bytes(plistlib.dumps({"CFBundleIdentifier": RUNNER_ID, + "CFBundleSupportedPlatforms": ["iPhoneSimulator"]})) + bundle_info = self.test_bundle / "Info.plist" + for info in ({}, {"CFBundleIdentifier": ""}, {"CFBundleIdentifier": "bad/bundle"}): + with self.subTest(info=info): + bundle_info.write_bytes(plistlib.dumps(info)) + with self.assertRaises(production.Failed): + self.transform() + bundle_info.unlink() + with self.assertRaises(production.Failed): + self.transform() + + def test_test_bundle_escape_wrong_embedding_and_links_are_rejected(self): + outside = self.root / "outside.xctest" + outside.mkdir() + for path in (str(outside), str(self.runner), str(self.runner / "Info.plist")): + with self.subTest(path=path), self.assertRaises(production.Failed): + self.transform({"RenPyAcceptance": {**copy.deepcopy(self.target), "TestBundlePath": path}}) + inside_link = self.runner / "PlugIns/linked.xctest" + inside_link.symlink_to(self.test_bundle, target_is_directory=True) + outside_link = self.runner / "PlugIns/escaped.xctest" + outside_link.symlink_to(outside, target_is_directory=True) + for link in (inside_link, outside_link): + with self.subTest(link=link), self.assertRaises(production.Failed): + self.transform({"RenPyAcceptance": {**copy.deepcopy(self.target), "TestBundlePath": str(link)}}) + # Even a symlink to a valid actual metadata file is refused. + info = self.test_bundle / "Info.plist" + moved = self.root / "test-info.plist" + info.rename(moved) + info.symlink_to(moved) + with self.assertRaises(production.Failed): + self.transform() + + def test_configuration_identity_and_existing_destination_input_are_rejected(self): + for key, values in (("run_id", ["", "g" * 32, RUN_ID.upper(), 1]), + ("bundle_id", ["", "bad/app", 1]), + ("overall_timeout", [59, 1201, True, "600"])): + for value in values: + with self.subTest(key=key, value=value), self.assertRaises(production.Failed): + self.transform(**{key: value}) + result, _ = self.transform() + with self.assertRaises(production.Failed): + self.transform(result) + + def test_actual_directory_identity_alias_replacement_and_missing_guard(self): + documents = self.root / "Documents" + documents.mkdir() + acceptance = production.Acceptance(argparse.Namespace(output_dir=self.root / "output", udid=None)) + acceptance.summary.update(status="failed", blocker="original gameplay failure") + acceptance.staged_documents_identity = production.documents_identity(documents) + alias = self.root / "host-alias" + alias.symlink_to(self.root, target_is_directory=True) + acceptance.verify_staged_documents(alias / "Documents") + self.assertTrue(acceptance.summary["startup_diagnostics"]["container_identity_checked"]) + self.assertTrue(acceptance.summary["startup_diagnostics"]["container_identity_same"]) + # Real directory replacement must fail even if the lexical path is + # unchanged. The original inode remains alive at its renamed path. + documents.rename(self.root / "old-documents") + documents.mkdir() + with self.assertRaises(production.Failed): + acceptance.verify_staged_documents(documents) + self.assertFalse(acceptance.summary["startup_diagnostics"]["container_identity_same"]) + self.assertEqual(acceptance.summary["status"], "failed") + self.assertEqual(acceptance.summary["checks"], []) + self.assertEqual(acceptance.summary["blocker"], "original gameplay failure") + with self.assertRaises(production.Failed): + acceptance.verify_staged_documents(self.root / "missing") + self.assertFalse(acceptance.summary["startup_diagnostics"]["container_identity_checked"]) + self.assertIsNone(acceptance.summary["startup_diagnostics"]["container_identity_same"]) + with self.assertRaises(production.Failed): + acceptance.verify_staged_documents(Path("relative/Documents")) + link = self.root / "linked-documents" + link.symlink_to(documents, target_is_directory=True) + with self.assertRaises(production.Failed): + acceptance.verify_staged_documents(link) + self.assertNotIn(str(self.root), str(acceptance.summary["startup_diagnostics"])) + acceptance.staged_documents_identity = None + with self.assertRaises(production.Failed): + acceptance.verify_staged_documents(documents) + + def test_production_order_keeps_all_installs_before_stage_and_budget_before_build(self): + # Static ordering supplements the real transform/filesystem tests; it + # does not execute or mock an Apple command or its successful result. + source = Path(production.__file__).read_text() + tree = ast.parse(source) + acceptance = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "Acceptance") + methods = {node.name: node for node in acceptance.body if isinstance(node, ast.FunctionDef)} + run = methods["run"] + calls = [(node.lineno, node.func.attr, [arg.value if isinstance(arg, ast.Constant) else None for arg in node.args]) + for node in ast.walk(run) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) and node.func.value.id == "self"] + line = lambda name: next(number for number, operation, _ in calls if operation == name) + install = next(number for number, operation, args in calls if operation == "simctl" and args[0] == "install") + container = next(number for number, operation, args in calls if operation == "simctl" and args[0] == "get_app_container") + deadline = next(node.lineno for node in ast.walk(run) if isinstance(node, ast.Assign) + and any(isinstance(target, ast.Attribute) and target.attr == "deadline" for target in node.targets)) + self.assertLess(install, deadline) + self.assertLess(deadline, line("prepare_xcuitest")) + self.assertLess(line("prepare_xcuitest"), container) + self.assertLess(container, line("stage")) + self.assertLess(line("stage"), line("start_xcuitest")) + self.assertLess(line("start_xcuitest"), line("wait_game")) + prepare_calls = [node for node in ast.walk(methods["prepare_xcuitest"]) if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) and node.func.attr == "simctl"] + self.assertEqual(len(prepare_calls), 1) + self.assertEqual(prepare_calls[0].args[0].value, "install") + self.assertFalse(any(key.arg == "check" and isinstance(key.value, ast.Constant) + and key.value.value is False for key in prepare_calls[0].keywords)) + self.assertNotIn("build-for-testing", ast.get_source_segment(source, methods["start_xcuitest"])) + self.assertIn("test-without-building", ast.get_source_segment(source, methods["start_xcuitest"])) + startup = methods["start_xcuitest"] + verification = next(node.lineno for node in ast.walk(startup) if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) and node.func.attr == "verify_staged_documents") + ready_wait = next(node.lineno for node in ast.walk(startup) if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) and node.func.attr == "wait") + self.assertLess(ready_wait, verification) + + +if __name__ == "__main__": + unittest.main(verbosity=2)