From 06fbe06e46ba606a8090cddfaae3d11990e2dbf7 Mon Sep 17 00:00:00 2001 From: SaltyC <27722486+MadCcc@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:10:39 +0800 Subject: [PATCH 1/3] fix(app): reinitialize engine handles when reopening games Returning to the library destroys the native engine while retaining the player Node and runtime kind. Reuse a matching player only while its engine remains initialized so RFVP can set its encoding before opening the next session. Add behavioral regressions for repeated reopening, restored settings, initialization failure and retry. Validate the macOS Debug build with RFVP and AetherInternal enabled, five Godot script suites, and normal-library first launch, natural exit, second launch and Chinese story input in Astrea. --- apps/godot_app/scripts/main.gd | 8 +- .../tests/runtime_player_reopen_test.gd | 130 ++++++++++++++++++ .../tests/runtime_player_reopen_test.gd.uid | 1 + 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 apps/godot_app/tests/runtime_player_reopen_test.gd create mode 100644 apps/godot_app/tests/runtime_player_reopen_test.gd.uid diff --git a/apps/godot_app/scripts/main.gd b/apps/godot_app/scripts/main.gd index 586ed1e2..9f345634 100644 --- a/apps/godot_app/scripts/main.gd +++ b/apps/godot_app/scripts/main.gd @@ -12493,7 +12493,13 @@ func _switch_runtime_player(runtime_kind: String) -> bool: RUNTIME_ADVHD, ]: normalized = RUNTIME_KIRIKIRI - if player != null and current_player_runtime_kind == normalized: + # Returning to the library keeps the player Node but destroys its engine. + # Recreate that handle before callers apply any pre-open runtime options. + if ( + player != null + and current_player_runtime_kind == normalized + and player.is_initialized() + ): return true if game_running: _append_log("Cannot switch visual-novel runtimes while a game is running.") diff --git a/apps/godot_app/tests/runtime_player_reopen_test.gd b/apps/godot_app/tests/runtime_player_reopen_test.gd new file mode 100644 index 00000000..c03bd73e --- /dev/null +++ b/apps/godot_app/tests/runtime_player_reopen_test.gd @@ -0,0 +1,130 @@ +extends SceneTree + +class RuntimePlayer: + extends RefCounted + + var initialized := false + var initialization_succeeds := true + var initializations := 0 + var options: Dictionary = {} + + func is_initialized() -> bool: + return initialized + + func initialize_engine(_user_dir: String, _cache_dir: String) -> bool: + initializations += 1 + initialized = initialization_succeeds + return initialized + + func destroy_engine() -> void: + initialized = false + options.clear() + + func set_engine_option(key: String, value: String) -> int: + if not initialized: + return -2 + options[key] = value + return 0 + + func get_last_result() -> String: + return "INVALID_STATE" + + func get_last_error() -> String: + return "engine is not initialized" + +class RuntimeApp: + extends "res://scripts/main.gd" + + var backend_applications := 0 + var option_applications := 0 + var enhancement_applications := 0 + + func _append_log(_message: String) -> void: + pass + + func _cleanup_softpal_platform_ui() -> void: + pass + + func _apply_backend(_log_selection: bool) -> void: + backend_applications += 1 + + func _apply_engine_options() -> void: + option_applications += 1 + + func _apply_frame_enhancement_settings() -> void: + enhancement_applications += 1 + +var failures := 0 + +func _expect(value: bool, message: String) -> void: + if not value: + failures += 1 + push_error(message) + +func _initialize() -> void: + var fixture := DirAccess.create_temp("aether-runtime-reopen") + if fixture == null: + push_error("Create isolated runtime fixture") + quit(1) + return + var had_config := OS.has_environment("AETHERKIRI_TEST_CONFIG_JSON") + var old_config := OS.get_environment("AETHERKIRI_TEST_CONFIG_JSON") + var had_user := OS.has_environment("AETHERKIRI_USER_DIR") + var old_user := OS.get_environment("AETHERKIRI_USER_DIR") + OS.set_environment("AETHERKIRI_TEST_CONFIG_JSON", JSON.stringify({ + "user_dir": fixture.get_current_dir(), + })) + OS.unset_environment("AETHERKIRI_USER_DIR") + for runtime_kind in ["rfvp", "onscripter"]: + _check_reopen(runtime_kind) + if had_config: + OS.set_environment("AETHERKIRI_TEST_CONFIG_JSON", old_config) + else: + OS.unset_environment("AETHERKIRI_TEST_CONFIG_JSON") + if had_user: + OS.set_environment("AETHERKIRI_USER_DIR", old_user) + DirAccess.remove_absolute(fixture.get_current_dir().path_join("cache")) + fixture = null + if failures == 0: + print("RUNTIME_PLAYER_REOPEN_OK") + quit(1 if failures else 0) + +func _check_reopen(runtime_kind: String) -> void: + var app := RuntimeApp.new() + var player := RuntimePlayer.new() + app.player = player + app.cli_probe_script = "res://scripts/gui_render_probe.gd" + app.current_player_runtime_kind = ( + app.RUNTIME_KIRIKIRI if runtime_kind == "rfvp" else runtime_kind + ) + _expect(app._ensure_player_initialized(), "Initialize the app's first engine") + _expect(app._switch_runtime_player(runtime_kind), "Reuse the initialized engine") + _expect(player.initializations == 1, "An initialized engine is reused") + _expect(app.backend_applications == 0, "Reuse preserves the active renderer") + + # Returning to the library destroys the handle while retaining the player + # Node and runtime kind. RFVP sets its encoding before _on_open_game(). + for launch in range(3): + player.destroy_engine() + _expect(app._switch_runtime_player(runtime_kind), "Reopen after returning to the library") + _expect(player.is_initialized(), "Recreate the handle before launch options") + _expect(player.initializations == launch + 2, "Initialize once per reopened session") + _expect(player.options.get("runtime", "") == ( + "auto" if runtime_kind == "rfvp" else runtime_kind + ), "Restore runtime selection after recreation") + _expect(player.set_engine_option("rfvp_encoding", "gbk") == 0, "Pre-open options have an initialized engine") + _expect(app.backend_applications == launch + 1, "Restore the renderer after recreation") + _expect(app.option_applications == launch + 1, "Restore engine options after recreation") + _expect(app.enhancement_applications == launch + 1, "Restore frame effects after recreation") + + player.destroy_engine() + player.initialization_succeeds = false + _expect(not app._switch_runtime_player(runtime_kind), "Report reinitialization failure before launch") + _expect(not player.is_initialized(), "A failed initialization stays unavailable") + _expect(app.backend_applications == 3, "Do not configure a failed engine") + player.initialization_succeeds = true + _expect(app._switch_runtime_player(runtime_kind), "Allow retry after reinitialization failure") + _expect(player.is_initialized(), "Retry recreates the engine") + app.player = null + app.ui_motion.free() + app.free() diff --git a/apps/godot_app/tests/runtime_player_reopen_test.gd.uid b/apps/godot_app/tests/runtime_player_reopen_test.gd.uid new file mode 100644 index 00000000..1e3b7226 --- /dev/null +++ b/apps/godot_app/tests/runtime_player_reopen_test.gd.uid @@ -0,0 +1 @@ +uid://ce66oywbt7sbd From cfb782d492dd157da157ba744c171eec0b59279f Mon Sep 17 00:00:00 2001 From: SaltyC <27722486+MadCcc@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:30:36 +0800 Subject: [PATCH 2/3] test(app): automate production library reopen flows Add an opt-in library launch mode to the runtime regression probe so pre-open RFVP options execute through the production launch path without eager initialization. Record engine lifetime and actual presented-frame statistics, and cover host-requested and natural exits. Skip empty iOS seed copies because the app creates the isolated writable root at initialization. Validation passed on macOS and an iPad mini (A17 Pro, iPadOS 26.5.2): 23 automatic steps, four RFVP sessions, two host exits, one natural game exit, presented frames, engine cleanup and process shutdown. Godot probe/reopen contracts and five Python runner checks also passed. --- .../scripts/runtime_regression_probe.gd | 98 +++++++++++++++++-- .../tests/runtime_regression_probe_test.gd | 38 +++++++ tools/run_runtime_regression.py | 12 ++- 3 files changed, 134 insertions(+), 14 deletions(-) diff --git a/apps/godot_app/scripts/runtime_regression_probe.gd b/apps/godot_app/scripts/runtime_regression_probe.gd index 2899b0ab..7e60bd0e 100644 --- a/apps/godot_app/scripts/runtime_regression_probe.gd +++ b/apps/godot_app/scripts/runtime_regression_probe.gd @@ -1,6 +1,8 @@ extends RefCounted -# Explicit opt-in device regression runner. It never captures screenshots and +const ProbeConfig = preload("res://scripts/probe_config.gd") + +# Explicit opt-in device regression runner. It never saves screenshots and # writes engine saves only beneath a separate, seeded regression directory. var app: Node var config: Dictionary @@ -98,21 +100,79 @@ func host_state() -> Dictionary: "library_mode": app.home_library_mode, "game_running": app.game_running, "frame_texture_cleared": app.viewport.texture == null, + "engine_initialized": app.player != null and app.player.is_initialized(), + "player_instance_id": app.player.get_instance_id() if app.player != null else 0, + "startup_state": app.cached_startup_state, + "runtime": app.active_runtime_kind, + "render_errors": app.render_errors, } func state() -> Dictionary: var shell := host_state() - var parsed: Variant = {} if shell.library_visible else JSON.parse_string( - String(app.player.get_plugin_debug_info())) - if not parsed is Dictionary: - parsed = {} - if not shell.library_visible: + var inactive: bool = shell.library_visible or not shell.engine_initialized + var parsed: Dictionary = {} + if not inactive: + var diagnostics := JSON.new() + if diagnostics.parse(String(app.player.get_plugin_debug_info())) == OK and diagnostics.data is Dictionary: + parsed = diagnostics.data + if not inactive: var frame_size: Vector2i = app.player.get_frame_source_size() parsed["probe_frame"] = {"width": frame_size.x, "height": frame_size.y} parsed["host"] = shell last_state = parsed return parsed +func launch_from_library(game_path: String) -> bool: + # Exercise the production pre-open options and handle-reuse decision. + # Calling _ensure_player_initialized() here would hide reopen regressions. + app._prepare_cli_probe_view(config) + app.selected_backend = ProbeConfig.backend(config) + var game: Dictionary = app._game_info_from_path(game_path) + var overrides: Variant = config.get("library_game", {}) + if overrides is Dictionary: + game.merge(overrides, true) + game["path"] = game_path + app.selected_game = game + app._start_selected_game_after_entitlements() + if not app.game_running: + return false + return await app._probe_wait_startup(config) + +func check_frame(action: Dictionary, label: String) -> bool: + if not app.game_running or not app.player.is_initialized() or app.viewport.texture == null: + record({"event": "failed", "label": label, "reason": "game frame unavailable", "state": state()}) + return false + # Read the actual Godot output after presentation as well as the provider + # pixels. Only numerical evidence and hashes are saved, never images. + await RenderingServer.frame_post_draw + var frame: Dictionary = app.player.read_frame_rgba() + var image: Image = app.get_viewport().get_texture().get_image() + if image == null or image.is_empty(): + record({"event": "failed", "label": label, "reason": "presentation unavailable"}) + return false + var source_stats: Dictionary = app._frame_stats(frame) + var presented_stats: Dictionary = app._image_stats(image) + var minimum := float(action.get("min_visible_ratio", 0.1)) + var source_ratio := float(source_stats.visible) / maxf(1.0, float(source_stats.sampled)) + var presented_ratio := float(presented_stats.visible) / maxf(1.0, float(presented_stats.sampled)) + var hash := HashingContext.new() + hash.start(HashingContext.HASH_SHA256) + hash.update(frame.get("rgba", PackedByteArray())) + var evidence := { + "event": "frame", "label": label, + "width": frame.get("width", 0), "height": frame.get("height", 0), + "serial": frame.get("frame_serial", 0), + "source": source_stats, "presented": presented_stats, + "source_visible_ratio": source_ratio, "presented_visible_ratio": presented_ratio, + "source_sha256": hash.finish().hex_encode(), + "renderer": app.player.get_renderer_info(), "state": state(), + } + record(evidence) + if int(frame.get("width", 0)) <= 0 or int(frame.get("height", 0)) <= 0 or source_ratio < minimum or presented_ratio < minimum: + record({"event": "failed", "label": label, "reason": "empty or black frame"}) + return false + return true + func advance(frames: int) -> bool: for i in range(maxi(1, frames)): # Once a natural exit has returned to the shell, the engine handle is @@ -250,8 +310,17 @@ func run(game_path: String) -> int: if pointer_input not in ["mouse", "touch"]: record({"event": "failed", "reason": "invalid pointer_input"}) return 1 - record({"event": "start", "name": config.get("name", "regression"), "screenshots": false, "pointer_input": pointer_input}) - if not app._probe_open_game(config, game_path, "AETHERKIRI_PROBE_BACKEND") or not await app._probe_wait_startup(config): + var launch_mode := String(config.get("launch_mode", "probe")) + if launch_mode not in ["probe", "library"]: + record({"event": "failed", "reason": "invalid launch_mode"}) + return 1 + record({"event": "start", "name": config.get("name", "regression"), "screenshots": false, "pointer_input": pointer_input, "launch_mode": launch_mode}) + var opened := false + if launch_mode == "library": + opened = await launch_from_library(game_path) + else: + opened = app._probe_open_game(config, game_path, "AETHERKIRI_PROBE_BACKEND") and await app._probe_wait_startup(config) + if not opened: record({"event": "failed", "reason": "startup", "error": app.player.get_last_error()}) return 1 for raw in config.get("steps", []): @@ -269,10 +338,19 @@ func run(game_path: String) -> int: if not bool(host_state().library_visible): record({"event": "failed", "label": label, "reason": "library is not visible", "state": state()}) return 1 - app._prepare_cli_probe_view(config) - if not app._ensure_player_initialized() or not app._probe_open_game(config, game_path, "AETHERKIRI_PROBE_BACKEND") or not await app._probe_wait_startup(config): + if launch_mode == "library": + opened = await launch_from_library(game_path) + else: + app._prepare_cli_probe_view(config) + opened = app._ensure_player_initialized() and app._probe_open_game(config, game_path, "AETHERKIRI_PROBE_BACKEND") and await app._probe_wait_startup(config) + if not opened: record({"event": "failed", "label": label, "reason": "reopen", "error": app.player.get_last_error()}) return 1 + elif kind == "exit_to_library": + app._exit_game_for_go_back() + elif kind == "frame": + if not await check_frame(action, label): + return 1 elif kind == "wait": if not await wait_for(action.get("until", []), int(action.get("timeout_ms", 15000)), int(action.get("stable_frames", 3)), action.get("until_any", [])): record({"event": "failed", "label": label, "reason": "timeout", "state": last_state, "error": app.player.get_last_error()}) diff --git a/apps/godot_app/tests/runtime_regression_probe_test.gd b/apps/godot_app/tests/runtime_regression_probe_test.gd index 29decdea..ee7c2191 100644 --- a/apps/godot_app/tests/runtime_regression_probe_test.gd +++ b/apps/godot_app/tests/runtime_regression_probe_test.gd @@ -5,6 +5,10 @@ const Probe = preload("res://scripts/runtime_regression_probe.gd") class FakePlayer: extends RefCounted var queries := 0 + var initialized := true + + func is_initialized() -> bool: + return initialized func get_plugin_debug_info() -> String: queries += 1 @@ -23,6 +27,31 @@ class FakeHost: var shell_route := "library" var home_library_mode := "game" var game_running := false + var cached_startup_state := 2 + var active_runtime_kind := "rfvp" + var render_errors := 0 + var selected_backend := "" + var selected_game: Dictionary = {} + var production_launches := 0 + var eager_initializations := 0 + + func _prepare_cli_probe_view(_config: Dictionary) -> void: + game_running = false + + func _game_info_from_path(path: String) -> Dictionary: + return {"path": path, "name": "fixture"} + + func _start_selected_game_after_entitlements() -> void: + production_launches += 1 + player.initialized = true + game_running = true + + func _probe_wait_startup(_config: Dictionary) -> bool: + return true + + func _ensure_player_initialized() -> bool: + eager_initializations += 1 + return true func _initialize() -> void: var saved := {"before": {"dialogue_key": "original", "navigation": {"restore_pending": false}}} @@ -54,13 +83,22 @@ func _initialize() -> void: host.shell_root.visible = true host.game_view.visible = false host.viewport.visible = false + host.player.initialized = false var exited: Dictionary = probe.state() assert(exited.host.library_visible) assert(exited.host.route == "library") assert(exited.host.library_mode == "game") assert(exited.host.frame_texture_cleared) + assert(not exited.host.engine_initialized) + assert(exited.host.player_instance_id == active.host.player_instance_id) assert(not exited.has("vm_status")) assert(host.player.queries == 1) # Never query the destroyed engine. + probe.config = {"library_game": {"rfvpEncoding": "gbk", "path": "wrong"}} + assert(await probe.launch_from_library("fixture-root")) + assert(host.production_launches == 1) + assert(host.eager_initializations == 0) # Never mask the production bug. + assert(host.selected_game.path == "fixture-root") + assert(host.selected_game.rfvpEncoding == "gbk") host.shell_root.free() host.home_view.free() host.game_view.free() diff --git a/tools/run_runtime_regression.py b/tools/run_runtime_regression.py index cb13b769..9e7335b1 100644 --- a/tools/run_runtime_regression.py +++ b/tools/run_runtime_regression.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Run an opt-in runtime regression on macOS or a connected iOS device. -Cases and writable-state seeds are local inputs. No images are captured. +Cases and writable-state seeds are local inputs. No image files are saved. """ import argparse import datetime as dt @@ -104,9 +104,13 @@ def device(*command, name, check=True): if args.platform == "ios": remote = "Documents/regressions/" + run_id - device("device", "copy", "to", "--device", args.device, - "--domain-type", "appDataContainer", "--domain-identifier", args.bundle, - "--source", str(isolated), "--destination", remote, name="seed-copy") + # The app creates its isolated writable root during initialization. + # devicectl cannot copy an empty directory to a path that does not yet + # exist, so only transfer a seed when it actually contains state. + if any(isolated.iterdir()): + device("device", "copy", "to", "--device", args.device, + "--domain-type", "appDataContainer", "--domain-identifier", args.bundle, + "--source", str(isolated), "--destination", remote, name="seed-copy") if args.prepare_only: device("device", "copy", "from", "--device", args.device, "--domain-type", "appDataContainer", "--domain-identifier", args.bundle, From 732602b3452524587308b337a889413026c47096 Mon Sep 17 00:00:00 2001 From: SaltyC <27722486+MadCcc@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:12:24 +0800 Subject: [PATCH 3/3] test(app): update touch probe initialization fixture --- apps/godot_app/tests/runtime_regression_touch_test.gd | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/godot_app/tests/runtime_regression_touch_test.gd b/apps/godot_app/tests/runtime_regression_touch_test.gd index ae768b5e..094d6df5 100644 --- a/apps/godot_app/tests/runtime_regression_touch_test.gd +++ b/apps/godot_app/tests/runtime_regression_touch_test.gd @@ -5,6 +5,8 @@ const Probe = preload("res://scripts/runtime_regression_probe.gd") class RecordingPlayer: extends Node var events: Array[Dictionary] = [] + func is_initialized() -> bool: + return true func get_frame_source_size() -> Vector2i: return Vector2i(1280, 720) func get_renderer_info() -> String: