Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/godot_app/scripts/main.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
98 changes: 88 additions & 10 deletions apps/godot_app/scripts/runtime_regression_probe.gd
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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", []):
Expand All @@ -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()})
Expand Down
130 changes: 130 additions & 0 deletions apps/godot_app/tests/runtime_player_reopen_test.gd
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions apps/godot_app/tests/runtime_player_reopen_test.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://ce66oywbt7sbd
38 changes: 38 additions & 0 deletions apps/godot_app/tests/runtime_regression_probe_test.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}}}
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions apps/godot_app/tests/runtime_regression_touch_test.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 8 additions & 4 deletions tools/run_runtime_regression.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading