§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
180 KiB
CLIENT-GAP 修改细则(1:1 复刻 40250/ClientVS22)
本文是
CLIENT-GAP.md§「ClientVS22 实现细节审计(2026-09-02)」的逐条修改说明。 编号与该节 1–10 完全对应。原则:每条只写
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 或完整场景生命周期全部完成。
已完成并已编译验证:
GC_LOGIN_SUCCESS3/4的角色槽保存lAddr/wPort;M2Client.enter_game(index)已接入 Classic DirectEnter:断开当前游戏连接,按角色槽 IP/端口重连,并在新连接的 SELECT 阶段自动发送CG_CHARACTER_SELECT。select_char()、登录包、CG_ENTERGAME均透传send_fixed()结果;CG_ENTERGAME成功后 执行即时 flush。m_entergame_sent只在发送成功后置位。- 未知 header 不再扫描 Loading 缓冲寻找伪边界;保留参考端唯一的
header == 0填充跳过,错误会 记录最近两个 header 并断开。 - ClassicParser 对已知但未实现的集成包不再静默成功,而是记录阶段 / header 并触发失败链路。
- 已建立游戏连接后的断线现在发
disconnected;AppFlow无条件回 LOGIN,匹配OnRemoteDisconnect() -> SetLoginPhase()的最小语义。 - 回归覆盖:角色槽地址透传、未知包不重同步、未处理包显式失败、发送包顺序;
net_classic_stream_test、net_classic_session_test、netbridge_test、p10_test已通过。net_classic_session_test内的 localhost 双监听目标测试因当前受限环境禁止 bind 而自动 skip, 未将 skip 计作真实 TCP / 真服验收。 - Classic
GC_WARP透传lX/lY/lAddr/wPort,不再用addr==0判断同服;保留登录票据和已选 角色槽,通过同一个ClassicSession重连目标lAddr:wPort,并在重连前清理EntityStore的 地图实体、掉落物、目标、PVP / 决斗、技能 / affect / 交互状态。 - 新增
M2Client.world_reset;GameScene/NetWorld/GroundItems清理旧表现节点, 重建地图、HUD 小地图、Atlas、环境和玩家坐标;地图切换时保留背包、任务、好友等持久数据。 - 回归覆盖 Classic Warp endpoint / selected slot 保留、EntityStore loading reset、场景重建
相关 GDScript;C++ 扩展和
gamescene_test/p9_test/p2b_test已通过。 - 增量 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)、 F1–F4 →quickbar.activate(4..7)(原为翻页)、Ctrl/Cmd+1..9 →_emote(n)统一入口 (表情包发送与本地表情动作由 W3 在 §8.6 补全,当前调用client.send_emoticon若存在)。- 测试:
cmake --build build通过;ctest16/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、发布验收文档 |
不以“能启动”代替资源、架构、签名和真实功能验收 |
执行波次
- G0 公共基础冻结:W0 维护
enter_game、warp、world_reset、实体快照、UI 信号和 测试 fixture 的契约,并在本文登记影响文件。 - 并行波 A:W1–W5 按上表同时开发;每条工作流先使用 loopback、fixture、最小地图或 debug 构建完成自己的回归,不等待其他工作流的全部功能。
- G1 接口集成:W0 合并共享接口,执行扩展构建、原生网络测试和受影响的 Godot 测试;冲突 只在这一层解决,不在各工作流中反复复制修复。
- 并行波 B:未阻塞的协议细节、窗口行为、渲染资产、音频生命周期和打包检查继续并行;真实 服务端、双账号和 Mac App 验收按矩阵分别记录。
- G2 发布门禁:只有代码回归、真实链路(适用时)、资源包、架构 / 签名和文档状态都齐全,
对应条目才可从“开发中 / 待验收”改为“完成”。受限环境中的
skip只能记为未验证。
每批次必须同步文档
每个开发批次都要在开始、完成、阻塞、重新验证时更新本文和 CLIENT-GAP.md。代码提交而没有
对应文档记录时,不得把条目标成完成。
批次记录至少包含:
W0–W5工作流、§ 条目、状态(⬜ 未开始 / 🟡 开发中 / ✅ 完成 / ⚠️ 阻塞 / 🔎 待真实验收);- 参考文件与行号、修改文件、接口 / 数据结构变化;
- 自动化测试命令和结果,包含被
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) 游戏连接 #1:HandShake → 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) 游戏连接 #2(DirectEnterMode):
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。
修改
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()。
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"。- ✅
M2Client::enter_game(index)已接入:调用classic_sess->connect_direct_enter(index),即断开当前连接、按角色槽地址重连;app_flow.gd、charselect.gd、login.gd已优先使用该入口,旧 fake client 仍可回退到select_character()。 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)。
MT_CLASSIC_ENTER_DELAY环境变量连同m2_client.cpp的兼容性注入仍未删除——参考端CG_ENTERGAME的时机是 Pythongame.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)只有一行:
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 主流程移除,文件仍保留
供旧测试 / 独立增强使用。
修改
- ✅
m2_client.cpppump_classic()已按“游戏连接是否曾进入 Online”区分——认证失败 / 游戏连接尚未建立 →login_failed;已经建立过游戏连接的后续断开 →disconnected(reason)。ClassicSession::was_online_lost()提供该判定。 - ✅
app_flow.gd的_on_disconnected()已改为无条件_goto_login(),并附_set_status(reason)—— 与SetLoginPhase()1:1。 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,以及真实断线验收。
- 走严格 1:1 → 删除
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):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:194netFieldMusicFileName = 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、环境和玩家定位。背包、任务、好友等持久账号数据不在该清理中删除。
修改
- ✅
EntityStore::reset_for_map_change()清空实体、地面物、PVP / 决斗、目标、技能 / affect、 观察者 / 地图 marker、交互状态和旧事件队列;保留背包、任务、好友、公会等持久数据。 - ✅
net_world.gd/ui/ground_items.gd增加清理入口;M2Client.world_reset由GameScene关闭临时窗口、清除表现节点并停止当前 BGM。 - ✅
ClassicSession::connect_warp(host, port)保留 login key 与选人槽,在同一 session 上 重置连接状态并进入 DirectEnter;M2Client对 Classic 的每个 Warp 都按包内lAddr:wPort连接,失败进入既有断线链路。 - ✅
GameScene._reload_map_for_warp()根据 Warp 坐标重选地图,重建Metin2World、HUD 小地图、 Atlas 和环境引用,并重新贴地定位玩家。 - ⬜ 仍需按
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() 回退。
修改
- ✅
classic_session.cpp的选人路径 →return m_stream.send_fixed(&p, sizeof(p)); - ✅
classic_session.cpp的进场路径 →if (!m_stream.send_fixed(&p, sizeof(p))) return false; m_entergame_sent = true; return m_stream.flush_internal(); // = __SendInternalBuffer()m_entergame_sent只在发送成功后置位。 - ✅ 登录 / 认证发送路径也已检查
send_fixed()返回值;其它玩法发送函数原本已透传。 - ✅
M2Client.enter_game()/select_character()透传失败结果。GDScript 当前保留按钮状态, 但失败提示 / 可重试文案仍是后续 UI 细化项。
1.5 客户端版本包是硬编码的
参考:SendClientVersionPacket()(REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:4155)
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 分支)。
修改
- ✅
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);。 - ✅
send_client_version()按 locale 分支:m_locale_is_europe(默认 true)→ 发CGClientVersion2{0xf1, filename, "1215955205"}, headerHDR_CG_CLIENT_VERSION2;- 否则 → 发
CGClientVersion{0xfd, filename, MT_BUILD_TIMESTAMP}。 本工程默认 locale 与参考一致(EUROPE 系),默认走 0xf1。
- ✅
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))。 - ✅
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__的日是空格补位——按本节“近似即可”接受。 - ✅ 发送时机:
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_europesetter、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];ctestnet.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 的「不做清单」已把反作弊标注为 ❌。本条不改代码,只补文档:
修改
- 在
CLIENT-GAP.md的不做清单里把 HackShield / XTrap / NProtect / Passpod 显式列入 (目前只写了CG_HACK与桌面外壳)。 ServerStateChecker(服务器列表状态查询,UDP)不属于反作弊,参考端用于登录页显示 频道负载。若目标服务端开着它,需要单独立项;本文不在此展开。ClassicParser对这些 header 的处理按 §1.8 的规则(记日志 + 清接收缓冲), 不静默吞。
1.7 未知包 Loading 重同步(第一批已修复)
参考:CPythonNetworkStream::CheckPacket()(REF/UserInterface/PythonNetworkStream.cpp:533)
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 的填充跳过仍在尺寸表
查询前执行。
修改
- ✅ 删除
classic_stream.cpp的整段启发式恢复逻辑。 - ✅ 未知 header 的处理已改为移植后的最小安全语义:
并新增
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→ 回登录页)。 要保住的语义只有两条:不做启发式重同步、不静默继续。 - ✅
header == 0的填充跳过循环位于尺寸表查询之前,与参考同序。 - ✅
disconnect()触发 §1.2 的disconnected→ 回登录页;这对应 Godot 移植中PostQuitMessage(0)的安全映射。真实 TCP 断线场景仍待 E2E 验收。
1.8 已知但没有业务处理的包(第一批已修复静默失败)
参考:CPythonNetworkStream::RecvErrorPacket(header)(PythonNetworkStream.cpp:582)
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 等集成包也走同一条显式失败路径。
修改
- ✅
ClassicParser增加成员m_phase_name("Offline"/"HandShake"/"Login"/"Select"/"Loading"/"Game"), 由ClassicSession在阶段切换时写入。 - ✅
default:分支改为: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 - ✅
classic_stream.cpp在 parser 返回 false 时通过断开路径清空m_recv,并触发显式错误 回调;与参考的ClearRecvBuffer()相比,本移植选择“清空 + 断开”以保持错误不可继续。 - 待完成:按阶段分表,把现在“一个大 switch 处理所有 header”拆成 6 个阶段表,与参考的
OffLinePhase/HandShakePhase/LoginPhase/SelectPhase/LoadingPhase/GamePhase一一对应。 这样“Select 阶段收到 GC_MOVE”才会像参考一样报错,而不是被当成合法包消费。 - 待完成:新增
classic_phase_table_test.gd,对每个阶段喂一个不属于该阶段的合法 header, 断言unhandled_headers记录且缓冲被清;当前已有 C++ 回归覆盖未知包、未处理包和不重同步。
1.9 封包覆盖仍停留在结构和尺寸层
参考:REF/EterLib/NetStream.cpp:520-620 的 CMainPacketHeaderMap 是尺寸表;
真正的“完成”判据是每个 header 在对应阶段函数里有 case,且 case 里
① 完整 Recv ② 写业务状态 ③ 通知 Python 窗口 ④ 需要时回包。
修改(流程性,不是单点改代码)
- 从
REF/EterLib/NetStream.cpp:520起的 header 名表导出全量清单,生成POC/docs/PACKET-MATRIX.md,每行五列:header / 尺寸 / 所属阶段 / 参考处理函数 / POC 实现位置。 - 逐行核对上文 ①②③④ 四项,只有四项齐备才标 ✅。
- 动态长度包(
PacketType.isDynamicSizePacket)另标一列,核对TDynamicSizePacketHeader{header, size}的Peek(size)完整性校验(PythonNetworkStream.cpp:540)。 - 该矩阵替代
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。
修改
- ✅ 新建
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:1(follow-up):目前 wire 字节即 UTF-8,未做 locale 代码页(CP949 / CP1252) 转换;ASCII 可往返,非 ASCII 名对单字节编码服务端会呈现 mojibake。头注释已登记此缺口。
- ✅
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。 - ⬜ 不在本批次:
charselect.gd/guild_creation_ui.gd的名字长度校验统一为<= 24字节属 GDScript(W3 / Phase 2);本批次只交付 C++ 侧编码。已在下方登记。 - ✅ 回归:新增
extension/tests/net_text_codec_test.cpp(ctestnet.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(测试注册)。 - 测试:
ctestnet.text_codec通过(新增,套件 16 → 17);net.classic_session通过。 - 验收:未验证(无真实服务端确认非 ASCII 名编码);locale 代码页转换为 follow-up。
1.11 登录错误、超时、会话失效和服务器列表状态没有完整映射
参考
GC_LOGIN_FAILURE→TPacketGCLoginFailure.szStatus(字符串), 直接透给 PythonOnLoginFailure(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)。
修改
ClassicParser保留login_failure()的原始 szStatus 字符串(不要翻译、不要拼接前缀)。M2Client的login_failed信号第二参数新增code: String(= szStatus), 现有第一参数保留为可读描述。login.gd增加OnLoginFailure文案表,逐条抄POC/assets/root/intrologin.py:390起的localeInfo键;未命中的 code 原样显示(与参考一致)。GC_AUTH_SUCCESS.bResult == 0走同一条,code 固定"BESAMEKEY"。- 连接失败:
direct_enter期间 → 触发ClosePhase等价(回登录页,不弹错误框); 否则 →login.gd的OnConnectFailure()。
2. P0/P1:实体、世界和状态一致性
本节全部条目的前提是 §0.2 的两层模型。先做 2.1 的重构,其余条目才有落点。
2.1 未知 VID 的更新包会直接丢失
参考:参考端也丢弃未知 VID 的更新,但丢的是「不在 m_kNetActorDict 里的 VID」,
而不是「没有场景节点的 VID」:
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)
关键点有三:
- 数据表更新与实例存在与否解耦——
UpdateActor末尾无条件写回rkNetActorData的affect / guild / movSpd / atkSpd / armor / weapon / hair / alignment / pkMode。 __FindActor()(NetworkActorManager.cpp:189)在实例缺失且可见时当场创建。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 同时充当数据表和「场景应有节点」的清单——两层被合并了。
修改
EntityStore保持为数据层(=m_kNetActorDict),并明确:条目只由GC_CHARACTER_ADD(含 additional info 合流)创建、由GC_CHARACTER_DEL删除、 由mut_set_main_vid()(§1.3)整表清空。任何其它包都不创建、也不删除条目。- 可见性下沉到
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)。
- 每帧对
- 所有
mut_*保持「未知 VID 直接 return + 记一条 debug 日志」,与参考一致—— 不要引入 pending / 重放机制,参考端没有。 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 丢弃:
// 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 丢失或顺序异常,实体会长期只有数据没有表现层节点。
修改
- ✅
ClassicParser已有成员std::unordered_map<uint32_t, mtnet::Entity> m_pending_actor;(W0 增量 49 冻结,等价s_kNetActorData,此处按多槽 map 而非单槽实现); 新增pending_actor_count()供测试断言。 - ✅
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默认)。
- ✅
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该条目。
- ⬜ W2 依赖(本批次不改
entity_store.*):mut_char_info()的touch()建实体路径、mut_shop_sign()的凭空建 Entity 由 W2 按 §2.2 步骤 4–5 处理;本批次的合并逻辑对当前 W2 代码即可工作(mut_spawn_full已就位)。 - ⬜ 同上,
mut_shop_sign()→ 独立ShopSign表现状态属 W2。 - ✅ 跨文件待接线:
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”。ctestnet.classic_session通过。 - 验收:未验证(两包合并需真实 40250 服务端
GC_CHARACTER_ADD+GC_CHAR_ADDITIONAL_INFO序列)。
2.3 VID 重用时旧状态可能污染新实体
参考:CNetworkActorManager::AppendActor(NetworkActorManager.cpp:409)
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。
修改
mut_spawn_full()(§2.2 新增)实现为:即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_*全部回默认值。net_world.gd收到Spawn且节点已存在 → 销毁旧节点重建(对齐DeleteInstance+CreateInstance);例外:上下马导致的重建保留旧世界坐标 (kCreateData.m_lPosX = oldPos.x; m_lPosY = -oldPos.y;,注意 Y 取反)。mut_spawn()旧签名保留给GC_MAIN_CHARACTER之外的老调用点,但内部改为委托mut_spawn_full(),避免两套覆盖规则。
2.4 点数组可能残留旧值
参考:__RecvPlayerPoints()(REF/.../PythonNetworkStreamPhaseLoading.cpp:325)
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,所以该路径本身是全量覆盖,残留风险只存在于
未来或其他调用者传入较短长度时。
修改
wire_classic.h确认GCPoints.points为int32_t[POINT_MAX_NUM],加static_assert。- 若
mut_set_points()继续允许部分更新,先明确区分“全量快照”和“单点更新”:全量快照必须 传入并校验POINT_MAX_NUM,部分更新不得被误称为参考端的GC_POINTS全量包;必要时在 部分更新前清零尾部,避免未来调用者读到旧值。 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 图标与剩余时间没有统一状态。
修改
Entity结构补齐到与SNetworkActorData一一对应: 新增uint8_t empire; uint64_t affect_flags; uint32_t owner_vid; uint32_t state_flags;(state_flags已有func/position的话合并成一个字段,按bStateFlag原值保存)。mut_char_info()写入e.empire = empire;,删除(void)empire;。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]。net_world.gd的实体刷新按上表的参考调用序执行,不要重排。- 名字颜色 / 称号 / 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 装配完成前可能被消费。
修改
- 不要加事件缓存。改为对齐顺序:
CG_ENTERGAME的发送点从ClassicSession的定时器(§1.1 已删)挪到game_scene.gd完成全部窗口装配后显式调用client.enter_game(), 即 1:1 对齐game.py:207。 game_scene.gd的装配顺序照抄game.py:137 Open(): 相机 → 玩家清理 → 各 UI Open → 声音音量 → BGM → 快捷栏页 →SetFocus/Show/ShowCursor→net.SendEnterGamePacket()→StartGame()。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将 ClassicGC_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、环境和 玩家位置;背包、任务、好友等跨地图数据保留。
剩余修改与验收
- 保持
lX / lY / lAddr / wPort的 wire、信号和重连路径一致;注释统一写“本步不消费”, 不写“坐标无用”。 - 完成真实 TCP E2E:验证第二次连接确实命中 Warp 的
lAddr:wPort、发送 DirectEnter / Select, 收到目标GC_MAIN_CHARACTER*,并确认旧场景节点、旧掉落和旧实体没有残留。 - 完成换图后的地图 BGM、天气、飞行物、EffectLib 实例和其他地图局部资源清理 / 重建;当前 只覆盖基础场景和 UI。
- 回归:
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,只做地形阻挡。
修改
player_controller.gd重构为「Src/Dst +is_going+walk_mode」模型,一一对应上面的NEW_Goto/NEW_MoveToDirection,并保留三个前置门(syncing / moving_skill / lock)。- 新增
MOVABLE_GROUND_DISTANCE(参考默认值由SetMovableGroundDistance从 Python 设置 [待读参考:POC/assets/root/game.py/playersettingmodule.py中的player.SetMovableGroundDistance调用])。 __ReserveClickGround的 0.1 s 延迟、NEW_IsEmptyReservedDelayTime的递减逻辑 (PythonPlayerInput.cpp:626)照搬。- Actor 之间的碰撞:用 Godot 的 Area3D 近似
TestActorCollision;命中 →BlockMovement()(= 停在上一帧位置,不做滑移)。 - 坐骑:
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_WAITfDirLen > 1.0f走过去(Src/Dst + StartWalking, m_kMovAfterFunc = WAIT)FUNC_WAIT否则 SCRIPT_SetPixelPosition(dst)+SetAdvancingRotation+SetRotation(瞬移对齐)FUNC_MOVE无条件 Src/Dst + StartWalking, m_kMovAfterFunc = MOVEFUNC_COMBOfDirLen >= 50.0f先走过去,到达后 RunComboAttack(rot, uArg)FUNC_COMBO否则 瞬移对齐 + RunComboAttack(rot, uArg)FUNC_ATTACKfDirLen >= 50.0f先走过去,到达后普攻 FUNC_ATTACK否则 瞬移对齐 + BlendRotation+RunNormalAttack(rot)FUNC_MOB_SKILLfDirLen >= 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, 0OnMoving(:62)300 ms FUNC_MOVE, 0OnWaiting(:38)100 ms 且位移 ≥ 1.0 FUNC_WAIT, 0OnStop(:88)无 FUNC_WAIT, 0OnAttack(:102)无 FUNC_COMBO, wMotionIndexOnUseSkill(: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 按固定时间/距离发送。
修改
EntityStore新增每实体的std::deque<StateCmd>({chk_time, dst_x, dst_y, rot, func, arg}),mut_move()改为push_tcp_state(),不再直接改坐标。- 新增
EntityStore::process_states(server_frame_ms),1:1 实现上表的 switch (含1.0f/50.0f两个阈值与m_kMovAfterFunc的「到达后动作」)。 EntityStore::update(now)里保留SNetworkActorData::UpdatePosition()的线性插值, 但只用于 §2.1 的可见性判定,不驱动节点位置。net_play.gd的上行改为 6 个事件回调 + 上表节流常量(100 / 300 ms、位移 1.0), 删除现有的「固定时间或距离」逻辑。ELTimer_GetServerMSec()的等价:GC_HANDSHAKE的lDelta已用于时钟对齐 (REF/.../PythonNetworkStreamPhaseHandShake.cpp:124),确认M2Client的server_time_ms()与之一致后统一使用,不要用本地ticks_msec。- 不要引入预测回滚 / 抖动缓冲——参考端没有。
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 一组固定常量。
修改
- 距离常量搬到
net_play.gd:CLICK_DIST_DEFAULT=150.0、CLICK_DIST_NPC=500.0、CLICK_DIST_RESOURCE=100.0,弓箭走__GetBowRange()的等价 [待读参考:InstanceBaseBattle.cpp的__GetBowRange]。 - 攻击间隔改为读
.msa的 combo 输入时间(本工程已有.msa解析,见metin2_anim.cpp);m2_anim需要额外导出combo_input_start / next_combo / combo_input_end / motion_duration。 - 删除
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。
当前:只有「测距后发攻击包」。
修改
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。- 每帧执行
_refresh_mouse_walking_direction(),四个 case 分别照抄PythonPlayerInputMouse.cpp:288-370(含MODE_CLICK_ITEM的< 20.0f拾取阈值、MODE_USE_SKILL的IS_HUGE_RACE距离 −200 修正)。 _reserve_process_click_actor()严格按 (b) 的 10 步实现。_update_auto_attack()按 (a) 实现;m_dwAutoAttackTargetVID只在__OnPressActor(isAuto=true)且IsAttackableInstance时设置 (PythonPlayerInput.cpp:306__OnPressActor)。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 循环,命中/发包用定时器。
修改
metin2_anim导出.msa的TMotionAttackData.HitDataContainer(起止时间)与 combo 输入三时间,.msa已解析结构见POC/extension/src/metin2_anim.cpp。RaceData的 combo 表(ComboIndexVector,按MAKE_COMBO_KEY(motionMode, comboType)索引,REF/GameLib/RaceData.h:9)需要从.msm解析出来;当前.msm解析只用于换肤。player_controller.gd实现 (a) 的状态机(_cur_combo_index / _is_pre_input / _is_next_pre_input), 删除 0/1/2 循环。- 命中判定与发包挂到动作事件:进入
THitData窗口 → 几何判定 →OnHit→CG_ATTACK;FlushVictimList每帧末尾发一次CG_SYNC_POSITION(≤16 条),M2Client::sync_positions()已有上限 16 的实现,直接复用。 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 会被覆盖。
修改
.msa的 motion event 分派(本工程已实现,见game_scene._on_local_motion_event)新增FLY分支:触发OnSetFlyTarget→client.fly_targeting(vid, x, y, z); 飞行物命中/到期 →OnShoot(skill)→client.shoot(skill)。- 删除
game_scene.gd:218的定时器与单槽pending skill;改为每次攻击创建一个 handler 实例(对齐_AutoClear语义:射完自动清理),支持并发多发。 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 两套并存。
修改
- 删除
net_play.gd的_hitstun_until及其全部引用——参考端不存在这个概念。 Entity增加knock_down: bool,由受击动作(.msa的 knockdown 动作播放中)置位, 动作结束清零;dead/stunned保持由服务端包驱动。- 所有「能否动作」的判定统一走三个函数:
_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 等参数被无符号化。
修改
- 在 GDScript 侧新建
player_skill.gd,把 (a)(b)(c)(d) 四段逐行移植;SKILL_MAX_NUM、SKILL_TYPE_GUILD、骑乘术109/ 空瓶27995/ 毒瓶27996均按参考写成命名常量。 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]。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)。
修改
- 在
m2_client.cpp的每个公开send_*入口按上表补int范围校验,越界返回false并记日志——不要先static_cast<uint32_t>。 - 新建
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)。 - 回归:
net_bounds_test.gd对每个 API 各喂一个越界值,断言返回 false 且未产生出站字节。
3.10 技能升级传输已对齐但状态门控不完整
证据边界与订正:本次 ClientVS22/source/ 不含 Python UI 脚本;本仓与该客户端配套的
迁移资产脚本本来就是聊天命令。因此不能据“使用命令”断定实现不符合参考端,也不能据此
断定当前必须改传技能 vnum。uicharacter.py:1022 OnPressedSlotButton()(本仓
POC/assets/root/uicharacter.py):
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:
# 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() 状态门。
修改
skill_ui.gd按上面的三段(RefreshSkillPlusButtonList/__RefreshSkillPlusButton/CanShowPlusButton)逐行实现加号按钮的显示门控,替换现有单行判断。- 三个技能点来源分开取:
SKILL_ACTIVE/SKILL_SUPPORT/SKILL_HORSE(uicharacter.py:1010-1019),不是统一一个POINT_STAT。 skill.CanLevelUpSkill(skillIndex, skillLevel)与skill.GetSkillLevelLimit(skillIndex)需要 skill table 支持,见 §3.8 第 2 点的TSkillData解析 [待读参考:REF/UserInterface/PythonSkill.cpp的CanLevelUpSkill/GetSkillLevelLimit]。M2Client::skill_up(int skill_id)(m2_client.cpp:850)的发包本身保持不变 (/skillup <skillIndex>),只补 §3.9 的入参范围校验。- 升级结果由
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 = 122,6 槽 × 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)。
修改
proto层导出上述四组位标志(当前只用了specular),落到POC/extension/src/proto/item_proto.h的ItemProto。- 装备穿脱校验按参考顺序:
antiflag(性别/职业/帝国)→wearable_flag与目标槽位匹配 →limit(等级/属性) → 发CG_ITEM_USE。校验失败的文案走localeInfo的CANNOT_EQUIP_*键。 - costume(19/20) / ring(21/22) / belt(23) 的槽位在
inventory_ui.gd/equipment_dialog里补齐,并按uiequipmentdialog.py的布局装载。 - 部位隐藏 / 遮挡(穿 costume 时隐藏本体 armor、穿头盔隐藏发型)按
REF/UserInterface/InstanceBase.cpp的__IsShapeAnimalWear/SetHair等实现 [待读参考:InstanceBase.cpp的ChangeArmor/SetArmor/__AttachHorseSaddle]。 - 远端角色换装:
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 ∈ ESlotType(GameType.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。
当前:各窗口各自实现点击/拖放,没有全局持有态。
修改
- 新建
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)。 ESlotType/EWindows/SlotTypeToInvenType三张表移植到POC/project/ui/game_type.gd,值与GameType.h完全一致。- 所有物品窗口(inventory / safebox / mall / shop / exchange / private_shop / quickslot /
dragon_soul)统一改为
select_empty_slot/select_item_slot两个入口, 分支照抄各自的ui*.py。 - 鼠标跟随图标:
CanvasLayer上画AttachedIconHandle等价物,位置跟随鼠标, 数量文字用countNumberLine(mousemodule.py:87)。 - 拖动取消:右键或落到无效目标 →
deattach_object(),不发包(参考无回滚包)。 - 音效按上表四个固定路径。
4.3 物品 tooltip 只覆盖基础信息
参考:POC/assets/root/uitooltip.py(2718 行)是完整实现,本仓已有该脚本。
入口按来源分:SetInventoryItem / SetShopItem / SetExchangeOwnerItem / SetExchangeTargetItem / SetSafeBoxItem / SetMallItem / SetPrivateShopBuilderItem / SetSkill / SetQuickSlotItem。
修改
- 不要重写 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)逐条对齐。
- 数值来源逐字对齐(
proto层补齐 tooltip 需要的全部字段(ItemProto.value[6] / limit[2] / apply[3] / flags / anti_flags / wear_flags / socket / refine等),当前只导出了少量字段。- 装备对比(穿戴中 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)全局槽号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 是列表 + 自动找空位。
修改
safebox_ui.gd重写为 5×9 网格 + 最多 3 页的页签,__LocalPosToGlobalPos照抄。- 存取一律指定目标格:
M2Client::safebox_checkin(inven_window, inven_pos, safe_pos), 删除「找下一个空位」的逻辑。 - 补
CG_SAFEBOX_ITEM_MOVE(77)的发送(仓库内移动)。 - 金钱走
/safebox_money类聊天命令**[待读参考:netSendSafeboxSaveMoneyPacket在PythonNetworkStreamModule.cpp中的实现,确认是包还是聊天命令]**; 关闭固定用client.say(0, "/safebox_close")。 - 接入
mouse_controller(§4.2),两个 slot 事件按上面的分支表实现,含光标态提示文案。 - 密码窗与
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 规则。
修改
exchange_ui.gd重写为uiexchange.py的 1:1:12 格 × 2 + 金钱 + 两个 accept 灯 +AcceptButton,用exchangedialog.pyuiscript 装载真实布局。EntityStore的交易状态补齐TExchangeData全字段(含 socket / attribute 二维数组)。- accept 语义:只跟随服务端。发送
CG_EXCHANGE(ACCEPT)后立刻禁用按钮; 收到GC_EXCHANGE且accept == 0时重新启用——不写本地「修改则取消」逻辑。 OnUpdate的 1000 距离退出、ANTIFLAG_GIVE拒绝、金钱 7 位上限与EXCHANGE_CANT_EDIT_MONEY全部照抄。- 交易中的背包锁定:参考端没有——不要加。
4.6 NPC 商店仍是基础列表
参考(POC/assets/root/uishop.py)
- 槽位:
shop.SHOP_SLOT_COUNT;多货架的全局槽号GetIndexFromSlotPos(slotPos) = self.tabIdx * shop.SHOP_SLOT_COUNT + slotPos(:226, 本工程增量 34 已按此实现,保持)。 - 买卖是光标模式,不是按钮直接买:
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):注意卖价 = 单价 × 数量 ÷ 5(整数除),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.wavIs1GoldItem走另一条公式。 - 关闭:
OnPressEscapeKey(:250)→self.Close()并return True。
当前:基础列表 + 直接买。
修改
shop_ui.gd用shopdialog.pyuiscript 装载真实布局(格子 + 图标 + 买/卖两个 toggle)。- 实现 BUY / SELL 光标模式:
app.SetCursor的等价见 §8.5 的光标管理器。 - 卖出流程按上面的 8 步照抄,含
ANTIFLAG_SELL弹窗、IsValuableItem的确认框、÷5价格公式和Is1GoldItem分支。 - 买入:点格子(光标为 BUY 时)→
net.SendShopBuyPacket(GetIndexFromSlotPos(pos))。 - 货币类型(
ShopTab.coin_type)与库存计数已在增量 34 实现,保持。 - 错误提示一律用服务端
GC_CHAT文案 +localeInfo的SHOP_*键。
4.7 私人商店是兼容实现
参考:POC/assets/root/uiprivateshopbuilder.py(itemStock 模型)+
PRIVATE_SHOP_ITEM_MAX_NUM(= TPacketCGMyShop::bCount 上限)。
本工程增量 35/36 已按该文件重写过一次,交互模型(拿起 / 落位 / 价格弹窗 / 排序 +
display_pos / 撤下 / 开设 / 撤收)与参考一致。
剩余差距与修改
- 拖放改走 §4.2 的全局
mouse_controller,而不是当前的「点选候选面板」。 - 开店前检查:参考在
uiprivateshopbuilder.py里检查item.IsAntiFlag(item.ANTIFLAG_MYSHOP)(ITEM_ANTIFLAG_MYSHOP = 1<<16,REF/GameLib/ItemData.h:204)——当前缺,补上。 - 其他玩家查看:
GC_SHOP的SHOP_SUBHEADER_GC_START_EX已实现;招牌走GC_SHOP_SIGN(已实现),但需按 §8.2 换成真.sub资源渲染。 - 撤下回滚:参考端没有本地回滚——撤下即发包,状态由
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是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):def __PressQuickSlot(self, localSlotIndex): if app.IsRTL(): if 0 <= localSlotIndex < 4: player.RequestUseLocalQuickSlot(3 - localSlotIndex) else: player.RequestUseLocalQuickSlot(11 - localSlotIndex) else: player.RequestUseLocalQuickSlot(localSlotIndex)数字键 1–4 走
pressNumber(默认 =__PressQuickSlot), 按住 LSHIFT 时pressNumber切换成__SelectQuickPage(game.py:506)。 F1–F4 固定映射到__PressQuickSlot(4..7)(game.py:318)。
当前:界面显示 36 格(4 页 × 9 格)+ F1–F4 + 数字键 1–9;参考端是服务器保存 36 格、 客户端显示 32 格(4 页 × 8 格)。
修改
- 数字键改为 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 全给快捷栏」要改掉。 - LSHIFT 按下 →
pressNumber = __SelectQuickPage;松开 →pressNumber = __PressQuickSlot(game.py:370/:408)。 - 先修类型编号:
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 工作修复。 - 快捷栏类型只保留
INVENTORY / SKILL / EMOTION三种,SKILL必须走 §3.8 的ClickSkillSlot完整链,不能直接发包;收到其它ESlotType一律不执行(与参考的 switch 落空行为一致)。 - 边界常量按 §3.9 的
limits.h;MoveQuickSlot改为 swap。 - 物品拖入快捷栏:走 §4.2 的
mouse_controller,AttachObject的SLOT_TYPE_QUICK_SLOT分支会递归解析真实类型(mousemodule.py:218)。 - 冷却 / 死亡 / 变身 / 换武器后的刷新由对应回包驱动,不本地计时。
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)。
剩余差距与修改
affects[7]的槽位语义目前是“暂定映射”。需要从 40250 服务端game/src/party.cpp的CParty::ComputeRolePoint / Update读出每个下标的含义 [待读参考:服务端源码,不在 ClientVS22 内];在拿到之前, UI 上的 chip 名称保留占位并在文档标注,不要臆造名称。- 队伍技能冷却:参考客户端不本地计时,冷却由服务端拒绝 +
GC_CHAT提示体现。 若当前有本地冷却,删除。 - 队长权限动态变化:以
get_party()里本地 VID 那条的leader位为唯一判据(已实现), 收到GC_PARTY_*后立即重算按钮可用性。 - 离线 / 死亡 / 距离状态:来自
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)。
修改
- 按 §8 的窗口迁移样板逐窗移植
uimessenger.py/uiwhisper.py/uiguild.py, 不自行设计交互。 - 公会相关按
CLIENT-GAP.md的当前范围说明暂缓,但常量先落到limits.h。 - 情侣:已有
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=1;end:全 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 个标签。
修改
quest_dialog.gd的parse_script换成完整的EventTypeMap(38 项)+ 上表的ProcessEventSet分派,逐条实现。- 引入
EventSet状态:lWaitingTime / lLastDelayTime / iCurrentLetter / iRestrictedCharacterCount / iAdjustLine / iyLocal / isLock / isWaitFlag / isConfirmWait / iConfirmEndTime / isTextCenterMode / CurrentColor,字段名保持一致。 SETMSGPOS/ADJMSGPOS实现为空(与参考一致),不要自行发挥。MakeNextButton的三种类型NEXT / DONE / CANCEL、MakeQuestion的选项按钮 按PythonEventManager.cpp的对应函数移植 (MakeNextButton:878、MakeQuestion:884、ClearLine:953、__InsertLine:976)。- 相机类标签(
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)。
修改
- 按 §8 的窗口迁移样板移植
uiquest.py(QuestButton / 图标 / 倒计时 / 定位)。 quest_log.gd:8的纯文字列表改为读icon_type+icon_name, 图标资源走UiAssets.load_dds_image。- 世界箭头 / 边缘指示对应
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)里都是第一道门。
修改
player_controller.gd加is_fishing_mode / is_observer_mode / is_hand_mode三个状态位, 并把上面四处门禁接上。- 钓鱼:攻击键在钓鱼模式改发
CG_FISHING(irot);方向键取消钓鱼;OnFishingWrongPlace走localeInfo文案。 - 坐骑:
GC_MOUNT/mount_vnum已入EntityStore;补模型挂载与MODE_HORSE*动作模式(§6.4)、CanAttackHorseLevel限制。 - 副本:
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)。
修改
- 在
player_controller.gd(或entity_rules.gd)逐行实现is_attackable_instance(self_vid, victim_vid),不做简化。 - 依赖判据补齐:
IsKiller()(alignment < 0 或 PK 标记)、IsSameEmpire()(empire 字段, §2.5 已补)、IsPVPInstance()(GC_PVP关系表)、IsConflictAlignmentInstance()(alignment 符号相反)、IsSamePartyMember()(get_party())、GetDuelMode()+__FindDUELKey(GC_DUEL_START已实现)。 - 目标框 / 名字颜色 / 可攻击光标按
ENameColor与is_attackable_instance的结果驱动(§8.8)。 - 保护区:
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 再换真模型。
修改
- 删除
game_scene.gd:136与:811的 Capsule 占位路径。 - 主角节点只在收到
Spawn(main_vid)(合流后)时创建一次,位置/朝向直接取包内值。 - 在此之前保持
introloading等价的加载幕布(POC/project/ui下已有_loading层)覆盖, 与GameWindow.Open()的Show()时机对齐(§2.6 第 2 步)。 - 相机目标高度、碰撞体、阴影都挂在真实模型上,不再迁移。
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 几何。
修改
- 这是独立的大工作量项:需要实现
.spt解析器(SpeedTree 5 二进制),产出 branches / fronds / leaves / billboards 四组几何与各自材质。 - 在
.spt解析落地之前,tree_placeholder.cpp应至少:- 读
TPropertyTree的fSize/fVariance并按nSeed做确定性缩放(当前缺); - 把风强度接到
TEnvironmentData.fWindStrength / fWindRandom(.msenv,§7.5)。
- 读
- 在
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 代替。
修改
.dds贴图改为真加载——本工程已有Metin2World.load_dds,把effect_player.gd:134的程序化渐变换掉。.mde网格特效:新增解析器输出ArrayMesh,替换 box [待读参考:REF/EffectLib/EffectMesh.{h,cpp}的CEffectMeshScript::LoadScript]。- SimpleLight:按
CLightData的 8 个字段生成OmniLight3D,range走时间曲线,attenuation三系数映射到 Godot 的衰减近似并在文档记录差异。 - 粒子属性补齐:
rotation_type/texture_animation_type/ 多贴图帧 / emitter shape 与 advanced type /emit_from_edge/ cycle & loop。 - 骨骼挂点:
.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 / 动作保留未实现。
修改
metin2_model/metin2_anim引入motion_mode,.msm解析出 「(motionMode, motionIndex) → 动作文件 + 权重(byPercentage)」的两级表 (CRaceData::TMotionModeDataMap/TMotionVectorMap,REF/GameLib/RaceData.h:50)。- 实现
refresh_state():逐行照抄上面的优先级链,输入是is_poly / is_wearing_dress / is_holding_pickaxe / item_type / weapon_subtype / is_mounting。 - 播放接口对齐三种:
InterceptLoopMotion/InterceptOnceMotion/SetLoopMotion, 带fBlendTime(技能固定0.1f)与fSpeedRatio。 accumulation motion(位移由动作数据驱动)与SetMotionLoopCount补上。- 击退 / 倒地 / 起身 / 死亡动作:按
NAME_*动作名枚举(RaceMotionData.h:40-160)接入。 - 武器轨迹
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。
修改
PART_WEAPON_LEFT是参考里明确存在的部件——补左手武器 / 双持挂点 (本工程当前只有右手 + 部分盾牌)。- 挂点骨骼名从
.msm的TAttachingBoneNameMap读,不再硬编码 (本工程当前硬编码equip_right_hand等)。 - 坐骑挂点、骨骼特效(
EFFECT_TO_TARGET)按ActorInstanceAttach.cpp移植。 - 装备切换时清理旧挂点:参考在
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)。
修改
m2_material.h:39的spec_power默认 0 保持——参考端确实由装备数据驱动; 缺的是远端角色也要按其armor/weapon的 proto 取 specular(当前只有本地主角)。- alpha-test ref 值、贴图 stage 数、color op 三项在
EterGrnLib的渲染端读出后再定, 目前不要猜数值。 - 透明排序:参考按
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。
修改
- 补
PROPERTY_TYPE_EFFECT:按TPropertyEffect.strFileName注册.mse并在 对象位置创建常驻特效实例(旋转按Yaw/Pitch/Roll的D3DXMatrixRotationYawPitchRoll)。 - 补
PROPERTY_TYPE_AMBIENCE:三种播放型 +dwRange+fMaxVolumeAreaPercentage, 接 §9.4 的 ambience 声道。 - Building 补
isShadowFlag与IsMotionThing() → SetMotion(0)两条分桶规则。 m_fHeightBias目前是否参与位置计算需核对;abyPortalID/ portal / area 关系 [待读参考:REF/GameLib/Area.cpp中abyPortalID的消费点]。- 对象 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。
修改
- 全量替换:
grep -rn "AssetResolver::resolve" POC/extension/src逐个改为AssetSource::open();asset_io.cpp已经把 gr2/dds/map/proto 路由到FileAccess, 把 world / terrain / tree / water / static object 补齐。 AssetResolver的目录扫描(Phase 2b)改为AssetSource的统一枚举接口。- 新增 CI 门禁:
MT_ASSETS_PACK_ONLY=1时禁用散文件回退,跑一遍现有全部 GDScript 测试。 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
修改
- 本条是渲染端逐项对照,应在
PARITY-GAP.md建立子清单,每项列 「参考文件:函数 / 当前实现 / 差异」,包括: alpha 语义、texture wrap/filter、stage 数、patch geometry LOD、skirt、 patch 碰撞、baked shadow mask 叠加。 - 在读完
MapOutdoorRenderSTP.cpp/MapOutdoorRenderHTP.cpp之前不要调数值—— 本文档不给具体参数。 - 先落地的是常量对齐:把上面 4 个 Section/Cell 常量核对进
POC/extension/src/terrain_splat.cpp。
7.4 水面是序列纹理加近似深度
参考:REF/GameLib/MapOutdoorWater.cpp。
修改
- 同 §7.3:先读
MapOutdoorWater.cpp建立逐项清单(反射 / 折射 / 岸线泡沫 / 法线 / blend·filter·wrap / 透明排序 / 水深标定),再改实现。 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),本工程当前只有一盏。
修改
environment_builder.cpp解析并落实上表全部字段;TEnvironmentData的 字段名逐个对应,缺哪个补哪个。- 两盏方向光分别作用于「地形/建筑」与「角色」层——Godot 侧用两个
DirectionalLight3D+cull_mask分层实现。 - 云层用
strCloudTextureFileName+v2CloudScale/TextureScale/Speed+fCloudHeight+CloudGradientColor真实渲染,不再省略。 - lens flare 按
bLensFlareEnable / LensFlareBrightnessColor / fLensFlareMaxBrightness / bMainFlareEnable / strMainFlareTextureFileName / fMainFlareSize实现。 - 滤色(
bFilteringEnable+ src/dest alpha blend 因子)是全屏后处理,补上。 - 昼夜插值:参考按
TEnvironmentDataMap(MapType.h:187,DWORD → TEnvironmentData*) 在多个环境之间切换;[待读参考:REF/GameLib/MapOutdoor.cpp中该 map 的键与插值方式]。 - 环境循环音属 §7.1 的
PROPERTY_TYPE_AMBIENCE,不在.msenv里。
7.6 相机不是固定基线机位
参考
- 默认机位(
POC/assets/root/game.py:58,GameWindow.Open()时app.SetCamera(cameraDistance, cameraPitch, cameraRotation, cameraHeight)):关窗时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):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 射线。
修改
- 默认值改为
distance 1550 / pitch 27 / rotation 0 / height 100, 夹在[CAMERA_MIN_DISTANCE 200, CAMERA_MAX_DISTANCE 2500]。 (§8.4 的「远/近视角」两个 radio 也要落在这个范围内。) - 实现三种模式
NORMAL / STAND / BLEND,BLEND用线性插值三参数;SetDefaultCamera()恢复默认设置——这三者是 §5.1 的SET_CAMERA / BLEND_CAMERA / RESTORE_CAMERA标签的落点。 - 地形碰撞按上面两条射线 +
2 * m_fTerrainCollisionRadius的抬高规则重写, 替换现有 AABB 射线。 - 建筑碰撞用
2.0f / 4.0f两个位移量的球体检测。 - 相机参数跨进出图保持(
game.py:225的全局回写语义)。 - 键位按上表补齐 Q/E/R/F/T/G 与松键停止。
8. P1/P2:UI、输入和本地化
8.1 uiscript 可解析不等于控件语义完成
参考:控件实现在 REF/EterPythonLib/(PythonWindow*.cpp、PythonGraphic*.cpp)
与 POC/assets/root/ui.py(本仓已有)。ui.py 是权威的控件语义定义。
修改
- 逐个补齐控件,顺序按使用频度:
ani_image/mark/grid_table/list/scroll/slotbar/thinboard/expanded_image(nine-patch) /titlebar/radio_button/toggle_button。 - 每个控件的属性名、默认值、事件名以
ui.py的类定义为准,不自行命名。 SetTop / OnTop / focus / modal / layer的语义取ui.py的Window.SetTop / wndMgr相关调用。- 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")。
修改
- 沿用本工程已验证的迁移样板(
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。 - 待迁清单(按
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)。 - 每迁一个窗口配一个
*_ui_test.gd,覆盖「装载 + 关键绑定 + 原聊天命令/封包」。 - 分辨率缩放与
.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 只显示占位提示。
修改
- 新建
POC/project/ui/help_ui.gd,按样板装helpwindow.pyuiscript, 内容绑定逐字对齐uihelp.py。 - 挂
H键(非 Ctrl)与 ESC 菜单的help_button,替换system_menu_ui.gd:91的占位。 - 配
help_ui_test.gd。
8.4 系统设置部分只是保存数值
参考:POC/assets/root/uisystemoption.py(本工程增量 36 已按其绑定)。
其中 CPU/GPU tiling 对应 D3D 的分块渲染开关,Godot 无对应物。
修改
- CPU/GPU tiling:参考行为是切换渲染路径,Godot 渲染器没有等价物。
保留控件但明确标注为「参考功能无 Godot 对应」,并在
CLIENT-GAP.md记为 ❌(超出移植范围),不要伪造行为。 - BGM 更换按钮:参考对应
uiselectmusic.py(本仓已有)—— 按 §8.2 迁移该窗口后启用按钮,接musicInfo.SaveLastPlayFieldMusic/LoadLastPlayFieldMusic(POC/assets/root/musicinfo.py)与game.py:2217 __PlayMusic(flag, filename)。 - 名字颜色 / 伤害数字 / 总是显示名字:接到 §8.8 的 TextTail 渲染,
开关键名沿用现有
user://system_option.cfg [gameopt]。 - 雾等级:现有
[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 等。
修改
- 新建
POC/project/ui/cursor_manager.gd:set_cursor(shape)/get_cursor(), 枚举与资源映射按上表;资源用.sub(UiAssets)。 - 鼠标跟随物品图标由 §4.2 的
mouse_controller负责,与光标是两层。 - 悬停实体时的光标:可攻击 →
ATTACK;NPC →TALK;地面物品 →PICK; 不可达 →CANT_GO——判据用 §5.4 的is_attackable_instance与实体类型。 - 窗口焦点屏蔽:窗口下的鼠标事件不穿透到世界(
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 上物理 F1–F12
是否直接产生 KEY_F1–KEY_F12 还受系统“将 F1、F2 等键用作标准功能键”设置或 Fn 键影响,
这是平台验收条件,不应写成 ClientVS22 的额外键位功能。
修改
game_scene.gd的输入表按上面逐行补齐,包括每个 Ctrl 组合分支。SaveScreen()(SYSRQ)实现正式截图,替代现有的调试截图。StartAttack/EndAttack(空格)接 §3.4 的m_isAtkKey连续攻击。ShowName / HideName(LALT)接 §8.8 的 TextTail 显示开关。- 键位重绑定 / 冲突检测:参考端没有——不要实现,若需要另立需求。
- 相机灵敏度持久化:参考端也没有独立设置(相机参数持久化见 §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 实现(不必自绘候选窗), 但必须保持参考端的输入行为——不能因为换了实现就默认等价。以下逐项验证后才算完成:
- 组合文本(composition):未确认的组合串要显示在输入位置,且不计入已提交文本;
对应
OnWM_CHAR/OnUpdate。 - 候选词与候选窗:候选列表可见、可翻页、可用数字键选中;候选窗位置跟随光标,
不被游戏窗口遮挡;对应
OnOpenCandidateList/OnCloseCandidateList/OnOpenReadingWnd/OnCloseReadingWnd。 - 焦点切换:焦点离开输入框时组合串的处置(提交还是丢弃)要与参考一致; 窗口失焦 / 切窗时不残留候选窗。
- 光标与编辑:
MoveLeft / MoveRight / MoveHome / MoveEnd / SetCursorPosition / Delete六个操作在含多字节字符时按字符移动,不能按字节。 - 长度限制:见 §1.10——上限是目标字段的字节数(角色名 24、聊天 128、 公会名 12 …),且不得在多字节字符中间截断;组合中的未确认串也要参与上限计算。
- 代码页:
OnChangeCodePage对应LocaleService_GetCodePage(); locale 切换后输入与显示都要跟着换(§1.10)。 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]。
修改
- 按 §8.2 迁移
uichat.py(notice / big notice / 频道过滤 / 历史滚动 / 私聊窗生命周期)。 - 新建
text_tail.gd移植PythonTextTail:名字 / 称号 / 公会名 / 伤害数字 / 聊天气泡 / 屏幕空间血条,显示开关接 §8.6 的 LALT 与 §8.4 的三个 radio。 - 名字颜色按
ENameColor的完整枚举实现,输入是 §2.5 补齐的ch_type / empire / guild / alignment / pk_mode与 §5.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// 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()内):关窗(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):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 名与音量。
修改
ClassicParser保存field_music_name/field_music_volume,默认音量1.0f/5.0f*0.1f = 0.02f;M2Client暴露get_field_music_name()/get_field_music_volume()。game_scene.gd进图时按game.py:190的三行逻辑选 BGM(服务端名优先, 否则musicInfo.fieldMusic),并先SetMusicVolume/SetSoundVolume。musicinfo.gd落METIN2THEMA / loginMusic / createMusic / selectMusic / fieldMusic五个常量与save_last_play_field_music / load_last_play_field_music(文件路径user://BGM/lastplay.inf)。- 换图(§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)
POC/project/app_lifecycle.gd:67的_enter_background()把_bound_audio.stop_bgm()改为_bound_audio.save_volume()+ 静音 (等价SaveVolume()后SetMusicVolume(0))。_exit_background()(app_lifecycle.gd:75)加_bound_audio.restore_volume()。Audio增加save_volume() / restore_volume(),字段名对齐m_fBackupMusicVolume / m_fBackupSoundVolume。- 这样
_bgm_name不再被清空,曲目继续播放,恢复后音量还原。
9.3 生命周期所有权重复
参考:唯一协调者是 CPythonApplication
(REF/UserInterface/PythonApplication.cpp + PythonApplicationEvent.cpp),
CPythonNetworkStream 只是被它驱动的一个子系统。
修改
- 确立
AppFlow为唯一生命周期协调者(等价CPythonApplication):AppLifecycle由AppFlow创建并持有,GameScene不再自建。 M2Client删除自己的NOTIFICATION_*处理,只保留被调用的suspend() / resume()。- 暂停 / 恢复 / 内存警告 / 关闭四条路径各只有一个入口,写进
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):
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 音效。
修改
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。- 3D 音效位置一律除以
SOUND_SCALE。 - 音量换算函数按
__ConvertRatioVolumeToApplyVolume的分段公式实现 (r < 0.1线性,否则pow(10, r - 1)),grade除以 5 转 ratio;system_option_ui.gd的音量滑条改走这个换算(当前是线性)。 - 三类声道分开:普通 3D(
PlaySound3D)、环境(PlayAmbienceSound3D, 有独立的m_fAmbienceSoundScale)、角色(PlayCharacterSound3D+ 去重)。 .msa的 sound 事件走UpdateSoundInstance(x,y,z,frame,vector,bCheckFrequency=TRUE)(角色动作声音一律开去重)。- 音乐通道支持
FADE_LIMIT_OUT(降到某音量而不是 0)与FadeOutAllMusic。 .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 不是自包含资源包
修改
build-macos-client.sh里把assets/bgm的符号链接改为ditto复制进Contents/Resources/(或打成.epk/.eix后复制)。AssetRoot.path()在 macOS 上优先解析Contents/Resources/assets,MT_ASSETS仍可覆盖(开发用)。- 验收:把
.app拷到另一台机器(或mv掉工作区)后能启动进图。
10.2 主程序和 GDExtension 架构不一致
修改
build.sh/CMakeLists.txt为 macOS 增加x86_64目标, 用lipo -create合成 universal.dylib。*.gdextension的macos.debug/release指向该 universal 库。- 验收:
lipo -info显示x86_64 arm64;Godot 主程序也是 universal。
10.3 当前是 Ad-hoc 签名
修改
codesign --force --options runtime --sign "Developer ID Application: …"对.app内所有.dylib与主可执行文件逐个签名(先内后外)。xcrun notarytool submit --wait提交公证,xcrun stapler staple装订。- 验收:
spctl -a -vvv -t install <app>通过。
10.4 发布构建仍包含 Demo 默认账号密码
当前:POC/project/app_flow.gd:32-33
var _id := "admin"
var _pw := "123456789"
修改
- 两行改为空串。
- 自动登录只在
MT_AUTOLOGIN=1且MT_ACCOUNT/MT_PASSWORD环境变量存在时生效 (app_flow.gd:37的auto_login保留,凭据不再硬编码)。 - 加一条 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,已在不做清单。
修改
- 在
CLIENT-GAP.md的不做清单里逐项定性: Logo / Movie / Web / Discord(参考默认关闭)→ ❌; 补丁器(CheckLatestFiles)→ 单独立项或 ❌。 - 决策写进
CLIENT-ROADMAP.md,避免每轮审计重复出现。
10.6 缺少真实验收门禁
修改:建立 POC/docs/ACCEPTANCE.md,每项写「前置条件 / 步骤 / 通过判据」:
- 40250 真服矩阵:登录 → 选人 → DirectEnter → 进图 → 换图(§2.7)→ 登出。
- 双账号:交易(§4.5)、组队(§4.9)、PVP(§5.4)。
- 断线重连:拔网线 → 回登录页(§1.2)。
- 纯 pack 运行:
MT_ASSETS_PACK_ONLY=1(§7.2)。 - Intel Mac(§10.2)。
- 受控机位截图:地图 / 机位 / 角色 / 时间 / 天气 / 分辨率契约
(见
PARITY-GAP.md §0,该门禁已存在,保持)。 - 移动端真机性能。
附录 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 1(W1/W2/W3/W4)4 个并行工作流提交后执行 |
| 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 的变更记录 / 总览表和本文对应工作流 / 条目:
批次:YYYY-MM-DD / W? / §x.x
状态:🟡 开发中 | ✅ 完成 | ⚠️ 阻塞 | 🔎 待真实验收
参考:ClientVS22/source/...:line
改动:文件列表;接口 / 数据结构变化
测试:命令、结果;skip 项及原因
验收:真服 / 双账号 / Mac App 证据,或明确“未验证”
风险与依赖:...
下一批次:...
同步责任固定为:工作流提交者提供事实和证据,W0 集成维护者统一合并两份文档;任何状态从 “开发中 / 待验收”改为“完成”前,必须同时满足代码回归和文档回写。
C.5 批次记录
批次: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/4(BGM)
改动:
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 落 empire;reset_for_map_change 清 m_dirty
extension/src/net/m2_client.cpp —— +bgm_changed 信号;两条 pump 回路消费 drain_dirty/
take_bgm_dirty;entity_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/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
验收:未验证(无真实 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 集成与文档合并
批次:2026-09-02 / W1 / §1.5 §1.10 §2.2
状态:🔎 待真实验收(代码 + 本地回归齐全;无 40250 真实服务端)
参考:REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:4155(SendClientVersionPacket);
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_ASSETS;mtnet-safe(std::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_codec;net.classic_session 及既有 16 项全绿);
godot --headless --path project --script res://netbridge_test.gd → PASS(exit 0);
godot --headless --path project --script res://p10_test.gd → PASS(exit 0)。
环境强制项:GDScript 回归前需先跑一次 godot --headless --path project --import
以填充 project/.godot/global_script_class_cache.cfg(worktree 内 .godot 为空且 gitignore),
否则 class_name AssetRoot 无法解析;未改 project/*。无 skip。
验收:未验证——无真实 40250 服务端确认 0xf1 版本包被接受、两包合并序列、非 ASCII 名编码。
风险与依赖:
- to_wire() 仅做 UTF-8 边界安全字节截断,未做 locale 代码页(CP949/CP1252)转码;
ASCII 往返正确,非 ASCII 名对单字节编码服务端呈 mojibake(follow-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 步骤 4–5(entity_store mut_char_info touch / mut_shop_sign)属 W2,本批次未动;
合并逻辑对当前 W2 代码可工作
- parser 中 ADDITIONAL_INFO 以外的名字读取仍走 strnlen;GC_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 真服验收