Files
mtgodot-poc/docs/CLIENT-GAP-FIX.md
T
shenleiandClaude Sonnet 5 f32064c74a 40250 classic: parallel-dev checkpoint through increment 49 (W0 interface freeze)
Captures the uncommitted parallel-development work (increments 46-49) on the
40250 classic client port, folded into docs/CLIENT-GAP.md + CLIENT-GAP-FIX.md.

Increment 49 (W0 interface-freeze batch, Phase 1 prerequisite) lands the
cross-workflow shared-file scaffolding so the 4 Phase 1 workflows can run in
isolated worktrees without contention:

- entity_store.{h,cpp}: Entity gains empire/affect_flags/owner_vid/state_flags;
  new mut_spawn_full() (single-shot two-packet merge, §2.3), mut_ownership()
  (§2.5), mut_map_bgm() + take_bgm_dirty()/bgm_name()/bgm_volume() (§9.1),
  drain_dirty() (§2.1/§2.5 bare-field-update queue); mut_char_info() now stores
  empire; reset_for_map_change() clears m_dirty.
- m2_client.cpp: new bgm_changed(name, volume) signal; classic + m2dev pump
  loops drain drain_dirty() -> entity_info and take_bgm_dirty() -> bgm_changed;
  entity_dict() exposes the 4 new keys.
- classic/classic_parser.{h,cpp}: GC_MAIN_CHARACTER3_BGM /
  GC_MAIN_CHARACTER4_BGM_VOL route bgm_name/bgm_volume to mut_map_bgm() (was
  discarded); new m_pending_actor staging map (W1 fills §2.2 merge logic).
- project/bgm_director.gd (new) + game_scene.gd: BGM consumption split out of
  net_world.gd so W2/W4 don't collide; §8.6/§4.8 keybind convergence (digits
  1-4 -> quickslots 0-3, F1-F4 -> quickslots 4-7, Ctrl+1..9 -> _emote()).

Tests: cmake --build build clean; ctest 16/16; 15 GDScript regressions green
(netbridge, gamescene, netplay, p9, p10, p2b, p8, system_menu_ui, skill,
combat_fx, skill_fx, player_motion, char_status_ui, chat, inventory), no skips.

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

170 KiB
Raw Blame History

