Files
mtgodot-poc/docs/PORT-PLAN.md
T
shenleiandClaude Opus 5.5 ac4c2aa4e0 2V2-e.3: live smoke of the Godot render adapter
Fix the material cache key: bools given to %d failed to format, so every
draw shared the first material (the face texture). The render test now
checks one texture per texture name. Offline and live (192.168.21.203)
show the dressed main character; live also shows the nearby mobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:42:24 +09:00

67 KiB
Raw Blame History

40250 1:1 移植:路线与方案

本文是移植工作的总入口,换一台电脑继续开发时先读这份。更新日期:2026-09-22。

  • 方法细则:.agents/skills/metin2-40250-parity-audit/SKILL.md(每轮的做法、状态定义、工具)
  • 批次队列:audit/remediation-roadmap.md
  • 逐函数进度:audit/port-map/**;每轮一行记录:audit/history.jsonl
  • Python UI 评估:docs/PYTHON-EMBED-EVAL.md
  • 旧方案 docs/CLIENT-REFACTOR-40250-PLAN.md("形式上不照搬类文件")已被本文取代

1. 目标与原则

metin2-client 是 40250 Windows 客户端的跨平台版本。目标平台是 macOS arm64 和 Android arm64(2026-09-23 定); iOS arm64 保持交叉编译干净,但不作为验收目标。Linux 和 Windows 不做——不验收、不作为任何步骤的阻塞项。 仓库里的 mingw-w64 交叉编译只留作可移植性门禁(它这次抓到了 UserInterface/StdAfx.h 的真问题), 不代表支持 Windows,也不要为它投入额外工作。 除渲染和平台 API 外,所有玩法算法、分支、常量、状态顺序、计时来源、数据来源、封包副作用和清理路径都以 40250 为准。40250 源码就是规格,不设计行为,只照抄。

  • 工作单位是 40250 的一个源文件(或超大文件里的一组函数),不是某个行为分支。
  • 当前代码里没有 40250 对应物的逻辑是缺陷,要删掉。
  • 运行时不会加载的代码不算实现,它的测试也不算证据。

2. 已定的决定

日期 决定 依据
2026-09-22 逻辑层按 40250 结构移植:C++ → C++,文件名、类名、方法名、成员名、语句顺序都保持一致 能逐文件机械核对,不再"找差异"
2026-09-22 UI 走内嵌 CPython 2.7.18(方案 A),原样运行 40250 的 root/*.py 和 uiscript/,只移植 C++ 模块 脚本无需改动;macOS 和 Android 真机均已验证,见 docs/PYTHON-EMBED-EVAL.md
2026-09-22 数据源统一改为 40250(Client/Eternexus 的 root/uiscript/locale,以及 Client/pack 的资源),不再用仓库里 m2dev 版本的 assets/root、assets/uiscript、assets/locale 两者有实质差异,见第 5 节
2026-09-22 现有 project/ui/*.gd(60 个文件,依据的是错误版本的脚本)不再维护,由 Python 层取代 —
2026-09-22 照抄时保持 40250 的宽度和溢出语义(Win32 下 long 为 32 位),序列化结构用定宽类型并 static_assert 大小 64 位平台 long 是 8 字节,机械照抄会破坏 proto/封包/EPK 布局和 TEA
2026-09-22 迁移期新旧路径共存,新路径接通运行后才在同一提交删除旧逻辑;纵向切片拆成 2V0–2V3 main 始终可玩;运行时不可达的新代码不算实现

3. 三个层和代码位置

层 40250 单元 做法 位置
logic UserInterface/、GameLib/、EterLib 的网络/计时/文本解析、EterPack、EterLocale、EterBase 的纯逻辑单元(tea、lzo、cipher、Random、Stl、Timer、Poly/) 逐文件照抄 C++ extension/src/port/<Lib>/<File>.{h,cpp}
python root/*.py、uiscript/、UserInterface/*Module.cpp、EterPythonLib/、ScriptLib/ 脚本原样运行;*Module.cpp、窗口系统、CPythonLauncher 照抄 extension/src/port/<Lib>/ + extension/third_party/cpython-2.7.18/
platform Direct3D/Grp*、Granny、Miles、SpeedTree、特效/地形渲染、Win32 窗口/输入/IME、线程、反作弊、EterBase 的文件/OS 单元(CPostIt、CRC32、Debug、FileBase、FileDir、FileLoader、MappedFile、TempFile、Utils、error;CRC32、Utils 里的纯函数在 platform 实现里照抄) 适配层,接口与 40250 调用方看到的一致,按可观察输出核对 extension/src/platform/ + 现有渲染代码
extension/src/port/UserInterface/InstanceBase.cpp      # 40250 同名文件的照抄
extension/src/port/GameLib/ActorInstance.cpp
extension/src/port/ScriptLib/PythonLauncher.cpp
extension/src/port/EterPythonLib/PythonWindow*.cpp
extension/src/port/UserInterface/*Module.cpp           # app/net/player/chr/... Python 模块
extension/src/port/EterLib/GrpBase.h                   # 平台类的 40250 头文件同样照抄在镜像路径(接口)
extension/src/platform/                                # 这些平台类的实现(CGraphicThingInstance、CSoundManager 等)
extension/third_party/cpython-2.7.18/                  # 静态库(2P 批次加入)

逻辑层规则:

  • 40250 调用平台类的地方,调用 extension/src/platform/ 下同名的适配接口,ported 代码里不直接写 Godot 调用。 闭包里的 40250 头文件不分逻辑层和平台层,一律照抄到镜像路径(port/EterLib/GrpBase.h 等),作为平台类的接口, 这样原来的相对 include(../eterLib/StdAfx.h)保持不变;platform/ 只放这些类的 .cpp 实现,不另写同名头文件。
  • 照抄只做机械转换,用 port_copy.py copy <Lib/File>:CP949 转 UTF-8、CRLF 转 LF、#include "..." 路径改成磁盘上的 大小写;含非 ASCII 字符串字面量的文件拒绝照抄,需手工处理。其他任何改动都是手工修改,行上标 // PORT: 并写原因, port_copy.py diff 列出全部手工修改。
  • 40250 源码里的系统头保持原样 include,由 port/common/shim/ 提供替身:shim/sdk/(d3d8.h、d3dx8.h、mss.h, 所有平台都用,只含值类型和不透明接口)、shim/win32/(windows.h、winsock.h、mmsystem.h 等,仅非 Windows 平台用)。
  • 40250 的单例(CPythonPlayer、CPythonCharacterManager、CPythonNetworkStream)归扩展所有,GDScript 不持有玩法状态。
  • 保持 40250 的宽度和溢出语义,不是机械保留 C++ 类型名。 40250 可执行文件目标是 32 位 Win32(ILP32): long/unsigned long 和指针都是 32 位;移植目标则可能是 LP64 或 LLP64。port/common/Win32Types.h(批次 2A) 只为 BYTE/WORD/DWORD/LONG/BOOL/UINT 等 Win32 标量别名提供定宽定义,不能重定义 C++ 关键字 long:
    • 凡是参与序列化的结构(proto 记录、封包、EPK 索引、msa/msm 二进制)一律用定宽类型,#pragma pack 与 40250 相同, 并对 40250 已知大小加 static_assert(sizeof(...) == N)(如 TItemTable == 156);
    • 纯计算里的 long 按语义改成 int32_t/uint32_t;依赖 32 位回绕时使用无符号运算或显式 wrapping helper,不能依赖 C++ 有符号溢出的未定义行为;
    • HANDLE、HWND、WPARAM、LPARAM 等句柄/指针类型保持指针宽度并隔离在 platform adapter;指针存进 DWORD 的原写法改成 uintptr_t,在 port-map 的 note 里记录适配不变式。
  • 单位保持 40250 的(TPixelPosition 用 cm,时间是 ELTimer_GetMSec 的毫秒,角度用度),只在适配层换算。
  • 迁移来源:net_play.gd、net_world.gd、game_scene.gd、player_controller.gd、entity_store.cpp。旧逻辑只在新路径 已接通运行时之后才删除(见第 4 节"迁移方式"),删除与接通在同一次提交完成。
  • 迁移结束后 Godot 这边只剩:场景节点、渲染适配、一个把输入转发给 CPythonWindowManager 并每帧调用 Update/Render 的宿主 Control。

4. 路线

批次 内容 状态
0 工具:参考根解析、port_map.py、构建/测试入口 完成(55f733fd、5afd5a4f)
1 删除运行时不加载的 106 个 *_system.gd 及其测试 完成(a989b8f1)
— 定下目录结构,评估内嵌 Python;Android 独立程序验证 完成(9d0e50de、b02c49bb)
2A 基础:Win32 类型层、参考公共头的最小闭包、port_logic CMake 目标、platform 接口骨架、头文件可编译门禁、port-map 重新基线 进行中(剩 Python 库闭包头,等 2P)
2R 资源包能力盘点:EPK 类型/密钥/覆盖顺序/路径大小写/移动端交付 完成:只有 NONE/COMPRESS/SECURITY,不需要服务器密钥;设备上直接读 EPK
2P CPython 2.7.18 编进 libmtgodot,macOS arm64 + Android arm64 分别配置和验证 进行中:step 1(静态库 + macOS/Android/iOS 配置)、step 2(ScriptLib 进 port_logic)、step 3a/3b(python27.zip + PythonHost + pack 模块)完成
2D 数据源切到 40250(msm 路径、proto、资源根、严格资源测试) 完成(5a26e93f、232d0461、240d2827、a9b31dec);资源根只切了 proto/pack 后端,其余按第 5 节由 2V 切片逐个切换
2V0 UI 壳:真实 PythonLauncher/wndMgr/grp/app + platform UI,其他玩法模块用桩,显示并操作 Logo/Popup 需要 2A、2P 和 root/uiscript 子集
2V1 登录/选角:真实 net phase、登录/选角脚本及所需模块,走到 Loading 2V0 之后
2V2 GamePhase:真实角色管理、主角创建和最小渲染,进入游戏并显示角色 2V1、2D 之后
2V3 本地移动:真实 player/input/event/movement 链,输入、位置和移动封包切到新路径 2V2 之后;第一个可玩的新架构里程碑
2 2V3 未覆盖的其余 P0 角色/移动单元,按依赖拓扑移植 2V3 之后
3 剩余 Python 模块和窗口 → P1 战斗/技能 → P2 游戏阶段封包 → P3 物品 未开始
4 NEEDS_LIVE 真服验证 未开始

迁移方式:始终保持可玩

旧 GDScript 路径和新的 ported 路径在切片接通前共存,由构建/运行开关选择(例如 MT_PORT_PATH=legacy|port,默认 legacy)。规则:

  • 新单元先编译进 port_logic,但只有被新路径的运行时调用到,才算实现("运行时不可达不算实现"同样适用于新代码); 没有接通前,port-map 状态保持 TODO,note 写"已照抄,未接通"。
  • 每个接通步骤的提交同时:切换调用方到新路径、删除被取代的旧逻辑、跑一遍能覆盖该路径的运行测试(离线 FakeClient 或真服 e2e)。
  • 每次接通都同时跑 legacy 和 port 两套路由的相关回归;共享 extension 的改动不能破坏默认的 legacy 路径。
  • main 在任意提交上都能进游戏走动;legacy 路径在 2V3 完成、默认值切到 port 并稳定后整体删除。

批次 2A:基础

  1. extension/src/port/common/:Win32Types.h(BYTE/WORD/DWORD/LONG/BOOL/UINT 等标量的定宽映射; HANDLE/窗口句柄/消息参数另用指针宽度的平台类型)、 40250 用到的 Win32/CRT 宏和函数(ZeroMemory、_snprintf、stricmp、timeGetTime 等)的最小实现、 StdAfx.h 等价物。GameLib 的逻辑头直接使用 D3DX 数学类型(TPixelPosition 就是 D3DXVECTOR3),所以还要一个 与平台无关的 D3DX 数学层(D3DXVECTOR2/3/4、D3DXMATRIX、D3DXQUATERNION、D3DXCOLOR 及逻辑层用到的 D3DX* 函数), 按 D3DX8 的 float 语义实现。
  2. 参考公共头的最小闭包:从第一批要移植的单元出发(PythonPlayerEventHandler.h 依赖 ActorInstance.h、FlyHandler.h、 PythonNetworkStream.h、InstanceBase.h),用脚本列出 #include 闭包,把闭包里的头文件先照抄为可编译的声明。
  3. port_logic 静态库 CMake 目标,链接进 libmtgodot;macOS 和 Android 分别编译(iOS、mingw-w64 作为附带的 可移植性门禁一起编)。某个目标平台的工具链暂不可用时,2A 对该平台保持 BLOCKED,不能用其他平台的通过结果替代。
  4. extension/src/platform/ 接口骨架:闭包里出现的平台类(CGraphicThingInstance、CSoundManager 等)只声明 40250 调用方用到的方法,先给空实现。
  5. 门禁:port/** 下每个头文件单独编译通过(header self-containment),序列化结构的 static_assert 全部通过。
  6. port-map 重新基线(见第 6 节)。已完成(2026-09-22)。
  7. 用 #include 依赖图生成批次 2 的移植顺序(拓扑序),替换原来"互不共享实现文件即可并行"的假设:共享头文件的单元, 头文件由先做的那个单元负责,后面的单元只能在它合入后开始。 已完成:port_deps.py(显式 #include + 按符号解析的 StdAfx 隐式依赖)生成 audit/slices/2V0–2V3.json 和 audit/slices/batch2-order.json。结论:每个切片的头文件闭包约 210–220 个(其中平台层约 100 个,来自 ThingInstance.h/GrpBase.h 等);P0 单元里 InstanceBase、PythonCharacterManager、PythonPlayer、 PythonPlayerEventHandler 的头文件互相包含,必须作为一组串行移植,ActorInstance.cpp 排在最前。

2A 当前进度(script/port_gate.sh 复现;构建目录 build-port-gate/):

  • 已完成:port/common/(Win32Types.h、Win32Crt.{h,cpp}(MSVC _snprintf 截断语义、timeGetTime 32 位回绕)、 StdAfx.h);port_logic 目标已链接进 libmtgodot,也可用 -DMTGODOT_BUILD_EXTENSION=OFF -DMTGODOT_BUILD_PORT=ON 单独构建;每个 port/**.h 独立编译两次的门禁 port_header_gate;port.common 测试。编译宏取 40250 发布版 Metin2Distribute.exe 的 Distribute|Win32 配置:所有库 NDEBUG,GameLib 加 USE_LOD,UserInterface 加 USE_LOD;_DISTRIBUTE;不定义 WIN32。

  • 已完成:D3DX 数学层 port/common/D3DXMath.{h,cpp}。内容包括:

    • 基础结构 D3DVECTOR/D3DCOLORVALUE/D3DMATRIX;
    • 类型 D3DXVECTOR2/3/4、D3DXMATRIX、D3DXQUATERNION、D3DXPLANE、D3DXCOLOR,运算符照 d3dx8math.inl (例如 v / f 实现为乘以 1/f);
    • 2V3 切片用到的 D3DXVec*、D3DXMatrix*、D3DXQuaternion* 函数,采用行向量、左手旋转、D3DX 的乘法顺序; 后续单元用到新函数时再按需补。

    测试在 port.common 里。

  • 已完成(步骤 2,不含依赖 Python 的库):2V3 切片闭包里的头文件照抄,共 173 个,覆盖 EterBase(含 Poly/)、 EterLocale、EterPack、EterLib、GameLib、MilesLib、EffectLib、EterGrnLib、SpeedTreeLib、SphereLib、PRTerrainLib、 EterImageLib。

    • 门禁中每个头文件前先 include 本库的 StdAfx.h(40250 把它作为预编译头)。
    • D3D8 的值类型(D3DFORMAT、D3DCAPS8、D3DLIGHT8、D3DMATERIAL8、渲染状态/变换/纹理阶段枚举、D3DFVF_* 等, 按 SDK 取值和布局并加 static_assert)放在 port/common/D3D8Types.h;COM 接口(IDirect3DDevice8、纹理/表面/ 缓冲区的继承层次等)只做不透明声明,由 platform 持有。
    • 第三方 SDK 头由 shim/sdk/ 提供不透明替身:granny.h(Granny 2.11 的标量和 granny_data_type_definition)、 SpeedTreeRT.h(CSpeedTreeRT 只声明头文件里用到的嵌套类型和方法)、d3dx8.h(加 ID3DXMatrixStack、 D3DX_FILTER_*)。EterLib/Dimm.h 是 SDK dimm.h 的 MIDL 输出,按 SDK 头处理:不进镜像,由 shim/win32/Dimm.h (连同 imm.h、dinput.h)提供。
    • EterBase/cipher.h 用 Crypto++,EterBase/lzo.h 用 LZO:port_logic 链接已 vendor 的 mt3p::cryptopp 和 mt3p::minilzo;单独构建时顶层 CMake 先加入 extension/third_party。
    • 手工 // PORT: 修改共 10 个文件(port_copy.py diff 列出):
      • Random.h:random/srandom 与 POSIX 重名,改名;
      • Singleton.h:(int) 指针运算改为 intptr_t;
      • Pool.h:operator new(unsigned int) 改为 size_t;补 <algorithm>(MSVC 头文件间接带入 std::find);
      • Stl.h:<SSTREAM> 改为小写;
      • Utils.h:补 <math.h>;
      • FlyTarget.h、EffectLib/ParticleSystemInstance.h:补前置声明(MSVC 会让 friend 声明可见);
      • EffectLib/Type.h:依赖名前补 typename;
      • EffectLib/EffectUpdateDecorator.h:指针转 (DWORD) 改为 (uintptr_t);
      • PRTerrainLib/StdAfx.h:去掉 #include "../ScriptLib/StdAfx.h"(PRTerrainLib 不用 Python,否则每个地形编译单元 都要 CPython 头)。
    • 40250 StdAfx 里有影响语义的宏,移植逻辑时要注意:
      • EterBase 的 #define atoi _atoi64;
      • UserInterface 的 _USE_32BIT_TIME_T;
      • EterBase/ServiceDefs.h 的 _IMPROVED_PACKET_ENCRYPTION_;
      • 工程字符集是 MultiByte,所以 TCHAR 就是 char;
      • 非 Windows 上 WCHAR 是 32 位 wchar_t(Win32 是 16 位),只影响 IME 路径,移植 IME 时按 UTF-16 处理。
    • ScriptLib、EterPythonLib、UserInterface 的 StdAfx 会 include Python 头,要等 2P 把 CPython 加进来之后再照抄。
  • 已完成(步骤 4):platform 骨架 extension/src/platform/,目标 port_platform(静态库,链接 port_logic, libmtgodot 改为链接它;从 port/CMakeLists.txt 加入以共用每个库的编译宏;port_gate.sh 构建它)。

    • 平台层头文件(同名 40250 .cpp 属于 platform 层,见 port_map.py 的 LAYER_PATTERNS)对应 platform/<Lib>/<File>.cpp 95 个,1843 个函数桩(步骤 6 把 EterBase 的纯逻辑单元改为 logic 层后,删掉了 Random、Stl、cipher、lzo、tea、Timer、Poly/Poly、Poly/SymTable 的 8 个桩文件)。
    • 其中 22 个桩对应 40250 只声明、从未定义的函数(如 CEffectManager::GetRenderingEffectCount、Mutex::Trylock), 40250 里没有调用方,不算待移植函数。
    • 由 platform_stub.py gen 生成:用 clang 对门禁编译单元按类名/函数名 -ast-dump-filter 导出声明,定义头文件里 声明了但没有定义的成员函数、构造/析构、自由函数和静态成员;返回类型写成尾置形式,使类内嵌套类型在类作用域里解析, 静态成员写成 decltype(C::x) C::x{};。已存在的文件不覆盖(除非 --force)。
    • 函数体是 MT_PLATFORM_STUB() 加默认返回值(platform/PlatformStub.h);grep -r MT_PLATFORM_STUB 就是还没实现的 平台函数清单,设置环境变量 MT_PLATFORM_STUB_TRACE 后每个桩第一次被调用时打印到 stderr。
    • 6 个构造函数手工补了与 40250 相同的初始化列表(基类 CResource(c_szFileName)、CDecal 的 const 成员)。
    • 未覆盖:platform 层 .cpp 定义的逻辑层头文件成员(GameLib/MapOutdoorRender*.cpp、MapOutdoorWater.cpp 定义 MapOutdoor.h 的方法等)。这些在对应逻辑单元移植时手工补桩。
  • 已完成(步骤 6):port-map 重新基线,见第 6 节。

  • 未完成:依赖 Python 的三个库的闭包头(等 2P)。

平台 port_gate
macOS arm64 PASS(含 port.common 测试)
Android arm64(脚本取已装的最新 NDK,本机 28.2;API 24) PASS
iOS arm64 PASS(Xcode,未签名)
Windows x64 mingw-w64 PASS(只是可移植性门禁,不是目标平台;MSVC 不做)
Linux x86_64 不做(2026-09-23 定,见第 1 节)

已知的无关问题:完整 Android 扩展构建在 extension/src/net/text_codec.cpp 失败(bionic 从 API 28 才声明 iconv, 而构建用 API 24;c9389431 引入);仓库里的 build-android/ 缓存还指向旧路径 mtgodot-poc。

批次 2R:资源包能力盘点(完成,2026-09-22)

工具:tools/epk_scan/epk_scan.py(只读)。summary 统计并写 audit/packs/2R-scan.json;verify 解码全部可本地解的条目。 索引密钥和 SECURITY 密钥运行时从参考树 EterPack/EterPack.cpp 读取,仓库里没有密钥。读取链照 40250:PackInitialize (pack/Index)→ CEterPackManager::RegisterPack(每个包再注册 <name>_texcache,最后 RegisterRootPack("pack/root")) → CEterPack::__BuildIndex(.eix 全部是 MCOZ:TEA + LZO 包着 EPKD v2,每条 192 字节)→ Get2。

  1. 类型统计(Index 注册到的 103 个包加 root,54,891 条,52,609 个不同路径):

    类型 文件数 存储 解码后
    NONE 20,727 737.2 MiB 737.2 MiB
    COMPRESS(LZO) 29,812 592.9 MiB 1296.8 MiB
    SECURITY(TEA + LZO) 4,352 2.0 MiB 6.6 MiB
    PANAMA / HYBRIDCRYPT* 0 — —

    verify --per-type 1000000 全部 54,891 条解码成功。每个包的类型分布见 JSON 的 per_pack。

  2. 密钥来源:这个客户端只用编译进客户端的两组 TEA 密钥(s_adwEterPackKey 解索引,s_adwEterPackSecurityKey 解 SECURITY 数据)。没有 PANAMA 和 HybridCrypt 条目,所以不需要服务器下发 IV/密钥,也不需要真服抓包。 CAccountConnector 的 RegisterPack(..., abIV) 和 GC_HYBRIDCRYPT_KEYS/SDB 照抄移植即可,但这批资源用不到。 移植后的 EterPack.cpp 需要这两组常量;按"密钥不进仓库",由构建时从参考树生成一个 gitignore 的头文件,不直接写进源码。

  3. 注册顺序与覆盖:

    • Index 第 0 行是 PACK,后面是 (目录, 名字) 对,共 124 个名字。
    • 124 对里有 119 次 RegisterPack 找到了包(有几个名字重复出现),去重后 103 个包(含两个 metin2_patch_dragon_rock*_texcache),再加 root。2D 用移植后的 CEterPackManager 复核过(原先写的 136 有误)。
    • 缺失的包:106 个 _texcache,以及 locale_{ae,bg,ca,mx,pa,uk,us}。40250 遇到缺失的包只记日志、继续执行。
    • 目录里有、但没注册的文件,40250 永远不会读到:Outdoor、Psyco(0 字节)、outdoorempirebattle1、两个 .rdch。 .rdch 在源码里没有任何引用。
    • 查找由 CEterFileDict 完成,它是以 filename_crc 为键的 unordered_multimap。GetItem 在 equal_range 里返回第一个 同名项。40250 用 VS2022(ClientVS22)构建,MSVC STL 的 multi 插入会把新元素放在同键已有元素的后面,所以先注册的 包优先。UpdateItem(后注册的覆盖先注册的)只在带 IV 的注册里用到,这批资源没有。
    • 同名条目共 2,282 个,其中 2,194 个内容不同。主要是 patch1/patch2/season3_eu/metin2_patch_* 覆盖后面的 zone、pc*、season2、outdoor*、icon。覆盖顺序会影响画面,必须 1:1。
    • 包里有两种打包器留下的格式:
      • Ymir 格式:data_size 不小于整个 CLZObject,常补到 256 字节的倍数;data_crc 按存储字节算。
      • 另一种格式出现在 metin2_patch_sd、metin2_patch_mineral、metin2_patch_mundi 三个包里:data_size 比 CLZObject 短 16 字节,data_crc 按解码后的内容算。
    • 在第二种格式下,40250 的 COMPRESS 条目仍能读出来:CLZO::Decompress 按 LZO 头里的长度读,不看 data_size,读到 映射视图里 data_size 之后的字节。但 SECURITY 条目过不了 Get2 的 CRC 检查。
    • 结果是 4 个文件在原版里加载失败:npc/mineral2/wait.msa、effect/background/metinstone_loop_redblack.mse、 monster2/spider_spawn/{wait.msa,line1.mse}。第 5 个 effect/etc/buff/buff_symbol1.mse 由先注册的 metin2_patch_eu4 提供,不受影响。
    • 移植的读取器要照这个行为实现:按头部长度读,保留 CRC 拒绝。
  4. 路径归一化:

    • ConvertFileName 只做两件事:ASCII tolower,以及 \ 转 /。CP949 字节原样保留。
    • 包内名字全部已经是小写、只用 /;52,609 个路径没有大小写冲突,filename_crc 与 crc32(name) 全部一致。
    • d:/ymir work/... 是包内的真实路径,一共 26,955 个。40250 的相对路径映射已经注释掉,所以调用方必须使用 40250 原样的 路径名,不能再用 m2dev assets/ 的相对路径。
    • 包文件名与 Index 大小写不一致,例如 Etc 对应磁盘上的 ETC.eix。NTFS 下能打开;在移动端和 Linux 上,按包名查找 磁盘文件时要做大小写不敏感匹配。
  5. 移动端交付(已注册包的存储大小共 1337 MiB):

    分组 大小
    metin2_patch_*/patch1/patch2/season3_eu 692.7 MiB
    核心 d:/ymir work(pc/monster/zone/effect/npc/item…)+ icon/property/textureset 405.7 MiB
    sound/bgm 87.6 MiB
    season1/2 71.5 MiB
    其他 14 种语言的 locale 49.9 MiB
    地图包 25.9 MiB
    locale_en 3.5 MiB
    root + uiscript 0.6 MiB

    决定:

    • 设备上直接读 EPK,用移植的 EterPack/EterPackManager 经 asset_io 读取。不重新打包成 mtpack。这样覆盖顺序、 CRC 拒绝等行为天然 1:1,更新时直接替换包文件。
    • 首包包含 Index 里除其他语言外的全部包,约 1.29 GB。其他语言按需下载。
    • patch 包在覆盖链的最前面,按需下载会改变已有文件的内容,所以不做细粒度按需下载。
    • 如果以后要按需下载,任何新到的包都必须按 Index 顺序重建整个字典,不能追加到末尾。否则先注册者优先的结果就变了。
    • 更新方式是整包替换 .eix/.epk,由我们自己的清单记录版本号和 sha256。
  6. 2D 的资源根切换:

    • 新增 pack 后端:asset_io 在 MT_ASSETS 之外,接上移植的 CEterPackManager,按 pack/Index 注册。
    • 渲染和 GDScript 的资源请求改用 40250 路径,经过 ConvertFileName 查找。
    • m2dev assets/ 的散文件保留作开发期覆盖,直到对应读取方切到新路径。
    • 严格门禁(第 5 节步骤 3)的来源清单改为记录各个 .eix/.epk 的 sha256。

