Compare commits

...
12 Commits
Author SHA1 Message Date
shenandshen 66d217b313 feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00
shenlei 1db9e9a129 no message 2026-09-16 22:15:52 +09:00
shenleiandClaude Opus 5 400e3e8ea5 Merge classic enter-game framing fixes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-12 21:29:22 +09:00
shenleiandClaude Opus 5 85ce75e707 fix(net): 修复 40250 进入游戏时的收包错位
- GC_ITEM_DEL(20) 按 40250 旧结构为 42 字节,GC_REFINE_INFORMATION(95) 为 59 字节
- 公会标记连接接受 GC_MARK_DIFF_DATA(101),按 1 字节消费
- GUILD_SUBHEADER_GC_SKILL_INFO 服务器声明 22 字节但实际只写 21 字节,
  按实际长度分帧,修复 "unknown GC header 200 (last: 75,20)" 断线
- 会话测试的 quest info 包改为 40250 按 flag 定长的格式,新增对应分帧测试

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-12 21:29:21 +09:00
shenlei 474a709f69 Merge combat 40250 parity fixes 2026-09-12 13:15:28 +09:00
shenleiandClaude Opus 5 1933a5ceda fix(combat): 战斗逐项对齐 40250,修复移动 / 技能释放与武器挂点
- 连击类型由 combo_changed 驱动(SetComboSkillFlag,技能 122 等级)
- 命中判定改为 .msa 刀尖 Z 圆柱 vs .msm 防御球(hit_collision.gd 逐行移植)
- _register_hit 改为 map::insert 语义,修正命中上限
- 攻速同时缩放 GetAttackingElapsedTime 与攻击动作速率;按武器动作模式目录绑定攻击段
- 去掉本地连击超时,motion_bound 清命中表 / 连击段号
- __ProcessDataAttackSuccess:InsertDelay 硬直、physics_push.gd 击退 + GetBlendingPosition 同步、
  命中特效、__HitGood / __HitGreate / __HitStone 受击动作链与抖动(ui/hit_reaction.gd)
- ClassicSession::send_use_skill 不再夹带 CG_FLY_TARGETING
- mac 前进 / 后退方向与技能释放修复;武器按职业骨骼挂到手上
- 新增 hit_collision / physics_push / hit_view / net_world_push / weapon_attach 测试;
  gpu_pose_bounds / race_motion_assembly 适配 damage 随机变体
- 文档:CLIENT-GAP.md、CLIENT-GAP-FIX.md §3.5 / §3.7 与 C.5 增量 130

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-12 13:15:28 +09:00
shenandshen 96c29d2bb7 Merge playable STB-01 validation fixes 2026-09-11 21:23:52 +08:00
shenandshen cc5573ddc1 fix(playable): close release validation lifecycle gaps 2026-09-11 21:23:44 +08:00
shenleiandClaude Opus 5 f39a55fdd5 feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档
- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay
  只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续
  运行并记 BLOCKED;写本批次 release-manifest.json 后聚合
- run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理);
  soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数)
- 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试
- forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别
- 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求
- .gitignore 排除本地场景配置、凭据文件与运行输出

