Files
mtgodot-poc/docs/CLIENT-GAP-FIX.md
T
shenleiandClaude Sonnet 5 b296457e3c 40250 classic: increment 50 (Phase 1 W1) — §1.5 client version, §1.10 text codec, §2.2 two-packet merge
§1.5 CG_CLIENT_VERSION: send_client_version() now branches on locale. EUROPE
(this project's locale) sends CGClientVersion2 (header 0xf1, 67B, timestamp
"1215955205"); non-EUROPE sends CGClientVersion (0xfd) + CMake-injected
MT_BUILD_TIMESTAMP in C __TIMESTAMP__ form. New ClassicSession::set_executable_name()
(default "metin2.bin"; TODO(W0): M2Client passes OS::get_executable_path().get_file()).
m_version_sent set only after send_fixed() succeeds. wire_classic.h gains
CGClientVersion2 + size-table entry.

§1.10 fixed-length text: new extension/src/net/text_codec.h — to_wire()/
from_wire_str() apply a hard byte cap without splitting a UTF-8 multibyte
sequence; godot::String overloads are __has_include-guarded so mtnet stays
godot-free. classic_session.cpp routes 8 fixed-length name/sign/comment fills
through to_wire(); parser reads GC_CHAR_ADDITIONAL_INFO name via from_wire_str().
New net_text_codec_test (ctest net.text_codec). Locale codepage (CP949/CP1252)
transcoding remains a follow-up; wire bytes are currently UTF-8 (ASCII round-trips).

§2.2 two-packet PC/NPC merge: ClassicParser GC_CHARACTER_ADD drops invisible
races (20025/20038/20039); PC/NPC only stash a bare Entity into m_pending_actor;
everything else spawns immediately with a name from root/npclist.txt (new
npc_names.h + set_npclist_path(); $MT_ASSETS/$M2_ASSETS fallback; TODO(W0):
M2Client passes the asset root). GC_CHAR_ADDITIONAL_INFO with no pending entry
records m_last_error + m_unhandled_headers and drops (never half-creates);
on a hit it merges the pending Entity with the additional-info's 9 fields into
one mut_spawn_full() then erases the pending entry. New pending_actor_count()
test hook; net_classic_session_test updated (version assertions -> 0xf1;
stash -> merge -> spawn, immediate monster spawn, invisible-race drop,
no-pending additional-info is a no-op). Depends on W2 §2.2 steps 4-5
(entity_store mut_char_info touch / mut_shop_sign cleanup) — the merge works
with current W2 code as-is.

Build + ctest 17/17 (16 + net.text_codec); netbridge_test, p10_test green.
Docs: CLIENT-GAP.md W1 row + increment-50 change-log entry; CLIENT-GAP-FIX.md
§1.5/§1.10/§2.2 status + facts and C.5 batch record.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
2026-09-02 15:51:48 +09:00