CLIENT-GAP 修改细则(1:1 复刻 40250/ClientVS22

本文是 CLIENT-GAP.md §「ClientVS22 实现细节审计(2026-09-02)」的逐条修改说明。 编号与该节 110 完全对应。

原则:每条只写 40250/ClientVS22 里实际存在的行为、常量和结构,附源码 文件:行; 不设计新机制、不做“更好”的替代方案。ClientVS22/source/ 本身没有完整的 Python UI 脚本;凡引用 POC/assets/root/*.py 的地方,表示迁移资产 / 历史脚本依据,不冒充 ClientVS22/source/ 证据。凡本文没有从参考源码读出的细节,标注为 [待读参考] 并给出应读的参考文件,而不是臆造。

路径约定:REF/ = /Users/shenlei/Work/mt/40250/ClientVS22/source/ POC/ = /Users/shenlei/Work/mt/mtgodot-poc/

创建 / 修订日期:2026-09-02


实施进度(2026-09-02

当前已完成 G0 公共接口的首版冻结,并已交付 W0/W1/W2 的“连接与阶段骨架”及 Warp / 换图清理 第一批;正在进入并行波 A,不等于 §1、§2.7 或完整场景生命周期全部完成。

已完成并已编译验证:

  • GC_LOGIN_SUCCESS3/4 的角色槽保存 lAddr/wPortM2Client.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 并触发失败链路。
  • 已建立游戏连接后的断线现在发 disconnectedAppFlow 无条件回 LOGIN,匹配 OnRemoteDisconnect() -> SetLoginPhase() 的最小语义。
  • 回归覆盖:角色槽地址透传、未知包不重同步、未处理包显式失败、发送包顺序; net_classic_stream_testnet_classic_session_testnetbridge_testp10_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_resetGameScene / NetWorld / GroundItems 清理旧表现节点, 重建地图、HUD 小地图、Atlas、环境和玩家坐标;地图切换时保留背包、任务、好友等持久数据。
  • 回归覆盖 Classic Warp endpoint / selected slot 保留、EntityStore loading reset、场景重建 相关 GDScriptC++ 扩展和 gamescene_test / p9_test / p2b_test 已通过。
  • 增量 49 —— W0 接口冻结批次(Phase 1 前置)。为 4 个并行工作流一次性落地共享文件骨架:
    • entity_store.h/.cppEntity 新增 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.1volume<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_infotake_bgm_dirty()bgm_changed entity_dict()empire / affect_flags / owner_vid / state_flags 键。
    • classic_parser.cpp/.hGC_MAIN_CHARACTER3_BGM / GC_MAIN_CHARACTER4_BGM_VOLbgm_name / bgm_volume 从「读出即丢弃」改为路由到 mut_map_bgm();新增 std::unordered_map<uint32_t, Entity> m_pending_actor 成员(W1 在 §2.2 填充合并逻辑)。
    • project/bgm_director.gd(新):M2Client.bgm_changedAudio.play_bgm / stop_bgm 把地图 BGM 消费点从 net_world.gd 拆出,避免 W2(可见性)与 W4(音频)抢改同一文件; game_scene.gd 装配 _bgm_directorbind(client, _audio)
    • game_scene.gd §8.6 / §4.8 键位收敛:数字键 1–4 → quickbar.activate(0..3)、 F1F4 → quickbar.activate(4..7)(原为翻页)、Ctrl/Cmd+1..9 → _emote(n) 统一入口 (表情包发送与本地表情动作由 W3 在 §8.6 补全,当前调用 client.send_emoticon 若存在)。
    • 测试:cmake --build build 通过;ctest 16/16 通过;netbridge_testgamescene_testp9_testnetplay_testsystem_menu_ui_testskill_testcombat_fx_testskill_fx_testplayer_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.gdnet_play.gdplayer_controller.gdproject/world/*、动画 / 战斗扩展和测试 不把 UI 状态写入协议层;共享 reset 只通过 W0 接口接入
W3 玩法 UI / 输入 §4–§5、§8、背包、任务、社交、IME、键位 project/ui/*hud.gdlogin.gdcharselect.gd、UI 测试和本地化资源 不修改 Classic parser;缺少真服时使用 fixture,并注明未验证
W4 渲染 / 资产 / 音频 §6–§7、§9、模型、特效、地图、材质、天气、BGM、生命周期表现 extension/src/{gr2_bridge,metin2_world,terrain_splat,m2_material,environment_builder}.*project/fx/*、音频 / 渲染专属脚本和测试 不把渲染 fallback 塞进网络状态机;场景钩子由 W0 提供
W5 打包 / 发布 / 验收 §10、Mac App 自包含资源、架构、签名、发布配置和验收矩阵 project/export_presets.cfg、打包 / 发布脚本、extension/CMakeLists.txt、发布验收文档 不以“能启动”代替资源、架构、签名和真实功能验收

执行波次

  1. G0 公共基础冻结W0 维护 enter_gamewarpworld_reset、实体快照、UI 信号和 测试 fixture 的契约,并在本文登记影响文件。
  2. 并行波 A:W1–W5 按上表同时开发;每条工作流先使用 loopback、fixture、最小地图或 debug 构建完成自己的回归,不等待其他工作流的全部功能。
  3. G1 接口集成:W0 合并共享接口,执行扩展构建、原生网络测试和受影响的 Godot 测试;冲突 只在这一层解决,不在各工作流中反复复制修复。
  4. 并行波 B:未阻塞的协议细节、窗口行为、渲染资产、音频生命周期和打包检查继续并行;真实 服务端、双账号和 Mac App 验收按矩阵分别记录。
  5. G2 发布门禁:只有代码回归、真实链路(适用时)、资源包、架构 / 签名和文档状态都齐全, 对应条目才可从“开发中 / 待验收”改为“完成”。受限环境中的 skip 只能记为未验证。

每批次必须同步文档

每个开发批次都要在开始、完成、阻塞、重新验证时更新本文和 CLIENT-GAP.md。代码提交而没有 对应文档记录时,不得把条目标成完成。

批次记录至少包含:

  • W0W5 工作流、§ 条目、状态( 未开始 / 🟡 开发中 / 完成 / ⚠️ 阻塞 / 🔎 待真实验收);
  • 参考文件与行号、修改文件、接口 / 数据结构变化;
  • 自动化测试命令和结果,包含被 skip 的测试及原因;
  • 真服 / 双账号 / Mac App 验收证据,或明确列出尚缺证据;
  • 风险、依赖、文件冲突和下一批次。

同步职责固定为:CLIENT-GAP.md 更新总览表与变更记录,本文更新对应条目的实现细节、测试证据和 实施进度;W0 集成维护者统一合并文档,避免多个工作流覆盖彼此的状态。每次合并后应检查两份文档的 状态、数字、当前路径和“已完成 / 未验证”表述一致。


0. 先决:参考端的整体骨架

改任何一条之前,必须先认这套骨架,否则单条修改会互相打架。

0.1 连接与阶段(唯一真相)

参考客户端只有两条 TCPCAccountConnector(认证)与 CPythonNetworkStream(游戏), 且后者在一次登录里会连接两次

1) CAccountConnector::Connect(gameAddr, gamePort, authAddr, authPort)   REF/UserInterface/AccountConnector.cpp:37
     └ 记住 game 地址,连的是 auth 地址
2) auth 上发 CG_LOGIN / CG_LOGIN3            → GC_AUTH_SUCCESS{bResult, dwLoginKey}
     └ __AuthState_RecvAuthSuccess()          REF/UserInterface/AccountConnector.cpp:451
         bResult==0  → Python OnLoginFailure("BESAMEKEY")
         bResult!=0  → rkNet.SetLoginKey(dwLoginKey); rkNet.Connect(gameAddr, gamePort);
                       随后 AccountConnector 立刻 Disconnect()
3) 游戏连接 #1HandShake → Login → Select
     Login 阶段:m_dwLoginKey != 0 → SendLoginPacketNew()  = TPacketCGLogin2{header=109,
       login_key, name[ID_MAX_NUM+1], adwClientKey[4]}     REF/.../PythonNetworkStreamPhaseLogin.cpp:274
     Select 阶段:GC_LOGIN_SUCCESS3/4 填 m_akSimplePlayerInfo[4](含每槽 lAddr / wPort
4) 点“开始”:introselect.py:754 `net.DirectEnter(chrSlot)`
     → CPythonNetworkStream::ConnectGameServer(iChrSlot)   REF/.../PythonNetworkStream.cpp:469
         __DirectEnterMode_Set(iChrSlot)
         CNetworkStream::Connect((DWORD)slot.lAddr, slot.wPort)   ← **用角色槽里的地址,不是配置地址**
5) 游戏连接 #2DirectEnterMode):
     HandShake:不通知 Python                REF/.../PythonNetworkStreamPhaseHandShake.cpp:114
     Login    :同样 SendLoginPacketNew,不通知 Python,不清角色数据
     Select   **直接** PyCall SetLoadingPhase()   REF/.../PythonNetworkStreamPhaseSelect.cpp:28
     Loading  __DirectEnterMode_Initialize()      REF/.../PythonNetworkStreamPhaseLoading.cpp:190

阶段切换只由 GC_PHASE(0xfd) 驱动,TPacketGCPhase.phase ∈ {PHASE_CLOSE, PHASE_HANDSHAKE, PHASE_LOGIN, PHASE_SELECT, PHASE_LOADING, PHASE_GAME, PHASE_DEAD} REF/UserInterface/Packet.h:1124,处理见 PythonNetworkStream.cpp:591 RecvPhasePacket)。

0.2 实体两层模型(唯一真相)

参考端数据层表现层是分开的,这是 §2 全部条目的根因:

参考类 生命周期
数据 CNetworkActorManager::m_kNetActorDictstd::map<VID, SNetworkActorData> 只由 GC_CHARACTER_ADD/DEL 增删,不随可见性变化
表现 CPythonCharacterManagerCInstanceBase 按距离 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_ADDTYPE_PC / TYPE_NPC 不立刻创建,而是暂存到 static SNetworkActorData s_kNetActorData;等 GC_CHAR_ADDITIONAL_INFO 同 VID 到达后合并再 AppendActorREF/UserInterface/PythonNetworkStreamPhaseGameActor.cpp:83:140):

  • RecvCharacterAppendPacket 显式把 alignment / pkMode / guild / empire / armor / weapon / hair / mount / level 全部置 0,它们只能来自 additional info。
  • 非 PC/NPC(怪 / 石 / 门 / 器物)立即 AppendActor,名字取 CPythonNonPlayer::GetName(race)
  • IsInvisibleRace(20025 / 20038 / 20039) → 直接丢弃(PhaseGameActor.cpp:66)。
  • 若 additional info 的 VID 与暂存不符:TraceError 并丢弃,不创建实体

1. P0:连接、协议和生命周期

1.1 Classic 直连分支(第一批已接入,仍待 E2E)

参考:不存在“直连 vs Auth”的二选一。参考端固定是 §0.1 的四步,其中 “进入游戏”这一步必须用角色槽自带的 lAddr / wPort 重新连一次 REF/UserInterface/PythonNetworkStream.cpp:469),并置 __DirectEnterMode

当前(第一批已修改):登录入口仍由 M2Client::connect_to_server()POC/extension/src/net/m2_client.cpp 走 Auth → Game;收到角色列表后,M2Client.enter_game(index) 会调用 ClassicSession::connect_direct_enter(index),断开第一条游戏连接并按角色槽 lAddr/wPort 建立第二条 游戏连接。新连接的 LOGIN 使用登录 key,SELECT 阶段自动发送 CG_CHARACTER_SELECT

修改

  1. classic_session.h/.cpp:把两个 connect() 重命名为语义化的两段,并新增第三段(待完成的 清理项;当前先保留原入口以减少无关改动):
    • connect_auth(auth_host, auth_port, id, pw) —— 现有带 auth 的重载,只做认证 收到 GC_AUTH_SUCCESS 后保存 m_auth_login_key,随即关闭 auth 流
    • connect_game(game_host, game_port) —— 用 m_auth_login_keyCG_LOGIN2 (已有 connect_with_login_key,改为公开主路径)。
    • connect_direct_enter(slot_addr, slot_port, slot_index) —— 新增。置 m_direct_enter = true; m_direct_enter_slot = slot_index;,再 m_stream.connect()
  2. ClassicParserGC_LOGIN_SUCCESS3/4 解析时把每槽的 lAddr(LONG, 小端 4 字节 IP) 与 wPort 存进 SimplePlayer(当前只存了展示字段)。IP 还原按参考 GetAccountCharacterSlotDataz(PythonNetworkStream.cpp:420) ip[i] = (lAddr >> (8*i)) & 0xff,格式化为 "%d.%d.%d.%d"
  3. M2Client::enter_game(index) 已接入:调用 classic_sess->connect_direct_enter(index),即断开当前连接、按角色槽地址重连 app_flow.gdcharselect.gdlogin.gd 已优先使用该入口,旧 fake client 仍可回退到 select_character()
  4. ClassicSession::pump()m_direct_enter == true 时:
    • Stage::ConnectingHandShake)不发 phase_changed 给选人 UI
    • Stage::CharSelect 一进入就自动推进到 Stage::Loading(等价 PythonNetworkStreamPhaseSelect.cpp:28SetLoadingPhase), 当前仍保留 set_auto_entergame_delay(1500) 的兼容性定时发送,这一步尚未完成; 后续应改为 Loading/游戏窗口完成初始化后的明确发送点;
    • Stage::Loading 入口清 m_direct_enter(等价 PhaseLoading.cpp:190)。
  5. MT_CLASSIC_ENTER_DELAY 环境变量连同 m2_client.cpp 的兼容性注入仍未删除——参考端 CG_ENTERGAME 的时机是 Python game.py:207 net.SendEnterGamePacket(),即 游戏窗口 Open() 的最后一步,不是定时器。

已完成的离线验收:角色槽 lAddr/wPort 能被解析并保存;M2Client.enter_game 已注册; DirectEnter 的状态推进和第二次连接入口已接入。待验收net_e2e 观察两次 TCP 连接,且第二次 目标地址来自 GC_LOGIN_SUCCESS4 槽位数据。


1.2 游戏中断线回登录(第一批已接入,仍待 E2E)

参考CNetworkStream 收到远端关闭 → OnRemoteDisconnect() REF/EterLib/NetStream.cpp:332/343/351);CPythonNetworkStream 的覆盖实现 REF/UserInterface/PythonNetworkStreamEvent.cpp:4只有一行

void CPythonNetworkStream::OnRemoteDisconnect()
{
    PyCallClassMemberFunc(m_poHandler, "SetLoginPhase", Py_BuildValue("()"));
}

即:任何阶段断线一律回登录阶段。参考端没有“重连遮罩 + 倒计时 + 自动重连”。 主动登出也走同一条:AbsoluteExitGame() = OnRemoteDisconnect(); Disconnect(); PythonNetworkStream.cpp:227)。

当前(第一批已修改)pump_classic() 在已经建立过游戏连接后发 disconnected(reason),认证 / 建立游戏连接前的失败仍发 login_failed AppFlow._on_disconnected 无条件回 LOGIN。ReconnectUI 已从 app_flow.gd 主流程移除,文件仍保留 供旧测试 / 独立增强使用。

修改

  1. m2_client.cpp pump_classic() 已按“游戏连接是否曾进入 Online”区分——认证失败 / 游戏连接尚未建立 → login_failed;已经建立过游戏连接的后续断开 → disconnected(reason)ClassicSession::was_online_lost() 提供该判定。
  2. app_flow.gd_on_disconnected() 已改为无条件 _goto_login(),并附 _set_status(reason) —— 与 SetLoginPhase() 1:1。
  3. reconnect_ui.gd 的去留是产品选择,不是必然修复AUTO_DELAY=5.0 / MAX_TRIES=5 是本工程自造,参考端不存在,所以 它不能计入 1:1 完成度;但保留与否不影响本条的缺陷修复—— 第 1、2 步修好信号链路后:
    • 走严格 1:1 → 删除 reconnect_ui.gd 及其在 app_flow.gd 的挂载;
    • 保留自动重连 → 另立需求,并在 CLIENT-GAP.md 标为「超出参考范围的增强」。 第 1、2 步已落地;剩余是产品是否删除独立的 reconnect_ui.gd,以及真实断线验收。
  4. M2Client::logout() / ESC 菜单的 /logout:按 ExitGame()(PythonNetworkStream.cpp:204) 的两分支——__IsNotPing() 恒为 falsePythonNetworkStream.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.FadeOutMusicchat.Close()snd.StopAllSound()grp.InitScreenEffect()chr.Destroy()textTail.Clear()quest.Clear()background.Destroy(),顺序不可换。
  • BGM 重选:game.py:194
    netFieldMusicFileName = net.GetFieldMusicFileName()
    if netFieldMusicFileName:  snd.FadeInMusic("BGM/" + netFieldMusicFileName)
    elif musicInfo.fieldMusic: snd.FadeInMusic("BGM/" + musicInfo.fieldMusic)
    

当前(增量 47Classic GC_WARP 已透传包内坐标和 lAddr:wPortM2Client.pump_classic() 先调用 EntityStore::reset_for_map_change(),发出 world_reset,再通过同一个 ClassicSession::connect_warp() 保留 login key / 已选角色槽并重连目标端点。Classic 的 WarpCue 不再以 addr==0 判定同服;m2dev 后端仍保留自己的旧协议语义,两者不混用。

Godot 侧 GameScene 已将 world_reset 映射为:清理 NetWorld / GroundItems 节点和 PVP / 决斗、 目标、交互窗;收到 Warp 坐标后按服务器坐标重新解析地图、重建 Metin2World、HUD 小地图、 Atlas、环境和玩家定位。背包、任务、好友等持久账号数据不在该清理中删除。

修改

  1. EntityStore::reset_for_map_change() 清空实体、地面物、PVP / 决斗、目标、技能 / affect、 观察者 / 地图 marker、交互状态和旧事件队列;保留背包、任务、好友、公会等持久数据。
  2. net_world.gd / ui/ground_items.gd 增加清理入口;M2Client.world_resetGameScene 关闭临时窗口、清除表现节点并停止当前 BGM。
  3. ClassicSession::connect_warp(host, port) 保留 login key 与选人槽,在同一 session 上 重置连接状态并进入 DirectEnterM2Client 对 Classic 的每个 Warp 都按包内 lAddr:wPort 连接,失败进入既有断线链路。
  4. GameScene._reload_map_for_warp() 根据 Warp 坐标重选地图,重建 Metin2World、HUD 小地图、 Atlas 和环境引用,并重新贴地定位玩家。
  5. 仍需按 game.py:194GetFieldMusicFileName() / musicInfo.fieldMusic 实现地图 BGM 自动选择;还需补飞行物 / EffectLib 的逐实例清理、真实服务换图矩阵和受控画面验收。

1.4 选人和进入游戏发送结果(第一批已修复)

参考:每个发送函数都是 if (!Send(...)) { Tracen(...); return false; } return SendSequence(); REF/.../PythonNetworkStreamPhaseSelect.cpp:161 SendSelectCharacterPacket:177 SendDestroyCharacterPacket:194 SendCreateCharacterPacket:217 SendChangeNamePacketPhaseLoading.cpp:344 SendEnterGame)。 SendEnterGame 还额外要求 SendSequence() 成功后 __SendInternalBuffer()

当前(第一批已修改)ClassicSession::select_char() / send_select_char()、登录包和 enter_game() 已透传 send_fixed() 结果;enter_game() 发送成功后再执行 flush_internal(),并且只在发送成功后置 m_entergame_sentM2Client 和选人相关 GDScript 也优先调用新的 enter_game(index),旧测试 fake client 保留 select_character() 回退。

修改

  1. classic_session.cpp 的选人路径 → return m_stream.send_fixed(&p, sizeof(p));
  2. 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 只在发送成功后置位。
  3. 登录 / 认证发送路径也已检查 send_fixed() 返回值;其它玩法发送函数原本已透传。
  4. 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_SINGAPORELocale.cpp:186#if defined(LOCALE_SERVICE_EUROPE) || … || defined(LOCALE_SERVICE_SINGAPORE) || …_LSS_SERVICE_NAME 设为 LSS_EUROPE,于是 LocaleService_IsEUROPE() == trueLocaleService_IsYMIR() == false参考客户端发的是 HEADER_CG_CLIENT_VERSION2 = 0xf1timestamp = "1215955205"

调用点是 4 个 RecvMainCharacter*最后一行PhaseLoading.cpp:214/240/267/294)。

当前ClassicSession::send_client_version()classic_session.cpp:335)发 HDR_CG_CLIENT_VERSION(0xfd) + "metin2.bin" + "1215955205" —— header 走错分支 0xfd 对应的 timestamp 应该是 __TIMESTAMP__,而 "1215955205" 只属于 0xf1 分支)。

修改

  1. wire_classic.hHDR_CG_CLIENT_VERSION2 = 0xf1struct CGClientVersion2 { uint8_t header; char filename[33]; char timestamp[33]; }; static_assert(sizeof(CGClientVersion2) == 67)(与 CGClientVersion 同布局)。
  2. send_client_version() 按 locale 分支:
    • Locale.is_europe() && !Locale.is_ymir() → 发 CGClientVersion2{0xf1, filename, "1215955205"}
    • 否则 → 发 CGClientVersion{0xfd, filename, MT_BUILD_TIMESTAMP}。 本工程默认 locale 与参考一致(EUROPE 系),默认走 0xf1
  3. filename 改为运行时取:OS::get_executable_path().get_file(),由 M2Client 传入; 取不到时回落 "metin2.bin"
  4. MT_BUILD_TIMESTAMP 由 CMake 注入,格式对齐 C 的 __TIMESTAMP__ "Www Mmm dd hh:mm:ss yyyy"),只在非 EUROPE 分支使用。
  5. 发送时机:跟随 GC_MAIN_CHARACTER*4 个变体)解析成功后立刻发,并且 return SendSequence()(本工程即 m_stream.send_fixed() 的返回值透传), 删除现有的独立触发点与 m_version_sent 早置位。

1.6 Passpod / HackShield / XTrap / StateChecker 兼容路径

参考:均存在于 REF/UserInterface/HackShield.cppWiseLogicXTrap.cppNProtectGameGuard.cppServerStateChecker.cpp,以及 CAccountConnector::SendNEWCIBNPasspodAnswerPacketAccountConnector.cpp:66, HEADER_CG_NEWCIBN_PASSPOD_ANSWER)。

CLIENT-GAP.md 的「不做清单」已把反作弊标注为 。本条不改代码,只补文档:

修改

  1. CLIENT-GAP.md 的不做清单里把 HackShield / XTrap / NProtect / Passpod 显式列入 (目前只写了 CG_HACK 与桌面外壳)。
  2. ServerStateChecker(服务器列表状态查询,UDP不属于反作弊,参考端用于登录页显示 频道负载。若目标服务端开着它,需要单独立项;本文不在此展开。
  3. ClassicParser 对这些 header 的处理按 §1.8 的规则(记日志 + 清接收缓冲), 不静默吞。

1.7 未知包 Loading 重同步(第一批已修复)

参考CPythonNetworkStream::CheckPacket()REF/UserInterface/PythonNetworkStream.cpp:533

if (!s_packetHeaderMap.Get(header, &PacketType)) {
    TraceError("Unknown packet header: %d, last: %d %d", header, g_iLastPacket[0], g_iLastPacket[1]);
    ClearRecvBuffer();
    PostQuitMessage(0);      // ← 直接结束进程
    return false;
}

另有一条唯一合法的重同步header == 0 时连续 Recv 吞掉零字节(填充), 直到读到非零 header 为止(PythonNetworkStream.cpp:505-524,在 CheckPacket :498 内)。 参考端没有“扫描后续字节找 GC_CHARACTER_ADD 边界”这种启发式。

当前(第一批已修改)classic_stream.cpp 已删除 Loading 阶段的启发式边界扫描;未知 header 记录最近两个已识别 header,清空接收缓冲并断开。header == 0 的填充跳过仍在尺寸表 查询前执行。

修改

  1. 删除 classic_stream.cpp 的整段启发式恢复逻辑。
  2. 未知 header 的处理已改为移植后的最小安全语义:
    log_error("Unknown packet header: %u, last: %u %u", header, m_last_pkt[0], m_last_pkt[1]);
    m_recv.clear();
    m_last_error = "unknown GC header " + std::to_string(header);
    disconnect();          // 等价 PostQuitMessage(0) 在本工程里的最小语义
    return;
    
    并新增 uint8_t m_last_pkt[2],在每次成功识别 header 后按 m_last_pkt[0]=m_last_pkt[1]; m_last_pkt[1]=header; 更新(PythonNetworkStream.cpp:575)。 落地限定PostQuitMessage(0) 是 Windows 的「结束进程」,移植时不要字面照搬—— 映射为「明确的协议错误 + 断线」即可(走 §1.2 的 disconnected → 回登录页)。 要保住的语义只有两条:不做启发式重同步不静默继续
  3. header == 0 的填充跳过循环位于尺寸表查询之前,与参考同序。
  4. disconnect() 触发 §1.2 的 disconnected → 回登录页;这对应 Godot 移植中 PostQuitMessage(0) 的安全映射。真实 TCP 断线场景仍待 E2E 验收。

1.8 已知但没有业务处理的包(第一批已修复静默失败)

参考CPythonNetworkStream::RecvErrorPacket(header)PythonNetworkStream.cpp:582

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:21PhaseHandShake.cpp:94PhaseLogin.cpp:90PhaseSelect.cpp:142PhaseLoading.cpp:166PhaseGame.cpp:673)。 注意语义是丢弃整个接收缓冲(不是丢一个包),并返回 true 让循环继续。

当前(第一批已修改)ClassicParserdefault: 分支会记录阶段名、header,并返回 falseClassicSession 将错误传给 ClassicStream,后者清空接收缓冲并断开,避免形成 “连接不断但功能静默失效”。可选的反作弊 / Passpod / Matrix 等集成包也走同一条显式失败路径。

修改

  1. ClassicParser 增加成员 m_phase_name"Offline"/"HandShake"/"Login"/"Select"/"Loading"/"Game"), 由 ClassicSession 在阶段切换时写入。
  2. 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
    
  3. classic_stream.cpp 在 parser 返回 false 时通过断开路径清空 m_recv,并触发显式错误 回调;与参考的 ClearRecvBuffer() 相比,本移植选择“清空 + 断开”以保持错误不可继续。
  4. 待完成:按阶段分表,把现在“一个大 switch 处理所有 header”拆成 6 个阶段表,与参考的 OffLinePhase/HandShakePhase/LoginPhase/SelectPhase/LoadingPhase/GamePhase 一一对应。 这样“Select 阶段收到 GC_MOVE”才会像参考一样报错,而不是被当成合法包消费。
  5. 待完成:新增 classic_phase_table_test.gd,对每个阶段喂一个不属于该阶段的合法 header, 断言 unhandled_headers 记录且缓冲被清;当前已有 C++ 回归覆盖未知包、未处理包和不重同步。

1.9 封包覆盖仍停留在结构和尺寸层

参考REF/EterLib/NetStream.cpp:520-620CMainPacketHeaderMap 是尺寸表; 真正的“完成”判据是每个 header 在对应阶段函数里有 case,且 case 里 ① 完整 Recv ② 写业务状态 ③ 通知 Python 窗口 ④ 需要时回包。

修改(流程性,不是单点改代码)

  1. REF/EterLib/NetStream.cpp:520 起的 header 名表导出全量清单,生成 POC/docs/PACKET-MATRIX.md,每行五列:header / 尺寸 / 所属阶段 / 参考处理函数 / POC 实现位置
  2. 逐行核对上文 ①②③④ 四项,只有四项齐备才标
  3. 动态长度包(PacketType.isDynamicSizePacket)另标一列,核对 TDynamicSizePacketHeader{header, size}Peek(size) 完整性校验(PythonNetworkStream.cpp:540)。
  4. 该矩阵替代 CLIENT-GAP.md 现在的“缺失 header 计数”。

1.10 文本编码和固定长度字段没有统一策略

参考常量REF/UserInterface/StdAfx.h:43Packet.h:380):

常量 说明
CHARACTER_NAME_MAX_LEN 24 角色名字节数;结构里一律 char[25]
ID_MAX_NUM 30 账号;char[31]
PASS_MAX_NUM 16 密码;char[17]
CHAT_MAX_NUM 128 聊天
SHOP_SIGN_MAX_LEN 32 私店招牌
GUILD_NAME_MAX_LEN 12 公会名
PRIVATE_CODE_LENGTH 8 删角色私密码
QUEST_INPUT_STRING_MAX_NUM 64 任务输入

参考的写法一律是 strncpy(dst, src, sizeof(dst)-1)(见 SendCreateCharacterPacket/SendChangeNamePacket/SendDirectEnterPacket), 且 SendLoginPacket 额外显式补 name[ID_MAX_NUM]='\0'。 参考是单字节本地编码REF/UserInterface/Locale.cpp + EterLocale),不是 UTF-8。

修改

  1. 新建 POC/extension/src/net/text_codec.h
    • std::string to_wire(const godot::String&, size_t cap):按当前 locale 代码页转换后按字节 截断到 cap,且不允许在多字节字符中间截断(截到最后一个完整字符)。
    • godot::String from_wire(const char*, size_t cap):反向,遇 \0 停。
    • 代码页由 Locale.gd 的 locale 决定,与 REF/UserInterface/Locale_inc_*.h 一一对应。
  2. wire_classic.h 的所有 char name[...] 字段统一经 to_wire() 填充,删掉散落的 memcpy / strncpy(utf8)
  3. 角色名校验统一为 <= 24 字节CHARACTER_NAME_MAX_LEN = 24,缓冲 25 含结尾符)。 当前代码里 < 24<= 24 两种规则各出现过,全部改为 to_wire(name, 24).size() > 0 && <= 24。涉及:charselect.gd 建号 / 改名、guild_creation_ui.gd(公会名上限 12)。
  4. 回归:text_codec_test.gd 覆盖 CJK 名恰好 24 字节、25 字节(应拒)、边界多字节截断。

1.11 登录错误、超时、会话失效和服务器列表状态没有完整映射

参考

  • GC_LOGIN_FAILURETPacketGCLoginFailure.szStatus(字符串), 直接透给 Python OnLoginFailure(szStatus)REF/.../PythonNetworkStreamPhaseLogin.cpp:222)。 文案映射在 POC/assets/root/intrologin.py:390 OnLoginFailure() 里(本仓已有该脚本)。
  • GC_AUTH_SUCCESS.bResult == 0OnLoginFailure("BESAMEKEY") REF/UserInterface/AccountConnector.cpp:460)。
  • 连接失败 → CPythonNetworkStream::OnConnectFailure()PhaseLogin.cpp:211): __DirectEnterMode_IsSet() 为真则 ClosePhase(),否则 PyCall(PHASE_WINDOW_LOGIN, "OnConnectFailure")
  • 阶段专用:RecvErrorPacket 的文案带 m_strPhase(见 §1.8)。

修改

  1. ClassicParser 保留 login_failure()原始 szStatus 字符串(不要翻译、不要拼接前缀)。
  2. M2Clientlogin_failed 信号第二参数新增 code: String= szStatus), 现有第一参数保留为可读描述。
  3. login.gd 增加 OnLoginFailure 文案表,逐条抄 POC/assets/root/intrologin.py:390 起的 localeInfo 键;未命中的 code 原样显示(与参考一致)。
  4. GC_AUTH_SUCCESS.bResult == 0 走同一条,code 固定 "BESAMEKEY"
  5. 连接失败:direct_enter 期间 → 触发 ClosePhase 等价(回登录页,不弹错误框); 否则 → login.gdOnConnectFailure()

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

关键点有三:

  1. 数据表更新与实例存在与否解耦——UpdateActor 末尾无条件写回 rkNetActorDataaffect / guild / movSpd / atkSpd / armor / weapon / hair / alignment / pkMode
  2. __FindActor()NetworkActorManager.cpp:189)在实例缺失且可见时当场创建。
  3. MoveActor__FindActor(d, dstX, dstY) 的重载,按目标点判可见(:172)。

当前EntityStore::mut_move/mut_set_position/mut_change_speed/mut_dead/mut_stun 等在 m_ents.find(vid) == end 时直接 returnPOC/extension/src/net/entity_store.cpp:123 等), 而 m_ents 同时充当数据表和「场景应有节点」的清单——两层被合并了。

修改

  1. EntityStore 保持为数据层= m_kNetActorDict),并明确:条目只由 GC_CHARACTER_ADD(含 additional info 合流)创建、由 GC_CHARACTER_DEL 删除、 由 mut_set_main_vid()(§1.3)整表清空。任何其它包都不创建、也不删除条目。
  2. 可见性下沉到 net_world.gd= CPythonCharacterManager):
    • 每帧对 EntityStore 的全部条目做 __IsVisibleActor 判定 (主角 / AFFECT_SHOW_ALWAYS / 距离 < CHAR_STAGE_VIEW_BOUND / IsWall(race));
    • 可见但无节点 → 建节点;节点距离 > CHAR_STAGE_VIEW_BOUND + 10 → 淡出删除节点。
    • 常量:const CHAR_STAGE_VIEW_BOUND := 200 * 100 # cmREF/.../PythonCharacterManager.cpp:13)。
  3. 所有 mut_* 保持「未知 VID 直接 return + 记一条 debug 日志」,与参考一致—— 不要引入 pending / 重放机制,参考端没有。
  4. EntityStore 新增 drain_dirty()net_world.gd 只对已有节点的脏实体做刷新, 其余只更新数据。

验收net_entity_test 补一条——先喂 200 m 外的 GC_CHARACTER_ADD,再喂 GC_CHARACTER_UPDATE,断言 EntityStore 里字段已更新且没有场景节点;把主角挪近后 断言节点出现且带更新后的装备。


2.2 GC_CHAR_ADDITIONAL_INFO 早于 Spawn 时状态不会被待合并缓存

参考:见 §0.3。参考端根本不会出现「additional info 早于 add」——它用 static SNetworkActorData s_kNetActorData 做单槽暂存,GC_CHARACTER_ADD 只写暂存槽, GC_CHAR_ADDITIONAL_INFOAppendActor。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:458touch(vid, created) 可以凭空建实体,且新建时不推 Spawn change;若后续 Spawn 正常到达, NetWorld 仍会创建节点,但若 Spawn 丢失或顺序异常,实体会长期只有数据没有表现层节点。

修改

  1. ClassicParser 增加成员 mtnet::PendingActor m_pending_actor;(等价 s_kNetActorData)。
  2. GC_CHARACTER_ADD 处理改为:
    • IsInvisibleRace(race)20025 / 20038 / 20039)→ 直接返回;
    • bType == CHRTYPE_PC || bType == CHRTYPE_NPCm_pending_actor,不调 EntityStore
    • 否则 → 名字取 npclist.txtrace → name(等价 CPythonNonPlayer::GetName), 立即 mut_spawn(...),且 alignment/pk_mode/guild/empire/parts/mount/level 全部置 0。
  3. GC_CHAR_ADDITIONAL_INFO 处理改为:
    • m_pending_actor.vid != pkt.dwVID → 记 error 并丢弃,不建实体;
    • 相等 → 用 pending + additional 的 9 个字段合成一次 mut_spawn_full(...) (新 API,一次性写全字段并推 ChangeKind::Spawn)。
  4. 删除 mut_char_info() 里的 touch() 建实体路径;该函数只保留 「已存在实体的字段更新」语义,未知 VID 直接 return。
  5. mut_shop_sign()entity_store.cpp:483)不要再创建通用 Entity;改为独立的 ShopSign 表现状态,分别对应参考端 BINARY_PrivateShop_Appear/Disappear 的招牌显示 / 隐藏回调。这里不是把招牌包静默丢弃,而是移除“凭空建 Actor”的补丁。

2.3 VID 重用时旧状态可能污染新实体

参考CNetworkActorManager::AppendActorNetworkActorManager.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

修改

  1. 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_* 全部回默认值。
  2. net_world.gd 收到 Spawn 且节点已存在 → 销毁旧节点重建(对齐 DeleteInstance + CreateInstance);例外:上下马导致的重建保留旧世界坐标 (kCreateData.m_lPosX = oldPos.x; m_lPosY = -oldPos.y;,注意 Y 取反)。
  3. mut_spawn() 旧签名保留给 GC_MAIN_CHARACTER 之外的老调用点,但内部改为委托 mut_spawn_full(),避免两套覆盖规则。

2.4 点数组可能残留旧值

参考__RecvPlayerPoints()REF/.../PythonNetworkStreamPhaseLoading.cpp:325

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)只 memcpyn 个; 当前 Classic 固定长度包路径传入 POINT_MAX_NUM,所以该路径本身是全量覆盖,残留风险只存在于 未来或其他调用者传入较短长度时。

修改

  1. wire_classic.h 确认 GCPoints.pointsint32_t[POINT_MAX_NUM],加 static_assert
  2. mut_set_points() 继续允许部分更新,先明确区分“全量快照”和“单点更新”:全量快照必须 传入并校验 POINT_MAX_NUM,部分更新不得被误称为参考端的 GC_POINTS 全量包;必要时在 部分更新前清零尾部,避免未来调用者读到旧值。
  3. GC_POINT_CHANGE(单点更新)保持只改一个下标——参考同样是单点 (PhaseSelect.cpp:129 在 Select 阶段直接 Recv 丢弃,Game 阶段才处理)。

2.5 实体数据字段不完整

参考SNetworkActorDataREF/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 → SetStateFlagsMountHorse/DismountHorse 在参考里是注释掉的,坐骑变化只经 AppendActor 重建。)

当前mut_char_info()entity_store.cpp:477)显式 (void)empire; 丢弃 empire; 名字颜色 / 称号 / 会徽 / target highlight / attackable / weapon mode / motion mode / affect 图标与剩余时间没有统一状态。

修改

  1. Entity 结构补齐到与 SNetworkActorData 一一对应: 新增 uint8_t empire; uint64_t affect_flags; uint32_t owner_vid; uint32_t state_flags; state_flags 已有 func/position 的话合并成一个字段,按 bStateFlag 原值保存)。
  2. mut_char_info() 写入 e.empire = empire;,删除 (void)empire;
  3. affect_flags 按参考的两段拷贝:CopyData(0, 4, &dwAffectFlag[0]) + CopyData(32, 4, &dwAffectFlag[1])uint64_t = lo | (hi << 32)AFFECT_SHOW_ALWAYS 的位号取 REF/UserInterface/InstanceBase.hAFFECT_* 枚举 [待读参考:InstanceBase.henum EAffect]
  4. net_world.gd 的实体刷新按上表的参考调用序执行,不要重排。
  5. 名字颜色 / 称号 / PK 颜色 / 目标框 属表现层,见 §8.8,数据源就是本节的 empire / guild / alignment / pk_mode / level

2.6 一次性事件没有可靠的生命周期缓存

参考:参考端不缓存CPythonNetworkStream 收到包立刻 PyCallClassMemberFunc(m_apoPhaseWnd[PHASE_WINDOW_GAME], ...);窗口指针由 Python 在 SetPhaseWindow(ePhaseWnd, poPhaseWnd)PythonNetworkStream.h:133)注册, PyCallClassMemberFunc 对空指针是安全 no-op。真正的保证是顺序 GC_PHASE(PHASE_GAME) 之前 Python 已经 game.py:137 GameWindow.Open() 完成装配, 而 SendEnterGamePacket()Open() 的倒数第二步(game.py:207)—— 服务端在收到 CG_ENTERGAME 之前不会推游戏事件

当前M2Client 的 drain queue 在 game_scene 装配完成前可能被消费。

修改

  1. 不要加事件缓存。改为对齐顺序: CG_ENTERGAME 的发送点从 ClassicSession 的定时器(§1.1 已删)挪到 game_scene.gd 完成全部窗口装配后显式调用 client.enter_game() 即 1:1 对齐 game.py:207
  2. game_scene.gd 的装配顺序照抄 game.py:137 Open(): 相机 → 玩家清理 → 各 UI Open → 声音音量 → BGM → 快捷栏页 → SetFocus/Show/ShowCursornet.SendEnterGamePacket()StartGame()
  3. M2Client 的 drain 接口保持「不缓存」,但在 enter_game() 之前不启动 world 分派, 避免登录洪流里的包被提前消费。

2.7 GC_WARP 永远重连,换图必须清理并重建世界

参考CPythonNetworkStream::RecvWarpPacket()REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:923 整体只有三步:接收 {bHeader, lX, lY, lAddr, wPort}、设置 __DirectEnterMode_Set(m_dwSelectedCharacterIndex)、调用 CNetworkStream::Connect((DWORD)lAddr, wPort)

结论:

  • GC_WARP 在参考端永远是重连,不存在用 addr == 0 判断“同服只移动玩家”的分支。
  • lX / lYRecvWarpPacket() 这一步不直接消费,但重连后的 GC_MAIN_CHARACTER* / PhaseLoading.cpp:211 LoadData(lX, lY) 仍会用它们定位角色,必须 透传,不能标成无用字段。
  • CNetworkStream::Connect()REF/EterLib/NetStream.cpp:401)先 Clear(),会清理 socket、 收发缓冲和 sequence;新连接再按 DirectEnter 流程 HandShake → Login → Select → Loading。

当前首版(已实现,仍待真实服务端)

  • classic_parser.cpp 将 Classic GC_WARP 标记为 reconnect=trueWarpCue::same_server() 不再作为 Classic 的判据,m2dev 旧语义仍可保留在其独立协议路径。
  • M2Client 在收到 Warp 后发出 world_reset,保存登录 key / 已选角色槽,清空实体、掉落、 目标、PVP / 决斗、技能 / affect / 交互等地图局部状态,再由 ClassicSession::connect_warp()lAddr:wPort 建立第二次游戏连接。
  • Godot GameScene / NetWorld / GroundItems 清理旧节点并重建地图、HUD、Atlas、环境和 玩家位置;背包、任务、好友等跨地图数据保留。

剩余修改与验收

  1. 保持 lX / lY / lAddr / wPort 的 wire、信号和重连路径一致;注释统一写“本步不消费”, 不写“坐标无用”。
  2. 完成真实 TCP E2E:验证第二次连接确实命中 Warp 的 lAddr:wPort、发送 DirectEnter / Select 收到目标 GC_MAIN_CHARACTER*,并确认旧场景节点、旧掉落和旧实体没有残留。
  3. 完成换图后的地图 BGM、天气、飞行物、EffectLib 实例和其他地图局部资源清理 / 重建;当前 只覆盖基础场景和 UI。
  4. 回归:net_classic_session_test 覆盖 endpoint / selected slot 保留,net_entity_test 覆盖 loading resetgamescene_test / p9_test / p2b_test 覆盖场景重建;localhost bind 被 skip 时只能记为“未验证”,不能替代真实 E2E。

3. P1:移动、战斗和技能

3.1 本地移动是直线位置修改

参考:本地移动不是直接改坐标,而是「设 Src/Dst + 起走路动作 + 每帧推进 + 碰撞回退」。

  • 点地移动 CPythonPlayer::__OnPressGroundREF/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.1fPythonPlayerInput.cpp:834)。
  • CInstanceBase::NEW_Goto()REF/UserInterface/InstanceBaseMovement.cpp:251)的前置门依序: __IsSyncing() → falseIsUsingMovingSkill() → 只转向;isLock() → false !NEW_CanMoveToDestPixelPosition(dst) → 停走并返回 true。 通过后:NEW_SetSrcPixelPosition(cur) / NEW_SetDstPixelPosition(dst) / NEW_SetDstPixelPositionZ(src.z) / m_fDstRot / m_isGoing=TRUE / StartWalking()
  • 方向键移动 NEW_MoveToDirectionInstanceBaseMovement.cpp:293)同样有 __IsSyncing / IsUsingMovingSkill / isLock 三门,且 m_isGoing = FALSE
  • 玩家层 CPythonPlayer::NEW_MoveToDirectionPythonPlayerInput.cpp:445)额外: IsOpenPrivateShop() → 直接返回;isLock() && !IsUsingMovingSkill() → 返回。
  • 碰撞:CInstanceBase::CheckAdvancing()InstanceBaseBattle.cpp:469)—— 地形 ATTRIBUTE_BLOCK 检测(InstanceBaseBattle.cpp:560+ 与其它 CInstanceBaseTestActorCollision,命中即 BlockMovement()CanSkipCollision() 为真时整段跳过 (网络状态包驱动的移动会 __EnableSkipCollision(),见 §3.2)。

当前player_controller.gd:216 直接改 X/Z,只做地形阻挡。

修改

  1. player_controller.gd 重构为「Src/Dst + is_going + walk_mode」模型,一一对应上面的 NEW_Goto / NEW_MoveToDirection,并保留三个前置门(syncing / moving_skill / lock)。
  2. 新增 MOVABLE_GROUND_DISTANCE(参考默认值由 SetMovableGroundDistance 从 Python 设置 [待读参考:POC/assets/root/game.py / playersettingmodule.py 中的 player.SetMovableGroundDistance 调用])。
  3. __ReserveClickGround 的 0.1 s 延迟、NEW_IsEmptyReservedDelayTime 的递减逻辑 PythonPlayerInput.cpp:626)照搬。
  4. Actor 之间的碰撞:用 Godot 的 Area3D 近似 TestActorCollision;命中 → BlockMovement() = 停在上一帧位置,不做滑移)。
  5. 坐骑:IsMountingHorse() 时移动速度与动作模式按 §6.4 的 MODE_HORSE* 走。

3.2 同步是距离阈值加 lerp

参考:远端 Actor 的位置完全由一条命令队列驱动,而不是 lerp。

  • 入队:CInstanceBase::PushTCPState(dwCmdTime, kPPosDst, fRot, eFunc, uArg)CNetworkActorManager::MoveActorNetworkActorManager.cpp:520)与 __AppendCharacterManagerActor:410,用于补发正在移动中的 actor)调用。
  • 出队:CInstanceBase::StateProcess()REF/UserInterface/InstanceBase.cpp:1278
    while (队非空):
        if (ELTimer_GetServerFrameMSec() < front.m_dwChkTime) return;   // 按服务器帧时间释放
        pop;
        if (!__CanProcessNetworkStatePacket()) return;   // Dead / KnockDown / 不可取消的技能中
        if (!__IsEnableTCPProcess(eFunc)) return;        // 表情中 / m_bEnableTCPState==false
        switch (eFunc) { ... }
    
  • eFunc 的阈值是硬阈值,不是 lerp
    eFunc 条件 行为
    FUNC_WAIT fDirLen > 1.0f 走过去(Src/Dst + StartWalkingm_kMovAfterFunc = WAIT
    FUNC_WAIT 否则 SCRIPT_SetPixelPosition(dst) + SetAdvancingRotation + SetRotation(瞬移对齐)
    FUNC_MOVE 无条件 Src/Dst + StartWalkingm_kMovAfterFunc = MOVE
    FUNC_COMBO fDirLen >= 50.0f 先走过去,到达后 RunComboAttack(rot, uArg)
    FUNC_COMBO 否则 瞬移对齐 + RunComboAttack(rot, uArg)
    FUNC_ATTACK fDirLen >= 50.0f 先走过去,到达后普攻
    FUNC_ATTACK 否则 瞬移对齐 + BlendRotation + RunNormalAttack(rot)
    FUNC_MOB_SKILL fDirLen >= 50.0f / 否则 同上,动作换 mob skill
    所有「走过去」分支都会 __EnableSkipCollision()
  • 数据层的插值另有一份,只用于可见性判定:SNetworkActorData::UpdatePosition() NetworkActorManager.cpp:8)按 (now - m_dwClientSrcTime) / m_dwDuration 线性插值, 超时钳到 Dst。
  • 本地上行的节流(REF/UserInterface/PythonPlayerEventHandler.cpp):
    事件 节流 发送
    OnMove (:76) 重置两个计时器(wait +100 ms / moving +300 ms FUNC_MOVE, 0
    OnMoving(:62) 300 ms FUNC_MOVE, 0
    OnWaiting(:38) 100 ms 位移 ≥ 1.0 FUNC_WAIT, 0
    OnStop (:88) FUNC_WAIT, 0
    OnAttack(:102) FUNC_COMBO, wMotionIndex
    OnUseSkill(:120) FUNC_SKILL|uMotSkill, uArg
  • 上行包 SendCharacterStatePacketPythonNetworkStreamPhaseGame.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/lerpnet_play.gd 按固定时间/距离发送。

修改

  1. EntityStore 新增每实体的 std::deque<StateCmd>{chk_time, dst_x, dst_y, rot, func, arg}), mut_move() 改为 push_tcp_state(),不再直接改坐标。
  2. 新增 EntityStore::process_states(server_frame_ms)1:1 实现上表的 switch (含 1.0f / 50.0f 两个阈值与 m_kMovAfterFunc 的「到达后动作」)。
  3. EntityStore::update(now) 里保留 SNetworkActorData::UpdatePosition() 的线性插值, 但用于 §2.1 的可见性判定,不驱动节点位置。
  4. net_play.gd 的上行改为 6 个事件回调 + 上表节流常量(100 / 300 ms、位移 1.0), 删除现有的「固定时间或距离」逻辑。
  5. ELTimer_GetServerMSec() 的等价:GC_HANDSHAKElDelta 已用于时钟对齐 REF/.../PythonNetworkStreamPhaseHandShake.cpp:124),确认 M2Clientserver_time_ms() 与之一致后统一使用,不要用本地 ticks_msec
  6. 不要引入预测回滚 / 抖动缓冲——参考端没有。

3.3 普攻节奏硬编码

参考:攻击节奏完全来自 .msa 动作数据,没有任何硬编码间隔。

  • 攻击距离:CInstanceBase::NEW_IsClickableDistanceDestInstance REF/UserInterface/InstanceBaseBattle.cpp:294
    fDistance = 150.0f;
    if (IsBowMode())        fDistance = __GetBowRange();
    if (dst.IsNPC())        fDistance = 500.0f;
    if (dst.IsResource())   fDistance = 100.0f;
    
    对像素坐标的通用判定 NEW_IsClickableDistanceDestPixelPosition 固定 150.0f:283)。
  • 攻击间隔:由动作时长决定。CActorInstance::InputComboAttackCommand REF/GameLib/ActorInstanceBattle.cpp:157)用 m_pkCurRaceMotionData->GetComboInputStartTime() / GetNextComboTime() / GetComboInputEndTime() CRaceMotionData::TComboInputData{fInputStartTime, fNextComboTime, fInputEndTime} REF/GameLib/RaceMotionData.h:177);无 combo 输入数据时退化为 fElapsedTime > GetMotionDuration() * 0.9f
  • 攻速:SetAttackSpeed(m_dwAtkSpd) 影响动作播放速率 [待读参考:REF/UserInterface/InstanceBase.cppCInstanceBase::SetAttackSpeed]

当前POC/project/net_play.gd:25 一组固定常量。

修改

  1. 距离常量搬到 net_play.gdCLICK_DIST_DEFAULT=150.0CLICK_DIST_NPC=500.0CLICK_DIST_RESOURCE=100.0,弓箭走 __GetBowRange() 的等价 [待读参考:InstanceBaseBattle.cpp__GetBowRange]
  2. 攻击间隔改为读 .msa 的 combo 输入时间(本工程已有 .msa 解析,见 metin2_anim.cpp);m2_anim 需要额外导出 combo_input_start / next_combo / combo_input_end / motion_duration
  3. 删除 net_play.gd 里所有「按攻速缩放的固定间隔」。

3.4 自动攻击没有复现 ClientVS22 的目标预留逻辑

参考:两段。

(a) CPythonPlayer::__Update_AutoAttack()REF/UserInterface/PythonPlayer.cpp:207),每帧:

if (0 == m_dwAutoAttackTargetVID) return;
if (!NEW_GetMainActorPtr()) return;
if (__IsUsingChargeSkill()) return;               // 弹射/冲锋中跳过
victim = NEW_FindActorPtr(m_dwAutoAttackTargetVID);
if (!victim)                                   __ClearAutoAttackTargetActorID();
else if (victim->IsDead())                     __ClearAutoAttackTargetActorID();
else if (main->IsMountingHorse() && !main->CanAttackHorseLevel())
                                               __ClearAutoAttackTargetActorID();
else if (main->IsAttackableInstance(*victim))
     { if (main->IsSleep()) { /* skip */ } else __ReserveClickActor(m_dwAutoAttackTargetVID); }

