Metin2 game client (P0–P11) + mobile asset pipeline

Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
  phases, EntityStore world model, ~all GC/CG headers. char create/delete,
  private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
  quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
  char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
  token), system-option + game-option + ESC system menu, private-shop 39-grid,
  party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
  dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.

Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.

Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).

ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+46
View File
@@ -0,0 +1,46 @@
# oracle —— 独立 Windows 工程(不在主 CMake 树内)。见 ../docs/steps/00-oracle.md、RUNBOOK.md
#
# cmake -S oracle -B oracle/build -DGRANNY_SDK=<泄露 SDK 根>
# cmake --build oracle/build --config Release
#
# GRANNY_SDK 默认指向本仓库同级的 MobileSource 里的 Granny-3D-SDK-main。
# 链 granny2_x64.libimport lib);运行时需要对应的 granny2_x64.dll 在 PATH / exe 目录。
# ⚠ 这个 DLL 是 SDK 2.9.12。资产由 2.4.0.7 导出(M0 T2 实测)。容器 + 组合公式在 2.4–2.11
# 稳定,但 B 样条边界 / 少数曲线细节可能有差异 —— 先跑 selfcheck(T7)确认坐标约定,
# 再决定是否要换成 2.4.x 的 runtime DLL(见 GRANNY-VERSION.md)。
cmake_minimum_required(VERSION 3.20)
project(xrender_oracle C)
set(CMAKE_C_STANDARD 11)
set(GRANNY_SDK "${CMAKE_SOURCE_DIR}/../../MobileSource/Cross Platform/Granny-3D-SDK-main"
CACHE PATH "泄露 Granny SDK 根(含 include/ 和 lib/win64/")
if(NOT EXISTS "${GRANNY_SDK}/include/granny.h")
message(FATAL_ERROR "找不到 ${GRANNY_SDK}/include/granny.h —— 传 -DGRANNY_SDK=<SDK 根>")
endif()
add_executable(oracle oracle.c)
target_include_directories(oracle PRIVATE "${GRANNY_SDK}/include")
if(WIN32)
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
target_link_libraries(oracle PRIVATE "${GRANNY_SDK}/lib/win64/granny2_x64.lib")
set(_dll "${GRANNY_SDK}/lib/win64/granny2_x64.dll")
else()
target_link_libraries(oracle PRIVATE "${GRANNY_SDK}/lib/win32/granny2.lib")
set(_dll "${GRANNY_SDK}/lib/win32/granny2.dll")
endif()
# 把 DLL 拷到 exe 旁边,方便直接跑 / Wine
add_custom_command(TARGET oracle POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_dll}" "$<TARGET_FILE_DIR:oracle>")
else()
message(WARNING
"非 WindowsSDK 只提供 win32/win64/wii/ps3/xenon 的 granny2 预编译库。\n"
" 选项 A:在 Windows 机器 / VM 上 build+run。\n"
" 选项 BWineoracle 只做文件 IO + 数学 + 写二进制,不碰 GPUWine 够用)——\n"
" 用 MinGW 交叉编译或直接在 Wine 里跑 MSVC,链 granny2_x64.lib。\n"
" 选项 Cstretch):从 SDK source/ 移植 winxx/ 平台层出一个 POSIX libgranny。\n"
" 见 RUNBOOK.md。")
endif()
+45
View File
@@ -0,0 +1,45 @@
# oracle dump 二进制格式
> `oracle.c``oracle dump`)产出。libgr2 侧对拍读取器(`tools/oracle_diff/`)必须与本文件一致。
> `oracle dump` 把**层① 世界矩阵**和**mesh N 蒙皮顶点**写进**同一个文件**:先文件头,
> 再 `kind=1` 段,紧接着 `kind=3` 段(`--mesh` 未命中时 `vertex_count=0`)。全部小端。
## 文件头(所有 dump 共用)
```
magic "XORC" 4B
version u32 1
kind u32 1=层① 2=层② 3=蒙皮顶点 1
coord u32 坐标约定编码: 1
bit0 手系 (0=左手 1=右手)
bit1 单位 (0=原始 1=米)
bit2 根变换已 apply
granny_ver char[16] 选定 granny2.dll 版本串
model_path len-prefixed str
anim_path len-prefixed str
t f32 采样时刻
```
## kind=1/2 段(骨骼世界矩阵,紧跟文件头)
```
bone_count u32
matrices f32[bone_count][16] GrannyGetWorldPose4x4Array 直出(骨骼空间→世界),行主序 Granny 原生
```
> ⚠ 不是 `GrannyGetWorldPoseComposite4x4Array` —— 那个返回 `InverseWorld4x4·world` = **蒙皮矩阵**bind pose 时 ≈ I)。
> `GrannyBuildWorldPose` 的 `Offset4x4` 传的是 model 的 `InitialPlacement``GrannyGetModelInitialPlacement4x4`),
> 与 libgr2 `skeleton.initial_placement` 一致;不传的话非单位 IP 的模型(如 assassin 双 root)会差一个平移。
## kind=3 段(mesh N 蒙皮顶点,紧跟 kind=1 段)
```
vertex_count u32 (--mesh 未命中 → 0,无后续)
positions f32[vertex_count][3] GrannyDeformVertices(PositionNormal) 后的位置
```
## 坐标约定
**M0/oracle 全程 raw**`coord` = 手系按选定 DLL 实际输出、单位=原始、根变换未 apply。
libgr2 的 `gr2::Mat4` 声明同一约定(见 `docs/steps/M0-gr2-reader.md` 「约定」)。
basis fix / 单位缩放是 M1 渲染时才做的事,不进 dump。
+53
View File
@@ -0,0 +1,53 @@
# Granny 版本判定(oracle T1
## 已知(M0 T2
- 资产由 **Granny Standard Exporter, SDK version 2.4.0.7** 导出(从解压后的 gr2 内嵌串读到)。
- 9166 个 gr2 里 **9155 个文件格式 v6、11 个 v7**magicv6=`GRNFileMV_Old`v7=`GRNFileMV_32Bit_LittleEndian`)。
- libgr2 自研,`bind_pose_self_check` 全量 2013/2017 多骨 skeleton < 1e-3`warrior_cheongrin` 6.1e-5)。
## 候选 runtime
| DLL | 来源 | 状态 |
|---|---|---|
| `granny2_x64.dll` 2.9.12 | 泄露 SDK `lib/win64/` | **先用这个**(SDK 自带、能直接链)。容器 + 组合公式在 2.4–2.11 稳定。 |
| granny2.dll 2.4.x | 需另找(对应 2.4.0.7 exporter 的 runtime | 若 2.9.12 在曲线 / B 样条边界上和 libgr2 分歧且 Blender 仲裁站 libgr2 这边,换这个。 |
| granny2.dll 2.11.x | m2dev-client-src 头文件写的版本 | 兜底候选。 |
## 判定步骤(跑 `oracle probe`
对每个候选 DLL,跑:
```
oracle probe "assets/PC/ymir work/pc/warrior/warrior_cheongrin.gr2"
oracle probe "assets/PC/ymir work/pc/warrior/action/dance_1.gr2"
oracle probe "assets/Zone/ymir work/zone/.../<某静态件>.gr2"
```
AC(选定 DLL 下):
- `warrior_cheongrin.gr2``Skeletons=1``Meshes=5``Animations=0``Models=1`
`Materials=4``Textures=2`、skeleton 0 = 75 骨、骨骼名以 `Bip01` 开头
—— 与 libgr2`gr2dump`**逐字段一致**。
- `dance_1.gr2``Animations=1``Duration≈28.333334``TrackGroups=1`
- 静态件:`Skeletons=0` 或 1、`Meshes≥1`
- 3+ 文件全过;其余候选 DLL 的失败表现记进本表。
## 选定结果 —— ✅ 已跑(2026-08-29
- **选定:`granny2_x64.dll` 2.9.12.0**(泄露 SDK `lib/win64/`)。
- 环境:macOS 26.4 / Apple Silicon / wine-stable 11.0Rosetta/ MinGW-w64 交叉编译(`build-wine.sh`)。
- **`oracle probe` vs `gr2dump` 逐字段一致**
| 文件 | Textures | Materials | Skeletons | Meshes | Models | Animations | skel0 骨骼数 | 骨骼名/父索引 |
|---|---|---|---|---|---|---|---|---|
| `warrior_cheongrin.gr2` | 2=2 | 4=4 | 1=1 | 5=5 | 1=1 | 0=0 | 75=75 | 逐项一致(Bip01…)|
| `assassin.gr2` | 3=3 | 6=6 | 1=1 | 17=17 | 1=1 | 0=0 | 95=95 | 逐项一致(含双 root`assasin_low` + `Bip01`|
| `dance_1.gr2` | — | — | 0 | 0 | 0 | 1=1 | — | Duration 28.333334TrackGroups 1 |
- `FromFileName` 连非法编码的 Korean 路径都逐字节一致(libgr2 读原始字节 = Granny)。
**结论**2.4 导出的资产在 2.9.12 runtime 下 `GrannyGetFileInfo` 完全正常,libgr2 与之逐字段对齐。
容器 + 组合公式在 2.4–2.9 稳定这一假设成立,**不需要换 2.4.x runtime**。
`oracle.exe` + `granny2_x64.dll` 不进仓库(体积 + 版权);`build-wine.sh` 一条命令重建。
+43
View File
@@ -0,0 +1,43 @@
# oracle/ —— Windows 真值源工具
**独立 Windows/MSVC 工程,不在主 CMake 树内。** 施工文档:[`../docs/steps/00-oracle.md`](../docs/steps/00-oracle.md)
## 两条轨
| 轨 | 产物 | 状态 |
|---|---|---|
| **Oracle-Lite**(只用 `granny2.dll` C API | `granny_probe.exe` · `oracle dump` CLI · 最小 D3D9 渲染器 | 必做,关键路径 |
| **Oracle-Full**+ 抽出的 `EterGrnLib` | `oracle_etergrn` + 层② dump | 延后,demo 不需要 |
## 目录
```
oracle/
oracle.c ★ Oracle-Lite 全部:probeT1+ dumpT3 层① / T5 蒙皮顶点)
CMakeLists.txt Windows/MSVC 工程,链泄露 SDK 的 granny2_x64.lib
RUNBOOK.md 怎么 build+run(原生 Windows / Wine / stretch: POSIX 移植)
GRANNY-VERSION.md 版本判定 + 候选对比(待跑 probe 填实)
FORMAT.md dump 二进制布局 + 坐标约定
vendor/ 锁定的 granny2 DLLT1 选定后 git-track
render/ 独立最小 D3D9 → PNGT6,不依赖 EterGrnLib;待写)
selfcheck.* T7 自检(Windows CI runner;逻辑见 RUNBOOK.md「selfcheck」)
full/ Oracle-Fulloracle_etergrn + 层② dumpT2/T4,延后)
```
## 状态
- [x] `oracle.c` —— probe + dump(层① 世界矩阵 + mesh N 蒙皮顶点)
- [x] **macOS + Wine 跑通**`build-wine.sh` = MinGW-w64 交叉编译 + wine-stable)。Granny **2.9.12.0**
- [x] **T1** DLL 选定 + probe vs `gr2dump` 逐字段一致(`GRANNY-VERSION.md`
- [x] **T3/T5** 层① dump + 蒙皮顶点 dump 跑通;**M2 门禁·主对拍 PASS**
- `warrior_cheongrin` bind + `dance_1` @ t={0, 4.7, 11.333, 20}:骨骼矩阵 `max|Δ| ≤ 4.1e-5`、蒙皮顶点 `max‖Δ‖ ≤ 6.5e-5`
- `assassin`(双 root,非单位 InitialPlacementbind`3.1e-5 / 4.6e-5`
- 对拍工具 `tools/oracle_diff`libgr2 侧读 `.bin` 逐字段比)
- [ ] T8 noise_floor.json(跑同 dump 100 次 + 换相邻 DLL 一次,取 `max|Δ|`
- [ ] T6 D3D9 确定态截图 harnessM1 视觉对拍用)
- [ ] Oracle-Full(层②,`EterGrnLib`)—— 延后
## 输出
- dump → `../test/oracle_dumps/`(格式见 `FORMAT.md`
- 噪声地板 → `../test/noise_floor.json`T8M2 用它设 ε)
+88
View File
@@ -0,0 +1,88 @@
# oracle 运行手册(Oracle-Lite
`oracle.c` 只用 `granny2` 的公开 C API`granny.h`):文件 IO + 数学 + 写二进制,**不碰 GPU / D3D / EterGrnLib**。
所以除了原生 WindowsWine 也能跑。
## 依赖
- `granny.h` + `granny2_x64.lib` + `granny2_x64.dll` —— 泄露 SDK 里就有:
`MobileSource/Cross Platform/Granny-3D-SDK-main/{include,lib/win64}/`
32 位版在 `lib/win32/`。)
- 这套 DLL 是 **SDK 2.9.12**。资产由 **2.4.0.7** 导出(M0 T2 从解压后的 gr2 里读到)。
容器格式 + 组合公式(`BuildCompositeTransform4x4` / `ColumnMatrixMultiply4x3` / B 样条系数)
在 2.42.11 稳定,libgr2 的 self-check 6.1e-5 已印证。差异风险在 B 样条边界 / 少数曲线细节。
→ 先跑 `selfcheck`(下)确认坐标约定和 t=0 == bind pose,再决定是否换 2.4.x runtime(见 `GRANNY-VERSION.md`)。
## A. 原生 WindowsMSVC
```bat
cmake -S oracle -B oracle\build -DGRANNY_SDK="C:\path\to\Granny-3D-SDK-main"
cmake --build oracle\build --config Release
oracle\build\Release\oracle.exe probe "assets\PC\ymir work\pc\warrior\warrior_cheongrin.gr2"
```
## B. macOS + Wine —— ✅ 已验证(macOS 26.4 / Apple Silicon
```sh
brew install mingw-w64
# wine-stable cask 需要 sudo 装 gstreamer 依赖;无 sudo 时直接解包用:
tar -xf "$(ls ~/Library/Caches/Homebrew/downloads/*wine-stable*.tar.xz)" -C /tmp/wine
xattr -rc "/tmp/wine/Wine Stable.app"
WINE="/tmp/wine/Wine Stable.app/Contents/Resources/wine/bin/wine"
# 编译(脚本封装了 MinGW 的坑:granny.h 的 __int64 关键字)
oracle/build-wine.sh # → oracle/oracle.exe + oracle/granny2_x64.dll
# ⚠ WINEPREFIX 不能在 /tmpWine 拒绝在非属主目录建 prefix)
export WINEPREFIX="$HOME/.wine-oracle" WINEDEBUG=-all
"$WINE" wineboot --init # 首次,Rosetta 下 38 分钟
cd oracle
GR2='Z:\Users\me\...\assets\PC\ymir work\pc\warrior\warrior_cheongrin.gr2'
"$WINE" oracle.exe probe "$GR2"
"$WINE" oracle.exe dump "$GR2" - 0.0 \
-o 'Z:\Users\me\...\xrender-poc\test\oracle_dumps\cheongrin_bind_t0.bin' --mesh 4
"$WINE" oracle.exe dump "$GR2" 'Z:\...\action\dance_1.gr2' 11.333 \
-o 'Z:\...\test\oracle_dumps\cheongrin_dance1_t11.333.bin' --mesh 4
```
坑:① MinGW GCC 不认 `granny.h` 里的 `__int64``build-wine.sh``-D__int64="long long"`
② Wine prefix 不能放 `/tmp`(非属主);③ wine-stable 首启会刷一大堆 MoltenVK 日志到 stderr,无害,`WINEDEBUG=-all` 挡不住它,grep 掉即可。
## C.stretch)从 SDK source/ 移植 POSIX libgranny
`Granny-3D-SDK-main/source/` 有全套源码(62k 行)+ CMakeLists,但重度 Windows 化
`winxx/win64/*.cpp``winxx_granny_windows.cpp``WINDOWS_IGNORE_PACKING_MISMATCH`)。
`wii/``wiiu/``ps3/` 目录里有 `ansi_*` 的通用实现可参考。真要做是几小时的平台层移植,
不在关键路径 —— 先走 A / B。
## 对拍(`tools/oracle_diff`
```sh
build/tools/oracle_diff <model.gr2> <anim.gr2|-> <dump.bin> [--t <sec>] [--mesh N]
```
libgr2 侧用 `gr2::sample_pose` / `bind_pose` + 手写 LBS 算一遍,报:
- **gate 1**:骨骼世界矩阵 vs oracle 层① `max|Δ|`
- **gate 2**mesh N 蒙皮顶点 vs oracle `max‖Δ‖`
- verdict:阈值 1e-3noise_floor 就绪前)
**已跑结果**Granny 2.9.12.0 vs libgr2):
| 用例 | mat max\|Δ\| | vtx max‖Δ‖ |
|---|---|---|
| `warrior_cheongrin` bind | 1.5e-5 | 4.6e-5 |
| `warrior_cheongrin` + `dance_1` @ t=0/4.7/11.333/20 | ≤ 4.1e-5 | ≤ 6.5e-5 |
| `assassin` bind(双 root + 非单位 InitialPlacement| 3.1e-5 | 4.6e-5 |
全部 ≈ float32 累积误差 → **libgr2 的类型树 / 世界姿势 / OldCurve 求值 / CPU LBS 与真 Granny 数值一致**
> 关键:`oracle.c` 的 `GrannyBuildWorldPose` 要把 model 的 `InitialPlacement` 当 `Offset4x4` 传
> `GrannyGetModelInitialPlacement4x4`),否则非单位 InitialPlacement 的模型(如 assassin)会差一个平移。
> libgr2 的 `skeleton.initial_placement` 做同一件事。踩过一次。
## 产出
- dump → `test/oracle_dumps/`(布局见 `FORMAT.md`
- 噪声地板 → `test/noise_floor.json``oracle dump` 跑同 `(model,anim,t)` 100 次 + 换一版
相邻 DLL 一次,取矩阵 / 顶点的 `max|Δ|`M2 的 ε = 这个 ×10
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# 在 macOS 上用 MinGW-w64 交叉编译 oracle.exe(链泄露 SDK 的 granny2_x64.lib),
# 用 Wine 跑。见 RUNBOOK.md。实测 macOS 26.4 / Apple Silicon / wine-stable 11.0 / Granny 2.9.12.
set -euo pipefail
cd "$(dirname "$0")"
SDK="${GRANNY_SDK:-../../MobileSource/Cross Platform/Granny-3D-SDK-main}"
[ -f "$SDK/include/granny.h" ] || { echo "找不到 $SDK/include/granny.h(传 GRANNY_SDK=<SDK 根>"; exit 1; }
CC="${MINGW_CC:-x86_64-w64-mingw32-gcc}"
command -v "$CC" >/dev/null || { echo "没装 MinGWbrew install mingw-w64"; exit 1; }
# 关键:granny.h 在 _WIN64 分支里 `#define GRANNY_64BIT_TYPE __int64`
# MinGW GCC 默认不认 __int64 关键字 → 用 -D__int64="long long" 顶掉。
"$CC" -O2 -std=gnu11 -fms-extensions -D'__int64=long long' \
-I "$SDK/include" \
oracle.c "$SDK/lib/win64/granny2_x64.lib" \
-o oracle.exe
cp "$SDK/lib/win64/granny2_x64.dll" ./
echo "built: $(pwd)/oracle.exe (+ granny2_x64.dll)"
echo
echo "跑(Wine):"
echo " export WINEPREFIX=\$HOME/.wine-oracle # ⚠ 别放 /tmpWine 拒绝在非属主目录建 prefix"
echo " wine wineboot --init # 首次,Rosetta 下较慢"
echo " wine oracle.exe probe 'Z:\\path\\to\\warrior_cheongrin.gr2'"
echo " wine oracle.exe dump 'Z:\\...\\model.gr2' 'Z:\\...\\anim.gr2' <t> -o 'Z:\\...\\out.bin' --mesh N"
echo
echo "对拍:build/tools/oracle_diff <model.gr2> <anim.gr2|-> <out.bin> --mesh N"
+215
View File
@@ -0,0 +1,215 @@
/* oracle —— Windows 真值源(Oracle-Lite)。见 ../docs/steps/00-oracle.md
*
* oracle probe <file.gr2>
* → 打 GrannyGetFileInfo 的 count / FromFileName / 每 skeleton 骨骼数 + 前几个骨骼名
*
* oracle dump <model.gr2> <anim.gr2|-> <t> -o out.bin [--mesh N]
* → 层① 世界矩阵(GrannyGetWorldPoseComposite4x4Array 直出)+ mesh N 蒙皮顶点
* → 二进制布局见 ../FORMAT.md;libgr2 侧对拍读取器必须与之一致
*
* 只用 granny2 的公开 C APIgranny.h)。链 granny2_x64.lib(见 CMakeLists.txt / RUNBOOK.md)。
* 不碰 D3D、不碰 EterGrnLib —— 那是 Oracle-Full(延后)。
*/
#include <granny.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#define ORC_MAGIC "XORC"
#define ORC_VERSION 1u
#define KIND_LAYER1 1u
#define KIND_SKINV 3u
static void die(const char *msg) { fprintf(stderr, "oracle: %s\n", msg); exit(2); }
static const char *granny_version_string(void) {
/* 运行期版本(链接到的 granny2.dll 实际报的)。编译期宏是 GrannyProduct*Version。*/
const char *rt = GrannyGetVersionString();
return rt ? rt : "unknown";
}
/* ── probe ─────────────────────────────────────────────────────────── */
static int cmd_probe(const char *path) {
granny_file *f = GrannyReadEntireFile(path);
if (!f) { fprintf(stderr, "probe: cannot read %s\n", path); return 1; }
granny_file_info *fi = GrannyGetFileInfo(f);
if (!fi) { fprintf(stderr, "probe: GrannyGetFileInfo NULL\n"); return 1; }
printf("file : %s\n", path);
printf("granny runtime : %s\n", granny_version_string());
printf("FromFileName : %s\n", fi->FromFileName ? fi->FromFileName : "(null)");
printf("Textures=%d Materials=%d Skeletons=%d VertexDatas=%d TriTopologies=%d "
"Meshes=%d Models=%d TrackGroups=%d Animations=%d\n",
fi->TextureCount, fi->MaterialCount, fi->SkeletonCount, fi->VertexDataCount,
fi->TriTopologyCount, fi->MeshCount, fi->ModelCount, fi->TrackGroupCount,
fi->AnimationCount);
for (int s = 0; s < fi->SkeletonCount; ++s) {
granny_skeleton *sk = fi->Skeletons[s];
printf(" skeleton %d: %d bones : ", s, sk->BoneCount);
for (int b = 0; b < sk->BoneCount && b < 6; ++b)
printf("[%d]%s(p=%d) ", b, sk->Bones[b].Name, sk->Bones[b].ParentIndex);
printf("%s\n", sk->BoneCount > 6 ? "..." : "");
}
for (int a = 0; a < fi->AnimationCount; ++a)
printf(" animation %d: '%s' Duration=%.6f TimeStep=%.6f TrackGroups=%d\n",
a, fi->Animations[a]->Name, fi->Animations[a]->Duration,
fi->Animations[a]->TimeStep, fi->Animations[a]->TrackGroupCount);
GrannyFreeFile(f);
return 0;
}
/* ── dump helpers ──────────────────────────────────────────────────── */
static void wr_u32(FILE *o, uint32_t v) { fwrite(&v, 4, 1, o); }
static void wr_f32(FILE *o, float v) { fwrite(&v, 4, 1, o); }
static void wr_str(FILE *o, const char *s) {
uint32_t n = (uint32_t)(s ? strlen(s) : 0);
wr_u32(o, n);
if (n) fwrite(s, 1, n, o);
}
/* 与 ../FORMAT.md「文件头」一致 */
static void write_header(FILE *o, uint32_t kind, uint32_t coord,
const char *model_path, const char *anim_path, float t) {
fwrite(ORC_MAGIC, 1, 4, o);
wr_u32(o, ORC_VERSION);
wr_u32(o, kind);
wr_u32(o, coord);
char gv[16]; memset(gv, 0, sizeof gv);
{ const char *v = granny_version_string(); size_t n = strlen(v);
memcpy(gv, v, n < 15 ? n : 15); }
fwrite(gv, 1, 16, o);
wr_str(o, model_path);
wr_str(o, anim_path ? anim_path : "");
wr_f32(o, t);
}
/* ── dump ──────────────────────────────────────────────────────────── */
static int cmd_dump(int argc, char **argv) {
if (argc < 4) die("dump <model.gr2> <anim.gr2|-> <t> -o out.bin [--mesh N]");
const char *model_path = argv[0];
const char *anim_path = strcmp(argv[1], "-") == 0 ? NULL : argv[1];
float t = (float)atof(argv[2]);
const char *out_path = NULL;
int mesh_index = 0;
for (int i = 3; i < argc; ++i) {
if (!strcmp(argv[i], "-o") && i + 1 < argc) out_path = argv[++i];
else if (!strcmp(argv[i], "--mesh") && i + 1 < argc) mesh_index = atoi(argv[++i]);
}
if (!out_path) die("dump: 缺 -o out.bin");
granny_file *mf = GrannyReadEntireFile(model_path);
if (!mf) die("dump: model 读失败");
granny_file_info *mfi = GrannyGetFileInfo(mf);
if (!mfi || mfi->ModelCount < 1) die("dump: model 无 Models[0]");
granny_model_instance *mi = GrannyInstantiateModel(mfi->Models[0]);
if (!mi) die("dump: GrannyInstantiateModel 失败");
granny_skeleton *skel = GrannyGetSourceSkeleton(mi);
int bone_count = skel->BoneCount;
granny_file *af = NULL;
if (anim_path) {
af = GrannyReadEntireFile(anim_path);
if (!af) die("dump: anim 读失败");
granny_file_info *afi = GrannyGetFileInfo(af);
if (!afi || afi->AnimationCount < 1) die("dump: anim 无 Animations[0]");
granny_control *ctrl = GrannyPlayControlledAnimation(0.0f, afi->Animations[0], mi);
if (!ctrl) die("dump: GrannyPlayControlledAnimation 失败");
}
GrannySetModelClock(mi, t);
/* Offset4x4 = model 的 InitialPlacement(客户端渲染时也这么给)。
* libgr2 侧 gr2::sample_pose 用 skeleton.initial_placement 做同一件事。 */
granny_real32 placement[16];
GrannyGetModelInitialPlacement4x4(mfi->Models[0], placement);
granny_local_pose *lp = GrannyNewLocalPose(bone_count);
granny_world_pose *wp = GrannyNewWorldPose(bone_count);
/* 先填 rest(无 track 的骨骼用它);有动画再让 SampleModelAnimations 覆盖动的那些。 */
GrannyBuildRestLocalPose(skel, 0, bone_count, lp);
if (af) GrannySampleModelAnimations(mi, 0, bone_count, lp);
GrannyBuildWorldPose(skel, 0, bone_count, lp, placement, wp);
/* 层① = 每骨「骨骼空间 → 世界」的原始世界矩阵(GrannyGetWorldPose4x4Array)。
* 注意:GrannyGetWorldPoseComposite4x4Array 返回的是 InverseWorld4x4·world = 蒙皮矩阵,
* 不是这里要的。 */
granny_matrix_4x4 *world = GrannyGetWorldPose4x4Array(wp);
/* coordraw(手系按 DLL 输出、单位=原始、根变换未 apply)。见 FORMAT.md。 */
const uint32_t coord = 0u;
FILE *o = fopen(out_path, "wb");
if (!o) die("dump: 打不开输出文件");
write_header(o, KIND_LAYER1, coord, model_path, anim_path, t);
wr_u32(o, (uint32_t)bone_count);
for (int b = 0; b < bone_count; ++b)
fwrite(&world[b][0][0], 4, 16, o);
/* kind=3mesh_index 的蒙皮顶点位置。照 SDK tutorialwii_basic.cpp CreateMesh/Render):
* deformer(输入=mesh 自带顶点类型, 输出=PNT332)
* 矩阵缓冲 = GrannyGetWorldPoseComposite4x4Array= InverseWorld·world = 蒙皮矩阵)
* 索引 = GrannyGetMeshBindingToBoneIndicesmesh 骨骼槽 → model 骨骼) */
if (mfi->MeshCount > mesh_index) {
granny_mesh *mesh = mfi->Meshes[mesh_index];
int vcount = GrannyGetMeshVertexCount(mesh);
typedef struct { float p[3], n[3], uv[2]; } pnt332;
pnt332 *dst = (pnt332 *)malloc(sizeof(pnt332) * vcount);
granny_matrix_4x4 *composite = GrannyGetWorldPoseComposite4x4Array(wp);
granny_mesh_binding *mb = GrannyNewMeshBinding(mesh, skel, skel);
granny_int32x const *toBone = GrannyGetMeshBindingToBoneIndices(mb);
granny_mesh_deformer *def = GrannyNewMeshDeformer(
GrannyGetMeshVertexType(mesh), GrannyPNT332VertexType,
GrannyDeformPositionNormal, GrannyDontAllowUncopiedTail);
if (def) {
GrannyDeformVertices(def, toBone, (const granny_real32 *)composite,
vcount, GrannyGetMeshVertices(mesh), dst);
} else {
fprintf(stderr, "dump: no deformer for mesh %d vertex type\n", mesh_index);
}
wr_u32(o, (uint32_t)vcount);
for (int v = 0; v < vcount; ++v)
fwrite(dst[v].p, 4, 3, o);
if (def) GrannyFreeMeshDeformer(def);
GrannyFreeMeshBinding(mb);
free(dst);
} else {
wr_u32(o, 0);
}
fclose(o);
printf("dump: wrote %s bones=%d t=%.6f granny=%s\n",
out_path, bone_count, t, granny_version_string());
GrannyFreeWorldPose(wp);
GrannyFreeLocalPose(lp);
GrannyFreeModelInstance(mi);
if (af) GrannyFreeFile(af);
GrannyFreeFile(mf);
return 0;
}
int main(int argc, char **argv) {
if (argc < 2) {
fprintf(stderr,
"usage:\n"
" oracle probe <file.gr2>\n"
" oracle dump <model.gr2> <anim.gr2|-> <t> -o out.bin [--mesh N]\n");
return 2;
}
if (!strcmp(argv[1], "probe")) {
if (argc < 3) die("probe <file.gr2>");
return cmd_probe(argv[2]);
}
if (!strcmp(argv[1], "dump")) return cmd_dump(argc - 2, argv + 2);
die("未知子命令(probe / dump");
return 2;
}
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# M2 门禁·主:对测试资产集跑 oracle dump + oracle_diff,汇总 test/m2-numeric.json。
# 前置:oracle/build-wine.sh 已产出 oracle.exeWINEPREFIX 已 init(见 RUNBOOK.md);
# oracle_diff 已构建(cmake -B build-tools -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_TOOLS=ON)。
set -uo pipefail
cd "$(dirname "$0")/.."
REPO="$(pwd)"
ASSETS="$(cd "${MT_ASSETS:-$REPO/assets}" && pwd)"
DUMPS="$REPO/test/oracle_dumps"
DIFF=""
for c in "$REPO/build-tools/tools/oracle_diff" "$REPO/build/tools/oracle_diff"; do
[ -x "$c" ] && DIFF="$c" && break
done
[ -n "$DIFF" ] || { echo "oracle_diff not built — run: cmake -B build-tools -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_TOOLS=ON && cmake --build build-tools"; exit 2; }
mkdir -p "$DUMPS"
WINE="${WINE:-/tmp/wine/Wine Stable.app/Contents/Resources/wine/bin/wine}"
export WINEPREFIX="${WINEPREFIX:-$HOME/.wine-oracle}" WINEDEBUG="${WINEDEBUG:--all}"
# Z: 盘映射到 /
winpath() { printf 'Z:%s' "$(printf '%s' "$1" | sed 's#/#\\#g')"; }
# 用例:<model 相对 assets> | <anim 相对 assets,或 -> | <t> | <mesh idx>
CASES=$(cat <<'EOF'
PC/ymir work/pc/warrior/warrior_cheongrin.gr2|-|0|4
PC/ymir work/pc/warrior/warrior_cheongrin_lod_01.gr2|-|0|4
PC/ymir work/pc/warrior/warrior_cheongrin_lod_02.gr2|-|0|4
PC/ymir work/pc/warrior/warrior_cheongrin_lod_03.gr2|-|0|4
PC/ymir work/pc/warrior/warrior_lord.gr2|-|0|1
PC/ymir work/pc/warrior/warrior_lord_lod_01.gr2|-|0|1
PC/ymir work/pc/warrior/warrior_lord_lod_02.gr2|-|0|1
PC/ymir work/pc/warrior/warrior_lord_lod_03.gr2|-|0|1
PC/ymir work/pc/assassin/assassin.gr2|-|0|16
PC/ymir work/pc/shaman/shaman_lord.gr2|-|0|0
metin2_patch_dragon_rock_mobs/ymir work/monster2/redthief2_soldier2/redthief2_soldier2_lod_01.gr2|-|0|4
monster2/ymir work/monster2/snakeman_bow/snakeman_bow_lod_01.gr2|-|0|0
monster2/ymir work/monster2/snakeman_bow/snakeman_bow_lod_01.gr2|-|0|1
metin2_patch_easter1/ymir work/pc/warrior/warrior_rabbit1_backup.gr2|-|0|0
Zone/ymir work/zone/oxevent/ox_01.gr2|-|0|0
PC/ymir work/pc/warrior/warrior_cheongrin.gr2|PC/ymir work/pc/warrior/action/dance_1.gr2|0|4
PC/ymir work/pc/warrior/warrior_cheongrin.gr2|PC/ymir work/pc/warrior/action/dance_1.gr2|4.7|4
PC/ymir work/pc/warrior/warrior_cheongrin.gr2|PC/ymir work/pc/warrior/action/dance_1.gr2|11.333|4
PC/ymir work/pc/warrior/warrior_cheongrin.gr2|PC/ymir work/pc/warrior/action/dance_1.gr2|20|4
PC/ymir work/pc/warrior/warrior_cheongrin.gr2|PC/ymir work/pc/warrior/general/attack.gr2|0.3|4
PC/ymir work/pc/warrior/warrior_cheongrin.gr2|PC/ymir work/pc/warrior/general/run.gr2|0.5|4
PC/ymir work/pc/warrior/warrior_lord.gr2|PC/ymir work/pc/warrior/action/dance_1.gr2|10|1
PC/ymir work/pc/warrior/warrior_lord.gr2|PC/ymir work/pc/warrior/general/wait.gr2|1.0|1
EOF
)
key_for() { echo "$1|$2|$3|$4" | md5 -q | cut -c1-12; }
# ── 1. dump(并行,4 个一批)──────────────────────────────────────────
run_dump() {
local model="$1" anim="$2" t="$3" mesh="$4" out="$5"
local wm; wm="$(winpath "$ASSETS/$model")"
local wa="-"; [ "$anim" != "-" ] && wa="$(winpath "$ASSETS/$anim")"
local wo; wo="$(winpath "$out")"
"$WINE" "$REPO/oracle/oracle.exe" dump "$wm" "$wa" "$t" -o "$wo" --mesh "$mesh" >/dev/null 2>&1
}
echo "== oracle dump =="
i=0
while IFS='|' read -r model anim t mesh; do
[ -z "$model" ] && continue
k=$(key_for "$model" "$anim" "$t" "$mesh")
out="$DUMPS/suite_$k.bin"
run_dump "$model" "$anim" "$t" "$mesh" "$out" &
i=$((i+1)); [ $((i % 4)) -eq 0 ] && wait
done <<< "$CASES"
wait
# ── 2. diff + 汇总 ─────────────────────────────────────────────────────
echo "== oracle_diff =="
JSON="$REPO/test/m2-numeric.json"
{
echo '{'
echo ' "granny": "2.9.12.0",'
echo ' "threshold": {"mat": 1e-3, "vtx": 1e-3},'
echo ' "cases": ['
} > "$JSON"
total=0; passed=0; first=1
worst_mat=0; worst_vtx=0
while IFS='|' read -r model anim t mesh; do
[ -z "$model" ] && continue
k=$(key_for "$model" "$anim" "$t" "$mesh")
bin="$DUMPS/suite_$k.bin"
total=$((total+1))
if [ ! -s "$bin" ]; then
line=" {\"model\":\"$(basename "$model")\",\"anim\":\"$(basename "$anim")\",\"t\":$t,\"mesh\":$mesh,\"status\":\"NO_DUMP\"}"
else
amodel="$ASSETS/$model"; aanim="-"; [ "$anim" != "-" ] && aanim="$ASSETS/$anim"
v=$("$DIFF" "$amodel" "$aanim" "$bin" --mesh "$mesh" 2>/dev/null | tail -1)
m=$(echo "$v" | sed -n 's/.*mat \([0-9.e+-]*\) <.*/\1/p')
vt=$(echo "$v" | sed -n 's/.*vtx \([0-9.e+-]*\) <.*/\1/p')
st="FAIL"; echo "$v" | grep -q PASS && { st="PASS"; passed=$((passed+1)); }
awk -v m="$m" -v w="$worst_mat" 'BEGIN{exit !(m+0>w+0)}' && worst_mat=$m
awk -v m="$vt" -v w="$worst_vtx" 'BEGIN{exit !(m+0>w+0)}' && worst_vtx=$vt
printf ' [%-4s] %-40s %-14s mat=%-10s vtx=%s\n' "$st" "$(basename "$model")" "$(basename "$anim") @${t}" "$m" "$vt"
line=" {\"model\":\"$(basename "$model")\",\"anim\":\"$(basename "$anim")\",\"t\":$t,\"mesh\":$mesh,\"mat\":$m,\"vtx\":$vt,\"status\":\"$st\"}"
fi
[ $first -eq 1 ] && first=0 || echo ',' >> "$JSON"
printf '%s' "$line" >> "$JSON"
done <<< "$CASES"
{
echo ''
echo ' ],'
echo " \"summary\": {\"total\": $total, \"passed\": $passed, \"worst_mat\": $worst_mat, \"worst_vtx\": $worst_vtx}"
echo '}'
} >> "$JSON"
echo
echo "== $passed/$total PASS worst mat=$worst_mat vtx=$worst_vtx$JSON =="
[ "$passed" -eq "$total" ]