fix(net): 主角 VID 归参考端所有,GC_CHARACTER_DEL 不再把玩家「去主角化」

实机故障:进入游戏后整屏灰紫,主角还是蓝色胶囊占位体,2D UI 与小地图正常。

根因(已实证,非推测)。`EntityStore::mut_despawn()` 在 despawn 的 vid 等于
主角 vid 时把 `m_main_vid` 清零。40250 服务器在 spawn-in 头几秒会对玩家自己的
vid 反复下发 GC_CHARACTER_DEL + GC_CHARACTER_ADD(视野重算,实测约 10 次)。
第一次 DEL 就把玩家永久去主角化:`get_main_vid()` 返回 0 →
`_sync_main_character()` 直接 return → 建模代码从未跑过(main_attempts=0)→
占位胶囊留在 y=0 → 相机跟着它钻到 C1 地形底下 → 满屏灰紫。地图其实一直是好的
(chunks_built 20/20、objects_placed 565)。

参考端怎么做(40250 ClientVS22)。主角身份属于 `CNetworkActorManager::m_dwMainVID`,
只由 `SetMainActorVID()`(GC_MAIN_CHARACTER)写,只在管理器析构时清;
`RemoveActor()` 删 actor 行和角色实例,但绝不碰它;重新加入时
`__AppendCharacterManagerActor()` 用 `kCreateData.m_isMain = __IsMainActorVID(dwVID)`
重新推导,并再跑一次 `SetMainCharacterIndex(dwVID)`。

修法(entity_store.cpp):
- `mut_despawn()` 只删行、只发 Despawn,不动 m_main_vid;m2dev 分支的
  GC_CHARACTER_DEL 同规则。
- `mut_spawn_full()` 在 `e.vid == m_main_vid` 时把 `is_main` 置回 true,
  并重新广播 MainSet,对齐参考端的重新推导。

顺带落地的渲染修复(同一次实机排查中定位):
- libgr2 `sample_pose()` 增加 `root_offset` 形参;`Metin2AnimPlayer::apply_pose()`
  显式传单位阵而不是模型的 InitialPlacement。Metin2 动画 gr2 的根骨轨道自带骨盆
  高度,再乘一次 IP 会把整个人抬高一个骨盆高(武 99.4cm / 刺 92.6 / 巫 103.7 /
  萨 95.5,正好各自的 IP.z)——即此前的「人物悬空」。`bind_pose()` 语义不变。
- `Metin2Model::_load_hair()` 改用与身体/武器同一条 `make_material()`
  (specular_disabled)而不是 roughness=1.0 的 StandardMaterial3D,头发不再被
  PBR 高光洗成奶白色块。

取证与回归工具:
- `project/net_trace.gd`(新):主角 vid 生命周期取证。挂在 AppFlow 之后逐帧读
  `get_main_vid()`,归零时打印 `NETTRACE: MAIN_LOST was=.. now=0 reason=..` 加最近
  24 条环形事件;`reason` 区分 despawn 与 silent(reset_for_map_change 清空
  m_changes,是静默清零)。`MT_NETTRACE=1` 打开逐事件日志。
- `game_scene.gd`:诊断 tick 支持重复采样(分辨主角装配是「没跑」还是「没跑完」),
  新增 main_sync_ready / main_loading / main_view_key / main_map_vid /
  main_retry_in_ms 字段;`MT_DIAG_SHOT=<png>` 让游戏自己存一帧实机画面
  (screencapture 拍到的是终端窗口,没用)。
- `project/model_render_test.gd` + `test/rendering/model_baseline.json`(新):
  八个种族的模型渲染基线(--bless 重新落盘)。
- `project/char_bench_test.gd`(新):无网络无地图的单角色实验台,五变体 ×
  六机位出证据图,只产图不做阈值判定。
- `project/package_render_test.gd`(新):导出包内的渲染自检入口
  (导出模板不接受 --script,只能走 MT_TEST_MODE)。
- `/project/build/` 加入 .gitignore(渲染用例的落图输出)。

实机复验(真实服务器,非模拟):
- 修前 `LIVE_SMOKE FAIL: 主角色 VID 有效`;
  `NETTRACE: MAIN_LOST was=25910 now=0 reason=despawn t=+7737ms`,
  环里紧跟 `spawn vid=25910 is_main=false`。
- 修后 `LIVE_SMOKE RESULT: PASS`,main_vid=25933、entities=19;
  40s 实机跑 16 次诊断采样全部 model_built=True / main_attempts=1 /
  main_sync_ready=True,player_pos 落在 C1 地形上而非 y=0;
  MT_DIAG_SHOT 存下的帧里地形、建筑、NPC、树木、真实角色模型、HUD、小地图俱全。

回归:gamescene_test / equip_model_test / gamescene_visual_test(feet_gap
0.001373)/ model_render_test(8 races)/ char_select_visual_test / 包内
MT_TEST_MODE=render 全绿;ctest 22/23,唯一失败 net.classic_session 已用
「还原本次改动 → 重跑 → diff 失败集合」确认与本次修改无关(失败集合完全相同)。