批次 2P:内嵌 Python 集成

  1. CPython 2.7.18 源码放进 extension/third_party/cpython-2.7.18/。共用源码清单和静态模块清单(Modules/Setup 中启用的 C 模块,见 tools/py_embed_android/build-and-run.sh);每个平台各自一份 pyconfig.h(由该平台的 configure 生成后提交),因为它是对目标平台类型大小和系统 API 的探测结果,不能共用。已知平台差异:Android API 24 需关掉 HAVE_LANGINFO_H。 已完成:mtpython 静态库(-DMTGODOT_EMBED_PYTHON=ON,133 个目标文件 = 参考 libpython2.7.a 的全集), tools/py_embed/gen_pyconfig.sh <平台> 生成各平台 config/<平台>/pyconfig.h 并校验内建模块表一致, extension/tests/py_embed_test.cpp(ctest python.embed)。三处 vendor 补丁见 docs/THIRD-PARTY.md。 目标平台是 macOS arm64 和 Android arm64;Linux/Windows 不做(见第 1 节)。

  2. 静态链接进 libmtgodot,照抄 ScriptLib/PythonLauncher.cpp。 已完成:ScriptLib/{PythonLauncher,PythonUtils,PythonMarshal}.{h,cpp} 进入 port_logic(mt3p::python), common/shim/sdk/Python-2.7/ 12 个转发头让 40250 的 #include <Python-2.7/*> 原样编译 (symtable.h 自带 mod_ty 前向声明,绕开 Python-ast.h 的 Delete/Module 等单词宏与 EterLib/Pool.h 冲突)。 extension/tests/port_python_launcher_test.cpp(ctest port.python_launcher)按 UserInterface.cpp:241-434 的顺序跑: Create() → __DEBUG__/__COMMAND_LINE__ → TRUE/FALSE → RunMemoryTextFile(CRLF)→ RunCompiledFile(.pyc) → RunFile("system.py") 从真 pack 里取脚本,停在 ImportError: No module named app,即 2V0 的 C++ 模块。 Py_NoSiteFlag 等宿主标志目前在测试里设置,step 3 移进 platform 层。mingw-w64 可移植性门禁下没有 mtpython, port_logic 会自动排除 ScriptLib 和这些转发头(连带 UserInterface/StdAfx.h、UserInterface/PythonPackModule.cpp)。

  3. pack 模块通过 asset_io 读取 40250 的 root/uiscript。标准库沿用已验证的 python27.zip + sys.path:

    • 桌面端把 zip 放在 CPython 可读的真实文件系统路径;
    • Android/iOS 首次启动从应用资源复制到应用沙盒,校验提交在资源清单中的 sha256,再加入 sys.path;
    • zipimport 静态编进解释器。system.py 的导入钩子只负责 pack 内的简单 root 模块,不能用来加载标准库 package、 dotted import 或 encodings。

    已完成(3a 标准库 + 3b pack 模块):tools/py_embed/make_stdlib_zip.py 把 vendored Lib/ 打成 <build>/python27.zip(397 个模块 / 6.9MB,全部 STORED——没有 zlib 模块,zipimport 解不了 deflate; 内容确定性写入,sha256 就是 3c 的校验值),CMake 目标 mtpython_stdlib 跟着 Lib/*.py 重建。 宿主引导收进 extension/src/platform/ScriptLib/PythonHost.{h,cpp}:Configure() 设四个标志和程序名, InstallStdLib() 在 Py_Initialize 之后换掉 sys.path 并重新 codecs.register(encodings.search_function) (2.7 的 codec 注册表只在启动时初始化一次)。python.embed 现在直接跑 zip。 UserInterface/PythonPackModule.cpp 已移植(packExist/packGet/initpack),经 CEterPackManager 读 pack,即 asset_io 的 pack:// 用的同一套;port.python_launcher 验证 pack.Get 取到的 system.py 与 CEterPackManager 的字节数一致,并能读 uiscript/selectcharacterwindow.py、 locale/en/locale_game.txt,非 .py/.pyc/.txt 和不存在的文件返回 None。 UserInterface/StdAfx.h 只镜像了当前能编的部分(原文件 include 整个客户端和 DirectShow), PythonPackModule.cpp 里属于 CAccountConnector 的登录密钥函数原样保留但编译时排除,2V1-b 已恢复编译。 已完成(3c 落盘):make_stdlib_zip.py 额外写出 python27.zip.sha256;构建把两个文件 copy_if_different 到 project/(目标 mtpython_stdlib_project,两文件 gitignore), 导出预设的 include_filter 列出它们,所以它们进 PCK——--export-pack 验证过两个条目都在。 extension/src/python_stdlib.{h,cpp}(GDScript 侧 Metin2Python.stdlib_path(force_stage)): 桌面 res:// 就是磁盘目录,globalize_path 得到真实路径直接用,不复制;导出后 res:// 在 PCK 里, 于是读出字节 → 写 user://python27.zip.part → 按随包 sha256 校验落盘后的文件(不是内存里的 buffer, 磁盘满要在这里报,而不是三个 import 之后变成 zipimport 失败)→ rename 成 user://python27.zip。 已经在沙盒里且 sha 相符就直接复用;被杀在半路的半成品或换了一版构建的旧 zip 都会重新落盘。 路径交给 PythonHost::SetDefaultStdLibPath(),DefaultStdLibPath() 从此答得出(MT_PYTHON_STDLIB 仍优先)。 测试 project/python_stdlib_test.gd(godot --headless --path project --script python_stdlib_test.gd)在 macOS 上用 force_stage=true 跑的就是移动端那条路径:落盘、sha 一致、字节与 res:// 相同、.part 不残留、 ZIPReader 能打开且含 encodings/__init__.py,重复调用不重复复制,人为弄坏副本后重新落盘。 未完成:真机上跑(Android/iOS 的 user:// 沙盒路径本身没验过),随 step 4 一起; 进程内的调用者见 step 4(Metin2PythonHost.start() 空路径时就调 Metin2Python.stdlib_path()); 正式启动路径要到 2V0 才起解释器,现在只有 MT_TEST_MODE=python 和测试调。 port_platform 现在在没有 mtpython 时也会排除 platform/ScriptLib/(与 port_logic 同一条规则), 否则 Windows 门禁会编到 PythonHost.cpp。

  4. 两个目标平台分别验证:编译、链接无未定义符号、Py_Initialize、静态 C 模块逐个 import、在 app 进程里跑 system.py → prototype.RunApp()(对照结果:74 个模块中 66 个加载成功,引导期调用 33 个 C++ 函数)。

    进程内入口(2026-09-23):extension/src/platform/ScriptLib/PythonBoot.{h,cpp} 是 40250 RunMainScript 的移植(audit/port-map/UserInterface/UserInterface.cpp.json), 拆成 Start / RunMainScript / RunLine / Stop——40250 把 CPythonLauncher 放在 Main() 的栈上, 这里进程归 Godot,launcher 必须活过创建它的那次调用。37 个模块初始化函数里只调了已移植的 initpack()。 GDScript 侧是 Metin2PythonHost(extension/src/python_host_node.{h,cpp},唯一同时认识 godot-cpp 和 port 树的单元;PythonBoot 保持 godot-free)。脚本报错能传回 GDScript,是因为 platform/EterBase/Debug.cpp 实现了 LogBox/LogBoxf:Traceback() 在 PyErr_Fetch 之后只剩这一条路 (platform/EterBase/LogBox.h 的 LastLogBoxMessage())。 检查脚本 project/python_host_check.gd 分两段:解释器 + 标准库那段不需要 40250 的 pack,哪个平台都能跑; system.py 那段需要 pack,没有就跳过。桌面入口 project/python_host_test.gd (godot --headless --path project --script python_host_test.gd),真机入口是 MT_TEST_MODE=python(导出模板不接受 --script)。

    平台 状态
    macOS arm64 step 1、2、3 完成,step 4 完成:mtpython 静态库 + python.embed(39 个内建模块全部 import,标准库 codec/pickle 通过)+ port.python_launcher(CPythonLauncher 跑到 system.py 的第一个 import)+ app 进程内(python_host_test.gd / MT_TEST_MODE=python):标准库可 import、codec 注册表可用,system.py 停在和 ctest 完全相同的 ImportError: No module named app。prototype.RunApp() 要等 2V0
    Android arm64 step 1 完成:NDK API 24 交叉编译出 libmtpython.a;独立可执行文件在真机跑通(b02c49bb);APK 进程内未做。step 2 的 ScriptLib 只在 macOS 编译过
    iOS arm64 step 1 完成:iphoneos arm64 编出 133 个目标文件,无 _system/_fork/_popen 未定义符号;真机未跑。step 2 的 ScriptLib 只在 macOS 编译过
    Linux x86_64 不做(2026-09-23 定,见第 1 节)
    Windows x64 不做(2026-09-23 定,见第 1 节)。记录原因备查:上游 2.7 只支持 MSVC + PC/pyconfig.h,而本仓库的 Windows 门禁是 mingw-w64 交叉编译,PC/pyconfig.h 的 gnu-win32 分支不设 HAVE_UNISTD_H,posixmodule.c/dynload_win.c 与 MinGW 头文件冲突。因此 mingw 门禁下不建 mtpython,port_logic/port_platform 自动排除 ScriptLib

已知问题:Android 的 GBK 编解码(挂起,2026-09-23)

优先级:macOS 先跑通,Android 后面再说(用户 2026-09-23 定)。

extension/src/net/text_codec.cpp:10 用 __has_include(<iconv.h>) 探测 iconv。NDK 里这个头文件在 任何 API 等级都存在,但函数声明被 __BIONIC_AVAILABILITY_GUARD(28) 挡住,所以 build-android.sh 的默认 API 24(build-android.sh:14)下编译报 use of undeclared identifier 'iconv_open'。

升到 API 28 不是解法:NDK iconv.h 原文写明 bionic 只支持 utf8/ascii/usascii/utf16be/utf16le/utf32be/utf32le/wchart,没有 GB2312/GBK。升级后 iconv_open("GB2312","UTF-8") 运行期返回 -1,encode_one()/decode_wire() 对所有输入都 返回 false——连纯 ASCII 也被拒,比现在 #else 的 ASCII 回退分支(text_codec.cpp:104-127)更糟。 附带成本:APK min_sdk 必须跟着 ≥28,且 config/android/pyconfig.h 是 API 24 下 configure 出来的 (HAVE_LANGINFO_H 因 nl_langinfo 需 API 26 被关掉),要重跑 gen_pyconfig.sh android 并重验内建模块表。

将来动手时分两步:

  1. 探测条件从「头文件在不在」改成「这个平台的 iconv 能不能用」——#if __has_include(<iconv.h>) && !defined(__ANDROID__), Android 一律走回退分支,先把交叉编译修绿。不要写成 __ANDROID_API__ >= 28。
  2. 补真正的转换:Android 需要自带 GBK↔Unicode 表(约 22k 码位)。40250 原客户端走 Windows MultiByteToWideChar(CP949/CP936),跨平台没有对应物;macOS 能用只是因为系统 libiconv 恰好带 GBK。

批次 2V0–2V3:纵向切片

2A 先根据真实 #include 和 Python import 生成每个切片的机器可读单元清单;清单提交到 audit/,进入切片前不得仍有 “后续批次才实现”的隐式依赖。未列出的 native 模块可以使用行为可观察、会记录调用的桩,但桩函数不能计为移植完成。

  1. 2V0 — UI 壳:真实 ScriptLib/PythonLauncher.cpp、PythonPackModule.cpp、 EterPythonLib/PythonWindow*.cpp、PythonWindowManager*.cpp、PythonGraphic*Module.cpp、 PythonApplicationModule.cpp 和所需 platform UI adapter;玩法模块先用桩。验收为 app 进程内运行 system.py,显示并可操作 Logo/Popup,输入、焦点、裁剪、文本和图片至少各有一个运行测试。

    入口状态(2026-09-23 实测):audit/slices/2V0.json 的 212 个头里 164 个已在镜像中,缺 48 个; 10 个单元里 ScriptLib/PythonLauncher.cpp、UserInterface/PythonPackModule.cpp 已移植,缺 8 个。 (清单里的 EterLib/Dimm.h 是误报:那是 Windows SDK <dimm.h> 的 MIDL 产物,40250 把它放在 EterLib 下, port/common/shim/win32/Dimm.h 已经顶掉了;镜像一份反而会遮住 shim,IME.h 就找不到 rpc.h。)

    子步骤,按依赖从下往上:

    子步骤 内容 状态
    2V0-a EterPythonLib/StdAfx.h + 5 个窗口系统头进镜像,门禁绿 完成
    2V0-b EterPythonLib/PythonWindow.cpp、PythonWindowManager.cpp(窗口树逻辑;含 Slot/GridSlot 与资源层依赖) 完成:原版源码机械移植,port.window_tree 通过;正式 UI 渲染仍属 2V0-f
    2V0-c wndMgr / grp / grpImage / grpText / grpThing 模块 绑定已移植并通过导入/窗口句柄测试;图形绘制仍属 2V0-f
    2V0-d 42 个 UserInterface 头(都在 PythonApplication.h 后面)+ PythonApplicationModule.cpp(app) 头与绑定已编译、链接并导入;应用生命周期 adapter 仍属 2V0-f
    2V0-e dbg 等桩模块补齐,system.py 跑到 prototype.RunApp() 完成:27 个玩法 native 模块导出名桩记录每次调用;真实 system.py 已进入 RunApp(),目前由待实现的 app.Create() 报 CREATE_DEVICE
    2V0-f Godot 渲染器上的 platform UI adapter;Logo/Popup 可显示可操作 完成:真实 system.py 在 app 进程内的脚本纤程上跑 app.Loop()(每个 UIUpdate = 一次 CPythonApplication::Process()),Logo→Login 切换、Popup ESC/点击、app.Exit 收尾(按 40250 Main 先 pyLauncher.Clear());图像统一为基贴图 + UV 的四边形命令(.sub 共用父贴图名),ExpandedImage 缩放/旋转/RenderingRect 原样移植;grp.GenerateColor 已移植(ThinBoard 半透明底)。port.app_loop + python_host_test.gd 覆盖输入/焦点/裁剪/文字/图像;MT_TEST_MODE=python_ui 可视运行。TODO:ExpandedImage 混合模式(SCREEN/COLOR_DODGE/MODULATE)未应用(字体管线见 2V0-g)。
    2V0-g .fnt 字体管线:CGraphicText / CGraphicFontTexture / CGraphicDib / CGraphicTextInstance 完成:四个文件原样移植(GrpTextInstance 只把 D3D 绘制换成 UI 命令:每个字形一个四边形,光标/IME 下划线为 Bar);GDI 子集(CreateFontIndirect/CreateDIBSection/TextOutW/GetTextExtentPoint32W/GetCharABCWidthsFloatW)在 platform/Win32Gdi.cpp 用 FreeType 按 GDI 规则模拟(LOGFONT 负高度=字符格高、win ascent/descent、gasp 决定单色/灰度、v35 解释器、斜体 oblique),字体按文件名在系统字体目录找,缺字体按 Arial/Roboto 等替换、缺字按 CJK 字体回退;字形页作为内存纹理(mem:<id>@<rev>)交给 Godot,Canvas 按 id 缓存、rev 变了才重取;MultiByteToWideChar/WideCharToMultiByte(UTF-8/1252 手写,其余代码页走 iconv);Util.cpp 代码页→字体表、SetDefaultCodePage 在启动时按 40250 Main 调用;DefaultFont_* 原样;fnt 资源类型注册。port.text(Tahoma 12 行高 12、宽度/字符定位/居中、CP949 往返)+ port.app_loop + python_host_test.gd 验证。TODO:Android 的 /system/fonts 没有 Tahoma/Gulim,需要随包带字体(现走 Roboto/Noto 替换,度量与原版不同);字形与 Windows GDI 的逐像素对比未做。
  2. 2V1 — 登录/选角:加入 PythonNetworkStream{,PhaseHandShake,PhaseLogin,PhaseSelect,PhaseLoading}.cpp、 PythonNetworkStreamModule.cpp、AccountConnector 及登录/选角脚本真实调用到的模块。验收为 FakeServer 离线完成 phase 顺序, 再以真服 smoke 证明登录、角色列表和选择进入 Loading;未验证的服务器分支记 NEEDS_LIVE。

    子步骤 内容 状态
    2V1-a EterLib/NetStream、NetAddress、NetDevice、NetPacketHeaderMap、EterBase/cipher.cpp 完成:五个文件 port_copy 原样(含 32768 字节序列表、_IMPROVED_PACKET_ENCRYPTION_ 的 Crypto++ DH2 + CTR 路径);shim/win32/winsock.h 把 Winsock 名字映射到 BSD socket,并补齐两处语义差异:select 忽略 nfds,connect 失败的 socket 不算可写(Winsock 只在 exceptfds 报,40250 靠超时判失败);非阻塞 connect 的 EINPROGRESS 报成 WSAEWOULDBLOCK;WSAStartup 忽略 SIGPIPE。port.net 用 loopback 覆盖连接、收发、对端断开、拒绝连接超时和双方密钥协商后的加解密
    2V1-b PythonNetworkStream + HandShake/Login/Select/Loading phase + AccountConnector 完成:PythonNetworkStream{,Event,PhaseHandShake,PhaseLogin,PhaseSelect,PhaseLoading,PhaseOffline}.cpp、AccountConnector.cpp、InsultChecker.cpp、AffectFlagContainer.cpp 原样 port_copy;Packet.h 里所有 long 字段改为 32 位 LONG(PORT 标注),port.packet 把登录/选角/Loading 用到的 38 个包的尺寸与已在真服 1:1 验证过的 net/classic/wire_classic.h 比对(两头宏冲突,分两个编译单元);PythonPackModule.cpp 的登录密钥函数恢复编译;Locale.cpp 的 LocaleService_Is*/安全密钥部分原样放进 platform PythonApplication.cpp;WiseLogicXTrap 取 40250 无 XTRAP 分支,ProcessCRC 为桩(魔方只用于 PhaseGame 攻击包)。链接闭包里尚未移植的逻辑单元(PythonPlayer、NetworkActorManager、FlyingObjectManager、GuildMark*、MarkManager、PhaseGame*/PythonGuild 的部分成员)放在 extension/src/platform/pending/<Lib>/ 作为替身,移植对应单元的提交里删除;Loading 阶段会收到的 CharacterUpdate/PointChange/ItemSet/QuickSlotAdd 替身只吃掉包体,保证读流不断。port_gate.sh macos 改为构建 all(此前 ctest 会跑门禁没构建的测试),port-only 配置补 find_library(iconv)
    2V1-c net 模块(PythonNetworkStreamModule.cpp)替换 2V0-e 的桩;app.Create/Process 接网络 完成:PythonNetworkStreamModule.cpp 原样移植,GameplayModules.cpp 的 net 桩删除,PythonBoot 按 40250 CPythonApplication 成员顺序创建 CPythonNetworkStream/CAccountConnector/CPythonPlayer/CFlyingManager/CEffectManager 并调用 initnet();app.Create 创建 CNetworkDevice,Process() 每帧先 CPythonNetworkStream::Process 再 CAccountConnector::Process(公会标记上传/下载的 Process 随 GuildMark* 移植)。python_host_test.gd 断言 net 不再是桩(PHASE_WINDOW_SELECT == 2、未连接)
    2V1-d FakeServer 离线跑完 HandShake→Login→Select→Loading 完成:port.login_flow 用真实 40250 root 脚本(intrologin/introselect/introloading/networkmodule)驱动移植的 CPythonNetworkStream + CAccountConnector,对接 tests/port_login_flow_server.cpp 的回环 auth + game 假服(用已在真服 1:1 验证过的 net/classic 包布局、序列表与 cipher 按 40250 desc.cpp/input*.cpp 应答,与客户端不共享代码):握手 bias/重试 + 密钥协商 → CG_LOGIN3/GC_AUTH_SUCCESS → CG_LOGIN2 → GC_EMPIRE/PHASE_SELECT/GC_LOGIN_SUCCESS_NEWSLOT 到 SelectCharacterWindow → StartGame() 3 秒后 DirectEnter 按槽位地址重连、再登录、直入 LoadingWindow 发 CG_CHARACTER_SELECT → PHASE_LOADING/GC_MAIN_CHARACTER/GC_CHARACTER_POINTS → 客户端回 CG_CLIENT_VERSION(2);假服逐包校验序列字节。修正:NetStream::ActivateCipher 解密 COMPLETED 后已缓冲的密文(PORT,40250 靠服务器 pulse 间隔规避此竞争);CPythonApplication 加 final(PORT,platform 无应用对象,虚函数需直接调用),补 GetGlobalTime(app.GetTime)与 SetCenterPosition/SetEventCamera/SetDefaultCamera 桩;SendClientVersionPacket 原样放进 PhaseGame 替身。偏差:GetExcutedFileName 仍为桩(版本包文件名为空)。Loading 结束后 net.StartGame() 打开 game.GameWindow 属 2V2
    2V1-e 真服 smoke(凭据只从环境变量读) 完成:port.login_live(同一 port_login_flow_test,参数 live)走同一脚本路径连真服,以客户端自身状态为证据(选角窗口 + 槽位有名字 + 帝国号;LoadingWindow.LoadData 已由 GC_MAIN_CHARACTER 调用,随后同一调用发 CG_CLIENT_VERSION),在 net.StartGame() 进游戏前退出。MT_LIVE_HOST/MT_LIVE_LOGIN/MT_LIVE_PASSWORD 必填(缺则 SKIP),MT_LIVE_AUTH_PORT(11000)/MT_LIVE_GAME_PORT(13000)/MT_LIVE_SLOT(0) 可选,凭据不打印不入库。2026-09-23 对 192.168.21.203 连跑两次通过:auth 握手/密钥协商/LOGIN3、game LOGIN2、选角列表、DirectEnter 重连、CHARACTER_SELECT、主角包全部按移植代码走通,读流无未知包。NEEDS_LIVE:主角包之后的 Loading 包洪流与 ENTERGAME(2V2 的 GameWindow)、登录失败弹窗、服务器对空文件名版本包的处理(客户端侧不可观测)
  3. 2V2 — GamePhase/角色显示:加入 GamePhase 的最小分派闭包、PythonCharacterManager、InstanceBase、ActorInstance 及角色模型/动画/资源 adapter。验收为进入游戏、创建主角并显示可辨认的静止角色;这个切片要求 2D 的 proto 和所需资源通过 strict gate。

    子步骤 内容 状态
    2V2-a Loading 结束后打开 game.GameWindow,发 CG_ENTERGAME 完成:2V0 桩模块的常量与返回值形状改为按 40250 生成(scripts/stub_constants.py:常量逐行抄各模块 init 的 PyModule_Add*Constant 及其预处理行/枚举/extern const 定义,写到 platform/ScriptLib/GameplayModuleConstants.cpp;桩函数返回参考函数最后一个 return 的 Py_BuildValue 格式的零值或 None,而不是一律 int 0),uiSafebox.SetTableSize 的除零和 uiTaskBar 解包失败随之消失,interfaceModule.MakeInterface 走完。修正:UserInterface/StdAfx.h 按 40250 PCH 顺序先 Locale.h 后 GameType.h(之前反了,GameType.h 里 ENABLE_NEW_EQUIPMENT_SYSTEM 分支全被丢掉)。port.login_flow 假服在 CG_CLIENT_VERSION 后收 CG_ENTERGAME(校验序列字节),测试等到 GameWindow 显示;port.login_live 2026-09-23 对 192.168.21.203 同样走到 GameWindow。GamePhase/SetGamePhase 仍是替身:服务器的 PHASE_GAME 及 Loading 阶段落到 GamePhase() 的包会卡住读流
    2V2-b PythonNetworkStreamPhaseGame*.cpp 分派闭包:SetGamePhase/GamePhase,PHASE_GAME 后的 Entergame 包洪流(GC_TIME/GC_CHANNEL/GC_CHARACTER_ADD/GC_CHAR_ADDITIONAL_INFO/GC_CHARACTER_UPDATE/技能/物品/快捷栏…)读流不断 完成:PythonNetworkStreamPhaseGame/PhaseGameActor/PhaseGameItem/Command、NetworkActorManager 原样移植并在运行时分派(port-map 标 PORTED);GC_CHAT 需要的 PythonChat.cpp/PythonChatModule.cpp 一并原样移植,GameplayModules.cpp 的 chat 桩删除,initChat() 按 40250 顺序在 initnet() 前调用。未移植单元的单例在 PythonBoot 的 GameSingletons 里按 CPythonApplication 成员顺序创建(calloc 存储:替身构造函数不初始化标量);替身由 platform_stub.py pending <header> <符号...> [--ctor] 按链接缺失符号生成到 platform/pending/<Lib>/,返回 const char* 的替身返回 ""(RecvGuild 里 strcmp(…, CPythonPlayer::GetName()))。port.login_flow 假服按 CInputLogin::Entergame 顺序发:Loading 阶段 CHARACTER_ADD+CHAR_ADDITIONAL_INFO+CHARACTER_UPDATE、NPC_POSITION → PHASE_GAME → LAND_LIST、TIME、CHANNEL、问候 CHAT → PING,客户端回 PONG,公告进 CPythonChat,TraceError 观察钩子(EterBase/TraceErrorObserver.h)断言无 Unknown packet header。port.login_live 2026-09-23 对 192.168.21.203 读 10 s 真实游戏阶段:连接保持、GameWindow 显示、无未知包;剩下的 SYSERR 只有 CreateInstance 失败(2V2-d 的 SetRace 占位)
    2V2-c PythonCharacterManager + chrmgr/chr 模块:playerSettingModule 的种族/动作/特效注册走真实实现 完成:PythonCharacterManagerModule.cpp/PythonCharacterModule.cpp 原样移植,GameplayModules.cpp 的 chr/chrmgr 桩删除,initchr()/initchrmgr() 按 40250 顺序在 initChat() 前调用;注册链需要的 RaceData/RaceDataFile/RaceManager/RaceMotionData、GameType、AttributeData/AttributeInstance、TextFileLoader、Util.cpp(代码页/字体部分留在 platform 字体后端,port-map 标 ADAPTED/N_A)、Camera/CameraProcedure 原样移植(port-map 标 PORTED),CRaceManager 按 CPythonApplication 成员顺序在 PythonBoot 创建;D3DXMath 补 D3DXVec2CCW/D3DXMatrixfDeterminant/D3DXMatrixLookAtRH。port.login_flow 断言 chr/chrmgr 不是桩、种族 0 的 MODE_GENERAL/NAME_WAIT 动作已注册、Syserr 无“처리되지 않은 패킷 헤더”;port.login_live 2026-09-23 对 192.168.21.203 连跑 8 次通过、script/port_gate.sh macos PASS,怪物种族实例随真实 SetRace 创建。偶发(约 1/11)Loading 阶段报头 7 未处理(疑包长失步),测试现打印 g_iLastPacket 并判失败,未再复现
    2V2-d InstanceBase + ActorInstance + CGraphicThingInstance adapter:GC_CHARACTER_ADD 创建主角实例(msm 模型 + 待机动作) 完成。d.1:ActorInstance*.cpp(Attach/Battle/Blend/CollisionDetection/Event/Fly/Motion/MotionEvent/Position/Render/Rotation/Sync/WeaponTrace)、InstanceBase*.cpp(Battle/Effect/Event/Motion/Transform)、GameUtil、FlyTarget、PhysicsObject、GameEventManager、WeaponTrace、MapUtil、lineintersect_utils 原样移植,pending/GameLib/{ActorInstance,FlyTarget,PhysicsObject,MapUtil}.cpp、pending/UserInterface/InstanceBase.cpp 替身删除,ActorInstance.cpp/InstanceBase.cpp 随主角创建进入运行时(port-map 标 PORTED);CGameEventManager 与 CPythonSystem(最后一个成员)按 CPythonApplication 成员顺序在 PythonBoot 创建;D3D8Types.h 补全 D3DTA/D3DTEXTUREOP/D3DCMPFUNC/D3DBLEND/D3DCULL。假服 CHARACTER_ADD 的 bType 改为 CHAR_TYPE_PC(6,原 0 使 RecvCharacterAppendPacket 不记主角、CHAR_ADDITIONAL_INFO 报错)。2V2-c 的偶发 Loading 头 7 根因:TPlayerSkill::tNextRead 是 time_t,40250 用 _USE_32BIT_TIME_T,LP64 下 TPacketGCSkillLevelNew 变成 2551 字节(线上 1531),CInputDB::PlayerLoad 在 PointsPacket 后发的 SKILL_LEVEL(76)读多吞掉后续包;Packet.h 改 LONG 并 static_assert 1531,假服 Loading 阶段补发 SKILL_LEVEL 回归。port.login_flow 断言主角实例存在且种族 0、无 TPacketGCCharacterAdditionalInfo 报错;port.login_live 2026-09-23 对 192.168.21.203 连跑 8 次通过,剩余 SYSERR 只有 gr2 NOT SUPPORT FILE。d.2:Granny SDK 2.11 运行时在 libgr2 上实现(platform/EterGrnLib/GrannyRuntime.cpp,shim/sdk/granny.h 按 SDK 头声明所用类型与函数:文件/段、按类型定义的顶点转换含 half、材质贴图按 Usage 查找、网格绑定、变形器、控制器时钟/循环/缓入缓出、加权 SRT 采样与复合矩阵),EterGrnLib/*.cpp(LODController/Material/Mesh/Model/ModelInstance*/Motion/Thing/ThingInstance/Util)与 EterLib/{GrpObjectInstance,GrpCollisionObject,CollisionData}.cpp 原样移植,对应 platform 骨架与 pending 替身删除(port-map 标 PORTED);Resource.cpp 注册 gr2 工厂(mdatr 等 CAttributeData 资源化时再注册);VB/IB 为 CPU 缓冲(platform/EterLib/CpuBuffer.h),渲染 adapter 属 2V2-e。app.UpdateGame/RenderGame(40250 PythonApplication.cpp:321/168)接入,成员经单例访问(平台没有 CPythonApplication 对象),CPythonGraphic(含 CCullingManager)按成员顺序进 GameSingletons;GetMousePosition 读 host 光标。port.login_flow 断言主角 PART_MAIN 模型有网格、材质漫反射贴图已加载、WAIT 动作使骨骼矩阵逐帧变化;离线与 port.login_live(192.168.21.203)2026-09-23 通过
    2V2-e Godot 显示主角(渲染 adapter),离线 + 真服 smoke:进游戏看到可辨认的静止角色 完成。e.1 完成:3D 绘制命令通道。EterLib/StateManager.cpp、EterLib/GrpBase.cpp 原样移植(platform 骨架删除,port-map 标 PORTED);CGraphicDevice::Create 建平台 IDirect3DDevice8(platform/EterLib/RecordingDevice.cpp:记住 transform/渲染状态/贴图阶段/材质/灯光/FVF/流/索引,按 FVF 解码顶点、展开 strip/fan,每次 Draw* 记成一条 Render3DDraw,RenderCommands3D.h),默认 IB/PDT VB 为 CPU 缓冲;D3DXMath 补 PerspectiveFovRH/OrthoRH/OrthoOffCenterRH 与 ID3DXMatrixStack。相机:PythonApplicationCamera.cpp 原样移植 __UpdateCamera/SetCamera/SetCenterPosition/GetCenterPosition 等(成员放文件内静态结构,平台没有 CPythonApplication 对象),Process 按 40250 顺序调 __UpdateCamera→OnCameraUpdate→OnUIUpdate→Begin/SetInterfaceRenderState/OnUIRender/End;CScreen::Begin/End/Set*Operation、CPythonGraphic::Set{Game,Interface}RenderState/PushState/PopState/GetOrthoDepth、CCullingManager::Process 的 view/proj 更新(视锥剔除等 SphereLib)原样;pending 补 CPythonPlayer::NEW_GetMainActorPosition/NEW_GetMainActorPtr/NEW_FindActorPtr/SetMainCharacterIndex(事件处理器留待 PythonPlayerEventHandler)、CMapManager::IsMapReady。port.login_flow 断言某帧有贴图、带法线/UV 的三角形绘制,投影为 SetPerspective 的 RH 透视(_34=-1),且顶点全部落在屏幕内(相机对准主角);离线与 port.login_live(192.168.21.203)2026-09-23 通过。e.2 完成:Godot 消费端 project/python_3d_surface.gd(Metin2PythonHost.render3d_draws 绑定):每条透视绘制成一个 MeshInstance3D,变换为 world·view(D3D 行向量矩阵的行即基列),Camera3D 在原点,fov/near/far 从 SetPerspective 投影反推;材质按贴图/混合/alpha test/剔除/光照状态缓存,贴图只从 40250 包取(UiAssets.load_pack_tex,包名保留 d: 盘符)。CPythonBackground::GetFarClip 原样移植(m_pkMap 为空 → 50000),CMapManager 构造原样(m_pkMap=NULL)。PORT 替身:地图未移植前没有环境数据(LoadMap→SetEnvironmentData→SetCharacterDirLight),D3D 会把受光网格画成全黑;「开光照、无灯、环境光与自发光为 0」的绘制按无光照贴图显示并计入 unlit_stand_in_count,随环境移植删除。script/python_game_render_test.sh(离线起 port_fake_login_server,--live 读 MT_LIVE_*)跑 python_game_render_test.gd:system.py 登录→选人→进游戏,4 条绘制(脸、Object05、身体、头发)全部带贴图,截图中心有角色;2026-09-23 离线通过。e.3 完成:实服冒烟 MT_LIVE_* 指向 192.168.21.203,--live 进游戏后主角与周围狼群带正确贴图、HUD 在上(246 条绘制、10 张贴图);修 python_3d_surface.gd 材质缓存键把 bool 交给 %d(格式化失败使所有绘制共用第一个材质即脸部贴图),测试加「每个贴图名一张贴图」断言;2026-09-23 离线与实服通过
  4. 2V3 — 本地移动:加入 PythonPlayer、PythonPlayerInput*、PythonPlayerEventHandler、 InstanceBaseMovement、ActorInstance{Motion,Event,Position,Rotation,CollisionDetection} 的实际依赖闭包。验收为输入经过新路径 改变位置/朝向、发送与 40250 相同的移动封包,并在离线边界测试和真服 smoke 中通过。接通时删除对应 legacy 玩法逻辑。