__IsUsingChargeSkill()PythonPlayer.cpp:182):__CheckDashAffect 为真,或 MODE_USE_SKILL 且预约技能是 IsChargeSkill()

(b) CPythonPlayer::__ReserveProcess_ClickActor()PythonPlayerInput.cpp:877), 每帧在 MODE_CLICK_ACTOR 下跑,顺序不可改

1  main / reserved 任一为空            → __ClearReservedAction(); return
2  !NEW_IsClickableDistanceDestInstance → NEW_MoveToDestInstanceDirection(); return   (继续靠近)
3  !IsAttackableInstance                → NEW_Stop(); __SendClickActorPacket(); 清预约; return  (NPC 对话)
4  reserved->IsDead()                   → 清预约; return
5  main->IsInSafe()                     → OnCannotAttack(..., "IN_SAFE"); NEW_Stop(); 清预约; return
6  reserved->IsInSafe()                 → OnCannotAttack(..., "DEST_IN_SAFE"); NEW_Stop(); 清预约; return
7  __CheckDashAffect(main)              → return   (保持预约)
8  main->IsBowMode() && !__HasEnoughArrow() → OnCannotShot(..., "EMPTY_ARROW"); NEW_Stop(); 清预约; return
9  reserved VID != GetTargetVID()       → SetTarget(reserved VID)
10 NEW_AttackToDestInstanceDirection(reserved); __ClearReservedAction();