注:画面整体偏暗属于光照/色调差距,受 PARITY-GAP.md §0 门禁约束(对照帧未落盘前
不做相机/光照/色调/材质调参),本次不碰。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJugvEJwz3FK4hw9ti3SRb
This commit is contained in:
shenlei
2026-09-08 17:15:55 +09:00
co-authored by Claude Opus 5
parent fb4d2d222b
commit e0c1853909
31 changed files with 1368 additions and 42 deletions
+2
View File
@@ -5,6 +5,8 @@
# build
/build/
# 渲染测试的落图输出(test/rendering 用例自建)
/project/build/
/build-ios/
/build-android/
/build-classic/
+108 -2
View File
@@ -241,8 +241,8 @@ godot --path project --script char_select_visual_test.gd
| R0-01 | 固定资源/场景基线与区域截图配置 | 可用参考资源 | 已有 A1/C1 实景入口和部分哈希;区域差异待补 |
| R1-01 | 主角晚到、重复事件、重连回归与修复 | R0 最小基线 | 晚到/重入/重试回归通过;真实重连待验 |
| R1-02 | 地图坐标、高度、相机就绪检查 | R1-01 | A1/C1 晚到定位实景通过;坡地/边界待验 |
| R2-01 | 同战士 GR2/GLB 实验台及对照报告 | R0 | 待执行 |
| R2-02 | 发型/身体/附件修复及路线决定 | R2-01 | 待执行 |
| R2-01 | 同战士 GR2/GLB 实验台及对照报告 | R0 | 实验台与悬空修复已完成并有数值断言;GLB 对照仍待执行 |
| R2-02 | 发型/身体/附件修复及路线决定 | R2-01 | 发型材质根因已修复并有八职业回归;导出包复验待做 |
| R3-01 | 深度/材质/环境与地形校准 | R1、R2 几何稳定 | 材质已有改动,待验证 |
| R4-01 | 选角、技能栏装配与数据恢复 | R1 | 待执行 |
| R5-01 | 包资源清单、最终 `.app` 冒烟与画面报告 | R1–R4 | 待执行 |
@@ -263,3 +263,109 @@ godot --path project --script char_select_visual_test.gd
- 截图人工观察:地图和真实人物出现;UI 仍有布局/风格问题,不能标为 R3/R4 完成。选角截图仍复现发型遮脸;单独翻转 winding 的试验未解决,未保留新的绕序修改。
- 用例命令、输出位置和验证边界见 [test/rendering/README.md](../test/rendering/README.md)。早期失败截图保留在 `build/rendering/`;后续脚本变化应重新执行,不复用旧报告作为新代码证据。
- 尚未完成:GLB 对照、发型根因修复、完整视觉基线与区域阈值、真实服务器重连、最终 macOS 包验收、R6 优化。下一优先项是发型的身体/附件变换与深度隔离。
### 2026-09-08 第二批实施记录(R2-01 悬空 / R2-02 发型)
#### R2-01 人物整体悬空
- 复现:`gamescene_visual_test.gd`A1、C1)与 `char_bench_test.gd` 的 feet 机位,角色脚底离地约一个骨盆高;`tools` 下的 foot_probe 预测偏差 0.146 cm 量级。
- 确认根因:渲染桥把动画世界姿势按 Granny `GrannyBuildWorldPose``Offset4x4` 又乘了一次模型导出期的 `InitialPlacement`,而 Metin2 的动画 `.gr2` 根骨轨道本身已含骨盆高度,等于重复计一次(实测 IP.z:武士 99.443、刺客 92.589、修罗 103.674、萨满 95.473,单位 cm)。
- 修改文件:`libgr2/include/gr2/gr2.h``libgr2/src/gr2_skeleton.cpp``sample_pose` 增加可选 `root_offset`,默认仍取 `sk.initial_placement``bind_pose` 行为不变,oracle 数值一致性未放宽);`extension/src/metin2_anim.cpp`(渲染路径显式传单位阵)。
- 断言:`gamescene_visual_test.gd` 新增 `feet_gap`(阈值 0.03 m)。A1、C1 实测均为 **0.001373 m**`passed: true`
- 未覆盖:只验证了 `wait` @0.5 s 与平地。坡地、跳跃/受击等位移较大的动作、以及 NPC/怪物模型未逐一复验。
#### R2-02 发型发白、看起来「挡脸」
- 复现:`char_bench_test.gd``face_side` 近景,额发和鬓角出现成片浅奶油色三角面。
- 排除项(都不是原因):几何与挂接(发型权重 100% 在 `Bip01 Head`,骨骼按名重映射 74/74,蒙皮包围盒落在头部 z 149–178,`bind_pose_self_check = 7.6e-05`);贴图(`hair/hair_1_1.dds` 就是 gr2 材质里写的那张,256×256 DXT1,最亮纹素 RGB(90,81,90)**0 个透明纹素**,所以 alpha scissor 是空操作,`dxt.cpp` 无关);UV(全在 [0,1])。
- 材质二分(同一机位逐项改一个开关):`TRANSPARENCY_DISABLED` 仍有、`CULL_BACK` 更糟、`metallic_specular = 0` 仍有、**`roughness = 0` 干净**、**`SHADING_MODE_UNSHADED` 干净**。
- 确认根因:`Metin2Model::_load_hair()` 是全角色唯一没走 `mtgodot::make_material` 的表面,用的是裸 `StandardMaterial3D``roughness = 1.0`,浅色斑是 Godot PBR 的高光响应,与身体/武器用的前向材质(shader 带 `specular_disabled`)光照模型不一致。
- 修改文件:`extension/src/metin2_model.cpp``_load_hair()` —— 改用 `mtgodot::MaterialDesc{blend = AlphaTest, alpha_scissor = 0.5, two_sided = true, spec_power = 0}``make_material`;无贴图时用 `modulate` 保留原来的深棕兜底色。GPU 蒙皮路径本来就已经走 `make_material`,此改动使 CPU 路径与之一致。
- 资源哈希(sha256):`PC/ymir work/pc/warrior/warrior_novice.gr2` `7ac881b0c60ced40c9388d6186bdf02a353a58382d12804345cb65fc3dc1fa74``PC/ymir work/pc/warrior/hair/hair_1_1.gr2` `2e3c00bda5397d579a2399cfb38a69974667e4f7b92dca6b2a709058a37d0a84``PC/ymir work/pc/warrior/general/wait.msa` `7bc14bc29a63a78708b9a76618aee9229286f255e725dd669c60ef838cec6a15`。其余七个职业的身体/发型哈希记在各次 `report.json` 里。
- 执行命令与结果:
```bash
./build.sh Debug
godot --headless --path project --script main_character_sync_test.gd # failures=0
godot --headless --path project --script gamescene_test.gd # PASS
godot --headless --path project --script equip_model_test.gd # PASS
godot --path project --script char_select_visual_test.gd # PASS
godot --path project --script gamescene_visual_test.gd # passed:true, feet_gap 0.001373
godot --path project --script gamescene_visual_test.gd -- --c1 # passed:true, feet_gap 0.001373
godot --path project --script char_bench_test.gd -- --race {0,1,2,3} # 四职业近景人工复核,浅色斑消失
godot --path project --script model_render_test.gd # PASS(八职业)
```
- 未覆盖:只验证了基础 `*_novice` 身体加 `hair_1_1`。5th 护甲等换装组合、`hair_skin` 覆写路径、GPU 蒙皮路径的实机画面未逐一复验;**导出包内复验尚未完成**(见下)。
#### 新增:不启动游戏的模型渲染自动回归
- 新增 `project/model_render_test.gd` 与基线 `test/rendering/model_baseline.json`。离线装配单角色(无地图、无网络、无服务器),固定灯光与机位,对像素算指标并断言,退出码即结论。
- 指标与阈值、为什么用平均亮度而不是「炸白像素占比」、以及覆盖边界,见 [test/rendering/README.md](../test/rendering/README.md)。
- **有效性已验证**:把 `_load_hair()` 临时改回 `StandardMaterial3D(roughness = 1)` 重新编译后,八个职业全部失败(`hair_mean_lum` 偏离基线 0.0109…0.0371,阈值 0.006);改回 `make_material``PASS`。这条不是「截图已生成」式的通过。
- 局限:基线绑定当前引擎版本、灯光和资源。改模型/发型材质/灯光都会使其失效,必须人工看过渲染结果再 `--bless` 并在此记录原因。
#### 纠正前一批记录的一处误判
- 第一批记录里「选角截图仍复现发型遮脸」中被一并当作缺陷的「武器平躺在地上」不成立:那是选角页 `ui/char_select_screen.gd:517` 建的 `dragon_left/right_button_0{1,2,3}.sub` 两个 2D 导航按钮(`(714,712)` / `(1024,712)`,160×44),不是武器挂点问题。武器挂接没有发现缺陷。
#### 仍未完成
- ~~**导出包复验**:包内没有离线渲染入口(导出模板不接受 `--script`~~ **已解决(2026-09-08**:新增
`project/package_render_test.gd`,由 `client_main.gd``MT_TEST_MODE=render` 时挂到主场景下——
绕开导出模板不收 `--script` 的限制,走真实 `GameScene` + `Metin2World` + `PlayerView`,不连服务器,
报告和截图落到 `user://render-check/``MT_RENDER_OUTPUT` 可改)。
```
MT_TEST_MODE=render build/export/mtgodot-poc.app/Contents/MacOS/mtgodot-poc
```
首次在**导出包内**跑的结果(2026-09-08release 包):`PASS` —— 扩展三个类都注册、资源根解析到
`Contents/Resources/assets`、地图 20/20 chunk、`model_built=true`、`surfaces=4`、`feet_gap=0.001373`
截图是正常的草地 + 角色。R2-01/R2-02 的「导出包中复验」这一步由此补齐。
未覆盖:网络驱动的进场时序、40250 像素比对、性能。
- ~~**新发现的实机故障**:进入游戏后 3D 画面整片灰紫、主角仍是蓝色胶囊,2D UI 与小地图正常~~
**已修复(2026-09-08**。
**根因**`EntityStore::mut_despawn()` 在 `GC_CHARACTER_DEL` 命中主角 vid 时把 `m_main_vid` 清零了。
40250 服务器在进场那一两秒里会对**主角自己的 vid** 连发 `GC_CHARACTER_DEL` + `GC_CHARACTER_ADD`
(视野重算,实测 10 次左右),于是主角被永久「取消主角身份」:`get_main_vid()` 返回 0 →
`_sync_main_character()` 直接 return → 模型装配代码一次都没跑(`main_attempts=0`),
占位胶囊留在 y=0,相机跟着它钻到地形底下 → 满屏灰紫。地图其实一直是好的。
**参考端怎么做**`40250/ClientVS22/source/UserInterface/`):主角 vid 归
`CNetworkActorManager::m_dwMainVID` 所有,只由 `SetMainActorVID()`GC_MAIN_CHARACTER)写、
只在管理器销毁时清;`RemoveActor()`NetworkActorManager.cpp:440)删 actor 行和角色实例,
**不碰 m_dwMainVID**。重新 add 时 `__AppendCharacterManagerActor()` 用
`kCreateData.m_isMain = __IsMainActorVID(dwVID)` 把它重新认成主角,并再跑一次
`SetMainCharacterIndex(dwVID)`。
**修法**`extension/src/net/entity_store.cpp`,两处对齐参考端):
1. `mut_despawn()` 不再清 `m_main_vid`m2dev 分支的 `GC_CHARACTER_DEL` 同样处理);
该字段现在只由 `mut_spawn_main()` 写、只由 `reset_for_map_change()` 清(对应参考端的销毁)。
2. `mut_spawn_full()` 里 `vid == m_main_vid` 就把 `is_main` 置回 true,并补一条 `MainSet`
变更,对应 `__AppendCharacterManagerActor()` 的 `m_isMain` + `SetMainCharacterIndex`。
**取证手段**(保留在树里):
- `project/net_trace.gd`(由 `client_main.gd` 挂在 AppFlow 之后)每帧比对 `get_main_vid()`
丢主角时打 `NETTRACE: MAIN_LOST ... reason=despawn|silent` 和最近 24 条事件环。
`reason` 直接区分「`GC_CHARACTER_DEL` 命中主角」和「`reset_for_map_change()` 静默清零」
(后者会清空 `m_changes`,不发 despawn 信号)。`MT_NETTRACE=1` 打全量。
- `game_scene.gd` `_diag_tick()`:进游戏若干秒后,`_model_built` 或 `_map_loaded()` 任一为假就打一行
`GAMESCENE_DIAG:``race` / `pos_cm` / `map_path` / `map_base_set` / `main_attempts` /
`main_sync_ready` / `map_report` …)。`MT_DIAG=1` 强制输出并连打 6 次(间隔 2 s),
`MT_DIAG_AFTER_MS` 改延时,`MT_DIAG_SHOT=<png>` 在最后一次存一张实机帧。
**实机复验**(真服 192.168.21.203classic2026-09-08):
- 修前 `./script/live_smoke_test.sh --no-build --no-mutations --no-reconnect` →
`FAIL: 主角色 VID 有效` / `FAIL: 主角色不存在``NETTRACE: MAIN_LOST was=25910 now=0 reason=despawn`。
- 修后同一命令 → `LIVE_SMOKE RESULT: PASS``main_vid=25933 pos_cm=(963444.0, 266221.0, 0.0) entities=19`。
- 40 秒实机连打诊断:`model_built=true`、`main_attempts=1`、`player_pos=(418.44, 177.67, 614.21)`
(站在 C1 地形上,不再是 y=0),16 次采样全程稳定;`MT_DIAG_SHOT` 存下的帧是正常的
地形 + 建筑 + NPC + 主角模型 + HUD + 小地图。
未覆盖:主角材质在俯视机位下偏暗的观感问题——那属于光照/色调,受 PARITY-GAP §0 门禁约束,
不在这次修复范围内。
- R2-01 的 GLB 对照、R0 完整视觉基线与区域阈值、R4 选角/快捷栏、R5 最终包验收、R6 性能,均未开始。
+15 -2
View File
@@ -415,7 +415,20 @@ void Metin2AnimPlayer::apply_pose(double t) {
tt = std::fmax(0.0, std::fmin(t, duration));
}
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf);
// Offset4x4 = identity, NOT the model's exported InitialPlacement.
//
// gr2::sample_pose defaults to sk.initial_placement because that is what the
// oracle passes (GrannyGetModelInitialPlacement4x4) and what the bind pose /
// InverseWorldTransform pair is self-consistent with. Metin2's animation .gr2
// carries the root bone's pelvis height inside its own track, so composing it
// onto InitialPlacement counts that placement twice and lifts the whole actor
// by one pelvis height (warrior 99.4 cm, assassin 92.6, sura 103.7, shaman
// 95.5 — exactly each model's IP.z). That was the "人物悬空" report.
// Measured with tools/rendering foot probe: with identity the lowest skinned
// vertex sits within a few mm of z=0 for wait/walk on all four classes, and
// only leaves the ground during run's airborne frames.
static const gr2::Mat4 kNoOffset{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf, &kNoOffset);
// Crossfade: blend the frozen outgoing-clip pose into this one, then rebuild
// skin_buf = invWorld · world_blended (PARITY §2.9).
@@ -423,7 +436,7 @@ void Metin2AnimPlayer::apply_pose(double t) {
const gr2::FileInfo &pfi = prev_anim_file->file_info();
if (!pfi.animations.empty()) {
gr2::sample_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_world_buf,
prev_skin_buf);
prev_skin_buf, &kNoOffset);
double w = blend_elapsed / blend_time;
// ease-in on the incoming clip, matching the client's
// GrannySetControlEaseInCurve(t0,t1, 0,0,1,1) Hermite (p0=0,m0=0,
+14 -10
View File
@@ -554,12 +554,6 @@ void Metin2Model::_load_hair() {
hair_parts = mtgodot::build_parts(hfi);
// hair 贴图:同名 .ddsalpha-scissor 镂空
Ref<StandardMaterial3D> hm;
hm.instantiate();
hm->set_roughness(1.0f);
hm->set_transparency(StandardMaterial3D::TRANSPARENCY_ALPHA_SCISSOR);
hm->set_alpha_scissor_threshold(0.5f);
hm->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
// SourceSkin (gr2 sibling .dds) -> TargetSkin override (§2.4)
String hdds = hair_skin.is_empty() ? (hair_gr2.get_basename() + ".dds") : hair_skin;
Ref<ImageTexture> ht = _load_dds(hdds);
@@ -567,10 +561,20 @@ void Metin2Model::_load_hair() {
UtilityFunctions::push_warning(String("[Metin2Model] hair_skin not found: ") + hair_skin);
ht = _load_dds(hair_gr2.get_basename() + ".dds"); // fall back to SourceSkin
}
if (ht.is_valid())
hm->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, ht);
else
hm->set_albedo(Color(0.18f, 0.12f, 0.08f));
// R2-02: the same forward material the body/weapon surfaces use, not a
// StandardMaterial3D. The old one ran Godot's PBR specular at roughness 1.0
// and washed the fringe out into flat cream patches (bisection: roughness 0
// or unshaded clean, alpha/cull/metallic changes not). make_material's
// shaders are `specular_disabled`, so the hair now lights like the body.
// CPU path: skinning already happened on the CPU, so skinned = false.
mtgodot::MaterialDesc hd;
hd.albedo = ht;
hd.blend = mtgodot::BlendMode::AlphaTest; // discard < 0.5, still depth-opaque
hd.alpha_scissor = 0.5f;
hd.two_sided = true; // hair cards are single-sided geometry
Ref<ShaderMaterial> hm = mtgodot::make_material(hd);
if (ht.is_null()) // keep the untextured fallback colour (make_material's is pale grey)
hm->set_shader_parameter("modulate", Color(0.18f, 0.12f, 0.08f, 1.0f));
hair_mat = hm;
hair_tex = ht; // reused by the GPU-skin path (§2.2)
+23 -9
View File
@@ -119,11 +119,24 @@ void EntityStore::mut_spawn_full(const Entity &src) {
// caller ever passes `store.get(vid)` back in.
Entity e = src;
e.vid = src.vid;
m_ents[src.vid] = e;
if (e.is_main) {
m_main_vid = src.vid;
// Reference CNetworkActorManager::__AppendCharacterManagerActor():
// kCreateData.m_isMain = __IsMainActorVID(dwVID);
// The main VID is owned by the manager, not by the actor row, so an actor
// re-added under the main VID becomes the main instance again. The 40250
// server does exactly this at spawn-in: GC_CHARACTER_DEL + GC_CHARACTER_ADD
// on our own VID moments after GC_MAIN_CHARACTER. Without this, the re-added
// row is a plain PC and the local player stays the fallback capsule.
if (e.vid == m_main_vid) {
e.is_main = true;
}
m_ents[src.vid] = e;
m_changes.push_back({ChangeKind::Spawn, src.vid});
if (e.is_main) {
// __AppendCharacterManagerActor() re-runs SetMainCharacterIndex(dwVID)
// on every main re-add, so re-announce it here too.
m_main_vid = src.vid;
m_changes.push_back({ChangeKind::MainSet, src.vid});
}
}
void EntityStore::mut_ownership(uint32_t vid, uint32_t owner_vid) {
@@ -170,12 +183,14 @@ void EntityStore::mut_map_bgm(const std::string &name, float volume) {
}
void EntityStore::mut_despawn(uint32_t vid) {
// Reference CNetworkActorManager::RemoveActor() drops the actor row and the
// character instance but never touches m_dwMainVID — that field is written
// only by SetMainActorVID() (GC_MAIN_CHARACTER) and cleared only when the
// manager is destroyed (our reset_for_map_change()). Clearing it here made a
// routine GC_CHARACTER_DEL on our own VID permanently un-main the player.
if (m_ents.erase(vid)) {
m_changes.push_back({ChangeKind::Despawn, vid});
}
if (vid == m_main_vid) {
m_main_vid = 0;
}
}
// CLIENT-GAP §3.2: GC_MOVE no longer mutates position directly. It appends a
@@ -1658,12 +1673,11 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
}
GCCharacterDel p;
std::memcpy(&p, body, sizeof(p));
// Same rule as mut_despawn(): the main VID survives a
// GC_CHARACTER_DEL and is re-bound by the matching re-add.
if (m_ents.erase(p.vid)) {
m_changes.push_back({ChangeKind::Despawn, p.vid});
}
if (p.vid == m_main_vid) {
m_main_vid = 0;
}
return;
}
case GC_MOVE: {
+1 -1
View File
@@ -1,6 +1,6 @@
# libgr2
自研 gr2 v6/v7 **只读**读取器。零第三方依赖。施工文档 [`../docs/steps/M0-gr2-reader.md`](../docs/steps/M0-gr2-reader.md)。
自研 gr2 v6/v7 **只读**读取器。零第三方依赖。施工文档 [`../docs/reference/steps/M0-gr2-reader.md`](../docs/reference/steps/M0-gr2-reader.md)。
## 能做什么
+12 -3
View File
@@ -1,5 +1,5 @@
// libgr2 —— gr2 v6 只读读取器(demo 子集)。
// 施工文档:docs/steps/M0-gr2-reader.md
// 施工文档:docs/reference/steps/M0-gr2-reader.md
#pragma once
#include "types.h"
#include <cstdint>
@@ -83,11 +83,20 @@ private:
// ── M2:姿势采样(model 的 skeleton + anim 的 tracks 可来自不同 gr2)──────
// Granny 语义、行主序、平移在 elem 12..14
// world[i] = Composite(local[i]) · (parent<0 ? sk.initial_placement : world[parent])
// world[i] = Composite(local[i]) · (parent<0 ? root_offset : world[parent])
// skin[i] = sk.bones[i].inverse_world · world[i]
// tracks 按骨骼名匹配 sk;无匹配的骨骼用 sk 的 bind LocalTransform。
//
// root_offset = GrannyBuildWorldPose 的 Offset4x4。默认(nullptr)用
// sk.initial_placement —— 与 oracle/oracle.c 的 GrannyGetModelInitialPlacement4x4
// 一致,是 bind pose 与 inverse_world 自洽所要求的那一个(bind_pose 只用这一种)。
// 渲染动画时客户端另有取法:EterGrnLib 对 Offset4x4 传身位矩阵而不是导出期的
// InitialPlacement,而 Metin2 的 .gr2 动画 track 里根骨的位移已经含了骨盆高度,
// 所以再乘一次 InitialPlacement 会把整个人抬高一个骨盆高。要那条语义就显式传
// 单位阵(见 extension/src/metin2_anim.cpp 与 test/rendering/README.md 的实测)。
void sample_pose(const Skeleton& sk, const Animation& an, float t,
std::vector<Mat4>& world, std::vector<Mat4>& skin);
std::vector<Mat4>& world, std::vector<Mat4>& skin,
const Mat4* root_offset = nullptr);
void bind_pose(const Skeleton& sk, std::vector<Mat4>& world, std::vector<Mat4>& skin);
// 打印辅助
+1 -1
View File
@@ -10,7 +10,7 @@
namespace gr2 {
using Mat4 = std::array<float, 16>; // 行主序,Granny 原生。坐标约定见 docs/steps/M0-gr2-reader.md「约定」
using Mat4 = std::array<float, 16>; // 行主序,Granny 原生。坐标约定见 docs/reference/steps/M0-gr2-reader.md「约定」
// ── 骨架 ──────────────────────────────────────────────────────────────
struct Bone {
+1 -1
View File
@@ -1,5 +1,5 @@
// M0 T7a —— 曲线子类型分类(gr2fuzz 直方图依赖)。T7b(解码)见文末。
// 见 docs/steps/M0-gr2-reader.md
// 见 docs/reference/steps/M0-gr2-reader.md
// 规格:granny_track_group.cpp (TrackGroupType/TransformTrackType/Curve2Type) /
// granny_curve.cpp (CurveData*Type,均以 CurveDataHeaderType 开头) /
// granny_curve.h (curve_data_header{ u8 Format; u8 Degree })
+1 -1
View File
@@ -1,4 +1,4 @@
// M0 T2 —— section 解压分派。见 docs/steps/M0-gr2-reader.md
// M0 T2 —— section 解压分派。见 docs/reference/steps/M0-gr2-reader.md
#include "internal.h"
#include "oodle1.h"
#include <cstring>
+1 -1
View File
@@ -1,4 +1,4 @@
// M0 T1 —— header + section table + fixup。见 docs/steps/M0-gr2-reader.md
// M0 T1 —— header + section table + fixup。见 docs/reference/steps/M0-gr2-reader.md
// 规格:MobileSource/Cross Platform/Granny-3D-SDK-main/source/granny_file_format.h + .cpp
//
// 本次实现范围:magic 识别 + grn_file_header + section 表解析(--sections 用)。
+1 -1
View File
@@ -1,5 +1,5 @@
// M0 T4 —— FileInfo 汇总(驱动 T5 骨架 / T6 网格 / T7a 曲线分类)。
// 见 docs/steps/M0-gr2-reader.md
// 见 docs/reference/steps/M0-gr2-reader.md
// 规格:granny_file_info.cpp (FileInfoType) / granny_animation.cpp (AnimationType)
#include "internal.h"
#include <cstdio>
+1 -1
View File
@@ -1,4 +1,4 @@
// M0 T6 —— 顶点 / 索引 / 三角组 / BoneBindings。见 docs/steps/M0-gr2-reader.md
// M0 T6 —— 顶点 / 索引 / 三角组 / BoneBindings。见 docs/reference/steps/M0-gr2-reader.md
// 规格:granny_mesh.cpp (MeshType/BoneBindingType) / granny_vertex_data.cpp (VertexDataType
// + 各顶点格式) / granny_tri_topology.cpp (TriTopologyType/TriMaterialGroupType)
// 顶点格式是自描述的(Vertices 是 ReferenceToVariantArray,带 data_type_definition*),
+8 -6
View File
@@ -1,4 +1,4 @@
// M0 T5 —— 骨架提取 + bind pose 自洽检查。见 docs/steps/M0-gr2-reader.md
// M0 T5 —— 骨架提取 + bind pose 自洽检查。见 docs/reference/steps/M0-gr2-reader.md
// 规格:granny_transform.cpp BuildCompositeTransform4x4 / granny_math.cpp
// MatrixEqualsQuaternion3x3 + MatrixMultiply3x3 /
// granny_matrix_operations.cpp ColumnMatrixMultiply4x3Impl /
@@ -131,14 +131,15 @@ bool File::Impl::extract_skeleton(Ref skel_obj, Ref skel_type, Skeleton& out) {
// ── M2:姿势采样(自由函数 = 主 primitive;File::* 是便捷封装)──────────
namespace {
void accumulate_world(const Skeleton& sk, const std::vector<Mat4>& local,
std::vector<Mat4>& world, std::vector<Mat4>& skin) {
std::vector<Mat4>& world, std::vector<Mat4>& skin,
const Mat4& root_offset) {
const size_t n = sk.bones.size();
const Mat4 I{1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
world.assign(n, I);
skin.assign(n, I);
for (size_t i = 0; i < n; ++i) {
int32_t par = sk.bones[i].parent;
const Mat4& parent_w = (par < 0 || (size_t)par >= i) ? sk.initial_placement : world[par];
const Mat4& parent_w = (par < 0 || (size_t)par >= i) ? root_offset : world[par];
world[i] = mul4x3((i < local.size()) ? local[i] : sk.bones[i].local_transform, parent_w);
skin[i] = mul4x3(sk.bones[i].inverse_world, world[i]);
}
@@ -148,11 +149,12 @@ void accumulate_world(const Skeleton& sk, const std::vector<Mat4>& local,
void bind_pose(const Skeleton& sk, std::vector<Mat4>& world, std::vector<Mat4>& skin) {
std::vector<Mat4> local(sk.bones.size());
for (size_t i = 0; i < sk.bones.size(); ++i) local[i] = sk.bones[i].local_transform;
accumulate_world(sk, local, world, skin);
accumulate_world(sk, local, world, skin, sk.initial_placement);
}
void sample_pose(const Skeleton& sk, const Animation& an, float t,
std::vector<Mat4>& world, std::vector<Mat4>& skin) {
std::vector<Mat4>& world, std::vector<Mat4>& skin,
const Mat4* root_offset) {
std::vector<Mat4> track_local;
an.sample_local(t, track_local);
@@ -167,7 +169,7 @@ void sample_pose(const Skeleton& sk, const Animation& an, float t,
if (sk.bones[k].name == tr.bone_name) { bi = int(k); break; }
if (bi >= 0) local[bi] = track_local[ti];
}
accumulate_world(sk, local, world, skin);
accumulate_world(sk, local, world, skin, root_offset ? *root_offset : sk.initial_placement);
}
bool File::bind_pose(int skeleton, std::vector<Mat4>& world, std::vector<Mat4>& skin) const {
+1 -1
View File
@@ -1,4 +1,4 @@
// M0 T1fixup 索引)+ T3(自描述类型树遍历)。见 docs/steps/M0-gr2-reader.md
// M0 T1fixup 索引)+ T3(自描述类型树遍历)。见 docs/reference/steps/M0-gr2-reader.md
// 规格:granny_file_format.hfixup+ granny_data_type_definition.h/.cpp(成员类型 + stride
#include "internal.h"
#include <cstring>
+1 -1
View File
@@ -3,7 +3,7 @@
* Ported (decode path only) from the leaked Granny 2.9.12 SDK:
* granny_oodle1_compression.cpp + radlz.c + radarith.c + arithbit.c
* Used as algorithm spec; RAD's build machinery / compressor are NOT included.
* See docs/steps/M0-gr2-reader.md T2.
* See docs/reference/steps/M0-gr2-reader.md T2.
*/
#include "oodle1.h"
#include <stdlib.h>
+243
View File
@@ -0,0 +1,243 @@
# R2-01 角色实验台 —— 无网络、无地图,只装配一个角色并从固定机位取近景。
#
# godot --path project --script char_bench_test.gd -- --race 0
# godot --path project --script char_bench_test.gd -- --race 0 --variant no_hair
# godot --path project --script char_bench_test.gd -- --body <gr2> --hair <gr2> --skin <dds>
#
# 目的是把「发型挡脸」拆成几何问题和材质/深度问题:同一机位分别渲染
# full 身体+发型(生产路径)
# no_hair 只有身体(脸是否本来就正确)
# hair_only 只有发型(发型网格实际落在哪、包围盒多大)
# unlit 身体+发型,禁用光照/阴影的诊断材质(排除光照与阴影影响)
# wire 身体+发型,线框(几何形状判定)
# 每个变体输出 front/side/back/face/feet 五个机位,附 report.json 记录
# 表面数、包围盒、材质模式和资源哈希。它不做像素阈值判定,只产证据。
extends SceneTree
const PlayerView = preload("res://ui/player_view.gd")
var _out := ""
var _report := {}
# 生产材质就挂在 surface override 上(cpu_skin 每帧重挂)。冻结动画后 cpu_skin
# 不再跑,所以变体切换必须把它们存下来再还原 —— 早先用 null "还原" 等于把贴图
# 材质丢掉,五个变体全渲成白模。
var _base_mats: Array = []
func _initialize() -> void:
call_deferred("run")
func _arg(name: String, dflt := "") -> String:
var args := OS.get_cmdline_user_args()
var i := args.find("--" + name)
return args[i + 1] if i >= 0 and i + 1 < args.size() else dflt
func _flag(name: String) -> bool:
return ("--" + name) in OS.get_cmdline_user_args()
func run() -> void:
root.size = Vector2i(720, 720)
_out = OS.get_environment("MT_RENDER_OUTPUT")
if _out.is_empty():
_out = ProjectSettings.globalize_path("res://../build/rendering/charbench-%d"
% Time.get_unix_time_from_system())
DirAccess.make_dir_recursive_absolute(_out)
var race := int(_arg("race", "0"))
var assets := AssetRoot.path()
var pv := PlayerView.new()
root.add_child(pv)
if not pv.build(assets, race):
printerr("CHARBENCH FAIL: build(race=%d) returned false" % race)
quit(1)
return
# 允许覆盖具体身体/发型/贴图,以便复现用户出错的那一套。
var body := _arg("body")
if not body.is_empty(): pv.set("gr2_path", body)
var hair := _arg("hair")
if not hair.is_empty(): pv.set("hair_gr2", hair)
var skin := _arg("skin")
if not skin.is_empty(): pv.set("hair_skin", skin)
var anim := _arg("anim", "wait")
pv.set_anim_state(anim)
var sample := float(_arg("time", "0.5"))
await process_frame
await process_frame
if pv.anim:
pv.anim.call("set_time", sample)
pv.anim.set_process(false)
pv.rotation = Vector3.ZERO
_light()
var cam := Camera3D.new()
cam.fov = 40.0
cam.near = 0.02
root.add_child(cam)
cam.make_current()
_report = {
"race": race, "anim": anim, "sample_seconds": sample,
"body_gr2": String(pv.model.get("gr2_path")),
"hair_gr2": String(pv.model.get("hair_gr2")),
"hair_skin": String(pv.model.get("hair_skin")),
"ground_offset": pv.model.position.y,
"model_info": String(pv.model.call("get_info")),
"engine": Engine.get_version_info(),
"viewport": str(root.size),
"scope": "isolated bench: no map, no network, no 40250 pixel comparison",
"variants": {},
}
var variants := PackedStringArray(["full", "no_hair", "hair_only", "unlit", "wire"])
var only := _arg("variant")
if not only.is_empty():
variants = PackedStringArray([only])
for v in variants:
await _shoot(pv, cam, v)
_report["sha256"] = _hashes(pv)
var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE)
f.store_string(JSON.stringify(_report, "\t"))
f.close()
print("CHARBENCH: ", _out)
print(JSON.stringify(_report))
quit(0)
func _light() -> void:
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-42, 155, 0)
sun.light_energy = 1.1
sun.set_layer_mask(0xFFFFF) # CharacterLight 层:模型 mi 用 layer 2
root.add_child(sun)
var env := WorldEnvironment.new()
var e := Environment.new()
e.background_mode = Environment.BG_COLOR
e.background_color = Color(0.15, 0.16, 0.2)
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
e.ambient_light_color = Color(0.6, 0.62, 0.68)
e.ambient_light_energy = 0.8
env.environment = e
root.add_child(env)
# 每个变体:改可见性/材质,然后跑五个机位。
func _shoot(pv: Node3D, cam: Camera3D, variant: String) -> void:
var meshes := _meshes(pv)
var body_mi: MeshInstance3D = null
for mi in meshes:
if mi.name == "MeshInstance3D": body_mi = mi
if body_mi == null:
printerr("CHARBENCH: no body MeshInstance3D for variant ", variant)
return
# 身体 surface 数(发型折进同一个 mesh 的尾部 surface)。
var total := body_mi.mesh.get_surface_count()
if _base_mats.size() != total:
_base_mats.clear()
for s in total:
_base_mats.append(body_mi.get_surface_override_material(s))
var hair_first := int(_report.get("hair_surface_first", -1))
if hair_first < 0:
hair_first = _hair_surface_first(pv, body_mi)
_report["hair_surface_first"] = hair_first
_restore(body_mi, total)
var is_hair := func(s: int) -> bool: return hair_first >= 0 and s >= hair_first
match variant:
"no_hair":
for s in total:
if is_hair.call(s): body_mi.set_surface_override_material(s, _hidden())
"hair_only":
for s in total:
if not is_hair.call(s): body_mi.set_surface_override_material(s, _hidden())
"unlit":
for s in total:
body_mi.set_surface_override_material(s,
_diag(Color(1, 0.35, 0.2) if is_hair.call(s) else Color(0.35, 0.6, 1.0), false))
"wire":
for s in total:
body_mi.set_surface_override_material(s,
_diag(Color(1, 0.35, 0.2) if is_hair.call(s) else Color(0.35, 0.6, 1.0), true))
var aabb: AABB = body_mi.get_aabb()
var vr := {"surfaces": total, "hair_surface_first": hair_first,
"local_aabb": str(aabb), "shots": {}}
for shot in _shots():
cam.global_transform = Transform3D(Basis(), shot["eye"] as Vector3) \
.looking_at(shot["at"] as Vector3, Vector3.UP)
cam.fov = float(shot["fov"])
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var name := "%s-%s.png" % [variant, shot["name"]]
var err := root.get_texture().get_image().save_png(_out.path_join(name))
vr["shots"][String(shot["name"])] = name if err == OK else "SAVE_FAILED"
_report["variants"][variant] = vr
_restore(body_mi, total)
func _restore(body_mi: MeshInstance3D, total: int) -> void:
for s in total:
body_mi.set_surface_override_material(s, _base_mats[s] if s < _base_mats.size() else null)
# 头顶约 1.74 m;机位单位是米,正面在 +Z。
func _shots() -> Array:
return [
{"name": "front", "eye": Vector3(0, 1.0, 3.1), "at": Vector3(0, 0.9, 0), "fov": 40},
{"name": "side", "eye": Vector3(3.1, 1.0, 0), "at": Vector3(0, 0.9, 0), "fov": 40},
{"name": "back", "eye": Vector3(0, 1.0, -3.1), "at": Vector3(0, 0.9, 0), "fov": 40},
{"name": "face", "eye": Vector3(0, 1.68, 0.75), "at": Vector3(0, 1.62, 0), "fov": 32},
{"name": "face_side", "eye": Vector3(0.75, 1.68, 0.05), "at": Vector3(0, 1.62, 0), "fov": 32},
{"name": "feet", "eye": Vector3(0.35, 0.30, 0.75), "at": Vector3(0, 0.06, 0), "fov": 32},
]
func _meshes(pv: Node3D) -> Array:
return pv.find_children("*", "MeshInstance3D", true, false)
# 发型被折进 body mesh 的末尾若干 surface。清 hair_gr2 再数一遍是不行的:
# surface 数要等下一次 cpu_skin 才变,读回来还是折叠后的值(旧实现因此恒返回
# -1,所有变体渲染成同一张图)。get_info() 报的 surfaces= 是身体 gr2 自己的
# surface 数,与折叠无关,直接拿它当分界。
func _hair_surface_first(pv: Node3D, body_mi: MeshInstance3D) -> int:
if String(pv.model.get("hair_gr2")).is_empty():
return -1
var total := body_mi.mesh.get_surface_count()
var info := String(pv.model.call("get_info"))
var body_surfaces := -1
for token in info.split(" ", false):
if token.begins_with("surfaces="):
body_surfaces = int(token.substr(9))
if body_surfaces <= 0 or body_surfaces >= total:
printerr("CHARBENCH: cannot split hair surfaces (info=%s, mesh surfaces=%d)" % [info, total])
return -1
return body_surfaces
func _hidden() -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED
m.no_depth_test = false
m.render_priority = 0
m.albedo_color = Color(0, 0, 0, 0)
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
return m
func _diag(c: Color, wire: bool) -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.albedo_color = c
m.cull_mode = BaseMaterial3D.CULL_DISABLED
if wire:
m.albedo_color = Color(c.r, c.g, c.b, 0.35)
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
return m
func _hashes(pv: Node3D) -> Dictionary:
var h := {}
for p in ["res://ui/player_view.gd", "res://char_bench_test.gd",
"res://bin/libmtgodot.macos.template_debug.dylib"]:
h[p] = FileAccess.get_sha256(p)
for prop in ["gr2_path", "hair_gr2", "hair_skin"]:
var path := String(pv.model.get(prop))
if not path.is_empty() and FileAccess.file_exists(path):
h[path] = FileAccess.get_sha256(path)
return h
+1
View File
@@ -0,0 +1 @@
uid://yu8c7genhjae
+17
View File
@@ -7,8 +7,18 @@ extends Node
const AppFlow = preload("res://app_flow.gd")
const LiveSmokeTest = preload("res://live_smoke_test.gd")
const PackageRenderTest = preload("res://package_render_test.gd")
const NetTrace = preload("res://net_trace.gd")
func _ready() -> void:
# MT_TEST_MODE=render:包内离线渲染自检,不起 AppFlow、不连服务器。
# 导出模板不接受 --script,这是导出包里唯一能跑离线渲染回归的入口。
if OS.get_environment("MT_TEST_MODE") == "render":
AssetPack.ensure()
var t := PackageRenderTest.new()
t.name = "PackageRenderTest"
add_child(t)
return
# 打包 app 从 Finder/open 启动时没有 shell 环境;40250 服务器使用 classic
# DH2/CTR 协议,因此默认固定到 classic,同时保留 MT_PROTOCOL 覆盖能力。
if OS.get_environment("MT_PROTOCOL").is_empty():
@@ -22,6 +32,13 @@ func _ready() -> void:
af.name = "AppFlow"
add_child(af)
af.start(root)
# 主角 vid 生命周期取证(见 net_trace.gd):排在 AppFlow 之后,保证每帧
# 在 client 派发完信号之后再读 main_vid。
if af.client != null:
var trace := NetTrace.new()
trace.name = "NetTrace"
add_child(trace)
trace.setup(af.client)
if OS.get_environment("MT_TEST_MODE") == "smoke":
var smoke := LiveSmokeTest.new()
smoke.name = "LiveSmokeTest"
+64
View File
@@ -130,6 +130,19 @@ var _main_retry_at := 0
var _main_placed_vid := 0
var _main_map_vid := 0
const MAIN_MODEL_MAX_ATTEMPTS := 3
# 实机「灰紫虚空 + 蓝胶囊」的取证钩子。离线自检(package_render_test.gd)在导出包里
# 是全绿的,所以这个故障只出现在服务器驱动的进场路径上,而实机唯一的产物是一张截图。
# 进游戏 DIAG_AFTER_MS 后如果地图或主角模型任一没落地,就把决定性的几个值打一次到
# godot.log —— 让下一次实机复现直接给出原因,而不是再来一张截图。
# MT_DIAG=1 时无论成败都打。
const DIAG_AFTER_MS := 8000
const DIAG_REPEAT_MS := 2000 # MT_DIAG=1 时连打几次,看主角装配是"没跑"还是"没跑完"
const DIAG_REPEATS := 6
var _diag_at := 0
var _diag_done := false
var _diag_left := 1
var _diag_started := false
const MAIN_MODEL_RETRY_MS := 1000
var _audio: Node
var _bgm_director: Node
@@ -153,6 +166,8 @@ func setup(m2client: Node, assets_root: String,
initial_map_path: String = "OutdoorA1/metin2_map_a1") -> void:
client = m2client
_assets = assets_root
var diag_after := int(OS.get_environment("MT_DIAG_AFTER_MS")) if OS.get_environment("MT_DIAG_AFTER_MS") != "" else DIAG_AFTER_MS
_diag_at = Time.get_ticks_msec() + diag_after
_ui_mobile = UiProfile.resolve() == UiProfile.Mode.MOBILE
# GC_MAIN_CHARACTER only carries the server-space coordinates, not the map
# folder. The old fixed A1 fallback puts characters from A2/A3 outside the
@@ -658,6 +673,7 @@ func _process(_dt: float) -> void:
# Without it the local player stays as the blue CapsuleMesh forever and the
# camera follows an ungrounded placeholder, which makes the map look empty.
_sync_main_character()
_diag_tick()
# MilesLib::CSoundManager::SetPosition/SetDirection + Update. The audio
# node keeps its listener at the origin and rebases active 3D instances.
if _audio == null or player == null or not is_instance_valid(player):
@@ -667,6 +683,54 @@ func _process(_dt: float) -> void:
if world and world.has_method("get_ambience_sources") and _audio.has_method("update_ambience_sources"):
_audio.update_ambience_sources(world.call("get_ambience_sources"))
# 进游戏若干秒后的一次性取证。只在「地图或主角没落地」时输出,正常局内不产生噪音。
# 打印的每个值都对应 _build_world / _sync_main_character 里的一个具体分支,读日志的人
# 不用再猜是资源没找到、扩展没注册、race 越界还是坐标落在了别的地图上。
func _diag_tick() -> void:
if _diag_done or _diag_at == 0 or Time.get_ticks_msec() < _diag_at:
return
var forced := OS.get_environment("MT_DIAG") == "1"
if forced and not _diag_started:
_diag_started = true
_diag_left = DIAG_REPEATS
_diag_left -= 1
if _diag_left <= 0:
_diag_done = true
else:
_diag_at = Time.get_ticks_msec() + DIAG_REPEAT_MS
var in_game: bool = client != null and client.has_method("is_in_game") and client.is_in_game()
if not forced and (not in_game or (_model_built and _map_loaded())):
_diag_done = true
return
var vid := int(client.get_main_vid()) if client != null and client.has_method("get_main_vid") else 0
var e: Dictionary = client.get_entity(vid) if vid != 0 and client.has_method("get_entity") else {}
var d := {
"in_game": in_game, "model_built": _model_built, "map_loaded": _map_loaded(),
"map_path": map_path, "assets": _assets, "assets_exists": DirAccess.dir_exists_absolute(_assets),
"main_vid": vid, "race": e.get("race", null), "pos": str(e.get("pos", "")),
"pos_cm": str(e.get("pos_cm", "")), "entity_empty": e.is_empty(),
"main_attempts": _main_attempts, "main_model_attempted": _main_model_attempted,
"main_sync_ready": _main_sync_ready, "main_loading": _main_loading,
"main_view_key": _main_view_key, "main_map_vid": _main_map_vid,
"main_retry_in_ms": _main_retry_at - Time.get_ticks_msec(),
"t_ms": Time.get_ticks_msec(),
"classes": {"Metin2Model": ClassDB.class_exists("Metin2Model"),
"Metin2World": ClassDB.class_exists("Metin2World")},
"world_null": world == null,
"map_report": world.call("get_load_report") if world != null else {},
"player_pos": str(player.position) if player != null else "",
"map_base_set": MapCoord.has_base(),
"map_base_cm": str(MapCoord.to_server_cm(Vector3.ZERO)),
}
printerr("GAMESCENE_DIAG: ", JSON.stringify(d))
# MT_DIAG_SHOT=<png>:最后一次 tick 存一张实机帧,用于人工核对渲染结果。
var shot := OS.get_environment("MT_DIAG_SHOT")
if not shot.is_empty() and _diag_done:
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
if img != null:
img.save_png(shot)
# 会徽上传图源:优先 res://ui/default_guild_mark.png,没有就现造一张 16×12 占位。
func _guild_mark_upload_image() -> Image:
var p := "res://ui/default_guild_mark.png"
+16 -1
View File
@@ -5,6 +5,10 @@ const GameScene = preload("res://game_scene.gd")
const Fixtures = preload("res://gamescene_test.gd")
var failed := false
# wait.gr2 @0.5s keeps the warrior planted; measured lowest vertex is +1.5 mm.
# 3 cm leaves room for terrain interpolation without hiding a real float.
const FEET_GAP_MAX := 0.03
func _initialize() -> void:
call_deferred("run")
@@ -39,13 +43,24 @@ func run() -> void:
if scene._model_built and scene.player.anim:
scene.player.anim.set_time(0.5)
scene.player.anim.set_process(false)
await process_frame # let the frozen pose reach cpu_skin before measuring
# R2-01 grounding: the lowest skinned vertex must sit on the terrain, not
# above it. Before the Offset4x4 fix the whole actor hovered one pelvis
# height (warrior 0.994 m) up; this is the numeric guard for that.
var feet_y := INF
for mi in scene.player.find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null: continue
feet_y = minf(feet_y, (mi.global_transform * mi.get_aabb()).position.y)
var feet_gap := feet_y - expected.y
failed = failed or not is_finite(feet_gap) or absf(feet_gap) > FEET_GAP_MAX
scene.cam.snap_to_target()
if DisplayServer.get_name() != "headless":
await RenderingServer.frame_post_draw
failed = failed or root.get_texture().get_image().save_png(output.path_join("game.png")) != OK
var report := {"passed": not failed, "map": scene.map_path, "server_cm": str(server),
"world_position": str(scene.player.position), "expected": str(expected),
"surfaces": surfaces, "map_report": scene.world.get_load_report(),
"surfaces": surfaces, "feet_y": feet_y, "feet_gap": feet_gap,
"feet_gap_max": FEET_GAP_MAX, "map_report": scene.world.get_load_report(),
"engine": Engine.get_version_info(), "viewport": str(root.size),
"camera_transform": str(scene.cam.global_transform), "fov": scene.cam.fov,
"assets": AssetRoot.path(), "headless": DisplayServer.get_name() == "headless",
+1
View File
@@ -0,0 +1 @@
uid://bii82ax05bwuj
+1
View File
@@ -0,0 +1 @@
uid://dp333tqyl0dcg
+423
View File
@@ -0,0 +1,423 @@
# model_render_test —— 不启动游戏、不连服务器的模型渲染自动回归。
#
# 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 # 重建基线
#
# 与 char_bench_test.gd 的区别:bench 只产图、判定靠人眼;这里对渲染结果算数值
# 指标并断言,退出码即结论。覆盖范围仅限「离线装配的单角色」——没有地图、没有
# 光照环境的真实动态、没有 40250 像素比对,因此它不能替代 gamescene_visual_test
# 和导出包验收,只用来挡住模型/发型/材质改动引入的回归。
#
# 指标(每个 race):
# feet_gap_m 最低蒙皮顶点到 y=0 的距离(悬空/陷地)
# silhouette_px 正面剪影像素数(模型是否真的画出来了)
# hair_mean_lum 只画发型时,发型像素的平均亮度(多余高光会把它抬上去)
# hair_mean_lum_ctrl 同上,但换回旧的 StandardMaterial3D(roughness=1) —— 负对照,
# 用来证明这个指标现在仍然分得开好坏,而不是恒过
# head_cover full 与 no_hair 在近景机位的差异像素占比(发型遮挡量)
#
# 绝对断言不依赖基线;head_cover / silhouette_px 这类因模型而异的量对比
# test/rendering/model_baseline.json。改模型或改光照后用 --bless 重建基线,
# 并在计划书执行记录里写清为什么重建。
extends SceneTree
const PlayerView = preload("res://ui/player_view.gd")
const BASELINE := "res://../test/rendering/model_baseline.json"
# 渲染尺寸:指标在 GDScript 里逐像素统计,512 是精度和耗时的折中。
const SIZE := 512
const BG := Color(1, 0, 1) # 洋红背景,模型不会出现这个颜色
# --- 绝对阈值(不依赖基线)------------------------------------------------
# 脚底:bench 无地形,模型自带 ground_offset,实测四职业 |gap| <= 0.003 m。
const FEET_GAP_MAX := 0.03
# 剪影:正面 512x512 里角色至少占这么多像素,防止「什么都没画」也算过。
const SILHOUETTE_MIN := 3000
# 发型高光用「发型像素的平均亮度」度量。发色本身明暗差别很大(武士近黑、
# 修罗银白),所以没有跨职业通用的绝对上限:绝对判定只用下面的负对照差,
# 具体数值按职业记进基线。旧材质相对新材质的实测抬升是 +0.011 ~ +0.037
# (八个职业,见 test/rendering/README.md)。
const LUM_BRIGHT := 0.20 # hair_lum_p99 只作参考量,不参与判定
# 负对照必须比生产材质亮这么多,否则说明这条指标已经不分好坏了(例如有人把
# 灯光调暗,两边都测不出高光)。八职业最小实测差 0.0109。
const HAIR_CTRL_MARGIN := 0.008
# --- 基线容差 -------------------------------------------------------------
const TOL_HEAD_COVER := 0.03 # 绝对
const TOL_SILHOUETTE := 0.03 # 相对
const TOL_HAIR_LUM := 0.006 # 绝对;最小的「旧材质」信号是 0.0109
var _out := ""
var _bless := false
var _fail: Array[String] = []
func _initialize() -> void:
call_deferred("run")
func _arg(name: String, dflt := "") -> String:
var args := OS.get_cmdline_user_args()
var i := args.find("--" + name)
return args[i + 1] if i >= 0 and i + 1 < args.size() else dflt
func run() -> void:
root.size = Vector2i(SIZE, SIZE)
_bless = "--bless" in OS.get_cmdline_user_args()
_out = OS.get_environment("MT_RENDER_OUTPUT")
if _out.is_empty():
_out = ProjectSettings.globalize_path("res://../build/rendering/modeltest-%d"
% Time.get_unix_time_from_system())
DirAccess.make_dir_recursive_absolute(_out)
var races: Array = []
var only := _arg("race")
if only.is_empty():
races = [0, 1, 2, 3, 4, 5, 6, 7]
else:
races = [int(only)]
var baseline := _load_baseline()
var results := {}
_light()
var cam := Camera3D.new()
cam.near = 0.02
root.add_child(cam)
cam.make_current()
for race in races:
var m := await _measure(int(race), cam)
if m.is_empty():
_fail.append("race %d: build failed" % race)
continue
results[str(race)] = m
_check_absolute(int(race), m)
if not _bless:
_check_baseline(int(race), m, baseline)
var report := {
"passed": _fail.is_empty(),
"failures": _fail,
"blessed": _bless,
"races": results,
"thresholds": {
"feet_gap_max": FEET_GAP_MAX, "silhouette_min": SILHOUETTE_MIN,
"hair_ctrl_margin": HAIR_CTRL_MARGIN, "tol_hair_lum": TOL_HAIR_LUM,
"tol_head_cover": TOL_HEAD_COVER, "tol_silhouette": TOL_SILHOUETTE,
},
"engine": Engine.get_version_info(),
"viewport": str(root.size),
"assets": AssetRoot.path(),
"headless": DisplayServer.get_name() == "headless",
"scope": "offline single-actor assembly; no map, no network, no 40250 pixel parity",
"sha256": {"res://bin/libmtgodot.macos.template_debug.dylib":
FileAccess.get_sha256("res://bin/libmtgodot.macos.template_debug.dylib"),
"res://model_render_test.gd": FileAccess.get_sha256("res://model_render_test.gd")},
}
var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE)
f.store_string(JSON.stringify(report, "\t"))
f.close()
if _bless:
_write_baseline(results)
print("MODELRENDER: baseline written -> ", ProjectSettings.globalize_path(BASELINE))
print("MODELRENDER: ", _out)
print(JSON.stringify(report))
for msg in _fail:
printerr("MODELRENDER FAIL: ", msg)
print("MODELRENDER: ", "PASS" if _fail.is_empty() else "FAIL")
quit(0 if _fail.is_empty() else 1)
# --- 单个 race 的测量 -----------------------------------------------------
func _measure(race: int, cam: Camera3D) -> Dictionary:
var pv := PlayerView.new()
root.add_child(pv)
if not pv.build(AssetRoot.path(), race):
pv.queue_free()
return {}
pv.set_anim_state("wait")
await process_frame
await process_frame
if pv.anim:
pv.anim.call("set_time", 0.5)
pv.anim.set_process(false)
pv.rotation = Vector3.ZERO
await process_frame
var body: MeshInstance3D = null
for mi in pv.find_children("*", "MeshInstance3D", true, false):
if mi.name == "MeshInstance3D":
body = mi
if body == null or body.mesh == null:
pv.queue_free()
return {}
var total := body.mesh.get_surface_count()
var base_mats: Array = []
for s in total:
base_mats.append(body.get_surface_override_material(s))
var hair_first := _hair_surface_first(pv, body, total)
# 脚底:最低蒙皮顶点(bench 里地面就是 y=0)
var feet_y := INF
for mi in pv.find_children("*", "MeshInstance3D", true, false):
if mi.mesh:
feet_y = minf(feet_y, (mi.global_transform * mi.get_aabb()).position.y)
var m := {
"body_gr2": String(pv.model.get("gr2_path")),
"hair_gr2": String(pv.model.get("hair_gr2")),
"surfaces": total, "hair_surface_first": hair_first,
"feet_gap_m": feet_y if is_finite(feet_y) else NAN,
}
# 1) 正面剪影
_aim(cam, Vector3(0, 1.0, 3.1), Vector3(0, 0.9, 0), 40.0)
var front := await _grab("race%d-front" % race)
m["silhouette_px"] = _count_fg(front)
# 2) 近景:full / no_hair 的差异 = 发型遮挡量
_aim(cam, Vector3(0.75, 1.68, 0.05), Vector3(0, 1.62, 0), 32.0)
var full := await _grab("race%d-face_side" % race)
if hair_first >= 0:
for s in range(hair_first, total):
body.set_surface_override_material(s, _hidden())
var nohair := await _grab("race%d-face_side-no_hair" % race)
_restore(body, base_mats)
var fg := _count_fg(nohair)
m["head_cover"] = (float(_count_diff(full, nohair)) / float(maxi(fg, 1))) if hair_first >= 0 else 0.0
m["head_px"] = fg
# 3) 发型亮度:只画发型,量 99 分位亮度;再用旧材质做负对照
if hair_first >= 0:
for s in range(0, hair_first):
body.set_surface_override_material(s, _hidden())
var hair_only := await _grab("race%d-hair_only" % race)
var hs := _lum_stats(hair_only)
m["hair_lum_p99"] = hs["p99"]
m["hair_bright_frac"] = hs["bright_frac"]
m["hair_mean_lum"] = hs["mean"]
var legacy := _legacy_hair_material(base_mats[hair_first])
for s in range(hair_first, total):
body.set_surface_override_material(s, legacy)
var ctrl := await _grab("race%d-hair_only-legacy_ctrl" % race)
var cs := _lum_stats(ctrl)
m["hair_lum_ctrl"] = cs["p99"]
m["hair_bright_frac_ctrl"] = cs["bright_frac"]
m["hair_mean_lum_ctrl"] = cs["mean"]
_restore(body, base_mats)
else:
m["hair_lum_p99"] = 0.0
m["hair_lum_ctrl"] = 0.0
m["hair_bright_frac"] = 0.0
m["hair_bright_frac_ctrl"] = 0.0
m["hair_mean_lum"] = 0.0
m["hair_mean_lum_ctrl"] = 0.0
pv.queue_free()
await process_frame
return m
# --- 断言 -----------------------------------------------------------------
func _check_absolute(race: int, m: Dictionary) -> void:
var gap: float = m["feet_gap_m"]
if not is_finite(gap) or absf(gap) > FEET_GAP_MAX:
_fail.append("race %d: feet_gap_m=%s exceeds %.3f" % [race, gap, FEET_GAP_MAX])
if int(m["silhouette_px"]) < SILHOUETTE_MIN:
_fail.append("race %d: silhouette_px=%d below %d" % [race, int(m["silhouette_px"]), SILHOUETTE_MIN])
if int(m["hair_surface_first"]) < 0:
return
var margin: float = float(m["hair_mean_lum_ctrl"]) - float(m["hair_mean_lum"])
if margin < HAIR_CTRL_MARGIN:
_fail.append(("race %d: legacy control only %.4f above production; " % [race, margin])
+ "hair_mean_lum no longer discriminates")
func _check_baseline(race: int, m: Dictionary, baseline: Dictionary) -> void:
var key := str(race)
if not baseline.has(key):
_fail.append("race %d: no baseline entry (run with --bless after reviewing the render)" % race)
return
var b: Dictionary = baseline[key]
var cover_delta: float = absf(float(m["head_cover"]) - float(b.get("head_cover", 0.0)))
if cover_delta > TOL_HEAD_COVER:
_fail.append("race %d: head_cover %.4f vs baseline %.4f (delta %.4f > %.3f)"
% [race, float(m["head_cover"]), float(b.get("head_cover", 0.0)), cover_delta, TOL_HEAD_COVER])
var base_px: float = float(b.get("silhouette_px", 0.0))
if base_px > 0.0:
var rel: float = absf(float(m["silhouette_px"]) - base_px) / base_px
if rel > TOL_SILHOUETTE:
_fail.append("race %d: silhouette_px %d vs baseline %d (%.1f%% > %.1f%%)"
% [race, int(m["silhouette_px"]), int(base_px), rel * 100.0, TOL_SILHOUETTE * 100.0])
var lum_delta: float = absf(float(m["hair_mean_lum"]) - float(b.get("hair_mean_lum", 0.0)))
if lum_delta > TOL_HAIR_LUM:
_fail.append("race %d: hair_mean_lum %.4f vs baseline %.4f (delta %.4f > %.4f)"
% [race, float(m["hair_mean_lum"]), float(b.get("hair_mean_lum", 0.0)),
lum_delta, TOL_HAIR_LUM])
# --- 像素统计 -------------------------------------------------------------
# get_pixel 逐点调用在 512x512 上太慢,直接读 RGBA8 字节。
func _fg_mask(img: Image) -> PackedByteArray:
var d := img.get_data()
var n := img.get_width() * img.get_height()
var mask := PackedByteArray()
mask.resize(n)
for i in n:
var o := i * 4
# 背景是纯洋红;模型不会出现 R 高 / G 近 0 / B 高 的组合
mask[i] = 0 if (d[o] > 240 and d[o + 1] < 24 and d[o + 2] > 240) else 1
return mask
func _count_fg(img: Image) -> int:
var mask := _fg_mask(img)
var c := 0
for v in mask:
c += v
return c
# 「发型盖住了脑袋多少」:只数 no_hair 里属于人体的像素中被 full 改写的那些,
# 不含发型溢出到背景的部分(那属于轮廓,不是遮挡)。
func _count_diff(a: Image, b: Image) -> int:
var da := a.get_data()
var db := b.get_data()
var n := mini(da.size(), db.size()) / 4
var c := 0
for i in n:
var o := i * 4
var br := int(db[o]); var bg := int(db[o + 1]); var bb := int(db[o + 2])
if br > 240 and bg < 24 and bb > 240:
continue # no_hair 里是背景 —— 不算遮挡
if absi(int(da[o]) - br) > 12 or absi(int(da[o + 1]) - bg) > 12 \
or absi(int(da[o + 2]) - bb) > 12:
c += 1
return c
# 前景像素亮度统计(256 桶直方图)。返回 {p99, bright_frac, mean}。
# 判定用 mean:它对「整片发型被额外高光抬亮」最敏感,且不受发色基准影响
# (基准差异由按职业的基线吸收)。p99/bright_frac 只写进报告备查。
func _lum_stats(img: Image) -> Dictionary:
var d := img.get_data()
var n := img.get_width() * img.get_height()
var hist := PackedInt32Array()
hist.resize(256)
var total := 0
for i in n:
var o := i * 4
var r := int(d[o]); var g := int(d[o + 1]); var bl := int(d[o + 2])
if r > 240 and g < 24 and bl > 240:
continue
var lum := int((0.2126 * float(r) + 0.7152 * float(g) + 0.0722 * float(bl)))
hist[clampi(lum, 0, 255)] += 1
total += 1
if total == 0:
return {"p99": 0.0, "bright_frac": 0.0, "mean": 0.0}
var cut := int(float(total) * 0.99)
var acc := 0
var p99 := 1.0
for v in 256:
acc += hist[v]
if acc >= cut:
p99 = float(v) / 255.0
break
var bright := 0
var sum := 0
for v in 256:
sum += hist[v] * v
if v >= int(LUM_BRIGHT * 255.0):
bright += hist[v]
return {"p99": p99, "bright_frac": float(bright) / float(total),
"mean": float(sum) / float(total) / 255.0}
# --- 场景辅助 -------------------------------------------------------------
func _aim(cam: Camera3D, eye: Vector3, at: Vector3, fov: float) -> void:
cam.global_transform = Transform3D(Basis(), eye).looking_at(at, Vector3.UP)
cam.fov = fov
func _grab(name: String) -> Image:
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var img := root.get_texture().get_image()
img.convert(Image.FORMAT_RGBA8)
img.save_png(_out.path_join(name + ".png"))
return img
func _light() -> void:
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-42, 155, 0)
sun.light_energy = 1.1
sun.set_layer_mask(0xFFFFF)
root.add_child(sun)
var env := WorldEnvironment.new()
var e := Environment.new()
e.background_mode = Environment.BG_COLOR
e.background_color = BG
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
e.ambient_light_color = Color(0.6, 0.62, 0.68)
e.ambient_light_energy = 0.8
env.environment = e
root.add_child(env)
func _hidden() -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.albedo_color = Color(0, 0, 0, 0)
m.no_depth_test = false
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
return m
func _restore(body: MeshInstance3D, base_mats: Array) -> void:
for s in base_mats.size():
body.set_surface_override_material(s, base_mats[s])
# 负对照:R2-02 修复前 _load_hair() 用的那一套。
func _legacy_hair_material(production: Material) -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.roughness = 1.0
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
m.alpha_scissor_threshold = 0.5
m.cull_mode = BaseMaterial3D.CULL_DISABLED
if production is ShaderMaterial:
var tex = (production as ShaderMaterial).get_shader_parameter("albedo_tex")
if tex != null:
m.albedo_texture = tex
return m
# 发型折在 body mesh 尾部;get_info() 报的 surfaces= 是身体自己的 surface 数。
func _hair_surface_first(pv: Node3D, body: MeshInstance3D, total: int) -> int:
if String(pv.model.get("hair_gr2")).is_empty():
return -1
var info := String(pv.model.call("get_info"))
var body_surfaces := -1
for token in info.split(" ", false):
if token.begins_with("surfaces="):
body_surfaces = int(token.substr(9))
if body_surfaces <= 0 or body_surfaces >= total:
return -1
return body_surfaces
# --- 基线读写 -------------------------------------------------------------
func _load_baseline() -> Dictionary:
if not FileAccess.file_exists(BASELINE):
return {}
var txt := FileAccess.get_file_as_string(BASELINE)
var parsed = JSON.parse_string(txt)
return parsed.get("races", {}) if parsed is Dictionary else {}
func _write_baseline(results: Dictionary) -> void:
var slim := {}
for k in results:
var m: Dictionary = results[k]
slim[k] = {"body_gr2": String(m["body_gr2"]).get_file(),
"hair_gr2": String(m["hair_gr2"]).get_file(),
"head_cover": m["head_cover"], "silhouette_px": m["silhouette_px"],
"head_px": m["head_px"], "hair_mean_lum": m["hair_mean_lum"],
"hair_mean_lum_ctrl": m["hair_mean_lum_ctrl"],
"hair_lum_p99": m["hair_lum_p99"], "hair_bright_frac": m["hair_bright_frac"]}
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(BASELINE).get_base_dir())
var f := FileAccess.open(BASELINE, FileAccess.WRITE)
f.store_string(JSON.stringify({
"note": "godot --path project --script model_render_test.gd -- --bless",
"engine": Engine.get_version_info()["string"],
"races": slim}, "\t"))
f.close()
+1
View File
@@ -0,0 +1 @@
uid://fb0dr5ulbc3g
+101
View File
@@ -0,0 +1,101 @@
# net_trace —— 主角 vid 生命周期取证。
#
# 实机进游戏后画面全灰、主角是蓝胶囊:GAMESCENE_DIAG 显示地图正常装载,
# 但 main_vid=0、主角实体为空 —— 主角在 setup() 之后被从 EntityStore 里
# 摘掉了。C++ 侧能把 m_main_vid 清零的路只有三条(mut_despawn /
# m2dev GC_CHARACTER_DEL / reset_for_map_change),前两条会发
# entity_despawned 信号,第三条会清空 m_changes、悄悄归零。
# 这个节点每帧轮询 main_vid 并记录信号,于是「哪条路」直接可读:
# MAIN_LOST reason=despawn → GC_CHARACTER_DEL 命中了主角
# MAIN_LOST reason=silent → reset_for_map_changewarp)或别的静默清零
#
# 常驻但极廉价(一次 get_main_vid + 一个整数比较);只在事件发生时打印。
extends Node
const RING := 24
var _client: Node = null
var _main_vid := 0
var _t0 := 0
var _ring: Array[String] = []
var _verbose := false
func setup(client: Node) -> void:
_client = client
_t0 = Time.get_ticks_msec()
# 主角在进场那几秒会被服务器 DEL+ADD 若干次(参考端同样如此),逐条打太吵,
# 默认只报异常;MT_NETTRACE=1 才打全量。
_verbose = OS.get_environment("MT_NETTRACE") == "1"
for sig in ["entity_main_set", "entity_despawned", "entity_spawned",
"entered_game", "disconnected", "stage_changed", "phase_changed",
"world_reset", "warp"]:
if client.has_signal(sig):
client.connect(sig, Callable(self, "_on_" + sig))
func _ms() -> int:
return Time.get_ticks_msec() - _t0
func _note(line: String) -> void:
var s := "+%dms %s" % [_ms(), line]
_ring.append(s)
if _ring.size() > RING:
_ring.pop_front()
func _dump(head: String) -> void:
printerr("NETTRACE: ", head)
for s in _ring:
printerr("NETTRACE: ", s)
func _on_entity_main_set(vid: int) -> void:
_note("main_set vid=%d" % vid)
if _verbose:
printerr("NETTRACE: MAIN_SET vid=%d t=+%dms" % [vid, _ms()])
func _on_entity_spawned(entity: Dictionary) -> void:
var vid := int(entity.get("vid", 0))
if vid == _main_vid or bool(entity.get("is_main", false)):
_note("spawn vid=%d race=%s is_main=%s" % [vid,
str(entity.get("race", null)), str(entity.get("is_main", false))])
func _on_entity_despawned(vid: int) -> void:
_note("despawn vid=%d" % vid)
if vid == _main_vid:
_note("*** despawn hit MAIN vid=%d" % vid)
func _on_entered_game() -> void:
_note("entered_game")
func _on_disconnected(reason: String) -> void:
_note("disconnected " + reason)
printerr("NETTRACE: DISCONNECTED %s t=+%dms" % [reason, _ms()])
func _on_world_reset() -> void:
_note("world_reset (reset_for_map_change)")
func _on_warp(pos: Vector3, _reconnect: bool) -> void:
_note("warp " + str(pos))
func _on_stage_changed(stage: String) -> void:
_note("stage=" + stage)
func _on_phase_changed(phase: String) -> void:
_note("phase=" + phase)
func _process(_dt: float) -> void:
if _client == null or not _client.has_method("get_main_vid"):
return
var vid := int(_client.get_main_vid())
if vid == _main_vid:
return
if vid != 0 and _main_vid == 0:
_main_vid = vid
_note("main_vid 0 -> %d" % vid)
return
# 丢主角:把最近的事件环一起打出来,能读出是 despawn 还是静默清零。
var was := _main_vid
_main_vid = vid
var reason := "silent"
for s in _ring:
if s.ends_with("despawn hit MAIN vid=%d" % was):
reason = "despawn"
_dump("MAIN_LOST was=%d now=%d reason=%s t=+%dms" % [was, vid, reason, _ms()])
+1
View File
@@ -0,0 +1 @@
uid://bln1u7gitn4jw
+165
View File
@@ -0,0 +1,165 @@
# package_render_test —— 在**导出包内部**跑的渲染自检。
#
# MT_TEST_MODE=render build/export/mtgodot-poc.app/Contents/MacOS/mtgodot-poc
#
# 存在的理由:导出模板不接受 `--script`,所以 model_render_test.gd /
# gamescene_visual_test.gd 这类 `extends SceneTree` 的离线回归**只能在编辑器里跑**。
# 编辑器全绿而实机进游戏是「灰紫虚空 + 蓝胶囊」时,两边差异无从定位——包内没有
# 任何不依赖服务器的入口。本脚本就是那个入口:由 client_main.gd 在
# MT_TEST_MODE=render 时挂到主场景下,走真实 GameScene + 真实 Metin2World +
# 真实 PlayerView,不连服务器、不写账号,跑完把报告和截图落到 user:// 并退出。
#
# 覆盖范围:包内的资源解析、GDExtension 注册、地图装载、角色装配、落地高度。
# **不覆盖**:网络驱动的进场时序、40250 像素比对、性能。
extends Node
const GameScene = preload("res://game_scene.gd")
const Fixtures = preload("res://gamescene_test.gd")
const MAP := "OutdoorA1/metin2_map_a1"
# 与 gamescene_visual_test.gd 同一组坐标和阈值,两边结论才可比。
const SERVER_CM := Vector2(454600, 934000)
const FEET_GAP_MAX := 0.03
const SILHOUETTE_MIN := 3000
var _fail: Array[String] = []
var _out := ""
func _ready() -> void:
_out = OS.get_environment("MT_RENDER_OUTPUT")
if _out.is_empty():
_out = ProjectSettings.globalize_path("user://render-check")
DirAccess.make_dir_recursive_absolute(_out)
call_deferred("_run")
func _run() -> void:
var report := {"env": _env(), "scope":
"in-package offline check: real GameScene + Metin2World + PlayerView, no network"}
# 1) 扩展必须注册 —— 少了它 GameScene 会退回蓝胶囊 + 无地形灰底,
# 而这两件事在画面上和「资源没找到」长得一模一样,必须先分开。
for cls in ["Metin2Model", "Metin2World", "Metin2AnimPlayer"]:
if not ClassDB.class_exists(cls):
_fail.append("GDExtension 类未注册:%s" % cls)
# 2) 资源根必须落在包内(或 MT_ASSETS 指定处)且真的有地图和角色
var root: String = report["env"]["asset_root"]
for probe in [MAP, "PC/ymir work/pc/warrior", "asset_index.txt"]:
var p := root.path_join(probe)
if not (FileAccess.file_exists(p) or DirAccess.dir_exists_absolute(p)):
_fail.append("资源缺失:%s" % p)
if _fail.is_empty():
report["scene"] = await _build_and_measure()
else:
report["scene"] = {"skipped": "前置检查已失败,不再装场景"}
report["passed"] = _fail.is_empty()
report["failures"] = _fail
var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE)
f.store_string(JSON.stringify(report, "\t"))
f.close()
print("PKGRENDER: ", _out)
print(JSON.stringify(report))
for m in _fail:
printerr("PKGRENDER FAIL: ", m)
print("PKGRENDER: ", "PASS" if _fail.is_empty() else "FAIL")
get_tree().quit(0 if _fail.is_empty() else 1)
# 包内和开发树的差异几乎都藏在这几个值里,先无条件记下来。
func _env() -> Dictionary:
var cands := {}
for c in _asset_candidates():
cands[c] = DirAccess.dir_exists_absolute(c)
return {
"engine": Engine.get_version_info()["string"],
"executable": OS.get_executable_path(),
"res_globalized": ProjectSettings.globalize_path("res://"),
"user_dir": OS.get_user_data_dir(),
"features": {"template": OS.has_feature("template"), "debug": OS.has_feature("debug"),
"release": OS.has_feature("release"), "editor": OS.has_feature("editor")},
"MT_ASSETS": OS.get_environment("MT_ASSETS"),
"MT_PROTOCOL": OS.get_environment("MT_PROTOCOL"),
"asset_candidates": cands,
"asset_root": AssetRoot.path(),
"asset_root_available": AssetRoot.available(),
"classes": {"Metin2Model": ClassDB.class_exists("Metin2Model"),
"Metin2World": ClassDB.class_exists("Metin2World"),
"Metin2AnimPlayer": ClassDB.class_exists("Metin2AnimPlayer")},
"rendering_driver": DisplayServer.get_name(),
"viewport": str(get_window().size),
}
# AssetRoot._candidates() 是私有的;这里复述一遍只为把每个候选的命中情况写进报告。
func _asset_candidates() -> Array:
var out: Array = []
var env := OS.get_environment("MT_ASSETS")
if env != "":
out.append(env)
if FileAccess.file_exists("res://assets/asset_index.txt"):
out.append("res://assets")
out.append(ProjectSettings.globalize_path("res://../assets"))
var exe := OS.get_executable_path()
if exe != "":
var d := exe.get_base_dir()
out.append(d.path_join("../Resources/assets").simplify_path())
out.append(d.path_join("../../../assets").simplify_path())
out.append(d.path_join("assets"))
return out
func _build_and_measure() -> Dictionary:
var fc := Fixtures.FakeClient.new()
add_child(fc)
var scene := GameScene.new()
add_child(scene)
await scene.setup(fc, AssetRoot.path(), MAP)
fc.main = 1000
fc.ents[1000] = {"vid": 1000, "name": "PkgRenderTest", "race": 0,
"pos": Vector3(SERVER_CM.x * 0.01, 0, -SERVER_CM.y * 0.01),
"pos_cm": SERVER_CM, "is_main": true, "angle_deg": 0.0,
"func": 0, "moving": false, "hp": 100, "max_hp": 100, "dead": false}
for i in 30:
await get_tree().process_frame
var expected := MapCoord.to_world(fc.ents[1000].pos)
expected.y = scene.world.sample_height(expected.x, expected.z) if scene.world else 0.0
var surfaces := 0
for mi in scene.player.find_children("*", "MeshInstance3D", true, false):
if mi.mesh:
surfaces += mi.mesh.get_surface_count()
if scene._model_built and scene.player.anim:
scene.player.anim.set_time(0.5)
scene.player.anim.set_process(false)
await get_tree().process_frame
var feet_y := INF
for mi in scene.player.find_children("*", "MeshInstance3D", true, false):
if mi.mesh:
feet_y = minf(feet_y, (mi.global_transform * mi.get_aabb()).position.y)
var feet_gap := feet_y - expected.y
if not scene._model_built:
_fail.append("主角模型没装出来(仍是 _make_placeholder_player 的蓝胶囊)")
if not scene._map_loaded():
_fail.append("地图没装出来(灰底虚空)")
if surfaces == 0:
_fail.append("玩家节点下 0 个 surface")
if not is_finite(feet_gap) or absf(feet_gap) > FEET_GAP_MAX:
_fail.append("feet_gap=%s 超出 %.3f" % [feet_gap, FEET_GAP_MAX])
var shot := "SKIPPED_HEADLESS"
if DisplayServer.get_name() != "headless":
scene.cam.snap_to_target()
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var img := get_window().get_texture().get_image()
shot = "game.png" if img.save_png(_out.path_join("game.png")) == OK else "SAVE_FAILED"
var d := {"map": scene.map_path, "model_built": scene._model_built,
"map_loaded": scene._map_loaded(), "surfaces": surfaces,
"world_position": str(scene.player.position), "expected": str(expected),
"feet_y": feet_y, "feet_gap": feet_gap, "feet_gap_max": FEET_GAP_MAX,
"map_report": scene.world.get_load_report() if scene.world else {},
"camera_transform": str(scene.cam.global_transform), "screenshot": shot}
scene.queue_free()
fc.queue_free()
await get_tree().process_frame
return d
+1
View File
@@ -0,0 +1 @@
uid://rm3i2avq75g
+48
View File
@@ -15,9 +15,57 @@ godot --path project --script gamescene_visual_test.gd -- --c1
- `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` 验收。
+94
View File
@@ -0,0 +1,94 @@
{
"engine": "4.7.1-stable (official)",
"note": "godot --path project --script model_render_test.gd -- --bless",
"races": {
"0": {
"body_gr2": "warrior_novice.gr2",
"hair_bright_frac": 0.0224071972085593,
"hair_gr2": "hair_1_1.gr2",
"hair_lum_p99": 0.254901960784314,
"hair_mean_lum": 0.0607960189162477,
"hair_mean_lum_ctrl": 0.097848223242717,
"head_cover": 0.324674209407673,
"head_px": 46579,
"silhouette_px": 35073
},
"1": {
"body_gr2": "assassin_novice.gr2",
"hair_bright_frac": 0.316447908121411,
"hair_gr2": "hair_1_1.gr2",
"hair_lum_p99": 0.349019607843137,
"hair_mean_lum": 0.184415208398222,
"hair_mean_lum_ctrl": 0.195338585878083,
"head_cover": 0.244265393733581,
"head_px": 62428,
"silhouette_px": 36052
},
"2": {
"body_gr2": "sura_novice.gr2",
"hair_bright_frac": 0.834898912836162,
"hair_gr2": "hair_1_1.gr2",
"hair_lum_p99": 0.435294117647059,
"hair_mean_lum": 0.260437632997124,
"hair_mean_lum_ctrl": 0.281076636261981,
"head_cover": 0.0281083316817606,
"head_px": 116051,
"silhouette_px": 39682
},
"3": {
"body_gr2": "shaman_novice.gr2",
"hair_bright_frac": 0.122538024274082,
"hair_gr2": "hair_1_1.gr2",
"hair_lum_p99": 0.32156862745098,
"hair_mean_lum": 0.134457749300365,
"hair_mean_lum_ctrl": 0.166435011552631,
"head_cover": 0.295606972434941,
"head_px": 60983,
"silhouette_px": 36257
},
"4": {
"body_gr2": "warrior_novice.gr2",
"hair_bright_frac": 0.0294142102856193,
"hair_gr2": "hair_1_1.gr2",
"hair_lum_p99": 0.23921568627451,
"hair_mean_lum": 0.0852886966119754,
"hair_mean_lum_ctrl": 0.107547303626061,
"head_cover": 0.190623521275434,
"head_px": 27473,
"silhouette_px": 27104
},
"5": {
"body_gr2": "assassin_novice.gr2",
"hair_bright_frac": 0.0384454367848172,
"hair_gr2": "hair_1_1.gr2",
"hair_lum_p99": 0.270588235294118,
"hair_mean_lum": 0.0734998604521351,
"hair_mean_lum_ctrl": 0.0947519741918537,
"head_cover": 0.25690174015439,
"head_px": 30572,
"silhouette_px": 24776
},
"6": {
"body_gr2": "sura_novice.gr2",
"hair_bright_frac": 0.905465795806119,
"hair_gr2": "hair_1_1.gr2",
"hair_lum_p99": 0.427450980392157,
"hair_mean_lum": 0.277393012894398,
"hair_mean_lum_ctrl": 0.298905357949299,
"head_cover": 0.617005584237699,
"head_px": 37964,
"silhouette_px": 30280
},
"7": {
"body_gr2": "shaman_novice.gr2",
"hair_bright_frac": 0.0142828216749996,
"hair_gr2": "hair_1_1.gr2",
"hair_lum_p99": 0.219607843137255,
"hair_mean_lum": 0.0624432979584058,
"hair_mean_lum_ctrl": 0.0774457732565792,
"head_cover": 0.715665995318709,
"head_px": 36742,
"silhouette_px": 21953
}
}
}