每个切片都必须同时跑 legacy/port 双路回归;只有 2V3 稳定并把默认路由切到 port 后,才能删除总开关和剩余 legacy 宿主。

5. 数据源切换:m2dev assets → 40250

现在的渲染和逻辑读的是 assets/,这是从 m2dev 的包解出来的。2026-09-22 与 40250 Client/Eternexus 逐项对比的结果:

数据 读取方 与 40250 的差异 处理
root/npclist.txt mob_view.gd、m2_client.cpp 相同 无
8 个 *.msm(种族模型、挂点、染色) equip_model.gd、player_view.gd 内容相同,位置不同:m2dev 在 root/msm/<cls>_<m/w>.msm,40250 在 root/<cls>_<m/w>.msm(LoadLocalRaceData("warrior_m.msm")) 查找列表加上 40250 的位置
playersettingmodule.py 的动作注册(SetGeneralMotions、__LoadGame*Ex)和连击表 motion_registry.gd、net_play.gd 相同 40250 的文件是 CP949 + CRLF,切换后跑一遍解析测试
playersettingmodule.py 的特效注册 暂无 40250 多了 EFFECT_LEVELUP_*_FOR_GERMANY、EFFECT_EMPIRE+1..3,少了 EFFECT_AGGREGATE_MONSTER 移植特效时按 40250
atlasinfo.txt、grpblk.txt — 相同 无
uiscript/uiscript 现有 project/ui/*.gd 40250 有 94 个,m2dev 有 80 个;共有的 71 个中,去掉空白差异后有 11 个不同(建角/选角/密码/信使/公会等) 由 2P 直接运行 40250 的 uiscript,不单独处理
item_proto proto.cpp 读不了:40250 每条 156 字节(TItemTable,含 long 字段,需按 32 位布局),m2dev 236 字节;40250 用经典 TEA(32 轮,0x9E3779B9,按 32 位字处理),m2dev 用 XChaCha20 按 40250 移植(需 2A 的类型层)
mob_proto proto.cpp 读不了:每条 255 字节 vs 335 字节,加密同上 同上
item_list.txt item_list.gd 177 行不同(m2dev 有 dummy 条目等);40250 放在 locale/en/,m2dev 在 locale/common/ 按 40250 路径读取
locale_interface.txt、itemdesc.txt、skilldesc.txt、skilltable.txt UI、技能表 20 行 / 2 行 / 2 行 / 位置不同 按 40250 路径读取
模型、贴图、地图(PC、Monster、Outdoor 等) 渲染 40250 是经典 .eix/.epk,103 个包加 root、52,609 个路径,全部可以本地解码(2R) 移植 EterPack/EterPackManager 接到 asset_io,请求改用 40250 路径(见 2R 第 6 条)

切换步骤(批次 2D):

  1. msm 查找路径加上 root/<cls>_<m/w>.msm。
  2. proto 读取按 40250 移植:EterBase/tea.cpp、GameLib/ItemData.h 的 TItemTable、CPythonNonPlayer 的 mob 表,放进镜像文件(使用 2A 的定宽类型和 static_assert),替换 extension/src/proto/proto.cpp 里的 m2dev 格式。
  3. 资源测试严格模式:
    • 统一资源环境变量:C++ 测试(extension/CMakeLists.txt 目前只透传 M2_ASSETS)和 GDScript(MT_ASSETS)统一读 MT_ASSETS,过渡期两者都透传;
    • 新增 MT_ASSETS_STRICT=1:该模式下 MT_ASSETS 未设置、来源清单缺失、sha256 不匹配或必需文件缺失都必须失败,不能跳过;
    • 为 40250 的 root、locale、proto 和关键包生成来源清单(路径 + sha256),提交到 audit/,测试开始时核对。
  4. 新增并在 CI/发布前强制运行 script/run_40250_asset_gate.sh;它设置/校验 MT_ASSETS_STRICT=1,运行 native proto/pack/formats 测试和 Godot root/locale/render 测试。脚本在没有 40250 资源时必须失败,不能把跳过报告成 PASS。
  5. 资源根切换按 2R 第 6 条执行:移植 EterPack(CLZObject/TEA 已经在第 2 步)和 EterPackManager,接上 asset_io 的 pack 后端,读取方逐个改用 40250 路径,跑严格门禁,更新本节状态。

状态(2026-09-23):

步骤 状态 提交
1. msm 路径 完成:equip_model.gd 先查 root/<cls>_<m/w>.msm,m2dev 的 root/msm/ 作后备;8 个种族都能从 Eternexus/root 找到 240d2827
2. proto 完成:proto.cpp 用移植的 CLZO/TEA 读 MIPX/MMPT(156 / 255 字节),m2dev 的 XChaCha20 / 236 / 335 读取已删除;名字按 GetName/GetMonsterName 取 szLocaleName(cp1252)。SMobTable 暂时在 proto.cpp 里放一份 #pragma pack(1) 镜像(static_assert == 255),2P 移植 GameLib/PythonNonPlayer 后改用正式镜像 232d0461
3. 严格模式 完成(见下) 本批最后一个提交
4. 门禁脚本 完成:script/run_40250_asset_gate.sh 在 macOS 上 PASS 同上
5. 资源根 部分完成:pack 后端(PackInitialize + asset_io 的 pack:// + Metin2Pack 节点)已接入,proto 经 pack://locale/<lang>/ 读取(5a 1f1004da、5b 5a26e93f)。模型、贴图、地图和 root/locale 文本仍读 m2dev assets/,由 2V 切片逐个切换

环境变量(取代步骤 3 里“两者都透传”的过渡方案):

  • MT_40250_CLIENT:40250 的 Client 目录(pack/、Eternexus/)。native 测试(CMake 缓存变量默认读同名环境变量, 再退回 ${MT_40250_SOURCE}/../../Client)、AssetRoot.client_path() 和门禁脚本都用它;
  • MT_ASSETS:只表示 m2dev 散文件目录(开发期覆盖);
  • M2_ASSETS:测试不再使用;
  • MT_ASSETS_STRICT=1:native 测试在资源缺失时返回 1(不是 ctest 的跳过码 77),门禁脚本把 Godot 测试输出里的 (skip 也算失败。

来源清单:tools/asset_manifest.py generate|verify <Client> 记录 pack/Index、全部 .eix/.epk 和 Eternexus/root/*.msm 的路径、大小、sha256,结果在 audit/assets/40250-client.json(223 个文件,1,407,474,045 字节)。文件缺失、内容变化或清单外多出的文件都会让 verify 失败。

已知问题(不是本批引入的):完整 Android 链接在 API 24 上失败,原因是 extension/src/net/text_codec.cpp 用了 iconv; 本批的 mtproto、pack40250_node、asset_io、proto_node 都能在 Android 上编译。

6. 进度与 port-map 基线

实时数字用 port_map.py status 查看。

本次方案修订重置了旧的 6 个完成状态;2A 完成工具和基础目标后,2026-09-22 重新确认了基线:

  • 之前"完成"的 6 个函数(PythonPlayerEventHandler.cpp)已作废并回退为 TODO:OnMove/OnMoving/OnStop 的实现在 net_play.gd,属于迁移来源;3 个 N_A(单例、构造、析构)与"单例归扩展所有"冲突。
  • 原样运行的 Python 脚本函数不标 N_A(N_A 只用于没有玩法语义的平台胶水)。状态 RUN_AS_IS: impl 指向随包运行的原脚本,evidence 要求目标平台的导入/运行证据;单元的 reference hash 与资源来源清单共同证明 运行的是完全相同的字节。port_map.py、SKILL 和 schema 已同步支持并把它计入 done。

基线(2026-09-22,port_map.py status):

层 函数 完成 备注
logic 3343 0 1 个 DIVERGENT(CPythonPlayerEventHandler::OnWaiting)
python 5175 0 其中 root/*.py 3350
platform 2091 0
合计 10609 0

2D 之后(2026-09-23):logic 46 个 PORTED(EterPack、EterPackManager、lzo、tea、Stl),platform 21 个 ADAPTED (PackInitialize、FileLoader、MappedFile、FileBase、CRC32)。只标了 proto_test + port_eterpack_test 覆盖率运行实际执行到 的函数。

与基线前的数字(logic 3285、python 4890、platform 2229,共 10404)相比的变化:

  • 函数清单(port_map.cpp_functions)用 platform 骨架的 clang 声明逐个对照后修正:
    • 新增 namespace 块内缩进的定义(EterPythonLib 的 UI::CWindow 等窗口类、CWindowManager、NEffectUpdateDecorator), 这是 python 层 +285 的主要来源;
    • 新增初始化列表或参数表超过 8 行的定义、类名全大写的构造/析构(CLZO::CLZO、CIME::CIME);
    • 去掉 /* */ 里注释掉的旧代码(如 PythonNetworkDatagram.cpp 整个文件、GameUtil.cpp 的碰撞函数)和被误当成函数的 构造函数初始化项(m_hFM(NULL),)。
    • 核对结果:platform 骨架里的每个函数桩都在清单里,只有 22 个 40250 未定义的声明,以及 namespace/typedef 写法不同的 同一函数(NEffectUpdateDecorator::CAirResistanceDecorator::__Excute、CSkyObject::TSkyObjectFace 与 CSkyBox)除外。
  • EterBase 按单元分层(见第 3 节),不再整个库算 platform。audit/slices/*.json 已重新生成(EterLib/Dimm.h 同时改为 platform)。

7. 换机器后的环境准备

目录布局(路径都相对 ~/Work/mt/,工具按相对路径查找):

~/Work/mt/metin2-client/                     # 本仓库(git remote origin)
~/Work/mt/40250/Server Client TMP4/          # 40250 参考(不在 git 里,需自行复制)
    ClientVS22/source/                       # C++ 参考根(audit/manifest.json 的 reference_root)
    Client/Eternexus/root, uiscript, locale  # Python 参考
~/Work/mt/metin2-client/assets/, bgm/        # 游戏资源(gitignored,需自行复制;或用 MT_ASSETS 指到别处)
  • 参考源放在其他位置时,设置 MT_40250_SOURCE=<.../ClientVS22/source>。
  • Python 2.7.18:用于运行 py_embed_spike.py。macOS 安装 python.org 的 2.7.18 包,确认 python2.7 --version。
  • Python 3:用于 port_map.py 和其他审计脚本。
  • Android:NDK 27.2(ANDROID_NDK_HOME 指向它;未设置时脚本选版本号最低的 NDK)、openjdk@21、Godot 导出模板; adb 用 /opt/homebrew/bin/adb。APK 导出用 ./export-android.sh。
  • 真服账号、密码不写进仓库或 audit 账本。

新会话恢复工作:

cd ~/Work/mt/metin2-client
git pull
python3 .agents/skills/metin2-40250-parity-audit/scripts/port_map.py status
python3 .agents/skills/metin2-40250-parity-audit/scripts/port_map.py queue --limit 20
python3 .agents/skills/metin2-40250-parity-audit/scripts/port_map.py check
python2.7 .agents/skills/metin2-40250-parity-audit/scripts/py_embed_spike.py --out /tmp/py_embed_spike.txt   # 可选:复核 Python 原型
tools/py_embed_android/build-and-run.sh                                                         # 可选:连接 Android 真机复核

然后按第 4 节标为"下一步"的批次继续,每完成一步在第 4 节(及 2P 平台表、第 5 节)更新状态并提交。