(c) 预约模式枚举PythonPlayerInput.cpp:820-856): MODE_NONE / MODE_CLICK_ITEM / MODE_CLICK_ACTOR / MODE_CLICK_POSITION / MODE_USE_SKILL 每帧在 NEW_RefreshMouseWalkingDirection()PythonPlayerInputMouse.cpp:287)里分派; 末尾还依次处理 m_isSmtMov / m_isDirMov / m_isDirKey / m_isAtkKey

当前:只有「测距后发攻击包」。

修改

  1. player_controller.gd 新增 enum ReservedMode {NONE, CLICK_ITEM, CLICK_ACTOR, CLICK_POSITION, USE_SKILL}_vid_reserved / _iid_reserved / _ppos_reserved / _skill_slot_reserved / _skill_range_reserved / _reserved_delay,逐字对齐 PythonPlayerInput.cpp:820-865
  2. 每帧执行 _refresh_mouse_walking_direction(),四个 case 分别照抄 PythonPlayerInputMouse.cpp:288-370(含 MODE_CLICK_ITEM< 20.0f 拾取阈值、 MODE_USE_SKILLIS_HUGE_RACE 距离 200 修正)。
  3. _reserve_process_click_actor() 严格按 (b) 的 10 步实现。
  4. _update_auto_attack() 按 (a) 实现;m_dwAutoAttackTargetVID 只在 __OnPressActor(isAuto=true)IsAttackableInstance 时设置 PythonPlayerInput.cpp:306 __OnPressActor)。
  5. OnCannotAttack / OnCannotShot / OnCannotUseSkill 的字符串码 IN_SAFE / DEST_IN_SAFE / EMPTY_ARROW / NEED_TARGET / NOT_HORSE_SKILL / HAVE_TO_RIDE / NEED_EMPTY_BOTTLE / NEED_POISON_BOTTLE / REMOVE_FISHING_ROD)走 game.py 同名回调的 localeInfo 文案表。

3.5 攻击动作、命中时机和网络包没有严格绑定

参考:三条链完全由动作数据驱动。

(a) 连击索引REF/GameLib/ActorInstanceBattle.cpp:157 / 213 / 253):

InputComboAttackCommand(rot):
    m_fAtkDirRot = rot;
    if (m_isPreInput) return false;
    if (0 == m_dwcurComboIndex) { __RunNextCombo(); return true; }
    if (motion->IsComboInputTimeData()):
        t = GetAttackingElapsedTime()
        if (t > GetComboInputEndTime())   { if (IsBowMode()) m_isNextPreInput = TRUE; return false; }
        if (t > GetNextComboTime())       { __RunNextCombo(); return true; }
        if (t > GetComboInputStartTime()) { m_isPreInput = TRUE; return false; }
    else:
        if (GetAttackingElapsedTime() > GetMotionDuration()*0.9f) { __RunNextCombo(); return true; }

__RunNextCombo():253):++m_dwcurComboIndex,用 m_pkCurRaceData->GetComboDataPointer(m_wcurMotionMode, __GetCurrentComboType(), &pComboData)ComboIndexVector[m_dwcurComboIndex-1] 作为动作号,ComboAttack(idx, m_fAtkDirRot, 0.1f)。 连击打满时 __OnEndCombo():骑马下 m_dwcurComboIndex = 1,否则不复位(靠动作回 Wait 复位)。 __ClearCombo():305)才把索引清零。

(b) 命中帧ActorInstanceBattle.cpp:355 / 386 + RaceMotionData): isValidAttacking() 遍历 TMotionAttackData::HitDataContainerTHitData{fAttackStartTime, fAttackEndTime},落在窗口内才算命中窗口; CanCheckAttacking() = isAttacking()(普攻 / 连击 / splash 三者之一)。

(c) 发包REF/UserInterface/PythonPlayerEventHandler.cpp:135 OnHit):

OnHit(uSkill, victimActor, isSendPacket):
    CPythonPlayer::SetTarget(victimVID, FALSE);
    if (isSendPacket) rkStream.SendAttackPacket(uSkill, victimVID);      // CG_ATTACK{bType, dwVictimVID}
    if (!victim.IsPushing()) return;
    if (IS_HUGE_RACE(victim.GetRace())) return;
    CPythonCharacterManager::AdjustCollisionWithOtherObjects(&victim);
    victim.TEMP_Push(lastX, lastY);   记录到 m_kVctkVictim

FlushVictimList():194)把本帧所有被击退者打成一个 TPacketCGSyncPosition (上限 SYNC_POSITION_COUNT_LIMIT = 16)。 CInstanceBase::AttackProcess()InstanceBaseBattle.cpp:413)在 CanCheckAttacking() 为真时 遍历全部实例做 CheckAttacking(),后者先查 IsInSafe() 双方,再 AttackingProcess(victimActor) 做真正的几何判定。

当前net_play.gd 的 combo 只在 0/1/2 循环,命中/发包用定时器。

修改

  1. metin2_anim 导出 .msaTMotionAttackData.HitDataContainer(起止时间)与 combo 输入三时间,.msa 已解析结构见 POC/extension/src/metin2_anim.cpp
  2. RaceData 的 combo 表(ComboIndexVector,按 MAKE_COMBO_KEY(motionMode, comboType) 索引, REF/GameLib/RaceData.h:9)需要从 .msm 解析出来;当前 .msm 解析只用于换肤。
  3. player_controller.gd 实现 (a) 的状态机(_cur_combo_index / _is_pre_input / _is_next_pre_input), 删除 0/1/2 循环。
  4. 命中判定与发包挂到动作事件:进入 THitData 窗口 → 几何判定 → OnHitCG_ATTACK FlushVictimList 每帧末尾发一次 CG_SYNC_POSITION(≤16 条), M2Client::sync_positions() 已有上限 16 的实现,直接复用。
  5. OnAttack 事件(动作真正起手时)发 CG_CHARACTER_MOVE{FUNC_COMBO, wMotionIndex}CG_ATTACK两个不同时刻的两个包,不能合并。

3.6 弓箭发射使用固定 1.5 秒定时器

参考:由 .msaMOTION_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_AttackToDestInstanceDirectionPythonPlayerInput.cpp:358)。 MOTION_EVENT_TYPE_FLYCRaceMotionData::EMotionEventType 的第 6 项 REF/GameLib/RaceMotionData.h:168)。

当前game_scene.gd:218 固定 1.5 s 定时器,单个 pending skill 会被覆盖。

修改

  1. .msa 的 motion event 分派(本工程已实现,见 game_scene._on_local_motion_event)新增 FLY 分支:触发 OnSetFlyTargetclient.fly_targeting(vid, x, y, z) 飞行物命中/到期 → OnShoot(skill)client.shoot(skill)
  2. 删除 game_scene.gd:218 的定时器与单槽 pending skill;改为每次攻击创建一个 handler 实例(对齐 _AutoClear 语义:射完自动清理),支持并发多发。
  3. SendFlyTargetingPacket 的坐标取目标的 OnGetFlyTargetPosition() [待读参考:REF/GameLib/ActorInstanceFly.cppOnGetFlyTargetPosition]

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 / IsKnockDownNEW_Attack 多一个 IsUsingSkill)。 Stun 来自服务端 affectInstanceBaseEffect.cpp:932 case AFFECT_STUN), KnockDown 来自被击退动作,都不是本地计时器

当前EntityStore.stunnedNetPlay._hitstun_until 两套并存。

修改

  1. 删除 net_play.gd_hitstun_until 及其全部引用——参考端不存在这个概念。
  2. Entity 增加 knock_down: bool,由受击动作(.msa 的 knockdown 动作播放中)置位, 动作结束清零;dead / stunned 保持由服务端包驱动。
  3. 所有「能否动作」的判定统一走三个函数: _can_process_network_state() / _can_attack() / _can_use_skill() 实现分别对齐 InstanceBase.cpp:1247InstanceBaseBattle.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 > 0target_vid 等参数被无符号化。

修改

  1. 在 GDScript 侧新建 player_skill.gd,把 (a)(b)(c)(d) 四段逐行移植; SKILL_MAX_NUMSKILL_TYPE_GUILD、骑乘术 109 / 空瓶 27995 / 毒瓶 27996 均按参考写成命名常量。
  2. CPythonSkill::TSkillData 的判定位(IsCanUseSkill / IsStandingSkill / IsToggleSkill / IsHorseSkill / IsAttackSkill / IsNeedTarget / IsNeedCorpse / IsAutoSearchTarget / CanChangeDirection / IsNeedEmptyBottle / IsNeedPoisonBottle / IsChargeSkill 来自 skilltable / skilldesc,需要在 POC/extension/src/proto 侧补解析 [待读参考:REF/UserInterface/PythonSkill.hTSkillData 字段与 PythonSkill.cppRegisterSkillDesc / LoadSkillTable]
  3. M2Client::use_skill() 之类的公开 API 参数改为带符号并做范围校验(见 §3.9)。

3.9 公共网络 API 缺少参数边界检查

参考:参考端在发送前就有断言/裁剪,而不是靠服务端拒绝:

  • SendCharacterStatePacketassert(lX >= 0 && lX < 204800)PhaseGame.cpp:1132); 角度先归一到 [0,360)bRot = rot/5.0f
  • OnAttackassert(wMotionIndex < 255)PythonPlayerEventHandler.cpp:104)。
  • FlushVictimListSYNC_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) 返回 0PythonNetworkStream.cpp:378)。
  • __RecvPlayerCreateSuccessPacket:槽位越界 TraceError吞掉PhaseSelect.cpp:239)。

修改

  1. m2_client.cpp 的每个公开 send_* 入口按上表补 int 范围校验,越界返回 false 并记日志——不要static_cast<uint32_t>
  2. 新建 POC/extension/src/net/limits.h 收纳参考常量: PLAYER_PER_ACCOUNT4=4, QUICKSLOT_MAX_NUM=36, QUICKSLOT_MAX_COUNT=32, QUICKSLOT_MAX_COUNT_PER_LINE=8, QUICKSLOT_MAX_LINE=4, SHOP_HOST_ITEM_MAX_NUM=40, METIN_SOCKET_COUNT=6, PARTY_AFFECT_SLOT_MAX_NUM=7, EXCHANGE_ITEM_MAX_NUM=12, SAFEBOX_PAGE_SIZE=45, REFINE_MATERIAL_MAX_NUM=5, SYNC_POSITION_COUNT_LIMIT=16, MAP_COORD_MAX=204800(来源:REF/UserInterface/Packet.h:380PythonSafeBox.h:8PythonExchange.h:13PythonPlayerEventHandler.cpp:200PhaseGame.cpp:1132)。
  3. 回归:net_bounds_test.gd 对每个 API 各喂一个越界值,断言返回 false 且未产生出站字节。

3.10 技能升级传输已对齐但状态门控不完整

证据边界与订正:本次 ClientVS22/source/ 不含 Python UI 脚本;本仓与该客户端配套的 迁移资产脚本本来就是聊天命令。因此不能据“使用命令”断定实现不符合参考端,也不能据此 断定当前必须改传技能 vnum。uicharacter.py:1022 OnPressedSlotButton()(本仓 POC/assets/root/uicharacter.py):

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() 状态门。

修改

  1. skill_ui.gd 按上面的三段(RefreshSkillPlusButtonList / __RefreshSkillPlusButton / CanShowPlusButton逐行实现加号按钮的显示门控,替换现有单行判断。
  2. 三个技能点来源分开取:SKILL_ACTIVE / SKILL_SUPPORT / SKILL_HORSE uicharacter.py:1010-1019),不是统一一个 POINT_STAT
  3. skill.CanLevelUpSkill(skillIndex, skillLevel)skill.GetSkillLevelLimit(skillIndex) 需要 skill table 支持,见 §3.8 第 2 点的 TSkillData 解析 [待读参考:REF/UserInterface/PythonSkill.cppCanLevelUpSkill / GetSkillLevelLimit]
  4. M2Client::skill_up(int skill_id)m2_client.cpp:850)的发包本身保持不变 /skillup <skillIndex>),只补 §3.9 的入参范围校验。
  5. 升级结果由 GC_SKILL_LEVEL / GC_CHANGE_SKILL_GROUP 回包驱动刷新, 失败提示走 GC_CHAT 的服务端文案——不本地伪造成功。

4. P1:物品、装备、商店和社交

4.1 24 个 wear 位置进入数据层,但没有完整视觉和规则

参考ClientVS22 的编译开关(REF/UserInterface/Locale_inc.h,本份为 LOCALE_SERVICE_SINGAPORE)打开了 ENABLE_COSTUME_SYSTEM / ENABLE_ENERGY_SYSTEM / ENABLE_DRAGON_SOUL_SYSTEM / ENABLE_NEW_EQUIPMENT_SYSTEM。对应的槽位版图 REF/UserInterface/GameType.h:40-112):

c_Inventory_Page_Size = 5*9 = 45     c_Inventory_Page_Count = 2
c_ItemSlot_Count      = 90           c_Equipment_Start = 90     c_Equipment_Count = 12
  +0 Body  +1 Head  +2 Shoes  +3 Wrist  +4 Weapon  +5 Neck
  +6 Ear   +7 Unique1 +8 Unique2 +9 Arrow +10 Shield
  +19 Costume_Body  +20 Costume_Hair                      (ENABLE_COSTUME_SYSTEM)
  +21 Ring1  +22 Ring2  +23 Belt                          (ENABLE_NEW_EQUIPMENT_SYSTEM)
c_Wear_Max = 32
c_DragonSoul_Equip_Start = 90 + 32 = 1226 槽 × DS_DECK_MAX_NUM(2)
c_Belt_Inventory_Width/Height = 4/4 → 16 格

本工程的「24 个 wear 位置」= c_Equipment_Start + 0 .. +23与参考一致,无需改数据层。

装备规则来自 CItemDataREF/GameLib/ItemData.h):

  • EWearPositions:227):WEAR_BODY..WEAR_SHIELDWEAR_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/MYSHOPEMPIRE_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 = 28ITEM_TYPE_RING = 33ITEM_TYPE_BELT = 34ITEM_TYPE_ROD = 13ITEM_TYPE_METIN = 10
  • 武器子类 EWeaponSubTypes:64):WEAPON_SWORD/DAGGER/BOW/TWO_HANDED/BELL/FAN/ARROW

换模CInstanceBase::ChangeArmor/ChangeWeapon/ChangeHair,最终经 __GetRaceType + RefreshState(§6.4)决定 motion mode。远端角色的换装同样走 CNetworkActorManager::UpdateActor(§2.5)。

修改

  1. proto 层导出上述四组位标志(当前只用了 specular),落到 POC/extension/src/proto/item_proto.hItemProto
  2. 装备穿脱校验按参考顺序:antiflag(性别/职业/帝国)wearable_flag 与目标槽位匹配 → limit(等级/属性) → 发 CG_ITEM_USE。校验失败的文案走 localeInfoCANNOT_EQUIP_* 键。
  3. costume(19/20) / ring(21/22) / belt(23) 的槽位在 inventory_ui.gd / equipment_dialog 里补齐,并按 uiequipmentdialog.py 的布局装载。
  4. 部位隐藏 / 遮挡(穿 costume 时隐藏本体 armor、穿头盔隐藏发型)按 REF/UserInterface/InstanceBase.cpp__IsShapeAnimalWear / SetHair 等实现 [待读参考:InstanceBase.cppChangeArmor / SetArmor / __AttachHorseSaddle]
  5. 远端角色换装:net_world.gd 的实体刷新已在 §2.5 对齐调用序,此处只需把 armor/weapon/hair 三个字段接到换模。

4.2 物品拖放不是原版鼠标物品态

参考:全局单例 mouseModule.mouseControllerPOC/assets/root/mousemodule.py class CMouseController),是所有窗口之间拖放的唯一中介

AttachObject(Owner, Type, SlotNumber, ItemIndex, count=0)   mousemodule.py:171
    Type ∈ ESlotTypeGameType.h:140:
      SLOT_TYPE_INVENTORY / SHOP / EXCHANGE_OWNER / EXCHANGE_TARGET / QUICK_SLOT /
      SAFEBOX / PRIVATE_SHOP / MALL / DRAGON_SOUL_INVENTORY → 取物品图标
      SLOT_TYPE_SKILL / SLOT_TYPE_EMOTION                   → 取技能 / 表情图标
      SLOT_TYPE_QUICK_SLOT → 再取快捷栏里登记的真实类型递归解析
