Metin2 game client (P0–P11) + mobile asset pipeline
Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
phases, EntityStore world model, ~all GC/CG headers. char create/delete,
private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
token), system-option + game-option + ESC system menu, private-shop 39-grid,
party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.
Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.
Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).
ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
# 客户端移植 —— 从素材渲染器到能联机的客户端
|
||||
|
||||
> 起点:`mtgodot-poc` 的渲染纵切「差不多能用」(见 [`PARITY-GAP.md`](./PARITY-GAP.md))。
|
||||
> 本文是把它变成**能连服务端玩**的客户端的分层计划。分水岭是**网络层**——别的玩家、
|
||||
> 物品、战斗、聊天都以封包为数据源。
|
||||
>
|
||||
> 玩法 / 客户端系统层面「原始客户端有、这里还没有」的完整差距清单见
|
||||
> [`CLIENT-GAP.md`](./CLIENT-GAP.md)。
|
||||
|
||||
## 0. 目标服务端
|
||||
|
||||
- LAN:`192.168.21.203`(这台 Mac 能路由到,但握手端口目前 **connection refused** ——
|
||||
服务端进程没起 / 端口和默认不同,需确认)。
|
||||
- 默认端口(`assets/root/serverinfo.py`):auth `11000`,频道/游戏 `11011/11021/11031/11041`。
|
||||
- 测试账号:`admin` / `123456789`。
|
||||
|
||||
## 1. 协议(本 m2dev fork,**不是老 Metin2**)
|
||||
|
||||
### 1.1 帧格式
|
||||
|
||||
每个包 = `[uint16 header LE][uint16 length LE][payload...]`,`length` = 含 4 字节前缀的整包长度
|
||||
(≥ 4)。包间的 0 字节是加密对齐 padding,跳过。header 是 `CG::`/`GC::` 命名空间常量
|
||||
(`UserInterface/Packet.h` + `EterLib/ControlPackets.h`)。
|
||||
|
||||
### 1.2 加密握手(libsodium)
|
||||
|
||||
`EterBase/SecureCipher` —— X25519 密钥交换(`crypto_kx`)+ 每方向固定 nonce 的 XChaCha20
|
||||
流加密(带运行字节计数,**顺序敏感**)+ `crypto_auth` HMAC 挑战应答 + XChaCha20-Poly1305
|
||||
AEAD 加密 session token。
|
||||
|
||||
握手序列(连上 auth server 后):
|
||||
|
||||
| 方向 | 包 | header | 动作 |
|
||||
|---|---|---|---|
|
||||
| S→C | `GCPhase{phase=PHASE_HANDSHAKE}` | `GC_PHASE` 0x0008 | 进握手态 |
|
||||
| S→C | `GCKeyChallenge{server_pk[32], challenge[32], server_time}` | `GC_KEY_CHALLENGE` 0x000B | `Initialize()`;`compute_client_keys(server_pk)` |
|
||||
| C→S | `CGKeyResponse{client_pk[32], challenge_response[32]}` | `CG_KEY_RESPONSE` 0x000A | `get_public_key()`;`compute_challenge_response(challenge)` |
|
||||
| S→C | `GCKeyComplete{encrypted_token[48], nonce[24]}` | `GC_KEY_COMPLETE` 0x000C | `decrypt_token()`→token;`set_activated(true)`;解密已缓冲的剩余字节 |
|
||||
| S→C | `GCPhase{phase=PHASE_AUTH}` | `GC_PHASE` | 进认证态 |
|
||||
| C→S | `CGLogin3{name[31], pwd[17]}` | `CG_LOGIN3` 0x0102 | 明文账号密码(**此时流已加密**)|
|
||||
| S→C | `GCAuthSuccess{login_key, result}` | `GC_AUTH_SUCCESS` 0x0108 / 失败 `GC_LOGIN_FAILURE` 0x0106 | `result` 真→拿 `login_key` |
|
||||
|
||||
拿到 `login_key` 后断开 auth,连**游戏服**(`:11011`),同样握手,然后 `CGLogin2{name, login_key}`
|
||||
(`CG_LOGIN2` 0x0101)→ 选人 → 加载 → 进游戏相位。
|
||||
|
||||
> S→C 流 nonce `[0]=0x01`,C→S `[0]=0x02`。`ping`(`GC_PING` 0x0007)→`pong`(`CG_PONG` 0x0006) 全程要回。
|
||||
|
||||
## 2. 进度
|
||||
|
||||
| 模块 | 状态 | 位置 |
|
||||
|---|---|---|
|
||||
| `SecureCipher`(KX + 流 + AEAD token) | ✅ 端口完成,`net.cipher_roundtrip` CTest(KX / 挑战应答 + 篡改拒绝 / token 往返 / 跨块流加解密 / 字节计数) | `extension/src/net/secure_cipher.{h,cpp}` |
|
||||
| `wire.h`(帧 + 控制 + KX + auth 结构体) | ✅ `#pragma pack(1)` 尺寸断言 | `extension/src/net/wire.h` |
|
||||
| `ByteBuffer`(收发暂存) | ✅ header-only | `extension/src/net/byte_buffer.h` |
|
||||
| libsodium 接入 | ✅ 从源码 vendor(`extension/third_party/libsodium-cmake` + zstd + miniLZO),macOS/iOS 都编过,不再依赖 Homebrew。详见 `docs/THIRD-PARTY.md` | `extension/third_party/CMakeLists.txt` |
|
||||
| `NetStream`(非阻塞 POSIX socket + `process()` 循环 + 帧解析 + 内建控制面 handler:`GC_PHASE` / `GC_PING`→`CG_PONG` / KX 握手 / 激活后解密已缓冲字节) | ✅ 编译通过 | `extension/src/net/net_stream.{h,cpp}` |
|
||||
| `AuthClient`(auth 相位:`PHASE_AUTH`→发 `CGLogin3`→收 `GCAuthSuccess`/`GCLoginFailure`) | ✅ 编译通过,等可达服务端端到端测 | `extension/src/net/auth_client.h` |
|
||||
| `GameClient`(游戏服:`PHASE_LOGIN`→发 `CGLogin2{name, login_key}`→收 `GCLoginSuccess3` 角色列表→`select_character()`→`PHASE_SELECT/LOADING/GAME`) | ✅ 编译通过。角色列表按 `TSimplePlayerInformation`(pack(1),103B)解析成 `{index,id,name,job,level,x,y}`;游戏相位包暂只回 PING、记未知 header | `extension/src/net/game_client.h` |
|
||||
| `net_probe` 诊断工具 | ✅ `net_probe <host> <port> <id> <pw>`,打印 state / 握手 / 登录结果 / login_key | `extension/tools/net_probe.cpp` |
|
||||
| **`M2Client` GDExtension 节点** | ✅ 已注册可用。`connect_to_server(...)` 编排 Auth→(`auth_ok`)→GameConnect→游戏相位;`select_character(index)`。信号 `stage_changed` / `phase_changed` / `auth_ok` / `login_failed` / `char_list` / `entered_game` / `disconnected`。生命周期(F5):`suspend()`/`resume()`/`reconnect()` + `suspended`/`resumed` 信号,`NOTIFICATION_APPLICATION_PAUSED/RESUMED` 自动触发。烟测:正确报 `auth_connect → login_failed(Connection refused) → failed` | `extension/src/net/m2_client.{h,cpp}` |
|
||||
| **`M2Client` 游戏内 API** | ✅ 出站 `move(func,arg,rot_deg,x,y)`(rot `/5`、`time` 取帧钟)/ `attack(motion,vid)` / `set_target(vid)` / `say(type,text)`,未入局返回 false。快照 `get_entities()` / `get_entity(vid)`(含 hp/sp/level/dead/stunned,坐标转 Godot 米)/ `get_main_vid()` / `get_points()`(HP/SP/level/exp/gold)/ `get_target()`(vid + hp%)。`pump_game()` 每帧 `EntityStore::set_now/tick` 并抽信号:`entity_spawned/despawned/moved/main_set`、`chat`、`vitals_changed(vid)`、`entity_dead(vid)`(首次去重)、`damage(vid,amount,flag)`、`motion(vid,victim,motion)`、`points_changed(Dict)`、`target_info(vid,hp%)`。`net.loopback_flow` 扩展:出站 `CG_MOVE/ATTACK/TARGET/CHAT` 经加密信道字节级校验 | `extension/src/net/m2_client.{h,cpp}` |
|
||||
| **游戏相位封包结构** | ✅ `wire.h`:`GC_MAIN_CHARACTER` / `GC_CHARACTER_ADD[2]` / `GC_CHARACTER_DEL` / `GC_CHARACTER_UPDATE` / `GC_MOVE` / `GC_CHAT` + `CG_MOVE` / `CG_ATTACK` / `CG_TARGET` / `CG_CHAT`。**战斗/状态**(对 `Packet.h`):`GC_PLAYER_POINTS`(0x0214, int32[255] 1024B) / `GC_PLAYER_POINT_CHANGE`(0x0215) / `GC_STUN`(0x0216) / `GC_DEAD`(0x0217) / `GC_MOTION`(0x0307) / `GC_DAMAGE_INFO`(0x0410) / `GC_TARGET_INFO`(0x0A10) + `EPointTypes`(HP/MAX_HP/SP/MAX_SP/LEVEL/EXP/GOLD…)+ `EDamageFlag`(NORMAL/POISON/DODGE/BLOCK/PENETRATE/CRITICAL)。全部 `static_assert` 尺寸 | `extension/src/net/wire.h` |
|
||||
| **`EntityStore`(封包→世界模型)** | ✅ headless。`apply()` 消化上述包 → `vid → Entity{race,name,x,y,z,angle,parts,func,moving, hp,max_hp,sp,max_sp,level,dead,stunned}`;`GC_MOVE` FUNC_MOVE 插值/其他 func 吸附;`tick()` 按 `now_ms` 插值。战斗:`GC_PLAYER_POINTS`→`points()` 全量属性 + 镜像到主角实体;`GC_PLAYER_POINT_CHANGE`→按 `EPointTypes` 更新单实体 vital;`GC_DEAD`/`GC_STUN`→标记;拉取口 `drain_changes` / `drain_chat` / `drain_vitals` / `drain_damage` / `drain_motions` / `take_points_dirty` / `take_target_dirty`。单测 `net.entity_store` 扩展覆盖全部战斗包 | `extension/src/net/entity_store.{h,cpp}` |
|
||||
| **`CG_*` 出站构造** | ✅ `GameClient::send_move/send_attack/send_target/send_chat`(`CG_CHAT` 变长) | `extension/src/net/game_client.h` |
|
||||
| **环回集成测试** | ✅ `net.loopback_flow` CTest:进程内 `MockServer`(真协议:帧 + libsodium KX + 脚本相位包)驱动 `AuthClient`→`GameClient` 走完 auth→角色列表→选人→`PHASE_GAME`→游戏包进 `EntityStore`→NPC 移动插值。服务端一通改个 IP 即可 | `extension/tests/net_loopback_test.cpp` |
|
||||
| **`EntityStore` 单测** | ✅ `net.entity_store` CTest:合成包 → spawn / 移动插值(半程/到达/func 复位)/ 吸附 / 变长 chat / despawn | `extension/tests/net_entity_test.cpp` |
|
||||
| **`NetWorld` 场景桥(GDScript)** | ✅ `net_world.gd`:消费 M2Client 实体信号,每 vid 一个子节点,位置向 `get_entity(vid).pos` 平滑逼近(超 `snap_dist` 瞬移)、`rotation.y` 取自 `angle_deg`、动画状态取自 `func`。默认「占位胶囊 + 名字牌 + HP 条」;`set_model_factory(cb)` 换真模型。**战斗**:`vitals_changed`→缩放 HP 条 + emit `vitals(vid,hp,max_hp,dead)`;`damage`→实体头顶飘字(crit 放大变金 / dodge=MISS,tween 上浮淡出)+ emit `damage_number`;`entity_dead`→倒地 + `set_anim_state("dead")`。`main_entity_ready` / `chat_line` 信号。headless 自检 `netbridge_test.gd`(spawn/main/远移吸附/近移插值/chat/伤害数字/HP 条/暴击/死亡/despawn)通过 | `project/net_world.gd` |
|
||||
| **P0 闭环胶水 + 场景组装** | ✅ `net_play.gd`:`player_controller` ↔ `M2Client` —— 点地→`move(FUNC_MOVE)`(节流)/ 停→`move(FUNC_WAIT)` / 点实体→`set_target` + 进 2.5m 自动 `attack` / 收自己 `GC_MOVE`→位置校正 / `points_changed`+`vitals_changed`+`target_info`→HUD。`hud.gd` 加 `set_vitals/set_exp/set_level/set_target/clear_target`。`game_scene.gd`(简版)把 world+player+camera+pc+net_world+hud+net_play+audio+lifecycle 拼起来,`login.gd` 的 `entered_game`(`--auto` 自动选槽)实例化。`netplay_test.gd` + `gamescene_test.gd` headless 全过 | `project/{net_play,game_scene,hud,login}.gd` |
|
||||
| **P1 UI 工具层** | ✅ `ui/uiscript.gd` 解析原始 `uiscript/*.py`(子集解释器,**80/80 真实文件通过**)+ `ui/ui_build.gd` dict→Control 树(~20 种 type)+ `ui/ui_assets.gd`(`.sub`/tga/png)+ `ui/ui_manager.gd`(窗口栈 / ESC / modal / 拖动)+ `ui/dialogs.gd`。`ui_test.gd` 全过 | `project/ui/*.gd` |
|
||||
| **P2 物品数据层** | 🟡 `wire.h`:`GC_ITEM_SET/DEL/UPDATE/USE/GROUND_ADD/GROUND_DEL/GET` + `CG_ITEM_MOVE/USE/DROP/PICKUP`(全 static_assert)。`EntityStore`:`inventory[90]` / `equipment[11]` / ground map + `drain_inv/ground/item_events`。`M2Client`:`get_inventory/equipment/item/ground_items` + `move_item/use_item/drop_item/pickup_item` + 5 信号。新 `Metin2Proto` 节点(`item(vnum)`/`mob(vnum)`,载 5748 真实 item)。⬜ 背包 / 装备窗 UI + 穿脱换模型 | `extension/src/{net/wire.h,net/entity_store.*,net/game_client.h,net/m2_client.*,proto/proto_node.*}` |
|
||||
| **P3 聊天** | ✅ `wire.h` `GC_WHISPER`/`CG_WHISPER` + `EntityStore` whisper 解析(`ChatMsg` 加 from/sub)+ `M2Client.whisper()` + `whisper_received` 信号。`ui/chat_ui.gd`:4 标签 + 前缀 `/w`/`/g`/`/p`/`/s` + 系统/战斗日志。`net_world._bubble` 头顶气泡 + `main_bubble`。`chat_test.gd` 全过 | `extension/src/net/{wire.h,entity_store.*,game_client.h,m2_client.*}` · `project/ui/chat_ui.gd` |
|
||||
| **P4 战斗表现** | 🟡 `wire.h` `GC_AFFECT_ADD/REMOVE` + `EntityStore` affect map + `M2Client.get_affects()` / `affect_added`·`affect_removed`。受击硬直(`net_play` + `pc.frozen`)· combo(motion 递增)· 相机抖动(`game_camera.shake`)· 死亡窗(`ui/death_ui.gd` → `/restart_here`·`/restart_town`)· `hud.set_affects` 图标条。`combat_fx_test.gd` 全过。⬜ 武器拖尾 / 相机锁定 | `extension/src/net/*` · `project/{net_play,game_camera,hud}.gd` · `project/ui/death_ui.gd` |
|
||||
| **P5 特效引擎(首版)** | 🟡 `fx/mse.gd`(`.mse` 花括号树解析,真文件 6 particle)+ `fx/effect_player.gd`(Particle→`GPUParticles3D`:发射形状/速率/寿命/曲线/颜色渐变/billboard/加法混合)+ `fx/effect_registry.gd`(名字→`.mse` + 缓存 + spawn)。`wire.h` `GC_SPECIAL/SPECIFIC_EFFECT` + `M2Client.effect_cue` 信号 → `game_scene` 在实体上播。`fx_test.gd` 全过。⬜ `.dds` 纹理 / `.mde` mesh / 绑骨骼 | `extension/src/net/*` · `project/fx/*.gd` |
|
||||
| **P6 技能系统(首版)** | 🟢 `wire.h` `GC_SKILL_LEVEL/COOLTIME_END/CREATE_FLY` + `EntityStore` skill 表 + `M2Client.get_skills`/`cast_skill`/`skill_up` + `skills_changed`/`skill_cooldown_end`/`fly_cue` 信号。`ui/skill_table.gd`(skilldesc.txt)+ `ui/skill_ui.gd`(K 键,加点)+ `ui/quickbar.gd`(1-8 施放 + 冷却)+ `net_world` 飞行球。`skill_test.gd` 全过 | `extension/src/net/*` · `project/ui/{skill_table,skill_ui,quickbar}.gd` |
|
||||
| **P7 任务 / NPC(首版)** | 🟡 `wire.h` `GC_SCRIPT`/`GC_QUEST_CONFIRM`/`GC_QUEST_INFO` + `CG_ON_CLICK`/`SCRIPT_ANSWER`/`SCRIPT_BUTTON`/`QUEST_INPUT_STRING`/`QUEST_CONFIRM`(全 static_assert)。`EntityStore`:`ScriptCue`/`ConfirmCue`/`QuestInfo` map + `drain_scripts/confirms/quest_changes` + `quest(idx)`。`Entity` 加 `ch_type`。`M2Client`:`click_npc`/`script_answer`/`script_button`/`quest_input`/`quest_confirm`/`get_quests` + `script_dialog`/`quest_confirm_ask`/`quest_info` 信号。`ui/quest_dialog.gd`(EventManager 脚本渲染:`[ENTER]`/`[QUESTION]`/`[DONE]` → 文本+选项+确认)+ `ui/quest_log.gd`(J 键)。`net_play._on_pick` NPC 分流。quest 逻辑纯服务器驱动(选型②)。`quest_test.gd` 全过。⬜ `[INPUT]` 输入 UI / 选道具 / 立绘 / 世界任务箭头 | `extension/src/net/*` · `project/ui/{quest_dialog,quest_log}.gd` · `project/net_play.gd` |
|
||||
| charselect 串场 | ⬜ P10 | — |
|
||||
| **P8 社交 / 商店 / 仓库(首版)** | 🟡 `wire.h` `GC_PARTY_*`(0x0710-16) / `GC_MESSENGER`(0x0741) / `GC_SHOP`(0x0810) / `GC_EXCHANGE`(0x051C) / `GC_SAFEBOX_*`(0x0830-34) + 对应 CG(全 static_assert;`ShopItem`/`GCExchange`/`GCSubHeader` 等)。`EntityStore`:`PartyMember` map / `Friend` map / `ShopEntry` 表 / `ExchangeState` / `m_safebox[135]` + dirty 标志 + `drain_party_invites`/`drain_shop_errors`。`M2Client`:`party_invite/answer/leave` · `add/remove_friend` · `shop_buy/sell/close` · `exchange_start/add_item/add_gold/accept/cancel` · `safebox_checkin/checkout/move` + `get_party/friends/shop_items/exchange/safebox_items` + 8 信号。`ui/{party_ui,friend_ui,shop_ui,exchange_ui,safebox_ui}.gd`(队员面板常驻 / O 键好友 / 商店·交易·仓库随信号自动开)。`inventory_ui` 右键分流(商店卖 / 交易放 / 仓库存)。`p8_test.gd` + `net.entity_store` / `net.loopback_flow` 扩测全过。⬜ 公会(`PythonGuild` 40 法 + Mark 下载)、精炼 / 强化 / 龙魂、私人商店 / 拍卖、交易道具图标网格 | `extension/src/net/*` · `project/ui/{party,friend,shop,exchange,safebox}_ui.gd` · `project/game_scene.gd` |
|
||||
| 公会 / 精炼 / 龙魂精炼 / 会徽下载(首版) | 🟡 `GC_GUILD`(0x0730) INFO/LIST/GRADE + `ui/guild_ui.gd`(G,真机 `[GM-TEAM]`);`GC_REFINE_INFO`(0x051D) + `ui/refine_ui.gd`(`refine_ask` → `refine(pos,type)`);`GC_DRAGON_SOUL_REFINE`(0x051F)/`CG`(0x050D `grid[15]`) + `DS_SUB_*` + `EntityStore.DragonSoulCue`/`m_dragon_soul[180]` + `M2Client.ds_refine(mode,cells)`/`ds_window_open`/`ds_refine_result` + `ui/dragon_soul_ui.gd`(L / 自动弹,3 模式 + 15 格背包右键填)。**会徽**:`MARK_*` 独立连接(`GC_MARK_IDXLIST`/`BLOCK` 0x0C1x、`GC_MARK_UPDATE` 0x0B15、`CG_MARK_LOGIN/IDXLIST/CRCLIST` 0x0C0x);`NetStream` 加 `on_cipher_active()`/`on_raw()`(u32 `buf_size` 分帧);`mark_image.{h,cpp}` `MarkImageSet`(LZO1X → 512×512 → `mark_pixels`)+ `mark_client.h` `MarkClient`;`M2Client.download_guild_marks`/`get_guild_mark_image`→`Image`;`serverinfo` `mark_port` 第 8 列;`app_flow` 进游戏连 + `guild_mark_updated` 冷却重连。**公会战 + 技能页**:`GUILD_GC_SKILL_INFO`(12) `GuildSkillInfoBody`(17)、`WAR`(15) `GCGuildWar`(10)、`GUILD_WAR_LIST`/`END_LIST`(17/18) `GuildWarPair`(8)、`WAR_POINT`(19) `GuildWarPoint`(12)、`NAME`(16) `GuildNameEntry`(16);`EntityStore` `GuildSkillState`/`GuildWarStatus`/`m_guild_wars`/`m_guild_names` + `drain_guild_war_events`/`_scores`;`GameClient.send_guild_use_skill`;`M2Client` `get_guild_skill`/`get_guild_wars`/`get_guild_war`/`use_guild_skill`/`declare_guild_war`(`/war` 命令) + `guild_skill_changed`/`guild_war_changed`/`guild_war_event`/`guild_war_point` 信号;`ui/guild_ui.gd` 改三页 Tab(成员/技能/公会战)。**会徽上传**:`CG_MARK_UPLOAD`(0x0C03) `CGMarkUpload`(776)、`CG_GUILD_SYMBOL_UPLOAD`(0x0722) `CGSymbolUpload`(8)+文件字节;`NetStream.send_pending()`;`MarkClient` `Mode`(Download/UploadMark/UploadSymbol) + `upload_done()`;`M2Client.upload_guild_mark`(转 RGBA8/缩放)/`upload_guild_symbol`/`get_mark_server` + `guild_mark_uploaded`;`guild_ui` 成员页「上传会徽」按钮。`net.loopback_flow` 加 `MockServer::Mode::Mark` 真 socket 端到端校验(KX → CG_MARK_LOGIN → CG_MARK_UPLOAD 768 字节校验和 → upload_done)。`guild_refine_test.gd` + `dragon_soul_test.gd` + `guild_mark_test.gd` + `guild_war_skill_test.gd` + `net.entity_store` + `net.guild_mark` + `net.loopback_flow`(CTest) 全过。**真机验证(2026-08-31,`net_e2e`)**:`GUILD_GC_SKILL_INFO`(sub 12) 服务器进游戏自动推送,17B body 解析正确(skill_point=19 / guild_point 0/2300 / 12 等级);`GUILD_GC_WAR`(sub 15) 无战状态解析正确;trace 显示 `GC_GUILD`(0x0730) 多个子包 len=171/40/83/22/21/9/5 均无 unknown / parse 错误。会徽独立连接 + 会徽上传仍未接真机(联调服未开会徽服端口)。⬜ 龙魂属性表、公会等级/日志页 | `extension/src/net/*` · `project/ui/{guild,refine,dragon_soul}_ui.gd` · `project/{net/serverinfo,app_flow}.gd` |
|
||||
| **私人商店 / 道具商城 / Cube(首版)** | 🟡 `wire.h`:`CG_MYSHOP`(0x0802) `CGMyShopHead`(38,`char sign[33]+u8 count`) + `MyShopItem`(13,`u32 vnum,u8 count,ItemPos,u32 price,u8 display_pos`)×N;`CG_MALL_CHECKOUT`(0x0840) `CGMallCheckout`(8);`GC_MALL_OPEN`(0x0841) `GCMallOpen`(5)、`GC_MALL_SET`(0x0842)/`GC_MALL_DEL`(0x0843) 复用 `GCItemSet`/`GCItemDel`,全 static_assert。`GameClient`:`send_open_private_shop(sign, vector<MyShopItem>)`(打包头+N项)/ `send_close_private_shop`(=`SHOP_CG_END`) / `send_mall_checkout` / `send_cube_make|material_info|result_list`(`/cube make|mInfo|rList` 走 `send_chat(CHAT_TYPE_COMMAND,…)`)。`EntityStore`:`m_mall[135]` + `mall_open/size/slot/dirty`;`GC_CHAT` 若 `type==CHAT_TYPE_COMMAND` 不进聊天,转 `apply_server_command()` 解析 `cube open/close/info/success/fail/r_list/m_info`(`v,c` 结果列表 + `@`配方 `&`组 `|`槽 `/`金币)→ `CubeState{open,npc,recipes[],results[],need_*}` + `drain_cube_events()`。`M2Client`:`open/close_private_shop`、`get_mall_items`/`mall_checkout`、`get_cube`/`cube_make`/`cube_request_result_list`/`cube_request_materials` + `mall_opened`/`mall_changed`/`cube_opened`/`cube_closed`/`cube_changed`/`cube_result` 信号。`ui/private_shop_ui.gd`(招牌 + 背包挑件 + 定价 + 개설/철수)、`ui/mall_ui.gd`(列表 + 取出到背包空格)、`ui/cube_ui.gd`(配方列表 + 材料/金币 + 제작);`game_scene` 装配三窗。`net.entity_store`(合成 GC_MALL_* + `GC_CHAT/COMMAND` cube 全家桶)+ `net.loopback_flow`(真 socket:server 推 mall+cube,client 发 `CG_MYSHOP`/`CG_MALL_CHECKOUT`/`/cube` 字节校验)+ `shop_cube_mall_test.gd`(三窗 FakeClient)全过。**真机验证(2026-08-31,`net_e2e MT_E2E_MYSHOP=1`)**:`CG_MYSHOP`(0x0802) 38B head + 13B item×1(vnum 149,price 99999999)被真服接受,无断连 / 无 shop error;`SHOP_CG_END` 关店正常。Cube `/cube rList` 与 `CG_MALL_CHECKOUT` 送出路径不断连,但服务器只在玩家站到对应 NPC 前才回应,`mall_open=0` / `cube open=0` —— 需 NPC 交互才能完整验。⬜ `GC_SHOP_SIGN`/`SYMBOL_DATA` 头顶招牌、他人开店 `START_EX` 多标签、39 格拖放网格 / 鼠标物品态、能量条、NPC 前完整验 cube/mall。**拍卖行**:m2dev-client 无此系统(`0x08xx` 只有 shop/myshop/safebox/mall),非移植项,需自定义协议 + 服务端 | `extension/src/net/{wire.h,game_client.h,entity_store.{h,cpp},m2_client.{h,cpp}}` · `project/ui/{private_shop,mall,cube}_ui.gd` |
|
||||
| **P9 世界系统(首版)** | 🟡 `wire.h` `GCWarp`(18) / `GCTime`(12) / `GCChannel`(5) / `GCNPCPosition`(6+78×n) / `GCTargetCreate/Update/Delete`(46/16/8) / `GCMount`(21),全 static_assert。`EntityStore`:`WarpCue` + `drain_warps`、`server_time`/`channel` + dirty、`m_npc_marks` / `m_markers` map、`Entity.mount_vnum` + `drain_mount_changes`。`M2Client`:`get_channel/get_server_time/get_npc_marks/get_world_markers` + `warp`/`time_changed`/`channel_changed`/`npc_marks_changed`/`world_markers_changed`/`mount_changed` 信号。`ui/minimap.gd`(右上圆形,北朝上,实体/NPC/任务标记 + 玩家箭头 + CH 角标)+ `world/world_time.gd`(epoch → 太阳角度/光色/ambient 昼夜)+ `fx/weather.gd`(雪/雨 GPUParticles 跟相机)。`game_scene._on_warp` 同服挪人。`p9_test.gd` + `net.entity_store` 扩测全过 | `extension/src/net/*` · `project/ui/minimap.gd` · `project/world/world_time.gd` · `project/fx/weather.gd` · `project/game_scene.gd` |
|
||||
| **P10 网络补全(首版)** | 🟢 `wire.h` `CGClientVersion`(0x000D,70B)。`GameClient` 收 `GC_MAIN_CHARACTER` → 自动发 `CG_CLIENT_VERSION` = **打通 LOADING→PHASE_GAME**(真机验证进游戏 + 121 实体)。`net/serverinfo.gd`(`class_name ServerInfo`,TSV 服务器表 + 频道端口偏移)+ `ui/loading_screen.gd`(相位驱动读取遮罩)+ `ui/reconnect_ui.gd`(断线倒计时 + 自动/手动重连)+ `app_flow.gd`(LOGIN→SELECT→GAME 串场状态机,单 `M2Client` 贯穿)。attack CRC 全 0 实测服务器接受。`net_e2e` 加 move+attack poke;`net.loopback_flow` 断言自动版本报文;`p10_test.gd` 全过 | `extension/src/net/{wire.h,game_client.h}` · `extension/tools/net_e2e.cpp` · `project/{app_flow,net/serverinfo}.gd` · `project/ui/{loading_screen,reconnect_ui}.gd` |
|
||||
| 大地图窗 / 地形贴花 / 钓鱼 / 副本 / 坐骑换模型 / 跨服换图 / 频道人数轮询 / 真机字节校准 | ⬜ 见 [`CLIENT-ROADMAP.md`](./CLIENT-ROADMAP.md) P9·P10 剩余 | — |
|
||||
| 物品 / 装备(`item_proto` + 背包封包 + 装备挂模型 → 解锁 §2.7 高光 / §2.3 时装) | ⬜ | — |
|
||||
|
||||
## 3. 连通性(2026-08-30:真机端到端打通到 PHASE_GAME)
|
||||
|
||||
服务端 = **m2dev-fork 协议**。`net_e2e`(`extension/tools/net_e2e.cpp`,`build/extension/net_e2e`)
|
||||
驱动 auth→game→选人→进游戏全程;`MT_NET_TRACE=1` 打印每包 header/length/consumed。
|
||||
|
||||
**真机结果 —— 全程打通**:
|
||||
- ✅ auth 握手(libsodium KX)+ `CG_LOGIN3` → `GC_AUTH_SUCCESS`
|
||||
- ✅ game `CG_LOGIN2` → **`GC_LOGIN_SUCCESS4`(0x0105,4 槽)** 角色列表(`[SA]Admin` lv105)
|
||||
- ✅ `CG_CHARACTER_SELECT` → `GC_PHASE(LOADING)` → 整包洪流全部按正确字节解析
|
||||
- ✅ **`GC_MAIN_CHARACTER` 后自动发 `CG_CLIENT_VERSION`(0x000D)** → `GC_PHASE(GAME)` / **IN GAME**
|
||||
- ✅ 进游戏后:121 个实体、HP 14990/15178、level 105、20 格背包、好友列表 1 人
|
||||
- ✅ `CG_MOVE` + `CG_ATTACK`(CRC 全 0)保持连接不被踢 → **不需要占位 CRC**
|
||||
|
||||
**修的 3 个真 bug(对任何真服务器都必需)**:
|
||||
1. `GC_LOGIN_SUCCESS4`(0x0105) 没处理(只认 3 槽的)→ 加 `GCLoginSuccess4`(492B) + handler。
|
||||
2. 帧分发器 "did not consume" 误判:连续同 header 的包(一串 `GC_PLAYER_POINT_CHANGE`)会误杀连接
|
||||
→ 改成按**实际消费字节数**判定(`net_stream.cpp` dispatch())。
|
||||
3. **缺 `CG_CLIENT_VERSION`**:服务器收到 `GC_MAIN_CHARACTER` 后等客户端回版本报文,缺它就在
|
||||
phase 4 断线 → `GameClient` 收到 `GC_MAIN_CHARACTER` 时自动发(fork 固定 timestamp `1215955205`)。
|
||||
|
||||
**真机字节校准(2026-08-30,已做)** —— 用 `MT_NET_DUMP=1 net_e2e` 抓真实封包逐字节对:
|
||||
- **实体名 / 等级 / 公会 / 骑乘** ✅ 走 `GC_CHAR_ADD_INFO`(0x0207,`TPacketGCCharacterAdditionalInfo`
|
||||
97B),之前根本没处理。加 `GCCharAddInfo` struct + handler,NPC「Old Man」(race 20009) 名字正确出。
|
||||
- **`GC_CHARACTER_UPDATE`**(0x0209,38B) ✅ 之前只粗解 parts;现按 `TPacketGCCharacterUpdate` 全解
|
||||
(parts / guild / alignment / pk_mode / mount,`GCCharacterUpdate` struct)。
|
||||
- **`ch_type`**:`GC_CHARACTER_ADD.bType` 对 NPC/怪都是 0(服务器不填)。改在 GDScript 按 `race` 查
|
||||
`mob_proto.type` 分类(`net_play._entity_kind`:0 怪 / 1 NPC / 2 石头 / 3 warp),`_entity_name`
|
||||
怪名回退 `mob_proto.locale_name`。`game_scene` 现在也载 `mob_proto`。
|
||||
- **`GC_PLAYER_POINTS`**(0x0214,1024B) —— **本来就对的**。真机 gold=13.5 亿(GM 号)、exp=0、
|
||||
next_exp=int32 溢出为负都是服务器真实值。`M2Client.get_points` 把 exp/next_exp 按 unsigned 输出。
|
||||
|
||||
**第二轮校准(2026-08-30,已做)** —— 又抓了 in-game 洪流里没对过的包:
|
||||
- **`GC_SKILL_LEVEL_NEW`**(0x021B,1534B) ✅ 这才是本服发的技能包(**没发老的 0x021A**),之前
|
||||
`get_skills()` 永远空、K 键技能窗没内容、快捷栏没法施放。加 `PlayerSkill{u8 master, u8 level,
|
||||
u32 next_read}`(6B/条,fork 的 `time_t` 是 32 位)+ `GCSkillLevelNew` + handler。真机实测:33 个
|
||||
技能全 lvl 40 master 3(GM 号)。`get_skills()` 加 `master`(0 普/1 M/2 G/3 P)。
|
||||
- **`GC_QUICKSLOT_ADD/DEL/SWAP`**(0x0519/1A/1B) ✅ 之前没处理 → 快捷栏进游戏是空的。加
|
||||
`QuickSlot{u8 type,u8 position}` + 3 个 handler + `M2Client.get_quickslots()` + `quickslots_changed`。
|
||||
`ui/quickbar.gd.restore_from_server()` 按服务器内容填 1-8 号格(type 1 道具 / 2 技能)。真机 10 格。
|
||||
- **`GC_NPC_POSITION`**(0x0A50) —— struct **本来就对**(6 + 78×n,name[65])。真机 49 条 NPC,名字对
|
||||
(服务器不 zero-pad name buffer,`strnlen` 已兜住)。
|
||||
- **`GC_ITEM_UPDATE`**(0x0514,41B) / **`GC_CHAT`**(0x0603,10B 头) / **`GC_AFFECT_ADD`**(0x0A20,25B)
|
||||
/ **`GC_SPECIAL_EFFECT`**(0x0A30,9B) / **`GC_QUEST_INFO`**(0x0912,7B) —— 逐字节核对,**全部已对**。
|
||||
- **`GC_GUILD`**(0x0730) sub-header 包(grade/info/list)已解析(见 M4 行);会徽走 `MARK_*` 独立连接。
|
||||
|
||||
**校准后续(2026-08-30,已做)**:
|
||||
- **3D 名字标签刷新** ✅ `EntityStore` 加 `ChangeKind::Info`;`GC_CHAR_ADD_INFO` 到达时对已存在实体
|
||||
推 `Info` 变更 → `M2Client.entity_info(vid, dict)` 信号 → `net_world._on_info` 刷 Label3D。
|
||||
`net_world.name_resolver`(`game_scene` 设为 `net_play._entity_name`)让怪名走 `mob_proto`。
|
||||
- **跨服换图 `GC_WARP` addr≠0** ✅ `M2Client`:`WarpCue.addr` 是网络序 IPv4,转点分十进制后
|
||||
`warp_to_game_server(host, port)` —— 拆掉当前 game 连接、复用存下的 `login_key` 直连新 game
|
||||
server(跳过 auth)。同服 warp 仍只挪玩家。
|
||||
- **断点快连** ✅ `M2Client.reconnect()` 现在优先用 `last_login_key` 直连 game server(省一次
|
||||
auth 往返),失败再走完整 auth→game。服务器重发 spawn = 断点续连。
|
||||
- **频道负载查询** ✅ `net/channel_status.gd`(对齐 `CServerStateChecker`):明文 TCP 连任一频道口
|
||||
→ 发 `CG_STATE_CHECKER`(0x000F,len 4) → 跳过杂包到 `GC_RESPOND_CHANNELSTATUS`(0x0010) →
|
||||
读 `int32 count` + count×`{i16 port, u8 status}`。status:0 关 / 1 正常 / 2 拥挤 / 3 爆满
|
||||
(Metin2 协议里就是 4 态,不是在线人数)。`app_flow._probe_channels` 先查状态、没答上的频道回退
|
||||
`_probe_tcp`,下拉项标 ●/◐/✕。**真机实测**:`192.168.21.203:11011` 回 `{11011:1, 11012:1,
|
||||
11013:1, 11991:1}` —— 也据此把 `serverinfo` 的频道端口步长从 +10 改成 **+1**(可在 serverlist.txt
|
||||
第 7 列 `port_step` 按服覆盖)。`channel_status_test`(进程内 mock 服务器:请求字节 + 跳杂包 +
|
||||
解析 + 不可达返回空)。
|
||||
|
||||
- **怪 / NPC 真 3D 模型** ✅ `ui/mob_view.gd`(对齐 `player_view.gd`):`race` = `mob_proto` vnum →
|
||||
**`root/npclist.txt`**(`<vnum>\t<代号>\t…`,如 `113→bear_brown`、`185→tiger_big`、`102→wolf`;
|
||||
翻译版 `mob_proto` 把 `szName` 也本地化成 "Brown Bear" 了,解不出目录,必须靠这张表,`mob_proto`
|
||||
名仅作兜底)→ 目录按代号逐段回退(`bear_brown → bear`),网格 / 贴图用完整代号
|
||||
(换肤网格都在基础目录里:`bear/bear_brown.gr2` + `bear/bear_brown.dds`)→ 进 `Metin2Model`
|
||||
(texture_dir = 目录)+ 解 `motlist.txt` → `Metin2AnimPlayer`。扫
|
||||
`<assets>/*/ymir work/{monster,monster2,npc,npc2}/<folder>/`。npclist 解析一次进 `static` 缓存。
|
||||
`set_anim_state("wait/run/attack/damage/dead/skill")` 按 motlist 动作名映射。
|
||||
`game_scene._make_entity_model` 设为 `net_world` 的默认工厂(非主角 + 有 mob_proto 时);race→是否有模型
|
||||
缓存,失败回退占位胶囊。`net_world._attach_nameplate` 抽出来,真模型也挂头顶名字 + HP 条。
|
||||
`mob_view_test`:race 102 Wolf → `wolf/` 目录、Metin2Model(40 骨 762 顶点)、motlist ≥3 动作、状态切换。
|
||||
实测进游戏「Alpha Grey Wolf」「Hungry Black Bear」等出真模型 + 贴图 + 动作 + 名字牌 / 血条。
|
||||
|
||||
- **重连 / 已在局内时补拉实体** ✅ `game_scene.setup()` 现在是协程(分帧避免阻塞漏 PONG),
|
||||
`net_world.setup()` 接 `entity_spawned` 已晚于 `M2Client` 的初始 spawn 洪流 → `_on_spawn` 0 次触发。
|
||||
修:`net_world.catch_up()` 遍历 `client.get_entities()` 补 spawn 未镜像的;`game_scene` 已在局内分支里
|
||||
**先** `net_world.set_local_vid(get_main_vid())` + `net_play._on_main_set(get_main_vid())` 再
|
||||
`catch_up()`,否则本地玩家会被画成一个压在身上的大蓝胶囊。
|
||||
|
||||
**剩的都不是代码问题**:字面「在线人数」(数字)Metin2 协议里没有 —— `bStatus` 就是 4 态负载指示,
|
||||
要真数字得改服务器;本地 `assets/` 只有 ~85 个 monster 目录(不是全量),没目录的 race
|
||||
仍是占位胶囊 —— 换全量资产包即可全部出真模型。
|
||||
|
||||
**联调路上修的两个真 bug**(对任何真实服务器都必需):
|
||||
1. **`GC_LOGIN_SUCCESS4`(0x0105) 没处理** —— 只认 3 槽的 `GC_LOGIN_SUCCESS3`。已加 `GCLoginSuccess4`
|
||||
结构(492B) + handler。
|
||||
2. **帧分发器误杀连接** —— 连续同 header 的封包(如一串 `GC_PLAYER_POINT_CHANGE`)会触发
|
||||
"on_packet did not consume" 误判并断线。改成按**实际消费字节数**判定
|
||||
(`net_stream.cpp` dispatch())。此 bug 之前会让 P0–P8 对任何真服务器的端到端全挂。
|
||||
|
||||
**当前状态**:P0–P8 的封包层仍以合成封包 CTest + FakeClient GDScript 测试离线验证(14 套 GDScript +
|
||||
9 CTest 全绿)。真机端到端到 PHASE_GAME 的最后一跳留给 P10。
|
||||
|
||||
## 3b. B 轨(不依赖服务端的客户端系统)
|
||||
|
||||
| 模块 | 状态 | 位置 |
|
||||
|---|---|---|
|
||||
| **eterpack 读写器**(G1)| ✅ 本 fork = `PackLib` 单文件包(header + XChaCha20 加密索引 + zstd 数据 + 可选逐文件加密,硬编码 `PACK_KEY`)。`EterPack`(读,name-field 从 header 反推兼容真实包)+ `write_pack` + `packtool` CLI。`pack.roundtrip` CTest + 真实 `textureset/` 45 文件明文/加密往返 `diff` 一致 | `extension/src/pack/eterpack.{h,cpp}`、`extension/tools/packtool.cpp` |
|
||||
| **登录 / 选人界面**(首版)| ✅ `charselect.gd`:3 旋转台 × 真实职业模型(`<class>_novice.gr2` + `wait.msa`)+ 真实 `board` 九宫格面板(槽位按钮 + 进入游戏)。数据来自 `M2Client.char_list`(联机)或 `--mock`(离线开发)。`login.gd`:服务器/账号/密码 + 连接,接全部 `M2Client` 信号(`--auto` 测试)。`ui_kit.gd`:从 `hud.gd` 抽出的九宫格助手。⬜ 创建/删除角色、旋转台微调、login→charselect→world 串场 | `project/{login,charselect,ui_kit}.gd` |
|
||||
| **正式选人页(Metin2 风)** | ✅ `ui/char_select_screen.gd`:`app_flow._build_char_list` 用它替掉原来的裸按钮列表。全屏背景 `d:/ymir work/ui/intro/select/select.sub`(=select.jpg)+ 右侧 `SubViewport`(`own_world_3d`)里 `PlayerView` 真模型(正面朝相机 + `wait` + 缓摆)+ 左上职业名(locale `name_<class>.sub` → `select.dds`,DDS 走 `Metin2World.load_dds`;解不出退中文描边字 猛将/刺客/术士/巫女)+ 左侧 `board` 信息板(帮会徽 `get_guild_mark_image` / **国家名 `get_empire()`→`EMPIRE_A/B/C` locale + 国旗 `empireflag_<a/b/c>.sub`** / 帮会名 / 名称 / 等级 / 游戏时间 / 体力·智力·力量·敏捷 条)+ 开始 / 创建 / 删除 / 退出 + 多角色 ◀ ▶ 槽位切换(`_pad_slots` 按 `get_slot_count()` 补空槽,◀▶ 能走到空槽建号)。**建号 / 删号(1:1 对齐 `PythonNetworkStreamPhaseSelect.cpp`)**:`CG_CHARACTER_CREATE`(0x0201) `CGCreateCharacter`(77,job 是 **u16**)、`CG_CHARACTER_DELETE`(0x0202) `CGDeleteCharacter`(13,`private_code[8]`)、`GC_PLAYER_CREATE_SUCCESS`(0x020C) `GCPlayerCreateSuccess`(108=`{u8 slot, SimplePlayerInfo}`)、`GC_PLAYER_CREATE_FAILURE`(0x020D) `{u8 type}`、`GC_PLAYER_DELETE_SUCCESS`(0x020E) `{u8 slot}`、`GC_PLAYER_DELETE_WRONG_SOCIAL_ID`(0x020F)。`GameClient`:`create_character`/`delete_character` + `drain_char_events()`(`CharEvent{CreateOk/CreateFail/DeleteOk/DeleteFail}`)+ `replace_slot`/`clear_slot`(服务器只补丁槽位不重发列表)+ `slot_count()`。`M2Client`:`create_character(slot,name,job,shape,con,int,str,dex)`/`delete_character(slot,code)`/`get_empire`/`get_slot_count` + `char_created`/`char_create_failed`/`char_deleted`/`char_delete_failed` 信号(建 / 删成功后重发 `char_list`)。选人页建号弹窗(职业下拉 + 名称 + 各职业起始四维只读)、删号弹窗(secret 删除码);`app_flow._on_char_list` 在 SELECT 态只 `set_chars()` 不整屏重建。会徽:`app_flow._goto_select` 也发 `download_guild_marks`,`guild_marks_ready`(one-shot) → `refresh_crest()`。相机改按**包围球** `r/sin(fovV/2)` 自适应 + 沿 -X 推 `r*0.5`(横向视锥更宽,不切远侧手),蒙皮定型后多 fit 4 帧。C++:`CharSlot` + `char_list` dict 补 `play_minutes/st/ht/dx/iq/main_part/hair_part`。`ui_assets.gd` 加 `load_dds_image()`。`net.loopback_flow` MockServer 加 `CG_CHARACTER_CREATE/DELETE` 真 socket 端到端(名称字节校验 + 成功补丁槽位 + 名称冲突 fail + 错误删除码拒绝);`char_create_delete_test.gd`(弹窗 → 假 client 收 `create_character(slot1,"Newbie",job1,con3/int3/str4/dex6)` + 短名拒绝 + `char_created` 关窗 + 删号码 + 失败清框 + `set_chars` 保持选中);`p10_test` 选人断言改点「开始」 | `project/ui/char_select_screen.gd` · `project/ui/ui_assets.gd` · `project/app_flow.gd` · `extension/src/net/{wire.h,game_client.h,m2_client.{h,cpp}}` · `extension/tests/net_loopback_test.cpp` |
|
||||
| **角色状态窗(1:1 窗口迁移样板)** | ✅ `project/ui/char_status_ui.gd`:`UiScript`+`UiBuild` 直接装载 `assets/uiscript/uiscript/characterwindow.py` 真布局(4 页 tab:STATUS/SKILL/EMOTICON/QUEST,`SetState` 切页;技能 / 表情 / 任务的数据仍走各自专用窗)。STATUS 页数值逐字对照 `assets/root/uicharacter.py.RefreshStatus`:`Level_Value`=`points[LEVEL]`、`Exp_Value`=`u32(exp)`、`RestExp_Value`=`u32(next_exp)-u32(exp)`、`HP/SP_Value`=`cur/max`、`STR/DEX/HTH/INT_Value`=`points[ST/DX/HT/IQ]`、`ATT_Value`=`(MIN_ATK|MAX_ATK)+ATT_GRADE_BONUS+PARTY_ATT_GRADE`(min==max 取单值,全 0 退 `ATT_POWER`)、`DEF_Value`=`DEF_GRADE(+DEF_GRADE_BONUS 若非 0)`、`MATT_Value`=`MAGIC_ATT_GRADE+(MIN|MAX)_MAGIC_WEP`、`MDEF_Value`=`MAGIC_DEF_GRADE`、`ASPD/MSPD/CSPD/ER_Value`=`points[ATT_SPEED/MOV_SPEED/CASTING_SPEED/EVADE_RATE]`(EPointTypes 索引取自 m2dev `src/UserInterface/Packet.h`)。加点:`HTH/INT/STR/DEX` 的 `_Plus`/`_Minus` 按钮 → `client.say(0, "/stat ht"|"/stat- ht" …)`,与原 `statusPlusCommandDict`/`statusMinusCommandDict` 完全一致;`Status_Plus_Value`=`points[STAT]`,`>0` 才显示 `_Plus` 按钮和 `Status_Plus_Label`。名称 / 帮会 / 职业头像取 `get_entity(get_main_vid())`(`race%4` → `face_warrior/assassin/sura/shaman.sub`)。`points_changed` 信号刷新。`game_scene`:`char_status_ui` 节点 + V / C 热键。`char_status_ui_test.gd`:假 client 灌 points → 断言全部 Value label 文本 + `/stat` 命令 + `STAT==0` 隐藏加点按钮 + tab 切换页可见性。后续选道具网格 / 商店 START_EX / 私人商店 39 格照抄这套(uiscript 装载 + 逐字数值绑定 + 原聊天命令 / 封包)| `project/ui/char_status_ui.gd` · `project/game_scene.gd` · `project/char_status_ui_test.gd` |
|
||||
| **组队成员信息板(`uiparty.PartyMemberInfoBoard`)** | ✅ `wire.h`:`CGPartySetState{u16 hdr,u16 len,u32 pid,u8 role,u8 on}`(static_assert 10)+ `EPartyRole` 枚举(NORMAL 0 / LEADER 1 / ATTACKER 2 / TANKER 3 / BUFFER 4 / SKILL_MASTER 5 / BERSERKER 6 / DEFENDER 7,PythonPlayer.h)。`game_client.send_party_set_state(pid,role,on)`(`CG_PARTY_SET_STATE` 0x0704,注意结构字段名 `dwVID` 实际传 PID —— 对齐 `uiparty.py OnSelectState` 的 `net.SendPartySetStatePacket(self.pid,...)`)。`M2Client.party_set_state(pid,role,on)` + bind;`get_party()` dict 补 `state`(完整角色字节,bit0=leader)+ `affects[7]`(int×7)。`project/ui/party_ui.gd` 从「名字+一条 HP」重做成 `partymemberinfoboard.py` 布局(每员 106×36 条:StateButton 22px 角色图标 + NameSlot + Gauge + 附加图标行):`_strip(m)` → 角色状态按钮(`_local_is_leader()` 才可点 → `_open_role_popup` 弹 普通/攻击/坦克/狂战/辅助/宗师/防御 + 踢出;普通=`party_set_state(pid, cur_role, false)` 清角色;踢出=`party_leave(pid)`=`SendPartyRemovePacket`)+ 名字(★队长) + HP `ProgressBar` + `affects[i]!=0` → chip(`AFFECT_LABEL` 暂定 经验/攻击/防御/辅助/宗师/时间/回复,tooltip 带值)。点名字 → `set_target(vid)`(`OnMouseLeftButtonDown` 选中)。header:EXP 分配开关 `_toggle_distribute` → `party_set_distribute`(`EPartyExpDistributionType` 0 NON_PARITY / 1 PARITY)+ 组队治疗 `_party_heal` → `party_use_skill(PARTY_SKILL_HEAL=1, 0)`。`_local_is_leader()` = `get_party()` 里 `vid == get_main_vid()` 那条的 `leader`。角色菜单按党技能等级门控(`uiparty.__ShowStateButton` Tanker≥10…Defender≥40)暂略。`p8_test` 组队段扩:affect chip 渲染 + 队长点 StateButton 弹菜单 + 「攻击」→ `party_set_state(pid, 2, true)` + 踢出 → `party_leave` + 分配切换 → `party_set_distribute(1)` + 治疗 → `party_use_skill(1,0)` + 点名字 → `set_target` + 非队长 StateButton disabled。**顺带修 `p8_test._init` 未 `await _run()` 的隐患**(第一个 `await` 之后所有断言从未生效,含增量 34 `shop_ex`)→ 改 `await` 后暴露 `shop_ui.refresh()` `queue_free` 延迟 bug(旧 row 当帧未消失),改 `remove_child`+`queue_free` | `extension/src/net/{wire.h,game_client.h,m2_client.{h,cpp}}` · `project/ui/party_ui.gd` · `project/ui/shop_ui.gd` · `project/p8_test.gd` |
|
||||
| **ESC 系统菜单 + 游戏设置窗(`uisystem.SystemDialog` + `uigameoption.OptionDialog`)** | ✅ `project/ui/system_menu_ui.gd`(`systemdialog.py` 真 uiscript,thinboard 8 键)= `uisystem.py __LoadSystemMenu_Default` 1:1:`system_option_button`→`system_option_ui.open()`,`game_option_button`→`game_option_ui.open()`,`change_button`→`client.say(0,"/phase_select")`(`net.ExitGame()` 在有 ping 时发 `SendChatPacket("/phase_select")`),`logout_button`→`"/logout"`(`net.LogOutGame()`),`mall_button`→`"/in_game_mall"`,`exit_button`→`get_tree().quit()`,`help_button`→占位 `toast`,`cancel_button`/标题栏→关。modal 打开。`game_scene`:ESC 从直接 `system_option_ui.toggle()` 改成 `system_menu_ui.toggle()`。<br>`project/ui/game_option_ui.gd`(`gameoptiondialog.py` 真 uiscript)= `uigameoption.py`:`BLOCK_BITS` = `block_{exchange,party,guild,whisper,friend,party_request}_button`(toggle_button) → `_toggle_block(bit)`:`_block_mode ^= bit` + `client.say(0, "/setblockmode %d" % _block_mode)`(`Packet.h EBlockAction` 位 `1<<0..1<<5`,逐字对照 `__OnClickBlock*` 的 `net.SendChatPacket("/setblockmode " + str(blockMode ^ player.BLOCK_*))`)+ 持久化 `[gameopt] block_mode`(服务器回包同步待补)。`PK_CMD` = `pvp_{peace,revenge,free,guild}`(radio) → `client.say(0,"/pkmode %d")` 值 `{0,1,2,4}`(`__OnClickPvPMode*`;guild 是 `/pkmode 4`,`PK_MODE_PROTECT`=3 跳过)。`DISPLAY_RADIOS`:`name_color`/`target_board`/`view_chat`/`always_show_name`/`show_damage`/`salestext` 的 on/off/normal/empire radio → `_display[key]` + 持久化 `user://system_option.cfg [gameopt]`(等价 `systemSetting.Set*Flag`;渲染侧钩子——头顶名字配色 / 伤害数字 / 聊天显隐——待补)。`_bind_radio` 处理 radio 배타성,`_sync`/`_sync_block` 开窗回填,`_relabel` 中文兜底(uiScriptLocale 未接全局 Locale)。`system_menu_ui_test.gd`:两窗装载 + `/setblockmode 1`→`3`→`2`(位 XOR)+ `_block_mode` 跟踪 + `/pkmode 1`/`4` + radio 배타 + 显示开关落 cfg + 二次实例读 `block_mode` 回填按钮态 + menu 各键 `/in_game_mall`/`/phase_select`/`/logout` + `system_option_button`/`game_option_button` 开子窗关菜单 + `cancel` 关。C++ 无改动 | `project/ui/system_menu_ui.gd` · `project/ui/game_option_ui.gd` · `project/game_scene.gd` · `project/system_menu_ui_test.gd` |
|
||||
| **系统设置窗(`uisystemoption.OptionDialog`)** | ✅ `project/ui/system_option_ui.gd`:`UiScript`+`UiBuild` 装 `assets/uiscript/uiscript/systemoptiondialog.py`(board + titlebar + `music/sound_volume_controller` sliderbar + `camera_short/long` + `fog_level0/1/2` + `tiling_cpu/gpu` + `tiling_apply` + `bgm_button`)。控件绑定逐字对照 `assets/root/uisystemoption.py`:`OnChangeMusicVolume`→`snd.SetMusicVolume`+`systemSetting.SetMusicVolume`(接 `Audio.master_bgm` + 即时改在播 `_bgm[_bgm_cur].volume_db`),`OnChangeSoundVolume`→`Audio.master_sfx`(`play_ui`/`play_at` 每次读),`camera_short/long`→`game_camera.max_dist`(`CAMERA_MAX=[11,20]`,并 `dist=min(dist,max)`),`fog_level0/1/2`(浓/중/淡)→`__SetFogLevel` = `Environment.fog_enabled=true`+`fog_density=[0.055,0.018,0.004][i]`,`tiling_*`/`tiling_apply`(原 CPU/GPU 分块渲染 + `net.ExitGame` 重启)Godot 渲染器无对应 → 保留占位。`_bind_radio` 处理 radio 배타성。持久化 `user://system_option.cfg`(section `audio`/`video`,= `systemSetting` 配置文件);`setup()` 读回并 `_apply_all()` 即时生效;`game_scene._on_main_set` 在 `.msenv` 换 `Environment` 后再 `_apply_all()`。`game_scene`:ESC 呼出(`ui_manager._unhandled_input` 先 `close_top()` 吃事件,无窗时才落到 `game_scene`)。uiScriptLocale 未接全局 Locale → `LABELS` 中文兜底。`system_option_ui_test.gd`:装载 + `music/sound` slider `value_changed` 已连 + `_on_*_volume` 应用到 `master_bgm/sfx` + camera radio→`max_dist`+dist 夹 + fog radio→`fog_density` + cfg 落盘 + 二次实例读 cfg 即时应用。C++ 无改动 | `project/ui/system_option_ui.gd` · `project/game_scene.gd` · `project/system_option_ui_test.gd` |
|
||||
| **私人商店开设窗(`PrivateShopBuilder` 模型)** | ✅ `project/ui/private_shop_ui.gd` 从「前 5 行 checkbox」重做成 1:1 复刻 `assets/root/uiprivateshopbuilder.py`(**增量 36:布局改走真 uiscript `privateshopbuilder.py` —— board + TitleBar + `NameLine`(盖 LineEdit) + `ItemSlot` 5×8=40 grid + Ok/Close;背包候选面板挂窗右侧**):`_stock` = `itemStock`(`{格号 -> {cell,vnum,count,price}}`)。左侧 `_inv_list` 背包候选(排除已上货的 cell),点一件 → `_pick` 设 `_picked`(= `mouseModule` attached item);点右侧 40 格(`GRID_SLOTS=40` = `shop.SHOP_SLOT_COUNT`,5×8)空格 → `_ask_price`(`uiCommon.MoneyInputDialog`,`SpinBox`)→ `_place(slot, price)` 写 `_stock`(= `AddPrivateShopItemStock` + `itemStock[targetSlotPos]=(src)`);点已占用格 → `_stock.erase` (= `OnSelectItemSlot` / `DelPrivateShopItemStock`)。`_ok()`(OkButton → `BuildPrivateShop`):`_stock` 按格号 `sort()`、`display_pos = 格号`、`items.size() >= PRIVATE_SHOP_ITEM_MAX(39)` 截断 → `client.open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])`(`MyShopItem`/`TShopItemTable` 字段 1:1:`{u32 vnum, u8 count, ItemPos pos, u32 price, u8 display_pos}`)。`_close_shop()` = `close_private_shop()` = `SHOP_CG_END`。测试拆成独立 `private_shop_ui_test.gd`(真 uiscript,缺资产跳过):`_cells` 40 格 / `NameLine` 盖 LineEdit / 2 候选 / 空 stock 不发 / 拿起→价格弹窗→`_place` / 개설按格号排序(格 0 在格 3 前)+ `display_pos` / 点占用格撤下 / 철수;`shop_cube_mall_test` 私人商店段移除。C++ 无改动(`open_private_shop` / `MyShopItem` 早已就位)| `project/ui/private_shop_ui.gd` · `project/game_scene.gd` · `project/private_shop_ui_test.gd` · `project/shop_cube_mall_test.gd` |
|
||||
| **`SHOP_GC_START_EX` 多货架商店** | ✅ `wire.h`:`ShopTabHead{char name[SHOP_TAB_NAME_MAX=32]; u8 coin_type}`(static_assert 33)+ `SHOP_TAB_COUNT_MAX=3`。`entity_store`:`SHOP_GC_START_EX`(10) 解析 `TPacketGCShopStartEx{u32 owner_vid; u8 tab_count}` + `tab_count` × `{ShopTabHead, ShopItem[SHOP_HOST_ITEM_MAX_NUM=40]}`(对齐 `PythonNetworkStreamPhaseGame.cpp RecvShopSub_StartEx`)。`ShopEntry` 加 `u8 pos`(货架内槽位);`ShopTab{name, coin_type, items}` + `m_shop_tabs`;`SHOP_GC_START` 也改为按固定 40-槽数组填 `pos`(空槽计入,与参考 `SetItemData(j,...)` 一致);`SHOP_GC_END` 清 `m_shop_tabs`。`shop_items()` 保持是 `m_shop_tabs[0].items` 的镜像,旧 `get_shop_items()` 不动。`M2Client.get_shop()` → `{vid, open, tabs:[{name, coin_type, items:[{pos,vnum,price,count}]}]}`。`shop_ui.gd`:`tabs.size()>1` 时显示货架 `Button` 行,切货架重建列表;买位置 = `_active_tab * SHOP_SLOT_COUNT(40) + it.pos`(1:1 对齐 `uishop.py GetIndexFromSlotPos` = `tabIdx * shop.SHOP_SLOT_COUNT + slotPos`,`shop_buy` 的 `u8 pos` 容得下 3×40)。`net_entity_test`:双货架合成包 → 断言名称 / coin_type / 槽位保留 / tab0 镜像 / END 清空;`p8_test`:多货架 UI + 切页 + `buy pos 5`(tab0 slot5)/ `buy pos 43`(tab1 slot3) | `extension/src/net/{wire.h,entity_store.{h,cpp},m2_client.{h,cpp}}` · `project/ui/shop_ui.gd` · `extension/tests/net_entity_test.cpp` · `project/p8_test.gd` |
|
||||
| **选魔石窗(样板第 2 例)** | ✅ `project/ui/select_item_ui.gd`:`UiScript`+`UiBuild` 装 `assets/uiscript/uiscript/selectitemwindow.py`(`ItemSlot` = 5×8 `grid_table`,`ui_build._slot_cells` 按 `x_count/start_index/x_step/y_step` 生成 `slot_0..39`)。填格逐字照 `assets/root/uiselectitem.py.RefreshSlot`:遍历背包 `range(INVENTORY_PAGE_SIZE*2=90)` 格,`_is_metin`(`proto.item(vnum).type == 10 = ITEM_TYPE_METIN`)+ `_item_grade(vnum) <= 2`(= `proto.item(vnum).name` **内部名**最后一位数字,非数字按 0,复刻 `PythonPlayerModule.GetItemGrade`),`slotPos > 54` 截断,按背包格号升序映射到选择窗格 `_slot_to_inv[slotPos]=invCell`。点格 → `client.script_select_item(invCell)`(`send_script_select_item` → `CGScriptSelectItem{CG_SCRIPT_SELECT_ITEM 0x0903, len, selection}`,= `net.SendSelectItemPacket`)+ 关窗;`ExitButton` / 标题栏关闭 → `script_select_item(0)`(`uiselectitem.Close`),`_sent` 标记避免选完再补发 0。**触发链 1:1**:`quest_dialog.gd.parse_script` 新识别 EventManager `[SELECT_ITEM]` token → `has_select_item` → `_on_script` 发新 `select_item_requested` 信号(对应 `PythonEventManager EVENT_TYPE_SELECT_ITEM` → `interfacemodule.BINARY_OpenSelectItemWindow` → `wndItemSelect.Open()`);`game_scene` 在 `proto`/`item_list` 就绪后建 `select_item_ui` 并把该信号接到 `.open`。`select_item_ui_test.gd`:`[SELECT_ITEM]` token 解析 + 非 metin / grade>2 过滤 + 升序映射 + 点格发 `script_select_item(7)` + 关窗不补发 + 空手关窗发 `script_select_item(0)` | `project/ui/select_item_ui.gd` · `project/ui/quest_dialog.gd` · `project/game_scene.gd` · `project/select_item_ui_test.gd` |
|
||||
| **`item_proto` / `mob_proto` 读取器** | ✅ `mtproto::load_proto` —— 外层 `MIPX`(item:`[fourcc][ver=1][stride][elements][datasize]`) / `MMPT`(mob:`[fourcc][elements][datasize]`);内层 `MCOZ` CLZO 容器 = **XChaCha20**(本 fork 把 TEA 换成 libsodium:key=BLAKE2b(key16,"M2DevPackEncrypt"),nonce=BLAKE2b(key16,"M2DevNonce")[:24])解密 + **LZO1X** 解压。两把硬编码 4-DWORD key(`ItemManager.cpp` / `PythonNonPlayer.cpp`)。`ItemRecord`/`MobRecord` 解前导字段(`bSpecular`@234 → §2.7;**`alValues[6]`@191** —— aLimits[2]×5 @166 + aApplies[3]×5 @176 后,`bSpecular`@234 锚定整链)。armor 的 `alValues[3]` = race `.msm` 的 body shape index(对齐客户端 `__ArmorVnumToShape` `SHAPE_VALUE_SLOT_INDEX=3`)—— 实测 "Monk Plate Armour" 11200-09 全 = shape 3,specular 随强化 0→100 递增。`proto.item_mob` CTest 断言 11209 `values[3]==3` + `specular==100`。5748 items(stride 236)+ 1338 mobs(stride 335) | `extension/src/proto/proto.{h,cpp}` |
|
||||
| **`AssetResolver` 接包**(首版)| ✅ `mtpack::PackMount` —— 挂载多个 `.epk`,按虚拟路径(盘符 + 反斜杠 + 大小写不敏感 + `ymir work/` 后缀)resolve/read,`metin2_patch_*` 包后挂覆盖。`mtpack::AssetSource` —— 散文件 + 包统一:`read()` 取字节;`to_path()` 把包内文件解到缓存目录一次(路径型调用方不用改)。散文件优先于包(dev overlay,对齐客户端 `pack/` 覆盖)。`pack.roundtrip` CTest 扩:2 包挂载 + 覆盖 + 虚拟路径 + 非 ymir 路径。⬜ 把 `Metin2World`/`Metin2Model` 等 ~6 处 `resolver->resolve()` 换成 `AssetSource::to_path()`(机械改动)| `extension/src/pack/{pack_mount,asset_source}.{h,cpp}` |
|
||||
| **输入 & 游戏相机**(首版)| ✅ `game_camera.gd` = `GameCamera`:右键拖拽环绕(yaw/pitch clamp)+ 滚轮缩放(3.5–20m)+ 目标平滑跟随 + 地形防穿。`player_controller.gd` = `PlayerController`:**点地移动**(射线求地表交点 → 走过去,沿途 `is_blocked`(`attr.atr` bit0)判可行走,贴墙磨蹭按实际位移切回 wait —— 客户端预测,无服务端纠正)+ WASD 覆盖 + **选目标**(射线取最近 pickable → `target_selected` 信号)+ `anim_state`/`moved` 信号。已换进 `world_demo.gd`(去掉 ~70 行临时相机/输入)。⬜ 相机撞建筑/树淡出(§8.2)、格子 A* 寻路、NPC 交互/拾取 | `project/{game_camera,player_controller}.gd` |
|
||||
| **本地化**(首版)| ✅ `locale.gd` = `Locale`:加载 `locale/locale/<lang>/{locale_interface,locale_game,itemdesc,skilldesc}.txt`(`KEY\tVALUE`,UTF-8)。`t(key, args)` —— `%` 格式化 + 缺失回 `<KEY>`(开发期可见);`set_lang()`;`apply_font()` 挂 CJK ttf。已接 `charselect`(`JOB_*` 职业名 / `SELECT_LEVEL` / `SELECT_SELECT`=Start / `SELECT_CREATE`)+ `login`(`LOGIN_ID`/`LOGIN_PASSWORD`/`LOGIN_CONNECT`)。18 语言(拉丁/西里尔/希腊/阿拉伯,Godot 原生,无需打包字体;CJK 需外挂 ttf)| `project/locale.gd` |
|
||||
| **声音**(首版)| ✅ `audio.gd` = `Audio` 节点:**BGM 交叉淡**(2× `AudioStreamPlayer`,`bgm/*.mp3`)+ **UI 音效池**(8×,`**/sound/ui/*.wav`)+ **3D 定位音效**(`AudioStreamPlayer3D`,`**/sound/<rel>`)。路径解析走 11 个声音根 + `bgm/`(`.mss` 不用,Godot 原生解 mp3/wav/ogg)。已接:`charselect`→`characterselect` BGM + `<class>_select` 选人音;`login`→`loginok`/`loginfail`;`world_demo`→`Metin2AnimPlayer.motion_event` 的 `sound` 字段 → `play_at(model_pos)`(脚步/挥击)。⬜ 按地图 BGM(服务端 `GCMainCharacter.szBGMName`)、`.msenv` 环境音循环、战斗/怪物/NPC 语音 | `project/audio.gd` |
|
||||
| 登录 / 选人界面 | 🟡 首版 | `project/{login,charselect,ui_kit}.gd` |
|
||||
| 声音(`AudioLib`→Godot audio)| 🟡 首版 | `project/audio.gd` |
|
||||
| `item_proto` / `mob_proto` 读取器 | ✅ | `extension/src/proto/proto.{h,cpp}` |
|
||||
| 本地化(`EterLocale`)| ✅ 首版 | `project/locale.gd` |
|
||||
| 输入 & 游戏相机 | 🟡 首版 | `project/{game_camera,player_controller}.gd` |
|
||||
| `AssetResolver` 从包读取(接 eterpack)| 🟡 | `extension/src/pack/{pack_mount,asset_source}.{h,cpp}` |
|
||||
|
||||
## 3c. C 轨(渲染打磨,PARITY-GAP 不卡 §0 门禁的项)
|
||||
|
||||
| 项 | 状态 | 位置 |
|
||||
|---|---|---|
|
||||
| §8.2 相机遮挡淡出 + §8.3 相机撞建筑防穿 | ✅ 首版 | `extension/src/metin2_world.cpp`(层 2 盒碰撞)+ `project/game_camera.gd` |
|
||||
| §2.2 GPU 路径发型 | ✅ | `extension/src/metin2_model.{h,cpp}` |
|
||||
| §2.10 角色 LOD 距离切换 | ✅ 首版 | `extension/src/metin2_model.{h,cpp}` |
|
||||
| §3.5 地形 patch 裁剪 | ✅ 首版(拆 4×4 patch + 逐 patch 视锥剔除 + 远景剔除)| `extension/src/metin2_world.{h,cpp}` |
|
||||
| §2.6 顶点色 | ✅ 排查=不适用(全资产 gr2 无顶点色成员;逐顶点色是地形雾运行时 VB + `.mse` colorfactor)| — |
|
||||
| §1.2 `.mse` EffectLib 端口 | ⬜(大工程)| — |
|
||||
|
||||
## 4. 下一步
|
||||
|
||||
1. 打通到 `.203` 的连通性(见上)。可达后立即能测:
|
||||
- `./build/extension/net_probe 192.168.21.203 11000 admin 123456789` → 完整握手 + `CGLogin3` → `login_key`
|
||||
- GDScript 里 `M2Client.connect_to_server(...)` → 一路到 `char_list` / `entered_game`
|
||||
2. **`GCLoginSuccess3` 布局对真实字节校准**:`net_probe` / `M2Client` 收到后 dump 原始字节,
|
||||
核对 `TSimplePlayerInformation`(现假设 pack(1)、字段顺序照 `Packet.h:1161`)。size 不符会走
|
||||
`login_failed` 兜底。
|
||||
3. 游戏相位包解码:`GC_CHARACTER_ADD/DEL` / `GC_MOVE` / `GC_CHARACTER_UPDATE` →
|
||||
`NetworkActorManager` 端口 → 每实体一个 `Metin2Model`+`Metin2AnimPlayer`,用现有
|
||||
`Metin2World` 当场景。里程碑「角色连上、站在地图里、别的实体在动」。
|
||||
4. 输入(点地移动 → `CG_MOVE`)、聊天(`CG_CHAT`/`GC_CHAT` 接 HUD)。
|
||||
|
||||
## 4. 风险
|
||||
|
||||
- **协议版本漂移**:`Packet.h` 结构体要和服务端 build 一致。冻结一对 client/server commit。
|
||||
- **端到端测试阻塞**:目前握手端口 refused,走不到 `CGLogin3` 之前的验证。
|
||||
- **移动端 libsodium**:Android/iOS 要静态 vendor(本身可移植,非阻塞)。
|
||||
- 自用范围可跳过:商城 / GM 工具 / 部分社交系统。**反作弊(`CG_HACK` / `ProcessCRC` /
|
||||
`ProcessScanner`)已在 `CLIENT-GAP.md`「不做清单」标注 ❌ 不做**,除非服务端强制校验。
|
||||
|
||||
## 5. 移植面(客户端源码)
|
||||
|
||||
`EterBase/SecureCipher` · `EterLib/{NetStream,ControlPackets,NetPacketHeaderMap}` ·
|
||||
`UserInterface/{Packet.h, AccountConnector, PythonNetworkStream, PythonNetworkStreamPhase*}` ·
|
||||
`UserInterface/NetworkActorManager`(实体) · `GameLib`(玩法)。
|
||||
Reference in New Issue
Block a user