From a76787c7ac8739de7242be0f59a3e60fe0f3ce63 Mon Sep 17 00:00:00 2001 From: jasminelcy <2473533997@qq.com> Date: Sat, 24 Oct 2026 18:51:53 +0800 Subject: [PATCH 01/11] fix: preserve Chinese System4 text and scene history --- bridge/xsystem4_runtime/README.md | 28 +++++- .../src/xsystem4_runtime_provider.cpp | 60 +++++++++++-- doc/verified_games.md | 4 +- doc/verified_games.zh-CN.md | 4 +- packages/xsystem4 | 2 +- tests/CMakeLists.txt | 7 ++ tests/xsystem4_native/CMakeLists.txt | 7 ++ tests/xsystem4_native/contract.cpp | 89 +++++++++++++++++++ tests/xsystem4_native/scene_keeper.c | 73 +++++++++++++++ 9 files changed, 260 insertions(+), 14 deletions(-) create mode 100644 tests/xsystem4_native/scene_keeper.c diff --git a/bridge/xsystem4_runtime/README.md b/bridge/xsystem4_runtime/README.md index a273e135..7830079d 100644 --- a/bridge/xsystem4_runtime/README.md +++ b/bridge/xsystem4_runtime/README.md @@ -47,9 +47,13 @@ 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. 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. Shaders are embedded in the library and do not depend on the process directory. ## Building and validation @@ -91,6 +95,15 @@ Other actions are `click` (`x`, `y`, `button`: left=0/right=1/middle=2, `count`) 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. + 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 +111,12 @@ 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. diff --git a/bridge/xsystem4_runtime/src/xsystem4_runtime_provider.cpp b/bridge/xsystem4_runtime/src/xsystem4_runtime_provider.cpp index c0c16436..c5a79f4e 100644 --- a/bridge/xsystem4_runtime/src/xsystem4_runtime_provider.cpp +++ b/bridge/xsystem4_runtime/src/xsystem4_runtime_provider.cpp @@ -84,6 +84,19 @@ 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; + uchardet_handle_data(detector, sample.data(), sample.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 +106,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..baadfc46 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-24 This document tracks games that have been manually smoke-tested with AetherKiri. It is a compatibility notebook, not a guarantee that every route, @@ -20,6 +20,8 @@ movie, plugin path, or save state in a title has been exhaustively validated. | Game | Verified platforms / builds | Verified scope | Result | Verifier | Notes | | --- | --- | --- | --- | --- | --- | +| 兰斯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 | 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. | diff --git a/doc/verified_games.zh-CN.md b/doc/verified_games.zh-CN.md index 6a22ce60..e0254059 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-24 本文档记录已经用 AetherKiri 手动 smoke test 或 flow test 过的游戏。它是兼容性记录, 不代表每条路线、每个视频、每个插件路径或每个存档状态都已经完整验证。 @@ -19,6 +19,8 @@ | 游戏 | 已验证平台/构建 | 验证范围 | 结果 | 验证人 | 备注 | | --- | --- | --- | --- | --- | --- | +| 兰斯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 | 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 只跳过这些缺失开场图对应的等待。本地游戏文件不提交到仓库。 | diff --git a/packages/xsystem4 b/packages/xsystem4 index 16f1aa21..0c183c71 160000 --- a/packages/xsystem4 +++ b/packages/xsystem4 @@ -1 +1 @@ -Subproject commit 16f1aa210e48fa9d910727e93fffd4f4d76226ab +Subproject commit 0c183c7136de90705c1a9bc99b13ed8ef111931b diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6c566e43..c3b0a9fb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -41,6 +41,13 @@ 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) endif() if(AETHERKIRI_ENABLE_ONSCRIPTER) add_subdirectory(unit-tests/onscripter_runtime) diff --git a/tests/xsystem4_native/CMakeLists.txt b/tests/xsystem4_native/CMakeLists.txt index 8d19154c..c80a5ad9 100644 --- a/tests/xsystem4_native/CMakeLists.txt +++ b/tests/xsystem4_native/CMakeLists.txt @@ -16,3 +16,10 @@ 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) diff --git a/tests/xsystem4_native/contract.cpp b/tests/xsystem4_native/contract.cpp index f9e4928e..f2548aa6 100644 --- a/tests/xsystem4_native/contract.cpp +++ b/tests/xsystem4_native/contract.cpp @@ -80,6 +80,95 @@ 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"); + 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/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; +} From 1014e9a663561929aae21193b74d1ad048b6f708 Mon Sep 17 00:00:00 2001 From: jasminelcy <2473533997@qq.com> Date: Sat, 24 Oct 2026 22:22:31 +0800 Subject: [PATCH 02/11] feat: integrate native AliceSoft System35 runtime --- .github/workflows/build.yml | 10 +- .gitmodules | 4 + CMakeLists.txt | 9 + README.md | 4 + README.zh-CN.md | 2 + apps/godot_app/scripts/game_input_mapping.gd | 2 +- apps/godot_app/scripts/game_launch_entry.gd | 2 + apps/godot_app/scripts/game_metadata.gd | 14 + apps/godot_app/scripts/main.gd | 23 +- .../godot_app/tests/xsystem35_library_test.gd | 50 ++ .../tests/xsystem35_library_test.gd.uid | 1 + bridge/godot_extension/CMakeLists.txt | 4 + .../src/aether_runtime_player.cpp | 8 + bridge/xsystem35_runtime/CMakeLists.txt | 78 ++ bridge/xsystem35_runtime/README.md | 119 +++ .../include/xsystem35_runtime_provider.h | 9 + .../src/xsystem35_runtime_provider.cpp | 814 ++++++++++++++++++ doc/verified_games.md | 2 + doc/verified_games.zh-CN.md | 2 + packages/xsystem35 | 1 + scripts/build_ios.sh | 2 + tests/CMakeLists.txt | 41 + tests/xsystem35_native/CMakeLists.txt | 54 ++ tests/xsystem35_native/audio.c | 41 + tests/xsystem35_native/contract.cpp | 205 +++++ tests/xsystem35_native/encoding.c | 43 + tests/xsystem35_native/mosaic.c | 52 ++ tests/xsystem35_native/probe.cpp | 206 +++++ vcpkg.json | 14 +- 29 files changed, 1806 insertions(+), 10 deletions(-) create mode 100644 apps/godot_app/tests/xsystem35_library_test.gd create mode 100644 apps/godot_app/tests/xsystem35_library_test.gd.uid create mode 100644 bridge/xsystem35_runtime/CMakeLists.txt create mode 100644 bridge/xsystem35_runtime/README.md create mode 100644 bridge/xsystem35_runtime/include/xsystem35_runtime_provider.h create mode 100644 bridge/xsystem35_runtime/src/xsystem35_runtime_provider.cpp create mode 160000 packages/xsystem35 create mode 100644 tests/xsystem35_native/CMakeLists.txt create mode 100644 tests/xsystem35_native/audio.c create mode 100644 tests/xsystem35_native/contract.cpp create mode 100644 tests/xsystem35_native/encoding.c create mode 100644 tests/xsystem35_native/mosaic.c create mode 100644 tests/xsystem35_native/probe.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 45b752ec..28739023 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -136,7 +136,7 @@ jobs: git submodule update --init --depth 1 packages/AetherMinori git submodule update --init --depth 1 packages/AetherKrkr 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 - name: Set application release version if: env.AETHERKIRI_RELEASE_VERSION != '' @@ -436,7 +436,7 @@ jobs: git submodule update --init --depth 1 packages/AetherMinori git submodule update --init --depth 1 packages/AetherKrkr 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 - name: Set application release version if: env.AETHERKIRI_RELEASE_VERSION != '' @@ -764,7 +764,7 @@ jobs: git submodule update --init --depth 1 packages/AetherMinori git submodule update --init --depth 1 packages/AetherKrkr 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 - name: Set application release version if: env.AETHERKIRI_RELEASE_VERSION != '' @@ -1101,7 +1101,7 @@ jobs: git submodule update --init --depth 1 packages/AetherMinori git submodule update --init --depth 1 packages/AetherKrkr 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 - name: Verify private runtime packages if: env.AETHERKIRI_WITH_INTERNAL == 'true' @@ -1228,7 +1228,7 @@ 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 renpy_library xsystem4_library rfvp_import; do + for test in video_library_folders video_library_ui video_import_permissions flash_library tyrano_library renpy_library xsystem4_library xsystem35_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 39042037..ba50d337 100644 --- a/.gitmodules +++ b/.gitmodules @@ -42,3 +42,7 @@ 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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 9660648e..4e215325 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -115,6 +115,8 @@ 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_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 +150,9 @@ set(AETHERKIRI_INTERNAL_DIR set(AETHERKIRI_ONSCRIPTERYURI_DIR "${CMAKE_CURRENT_SOURCE_DIR}/packages/OnscripterYuri" CACHE PATH "path to an OnscripterYuri 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") @@ -309,6 +314,10 @@ 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_XSYSTEM35 AND BUILD_GODOT_EXTENSION 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 AND (APPLE OR ANDROID OR UNIX) AND NOT (WEB OR EMSCRIPTEN)) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/xsystem4_runtime) diff --git a/README.md b/README.md index 2411358b..779ef29b 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Godot App Shell ├─ OnsRuntime ───────── OnscripterYuri ├─ SiglusRuntime ────── siglus_rs ├─ System4 Runtime ──── xsystem4 (AliceSoft) + ├─ System35 Runtime ─── xsystem35 (AliceSoft) ├─ Artemis Runtime ──── Artemis (private package) ├─ CatSystem2 Runtime ─ CatSystem2 (private package) ├─ BGI Runtime ──────── BGI / Ethornell (private package) @@ -63,6 +64,9 @@ 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. Older System3 DAT + editions are identified separately. See the [native host and tested games](bridge/xsystem35_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 17fccfb3..6a11f88f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -36,6 +36,7 @@ Godot App Shell ├─ OnsRuntime ───────── OnscripterYuri ├─ SiglusRuntime ────── siglus_rs ├─ System4 Runtime ──── xsystem4(AliceSoft) + ├─ System35 Runtime ─── xsystem35(AliceSoft) ├─ Artemis Runtime ──── Artemis(私有 package) ├─ CatSystem2 Runtime ─ CatSystem2(私有 package) ├─ BGI Runtime ──────── BGI / Ethornell(私有 package) @@ -55,6 +56,7 @@ Godot App Shell - 集成 OnscripterYuri,Godot 的鼠标/触摸/键盘输入会映射回 ONS 事件。 - 覆盖 macOS、iOS/iPadOS、Android、Web 与 Linux 的导出配置。 - 独立的 Flash 分类,使用原生 Ruffle 播放,支持声音、键鼠、触摸以及视觉小说的虚拟按钮。 +- 通过内置 xsystem35 原生运行 AliceSoft System 3.5/3.9 游戏,支持 ALD 目录导入、中日文和可选虚拟按键;旧 System3 DAT 版本会单独识别。参见[原生宿主与游戏测试范围](bridge/xsystem35_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 977d960b..59ac29b0 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 ["minori", "onscripter", "rfvp", "siglus", "softpal", "xsystem4"]: + if runtime_kind in ["minori", "onscripter", "rfvp", "siglus", "softpal", "xsystem4", "xsystem35"]: 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 9ff67481..01a1a6f8 100644 --- a/apps/godot_app/scripts/game_launch_entry.gd +++ b/apps/godot_app/scripts/game_launch_entry.gd @@ -14,6 +14,8 @@ const DIRECTORY_RUNTIME_KINDS := [ "tyranoscript", "wa2", "xsystem4", + "xsystem35", + "system3", ] const RFVP_ENCODING_FIELD := "rfvpEncoding" const RFVP_ENCODINGS := ["sjis", "gbk", "utf8"] diff --git a/apps/godot_app/scripts/game_metadata.gd b/apps/godot_app/scripts/game_metadata.gd index 88473094..95216b32 100644 --- a/apps/godot_app/scripts/game_metadata.gd +++ b/apps/godot_app/scripts/game_metadata.gd @@ -10,6 +10,8 @@ const RUNTIME_CATSYSTEM2 := "catsystem2" const RUNTIME_TYRANO := "tyranoscript" const RUNTIME_RENPY := "renpy" const RUNTIME_XSYSTEM4 := "xsystem4" +const RUNTIME_XSYSTEM35 := "xsystem35" +const RUNTIME_SYSTEM3 := "system3" static func inspect(path: String) -> Dictionary: var root := path @@ -52,6 +54,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") and files.has("acg.dat"): + result.engine = RUNTIME_SYSTEM3 + result.signals.append("system3-dat") elif not hcb.is_empty(): result.engine = RUNTIME_RFVP result.signals.append("fvp-hcb") @@ -344,3 +352,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 ed703f81..00a74068 100644 --- a/apps/godot_app/scripts/main.gd +++ b/apps/godot_app/scripts/main.gd @@ -526,6 +526,7 @@ const UI_TEXT := { "message.game_exists": "游戏已存在:%s", "message.builtin_delete_failed": "删除内置 Demo 时发生错误:%s", "alert.error_title": "Aether 错误", + "alert.system3_runtime_required": "此游戏使用旧 System3 DAT 格式,需要 system3-sdl2;xsystem35 支持的是 ALD 格式。", "alert.warning_title": "Aether 警告", "alert.runtime_class_missing": "运行时扩展加载失败:AetherRuntimePlayer 不可用", "alert.runtime_create_failed": "运行时扩展加载失败:无法创建 AetherRuntimePlayer", @@ -889,6 +890,7 @@ const UI_TEXT := { "message.game_exists": "遊戲已存在:%s", "message.builtin_delete_failed": "刪除內建 Demo 時發生錯誤:%s", "alert.error_title": "Aether 錯誤", + "alert.system3_runtime_required": "此遊戲使用舊 System3 DAT 格式,需要 system3-sdl2;xsystem35 支援的是 ALD 格式。", "alert.warning_title": "Aether 警告", "alert.runtime_class_missing": "執行時擴充載入失敗:AetherRuntimePlayer 不可用", "alert.runtime_create_failed": "執行時擴充載入失敗:無法建立 AetherRuntimePlayer", @@ -1296,6 +1298,7 @@ You can find it any time under Settings → QQ Group.", "message.game_exists": "Game already exists: %s", "message.builtin_delete_failed": "Could not completely delete the built-in demo: %s", "alert.error_title": "Aether Error", + "alert.system3_runtime_required": "This game uses the older System3 DAT format and requires system3-sdl2. xsystem35 supports ALD games.", "alert.warning_title": "Aether Warning", "alert.runtime_class_missing": "Runtime extension failed to load: AetherRuntimePlayer is unavailable", "alert.runtime_create_failed": "Runtime extension failed to load: could not create AetherRuntimePlayer", @@ -1659,6 +1662,7 @@ You can find it any time under Settings → QQ Group.", "message.game_exists": "ゲームは既に存在します:%s", "message.builtin_delete_failed": "内蔵デモを完全に削除できませんでした:%s", "alert.error_title": "Aether エラー", + "alert.system3_runtime_required": "このゲームは旧 System3 の DAT 形式です。system3-sdl2 が必要です。xsystem35 は ALD 形式に対応しています。", "alert.warning_title": "Aether 警告", "alert.runtime_class_missing": "ランタイム拡張の読み込みに失敗しました:AetherRuntimePlayer は利用できません", "alert.runtime_create_failed": "ランタイム拡張の読み込みに失敗しました:AetherRuntimePlayer を作成できません", @@ -2022,6 +2026,7 @@ You can find it any time under Settings → QQ Group.", "message.game_exists": "게임이 이미 있습니다: %s", "message.builtin_delete_failed": "내장 데모를 완전히 삭제하지 못했습니다: %s", "alert.error_title": "Aether 오류", + "alert.system3_runtime_required": "이 게임은 이전 System3 DAT 형식으로 system3-sdl2가 필요합니다. xsystem35는 ALD 형식을 지원합니다.", "alert.warning_title": "Aether 경고", "alert.runtime_class_missing": "런타임 확장 로드 실패: AetherRuntimePlayer를 사용할 수 없습니다", "alert.runtime_create_failed": "런타임 확장 로드 실패: AetherRuntimePlayer를 만들 수 없습니다", @@ -2067,6 +2072,7 @@ const RUNTIME_CATSYSTEM2 := "catsystem2" const RUNTIME_SIGLUS := "siglus" const RUNTIME_RENPY := "renpy" const RUNTIME_XSYSTEM4 := "xsystem4" +const RUNTIME_XSYSTEM35 := "xsystem35" const RUNTIME_WA2 := "wa2" const RUNTIME_BGI := "bgi" const RUNTIME_TYRANO := "tyranoscript" @@ -9592,7 +9598,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, "system3"] \ and String(game.get("type", "Directory")).to_lower() == "directory" func _set_launch_file_for_selected() -> void: @@ -11869,6 +11875,9 @@ func _start_selected_game_after_entitlements() -> void: if not _mount_web_game(selected_game): return active_runtime_kind = _game_runtime_kind(library_path) + if active_runtime_kind == "system3": + _show_system_alert(_t("alert.system3_runtime_required"), _t("alert.error_title")) + return if active_runtime_kind == RUNTIME_TYRANO: _open_tyrano_game(selected_game) return @@ -12419,6 +12428,7 @@ func _switch_runtime_player(runtime_kind: String) -> bool: RUNTIME_SIGLUS, RUNTIME_BGI, RUNTIME_XSYSTEM4, + RUNTIME_XSYSTEM35, ]: normalized = RUNTIME_KIRIKIRI if player != null and current_player_runtime_kind == normalized: @@ -12447,7 +12457,9 @@ 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 3.5/3.9" + if normalized == RUNTIME_XSYSTEM35 + else "AliceSoft System 4" if normalized == RUNTIME_XSYSTEM4 else "OnscripterYuri" @@ -13084,6 +13096,7 @@ func _ensure_player_initialized() -> bool: RUNTIME_SIGLUS, RUNTIME_BGI, RUNTIME_XSYSTEM4, + RUNTIME_XSYSTEM35, ]: runtime_id = current_player_runtime_kind var runtime_result := int(player.set_engine_option("runtime", runtime_id)) @@ -13098,7 +13111,9 @@ func _ensure_player_initialized() -> bool: return false _append_log("%s engine initialized." % ( - "AliceSoft System 4" + "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 "OnscripterYuri" @@ -15617,6 +15632,8 @@ func _renderer_summary(renderer: String) -> String: var summary := selected_backend if renderer.contains("backend=onscripter_yuri"): summary = "OnscripterYuri (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/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 612018e5..bc9dc8c5 100644 --- a/bridge/godot_extension/CMakeLists.txt +++ b/bridge/godot_extension/CMakeLists.txt @@ -275,6 +275,10 @@ if(TARGET aether_onscripter_runtime) target_compile_definitions(aether_kiri_godot PRIVATE AETHERKIRI_WITH_ONSCRIPTER=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 e96d90ba..134222f2 100644 --- a/bridge/godot_extension/src/aether_runtime_player.cpp +++ b/bridge/godot_extension/src/aether_runtime_player.cpp @@ -4,6 +4,9 @@ #include "flash_player.h" #include "tyrano_player.h" #include "renpy_native.h" +#if defined(AETHERKIRI_WITH_XSYSTEM35) +#include "xsystem35_runtime_provider.h" +#endif #if defined(AETHERKIRI_WITH_XSYSTEM4) #include "xsystem4_runtime_provider.h" #endif @@ -12780,6 +12783,11 @@ void InitializeAetherRuntime(ModuleInitializationLevel level) { #if defined(AETHERKIRI_WITH_ONSCRIPTER) aetherkiri::onscripter::RegisterRuntimeProvider(); #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/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..71607db5 --- /dev/null +++ b/bridge/xsystem35_runtime/README.md @@ -0,0 +1,119 @@ +# 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 +require [system3-sdl2](https://github.com/kichikuou/system3-sdl2) and cannot be +run by xsystem35. Aether displays this requirement on launch. 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/doc/verified_games.md b/doc/verified_games.md index baadfc46..e60987de 100644 --- a/doc/verified_games.md +++ b/doc/verified_games.md @@ -20,6 +20,8 @@ movie, plugin path, or save state in a title has been exhaustively validated. | Game | Verified platforms / builds | Verified scope | Result | Verifier | Notes | | --- | --- | --- | --- | --- | --- | +| 兰斯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 | iOS, Android and Linux game playback still needs validation. Game assets are not included. | diff --git a/doc/verified_games.zh-CN.md b/doc/verified_games.zh-CN.md index e0254059..bfaf616f 100644 --- a/doc/verified_games.zh-CN.md +++ b/doc/verified_games.zh-CN.md @@ -19,6 +19,8 @@ | 游戏 | 已验证平台/构建 | 验证范围 | 结果 | 验证人 | 备注 | | --- | --- | --- | --- | --- | --- | +| 兰斯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 | iOS、Android、Linux 的实际游戏流程待验证;不包含游戏资源。 | diff --git a/packages/xsystem35 b/packages/xsystem35 new file mode 160000 index 00000000..bb7d2fdd --- /dev/null +++ b/packages/xsystem35 @@ -0,0 +1 @@ +Subproject commit bb7d2fdd047317253d2c3b35743fe66549ec87ea diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 7cb2fe5b..a72ef37f 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -325,6 +325,8 @@ 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/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 c3b0a9fb..200f409a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -52,3 +52,44 @@ 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) +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() diff --git a/tests/xsystem35_native/CMakeLists.txt b/tests/xsystem35_native/CMakeLists.txt new file mode 100644 index 00000000..47861380 --- /dev/null +++ b/tests/xsystem35_native/CMakeLists.txt @@ -0,0 +1,54 @@ +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) +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..1b4a983a --- /dev/null +++ b/tests/xsystem35_native/audio.c @@ -0,0 +1,41 @@ +#include +#include +#include +#include + +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) + 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_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; + } + SDL_Delay(20); + if(!Mix_PlayingMusic()) + return 4; + 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/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/vcpkg.json b/vcpkg.json index bbe14050..caeb4159 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -56,6 +56,10 @@ "name": "libgdiplus", "platform": "!windows & !emscripten" }, + { + "name": "libiconv", + "platform": "!emscripten" + }, "libjpeg-turbo", "lua", { @@ -116,7 +120,15 @@ "libwebp" ] }, - "sdl2-mixer", + { + "name": "sdl2-mixer", + "features": [ + { + "name": "mpg123", + "platform": "!emscripten" + } + ] + }, { "name": "sdl2-ttf", "features": [ From 30f99eff0300fcdd38f4eea634ef40f963216068 Mon Sep 17 00:00:00 2001 From: jasminelcy <2473533997@qq.com> Date: Sat, 24 Oct 2026 23:54:02 +0800 Subject: [PATCH 03/11] feat: integrate native AliceSoft System 1-3 runtime --- .github/workflows/build.yml | 10 +- .gitmodules | 4 + CMakeLists.txt | 9 + README.md | 7 +- README.zh-CN.md | 4 +- apps/godot_app/scripts/game_input_mapping.gd | 2 +- apps/godot_app/scripts/game_metadata.gd | 2 +- apps/godot_app/scripts/main.gd | 23 +- apps/godot_app/tests/system3_library_test.gd | 50 ++ .../tests/system3_library_test.gd.uid | 1 + bridge/godot_extension/CMakeLists.txt | 4 + .../src/aether_runtime_player.cpp | 8 + bridge/system3_runtime/CMakeLists.txt | 56 ++ bridge/system3_runtime/README.md | 91 +++ .../include/system3_runtime_provider.h | 9 + .../src/system3_runtime_provider.cpp | 705 ++++++++++++++++++ bridge/xsystem35_runtime/README.md | 3 +- doc/verified_games.md | 2 + doc/verified_games.zh-CN.md | 2 + packages/system3 | 1 + scripts/build_ios.sh | 2 + tests/CMakeLists.txt | 4 + tests/system3_native/CMakeLists.txt | 14 + tests/system3_native/contract.cpp | 198 +++++ tests/system3_native/contracts.cmake | 41 + tests/system3_native/encoding.cpp | 39 + tests/system3_native/probe.cpp | 205 +++++ tests/system3_native/scenario.cpp | 35 + 28 files changed, 1508 insertions(+), 23 deletions(-) create mode 100644 apps/godot_app/tests/system3_library_test.gd create mode 100644 apps/godot_app/tests/system3_library_test.gd.uid create mode 100644 bridge/system3_runtime/CMakeLists.txt create mode 100644 bridge/system3_runtime/README.md create mode 100644 bridge/system3_runtime/include/system3_runtime_provider.h create mode 100644 bridge/system3_runtime/src/system3_runtime_provider.cpp create mode 160000 packages/system3 create mode 100644 tests/system3_native/CMakeLists.txt create mode 100644 tests/system3_native/contract.cpp create mode 100644 tests/system3_native/contracts.cmake create mode 100644 tests/system3_native/encoding.cpp create mode 100644 tests/system3_native/probe.cpp create mode 100644 tests/system3_native/scenario.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 28739023..b91563cb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -136,7 +136,7 @@ jobs: git submodule update --init --depth 1 packages/AetherMinori git submodule update --init --depth 1 packages/AetherKrkr git submodule update --init --depth 1 packages/ruffle - git submodule update --init --recursive --depth 1 packages/xsystem4 packages/xsystem35 + git submodule update --init --recursive --depth 1 packages/xsystem4 packages/xsystem35 packages/system3 - name: Set application release version if: env.AETHERKIRI_RELEASE_VERSION != '' @@ -436,7 +436,7 @@ jobs: git submodule update --init --depth 1 packages/AetherMinori git submodule update --init --depth 1 packages/AetherKrkr git submodule update --init --depth 1 packages/ruffle - git submodule update --init --recursive --depth 1 packages/xsystem4 packages/xsystem35 + git submodule update --init --recursive --depth 1 packages/xsystem4 packages/xsystem35 packages/system3 - name: Set application release version if: env.AETHERKIRI_RELEASE_VERSION != '' @@ -764,7 +764,7 @@ jobs: git submodule update --init --depth 1 packages/AetherMinori git submodule update --init --depth 1 packages/AetherKrkr git submodule update --init --depth 1 packages/ruffle - git submodule update --init --recursive --depth 1 packages/xsystem4 packages/xsystem35 + git submodule update --init --recursive --depth 1 packages/xsystem4 packages/xsystem35 packages/system3 - name: Set application release version if: env.AETHERKIRI_RELEASE_VERSION != '' @@ -1101,7 +1101,7 @@ jobs: git submodule update --init --depth 1 packages/AetherMinori git submodule update --init --depth 1 packages/AetherKrkr git submodule update --init --depth 1 packages/ruffle - git submodule update --init --recursive --depth 1 packages/xsystem4 packages/xsystem35 + git submodule update --init --recursive --depth 1 packages/xsystem4 packages/xsystem35 packages/system3 - name: Verify private runtime packages if: env.AETHERKIRI_WITH_INTERNAL == 'true' @@ -1228,7 +1228,7 @@ 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 renpy_library xsystem4_library xsystem35_library rfvp_import; do + for test in video_library_folders video_library_ui video_import_permissions flash_library tyrano_library 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 ba50d337..be24b201 100644 --- a/.gitmodules +++ b/.gitmodules @@ -46,3 +46,7 @@ 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 4e215325..3e9dd4cf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -115,6 +115,8 @@ 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 @@ -150,6 +152,9 @@ 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") @@ -314,6 +319,10 @@ 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_SYSTEM3 AND BUILD_GODOT_EXTENSION 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 AND (APPLE OR ANDROID OR UNIX) AND NOT (WEB OR EMSCRIPTEN)) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/bridge/xsystem35_runtime) diff --git a/README.md b/README.md index 779ef29b..4b43938b 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Godot App Shell ├─ 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) @@ -65,8 +66,10 @@ and **Debug CPU** is a diagnostic fallback only. 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. Older System3 DAT - editions are identified separately. See the [native host and tested games](bridge/xsystem35_runtime/README.md). + 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 6a11f88f..653a7ef4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -37,6 +37,7 @@ Godot App Shell ├─ 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) @@ -56,7 +57,8 @@ Godot App Shell - 集成 OnscripterYuri,Godot 的鼠标/触摸/键盘输入会映射回 ONS 事件。 - 覆盖 macOS、iOS/iPadOS、Android、Web 与 Linux 的导出配置。 - 独立的 Flash 分类,使用原生 Ruffle 播放,支持声音、键鼠、触摸以及视觉小说的虚拟按钮。 -- 通过内置 xsystem35 原生运行 AliceSoft System 3.5/3.9 游戏,支持 ALD 目录导入、中日文和可选虚拟按键;旧 System3 DAT 版本会单独识别。参见[原生宿主与游戏测试范围](bridge/xsystem35_runtime/README.md)。 +- 通过内置 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 59ac29b0..76c92218 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 ["minori", "onscripter", "rfvp", "siglus", "softpal", "xsystem4", "xsystem35"]: + if runtime_kind in ["minori", "onscripter", "rfvp", "siglus", "softpal", "xsystem4", "xsystem35", "system3"]: 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_metadata.gd b/apps/godot_app/scripts/game_metadata.gd index 95216b32..84723adc 100644 --- a/apps/godot_app/scripts/game_metadata.gd +++ b/apps/godot_app/scripts/game_metadata.gd @@ -57,7 +57,7 @@ static func inspect(path: String) -> Dictionary: 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") and files.has("acg.dat"): + 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(): diff --git a/apps/godot_app/scripts/main.gd b/apps/godot_app/scripts/main.gd index 00a74068..9219fc58 100644 --- a/apps/godot_app/scripts/main.gd +++ b/apps/godot_app/scripts/main.gd @@ -526,7 +526,6 @@ const UI_TEXT := { "message.game_exists": "游戏已存在:%s", "message.builtin_delete_failed": "删除内置 Demo 时发生错误:%s", "alert.error_title": "Aether 错误", - "alert.system3_runtime_required": "此游戏使用旧 System3 DAT 格式,需要 system3-sdl2;xsystem35 支持的是 ALD 格式。", "alert.warning_title": "Aether 警告", "alert.runtime_class_missing": "运行时扩展加载失败:AetherRuntimePlayer 不可用", "alert.runtime_create_failed": "运行时扩展加载失败:无法创建 AetherRuntimePlayer", @@ -890,7 +889,6 @@ const UI_TEXT := { "message.game_exists": "遊戲已存在:%s", "message.builtin_delete_failed": "刪除內建 Demo 時發生錯誤:%s", "alert.error_title": "Aether 錯誤", - "alert.system3_runtime_required": "此遊戲使用舊 System3 DAT 格式,需要 system3-sdl2;xsystem35 支援的是 ALD 格式。", "alert.warning_title": "Aether 警告", "alert.runtime_class_missing": "執行時擴充載入失敗:AetherRuntimePlayer 不可用", "alert.runtime_create_failed": "執行時擴充載入失敗:無法建立 AetherRuntimePlayer", @@ -1298,7 +1296,6 @@ You can find it any time under Settings → QQ Group.", "message.game_exists": "Game already exists: %s", "message.builtin_delete_failed": "Could not completely delete the built-in demo: %s", "alert.error_title": "Aether Error", - "alert.system3_runtime_required": "This game uses the older System3 DAT format and requires system3-sdl2. xsystem35 supports ALD games.", "alert.warning_title": "Aether Warning", "alert.runtime_class_missing": "Runtime extension failed to load: AetherRuntimePlayer is unavailable", "alert.runtime_create_failed": "Runtime extension failed to load: could not create AetherRuntimePlayer", @@ -1662,7 +1659,6 @@ You can find it any time under Settings → QQ Group.", "message.game_exists": "ゲームは既に存在します:%s", "message.builtin_delete_failed": "内蔵デモを完全に削除できませんでした:%s", "alert.error_title": "Aether エラー", - "alert.system3_runtime_required": "このゲームは旧 System3 の DAT 形式です。system3-sdl2 が必要です。xsystem35 は ALD 形式に対応しています。", "alert.warning_title": "Aether 警告", "alert.runtime_class_missing": "ランタイム拡張の読み込みに失敗しました:AetherRuntimePlayer は利用できません", "alert.runtime_create_failed": "ランタイム拡張の読み込みに失敗しました:AetherRuntimePlayer を作成できません", @@ -2026,7 +2022,6 @@ You can find it any time under Settings → QQ Group.", "message.game_exists": "게임이 이미 있습니다: %s", "message.builtin_delete_failed": "내장 데모를 완전히 삭제하지 못했습니다: %s", "alert.error_title": "Aether 오류", - "alert.system3_runtime_required": "이 게임은 이전 System3 DAT 형식으로 system3-sdl2가 필요합니다. xsystem35는 ALD 형식을 지원합니다.", "alert.warning_title": "Aether 경고", "alert.runtime_class_missing": "런타임 확장 로드 실패: AetherRuntimePlayer를 사용할 수 없습니다", "alert.runtime_create_failed": "런타임 확장 로드 실패: AetherRuntimePlayer를 만들 수 없습니다", @@ -2073,6 +2068,7 @@ 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_TYRANO := "tyranoscript" @@ -9598,7 +9594,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, RUNTIME_XSYSTEM35, "system3"] \ + 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: @@ -11875,9 +11871,6 @@ func _start_selected_game_after_entitlements() -> void: if not _mount_web_game(selected_game): return active_runtime_kind = _game_runtime_kind(library_path) - if active_runtime_kind == "system3": - _show_system_alert(_t("alert.system3_runtime_required"), _t("alert.error_title")) - return if active_runtime_kind == RUNTIME_TYRANO: _open_tyrano_game(selected_game) return @@ -12429,6 +12422,7 @@ func _switch_runtime_player(runtime_kind: String) -> bool: RUNTIME_BGI, RUNTIME_XSYSTEM4, RUNTIME_XSYSTEM35, + RUNTIME_SYSTEM3, ]: normalized = RUNTIME_KIRIKIRI if player != null and current_player_runtime_kind == normalized: @@ -12457,7 +12451,9 @@ func _switch_runtime_player(runtime_kind: String) -> bool: diagnostic_session.finish() diagnostic_session.start(player, selected_backend) _append_log("Runtime selected: %s" % ( - "AliceSoft System 3.5/3.9" + "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 @@ -13097,6 +13093,7 @@ func _ensure_player_initialized() -> bool: RUNTIME_BGI, RUNTIME_XSYSTEM4, RUNTIME_XSYSTEM35, + RUNTIME_SYSTEM3, ]: runtime_id = current_player_runtime_kind var runtime_result := int(player.set_engine_option("runtime", runtime_id)) @@ -13111,7 +13108,9 @@ func _ensure_player_initialized() -> bool: return false _append_log("%s engine initialized." % ( - "AliceSoft System 3.5/3.9" + "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 @@ -15632,6 +15631,8 @@ 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"): 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/bridge/godot_extension/CMakeLists.txt b/bridge/godot_extension/CMakeLists.txt index bc9dc8c5..96301c39 100644 --- a/bridge/godot_extension/CMakeLists.txt +++ b/bridge/godot_extension/CMakeLists.txt @@ -275,6 +275,10 @@ 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) diff --git a/bridge/godot_extension/src/aether_runtime_player.cpp b/bridge/godot_extension/src/aether_runtime_player.cpp index 134222f2..006df75f 100644 --- a/bridge/godot_extension/src/aether_runtime_player.cpp +++ b/bridge/godot_extension/src/aether_runtime_player.cpp @@ -4,6 +4,9 @@ #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 @@ -12783,6 +12786,11 @@ 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"); 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/README.md b/bridge/xsystem35_runtime/README.md index 71607db5..ad67ebd7 100644 --- a/bridge/xsystem35_runtime/README.md +++ b/bridge/xsystem35_runtime/README.md @@ -60,8 +60,7 @@ 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 -require [system3-sdl2](https://github.com/kichikuou/system3-sdl2) and cannot be -run by xsystem35. Aether displays this requirement on launch. In particular, +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). diff --git a/doc/verified_games.md b/doc/verified_games.md index e60987de..39fce51d 100644 --- a/doc/verified_games.md +++ b/doc/verified_games.md @@ -20,6 +20,8 @@ movie, plugin path, or save state in a title has been exhaustively validated. | Game | Verified platforms / builds | Verified scope | Result | Verifier | Notes | | --- | --- | --- | --- | --- | --- | +| 兰斯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. | diff --git a/doc/verified_games.zh-CN.md b/doc/verified_games.zh-CN.md index bfaf616f..8a540f11 100644 --- a/doc/verified_games.zh-CN.md +++ b/doc/verified_games.zh-CN.md @@ -19,6 +19,8 @@ | 游戏 | 已验证平台/构建 | 验证范围 | 结果 | 验证人 | 备注 | | --- | --- | --- | --- | --- | --- | +| 兰斯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 整包及其他平台,不包含游戏资源。 | 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/scripts/build_ios.sh b/scripts/build_ios.sh index a72ef37f..01273a7b 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -325,6 +325,8 @@ 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" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 200f409a..869c9542 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -93,3 +93,7 @@ if(TARGET aether_xsystem35_core) $,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/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..40c679b3 --- /dev/null +++ b/tests/system3_native/contract.cpp @@ -0,0 +1,198 @@ +#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 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"); + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + Require(Frame(runtime).frame_serial > 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..b9a5959e --- /dev/null +++ b/tests/system3_native/scenario.cpp @@ -0,0 +1,35 @@ +#include "embedded.h" +#include +#include +#include +#include + +int main(int argc, char **argv) { + if(argc != 4) + return 2; + namespace fs = std::filesystem; + const auto root = fs::temp_directory_path() / + ("aether-system3-scenario-" + + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + fs::create_directories(root / "game"); + fs::create_directories(root / "saves"); + 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; +} From bc188490df248f2ef85458b21ed01951ae0d299c Mon Sep 17 00:00:00 2001 From: jasminelcy <2473533997@qq.com> Date: Sun, 25 Oct 2026 00:51:46 +0800 Subject: [PATCH 04/11] fix: detect short Chinese System4 titles reliably --- bridge/xsystem4_runtime/README.md | 15 +++++++++++++-- .../src/xsystem4_runtime_provider.cpp | 12 +++++++++++- doc/verified_games.md | 2 +- doc/verified_games.zh-CN.md | 2 +- tests/xsystem4_native/contract.cpp | 12 ++++++++++++ tests/xsystem4_native/probe.cpp | 7 +++++++ 6 files changed, 45 insertions(+), 5 deletions(-) diff --git a/bridge/xsystem4_runtime/README.md b/bridge/xsystem4_runtime/README.md index 7830079d..4712c77c 100644 --- a/bridge/xsystem4_runtime/README.md +++ b/bridge/xsystem4_runtime/README.md @@ -50,7 +50,9 @@ Existing saves in the imported game's `SaveData` are not automatically copied. `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. Quoted `;` and `//` +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. @@ -89,7 +91,7 @@ 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 @@ -120,3 +122,12 @@ 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. diff --git a/bridge/xsystem4_runtime/src/xsystem4_runtime_provider.cpp b/bridge/xsystem4_runtime/src/xsystem4_runtime_provider.cpp index c5a79f4e..ecaf2db6 100644 --- a/bridge/xsystem4_runtime/src/xsystem4_runtime_provider.cpp +++ b/bridge/xsystem4_runtime/src/xsystem4_runtime_provider.cpp @@ -90,7 +90,17 @@ namespace aetherkiri::xsystem4 { uchardet_t detector = uchardet_new(); if(!detector) return false; - uchardet_handle_data(detector, sample.data(), sample.size()); + // 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); diff --git a/doc/verified_games.md b/doc/verified_games.md index 39fce51d..ebce3863 100644 --- a/doc/verified_games.md +++ b/doc/verified_games.md @@ -26,7 +26,7 @@ movie, plugin path, or save state in a title has been exhaustively validated. | 兰斯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 | iOS, Android and Linux game playback still needs validation. 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 8a540f11..823a691c 100644 --- a/doc/verified_games.zh-CN.md +++ b/doc/verified_games.zh-CN.md @@ -25,7 +25,7 @@ | 兰斯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 | iOS、Android、Linux 的实际游戏流程待验证;不包含游戏资源。 | +| 战国兰斯 / 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/tests/xsystem4_native/contract.cpp b/tests/xsystem4_native/contract.cpp index f2548aa6..6a6ac146 100644 --- a/tests/xsystem4_native/contract.cpp +++ b/tests/xsystem4_native/contract.cpp @@ -151,6 +151,18 @@ int main() { "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; 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); From 92ec69a823503ea6bd229ef1497c5008d6a0238b Mon Sep 17 00:00:00 2001 From: jasminelcy <2473533997@qq.com> Date: Sun, 25 Oct 2026 08:28:49 +0800 Subject: [PATCH 05/11] fix: load fan-repacked Rance IX AFA indexes --- bridge/xsystem4_runtime/CMakeLists.txt | 26 ++++ bridge/xsystem4_runtime/README.md | 18 +++ .../xsystem4_runtime/src/afa_index_compat.h | 32 +++++ doc/verified_games.md | 3 +- doc/verified_games.zh-CN.md | 3 +- tests/xsystem4_native/CMakeLists.txt | 6 + tests/xsystem4_native/afa.cpp | 127 ++++++++++++++++++ 7 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 bridge/xsystem4_runtime/src/afa_index_compat.h create mode 100644 tests/xsystem4_native/afa.cpp diff --git a/bridge/xsystem4_runtime/CMakeLists.txt b/bridge/xsystem4_runtime/CMakeLists.txt index a402fcbc..98af5043 100644 --- a/bridge/xsystem4_runtime/CMakeLists.txt +++ b/bridge/xsystem4_runtime/CMakeLists.txt @@ -45,6 +45,32 @@ 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") 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 4712c77c..3d9bedf2 100644 --- a/bridge/xsystem4_runtime/README.md +++ b/bridge/xsystem4_runtime/README.md @@ -106,6 +106,14 @@ 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. + 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. @@ -131,3 +139,13 @@ 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. 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/doc/verified_games.md b/doc/verified_games.md index ebce3863..9ef2f517 100644 --- a/doc/verified_games.md +++ b/doc/verified_games.md @@ -2,7 +2,7 @@ [简体中文](verified_games.zh-CN.md) -Last updated: 2026-10-24 +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,6 +20,7 @@ movie, plugin path, or save state in a title has been exhaustively validated. | Game | Verified platforms / builds | Verified scope | Result | Verifier | Notes | | --- | --- | --- | --- | --- | --- | +| 兰斯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. | diff --git a/doc/verified_games.zh-CN.md b/doc/verified_games.zh-CN.md index 823a691c..7f534444 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-24 +最后更新:2026-10-25 本文档记录已经用 AetherKiri 手动 smoke test 或 flow test 过的游戏。它是兼容性记录, 不代表每条路线、每个视频、每个插件路径或每个存档状态都已经完整验证。 @@ -19,6 +19,7 @@ | 游戏 | 已验证平台/构建 | 验证范围 | 结果 | 验证人 | 备注 | | --- | --- | --- | --- | --- | --- | +| 兰斯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 验证,使用独立测试存档。其他平台和存读档流程未验证;不包含游戏资源。 | diff --git a/tests/xsystem4_native/CMakeLists.txt b/tests/xsystem4_native/CMakeLists.txt index c80a5ad9..c17d3219 100644 --- a/tests/xsystem4_native/CMakeLists.txt +++ b/tests/xsystem4_native/CMakeLists.txt @@ -23,3 +23,9 @@ target_include_directories(aether_xsystem4_scene_keeper_contract PRIVATE "${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) 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; + } +} From c1eccbe8cea900356e01bddf0daa6523073808a4 Mon Sep 17 00:00:00 2001 From: jasminelcy <2473533997@qq.com> Date: Sun, 25 Oct 2026 12:11:51 +0800 Subject: [PATCH 06/11] fix: render Rance Quest titles and Chinese dialogue --- bridge/xsystem4_runtime/CMakeLists.txt | 24 ++++ bridge/xsystem4_runtime/README.md | 27 +++- bridge/xsystem4_runtime/src/tga_compat.h | 112 ++++++++++++++++ doc/verified_games.md | 1 + doc/verified_games.zh-CN.md | 1 + packages/xsystem4 | 2 +- tests/xsystem4_native/CMakeLists.txt | 26 ++++ tests/xsystem4_native/font_fallback.c | 50 +++++++ tests/xsystem4_native/rance_quest.cpp | 164 +++++++++++++++++++++++ tests/xsystem4_native/tga.cpp | 108 +++++++++++++++ 10 files changed, 513 insertions(+), 2 deletions(-) create mode 100644 bridge/xsystem4_runtime/src/tga_compat.h create mode 100644 tests/xsystem4_native/font_fallback.c create mode 100644 tests/xsystem4_native/rance_quest.cpp create mode 100644 tests/xsystem4_native/tga.cpp diff --git a/bridge/xsystem4_runtime/CMakeLists.txt b/bridge/xsystem4_runtime/CMakeLists.txt index 98af5043..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) @@ -71,6 +72,29 @@ 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 3d9bedf2..c36cf616 100644 --- a/bridge/xsystem4_runtime/README.md +++ b/bridge/xsystem4_runtime/README.md @@ -62,7 +62,7 @@ Shaders are embedded in the library and do not depend on the process directory. `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. @@ -114,6 +114,21 @@ 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. @@ -149,3 +164,13 @@ 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/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/doc/verified_games.md b/doc/verified_games.md index 9ef2f517..a0b28b34 100644 --- a/doc/verified_games.md +++ b/doc/verified_games.md @@ -20,6 +20,7 @@ movie, plugin path, or save state in a title has been exhaustively validated. | Game | Verified platforms / builds | Verified scope | Result | Verifier | Notes | | --- | --- | --- | --- | --- | --- | +| 兰斯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. | diff --git a/doc/verified_games.zh-CN.md b/doc/verified_games.zh-CN.md index 7f534444..feef59e9 100644 --- a/doc/verified_games.zh-CN.md +++ b/doc/verified_games.zh-CN.md @@ -19,6 +19,7 @@ | 游戏 | 已验证平台/构建 | 验证范围 | 结果 | 验证人 | 备注 | | --- | --- | --- | --- | --- | --- | +| 兰斯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 验证;实际保存流程、其他平台与完整流程未验证;不包含游戏资源。 | diff --git a/packages/xsystem4 b/packages/xsystem4 index 0c183c71..0f31d9d9 160000 --- a/packages/xsystem4 +++ b/packages/xsystem4 @@ -1 +1 @@ -Subproject commit 0c183c7136de90705c1a9bc99b13ed8ef111931b +Subproject commit 0f31d9d9691fa45da4a4332a884a7c7c18023887 diff --git a/tests/xsystem4_native/CMakeLists.txt b/tests/xsystem4_native/CMakeLists.txt index c17d3219..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") @@ -29,3 +30,28 @@ 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/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/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/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; + } +} From ee4e9a4df172084d30be69fa6df1a61fd6ba3e50 Mon Sep 17 00:00:00 2001 From: jasminelcy <2473533997@qq.com> Date: Sun, 25 Oct 2026 13:53:29 +0800 Subject: [PATCH 07/11] chore: sync xsystem4 master into the Aether runtime --- bridge/xsystem4_runtime/README.md | 1 + packages/xsystem4 | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bridge/xsystem4_runtime/README.md b/bridge/xsystem4_runtime/README.md index c36cf616..e1708c52 100644 --- a/bridge/xsystem4_runtime/README.md +++ b/bridge/xsystem4_runtime/README.md @@ -56,6 +56,7 @@ 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 diff --git a/packages/xsystem4 b/packages/xsystem4 index 0f31d9d9..dd4a10df 160000 --- a/packages/xsystem4 +++ b/packages/xsystem4 @@ -1 +1 @@ -Subproject commit 0f31d9d9691fa45da4a4332a884a7c7c18023887 +Subproject commit dd4a10df4eedcd6117e3d1813e677652255ce54c From af884c3ae7b1965454218db66d9023f946713402 Mon Sep 17 00:00:00 2001 From: jasminelcy <2473533997@qq.com> Date: Sun, 25 Oct 2026 18:13:44 +0800 Subject: [PATCH 08/11] test: run AliceSoft contracts in parallel CI builds --- CMakeLists.txt | 6 +++--- tests/system3_native/scenario.cpp | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 054076f3..9d2a0fe3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -329,15 +329,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_SYSTEM3 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 AND +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 AND +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() diff --git a/tests/system3_native/scenario.cpp b/tests/system3_native/scenario.cpp index b9a5959e..823bddfe 100644 --- a/tests/system3_native/scenario.cpp +++ b/tests/system3_native/scenario.cpp @@ -3,17 +3,27 @@ #include #include #include +#include int main(int argc, char **argv) { if(argc != 4) return 2; namespace fs = std::filesystem; - const auto root = fs::temp_directory_path() / - ("aether-system3-scenario-" + + 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()); From 9147a216b22f7fc0e0103bbf890e689bb6218364 Mon Sep 17 00:00:00 2001 From: jasminelcy <2473533997@qq.com> Date: Sun, 25 Oct 2026 18:44:10 +0800 Subject: [PATCH 09/11] fix: initialize AliceSoft CI runtimes and ARM64 MP3 decoding --- .../initialize-private-runtime-submodules.sh | 2 + .github/workflows/build.yml | 5 +- .../test_runtime_submodule_initialization.py | 58 +++++++++++++++++++ vcpkg/ports/mpg123/have-fpu.diff | 13 +++++ vcpkg/ports/mpg123/pkgconfig.diff | 14 +++++ vcpkg/ports/mpg123/portfile.cmake | 44 ++++++++++++++ vcpkg/ports/mpg123/vcpkg.json | 27 +++++++++ 7 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 tests/python/test_runtime_submodule_initialization.py create mode 100644 vcpkg/ports/mpg123/have-fpu.diff create mode 100644 vcpkg/ports/mpg123/pkgconfig.diff create mode 100644 vcpkg/ports/mpg123/portfile.cmake create mode 100644 vcpkg/ports/mpg123/vcpkg.json 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 44428382..f57fd08b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -737,7 +737,9 @@ 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 - name: Initialize private runtime submodules if: env.AETHERKIRI_WITH_INTERNAL == 'true' @@ -1257,6 +1259,7 @@ 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 + 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" \ 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/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" + } + ] +} From 74e5bfa85e0cc55f3f1c6ca55d448662fe160873 Mon Sep 17 00:00:00 2001 From: jasminelcy <2473533997@qq.com> Date: Sun, 25 Oct 2026 20:57:46 +0800 Subject: [PATCH 10/11] fix: cover native runtime suites and stabilize CI contracts Enable desktop test providers independently of the Godot extension and reject missing CTest suites. Wait for asynchronous audio and VM progress, isolate parallel fixtures, and pin embedded Android modal input support. Refs #389 and AetherKiri/AetherInternal#70. --- .github/workflows/build.yml | 23 ++++++++++ CMakeLists.txt | 9 +++- packages/AetherInternal | 2 +- packages/xsystem35 | 2 +- tests/CMakeLists.txt | 2 +- tests/python/test_ctest_inventory.py | 51 +++++++++++++++++++++ tests/system3_native/contract.cpp | 13 +++++- tests/unit-tests/mages_runtime/provider.cpp | 29 +++++++++--- tests/xsystem35_native/audio.c | 21 +++++++-- tools/check_ctest_inventory.py | 37 +++++++++++++++ 10 files changed, 172 insertions(+), 17 deletions(-) create mode 100644 tests/python/test_ctest_inventory.py create mode 100644 tools/check_ctest_inventory.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f57fd08b..6c06e661 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -740,6 +740,7 @@ jobs: 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' @@ -960,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 \ @@ -974,6 +977,26 @@ 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 + ) + 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 \ diff --git a/CMakeLists.txt b/CMakeLists.txt index 9d2a0fe3..eaffd884 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -307,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. @@ -319,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() @@ -346,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/packages/AetherInternal b/packages/AetherInternal index e1405bdf..c14937d2 160000 --- a/packages/AetherInternal +++ b/packages/AetherInternal @@ -1 +1 @@ -Subproject commit e1405bdfa22ab86c96ba2ff4bcd35ee3e56efbaa +Subproject commit c14937d298c1a58d74d9cda0463d05d34756561b diff --git a/packages/xsystem35 b/packages/xsystem35 index bb7d2fdd..33045bad 160000 --- a/packages/xsystem35 +++ b/packages/xsystem35 @@ -1 +1 @@ -Subproject commit bb7d2fdd047317253d2c3b35743fe66549ec87ea +Subproject commit 33045bad279458323affb8c6752bfbfd703a4119 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index eeb6d7c8..3c543469 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) 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/system3_native/contract.cpp b/tests/system3_native/contract.cpp index 40c679b3..76056b3b 100644 --- a/tests/system3_native/contract.cpp +++ b/tests/system3_native/contract.cpp @@ -41,6 +41,16 @@ static engine_frame_desc_t Frame(void *runtime) { "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; @@ -151,8 +161,7 @@ int main() { std::getline(imported, text); Require(text == "original save", "original overwritten"); Require(provider->resume(runtime) == ENGINE_RESULT_OK, "resume"); - std::this_thread::sleep_for(std::chrono::milliseconds(80)); - Require(Frame(runtime).frame_serial > paused, "VM did not 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; 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/audio.c b/tests/xsystem35_native/audio.c index 1b4a983a..77c5aa1e 100644 --- a/tests/xsystem35_native/audio.c +++ b/tests/xsystem35_native/audio.c @@ -3,6 +3,10 @@ #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; @@ -11,8 +15,10 @@ int main(void) { Mix_GetError()); return 1; } - if(Mix_OpenAudio(44100, AUDIO_S16SYS, 2, 512) != 0) + 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 }; @@ -23,14 +29,23 @@ int main(void) { 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; } - SDL_Delay(20); - if(!Mix_PlayingMusic()) + // 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(); 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()) From 0496352dc16a74e4da0aa62494e893f67fa6e27e Mon Sep 17 00:00:00 2001 From: jasminelcy <2473533997@qq.com> Date: Mon, 26 Oct 2026 00:02:40 +0800 Subject: [PATCH 11/11] fix: handle headless macOS tests and embedded Android MIDI Pin OpenGL capability detection for private pixel tests and the portable System35 MIDI dispatcher. Add a native Android-selection link contract and require it in CI discovery. Refs #389 and AetherKiri/AetherInternal#70. --- .github/workflows/build.yml | 1 + packages/AetherInternal | 2 +- packages/xsystem35 | 2 +- tests/CMakeLists.txt | 11 +++++++ tests/xsystem35_native/CMakeLists.txt | 11 +++++++ tests/xsystem35_native/midi.c | 41 +++++++++++++++++++++++++++ 6 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/xsystem35_native/midi.c diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6c06e661..aaa213ca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -983,6 +983,7 @@ jobs: --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+=( diff --git a/packages/AetherInternal b/packages/AetherInternal index c14937d2..2dbbfeed 160000 --- a/packages/AetherInternal +++ b/packages/AetherInternal @@ -1 +1 @@ -Subproject commit c14937d298c1a58d74d9cda0463d05d34756561b +Subproject commit 2dbbfeedea6f951322dcfdbeb30d15b6d93a0fab diff --git a/packages/xsystem35 b/packages/xsystem35 index 33045bad..79437cc8 160000 --- a/packages/xsystem35 +++ b/packages/xsystem35 @@ -1 +1 @@ -Subproject commit 33045bad279458323affb8c6752bfbfd703a4119 +Subproject commit 79437cc884d7e2efa613c1b946825d087c321ccd diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3c543469..2e1c6c69 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -97,6 +97,17 @@ if(TARGET aether_xsystem35_core) "${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) diff --git a/tests/xsystem35_native/CMakeLists.txt b/tests/xsystem35_native/CMakeLists.txt index 47861380..a8d13602 100644 --- a/tests/xsystem35_native/CMakeLists.txt +++ b/tests/xsystem35_native/CMakeLists.txt @@ -28,6 +28,17 @@ if(TARGET aether_xsystem35_core) "${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) 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; +}