AttachMoney(owner, type, count)   :264      IsAttachedMoney() :251
DeattachObject()                  :284      isAttached()      :315
GetAttachedOwner/Type/SlotNumber/ItemIndex/ItemCount  :318-343

落点窗口的两个事件与参考里到处出现的模式一致(以 uisafebox.py:411 为样板):

SelectEmptySlot(pos):  if not isAttached(): return
                       按 GetAttachedType() 分支 → 发对应移动/存取包 → DeattachObject()
SelectItemSlot(pos):   已有物品的格子;未持物时 AttachObject(self, <本窗类型>, pos, itemID)
                       并 snd.PlaySound("sound/ui/pick.wav")

SlotTypeToInvenType(bSlotType)REF/UserInterface/GameType.cpp:106 + 表 :90)把 slot type 映射到 EWindows:只有 SLOT_TYPE_INVENTORY→INVENTORYSLOT_TYPE_DRAGON_SOUL_INVENTORY→DRAGON_SOUL_INVENTORY,其余一律 RESERVED_WINDOW。 落点代码里 if RESERVED_WINDOW == attachedInvenType: return 是标准防御 uisafebox.py:437)。 音效固定:拿起 sound/ui/pick.wav、放下 sound/ui/drop.wav、金钱 sound/ui/money.wav、 非法 sound/ui/loginfail.wav

当前:各窗口各自实现点击/拖放,没有全局持有态。

修改

  1. 新建 POC/project/ui/mouse_controller.gdautoload 单例),API 逐个对齐上表 (名字保持 attach_object / attach_money / deattach_object / is_attached / get_attached_type / get_attached_slot_number / get_attached_item_index / get_attached_item_count / is_attached_money)。
  2. ESlotType / EWindows / SlotTypeToInvenType 三张表移植到 POC/project/ui/game_type.gd,值与 GameType.h 完全一致。
  3. 所有物品窗口(inventory / safebox / mall / shop / exchange / private_shop / quickslot / dragon_soul)统一改为 select_empty_slot / select_item_slot 两个入口, 分支照抄各自的 ui*.py
  4. 鼠标跟随图标:CanvasLayer 上画 AttachedIconHandle 等价物,位置跟随鼠标, 数量文字用 countNumberLinemousemodule.py:87)。
  5. 拖动取消:右键或落到无效目标 → deattach_object(),不发包(参考无回滚包)。
  6. 音效按上表四个固定路径。

4.3 物品 tooltip 只覆盖基础信息

参考POC/assets/root/uitooltip.py(2718 行)是完整实现,本仓已有该脚本。 入口按来源分:SetInventoryItem / SetShopItem / SetExchangeOwnerItem / SetExchangeTargetItem / SetSafeBoxItem / SetMallItem / SetPrivateShopBuilderItem / SetSkill / SetQuickSlotItem

修改

  1. 不要重写 tooltip 逻辑。按 §8 的窗口迁移样板(char_status_ui.gd 的做法) 直接移植 uitooltip.pyItemToolTip 类:
    • 数值来源逐字对齐(item.GetItemName / GetItemDescription / GetItemSummary / GetValue(i) / GetLimit(i) / GetAffect(i) / GetItemSubType / GetItemSize 等);
    • 属性行(attribute)、魔石孔(socketMETIN_SOCKET_COUNT = 6)、 ITEM_FLAG_* / ITEM_ANTIFLAG_* 行、LIMIT_LEVEL/LIMIT_STR/...、 精炼等级、使用时限(GetItemDuration)逐条对齐。
  2. proto 层补齐 tooltip 需要的全部字段(ItemProto.value[6] / limit[2] / apply[3] / flags / anti_flags / wear_flags / socket / refine 等),当前只导出了少量字段。
  3. 装备对比(穿戴中 vs 鼠标所指)按 uitooltip.py__AppendEquipmentCompare 等价实现 [待读参考:uitooltip.py 中比较相关的方法名以本仓文件为准]

4.4 Safebox 是列表和自动找空位

参考

  • 容量:CPythonSafeBoxREF/UserInterface/PythonSafeBox.h:8 SAFEBOX_SLOT_X_COUNT = 5SAFEBOX_SLOT_Y_COUNT = 9SAFEBOX_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 = 70CG_SAFEBOX_CHECKOUT = 71CG_SAFEBOX_ITEM_MOVE = 77 GC_SAFEBOX_MONEY_CHANGE = 84GC_SAFEBOX_SET = 85GC_SAFEBOX_DEL = 86GC_SAFEBOX_WRONG_PASSWORD = 87GC_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.SELLSAFEBOX_SELL_DISABLE_SAFEITEMapp.BUYSHOP_BUY_INFO 否则 AttachObject(self, SLOT_TYPE_SAFEBOX, pos, itemID) + sound/ui/pick.wav
  • 关闭:Close() = net.SendChatPacket("/safebox_close")uisafebox.py:400)。
  • 密码 / 改密:dlgChangePassworduisafebox.py),错误由 GC_SAFEBOX_WRONG_PASSWORD 驱动。

当前POC/project/ui/safebox_ui.gd:7 是列表 + 自动找空位。

修改

  1. safebox_ui.gd 重写为 5×9 网格 + 最多 3 页的页签,__LocalPosToGlobalPos 照抄。
  2. 存取一律指定目标格M2Client::safebox_checkin(inven_window, inven_pos, safe_pos) 删除「找下一个空位」的逻辑。
  3. CG_SAFEBOX_ITEM_MOVE(77) 的发送(仓库内移动)。
  4. 金钱走 /safebox_money 类聊天命令**[待读参考:netSendSafeboxSaveMoneyPacketPythonNetworkStreamModule.cpp 中的实现,确认是包还是聊天命令]**; 关闭固定用 client.say(0, "/safebox_close")
  5. 接入 mouse_controller(§4.2),两个 slot 事件按上面的分支表实现,含光标态提示文案。
  6. 密码窗与 GC_SAFEBOX_WRONG_PASSWORD 的重试提示接上。

4.5 交易缺少事务状态

参考

  • 数据:CPythonExchangeREF/UserInterface/PythonExchange.h:9 EXCHANGE_ITEM_MAX_NUM = 12TExchangeData{ 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 各一份。
  • UIPOC/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_GIVEDeattachObject() 返回;否则 net.SendExchangeItemAddPacket(attachedInvenType, SrcSlotNumber, DstSlotNumber)
    • OnUpdate()USE_EXCHANGE_LIMIT_RANGE = 1000——自开窗坐标起 abs(dx) > 1000 or abs(dy) > 1000net.SendExchangeExitPacket()
    • 标题栏关闭按钮直接绑 net.SendExchangeExitPacket

当前:两列文字列表,无 accept / 距离 / anti-flag 规则。

修改

  1. exchange_ui.gd 重写为 uiexchange.py 的 1:1:12 格 × 2 + 金钱 + 两个 accept 灯 + AcceptButton,用 exchangedialog.py uiscript 装载真实布局。
  2. EntityStore 的交易状态补齐 TExchangeData 全字段(含 socket / attribute 二维数组)。
  3. accept 语义:只跟随服务端。发送 CG_EXCHANGE(ACCEPT) 后立刻禁用按钮; 收到 GC_EXCHANGEaccept == 0 时重新启用——不写本地「修改则取消」逻辑。
  4. OnUpdate 的 1000 距离退出、ANTIFLAG_GIVE 拒绝、金钱 7 位上限与 EXCHANGE_CANT_EDIT_MONEY 全部照抄。
  5. 交易中的背包锁定:参考端没有——不要加。

4.6 NPC 商店仍是基础列表

参考POC/assets/root/uishop.py

  • 槽位:shop.SHOP_SLOT_COUNT;多货架的全局槽号 GetIndexFromSlotPos(slotPos) = self.tabIdx * shop.SHOP_SLOT_COUNT + slotPos:226, 本工程增量 34 已按此实现,保持)。
  • 买卖是光标模式,不是按钮直接买:
    OnBuy():  chat.AppendChat(CHAT_TYPE_INFO, SHOP_BUY_INFO);  app.SetCursor(app.BUY);  btnSell.SetUp()
    OnSell(): chat.AppendChat(CHAT_TYPE_INFO, SHOP_SELL_INFO); app.SetCursor(app.SELL); btnBuy.SetUp()
    CancelShopping(): btnBuy.SetUp(); btnSell.SetUp(); app.SetCursor(app.NORMAL)
    
  • 卖出 SellAttachedItem():279):
    shop.IsPrivateShop() → DeattachObject(); return
    attachedSlotType 必须是 SLOT_TYPE_INVENTORY,否则 sound/ui/loginfail.wav
    item.IsAntiFlag(item.ANTIFLAG_SELL) → PopupDialog(SHOP_CANNOT_SELL_ITEM); return
    player.IsValuableItem(pos):
        itemPrice = item.GetISellItemPrice()
        if item.Is1GoldItem(): itemPrice = attachedCount // itemPrice // 5
        else:                  itemPrice = itemPrice * max(1, attachedCount) // 5
        QuestionDialog(DO_YOU_SELL_ITEM(name, count, price)) → OnSellItem(pos, count, itemtype)
    否则直接 OnSellItem(...)
    OnSellItem: net.SendShopSellPacketNew(slotPos, count, itemtype); sound/ui/money.wav
    
    注意卖价 = 单价 × 数量 ÷ 5(整数除),Is1GoldItem 走另一条公式。
  • 关闭:OnPressEscapeKey:250)→ self.Close()return True

当前:基础列表 + 直接买。

修改

  1. shop_ui.gdshopdialog.py uiscript 装载真实布局(格子 + 图标 + 买/卖两个 toggle)。
  2. 实现 BUY / SELL 光标模式:app.SetCursor 的等价见 §8.5 的光标管理器。
  3. 卖出流程按上面的 8 步照抄,含 ANTIFLAG_SELL 弹窗、IsValuableItem 的确认框、 ÷5 价格公式和 Is1GoldItem 分支。
  4. 买入:点格子(光标为 BUY 时)→ net.SendShopBuyPacket(GetIndexFromSlotPos(pos))
  5. 货币类型(ShopTab.coin_type)与库存计数已在增量 34 实现,保持。
  6. 错误提示一律用服务端 GC_CHAT 文案 + localeInfoSHOP_* 键。

4.7 私人商店是兼容实现

参考POC/assets/root/uiprivateshopbuilder.pyitemStock 模型)+ PRIVATE_SHOP_ITEM_MAX_NUM= TPacketCGMyShop::bCount 上限)。 本工程增量 35/36 已按该文件重写过一次,交互模型(拿起 / 落位 / 价格弹窗 / 排序 + display_pos / 撤下 / 开设 / 撤收)与参考一致。

剩余差距与修改

  1. 拖放改走 §4.2 的全局 mouse_controller,而不是当前的「点选候选面板」。
  2. 开店前检查:参考在 uiprivateshopbuilder.py 里检查 item.IsAntiFlag(item.ANTIFLAG_MYSHOP)ITEM_ANTIFLAG_MYSHOP = 1<<16 REF/GameLib/ItemData.h:204)——当前缺,补上。
  3. 其他玩家查看:GC_SHOPSHOP_SUBHEADER_GC_START_EX 已实现;招牌走 GC_SHOP_SIGN(已实现),但需按 §8.2 换成真 .sub 资源渲染。
  4. 撤下回滚:参考端没有本地回滚——撤下即发包,状态由 GC_SHOP 回包驱动。 现有实现若做了本地乐观更新,删掉。

4.8 快捷栏来源类型不完整

参考

  • 容量(REF/UserInterface/Packet.h:392): QUICKSLOT_MAX_LINE = 4QUICKSLOT_MAX_COUNT_PER_LINE = 8QUICKSLOT_MAX_COUNT = 32QUICKSLOT_MAX_NUM = 36服务器保存 36,客户端只显示 32)。

  • 类型:TQuickSlot{ BYTE Type; BYTE Position; }REF/UserInterface/GameType.h:307), 其中 Type 直接就是 ESlotTypeGameType.h:140)—— CPythonPlayer::RequestUseLocalQuickSlotREF/UserInterface/PythonPlayer.cpp:813GetLocalQuickSlotData() 的原值直接 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_EMOTION4 == SLOT_TYPE_SHOP(快捷栏不可执行)。 也就是说当前把服务端发来的 EMOTION(3) 当成了 command,把不可执行的 SHOP(4) 当成了 emotion——服务端下发的表情快捷栏会被误判,quickslot_add() m2_client.cpp:897type < 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)
    

    数字键 14 走 pressNumber(默认 = __PressQuickSlot), 按住 LSHIFT 时 pressNumber 切换成 __SelectQuickPagegame.py:506)。 F1F4 固定映射到 __PressQuickSlot(4..7)game.py:318)。

当前:界面显示 36 格(4 页 × 9 格)+ F1–F4 + 数字键 1–9;参考端是服务器保存 36 格、 客户端显示 32 格(4 页 × 8 格)。

修改

  1. 数字键改为 14 → 快捷栏 03__PressNumKeygame.py:431 非 Ctrl 时 if 1 <= num <= 4: self.pressNumber(num-1) Ctrl + 1..9 是表情chrmgr.SetEmoticon(-1, num-1) + net.SendEmoticon(num-1))。 当前的「数字键 1–9 全给快捷栏」要改掉。
  2. LSHIFT 按下 → pressNumber = __SelectQuickPage;松开 → pressNumber = __PressQuickSlot game.py:370 / :408)。
  3. 先修类型编号EntityStore / M2Client / quickslot_ui 里的快捷栏 type 一律改用 §4.2 移植的 ESlotTypeSLOT_TYPE_INVENTORY=1 / SKILL=2 / EMOTION=3), 删掉 m2_client.cpp:8783 command / 4 emotion 注释与对应分支; quickslot_add() 的入参校验从 1..4 改为「只接受 1 / 2 / 3」。 这是数据错误,必须先于 UI 工作修复。
  4. 快捷栏类型只保留 INVENTORY / SKILL / EMOTION 三种,SKILL 必须走 §3.8 的 ClickSkillSlot 完整链,不能直接发包;收到其它 ESlotType 一律不执行(与参考的 switch 落空行为一致)。
  5. 边界常量按 §3.9 的 limits.hMoveQuickSlot 改为 swap。
  6. 物品拖入快捷栏:走 §4.2 的 mouse_controllerAttachObjectSLOT_TYPE_QUICK_SLOT 分支会递归解析真实类型(mousemodule.py:218)。
  7. 冷却 / 死亡 / 变身 / 换武器后的刷新由对应回包驱动,不本地计时。

4.9 组队状态是近似定义

参考PARTY_AFFECT_SLOT_MAX_NUM = 7REF/UserInterface/Packet.h:404)。 角色枚举与 Leadership 门槛在本工程增量 43 已按 40250 服务端 CParty::Update 对齐 NORMAL 0 / LEADER 1 / ATTACKER 2 / TANKER 3 / BUFFER 4 / SKILL_MASTER 5 / HASTE 6 / DEFENDER 7;门槛 10/20/20/25/35/40,治疗 18)。

剩余差距与修改

  1. affects[7]槽位语义目前是“暂定映射”。需要从 40250 服务端 game/src/party.cppCParty::ComputeRolePoint / Update 读出每个下标的含义 [待读参考:服务端源码,不在 ClientVS22 内];在拿到之前, UI 上的 chip 名称保留占位并在文档标注,不要臆造名称。
  2. 队伍技能冷却:参考客户端不本地计时,冷却由服务端拒绝 + GC_CHAT 提示体现。 若当前有本地冷却,删除。
  3. 队长权限动态变化:以 get_party() 里本地 VID 那条的 leader 位为唯一判据(已实现), 收到 GC_PARTY_* 后立即重算按钮可用性。
  4. 离线 / 死亡 / 距离状态:来自 GC_PARTY_UPDATE 的字段,接到 PartyMemberInfoBoard 的 对应显示位。

4.10 好友、情侣、公会只有首版状态展示

参考POC/assets/root/uimessenger.py(739 行,分组 / 上线离线 / 右键菜单)、 uiwhisper.py(私聊窗生命周期)、uiguild.py。 公会名长度 GUILD_NAME_MAX_LEN = 12、等级名 GUILD_GRADE_NAME_MAX_LEN = 8、 等级数 GUILD_GRADE_COUNT = 15、公告 GULID_COMMENT_MAX_LEN = 50 REF/UserInterface/Packet.h:408)。

修改

  1. 按 §8 的窗口迁移样板逐窗移植 uimessenger.py / uiwhisper.py / uiguild.py 不自行设计交互。
  2. 公会相关按 CLIENT-GAP.md 的当前范围说明暂缓,但常量先落到 limits.h
  3. 情侣:已有 GC_LOVER_INFO / GC_LOVE_POINT_UPDATElove_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 到当前行;着色 CurrentColoriCurrentLetter += len;超 iRestrictedCharacterCount 换行;lLastDelayTime = lWaitingTime
DELAY 416 startlWaitingTime = atoi(value)end:恢复 c_lNormal_Waiting_Time
COLOR 425 startr/g/batofa=1end:全 1
COLOR256 444 同上但 /255.0f
ENTER 463 __InsertLine
WAIT 469 iyLocal = 0; isLock = true
NEXT 477 MakeNextButton(BUTTON_TYPE_NEXT)iAdjustLine += 2
DONE 484 MakeNextButton(BUTTON_TYPE_DONE) + Python DoneEvent()iAdjustLine += 2
CLEAR / CLEAR_TEXT 492 / 667 ClearLine
QUESTION 498 MakeQuestion(argList)
LEFTIMAGE/TOPIMAGE/BGIMAGE 504/510/516 Python OnLeftImage/OnTopImage/OnBackgroundImage(src)
IMAGE 522 Python OnImage(x, y, src)
INSERT_IMAGE 532 image_nameOnInsertImage(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 递减,<=0ProcessEventSet 一条; isWaitFlag 为真时整体暂停;isConfirmWait 时每帧刷新倒计时文本 m_strLeftTimeString(默认 "남은 시간 : %d초",可由 SetLeftTimeString 改)。

当前POC/project/ui/quest_dialog.gd:102 只支持 8 个标签。

修改

  1. quest_dialog.gdparse_script 换成完整的 EventTypeMap38 项)+ 上表的 ProcessEventSet 分派,逐条实现。
  2. 引入 EventSet 状态:lWaitingTime / lLastDelayTime / iCurrentLetter / iRestrictedCharacterCount / iAdjustLine / iyLocal / isLock / isWaitFlag / isConfirmWait / iConfirmEndTime / isTextCenterMode / CurrentColor,字段名保持一致。
  3. SETMSGPOS / ADJMSGPOS 实现为(与参考一致),不要自行发挥。
  4. MakeNextButton 的三种类型 NEXT / DONE / CANCELMakeQuestion 的选项按钮 按 PythonEventManager.cpp 的对应函数移植 MakeNextButton :878MakeQuestion :884ClearLine :953__InsertLine :976)。
  5. 相机类标签(SET_CAMERA/BLEND_CAMERA/RESTORE_CAMERA)接 §7.6 的相机模式。