3086 lines
180 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLIENT-GAP 修改细则(1:1 复刻 40250/ClientVS22
> 本文是 [`CLIENT-GAP.md`](CLIENT-GAP.md) §「ClientVS22 实现细节审计(2026-09-02)」的**逐条修改说明**。
> 编号与该节 110 完全对应。
>
> **原则**:每条只写 `40250/ClientVS22` 里实际存在的行为、常量和结构,附源码 `文件:行`;
> 不设计新机制、不做“更好”的替代方案。`ClientVS22/source/` 本身没有完整的 Python UI
> 脚本;凡引用 `POC/assets/root/*.py` 的地方,表示迁移资产 / 历史脚本依据,不冒充
> `ClientVS22/source/` 证据。凡本文没有从参考源码读出的细节,标注为
> **[待读参考]** 并给出应读的参考文件,而不是臆造。
>
> 路径约定:`REF/` = `/Users/shenlei/Work/mt/40250/ClientVS22/source/`
> `POC/` = `/Users/shenlei/Work/mt/mtgodot-poc/`。
>
> 创建 / 修订日期:2026-09-02
---
## 实施进度(2026-09-02
当前已完成 G0 公共接口的首版冻结,并已交付 W0/W1/W2 的“连接与阶段骨架”及 Warp / 换图清理
第一批;正在进入并行波 A,不等于 §1、§2.7 或完整场景生命周期全部完成。
已完成并已编译验证:
- [x] `GC_LOGIN_SUCCESS3/4` 的角色槽保存 `lAddr/wPort``M2Client.enter_game(index)` 已接入
Classic DirectEnter:断开当前游戏连接,按角色槽 IP/端口重连,并在新连接的 SELECT 阶段自动发送
`CG_CHARACTER_SELECT`
- [x] `select_char()`、登录包、`CG_ENTERGAME` 均透传 `send_fixed()` 结果;`CG_ENTERGAME` 成功后
执行即时 flush。`m_entergame_sent` 只在发送成功后置位。
- [x] 未知 header 不再扫描 Loading 缓冲寻找伪边界;保留参考端唯一的 `header == 0` 填充跳过,错误会
记录最近两个 header 并断开。
- [x] ClassicParser 对已知但未实现的集成包不再静默成功,而是记录阶段 / header 并触发失败链路。
- [x] 已建立游戏连接后的断线现在发 `disconnected``AppFlow` 无条件回 LOGIN,匹配
`OnRemoteDisconnect() -> SetLoginPhase()` 的最小语义。
- [x] 回归覆盖:角色槽地址透传、未知包不重同步、未处理包显式失败、发送包顺序;
`net_classic_stream_test``net_classic_session_test``netbridge_test``p10_test` 已通过。
`net_classic_session_test` 内的 localhost 双监听目标测试因当前受限环境禁止 bind 而自动 skip,
未将 skip 计作真实 TCP / 真服验收。
- [x] Classic `GC_WARP` 透传 `lX/lY/lAddr/wPort`,不再用 `addr==0` 判断同服;保留登录票据和已选
角色槽,通过同一个 `ClassicSession` 重连目标 `lAddr:wPort`,并在重连前清理 `EntityStore`
地图实体、掉落物、目标、PVP / 决斗、技能 / affect / 交互状态。
- [x] 新增 `M2Client.world_reset``GameScene` / `NetWorld` / `GroundItems` 清理旧表现节点,
重建地图、HUD 小地图、Atlas、环境和玩家坐标;地图切换时保留背包、任务、好友等持久数据。
- [x] 回归覆盖 Classic Warp endpoint / selected slot 保留、EntityStore loading reset、场景重建
相关 GDScriptC++ 扩展和 `gamescene_test` / `p9_test` / `p2b_test` 已通过。
- [x] **增量 49 —— W0 接口冻结批次**(Phase 1 前置)。为 4 个并行工作流一次性落地共享文件骨架:
- `entity_store.h/.cpp``Entity` 新增 `empire / affect_flags / owner_vid / state_flags`
`mut_spawn_full(const Entity&)`(§2.3 两包合并的单次落地,保留在途插值状态)、
`mut_ownership(vid, owner_vid)`(§2.5)、`mut_map_bgm(name, volume)` +
`take_bgm_dirty()/bgm_name()/bgm_volume()`(§9.1`volume<0` = 服务器未指定)、
`drain_dirty()`(§2.1/§2.5 裸字段更新去重队列);`mut_char_info()``e.empire = empire`
取代 `(void)empire;``reset_for_map_change()` 一并清 `m_dirty`
- `m2_client.cpp`:新增 `bgm_changed(name, volume)` 信号;classic 与 m2dev 两条 pump 回路
都消费 `drain_dirty()``entity_info``take_bgm_dirty()``bgm_changed`
`entity_dict()``empire / affect_flags / owner_vid / state_flags` 键。
- `classic_parser.cpp/.h``GC_MAIN_CHARACTER3_BGM` / `GC_MAIN_CHARACTER4_BGM_VOL`
`bgm_name` / `bgm_volume` 从「读出即丢弃」改为路由到 `mut_map_bgm()`;新增
`std::unordered_map<uint32_t, Entity> m_pending_actor` 成员(W1 在 §2.2 填充合并逻辑)。
- `project/bgm_director.gd`(新):`M2Client.bgm_changed``Audio.play_bgm` / `stop_bgm`
把地图 BGM 消费点从 `net_world.gd` 拆出,避免 W2(可见性)与 W4(音频)抢改同一文件;
`game_scene.gd` 装配 `_bgm_director``bind(client, _audio)`
- `game_scene.gd` §8.6 / §4.8 键位收敛:数字键 1–4 → `quickbar.activate(0..3)`
F1F4 → `quickbar.activate(4..7)`(原为翻页)、Ctrl/Cmd+1..9 → `_emote(n)` 统一入口
(表情包发送与本地表情动作由 W3 在 §8.6 补全,当前调用 `client.send_emoticon` 若存在)。
- 测试:`cmake --build build` 通过;`ctest` 16/16 通过;`netbridge_test``gamescene_test`
`p9_test``netplay_test``system_menu_ui_test``skill_test``combat_fx_test`
`skill_fx_test``player_motion_test` 全绿。无 skip。
- 未做(留给 Phase 1):BGM 名 → 曲目 / 路径解析与音量真正应用到混音(W4 §9.1)、
`m_pending_actor` 合并逻辑(W1 §2.2)、§8.6 剩余逐行键位与 LSHIFT 翻页(W3)。
仍未完成:
- [ ] 真实 TCP E2E 证明两次游戏连接,以及第二次连接确实命中 `GC_LOGIN_SUCCESS4` 的槽地址;
- [ ] 移除 `MT_CLASSIC_ENTER_DELAY` 定时发送,改为 Loading/游戏窗口完成初始化后的明确发送点;
- [ ] 真实服务器验证第二次 Warp TCP 连接、目标地址、重连后的 `GC_MAIN_CHARACTER` 定位和旧场景
节点无残留;受限环境下 localhost bind 子测试自动 skip。
- [ ] 地图 BGM / 天气配置、飞行物和 EffectLib 实例的完整按图清理与重建(当前已覆盖基础场景 / UI)。
- [ ] 版本包 `0xf1` / 可执行文件名 / locale 分支(§1.5);当前仍是已知差距。
本节中的“已完成”只表示代码路径和离线门禁已落地;没有真实 40250 服务端证据的项目仍保持“未验证”。
## 并行开发与进度同步协议
本细则按工作流并行执行,不按 §0–§10 的编号逐条串行实现。章节号用于定位差距,工作流用于分配
代码边界;依赖关系通过公共接口和集成门禁处理。
### 工作流边界
| 工作流 | 负责条目 | 主要修改范围 | 不得越界修改 |
|---|---|---|---|
| W0 公共基础 / 集成 | §0、共享会话、实体接口、场景生命周期、文档 | `extension/src/net/m2_client.*``entity_store.*``classic_session.*``project/game_scene.gd`、两份差距文档 | 其他工作流不得各自复制网络核心或场景 reset;接口改动必须回 W0 |
| W1 协议 / 账号 | §1、Classic wire / parser、登录、选人、DirectEnter、断线 | `extension/src/net/classic/*``extension/tests/net_classic_*`、登录 / 选人专属脚本 | 不直接重写 W0 的公共信号和 EntityStore 生命周期 |
| W2 实体 / 世界 / 战斗 | §2–§3、移动、动作、技能、目标和换图后的实体表现 | `project/net_world.gd``net_play.gd``player_controller.gd``project/world/*`、动画 / 战斗扩展和测试 | 不把 UI 状态写入协议层;共享 reset 只通过 W0 接口接入 |
| W3 玩法 UI / 输入 | §4–§5、§8、背包、任务、社交、IME、键位 | `project/ui/*``hud.gd``login.gd``charselect.gd`、UI 测试和本地化资源 | 不修改 Classic parser;缺少真服时使用 fixture,并注明未验证 |
| W4 渲染 / 资产 / 音频 | §6–§7、§9、模型、特效、地图、材质、天气、BGM、生命周期表现 | `extension/src/{gr2_bridge,metin2_world,terrain_splat,m2_material,environment_builder}.*``project/fx/*`、音频 / 渲染专属脚本和测试 | 不把渲染 fallback 塞进网络状态机;场景钩子由 W0 提供 |
| W5 打包 / 发布 / 验收 | §10、Mac App 自包含资源、架构、签名、发布配置和验收矩阵 | `project/export_presets.cfg`、打包 / 发布脚本、`extension/CMakeLists.txt`、发布验收文档 | 不以“能启动”代替资源、架构、签名和真实功能验收 |
### 执行波次
1. **G0 公共基础冻结**W0 维护 `enter_game``warp``world_reset`、实体快照、UI 信号和
测试 fixture 的契约,并在本文登记影响文件。
2. **并行波 A**:W1–W5 按上表同时开发;每条工作流先使用 loopback、fixture、最小地图或 debug
构建完成自己的回归,不等待其他工作流的全部功能。
3. **G1 接口集成**:W0 合并共享接口,执行扩展构建、原生网络测试和受影响的 Godot 测试;冲突
只在这一层解决,不在各工作流中反复复制修复。
4. **并行波 B**:未阻塞的协议细节、窗口行为、渲染资产、音频生命周期和打包检查继续并行;真实
服务端、双账号和 Mac App 验收按矩阵分别记录。
5. **G2 发布门禁**:只有代码回归、真实链路(适用时)、资源包、架构 / 签名和文档状态都齐全,
对应条目才可从“开发中 / 待验收”改为“完成”。受限环境中的 `skip` 只能记为未验证。
### 每批次必须同步文档
每个开发批次都要在**开始、完成、阻塞、重新验证**时更新本文和 `CLIENT-GAP.md`。代码提交而没有
对应文档记录时,不得把条目标成完成。
批次记录至少包含:
- `W0W5` 工作流、§ 条目、状态(⬜ 未开始 / 🟡 开发中 / ✅ 完成 / ⚠️ 阻塞 / 🔎 待真实验收);
- 参考文件与行号、修改文件、接口 / 数据结构变化;
- 自动化测试命令和结果,包含被 `skip` 的测试及原因;
- 真服 / 双账号 / Mac App 验收证据,或明确列出尚缺证据;
- 风险、依赖、文件冲突和下一批次。
同步职责固定为:`CLIENT-GAP.md` 更新总览表与变更记录,本文更新对应条目的实现细节、测试证据和
实施进度;W0 集成维护者统一合并文档,避免多个工作流覆盖彼此的状态。每次合并后应检查两份文档的
状态、数字、当前路径和“已完成 / 未验证”表述一致。
---
## 0. 先决:参考端的整体骨架
改任何一条之前,必须先认这套骨架,否则单条修改会互相打架。
### 0.1 连接与阶段(唯一真相)
参考客户端**只有两条 TCP**`CAccountConnector`(认证)与 `CPythonNetworkStream`(游戏),
且后者**在一次登录里会连接两次**。
```
1) CAccountConnector::Connect(gameAddr, gamePort, authAddr, authPort) REF/UserInterface/AccountConnector.cpp:37
└ 记住 game 地址,连的是 auth 地址
2) auth 上发 CG_LOGIN / CG_LOGIN3 → GC_AUTH_SUCCESS{bResult, dwLoginKey}
└ __AuthState_RecvAuthSuccess() REF/UserInterface/AccountConnector.cpp:451
bResult==0 → Python OnLoginFailure("BESAMEKEY")
bResult!=0 → rkNet.SetLoginKey(dwLoginKey); rkNet.Connect(gameAddr, gamePort);
随后 AccountConnector 立刻 Disconnect()
3) 游戏连接 #1HandShake → Login → Select
Login 阶段:m_dwLoginKey != 0 → SendLoginPacketNew() = TPacketCGLogin2{header=109,
login_key, name[ID_MAX_NUM+1], adwClientKey[4]} REF/.../PythonNetworkStreamPhaseLogin.cpp:274
Select 阶段:GC_LOGIN_SUCCESS3/4 填 m_akSimplePlayerInfo[4](含每槽 lAddr / wPort
4) 点“开始”:introselect.py:754 `net.DirectEnter(chrSlot)`
→ CPythonNetworkStream::ConnectGameServer(iChrSlot) REF/.../PythonNetworkStream.cpp:469
__DirectEnterMode_Set(iChrSlot)
CNetworkStream::Connect((DWORD)slot.lAddr, slot.wPort) ← **用角色槽里的地址,不是配置地址**
5) 游戏连接 #2DirectEnterMode):
HandShake:不通知 Python REF/.../PythonNetworkStreamPhaseHandShake.cpp:114
Login :同样 SendLoginPacketNew,不通知 Python,不清角色数据
Select **直接** PyCall SetLoadingPhase() REF/.../PythonNetworkStreamPhaseSelect.cpp:28
Loading __DirectEnterMode_Initialize() REF/.../PythonNetworkStreamPhaseLoading.cpp:190
```
阶段切换只由 `GC_PHASE`(0xfd) 驱动,`TPacketGCPhase.phase ∈ {PHASE_CLOSE, PHASE_HANDSHAKE,
PHASE_LOGIN, PHASE_SELECT, PHASE_LOADING, PHASE_GAME, PHASE_DEAD}`
`REF/UserInterface/Packet.h:1124`,处理见 `PythonNetworkStream.cpp:591 RecvPhasePacket`)。
### 0.2 实体两层模型(唯一真相)
参考端**数据层**与**表现层**是分开的,这是 §2 全部条目的根因:
| 层 | 参考类 | 生命周期 |
|---|---|---|
| 数据 | `CNetworkActorManager::m_kNetActorDict``std::map<VID, SNetworkActorData>` | 只由 `GC_CHARACTER_ADD/DEL` 增删,**不随可见性变化** |
| 表现 | `CPythonCharacterManager``CInstanceBase` | 按距离 `CHAR_STAGE_VIEW_BOUND = 200*100` cm 懒创建 / 淡出删除 |
- 每帧 `CNetworkActorManager::__OLD_Update()``REF/UserInterface/NetworkActorManager.cpp:157`
遍历 `m_kNetActorDict`,插值位置;发现「有数据但没实例」且 `__IsVisibleActor()` 为真时才建实例。
- `CPythonCharacterManager::Update()``REF/.../PythonCharacterManager.cpp:182`)把
`距离 > CHAR_STAGE_VIEW_BOUND + 10` 的实例 `__DeleteBlendOutInstance` 掉,**数据仍在**。
- `__IsVisibleActor()``NetworkActorManager.cpp:295`):主角 VID / `AFFECT_SHOW_ALWAYS` /
距离内 / `IsWall(race)` 四者任一为真。
### 0.3 PC/NPC 的两包合一(§2.2 的根因)
`GC_CHARACTER_ADD``TYPE_PC` / `TYPE_NPC` **不立刻创建**,而是暂存到
`static SNetworkActorData s_kNetActorData`;等 `GC_CHAR_ADDITIONAL_INFO` 同 VID 到达后合并再
`AppendActor``REF/UserInterface/PythonNetworkStreamPhaseGameActor.cpp:83``:140`):
- `RecvCharacterAppendPacket` 显式把 `alignment / pkMode / guild / empire / armor / weapon /
hair / mount / level` **全部置 0**,它们只能来自 additional info。
- 非 PC/NPC(怪 / 石 / 门 / 器物)立即 `AppendActor`,名字取
`CPythonNonPlayer::GetName(race)`。
- `IsInvisibleRace(20025 / 20038 / 20039)` → 直接丢弃(`PhaseGameActor.cpp:66`)。
- 若 additional info 的 VID 与暂存不符:`TraceError` 并丢弃,**不创建实体**。
---
## 1. P0:连接、协议和生命周期
### 1.1 Classic 直连分支(第一批已接入,仍待 E2E)
**参考**:不存在“直连 vs Auth”的二选一。参考端固定是 §0.1 的四步,其中
“进入游戏”这一步**必须**用角色槽自带的 `lAddr` / `wPort` 重新连一次
`REF/UserInterface/PythonNetworkStream.cpp:469`),并置 `__DirectEnterMode`。
**当前(第一批已修改)**:登录入口仍由 `M2Client::connect_to_server()``POC/extension/src/net/m2_client.cpp`
走 Auth → Game;收到角色列表后,`M2Client.enter_game(index)` 会调用
`ClassicSession::connect_direct_enter(index)`,断开第一条游戏连接并按角色槽 `lAddr/wPort` 建立第二条
游戏连接。新连接的 LOGIN 使用登录 key,SELECT 阶段自动发送 `CG_CHARACTER_SELECT`。
**修改**
1. `classic_session.h/.cpp`:把两个 `connect()` 重命名为语义化的两段,并新增第三段(**待完成的
清理项**;当前先保留原入口以减少无关改动):
- `connect_auth(auth_host, auth_port, id, pw)` —— 现有带 auth 的重载,**只做认证**;
收到 `GC_AUTH_SUCCESS` 后保存 `m_auth_login_key`,随即**关闭 auth 流**。
- `connect_game(game_host, game_port)` —— 用 `m_auth_login_key` 走 `CG_LOGIN2`
(已有 `connect_with_login_key`,改为公开主路径)。
- `connect_direct_enter(slot_addr, slot_port, slot_index)` —— 新增。置
`m_direct_enter = true; m_direct_enter_slot = slot_index;`,再 `m_stream.connect()`。
2. `ClassicParser``GC_LOGIN_SUCCESS3/4` 解析时把每槽的 `lAddr`(LONG, 小端 4 字节 IP) 与
`wPort` 存进 `SimplePlayer`(当前只存了展示字段)。IP 还原按参考
`GetAccountCharacterSlotDataz`(`PythonNetworkStream.cpp:420`)
`ip[i] = (lAddr >> (8*i)) & 0xff`,格式化为 `"%d.%d.%d.%d"`。
3. ✅ `M2Client::enter_game(index)` 已接入:调用
`classic_sess->connect_direct_enter(index)`,即**断开当前连接、按角色槽地址重连**;
`app_flow.gd`、`charselect.gd`、`login.gd` 已优先使用该入口,旧 fake client 仍可回退到
`select_character()`。
4. `ClassicSession::pump()` 在 `m_direct_enter == true` 时:
- `Stage::Connecting`HandShake)不发 `phase_changed` 给选人 UI
- `Stage::CharSelect` 一进入就自动推进到 `Stage::Loading`(等价
`PythonNetworkStreamPhaseSelect.cpp:28` 的 `SetLoadingPhase`),
当前仍保留 `set_auto_entergame_delay(1500)` 的兼容性定时发送,**这一步尚未完成**;
后续应改为 Loading/游戏窗口完成初始化后的明确发送点;
- `Stage::Loading` 入口清 `m_direct_enter`(等价 `PhaseLoading.cpp:190`)。
5. `MT_CLASSIC_ENTER_DELAY` 环境变量连同 `m2_client.cpp` 的兼容性注入仍未删除——参考端
`CG_ENTERGAME` 的时机是 Python `game.py:207 net.SendEnterGamePacket()`,即
**游戏窗口 Open() 的最后一步**,不是定时器。
**已完成的离线验收**:角色槽 `lAddr/wPort` 能被解析并保存;`M2Client.enter_game` 已注册;
DirectEnter 的状态推进和第二次连接入口已接入。**待验收**:`net_e2e` 观察两次 TCP 连接,且第二次
目标地址来自 `GC_LOGIN_SUCCESS4` 槽位数据。
---
### 1.2 游戏中断线回登录(第一批已接入,仍待 E2E)
**参考**`CNetworkStream` 收到远端关闭 → `OnRemoteDisconnect()`
`REF/EterLib/NetStream.cpp:332/343/351`);`CPythonNetworkStream` 的覆盖实现
`REF/UserInterface/PythonNetworkStreamEvent.cpp:4`**只有一行**
```cpp
void CPythonNetworkStream::OnRemoteDisconnect()
{
PyCallClassMemberFunc(m_poHandler, "SetLoginPhase", Py_BuildValue("()"));
}
```
即:**任何阶段断线一律回登录阶段**。参考端没有“重连遮罩 + 倒计时 + 自动重连”。
主动登出也走同一条:`AbsoluteExitGame()` = `OnRemoteDisconnect(); Disconnect();`
`PythonNetworkStream.cpp:227`)。
**当前(第一批已修改)**`pump_classic()` 在已经建立过游戏连接后发
`disconnected(reason)`,认证 / 建立游戏连接前的失败仍发 `login_failed`
`AppFlow._on_disconnected` 无条件回 LOGIN。`ReconnectUI` 已从 `app_flow.gd` 主流程移除,文件仍保留
供旧测试 / 独立增强使用。
**修改**
1. ✅ `m2_client.cpp` `pump_classic()` 已按“游戏连接是否曾进入 Online”区分——认证失败 /
游戏连接尚未建立 → `login_failed`;已经建立过游戏连接的后续断开 → **`disconnected(reason)`**。
`ClassicSession::was_online_lost()` 提供该判定。
2. ✅ `app_flow.gd` 的 `_on_disconnected()` 已改为**无条件** `_goto_login()`,并附
`_set_status(reason)` —— 与 `SetLoginPhase()` 1:1。
3. **`reconnect_ui.gd` 的去留是产品选择,不是必然修复**。
`AUTO_DELAY=5.0` / `MAX_TRIES=5` 是本工程自造,参考端不存在,所以
**它不能计入 1:1 完成度**;但保留与否不影响本条的缺陷修复——
第 1、2 步修好信号链路后:
- 走严格 1:1 → 删除 `reconnect_ui.gd` 及其在 `app_flow.gd` 的挂载;
- 保留自动重连 → 另立需求,并在 `CLIENT-GAP.md` 标为「超出参考范围的增强」。
第 1、2 步已落地;剩余是产品是否删除独立的 `reconnect_ui.gd`,以及真实断线验收。
4. `M2Client::logout()` / ESC 菜单的 `/logout`:按 `ExitGame()`(`PythonNetworkStream.cpp:204`)
的两分支——`__IsNotPing()` 恒为 `false``PythonNetworkStream.cpp:243`),
所以实际总是走 `SendChatPacket("/phase_select")` / `"/logout"`
**不是**本地断开。现有 `system_menu_ui.gd` 已符合,保持。
---
### 1.3 跨服或换图的首版生命周期(已实现,仍待真服)
**参考**:换图/跨服的清理分布在三处,必须全部照做。
- `SetLoadingPhase()``REF/.../PythonNetworkStreamPhaseLoading.cpp:180`):
```cpp
CPythonPlayer::Instance().Clear();
CFlyingManager::Instance().DeleteAllInstances();
CEffectManager::Instance().DeleteAllInstances();
__DirectEnterMode_Initialize();
```
- `CNetworkActorManager::SetMainActorVID(vid)``NetworkActorManager.cpp:139`):
`m_dwMainVID=vid; m_lMainPosX=m_lMainPosY=0; m_kNetActorDict.clear();`
—— 由 `RecvMainCharacter*` 调用(`PhaseLoading.cpp:203` 等),即**每次进图清空整张实体表**。
- 主角 `AppendActor` 时(`NetworkActorManager.cpp:409`)若不是上下马导致的重建:
`__RemoveDynamicActors(); __RemoveAllGroundItems();`
`__RemoveDynamicActors` 只删 `IsPC() || IsNPC() || IsEnemy()`,保留门/器物)。
- Python 侧 `game.py:221 GameWindow.Close()``snd.FadeOutMusic` → `chat.Close()` →
`snd.StopAllSound()` → `grp.InitScreenEffect()` → `chr.Destroy()` → `textTail.Clear()` →
`quest.Clear()` → `background.Destroy()`,顺序不可换。
- BGM 重选:`game.py:194`
```python
netFieldMusicFileName = net.GetFieldMusicFileName()
if netFieldMusicFileName: snd.FadeInMusic("BGM/" + netFieldMusicFileName)
elif musicInfo.fieldMusic: snd.FadeInMusic("BGM/" + musicInfo.fieldMusic)
```
**当前(增量 47**Classic `GC_WARP` 已透传包内坐标和 `lAddr:wPort``M2Client.pump_classic()`
先调用 `EntityStore::reset_for_map_change()`,发出 `world_reset`,再通过同一个
`ClassicSession::connect_warp()` 保留 login key / 已选角色槽并重连目标端点。Classic 的 `WarpCue`
不再以 `addr==0` 判定同服;m2dev 后端仍保留自己的旧协议语义,两者不混用。
Godot 侧 `GameScene` 已将 `world_reset` 映射为:清理 `NetWorld` / `GroundItems` 节点和 PVP / 决斗、
目标、交互窗;收到 Warp 坐标后按服务器坐标重新解析地图、重建 `Metin2World`、HUD 小地图、
Atlas、环境和玩家定位。背包、任务、好友等持久账号数据不在该清理中删除。
**修改**
1. ✅ `EntityStore::reset_for_map_change()` 清空实体、地面物、PVP / 决斗、目标、技能 / affect、
观察者 / 地图 marker、交互状态和旧事件队列;保留背包、任务、好友、公会等持久数据。
2. ✅ `net_world.gd` / `ui/ground_items.gd` 增加清理入口;`M2Client.world_reset` 由 `GameScene`
关闭临时窗口、清除表现节点并停止当前 BGM。
3. ✅ `ClassicSession::connect_warp(host, port)` 保留 login key 与选人槽,在同一 session 上
重置连接状态并进入 DirectEnter`M2Client` 对 Classic 的每个 Warp 都按包内 `lAddr:wPort`
连接,失败进入既有断线链路。
4. ✅ `GameScene._reload_map_for_warp()` 根据 Warp 坐标重选地图,重建 `Metin2World`、HUD 小地图、
Atlas 和环境引用,并重新贴地定位玩家。
5. ⬜ 仍需按 `game.py:194` 的 `GetFieldMusicFileName()` / `musicInfo.fieldMusic` 实现地图 BGM
自动选择;还需补飞行物 / EffectLib 的逐实例清理、真实服务换图矩阵和受控画面验收。
---
### 1.4 选人和进入游戏发送结果(第一批已修复)
**参考**:每个发送函数都是 `if (!Send(...)) { Tracen(...); return false; } return SendSequence();`
`REF/.../PythonNetworkStreamPhaseSelect.cpp:161 SendSelectCharacterPacket`、
`:177 SendDestroyCharacterPacket`、`:194 SendCreateCharacterPacket`、
`:217 SendChangeNamePacket``PhaseLoading.cpp:344 SendEnterGame`)。
`SendEnterGame` 还额外要求 `SendSequence()` 成功后 `__SendInternalBuffer()`。
**当前(第一批已修改)**`ClassicSession::select_char()` / `send_select_char()`、登录包和
`enter_game()` 已透传 `send_fixed()` 结果;`enter_game()` 发送成功后再执行
`flush_internal()`,并且只在发送成功后置 `m_entergame_sent`。`M2Client` 和选人相关 GDScript
也优先调用新的 `enter_game(index)`,旧测试 fake client 保留 `select_character()` 回退。
**修改**
1. ✅ `classic_session.cpp` 的选人路径 → `return m_stream.send_fixed(&p, sizeof(p));`
2. ✅ `classic_session.cpp` 的进场路径 →
```cpp
if (!m_stream.send_fixed(&p, sizeof(p))) return false;
m_entergame_sent = true;
return m_stream.flush_internal(); // = __SendInternalBuffer()
```
`m_entergame_sent` 只在**发送成功后**置位。
3. ✅ 登录 / 认证发送路径也已检查 `send_fixed()` 返回值;其它玩法发送函数原本已透传。
4. ✅ `M2Client.enter_game()` / `select_character()` 透传失败结果。GDScript 当前保留按钮状态,
但失败提示 / 可重试文案仍是后续 UI 细化项。
---
### 1.5 客户端版本包是硬编码的
**参考**`SendClientVersionPacket()``REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:4155`
```cpp
std::string filename;
GetExcutedFileName(filename); // 真实可执行文件全路径
filename = CFileNameHelper::NoPath(filename); // 只留文件名
CFileNameHelper::ChangeDosPath(filename);
if (LocaleService_IsEUROPE() && !LocaleService_IsYMIR()) {
TPacketCGClientVersion2 { header = HEADER_CG_CLIENT_VERSION2 (0xf1),
filename[33], timestamp[33] = "1215955205" }
} else {
TPacketCGClientVersion { header = HEADER_CG_CLIENT_VERSION (0xfd),
filename[33], timestamp[33] = __TIMESTAMP__ }
}
return SendSequence();
```
**这份 ClientVS22 实际走哪一支是确定的**:`REF/UserInterface/Locale_inc.h:1` 定义
`LOCALE_SERVICE_SINGAPORE``Locale.cpp:186` 的
`#if defined(LOCALE_SERVICE_EUROPE) || … || defined(LOCALE_SERVICE_SINGAPORE) || …`
把 `_LSS_SERVICE_NAME` 设为 `LSS_EUROPE`,于是
`LocaleService_IsEUROPE() == true`、`LocaleService_IsYMIR() == false`。
**参考客户端发的是 `HEADER_CG_CLIENT_VERSION2 = 0xf1`timestamp = `"1215955205"`。**
调用点是 4 个 `RecvMainCharacter*` 的**最后一行**`PhaseLoading.cpp:214/240/267/294`)。
**当前**`ClassicSession::send_client_version()``classic_session.cpp:335`)发
`HDR_CG_CLIENT_VERSION`(0xfd) + `"metin2.bin"` + `"1215955205"` —— **header 走错分支**
0xfd 对应的 timestamp 应该是 `__TIMESTAMP__`,而 `"1215955205"` 只属于 0xf1 分支)。
**修改**
1. ✅ `wire_classic.h` 补 `HDR_CG_CLIENT_VERSION2 = 0xF1` 与
`struct CGClientVersion2 { uint8_t header; char filename[33]; char timestamp[33]; };`
`static_assert(sizeof(CGClientVersion2) == 67)`(与 `CGClientVersion` 同布局);
尺寸表补 `case HDR_CG_CLIENT_VERSION2: return sizeof(CGClientVersion2);`。
2. ✅ `send_client_version()` 按 locale 分支:
- `m_locale_is_europe`(默认 true)→ 发 `CGClientVersion2{0xf1, filename, "1215955205"}`
header `HDR_CG_CLIENT_VERSION2`
- 否则 → 发 `CGClientVersion{0xfd, filename, MT_BUILD_TIMESTAMP}`。
本工程默认 locale 与参考一致(EUROPE 系),**默认走 0xf1**。
3. ✅ `filename` 改为运行时取:新增 `ClassicSession::set_executable_name(std::string)` 由
`M2Client` 传入 `OS::get_executable_path().get_file()`;未设置时回落 `"metin2.bin"`。
填充经 §1.10 的 `mtnet::to_wire(name, sizeof(p.filename) - 1)`。
**跨文件待接线**`M2Client` 需调用 `classic_sess->set_executable_name(...)`W0,已在
`classic_session.h` 留 `TODO(W0)`)。
4. ✅ `MT_BUILD_TIMESTAMP` 由 `extension/CMakeLists.txt` 的
`string(TIMESTAMP MT_BUILD_TS "%a %b %d %H:%M:%S %Y")` +
`target_compile_definitions(mtnet PRIVATE MT_BUILD_TIMESTAMP=...)` 注入,格式对齐 C 的
`__TIMESTAMP__``"Www Mmm dd hh:mm:ss yyyy"`),只在非 EUROPE 分支使用;
`.cpp` 内有 `#ifndef MT_BUILD_TIMESTAMP #define ... __TIMESTAMP__` 兜底。
非 1:1`%d` 是零补位,`__TIMESTAMP__` 的日是空格补位——按本节“近似即可”接受。
5. ✅ 发送时机:`on_packet()` 在四个 `GC_MAIN_CHARACTER*` 变体解析成功后立刻发,
`return send_fixed()` 的返回值;`m_version_sent` 只在发送成功后置位,删除独立触发点。
**W1 增量 50 已落地(🔎 待真实验收)**
- 改动:`wire_classic.h`enum + struct + 尺寸表)、`classic_session.{h,cpp}`
`set_executable_name` / `set_locale_is_europe` setter、`m_executable_name` /
`m_locale_is_europe` 成员、`send_client_version()` locale 分支重写)、
`extension/CMakeLists.txt``MT_BUILD_TIMESTAMP` 定义)。
- 测试:`net_classic_session_test` 的版本断言改为 `CGClientVersion2` / `0xf1` /
`filename=="metin2.bin"` / `timestamp=="1215955205"` / 尾字节 `SEQUENCE_TABLE[2]`
`ctest` `net.classic_session` 通过。
- 验收:未验证(无真实 40250 服务端确认 0xf1 被接受)。
### 1.6 Passpod / HackShield / XTrap / StateChecker 兼容路径
**参考**:均存在于 `REF/UserInterface/``HackShield.cpp`、`WiseLogicXTrap.cpp`、
`NProtectGameGuard.cpp`、`ServerStateChecker.cpp`,以及
`CAccountConnector::SendNEWCIBNPasspodAnswerPacket``AccountConnector.cpp:66`,
`HEADER_CG_NEWCIBN_PASSPOD_ANSWER`)。
`CLIENT-GAP.md` 的「不做清单」已把反作弊标注为 ❌。本条**不改代码**,只补文档:
**修改**
1. 在 `CLIENT-GAP.md` 的不做清单里把 HackShield / XTrap / NProtect / Passpod 显式列入
(目前只写了 `CG_HACK` 与桌面外壳)。
2. `ServerStateChecker`(服务器列表状态查询,UDP)**不属于反作弊**,参考端用于登录页显示
频道负载。若目标服务端开着它,需要单独立项;本文不在此展开。
3. `ClassicParser` 对这些 header 的处理按 §1.8 的规则(记日志 + 清接收缓冲),
不静默吞。
---
### 1.7 未知包 Loading 重同步(第一批已修复)
**参考**`CPythonNetworkStream::CheckPacket()``REF/UserInterface/PythonNetworkStream.cpp:533`
```cpp
if (!s_packetHeaderMap.Get(header, &PacketType)) {
TraceError("Unknown packet header: %d, last: %d %d", header, g_iLastPacket[0], g_iLastPacket[1]);
ClearRecvBuffer();
PostQuitMessage(0); // ← 直接结束进程
return false;
}
```
另有一条**唯一合法的重同步**`header == 0` 时连续 `Recv` 吞掉零字节(填充),
直到读到非零 header 为止(`PythonNetworkStream.cpp:505-524`,在 `CheckPacket` `:498` 内)。
参考端**没有**“扫描后续字节找 GC_CHARACTER_ADD 边界”这种启发式。
**当前(第一批已修改)**`classic_stream.cpp` 已删除 Loading 阶段的启发式边界扫描;未知
header 记录最近两个已识别 header,清空接收缓冲并断开。`header == 0` 的填充跳过仍在尺寸表
查询前执行。
**修改**
1. ✅ 删除 `classic_stream.cpp` 的整段启发式恢复逻辑。
2. ✅ 未知 header 的处理已改为移植后的最小安全语义:
```cpp
log_error("Unknown packet header: %u, last: %u %u", header, m_last_pkt[0], m_last_pkt[1]);
m_recv.clear();
m_last_error = "unknown GC header " + std::to_string(header);
disconnect(); // 等价 PostQuitMessage(0) 在本工程里的最小语义
return;
```
并新增 `uint8_t m_last_pkt[2]`,在每次成功识别 header 后按
`m_last_pkt[0]=m_last_pkt[1]; m_last_pkt[1]=header;` 更新(`PythonNetworkStream.cpp:575`)。
**落地限定**`PostQuitMessage(0)` 是 Windows 的「结束进程」,移植时**不要字面照搬**——
映射为「明确的协议错误 + 断线」即可(走 §1.2 的 `disconnected` → 回登录页)。
要保住的语义只有两条:**不做启发式重同步**、**不静默继续**。
3. ✅ `header == 0` 的填充跳过循环位于尺寸表查询之前,与参考同序。
4. ✅ `disconnect()` 触发 §1.2 的 `disconnected` → 回登录页;这对应 Godot 移植中
`PostQuitMessage(0)` 的安全映射。真实 TCP 断线场景仍待 E2E 验收。
---
### 1.8 已知但没有业务处理的包(第一批已修复静默失败)
**参考**`CPythonNetworkStream::RecvErrorPacket(header)``PythonNetworkStream.cpp:582`
```cpp
TraceError("Phase %s does not handle this header (header: %d, last: %d, %d)",
m_strPhase.c_str(), header, g_iLastPacket[0], g_iLastPacket[1]);
ClearRecvBuffer();
return true;
```
六个阶段函数的 `switch` **落空即调它**`PhaseOffline.cpp:21`、`PhaseHandShake.cpp:94`、
`PhaseLogin.cpp:90`、`PhaseSelect.cpp:142`、`PhaseLoading.cpp:166`、`PhaseGame.cpp:673`)。
注意语义是**丢弃整个接收缓冲**(不是丢一个包),并返回 true 让循环继续。
**当前(第一批已修改)**`ClassicParser` 的 `default:` 分支会记录阶段名、header,并返回
`false``ClassicSession` 将错误传给 `ClassicStream`,后者清空接收缓冲并断开,避免形成
“连接不断但功能静默失效”。可选的反作弊 / Passpod / Matrix 等集成包也走同一条显式失败路径。
**修改**
1. ✅ `ClassicParser` 增加成员 `m_phase_name``"Offline"/"HandShake"/"Login"/"Select"/"Loading"/"Game"`),
由 `ClassicSession` 在阶段切换时写入。
2. ✅ `default:` 分支改为:
```cpp
default:
log_error("Phase %s does not handle this header (header: %u, last: %u, %u)",
m_phase_name, header, m_last_pkt[0], m_last_pkt[1]);
m_unhandled_headers.push_back(header); // 供测试断言
return false; // 让 stream 执行 ClearRecvBuffer
```
3. ✅ `classic_stream.cpp` 在 parser 返回 false 时通过断开路径清空 `m_recv`,并触发显式错误
回调;与参考的 `ClearRecvBuffer()` 相比,本移植选择“清空 + 断开”以保持错误不可继续。
4. **待完成**:按阶段分表,把现在“一个大 switch 处理所有 header”拆成 6 个阶段表,与参考的
`OffLinePhase/HandShakePhase/LoginPhase/SelectPhase/LoadingPhase/GamePhase` 一一对应。
这样“Select 阶段收到 GC_MOVE”才会像参考一样报错,而不是被当成合法包消费。
5. **待完成**:新增 `classic_phase_table_test.gd`,对每个阶段喂一个不属于该阶段的合法 header,
断言 `unhandled_headers` 记录且缓冲被清;当前已有 C++ 回归覆盖未知包、未处理包和不重同步。
---
### 1.9 封包覆盖仍停留在结构和尺寸层
**参考**`REF/EterLib/NetStream.cpp:520-620` 的 `CMainPacketHeaderMap` 是尺寸表;
真正的“完成”判据是每个 header 在**对应阶段函数**里有 case,且 case 里
① 完整 `Recv` ② 写业务状态 ③ 通知 Python 窗口 ④ 需要时回包。
**修改**(流程性,不是单点改代码)
1. 从 `REF/EterLib/NetStream.cpp:520` 起的 header 名表导出全量清单,生成
`POC/docs/PACKET-MATRIX.md`,每行五列:`header / 尺寸 / 所属阶段 / 参考处理函数 / POC 实现位置`。
2. 逐行核对上文 ①②③④ 四项,只有四项齐备才标 ✅。
3. 动态长度包(`PacketType.isDynamicSizePacket`)另标一列,核对
`TDynamicSizePacketHeader{header, size}` 的 `Peek(size)` 完整性校验(`PythonNetworkStream.cpp:540`)。
4. 该矩阵替代 `CLIENT-GAP.md` 现在的“缺失 header 计数”。
---
### 1.10 文本编码和固定长度字段没有统一策略
**参考常量**`REF/UserInterface/StdAfx.h:43`、`Packet.h:380`):
| 常量 | 值 | 说明 |
|---|---|---|
| `CHARACTER_NAME_MAX_LEN` | 24 | 角色名字节数;结构里一律 `char[25]` |
| `ID_MAX_NUM` | 30 | 账号;`char[31]` |
| `PASS_MAX_NUM` | 16 | 密码;`char[17]` |
| `CHAT_MAX_NUM` | 128 | 聊天 |
| `SHOP_SIGN_MAX_LEN` | 32 | 私店招牌 |
| `GUILD_NAME_MAX_LEN` | 12 | 公会名 |
| `PRIVATE_CODE_LENGTH` | 8 | 删角色私密码 |
| `QUEST_INPUT_STRING_MAX_NUM` | 64 | 任务输入 |
参考的写法一律是 `strncpy(dst, src, sizeof(dst)-1)`(见
`SendCreateCharacterPacket`/`SendChangeNamePacket`/`SendDirectEnterPacket`),
且 `SendLoginPacket` 额外显式补 `name[ID_MAX_NUM]='\0'`。
参考是**单字节本地编码**`REF/UserInterface/Locale.cpp` + `EterLocale`),不是 UTF-8。
**修改**
1. ✅ 新建 `extension/src/net/text_codec.h`mtnet 侧,不依赖 godot-cpp):
- `std::string to_wire(std::string_view utf8, size_t cap)`**按字节**截断到 `cap`
且不在 UTF-8 多字节序列中间截断(截到最后一个完整码点)。
- `std::string from_wire_str(const char *bytes, size_t cap)`:反向,遇 `\0` 停,
并裁掉末尾悬空的部分序列。
- `godot::String` 重载用 `#if __has_include(<godot_cpp/variant/string.hpp>)`
`MT_TEXT_CODEC_HAS_GODOT`)守卫,保持 `net_classic_wire_test`(不链 mtnet)可编译。
- **非 1:1follow-up**:目前 wire 字节即 UTF-8,未做 locale 代码页(CP949 / CP1252
转换;ASCII 可往返,非 ASCII 名对单字节编码服务端会呈现 mojibake。头注释已登记此缺口。
2. ✅ `wire_classic.h` `#include "../text_codec.h"``classic_session.cpp` 里所有定长名字 /
招牌 / 公会名 / 好友名 / 私密文本填充改为 `mtnet::to_wire(src, cap)` +
`memcpy(dst, w.data(), w.size())`,共 8 处:`create_character` / `change_name` /
`send_whisper` / `send_private_shop` / `send_guild_grade_name` / `send_guild_comment` /
`send_guild_answer_make` / `send_friend_add|remove`。`GC_CHAR_ADDITIONAL_INFO` 的名字
读取改用 `mtnet::from_wire_str(p.name, sizeof(p.name))`。
保留 `strncpy`:登录 id / 密码(`m_id` / `m_pw`,非 wire 定长名字段)、`send_chat` /
whisper 的动态文本 body。**其余 parser 名字读取(非 ADDITIONAL_INFO)仍走原 `strnlen`**
作为 follow-up。
3. ⬜ **不在本批次**`charselect.gd` / `guild_creation_ui.gd` 的名字长度校验统一为
`<= 24` 字节属 GDScriptW3 / Phase 2);本批次只交付 C++ 侧编码。已在下方登记。
4. ✅ 回归:新增 `extension/tests/net_text_codec_test.cpp``ctest` `net.text_codec`),
覆盖 CJK 名恰好 24 字节(接受)、25/27 字节(截断到 24,绝不 25/26)、
多字节边界截断(切进 3 字节序列中间丢整字)、ASCII 往返、`from_wire_str` 遇 NUL 停 /
悬空前导字节裁除 / 无终止符满字段整读。`extension/CMakeLists.txt` 注册。
**W1 增量 50 已落地(🔎 待真实验收)**
- 改动:`extension/src/net/text_codec.h`(新)、`extension/tests/net_text_codec_test.cpp`(新)、
`wire_classic.h`include)、`classic_session.cpp`8 处 to_wire 路由)、
`classic_parser.cpp`ADDITIONAL_INFO from_wire_str)、`extension/CMakeLists.txt`(测试注册)。
- 测试:`ctest` `net.text_codec` 通过(新增,套件 16 → 17);`net.classic_session` 通过。
- 验收:未验证(无真实服务端确认非 ASCII 名编码);locale 代码页转换为 follow-up。
---
### 1.11 登录错误、超时、会话失效和服务器列表状态没有完整映射
**参考**
- `GC_LOGIN_FAILURE` → `TPacketGCLoginFailure.szStatus`(字符串),
直接透给 Python `OnLoginFailure(szStatus)``REF/.../PythonNetworkStreamPhaseLogin.cpp:222`)。
文案映射在 `POC/assets/root/intrologin.py:390 OnLoginFailure()` 里(本仓已有该脚本)。
- `GC_AUTH_SUCCESS.bResult == 0` → `OnLoginFailure("BESAMEKEY")`
`REF/UserInterface/AccountConnector.cpp:460`)。
- 连接失败 → `CPythonNetworkStream::OnConnectFailure()``PhaseLogin.cpp:211`):
`__DirectEnterMode_IsSet()` 为真则 `ClosePhase()`,否则
`PyCall(PHASE_WINDOW_LOGIN, "OnConnectFailure")`。
- 阶段专用:`RecvErrorPacket` 的文案带 `m_strPhase`(见 §1.8)。
**修改**
1. `ClassicParser` 保留 `login_failure()` 的**原始 szStatus 字符串**(不要翻译、不要拼接前缀)。
2. `M2Client` 的 `login_failed` 信号第二参数新增 `code: String`= szStatus),
现有第一参数保留为可读描述。
3. `login.gd` 增加 `OnLoginFailure` 文案表,逐条抄 `POC/assets/root/intrologin.py:390` 起的
`localeInfo` 键;未命中的 code 原样显示(与参考一致)。
4. `GC_AUTH_SUCCESS.bResult == 0` 走同一条,code 固定 `"BESAMEKEY"`。
5. 连接失败:`direct_enter` 期间 → 触发 `ClosePhase` 等价(回登录页,不弹错误框);
否则 → `login.gd` 的 `OnConnectFailure()`。
---
## 2. P0/P1:实体、世界和状态一致性
> 本节全部条目的前提是 §0.2 的**两层模型**。先做 2.1 的重构,其余条目才有落点。
### 2.1 未知 VID 的更新包会直接丢失
**参考**:参考端**也**丢弃未知 VID 的更新,但丢的是「不在 `m_kNetActorDict` 里的 VID」,
而不是「没有场景节点的 VID」:
```cpp
CNetworkActorManager::UpdateActor / MoveActor / SyncActor / SetActorOwner
auto f = m_kNetActorDict.find(vid);
if (f == end) { TraceError("... NOT EXIST VID"); return; } // 仅 _DEBUG 下报错
SNetworkActorData& d = f->second;
CInstanceBase* inst = __FindActor(d); // 可见则**按需创建**实例
if (inst) { ...改实例... }
...无论实例在不在,都更新 d...
```
`REF/UserInterface/NetworkActorManager.cpp:456 / 508 / 536 / 553`
关键点有三:
1. **数据表更新与实例存在与否解耦**——`UpdateActor` 末尾无条件写回 `rkNetActorData` 的
`affect / guild / movSpd / atkSpd / armor / weapon / hair / alignment / pkMode`。
2. `__FindActor()``NetworkActorManager.cpp:189`)在实例缺失且**可见**时当场创建。
3. `MoveActor` 用 `__FindActor(d, dstX, dstY)` 的重载,按**目标点**判可见(`:172`)。
**当前**`EntityStore::mut_move/mut_set_position/mut_change_speed/mut_dead/mut_stun` 等在
`m_ents.find(vid) == end` 时直接 `return``POC/extension/src/net/entity_store.cpp:123` 等),
而 `m_ents` 同时充当数据表和「场景应有节点」的清单——两层被合并了。
**修改**
1. `EntityStore` 保持为**数据层**= `m_kNetActorDict`),并明确:条目只由
`GC_CHARACTER_ADD`(含 additional info 合流)创建、由 `GC_CHARACTER_DEL` 删除、
由 `mut_set_main_vid()`(§1.3)整表清空。**任何其它包都不创建、也不删除条目。**
2. 可见性下沉到 `net_world.gd`= `CPythonCharacterManager`):
- 每帧对 `EntityStore` 的全部条目做 `__IsVisibleActor` 判定
(主角 / `AFFECT_SHOW_ALWAYS` / 距离 < `CHAR_STAGE_VIEW_BOUND` / `IsWall(race)`);
- 可见但无节点 → 建节点;节点距离 > `CHAR_STAGE_VIEW_BOUND + 10` → 淡出删除节点。
- 常量:`const CHAR_STAGE_VIEW_BOUND := 200 * 100 # cm``REF/.../PythonCharacterManager.cpp:13`)。
3. 所有 `mut_*` 保持「未知 VID 直接 return + 记一条 debug 日志」,与参考一致——
**不要**引入 pending / 重放机制,参考端没有。
4. `EntityStore` 新增 `drain_dirty()``net_world.gd` 只对**已有节点**的脏实体做刷新,
其余只更新数据。
**验收**`net_entity_test` 补一条——先喂 200 m 外的 `GC_CHARACTER_ADD`,再喂
`GC_CHARACTER_UPDATE`,断言 `EntityStore` 里字段已更新且没有场景节点;把主角挪近后
断言节点出现且带更新后的装备。
---
### 2.2 `GC_CHAR_ADDITIONAL_INFO` 早于 Spawn 时状态不会被待合并缓存
**参考**:见 §0.3。参考端**根本不会**出现「additional info 早于 add」——它用
`static SNetworkActorData s_kNetActorData` 做单槽暂存,`GC_CHARACTER_ADD` 只写暂存槽,
`GC_CHAR_ADDITIONAL_INFO` 才 `AppendActor`。VID 不匹配一律 `TraceError` 丢弃:
```cpp
// REF/UserInterface/PythonNetworkStreamPhaseGameActor.cpp:140
SNetworkActorData kNetActorData = s_kNetActorData;
if (IsInvisibleRace(kNetActorData.m_dwRace)) return true;
if (kNetActorData.m_dwVID == chrInfoPacket.dwVID) { ...合并 9 个字段...; __RecvCharacterAppendPacket(&kNetActorData); }
else TraceError("TPacketGCCharacterAdditionalInfo name=%s vid=%d race=%d Error", ...);
```
**当前**`EntityStore::mut_char_info()``POC/extension/src/net/entity_store.cpp:458`
用 `touch(vid, created)` 可以凭空建实体,且新建时不推 `Spawn` change;若后续 Spawn 正常到达,
`NetWorld` 仍会创建节点,但若 Spawn 丢失或顺序异常,实体会长期只有数据没有表现层节点。
**修改**
1. ✅ `ClassicParser` 已有成员 `std::unordered_map<uint32_t, mtnet::Entity> m_pending_actor;`
(W0 增量 49 冻结,等价 `s_kNetActorData`,此处按多槽 map 而非单槽实现);
新增 `pending_actor_count()` 供测试断言。
2. ✅ `GC_CHARACTER_ADD` 处理改为:
- `is_invisible_race(race)`20025 / 20038 / 20039)→ `return true`(丢弃,不断连接);
- `type == CHRTYPE_PC || type == CHRTYPE_NPC` → **只**把裸 `Entity`vid / race / ch_type /
坐标 / 角度 / 速度 / state_flags / affect_flags)写入 `m_pending_actor[vid]`,不调 `m_world`
- 否则 → 名字取 `npclist.txt` 的 `race → code`(新 `npc_names.h`,等价
`CPythonNonPlayer::GetName`;取不到为空串),立即 `m_world.mut_spawn(...)`
`alignment / pk_mode / guild / empire / parts / mount / level` 全部为 0`mut_spawn` 默认)。
3. ✅ `GC_CHAR_ADDITIONAL_INFO` 处理改为:
- `m_pending_actor` 无 `pkt.dwVID` 条目 → 写 `m_last_error` + `m_unhandled_headers`
`return true`**丢弃**,不建实体;
- 命中 → 用 pending `Entity` + additional 的 9 个字段(name / guild / level / alignment /
pk_mode / empire / parts[armor/weapon/hair] / mount_vnum)合成一次
`m_world.mut_spawn_full(e)`(W0 新 API,一次写全字段并推 `Spawn`),随后 `erase` 该条目。
4. ⬜ **W2 依赖(本批次不改 `entity_store.*`**`mut_char_info()` 的 `touch()` 建实体路径、
`mut_shop_sign()` 的凭空建 Entity 由 W2 按 §2.2 步骤 4–5 处理;本批次的合并逻辑对当前 W2
代码即可工作(`mut_spawn_full` 已就位)。
5. ⬜ 同上,`mut_shop_sign()` → 独立 `ShopSign` 表现状态属 W2。
6. ✅ **跨文件待接线**`M2Client` 需调用
`parser().set_npclist_path(AssetRoot::path("root/npclist.txt"))`W0,已在
`classic_parser.h` 留 `TODO(W0)`);未设置时 `npc_names.h` 回落
`$MT_ASSETS` / `$M2_ASSETS` 环境变量,再回落空串。
**W1 增量 50 已落地(🔎 待真实验收)**
- 改动:`extension/src/net/classic/npc_names.h`(新,`load_npclist` / `default_npclist_path`
mtnet-safe)、`classic_parser.{h,cpp}``set_npclist_path` / `pending_actor_count` /
`npc_name_for` 惰性加载、`GC_CHARACTER_ADD` 与 `GC_CHAR_ADDITIONAL_INFO` 处理重写)。
`GC_CHARACTER_ADD2` 处理**未动**(超出本条范围,登记为 follow-up)。
- 测试:`net_classic_session_test` 的 NPC 块重写为“先 `GC_CHARACTER_ADD`(NPC) 只暂存
`world().size()` 不变、`pending_actor_count()==1`)→ 再 `GC_CHAR_ADDITIONAL_INFO` 合并落地
(暂存坐标 / 速度保留、9 字段合入、`pending_actor_count()==0`)→ 怪物立即 spawn →
隐身 race 丢弃 → 无 pending 的 additional-info 为 no-op”。`ctest` `net.classic_session` 通过。
- 验收:未验证(两包合并需真实 40250 服务端 `GC_CHARACTER_ADD` + `GC_CHAR_ADDITIONAL_INFO` 序列)。
---
### 2.3 VID 重用时旧状态可能污染新实体
**参考**`CNetworkActorManager::AppendActor``NetworkActorManager.cpp:409`
```cpp
SNetworkActorData& rkNetActorData = m_kNetActorDict[c_rkNetActorData.m_dwVID];
rkNetActorData = c_rkNetActorData; // operator= → __copy__ 覆盖**全部** 23 个字段
```
`SNetworkActorData::__copy__``NetworkActorManager.cpp:53`)逐个赋值
`m_bType, m_dwVID, m_dwStateFlags, m_dwEmpireID, m_dwRace, m_dwMovSpd, m_dwAtkSpd, m_fRot,
m_lCurX/Y, m_lSrcX/Y, m_lDstX/Y, m_kAffectFlags, m_dwServerSrcTime, m_dwClientSrcTime,
m_dwDuration, m_dwArmor, m_dwWeapon, m_dwHair, m_dwOwnerVID, m_sAlignment, m_byPKMode,
m_dwMountVnum, m_dwGuildID, m_dwLevel, m_stName`。
表现层同样重建:`__AppendCharacterManagerActor``:327`)在已有实例时先
`rkChrMgr.DeleteInstance(dwVID)` 再 `CreateInstance(kCreateData)`——**整个实例重建**
只有上/下马时才把旧坐标覆盖进 `kCreateData``:342`)。
**当前**`EntityStore::mut_spawn()``entity_store.cpp:75`)用 `touch()` 后只覆盖
`race/ch_type/name/x/y/z/angle/moving_speed/attack_speed/moving`。
**修改**
1. `mut_spawn_full()`(§2.2 新增)实现为:
```cpp
Entity e{}; // 值初始化 —— 等价 SNetworkActorData 默认构造 + operator=
e.vid = vid; ...逐字段赋值全部字段...
m_ents[vid] = e; // 整条替换,不做 touch/局部覆盖
m_changes.push_back({ChangeKind::Spawn, vid});
```
即 `dead / stunned / mount_vnum / guild / pk_mode / alignment / parts / shop_sign /
affect / func / walk_mode / fly_target_*` 全部回默认值。
2. `net_world.gd` 收到 `Spawn` 且节点已存在 → **销毁旧节点重建**(对齐
`DeleteInstance` + `CreateInstance`);例外:上下马导致的重建保留旧世界坐标
`kCreateData.m_lPosX = oldPos.x; m_lPosY = -oldPos.y;`,注意 Y 取反)。
3. `mut_spawn()` 旧签名保留给 `GC_MAIN_CHARACTER` 之外的老调用点,但内部改为委托
`mut_spawn_full()`,避免两套覆盖规则。
---
### 2.4 点数组可能残留旧值
**参考**`__RecvPlayerPoints()``REF/.../PythonNetworkStreamPhaseLoading.cpp:325`
```cpp
for (DWORD i = 0; i < POINT_MAX_NUM; ++i)
CPythonPlayer::Instance().SetStatus(i, PointsPacket.points[i]);
```
`TPacketGCPoints.points` 是**定长 `POINT_MAX_NUM` 数组**,全量覆盖,不存在“部分长度”。
**当前**`EntityStore::mut_set_points(pts, n)``entity_store.cpp:133`)只 `memcpy` 前 `n` 个;
当前 Classic 固定长度包路径传入 `POINT_MAX_NUM`,所以该路径本身是全量覆盖,残留风险只存在于
未来或其他调用者传入较短长度时。
**修改**
1. `wire_classic.h` 确认 `GCPoints.points` 为 `int32_t[POINT_MAX_NUM]`,加 `static_assert`。
2. 若 `mut_set_points()` 继续允许部分更新,先明确区分“全量快照”和“单点更新”:全量快照必须
传入并校验 `POINT_MAX_NUM`,部分更新不得被误称为参考端的 `GC_POINTS` 全量包;必要时在
部分更新前清零尾部,避免未来调用者读到旧值。
3. `GC_POINT_CHANGE`(单点更新)保持只改一个下标——参考同样是单点
`PhaseSelect.cpp:129` 在 Select 阶段直接 `Recv` 丢弃,Game 阶段才处理)。
---
### 2.5 实体数据字段不完整
**参考**`SNetworkActorData``REF/UserInterface/NetworkActorManager.h:5`)的完整字段表见 §2.3。
`CInstanceBase::SCreateData` 又比它多出 `m_isMain`。参考端各字段的**唯一来源**
| 字段 | 来源包 | 参考行 |
|---|---|---|
| `m_bType`(CHRTYPE) / `m_dwRace` / `m_dwStateFlags` / `m_fRot` / `m_dwMovSpd` / `m_dwAtkSpd` / 位置 / `m_kAffectFlags`(64 bit, 两段 32) | `GC_CHARACTER_ADD` | `PhaseGameActor.cpp:83` |
| `m_stName` / `m_dwGuildID` / `m_dwLevel` / `m_sAlignment` / `m_byPKMode` / `m_dwEmpireID` / `armor,weapon,hair`(`awPart[CHR_EQUIPPART_*]`) / `m_dwMountVnum` | `GC_CHAR_ADDITIONAL_INFO` | `PhaseGameActor.cpp:140` |
| 同上 9 项的后续变更 | `GC_CHARACTER_UPDATE[2]` | `PhaseGameActor.cpp:211/236` |
| `m_dwOwnerVID` | `GC_OWNERSHIP` | `PhaseGameActor.cpp:375` |
`UpdateActor` 应用到实例的调用序(`NetworkActorManager.cpp:470`,顺序即参考顺序):
`ChangeArmor → ChangeWeapon → ChangeHair → ChangeGuild → SetAffectFlagContainer →
SetMoveSpeed → SetAttackSpeed → SetAlignment → SetPKMode → SetStateFlags`。
`MountHorse/DismountHorse` 在参考里是**注释掉的**,坐骑变化只经 `AppendActor` 重建。)
**当前**`mut_char_info()``entity_store.cpp:477`)显式 `(void)empire;` 丢弃 empire
名字颜色 / 称号 / 会徽 / target highlight / attackable / weapon mode / motion mode /
affect 图标与剩余时间没有统一状态。
**修改**
1. `Entity` 结构补齐到与 `SNetworkActorData` 一一对应:
新增 `uint8_t empire; uint64_t affect_flags; uint32_t owner_vid; uint32_t state_flags;`
`state_flags` 已有 `func/position` 的话合并成一个字段,按 `bStateFlag` 原值保存)。
2. `mut_char_info()` 写入 `e.empire = empire;`,删除 `(void)empire;`。
3. `affect_flags` 按参考的两段拷贝:`CopyData(0, 4, &dwAffectFlag[0])` +
`CopyData(32, 4, &dwAffectFlag[1])` → `uint64_t = lo | (hi << 32)`。
`AFFECT_SHOW_ALWAYS` 的位号取 `REF/UserInterface/InstanceBase.h` 的 `AFFECT_*` 枚举
**[待读参考:`InstanceBase.h` 的 `enum EAffect`]**。
4. `net_world.gd` 的实体刷新按上表的**参考调用序**执行,不要重排。
5. 名字颜色 / 称号 / PK 颜色 / 目标框 属表现层,见 §8.8,数据源就是本节的
`empire / guild / alignment / pk_mode / level`。
---
### 2.6 一次性事件没有可靠的生命周期缓存
**参考**:参考端**不缓存**。`CPythonNetworkStream` 收到包立刻
`PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], ...)`;窗口指针由 Python 在
`SetPhaseWindow(ePhaseWnd, poPhaseWnd)``PythonNetworkStream.h:133`)注册,
`PyCallClassMemberFunc` 对空指针是安全 no-op。真正的保证是**顺序**:
`GC_PHASE(PHASE_GAME)` 之前 Python 已经 `game.py:137 GameWindow.Open()` 完成装配,
而 `SendEnterGamePacket()` 是 `Open()` 的倒数第二步(`game.py:207`)——
**服务端在收到 `CG_ENTERGAME` 之前不会推游戏事件**。
**当前**`M2Client` 的 drain queue 在 `game_scene` 装配完成前可能被消费。
**修改**
1. **不要**加事件缓存。改为对齐顺序:
`CG_ENTERGAME` 的发送点从 `ClassicSession` 的定时器(§1.1 已删)挪到
`game_scene.gd` 完成全部窗口装配后显式调用 `client.enter_game()`
即 1:1 对齐 `game.py:207`。
2. `game_scene.gd` 的装配顺序照抄 `game.py:137 Open()`
相机 → 玩家清理 → 各 UI Open → 声音音量 → BGM → 快捷栏页 → `SetFocus/Show/ShowCursor`
→ `net.SendEnterGamePacket()` → `StartGame()`。
3. `M2Client` 的 drain 接口保持「不缓存」,但在 `enter_game()` 之前**不启动** world 分派,
避免登录洪流里的包被提前消费。
---
### 2.7 GC_WARP 永远重连,换图必须清理并重建世界
**参考**`CPythonNetworkStream::RecvWarpPacket()``REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:923`
整体只有三步:接收 `{bHeader, lX, lY, lAddr, wPort}`、设置
`__DirectEnterMode_Set(m_dwSelectedCharacterIndex)`、调用
`CNetworkStream::Connect((DWORD)lAddr, wPort)`。
结论:
- `GC_WARP` 在参考端**永远是重连**,不存在用 `addr == 0` 判断“同服只移动玩家”的分支。
- `lX / lY` 在 `RecvWarpPacket()` 这一步不直接消费,但重连后的
`GC_MAIN_CHARACTER*` / `PhaseLoading.cpp:211 LoadData(lX, lY)` 仍会用它们定位角色,必须
透传,不能标成无用字段。
- `CNetworkStream::Connect()``REF/EterLib/NetStream.cpp:401`)先 `Clear()`,会清理 socket、
收发缓冲和 sequence;新连接再按 DirectEnter 流程 HandShake → Login → Select → Loading。
**当前首版(已实现,仍待真实服务端)**:
- `classic_parser.cpp` 将 Classic `GC_WARP` 标记为 `reconnect=true``WarpCue::same_server()`
不再作为 Classic 的判据,m2dev 旧语义仍可保留在其独立协议路径。
- `M2Client` 在收到 Warp 后发出 `world_reset`,保存登录 key / 已选角色槽,清空实体、掉落、
目标、PVP / 决斗、技能 / affect / 交互等地图局部状态,再由 `ClassicSession::connect_warp()`
按 `lAddr:wPort` 建立第二次游戏连接。
- Godot `GameScene` / `NetWorld` / `GroundItems` 清理旧节点并重建地图、HUD、Atlas、环境和
玩家位置;背包、任务、好友等跨地图数据保留。
**剩余修改与验收**
1. 保持 `lX / lY / lAddr / wPort` 的 wire、信号和重连路径一致;注释统一写“本步不消费”,
不写“坐标无用”。
2. 完成真实 TCP E2E:验证第二次连接确实命中 Warp 的 `lAddr:wPort`、发送 DirectEnter / Select
收到目标 `GC_MAIN_CHARACTER*`,并确认旧场景节点、旧掉落和旧实体没有残留。
3. 完成换图后的地图 BGM、天气、飞行物、EffectLib 实例和其他地图局部资源清理 / 重建;当前
只覆盖基础场景和 UI。
4. 回归:`net_classic_session_test` 覆盖 endpoint / selected slot 保留,`net_entity_test` 覆盖
loading reset`gamescene_test` / `p9_test` / `p2b_test` 覆盖场景重建;localhost bind 被
`skip` 时只能记为“未验证”,不能替代真实 E2E。
---
## 3. P1:移动、战斗和技能
### 3.1 本地移动是直线位置修改
**参考**:本地移动**不是**直接改坐标,而是「设 Src/Dst + 起走路动作 + 每帧推进 + 碰撞回退」。
- 点地移动 `CPythonPlayer::__OnPressGround``REF/UserInterface/PythonPlayerInput.cpp:419`):
```
__ClearReservedAction(); __ClearAutoAttackTargetActorID();
if (NEW_CancelFishing()) return;
if (!__IsMovableGroundDistance(...)) return; // 距离 < MOVABLE_GROUND_DISTANCE 不动
if (!rkInstMain.NEW_MoveToDestPixelPositionDirection(p)) __ReserveClickGround(p);
```
`__ReserveClickGround` 会设 `m_fReservedDelayTime = 0.1f``PythonPlayerInput.cpp:834`)。
- `CInstanceBase::NEW_Goto()``REF/UserInterface/InstanceBaseMovement.cpp:251`)的**前置门**依序:
`__IsSyncing()` → false`IsUsingMovingSkill()` → 只转向;`isLock()` → false
`!NEW_CanMoveToDestPixelPosition(dst)` → 停走并返回 true。
通过后:`NEW_SetSrcPixelPosition(cur) / NEW_SetDstPixelPosition(dst) /
NEW_SetDstPixelPositionZ(src.z) / m_fDstRot / m_isGoing=TRUE / StartWalking()`。
- 方向键移动 `NEW_MoveToDirection``InstanceBaseMovement.cpp:293`)同样有
`__IsSyncing / IsUsingMovingSkill / isLock` 三门,且 `m_isGoing = FALSE`。
- 玩家层 `CPythonPlayer::NEW_MoveToDirection``PythonPlayerInput.cpp:445`)额外:
`IsOpenPrivateShop()` → 直接返回;`isLock() && !IsUsingMovingSkill()` → 返回。
- 碰撞:`CInstanceBase::CheckAdvancing()``InstanceBaseBattle.cpp:469`)——
地形 `ATTRIBUTE_BLOCK` 检测(`InstanceBaseBattle.cpp:560`+ 与其它 `CInstanceBase` 的
`TestActorCollision`,命中即 `BlockMovement()`。`CanSkipCollision()` 为真时整段跳过
(网络状态包驱动的移动会 `__EnableSkipCollision()`,见 §3.2)。
**当前**`player_controller.gd:216` 直接改 X/Z,只做地形阻挡。
**修改**
1. `player_controller.gd` 重构为「Src/Dst + `is_going` + `walk_mode`」模型,一一对应上面的
`NEW_Goto` / `NEW_MoveToDirection`,并保留三个前置门(`syncing / moving_skill / lock`)。
2. 新增 `MOVABLE_GROUND_DISTANCE`(参考默认值由 `SetMovableGroundDistance` 从 Python 设置
**[待读参考:`POC/assets/root/game.py` / `playersettingmodule.py` 中的
`player.SetMovableGroundDistance` 调用]**)。
3. `__ReserveClickGround` 的 0.1 s 延迟、`NEW_IsEmptyReservedDelayTime` 的递减逻辑
`PythonPlayerInput.cpp:626`)照搬。
4. Actor 之间的碰撞:用 Godot 的 Area3D 近似 `TestActorCollision`;命中 → `BlockMovement()`
(= 停在上一帧位置,不做滑移)。
5. 坐骑:`IsMountingHorse()` 时移动速度与动作模式按 §6.4 的 `MODE_HORSE*` 走。
---
### 3.2 同步是距离阈值加 lerp
**参考**:远端 Actor 的位置**完全由一条命令队列**驱动,而不是 lerp。
- 入队:`CInstanceBase::PushTCPState(dwCmdTime, kPPosDst, fRot, eFunc, uArg)`
由 `CNetworkActorManager::MoveActor``NetworkActorManager.cpp:520`)与
`__AppendCharacterManagerActor``:410`,用于补发正在移动中的 actor)调用。
- 出队:`CInstanceBase::StateProcess()``REF/UserInterface/InstanceBase.cpp:1278`
```
while (队非空):
if (ELTimer_GetServerFrameMSec() < front.m_dwChkTime) return; // 按服务器帧时间释放
pop;
if (!__CanProcessNetworkStatePacket()) return; // Dead / KnockDown / 不可取消的技能中
if (!__IsEnableTCPProcess(eFunc)) return; // 表情中 / m_bEnableTCPState==false
switch (eFunc) { ... }
```
- 各 `eFunc` 的阈值是**硬阈值**,不是 lerp:
| eFunc | 条件 | 行为 |
|---|---|---|
| `FUNC_WAIT` | `fDirLen > 1.0f` | 走过去(Src/Dst + StartWalking`m_kMovAfterFunc = WAIT` |
| `FUNC_WAIT` | 否则 | `SCRIPT_SetPixelPosition(dst)` + `SetAdvancingRotation` + `SetRotation`(瞬移对齐) |
| `FUNC_MOVE` | 无条件 | Src/Dst + StartWalking`m_kMovAfterFunc = MOVE` |
| `FUNC_COMBO` | `fDirLen >= 50.0f` | 先走过去,到达后 `RunComboAttack(rot, uArg)` |
| `FUNC_COMBO` | 否则 | 瞬移对齐 + `RunComboAttack(rot, uArg)` |
| `FUNC_ATTACK` | `fDirLen >= 50.0f` | 先走过去,到达后普攻 |
| `FUNC_ATTACK` | 否则 | 瞬移对齐 + `BlendRotation` + `RunNormalAttack(rot)` |
| `FUNC_MOB_SKILL` | `fDirLen >= 50.0f` / 否则 | 同上,动作换 mob skill |
所有「走过去」分支都会 `__EnableSkipCollision()`。
- 数据层的插值另有一份,只用于可见性判定:`SNetworkActorData::UpdatePosition()`
`NetworkActorManager.cpp:8`)按 `(now - m_dwClientSrcTime) / m_dwDuration` 线性插值,
超时钳到 Dst。
- 本地上行的节流(`REF/UserInterface/PythonPlayerEventHandler.cpp`):
| 事件 | 节流 | 发送 |
|---|---|---|
| `OnMove` (:76) | 重置两个计时器(wait +100 ms / moving +300 ms | `FUNC_MOVE, 0` |
| `OnMoving`(:62) | 300 ms | `FUNC_MOVE, 0` |
| `OnWaiting`(:38)| 100 ms **且**位移 ≥ 1.0 | `FUNC_WAIT, 0` |
| `OnStop` (:88) | 无 | `FUNC_WAIT, 0` |
| `OnAttack`(:102)| 无 | `FUNC_COMBO, wMotionIndex` |
| `OnUseSkill`(:120)| 无 | `FUNC_SKILL\|uMotSkill, uArg` |
- 上行包 `SendCharacterStatePacket``PythonNetworkStreamPhaseGame.cpp:1111`):
`TPacketCGMove{ header=HEADER_CG_CHARACTER_MOVE, bFunc, bArg, bRot = rot/5.0f,
lX, lY, dwTime = ELTimer_GetServerMSec() }`;发送前 `__CanActMainInstance()` 为假直接返回 true
角度先归一到 `[0,360)`;坐标最后经 `__LocalPositionToGlobalPosition`。
**当前**`net_world.gd` 按距离阈值 snap/lerp`net_play.gd` 按固定时间/距离发送。
**修改**
1. `EntityStore` 新增每实体的 `std::deque<StateCmd>``{chk_time, dst_x, dst_y, rot, func, arg}`),
`mut_move()` 改为 `push_tcp_state()`,不再直接改坐标。
2. 新增 `EntityStore::process_states(server_frame_ms)`1:1 实现上表的 switch
(含 `1.0f` / `50.0f` 两个阈值与 `m_kMovAfterFunc` 的「到达后动作」)。
3. `EntityStore::update(now)` 里保留 `SNetworkActorData::UpdatePosition()` 的线性插值,
但**只**用于 §2.1 的可见性判定,不驱动节点位置。
4. `net_play.gd` 的上行改为 6 个事件回调 + 上表节流常量(100 / 300 ms、位移 1.0),
删除现有的「固定时间或距离」逻辑。
5. `ELTimer_GetServerMSec()` 的等价:`GC_HANDSHAKE` 的 `lDelta` 已用于时钟对齐
`REF/.../PythonNetworkStreamPhaseHandShake.cpp:124`),确认 `M2Client` 的
`server_time_ms()` 与之一致后统一使用,不要用本地 `ticks_msec`。
6. **不要**引入预测回滚 / 抖动缓冲——参考端没有。
---
### 3.3 普攻节奏硬编码
**参考**:攻击节奏完全来自 `.msa` 动作数据,没有任何硬编码间隔。
- 攻击距离:`CInstanceBase::NEW_IsClickableDistanceDestInstance`
`REF/UserInterface/InstanceBaseBattle.cpp:294`
```
fDistance = 150.0f;
if (IsBowMode()) fDistance = __GetBowRange();
if (dst.IsNPC()) fDistance = 500.0f;
if (dst.IsResource()) fDistance = 100.0f;
```
对像素坐标的通用判定 `NEW_IsClickableDistanceDestPixelPosition` 固定 `150.0f``:283`)。
- 攻击间隔:由动作时长决定。`CActorInstance::InputComboAttackCommand`
`REF/GameLib/ActorInstanceBattle.cpp:157`)用
`m_pkCurRaceMotionData->GetComboInputStartTime() / GetNextComboTime() / GetComboInputEndTime()`
`CRaceMotionData::TComboInputData{fInputStartTime, fNextComboTime, fInputEndTime}`
`REF/GameLib/RaceMotionData.h:177`);无 combo 输入数据时退化为
`fElapsedTime > GetMotionDuration() * 0.9f`。
- 攻速:`SetAttackSpeed(m_dwAtkSpd)` 影响动作播放速率
**[待读参考:`REF/UserInterface/InstanceBase.cpp` 的 `CInstanceBase::SetAttackSpeed`]**。
**当前**`POC/project/net_play.gd:25` 一组固定常量。
**修改**
1. 距离常量搬到 `net_play.gd``CLICK_DIST_DEFAULT=150.0`、`CLICK_DIST_NPC=500.0`、
`CLICK_DIST_RESOURCE=100.0`,弓箭走 `__GetBowRange()` 的等价
**[待读参考:`InstanceBaseBattle.cpp` 的 `__GetBowRange`]**。
2. 攻击间隔改为读 `.msa` 的 combo 输入时间(本工程已有 `.msa` 解析,见
`metin2_anim.cpp`);`m2_anim` 需要额外导出 `combo_input_start / next_combo / combo_input_end /
motion_duration`。
3. 删除 `net_play.gd` 里所有「按攻速缩放的固定间隔」。
---
### 3.4 自动攻击没有复现 ClientVS22 的目标预留逻辑
**参考**:两段。
**(a) `CPythonPlayer::__Update_AutoAttack()`**`REF/UserInterface/PythonPlayer.cpp:207`),每帧:
```
if (0 == m_dwAutoAttackTargetVID) return;
if (!NEW_GetMainActorPtr()) return;
if (__IsUsingChargeSkill()) return; // 弹射/冲锋中跳过
victim = NEW_FindActorPtr(m_dwAutoAttackTargetVID);
if (!victim) __ClearAutoAttackTargetActorID();
else if (victim->IsDead()) __ClearAutoAttackTargetActorID();
else if (main->IsMountingHorse() && !main->CanAttackHorseLevel())
__ClearAutoAttackTargetActorID();
else if (main->IsAttackableInstance(*victim))
{ if (main->IsSleep()) { /* skip */ } else __ReserveClickActor(m_dwAutoAttackTargetVID); }
```
`__IsUsingChargeSkill()``PythonPlayer.cpp:182`):`__CheckDashAffect` 为真,或
`MODE_USE_SKILL` 且预约技能是 `IsChargeSkill()`。
**(b) `CPythonPlayer::__ReserveProcess_ClickActor()`**`PythonPlayerInput.cpp:877`),
每帧在 `MODE_CLICK_ACTOR` 下跑,**顺序不可改**
```
1 main / reserved 任一为空 → __ClearReservedAction(); return
2 !NEW_IsClickableDistanceDestInstance → NEW_MoveToDestInstanceDirection(); return (继续靠近)
3 !IsAttackableInstance → NEW_Stop(); __SendClickActorPacket(); 清预约; return (NPC 对话)
4 reserved->IsDead() → 清预约; return
5 main->IsInSafe() → OnCannotAttack(..., "IN_SAFE"); NEW_Stop(); 清预约; return
6 reserved->IsInSafe() → OnCannotAttack(..., "DEST_IN_SAFE"); NEW_Stop(); 清预约; return
7 __CheckDashAffect(main) → return (保持预约)
8 main->IsBowMode() && !__HasEnoughArrow() → OnCannotShot(..., "EMPTY_ARROW"); NEW_Stop(); 清预约; return
9 reserved VID != GetTargetVID() → SetTarget(reserved VID)
10 NEW_AttackToDestInstanceDirection(reserved); __ClearReservedAction();
```
**(c) 预约模式枚举**`PythonPlayerInput.cpp:820-856`):
`MODE_NONE / MODE_CLICK_ITEM / MODE_CLICK_ACTOR / MODE_CLICK_POSITION / MODE_USE_SKILL`
每帧在 `NEW_RefreshMouseWalkingDirection()``PythonPlayerInputMouse.cpp:287`)里分派;
末尾还依次处理 `m_isSmtMov / m_isDirMov / m_isDirKey / m_isAtkKey`。
**当前**:只有「测距后发攻击包」。
**修改**
1. `player_controller.gd` 新增 `enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL}`
与 `_vid_reserved / _iid_reserved / _ppos_reserved / _skill_slot_reserved / _skill_range_reserved /
_reserved_delay`,逐字对齐 `PythonPlayerInput.cpp:820-865`。
2. 每帧执行 `_refresh_mouse_walking_direction()`,四个 case 分别照抄
`PythonPlayerInputMouse.cpp:288-370`(含 `MODE_CLICK_ITEM` 的 `< 20.0f` 拾取阈值、
`MODE_USE_SKILL` 的 `IS_HUGE_RACE` 距离 200 修正)。
3. `_reserve_process_click_actor()` 严格按 (b) 的 10 步实现。
4. `_update_auto_attack()` 按 (a) 实现;`m_dwAutoAttackTargetVID` 只在
`__OnPressActor(isAuto=true)` 且 `IsAttackableInstance` 时设置
`PythonPlayerInput.cpp:306` `__OnPressActor`)。
5. `OnCannotAttack` / `OnCannotShot` / `OnCannotUseSkill` 的字符串码
`IN_SAFE / DEST_IN_SAFE / EMPTY_ARROW / NEED_TARGET / NOT_HORSE_SKILL / HAVE_TO_RIDE /
NEED_EMPTY_BOTTLE / NEED_POISON_BOTTLE / REMOVE_FISHING_ROD`)走 `game.py` 同名回调的
`localeInfo` 文案表。
---
### 3.5 攻击动作、命中时机和网络包没有严格绑定
**参考**:三条链完全由动作数据驱动。
**(a) 连击索引**`REF/GameLib/ActorInstanceBattle.cpp:157 / 213 / 253`):
```
InputComboAttackCommand(rot):
m_fAtkDirRot = rot;
if (m_isPreInput) return false;
if (0 == m_dwcurComboIndex) { __RunNextCombo(); return true; }
if (motion->IsComboInputTimeData()):
t = GetAttackingElapsedTime()
if (t > GetComboInputEndTime()) { if (IsBowMode()) m_isNextPreInput = TRUE; return false; }
if (t > GetNextComboTime()) { __RunNextCombo(); return true; }
if (t > GetComboInputStartTime()) { m_isPreInput = TRUE; return false; }
else:
if (GetAttackingElapsedTime() > GetMotionDuration()*0.9f) { __RunNextCombo(); return true; }
```
`__RunNextCombo()``:253`):`++m_dwcurComboIndex`,用
`m_pkCurRaceData->GetComboDataPointer(m_wcurMotionMode, __GetCurrentComboType(), &pComboData)`
取 `ComboIndexVector[m_dwcurComboIndex-1]` 作为动作号,`ComboAttack(idx, m_fAtkDirRot, 0.1f)`。
连击打满时 `__OnEndCombo()`:骑马下 `m_dwcurComboIndex = 1`,否则**不复位**(靠动作回 Wait 复位)。
`__ClearCombo()``:305`)才把索引清零。
**(b) 命中帧**`ActorInstanceBattle.cpp:355 / 386` + `RaceMotionData`):
`isValidAttacking()` 遍历 `TMotionAttackData::HitDataContainer` 的
`THitData{fAttackStartTime, fAttackEndTime}`,落在窗口内才算命中窗口;
`CanCheckAttacking()` = `isAttacking()`(普攻 / 连击 / splash 三者之一)。
**(c) 发包**`REF/UserInterface/PythonPlayerEventHandler.cpp:135 OnHit`):
```
OnHit(uSkill, victimActor, isSendPacket):
CPythonPlayer::SetTarget(victimVID, FALSE);
if (isSendPacket) rkStream.SendAttackPacket(uSkill, victimVID); // CG_ATTACK{bType, dwVictimVID}
if (!victim.IsPushing()) return;
if (IS_HUGE_RACE(victim.GetRace())) return;
CPythonCharacterManager::AdjustCollisionWithOtherObjects(&victim);
victim.TEMP_Push(lastX, lastY); 记录到 m_kVctkVictim
```
`FlushVictimList()``:194`)把本帧所有被击退者打成一个 `TPacketCGSyncPosition`
(上限 `SYNC_POSITION_COUNT_LIMIT = 16`)。
`CInstanceBase::AttackProcess()``InstanceBaseBattle.cpp:413`)在 `CanCheckAttacking()` 为真时
遍历全部实例做 `CheckAttacking()`,后者先查 `IsInSafe()` 双方,再
`AttackingProcess(victimActor)` 做真正的几何判定。
**当前**`net_play.gd` 的 combo 只在 0/1/2 循环,命中/发包用定时器。
**修改**
1. `metin2_anim` 导出 `.msa` 的 `TMotionAttackData.HitDataContainer`(起止时间)与
combo 输入三时间,`.msa` 已解析结构见 `POC/extension/src/metin2_anim.cpp`。
2. `RaceData` 的 combo 表(`ComboIndexVector`,按 `MAKE_COMBO_KEY(motionMode, comboType)` 索引,
`REF/GameLib/RaceData.h:9`)需要从 `.msm` 解析出来;当前 `.msm` 解析只用于换肤。
3. `player_controller.gd` 实现 (a) 的状态机(`_cur_combo_index / _is_pre_input / _is_next_pre_input`),
删除 0/1/2 循环。
4. 命中判定与发包挂到动作事件:进入 `THitData` 窗口 → 几何判定 → `OnHit` → `CG_ATTACK`
`FlushVictimList` 每帧末尾发一次 `CG_SYNC_POSITION`(≤16 条),
`M2Client::sync_positions()` 已有上限 16 的实现,直接复用。
5. `OnAttack` 事件(动作真正起手时)发 `CG_CHARACTER_MOVE{FUNC_COMBO, wMotionIndex}`
与 `CG_ATTACK` 是**两个不同时刻**的两个包,不能合并。
---
### 3.6 弓箭发射使用固定 1.5 秒定时器
**参考**:由 `.msa` 的 `MOTION_EVENT_TYPE_FLY` 事件驱动,
经 `IFlyEventHandler` 两个回调(`REF/UserInterface/PythonPlayerEventHandler.cpp:235`):
```
CNormalBowAttack_FlyEventHandler_AutoClear::OnSetFlyTarget()
→ SendFlyTargetingPacket(targetVID, target->OnGetFlyTargetPosition()) // CG_FLY_TARGETING
CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot(dwSkillIndex)
→ SendShootPacket(dwSkillIndex) // CG_SHOOT
```
handler 由 `__OnPressActor` 通过
`rkPlayerEventHandler.GetNormalBowAttackFlyEventHandler(&main, &victim)` 取得并传给
`NEW_AttackToDestInstanceDirection``PythonPlayerInput.cpp:358`)。
`MOTION_EVENT_TYPE_FLY` 是 `CRaceMotionData::EMotionEventType` 的第 6 项
`REF/GameLib/RaceMotionData.h:168`)。
**当前**`game_scene.gd:218` 固定 1.5 s 定时器,单个 pending skill 会被覆盖。
**修改**
1. `.msa` 的 motion event 分派(本工程已实现,见 `game_scene._on_local_motion_event`)新增
`FLY` 分支:触发 `OnSetFlyTarget` → `client.fly_targeting(vid, x, y, z)`
飞行物命中/到期 → `OnShoot(skill)` → `client.shoot(skill)`。
2. 删除 `game_scene.gd:218` 的定时器与单槽 `pending skill`;改为**每次攻击创建一个
handler 实例**(对齐 `_AutoClear` 语义:射完自动清理),支持并发多发。
3. `SendFlyTargetingPacket` 的坐标取目标的 `OnGetFlyTargetPosition()`
**[待读参考:`REF/GameLib/ActorInstanceFly.cpp` 的 `OnGetFlyTargetPosition`]**。
---
### 3.7 受击状态分裂
**参考**:**没有**「客户端硬直」这种本地状态。可用于门控的只有三个,且全部来自
`CActorInstance`
- `IsDead()` / `IsKnockDown()` / `IsStun()`
- `__CanProcessNetworkStatePacket()``REF/UserInterface/InstanceBase.cpp:1247`)只查
`IsDead / IsKnockDown / (IsUsingSkill && !CanCancelSkill)`
- `NEW_Attack()``InstanceBaseBattle.cpp:355`)与 `NEW_UseSkill()``:310`)的门也只有
`IsDead / IsStun / IsKnockDown``NEW_Attack` 多一个 `IsUsingSkill`)。
`Stun` 来自服务端 affect`InstanceBaseEffect.cpp:932 case AFFECT_STUN`),
`KnockDown` 来自被击退动作,**都不是本地计时器**。
**当前**`EntityStore.stunned` 与 `NetPlay._hitstun_until` 两套并存。
**修改**
1. **删除 `net_play.gd` 的 `_hitstun_until` 及其全部引用**——参考端不存在这个概念。
2. `Entity` 增加 `knock_down: bool`,由受击动作(`.msa` 的 knockdown 动作播放中)置位,
动作结束清零;`dead` / `stunned` 保持由服务端包驱动。
3. 所有「能否动作」的判定统一走三个函数:
`_can_process_network_state()` / `_can_attack()` / `_can_use_skill()`
实现分别对齐 `InstanceBase.cpp:1247`、`InstanceBaseBattle.cpp:355`、`:310`。
---
### 3.8 技能缺少统一合法性检查
**参考**:三层,缺一不可。
**(a) `CPythonPlayer::ClickSkillSlot(dwSlotIndex)`**`REF/UserInterface/PythonPlayerSkill.cpp:108`
```
slot >= SKILL_MAX_NUM → return
!GetSkillData(inst.dwIndex, &data) → return
SKILL_TYPE_GUILD == data->byType → UseGuildSkill(slot); return
!data->IsCanUseSkill() → return // 被动技能挡在这
data->IsStandingSkill():
data->IsToggleSkill() && IsSkillActive(slot):
main->IsUsingSkill() → return
SendUseSkillPacket(inst.dwIndex); return // 关闭 toggle
__UseSkill(slot); return
m_dwcurSkillSlotIndex == slot → __UseSkill(slot); return
!__IsRightButtonSkillMode() → __UseSkill(slot)
else → m_dwcurSkillSlotIndex = slot; ChangeCurrentSkill(slot)
```
**(b) `CPythonPlayer::__CanUseSkill()`**`PythonPlayerSkill.cpp:439`
```
!NEW_GetMainActorPtr() → false
IsObserverMode() → false
IsMountingHorse() && GetSkillGrade(109) < 1 && GetSkillLevel(109) < 20 → false // 骑乘术硬编码
return main->CanUseSkill();
```
**(c) `CPythonPlayer::__CheckSkillUsable(slot)`**`PythonPlayerSkill.cpp:166`
```
IsMountingHorse() && !data->IsHorseSkill() → OnCannotUseSkill("NOT_HORSE_SKILL"); false
data->IsHorseSkill() && !IsMountingHorse() → OnCannotUseSkill("HAVE_TO_RIDE"); false
data->IsAttackSkill() && main->IsInSafe() → OnCannotUseSkill("IN_SAFE"); false
!data->IsCanUseSkill() → false
data->IsNeedEmptyBottle() && !__HasItem(27995) → OnCannotUseSkill("NEED_EMPTY_BOTTLE"); false
data->IsNeedPoisonBottle() && !__HasItem(27996) → OnCannotUseSkill("NEED_POISON_BOTTLE"); false
main->IsFishingMode() → OnCannotUseSkill("REMOVE_FISHING_ROD"); false
```
**(d) `__UseSkill(slot)`**`PythonPlayerSkill.cpp:460`)额外:
`IsOpenPrivateShop()` → true(静默);`__CheckSpecialSkill(idx)` → true
`data->IsToggleSkill() && IsSkillActive(slot)` → 发 `SendUseSkillPacket(idx, slot)` 并返回 false
`main->IsUsingSkill()` → false
需要目标时(`IsNeedTarget() || CanChangeDirection() || IsAutoSearchTarget()`
按 `IsNeedCorpse()` 取死/活目标,取不到就 `__ChangeTargetToPickedInstance()` 再取一次。
**当前**:只查 `skill id > 0``target_vid` 等参数被无符号化。
**修改**
1. 在 GDScript 侧新建 `player_skill.gd`,把 (a)(b)(c)(d) 四段**逐行**移植;
`SKILL_MAX_NUM`、`SKILL_TYPE_GUILD`、骑乘术 `109` / 空瓶 `27995` / 毒瓶 `27996`
均按参考写成命名常量。
2. `CPythonSkill::TSkillData` 的判定位(`IsCanUseSkill / IsStandingSkill / IsToggleSkill /
IsHorseSkill / IsAttackSkill / IsNeedTarget / IsNeedCorpse / IsAutoSearchTarget /
CanChangeDirection / IsNeedEmptyBottle / IsNeedPoisonBottle / IsChargeSkill`
来自 `skilltable` / `skilldesc`,需要在 `POC/extension/src/proto` 侧补解析
**[待读参考:`REF/UserInterface/PythonSkill.h` 的 `TSkillData` 字段与
`PythonSkill.cpp` 的 `RegisterSkillDesc` / `LoadSkillTable`]**。
3. `M2Client::use_skill()` 之类的公开 API 参数改为**带符号**并做范围校验(见 §3.9)。
---
### 3.9 公共网络 API 缺少参数边界检查
**参考**:参考端在**发送前**就有断言/裁剪,而不是靠服务端拒绝:
- `SendCharacterStatePacket``assert(lX >= 0 && lX < 204800)``PhaseGame.cpp:1132`);
角度先归一到 `[0,360)``bRot = rot/5.0f`。
- `OnAttack``assert(wMotionIndex < 255)``PythonPlayerEventHandler.cpp:104`)。
- `FlushVictimList``SYNC_POSITION_COUNT_LIMIT = 16``:196`)。
- `AddQuickSlot / DeleteQuickSlot / MoveQuickSlot``< 0 || >= QUICKSLOT_MAX_NUM(36)` 直接返回
`PythonPlayer.cpp:843/852/861`)。
- `RequestUseLocalQuickSlot``>= QUICKSLOT_MAX_COUNT_PER_LINE(8)` 返回(`:815`)。
- `RequestDeleteGlobalQuickSlot``>= QUICKSLOT_MAX_COUNT(32)` 返回(`:800`)。
- `GetAccountCharacterSlotData*``>= PLAYER_PER_ACCOUNT4(4)` 返回 0`PythonNetworkStream.cpp:378`)。
- `__RecvPlayerCreateSuccessPacket`:槽位越界 `TraceError` 并**吞掉**`PhaseSelect.cpp:239`)。
**修改**
1. 在 `m2_client.cpp` 的每个公开 `send_*` 入口按上表补 `int` 范围校验,越界返回 `false`
并记日志——**不要**先 `static_cast<uint32_t>`。
2. 新建 `POC/extension/src/net/limits.h` 收纳参考常量:
`PLAYER_PER_ACCOUNT4=4, QUICKSLOT_MAX_NUM=36, QUICKSLOT_MAX_COUNT=32,
QUICKSLOT_MAX_COUNT_PER_LINE=8, QUICKSLOT_MAX_LINE=4, SHOP_HOST_ITEM_MAX_NUM=40,
METIN_SOCKET_COUNT=6, PARTY_AFFECT_SLOT_MAX_NUM=7, EXCHANGE_ITEM_MAX_NUM=12,
SAFEBOX_PAGE_SIZE=45, REFINE_MATERIAL_MAX_NUM=5, SYNC_POSITION_COUNT_LIMIT=16,
MAP_COORD_MAX=204800`(来源:`REF/UserInterface/Packet.h:380`、`PythonSafeBox.h:8`、
`PythonExchange.h:13`、`PythonPlayerEventHandler.cpp:200`、`PhaseGame.cpp:1132`)。
3. 回归:`net_bounds_test.gd` 对每个 API 各喂一个越界值,断言返回 false 且未产生出站字节。
---
### 3.10 技能升级传输已对齐但状态门控不完整
**证据边界与订正**:本次 `ClientVS22/source/` 不含 Python UI 脚本;本仓与该客户端配套的
迁移资产脚本本来就是聊天命令。因此不能据“使用命令”断定实现不符合参考端,也不能据此
断定当前必须改传技能 vnum。`uicharacter.py:1022 OnPressedSlotButton()`(本仓
`POC/assets/root/uicharacter.py`):
```python
srcSlotIndex = self.__RealSkillSlotToSourceSlot(slotNumber)
skillIndex = player.GetSkillIndex(srcSlotIndex)
curLevel = player.GetSkillLevel(srcSlotIndex) # 本版本算了但未使用
maxLevel = skill.GetSkillMaxLevel(skillIndex) # 本版本算了但未使用
net.SendChatPacket("/skillup " + str(skillIndex))
```
所以 `CLIENT-GAP.md` §3.10 的「应改成非命令路径」不成立,主文档已改为记录门控和状态流程差距。
**同时**:本文早前版本曾断言「当前实现应把参数从技能槽改为技能 vnum」——
该结论**未经证明,撤回**。`POC/project/ui/skill_ui.gd:145` 传给
`client.skill_up(sid)` 的 `sid` 来自 `table.for_category()`,本来就是技能表 ID/索引,
与参考端 `player.GetSkillIndex(srcSlotIndex)` 的产物同类。
**真正的差距是「加号按钮门控」完全缺失。** 迁移资产中的参考脚本把全部条件放在按钮
**显示**阶段(`uicharacter.py`),点下去就无条件发包;C++ 侧可核实的技能上限检查见
`PythonSkill.cpp`
```python
# RefreshSkillPlusButtonList() :984
if self.isLoaded == 0: return
self.RefreshSkillPlusPointLabel()
if not self.__CanUseSkillNow(): return # 死亡/变身/骑乘等状态门(:1270)
curSelectedSkillGroup == PAGE_HORSE ? __RefreshSkillPlusButton("HORSE")
: __RefreshSkillPlusButton("ACTIVE")
__RefreshSkillPlusButton("SUPPORT")
# __RefreshSkillPlusButton(name) :937
if HIDE_SUPPORT_SKILL_POINT and name == "SUPPORT": return
slotWindow.HideAllSlotButton()
slotStatType = self.skillPageStatDict[name] # ACTIVE→SKILL_ACTIVE, SUPPORT→SKILL_SUPPORT, HORSE→SKILL_HORSE
if 0 == slotStatType: return
statPoint = player.GetStatus(slotStatType)
if statPoint > 0: # ← 技能点校验
for 每个槽:
skillIndex = player.GetSkillIndex(slot); skillGrade = player.GetSkillGrade(slot)
skillLevel = player.GetSkillLevel(slot)
if skillIndex == 0: continue
if skillGrade != 0: continue # ← 已升阶的技能不给加号
if name == "HORSE":
if player.GetStatus(player.LEVEL) >= skill.GetSkillLevelLimit(skillIndex): # ← 等级前置
if skillLevel < 20: # ← 马术上限 20
slotWindow.ShowSlotButton(__GetETCSkillRealSlotIndex(slot))
else:
if name == "SUPPORT" and SHOW_LIMIT_SUPPORT_SKILL_LIST:
if skillIndex not in SHOW_LIMIT_SUPPORT_SKILL_LIST: continue # ← 辅助技能白名单
if self.CanShowPlusButton(skillIndex, skillLevel, statPoint):
slotWindow.ShowSlotButton(slot)
# CanShowPlusButton(skillIndex, skillLevel, curStatPoint) :925
if 0 == skillIndex: return False
if not skill.CanLevelUpSkill(skillIndex, skillLevel): return False
return True
```
**当前**`POC/project/ui/skill_ui.gd:141` 只有
`if table.can_level_up(id) and not table.is_passive(id)`,缺技能点校验、skillGrade、
等级前置、马术上限、辅助白名单和 `__CanUseSkillNow()` 状态门。
**修改**
1. `skill_ui.gd` 按上面的三段(`RefreshSkillPlusButtonList` / `__RefreshSkillPlusButton` /
`CanShowPlusButton`)**逐行**实现加号按钮的显示门控,替换现有单行判断。
2. 三个技能点来源分开取:`SKILL_ACTIVE` / `SKILL_SUPPORT` / `SKILL_HORSE`
`uicharacter.py:1010-1019`),不是统一一个 `POINT_STAT`。
3. `skill.CanLevelUpSkill(skillIndex, skillLevel)` 与 `skill.GetSkillLevelLimit(skillIndex)`
需要 skill table 支持,见 §3.8 第 2 点的 `TSkillData` 解析
**[待读参考:`REF/UserInterface/PythonSkill.cpp` 的 `CanLevelUpSkill` / `GetSkillLevelLimit`]**。
4. `M2Client::skill_up(int skill_id)``m2_client.cpp:850`)的发包本身保持不变
`/skillup <skillIndex>`),只补 §3.9 的入参范围校验。
5. 升级结果由 `GC_SKILL_LEVEL` / `GC_CHANGE_SKILL_GROUP` 回包驱动刷新,
失败提示走 `GC_CHAT` 的服务端文案——不本地伪造成功。
---
## 4. P1:物品、装备、商店和社交
### 4.1 24 个 wear 位置进入数据层,但没有完整视觉和规则
**参考**`ClientVS22` 的编译开关(`REF/UserInterface/Locale_inc.h`,本份为
`LOCALE_SERVICE_SINGAPORE`)打开了 `ENABLE_COSTUME_SYSTEM` / `ENABLE_ENERGY_SYSTEM` /
`ENABLE_DRAGON_SOUL_SYSTEM` / `ENABLE_NEW_EQUIPMENT_SYSTEM`。对应的**槽位版图**
`REF/UserInterface/GameType.h:40-112`):
```
c_Inventory_Page_Size = 5*9 = 45 c_Inventory_Page_Count = 2
c_ItemSlot_Count = 90 c_Equipment_Start = 90 c_Equipment_Count = 12
+0 Body +1 Head +2 Shoes +3 Wrist +4 Weapon +5 Neck
+6 Ear +7 Unique1 +8 Unique2 +9 Arrow +10 Shield
+19 Costume_Body +20 Costume_Hair (ENABLE_COSTUME_SYSTEM)
+21 Ring1 +22 Ring2 +23 Belt (ENABLE_NEW_EQUIPMENT_SYSTEM)
c_Wear_Max = 32
c_DragonSoul_Equip_Start = 90 + 32 = 1226 槽 × DS_DECK_MAX_NUM(2)
c_Belt_Inventory_Width/Height = 4/4 → 16 格
```
本工程的「24 个 wear 位置」= `c_Equipment_Start + 0 .. +23`,**与参考一致**,无需改数据层。
**装备规则**来自 `CItemData``REF/GameLib/ItemData.h`):
- `EWearPositions``:227`):`WEAR_BODY..WEAR_SHIELD``WEAR_MAX_NUM = 11`(基础 11 位)。
- `EItemWearableFlag``:241`):`WEARABLE_BODY(1<<0) … WEARABLE_ARROW(1<<9)`——
「这件装备能穿在哪些位置」的位掩码。
- `EItemAntiFlags``:188`):`FEMALE/MALE/WARRIOR/ASSASSIN/SURA/SHAMAN`(职业性别限制)、
`GET/DROP/SELL/SAVE/GIVE/PKDROP/STACK/MYSHOP`、`EMPIRE_A/B/R`。
- `EItemFlag``:207`):`REFINEABLE/STACKABLE/COUNT_PER_1GOLD/RARE/UNIQUE/IRREMOVABLE/
CONFIRM_WHEN_USE/QUEST_USE/APPLICABLE` 等。
- `EItemTypes``:24`):0..34,其中 `ITEM_TYPE_COSTUME = 28`、`ITEM_TYPE_RING = 33`、
`ITEM_TYPE_BELT = 34`、`ITEM_TYPE_ROD = 13`、`ITEM_TYPE_METIN = 10`。
- 武器子类 `EWeaponSubTypes``:64`):`WEAPON_SWORD/DAGGER/BOW/TWO_HANDED/BELL/FAN/ARROW`。
**换模**`CInstanceBase::ChangeArmor/ChangeWeapon/ChangeHair`,最终经
`__GetRaceType` + `RefreshState`(§6.4)决定 motion mode。远端角色的换装同样走
`CNetworkActorManager::UpdateActor`(§2.5)。
**修改**
1. `proto` 层导出上述四组位标志(当前只用了 `specular`),落到
`POC/extension/src/proto/item_proto.h` 的 `ItemProto`。
2. 装备穿脱校验按参考顺序:`antiflag(性别/职业/帝国)` → `wearable_flag` 与目标槽位匹配 →
`limit`(等级/属性) → 发 `CG_ITEM_USE`。校验失败的文案走 `localeInfo` 的
`CANNOT_EQUIP_*` 键。
3. costume(19/20) / ring(21/22) / belt(23) 的槽位在 `inventory_ui.gd` /
`equipment_dialog` 里补齐,并按 `uiequipmentdialog.py` 的布局装载。
4. 部位隐藏 / 遮挡(穿 costume 时隐藏本体 armor、穿头盔隐藏发型)按
`REF/UserInterface/InstanceBase.cpp` 的 `__IsShapeAnimalWear` / `SetHair` 等实现
**[待读参考:`InstanceBase.cpp` 的 `ChangeArmor` / `SetArmor` / `__AttachHorseSaddle`]**。
5. 远端角色换装:`net_world.gd` 的实体刷新已在 §2.5 对齐调用序,此处只需把
`armor/weapon/hair` 三个字段接到换模。
---
### 4.2 物品拖放不是原版鼠标物品态
**参考**:全局单例 `mouseModule.mouseController``POC/assets/root/mousemodule.py`
`class CMouseController`),是所有窗口之间拖放的**唯一中介**:
```
AttachObject(Owner, Type, SlotNumber, ItemIndex, count=0) mousemodule.py:171
Type ∈ ESlotTypeGameType.h:140:
SLOT_TYPE_INVENTORY / SHOP / EXCHANGE_OWNER / EXCHANGE_TARGET / QUICK_SLOT /
SAFEBOX / PRIVATE_SHOP / MALL / DRAGON_SOUL_INVENTORY → 取物品图标
SLOT_TYPE_SKILL / SLOT_TYPE_EMOTION → 取技能 / 表情图标
SLOT_TYPE_QUICK_SLOT → 再取快捷栏里登记的真实类型递归解析
AttachMoney(owner, type, count) :264 IsAttachedMoney() :251
DeattachObject() :284 isAttached() :315
GetAttachedOwner/Type/SlotNumber/ItemIndex/ItemCount :318-343
```
落点窗口的两个事件与参考里到处出现的模式一致(以 `uisafebox.py:411` 为样板):
```
SelectEmptySlot(pos): if not isAttached(): return
按 GetAttachedType() 分支 → 发对应移动/存取包 → DeattachObject()
SelectItemSlot(pos): 已有物品的格子;未持物时 AttachObject(self, <本窗类型>, pos, itemID)
并 snd.PlaySound("sound/ui/pick.wav")
```
`SlotTypeToInvenType(bSlotType)``REF/UserInterface/GameType.cpp:106` + 表 `:90`)把
slot type 映射到 `EWindows`:只有 `SLOT_TYPE_INVENTORY→INVENTORY`、
`SLOT_TYPE_DRAGON_SOUL_INVENTORY→DRAGON_SOUL_INVENTORY`,其余一律 `RESERVED_WINDOW`。
落点代码里 `if RESERVED_WINDOW == attachedInvenType: return` 是标准防御
`uisafebox.py:437`)。
音效固定:拿起 `sound/ui/pick.wav`、放下 `sound/ui/drop.wav`、金钱 `sound/ui/money.wav`、
非法 `sound/ui/loginfail.wav`。
**当前**:各窗口各自实现点击/拖放,没有全局持有态。
**修改**
1. 新建 `POC/project/ui/mouse_controller.gd`autoload 单例),API 逐个对齐上表
(名字保持 `attach_object / attach_money / deattach_object / is_attached /
get_attached_type / get_attached_slot_number / get_attached_item_index /
get_attached_item_count / is_attached_money`)。
2. `ESlotType` / `EWindows` / `SlotTypeToInvenType` 三张表移植到
`POC/project/ui/game_type.gd`,值与 `GameType.h` 完全一致。
3. 所有物品窗口(inventory / safebox / mall / shop / exchange / private_shop / quickslot /
dragon_soul)统一改为 `select_empty_slot` / `select_item_slot` 两个入口,
分支照抄各自的 `ui*.py`。
4. 鼠标跟随图标:`CanvasLayer` 上画 `AttachedIconHandle` 等价物,位置跟随鼠标,
数量文字用 `countNumberLine``mousemodule.py:87`)。
5. 拖动取消:右键或落到无效目标 → `deattach_object()`,不发包(参考无回滚包)。
6. 音效按上表四个固定路径。
---
### 4.3 物品 tooltip 只覆盖基础信息
**参考**`POC/assets/root/uitooltip.py`(2718 行)是完整实现,本仓已有该脚本。
入口按来源分:`SetInventoryItem / SetShopItem / SetExchangeOwnerItem /
SetExchangeTargetItem / SetSafeBoxItem / SetMallItem / SetPrivateShopBuilderItem /
SetSkill / SetQuickSlotItem`。
**修改**
1. **不要**重写 tooltip 逻辑。按 §8 的窗口迁移样板(`char_status_ui.gd` 的做法)
直接移植 `uitooltip.py` 的 `ItemToolTip` 类:
- 数值来源逐字对齐(`item.GetItemName / GetItemDescription / GetItemSummary /
GetValue(i) / GetLimit(i) / GetAffect(i) / GetItemSubType / GetItemSize` 等);
- 属性行(attribute)、魔石孔(socket`METIN_SOCKET_COUNT = 6`)、
`ITEM_FLAG_*` / `ITEM_ANTIFLAG_*` 行、`LIMIT_LEVEL/LIMIT_STR/...`、
精炼等级、使用时限(`GetItemDuration`)逐条对齐。
2. `proto` 层补齐 tooltip 需要的全部字段(`ItemProto.value[6] / limit[2] / apply[3] /
flags / anti_flags / wear_flags / socket / refine` 等),当前只导出了少量字段。
3. 装备对比(穿戴中 vs 鼠标所指)按 `uitooltip.py` 的 `__AppendEquipmentCompare` 等价实现
**[待读参考:`uitooltip.py` 中比较相关的方法名以本仓文件为准]**。
---
### 4.4 Safebox 是列表和自动找空位
**参考**
- 容量:`CPythonSafeBox``REF/UserInterface/PythonSafeBox.h:8`
`SAFEBOX_SLOT_X_COUNT = 5`、`SAFEBOX_SLOT_Y_COUNT = 9`、`SAFEBOX_PAGE_SIZE = 45`。
- 分页:`uisafebox.py:362 SetTableSize(size)`
```python
pageCount = max(1, size // safebox.SAFEBOX_SLOT_Y_COUNT)
pageCount = min(3, pageCount) # 最多 3 页
size = safebox.SAFEBOX_SLOT_Y_COUNT
self.wndItem.ArrangeSlot(0, SAFEBOX_SLOT_X_COUNT, size, 32, 32, 0, 0)
wnd_height = 130 + 32 * size
```
全局槽号 `__LocalPosToGlobalPos(local) = curPageIndex * SAFEBOX_PAGE_SIZE + local``:357`)。
- 协议(`REF/UserInterface/Packet.h:98`):
`CG_SAFEBOX_CHECKIN = 70`、`CG_SAFEBOX_CHECKOUT = 71`、`CG_SAFEBOX_ITEM_MOVE = 77`
`GC_SAFEBOX_MONEY_CHANGE = 84`、`GC_SAFEBOX_SET = 85`、`GC_SAFEBOX_DEL = 86`、
`GC_SAFEBOX_WRONG_PASSWORD = 87`、`GC_SAFEBOX_SIZE = 88`。
发送函数:`SendSafeBoxCheckinPacket(TItemPos inventoryPos, BYTE safeBoxPos)`
`REF/.../PythonNetworkStreamPhaseGameItem.cpp:29`)——**目标格由调用方指定**
`SendSafeBoxCheckoutPacket(BYTE safeBoxPos, TItemPos inventoryPos)``:43`)、
`SendSafeBoxItemMovePacket(BYTE src, BYTE dst, BYTE count)``:57`)。
`SendSafeBoxMoneyPacket` 在参考里是 `assert(!"…사용하지 않는 함수")`——**已废弃**
金钱走聊天命令(见下)。
- 落点分支(`uisafebox.py:411 SelectEmptySlot`):
```
attachedType == SLOT_TYPE_SAFEBOX → SendSafeboxItemMovePacket(src, dst, 0) # 仓库内移动
否则 attachedInvenType = SlotTypeToInvenType(attachedType)
RESERVED_WINDOW → return
ITEM_MONEY → SendSafeboxSaveMoneyPacket(count) + sound/ui/money.wav
其它 → SendSafeboxCheckinPacket(attachedInvenType, attachedSlotPos, selectedSlotPos)
最后 DeattachObject()
```
`SelectItemSlot``:451`):持物时只处理金钱;未持物时先看光标——
`app.SELL` → `SAFEBOX_SELL_DISABLE_SAFEITEM``app.BUY` → `SHOP_BUY_INFO`
否则 `AttachObject(self, SLOT_TYPE_SAFEBOX, pos, itemID)` + `sound/ui/pick.wav`。
- 关闭:`Close()` = `net.SendChatPacket("/safebox_close")``uisafebox.py:400`)。
- 密码 / 改密:`dlgChangePassword``uisafebox.py`),错误由 `GC_SAFEBOX_WRONG_PASSWORD` 驱动。
**当前**`POC/project/ui/safebox_ui.gd:7` 是列表 + 自动找空位。
**修改**
1. `safebox_ui.gd` 重写为 5×9 网格 + 最多 3 页的页签,`__LocalPosToGlobalPos` 照抄。
2. 存取一律**指定目标格**`M2Client::safebox_checkin(inven_window, inven_pos, safe_pos)`
删除「找下一个空位」的逻辑。
3. 补 `CG_SAFEBOX_ITEM_MOVE(77)` 的发送(仓库内移动)。
4. 金钱走 `/safebox_money` 类聊天命令**[待读参考:`netSendSafeboxSaveMoneyPacket` 在
`PythonNetworkStreamModule.cpp` 中的实现,确认是包还是聊天命令]**;
关闭固定用 `client.say(0, "/safebox_close")`。
5. 接入 `mouse_controller`(§4.2),两个 slot 事件按上面的分支表实现,含光标态提示文案。
6. 密码窗与 `GC_SAFEBOX_WRONG_PASSWORD` 的重试提示接上。
---
### 4.5 交易缺少事务状态
**参考**
- 数据:`CPythonExchange``REF/UserInterface/PythonExchange.h:9`
`EXCHANGE_ITEM_MAX_NUM = 12``TExchangeData{ name[25], item_vnum[12], item_count[12],
item_metin[12][ITEM_SOCKET_SLOT_MAX_NUM], item_attr[12][ITEM_ATTRIBUTE_SLOT_MAX_NUM],
accept, elk }`self / target 各一份。
- UI`POC/assets/root/uiexchange.py`):
- `AcceptExchange()``net.SendExchangeAcceptPacket()` 然后 `self.AcceptButton.Disable()`。
- `Refresh()``GetAcceptFromSelf()` 为真 → `OwnerAcceptLight.Down()`
**为假 → `AcceptButton.Enable(); AcceptButton.SetUp(); OwnerAcceptLight.SetUp()`**
—— 即「对方/自己改动导致服务端清 accept」时按钮自动恢复可点,这就是
“接受后修改自动取消接受”的 1:1 实现,客户端**不本地判定**,只跟随服务端的 accept 位。
- `OpenPickMoneyDialog()``if exchange.GetElkFromSelf() > 0:` →
`EXCHANGE_CANT_EDIT_MONEY`,不弹窗(金钱一旦放入不可改)。`SetMax(7)` = 7 位数上限。
- `SelectOwnerEmptySlot()`:持金钱 → `SendExchangeElkAddPacket(amount)`
持物品且 `attachedSlotType ∈ {SLOT_TYPE_INVENTORY, SLOT_TYPE_DRAGON_SOUL_INVENTORY}` →
`item.SelectItem(itemID)``item.IsAntiFlag(item.ANTIFLAG_GIVE)` → 提示
`EXCHANGE_CANNOT_GIVE` 并 `DeattachObject()` 返回;否则
`net.SendExchangeItemAddPacket(attachedInvenType, SrcSlotNumber, DstSlotNumber)`。
- `OnUpdate()``USE_EXCHANGE_LIMIT_RANGE = 1000`——自开窗坐标起
`abs(dx) > 1000 or abs(dy) > 1000` → `net.SendExchangeExitPacket()`。
- 标题栏关闭按钮直接绑 `net.SendExchangeExitPacket`。
**当前**:两列文字列表,无 accept / 距离 / anti-flag 规则。
**修改**
1. `exchange_ui.gd` 重写为 `uiexchange.py` 的 1:112 格 × 2 + 金钱 + 两个 accept 灯 +
`AcceptButton`,用 `exchangedialog.py` uiscript 装载真实布局。
2. `EntityStore` 的交易状态补齐 `TExchangeData` 全字段(含 socket / attribute 二维数组)。
3. accept 语义:**只跟随服务端**。发送 `CG_EXCHANGE(ACCEPT)` 后立刻禁用按钮;
收到 `GC_EXCHANGE` 且 `accept == 0` 时重新启用——不写本地「修改则取消」逻辑。
4. `OnUpdate` 的 1000 距离退出、`ANTIFLAG_GIVE` 拒绝、金钱 7 位上限与
`EXCHANGE_CANT_EDIT_MONEY` 全部照抄。
5. 交易中的背包锁定:参考端**没有**——不要加。
---
### 4.6 NPC 商店仍是基础列表
**参考**`POC/assets/root/uishop.py`
- 槽位:`shop.SHOP_SLOT_COUNT`;多货架的全局槽号
`GetIndexFromSlotPos(slotPos) = self.tabIdx * shop.SHOP_SLOT_COUNT + slotPos``:226`
本工程增量 34 已按此实现,保持)。
- 买卖是**光标模式**,不是按钮直接买:
```python
OnBuy(): chat.AppendChat(CHAT_TYPE_INFO, SHOP_BUY_INFO); app.SetCursor(app.BUY); btnSell.SetUp()
OnSell(): chat.AppendChat(CHAT_TYPE_INFO, SHOP_SELL_INFO); app.SetCursor(app.SELL); btnBuy.SetUp()
CancelShopping(): btnBuy.SetUp(); btnSell.SetUp(); app.SetCursor(app.NORMAL)
```
- 卖出 `SellAttachedItem()``:279`):
```
shop.IsPrivateShop() → DeattachObject(); return
attachedSlotType 必须是 SLOT_TYPE_INVENTORY,否则 sound/ui/loginfail.wav
item.IsAntiFlag(item.ANTIFLAG_SELL) → PopupDialog(SHOP_CANNOT_SELL_ITEM); return
player.IsValuableItem(pos):
itemPrice = item.GetISellItemPrice()
if item.Is1GoldItem(): itemPrice = attachedCount // itemPrice // 5
else: itemPrice = itemPrice * max(1, attachedCount) // 5
QuestionDialog(DO_YOU_SELL_ITEM(name, count, price)) → OnSellItem(pos, count, itemtype)
否则直接 OnSellItem(...)
OnSellItem: net.SendShopSellPacketNew(slotPos, count, itemtype); sound/ui/money.wav
```
注意**卖价 = 单价 × 数量 ÷ 5**(整数除),`Is1GoldItem` 走另一条公式。
- 关闭:`OnPressEscapeKey``:250`)→ `self.Close()` 并 `return True`。
**当前**:基础列表 + 直接买。
**修改**
1. `shop_ui.gd` 用 `shopdialog.py` uiscript 装载真实布局(格子 + 图标 + 买/卖两个 toggle)。
2. 实现 BUY / SELL 光标模式:`app.SetCursor` 的等价见 §8.5 的光标管理器。
3. 卖出流程按上面的 8 步照抄,含 `ANTIFLAG_SELL` 弹窗、`IsValuableItem` 的确认框、
`÷5` 价格公式和 `Is1GoldItem` 分支。
4. 买入:点格子(光标为 BUY 时)→ `net.SendShopBuyPacket(GetIndexFromSlotPos(pos))`。
5. 货币类型(`ShopTab.coin_type`)与库存计数已在增量 34 实现,保持。
6. 错误提示一律用服务端 `GC_CHAT` 文案 + `localeInfo` 的 `SHOP_*` 键。
---
### 4.7 私人商店是兼容实现
**参考**`POC/assets/root/uiprivateshopbuilder.py``itemStock` 模型)+
`PRIVATE_SHOP_ITEM_MAX_NUM`= `TPacketCGMyShop::bCount` 上限)。
本工程增量 35/36 已按该文件重写过一次,交互模型(拿起 / 落位 / 价格弹窗 / 排序 +
`display_pos` / 撤下 / 开设 / 撤收)与参考一致。
**剩余差距与修改**
1. 拖放改走 §4.2 的全局 `mouse_controller`,而不是当前的「点选候选面板」。
2. 开店前检查:参考在 `uiprivateshopbuilder.py` 里检查
`item.IsAntiFlag(item.ANTIFLAG_MYSHOP)``ITEM_ANTIFLAG_MYSHOP = 1<<16`
`REF/GameLib/ItemData.h:204`)——当前缺,补上。
3. 其他玩家查看:`GC_SHOP` 的 `SHOP_SUBHEADER_GC_START_EX` 已实现;招牌走
`GC_SHOP_SIGN`(已实现),但需按 §8.2 换成真 `.sub` 资源渲染。
4. 撤下回滚:参考端**没有**本地回滚——撤下即发包,状态由 `GC_SHOP` 回包驱动。
现有实现若做了本地乐观更新,删掉。
---
### 4.8 快捷栏来源类型不完整
**参考**
- 容量(`REF/UserInterface/Packet.h:392`):
`QUICKSLOT_MAX_LINE = 4`、`QUICKSLOT_MAX_COUNT_PER_LINE = 8`、
`QUICKSLOT_MAX_COUNT = 32`、`QUICKSLOT_MAX_NUM = 36`(**服务器保存 36,客户端只显示 32**)。
- 类型:`TQuickSlot{ BYTE Type; BYTE Position; }``REF/UserInterface/GameType.h:307`),
其中 **`Type` 直接就是 `ESlotType`**`GameType.h:140`)——
`CPythonPlayer::RequestUseLocalQuickSlot``REF/UserInterface/PythonPlayer.cpp:813`
拿 `GetLocalQuickSlotData()` 的原值直接 switch
```
SLOT_TYPE_INVENTORY (1) → SendItemUsePacket(TItemPos(INVENTORY, pos))
SLOT_TYPE_SKILL (2) → ClickSkillSlot(pos) // 走 §3.8 的完整链
SLOT_TYPE_EMOTION (3) → BINARY_ActEmotion(pos)
```
**参考端快捷栏没有「命令」类型**,`CLIENT-GAP.md` 里提到的“缺命令”不成立。
- **⚠ 当前实现的类型编号与参考端不兼容**:
`POC/extension/src/net/m2_client.cpp:878` 是
```cpp
d["type"] = (int)qs.type; // 1 item / 2 skill / 3 command / 4 emotion
```
而参考端 `3 == SLOT_TYPE_EMOTION`、`4 == SLOT_TYPE_SHOP`(快捷栏不可执行)。
也就是说**当前把服务端发来的 EMOTION(3) 当成了 command,把不可执行的 SHOP(4) 当成了
emotion**——服务端下发的表情快捷栏会被误判,`quickslot_add()`
`m2_client.cpp:897` 的 `type < 1 || type > 4`)也会把错误类型发回服务端。
- 边界(`PythonPlayer.cpp:800/815/843/852/861`):
`RequestDeleteGlobalQuickSlot >= QUICKSLOT_MAX_COUNT(32) → return`
`RequestUseLocalQuickSlot >= QUICKSLOT_MAX_COUNT_PER_LINE(8) → return`
`AddQuickSlot / DeleteQuickSlot / MoveQuickSlot` 越界 `QUICKSLOT_MAX_NUM(36)` → return。
`MoveQuickSlot(src, dst)` 是 `std::swap`,不是覆盖。
- 键位(`POC/assets/root/game.py:509`):
```python
def __PressQuickSlot(self, localSlotIndex):
if app.IsRTL():
if 0 <= localSlotIndex < 4: player.RequestUseLocalQuickSlot(3 - localSlotIndex)
else: player.RequestUseLocalQuickSlot(11 - localSlotIndex)
else: player.RequestUseLocalQuickSlot(localSlotIndex)
```
数字键 14 走 `pressNumber`(默认 = `__PressQuickSlot`),
按住 LSHIFT 时 `pressNumber` 切换成 `__SelectQuickPage``game.py:506`)。
F1F4 固定映射到 `__PressQuickSlot(4..7)``game.py:318`)。
**当前**:界面显示 36 格(4 页 × 9 格)+ F1–F4 + 数字键 1–9;参考端是服务器保存 36 格、
客户端显示 32 格(4 页 × 8 格)。
**修改**
1. 数字键改为 **1–4 → 快捷栏 0–3**(`__PressNumKey` 在 `game.py:431`
非 Ctrl 时 `if 1 <= num <= 4: self.pressNumber(num-1)`
**Ctrl + 1..9 是表情**`chrmgr.SetEmoticon(-1, num-1)` + `net.SendEmoticon(num-1)`)。
当前的「数字键 1–9 全给快捷栏」要改掉。
2. LSHIFT 按下 → `pressNumber = __SelectQuickPage`;松开 → `pressNumber = __PressQuickSlot`
`game.py:370` / `:408`)。
3. **先修类型编号**`EntityStore` / `M2Client` / `quickslot_ui` 里的快捷栏 type 一律改用
§4.2 移植的 `ESlotType``SLOT_TYPE_INVENTORY=1 / SKILL=2 / EMOTION=3`),
删掉 `m2_client.cpp:878` 的 `3 command / 4 emotion` 注释与对应分支;
`quickslot_add()` 的入参校验从 `1..4` 改为「只接受 1 / 2 / 3」。
这是**数据错误**,必须先于 UI 工作修复。
4. 快捷栏类型只保留 `INVENTORY / SKILL / EMOTION` 三种,`SKILL` 必须走 §3.8 的
`ClickSkillSlot` 完整链,不能直接发包;收到其它 `ESlotType` 一律不执行(与参考的
switch 落空行为一致)。
5. 边界常量按 §3.9 的 `limits.h``MoveQuickSlot` 改为 swap。
6. 物品拖入快捷栏:走 §4.2 的 `mouse_controller``AttachObject` 的
`SLOT_TYPE_QUICK_SLOT` 分支会递归解析真实类型(`mousemodule.py:218`)。
7. 冷却 / 死亡 / 变身 / 换武器后的刷新由对应回包驱动,不本地计时。
---
### 4.9 组队状态是近似定义
**参考**`PARTY_AFFECT_SLOT_MAX_NUM = 7``REF/UserInterface/Packet.h:404`)。
角色枚举与 Leadership 门槛在本工程增量 43 已按 40250 服务端 `CParty::Update` 对齐
NORMAL 0 / LEADER 1 / ATTACKER 2 / TANKER 3 / BUFFER 4 / SKILL_MASTER 5 / HASTE 6 /
DEFENDER 7;门槛 10/20/20/25/35/40,治疗 18)。
**剩余差距与修改**
1. `affects[7]` 的**槽位语义**目前是“暂定映射”。需要从 40250 服务端
`game/src/party.cpp` 的 `CParty::ComputeRolePoint / Update` 读出每个下标的含义
**[待读参考:服务端源码,不在 ClientVS22 内]**;在拿到之前,
UI 上的 chip 名称保留占位并在文档标注,不要臆造名称。
2. 队伍技能冷却:参考客户端**不本地计时**,冷却由服务端拒绝 + `GC_CHAT` 提示体现。
若当前有本地冷却,删除。
3. 队长权限动态变化:以 `get_party()` 里本地 VID 那条的 `leader` 位为唯一判据(已实现),
收到 `GC_PARTY_*` 后立即重算按钮可用性。
4. 离线 / 死亡 / 距离状态:来自 `GC_PARTY_UPDATE` 的字段,接到 `PartyMemberInfoBoard` 的
对应显示位。
---
### 4.10 好友、情侣、公会只有首版状态展示
**参考**`POC/assets/root/uimessenger.py`(739 行,分组 / 上线离线 / 右键菜单)、
`uiwhisper.py`(私聊窗生命周期)、`uiguild.py`。
公会名长度 `GUILD_NAME_MAX_LEN = 12`、等级名 `GUILD_GRADE_NAME_MAX_LEN = 8`、
等级数 `GUILD_GRADE_COUNT = 15`、公告 `GULID_COMMENT_MAX_LEN = 50`
`REF/UserInterface/Packet.h:408`)。
**修改**
1. 按 §8 的窗口迁移样板逐窗移植 `uimessenger.py` / `uiwhisper.py` / `uiguild.py`
不自行设计交互。
2. 公会相关按 `CLIENT-GAP.md` 的当前范围说明**暂缓**,但常量先落到 `limits.h`。
3. 情侣:已有 `GC_LOVER_INFO` / `GC_LOVE_POINT_UPDATE` 与 `love_ui`
完整交互按 `uiwhisper.py` / `uicharacter.py` 里的情侣分支移植。
---
## 5. P1:任务、NPC 和世界玩法
### 5.1 Quest EventManager 只支持少量标签
**参考**:标签表在 `CPythonEventManager::CPythonEventManager()`
`REF/UserInterface/PythonEventManager.cpp:1148`)里注册,**共 38 个**
```
LETTER COLOR COLOR256 DELAY ENTER WAIT CLEAR QUESTION NEXT DONE
LEFTIMAGE TOPIMAGE BGIMAGE IMAGE INSERT_IMAGE TITLE_IMAGE
ADDMAPSIGNAL CLEARMAPSIGNAL SETMSGPOS ADJMSGPOS SETCMAPPOS
QUESTBUTTON QUESTBUTTON_CLOSE
SLEEP SET_CAMERA BLEND_CAMERA RESTORE_CAMERA
FADE_OUT FADE_IN WHITE_OUT WHITE_IN
CLEAR_TEXT TEXT_HORIZONTAL_ALIGN_CENTER
RUN_CINEMA DUNGEON_RESULT ITEM MOB WINDOW_SIZE
INPUT CONFIRM_WAIT END_CONFIRM_WAIT SELECT_ITEM
```
分派在 `ProcessEventSet()``PythonEventManager.cpp:377`),每个 case 的语义
**逐条照抄**,行号为 `PythonEventManager.cpp`):
| 标签 | 行 | 行为 |
|---|---|---|
| `LETTER` | 399 | 追加 `value` 到当前行;着色 `CurrentColor``iCurrentLetter += len`;超 `iRestrictedCharacterCount` 换行;`lLastDelayTime = lWaitingTime` |
| `DELAY` | 416 | start`lWaitingTime = atoi(value)`end:恢复 `c_lNormal_Waiting_Time` |
| `COLOR` | 425 | start`r/g/b` 取 `atof`a=1end:全 1 |
| `COLOR256` | 444 | 同上但 `/255.0f` |
| `ENTER` | 463 | `__InsertLine` |
| `WAIT` | 469 | `iyLocal = 0; isLock = true` |
| `NEXT` | 477 | `MakeNextButton(BUTTON_TYPE_NEXT)``iAdjustLine += 2` |
| `DONE` | 484 | `MakeNextButton(BUTTON_TYPE_DONE)` + Python `DoneEvent()``iAdjustLine += 2` |
| `CLEAR` / `CLEAR_TEXT` | 492 / 667 | `ClearLine` |
| `QUESTION` | 498 | `MakeQuestion(argList)` |
| `LEFTIMAGE`/`TOPIMAGE`/`BGIMAGE` | 504/510/516 | Python `OnLeftImage/OnTopImage/OnBackgroundImage(src)` |
| `IMAGE` | 522 | Python `OnImage(x, y, src)` |
| `INSERT_IMAGE` | 532 | 有 `image_name` → `OnInsertImage(file, title, title, desc, index, total)`;否则 `OnInsertItemIcon(image_type, idx, title, desc, index, total)``iAdjustLine += 2` |
| `ADDMAPSIGNAL` | 554 | `MiniMap.AddSignalPoint(x,y)` + `OpenAtlasWindow()` |
| `CLEARMAPSIGNAL` | 564 | `MiniMap.ClearAllSignalPoint()` |
| `SETCMAPPOS` | 612 | `MiniMap.SetAtlasCenterPosition(x, y)` |
| `SETMSGPOS`/`ADJMSGPOS` | 604/608 | **空实现** |
| `QUESTBUTTON` | 579 | `icon_name` 为空 → `RecvQuest(idx, name)`;否则 `BINARY_RecvQuest(idx, name, icon_type, icon_name)` |
| `QUESTBUTTON_CLOSE` | 571 | `BINARY_ClearQuest(idx)` |
| `SLEEP` | 617 | `lLastDelayTime = atoi(value)` |
| `SET_CAMERA` | 620 | `rApp.SetEventCamera(设置)` |
| `BLEND_CAMERA` | 627 | `rApp.BlendEventCamera(设置, blendtime)` |
| `RESTORE_CAMERA` | 637 | `rApp.SetDefaultCamera()` |
| `FADE_OUT/FADE_IN/WHITE_OUT/WHITE_IN` | 642/649/655/661 | Python `FadeOut/FadeIn/WhiteOut/WhiteIn(speed)`,并 `isWaitFlag = true` |
| `TEXT_HORIZONTAL_ALIGN_CENTER` | 672 | `isTextCenterMode = true` + 当前行居中 |
| `TITLE_IMAGE` | 679 | Python `OnTitleImage(src)` |
| `DUNGEON_RESULT` | 684 | `ShowDungeonResult(killstone_count, killmob_count, find_hidden, hidden_total, use_potion, is_revived, killallmob, total_time, bonus_exp)` |
| `ITEM` | 709 | 用 `CItemManager` 取物品名追加,颜色固定 `(1.0, 0.2, 0.2)` |
| `MOB` | 728 | 用 `CPythonNonPlayer` 取怪物名追加(无着色) |
| `WINDOW_SIZE` | 748 | Python `OnSize(width, height)` |
| `INPUT` | 755 | `__InsertLine` + Python `OnInput()` |
| `CONFIRM_WAIT` | 761 | `isConfirmWait = TRUE; pConfirmTimeTextLine = 当前行; iConfirmEndTime = now/1000 + timeout;` `__InsertLine(TRUE)` + `MakeNextButton(BUTTON_TYPE_CANCEL)` |
| `END_CONFIRM_WAIT` | 771 | 遍历所有 event set,清 `isConfirmWait` |
| `SELECT_ITEM` | 790 | Python 侧打开选魔石窗(本工程已实现,保持) |
| `RUN_CINEMA` | 169 | **在注册阶段**特判:脚本首条是它时改为 `RegisterEventSet(value)` 加载另一个文件 |
时间推进(`UpdateEventSet``:312`):
`lLastDelayTime` 按帧 elapsed 递减,`<=0` 就 `ProcessEventSet` 一条;
`isWaitFlag` 为真时整体暂停;`isConfirmWait` 时每帧刷新倒计时文本
`m_strLeftTimeString`(默认 `"남은 시간 : %d초"`,可由 `SetLeftTimeString` 改)。
**当前**`POC/project/ui/quest_dialog.gd:102` 只支持 8 个标签。
**修改**
1. `quest_dialog.gd` 的 `parse_script` 换成完整的 `EventTypeMap`38 项)+
上表的 `ProcessEventSet` 分派,逐条实现。
2. 引入 `EventSet` 状态:`lWaitingTime / lLastDelayTime / iCurrentLetter /
iRestrictedCharacterCount / iAdjustLine / iyLocal / isLock / isWaitFlag /
isConfirmWait / iConfirmEndTime / isTextCenterMode / CurrentColor`,字段名保持一致。
3. `SETMSGPOS` / `ADJMSGPOS` 实现为**空**(与参考一致),不要自行发挥。
4. `MakeNextButton` 的三种类型 `NEXT / DONE / CANCEL`、`MakeQuestion` 的选项按钮
按 `PythonEventManager.cpp` 的对应函数移植
`MakeNextButton` `:878`、`MakeQuestion` `:884`、`ClearLine` `:953`、`__InsertLine` `:976`)。
5. 相机类标签(`SET_CAMERA/BLEND_CAMERA/RESTORE_CAMERA`)接 §7.6 的相机模式。
---
### 5.2 任务日志不是完整 Quest 系统
**参考**`REF/UserInterface/PythonQuest.h` + `POC/assets/root/uiquest.py`926 行)。
任务条目由 `EVENT_TYPE_QUEST_BUTTON` 的 `BINARY_RecvQuest(idx, name, icon_type, icon_name)`
(§5.1)与 `GC_QUEST_INFO` 共同驱动;关闭走 `BINARY_ClearQuest(idx)`。
**修改**
1. 按 §8 的窗口迁移样板移植 `uiquest.py`QuestButton / 图标 / 倒计时 / 定位)。
2. `quest_log.gd:8` 的纯文字列表改为读 `icon_type` + `icon_name`
图标资源走 `UiAssets.load_dds_image`。
3. 世界箭头 / 边缘指示对应 `EVENT_TYPE_ADD_MAP_SIGNAL` + `CPythonMiniMap::AddSignalPoint`
接到已有的 minimap 实现。
---
### 5.3 钓鱼、副本、坐骑和观战者只有部分协议入口
**参考要点**
- 钓鱼:`CPythonPlayer::NEW_Fishing()``REF/UserInterface/PythonPlayerInput.cpp:526`
```
if (pkInstMain->GetFishingRot(&irot)) SendFishingPacket(irot);
else PyCall(gameWindow, "OnFishingWrongPlace")
```
攻击键在钓鱼模式下改为钓鱼:`SetAttackKeyState``PythonPlayerInputKeyboard.cpp:6`
`if (isPress && main->IsFishingMode()) { NEW_Fishing(); return; }`。
方向键会 `NEW_CancelFishing()``PythonPlayerInputKeyboard.cpp:24`)。
- 坐骑:`CInstanceBase::MountHorse` → `SetMotionMode(MODE_HORSE)``InstanceBase.cpp:953`);
攻击限制 `CanAttackHorseLevel()``InstanceBaseBattle.cpp:623`=
`IsMountingHorse() && m_kHorse.CanAttack()`
骑马手部模式下禁止徒手攻击:`NEW_Attack()``PythonPlayerInput.cpp:613`
`if (main->IsMountingHorse() && main->IsHandMode()) return;`
动作模式表见 §6.4。
- 副本:`CDungeonBlock``REF/GameLib/DungeonBlock.h`+ `PROPERTY_TYPE_DUNGEON_BLOCK`
(§7.1);结算走 `EVENT_TYPE_DUNGEON_RESULT`(§5.1)。
- 观战:`CPythonPlayer::IsObserverMode()`——它在 `__CanUseSkill()``PythonPlayerSkill.cpp:445`
与 `IsAttackableInstance()``InstanceBase.cpp:2151`)里都是**第一道门**。
**修改**
1. `player_controller.gd` 加 `is_fishing_mode / is_observer_mode / is_hand_mode` 三个状态位,
并把上面四处门禁接上。
2. 钓鱼:攻击键在钓鱼模式改发 `CG_FISHING(irot)`;方向键取消钓鱼;
`OnFishingWrongPlace` 走 `localeInfo` 文案。
3. 坐骑:`GC_MOUNT` / `mount_vnum` 已入 `EntityStore`;补模型挂载与
`MODE_HORSE*` 动作模式(§6.4)、`CanAttackHorseLevel` 限制。
4. 副本:`DungeonBlock` 已接入静态地图(§7.1 保持);结算窗按 `EVENT_TYPE_DUNGEON_RESULT`
的 9 个参数实现。
---
### 5.4 PVP、PK 和决斗没有完整规则
**参考**`CInstanceBase::IsAttackableInstance(victim)`
`REF/UserInterface/InstanceBase.cpp:2147`)是**唯一**判据,完整逻辑:
```
主角且 IsObserverMode() → false
自己打自己 → false
IsStone(): victim.IsPC() → true
IsPC():
victim.IsStone() → true
victim.IsPC():
GetDuelMode():
DUEL_CANNOTATTACK → false
DUEL_START → __FindDUELKey(self, victim) ? true : false
PK_MODE_GUILD == GetPKMode() && 同公会 → false
victim.IsKiller() && !同队 → true
PK_MODE_PROTECT != GetPKMode():
PK_MODE_FREE : victim 非 PROTECT && !同队 → true
PK_MODE_GUILD : victim 非 PROTECT && !同队 && 公会不同 → true
IsSameEmpire(victim):
IsPVPInstance(victim) → true
PK_MODE_REVENGE && !同队 && IsConflictAlignmentInstance → true
else (不同帝国) → true
victim.IsEnemy() → true
victim.IsWoodenDoor() → true
IsEnemy(): victim.IsPC() || victim.IsBuilding() → true
IsPoly(): victim.IsPC() || victim.IsEnemy() → true
否则 false
```
`EPKModes``REF/UserInterface/Packet.h:1273`):
`PK_MODE_PEACE=0, PK_MODE_REVENGE=1, PK_MODE_FREE=2, PK_MODE_PROTECT=3, PK_MODE_GUILD=4,
PK_MODE_MAX_NUM=5`。
**注意**`uigameoption.py` 的 PK 单选按钮映射是
`peace→/pkmode 0`、`revenge→/pkmode 1`、`guild→/pkmode 4`、`free→/pkmode 2`
(本工程增量 38 已实现,与上面的枚举一致,保持)。
名字颜色枚举 `ENameColor``REF/UserInterface/InstanceBase.h:227`):
`NAMECOLOR_MOB / NPC / PC(+EMPIRE_NUM) / NORMAL_* / EMPIRE_* / FUNC / PK / PVP / PARTY /
WARP / WAYPOINT / EXTRA(=FUNC+10) / NUM(=EXTRA+10)`。
**修改**
1. 在 `player_controller.gd`(或 `entity_rules.gd`**逐行**实现
`is_attackable_instance(self_vid, victim_vid)`,不做简化。
2. 依赖判据补齐:`IsKiller()`alignment < 0 或 PK 标记)、`IsSameEmpire()`empire 字段,
§2.5 已补)、`IsPVPInstance()``GC_PVP` 关系表)、`IsConflictAlignmentInstance()`
alignment 符号相反)、`IsSamePartyMember()``get_party()`)、
`GetDuelMode()` + `__FindDUELKey``GC_DUEL_START` 已实现)。
3. 目标框 / 名字颜色 / 可攻击光标按 `ENameColor` 与 `is_attackable_instance` 的结果驱动(§8.8)。
4. 保护区:`IsInSafe()` 已在 §3.4 的预约流程里用到,数据来自地形属性
`ATTRIBUTE_BANPK`**[待读参考:`REF/GameLib/TerrainImpl` 的 `ATTRIBUTE_*` 枚举]**。
---
## 6. P1/P2:模型、动画和特效
### 6.1 本地角色先显示胶囊占位体
**参考**:参考端**没有占位体**。加载期由 Python 的加载幕布盖住:
`PHASE_WINDOW_LOAD` 的 `LoadData(lX, lY)``REF/.../PythonNetworkStreamPhaseLoading.cpp:211`
在 `introloading.py` 里显示加载画面;真正的主角实例由
`GC_CHARACTER_ADD` + `GC_CHAR_ADDITIONAL_INFO` 走 §0.3 的合流创建;
`GameWindow.Open()``game.py:137`)在 `PHASE_GAME` 才 `Show()`。
**当前**`game_scene.gd:136` 先建 Capsule`:811` 再换真模型。
**修改**
1. 删除 `game_scene.gd:136` 与 `:811` 的 Capsule 占位路径。
2. 主角节点只在收到 `Spawn(main_vid)`(合流后)时创建一次,位置/朝向直接取包内值。
3. 在此之前保持 `introloading` 等价的加载幕布(`POC/project/ui` 下已有 `_loading` 层)覆盖,
与 `GameWindow.Open()` 的 `Show()` 时机对齐(§2.6 第 2 步)。
4. 相机目标高度、碰撞体、阴影都挂在真实模型上,不再迁移。
---
### 6.2 SpeedTree 仍是程序化代理
**参考**`REF/SpeedTreeLib/``SpeedTreeWrapper.{h,cpp}` / `SpeedTreeForest*.cpp` /
`SpeedTreeMaterial.h` / `SpeedGrass*`),核心 API
```
LoadTree(pszSptFile, pbBlock, uiBlockSize, nSeed=1, fSize=-1, fSizeVariance=-1) // .spt
RenderBranches() / RenderFronds() / RenderLeaves() / RenderBillboards() // 四类几何
GetLeafMaterial() / GetLeafLightingAdjustment()
SetWindStrength(float)
```
地图侧:`CArea::__SetObjectInstance_SetTree` → `prt::PROPERTY_TYPE_TREE`
属性 `TPropertyTree{strName, strFileName, fSize, fVariance}``REF/GameLib/MapType.h:47`);
风强度来自 `TEnvironmentData.fWindStrength / fWindRandom``MapType.h:154`)。
**当前**`POC/extension/src/tree_placeholder.cpp:190` 只嗅探贴图 + proxy 几何。
**修改**
1. 这是**独立的大工作量项**:需要实现 `.spt` 解析器(SpeedTree 5 二进制),产出
branches / fronds / leaves / billboards 四组几何与各自材质。
2. 在 `.spt` 解析落地之前,`tree_placeholder.cpp` 应至少:
- 读 `TPropertyTree` 的 `fSize` / `fVariance` 并按 `nSeed` 做确定性缩放(当前缺);
- 把风强度接到 `TEnvironmentData.fWindStrength / fWindRandom``.msenv`,§7.5)。
3. 在 `PARITY-GAP.md:77` 标注为「参考模块未移植」,不要按截图反推参数。
---
### 6.3 EffectLib 只有粒子近似
**参考**`CEffectData``REF/EffectLib/EffectData.h:9`)一个 `.mse` 由**三类元素 + 声音**组成:
```
TParticleVector m_ParticleVector; // CParticleSystemData —— 粒子系统
TMeshVector m_MeshVector; // CEffectMeshScript —— .mde 网格特效
TLightVector m_LightVector; // CLightData —— SimpleLight
NSound::TSoundInstanceVector m_SoundInstanceVector;
float m_fBoundingSphereRadius; D3DXVECTOR3 m_v3BoundingSpherePosition;
```
- `CLightData``REF/EffectLib/SimpleLightData.h`):
`m_fMaxRange / m_fDuration / m_cAmbient / m_cDiffuse / m_bLoopFlag /
m_fAttenuation0 / m_fAttenuation1 / m_fAttenuation2``GetRange(fTime, &range)` 是时间曲线。
- `CParticleProperty``REF/EffectLib/ParticleProperty.h:12`):
`ERotationType{NONE, TIME_EVENT, CW, CCW, RANDOM_DIRECTION}`、
`ETextureAnimationType{NONE, CW, CCW, RANDOM_FRAME, RANDOM_DIRECTION}`
多贴图(`InsertTexture`+ `GetTextureAnimationFrameCount()`。
- `CEmitterProperty``REF/EffectLib/EmitterProperty.h:9`):
`EmitterShape{POINT, ELLIPSE, SQUARE, SPHERE}`、
`EmitterAdvancedType{FREE, OUTER, INNER}`、`isEmitFromEdge()`、
`GetMaxEmissionCount / GetCycleLength / isCycleLoop / GetLoopCount`
以及一组时间曲线 `GetEmittingSize / AngularVelocity / DirectionX/Y/Z(fTime, &v)`。
**当前**`POC/project/fx/effect_player.gd:8` 只映射 Particle → `GPUParticles3D`
`:134` 用程序化渐变代替 `.dds``.mde` 用 box 代替。
**修改**
1. `.dds` 贴图改为真加载——本工程已有 `Metin2World.load_dds`,把
`effect_player.gd:134` 的程序化渐变换掉。
2. `.mde` 网格特效:新增解析器输出 `ArrayMesh`,替换 box
**[待读参考:`REF/EffectLib/EffectMesh.{h,cpp}` 的 `CEffectMeshScript::LoadScript`]**。
3. SimpleLight:按 `CLightData` 的 8 个字段生成 `OmniLight3D`
`range` 走时间曲线,`attenuation` 三系数映射到 Godot 的衰减近似并在文档记录差异。
4. 粒子属性补齐:`rotation_type` / `texture_animation_type` / 多贴图帧 /
emitter shape 与 advanced type / `emit_from_edge` / cycle & loop。
5. 骨骼挂点:`.mse` 挂到角色骨骼由 `CActorInstance::AttachEffect`
**[待读参考:`REF/GameLib/ActorInstanceAttach.cpp`]** 驱动,接到本工程的 bone attach。
---
### 6.4 GR2 运行时不是完整 ActorInstance
**参考**
- 动作模式枚举 `CRaceMotionData::EMotionMode``REF/GameLib/RaceMotionData.h:33`):
```
MODE_RESERVED, MODE_GENERAL, MODE_ONEHAND_SWORD, MODE_TWOHAND_SWORD,
MODE_DUALHAND_SWORD, MODE_BOW, MODE_FAN, MODE_BELL, MODE_FISHING,
MODE_HORSE, MODE_HORSE_ONEHAND_SWORD, MODE_HORSE_TWOHAND_SWORD,
MODE_HORSE_DUALHAND_SWORD, MODE_HORSE_BOW, MODE_HORSE_FAN, MODE_HORSE_BELL,
MODE_WEDDING_DRESS, MODE_MAX_NUM
```
- 选择逻辑 `CInstanceBase::RefreshState``REF/UserInterface/InstanceBase.cpp:2851`),
**顺序即优先级**
```
IsPoly() → MODE_GENERAL
IsWearingDress() → MODE_WEDDING_DRESS
IsHoldingPickAxe() → 骑马 ? MODE_HORSE : MODE_GENERAL
ITEM_TYPE_ROD → 骑马 ? MODE_HORSE : MODE_FISHING
骑马: WEAPON_SWORD→HORSE_ONEHAND_SWORD, TWO_HANDED→HORSE_TWOHAND_SWORD,
DAGGER→HORSE_DUALHAND_SWORD, FAN→HORSE_FAN, BELL→HORSE_BELL,
BOW→HORSE_BOW, default→MODE_HORSE
徒步: WEAPON_SWORD→ONEHAND_SWORD, TWO_HANDED→TWOHAND_SWORD,
DAGGER→DUALHAND_SWORD, BOW→MODE_BOW, FAN→MODE_FAN, BELL→MODE_BELL,
WEAPON_ARROW/default→MODE_GENERAL
最后: isLoop ? InterceptLoopMotion(idx) : InterceptOnceMotion(idx); RefreshActorInstance();
```
`SetMotionMode(m)``REF/GameLib/ActorInstanceMotion.cpp:247`):非法值回落 `MODE_GENERAL`。
- 动作查表:`m_pkCurRaceData->GetMotionKey(m_wcurMotionMode, dwMotion, &dwMotionKey)`
`ActorInstanceMotion.cpp:306/345/404`)——**动作号必须带 motion mode 一起查**。
- 技能动作:`CInstanceBase::NEW_UseSkill(uSkill, uMot, uMotLoopCount, isMovingSkill)`
`InstanceBaseBattle.cpp:310`
```
IsDead/IsStun/IsKnockDown → false
isMovingSkill ? (未走则 StartWalking, m_isGoing=TRUE) : (在走则 EndWalking, m_isGoing=FALSE)
SetAdvancingRotation(GetTargetRotation())
InterceptOnceMotion(CRaceMotionData::NAME_SKILL + uMot, 0.1f, uSkill, 1.0f)
__OnUseSkill(uMot, uMotLoopCount, isMovingSkill)
uMotLoopCount > 0 → SetMotionLoopCount(uMotLoopCount)
```
- 动作事件类型 `EMotionEventType``RaceMotionData.h:160`):
`NONE, EFFECT, SCREEN_WAVING, SCREEN_FLASHING, SPECIAL_ATTACKING, SOUND, FLY,
CHARACTER_SHOW, CHARACTER_HIDE, WARP, EFFECT_TO_TARGET`。
- 动作数据接口(`RaceMotionData.h:216`):
`GetMotionDuration / IsAccumulationMotion / GetAccumulationPosition /
IsComboInputTimeData / GetComboInputStartTime / GetNextComboTime / GetComboInputEndTime /
isAttackingMotion / GetMotionAttackDataPointer / HasSplashMotionEvent /
IsCancelEnableSkill / IsLoopMotion / GetLoopStartTime / GetLoopEndTime /
GetMotionEventDataCount / GetEventType / GetEventStartTime / GetSoundInstanceVectorPointer`。
**当前**:无 motion mode 概念;loop / interrupt / accumulation / 动作保留未实现。
**修改**
1. `metin2_model` / `metin2_anim` 引入 `motion_mode``.msm` 解析出
「(motionMode, motionIndex) → 动作文件 + 权重(byPercentage)」的两级表
`CRaceData::TMotionModeDataMap` / `TMotionVectorMap``REF/GameLib/RaceData.h:50`)。
2. 实现 `refresh_state()`:**逐行**照抄上面的优先级链,输入是
`is_poly / is_wearing_dress / is_holding_pickaxe / item_type / weapon_subtype / is_mounting`。
3. 播放接口对齐三种:`InterceptLoopMotion` / `InterceptOnceMotion` / `SetLoopMotion`
带 `fBlendTime`(技能固定 `0.1f`)与 `fSpeedRatio`。
4. `accumulation motion`(位移由动作数据驱动)与 `SetMotionLoopCount` 补上。
5. 击退 / 倒地 / 起身 / 死亡动作:按 `NAME_*` 动作名枚举(`RaceMotionData.h:40-160`)接入。
6. 武器轨迹 `CWeaponTrace``REF/GameLib/WeaponTrace.{h,cpp}`)单列一项。
---
### 6.5 武器和特效骨骼挂点不完整
**参考**`CRaceData::EParts``REF/GameLib/RaceData.h:16`)只有 5 个:
```
PART_MAIN, PART_WEAPON, PART_HEAD, PART_WEAPON_LEFT, PART_HAIR, PART_MAX_NUM
```
挂点名表 `TAttachingBoneNameMap``RaceData.h:35`)与
`TModelData{ NRaceData::TAttachingDataVector AttachingDataVector; }``:63`)来自 `.msm`。
挂载实现在 `REF/GameLib/ActorInstanceAttach.cpp`。
**修改**
1. `PART_WEAPON_LEFT` 是参考里明确存在的部件——补左手武器 / 双持挂点
(本工程当前只有右手 + 部分盾牌)。
2. 挂点骨骼名从 `.msm` 的 `TAttachingBoneNameMap` 读,不再硬编码
(本工程当前硬编码 `equip_right_hand` 等)。
3. 坐骑挂点、骨骼特效(`EFFECT_TO_TARGET`)按 `ActorInstanceAttach.cpp` 移植。
4. 装备切换时清理旧挂点:参考在 `ChangeWeapon` 里 detach 后再 attach
**[待读参考:`InstanceBase.cpp` 的 `ChangeWeapon` / `__AttachWeapon`]**。
---
### 6.6 材质是固定功能近似
**参考**:材质开关来自 `.gr2` 的 `MaterialInfo`(本工程增量 30 已接 `alpha_blend / two_sided`);
多贴图 stage / color operation 由 `CGraphicThing` 的渲染路径决定
**[待读参考:`REF/EterGrnLib/Thing.cpp` 与 `Material.cpp` 的 stage 设置]**。
球面高光来自 `item_proto` 的 `bSpecular`(本工程已接 `specular/100`)。
**修改**
1. `m2_material.h:39` 的 `spec_power` 默认 0 保持——参考端确实由装备数据驱动;
缺的是**远端角色**也要按其 `armor/weapon` 的 proto 取 specular(当前只有本地主角)。
2. alpha-test ref 值、贴图 stage 数、color op 三项在 `EterGrnLib` 的渲染端读出后再定,
目前不要猜数值。
3. 透明排序:参考按 `CArea` 的 `m_ThingCloneInstaceVector` 与角色分批渲染;
Godot 侧对应 `render_priority` + `transparency` 的固定分层,先在文档记录映射再实现。
---
## 7. P1/P2:地图、环境和相机
### 7.1 静态地图只完整接入 Building / DungeonBlock
**参考**`CArea``REF/GameLib/Area.{h,cpp}`)的对象只有 **5 种**
`prt::PROPERTY_TYPE_*``Area.cpp:398-446`):
| 类型 | 属性结构(`REF/GameLib/MapType.h` | 运行时 | 参考行 |
|---|---|---|---|
| `TREE` | `TPropertyTree{strName, strFileName, fSize, fVariance}` (:47) | `CSpeedTreeWrapper` | `Area.cpp:590` |
| `BUILDING` | `TPropertyBuilding{strName, strFileName, strAttributeDataFileName, isShadowFlag}` (:56) | `CGraphicThingInstance` + 属性/碰撞/高度 | `Area.cpp:615` |
| `EFFECT` | `TPropertyEffect{strName, strFileName}` (:64) | `CEffectManager` 实例 | `Area.cpp:531` |
| `AMBIENCE` | `TPropertyAmbience{strName, strPlayType, fPlayInterval, fPlayIntervalVariation, fMaxVolumeAreaPercentage, AmbienceSoundVector}` (:85) | `TAmbienceInstance` | `Area.cpp:661` |
| `DUNGEON_BLOCK` | `TPropertyDungeonBlock{strName, strFileName, strAttributeDataFileName}` (:96) | `CDungeonBlock` | `Area.cpp:695` |
对象静态数据 `CArea::TObjectData``Area.h:27`):
`Position(D3DXVECTOR3) / dwCRC / abyPortalID[PORTAL_ID_MAX_NUM] /
m_fYaw / m_fPitch / m_fRoll / m_fHeightBias / dwRange / fMaxVolumeAreaPercentage`。
旋转步进常量(`Area.h:13`):`ROTATION_STEP_COUNT = 24`、`ROTATION_STEP_AMOUNT = 15`
`YAW/PITCH` 同。
Ambience 的三种播放型(`Area.cpp:678`):
`"ONCE" → UpdateOnceSound`、`"STEP" → UpdateStepSound`、`"LOOP" → UpdateLoopSound`
位置 `z = Position.z + m_fHeightBias`,作用半径 `dwRange`。
`CArea::Refresh()``Area.cpp:383`)把对象分桶到
`m_TreeCloneInstaceVector / m_ThingCloneInstaceVector / m_AniThingCloneInstanceVector /
m_ShadowThingCloneInstaceVector / m_AmbienceCloneInstanceVector /
m_DungeonBlockCloneInstanceVector``IsMotionThing()` 的 building 会额外进动画桶并
`SetMotion(0)``isShadowFlag` 决定是否进阴影桶。
**当前**`POC/extension/src/metin2_world.cpp:367` 只处理 Building / DungeonBlock。
**修改**
1. 补 `PROPERTY_TYPE_EFFECT`:按 `TPropertyEffect.strFileName` 注册 `.mse` 并在
对象位置创建常驻特效实例(旋转按 `Yaw/Pitch/Roll` 的 `D3DXMatrixRotationYawPitchRoll`)。
2. 补 `PROPERTY_TYPE_AMBIENCE`:三种播放型 + `dwRange` + `fMaxVolumeAreaPercentage`
接 §9.4 的 ambience 声道。
3. Building 补 `isShadowFlag` 与 `IsMotionThing() → SetMotion(0)` 两条分桶规则。
4. `m_fHeightBias` 目前是否参与位置计算需核对;`abyPortalID` / portal / area 关系
**[待读参考:`REF/GameLib/Area.cpp` 中 `abyPortalID` 的消费点]**。
5. 对象 LOD 与透明排序按 `Refresh()` 的分桶来做,不要自定义分层。
---
### 7.2 Pack 资源读取没有统一
**参考**`CEterPackManager``REF/EterPack/`)是唯一入口,
连 `CAccountConnector::__AuthState_RecvAuthSuccess``AccountConnector.cpp:465`)都要
`CEterPackManager::instance().DecryptPackIV(dwPanamaKey)`——**pack 的解密 IV 来自登录密钥**。
**当前**`metin2_world.cpp:380`、`terrain_splat.cpp:133` 等仍直接
`AssetResolver::resolve()`,绕过 `AssetSource`/`PackMount`。
**修改**
1. 全量替换:`grep -rn "AssetResolver::resolve" POC/extension/src` 逐个改为
`AssetSource::open()``asset_io.cpp` 已经把 gr2/dds/map/proto 路由到 `FileAccess`
把 world / terrain / tree / water / static object 补齐。
2. `AssetResolver` 的目录扫描(Phase 2b)改为 `AssetSource` 的统一枚举接口。
3. 新增 CI 门禁:`MT_ASSETS_PACK_ONLY=1` 时禁用散文件回退,跑一遍现有全部 GDScript 测试。
4. `DecryptPackIV(loginKey ^ clientKey[0..3])` 这条参考行为若目标服务端使用,需要在
`ClassicSession` 收到 `GC_AUTH_SUCCESS` 后同样调用——当前未实现,先记录。
---
### 7.3 地形 splat 仍非完整固定功能复现
**参考**`REF/GameLib/MapOutdoor*.cpp``MapOutdoorRenderSTP.cpp` = 单贴图通道、
`MapOutdoorRenderHTP.cpp` = 高贴图通道、`MapOutdoorIndexBuffer.cpp` = patch 索引、
`MapOutdoorQuadtree.cpp` = LOD/剔除、`TerrainPatch.{h,cpp}`、`TerrainQuadtree.{h,cpp}`)。
Section/Cell 常量(`REF/GameLib/MapType.h:216`):
```
c_Section_xAttributeCellSize = 50 c_Section_yAttributeCellSize = 50
c_Section_xCellSize = 100 c_Section_yCellSize = 100
```
**修改**
1. 本条是**渲染端逐项对照**,应在 `PARITY-GAP.md` 建立子清单,每项列
「参考文件:函数 / 当前实现 / 差异」,包括:
alpha 语义、texture wrap/filter、stage 数、patch geometry LOD、skirt、
patch 碰撞、baked shadow mask 叠加。
2. 在读完 `MapOutdoorRenderSTP.cpp` / `MapOutdoorRenderHTP.cpp` 之前不要调数值——
本文档不给具体参数。
3. 先落地的是**常量对齐**:把上面 4 个 Section/Cell 常量核对进
`POC/extension/src/terrain_splat.cpp`。
---
### 7.4 水面是序列纹理加近似深度
**参考**`REF/GameLib/MapOutdoorWater.cpp`。
**修改**
1. 同 §7.3:先读 `MapOutdoorWater.cpp` 建立逐项清单(反射 / 折射 / 岸线泡沫 / 法线 /
blend·filter·wrap / 透明排序 / 水深标定),再改实现。
2. `PARITY-GAP.md` 里「浅水透明度未校准」的标记保留,直到参考端的水深公式读出为止。
---
### 7.5 `.msenv` 只完成基础天空、雾和光照
**参考**`TEnvironmentData``REF/GameLib/MapType.h:126`)的**完整字段表**
```
// 光照
BOOL bDirLightsEnable[2]; // ENV_DIRLIGHT_BACKGROUND, ENV_DIRLIGHT_CHARACTER
D3DLIGHT8 DirLights[2]; // 背景光 / 角色光 分开!
D3DMATERIAL8 Material;
// 雾
BOOL bFogEnable; BOOL bDensityFog;
float m_fFogNearDistance; float m_fFogFarDistance;
D3DXCOLOR FogColor;
// 滤色
BOOL bFilteringEnable; D3DXCOLOR FilteringColor;
BYTE byFilteringAlphaSrc; BYTE byFilteringAlphaDest;
// 风
float fWindStrength; float fWindRandom;
// 天空盒
D3DXVECTOR3 v3SkyBoxScale; BOOL bSkyBoxTextureRenderMode;
BYTE bySkyBoxGradientLevelUpper; BYTE bySkyBoxGradientLevelLower;
std::string strSkyBoxFaceFileName[6]; // front/back/left/right/top/bottom
std::vector<TGradientColor> SkyBoxGradientColorVector;
// 云
D3DXVECTOR2 v2CloudScale; float fCloudHeight;
D3DXVECTOR2 v2CloudTextureScale; D3DXVECTOR2 v2CloudSpeed;
std::string strCloudTextureFileName; TGradientColor CloudGradientColor;
// 镜头光晕
BOOL bLensFlareEnable; D3DXCOLOR LensFlareBrightnessColor; float fLensFlareMaxBrightness;
BOOL bMainFlareEnable; std::string strMainFlareTextureFileName; float fMainFlareSize;
```
**关键差异**:参考端**背景光和角色光是两盏独立方向光**(`ENV_DIRLIGHT_BACKGROUND` /
`ENV_DIRLIGHT_CHARACTER`),本工程当前只有一盏。
**修改**
1. `environment_builder.cpp` 解析并落实上表**全部**字段;`TEnvironmentData` 的
字段名逐个对应,缺哪个补哪个。
2. 两盏方向光分别作用于「地形/建筑」与「角色」层——Godot 侧用两个
`DirectionalLight3D` + `cull_mask` 分层实现。
3. 云层用 `strCloudTextureFileName` + `v2CloudScale/TextureScale/Speed` + `fCloudHeight` +
`CloudGradientColor` 真实渲染,不再省略。
4. lens flare 按 `bLensFlareEnable / LensFlareBrightnessColor / fLensFlareMaxBrightness /
bMainFlareEnable / strMainFlareTextureFileName / fMainFlareSize` 实现。
5. 滤色(`bFilteringEnable` + src/dest alpha blend 因子)是全屏后处理,补上。
6. 昼夜插值:参考按 `TEnvironmentDataMap``MapType.h:187``DWORD → TEnvironmentData*`
在多个环境之间切换;**[待读参考:`REF/GameLib/MapOutdoor.cpp` 中该 map 的键与插值方式]**。
7. 环境循环音属 §7.1 的 `PROPERTY_TYPE_AMBIENCE`,不在 `.msenv` 里。
---
### 7.6 相机不是固定基线机位
**参考**
- 默认机位(`POC/assets/root/game.py:58``GameWindow.Open()` 时
`app.SetCamera(cameraDistance, cameraPitch, cameraRotation, cameraHeight)`):
```python
cameraDistance = 1550.0 cameraPitch = 27.0
cameraRotation = 0.0 cameraHeight = 100.0
```
关窗时 `(cameraDistance, cameraPitch, cameraRotation, cameraHeight) = app.GetCamera()`
存回全局(`game.py:225`)——即**跨进出图保持**。
- 距离范围(`REF/UserInterface/CameraProcedure.cpp:10`):
```cpp
float CCamera::CAMERA_MIN_DISTANCE = 200.0f;
float CCamera::CAMERA_MAX_DISTANCE = 2500.0f;
```
- 相机模式(`REF/UserInterface/PythonApplicationCamera.cpp:23/48/57`):
`CAMERA_MODE_NORMAL`(跟随主角 + `GetTargetHeight()`)、
`CAMERA_MODE_STAND`(固定点)、`CAMERA_MODE_BLEND`(线性插值 zoom/pitch/rotation
`BlendValueByLinear(elapsed, blendTime, from, to)`)。
`SetDefaultCamera()``:295`)回 NORMAL 并恢复 `m_DefaultCameraSetting`。
- 地形碰撞(`CameraProcedure.cpp:13 ProcessTerrainCollision`):
两条射线 `m_kTargetToCameraBottomRay` 与 `m_kCameraBottomToTerrainRay`
命中即 `SetCameraState(CAMERA_STATE_CANTGODOWN)` 并把 eye 抬到
`碰撞点 + 2.0f * m_fTerrainCollisionRadius * m_v3Up`(只在抬高时才 `SetEye`)。
未命中则 `SetCameraState(CAMERA_STATE_NORMAL)`。
- 建筑碰撞(`:125 ProcessBuildingCollision`):
`fMoveAmountSmall = 2.0f` / `fMoveAmountLarge = 4.0f`,用
`CameraCollisionChecker` + `CollisionDynamicSphere` 逐对象判定。
- 键位(`POC/assets/root/game.py:340`):
`Q/E → RotateCamera(NEGATIVE/POSITIVE)`、`R → ZoomCamera(NEGATIVE)`、
`F → ZoomCamera(POSITIVE)`Ctrl+F 是喂马)、`T/G → PitchCamera(NEGATIVE/POSITIVE)`
松键一律 `CAMERA_STOP`;小键盘 4/6/8/2 + PGUP/PGDN 是 Movie 相机。
**当前**`POC/project/game_camera.gd` 用脚本内 FOV/yaw/pitch/距离 + AABB 射线。
**修改**
1. 默认值改为 `distance 1550 / pitch 27 / rotation 0 / height 100`
夹在 `[CAMERA_MIN_DISTANCE 200, CAMERA_MAX_DISTANCE 2500]`。
(§8.4 的「远/近视角」两个 radio 也要落在这个范围内。)
2. 实现三种模式 `NORMAL / STAND / BLEND``BLEND` 用线性插值三参数;
`SetDefaultCamera()` 恢复默认设置——这三者是 §5.1 的
`SET_CAMERA / BLEND_CAMERA / RESTORE_CAMERA` 标签的落点。
3. 地形碰撞按上面两条射线 + `2 * m_fTerrainCollisionRadius` 的抬高规则重写,
替换现有 AABB 射线。
4. 建筑碰撞用 `2.0f / 4.0f` 两个位移量的球体检测。
5. 相机参数跨进出图保持(`game.py:225` 的全局回写语义)。
6. 键位按上表补齐 Q/E/R/F/T/G 与松键停止。
---
## 8. P1/P2UI、输入和本地化
### 8.1 uiscript 可解析不等于控件语义完成
**参考**:控件实现在 `REF/EterPythonLib/``PythonWindow*.cpp`、`PythonGraphic*.cpp`
与 `POC/assets/root/ui.py`(本仓已有)。`ui.py` 是权威的控件语义定义。
**修改**
1. 逐个补齐控件,顺序按使用频度:
`ani_image` / `mark` / `grid_table` / `list` / `scroll` / `slotbar` / `thinboard` /
`expanded_image`(nine-patch) / `titlebar` / `radio_button` / `toggle_button`。
2. 每个控件的属性名、默认值、事件名以 `ui.py` 的类定义为准,不自行命名。
3. `SetTop / OnTop / focus / modal / layer` 的语义取 `ui.py` 的
`Window.SetTop / wndMgr` 相关调用。
4. nine-patch`ui.py` 的 `ExpandedImageBox` + `.sub` 资源的 rect 定义。
---
### 8.2 大量窗口是手工 Panel
**参考**`Interface.MakeInterface()``POC/assets/root/interfacemodule.py`)逐个
`LoadDialog()`,每个窗口用 `ui.PythonScriptLoader().LoadScriptFile(self, "UIScript/xxx.py")`。
**修改**
1. 沿用本工程已验证的迁移样板(`char_status_ui.gd` / `select_item_ui.gd` /
`system_option_ui.gd` / `private_shop_ui.gd` / `party_ui.gd` / `system_menu_ui.gd` /
`game_option_ui.gd`):`UiScript` + `UiBuild` 装真 uiscript + 逐字绑定同名 `ui*.py`。
2. 待迁清单(按 `interfacemodule.py` 的 `MakeInterface` 顺序):
`exchangedialog.py`(§4.5)、`safeboxwindow.py`(§4.4)、`shopdialog.py`(§4.6)、
`guilddialog.py`、`questdialog.py`(§5.2)、`messengerwindow.py`、`chatwindow.py`、
`helpwindow.py`(§8.3)、`inventorywindow.py`、`equipmentdialog.py`、
`tooltip`(§4.3,非 uiscript)。
3. 每迁一个窗口配一个 `*_ui_test.gd`,覆盖「装载 + 关键绑定 + 原聊天命令/封包」。
4. 分辨率缩放与 `.sub` 资源、字体描边按 `ui.py` 的实现,不自定义。
---
### 8.3 帮助窗口未实现
**参考**`POC/assets/root/uihelp.py`136 行)+ uiscript `helpwindow.py`。
入口:`game.py:459 __PressHKey()` —— 非 Ctrl 时 `self.interface.OpenHelpWindow()`
Ctrl+H 是 `/user_horse_ride`)。
**当前**`POC/project/ui/system_menu_ui.gd:91` 只显示占位提示。
**修改**
1. 新建 `POC/project/ui/help_ui.gd`,按样板装 `helpwindow.py` uiscript
内容绑定逐字对齐 `uihelp.py`。
2. 挂 `H` 键(非 Ctrl)与 ESC 菜单的 `help_button`,替换 `system_menu_ui.gd:91` 的占位。
3. 配 `help_ui_test.gd`。
---
### 8.4 系统设置部分只是保存数值
**参考**`POC/assets/root/uisystemoption.py`(本工程增量 36 已按其绑定)。
其中 CPU/GPU tiling 对应 D3D 的分块渲染开关,Godot 无对应物。
**修改**
1. **CPU/GPU tiling**:参考行为是切换渲染路径,Godot 渲染器没有等价物。
保留控件但明确标注为「参考功能无 Godot 对应」,并在 `CLIENT-GAP.md` 记为
❌(超出移植范围),不要伪造行为。
2. **BGM 更换按钮**:参考对应 `uiselectmusic.py`(本仓已有)——
按 §8.2 迁移该窗口后启用按钮,接 `musicInfo.SaveLastPlayFieldMusic` /
`LoadLastPlayFieldMusic``POC/assets/root/musicinfo.py`)与
`game.py:2217 __PlayMusic(flag, filename)`。
3. **名字颜色 / 伤害数字 / 总是显示名字**:接到 §8.8 的 TextTail 渲染,
开关键名沿用现有 `user://system_option.cfg [gameopt]`。
4. **雾等级**:现有 `[0.055/0.018/0.004]` 是本工程标定值,参考端是
`TEnvironmentData` 的 `m_fFogNearDistance/m_fFogFarDistance/bDensityFog`(§7.5)——
改为按环境数据的近远距离设置,浓/中/淡对应三档缩放
**[待读参考:`uisystemoption.py` 的 `__SetFogLevel` 实参]**。
---
### 8.5 鼠标物品态和光标状态缺失
**参考**:光标枚举 `CPythonApplication::ECursorShapes`
`REF/UserInterface/PythonApplication.h:63`):
```
NORMAL, ATTACK, TARGET, TALK, CANT_GO, PICK, DOOR, CHAIR, MAGIC, BUY, SELL,
CAMERA_ROTATE, HSIZE, VSIZE, HVSIZE, COUNT
```
资源(`REF/UserInterface/Cursors/`Python 侧映射见 `mousemodule.py:100`):
```
NORMAL→cursor.sub ATTACK/TARGET→cursor_attack.sub TALK→cursor_talk.sub
CANT_GO→cursor_no.sub PICK→cursor_pick.sub DOOR→cursor_door.sub
CHAIR/MAGIC→cursor_chair.sub BUY→cursor_buy.sub SELL→cursor_sell.sub
CAMERA_ROTATE→cursor_camera_rotate.sub
HSIZE→cursor_hsize.sub VSIZE→cursor_vsize.sub HVSIZE→cursor_hvsize.sub
```
软件光标开关:`systemSetting.IsSoftwareCursor()``mousemodule.py:98`)。
`SetCursorNum` / `SetCursor` 的调用点:商店 BUY/SELL(§4.6)、
NPC 悬停 TALK、可攻击目标 ATTACK 等。
**修改**
1. 新建 `POC/project/ui/cursor_manager.gd``set_cursor(shape)` / `get_cursor()`
枚举与资源映射按上表;资源用 `.sub``UiAssets`)。
2. 鼠标跟随物品图标由 §4.2 的 `mouse_controller` 负责,与光标是两层。
3. 悬停实体时的光标:可攻击 → `ATTACK`NPC → `TALK`;地面物品 → `PICK`
不可达 → `CANT_GO`——判据用 §5.4 的 `is_attackable_instance` 与实体类型。
4. 窗口焦点屏蔽:窗口下的鼠标事件不穿透到世界(`wndMgr` 的语义)。
---
### 8.6 键位配置不完整
**参考补充**`ClientVS22/source/` 不含完整 Python 键位脚本;下表来自本仓迁移资产
`POC/assets/root/game.py:309-408`,只能作为历史脚本行为依据,仍需用当前目标构建和实际
输入验收确认。逐条:
```
数字 1..9 → __PressNumKey(n)
Ctrl+1..9 → chrmgr.SetEmoticon(-1, n-1) + net.SendEmoticon(n-1)
否则 1..4 → pressNumber(n-1) (§4.8
F1..F4 → __PressQuickSlot(4..7)
LALT 按/放 → ShowName() / HideName()
LCONTROL → ShowMouseImage() / HideMouseImage()
SYSRQ → SaveScreen()
SPACE 按/放 → StartAttack() / EndAttack()
方向键/WASD → MoveUp/Down/Left/Right,松键 StopUp/Down/Left/Right
D 键:Ctrl+D → 龙魂 deck 1;否则 MoveRight
Q / E → RotateCamera(NEGATIVE / POSITIVE),松键 CAMERA_STOP
Ctrl+Q → 切换所有 QuestButton 显示
R → ZoomCamera(NEGATIVE),松键 STOP
F → ZoomCamera(POSITIVE)Ctrl+F → "/user_horse_feed"
T → PitchCamera(NEGATIVE),松键 STOP
G → 若 ShowNameFlag 则 ToggleGuildWindow(),否则 PitchCamera(POSITIVE)
Ctrl+G → "/ride";松键 CAMERA_STOP
NUMPAD 9/4/6/8/2, PGUP, PGDN → Movie 相机
GRAVE / Z → PickUpItem()
C → Ctrl+C → 龙魂 deck 0;否则 ToggleCharacterWindow("STATUS")
V → ToggleCharacterWindow("SKILL")
N → ToggleCharacterWindow("QUEST")
B → Ctrl+B → "/user_horse_back";否则 ToggleCharacterWindow("EMOTICON")
H → Ctrl+H → "/user_horse_ride";否则 OpenHelpWindow() (§8.3
J → Ctrl+J → 骑乘中 "/unmount",否则在背包里找 71114/71116/71118/71120 使用
I → ToggleInventoryWindow()
O → ToggleDragonSoulWindowWithNoInfo()
M → interface.PressMKey()
ADD/SUBTRACT→ MiniMapScaleUp / MiniMapScaleDown
L → ToggleChatLogWindow()
COMMA → ShowConsole()
LSHIFT 按/放→ __SetQuickPageMode() / __SetQuickSlotMode() (§4.8
```
**当前**:只有 WASD、点地、相机拖动、滚轮、触屏、F1–F4、数字键 1–9。macOS 上物理 F1F12
是否直接产生 `KEY_F1``KEY_F12` 还受系统“将 F1、F2 等键用作标准功能键”设置或 Fn 键影响,
这是平台验收条件,不应写成 ClientVS22 的额外键位功能。
**修改**
1. `game_scene.gd` 的输入表按上面**逐行**补齐,包括每个 Ctrl 组合分支。
2. `SaveScreen()`(SYSRQ)实现正式截图,替代现有的调试截图。
3. `StartAttack/EndAttack`(空格)接 §3.4 的 `m_isAtkKey` 连续攻击。
4. `ShowName / HideName`LALT)接 §8.8 的 TextTail 显示开关。
5. 键位重绑定 / 冲突检测:参考端**没有**——不要实现,若需要另立需求。
6. 相机灵敏度持久化:参考端也没有独立设置(相机参数持久化见 §7.6 第 5 点)。
---
### 8.7 CJK 输入不等价
**参考**`REF/UserInterface/PythonIME.{h,cpp}`,接口:
```
Create(HWND) / MoveLeft / MoveRight / MoveHome / MoveEnd / SetCursorPosition / Delete
OnTab / OnReturn / OnEscape / OnWM_CHAR(wParam, lParam) / OnUpdate / OnChangeCodePage
OnOpenCandidateList / OnCloseCandidateList / OnOpenReadingWnd / OnCloseReadingWnd
```
即:候选窗(candidate list)、读音窗(reading window)、代码页切换(`OnChangeCodePage`
与 §1.10 的 `LocaleService_GetCodePage()` 联动)都由客户端自绘。
**修改**
**定位**macOS / Godot **可以用原生 IME 取代 Windows IMM32 实现**(不必自绘候选窗),
但**必须保持参考端的输入行为**——不能因为换了实现就默认等价。以下逐项验证后才算完成:
1. **组合文本(composition)**:未确认的组合串要显示在输入位置,且**不计入**已提交文本;
对应 `OnWM_CHAR` / `OnUpdate`。
2. **候选词与候选窗**:候选列表可见、可翻页、可用数字键选中;候选窗位置跟随光标,
不被游戏窗口遮挡;对应 `OnOpenCandidateList` / `OnCloseCandidateList` /
`OnOpenReadingWnd` / `OnCloseReadingWnd`。
3. **焦点切换**:焦点离开输入框时组合串的处置(提交还是丢弃)要与参考一致;
窗口失焦 / 切窗时不残留候选窗。
4. **光标与编辑**`MoveLeft / MoveRight / MoveHome / MoveEnd / SetCursorPosition / Delete`
六个操作在含多字节字符时按**字符**移动,不能按字节。
5. **长度限制**:见 §1.10——上限是**目标字段的字节数**(角色名 24、聊天 128、
公会名 12 …),且不得在多字节字符中间截断;组合中的未确认串也要参与上限计算。
6. **代码页**`OnChangeCodePage` 对应 `LocaleService_GetCodePage()`
locale 切换后输入与显示都要跟着换(§1.10)。
7. **`OnTab / OnReturn / OnEscape`**:聊天框 Tab 切换频道、Return 发送、Escape 关闭——
按 `uichat.py` 的绑定;这三个键在**候选窗打开时**必须优先给 IME,不能被窗口吃掉。
上述任一项不满足,本条不得标 ✅;`CLIENT-GAP.md` 里也不应写成「平台差异,等价即可」。
---
### 8.8 聊天和头顶信息不完整
**参考**
- 聊天:`REF/UserInterface/PythonChat.{h,cpp}` + `POC/assets/root/uichat.py`
`CHAT_MAX_NUM = 128``Packet.h:383`)。
`InsultChecker``REF/UserInterface/InsultChecker.cpp`)做脏词过滤。
- 头顶信息:`REF/UserInterface/PythonTextTail.{h,cpp}`——名字 / 称号 / 公会名 /
伤害数字 / 聊天气泡都在这里。
- 名字颜色:`ENameColor``REF/UserInterface/InstanceBase.h:227`,完整枚举见 §5.4)——
按 `MOB / NPC / PC+empire / NORMAL_* / EMPIRE_* / FUNC / PK / PVP / PARTY / WARP /
WAYPOINT` 分类,`EMPIRE_NUM` 决定每类里帝国的偏移。
- 帝国常量:`EMPIRE_NUM` **[待读参考:`REF/UserInterface/GameType.h` 或 `Packet.h`]**。
**修改**
1. 按 §8.2 迁移 `uichat.py`notice / big notice / 频道过滤 / 历史滚动 / 私聊窗生命周期)。
2. 新建 `text_tail.gd` 移植 `PythonTextTail`:名字 / 称号 / 公会名 / 伤害数字 /
聊天气泡 / 屏幕空间血条,显示开关接 §8.6 的 LALT 与 §8.4 的三个 radio。
3. 名字颜色按 `ENameColor` 的完整枚举实现,输入是 §2.5 补齐的
`ch_type / empire / guild / alignment / pk_mode` 与 §5.4 的可攻击判定。
4. `InsultChecker` 若目标服务端不需要,可标 ❌,但要在 `CLIENT-GAP.md` 明示。
---
### 8.9 任务日志图标仍是占位
见 §5.2,同一条修改。`POC/project/ui/quest_log.gd:8` 的文字列表在
`EVENT_TYPE_QUEST_BUTTON` 补齐 `icon_type` / `icon_name` 后即可接真图标。
---
## 9. P1/P2:音频和应用生命周期
### 9.1 地图 BGM 字段被解析但没有消费
**参考**
- 解析与存储(`REF/UserInterface/PythonNetworkStreamPhaseLoading.cpp`):
```
RecvMainCharacter3_BGM() :244 → __SetFieldMusicFileName(mainChrPacket.szBGMName) // :261
RecvMainCharacter4_BGM_VOL() :271 → __SetFieldMusicFileInfo(szBGMName, fBGMVol) // :288
```
```cpp
// PythonNetworkStreamPhaseLoading.cpp:299
static std::string gs_fieldMusic_fileName;
static float gs_fieldMusic_volume = 1.0f / 5.0f * 0.1f; // = 0.02f 默认值
void __SetFieldMusicFileName(const char* n) { gs_fieldMusic_fileName = n; }
void __SetFieldMusicFileInfo(const char* n, float vol) { gs_fieldMusic_fileName = n; gs_fieldMusic_volume = vol; }
const char* GetFieldMusicFileName() { return gs_fieldMusic_fileName.c_str(); }
float GetFieldMusicVolume() { return gs_fieldMusic_volume; }
```
- 消费(`POC/assets/root/game.py:190``GameWindow.Open()` 内):
```python
snd.SetMusicVolume(systemSetting.GetMusicVolume())
snd.SetSoundVolume(systemSetting.GetSoundVolume())
netFieldMusicFileName = net.GetFieldMusicFileName()
if netFieldMusicFileName: snd.FadeInMusic("BGM/" + netFieldMusicFileName)
elif musicInfo.fieldMusic != "": snd.FadeInMusic("BGM/" + musicInfo.fieldMusic)
```
关窗(`game.py:225`):`if musicInfo.fieldMusic != "": snd.FadeOutMusic("BGM/" + musicInfo.fieldMusic)`。
- 默认曲目(`POC/assets/root/musicinfo.py`):
```python
METIN2THEMA = "M2BG.mp3"
loginMusic = "login_window.mp3"
createMusic = "characterselect.mp3"
selectMusic = "characterselect.mp3"
fieldMusic = METIN2THEMA
SaveLastPlayFieldMusic() / LoadLastPlayFieldMusic() → 文件 "BGM/lastplay.inf"
```
**当前**`POC/extension/src/net/classic/classic_parser.cpp:173` 丢弃 BGM 名与音量。
**修改**
1. `ClassicParser` 保存 `field_music_name` / `field_music_volume`,默认音量
`1.0f/5.0f*0.1f = 0.02f``M2Client` 暴露 `get_field_music_name()` / `get_field_music_volume()`。
2. `game_scene.gd` 进图时按 `game.py:190` 的三行逻辑选 BGM(服务端名优先,
否则 `musicInfo.fieldMusic`),并先 `SetMusicVolume/SetSoundVolume`。
3. `musicinfo.gd` 落 `METIN2THEMA / loginMusic / createMusic / selectMusic / fieldMusic`
五个常量与 `save_last_play_field_music / load_last_play_field_music`
(文件路径 `user://BGM/lastplay.inf`)。
4. 换图(§1.3)走同一条选曲逻辑。
---
### 9.2 后台恢复后可能没有 BGM
**参考**:参考端是 Windows 桌面客户端,没有 Android/iOS 的前后台切换语义——
本条**没有 1:1 参考**。最接近的参考行为是
`CSoundManager::SaveVolume() / RestoreVolume()``REF/MilesLib/SoundManager.h:31`),
它保存/恢复 `m_fBackupMusicVolume` / `m_fBackupSoundVolume`。
**修改**(按参考的 Save/Restore 语义,而不是 stop/replay
1. `POC/project/app_lifecycle.gd:67` 的 `_enter_background()` 把
`_bound_audio.stop_bgm()` 改为 `_bound_audio.save_volume()` + 静音
(等价 `SaveVolume()` 后 `SetMusicVolume(0)`)。
2. `_exit_background()``app_lifecycle.gd:75`)加 `_bound_audio.restore_volume()`。
3. `Audio` 增加 `save_volume() / restore_volume()`,字段名对齐
`m_fBackupMusicVolume / m_fBackupSoundVolume`。
4. 这样 `_bgm_name` 不再被清空,曲目继续播放,恢复后音量还原。
---
### 9.3 生命周期所有权重复
**参考**:唯一协调者是 `CPythonApplication`
`REF/UserInterface/PythonApplication.cpp` + `PythonApplicationEvent.cpp`),
`CPythonNetworkStream` 只是被它驱动的一个子系统。
**修改**
1. 确立 `AppFlow` 为唯一生命周期协调者(等价 `CPythonApplication`):
`AppLifecycle` 由 `AppFlow` 创建并持有,`GameScene` 不再自建。
2. `M2Client` 删除自己的 `NOTIFICATION_*` 处理,只保留被调用的
`suspend() / resume()`。
3. 暂停 / 恢复 / 内存警告 / 关闭四条路径各只有一个入口,写进
`POC/docs/CLIENT-PORT.md` 的生命周期小节。
---
### 9.4 MilesLib 事件模型没有迁移
**参考**`CSoundManager``REF/MilesLib/SoundManager.h`)的完整接口:
```
// 3D 监听者
SetPosition(x,y,z) SetDirection(xDir,yDir,zDir, xUp,yUp,zUp) Update()
// 缩放与音量
float m_fSoundScale = 200.0f; // SoundManager.cpp:20
GetSoundScale / SetSoundScale / SetAmbienceSoundScale
SetSoundVolume / SetSoundVolumeRatio / SetSoundVolumeGrade(int)
SetMusicVolume / SetMusicVolumeRatio / SetMusicVolumeGrade(int)
SaveVolume / RestoreVolume / GetSoundVolume / GetMusicVolume
// 音量换算(SoundManager.cpp:245/250
__ConvertGradeVolumeToApplyVolume(grade) = __ConvertRatioVolumeToApplyVolume(grade / 5.0f)
__ConvertRatioVolumeToApplyVolume(r) = (r < 0.1f) ? r : pow(10.0f, -1.0f + r)
// 播放
PlaySound2D(file)
PlaySound3D(x,y,z,file, playCount=1) StopSound3D(index)
PlayAmbienceSound3D(x,y,z,file, playCount=1)
PlayCharacterSound3D(x,y,z,file, bCheckFrequency=FALSE)
SetSoundVolume3D(index, volume) StopAllSound3D()
// 音乐(MUSIC_INSTANCE_MAX_NUM 个通道)
PlayMusic(file)
FadeInMusic(file, volumeSpeed = 0.016f)
FadeOutMusic(file, volumeSpeed = 0.016f)
FadeLimitOutMusic(file, limitVolume, volumeSpeed = 0.016f)
FadeOutAllMusic() FadeAll()
EMusicState { OFF, PLAY, FADE_IN, FADE_OUT, FADE_LIMIT_OUT }
// 动作声音节点(.msa 的 sound 事件)
UpdateSoundData(curFrame, soundDataVector)
UpdateSoundData(x,y,z, curFrame, soundDataVector)
UpdateSoundInstance(x,y,z, curFrame, soundInstanceVector, bCheckFrequency=FALSE)
UpdateSoundInstance(curFrame, soundInstanceVector)
```
3D 定位(`SoundManager.cpp:349` / `:415`):
```cpp
pInstance->SetPosition((fx - m_fxPosition) / m_fSoundScale,
(fy - m_fyPosition) / m_fSoundScale,
(fz - m_fzPosition) / m_fSoundScale);
```
即**世界坐标差 ÷ 200** 才是音频引擎坐标。
角色声音去重(`PlayCharacterSound3D``SoundManager.cpp:380`,仅当 `bCheckFrequency`):
```
static float s_fLimitDistance = 5000*5000; // 距离平方上限
dx² + dy² > s_fLimitDistance → 不播
同一文件距上次播放 < 0.3f 秒 → 不播
否则记录 m_PlaySoundHistoryMap[file] = now
```
**当前**:只有 BGM crossfade、UI 音效池和通用 3D 音效。
**修改**
1. `POC/project/audio.gd` 按上表补齐 API 与常量:
`SOUND_SCALE = 200.0`、`FADE_SPEED = 0.016`、`CHAR_SOUND_MIN_INTERVAL = 0.3`、
`CHAR_SOUND_LIMIT_DIST_SQ = 5000*5000`。
2. 3D 音效位置一律除以 `SOUND_SCALE`。
3. 音量换算函数按 `__ConvertRatioVolumeToApplyVolume` 的分段公式实现
`r < 0.1` 线性,否则 `pow(10, r - 1)`),`grade` 除以 5 转 ratio
`system_option_ui.gd` 的音量滑条改走这个换算(当前是线性)。
4. 三类声道分开:普通 3D`PlaySound3D`)、环境(`PlayAmbienceSound3D`
有独立的 `m_fAmbienceSoundScale`)、角色(`PlayCharacterSound3D` + 去重)。
5. `.msa` 的 sound 事件走 `UpdateSoundInstance(x,y,z,frame,vector,bCheckFrequency=TRUE)`
(角色动作声音一律开去重)。
6. 音乐通道支持 `FADE_LIMIT_OUT`(降到某音量而不是 0)与 `FadeOutAllMusic`。
7. `.mss` 声音脚本:`CEffectData::LoadSoundScriptData` 与
`CRaceMotionData::GetSoundScriptFileName()``REF/GameLib/RaceMotionData.h:220`
**[待读参考:`REF/MilesLib/SoundData.cpp` 的 `.mss` 解析]**。
---
## 10. 发布和验证问题
> 本节没有 `ClientVS22` 参考(它是 Windows / VS2022 工程),属于本工程的工程化要求。
> 因此这里只写**可执行的动作**,不假装有 1:1 依据。
### 10.1 Mac App 不是自包含资源包
**修改**
1. `build-macos-client.sh` 里把 `assets` / `bgm` 的**符号链接**改为
`ditto` 复制进 `Contents/Resources/`(或打成 `.epk/.eix` 后复制)。
2. `AssetRoot.path()` 在 macOS 上优先解析 `Contents/Resources/assets`
`MT_ASSETS` 仍可覆盖(开发用)。
3. 验收:把 `.app` 拷到另一台机器(或 `mv` 掉工作区)后能启动进图。
### 10.2 主程序和 GDExtension 架构不一致
**修改**
1. `build.sh` / `CMakeLists.txt` 为 macOS 增加 `x86_64` 目标,
用 `lipo -create` 合成 universal `.dylib`。
2. `*.gdextension` 的 `macos.debug/release` 指向该 universal 库。
3. 验收:`lipo -info` 显示 `x86_64 arm64`Godot 主程序也是 universal。
### 10.3 当前是 Ad-hoc 签名
**修改**
1. `codesign --force --options runtime --sign "Developer ID Application: …"`
对 `.app` 内所有 `.dylib` 与主可执行文件逐个签名(先内后外)。
2. `xcrun notarytool submit --wait` 提交公证,`xcrun stapler staple` 装订。
3. 验收:`spctl -a -vvv -t install <app>` 通过。
### 10.4 发布构建仍包含 Demo 默认账号密码
**当前**`POC/project/app_flow.gd:32-33`
```gdscript
var _id := "admin"
var _pw := "123456789"
```
**修改**
1. 两行改为空串。
2. 自动登录只在 `MT_AUTOLOGIN=1` 且 `MT_ACCOUNT` / `MT_PASSWORD` 环境变量存在时生效
`app_flow.gd:37` 的 `auto_login` 保留,凭据不再硬编码)。
3. 加一条 CI 检查:release 预设的 PCK 里 `grep -c "123456789"` 必须为 0。
### 10.5 补丁器 / Logo / Movie / Web / Discord / 反作弊没有发布决策
**参考侧的事实**(用于做决策,不是要求实现):
- `REF/UserInterface/PythonApplicationLogo.cpp`、`MovieMan.cpp`、
`PythonApplicationWebPage.cpp` + `REF/CWebBrowser/`、`REF/Discord/`
`ENABLE_DISCORD_RPC` 在 `REF/UserInterface/Locale_inc.h:6` 是**注释掉的**)、
`CheckLatestFiles.cpp`(补丁器)。
- 反作弊四件套见 §1.6,已在不做清单。
**修改**
1. 在 `CLIENT-GAP.md` 的不做清单里逐项定性:
Logo / Movie / Web / Discord(参考默认关闭)→ ❌;
补丁器(`CheckLatestFiles`)→ 单独立项或 ❌。
2. 决策写进 `CLIENT-ROADMAP.md`,避免每轮审计重复出现。
### 10.6 缺少真实验收门禁
**修改**:建立 `POC/docs/ACCEPTANCE.md`,每项写「前置条件 / 步骤 / 通过判据」:
1. 40250 真服矩阵:登录 → 选人 → DirectEnter → 进图 → 换图(§2.7)→ 登出。
2. 双账号:交易(§4.5)、组队(§4.9)、PVP(§5.4)。
3. 断线重连:拔网线 → 回登录页(§1.2)。
4. 纯 pack 运行:`MT_ASSETS_PACK_ONLY=1`(§7.2)。
5. Intel Mac(§10.2)。
6. 受控机位截图:地图 / 机位 / 角色 / 时间 / 天气 / 分辨率契约
(见 `PARITY-GAP.md §0`,该门禁已存在,保持)。
7. 移动端真机性能。
---
## 附录 A:本次审计对 `CLIENT-GAP.md` 的订正
对照参考源码后,`CLIENT-GAP.md`2026-09-02 审计节)有以下条目需要订正或加限定。
**订正分三档**:❶ 参考端行为与原文不符 · ❷ 原文方向对但表述需加限定 ·
❸ 原文低估了差距(当前实现另有问题)。
| 原条目 | 档 | 订正后的说法 | 依据 |
|---|---|---|---|
| 协议覆盖审计 | ❶+❸ | `Packet.h` 是按 To Server / From Server 分段的全局 enum,不是 `namespace CG/GC`。主文档此前把 m2dev `wire.h` 与默认 Classic 混算,并把历史统计写成当前结论;应按 `MT_PROTOCOL=classic` 与 `MT_PROTOCOL=m2dev` 分开。当前默认 Classic 的参考编号是 `CG_CHARACTER_CREATE=4`、`CG_MYSHOP=55`、`CG_WARP=65`、`CG_FISHING=82`、`CG_DUNGEON=110`,而不是 `0x0201`、`0x0802`、`0x0B01` 等 m2dev 编号。 | `REF/UserInterface/Packet.h:25`、`POC/extension/src/net/classic/wire_classic.h:104`、`POC/project/client_main.gd:11` |
| `CG_TEXT` | ❶ | `CG_TEXT=64 (0x40)`,不是 `0x0011`。它存在于 40250 Server / Classic wire,但不在 ClientVS22 客户端普通 To Server 命名项中,不应计入 ClientVS22 客户端 header 总数。 | `40250/Server/.../packet.h:49`、`REF/UserInterface/Packet.h:88-93` |
| 拍卖行 | ❶ | 默认构建未见完整 UI / handler,但 `__AUCTION__` 条件下仍有 `CG_AUCTION_CMD=205` 和 GC 列表包 `=206`;当前 40250 默认服务配置关闭该宏,因此当前不做。不能写成全源码 / 协议 / 资产零命中。 | `REF/UserInterface/Packet.h:158-160,354-356`、`40250/Server/.../service.h:8` |
| `CG_HACK` | ❶ | 参考编号是 `105 (0x69)`,当前 Classic 也使用该值;不做决定可以保留,但编号必须更正。 | `REF/UserInterface/Packet.h:134-136`、`POC/extension/src/net/classic/wire_classic.h:162` |
| 物品 header 变体 | ❷ | `GC_ITEM_DEL/SET` 的 20/21 映射受 `GAIDEN` 条件编译影响;当前 40250 Server 使用 `DEL=20、SET=21`,文档应明确目标构建变体,不能概括为无条件的 ClientVS22 语义。 | `REF/UserInterface/Packet.h:191-197`、`40250/Server/.../packet.h:150-153` |
| `GC_ITEM_DROP` | ❷ | ClientVS22 保留 `GC_ITEM_DROP=23` 声明但没有注册 / 消费,实际掉落流程使用 `GC_ITEM_GROUND_ADD=26`。应标记为“保留声明 / 未消费”,不能宣称两个包逐字节等价。 | `REF/UserInterface/Packet.h:199-202`、`REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:332` |
| §1.2 | ❷ | 参考端确实**没有**重连遮罩 / 倒计时,`OnRemoteDisconnect()` 只调 `SetLoginPhase()`。但「删掉 `reconnect_ui.gd`」只是**严格 1:1 时的选择,不是必然修复**;原先的断线信号链路缺陷已在本轮第一批修复:已有游戏连接的 Classic 断开现在发 `disconnected``AppFlow` 无条件回 LOGIN;真实 TCP 断线仍待验收。 | `PythonNetworkStreamEvent.cpp:4` |
| §2.7 | ❷ | `RecvWarpPacket()` 确实**无条件**按 `lAddr / wPort` 重连,`same_server()` 不是参考端逻辑。但应表述为「**`RecvWarpPacket()` 内不直接使用 `lX/lY`**」——这两个坐标在重连后的 `GC_MAIN_CHARACTER*` 阶段仍参与角色加载定位,不是「完全没用」。 | `PythonNetworkStreamPhaseGame.cpp:923` |
| §3.10 | ❶+❸ | 本次 `ClientVS22/source/` 不含 Python UI 脚本;本仓迁移资产中的 `uicharacter.py` 仍显示参考脚本通过 `net.SendChatPacket("/skillup " + str(skillIndex))` 发包,因此“应改成非命令路径”不成立,也不能仅据此断定当前应传 skill vnum。当前明确差距是 C++ 技能合法性 / 等级上限逻辑和 UI 加号按钮门控、失败提示、升级后状态刷新不完整。 | `POC/assets/root/uicharacter.py:1022`、`REF/UserInterface/PythonSkill.cpp:1833` |
| §4.8 | ❶+❸ | 参考端可执行的快捷栏类型只有 `INVENTORY / SKILL / EMOTION`,没有「命令类型」——这一半正确。但原文**漏了当前实现的新问题**:`m2_client.cpp:878` 把 `3` 注释/使用为 `command`、`4` 为 `emotion`,而参考的 `TQuickSlot.Type` 直接就是 `ESlotType``SLOT_TYPE_EMOTION == 3``4` 是 `SLOT_TYPE_SHOP`)。**当前类型编号与参考端不兼容**;同时,键位表来源于本仓迁移资产,不能伪称为 ClientVS22/source 中的 Python 文件。 | `REF/UserInterface/PythonPlayer.cpp:813-841`、`REF/UserInterface/GameType.h:140,307`、`POC/assets/root/game.py:309` |
| §8.7 | ❷ | 参考端**确有** `CPythonIME` 的候选窗与读音窗,不是完全依赖系统输入框,所以不能一句「平台差异」了事。正确说法是:**macOS/Godot 可以用原生 IME 取代 Windows IMM32 实现,但必须保持参考端的输入行为**——候选词、组合文本(composition)、焦点切换、候选窗位置、长度限制都要逐项验证。字节编码与长度(§1.10)只是其中一部分。 | `PythonIME.h:6` |
| §8.6 | ❷ | 参考键位表来自本仓迁移资产而非 `ClientVS22/source/` 的 Python 文件;主文档已改为明确这一证据边界。macOS 物理 F1–F12 是否直接产生功能键事件受系统功能键设置 / Fn 键影响,应作为平台验收条件,不应虚构成参考端额外键位。 | `POC/assets/root/game.py:309-408`、Godot 当前 `project/game_scene.gd:558` |
| §1.5 | ✅ | **属实,保留**。参考这份 `ClientVS22``Locale_inc.h` = `LOCALE_SERVICE_SINGAPORE` → `LSS_EUROPE`)发 `HEADER_CG_CLIENT_VERSION2 = 0xf1` + `"1215955205"`;当前实现发 `0xfd` 却用了 0xf1 分支的 timestamp。另外当前 `filename` 固定 `"metin2.bin"`,参考端用**实际可执行文件名**(`GetExcutedFileName` + `NoPath`)。 | `PythonNetworkStreamPhaseGame.cpp:4155`、`Locale.cpp:186` |
**§1.7 / §1.8 的订正属实并保留**,但补一条落地限定:
参考端对未知 header 是 `ClearRecvBuffer()` + `PostQuitMessage(0)`,对阶段内未处理 header 是
`TraceError` + `ClearRecvBuffer()`。移植到 Godot 时**不必字面调用 `PostQuitMessage(0)`**——
应映射为**明确的协议错误 + 断线处理**(走 §1.2 的 `disconnected` → 回登录页),
关键是保住「不做启发式重同步、不静默吞包」这两条语义。
## 附录 B:本文标注 `[待读参考]` 的条目
以下细节本次没有读到参考源码,实现前必须先读,**不得按经验补写**:
| 条目 | 应读文件 |
|---|---|
| §2.5 affect 位号 | `REF/UserInterface/InstanceBase.h` 的 `enum EAffect` |
| §3.1 `MOVABLE_GROUND_DISTANCE` 默认值 | `POC/assets/root/game.py` / `playersettingmodule.py` |
| §3.3 攻速对动作速率的影响、`__GetBowRange` | `REF/UserInterface/InstanceBase.cpp` / `InstanceBaseBattle.cpp` |
| §3.6 `OnGetFlyTargetPosition` | `REF/GameLib/ActorInstanceFly.cpp` |
| §3.8 `TSkillData` 字段与加载 | `REF/UserInterface/PythonSkill.{h,cpp}` |
| §3.10 `CanLevelUpSkill` / `GetSkillLevelLimit` | `REF/UserInterface/PythonSkill.cpp` |
| §4.1 换装遮挡规则 | `REF/UserInterface/InstanceBase.cpp` 的 `ChangeArmor/SetArmor` |
| §4.3 装备对比 | `POC/assets/root/uitooltip.py` |
| §4.4 仓库金钱包/命令 | `REF/UserInterface/PythonNetworkStreamModule.cpp` |
| §4.9 `affects[7]` 槽位语义 | 40250 **服务端** `game/src/party.cpp` |
| §5.4 `ATTRIBUTE_BANPK` 等地形属性 | `REF/GameLib/TerrainImpl``MapType.h` / `TerrainPatch.h` |
| §6.3 `.mde` 解析 | `REF/EffectLib/EffectMesh.{h,cpp}` |
| §6.3 骨骼挂特效 | `REF/GameLib/ActorInstanceAttach.cpp` |
| §6.5 `ChangeWeapon` 的 detach | `REF/UserInterface/InstanceBase.cpp` |
| §6.6 材质 stage / alpha-test ref | `REF/EterGrnLib/Thing.cpp`、`Material.cpp` |
| §7.1 `abyPortalID` 消费点、`m_fHeightBias` | `REF/GameLib/Area.cpp` |
| §7.3 地形渲染逐项 | `REF/GameLib/MapOutdoorRenderSTP.cpp` / `RenderHTP.cpp` / `TerrainPatch.cpp` |
| §7.4 水面逐项 | `REF/GameLib/MapOutdoorWater.cpp` |
| §7.5 昼夜插值 / `TEnvironmentDataMap` 键 | `REF/GameLib/MapOutdoor.cpp` |
| §8.4 雾等级实参 | `POC/assets/root/uisystemoption.py` |
| §8.8 `EMPIRE_NUM` | `REF/UserInterface/GameType.h` / `Packet.h` |
| §9.4 `.mss` 解析 | `REF/MilesLib/SoundData.cpp` |
---
## 附录 C:并行实施计划、依赖门禁与进度看板
本附录取代旧版“按 1–12 步串行实施”的顺序。条目仍按依赖关系分组,但同一波次内的工作流可以
并行;只有公共接口和集成门禁需要排队。具体的文件主维护者、状态定义和同步要求见本文
《并行开发与进度同步协议》以及 `CLIENT-GAP.md` 顶部的同名章节。
### C.1 公共门禁
| 门禁 | 目标 | 通过条件 | 当前状态 |
|---|---|---|---|
| G0 公共接口冻结 | 统一连接、实体、Warp、场景 reset、UI 信号和 fixture | 接口有唯一实现者;影响文件已登记;每个工作流能独立编译 / 测试 | 🟡 增量 49 接口冻结批次已落地(`Entity` 字段、`mut_spawn_full/mut_ownership/mut_map_bgm/drain_dirty`、`bgm_changed`、`bgm_director.gd`、§8.6 键位入口);真实双连接和部分生命周期仍待验收 |
| G1 接口集成 | 汇合并行工作流的共享改动 | C++ 扩展构建、受影响原生测试、Godot 回归通过;无未登记共享文件冲突 | ⬜ 待 Phase 1W1/W2/W3/W44 个并行工作流提交后执行 |
| G2 真实 / 发布验收 | 验证参考链路与可交付 Mac App | 40250 真实连接 / 换图、适用的双账号玩法、资源包、架构、签名和验收记录齐全 | ⬜ 未开始;受限环境 `skip` 不算通过 |
### C.2 可并行工作流
| 工作流 | 对应条目 | 本批次可独立交付 | 依赖 | 集成时必须提供 |
|---|---|---|---|---|
| W1 协议与账号会话 | §1、Classic wire / parser、登录、选人、DirectEnter、断线 | wire 尺寸 / parser、错误处理、loopback、账号阶段测试 | G0 的会话接口;真实服务端不阻塞本地开发 | 包字节证据、阶段转换、unknown / unhandled 处理、真实验收缺口 |
| W2 实体、世界与战斗 | §2–§3、移动、动作、技能、目标、换图 | EntityStore / NetWorld / PlayerController、动作 fixture、战斗与实体测试 | G0 的实体快照和 `world_reset` | 节点生命周期、坐标 / 状态语义、受影响测试和性能风险 |
| W3 玩法 UI、输入与本地化 | §4–§5、§8、背包、任务、社交、IME、键位 | 独立 UI、输入映射、CJK / 键位测试、fixture 驱动回归 | M2Client 公开信号;无真服时可用 fixture | 对应窗口状态、发送包、平台差异和视觉未验收项 |
| W4 渲染、资产与音频 | §6–§7、§9、模型、特效、地图、材质、天气、BGM | 单项渲染 / 资产解析、效果和音频生命周期测试 | G0 的场景 / 地图钩子;可用最小资源 | 参考参数、资产路径、帧 / 内存风险、地图切换清理证据 |
| W5 打包、平台与验收 | §10、Mac App、架构、签名、发布矩阵 | debug / release 打包、资源自包含检查、签名和启动检查 | 可与 W1–W4 并行;G2 消费集成产物 | 包路径、架构、签名、运行日志和失败原因 |
### C.3 当前进度初始化
| 工作流 | 当前状态 | 已有证据 | 下一批次 |
|---|---|---|---|
| W0 公共基础 / 集成 | 🟡 开发中 | DirectEnter、Classic Warp endpoint、`world_reset`、场景重建首版;增量 49 接口冻结批次(`Entity` 字段 + `mut_spawn_full/mut_ownership/mut_map_bgm/drain_dirty` + `bgm_changed` 信号 + `bgm_director.gd` + §8.6 键位入口)已提交,C++ `ctest` 16/16 + 9 项 GDScript 回归通过 | 接收 Phase 1 W1/W2/W3/W4 批次并执行 G1 |
| W1 协议 / 账号会话 | 🟡 开发中(增量 50:§1.5 / §1.10 / §2.2 已落地,待真实验收) | 角色槽地址透传、发送结果、unknown / unhandled 错误链路、断线回 LOGIN;§1.5 locale 分支 + `CGClientVersion2`(0xf1);§1.10 `text_codec.h` + `to_wire` 路由 + `net_text_codec_test`(套件 16→17);§2.2 `GC_CHARACTER_ADD` 隐身丢弃 / PC·NPC 暂存 / 其余按 `npclist.txt` 命名立即 spawn`GC_CHAR_ADDITIONAL_INFO` 无 pending 丢弃、命中则 `mut_spawn_full` 合并 | locale 代码页转码(CP949/CP1252)、真实双连接、进场延迟、GDScript 名字校验统一(Phase 2 / W3);`M2Client` 接线 `set_executable_name` / `set_npclist_path` |
| W2 实体 / 世界 / 战斗 | 🟡 开发中 | 地图实体 / 掉落清理、地图重建首版、实体测试和场景测试;`drain_dirty` / `mut_spawn_full` 骨架可用 | §2.1 两层模型 + 可见性、§2.3 `mut_spawn→mut_spawn_full`、§2.4 点数数组尾部、§2.5 应用顺序 + affect_flags |
| W3 玩法 UI / 输入 | 🟡 已有首版 | 多数窗口、背包 / 任务 / 社交、键位和输入已有回归;§8.6 数字键 / F1–F4 / Ctrl+数字入口已收敛 | §8.3 帮助窗;§8.6 剩余逐行键位、LSHIFT 翻页、表情包 |
| W4 渲染 / 资产 / 音频 | 🟡 已有首版 | GR2 / `.msa` / `.mse`、地图 / 水体 / 天气 / BGM 部分链路已有;`bgm_director.gd` 消费点就位 | §9.1 地图 BGM 曲目解析 + 音量、§9.2 后台恢复曲目、§9.3 生命周期单一属主 |
| W5 打包 / 发布 / 验收 | ⬜ 未开始 | 已有 Godot 导出配置和构建入口 | 自包含资源包、Intel / Apple Silicon、签名和 Mac App 验收 |
### C.4 每批次回写模板
后续开发不得只在提交说明中记录进度;每个批次完成后,将下面的信息同时回写到
`CLIENT-GAP.md` 的变更记录 / 总览表和本文对应工作流 / 条目:
```text
批次:YYYY-MM-DD / W? / §x.x
状态:🟡 开发中 | ✅ 完成 | ⚠️ 阻塞 | 🔎 待真实验收
参考:ClientVS22/source/...:line
改动:文件列表;接口 / 数据结构变化
测试:命令、结果;skip 项及原因
验收:真服 / 双账号 / Mac App 证据,或明确“未验证”
风险与依赖:...
下一批次:...
```
同步责任固定为:工作流提交者提供事实和证据,W0 集成维护者统一合并两份文档;任何状态从
“开发中 / 待验收”改为“完成”前,必须同时满足代码回归和文档回写。
### C.5 批次记录
```text
批次:2026-09-02 / W0 / §2.1 §2.3 §2.5 §8.6 §9.1(接口冻结,Phase 1 前置)
状态:🟡 开发中(骨架落地;语义补全在 Phase 1 各工作流)
参考:POC/assets/root/game.py:309-431(键位);ClientVS22 GC_MAIN_CHARACTER3/4BGM
改动:
extension/src/net/entity_store.h/.cpp —— Entity +empire/affect_flags/owner_vid/state_flags
+mut_spawn_full()/mut_ownership()/mut_map_bgm()/drain_dirty()/take_bgm_dirty()/
bgm_name()/bgm_volume()mut_char_info 落 empirereset_for_map_change 清 m_dirty
extension/src/net/m2_client.cpp —— +bgm_changed 信号;两条 pump 回路消费 drain_dirty/
take_bgm_dirtyentity_dict() 补 4 键
extension/src/net/classic/classic_parser.h/.cpp —— BGM 名/音量路由到 mut_map_bgm
+m_pending_actor 成员
project/bgm_director.gd(新)、project/game_scene.gd —— 装配 BgmDirector;§8.6 键位入口
测试:cmake --build build 通过;ctest 16/16netbridge_test、gamescene_test、p9_test、
netplay_test、system_menu_ui_test、skill_test、combat_fx_test、skill_fx_test、
player_motion_test 全绿;无 skip
验收:未验证(无真实 40250 服务端;BGM / 两包合并需服务端数据)
风险与依赖:mut_spawn_full 目前无调用方(W2 §2.3 接入);m_pending_actor 未使用
W1 §2.2 接入);bgm_director 仅透传曲目名,未解析地图→曲目(W4 §9.1)
下一批次:Phase 1 —— W1(§1.5/§1.10/§2.2)、W2(§2.1/§2.3/§2.4/§2.5)、W3(§8.3)、
W4(§9.1/§9.2/§9.3) 在各自 worktree 并行;Phase 2 由 W0 执行 G1 集成与文档合并
```
```text
批次:2026-09-02 / W1 / §1.5 §1.10 §2.2
状态:🔎 待真实验收(代码 + 本地回归齐全;无 40250 真实服务端)
参考:REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:4155SendClientVersionPacket);
REF/UserInterface/PythonNetworkStreamPhaseGameActor.cpp:140(两包合并 / IsInvisibleRace);
REF/UserInterface/StdAfx.h:43、Packet.h:380(定长文本常量)
改动:
extension/src/net/text_codec.h(新)—— to_wire(string_view,cap) / from_wire_str(char*,cap)
UTF-8 边界安全字节截断;godot::String 重载用 __has_include 守卫
extension/src/net/classic/npc_names.h(新)—— load_npclist() 解析 root/npclist.txt(vnum→code)
default_npclist_path() 走 $MT_ASSETS/$M2_ASSETSmtnet-safestd::ifstream
extension/src/net/classic/wire_classic.h —— +HDR_CG_CLIENT_VERSION2=0xF1、
struct CGClientVersion2(static_assert 67)、尺寸表分支;#include text_codec.h
CHRTYPE_* enum + is_invisible_race()W0 冻结项,已在增量 49 落地则保留)
extension/src/net/classic/classic_session.{h,cpp} —— set_executable_name/set_locale_is_europe
setter + m_executable_name/m_locale_is_europe 成员;send_client_version() locale 分支
(默认 0xf1 + "1215955205",非 EUROPE 走 0xfd + MT_BUILD_TIMESTAMP);8 处定长文本
填充路由到 to_wire()
extension/src/net/classic/classic_parser.{h,cpp} —— set_npclist_path/pending_actor_count/
npc_name_for 惰性加载;GC_CHARACTER_ADD(隐身丢弃 / PC·NPC 暂存 m_pending_actor /
其余 npclist 命名 + mut_spawn);GC_CHAR_ADDITIONAL_INFO(无 pending → m_last_error +
drop;命中 → 合并 9 字段 + mut_spawn_full + erase);ADDITIONAL_INFO 名字读 from_wire_str
extension/CMakeLists.txt —— string(TIMESTAMP)→MT_BUILD_TIMESTAMP 定义;net.text_codec 测试注册
extension/tests/net_text_codec_test.cpp(新)、net_classic_session_test.cpp(版本断言 +
两包合并断言重写)
接口/数据结构变化:ClassicSession +2 setter +2 成员;ClassicParser +2 public 方法
+1 private 方法 +3 私有成员(m_npclist_path / m_npc_names / m_npc_names_loaded);
wire +CGClientVersion2。未改 entity_store.* / m2_client.* / project/*。
测试:cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug && cmake --build build -j 通过;
cd build && ctest --output-on-failure → 100% tests passed, 17/17
(新增 net.text_codecnet.classic_session 及既有 16 项全绿);
godot --headless --path project --script res://netbridge_test.gd → PASSexit 0);
godot --headless --path project --script res://p10_test.gd → PASSexit 0)。
环境强制项:GDScript 回归前需先跑一次 godot --headless --path project --import
以填充 project/.godot/global_script_class_cache.cfgworktree 内 .godot 为空且 gitignore),
否则 class_name AssetRoot 无法解析;未改 project/*。无 skip。
验收:未验证——无真实 40250 服务端确认 0xf1 版本包被接受、两包合并序列、非 ASCII 名编码。
风险与依赖:
- to_wire() 仅做 UTF-8 边界安全字节截断,未做 locale 代码页(CP949/CP1252)转码;
ASCII 往返正确,非 ASCII 名对单字节编码服务端呈 mojibakefollow-up
- MT_BUILD_TIMESTAMP 的日为零补位(%d),__TIMESTAMP__ 为空格补位——按 §1.5“近似即可”
- 跨文件待接线(W0/M2Client):set_executable_name(OS::get_executable_path().get_file())、
parser().set_npclist_path(AssetRoot::path("root/npclist.txt"));均已留 TODO(W0)
- §2.2 步骤 45entity_store mut_char_info touch / mut_shop_sign)属 W2,本批次未动;
合并逻辑对当前 W2 代码可工作
- parser 中 ADDITIONAL_INFO 以外的名字读取仍走 strnlenGC_CHARACTER_ADD2 处理未动(follow-up
- GDScript 名字长度校验统一(charselect.gd / guild_creation_ui.gd)属 Phase 2 / W3
下一批次:Phase 2 —— W0 执行 G1 集成,接线 set_executable_name / set_npclist_path
合并 §2.2 步骤 4–5,统一 GDScript 名字校验;真实 40250 服务端到位后做 §1.5/§1.10/§2.2 真服验收
```