离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS,
node 夹具测试 PASS。未联网运行,未重建候选包。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-11 22:06:48 +09:00
shenandshen 374d4165d8 Implement playable Mac client and rendering validation 2026-09-11 14:58:22 +08:00
shenandshen 1ab68bd06e Merge branch 'main' of http://192.168.21.200:8418/4v5u09Z5a4Yuc/metin2-client
# Conflicts:
#	docs/RENDERING-REPAIR-PLAN.md
2026-09-08 16:25:54 +08:00
shenandshen cea79fe17c fix(rendering): complete character visual regression fixes 2026-09-08 13:26:42 +08:00
911 changed files with 71230 additions and 1937 deletions
+10
View File
@@ -37,6 +37,16 @@ compile_commands.json
*.a
.env.live-smoke.local
# playable network tests: local scenario/fixture configs, credential files and
# run outputs never enter git. Only scenario.example.json and the static gate
# fixtures in test/playable are committed. Run outputs live under /build/playable.
/test/playable/*.local.json
/test/playable/*.local.txt
/test/playable/runs/
*.credentials
*.credentials.json
.env.*.local
# clangd
/.cache/
+27
View File
@@ -0,0 +1,27 @@
# Native tree geometry
These 87 GLBs are offline SpeedTreeRT extractions of this workspace's existing
SPT assets, not procedural replacements. Their embedded bark/composite textures
and meshes are game artwork: the same internal-use/licensing restrictions as
`assets/Tree` apply. Do not treat them as freely redistributable open-source art.
`manifest.json` maps original SPT SHA-256 to the GLB filename and SHA-256.
Both hashes are checked by `script/stage_tree_geometry.mjs`; the runtime also
verifies both before loading. The source conversion index lives in the sibling
`metin2-godot-main/generated/content-v1/40250-full-unpacked-final` workspace.
No proprietary Windows DLL is shipped or loaded by the Mac client.
Coordinates are already Godot Y-up metres. Keep original height, origin, UVs,
branches, fronds and leaf cards; do not apply the former proxy's 12m scale.
Leaf materials use double-sided alpha scissor with cutoff 0.3.
This is real **static** geometry: full SpeedTree wind, camera-facing leaf
clusters and native LOD transitions are not reconstructed by these GLBs.
Validation: all 87 species import; all 81 species referenced by current static
AreaData have a conversion; six principal maps render 2,092 native trees with
zero proxies. The 31 legacy SPTs under `Zone/ymir work/zone/b/tree` have different
source hashes and are not converted or aliased to these meshes. They are not
referenced by current tree property records. One unused historical property
references missing `christmastree.spt`; the actual `christmastree1/2/3.spt`
variants are converted. Seasonal event spawning is separate from static maps.
+963
View File
@@ -0,0 +1,963 @@
{
"schema_version": 1,
"coordinates": "godot_y_up_meters",
"trees": {
"1d20b68d50ce007b4814da28936b1992ccb399f1c264df3a5dcaa8b92de5be5e": {
"source": "Tree/ymir work/tree/b1_baobab_rt.spt",
"source_sha256": "1d20b68d50ce007b4814da28936b1992ccb399f1c264df3a5dcaa8b92de5be5e",
"glb": "1d20b68d50ce007b4814da28936b1992ccb399f1c264df3a5dcaa8b92de5be5e.glb",
"glb_sha256": "642c31b558023112fc6c5fba9955c235d39d97a7c05eb56613bd76a4cf33e83e",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"9e5b696ab020cd18232e18c866454c5d52deb1076998db0a1fbf8703d1599741": {
"source": "Tree/ymir work/tree/b1_baobab_rt2.spt",
"source_sha256": "9e5b696ab020cd18232e18c866454c5d52deb1076998db0a1fbf8703d1599741",
"glb": "9e5b696ab020cd18232e18c866454c5d52deb1076998db0a1fbf8703d1599741.glb",
"glb_sha256": "4a4d6013668a084dc3b00d44e9cc7474457ddd1f06e64229a348ff8f0a445326",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"de17c0c13f956cf75d92997063a12c5dbca1a26234ad14a5f868a03362a11b48": {
"source": "Tree/ymir work/tree/b1_beech_rt.spt",
"source_sha256": "de17c0c13f956cf75d92997063a12c5dbca1a26234ad14a5f868a03362a11b48",
"glb": "de17c0c13f956cf75d92997063a12c5dbca1a26234ad14a5f868a03362a11b48.glb",
"glb_sha256": "18f49b44e9b435f91da8b75958aab7aef4b0d368301da7b3789f51e0570481ac",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"cdb81537efcda4c26781463a6018d59c42edcb12ca3d5b724c029810bf434de9": {
"source": "Tree/ymir work/tree/b1_beech_rt2.spt",
"source_sha256": "cdb81537efcda4c26781463a6018d59c42edcb12ca3d5b724c029810bf434de9",
"glb": "cdb81537efcda4c26781463a6018d59c42edcb12ca3d5b724c029810bf434de9.glb",
"glb_sha256": "9da1a6ab558c70dda232c36a2bd85bb710d24031149dac0198af3532b0bd4145",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"9d7ee35f949b4764090b1a916ade1005b622b31fea49b2d4b8586076711c8fc7": {
"source": "Tree/ymir work/tree/b1_beech_rt3.spt",
"source_sha256": "9d7ee35f949b4764090b1a916ade1005b622b31fea49b2d4b8586076711c8fc7",
"glb": "9d7ee35f949b4764090b1a916ade1005b622b31fea49b2d4b8586076711c8fc7.glb",
"glb_sha256": "edf0fbac525cfc48f87527aa25d3f49df6f010ee51f56ae78e0ac69d00aa4ba5",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"25d677d269f8cfc38ca1cd3f692810cb293ab13db46b576b278cc695f7cf3f16": {
"source": "Tree/ymir work/tree/b1_beech_rt4.spt",
"source_sha256": "25d677d269f8cfc38ca1cd3f692810cb293ab13db46b576b278cc695f7cf3f16",
"glb": "25d677d269f8cfc38ca1cd3f692810cb293ab13db46b576b278cc695f7cf3f16.glb",
"glb_sha256": "edc5c99aab45da766baa28ee402086b2b107326ec52d81b3324c06ad38def6ee",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"66392538f04a55d484f9c92cdc9d19d21341f499f30162d288e75812669f1f04": {
"source": "Tree/ymir work/tree/b1_montereycypress_rt.spt",
"source_sha256": "66392538f04a55d484f9c92cdc9d19d21341f499f30162d288e75812669f1f04",
"glb": "66392538f04a55d484f9c92cdc9d19d21341f499f30162d288e75812669f1f04.glb",
"glb_sha256": "9abdfc7f413b5aa4117f36106720dc979ebfa4c647c28a833904aa4d9e713466",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"0b90a99d6480452b12b5f78a9b61e453180ec4a83f4246bdf1b91c02b949f4e8": {
"source": "Tree/ymir work/tree/b1_montereycypress_rt2.spt",
"source_sha256": "0b90a99d6480452b12b5f78a9b61e453180ec4a83f4246bdf1b91c02b949f4e8",
"glb": "0b90a99d6480452b12b5f78a9b61e453180ec4a83f4246bdf1b91c02b949f4e8.glb",
"glb_sha256": "acd4675f30e805d5543c17f0a7a52fe37f31f6e67874b196b7395997e1ea8e83",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"f6691c8b816eea9bc8c49189d7f780f531ed9ec82d432fbba3a847d0776c27db": {
"source": "Tree/ymir work/tree/b1_montereycypress_rt3.spt",
"source_sha256": "f6691c8b816eea9bc8c49189d7f780f531ed9ec82d432fbba3a847d0776c27db",
"glb": "f6691c8b816eea9bc8c49189d7f780f531ed9ec82d432fbba3a847d0776c27db.glb",
"glb_sha256": "56effe5240c0cf44f74c033412fa2bf25a8329be74c66a62da3d2288ae24ab10",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"d7ca320772595a77c28856c45a36674e18918b9e7bc5101cd5cb8cdf12de27e1": {
"source": "Tree/ymir work/tree/b1_montereycypress_rt4.spt",
"source_sha256": "d7ca320772595a77c28856c45a36674e18918b9e7bc5101cd5cb8cdf12de27e1",
"glb": "d7ca320772595a77c28856c45a36674e18918b9e7bc5101cd5cb8cdf12de27e1.glb",
"glb_sha256": "d371eba315defbfb6956cc8cfbd26dde7ff9240d21f832c37a6fc63559bfcca2",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"cb9102770aca71a6e269e806c6e6e74e5d4cb8096abbf078180ab27c8e1367c3": {
"source": "Tree/ymir work/tree/b1_montereycypress_rt5.spt",
"source_sha256": "cb9102770aca71a6e269e806c6e6e74e5d4cb8096abbf078180ab27c8e1367c3",
"glb": "cb9102770aca71a6e269e806c6e6e74e5d4cb8096abbf078180ab27c8e1367c3.glb",
"glb_sha256": "28c7a3e30c21d3874247e10a0bf6107de1775d29b0effa664edeb743c036f953",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"2851666e8f3556aa6606642bcd529672ab45b0f8db1304d6fbb4466900a2ca5a": {
"source": "Tree/ymir work/tree/b1_pagodatree_rt.spt",
"source_sha256": "2851666e8f3556aa6606642bcd529672ab45b0f8db1304d6fbb4466900a2ca5a",
"glb": "2851666e8f3556aa6606642bcd529672ab45b0f8db1304d6fbb4466900a2ca5a.glb",
"glb_sha256": "0af3909bf5fe30ae77f918e7641e12791b8c2d3f40ec576aa375b3005bb8f528",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"d35194db82fe4ca4a7668f95d5d95f116ab6b08c326b7baf0c0ab074276db953": {
"source": "Tree/ymir work/tree/b1_pagodatree_rt2.spt",
"source_sha256": "d35194db82fe4ca4a7668f95d5d95f116ab6b08c326b7baf0c0ab074276db953",
"glb": "d35194db82fe4ca4a7668f95d5d95f116ab6b08c326b7baf0c0ab074276db953.glb",
"glb_sha256": "a6546ab05a6722bf2eaefc0b23ae399d97050a280631488bb82d651ed131ea52",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"a566eff0304a67971167e4e7d3bf254ab0c7a33478fcd657cec613c16058e900": {
"source": "Tree/ymir work/tree/b1_pagodatree_rt3.spt",
"source_sha256": "a566eff0304a67971167e4e7d3bf254ab0c7a33478fcd657cec613c16058e900",
"glb": "a566eff0304a67971167e4e7d3bf254ab0c7a33478fcd657cec613c16058e900.glb",
"glb_sha256": "fd60281f1384d80b51f838ee512e5d7506fef17d8ad9c28c962a4970e5e02be6",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"1bc5e65c0e4efcb60f7ea930f683b80001b8dd97762b6d25281946a295685149": {
"source": "Tree/ymir work/tree/b1_sassafras_rt_fall.spt",
"source_sha256": "1bc5e65c0e4efcb60f7ea930f683b80001b8dd97762b6d25281946a295685149",
"glb": "1bc5e65c0e4efcb60f7ea930f683b80001b8dd97762b6d25281946a295685149.glb",
"glb_sha256": "69ba5cd343cb193e12d5fa9203356e08e5536e6a0083650ca4be88f87472fcf8",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"5c6dabb419b86756136240a32b532746d6f86759c8e744f1701c24bcb840262a": {
"source": "Tree/ymir work/tree/b1_sassafras_rt_fall2.spt",
"source_sha256": "5c6dabb419b86756136240a32b532746d6f86759c8e744f1701c24bcb840262a",
"glb": "5c6dabb419b86756136240a32b532746d6f86759c8e744f1701c24bcb840262a.glb",
"glb_sha256": "14cb7ad036580d3387112e2098d52ee6815d158c533ed52f3db0604a00c33b09",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"d76530cfba5e75a94a9634fd76f86a02126c22bd51cccfc3d995eff8974742f2": {
"source": "Tree/ymir work/tree/b2_cedaroflebanon_rt.spt",
"source_sha256": "d76530cfba5e75a94a9634fd76f86a02126c22bd51cccfc3d995eff8974742f2",
"glb": "d76530cfba5e75a94a9634fd76f86a02126c22bd51cccfc3d995eff8974742f2.glb",
"glb_sha256": "d8df7faf73265c6a5b2a725cfacab421b609cd9a4bd2bad41997e349e9ee4fa7",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"73d757a146ca27a9459ccc3027cd9464ce415f1a6731771cbff37827a3ee1caa": {
"source": "Tree/ymir work/tree/b2_cedaroflebanon_rt2.spt",
"source_sha256": "73d757a146ca27a9459ccc3027cd9464ce415f1a6731771cbff37827a3ee1caa",
"glb": "73d757a146ca27a9459ccc3027cd9464ce415f1a6731771cbff37827a3ee1caa.glb",
"glb_sha256": "c6c15c69d0b60f232293e74f8da191b3ae62f4a69fba61d0e7fe94008c51176e",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"fd2e5bb94c5223e1eaca97544e757e135252edb05f0ddfa991e3abad3fb78b3f": {
"source": "Tree/ymir work/tree/b2_cedaroflebanon_rt3.spt",
"source_sha256": "fd2e5bb94c5223e1eaca97544e757e135252edb05f0ddfa991e3abad3fb78b3f",
"glb": "fd2e5bb94c5223e1eaca97544e757e135252edb05f0ddfa991e3abad3fb78b3f.glb",
"glb_sha256": "81e2b1c1cd753ba17e8d875bc5ad0104c0f2a8cc1831d4decbc64d7a82fb1202",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"50557c6da89615d82f9d81799315549bed25e9ae09dd128a6f161f83a17f04f5": {
"source": "Tree/ymir work/tree/b2_ivyspy_rt.spt",
"source_sha256": "50557c6da89615d82f9d81799315549bed25e9ae09dd128a6f161f83a17f04f5",
"glb": "50557c6da89615d82f9d81799315549bed25e9ae09dd128a6f161f83a17f04f5.glb",
"glb_sha256": "d632d027f5e228e6d85a986068ada05996461692623dc082569b9078965af7dd",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"d012fcd397e8dac1d1778b3ad700ee36f08d282bd53ca6466a7232824e453f5a": {
"source": "Tree/ymir work/tree/b2_ivyspy_rt2.spt",
"source_sha256": "d012fcd397e8dac1d1778b3ad700ee36f08d282bd53ca6466a7232824e453f5a",
"glb": "d012fcd397e8dac1d1778b3ad700ee36f08d282bd53ca6466a7232824e453f5a.glb",
"glb_sha256": "ce88fcfb1f04048912665592e0ad4da69679163c23b8b2f541641bad7e319f2d",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"065babe8c785f54d23b99e91e9d505b91eadaae1dff492bcfe236b657acf9272": {
"source": "Tree/ymir work/tree/b2_ivyspy_rt3.spt",
"source_sha256": "065babe8c785f54d23b99e91e9d505b91eadaae1dff492bcfe236b657acf9272",
"glb": "065babe8c785f54d23b99e91e9d505b91eadaae1dff492bcfe236b657acf9272.glb",
"glb_sha256": "aea108dfb8fcaf7021b54df8c27a194ac5486fac08fb32dbc15c00b7834d2596",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"61f67291ff5d7c49014991dddb3d03550ae18e157f1acf62daf249ca40206bf3": {
"source": "Tree/ymir work/tree/b2_ivyspy_rt4.spt",
"source_sha256": "61f67291ff5d7c49014991dddb3d03550ae18e157f1acf62daf249ca40206bf3",
"glb": "61f67291ff5d7c49014991dddb3d03550ae18e157f1acf62daf249ca40206bf3.glb",
"glb_sha256": "fcd95e7569a5a6d8af5e987f3764d520d941fa518827ae247514b003c2de5a87",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"f40054b4e07fdc6071f8b5bd6ad85782d5207c51fa116bfb8e0aca4d8675badd": {
"source": "Tree/ymir work/tree/b2_ivyspy_rt5.spt",
"source_sha256": "f40054b4e07fdc6071f8b5bd6ad85782d5207c51fa116bfb8e0aca4d8675badd",
"glb": "f40054b4e07fdc6071f8b5bd6ad85782d5207c51fa116bfb8e0aca4d8675badd.glb",
"glb_sha256": "0824f3fc789c420d6eb543cf932f617eabb8f01437e5bf6ef9aacf116e0eca83",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"80422882e2d1ec63f6c5080c9c308634b8e8acc1342ebe1ebc74039fe2384631": {
"source": "Tree/ymir work/tree/b2_ivyspy_rt_winter.spt",
"source_sha256": "80422882e2d1ec63f6c5080c9c308634b8e8acc1342ebe1ebc74039fe2384631",
"glb": "80422882e2d1ec63f6c5080c9c308634b8e8acc1342ebe1ebc74039fe2384631.glb",
"glb_sha256": "f2a4fa095e0d7ca96a49afc371b9f9189a5b370ca4543827fc3098971b779a31",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"8c9d519de4a1652c2b923e11927be3818942235052cc67e6825d9705a09feaeb": {
"source": "Tree/ymir work/tree/b2_ivyspy_rt_winter2.spt",
"source_sha256": "8c9d519de4a1652c2b923e11927be3818942235052cc67e6825d9705a09feaeb",
"glb": "8c9d519de4a1652c2b923e11927be3818942235052cc67e6825d9705a09feaeb.glb",
"glb_sha256": "97a8e81c46cf1f5dbe8084535e17286c7df07e7b71d61ae7c591adff1dd62fda",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"fa2b9ac46f0f3be36f386fb8630f034393bde37d9cc5b2ec8b38470bdbb14c59": {
"source": "Tree/ymir work/tree/b2_japanesemaple_rt_fall.spt",
"source_sha256": "fa2b9ac46f0f3be36f386fb8630f034393bde37d9cc5b2ec8b38470bdbb14c59",
"glb": "fa2b9ac46f0f3be36f386fb8630f034393bde37d9cc5b2ec8b38470bdbb14c59.glb",
"glb_sha256": "34831b921865e35889704938333c80d69daaba09762e4fe8351c673811053dea",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"86b5d9065583eabca29ad89b7ccb67ac640bada57af8504c6448027fa05bd655": {
"source": "Tree/ymir work/tree/b2_japanesemaple_rt_fall2.spt",
"source_sha256": "86b5d9065583eabca29ad89b7ccb67ac640bada57af8504c6448027fa05bd655",
"glb": "86b5d9065583eabca29ad89b7ccb67ac640bada57af8504c6448027fa05bd655.glb",
"glb_sha256": "f38ffdbebb6f60e4e92d3b8993cc3001c502a8223c72bfb3688812d9217a67b2",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"13292b7af3472178d07e40005acd73b09d61f1d9f9570606cc74f115a42a1637": {
"source": "Tree/ymir work/tree/b2_redoak_rt.spt",
"source_sha256": "13292b7af3472178d07e40005acd73b09d61f1d9f9570606cc74f115a42a1637",
"glb": "13292b7af3472178d07e40005acd73b09d61f1d9f9570606cc74f115a42a1637.glb",
"glb_sha256": "6fc61c59dc838a768f10b749a9b7ce1ea659e4a9d6f9fc6ee6c887c0532adc6c",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"55ee1c716113b30ebc1e4166698765f041f1c094bf28d4ec86f8ca61202e5116": {
"source": "Tree/ymir work/tree/b2_redoak_rt2.spt",
"source_sha256": "55ee1c716113b30ebc1e4166698765f041f1c094bf28d4ec86f8ca61202e5116",
"glb": "55ee1c716113b30ebc1e4166698765f041f1c094bf28d4ec86f8ca61202e5116.glb",
"glb_sha256": "3200e730f4551792a504f20e4046e7bcf466e1e51d90229c9328b3ea63bc47cb",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"c50b043a6ceed03fbdac17cd0a861b1de1d06f4c5933a9078d073f42867e1a22": {
"source": "Tree/ymir work/tree/b2_redoak_rt3.spt",
"source_sha256": "c50b043a6ceed03fbdac17cd0a861b1de1d06f4c5933a9078d073f42867e1a22",
"glb": "c50b043a6ceed03fbdac17cd0a861b1de1d06f4c5933a9078d073f42867e1a22.glb",
"glb_sha256": "6781839f9fcdcdbae7b156b5708436e2580781a11bb784ff11f5f088a841471a",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"0d62629ce2423bdfce51595038cab213dec628acb4cf063b0b271279a28e8832": {
"source": "Tree/ymir work/tree/b2_redoak_rt4.spt",
"source_sha256": "0d62629ce2423bdfce51595038cab213dec628acb4cf063b0b271279a28e8832",
"glb": "0d62629ce2423bdfce51595038cab213dec628acb4cf063b0b271279a28e8832.glb",
"glb_sha256": "febe8dcbe1880cec8f91dc5844a55749c8d183e86c1b581d07440f6bf38ab794",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"eace2fb1af759da813559a33d38690596a066397983994cfea75232441ca3086": {
"source": "Tree/ymir work/tree/b3_beech_rt.spt",
"source_sha256": "eace2fb1af759da813559a33d38690596a066397983994cfea75232441ca3086",
"glb": "eace2fb1af759da813559a33d38690596a066397983994cfea75232441ca3086.glb",
"glb_sha256": "1d29e58f3e04b4dbfe4cbfb91403bddcfed2af0f282636f506d358b5f15cccb1",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"65846f0ea0ac8ace155a11cc6a41631d3330e6db25ba9a9c8b8171b96fdfde67": {
"source": "Tree/ymir work/tree/b3_beech_rt2.spt",
"source_sha256": "65846f0ea0ac8ace155a11cc6a41631d3330e6db25ba9a9c8b8171b96fdfde67",
"glb": "65846f0ea0ac8ace155a11cc6a41631d3330e6db25ba9a9c8b8171b96fdfde67.glb",
"glb_sha256": "2bdbe584e694ec4516803ac0966f741cf39920f20c4692b6f5f4496e5fff0b06",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"09c12d2bf8ef2c5ada7d54323b7b11816bebc56cc3fddd7373aba4eed7d3527e": {
"source": "Tree/ymir work/tree/b3_beech_rt3.spt",
"source_sha256": "09c12d2bf8ef2c5ada7d54323b7b11816bebc56cc3fddd7373aba4eed7d3527e",
"glb": "09c12d2bf8ef2c5ada7d54323b7b11816bebc56cc3fddd7373aba4eed7d3527e.glb",
"glb_sha256": "cae93d33e0849b769cb9070bb094239a2d68c33e22942df24836190757c3cd98",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"e622c7689d6525c09b22244a9a112ce9448c1c189cbe7f0468da9de82b8d9c36": {
"source": "Tree/ymir work/tree/b3_beech_rt_fall.spt",
"source_sha256": "e622c7689d6525c09b22244a9a112ce9448c1c189cbe7f0468da9de82b8d9c36",
"glb": "e622c7689d6525c09b22244a9a112ce9448c1c189cbe7f0468da9de82b8d9c36.glb",
"glb_sha256": "59e6e7c3378af28c44a3293b1bd026023cbcc9c5447be1684e63a88d9b3691f8",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"8aaf7a77ff61d8dd3828057df396ae27a6471faa7db94a6f2c93c0522ce22aa0": {
"source": "Tree/ymir work/tree/b3_beech_rt_fall2.spt",
"source_sha256": "8aaf7a77ff61d8dd3828057df396ae27a6471faa7db94a6f2c93c0522ce22aa0",
"glb": "8aaf7a77ff61d8dd3828057df396ae27a6471faa7db94a6f2c93c0522ce22aa0.glb",
"glb_sha256": "3e684dc00afd7451b28ed3f89a00baa7ea6c9d45c442caad332b9596ed0b872c",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"6600b5cae4c4f473b5cea7e9d90614ffeb3559807d4605d6d729ab7b3182eb77": {
"source": "Tree/ymir work/tree/b3_pagodatree_rt_winter.spt",
"source_sha256": "6600b5cae4c4f473b5cea7e9d90614ffeb3559807d4605d6d729ab7b3182eb77",
"glb": "6600b5cae4c4f473b5cea7e9d90614ffeb3559807d4605d6d729ab7b3182eb77.glb",
"glb_sha256": "bec163b2c1fa99b26d97bad86155a401ab19c067b2acb160a747fceb5b168226",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"ea34cd93e320c38e79837ab348022ea6cab4a62b295fd20a54c7a4a9688129f5": {
"source": "Tree/ymir work/tree/b3_pagodatree_rt_winter2.spt",
"source_sha256": "ea34cd93e320c38e79837ab348022ea6cab4a62b295fd20a54c7a4a9688129f5",
"glb": "ea34cd93e320c38e79837ab348022ea6cab4a62b295fd20a54c7a4a9688129f5.glb",
"glb_sha256": "63aa6ef7fe8181bc1877e86a6b3f1f3bd617a2af303208006289c8e68a76b859",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"e03e7c40dd56befae1804550d90f114a1496f7a5c0fa18ae111de5a19372639b": {
"source": "Tree/ymir work/tree/b3_shingleoak_rt.spt",
"source_sha256": "e03e7c40dd56befae1804550d90f114a1496f7a5c0fa18ae111de5a19372639b",
"glb": "e03e7c40dd56befae1804550d90f114a1496f7a5c0fa18ae111de5a19372639b.glb",
"glb_sha256": "54bb8cf813b9c6f05f14ea7dbb39689a3a26f9737e7526969c2bea070b8bf78f",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 5,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"d59d8a429a63272bcd21b403cd581556b57a140f5cdc65c7af329d30b6770d26": {
"source": "Tree/ymir work/tree/b3_shingleoak_rt2.spt",
"source_sha256": "d59d8a429a63272bcd21b403cd581556b57a140f5cdc65c7af329d30b6770d26",
"glb": "d59d8a429a63272bcd21b403cd581556b57a140f5cdc65c7af329d30b6770d26.glb",
"glb_sha256": "6b85b0a649472c7268791a0adce367099b4fbb783d07ad3a508e0aa88d77c331",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 5,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"2bec6d9317e3947386c56c4183db454fe629af9fa87de759324928e681eb5742": {
"source": "Tree/ymir work/tree/b3_shingleoak_rt3.spt",
"source_sha256": "2bec6d9317e3947386c56c4183db454fe629af9fa87de759324928e681eb5742",
"glb": "2bec6d9317e3947386c56c4183db454fe629af9fa87de759324928e681eb5742.glb",
"glb_sha256": "3a3fc8e8afa93b2a65806fd5d6eb625a05426c6f8a99c50a77ac1c197bf9a6f5",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 5,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"0a15ca2a0809cc74e6d72ed48b137906f02f0e44994228fa9f840d76a6ca52cc": {
"source": "Tree/ymir work/tree/b3_shingleoak_rt4.spt",
"source_sha256": "0a15ca2a0809cc74e6d72ed48b137906f02f0e44994228fa9f840d76a6ca52cc",
"glb": "0a15ca2a0809cc74e6d72ed48b137906f02f0e44994228fa9f840d76a6ca52cc.glb",
"glb_sha256": "39dc26f0713574b98e8bba0a26ff448bd6fabd97c5b66880f155516f536d35a4",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 5,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"dbc300acb7356f4f51e897a2e248a2883241ea570d5d8cded218b31401206534": {
"source": "Tree/ymir work/tree/b3_shingleoak_rt5.spt",
"source_sha256": "dbc300acb7356f4f51e897a2e248a2883241ea570d5d8cded218b31401206534",
"glb": "dbc300acb7356f4f51e897a2e248a2883241ea570d5d8cded218b31401206534.glb",
"glb_sha256": "08dc609a9aa5b16ce943dfd6bc63d2db45daf3bc7252fd6d1ee58a7ce6449685",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 5,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"0e8ab0197ee0db8e3a40e4b8d6c5c8f0b291284d2ccb7e13cee41d0f0792cc6f": {
"source": "Tree/ymir work/tree/b3_umbrellathorn_rt_flowers.spt",
"source_sha256": "0e8ab0197ee0db8e3a40e4b8d6c5c8f0b291284d2ccb7e13cee41d0f0792cc6f",
"glb": "0e8ab0197ee0db8e3a40e4b8d6c5c8f0b291284d2ccb7e13cee41d0f0792cc6f.glb",
"glb_sha256": "580a3ff0582a7723250650e8b0828912000faf494afb25ace42578e0d1397bb8",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"3c5c75535819e405b61cd21c89d514a8d2b0e6afdd75428cc0687745a3314cfe": {
"source": "Tree/ymir work/tree/b3_umbrellathorn_rt_flowers2.spt",
"source_sha256": "3c5c75535819e405b61cd21c89d514a8d2b0e6afdd75428cc0687745a3314cfe",
"glb": "3c5c75535819e405b61cd21c89d514a8d2b0e6afdd75428cc0687745a3314cfe.glb",
"glb_sha256": "2483acf297ce1f35e3ef297ff14e84c33b16bfae885fb04f373a601ad41cfab6",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"a6592779c9460b004be7956123a13f439bef0669dbfdad4bd330b78c44b100d2": {
"source": "Tree/ymir work/tree/b3_umbrellathorn_rt_flowers3.spt",
"source_sha256": "a6592779c9460b004be7956123a13f439bef0669dbfdad4bd330b78c44b100d2",
"glb": "a6592779c9460b004be7956123a13f439bef0669dbfdad4bd330b78c44b100d2.glb",
"glb_sha256": "0d5fe2e83f4621f6cefbbf8e645306af026c540364d05d91e2757d8d4775ceff",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"44ec99e53b3158a6619ec7a5cb4110bfb67ff9943224b068a8bc99fa1d92c0d0": {
"source": "Tree/ymir work/tree/b3_umbrellathorn_rt_flowers4.spt",
"source_sha256": "44ec99e53b3158a6619ec7a5cb4110bfb67ff9943224b068a8bc99fa1d92c0d0",
"glb": "44ec99e53b3158a6619ec7a5cb4110bfb67ff9943224b068a8bc99fa1d92c0d0.glb",
"glb_sha256": "1efd17b58f6dd39b3df3e7de84d20a1d4740e8d53c05a08080a3e335619e6ac8",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"d85676762825b8324ebeba85bc9a0e2242da8d225c5699c042f469abd17d01d7": {
"source": "Tree/ymir work/tree/christmastree1.spt",
"source_sha256": "d85676762825b8324ebeba85bc9a0e2242da8d225c5699c042f469abd17d01d7",
"glb": "d85676762825b8324ebeba85bc9a0e2242da8d225c5699c042f469abd17d01d7.glb",
"glb_sha256": "6836fef2133fbc14a4ac17f3762cfe64b596e2cc1dbae818c80fe4eb421a510f",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"6277b6d6dd747b0a6aa067fc2a27e8e7dad3bd8c9c755d6b124bc474b4107a1d": {
"source": "Tree/ymir work/tree/christmastree2.spt",
"source_sha256": "6277b6d6dd747b0a6aa067fc2a27e8e7dad3bd8c9c755d6b124bc474b4107a1d",
"glb": "6277b6d6dd747b0a6aa067fc2a27e8e7dad3bd8c9c755d6b124bc474b4107a1d.glb",
"glb_sha256": "451bd2edca71c9aa5c658ce8bc1353b7315530220f1d1458c037d5ea007b2794",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"074755f44edc3d836c20b8e254c61af805c3e7f3cbe1e65f76bb35bd3d66ca8f": {
"source": "Tree/ymir work/tree/christmastree3.spt",
"source_sha256": "074755f44edc3d836c20b8e254c61af805c3e7f3cbe1e65f76bb35bd3d66ca8f",
"glb": "074755f44edc3d836c20b8e254c61af805c3e7f3cbe1e65f76bb35bd3d66ca8f.glb",
"glb_sha256": "6445e98dd9760e3371dbcd13673b539b2c6be6450dc883605c50b3d5fac3f825",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"0ce9fe068b0a227cb4ce1c36dbeff5792ae6488f012c028b54ab89d7ef2b2f3b": {
"source": "Tree/ymir work/tree/n1_baobab_rt_winter.spt",
"source_sha256": "0ce9fe068b0a227cb4ce1c36dbeff5792ae6488f012c028b54ab89d7ef2b2f3b",
"glb": "0ce9fe068b0a227cb4ce1c36dbeff5792ae6488f012c028b54ab89d7ef2b2f3b.glb",
"glb_sha256": "d49a2346357486cab5368ecb1a32a8e728bd45318b301b86755c41d481f3712f",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"0d766295707ebd0e6af56bfc49ed1bca8bc11f7b15f79be44e221bbe9e18d53e": {
"source": "Tree/ymir work/tree/n1_beech_rt_winter.spt",
"source_sha256": "0d766295707ebd0e6af56bfc49ed1bca8bc11f7b15f79be44e221bbe9e18d53e",
"glb": "0d766295707ebd0e6af56bfc49ed1bca8bc11f7b15f79be44e221bbe9e18d53e.glb",
"glb_sha256": "031cde20e4c46d5f75cda94fd783834614d31584f7af36e9be2218dabc6a0cbb",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"e5497e163459d6ad06745e4b8a5ab95422fb59143e7aa76cf40ac65d242872b3": {
"source": "Tree/ymir work/tree/n1_beech_rt_winter02.spt",
"source_sha256": "e5497e163459d6ad06745e4b8a5ab95422fb59143e7aa76cf40ac65d242872b3",
"glb": "e5497e163459d6ad06745e4b8a5ab95422fb59143e7aa76cf40ac65d242872b3.glb",
"glb_sha256": "f542b8c33350569b80f2a3c88fc1db22be93ed51fbcfe46654d2b29fccaa413a",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"41997e16e6110e21096ffa2d85f0d2135e7b8a4ec82a869f899a6325776a2225": {
"source": "Tree/ymir work/tree/n1_beech_rt_winter03.spt",
"source_sha256": "41997e16e6110e21096ffa2d85f0d2135e7b8a4ec82a869f899a6325776a2225",
"glb": "41997e16e6110e21096ffa2d85f0d2135e7b8a4ec82a869f899a6325776a2225.glb",
"glb_sha256": "fd2fd865a012ba6f53b0bdd0eea8971c7858826cc4cbb08d3a6db1becf027fc0",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"b60fa3d6329d76d4b85da226e88c36241bf8fb4e3a3fc5e1316f0a64d7e2c6ad": {
"source": "Tree/ymir work/tree/n1_coloradobluespruce_rt_01.spt",
"source_sha256": "b60fa3d6329d76d4b85da226e88c36241bf8fb4e3a3fc5e1316f0a64d7e2c6ad",
"glb": "b60fa3d6329d76d4b85da226e88c36241bf8fb4e3a3fc5e1316f0a64d7e2c6ad.glb",
"glb_sha256": "82d906bb624b0041c3d687e3af51cff8ed524ba75e6432a5f293554d5d4ff137",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 5,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"265aef90b037111a8d08bd4f258501872be9fc0b4bacf6ad17820241c5f959a1": {
"source": "Tree/ymir work/tree/n1_coloradobluespruce_rt_02.spt",
"source_sha256": "265aef90b037111a8d08bd4f258501872be9fc0b4bacf6ad17820241c5f959a1",
"glb": "265aef90b037111a8d08bd4f258501872be9fc0b4bacf6ad17820241c5f959a1.glb",
"glb_sha256": "6265bd35d1da9f9daa34a15b34dd9cadc5af9d6346c0b9104b2c3d582c510225",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 5,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"2e3fe98b0ca1aae0ad512741f9465e42653b4c4df7345bc915e6b6b3bf80d27d": {
"source": "Tree/ymir work/tree/n1_coloradobluespruce_rt_03.spt",
"source_sha256": "2e3fe98b0ca1aae0ad512741f9465e42653b4c4df7345bc915e6b6b3bf80d27d",
"glb": "2e3fe98b0ca1aae0ad512741f9465e42653b4c4df7345bc915e6b6b3bf80d27d.glb",
"glb_sha256": "cbeb5930c57e2dbefbe9c50987275e7abc2b1ac9aa9cf190f60de71dae75f52b",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 5,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"4c3b7f1bc32ca54a581a22eb99a5229c066a2e3db84d73c4a260b41ecb4151d6": {
"source": "Tree/ymir work/tree/n1_commonolive_rt_winter.spt",
"source_sha256": "4c3b7f1bc32ca54a581a22eb99a5229c066a2e3db84d73c4a260b41ecb4151d6",
"glb": "4c3b7f1bc32ca54a581a22eb99a5229c066a2e3db84d73c4a260b41ecb4151d6.glb",
"glb_sha256": "a2fb958788a84301f9008e4efe1ea9994314d28b4b3954649c64b3033bf912c0",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"e63e6fd8aec38f5a4dea10edce507870f834cf1da5fbd8b5e789dc75f773957c": {
"source": "Tree/ymir work/tree/n1_montereycypress_rt_winter_01.spt",
"source_sha256": "e63e6fd8aec38f5a4dea10edce507870f834cf1da5fbd8b5e789dc75f773957c",
"glb": "e63e6fd8aec38f5a4dea10edce507870f834cf1da5fbd8b5e789dc75f773957c.glb",
"glb_sha256": "9dc165cb8f6417f9e27486db714809722b5e85ff4ed827b21a0fe1d52feee89f",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"bd813274fcc6fc3c8bf26a9bc85eb6b3c18d0a4a68dd0a51e13183f4634680da": {
"source": "Tree/ymir work/tree/n1_montereycypress_rt_winter_02.spt",
"source_sha256": "bd813274fcc6fc3c8bf26a9bc85eb6b3c18d0a4a68dd0a51e13183f4634680da",
"glb": "bd813274fcc6fc3c8bf26a9bc85eb6b3c18d0a4a68dd0a51e13183f4634680da.glb",
"glb_sha256": "f64d701e3ec752f566eef179ed62d613fd20d7dcc0bd86dcb95c1e3a0ef56c86",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"1af978f7efd1be5ba7af90056c4b933f74154c60a47a20fe88f4872aaf7bc862": {
"source": "Tree/ymir work/tree/n1_montereycypress_rt_winter_03.spt",
"source_sha256": "1af978f7efd1be5ba7af90056c4b933f74154c60a47a20fe88f4872aaf7bc862",
"glb": "1af978f7efd1be5ba7af90056c4b933f74154c60a47a20fe88f4872aaf7bc862.glb",
"glb_sha256": "f80e080f78042b751116ef056edf98501922de0bb46c6e0efb950e17115afbbe",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"36389501b6156b934f960d826ac2e3ecae86430cf53dc88dd6c30f5a8415aba9": {
"source": "Tree/ymir work/tree/n1_tulip_rt_winter_01.spt",
"source_sha256": "36389501b6156b934f960d826ac2e3ecae86430cf53dc88dd6c30f5a8415aba9",
"glb": "36389501b6156b934f960d826ac2e3ecae86430cf53dc88dd6c30f5a8415aba9.glb",
"glb_sha256": "ce0f23b6d5931ebeea06862a4ffef37b2274dc0b8643ec2f5768f1b0df3ae858",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 1,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"0df26ab085d443bccc61d8ec878b61117f64db3b3ffa31e70fa0fce6d5f59361": {
"source": "Tree/ymir work/tree/n1_whitepine_rt_low_01.spt",
"source_sha256": "0df26ab085d443bccc61d8ec878b61117f64db3b3ffa31e70fa0fce6d5f59361",
"glb": "0df26ab085d443bccc61d8ec878b61117f64db3b3ffa31e70fa0fce6d5f59361.glb",
"glb_sha256": "48bd12ddffdc0aee165a29bfc6fd0dcbfe1366aec5542bcee102bf8d5d82c3e2",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"ac5bb9b19999fe6d20793898edc7b709b1ba792e6cac2cc7ed5fcd64abdfb03f": {
"source": "Tree/ymir work/tree/n1_whitepine_rt_low_02.spt",
"source_sha256": "ac5bb9b19999fe6d20793898edc7b709b1ba792e6cac2cc7ed5fcd64abdfb03f",
"glb": "ac5bb9b19999fe6d20793898edc7b709b1ba792e6cac2cc7ed5fcd64abdfb03f.glb",
"glb_sha256": "fa1fe6e118c91b6d1276b17ed05297eed6349e4c29726e11a51de48252dbf787",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"34314fd26754a94e9215723fd970a09b360eef0f8fa7e93c55c44b6dadd4b4c8": {
"source": "Tree/ymir work/tree/n2_aloevera_rt_flowers_01.spt",
"source_sha256": "34314fd26754a94e9215723fd970a09b360eef0f8fa7e93c55c44b6dadd4b4c8",
"glb": "34314fd26754a94e9215723fd970a09b360eef0f8fa7e93c55c44b6dadd4b4c8.glb",
"glb_sha256": "96d7b310fa8d13e910b11e5524a1a30886e21c9e715290c64d1df2e9b4298d17",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"e0c3f6cfe9200acf3fe66fcc33fa52e2bbab55a48a35815178e74ee80e717c36": {
"source": "Tree/ymir work/tree/n2_aloevera_rt_flowers_02.spt",
"source_sha256": "e0c3f6cfe9200acf3fe66fcc33fa52e2bbab55a48a35815178e74ee80e717c36",
"glb": "e0c3f6cfe9200acf3fe66fcc33fa52e2bbab55a48a35815178e74ee80e717c36.glb",
"glb_sha256": "968300bd31c235557926343e46d7335ae75644aff6d0ca58f3a3e4a1963c5e51",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"f7c94a390c712edae5557d03849373c713d4ce28fba6080d87fc761216c4a26d": {
"source": "Tree/ymir work/tree/n2_bananatree_rt_01.spt",
"source_sha256": "f7c94a390c712edae5557d03849373c713d4ce28fba6080d87fc761216c4a26d",
"glb": "f7c94a390c712edae5557d03849373c713d4ce28fba6080d87fc761216c4a26d.glb",
"glb_sha256": "0b43c6087d26a19e6863a1d488eb6e3e524240793d850b27f844e4c194818afa",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"23b662d2b6d87d268460e4ae100b618ecfac6dd5262f59a2912e6dfe74530436": {
"source": "Tree/ymir work/tree/n2_bananatree_rt_02.spt",
"source_sha256": "23b662d2b6d87d268460e4ae100b618ecfac6dd5262f59a2912e6dfe74530436",
"glb": "23b662d2b6d87d268460e4ae100b618ecfac6dd5262f59a2912e6dfe74530436.glb",
"glb_sha256": "c5bd49676be213f69141b644b109a890f682fcc31ae8bf4fb0d078b72b28d8b8",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"93ded82028c0f2e97d0403ec045b89bbc1ac859ad8b84a2e1930e0503907b354": {
"source": "Tree/ymir work/tree/n2_cinnamonfern_rt_01.spt",
"source_sha256": "93ded82028c0f2e97d0403ec045b89bbc1ac859ad8b84a2e1930e0503907b354",
"glb": "93ded82028c0f2e97d0403ec045b89bbc1ac859ad8b84a2e1930e0503907b354.glb",
"glb_sha256": "027fe6300b18a9608ffe465d1e1c77783a8c05c17e99c2999e058095ed62de05",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 1,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"dc389cbfc25b6e3226e961e7e392ed96c7a789c0dfc1d392b0a1950e81f59f9e": {
"source": "Tree/ymir work/tree/n2_cinnamonfern_rt_02.spt",
"source_sha256": "dc389cbfc25b6e3226e961e7e392ed96c7a789c0dfc1d392b0a1950e81f59f9e",
"glb": "dc389cbfc25b6e3226e961e7e392ed96c7a789c0dfc1d392b0a1950e81f59f9e.glb",
"glb_sha256": "0c537775ba7e98e11b6d5b74a6665dfd5eb74c94f70ad39c80c4f0979b5246ac",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 1,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"82566982c7decbccbbb9e50f0b1d399a86bed94a968e0a78e2ce8b25989d2462": {
"source": "Tree/ymir work/tree/n2_cinnamonfern_rt_03.spt",
"source_sha256": "82566982c7decbccbbb9e50f0b1d399a86bed94a968e0a78e2ce8b25989d2462",
"glb": "82566982c7decbccbbb9e50f0b1d399a86bed94a968e0a78e2ce8b25989d2462.glb",
"glb_sha256": "1d38479f01b5dac599ff8dbc80dab1657671950047da6bae43e7a201562da106",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 1,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"af5abcd685bf92c9597740c9c85168f6104e62c12e30117ca5759bf555f2c6ff": {
"source": "Tree/ymir work/tree/n2_coconutpalm_rt_01.spt",
"source_sha256": "af5abcd685bf92c9597740c9c85168f6104e62c12e30117ca5759bf555f2c6ff",
"glb": "af5abcd685bf92c9597740c9c85168f6104e62c12e30117ca5759bf555f2c6ff.glb",
"glb_sha256": "3344943038999faf012f8d1520b2feaaab367c06a6b24748685a233b89f7ec70",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"e45bdc521daaeb53191a995485513b046b0602b60d55185e465ded20a8242828": {
"source": "Tree/ymir work/tree/n2_coconutpalm_rt_02.spt",
"source_sha256": "e45bdc521daaeb53191a995485513b046b0602b60d55185e465ded20a8242828",
"glb": "e45bdc521daaeb53191a995485513b046b0602b60d55185e465ded20a8242828.glb",
"glb_sha256": "3f46282c3c8d094206d516475cf1e086e87af584c4537c3d48da1e2ee544a248",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"75b89b54281bedd408be91863cfbf1131a7c9fce5c0e153e601db3c7195c8dd6": {
"source": "Tree/ymir work/tree/n2_coconutpalm_rt_03.spt",
"source_sha256": "75b89b54281bedd408be91863cfbf1131a7c9fce5c0e153e601db3c7195c8dd6",
"glb": "75b89b54281bedd408be91863cfbf1131a7c9fce5c0e153e601db3c7195c8dd6.glb",
"glb_sha256": "1e19c9fe65ef02d0277206f4737eeafec5541ba6bc2619dcb015e31bf8d76800",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"8a6d6468b7f97040b08ba70718650abbd0d0c4684fa6e092b0a603717ed0ec28": {
"source": "Tree/ymir work/tree/n2_curlypalm_rt_01.spt",
"source_sha256": "8a6d6468b7f97040b08ba70718650abbd0d0c4684fa6e092b0a603717ed0ec28",
"glb": "8a6d6468b7f97040b08ba70718650abbd0d0c4684fa6e092b0a603717ed0ec28.glb",
"glb_sha256": "a0859095e4b20c71d33e3cde76696584880df357d3642c07d79355cb0d732809",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"de5e22bb1610c61bcb00e3835698b6a9b50b67263a438b9ba7400157dce42b5e": {
"source": "Tree/ymir work/tree/n2_curlypalm_rt_02.spt",
"source_sha256": "de5e22bb1610c61bcb00e3835698b6a9b50b67263a438b9ba7400157dce42b5e",
"glb": "de5e22bb1610c61bcb00e3835698b6a9b50b67263a438b9ba7400157dce42b5e.glb",
"glb_sha256": "c8b1d7ad7faf78e14b0c50f7c33dd9712a2c79dbec54a574945e2b20f7e22d4a",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"97ce0c94956eeafad580424081ae4ab767f01e948ab9461a9b48e5cf8bd824af": {
"source": "Tree/ymir work/tree/n2_curlypalm_rt_03.spt",
"source_sha256": "97ce0c94956eeafad580424081ae4ab767f01e948ab9461a9b48e5cf8bd824af",
"glb": "97ce0c94956eeafad580424081ae4ab767f01e948ab9461a9b48e5cf8bd824af.glb",
"glb_sha256": "700aa9caba46ed1b7141d58a653d77ea9eb96911172d1a4dacb414328c8f18ee",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"6e24a4896134057a1bc495f0f8af32ca74a7b51e0d54baa612f6dcd36a99b54e": {
"source": "Tree/ymir work/tree/n2_datepalm_rt_01.spt",
"source_sha256": "6e24a4896134057a1bc495f0f8af32ca74a7b51e0d54baa612f6dcd36a99b54e",
"glb": "6e24a4896134057a1bc495f0f8af32ca74a7b51e0d54baa612f6dcd36a99b54e.glb",
"glb_sha256": "7c2a38b68b0b33d46db13830c12302a1962584e51e628386d654509998e1795f",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"55de8a592ad3d9294583bcf27c80fba3fe71afdc669c5657ee7cc77a4002d255": {
"source": "Tree/ymir work/tree/n2_datepalm_rt_02.spt",
"source_sha256": "55de8a592ad3d9294583bcf27c80fba3fe71afdc669c5657ee7cc77a4002d255",
"glb": "55de8a592ad3d9294583bcf27c80fba3fe71afdc669c5657ee7cc77a4002d255.glb",
"glb_sha256": "e7391ec2d87aa7df6f87943e4faaf91de5951dccb045e6935844c997f7de1179",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"2007d4ba90f12b8a00d2a0a539954e420c37f2151f438c5ad06eb63a78fe853d": {
"source": "Tree/ymir work/tree/n2_datepalm_rt_03.spt",
"source_sha256": "2007d4ba90f12b8a00d2a0a539954e420c37f2151f438c5ad06eb63a78fe853d",
"glb": "2007d4ba90f12b8a00d2a0a539954e420c37f2151f438c5ad06eb63a78fe853d.glb",
"glb_sha256": "ec41d7cb6c58864cc32bccca5e364d8fa4746501d4bf9a37d3f64c611834e0c9",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 2,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"2bc2d707e302c58d099e64615ae127371c93700f91d52d31e8ec6f63010e2c0f": {
"source": "Tree/ymir work/tree/n2_joshuatree_rt_01.spt",
"source_sha256": "2bc2d707e302c58d099e64615ae127371c93700f91d52d31e8ec6f63010e2c0f",
"glb": "2bc2d707e302c58d099e64615ae127371c93700f91d52d31e8ec6f63010e2c0f.glb",
"glb_sha256": "27937a1b7cb30186a7041b4c8e405e531a3152004d5365082d9e0908e3998670",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 4,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"111cb80abecb5c5a35aaa8315ad6ec4c59772af927729780e0add02c9ba262b5": {
"source": "Tree/ymir work/tree/n2_joshuatree_rt_02.spt",
"source_sha256": "111cb80abecb5c5a35aaa8315ad6ec4c59772af927729780e0add02c9ba262b5",
"glb": "111cb80abecb5c5a35aaa8315ad6ec4c59772af927729780e0add02c9ba262b5.glb",
"glb_sha256": "6082d34315db4cd58e606b4b35cdbd07ab374d7587580512e07b0e27cfdedb11",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 4,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"a89a40ac942ee7a3d701a6835450a513a336e9835d20750036374e27c259b2d5": {
"source": "Tree/ymir work/tree/n2_joshuatree_rt_03.spt",
"source_sha256": "a89a40ac942ee7a3d701a6835450a513a336e9835d20750036374e27c259b2d5",
"glb": "a89a40ac942ee7a3d701a6835450a513a336e9835d20750036374e27c259b2d5.glb",
"glb_sha256": "5964a02431360700dabd0f88cddde3419141f9f2626b48068b80a6da4110ddf6",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 6,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"19685a989e47f21d14fb9c2f2e71fd86930f8841a3dafae42b608988c3317119": {
"source": "Tree/ymir work/tree/n2_palmetto_rt_01.spt",
"source_sha256": "19685a989e47f21d14fb9c2f2e71fd86930f8841a3dafae42b608988c3317119",
"glb": "19685a989e47f21d14fb9c2f2e71fd86930f8841a3dafae42b608988c3317119.glb",
"glb_sha256": "6fc27607202b42a36b0cea01228e05e3ffd36fb21800ed5ba171831c8a4b7ae3",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 5,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"e167a7cb1d19c9d118be00d5dff39221695a582ec7caeabfae1f7826aff0d823": {
"source": "Tree/ymir work/tree/n2_palmetto_rt_02.spt",
"source_sha256": "e167a7cb1d19c9d118be00d5dff39221695a582ec7caeabfae1f7826aff0d823",
"glb": "e167a7cb1d19c9d118be00d5dff39221695a582ec7caeabfae1f7826aff0d823.glb",
"glb_sha256": "1597e961990b4bae697f92238b93978910c39d839f8585baaa44cda135197cde",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 5,
"leaves": "static_extracted_cards",
"wind": "not_baked"
},
"d096c9a78ede5fb7db97685b6dab4967f9dad9a24dbf147b762faffdf1281fc4": {
"source": "Tree/ymir work/tree/n2_palmetto_rt_03.spt",
"source_sha256": "d096c9a78ede5fb7db97685b6dab4967f9dad9a24dbf147b762faffdf1281fc4",
"glb": "d096c9a78ede5fb7db97685b6dab4967f9dad9a24dbf147b762faffdf1281fc4.glb",
"glb_sha256": "cb67edad8c58da648f2e7b19e5cce0d1ec665bb7738f0fa91ac0fd7dab1176ec",
"native_extractor": "spt_extract.exe",
"extractor_runs": 2,
"primitives": 5,
"leaves": "static_extracted_cards",
"wind": "not_baked"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+10 -24
View File
@@ -6,11 +6,11 @@ window = {
"name" : "SystemDialog",
"style" : ("float",),
"x" : (SCREEN_WIDTH - 200) /2,
"y" : (SCREEN_HEIGHT - 288) /2,
"x" : SCREEN_WIDTH/2 - 100,
"y" : SCREEN_HEIGHT/2 - 114,
"width" : 200,
"height" : 288,
"height" : 258,
"children" :
(
@@ -22,7 +22,7 @@ window = {
"y" : 0,
"width" : 200,
"height" : 288,
"height" : 258,
"children" :
(
@@ -39,27 +39,13 @@ window = {
"over_image" : ROOT + "XLarge_Button_02.sub",
"down_image" : ROOT + "XLarge_Button_03.sub",
},
{
"name" : "mall_button",
"type" : "button",
"x" : 10,
"y" : 57,
"text" : uiScriptLocale.SYSTEM_MALL,
"text_color" : 0xffF8BF24,
"default_image" : ROOT + "XLarge_Button_02.sub",
"over_image" : ROOT + "XLarge_Button_02.sub",
"down_image" : ROOT + "XLarge_Button_02.sub",
},
{
"name" : "system_option_button",
"type" : "button",
"x" : 10,
"y" : 87,
"y" : 57,
"text" : uiScriptLocale.SYSTEMOPTION_TITLE,
@@ -72,7 +58,7 @@ window = {
"type" : "button",
"x" : 10,
"y" : 117,
"y" : 87,
"text" : uiScriptLocale.GAMEOPTION_TITLE,
@@ -85,7 +71,7 @@ window = {
"type" : "button",
"x" : 10,
"y" : 147,
"y" : 117,
"text" : uiScriptLocale.SYSTEM_CHANGE,
@@ -98,7 +84,7 @@ window = {
"type" : "button",
"x" : 10,
"y" : 177,
"y" : 147,
"text" : uiScriptLocale.SYSTEM_LOGOUT,
@@ -111,7 +97,7 @@ window = {
"type" : "button",
"x" : 10,
"y" : 217,
"y" : 177,
"text" : uiScriptLocale.SYSTEM_EXIT,
@@ -124,7 +110,7 @@ window = {
"type" : "button",
"x" : 10,
"y" : 247,
"y" : 217,
"text" : uiScriptLocale.CANCEL,
+15 -1
View File
@@ -10,10 +10,21 @@ set -euo pipefail
cd "$(dirname "$0")"
REPO="$(pwd)"
CFG="${1:-debug}"
OUT="$REPO/build/export"
OUT="${MT_MAC_OUTPUT_DIR:-$REPO/build/export}"
APP="$OUT/mtgodot-poc.app"
GODOT="${GODOT:-godot}"
MAC_ARCHES="${MT_MAC_ARCHES:-universal}"
ENGINE_OVERRIDE="${MT_MAC_ENGINE:-}"
if [ -n "$ENGINE_OVERRIDE" ]; then
test -x "$ENGINE_OVERRIDE"
ENGINE_INFO="$(lipo -info "$ENGINE_OVERRIDE")"
case "$MAC_ARCHES:$ENGINE_INFO" in
universal:*x86_64*arm64*|universal:*arm64*x86_64*|arm64:*arm64*|x86_64:*x86_64*) ;;
*) echo "错误:自定义引擎架构不匹配:$ENGINE_INFO" >&2; exit 1 ;;
esac
echo "自定义引擎:$ENGINE_OVERRIDE"
"$ENGINE_OVERRIDE" --version
fi
BUILD_TYPE="Debug"
if [ "$CFG" = release ]; then
BUILD_TYPE="Release"
@@ -55,6 +66,9 @@ ditto "$REPO/bgm" "$APP/Contents/Resources/bgm"
test -f "$APP/Contents/Resources/assets/asset_index.txt"
echo "== 4. 签名(默认 ad-hocCODESIGN_IDENTITY 可切正式证书)=="
if [ -n "$ENGINE_OVERRIDE" ]; then
cp "$ENGINE_OVERRIDE" "$APP/Contents/MacOS/mtgodot-poc"
fi
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:--}"
ENTITLEMENTS="$REPO/macos.entitlements"
test -f "$ENTITLEMENTS"
+150 -7
View File
@@ -355,8 +355,10 @@
普攻 `_swing_skill=0`),被击退目标记 `_victim_flush``_emit_swing()` 改为**只有拿不到
命中窗**(headless / 缺资源)才在挥击时立即发 `CG_ATTACK``_flush_victim_list()`(对齐
`PythonPlayerEventHandler.cpp:194`)帧末把 `_victim_flush`(≤16)打包 `client.sync_positions()`
几何判定为近似:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面 `±60°`
`(reach+20+45) cm` 弧内;真实动态圆柱扫掠需骨骼矩阵 + defending sphere(随 §3.1 留桩)。
~~几何判定为近似:`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面 `±60°`
`(reach+20+45) cm` 弧内;真实动态圆柱扫掠需骨骼矩阵 + defending sphere(随 §3.1 留桩)。~~
**增量 130 已替换**为 `.msa` 刀尖扫掠 Z 圆柱 vs `.msm` 防御球的 `detect_z_cylinder` 判定(见 §3.5 修改 4 /
C.5 增量 130)。
`combat_fx_test.gd``FakeNetWorld` + 6 段断言(`CG_ATTACK` 延后 / 窗内命中 / `CG_SYNC_POSITION` /
COMBO 单次 / 正面弧 / 越窗收起)。C++ 21/21 + 7 个 GDScript 回归全绿。留给增量 84:武器种类 →
`combo_motion_mode` 映射。
@@ -700,7 +702,7 @@
新增 `_attack_ctx()``client.get_duel()` / `get_pvp_relations()` / `get_guild_wars()` /
`get_party()` 装配 ctxobserver / main_vid / pk_mode / duel_mode / duel_opponents /
pvp_pairs / gvg_pairs / party_vids)。5 处调用点(含 `__ReserveProcess_ClickActor` 第 3 步、
`_can_shot``_hit_geometry`)无签名变化。
`_can_shot``_hit_geometry`;增量 130 起后者并入 `_normal_attack_process`)无签名变化。
④ 自检 `project/entity_rules_test.gd`(第 15 个 canonical25 组断言);`combat_fx_test` /
`netplay_test` / `fly_test` 的怪物夹具补 `ch_type: 2`(此前无 `ch_type``_entity_kind` 归 PC
桩按 PC 也「可打」;1:1 规则下和平同帝国 PC 不可打 → 夹具需明确怪类别,与真实服务器一致)。
@@ -2352,9 +2354,23 @@ OnHit(uSkill, victimActor, isSendPacket):
**只有拿不到命中窗数据**(headless / 缺资源)才在挥击时立即发 `CG_ATTACK`,有窗时交给
`_attack_process``_flush_victim_list()`(对齐 `PythonPlayerEventHandler.cpp:194`)在
`_process` 帧末把 `_victim_flush`(≤ `SYNC_POSITION_LIMIT` 16)打包 `client.sync_positions()`
几何判定是 **近似**`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面
~~几何判定是 **近似**`reach = max(WeaponLength, 窗内采样最大水平偏移)`,命中 = 目标落在正面
`±60°``(reach + 20 + 45) cm` 弧内;真实的动态圆柱-圆柱扫掠需要骨骼矩阵 + defending
sphere 数据(同 §3.1 actor 碰撞一起留桩)。`combat_fx_test.gd``FakeNetWorld` +
sphere 数据(同 §3.1 actor 碰撞一起留桩)。~~
**增量 130 升级为逐行判定**:删除 `_hit_deduped` / `_hit_geometry`,新增 `_normal_attack_process()`
对齐 `__NormalAttackProcess`
- 距离门 `v3Distance = (dX, dZ, dZ)`:出货代码平面 Y 不参与、高度差算两次,原样保留;上限 300 cm,
`IS_HUGE_RACE` 为 500 cm。
- 命中窗 `[start,end]``[t-dt, t]` 相交后查 `m_HitDataMap`COMBO 同窗不再判,其余等 `fInvisibleTime`
- 窗内每个采样的刀尖 `last_pos → pos` 按攻击者朝向转到世界 cm,作为半径 20 的动态 Z 圆柱,
对受击方每个防御球做 `HitCollision.detect_z_cylinder()`(逐行移植
`DetectCollisionDynamicZCylinderVSDynamicZCylinder` + `IntersectLineSegments`)。
- 碰上后 `_register_hit()``_process_attack_success()`
- `_register_hit` 修正为 `map::insert` 语义:已有目标不覆盖冷却,插入后超上限才返回 FALSE。
- 防御球取 `.msm``CollisionType 3``HitCollision.parse_msm_defending`):PC 读
`root/msm/<class>_<m|w>.msm`,怪物读模型目录 `.msm`。上一帧球心缓存在 `_def_last`
- 判定时间改为 `_attacking_elapsed() = (_local_time - _motion_start_t) * _atk_speed_factor`
`GetAttackingElapsedTime`);`_local_time``_advance_local_time()` 先扣 `m_fDelay` 再推进。`combat_fx_test.gd``FakeNetWorld` +
命中窗几何判定 / `CG_ATTACK` 延后 / `CG_SYNC_POSITION` / COMBO 单次 / 正面弧 / 越窗收起 6 段断言。
5. `OnAttack` 事件(动作真正起手时)发 `CG_CHARACTER_MOVE{FUNC_COMBO, wMotionIndex}`
`CG_ATTACK` 是**两个不同时刻**的两个包,不能合并。**[已在增量 78 `_do_attack_swing` 落地]**
@@ -2395,8 +2411,10 @@ OnHit(uSkill, victimActor, isSendPacket):
修改 6 完成(增量 84`motion_mode_for` + `_refresh_motion_mode` 按装备武器 subtype / 骑乘 / 变身
`combo_motion_mode`,对齐 `RefreshState`);修改 7 完成(增量 85`_clickable_distance()` 弓箭
`__GetBowRange()` 分支 + `_bow_range_cm()` + `POINT_BOW_DISTANCE` 加成)。C++ 21/21 + 7 GDScript
全绿。仍待:几何判定升级为真实动态圆柱扫掠(需骨骼矩阵 + defending sphere,随 §3.1 actor 碰撞)、
服务端合法性校准。§3.6 箭矢 `MOTION_EVENT_TYPE_FLY` 事件已在增量 86 落地(见 §3.6)。
全绿。~~仍待:几何判定升级为真实动态圆柱扫掠(需骨骼矩阵 + defending sphere,随 §3.1 actor 碰撞)、
服务端合法性校准。~~ —— 增量 130 已落地:Z 圆柱 vs `.msm` 防御球,以及 `__ProcessDataAttackSuccess`
的完整效果(见修改 4 / §3.7 / C.5 增量 130)。仍待:服务端合法性校准;防御球当前是模型本地近似,
未跟骨骼矩阵走。§3.6 箭矢 `MOTION_EVENT_TYPE_FLY` 事件已在增量 86 落地(见 §3.6)。
---
@@ -2512,6 +2530,35 @@ handler 由 `__OnPressActor` 通过
`netplay_test` / `netbridge_test` / `gamescene_test` / `player_motion_test`
全绿。
**增量 130(受击方反应 1:12026-09-12**
- 新增 `project/ui/hit_reaction.gd`,由 PlayerView / MobView 共用。`good()` / `greate()` / `stone()`
逐分支对齐 `__HitGood` / `__HitGreate` / `__HitStone`
- 击倒中不再换受击动作,Greate 在起身中也不换。
- 晕眩时:Good → `Die`(死亡动作由服务端驱动,seam);Greate 只播 `DAMAGE_FLYING(_BACK)`
并置 `m_isRealDead`,不起身。
- `isLock`(普攻 / 连击 / 技能 / 表情 / 钓鱼等)只抖动不换动作;Greate 在 `IsUsingSkill` 时也只抖。
- `scalar = dot(攻击方朝向, 受击方朝向)`:小于 0 走正面 `DAMAGE` / `DAMAGE_FLYING → STAND_UP`
否则先试 `_BACK` 版,缺资源退回正面版。
- 动作链 = `InterceptOnceMotion` + `PushOnceMotion` + 链尾 `PushLoopMotion`,回到受击前的
wait / walk / run。
- `__Shake(100)`100 ms 内模型位置每帧偏移 ±`rand()%10` cm,结束复位。
- `InsertDelay(fStiffenTime)``time_scale = 0` 冻结,到期恢复该动作的 `fSpeedRatio`;冻结期间新绑定的
动作同样保持冻结。
- PC 受击文件按 `playersettingmodule` 注册表取,同名多份等权随机:
- `damage` / `damage_1`BACK 版 `damage_2` / `damage_3`
- `damage_flying` / `back_damage_flying`
- `falling_stand` / `back_falling_stand`
- 怪物在 `STATE_MOTIONS` 新增 `BACK_DAMAGE` / `FRONT_KNOCKDOWN` / `BACK_KNOCKDOWN` / `FRONT_STANDUP` /
`BACK_STANDUP`
- `is_in_hit_reaction()` 改为返回 `_hit.active`:整条链播完才清 `net_play._knock_down`。原来的
`_state == "damage"` 分支并入链尾处理。
- 击退:`net_world.push_victim()``physics_push.gd`(逐行移植 `CPhysicsObject` +
`CEaseOutInterpolation`),每帧叠加位移。
- `blending_position()` = `GetBlendingPosition`,供 `OnHit``CG_SYNC_POSITION` 使用。
- 推动期间服务端位置变化:以新位置为基准继续叠加。
- 推动结束后服务端位置一变,即丢弃偏移。
---
### 3.8 技能缺少统一合法性检查
@@ -10145,3 +10192,99 @@ dx² + dy² > s_fLimitDistance → 不播
seam ⑨ 名字与气泡合并为单尾标链表;完整 Quest / fishing / dungeon / mount / observer 玩法与
复杂 UI 分支;真实服务器、真机、纯 pack、双架构和正式签名验收。
```
### C.5 批次记录 · 增量 130W2 / §3.5 · §3.7 战斗与 40250 对齐:命中判定、命中效果、受击反应、攻速、连击类型、技能包)
```text
批次:增量 130 —— 逐项修正与 40250 参考端战斗实现的差异(2026-09-12)。每项先写失败断言再改实现。
1. 连击类型:
net_play.gd 监听 client.combo_changed,由 _on_combo_changed() 处理,对齐
CPythonPlayer::SetComboSkillFlag
· 查连击技能 122 的等级;缺槽或等级 <= 0 直接返回,不改 combo type。
· 否则 _combo_type = enabled ? MIN(level, 2) : 0。
· 不在本地伪造技能等级。
2. 命中判定:
删除 _hit_deduped / _hit_geometryreach + 正面 ±60° 近似)。
· 新增 project/hit_collision.gd,逐行移植:
IntersectLineSegments / FindNearestPointOnLineSegment / FindNearestPointOfParallelLineSegments /
AdjustNearestPoints / DetectCollisionDynamicZCylinderVSDynamicZCylinder
另含 .msm CollisionType 3 防御球解析与 IS_HUGE_RACE。
· net_play._normal_attack_process() 流程:
(dX, dZ, dZ) 距离门 300 / 500 cm → 命中窗相交 → m_HitDataMap →
刀尖采样半径 20 的 Z 圆柱 vs 受击方防御球(上一帧球心存 _def_last)。
· PlayerView 取 root/msm/<class>_<m|w>.msmMobView 取模型目录 .msm,都经 get_defending_spheres() 提供。
· 没有 .msm 的占位节点退化为单球 HIT_FALLBACK_SPHEREseam)。
3. _register_hit
改为 map::insert 语义:已登记的目标不刷新冷却,插入后超上限才返回 FALSE。
4. 攻速:
_atk_speed_factorbAttackSpeed/100>1100 → 0)同时用于两处:
· _attacking_elapsed() = (_local_time - _motion_start_t) * fSpeedRatio
· PlayerView.play_attack_motion(mode, index, speed_ratio) 的 time_scale
play_attack_motion 按 MOTION_MODE 目录绑定 attack(_1) / combo_0N,模式目录缺该段时回退 general。
5. 连击清零:
删除本地连击超时清空。PlayerView / MobView 新增 motion_bound(state) 信号(__SetMotion 尾),
由 net_play._on_motion_bound() 处理:
· 新动作带 AttackingData → 清 m_HitDataMap。
· 连击中换到无 ComboInputData 的动作 → _combo_index = 0。
6. __ProcessDataAttackSuccess_process_attack_success):
· InsertDelay(fStiffenTime):攻击者写 _delay_advance_local_time 先扣),视图 time_scale 冻结;
受击方同样 insert_delay,并记 m_fInvisibleTime_victim_invisible_until,期间 AttackingProcess 跳过它)。
· 击退门 __CanPushDestActor_can_push):
建筑 / 门 / 石头 / NPC / 巨型不推;晕眩必推;否则要求 owner 为主角且 owner 时间 <= 3 s。
通过后 net_world.push_victim() 推动(physics_push.gd 逐行移植 CPhysicsObject + CEaseOutInterpolation)。
OnHit 时目标仍在推动中 → GetBlendingPosition 进 CG_SYNC_POSITION 列表。
· 命中特效 blow_1_low.mse
建筑 / 门放在攻击者身前 30 cm、不转向;巨型放在命中点;其余放在受击方。
game_scene 装配 net_play.fx = fx、fx_parent = Entities。
· 受击反应:石头 / 门 → hit_stoneHIT_TYPE_GOOD → hit_goodHIT_TYPE_GREAT → hit_greate
scalar = cos(攻击方 yaw 受击方 yaw)。状态机 ui/hit_reaction.gd 见 §3.7 增量 130。
· 最后 _on_hitSetTarget + CG_ATTACK。
7. 技能包:
ClassicSession::send_use_skill 删除夹带的 CG_FLY_TARGETING,只发 TPacketCGUseSkill(对齐
SendUseSkillPacket)。飞行目标仍由施法方的 CG_ADD_FLY_TARGETING / OnSetFlyTarget 发送。
net_classic_session_test 新增 "use skill wire: no extra fly targeting" 断言。
8. 既有测试随行为调整:
受击 damage 按 playersettingmodule 注册为 damage / damage_1 等权随机。
· gpu_pose_bounds_testCPU / GPU 两个视图绑定前用同一 seed,保证选中同一段。
· race_motion_assembly_testdamage 允许任一注册变体。
这两个测试在本批次改动后曾失败(CPU / GPU 选到不同段、断言写死 damage.msa),调整后通过。
回归(godot --headless --path project --script <test>.gd):
新增 PASShit_collision_test、physics_push_test、hit_view_test、net_world_push_test。
PASScombat_fx_test、gamescene_test、netplay_test、skill_test、player_move_test、input_key_test、
weapon_attach_test、mob_view_test、player_motion_test、fly_test、remote_player_test、
net_world_vis_test、forest_mob_render_test、p2b_test、playable_harness_test、playable_combat_test、
gpu_pose_bounds_test、race_motion_assembly_test。
已知失败(与本批次无关):equip_model_test "armor 11000 -> values[3]=0 回退 =vnum"(夹具期望过期)。
forest_map_render_test 是 extends Node 的 MT_TEST_MODE 入口,不能用 --script 直跑,本批次未运行。
ctest --test-dir build22/23 PASS。net.classic_session 仍是既有 25 条失败
quest / duel / messenger / exchange / guild / safebox / mall / sync / ground item / despawn),
都与 use_skill 无关;新增的 use skill 断言通过。
验收边界:
已验证(离线):Z 圆柱几何、.msm 防御球解析、命中表 / 上限、攻速缩放、连击类型与清零、
硬直 / 击退 / 同步位置 / 特效挂载、受击动作链 / 抖动 / 冻结、技能包线格式。
未验证:真实服务器下的命中与击退手感、真机视觉。
seam
· 击退没有地形 / 角色碰撞,也无 AdjustCollisionWithOtherObjects、溅射、IsResistFallen。
· owner 时间取客户端观测时刻。
· 防御球按模型本地坐标、不跟骨骼矩阵;占位节点退化为单球。
· 远端角色不跑 AttackProcess;晕眩 → Die 不在本地播。
· 攻击 / 受击动作变体等权随机;受击动作固定取 general 目录。
· 抖动用模型位置偏移,InsertDelay 用 time_scale 冻结。
· 受击链期间的循环动作请求延后到链尾。
· 推动结束后服务端位置一变即丢弃偏移。
· 本地 GC 伤害包仍直接走 set_anim_state("damage")。
改动 C++ 后已重建 mtgodot 扩展;脚本改动需要重新导出 PCK 才进包。
下一批次:
击退世界碰撞与 IsResistFallen;防御球跟骨骼矩阵;远端 AttackProcess;真实服务器战斗验收。
```
+26 -8
View File
File diff suppressed because one or more lines are too long
+89
View File
@@ -0,0 +1,89 @@
# 40250 客户端对拍缺陷发现与修复工程指南 (CLIENT-PARITY-AUDIT-AND-FIX-GUIDE)
> **目标**:建立系统化的排查、探针探测与修复流程,解决 Godot 客户端从 40250 移植过程中由于“管线实现不完整”、“只实现裸数据漏掉复合层”导致的隐性细节缺陷(典型案例如:桥梁可行走但高度采样遗漏 `.mdatr` 导致掉落穿透)。
---
## 一、问题本质剖析:为何会出现此类缺陷?
在 Metin2 40250 原版中,几乎所有物理、移动、战斗与视觉表现都是**“多层复合叠加”**的。在向 Godot 移植的早期原型阶段,很多系统往往只实现了最底层的第 1 层,缺少了第 2 层和第 3 层的复合逻辑:
```mermaid
flowchart TD
subgraph 原版 40250 表现
L1["基础数据层 (如: PRTerrain height.raw 裸高度图)"]
L2["静态实体层 (如: CGraphicThingInstance / .mdatr 高度多边形)"]
L3["动态状态层 (如: 骨骼动作高度、骑乘偏置、水体/浮力判定)"]
L1 --> L2 --> L3 --> R1["最终真实表现 (如: 角色稳稳立于桥面)"]
end
subgraph 缺陷实现模式
D1["基础数据层 (fmt::terrain_height_at)"]
D1 --> R2["直接作为最终结果 (角色跌落到桥底裸地形)"]
end
```
如果仅依赖玩家跑图偶然触发,发现成本极高且容易重复反弹。必须转为**白盒走查、探针自动化与协议日志审计**。
---
## 二、高危缺陷领域清单与 40250 真实机制对照
| 领域 | 常见隐性缺陷表现 | 40250 原版真实机制(唯一真相源) | 关键代码位置 |
| :--- | :--- | :--- | :--- |
| **1. 空间与地貌 (Terrain & Objects)** | • 桥梁、楼梯、门廊台阶悬空或掉落穿透<br>• 水陆交界处判断错误(干地游泳或踩深水不减速/不可钓鱼)<br>• 地下城/山洞进出门廊穿透掉落 | • `CMapOutdoor::GetHeight` 查询 `.mdatr` 中的 `heights` 网格多边形,与裸高度图取 `fMAX`<br>• `attr.atr` 中的 `ATTRIBUTE_WATER` 与水面多边形绝对高度差<br>• `CDungeonBlock::GetObjectHeight` | `GameLib/MapOutdoor.cpp`<br>`EterLib/AttributeInstance.cpp`<br>`GameLib/AreaTerrain.cpp` |
| **2. 物理与移动 (Physics & Locomotion)** | • 遇到陡坡时直接穿山或以相同速度瞬移上山<br>• 被怪物或技能击退(Knockback)时穿透墙体飞出地图<br>• 自动寻路走直线卡死在栅栏/岩石缝隙 | • `ActorInstanceSync.cpp` 坡度倾角与重力滑落向量计算<br>• `ActorInstanceCollisionDetection.cpp` 击退时沿障碍物法线切向滑动<br>• `CMapOutdoor::is_blocked` 的栅格阻挡光栅化与逐轴滑动降级 | `GameLib/ActorInstanceSync.cpp`<br>`GameLib/ActorInstanceCollisionDetection.cpp` |
| **3. 动作与挂点 (Animation & Attachment)** | • 武器/盾牌朝向偏转或拿反、脱离手部<br>• 骑马时双手未握缰绳或双腿穿透马鞍<br>• 更换头盔/发型时头部穿模或发色异常 | • `CItemData::GetItemScale` 与骨骼挂点矩阵 `m_matBone` 变换<br>• `InstanceBaseMotion.cpp` 的骑乘姿势 override (`COMBO_TYPE_HORSE`)<br>• `CRaceData::SetHair` 的 MSM 顶点隐藏机制与 Alpha Blending 管道 | `GameLib/ActorInstanceAttach.cpp`<br>`UserInterface/InstanceBaseMotion.cpp`<br>`GameLib/RaceData.cpp` |
| **4. 战斗与射弹 (Combat & Flying)** | • 弓箭/法球穿透墙体或直接打入地底<br>• 普攻挥刀动作还没劈下来伤害数值已提前跳出<br>• 怪物死后不立刻倒地或死亡姿势嵌入地面 | • `CFlyingInstance::Update` 中的 `BackgroundHit` 碰撞(与地面/建筑交点)<br>• `.msa` 中的 `MotionEvent`(命中帧 `HIT` 严格帧事件驱动)<br>• `ActorInstanceBlend.cpp` 死亡动作的地面投影与骨骼终帧冻结 | `GameLib/FlyingInstance.cpp`<br>`GameLib/ActorInstanceBattle.cpp`<br>`GameLib/ActorInstanceBlend.cpp` |
| **5. 交互与拾取 (Picking & Decal)** | • 鼠标点击桥面、屋顶或 NPC 拾取点偏移到桥下<br>• 地面范围技能光圈(Decal)漏到桥底或悬空 | • `CMapOutdoor::GetPickingPointWithRay` 射线先求交物体再求交地形<br>• `CTerrainDecal` 的多边形表面投影裁切 | `GameLib/MapOutdoor.cpp`<br>`GameLib/TerrainDecal.cpp` |
---
## 三、四大系统化排查手段(主动发现)
### 手段 1:全图几何与阻挡坐标探针(Headless Coordinate Probes
由于地图静态放置数据由 `areadata.txt``attr.atr` 完全定义,无需人工跑图,可由自动化脚本扫盘检测:
1. **特殊静态模型巡检**
- 提取地图上全部引用了含有 `.mdatr`(带有 `heights` 网格)的物体(桥梁、台阶、悬崖栈道等)。
- 在该物体几何中心上方向下打垂直射线或采样 `sample_height(x, z)`
- **断言**:采样高度必须落在物体表面标高区间内,与裸地形高度应有明显的阶跃高差;若 `sample_height == terrain_height`,直接报警并定位对应模型路径与坐标。
2. **地貌高度一阶导数(梯度突变)扫描**
- 按 0.5m 步长对可通行路网进行网格采样。
- 检测相邻点高度差:若连续两点高度差 $> 2.5\text{m}$ 且未标记 `ATTRIBUTE_BLOCK`,判定为地貌穿透或缝隙掉落点。
### 手段 240250 核心链路白盒对拍(Static Call-Graph Audit
以 40250 源码的关键函数为索引,走查当前客户端对应的实现:
- **查阅 40250 源码**`40250/Server Client TMP4/ClientVS22/source/`
- **重点审查模式**
- 40250 中包含 `fMAX(...)``min(...)`、多层分支判断的函数,当前客户端是否只实现了其中单条简单路径?
- 40250 中从资源读取参数的逻辑(如 `.msa` 帧事件、`.msm` 挂点、`.mdatr` 碰撞体),当前客户端是否被硬编码(hardcoded)常量替代?
### 手段 3:未决协议与资源缺失日志审计(Log & Protocol Auditing
在测试与实机运行期间,关注并收集控制台日志中的警告:
- 过滤关键 Warning`unhandled packet``missing model``cannot find mdatr``mesh has no height`
- 检查服务端发送的所有 GC 封包(如 `GC_MOVE`, `GC_SYNC_POSITION`, `GC_ATTACK`, `GC_FLY_TARGETING`),确认不仅有网络层的解析反序列化,更在表现层驱动了正确的物理与动画状态。
### 手段 4:自动化无头路网巡逻测试(Headless Patrol Suite
利用 Godot `--headless` 启动无头客户端:
- 让角色沿关键地貌路网(广场 $\to$ 跨河桥梁 $\to$ 仓库台阶 $\to$ 城门通道 $\to$ 郊外野怪区)自动移动。
- 监控指标:
- 角色 Y 坐标是否发生非预期的下坠(掉水、穿地);
- `_blocked` 是否产生虚假阻挡导致角色提前停步卡死;
- 状态机是否在 `walk` / `run` / `wait` 之间异常震荡。
---
## 四、工程化修改与防回退治理原则
每次发现并修复类似细节问题时,必须严格执行以下三条工程原则:
### 原则 1:拒绝打补丁式硬编码(Treat the Root, Never Patch Coordinates
- **禁止**针对个别坐标做特殊条件判断(如 `if x > 750 and x < 770: pos.y = 206.0`)。
- **必须**在底层管线实现通用机制(如在 C++ 引擎层解析 `.mdatr``heights` 并求交平面),确保全服数十张地图、数百座桥梁与所有建筑台阶自动获得修复。
### 原则 2:路线 A(严格 40250 原版实现)
- 不凭空发明“更好”的经验算法,遇到模糊细节直接翻阅 40250 对应 C++ 模块源码,将原版的数学公式、坐标系旋转(Z-up 转 Y-up、米制转换)与浮点容差 1:1 映射。
### 原则 3:修一处问题,必补一个 Parity 自动化回归测试
- 修复缺陷后,必须在 `project/` 下建立对应的 `test_*_parity.gd` 单元/集成测试(例如针对桥梁建立的 `test_bridge_height_parity.gd`)。
- 将测试纳入持续集成验证集(134+ Parity 测试套件),任何一次打包或构建前运行,确保旧缺陷绝不回退。
-252
View File
@@ -1,252 +0,0 @@
# 客户端移植 —— 从素材渲染器到能联机的客户端
> **适用范围(2026-09-08 复核)**:本文描述的是 **m2dev fork 协议**后端。该后端**已不是默认**——
> `project/client_main.gd:14-15` 在 `MT_PROTOCOL` 为空时无条件兜底成 `classic`,当前默认走
> 40250 经典协议。迁移方案与 classic 后端的协议细节见 [`CLIENT-40250-PORT.md`](./CLIENT-40250-PORT.md)。
> 本文仍然有效的部分:m2dev 后端自身的握手 / 封包 / 相位编排(`MT_PROTOCOL=m2dev` 时仍可切回),
> 以及 §2 记录的移植进度。**不要**把本文的封包号或 `Packet.h` 依据用到 classic 后端上。
> 起点:本仓库(`metin2-client`)的渲染纵切「差不多能用」(见 [`PARITY-GAP.md`](./PARITY-GAP.md))。
> 本文是把它变成**能连服务端玩**的客户端的分层计划。分水岭是**网络层**——别的玩家、
> 物品、战斗、聊天都以封包为数据源。
>
> 玩法 / 客户端系统层面「原始客户端有、这里还没有」的完整差距清单见
> [`CLIENT-GAP.md`](./CLIENT-GAP.md)。
## 0. 目标服务端
- LAN`192.168.21.203`。~~握手端口 connection refused~~ —— **已解决(2026-08-30**:真机
端到端已打通到 PHASE_GAME,见本文 §3。
- 默认端口(`assets/root/serverinfo.py`):auth `11000`,频道/游戏 `11011/11021/11031/11041`
- 测试账号:`admin` / `123456789`
## 1. 协议(本 m2dev fork**不是老 Metin2**
### 1.1 帧格式
每个包 = `[uint16 header LE][uint16 length LE][payload...]``length` = 含 4 字节前缀的整包长度
(≥ 4)。包间的 0 字节是加密对齐 padding,跳过。header 是 `CG::`/`GC::` 命名空间常量
`UserInterface/Packet.h` + `EterLib/ControlPackets.h`)。
### 1.2 加密握手(libsodium
`EterBase/SecureCipher` —— X25519 密钥交换(`crypto_kx`+ 每方向固定 nonce 的 XChaCha20
流加密(带运行字节计数,**顺序敏感**)+ `crypto_auth` HMAC 挑战应答 + XChaCha20-Poly1305
AEAD 加密 session token。
握手序列(连上 auth server 后):
| 方向 | 包 | header | 动作 |
|---|---|---|---|
| S→C | `GCPhase{phase=PHASE_HANDSHAKE}` | `GC_PHASE` 0x0008 | 进握手态 |
| S→C | `GCKeyChallenge{server_pk[32], challenge[32], server_time}` | `GC_KEY_CHALLENGE` 0x000B | `Initialize()``compute_client_keys(server_pk)` |
| C→S | `CGKeyResponse{client_pk[32], challenge_response[32]}` | `CG_KEY_RESPONSE` 0x000A | `get_public_key()``compute_challenge_response(challenge)` |
| S→C | `GCKeyComplete{encrypted_token[48], nonce[24]}` | `GC_KEY_COMPLETE` 0x000C | `decrypt_token()`→token`set_activated(true)`;解密已缓冲的剩余字节 |
| S→C | `GCPhase{phase=PHASE_AUTH}` | `GC_PHASE` | 进认证态 |
| C→S | `CGLogin3{name[31], pwd[17]}` | `CG_LOGIN3` 0x0102 | 明文账号密码(**此时流已加密**)|
| S→C | `GCAuthSuccess{login_key, result}` | `GC_AUTH_SUCCESS` 0x0108 / 失败 `GC_LOGIN_FAILURE` 0x0106 | `result` 真→拿 `login_key` |
拿到 `login_key` 后断开 auth,连**游戏服**`:11011`),同样握手,然后 `CGLogin2{name, login_key}`
`CG_LOGIN2` 0x0101)→ 选人 → 加载 → 进游戏相位。
> S→C 流 nonce `[0]=0x01`C→S `[0]=0x02`。`ping`(`GC_PING` 0x0007)→`pong`(`CG_PONG` 0x0006) 全程要回。
## 2. 进度
| 模块 | 状态 | 位置 |
|---|---|---|
| `SecureCipher`KX + 流 + AEAD token | ✅ 端口完成,`net.cipher_roundtrip` CTestKX / 挑战应答 + 篡改拒绝 / token 往返 / 跨块流加解密 / 字节计数) | `extension/src/net/secure_cipher.{h,cpp}` |
| `wire.h`(帧 + 控制 + KX + auth 结构体) | ✅ `#pragma pack(1)` 尺寸断言 | `extension/src/net/wire.h` |
| `ByteBuffer`(收发暂存) | ✅ header-only | `extension/src/net/byte_buffer.h` |
| libsodium 接入 | ✅ 从源码 vendor`extension/third_party/libsodium-cmake` + zstd + miniLZO),macOS/iOS 都编过,不再依赖 Homebrew。详见 `docs/THIRD-PARTY.md` | `extension/third_party/CMakeLists.txt` |
| `NetStream`(非阻塞 POSIX socket + `process()` 循环 + 帧解析 + 内建控制面 handler:`GC_PHASE` / `GC_PING``CG_PONG` / KX 握手 / 激活后解密已缓冲字节) | ✅ 编译通过 | `extension/src/net/net_stream.{h,cpp}` |
| `AuthClient`auth 相位:`PHASE_AUTH`→发 `CGLogin3`→收 `GCAuthSuccess`/`GCLoginFailure`) | ✅ 编译通过,等可达服务端端到端测 | `extension/src/net/auth_client.h` |
| `GameClient`(游戏服:`PHASE_LOGIN`→发 `CGLogin2{name, login_key}`→收 `GCLoginSuccess3` 角色列表→`select_character()``PHASE_SELECT/LOADING/GAME` | ✅ 编译通过。角色列表按 `TSimplePlayerInformation`pack(1)103B)解析成 `{index,id,name,job,level,x,y}`;游戏相位包暂只回 PING、记未知 header | `extension/src/net/game_client.h` |
| `net_probe` 诊断工具 | ✅ `net_probe <host> <port> <id> <pw>`,打印 state / 握手 / 登录结果 / login_key | `extension/tools/net_probe.cpp` |
| **`M2Client` GDExtension 节点** | ✅ 已注册可用。`connect_to_server(...)` 编排 Auth→(`auth_ok`)→GameConnect→游戏相位;`select_character(index)`。信号 `stage_changed` / `phase_changed` / `auth_ok` / `login_failed` / `char_list` / `entered_game` / `disconnected`。生命周期(F5):`suspend()`/`resume()`/`reconnect()` + `suspended`/`resumed` 信号,`NOTIFICATION_APPLICATION_PAUSED/RESUMED` 自动触发。烟测:正确报 `auth_connect → login_failed(Connection refused) → failed` | `extension/src/net/m2_client.{h,cpp}` |
| **`M2Client` 游戏内 API** | ✅ 出站 `move(func,arg,rot_deg,x,y)`rot `/5``time` 取帧钟)/ `attack(motion,vid)` / `set_target(vid)` / `say(type,text)`,未入局返回 false。快照 `get_entities()` / `get_entity(vid)`(含 hp/sp/level/dead/stunned,坐标转 Godot 米)/ `get_main_vid()` / `get_points()`HP/SP/level/exp/gold/ `get_target()`vid + hp%)。`pump_game()` 每帧 `EntityStore::set_now/tick` 并抽信号:`entity_spawned/despawned/moved/main_set``chat``vitals_changed(vid)``entity_dead(vid)`(首次去重)、`damage(vid,amount,flag)``motion(vid,victim,motion)``points_changed(Dict)``target_info(vid,hp%)``net.loopback_flow` 扩展:出站 `CG_MOVE/ATTACK/TARGET/CHAT` 经加密信道字节级校验 | `extension/src/net/m2_client.{h,cpp}` |
| **游戏相位封包结构** | ✅ `wire.h``GC_MAIN_CHARACTER` / `GC_CHARACTER_ADD[2]` / `GC_CHARACTER_DEL` / `GC_CHARACTER_UPDATE` / `GC_MOVE` / `GC_CHAT` + `CG_MOVE` / `CG_ATTACK` / `CG_TARGET` / `CG_CHAT`。**战斗/状态**(对 `Packet.h`):`GC_PLAYER_POINTS`(0x0214, int32[255] 1024B) / `GC_PLAYER_POINT_CHANGE`(0x0215) / `GC_STUN`(0x0216) / `GC_DEAD`(0x0217) / `GC_MOTION`(0x0307) / `GC_DAMAGE_INFO`(0x0410) / `GC_TARGET_INFO`(0x0A10) + `EPointTypes`HP/MAX_HP/SP/MAX_SP/LEVEL/EXP/GOLD…)+ `EDamageFlag`NORMAL/POISON/DODGE/BLOCK/PENETRATE/CRITICAL)。全部 `static_assert` 尺寸 | `extension/src/net/wire.h` |
| **`EntityStore`(封包→世界模型)** | ✅ headless。`apply()` 消化上述包 → `vid → Entity{race,name,x,y,z,angle,parts,func,moving, hp,max_hp,sp,max_sp,level,dead,stunned}``GC_MOVE` FUNC_MOVE 插值/其他 func 吸附;`tick()``now_ms` 插值。战斗:`GC_PLAYER_POINTS``points()` 全量属性 + 镜像到主角实体;`GC_PLAYER_POINT_CHANGE`→按 `EPointTypes` 更新单实体 vital`GC_DEAD`/`GC_STUN`→标记;拉取口 `drain_changes` / `drain_chat` / `drain_vitals` / `drain_damage` / `drain_motions` / `take_points_dirty` / `take_target_dirty`。单测 `net.entity_store` 扩展覆盖全部战斗包 | `extension/src/net/entity_store.{h,cpp}` |
| **`CG_*` 出站构造** | ✅ `GameClient::send_move/send_attack/send_target/send_chat``CG_CHAT` 变长) | `extension/src/net/game_client.h` |
| **环回集成测试** | ✅ `net.loopback_flow` CTest:进程内 `MockServer`(真协议:帧 + libsodium KX + 脚本相位包)驱动 `AuthClient``GameClient` 走完 auth→角色列表→选人→`PHASE_GAME`→游戏包进 `EntityStore`→NPC 移动插值。服务端一通改个 IP 即可 | `extension/tests/net_loopback_test.cpp` |
| **`EntityStore` 单测** | ✅ `net.entity_store` CTest:合成包 → spawn / 移动插值(半程/到达/func 复位)/ 吸附 / 变长 chat / despawn | `extension/tests/net_entity_test.cpp` |
| **`NetWorld` 场景桥(GDScript** | ✅ `net_world.gd`:消费 M2Client 实体信号,每 vid 一个子节点,位置向 `get_entity(vid).pos` 平滑逼近(超 `snap_dist` 瞬移)、`rotation.y` 取自 `angle_deg`、动画状态取自 `func`。默认「占位胶囊 + 名字牌 + HP 条」;`set_model_factory(cb)` 换真模型。**战斗**`vitals_changed`→缩放 HP 条 + emit `vitals(vid,hp,max_hp,dead)``damage`→实体头顶飘字(crit 放大变金 / dodge=MISStween 上浮淡出)+ emit `damage_number``entity_dead`→倒地 + `set_anim_state("dead")``main_entity_ready` / `chat_line` 信号。headless 自检 `netbridge_test.gd`spawn/main/远移吸附/近移插值/chat/伤害数字/HP 条/暴击/死亡/despawn)通过 | `project/net_world.gd` |
| **P0 闭环胶水 + 场景组装** | ✅ `net_play.gd``player_controller``M2Client` —— 点地→`move(FUNC_MOVE)`(节流)/ 停→`move(FUNC_WAIT)` / 点实体→`set_target` + 进 2.5m 自动 `attack` / 收自己 `GC_MOVE`→位置校正 / `points_changed`+`vitals_changed`+`target_info`→HUD。`hud.gd``set_vitals/set_exp/set_level/set_target/clear_target``game_scene.gd`(简版)把 world+player+camera+pc+net_world+hud+net_play+audio+lifecycle 拼起来,`login.gd``entered_game``--auto` 自动选槽)实例化。`netplay_test.gd` + `gamescene_test.gd` headless 全过 | `project/{net_play,game_scene,hud,login}.gd` |
| **P1 UI 工具层** | ✅ `ui/uiscript.gd` 解析原始 `uiscript/*.py`(子集解释器,**80/80 真实文件通过**)+ `ui/ui_build.gd` dict→Control 树(~20 种 type+ `ui/ui_assets.gd``.sub`/tga/png+ `ui/ui_manager.gd`(窗口栈 / ESC / modal / 拖动)+ `ui/dialogs.gd``ui_test.gd` 全过 | `project/ui/*.gd` |
| **P2 物品数据层** | 🟡 `wire.h``GC_ITEM_SET/DEL/UPDATE/USE/GROUND_ADD/GROUND_DEL/GET` + `CG_ITEM_MOVE/USE/DROP/PICKUP`(全 static_assert)。`EntityStore``inventory[90]` / `equipment[11]` / ground map + `drain_inv/ground/item_events``M2Client``get_inventory/equipment/item/ground_items` + `move_item/use_item/drop_item/pickup_item` + 5 信号。新 `Metin2Proto` 节点(`item(vnum)`/`mob(vnum)`,载 5748 真实 item)。⬜ 背包 / 装备窗 UI + 穿脱换模型 | `extension/src/{net/wire.h,net/entity_store.*,net/game_client.h,net/m2_client.*,proto/proto_node.*}` |
| **P3 聊天** | ✅ `wire.h` `GC_WHISPER`/`CG_WHISPER` + `EntityStore` whisper 解析(`ChatMsg` 加 from/sub+ `M2Client.whisper()` + `whisper_received` 信号。`ui/chat_ui.gd`4 标签 + 前缀 `/w`/`/g`/`/p`/`/s` + 系统/战斗日志。`net_world._bubble` 头顶气泡 + `main_bubble``chat_test.gd` 全过 | `extension/src/net/{wire.h,entity_store.*,game_client.h,m2_client.*}` · `project/ui/chat_ui.gd` |
| **P4 战斗表现** | 🟡 `wire.h` `GC_AFFECT_ADD/REMOVE` + `EntityStore` affect map + `M2Client.get_affects()` / `affect_added`·`affect_removed`。受击硬直(`net_play` + `pc.frozen`)· combo(motion 递增)· 相机抖动(`game_camera.shake`)· 死亡窗(`ui/death_ui.gd``/restart_here`·`/restart_town`)· `hud.set_affects` 图标条。`combat_fx_test.gd` 全过。⬜ 武器拖尾 / 相机锁定 | `extension/src/net/*` · `project/{net_play,game_camera,hud}.gd` · `project/ui/death_ui.gd` |
| **P5 特效引擎(首版)** | 🟡 `fx/mse.gd``.mse` 花括号树解析,真文件 6 particle)+ `fx/effect_player.gd`Particle→`GPUParticles3D`:发射形状/速率/寿命/曲线/颜色渐变/billboard/加法混合)+ `fx/effect_registry.gd`(名字→`.mse` + 缓存 + spawn)。`wire.h` `GC_SPECIAL/SPECIFIC_EFFECT` + `M2Client.effect_cue` 信号 → `game_scene` 在实体上播。`fx_test.gd` 全过。⬜ `.dds` 纹理 / `.mde` mesh / 绑骨骼 | `extension/src/net/*` · `project/fx/*.gd` |
| **P6 技能系统(首版)** | 🟢 `wire.h` `GC_SKILL_LEVEL/COOLTIME_END/CREATE_FLY` + `EntityStore` skill 表 + `M2Client.get_skills`/`cast_skill`/`skill_up` + `skills_changed`/`skill_cooldown_end`/`fly_cue` 信号。`ui/skill_table.gd`skilldesc.txt+ `ui/skill_ui.gd`K 键,加点)+ `ui/quickbar.gd`1-8 施放 + 冷却)+ `net_world` 飞行球。`skill_test.gd` 全过 | `extension/src/net/*` · `project/ui/{skill_table,skill_ui,quickbar}.gd` |
| **P7 任务 / NPC(首版)** | 🟡 `wire.h` `GC_SCRIPT`/`GC_QUEST_CONFIRM`/`GC_QUEST_INFO` + `CG_ON_CLICK`/`SCRIPT_ANSWER`/`SCRIPT_BUTTON`/`QUEST_INPUT_STRING`/`QUEST_CONFIRM`(全 static_assert)。`EntityStore``ScriptCue`/`ConfirmCue`/`QuestInfo` map + `drain_scripts/confirms/quest_changes` + `quest(idx)``Entity``ch_type``M2Client``click_npc`/`script_answer`/`script_button`/`quest_input`/`quest_confirm`/`get_quests` + `script_dialog`/`quest_confirm_ask`/`quest_info` 信号。`ui/quest_dialog.gd`EventManager 脚本渲染:`[ENTER]`/`[QUESTION]`/`[DONE]` → 文本+选项+确认)+ `ui/quest_log.gd`J 键)。`net_play._on_pick` NPC 分流。quest 逻辑纯服务器驱动(选型②)。`quest_test.gd` 全过。⬜ `[INPUT]` 输入 UI / 选道具 / 立绘 / 世界任务箭头 | `extension/src/net/*` · `project/ui/{quest_dialog,quest_log}.gd` · `project/net_play.gd` |
| charselect 串场 | ⬜ P10 | — |
| **P8 社交 / 商店 / 仓库(首版)** | 🟡 `wire.h` `GC_PARTY_*`(0x0710-16) / `GC_MESSENGER`(0x0741) / `GC_SHOP`(0x0810) / `GC_EXCHANGE`(0x051C) / `GC_SAFEBOX_*`(0x0830-34) + 对应 CG(全 static_assert`ShopItem`/`GCExchange`/`GCSubHeader` 等)。`EntityStore``PartyMember` map / `Friend` map / `ShopEntry` 表 / `ExchangeState` / `m_safebox[135]` + dirty 标志 + `drain_party_invites`/`drain_shop_errors``M2Client``party_invite/answer/leave` · `add/remove_friend` · `shop_buy/sell/close` · `exchange_start/add_item/add_gold/accept/cancel` · `safebox_checkin/checkout/move` + `get_party/friends/shop_items/exchange/safebox_items` + 8 信号。`ui/{party_ui,friend_ui,shop_ui,exchange_ui,safebox_ui}.gd`(队员面板常驻 / O 键好友 / 商店·交易·仓库随信号自动开)。`inventory_ui` 右键分流(商店卖 / 交易放 / 仓库存)。`p8_test.gd` + `net.entity_store` / `net.loopback_flow` 扩测全过。⬜ 公会(`PythonGuild` 40 法 + Mark 下载)、精炼 / 强化 / 龙魂、私人商店 / 拍卖、交易道具图标网格 | `extension/src/net/*` · `project/ui/{party,friend,shop,exchange,safebox}_ui.gd` · `project/game_scene.gd` |
| 公会 / 精炼 / 龙魂精炼 / 会徽下载(首版) | 🟡 `GC_GUILD`(0x0730) INFO/LIST/GRADE + `ui/guild_ui.gd`G,真机 `[GM-TEAM]`);`GC_REFINE_INFO`(0x051D) + `ui/refine_ui.gd``refine_ask``refine(pos,type)`);`GC_DRAGON_SOUL_REFINE`(0x051F)/`CG`(0x050D `grid[15]`) + `DS_SUB_*` + `EntityStore.DragonSoulCue`/`m_dragon_soul[180]` + `M2Client.ds_refine(mode,cells)`/`ds_window_open`/`ds_refine_result` + `ui/dragon_soul_ui.gd`(L / 自动弹,3 模式 + 15 格背包右键填)。**会徽**:`MARK_*` 独立连接(`GC_MARK_IDXLIST`/`BLOCK` 0x0C1x、`GC_MARK_UPDATE` 0x0B15、`CG_MARK_LOGIN/IDXLIST/CRCLIST` 0x0C0x);`NetStream``on_cipher_active()`/`on_raw()`u32 `buf_size` 分帧);`mark_image.{h,cpp}` `MarkImageSet`LZO1X → 512×512 → `mark_pixels`+ `mark_client.h` `MarkClient``M2Client.download_guild_marks`/`get_guild_mark_image``Image``serverinfo` `mark_port` 第 8 列;`app_flow` 进游戏连 + `guild_mark_updated` 冷却重连。**公会战 + 技能页**:`GUILD_GC_SKILL_INFO`(12) `GuildSkillInfoBody`(17)、`WAR`(15) `GCGuildWar`(10)、`GUILD_WAR_LIST`/`END_LIST`(17/18) `GuildWarPair`(8)、`WAR_POINT`(19) `GuildWarPoint`(12)、`NAME`(16) `GuildNameEntry`(16)`EntityStore` `GuildSkillState`/`GuildWarStatus`/`m_guild_wars`/`m_guild_names` + `drain_guild_war_events`/`_scores``GameClient.send_guild_use_skill``M2Client` `get_guild_skill`/`get_guild_wars`/`get_guild_war`/`use_guild_skill`/`declare_guild_war`(`/war` 命令) + `guild_skill_changed`/`guild_war_changed`/`guild_war_event`/`guild_war_point` 信号;`ui/guild_ui.gd` 改三页 Tab(成员/技能/公会战)。**会徽上传**:`CG_MARK_UPLOAD`(0x0C03) `CGMarkUpload`(776)、`CG_GUILD_SYMBOL_UPLOAD`(0x0722) `CGSymbolUpload`(8)+文件字节;`NetStream.send_pending()``MarkClient` `Mode`(Download/UploadMark/UploadSymbol) + `upload_done()``M2Client.upload_guild_mark`(转 RGBA8/缩放)/`upload_guild_symbol`/`get_mark_server` + `guild_mark_uploaded``guild_ui` 成员页「上传会徽」按钮。`net.loopback_flow``MockServer::Mode::Mark` 真 socket 端到端校验(KX → CG_MARK_LOGIN → CG_MARK_UPLOAD 768 字节校验和 → upload_done)。`guild_refine_test.gd` + `dragon_soul_test.gd` + `guild_mark_test.gd` + `guild_war_skill_test.gd` + `net.entity_store` + `net.guild_mark` + `net.loopback_flow`(CTest) 全过。**真机验证(2026-08-31`net_e2e`**`GUILD_GC_SKILL_INFO`(sub 12) 服务器进游戏自动推送,17B body 解析正确(skill_point=19 / guild_point 0/2300 / 12 等级);`GUILD_GC_WAR`(sub 15) 无战状态解析正确;trace 显示 `GC_GUILD`(0x0730) 多个子包 len=171/40/83/22/21/9/5 均无 unknown / parse 错误。会徽独立连接 + 会徽上传仍未接真机(联调服未开会徽服端口)。⬜ 龙魂属性表、公会等级/日志页 | `extension/src/net/*` · `project/ui/{guild,refine,dragon_soul}_ui.gd` · `project/{net/serverinfo,app_flow}.gd` |
| **私人商店 / 道具商城 / Cube(首版)** | 🟡 `wire.h``CG_MYSHOP`(0x0802) `CGMyShopHead`(38`char sign[33]+u8 count`) + `MyShopItem`(13`u32 vnum,u8 count,ItemPos,u32 price,u8 display_pos`)×N`CG_MALL_CHECKOUT`(0x0840) `CGMallCheckout`(8)`GC_MALL_OPEN`(0x0841) `GCMallOpen`(5)、`GC_MALL_SET`(0x0842)/`GC_MALL_DEL`(0x0843) 复用 `GCItemSet`/`GCItemDel`,全 static_assert。`GameClient``send_open_private_shop(sign, vector<MyShopItem>)`(打包头+N项)/ `send_close_private_shop`(=`SHOP_CG_END`) / `send_mall_checkout` / `send_cube_make|material_info|result_list``/cube make|mInfo|rList``send_chat(CHAT_TYPE_COMMAND,…)`)。`EntityStore``m_mall[135]` + `mall_open/size/slot/dirty``GC_CHAT``type==CHAT_TYPE_COMMAND` 不进聊天,转 `apply_server_command()` 解析 `cube open/close/info/success/fail/r_list/m_info``v,c` 结果列表 + `@`配方 `&``|``/`金币)→ `CubeState{open,npc,recipes[],results[],need_*}` + `drain_cube_events()``M2Client``open/close_private_shop``get_mall_items`/`mall_checkout``get_cube`/`cube_make`/`cube_request_result_list`/`cube_request_materials` + `mall_opened`/`mall_changed`/`cube_opened`/`cube_closed`/`cube_changed`/`cube_result` 信号。`ui/private_shop_ui.gd`(招牌 + 背包挑件 + 定价 + 개설/철수)、`ui/mall_ui.gd`(列表 + 取出到背包空格)、`ui/cube_ui.gd`(配方列表 + 材料/金币 + 제작);`game_scene` 装配三窗。`net.entity_store`(合成 GC_MALL_* + `GC_CHAT/COMMAND` cube 全家桶)+ `net.loopback_flow`(真 socketserver 推 mall+cubeclient 发 `CG_MYSHOP`/`CG_MALL_CHECKOUT`/`/cube` 字节校验)+ `shop_cube_mall_test.gd`(三窗 FakeClient)全过。**真机验证(2026-08-31`net_e2e MT_E2E_MYSHOP=1`**`CG_MYSHOP`(0x0802) 38B head + 13B item×1vnum 149price 99999999)被真服接受,无断连 / 无 shop error`SHOP_CG_END` 关店正常。Cube `/cube rList``CG_MALL_CHECKOUT` 送出路径不断连,但服务器只在玩家站到对应 NPC 前才回应,`mall_open=0` / `cube open=0` —— 需 NPC 交互才能完整验。⬜ `GC_SHOP_SIGN`/`SYMBOL_DATA` 头顶招牌、他人开店 `START_EX` 多标签、39 格拖放网格 / 鼠标物品态、能量条、NPC 前完整验 cube/mall。**拍卖行**m2dev-client 无此系统(`0x08xx` 只有 shop/myshop/safebox/mall),非移植项,需自定义协议 + 服务端 | `extension/src/net/{wire.h,game_client.h,entity_store.{h,cpp},m2_client.{h,cpp}}` · `project/ui/{private_shop,mall,cube}_ui.gd` |
| **P9 世界系统(首版)** | 🟡 `wire.h` `GCWarp`(18) / `GCTime`(12) / `GCChannel`(5) / `GCNPCPosition`(6+78×n) / `GCTargetCreate/Update/Delete`(46/16/8) / `GCMount`(21),全 static_assert。`EntityStore``WarpCue` + `drain_warps``server_time`/`channel` + dirty、`m_npc_marks` / `m_markers` map、`Entity.mount_vnum` + `drain_mount_changes``M2Client``get_channel/get_server_time/get_npc_marks/get_world_markers` + `warp`/`time_changed`/`channel_changed`/`npc_marks_changed`/`world_markers_changed`/`mount_changed` 信号。`ui/minimap.gd`(右上圆形,北朝上,实体/NPC/任务标记 + 玩家箭头 + CH 角标)+ `world/world_time.gd`epoch → 太阳角度/光色/ambient 昼夜)+ `fx/weather.gd`(雪/雨 GPUParticles 跟相机)。`game_scene._on_warp` 同服挪人。`p9_test.gd` + `net.entity_store` 扩测全过 | `extension/src/net/*` · `project/ui/minimap.gd` · `project/world/world_time.gd` · `project/fx/weather.gd` · `project/game_scene.gd` |
| **P10 网络补全(首版)** | 🟢 `wire.h` `CGClientVersion`(0x000D70B)。`GameClient``GC_MAIN_CHARACTER` → 自动发 `CG_CLIENT_VERSION` = **打通 LOADING→PHASE_GAME**(真机验证进游戏 + 121 实体)。`net/serverinfo.gd``class_name ServerInfo`,TSV 服务器表 + 频道端口偏移)+ `ui/loading_screen.gd`(相位驱动读取遮罩)+ `ui/reconnect_ui.gd`(断线倒计时 + 自动/手动重连)+ `app_flow.gd`LOGIN→SELECT→GAME 串场状态机,单 `M2Client` 贯穿)。attack CRC 全 0 实测服务器接受。`net_e2e` 加 move+attack poke`net.loopback_flow` 断言自动版本报文;`p10_test.gd` 全过 | `extension/src/net/{wire.h,game_client.h}` · `extension/tools/net_e2e.cpp` · `project/{app_flow,net/serverinfo}.gd` · `project/ui/{loading_screen,reconnect_ui}.gd` |
| 大地图窗 / 地形贴花 / 钓鱼 / 副本 / 坐骑换模型 / 跨服换图 / 频道人数轮询 / 真机字节校准 | ⬜ 见 [`CLIENT-ROADMAP.md`](./CLIENT-ROADMAP.md) P9·P10 剩余 | — |
| 物品 / 装备(`item_proto` + 背包封包 + 装备挂模型 → 解锁 §2.7 高光 / §2.3 时装) | ⬜ | — |
## 3. 连通性(2026-08-30:真机端到端打通到 PHASE_GAME
服务端 = **m2dev-fork 协议**`net_e2e``extension/tools/net_e2e.cpp``build/extension/net_e2e`
驱动 auth→game→选人→进游戏全程;`MT_NET_TRACE=1` 打印每包 header/length/consumed。
**真机结果 —— 全程打通**
- ✅ auth 握手(libsodium KX+ `CG_LOGIN3``GC_AUTH_SUCCESS`
- ✅ game `CG_LOGIN2`**`GC_LOGIN_SUCCESS4`(0x01054 槽)** 角色列表(`[SA]Admin` lv105
-`CG_CHARACTER_SELECT``GC_PHASE(LOADING)` → 整包洪流全部按正确字节解析
-**`GC_MAIN_CHARACTER` 后自动发 `CG_CLIENT_VERSION`(0x000D)** → `GC_PHASE(GAME)` / **IN GAME**
- ✅ 进游戏后:121 个实体、HP 14990/15178、level 105、20 格背包、好友列表 1 人
-`CG_MOVE` + `CG_ATTACK`CRC 全 0)保持连接不被踢 → **不需要占位 CRC**
**修的 3 个真 bug(对任何真服务器都必需)**
1. `GC_LOGIN_SUCCESS4`(0x0105) 没处理(只认 3 槽的)→ 加 `GCLoginSuccess4`(492B) + handler。
2. 帧分发器 "did not consume" 误判:连续同 header 的包(一串 `GC_PLAYER_POINT_CHANGE`)会误杀连接
→ 改成按**实际消费字节数**判定(`net_stream.cpp` dispatch())。
3. **缺 `CG_CLIENT_VERSION`**:服务器收到 `GC_MAIN_CHARACTER` 后等客户端回版本报文,缺它就在
phase 4 断线 → `GameClient` 收到 `GC_MAIN_CHARACTER` 时自动发(fork 固定 timestamp `1215955205`)。
**真机字节校准(2026-08-30,已做)** —— 用 `MT_NET_DUMP=1 net_e2e` 抓真实封包逐字节对:
- **实体名 / 等级 / 公会 / 骑乘** ✅ 走 `GC_CHAR_ADD_INFO`(0x0207`TPacketGCCharacterAdditionalInfo`
97B),之前根本没处理。加 `GCCharAddInfo` struct + handlerNPC「Old Man」(race 20009) 名字正确出。
- **`GC_CHARACTER_UPDATE`**(0x020938B) ✅ 之前只粗解 parts;现按 `TPacketGCCharacterUpdate` 全解
parts / guild / alignment / pk_mode / mount`GCCharacterUpdate` struct)。
- **`ch_type`**`GC_CHARACTER_ADD.bType` 对 NPC/怪都是 0(服务器不填)。改在 GDScript 按 `race`
`mob_proto.type` 分类(`net_play._entity_kind`0 怪 / 1 NPC / 2 石头 / 3 warp),`_entity_name`
怪名回退 `mob_proto.locale_name``game_scene` 现在也载 `mob_proto`
- **`GC_PLAYER_POINTS`**(0x02141024B) —— **本来就对的**。真机 gold=13.5 亿(GM 号)、exp=0、
next_exp=int32 溢出为负都是服务器真实值。`M2Client.get_points` 把 exp/next_exp 按 unsigned 输出。
**第二轮校准(2026-08-30,已做)** —— 又抓了 in-game 洪流里没对过的包:
- **`GC_SKILL_LEVEL_NEW`**(0x021B1534B) ✅ 这才是本服发的技能包(**没发老的 0x021A**),之前
`get_skills()` 永远空、K 键技能窗没内容、快捷栏没法施放。加 `PlayerSkill{u8 master, u8 level,
u32 next_read}`6B/条,fork 的 `time_t` 是 32 位)+ `GCSkillLevelNew` + handler。真机实测:33 个
技能全 lvl 40 master 3GM 号)。`get_skills()``master`0 普/1 M/2 G/3 P)。
- **`GC_QUICKSLOT_ADD/DEL/SWAP`**(0x0519/1A/1B) ✅ 之前没处理 → 快捷栏进游戏是空的。加
`QuickSlot{u8 type,u8 position}` + 3 个 handler + `M2Client.get_quickslots()` + `quickslots_changed`
`ui/quickbar.gd.restore_from_server()` 按服务器内容填 1-8 号格(type 1 道具 / 2 技能)。真机 10 格。
- **`GC_NPC_POSITION`**(0x0A50) —— struct **本来就对**6 + 78×nname[65])。真机 49 条 NPC,名字对
(服务器不 zero-pad name buffer`strnlen` 已兜住)。
- **`GC_ITEM_UPDATE`**(0x051441B) / **`GC_CHAT`**(0x060310B 头) / **`GC_AFFECT_ADD`**(0x0A2025B)
/ **`GC_SPECIAL_EFFECT`**(0x0A309B) / **`GC_QUEST_INFO`**(0x09127B) —— 逐字节核对,**全部已对**。
- **`GC_GUILD`**(0x0730) sub-header 包(grade/info/list)已解析(见 M4 行);会徽走 `MARK_*` 独立连接。
**校准后续(2026-08-30,已做)**
- **3D 名字标签刷新** ✅ `EntityStore``ChangeKind::Info``GC_CHAR_ADD_INFO` 到达时对已存在实体
`Info` 变更 → `M2Client.entity_info(vid, dict)` 信号 → `net_world._on_info` 刷 Label3D。
`net_world.name_resolver``game_scene` 设为 `net_play._entity_name`)让怪名走 `mob_proto`
- **跨服换图 `GC_WARP` addr≠0** ✅ `M2Client``WarpCue.addr` 是网络序 IPv4,转点分十进制后
`warp_to_game_server(host, port)` —— 拆掉当前 game 连接、复用存下的 `login_key` 直连新 game
server(跳过 auth)。同服 warp 仍只挪玩家。
- **断点快连** ✅ `M2Client.reconnect()` 现在优先用 `last_login_key` 直连 game server(省一次
auth 往返),失败再走完整 auth→game。服务器重发 spawn = 断点续连。
- **频道负载查询** ✅ `net/channel_status.gd`(对齐 `CServerStateChecker`):明文 TCP 连任一频道口
→ 发 `CG_STATE_CHECKER`(0x000Flen 4) → 跳过杂包到 `GC_RESPOND_CHANNELSTATUS`(0x0010) →
`int32 count` + count×`{i16 port, u8 status}`。status0 关 / 1 正常 / 2 拥挤 / 3 爆满
(Metin2 协议里就是 4 态,不是在线人数)。`app_flow._probe_channels` 先查状态、没答上的频道回退
`_probe_tcp`,下拉项标 ●/◐/✕。**真机实测**:`192.168.21.203:11011``{11011:1, 11012:1,
11013:1, 11991:1}` —— 也据此把 `serverinfo` 的频道端口步长从 +10 改成 **+1**(可在 serverlist.txt
第 7 列 `port_step` 按服覆盖)。`channel_status_test`(进程内 mock 服务器:请求字节 + 跳杂包 +
解析 + 不可达返回空)。
- **怪 / NPC 真 3D 模型** ✅ `ui/mob_view.gd`(对齐 `player_view.gd`):`race` = `mob_proto` vnum →
**`root/npclist.txt`**`<vnum>\t<代号>\t…`,如 `113→bear_brown``185→tiger_big``102→wolf`
翻译版 `mob_proto``szName` 也本地化成 "Brown Bear" 了,解不出目录,必须靠这张表,`mob_proto`
名仅作兜底)→ 目录按代号逐段回退(`bear_brown → bear`),网格 / 贴图用完整代号
(换肤网格都在基础目录里:`bear/bear_brown.gr2` + `bear/bear_brown.dds`)→ 进 `Metin2Model`
texture_dir = 目录)+ 解 `motlist.txt``Metin2AnimPlayer`。扫
`<assets>/*/ymir work/{monster,monster2,npc,npc2}/<folder>/`。npclist 解析一次进 `static` 缓存。
`set_anim_state("wait/run/attack/damage/dead/skill")` 按 motlist 动作名映射。
`game_scene._make_entity_model` 设为 `net_world` 的默认工厂(非主角 + 有 mob_proto 时);race→是否有模型
缓存,失败回退占位胶囊。`net_world._attach_nameplate` 抽出来,真模型也挂头顶名字 + HP 条。
`mob_view_test`race 102 Wolf → `wolf/` 目录、Metin2Model40 骨 762 顶点)、motlist ≥3 动作、状态切换。
实测进游戏「Alpha Grey Wolf」「Hungry Black Bear」等出真模型 + 贴图 + 动作 + 名字牌 / 血条。
- **重连 / 已在局内时补拉实体** ✅ `game_scene.setup()` 现在是协程(分帧避免阻塞漏 PONG),
`net_world.setup()``entity_spawned` 已晚于 `M2Client` 的初始 spawn 洪流 → `_on_spawn` 0 次触发。
修:`net_world.catch_up()` 遍历 `client.get_entities()` 补 spawn 未镜像的;`game_scene` 已在局内分支里
**先** `net_world.set_local_vid(get_main_vid())` + `net_play._on_main_set(get_main_vid())`
`catch_up()`,否则本地玩家会被画成一个压在身上的大蓝胶囊。
**剩的都不是代码问题**:字面「在线人数」(数字)Metin2 协议里没有 —— `bStatus` 就是 4 态负载指示,
要真数字得改服务器;本地 `assets/` 只有 ~85 个 monster 目录(不是全量),没目录的 race
仍是占位胶囊 —— 换全量资产包即可全部出真模型。
**联调路上修的两个真 bug**(对任何真实服务器都必需):
1. **`GC_LOGIN_SUCCESS4`(0x0105) 没处理** —— 只认 3 槽的 `GC_LOGIN_SUCCESS3`。已加 `GCLoginSuccess4`
结构(492B) + handler。
2. **帧分发器误杀连接** —— 连续同 header 的封包(如一串 `GC_PLAYER_POINT_CHANGE`)会触发
"on_packet did not consume" 误判并断线。改成按**实际消费字节数**判定
`net_stream.cpp` dispatch())。此 bug 之前会让 P0–P8 对任何真服务器的端到端全挂。
**当前状态**:P0–P8 的封包层仍以合成封包 CTest + FakeClient GDScript 测试离线验证(14 套 GDScript +
9 CTest 全绿)。真机端到端到 PHASE_GAME 的最后一跳留给 P10。
## 3b. B 轨(不依赖服务端的客户端系统)
| 模块 | 状态 | 位置 |
|---|---|---|
| **eterpack 读写器**G1| ✅ 本 fork = `PackLib` 单文件包(header + XChaCha20 加密索引 + zstd 数据 + 可选逐文件加密,硬编码 `PACK_KEY`)。`EterPack`(读,name-field 从 header 反推兼容真实包)+ `write_pack` + `packtool` CLI。`pack.roundtrip` CTest + 真实 `textureset/` 45 文件明文/加密往返 `diff` 一致 | `extension/src/pack/eterpack.{h,cpp}``extension/tools/packtool.cpp` |
| **登录 / 选人界面**(首版)| ✅ `charselect.gd`3 旋转台 × 真实职业模型(`<class>_novice.gr2` + `wait.msa`+ 真实 `board` 九宫格面板(槽位按钮 + 进入游戏)。数据来自 `M2Client.char_list`(联机)或 `--mock`(离线开发)。`login.gd`:服务器/账号/密码 + 连接,接全部 `M2Client` 信号(`--auto` 测试)。`ui_kit.gd`:从 `hud.gd` 抽出的九宫格助手。⬜ 创建/删除角色、旋转台微调、login→charselect→world 串场 | `project/{login,charselect,ui_kit}.gd` |
| **正式选人页(Metin2 风)** | ✅ `ui/char_select_screen.gd``app_flow._build_char_list` 用它替掉原来的裸按钮列表。全屏背景 `d:/ymir work/ui/intro/select/select.sub`=select.jpg+ 右侧 `SubViewport``own_world_3d`)里 `PlayerView` 真模型(正面朝相机 + `wait` + 缓摆)+ 左上职业名(locale `name_<class>.sub``select.dds`DDS 走 `Metin2World.load_dds`;解不出退中文描边字 猛将/刺客/术士/巫女)+ 左侧 `board` 信息板(帮会徽 `get_guild_mark_image` / **国家名 `get_empire()`→`EMPIRE_A/B/C` locale + 国旗 `empireflag_<a/b/c>.sub`** / 帮会名 / 名称 / 等级 / 游戏时间 / 体力·智力·力量·敏捷 条)+ 开始 / 创建 / 删除 / 退出 + 多角色 ◀ ▶ 槽位切换(`_pad_slots``get_slot_count()` 补空槽,◀▶ 能走到空槽建号)。**建号 / 删号(1:1 对齐 `PythonNetworkStreamPhaseSelect.cpp`**`CG_CHARACTER_CREATE`(0x0201) `CGCreateCharacter`(77job 是 **u16**)、`CG_CHARACTER_DELETE`(0x0202) `CGDeleteCharacter`(13`private_code[8]`)、`GC_PLAYER_CREATE_SUCCESS`(0x020C) `GCPlayerCreateSuccess`(108=`{u8 slot, SimplePlayerInfo}`)、`GC_PLAYER_CREATE_FAILURE`(0x020D) `{u8 type}``GC_PLAYER_DELETE_SUCCESS`(0x020E) `{u8 slot}``GC_PLAYER_DELETE_WRONG_SOCIAL_ID`(0x020F)。`GameClient``create_character`/`delete_character` + `drain_char_events()``CharEvent{CreateOk/CreateFail/DeleteOk/DeleteFail}`+ `replace_slot`/`clear_slot`(服务器只补丁槽位不重发列表)+ `slot_count()``M2Client``create_character(slot,name,job,shape,con,int,str,dex)`/`delete_character(slot,code)`/`get_empire`/`get_slot_count` + `char_created`/`char_create_failed`/`char_deleted`/`char_delete_failed` 信号(建 / 删成功后重发 `char_list`)。选人页建号弹窗(职业下拉 + 名称 + 各职业起始四维只读)、删号弹窗(secret 删除码);`app_flow._on_char_list` 在 SELECT 态只 `set_chars()` 不整屏重建。会徽:`app_flow._goto_select` 也发 `download_guild_marks``guild_marks_ready`(one-shot) → `refresh_crest()`。相机改按**包围球** `r/sin(fovV/2)` 自适应 + 沿 -X 推 `r*0.5`(横向视锥更宽,不切远侧手),蒙皮定型后多 fit 4 帧。C++:`CharSlot` + `char_list` dict 补 `play_minutes/st/ht/dx/iq/main_part/hair_part``ui_assets.gd``load_dds_image()``net.loopback_flow` MockServer 加 `CG_CHARACTER_CREATE/DELETE` 真 socket 端到端(名称字节校验 + 成功补丁槽位 + 名称冲突 fail + 错误删除码拒绝);`char_create_delete_test.gd`(弹窗 → 假 client 收 `create_character(slot1,"Newbie",job1,con3/int3/str4/dex6)` + 短名拒绝 + `char_created` 关窗 + 删号码 + 失败清框 + `set_chars` 保持选中);`p10_test` 选人断言改点「开始」 | `project/ui/char_select_screen.gd` · `project/ui/ui_assets.gd` · `project/app_flow.gd` · `extension/src/net/{wire.h,game_client.h,m2_client.{h,cpp}}` · `extension/tests/net_loopback_test.cpp` |
| **角色状态窗(1:1 窗口迁移样板)** | ✅ `project/ui/char_status_ui.gd``UiScript`+`UiBuild` 直接装载 `assets/uiscript/uiscript/characterwindow.py` 真布局(4 页 tabSTATUS/SKILL/EMOTICON/QUEST`SetState` 切页;技能 / 表情 / 任务的数据仍走各自专用窗)。STATUS 页数值逐字对照 `assets/root/uicharacter.py.RefreshStatus``Level_Value`=`points[LEVEL]``Exp_Value`=`u32(exp)``RestExp_Value`=`u32(next_exp)-u32(exp)``HP/SP_Value`=`cur/max``STR/DEX/HTH/INT_Value`=`points[ST/DX/HT/IQ]``ATT_Value`=`(MIN_ATK|MAX_ATK)+ATT_GRADE_BONUS+PARTY_ATT_GRADE`min==max 取单值,全 0 退 `ATT_POWER`)、`DEF_Value`=`DEF_GRADE(+DEF_GRADE_BONUS 若非 0)``MATT_Value`=`MAGIC_ATT_GRADE+(MIN|MAX)_MAGIC_WEP``MDEF_Value`=`MAGIC_DEF_GRADE``ASPD/MSPD/CSPD/ER_Value`=`points[ATT_SPEED/MOV_SPEED/CASTING_SPEED/EVADE_RATE]`EPointTypes 索引取自 m2dev `src/UserInterface/Packet.h`)。加点:`HTH/INT/STR/DEX``_Plus`/`_Minus` 按钮 → `client.say(0, "/stat ht"|"/stat- ht" …)`,与原 `statusPlusCommandDict`/`statusMinusCommandDict` 完全一致;`Status_Plus_Value`=`points[STAT]``>0` 才显示 `_Plus` 按钮和 `Status_Plus_Label`。名称 / 帮会 / 职业头像取 `get_entity(get_main_vid())``race%4``face_warrior/assassin/sura/shaman.sub`)。`points_changed` 信号刷新。`game_scene``char_status_ui` 节点 + V / C 热键。`char_status_ui_test.gd`:假 client 灌 points → 断言全部 Value label 文本 + `/stat` 命令 + `STAT==0` 隐藏加点按钮 + tab 切换页可见性。后续选道具网格 / 商店 START_EX / 私人商店 39 格照抄这套(uiscript 装载 + 逐字数值绑定 + 原聊天命令 / 封包)| `project/ui/char_status_ui.gd` · `project/game_scene.gd` · `project/char_status_ui_test.gd` |
| **组队成员信息板(`uiparty.PartyMemberInfoBoard`** | ✅ `wire.h``CGPartySetState{u16 hdr,u16 len,u32 pid,u8 role,u8 on}`static_assert 10+ `EPartyRole` 枚举(NORMAL 0 / LEADER 1 / ATTACKER 2 / TANKER 3 / BUFFER 4 / SKILL_MASTER 5 / BERSERKER 6 / DEFENDER 7PythonPlayer.h)。`game_client.send_party_set_state(pid,role,on)``CG_PARTY_SET_STATE` 0x0704,注意结构字段名 `dwVID` 实际传 PID —— 对齐 `uiparty.py OnSelectState``net.SendPartySetStatePacket(self.pid,...)`)。`M2Client.party_set_state(pid,role,on)` + bind`get_party()` dict 补 `state`(完整角色字节,bit0=leader+ `affects[7]`int×7)。`project/ui/party_ui.gd` 从「名字+一条 HP」重做成 `partymemberinfoboard.py` 布局(每员 106×36 条:StateButton 22px 角色图标 + NameSlot + Gauge + 附加图标行):`_strip(m)` → 角色状态按钮(`_local_is_leader()` 才可点 → `_open_role_popup` 弹 普通/攻击/坦克/狂战/辅助/宗师/防御 + 踢出;普通=`party_set_state(pid, cur_role, false)` 清角色;踢出=`party_leave(pid)`=`SendPartyRemovePacket`+ 名字(★队长) + HP `ProgressBar` + `affects[i]!=0` → chip`AFFECT_LABEL` 暂定 经验/攻击/防御/辅助/宗师/时间/回复,tooltip 带值)。点名字 → `set_target(vid)``OnMouseLeftButtonDown` 选中)。header:EXP 分配开关 `_toggle_distribute``party_set_distribute``EPartyExpDistributionType` 0 NON_PARITY / 1 PARITY+ 组队治疗 `_party_heal``party_use_skill(PARTY_SKILL_HEAL=1, 0)``_local_is_leader()` = `get_party()``vid == get_main_vid()` 那条的 `leader`。角色菜单按党技能等级门控(`uiparty.__ShowStateButton` Tanker≥10…Defender≥40)暂略。`p8_test` 组队段扩:affect chip 渲染 + 队长点 StateButton 弹菜单 + 「攻击」→ `party_set_state(pid, 2, true)` + 踢出 → `party_leave` + 分配切换 → `party_set_distribute(1)` + 治疗 → `party_use_skill(1,0)` + 点名字 → `set_target` + 非队长 StateButton disabled。**顺带修 `p8_test._init``await _run()` 的隐患**(第一个 `await` 之后所有断言从未生效,含增量 34 `shop_ex`)→ 改 `await` 后暴露 `shop_ui.refresh()` `queue_free` 延迟 bug(旧 row 当帧未消失),改 `remove_child`+`queue_free` | `extension/src/net/{wire.h,game_client.h,m2_client.{h,cpp}}` · `project/ui/party_ui.gd` · `project/ui/shop_ui.gd` · `project/p8_test.gd` |
| **ESC 系统菜单 + 游戏设置窗(`uisystem.SystemDialog` + `uigameoption.OptionDialog`** | ✅ `project/ui/system_menu_ui.gd``systemdialog.py` 真 uiscriptthinboard 8 键)= `uisystem.py __LoadSystemMenu_Default` 1:1`system_option_button``system_option_ui.open()``game_option_button``game_option_ui.open()``change_button``client.say(0,"/phase_select")``net.ExitGame()` 在有 ping 时发 `SendChatPacket("/phase_select")`),`logout_button``"/logout"``net.LogOutGame()`),`mall_button``"/in_game_mall"``exit_button``get_tree().quit()``help_button`→占位 `toast``cancel_button`/标题栏→关。modal 打开。`game_scene`ESC 从直接 `system_option_ui.toggle()` 改成 `system_menu_ui.toggle()`。<br>`project/ui/game_option_ui.gd``gameoptiondialog.py` 真 uiscript= `uigameoption.py``BLOCK_BITS` = `block_{exchange,party,guild,whisper,friend,party_request}_button`(toggle_button) → `_toggle_block(bit)``_block_mode ^= bit` + `client.say(0, "/setblockmode %d" % _block_mode)``Packet.h EBlockAction``1<<0..1<<5`,逐字对照 `__OnClickBlock*``net.SendChatPacket("/setblockmode " + str(blockMode ^ player.BLOCK_*))`+ 持久化 `[gameopt] block_mode`(服务器回包同步待补)。`PK_CMD` = `pvp_{peace,revenge,free,guild}`(radio) → `client.say(0,"/pkmode %d")``{0,1,2,4}``__OnClickPvPMode*`guild 是 `/pkmode 4``PK_MODE_PROTECT`=3 跳过)。`DISPLAY_RADIOS``name_color`/`target_board`/`view_chat`/`always_show_name`/`show_damage`/`salestext` 的 on/off/normal/empire radio → `_display[key]` + 持久化 `user://system_option.cfg [gameopt]`(等价 `systemSetting.Set*Flag`;渲染侧钩子——头顶名字配色 / 伤害数字 / 聊天显隐——待补)。`_bind_radio` 处理 radio 배타성,`_sync`/`_sync_block` 开窗回填,`_relabel` 中文兜底(uiScriptLocale 未接全局 Locale)。`system_menu_ui_test.gd`:两窗装载 + `/setblockmode 1``3``2`(位 XOR+ `_block_mode` 跟踪 + `/pkmode 1`/`4` + radio 배타 + 显示开关落 cfg + 二次实例读 `block_mode` 回填按钮态 + menu 各键 `/in_game_mall`/`/phase_select`/`/logout` + `system_option_button`/`game_option_button` 开子窗关菜单 + `cancel` 关。C++ 无改动 | `project/ui/system_menu_ui.gd` · `project/ui/game_option_ui.gd` · `project/game_scene.gd` · `project/system_menu_ui_test.gd` |
| **系统设置窗(`uisystemoption.OptionDialog`** | ✅ `project/ui/system_option_ui.gd``UiScript`+`UiBuild``assets/uiscript/uiscript/systemoptiondialog.py`board + titlebar + `music/sound_volume_controller` sliderbar + `camera_short/long` + `fog_level0/1/2` + `tiling_cpu/gpu` + `tiling_apply` + `bgm_button`)。控件绑定逐字对照 `assets/root/uisystemoption.py``OnChangeMusicVolume``snd.SetMusicVolume`+`systemSetting.SetMusicVolume`(接 `Audio.master_bgm` + 即时改在播 `_bgm[_bgm_cur].volume_db`),`OnChangeSoundVolume``Audio.master_sfx``play_ui`/`play_at` 每次读),`camera_short/long``game_camera.max_dist``CAMERA_MAX=[11,20]`,并 `dist=min(dist,max)`),`fog_level0/1/2`(浓/중/淡)→`__SetFogLevel` = `Environment.fog_enabled=true`+`fog_density=[0.055,0.018,0.004][i]``tiling_*`/`tiling_apply`(原 CPU/GPU 分块渲染 + `net.ExitGame` 重启)Godot 渲染器无对应 → 保留占位。`_bind_radio` 处理 radio 배타성。持久化 `user://system_option.cfg`section `audio`/`video`= `systemSetting` 配置文件);`setup()` 读回并 `_apply_all()` 即时生效;`game_scene._on_main_set``.msenv``Environment` 后再 `_apply_all()``game_scene`ESC 呼出(`ui_manager._unhandled_input``close_top()` 吃事件,无窗时才落到 `game_scene`)。uiScriptLocale 未接全局 Locale → `LABELS` 中文兜底。`system_option_ui_test.gd`:装载 + `music/sound` slider `value_changed` 已连 + `_on_*_volume` 应用到 `master_bgm/sfx` + camera radio→`max_dist`+dist 夹 + fog radio→`fog_density` + cfg 落盘 + 二次实例读 cfg 即时应用。C++ 无改动 | `project/ui/system_option_ui.gd` · `project/game_scene.gd` · `project/system_option_ui_test.gd` |
| **私人商店开设窗(`PrivateShopBuilder` 模型)** | ✅ `project/ui/private_shop_ui.gd` 从「前 5 行 checkbox」重做成 1:1 复刻 `assets/root/uiprivateshopbuilder.py`(**增量 36:布局改走真 uiscript `privateshopbuilder.py` —— board + TitleBar + `NameLine`(盖 LineEdit) + `ItemSlot` 5×8=40 grid + Ok/Close;背包候选面板挂窗右侧**):`_stock` = `itemStock``{格号 -> {cell,vnum,count,price}}`)。左侧 `_inv_list` 背包候选(排除已上货的 cell),点一件 → `_pick``_picked`= `mouseModule` attached item);点右侧 40 格(`GRID_SLOTS=40` = `shop.SHOP_SLOT_COUNT`5×8)空格 → `_ask_price``uiCommon.MoneyInputDialog``SpinBox`)→ `_place(slot, price)``_stock`= `AddPrivateShopItemStock` + `itemStock[targetSlotPos]=(src)`);点已占用格 → `_stock.erase` = `OnSelectItemSlot` / `DelPrivateShopItemStock`)。`_ok()`OkButton → `BuildPrivateShop`):`_stock` 按格号 `sort()``display_pos = 格号``items.size() >= PRIVATE_SHOP_ITEM_MAX(39)` 截断 → `client.open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])``MyShopItem`/`TShopItemTable` 字段 1:1`{u32 vnum, u8 count, ItemPos pos, u32 price, u8 display_pos}`)。`_close_shop()` = `close_private_shop()` = `SHOP_CG_END`。测试拆成独立 `private_shop_ui_test.gd`(真 uiscript,缺资产跳过):`_cells` 40 格 / `NameLine` 盖 LineEdit / 2 候选 / 空 stock 不发 / 拿起→价格弹窗→`_place` / 개설按格号排序(格 0 在格 3 前)+ `display_pos` / 点占用格撤下 / 철수;`shop_cube_mall_test` 私人商店段移除。C++ 无改动(`open_private_shop` / `MyShopItem` 早已就位)| `project/ui/private_shop_ui.gd` · `project/game_scene.gd` · `project/private_shop_ui_test.gd` · `project/shop_cube_mall_test.gd` |
| **`SHOP_GC_START_EX` 多货架商店** | ✅ `wire.h``ShopTabHead{char name[SHOP_TAB_NAME_MAX=32]; u8 coin_type}`static_assert 33+ `SHOP_TAB_COUNT_MAX=3``entity_store``SHOP_GC_START_EX`(10) 解析 `TPacketGCShopStartEx{u32 owner_vid; u8 tab_count}` + `tab_count` × `{ShopTabHead, ShopItem[SHOP_HOST_ITEM_MAX_NUM=40]}`(对齐 `PythonNetworkStreamPhaseGame.cpp RecvShopSub_StartEx`)。`ShopEntry``u8 pos`(货架内槽位);`ShopTab{name, coin_type, items}` + `m_shop_tabs``SHOP_GC_START` 也改为按固定 40-槽数组填 `pos`(空槽计入,与参考 `SetItemData(j,...)` 一致);`SHOP_GC_END``m_shop_tabs``shop_items()` 保持是 `m_shop_tabs[0].items` 的镜像,旧 `get_shop_items()` 不动。`M2Client.get_shop()``{vid, open, tabs:[{name, coin_type, items:[{pos,vnum,price,count}]}]}``shop_ui.gd``tabs.size()>1` 时显示货架 `Button` 行,切货架重建列表;买位置 = `_active_tab * SHOP_SLOT_COUNT(40) + it.pos`1:1 对齐 `uishop.py GetIndexFromSlotPos` = `tabIdx * shop.SHOP_SLOT_COUNT + slotPos``shop_buy``u8 pos` 容得下 3×40)。`net_entity_test`:双货架合成包 → 断言名称 / coin_type / 槽位保留 / tab0 镜像 / END 清空;`p8_test`:多货架 UI + 切页 + `buy pos 5`tab0 slot5/ `buy pos 43`tab1 slot3 | `extension/src/net/{wire.h,entity_store.{h,cpp},m2_client.{h,cpp}}` · `project/ui/shop_ui.gd` · `extension/tests/net_entity_test.cpp` · `project/p8_test.gd` |
| **选魔石窗(样板第 2 例)** | ✅ `project/ui/select_item_ui.gd``UiScript`+`UiBuild``assets/uiscript/uiscript/selectitemwindow.py``ItemSlot` = 5×8 `grid_table``ui_build._slot_cells``x_count/start_index/x_step/y_step` 生成 `slot_0..39`)。填格逐字照 `assets/root/uiselectitem.py.RefreshSlot`:遍历背包 `range(INVENTORY_PAGE_SIZE*2=90)` 格,`_is_metin``proto.item(vnum).type == 10 = ITEM_TYPE_METIN`+ `_item_grade(vnum) <= 2`= `proto.item(vnum).name` **内部名**最后一位数字,非数字按 0,复刻 `PythonPlayerModule.GetItemGrade`),`slotPos > 54` 截断,按背包格号升序映射到选择窗格 `_slot_to_inv[slotPos]=invCell`。点格 → `client.script_select_item(invCell)``send_script_select_item` → `CGScriptSelectItem{CG_SCRIPT_SELECT_ITEM 0x0903, len, selection}`= `net.SendSelectItemPacket`+ 关窗;`ExitButton` / 标题栏关闭 → `script_select_item(0)``uiselectitem.Close`),`_sent` 标记避免选完再补发 0。**触发链 1:1**`quest_dialog.gd.parse_script` 新识别 EventManager `[SELECT_ITEM]` token → `has_select_item``_on_script` 发新 `select_item_requested` 信号(对应 `PythonEventManager EVENT_TYPE_SELECT_ITEM``interfacemodule.BINARY_OpenSelectItemWindow``wndItemSelect.Open()`);`game_scene``proto`/`item_list` 就绪后建 `select_item_ui` 并把该信号接到 `.open``select_item_ui_test.gd``[SELECT_ITEM]` token 解析 + 非 metin / grade>2 过滤 + 升序映射 + 点格发 `script_select_item(7)` + 关窗不补发 + 空手关窗发 `script_select_item(0)` | `project/ui/select_item_ui.gd` · `project/ui/quest_dialog.gd` · `project/game_scene.gd` · `project/select_item_ui_test.gd` |
| **`item_proto` / `mob_proto` 读取器** | ✅ `mtproto::load_proto` —— 外层 `MIPX`(item`[fourcc][ver=1][stride][elements][datasize]`) / `MMPT`(mob`[fourcc][elements][datasize]`);内层 `MCOZ` CLZO 容器 = **XChaCha20**(本 fork 把 TEA 换成 libsodiumkey=BLAKE2b(key16,"M2DevPackEncrypt")nonce=BLAKE2b(key16,"M2DevNonce")[:24])解密 + **LZO1X** 解压。两把硬编码 4-DWORD key`ItemManager.cpp` / `PythonNonPlayer.cpp`)。`ItemRecord`/`MobRecord` 解前导字段(`bSpecular`@234 → §2.7**`alValues[6]`@191** —— aLimits[2]×5 @166 + aApplies[3]×5 @176 后,`bSpecular`@234 锚定整链)。armor 的 `alValues[3]` = race `.msm` 的 body shape index(对齐客户端 `__ArmorVnumToShape` `SHAPE_VALUE_SLOT_INDEX=3`)—— 实测 "Monk Plate Armour" 11200-09 全 = shape 3specular 随强化 0→100 递增。`proto.item_mob` CTest 断言 11209 `values[3]==3` + `specular==100`。5748 itemsstride 236+ 1338 mobsstride 335 | `extension/src/proto/proto.{h,cpp}` |
| **`AssetResolver` 接包**(首版)| ✅ `mtpack::PackMount` —— 挂载多个 `.epk`,按虚拟路径(盘符 + 反斜杠 + 大小写不敏感 + `ymir work/` 后缀)resolve/read`metin2_patch_*` 包后挂覆盖。`mtpack::AssetSource` —— 散文件 + 包统一:`read()` 取字节;`to_path()` 把包内文件解到缓存目录一次(路径型调用方不用改)。散文件优先于包(dev overlay,对齐客户端 `pack/` 覆盖)。`pack.roundtrip` CTest 扩:2 包挂载 + 覆盖 + 虚拟路径 + 非 ymir 路径。⬜ 把 `Metin2World`/`Metin2Model` 等 ~6 处 `resolver->resolve()` 换成 `AssetSource::to_path()`(机械改动)| `extension/src/pack/{pack_mount,asset_source}.{h,cpp}` |
| **输入 & 游戏相机**(首版)| ✅ `game_camera.gd` = `GameCamera`:右键拖拽环绕(yaw/pitch clamp+ 滚轮缩放(3.5–20m)+ 目标平滑跟随 + 地形防穿。`player_controller.gd` = `PlayerController`:**点地移动**(射线求地表交点 → 走过去,沿途 `is_blocked``attr.atr` bit0)判可行走,贴墙磨蹭按实际位移切回 wait —— 客户端预测,无服务端纠正)+ WASD 覆盖 + **选目标**(射线取最近 pickable → `target_selected` 信号)+ `anim_state`/`moved` 信号。已换进 `world_demo.gd`(去掉 ~70 行临时相机/输入)。⬜ 相机撞建筑/树淡出(§8.2)、格子 A* 寻路、NPC 交互/拾取 | `project/{game_camera,player_controller}.gd` |
| **本地化**(首版)| ✅ `locale.gd` = `Locale`:加载 `locale/locale/<lang>/{locale_interface,locale_game,itemdesc,skilldesc}.txt``KEY\tVALUE`UTF-8)。`t(key, args)` —— `%` 格式化 + 缺失回 `<KEY>`(开发期可见);`set_lang()``apply_font()` 挂 CJK ttf。已接 `charselect``JOB_*` 职业名 / `SELECT_LEVEL` / `SELECT_SELECT`=Start / `SELECT_CREATE`+ `login``LOGIN_ID`/`LOGIN_PASSWORD`/`LOGIN_CONNECT`)。18 语言(拉丁/西里尔/希腊/阿拉伯,Godot 原生,无需打包字体;CJK 需外挂 ttf)| `project/locale.gd` |
| **声音**(首版)| ✅ `audio.gd` = `Audio` 节点:**BGM 交叉淡**2× `AudioStreamPlayer``bgm/*.mp3`+ **UI 音效池**8×,`**/sound/ui/*.wav`+ **3D 定位音效**`AudioStreamPlayer3D``**/sound/<rel>`)。路径解析走 11 个声音根 + `bgm/``.mss` 不用,Godot 原生解 mp3/wav/ogg)。已接:`charselect``characterselect` BGM + `<class>_select` 选人音;`login``loginok`/`loginfail``world_demo``Metin2AnimPlayer.motion_event``sound` 字段 → `play_at(model_pos)`(脚步/挥击)。⬜ 按地图 BGM(服务端 `GCMainCharacter.szBGMName`)、`.msenv` 环境音循环、战斗/怪物/NPC 语音 | `project/audio.gd` |
| 登录 / 选人界面 | 🟡 首版 | `project/{login,charselect,ui_kit}.gd` |
| 声音(`AudioLib`→Godot audio| 🟡 首版 | `project/audio.gd` |
| `item_proto` / `mob_proto` 读取器 | ✅ | `extension/src/proto/proto.{h,cpp}` |
| 本地化(`EterLocale`| ✅ 首版 | `project/locale.gd` |
| 输入 & 游戏相机 | 🟡 首版 | `project/{game_camera,player_controller}.gd` |
| `AssetResolver` 从包读取(接 eterpack| 🟡 | `extension/src/pack/{pack_mount,asset_source}.{h,cpp}` |
## 3c. C 轨(渲染打磨,PARITY-GAP 不卡 §0 门禁的项)
| 项 | 状态 | 位置 |
|---|---|---|
| §8.2 相机遮挡淡出 + §8.3 相机撞建筑防穿 | ✅ 首版 | `extension/src/metin2_world.cpp`(层 2 盒碰撞)+ `project/game_camera.gd` |
| §2.2 GPU 路径发型 | ✅ | `extension/src/metin2_model.{h,cpp}` |
| §2.10 角色 LOD 距离切换 | ✅ 首版 | `extension/src/metin2_model.{h,cpp}` |
| §3.5 地形 patch 裁剪 | ✅ 首版(拆 4×4 patch + 逐 patch 视锥剔除 + 远景剔除)| `extension/src/metin2_world.{h,cpp}` |
| §2.6 顶点色 | ✅ 排查=不适用(全资产 gr2 无顶点色成员;逐顶点色是地形雾运行时 VB + `.mse` colorfactor| — |
| §1.2 `.mse` EffectLib 端口 | ⬜(大工程)| — |
## 4. 下一步
> **本节 14 项已于 2026-08-30 全部完成**(见 §3 与 [`CLIENT-ROADMAP.md`](./CLIENT-ROADMAP.md)
> 的 P0–P10)。保留为当时的计划记录,不要再当作待办执行。
1. ~~打通到 `.203` 的连通性~~ ✅。可达后立即能测:
- `./build/extension/net_probe 192.168.21.203 11000 admin 123456789` → 完整握手 + `CGLogin3``login_key`
- GDScript 里 `M2Client.connect_to_server(...)` → 一路到 `char_list` / `entered_game`
2. **`GCLoginSuccess3` 布局对真实字节校准**`net_probe` / `M2Client` 收到后 dump 原始字节,
核对 `TSimplePlayerInformation`(现假设 pack(1)、字段顺序照 `Packet.h:1161`)。size 不符会走
`login_failed` 兜底。
3. 游戏相位包解码:`GC_CHARACTER_ADD/DEL` / `GC_MOVE` / `GC_CHARACTER_UPDATE`
`NetworkActorManager` 端口 → 每实体一个 `Metin2Model`+`Metin2AnimPlayer`,用现有
`Metin2World` 当场景。里程碑「角色连上、站在地图里、别的实体在动」。
4. 输入(点地移动 → `CG_MOVE`)、聊天(`CG_CHAT`/`GC_CHAT` 接 HUD)。
## 5. 风险
- **协议版本漂移**`Packet.h` 结构体要和服务端 build 一致。冻结一对 client/server commit。
- ~~**端到端测试阻塞**:目前握手端口 refused,走不到 `CGLogin3` 之前的验证。~~ 已解除(§3)。
- **移动端 libsodium**Android/iOS 要静态 vendor(本身可移植,非阻塞)。
- 自用范围可跳过:商城 / GM 工具 / 部分社交系统。**反作弊(`CG_HACK` / `ProcessCRC` /
`ProcessScanner`)已在 `CLIENT-GAP.md`「不做清单」标注 ❌ 不做**,除非服务端强制校验。
## 6. 移植面(客户端源码)
`EterBase/SecureCipher` · `EterLib/{NetStream,ControlPackets,NetPacketHeaderMap}` ·
`UserInterface/{Packet.h, AccountConnector, PythonNetworkStream, PythonNetworkStreamPhase*}` ·
`UserInterface/NetworkActorManager`(实体) · `GameLib`(玩法)。
+263
View File
@@ -0,0 +1,263 @@
# 客户端重构与 40250 逻辑对齐实施方案
## 1. 现状诊断与核心痛点
### 1.1 现状与优势
- **渲染层(已基本稳定)**
- `extension/src/metin2_model.{h,cpp}`:基于开源 libgr2 的模型网格解析、材质贴图加载、GPU/CPU 骨骼蒙皮。
- `extension/src/metin2_anim.{h,cpp}`:骨骼动画采样、双动画平滑融合(Crossfade)、挂点武器同步。
- `extension/src/metin2_world.{h,cpp}`:地形高度场采样、贴图混合、水体、静态建筑碰撞。
- `project/game_camera.gd`:轨道摄像机、防穿模逻辑。
- **结论**:渲染层已具备优秀的跨平台能力(macOS/iOS/Android/Linux/Web),**无需推倒,纯粹作为“哑终端”画面渲染器(View)予以保留**。
### 1.2 逻辑层的混乱根源
当前各种 Bug(“WASD翻跟头”、“连击不流畅/手感怪异”、“NPC交互后商店空白”、“任务无法打开”)的共同根源在于:
1. **庞大的 GDScript 意大利面条代码**
- `net_play.gd` (~2200 行)、`game_scene.gd` (~1800 行)、`net_world.gd` (~1760 行) 试图用 GDScript 凭空重写 40250 的几万行 C++ 客户端逻辑。
2. **凭感觉实现的“伪逻辑”**
- 没有严格对照 40250 C++ 源码的状态机(如按键优先级、阻尼公式、连击输入窗、NPC点击距离分流、封包处理链),而是用自制计时器和启发式逻辑拼接,导致产生大量边界 Bug 和时序脱节。
3. **数据没有单一真实来源(Single Source of Truth**
- 实体数据同时散落在 C++ `EntityStore``net_play.gd``net_world.gd``client.ents``node.meta` 中,互相通过数十个信号来回同步,极易丢状态或时序错乱。
---
## 2. 1:1 复刻的边界与原则(复刻哪些,不复刻哪些)
> [!IMPORTANT]
> **核心原则**:**“形式上”不要 1:1 照搬类文件,但“内核上”必须 1:1 严格对齐封包、状态机与数学公式。**
### 2.1 坚决不要 1:1 复刻的部分(直接使用 Godot 原生)
40250 源码中超过 60% 的代码是在重复造 2004 年 DirectX 8 / Windows 的轮子,这些在现代跨平台环境中是沉重的技术包袱:
| 40250 原版模块 | 为什么不要 1:1 复刻 | Godot 现成替代方案 |
| :--- | :--- | :--- |
| **`EterLib` 图形底层**<br>(`CGraphicDevice`, `CStateManager`, `CRenderTarget`) | 深度强绑定 DirectX 8/9,引入会导致无法在 macOS (Metal) 及移动端 (Vulkan) 编译。 | **Godot 渲染引擎**(原生支持 Metal / Vulkan / WebGL |
| **`EterPythonLib` 2D UI 系统**<br>(`CWindowManager`, `CWindow`, `CButton`, `CScrollBar`) | 20 年前手工编写的固定坐标 2D 贴图拼接系统,不支持高分屏自适应、矢量字体、触屏手势。 | **Godot Control 体系**(完善的锚点自适应、触屏支持) |
| **`milesLib` 音频系统** | 2004 年的 Miles 闭源声卡驱动库。 | **Godot AudioServer / AudioStreamPlayer** |
| **`CCamera` 摄像机类** | 自行维护的 D3D 视锥矩阵,死板且不灵活。 | **Godot `Camera3D`**(现有的 `game_camera.gd` 表现良好) |
| **`EterGrnLib` 模型封装** | 依赖 Granny 2.8 闭源 Windows DLL。 | **现有 `metin2_model` + `metin2_anim`**(基于开源 libgr2,功能已完备) |
| **内嵌 Python 2.7 解释器** | Python 2.7 早已停止维护,在 iOS / Android 上打包极其臃肿且受 App Store 审核限制。 | **C++ 纯逻辑导出给 GDScript 调用** |
### 2.2 必须 1:1 严格复刻的部分(下沉至 C++ GDExtension 核心逻辑层)
下面这些是 Metin2 的“灵魂与运行规则”,必须 100% 对齐 40250 源码,漏掉一个判断分支就会导致手感完全走样:
1. **网络协议与封包结构(1:1 严丝合缝)**
- 对应源码:`Packet.h`
- 要求:每个 `TPacketGC*`(如 `TPacketGCShop``TPacketGCCharacterAdd``TPacketGCDamageInfo`)的结构体字段、字节对齐、Subheader 常量 100% 照搬。
2. **角色移动与物理状态机(1:1 算法复刻)**
- 对应源码:`PythonPlayerInput.cpp``InstanceBaseMovement.cpp``ActorInstanceMovement.cpp`
- 要求:按键优先级判定(`NEW_GetMultiKeyDirRotation`)、转向角 45° 阻尼与防过冲单向贴齐(`GetRotatingDirection``SetAdvancingRotation`)、碰撞切线滑移(`AdjustDynamicCollisionMovement`)、位移累加(`__AccumulationMovement`)。
3. **战斗与打击判定状态机(1:1 状态机复刻)**
- 对应源码:`ActorInstanceBattle.cpp``InstanceBaseBattle.cpp`
- 要求:连击段位状态转移(Combo 1→2→3→4)与按键输入时间窗(`ComboInputData`)、`.msa` 多命中时间窗(`HitDataContainer`)的毫秒级触发、普通受击方位判定与击倒击飞硬直。
4. **业务数据模型与操作上下文(1:1 逻辑复刻)**
- 对应源码:`PythonShop.cpp`(商店货架数据)、`PythonQuest.cpp`(任务状态树)、`PythonPlayerSkill.cpp`(技能冷却与释放条件三门)。
---
## 3. 架构重构结论与策略
### 3.1 架构重构的必要性
现有项目积累了大量技术债务,表面上看是“连击不连贯”、“WASD翻跟头”、“NPC打不开商店”等散发 Bug,深层原因是架构失衡:
1. **“上帝文件(God Class)”过载**:`net_play.gd` (2200+行) 与 `game_scene.gd` (1800+行) 把网络封包解码、角色状态、背包/商店业务、3D渲染节点管理、点击拾取、UI开关全部混在一起,牵一发而动全身。
2. **缺乏单一真实来源(No Single Source of Truth**:角色坐标、朝向、连击段位在 `player_controller``net_play``net_world` 和 C++ `EntityStore` 中各自维护,靠数十个信号来回同步,极易时序混乱或丢状态。
3. **启发式“伪逻辑”**:没有遵循 40250 的严密数学公式与状态机,而是用散落的计时器和直觉代码修补。
### 3.2 重构策略:拒绝推倒重来,采用“绞杀者式渐进解耦(Strangler Fig Pattern)”
- **保留成果(绝不推倒)**:已稳定的 C++ GR2 渲染层(`metin2_model``metin2_anim``metin2_world`)和摄像机防穿模逻辑完全保留。
- **业务逐块下沉**:按“战斗 -> 商店/NPC -> 任务 -> 背包”的顺序,把上帝文件中的业务逐个抽成独立的 Manager/状态机,先解耦跑通,再下沉对齐 40250。
---
## 4. 推荐的目标系统架构(`extension/src/m2_core/`
在 C++ GDExtension 中建立清晰、模块化、无 DirectX 依赖的逻辑层:
```
extension/src/m2_core/
├── protocol/ # 1:1 严格对齐 40250 的网络层
│ ├── packets.h # 1:1 复制 40250 Packet.h 的所有包结构体
│ └── packet_dispatcher.h # 1:1 复制 PythonNetworkStreamPhaseGame 的封包解析分发
├── player/ # 1:1 复制 CPythonPlayer 的核心逻辑
│ ├── player_input.cpp # 1:1 移植 PythonPlayerInput.cpp (按键优先级、朝向合成、预约动作)
│ ├── player_state.cpp # 1:1 移植 玩家属性、金币、背包、技能CD数据
│ └── player_combat.cpp # 1:1 移植 连击判定、攻击输入窗
├── world/ # 1:1 复制 CPythonCharacterManager & CInstanceBase
│ ├── instance_entity.cpp # 1:1 移植 实体状态机、45°阻尼移动、切线碰撞滑移
│ └── combat_resolver.cpp # 1:1 移植 命中范围、受击硬直计算
└── gameplay/ # 1:1 复制业务逻辑数据源
├── shop_system.cpp # 1:1 移植 PythonShop.cpp (解决商店商品空白)
└── quest_system.cpp # 1:1 移植 PythonQuest.cpp (解决NPC任务交互)
```
### 运行时数据与渲染流向:
```mermaid
flowchart TD
subgraph View ["Godot 4 表现与输入层 (View & Input)"]
InputHandler["Input / Touch / UI 事件捕获"]
GodotNodes["PlayerView / MobView (Metin2Model + Metin2AnimPlayer)"]
UI_Screens["ShopUI / QuestUI / InventoryUI / CharacterUI"]
end
subgraph LogicCore ["GDExtension 40250 纯逻辑核心 (Core Logic)"]
CPP_Input["PlayerInput (按键优先级/朝向合成/输入队列)"]
CPP_Move["InstanceEntity (45°阻尼/切线滑动/累积位移)"]
CPP_Combat["Combat & Combo (命中窗/连击状态机)"]
CPP_Net["NetworkStream & Dispatcher (收发严丝合缝对齐 40250)"]
CPP_Data["Shop & Quest Data (货架缓存/任务对话树)"]
end
subgraph Server ["40250 游戏服务端"]
GameServer["TCP Server"]
end
InputHandler -->|按键/点击事件| CPP_Input
CPP_Input --> CPP_Move
CPP_Move -->|状态包| CPP_Net
CPP_Combat <--> CPP_Net
CPP_Net <-->|TCP 封包| GameServer
CPP_Move -.->|输出权威坐标 (x,y,z) 与朝向 yaw| GodotNodes
CPP_Combat -.->|输出当前动作名、时间与打击帧| GodotNodes
CPP_Data -.->|响应数据变动通知| UI_Screens
```
---
## 5. 移动端(iOS / Android)操作界面与跨端支持设计
> [!NOTE]
> **结论**:**当前规划的解耦架构天然、完美支持后续无缝接入移动端专属操作界面与触控系统。**
### 5.1 核心解耦机制:输入意图抽象层(Input Intent Abstraction
重构后的架构将**“硬件输入采集”**与**“游戏角色行为”**彻底解耦。底层逻辑不再监听 `KEY_W``KEY_SPACE` 或鼠标点击,而是消费高阶**输入意图(Input Intents**
```mermaid
flowchart TD
subgraph InputSources ["输入源采集(多端隔离)"]
PC_Input["PC 桌面端输入<br>(WASD / 鼠标左右键 / 快捷键 1~4 / 空格)"]
Mobile_Input["移动端触控输入<br>(虚拟摇杆 / 技能轮盘 / 普攻大按钮 / 目标切换)"]
end
subgraph IntentLayer ["输入意图抽象接口 (PlayerInputIntent)"]
I_Move["move_intent(dir: Vector2, is_run: bool)"]
I_Attack["attack_intent()"]
I_Skill["skill_intent(slot_idx: int)"]
I_Target["target_intent(vid: int)"]
I_Interact["interact_intent(vid: int)"]
end
subgraph CoreLogic ["40250 核心状态机 (跨平台纯逻辑)"]
M2_Move["45° 转向阻尼 / 切线滑移碰撞"]
M2_Combat["连击输入时间窗 / 多段命中判定"]
M2_Target["500cm 距离门限 / 预约走向交互"]
end
PC_Input -->|转换映射| IntentLayer
Mobile_Input -->|转换映射| IntentLayer
IntentLayer --> CoreLogic
```
1. **移动控制无缝适配**
- PC 端:WASD 键位经过 40250 `NEW_GetMultiKeyDirRotation` 算成方向向量。
- 移动端:屏幕左侧的**虚拟摇杆(Virtual Joystick**直接输出方向向量给 `move_intent`,底层 45° 转向阻尼与碰撞滑移算法无需修改一行代码。
2. **战斗与技能轮盘**
- 移动端右侧可布局经典“大按键普攻 + 环形技能轮盘”,普攻按钮每次点击触发 `attack_intent()`,底层直接进入 40250 的 `ComboInputData` 输入缓冲队列。
### 5.2 双端自适应 UI 表现层(Dual-Mode Adaptive UI
由于业务数据(`ShopManager``InventoryManager``QuestManager`)已与界面彻底解耦:
- **数据核心统一**:所有数据模型只负责维护商品列表、道具属性、背包格子、对话选项,通过信号/事件对外广播。
- **UI 视图按端加载**
- PC 端:加载 `DesktopHUD.tscn`(支持鼠标 Hover 查看装备 Tooltip、右键穿戴、拖拽格子)。
- 移动端:根据 `OS.has_feature("mobile")` 或屏幕分辨率动态加载 `MobileHUD.tscn`(更大的触控热区、点击弹出操作抽屉 Drawer、全屏大卡片商店展示)。
- **完全零冗余**:两套 UI 共享 100% 的底层数据逻辑、网络协议与状态机。
---
## 6. 分阶段实施路线图
按照“**先主要解决战斗系统不一致,再解决 NPC/商店/任务交互混乱**”的优先级分步推进:
### 阶段一:战斗与连击系统精准对齐(优先级:最高)
> 对应 40250 源码:`ActorInstanceBattle.cpp`、`InstanceBaseBattle.cpp`、`formats/msa.h`
1. **普通攻击与连击链(Combo Chain1:1 复刻**
- 移植 40250 `ActorInstanceBattle.cpp:157-308` 状态机:
- `m_dwcurComboIndex`(连击段数 1→2→3→4
- `ComboInputData`:严格按照 `.msa``InputStartTime``InputEndTime``NextComboTime` 判定是否允许按下一次普攻。
### 阶段一:移动与战斗核心状态机 40250 对齐(优先级:最高)✅ [COMPLETED]
> 对应 40250 源码:`PythonPlayerInput.cpp`、`ActorInstanceBattle.cpp`、`InstanceBaseBattle.cpp`
1. **废弃原有人为的外层普攻 CD 拦截**
- 彻底废除 `_attack_cd` 吞键逻辑,完全对齐 40250 普攻/连击内禀门控机制。
2. **严格 1:1 接入 `.msa` 的连击输入数据窗**
- 读取并严格应用 `duration``InputStartTime``NextComboTime``InputEndTime`
- 完美支持长按空格连续 4 击(1→2→3→4 段无缝切换与回 Wait 重置)与快速连点。
3. **受击与击倒对齐**
- 动作播放时长内动态释放受击锁,触发屏幕震颤与击退同步。
### 阶段二:NPC 交互与商店系统修复(优先级:高)✅ [COMPLETED]
> 对应 40250 源码:`PythonPlayerInputMouse.cpp`、`PythonNetworkStreamPhaseGame.cpp`、`PythonShop.cpp`、`uishop.py`
1. **NPC 点击与预约走向交互(__OnClickActor**
- 点击 NPC:距离 `<= 500 cm``CLICK_DIST_NPC`)立即停步、面朝 NPC 发送 `HEADER_CG_ON_CLICK` (26)。
- 距离 `> 500 cm`:进入 `m_eReservedMode = CLICK_ACTOR` 预约模式,驱动角色寻路靠近,进入 500cm 瞬间停步交互。
- 加入 40250 `CPythonPlayer::__SendClickActorPacket` 的 1000ms 狂点节流保护。
2. **修复商店数据流与属性丢失(彻底解决白板商品 Bug)**
- C++ `mtnet::ShopEntry` 补齐 `sockets[3]``attrs[7]`,解析 `START` / `START_EX` / `UPDATE_ITEM` 全量属性。
- `m2_client` 修复切页被冲掉问题,仅在初次打开触发 `shop_opened`,格位更新触发 `shop_updated(pos)`
- UI 接入 40-slot 货架、多 Tab 切换(`tabIdx * 40 + slot`)、右键直接秒买(`UnselectItemSlot`)、超出 10m 离开自动关闭窗口。
### 阶段三:任务系统与对话流对齐(优先级:中)✅ [COMPLETED]
> 对应 40250 源码:`PythonEventManager.h/cpp`、`PythonQuest.cpp`、`PythonNetworkStreamPhaseGame.cpp``RecvScriptPacket`)、`uiquest.py`
1. **40250 任务与 NPC 交互协议全套对齐**
- 封包层精准对齐:`HEADER_GC_SCRIPT` (45)、`HEADER_CG_SCRIPT_ANSWER` (29)、`HEADER_CG_SCRIPT_BUTTON` (66)、`HEADER_CG_QUEST_INPUT_STRING` (30)、`HEADER_GC_QUEST_CONFIRM` (46)、`HEADER_CG_QUEST_CONFIRM` (31)。
- 在 C++ 网络会话层 (`ClassicSession`) 与 `M2Client` 实现全套安全发包接口:`send_script_answer``send_script_button``send_quest_input``send_quest_confirm``send_quest_cancel`
2. **40250 `CPythonEventManager` 脚本语法与表现层 1:1 解析**
- 支持标准 40250 分号/管道符问题格式 `[QUESTION 1;选项一|2;选项二|...]`,0-indexed 答案映射,保留向后兼容旧格式。
- 实现 40250 `uiquest.py` 8 选项多页分页机制(`MAX_CHOICES_PER_PAGE = 8`),自动挂载 `[上一页]` / `[下一页]` 导航行,精确计算全局索引 `page * 8 + slot`
- 皮肤与立绘对齐:`SKIN_NOWINDOW` (1) 与 `SKIN_CINEMA` (5) 隐藏背景板;`LEFTIMAGE` 自动偏移文本与按钮内容区宽度。
- 支持 `[INPUT]` 文本输入框即时提交、`[NEXT]` 剧情推进与 `[DONE]` 流程收尾(`script_answer(254)`),以及 ESC 键安全取消(`OnCancel`)。
- 编写 `test_quest_dialog_parity.gd` 覆盖 9 大测试场景 45 项断言全部 100% 通过。
### 阶段四:角色属性面板、背包与技能系统对齐(优先级:中)✅ [COMPLETED]
> 对应 40250 源码:`PythonPlayerSkill.cpp`、`PythonSkill.h`、`PythonItem.cpp`、`GameType.h`、`Packet.h`、`uicharacter.py`
1. **主动技能释放链与三层完整门控(40250 CheckSkillUsable 对齐)**
- 严格 1:1 实现死亡状态(`CANNOT_ACT`)、安全区攻击拦截(`IN_SAFE`,增益 Buff 技能放行)、武器类型匹配(`NOT_MATCHABLE_WEAPON`)、弓箭数量检验(`EMPTY_ARROW`)、SP/HP 消耗门检(`NOT_ENOUGH_SP`)、冷却时间阻断(`WAIT_COOLTIME`)以及施法距离/目标判定(超出距离进入 `RESERVED` 预约模式,进入范围后放行)。
2. **装备与背包双端线协议对齐(40250 ItemPos 对齐)**
- `_to_wire` 严格映射 40250 `TItemPos`:背包槽位 `0..89` 映射 `WINDOW_INVENTORY (1)`;装备槽位 `90..100` 映射 `WINDOW_EQUIPMENT (2)` 且内部 cell 严格从 `0` 开始偏移;时装槽位 `109..113` 映射 `WINDOW_INVENTORY (1)`
- 右键穿脱发送 `HEADER_CG_ITEM_USE` (11),拖拽移动发送 `HEADER_CG_ITEM_MOVE` (12),并严格走 `EquipRules` 职业、等级(`LIMIT_LEVEL`)与性别门控。
3. **角色属性面板实时换算与指令对齐(uicharacter.py:RefreshStatus 对齐)**
- 物攻格式严格呈现 `min-max`(或相等时单值显示)、物防 `DEF_GRADE + DEF_BONUS`、魔攻 `MAG_ATT + MAGIC_WEP` 纯正公式。
- 加点与洗点协议通过安全网络通道发送原生 `/stat ht``/stat iq``/stat st``/stat dx``/stat-` 指令。
4. **快捷栏(Quickbar)持久化与全量同步(40250 TQuickSlot 协议)**
- 快捷栏配置新增发送 `HEADER_CG_QUICKSLOT_ADD` (16)(支持技能、物品、动作表情)、交换发送 `HEADER_CG_QUICKSLOT_SWAP` (18)、清空发送 `HEADER_CG_QUICKSLOT_DEL` (17),并支持全量 36 格状态本地与服务端双向恢复。
- 编写 `test_skill_inventory_parity.gd` 覆盖 4 大领域 42 项测试断言全部 100% 通过。
---
## 7. 技术实施规范
1. **严禁“自造算法”**
- 任何涉及移动、旋转、连击、受击、技能、网络包逻辑的代码,必须明确注明对应的 40250 源码文件和行号(例如 `// 对齐 40250 ActorInstanceBattle.cpp:215`),1:1 复刻其状态转移条件。
2. **职责边界分明**
- **C++ (GDExtension)**:拥有所有数据、状态机、网络封包、物理数学计算。
- **GDScript / Godot Node**:只做两件事:① 把 C++ 的位置和动作画在屏幕上;② 把屏幕上的按键和点击事件传给 C++。
3. **严格单向数据流**
- 废除 GDScript 层各节点之间混乱的信号交叉监听,所有状态统一归拢到核心状态机。
---
## 8. 验证计划
1. **单元测试与 Headless 自动化测试**
- 为连击状态机编写类似 `test_wasd_steering_parity.gd` 的无头自动化回归测试。
- 模拟封包注入测试商店数据解析与 NPC 交互流程。
2. **实服联机验证**
- 配合真实 40250 服务端,联机点击杂货商/铁匠,验证商品列表 100% 正常展示并成功购买。
- 联机打怪,验证普通攻击连击节奏、打击僵直与伤害数字飘字时机与原版客户端完全吻合。
+4 -4
View File
@@ -1,7 +1,7 @@
# 客户端玩法开发计划
> 把 [`CLIENT-GAP.md`](./CLIENT-GAP.md) 的缺口排成可执行的阶段计划。
> 网络栈进度见 [`CLIENT-PORT.md`](./CLIENT-PORT.md),渲染见 [`PARITY-GAP.md`](./PARITY-GAP.md) / [`BACKLOG.md`](./BACKLOG.md)。
> 网络栈进度见 [`CLIENT-40250-PORT.md`](./CLIENT-40250-PORT.md),渲染见 [`PARITY-GAP.md`](./PARITY-GAP.md) / [`BACKLOG.md`](./BACKLOG.md)。
>
> 规模:S ≤ 3 天 · M ≈ 1 周 · L ≈ 2–3 周 · XL ≈ 1 月+
> 标记:🌐 需服务器端到端 · 💻 离线可做(mock server / 合成封包 / 假 client
@@ -197,7 +197,7 @@ motion_name 就是特效基名,上一轮列偏移修好后自然接上);
**已验证**`net.entity_store` 扩展(`GC_SCRIPT``[QUESTION]``ScriptCue``GC_QUEST_CONFIRM``ConfirmCue``GC_QUEST_INFO` 带 TITLE+COUNTER flag → `QuestInfo` 变长尾解析,`length` 字段回填);`quest_test.gd``parse_script`:纯文本保留 / `[ENTER]`→换行 / `[QUESTION]`→2 选项 / `[DONE]`→继续钮;`script_dialog` 信号 → 弹窗 + 3 选项钮 + 选 B → `script_answer(1)` + 关窗;无选项 → 继续 → `script_answer(255)``quest_confirm_ask` → 接受 → `quest_confirm(true,999)`quest_log 只显示有标题的 quest + `quest_info` 刷新不炸)。13 个 GDScript 测试套件全绿,ctest 9/9iOS 编过。
**未完成**`[INPUT]` 文本输入 UI`CGQuestInputString` 封包已备)、`SCRIPT_SELECT_ITEM` 选道具、NPC 对话左右立绘、`[QUESTBUTTON]``script_button`、地图 / 世界任务箭头(`TARGET_CREATE_TYPE_LOCATION`)、端到端(见 `CLIENT-PORT.md` §3:握手 + 认证已联通,进游戏串场归 P10)。
**未完成**`[INPUT]` 文本输入 UI`CGQuestInputString` 封包已备)、`SCRIPT_SELECT_ITEM` 选道具、NPC 对话左右立绘、`[QUESTBUTTON]``script_button`、地图 / 世界任务箭头(`TARGET_CREATE_TYPE_LOCATION`)、端到端(见 `CLIENT-40250-PORT.md`:握手 + 认证已联通,进游戏串场归 P10)。
## P8 — 社交 / 商店 / 仓库(XL,🌐)
@@ -290,7 +290,7 @@ motion_name 就是特效基名,上一轮列偏移修好后自然接上);
(协程化的 `setup()``entity_spawned` 接得晚,会漏初始 spawn 洪流);`game_scene`
`set_local_vid` + `net_play._on_main_set``catch_up`,避免本地玩家被画成压身的蓝胶囊。
详见 `CLIENT-PORT.md` §3。**23 套 GDScript + 10 CTest + 项目导入 + macOS/iOS 扩展全绿。**
详见 `CLIENT-40250-PORT.md`。**23 套 GDScript + 10 CTest + 项目导入 + macOS/iOS 扩展全绿。**
**剩的不是代码问题**:字面在线人数(Metin2 协议只有 4 态负载,要数字得改服务器)、全量 monster 资产。
---
@@ -313,7 +313,7 @@ PHASE_GAME —— 收到 121 个实体、HP/SP/等级、20 格背包、好友列
1. ~~真机字节校准第二轮~~ ✅ 已做(2026-08-30):`GC_SKILL_LEVEL_NEW`(0x021B,本服才发这个,
之前技能窗全空) + `GC_QUICKSLOT_*`(0x0519/1A/1B) 补上;`GC_NPC_POSITION` / `GC_ITEM_UPDATE` /
`GC_CHAT` / `GC_AFFECT_ADD` / `GC_SPECIAL_EFFECT` / `GC_QUEST_INFO` 逐字节确认全对。详见 `CLIENT-PORT.md` §3
`GC_CHAT` / `GC_AFFECT_ADD` / `GC_SPECIAL_EFFECT` / `GC_QUEST_INFO` 逐字节确认全对。详见 `CLIENT-40250-PORT.md`
2. ~~换装完整~~ ✅ 已做(2026-08-30):`Metin2Model` 加第二刚体挂点(盾,`Bip01 L Hand``_load_attach`
抽公共加载器);`equip_model` 补 WEAR_SHIELD / WEAR_HEAD(头盔覆盖 hair 槽)/ 头发随 `parts[HAIR]`
**armor→shape 真表**proto 读器加 `alValues[6]`@191`equip_model._armor_shape_default` = `item_proto
+275
View File
@@ -0,0 +1,275 @@
# 首个 Mac 联网内测版:开发实施文档
日期:2026-09-11。范围:Apple Silicon / arm64 Release。
本文将联网闭环、基础战斗、首测地图和稳定性四项需求拆为可开发任务。本文新增的文件、API、环境变量均为**待实现设计**,不是已存在功能;列为“现有”的入口已按当前工作树核对。本轮只编写文档,不执行联网测试或修改运行时代码。
## 1. 基线与交付边界
现有候选包:`build/export-native-trees-20260911/mtgodot-poc.app`。已通过离线包内加载及干净退出;树木专项覆盖 87 种资源、六图 2092 棵树,不能代替联网或树怪验收。
首版放行不是“渲染全部完成”。必须交付:固定哈希的候选包、实际联网闭环证据、基础战斗矩阵、首测地图截图、稳定性报告和已知问题。风动、逐叶朝向、SpeedTree 原生 LOD、40250 全环境像素一致性可延期。
首测地图拟为出生城镇及练级区、鬼木林、赤鬼木林。开发前通过资源和实际服务器确定 map key、进入坐标、合法路线、角色槽位与怪物刷新;不得凭中文名硬编码地图路径。测试角色等级、装备、技能、掉落条件需由测试环境负责人准备;没有条件时报告 BLOCKED,禁止伪造事件或偷偷修改服务器。
## 2. 已有实现与明确缺口
| 现有文件 / API | 可复用部分 | 本轮必须补齐 |
| --- | --- | --- |
| `script/live_smoke_test.sh` | 指定 MT_SMOKE_APP、凭据输入、报告与进程码 | 地址预检写死;无完整退出错误门禁;缺墙钟超时和战斗流程 |
| `project/live_smoke_test.gd` | setup、状态机、世界读取、移动、聊天、reconnect | 发包成功不是服务端结果;无完整打怪拾取链 |
| `project/client_main.gd` | 包内 MT_TEST_MODE=smoke/render 分流 | 增加 playable、地图视觉入口;普通启动不启用测试 |
| `project/app_flow.gd` | start、state、登录/选角/进场和断线生命周期 | 提供只读场景快照,避免测试遍历私有节点 |
| `project/net_play.gd` | set_attack_key、pick_ground_item、skill_context | 提取公共选目标入口,沿用真实战斗逻辑 |
| `project/ui/quickbar.gd` | activate、assign、restore_from_serverskill_cast_started、skill_rejected | 输入、服务端结果与视觉证据关联 |
| `project/player_skill.gd` | use_skill、可用性与目标检查 | 新增负向/生命周期覆盖,不另造施法实现 |
| `project/ui/mob_view.gd` | build、set_anim_state、motlist 映射 | 树怪矩阵及禁止静默动作回退的验收 |
| `project/rendering_scenario_test.gd` | 离线 CPU/GPU 多角色、帧时间、内存计数 | P99、事件关联、RSS、长期联网负载;现有假客户端不是实网证据 |
| `script/package_render_test.sh` | 进程退出后扫描完整日志 | 抽取通用门禁供联网/稳定性使用 |
当前 `mob_winding_test.gd` 只覆盖 101、110、20001,不覆盖树怪。`native_tree_test.gd` 测的是静态场景树,不能复用其 PASS 证明怪物蒙皮正确。
## 3. 开发拆分与依赖
每个任务需单独可审查 diff,先添加失败用例再修复;工作树已有修改必须保留,不把无关修改混进任务结论。
| ID | 开发内容 | 依赖 | 完成产物 |
| --- | --- | --- | --- |
| INF-01 | 场景清单、报告协议、事件采集、配置校验 | 无 | 配置负测及报告协议测试 |
| INF-02 | 通用包运行器、墙钟超时、退出门禁 | INF-01 | 假进程门禁测试 |
| NET-01 | 包内真实联网状态机、只读诊断接口 | INF-01/02 | 3 次真实闭环 |
| CBT-01 | 技能/普攻输入路径与证据关联 | NET-01 | 四职业矩阵 |
| MAP-01 | 地图/树怪资源审计与离线 Metal 截图 | INF-01,可与 NET 并行 | 资源清单、动作截图 |
| MAP-02 | 实际地图定位、战斗、落地检查 | NET-01、MAP-01 | 首测地图验收 |
| STB-01 | 采样、循环场景、故障与窗口测试 | NET-01、CBT-01、MAP-02 | 2 小时报告 |
| REL-01 | 重打包、聚合门禁、已知问题 | 全部 | 可追溯内测包 |
实际运行缺陷的修复范围由证据决定;不能预先将未知 bug 标成已定位。缺陷修复卡必须写出:失败用例 ID、资源/角色/地图、原因、修改文件、修复前后证据、回归集合。
## 4. INF-01:测试基础设施的数据契约
### 4.1 新增文件
- `test/playable/scenario.example.json`:无账号密码的模板,未解析的地图/技能字段留空并拒绝运行。
- `project/testing/playable_config.gd`:验证配置、场景与资源前置条件。
- `project/testing/playable_report.gd`:统一检查、事件记录与最终报告。
- `project/testing/playable_probe.gd`:只读订阅生产对象,不发包、不写实体数据。
- `project/playable_harness_test.gd`:假客户端验证状态机和证据判定,不作为实网验收。
配置采用 schema_version=1;包含 scenario_id、protocol、serverinfo 中选定地址、character_slot、map_key、waypoints_cm、allowed_mob_vnums、allowed_drop_vnums、skill_cases、resolution、loops、timeout_seconds。所有坐标字段显式带单位,网络到世界换算沿用生产代码;不把 Vector2/Vector3 强转用于凑通过。路线必须预先人工确认安全可达。
账号密码仅通过现有 MT_ACCOUNT/MT_PASSWORD 或安全交互输入提供;禁止写入 JSON、命令行参数、截图或报告。报告不复制进程环境。启动脚本不能使用 set -x。日志在落盘前脱敏,测试账号名也不写入最终公开报告。
### 4.2 报告契约
`report.json` 必须具有如下字段:
```json
{
"schema_version": 1,
"run_id": "unique-run-id",
"suite": "playable",
"status": "BLOCKED",
"build": {"engine_sha256": "...", "extension_sha256": "...", "pck_sha256": "...", "arch": "arm64"},
"environment": {"os": "...", "renderer": "...", "resolution": [1280, 720]},
"cases": [{"id": "NET-ATTACK-01", "status": "BLOCKED", "duration_ms": 0, "reason": "fixture_not_ready", "evidence": []}],
"failures": [],
"blocked": ["fixture_not_ready"],
"coverage": {"required": 1, "passed": 0},
"exit_gate": {"checked": false, "process_code": null, "errors": []}
}
```
状态限定 PASS/FAIL/BLOCKED/SKIP;必测项 BLOCKED、SKIP 均不得放行。未知 schema、空 cases、缺哈希、证据文件不存在、required 与清单不一致均失败。客户端只能写 `client-report.json`,父运行器在真实退出后生成最终 `report.json`,防止退出前 PASS 掩盖泄漏。
事件写 `events.jsonl`,每条含 monotonic_us、run_id、case_id、connection_epoch、stage、kind、actor_vid、target_vid、payload(白名单字段)。重连后 VID 可变化,按角色身份与新 epoch 重新绑定,禁止等待旧 VID。检测“来自服务端”的依据必须是接收端分发,而非本地预测信号。
### 4.3 必须先有的负向测试
无凭据/坏配置、空覆盖、旧报告、错 run_id、报告 PASS 但进程失败、PASS 后输出 RID 警告、只有本地动画无服务端事件、重连后旧事件、超时不退出,均应返回非零。任何缺测都不能降级成 PASS。
## 5. INF-02:统一运行与退出门禁
新增 `script/run_client_gate.sh``script/validate_playable_report.mjs`,由 `script/playable_test.sh` 调用;复用 `package_render_test.sh` 的错误匹配规则,不弱化现有门禁。
1. 每次创建唯一输出目录,记录本次 run_id 和包哈希;不复用历史报告。
2. 校验包可执行、架构和签名;清除 MT_ASSETS,保证验收用包内资源。
3. 预检地址与 `project/net/serverinfo.gd` 的实际选中配置一致。新增测试配置覆盖只能在测试模式生效;删除冒烟脚本预检地址与客户端连接地址分叉的风险。
4. 只管理本运行器启动的子进程 PID;超时先 TERM,宽限 10 秒后 KILL,再 wait 收集状态。不得 pkill 所有客户端或修改全机网络。
5. 读取完整标准输出/错误,检查 SCRIPT ERROR、ERROR、leaked at exit、shaders never freed。无过滤 RID 警告的允许名单。
6. 同时满足进程 0、报告协议正确、全部必测 PASS、完整日志门禁通过,才写最终 PASS。
建议退出码:0=PASS,1=断言或退出门禁失败,2=配置/前置条件阻塞,124=墙钟超时。进程原始退出码另存报告,不丢失。
门禁单测使用脚本生成的假子进程输出,不需游戏服务器。确认超时分支、信号退出和 stdout 管道不会绕过真实进程码。
## 6. NET-01:真实联网闭环状态机
### 6.1 修改点与接口
- 新增 `project/playable_live_test.gd`,实现 `setup(flow: Node, client: Node, config: Dictionary)`
- `client_main.gd` 增加 `MT_TEST_MODE=playable` 分支,在 AppFlow 建立后挂载该 Node;不得导出后用 --script 替代包内入口。
- `app_flow.gd` 新增只读 `get_playable_snapshot() -> Dictionary`,返回 stage、角色身份摘要、scene_ready 和当前地图。测试对象引用通过明确的测试 adapter 获取,不把 Node 放入 JSON。
- `game_scene.gd` 新增只读 `get_playable_context() -> Dictionary`,在测试模式提供现有 net_play、quickbar、net_world、world、player 的引用;场景销毁时失效。
- `net_play.gd` 新增公共 `select_target(vid: int) -> bool`,复用现有 `_change_target_to_picked_instance``_on_pick` 的共同目标选择逻辑;保留距离、死亡和可攻击检查,不直接改私有字段。
先核实 M2Client 信号签名与 classic 收包源;缺少来源可辨识证据时,向现有接收分发处增加测试诊断通知,不添加假业务事件,不改变协议格式。
### 6.2 状态及判定
| 状态 | 动作 | 成功证据 | 默认超时 |
| --- | --- | --- | --- |
| LOGIN | 复用 AppFlow 自动登录 | char_list 接收且指定槽位存在 | 30s |
| SELECT | 现有选角流程 | entered_game + 主实体有效 | 45s |
| WORLD_READY | 等待场景加载 | map key 符合、地图与角色就绪、HUD 可用 | 60s |
| MOVE | 通过玩家控制器走配置路线 | 本地到达 + 无服务端纠正;重登/服务端观察验证落点 | 20s/点 |
| TARGET | select_target 选择允许范围怪物 | 当前 epoch 中存活实体、race 合法 | 30s |
| ATTACK | set_attack_key(true),结束/失败必释放 | 指定目标接收血量/伤害变化与死亡结果 | 90s |
| DROP | 观察真实掉落 | 新地面物品、允许 vnum、可拾取归属和邻近位置 | 30s |
| PICKUP | pick_ground_item(iid) | 地面消失 + 背包对应 vnum 总量增加 | 15s |
| EXIT | 正常退出入口 | 父运行器检查真实退出和完整日志 | 20s |
MOVE 不要求服务器必然回显主角每一个移动包;classic 若无回显,使用重登位置或服务器/第二观察客户端确认,不把本地 position 变化叫做服务端确认。若没有可用证据,标记该断言 BLOCKED。
拾取比较所有背包格的 vnum 总量(支持堆叠),并关联时间窗和地面 iid;只看到地面删除不能证明由自己拾取。金钱掉落另测点数,不套背包断言。无掉落的随机战斗不能无限重试:测试环境准备确定可拾取掉落,最多 3 次允许怪物击杀,无条件则 BLOCKED。
每个状态入口记录快照,失败保存截图;禁止跨状态复用旧计数。断线立即取消输入、释放目标和待处理操作。统一 teardown 断开监听,场景 free 后 probe 不再访问节点。
### 6.3 用例与完成条件
- NET-01:完整闭环 3 次,每次独立进程与报告。
- NET-02:无效账号和不存在槽位,明确错误且有界退出(限制尝试,避免账号锁定)。
- NET-03:进场期间断线,不能留下半加载操作对象。
- NET-04:目标在攻击前消失,取消操作而非继续攻击旧 VID。
- NET-05:掉落被他人拾取、背包已满,不能误报成功;需专用夹具或离线负测,实网缺条件须标出。
自动调用业务入口不等于鼠标命中正确;另做一次真实鼠标/键盘闭环,记录输入设备与截图,作为手动必测项。
## 7. CBT-01:基础战斗和技能
新增 `project/playable_combat_test.gd`(离线事件与输入断言)、`test/playable/skill-cases.json`(实际角色技能矩阵)。联网用例由 playable_live_test 调度,不再创建第二条发包路径。
### 7.1 实现与定位
1. 通过 Quickbar.assign/restore_from_server 构造测试账号实际槽位;需要持久化变更时仅对专用账号执行,记录原值并在可行时恢复。无服务端技能等级不得本地伪造。
2. 调用 activate 复用 PlayerSkill.use_skill,监听现有 skill_cast_started(skill_id,target_vid) 与 skill_rejected(skill_id,code)。cast_started 只是本地施法开始,不是服务端命中证明。
3. case_id 绑定施法时目标;施法中换目标后,定点效果仍使用原目标快照,跟随效果按技能定义更新。
4. probe 关联接收端资源/状态变化、目标血量或伤害事件;不同技能配置 required_evidence,不强制所有技能都有伤害或资源消耗。
5.`game_scene.gd``fx/skill_fx.gd``fx/effect_player.gd` 的实际生成/结束边界记录测试事件。不可只用节点数量增加判定特效正确。
6. 自动截图覆盖施法前、触发帧、峰值、结束;短命特效按事件时间采样,不统一等一秒。
诊断路由:图标/点击区域→quickbar;拒绝条件/目标→player_skill;动作触发→net_play 与玩家视图;挂点/生命周期→game_scene、skill_fx、effect_player;骨骼坐标→metin2_model/metin2_anim。只在复现后修改对应层。
### 7.2 测试矩阵
每职业从真实已学技能中选择适用类型:单体、范围、自身增益、飞行效果;不存在的类型标注不适用并经清单确认,不擅自补技能 ID。每技能成功施放 10 次;另覆盖无目标、超距、冷却、资源不足、目标死亡、自身死亡、切图/断线、目标切换。
断言包括:拒绝不发有效施法请求;正常请求数无重复;服务端效果可观察;角色持续可见且包围盒有限;特效在定义寿命+1秒宽限内清理;挂点相对预期骨骼/目标坐标误差默认不超过 0.1m(有资源偏移需记录校准值)。
`skill_test.gd``player_skill_test.gd``combat_fx_test.gd``target_effect_test.gd` 扩展相应失败用例,并纳入 rendering_batch_test。截图必须人工复核一次,像素非空不能证明技能外观正确。
完成条件:必测职业/技能覆盖完整、功能阻断清零、首次施法与重复施法均通过;高级特效差异写入已知问题,不改成假 PASS。
## 8. MAP-01/02:鬼木林、赤鬼木林与树怪
### 8.1 资源审计
新增 `script/audit_playable_maps.mjs`,输出 `map-assets.json`。读取实际 map 配置、AreaData/Property、npclist、MSM、motlist 和引用文件,分别列出静态 SPT、动态 GR2、贴图、动作及缺失项。没有服务端刷新数据时只报告候选树怪,不声称已确定地图所有怪物。
已核实 `assets/root/npclist.txt`23012307 对应 ent_trent/ent_guru/ent_hu/ent_red/ent_black/ent_huge/ent_elder23112315 对应前五种。共享模型仍保留独立 race 用例,避免漏掉 proto/外观配置差异。实际 map key 与刷新关联由前置审计填入配置。
### 8.2 单模型 GPU 验收
新增 `project/forest_mob_render_test.gd`,沿用 MobView.build、set_anim_state 和现有 Metin2Model/Metin2AnimPlayer。读取 motlist 的具体文件,报告 requested_state、resolved_motion、fallback_reason;“调用 dead 却播放 wait”不能通过。
对 12 个候选 race × CPU/GPU × wait/run/attack/damage/dead 生成基准集合;资源确无动作时据旧客户端回退规则单独判定,不能静默跳过。动作每个采样起点、25%、50%、75%、末段;固定随机种子或明确选择变体。死亡动作不能为了截图强行循环。
自动断言:构建成功;贴图实际加载;顶点/包围盒有限;无非法材质;同一姿态 CPU/GPU 包围盒一致(初始容差 1cm 或高度0.5%,取较大值);截图非空。容差变更需记录理由,不通过放宽到无限解决失败。
输出每 race 的正面、侧面、动作 contact sheet 和 JSON。检查材质双面/裁剪应依资源,不对所有树怪强制双面;蒙皮异常先检查骨骼索引、权重、bind pose、坐标,再修改 C++ 公共路径。`mob_winding_test.gd` 增加这些 race,若影响全怪物必须回归原 101/110/20001。
### 8.3 地图内验收
新增 `project/forest_map_render_test.gd` 和包内 `MT_TEST_MODE=forest_render` 分支,加载配置指定的生产地图。新测试模式不存在于旧包,必须重新构建后执行。
在经确认的平地、坡地、密林和传送点保存固定机位截图,记录 world.sample_height、怪物世界位置、ground offset、材质和实际动作路径。平地不允许持续悬空/埋地超过校准阈值;坡地需人工检查脚/根部接触,AABB 底部不是精确足部,不能用它宣布足部 IK 完成。
实网追加逐地图进入、选中树怪、攻击、死亡、拾取、离开。静态地图截图与动态服务器刷新分别记录证据,不混为一次测试。
完成条件:首测区域关键资源无未解释缺失、主要动作和目标交互正常、Metal 截图人工签核;其余地图不在本轮覆盖中。
## 9. STB-01:稳定性与长帧
新增 `project/testing/playable_metrics.gd``script/playable_soak.sh`,扩展 rendering_scenario_test 的 P99 与事件时间点,但保留“离线合成”和“真实联网”两种 suite 标签。
### 9.1 指标实现
- 每帧记录 monotonic 间隔;按 map/phase/actor_count/gpu_skin/动作切换标记。加载与游玩分段统计。
- 固定大小环形缓冲,按时间窗口输出分位数和 >50ms/>100ms 样本;不得无限数组增长制造测试自身内存泄漏。
- 默认关闭逐帧打印,只保留长帧事件;采样开启/关闭各跑短基线,量化探针开销。
- 外部采样父运行器所持 PID 的 RSS,每秒一次,记录单位 KiBGodot MEMORY_STATIC 单独记录。GPU 内存拿不到写 null/unavailable,不填写 0 或据此宣称无显存泄漏。
- 缓存预热后比较重复路线每轮结束并静置30秒的低水位;记录前5轮与后5轮中位数。连续5轮增长且后5轮中位数高出 max(50MiB,5%) 时门禁失败并分析;阈值以下也不能证明长期无泄漏。
### 9.2 执行场景与边界
2小时实网循环移动/战斗/拾取;20次经合法传送入口切图;10次重连;3种受支持逻辑分辨率各切10次;10次正常退出。窗口物理像素与逻辑尺寸都记入报告,不能把 Retina 缩放当成分辨率错误。
主动 reconnect() 与真实传输故障分别计数。真实故障至少覆盖“连接被关闭”和“短时不可达”;仅测试专用连接,可使用经确认的局部代理/测试网络条件,不改全机路由、防火墙、不关生产服务。缺少该条件标为 BLOCKED,不用主动重连替代断网验收。
时间采用墙钟期限,不用固定帧数冒充2小时。重连后重新获取主 VID、场景和 UI 对象,检测重复信号、重复实体、旧目标/技能残留。跨服切图需核实测试服务器支持,失败不能通过直接传送客户端节点掩盖。
### 9.3 长帧处理流程
先标记同步模型 build、动作 reload、GR2解析、材质初次使用和实体批量生成;记录缓存命中。确认瓶颈后才做路径级修改:动画缓存→metin2_anim;实例建立→net_world/game_scene;材质预热→实际渲染建立路径。后台工作只做线程安全的解析,不把 SceneTree/RenderingServer 操作随意搬入工作线程。
相同机器/路线对比修改前后3轮,保留最大帧而非只报平均。正常游玩目标60FPS;同一交互3次重复中至少2次出现 >100ms 视为阻断,任何 >50ms 样本需归因。首次加载独立预算在基线采集后固定,不临时扩大预算让门禁通过。
完成条件:零崩溃/永久卡死、循环后功能正常、无达到上述阈值的未解释内存增长、无重复严重长帧、每次退出干净。冷启动报告注明进程冷启动/系统缓存状态未知,不执行系统缓存清空来影响用户其他程序。
## 10. 执行命令与验证顺序
以下命令在仓库根目录执行。账号通过安全交互提供,禁止放入命令历史。新脚本落地前不得宣称这些未来命令已可运行。
现有可执行回归:
```bash
bash script/rendering_batch_test.sh
MT_RENDER_APP="$PWD/build/export-native-trees-20260911/mtgodot-poc.app" bash script/package_render_test.sh
```
待实现 CLI 契约(各脚本必须有 --help、配置校验、非零失败码):
```bash
# 离线资源/蒙皮/报告门禁,不连接服务器
node script/audit_playable_maps.mjs --config test/playable/scenario.local.json --output build/playable/map-assets.json
godot --headless --path project --script playable_harness_test.gd
godot --path project --script forest_mob_render_test.gd
# 重新构建带新包内测试入口的候选包,不能用旧包冒充
MT_MAC_ARCHES=arm64 MT_MAC_ENGINE="$PWD/build/godot-particle-diagnostic/bin/godot.macos.template_release.arm64" MT_MAC_OUTPUT_DIR="$PWD/build/export-playable-rc1" bash build-macos-client.sh release
# 明确显式允许专用账号上的游戏操作;默认仅校验配置,不发移动/战斗请求
bash script/playable_test.sh --app "$PWD/build/export-playable-rc1/mtgodot-poc.app" --config test/playable/scenario.local.json --suite full --allow-gameplay --repeat 3
bash script/playable_soak.sh --app "$PWD/build/export-playable-rc1/mtgodot-poc.app" --config test/playable/scenario.local.json --allow-gameplay --duration-seconds 7200
node script/validate_playable_report.mjs --release-dir build/playable/rc1
```
新脚本参数还需支持 --output;聚合器只接受显式列出的本次 run_id 与相同包哈希,不扫描历史 PASS 凑覆盖。测试模式、测试账号均禁止自动聊天、交易、出售、丢弃物品或创建/删除角色;不沿用旧 smoke 的聊天副作用。
配置 local.json、凭据文件、运行产物加入适当忽略规则;提交无敏感信息的 example/schema。最终修复 C++ 后必须重建同步扩展、重新签名和打包;脚本变更也必须重新导出 PCK。
## 11. REL-01:放行检查表
- [ ] INF-01/02 负向用例通过,必测项缺失不能 PASS。
- [ ] 同一候选包3轮联网闭环通过,另有鼠标键盘流程证据。
- [ ] 四职业基础战斗、技能及异常路径清单完整。
- [ ] 首测地图和树怪资源/动作/Metal画面验收通过。
- [ ] 2小时、切图、故障恢复、窗口缩放、退出门禁达到配置次数。
- [ ] P0(崩溃/数据异常/无法进场)与 P1(核心操作失效/主要模型丢失/严重错位/重复严重卡顿)清零。
- [ ] 所有 BLOCKED 明确处理;首测必测项不得以“已知问题”绕过。
- [ ] 包哈希、签名、引擎来源、设备信息与报告一致。
- [ ] 已知 P2、未覆盖地图/装备/技能及未验收平台明确列出。
官方 Release 模板仍有粒子退出警告;必须显式使用验证过的自编译 arm64 引擎,并对最终包重新跑退出门禁。不将当前方案表述为已修复所有官方模板或 Intel 平台。
完成文档不等于完成上述开发。开发起点是 INF-01/02;它们完成后 NET-01 与 MAP-01 可并行推进。工期在首次真实闭环和树怪截图产生后根据实际缺陷估算。

Some files were not shown because too many files have changed in this diff Show More