5.2 任务日志不是完整 Quest 系统

参考REF/UserInterface/PythonQuest.h + POC/assets/root/uiquest.py926 行)。 任务条目由 EVENT_TYPE_QUEST_BUTTONBINARY_RecvQuest(idx, name, icon_type, icon_name) (§5.1)与 GC_QUEST_INFO 共同驱动;关闭走 BINARY_ClearQuest(idx)

修改

  1. 按 §8 的窗口迁移样板移植 uiquest.pyQuestButton / 图标 / 倒计时 / 定位)。
  2. quest_log.gd:8 的纯文字列表改为读 icon_type + icon_name 图标资源走 UiAssets.load_dds_image
  3. 世界箭头 / 边缘指示对应 EVENT_TYPE_ADD_MAP_SIGNAL + CPythonMiniMap::AddSignalPoint 接到已有的 minimap 实现。

5.3 钓鱼、副本、坐骑和观战者只有部分协议入口

参考要点

  • 钓鱼:CPythonPlayer::NEW_Fishing()REF/UserInterface/PythonPlayerInput.cpp:526
    if (pkInstMain->GetFishingRot(&irot)) SendFishingPacket(irot);
    else PyCall(gameWindow, "OnFishingWrongPlace")
    
    攻击键在钓鱼模式下改为钓鱼:SetAttackKeyStatePythonPlayerInputKeyboard.cpp:6 if (isPress && main->IsFishingMode()) { NEW_Fishing(); return; }。 方向键会 NEW_CancelFishing()PythonPlayerInputKeyboard.cpp:24)。
  • 坐骑:CInstanceBase::MountHorseSetMotionMode(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。
  • 副本:CDungeonBlockREF/GameLib/DungeonBlock.h+ PROPERTY_TYPE_DUNGEON_BLOCK (§7.1);结算走 EVENT_TYPE_DUNGEON_RESULT(§5.1)。
  • 观战:CPythonPlayer::IsObserverMode()——它在 __CanUseSkill()PythonPlayerSkill.cpp:445IsAttackableInstance()InstanceBase.cpp:2151)里都是第一道门

修改

  1. player_controller.gdis_fishing_mode / is_observer_mode / is_hand_mode 三个状态位, 并把上面四处门禁接上。
  2. 钓鱼:攻击键在钓鱼模式改发 CG_FISHING(irot);方向键取消钓鱼; OnFishingWrongPlacelocaleInfo 文案。
  3. 坐骑:GC_MOUNT / mount_vnum 已入 EntityStore;补模型挂载与 MODE_HORSE* 动作模式(§6.4)、CanAttackHorseLevel 限制。
  4. 副本:DungeonBlock 已接入静态地图(§7.1 保持);结算窗按 EVENT_TYPE_DUNGEON_RESULT 的 9 个参数实现。

5.4 PVP、PK 和决斗没有完整规则

参考CInstanceBase::IsAttackableInstance(victim) REF/UserInterface/InstanceBase.cpp:2147)是唯一判据,完整逻辑:

主角且 IsObserverMode()                    → false
自己打自己                                  → false
IsStone():   victim.IsPC()                 → true
IsPC():
    victim.IsStone()                       → true
    victim.IsPC():
        GetDuelMode():
            DUEL_CANNOTATTACK              → false
            DUEL_START                     → __FindDUELKey(self, victim) ? true : false
        PK_MODE_GUILD == GetPKMode() && 同公会 → false
        victim.IsKiller() && !同队          → true
        PK_MODE_PROTECT != GetPKMode():
            PK_MODE_FREE  : victim 非 PROTECT && !同队                  → true
            PK_MODE_GUILD : victim 非 PROTECT && !同队 && 公会不同        → true
        IsSameEmpire(victim):
            IsPVPInstance(victim)                                      → true
            PK_MODE_REVENGE && !同队 && IsConflictAlignmentInstance     → true
        else (不同帝国)                                                 → true
    victim.IsEnemy()                       → true
    victim.IsWoodenDoor()                  → true
IsEnemy():   victim.IsPC() || victim.IsBuilding() → true
IsPoly():    victim.IsPC() || victim.IsEnemy()    → true
否则 false

EPKModesREF/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 0revenge→/pkmode 1guild→/pkmode 4free→/pkmode 2 (本工程增量 38 已实现,与上面的枚举一致,保持)。

名字颜色枚举 ENameColorREF/UserInterface/InstanceBase.h:227): NAMECOLOR_MOB / NPC / PC(+EMPIRE_NUM) / NORMAL_* / EMPIRE_* / FUNC / PK / PVP / PARTY / WARP / WAYPOINT / EXTRA(=FUNC+10) / NUM(=EXTRA+10)

修改

  1. player_controller.gd(或 entity_rules.gd逐行实现 is_attackable_instance(self_vid, victim_vid),不做简化。
  2. 依赖判据补齐:IsKiller()alignment < 0 或 PK 标记)、IsSameEmpire()empire 字段, §2.5 已补)、IsPVPInstance()GC_PVP 关系表)、IsConflictAlignmentInstance() alignment 符号相反)、IsSamePartyMember()get_party())、 GetDuelMode() + __FindDUELKeyGC_DUEL_START 已实现)。
  3. 目标框 / 名字颜色 / 可攻击光标按 ENameColoris_attackable_instance 的结果驱动(§8.8)。
  4. 保护区:IsInSafe() 已在 §3.4 的预约流程里用到,数据来自地形属性 ATTRIBUTE_BANPK[待读参考:REF/GameLib/TerrainImplATTRIBUTE_* 枚举]

6. P1/P2:模型、动画和特效

6.1 本地角色先显示胶囊占位体

参考:参考端没有占位体。加载期由 Python 的加载幕布盖住: PHASE_WINDOW_LOADLoadData(lX, lY)REF/.../PythonNetworkStreamPhaseLoading.cpp:211introloading.py 里显示加载画面;真正的主角实例由 GC_CHARACTER_ADD + GC_CHAR_ADDITIONAL_INFO 走 §0.3 的合流创建; GameWindow.Open()game.py:137)在 PHASE_GAMEShow()

当前game_scene.gd:136 先建 Capsule:811 再换真模型。

修改

  1. 删除 game_scene.gd:136:811 的 Capsule 占位路径。
  2. 主角节点只在收到 Spawn(main_vid)(合流后)时创建一次,位置/朝向直接取包内值。
  3. 在此之前保持 introloading 等价的加载幕布(POC/project/ui 下已有 _loading 层)覆盖, 与 GameWindow.Open()Show() 时机对齐(§2.6 第 2 步)。
  4. 相机目标高度、碰撞体、阴影都挂在真实模型上,不再迁移。

6.2 SpeedTree 仍是程序化代理

参考REF/SpeedTreeLib/SpeedTreeWrapper.{h,cpp} / SpeedTreeForest*.cpp / SpeedTreeMaterial.h / SpeedGrass*),核心 API

LoadTree(pszSptFile, pbBlock, uiBlockSize, nSeed=1, fSize=-1, fSizeVariance=-1)   // .spt
RenderBranches() / RenderFronds() / RenderLeaves() / RenderBillboards()           // 四类几何
GetLeafMaterial() / GetLeafLightingAdjustment()
SetWindStrength(float)

地图侧:CArea::__SetObjectInstance_SetTreeprt::PROPERTY_TYPE_TREE 属性 TPropertyTree{strName, strFileName, fSize, fVariance}REF/GameLib/MapType.h:47); 风强度来自 TEnvironmentData.fWindStrength / fWindRandomMapType.h:154)。

当前POC/extension/src/tree_placeholder.cpp:190 只嗅探贴图 + proxy 几何。

修改

  1. 这是独立的大工作量项:需要实现 .spt 解析器(SpeedTree 5 二进制),产出 branches / fronds / leaves / billboards 四组几何与各自材质。
  2. .spt 解析落地之前,tree_placeholder.cpp 应至少:
    • TPropertyTreefSize / fVariance 并按 nSeed 做确定性缩放(当前缺);
    • 把风强度接到 TEnvironmentData.fWindStrength / fWindRandom.msenv,§7.5)。
  3. PARITY-GAP.md:77 标注为「参考模块未移植」,不要按截图反推参数。

6.3 EffectLib 只有粒子近似

参考CEffectDataREF/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;
  • CLightDataREF/EffectLib/SimpleLightData.h): m_fMaxRange / m_fDuration / m_cAmbient / m_cDiffuse / m_bLoopFlag / m_fAttenuation0 / m_fAttenuation1 / m_fAttenuation2GetRange(fTime, &range) 是时间曲线。
  • CParticlePropertyREF/EffectLib/ParticleProperty.h:12): ERotationType{NONE, TIME_EVENT, CW, CCW, RANDOM_DIRECTION}ETextureAnimationType{NONE, CW, CCW, RANDOM_FRAME, RANDOM_DIRECTION} 多贴图(InsertTexture+ GetTextureAnimationFrameCount()
  • CEmitterPropertyREF/EffectLib/EmitterProperty.h:9): EmitterShape{POINT, ELLIPSE, SQUARE, SPHERE}EmitterAdvancedType{FREE, OUTER, INNER}isEmitFromEdge()GetMaxEmissionCount / GetCycleLength / isCycleLoop / GetLoopCount 以及一组时间曲线 GetEmittingSize / AngularVelocity / DirectionX/Y/Z(fTime, &v)

当前POC/project/fx/effect_player.gd:8 只映射 Particle → GPUParticles3D :134 用程序化渐变代替 .dds.mde 用 box 代替。

修改

  1. .dds 贴图改为真加载——本工程已有 Metin2World.load_dds,把 effect_player.gd:134 的程序化渐变换掉。
  2. .mde 网格特效:新增解析器输出 ArrayMesh,替换 box [待读参考:REF/EffectLib/EffectMesh.{h,cpp}CEffectMeshScript::LoadScript]
  3. SimpleLight:按 CLightData 的 8 个字段生成 OmniLight3D range 走时间曲线,attenuation 三系数映射到 Godot 的衰减近似并在文档记录差异。
  4. 粒子属性补齐:rotation_type / texture_animation_type / 多贴图帧 / emitter shape 与 advanced type / emit_from_edge / cycle & loop。
  5. 骨骼挂点:.mse 挂到角色骨骼由 CActorInstance::AttachEffect [待读参考:REF/GameLib/ActorInstanceAttach.cpp] 驱动,接到本工程的 bone attach。

6.4 GR2 运行时不是完整 ActorInstance

参考

  • 动作模式枚举 CRaceMotionData::EMotionModeREF/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::RefreshStateREF/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)
    
  • 动作事件类型 EMotionEventTypeRaceMotionData.h:160): NONE, EFFECT, SCREEN_WAVING, SCREEN_FLASHING, SPECIAL_ATTACKING, SOUND, FLY, CHARACTER_SHOW, CHARACTER_HIDE, WARP, EFFECT_TO_TARGET
  • 动作数据接口(RaceMotionData.h:216): GetMotionDuration / IsAccumulationMotion / GetAccumulationPosition / IsComboInputTimeData / GetComboInputStartTime / GetNextComboTime / GetComboInputEndTime / isAttackingMotion / GetMotionAttackDataPointer / HasSplashMotionEvent / IsCancelEnableSkill / IsLoopMotion / GetLoopStartTime / GetLoopEndTime / GetMotionEventDataCount / GetEventType / GetEventStartTime / GetSoundInstanceVectorPointer

当前:无 motion mode 概念;loop / interrupt / accumulation / 动作保留未实现。

修改

  1. metin2_model / metin2_anim 引入 motion_mode.msm 解析出 「(motionMode, motionIndex) → 动作文件 + 权重(byPercentage)」的两级表 CRaceData::TMotionModeDataMap / TMotionVectorMapREF/GameLib/RaceData.h:50)。
  2. 实现 refresh_state()逐行照抄上面的优先级链,输入是 is_poly / is_wearing_dress / is_holding_pickaxe / item_type / weapon_subtype / is_mounting
  3. 播放接口对齐三种:InterceptLoopMotion / InterceptOnceMotion / SetLoopMotionfBlendTime(技能固定 0.1f)与 fSpeedRatio
  4. accumulation motion(位移由动作数据驱动)与 SetMotionLoopCount 补上。
  5. 击退 / 倒地 / 起身 / 死亡动作:按 NAME_* 动作名枚举(RaceMotionData.h:40-160)接入。
  6. 武器轨迹 CWeaponTraceREF/GameLib/WeaponTrace.{h,cpp})单列一项。

6.5 武器和特效骨骼挂点不完整

参考CRaceData::EPartsREF/GameLib/RaceData.h:16)只有 5 个:

PART_MAIN, PART_WEAPON, PART_HEAD, PART_WEAPON_LEFT, PART_HAIR, PART_MAX_NUM

挂点名表 TAttachingBoneNameMapRaceData.h:35)与 TModelData{ NRaceData::TAttachingDataVector AttachingDataVector; }:63)来自 .msm。 挂载实现在 REF/GameLib/ActorInstanceAttach.cpp

修改

  1. PART_WEAPON_LEFT 是参考里明确存在的部件——补左手武器 / 双持挂点 (本工程当前只有右手 + 部分盾牌)。
  2. 挂点骨骼名从 .msmTAttachingBoneNameMap 读,不再硬编码 (本工程当前硬编码 equip_right_hand 等)。
  3. 坐骑挂点、骨骼特效(EFFECT_TO_TARGET)按 ActorInstanceAttach.cpp 移植。
  4. 装备切换时清理旧挂点:参考在 ChangeWeapon 里 detach 后再 attach [待读参考:InstanceBase.cppChangeWeapon / __AttachWeapon]

6.6 材质是固定功能近似

参考:材质开关来自 .gr2MaterialInfo(本工程增量 30 已接 alpha_blend / two_sided); 多贴图 stage / color operation 由 CGraphicThing 的渲染路径决定 [待读参考:REF/EterGrnLib/Thing.cppMaterial.cpp 的 stage 设置]。 球面高光来自 item_protobSpecular(本工程已接 specular/100)。

修改

  1. m2_material.h:39spec_power 默认 0 保持——参考端确实由装备数据驱动; 缺的是远端角色也要按其 armor/weapon 的 proto 取 specular(当前只有本地主角)。
  2. alpha-test ref 值、贴图 stage 数、color op 三项在 EterGrnLib 的渲染端读出后再定, 目前不要猜数值。
  3. 透明排序:参考按 CAream_ThingCloneInstaceVector 与角色分批渲染; Godot 侧对应 render_priority + transparency 的固定分层,先在文档记录映射再实现。

7. P1/P2:地图、环境和相机

7.1 静态地图只完整接入 Building / DungeonBlock

参考CAreaREF/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::TObjectDataArea.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 = 24ROTATION_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_DungeonBlockCloneInstanceVectorIsMotionThing() 的 building 会额外进动画桶并 SetMotion(0)isShadowFlag 决定是否进阴影桶。

当前POC/extension/src/metin2_world.cpp:367 只处理 Building / DungeonBlock。

修改

  1. PROPERTY_TYPE_EFFECT:按 TPropertyEffect.strFileName 注册 .mse 并在 对象位置创建常驻特效实例(旋转按 Yaw/Pitch/RollD3DXMatrixRotationYawPitchRoll)。
  2. PROPERTY_TYPE_AMBIENCE:三种播放型 + dwRange + fMaxVolumeAreaPercentage 接 §9.4 的 ambience 声道。
  3. Building 补 isShadowFlagIsMotionThing() → SetMotion(0) 两条分桶规则。
  4. m_fHeightBias 目前是否参与位置计算需核对;abyPortalID / portal / area 关系 [待读参考:REF/GameLib/Area.cppabyPortalID 的消费点]
  5. 对象 LOD 与透明排序按 Refresh() 的分桶来做,不要自定义分层。

7.2 Pack 资源读取没有统一

参考CEterPackManagerREF/EterPack/)是唯一入口, 连 CAccountConnector::__AuthState_RecvAuthSuccessAccountConnector.cpp:465)都要 CEterPackManager::instance().DecryptPackIV(dwPanamaKey)——pack 的解密 IV 来自登录密钥

当前metin2_world.cpp:380terrain_splat.cpp:133 等仍直接 AssetResolver::resolve(),绕过 AssetSource/PackMount

修改

  1. 全量替换:grep -rn "AssetResolver::resolve" POC/extension/src 逐个改为 AssetSource::open()asset_io.cpp 已经把 gr2/dds/map/proto 路由到 FileAccess 把 world / terrain / tree / water / static object 补齐。
  2. AssetResolver 的目录扫描(Phase 2b)改为 AssetSource 的统一枚举接口。
  3. 新增 CI 门禁:MT_ASSETS_PACK_ONLY=1 时禁用散文件回退,跑一遍现有全部 GDScript 测试。
  4. DecryptPackIV(loginKey ^ clientKey[0..3]) 这条参考行为若目标服务端使用,需要在 ClassicSession 收到 GC_AUTH_SUCCESS 后同样调用——当前未实现,先记录。

7.3 地形 splat 仍非完整固定功能复现

