diff --git a/README.md b/README.md index f92d3d4f..6008b066 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ [`docs/SHINSOO-WORLD-RENDERING.md`](docs/SHINSOO-WORLD-RENDERING.md)(正式移植,Phase 2 bring-up 之后); 距参考图「目视等价」还差哪些保真化工作见 [`docs/PARITY-GAP.md`](docs/PARITY-GAP.md)。 +当前进入游戏、模型/发型、贴地与技能栏问题的分阶段修复步骤,以及结合两个 Godot 项目的 +对照验证和 macOS 包验收要求,见 [`docs/RENDERING-REPAIR-PLAN.md`](docs/RENDERING-REPAIR-PLAN.md) +(2026-09-08,实施计划;不表示其中工作已完成)。 + --- ## 现状 diff --git a/docs/RENDERING-REPAIR-PLAN.md b/docs/RENDERING-REPAIR-PLAN.md new file mode 100644 index 00000000..f90b80e1 --- /dev/null +++ b/docs/RENDERING-REPAIR-PLAN.md @@ -0,0 +1,265 @@ +# 新客户端渲染修复实施路线 + +更新日期:2026-09-08。目标客户端:本仓库 `metin2-client`,第一交付平台为 macOS。 + +本文是实施规格,2026-09-08 已开始第一批 R0/R1 修改;阶段结果见文末执行记录。未标明通过的工作仍待执行,不代表整体渲染或最终包完成。 + +## 1. 目标、依据与当前状态 + +第一交付目标:实际 macOS 客户端进入游戏后显示正确地图和真实人物,身体/发型/装备组合正常,人物在地面上,底部技能栏可用。之后再精细还原 40250 的材质、环境、树木、特效和性能。 + +画面真值优先使用用户当前 40250 Windows 客户端的同资源、同场景截图。EnriqueGF 的场景截图是可行性和场景完整性参考,不能直接当作 40250 像素真值;其主角背对镜头,也不能证明正面发型和所有动画正确。原始骨骼数值以固定版本 Granny oracle 的可复现实测为参照。 + +| 当前问题/证据 | 状态 | 实施要求 | +| --- | --- | --- | +| 用户反馈进入游戏出现胶囊、背景异常 | 待真实客户端复验 | 检查地图、实体、相机与加载时序,不直接归因于模型格式 | +| `game_scene.gd` 已加入 `_sync_main_character()` 和 `_main_model_attempted` | 工作区有补丁,尚未完成最终画面验收 | 检查晚到实体、失败重试、角色切换和重连的生命周期 | +| 本地 `world_demo.gd` 曾加载真实 A1 地图及模型 | 历史组件证据 | 只能证明这组资源链路可运行,不能证明联机 `.app` 正常 | +| 发型挡脸、人物悬空 | 用户反馈,根因仍需隔离 | 分别验证变换、蒙皮、深度、地形高度与动画接触点 | +| 底部技能未显示 | 用户反馈,已有 `quickbar.gd` | 检查创建、布局、图标、服务端槽位恢复,不重新发明快捷栏 | +| 日志曾出现 `unknown GC header 59 (last: 32,148)` | 历史协议阻塞,是否仍存在待复验 | 若重现,单列协议问题;不得跳过未知包继续解析来伪造联机通过 | +| 选角 `flip_winding`、材质及视觉测试已有未提交修改 | 实验状态 | 保留现有改动,记录基线,再逐项验证,不全局翻转或覆盖 | + +历史 README / MIDREVIEW 的阶段完成描述不代表当前最终包通过。现有截图、日志在使用前检查是否存在、属于哪个构建和资源版本。 + +## 2. 两个上游项目的使用边界 + +本路线依据已经检查的固定提交;上游后续变化需要重新审查: + +- EnriqueGF/metin2-godot:`078d39a41a3ef17c9e09fb9807956cb1a2f16641`。 +- kcanakdag/metin2-godotime:`6a82d793c1d83e34f3e71200fdfc67a78bcb96d3`。 + +| 参考实现 | 本项目的应用方式 | 不应推导的结论 | +| --- | --- | --- | +| EnriqueGF:`tools/compare_visual_golden.py` | 引入区域、遮罩、误差阈值和差异图方法 | 截图存在或整体平均误差低不代表脸部正确 | +| EnriqueGF:`tools/build_character_glb.py`、`godot/client/online/presentation/warrior_actor.gd` | 同一角色离线 GLB 对照;核对身体/发型/武器的组合和动作同步 | 转换包含 TRS 分解、舍弃 shear;不能假定动画无损 | +| EnriqueGF:`godot/client/online/presentation/avatar_registry.gd` | 借鉴有效实体数据驱动创建、切地图清理、重复数据处理 | 其快照协议不能直接替代我们的原版协议事件 | +| EnriqueGF:`tools/prepare_legacy_ui_assets.py`、`godot/client/online/ui/` | 核对 `.sub` 裁剪、原版布局和按钮状态 | 西班牙语素材、字体、窗口尺寸不能直接替代当前中文配置 | +| EnriqueGF:`tools/audit_city_asset_dependencies.py` | 地图资源依赖追踪、缺失分类和报告 | 全部依赖可解析不等于全部已经正确渲染 | +| godotime:`tools/export_playable.py` | 实际包资源清单、哈希与加载审计,适配 macOS | Web/Linux 通过不代表 macOS 通过 | +| godotime:`content/profiles/p0-warrior-dog.json`、`client/scripts/content/` | 模型、动作、附件和依赖的统一验证清单 | 三个精选模型通过不代表四职业和全量换装通过 | +| godotime:`tools/test_browser_accounts.py` | 借鉴双客户端、重连、换装及账户生命周期用例 | 不迁移其 SpacetimeDB 服务端来修复渲染 | + +参考源码: + +- [EnriqueGF 固定版本](https://github.com/EnriqueGF/metin2-godot/tree/078d39a41a3ef17c9e09fb9807956cb1a2f16641) +- [EnriqueGF 场景截图](https://github.com/EnriqueGF/metin2-godot/blob/078d39a41a3ef17c9e09fb9807956cb1a2f16641/docs/images/ingame-shinsoo.png) +- [godotime 固定版本](https://github.com/kcanakdag/metin2-godotime/tree/6a82d793c1d83e34f3e71200fdfc67a78bcb96d3) + +直接移植 EnriqueGF 原创代码时保留 MIT 声明和提交来源。godotime 在本次检查的提交没有项目级许可证,先借鉴方法;直接复制实现前明确授权。原始游戏资源的来源与使用范围沿用本仓库 [THIRD-PARTY.md](THIRD-PARTY.md),不把上游许可证视为原版资产授权。 + +## 3. 技术路线与顺序 + +执行顺序:R0 基线 → R1 场景装配 → R2 模型/发型 → R3 材质/环境 → R4 UI → R5 最终包验收 → R6 性能与内容扩展。R4 可在 R1 后独立推进;R5 的资源清单工具可提前建设,最终验收必须等待 R1–R4 完成。 + +1. 保留原版网络协议与当前 GR2 主链路,以最小改动修复实际缺陷。 +2. 引入独立 GLB 对照场景,不先全量转换资源或更换运行时。 +3. 对照决定具体资源采用哪条路径;结果需要记录,不能预设 GR2 或 GLB 必然更好。 +4. 角色与静态物件分别决策。静态物件可先试离线 Godot 资源缓存;人物保留完整仿射矩阵的需求单独评估。 +5. 每项修复必须在真实 GameScene 和导出包中复验。单模型预览只能作为中间证据。 + +## 4. R0:建立可重复基线 + +涉及现有文件:`test/compare.py`、`project/char_select_visual_test.gd`、`project/world_demo.gd`、`project/game_camera.gd`。 + +### 具体步骤 + +1. 记录当前 Git 提交、未提交补丁摘要、Godot/导出模板版本、扩展库哈希、macOS/GPU、渲染后端、窗口和实际渲染分辨率。不能仅凭提交号标识脏工作区构建。 +2. 固定资产来源及哈希:身体、发型、武器、护甲、MSM/MSA、动画 GR2、纹理、地图和环境。两个上游与我们可能使用不同资源版本,必须先核对。 +3. 为每个场景保存地图名、服务器坐标、地图基点、转换后本地坐标、相机位置/目标/FOV、角色朝向、装备、动作及取样时间。 +4. 建立三种独立场景:无网络的单角色实验台;真实资产的 GameScene + FakeClient;实际服务器上的导出 `.app`。 +5. 保存正面、侧面、背面和脚底近景,以及选角界面和游戏全景。待机/跑步/攻击每段至少取开始、中间、结束,过渡动作另测。 +6. 在现有截图工具基础上增加局部区域和差异图。脸、头发、脚底、技能栏不允许被动态遮罩排除;时间文字、随机粒子等可单列遮罩。 +7. 区分两类比较:同引擎修复前后用于自动回归;40250 对照用于保真评估。没有匹配的相机和动作时,只能做人工结构检查,不执行严格像素判定。 + +拟新增产物(尚未实现):`test/rendering/cases.json`、`test/rendering/README.md`、`tools/rendering/compare_regions.py`。本地日志/截图/报告建议放 `build/rendering//`,不覆盖失败证据。 + +阈值:先在不改代码的条件下重复采样三次,测量阴影/抗锯齿噪声,再设置每区域阈值。报告包含 MAE、RMSE、变化像素比例和遮罩范围;不能为了让错误图片通过而放宽阈值。 + +完成条件:每条用例可由配置重复启动,并能将截图关联到确定的代码、资源、相机和构建;缺参考图的用例标为待验证。 + +## 5. R1:修复主角装配、地图定位与相机 + +涉及现有文件:`project/game_scene.gd`、`project/net_world.gd`、`project/net/mapcoord.gd`、`project/ui/player_view.gd`、`project/game_camera.gd`。必要时追踪 `extension/src/net/m2_client.cpp` 的事件顺序及 `extension/src/metin2_world.cpp` 的地图数据。 + +### 具体步骤 + +1. 增加定点诊断:阶段进入、地图就绪、主角 VID 获得、实体行有效、模型创建、贴地定位、相机开始跟随。每条带角色/场景生命周期编号,避免把上一次登录数据误认为当前数据。 +2. 复核现有 `_sync_main_character()`:明确“等待数据、加载中、成功、失败”的状态;缺 race 等必要数据时保持等待,重复事件不创建重复人物。 +3. 检查 `_main_model_attempted` 是否在失败后永久阻止恢复。数据或资源就绪条件变化时允许受控重试;永久资源错误显示明确失败原因,不能每帧重复加载或刷日志。 +4. 切角色、地图切换、重连时清理旧模型/相机目标/信号并重置对应状态。异步完成结果必须核对是否仍属于当前角色与场景。 +5. 统一通过 MapCoord 转换:服务器全局厘米 → 地图本地米。现有地图本地 Z 约定与上游不同,禁止直接复制对方轴翻转或地图中心公式。 +6. 验证坐标往返、合法边界及高度查询位置。`(0,0)` 可是合法地图基点,地图是否就绪应有明确状态,不能仅靠非零基点推断。 +7. 在地图可查询、角色位置有效后计算地面高度并绑定相机;模型就绪后移除胶囊。资源等待期间提供加载状态,不能把永久占位当正常游戏画面。 + +### 必须补充的回归 + +- 主角 VID 在 setup 前已有、setup 后出现、实体行再晚一帧、多帧后出现。 +- 不发 `entity_main_set`,只更新实体存储,最终仍创建主角。 +- 重复主角事件只产生一个模型;缺 race 时不使用错误职业创建人物。 +- 加载失败有可见原因;条件恢复后可受控重试;旧加载不会覆盖新角色。 +- A1/C1 各一个合法出生点;基点为零的合成坐标用例;地图外坐标被明确诊断。 +- 切角色、重连、切地图后相机跟随当前人物,旧节点与旧状态不残留。 + +测试落点:扩展 `project/gamescene_test.gd`、`project/app_flow_lifecycle_test.gd`;新增真实资源的 GameScene 视觉用例。FakeClient 的字段/信号需匹配实际接口。 + +完成条件:自动回归通过;真实 GameScene 截图同时出现正确地表与真实主角。协议若中断,保留本地通过结果,但将实际联机验收标为未通过。 + +## 6. R2:修复身体、发型、武器与贴地 + +涉及现有文件:`extension/src/metin2_model.cpp`、`extension/src/metin2_anim.cpp`、`extension/src/gr2_bridge.cpp`、`project/ui/player_view.gd`、`project/ui/equip_model.gd`、`project/ui/race_spec.gd`、`project/ui/char_select_screen.gd`。 + +### 同资源对照与定位顺序 + +1. 先选用户出错角色的确切身体/发型/装备;再建立男战士最小对照。GLB 转换必须使用同一组源文件,记录转换器版本与参数。生成文件留在隔离输出目录。 +2. 在统一实验台逐步加载:身体静态绑定姿势 → 身体动画 → 加发型 → 加武器 → 换护甲。每一步保存矩阵、包围盒、贴图绑定和截图。 +3. 检查模型初始 placement、单位缩放、轴转换、父子节点变换是否重复应用。分别核查三角形绕序和负缩放,不能以全局 `flip_winding` 代替定位。 +4. 对身体和发型按骨名核对 mesh binding、骨架索引、inverse bind、当前 world/skin 矩阵。验证发型是否错误重复乘身体变换,是否使用了另一套不兼容的绑定姿势。 +5. 检查附件共享的动画时间与过渡状态。身体切动作/换装时,发型不能停留在旧姿势。武器验证手部附件空间,避免在 model-local 与 world-space 之间重复转换。 +6. 先用不受光照影响的诊断材质、线框或深度视图判断几何是否错误;几何正确而发型仍盖脸时进入 R3 检查深度和混合。 +7. 数值正确后比较 GR2 CPU、GR2 GPU 与 GLB 的同时间画面。对关键骨骼矩阵、选定顶点和完整动画周期采样,保留已有 oracle 精度要求,不只检查 NaN。 + +### 贴地专项 + +1. 分开记录实体基准高度、地形采样高度、模型局部原点和脚部接触点。 +2. 平地待机时用足底接触点判断高度;行走/攻击存在抬脚,不能用动画每帧 AABB 最低点强行贴地,否则人物会抖动。 +3. 测试坡地、台阶或可行走建筑表面,以及地图边界。确认所采样的是人物实际站立表面。 +4. 阴影检查与几何检查分开:阴影偏差可造成悬空观感,但不能用加深阴影掩盖高度错误。 +5. 建议初始平地接触误差上限为 2 cm;这是待以资源与 40250 校准的项目目标,不是原版已证实精度。腾空动作不使用该判据。 + +### 路线决策 + +| 实测结果 | 后续动作 | +| --- | --- | +| GR2 数值正确,仅材质/装配错误 | 修复现有渲染与组合,不迁移动画 | +| GLB 在选定资源上可靠,GR2 缺陷短期难解决 | 为该资源提供可回退的 GLB 适配,保持相同的角色表现接口 | +| GLB 丢 shear 或过渡不一致 | 不将该角色纳入迁移范围;保留失败样本 | +| 静态 GLB/原生资源缓存画面一致且加载更快 | 单独推进静态物件缓存,与角色动画路线解耦 | + +GLB 扩大范围前必须覆盖四职业、实际使用的性别/护甲/发型变体,以及待机、走、跑、攻击、受击、死亡和选角动作。缺少资产时明确未覆盖,不以一个战士替代全体结论。 + +完成条件:出错角色的正面/侧面/背面及关键动作通过;换装、重新选角后仍正确;记录 GR2/GLB 选择依据。清理临时强制绕序、调试打印和 `/tmp` 截图导出时只删除本轮明确归属的诊断代码。 + +## 7. R3:材质、地形与环境校准 + +涉及现有文件:`extension/src/m2_material.cpp`、`extension/src/static_object.cpp`、`extension/src/terrain_splat.cpp`、`extension/src/environment_builder.cpp`、`extension/src/metin2_world.cpp`、`project/ui/char_shadow.gd`。 + +### 具体步骤 + +1. 逐 surface 输出源 mesh、tri-group、材质槽、最终纹理和渲染状态。确认 mesh-local 材质索引没有误当成全局索引,贴图回退有明确诊断。 +2. 身体、脸等不透明面验证深度写入;发丝按资产语义选择 alpha-test;真正半透明面单独混合。不要把所有头发强制成同一模式,也不要对所有表面关闭背面剔除。 +3. 同贴图下比较标准材质和自定义 shader,逐项打开光照、顶点色、透明、雾。检查 DDS 解码、颜色空间、alpha、mipmap 和双重 gamma。 +4. 身体、头发、武器分开检查投影与受影。正面、侧面、逆光及脸部重叠处验证深度;根据场景尺寸校准 shadow bias。 +5. 地形逐层验证 tile/alpha splat 对应、UV 比例、接缝和法线。固定至少一处道路/草地边界作为回归,防止纹理层顺序交换。 +6. 列出地图物件:应有、找到资源、解析成功、实例化、可见、失败原因。资源 CRC 可解析不等于模型已画出,树木占位必须单独计数。 +7. 基于原 `.msenv` 对照太阳方向、角色/背景光、环境光、雾距离与天空;逐项记录 Godot 近似规则。EnriqueGF 的固定 filmic/雾参数只作实验参照。 +8. 完成角色与场景单项校准后再组合全景,避免同时改曝光、贴图与相机导致无法归因。 + +完成条件:脸/身体不误透明,发丝边缘和遮挡正确;道路与草地层正确;主要建筑不缺材质;角色高度与阴影分别通过;每项与 40250 的剩余差异有说明。 + +## 8. R4:选角界面与技能栏 + +涉及现有文件:`project/ui/char_select_screen.gd`、`project/ui/ui_assets.gd`、`project/ui/quickbar.gd`、`project/game_scene.gd`。 + +### 具体步骤 + +1. 对照当前中文 40250 的 `uiscript`、`.sub`、字体及图集,记录选角背景、角色相机、按钮、任务栏、快捷槽的来源与尺寸。 +2. 检查 `.sub` 裁剪边界、大小写路径、透明通道与纹理加载失败;输出 UI 依赖清单。 +3. 从 GameScene 开始检查快捷栏是否创建、挂在正确 CanvasLayer、可见、位于窗口内;核对缩放、锚点、裁剪和其他窗口遮挡。 +4. 跟踪 `quickslots_changed` 与 `restore_from_server` 的调用时序:初始槽位、晚到数据、重新登录均应恢复。保留现有服务端 36 槽与本地 4 页 × 8 格映射。 +5. 将空槽、未学习技能、图标缺失、技能不可用、冷却中区分呈现。不得通过凭空填充技能图标让截图看似正常。 +6. 使用确实已学习并已配置的技能验证图标、拖放、翻页、数字键/F 键和冷却;UI 对比先用 FakeClient,真实施放在适用测试环境下执行。 +7. 验证背包/聊天打开关闭后输入焦点,避免空格攻击误触 UI 按钮。 +8. 按 1280×720、1920×1080 和实际 Retina 窗口验证逻辑尺寸/物理像素关系;中文字体宽度单独检查。 + +完成条件:选角画面达到已记录的参考布局;底部栏始终可见,槽位状态真实;切角色/重连能恢复;技能冷却和输入行为保持正确。 + +## 9. R5:最终 macOS 包与验收证据 + +涉及现有文件:`build-macos-client.sh`、`script/live_smoke_test.sh`、`project/live_smoke_test.gd`、`project/asset_pack.gd`、`project/bake_asset_index.gd`。 + +### 具体步骤 + +1. 扩展构建清单,记录代码版本及脏补丁摘要、构建参数、扩展/模板版本、资源及实际产物哈希。 +2. 对实际 `.app` 检查身体、发型、装备、动作、地图、UI 图标依赖是否存在且可加载。清单生成后仍要启动包验证,不能用工作区资源替代包内检查。 +3. 从不依赖开发者资源路径的目录启动最终包;验证资源解析不会偷偷回退到源码 checkout。用受控资源根测试覆盖此情况。 +4. 执行只读登录冒烟,再运行有画面和输入的 GameScene 验收。现有脚本不是完整视觉测试;需新增或扩展包内测试场景并保存截图。 +5. 移动、攻击、换装等会改变服务端状态的用例使用适用测试角色/服务器;不要把现有冒烟默认包含的聊天发送当作渲染验收必需动作。 +6. 两客户端用例验证角色互见、移动朝向、武器/护甲变化、断开移除及重连重建;同时检查本地与远端外观。 +7. 排除凭据、私有日志、源码资产归档和临时探针出现在普通交付包中。若通过带探针的包定位问题,移除探针后还要对最终普通包重新冒烟。 +8. 将通过记录绑定到交付 `.app` 的确切构建。此后任何影响画面的代码、资源、导出设置变化,都使相关验收失效,需要重测受影响范围。 + +### 现有命令与覆盖边界 + +以下命令在仓库根目录运行。执行前检查本机 Godot、扩展和资源配置;这些是已存在的入口,本文没有执行它们或宣称通过。 + +```bash +godot --headless --path project --script gamescene_test.gd +godot --headless --path project --script equip_model_test.gd +godot --headless --path project --script environment_test.gd +godot --path project --script char_select_visual_test.gd +./build-macos-client.sh release +./script/live_smoke_test.sh --no-build --no-mutations --no-reconnect +``` + +- Headless 测试仅证明其断言覆盖的逻辑,不能证明实际 GPU 画面正确。 +- 当前选角视觉脚本有实验性诊断,使用前复核其输出和退出行为;它不是完整 40250 比较器。 +- `--no-mutations` 不覆盖移动、聊天、重连、换装和攻击;此处同时给出 `--no-reconnect` 以明确首轮只读范围。 +- 当前脚本包含特定服务器地址与端口,运行前检查是否仍是目标环境;凭据只通过既有本地配置/环境提供,不写进报告。 +- 现有产物路径为 `build/export/mtgodot-poc.app`;日志在 `build/live-smoke/`。保留每次结果时复制到独立 run-id 归档,避免脚本固定路径覆盖历史。 + +### 第一版交付检查表 + +- [ ] 首次进入、切角色、重连后出现真实主角,地图和相机正确。 +- [ ] 用户出错角色的身体/发型/装备在正面及动画中正常。 +- [ ] 平地和选定坡地测试通过,脚底与阴影分别正确。 +- [ ] 底部任务栏可见,已配置技能的图标、输入、恢复、冷却正确。 +- [ ] 材质与地图关键区域通过固定场景比较,差异图有人审阅。 +- [ ] 最终普通 `.app` 独立启动,关键资源无开发目录回退。 +- [ ] 协议错误未导致用例提前终止;未完成的联机项明确标注。 +- [ ] 日志、截图、报告和包哈希对应同一次构建。 + +上述为本次内部 macOS 修复版交付标准,不代表全游戏、所有地图或所有平台均已验收。 + +## 10. R6:性能、缓存与内容扩展 + +在 R1–R5 通过后执行,不以优化替代正确性修复。 + +1. 固定场景采集 1/8/20/50 个角色的 CPU/GPU 帧时间、p95/p99、内存、冷加载与重复加载耗时,明确前台/后台和帧率限制。 +2. 量化当前 CPU 蒙皮逐帧构建网格的成本;GPU 路径先通过数值与画面回归,再扩大启用范围。 +3. 选择一组代表性静态建筑试缓存,缓存键至少包括源资源、转换器版本、坐标和材质选项;改变任一输入必须失效重建。 +4. 测量缓存对冷加载、包体和内存的影响,存在收益再扩大导入规模。保留原始资源来源和可重建流程。 +5. 树木、效果、LOD 和其他地图分别建立缺失清单与回归场景。占位替代物不能计入原版模型已完成数量。 +6. 最后扩展 Android/iOS 独立验证;macOS 的画面和性能结果不能直接外推。 + +## 11. 任务拆分与执行记录 + +每项宜形成一个可独立复查的补丁;无需先完成所有工具才修复已定位的问题。已有用户改动应在相邻上下文中保留,不因路线实施而重置。 + +| 任务 ID | 交付物 | 前置条件 | 当前状态 | +| --- | --- | --- | --- | +| R0-01 | 固定资源/场景基线与区域截图配置 | 可用参考资源 | 已有 A1/C1 实景入口和部分哈希;区域差异待补 | +| R1-01 | 主角晚到、重复事件、重连回归与修复 | R0 最小基线 | 晚到/重入/重试回归通过;真实重连待验 | +| R1-02 | 地图坐标、高度、相机就绪检查 | R1-01 | A1/C1 晚到定位实景通过;坡地/边界待验 | +| R2-01 | 同战士 GR2/GLB 实验台及对照报告 | R0 | 待执行 | +| R2-02 | 发型/身体/附件修复及路线决定 | R2-01 | 待执行 | +| R3-01 | 深度/材质/环境与地形校准 | R1、R2 几何稳定 | 材质已有改动,待验证 | +| R4-01 | 选角、技能栏装配与数据恢复 | R1 | 待执行 | +| R5-01 | 包资源清单、最终 `.app` 冒烟与画面报告 | R1–R4 | 待执行 | +| R6-01 | 性能基线与静态资源缓存试验 | R5 | 后续 | + +每项完成记录必须包含:问题复现、确认根因、修改文件、源资源哈希、实际执行命令、断言/截图结果、包或库哈希、未覆盖范围。失败时记录下一步所需证据;不要把“方案合理”“截图已生成”更新成通过。 + +与现有文档的关系:本路线聚焦用户当前渲染故障和首个 macOS 修复包;[SHINSOO-WORLD-RENDERING.md](SHINSOO-WORLD-RENDERING.md) 提供世界渲染背景,[PARITY-GAP.md](PARITY-GAP.md) 和 [BACKLOG.md](BACKLOG.md) 保留更广范围。后续只在实测完成时同步这些文档的对应状态。 + +### 2026-09-08 第一批实施记录 + +- 修改 `game_scene.gd`:setup 完成相关接线后才开始兜底;加载期间阻止网络事件重入;按 VID/race/资源根区分模型;失败间隔 1 秒最多尝试 3 次;丢弃构建期间失效的主角结果;避免重复动画订阅和每帧重置人物位置。 +- 发现地图路径原来只在 setup 时按已收到的主角位置选择。新增首次有效主角位置到达后的地图重选,复用已有地图重载和相机/移动同步基点定位函数。 +- 修改 `ui/player_view.gd`:拒绝非法 race、缺失身体文件及解析失败的源文件。模型节点在入树时才实际加载,因此使用源文件预检,不能把入树前为空的 `get_info()` 当成加载失败。 +- 新增 `main_character_sync_test.gd`:测试失败重试、晚到/无信号实体、重入、重复事件、移动不回滚、过期构建等,已执行得到 `failures=0`。注入失败用例的 warning 属预期。 +- `gamescene_test.gd` 与 `equip_model_test.gd` 已通过;前者故意使用缺失地图,其预期加载错误不能当成真实地图测试通过。 +- 新增 `gamescene_visual_test.gd`:Apple M4 / Metal Forward+ / Godot 4.7.1 上已执行 A1/C1;A1 加载 20 区块/601 物件,C1 加载 20 区块/565 物件,身体加发型共 4 个表面,实体定位断言通过。两者均使用 FakeClient,未连接实际服务器。 +- 截图人工观察:地图和真实人物出现;UI 仍有布局/风格问题,不能标为 R3/R4 完成。选角截图仍复现发型遮脸;单独翻转 winding 的试验未解决,未保留新的绕序修改。 +- 用例命令、输出位置和验证边界见 [test/rendering/README.md](../test/rendering/README.md)。早期失败截图保留在 `build/rendering/`;后续脚本变化应重新执行,不复用旧报告作为新代码证据。 +- 尚未完成:GLB 对照、发型根因修复、完整视觉基线与区域阈值、真实服务器重连、最终 macOS 包验收、R6 优化。下一优先项是发型的身体/附件变换与深度隔离。 diff --git a/extension/src/m2_material.cpp b/extension/src/m2_material.cpp index 8500f3f4..2c31b227 100644 --- a/extension/src/m2_material.cpp +++ b/extension/src/m2_material.cpp @@ -183,8 +183,24 @@ Ref s_mix_2s; Ref s_add_2s; Ref s_skin_2s; Ref s_add_skin_2s; +Ref s_opaque[4]; -Ref shader_for(bool additive, bool skinned, bool two_sided) { +Ref shader_for(bool additive, bool skinned, bool two_sided, bool opaque) { + if (opaque) { + Ref &solid = s_opaque[(skinned ? 2 : 0) + (two_sided ? 1 : 0)]; + if (solid.is_null()) { + String code = skinned ? SRC_SKIN : SRC_MIX; + // Writing ALPHA puts even alpha=1 into Godot's transparent pass. + // Solid faces must write depth before the hair cutout is drawn. + code = code.replace("ALPHA = ((mode == 1) ? c.a : 1.0) * lod_fade;", + "if (lod_fade < fract(sin(dot(FRAGCOORD.xy, vec2(12.9898, 78.233))) * 43758.5453)) { discard; }"); + if (two_sided) + code = code.replace("cull_back", "cull_disabled"); + solid.instantiate(); + solid->set_code(code); + } + return solid; + } Ref &slot = two_sided ? (additive ? (skinned ? s_add_skin_2s : s_add_2s) : (skinned ? s_skin_2s : s_mix_2s)) : (additive ? (skinned ? s_add_skin : s_add) : (skinned ? s_skin : s_mix)); @@ -206,7 +222,8 @@ Ref make_material(const MaterialDesc &d) { const bool additive = (d.blend == BlendMode::Add); const bool skinned = d.skinned; - m->set_shader(shader_for(additive, skinned, d.two_sided)); + m->set_shader(shader_for(additive, skinned, d.two_sided, + d.blend == BlendMode::Opaque || d.blend == BlendMode::AlphaTest)); if (skinned && d.bones_tex.is_valid()) { m->set_shader_parameter("bones_tex", d.bones_tex); } @@ -244,6 +261,8 @@ Ref make_material(const MaterialDesc &d) { } void cleanup_material_shaders() { + for (auto &shader : s_opaque) + shader.unref(); s_mix.unref(); s_add.unref(); s_skin.unref(); diff --git a/project/char_select_visual_test.gd b/project/char_select_visual_test.gd index f912e959..d9208eb9 100644 --- a/project/char_select_visual_test.gd +++ b/project/char_select_visual_test.gd @@ -29,6 +29,9 @@ func _run() -> void: quit(1) return print("Selected armor: ", screen._pv.model.get("gr2_path")) + print("Hair skin: ", screen._pv.model.get("hair_skin")) + var ua = preload("res://ui/ui_assets.gd") + ua.load_dds_image(String(screen._pv.model.get("hair_skin"))).save_png("/tmp/metin2-hair.png") for mi in screen._pv.find_children("*", "MeshInstance3D", true, false): for surface in mi.mesh.get_surface_count(): print("SURFACE ", surface, " ", mi.mesh.surface_get_name(surface)) diff --git a/project/game_scene.gd b/project/game_scene.gd index fb984f62..b07506dd 100644 --- a/project/game_scene.gd +++ b/project/game_scene.gd @@ -120,6 +120,17 @@ var weather: Node3D # Weather var _assets := "" var map_path := "" var _model_built := false +var _main_model_attempted := false +var _main_sync_ready := false +var _main_loading := false +var _main_generation := 0 +var _main_view_key := "" +var _main_attempts := 0 +var _main_retry_at := 0 +var _main_placed_vid := 0 +var _main_map_vid := 0 +const MAIN_MODEL_MAX_ATTEMPTS := 3 +const MAIN_MODEL_RETRY_MS := 1000 var _audio: Node var _bgm_director: Node var _mount: Node3D @@ -548,6 +559,7 @@ func setup(m2client: Node, assets_root: String, _setup_mobile_ui() client.entity_main_set.connect(_on_main_set) + _main_sync_ready = true if client.get_main_vid() != 0: # 重连 / 已在局内 # net_play._on_main_set 的信号早在它连上前就发过了 —— 手动补上关键那步: # 告诉 net_world 主角 vid 由本地 player 代表,别给它生成节点(否则 catch_up 会 @@ -639,6 +651,13 @@ func _lifecycle_node() -> Node: return null func _process(_dt: float) -> void: + # `entered_game` is emitted by the phase transition, while the first + # GC_MAIN_CHARACTER / EntityStore change is drained later in the same or a + # following frame. The setup-time catch-up below cannot see that packet in + # all server timings, so keep the event path and add this idempotent fallback. + # 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() # 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): @@ -853,6 +872,14 @@ func _on_warp(pos: Vector3, same_server: bool) -> void: _place_player_at_net_pos(pos) func _on_world_reset() -> void: + _main_generation += 1 + _main_view_key = "" + _main_attempts = 0 + _main_retry_at = 0 + _main_placed_vid = 0 + _main_map_vid = 0 + _model_built = false + _main_model_attempted = false if net_world and net_world.has_method("clear_for_map_change"): net_world.clear_for_map_change() if ground_items and ground_items.has_method("clear_for_map_change"): @@ -1369,6 +1396,10 @@ func _build_lighting() -> void: _env = e func _on_main_set(vid: int) -> void: + if not _main_sync_ready or _main_loading or vid <= 0 or client == null: + return + if vid != int(client.get_main_vid()): + return # A queued event may belong to the previous character. # GC_MAIN_CHARACTER can arrive after GameScene has subscribed. Mark this VID # as local before building its view so NetWorld removes (and never recreates) # the generic remote-player capsule for our own character. @@ -1379,11 +1410,45 @@ func _on_main_set(vid: int) -> void: var e: Dictionary = client.get_entity(vid) if e.is_empty() or player == null: return + var net_pos: Variant = e.get("pos", null) + if not net_pos is Vector3 or not net_pos.is_finite(): + return + # setup can only choose a map from data already received. Resolve again + # once the late authoritative position is available, before placing/viewing. + if _main_map_vid != vid: + _main_map_vid = vid + if _assets != "": + var next_map := _resolve_map_path(map_path, Vector2(net_pos.x * 100.0, -net_pos.z * 100.0)) + if next_map != map_path: + _main_loading = true + _reload_map_for_warp(net_pos) + _main_loading = false + var key := "%d:%d:%s" % [vid, int(e.get("race", -1)), _assets] + if key != _main_view_key: + _main_view_key = key + _main_attempts = 0 + _main_retry_at = 0 + _main_model_attempted = false + _model_built = false # 首次拿到 race -> 换成真 Metin2Model - if not _model_built and _assets != "" and int(e.get("race", 0)) >= 0: - var pv := PlayerView.new() + if not _model_built and _assets != "" and int(e.get("race", -1)) in range(8) \ + and _main_attempts < MAIN_MODEL_MAX_ATTEMPTS and Time.get_ticks_msec() >= _main_retry_at: + _main_model_attempted = true + _main_attempts += 1 + _main_loading = true + var generation := _main_generation + var pv = _create_main_view() var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable() - if pv.build(_assets, int(e.get("race", 0)), pump): + var built: bool = pv.build(_assets, int(e.get("race", 0)), pump) + _main_loading = false + # build() polls the network. World reset / new main data may arrive + # inside it; do not publish a view belonging to an obsolete generation. + if generation != _main_generation or is_queued_for_deletion() \ + or vid != int(client.get_main_vid()) \ + or int(client.get_entity(vid).get("race", -1)) != int(e.get("race", -1)): + pv.free() + return + if built: pv.set_audio(_audio) set_player_model(pv) _model_built = true @@ -1391,21 +1456,43 @@ func _on_main_set(vid: int) -> void: net_play.player_view = pv if pv.anim and pv.anim.has_signal("motion_event"): pv.anim.motion_event.connect(_on_local_motion_event) - if pc and pc.has_signal("anim_state"): - pc.anim_state.connect(func(s: String): - if is_instance_valid(player) and player.has_method("set_anim_state"): - player.set_anim_state(s)) + if pc and pc.has_signal("anim_state") and not pc.anim_state.is_connected(_on_main_anim_state): + pc.anim_state.connect(_on_main_anim_state) if equip_model: equip_model.set_race(int(e.get("race", 0))) equip_model.refresh() else: - pv.queue_free() - var p: Vector3 = MapCoord.to_world(e.get("pos", Vector3.ZERO)) - if world and world.has_method("set_focus_position"): - world.call("set_focus_position", p.x, p.z) # 先对准,才建得出脚下的区块 - if world and world.has_method("sample_height"): - p.y = float(world.call("sample_height", p.x, p.z)) - player.position = p + _main_retry_at = Time.get_ticks_msec() + MAIN_MODEL_RETRY_MS + push_warning("GameScene: 主角模型加载失败(vid=%d race=%d attempt=%d/%d)" % [vid, int(e.get("race", 0)), _main_attempts, MAIN_MODEL_MAX_ATTEMPTS]) + pv.free() + # Polling for model readiness must not snap a moving player to the initial + # server position on every frame or on duplicate main-character events. + if _main_placed_vid == vid: + return + _place_player_at_net_pos(net_pos) + _main_placed_vid = vid + +func _create_main_view() -> Node3D: + return PlayerView.new() + +func _on_main_anim_state(state: String) -> void: + if is_instance_valid(player) and player.has_method("set_anim_state"): + player.set_anim_state(state) + +func _sync_main_character() -> void: + if not _main_sync_ready or _main_loading or player == null or not is_instance_valid(player): + return + if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"): + return + var vid := int(client.get_main_vid()) + if vid == 0: + return + var e: Dictionary = client.get_entity(vid) + # Do not mark the attempt until the authoritative character row exists. The + # phase packet can precede the row by several frames on a busy connection. + if e.is_empty(): + return + _on_main_set(vid) # AppFlow 的唯一生命周期协调者在 GameScene 装配完成后调用,绑定当前场景的音频对象。 # GameScene 不创建 AppLifecycle,避免切屏时重复监听暂停 / 恢复通知。 diff --git a/project/gamescene_visual_test.gd b/project/gamescene_visual_test.gd new file mode 100644 index 00000000..ccd9fe55 --- /dev/null +++ b/project/gamescene_visual_test.gd @@ -0,0 +1,72 @@ +# Real GameScene/assets, delayed authoritative row, no network or account writes. +extends SceneTree + +const GameScene = preload("res://game_scene.gd") +const Fixtures = preload("res://gamescene_test.gd") +var failed := false + +func _initialize() -> void: + call_deferred("run") + +func run() -> void: + root.size = Vector2i(1280, 720) + var output := OS.get_environment("MT_RENDER_OUTPUT") + if output.is_empty(): + output = ProjectSettings.globalize_path("res://../build/rendering/gamescene-%d" % Time.get_unix_time_from_system()) + DirAccess.make_dir_recursive_absolute(output) + var fc := Fixtures.FakeClient.new() + root.add_child(fc) + var scene := GameScene.new() + root.add_child(scene) + await scene.setup(fc, AssetRoot.path(), "OutdoorA1/metin2_map_a1") + # Choose C1 to exercise late map correction too (setup initially had no VID). + var use_c1 := "--c1" in OS.get_cmdline_user_args() + var server := Vector2(959109, 269267) if use_c1 else Vector2(454600, 934000) + fc.main = 1000 + fc.ents[1000] = {"vid": 1000, "name": "RenderTest", "race": 0, + "pos": Vector3(server.x * 0.01, 0, -server.y * 0.01), + "pos_cm": server, "is_main": true, "angle_deg": 0.0, + "func": 0, "moving": false, "hp": 100, "max_hp": 100, "dead": false} + for i in 30: await process_frame + var expected := MapCoord.to_world(fc.ents[1000].pos) + expected.y = scene.world.sample_height(expected.x, expected.z) + failed = not scene._model_built or scene.player.position.distance_to(expected) > 0.02 + if use_c1: failed = failed or scene.map_path != "OutdoorC1/metin2_map_c1" + var surfaces := 0 + for mi in scene.player.find_children("*", "MeshInstance3D", true, false): + if mi.mesh: surfaces += mi.mesh.get_surface_count() + failed = failed or surfaces == 0 or not scene._map_loaded() + if scene._model_built and scene.player.anim: + scene.player.anim.set_time(0.5) + scene.player.anim.set_process(false) + 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(), + "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", + "scope": "real GameScene, delayed main row, no main signal; no server/40250 parity"} + var hashes := {} + for path in ["res://game_scene.gd", "res://ui/player_view.gd", "res://net/mapcoord.gd", + "res://gamescene_visual_test.gd", "res://bin/libmtgodot.macos.template_debug.dylib"]: + hashes[path] = FileAccess.get_sha256(path) + if scene._model_built: + for property in ["gr2_path", "hair_gr2", "hair_skin"]: + var path := String(scene.player.model.get(property)) + if not path.is_empty(): hashes[path] = FileAccess.get_sha256(path) + var motion := String(scene.player.anim.get("anim_path")) if scene.player.anim else "" + if not motion.is_empty(): hashes[motion] = FileAccess.get_sha256(motion) + report["sha256"] = hashes + report["animation_sample_seconds"] = 0.5 + var file := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE) + file.store_string(JSON.stringify(report, "\t")) + file.close() + print("GAMESCENE_VISUAL: ", JSON.stringify(report)) + scene.queue_free() + fc.queue_free() + await process_frame + quit(1 if failed else 0) diff --git a/project/main_character_sync_test.gd b/project/main_character_sync_test.gd new file mode 100644 index 00000000..a09f16af --- /dev/null +++ b/project/main_character_sync_test.gd @@ -0,0 +1,116 @@ +extends SceneTree + +const Scene = preload("res://game_scene.gd") +const View = preload("res://ui/player_view.gd") + +class Client extends Node: + var main := 0 + var rows := {} + func get_main_vid() -> int: return main + func get_entity(vid: int) -> Dictionary: return rows.get(vid, {}) + +class StubView extends Node3D: + var anim: Node + var succeeds := true + var during_build := Callable() + func build(_assets: String, _race: int, _pump: Callable) -> bool: + if during_build.is_valid(): during_build.call() + return succeeds + func set_audio(_node: Node) -> void: pass + +class Harness extends Scene: + var builds := 0 + var succeeds := true + var during_build := Callable() + func _create_main_view() -> Node3D: + builds += 1 + var view := StubView.new() + view.succeeds = succeeds + view.during_build = during_build + return view + +class Controller extends Node: + signal anim_state(state: String) + var player: Node3D + +class Camera extends Camera3D: + var target: Node3D + +var failures := 0 +func check(value: bool, label: String) -> void: + if not value: + failures += 1 + printerr("FAIL: " + label) + +func _initialize() -> void: + call_deferred("run") + +func run() -> void: + var client := Client.new() + var scene := Harness.new() + root.add_child(client) + root.add_child(scene) + scene.set_process(false) + scene.client = client + scene._assets = "synthetic-assets" + scene.player = Node3D.new() + scene.add_child(scene.player) + scene.pc = Controller.new() + scene.add_child(scene.pc) + scene.cam = Camera.new() + scene.add_child(scene.cam) + scene._sync_main_character() + check(scene.builds == 0, "setup incomplete does not build") + scene._main_sync_ready = true + client.main = 10 + scene._sync_main_character() + check(scene.builds == 0, "late entity is awaited") + client.rows[10] = {"pos": Vector3(20, 0, -30)} + scene._sync_main_character() + check(scene.builds == 0, "missing race does not build") + check(scene.player.position == Vector3(20, 0, 30), "late row places placeholder") + client.rows[10].race = 0 + scene.during_build = func(): scene._on_main_set(10) + scene._sync_main_character() + check(scene.builds == 1 and scene._model_built, "reentrant network event creates one view") + check(scene.cam.target == scene.player and scene.pc.player == scene.player, "replacement updates consumers") + scene.player.position = Vector3(25, 0, 35) + for i in 5: + scene._sync_main_character() + scene._on_main_set(10) + check(scene.builds == 1, "duplicate main events are idempotent") + check(scene.player.position == Vector3(25, 0, 35), "polling does not undo movement") + client.main = 20 + client.rows[20] = {"race": 1, "pos": Vector3(40, 0, -50)} + scene.during_build = Callable() + scene.succeeds = false + scene._sync_main_character() + check(scene.builds == 2 and not scene._model_built, "failed replacement is not marked ready") + for i in 5: scene._sync_main_character() + check(scene.builds == 2, "failure does not retry every frame") + scene._main_retry_at = 0 + scene.succeeds = true + scene._sync_main_character() + check(scene.builds == 3 and scene._model_built, "transient failure recovers") + check(scene.pc.anim_state.get_connections().size() == 1, "replacement does not duplicate animation subscriptions") + var previous = scene.player + client.main = 30 + client.rows[30] = {"race": 0, "pos": Vector3.ZERO} + scene.during_build = func(): client.main = 40 + scene._sync_main_character() + check(scene.player == previous, "obsolete build is discarded") + client.rows[40] = {"race": 0, "pos": Vector3.ZERO} + scene.during_build = Callable() + scene.succeeds = false + for i in 5: + scene._main_retry_at = 0 + scene._sync_main_character() + check(scene._main_attempts == 3, "permanent failure has bounded attempts") + var view := View.new() + check(not view.build("missing-assets", 8), "unsupported race rejected") + view.free() + scene.queue_free() + client.queue_free() + await process_frame + print("main_character_sync_test: failures=", failures) + quit(1 if failures else 0) diff --git a/project/ui/char_select_screen.gd b/project/ui/char_select_screen.gd index 562dce55..6dcbf618 100644 --- a/project/ui/char_select_screen.gd +++ b/project/ui/char_select_screen.gd @@ -305,9 +305,8 @@ func _rebuild_model() -> void: # Expansion armor textures live beside their GR2, outside the base PC pack. var body_path := String(pv.model.get("gr2_path")) pv.model.set("texture_dir", body_path.get_base_dir()) - # Shape zero uses the head/hair already included in the armor mesh. - if int(c.get("hair_part", 0)) == 0: - pv.model.set("hair_gr2", "") + pv.model.set("lod_enabled", false) + pv.model.set("flip_winding", true) pv._ground_model() var intro := pv.motion_dir.get_base_dir().path_join("intro/wait.msa") if pv.anim and FileAccess.file_exists(intro): diff --git a/project/ui/player_view.gd b/project/ui/player_view.gd index f1fd4982..3cfb77e1 100644 --- a/project/ui/player_view.gd +++ b/project/ui/player_view.gd @@ -29,7 +29,7 @@ var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin" # 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。 func build(assets_root: String, race: int, pump := Callable()) -> bool: _assets_root = assets_root - if not ClassDB.class_exists("Metin2Model"): + if race < 0 or race > 7 or not ClassDB.class_exists("Metin2Model"): return false var cls: String = CLASS_OF[race & 3] var pc_dir := "pc2/ymir work/pc2/%s" % cls if race in FEMALE_RACES else "PC/ymir work/pc/%s" % cls @@ -46,9 +46,18 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool: model.set("texture_dir", base) if pump.is_valid(): pump.call() var body := _first_existing([base.path_join("%s_novice.gr2" % cls), base.path_join("%s.gr2" % cls)]) - if body != "": - model.set("gr2_path", body) # 重:解 gr2 + 建网格 + 扫 3 级 LOD - if pump.is_valid(): pump.call() + if body == "": + model.free() + model = null + return false + model.set("gr2_path", body) # 重:解 gr2 + 建网格 + 扫 3 级 LOD + # This view is built off-tree: set_gr2_path only stores the path until ready. + # Validate the source explicitly rather than reading a not-yet-loaded report. + if String(model.call("probe_gr2", body)).contains("FAILED"): + model.free() + model = null + return false + if pump.is_valid(): pump.call() var hair := _first_existing([base.path_join("hair/hair_1_1.gr2"), base.path_join("hair/hair_01.gr2")]) if hair != "": model.set("hair_gr2", hair) # 重:解 hair gr2 + 折进网格 diff --git a/test/golden/world-a1-r2.png b/test/golden/world-a1-r2.png index c166398d..25da693f 100644 Binary files a/test/golden/world-a1-r2.png and b/test/golden/world-a1-r2.png differ diff --git a/test/rendering/README.md b/test/rendering/README.md new file mode 100644 index 00000000..a0e9b6db --- /dev/null +++ b/test/rendering/README.md @@ -0,0 +1,23 @@ +# 渲染回归入口 + +第一批用例针对 R1 场景装配,2026-09-08 开始执行。未宣称 40250 像素一致、发型正确或最终包通过。 + +在仓库根目录运行: + +```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 服务器位置,用来验证晚到坐标能纠正地图选择。 +- 视觉用例输出 `build/rendering/gamescene-/game.png` 与 `report.json`。可用环境变量 `MT_RENDER_OUTPUT` 指定独立目录;复用路径会覆盖同名报告,建议每次新目录。 +- 报告包含位置、相机、地图加载计数、模型表面数、引擎、关键脚本/扩展/模型路径哈希。资产依赖尚未穷举;这只是 R0 部分基线。 +- 截图尺寸为 1280×720,角色待机固定在 0.5 秒。地形/环境动态因素尚未完全冻结,因此暂不设置像素误差硬阈值。 +- 测试不配置学习技能,截图中的空快捷槽不代表技能图标验证成功。 + +已知后续:脸部/脚底近景和区域差异器、完整资产哈希、四职业 GR2/GLB 对照、技能状态与图标用例、真实联机及普通 macOS `.app` 验收。