Files
mtgodot-poc/docs/PORT-PLAN.md
T
shenleiandClaude Opus 5.5 85e176bdd5 Batch 4-b: verify combat on the port route
The fake server spawns a stray dog outside the village's BANPK safe disc
and answers CG_TARGET/CG_ATTACK with GC_TARGET/GC_DAMAGE_INFO/GC_DEAD.
port.login_flow and python_game_render_test hold the left button on it
(MBF_SMART -> __OnPressActor) until it dies. Adds the host/test helper
PythonBoot::Evaluate / Metin2PythonHost.evaluate.

python_3d_surface: emission is now multiplied by the texture, like
D3D's emissive-then-MODULATE, which removes the grey click-glow quad and
the grey wash on characters. Damage numbers wait on CPythonSystem (4-g).

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

89 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 选择(project/client_main.gd)。 2026-09-24 起默认 port;未设开关而缺 Metin2PythonHost 或 40250 Client/pack 时退回 legacy 并告警,显式 port 则直接报错退出;MT_TEST_MODE=smoke/playable(AppFlow 真服测试)固定走 legacy,MT_TEST_MODE=python_ui 保留为 port 的别名。规则:

  • 新单元先编译进 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 离线与实服通过
    2V2-e.4(2026-09-23):Python 3D 表面接上 40250 包内真实 A1 地图。Metin2World 的高度图、tile/splat、地表 DDS、.msenv 和 AtlasInfo.txt 经 pack:// 读取;相机逆矩阵选当前 3×3 区块,地表随 D3D view 进入同一相机空间。角色材质不再使用 unlit_stand_in_count 的无光照替身,方向光取地图 .msenv 的 Character 参数,地形由同一环境的 Background 参数照明。离线截图能看到脚下石砖/沙地,9/9 区块带 splat,角色受光;python_game_render_test.sh 检查真实地图、光和地表像素差异。CMapManager::LoadMap、CPythonBackground::SetCharacterDirLight 的 native 链仍待逐单元移植;当前平台适配将地形一次性对齐到角色初始海拔(native GetHeight 尚为替身),所以不把这一步记作该 native 单元的 PORTED。地图内流式更新已接,跨图 warp 的 3D 场景替换留后续地图生命周期批次。
    2V2-e.5(2026-09-23):原生 CPythonNetworkStream::Warp → CPythonBackground::Warp → CMapManager::LoadMap/GetHeight 已在运行路径接上包内 AtlasInfo、Setting.txt、height.raw 和 .msenv。地形高度使用 40250 的三角插值;RenderGame 调 SetCharacterDirLight 后录制绘制的 light0 来自该环境。Godot 地表按 native map 名和地图本地坐标放置,移除初始海拔平移,换图时重建地表;离线角色脚底与地表高度差约 0.1 cm,4/4 绘制带 light0。地图后端仍是过渡适配:CMapOutdoor、放置物件高度/属性、雾与完整环境状态、迷你地图/传送门等未移植,port-map 保持 ADAPTED/TODO,不宣称整个 native 地图单元 PORTED。修正 EterBase/Utils 的 MIN/MAX/fMIN/fMAX 系列返回零桩:地图就绪后相机 Update 会调用 fMIN/fMAX,否则距离被压成 0、view 矩阵变 NaN。离线测试增加有限矩阵、native map 一致性、脚底高度和 light0 检查。2026-09-23 实服(192.168.21.203,metin2_map_c1,244 条绘制、142 个 actor)验证时发现两处 Metin2World 旧单位问题:.msenv 深度雾(120–520,按米写)和地形 patch 的 visibility_range_end(3500,按米写)在本表面的 cm 场景里分别在约 5 m、35 m 处把角色身后的地图洗成天色/剔除,怪物看起来悬空。修正:Python 3D 表面关掉旧雾(雾应来自录制的 D3DRS_FOG*,SetEnvironmentData 移植前保持关闭),terrain_patches=1 整块出网格只做视锥剔除。测试新增“每个 actor 站在地图上”(按 world 矩阵分组取最低顶点,实服最差 19 cm)与“角色身后地图可见”(画面上部纹理对比);离线与实服通过。换图仍待 live 验证。
  4. 2V3 — 本地移动:加入 PythonPlayer、PythonPlayerInput*、PythonPlayerEventHandler、 InstanceBaseMovement、ActorInstance{Motion,Event,Position,Rotation,CollisionDetection} 的实际依赖闭包。验收为输入经过新路径 改变位置/朝向、发送与 40250 相同的移动封包,并在离线边界测试和真服 smoke 中通过。接通时删除对应 legacy 玩法逻辑。 2V3-a(2026-09-23):PythonPlayer、PythonPlayerInput/InputKeyboard/InputMouse、PythonPlayerEventHandler、PythonPlayerSkill、PythonPlayerModule、GameType 原样移植(port-map 标 PORTED),GameplayModules.cpp 的 player 桩删除,initPlayer() 按 40250 顺序在 initchrmgr() 后调用;pending/UserInterface/{PythonPlayer,GameType}.cpp 替身删除,其余被调方(CPythonSkill、CItemData、CItemManager、CPythonItem 等)进 pending。64 位修正:playerGetEmotionIconImage 用 Py_BuildPointer,PyDict_Next 用 Py_ssize_t。根运动:Granny 运行时的 GrannyUpdateModelMatrix 原为直通,按 TrackGroups[0] 的 AccumulationFlags(AccumulationExtracted)/LoopTranslation(libgr2 新读出,战士 run = (0,-300,0),同 .msa Accumulation)按控制器本地时钟推进与权重累加位移(Delta × ModelMatrix);VDA 抽取未实现。port.login_flow 按 DIK_UP 经 game.GameWindow → player.SetSingleDIKKeyState 行走 >100 cm,假服解析 CG 包,收到 CG_MOVE FUNC_MOVE 后 FUNC_WAIT 且坐标变化;port.login_live 2026-09-23 对 192.168.21.203 走 132 cm 通过。(2V3-a 的待办见 2V3-b。) 2V3-b(2026-09-23):鼠标点地移动。CScreen::SetCursorPosition(GrpScreen.cpp:487)与 CPythonGraphic::SetCursorPosition 原样实现,RenderGame 每帧用光标建拾取射线;CPythonBackground::GetPickingPoint/GetPickingPointWithRay/GetPickingPointWithRayOnlyTerrain 按 CMapOutdoor::__PickTerrainHeight 原样沿射线步进,地形高度取 native CMapManager::GetHeight(PR_FLOAT_TO_INT 的 x87 取整按 floor),物件拾取随 CMapOutdoor 移植再接。CMapManager::isAttrOn 按 CMapOutdoor::isAttrOn+CTerrain::isAttrOn 读包内 <map>/<xxxyyy>/attr.atr(阻挡/水/禁 PK),CheckAdvancing 及两个碰撞检查器原样(地图物件未注册到 CCullingManager 前不会阻挡)。以上在 pending/platform 层,port-map 标 ADAPTED。port.login_flow 在按键行走后左键点击角色下方地面:game.GameWindow.OnMouseLeftButtonDown → player.SetMouseState → 角色走到拾取点(离线 334 cm),假服收到 FUNC_MOVE … FUNC_WAIT;另断言角色所在格不阻挡、所在地形块有阻挡格(离线 17814 格)。port.login_live 2026-09-23 对 192.168.21.203 通过(按键 133 cm、点地 296 cm、11406 阻挡格),python_game_render_test.sh 离线通过。client_main 的 MT_TEST_MODE=python_ui 加上 python_3d_surface.gd,新路径可手动登录进游戏走动。待办:默认路由切到 port(当前 client_main 默认仍走 GDScript AppFlow)后整体删除 legacy;攻击/拾取物品等 P1 战斗交互属批次 2 之后。 批次 2 P0 收尾(2026-09-24):UserInterface/PythonCharacterManager.cpp(54 函数)与 InstanceBaseMovement.cpp(32 函数)在 2V2-b 已原样拷入 port/,2V3 起即是运行路径(行走、点地移动、远程角色同步都经它们),本轮逐行比对 40250(仅 include 大小写、注释编码和两处 // PORT: 修正:CreateInstance 的 TraceError 传 m_dwVID,NEW_SyncPixelPosition 用 32 位 LONG &),确认 pending/platform 无重复定义后标 PORTED。P0 队列已清空;P1 飞行物(同日):GameLib/FlyingData、FlyingInstance、FlyingObjectManager、FlyTrace 与 Python 模块 PythonEffectModule(effect)、PythonFlyModule(fly)原样移植,删除 pending/GameLib/FlyingObjectManager.cpp 替身和两个模块的桩,initeffect()/initfly() 按 40250 顺序在 initnet() 后调用;CFlyingManager::SetMapManagerPtr(&CPythonBackground) 按 40250 构造函数接上;FlyTrace.h 加 // PORT: include 以过头文件门禁。CMapManager::GetTerrainHeight(飞行物落地判断用)在 pending 层按 native 地形高度实现,GetHeight 转发到它(地图物件高度待 CMapOutdoor)。ActorInstanceData.cpp 早已原样在 port/,一并标 PORTED。port.login_flow 断言 playersettingmodule.py 已经 effect.RegisterIndexedFlyData 注册并能解析 .msf。特效本体(CEffectManager)仍是平台桩,飞行物不可见;队列里 P0/P1 已清空,剩下 P2 网络单元(多数已在 2V1 原样拷入 port/,待逐个核对后标记)。同日核对:队列中 28 个早已拷入 port/ 的单元(网络流各阶段、AccountConnector、NetStream/NetDevice/NetAddress、EterPack*、lzo/tea/cipher/Timer 等,372 函数)用 port_copy.py diff 比对,23 个与机械拷贝完全一致,其余 5 个的手改全部带 // PORT:(CP949 转义、e- pp-number、32 位 LONG、包密钥外置、lzo_uint 宽度、ActivateCipher 解密同批已收密文),平台层无同名替身,标 PORTED;logic 层 54.8% → 67.9%。队列剩余 P2 仅 ServerStateChecker(ServerStateChecker 模块仍是桩),其后是 P3 物品/小地图/名牌。 P2 收尾(2026-09-24):ServerStateChecker.cpp 与 ServerStateCheckerModule.cpp 原样移植,删除模块桩,实例按 CPythonApplication::m_kServerStateChecker 由 PythonBoot 持有,initServerStateChecker() 按 40250 顺序调用。port.login_flow 在选角后按 intrologin 的 __RequestServerStateList 调 AddChannel/Request/Update,假服新增状态端口按 CInputHandshake+CInputDB::RespondChannelStatus 应答(先握手字节,再 210,n,TChannelStatus[n],1);TChannelStatus::nPort 是 short,假服临时端口 >32767 对不上自身频道,离线以第二个频道 13002 为证。port.login_live 真服返回 {1: 1, 2: 1}。P2 队列清空,下一步 P3 物品链。

    P3-a(2026-09-24):GameLib/ItemData.cpp、GameLib/ItemManager.cpp、UserInterface/PythonNonPlayer.cpp 与 PythonNonPlayerModule.cpp 原样移植,删除三个 pending 替身与 nonplayer 模块桩,initNonPlayer() 按 40250 顺序插在 initPlayer() 之后。LoadLocaleData 的函数体原样放进平台 PythonApplication.cpp(选角阶段 localeInfo.LoadLocaleData() 调它):读 item_list/item_proto/itemdesc 与 mob_proto;CPythonSkill 仍是替身,RegisterSkillDesc 失败后返回 false(脚本不看返回值),记 ADAPTED。port.login_flow 3c 步:物品 19 = Sword+9(武器、refine 9、模型可载),怪 101 = Wild Dog;真服同样通过。下一步 P3-b:PythonItem + PythonItemModule(地面物品、item 模块)。

    P3-b(2026-09-24):PythonItem.cpp 与 PythonItemModule.cpp 原样移植,删除 pending 替身与 item 模块桩,initItem() 按 40250 顺序在 initPlayer() 之后;平台 CPythonApplication::Create 按原序调 m_pyItem.Create()(dropitem.mse 注册仍落在 CEffectManager 桩)。改动:itemGetIconImage 用 Py_BuildPointer 返回图标指针(40250 ILP32 用 "i",64 位下被截断,真服背包 wndMgr.SetSlot 段错误);D3DXMath.h 补 D3DXQuaternionConjugate;pending CMapManager::GetNormal 以 CTerrain::CalculateNormal 公式按需在高度块上算法线;CPythonTextTail 的物品名牌四函数暂为桩,Pick 按 40250 空表语义返回 -1(返回 0 会把地面物品 VID 0 当作选中,点地移动失效)。假服入场时按 CItem::EncodeInsertPacket 发 vnum 19 的 GC_ITEM_GROUND_ADD+GC_ITEM_OWNERSHIP,收到 CG_ITEM_PICKUP 回 GC_ITEM_GROUND_DEL;port.login_flow 验证地面物品位置/归属/vnum,player.PickCloseItem() 发出拾取、物品被删除,以及 item.SelectItem(19) 的名称/类型/子类型/数值。下一步 P3-c:PythonExchange,之后 PythonTextTail(物品名牌)与 PythonMiniMap。

    P3-c(2026-09-24):PythonExchange.cpp 与 PythonExchangeModule.cpp 原样移植(无手改),删除 pending 替身与 exchange 模块桩,initTrade() 按 40250 顺序在 initNonPlayer() 之后。假服按 CInputMain::Exchange 应答:CG_EXCHANGE START 回 GC_EXCHANGE START(arg1 = 请求的 vid)+ 对方 ITEM_ADD(vnum 19,格 0)+ GOLD_ADD 1000,CANCEL 回 END;port.login_flow 用 net.SendExchangeStartPacket 开交易,验证 exchange.isTrading/GetItemVnumFromTarget/GetItemCountFromTarget/GetElkFromTarget,uiexchange 打开后 net.SendExchangeExitPacket 收到 END 关闭。注意:40250 SendExchangeExitPacket 的 arg1 未初始化(原样保留,假服不记录它);uiexchange.CloseDialog 调 wndMgr.OnceIgnoreMouseLeftButtonUpEvent(),测试先消耗这次被忽略的左键抬起,否则下一次点地的抬起被吞、角色一直按住行走。下一步 PythonTextTail(物品名牌)与 PythonMiniMap。

    P3-d(2026-09-24):PythonTextTail.cpp 与 PythonTextTailModule.cpp 原样移植(无手改),删除 pending 替身(含 P3-b 的物品名牌桩)、textTail 模块桩与 TextTail_SetLivingTime 桩,initTextTail() 按 40250 顺序在 initChat() 之后,平台 CPythonApplication::Create 按原序调 CPythonTextTail::Initialize()。连带补齐:CScreen::ProjectPosition/UnprojectPosition 原样实现(此前是桩,名牌永远停在 -100,-100),D3DXMath 补 D3DXVec3Project/Unproject(D3DX 语义);ApplicationStringTable_GetString(z)(DWORD) 在平台 UserInterface.cpp 以 UserInterface.rc 英文 STRINGTABLE 代替 LoadString(物品归属名 "'s"),记 ADAPTED。port.login_flow 验证:假服地面物品注册了物品名牌(ItemTail Map 1),按住 ALT 一帧后 game.GameWindow.OnRender 在 app.RenderGame 之后投影名牌,CPythonTextTail::Pick 在屏幕上找回该物品 VID;拾取后名牌删除。下一步 PythonMiniMap。

    P3-e(2026-09-24):PythonMiniMap.cpp 与 PythonMiniMapModule.cpp 原样移植(无手改),删除 pending 替身与 miniMap 模块桩,initMiniMap() 按 40250 顺序在 initnet() 之后;PRTerrainLib/StdAfx.cpp(PR_FCNV/PR_ICNV)原样复制,UserInterface PCH 按 40250 补 PRTerrainLib/StdAfx.h,TerrainType.h 的 PR_FLOAT_TO_INTASM 在非 MSVC 下用 lrintf(x87 fistp 默认舍入)代替内联汇编;D3DXMath.h 补 D3DXColorModulate。缺口:CMapOutdoor 未移植(地形由 Godot 适配层绘制),pending CMapManager::IsMapOutdoor() 返回 false(记 DIVERGENT),GetMapOutdoorRef 与 pending CMapOutdoor::GetTerrainPointer/GetBaseXY 只为链接存在、不可达——小地图地形贴图、标记与大地图(atlas)因此走 40250「非室外地图」路径不显示,待 CMapOutdoor 移植后自然恢复。port.login_flow 验证 uiminimap 创建并显示 miniMap、GetAtlasSize() 报未载入。P3 物品链与三个 UI 单元至此完成;下一步候选:CMapOutdoor(解锁小地图/大地图)、CEffectManager/EffectLib、CPythonSkill。

    批次 3-a CPythonSkill(2026-09-24):UserInterface/PythonSkill.cpp(含 skill 模块)与 EterBase/Poly/(技能公式解析器)原样移植,删除 pending 替身与 skill 模块桩,initskill() 按 40250 顺序在 initfly() 之后,实例按 m_pySkill 位置进 PythonBoot 的 GameSingletons;平台 PythonApplication.cpp 里重复的 SKILL_EFFECT_UPGRADE_ENABLE 删掉(40250 定义在 PythonSkill.cpp),LoadLocaleData 的 skilldesc/skilltable 现在真正载入。改动:技能图标/图标实例句柄用 Py_BuildPointer/PyTuple_GetPointer(item 模块的 GetIconInstance/DeleteIconInstance 同改),skillGetGradeData 把非平凡结构传进 varargs(clang 拒绝),改为显式读同样的前 4 字节(脚本不调用)。port.login_flow:选角阶段 skill 1 = Three-Way Cut、ACTIVE、冷却 12;进游戏后主动技能只有分等级图标(GetIconImage 为 0,与 40250 相同),GetIconImageNew/GetIconInstanceNew 可用;真服同样通过。 批次 3-b EffectLib(2026-09-24):EffectLib/ 18 个 .cpp 原样移植(EffectManager、EffectData/Instance、EffectMesh、粒子系统、SimpleLight 等),删除 platform/EffectLib 生成的桩骨架;唯一改动是 Type.h 里 BILLBOARD_TYPE_2FACE 行尾注释的 \ 被 clang 当作续行吞掉下一行,去掉反斜杠。platform/EterBase/Utils.cpp 剩下的 21 个桩按 §3 照抄 40250 函数体(GetOnlyPathName/IsGlobalFileName 缺失会让 .msf 以相对名挂的 .mse 丢目录,106 个 RegisterEffect 报错),Win32 调用换成 POSIX 的记 ADAPTED;平台 CPythonResource 补上 40250 的 mde(CEffectMesh)和 mdatr(CAttributeData)资源类型。port.utils 覆盖路径/分词/命令行辅助函数;port.login_flow:在主角脚下 CreateEffect(level_up.mse),RenderGame 的 CEffectManager::Render 录出 levelup_1 贴图的 alpha 混合粒子绘制(dust、dropitem 同样出现)。40250 声明但未定义 GetRenderingEffectCount,测试不用。 批次 3-c CMapOutdoor(2026-09-24):GameLib/ 的 Area、AreaTerrain、DungeonBlock、MapBase、MapManager、MapOutdoor(含 IndexBuffer/Load/Quadtree/Render/Update)、MapType、MonsterAreaInfo、Property/PropertyLoader/PropertyManager、SnowEnvironment/SnowParticle、TerrainDecal/Patch/Quadtree,PRTerrainLib/ Terrain、TextureSet,UserInterface/PythonBackground(+Module),EterLib/ColorTransitionHelper 原样移植,删除 pending 替身与 background 模块桩(含 GameplayModuleConstants 里的常量桩);EterBase/FileDir、TempFile 在 platform 下照抄 40250 函数体(Win32 查找/临时文件走 common 垫片,记 ADAPTED)。TEMP_CAreaLoaderThread 在 40250 已全部注释掉,.cpp 不编(N_A)。手改两处:CMapManager::BeginEnvironment 的 ApplyLight 标签指针经 uintptr_t 转 DWORD;CTerrainImpl::LoadWaterMap 按 32 位读 water.wtr 的 long 再逐个扩宽(LP64 下 long 是 8 字节,否则报 FILE DATA SIZE 16392 != 16400)。平台文件:HTP/STP 地形绘制与 RenderWater 为空(地形仍由 Godot 适配层画,§3),STP 的 Initialize/Create/Destroy、水面贴图载入、角色阴影贴图创建照抄;BeginRenderCharacterShadowToTexture 返回 false(记 DIVERGENT)。录制设备补齐 CreateTexture/VertexBuffer/IndexBuffer/DepthStencilSurface、Get/SetRenderTarget、LightEnable、Clear(CPU 表面/贴图),离屏渲染目标的绘制丢弃,light0 只在启用且为方向光时记录。小地图:CPythonMiniMap 在正交投影下画 minimap.dds 瓦片并以 stage 1 相机空间坐标 × D3DTS_TEXTURE1 采样 minimap_image_filter.dds 做圆形遮罩——录制设备把「正交投影 + 未变换顶点」的绘制按四边形转成 UI 命令(带 mask 贴图与四角 mask uv),UIRenderAddImage 按 D3DTS_WORLD 平移、按 TFACTOR 调色(标记点),python_ui_surface.gd 用按序的子 canvas item 画 UI 流,带遮罩的图各用一个 shader 材质。port.login_flow:Warp 后 IsMapOutdoor()、miniMap.GetAtlasSize() 已载入、background.GetCurrentMapName() 非空;python_game_render_test 新增「minimap tiles drawn through the filter mask」(9 块)。截图里主角旁地上的剑是假服 entergame 时按 GC_ITEM_GROUND_ADD 放的地面物品(vnum 19,P3-b 拾取测试用),不是武器挂点问题;假服角色没装备武器。 默认路由切换(2026-09-24):client_main 不设 MT_PORT_PATH 时走 port 路由(python_ui_surface.gd + python_3d_surface.gd 跑 40250 system.py),MT_PORT_PATH=legacy 回到 GDScript AppFlow,规则见 §2「迁移方式」。已验证:实例化 client_main.tscn,默认/port 起 PythonUISurface + Python3DSurface 并产出 UI 命令,legacy 起 AppFlow + NetTrace。下一步:port 路由稳定后删除 legacy 宿主与开关。 批次 4(2026-09-24 起):补齐 port 路由的玩法,以便删除 legacy。顺序:4-a 文字输入 → 4-b 战斗 → 4-c 商店 → 4-d 任务/NPC 对话 → 4-e 仓库 → 4-f 好友/公会/公会标记 → 4-g systemSetting → 4-h 声音。 批次 4-a 文字输入(2026-09-24):UserInterface/PythonIME.cpp 与 PythonIMEModule.cpp 原样移植(仅把 MSVC 专有的 unsigned char(x) 函数式转换改成 C 式转换),删除 ime 模块桩,initime() 按 40250 顺序在 initdbg() 之后,CPythonApplication::Create 按原序 CPythonIME::Create + SetText("", 0)。EterLib/IME.cpp 在 platform 下:缓冲区、光标、WMChar(退格、| 转义、按输入代码页 MultiByteToWideChar)、候选/读音状态、数字/字符串模式等函数体照抄;IMM/TSF 在 Godot 下没有对应物,组字相关处理器为空、EnableIME 恒关,已提交的非单字节文本由 PythonBoot::UIChar 模拟 GCS_RESULTSTR(InsertString + OnUpdate),DelCurPos 用 memmove 替代重叠的 wcscpy(全部记 ADAPTED)。GameApplicationAdapter 的 RunIME* 转发到 CWindowManager;注意 RunIMEUpdate/RunIMEKeyDown 只送达正在渲染的活动窗口(登录框未打开时输入被丢弃)。Godot 侧 python_ui_surface.gd 把按键送 ui_ime_key(VK)再送 ui_char(unicode)。port.login_flow 与 port.login_live 打开登录框后逐字键入账号(含 LEFT/DELETE/BACK 编辑)、TAB 切到密码、RETURN 登录(凭据只来自环境变量,测试随后删除 Python 局部变量);进游戏后 RETURN 打开聊天、键入 hello、RETURN 发出 CG_CHAT(假服回显 GC_CHAT),空行 RETURN 关闭聊天(40250 发完后只清空、不关闭)。python_game_render_test 用 Godot 按键事件走同一路径登录。 批次 4-b 战斗(2026-09-24):战斗代码此前已原样移植(InstanceBaseBattle、ActorInstanceCollisionDetection、PythonPlayerInput*、uiTarget),本批只补验证。假服在出生点北 18 m 放一只野狗(npclist 101,怪物不发 GC_CHAR_ADDITIONAL_INFO,名字取自 mob_proto;发了会 syserr),出生点在村庄 ATTRIBUTE_BANPK 圈内,CInstanceBase::IsInSafe() 会挡掉所有命中。CG_TARGET 回 GC_TARGET,CG_ATTACK 回 GC_DAMAGE_INFO + GC_TARGET,第三下回 GC_DEAD。与 40250 相同:单击怪物(__OnClickActor)只走过去,按住左键(MBF_SMART → __OnPressActor)才走近并连击。port.login_flow 第 9 步用显式透视矩阵投影野狗、按住左键直到死亡,检查 target 面板和至少 3 次 CG_ATTACK;python_game_render_test 用 Godot 鼠标事件走同一路径,截图 combat_mid/combat.png(野狗位于镜头与战士的连线上,被战士挡住,属正常)。新增宿主/测试辅助 PythonBoot::Evaluate / Metin2PythonHost.evaluate(40250 无对应物)。python_3d_surface.gd 修正:D3D 固定管线中材质 emissive 先加进顶点色,再被纹理 MODULATE,Godot 的 emission 却加在纹理之后,导致黑底加色的点击光圈(click_glow_select copy.jpg)显示成灰色方块,角色也被 0.32 的 emissive 洗灰;现在 emission 乘以同一张纹理(EMISSION_OP_MULTIPLY)。战斗步骤先等镜头稳定(上一步的走路可能尚未结束),野狗投影落在小地图或任务栏下时先点地面走近一段。已知缺口:伤害数字依赖 CPythonSystem::IsShowDamage(),该单元仍在 pending 桩(4-g),故暂不显示。

每个切片都必须同时跑 legacy/port 双路回归;默认路由已切到 port(2026-09-24);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 节)更新状态并提交。