参考REF/GameLib/MapOutdoor*.cppMapOutdoorRenderSTP.cpp = 单贴图通道、 MapOutdoorRenderHTP.cpp = 高贴图通道、MapOutdoorIndexBuffer.cpp = patch 索引、 MapOutdoorQuadtree.cpp = LOD/剔除、TerrainPatch.{h,cpp}TerrainQuadtree.{h,cpp})。 Section/Cell 常量(REF/GameLib/MapType.h:216):

c_Section_xAttributeCellSize = 50   c_Section_yAttributeCellSize = 50
c_Section_xCellSize          = 100  c_Section_yCellSize          = 100

修改

  1. 本条是渲染端逐项对照,应在 PARITY-GAP.md 建立子清单,每项列 「参考文件:函数 / 当前实现 / 差异」,包括: alpha 语义、texture wrap/filter、stage 数、patch geometry LOD、skirt、 patch 碰撞、baked shadow mask 叠加。
  2. 在读完 MapOutdoorRenderSTP.cpp / MapOutdoorRenderHTP.cpp 之前不要调数值—— 本文档不给具体参数。
  3. 先落地的是常量对齐:把上面 4 个 Section/Cell 常量核对进 POC/extension/src/terrain_splat.cpp

7.4 水面是序列纹理加近似深度

参考REF/GameLib/MapOutdoorWater.cpp

修改

  1. 同 §7.3:先读 MapOutdoorWater.cpp 建立逐项清单(反射 / 折射 / 岸线泡沫 / 法线 / blend·filter·wrap / 透明排序 / 水深标定),再改实现。
  2. PARITY-GAP.md 里「浅水透明度未校准」的标记保留,直到参考端的水深公式读出为止。

7.5 .msenv 只完成基础天空、雾和光照

参考TEnvironmentDataREF/GameLib/MapType.h:126)的完整字段表

// 光照
BOOL       bDirLightsEnable[2];        // ENV_DIRLIGHT_BACKGROUND, ENV_DIRLIGHT_CHARACTER
D3DLIGHT8  DirLights[2];               // 背景光 / 角色光 分开!
D3DMATERIAL8 Material;

// 雾
BOOL bFogEnable;  BOOL bDensityFog;
float m_fFogNearDistance;  float m_fFogFarDistance;
D3DXCOLOR FogColor;

// 滤色
BOOL bFilteringEnable;  D3DXCOLOR FilteringColor;
BYTE byFilteringAlphaSrc;  BYTE byFilteringAlphaDest;

// 风
float fWindStrength;  float fWindRandom;

// 天空盒
D3DXVECTOR3 v3SkyBoxScale;   BOOL bSkyBoxTextureRenderMode;
BYTE bySkyBoxGradientLevelUpper;  BYTE bySkyBoxGradientLevelLower;
std::string strSkyBoxFaceFileName[6];   // front/back/left/right/top/bottom
std::vector<TGradientColor> SkyBoxGradientColorVector;

// 云
D3DXVECTOR2 v2CloudScale;  float fCloudHeight;
D3DXVECTOR2 v2CloudTextureScale;  D3DXVECTOR2 v2CloudSpeed;
std::string strCloudTextureFileName;  TGradientColor CloudGradientColor;

// 镜头光晕
BOOL bLensFlareEnable;  D3DXCOLOR LensFlareBrightnessColor;  float fLensFlareMaxBrightness;
BOOL bMainFlareEnable;  std::string strMainFlareTextureFileName;  float fMainFlareSize;

关键差异:参考端背景光和角色光是两盏独立方向光ENV_DIRLIGHT_BACKGROUND / ENV_DIRLIGHT_CHARACTER),本工程当前只有一盏。

修改

  1. environment_builder.cpp 解析并落实上表全部字段;TEnvironmentData 的 字段名逐个对应,缺哪个补哪个。
  2. 两盏方向光分别作用于「地形/建筑」与「角色」层——Godot 侧用两个 DirectionalLight3D + cull_mask 分层实现。
  3. 云层用 strCloudTextureFileName + v2CloudScale/TextureScale/Speed + fCloudHeight + CloudGradientColor 真实渲染,不再省略。
  4. lens flare 按 bLensFlareEnable / LensFlareBrightnessColor / fLensFlareMaxBrightness / bMainFlareEnable / strMainFlareTextureFileName / fMainFlareSize 实现。
  5. 滤色(bFilteringEnable + src/dest alpha blend 因子)是全屏后处理,补上。
  6. 昼夜插值:参考按 TEnvironmentDataMapMapType.h:187DWORD → TEnvironmentData* 在多个环境之间切换;[待读参考:REF/GameLib/MapOutdoor.cpp 中该 map 的键与插值方式]
  7. 环境循环音属 §7.1 的 PROPERTY_TYPE_AMBIENCE,不在 .msenv 里。

7.6 相机不是固定基线机位

参考

  • 默认机位(POC/assets/root/game.py:58GameWindow.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_kTargetToCameraBottomRaym_kCameraBottomToTerrainRay 命中即 SetCameraState(CAMERA_STATE_CANTGODOWN) 并把 eye 抬到 碰撞点 + 2.0f * m_fTerrainCollisionRadius * m_v3Up(只在抬高时才 SetEye)。 未命中则 SetCameraState(CAMERA_STATE_NORMAL)
  • 建筑碰撞(:125 ProcessBuildingCollision): fMoveAmountSmall = 2.0f / fMoveAmountLarge = 4.0f,用 CameraCollisionChecker + CollisionDynamicSphere 逐对象判定。
  • 键位(POC/assets/root/game.py:340): Q/E → RotateCamera(NEGATIVE/POSITIVE)R → ZoomCamera(NEGATIVE)F → ZoomCamera(POSITIVE)Ctrl+F 是喂马)、T/G → PitchCamera(NEGATIVE/POSITIVE) 松键一律 CAMERA_STOP;小键盘 4/6/8/2 + PGUP/PGDN 是 Movie 相机。

当前POC/project/game_camera.gd 用脚本内 FOV/yaw/pitch/距离 + AABB 射线。

修改

  1. 默认值改为 distance 1550 / pitch 27 / rotation 0 / height 100 夹在 [CAMERA_MIN_DISTANCE 200, CAMERA_MAX_DISTANCE 2500]。 (§8.4 的「远/近视角」两个 radio 也要落在这个范围内。)
  2. 实现三种模式 NORMAL / STAND / BLENDBLEND 用线性插值三参数; SetDefaultCamera() 恢复默认设置——这三者是 §5.1 的 SET_CAMERA / BLEND_CAMERA / RESTORE_CAMERA 标签的落点。
  3. 地形碰撞按上面两条射线 + 2 * m_fTerrainCollisionRadius 的抬高规则重写, 替换现有 AABB 射线。
  4. 建筑碰撞用 2.0f / 4.0f 两个位移量的球体检测。
  5. 相机参数跨进出图保持(game.py:225 的全局回写语义)。
  6. 键位按上表补齐 Q/E/R/F/T/G 与松键停止。

8. P1/P2UI、输入和本地化

8.1 uiscript 可解析不等于控件语义完成

参考:控件实现在 REF/EterPythonLib/PythonWindow*.cppPythonGraphic*.cppPOC/assets/root/ui.py(本仓已有)。ui.py 是权威的控件语义定义。

修改

  1. 逐个补齐控件,顺序按使用频度: ani_image / mark / grid_table / list / scroll / slotbar / thinboard / expanded_image(nine-patch) / titlebar / radio_button / toggle_button
  2. 每个控件的属性名、默认值、事件名以 ui.py 的类定义为准,不自行命名。
  3. SetTop / OnTop / focus / modal / layer 的语义取 ui.pyWindow.SetTop / wndMgr 相关调用。
  4. nine-patchui.pyExpandedImageBox + .sub 资源的 rect 定义。

8.2 大量窗口是手工 Panel

参考Interface.MakeInterface()POC/assets/root/interfacemodule.py)逐个 LoadDialog(),每个窗口用 ui.PythonScriptLoader().LoadScriptFile(self, "UIScript/xxx.py")

修改

  1. 沿用本工程已验证的迁移样板(char_status_ui.gd / select_item_ui.gd / system_option_ui.gd / private_shop_ui.gd / party_ui.gd / system_menu_ui.gd / game_option_ui.gd):UiScript + UiBuild 装真 uiscript + 逐字绑定同名 ui*.py
  2. 待迁清单(按 interfacemodule.pyMakeInterface 顺序): exchangedialog.py(§4.5)、safeboxwindow.py(§4.4)、shopdialog.py(§4.6)、 guilddialog.pyquestdialog.py(§5.2)、messengerwindow.pychatwindow.pyhelpwindow.py(§8.3)、inventorywindow.pyequipmentdialog.pytooltip(§4.3,非 uiscript)。
  3. 每迁一个窗口配一个 *_ui_test.gd,覆盖「装载 + 关键绑定 + 原聊天命令/封包」。
  4. 分辨率缩放与 .sub 资源、字体描边按 ui.py 的实现,不自定义。

8.3 帮助窗口未实现

参考POC/assets/root/uihelp.py136 行)+ uiscript helpwindow.py。 入口:game.py:459 __PressHKey() —— 非 Ctrl 时 self.interface.OpenHelpWindow() Ctrl+H 是 /user_horse_ride)。

当前POC/project/ui/system_menu_ui.gd:91 只显示占位提示。

修改

  1. 新建 POC/project/ui/help_ui.gd,按样板装 helpwindow.py uiscript, 内容绑定逐字对齐 uihelp.py
  2. H 键(非 Ctrl)与 ESC 菜单的 help_button,替换 system_menu_ui.gd:91 的占位。
  3. help_ui_test.gd

8.4 系统设置部分只是保存数值

参考POC/assets/root/uisystemoption.py(本工程增量 36 已按其绑定)。 其中 CPU/GPU tiling 对应 D3D 的分块渲染开关,Godot 无对应物。

修改

  1. CPU/GPU tiling:参考行为是切换渲染路径,Godot 渲染器没有等价物。 保留控件但明确标注为「参考功能无 Godot 对应」,并在 CLIENT-GAP.md 记为 (超出移植范围),不要伪造行为。
  2. BGM 更换按钮:参考对应 uiselectmusic.py(本仓已有)—— 按 §8.2 迁移该窗口后启用按钮,接 musicInfo.SaveLastPlayFieldMusic / LoadLastPlayFieldMusicPOC/assets/root/musicinfo.py)与 game.py:2217 __PlayMusic(flag, filename)
  3. 名字颜色 / 伤害数字 / 总是显示名字:接到 §8.8 的 TextTail 渲染, 开关键名沿用现有 user://system_option.cfg [gameopt]
  4. 雾等级:现有 [0.055/0.018/0.004] 是本工程标定值,参考端是 TEnvironmentDatam_fFogNearDistance/m_fFogFarDistance/bDensityFog(§7.5)—— 改为按环境数据的近远距离设置,浓/中/淡对应三档缩放 [待读参考:uisystemoption.py__SetFogLevel 实参]

8.5 鼠标物品态和光标状态缺失

参考:光标枚举 CPythonApplication::ECursorShapes REF/UserInterface/PythonApplication.h:63):

NORMAL, ATTACK, TARGET, TALK, CANT_GO, PICK, DOOR, CHAIR, MAGIC, BUY, SELL,
CAMERA_ROTATE, HSIZE, VSIZE, HVSIZE, COUNT

资源(REF/UserInterface/Cursors/Python 侧映射见 mousemodule.py:100):

NORMAL→cursor.sub   ATTACK/TARGET→cursor_attack.sub   TALK→cursor_talk.sub
CANT_GO→cursor_no.sub  PICK→cursor_pick.sub  DOOR→cursor_door.sub
CHAIR/MAGIC→cursor_chair.sub  BUY→cursor_buy.sub  SELL→cursor_sell.sub
CAMERA_ROTATE→cursor_camera_rotate.sub
HSIZE→cursor_hsize.sub  VSIZE→cursor_vsize.sub  HVSIZE→cursor_hvsize.sub

软件光标开关:systemSetting.IsSoftwareCursor()mousemodule.py:98)。 SetCursorNum / SetCursor 的调用点:商店 BUY/SELL(§4.6)、 NPC 悬停 TALK、可攻击目标 ATTACK 等。

修改

  1. 新建 POC/project/ui/cursor_manager.gdset_cursor(shape) / get_cursor() 枚举与资源映射按上表;资源用 .subUiAssets)。
  2. 鼠标跟随物品图标由 §4.2 的 mouse_controller 负责,与光标是两层。
  3. 悬停实体时的光标:可攻击 → ATTACKNPC → TALK;地面物品 → PICK 不可达 → CANT_GO——判据用 §5.4 的 is_attackable_instance 与实体类型。
  4. 窗口焦点屏蔽:窗口下的鼠标事件不穿透到世界(wndMgr 的语义)。

8.6 键位配置不完整

参考补充ClientVS22/source/ 不含完整 Python 键位脚本;下表来自本仓迁移资产 POC/assets/root/game.py:309-408,只能作为历史脚本行为依据,仍需用当前目标构建和实际 输入验收确认。逐条:

数字 1..9   → __PressNumKey(n)
              Ctrl+1..9 → chrmgr.SetEmoticon(-1, n-1) + net.SendEmoticon(n-1)
              否则 1..4 → pressNumber(n-1)             (§4.8
F1..F4      → __PressQuickSlot(4..7)
LALT 按/放  → ShowName() / HideName()
LCONTROL    → ShowMouseImage() / HideMouseImage()
SYSRQ       → SaveScreen()
SPACE 按/放 → StartAttack() / EndAttack()
方向键/WASD → MoveUp/Down/Left/Right,松键 StopUp/Down/Left/Right
              D 键:Ctrl+D → 龙魂 deck 1;否则 MoveRight
Q / E       → RotateCamera(NEGATIVE / POSITIVE),松键 CAMERA_STOP
              Ctrl+Q → 切换所有 QuestButton 显示
R           → ZoomCamera(NEGATIVE),松键 STOP
F           → ZoomCamera(POSITIVE)Ctrl+F → "/user_horse_feed"
T           → PitchCamera(NEGATIVE),松键 STOP
G           → 若 ShowNameFlag 则 ToggleGuildWindow(),否则 PitchCamera(POSITIVE)
              Ctrl+G → "/ride";松键 CAMERA_STOP
NUMPAD 9/4/6/8/2, PGUP, PGDN → Movie 相机
GRAVE / Z   → PickUpItem()
C           → Ctrl+C → 龙魂 deck 0;否则 ToggleCharacterWindow("STATUS")
V           → ToggleCharacterWindow("SKILL")
N           → ToggleCharacterWindow("QUEST")
B           → Ctrl+B → "/user_horse_back";否则 ToggleCharacterWindow("EMOTICON")
H           → Ctrl+H → "/user_horse_ride";否则 OpenHelpWindow()      (§8.3
J           → Ctrl+J → 骑乘中 "/unmount",否则在背包里找 71114/71116/71118/71120 使用
I           → ToggleInventoryWindow()
O           → ToggleDragonSoulWindowWithNoInfo()
M           → interface.PressMKey()
ADD/SUBTRACT→ MiniMapScaleUp / MiniMapScaleDown
L           → ToggleChatLogWindow()
COMMA       → ShowConsole()
LSHIFT 按/放→ __SetQuickPageMode() / __SetQuickSlotMode()             (§4.8

当前:只有 WASD、点地、相机拖动、滚轮、触屏、F1–F4、数字键 1–9。macOS 上物理 F1–F12 是否直接产生 KEY_F1KEY_F12 还受系统“将 F1、F2 等键用作标准功能键”设置或 Fn 键影响, 这是平台验收条件,不应写成 ClientVS22 的额外键位功能。

修改

  1. game_scene.gd 的输入表按上面逐行补齐,包括每个 Ctrl 组合分支。
  2. SaveScreen()(SYSRQ)实现正式截图,替代现有的调试截图。
  3. StartAttack/EndAttack(空格)接 §3.4 的 m_isAtkKey 连续攻击。
  4. ShowName / HideNameLALT)接 §8.8 的 TextTail 显示开关。
  5. 键位重绑定 / 冲突检测:参考端没有——不要实现,若需要另立需求。
  6. 相机灵敏度持久化:参考端也没有独立设置(相机参数持久化见 §7.6 第 5 点)。

8.7 CJK 输入不等价

参考REF/UserInterface/PythonIME.{h,cpp},接口:

Create(HWND) / MoveLeft / MoveRight / MoveHome / MoveEnd / SetCursorPosition / Delete
OnTab / OnReturn / OnEscape / OnWM_CHAR(wParam, lParam) / OnUpdate / OnChangeCodePage
OnOpenCandidateList / OnCloseCandidateList / OnOpenReadingWnd / OnCloseReadingWnd

即:候选窗(candidate list)、读音窗(reading window)、代码页切换(OnChangeCodePage 与 §1.10 的 LocaleService_GetCodePage() 联动)都由客户端自绘。

修改

定位macOS / Godot 可以用原生 IME 取代 Windows IMM32 实现(不必自绘候选窗), 但必须保持参考端的输入行为——不能因为换了实现就默认等价。以下逐项验证后才算完成:

  1. 组合文本(composition:未确认的组合串要显示在输入位置,且不计入已提交文本; 对应 OnWM_CHAR / OnUpdate
  2. 候选词与候选窗:候选列表可见、可翻页、可用数字键选中;候选窗位置跟随光标, 不被游戏窗口遮挡;对应 OnOpenCandidateList / OnCloseCandidateList / OnOpenReadingWnd / OnCloseReadingWnd
  3. 焦点切换:焦点离开输入框时组合串的处置(提交还是丢弃)要与参考一致; 窗口失焦 / 切窗时不残留候选窗。
  4. 光标与编辑MoveLeft / MoveRight / MoveHome / MoveEnd / SetCursorPosition / Delete 六个操作在含多字节字符时按字符移动,不能按字节。
  5. 长度限制:见 §1.10——上限是目标字段的字节数(角色名 24、聊天 128、 公会名 12 …),且不得在多字节字符中间截断;组合中的未确认串也要参与上限计算。
  6. 代码页OnChangeCodePage 对应 LocaleService_GetCodePage(); locale 切换后输入与显示都要跟着换(§1.10)。
  7. OnTab / OnReturn / OnEscape:聊天框 Tab 切换频道、Return 发送、Escape 关闭—— 按 uichat.py 的绑定;这三个键在候选窗打开时必须优先给 IME,不能被窗口吃掉。

上述任一项不满足,本条不得标 CLIENT-GAP.md 里也不应写成「平台差异,等价即可」。


8.8 聊天和头顶信息不完整

参考

  • 聊天:REF/UserInterface/PythonChat.{h,cpp} + POC/assets/root/uichat.py CHAT_MAX_NUM = 128Packet.h:383)。 InsultCheckerREF/UserInterface/InsultChecker.cpp)做脏词过滤。
  • 头顶信息:REF/UserInterface/PythonTextTail.{h,cpp}——名字 / 称号 / 公会名 / 伤害数字 / 聊天气泡都在这里。
  • 名字颜色:ENameColorREF/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.hPacket.h]

