Files
mtgodot-poc/audit/contracts/network.character-select.md
T
shenandshen c93894313a fix: 装备属性面板避让逻辑 + 多项功能更新
- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧
- inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域
- 包含其他累积的功能开发和测试文件
2026-09-21 16:38:59 -07:00

153 lines
17 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.
# network.character_select.create_delete
## Scope
对比 40250 角色列表、固定槽位、帝国选择、角色选择、创建、删除、强制改名和 DirectEnter 的完整网络调用链、固定字段、阶段副作用、回调顺序和失败清理。
本轮是静态审计、阶段副作用核对和现有测试复核;角色选择主路径已有多轮实现修复,合同仍为 `PARTIAL`
## Reference call chain
### 普通登录到角色列表
1. `CPythonNetworkStream::SetLoginPhase()``UserInterface/PythonNetworkStreamPhaseLogin.cpp` 发送 `CG_LOGIN`/`CG_LOGIN2`,随后 `ClearLoginInfo()` 清除密码。
2. `CPythonNetworkStream::LoginPhase()` 接收 `HEADER_GC_LOGIN_SUCCESS3/4``HEADER_GC_EMPIRE`、创建/删除/改名结果。
3. `__RecvLoginSuccessPacket3/4()` 把数据写入固定的 `m_akSimplePlayerInfo[PLAYER_PER_ACCOUNT4]` 和对应 guild 数组;3 槽报文只覆盖前 3 个槽,未覆盖的第 4 槽仍是已清零的空槽。
4. `RecvPhasePacket()` -> `SetSelectPhase()`;普通路径根据 `IsSelectedEmpire()` 进入选人或选帝国窗口。
5. `SelectPhase()` 分派创建、删除、改名和再次登录成功包;UI 回调读取同一份固定槽位数据。
### 普通选人/创建/删除/改名
- `SendSelectEmpirePacket()` 写入 `CG_EMPIRE`,发送成功后立即 `SetEmpireID(dwEmpireID)`,再 `SendSequence()`
- `SendSelectCharacterPacket()` 写入 `CG_PLAYER_SELECT`,再 `SendSequence()`
- `SendCreateCharacterPacket()` 写入 slot/name/job/shape/CON/INT/STR/DEX 固定字段,再 `SendSequence()`
- `SendDestroyCharacterPacket()``strncpy(..., PRIVATE_CODE_LENGTH-1)` 写入删除码,再 `SendSequence()`
- `SendChangeNamePacket()` 写入 slot/name,再 `SendSequence()`
- `__RecvPlayerCreateSuccessPacket()``< PLAYER_PER_ACCOUNT4` 时更新固定槽位并调用 `OnCreateSuccess()`;越界只记录错误并返回,不产生成功回调。
- `__RecvPlayerCreateFailurePacket()` 同时通知创建窗口和选人窗口。
- `__RecvPlayerDestroySuccessPacket()` 清零整个槽位,并独立清零 guild id/name,然后调用 `OnDeleteSuccess(index)`
- `__RecvPlayerDestroyFailurePacket()` 消费空包并调用 `OnDeleteFailure()`
- `__RecvChangeName()` 只在 PID 命中固定槽位时清除 `bChangeName`、更新名称并调用 `OnChangeName(index,name)`PID 不命中时调用创建失败码 `100`
### DirectEnter
1. Python `net.DirectEnter(slot)` -> `CPythonNetworkStream::ConnectGameServer(slot)`
2. `ConnectGameServer()` 校验 `< PLAYER_PER_ACCOUNT4`,保存所选槽位,设置 `__DirectEnterMode`,按该槽的 `lAddr:wPort` 建立新的 game 连接。
3. 新连接握手和 `SetLoginPhase()` 仍发送带 login key 的 `CG_LOGIN2`,不是普通选人窗口路径;登录信息随后被清除。
4. `SetSelectPhase()` 在 DirectEnter 模式只切换到 Loading UI,不调用普通 `SendSelectCharacterPacket()`DirectEnter 标记在 `SetLoadingPhase()` 中初始化清除。
5. `GC_WARP` 也通过 `__DirectEnterMode_Set(m_dwSelectedCharacterIndex)` 后连接目标 `lAddr:wPort` 复用这条路径。
## Current call chain
- `extension/src/net/classic/classic_session.cpp::on_phase()``select_char()``create_character()``delete_character()``change_name()``send_empire()``connect_direct_enter()`
- `extension/src/net/classic/classic_parser.cpp::parse_login_success()``on_gc()`
- `extension/src/net/classic/wire_classic.h` 的固定包结构和 `is_sequence_cg()`
- `extension/src/net/m2_client.cpp::select_character()``enter_game()`、CRUD 转发、`pump_classic()` 事件分派和 `build_char_list()`
- `project/app_flow.gd::_on_char_list()``_build_char_list()``_enter_character()``_forward_char_evt()`
- `project/ui/char_select_screen.gd::_pad_slots()``_do_start()`、创建/删除/改名回调。
## Branch-by-branch comparison
| 场景 | 40250 参考行为 | 当前实现 | 结论 |
|---|---|---|---|
| 4 槽登录成功 | 写入 4 个固定槽位,空槽仍有 index 2/3 和全字段零值 | `parse_login_success(..., n=4)` 重建 4 个 `CharSlot`;主路径等价 | MAPPED |
| 3 槽登录成功 | 写入固定 4 槽数组的前 3 项,第 4 项保持清零空槽 | `parse_login_success(..., PLAYER_PER_ACCOUNT3)` 仍分配 4 个零初始化槽,`slot_count()`/`build_char_list()` 暴露 4 槽 | MAPPED |
| 空槽建号 | 固定槽位 UI 允许空槽,创建成功写回该槽 | 4 槽数组和 UI 补槽均保留空槽 index,创建回包按 slot 写回 | MAPPED;真实服务端异常仍缺包序证据 |
| 帝国选择 | `CG_EMPIRE` 发送成功后立即本地写入 empire,再发 sequence | `send_empire()` 只发包;当前 `m_empire` 只有收到 `GC_EMPIRE` 后才改变 | GAP:本地状态写入时点不同 |
| 普通选人 | UI 调用 `SendSelectCharacterPacket`,包后有 sequence | `select_char()``CharSelect` 且 slot 在当前 vector 范围内时发送固定包;stream 自动追加 sequence | MAPPED,重复触发/空槽语义缺少测试 |
| 创建请求 | 固定 name/job/shape/四维字段,发 sequence;参考发送函数本身不做 slot 空闲判定;中文名称按 GB2312 字节字段发送 | 当前做 stage、slot、name 和整数范围校验,先将 UTF-8 名称转成 GB2312/GBK,再按 24 字节 wire cap 填包 | MAPPED:中文 40250 字节容量已对齐,前置校验/真实包序仍需继续证明 |
| 创建成功 | `<4` 才更新固定槽并回调;越界只消费包,不产生成功回调 | `account_slot >= m_slots.size()` 只消费包并返回;有效槽才更新并排入 `CreateOk` | MAPPED |
| 创建失败 | 同时通知创建窗口和选人窗口 | 一个 `char_create_failed` 信号由当前选人页转发 | PARTIAL:单窗口适配存在,但缺少双接收者/顺序证明 |
| 删除请求 | 删除码最多复制 7 个字节,固定包后发 sequence | `strncpy(..., PRIVATE_CODE_LENGTH-1)`,零初始化尾部后发送 | MAPPED:删除码边界已对齐 |
| 删除成功 | 清零槽位和 guild id/name 后回调 slot | `CharSlot{}` 清空所有字段后排入 `DeleteOk`;有效 4 槽时等价 | MAPPED,3 槽第 4 槽和异常 slot 未覆盖 |
| 删除失败 | 消费空包并回调 `OnDeleteFailure()` | 解析为 `DeleteFail`,由 UI 清空输入框 | MAPPED,需补固定包/连续包测试 |
| 改名成功 | 按 PID 命中槽位后清除强制改名并回调 index/name | 按 PID 更新槽位并排入 name eventUI 再发整表和 PID/name | PARTIALevent 适配存在,index 语义和顺序未完全保持 |
| 改名 PID 不命中 | 调用创建失败码 100 | 未命中时排入 `CreateFail(100)`,不产生 name event | MAPPED |
| DirectEnter 目标连接 | 设置 DirectEnter,连接角色槽 `lAddr:wPort`,重新走 `CG_LOGIN2`DirectEnter 的 select 阶段不发普通选择包 | 连接槽地址并保留 login key;在 `PHASE_SELECT` 中只切 Loading,不发送 `CG_PLAYER_SELECT` | MAPPED 主分支;真实服务器时序仍未证明 |
| DirectEnter 重复进入 | 参考 API 通过单一网络流状态和 UI phase 控制 | 当前 `enter_game()`/`connect_direct_enter()` 没有 native epoch/重复请求保护 | PARTIAL |
| 旧数据清理 | 登录阶段先清固定角色/guild 数组;离线/Loading 也清 DirectEnter 标记 | 新的 `ClassicSession` 通常从空 parser 开始;复用/失败/断线时没有同等固定槽和事件队列清理合同 | PARTIAL |
## Equivalence matrix
| 项目 | 状态 | 证据 |
|---|---|---|
| Preconditions | PARTIAL | 当前有 stage/slot/name/range 校验,但 40250 的固定 4 槽、空槽、DirectEnter 和重复调用边界未完全一致 |
| Branch structure | PARTIAL | CRUD 主分支存在;3 槽固定数组、未命中改名、DirectEnter select 分支不同 |
| Algorithms/formulas | PARTIAL | 包字段和 sequence 基本一致;classic 固定名称现在按 UTF-8 -> GB2312/GBK 转换后的字节截断,非中文 locale 和完整异常矩阵仍未闭合 |
| State transition order | PARTIAL | 帝国本地写入时点、创建/删除/改名事件和 DirectEnter 选择包顺序不同 |
| Constants/units | PARTIAL | 私码 8 字节、角色名 24 GB2312 字节 cap、3/4 槽暴露规则已定位;其它 locale 和异常边界仍未完全证明 |
| Timing/event sources | PARTIAL | 参考 phase/window callback;当前为 parser queue -> `M2Client` signal -> Godot UI,未证明所有成功/失败顺序等价 |
| Resource/data sources | MAPPED | 角色槽、guild、login key、角色 game 地址均已定位到对应报文/状态来源 |
| Protocol side effects | PARTIAL | 普通 CRUD 包和 sequence 有;帝国本地副作用、越界响应和 DirectEnter 额外 `CG_PLAYER_SELECT` 不同 |
| Interruption/failure/cleanup | PARTIAL | 创建/删除/改名异常、连接中断、重复进入和角色实例管理器生命周期缺少完整自动测试 |
## Existing test evidence
通过的现有回归:
- `extension/tests/net_classic_session_test.cpp` / `build/extension/net_classic_session_test`:4 槽成功列表、强制改名成功、创建失败包兼容、删除码 7 字节边界、普通选人、槽地址连接。
- 同一测试还覆盖 legacy 3 槽保持第 4 个空槽、DirectEnter SelectPhase 不发送普通选择包、未知改名 PID 的 `CreateFail(100)`,以及 Select phase 外角色回包被拒绝。
- `extension/tests/net_loopback_test.cpp` / `build/extension/net_loopback_test`:创建/删除/改名的经典 loopback 主路径。
- `project/char_create_delete_test.gd`:Godot 选人页 UI 创建、删除、改名和帝国按钮流程。
- `project/test_intro_select_parity.gd`:选人页及删除码 UI 的既有测试。
这些测试没有覆盖:3 槽登录后的固定第 4 槽、空槽 slot 3 的创建/删除结果、非中文 locale codepage、未命中 PID 的改名错误码、DirectEnter 在 `PHASE_SELECT` 的包序列、重复进入和中断清理。因此通过结果不能提升合同状态。
## Deep audit round 2026-09-20
本轮重跑角色选择/CRUD/loopback 回归,并把 native 代码中的 DirectEnter 与固定槽位行为重新对照:
- `char_create_delete_test.gd``test_intro_select_parity.gd``net_classic_session_test``net_loopback_test` 均退出码 0;它们证明当前 4 槽主路径、UI 创建/删除/改名、普通选人和 loopback CRUD 没有回归。
- `ClassicParser::parse_login_success()` 仍按报文 `n` 清空并创建 vector;legacy 3 槽报文因此不会保留 reference 固定 4 槽数组中的 slot 3 空项。当前 UI 测试使用的是补齐后的 4 槽 fixture,不能覆盖真实 3 槽包。
- `ClassicSession::on_phase(PHASE_SELECT)``m_direct_enter` 时先切 Loading,但仍调用 `send_select_char(m_direct_enter_slot)`;参考 DirectEnter 的 Select phase 只切 Loading,不发送普通 `CG_PLAYER_SELECT`,所以当前多发包差异仍存在。
- 创建成功即使 `account_slot >= m_slots.size()` 也会排入 `CreateOk`;改名 PID 未命中时当前只排入 name event,不生成参考创建失败码 100。帝国选择也没有参考端“发送成功后立即写本地 empire”的时点。
- 当前角色名、改名和创建字段仍经 UTF-8 `to_wire`,本轮安全编码审计已确认与 Windows locale codepage 不是同一语义;重复 `enter_game`/DirectEnter 失败和中断清理仍没有独立状态 epoch。
结论:4 槽/legacy 3 槽固定槽、DirectEnter 不重复选人、越界创建和未命中改名回调、帝国写入时点已按 40250 对齐;locale 字节、双窗口回调/连续异常包、重复进入/中断清理、角色实例管理器生命周期和真实服务端包序仍与 40250 不一致,合同保持 `PARTIAL`
## Required tests before verification
- 3 槽/4 槽登录成功后都暴露固定 4 个槽位,空槽 index、guild 和角色字段全部清零。
- 在 3 槽协议下创建/删除 slot 3,证明参考行为与当前行为的边界;若协议确实禁止,记录服务器分支证据而不是用 vector 长度推断。
- 创建/改名名称在 0、最大字节、最大字节+1、非 ASCII、locale codepage 不可逆字符下的实际 wire bytes 与参考比较。
- 创建成功越界、创建失败双窗口、删除成功/失败连续包的状态和事件顺序。
- 改名 PID 命中和未命中(错误码 100)以及强制改名状态清理。
- DirectEnter 从初始 game 连接到槽地址的完整阶段和 wire 序列,确认 `PHASE_SELECT` 是否可达以及是否应发送 `CG_PLAYER_SELECT`;增加重复 `enter_game`、连接失败和中断后的清理断言。
### Active UserInterface character-manager review
- `AbstractCharacterManager.h`/`PythonCharacterManager.h` 把 VID→`CInstanceBase` 实例表、主实例、选中实例、fade/dead list、设备对象、point effect、transform update 和销毁顺序作为角色管理器职责;`PythonCharacterManagerModule.cpp` 还注册 RaceData 的 motion mode、weighted motion、normal/combo attack、attach bone、shape 和 motion resource。
- `PythonCharacterModule.cpp` 的创建/注册/删除/淡出/选择、装备/发型/武器、位置/方向/动作、render mode、affect、bound box 和 faint 起身队列构成角色选择后到游戏内的统一实例入口。当前端角色选择和 `NetWorld` entity view 能显示/删除角色,但没有同一 manager 的 dead/fade/device/motion registration 生命周期。
- 本轮完成活跃角色管理源码静态核对;现有 character select 测试和网络包测试不能替代参考端实例表、重复 VID、fade 删除、主实例切换和进入游戏清理语义,合同保持 `PARTIAL`
## Implementation fix round 2026-09-21
按 40250 的固定数组和 phase 分支修复当前 classic 客户端:legacy `GC_LOGIN_SUCCESS3` 现在只填充前 3 项但始终保留 4 个零初始化槽位;创建成功的越界槽不再产生成功事件;未知 PID 的改名回包改为创建失败码 `100``SendSelectEmpirePacket` 对齐为写包成功后立即更新本地 empireDirectEnter 的 `PHASE_SELECT` 只切换 Loading,不再额外发送普通 `CG_PLAYER_SELECT`
新增/更新 native 边界回归覆盖 legacy 三槽固定槽、帝国本地状态时点、未知改名 PID 和 DirectEnter 包序。`cmake --build build -j2``ctest --test-dir build --output-on-failure`23/23)、`char_create_delete_test.gd``test_intro_select_parity.gd``p10_test.gd``app_flow_lifecycle_test.gd``git diff --check` 均通过。
剩余 locale codepage 字节语义、创建/删除异常和双窗口回调、DirectEnter 重复进入/中断清理、角色实例管理器生命周期以及真实服务端包序仍未达到 40250 统一语义,合同保持 `PARTIAL`
## Audit round 2026-09-20 — Select-phase side-effect gate
继续核对 `PythonNetworkStreamPhaseLogin.cpp::LoginPhase`
`PythonNetworkStreamPhaseSelect.cpp::SelectPhase` 与当前 `ClassicSession::on_packet`:40250 的角色创建、删除、强制改名回包只在 SelectPhase 的分支中消费;错误阶段不会进入固定槽位和 UI 回调。当前端此前由 phase-neutral parser 直接接收这些 header,延迟的创建/改名包可能在 Login/Loading 阶段改写角色列表。
本轮修复 `ClassicSession::on_packet` 的阶段门禁:
- `GC_PLAYER_CREATE_SUCCESS``GC_PLAYER_CREATE_FAILURE``GC_PLAYER_DELETE_SUCCESS``GC_PLAYER_DELETE_WRONG_SOCIAL_ID``GC_CHANGE_NAME` 只有 `CharSelect` 或 DirectEnter 尚处协议 SelectPhase 时才进入 parser。
- 错误阶段返回明确错误并断开当前 phase owner,避免角色槽、改名状态和 UI 事件产生错误副作用。
- DirectEnter 的 UI stage 已是 `Loading`,但 `m_direct_enter` 仍作为协议 SelectPhase 标志保留,确保其合法的阶段边界不被误拒绝。
回归:`net_classic_session_test``net_classic_stream_test``net_classic_wire_test``net_loopback_test``char_create_delete_test.gd``test_intro_select_parity.gd``p10_test.gd``git diff --check` 均通过。
这只闭合了角色 CRUD 回包的阶段副作用;Login/Select/Loading/Game 的完整 117-header allowlist、burst tick 消费顺序、locale codepage、双窗口回调和真实服务端包序继续由 `network.packet_dispatch_protocol`/本合同跟踪,状态保持 `PARTIAL`
## Implementation fix round 2026-09-21 — 40250 Chinese name bytes
角色创建和改名入口保持 UTF-8 业务字符串,但实际 `CG_PLAYER_CREATE`/`CG_CHANGE_NAME` 固定字段改由 `mtnet::to_wire()` 写入 GB2312/GBK bytes。`wire_text_fits()` 在编码后按 `CHARACTER_NAME_MAX_LEN=24` 校验:12 个中文字符可发送,13 个中文字符在客户端被拒绝;结构体零初始化保证第 25 个字节为 NUL。`net_text_codec_test` 覆盖独立转换,`net_classic_session_test` 直接检查创建/改名 packet bytes,避免只测试 helper 而漏掉真实发送入口。
## Status
`PARTIAL`