Adapt Android mobile controls and native client UI
This commit is contained in:
+8
-18
@@ -1,22 +1,12 @@
|
||||
# test/
|
||||
# Test data
|
||||
|
||||
| 文件 / 目录 | 产出者 | 内容 |
|
||||
|---|---|---|
|
||||
| `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 | 1–100 角色 `_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 二进制转储中间产物 |
|
||||
`golden/` and `bgfx-reference/` contain historical reference screenshots. `assets.list` and `assets-good.txt` list asset samples; `m2-numeric.json` records Granny oracle comparisons.
|
||||
|
||||
## 跑 libgr2 回归
|
||||
Build the asset tools without a UI engine:
|
||||
|
||||
```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 损坏输入单测
|
||||
```sh
|
||||
cmake -S . -B build-tools -DMT_BUILD_TOOLS=ON
|
||||
cmake --build build-tools -j8
|
||||
```
|
||||
|
||||
The active 40250 port tests run through `script/port_gate.sh macos`. The native SDL/Vulkan client has its own acceptance runner, `script/native_mac_acceptance.mjs`.
|
||||
|
||||
-206
@@ -1,206 +0,0 @@
|
||||
#!/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"),
|
||||
"res://main.tscn", "--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())
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"characters": 1,
|
||||
"build_ms_per_char": 171.7,
|
||||
"frame_ms_avg": 21.8,
|
||||
"fps_avg": 46.0
|
||||
},
|
||||
{
|
||||
"characters": 10,
|
||||
"build_ms_per_char": 171.1,
|
||||
"frame_ms_avg": 27.5,
|
||||
"fps_avg": 36.3
|
||||
},
|
||||
{
|
||||
"characters": 30,
|
||||
"build_ms_per_char": 168.7,
|
||||
"frame_ms_avg": 41.2,
|
||||
"fps_avg": 24.3
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"engine_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"extension_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"arch": "arm64"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
PLAYABLE FIXTURE: child completed without renderer errors
|
||||
@@ -1 +0,0 @@
|
||||
{"monotonic_us":1000,"run_id":"fixture-run","case_id":"FIXTURE-01","connection_epoch":1,"stage":"LOGIN","kind":"phase_changed","actor_vid":0,"target_vid":0,"payload":{"source":"server"}}
|
||||
@@ -1,55 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Stand-in for the packaged client used by script/playable_gate_test.sh.
|
||||
# The fake app's Mach-O execs this file, so it runs as the runner's own child
|
||||
# PID. It never contacts a server; MT_FAKE_SCENARIO picks the behaviour.
|
||||
scenario="${MT_FAKE_SCENARIO:-pass}"
|
||||
|
||||
write_report() {
|
||||
# $1 = PASS|BLOCKED, $2 = drop-last|evidence-missing|wrong-run|""
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const [status, variant] = process.argv.slice(1);
|
||||
const out = process.env.MT_TEST_OUTPUT;
|
||||
const required = JSON.parse(fs.readFileSync(path.join(out, "required-cases.json"), "utf8")).cases;
|
||||
const runId = variant === "wrong-run" ? "previous-run-0000" : process.env.MT_PLAYABLE_RUN_ID;
|
||||
const ids = variant === "drop-last" ? required.slice(0, -1) : required;
|
||||
const cases = ids.map((id) => ({ id, status, duration_ms: 1, reason: status === "PASS" ? "fake" : "fixture_not_ready",
|
||||
evidence: variant === "evidence-missing" && id === "NET-LOGIN-01" ? ["net-login-01.png"] : [] }));
|
||||
const events = ids.map((id, i) => JSON.stringify({ monotonic_us: 1000 + i, run_id: runId, case_id: id, connection_epoch: 1,
|
||||
stage: "LOGIN", kind: "phase_changed", actor_vid: 0, target_vid: 0, payload: { source: "server" } }));
|
||||
fs.writeFileSync(process.env.MT_TEST_EVENTS, events.join("\n") + "\n");
|
||||
const report = {
|
||||
schema_version: 1, run_id: runId, suite: process.env.MT_PLAYABLE_SUITE || process.env.MT_TEST_MODE, status,
|
||||
build: { engine_sha256: process.env.MT_BUILD_ENGINE_SHA256, extension_sha256: process.env.MT_BUILD_EXTENSION_SHA256,
|
||||
pck_sha256: process.env.MT_BUILD_PCK_SHA256, arch: process.env.MT_BUILD_ARCH },
|
||||
environment: { os: "macOS", renderer: "fake", resolution: [1280, 720] },
|
||||
cases, required_cases: ids, events: "events.jsonl", event_count: events.length, failures: [],
|
||||
blocked: status === "BLOCKED" ? ids.map((id) => `${id}: fixture_not_ready`) : [],
|
||||
coverage: { required: cases.length, passed: status === "PASS" ? cases.length : 0 },
|
||||
exit_gate: { checked: false, process_code: null, errors: [] },
|
||||
};
|
||||
fs.writeFileSync(process.env.MT_TEST_REPORT, JSON.stringify(report, null, 2));
|
||||
' "$1" "${2:-}"
|
||||
}
|
||||
|
||||
case "$scenario" in
|
||||
cancel) exec node -e 'require("node:fs").writeFileSync(process.env.MT_TEST_OUTPUT + "/fake-child.pid", String(process.pid)); setInterval(() => {}, 1000)' ;;
|
||||
pass) write_report PASS; echo "PLAYABLE CLIENT: PASS" ;;
|
||||
soak-pass) write_report PASS; if [ "${MT_PLAYABLE_SUITE:-}" = "soak" ]; then sleep 3; fi; echo "PLAYABLE CLIENT: PASS" ;;
|
||||
exit3) write_report PASS; echo "PLAYABLE CLIENT: PASS"; exit 3 ;;
|
||||
signal) write_report PASS; kill -KILL $$ ;;
|
||||
hang) trap '' TERM; echo "ignoring TERM"; while :; do sleep 1; done ;;
|
||||
stale) write_report PASS wrong-run ;;
|
||||
no-report) echo "exited before writing a report" ;;
|
||||
rid) write_report PASS; echo "WARNING: 3 shaders of type CanvasItem were never freed." ;;
|
||||
leak) write_report PASS; echo "WARNING: ObjectDB instances leaked at exit (run with --verbose for details)." ;;
|
||||
missing-case) write_report PASS drop-last ;;
|
||||
missing-evidence) write_report PASS evidence-missing ;;
|
||||
blocked) write_report BLOCKED ;;
|
||||
print-secret) write_report PASS; echo "login request for $MT_ACCOUNT with $MT_PASSWORD" ;;
|
||||
fifo-holder) write_report PASS; sleep 13 & exit 0 ;;
|
||||
no-creds) if [ -n "${MT_ACCOUNT:-}${MT_PASSWORD:-}" ]; then echo "SCRIPT ERROR: credentials reached an offline child"; fi; write_report PASS ;;
|
||||
*) echo "unknown fake scenario" >&2; exit 64 ;;
|
||||
esac
|
||||
exit 0
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"maps": {
|
||||
"<map_key from map-assets.json>": {
|
||||
"races": [],
|
||||
"viewpoints": [
|
||||
{
|
||||
"id": "flat-01",
|
||||
"kind": "flat",
|
||||
"status": "unconfirmed",
|
||||
"server_cm": [0, 0],
|
||||
"camera_yaw_deg": 45
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"notes": "Copy to test/playable/forest-viewpoints.<name>.local.json (ignored). kind is flat|slope|dense|warp; server_cm is the confirmed in-game global position; status becomes confirmed only with confirmed_by set to the confirming role (not a person's name). races lists server-confirmed mob vnums for the map; empty means the 12 candidate tree monsters are placed as candidates only. Never add credentials here."
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"run_id": "fixture-run",
|
||||
"suite": "playable",
|
||||
"status": "BLOCKED",
|
||||
"build": {
|
||||
"engine_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"extension_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"arch": "arm64"
|
||||
},
|
||||
"environment": {
|
||||
"os": "macOS",
|
||||
"renderer": "Metal",
|
||||
"resolution": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"id": "FIXTURE-01",
|
||||
"status": "BLOCKED",
|
||||
"duration_ms": 0,
|
||||
"reason": "fixture_not_ready",
|
||||
"evidence": []
|
||||
}
|
||||
],
|
||||
"required_cases": [
|
||||
"FIXTURE-01"
|
||||
],
|
||||
"events": "events.jsonl",
|
||||
"event_count": 1,
|
||||
"failures": [],
|
||||
"blocked": [
|
||||
"FIXTURE-01: fixture_not_ready"
|
||||
],
|
||||
"coverage": {
|
||||
"required": 1,
|
||||
"passed": 0
|
||||
},
|
||||
"exit_gate": {
|
||||
"checked": false,
|
||||
"process_code": null,
|
||||
"errors": []
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"run_id": "fixture-run",
|
||||
"suite": "playable",
|
||||
"status": "PASS",
|
||||
"build": {
|
||||
"engine_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"extension_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"arch": "arm64"
|
||||
},
|
||||
"environment": {
|
||||
"os": "macOS",
|
||||
"renderer": "Metal",
|
||||
"resolution": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"id": "FIXTURE-01",
|
||||
"status": "PASS",
|
||||
"duration_ms": 1,
|
||||
"reason": "ok",
|
||||
"evidence": []
|
||||
}
|
||||
],
|
||||
"required_cases": [
|
||||
"FIXTURE-01"
|
||||
],
|
||||
"events": "events.jsonl",
|
||||
"event_count": 1,
|
||||
"failures": [],
|
||||
"blocked": [],
|
||||
"coverage": {
|
||||
"required": 1,
|
||||
"passed": 1
|
||||
},
|
||||
"exit_gate": {
|
||||
"checked": false,
|
||||
"process_code": null,
|
||||
"errors": []
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"suite": "playable",
|
||||
"cases": [
|
||||
"FIXTURE-01"
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
WARNING: 3 shaders of type CanvasItem were never freed.
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"scenario_id": "first-mac-playable-forest",
|
||||
"protocol": "classic",
|
||||
"server": {
|
||||
"server_index": 0,
|
||||
"channel": 1,
|
||||
"auth_host": "",
|
||||
"auth_port": 0,
|
||||
"game_host": "",
|
||||
"game_port": 0
|
||||
},
|
||||
"character_slot": 0,
|
||||
"map_key": "",
|
||||
"waypoints_cm": [],
|
||||
"allowed_mob_vnums": [],
|
||||
"allowed_drop_vnums": [],
|
||||
"skill_cases": [],
|
||||
"resolution": [1280, 720],
|
||||
"loops": 3,
|
||||
"timeout_seconds": 900,
|
||||
"soak": {
|
||||
"duration_seconds": 7200,
|
||||
"rest_seconds": 30,
|
||||
"warmup_rounds": 1,
|
||||
"min_rounds": 10,
|
||||
"reconnects": 10,
|
||||
"exits": 10,
|
||||
"resolutions": {
|
||||
"sizes": [[1280, 720], [1440, 900], [1600, 1000]],
|
||||
"switches_per_size": 10
|
||||
},
|
||||
"warp": {
|
||||
"status": "unconfirmed",
|
||||
"notes": "Set status=confirmed only after the environment owner verifies a legal portal: required (even, >=20 out-and-back legs), portal_cm, destination_map_key, return_portal_cm, cross_server."
|
||||
},
|
||||
"faults": {
|
||||
"status": "unconfirmed",
|
||||
"notes": "Set status=confirmed only with a confirmed 127.0.0.1 test proxy (mode local_proxy, per_type, unreachable_seconds 1..20, upstream {auth_host,auth_port,game_host,game_port} = the real test server). server.* and the serverlist entry must then be 127.0.0.1 proxy ports; script/run_client_gate.sh starts script/playable_fault_proxy.mjs for this client only. A server warp that hands out a direct game address bypasses the proxy. Never change global routes or firewall; active reconnect is not a substitute."
|
||||
}
|
||||
},
|
||||
"notes": "Copy to test/playable/<name>.local.json (ignored) and fill from the confirmed server fixture: server.* must equal the serverinfo entry for server_index/channel; map_key is the asset directory holding Setting.txt; waypoints are server cm. Never add credentials here. The soak suite (STB-01) additionally needs timeout_seconds >= soak.duration_seconds + 900."
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"notes": [
|
||||
"每职业从测试角色真实已学技能中选择:single_target 单体、area 范围、self_buff 自身增益、flying 飞行效果。",
|
||||
"status=unconfirmed:尚未由测试环境负责人确认,对应 CBT 用例为 BLOCKED,不得发布。",
|
||||
"status=confirmed:填 skill_id(该职业已学、非被动)、target(enemy|self|ground)、repeats(>=10)、required_evidence(至少一个服务端证据)。",
|
||||
"status=not_applicable:该职业确无此类技能,必须填 checklist_ref 指向发布清单中的确认记录。",
|
||||
"脚本和测试从不补技能 ID;只改本地副本 test/playable/skill-cases.local.json 时同样适用。检查:godot --headless --path project --script playable_skill_matrix.gd -- --job WARRIOR"
|
||||
],
|
||||
"jobs": {
|
||||
"WARRIOR": {
|
||||
"single_target": {
|
||||
"status": "unconfirmed"
|
||||
},
|
||||
"area": {
|
||||
"status": "unconfirmed"
|
||||
},
|
||||
"self_buff": {
|
||||
"status": "unconfirmed"
|
||||
},
|
||||
"flying": {
|
||||
"status": "unconfirmed"
|
||||
}
|
||||
},
|
||||
"ASSASSIN": {
|
||||
"single_target": {
|
||||
"status": "unconfirmed"
|
||||
},
|
||||
"area": {
|
||||
"status": "unconfirmed"
|
||||
},
|
||||
"self_buff": {
|
||||
"status": "unconfirmed"
|
||||
},
|
||||
"flying": {
|
||||
"status": "unconfirmed"
|
||||
}
|
||||
},
|
||||
"SURA": {
|
||||
"single_target": {
|
||||
"status": "unconfirmed"
|
||||
},
|
||||
"area": {
|
||||
"status": "unconfirmed"
|
||||
},
|
||||
"self_buff": {
|
||||
"status": "unconfirmed"
|
||||
},
|
||||
"flying": {
|
||||
"status": "unconfirmed"
|
||||
}
|
||||
},
|
||||
"SHAMAN": {
|
||||
"single_target": {
|
||||
"status": "unconfirmed"
|
||||
},
|
||||
"area": {
|
||||
"status": "unconfirmed"
|
||||
},
|
||||
"self_buff": {
|
||||
"status": "unconfirmed"
|
||||
},
|
||||
"flying": {
|
||||
"status": "unconfirmed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
# 渲染回归入口
|
||||
|
||||
第一批用例针对 R1 场景装配与 R2 角色选择,2026-09-08 开始执行。未宣称 40250 像素一致或最终包通过。
|
||||
|
||||
在仓库根目录运行:
|
||||
|
||||
本地 macOS 修改 C++ 后先执行 `bash script/rendering_test.sh`:它构建并同步 Godot 实际加载的 debug 路径,重签名后运行 CPU 绕序回归与选角截图。仅构建 Release 不会更新本地 Godot 的 debug 扩展。可传 `--hair=1001`;截图与哈希报告在 `build/rendering/selection-<timestamp>-<pid>/`。`--job=0..7` 与 `--main-part=0` 可复现无装备的八种族选择矩阵;`MTGODOT_GPUSKIN=1` 验证 GPU 路径。`--no-hair` 和 `--solid-body` 仅用于隔离诊断,其画面不能当作普通角色验收。
|
||||
|
||||
```bash
|
||||
godot --headless --path project --script main_character_sync_test.gd
|
||||
godot --headless --path project --script gamescene_test.gd
|
||||
godot --path project --script gamescene_visual_test.gd
|
||||
godot --path project --script gamescene_visual_test.gd -- --c1
|
||||
```
|
||||
|
||||
- `main_character_sync_test.gd` 使用可控模型工厂,覆盖晚到实体/缺 race、网络重入、重复事件、移动不被回滚、失败节流与重试上限、VID 变化导致旧构建失效。通过标志是 `failures=0`,不能只看 Godot 退出码。
|
||||
- `gamescene_test.gd` 使用不存在的地图,预期有加载错误日志;它只验证无地图装配,不能当作画面正常证据。
|
||||
- `gamescene_visual_test.gd` 使用真实资产和实际 GameScene,通过 FakeClient 延迟提供 race=0 主角数据,**不发**主角信号。无账号和服务器写入。
|
||||
- 默认先加载 A1;`--c1` 随后提供 C1 服务器位置,用来验证晚到坐标能纠正地图选择。
|
||||
- `gamescene_visual_test.gd` 除位置/地图/表面数外还断言 `feet_gap`:把动画冻结在 0.5 s
|
||||
后取全部 MeshInstance3D 世界包围盒的最低点,减去该处地形高度,要求 |gap| ≤ 0.03 m
|
||||
(R2-01 悬空修复的数值守卫;A1 与 C1 实测均为 0.001373 m)。报告里同时记录
|
||||
`feet_y` / `feet_gap` / `feet_gap_max`。
|
||||
- 视觉用例输出 `build/rendering/gamescene-<timestamp>/game.png` 与 `report.json`。可用环境变量 `MT_RENDER_OUTPUT` 指定独立目录;复用路径会覆盖同名报告,建议每次新目录。
|
||||
- 报告包含位置、相机、地图加载计数、模型表面数、引擎、关键脚本/扩展/模型路径哈希。资产依赖尚未穷举;这只是 R0 部分基线。
|
||||
- 截图尺寸为 1280×720,角色待机固定在 0.5 秒。地形/环境动态因素尚未完全冻结,因此暂不设置像素误差硬阈值。
|
||||
- 测试不配置学习技能,截图中的空快捷槽不代表技能图标验证成功。
|
||||
|
||||
## 不启动游戏的模型渲染回归(model_render_test.gd)
|
||||
|
||||
```bash
|
||||
godot --path project --script model_render_test.gd # 八职业,判定
|
||||
godot --path project --script model_render_test.gd -- --race 0
|
||||
godot --path project --script model_render_test.gd -- --bless # 重建基线
|
||||
```
|
||||
|
||||
无地图、无网络、无服务器:直接用 `ui/player_view.gd` 离线装配一个角色,`wait`
|
||||
动画冻结在 0.5 s,固定灯光(平行光 `(-42,155,0)` energy 1.1 + 环境色
|
||||
`(0.6,0.62,0.68)` energy 0.8)、洋红背景、512×512。与 `char_bench_test.gd` 的区别
|
||||
是它对像素算指标并断言,退出码即结论;bench 仍然只产图供人看。
|
||||
|
||||
指标(每个 race)与判定方式:
|
||||
|
||||
| 指标 | 含义 | 判定 |
|
||||
| --- | --- | --- |
|
||||
| `feet_gap_m` | 最低蒙皮顶点到 y=0 | 绝对,\|gap\| ≤ 0.03 m(实测 −0.0007…0.0030) |
|
||||
| `silhouette_px` | 正面剪影像素数 | 绝对 ≥ 3000;且对基线 ±3% |
|
||||
| `head_cover` | 近景机位下发型盖住头部像素的比例(只统计 no_hair 里属于人体的像素) | 对基线 ±0.03 |
|
||||
| `hair_mean_lum` | 只画发型时发型像素平均亮度 | 对基线 ±0.006 |
|
||||
| `hair_mean_lum_ctrl` | 同上,但换回 R2-02 修复前的 `StandardMaterial3D(roughness=1)` | 负对照:必须比生产值高 ≥ 0.008 |
|
||||
| `hair_lum_p99` / `hair_bright_frac` | 亮度 99 分位 / 超过 0.20 的像素占比 | 仅记录,不判定 |
|
||||
|
||||
为什么用平均亮度而不是「有多少像素炸白」:发色本身差别极大(武士近黑
|
||||
`mean 0.061`,修罗银白 `mean 0.260`),任何跨职业的绝对亮度上限都不成立。平均
|
||||
亮度对「整片发型被额外高光抬亮」敏感,基准差异由按职业记录的基线吸收。
|
||||
|
||||
负对照不是摆设:它每次都真的用旧材质再渲一遍,保证这条指标现在仍分得开好坏
|
||||
(例如有人把灯光调暗到两边都测不出高光时会直接失败)。八职业实测旧材质相对
|
||||
新材质的抬升是 +0.0109 … +0.0371,最小值 0.0109 给阈值 0.006/0.008 留了余量。
|
||||
|
||||
已验证它确实能抓到目标缺陷:把 `Metin2Model::_load_hair()` 临时改回
|
||||
`StandardMaterial3D(roughness=1)` 重新编译后,**八个职业全部失败**
|
||||
(`hair_mean_lum` 偏离基线 0.0109…0.0371);改回 `mtgodot::make_material` 后
|
||||
`PASS`。
|
||||
|
||||
基线在 `test/rendering/model_baseline.json`,含引擎版本。改模型、改发型材质或改
|
||||
这套灯光都会让基线失效,需要人工看过渲染结果再 `--bless`,并在
|
||||
`docs/RENDERING-REPAIR-PLAN.md` 的执行记录里写清重建原因。
|
||||
|
||||
覆盖边界:只有离线单角色装配。不含地图、地形接触、真实光照昼夜、装备叠加、
|
||||
40250 像素比对,也不能替代 `gamescene_visual_test.gd` 与导出包验收。
|
||||
|
||||
已知后续:脸部/脚底近景和区域差异器、完整资产哈希、四职业 GR2/GLB 对照、技能状态与图标用例、真实联机及普通 macOS `.app` 验收。
|
||||
@@ -1 +0,0 @@
|
||||
.godot/
|
||||
@@ -1,31 +0,0 @@
|
||||
# Particle shader lifetime reproducer
|
||||
|
||||
Independent Godot project: no game assets, autoloads, GDExtension, network,
|
||||
EffectPlayer, or particles emitter. Eight short-lived ParticleProcessMaterials
|
||||
cycle through three emission shapes. Weak references must all be dead before
|
||||
exit; a RenderingServer synchronization also runs before quitting.
|
||||
|
||||
Export this project with the installed macOS template using `--export-release`
|
||||
and `--export-debug` into separate directories under the repository's `build/`.
|
||||
Run each exported executable and preserve both stdout and stderr until process
|
||||
exit. A run is clean only if `ISOLATED_PARTICLE_EXIT alive=0` is present and no
|
||||
shader/RID error follows. The success marker alone is not a clean-exit verdict.
|
||||
|
||||
2026-09-10, Godot 4.7.1 official, Apple M4/Metal:
|
||||
|
||||
- Release: zero surviving materials, three ParticlesShaderRD/Shader RID leaks.
|
||||
- Debug: zero surviving materials, no shader/RID leak.
|
||||
|
||||
Logs: `build/rendering/particle-isolated-release.log` and
|
||||
`build/rendering/particle-isolated-debug.log`. Path overrides (`--main-pack`,
|
||||
external `--script`) are not supported by the installed release template;
|
||||
export a standalone app instead. This project contains no copyrighted artwork
|
||||
and may be used as a minimal upstream reproduction if separately authorized.
|
||||
|
||||
2026-09-11: an unmodified same-revision arm64 release build using
|
||||
`debug_symbols=yes optimize=speed lto=none vulkan=no` exits cleanly in this
|
||||
project and in two complete game package checks. Rebuild it with
|
||||
`bash script/build_macos_release_engine.sh arm64` from the repository root.
|
||||
This is a verified alternative build path, not proof of a specific compiler
|
||||
bug or a source fix to the installed official template. Intel and Vulkan are
|
||||
outside this result.
|
||||
@@ -1,9 +0,0 @@
|
||||
[preset.0]
|
||||
name="macOS"
|
||||
platform="macOS"
|
||||
runnable=true
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
[preset.0.options]
|
||||
application/bundle_identifier="org.metin2.particleprobe"
|
||||
@@ -1,17 +0,0 @@
|
||||
extends Node
|
||||
var refs: Array[WeakRef] = []
|
||||
func _ready() -> void: call_deferred("run")
|
||||
func run() -> void:
|
||||
for i in 8: create_material(i)
|
||||
for i in 10: await get_tree().process_frame
|
||||
RenderingServer.force_sync()
|
||||
var alive := 0
|
||||
for reference in refs:
|
||||
if reference.get_ref() != null: alive += 1
|
||||
print("ISOLATED_PARTICLE_EXIT alive=", alive)
|
||||
get_tree().quit(1 if alive else 0)
|
||||
func create_material(index: int) -> void:
|
||||
var material := ParticleProcessMaterial.new()
|
||||
material.emission_shape = index % 3
|
||||
material.get_rid()
|
||||
refs.append(weakref(material))
|
||||
@@ -1 +0,0 @@
|
||||
uid://b3vjaypwq6yx6
|
||||
@@ -1,4 +0,0 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
[ext_resource type="Script" path="res://main.gd" id="1"]
|
||||
[node name="Probe" type="Node"]
|
||||
script = ExtResource("1")
|
||||
@@ -1,9 +0,0 @@
|
||||
config_version=5
|
||||
[application]
|
||||
config/name="Particle lifetime isolation"
|
||||
run/main_scene="res://main.tscn"
|
||||
[display]
|
||||
window/size/viewport_width=320
|
||||
window/size/viewport_height=240
|
||||
[rendering]
|
||||
textures/vram_compression/import_etc2_astc=true
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
repo="$PWD"
|
||||
godot_bin="${GODOT:-$(which godot 2>/dev/null || echo "/Applications/Godot.app/Contents/MacOS/Godot")}"
|
||||
server="$repo/build/extension/src/port/port_fake_login_server"
|
||||
|
||||
server_pid=""
|
||||
cleanup() {
|
||||
if [ -n "$server_pid" ]; then
|
||||
kill -TERM "$server_pid" 2>/dev/null || true
|
||||
wait "$server_pid" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fifo="/tmp/server.ports"
|
||||
rm -f "$fifo"
|
||||
mkfifo "$fifo" 2>/dev/null || touch "$fifo"
|
||||
"$server" > "$fifo" 2> "/tmp/server.log" &
|
||||
server_pid=$!
|
||||
for _ in $(seq 50); do
|
||||
[ -s "$fifo" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
read -r word auth_port game_port < "$fifo" || true
|
||||
if [ "${word:-}" != "ports" ]; then
|
||||
echo "Error: fake server did not start" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export MT_LIVE_HOST=127.0.0.1
|
||||
export MT_LIVE_LOGIN=fakeuser
|
||||
export MT_LIVE_PASSWORD=fakepass
|
||||
export MT_LIVE_AUTH_PORT="$auth_port"
|
||||
export MT_LIVE_GAME_PORT="$game_port"
|
||||
export MT_LIVE_SLOT=0
|
||||
|
||||
echo "Running test with Godot: $godot_bin"
|
||||
"$godot_bin" --path "$repo/project" --script res://test_select_character.gd
|
||||
Reference in New Issue
Block a user