修改

  1. 按 §8.2 迁移 uichat.pynotice / big notice / 频道过滤 / 历史滚动 / 私聊窗生命周期)。
  2. 新建 text_tail.gd 移植 PythonTextTail:名字 / 称号 / 公会名 / 伤害数字 / 聊天气泡 / 屏幕空间血条,显示开关接 §8.6 的 LALT 与 §8.4 的三个 radio。
  3. 名字颜色按 ENameColor 的完整枚举实现,输入是 §2.5 补齐的 ch_type / empire / guild / alignment / pk_mode 与 §5.4 的可攻击判定。
  4. InsultChecker 若目标服务端不需要,可标 ,但要在 CLIENT-GAP.md 明示。

8.9 任务日志图标仍是占位

见 §5.2,同一条修改。POC/project/ui/quest_log.gd:8 的文字列表在 EVENT_TYPE_QUEST_BUTTON 补齐 icon_type / icon_name 后即可接真图标。


9. P1/P2:音频和应用生命周期

9.1 地图 BGM 字段被解析但没有消费

参考

  • 解析与存储(REF/UserInterface/PythonNetworkStreamPhaseLoading.cpp):
    RecvMainCharacter3_BGM()      :244  → __SetFieldMusicFileName(mainChrPacket.szBGMName)      // :261
    RecvMainCharacter4_BGM_VOL()  :271  → __SetFieldMusicFileInfo(szBGMName, fBGMVol)              // :288
    
    // 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:190GameWindow.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 名与音量。

修改

  1. ClassicParser 保存 field_music_name / field_music_volume,默认音量 1.0f/5.0f*0.1f = 0.02fM2Client 暴露 get_field_music_name() / get_field_music_volume()
  2. game_scene.gd 进图时按 game.py:190 的三行逻辑选 BGM(服务端名优先, 否则 musicInfo.fieldMusic),并先 SetMusicVolume/SetSoundVolume
  3. musicinfo.gdMETIN2THEMA / loginMusic / createMusic / selectMusic / fieldMusic 五个常量与 save_last_play_field_music / load_last_play_field_music (文件路径 user://BGM/lastplay.inf)。
  4. 换图(§1.3)走同一条选曲逻辑。

9.2 后台恢复后可能没有 BGM

参考:参考端是 Windows 桌面客户端,没有 Android/iOS 的前后台切换语义—— 本条没有 1:1 参考。最接近的参考行为是 CSoundManager::SaveVolume() / RestoreVolume()REF/MilesLib/SoundManager.h:31), 它保存/恢复 m_fBackupMusicVolume / m_fBackupSoundVolume

修改(按参考的 Save/Restore 语义,而不是 stop/replay

  1. POC/project/app_lifecycle.gd:67_enter_background()_bound_audio.stop_bgm() 改为 _bound_audio.save_volume() + 静音 (等价 SaveVolume()SetMusicVolume(0))。
  2. _exit_background()app_lifecycle.gd:75)加 _bound_audio.restore_volume()
  3. Audio 增加 save_volume() / restore_volume(),字段名对齐 m_fBackupMusicVolume / m_fBackupSoundVolume
  4. 这样 _bgm_name 不再被清空,曲目继续播放,恢复后音量还原。

9.3 生命周期所有权重复

参考:唯一协调者是 CPythonApplication REF/UserInterface/PythonApplication.cpp + PythonApplicationEvent.cpp), CPythonNetworkStream 只是被它驱动的一个子系统。

修改

  1. 确立 AppFlow 为唯一生命周期协调者(等价 CPythonApplication): AppLifecycleAppFlow 创建并持有,GameScene 不再自建。
  2. M2Client 删除自己的 NOTIFICATION_* 处理,只保留被调用的 suspend() / resume()
  3. 暂停 / 恢复 / 内存警告 / 关闭四条路径各只有一个入口,写进 POC/docs/CLIENT-PORT.md 的生命周期小节。

9.4 MilesLib 事件模型没有迁移

参考CSoundManagerREF/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 才是音频引擎坐标。

角色声音去重(PlayCharacterSound3DSoundManager.cpp:380,仅当 bCheckFrequency):

static float s_fLimitDistance = 5000*5000;      // 距离平方上限
dx² + dy² > s_fLimitDistance                → 不播
同一文件距上次播放 < 0.3f 秒                 → 不播
否则记录 m_PlaySoundHistoryMap[file] = now

当前:只有 BGM crossfade、UI 音效池和通用 3D 音效。

修改

  1. POC/project/audio.gd 按上表补齐 API 与常量: SOUND_SCALE = 200.0FADE_SPEED = 0.016CHAR_SOUND_MIN_INTERVAL = 0.3CHAR_SOUND_LIMIT_DIST_SQ = 5000*5000
  2. 3D 音效位置一律除以 SOUND_SCALE
  3. 音量换算函数按 __ConvertRatioVolumeToApplyVolume 的分段公式实现 r < 0.1 线性,否则 pow(10, r - 1)),grade 除以 5 转 ratio system_option_ui.gd 的音量滑条改走这个换算(当前是线性)。
  4. 三类声道分开:普通 3DPlaySound3D)、环境(PlayAmbienceSound3D 有独立的 m_fAmbienceSoundScale)、角色(PlayCharacterSound3D + 去重)。
  5. .msa 的 sound 事件走 UpdateSoundInstance(x,y,z,frame,vector,bCheckFrequency=TRUE) (角色动作声音一律开去重)。
  6. 音乐通道支持 FADE_LIMIT_OUT(降到某音量而不是 0)与 FadeOutAllMusic
  7. .mss 声音脚本:CEffectData::LoadSoundScriptDataCRaceMotionData::GetSoundScriptFileName()REF/GameLib/RaceMotionData.h:220 [待读参考:REF/MilesLib/SoundData.cpp.mss 解析]

10. 发布和验证问题

本节没有 ClientVS22 参考(它是 Windows / VS2022 工程),属于本工程的工程化要求。 因此这里只写可执行的动作,不假装有 1:1 依据。

10.1 Mac App 不是自包含资源包

修改

  1. build-macos-client.sh 里把 assets / bgm符号链接改为 ditto 复制进 Contents/Resources/(或打成 .epk/.eix 后复制)。
  2. AssetRoot.path() 在 macOS 上优先解析 Contents/Resources/assets MT_ASSETS 仍可覆盖(开发用)。
  3. 验收:把 .app 拷到另一台机器(或 mv 掉工作区)后能启动进图。

10.2 主程序和 GDExtension 架构不一致

修改

  1. build.sh / CMakeLists.txt 为 macOS 增加 x86_64 目标, 用 lipo -create 合成 universal .dylib
  2. *.gdextensionmacos.debug/release 指向该 universal 库。
  3. 验收:lipo -info 显示 x86_64 arm64Godot 主程序也是 universal。

10.3 当前是 Ad-hoc 签名

修改

  1. codesign --force --options runtime --sign "Developer ID Application: …".app 内所有 .dylib 与主可执行文件逐个签名(先内后外)。
  2. xcrun notarytool submit --wait 提交公证,xcrun stapler staple 装订。
  3. 验收:spctl -a -vvv -t install <app> 通过。

10.4 发布构建仍包含 Demo 默认账号密码

当前POC/project/app_flow.gd:32-33

var _id := "admin"
var _pw := "123456789"

修改

  1. 两行改为空串。
  2. 自动登录只在 MT_AUTOLOGIN=1MT_ACCOUNT / MT_PASSWORD 环境变量存在时生效 app_flow.gd:37auto_login 保留,凭据不再硬编码)。
  3. 加一条 CI 检查:release 预设的 PCK 里 grep -c "123456789" 必须为 0。

10.5 补丁器 / Logo / Movie / Web / Discord / 反作弊没有发布决策

参考侧的事实(用于做决策,不是要求实现):

  • REF/UserInterface/PythonApplicationLogo.cppMovieMan.cppPythonApplicationWebPage.cpp + REF/CWebBrowser/REF/Discord/ ENABLE_DISCORD_RPCREF/UserInterface/Locale_inc.h:6注释掉的)、 CheckLatestFiles.cpp(补丁器)。
  • 反作弊四件套见 §1.6,已在不做清单。

修改

  1. CLIENT-GAP.md 的不做清单里逐项定性: Logo / Movie / Web / Discord(参考默认关闭)→ 补丁器(CheckLatestFiles)→ 单独立项或
  2. 决策写进 CLIENT-ROADMAP.md,避免每轮审计重复出现。

10.6 缺少真实验收门禁

修改:建立 POC/docs/ACCEPTANCE.md,每项写「前置条件 / 步骤 / 通过判据」:

  1. 40250 真服矩阵:登录 → 选人 → DirectEnter → 进图 → 换图(§2.7)→ 登出。
  2. 双账号:交易(§4.5)、组队(§4.9)、PVP(§5.4)。
  3. 断线重连:拔网线 → 回登录页(§1.2)。
  4. 纯 pack 运行:MT_ASSETS_PACK_ONLY=1(§7.2)。
  5. Intel Mac(§10.2)。
  6. 受控机位截图:地图 / 机位 / 角色 / 时间 / 天气 / 分辨率契约 (见 PARITY-GAP.md §0,该门禁已存在,保持)。
  7. 移动端真机性能。

附录 A:本次审计对 CLIENT-GAP.md 的订正

对照参考源码后,CLIENT-GAP.md2026-09-02 审计节)有以下条目需要订正或加限定。 订正分三档:❶ 参考端行为与原文不符 · ❷ 原文方向对但表述需加限定 · ❸ 原文低估了差距(当前实现另有问题)。

原条目 订正后的说法 依据
协议覆盖审计 ❶+❸ Packet.h 是按 To Server / From Server 分段的全局 enum,不是 namespace CG/GC。主文档此前把 m2dev wire.h 与默认 Classic 混算,并把历史统计写成当前结论;应按 MT_PROTOCOL=classicMT_PROTOCOL=m2dev 分开。当前默认 Classic 的参考编号是 CG_CHARACTER_CREATE=4CG_MYSHOP=55CG_WARP=65CG_FISHING=82CG_DUNGEON=110,而不是 0x02010x08020x0B01 等 m2dev 编号。 REF/UserInterface/Packet.h:25POC/extension/src/net/classic/wire_classic.h:104POC/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:49REF/UserInterface/Packet.h:88-93
拍卖行 默认构建未见完整 UI / handler,但 __AUCTION__ 条件下仍有 CG_AUCTION_CMD=205 和 GC 列表包 =206;当前 40250 默认服务配置关闭该宏,因此当前不做。不能写成全源码 / 协议 / 资产零命中。 REF/UserInterface/Packet.h:158-160,354-35640250/Server/.../service.h:8
CG_HACK 参考编号是 105 (0x69),当前 Classic 也使用该值;不做决定可以保留,但编号必须更正。 REF/UserInterface/Packet.h:134-136POC/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-19740250/Server/.../packet.h:150-153
GC_ITEM_DROP ClientVS22 保留 GC_ITEM_DROP=23 声明但没有注册 / 消费,实际掉落流程使用 GC_ITEM_GROUND_ADD=26。应标记为“保留声明 / 未消费”,不能宣称两个包逐字节等价。 REF/UserInterface/Packet.h:199-202REF/UserInterface/PythonNetworkStreamPhaseGame.cpp:332
§1.2 参考端确实没有重连遮罩 / 倒计时,OnRemoteDisconnect() 只调 SetLoginPhase()。但「删掉 reconnect_ui.gd」只是严格 1:1 时的选择,不是必然修复;原先的断线信号链路缺陷已在本轮第一批修复:已有游戏连接的 Classic 断开现在发 disconnectedAppFlow 无条件回 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:1022REF/UserInterface/PythonSkill.cpp:1833
§4.8 ❶+❸ 参考端可执行的快捷栏类型只有 INVENTORY / SKILL / EMOTION,没有「命令类型」——这一半正确。但原文漏了当前实现的新问题m2_client.cpp:8783 注释/使用为 command4emotion,而参考的 TQuickSlot.Type 直接就是 ESlotTypeSLOT_TYPE_EMOTION == 34SLOT_TYPE_SHOP)。当前类型编号与参考端不兼容;同时,键位表来源于本仓迁移资产,不能伪称为 ClientVS22/source 中的 Python 文件。 REF/UserInterface/PythonPlayer.cpp:813-841REF/UserInterface/GameType.h:140,307POC/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 属实,保留。参考这份 ClientVS22Locale_inc.h = LOCALE_SERVICE_SINGAPORELSS_EUROPE)发 HEADER_CG_CLIENT_VERSION2 = 0xf1 + "1215955205";当前实现发 0xfd 却用了 0xf1 分支的 timestamp。另外当前 filename 固定 "metin2.bin",参考端用实际可执行文件名GetExcutedFileName + NoPath)。 PythonNetworkStreamPhaseGame.cpp:4155Locale.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.henum 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.cppChangeArmor/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/TerrainImplMapType.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.cppMaterial.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_dirtybgm_changedbgm_director.gd、§8.6 键位入口);真实双连接和部分生命周期仍待验收
G1 接口集成 汇合并行工作流的共享改动 C++ 扩展构建、受影响原生测试、Godot 回归通过;无未登记共享文件冲突 待 Phase 1W1/W2/W3/W44 个并行工作流提交后执行
G2 真实 / 发布验收 验证参考链路与可交付 Mac App 40250 真实连接 / 换图、适用的双账号玩法、资源包、架构、签名和验收记录齐全 未开始;受限环境 skip 不算通过

C.2 可并行工作流

工作流 对应条目 本批次可独立交付 依赖 集成时必须提供
W1 协议与账号会话 §1、Classic wire / parser、登录、选人、DirectEnter、断线 wire 尺寸 / parser、错误处理、loopback、账号阶段测试 G0 的会话接口;真实服务端不阻塞本地开发 包字节证据、阶段转换、unknown / unhandled 处理、真实验收缺口
W2 实体、世界与战斗 §2–§3、移动、动作、技能、目标、换图 EntityStore / NetWorld / PlayerController、动作 fixture、战斗与实体测试 G0 的实体快照和 world_reset 节点生命周期、坐标 / 状态语义、受影响测试和性能风险
W3 玩法 UI、输入与本地化 §4–§5、§8、背包、任务、社交、IME、键位 独立 UI、输入映射、CJK / 键位测试、fixture 驱动回归 M2Client 公开信号;无真服时可用 fixture 对应窗口状态、发送包、平台差异和视觉未验收项
W4 渲染、资产与音频 §6–§7、§9、模型、特效、地图、材质、天气、BGM 单项渲染 / 资产解析、效果和音频生命周期测试 G0 的场景 / 地图钩子;可用最小资源 参考参数、资产路径、帧 / 内存风险、地图切换清理证据
W5 打包、平台与验收 §10、Mac App、架构、签名、发布矩阵 debug / release 打包、资源自包含检查、签名和启动检查 可与 W1–W4 并行;G2 消费集成产物 包路径、架构、签名、运行日志和失败原因

C.3 当前进度初始化

工作流 当前状态 已有证据 下一批次
W0 公共基础 / 集成 🟡 开发中 DirectEnter、Classic Warp endpoint、world_reset、场景重建首版;增量 49 接口冻结批次(Entity 字段 + mut_spawn_full/mut_ownership/mut_map_bgm/drain_dirty + bgm_changed 信号 + bgm_director.gd + §8.6 键位入口)已提交,C++ ctest 16/16 + 9 项 GDScript 回归通过 接收 Phase 1 W1/W2/W3/W4 批次并执行 G1
W1 协议 / 账号会话 🟡 开发中 角色槽地址透传、发送结果、unknown / unhandled 错误链路、断线回 LOGINm_pending_actor 暂存表成员就位 §1.5 真实版本包、§1.10 定长文本编码、§2.2 两包合并逻辑
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/4BGM
改动:
  extension/src/net/entity_store.h/.cpp —— Entity +empire/affect_flags/owner_vid/state_flags
    +mut_spawn_full()/mut_ownership()/mut_map_bgm()/drain_dirty()/take_bgm_dirty()/
    bgm_name()/bgm_volume()mut_char_info 落 empirereset_for_map_change 清 m_dirty
  extension/src/net/m2_client.cpp —— +bgm_changed 信号;两条 pump 回路消费 drain_dirty/
    take_bgm_dirtyentity_dict() 补 4 键
  extension/src/net/classic/classic_parser.h/.cpp —— BGM 名/音量路由到 mut_map_bgm
    +m_pending_actor 成员
  project/bgm_director.gd(新)、project/game_scene.gd —— 装配 BgmDirector;§8.6 键位入口
测试:cmake --build build 通过;ctest 16/16netbridge_test、gamescene_test、p9_test、
  netplay_test、system_menu_ui_test、skill_test、combat_fx_test、skill_fx_test、
  player_motion_test 全绿;无 skip
验收:未验证(无真实 40250 服务端;BGM / 两包合并需服务端数据)
风险与依赖:mut_spawn_full 目前无调用方(W2 §2.3 接入);m_pending_actor 未使用
  W1 §2.2 接入);bgm_director 仅透传曲目名,未解析地图→曲目(W4 §9.1)
下一批次:Phase 1 —— W1(§1.5/§1.10/§2.2)、W2(§2.1/§2.3/§2.4/§2.5)、W3(§8.3)、
  W4(§9.1/§9.2/§9.3) 在各自 worktree 并行;Phase 2 由 W0 执行 G1 集成与文档合并