diff --git a/.github/scripts/initialize-private-runtime-submodules.sh b/.github/scripts/initialize-private-runtime-submodules.sh index db2befa0..40c8c47e 100755 --- a/.github/scripts/initialize-private-runtime-submodules.sh +++ b/.github/scripts/initialize-private-runtime-submodules.sh @@ -24,6 +24,8 @@ git submodule update --init --recursive --depth 1 \ packages/psdfile \ packages/ruffle \ packages/xsystem4 \ + packages/xsystem35 \ + packages/system3 \ packages/rfvp \ packages/tjs2Decompiler diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c78dcb8e..aaa213ca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -142,7 +142,7 @@ jobs: git submodule update --init --depth 1 packages/AetherKrkr git submodule update --init --depth 1 packages/AetherMAGES git submodule update --init --depth 1 packages/ruffle - git submodule update --init --recursive --depth 1 packages/xsystem4 + git submodule update --init --recursive --depth 1 packages/xsystem4 packages/xsystem35 packages/system3 - name: Set application release version if: env.AETHERKIRI_RELEASE_VERSION != '' @@ -449,7 +449,7 @@ jobs: git submodule update --init --depth 1 packages/AetherMAGES git submodule update --init --depth 1 packages/rfvp git submodule update --init --depth 1 packages/ruffle - git submodule update --init --recursive --depth 1 packages/xsystem4 + git submodule update --init --recursive --depth 1 packages/xsystem4 packages/xsystem35 packages/system3 - name: Set application release version if: env.AETHERKIRI_RELEASE_VERSION != '' @@ -737,7 +737,10 @@ jobs: persist-credentials: false - name: Test Mac App Store nested signing - run: python3 -m unittest discover -s tests/python -p test_macos_app_store_signing.py -v + run: | + python3 -m unittest discover -s tests/python -p test_macos_app_store_signing.py -v + python3 -m unittest discover -s tests/python -p test_runtime_submodule_initialization.py -v + python3 -m unittest discover -s tests/python -p test_ctest_inventory.py -v - name: Initialize private runtime submodules if: env.AETHERKIRI_WITH_INTERNAL == 'true' @@ -787,7 +790,7 @@ jobs: git submodule update --init --depth 1 packages/AetherMAGES git submodule update --init --depth 1 packages/rfvp git submodule update --init --depth 1 packages/ruffle - git submodule update --init --recursive --depth 1 packages/xsystem4 + git submodule update --init --recursive --depth 1 packages/xsystem4 packages/xsystem35 packages/system3 - name: Set application release version if: env.AETHERKIRI_RELEASE_VERSION != '' @@ -958,6 +961,8 @@ jobs: -D BUILD_TOOLS=ON \ -D BUILD_ENGINE_API=OFF \ -D BUILD_GODOT_EXTENSION=OFF \ + -D AETHERKIRI_BUILD_YURIS_CORE_TESTS=ON \ + -D AETHERKIRI_BUILD_YURIS_PROVIDER_TESTS=ON \ -D VCPKG_MANIFEST_INSTALL=OFF \ -D VCPKG_INSTALLED_DIR="${GITHUB_WORKSPACE}/out/macos/debug/vcpkg_installed" \ -D CMAKE_C_COMPILER_LAUNCHER=ccache \ @@ -972,6 +977,27 @@ jobs: cmake --build "$test_build_dir" --parallel 4 ctest --test-dir "$test_build_dir" --show-only=human + inventory_file="${RUNNER_TEMP}/aetherkiri-test-inventory.json" + ctest --test-dir "$test_build_dir" --show-only=json-v1 > "$inventory_file" + required_suites=( + --require-executable siglus-runtime-jpeg + --require-executable mages-runtime-provider + --require-executable mages-host-events + --require-executable aether_xsystem35_embedded_midi_contract + ) + if [[ "$AETHERKIRI_WITH_INTERNAL" == "true" ]]; then + required_suites+=( + --require-executable aetherinternal-artemis-storage-tests + --require-executable engine-api-diagnostics + --require-executable advhd-runtime-tests + --require-executable advhd-registration-tests + --require-executable aether_yuris_compiled_tests + --require-executable aether_yuris_provider_tests + --require-executable bgi-runtime-vm + --require-executable bgi-runtime-provider + ) + fi + python3 tools/check_ctest_inventory.py "$inventory_file" "${required_suites[@]}" ctest \ --test-dir "$test_build_dir" \ --output-on-failure \ @@ -1130,7 +1156,7 @@ jobs: git submodule update --init --depth 1 packages/AetherKrkr git submodule update --init --depth 1 packages/AetherMAGES git submodule update --init --depth 1 packages/ruffle - git submodule update --init --recursive --depth 1 packages/xsystem4 + git submodule update --init --recursive --depth 1 packages/xsystem4 packages/xsystem35 packages/system3 - name: Verify private runtime packages if: env.AETHERKIRI_WITH_INTERNAL == 'true' @@ -1257,7 +1283,8 @@ jobs: LD_LIBRARY_PATH: ${{ github.workspace }}/apps/godot_app/bin/linux/debug run: | python3 -m unittest discover -s tests/python -p test_renpy_host.py - for test in video_library_folders video_library_ui video_import_permissions flash_library tyrano_library tyrano_asar renpy_library xsystem4_library rfvp_import; do + python3 -m unittest discover -s tests/python -p test_runtime_submodule_initialization.py -v + for test in video_library_folders video_library_ui video_import_permissions flash_library tyrano_library tyrano_asar renpy_library xsystem4_library xsystem35_library system3_library rfvp_import; do timeout 60 "$GODOT_BIN" --headless --path apps/godot_app \ --script "res://tests/${test}_test.gd" \ 2>&1 | tee "$RUNNER_TEMP/${test}.log" diff --git a/.gitmodules b/.gitmodules index 7e61132c..d37b8366 100644 --- a/.gitmodules +++ b/.gitmodules @@ -45,3 +45,11 @@ path = packages/xsystem4 url = https://github.com/AetherKiri/xsystem4.git branch = aether +[submodule "packages/xsystem35"] + path = packages/xsystem35 + url = https://github.com/AetherKiri/xsystem35-sdl2.git + branch = aether +[submodule "packages/system3"] + path = packages/system3 + url = https://github.com/AetherKiri/system3-sdl2.git + branch = aether diff --git a/CMakeLists.txt b/CMakeLists.txt index 9160ec0b..eaffd884 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -115,6 +115,10 @@ option(AETHERKIRI_ENABLE_RFVP "build the experimental native FVP runtime (requir option(BUILD_GPU_BRIDGE "build legacy external GPU bridge renderer" OFF) option(AETHERKIRI_ENABLE_ONSCRIPTER "enable the bundled OnscripterYuri runtime" ON) +option(AETHERKIRI_ENABLE_SYSTEM3 + "enable the native AliceSoft System 1/2/3 runtime" ON) +option(AETHERKIRI_ENABLE_XSYSTEM35 + "enable the native AliceSoft System 3.5/3.9 runtime" ON) option(AETHERKIRI_ENABLE_XSYSTEM4 "enable the native AliceSoft System 4 runtime" ON) option(AETHERKIRI_ENABLE_SIGLUS @@ -148,6 +152,12 @@ set(AETHERKIRI_INTERNAL_DIR set(AETHERKIRI_ONSCRIPTERYURI_DIR "${CMAKE_CURRENT_SOURCE_DIR}/packages/OnscripterYuri" CACHE PATH "path to an OnscripterYuri runtime checkout") +set(AETHERKIRI_SYSTEM3_DIR + "${CMAKE_CURRENT_SOURCE_DIR}/packages/system3" + CACHE PATH "path to a system3 runtime checkout") +set(AETHERKIRI_XSYSTEM35_DIR + "${CMAKE_CURRENT_SOURCE_DIR}/packages/xsystem35" + CACHE PATH "path to an xsystem35 runtime checkout") set(AETHERKIRI_XSYSTEM4_DIR "${CMAKE_CURRENT_SOURCE_DIR}/packages/xsystem4" CACHE PATH "path to an xsystem4 runtime checkout") @@ -297,6 +307,10 @@ if(IOS AND AETHERKIRI_INTERNAL_ENABLED AND AETHERKIRI_ENABLE_CODE_OBFUSCATION) endif() set(AETHERKIRI_BUILD_NATIVE_ONSCRIPTER_TESTS OFF) +set(AETHERKIRI_BUILD_NATIVE_RUNTIME_TESTS OFF) +if(ENABLE_TESTS AND NOT (IOS OR ANDROID OR WEB OR EMSCRIPTEN)) + set(AETHERKIRI_BUILD_NATIVE_RUNTIME_TESTS ON) +endif() if(ENABLE_TESTS AND AETHERKIRI_ENABLE_ONSCRIPTER AND NOT (IOS OR ANDROID OR WEB OR EMSCRIPTEN)) # The provider contract links the real ONS runtime and runtime registry. @@ -309,7 +323,7 @@ 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) +if(BUILD_ENGINE_API OR AETHERKIRI_BUILD_NATIVE_RUNTIME_TESTS OR AETHERKIRI_ENABLE_RFVP) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/krkr2_runtime) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/abi) endif() @@ -319,7 +333,15 @@ if(AETHERKIRI_ENABLE_ONSCRIPTER AND (BUILD_GODOT_EXTENSION OR AETHERKIRI_BUILD_NATIVE_ONSCRIPTER_TESTS)) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/onscripter_runtime) endif() -if(AETHERKIRI_ENABLE_XSYSTEM4 AND BUILD_GODOT_EXTENSION AND +if(AETHERKIRI_ENABLE_SYSTEM3 AND (BUILD_GODOT_EXTENSION OR ENABLE_TESTS) AND + (APPLE OR ANDROID OR UNIX) AND NOT (WEB OR EMSCRIPTEN)) + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/system3_runtime) +endif() +if(AETHERKIRI_ENABLE_XSYSTEM35 AND (BUILD_GODOT_EXTENSION OR ENABLE_TESTS) AND + (APPLE OR ANDROID OR UNIX) AND NOT (WEB OR EMSCRIPTEN)) + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/xsystem35_runtime) +endif() +if(AETHERKIRI_ENABLE_XSYSTEM4 AND (BUILD_GODOT_EXTENSION OR ENABLE_TESTS) AND (APPLE OR ANDROID OR UNIX) AND NOT (WEB OR EMSCRIPTEN)) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/xsystem4_runtime) endif() @@ -328,7 +350,8 @@ if(AETHERKIRI_ENABLE_SIGLUS AND BUILD_GODOT_EXTENSION AND # Web stays excluded until the wasm host path is designed (phase 5). add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/siglus_runtime) endif() -if(AETHERKIRI_ENABLE_MAGES AND BUILD_GODOT_EXTENSION AND +if(AETHERKIRI_ENABLE_MAGES AND + (BUILD_GODOT_EXTENSION OR AETHERKIRI_BUILD_NATIVE_RUNTIME_TESTS) AND NOT (WEB OR EMSCRIPTEN)) # The MAGES. runtime renders offscreen inside the embedded impacto host # library (compiled from the packages/AetherMAGES submodule). Built on diff --git a/README.md b/README.md index e4b7a7ac..62226ac8 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ Godot App Shell ├─ OnsRuntime ───────── OnscripterYuri ├─ SiglusRuntime ────── siglus_rs ├─ System4 Runtime ──── xsystem4 (AliceSoft) + ├─ System35 Runtime ─── xsystem35 (AliceSoft) + ├─ System3 Runtime ──── system3-sdl2 (AliceSoft System 1–3) ├─ Artemis Runtime ──── Artemis (private package) ├─ CatSystem2 Runtime ─ CatSystem2 (private package) ├─ BGI Runtime ──────── BGI / Ethornell (private package) @@ -74,6 +76,11 @@ and **Debug CPU** is a diagnostic fallback only. - Experimental TyranoScript browser-project playback from the visual-novel library on macOS, iOS/iPadOS and Android, with isolated saves and virtual keys. See the [prototype scope and tests](bridge/tyrano_runtime/README.md). +- Native AliceSoft System 3.5/3.9 playback via xsystem35, with ALD directory + import, Chinese/Japanese text and optional virtual keys. See the [native host and tested games](bridge/xsystem35_runtime/README.md). +- Native AliceSoft System 1–3 DAT playback via system3-sdl2, preserving translated + game IDs, UTF-8 and font configuration, with Shift-JIS for original Japanese games. + Virtual keys default off. See the [native host and validation](bridge/system3_runtime/README.md). - Native AliceSoft System 4 playback via the bundled xsystem4 runtime, with directory import, Chinese/Japanese text, audio, movies and optional virtual keys. See the [native host and validation](bridge/xsystem4_runtime/README.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index ccf43143..292c1dea 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -36,6 +36,8 @@ Godot App Shell ├─ OnsRuntime ───────── OnscripterYuri ├─ SiglusRuntime ────── siglus_rs ├─ System4 Runtime ──── xsystem4(AliceSoft) + ├─ System35 Runtime ─── xsystem35(AliceSoft) + ├─ System3 Runtime ──── system3-sdl2(AliceSoft System 1–3) ├─ Artemis Runtime ──── Artemis(私有 package) ├─ CatSystem2 Runtime ─ CatSystem2(私有 package) ├─ BGI Runtime ──────── BGI / Ethornell(私有 package) @@ -64,6 +66,8 @@ Godot App Shell 不覆盖已有本地存档。游戏目录只读时使用按游戏隔离的应用存储。 - 覆盖 macOS、iOS/iPadOS、Android、Web 与 Linux 的导出配置。 - 独立的 Flash 分类,使用原生 Ruffle 播放,支持声音、键鼠、触摸以及视觉小说的虚拟按钮。 +- 通过内置 xsystem35 原生运行 AliceSoft System 3.5/3.9 游戏,支持 ALD 目录导入、中日文和可选虚拟按键。参见[原生宿主与游戏测试范围](bridge/xsystem35_runtime/README.md)。 +- 通过内置 system3-sdl2 原生运行 AliceSoft System 1–3 DAT 游戏,沿用汉化包游戏 ID、UTF-8 编码与字体配置,日文版默认 Shift-JIS,虚拟按键默认关闭。参见[原生宿主与验证范围](bridge/system3_runtime/README.md)。 - 通过内置 xsystem4 原生运行 AliceSoft System 4 游戏,支持目录导入、中日文字体、音频、视频和可选虚拟按键。参见[原生宿主与验证范围](bridge/xsystem4_runtime/README.md)。 - macOS、iOS/iPadOS 和 Android 原型支持从视觉小说库运行 TyranoScript 网页项目,提供独立存档和虚拟按键。 支持范围与验证方法见 [接入说明](bridge/tyrano_runtime/README.md)。 diff --git a/apps/godot_app/scripts/game_input_mapping.gd b/apps/godot_app/scripts/game_input_mapping.gd index d2e80ac7..32f9db3f 100644 --- a/apps/godot_app/scripts/game_input_mapping.gd +++ b/apps/godot_app/scripts/game_input_mapping.gd @@ -7,7 +7,7 @@ static func input_surface_size( content_size: Vector2, requested_surface: Vector2 ) -> Vector2: - if runtime_kind in ["advhd", "minori", "onscripter", "rfvp", "siglus", "softpal", "xsystem4", "yuris"]: + if runtime_kind in ["advhd", "minori", "onscripter", "rfvp", "siglus", "softpal", "xsystem4", "xsystem35", "system3", "yuris"]: return content_size if requested_surface.x > 0.0 and requested_surface.y > 0.0: return requested_surface diff --git a/apps/godot_app/scripts/game_launch_entry.gd b/apps/godot_app/scripts/game_launch_entry.gd index b9a517bf..5fc7af60 100644 --- a/apps/godot_app/scripts/game_launch_entry.gd +++ b/apps/godot_app/scripts/game_launch_entry.gd @@ -17,6 +17,8 @@ const DIRECTORY_RUNTIME_KINDS := [ "tyranoscript", "wa2", "xsystem4", + "xsystem35", + "system3", ] const YURIS_USER_STARTUP_CANDIDATES := [ # The stock YU-RIS package enters through this script. It owns the diff --git a/apps/godot_app/scripts/game_metadata.gd b/apps/godot_app/scripts/game_metadata.gd index a445b9ee..236d81a6 100644 --- a/apps/godot_app/scripts/game_metadata.gd +++ b/apps/godot_app/scripts/game_metadata.gd @@ -12,6 +12,8 @@ const RUNTIME_TYRANO := "tyranoscript" const RUNTIME_RENPY := "renpy" const RUNTIME_MAGES := "mages" const RUNTIME_XSYSTEM4 := "xsystem4" +const RUNTIME_XSYSTEM35 := "xsystem35" +const RUNTIME_SYSTEM3 := "system3" const RUNTIME_YURIS := "yuris" static func inspect(path: String) -> Dictionary: @@ -61,6 +63,12 @@ static func inspect(path: String) -> Dictionary: elif (files.has("system40.ini") or files.has("alicestart.ini")) and _has_extension(files, "ain"): result.engine = RUNTIME_XSYSTEM4 result.signals.append("system4-ain-ini") + elif _has_suffix(files, "sa.ald") or files.has("adisk.ald"): + result.engine = RUNTIME_XSYSTEM35 + result.signals.append("system35-scenario-ald") + elif (files.has("adisk.dat") or files.has("gdisk.dat") or files.has("agame.dat") or files.has("adisk.pat")) and (files.has("acg.dat") or files.has("gcg.dat")): + result.engine = RUNTIME_SYSTEM3 + result.signals.append("system3-dat") elif not hcb.is_empty(): result.engine = RUNTIME_RFVP result.signals.append("fvp-hcb") @@ -408,3 +416,9 @@ static func _unique_nonempty(values: PackedStringArray) -> PackedStringArray: seen[normalized] = true result.append(normalized) return result + +static func _has_suffix(files: PackedStringArray, suffix: String) -> bool: + for name in files: + if name.ends_with(suffix): + return true + return false diff --git a/apps/godot_app/scripts/main.gd b/apps/godot_app/scripts/main.gd index 9f345634..bf1ad7ee 100644 --- a/apps/godot_app/scripts/main.gd +++ b/apps/godot_app/scripts/main.gd @@ -2076,6 +2076,8 @@ const RUNTIME_CATSYSTEM2 := "catsystem2" const RUNTIME_SIGLUS := "siglus" const RUNTIME_RENPY := "renpy" const RUNTIME_XSYSTEM4 := "xsystem4" +const RUNTIME_XSYSTEM35 := "xsystem35" +const RUNTIME_SYSTEM3 := "system3" const RUNTIME_WA2 := "wa2" const RUNTIME_BGI := "bgi" const RUNTIME_ADVHD := "advhd" @@ -9654,7 +9656,7 @@ func _game_launch_entry_label(game: Dictionary) -> String: func _can_configure_launch_file(game: Dictionary) -> bool: return OS.get_name() != "Web" \ and not builtin_demo.is_game(game) \ - and _game_runtime_kind(String(game.get("path", ""))) not in [RUNTIME_ONSCRIPTER, RUNTIME_XSYSTEM4] \ + and _game_runtime_kind(String(game.get("path", ""))) not in [RUNTIME_ONSCRIPTER, RUNTIME_XSYSTEM4, RUNTIME_XSYSTEM35, RUNTIME_SYSTEM3] \ and String(game.get("type", "Directory")).to_lower() == "directory" func _set_launch_file_for_selected() -> void: @@ -12489,6 +12491,8 @@ func _switch_runtime_player(runtime_kind: String) -> bool: RUNTIME_BGI, RUNTIME_MAGES, RUNTIME_XSYSTEM4, + RUNTIME_XSYSTEM35, + RUNTIME_SYSTEM3, RUNTIME_YURIS, RUNTIME_ADVHD, ]: @@ -12525,7 +12529,11 @@ func _switch_runtime_player(runtime_kind: String) -> bool: diagnostic_session.finish() diagnostic_session.start(player, selected_backend) _append_log("Runtime selected: %s" % ( - "AliceSoft System 4" + "AliceSoft System 1/2/3" + if normalized == RUNTIME_SYSTEM3 + else "AliceSoft System 3.5/3.9" + if normalized == RUNTIME_XSYSTEM35 + else "AliceSoft System 4" if normalized == RUNTIME_XSYSTEM4 else "YU-RIS" if normalized == RUNTIME_YURIS @@ -13212,6 +13220,8 @@ func _ensure_player_initialized() -> bool: RUNTIME_BGI, RUNTIME_MAGES, RUNTIME_XSYSTEM4, + RUNTIME_XSYSTEM35, + RUNTIME_SYSTEM3, RUNTIME_YURIS, RUNTIME_ADVHD, ]: @@ -13228,7 +13238,11 @@ func _ensure_player_initialized() -> bool: return false _append_log("%s engine initialized." % ( - "AliceSoft System 4" + "AliceSoft System 1/2/3" + if current_player_runtime_kind == RUNTIME_SYSTEM3 + else "AliceSoft System 3.5/3.9" + if current_player_runtime_kind == RUNTIME_XSYSTEM35 + else "AliceSoft System 4" if current_player_runtime_kind == RUNTIME_XSYSTEM4 else "YU-RIS" if current_player_runtime_kind == RUNTIME_YURIS else "OnscripterYuri" @@ -15932,6 +15946,10 @@ func _renderer_summary(renderer: String) -> String: var summary := selected_backend if renderer.contains("backend=onscripter_yuri"): summary = "OnscripterYuri (Godot Texture)" + elif renderer.contains("backend=system3"): + summary = "System 1/2/3 (Godot Texture)" + elif renderer.contains("backend=xsystem35"): + summary = "System 3.5/3.9 (Godot Texture)" elif renderer.contains("backend=xsystem4"): summary = "System 4 (Godot Texture)" elif renderer.contains("backend=godot_native"): diff --git a/apps/godot_app/tests/system3_library_test.gd b/apps/godot_app/tests/system3_library_test.gd new file mode 100644 index 00000000..66f6b1dc --- /dev/null +++ b/apps/godot_app/tests/system3_library_test.gd @@ -0,0 +1,50 @@ +extends SceneTree + +const Metadata = preload("res://scripts/game_metadata.gd") +const Launch = preload("res://scripts/game_launch_entry.gd") +const Mapping = preload("res://scripts/game_input_mapping.gd") +const Controls = preload("res://scripts/game_virtual_controls.gd") +var fixture := "user://system3_library_test" + +func _initialize() -> void: + _remove() + _write("ADISK.DAT") + assert(Metadata.inspect(fixture).engine == "kirikiri", "DAT alone is ambiguous") + _write("AcG.dAt") + _write("SyStEm3.ExE") + var found := Metadata.inspect(fixture) + assert(found.engine == "system3" and found.launchFile.is_empty()) + assert(Metadata.inspect(fixture.path_join("SyStEm3.ExE")).engine == "system3") + assert(Launch.runtime_uses_directory("system3")) + assert(Launch.resolve_for_runtime({"path": fixture, "launchFile": "SyStEm3.ExE"}, "system3") == fixture) + _write("RanceSa.AlD") + assert(Metadata.inspect(fixture).engine == "xsystem35") + _write("ALICESTART.INI") + _write("Game.ain") + assert(Metadata.inspect(fixture).engine == "xsystem4") + _remove() + _write("GDISK.DAT") + _write("GCG.DAT") + assert(Metadata.inspect(fixture).engine == "system3", "Hint disk variants retain directory launch") + assert(Mapping.input_surface_size("system3", Vector2(640, 400), Vector2(1920, 1080)) == Vector2(640, 400)) + assert(Mapping.map_point(Vector2(960, 540), Rect2(0, 0, 1920, 1080), Vector2(640, 400)).is_equal_approx(Vector2(320, 200))) + var controls := Controls.new() + assert(not controls.is_panel_open() and not controls.is_menu_open()) + controls.free() + _remove() + print("SYSTEM3_LIBRARY_OK") + quit() + +func _write(name: String) -> void: + DirAccess.make_dir_recursive_absolute(fixture) + var file := FileAccess.open(fixture.path_join(name), FileAccess.WRITE) + assert(file != null) + file.store_string("") + +func _remove() -> void: + var directory := DirAccess.open(fixture) + if directory == null: + return + for child in directory.get_files(): + DirAccess.remove_absolute(fixture.path_join(child)) + DirAccess.remove_absolute(fixture) diff --git a/apps/godot_app/tests/system3_library_test.gd.uid b/apps/godot_app/tests/system3_library_test.gd.uid new file mode 100644 index 00000000..89957b1d --- /dev/null +++ b/apps/godot_app/tests/system3_library_test.gd.uid @@ -0,0 +1 @@ +uid://rkijehsw0tox diff --git a/apps/godot_app/tests/xsystem35_library_test.gd b/apps/godot_app/tests/xsystem35_library_test.gd new file mode 100644 index 00000000..feffc47d --- /dev/null +++ b/apps/godot_app/tests/xsystem35_library_test.gd @@ -0,0 +1,50 @@ +extends SceneTree + +const Metadata = preload("res://scripts/game_metadata.gd") +const Launch = preload("res://scripts/game_launch_entry.gd") +const Mapping = preload("res://scripts/game_input_mapping.gd") +const Controls = preload("res://scripts/game_virtual_controls.gd") +var fixture := "user://xsystem35_library_test" + +func _initialize() -> void: + _remove() + _write("System39.ain") + assert(Metadata.inspect(fixture).engine == "kirikiri", "An AIN alone is ambiguous") + _write("Rance4Sa.AlD") + _write("System39.exe") + var found := Metadata.inspect(fixture) + assert(found.engine == "xsystem35") + assert(found.launchFile.is_empty()) + assert(Metadata.inspect(fixture.path_join("System39.exe")).engine == "xsystem35") + assert(Launch.runtime_uses_directory("xsystem35")) + assert(Launch.resolve_for_runtime({"path": fixture, "launchFile": "System39.exe"}, "xsystem35") == fixture) + _write("ALICESTART.INI") + assert(Metadata.inspect(fixture).engine == "xsystem4", "System4 INI takes priority over ALD") + DirAccess.remove_absolute(fixture.path_join("ALICESTART.INI")) + DirAccess.remove_absolute(fixture.path_join("Rance4Sa.AlD")) + _write("ADISK.dat") + assert(Metadata.inspect(fixture).engine == "kirikiri", "DAT alone is ambiguous") + _write("ACG.DAT") + assert(Metadata.inspect(fixture).engine == "system3", "Older Rance4.1/4.2 need System3") + assert(Mapping.input_surface_size("xsystem35", Vector2(640, 480), Vector2(1920, 1080)) == Vector2(640, 480)) + assert(Mapping.map_point(Vector2(960, 540), Rect2(0, 0, 1920, 1080), Vector2(640, 480)).is_equal_approx(Vector2(320, 240))) + var controls := Controls.new() + assert(not controls.is_panel_open() and not controls.is_menu_open()) + controls.free() + _remove() + print("XSYSTEM35_LIBRARY_OK") + quit() + +func _write(name: String) -> void: + DirAccess.make_dir_recursive_absolute(fixture) + var file := FileAccess.open(fixture.path_join(name), FileAccess.WRITE) + assert(file != null) + file.store_string("") + +func _remove() -> void: + var directory := DirAccess.open(fixture) + if directory == null: + return + for child in directory.get_files(): + DirAccess.remove_absolute(fixture.path_join(child)) + DirAccess.remove_absolute(fixture) diff --git a/apps/godot_app/tests/xsystem35_library_test.gd.uid b/apps/godot_app/tests/xsystem35_library_test.gd.uid new file mode 100644 index 00000000..00e91d96 --- /dev/null +++ b/apps/godot_app/tests/xsystem35_library_test.gd.uid @@ -0,0 +1 @@ +uid://de16gl6e8hima diff --git a/bridge/godot_extension/CMakeLists.txt b/bridge/godot_extension/CMakeLists.txt index d12d7eec..0d41764c 100644 --- a/bridge/godot_extension/CMakeLists.txt +++ b/bridge/godot_extension/CMakeLists.txt @@ -277,6 +277,14 @@ if(TARGET aether_onscripter_runtime) target_compile_definitions(aether_kiri_godot PRIVATE AETHERKIRI_WITH_ONSCRIPTER=1) endif() +if(TARGET aether_system3_runtime) + target_link_libraries(aether_kiri_godot PRIVATE aether_system3_runtime) + target_compile_definitions(aether_kiri_godot PRIVATE AETHERKIRI_WITH_SYSTEM3=1) +endif() +if(TARGET aether_xsystem35_runtime) + target_link_libraries(aether_kiri_godot PRIVATE aether_xsystem35_runtime) + target_compile_definitions(aether_kiri_godot PRIVATE AETHERKIRI_WITH_XSYSTEM35=1) +endif() if(TARGET aether_xsystem4_runtime) target_link_libraries(aether_kiri_godot PRIVATE aether_xsystem4_runtime) target_compile_definitions(aether_kiri_godot PRIVATE AETHERKIRI_WITH_XSYSTEM4=1) diff --git a/bridge/godot_extension/src/aether_runtime_player.cpp b/bridge/godot_extension/src/aether_runtime_player.cpp index e14ad5a8..de745e24 100644 --- a/bridge/godot_extension/src/aether_runtime_player.cpp +++ b/bridge/godot_extension/src/aether_runtime_player.cpp @@ -5,6 +5,12 @@ #include "flash_player.h" #include "tyrano_player.h" #include "renpy_native.h" +#if defined(AETHERKIRI_WITH_SYSTEM3) +#include "system3_runtime_provider.h" +#endif +#if defined(AETHERKIRI_WITH_XSYSTEM35) +#include "xsystem35_runtime_provider.h" +#endif #if defined(AETHERKIRI_WITH_XSYSTEM4) #include "xsystem4_runtime_provider.h" #endif @@ -13887,6 +13893,16 @@ void InitializeAetherRuntime(ModuleInitializationLevel level) { #if defined(AETHERKIRI_WITH_ONSCRIPTER) aetherkiri::onscripter::RegisterRuntimeProvider(); #endif +#if defined(AETHERKIRI_WITH_SYSTEM3) + if(aetherkiri_system3_register_runtime_provider() != ENGINE_RESULT_OK) { + UtilityFunctions::printerr("Failed to register System 1/2/3 runtime provider"); + } +#endif +#if defined(AETHERKIRI_WITH_XSYSTEM35) + if(aetherkiri_xsystem35_register_runtime_provider() != ENGINE_RESULT_OK) { + UtilityFunctions::printerr("Failed to register System 3.5/3.9 runtime provider"); + } +#endif #if defined(AETHERKIRI_WITH_XSYSTEM4) if(aetherkiri_xsystem4_register_runtime_provider() != ENGINE_RESULT_OK) { UtilityFunctions::printerr("Failed to register System 4 runtime provider"); diff --git a/bridge/system3_runtime/CMakeLists.txt b/bridge/system3_runtime/CMakeLists.txt new file mode 100644 index 00000000..7f83e7fc --- /dev/null +++ b/bridge/system3_runtime/CMakeLists.txt @@ -0,0 +1,56 @@ +cmake_minimum_required(VERSION 3.28) +if(NOT DEFINED AETHERKIRI_SYSTEM3_DIR) + get_filename_component(AETHERKIRI_SYSTEM3_DIR "${CMAKE_CURRENT_LIST_DIR}/../../packages/system3" ABSOLUTE) +endif() +if(NOT EXISTS "${AETHERKIRI_SYSTEM3_DIR}/src/embedded.cpp") + message(FATAL_ERROR "Initialize packages/system3 on its Aether embedding branch") +endif() +if(NOT EXISTS "${AETHERKIRI_SYSTEM3_DIR}/deps/ymfm/src/ymfm_opn.cpp") + message(FATAL_ERROR "Initialize packages/system3 recursively (deps/ymfm)") +endif() +find_package(SDL2 CONFIG REQUIRED) +find_package(SDL2_ttf CONFIG REQUIRED) +find_package(Threads REQUIRED) + +set(core "${AETHERKIRI_SYSTEM3_DIR}") +file(GLOB vm_sources CONFIGURE_DEPENDS "${core}/src/sys/*.cpp") +list(FILTER vm_sources EXCLUDE REGEX "/mako_midi\\.cpp$") +add_library(aether_system3_core STATIC + ${vm_sources} + "${core}/src/embedded.cpp" + "${core}/src/config.cpp" + "${core}/src/fileio.cpp" + "${core}/src/resource.cpp" + "${core}/src/encoding.cpp" + "${core}/src/texthook.cpp" + "${core}/src/generic/nact_generic.cpp" + "${core}/src/generic/mako.cpp" + "${core}/src/fm/makofm.cpp" + "${core}/src/fm/mako_ymfm.cpp" + "${core}/deps/ymfm/src/ymfm_opn.cpp" + "${core}/deps/ymfm/src/ymfm_adpcm.cpp" + "${core}/deps/ymfm/src/ymfm_ssg.cpp") +target_compile_features(aether_system3_core PRIVATE cxx_std_17) +target_compile_definitions(aether_system3_core PRIVATE SYSTEM3_EMBEDDED + SYSTEM3_SDL_VERSION=2 SDL_MAIN_HANDLED RESOURCE_PATH="") +target_compile_options(aether_system3_core PRIVATE + -include "${core}/src/embedded_symbols.h") +target_include_directories(aether_system3_core PRIVATE + "${core}/src" "${core}/src/sys" "${core}/deps/ymfm/src" + "${core}/deps/dr_libs" "${core}/deps/stb") +set_source_files_properties("${core}/src/sys/music_decoder.cpp" PROPERTIES COMPILE_OPTIONS "-w") +target_link_libraries(aether_system3_core PRIVATE + $,SDL2::SDL2,SDL2::SDL2-static> + $,SDL2_ttf::SDL2_ttf,SDL2_ttf::SDL2_ttf-static>) +set_target_properties(aether_system3_core PROPERTIES POSITION_INDEPENDENT_CODE ON) + +add_library(aether_system3_runtime STATIC src/system3_runtime_provider.cpp) +target_compile_features(aether_system3_runtime PRIVATE cxx_std_17) +target_include_directories(aether_system3_runtime PUBLIC "${CMAKE_CURRENT_LIST_DIR}/include" + PRIVATE "${CMAKE_CURRENT_LIST_DIR}/../../abi/include" "${core}/src") +target_link_libraries(aether_system3_runtime PRIVATE aether_system3_core Threads::Threads + $,SDL2::SDL2,SDL2::SDL2-static>) +set_target_properties(aether_system3_runtime PROPERTIES POSITION_INDEPENDENT_CODE ON) +if(TARGET engine_api) + target_link_libraries(aether_system3_runtime PRIVATE engine_api) +endif() diff --git a/bridge/system3_runtime/README.md b/bridge/system3_runtime/README.md new file mode 100644 index 00000000..d7b41783 --- /dev/null +++ b/bridge/system3_runtime/README.md @@ -0,0 +1,91 @@ +# AliceSoft System 1–3 native runtime + +`packages/system3` pins the `aether` branch of +[AetherKiri/system3-sdl2](https://github.com/AetherKiri/system3-sdl2). +The fork’s `master` branch is reserved for upstream synchronization. + +```sh +git submodule update --init --recursive packages/system3 +``` + +Import the complete DAT game directory into the visual-novel library. Detection +requires a scenario archive (`ADISK.DAT`, `GDISK.DAT`, `AGAME.DAT` or `ADISK.PAT`) +and `ACG.DAT` or `GCG.DAT`, ignoring case. System4 INI and System35 ALD markers +take precedence. Windows launchers select their containing asset directory. +On iOS, use the existing `Games` folder. Shared virtual controls default off. + +## Native host + +The C++ VM runs on a worker thread. SDL2’s software renderer produces RGBA frames +for the existing Godot texture inside Aether’s window. The same native source is +wired for macOS, iOS/iPadOS, Android and Linux. No SDL video window or WebAssembly +is involved. `AETHERKIRI_ENABLE_SYSTEM3` defaults to `ON` for native exports; +`AETHERKIRI_SYSTEM3_DIR` selects an alternate checkout. + +Mouse/touch coordinates, buttons, wheel and Windows virtual keys enter a private +queue. Held presses and releases remain separate between interpreter polls. +Pause suspends the VM and its SDL audio device and clears held inputs. Closing +joins the worker and releases graphics, fonts, archives and audio. C++ exceptions +contain fatal VM errors without terminating Aether. One System3 VM can run at a +time. Dependencies are SDL2, SDL2_ttf and the fork’s pinned ymfm; the existing +native MP3/Ogg/WAV decoders and FM emulation provide audio without an external +MIDI device. + +Relative resources resolve against the selected game root, including Windows +backslashes and inconsistent filename case, without changing process cwd. +New saves and read-history data go to `/system3//`. +The identity uses archive content rather than the absolute path so iOS container +changes do not orphan saves. Existing saves in the imported game are read as a +fallback; a saved slot inside Aether takes precedence. Imported files are not +overwritten. + +## Text and translated games + +The game’s `system3.ini` retains its `game`, `encoding`, `fontfile` and `playlist` +settings. Original games default to Shift-JIS, while Unicode translations can +select UTF-8. If the game does not specify a font, Aether supplies its CJK font. +`system3_encoding=auto` preserves the game configuration; `sjis`, `utf8` and +`utf-8` are explicit overrides. Text hooks default off in the product and are +enabled only by the native diagnostic probe. + +The supplied Chinese Rance4.1 and Rance4.2 DAT editions explicitly select their +game IDs, UTF-8, a Chinese font and MP3 playlists. They use this runtime; their +newer ALD editions continue to use xsystem35. + +## Validation + +```sh +cmake -S tests/system3_native -B out/system3-native -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \ + -DVCPKG_MANIFEST_MODE=OFF -DVCPKG_TARGET_TRIPLET=arm64-osx \ + -DVCPKG_INSTALLED_DIR="$VCPKG_ROOT/installed" +cmake --build out/system3-native --parallel 4 +ctest --test-dir out/system3-native --output-on-failure +Godot --headless --path apps/godot_app --script res://tests/system3_library_test.gd +out/system3-native/aether_system3_native_probe GAME_DIR OUTPUT_DIR \ + apps/godot_app/assets/fonts/aetherkiri-runtime-cjk.otf +``` + +The probe accepts atomic `command.json` replacements with an increasing `seq`: + +```json +{"seq":1,"action":"capture","name":"title"} +``` + +Actions are `capture`, `click` (`x`, `y`, `button`, `count`, `hold_ms`), `key` +(Windows virtual key code), `pause`, `resume`, `reopen` and `quit`. `status.json` +acknowledges commands and reports the frame, errors, isolated save path and +audio peak. Tests use synthetic archives or upstream’s checked-in scenario +fixtures, without game assets. Contracts cover detection, contained errors, +repeated opening, RGBA reads, pause/resume, save fallback/isolation, unchanged +cwd, Chinese UTF-8/Japanese Shift-JIS and all four System 1/2/3/Gakuen King +interpreter variants. Other platforms and full-game completion need separate +playback verification. + +On 2026-10-24, macOS arm64 native JSON probes and the exported Debug app +verified Chinese Rance4.1/4.2 DAT startup, 640×400 frames, Chinese menus and +dialogue, graphics, 20 dialogue advances and MP3 music. Both games’ existing +imported saves were loaded in the native probe. Rance4.1 additionally saved an +isolated slot and loaded it after reopening; Rance4.2 passed pause/resume and +reopening. This is initial-flow coverage, not full-game completion. iOS, +Android and Linux playback remains unverified. diff --git a/bridge/system3_runtime/include/system3_runtime_provider.h b/bridge/system3_runtime/include/system3_runtime_provider.h new file mode 100644 index 00000000..61759bb0 --- /dev/null +++ b/bridge/system3_runtime/include/system3_runtime_provider.h @@ -0,0 +1,9 @@ +#pragma once +#include "engine_runtime_provider.h" +#ifdef __cplusplus +extern "C" { +#endif +engine_result_t aetherkiri_system3_register_runtime_provider(void); +#ifdef __cplusplus +} +#endif diff --git a/bridge/system3_runtime/src/system3_runtime_provider.cpp b/bridge/system3_runtime/src/system3_runtime_provider.cpp new file mode 100644 index 00000000..9262764c --- /dev/null +++ b/bridge/system3_runtime/src/system3_runtime_provider.cpp @@ -0,0 +1,705 @@ +#include "system3_runtime_provider.h" +#include "embedded.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +namespace aetherkiri::system3 { + namespace { + namespace fs = std::filesystem; + std::atomic active_runtime{ nullptr }; + std::string Lower(std::string value) { + for(char &c : value) + if(c >= 'A' && c <= 'Z') + c += 'a' - 'A'; + return value; + } + bool IsScenario(const std::string &name) { + return name == "adisk.dat" || name == "gdisk.dat" || + name == "agame.dat" || name == "adisk.pat"; + } + bool LooksLikeGame(const char *path) { + if(!path || !*path) + return false; + bool scenario = false, graphics = false, newer = false; + std::error_code error; + for(const auto &file : + fs::directory_iterator(fs::u8path(path), error)) { + if(!file.is_regular_file(error)) + continue; + const auto name = Lower(file.path().filename().u8string()); + newer |= name == "system40.ini" || name == "alicestart.ini" || + name == "adisk.ald" || + (name.size() >= 6 && + name.substr(name.size() - 6) == "sa.ald"); + scenario |= IsScenario(name); + graphics |= name == "acg.dat" || name == "gcg.dat"; + } + return !error && !newer && scenario && graphics; + } + std::string GameIdentity(const std::string &root) { + uint64_t value = 14695981039346656037ull; + auto append = [&](const std::string &data) { + for(unsigned char c : data) { + value ^= c; + value *= 1099511628211ull; + } + }; + // iOS container prefixes change after an app update. Identify the + // script content rather than the absolute import path so saves + // remain reachable. + std::vector scripts; + for(const auto &entry : fs::directory_iterator(fs::u8path(root))) + if(entry.is_regular_file() && + IsScenario(Lower(entry.path().filename().u8string()))) + scripts.push_back(entry.path()); + std::sort(scripts.begin(), scripts.end(), + [](const auto &a, const auto &b) { + return Lower(a.filename().u8string()) < + Lower(b.filename().u8string()); + }); + for(const auto &script : scripts) { + append(Lower(script.filename().u8string())); + append(std::to_string(fs::file_size(script))); + std::ifstream stream(script, std::ios::binary); + std::string data(65536, '\0'); + stream.read(data.data(), data.size()); + data.resize(stream.gcount()); + if(stream.bad()) + throw std::runtime_error( + "Could not read the System 1/2/3 script identity"); + append(data); + } + std::ostringstream result; + result << std::hex << value; + return result.str(); + } + + struct Frame { + uint32_t width = 0, height = 0; + uint64_t serial = 0; + std::vector pixels; + }; + struct Runtime { + engine_runtime_host_v1_t host{}; + std::string writable, root, saves, font, error, encoding = "auto", + selected_encoding; + std::mutex mutex, input_mutex; + std::condition_variable condition; + std::thread worker; + std::atomic stopping{ false }, paused{ false }; + std::atomic text_input{ false }, print_text{ false }; + std::atomic fps{ 60 }; + bool ended = false, ready = false; + std::shared_ptr frame, snapshot; + std::atomic serial{ 0 }; + uint64_t delivered = 0; + std::deque input; + // Do not drain a press and its release in the same VM input poll. + // System 3.x games read held key/button state after polling, rather + // than the events. + std::chrono::steady_clock::time_point next_button_event; + int mouse_x = 0, mouse_y = 0; + std::chrono::steady_clock::time_point last_present; + Runtime(const engine_runtime_host_v1_t *h, + const engine_create_desc_t *desc) : host(*h) { + if(desc->writable_path_utf8) + writable = desc->writable_path_utf8; + } + ~Runtime() { stop(); } + void fail(const std::string &message) { + std::lock_guard guard(mutex); + if(error.empty()) + error = message; + condition.notify_all(); + } + void stop() { + stopping = true; + paused = false; + condition.notify_all(); + if(worker.joinable()) + worker.join(); + } + bool checkpoint() { + if(stopping) + return true; + if(paused) { + system3_host_clear_input(); + + { + std::lock_guard guard(input_mutex); + input.clear(); + } + system3_host_pause_audio(true); + std::unique_lock guard(mutex); + condition.wait(guard, [&] { return !paused || stopping; }); + guard.unlock(); + system3_host_pause_audio(false); + } + return stopping; + } + void publish(const SDL_Surface *surface) { + if(!surface || surface->w < 1 || surface->h < 1 || + surface->w > 8192 || surface->h > 8192) { + fail("Invalid native frame dimensions"); + stopping = true; + return; + } + auto next = std::make_shared(); + next->width = surface->w; + next->height = surface->h; + next->pixels.resize(size_t(surface->w) * surface->h * 4); + for(int y = 0; y < surface->h; y++) + std::memcpy(next->pixels.data() + + size_t(y) * surface->w * 4, + static_cast(surface->pixels) + + size_t(y) * surface->pitch, + size_t(surface->w) * 4); + next->serial = ++serial; + { + std::lock_guard guard(mutex); + frame = std::move(next); + ready = true; + condition.notify_all(); + } + if(const auto limit = fps.load()) { + const auto deadline = last_present + + std::chrono::microseconds(1000000 / limit); + std::unique_lock guard(mutex); + condition.wait_until(guard, deadline, [&] { + return stopping.load() || paused.load(); + }); + } + last_present = std::chrono::steady_clock::now(); + } + void run() { + system3_host hooks{}; + hooks.user = this; + hooks.present = [](void *p, const SDL_Surface *surface) { + auto &self = *static_cast(p); + try { + self.publish(surface); + } catch(const std::exception &error) { + self.fail(error.what()); + self.stopping = true; + } + }; + hooks.poll_event = [](void *p, SDL_Event *event) -> int { + auto &self = *static_cast(p); + std::lock_guard guard(self.input_mutex); + if(self.input.empty()) + return 0; + const auto type = self.input.front().type; + const bool transition = type == SDL_MOUSEBUTTONDOWN || + type == SDL_MOUSEBUTTONUP || type == SDL_KEYDOWN || + type == SDL_KEYUP; + const auto now = std::chrono::steady_clock::now(); + if(transition && now < self.next_button_event) + return 0; + if(transition) + self.next_button_event = + now + std::chrono::milliseconds(16); + *event = self.input.front(); + self.input.pop_front(); + if(event->type == SDL_MOUSEMOTION) { + self.mouse_x = event->motion.x; + self.mouse_y = event->motion.y; + } + if(event->type == SDL_MOUSEBUTTONDOWN || + event->type == SDL_MOUSEBUTTONUP) { + self.mouse_x = event->button.x; + self.mouse_y = event->button.y; + } + return 1; + }; + hooks.checkpoint = [](void *p) -> int { + return static_cast(p)->checkpoint(); + }; + hooks.log = [](void *p, int level, const char *message) { + auto &self = *static_cast(p); + if(level == 0) + self.fail(message ? message + : "system3 reported an error"); + if(self.host.log) + self.host.log(self.host.user_data, + level == 0 ? ENGINE_RUNTIME_LOG_ERROR + : ENGINE_RUNTIME_LOG_INFO, + "system3", message); + }; + std::string session_font; + { + std::lock_guard guard(mutex); + session_font = font; + } + const int result = system3_host_run( + &hooks, root.c_str(), saves.c_str(), session_font.c_str(), + selected_encoding.c_str(), print_text.load()); + { + std::lock_guard guard(mutex); + ended = true; + if(result && error.empty()) + error = "The System 1/2/3 VM exited with code " + + std::to_string(result); + condition.notify_all(); + } + text_input = false; + void *expected = this; + active_runtime.compare_exchange_strong(expected, nullptr); + } + }; + engine_result_t Copy(const std::string &text, char *out, uint32_t size, + uint32_t *written = nullptr) { + if(!out || !size) + return ENGINE_RESULT_INVALID_ARGUMENT; + const size_t count = std::min(text.size(), size_t(size - 1)); + std::memcpy(out, text.data(), count); + out[count] = 0; + if(written) + *written = uint32_t(count); + return count == text.size() ? ENGINE_RESULT_OK + : ENGINE_RESULT_INVALID_ARGUMENT; + } + int32_t Probe(void *, const char *path) { + try { + return LooksLikeGame(path) ? 100 : 0; + } catch(...) { + return 0; + } + } + engine_result_t Create(void *, const engine_runtime_host_v1_t *host, + const engine_create_desc_t *desc, void **out) { + if(!host || !desc || !out || host->struct_size < sizeof(*host) || + desc->struct_size < sizeof(*desc) || + desc->api_version != ENGINE_API_VERSION || + host->api_version != ENGINE_RUNTIME_PROVIDER_API_VERSION) + return ENGINE_RESULT_INVALID_ARGUMENT; + *out = nullptr; + try { + *out = new Runtime(host, desc); + return ENGINE_RESULT_OK; + } catch(...) { + return ENGINE_RESULT_INTERNAL_ERROR; + } + } + void Destroy(void *opaque) { delete static_cast(opaque); } + engine_result_t Open(void *opaque, const char *path, const char *) { + auto *self = static_cast(opaque); + if(!self || !path) + return ENGINE_RESULT_INVALID_ARGUMENT; + if(!Probe(nullptr, path)) { + self->fail("Select a System 1/2/3 folder containing its " + "scenario DAT and graphics DAT archives"); + return ENGINE_RESULT_INVALID_ARGUMENT; + } + self->stop(); + void *expected = nullptr; + if(!active_runtime.compare_exchange_strong(expected, self)) { + self->fail("Another System 1/2/3 game is running"); + return ENGINE_RESULT_INVALID_STATE; + } + try { + self->root = fs::absolute(fs::u8path(path)) + .lexically_normal() + .u8string(); + { + std::lock_guard guard(self->mutex); + self->selected_encoding = self->encoding; + } + if(self->writable.empty()) + throw std::runtime_error( + "A writable save directory is required"); + self->saves = (fs::u8path(self->writable) / "system3" / + GameIdentity(self->root)) + .u8string(); + fs::create_directories(fs::u8path(self->saves)); + { + std::lock_guard guard(self->mutex); + self->error.clear(); + self->ready = self->ended = false; + self->frame.reset(); + self->snapshot.reset(); + self->delivered = 0; + } + self->stopping = self->paused = false; + { + std::lock_guard guard(self->input_mutex); + self->input.clear(); + self->next_button_event = {}; + self->mouse_x = self->mouse_y = 0; + } + self->worker = std::thread([self] { + try { + self->run(); + } catch(const std::exception &error) { + self->fail(error.what()); + { + std::lock_guard guard(self->mutex); + self->ended = true; + self->condition.notify_all(); + } + void *value = self; + active_runtime.compare_exchange_strong(value, nullptr); + } + }); + bool succeeded; + { + std::unique_lock guard(self->mutex); + self->condition.wait_for( + guard, std::chrono::seconds(45), + [&] { return self->ready || self->ended; }); + succeeded = self->ready && self->error.empty(); + } + if(!succeeded) { + self->fail("System 1/2/3 did not produce a startup frame"); + self->stop(); + return ENGINE_RESULT_IO_ERROR; + } + return ENGINE_RESULT_OK; + } catch(const std::exception &error) { + self->fail(error.what()); + self->stop(); + void *value = self; + active_runtime.compare_exchange_strong(value, nullptr); + return ENGINE_RESULT_IO_ERROR; + } + } + engine_result_t Tick(void *p, uint32_t) { + if(!p) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + std::lock_guard guard(self.mutex); + if(self.ended && self.error.empty()) + self.error = "System 1/2/3 runtime requested termination"; + return self.error.empty() ? ENGINE_RESULT_OK + : ENGINE_RESULT_IO_ERROR; + } + engine_result_t Pause(void *p) { + if(!p) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + self.paused = true; + self.condition.notify_all(); + return ENGINE_RESULT_OK; + } + engine_result_t Resume(void *p) { + if(!p) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + self.paused = false; + self.condition.notify_all(); + return ENGINE_RESULT_OK; + } + engine_result_t Option(void *p, const engine_option_t *option) { + if(!p || !option || !option->key_utf8) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + const std::string key = option->key_utf8, + value = + option->value_utf8 ? option->value_utf8 : ""; + if(key == "default_font") { + std::lock_guard guard(self.mutex); + self.font = value; + return ENGINE_RESULT_OK; + } + if(key == "system3_texthook") { + if(value != "none" && value != "print") + return ENGINE_RESULT_INVALID_ARGUMENT; + self.print_text = value == "print"; + return ENGINE_RESULT_OK; + } + if(key == "system3_encoding") { + if(value != "auto" && value != "sjis" && value != "utf8" && + value != "utf-8") + return ENGINE_RESULT_INVALID_ARGUMENT; + std::lock_guard guard(self.mutex); + self.encoding = value; + return ENGINE_RESULT_OK; + } + if(key == "fps_limit") { + try { + self.fps = std::clamp(std::stoi(value), 0, 240); + return ENGINE_RESULT_OK; + } catch(...) { + return ENGINE_RESULT_INVALID_ARGUMENT; + } + } + return ENGINE_RESULT_NOT_SUPPORTED; + } + engine_result_t Surface(void *p, uint32_t w, uint32_t h) { + return p && w && h ? ENGINE_RESULT_OK + : ENGINE_RESULT_INVALID_ARGUMENT; + } + engine_result_t Describe(void *p, engine_frame_desc_t *out) { + if(!p || !out || out->struct_size < sizeof(*out)) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + std::lock_guard guard(self.mutex); + if(!self.frame) + return ENGINE_RESULT_INVALID_STATE; + self.snapshot = self.frame; + out->width = self.snapshot->width; + out->height = self.snapshot->height; + out->stride_bytes = out->width * 4; + out->pixel_format = ENGINE_PIXEL_FORMAT_RGBA8888; + out->frame_serial = self.snapshot->serial; + return ENGINE_RESULT_OK; + } + engine_result_t Read(void *p, void *out, size_t size) { + if(!p || !out) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + std::lock_guard guard(self.mutex); + auto frame = self.snapshot ? self.snapshot : self.frame; + if(!frame) + return ENGINE_RESULT_INVALID_STATE; + if(size < frame->pixels.size()) + return ENGINE_RESULT_INVALID_ARGUMENT; + std::memcpy(out, frame->pixels.data(), frame->pixels.size()); + self.delivered = frame->serial; + return ENGINE_RESULT_OK; + } + SDL_Scancode Scancode(int key) { + if(key >= 'A' && key <= 'Z') + return SDL_Scancode(SDL_SCANCODE_A + key - 'A'); + if(key >= '1' && key <= '9') + return SDL_Scancode(SDL_SCANCODE_1 + key - '1'); + if(key == '0') + return SDL_SCANCODE_0; + if(key >= 0x70 && key <= 0x7b) + return SDL_Scancode(SDL_SCANCODE_F1 + key - 0x70); + switch(key) { + case 8: + return SDL_SCANCODE_BACKSPACE; + case 9: + return SDL_SCANCODE_TAB; + case 13: + return SDL_SCANCODE_RETURN; + case 16: + return SDL_SCANCODE_LSHIFT; + case 17: + return SDL_SCANCODE_LCTRL; + case 18: + return SDL_SCANCODE_LALT; + case 27: + return SDL_SCANCODE_ESCAPE; + case 32: + return SDL_SCANCODE_SPACE; + case 33: + return SDL_SCANCODE_PAGEUP; + case 34: + return SDL_SCANCODE_PAGEDOWN; + case 35: + return SDL_SCANCODE_END; + case 36: + return SDL_SCANCODE_HOME; + case 37: + return SDL_SCANCODE_LEFT; + case 38: + return SDL_SCANCODE_UP; + case 39: + return SDL_SCANCODE_RIGHT; + case 40: + return SDL_SCANCODE_DOWN; + case 45: + return SDL_SCANCODE_INSERT; + case 46: + return SDL_SCANCODE_DELETE; + default: + return SDL_SCANCODE_UNKNOWN; + } + } + engine_result_t Input(void *p, const engine_input_event_t *input) { + if(!p || !input || input->struct_size < sizeof(*input)) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + if(self.stopping || self.paused) + return ENGINE_RESULT_OK; + SDL_Event event{}; + event.common.timestamp = SDL_GetTicks(); + switch(input->type) { + case ENGINE_INPUT_EVENT_POINTER_MOVE: + event.type = SDL_MOUSEMOTION; + event.motion.x = int(input->x); + event.motion.y = int(input->y); + break; + case ENGINE_INPUT_EVENT_POINTER_DOWN: + case ENGINE_INPUT_EVENT_POINTER_UP: + event.type = input->type == ENGINE_INPUT_EVENT_POINTER_DOWN + ? SDL_MOUSEBUTTONDOWN + : SDL_MOUSEBUTTONUP; + event.button.button = + input->button == 1 || (input->modifiers & 0x10) + ? SDL_BUTTON_RIGHT + : input->button == 2 || (input->modifiers & 0x20) + ? SDL_BUTTON_MIDDLE + : SDL_BUTTON_LEFT; + event.button.state = event.type == SDL_MOUSEBUTTONDOWN + ? SDL_PRESSED + : SDL_RELEASED; + event.button.x = int(input->x); + event.button.y = int(input->y); + break; + case ENGINE_INPUT_EVENT_POINTER_SCROLL: + event.type = SDL_MOUSEWHEEL; + event.wheel.y = input->delta_y > 0 ? 1 + : input->delta_y < 0 ? -1 + : 0; + break; + case ENGINE_INPUT_EVENT_KEY_DOWN: + case ENGINE_INPUT_EVENT_KEY_UP: + case ENGINE_INPUT_EVENT_BACK: { + event.type = input->type == ENGINE_INPUT_EVENT_KEY_UP + ? SDL_KEYUP + : SDL_KEYDOWN; + event.key.state = + event.type == SDL_KEYDOWN ? SDL_PRESSED : SDL_RELEASED; + event.key.keysym.scancode = + Scancode(input->type == ENGINE_INPUT_EVENT_BACK + ? 27 + : input->key_code); + if(event.key.keysym.scancode == SDL_SCANCODE_UNKNOWN) + return ENGINE_RESULT_NOT_SUPPORTED; + const int key = input->type == ENGINE_INPUT_EVENT_BACK + ? 27 + : input->key_code; + event.key.keysym.sym = key >= 'A' && key <= 'Z' + ? key + ('a' - 'A') + : (key >= '0' && key <= '9') || key == 8 || key == 9 || + key == 13 || key == 27 || key == 32 + ? key + : SDL_Keycode(event.key.keysym.scancode | + SDLK_SCANCODE_MASK); + event.key.keysym.mod = + (input->modifiers & 1 ? KMOD_SHIFT : 0) | + (input->modifiers & 2 ? KMOD_ALT : 0) | + (input->modifiers & 4 ? KMOD_CTRL : 0); + event.key.repeat = (input->modifiers & 0x80) != 0; + break; + } + case ENGINE_INPUT_EVENT_TEXT_INPUT: { + const uint32_t c = input->unicode_codepoint; + if(c > 0x10ffff || (c >= 0xd800 && c <= 0xdfff)) + return ENGINE_RESULT_INVALID_ARGUMENT; + event.type = SDL_TEXTINPUT; + if(c < 0x80) + event.text.text[0] = char(c); + else if(c < 0x800) { + event.text.text[0] = char(0xc0 | c >> 6); + event.text.text[1] = char(0x80 | (c & 63)); + } else if(c < 0x10000) { + event.text.text[0] = char(0xe0 | c >> 12); + event.text.text[1] = char(0x80 | ((c >> 6) & 63)); + event.text.text[2] = char(0x80 | (c & 63)); + } else { + event.text.text[0] = char(0xf0 | c >> 18); + event.text.text[1] = char(0x80 | ((c >> 12) & 63)); + event.text.text[2] = char(0x80 | ((c >> 6) & 63)); + event.text.text[3] = char(0x80 | (c & 63)); + } + break; + } + default: + return ENGINE_RESULT_NOT_SUPPORTED; + } + std::lock_guard guard(self.input_mutex); + if(event.type == SDL_MOUSEMOTION && !self.input.empty() && + self.input.back().type == SDL_MOUSEMOTION) + self.input.back() = event; + else { + if(self.input.size() >= 256) + self.input.pop_front(); + self.input.push_back(event); + } + return ENGINE_RESULT_OK; + } + engine_result_t Rendered(void *p, uint32_t *out) { + if(!p || !out) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + std::lock_guard guard(self.mutex); + *out = self.frame && self.frame->serial != self.delivered; + return ENGINE_RESULT_OK; + } + engine_result_t TextInput(void *p, uint32_t *out) { + if(!p || !out) + return ENGINE_RESULT_INVALID_ARGUMENT; + *out = static_cast(p)->text_input + ? ENGINE_TEXT_INPUT_STATE_ACTIVE + : ENGINE_TEXT_INPUT_STATE_NONE; + return ENGINE_RESULT_OK; + } + engine_result_t Renderer(void *p, char *out, uint32_t size) { + return p + ? Copy("backend=system3 native-software frame=rgba", out, size) + : ENGINE_RESULT_INVALID_ARGUMENT; + } + engine_result_t Debug(void *p, char *out, uint32_t size, + uint32_t *written) { + if(!p) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + std::lock_guard guard(self.mutex); + return Copy("runtime=system3 native-library frames=" + + std::to_string(self.serial.load()) + " encoding=" + + self.selected_encoding + " audio_peak=" + + std::to_string(system3_host_audio_peak()) + + " saves=" + self.saves, + out, size, written); + } + const char *Error(void *p) { + if(!p) + return "System 1/2/3 runtime is unavailable"; + auto &self = *static_cast(p); + std::lock_guard guard(self.mutex); + thread_local std::string text; + text = self.error; + return text.c_str(); + } + } // namespace +} // namespace aetherkiri::system3 + +extern "C" engine_result_t aetherkiri_system3_register_runtime_provider() { + using namespace aetherkiri::system3; + static engine_runtime_provider_v1_t provider{}; + static std::once_flag flag; + static engine_result_t result = ENGINE_RESULT_INTERNAL_ERROR; + std::call_once(flag, [] { + provider.struct_size = sizeof(provider); + provider.api_version = ENGINE_RUNTIME_PROVIDER_API_VERSION; + provider.runtime_id_utf8 = "system3"; + provider.display_name_utf8 = "AliceSoft System 1/2/3"; + provider.priority = 100; + provider.probe = Probe; + provider.create = Create; + provider.destroy = Destroy; + provider.open_game = Open; + provider.tick = Tick; + provider.pause = Pause; + provider.resume = Resume; + provider.set_option = Option; + provider.set_surface_size = Surface; + provider.get_frame_desc = Describe; + provider.read_frame_rgba = Read; + provider.send_input = Input; + provider.get_frame_rendered_flag = Rendered; + provider.get_renderer_info = Renderer; + provider.get_plugin_debug_info = Debug; + provider.get_last_error = Error; + provider.get_text_input_state = TextInput; + result = engine_register_runtime_provider(&provider); + }); + return result; +} diff --git a/bridge/xsystem35_runtime/CMakeLists.txt b/bridge/xsystem35_runtime/CMakeLists.txt new file mode 100644 index 00000000..27749ad2 --- /dev/null +++ b/bridge/xsystem35_runtime/CMakeLists.txt @@ -0,0 +1,78 @@ +cmake_minimum_required(VERSION 3.28) +if(NOT DEFINED AETHERKIRI_XSYSTEM35_DIR) + get_filename_component(AETHERKIRI_XSYSTEM35_DIR "${CMAKE_CURRENT_LIST_DIR}/../../packages/xsystem35" ABSOLUTE) +endif() +if(NOT EXISTS "${AETHERKIRI_XSYSTEM35_DIR}/src/embedded.c") + message(FATAL_ERROR "Initialize packages/xsystem35 on its Aether embedding branch") +endif() + +find_package(ZLIB REQUIRED) +find_package(SDL2 CONFIG REQUIRED) +find_package(SDL2_mixer CONFIG REQUIRED) +find_package(Freetype REQUIRED) +find_package(WebP CONFIG REQUIRED) +find_package(Threads REQUIRED) +find_package(Iconv REQUIRED) +find_package(uchardet CONFIG REQUIRED) + +set(generated "${CMAKE_CURRENT_BINARY_DIR}/generated") +file(MAKE_DIRECTORY "${generated}") +set(PACKAGE xsystem35) +set(XSYSTEM35_VERSION 2.20.0) +set(XSYSTEM35_SDL_VERSION 2) +set(ENABLE_MIDI_SDLMIXER 1) +set(HAVE_MMAP 1) +set(HAVE_WEBP 1) +set(DEFAULT_PLAYLIST_PATH playlist.txt) +configure_file("${AETHERKIRI_XSYSTEM35_DIR}/config.h.in" "${generated}/config.h") +add_custom_command(OUTPUT "${generated}/nls_catalog.h" + COMMAND "${CMAKE_COMMAND}" "-DPO_DIR=${AETHERKIRI_XSYSTEM35_DIR}/po" + -DLANGUAGES=ja "-DOUTPUT=${generated}/nls_catalog.h" + -P "${AETHERKIRI_XSYSTEM35_DIR}/po/generate_nls_catalog.cmake" + DEPENDS "${AETHERKIRI_XSYSTEM35_DIR}/po/ja.po" + "${AETHERKIRI_XSYSTEM35_DIR}/po/generate_nls_catalog.cmake" + VERBATIM) + +# Use the same source sets as upstream, without its standalone executable, +# desktop debugger, platform window or platform-specific audio front ends. +file(GLOB sources CONFIGURE_DEPENDS "${AETHERKIRI_XSYSTEM35_DIR}/src/*.c") +list(FILTER sources EXCLUDE REGEX "(_test|src_tests|nanojpeg|debug[^/]*|midi\.[^/]*|mixer\.[^/]*|pcm\.[^/]*|bgm\.[^/]*|cdrom\.emscripten)\\.c$") +list(APPEND sources + "${AETHERKIRI_XSYSTEM35_DIR}/src/microui/microui.c" + "${AETHERKIRI_XSYSTEM35_DIR}/src/midi.sdlmixer.c" + "${AETHERKIRI_XSYSTEM35_DIR}/src/mixer.sdl2mixer.c" + "${AETHERKIRI_XSYSTEM35_DIR}/src/pcm.sdlmixer.c" + "${AETHERKIRI_XSYSTEM35_DIR}/src/bgm.sdlmixer.c") +file(STRINGS "${AETHERKIRI_XSYSTEM35_DIR}/modules/CMakeLists.txt" module_lines REGEX "^[ ]+[^ ]+\\.c$") +set(modules "") +foreach(line IN LISTS module_lines) + string(STRIP "${line}" relative) + list(APPEND modules "${AETHERKIRI_XSYSTEM35_DIR}/modules/${relative}") +endforeach() +add_library(aether_xsystem35_core STATIC ${sources} ${modules} "${generated}/nls_catalog.h") +target_compile_definitions(aether_xsystem35_core PRIVATE XSYSTEM35_EMBEDDED SDL_MAIN_HANDLED _DEFAULT_SOURCE) +target_compile_options(aether_xsystem35_core PRIVATE -Wno-pointer-sign + -include "${AETHERKIRI_XSYSTEM35_DIR}/src/embedded_io.h") +target_include_directories(aether_xsystem35_core PRIVATE "${generated}" + "${AETHERKIRI_XSYSTEM35_DIR}/src" "${AETHERKIRI_XSYSTEM35_DIR}/modules" + "${AETHERKIRI_XSYSTEM35_DIR}/modules/lib") +target_link_libraries(aether_xsystem35_core PRIVATE + $,SDL2::SDL2,SDL2::SDL2-static> + $,SDL2_mixer::SDL2_mixer,SDL2_mixer::SDL2_mixer-static> + Freetype::Freetype ZLIB::ZLIB WebP::webp Iconv::Iconv) +set_target_properties(aether_xsystem35_core PROPERTIES POSITION_INDEPENDENT_CODE ON) +if(UNIX AND NOT APPLE) + target_link_libraries(aether_xsystem35_core PRIVATE m) +endif() + +add_library(aether_xsystem35_runtime STATIC src/xsystem35_runtime_provider.cpp) +target_compile_features(aether_xsystem35_runtime PRIVATE cxx_std_17) +target_include_directories(aether_xsystem35_runtime PUBLIC "${CMAKE_CURRENT_LIST_DIR}/include" + PRIVATE "${CMAKE_CURRENT_LIST_DIR}/../../abi/include" "${AETHERKIRI_XSYSTEM35_DIR}/src") +target_link_libraries(aether_xsystem35_runtime PRIVATE aether_xsystem35_core Threads::Threads uchardet::libuchardet + $,SDL2::SDL2,SDL2::SDL2-static> + $,SDL2_mixer::SDL2_mixer,SDL2_mixer::SDL2_mixer-static>) +set_target_properties(aether_xsystem35_runtime PROPERTIES POSITION_INDEPENDENT_CODE ON) +if(TARGET engine_api) + target_link_libraries(aether_xsystem35_runtime PRIVATE engine_api) +endif() diff --git a/bridge/xsystem35_runtime/README.md b/bridge/xsystem35_runtime/README.md new file mode 100644 index 00000000..ad67ebd7 --- /dev/null +++ b/bridge/xsystem35_runtime/README.md @@ -0,0 +1,118 @@ +# AliceSoft System 3.5 / 3.9 native runtime + +`packages/xsystem35` pins the `aether` branch of +[AetherKiri/xsystem35-sdl2](https://github.com/AetherKiri/xsystem35-sdl2). +The fork's `master` branch is reserved for upstream synchronization. Initialize +the package with: + +```sh +git submodule update --init packages/xsystem35 +``` + +Import the complete game directory into the visual-novel library. Detection +uses `*SA.ALD` or `ADISK.ALD`, ignoring case. A System4 INI takes precedence, +so System4 archives continue to use `xsystem4`. Selecting a Windows launcher +still opens its asset directory; Windows executables are never executed. +On iOS, use the existing `Games` directory. The shared virtual controls start +disabled, as for other visual novels. + +## Native host + +The VM runs on a worker thread inside Aether. SDL2's software renderer draws +to an RGBA surface, which the provider presents through the existing Godot +texture in Aether's window. The same native C/C++ source is wired for macOS, +iOS/iPadOS, Android and Linux. There is no SDL video window or WebAssembly. +Dependencies are SDL2, SDL2_mixer with its mpg123 feature, FreeType, WebP, +zlib, iconv and uchardet. MIDI additionally requires a MIDI-capable SDL_mixer +build and suitable instrument banks. +`AETHERKIRI_ENABLE_XSYSTEM35` defaults to `ON` for native exports; a local +checkout can be selected with `AETHERKIRI_XSYSTEM35_DIR`. + +The adapter maps logical pointer/touch coordinates, mouse buttons, wheel, +Windows virtual key codes and UTF-8 text into a private SDL event queue. +Press/release transitions remain separate when scripts poll held input. +Native text-input dialogs use the host keyboard state. Pause suspends the VM +and audio, and clears held input. Closing joins the worker and releases VM, +graphics, font, sprite, archive, modal and audio resources. VM errors return +to the host instead of terminating Aether. One System35 VM can run at a time. + +Game-relative file paths are resolved without changing the process working +directory. New saves and settings go to +`/xsystem35//`; the identity uses scenario archive +names, sizes and leading data, so an iOS container change preserves it. +Existing saves in the imported directory are not automatically migrated. + +## Text encodings + +Original Japanese games default to Shift-JIS. Unicode translations keep +upstream's script-controlled UTF-8 mode (`ZU`). For older Chinese translations, +`xsystem35_encoding=auto` detects GBK-family text from the decoded AIN `MSGI` +messages, excluding Japanese metadata and binary script instructions. +`gbk` and `sjis` are explicit overrides for the legacy text encoding. +The runtime handles GBK double-byte boundaries in messages/string operations +and converts text with iconv before drawing with Aether's CJK font. + +The supplied RanceIV Chinese build uses UTF-8; the supplied Rance5D Chinese +build uses GBK. GBK trail bytes must stay inside their character even when they +look like a script opcode. Mosaic transitions use bounded downsample/magnify +rectangles so the software renderer does not blit enormous negative rectangles. + +## Older System3 DAT games + +`ADISK.DAT` plus `ACG.DAT` is classified separately as `system3`. Those games +run through Aether’s separate [system3-sdl2 native runtime](../system3_runtime/README.md). In particular, +the supplied Chinese Rance4.1 and Rance4.2 builds use DAT; upstream's supported +Rance4.1/4.2 releases are the newer ALD editions. See the +[upstream compatibility table](https://github.com/AetherKiri/xsystem35-sdl2/blob/master/game_compatibility.md). + +## Validation + +The asset-free library/import/input regression is: + +```sh +Godot --headless --path apps/godot_app --script res://tests/xsystem35_library_test.gd +``` + +Build the production provider and native JSON probe independently of Godot: + +```sh +cmake -S tests/xsystem35_native -B out/xsystem35-native -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \ + -DVCPKG_MANIFEST_MODE=OFF -DVCPKG_TARGET_TRIPLET=arm64-osx \ + -DVCPKG_INSTALLED_DIR="$VCPKG_ROOT/installed" +cmake --build out/xsystem35-native --parallel 4 +ctest --test-dir out/xsystem35-native --output-on-failure +out/xsystem35-native/aether_xsystem35_native_probe GAME_DIR OUTPUT_DIR \ + apps/godot_app/assets/fonts/aetherkiri-runtime-cjk.otf +``` + +Atomically replace `OUTPUT_DIR/command.json` with increasing `seq` values: + +```json +{"seq":1,"action":"capture","name":"title"} +``` + +Actions include `capture`, `click` (`x`, `y`, `button`, `count`, `hold_ms`), +`key` (Windows virtual key code), `pause`, `resume`, `reopen` and `quit`. +`status.json` acknowledges commands and reports the frame, selected legacy +encoding, save directory and error. Text hooks are enabled only in the native +probe for diagnostics. Use a fresh output directory for each run. + +Four native CTests cover provider detection/ABI, contained errors, repeated +opening and RGB-world allocation, unchanged process cwd, isolated writes, +encoding auto-detection/overrides, GBK/Shift-JIS/UTF-8 conversion and character +boundaries, software mosaic colors at multiple scales, and actual MP3 decoding/playback +through SDL_mixer with synthetic silent frames. They use synthetic +archives and text without game assets. + +On 2026-10-24, macOS arm64 native JSON probes verified RanceIV Chinese startup, +640×480 rendering, entering the game, Chinese UI/dialogue, map navigation, MP3 background music, pause/resume +and reopening. Rance5D Chinese passed GBK startup, Chinese title/dialogue, entering the +game, the first-chapter mosaic transition, repeated pointer input, pause/resume +and reopening. The supplied DAT-based Rance4.1/4.2 were checked and rejected by +the System35 provider. Whole-game completion, save/load flows and playback on +other platforms remain unverified. No game assets are included. + +The exported macOS Debug app was also checked with its Metal Godot renderer: +RanceIV map navigation and Chinese dialogue, and Rance5D new game and Chinese +dialogue. Both use the same Aether window and production provider. diff --git a/bridge/xsystem35_runtime/include/xsystem35_runtime_provider.h b/bridge/xsystem35_runtime/include/xsystem35_runtime_provider.h new file mode 100644 index 00000000..67cbe2d2 --- /dev/null +++ b/bridge/xsystem35_runtime/include/xsystem35_runtime_provider.h @@ -0,0 +1,9 @@ +#pragma once +#include "engine_runtime_provider.h" +#ifdef __cplusplus +extern "C" { +#endif +engine_result_t aetherkiri_xsystem35_register_runtime_provider(void); +#ifdef __cplusplus +} +#endif diff --git a/bridge/xsystem35_runtime/src/xsystem35_runtime_provider.cpp b/bridge/xsystem35_runtime/src/xsystem35_runtime_provider.cpp new file mode 100644 index 00000000..f2c6508b --- /dev/null +++ b/bridge/xsystem35_runtime/src/xsystem35_runtime_provider.cpp @@ -0,0 +1,814 @@ +#include "xsystem35_runtime_provider.h" +extern "C" { +#include "embedded.h" +#include +} +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +namespace aetherkiri::xsystem35 { + namespace { + namespace fs = std::filesystem; + std::atomic active_runtime{ nullptr }; + std::string Lower(std::string value) { + for(char &c : value) + if(c >= 'A' && c <= 'Z') + c += 'a' - 'A'; + return value; + } + bool IsScenario(const std::string &name) { + return name == "adisk.ald" || + (name.size() >= 6 && name.substr(name.size() - 6) == "sa.ald"); + } + bool LooksLikeGame(const char *path) { + if(!path || !*path) + return false; + bool scenario = false, system4 = false; + std::error_code error; + for(const auto &file : + fs::directory_iterator(fs::u8path(path), error)) { + if(!file.is_regular_file(error)) + continue; + auto name = Lower(file.path().filename().u8string()); + system4 |= name == "system40.ini" || name == "alicestart.ini"; + scenario |= IsScenario(name); + } + return !error && !system4 && scenario; + } + std::string GameIdentity(const std::string &root) { + uint64_t value = 14695981039346656037ull; + auto append = [&](const std::string &data) { + for(unsigned char c : data) { + value ^= c; + value *= 1099511628211ull; + } + }; + // iOS container prefixes change after an app update. Identify the + // script content rather than the absolute import path so saves + // remain reachable. + std::vector scripts; + for(const auto &entry : fs::directory_iterator(fs::u8path(root))) + if(entry.is_regular_file() && + IsScenario(Lower(entry.path().filename().u8string()))) + scripts.push_back(entry.path()); + std::sort(scripts.begin(), scripts.end(), + [](const auto &a, const auto &b) { + return Lower(a.filename().u8string()) < + Lower(b.filename().u8string()); + }); + for(const auto &script : scripts) { + append(Lower(script.filename().u8string())); + append(std::to_string(fs::file_size(script))); + std::ifstream stream(script, std::ios::binary); + std::string data(65536, '\0'); + stream.read(data.data(), data.size()); + data.resize(stream.gcount()); + if(stream.bad()) + throw std::runtime_error( + "Could not read the System 3.5/3.9 script identity"); + append(data); + } + std::ostringstream result; + result << std::hex << value; + return result.str(); + } + + std::string DetectEncoding(const std::string &root) { + // AIN metadata may keep Japanese function names while messages are + // translated. Detect only the decoded MSGI text, never the binary + // VM. + for(const auto &entry : fs::directory_iterator(fs::u8path(root))) { + if(Lower(entry.path().filename().u8string()) != "system39.ain") + continue; + const auto size = fs::file_size(entry.path()); + if(size < 24 || size > 16 * 1024 * 1024) + continue; + std::ifstream stream(entry.path(), std::ios::binary); + std::string data(size, '\0'); + if(!stream.read(data.data(), data.size()) || + data.compare(0, 3, "AIN")) + continue; + for(size_t i = 4; i < data.size(); i++) { + const auto c = static_cast(data[i]); + data[i] = char((c >> 6) | (c << 2)); + } + const auto at = data.find("MSGI"); + if(at == std::string::npos || at + 12 > data.size()) + continue; + const auto *p = reinterpret_cast( + data.data() + at + 8); + uint32_t count = uint32_t(p[0]) | (uint32_t(p[1]) << 8) | + (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24); + size_t pos = at + 12; + std::string sample; + while(count-- && pos < data.size() && sample.size() < 262144) { + const auto end = data.find('\0', pos); + if(end == std::string::npos) + break; + sample.append(data, pos, end - pos); + sample += '\n'; + pos = end + 1; + } + auto detector = uchardet_new(); + if(!detector) + return "sjis"; + uchardet_handle_data(detector, sample.data(), sample.size()); + uchardet_data_end(detector); + const auto encoding = Lower(uchardet_get_charset(detector)); + uchardet_delete(detector); + if(encoding == "gb18030" || encoding == "gb2312" || + encoding == "gbk" || encoding == "hz-gb-2312") + return "gbk"; + } + return "sjis"; + } + struct Frame { + uint32_t width = 0, height = 0; + uint64_t serial = 0; + std::vector pixels; + }; + struct Runtime { + engine_runtime_host_v1_t host{}; + std::string writable, root, saves, font, error, encoding = "auto", + selected_encoding; + std::mutex mutex, input_mutex; + std::condition_variable condition; + std::thread worker; + std::atomic stopping{ false }, paused{ false }; + std::atomic text_input{ false }; + std::atomic fps{ 60 }; + bool ended = false, ready = false; + std::shared_ptr frame, snapshot; + std::atomic serial{ 0 }; + uint64_t delivered = 0; + std::deque input; + // Do not drain a press and its release in the same VM input poll. + // System 3.x games read held key/button state after polling, rather + // than the events. + std::chrono::steady_clock::time_point next_button_event; + int mouse_x = 0, mouse_y = 0; + std::chrono::steady_clock::time_point last_present; + Runtime(const engine_runtime_host_v1_t *h, + const engine_create_desc_t *desc) : host(*h) { + if(desc->writable_path_utf8) + writable = desc->writable_path_utf8; + } + ~Runtime() { stop(); } + void fail(const std::string &message) { + std::lock_guard guard(mutex); + if(error.empty()) + error = message; + condition.notify_all(); + } + void stop() { + stopping = true; + paused = false; + condition.notify_all(); + if(worker.joinable()) + worker.join(); + } + bool checkpoint() { + if(stopping) + return true; + if(paused) { + xsystem35_host_clear_input(); + + { + std::lock_guard guard(input_mutex); + input.clear(); + } + Mix_Pause(-1); + Mix_PauseMusic(); + std::unique_lock guard(mutex); + condition.wait(guard, [&] { return !paused || stopping; }); + guard.unlock(); + Mix_Resume(-1); + Mix_ResumeMusic(); + } + return stopping; + } + void publish(const SDL_Surface *surface) { + if(!surface || surface->w < 1 || surface->h < 1 || + surface->w > 8192 || surface->h > 8192) { + fail("Invalid native frame dimensions"); + stopping = true; + return; + } + auto next = std::make_shared(); + next->width = surface->w; + next->height = surface->h; + next->pixels.resize(size_t(surface->w) * surface->h * 4); + for(int y = 0; y < surface->h; y++) + std::memcpy(next->pixels.data() + + size_t(y) * surface->w * 4, + static_cast(surface->pixels) + + size_t(y) * surface->pitch, + size_t(surface->w) * 4); + next->serial = ++serial; + { + std::lock_guard guard(mutex); + frame = std::move(next); + ready = true; + condition.notify_all(); + } + if(const auto limit = fps.load()) { + const auto deadline = last_present + + std::chrono::microseconds(1000000 / limit); + std::unique_lock guard(mutex); + condition.wait_until(guard, deadline, [&] { + return stopping.load() || paused.load(); + }); + } + last_present = std::chrono::steady_clock::now(); + } + void run() { + xsystem35_host hooks{}; + hooks.user = this; + hooks.gbk_text = selected_encoding == "gbk"; + hooks.present = [](void *p, const SDL_Surface *surface) { + auto &self = *static_cast(p); + try { + self.publish(surface); + } catch(const std::exception &error) { + self.fail(error.what()); + self.stopping = true; + } + }; + hooks.poll_event = [](void *p, SDL_Event *event) -> int { + auto &self = *static_cast(p); + std::lock_guard guard(self.input_mutex); + if(self.input.empty()) + return 0; + const auto type = self.input.front().type; + const bool transition = type == SDL_MOUSEBUTTONDOWN || + type == SDL_MOUSEBUTTONUP || type == SDL_KEYDOWN || + type == SDL_KEYUP; + const auto now = std::chrono::steady_clock::now(); + if(transition && now < self.next_button_event) + return 0; + if(transition) + self.next_button_event = + now + std::chrono::milliseconds(16); + *event = self.input.front(); + self.input.pop_front(); + if(event->type == SDL_MOUSEMOTION) { + self.mouse_x = event->motion.x; + self.mouse_y = event->motion.y; + } + if(event->type == SDL_MOUSEBUTTONDOWN || + event->type == SDL_MOUSEBUTTONUP) { + self.mouse_x = event->button.x; + self.mouse_y = event->button.y; + } + return 1; + }; + hooks.checkpoint = [](void *p) -> int { + return static_cast(p)->checkpoint(); + }; + hooks.text_input = [](void *p, int active) { + static_cast(p)->text_input = active != 0; + }; + hooks.log = [](void *p, int level, const char *message) { + auto &self = *static_cast(p); + if(level == 0) + self.fail(message ? message + : "xsystem35 reported an error"); + if(self.host.log) + self.host.log(self.host.user_data, + level == 0 ? ENGINE_RUNTIME_LOG_ERROR + : ENGINE_RUNTIME_LOG_INFO, + "xsystem35", message); + }; + std::string session_font; + { + std::lock_guard guard(mutex); + session_font = font; + } + const int result = xsystem35_host_run( + &hooks, root.c_str(), saves.c_str(), session_font.c_str()); + { + std::lock_guard guard(mutex); + ended = true; + if(result && error.empty()) + error = "The System 3.5/3.9 VM exited with code " + + std::to_string(result); + condition.notify_all(); + } + text_input = false; + void *expected = this; + active_runtime.compare_exchange_strong(expected, nullptr); + } + }; + engine_result_t Copy(const std::string &text, char *out, uint32_t size, + uint32_t *written = nullptr) { + if(!out || !size) + return ENGINE_RESULT_INVALID_ARGUMENT; + const size_t count = std::min(text.size(), size_t(size - 1)); + std::memcpy(out, text.data(), count); + out[count] = 0; + if(written) + *written = uint32_t(count); + return count == text.size() ? ENGINE_RESULT_OK + : ENGINE_RESULT_INVALID_ARGUMENT; + } + int32_t Probe(void *, const char *path) { + try { + return LooksLikeGame(path) ? 100 : 0; + } catch(...) { + return 0; + } + } + engine_result_t Create(void *, const engine_runtime_host_v1_t *host, + const engine_create_desc_t *desc, void **out) { + if(!host || !desc || !out || host->struct_size < sizeof(*host) || + desc->struct_size < sizeof(*desc) || + desc->api_version != ENGINE_API_VERSION || + host->api_version != ENGINE_RUNTIME_PROVIDER_API_VERSION) + return ENGINE_RESULT_INVALID_ARGUMENT; + *out = nullptr; + try { + *out = new Runtime(host, desc); + return ENGINE_RESULT_OK; + } catch(...) { + return ENGINE_RESULT_INTERNAL_ERROR; + } + } + void Destroy(void *opaque) { delete static_cast(opaque); } + engine_result_t Open(void *opaque, const char *path, const char *) { + auto *self = static_cast(opaque); + if(!self || !path) + return ENGINE_RESULT_INVALID_ARGUMENT; + if(!Probe(nullptr, path)) { + self->fail("Select a System 3.5/3.9 folder containing its " + "scenario SA.ALD archive"); + return ENGINE_RESULT_INVALID_ARGUMENT; + } + self->stop(); + void *expected = nullptr; + if(!active_runtime.compare_exchange_strong(expected, self)) { + self->fail("Another System 3.5/3.9 game is running"); + return ENGINE_RESULT_INVALID_STATE; + } + try { + self->root = fs::absolute(fs::u8path(path)) + .lexically_normal() + .u8string(); + { + std::lock_guard guard(self->mutex); + self->selected_encoding = self->encoding == "auto" + ? DetectEncoding(self->root) + : self->encoding; + } + if(self->writable.empty()) + throw std::runtime_error( + "A writable save directory is required"); + self->saves = (fs::u8path(self->writable) / "xsystem35" / + GameIdentity(self->root)) + .u8string(); + fs::create_directories(fs::u8path(self->saves)); + { + std::lock_guard guard(self->mutex); + self->error.clear(); + self->ready = self->ended = false; + self->frame.reset(); + self->snapshot.reset(); + self->delivered = 0; + } + self->stopping = self->paused = false; + { + std::lock_guard guard(self->input_mutex); + self->input.clear(); + self->next_button_event = {}; + self->mouse_x = self->mouse_y = 0; + } + self->worker = std::thread([self] { + try { + self->run(); + } catch(const std::exception &error) { + self->fail(error.what()); + { + std::lock_guard guard(self->mutex); + self->ended = true; + self->condition.notify_all(); + } + void *value = self; + active_runtime.compare_exchange_strong(value, nullptr); + } + }); + bool succeeded; + { + std::unique_lock guard(self->mutex); + self->condition.wait_for( + guard, std::chrono::seconds(45), + [&] { return self->ready || self->ended; }); + succeeded = self->ready && self->error.empty(); + } + if(!succeeded) { + self->fail( + "System 3.5/3.9 did not produce a startup frame"); + self->stop(); + return ENGINE_RESULT_IO_ERROR; + } + return ENGINE_RESULT_OK; + } catch(const std::exception &error) { + self->fail(error.what()); + self->stop(); + void *value = self; + active_runtime.compare_exchange_strong(value, nullptr); + return ENGINE_RESULT_IO_ERROR; + } + } + engine_result_t Tick(void *p, uint32_t) { + if(!p) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + std::lock_guard guard(self.mutex); + if(self.ended && self.error.empty()) + self.error = "System 3.5/3.9 runtime requested termination"; + return self.error.empty() ? ENGINE_RESULT_OK + : ENGINE_RESULT_IO_ERROR; + } + engine_result_t Pause(void *p) { + if(!p) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + self.paused = true; + self.condition.notify_all(); + return ENGINE_RESULT_OK; + } + engine_result_t Resume(void *p) { + if(!p) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + self.paused = false; + self.condition.notify_all(); + return ENGINE_RESULT_OK; + } + engine_result_t Option(void *p, const engine_option_t *option) { + if(!p || !option || !option->key_utf8) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + const std::string key = option->key_utf8, + value = + option->value_utf8 ? option->value_utf8 : ""; + if(key == "default_font") { + std::lock_guard guard(self.mutex); + self.font = value; + return ENGINE_RESULT_OK; + } + if(key == "xsystem35_encoding") { + if(value != "auto" && value != "sjis" && value != "gbk") + return ENGINE_RESULT_INVALID_ARGUMENT; + std::lock_guard guard(self.mutex); + self.encoding = value; + return ENGINE_RESULT_OK; + } + if(key == "fps_limit") { + try { + self.fps = std::clamp(std::stoi(value), 0, 240); + return ENGINE_RESULT_OK; + } catch(...) { + return ENGINE_RESULT_INVALID_ARGUMENT; + } + } + return ENGINE_RESULT_NOT_SUPPORTED; + } + engine_result_t Surface(void *p, uint32_t w, uint32_t h) { + return p && w && h ? ENGINE_RESULT_OK + : ENGINE_RESULT_INVALID_ARGUMENT; + } + engine_result_t Describe(void *p, engine_frame_desc_t *out) { + if(!p || !out || out->struct_size < sizeof(*out)) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + std::lock_guard guard(self.mutex); + if(!self.frame) + return ENGINE_RESULT_INVALID_STATE; + self.snapshot = self.frame; + out->width = self.snapshot->width; + out->height = self.snapshot->height; + out->stride_bytes = out->width * 4; + out->pixel_format = ENGINE_PIXEL_FORMAT_RGBA8888; + out->frame_serial = self.snapshot->serial; + return ENGINE_RESULT_OK; + } + engine_result_t Read(void *p, void *out, size_t size) { + if(!p || !out) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + std::lock_guard guard(self.mutex); + auto frame = self.snapshot ? self.snapshot : self.frame; + if(!frame) + return ENGINE_RESULT_INVALID_STATE; + if(size < frame->pixels.size()) + return ENGINE_RESULT_INVALID_ARGUMENT; + std::memcpy(out, frame->pixels.data(), frame->pixels.size()); + self.delivered = frame->serial; + return ENGINE_RESULT_OK; + } + SDL_Scancode Scancode(int key) { + if(key >= 'A' && key <= 'Z') + return SDL_Scancode(SDL_SCANCODE_A + key - 'A'); + if(key >= '1' && key <= '9') + return SDL_Scancode(SDL_SCANCODE_1 + key - '1'); + if(key == '0') + return SDL_SCANCODE_0; + if(key >= 0x70 && key <= 0x7b) + return SDL_Scancode(SDL_SCANCODE_F1 + key - 0x70); + switch(key) { + case 8: + return SDL_SCANCODE_BACKSPACE; + case 9: + return SDL_SCANCODE_TAB; + case 13: + return SDL_SCANCODE_RETURN; + case 16: + return SDL_SCANCODE_LSHIFT; + case 17: + return SDL_SCANCODE_LCTRL; + case 18: + return SDL_SCANCODE_LALT; + case 27: + return SDL_SCANCODE_ESCAPE; + case 32: + return SDL_SCANCODE_SPACE; + case 33: + return SDL_SCANCODE_PAGEUP; + case 34: + return SDL_SCANCODE_PAGEDOWN; + case 35: + return SDL_SCANCODE_END; + case 36: + return SDL_SCANCODE_HOME; + case 37: + return SDL_SCANCODE_LEFT; + case 38: + return SDL_SCANCODE_UP; + case 39: + return SDL_SCANCODE_RIGHT; + case 40: + return SDL_SCANCODE_DOWN; + case 45: + return SDL_SCANCODE_INSERT; + case 46: + return SDL_SCANCODE_DELETE; + default: + return SDL_SCANCODE_UNKNOWN; + } + } + engine_result_t Input(void *p, const engine_input_event_t *input) { + if(!p || !input || input->struct_size < sizeof(*input)) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + if(self.stopping || self.paused) + return ENGINE_RESULT_OK; + SDL_Event event{}; + event.common.timestamp = SDL_GetTicks(); + switch(input->type) { + case ENGINE_INPUT_EVENT_POINTER_MOVE: + event.type = SDL_MOUSEMOTION; + event.motion.x = int(input->x); + event.motion.y = int(input->y); + break; + case ENGINE_INPUT_EVENT_POINTER_DOWN: + case ENGINE_INPUT_EVENT_POINTER_UP: + event.type = input->type == ENGINE_INPUT_EVENT_POINTER_DOWN + ? SDL_MOUSEBUTTONDOWN + : SDL_MOUSEBUTTONUP; + event.button.button = + input->button == 1 || (input->modifiers & 0x10) + ? SDL_BUTTON_RIGHT + : input->button == 2 || (input->modifiers & 0x20) + ? SDL_BUTTON_MIDDLE + : SDL_BUTTON_LEFT; + event.button.state = event.type == SDL_MOUSEBUTTONDOWN + ? SDL_PRESSED + : SDL_RELEASED; + event.button.x = int(input->x); + event.button.y = int(input->y); + break; + case ENGINE_INPUT_EVENT_POINTER_SCROLL: + event.type = SDL_MOUSEWHEEL; + event.wheel.y = input->delta_y > 0 ? 1 + : input->delta_y < 0 ? -1 + : 0; + break; + case ENGINE_INPUT_EVENT_KEY_DOWN: + case ENGINE_INPUT_EVENT_KEY_UP: + case ENGINE_INPUT_EVENT_BACK: { + event.type = input->type == ENGINE_INPUT_EVENT_KEY_UP + ? SDL_KEYUP + : SDL_KEYDOWN; + event.key.state = + event.type == SDL_KEYDOWN ? SDL_PRESSED : SDL_RELEASED; + event.key.keysym.scancode = + Scancode(input->type == ENGINE_INPUT_EVENT_BACK + ? 27 + : input->key_code); + if(event.key.keysym.scancode == SDL_SCANCODE_UNKNOWN) + return ENGINE_RESULT_NOT_SUPPORTED; + const int key = input->type == ENGINE_INPUT_EVENT_BACK + ? 27 + : input->key_code; + event.key.keysym.sym = key >= 'A' && key <= 'Z' + ? key + ('a' - 'A') + : (key >= '0' && key <= '9') || key == 8 || key == 9 || + key == 13 || key == 27 || key == 32 + ? key + : SDL_Keycode(event.key.keysym.scancode | + SDLK_SCANCODE_MASK); + event.key.keysym.mod = + (input->modifiers & 1 ? KMOD_SHIFT : 0) | + (input->modifiers & 2 ? KMOD_ALT : 0) | + (input->modifiers & 4 ? KMOD_CTRL : 0); + event.key.repeat = (input->modifiers & 0x80) != 0; + break; + } + case ENGINE_INPUT_EVENT_TEXT_INPUT: { + const uint32_t c = input->unicode_codepoint; + if(c > 0x10ffff || (c >= 0xd800 && c <= 0xdfff)) + return ENGINE_RESULT_INVALID_ARGUMENT; + event.type = SDL_TEXTINPUT; + if(c < 0x80) + event.text.text[0] = char(c); + else if(c < 0x800) { + event.text.text[0] = char(0xc0 | c >> 6); + event.text.text[1] = char(0x80 | (c & 63)); + } else if(c < 0x10000) { + event.text.text[0] = char(0xe0 | c >> 12); + event.text.text[1] = char(0x80 | ((c >> 6) & 63)); + event.text.text[2] = char(0x80 | (c & 63)); + } else { + event.text.text[0] = char(0xf0 | c >> 18); + event.text.text[1] = char(0x80 | ((c >> 12) & 63)); + event.text.text[2] = char(0x80 | ((c >> 6) & 63)); + event.text.text[3] = char(0x80 | (c & 63)); + } + break; + } + default: + return ENGINE_RESULT_NOT_SUPPORTED; + } + std::lock_guard guard(self.input_mutex); + if(event.type == SDL_MOUSEMOTION && !self.input.empty() && + self.input.back().type == SDL_MOUSEMOTION) + self.input.back() = event; + else { + if(self.input.size() >= 256) + self.input.pop_front(); + self.input.push_back(event); + } + return ENGINE_RESULT_OK; + } + engine_result_t Rendered(void *p, uint32_t *out) { + if(!p || !out) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + std::lock_guard guard(self.mutex); + *out = self.frame && self.frame->serial != self.delivered; + return ENGINE_RESULT_OK; + } + engine_result_t TextInput(void *p, uint32_t *out) { + if(!p || !out) + return ENGINE_RESULT_INVALID_ARGUMENT; + *out = static_cast(p)->text_input + ? ENGINE_TEXT_INPUT_STATE_ACTIVE + : ENGINE_TEXT_INPUT_STATE_NONE; + return ENGINE_RESULT_OK; + } + engine_result_t Renderer(void *p, char *out, uint32_t size) { + return p ? Copy("backend=xsystem35 native-software frame=rgba", out, + size) + : ENGINE_RESULT_INVALID_ARGUMENT; + } + engine_result_t Debug(void *p, char *out, uint32_t size, + uint32_t *written) { + if(!p) + return ENGINE_RESULT_INVALID_ARGUMENT; + auto &self = *static_cast(p); + std::lock_guard guard(self.mutex); + return Copy("runtime=xsystem35 native-library frames=" + + std::to_string(self.serial.load()) + " encoding=" + + self.selected_encoding + " saves=" + self.saves, + out, size, written); + } + const char *Error(void *p) { + if(!p) + return "System 3.5/3.9 runtime is unavailable"; + auto &self = *static_cast(p); + std::lock_guard guard(self.mutex); + thread_local std::string text; + text = self.error; + return text.c_str(); + } + } // namespace +} // namespace aetherkiri::xsystem35 + +extern "C" engine_result_t aetherkiri_xsystem35_register_runtime_provider() { + using namespace aetherkiri::xsystem35; + static engine_runtime_provider_v1_t provider{}; + static std::once_flag flag; + static engine_result_t result = ENGINE_RESULT_INTERNAL_ERROR; + std::call_once(flag, [] { + provider.struct_size = sizeof(provider); + provider.api_version = ENGINE_RUNTIME_PROVIDER_API_VERSION; + provider.runtime_id_utf8 = "xsystem35"; + provider.display_name_utf8 = "AliceSoft System 3.5/3.9"; + provider.priority = 100; + provider.probe = Probe; + provider.create = Create; + provider.destroy = Destroy; + provider.open_game = Open; + provider.tick = Tick; + provider.pause = Pause; + provider.resume = Resume; + provider.set_option = Option; + provider.set_surface_size = Surface; + provider.get_frame_desc = Describe; + provider.read_frame_rgba = Read; + provider.send_input = Input; + provider.get_frame_rendered_flag = Rendered; + provider.get_renderer_info = Renderer; + provider.get_plugin_debug_info = Debug; + provider.get_last_error = Error; + provider.get_text_input_state = TextInput; + result = engine_register_runtime_provider(&provider); + }); + return result; +} + +extern "C" const char *xsystem35_host_path(const char *path, int write) { + using namespace aetherkiri::xsystem35; + thread_local std::string buffers[16]; + thread_local unsigned cursor = 0; + auto &out = buffers[(cursor++) % 16]; + try { + auto *self = static_cast(active_runtime.load()); + if(!self || !path || !*path) + return path; + std::string normalized(path); + std::replace(normalized.begin(), normalized.end(), '\\', '/'); + fs::path relative = fs::u8path(normalized).lexically_normal(); + auto base = fs::u8path(write ? self->saves : self->root); + if(write && relative.is_absolute()) { + auto prefix = fs::u8path(self->saves).lexically_normal(); + auto candidate = relative.lexically_relative(prefix); + if(!candidate.empty() && *candidate.begin() != "..") { + out = relative.u8string(); + return out.c_str(); + } + relative = relative.filename(); + } + if(relative.is_absolute()) { + out = relative.u8string(); + return out.c_str(); + } + // Do not let resource-relative '..' escape the selected root. + if(!relative.empty() && *relative.begin() == "..") + relative = relative.filename(); + if(!write) { + const auto saved = fs::u8path(self->saves) / relative; + if(fs::is_regular_file(saved)) { + out = saved.u8string(); + return out.c_str(); + } + // Windows games use inconsistent case for archive/font/audio paths. + for(const auto &part : relative) { + if(part == ".") + continue; + auto next = base / part; + if(!fs::exists(next) && fs::is_directory(base)) + for(const auto &entry : fs::directory_iterator(base)) + if(Lower(entry.path().filename().u8string()) == + Lower(part.u8string())) { + next = entry.path(); + break; + } + base = next; + } + out = base.u8string(); + } else { + base /= relative; + fs::create_directories(base.parent_path()); + out = base.u8string(); + } + return out.c_str(); + } catch(...) { + return "/dev/null/aether-invalid-path"; + } +} diff --git a/bridge/xsystem4_runtime/CMakeLists.txt b/bridge/xsystem4_runtime/CMakeLists.txt index a402fcbc..096a1fea 100644 --- a/bridge/xsystem4_runtime/CMakeLists.txt +++ b/bridge/xsystem4_runtime/CMakeLists.txt @@ -7,6 +7,7 @@ if(NOT EXISTS "${AETHERKIRI_XSYSTEM4_DIR}/src/embedded.c") endif() find_package(SDL2 CONFIG REQUIRED) +find_package(SDL2_image CONFIG REQUIRED) find_package(Freetype REQUIRED) find_package(SndFile CONFIG REQUIRED) find_package(cglm CONFIG REQUIRED) @@ -45,6 +46,55 @@ get_target_property(sys4_sources sys4 SOURCES) list(FILTER sys4_sources EXCLUDE REGEX "(^|/)src/system\\.c$") set_property(TARGET sys4 PROPERTY SOURCES "${sys4_sources};${generated}/system.c") target_include_directories(sys4 PRIVATE "${AETHERKIRI_XSYSTEM4_DIR}/include") +# Keep the pinned upstream dependency unchanged; adapt its AFA reader in the +# build directory, as with the embedded exit boundary above. +set(afa_path "${AETHERKIRI_XSYSTEM4_DIR}/subprojects/libsys4/src/afa.c") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${afa_path}") +file(READ "${afa_path}" afa_source) +set(afa_decompress "zlib_decompress_exact(table, ar->uncompressed_size, buf, ar->compressed_size)") +set(afa_size "ar->compressed_size = LittleEndian_getDW((uint8_t*)buf, 32) - 16;") +string(FIND "${afa_source}" "${afa_decompress}" afa_decompress_pos) +string(FIND "${afa_source}" "${afa_size}" afa_size_pos) +if(afa_decompress_pos LESS 0 OR afa_size_pos LESS 0) + message(FATAL_ERROR "libsys4 AFA index boundary changed") +endif() +string(REPLACE "${afa_decompress}" "aether_afa_decompress_index(f, ar, table, &buf)" afa_source "${afa_source}") +string(REPLACE "${afa_size}" [=[ + uint32_t info_size = LittleEndian_getDW((uint8_t*)buf, 32); + if (info_size < 16 || ar->data_start < 44 || + ar->data_start > ar->file_size || info_size - 16 > ar->data_start - 44) { + *error = ARCHIVE_BAD_ARCHIVE_ERROR; + return false; + } + ar->compressed_size = info_size - 16;]=] afa_source "${afa_source}") +file(WRITE "${generated}/afa.c" "#include \"afa_index_compat.h\"\n${afa_source}") +get_target_property(sys4_sources sys4 SOURCES) +list(FILTER sys4_sources EXCLUDE REGEX "(^|/)src/afa\\.c$") +set_property(TARGET sys4 PROPERTY SOURCES "${sys4_sources};${generated}/afa.c") +target_include_directories(sys4 PRIVATE "${CMAKE_CURRENT_LIST_DIR}/src") +set(cg_path "${AETHERKIRI_XSYSTEM4_DIR}/subprojects/libsys4/src/cg.c") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${cg_path}") +file(READ "${cg_path}" cg_source) +set(cg_metrics_signature "bool cg_get_metrics_internal(uint8_t *buf, size_t buf_size, struct cg_metrics *dst)\n{") +set(cg_load_signature "static struct cg *cg_load_internal(uint8_t *buf, size_t buf_size, struct archive *ar)\n{") +foreach(signature cg_metrics_signature cg_load_signature) + string(FIND "${cg_source}" "${${signature}}" cg_signature_pos) + if(cg_signature_pos LESS 0) + message(FATAL_ERROR "libsys4 CG decode boundary changed") + endif() +endforeach() +string(REPLACE "${cg_metrics_signature}" + "${cg_metrics_signature}\n\tif (aether_tga_matches(buf, buf_size))\n\t\treturn aether_tga_metrics(buf, buf_size, dst);" + cg_source "${cg_source}") +string(REPLACE "${cg_load_signature}" + "${cg_load_signature}\n\tif (aether_tga_matches(buf, buf_size))\n\t\treturn aether_tga_load(buf, buf_size);" + cg_source "${cg_source}") +file(WRITE "${generated}/cg.c" "#include \"tga_compat.h\"\n${cg_source}") +get_target_property(sys4_sources sys4 SOURCES) +list(FILTER sys4_sources EXCLUDE REGEX "(^|/)src/cg\\.c$") +set_property(TARGET sys4 PROPERTY SOURCES "${sys4_sources};${generated}/cg.c") +target_link_libraries(sys4 PRIVATE + $,SDL2_image::SDL2_image,SDL2_image::SDL2_image-static>) target_link_libraries(sys4 PRIVATE $,SDL2::SDL2,SDL2::SDL2-static>) set_target_properties(sys4 PROPERTIES POSITION_INDEPENDENT_CODE ON) if(APPLE) diff --git a/bridge/xsystem4_runtime/README.md b/bridge/xsystem4_runtime/README.md index a273e135..e1708c52 100644 --- a/bridge/xsystem4_runtime/README.md +++ b/bridge/xsystem4_runtime/README.md @@ -47,16 +47,23 @@ The identity uses the AIN filename, size and leading script data, so moving a game or updating an iOS app container does not create a new save namespace. Existing saves in the imported game's `SaveData` are not automatically copied. -`xsystem4_encoding=auto` detects GBK-family INI files and otherwise uses Shift-JIS. -`gbk` and `sjis` can be set explicitly before opening. The default font comes -from Aether's staged CJK font; a game-supplied `.xsys4rc` can provide font paths. +`xsystem4_encoding=auto` ignores INI comments and gives a GBK-family `GameName` +priority over untranslated Japanese labels in other values. It then checks the +remaining configuration and defaults to Shift-JIS when no GBK evidence is found, +so original Japanese games keep their native encoding. Short samples are +repeated at line boundaries before statistical detection, avoiding insufficient +evidence for short GBK titles such as Sengoku Rance. Quoted `;` and `//` +remain part of string values. `gbk` and `sjis` can be set explicitly before +opening. The default font comes from Aether's staged CJK font; a game-supplied +`.xsys4rc` can provide font paths. +Use `path/to/font.ttc:1` to select a face from a TrueType font collection. Shaders are embedded in the library and do not depend on the process directory. ## Building and validation `AETHERKIRI_ENABLE_XSYSTEM4` defaults to `ON` for the native app. A local fork can be selected with `AETHERKIRI_XSYSTEM4_DIR`. Dependencies include SDL2, FFmpeg, -FreeType, libsndfile, cglm, libffi, uchardet, Flex and Bison. Linux additionally +FreeType, libsndfile, SDL2_image, cglm, libffi, uchardet, Flex and Bison. Linux additionally needs EGL and GLES development libraries. iOS packaging merges both the adapter archive and `libsys4.a` into the existing static extension and links OpenGLES. @@ -85,12 +92,44 @@ Write an atomically replaced `OUTPUT_DIR/command.json` with increasing `seq`: {"seq":1,"action":"capture","name":"title"} ``` -Other actions are `click` (`x`, `y`, `button`: left=0/right=1/middle=2, `count`), +Other actions are `move` (`x`, `y`), `click` (`x`, `y`, `button`: left=0/right=1/middle=2, `count`), `key` (`key`: Windows virtual key code, `count`), `pause`, `resume`, `reopen` and `quit`. `status.json` acknowledges each command and reports frame dimensions, serial, selected encoding, save directory and errors. Use a fresh output directory, or remove an earlier command before starting a new probe. +The native contract includes encoding regressions for Chinese and Japanese +configurations, mixed-encoding comments, Chinese title priority, inline comments, +quoted comment markers, ASCII titles and explicit encoding overrides. Each case +also checks that a non-ASCII AIN filename is resolved before script parsing. +The scene-keeper contract checks that Chinese dialogue can use original +Shift-JIS VM structure names, GBK names take priority when both exist, and +scene serialization follows the current script's structure indices after +switching games. These tests use synthetic data without game assets. + +The AFA contract covers standard v1/v2 indexes and fan-repacked INFO chunks +whose length excludes the 16-byte INFO header. The reader retries that length +variant only after normal decompression fails, within the index region, with +the original checksum and exact output-size checks. Corrupt, truncated and +out-of-bounds indexes are rejected. Both file I/O and memory-mapped entry +loading are tested. The compatibility adapter is generated in the build +directory; imported archives and the pinned libsys4 sources are unchanged. + +The CG adapter uses the existing SDL2_image dependency to decode 24/32-bit TGA +resources, including RLE, image IDs, alpha and both axis origins. It validates +pixel/packet bounds before decoding and supplies RGBA data and CG metrics to +the existing renderer. Its synthetic contract checks channel order, alpha, +orientation and malformed/truncated data without game assets. + +Rance Quest's numeric-string CG interface is recognized from its `TapirEngine` +library when a translation changes the displayed title. MonsterInfo determines +its own Shift-JIS/GBK grammar encoding independently of dialogue, preserves +resource identifiers and restores the dialogue encoding after parsing. GBK +dialogue that requests a Shift-JIS-only FNL face uses Aether's Unicode CJK font; +Japanese games retain their original FNL face. Synthetic contracts cover +renamed/Chinese/Japanese/English titles, metadata encoding combinations, +malformed metadata and font selection across encoding changes. + On 2026-10-18, the supplied **Sengoku Rance v1.01** Chinese build was tested on macOS arm64: 800×600 startup, opening movie/audio, title, new game, Chinese dialogue and character rendering, pointer advancement and the in-game menu. @@ -98,3 +137,41 @@ The same flow was exercised through Aether's main window with its Metal Godot renderer. Native pause/resume, closing and repeated reopening during a movie were also checked. iOS and Android context/source compile checks passed; device playback and Linux playback still need validation. No game files are included. + +On 2026-10-24, the supplied Chinese builds of **Rance01** (1024×768) and +**Rance02** (800×600) were tested with the macOS arm64 native JSON probe: startup, +title/menu, new game, Chinese dialogue and pointer advancement. Rance02's +GBK-encoded `CodeName` resolves its Japanese-named AIN file, and 20 consecutive +dialogue clicks pass the scene-history boundary with original Shift-JIS +structure names. These checks used +isolated probe save directories; the packaged app and other platforms were not +retested for this encoding change. + +On 2026-10-25, a regression with the short GBK GameName in **Sengoku Rance** +was reproduced: Shift-JIS was incorrectly selected, corrupting the title text +and stalling new-game initialization. After the fix, native and exported macOS +Debug probes confirmed automatic GBK selection, Chinese title/menu, new-game +startup and 20 dialogue advances with backgrounds and characters. The packaged +app used its existing Metal presentation and existing Aether save namespace. +Synthetic regressions cover short Chinese, Japanese kanji and halfwidth-kana +titles, alongside the existing mixed-encoding fixtures and explicit overrides. + +On 2026-10-25, the supplied Chinese **Rance IX / The Helmanian Revolution** +build failed before its first frame because `Rance9CG.afa` and `Rance9CGEx.afa` +store the compressed index payload length rather than the full INFO length. +After the compatibility fix, the native JSON probe reached the opening movie, +Chinese title/menu and new-game narration. The exported macOS arm64 Debug app +also passed the movie, title, new game and 20 dialogue clicks, showing Chinese +text, backgrounds and characters through Aether's Metal presentation. The +three native CTests passed. Battle/3D flows, save/load, full-game completion +and other platforms were not tested for this change. No game assets included. + +On 2026-10-25, the supplied Chinese **Rance08 / Rance Quest Magnum** build +reproduced missing numeric CG bindings, a Japanese MonsterInfo parse failure, +unsupported TGA title assets and empty Chinese FNL text. The native JSON probe +confirmed startup, title/menu and Chinese help text after the fixes. The +exported macOS arm64 Debug app passed title/menu, new game and 20 dialogue +clicks with Chinese names/text, backgrounds and characters using Metal +presentation. All six native CTests passed. Battle/3D flows, save/load, +full-game completion and other platforms remain unverified. Imported game +assets are unchanged and are not included in this repository. diff --git a/bridge/xsystem4_runtime/src/afa_index_compat.h b/bridge/xsystem4_runtime/src/afa_index_compat.h new file mode 100644 index 00000000..53facd55 --- /dev/null +++ b/bridge/xsystem4_runtime/src/afa_index_compat.h @@ -0,0 +1,32 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later */ +#include +#include +#include "system4/afa.h" +#include "system4/zlib.h" + +static bool aether_afa_decompress_index(FILE *file, struct afa_archive *archive, + void *table, uint8_t **compressed) { + if(zlib_decompress_exact(table, archive->uncompressed_size, *compressed, + archive->compressed_size)) + return true; + + // Some fan repackers store the compressed payload size in INFO instead of + // the size including INFO's 16-byte header (Rance9CG / Rance9CGEx). + // Retry only that variant, within the index region, with a full checksum + // and exact decompressed-size check. Never repair the imported file. + const size_t end = 44u + (size_t)archive->compressed_size; + if(end > archive->data_start || archive->data_start - end < 16) + return false; + uint8_t *extended = realloc(*compressed, archive->compressed_size + 16u); + if(!extended) + return false; + *compressed = extended; + if(fseek(file, (long)end, SEEK_SET) || + fread(extended + archive->compressed_size, 16, 1, file) != 1) + return false; + if(!zlib_decompress_exact(table, archive->uncompressed_size, extended, + archive->compressed_size + 16u)) + return false; + archive->compressed_size += 16; + return true; +} diff --git a/bridge/xsystem4_runtime/src/tga_compat.h b/bridge/xsystem4_runtime/src/tga_compat.h new file mode 100644 index 00000000..2ba29f48 --- /dev/null +++ b/bridge/xsystem4_runtime/src/tga_compat.h @@ -0,0 +1,112 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later */ +#include +#include +#include +#include +#include "system4/cg.h" + +static bool aether_tga_matches(const uint8_t *data, size_t size) { + return size >= 18 && data[1] == 0 && (data[2] == 2 || data[2] == 10) && + (data[16] == 24 || data[16] == 32) && !(data[17] & 0xc0); +} + +static bool aether_tga_metrics(const uint8_t *data, size_t size, + struct cg_metrics *metrics) { + if(!aether_tga_matches(data, size)) + return false; + const int width = data[12] | (data[13] << 8); + const int height = data[14] | (data[15] << 8); + if(width < 1 || height < 1 || (size_t)width * height > 64u * 1024u * 1024u) + return false; + *metrics = (struct cg_metrics){ .w = width, + .h = height, + .bpp = data[16], + .has_pixel = true, + .has_alpha = + data[16] == 32 && (data[17] & 0xf) != 0, + .pixel_pitch = width * (data[16] / 8), + .alpha_pitch = 1 }; + return true; +} + +static struct cg *aether_tga_load(const uint8_t *data, size_t size) { + struct cg_metrics metrics; + if(size > INT_MAX || !aether_tga_metrics(data, size, &metrics)) + return NULL; + // SDL_image can return a partial surface for a truncated TGA. Validate the + // complete pixel stream before handing it to the decoder. + size_t position = 18u + data[0]; + size_t remaining = (size_t)metrics.w * metrics.h; + const size_t pixel_size = data[16] / 8; + if(position > size) + return NULL; + if(data[2] == 2) { + if(remaining > (size - position) / pixel_size) + return NULL; + } else { + while(remaining) { + if(position >= size) + return NULL; + const uint8_t packet = data[position++]; + const size_t count = (packet & 0x7f) + 1; + const size_t bytes = + (packet & 0x80) ? pixel_size : count * pixel_size; + if(count > remaining || bytes > size - position) + return NULL; + position += bytes; + remaining -= count; + } + } + // SDL_image handles vertical origin but rejects right-to-left storage. + // Normalize that header in memory, then mirror the decoded row below. + uint8_t *normalized = NULL; + if(data[17] & 0x10) { + normalized = malloc(size); + if(!normalized) + return NULL; + memcpy(normalized, data, size); + normalized[17] &= ~0x10; + } + SDL_RWops *stream = + SDL_RWFromConstMem(normalized ? normalized : data, (int)size); + if(!stream) { + free(normalized); + return NULL; + } + SDL_Surface *surface = IMG_LoadTyped_RW(stream, 1, "TGA"); + free(normalized); + if(!surface) + return NULL; + SDL_Surface *rgba = + SDL_ConvertSurfaceFormat(surface, SDL_PIXELFORMAT_RGBA32, 0); + SDL_FreeSurface(surface); + if(!rgba) + return NULL; + struct cg *image = calloc(1, sizeof(*image)); + if(image) { + image->metrics = metrics; + image->pixels = malloc((size_t)metrics.w * metrics.h * 4); + if(!image->pixels || SDL_LockSurface(rgba)) { + free(image->pixels); + free(image); + image = NULL; + } else { + for(int y = 0; y < metrics.h; ++y) { + uint8_t *row = + (uint8_t *)image->pixels + (size_t)y * metrics.w * 4; + const uint8_t *source = + (uint8_t *)rgba->pixels + (size_t)y * rgba->pitch; + if(data[17] & 0x10) { + for(int x = 0; x < metrics.w; ++x) + memcpy(row + x * 4, source + (metrics.w - 1 - x) * 4, + 4); + } else { + memcpy(row, source, (size_t)metrics.w * 4); + } + } + SDL_UnlockSurface(rgba); + } + } + SDL_FreeSurface(rgba); + return image; +} diff --git a/bridge/xsystem4_runtime/src/xsystem4_runtime_provider.cpp b/bridge/xsystem4_runtime/src/xsystem4_runtime_provider.cpp index c0c16436..ecaf2db6 100644 --- a/bridge/xsystem4_runtime/src/xsystem4_runtime_provider.cpp +++ b/bridge/xsystem4_runtime/src/xsystem4_runtime_provider.cpp @@ -84,6 +84,29 @@ namespace aetherkiri::xsystem4 { result << std::hex << value; return result.str(); } + bool LooksLikeGbk(const std::string &sample) { + if(sample.empty()) + return false; + uchardet_t detector = uchardet_new(); + if(!detector) + return false; + // A few GBK characters can be reported as UTF-8 with insufficient + // statistical evidence, even when their bytes are not valid UTF-8. + // Repeat short text at line boundaries to give the detector enough + // observations, without admitting comments into the sample. + std::string observations = sample; + while(observations.size() < 128) { + observations += '\n'; + observations += sample; + } + uchardet_handle_data(detector, observations.data(), + observations.size()); + uchardet_data_end(detector); + const std::string encoding = Lower(uchardet_get_charset(detector)); + uchardet_delete(detector); + return encoding == "gb18030" || encoding == "gb2312" || + encoding == "gbk" || encoding == "hz-gb-2312"; + } std::string DetectEncoding(const std::string &root) { for(const auto &entry : fs::directory_iterator(fs::u8path(root))) { const auto name = Lower(entry.path().filename().u8string()); @@ -93,14 +116,45 @@ namespace aetherkiri::xsystem4 { std::string data(65536, '\0'); stream.read(data.data(), data.size()); data.resize(stream.gcount()); - uchardet_t detector = uchardet_new(); - uchardet_handle_data(detector, data.data(), data.size()); - uchardet_data_end(detector); - const std::string encoding = - Lower(uchardet_get_charset(detector)); - uchardet_delete(detector); - if(encoding == "gb18030" || encoding == "gb2312" || - encoding == "gbk" || encoding == "hz-gb-2312") + // Translations often retain Shift-JIS comments while replacing + // the actual values with GBK. Comments must not determine the + // encoding used for script filenames and in-game text. + std::istringstream lines(data); + std::string sample, title, line; + while(std::getline(lines, line)) { + bool quoted = false; + for(size_t i = 0; i < line.size(); i++) { + if(line[i] == '"') + quoted = !quoted; + if(!quoted && + (line[i] == ';' || line.compare(i, 2, "//") == 0)) { + line.resize(i); + break; + } + } + const auto start = line.find_first_not_of(" \t\r"); + if(start == std::string::npos) + continue; + sample += line; + sample += '\n'; + const auto key_end = line.find_first_of(" \t=", start); + if(line.substr(start, key_end - start) != "GameName") + continue; + const auto equals = line.find('=', key_end); + if(equals == std::string::npos) + continue; + const auto first = + line.find_first_not_of(" \t", equals + 1); + if(first == std::string::npos || line[first] != '"') + continue; + const auto last = line.find('"', first + 1); + if(last != std::string::npos) + title = line.substr(first + 1, last - first - 1); + } + // A translated title takes priority over original Japanese + // labels left in other values (e.g. mixer channel names). + // Without positive GBK evidence, keep the Japanese default. + if(LooksLikeGbk(title) || LooksLikeGbk(sample)) return "gbk"; } return "sjis"; diff --git a/doc/verified_games.md b/doc/verified_games.md index 808c9af9..a0b28b34 100644 --- a/doc/verified_games.md +++ b/doc/verified_games.md @@ -2,7 +2,7 @@ [简体中文](verified_games.zh-CN.md) -Last updated: 2026-10-18 +Last updated: 2026-10-25 This document tracks games that have been manually smoke-tested with AetherKiri. It is a compatibility notebook, not a guarantee that every route, @@ -20,7 +20,15 @@ movie, plugin path, or save state in a title has been exhaustively validated. | Game | Verified platforms / builds | Verified scope | Result | Verifier | Notes | | --- | --- | --- | --- | --- | --- | -| 戦国ランス / Sengoku Rance v1.01 (Chinese) | macOS arm64 debug, Aether main window and native JSON probe | Native xsystem4 startup, 800×600 frames, opening movie/audio, title, new game, Chinese dialogue/characters, pointer advancement, in-game menu, pause/resume and repeated reopening | Flow verified | Local JSON probe | iOS, Android and Linux game playback still needs validation. Game assets are not included. | +| 兰斯8 / Rance Quest Magnum (Chinese) | macOS arm64 debug, native JSON probe and Aether window | Native xsystem4, translated-title CG interface detection, mixed-encoding MonsterInfo, TGA title/buttons, CJK font fallback, 1024×768 frames, new game and 20 dialogue clicks with Chinese names/text and characters | Flow verified | Local JSON probe | Checked on 2026-10-25. Six native CTests passed. Battle/3D flows, save/load, other platforms and full-game completion remain unverified. Imported game assets are unchanged and not included. | +| 兰斯9 赫尔曼革命 / Rance IX (Chinese) | macOS arm64 debug, native JSON probe and Aether window | Native xsystem4, fan-repacked AFA index loading, 1280×720 frames, opening movie, Chinese title/menu, new game, Chinese narration/dialogue and characters, 20 dialogue clicks | Flow verified | Local JSON probe | Checked on 2026-10-25. AFA INFO payload-length variant is handled without modifying game archives. Three native CTests passed. Battle/3D flows, save/load, other platforms and full-game completion remain unverified. No game assets included. | +| 兰斯4.1 / Rance4.1 (Chinese, DAT) | macOS arm64 debug, native JSON probe and Aether window | Native system3, 640×400 frames, Chinese menu/dialogue, 20 dialogue advances, MP3 music, imported-save loading, an isolated save slot and loading it after reopening | Flow verified | Local JSON probe | Checked on 2026-10-24. Writes use an isolated test directory and preserve imported saves. Other platforms and full-game completion remain unverified. No game assets included. | +| 兰斯4.2 / Rance4.2 (Chinese, DAT) | macOS arm64 debug, native JSON probe and Aether window | Native system3, 640×400 frames, Chinese menu/dialogue, backgrounds/characters, 20 dialogue advances, MP3 music, imported-save loading, pause/resume and reopening | Flow verified | Local JSON probe | Checked on 2026-10-24. Actual saving, other platforms and full-game completion remain unverified. No game assets included. | +| 兰斯IV -教团的遗产- / RanceIV (Chinese, ALD) | macOS arm64 debug, native JSON probe and Aether window | Native xsystem35, 640×480 startup, entering the game, Chinese UI/dialogue, map navigation, MP3 music, pause/resume and reopening | Flow verified | Local JSON probe | Checked on 2026-10-24 with isolated saves. Other platforms and save/load flows remain unverified. No game assets included. | +| 兰斯5D / Rance5D (Chinese, ALD) | macOS arm64 debug, native JSON probe and Aether window | Native xsystem35, GBK text, Chinese title/dialogue, entering the game, first-chapter mosaic transition, pointer input, pause/resume and reopening | Flow verified | Local JSON probe | Checked on 2026-10-24 with isolated saves. Other platforms and save/load flows remain unverified. No game assets included. | +| 兰斯01 / Rance01 (Chinese) | macOS arm64 debug, native JSON probe | Native xsystem4 GBK detection, 1024×768 frames, title/Chinese menu, new game, Chinese dialogue and pointer advancement | Flow verified | Local JSON probe | Checked on 2026-10-24 with isolated probe saves. Packaged app and other platforms were not retested for this change. Game assets are not included. | +| 兰斯02 / Rance02 (Chinese) | macOS arm64 debug, native JSON probe | Native xsystem4 GBK detection, Japanese AIN filename resolution, 800×600 frames, title/Chinese menu, new game, Chinese dialogue and pointer advancement | Flow verified | Local JSON probe | Checked on 2026-10-24 with isolated probe saves. Packaged app and other platforms were not retested for this change. Game assets are not included. | +| 戦国ランス / Sengoku Rance v1.01 (Chinese) | macOS arm64 debug, Aether main window and native JSON probe | Native xsystem4 startup, 800×600 frames, opening movie/audio, title, new game, Chinese dialogue/characters, pointer advancement, in-game menu, pause/resume and repeated reopening | Flow verified | Local JSON probe | Rechecked 2026-10-25: short GBK title auto-detection regression fixed; packaged Chinese menu, new game and 20 advances passed. iOS, Android and Linux game playback still needs validation. Game assets are not included. | | リップリップルズ | Android release APK | Import, startup, initial title/UI and basic input | Smoke verified | [@KYoiRyi](https://github.com/KYoiRyi) | Local game files are not committed. | | Magical Charming! | macOS x64 debug app | Startup through the package's absent optional opening cut-ins, title/menu rendering, new-game button dispatch, first scenario/background/text rendering, and basic input | Flow verified | [@akitaSummer](https://github.com/akitaSummer) | The iOS package omits the stock `ab_*` opening cut-ins; AetherKiri skips only those missing opening waits. Local game files are not committed. | | 恋がさくころ桜どき | Linux x64 release app; macOS app; iOS/iPadOS app build on iPad | Import, startup, initial title/UI and text rendering, and basic input | Smoke verified | [@KYoiRyi](https://github.com/KYoiRyi), [@MadCcc](https://github.com/MadCcc) | Local game files are not committed. | diff --git a/doc/verified_games.zh-CN.md b/doc/verified_games.zh-CN.md index 6a22ce60..feef59e9 100644 --- a/doc/verified_games.zh-CN.md +++ b/doc/verified_games.zh-CN.md @@ -2,7 +2,7 @@ [English](verified_games.md) -最后更新:2026-10-18 +最后更新:2026-10-25 本文档记录已经用 AetherKiri 手动 smoke test 或 flow test 过的游戏。它是兼容性记录, 不代表每条路线、每个视频、每个插件路径或每个存档状态都已经完整验证。 @@ -19,7 +19,15 @@ | 游戏 | 已验证平台/构建 | 验证范围 | 结果 | 验证人 | 备注 | | --- | --- | --- | --- | --- | --- | -| 战国兰斯 / Sengoku Rance v1.01(中文) | macOS arm64 debug,Aether 主窗口与原生 JSON probe | xsystem4 原生启动、800×600 画面、开场视频/音频、标题、新游戏、中文对白与立绘、点击推进、游戏菜单、暂停恢复与反复重开 | Flow verified | 本地 JSON probe | iOS、Android、Linux 的实际游戏流程待验证;不包含游戏资源。 | +| 兰斯8 / Rance Quest Magnum(中文) | macOS arm64 debug,原生 JSON probe 与 Aether 窗口 | xsystem4 中文标题下的图片索引识别、MonsterInfo 混合编码、TGA 标题/按钮、CJK 字体回退、1024×768 画面、新游戏、20 次点击推进、中文姓名/对白及立绘 | 流程验证通过 | 本地 JSON probe | 2026-10-25 验证;6 项原生单测通过。战斗/3D、存读档、其他平台及完整流程尚未验证;不修改或包含游戏资源。 | +| 兰斯9 赫尔曼革命(中文) | macOS arm64 debug,原生 JSON probe 与 Aether 窗口 | xsystem4 原生启动、补丁版 AFA 索引兼容、1280×720 画面、开场视频、中文标题/菜单、新游戏、中文旁白/对话与立绘、20 次点击推进 | 流程验证通过 | 本地 JSON probe | 2026-10-25 验证;兼容 AFA INFO 长度不含头部的打包格式,不修改游戏资源。3 项原生单测通过;战斗/3D、存读档、其他平台及完整流程尚未验证;不包含游戏资源。 | +| 兰斯4.1 -拯救制药厂-(中文 DAT 版) | macOS arm64 debug,原生 JSON probe 与 Aether 窗口 | system3 原生启动、640×400 渲染、中文菜单/对话、20 次推进、MP3 音乐、读取原目录存档、独立槽位保存并重新打开后读取 | 流程验证通过 | 本地 JSON probe | 2026-10-24 验证;新存档写入独立测试目录,不覆盖导入目录。其他平台与完整流程未验证;不包含游戏资源。 | +| 兰斯4.2 -天使组-(中文 DAT 版) | macOS arm64 debug,原生 JSON probe 与 Aether 窗口 | system3 原生启动、640×400 渲染、中文菜单/对话、背景/人物、20 次推进、MP3 音乐、读取原目录存档、暂停恢复和重新打开 | 流程验证通过 | 本地 JSON probe | 2026-10-24 验证;实际保存流程、其他平台与完整流程未验证;不包含游戏资源。 | +| 兰斯IV -教团的遗产-(中文 ALD 版) | macOS arm64 debug,原生 JSON probe 与 Aether 窗口 | xsystem35 启动、640×480 渲染、进入游戏、中文界面与对话、地图移动、MP3 背景音乐、暂停/恢复和重新打开 | 流程验证通过 | 本地 JSON probe | 2026-10-24 验证,使用独立测试存档。其他平台和存读档流程未验证;不包含游戏资源。 | +| 兰斯5D(中文 ALD 版) | macOS arm64 debug,原生 JSON probe 与 Aether 窗口 | xsystem35 GBK 文字、中文标题与对话、进入游戏、第一章马赛克转场、指针输入、暂停/恢复和重新打开 | 流程验证通过 | 本地 JSON probe | 2026-10-24 验证,使用独立测试存档。其他平台和存读档流程未验证;不包含游戏资源。 | +| 兰斯01 / Rance01(中文) | macOS arm64 debug,原生 JSON probe | xsystem4 GBK 自动识别、1024×768 画面、标题/中文菜单、新游戏、中文对白与点击推进 | 流程验证通过 | 本地 JSON probe | 2026-10-24 验证;使用独立测试存档目录。此次未复测 App 整包及其他平台,不包含游戏资源。 | +| 兰斯02 / Rance02(中文) | macOS arm64 debug,原生 JSON probe | xsystem4 GBK 自动识别、日文 AIN 文件名解析、800×600 画面、标题/中文菜单、新游戏、中文对白与点击推进 | 流程验证通过 | 本地 JSON probe | 2026-10-24 验证;使用独立测试存档目录。此次未复测 App 整包及其他平台,不包含游戏资源。 | +| 战国兰斯 / Sengoku Rance v1.01(中文) | macOS arm64 debug,Aether 主窗口与原生 JSON probe | xsystem4 原生启动、800×600 画面、开场视频/音频、标题、新游戏、中文对白与立绘、点击推进、游戏菜单、暂停恢复与反复重开 | Flow verified | 本地 JSON probe | 2026-10-25 复测:修复短 GBK 标题的自动识别回归;整包中文菜单、开局和 20 次剧情推进通过。 iOS、Android、Linux 的实际游戏流程待验证;不包含游戏资源。 | | リップリップルズ | Android release APK | 导入、启动、初始标题/UI 与基础输入 | 冒烟验证通过 | [@KYoiRyi](https://github.com/KYoiRyi) | 本地游戏文件不提交到仓库。 | | Magical Charming! | macOS x64 debug app | 启动时跳过包内缺失的可选开场切入图、标题/菜单渲染、开始游戏按钮分发、首个场景/背景/文字渲染,以及基础输入 | 流程验证通过 | [@akitaSummer](https://github.com/akitaSummer) | 该 iOS 资源包未包含标准 `ab_*` 开场切入图;AetherKiri 只跳过这些缺失开场图对应的等待。本地游戏文件不提交到仓库。 | | 恋がさくころ桜どき | Linux x64 release app;macOS app;iOS/iPadOS iPad app build | 导入、启动、初始标题/UI 与文本渲染,以及基础输入 | 冒烟验证通过 | [@KYoiRyi](https://github.com/KYoiRyi)、[@MadCcc](https://github.com/MadCcc) | 本地游戏文件不提交到仓库。 | diff --git a/packages/AetherInternal b/packages/AetherInternal index e1405bdf..2dbbfeed 160000 --- a/packages/AetherInternal +++ b/packages/AetherInternal @@ -1 +1 @@ -Subproject commit e1405bdfa22ab86c96ba2ff4bcd35ee3e56efbaa +Subproject commit 2dbbfeedea6f951322dcfdbeb30d15b6d93a0fab diff --git a/packages/system3 b/packages/system3 new file mode 160000 index 00000000..e1ceccbf --- /dev/null +++ b/packages/system3 @@ -0,0 +1 @@ +Subproject commit e1ceccbfa601be3d801ec1870208130f900d80bf diff --git a/packages/xsystem35 b/packages/xsystem35 new file mode 160000 index 00000000..79437cc8 --- /dev/null +++ b/packages/xsystem35 @@ -0,0 +1 @@ +Subproject commit 79437cc884d7e2efa613c1b946825d087c321ccd diff --git a/packages/xsystem4 b/packages/xsystem4 index 16f1aa21..dd4a10df 160000 --- a/packages/xsystem4 +++ b/packages/xsystem4 @@ -1 +1 @@ -Subproject commit 16f1aa210e48fa9d910727e93fffd4f4d76226ab +Subproject commit dd4a10df4eedcd6117e3d1813e677652255ce54c diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index afa73e18..c5e30b53 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -325,6 +325,10 @@ combine_ios_static_extension() { local libs=( "$CMAKE_BUILD_DIR/bridge/godot_extension/libaether_kiri_godot.a" "$CMAKE_BUILD_DIR/bridge/onscripter_runtime/libaether_onscripter_runtime.a" + "$CMAKE_BUILD_DIR/bridge/system3_runtime/libaether_system3_runtime.a" + "$CMAKE_BUILD_DIR/bridge/system3_runtime/libaether_system3_core.a" + "$CMAKE_BUILD_DIR/bridge/xsystem35_runtime/libaether_xsystem35_runtime.a" + "$CMAKE_BUILD_DIR/bridge/xsystem35_runtime/libaether_xsystem35_core.a" "$CMAKE_BUILD_DIR/bridge/xsystem4_runtime/libaether_xsystem4_runtime.a" "$CMAKE_BUILD_DIR/bridge/xsystem4_runtime/libsys4/libsys4.a" "$CMAKE_BUILD_DIR/abi/libengine_api.a" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7cad15a5..2e1c6c69 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -32,7 +32,7 @@ add_subdirectory(unit-tests/engine_api) if(AETHERKIRI_INTERNAL_ENABLED) aetherinternal_add_runtime_tests("${CMAKE_SOURCE_DIR}") endif() -if(TARGET aether_siglus_runtime) +if(AETHERKIRI_ENABLE_SIGLUS) add_subdirectory(unit-tests/siglus_runtime) endif() if(TARGET aether_mages_runtime) @@ -44,7 +44,95 @@ if(TARGET aether_xsystem4_runtime) target_include_directories(aether_xsystem4_native_contract PRIVATE "${CMAKE_SOURCE_DIR}/abi/include") target_link_libraries(aether_xsystem4_native_contract PRIVATE aether_xsystem4_runtime) add_test(NAME xsystem4-native-contract COMMAND aether_xsystem4_native_contract) + add_executable(aether_xsystem4_scene_keeper_contract xsystem4_native/scene_keeper.c) + target_include_directories(aether_xsystem4_scene_keeper_contract PRIVATE + "${AETHERKIRI_XSYSTEM4_DIR}/include" + "${AETHERKIRI_XSYSTEM4_DIR}/src/hll" + "${AETHERKIRI_XSYSTEM4_DIR}/subprojects/libsys4/include") + target_link_libraries(aether_xsystem4_scene_keeper_contract PRIVATE aether_xsystem4_runtime) + add_test(NAME xsystem4-scene-keeper-contract COMMAND aether_xsystem4_scene_keeper_contract) + find_package(SDL2 CONFIG REQUIRED) + find_package(SDL2_image CONFIG REQUIRED) + foreach(contract afa rance_quest tga) + add_executable(aether_xsystem4_${contract}_contract xsystem4_native/${contract}.cpp) + target_compile_features(aether_xsystem4_${contract}_contract PRIVATE cxx_std_17) + target_include_directories(aether_xsystem4_${contract}_contract PRIVATE + "${AETHERKIRI_XSYSTEM4_DIR}/include" + "${AETHERKIRI_XSYSTEM4_DIR}/subprojects/libsys4/include") + target_link_libraries(aether_xsystem4_${contract}_contract PRIVATE aether_xsystem4_runtime) + string(REPLACE "_" "-" test_name "${contract}") + add_test(NAME xsystem4-${test_name}-contract COMMAND aether_xsystem4_${contract}_contract) + endforeach() + target_link_libraries(aether_xsystem4_tga_contract PRIVATE + $,SDL2_image::SDL2_image,SDL2_image::SDL2_image-static>) + add_executable(aether_xsystem4_font_fallback_contract xsystem4_native/font_fallback.c) + target_compile_definitions(aether_xsystem4_font_fallback_contract PRIVATE XSYSTEM4_EMBEDDED) + if(NOT APPLE) + target_compile_definitions(aether_xsystem4_font_fallback_contract PRIVATE USE_GLES) + endif() + target_include_directories(aether_xsystem4_font_fallback_contract PRIVATE + "${AETHERKIRI_XSYSTEM4_DIR}/include" + "${AETHERKIRI_XSYSTEM4_DIR}/subprojects/libsys4/include") + target_link_libraries(aether_xsystem4_font_fallback_contract PRIVATE aether_xsystem4_runtime + $,SDL2::SDL2,SDL2::SDL2-static>) + add_test(NAME xsystem4-font-fallback-contract COMMAND aether_xsystem4_font_fallback_contract) endif() if(AETHERKIRI_ENABLE_ONSCRIPTER) add_subdirectory(unit-tests/onscripter_runtime) endif() + +if(TARGET aether_xsystem35_runtime) + add_executable(aether_xsystem35_native_contract xsystem35_native/contract.cpp) + target_compile_features(aether_xsystem35_native_contract PRIVATE cxx_std_17) + target_include_directories(aether_xsystem35_native_contract PRIVATE "${CMAKE_SOURCE_DIR}/abi/include") + target_link_libraries(aether_xsystem35_native_contract PRIVATE aether_xsystem35_runtime) + add_test(NAME xsystem35-native-contract COMMAND aether_xsystem35_native_contract) + set_tests_properties(xsystem35-native-contract PROPERTIES TIMEOUT 30) +endif() + +if(TARGET aether_xsystem35_core) + get_target_property(xsystem35_binary_dir aether_xsystem35_core BINARY_DIR) + add_executable(aether_xsystem35_encoding_contract xsystem35_native/encoding.c) + target_include_directories(aether_xsystem35_encoding_contract PRIVATE + "${AETHERKIRI_XSYSTEM35_DIR}/src" "${xsystem35_binary_dir}/generated") + target_link_libraries(aether_xsystem35_encoding_contract PRIVATE aether_xsystem35_core) + add_test(NAME xsystem35-encoding-contract COMMAND aether_xsystem35_encoding_contract) + + # Compile the real dispatcher with embedded Android selection even on a + # desktop host. Missing standalone JNI backends must fail at link time. + add_executable(aether_xsystem35_embedded_midi_contract xsystem35_native/midi.c + "${AETHERKIRI_XSYSTEM35_DIR}/src/midi.c") + target_compile_definitions(aether_xsystem35_embedded_midi_contract PRIVATE + XSYSTEM35_EMBEDDED __ANDROID__) + target_include_directories(aether_xsystem35_embedded_midi_contract PRIVATE + "${AETHERKIRI_XSYSTEM35_DIR}/src" "${xsystem35_binary_dir}/generated") + add_test(NAME xsystem35-embedded-midi-contract + COMMAND aether_xsystem35_embedded_midi_contract) +endif() + +if(TARGET aether_xsystem35_core) + find_package(SDL2 CONFIG REQUIRED) + add_executable(aether_xsystem35_mosaic_contract xsystem35_native/mosaic.c + "${AETHERKIRI_XSYSTEM35_DIR}/src/render_mosaic.c") + target_include_directories(aether_xsystem35_mosaic_contract PRIVATE + "${AETHERKIRI_XSYSTEM35_DIR}/src" "${xsystem35_binary_dir}/generated") + target_link_libraries(aether_xsystem35_mosaic_contract PRIVATE + $,SDL2::SDL2,SDL2::SDL2-static>) + if(UNIX AND NOT APPLE) + target_link_libraries(aether_xsystem35_mosaic_contract PRIVATE m) + endif() + add_test(NAME xsystem35-mosaic-contract COMMAND aether_xsystem35_mosaic_contract) +endif() + +if(TARGET aether_xsystem35_core) + find_package(SDL2_mixer CONFIG REQUIRED) + add_executable(aether_xsystem35_audio_contract xsystem35_native/audio.c) + target_link_libraries(aether_xsystem35_audio_contract PRIVATE + $,SDL2::SDL2,SDL2::SDL2-static> + $,SDL2_mixer::SDL2_mixer,SDL2_mixer::SDL2_mixer-static>) + add_test(NAME xsystem35-audio-contract COMMAND aether_xsystem35_audio_contract) +endif() + +if(TARGET aether_system3_runtime) + include("${CMAKE_CURRENT_SOURCE_DIR}/system3_native/contracts.cmake") +endif() diff --git a/tests/python/test_ctest_inventory.py b/tests/python/test_ctest_inventory.py new file mode 100644 index 00000000..4cdab958 --- /dev/null +++ b/tests/python/test_ctest_inventory.py @@ -0,0 +1,51 @@ +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +REPO = Path(__file__).resolve().parents[2] +SCRIPT = REPO / "tools/check_ctest_inventory.py" + + +class CTestInventoryTests(unittest.TestCase): + def check_inventory(self, tests): + with tempfile.TemporaryDirectory() as directory: + inventory = Path(directory) / "inventory.json" + inventory.write_text(json.dumps({"tests": tests})) + return subprocess.run( + [sys.executable, str(SCRIPT), str(inventory), + "--require-executable", "runtime-core-tests", + "--require-executable", "runtime-provider-tests"], + capture_output=True, text=True, + ) + + def test_all_required_suites_are_discovered(self): + result = self.check_inventory([ + {"name": "core case", "command": ["/build/runtime-core-tests", "core case"]}, + {"name": "provider case", "command": ["/build/runtime-provider-tests", "provider case"]}, + {"name": "other case", "command": ["/build/runtime-core-tests", "other case"]}, + ]) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("runtime-core-tests: 2", result.stdout) + + def test_other_suites_cannot_hide_an_omitted_runtime(self): + result = self.check_inventory([ + {"name": "core case", "command": ["/build/runtime-core-tests"]}, + {"name": "another engine", "command": ["/build/other-engine-tests"]}, + ]) + self.assertNotEqual(result.returncode, 0) + self.assertIn("runtime-provider-tests", result.stderr) + + def test_empty_or_unbuilt_discovery_is_rejected(self): + for tests in [[], [{"name": "runtime-provider-tests"}]]: + with self.subTest(tests=tests): + result = self.check_inventory(tests) + self.assertNotEqual(result.returncode, 0) + self.assertIn("runtime-core-tests", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/python/test_runtime_submodule_initialization.py b/tests/python/test_runtime_submodule_initialization.py new file mode 100644 index 00000000..0b87546b --- /dev/null +++ b/tests/python/test_runtime_submodule_initialization.py @@ -0,0 +1,58 @@ +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +REPO = Path(__file__).resolve().parents[2] +SCRIPT = REPO / ".github/scripts/initialize-private-runtime-submodules.sh" + + +class RuntimeSubmoduleInitializationTests(unittest.TestCase): + def test_private_build_initializes_every_pinned_runtime_recursively(self): + configured = subprocess.check_output( + ["git", "config", "-f", str(REPO / ".gitmodules"), "--get-regexp", + r"submodule\..*\.path"], + text=True, + ) + expected = {line.split(None, 1)[1] for line in configured.splitlines()} + with tempfile.TemporaryDirectory() as directory: + temporary = Path(directory) + log = temporary / "git-calls.jsonl" + git = temporary / "git" + git.write_text( + "#!/usr/bin/env python3\n" + "import json, os, sys\n" + "with open(os.environ['GIT_CALL_LOG'], 'a') as output:\n" + " output.write(json.dumps(sys.argv[1:]) + '\\n')\n" + ) + git.chmod(0o755) + environment = dict( + os.environ, + PATH=str(temporary) + os.pathsep + os.environ["PATH"], + RUNNER_TEMP=str(temporary), + GIT_CALL_LOG=str(log), + AETHER_INTERNAL_DEPLOY_KEY="test-only-internal-key", + AETHER_SOFTPAL_DEPLOY_KEY="test-only-softpal-key", + ) + subprocess.run(["bash", str(SCRIPT)], env=environment, check=True) + calls = [json.loads(line) for line in log.read_text().splitlines()] + + initialized = { + argument for call in calls for argument in call + if argument.startswith("packages/") + } + self.assertEqual(initialized, expected) + for runtime in expected - {"packages/AetherSoftPal"}: + with self.subTest(runtime=runtime): + self.assertTrue(any( + call[:2] == ["submodule", "update"] + and "--init" in call and "--recursive" in call + and runtime in call for call in calls + )) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/system3_native/CMakeLists.txt b/tests/system3_native/CMakeLists.txt new file mode 100644 index 00000000..deab90d9 --- /dev/null +++ b/tests/system3_native/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.28) +project(AetherSystem3NativeProbe LANGUAGES C CXX) +get_filename_component(AETHER_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) +set(AETHERKIRI_SYSTEM3_DIR "${AETHER_ROOT}/packages/system3") +add_subdirectory("${AETHER_ROOT}/bridge/system3_runtime" "${CMAKE_CURRENT_BINARY_DIR}/runtime") +find_package(nlohmann_json CONFIG REQUIRED) +find_package(SDL2_image CONFIG REQUIRED) +add_executable(aether_system3_native_probe probe.cpp) +target_compile_features(aether_system3_native_probe PRIVATE cxx_std_17) +target_include_directories(aether_system3_native_probe PRIVATE "${AETHER_ROOT}/abi/include" "${AETHERKIRI_SYSTEM3_DIR}/src") +target_link_libraries(aether_system3_native_probe PRIVATE aether_system3_runtime nlohmann_json::nlohmann_json + $,SDL2_image::SDL2_image,SDL2_image::SDL2_image-static>) +enable_testing() +include("${CMAKE_CURRENT_LIST_DIR}/contracts.cmake") diff --git a/tests/system3_native/contract.cpp b/tests/system3_native/contract.cpp new file mode 100644 index 00000000..76056b3b --- /dev/null +++ b/tests/system3_native/contract.cpp @@ -0,0 +1,207 @@ +#include "system3_runtime_provider.h" +#include "embedded_symbols.h" +#include "fileio.h" +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +static const engine_runtime_provider_v1_t *provider; +extern "C" engine_result_t +engine_register_runtime_provider(const engine_runtime_provider_v1_t *value) { + provider = value; + return ENGINE_RESULT_OK; +} +static void Require(bool value, const char *message) { + if(!value) + throw std::runtime_error(message); +} +static void MinimalGame(const fs::path &file) { + // A synthetic DRI archive: one UTF-8 text page, A (wait for input), loop. + std::vector data(768); + data[0] = 2; + data[2] = 3; + data[4] = 4; + data[256] = 1; + data[257] = 1; + const std::string page = + std::string("\x02\x00", 2) + "'中文测试'A@\x02\x00"; + std::copy(page.begin(), page.end(), data.begin() + 512); + std::ofstream output(file, std::ios::binary); + output.write(reinterpret_cast(data.data()), data.size()); +} +static engine_frame_desc_t Frame(void *runtime) { + engine_frame_desc_t frame{}; + frame.struct_size = sizeof(frame); + Require(provider->get_frame_desc(runtime, &frame) == ENGINE_RESULT_OK, + "describe frame"); + return frame; +} +static bool WaitForFrameAfter(void *runtime, uint64_t serial) { + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(2); + do { + if(Frame(runtime).frame_serial > serial) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } while(std::chrono::steady_clock::now() < deadline); + return false; +} +static void Set(void *runtime, const char *key, const char *value) { + engine_option_t option{}; + option.key_utf8 = key; + option.value_utf8 = value; + Require(provider->set_option(runtime, &option) == ENGINE_RESULT_OK, + "set option"); +} +int main() { + const auto cwd = fs::current_path(); + const auto root = + fs::temp_directory_path() / + fs::u8path( + "aether-system3-中文-" + + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + void *runtime = nullptr, *other = nullptr; + try { + Require(aetherkiri_system3_register_runtime_provider() == + ENGINE_RESULT_OK, + "register"); + Require(provider && std::string(provider->runtime_id_utf8) == "system3", + "provider ID"); + fs::create_directories(root / "game"); + fs::create_directories(root / "writable"); + const auto game = (root / "game").u8string(), + saves = (root / "writable").u8string(); + std::ofstream(root / "game/aDiSk.DaT") << "bad archive"; + Require(provider->probe(nullptr, game.c_str()) == 0, + "DAT alone is ambiguous"); + std::ofstream(root / "game/aCg.Dat") << "graphics"; + Require(provider->probe(nullptr, game.c_str()) > 0, + "mixed case DAT detection"); + for(const char *marker : { "ALICESTART.INI", "RanceSA.ALD" }) { + std::ofstream(root / "game" / marker) << "newer runtime"; + Require(provider->probe(nullptr, game.c_str()) == 0, + "System4/35 exclusion"); + fs::remove(root / "game" / marker); + } + std::ofstream(root / "game/SyStEm3.InI") + << "[config]\ngame=system3_generic\nencoding=utf8\n"; + engine_runtime_host_v1_t host{}; + host.struct_size = sizeof(host); + host.api_version = ENGINE_RUNTIME_PROVIDER_API_VERSION; + engine_create_desc_t desc{}; + desc.struct_size = sizeof(desc); + desc.api_version = ENGINE_API_VERSION; + desc.writable_path_utf8 = saves.c_str(); + Require(provider->create(nullptr, &host, &desc, &runtime) == + ENGINE_RESULT_OK, + "create"); + Set(runtime, "default_font", SYSTEM3_TEST_FONT); + for(int i = 0; i < 3; i++) + Require(provider->open_game(runtime, game.c_str(), nullptr) != + ENGINE_RESULT_OK, + "invalid archive contained without process exit"); + MinimalGame(root / "game/aDiSk.DaT"); + std::ofstream(root / "game/SyStEm3.InI") + << "[config]\ngame=unknown_translation\n"; + Require(provider->open_game(runtime, game.c_str(), nullptr) != + ENGINE_RESULT_OK, + "unknown game id contained"); + std::ofstream(root / "game/SyStEm3.InI") + << "[config]\ngame=system3_generic\nencoding=utf8\nsavedir=../" + "outside\n"; + Set(runtime, "default_font", "/missing/aether-system3-font.ttf"); + Require(provider->open_game(runtime, game.c_str(), nullptr) != + ENGINE_RESULT_OK, + "font failure contained"); + Set(runtime, "default_font", SYSTEM3_TEST_FONT); + std::ofstream(root / "game/ASLEEP_A.DAT") << "original save"; + for(int i = 0; i < 3; i++) { + Require(provider->open_game(runtime, game.c_str(), nullptr) == + ENGINE_RESULT_OK, + provider->get_last_error(runtime)); + auto frame = Frame(runtime); + Require(frame.width == 640 && frame.height == 400 && + frame.stride_bytes == 2560, + "native frame dimensions"); + std::vector pixels(frame.stride_bytes * frame.height); + Require(provider->read_frame_rgba(runtime, pixels.data(), + pixels.size() - 1) == + ENGINE_RESULT_INVALID_ARGUMENT, + "short read rejected"); + Require(provider->read_frame_rgba(runtime, pixels.data(), + pixels.size()) == + ENGINE_RESULT_OK, + "RGBA read"); + Require(provider->pause(runtime) == ENGINE_RESULT_OK, "pause"); + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + auto paused = Frame(runtime).frame_serial; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + Require(Frame(runtime).frame_serial == paused, + "VM continues while paused"); + auto original = FILEIO::open_save(1, FILEIO_READ_BINARY); + Require(original && + original->read_string(13) == + (i ? "isolated save" : "original save"), + "imported/isolated save lookup"); + original.reset(); + auto output = FILEIO::open_save(1, FILEIO_WRITE_BINARY); + Require(output && output->write("isolated save", 13), + "isolated save write"); + output.reset(); + Require(fs::file_size(root / "game/ASLEEP_A.DAT") == 13, + "original save changed"); + std::ifstream imported(root / "game/ASLEEP_A.DAT"); + std::string text; + std::getline(imported, text); + Require(text == "original save", "original overwritten"); + Require(provider->resume(runtime) == ENGINE_RESULT_OK, "resume"); + Require(WaitForFrameAfter(runtime, paused), "VM did not resume"); + engine_input_event_t input{}; + input.struct_size = sizeof(input); + input.type = ENGINE_INPUT_EVENT_POINTER_DOWN; + input.x = 320; + input.y = 350; + Require(provider->send_input(runtime, &input) == ENGINE_RESULT_OK, + "pointer down"); + input.type = ENGINE_INPUT_EVENT_POINTER_UP; + Require(provider->send_input(runtime, &input) == ENGINE_RESULT_OK, + "pointer up"); + input.type = ENGINE_INPUT_EVENT_KEY_DOWN; + input.key_code = 13; + Require(provider->send_input(runtime, &input) == ENGINE_RESULT_OK, + "keyboard input"); + input.type = ENGINE_INPUT_EVENT_KEY_UP; + Require(provider->send_input(runtime, &input) == ENGINE_RESULT_OK, + "keyboard release"); + Require(fs::current_path() == cwd, "process cwd changed"); + } + Require(provider->create(nullptr, &host, &desc, &other) == + ENGINE_RESULT_OK, + "create second VM"); + Require(provider->open_game(other, game.c_str(), nullptr) == + ENGINE_RESULT_INVALID_STATE, + "concurrent VM accepted"); + provider->destroy(other); + other = nullptr; + provider->destroy(runtime); + runtime = nullptr; + Require(fs::current_path() == cwd, "cwd changed after teardown"); + fs::remove_all(root); + std::cout << "SYSTEM3_CONTRACT_OK\n"; + return 0; + } catch(const std::exception &error) { + if(other) + provider->destroy(other); + if(runtime) + provider->destroy(runtime); + fs::remove_all(root); + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/system3_native/contracts.cmake b/tests/system3_native/contracts.cmake new file mode 100644 index 00000000..4e70c4a1 --- /dev/null +++ b/tests/system3_native/contracts.cmake @@ -0,0 +1,41 @@ +find_package(SDL2 CONFIG REQUIRED) +get_filename_component(system3_test_root "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) +add_executable(aether_system3_native_contract "${CMAKE_CURRENT_LIST_DIR}/contract.cpp") +target_compile_features(aether_system3_native_contract PRIVATE cxx_std_17) +target_include_directories(aether_system3_native_contract PRIVATE + "${system3_test_root}/abi/include" "${AETHERKIRI_SYSTEM3_DIR}/src") +target_link_libraries(aether_system3_native_contract PRIVATE aether_system3_runtime) +target_compile_definitions(aether_system3_native_contract PRIVATE + SYSTEM3_EMBEDDED + SYSTEM3_TEST_FONT="${AETHERKIRI_SYSTEM3_DIR}/resources/fonts/MTLc3m.ttf") +add_test(NAME system3-native-contract COMMAND aether_system3_native_contract) +set_tests_properties(system3-native-contract PROPERTIES TIMEOUT 30 ENVIRONMENT "SDL_AUDIODRIVER=dummy") + +add_executable(aether_system3_encoding_contract "${CMAKE_CURRENT_LIST_DIR}/encoding.cpp") +target_compile_features(aether_system3_encoding_contract PRIVATE cxx_std_17) +target_include_directories(aether_system3_encoding_contract PRIVATE "${AETHERKIRI_SYSTEM3_DIR}/src") +target_compile_definitions(aether_system3_encoding_contract PRIVATE SYSTEM3_EMBEDDED) +target_link_libraries(aether_system3_encoding_contract PRIVATE aether_system3_core) +add_test(NAME system3-encoding-contract COMMAND aether_system3_encoding_contract) + +# Exercise upstream's compiled arithmetic/branch/stack fixtures through the +# embedded lifecycle for all four interpreter variants, without game assets. +add_executable(aether_system3_scenario_contract "${CMAKE_CURRENT_LIST_DIR}/scenario.cpp") +target_compile_features(aether_system3_scenario_contract PRIVATE cxx_std_17) +target_include_directories(aether_system3_scenario_contract PRIVATE "${AETHERKIRI_SYSTEM3_DIR}/src") +target_link_libraries(aether_system3_scenario_contract PRIVATE aether_system3_core + $,SDL2::SDL2,SDL2::SDL2-static>) +foreach(variant system1 system2 system3 gakuen_king) + if(variant STREQUAL "gakuen_king") + set(game_id gakuen_king) + else() + set(game_id "${variant}_generic") + endif() + add_test(NAME "system3-scenario-${variant}" COMMAND aether_system3_scenario_contract + "${AETHERKIRI_SYSTEM3_DIR}/test/scenario/${variant}" "${game_id}" + "${AETHERKIRI_SYSTEM3_DIR}/resources/fonts/MTLc3m.ttf") + set_tests_properties("system3-scenario-${variant}" PROPERTIES + TIMEOUT 10 ENVIRONMENT "SDL_AUDIODRIVER=dummy" + FAIL_REGULAR_EXPRESSION "\\[FAIL\\]" + PASS_REGULAR_EXPRESSION "(^|[^0-9])0 tests failed") +endforeach() diff --git a/tests/system3_native/encoding.cpp b/tests/system3_native/encoding.cpp new file mode 100644 index 00000000..eebe876a --- /dev/null +++ b/tests/system3_native/encoding.cpp @@ -0,0 +1,39 @@ +#include "embedded_symbols.h" +#include "encoding.h" +#include +#include + +static void Require(bool value) { + if(!value) + throw std::runtime_error("encoding contract failed"); +} +int main() { + auto unicode = Encoding::create("utf-8"); + std::string chinese = "中文优先,支持日本語。"; + Require(unicode->fromUtf8(chinese) == chinese && + unicode->toUtf8(chinese) == chinese); + std::string_view characters(chinese); + Require(unicode->next_codepoint(characters) == U'中'); + Require(unicode->mbslen(chinese) == 11); + std::string_view short_utf8("\xe4\xb8", 2); + Require(unicode->next_codepoint(short_utf8) == 0xfffd); + for(const std::string invalid : + { std::string("\xc0\x80", 2), std::string("\xed\xa0\x80", 3), + std::string("\xf4\x90\x80\x80", 4) }) { + std::string_view bad(invalid); + Require(unicode->next_codepoint(bad) == 0xfffd); + } + auto japanese = Encoding::create("CP932"); + const std::string text = "日本語、ランスとシィル。"; + const auto sjis = japanese->fromUtf8(text); + Require(japanese->toUtf8(sjis) == text); + std::string_view view(sjis); + Require(japanese->next_codepoint(view) == U'日'); + Require(japanese->mblen(0xb1) == 1 && japanese->mblen(0x93) == 2); + std::string_view truncated("\x93", 1); + Require(japanese->next_codepoint(truncated) == 0xfffd && truncated.empty()); + std::string_view invalid_sjis("\x80", 1); + Require(japanese->next_codepoint(invalid_sjis) == 0xfffd); + Require(japanese->mbslen(std::string("\x93", 1)) == 1); + std::cout << "SYSTEM3_ENCODING_OK\n"; +} diff --git a/tests/system3_native/probe.cpp b/tests/system3_native/probe.cpp new file mode 100644 index 00000000..acfa84d7 --- /dev/null +++ b/tests/system3_native/probe.cpp @@ -0,0 +1,205 @@ +#include "system3_runtime_provider.h" +#include "embedded.h" +#include +#include +#include +using Json = nlohmann::json; +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace fs = std::filesystem; +void *player; +const engine_runtime_provider_v1_t *provider; +extern "C" engine_result_t +engine_register_runtime_provider(const engine_runtime_provider_v1_t *value) { + provider = value; + return ENGINE_RESULT_OK; +} +fs::path game, output; +std::string font; +uint64_t sequence; +void Check(engine_result_t result) { + if(result == ENGINE_RESULT_OK) + return; + const char *text = provider ? provider->get_last_error(player) + : "No runtime provider registered"; + throw std::runtime_error(std::string(text ? text : "") + + " (result=" + std::to_string(result) + ")"); +} +void Open() { + const std::string writable = (output / "saves").string(), + cache = (output / "cache").string(); + fs::create_directories(writable); + fs::create_directories(cache); + engine_create_desc_t desc{}; + desc.struct_size = sizeof(desc); + desc.api_version = ENGINE_API_VERSION; + desc.writable_path_utf8 = writable.c_str(); + desc.cache_path_utf8 = cache.c_str(); + engine_runtime_host_v1_t host{}; + host.struct_size = sizeof(host); + host.api_version = ENGINE_RUNTIME_PROVIDER_API_VERSION; + host.log = [](void *, uint32_t, const char *, const char *message) { + std::cerr << message << '\n'; + }; + Check(provider->create(nullptr, &host, &desc, &player)); + engine_option_t f{}; + f.key_utf8 = "default_font"; + f.value_utf8 = font.c_str(); + Check(provider->set_option(player, &f)); + engine_option_t hook{}; + hook.key_utf8 = "system3_texthook"; + hook.value_utf8 = "print"; + Check(provider->set_option(player, &hook)); + Check(provider->open_game(player, game.string().c_str(), nullptr)); +} +void Atomic(const fs::path &file, const std::string &value) { + auto temporary = file; + temporary += ".tmp"; + { + std::ofstream stream(temporary); + stream << value; + } + fs::rename(temporary, file); +} +void Status(const char *error = nullptr) { + engine_frame_desc_t frame{}; + frame.struct_size = sizeof(frame); + provider->get_frame_desc(player, &frame); + Json json; + json["ack"] = double(sequence); + json["width"] = frame.width; + json["height"] = frame.height; + json["serial"] = double(frame.frame_serial); + json["error"] = error ? error : ""; + char debug[4096]{}; + uint32_t written = 0; + provider->get_plugin_debug_info(player, debug, sizeof(debug), &written); + json["runtime"] = debug; + json["audio_peak"] = system3_host_audio_peak(); + Atomic(output / "status.json", json.dump()); +} +void Capture(const std::string &name) { + if(name.empty() || + name.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUV" + "WXYZ0123456789-_") != std::string::npos) + throw std::runtime_error("Invalid capture name"); + engine_frame_desc_t frame{}; + frame.struct_size = sizeof(frame); + Check(provider->get_frame_desc(player, &frame)); + std::vector pixels(size_t(frame.stride_bytes) * frame.height); + Check(provider->read_frame_rgba(player, pixels.data(), pixels.size())); + SDL_Surface *surface = SDL_CreateRGBSurfaceWithFormatFrom( + pixels.data(), frame.width, frame.height, 32, frame.stride_bytes, + SDL_PIXELFORMAT_RGBA32); + if(!surface) + throw std::runtime_error(SDL_GetError()); + const int result = + IMG_SavePNG(surface, (output / (name + ".png")).string().c_str()); + SDL_FreeSurface(surface); + if(result) + throw std::runtime_error(IMG_GetError()); +} +double Number(const Json &json, const char *key, double fallback = 0) { + return json.contains(key) && json[key].is_number() ? json[key].get() + : fallback; +} +std::string String(const Json &json, const char *key, + const char *fallback = "") { + return json.contains(key) && json[key].is_string() + ? json[key].get() + : fallback; +} +bool Command(const Json &json) { + const std::string action = String(json, "action"); + if(action == "quit") + return false; + if(action == "capture") + Capture(String(json, "name", "frame")); + else if(action == "pause") + Check(provider->pause(player)); + else if(action == "resume") + Check(provider->resume(player)); + else if(action == "reopen") { + provider->destroy(player); + player = nullptr; + Open(); + } else if(action == "click" || action == "key") { + engine_input_event_t event{}; + event.struct_size = sizeof(event); + event.x = Number(json, "x"); + event.y = Number(json, "y"); + event.button = int(Number(json, "button", 0)); + event.key_code = int(Number(json, "key", 13)); + const int count = + std::max(1, std::min(50, int(Number(json, "count", 1)))); + for(int i = 0; i < count; i++) { + event.type = action == "click" ? ENGINE_INPUT_EVENT_POINTER_DOWN + : ENGINE_INPUT_EVENT_KEY_DOWN; + Check(provider->send_input(player, &event)); + std::this_thread::sleep_for(std::chrono::milliseconds( + std::clamp(int(Number(json, "hold_ms", 100)), 0, 10000))); + event.type = action == "click" ? ENGINE_INPUT_EVENT_POINTER_UP + : ENGINE_INPUT_EVENT_KEY_UP; + Check(provider->send_input(player, &event)); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + } + return true; +} +int main(int argc, char **argv) { + if(argc != 4) { + std::cerr << "Usage: probe GAME OUTPUT FONT\n"; + return 2; + } + game = fs::absolute(argv[1]); + output = fs::absolute(argv[2]); + font = fs::absolute(argv[3]).string(); + fs::create_directories(output); + try { + Check(aetherkiri_system3_register_runtime_provider()); + Open(); + std::cout << "SYSTEM3_PROBE_READY\n" << std::flush; + Capture("startup"); + Status(); + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::minutes(20); + while(std::chrono::steady_clock::now() < deadline) { + auto command = output / "command.json"; + if(fs::is_regular_file(command)) { + std::ifstream file(command); + std::stringstream text; + text << file.rdbuf(); + Json json = Json::parse(text.str(), nullptr, false); + if(!json.is_discarded()) { + const uint64_t seq = uint64_t(Number(json, "seq")); + if(seq > sequence) { + sequence = seq; + const bool keep_running = Command(json); + Status(); + if(!keep_running) + break; + } + } + } + Check(provider->tick(player, 16)); + std::this_thread::sleep_for(std::chrono::milliseconds(16)); + } + provider->destroy(player); + player = nullptr; + std::cout << "SYSTEM3_PROBE_OK\n"; + return 0; + } catch(const std::exception &error) { + Status(error.what()); + if(player) + provider->destroy(player); + std::cerr << "SYSTEM3_PROBE_FAILED: " << error.what() << '\n'; + return 1; + } +} diff --git a/tests/system3_native/scenario.cpp b/tests/system3_native/scenario.cpp new file mode 100644 index 00000000..823bddfe --- /dev/null +++ b/tests/system3_native/scenario.cpp @@ -0,0 +1,45 @@ +#include "embedded.h" +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + if(argc != 4) + return 2; + namespace fs = std::filesystem; + const auto prefix = fs::temp_directory_path() / + ("aether-system3-scenario-" + std::string(argv[2]) + "-" + + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + // Parallel processes can observe the same clock tick. Reserve the fixture + // directory atomically so they cannot overwrite each other's game files. + fs::path root; + for(unsigned attempt = 0;; ++attempt) { + root = prefix.string() + "-" + std::to_string(attempt); + if(fs::create_directory(root)) + break; + } + fs::create_directories(root / "game"); + fs::create_directories(root / "saves"); + std::cerr << "Scenario fixture: " << root << '\n'; + for(const auto &file : fs::directory_iterator(argv[1])) + if(file.path().extension() == ".DAT") + fs::copy_file(file.path(), root / "game" / file.path().filename()); + std::ofstream(root / "game/system3.ini") + << "[config]\ngame=" << argv[2] << "\nencoding=utf8\n"; + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + system3_host host{}; + host.user = &deadline; + host.checkpoint = [](void *p) { + return int(std::chrono::steady_clock::now() > + *static_cast(p)); + }; + host.log = [](void *, int, const char *text) { std::cerr << text << '\n'; }; + const int result = + system3_host_run(&host, (root / "game").c_str(), + (root / "saves").c_str(), argv[3], "auto", true); + fs::remove_all(root); + return result; +} diff --git a/tests/unit-tests/mages_runtime/provider.cpp b/tests/unit-tests/mages_runtime/provider.cpp index ee29477d..2f583a9d 100644 --- a/tests/unit-tests/mages_runtime/provider.cpp +++ b/tests/unit-tests/mages_runtime/provider.cpp @@ -12,11 +12,24 @@ namespace fs = std::filesystem; namespace { struct TempDir { - fs::path path = fs::temp_directory_path() / ("mages-test-" + std::to_string( - std::chrono::steady_clock::now().time_since_epoch().count())); - TempDir() { fs::create_directories(path); } - ~TempDir() { std::error_code ec; fs::remove_all(path, ec); } - void touch(const fs::path& file) { + fs::path path; + TempDir() { + const auto prefix = fs::temp_directory_path() / + ("mages-test-" + + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + for(unsigned attempt = 0;; ++attempt) { + path = prefix; + path += "-" + std::to_string(attempt); + if(fs::create_directory(path)) + break; + } + } + ~TempDir() { + std::error_code ec; + fs::remove_all(path, ec); + } + void touch(const fs::path &file) { fs::create_directories((path / file).parent_path()); std::ofstream(path / file) << "marker"; } @@ -93,8 +106,10 @@ TEST_CASE("MAGES. probing and archive resolution share supported layouts") { check(root.path,"mo8"); check(root.path/"MemoriesoffIF","mo8"); Impacto::Host::ArchiveRoot = root.path/"MemoriesoffIF"; Impacto::Host::ProfileArchiveRoot = root.path; - CHECK(Impacto::Host::ResolveArchivePath((root.path/"script.mpk").string()) == - (root.path/"MemoriesoffIF/SCRIPT.MPK").string()); + const auto resolved = Impacto::Host::ResolveArchivePath( + (root.path / "script.mpk").string()); + REQUIRE(fs::is_regular_file(resolved)); + CHECK(fs::equivalent(resolved, root.path / "MemoriesoffIF/SCRIPT.MPK")); Impacto::Host::ArchiveRoot.clear(); Impacto::Host::ProfileArchiveRoot.clear(); } SECTION("all registered named layouts") { diff --git a/tests/xsystem35_native/CMakeLists.txt b/tests/xsystem35_native/CMakeLists.txt new file mode 100644 index 00000000..a8d13602 --- /dev/null +++ b/tests/xsystem35_native/CMakeLists.txt @@ -0,0 +1,65 @@ +cmake_minimum_required(VERSION 3.28) +project(AetherXsystem35NativeProbe LANGUAGES C CXX) +get_filename_component(AETHER_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) +set(AETHERKIRI_XSYSTEM35_DIR "${AETHER_ROOT}/packages/xsystem35") +add_subdirectory("${AETHER_ROOT}/bridge/xsystem35_runtime" "${CMAKE_CURRENT_BINARY_DIR}/runtime") +find_package(SDL2_image CONFIG REQUIRED) +find_package(nlohmann_json CONFIG REQUIRED) +add_executable(aether_xsystem35_native_probe probe.cpp) +target_compile_features(aether_xsystem35_native_probe PRIVATE cxx_std_17) +target_include_directories(aether_xsystem35_native_probe PRIVATE "${AETHER_ROOT}/abi/include" "${AETHERKIRI_XSYSTEM35_DIR}/src") +target_link_libraries(aether_xsystem35_native_probe PRIVATE aether_xsystem35_runtime nlohmann_json::nlohmann_json + $,SDL2_image::SDL2_image,SDL2_image::SDL2_image-static>) +enable_testing() + +if(TARGET aether_xsystem35_runtime) + add_executable(aether_xsystem35_native_contract contract.cpp) + target_compile_features(aether_xsystem35_native_contract PRIVATE cxx_std_17) + target_include_directories(aether_xsystem35_native_contract PRIVATE "${AETHER_ROOT}/abi/include") + target_link_libraries(aether_xsystem35_native_contract PRIVATE aether_xsystem35_runtime) + add_test(NAME xsystem35-native-contract COMMAND aether_xsystem35_native_contract) + set_tests_properties(xsystem35-native-contract PROPERTIES TIMEOUT 30) +endif() + +if(TARGET aether_xsystem35_core) + get_target_property(xsystem35_binary_dir aether_xsystem35_core BINARY_DIR) + add_executable(aether_xsystem35_encoding_contract encoding.c) + target_include_directories(aether_xsystem35_encoding_contract PRIVATE + "${AETHERKIRI_XSYSTEM35_DIR}/src" "${xsystem35_binary_dir}/generated") + target_link_libraries(aether_xsystem35_encoding_contract PRIVATE aether_xsystem35_core) + add_test(NAME xsystem35-encoding-contract COMMAND aether_xsystem35_encoding_contract) + + # Compile the real dispatcher with embedded Android selection even on a + # desktop host. Missing standalone JNI backends must fail at link time. + add_executable(aether_xsystem35_embedded_midi_contract midi.c + "${AETHERKIRI_XSYSTEM35_DIR}/src/midi.c") + target_compile_definitions(aether_xsystem35_embedded_midi_contract PRIVATE + XSYSTEM35_EMBEDDED __ANDROID__) + target_include_directories(aether_xsystem35_embedded_midi_contract PRIVATE + "${AETHERKIRI_XSYSTEM35_DIR}/src" "${xsystem35_binary_dir}/generated") + add_test(NAME xsystem35-embedded-midi-contract + COMMAND aether_xsystem35_embedded_midi_contract) +endif() + +if(TARGET aether_xsystem35_core) + find_package(SDL2 CONFIG REQUIRED) + add_executable(aether_xsystem35_mosaic_contract mosaic.c + "${AETHERKIRI_XSYSTEM35_DIR}/src/render_mosaic.c") + target_include_directories(aether_xsystem35_mosaic_contract PRIVATE + "${AETHERKIRI_XSYSTEM35_DIR}/src" "${xsystem35_binary_dir}/generated") + target_link_libraries(aether_xsystem35_mosaic_contract PRIVATE + $,SDL2::SDL2,SDL2::SDL2-static>) + if(UNIX AND NOT APPLE) + target_link_libraries(aether_xsystem35_mosaic_contract PRIVATE m) + endif() + add_test(NAME xsystem35-mosaic-contract COMMAND aether_xsystem35_mosaic_contract) +endif() + +if(TARGET aether_xsystem35_core) + find_package(SDL2_mixer CONFIG REQUIRED) + add_executable(aether_xsystem35_audio_contract audio.c) + target_link_libraries(aether_xsystem35_audio_contract PRIVATE + $,SDL2::SDL2,SDL2::SDL2-static> + $,SDL2_mixer::SDL2_mixer,SDL2_mixer::SDL2_mixer-static>) + add_test(NAME xsystem35-audio-contract COMMAND aether_xsystem35_audio_contract) +endif() diff --git a/tests/xsystem35_native/audio.c b/tests/xsystem35_native/audio.c new file mode 100644 index 00000000..77c5aa1e --- /dev/null +++ b/tests/xsystem35_native/audio.c @@ -0,0 +1,56 @@ +#include +#include +#include +#include + +static SDL_atomic_t music_finished; + +static void MusicFinished(void) { SDL_AtomicSet(&music_finished, 1); } + +int main(void) { + SDL_setenv("SDL_AUDIODRIVER", "dummy", 1); + const int requested = MIX_INIT_MP3 | MIX_INIT_OGG; + if((Mix_Init(requested) & requested) != requested) { + fprintf(stderr, "System35 needs MP3 and Ogg decoders: %s\n", + Mix_GetError()); + return 1; + } + if(Mix_OpenAudio(44100, AUDIO_S16SYS, 2, 512) != 0) { + fprintf(stderr, "Cannot open dummy audio device: %s\n", Mix_GetError()); + return 2; + } + // Four synthetic silent MPEG1 layer III frames, 128 kbps / 44.1 kHz. + // Exercise the real decoder and mixer without bundling a game audio file. + Uint8 mp3[417 * 4] = { 0 }; + for(int frame = 0; frame < 4; frame++) { + Uint8 *header = mp3 + 417 * frame; + header[0] = 0xff; + header[1] = 0xfb; + header[2] = 0x90; + header[3] = 0x00; + } + Mix_HookMusicFinished(MusicFinished); + Mix_Music *music = Mix_LoadMUS_RW(SDL_RWFromConstMem(mp3, sizeof(mp3)), 1); + if(!music || Mix_PlayMusic(music, 0) != 0) { + fprintf(stderr, "Cannot decode synthetic MP3: %s\n", Mix_GetError()); + return 3; + } + // Observe after the short clip normally ends, as can happen after a + // scheduler stall. Completion must survive a late observer. + SDL_Delay(200); + const Uint32 started = SDL_GetTicks(); + while(!SDL_AtomicGet(&music_finished) && SDL_GetTicks() - started < 2000) + SDL_Delay(5); + if(!SDL_AtomicGet(&music_finished)) { + fprintf(stderr, "MP3 playback did not finish: %s\n", Mix_GetError()); + return 4; + } + Mix_HookMusicFinished(NULL); + Mix_HaltMusic(); + Mix_FreeMusic(music); + Mix_CloseAudio(); + Mix_Quit(); + SDL_QuitSubSystem(SDL_INIT_AUDIO); + puts("XSYSTEM35_AUDIO_OK"); + return 0; +} diff --git a/tests/xsystem35_native/contract.cpp b/tests/xsystem35_native/contract.cpp new file mode 100644 index 00000000..ea5f2f86 --- /dev/null +++ b/tests/xsystem35_native/contract.cpp @@ -0,0 +1,205 @@ +#include "xsystem35_runtime_provider.h" +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +static const engine_runtime_provider_v1_t *provider; +extern "C" engine_result_t +engine_register_runtime_provider(const engine_runtime_provider_v1_t *value) { + provider = value; + return ENGINE_RESULT_OK; +} +static void Require(bool condition, const char *message) { + if(!condition) + throw std::runtime_error(message); +} +static void MinimalGame(const fs::path &file) { + // One synthetic ALD scenario page: Y 3,1 (wait), then jump back. + // No game code, images, fonts or other copyrighted assets are required. + std::vector bytes(1024); + bytes[0] = 1; + bytes[3] = 2; + bytes[6] = 2; + bytes[256] = 1; + bytes[257] = 2; + // Reallocate an RGB world on every boot, as Rance5D does. Reopening must + // not inspect the previous session's freed DIB/alpha plane. + bytes[512] = 16; + bytes[516] = 38; + const uint8_t page[] = { 'S', '3', '5', '0', 16, 0, 0, 0, + 38, 0, 0, 0, 0, 0, 0, 0, + 'W', 'W', 0x02, 0x80, 0x7f, 0x01, 0xe0, 0x7f, + 0x58, 0x7f, 'Y', 0x43, 0x7f, 0x41, 0x7f, '@', + 26, 0, 0, 0 }; + bytes[516] = sizeof(page); + std::copy(std::begin(page), std::end(page), bytes.begin() + 528); + std::ofstream stream(file, std::ios::binary); + stream.write(reinterpret_cast(bytes.data()), bytes.size()); +} +static void MessageAIN(const fs::path &file, const std::string &text) { + std::vector bytes(32); + bytes[0] = 'A'; + bytes[1] = 'I'; + bytes[2] = 'N'; + bytes[3] = 'I'; + bytes[8] = 'H'; + bytes[9] = 'E'; + bytes[10] = 'L'; + bytes[11] = '0'; + bytes[20] = 'M'; + bytes[21] = 'S'; + bytes[22] = 'G'; + bytes[23] = 'I'; + bytes[28] = 16; + for(int i = 0; i < 16; i++) { + bytes.insert(bytes.end(), text.begin(), text.end()); + bytes.push_back(0); + } + for(size_t i = 4; i < bytes.size(); i++) + bytes[i] = uint8_t((bytes[i] >> 2) | (bytes[i] << 6)); + std::ofstream stream(file, std::ios::binary); + stream.write(reinterpret_cast(bytes.data()), bytes.size()); +} +int main() { + const auto cwd = fs::current_path(); + auto root = + fs::temp_directory_path() / + fs::u8path( + "aether-system35-中文-" + + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + void *runtime = nullptr, *other = nullptr; + try { + Require(aetherkiri_xsystem35_register_runtime_provider() == + ENGINE_RESULT_OK, + "registration"); + Require(provider && + std::string(provider->runtime_id_utf8) == "xsystem35", + "runtime ID"); + fs::create_directories(root / "game"); + fs::create_directories(root / "saves"); + const auto game = (root / "game").u8string(), + saves = (root / "saves").u8string(); + Require(provider->probe(nullptr, game.c_str()) == 0, + "empty directory matched"); + std::ofstream(root / "game/ADISK.dat") << "old system3"; + std::ofstream(root / "game/ACG.dat") << "old system3"; + Require(provider->probe(nullptr, game.c_str()) == 0, + "legacy DAT game matched System35"); + const auto scenario = root / "game/MixedSa.AlD"; + std::ofstream(scenario) << "invalid ALD"; + Require(provider->probe(nullptr, game.c_str()) > 0, + "mixed-case ALD not matched"); + std::ofstream(root / "game/ALICESTART.INI") << "System4"; + Require(provider->probe(nullptr, game.c_str()) == 0, + "System4 took System35 route"); + fs::remove(root / "game/ALICESTART.INI"); + engine_runtime_host_v1_t host{}; + host.struct_size = sizeof(host); + host.api_version = ENGINE_RUNTIME_PROVIDER_API_VERSION; + engine_create_desc_t desc{}; + desc.struct_size = sizeof(desc); + desc.api_version = ENGINE_API_VERSION; + desc.writable_path_utf8 = saves.c_str(); + Require(provider->create(nullptr, &host, &desc, &runtime) == + ENGINE_RESULT_OK, + "create"); + for(int i = 0; i < 3; i++) { + Require(provider->open_game(runtime, game.c_str(), nullptr) != + ENGINE_RESULT_OK, + "malformed archive opened"); + Require(std::string(provider->get_last_error(runtime)) + .find("not an ALD") != std::string::npos, + "fatal archive error not contained"); + } + MinimalGame(scenario); + for(int i = 0; i < 3; i++) { + const auto result = + provider->open_game(runtime, game.c_str(), nullptr); + Require(result == ENGINE_RESULT_OK, + provider->get_last_error(runtime)); + std::this_thread::sleep_for(std::chrono::milliseconds(60)); + Require(provider->tick(runtime, 16) == ENGINE_RESULT_OK, + provider->get_last_error(runtime)); + Require(fs::current_path() == cwd, "game changed process cwd"); + engine_frame_desc_t frame{}; + frame.struct_size = sizeof(frame); + Require(provider->get_frame_desc(runtime, &frame) == + ENGINE_RESULT_OK, + "frame description"); + Require(frame.width == 640 && frame.height == 480, + "wrong logical size"); + std::vector rgba(size_t(frame.stride_bytes) * + frame.height); + Require(provider->read_frame_rgba(runtime, rgba.data(), + rgba.size() - 1) == + ENGINE_RESULT_INVALID_ARGUMENT, + "short frame buffer accepted"); + Require(provider->read_frame_rgba(runtime, rgba.data(), + rgba.size()) == ENGINE_RESULT_OK, + "frame copy"); + Require(provider->pause(runtime) == ENGINE_RESULT_OK, "pause"); + Require(provider->resume(runtime) == ENGINE_RESULT_OK, "resume"); + } + Require(provider->create(nullptr, &host, &desc, &other) == + ENGINE_RESULT_OK, + "second handle create"); + Require(provider->open_game(other, game.c_str(), nullptr) == + ENGINE_RESULT_INVALID_STATE, + "two VMs allowed concurrently"); + provider->destroy(other); + other = nullptr; + const auto check_encoding = [&](const std::string &text, + const char *expected, + const char *option = "auto") { + MessageAIN(root / "game/System39.ain", text); + engine_option_t encoding{}; + encoding.key_utf8 = "xsystem35_encoding"; + encoding.value_utf8 = option; + Require(provider->set_option(runtime, &encoding) == + ENGINE_RESULT_OK, + "encoding option"); + Require(provider->open_game(runtime, game.c_str(), nullptr) == + ENGINE_RESULT_OK, + provider->get_last_error(runtime)); + char debug[4096]{}; + uint32_t written = 0; + Require(provider->get_plugin_debug_info(runtime, debug, + sizeof(debug), &written) == + ENGINE_RESULT_OK, + "debug encoding"); + Require(std::string(debug).find(expected) != std::string::npos, + "wrong auto/explicit legacy encoding"); + }; + check_encoding("\xd6\xd0\xce\xc4\xb2\xe2\xca\xd4\xd3\xce\xcf\xb7\xb5" + "\xc4\xba\xba\xbb\xaf\xb0\xe6\xb1\xbe", + "encoding=gbk"); + const std::string japanese = + "\x83\x65\x83\x58\x83\x67\x83\x51\x81\x5b\x83\x80\x93\xfa\x96\x7b" + "\x8c\xea\x82\xcc\x95\xb6\x8e\x9a"; + check_encoding(japanese, "encoding=sjis"); + check_encoding("中文 Unicode 测试游戏", "encoding=sjis"); + check_encoding(japanese, "encoding=gbk", "gbk"); + provider->destroy(runtime); + runtime = nullptr; + Require(fs::current_path() == cwd, "close changed process cwd"); + Require(!fs::exists(root / "game/Volume.sav"), + "wrote settings into game assets"); + fs::remove_all(root); + std::cout << "XSYSTEM35_CONTRACT_OK\n"; + return 0; + } catch(const std::exception &error) { + if(other) + provider->destroy(other); + if(runtime) + provider->destroy(runtime); + fs::remove_all(root); + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/xsystem35_native/encoding.c b/tests/xsystem35_native/encoding.c new file mode 100644 index 00000000..165c0f93 --- /dev/null +++ b/tests/xsystem35_native/encoding.c @@ -0,0 +1,43 @@ +#include "utfsjis.h" +#include +#include +#include + +#define REQUIRE(expr) \ + do { \ + if(!(expr)) { \ + fprintf(stderr, "Failed: %s\n", #expr); \ + return 1; \ + } \ + } while(0) + +int main(void) { + const char *gbk = "\xd6\xd0\xce\xc4\xb2\xe2\xca\xd4"; + char *utf8 = codeconv(UTF8, GBK, gbk); + REQUIRE(utf8 && !strcmp(utf8, "中文测试")); + char *back = codeconv(GBK, UTF8, utf8); + REQUIRE(back && !strcmp(back, gbk)); + free(utf8); + free(back); + + const char *sjis = "\x83\x65\x83\x58\x83\x67"; + utf8 = codeconv(UTF8, SHIFT_JIS, sjis); + REQUIRE(utf8 && !strcmp(utf8, "テスト")); + free(utf8); + const char *trail_opcode = "\x81\x7e@"; + REQUIRE(advance_char(trail_opcode, GBK) == trail_opcode + 2); + REQUIRE(*advance_char(trail_opcode, GBK) == '@'); + const char *halfwidth_range = "\xa3\xe5@"; + REQUIRE(advance_char(halfwidth_range, GBK) == halfwidth_range + 2); + REQUIRE(advance_char(halfwidth_range, SHIFT_JIS) == halfwidth_range + 1); + const char *truncated = "\x81"; + REQUIRE(advance_char(truncated, GBK) == truncated + 1); + utf8 = codeconv(UTF8, GBK, truncated); + REQUIRE(utf8 && !strcmp(utf8, "?")); + free(utf8); + const char *unicode = "中文"; + REQUIRE(advance_char(unicode, UTF8) == unicode + 3); + REQUIRE(MAX_BYTES_PAR_CHAR(GBK) == 2); + puts("XSYSTEM35_ENCODING_OK"); + return 0; +} diff --git a/tests/xsystem35_native/midi.c b/tests/xsystem35_native/midi.c new file mode 100644 index 00000000..119babfe --- /dev/null +++ b/tests/xsystem35_native/midi.c @@ -0,0 +1,41 @@ +#include "midi.h" +#include + +static int init_calls; +static int reset_calls; +static bool available = true; + +static bool Init(int device) { + ++init_calls; + return available && device == 0; +} + +static void Reset(void) { ++reset_calls; } + +// Deliberately provide no midi_android. The embedded dispatcher must select +// SDL_mixer without depending on the standalone application's JNI activity. +mididevice_t midi_sdlmixer = { .init = Init, .reset = Reset }; + +int main(void) { + mididevice_t device = { 0 }; + if(!midi_init(&device) || init_calls != 1 || device.reset != Reset) { + fprintf(stderr, "Embedded Android did not select SDL_mixer MIDI\n"); + return 1; + } + device.reset(); + if(reset_calls != 1) + return 2; + + available = false; + if(midi_init(&device) || init_calls != 2) { + fprintf(stderr, "MIDI initialization failure was not propagated\n"); + return 3; + } + + midi_set_output_device('0', 0); + if(midi_init(&device) || init_calls != 2) { + fprintf(stderr, "Disabled MIDI initialized a backend\n"); + return 4; + } + return 0; +} diff --git a/tests/xsystem35_native/mosaic.c b/tests/xsystem35_native/mosaic.c new file mode 100644 index 00000000..679fe0cb --- /dev/null +++ b/tests/xsystem35_native/mosaic.c @@ -0,0 +1,52 @@ +#include "render_mosaic.h" +#include +#include + +int main(void) { + SDL_Surface *surface = sdl_CreateSurface(640, 480, SDL_PIXELFORMAT_RGBA32); + SDL_Renderer *renderer = SDL_CreateSoftwareRenderer(surface); + SDL_Texture *source = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_XRGB8888, + SDL_TEXTUREACCESS_TARGET, 640, 480); + SDL_Texture *temporary = SDL_CreateTexture( + renderer, SDL_PIXELFORMAT_XRGB8888, SDL_TEXTUREACCESS_TARGET, 640, 480); + SDL_Texture *destination = SDL_CreateTexture( + renderer, SDL_PIXELFORMAT_XRGB8888, SDL_TEXTUREACCESS_TARGET, 640, 480); + if(!renderer || !source || !temporary || !destination) + return 1; + SDL_SetRenderTarget(renderer, source); + SDL_SetRenderDrawColor(renderer, 240, 20, 10, 255); + SDL_RenderClear(renderer); + SDL_SetRenderDrawColor(renderer, 10, 40, 230, 255); + SDL_Rect right = { 320, 0, 320, 480 }; + sdl_RenderFillRect(renderer, &right); + SDL_SetRenderTarget(renderer, NULL); + SDL_Rect full = { 0, 0, 640, 480 }; + const float scales[] = { 1.f, 2.f, 47.5f, 80.f, 96.f, 1.f }; + for(size_t i = 0; i < sizeof(scales) / sizeof(scales[0]); i++) { + if(!render_mosaic(renderer, source, &full, temporary, destination, 640, + 480, scales[i])) + return 2; + sdl_RenderTexture(renderer, destination, NULL, &full); + SDL_RenderPresent(renderer); + Uint8 r, g, b, a; + Uint32 *row = + (Uint32 *)((Uint8 *)surface->pixels + 240 * surface->pitch); + SDL_GetRGBA(row[40], surface->format, &r, &g, &b, &a); + if(r < 200 || b > 50) { + fprintf(stderr, "Left color lost at scale %f\n", scales[i]); + return 3; + } + SDL_GetRGBA(row[600], surface->format, &r, &g, &b, &a); + if(b < 200 || r > 50) { + fprintf(stderr, "Right color lost at scale %f\n", scales[i]); + return 4; + } + } + SDL_DestroyTexture(source); + SDL_DestroyTexture(temporary); + SDL_DestroyTexture(destination); + SDL_DestroyRenderer(renderer); + SDL_FreeSurface(surface); + puts("XSYSTEM35_MOSAIC_OK"); + return 0; +} diff --git a/tests/xsystem35_native/probe.cpp b/tests/xsystem35_native/probe.cpp new file mode 100644 index 00000000..aedb4fc4 --- /dev/null +++ b/tests/xsystem35_native/probe.cpp @@ -0,0 +1,206 @@ +#include "xsystem35_runtime_provider.h" +extern "C" { +#include "texthook.h" +} +#include +#include +#include +#include +using Json = nlohmann::json; +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace fs = std::filesystem; +void *player; +const engine_runtime_provider_v1_t *provider; +extern "C" engine_result_t +engine_register_runtime_provider(const engine_runtime_provider_v1_t *value) { + provider = value; + return ENGINE_RESULT_OK; +} +fs::path game, output; +std::string font; +uint64_t sequence; +void Check(engine_result_t result) { + if(result == ENGINE_RESULT_OK) + return; + const char *text = provider ? provider->get_last_error(player) + : "No runtime provider registered"; + throw std::runtime_error(std::string(text ? text : "") + + " (result=" + std::to_string(result) + ")"); +} +void Open() { + const std::string writable = (output / "saves").string(), + cache = (output / "cache").string(); + fs::create_directories(writable); + fs::create_directories(cache); + engine_create_desc_t desc{}; + desc.struct_size = sizeof(desc); + desc.api_version = ENGINE_API_VERSION; + desc.writable_path_utf8 = writable.c_str(); + desc.cache_path_utf8 = cache.c_str(); + engine_runtime_host_v1_t host{}; + host.struct_size = sizeof(host); + host.api_version = ENGINE_RUNTIME_PROVIDER_API_VERSION; + host.log = [](void *, uint32_t, const char *, const char *message) { + std::cerr << message << '\n'; + }; + Check(provider->create(nullptr, &host, &desc, &player)); + engine_option_t f{}; + f.key_utf8 = "default_font"; + f.value_utf8 = font.c_str(); + Check(provider->set_option(player, &f)); + Check(provider->open_game(player, game.string().c_str(), nullptr)); +} +void Atomic(const fs::path &file, const std::string &value) { + auto temporary = file; + temporary += ".tmp"; + { + std::ofstream stream(temporary); + stream << value; + } + fs::rename(temporary, file); +} +void Status(const char *error = nullptr) { + engine_frame_desc_t frame{}; + frame.struct_size = sizeof(frame); + provider->get_frame_desc(player, &frame); + Json json; + json["ack"] = double(sequence); + json["width"] = frame.width; + json["height"] = frame.height; + json["serial"] = double(frame.frame_serial); + json["error"] = error ? error : ""; + char debug[4096]{}; + uint32_t written = 0; + provider->get_plugin_debug_info(player, debug, sizeof(debug), &written); + json["runtime"] = debug; + json["music_playing"] = Mix_PlayingMusic() != 0; + json["sound_channels"] = Mix_Playing(-1); + Atomic(output / "status.json", json.dump()); +} +void Capture(const std::string &name) { + if(name.empty() || + name.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUV" + "WXYZ0123456789-_") != std::string::npos) + throw std::runtime_error("Invalid capture name"); + engine_frame_desc_t frame{}; + frame.struct_size = sizeof(frame); + Check(provider->get_frame_desc(player, &frame)); + std::vector pixels(size_t(frame.stride_bytes) * frame.height); + Check(provider->read_frame_rgba(player, pixels.data(), pixels.size())); + SDL_Surface *surface = SDL_CreateRGBSurfaceWithFormatFrom( + pixels.data(), frame.width, frame.height, 32, frame.stride_bytes, + SDL_PIXELFORMAT_RGBA32); + if(!surface) + throw std::runtime_error(SDL_GetError()); + const int result = + IMG_SavePNG(surface, (output / (name + ".png")).string().c_str()); + SDL_FreeSurface(surface); + if(result) + throw std::runtime_error(IMG_GetError()); +} +double Number(const Json &json, const char *key, double fallback = 0) { + return json.contains(key) && json[key].is_number() ? json[key].get() + : fallback; +} +std::string String(const Json &json, const char *key, + const char *fallback = "") { + return json.contains(key) && json[key].is_string() + ? json[key].get() + : fallback; +} +bool Command(const Json &json) { + const std::string action = String(json, "action"); + if(action == "quit") + return false; + if(action == "capture") + Capture(String(json, "name", "frame")); + else if(action == "pause") + Check(provider->pause(player)); + else if(action == "resume") + Check(provider->resume(player)); + else if(action == "reopen") { + provider->destroy(player); + player = nullptr; + Open(); + } else if(action == "click" || action == "key") { + engine_input_event_t event{}; + event.struct_size = sizeof(event); + event.x = Number(json, "x"); + event.y = Number(json, "y"); + event.button = int(Number(json, "button", 0)); + event.key_code = int(Number(json, "key", 13)); + const int count = + std::max(1, std::min(50, int(Number(json, "count", 1)))); + for(int i = 0; i < count; i++) { + event.type = action == "click" ? ENGINE_INPUT_EVENT_POINTER_DOWN + : ENGINE_INPUT_EVENT_KEY_DOWN; + Check(provider->send_input(player, &event)); + std::this_thread::sleep_for(std::chrono::milliseconds( + std::clamp(int(Number(json, "hold_ms", 100)), 0, 10000))); + event.type = action == "click" ? ENGINE_INPUT_EVENT_POINTER_UP + : ENGINE_INPUT_EVENT_KEY_UP; + Check(provider->send_input(player, &event)); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + } + return true; +} +int main(int argc, char **argv) { + if(argc != 4) { + std::cerr << "Usage: probe GAME OUTPUT FONT\n"; + return 2; + } + game = fs::absolute(argv[1]); + output = fs::absolute(argv[2]); + font = fs::absolute(argv[3]).string(); + fs::create_directories(output); + try { + Check(aetherkiri_xsystem35_register_runtime_provider()); + texthook_set_mode(TEXTHOOK_PRINT); + Open(); + std::cout << "XSYSTEM35_PROBE_READY\n" << std::flush; + Capture("startup"); + Status(); + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::minutes(20); + while(std::chrono::steady_clock::now() < deadline) { + auto command = output / "command.json"; + if(fs::is_regular_file(command)) { + std::ifstream file(command); + std::stringstream text; + text << file.rdbuf(); + Json json = Json::parse(text.str(), nullptr, false); + if(!json.is_discarded()) { + const uint64_t seq = uint64_t(Number(json, "seq")); + if(seq > sequence) { + sequence = seq; + const bool keep_running = Command(json); + Status(); + if(!keep_running) + break; + } + } + } + Check(provider->tick(player, 16)); + std::this_thread::sleep_for(std::chrono::milliseconds(16)); + } + provider->destroy(player); + player = nullptr; + std::cout << "XSYSTEM35_PROBE_OK\n"; + return 0; + } catch(const std::exception &error) { + Status(error.what()); + if(player) + provider->destroy(player); + std::cerr << "XSYSTEM35_PROBE_FAILED: " << error.what() << '\n'; + return 1; + } +} diff --git a/tests/xsystem4_native/CMakeLists.txt b/tests/xsystem4_native/CMakeLists.txt index 8d19154c..ee1ac2b5 100644 --- a/tests/xsystem4_native/CMakeLists.txt +++ b/tests/xsystem4_native/CMakeLists.txt @@ -1,5 +1,6 @@ cmake_minimum_required(VERSION 3.28) project(AetherXsystem4NativeProbe LANGUAGES C CXX) +find_package(SDL2 CONFIG REQUIRED) get_filename_component(AETHER_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) set(VCS_TAG "aether-native") add_subdirectory("${AETHER_ROOT}/bridge/xsystem4_runtime" "${CMAKE_CURRENT_BINARY_DIR}/runtime") @@ -16,3 +17,41 @@ target_compile_features(aether_xsystem4_native_contract PRIVATE cxx_std_17) target_include_directories(aether_xsystem4_native_contract PRIVATE "${AETHER_ROOT}/abi/include") target_link_libraries(aether_xsystem4_native_contract PRIVATE aether_xsystem4_runtime) add_test(NAME xsystem4-native-contract COMMAND aether_xsystem4_native_contract) +add_executable(aether_xsystem4_scene_keeper_contract scene_keeper.c) +target_include_directories(aether_xsystem4_scene_keeper_contract PRIVATE + "${AETHER_ROOT}/packages/xsystem4/include" + "${AETHER_ROOT}/packages/xsystem4/src/hll" + "${AETHER_ROOT}/packages/xsystem4/subprojects/libsys4/include") +target_link_libraries(aether_xsystem4_scene_keeper_contract PRIVATE aether_xsystem4_runtime) +add_test(NAME xsystem4-scene-keeper-contract COMMAND aether_xsystem4_scene_keeper_contract) +add_executable(aether_xsystem4_afa_contract afa.cpp) +target_compile_features(aether_xsystem4_afa_contract PRIVATE cxx_std_17) +target_include_directories(aether_xsystem4_afa_contract PRIVATE + "${AETHER_ROOT}/packages/xsystem4/subprojects/libsys4/include") +target_link_libraries(aether_xsystem4_afa_contract PRIVATE aether_xsystem4_runtime) +add_test(NAME xsystem4-afa-contract COMMAND aether_xsystem4_afa_contract) +add_executable(aether_xsystem4_rance_quest_contract rance_quest.cpp) +target_compile_features(aether_xsystem4_rance_quest_contract PRIVATE cxx_std_17) +target_include_directories(aether_xsystem4_rance_quest_contract PRIVATE + "${AETHER_ROOT}/packages/xsystem4/include" + "${AETHER_ROOT}/packages/xsystem4/subprojects/libsys4/include") +target_link_libraries(aether_xsystem4_rance_quest_contract PRIVATE aether_xsystem4_runtime) +add_test(NAME xsystem4-rance-quest-contract COMMAND aether_xsystem4_rance_quest_contract) +add_executable(aether_xsystem4_tga_contract tga.cpp) +target_compile_features(aether_xsystem4_tga_contract PRIVATE cxx_std_17) +target_include_directories(aether_xsystem4_tga_contract PRIVATE + "${AETHER_ROOT}/packages/xsystem4/subprojects/libsys4/include") +target_link_libraries(aether_xsystem4_tga_contract PRIVATE aether_xsystem4_runtime + $,SDL2_image::SDL2_image,SDL2_image::SDL2_image-static>) +add_test(NAME xsystem4-tga-contract COMMAND aether_xsystem4_tga_contract) +add_executable(aether_xsystem4_font_fallback_contract font_fallback.c) +target_compile_definitions(aether_xsystem4_font_fallback_contract PRIVATE XSYSTEM4_EMBEDDED) +if(NOT APPLE) + target_compile_definitions(aether_xsystem4_font_fallback_contract PRIVATE USE_GLES) +endif() +target_include_directories(aether_xsystem4_font_fallback_contract PRIVATE + "${AETHER_ROOT}/packages/xsystem4/include" + "${AETHER_ROOT}/packages/xsystem4/subprojects/libsys4/include") +target_link_libraries(aether_xsystem4_font_fallback_contract PRIVATE aether_xsystem4_runtime + $,SDL2::SDL2,SDL2::SDL2-static>) +add_test(NAME xsystem4-font-fallback-contract COMMAND aether_xsystem4_font_fallback_contract) diff --git a/tests/xsystem4_native/afa.cpp b/tests/xsystem4_native/afa.cpp new file mode 100644 index 00000000..dab30ceb --- /dev/null +++ b/tests/xsystem4_native/afa.cpp @@ -0,0 +1,127 @@ +extern "C" { +#include "system4/afa.h" +#include "system4/archive.h" +#include "system4/zlib.h" +} +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using Bytes = std::vector; + +static void Require(bool condition, const char *message) { + if(!condition) + throw std::runtime_error(message); +} + +static void Put32(Bytes &bytes, size_t pos, uint32_t value) { + for(int i = 0; i < 4; ++i) + bytes[pos + i] = (value >> (8 * i)) & 0xff; +} + +static Bytes Fixture(bool payload_size, bool numbered) { + const std::string name = "image.qnt"; + const size_t fields = 8 + name.size() + 1; + Bytes table(fields + (numbered ? 20 : 16)); + Put32(table, 0, name.size()); + Put32(table, 4, name.size() + 1); + std::copy(name.begin(), name.end(), table.begin() + 8); + if(numbered) + Put32(table, fields, 1); + Put32(table, table.size() - 8, 8); + Put32(table, table.size() - 4, 4); + Bytes compressed(zlib_compress_bound(table.size())); + const size_t size = zlib_compress(compressed.data(), compressed.size(), + table.data(), table.size(), 6); + Require(size > 16, "Index compression failed"); + compressed.resize(size); + + Bytes bytes(4096 + 12); + std::copy_n("AFAH", 4, bytes.begin()); + Put32(bytes, 4, 28); + std::copy_n("AlicArch", 8, bytes.begin() + 8); + Put32(bytes, 16, numbered ? 1 : 2); + Put32(bytes, 20, 1); + Put32(bytes, 24, 4096); + std::copy_n("INFO", 4, bytes.begin() + 28); + Put32(bytes, 32, size + (payload_size ? 0 : 16)); + Put32(bytes, 36, table.size()); + Put32(bytes, 40, 1); + std::copy(compressed.begin(), compressed.end(), bytes.begin() + 44); + std::copy_n("DATA", 4, bytes.begin() + 4096); + Put32(bytes, 4100, 12); + std::copy_n("test", 4, bytes.begin() + 4104); + return bytes; +} + +int main() { + const fs::path directory = fs::temp_directory_path() / + ("aether-afa-contract-" + + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + try { + fs::create_directories(directory); + const fs::path file = directory / "archive.afa"; + const auto check = [&](const Bytes &bytes, bool expected, int flags) { + std::ofstream output(file, std::ios::binary); + output.write(reinterpret_cast(bytes.data()), + bytes.size()); + output.close(); + int error = ARCHIVE_SUCCESS; + struct afa_archive *archive = + afa_open(file.string().c_str(), flags, &error); + Require(bool(archive) == expected, + "Unexpected AFA validation result"); + if(!archive) { + Require(error != ARCHIVE_SUCCESS, + "Invalid AFA did not report an error"); + return; + } + struct archive_data *entry = + archive_get_by_name(&archive->ar, "image.qnt"); + const bool valid = entry && entry->size == 4 && + std::string(reinterpret_cast(entry->data), 4) == "test"; + if(entry) + archive_free_data(entry); + archive_free(&archive->ar); + Require(valid, "AFA entry payload changed"); + }; + for(int flags : { 0, int(ARCHIVE_MMAP) }) { + for(bool numbered : { false, true }) + for(bool payload_size : { false, true }) + check(Fixture(payload_size, numbered), true, flags); + Bytes corrupt = Fixture(true, false); + corrupt[50] ^= 0xff; + check(corrupt, false, flags); + corrupt = Fixture(true, false); + Put32(corrupt, 36, 1); // Decompressed size must match exactly. + check(corrupt, false, flags); + corrupt = Fixture(true, false); + Put32(corrupt, 32, 15); // Do not underflow INFO's header size. + check(corrupt, false, flags); + corrupt = Fixture(true, false); + Put32(corrupt, 32, 8192); // Index must not overlap DATA. + check(corrupt, false, flags); + corrupt = Fixture(true, false); + const uint32_t compressed = corrupt[32] | (corrupt[33] << 8); + Put32(corrupt, 32, compressed - 1); // Retry cannot mask truncation. + corrupt[44 + compressed - 1] = 0; + check(corrupt, false, flags); + corrupt.resize(60); + check(corrupt, false, flags); + } + fs::remove_all(directory); + std::cout << "XSYSTEM4_AFA_CONTRACT_OK\n"; + return 0; + } catch(const std::exception &error) { + fs::remove_all(directory); + std::cerr << "XSYSTEM4_AFA_CONTRACT_FAILED: " << error.what() << '\n'; + return 1; + } +} diff --git a/tests/xsystem4_native/contract.cpp b/tests/xsystem4_native/contract.cpp index f9e4928e..6a6ac146 100644 --- a/tests/xsystem4_native/contract.cpp +++ b/tests/xsystem4_native/contract.cpp @@ -80,6 +80,107 @@ int main() { error.find("Another System 4") == std::string::npos, "Failed open retained the VM ownership lock"); } + // Translation patches may leave comments in the original encoding. + // Resolve a non-ASCII script name using values, not comment text. + const std::string sjis_comment = + "\x82\xb1\x82\xcc\x83\x74\x83\x40\x83\x43\x83\x8b\x82\xcc\x8f\x89" + "\x8a\xfa\x90\xdd\x92\xe8\x82\xcd\x8f\x91\x82\xab\x8a\xb7\x82\xa6" + "\x82\xc8\x82\xa2\x82\xc5\x82\xad\x82\xbe\x82\xb3\x82\xa2\x81\x42"; + const std::string gbk_comment = + "\xc7\xeb\xb2\xbb\xd2\xaa\xd0\xde\xb8\xc4\xd5\xe2\xb8\xf6\xd3\xce" + "\xcf\xb7\xb5\xc4\xb3\xf5\xca\xbc\xbb\xaf\xc5\xe4\xd6\xc3\xce\xc4" + "\xbc\xfe\xa1\xa3"; + const char *gbk_title = + "\xd6\xd0\xce\xc4\xb2\xe2\xca\xd4\xd3\xce\xcf\xb7\x20\xba\xba\xbb" + "\xaf\xb0\xe6\xb1\xbe\x20\xb2\xe2\xca\xd4\xc6\xf4\xb6\xaf\xb6\xc1" + "\xc8\xa1\xce\xc4\xbc\xfe"; + const char *sjis_title = + "\x83\x65\x83\x58\x83\x67\x83\x51\x81\x5b\x83\x80\x20\x93\xfa\x96" + "\x7b\x8c\xea\x82\xcc\x93\xae\x8d\xec\x8a\x6d\x94\x46"; + const char *gbk_filename = + "\xa5\xc6\xa5\xb9\xa5\xc8\xa3\xb0\xa3\xb2\x2e\x61\x69\x6e"; + const char *sjis_filename = + "\x83\x65\x83\x58\x83\x67\x82\x4f\x82\x51\x2e\x61\x69\x6e"; + const auto check_encoding = [&](const char *folder, const char *title, + const char *filename, + const std::string &comment, + const char *expected, + const std::string &extra = "", + const char *option = "auto") { + const fs::path directory = root / folder; + fs::create_directories(directory); + std::ofstream(directory / fs::u8path(u8"テスト02.ain"), + std::ios::binary) + << "invalid script"; + std::ofstream config(directory / "AliceStart.ini", + std::ios::binary); + for(int i = 0; i < 8; i++) + config << " ; " << comment << "\r\n\t// " << comment << "\r\n"; + config << extra << "GameName = \"" << title << "\"\r\nCodeName = \"" + << filename << "\"\r\n"; + config.close(); + encoding.value_utf8 = option; + Require(provider->set_option(runtime, &encoding) == + ENGINE_RESULT_OK, + "Valid encoding option rejected"); + Require(provider->open_game(runtime, directory.string().c_str(), + nullptr) != ENGINE_RESULT_OK, + "Malformed encoded AIN must fail"); + const std::string error = provider->get_last_error(runtime); + Require( + error.find("Unrecognized or invalid AIN format") != + std::string::npos, + (std::string(folder) + ": wrong script path encoding").c_str()); + char info[4096]{}; + uint32_t written = 0; + Require(provider->get_plugin_debug_info(runtime, info, sizeof(info), + &written) == + ENGINE_RESULT_OK, + "Runtime debug info unavailable"); + Require(std::string(info).find(expected) != std::string::npos, + (std::string(folder) + ": wrong runtime text encoding") + .c_str()); + }; + check_encoding("gbk-values-sjis-comments", gbk_title, gbk_filename, + sjis_comment, "encoding=gbk"); + check_encoding("sjis-values-gbk-comments", sjis_title, sjis_filename, + gbk_comment, "encoding=sjis"); + check_encoding("pure-gbk", gbk_title, gbk_filename, gbk_comment, + "encoding=gbk"); + check_encoding("pure-sjis", sjis_title, sjis_filename, sjis_comment, + "encoding=sjis"); + check_encoding("ascii-title", "Contract", sjis_filename, "", + "encoding=sjis"); + check_encoding("short-chinese-title", + "\xd5\xbd\xb9\xfa\xc0\xbc\xcb\xb9", gbk_filename, "", + "encoding=gbk"); + check_encoding("short-chinese-rance", "\xc0\xbc\xcb\xb9\x30\x31", + gbk_filename, "", "encoding=gbk"); + check_encoding("short-japanese-kanji", "\x93\xfa\x96\x7b\x8c\xea", + sjis_filename, "", "encoding=sjis"); + check_encoding("short-japanese-halfwidth", "\xd7\xdd\xbd", + sjis_filename, "", "encoding=sjis"); + check_encoding("short-japanese-halfwidth-long", + "\xc4\xde\xd7\xba\xde\xdd", sjis_filename, "", + "encoding=sjis"); + std::string original_labels; + for(int i = 0; i < 8; i++) + original_labels += sjis_title; + check_encoding("chinese-title-priority", gbk_title, gbk_filename, + sjis_comment, "encoding=gbk", + "BootName = \"" + original_labels + "\"\r\n"); + check_encoding("comment-markers-in-title", + (std::string("; // ") + gbk_title).c_str(), gbk_filename, + sjis_comment, "encoding=gbk", + "BootName = \"" + original_labels + "\"\r\n"); + check_encoding("sjis-inline-gbk-comments", sjis_title, sjis_filename, + "", "encoding=sjis", + "ViewWidth = 800 ; " + gbk_comment + "\r\n" + + "ViewHeight = 600 // " + gbk_comment + "\r\n"); + check_encoding("explicit-sjis", gbk_title, sjis_filename, gbk_comment, + "encoding=sjis", "", "sjis"); + check_encoding("explicit-gbk", sjis_title, gbk_filename, sjis_comment, + "encoding=gbk", "", "gbk"); provider->destroy(runtime); runtime = nullptr; fs::remove_all(root); diff --git a/tests/xsystem4_native/font_fallback.c b/tests/xsystem4_native/font_fallback.c new file mode 100644 index 00000000..08d4c607 --- /dev/null +++ b/tests/xsystem4_native/font_fallback.c @@ -0,0 +1,50 @@ +#include "gfx/font.h" +#include "system4/utfsjis.h" +#include +#include + +extern struct font *font_ttf[2]; +extern struct font *font_fnl[32]; + +static struct font_size selected; + +static struct font_size *get_size(struct font *font, float size) { + selected.font = font; + selected.size = size; + return &selected; +} + +static void require(bool condition, const char *message) { + if(!condition) { + fprintf(stderr, "XSYSTEM4_FONT_FALLBACK_FAILED: %s\n", message); + exit(EXIT_FAILURE); + } +} + +int main(void) { + struct font gothic = { .get_size = get_size }; + struct font mincho = { .get_size = get_size }; + struct font fnl = { .get_size = get_size }; + font_ttf[FONT_GOTHIC] = &gothic; + font_ttf[FONT_MINCHO] = &mincho; + font_fnl[0] = &fnl; + // An FNL selector in a translation must resolve to the Unicode CJK font. + use_gbk = true; + require(gfx_font_get_size(256, 28)->font == &gothic, + "GBK dialogue selected a Shift-JIS-only bitmap font"); + require(selected.size == 28, "Fallback changed the requested font size"); + require(gfx_font_get_size(0, 24)->font == &gothic, + "Gothic selection changed"); + require(gfx_font_get_size(1, 24)->font == &mincho, + "Mincho selection changed"); + // Japanese games retain the authored bitmap glyphs and metrics. + use_gbk = false; + require(gfx_font_get_size(256, 28)->font == &fnl, + "Japanese dialogue lost its original bitmap font"); + use_gbk = true; + require(gfx_font_get_size(256, 28)->font == &gothic, + "Font choice leaked across encoding changes"); + font_ttf[0] = font_ttf[1] = font_fnl[0] = NULL; + puts("XSYSTEM4_FONT_FALLBACK_OK"); + return 0; +} diff --git a/tests/xsystem4_native/probe.cpp b/tests/xsystem4_native/probe.cpp index 95c92117..53d63a39 100644 --- a/tests/xsystem4_native/probe.cpp +++ b/tests/xsystem4_native/probe.cpp @@ -126,6 +126,13 @@ bool Command(cJSON *json) { provider->destroy(player); player = nullptr; Open(); + } else if(action == "move") { + engine_input_event_t event{}; + event.struct_size = sizeof(event); + event.type = ENGINE_INPUT_EVENT_POINTER_MOVE; + event.x = Number(json, "x"); + event.y = Number(json, "y"); + Check(provider->send_input(player, &event)); } else if(action == "click" || action == "key") { engine_input_event_t event{}; event.struct_size = sizeof(event); diff --git a/tests/xsystem4_native/rance_quest.cpp b/tests/xsystem4_native/rance_quest.cpp new file mode 100644 index 00000000..62c1a367 --- /dev/null +++ b/tests/xsystem4_native/rance_quest.cpp @@ -0,0 +1,164 @@ +extern "C" { +#include "system4/ain.h" +#include "system4/string.h" +#include "system4/utfsjis.h" +#include "vm.h" +#include "xsystem4.h" +void apply_game_specific_hacks(struct ain *script); +extern struct static_library lib_MonsterInfo; +} +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +static void Require(bool condition, const char *message) { + if(!condition) + throw std::runtime_error(message); +} + +static std::string Encode(const char *text, bool gbk) { + const bool previous = use_gbk; + use_gbk = gbk; + char *encoded = utf2sjis(text, 0); + std::string result(encoded); + free(encoded); + use_gbk = previous; + return result; +} + +template +static Function MonsterFunction(const char *name) { + for(auto *f = lib_MonsterInfo.functions; f->name; ++f) + if(std::string(f->name) == name) + return reinterpret_cast(f->fun); + throw std::runtime_error("Missing MonsterInfo export"); +} + +static void CheckDetection(const char *title, bool gbk, bool tapir, + bool expected, bool english = false) { + use_gbk = gbk; + config.game_name = strdup(Encode(title, gbk).c_str()); + struct ain *script = ain_new(6, 0); + ain_add_library(script, "ChipmunkSpriteEngine"); + ain_add_library(script, "GoatGUIEngine"); + if(tapir) + ain_add_library(script, "TapirEngine"); + apply_game_specific_hacks(script); + const bool detected = game_rance8, english_detected = game_rance8_mg; + ain_free(script); + free(config.game_name); + config.game_name = nullptr; + Require(detected == expected, "Incorrect Rance Quest compatibility mode"); + Require(english_detected == english, + "English workaround leaked into another translation"); + Require(use_gbk == gbk, "Game detection changed dialogue encoding"); +} + +static void WriteArchive(const fs::path &root, const std::string &data) { + const std::string name = "Data\\MonsterInfo.txt"; + const size_t offset = 24 + name.size() + 1; + std::vector bytes(offset + data.size()); + const auto put = [&](size_t pos, uint32_t value) { + for(int i = 0; i < 4; ++i) + bytes[pos + i] = (value >> (8 * i)) & 0xff; + }; + std::copy_n("AAR", 3, bytes.begin()); + put(8, 1); + put(12, offset); + put(16, data.size()); + put(20, 1); // AAR_RAW + std::copy(name.begin(), name.end(), bytes.begin() + 24); + std::copy(data.begin(), data.end(), bytes.begin() + offset); + fs::create_directories(root / "Data"); + std::ofstream output(root / "Data/ReignData.red", std::ios::binary); + output.write(reinterpret_cast(bytes.data()), bytes.size()); + Require(bool(output), "Could not write synthetic metadata archive"); +} + +static void CheckMetadata(const fs::path &root, bool dialogue_gbk, + bool metadata_gbk) { + const std::string data = + Encode("// fixture: leading comments and blank lines\r\n\t\r\n" + "モンスター [1] = {\r\n名前 = \"テスト\"\r\n" + "データ = \"モデル\"\r\nスケール = 1.25\r\n位置 = (1, 2, 3)\r\n" + "モーション [0] = {\r\n名前 = \"待機\"\r\nデータ = \"動作\"\r\n" + "タイプ = ループ\r\nフレーム = 10, 20\r\n" + "ループフレーム = 13, 18\r\n}\r\n}\r\n", + metadata_gbk); + WriteArchive(root, data); + use_gbk = dialogue_gbk; + config.game_dir = strdup(root.string().c_str()); + struct string *filename = cstr_to_string("Data\\MonsterInfo.txt"); + const bool loaded = + MonsterFunction("Load")(filename); + free_string(filename); + Require(loaded, "Mixed-encoding monster metadata failed to load"); + Require(use_gbk == dialogue_gbk, "Metadata load changed dialogue encoding"); + Require(MonsterFunction("GetNumof")() == 2, + "Monster index was not retained"); + Require(std::fabs(MonsterFunction("GetScale")(1) - 1.25f) < + 0.0001f, + "Monster scale was not parsed"); + Require(MonsterFunction("GetMotionType")(1, 0) == 2, + "Motion loop was not parsed"); + struct string *value = nullptr; + MonsterFunction("GetPolyName")(1, &value); + Require(value && value->text == Encode("モデル", metadata_gbk), + "Model resource identifier was transcoded"); + free_string(value); + value = nullptr; + MonsterFunction("GetMotionDataName")( + 1, 0, &value); + Require(value && value->text == Encode("動作", metadata_gbk), + "Motion resource identifier was transcoded"); + free_string(value); + MonsterFunction("Release")(); + + // A malformed final line must fail without crashing or changing encoding. + WriteArchive(root, Encode("モンスター [1] = {\nunknown", metadata_gbk)); + filename = cstr_to_string("Data\\MonsterInfo.txt"); + Require(!MonsterFunction("Load")(filename), + "Malformed metadata was accepted"); + free_string(filename); + Require(use_gbk == dialogue_gbk, + "Failed metadata load changed dialogue encoding"); + MonsterFunction("Release")(); + Require(MonsterFunction("GetNumof")() == 0, + "Monster data leaked into the next session"); + free(config.game_dir); + config.game_dir = nullptr; +} + +int main() { + const fs::path root = fs::temp_directory_path() / + ("aether-rance-quest-" + + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + try { + CheckDetection("兰斯8", true, true, true); + CheckDetection("Renamed", false, true, true); + CheckDetection("ランス・クエスト", false, false, true); + CheckDetection("Rance Quest", false, true, true, true); + CheckDetection("Unrelated", true, false, false); + CheckDetection("Renamed", false, true, true); + for(bool dialogue_gbk : { false, true }) + for(bool metadata_gbk : { false, true }) + CheckMetadata(root, dialogue_gbk, metadata_gbk); + fs::remove_all(root); + std::cout << "XSYSTEM4_RANCE_QUEST_CONTRACT_OK\n"; + return 0; + } catch(const std::exception &error) { + fs::remove_all(root); + std::cerr << "XSYSTEM4_RANCE_QUEST_CONTRACT_FAILED: " << error.what() + << '\n'; + return 1; + } +} diff --git a/tests/xsystem4_native/scene_keeper.c b/tests/xsystem4_native/scene_keeper.c new file mode 100644 index 00000000..0229b4b1 --- /dev/null +++ b/tests/xsystem4_native/scene_keeper.c @@ -0,0 +1,73 @@ +#include "AnteaterADVEngine.h" +#include "system4/ain.h" +#include "system4/utfsjis.h" +#include "vm/page.h" +#include +#include +#include + +static const char sjis_name[] = "\x89\xe6\x96\xca\x95\xdb\x8a\xc7_t"; +static const char gbk_name[] = "\xbb\xad\xc3\xe6\xb1\xa3\xb9\xdc_t"; + +static void require(bool condition, const char *message) { + if(!condition) { + fprintf(stderr, "XSYSTEM4_SCENE_KEEPER_FAILED: %s\n", message); + exit(EXIT_FAILURE); + } +} + +static void fatal_error(const char *message) { require(false, message); } + +static void check_scene(bool chinese, const char *name, int padding, + bool include_japanese) { + use_gbk = chinese; + ain = ain_new(6, 0); + // Keep the VM's error diagnostics valid without executing a script. + ain->code = calloc(2, 1); + require(ain->code != NULL, "Code allocation failed"); + ain->code_size = 2; + for(int i = 0; i < padding; i++) { + char unused[32]; + snprintf(unused, sizeof(unused), "Unused%d", i); + ain_add_struct(ain, unused); + } + if(include_japanese) + ain_add_struct(ain, sjis_name); + const int index = ain_add_struct(ain, name); + struct ain_struct *structure = &ain->structures[index]; + structure->nr_members = 1; + structure->members = calloc(1, sizeof(*structure->members)); + require(structure->members != NULL, "Member allocation failed"); + structure->members[0].name = strdup("value"); + structure->members[0].type.data = AIN_INT; + + struct page *scene = alloc_page(STRUCT_PAGE, index, 1); + scene->values[0].i = 42 + padding; + require(ADVSceneKeeper_AddADVScene(&scene), "Scene type was not found"); + require(ADVSceneKeeper_GetNumofADVScene() == 1, "Scene was not retained"); + struct page *restored = NULL; + require(ADVSceneKeeper_GetADVScene(0, &restored), "Scene restore failed"); + require(restored && restored->index == index && restored->nr_vars == 1 && + restored->values[0].i == scene->values[0].i, + "Scene used an incorrect or stale structure definition"); + require(use_gbk == chinese, "Scene lookup changed the text encoding"); + free_page(restored); + free_page(scene); + ADVSceneKeeper_Clear(); + ain_free(ain); + ain = NULL; +} + +int main(void) { + sys_error_handler = fatal_error; + // Translated dialogue can coexist with original Japanese VM identifiers. + check_scene(true, sjis_name, 0, false); + // Each fresh script can assign a different index to the same structure. + check_scene(false, sjis_name, 2, false); + check_scene(true, gbk_name, 1, false); + // Prefer the Chinese identifier when both encodings are present. + check_scene(true, gbk_name, 3, true); + check_scene(false, sjis_name, 0, false); + puts("XSYSTEM4_SCENE_KEEPER_OK"); + return 0; +} diff --git a/tests/xsystem4_native/tga.cpp b/tests/xsystem4_native/tga.cpp new file mode 100644 index 00000000..25bda782 --- /dev/null +++ b/tests/xsystem4_native/tga.cpp @@ -0,0 +1,108 @@ +extern "C" { +#include "system4/archive.h" +#include "system4/cg.h" +} +#include +#include +#include +#include +#include +#include + +using Bytes = std::vector; +static const std::array, 4> colors{ { { 255, 0, 0, 255 }, + { 0, 255, 0, 128 }, + { 0, 0, 255, 64 }, + { 255, 255, 255, + 0 } } }; + +static void Require(bool condition, const char *message) { + if(!condition) + throw std::runtime_error(message); +} + +static Bytes Fixture(int depth, bool top, bool right, bool rle, bool repeated) { + Bytes bytes(18); + bytes[0] = 3; // An optional image ID precedes the pixels. + bytes[2] = rle ? 10 : 2; + bytes[12] = bytes[14] = 2; + bytes[16] = depth; + bytes[17] = (depth == 32 ? 8 : 0) | (top ? 0x20 : 0) | (right ? 0x10 : 0); + bytes.insert(bytes.end(), { 'i', 'd', '!' }); + if(rle) + bytes.push_back(repeated ? 0x83 : 0x03); + for(int row = 0; row < 2; ++row) + for(int column = 0; column < 2; ++column) { + if(repeated && (row || column)) + continue; + const int x = right ? 1 - column : column; + const int y = top ? row : 1 - row; + const auto &color = colors[repeated ? 0 : y * 2 + x]; + bytes.insert(bytes.end(), { color[2], color[1], color[0] }); + if(depth == 32) + bytes.push_back(color[3]); + } + return bytes; +} + +static void Check(Bytes bytes, int depth, bool repeated = false) { + struct archive_data file{}; + file.data = bytes.data(); + file.size = bytes.size(); + struct cg_metrics metrics{}; + Require(cg_get_metrics_data(&file, &metrics), "TGA metrics failed"); + Require(metrics.w == 2 && metrics.h == 2 && metrics.bpp == depth && + metrics.has_pixel && metrics.has_alpha == (depth == 32), + "Incorrect TGA metrics"); + struct cg *image = cg_load_buffer(bytes.data(), bytes.size()); + Require(image && image->pixels, + (std::string("TGA decode failed: ") + SDL_GetError()).c_str()); + const auto *pixels = static_cast(image->pixels); + bool valid = true; + for(int i = 0; i < 4; ++i) { + const auto &color = colors[repeated ? 0 : i]; + for(int channel = 0; channel < 4; ++channel) + valid &= pixels[i * 4 + channel] == + (channel == 3 && depth == 24 ? 255 : color[channel]); + } + cg_free(image); + Require(valid, "TGA orientation, color channels or alpha changed"); +} + +int main() { + try { + for(int depth : { 24, 32 }) + for(bool top : { false, true }) + for(bool right : { false, true }) + for(bool rle : { false, true }) + try { + Check(Fixture(depth, top, right, rle, false), + depth); + } catch(const std::exception &error) { + throw std::runtime_error( + std::to_string(depth) + + " top=" + std::to_string(top) + + " right=" + std::to_string(right) + " rle=" + + std::to_string(rle) + ": " + error.what()); + } + Check(Fixture(32, true, false, true, true), 32, true); + for(bool rle : { false, true }) { + auto bytes = Fixture(32, true, false, rle, false); + bytes.resize(22); + struct cg *image = cg_load_buffer(bytes.data(), bytes.size()); + Require(!image, "Truncated TGA was accepted"); + bytes = Fixture(32, true, false, rle, false); + bytes[12] = 0; + image = cg_load_buffer(bytes.data(), bytes.size()); + Require(!image, "Zero-width TGA was accepted"); + bytes[12] = bytes[13] = bytes[14] = bytes[15] = 255; + image = cg_load_buffer(bytes.data(), bytes.size()); + Require(!image, "Oversized TGA was accepted"); + } + std::cout << "XSYSTEM4_TGA_CONTRACT_OK\n"; + return 0; + } catch(const std::exception &error) { + std::cerr << "XSYSTEM4_TGA_CONTRACT_FAILED: " << error.what() << '\n'; + return 1; + } +} diff --git a/tools/check_ctest_inventory.py b/tools/check_ctest_inventory.py new file mode 100644 index 00000000..c3f84e7b --- /dev/null +++ b/tools/check_ctest_inventory.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Reject a successful CTest discovery that silently omitted required suites.""" + +import argparse +from collections import Counter +import json +from pathlib import Path +import sys + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("inventory", type=Path) + parser.add_argument("--require-executable", action="append", required=True) + args = parser.parse_args() + inventory = json.loads(args.inventory.read_text()) + tests = inventory["tests"] + executables = Counter() + for test in tests: + command = test.get("command", []) + if command: + executables[Path(command[0]).name] += 1 + missing = [] + print(f"CTest inventory: {len(tests)} tests in {len(executables)} executables") + for executable in sorted(set(args.require_executable)): + count = executables[executable] + print(f" {executable}: {count}") + if not count: + missing.append(executable) + if missing: + print("Missing required CTest suites: " + ", ".join(missing), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vcpkg.json b/vcpkg.json index d01211f8..eb30f29d 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -81,6 +81,10 @@ "name": "libgdiplus", "platform": "!windows & !emscripten" }, + { + "name": "libiconv", + "platform": "!emscripten" + }, "libjpeg-turbo", "lua", { @@ -141,7 +145,15 @@ "libwebp" ] }, - "sdl2-mixer", + { + "name": "sdl2-mixer", + "features": [ + { + "name": "mpg123", + "platform": "!emscripten" + } + ] + }, { "name": "sdl2-ttf", "features": [ diff --git a/vcpkg/ports/mpg123/have-fpu.diff b/vcpkg/ports/mpg123/have-fpu.diff new file mode 100644 index 00000000..3f319096 --- /dev/null +++ b/vcpkg/ports/mpg123/have-fpu.diff @@ -0,0 +1,13 @@ +diff --git a/ports/cmake/src/CMakeLists.txt b/ports/cmake/src/CMakeLists.txt +index f6a77b9..0867669 100644 +--- a/ports/cmake/src/CMakeLists.txt ++++ b/ports/cmake/src/CMakeLists.txt +@@ -199,5 +199,7 @@ check_cpu_arch_arm64(ARCH_IS_ARM64) +-if(WIN32 OR (ARCH_IS_ARM64 AND APPLE)) ++if(WIN32 OR ARCH_IS_ARM64) + set(HAVE_FPU 1) ++elseif(CMAKE_CROSSCOMPILING OR DEFINED HAVE_FPU) ++ set(HAVE_FPU 1 CACHE STRING "Override in triplet if needed") + else() + cmake_host_system_information(RESULT HAVE_FPU QUERY HAS_FPU) + endif() diff --git a/vcpkg/ports/mpg123/pkgconfig.diff b/vcpkg/ports/mpg123/pkgconfig.diff new file mode 100644 index 00000000..1383ac35 --- /dev/null +++ b/vcpkg/ports/mpg123/pkgconfig.diff @@ -0,0 +1,14 @@ +diff --git a/ports/cmake/src/libsyn123/CMakeLists.txt b/ports/cmake/src/libsyn123/CMakeLists.txt +index 22051ab..70d5be3 100644 +--- a/ports/cmake/src/libsyn123/CMakeLists.txt ++++ b/ports/cmake/src/libsyn123/CMakeLists.txt +@@ -22,6 +22,9 @@ target_include_directories(${TARGET} INTERFACE + if(HAVE_M) + string(APPEND LIBSYN123_LIBS " -lm") + endif() ++if(WANT_WIN32_UNICODE) ++ string(APPEND LIBSYN123_LIBS " -lshlwapi") ++endif() + set(LIBSYN123_LIBS "${LIBSYN123_LIBS}" PARENT_SCOPE) + target_link_libraries(${TARGET} PRIVATE + $<$:m>) diff --git a/vcpkg/ports/mpg123/portfile.cmake b/vcpkg/ports/mpg123/portfile.cmake new file mode 100644 index 00000000..7ffffa9c --- /dev/null +++ b/vcpkg/ports/mpg123/portfile.cmake @@ -0,0 +1,44 @@ +# AArch64 has floating-point support. Keep mpg123 on its floating-point +# NEON decoder when CMake HAS_FPU reports false on native Linux ARM64. +vcpkg_from_sourceforge( + OUT_SOURCE_PATH SOURCE_PATH + REPO mpg123/mpg123 + REF "${VERSION}" + FILENAME "mpg123-${VERSION}.tar.bz2" + SHA512 9b7aa93b692132da7eb8dcfef12ce91bf66bf8475af50e9c57d7b80225f96c0e74264e518e558371af1f4cf6d2afda5b3dfc844949fd747db7ac7c44d0e9f6ad + PATCHES + have-fpu.diff + pkgconfig.diff +) + +if(VCPKG_TARGET_IS_WINDOWS AND NOT VCPKG_TARGET_IS_MINGW) + yasm_tool_helper(APPEND_TO_PATH) +endif() + +vcpkg_list(SET options) +if(VCPKG_TARGET_IS_WINDOWS) + vcpkg_list(APPEND options "-DLIBMPG123_LIBS=-lshlwapi") +endif() + +vcpkg_cmake_configure( + SOURCE_PATH "${SOURCE_PATH}/ports/cmake" + OPTIONS + -DUSE_MODULES=OFF + -DBUILD_PROGRAMS=OFF + -DCMAKE_DISABLE_FIND_PACKAGE_ALSA=ON + -DCMAKE_DISABLE_FIND_PACKAGE_PkgConfig=ON + ${options} + MAYBE_UNUSED_VARIABLES + BUILD_PROGRAMS + CMAKE_DISABLE_FIND_PACKAGE_ALSA + CMAKE_DISABLE_FIND_PACKAGE_PkgConfig +) +vcpkg_cmake_install() +vcpkg_copy_pdbs() +vcpkg_cmake_config_fixup(CONFIG_PATH lib/cmake/${PORT}) +vcpkg_fixup_pkgconfig() + +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/share") +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include") + +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/COPYING") diff --git a/vcpkg/ports/mpg123/vcpkg.json b/vcpkg/ports/mpg123/vcpkg.json new file mode 100644 index 00000000..9062e64f --- /dev/null +++ b/vcpkg/ports/mpg123/vcpkg.json @@ -0,0 +1,27 @@ +{ + "name": "mpg123", + "version": "1.33.4", + "port-version": 1, + "description": "mpg123 is a real time MPEG 1.0/2.0/2.5 audio player/decoder for layers 1, 2 and 3 (MPEG 1.0 layer 3 also known as MP3).", + "homepage": "https://sourceforge.net/projects/mpg123/", + "license": "LGPL-2.1-or-later", + "supports": "!xbox", + "dependencies": [ + { + "name": "vcpkg-cmake", + "host": true + }, + { + "name": "vcpkg-cmake-config", + "host": true + }, + { + "name": "yasm", + "host": true, + "features": [ + "tools" + ], + "platform": "windows & !mingw" + } + ] +}