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
+22
View File
@@ -0,0 +1,22 @@
# test/
| 文件 / 目录 | 产出者 | 内容 |
|---|---|---|
| `golden/` | M1 起 | Godot 渲染的对拍截图(`m1-*.png` / `m2-*.png` / `m25-*.png` |
| `bgfx-reference/` | 归档自 xrender-poc | bgfx demo 的同题材参考截图 + `render-samples/`。Godot 输出与之**结构化目视 + 直方图**对比(渲染器不同,不做像素比) |
| `assets.list` | xrender-poc M0 T9c | 精选 `.gr2`(多部件 / 全 4 级 LOD / 多 stage 材质 / 异常个例);里程碑对拍跑这套 |
| `assets-good.txt` | xrender-poc | 全量可解析清单 |
| `noise_floor.json` | xrender-poc M2 | 对拍噪声底:同 DLL 确定性 0、跨实现 ≤6.5e-5、epsilon=1e-3。Godot 对拍沿用此口径 |
| `m2-numeric.json` | `oracle/run-diff-suite.sh` | libgr2 vs Granny oracle 逐字段对拍结果(23 用例)。**libgr2 vendored 进本仓库后,这条链仍用 xrender-poc 时期建立的基线** |
| `godot-macos-stress.json` | M2 T2.7 | 1100 角色 `_process` 帧时间 + 资产构建成本采样(供 Phase 2 A/B 决策,非门禁) |
| `compare.py` | mtgodot 回归 harness | 运行 CPU/GPU 固定时间截图;Godot 退出/超时/缺截图/0 surface/缺 selfcheck/NaN 为硬失败,CPU↔GPU 像素差暂作 advisory |
| `oracle_dumps/` | `run-diff-suite.sh`gitignored | oracle 二进制转储中间产物 |
## 跑 libgr2 回归
```bash
cmake -B build-tools -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_TOOLS=ON && cmake --build build-tools -j8
./build-tools/tools/gr2fuzz "$PWD/../m2dev-client-main/assets" # 全量解析:期望 9166/9166 ok
./oracle/run-diff-suite.sh # vs Granny:期望 23/23 PASS(需 Wine + oracle.exe
ctest --test-dir build --output-on-failure # loader 损坏输入单测
```
+9166
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
# 测试资产集 —— 由 M0 T9c 从 fuzz 报告 + 人工挑产出。
# 路径相对 XRENDER_ASSET_ROOT。每行一个。带 # 的是注释。
# 覆盖:刚体 / 单材质蒙皮 / 多材质+alpha / 多部件装配+挂点 / 高骨骼数 / 全 4 级 LOD /
# 多 stage 材质(MODULATE2X 或 ADDSIGNED) / 特效挂点 / fuzz 异常个例
#
# ── bootstrap 引导集(M0 自身验证用,assets.list 产出前)──
PC/ymir work/pc/warrior/warrior_cheongrin.gr2
PC/ymir work/pc/warrior/warrior_cheongrin_lod_01.gr2
PC/ymir work/pc/warrior/warrior_cheongrin_lod_02.gr2
PC/ymir work/pc/warrior/warrior_cheongrin_lod_03.gr2
PC/ymir work/pc/warrior/action/dance_1.gr2
Zone/ymir work/zone/oxevent/ox_01.gr2
#
# ── 正式集:M0 T9c 补(fuzz 全量 9166 零崩溃 / 零谓词失败后精选)──
#
# 蒙皮角色 + 全 4 级 LODbody/face/附件 5 meshPNT332_Skinned75 骨 Bip01
# base 已在 bootstrap;补:另一套 warrior 全 LOD 用于跨模型回归
PC/ymir work/pc/warrior/warrior_lord.gr2
PC/ymir work/pc/warrior/warrior_lord_lod_01.gr2
PC/ymir work/pc/warrior/warrior_lord_lod_02.gr2
PC/ymir work/pc/warrior/warrior_lord_lod_03.gr2
# 高 mesh 数装配(17 mesh,6 材质 / 3 贴图)—— 顶点类型混合 + 多 stage 材质
PC/ymir work/pc/assassin/assassin.gr2
# 多 skeleton(本体 + 2 武器挂点,3 skeleton / 3 model)—— bone-binding 跨 skeleton 解析
metin2_patch_dragon_rock_mobs/ymir work/monster2/redthief2_soldier2/redthief2_soldier2_lod_01.gr2
# 刚体 meshPNT332rigid=1)—— 怪物 LOD
monster2/ymir work/monster2/snakeman_bow/snakeman_bow_lod_01.gr2
# 动画:曲线子类型齐全(pos d0/d2·dim3、rot d0/d2·dim4、scale d0·dim9 + d1·dim9
PC/ymir work/pc/warrior/action/dance_1.gr2
monster2/ymir work/monster2/snakeman_bow/00.gr2
# format v7Std32LE magic,非 GRNFileMV_Old)—— 11/9166;含 PC 与场景
PC/ymir work/pc/shaman/shaman_lord.gr2
Zone/ymir work/zone/dungeon/snow_dungeon/dungeon_a1.gr2
# 异常个例:bind-pose 自洽 >= 1e-1 的 4 个之一(root 偏移未落在 local/IP 链上)
metin2_patch_easter1/ymir work/pc/warrior/warrior_rabbit1_backup.gr2
Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""Phase-1 cross-check harness.
Skinning's numeric truth lives in libgr2 (shared by mtgodot and the retired
xrender-poc) and is oracle-verified against real Granny. mtgodot's default
CPU-skin path applies libgr2's `skin` matrices verbatim, so "mtgodot == Granny"
reduces to "libgr2 == Granny" (oracle suite) + "mtgodot ran libgr2 cleanly"
(0 NaN). It renders each case on the CPU path and, for animated cases, also on the
GPU-skin (SRC_SKIN vertex shader) path. No pixel diff vs bgfx-reference
(renderers/cameras differ) — the reference shot path is recorded for eyeballing.
test/compare.py [--cases test/compare-cases.json] [--out test/compare-report.json]
Prereqs used if present:
- test/m2-numeric.json (from oracle/run-diff-suite.sh; libgr2 vs Granny)
- godot on PATH, extension built (project/bin/*.dylib)
- Pillow + numpy
"""
import json, os, re, subprocess, sys, time
from pathlib import Path
import numpy as np
from PIL import Image
REPO = Path(__file__).resolve().parent.parent
# Metin2 assets live in-tree at <repo>/assets (gitignored); override with MT_ASSETS.
ASSETS = Path(os.environ.get("MT_ASSETS", REPO / "assets")).resolve()
OUT_DIR = REPO / "test" / "compare-shots"
REF_DIR = REPO / "test" / "bgfx-reference"
DEFAULT_CASES = [
# name, model (rel to assets/PC/ymir work), texdir, anim ('-' = bind), t
("warrior-bind", "pc/warrior/warrior_cheongrin.gr2", "pc/warrior", "-", 0.0),
("warrior-dance-t7", "pc/warrior/warrior_cheongrin.gr2", "pc/warrior",
"pc/warrior/action/dance_1.gr2", 7.0),
("warrior-dance-t19","pc/warrior/warrior_cheongrin.gr2", "pc/warrior",
"pc/warrior/action/dance_1.gr2", 19.0),
("sura-bind", "pc/sura/sura_lord.gr2", "pc/sura", "-", 0.0),
("assassin-bind", "pc/assassin/assassin.gr2", "pc/assassin", "-", 0.0),
("shaman-bind", "pc/shaman/shaman_lord.gr2", "pc/shaman", "-", 0.0),
]
def run_godot(env_extra, timeout=120):
godot = subprocess.run(["which", "godot"], capture_output=True, text=True).stdout.strip()
if not godot:
return None, "godot not on PATH", None
env = {**os.environ, **env_extra}
try:
p = subprocess.run([godot, "--path", str(REPO / "project"),
"--rendering-driver", "metal", "--quit-after", "45"],
capture_output=True, text=True, env=env, timeout=timeout)
except subprocess.TimeoutExpired:
return None, "timeout", None
log = p.stdout + p.stderr
if p.returncode != 0:
return log, f"godot exited with status {p.returncode}", p.returncode
return log, None, p.returncode
def case_env(c):
_, model, texdir, anim, t = c
e = {"MTGODOT_MODEL": str(ASSETS / "PC" / "ymir work" / model),
"MTGODOT_TEXDIR": str(ASSETS / "PC" / "ymir work" / texdir),
"MTGODOT_YAW": "0.7"}
if anim == "-":
e["MTGODOT_ANIM"] = "-"
else:
e["MTGODOT_ANIM"] = str(ASSETS / "PC" / "ymir work" / anim)
e["MTGODOT_ANIM_T"] = str(t)
return e
def _luma(path):
im = np.asarray(Image.open(path).convert("RGB")).astype(np.float32)
return 0.2126 * im[..., 0] + 0.7152 * im[..., 1] + 0.0722 * im[..., 2], im
def img_stats(path):
l, _ = _luma(path)
return {"luma_mean": round(float(l.mean()), 2), "luma_std": round(float(l.std()), 2)}
def img_diff(a, b):
"""Coarse diff between two same-camera renders (CPU vs GPU skinning).
Advisory only — both paths feed the SAME libgr2 matrices with the full affine,
so they match visually; residual changed_frac is AA / float-precision on the
shaded surface, not a collapse. (The old Skeleton3D route DID collapse limbs;
it's gone — replaced by the SRC_SKIN vertex shader.) Eyeball the .cpu/.gpu PNGs.
"""
la, ia = _luma(a)
lb, ib = _luma(b)
if ia.shape != ib.shape:
return {"error": "size mismatch"}
d = np.abs(ia - ib).sum(axis=2)
return {"changed_frac": round(float((d > 24).mean()), 4),
"max_abs": int(d.max()), "mean_abs": round(float(d.mean()), 3)}
def parse(log, pat):
m = re.search(pat, log or "")
return m.group(1) if m else None
def report_path(path):
try:
return str(path.relative_to(REPO))
except ValueError:
return str(path)
def oracle_gate():
js = REPO / "test" / "m2-numeric.json"
if not js.exists():
return {"status": "skipped", "reason": "test/m2-numeric.json absent — run oracle/run-diff-suite.sh"}
d = json.loads(js.read_text())
cases = d.get("cases", [])
fails = [c for c in cases if str(c.get("status", "")).upper() != "PASS"]
return {"status": "pass" if cases and not fails else ("fail" if fails else "empty"),
"cases": len(cases), "failures": len(fails),
"worst_mat": max((float(c.get("mat", 0)) for c in cases), default=0.0),
"worst_vtx": max((float(c.get("vtx", 0)) for c in cases), default=0.0),
"note": "libgr2 vs real Granny 2.9.12 — shared by mtgodot's CPU-skin path"}
def main():
cases_path = out_path = None
args = sys.argv[1:]
for i, a in enumerate(args):
if a == "--cases" and i + 1 < len(args): cases_path = Path(args[i + 1])
if a == "--out" and i + 1 < len(args): out_path = Path(args[i + 1])
out_path = out_path or REPO / "test" / "compare-report.json"
cases = json.loads(cases_path.read_text()) if cases_path and cases_path.exists() else DEFAULT_CASES
OUT_DIR.mkdir(parents=True, exist_ok=True)
report = {"generated": time.strftime("%Y-%m-%d %H:%M:%S"),
"oracle_gate": oracle_gate(), "cases": []}
fails = []
for c in cases:
name, anim = c[0], c[3]
rec = {"name": name}
env = case_env(c)
shot = OUT_DIR / f"{name}.cpu.png"
shot.unlink(missing_ok=True) # stale output must never turn a failed run green
log, err, returncode = run_godot({**env, "MTGODOT_AUTOSHOT": str(shot)})
nan = parse(log, r"NaN/Inf=(\d+)")
surf = parse(log, r"surfaces=(\d+)")
cpu = {"nan_inf": int(nan) if nan is not None else None,
"surfaces": int(surf) if surf else None,
"shot": report_path(shot) if shot.exists() else None,
"returncode": returncode, "error": err}
if shot.exists():
cpu.update(img_stats(shot))
rec["cpu"] = cpu
if err:
fails.append(f"{name}: {err}")
if not shot.exists():
fails.append(f"{name}: screenshot missing")
if cpu["surfaces"] is None or cpu["surfaces"] <= 0:
fails.append(f"{name}: model did not report a non-empty surface set")
if anim != "-" and cpu["nan_inf"] is None:
fails.append(f"{name}: animation self-check result missing")
elif cpu["nan_inf"] not in (0, None):
fails.append(f"{name}: NaN/Inf={cpu['nan_inf']}")
if anim != "-":
gshot = OUT_DIR / f"{name}.gpu.png"
gshot.unlink(missing_ok=True)
glog, gerr, greturncode = run_godot(
{**env, "MTGODOT_GPUSKIN": "1", "MTGODOT_AUTOSHOT": str(gshot)})
gpu = {"shot": report_path(gshot) if gshot.exists() else None,
"returncode": greturncode, "error": gerr}
if gshot.exists():
gpu.update(img_stats(gshot))
if shot.exists():
gpu["vs_cpu"] = img_diff(shot, gshot)
rec["gpu_skin"] = gpu
if gerr:
fails.append(f"{name} GPU: {gerr}")
if not gshot.exists():
fails.append(f"{name} GPU: screenshot missing")
ref = next((p for p in REF_DIR.glob(f"*{name.split('-')[0]}*")), None)
rec["bgfx_reference"] = str(ref.relative_to(REPO)) if ref else None
report["cases"].append(rec)
g = rec.get("gpu_skin", {})
print(f" {name:18s} cpu[nan={cpu['nan_inf']} surf={cpu['surfaces']} luma={cpu.get('luma_mean')}]"
+ (f" gpu[changed_vs_cpu={g.get('vs_cpu', {}).get('changed_frac')}]" if g else ""))
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(report, indent=2))
og = report["oracle_gate"]
print(f"\noracle gate: {og['status']}"
+ (f" ({og['cases']} cases, worst mat={og['worst_mat']:.1e} vtx={og['worst_vtx']:.1e})"
if og.get("cases") else f"{og.get('reason', '')}"))
print(f"cpu failures: {fails or 'none'}")
print("gpu-skin (SRC_SKIN vertex shader) rendered for each anim case — eyeball .gpu.png vs .cpu.png")
print(f"report -> {report_path(out_path)}")
return 1 if (og["status"] == "fail" or fails) else 0
if __name__ == "__main__":
sys.exit(main())
+18 -51
View File
@@ -1,63 +1,30 @@
{
"note": "macOS, background/unfocused window -> Godot throttles to ~30fps; frame_ms rows at ~33.3 are the throttle ceiling, not measured cost. build_ms_per_char and the 100-char breakout (52ms) are the reliable signals. Proper frame timing = Phase 2 (focused window / on-device).",
"renderer": "Apple M2 (Apple8)",
"method": "per-frame set_bone_global_pose (B)",
"measured": "2026-08-29 (foreground window, M2, Metal, vsync off)",
"method": "per-frame CPU LBS + full ArrayMesh rebuild (current default)",
"caveats": [
"frame_ms is the trustworthy signal; cpu_process_ms (Performance.TIME_PROCESS) reads inconsistent (drops as N rises) \u2014 do not use.",
"per-frame cpu_mesh->clear_surfaces()+add_surface_from_arrays is the likely bottleneck, not the LBS math.",
"build ~170ms/char is single-threaded, no cross-model texture/mesh cache.",
"Phase 2 needs: (1) custom GPU skinning shader (full bone matrices) so this path is not on the CPU, (2) multithreaded build + eterpack shared assets, (3) a proper bench harness."
],
"runs": [
{
"build_ms_per_char": 170.682,
"build_ms_total": 170.682,
"characters": 1,
"cpu_process_ms_avg": 34.1602583333333,
"cpu_process_ms_p95": 34.405,
"fps_avg": 29.345480700072,
"frame_ms_avg": 34.0767973856208,
"frame_ms_max": 58.8235294117647,
"frame_ms_p95": 33.3333333333333,
"frames": 240,
"method": "per-frame set_bone_global_pose (B)",
"renderer": "Apple M2 (Apple8)"
"build_ms_per_char": 171.7,
"frame_ms_avg": 21.8,
"fps_avg": 46.0
},
{
"build_ms_per_char": 167.27,
"build_ms_total": 1672.7,
"characters": 10,
"cpu_process_ms_avg": 35.1748875,
"cpu_process_ms_p95": 41.667,
"fps_avg": 30.0000000000001,
"frame_ms_avg": 33.3333333333332,
"frame_ms_max": 33.3333333333333,
"frame_ms_p95": 33.3333333333333,
"frames": 240,
"method": "per-frame set_bone_global_pose (B)",
"renderer": "Apple M2 (Apple8)"
"build_ms_per_char": 171.1,
"frame_ms_avg": 27.5,
"fps_avg": 36.3
},
{
"build_ms_per_char": 164.90146,
"build_ms_total": 8245.073,
"characters": 50,
"cpu_process_ms_avg": 34.5210166666665,
"cpu_process_ms_p95": 34.624,
"fps_avg": 30.1173930643639,
"frame_ms_avg": 33.203405017921,
"frame_ms_max": 33.3333333333333,
"frame_ms_p95": 33.3333333333333,
"frames": 240,
"method": "per-frame set_bone_global_pose (B)",
"renderer": "Apple M2 (Apple8)"
},
{
"build_ms_per_char": 164.65813,
"build_ms_total": 16465.813,
"characters": 100,
"cpu_process_ms_avg": 54.0874916666667,
"cpu_process_ms_p95": 62.877,
"fps_avg": 19.1516169676607,
"frame_ms_avg": 52.2149122807017,
"frame_ms_max": 52.6315789473684,
"frame_ms_p95": 52.6315789473684,
"frames": 240,
"method": "per-frame set_bone_global_pose (B)",
"renderer": "Apple M2 (Apple8)"
"characters": 30,
"build_ms_per_char": 168.7,
"frame_ms_avg": 41.2,
"fps_avg": 24.3
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+30
View File
@@ -0,0 +1,30 @@
{
"granny": "2.9.12.0",
"threshold": {"mat": 1e-3, "vtx": 1e-3},
"cases": [
{"model":"warrior_cheongrin.gr2","anim":"-","t":0,"mesh":4,"mat":1.5e-05,"vtx":4.6e-05,"status":"PASS"},
{"model":"warrior_cheongrin_lod_01.gr2","anim":"-","t":0,"mesh":4,"mat":1.5e-05,"vtx":4.6e-05,"status":"PASS"},
{"model":"warrior_cheongrin_lod_02.gr2","anim":"-","t":0,"mesh":4,"mat":1.5e-05,"vtx":4.6e-05,"status":"PASS"},
{"model":"warrior_cheongrin_lod_03.gr2","anim":"-","t":0,"mesh":4,"mat":1.5e-05,"vtx":4.6e-05,"status":"PASS"},
{"model":"warrior_lord.gr2","anim":"-","t":0,"mesh":1,"mat":1.5e-05,"vtx":1.5e-05,"status":"PASS"},
{"model":"warrior_lord_lod_01.gr2","anim":"-","t":0,"mesh":1,"mat":1.5e-05,"vtx":1.5e-05,"status":"PASS"},
{"model":"warrior_lord_lod_02.gr2","anim":"-","t":0,"mesh":1,"mat":1.5e-05,"vtx":1.5e-05,"status":"PASS"},
{"model":"warrior_lord_lod_03.gr2","anim":"-","t":0,"mesh":1,"mat":1.5e-05,"vtx":1.5e-05,"status":"PASS"},
{"model":"assassin.gr2","anim":"-","t":0,"mesh":16,"mat":3.1e-05,"vtx":4.6e-05,"status":"PASS"},
{"model":"shaman_lord.gr2","anim":"-","t":0,"mesh":0,"mat":7.6e-06,"vtx":3.8e-05,"status":"PASS"},
{"model":"redthief2_soldier2_lod_01.gr2","anim":"-","t":0,"mesh":4,"mat":0,"vtx":0,"status":"PASS"},
{"model":"snakeman_bow_lod_01.gr2","anim":"-","t":0,"mesh":0,"mat":3.1e-05,"vtx":6.1e-05,"status":"PASS"},
{"model":"snakeman_bow_lod_01.gr2","anim":"-","t":0,"mesh":1,"mat":3.1e-05,"vtx":3.2e-05,"status":"PASS"},
{"model":"warrior_rabbit1_backup.gr2","anim":"-","t":0,"mesh":0,"mat":1.5e-05,"vtx":3.9e-05,"status":"PASS"},
{"model":"ox_01.gr2","anim":"-","t":0,"mesh":0,"mat":0,"vtx":0,"status":"PASS"},
{"model":"warrior_cheongrin.gr2","anim":"dance_1.gr2","t":0,"mesh":4,"mat":2.3e-05,"vtx":3.9e-05,"status":"PASS"},
{"model":"warrior_cheongrin.gr2","anim":"dance_1.gr2","t":4.7,"mesh":4,"mat":3.1e-05,"vtx":5e-05,"status":"PASS"},
{"model":"warrior_cheongrin.gr2","anim":"dance_1.gr2","t":11.333,"mesh":4,"mat":4.1e-05,"vtx":5.8e-05,"status":"PASS"},
{"model":"warrior_cheongrin.gr2","anim":"dance_1.gr2","t":20,"mesh":4,"mat":3.1e-05,"vtx":6.5e-05,"status":"PASS"},
{"model":"warrior_cheongrin.gr2","anim":"attack.gr2","t":0.3,"mesh":4,"mat":4.5e-05,"vtx":5.9e-05,"status":"PASS"},
{"model":"warrior_cheongrin.gr2","anim":"run.gr2","t":0.5,"mesh":4,"mat":2.3e-05,"vtx":5e-05,"status":"PASS"},
{"model":"warrior_lord.gr2","anim":"dance_1.gr2","t":10,"mesh":1,"mat":4.6e-05,"vtx":6.4e-05,"status":"PASS"},
{"model":"warrior_lord.gr2","anim":"wait.gr2","t":1.0,"mesh":1,"mat":4.6e-05,"vtx":4.8e-05,"status":"PASS"}
],
"summary": {"total": 23, "passed": 23, "worst_mat": 4.6e-05, "worst_vtx": 6.5e-05}
}
+70
View File
@@ -0,0 +1,70 @@
{
"_doc": "PARITY-GAP.md §0.1 受控参考帧契约。'待采集' = 需从原 D3D 客户端实测,未落盘前只允许修正确性/管线问题,不接受为匹配这张图做的相机/光照/色调/材质调参。参考图应放到 test/reference/ingame-shinsoo.png。",
"reference_image": "test/reference/ingame-shinsoo.png",
"output": {
"resolution": [1920, 1080],
"godot_version": "4.7.1.stable",
"renderer": "待采集 (Forward+ / Mobile)",
"msaa": "待采集",
"taa": "待采集",
"color_space": "待采集 (sRGB / linear)",
"random_seed": 0
},
"map": {
"map_id": "OutdoorA1/metin2_map_a1",
"confirmed_is_a1": false,
"player_global_cm": ["待采集", "待采集", "待采集"],
"load_radius_tiles": 1,
"visible_tiles": "待采集"
},
"camera": {
"yaw_deg": "待采集",
"pitch_deg": "待采集",
"roll_deg": "待采集",
"distance_m": "待采集",
"height_m": "待采集",
"fov_deg": "待采集",
"near": "待采集",
"far": "待采集"
},
"player": {
"race": "待采集",
"sex": "待采集",
"body_gr2": "待采集",
"hair_gr2": "待采集",
"weapon": "待采集",
"equipment": "待采集",
"motion": "待采集",
"anim_time_s": "待采集"
},
"npc": [],
"environment": {
"msenv": "A1.msenv",
"time_of_day": "待采集",
"weather": "待采集",
"sun_dir": "待采集",
"fog": "待采集",
"exposure": "待采集",
"post": "待采集"
},
"ui": {
"locale": "待采集 (截图角标显示 es?)",
"inventory_open": "待采集",
"minimap_round": "待采集",
"chat_lines": ["待采集"],
"status_bar": "待采集",
"hotbar": "待采集"
},
"excluded_from_frame": [
{ "item": "完整 .mse 特效", "reason": "目标帧内不可见", "roi": null },
{ "item": "远离机位的地图内容", "reason": "load_radius 外 / 视锥外", "roi": null }
]
}
+32
View File
@@ -0,0 +1,32 @@
{
"_comment": "浮点噪声地板。M2/M3 的对拍阈值 ε = 对应值 × 安全余量(约 ×10)。来源见下。",
"same_dll_determinism": {
"granny": "2.9.12.0",
"mat": 0.0,
"vtx": 0.0,
"method": "同 (model,anim,t) 连续两次 oracle dumpbyte-identical。Granny 这条路径单线程无 RNG,完全确定。"
},
"cross_impl": {
"_comment": "libgr2 vs 真 Granny —— 两个都正确、浮点运算顺序不同造成的差。这就是实际的地板。",
"granny": "2.9.12.0",
"mat": 4.6e-05,
"vtx": 6.5e-05,
"method": "oracle/run-diff-suite.sh23 用例(13 模型 bind + 8 动画帧 + v7 + 双 root + 刚体),取 max。",
"report": "m2-numeric.json"
},
"cross_version": {
"_comment": "换一版相邻 granny2.dll2.4.x 或 2.11.x)跑同一 dump 的差 —— 待补,本机只有 2.9.12。",
"mat": null,
"vtx": null,
"status": "pending: 需要另一个 granny2.dll 版本"
},
"epsilon": {
"_comment": "M2/M3 数值门禁用。cross_impl × ~15 余量,向上取整到 1e-3。",
"mat": 1e-3,
"vtx": 1e-3
}
}
+37
View File
@@ -0,0 +1,37 @@
# Shear-bone survey (Godot GPU-skinning viability)
`tools/anim_probe <model.gr2> <anim.gr2>` — for every bone, the parent-relative
local that would be fed to `Skeleton3D::set_bone_pose`, its shear, and the error
of a `T·R(quat)·S` round-trip (= what Godot's GPU vertex skinning keeps after it
orthonormalizes the bone matrix before upload). High round-trip error = that bone
**collapses on the GPU (Skeleton3D) path once an animation moves it**.
Probed with `pc/warrior/action/dance_1.gr2` (retarget by bone name; unmatched
bones sit at bind, so their numbers reflect the model's rest rig).
| model | shear bones | worst TRS err | worst minDet | GPU-skin viable? |
|---|---|---|---|---|
| `warrior_cheongrin` | **7** (`bone_front_01..04`, `Bone_shoulder_01..04`) | 0.94 | 0.065 (6.5% squash) | ❌ collapses (`dance_1` animates these) |
| `assassin` | 0 | — | — | ✅ |
| `sura_lord` | **16** (`Bone05/06`, `Bone14..27`) | **1.52** | **0.0024** (0.24% squash) | ❌❌ severe |
| `shaman_lord` | 0 | — | — | ✅ |
## Takeaways
- The shear/non-uniform-scale is **baked into the rest rig** of these cloth/plate
helper bones (metrics are constant across the animation clock). Static bind pose
survives because `skin_matrix ≈ I` there; any animation that moves a shear bone
breaks it on GPU skinning.
- **`sura` is unusable on Godot's built-in GPU skinning** — 16 bones, one squashed
to 0.24%. It's a core class, so this isn't a corner case.
- `assassin` / `shaman` are clean — GPU skinning would be fine for them.
- **Done (2026-08-29)**: the custom skinning vertex shader is implemented —
`m2_material.cpp` `SRC_SKIN` does LBS with the full per-bone 4×3 matrices from an
RGBAF texture (`Metin2Model::gpu_skin` fills it each frame), no `Skeleton3D`, no
orthonormalization. `MTGODOT_GPUSKIN=1` now routes here (the old broken
`set_bone_pose` path is deleted). warrior `dance_1` GPU render == CPU render
(`test/compare-shots/*.gpu.png`). CPU LBS stays the default; GPU is for crowds
(Phase 2 bench).
Re-run for a full race sweep (incl. monsters, NPCs, mounts) before Phase 2 to
size the shader work and catch any rig that also needs >4 bone influences.