diff --git a/DateToSpine/.gitignore b/DateToSpine/.gitignore new file mode 100644 index 0000000..166dedc --- /dev/null +++ b/DateToSpine/.gitignore @@ -0,0 +1,7 @@ +/build/ +/.cache/ +/tests/fixtures/external/ +*.user +*.swp +.DS_Store + diff --git a/DateToSpine/CMakeLists.txt b/DateToSpine/CMakeLists.txt new file mode 100644 index 0000000..4b63ce5 --- /dev/null +++ b/DateToSpine/CMakeLists.txt @@ -0,0 +1,130 @@ +cmake_minimum_required(VERSION 3.25) + +project(DateToSpine VERSION 0.1.0 LANGUAGES CXX) + +option(DTS_BUILD_TESTS "Build DateToSpine tests" ON) +option(DTS_BUILD_GUI "Build the DateToSpine desktop application" ON) +option(DTS_BUILD_RUNTIME_PLUGINS "Build bundled Spine runtime adapters" ON) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +add_library(dts_core + src/domain/SpineVersion.cpp + src/atlas/AtlasParser.cpp + src/preview/RuntimePlugin.cpp + src/scan/SpineDataVersionDetector.cpp + src/editor/SpineEditorLocator.cpp + src/editor/SpineOutputParser.cpp + src/editor/SpineProbe.cpp + src/editor/SpineVersionAvailability.cpp +) + +if(APPLE OR UNIX) + target_sources(dts_core PRIVATE src/platform/ProcessRunner_posix.cpp) +elseif(WIN32) + target_sources(dts_core PRIVATE src/platform/ProcessRunner_windows.cpp) +else() + message(FATAL_ERROR "Unsupported platform") +endif() + +target_include_directories(dts_core PUBLIC src runtime-api) +target_link_libraries(dts_core PRIVATE ${CMAKE_DL_LIBS}) + +if(MSVC) + target_compile_options(dts_core PRIVATE /W4 /permissive- /utf-8) +else() + target_compile_options(dts_core PRIVATE -Wall -Wextra -Wpedantic) +endif() + +if(DTS_BUILD_RUNTIME_PLUGINS) + include(cmake/SpineRuntime.cmake) + dts_add_runtime_adapter(38 "3.8" spine38 "spine-cpp/spine-cpp") + dts_add_runtime_adapter(40 "4.0" spine40 "spine-cpp/spine-cpp") + dts_add_runtime_adapter(41 "4.1" spine41 "spine-cpp/spine-cpp") + dts_add_runtime_adapter(42 "4.2" spine42 "spine-cpp/spine-cpp") + dts_add_runtime_adapter(43 "4.3" spine43 "spine-cpp") +endif() + +add_executable(datetospine-cli src/app/cli_main.cpp) +target_link_libraries(datetospine-cli PRIVATE dts_core) + +if(DTS_BUILD_GUI) + find_package(Qt6 REQUIRED COMPONENTS Concurrent OpenGLWidgets Widgets) + add_executable(DateToSpine MACOSX_BUNDLE WIN32 + src/app/gui_main.cpp + src/conversion/ConversionJob.cpp + src/conversion/ConversionJob.hpp + src/ui/MainWindow.cpp + src/ui/MainWindow.hpp + src/ui/SpinePreviewWidget.cpp + src/ui/SpinePreviewWidget.hpp + ) + set_target_properties(DateToSpine PROPERTIES + AUTOMOC ON + MACOSX_BUNDLE_GUI_IDENTIFIER io.datetospine.desktop + MACOSX_BUNDLE_BUNDLE_NAME DateToSpine) + target_link_libraries(DateToSpine PRIVATE + dts_core + Qt6::Concurrent + Qt6::OpenGLWidgets + Qt6::Widgets + ) + if(DTS_BUILD_RUNTIME_PLUGINS) + add_dependencies(DateToSpine + dts_runtime_38 dts_runtime_40 dts_runtime_41 dts_runtime_42 dts_runtime_43) + foreach(runtime_line IN ITEMS 38 40 41 42 43) + add_custom_command(TARGET dts_runtime_${runtime_line} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory + "$/Contents/PlugIns/runtimes" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" + "$/Contents/PlugIns/runtimes/") + add_custom_command(TARGET DateToSpine POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory + "$/Contents/PlugIns/runtimes" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" + "$/Contents/PlugIns/runtimes/") + endforeach() + endif() +endif() + +if(DTS_BUILD_TESTS) + enable_testing() + add_executable(dts_core_tests + tests/unit/CoreTests.cpp + ) + target_link_libraries(dts_core_tests PRIVATE dts_core) + if(DTS_BUILD_RUNTIME_PLUGINS) + add_dependencies(dts_core_tests + dts_runtime_38 dts_runtime_40 dts_runtime_41 dts_runtime_42 dts_runtime_43) + target_compile_definitions(dts_core_tests PRIVATE + DTS_RUNTIME_38_PATH="$" + DTS_RUNTIME_40_PATH="$" + DTS_RUNTIME_41_PATH="$" + DTS_RUNTIME_42_PATH="$" + DTS_RUNTIME_43_PATH="$" + DTS_TEST_38_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/3.8/examples/spineboy/export/spineboy-pro.json" + DTS_TEST_38_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/3.8/examples/spineboy/export/spineboy-pro.skel" + DTS_TEST_38_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/3.8/examples/spineboy/export/spineboy.atlas" + DTS_TEST_40_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.0/examples/spineboy/export/spineboy-pro.json" + DTS_TEST_40_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.0/examples/spineboy/export/spineboy-pro.skel" + DTS_TEST_40_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.0/examples/spineboy/export/spineboy.atlas" + DTS_TEST_41_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.1/examples/spineboy/export/spineboy-pro.json" + DTS_TEST_41_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.1/examples/spineboy/export/spineboy-pro.skel" + DTS_TEST_41_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.1/examples/spineboy/export/spineboy.atlas" + DTS_TEST_42_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.2/examples/spineboy/export/spineboy-pro.json" + DTS_TEST_42_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.2/examples/spineboy/export/spineboy-pro.skel" + DTS_TEST_42_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.2/examples/spineboy/export/spineboy.atlas" + DTS_TEST_SPINEBOY_JSON="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.3/examples/spineboy/export/spineboy-pro.json" + DTS_TEST_SPINEBOY_SKEL="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.3/examples/spineboy/export/spineboy-pro.skel" + DTS_TEST_SPINEBOY_ATLAS="${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/4.3/examples/spineboy/export/spineboy.atlas") + endif() + add_test(NAME dts_core_tests COMMAND dts_core_tests) +endif() + +install(FILES third_party/spine-runtimes/4.3/spine-cpp/LICENSE + DESTINATION licenses + RENAME Spine-Runtimes-License.txt) diff --git a/DateToSpine/CMakePresets.json b/DateToSpine/CMakePresets.json new file mode 100644 index 0000000..c85167e --- /dev/null +++ b/DateToSpine/CMakePresets.json @@ -0,0 +1,65 @@ +{ + "version": 6, + "cmakeMinimumRequired": { + "major": 3, + "minor": 25, + "patch": 0 + }, + "configurePresets": [ + { + "name": "macos-debug", + "displayName": "macOS Debug", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/macos-debug", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "DTS_BUILD_TESTS": "ON" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Darwin" + } + }, + { + "name": "windows-msvc-debug", + "displayName": "Windows MSVC Debug", + "generator": "Visual Studio 17 2022", + "architecture": "x64", + "binaryDir": "${sourceDir}/build/windows-msvc-debug", + "cacheVariables": { + "DTS_BUILD_TESTS": "ON" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + } + ], + "buildPresets": [ + { + "name": "macos-debug", + "configurePreset": "macos-debug" + }, + { + "name": "windows-msvc-debug", + "configurePreset": "windows-msvc-debug", + "configuration": "Debug" + } + ], + "testPresets": [ + { + "name": "macos-debug", + "configurePreset": "macos-debug", + "output": { "outputOnFailure": true } + }, + { + "name": "windows-msvc-debug", + "configurePreset": "windows-msvc-debug", + "configuration": "Debug", + "output": { "outputOnFailure": true } + } + ] +} + diff --git a/DateToSpine/cmake/SpineRuntime.cmake b/DateToSpine/cmake/SpineRuntime.cmake new file mode 100644 index 0000000..c9403a8 --- /dev/null +++ b/DateToSpine/cmake/SpineRuntime.cmake @@ -0,0 +1,37 @@ +function(dts_add_spine_runtime target runtime_root) + if(NOT EXISTS "${runtime_root}/include/spine/spine.h") + message(FATAL_ERROR "Missing spine-cpp source at ${runtime_root}") + endif() + + file(GLOB_RECURSE runtime_sources CONFIGURE_DEPENDS + "${runtime_root}/src/*.cpp") + file(GLOB_RECURSE runtime_headers CONFIGURE_DEPENDS + "${runtime_root}/include/*.h") + + add_library(${target} STATIC ${runtime_sources} ${runtime_headers}) + target_include_directories(${target} PUBLIC "${runtime_root}/include") + set_target_properties(${target} PROPERTIES POSITION_INDEPENDENT_CODE ON) + + if(MSVC) + target_compile_options(${target} PRIVATE /W3 /utf-8) + else() + target_compile_options(${target} PRIVATE -Wno-deprecated-declarations) + endif() +endfunction() + +function(dts_add_runtime_adapter suffix version_line source_directory runtime_subdirectory) + set(runtime_root + "${CMAKE_CURRENT_SOURCE_DIR}/third_party/spine-runtimes/${version_line}/${runtime_subdirectory}") + set(runtime_target "dts_spine_cpp_${suffix}") + set(adapter_target "dts_runtime_${suffix}") + dts_add_spine_runtime(${runtime_target} "${runtime_root}") + add_library(${adapter_target} SHARED + "${CMAKE_CURRENT_SOURCE_DIR}/runtime-plugins/${source_directory}/RuntimeAdapter.cpp") + target_include_directories(${adapter_target} PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/runtime-api") + target_link_libraries(${adapter_target} PRIVATE ${runtime_target}) + set_target_properties(${adapter_target} PROPERTIES + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN YES + OUTPUT_NAME "dts-runtime-${version_line}") +endfunction() diff --git a/DateToSpine/docs/01-产品需求.md b/DateToSpine/docs/01-产品需求.md new file mode 100644 index 0000000..38afcf6 --- /dev/null +++ b/DateToSpine/docs/01-产品需求.md @@ -0,0 +1,146 @@ +# 产品需求 + +## 1. 产品目标 + +DateToSpine 帮助用户批量处理仅保留运行时导出文件、但缺失原始 `.spine` 工程的资源。软件需要在转换前验证资源并显示可交互动画预览,用户确认后才调用本地 Spine Editor 生成可编辑工程。 + +产品目标是“尽可能恢复可编辑工程”,不是承诺字节级还原原工程。运行时导出时已经丢失的数据无法凭空恢复。 + +## 2. 用户前提 + +- 用户对待处理资源拥有合法使用和转换权限。 +- 用户电脑已安装并激活 Spine Editor。 +- 需要生成 `.spine` 时,本地已有与资源主/次版本相匹配的 Editor 版本。 +- 未安装 Editor 时,软件仍可扫描、校验、配对和预览,但不能生成 `.spine`。 + +## 3. 支持范围 + +### 3.1 骨骼数据 + +| 格式 | 支持状态 | 说明 | +|---|---|---| +| `.json` | 正式支持 | 必须通过 Spine schema 识别,不能把普通 JSON 当作骨骼数据 | +| `.skel` | 正式支持 | 按导出版本选择匹配 Runtime 和 Editor | +| `.skel.bytes` | 候选兼容 | 通过内容识别,扩展名支持在技术原型后决定 | + +### 3.2 Spine 版本 + +| 版本 | 支持级别 | +|---|---| +| 3.8.20+ | 正式支持 | +| 3.8.0—3.8.19 | 尽力识别,不作正式保证 | +| 4.0.x | 正式支持 | +| 4.1.x | 正式支持 | +| 4.2.x | 正式支持 | +| 4.3.x | 正式支持 | +| 3.7.x 及更早 | 不支持 | + +### 3.3 Atlas 与纹理页 + +- `.atlas`、`.atlas.txt` +- 单页和多页 Atlas +- PNG +- JPG/JPEG +- WebP +- 同一 Atlas 中不同扩展名的多页纹理 +- Straight Alpha 和 Premultiplied Alpha + +其他图片格式通过可扩展解码接口保留未来支持空间,但不属于首版承诺。 + +## 4. 主用户流程 + +主界面只暴露四个动作: + +1. 用户把完整导出资源或资源文件夹拖入窗口。 +2. 软件自动配对并显示预览;有多套资源时用顶部下拉框切换。 +3. 用户用底部下拉框切换动画。 +4. 用户点击“一键转换为 .spine”并选择保存位置。 + +版本检测、Atlas 配对、图片处理、Editor 验证和结果校验均在后台自动完成。只有自动找不到 Spine Editor 时,才要求用户指定 Spine 应用或可执行文件路径。普通流程不提供 Editor 管理页或技术参数面板。 + +## 5. 功能需求 + +### 5.1 扫描与配对 + +- 支持文件、文件夹和批量拖放。 +- 支持递归扫描,允许配置最大深度。 +- 根据 Atlas 中声明的页面名查找纹理,不能只依赖同名前缀。 +- 根据附件 path、文件名、目录距离和名称前缀对数据与 Atlas 评分。 +- 自动结果必须显示置信度。 +- 歧义配对必须由用户确认,不能静默选择。 +- 单个失败组不能终止整个批次。 + +### 5.2 预览 + +- 默认循环播放,支持动画切换。 +- 画布自动适应窗口。 +- 支持 Mesh、权重、Clipping、双颜色 Tint 和四种混合模式。 +- 显示缺失纹理、版本错误和 Runtime 加载错误。 + +### 5.3 Editor 管理 + +- 自动查找常见安装位置。 +- 自动查找失败时才显示路径选择器,支持自定义应用、可执行文件或安装目录。 +- 支持一个安装入口对应多个本地可用版本。 +- 转换前执行实际版本探测。 +- 不读取、存储或传输 Spine 激活码。 + +### 5.4 批量转换 + +- 默认串行调用 Spine Editor,扫描和静态校验可并行。 +- 支持停止等待中的任务和取消当前子进程。 +- 支持跳过、自动重命名和覆盖策略。 +- 默认不覆盖输入文件。 +- 正式结果验证通过前只写入任务临时目录。 +- 每组资源产生可读日志和机器可读报告。 + +## 6. 恢复能力边界 + +通常可恢复:骨骼、Slots、Skins、Attachments、约束、动画、Deform、事件和绘制顺序。 + +如果源文件导出时未启用 Nonessential data,可能永久缺失: + +- 骨骼颜色和图标 +- Mesh 手工边 +- 编辑器辅助信息 +- 部分附件原始尺寸 +- 原工程图片和音频目录设置 +- 编辑器视图与工作区状态 + +报告必须区分: + +- 完整恢复 +- 主体恢复但缺少 Nonessential data +- 工程生成成功但图片关联需要注意 +- 转换失败 + +## 7. 非功能需求 + +- macOS 和 Windows 功能一致。 +- 运行时完全离线,不依赖 CDN 或在线服务。 +- 支持中文、空格、Unicode 和较长路径。 +- 所有外部进程使用参数数组启动,不能拼接 shell 命令。 +- 崩溃或取消不能留下半成品覆盖正式结果。 +- 日志不能包含用户授权信息或不必要的绝对路径。 +- 普通操作错误必须以用户可理解的语言呈现。 + +## 8. 首版不包含 + +- Spine 3.7 及更早版本。 +- 绕过 Spine Editor 授权或下载 Editor。 +- 自行生成或逆向写入 `.spine` 格式。 +- 动画编辑功能。 +- 云端转换、资源上传或账号系统。 +- 将官方示例资产打包给最终用户。 + +## 9. 产品验收标准 + +- 五个目标版本均可完成 JSON、SKEL 的预览和转换测试。 +- 断网环境可完成已准备版本的完整流程。 +- PNG、JPG/JPEG、WebP 页面可预览。 +- 纹理只允许使用匹配版本 Spine Editor 的官方 Texture Unpacker 解包。 +- 生成物能被匹配版本 Editor 重新读取。 +- 骨骼、Skin、附件、动画和事件数量通过语义核对。 +- 原始输入不被修改。 +- 单任务失败不影响批次其他任务。 +- 最终安装包不含官方测试资源和 Spine Editor。 diff --git a/DateToSpine/docs/02-系统架构.md b/DateToSpine/docs/02-系统架构.md new file mode 100644 index 0000000..b877ad3 --- /dev/null +++ b/DateToSpine/docs/02-系统架构.md @@ -0,0 +1,217 @@ +# 系统架构 + +## 1. 技术基线 + +| 项目 | 选择 | +|---|---| +| 语言 | C++20(第三方 Runtime target 可使用其原生标准) | +| UI | Qt 6 Widgets | +| 预览画布 | `QOpenGLWidget` + 自研批次渲染器 | +| 构建 | CMake 3.25+、CMake Presets | +| Windows 编译器 | Visual Studio 2022 / MSVC v143 | +| macOS 编译器 | Apple Clang | +| 进程管理 | `QProcess` | +| 设置存储 | `QSettings` | +| JSON | Qt JSON | +| 图片加载 | Qt Image plugins,随程序离线部署 | +| 测试 | Qt Test 或 Catch2,原型结束时二选一 | + +CMake 只用于开发构建,不是最终用户的运行依赖。Windows 可以使用 Visual Studio Generator 或 Ninja + MSVC。 + +## 2. 分层结构 + +```text +UI + ├─ 资源组表格 + ├─ 预览与播放控制 + ├─ Editor 管理 + └─ 任务、日志与报告 + ↓ +Application Services + ├─ ScanService + ├─ PreviewService + ├─ EditorRegistry + ├─ RecoveryCoordinator + └─ ReportService + ↓ +Domain + ├─ AssetGroup + ├─ SpineVersion + ├─ EditorInstallation + ├─ RecoveryJob + └─ RecoveryResult + ↓ +Infrastructure + ├─ Filesystem / Process + ├─ Runtime Plugin Host + ├─ Spine CLI Adapter + ├─ Atlas Unpackers + └─ Output Publisher +``` + +UI 只表达状态和用户意图,不能直接调用 Spine CLI 或解析 Atlas。 + +## 3. 建议目录 + +```text +DateToSpine/ + CMakeLists.txt + CMakePresets.json + cmake/ + docs/ + licenses/ + resources/ + icons/ + shaders/ + translations/ + src/ + app/ + domain/ + scan/ + atlas/ + editor/ + preview/ + recovery/ + report/ + platform/ + macos/ + windows/ + ui/ + util/ + runtime-api/ + runtime-plugins/ + spine38/ + spine40/ + spine41/ + spine42/ + spine43/ + third_party/ + spine-runtimes/ + licenses/ + tests/ + unit/ + integration/ + compatibility/ + manifests/ + fixtures/ + generated/ + external/ + packaging/ + macos/ + windows/ +``` + +`tests/fixtures/external/` 用于本地准备的官方测试资源,不进入发行包。 + +## 4. 多版本 Runtime 隔离 + +五套 Runtime 不能直接链接进同一个主程序命名空间。每个版本编译成独立动态模块,并隐藏所有上游 C++ 符号,只导出稳定的 DateToSpine C ABI。 + +```text +dts-runtime-3_8 +dts-runtime-4_0 +dts-runtime-4_1 +dts-runtime-4_2 +dts-runtime-4_3 +``` + +公共 ABI 只允许: + +- 固定宽度整数 +- POD 结构体 +- UTF-8 字节串 +- 不透明句柄 +- 显式长度的数组 +- 函数指针回调 + +禁止跨模块传递 Qt 对象、STL 容器、异常或 Spine Runtime 类。 + +每个适配模块承担: + +- 对应版本 JSON/SKEL 读取 +- Atlas 绑定 +- 动画状态推进 +- Skin/Animation 查询和切换 +- 把绘制结果转换为统一顶点、索引和 draw command +- 把上游错误转换为统一错误码 + +## 5. 预览渲染边界 + +Runtime 插件负责骨骼计算和裁剪;主程序渲染器负责: + +- 纹理创建和缓存 +- 顶点/索引上传 +- Shader +- Blend mode +- 相机、背景和调试覆盖层 +- DPI 和窗口生命周期 + +Runtime 不直接创建 Qt 或 OpenGL 对象。这使 Runtime 适配可独立测试,也避免上下文所有权问题。 + +## 6. Spine CLI 边界 + +所有 Editor 调用必须经过 `ISpineCli`: + +```text +probe installation +probe version +import data +export normalized json +unpack atlas +inspect project +``` + +实现要求: + +- 使用 `QProcess::setProgram` 和 `setArguments`。 +- 不经过 shell。 +- 每次调用有超时、取消令牌、工作目录和独立日志。 +- 只终止本软件启动的子进程。 +- 对 Windows 优先选择 `Spine.com`。 +- 对 macOS 把 `.app` 规范化为内部可执行文件。 + +## 7. 任务并发 + +- 扫描、哈希、图片头校验可以在线程池执行。 +- OpenGL 资源操作只在所属渲染线程执行。 +- Spine Editor 调用默认全局串行。 +- Spine 官方 Atlas 解包与工程导入全局串行,并受任务取消状态约束。 +- 每个恢复任务有独立临时目录和取消状态。 + +## 8. 文件长度和复杂度约束 + +自研代码执行以下软上限,超过时必须在评审中说明或拆分: + +| 文件类型 | 建议上限 | +|---|---:| +| 头文件 | 200 行 | +| 普通 `.cpp` | 350 行 | +| UI `.cpp` | 450 行 | +| 单元测试文件 | 500 行 | + +同时执行: + +- 一个类只有一个主要职责。 +- `MainWindow` 不包含解析、转换和进程逻辑。 +- 禁止无边界的 `Utils` 类。 +- 平台代码不得散落到业务模块。 +- 版本差异留在 Runtime adapter 和 version policy 中。 +- 第三方上游源码保持原样,位于 `third_party/`,不纳入自研文件行数限制。 + +CI 将检查自研源文件行数、循环依赖和禁止目录引用。 + +## 9. 可测试性 + +核心依赖使用小接口注入: + +```text +IFileSystem +IProcessRunner +ISpineCli +IRuntimePlugin +IAtlasUnpacker +IClock +IReportSink +``` + +单元测试不启动真实 Editor;集成和兼容测试才调用本地 Spine Editor。 diff --git a/DateToSpine/docs/03-版本识别与编辑器检测.md b/DateToSpine/docs/03-版本识别与编辑器检测.md new file mode 100644 index 0000000..6769a36 --- /dev/null +++ b/DateToSpine/docs/03-版本识别与编辑器检测.md @@ -0,0 +1,193 @@ +# 版本识别与 Spine Editor 检测 + +## 1. 目标 + +在用户确认转换前,软件必须回答: + +1. 资源由哪个 Spine 主/次版本导出? +2. 本机是否有可以处理该版本的 Spine Editor? +3. 配置路径是否指向可用的 Spine CLI? +4. 在断网状态下,该版本是否能实际启动和导入? + +路径存在不等于 Editor 可用。当前实现先读取本机 Spine 更新缓存,只把已经存在的精确版本视为可转换;转换时再次执行同一门禁。 + +## 2. 资源版本识别 + +### 2.1 JSON + +读取 `skeleton.spine` 字段,并同时验证最小 Spine schema: + +- 根对象 +- `bones` 数组 +- 可选 `slots`、`skins`、`animations` +- 版本字段格式 + +只解析识别所需的小范围数据。无 Spine schema 的业务 JSON 必须忽略。 + +### 2.2 SKEL + +识别顺序: + +1. 对二进制头执行有界读取,尝试提取 hash 和版本字符串。 +2. 用候选版本 Runtime 插件执行只读探测。 +3. 必要时用已配置 Editor 的 info 命令验证。 +4. 多个版本都失败则要求用户选择版本,并标记为未确认。 + +解析必须限制字符串长度、数组大小和文件大小,损坏文件不能导致无限分配。 + +## 3. Editor 数据模型 + +```text +EditorInstallation + id + displayName + platform + userSelectedPath + canonicalExecutable + discoverySource + lastVerifiedAt + baseVersion + signatureStatus + capabilities[] + +EditorCapability + versionLine // 3.8, 4.0, 4.1, 4.2, 4.3 + launchSelector // 可选 -u 参数或独立 executable + status + lastProbeResult +``` + +同一个安装入口可以声明多个本地版本能力;多个安装入口也可以映射到同一版本。用户可为每个版本选择首选项。 + +## 4. 自动发现 + +### 4.1 macOS + +优先位置: + +```text +/Applications/Spine.app +~/Applications/Spine.app +``` + +补充来源: + +- 系统应用索引 +- 当前会话已知应用路径 +- 历史用户配置 + +用户选择 `.app` 时规范化为: + +```text +Spine.app/Contents/MacOS/Spine +``` + +不递归扫描整个磁盘。 + +### 4.2 Windows + +发现来源: + +- HKLM/HKCU 安装与卸载注册信息 +- `Program Files`、`Program Files (x86)` 常见位置 +- `PATH` +- 开始菜单快捷方式目标 +- 历史用户配置 + +CLI 优先选择 `Spine.com`。如果用户选择 `Spine.exe`,查找同目录同名 `.com`;找不到时允许保存,但状态为“GUI 路径,CLI 待验证”。 + +## 5. 自定义路径 + +用户可选择: + +- macOS `.app` +- 内部 Spine 可执行文件 +- Windows `Spine.com` 或 `Spine.exe` +- 包含上述文件的目录 + +保存前执行路径规范化: + +- 解析相对路径 +- 清理多余分隔符 +- 保留用户显示路径 +- 保存 canonical path +- 记录文件标识和修改时间,用于发现安装被替换 + +路径移动或升级后,不自动删除旧配置,而是标记失效并允许重新定位。 + +## 6. 验证步骤 + +### 6.1 静态验证 + +- 文件存在且不是目录。 +- 当前用户可执行。 +- macOS bundle 结构或 Windows 同目录文件合理。 +- 可选检查平台签名;旧版签名异常只警告,不直接否定合法安装。 + +### 6.2 基础进程验证 + +使用参数数组执行 `--version`,捕获: + +- 退出码 +- 标准输出和错误输出 +- 启动耗时 +- 报告版本 + +设置短超时,只终止该次探测进程。 + +### 6.3 版本能力验证 + +对用户配置的目标版本执行实际启动探测。若使用 `-u ` 选择器: + +- 只允许已确认在本地缓存中存在的精确版本,例如资源线为 4.3 时选择本地最新的 `4.3.23`。 +- 不使用 `--force`。 +- 不使用 `latest`、`stable`、`beta` 或 `x.x.xx` 版本选择器。 +- 不由 DateToSpine 发起下载。 +- 缓存缺失时不启动 Spine Launcher,直接报告“本地未缓存目标版本”,从源头避免 Launcher 下载。 +- 启动进程前再次检查精确缓存文件,避免检测后被移动导致意外联网。 + +当前 macOS 检查 `~/Library/Application Support/Spine/updates`,Windows 检查 `%APPDATA%/Spine/updates` 和 `%LOCALAPPDATA%/Spine/updates`。后续若 Spine 改变缓存结构,需要同步更新并重新做断网回归。 + +### 6.4 导入冒烟测试 + +在用户点击“深度验证”或首次转换前,可在临时目录执行小型导入: + +- 输入与版本匹配的最小测试数据。 +- 输出到临时目录。 +- 验证退出码和 `.spine` 可再次读取。 +- 完成立即清理临时结果。 + +发行版不能使用未获再分发确认的官方测试资产作为内置冒烟样本;需要自有最小样本或仅对用户当前资源执行验证。 + +## 7. 状态与用户提示 + +```text +Available +PathMissing +NotExecutable +NotSpine +CliCompanionMissing +VersionMismatch +VersionNotPrepared +NotActivated +ProbeTimedOut +PermissionDenied +LaunchFailed +Unknown +``` + +每个状态需要: + +- 用户可读说明 +- 技术详情 +- 可执行的修复建议 +- 是否允许预览 +- 是否允许转换 + +资源全部载入后,主界面顶部直接显示当前资源的结果,例如“可转换 · Spine 4.3.23”或“缺少 Spine 4.1”。自动检测失败时,转换按钮才变为“指定 Spine 路径”。 + +## 8. 离线要求 + +DateToSpine 本身不联网。完整转换成立的前提是对应 Editor 版本已经在本机准备好并能在断网时运行。 + +离线验收必须在网络被禁用的独立测试环境进行,不能只通过代码审查推断。 diff --git a/DateToSpine/docs/04-恢复流水线.md b/DateToSpine/docs/04-恢复流水线.md new file mode 100644 index 0000000..2b4b35a --- /dev/null +++ b/DateToSpine/docs/04-恢复流水线.md @@ -0,0 +1,186 @@ +# 恢复流水线 + +## 1. 状态机 + +```text +Discovered + → Grouped + → PreflightPassed + → PreviewReady + → UserConfirmed + → EditorVerified + → AtlasUnpacked + → ProjectImported + → ProjectVerified + → Published +``` + +任一步骤失败进入 `Failed(stage, code, details)`。取消进入 `Cancelled`。失败与取消不能发布半成品。 + +## 2. 资源组 + +一个 `AssetGroup` 包含: + +```text +id +skeletonDataPath +skeletonFormat +detectedVersion +atlasPath +atlasPages[] +candidateScore +scale +alphaMode +warnings[] +``` + +Atlas 页面名是纹理配对的权威来源。文件前缀只作为寻找数据与 Atlas 关系的辅助信号。 + +## 3. 预检 + +转换前检查: + +- 数据格式和版本 +- 版本是否在支持范围 +- Atlas 基础语法 +- 所有页面是否存在 +- 图片解码和尺寸 +- Atlas scale/PMA +- 输出命名冲突 +- 目标 Editor 可用性 +- 临时目录和输出目录可写性 +- 磁盘空间粗略估计 + +预检不修改输入。 + +## 4. 用户确认边界 + +预览阶段不得生成正式 `.spine`。允许的临时操作仅包括: + +- Runtime 加载 +- 图片解码 +- 静态校验 +- Editor 只读版本探测 + +只有用户点击“开始转换”后,才能调用导入和解包命令。 + +## 5. 临时工作区 + +```text +job-/ + source-links/ + normalized/ + unpacked-images/ + intermediate/ + logs/ + publish/ +``` + +- 优先使用复制或只读访问,不能修改源文件。 +- 是否允许安全硬链接作为优化,在跨平台验证后决定。 +- 每个任务独立,名称使用内部 UUID,避免用户文件名注入路径。 + +## 6. Atlas 解包 + +1. 只调用匹配版本 Spine Editor 的官方 Texture Unpacker。 +2. 校验页面、region 数量、输出尺寸和文件存在性。 +3. 官方失败或验证失败时终止任务并显示原因,不使用其他解包实现。 +4. 原始 `.atlas` 和打包纹理页只作为只读输入,不复制到最终结果。 +5. 工程导入必须读取 Atlas 的 `scale` 并使用 `-s <数值>`,使骨骼与官方解包图片采用相同打包比例。 + +详细规则见 `06-图集解包设计.md`。 + +## 7. JSON 输入工程生成 + +1. 复制 JSON 到临时目录。 +2. 保留所有未知字段。 +3. 仅在必要时更新临时副本中的 `skeleton.images` 相对路径。 +4. 使用匹配主/次版本的 Editor 导入。 +5. 输出临时 `.spine`。 +6. 使用 Editor info 或再次导出执行语义验证。 + +禁止修改源 JSON。 + +## 8. SKEL 输入工程生成 + +SKEL 的图片路径自动关联是技术原型重点。候选官方流程: + +1. 匹配版本 Editor 把 SKEL 导入临时 `.spine`。 +2. Editor 从临时工程导出规范化 JSON。 +3. 在规范化 JSON 临时副本中设置 `skeleton.images`。 +4. 匹配版本 Editor 再导入为最终候选 `.spine`。 +5. 比较初次导入、规范化 JSON 和最终工程的语义计数。 + +此流程避免自行解析并序列化全部 SKEL,也避免修改未公开的 `.spine` 格式。 + +原型必须验证: + +- 默认 JSON 导出是否保留全部运行时语义。 +- 动画曲线、Deform、Linked Mesh、约束和事件是否无损。 +- 图片相对路径是否被最终 `.spine` 保存。 +- 3.8—4.3 是否行为一致。 + +若某版本失败,保留“工程 + images 目录 + 首次打开路径提示”作为降级方案,但正式自动化验收前必须由产品方确认是否接受。 + +## 9. Scale 与 Alpha + +- Atlas 显式 `scale` 优先。 +- 数据和 Atlas 都没有足够信息时,不猜测为确定值。 +- 可提供 1、0.5、0.25 等候选并让用户预览比较。 +- 用户可通过底部“去除预乘 Alpha”开关决定是否将解包图片转换为 Straight Alpha。 +- 为保证各版本行为一致,官方解包使用临时 Atlas 副本并关闭其自动去预乘;开关开启后由本地像素处理统一完成转换。 +- 临时 Atlas 不修改源文件,也不进入最终结果。 + +## 10. 结果验证 + +### 10.1 文件级 + +- `.spine` 存在且非空。 +- 所有预期图片存在且可解码。 +- 输出路径不逃逸目标目录。 +- 临时文件未混入正式结果。 + +### 10.2 语义级 + +至少比较: + +- 骨骼数和名称 +- Slot 数和名称 +- Skin 数和名称 +- Attachment 类型与数量 +- 约束类型与数量 +- Animation 名称、数量和时长 +- Event 名称和数量 +- Deform timeline 存在性 + +Nonessential 缺失作为恢复等级,不作为运行时语义失败。 + +### 10.3 可读性 + +- 使用匹配 Editor 重新读取生成项目。 +- 捕获所有 warning/error。 +- 版本不匹配警告视为失败,除非用户启用实验模式。 + +## 11. 原子发布 + +验证通过后: + +1. 在输出目录同一文件系统创建候选目录。 +2. 应用重名策略。 +3. 一次性重命名为正式结果。 +4. 覆盖时先备份或移入可恢复位置。 +5. 发布失败保留诊断,但不留下伪成功目录。 + +## 12. 输出结构 + +```text +Output/ + hero/ + hero.spine + images/ + body.png + face/ + eye.png +``` + +当前简化产品不输出原始 Atlas、打包纹理页、日志或恢复报告。发生错误时直接弹窗给出失败阶段和 Spine 输出摘要;成功时弹窗显示完整输出目录。 diff --git a/DateToSpine/docs/05-离线预览器.md b/DateToSpine/docs/05-离线预览器.md new file mode 100644 index 0000000..4e4c3dd --- /dev/null +++ b/DateToSpine/docs/05-离线预览器.md @@ -0,0 +1,156 @@ +# 离线预览器 + +## 1. 目标 + +预览器在不调用 Spine Editor、不开启网络的情况下直接读取: + +```text +.json/.skel + .atlas/.atlas.txt + texture pages +``` + +用户通过预览确认资源配对、版本、Scale、Alpha、动画和 Skin 后,才进入转换。 + +## 2. Runtime 版本矩阵 + +| 数据 | Runtime 插件 | +|---|---| +| 3.8.20+ | `dts-runtime-3_8` | +| 4.0.x | `dts-runtime-4_0` | +| 4.1.x | `dts-runtime-4_1` | +| 4.2.x | `dts-runtime-4_2` | +| 4.3.x | `dts-runtime-4_3` | + +每个插件基于官方仓库对应分支的 `spine-cpp`。Runtime 与数据主/次版本必须一致。 + +## 3. 公共插件 ABI + +公共 API 使用版本号和结构体大小实现向前兼容: + +```text +dtsRuntimeGetApiVersion +dtsRuntimeCreate +dtsRuntimeDestroy +dtsRuntimeLoad +dtsRuntimeGetMetadata +dtsRuntimeGetAnimations +dtsRuntimeGetSkins +dtsRuntimeSetAnimation +dtsRuntimeSetSkin +dtsRuntimeSeek +dtsRuntimeUpdate +dtsRuntimeBuildFrame +dtsRuntimeGetLastError +``` + +设计规则: + +- 调用方提供结构体 `size`。 +- 插件返回的内存由插件释放。 +- 错误不能以 C++ 异常越过 ABI。 +- 所有字符串为 UTF-8,并带长度。 +- 插件的所有上游符号默认隐藏。 +- 主程序校验插件文件和 ABI 版本后才加载。 + +## 4. 统一绘制数据 + +插件输出与 Runtime 版本无关的 draw list: + +```text +PreviewFrame + bounds + vertices[] + indices[] + commands[] + +DrawCommand + textureHandle + firstIndex + indexCount + blendMode + premultipliedAlpha +``` + +顶点至少包含: + +- position +- uv +- light color +- dark color + +Clipping 优先在对应 Runtime/adapter 中完成,使主渲染器只处理最终三角形。 + +## 5. 纹理加载 + +主程序实现统一 Texture Provider: + +- 根据 Atlas 页面路径读取本地文件。 +- 拒绝 HTTP/HTTPS 和 data URL。 +- 规范化相对路径并阻止目录逃逸。 +- 支持 PNG、JPG/JPEG、WebP。 +- 保留源 Alpha 模式元数据。 +- 缓存按 canonical path、修改时间和文件大小失效。 + +图片解码插件必须随安装包部署,不能在运行时下载。 + +## 6. 渲染功能 + +首版必须支持: + +- Region 和 Mesh +- Weighted Mesh、Linked Mesh +- Clipping +- Normal、Additive、Multiply、Screen +- 单颜色和 Two Color Tint +- PMA 和 Straight Alpha +- 多页 Atlas +- 高 DPI +- 骨骼、Mesh、边界、裁剪调试层 + +Shader 作为本地资源随程序部署,并在构建期或启动期从本地加载。 + +## 7. 播放状态 + +每个预览会话保存: + +```text +selectedAnimation +selectedSkin +loop +playbackSpeed +time +paused +camera +background +debugFlags +``` + +切换资源组时保留各组会话,避免用户往返查看时丢失位置。 + +Seek 需要确定性:先恢复 setup pose,再从动画起点应用到目标时间,不能依赖累积浮点步进得到任意帧。 + +## 8. 错误隔离 + +首版采用动态插件;技术原型评估损坏 SKEL 是否可能使上游 Runtime 崩溃。如果无法通过输入边界检查和测试降低风险,发布版改为每个版本一个 helper process: + +```text +UI ↔ local IPC ↔ preview-worker-4_2 +``` + +是否进程隔离由原型的崩溃与模糊测试结果决定,不在未验证前锁死。 + +## 9. 性能目标 + +- 普通单骨骼资源首次预览在本地 SSD 上目标小于 1 秒。 +- 预览默认 60 FPS;窗口不可见时暂停刷新。 +- 大资源限制最大纹理尺寸、顶点数、附件数和单帧命令数。 +- 纹理解码在线程池执行,OpenGL 创建在渲染线程执行。 +- 切换动画不重复加载 Atlas 纹理。 + +## 10. 预览验收 + +- 五个版本官方示例的选定动画与官方参考视觉一致。 +- 动画名、Skin 名和时长与 Runtime 数据一致。 +- PMA/Straight Alpha 无明显黑边或白边。 +- Mesh、Clipping、双颜色 Tint 和混合模式分别有测试样本。 +- 缺失纹理不会导致崩溃,并能在画布上定位缺失项。 +- 拔网后所有预览功能保持可用。 diff --git a/DateToSpine/docs/06-图集解包设计.md b/DateToSpine/docs/06-图集解包设计.md new file mode 100644 index 0000000..b9a0b09 --- /dev/null +++ b/DateToSpine/docs/06-图集解包设计.md @@ -0,0 +1,67 @@ +# Atlas 官方解包设计 + +## 1. 唯一实现 + +纹理恢复只调用用户本机、已授权且与数据版本匹配的 Spine Editor Texture Unpacker。项目不实现 Atlas 裁切、旋转、透明边恢复或图片重采样,也不存在自研兼容解包路径。 + +```text +本地匹配版本 Spine Texture Unpacker + ↓ + 输出校验 + ↓ + 失败并向用户显示具体原因 +``` + +官方解包失败、输出缺失或校验失败时,任务直接失败,不尝试以另一套图片算法生成看似成功的结果。 + +## 2. 官方调用 + +```text +Spine -u <本地精确缓存版本> + -i + -o <临时 images 目录> + -c +``` + +- 只允许精确的本地缓存版本,禁止 `latest`、通配版本和强制下载参数。 +- 原始 Atlas 与纹理页保持只读。 +- 输出先写入任务临时目录,验证成功后再发布。 +- 记录退出码与进程输出;成功退出后仍检查是否实际生成图片。 +- 最终结果不复制原始 `.atlas` 和打包纹理页。 + +## 3. PMA 处理 + +PMA 转换同样交给官方 Texture Unpacker: + +- 开启“去除预乘 Alpha”时,原样传入源 Atlas,由 Spine 根据 `pma: true` 执行官方转换。 +- 关闭时,仅建立临时 Atlas 元数据副本并把 `pma: true` 改为 `pma: false`,使官方解包器保留打包像素。 +- 不再由 Qt 解码、逐像素处理或重新保存解包图片,避免 JPEG/WebP 二次有损编码以及错误的重复去预乘。 + +## 4. Atlas Scale 与工程导入 + +Texture Unpacker 输出的是 Atlas 实际打包尺寸。例如页面声明 `scale: 0.5` 时,解出的图片也是原图的一半像素尺寸,这不是压缩错误。 + +生成工程时必须把同一 Atlas 传给官方导入参数: + +```text +Spine -u <本地精确缓存版本> + -i <骨骼 JSON 或 SKEL> + -s + -o <目标 .spine> + --import +``` + +`-s <数值>` 让 Editor 按 Atlas 的打包比例缩放骨骼、附件和动画数据。使用数值是为了兼容 3.8—4.3;旧版 Editor 不接受 Atlas 路径作为 `-s` 的值。遗漏此参数会形成“附件位置沿用原始坐标、解包图片却是半尺寸”的工程,表现为局部图片缩小和脱节。 + +多页 Atlas 必须使用一致的 `scale`;未声明时按 `1.0`。若各页比例不同,任务会说明原因并停止,避免生成内部尺寸不一致的工程。 + +## 5. 验证与失败规则 + +每次解包后至少检查: + +- 进程正常结束。 +- `images/` 中存在可识别的图片输出。 +- 最终 `.spine` 文件存在且非空。 +- 3.8、4.0、4.1、4.2、4.3 均使用对应版本 Editor 做导入回归。 + +任何一步失败都弹窗显示所属阶段和 Spine 返回的原因,不切换到非官方实现。 diff --git a/DateToSpine/docs/07-测试策略.md b/DateToSpine/docs/07-测试策略.md new file mode 100644 index 0000000..623f271 --- /dev/null +++ b/DateToSpine/docs/07-测试策略.md @@ -0,0 +1,195 @@ +# 测试策略 + +## 1. 测试目标 + +测试需要证明四件事: + +1. 每个目标版本使用正确 Runtime 和 Editor。 +2. 预览结果在语义和视觉上正确。 +3. 生成 `.spine` 后运行时主体数据没有意外丢失。 +4. 断网、损坏输入和批量失败情况下行为安全。 + +## 2. 测试层级 + +### 2.1 单元测试 + +不启动 Spine Editor: + +- 版本字符串解析 +- Spine JSON schema 识别 +- SKEL 头部有界探测 +- Atlas parser +- 自动配对评分 +- 路径规范化和逃逸防护 +- 输出重名策略 +- 恢复状态机 +- 报告序列化 +- Editor 命令参数生成 + +### 2.2 组件测试 + +- 每个 Runtime 插件独立加载相应版本资源。 +- Texture Provider 解码 PNG、JPG、WebP。 +- 统一 draw list 校验。 +- 官方解包结果的尺寸、Alpha 与 golden image 对比。 +- Editor Locator 使用模拟注册信息和目录结构。 + +### 2.3 Editor 集成测试 + +需要本地已激活的 Spine Editor: + +- `--version` 探测 +- JSON 导入 +- SKEL 导入 +- Atlas 官方解包 +- 临时工程导出规范化 JSON +- 最终 `.spine` 可重新读取 +- 取消、超时和错误码映射 + +### 2.4 端到端测试 + +从目录扫描开始,经过预览、确认、解包、导入、验证和发布。检查输入目录完全未改变。 + +## 3. 版本矩阵 + +每个版本至少覆盖: + +| 版本 | JSON | SKEL | 预览 | 官方解包 | 工程生成 | 语义回读 | +|---|---:|---:|---:|---:|---:|---:| +| 3.8.20+ | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 | +| 4.0.x | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 | +| 4.1.x | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 | +| 4.2.x | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 | +| 4.3.x | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 | + +## 4. 功能样本矩阵 + +- Region +- Unweighted/Weighted/Linked Mesh +- IK、Transform、Path constraints +- Clipping +- Deform +- Draw order +- Events +- 多动画、多 Skin +- Two Color Tint +- Normal/Additive/Multiply/Screen +- 单页、多页 Atlas +- PMA、Straight Alpha +- PNG、JPG/JPEG、WebP +- Atlas scale +- Polygon packing + +不要求一个样本覆盖所有功能,采用可追踪的样本能力清单。 + +## 5. 官方测试资源 + +开发阶段从官方 `EsotericSoftware/spine-runtimes` 仓库各版本分支取得对应样例: + +```text +3.8 +4.0 +4.1 +4.2 +4.3 +``` + +每份资源由 manifest 记录: + +```text +case id +source repository +branch +commit hash +relative paths +data version +features +expected animations/skins/counts +license reference +sha256 +``` + +规则: + +- 不用 4.3 资源测试旧 Runtime。 +- 测试基于固定 commit,不追随浮动分支。 +- 官方资源只放在开发测试缓存或 CI 临时空间。 +- `tests/fixtures/external/` 不进入发行打包输入。 +- 发行产物检查中把官方资源文件名和哈希作为禁止项。 +- 未经许可确认,不把官方资源提交到对外发布制品。 + +## 6. 自有与生成测试数据 + +除官方样例外,创建可自由分发的最小数据用于: + +- 安装后冒烟测试 +- 损坏输入测试 +- Unicode 路径测试 +- 极限尺寸测试 +- 输出命名冲突测试 + +生成数据的来源、生成脚本和许可必须清晰。若生成 `.spine`/导出数据需要 Spine Editor,构建产物仍按许可审查。 + +## 7. 视觉回归 + +在固定 viewport、背景、时间点、Skin 和动画下截图: + +- 与批准的 golden image 比较。 +- 对 JPG/WebP 使用合理像素误差。 +- 对 Alpha 边缘增加专门区域检测。 +- 单独测试 PMA 黑边、Straight Alpha 白边问题。 +- GPU 差异造成的小量误差采用阈值,不使用完全逐字节相等。 + +## 8. 语义回归 + +转换前后生成规范摘要并比较: + +```text +bones +slots +skins +attachments by type +constraints by type +animations and duration +events +deform timelines +draw-order timelines +``` + +Nonessential 数据单独报告,不与运行时语义失败混合。 + +## 9. 异常与安全测试 + +- 截断 SKEL +- 超长字符串和伪造数组长度 +- 深层/异常 JSON +- Atlas 路径逃逸 +- 重复 region 和页面 +- 缺失或损坏图片 +- 巨大图片和解压炸弹式输入 +- Editor 路径失效 +- Editor 未激活 +- 子进程卡住、崩溃、返回非零 +- 用户在各阶段取消 +- 输出目录无权限或空间不足 + +## 10. 离线测试 + +在 macOS 和 Windows 上分别执行: + +1. 准备并验证对应 Editor 版本。 +2. 禁用网络接口或在隔离环境阻断网络。 +3. 启动 DateToSpine。 +4. 完成扫描、预览、官方解包、生成和回读。 +5. 记录任何网络连接尝试。 +6. 确认不存在 CDN、在线字体、在线 Shader、更新或遥测依赖。 + +## 11. 发行物检查 + +对最终 `.app/.dmg/.exe/installer` 解包检查: + +- 不含官方测试素材。 +- 不含 Spine Editor。 +- 包含 Runtime License 和 Third-Party Notices。 +- 包含所需 Qt 图片插件,WebP 在断网环境可用。 +- 不含开发日志、绝对源码路径、测试缓存或调试密钥。 diff --git a/DateToSpine/docs/08-离线打包与许可.md b/DateToSpine/docs/08-离线打包与许可.md new file mode 100644 index 0000000..067166f --- /dev/null +++ b/DateToSpine/docs/08-离线打包与许可.md @@ -0,0 +1,133 @@ +# 离线、打包与许可 + +## 1. 离线定义 + +DateToSpine 的“全功能离线”定义为: + +- 应用本身不建立网络连接。 +- 不使用 CDN、远程字体、远程 Shader、WebView 在线页面或在线 API。 +- 不在运行时下载 Qt、Runtime、图片解码器或其他依赖。 +- 不上传用户资源、路径、日志或统计数据。 +- 不包含自动更新和联网遥测。 +- 用户已经准备好的匹配版本 Spine Editor 可在断网环境中完成转换。 + +Spine Editor 是外部前置软件。DateToSpine 不控制 Editor 自身的授权策略,但必须在断网验收中验证既有激活和已缓存版本能够完成流程。 + +## 2. 构建期与运行期网络 + +- 开发机可以联网取得依赖和官方测试资源。 +- 正式依赖必须锁定版本和校验值。 +- 可复现构建应支持从内部或本地依赖缓存构建。 +- 最终应用运行时不得使用网络。 +- CI 下载的官方测试资源不进入打包阶段的输入集合。 + +## 3. macOS 打包 + +目标: + +- Apple Silicon 原生包。 +- Intel 或 Universal 2 是否首发,由原型性能和构建环境决定。 +- `.app` 和 `.dmg`。 +- Qt framework、platform plugin、image plugin、Runtime 插件和本地资源完整部署。 +- 正式发行执行代码签名和 notarization;这是发布流程,不是最终应用运行时联网依赖。 + +应用不得修改用户的 Spine.app 或其缓存。 + +## 4. Windows 打包 + +目标: + +- Windows 10/11 x64。 +- MSVC 2022 v143。 +- 使用 `/MD` 与 Qt 官方构建一致。 +- 部署 MSVC Runtime、Qt platform plugin、imageformats 和 Runtime 插件。 +- 安装包格式在原型后从 MSIX、WiX/MSI 或 Inno Setup 中确定。 +- 正式发行建议代码签名。 + +最终用户不需要安装 CMake、Visual Studio 或 Qt SDK。 + +## 5. Runtime 许可 + +项目集成官方 Spine Runtimes 时必须遵守: + +- [Spine Runtimes License Agreement](https://esotericsoftware.com/spine-runtimes-license) +- [Spine Editor License Agreement](https://esotericsoftware.com/spine-editor-license) + +发行前需要法务或产品负责人确认最终分发模式。技术实现至少保证: + +- `licenses/` 保存完整、未修改的 Runtime 许可文本。 +- 安装包包含 Runtime 许可和版权声明。 +- About/关于 页面提供第三方许可入口。 +- 不隐藏用户需要合法 Spine 许可的前提。 +- 不分发 Spine Editor、激活信息或 Editor 更新文件。 +- Runtime 插件不作为通用 SDK 对外提供开发接口。 + +## 6. 官方测试资源 + +已确认规则: + +- 官方 GitHub 仓库资源只用于开发、兼容性测试和 CI。 +- 官方测试资源不进入最终安装包。 +- 打包清单不包含 `tests/fixtures/external/`。 +- 发行检查使用路径、文件名和 SHA-256 禁止列表防止误打包。 +- 测试报告可记录资源 case id 和哈希,但不嵌入资源文件。 + +## 7. 第三方依赖清单 + +每个依赖记录: + +```text +name +version/commit +source URL +license +modified or unmodified +linked statically or dynamically +included platforms +notices path +``` + +至少包括: + +- Qt 6 +- Spine Runtimes 3.8/4.0/4.1/4.2/4.3 +- 测试框架 +- 可能引入的日志或打包依赖 + +新增依赖必须经过:离线可部署性、许可证、维护状态、体积和安全评审。 + +## 8. 网络禁止措施 + +- 不链接应用层 HTTP 客户端,除非未来需求重新评审。 +- 资源加载器只接受本地文件协议。 +- 代码扫描阻止 CDN URL 和动态依赖下载逻辑。 +- 集成测试在网络阻断环境运行。 +- 打包后执行连接监控测试。 + +文档中的官方链接仅供人阅读,不会被应用自动访问。 + +## 9. 发行目录白名单 + +打包采取白名单而不是“复制整个构建目录”: + +```text +application executable +Qt runtime libraries/plugins +dts runtime plugins +icons/translations/shaders +licenses/notices +user documentation +``` + +明确排除: + +```text +tests/ +official sample assets +source repository metadata +developer caches +temporary recovery jobs +Spine Editor binaries +activation information +debug-only symbols(独立保存,不进入普通安装包) +``` diff --git a/DateToSpine/docs/09-开发计划.md b/DateToSpine/docs/09-开发计划.md new file mode 100644 index 0000000..40bfb0a --- /dev/null +++ b/DateToSpine/docs/09-开发计划.md @@ -0,0 +1,131 @@ +# 开发计划 + +## 1. 开发原则 + +- 先验证风险,再建设完整 UI。 +- 每个阶段有可运行结果和退出标准。 +- 未通过技术关卡时不扩大代码量掩盖问题。 +- 文档、测试和代码同步更新。 +- 当前文档批准后才进入功能开发。 + +## 2. 阶段 0:技术原型 + +### 目标 + +验证决定产品能否成立的核心问题。 + +### 工作项 + +1. 在 macOS 上发现并验证已安装 Spine Editor。 +2. 验证自定义 `.app`/可执行文件路径。 +3. 调用 3.8、4.0、4.1、4.2、4.3 本地版本。 +4. 为五个版本各构建最小 `spine-cpp` adapter。 +5. 用 JSON、SKEL、Atlas 和纹理加载一帧预览数据。 +6. 验证 QOpenGLWidget 绘制 Region、Mesh、Clipping 和混合模式。 +7. 验证官方 Texture Unpacker。 +8. 验证 SKEL → 临时 `.spine` → JSON → 设置 images → 最终 `.spine`。 +9. 验证断网运行。 +10. 在 Windows/MSVC 至少完成构建和 Editor CLI 冒烟测试。 + +### 技术关卡 + +- 五套 Runtime 可以隔离构建并通过统一 ABI 加载。 +- 五个版本至少各有一个 JSON/SKEL 可预览。 +- 本地 Editor 路径和版本能可靠识别。 +- JSON/SKEL 可以生成可回读 `.spine`。 +- 图片路径能自动关联,或明确证明需要采用哪种降级方案。 +- 官方解包和 PMA 行为被记录。 +- 断网时已准备版本可执行完整流程。 + +任何关卡失败都先更新架构决策,不进入大规模 UI 开发。 + +## 3. 阶段 1:工程骨架与领域模型 + +- CMake Presets 和平台 toolchain。 +- 目录、target 和第三方依赖边界。 +- `AssetGroup`、`SpineVersion`、`RecoveryJob`。 +- 文件系统、进程和报告接口。 +- 基础错误码和状态机。 +- 单元测试框架和 CI 基线。 +- 自研代码行数检查。 + +退出标准:macOS/Windows Debug 与 Release 构建通过,核心模型测试通过。 + +## 4. 阶段 2:扫描、配对与预检 + +- 递归扫描。 +- JSON/SKEL 版本识别。 +- Atlas parser。 +- 页面解析和图片校验。 +- 资源组配对评分。 +- 手动修正模型。 +- Unicode、长路径和安全边界。 + +退出标准:官方样例和异常样例能形成确定、可解释的资源组。 + +## 5. 阶段 3:离线预览器 + +- 五个 Runtime 插件完整实现。 +- Texture Provider。 +- OpenGL 批次渲染。 +- 动画、Skin、Seek、速度控制。 +- 调试显示。 +- 视觉回归测试。 + +退出标准:五个版本功能矩阵通过,拔网预览正常。 + +## 6. 阶段 4:Editor 管理与转换 + +- macOS/Windows 自动发现。 +- 自定义路径 UI。 +- 版本能力映射。 +- Spine CLI 封装。 +- JSON/SKEL 导入。 +- 图片路径规范化。 +- 语义回读验证。 +- 原子发布和恢复报告。 + +退出标准:五个版本端到端转换通过,输入未改变。 + +## 7. 阶段 5:Atlas 解包 + +- 官方解包器唯一流程。 +- 官方结果验证。 +- PMA、Scale、rotate、offset、多页和 polygon packing。 +- PNG/JPG/WebP 测试。 + +退出标准:官方解包与失败原因可观察、可报告,且各版本具有视觉回归。 + +## 8. 阶段 6:批处理和用户体验 + +- 任务队列、取消和失败隔离。 +- 批量重名策略。 +- 进度、日志和错误建议。 +- 设置迁移和会话恢复。 +- 中英文界面。 +- 无障碍和高 DPI 检查。 + +## 9. 阶段 7:发布准备 + +- macOS/Windows 安装包。 +- Qt 和图片插件部署。 +- Runtime 许可与 Third-Party Notices。 +- 官方测试资源排除检查。 +- 离线端到端测试。 +- 签名、公证和干净机器安装测试。 +- 性能、内存和损坏输入测试。 + +## 10. 建议评审点 + +每阶段完成后提交: + +- 功能演示 +- 自动测试结果 +- 新增风险 +- 文件长度报告 +- 依赖与许可变更 +- 下一阶段范围 + +## 11. 编码顺序建议 + +开始开发后的第一个提交不应是完整项目 UI,而应是阶段 0 的最小原型和实验记录。技术原型通过后,再固化公共接口和正式目录。 diff --git a/DateToSpine/docs/10-风险与架构决策.md b/DateToSpine/docs/10-风险与架构决策.md new file mode 100644 index 0000000..2a126fd --- /dev/null +++ b/DateToSpine/docs/10-风险与架构决策.md @@ -0,0 +1,102 @@ +# 风险与架构决策 + +## 1. 风险台账 + +| ID | 风险 | 影响 | 应对 | +|---|---|---|---| +| R-01 | 不同 Spine 版本二进制格式不兼容 | 错误预览或导入失败 | 五套匹配 Runtime;Editor 主/次版本强校验 | +| R-02 | 3.8 早期格式变化 | 部分 3.8 文件无法读取 | 正式范围从 3.8.20 开始;旧文件尽力识别 | +| R-03 | SKEL 不含可直接修改的图片路径 | 生成工程显示 Missing | Editor 双重导入/导出原型;输出布局降级方案 | +| R-04 | 五套 Runtime 符号/API 冲突 | 无法链接或运行不稳定 | 独立动态模块、隐藏符号、稳定 C ABI | +| R-05 | 损坏输入使 Runtime 崩溃 | 主程序退出 | 有界预检、模糊测试;必要时改 helper process | +| R-06 | Spine Launcher 缺少目标缓存版本 | 离线转换失败 | 版本能力预检;不自动下载;明确修复提示 | +| R-07 | Editor 自身的联网或授权行为 | 离线验收失败 | 真正断网测试;明确前置条件;不修改或绕过 Editor | +| R-08 | PMA/WebP 边缘错误 | 预览或解包出现色边 | 格式矩阵与 Alpha 视觉回归;Alpha 处理交给官方解包器 | +| R-09 | Polygon-packed Mesh 难以还原原图 | 图片包含邻近像素或裁切错误 | 仅使用官方解包器;失败时报告限制并终止 | +| R-10 | 官方测试素材误入安装包 | 许可与体积风险 | 打包白名单、哈希禁止清单、发行物解包检查 | +| R-11 | Qt/Runtime 许可遗漏 | 无法合规发行 | 依赖清单、licenses、About 页面、发行关卡 | +| R-12 | 自定义路径和 Unicode 处理错误 | Editor 无法启动 | 参数数组、canonical path、双平台路径测试 | +| R-13 | 巨大资源耗尽内存 | 卡死或崩溃 | 输入限制、预算检查、按需解码、可取消任务 | +| R-14 | 跨补丁 Editor 行为变化 | 同一主/次版本结果不同 | 记录实际补丁;固定测试矩阵;报告转换环境 | + +## 2. 已接受架构决策 + +### ADR-001:生成 `.spine` 使用官方 Editor + +- 状态:接受 +- 决策:调用本地已授权 Spine Editor,不自行写 `.spine`。 +- 原因:`.spine` 是编辑工程格式;官方 CLI 已提供数据导入能力。 + +### ADR-002:版本范围为 3.8.20+ 至 4.3 + +- 状态:接受 +- 决策:3.8.20+、4.0、4.1、4.2、4.3;不支持 3.8 以下。 +- 原因:覆盖目标游戏资源,同时控制旧格式维护成本。 + +### ADR-003:预览进入首版 + +- 状态:接受 +- 决策:用户确认转换前必须可离线预览。 +- 原因:避免批量生成后才发现配对、Scale、Alpha 或版本错误。 + +### ADR-004:每个主/次版本独立 Runtime + +- 状态:接受 +- 决策:五个 Runtime 插件,通过统一 C ABI 接入。 +- 原因:官方要求 Runtime 与数据主/次版本同步,API 也会变化。 + +### ADR-005:仅使用官方 Atlas 解包 + +- 状态:接受 +- 决策:官方 Texture Unpacker 是唯一纹理解包实现;失败时任务失败。 +- 原因:官方对自身 Atlas、PMA、旋转和多边形打包语义最权威,且软件以前置正版 Spine 为使用条件。 + +### ADR-006:运行期全离线 + +- 状态:接受 +- 决策:无 CDN、下载、遥测、资源上传和在线 API。 +- 原因:资源敏感性、稳定性和用户要求。 + +### ADR-007:官方测试资源不发行 + +- 状态:接受 +- 决策:官方资源只用于开发/CI,最终安装包不包含。 +- 原因:用户明确要求,并减少许可与安装体积风险。 + +### ADR-008:Qt 6 + CMake + MSVC/Apple Clang + +- 状态:接受 +- 决策:主程序 C++20,CMake 3.25+,Windows MSVC v143。 +- 原因:跨平台 UI、进程管理、图片插件和成熟构建生态。 + +## 3. 原型后待决策事项 + +### ADR-P01:Runtime 插件还是 helper process + +默认先验证动态插件。若损坏文件可导致不可接受的进程级崩溃,则采用 helper process 隔离。 + +### ADR-P02:macOS 架构 + +首发 Apple Silicon 或 Universal 2,根据目标用户和五套 Runtime 构建成本决定。 + +### ADR-P03:测试框架 + +Qt Test 与 Catch2 二选一,以 Runtime 插件和参数化兼容矩阵的便利性为主要标准。 + +### ADR-P04:SKEL 图片路径自动化方案 + +由 Editor 双重导入/导出原型结果决定正式流程及是否存在可接受降级。 + +### ADR-P05:Windows 安装器 + +在 MSIX、WiX/MSI、Inno Setup 中选择,需满足离线安装、签名和干净卸载。 + +## 4. 决策变更规则 + +变更已接受决策时必须记录: + +- 变更原因 +- 新证据或测试结果 +- 对范围、兼容性和许可的影响 +- 数据迁移或设置迁移方案 +- 负责人和日期 diff --git a/DateToSpine/docs/11-阶段0技术原型记录.md b/DateToSpine/docs/11-阶段0技术原型记录.md new file mode 100644 index 0000000..89e8e1d --- /dev/null +++ b/DateToSpine/docs/11-阶段0技术原型记录.md @@ -0,0 +1,93 @@ +# 阶段 0 技术原型记录 + +## 记录日期 + +2026-09-07 + +## 当前结论 + +首批工程骨架已经建立并在 macOS Apple Silicon 上通过构建测试。此阶段证明多版本 Runtime 隔离、基础输入识别、本地 Editor 发现和跨平台进程抽象可以按既定架构继续推进;尚未证明完整动画预览或 `.spine` 恢复流程。 + +## 本机环境 + +- Apple Clang 17 +- CMake 4.4.3(项目最低要求仍为 3.25) +- Ninja 1.13.2 +- Qt Base 6.11.2 +- Qt Image Formats 6.11.2 +- Spine Launcher 4.3.06 Apple Silicon +- Launcher 当前观察到启动 Spine Editor 3.8.99 Professional + +只安装 Qt Base 与 Qt Image Formats,没有安装 Qt WebEngine、Location、NetworkAuth 等与产品无关的 Qt 元组件。 + +## 固定 Runtime 提交 + +| Runtime | 官方分支 | 提交 | +|---|---|---| +| 3.8 | `3.8` | `8b4844bd4b193ba9e54487ed397a777993cbad56` | +| 4.0 | `4.0` | `425ce416bb218b28caeec47b317aa57cd7140375` | +| 4.1 | `4.1` | `77a5db0ec6d16331f5efbaa7662bba9355bd3424` | +| 4.2 | `4.2` | `e7dc1435fa4a0083ab431f1b28e083c14a1f5c68` | +| 4.3 | `4.3` | `4309c05c287d3f15da778e68f5d2a483fe10a6a3` | + +当前只展开各分支的 `spine-cpp` 源码。4.3 官方 Spineboy 资源仅用于开发测试,不进入安装目标和发行包。 + +## 已实现 + +- CMake/C++20 工程和 macOS、Windows Preset。 +- 标准 C++ 核心库,不依赖 Qt。 +- Spine 版本值对象和正式支持范围判断。 +- JSON 最小 Spine schema 与版本探测。 +- SKEL 二进制头部 hash/version 有界读取。 +- Atlas 多页、page、region、PMA、scale、rotate、bounds/legacy 字段基础解析。 +- macOS 常见 Spine 路径发现和 `.app` 规范化。 +- Windows 常见路径骨架和 `.exe` → `.com` 规范化。 +- 不经过 shell 的 POSIX/Windows 子进程执行器。 +- 超时后终止本软件启动的整个子进程树。 +- Launcher 版本与实际 Editor 版本分离解析。 +- 五个 Runtime 独立静态目标和动态 adapter。 +- 稳定 C ABI 版本与 metadata 加载验证。 +- 极简 Qt 桌面外壳:资源拖放/选择、资源切换、动画切换和一键转换。 +- Editor 自动发现;仅在自动发现失败时请求自定义路径。 +- 4.3 Runtime 已实际加载官方 JSON、SKEL 和 Atlas,并输出动画/Skin/骨骼元数据。 +- 4.3 Runtime 已输出裁剪后的纹理三角形,Qt 画布可循环播放并切换真实动画。 +- 4.3 beta 后缀版本和新版 SKEL 文件头识别。 +- CLI:Editor 发现、路径探测、数据版本检查。 + +## 构建与测试结果 + +- `cmake --preset macos-debug`:通过。 +- `cmake --build --preset macos-debug`:通过。 +- 五个官方 Runtime:通过 Apple Clang 编译。 +- 五个 Runtime adapter:通过动态加载和 ABI/版本检查。 +- 核心单元测试:全部通过。 +- 官方 Spineboy JSON/SKEL 真实加载测试:通过,识别到 11 个动画。 +- Qt 应用 offscreen 启动冒烟测试:通过。 + +Spine 4.3 上游源码在 Apple Clang 下产生一条未覆盖枚举值的 warning。第三方源码没有被修改,该警告暂不影响构建,后续记录在上游警告基线中。 + +## 实际 Editor 探测发现 + +直接执行本机 `Spine --version` 时先报告 Launcher 4.3.06,随后启动 3.8.99 Professional。旧版 launcher 在当前系统产生字体兼容警告,并且探测可能长时间不退出。 + +因此代码已经采用: + +- 分离解析 Launcher 和 Editor 版本。 +- 探测超时。 +- 进程树终止。 +- “已观察到 Spine 但探测超时”独立状态。 + +仅看到 Launcher 版本不能代表目标 Editor 版本可用。 + +## 尚未完成的阶段 0 关卡 + +1. 对 3.8—4.2 执行真实资源预览。 +2. 目标 Editor 版本选择与离线缓存能力验证。 +3. 官方 Texture Unpacker 调用与结果验证。 +4. JSON/SKEL 到 `.spine` 的端到端恢复。 +5. SKEL 图片路径双重导入/导出方案验证。 +6. Windows MSVC 构建与真实 Editor 测试。 + +## 下一步 + +先扩展 Runtime C ABI,以 4.3 adapter 实现真实资源加载、动画/Skin 元数据与 draw list;通过后把版本差异分别适配到 4.2、4.1、4.0 和 3.8。随后接 Qt OpenGL 预览画布,再进入 Editor 导入和官方解包流水线。 diff --git a/DateToSpine/docs/12-极简界面阶段记录.md b/DateToSpine/docs/12-极简界面阶段记录.md new file mode 100644 index 0000000..4a3624e --- /dev/null +++ b/DateToSpine/docs/12-极简界面阶段记录.md @@ -0,0 +1,26 @@ +# 极简界面阶段记录 + +## 交互结论 + +主界面已经收敛为“拖入、预览、切换动画、一键转换”。版本号、文件格式、校验状态和 Editor 管理等技术信息不再常驻显示。 + +## 当前可查看产出 + +- 空状态支持拖入 `.json`、`.skel`、`.atlas`、纹理或整个目录。 +- 多套资源通过顶部单一选择框切换。 +- 4.3 官方 Spineboy JSON/SKEL 可读取,动画选择框显示 11 个真实动画。 +- 当前画布已使用官方 4.3 Runtime 绘制真实骨骼姿态,并默认循环播放 `idle`。 +- 动画下拉框会实际切换 Runtime 动画,不再只是显示名称。 +- “一键转换为 .spine”按钮已接入本地 Spine Editor 自动发现和异步导入入口。 +- 自动发现 Editor 失败时才弹出路径选择,不再提供独立管理窗口。 +- 软件没有网络请求。 + +界面截图:`阶段产出/极简界面-资源已载入.png`。 + +## 尚未宣称完成 + +- 当前真实动画渲染已覆盖 4.3,3.8—4.2 尚未接入统一绘制接口。 +- 一键转换入口已形成,但官方解包、图片路径恢复和生成物回读验证尚未串成最终流水线。 +- 3.8—4.2 Runtime 仍需补齐真实数据加载和绘制接口。 + +这些未完成项不会通过增加设置页面暴露给用户,仍保持后台自动化和极简主流程。 diff --git a/DateToSpine/docs/13-四点三动画预览记录.md b/DateToSpine/docs/13-四点三动画预览记录.md new file mode 100644 index 0000000..dd0f653 --- /dev/null +++ b/DateToSpine/docs/13-四点三动画预览记录.md @@ -0,0 +1,40 @@ +# 4.3 动画预览阶段记录 + +## 阶段结果 + +4.3 官方 Runtime 已从“只读资源信息”升级为真实动画绘制。主界面现在会直接显示骨骼角色,而不是整张 Atlas 纹理。 + +## 已实现 + +- Runtime ABI 增加动画选择、逐帧更新和绘制命令接口。 +- 4.3 adapter 创建 `Skeleton`、`AnimationState` 和 `SkeletonRenderer`。 +- 输出世界坐标、UV、颜色、索引、纹理路径和混合模式。 +- Runtime 内部完成 Mesh 和 Clipping 处理。 +- Qt 独立预览画布按纹理三角形绘制并自动适应窗口。 +- 支持 Normal、Additive、Multiply、Screen 四种混合模式映射。 +- 默认循环播放动画列表第一项;列表为空时保持静态姿态,不启动动画播放。 +- 用户切换下拉框时立即切换动画并循环播放。 +- 预览画布尺寸独立于每帧骨骼边界;首次加载后锁定视口,动画过程不再忽大忽小。 +- 默认以画布可容纳的最大比例显示,并保留少量安全边距。 +- 滚动鼠标中键时,以鼠标所在位置为中心缩放画布。 +- 单击鼠标中键时重置缩放和画布位置。 +- 按住鼠标右键拖动时平移画布;右键短按时回到中心。 +- 软件绘制路径消除了逐三角形抗锯齿造成的 Mesh 接缝。 + +## 测试证据 + +官方 Spineboy 4.3 JSON 测试已经验证: + +- 动画可被选择。 +- 每帧能产生绘制命令、顶点和三角形索引。 +- 绘制命令引用的纹理文件存在。 +- 时间推进后世界坐标确实发生变化。 +- JSON 和 SKEL 仍能重复加载。 + +阶段截图:`阶段产出/骨骼动画预览-4.3.png`。 + +固定画布截图:`阶段产出/固定画布最大显示-4.3.png`。 + +## 后续范围 + +下一步复用相同 ABI 分别适配 4.2、4.1、4.0 和 3.8。各版本仍使用独立官方 Runtime,不把跨版本差异塞入界面代码。 diff --git a/DateToSpine/docs/14-离线转换与多版本回归记录.md b/DateToSpine/docs/14-离线转换与多版本回归记录.md new file mode 100644 index 0000000..2e5e4cf --- /dev/null +++ b/DateToSpine/docs/14-离线转换与多版本回归记录.md @@ -0,0 +1,81 @@ +# 离线转换与多版本回归记录 + +## 1. 本阶段结果 + +- 资源载入完成后自动检测匹配版本线,并在顶部显示是否可转换。 +- 4.3 资源在本机使用已缓存的 Spine `4.3.23`,不再把资源导出补丁号直接传给 Launcher。 +- 只传精确缓存版本;禁止 `latest`、通配版本和 `--force`。 +- 缓存缺失时不启动 Spine,转换任务内部也会二次拦截。 +- 成功和失败都显示弹窗;失败弹窗包含具体阶段与进程输出摘要。 +- 最终结果只包含 `.spine` 和 `images/`,不包含原始 Atlas 或打包纹理页。 +- 底部增加“去除预乘 Alpha”开关,默认关闭。 + +界面产出:[离线转换与去预乘开关.png](阶段产出/离线转换与去预乘开关.png) + +## 2. 输出结构 + +```text +选择的输出位置/ + spineboy-pro/ + spineboy-pro.spine + images/ + head.png + torso.png + ... +``` + +同名目录存在时使用 `-2`、`-3` 后缀。所有内容先写入同一输出磁盘的临时目录,校验成功后再整体发布;失败不会留下伪成功目录。 + +## 3. Alpha 处理 + +官方 CLI 没有独立的去预乘开关,而是读取 Atlas 的 `pma` 值。为让 3.8—4.3 具有统一、可控的行为: + +1. 原始 Atlas 与纹理页始终只读。 +2. 开启开关时原样传入 Atlas,由官方 Texture Unpacker 根据 `pma` 完成转换。 +3. 关闭开关时建立临时 Atlas 元数据副本,将 `pma:true` 改为 `pma:false`,再由官方解包器保留打包像素。 +4. 不使用 Qt 对输出图片做逐像素变换或重新编码。 +5. 临时 Atlas 随任务工作区清理,不进入结果目录。 + +## 4. 工程图片尺寸问题 + +已确认根因不是官方解包器压缩图片。测试 Atlas 声明 `scale: 0.5`,官方解包得到的 `head.png` 为 136×149、`front-shin.png` 为 41×92,符合实际打包尺寸。旧流程导入骨骼数据时没有传 Atlas Scale,导致附件坐标保持原始尺寸,而图片只有打包尺寸,于是局部图片缩小、脱节。 + +修复后的工程导入读取 Atlas 的 `scale`,再增加 `-s <数值>`,由匹配版本 Spine Editor 统一缩放骨骼、附件与动画数据。使用数值可以兼容不接受 Atlas 路径参数的 4.0 等旧版 Editor。 + +## 5. 多版本预览回归 + +每条版本线使用对应官方 `spine-cpp` 和该分支的 Spineboy 开发样本。样本仅用于开发测试,不进入安装包。 + +| 版本 | JSON | SKEL | 动画选择 | 绘制帧 | +|---|---|---|---|---| +| 3.8 | 通过 | 通过 | 通过 | 通过 | +| 4.0 | 通过 | 通过 | 通过 | 通过 | +| 4.1 | 通过 | 通过 | 通过 | 通过 | +| 4.2 | 通过 | 通过 | 通过 | 通过 | +| 4.3 | 通过 | 通过 | 通过 | 通过 | + +3.8—4.1 共享一个旧版绘制适配层,4.2 和 4.3 使用各自 Runtime 自带的 `SkeletonRenderer`,避免将版本差异堆叠到主界面代码。 + +### 5.1 旧版 UV 方向修正 + +3.8—4.2 的 Runtime 输出沿用 Y 轴向上的世界坐标,而当前 Qt 画布以左上角为原点。适配层在输出预览顶点时对这些版本执行 `y = -y`;旧版 UV 已经与 Runtime 的三角形顶点顺序配套,不能再次翻转。4.3 已符合当前预览器方向,不执行该转换。 + +修复后截图:[旧版预览方向修复-4.2.png](阶段产出/旧版预览方向修复-4.2.png) + +## 6. 本机实际转换回归 + +所有命令均选择已经存在的精确缓存版本: + +| 资源线 | 本地 Editor | JSON 导入 | SKEL 导入 | 解包图片数 | +|---|---:|---:|---:|---:| +| 3.8 | 3.8.99 | 前一轮通过;本轮缓存不可用 | 前一轮通过;本轮缓存不可用 | 40(前一轮) | +| 4.0 | 4.0.64 | 55,295 字节 | 58,616 字节 | 40 | +| 4.1 | 4.1.24 | 55,483 字节 | 58,908 字节 | 40 | +| 4.2 | 4.2.43 | 50,811 字节 | 54,940 字节 | 40 | +| 4.3 | 4.3.23 | 49,603 字节 | 49,905 字节 | 40 | + +4.2 A/B 回导验证:未传比例时 `hip.y=247.27`、前胫骨长度为 `128.77`;传 `-s 0.5` 后分别为 `123.64` 和 `64.39`,Mesh 顶点也同步减半,而图片维持官方解包尺寸。这说明工程数据与图片比例已恢复一致。 + +本轮 3.8 缓存文件在回归前已不可用,ARM Launcher 无法加载旧 x86 版本,因此遵守离线约束,没有尝试联网补齐。3.8 官方样本不声明 Atlas `scale`,修复路径会使用 `1.0`,与原导入行为一致;待本机重新具备可运行的 3.8 Editor 后仍需补跑本轮实机导入。 + +自动测试与完整构建通过。官方测试资源仍位于开发期 `third_party` 目录,安装规则只复制 Runtime 许可文件。 diff --git a/DateToSpine/docs/文档索引.md b/DateToSpine/docs/文档索引.md new file mode 100644 index 0000000..f59b38c --- /dev/null +++ b/DateToSpine/docs/文档索引.md @@ -0,0 +1,61 @@ +# DateToSpine 开发文档 + +## 文档状态 + +- 状态:技术原型开发中,极简主界面已形成 +- 文档基线:2026-09-07 +- 当前目录包含设计文档、C++/Qt 工程、Runtime 适配器和自动测试 + +## 产品概述 + +DateToSpine 是一个面向 macOS 和 Windows 的离线桌面工具,用于扫描、预览并批量恢复 Spine 游戏运行时资源: + +```text +.skel/.json + .atlas/.atlas.txt + texture pages + ↓ + 可编辑的 .spine 工程 +``` + +软件内置匹配版本的官方 `spine-cpp` Runtime 以实现转换前预览。生成 `.spine` 时,用户电脑必须已经安装、激活并准备好与资源版本匹配的 Spine Editor。 + +## 已确认的产品决策 + +1. 正式支持 Spine `3.8.20+`、`4.0.x`、`4.1.x`、`4.2.x`、`4.3.x`。 +2. 不支持 Spine 3.8 以下版本。 +3. 转换前必须支持离线预览,用户确认后才生成 `.spine`。 +4. 五个 Spine 主/次版本使用各自对应的官方 Runtime。 +5. 生成 `.spine` 必须调用本地已授权的 Spine Editor,不自行编写或逆向 `.spine` 文件。 +6. Atlas 纹理只使用 Spine 官方 Texture Unpacker 解包,不提供自研兜底。 +7. 软件运行期不使用 CDN、在线 API、在线依赖下载、遥测或资源上传。 +8. 官方测试资源仅用于开发和 CI 测试,不进入最终安装包。 +9. 最终安装包只携带 Runtime 许可、第三方许可和必要声明,不携带 Spine Editor 或官方示例资源。 +10. 自研源文件按职责拆分,禁止出现数千行的业务代码文件。 +11. 主界面只保留拖入资源、预览/动画切换和一键转换;仅在 Editor 自动识别失败时请求路径。 + +## 文档索引 + +- [01-产品需求.md](01-产品需求.md):产品范围、用户流程和验收标准 +- [02-系统架构.md](02-系统架构.md):技术架构、模块边界和代码约束 +- [03-版本识别与编辑器检测.md](03-版本识别与编辑器检测.md):版本识别、本地 Editor 检测和自定义路径 +- [04-恢复流水线.md](04-恢复流水线.md):恢复流水线、输出规则和错误处理 +- [05-离线预览器.md](05-离线预览器.md):多版本 Runtime 与离线预览器 +- [06-图集解包设计.md](06-图集解包设计.md):官方唯一解包、PMA 与 Atlas Scale 设计 +- [07-测试策略.md](07-测试策略.md):测试矩阵、官方测试资源管理和验收 +- [08-离线打包与许可.md](08-离线打包与许可.md):离线、发行与许可要求 +- [09-开发计划.md](09-开发计划.md):阶段计划、技术关卡与完成定义 +- [10-风险与架构决策.md](10-风险与架构决策.md):风险台账与架构决策记录 +- [11-阶段0技术原型记录.md](11-阶段0技术原型记录.md):首批代码、构建证据和后续关卡 +- [12-极简界面阶段记录.md](12-极简界面阶段记录.md):精简后的界面范围、当前产出与未完成项 +- [13-四点三动画预览记录.md](13-四点三动画预览记录.md):4.3 真实骨骼绘制、动画切换和测试证据 +- [14-离线转换与多版本回归记录.md](14-离线转换与多版本回归记录.md):本地版本门禁、完整输出、PMA 开关和 3.8—4.3 回归证据 + +## 官方参考资料 + +- [Spine Command Line Interface](https://en.esotericsoftware.com/spine-command-line-interface) +- [Importing skeleton data](https://esotericsoftware.com/blog/Importing-skeleton-data) +- [Spine versioning](https://esotericsoftware.com/spine-versioning) +- [spine-cpp Runtime Documentation](https://esotericsoftware.com/spine-cpp) +- [Spine JSON format](https://esotericsoftware.com/spine-json-format) +- [Spine binary format](https://esotericsoftware.com/spine-binary-format) +- [Spine Runtimes repository](https://github.com/EsotericSoftware/spine-runtimes) +- [Spine Runtimes License](https://esotericsoftware.com/spine-runtimes-license) diff --git a/DateToSpine/docs/阶段产出/固定画布最大显示-4.3.png b/DateToSpine/docs/阶段产出/固定画布最大显示-4.3.png new file mode 100644 index 0000000..05dbece Binary files /dev/null and b/DateToSpine/docs/阶段产出/固定画布最大显示-4.3.png differ diff --git a/DateToSpine/docs/阶段产出/旧版预览方向修复-4.2.png b/DateToSpine/docs/阶段产出/旧版预览方向修复-4.2.png new file mode 100644 index 0000000..7939886 Binary files /dev/null and b/DateToSpine/docs/阶段产出/旧版预览方向修复-4.2.png differ diff --git a/DateToSpine/docs/阶段产出/本地版本可转换状态.png b/DateToSpine/docs/阶段产出/本地版本可转换状态.png new file mode 100644 index 0000000..3630511 Binary files /dev/null and b/DateToSpine/docs/阶段产出/本地版本可转换状态.png differ diff --git a/DateToSpine/docs/阶段产出/极简界面-资源已载入.png b/DateToSpine/docs/阶段产出/极简界面-资源已载入.png new file mode 100644 index 0000000..7a305ec Binary files /dev/null and b/DateToSpine/docs/阶段产出/极简界面-资源已载入.png differ diff --git a/DateToSpine/docs/阶段产出/离线转换与去预乘开关.png b/DateToSpine/docs/阶段产出/离线转换与去预乘开关.png new file mode 100644 index 0000000..d17a16e Binary files /dev/null and b/DateToSpine/docs/阶段产出/离线转换与去预乘开关.png differ diff --git a/DateToSpine/docs/阶段产出/骨骼动画预览-4.3.png b/DateToSpine/docs/阶段产出/骨骼动画预览-4.3.png new file mode 100644 index 0000000..4ef7541 Binary files /dev/null and b/DateToSpine/docs/阶段产出/骨骼动画预览-4.3.png differ diff --git a/DateToSpine/runtime-api/RuntimeApi.h b/DateToSpine/runtime-api/RuntimeApi.h new file mode 100644 index 0000000..72e5f5a --- /dev/null +++ b/DateToSpine/runtime-api/RuntimeApi.h @@ -0,0 +1,90 @@ +#pragma once + +#include + +#if defined(_WIN32) +#define DTS_RUNTIME_EXPORT __declspec(dllexport) +#else +#define DTS_RUNTIME_EXPORT __attribute__((visibility("default"))) +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +enum { DTS_RUNTIME_API_VERSION = 3 }; + +enum DtsRuntimeCapability { + DTS_RUNTIME_CAP_SKELETON_METADATA = 1u << 0, + DTS_RUNTIME_CAP_DRAW_COMMANDS = 1u << 1 +}; + +typedef struct DtsRuntimeInfo { + uint32_t structSize; + uint32_t apiVersion; + const char* spineVersionLine; + const char* adapterBuild; + uint32_t capabilities; +} DtsRuntimeInfo; + +typedef void* DtsRuntimeHandle; + +typedef struct DtsLoadRequest { + uint32_t structSize; + const char* skeletonPathUtf8; + const char* atlasPathUtf8; + float scale; +} DtsLoadRequest; + +typedef struct DtsSkeletonInfo { + uint32_t structSize; + uint32_t boneCount; + uint32_t slotCount; + uint32_t skinCount; + uint32_t animationCount; + uint32_t eventCount; + uint32_t constraintCount; +} DtsSkeletonInfo; + +typedef struct DtsRenderVertex { + float x; + float y; + float u; + float v; + uint32_t color; +} DtsRenderVertex; + +typedef struct DtsDrawCommand { + uint32_t structSize; + const DtsRenderVertex* vertices; + uint32_t vertexCount; + const uint16_t* indices; + uint32_t indexCount; + const char* texturePathUtf8; + uint32_t blendMode; +} DtsDrawCommand; + +DTS_RUNTIME_EXPORT uint32_t dtsRuntimeGetApiVersion(void); +DTS_RUNTIME_EXPORT int32_t dtsRuntimeGetInfo(DtsRuntimeInfo* info); +DTS_RUNTIME_EXPORT DtsRuntimeHandle dtsRuntimeCreate(void); +DTS_RUNTIME_EXPORT void dtsRuntimeDestroy(DtsRuntimeHandle handle); +DTS_RUNTIME_EXPORT int32_t dtsRuntimeLoad(DtsRuntimeHandle handle, const DtsLoadRequest* request); +DTS_RUNTIME_EXPORT int32_t dtsRuntimeGetSkeletonInfo( + DtsRuntimeHandle handle, DtsSkeletonInfo* info); +DTS_RUNTIME_EXPORT const char* dtsRuntimeGetAnimationName( + DtsRuntimeHandle handle, uint32_t index); +DTS_RUNTIME_EXPORT float dtsRuntimeGetAnimationDuration( + DtsRuntimeHandle handle, uint32_t index); +DTS_RUNTIME_EXPORT const char* dtsRuntimeGetSkinName( + DtsRuntimeHandle handle, uint32_t index); +DTS_RUNTIME_EXPORT const char* dtsRuntimeGetLastError(DtsRuntimeHandle handle); +DTS_RUNTIME_EXPORT int32_t dtsRuntimeSetAnimation( + DtsRuntimeHandle handle, const char* animationNameUtf8, int32_t loop); +DTS_RUNTIME_EXPORT int32_t dtsRuntimeUpdate(DtsRuntimeHandle handle, float deltaSeconds); +DTS_RUNTIME_EXPORT uint32_t dtsRuntimeGetDrawCommandCount(DtsRuntimeHandle handle); +DTS_RUNTIME_EXPORT int32_t dtsRuntimeGetDrawCommand( + DtsRuntimeHandle handle, uint32_t index, DtsDrawCommand* command); + +#ifdef __cplusplus +} +#endif diff --git a/DateToSpine/runtime-plugins/common/LegacyRuntimeAdapter.inl b/DateToSpine/runtime-plugins/common/LegacyRuntimeAdapter.inl new file mode 100644 index 0000000..4ea893a --- /dev/null +++ b/DateToSpine/runtime-plugins/common/LegacyRuntimeAdapter.inl @@ -0,0 +1,305 @@ +#include "RuntimeApi.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#ifndef DTS_SPINE_LINE +#error DTS_SPINE_LINE must be defined before including LegacyRuntimeAdapter.inl +#endif + +namespace { +struct TextureRecord { std::string path; }; +class MetadataTextureLoader final : public spine::TextureLoader { +public: + void load(spine::AtlasPage& page, const spine::String& path) override { +#if defined(DTS_SPINE_41) + page.texture = new TextureRecord{path.buffer()}; +#else + page.setRendererObject(new TextureRecord{path.buffer()}); +#endif + } + void unload(void* texture) override { delete static_cast(texture); } +}; +struct RenderBatch { + std::vector vertices; + std::vector indices; + std::string texturePath; + std::uint32_t blendMode = 0; +}; +struct RuntimeContext { + MetadataTextureLoader textureLoader; + std::unique_ptr atlas; + std::unique_ptr skeletonData; + std::unique_ptr skeleton; + std::unique_ptr animationStateData; + std::unique_ptr animationState; + spine::SkeletonClipping clipper; + std::vector animationNames; + std::vector animationDurations; + std::vector skinNames; + std::vector renderBatches; + std::string error; +}; +RuntimeContext* context(DtsRuntimeHandle handle) { return static_cast(handle); } +std::string lowercaseExtension(const std::string& path) { + const auto dot = path.find_last_of('.'); + auto extension = dot == std::string::npos ? std::string{} : path.substr(dot); + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char value) { return static_cast(std::tolower(value)); }); + return extension; +} +void collectMetadata(RuntimeContext& value) { + value.animationNames.clear(); + value.animationDurations.clear(); + auto& animations = value.skeletonData->getAnimations(); + for (std::size_t index = 0; index < animations.size(); ++index) { + value.animationNames.emplace_back(animations[index]->getName().buffer()); + value.animationDurations.push_back(animations[index]->getDuration()); + } + value.skinNames.clear(); + auto& skins = value.skeletonData->getSkins(); + for (std::size_t index = 0; index < skins.size(); ++index) + value.skinNames.emplace_back(skins[index]->getName().buffer()); +} +spine::AtlasRegion* regionFor(spine::RegionAttachment& attachment) { +#if defined(DTS_SPINE_41) + return static_cast(attachment.getRegion()); +#else + return static_cast(attachment.getRendererObject()); +#endif +} +spine::AtlasRegion* regionFor(spine::MeshAttachment& attachment) { +#if defined(DTS_SPINE_41) + return static_cast(attachment.getRegion()); +#else + return static_cast(attachment.getRendererObject()); +#endif +} +std::uint32_t packedColor(spine::Skeleton& skeleton, spine::Slot& slot, + spine::Color& attachment) { + const auto component = [](float value) { + return static_cast(std::clamp(value, 0.0F, 1.0F) * 255.0F); + }; + const auto& skeletonColor = skeleton.getColor(); + const auto& slotColor = slot.getColor(); + const auto red = component(skeletonColor.r * slotColor.r * attachment.r); + const auto green = component(skeletonColor.g * slotColor.g * attachment.g); + const auto blue = component(skeletonColor.b * slotColor.b * attachment.b); + const auto alpha = component(skeletonColor.a * slotColor.a * attachment.a); + return (alpha << 24) | (red << 16) | (green << 8) | blue; +} +void appendBatch(RuntimeContext& value, spine::Slot& slot, spine::Color& color, + spine::AtlasRegion* region, spine::Vector& positions, + spine::Vector& uvs, spine::Vector& indices) { + if (!region || !region->page) return; +#if defined(DTS_SPINE_41) + auto* texture = static_cast(region->page->texture); +#else + auto* texture = static_cast(region->page->getRendererObject()); +#endif + if (!texture) return; + auto* sourcePositions = &positions; + auto* sourceUvs = &uvs; + auto* sourceIndices = &indices; + if (value.clipper.isClipping()) { + value.clipper.clipTriangles(positions, indices, uvs, 2); + sourcePositions = &value.clipper.getClippedVertices(); + sourceUvs = &value.clipper.getClippedUVs(); + sourceIndices = &value.clipper.getClippedTriangles(); + } + RenderBatch batch; + const auto vertexCount = sourcePositions->size() / 2; + batch.vertices.reserve(vertexCount); + const auto packed = packedColor(*value.skeleton, slot, color); + for (std::size_t index = 0; index < vertexCount; ++index) + batch.vertices.push_back({(*sourcePositions)[index * 2], -(*sourcePositions)[index * 2 + 1], + (*sourceUvs)[index * 2], (*sourceUvs)[index * 2 + 1], packed}); + batch.indices.assign(sourceIndices->buffer(), sourceIndices->buffer() + sourceIndices->size()); + batch.texturePath = texture->path; + batch.blendMode = static_cast(slot.getData().getBlendMode()); + value.renderBatches.push_back(std::move(batch)); +} +void collectDrawCommands(RuntimeContext& value) { + value.renderBatches.clear(); + spine::Vector positions; + spine::Vector quadIndices; + quadIndices.add(0); quadIndices.add(1); quadIndices.add(2); + quadIndices.add(2); quadIndices.add(3); quadIndices.add(0); + auto& drawOrder = value.skeleton->getDrawOrder(); + for (std::size_t index = 0; index < drawOrder.size(); ++index) { + auto& slot = *drawOrder[index]; + auto* attachment = slot.getAttachment(); + if (!attachment) { + value.clipper.clipEnd(slot); + continue; + } + if (attachment->getRTTI().isExactly(spine::RegionAttachment::rtti)) { + auto& region = *static_cast(attachment); + positions.setSize(8, 0); +#if defined(DTS_SPINE_41) + region.computeWorldVertices(slot, positions, 0, 2); +#else + region.computeWorldVertices(slot.getBone(), positions, 0, 2); +#endif + appendBatch(value, slot, region.getColor(), regionFor(region), + positions, region.getUVs(), quadIndices); + } else if (attachment->getRTTI().isExactly(spine::MeshAttachment::rtti)) { + auto& mesh = *static_cast(attachment); + positions.setSize(mesh.getWorldVerticesLength(), 0); + mesh.computeWorldVertices(slot, 0, mesh.getWorldVerticesLength(), + positions.buffer(), 0, 2); + appendBatch(value, slot, mesh.getColor(), regionFor(mesh), + positions, mesh.getUVs(), mesh.getTriangles()); + } else if (attachment->getRTTI().isExactly(spine::ClippingAttachment::rtti)) { + value.clipper.clipStart(slot, static_cast(attachment)); + } + value.clipper.clipEnd(slot); + } + value.clipper.clipEnd(); +} +} // namespace + +namespace spine { +SpineExtension* getDefaultExtension() { return new DefaultSpineExtension(); } +} // namespace spine + +extern "C" { +uint32_t dtsRuntimeGetApiVersion() { return DTS_RUNTIME_API_VERSION; } +int32_t dtsRuntimeGetInfo(DtsRuntimeInfo* info) { + if (!info || info->structSize < sizeof(DtsRuntimeInfo)) return -1; + info->apiVersion = DTS_RUNTIME_API_VERSION; + info->spineVersionLine = DTS_SPINE_LINE; + info->adapterBuild = "preview-1"; + info->capabilities = DTS_RUNTIME_CAP_SKELETON_METADATA | DTS_RUNTIME_CAP_DRAW_COMMANDS; + return 0; +} +DtsRuntimeHandle dtsRuntimeCreate() { return new RuntimeContext; } +void dtsRuntimeDestroy(DtsRuntimeHandle handle) { delete context(handle); } +int32_t dtsRuntimeLoad(DtsRuntimeHandle handle, const DtsLoadRequest* request) { + auto* value = context(handle); + if (!value || !request || request->structSize < sizeof(DtsLoadRequest) + || !request->skeletonPathUtf8 || !request->atlasPathUtf8) return -1; + value->error.clear(); + value->renderBatches.clear(); + value->animationState.reset(); + value->animationStateData.reset(); + value->skeleton.reset(); + value->skeletonData.reset(); + value->atlas.reset(); + value->atlas = std::make_unique(request->atlasPathUtf8, &value->textureLoader, true); + const float scale = request->scale > 0 ? request->scale : 1.0F; + if (lowercaseExtension(request->skeletonPathUtf8) == ".json") { + spine::SkeletonJson loader(value->atlas.get()); + loader.setScale(scale); + value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8)); + if (!value->skeletonData) value->error = loader.getError().buffer(); + } else { + spine::SkeletonBinary loader(value->atlas.get()); + loader.setScale(scale); + value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8)); + if (!value->skeletonData) value->error = loader.getError().buffer(); + } + if (!value->skeletonData) { + if (value->error.empty()) value->error = "Spine Runtime could not load skeleton data."; + return -2; + } + collectMetadata(*value); + value->skeleton = std::make_unique(value->skeletonData.get()); + value->animationStateData = std::make_unique(value->skeletonData.get()); + value->animationState = std::make_unique(value->animationStateData.get()); + if (!value->animationNames.empty()) + value->animationState->setAnimation(0, value->animationNames.front().c_str(), true); + value->animationState->apply(*value->skeleton); + value->skeleton->updateWorldTransform(); + collectDrawCommands(*value); + return 0; +} +int32_t dtsRuntimeGetSkeletonInfo(DtsRuntimeHandle handle, DtsSkeletonInfo* info) { + auto* value = context(handle); + if (!value || !value->skeletonData || !info || info->structSize < sizeof(DtsSkeletonInfo)) return -1; + auto* data = value->skeletonData.get(); + info->boneCount = static_cast(data->getBones().size()); + info->slotCount = static_cast(data->getSlots().size()); + info->skinCount = static_cast(value->skinNames.size()); + info->animationCount = static_cast(value->animationNames.size()); + info->eventCount = static_cast(data->getEvents().size()); + info->constraintCount = static_cast(data->getIkConstraints().size() + + data->getTransformConstraints().size() + data->getPathConstraints().size()); + return 0; +} +const char* dtsRuntimeGetAnimationName(DtsRuntimeHandle handle, uint32_t index) { + auto* value = context(handle); + return value && index < value->animationNames.size() ? value->animationNames[index].c_str() : nullptr; +} +float dtsRuntimeGetAnimationDuration(DtsRuntimeHandle handle, uint32_t index) { + auto* value = context(handle); + return value && index < value->animationDurations.size() ? value->animationDurations[index] : 0.0F; +} +const char* dtsRuntimeGetSkinName(DtsRuntimeHandle handle, uint32_t index) { + auto* value = context(handle); + return value && index < value->skinNames.size() ? value->skinNames[index].c_str() : nullptr; +} +const char* dtsRuntimeGetLastError(DtsRuntimeHandle handle) { + auto* value = context(handle); + return value ? value->error.c_str() : "Invalid runtime handle."; +} +int32_t dtsRuntimeSetAnimation(DtsRuntimeHandle handle, const char* name, int32_t loop) { + auto* value = context(handle); + if (!value || !value->animationState || !name) return -1; + if (!value->skeletonData->findAnimation(name)) { + value->error = "Animation was not found."; + return -2; + } + value->skeleton->setToSetupPose(); + value->animationState->setAnimation(0, name, loop != 0); + return 0; +} +int32_t dtsRuntimeUpdate(DtsRuntimeHandle handle, float seconds) { + auto* value = context(handle); + if (!value || !value->animationState || !value->skeleton) return -1; + value->animationState->update(std::max(0.0F, seconds)); + value->animationState->apply(*value->skeleton); + value->skeleton->updateWorldTransform(); + collectDrawCommands(*value); + return 0; +} +uint32_t dtsRuntimeGetDrawCommandCount(DtsRuntimeHandle handle) { + auto* value = context(handle); + return value ? static_cast(value->renderBatches.size()) : 0; +} +int32_t dtsRuntimeGetDrawCommand(DtsRuntimeHandle handle, uint32_t index, DtsDrawCommand* command) { + auto* value = context(handle); + if (!value || index >= value->renderBatches.size() || !command + || command->structSize < sizeof(DtsDrawCommand)) return -1; + const auto& batch = value->renderBatches[index]; + command->vertices = batch.vertices.data(); + command->vertexCount = static_cast(batch.vertices.size()); + command->indices = batch.indices.data(); + command->indexCount = static_cast(batch.indices.size()); + command->texturePathUtf8 = batch.texturePath.c_str(); + command->blendMode = batch.blendMode; + return 0; +} +} // extern "C" diff --git a/DateToSpine/runtime-plugins/spine38/RuntimeAdapter.cpp b/DateToSpine/runtime-plugins/spine38/RuntimeAdapter.cpp new file mode 100644 index 0000000..23ab9b9 --- /dev/null +++ b/DateToSpine/runtime-plugins/spine38/RuntimeAdapter.cpp @@ -0,0 +1,2 @@ +#define DTS_SPINE_LINE "3.8" +#include "../common/LegacyRuntimeAdapter.inl" diff --git a/DateToSpine/runtime-plugins/spine40/RuntimeAdapter.cpp b/DateToSpine/runtime-plugins/spine40/RuntimeAdapter.cpp new file mode 100644 index 0000000..4266970 --- /dev/null +++ b/DateToSpine/runtime-plugins/spine40/RuntimeAdapter.cpp @@ -0,0 +1,2 @@ +#define DTS_SPINE_LINE "4.0" +#include "../common/LegacyRuntimeAdapter.inl" diff --git a/DateToSpine/runtime-plugins/spine41/RuntimeAdapter.cpp b/DateToSpine/runtime-plugins/spine41/RuntimeAdapter.cpp new file mode 100644 index 0000000..337f929 --- /dev/null +++ b/DateToSpine/runtime-plugins/spine41/RuntimeAdapter.cpp @@ -0,0 +1,3 @@ +#define DTS_SPINE_LINE "4.1" +#define DTS_SPINE_41 +#include "../common/LegacyRuntimeAdapter.inl" diff --git a/DateToSpine/runtime-plugins/spine42/RuntimeAdapter.cpp b/DateToSpine/runtime-plugins/spine42/RuntimeAdapter.cpp new file mode 100644 index 0000000..cf769c5 --- /dev/null +++ b/DateToSpine/runtime-plugins/spine42/RuntimeAdapter.cpp @@ -0,0 +1,213 @@ +#include "RuntimeApi.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { +struct TextureRecord { std::string path; }; +class MetadataTextureLoader final : public spine::TextureLoader { +public: + void load(spine::AtlasPage& page, const spine::String& path) override { + page.texture = new TextureRecord{path.buffer()}; + } + void unload(void* texture) override { delete static_cast(texture); } +}; +struct RenderBatch { + std::vector vertices; + std::vector indices; + std::string texturePath; + std::uint32_t blendMode = 0; +}; +struct RuntimeContext { + MetadataTextureLoader textureLoader; + std::unique_ptr atlas; + std::unique_ptr skeletonData; + std::unique_ptr skeleton; + std::unique_ptr animationStateData; + std::unique_ptr animationState; + std::unique_ptr renderer; + std::vector animationNames; + std::vector animationDurations; + std::vector skinNames; + std::vector renderBatches; + std::string error; +}; +RuntimeContext* context(DtsRuntimeHandle handle) { return static_cast(handle); } +std::string lowercaseExtension(const std::string& path) { + const auto dot = path.find_last_of('.'); + auto extension = dot == std::string::npos ? std::string{} : path.substr(dot); + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char value) { return static_cast(std::tolower(value)); }); + return extension; +} +void collectMetadata(RuntimeContext& value) { + value.animationNames.clear(); + value.animationDurations.clear(); + auto& animations = value.skeletonData->getAnimations(); + for (std::size_t index = 0; index < animations.size(); ++index) { + value.animationNames.emplace_back(animations[index]->getName().buffer()); + value.animationDurations.push_back(animations[index]->getDuration()); + } + value.skinNames.clear(); + auto& skins = value.skeletonData->getSkins(); + for (std::size_t index = 0; index < skins.size(); ++index) + value.skinNames.emplace_back(skins[index]->getName().buffer()); +} +void collectDrawCommands(RuntimeContext& value) { + value.renderBatches.clear(); + for (auto* command = value.renderer->render(*value.skeleton); command; command = command->next) { + RenderBatch batch; + batch.blendMode = static_cast(command->blendMode); + if (const auto* texture = static_cast(command->texture)) + batch.texturePath = texture->path; + for (int index = 0; index < command->numVertices; ++index) + batch.vertices.push_back({command->positions[index * 2], -command->positions[index * 2 + 1], + command->uvs[index * 2], command->uvs[index * 2 + 1], command->colors[index]}); + batch.indices.assign(command->indices, command->indices + command->numIndices); + value.renderBatches.push_back(std::move(batch)); + } +} +} // namespace + +namespace spine { +SpineExtension* getDefaultExtension() { return new DefaultSpineExtension(); } +} // namespace spine + +extern "C" { +uint32_t dtsRuntimeGetApiVersion() { return DTS_RUNTIME_API_VERSION; } +int32_t dtsRuntimeGetInfo(DtsRuntimeInfo* info) { + if (!info || info->structSize < sizeof(DtsRuntimeInfo)) return -1; + info->apiVersion = DTS_RUNTIME_API_VERSION; + info->spineVersionLine = "4.2"; + info->adapterBuild = "preview-1"; + info->capabilities = DTS_RUNTIME_CAP_SKELETON_METADATA | DTS_RUNTIME_CAP_DRAW_COMMANDS; + return 0; +} +DtsRuntimeHandle dtsRuntimeCreate() { return new RuntimeContext; } +void dtsRuntimeDestroy(DtsRuntimeHandle handle) { delete context(handle); } +int32_t dtsRuntimeLoad(DtsRuntimeHandle handle, const DtsLoadRequest* request) { + auto* value = context(handle); + if (!value || !request || request->structSize < sizeof(DtsLoadRequest) + || !request->skeletonPathUtf8 || !request->atlasPathUtf8) return -1; + value->error.clear(); + value->renderBatches.clear(); + value->renderer.reset(); + value->animationState.reset(); + value->animationStateData.reset(); + value->skeleton.reset(); + value->skeletonData.reset(); + value->atlas.reset(); + value->atlas = std::make_unique(request->atlasPathUtf8, &value->textureLoader, true); + const float scale = request->scale > 0 ? request->scale : 1.0F; + if (lowercaseExtension(request->skeletonPathUtf8) == ".json") { + spine::SkeletonJson loader(value->atlas.get()); + loader.setScale(scale); + value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8)); + if (!value->skeletonData) value->error = loader.getError().buffer(); + } else { + spine::SkeletonBinary loader(value->atlas.get()); + loader.setScale(scale); + value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8)); + if (!value->skeletonData) value->error = loader.getError().buffer(); + } + if (!value->skeletonData) { + if (value->error.empty()) value->error = "Spine Runtime could not load skeleton data."; + return -2; + } + collectMetadata(*value); + value->skeleton = std::make_unique(value->skeletonData.get()); + value->animationStateData = std::make_unique(value->skeletonData.get()); + value->animationState = std::make_unique(value->animationStateData.get()); + value->renderer = std::make_unique(); + if (!value->animationNames.empty()) + value->animationState->setAnimation(0, value->animationNames.front().c_str(), true); + value->animationState->apply(*value->skeleton); + value->skeleton->updateWorldTransform(spine::Physics_Update); + collectDrawCommands(*value); + return 0; +} +int32_t dtsRuntimeGetSkeletonInfo(DtsRuntimeHandle handle, DtsSkeletonInfo* info) { + auto* value = context(handle); + if (!value || !value->skeletonData || !info || info->structSize < sizeof(DtsSkeletonInfo)) return -1; + auto* data = value->skeletonData.get(); + info->boneCount = static_cast(data->getBones().size()); + info->slotCount = static_cast(data->getSlots().size()); + info->skinCount = static_cast(value->skinNames.size()); + info->animationCount = static_cast(value->animationNames.size()); + info->eventCount = static_cast(data->getEvents().size()); + info->constraintCount = static_cast(data->getIkConstraints().size() + + data->getTransformConstraints().size() + data->getPathConstraints().size() + + data->getPhysicsConstraints().size()); + return 0; +} +const char* dtsRuntimeGetAnimationName(DtsRuntimeHandle handle, uint32_t index) { + auto* value = context(handle); + return value && index < value->animationNames.size() ? value->animationNames[index].c_str() : nullptr; +} +float dtsRuntimeGetAnimationDuration(DtsRuntimeHandle handle, uint32_t index) { + auto* value = context(handle); + return value && index < value->animationDurations.size() ? value->animationDurations[index] : 0.0F; +} +const char* dtsRuntimeGetSkinName(DtsRuntimeHandle handle, uint32_t index) { + auto* value = context(handle); + return value && index < value->skinNames.size() ? value->skinNames[index].c_str() : nullptr; +} +const char* dtsRuntimeGetLastError(DtsRuntimeHandle handle) { + auto* value = context(handle); + return value ? value->error.c_str() : "Invalid runtime handle."; +} +int32_t dtsRuntimeSetAnimation(DtsRuntimeHandle handle, const char* name, int32_t loop) { + auto* value = context(handle); + if (!value || !value->animationState || !name) return -1; + if (!value->skeletonData->findAnimation(name)) { + value->error = "Animation was not found."; + return -2; + } + value->skeleton->setToSetupPose(); + value->animationState->setAnimation(0, name, loop != 0); + return 0; +} +int32_t dtsRuntimeUpdate(DtsRuntimeHandle handle, float seconds) { + auto* value = context(handle); + if (!value || !value->animationState || !value->skeleton || !value->renderer) return -1; + value->animationState->update(std::max(0.0F, seconds)); + value->animationState->apply(*value->skeleton); + value->skeleton->updateWorldTransform(spine::Physics_Update); + collectDrawCommands(*value); + return 0; +} +uint32_t dtsRuntimeGetDrawCommandCount(DtsRuntimeHandle handle) { + auto* value = context(handle); + return value ? static_cast(value->renderBatches.size()) : 0; +} +int32_t dtsRuntimeGetDrawCommand(DtsRuntimeHandle handle, uint32_t index, DtsDrawCommand* command) { + auto* value = context(handle); + if (!value || index >= value->renderBatches.size() || !command + || command->structSize < sizeof(DtsDrawCommand)) return -1; + const auto& batch = value->renderBatches[index]; + command->vertices = batch.vertices.data(); + command->vertexCount = static_cast(batch.vertices.size()); + command->indices = batch.indices.data(); + command->indexCount = static_cast(batch.indices.size()); + command->texturePathUtf8 = batch.texturePath.c_str(); + command->blendMode = batch.blendMode; + return 0; +} +} // extern "C" diff --git a/DateToSpine/runtime-plugins/spine43/RuntimeAdapter.cpp b/DateToSpine/runtime-plugins/spine43/RuntimeAdapter.cpp new file mode 100644 index 0000000..eab193a --- /dev/null +++ b/DateToSpine/runtime-plugins/spine43/RuntimeAdapter.cpp @@ -0,0 +1,272 @@ +#include "RuntimeApi.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +struct TextureRecord { + std::string path; +}; + +class MetadataTextureLoader final : public spine::TextureLoader { +public: + void load(spine::AtlasPage& page, const spine::String& path) override { + page.texture = new TextureRecord{path.buffer()}; + } + void unload(void* texture) override { delete static_cast(texture); } +}; + +struct RenderBatch { + std::vector vertices; + std::vector indices; + std::string texturePath; + std::uint32_t blendMode = 0; +}; + +struct RuntimeContext { + MetadataTextureLoader textureLoader; + std::unique_ptr atlas; + std::unique_ptr skeletonData; + std::unique_ptr skeleton; + std::unique_ptr animationStateData; + std::unique_ptr animationState; + std::unique_ptr renderer; + std::vector animationNames; + std::vector animationDurations; + std::vector skinNames; + std::vector renderBatches; + std::string error; +}; + +RuntimeContext* context(DtsRuntimeHandle handle) { + return static_cast(handle); +} + +std::string lowercaseExtension(const std::string& path) { + const auto dot = path.find_last_of('.'); + auto extension = dot == std::string::npos ? std::string{} : path.substr(dot); + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char value) { return static_cast(std::tolower(value)); }); + return extension; +} + +void collectMetadata(RuntimeContext& value) { + value.animationNames.clear(); + value.animationDurations.clear(); + auto& animations = value.skeletonData->getAnimations(); + for (std::size_t index = 0; index < animations.size(); ++index) { + auto* animation = animations[index]; + value.animationNames.emplace_back(animation->getName().buffer()); + value.animationDurations.push_back(animation->getDuration()); + } + value.skinNames.clear(); + auto& skins = value.skeletonData->getSkins(); + for (std::size_t index = 0; index < skins.size(); ++index) { + auto* skin = skins[index]; + value.skinNames.emplace_back(skin->getName().buffer()); + } +} + +void collectDrawCommands(RuntimeContext& value) { + value.renderBatches.clear(); + for (auto* command = value.renderer->render(*value.skeleton); command; + command = command->next) { + RenderBatch batch; + batch.blendMode = static_cast(command->blendMode); + if (const auto* texture = static_cast(command->texture)) + batch.texturePath = texture->path; + batch.vertices.reserve(static_cast(command->numVertices)); + for (int index = 0; index < command->numVertices; ++index) { + batch.vertices.push_back({ + command->positions[index * 2], command->positions[index * 2 + 1], + command->uvs[index * 2], command->uvs[index * 2 + 1], + command->colors[index]}); + } + batch.indices.assign(command->indices, command->indices + command->numIndices); + value.renderBatches.push_back(std::move(batch)); + } +} + +} // namespace + +namespace spine { + +SpineExtension* getDefaultExtension() { + return new DefaultSpineExtension(); +} + +} // namespace spine + +extern "C" { + +uint32_t dtsRuntimeGetApiVersion() { + return DTS_RUNTIME_API_VERSION; +} + +int32_t dtsRuntimeGetInfo(DtsRuntimeInfo* info) { + if (!info || info->structSize < sizeof(DtsRuntimeInfo)) { + return -1; + } + info->apiVersion = DTS_RUNTIME_API_VERSION; + info->spineVersionLine = "4.3"; + info->adapterBuild = "prototype-2"; + info->capabilities = DTS_RUNTIME_CAP_SKELETON_METADATA | DTS_RUNTIME_CAP_DRAW_COMMANDS; + return 0; +} + +DtsRuntimeHandle dtsRuntimeCreate() { + return new RuntimeContext; +} + +void dtsRuntimeDestroy(DtsRuntimeHandle handle) { + delete context(handle); +} + +int32_t dtsRuntimeLoad(DtsRuntimeHandle handle, const DtsLoadRequest* request) { + auto* value = context(handle); + if (!value || !request || request->structSize < sizeof(DtsLoadRequest) + || !request->skeletonPathUtf8 || !request->atlasPathUtf8) { + return -1; + } + + value->error.clear(); + value->renderBatches.clear(); + value->renderer.reset(); + value->animationState.reset(); + value->animationStateData.reset(); + value->skeleton.reset(); + value->skeletonData.reset(); + value->atlas.reset(); + value->atlas = std::make_unique( + request->atlasPathUtf8, &value->textureLoader, true); + + const float scale = request->scale > 0 ? request->scale : 1.0F; + if (lowercaseExtension(request->skeletonPathUtf8) == ".json") { + spine::SkeletonJson loader(*value->atlas); + loader.setScale(scale); + value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8)); + if (!value->skeletonData) value->error = loader.getError().buffer(); + } else { + spine::SkeletonBinary loader(*value->atlas); + loader.setScale(scale); + value->skeletonData.reset(loader.readSkeletonDataFile(request->skeletonPathUtf8)); + if (!value->skeletonData) value->error = loader.getError().buffer(); + } + + if (!value->skeletonData) { + if (value->error.empty()) value->error = "Spine Runtime could not load skeleton data."; + return -2; + } + collectMetadata(*value); + value->skeleton = std::make_unique(*value->skeletonData); + value->animationStateData = std::make_unique( + *value->skeletonData); + value->animationState = std::make_unique( + *value->animationStateData); + value->renderer = std::make_unique(); + if (!value->animationNames.empty()) + value->animationState->setAnimation(0, value->animationNames.front().c_str(), true); + value->animationState->apply(*value->skeleton); + value->skeleton->updateWorldTransform(spine::Physics_Update); + collectDrawCommands(*value); + return 0; +} + +int32_t dtsRuntimeGetSkeletonInfo(DtsRuntimeHandle handle, DtsSkeletonInfo* info) { + auto* value = context(handle); + if (!value || !value->skeletonData || !info || info->structSize < sizeof(DtsSkeletonInfo)) { + return -1; + } + info->boneCount = static_cast(value->skeletonData->getBones().size()); + info->slotCount = static_cast(value->skeletonData->getSlots().size()); + info->skinCount = static_cast(value->skinNames.size()); + info->animationCount = static_cast(value->animationNames.size()); + info->eventCount = static_cast(value->skeletonData->getEvents().size()); + info->constraintCount = static_cast(value->skeletonData->getConstraints().size()); + return 0; +} + +const char* dtsRuntimeGetAnimationName(DtsRuntimeHandle handle, uint32_t index) { + auto* value = context(handle); + return value && index < value->animationNames.size() + ? value->animationNames[index].c_str() : nullptr; +} + +float dtsRuntimeGetAnimationDuration(DtsRuntimeHandle handle, uint32_t index) { + auto* value = context(handle); + return value && index < value->animationDurations.size() + ? value->animationDurations[index] : 0.0F; +} + +const char* dtsRuntimeGetSkinName(DtsRuntimeHandle handle, uint32_t index) { + auto* value = context(handle); + return value && index < value->skinNames.size() ? value->skinNames[index].c_str() : nullptr; +} + +const char* dtsRuntimeGetLastError(DtsRuntimeHandle handle) { + auto* value = context(handle); + return value ? value->error.c_str() : "Invalid runtime handle."; +} + +int32_t dtsRuntimeSetAnimation( + DtsRuntimeHandle handle, const char* animationNameUtf8, int32_t loop) { + auto* value = context(handle); + if (!value || !value->animationState || !animationNameUtf8) return -1; + if (!value->skeletonData->findAnimation(animationNameUtf8)) { + value->error = "Animation was not found."; + return -2; + } + value->skeleton->setupPose(); + value->animationState->setAnimation(0, animationNameUtf8, loop != 0); + return 0; +} + +int32_t dtsRuntimeUpdate(DtsRuntimeHandle handle, float deltaSeconds) { + auto* value = context(handle); + if (!value || !value->animationState || !value->skeleton || !value->renderer) return -1; + value->animationState->update(std::max(0.0F, deltaSeconds)); + value->animationState->apply(*value->skeleton); + value->skeleton->updateWorldTransform(spine::Physics_Update); + collectDrawCommands(*value); + return 0; +} + +uint32_t dtsRuntimeGetDrawCommandCount(DtsRuntimeHandle handle) { + auto* value = context(handle); + return value ? static_cast(value->renderBatches.size()) : 0; +} + +int32_t dtsRuntimeGetDrawCommand( + DtsRuntimeHandle handle, uint32_t index, DtsDrawCommand* command) { + auto* value = context(handle); + if (!value || index >= value->renderBatches.size() || !command + || command->structSize < sizeof(DtsDrawCommand)) return -1; + const auto& batch = value->renderBatches[index]; + command->vertices = batch.vertices.data(); + command->vertexCount = static_cast(batch.vertices.size()); + command->indices = batch.indices.data(); + command->indexCount = static_cast(batch.indices.size()); + command->texturePathUtf8 = batch.texturePath.c_str(); + command->blendMode = batch.blendMode; + return 0; +} + +} // extern "C" diff --git a/DateToSpine/src/app/cli_main.cpp b/DateToSpine/src/app/cli_main.cpp new file mode 100644 index 0000000..caff583 --- /dev/null +++ b/DateToSpine/src/app/cli_main.cpp @@ -0,0 +1,90 @@ +#include "editor/SpineEditorLocator.hpp" +#include "editor/SpineProbe.hpp" +#include "platform/ProcessRunner.hpp" +#include "scan/SpineDataVersionDetector.hpp" + +#include +#include +#include +#include + +namespace { + +void printProbe(const dts::EditorProbeResult& result) { + std::cout << "path: " << result.executable.string() << '\n'; + std::cout << "status: " << dts::toString(result.status) << '\n'; + if (result.launcherVersion) { + std::cout << "launcher: " << result.launcherVersion->toString() << '\n'; + } + if (result.editorVersion) { + std::cout << "editor: " << result.editorVersion->toString() << '\n'; + } + if (!result.edition.empty()) { + std::cout << "edition: " << result.edition << '\n'; + } + std::cout << "message: " << result.message << '\n'; +} + +void printUsage() { + std::cout << "DateToSpine technical prototype\n" + << "Usage:\n" + << " datetospine-cli detect\n" + << " datetospine-cli probe [timeout-ms]\n" + << " datetospine-cli inspect-data \n"; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) { + printUsage(); + return 2; + } + + const std::string command = argv[1]; + dts::SpineEditorLocator locator; + if (command == "detect") { + const auto candidates = locator.discover(); + for (const auto& candidate : candidates) { + std::cout << candidate.string() << '\n'; + } + return candidates.empty() ? 1 : 0; + } + + if (command == "probe" && argc >= 3) { + const auto executable = locator.normalize(std::filesystem::path(argv[2])); + if (!executable) { + std::cerr << "The supplied path does not contain a Spine executable.\n"; + return 1; + } + + auto timeout = std::chrono::milliseconds(10000); + if (argc >= 4) { + try { + timeout = std::chrono::milliseconds(std::stoll(argv[3])); + } catch (...) { + std::cerr << "Invalid timeout.\n"; + return 2; + } + } + auto runner = dts::createDefaultProcessRunner(); + dts::SpineProbe probe(*runner); + const auto result = probe.inspect(*executable, timeout); + printProbe(result); + return result.status == dts::EditorProbeStatus::Available ? 0 : 1; + } + + if (command == "inspect-data" && argc >= 3) { + const auto result = dts::SpineDataVersionDetector{}.inspect(argv[2]); + std::cout << "format: " << dts::toString(result.format) << '\n'; + std::cout << "status: " << dts::toString(result.status) << '\n'; + if (result.version) { + std::cout << "version: " << result.version->toString() << '\n'; + } + std::cout << "message: " << result.message << '\n'; + return result.status == dts::VersionDetectionStatus::Detected ? 0 : 1; + } + + printUsage(); + return 2; +} diff --git a/DateToSpine/src/app/gui_main.cpp b/DateToSpine/src/app/gui_main.cpp new file mode 100644 index 0000000..e1ed162 --- /dev/null +++ b/DateToSpine/src/app/gui_main.cpp @@ -0,0 +1,35 @@ +#include "ui/MainWindow.hpp" + +#include +#include +#include +#include + +int main(int argc, char** argv) { + QApplication application(argc, argv); + QCoreApplication::setApplicationName("DateToSpine"); + QCoreApplication::setOrganizationName("DateToSpine"); + + dts::MainWindow window; + window.show(); + const auto arguments = application.arguments(); + for (int index = 1; index < arguments.size(); ++index) { + if (arguments[index] == "--screenshot") { + ++index; + } else if (!arguments[index].startsWith('-')) { + window.openResourcePath(arguments[index]); + } + } + const int screenshotOption = arguments.indexOf("--screenshot"); + if (screenshotOption >= 0 && screenshotOption + 1 < arguments.size()) { + const auto screenshotPath = arguments[screenshotOption + 1]; + QTimer::singleShot(250, &application, [&application, &window, screenshotPath] { + window.grab().save(screenshotPath); + application.quit(); + }); + } + if (application.arguments().contains("--smoke-test")) { + QTimer::singleShot(100, &application, &QCoreApplication::quit); + } + return application.exec(); +} diff --git a/DateToSpine/src/atlas/AtlasParser.cpp b/DateToSpine/src/atlas/AtlasParser.cpp new file mode 100644 index 0000000..84d48c2 --- /dev/null +++ b/DateToSpine/src/atlas/AtlasParser.cpp @@ -0,0 +1,173 @@ +#include "atlas/AtlasParser.hpp" + +#include +#include +#include +#include +#include + +namespace dts { +namespace { + +std::string trim(std::string value) { + const auto isSpace = [](unsigned char character) { return std::isspace(character) != 0; }; + value.erase(value.begin(), std::find_if_not(value.begin(), value.end(), isSpace)); + value.erase(std::find_if_not(value.rbegin(), value.rend(), isSpace).base(), value.end()); + return value; +} + +std::vector splitValues(const std::string& value) { + std::vector values; + std::stringstream stream(value); + std::string item; + while (std::getline(stream, item, ',')) { + values.push_back(trim(item)); + } + return values; +} + +std::optional> parseIntegers(const std::string& value) { + std::vector integers; + for (const auto& item : splitValues(value)) { + int number = 0; + const auto result = std::from_chars(item.data(), item.data() + item.size(), number); + if (result.ec != std::errc{} || result.ptr != item.data() + item.size()) { + return std::nullopt; + } + integers.push_back(number); + } + return integers; +} + +bool isPageKey(const std::string& key) { + return key == "size" || key == "format" || key == "filter" || key == "repeat" + || key == "pma" || key == "scale"; +} + +bool parseBoolean(const std::string& value) { + return value == "true" || value == "yes" || value == "1"; +} + +void applyPageProperty(AtlasPage& page, const std::string& key, const std::string& value) { + if (key == "size") { + page.size = parseIntegers(value).value_or(std::vector{}); + } else if (key == "format") { + page.format = value; + } else if (key == "filter") { + page.filter = value; + } else if (key == "repeat") { + page.repeat = value; + } else if (key == "pma") { + page.premultipliedAlpha = parseBoolean(value); + } else if (key == "scale") { + try { + page.scale = std::stod(value); + } catch (...) { + page.scale.reset(); + } + } +} + +void applyRegionProperty(AtlasRegion& region, const std::string& key, const std::string& value) { + if (key == "rotate") { + region.rotated = parseBoolean(value) || value == "90" || value == "270"; + } else if (key == "bounds") { + region.bounds = parseIntegers(value).value_or(std::vector{}); + } else if (key == "xy") { + region.position = parseIntegers(value).value_or(std::vector{}); + } else if (key == "size") { + region.size = parseIntegers(value).value_or(std::vector{}); + } else if (key == "orig") { + region.originalSize = parseIntegers(value).value_or(std::vector{}); + } else if (key == "offset" || key == "offsets") { + region.offsets = parseIntegers(value).value_or(std::vector{}); + } else if (key == "split") { + region.split = parseIntegers(value).value_or(std::vector{}); + } else if (key == "pad") { + region.pad = parseIntegers(value).value_or(std::vector{}); + } else if (key == "index") { + const auto values = parseIntegers(value); + if (values && !values->empty()) { + region.index = values->front(); + } + } +} + +} // namespace + +AtlasParseResult AtlasParser::parseFile(const std::filesystem::path& path) const { + std::ifstream input(path, std::ios::binary); + if (!input) { + return {std::nullopt, "Atlas file could not be opened.", 0}; + } + std::ostringstream contents; + contents << input.rdbuf(); + return parseText(contents.str()); +} + +AtlasParseResult AtlasParser::parseText(const std::string& text) const { + AtlasDocument document; + AtlasPage* page = nullptr; + AtlasRegion* region = nullptr; + bool expectPage = true; + std::istringstream input(text); + std::string rawLine; + std::size_t lineNumber = 0; + + while (std::getline(input, rawLine)) { + ++lineNumber; + if (!rawLine.empty() && rawLine.back() == '\r') { + rawLine.pop_back(); + } + const auto line = trim(rawLine); + if (line.empty()) { + expectPage = true; + page = nullptr; + region = nullptr; + continue; + } + + const auto colon = line.find(':'); + if (colon == std::string::npos) { + if (expectPage || page == nullptr) { + AtlasPage nextPage; + nextPage.imagePath = std::filesystem::path(line); + document.pages.push_back(std::move(nextPage)); + page = &document.pages.back(); + region = nullptr; + expectPage = false; + } else { + AtlasRegion nextRegion; + nextRegion.name = line; + page->regions.push_back(std::move(nextRegion)); + region = &page->regions.back(); + } + continue; + } + + if (!page) { + return {std::nullopt, "Atlas property appears before a page name.", lineNumber}; + } + const auto key = trim(line.substr(0, colon)); + const auto value = trim(line.substr(colon + 1)); + if (!region && isPageKey(key)) { + applyPageProperty(*page, key, value); + } else if (region) { + applyRegionProperty(*region, key, value); + } else { + document.warnings.push_back("Unknown page property at line " + std::to_string(lineNumber)); + } + } + + if (document.pages.empty()) { + return {std::nullopt, "Atlas does not contain any pages.", 0}; + } + for (const auto& atlasPage : document.pages) { + if (atlasPage.imagePath.empty()) { + return {std::nullopt, "Atlas contains an empty page name.", 0}; + } + } + return {std::move(document), {}, 0}; +} + +} // namespace dts diff --git a/DateToSpine/src/atlas/AtlasParser.hpp b/DateToSpine/src/atlas/AtlasParser.hpp new file mode 100644 index 0000000..f75e594 --- /dev/null +++ b/DateToSpine/src/atlas/AtlasParser.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace dts { + +struct AtlasRegion { + std::string name; + bool rotated = false; + std::vector bounds; + std::vector position; + std::vector size; + std::vector originalSize; + std::vector offsets; + std::vector split; + std::vector pad; + std::optional index; +}; + +struct AtlasPage { + std::filesystem::path imagePath; + std::vector size; + std::string format; + std::string filter; + std::string repeat; + std::optional premultipliedAlpha; + std::optional scale; + std::vector regions; +}; + +struct AtlasDocument { + std::vector pages; + std::vector warnings; +}; + +struct AtlasParseResult { + std::optional document; + std::string error; + std::size_t errorLine = 0; +}; + +class AtlasParser { +public: + [[nodiscard]] AtlasParseResult parseFile(const std::filesystem::path& path) const; + [[nodiscard]] AtlasParseResult parseText(const std::string& text) const; +}; + +} // namespace dts diff --git a/DateToSpine/src/conversion/ConversionJob.cpp b/DateToSpine/src/conversion/ConversionJob.cpp new file mode 100644 index 0000000..14622ab --- /dev/null +++ b/DateToSpine/src/conversion/ConversionJob.cpp @@ -0,0 +1,218 @@ +#include "conversion/ConversionJob.hpp" + +#include "atlas/AtlasParser.hpp" +#include "domain/SpineVersion.hpp" +#include "editor/SpineVersionAvailability.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace dts { +namespace { + +std::filesystem::path localPath(const QString& value) { +#if defined(_WIN32) + return std::filesystem::path(value.toStdWString()); +#else + return std::filesystem::path(value.toStdString()); +#endif +} + +QString uniqueDirectory(const QString& parent, const QString& name) { + QDir directory(parent); + QString candidate = directory.filePath(name); + for (int suffix = 2; QFileInfo::exists(candidate); ++suffix) + candidate = directory.filePath(QStringLiteral("%1-%2").arg(name).arg(suffix)); + return candidate; +} + +} // namespace + +ConversionJob::ConversionJob(ConversionRequest request, QObject* parent) + : QObject(parent), request_(std::move(request)) {} + +ConversionJob::~ConversionJob() = default; + +void ConversionJob::start() { + QString error; + if (!prepare(error)) { + fail(error); + return; + } + startUnpack(); +} + +bool ConversionJob::prepare(QString& error) { + const auto version = SpineVersion::parse(request_.editorVersion.toStdString()); + if (!version || !SpineVersionAvailability{}.isCached(*version)) { + error = tr("本地未缓存 Spine %1。为避免触发下载,转换已停止。") + .arg(request_.editorVersion); + return false; + } + if (!QFileInfo::exists(request_.editorPath)) { + error = tr("找不到已指定的 Spine Editor。请重新指定程序路径。"); + return false; + } + const auto atlas = AtlasParser{}.parseFile(localPath(request_.atlasPath)); + if (!atlas.document || atlas.document->pages.empty()) { + error = tr("Atlas 无法识别,不能确定官方导入比例。"); + return false; + } + double scale = atlas.document->pages.front().scale.value_or(1.0); + if (!std::isfinite(scale) || scale <= 0.0) { + error = tr("Atlas 中的 scale 无效。"); + return false; + } + for (const auto& page : atlas.document->pages) { + const double pageScale = page.scale.value_or(1.0); + if (!std::isfinite(pageScale) || std::abs(pageScale - scale) > 0.000001) { + error = tr("Atlas 的纹理页使用了不同 scale,无法生成尺寸一致的工程。"); + return false; + } + } + importScale_ = QString::number(scale, 'g', 15); + QDir parent(request_.outputParent); + if (!parent.exists() && !parent.mkpath(QStringLiteral("."))) { + error = tr("无法创建输出目录。"); + return false; + } + finalDirectory_ = uniqueDirectory(request_.outputParent, request_.assetName); + staging_ = std::make_unique( + parent.filePath(QStringLiteral(".datetospine-XXXXXX"))); + if (!staging_->isValid()) { + error = tr("无法创建临时转换目录。"); + return false; + } + QDir staging(staging_->path()); + if (!staging.mkpath(QStringLiteral("images"))) { + error = tr("无法准备图片目录。"); + return false; + } + projectPath_ = staging.filePath(request_.assetName + QStringLiteral(".spine")); + // The official unpacker reads `pma` from the Atlas. When conversion to + // straight alpha is requested, pass the source Atlas unchanged so Spine + // performs the conversion itself. Otherwise a temporary metadata copy + // disables that conversion while leaving all geometry fields intact. + unpackAtlasPath_ = request_.unpremultiplyAlpha + ? request_.atlasPath + : staging.filePath(QStringLiteral("unpack.atlas")); + return writeUnpackAtlas(error); +} + +bool ConversionJob::writeUnpackAtlas(QString& error) { + if (request_.unpremultiplyAlpha) return true; + + QFile source(request_.atlasPath); + if (!source.open(QIODevice::ReadOnly)) { + error = tr("无法读取 Atlas 文件。"); + return false; + } + auto contents = QString::fromUtf8(source.readAll()); + source.close(); + // Preserve packed pixel values when the user explicitly leaves PMA enabled. + // Pixel decoding, cropping and Alpha handling remain entirely inside Spine. + contents.replace(QRegularExpression( + QStringLiteral("(?m)^(\\s*pma\\s*:\\s*)true(\\s*)$")), QStringLiteral("\\1false\\2")); + QFile temporary(unpackAtlasPath_); + const auto encoded = contents.toUtf8(); + if (!temporary.open(QIODevice::WriteOnly | QIODevice::Truncate) + || temporary.write(encoded) != encoded.size()) { + error = tr("无法准备离线图集解包配置。"); + return false; + } + return true; +} + +void ConversionJob::startUnpack() { + stage_ = Stage::Unpacking; + process_ = new QProcess(this); + process_->setProcessChannelMode(QProcess::MergedChannels); + connect(process_, &QProcess::finished, this, &ConversionJob::processFinished); + connect(process_, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { + if (error == QProcess::FailedToStart && stage_ != Stage::Done) + fail(tr("无法启动本机 Spine Editor。")); + }); + process_->start(request_.editorPath, { + "-u", request_.editorVersion, + "-i", QFileInfo(request_.atlasPath).absolutePath(), + "-o", QDir(staging_->path()).filePath(QStringLiteral("images")), + "-c", unpackAtlasPath_}); +} + +void ConversionJob::startImport() { + stage_ = Stage::Importing; + process_->start(request_.editorPath, { + "-u", request_.editorVersion, + "-i", request_.skeletonPath, + // Numeric scale works with all supported Editor lines. Older Editors + // (notably 4.0) do not accept an Atlas path for this argument. + "-s", importScale_, + "-o", projectPath_, + "--import"}); +} + +bool ConversionJob::hasUnpackedImages() const { + QDirIterator iterator(QDir(staging_->path()).filePath(QStringLiteral("images")), + {"*.png", "*.jpg", "*.jpeg", "*.webp"}, QDir::Files, + QDirIterator::Subdirectories); + return iterator.hasNext(); +} + +void ConversionJob::processFinished(int exitCode) { + if (stage_ == Stage::Done) return; + const auto output = QString::fromUtf8(process_->readAll()); + if (exitCode != 0) { + fail(stage_ == Stage::Unpacking + ? tr("官方图集解包失败:\n%1").arg(output.right(1600)) + : tr("Spine 工程导入失败:\n%1").arg(output.right(1600))); + return; + } + if (stage_ == Stage::Unpacking) { + if (!hasUnpackedImages()) { + fail(tr("官方图集解包未生成任何图片。")); + return; + } + if (unpackAtlasPath_ != request_.atlasPath) QFile::remove(unpackAtlasPath_); + startImport(); + return; + } + if (stage_ == Stage::Importing) { + if (QFileInfo(projectPath_).size() <= 0) { + fail(tr("Spine Editor 未生成有效的 .spine 工程。")); + return; + } + publish(); + } +} + +void ConversionJob::publish() { + stage_ = Stage::Publishing; + const auto source = localPath(staging_->path()); + const auto destination = localPath(finalDirectory_); + std::error_code error; + std::filesystem::rename(source, destination, error); + if (error) { + fail(tr("无法发布转换结果:%1").arg(QString::fromStdString(error.message()))); + return; + } + staging_->setAutoRemove(false); + stage_ = Stage::Done; + emit finished(true, finalDirectory_, {}); +} + +void ConversionJob::fail(const QString& reason) { + if (stage_ == Stage::Done) return; + stage_ = Stage::Done; + if (process_) process_->kill(); + emit finished(false, {}, reason); +} + +} // namespace dts diff --git a/DateToSpine/src/conversion/ConversionJob.hpp b/DateToSpine/src/conversion/ConversionJob.hpp new file mode 100644 index 0000000..8ab4024 --- /dev/null +++ b/DateToSpine/src/conversion/ConversionJob.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include +#include + +#include + +class QProcess; +class QTemporaryDir; + +namespace dts { + +struct ConversionRequest { + QString editorPath; + QString editorVersion; + QString skeletonPath; + QString atlasPath; + QString outputParent; + QString assetName; + bool unpremultiplyAlpha = false; +}; + +class ConversionJob final : public QObject { + Q_OBJECT + +public: + explicit ConversionJob(ConversionRequest request, QObject* parent = nullptr); + ~ConversionJob() override; + void start(); + +signals: + void finished(bool success, const QString& outputDirectory, const QString& error); + +private: + enum class Stage { Preparing, Unpacking, Importing, Publishing, Done }; + + bool prepare(QString& error); + bool writeUnpackAtlas(QString& error); + bool hasUnpackedImages() const; + void startUnpack(); + void startImport(); + void processFinished(int exitCode); + void fail(const QString& reason); + void publish(); + + ConversionRequest request_; + Stage stage_ = Stage::Preparing; + std::unique_ptr staging_; + QProcess* process_ = nullptr; + QString finalDirectory_; + QString projectPath_; + QString unpackAtlasPath_; + QString importScale_; +}; + +} // namespace dts diff --git a/DateToSpine/src/domain/SpineVersion.cpp b/DateToSpine/src/domain/SpineVersion.cpp new file mode 100644 index 0000000..c83d77e --- /dev/null +++ b/DateToSpine/src/domain/SpineVersion.cpp @@ -0,0 +1,71 @@ +#include "domain/SpineVersion.hpp" + +#include + +namespace dts { +namespace { + +std::optional parseNumber(std::string_view text) { + int value = 0; + const auto result = std::from_chars(text.data(), text.data() + text.size(), value); + if (result.ec != std::errc{} || result.ptr != text.data() + text.size()) { + return std::nullopt; + } + return value; +} + +} // namespace + +std::optional SpineVersion::parse(std::string_view text) { + const auto firstDot = text.find('.'); + if (firstDot == std::string_view::npos) { + return std::nullopt; + } + + const auto secondDot = text.find('.', firstDot + 1); + const auto majorValue = parseNumber(text.substr(0, firstDot)); + const auto minorEnd = secondDot == std::string_view::npos ? text.size() : secondDot; + const auto minorValue = parseNumber(text.substr(firstDot + 1, minorEnd - firstDot - 1)); + if (!majorValue || !minorValue) { + return std::nullopt; + } + + SpineVersion version{*majorValue, *minorValue, 0, false}; + if (secondDot != std::string_view::npos) { + auto patchText = text.substr(secondDot + 1); + const auto suffix = patchText.find_first_not_of("0123456789"); + const auto numericPatch = patchText.substr(0, suffix); + if (numericPatch.empty() || (suffix != std::string_view::npos + && patchText[suffix] != '-')) { + return std::nullopt; + } + const auto patchValue = parseNumber(numericPatch); + if (!patchValue) { + return std::nullopt; + } + version.patch = *patchValue; + version.hasPatch = true; + } + return version; +} + +std::string SpineVersion::line() const { + return std::to_string(major) + "." + std::to_string(minor); +} + +std::string SpineVersion::toString() const { + auto value = line(); + if (hasPatch) { + value += "." + std::to_string(patch); + } + return value; +} + +bool SpineVersion::isSupported() const { + if (major == 3 && minor == 8) { + return !hasPatch || patch >= 20; + } + return major == 4 && minor >= 0 && minor <= 3; +} + +} // namespace dts diff --git a/DateToSpine/src/domain/SpineVersion.hpp b/DateToSpine/src/domain/SpineVersion.hpp new file mode 100644 index 0000000..d16cecc --- /dev/null +++ b/DateToSpine/src/domain/SpineVersion.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +namespace dts { + +struct SpineVersion { + int major = 0; + int minor = 0; + int patch = 0; + bool hasPatch = false; + + [[nodiscard]] static std::optional parse(std::string_view text); + [[nodiscard]] std::string line() const; + [[nodiscard]] std::string toString() const; + [[nodiscard]] bool isSupported() const; + + friend bool operator==(const SpineVersion&, const SpineVersion&) = default; +}; + +} // namespace dts + diff --git a/DateToSpine/src/editor/SpineEditorLocator.cpp b/DateToSpine/src/editor/SpineEditorLocator.cpp new file mode 100644 index 0000000..c1e0ec4 --- /dev/null +++ b/DateToSpine/src/editor/SpineEditorLocator.cpp @@ -0,0 +1,79 @@ +#include "editor/SpineEditorLocator.hpp" + +#include +#include +#include + +namespace dts { +namespace fs = std::filesystem; +namespace { + +void addUnique(std::vector& paths, const fs::path& candidate) { + std::error_code error; + if (!fs::is_regular_file(candidate, error)) { + return; + } + const auto canonical = fs::weakly_canonical(candidate, error); + const auto value = error ? candidate.lexically_normal() : canonical; + if (std::find(paths.begin(), paths.end(), value) == paths.end()) { + paths.push_back(value); + } +} + +} // namespace + +std::vector SpineEditorLocator::discover() const { + std::vector results; + +#if defined(__APPLE__) + addUnique(results, "/Applications/Spine.app/Contents/MacOS/Spine"); + if (const char* userHome = std::getenv("HOME")) { + addUnique(results, fs::path(userHome) / "Applications/Spine.app/Contents/MacOS/Spine"); + } +#elif defined(_WIN32) + const auto addFromRoot = [&results](const char* variable) { + if (const char* root = std::getenv(variable)) { + addUnique(results, fs::path(root) / "Spine/Spine.com"); + } + }; + addFromRoot("ProgramFiles"); + addFromRoot("ProgramFiles(x86)"); +#endif + + return results; +} + +std::optional SpineEditorLocator::normalize(const fs::path& userPath) const { + std::error_code error; + auto candidate = userPath; + +#if defined(__APPLE__) + if (candidate.extension() == ".app") { + candidate /= "Contents/MacOS/Spine"; + } else if (fs::is_directory(candidate, error)) { + const auto appCandidate = candidate / "Spine.app/Contents/MacOS/Spine"; + if (fs::is_regular_file(appCandidate, error)) { + candidate = appCandidate; + } + } +#elif defined(_WIN32) + if (fs::is_directory(candidate, error)) { + candidate /= "Spine.com"; + } else if (candidate.extension() == ".exe") { + auto commandCandidate = candidate; + commandCandidate.replace_extension(".com"); + if (fs::is_regular_file(commandCandidate, error)) { + candidate = commandCandidate; + } + } +#endif + + if (!fs::is_regular_file(candidate, error)) { + return std::nullopt; + } + const auto canonical = fs::weakly_canonical(candidate, error); + return error ? std::optional(candidate.lexically_normal()) : std::optional(canonical); +} + +} // namespace dts + diff --git a/DateToSpine/src/editor/SpineEditorLocator.hpp b/DateToSpine/src/editor/SpineEditorLocator.hpp new file mode 100644 index 0000000..eff73fc --- /dev/null +++ b/DateToSpine/src/editor/SpineEditorLocator.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include + +namespace dts { + +class SpineEditorLocator { +public: + [[nodiscard]] std::vector discover() const; + [[nodiscard]] std::optional normalize( + const std::filesystem::path& userPath) const; +}; + +} // namespace dts + diff --git a/DateToSpine/src/editor/SpineOutputParser.cpp b/DateToSpine/src/editor/SpineOutputParser.cpp new file mode 100644 index 0000000..dd25a24 --- /dev/null +++ b/DateToSpine/src/editor/SpineOutputParser.cpp @@ -0,0 +1,80 @@ +#include "editor/SpineOutputParser.hpp" + +#include + +namespace dts { +namespace { + +std::optional firstVersion( + const std::string& text, + const std::regex& expression, + std::string* trailing = nullptr) { + std::smatch match; + if (!std::regex_search(text, match, expression)) { + return std::nullopt; + } + if (trailing && match.size() > 2) { + *trailing = match[2].str(); + } + return SpineVersion::parse(match[1].str()); +} + +} // namespace + +EditorProbeResult SpineOutputParser::parse( + const std::filesystem::path& executable, + const ProcessResult& process) const { + EditorProbeResult result; + result.executable = executable; + result.timedOut = process.timedOut; + result.exitCode = process.exitCode; + result.output = process.output; + + if (!process.started) { + result.status = EditorProbeStatus::LaunchFailed; + result.message = process.error.empty() ? "Spine process could not be started." : process.error; + return result; + } + + static const std::regex launcher(R"(Spine Launcher\s+(\d+\.\d+(?:\.\d+)?))"); + static const std::regex launching( + R"((?:Launching:|Starting:)\s*Spine\s+(\d+\.\d+(?:\.\d+)?)(?:\s+([^\r\n]+))?)"); + static const std::regex directEditor( + R"(^Spine\s+(\d+\.\d+(?:\.\d+)?)(?:\s+([^\r\n]+))?)", + std::regex_constants::multiline); + + result.launcherVersion = firstVersion(process.output, launcher); + result.editorVersion = firstVersion(process.output, launching, &result.edition); + if (!result.editorVersion) { + result.editorVersion = firstVersion(process.output, directEditor, &result.edition); + } + + if (!result.launcherVersion && !result.editorVersion) { + result.status = EditorProbeStatus::UnrecognizedOutput; + result.message = "Process started, but its output was not recognized as Spine."; + } else if (process.timedOut) { + result.status = EditorProbeStatus::ObservedButTimedOut; + result.message = "Spine was detected, but the version probe timed out."; + } else if (process.exitCode == 0) { + result.status = EditorProbeStatus::Available; + result.message = "Spine CLI probe succeeded."; + } else { + result.status = EditorProbeStatus::LaunchFailed; + result.message = "Spine was detected, but the process returned a failure code."; + } + return result; +} + +std::string toString(EditorProbeStatus status) { + switch (status) { + case EditorProbeStatus::Available: return "available"; + case EditorProbeStatus::ObservedButTimedOut: return "observed-but-timed-out"; + case EditorProbeStatus::PathMissing: return "path-missing"; + case EditorProbeStatus::LaunchFailed: return "launch-failed"; + case EditorProbeStatus::UnrecognizedOutput: return "unrecognized-output"; + } + return "unknown"; +} + +} // namespace dts + diff --git a/DateToSpine/src/editor/SpineOutputParser.hpp b/DateToSpine/src/editor/SpineOutputParser.hpp new file mode 100644 index 0000000..4863b19 --- /dev/null +++ b/DateToSpine/src/editor/SpineOutputParser.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "domain/SpineVersion.hpp" +#include "platform/ProcessRunner.hpp" + +#include +#include +#include + +namespace dts { + +enum class EditorProbeStatus { + Available, + ObservedButTimedOut, + PathMissing, + LaunchFailed, + UnrecognizedOutput +}; + +struct EditorProbeResult { + std::filesystem::path executable; + EditorProbeStatus status = EditorProbeStatus::UnrecognizedOutput; + std::optional launcherVersion; + std::optional editorVersion; + std::string edition; + bool timedOut = false; + int exitCode = -1; + std::string output; + std::string message; +}; + +class SpineOutputParser { +public: + [[nodiscard]] EditorProbeResult parse( + const std::filesystem::path& executable, + const ProcessResult& process) const; +}; + +[[nodiscard]] std::string toString(EditorProbeStatus status); + +} // namespace dts + diff --git a/DateToSpine/src/editor/SpineProbe.cpp b/DateToSpine/src/editor/SpineProbe.cpp new file mode 100644 index 0000000..8517237 --- /dev/null +++ b/DateToSpine/src/editor/SpineProbe.cpp @@ -0,0 +1,31 @@ +#include "editor/SpineProbe.hpp" + +#include + +namespace dts { + +SpineProbe::SpineProbe(const IProcessRunner& processRunner) + : processRunner_(processRunner) {} + +EditorProbeResult SpineProbe::inspect( + const std::filesystem::path& executable, + std::chrono::milliseconds timeout) const { + std::error_code error; + if (!std::filesystem::is_regular_file(executable, error)) { + EditorProbeResult result; + result.executable = executable; + result.status = EditorProbeStatus::PathMissing; + result.message = "Spine executable does not exist."; + return result; + } + + ProcessRequest request; + request.program = executable; + request.arguments = {"--version"}; + request.workingDirectory = executable.parent_path(); + request.timeout = timeout; + return parser_.parse(executable, processRunner_.run(request)); +} + +} // namespace dts + diff --git a/DateToSpine/src/editor/SpineProbe.hpp b/DateToSpine/src/editor/SpineProbe.hpp new file mode 100644 index 0000000..b0fd422 --- /dev/null +++ b/DateToSpine/src/editor/SpineProbe.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "editor/SpineOutputParser.hpp" +#include "platform/ProcessRunner.hpp" + +#include +#include + +namespace dts { + +class SpineProbe { +public: + explicit SpineProbe(const IProcessRunner& processRunner); + + [[nodiscard]] EditorProbeResult inspect( + const std::filesystem::path& executable, + std::chrono::milliseconds timeout = std::chrono::seconds(10)) const; + +private: + const IProcessRunner& processRunner_; + SpineOutputParser parser_; +}; + +} // namespace dts + diff --git a/DateToSpine/src/editor/SpineVersionAvailability.cpp b/DateToSpine/src/editor/SpineVersionAvailability.cpp new file mode 100644 index 0000000..4ed8c12 --- /dev/null +++ b/DateToSpine/src/editor/SpineVersionAvailability.cpp @@ -0,0 +1,60 @@ +#include "editor/SpineVersionAvailability.hpp" + +#include +#include +#include + +namespace dts { +namespace fs = std::filesystem; + +std::vector SpineVersionAvailability::defaultCacheRoots() const { + std::vector roots; +#if defined(__APPLE__) + if (const char* userHome = std::getenv("HOME")) + roots.emplace_back(fs::path(userHome) / "Library/Application Support/Spine/updates"); +#elif defined(_WIN32) + if (const char* roaming = std::getenv("APPDATA")) + roots.emplace_back(fs::path(roaming) / "Spine/updates"); + if (const char* local = std::getenv("LOCALAPPDATA")) + roots.emplace_back(fs::path(local) / "Spine/updates"); +#endif + return roots; +} + +std::vector SpineVersionAvailability::discover( + const std::vector& cacheRoots) const { + std::vector versions; + const auto roots = cacheRoots.empty() ? defaultCacheRoots() : cacheRoots; + for (const auto& root : roots) { + std::error_code error; + fs::directory_iterator iterator(root, error); + for (const auto& entry : iterator) { + if (!entry.is_regular_file(error) || entry.file_size(error) == 0) continue; + const auto parsed = SpineVersion::parse(entry.path().filename().string()); + if (parsed && std::find(versions.begin(), versions.end(), *parsed) == versions.end()) + versions.push_back(*parsed); + } + } + std::sort(versions.begin(), versions.end(), [](const auto& left, const auto& right) { + if (left.major != right.major) return left.major < right.major; + if (left.minor != right.minor) return left.minor < right.minor; + return left.patch < right.patch; + }); + return versions; +} + +std::optional SpineVersionAvailability::newestForLine( + std::string_view versionLine, const std::vector& cacheRoots) const { + std::optional result; + for (const auto& version : discover(cacheRoots)) + if (version.line() == versionLine) result = version; + return result; +} + +bool SpineVersionAvailability::isCached( + const SpineVersion& version, const std::vector& cacheRoots) const { + const auto versions = discover(cacheRoots); + return std::find(versions.begin(), versions.end(), version) != versions.end(); +} + +} // namespace dts diff --git a/DateToSpine/src/editor/SpineVersionAvailability.hpp b/DateToSpine/src/editor/SpineVersionAvailability.hpp new file mode 100644 index 0000000..2bbdab9 --- /dev/null +++ b/DateToSpine/src/editor/SpineVersionAvailability.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "domain/SpineVersion.hpp" + +#include +#include +#include +#include + +namespace dts { + +class SpineVersionAvailability { +public: + [[nodiscard]] std::vector defaultCacheRoots() const; + [[nodiscard]] std::vector discover( + const std::vector& cacheRoots = {}) const; + [[nodiscard]] std::optional newestForLine( + std::string_view versionLine, + const std::vector& cacheRoots = {}) const; + [[nodiscard]] bool isCached( + const SpineVersion& version, + const std::vector& cacheRoots = {}) const; +}; + +} // namespace dts diff --git a/DateToSpine/src/platform/ProcessRunner.hpp b/DateToSpine/src/platform/ProcessRunner.hpp new file mode 100644 index 0000000..68a4284 --- /dev/null +++ b/DateToSpine/src/platform/ProcessRunner.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace dts { + +struct ProcessRequest { + std::filesystem::path program; + std::vector arguments; + std::filesystem::path workingDirectory; + std::chrono::milliseconds timeout{10000}; +}; + +struct ProcessResult { + bool started = false; + bool timedOut = false; + int exitCode = -1; + std::string output; + std::string error; +}; + +class IProcessRunner { +public: + virtual ~IProcessRunner() = default; + [[nodiscard]] virtual ProcessResult run(const ProcessRequest& request) const = 0; +}; + +[[nodiscard]] std::unique_ptr createDefaultProcessRunner(); + +} // namespace dts + diff --git a/DateToSpine/src/platform/ProcessRunner_posix.cpp b/DateToSpine/src/platform/ProcessRunner_posix.cpp new file mode 100644 index 0000000..dcc3aca --- /dev/null +++ b/DateToSpine/src/platform/ProcessRunner_posix.cpp @@ -0,0 +1,134 @@ +#include "platform/ProcessRunner.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dts { +namespace { + +void appendAvailableOutput(int descriptor, std::string& output) { + char buffer[4096]; + while (true) { + const auto count = ::read(descriptor, buffer, sizeof(buffer)); + if (count > 0) { + output.append(buffer, static_cast(count)); + continue; + } + if (count < 0 && errno == EINTR) { + continue; + } + break; + } +} + +class PosixProcessRunner final : public IProcessRunner { +public: + ProcessResult run(const ProcessRequest& request) const override { + ProcessResult result; + int outputPipe[2]; + if (::pipe(outputPipe) != 0) { + result.error = std::strerror(errno); + return result; + } + + const pid_t child = ::fork(); + if (child < 0) { + result.error = std::strerror(errno); + ::close(outputPipe[0]); + ::close(outputPipe[1]); + return result; + } + + if (child == 0) { + ::setpgid(0, 0); + ::close(outputPipe[0]); + ::dup2(outputPipe[1], STDOUT_FILENO); + ::dup2(outputPipe[1], STDERR_FILENO); + ::close(outputPipe[1]); + + if (!request.workingDirectory.empty()) { + ::chdir(request.workingDirectory.c_str()); + } + + std::vector values; + values.reserve(request.arguments.size() + 1); + values.push_back(request.program.string()); + values.insert(values.end(), request.arguments.begin(), request.arguments.end()); + + std::vector arguments; + arguments.reserve(values.size() + 1); + for (auto& value : values) { + arguments.push_back(value.data()); + } + arguments.push_back(nullptr); + ::execv(request.program.c_str(), arguments.data()); + ::_exit(127); + } + + result.started = true; + ::setpgid(child, child); + ::close(outputPipe[1]); + const auto flags = ::fcntl(outputPipe[0], F_GETFL, 0); + ::fcntl(outputPipe[0], F_SETFL, flags | O_NONBLOCK); + + const auto deadline = std::chrono::steady_clock::now() + request.timeout; + int status = 0; + bool finished = false; + while (!finished) { + appendAvailableOutput(outputPipe[0], result.output); + const auto waitResult = ::waitpid(child, &status, WNOHANG); + if (waitResult == child) { + finished = true; + break; + } + if (waitResult < 0) { + result.error = std::strerror(errno); + break; + } + if (std::chrono::steady_clock::now() >= deadline) { + result.timedOut = true; + ::kill(-child, SIGTERM); + for (int attempt = 0; attempt < 25; ++attempt) { + if (::waitpid(child, &status, WNOHANG) == child) { + finished = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + if (!finished) { + ::kill(-child, SIGKILL); + ::waitpid(child, &status, 0); + } + break; + } + pollfd descriptor{outputPipe[0], POLLIN, 0}; + ::poll(&descriptor, 1, 25); + } + + appendAvailableOutput(outputPipe[0], result.output); + ::close(outputPipe[0]); + if (WIFEXITED(status)) { + result.exitCode = WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + result.exitCode = 128 + WTERMSIG(status); + } + return result; + } +}; + +} // namespace + +std::unique_ptr createDefaultProcessRunner() { + return std::make_unique(); +} + +} // namespace dts diff --git a/DateToSpine/src/platform/ProcessRunner_windows.cpp b/DateToSpine/src/platform/ProcessRunner_windows.cpp new file mode 100644 index 0000000..3dad635 --- /dev/null +++ b/DateToSpine/src/platform/ProcessRunner_windows.cpp @@ -0,0 +1,132 @@ +#include "platform/ProcessRunner.hpp" + +#define NOMINMAX +#include + +#include +#include + +namespace dts { +namespace { + +std::wstring widen(const std::string& value) { + if (value.empty()) { + return {}; + } + const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(), + static_cast(value.size()), nullptr, 0); + std::wstring output(static_cast(size), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast(value.size()), + output.data(), size); + return output; +} + +std::wstring quote(const std::wstring& value) { + if (value.find_first_of(L" \t\"") == std::wstring::npos) { + return value; + } + std::wstring output = L"\""; + std::size_t slashes = 0; + for (const auto character : value) { + if (character == L'\\') { + ++slashes; + } else if (character == L'\"') { + output.append(slashes * 2 + 1, L'\\'); + output += character; + slashes = 0; + } else { + output.append(slashes, L'\\'); + output += character; + slashes = 0; + } + } + output.append(slashes * 2, L'\\'); + return output + L"\""; +} + +std::wstring commandLine(const ProcessRequest& request) { + std::wstring value = quote(request.program.wstring()); + for (const auto& argument : request.arguments) { + value += L" " + quote(widen(argument)); + } + return value; +} + +class WindowsProcessRunner final : public IProcessRunner { +public: + ProcessResult run(const ProcessRequest& request) const override { + ProcessResult result; + SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE}; + HANDLE readPipe = nullptr; + HANDLE writePipe = nullptr; + if (!CreatePipe(&readPipe, &writePipe, &attributes, 0)) { + result.error = "CreatePipe failed."; + return result; + } + SetHandleInformation(readPipe, HANDLE_FLAG_INHERIT, 0); + + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + startup.dwFlags = STARTF_USESTDHANDLES; + startup.hStdOutput = writePipe; + startup.hStdError = writePipe; + startup.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + PROCESS_INFORMATION process{}; + auto command = commandLine(request); + const auto directory = request.workingDirectory.wstring(); + + const BOOL created = CreateProcessW(request.program.wstring().c_str(), command.data(), + nullptr, nullptr, TRUE, CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP, nullptr, + directory.empty() ? nullptr : directory.c_str(), &startup, &process); + CloseHandle(writePipe); + if (!created) { + CloseHandle(readPipe); + result.error = "CreateProcessW failed: " + std::to_string(GetLastError()); + return result; + } + + result.started = true; + HANDLE job = CreateJobObjectW(nullptr, nullptr); + if (job) { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)); + AssignProcessToJobObject(job, process.hProcess); + } + const DWORD waitTime = static_cast(request.timeout.count()); + if (WaitForSingleObject(process.hProcess, waitTime) == WAIT_TIMEOUT) { + result.timedOut = true; + if (job) { + TerminateJobObject(job, 1); + } else { + TerminateProcess(process.hProcess, 1); + } + WaitForSingleObject(process.hProcess, 1000); + } + + char buffer[4096]; + DWORD count = 0; + while (ReadFile(readPipe, buffer, sizeof(buffer), &count, nullptr) && count > 0) { + result.output.append(buffer, count); + } + DWORD exitCode = 1; + GetExitCodeProcess(process.hProcess, &exitCode); + result.exitCode = static_cast(exitCode); + + CloseHandle(readPipe); + if (job) { + CloseHandle(job); + } + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + return result; + } +}; + +} // namespace + +std::unique_ptr createDefaultProcessRunner() { + return std::make_unique(); +} + +} // namespace dts diff --git a/DateToSpine/src/preview/RuntimePlugin.cpp b/DateToSpine/src/preview/RuntimePlugin.cpp new file mode 100644 index 0000000..fc19082 --- /dev/null +++ b/DateToSpine/src/preview/RuntimePlugin.cpp @@ -0,0 +1,282 @@ +#include "preview/RuntimePlugin.hpp" + +#include +#include + +#if defined(_WIN32) +#define NOMINMAX +#include +#else +#include +#endif + +namespace dts { +namespace { + +void* openLibrary(const std::filesystem::path& path, std::string& error) { +#if defined(_WIN32) + auto* handle = LoadLibraryW(path.wstring().c_str()); + if (!handle) { + error = "LoadLibraryW failed: " + std::to_string(GetLastError()); + } + return handle; +#else + auto* handle = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL); + if (!handle) { + error = dlerror(); + } + return handle; +#endif +} + +void closeLibrary(void* handle) { +#if defined(_WIN32) + FreeLibrary(static_cast(handle)); +#else + dlclose(handle); +#endif +} + +void* findSymbol(void* handle, const char* name) { +#if defined(_WIN32) + return reinterpret_cast(GetProcAddress(static_cast(handle), name)); +#else + return dlsym(handle, name); +#endif +} + +template +Function functionPointer(void* symbol) { + Function function = nullptr; + static_assert(sizeof(function) == sizeof(symbol)); + std::memcpy(&function, &symbol, sizeof(function)); + return function; +} + +std::string utf8Path(const std::filesystem::path& path) { + const auto value = path.u8string(); + return {reinterpret_cast(value.data()), value.size()}; +} + +} // namespace + +RuntimePlugin::~RuntimePlugin() { + unload(); +} + +RuntimePlugin::RuntimePlugin(RuntimePlugin&& other) noexcept { + *this = std::move(other); +} + +RuntimePlugin& RuntimePlugin::operator=(RuntimePlugin&& other) noexcept { + if (this != &other) { + unload(); + handle_ = std::exchange(other.handle_, nullptr); + getApiVersion_ = std::exchange(other.getApiVersion_, nullptr); + getInfo_ = std::exchange(other.getInfo_, nullptr); + create_ = std::exchange(other.create_, nullptr); + destroy_ = std::exchange(other.destroy_, nullptr); + load_ = std::exchange(other.load_, nullptr); + getSkeletonInfo_ = std::exchange(other.getSkeletonInfo_, nullptr); + getAnimationName_ = std::exchange(other.getAnimationName_, nullptr); + getAnimationDuration_ = std::exchange(other.getAnimationDuration_, nullptr); + getSkinName_ = std::exchange(other.getSkinName_, nullptr); + getLastError_ = std::exchange(other.getLastError_, nullptr); + setAnimation_ = std::exchange(other.setAnimation_, nullptr); + update_ = std::exchange(other.update_, nullptr); + getDrawCommandCount_ = std::exchange(other.getDrawCommandCount_, nullptr); + getDrawCommand_ = std::exchange(other.getDrawCommand_, nullptr); + context_ = std::exchange(other.context_, nullptr); + } + return *this; +} + +bool RuntimePlugin::load(const std::filesystem::path& path, std::string& error) { + unload(); + handle_ = openLibrary(path, error); + if (!handle_) { + return false; + } + getApiVersion_ = functionPointer(findSymbol(handle_, "dtsRuntimeGetApiVersion")); + getInfo_ = functionPointer(findSymbol(handle_, "dtsRuntimeGetInfo")); + if (!getApiVersion_ || !getInfo_) { + error = "Runtime plugin does not expose the required DateToSpine ABI."; + unload(); + return false; + } + if (getApiVersion_() != DTS_RUNTIME_API_VERSION) { + error = "Runtime plugin API version is incompatible."; + unload(); + return false; + } + create_ = functionPointer(findSymbol(handle_, "dtsRuntimeCreate")); + destroy_ = functionPointer(findSymbol(handle_, "dtsRuntimeDestroy")); + load_ = functionPointer(findSymbol(handle_, "dtsRuntimeLoad")); + getSkeletonInfo_ = functionPointer( + findSymbol(handle_, "dtsRuntimeGetSkeletonInfo")); + getAnimationName_ = functionPointer( + findSymbol(handle_, "dtsRuntimeGetAnimationName")); + getAnimationDuration_ = functionPointer( + findSymbol(handle_, "dtsRuntimeGetAnimationDuration")); + getSkinName_ = functionPointer(findSymbol(handle_, "dtsRuntimeGetSkinName")); + getLastError_ = functionPointer(findSymbol(handle_, "dtsRuntimeGetLastError")); + setAnimation_ = functionPointer(findSymbol(handle_, "dtsRuntimeSetAnimation")); + update_ = functionPointer(findSymbol(handle_, "dtsRuntimeUpdate")); + getDrawCommandCount_ = functionPointer( + findSymbol(handle_, "dtsRuntimeGetDrawCommandCount")); + getDrawCommand_ = functionPointer( + findSymbol(handle_, "dtsRuntimeGetDrawCommand")); + return true; +} + +void RuntimePlugin::unload() { + if (context_ && destroy_) { + destroy_(context_); + } + context_ = nullptr; + getApiVersion_ = nullptr; + getInfo_ = nullptr; + create_ = nullptr; + destroy_ = nullptr; + load_ = nullptr; + getSkeletonInfo_ = nullptr; + getAnimationName_ = nullptr; + getAnimationDuration_ = nullptr; + getSkinName_ = nullptr; + getLastError_ = nullptr; + setAnimation_ = nullptr; + update_ = nullptr; + getDrawCommandCount_ = nullptr; + getDrawCommand_ = nullptr; + if (handle_) { + closeLibrary(handle_); + handle_ = nullptr; + } +} + +bool RuntimePlugin::isLoaded() const { + return handle_ != nullptr; +} + +std::optional RuntimePlugin::metadata(std::string& error) const { + if (!getInfo_) { + error = "Runtime plugin is not loaded."; + return std::nullopt; + } + DtsRuntimeInfo info{}; + info.structSize = sizeof(info); + if (getInfo_(&info) != 0 || !info.spineVersionLine || !info.adapterBuild) { + error = "Runtime plugin returned invalid metadata."; + return std::nullopt; + } + return RuntimePluginMetadata{ + info.apiVersion, + info.spineVersionLine, + info.adapterBuild, + info.capabilities + }; +} + +std::optional RuntimePlugin::loadSkeleton( + const std::filesystem::path& skeletonPath, + const std::filesystem::path& atlasPath, + float scale, + std::string& error) { + if (!create_ || !destroy_ || !load_ || !getSkeletonInfo_ || !getAnimationName_ + || !getAnimationDuration_ || !getSkinName_ || !getLastError_) { + error = "Runtime plugin does not provide skeleton metadata capability."; + return std::nullopt; + } + if (context_) { + destroy_(context_); + } + context_ = create_(); + if (!context_) { + error = "Runtime plugin could not create a context."; + return std::nullopt; + } + + const auto skeleton = utf8Path(skeletonPath); + const auto atlas = utf8Path(atlasPath); + DtsLoadRequest request{sizeof(DtsLoadRequest), skeleton.c_str(), atlas.c_str(), scale}; + if (load_(context_, &request) != 0) { + const auto* detail = getLastError_(context_); + error = detail ? detail : "Runtime plugin failed to load skeleton data."; + return std::nullopt; + } + + DtsSkeletonInfo info{}; + info.structSize = sizeof(info); + if (getSkeletonInfo_(context_, &info) != 0) { + error = "Runtime plugin failed to return skeleton metadata."; + return std::nullopt; + } + + SkeletonMetadata result; + result.boneCount = info.boneCount; + result.slotCount = info.slotCount; + result.eventCount = info.eventCount; + result.constraintCount = info.constraintCount; + result.animations.reserve(info.animationCount); + for (std::uint32_t index = 0; index < info.animationCount; ++index) { + const auto* name = getAnimationName_(context_, index); + result.animations.push_back({name ? name : "", getAnimationDuration_(context_, index)}); + } + result.skins.reserve(info.skinCount); + for (std::uint32_t index = 0; index < info.skinCount; ++index) { + const auto* name = getSkinName_(context_, index); + result.skins.emplace_back(name ? name : ""); + } + return result; +} + +bool RuntimePlugin::setAnimation(const std::string& name, bool loop, std::string& error) { + if (!context_ || !setAnimation_) { + error = "Runtime plugin does not provide animation playback."; + return false; + } + if (setAnimation_(context_, name.c_str(), loop ? 1 : 0) != 0) { + const auto* detail = getLastError_ ? getLastError_(context_) : nullptr; + error = detail ? detail : "Animation could not be selected."; + return false; + } + return true; +} + +std::optional> RuntimePlugin::updatePreview( + float deltaSeconds, std::string& error) { + if (!context_ || !update_ || !getDrawCommandCount_ || !getDrawCommand_) { + error = "Runtime plugin does not provide preview drawing."; + return std::nullopt; + } + if (update_(context_, deltaSeconds) != 0) { + const auto* detail = getLastError_ ? getLastError_(context_) : nullptr; + error = detail ? detail : "Preview frame could not be sampled."; + return std::nullopt; + } + std::vector result; + const auto count = getDrawCommandCount_(context_); + result.reserve(count); + for (std::uint32_t index = 0; index < count; ++index) { + DtsDrawCommand source{}; + source.structSize = sizeof(source); + if (getDrawCommand_(context_, index, &source) != 0 || !source.texturePathUtf8) { + error = "Runtime plugin returned an invalid draw command."; + return std::nullopt; + } + PreviewDrawCommand command; + command.texturePath = std::filesystem::path( + std::u8string(reinterpret_cast(source.texturePathUtf8))); + command.blendMode = source.blendMode; + command.indices.assign(source.indices, source.indices + source.indexCount); + command.vertices.reserve(source.vertexCount); + for (std::uint32_t vertex = 0; vertex < source.vertexCount; ++vertex) { + const auto& value = source.vertices[vertex]; + command.vertices.push_back({value.x, value.y, value.u, value.v, value.color}); + } + result.push_back(std::move(command)); + } + return result; +} + +} // namespace dts diff --git a/DateToSpine/src/preview/RuntimePlugin.hpp b/DateToSpine/src/preview/RuntimePlugin.hpp new file mode 100644 index 0000000..aaae822 --- /dev/null +++ b/DateToSpine/src/preview/RuntimePlugin.hpp @@ -0,0 +1,105 @@ +#pragma once + +#include "RuntimeApi.h" + +#include +#include +#include +#include +#include + +namespace dts { + +struct RuntimePluginMetadata { + std::uint32_t apiVersion = 0; + std::string spineVersionLine; + std::string adapterBuild; + std::uint32_t capabilities = 0; +}; + +struct AnimationMetadata { + std::string name; + float duration = 0; +}; + +struct SkeletonMetadata { + std::uint32_t boneCount = 0; + std::uint32_t slotCount = 0; + std::uint32_t eventCount = 0; + std::uint32_t constraintCount = 0; + std::vector skins; + std::vector animations; +}; + +struct PreviewVertex { + float x = 0; + float y = 0; + float u = 0; + float v = 0; + std::uint32_t color = 0xffffffff; +}; + +struct PreviewDrawCommand { + std::vector vertices; + std::vector indices; + std::filesystem::path texturePath; + std::uint32_t blendMode = 0; +}; + +class RuntimePlugin { +public: + RuntimePlugin() = default; + ~RuntimePlugin(); + + RuntimePlugin(const RuntimePlugin&) = delete; + RuntimePlugin& operator=(const RuntimePlugin&) = delete; + RuntimePlugin(RuntimePlugin&& other) noexcept; + RuntimePlugin& operator=(RuntimePlugin&& other) noexcept; + + [[nodiscard]] bool load(const std::filesystem::path& path, std::string& error); + void unload(); + [[nodiscard]] bool isLoaded() const; + [[nodiscard]] std::optional metadata(std::string& error) const; + [[nodiscard]] std::optional loadSkeleton( + const std::filesystem::path& skeletonPath, + const std::filesystem::path& atlasPath, + float scale, + std::string& error); + [[nodiscard]] bool setAnimation(const std::string& name, bool loop, std::string& error); + [[nodiscard]] std::optional> updatePreview( + float deltaSeconds, std::string& error); + +private: + using GetApiVersion = std::uint32_t (*)(); + using GetInfo = std::int32_t (*)(DtsRuntimeInfo*); + using Create = DtsRuntimeHandle (*)(); + using Destroy = void (*)(DtsRuntimeHandle); + using Load = std::int32_t (*)(DtsRuntimeHandle, const DtsLoadRequest*); + using GetSkeletonInfo = std::int32_t (*)(DtsRuntimeHandle, DtsSkeletonInfo*); + using GetName = const char* (*)(DtsRuntimeHandle, std::uint32_t); + using GetDuration = float (*)(DtsRuntimeHandle, std::uint32_t); + using GetLastError = const char* (*)(DtsRuntimeHandle); + using SetAnimation = std::int32_t (*)(DtsRuntimeHandle, const char*, std::int32_t); + using Update = std::int32_t (*)(DtsRuntimeHandle, float); + using GetDrawCommandCount = std::uint32_t (*)(DtsRuntimeHandle); + using GetDrawCommand = std::int32_t (*)(DtsRuntimeHandle, std::uint32_t, DtsDrawCommand*); + + void* handle_ = nullptr; + GetApiVersion getApiVersion_ = nullptr; + GetInfo getInfo_ = nullptr; + Create create_ = nullptr; + Destroy destroy_ = nullptr; + Load load_ = nullptr; + GetSkeletonInfo getSkeletonInfo_ = nullptr; + GetName getAnimationName_ = nullptr; + GetDuration getAnimationDuration_ = nullptr; + GetName getSkinName_ = nullptr; + GetLastError getLastError_ = nullptr; + SetAnimation setAnimation_ = nullptr; + Update update_ = nullptr; + GetDrawCommandCount getDrawCommandCount_ = nullptr; + GetDrawCommand getDrawCommand_ = nullptr; + DtsRuntimeHandle context_ = nullptr; +}; + +} // namespace dts diff --git a/DateToSpine/src/scan/SpineDataVersionDetector.cpp b/DateToSpine/src/scan/SpineDataVersionDetector.cpp new file mode 100644 index 0000000..eb0854c --- /dev/null +++ b/DateToSpine/src/scan/SpineDataVersionDetector.cpp @@ -0,0 +1,141 @@ +#include "scan/SpineDataVersionDetector.hpp" + +#include +#include +#include + +namespace dts { +namespace { + +constexpr std::size_t maxJsonProbeBytes = 1024 * 1024; +constexpr std::size_t maxBinaryStringBytes = 4096; + +std::optional readVarint(std::istream& input) { + std::uint32_t value = 0; + for (int shift = 0; shift < 35; shift += 7) { + const int byte = input.get(); + if (byte == std::char_traits::eof()) { + return std::nullopt; + } + value |= static_cast(byte & 0x7f) << shift; + if ((byte & 0x80) == 0) { + return value; + } + } + return std::nullopt; +} + +std::optional readBinaryString(std::istream& input) { + const auto encodedLength = readVarint(input); + if (!encodedLength || *encodedLength == 0) { + return std::nullopt; + } + const std::size_t length = *encodedLength - 1; + if (length > maxBinaryStringBytes) { + return std::nullopt; + } + std::string value(length, '\0'); + if (length > 0 && !input.read(value.data(), static_cast(length))) { + return std::nullopt; + } + return value; +} + +VersionDetectionResult finish(SkeletonDataFormat format, const SpineVersion& version) { + VersionDetectionResult result; + result.format = format; + result.version = version; + result.status = version.isSupported() + ? VersionDetectionStatus::Detected + : VersionDetectionStatus::UnsupportedVersion; + result.message = version.isSupported() + ? "Spine data version detected." + : "Spine data version is outside the supported range."; + return result; +} + +VersionDetectionResult inspectJson(std::ifstream& input) { + std::string text(maxJsonProbeBytes, '\0'); + input.read(text.data(), static_cast(text.size())); + text.resize(static_cast(input.gcount())); + + static const std::regex bones(R"("bones"\s*:)"); + static const std::regex version( + R"regex("spine"\s*:\s*"(\d+\.\d+(?:\.\d+)?(?:-[^"]+)?)")regex"); + std::smatch match; + if (!std::regex_search(text, bones) || !std::regex_search(text, match, version)) { + return {SkeletonDataFormat::Json, VersionDetectionStatus::InvalidData, std::nullopt, + "JSON does not contain the minimum Spine skeleton schema."}; + } + const auto parsed = SpineVersion::parse(match[1].str()); + if (!parsed) { + return {SkeletonDataFormat::Json, VersionDetectionStatus::InvalidData, std::nullopt, + "Spine JSON version is invalid."}; + } + return finish(SkeletonDataFormat::Json, *parsed); +} + +VersionDetectionResult inspectBinary(std::ifstream& input) { + const auto hash = readBinaryString(input); + const auto versionText = readBinaryString(input); + if (hash && versionText) { + if (const auto version = SpineVersion::parse(*versionText)) + return finish(SkeletonDataFormat::Binary, *version); + } + + input.clear(); + input.seekg(0); + std::string header(256, '\0'); + input.read(header.data(), static_cast(header.size())); + header.resize(static_cast(input.gcount())); + static const std::regex embeddedVersion( + R"regex((3\.8(?:\.\d+)?(?:-[A-Za-z0-9.-]+)?|4\.[0-3](?:\.\d+)?(?:-[A-Za-z0-9.-]+)?))regex"); + std::smatch match; + if (std::regex_search(header, match, embeddedVersion)) { + if (const auto version = SpineVersion::parse(match[1].str())) + return finish(SkeletonDataFormat::Binary, *version); + } + return {SkeletonDataFormat::Binary, VersionDetectionStatus::InvalidData, std::nullopt, + "SKEL header does not contain a valid Spine version."}; +} + +} // namespace + +VersionDetectionResult SpineDataVersionDetector::inspect(const std::filesystem::path& path) const { + std::ifstream input(path, std::ios::binary); + if (!input) { + return {SkeletonDataFormat::Unknown, VersionDetectionStatus::FileMissing, std::nullopt, + "Skeleton data file could not be opened."}; + } + + char first = '\0'; + while (input.get(first)) { + if (first != ' ' && first != '\t' && first != '\r' && first != '\n') { + break; + } + } + input.clear(); + input.seekg(0); + return first == '{' ? inspectJson(input) : inspectBinary(input); +} + +std::string toString(SkeletonDataFormat format) { + switch (format) { + case SkeletonDataFormat::Json: return "json"; + case SkeletonDataFormat::Binary: return "skel"; + case SkeletonDataFormat::Unknown: return "unknown"; + } + return "unknown"; +} + +std::string toString(VersionDetectionStatus status) { + switch (status) { + case VersionDetectionStatus::Detected: return "detected"; + case VersionDetectionStatus::FileMissing: return "file-missing"; + case VersionDetectionStatus::InvalidData: return "invalid-data"; + case VersionDetectionStatus::UnsupportedVersion: return "unsupported-version"; + } + return "unknown"; +} + +} // namespace dts diff --git a/DateToSpine/src/scan/SpineDataVersionDetector.hpp b/DateToSpine/src/scan/SpineDataVersionDetector.hpp new file mode 100644 index 0000000..87c4af7 --- /dev/null +++ b/DateToSpine/src/scan/SpineDataVersionDetector.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "domain/SpineVersion.hpp" + +#include +#include +#include + +namespace dts { + +enum class SkeletonDataFormat { Json, Binary, Unknown }; + +enum class VersionDetectionStatus { + Detected, + FileMissing, + InvalidData, + UnsupportedVersion +}; + +struct VersionDetectionResult { + SkeletonDataFormat format = SkeletonDataFormat::Unknown; + VersionDetectionStatus status = VersionDetectionStatus::InvalidData; + std::optional version; + std::string message; +}; + +class SpineDataVersionDetector { +public: + [[nodiscard]] VersionDetectionResult inspect(const std::filesystem::path& path) const; +}; + +[[nodiscard]] std::string toString(SkeletonDataFormat format); +[[nodiscard]] std::string toString(VersionDetectionStatus status); + +} // namespace dts + diff --git a/DateToSpine/src/ui/MainWindow.cpp b/DateToSpine/src/ui/MainWindow.cpp new file mode 100644 index 0000000..7b59ff6 --- /dev/null +++ b/DateToSpine/src/ui/MainWindow.cpp @@ -0,0 +1,437 @@ +#include "ui/MainWindow.hpp" + +#include "atlas/AtlasParser.hpp" +#include "conversion/ConversionJob.hpp" +#include "editor/SpineEditorLocator.hpp" +#include "editor/SpineVersionAvailability.hpp" +#include "scan/SpineDataVersionDetector.hpp" +#include "ui/SpinePreviewWidget.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace dts { +namespace { + +std::filesystem::path localPath(const QString& value) { +#if defined(_WIN32) + return std::filesystem::path(value.toStdWString()); +#else + return std::filesystem::path(value.toStdString()); +#endif +} + +QString platformLibraryName(const QString& line) { +#if defined(_WIN32) + return QStringLiteral("dts-runtime-%1.dll").arg(line); +#elif defined(__APPLE__) + return QStringLiteral("libdts-runtime-%1.dylib").arg(line); +#else + return QStringLiteral("libdts-runtime-%1.so").arg(line); +#endif +} + +} // namespace + +MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { + setupUi(); + setAcceptDrops(true); + setWindowTitle(tr("DateToSpine")); + resize(960, 680); +} + +void MainWindow::openResourcePath(const QString& path) { + addInput(path); +} + +void MainWindow::setupUi() { + auto* root = new QWidget; + auto* layout = new QVBoxLayout(root); + layout->setContentsMargins(28, 24, 28, 24); + layout->setSpacing(16); + + auto* top = new QHBoxLayout; + auto* title = new QLabel(tr("DateToSpine")); + title->setObjectName(QStringLiteral("title")); + availabilityLabel_ = new QLabel; + availabilityLabel_->setAlignment(Qt::AlignCenter); + availabilityLabel_->setMinimumWidth(190); + availabilityLabel_->setVisible(false); + resourceCombo_ = new QComboBox; + resourceCombo_->setMinimumWidth(260); + resourceCombo_->setVisible(false); + auto* addButton = new QPushButton(tr("选择资源")); + addButton->setObjectName(QStringLiteral("quietButton")); + top->addWidget(title); + top->addStretch(); + top->addWidget(availabilityLabel_); + top->addStretch(); + top->addWidget(resourceCombo_); + top->addWidget(addButton); + layout->addLayout(top); + + previewWidget_ = new SpinePreviewWidget; + previewWidget_->setObjectName(QStringLiteral("preview")); + layout->addWidget(previewWidget_, 1); + + auto* controls = new QHBoxLayout; + animationCombo_ = new QComboBox; + animationCombo_->setMinimumWidth(220); + animationCombo_->setEnabled(false); + statusLabel_ = new QLabel; + statusLabel_->setObjectName(QStringLiteral("status")); + unpremultiplyCheck_ = new QCheckBox(tr("去除预乘 Alpha")); + unpremultiplyCheck_->setToolTip(tr("由 Spine 官方纹理解包器转换为普通透明图片")); + convertButton_ = new QPushButton(tr("一键转换为 .spine")); + convertButton_->setObjectName(QStringLiteral("primaryButton")); + convertButton_->setEnabled(false); + controls->addWidget(new QLabel(tr("动画"))); + controls->addWidget(animationCombo_); + controls->addWidget(statusLabel_, 1); + controls->addWidget(unpremultiplyCheck_); + controls->addWidget(convertButton_); + layout->addLayout(controls); + + setCentralWidget(root); + setStyleSheet(QStringLiteral(R"( + QMainWindow, QWidget { background: #17191e; color: #f3f4f6; font-size: 14px; } + QLabel#title { font-size: 22px; font-weight: 650; } + QLabel#preview { background: #101217; border: 2px dashed #343944; border-radius: 14px; color: #a7adb8; } + QLabel#status { color: #9299a5; padding-left: 8px; } + QComboBox { background: #242832; border: 1px solid #3a404c; border-radius: 8px; padding: 8px 12px; } + QPushButton { border-radius: 8px; padding: 9px 16px; } + QPushButton#quietButton { background: #292d36; border: 1px solid #3d4350; } + QPushButton#primaryButton { background: #6e5eea; border: none; font-weight: 650; padding: 11px 20px; } + QPushButton#primaryButton:disabled { background: #343743; color: #777d89; } + QCheckBox { spacing: 8px; color: #c9ced8; } + QCheckBox::indicator { width: 18px; height: 18px; } + )")); + showEmptyState(); + + connect(addButton, &QPushButton::clicked, this, &MainWindow::chooseResources); + connect(resourceCombo_, &QComboBox::currentIndexChanged, + this, &MainWindow::resourceChanged); + connect(animationCombo_, &QComboBox::currentTextChanged, + this, &MainWindow::animationChanged); + connect(convertButton_, &QPushButton::clicked, this, &MainWindow::convertCurrent); + previewTimer_ = new QTimer(this); + previewTimer_->setInterval(16); + connect(previewTimer_, &QTimer::timeout, this, &MainWindow::advancePreview); +} + +void MainWindow::showEmptyState() { + previewWidget_->resetViewport(); + previewWidget_->setMessage( + tr("把 .json / .skel、.atlas 和图片拖到这里\n\n也可以拖入整个资源文件夹")); + statusLabel_->clear(); +} + +void MainWindow::chooseResources() { + const auto paths = QFileDialog::getOpenFileNames(this, tr("选择导出资源"), {}, + tr("Spine 资源 (*.json *.skel *.atlas *.png *.jpg *.jpeg *.webp);;所有文件 (*)")); + for (const auto& path : paths) addInput(path); +} + +void MainWindow::addInput(const QString& path) { + const QFileInfo info(path); + const auto scanDirectory = [this](const QString& directory, bool recursive) { + QDirIterator iterator(directory, {"*.json", "*.skel"}, QDir::Files, + recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags); + while (iterator.hasNext()) addSkeleton(iterator.next()); + }; + if (info.isDir()) scanDirectory(path, true); + else if (info.suffix().compare("json", Qt::CaseInsensitive) == 0 + || info.suffix().compare("skel", Qt::CaseInsensitive) == 0) addSkeleton(path); + else scanDirectory(info.absolutePath(), false); + scheduleAvailabilityCheck(); +} + +void MainWindow::addSkeleton(const QString& path) { + const auto result = SpineDataVersionDetector{}.inspect(localPath(path)); + if (result.status != VersionDetectionStatus::Detected || skeletonPaths_.contains(path)) return; + skeletonPaths_.append(path); + resourceCombo_->addItem(QFileInfo(path).completeBaseName(), path); + resourceCombo_->setVisible(true); + resourceCombo_->setCurrentIndex(resourceCombo_->count() - 1); +} + +QString MainWindow::findAtlas(const QString& skeletonPath) const { + const QFileInfo skeleton(skeletonPath); + QDir directory(skeleton.absolutePath()); + const QString base = skeleton.completeBaseName(); + for (const auto& name : {base + ".atlas", base + ".atlas.txt"}) { + const auto candidate = directory.filePath(name); + if (QFileInfo::exists(candidate)) return candidate; + } + const auto atlases = directory.entryInfoList({"*.atlas", "*.atlas.txt"}, QDir::Files); + if (atlases.size() == 1) return atlases.front().absoluteFilePath(); + for (const auto& atlas : atlases) { + if (base.startsWith(atlas.completeBaseName()) + || atlas.completeBaseName().startsWith(base)) return atlas.absoluteFilePath(); + } + return {}; +} + +QString MainWindow::runtimeLibrary(const QString& versionLine) const { + const QDir executableDirectory(QCoreApplication::applicationDirPath()); +#if defined(__APPLE__) + return QDir::cleanPath(executableDirectory.filePath( + "../PlugIns/runtimes/" + platformLibraryName(versionLine))); +#else + return executableDirectory.filePath("runtimes/" + platformLibraryName(versionLine)); +#endif +} + +void MainWindow::resourceChanged(int index) { + if (index >= 0) { + loadCurrentPreview(); + scheduleAvailabilityCheck(); + } +} + +void MainWindow::loadCurrentPreview() { + previewTimer_->stop(); + previewWidget_->resetViewport(); + animationCombo_->clear(); + animationCombo_->setEnabled(false); + convertButton_->setEnabled(false); + const QString skeletonPath = resourceCombo_->currentData().toString(); + const QString atlasPath = findAtlas(skeletonPath); + const auto detection = SpineDataVersionDetector{}.inspect(localPath(skeletonPath)); + if (!detection.version || atlasPath.isEmpty()) { + previewWidget_->setMessage(tr("没有找到与骨骼数据配套的 .atlas 文件")); + statusLabel_->setText(tr("请把完整导出资源一起拖入")); + return; + } + currentVersion_ = QString::fromStdString(detection.version->toString()); + std::string error; + if (!runtime_.load(localPath(runtimeLibrary( + QString::fromStdString(detection.version->line()))), error)) { + previewWidget_->setMessage(tr("暂时无法加载这套资源")); + statusLabel_->setText(QString::fromStdString(error)); + return; + } + const auto metadata = runtime_.loadSkeleton( + localPath(skeletonPath), localPath(atlasPath), 1.0F, error); + if (!metadata) { + previewWidget_->setMessage(tr("此版本的预览模块仍在接入中")); + statusLabel_->setText(tr("资源已识别,可以稍后转换")); + return; + } + for (const auto& animation : metadata->animations) + animationCombo_->addItem(QString::fromStdString(animation.name), animation.duration); + animationCombo_->setEnabled(animationCombo_->count() > 0); + statusLabel_->setText(tr("已识别 %1 个动画").arg(metadata->animations.size())); + if (animationCombo_->count() > 0) { + animationChanged(animationCombo_->itemText(0)); + } else { + auto frame = runtime_.updatePreview(0.0F, error); + if (frame) previewWidget_->setFrame(std::move(*frame)); + else previewWidget_->setMessage(tr("没有可播放的动画")); + } +} + +void MainWindow::animationChanged(const QString& name) { + if (name.isEmpty()) return; + std::string error; + if (!runtime_.setAnimation(name.toStdString(), true, error)) { + statusLabel_->setText(QString::fromStdString(error)); + return; + } + advancePreview(); + previewTimer_->start(); +} + +void MainWindow::advancePreview() { + std::string error; + auto frame = runtime_.updatePreview(0.016F, error); + if (!frame) { + previewTimer_->stop(); + previewWidget_->setMessage(tr("动画预览暂时不可用")); + statusLabel_->setText(QString::fromStdString(error)); + return; + } + previewWidget_->setFrame(std::move(*frame)); +} + +QString MainWindow::resolveEditor() { + QSettings settings; + SpineEditorLocator locator; + const auto saved = settings.value("spineEditorPath").toString(); + if (!saved.isEmpty()) { + if (const auto normalized = locator.normalize(localPath(saved))) + return QString::fromStdString(normalized->string()); + } + const auto found = locator.discover(); + if (!found.empty()) return QString::fromStdString(found.front().string()); + return {}; +} + +void MainWindow::scheduleAvailabilityCheck() { + if (availabilityCheckScheduled_) return; + availabilityCheckScheduled_ = true; + QTimer::singleShot(0, this, [this] { + availabilityCheckScheduled_ = false; + refreshAvailability(); + }); +} + +void MainWindow::showAvailability(const QString& text, const QString& color) { + availabilityLabel_->setText(QStringLiteral("● ") + text); + availabilityLabel_->setStyleSheet(QStringLiteral( + "QLabel { color: %1; background: #20242c; border: 1px solid #343a46; " + "border-radius: 8px; padding: 7px 12px; font-weight: 600; }").arg(color)); + availabilityLabel_->setVisible(true); +} + +void MainWindow::refreshAvailability() { + availableVersions_.clear(); + currentEditorVersion_.clear(); + editorPath_ = resolveEditor(); + SpineVersionAvailability availability; + for (const auto& path : skeletonPaths_) { + const auto detection = SpineDataVersionDetector{}.inspect(localPath(path)); + if (!detection.version) continue; + const auto line = QString::fromStdString(detection.version->line()); + if (const auto installed = availability.newestForLine(detection.version->line())) + availableVersions_.insert(line, QString::fromStdString(installed->toString())); + } + const auto current = SpineDataVersionDetector{}.inspect( + localPath(resourceCombo_->currentData().toString())); + if (!current.version) return; + const auto atlasPath = findAtlas(resourceCombo_->currentData().toString()); + if (atlasPath.isEmpty()) { + showAvailability(tr("缺少配套 Atlas"), QStringLiteral("#ef6b73")); + convertButton_->setText(tr("资源不完整")); + convertButton_->setEnabled(false); + return; + } + const auto atlas = AtlasParser{}.parseFile(localPath(atlasPath)); + if (!atlas.document) { + showAvailability(tr("Atlas 无法识别"), QStringLiteral("#ef6b73")); + convertButton_->setText(tr("资源不完整")); + convertButton_->setEnabled(false); + return; + } + const QDir atlasDirectory = QFileInfo(atlasPath).dir(); + for (const auto& page : atlas.document->pages) { + const auto pagePath = QString::fromStdString(page.imagePath.string()); + if (!QFileInfo::exists(atlasDirectory.filePath(pagePath))) { + showAvailability(tr("缺少纹理页 %1").arg(QFileInfo(pagePath).fileName()), + QStringLiteral("#ef6b73")); + convertButton_->setText(tr("资源不完整")); + convertButton_->setEnabled(false); + return; + } + } + const auto line = QString::fromStdString(current.version->line()); + if (editorPath_.isEmpty()) { + showAvailability(tr("未找到 Spine Editor"), QStringLiteral("#f2b84b")); + convertButton_->setText(tr("指定 Spine 路径")); + convertButton_->setEnabled(true); + return; + } + currentEditorVersion_ = availableVersions_.value(line); + if (currentEditorVersion_.isEmpty()) { + showAvailability(tr("缺少 Spine %1").arg(line), QStringLiteral("#ef6b73")); + convertButton_->setText(tr("指定 Spine %1 路径").arg(line)); + convertButton_->setEnabled(true); + return; + } + showAvailability(tr("可转换 · Spine %1").arg(currentEditorVersion_), + QStringLiteral("#55d68b")); + convertButton_->setText(tr("一键转换为 .spine")); + convertButton_->setEnabled(true); +} + +bool MainWindow::chooseEditorPath() { + const auto selected = QFileDialog::getOpenFileName(this, + tr("指定 Spine 程序"), {}, tr("所有文件 (*)")); + if (selected.isEmpty()) return false; + SpineEditorLocator locator; + const auto normalized = locator.normalize(localPath(selected)); + if (!normalized) { + showAvailability(tr("选择的路径不是 Spine Editor"), QStringLiteral("#ef6b73")); + return false; + } + QSettings{}.setValue("spineEditorPath", selected); + editorPath_ = QString::fromStdString(normalized->string()); + refreshAvailability(); + return !currentEditorVersion_.isEmpty(); +} + +void MainWindow::convertCurrent() { + if (editorPath_.isEmpty() || currentEditorVersion_.isEmpty()) + if (!chooseEditorPath()) return; + const QString input = resourceCombo_->currentData().toString(); + const auto outputParent = QFileDialog::getExistingDirectory(this, + tr("选择输出位置"), QFileInfo(input).absolutePath()); + if (outputParent.isEmpty()) return; + const auto atlas = findAtlas(input); + if (atlas.isEmpty()) { + QMessageBox::critical(this, tr("转换失败"), tr("没有找到配套的 .atlas 文件。")); + return; + } + ConversionRequest request{ + editorPath_, currentEditorVersion_, input, atlas, outputParent, + QFileInfo(input).completeBaseName(), unpremultiplyCheck_->isChecked()}; + conversionJob_ = new ConversionJob(std::move(request), this); + connect(conversionJob_, &ConversionJob::finished, + this, &MainWindow::conversionFinished); + setBusy(true); + conversionJob_->start(); +} + +void MainWindow::setBusy(bool busy) { + convertButton_->setEnabled(!busy); + convertButton_->setText(busy ? tr("正在转换…") : tr("一键转换为 .spine")); + if (busy) showAvailability(tr("正在使用 Spine %1 转换").arg(currentEditorVersion_), + QStringLiteral("#72a7ff")); +} + +void MainWindow::conversionFinished(bool success, const QString& outputDirectory, + const QString& error) { + conversionJob_->deleteLater(); + conversionJob_ = nullptr; + setBusy(false); + statusLabel_->setText(success ? tr("转换完成") : tr("转换失败")); + statusLabel_->setToolTip(success ? outputDirectory : error); + refreshAvailability(); + if (success) { + QMessageBox::information(this, tr("转换完成"), + tr("完整资源已生成:\n%1").arg(outputDirectory)); + } else { + QMessageBox::critical(this, tr("转换失败"), + error.isEmpty() ? tr("转换未完成,Spine Editor 未提供详细原因。") : error); + } +} + +void MainWindow::dragEnterEvent(QDragEnterEvent* event) { + if (event->mimeData()->hasUrls()) event->acceptProposedAction(); +} + +void MainWindow::dropEvent(QDropEvent* event) { + for (const auto& url : event->mimeData()->urls()) + if (url.isLocalFile()) addInput(url.toLocalFile()); + event->acceptProposedAction(); +} + +} // namespace dts diff --git a/DateToSpine/src/ui/MainWindow.hpp b/DateToSpine/src/ui/MainWindow.hpp new file mode 100644 index 0000000..e86159d --- /dev/null +++ b/DateToSpine/src/ui/MainWindow.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include "preview/RuntimePlugin.hpp" + +#include +#include +#include + +class QComboBox; +class QCheckBox; +class QLabel; +class QPushButton; +class QTimer; + +namespace dts { + +class SpinePreviewWidget; +class ConversionJob; + +class MainWindow final : public QMainWindow { + Q_OBJECT + +public: + explicit MainWindow(QWidget* parent = nullptr); + void openResourcePath(const QString& path); + +protected: + void dragEnterEvent(QDragEnterEvent* event) override; + void dropEvent(QDropEvent* event) override; + +private slots: + void chooseResources(); + void resourceChanged(int index); + void convertCurrent(); + void conversionFinished(bool success, const QString& outputDirectory, + const QString& error); + void animationChanged(const QString& name); + void advancePreview(); + +private: + void setupUi(); + void addInput(const QString& path); + void addSkeleton(const QString& path); + void loadCurrentPreview(); + QString findAtlas(const QString& skeletonPath) const; + QString runtimeLibrary(const QString& versionLine) const; + QString resolveEditor(); + void scheduleAvailabilityCheck(); + void refreshAvailability(); + bool chooseEditorPath(); + void showAvailability(const QString& text, const QString& color); + void showEmptyState(); + void setBusy(bool busy); + + QComboBox* resourceCombo_ = nullptr; + QComboBox* animationCombo_ = nullptr; + QCheckBox* unpremultiplyCheck_ = nullptr; + SpinePreviewWidget* previewWidget_ = nullptr; + QLabel* statusLabel_ = nullptr; + QLabel* availabilityLabel_ = nullptr; + QPushButton* convertButton_ = nullptr; + QStringList skeletonPaths_; + QString currentVersion_; + QString currentEditorVersion_; + QString editorPath_; + QHash availableVersions_; + bool availabilityCheckScheduled_ = false; + RuntimePlugin runtime_; + ConversionJob* conversionJob_ = nullptr; + QTimer* previewTimer_ = nullptr; +}; + +} // namespace dts diff --git a/DateToSpine/src/ui/SpinePreviewWidget.cpp b/DateToSpine/src/ui/SpinePreviewWidget.cpp new file mode 100644 index 0000000..0035a1d --- /dev/null +++ b/DateToSpine/src/ui/SpinePreviewWidget.cpp @@ -0,0 +1,195 @@ +#include "ui/SpinePreviewWidget.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace dts { +namespace { + +QString localString(const std::filesystem::path& path) { +#if defined(_WIN32) + return QString::fromStdWString(path.wstring()); +#else + const auto text = path.u8string(); + return QString::fromUtf8(reinterpret_cast(text.data()), + static_cast(text.size())); +#endif +} + +QPainter::CompositionMode compositionMode(std::uint32_t blendMode) { + switch (blendMode) { + case 1: return QPainter::CompositionMode_Plus; + case 2: return QPainter::CompositionMode_Multiply; + case 3: return QPainter::CompositionMode_Screen; + default: return QPainter::CompositionMode_SourceOver; + } +} + +} // namespace + +SpinePreviewWidget::SpinePreviewWidget(QWidget* parent) : QWidget(parent) { + setMinimumSize(640, 440); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + setAutoFillBackground(false); + setMouseTracking(true); + setToolTip(tr("滚轮缩放 · 中键重置 · 右键拖动 · 右键单击居中")); +} + +void SpinePreviewWidget::setFrame(std::vector commands) { + if (!viewBounds_ && !commands.empty()) { + float left = std::numeric_limits::max(); + float right = std::numeric_limits::lowest(); + float bottom = std::numeric_limits::max(); + float top = std::numeric_limits::lowest(); + for (const auto& command : commands) for (const auto& vertex : command.vertices) { + left = std::min(left, vertex.x); + right = std::max(right, vertex.x); + bottom = std::min(bottom, vertex.y); + top = std::max(top, vertex.y); + } + if (right > left && top > bottom) + viewBounds_ = QRectF(left, bottom, right - left, top - bottom); + } + commands_ = std::move(commands); + message_.clear(); + update(); +} + +void SpinePreviewWidget::resetViewport() { + viewBounds_.reset(); + zoom_ = 1.0F; + pan_ = {}; +} + +void SpinePreviewWidget::setMessage(const QString& message) { + commands_.clear(); + message_ = message; + update(); +} + +const QImage& SpinePreviewWidget::texture(const std::filesystem::path& path) { + const auto key = localString(path); + auto found = textures_.find(key); + if (found == textures_.end()) found = textures_.insert(key, QImage(key)); + return found.value(); +} + +void SpinePreviewWidget::paintEvent(QPaintEvent*) { + QPainter painter(this); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + painter.fillRect(rect(), QColor("#101217")); + if (commands_.empty()) { + painter.setPen(QColor("#a7adb8")); + painter.drawText(rect(), Qt::AlignCenter | Qt::TextWordWrap, message_); + return; + } + + const QRectF bounds = viewBounds_.value_or(QRectF(0, 0, 1, 1)); + const float contentWidth = std::max(1.0, bounds.width()); + const float contentHeight = std::max(1.0, bounds.height()); + const float baseScale = 0.94F * std::min(width() / contentWidth, height() / contentHeight); + const float scale = baseScale * zoom_; + const QPointF center = bounds.center(); + const auto toScreen = [this, center, scale](const PreviewVertex& vertex) { + return QPointF(width() * 0.5 + pan_.x() + (vertex.x - center.x()) * scale, + height() * 0.5 + pan_.y() + (vertex.y - center.y()) * scale); + }; + + for (const auto& command : commands_) { + const auto& image = texture(command.texturePath); + if (image.isNull()) continue; + painter.setCompositionMode(compositionMode(command.blendMode)); + for (std::size_t index = 0; index + 2 < command.indices.size(); index += 3) { + const auto i0 = command.indices[index]; + const auto i1 = command.indices[index + 1]; + const auto i2 = command.indices[index + 2]; + if (i0 >= command.vertices.size() || i1 >= command.vertices.size() + || i2 >= command.vertices.size()) continue; + const auto& v0 = command.vertices[i0]; + const auto& v1 = command.vertices[i1]; + const auto& v2 = command.vertices[i2]; + const QPointF s0(v0.u * image.width(), v0.v * image.height()); + const QPointF s1(v1.u * image.width(), v1.v * image.height()); + const QPointF s2(v2.u * image.width(), v2.v * image.height()); + const QPointF d0 = toScreen(v0); + const QPointF d1 = toScreen(v1); + const QPointF d2 = toScreen(v2); + QPolygonF source{s0, s1, s2, s1 + s2 - s0}; + QPolygonF destination{d0, d1, d2, d1 + d2 - d0}; + QTransform transform; + if (!QTransform::quadToQuad(source, destination, transform)) continue; + QPainterPath clip; + clip.addPolygon(QPolygonF{d0, d1, d2}); + painter.save(); + painter.setClipPath(clip); + painter.setOpacity(QColor::fromRgba(v0.color).alphaF()); + painter.setTransform(transform); + painter.drawImage(QPointF(0, 0), image); + painter.restore(); + } + } +} + +void SpinePreviewWidget::wheelEvent(QWheelEvent* event) { + if (commands_.empty() || event->angleDelta().y() == 0) return; + const float previousZoom = zoom_; + const float factor = event->angleDelta().y() > 0 ? 1.12F : 1.0F / 1.12F; + zoom_ = std::clamp(previousZoom * factor, 0.15F, 8.0F); + const QPointF canvasCenter(width() * 0.5, height() * 0.5); + const QPointF cursorOffset = event->position() - canvasCenter; + pan_ = cursorOffset - (cursorOffset - pan_) * (zoom_ / previousZoom); + update(); + event->accept(); +} + +void SpinePreviewWidget::mousePressEvent(QMouseEvent* event) { + if (event->button() == Qt::MiddleButton) { + zoom_ = 1.0F; + pan_ = {}; + update(); + event->accept(); + return; + } + if (event->button() == Qt::RightButton) { + rightPressPosition_ = event->position(); + lastMousePosition_ = event->position(); + rightDragging_ = false; + setCursor(Qt::ClosedHandCursor); + event->accept(); + } +} + +void SpinePreviewWidget::mouseMoveEvent(QMouseEvent* event) { + if (!(event->buttons() & Qt::RightButton)) return; + if (!rightDragging_ && (event->position() - rightPressPosition_).manhattanLength() + >= QApplication::startDragDistance()) rightDragging_ = true; + if (rightDragging_) { + pan_ += event->position() - lastMousePosition_; + update(); + } + lastMousePosition_ = event->position(); + event->accept(); +} + +void SpinePreviewWidget::mouseReleaseEvent(QMouseEvent* event) { + if (event->button() != Qt::RightButton) return; + unsetCursor(); + if (!rightDragging_) { + pan_ = {}; + update(); + } + rightDragging_ = false; + event->accept(); +} + +} // namespace dts diff --git a/DateToSpine/src/ui/SpinePreviewWidget.hpp b/DateToSpine/src/ui/SpinePreviewWidget.hpp new file mode 100644 index 0000000..f88de55 --- /dev/null +++ b/DateToSpine/src/ui/SpinePreviewWidget.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "preview/RuntimePlugin.hpp" + +#include +#include +#include +#include +#include + +#include + +namespace dts { + +class SpinePreviewWidget final : public QWidget { +public: + explicit SpinePreviewWidget(QWidget* parent = nullptr); + + void setFrame(std::vector commands); + void setMessage(const QString& message); + void resetViewport(); + +protected: + void paintEvent(QPaintEvent* event) override; + void wheelEvent(QWheelEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + +private: + const QImage& texture(const std::filesystem::path& path); + + std::vector commands_; + QHash textures_; + QString message_; + std::optional viewBounds_; + float zoom_ = 1.0F; + QPointF pan_; + QPointF rightPressPosition_; + QPointF lastMousePosition_; + bool rightDragging_ = false; +}; + +} // namespace dts diff --git a/DateToSpine/tests/unit/CoreTests.cpp b/DateToSpine/tests/unit/CoreTests.cpp new file mode 100644 index 0000000..8678ce3 --- /dev/null +++ b/DateToSpine/tests/unit/CoreTests.cpp @@ -0,0 +1,350 @@ +#include "atlas/AtlasParser.hpp" +#include "domain/SpineVersion.hpp" +#include "editor/SpineEditorLocator.hpp" +#include "editor/SpineOutputParser.hpp" +#include "editor/SpineVersionAvailability.hpp" +#include "platform/ProcessRunner.hpp" +#include "preview/RuntimePlugin.hpp" +#include "scan/SpineDataVersionDetector.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int failures = 0; + +void expect(bool condition, const std::string& message) { + if (!condition) { + std::cerr << "FAILED: " << message << '\n'; + ++failures; + } +} + +void testVersions() { + const auto supported = dts::SpineVersion::parse("3.8.99"); + expect(supported.has_value(), "3.8.99 parses"); + expect(supported && supported->line() == "3.8", "version line is 3.8"); + expect(supported && supported->isSupported(), "3.8.99 is supported"); + + const auto early = dts::SpineVersion::parse("3.8.19"); + expect(early && !early->isSupported(), "3.8.19 is outside formal support"); + const auto current = dts::SpineVersion::parse("4.3.6"); + expect(current && current->isSupported(), "4.3 is supported"); + const auto beta = dts::SpineVersion::parse("4.3.75-beta"); + expect(beta && beta->toString() == "4.3.75", "version suffix is accepted"); + const auto old = dts::SpineVersion::parse("3.7.94"); + expect(old && !old->isSupported(), "3.7 is unsupported"); + expect(!dts::SpineVersion::parse("four.two"), "invalid version is rejected"); +} + +void testLauncherOutput() { + const std::string output = + "Spine Launcher 4.3.06 (macOS Apple Silicon)\n" + "Launching: Spine 3.8.99 Professional\n" + "Starting: Spine 3.8.99\n"; + dts::ProcessResult process{true, false, 0, output, {}}; + const auto result = dts::SpineOutputParser{}.parse("/Applications/Spine", process); + expect(result.status == dts::EditorProbeStatus::Available, "probe is available"); + expect(result.launcherVersion && result.launcherVersion->toString() == "4.3.6", + "launcher version is parsed separately"); + expect(result.editorVersion && result.editorVersion->toString() == "3.8.99", + "editor version is parsed separately"); + expect(result.edition == "Professional", "edition is parsed"); +} + +void testTimedOutOutput() { + dts::ProcessResult process; + process.started = true; + process.timedOut = true; + process.exitCode = 143; + process.output = "Spine Launcher 4.3.06\nLaunching: Spine 4.2.83 Professional\n"; + const auto result = dts::SpineOutputParser{}.parse("Spine", process); + expect(result.status == dts::EditorProbeStatus::ObservedButTimedOut, + "recognized timeout is distinguished from missing Spine"); +} + +void testMacBundleNormalization() { +#if defined(__APPLE__) + const auto path = dts::SpineEditorLocator{}.normalize("/Applications/Spine.app"); + expect(path.has_value(), "macOS .app is normalized"); + expect(path && path->filename() == "Spine", "normalized executable is Spine"); +#endif +} + +void testProcessTimeout() { +#if !defined(_WIN32) + auto runner = dts::createDefaultProcessRunner(); + dts::ProcessRequest request; + request.program = "/bin/sleep"; + request.arguments = {"5"}; + request.timeout = std::chrono::milliseconds(50); + const auto result = runner->run(request); + expect(result.started, "timeout test process starts"); + expect(result.timedOut, "long-running process is timed out"); +#endif +} + +void writeVarint(std::ofstream& output, std::size_t value) { + while (true) { + auto byte = static_cast(value & 0x7f); + value >>= 7; + if (value != 0) { + byte |= 0x80; + } + output.put(static_cast(byte)); + if (value == 0) { + return; + } + } +} + +void writeBinaryString(std::ofstream& output, const std::string& value) { + writeVarint(output, value.size() + 1); + output.write(value.data(), static_cast(value.size())); +} + +void testDataVersionDetection() { + const auto directory = std::filesystem::temp_directory_path() / "dts-core-tests"; + std::filesystem::create_directories(directory); + + const auto jsonPath = directory / "skeleton.json"; + { + std::ofstream output(jsonPath); + output << R"({"skeleton":{"spine":"4.2.17"},"bones":[{"name":"root"}]})"; + } + const auto json = dts::SpineDataVersionDetector{}.inspect(jsonPath); + expect(json.status == dts::VersionDetectionStatus::Detected, "Spine JSON is detected"); + expect(json.version && json.version->toString() == "4.2.17", "JSON version is parsed"); + + const auto binaryPath = directory / "skeleton.skel"; + { + std::ofstream output(binaryPath, std::ios::binary); + writeBinaryString(output, "fixture-hash"); + writeBinaryString(output, "3.8.99"); + } + const auto binary = dts::SpineDataVersionDetector{}.inspect(binaryPath); + expect(binary.status == dts::VersionDetectionStatus::Detected, "SKEL is detected"); + expect(binary.version && binary.version->toString() == "3.8.99", "SKEL version is parsed"); + + const auto genericPath = directory / "generic.json"; + { + std::ofstream output(genericPath); + output << R"({"spine":"4.2.0","items":[]})"; + } + const auto generic = dts::SpineDataVersionDetector{}.inspect(genericPath); + expect(generic.status == dts::VersionDetectionStatus::InvalidData, + "ordinary JSON is not accepted as Spine data"); + std::filesystem::remove_all(directory); +} + +void testAtlasParsing() { + const std::string atlas = + "hero.webp\n" + "size: 1024, 512\n" + "filter: Linear, Linear\n" + "scale: 0.5\n" + "pma: true\n" + "body/arm\n" + "bounds: 10, 20, 30, 40\n" + "offsets: 1, 2, 32, 44\n" + "rotate: 90\n" + "\n" + "hero-2.jpg\n" + "size: 256, 256\n" + "face\n" + "xy: 4, 5\n" + "size: 20, 30\n" + "orig: 22, 34\n"; + const auto result = dts::AtlasParser{}.parseText(atlas); + expect(result.document.has_value(), "multi-page Atlas parses"); + expect(result.document && result.document->pages.size() == 2, "two pages are found"); + const auto& first = result.document->pages.front(); + expect(first.imagePath == "hero.webp", "WebP page path is retained"); + expect(first.premultipliedAlpha == true, "PMA is parsed"); + expect(first.scale && std::abs(*first.scale - 0.5) < 0.000001, + "Atlas page scale is parsed"); + expect(first.regions.size() == 1 && first.regions.front().rotated, + "rotated region is parsed"); + const auto& second = result.document->pages.back(); + expect(second.regions.size() == 1 && second.regions.front().size.size() == 2, + "legacy region size is parsed"); +} + +void testInstalledVersionDiscovery() { + const auto directory = std::filesystem::temp_directory_path() / "dts-version-cache-tests"; + std::filesystem::create_directories(directory); + for (const auto* name : {"4.3.18", "4.3.23", "4.2.43", "not-a-version"}) { + std::ofstream output(directory / name, std::ios::binary); + output.put('x'); + } + dts::SpineVersionAvailability availability; + const auto selected = availability.newestForLine("4.3", {directory}); + expect(selected && selected->toString() == "4.3.23", + "newest locally cached patch is selected for a version line"); + expect(selected && availability.isCached(*selected, {directory}), + "selected exact editor version is confirmed in local cache"); + expect(!availability.newestForLine("4.1", {directory}), + "missing cached version line is not reported as available"); + std::filesystem::remove_all(directory); +} + +void expectRuntimePlugin(const std::string& path, const std::string& versionLine) { + dts::RuntimePlugin plugin; + std::string error; + expect(plugin.load(path, error), versionLine + " Runtime plugin loads: " + error); + const auto metadata = plugin.metadata(error); + expect(metadata.has_value(), "Runtime metadata is available: " + error); + expect(metadata && metadata->apiVersion == DTS_RUNTIME_API_VERSION, + "Runtime API version matches"); + expect(metadata && metadata->spineVersionLine == versionLine, + "Runtime line is " + versionLine); +} + +void testRuntimePlugins() { +#if defined(DTS_RUNTIME_38_PATH) + expectRuntimePlugin(DTS_RUNTIME_38_PATH, "3.8"); + expectRuntimePlugin(DTS_RUNTIME_40_PATH, "4.0"); + expectRuntimePlugin(DTS_RUNTIME_41_PATH, "4.1"); + expectRuntimePlugin(DTS_RUNTIME_42_PATH, "4.2"); + expectRuntimePlugin(DTS_RUNTIME_43_PATH, "4.3"); +#endif +} + +void testRuntime43OfficialAssets() { +#if defined(DTS_TEST_SPINEBOY_JSON) + const auto jsonVersion = dts::SpineDataVersionDetector{}.inspect(DTS_TEST_SPINEBOY_JSON); + expect(jsonVersion.status == dts::VersionDetectionStatus::Detected, + "official 4.3 beta JSON version is detected"); + const auto binaryVersion = dts::SpineDataVersionDetector{}.inspect(DTS_TEST_SPINEBOY_SKEL); + expect(binaryVersion.status == dts::VersionDetectionStatus::Detected, + "official 4.3 binary header version is detected"); + dts::RuntimePlugin plugin; + std::string error; + expect(plugin.load(DTS_RUNTIME_43_PATH, error), "4.3 Runtime loads for official assets"); + const auto json = plugin.loadSkeleton( + DTS_TEST_SPINEBOY_JSON, DTS_TEST_SPINEBOY_ATLAS, 1.0F, error); + expect(json.has_value(), "official Spineboy JSON loads: " + error); + expect(json && json->boneCount > 0, "Spineboy JSON contains bones"); + expect(json && !json->animations.empty(), "Spineboy JSON contains animations"); + if (json && !json->animations.empty()) { + const auto idle = std::find_if(json->animations.begin(), json->animations.end(), + [](const auto& animation) { return animation.name == "idle"; }); + const auto& animated = idle == json->animations.end() + ? json->animations.front() : *idle; + expect(plugin.setAnimation(animated.name, true, error), + "Spineboy animation can be selected: " + error); + const auto frame = plugin.updatePreview(1.0F / 60.0F, error); + expect(frame.has_value(), "Spineboy preview frame is sampled: " + error); + expect(frame && !frame->empty(), "Spineboy preview contains draw commands"); + if (frame && !frame->empty()) { + expect(!frame->front().vertices.empty(), "draw command contains vertices"); + expect(!frame->front().indices.empty(), "draw command contains triangles"); + expect(std::filesystem::exists(frame->front().texturePath), + "draw command texture exists"); + } + const auto laterFrame = plugin.updatePreview(0.25F, error); + bool moved = false; + if (frame && laterFrame && frame->size() == laterFrame->size()) { + for (std::size_t command = 0; command < frame->size() && !moved; ++command) { + const auto& before = (*frame)[command].vertices; + const auto& after = (*laterFrame)[command].vertices; + for (std::size_t vertex = 0; vertex < std::min(before.size(), after.size()); ++vertex) { + if (std::abs(before[vertex].x - after[vertex].x) > 0.001F + || std::abs(before[vertex].y - after[vertex].y) > 0.001F) { + moved = true; + break; + } + } + } + } + expect(moved, "animation sampling changes vertex positions over time"); + } + + error.clear(); + const auto binary = plugin.loadSkeleton( + DTS_TEST_SPINEBOY_SKEL, DTS_TEST_SPINEBOY_ATLAS, 1.0F, error); + expect(binary.has_value(), "official Spineboy SKEL loads: " + error); + expect(binary && binary->boneCount > 0, "Spineboy SKEL contains bones"); + expect(binary && !binary->animations.empty(), "Spineboy SKEL contains animations"); +#endif +} + +void testRuntime42OfficialAssets() { +#if defined(DTS_TEST_42_JSON) + dts::RuntimePlugin plugin; + std::string error; + expect(plugin.load(DTS_RUNTIME_42_PATH, error), "4.2 Runtime loads for official assets"); + const auto json = plugin.loadSkeleton(DTS_TEST_42_JSON, DTS_TEST_42_ATLAS, 1.0F, error); + expect(json.has_value(), "official 4.2 Spineboy JSON loads: " + error); + expect(json && !json->animations.empty(), "official 4.2 JSON contains animations"); + if (json && !json->animations.empty()) { + expect(plugin.setAnimation(json->animations.front().name, true, error), + "4.2 animation can be selected: " + error); + const auto frame = plugin.updatePreview(1.0F / 60.0F, error); + expect(frame && !frame->empty(), "4.2 preview produces draw commands: " + error); + } + error.clear(); + const auto binary = plugin.loadSkeleton(DTS_TEST_42_SKEL, DTS_TEST_42_ATLAS, 1.0F, error); + expect(binary.has_value(), "official 4.2 Spineboy SKEL loads: " + error); +#endif +} + +void testLegacyOfficialAsset(const char* line, const char* runtimePath, + const char* jsonPath, const char* binaryPath, const char* atlasPath) { + dts::RuntimePlugin plugin; + std::string error; + expect(plugin.load(runtimePath, error), std::string(line) + " Runtime loads for assets"); + const auto json = plugin.loadSkeleton(jsonPath, atlasPath, 1.0F, error); + expect(json.has_value(), std::string("official ") + line + " JSON loads: " + error); + expect(json && !json->animations.empty(), std::string(line) + " JSON contains animations"); + if (json && !json->animations.empty()) { + expect(plugin.setAnimation(json->animations.front().name, true, error), + std::string(line) + " animation can be selected: " + error); + const auto first = plugin.updatePreview(1.0F / 60.0F, error); + expect(first && !first->empty(), std::string(line) + " preview draws: " + error); + const auto later = plugin.updatePreview(0.25F, error); + expect(later && !later->empty(), std::string(line) + " later frame draws: " + error); + } + error.clear(); + const auto binary = plugin.loadSkeleton(binaryPath, atlasPath, 1.0F, error); + expect(binary.has_value(), std::string("official ") + line + " SKEL loads: " + error); +} + +void testLegacyOfficialAssets() { +#if defined(DTS_TEST_38_JSON) + testLegacyOfficialAsset("3.8", DTS_RUNTIME_38_PATH, + DTS_TEST_38_JSON, DTS_TEST_38_SKEL, DTS_TEST_38_ATLAS); + testLegacyOfficialAsset("4.0", DTS_RUNTIME_40_PATH, + DTS_TEST_40_JSON, DTS_TEST_40_SKEL, DTS_TEST_40_ATLAS); + testLegacyOfficialAsset("4.1", DTS_RUNTIME_41_PATH, + DTS_TEST_41_JSON, DTS_TEST_41_SKEL, DTS_TEST_41_ATLAS); +#endif +} + +} // namespace + +int main() { + testVersions(); + testLauncherOutput(); + testTimedOutOutput(); + testMacBundleNormalization(); + testProcessTimeout(); + testDataVersionDetection(); + testAtlasParsing(); + testInstalledVersionDiscovery(); + testRuntimePlugins(); + testLegacyOfficialAssets(); + testRuntime42OfficialAssets(); + testRuntime43OfficialAssets(); + if (failures == 0) { + std::cout << "All core tests passed.\n"; + } + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/DateToSpine/third_party/spine-runtimes/3.8 b/DateToSpine/third_party/spine-runtimes/3.8 new file mode 160000 index 0000000..8b4844b --- /dev/null +++ b/DateToSpine/third_party/spine-runtimes/3.8 @@ -0,0 +1 @@ +Subproject commit 8b4844bd4b193ba9e54487ed397a777993cbad56 diff --git a/DateToSpine/third_party/spine-runtimes/4.0 b/DateToSpine/third_party/spine-runtimes/4.0 new file mode 160000 index 0000000..425ce41 --- /dev/null +++ b/DateToSpine/third_party/spine-runtimes/4.0 @@ -0,0 +1 @@ +Subproject commit 425ce416bb218b28caeec47b317aa57cd7140375 diff --git a/DateToSpine/third_party/spine-runtimes/4.1 b/DateToSpine/third_party/spine-runtimes/4.1 new file mode 160000 index 0000000..77a5db0 --- /dev/null +++ b/DateToSpine/third_party/spine-runtimes/4.1 @@ -0,0 +1 @@ +Subproject commit 77a5db0ec6d16331f5efbaa7662bba9355bd3424 diff --git a/DateToSpine/third_party/spine-runtimes/4.2 b/DateToSpine/third_party/spine-runtimes/4.2 new file mode 160000 index 0000000..e7dc143 --- /dev/null +++ b/DateToSpine/third_party/spine-runtimes/4.2 @@ -0,0 +1 @@ +Subproject commit e7dc1435fa4a0083ab431f1b28e083c14a1f5c68 diff --git a/DateToSpine/third_party/spine-runtimes/4.3 b/DateToSpine/third_party/spine-runtimes/4.3 new file mode 160000 index 0000000..4309c05 --- /dev/null +++ b/DateToSpine/third_party/spine-runtimes/4.3 @@ -0,0 +1 @@ +Subproject commit 4309c05c287d3f15da778e68f5d2a483fe10a6a3