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/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_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 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/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: 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,