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>
67 KiB
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里记录适配不变式。
- 凡是参与序列化的结构(proto 记录、封包、EPK 索引、msa/msm 二进制)一律用定宽类型,
- 单位保持 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:基础
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 语义实现。- 参考公共头的最小闭包:从第一批要移植的单元出发(
PythonPlayerEventHandler.h依赖ActorInstance.h、FlyHandler.h、PythonNetworkStream.h、InstanceBase.h),用脚本列出#include闭包,把闭包里的头文件先照抄为可编译的声明。 port_logic静态库 CMake 目标,链接进libmtgodot;macOS 和 Android 分别编译(iOS、mingw-w64 作为附带的 可移植性门禁一起编)。某个目标平台的工具链暂不可用时,2A 对该平台保持BLOCKED,不能用其他平台的通过结果替代。extension/src/platform/接口骨架:闭包里出现的平台类(CGraphicThingInstance、CSoundManager等)只声明 40250 调用方用到的方法,先给空实现。- 门禁:
port/**下每个头文件单独编译通过(header self-containment),序列化结构的static_assert全部通过。 - port-map 重新基线(见第 6 节)。已完成(2026-09-22)。
- 用
#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截断语义、timeGetTime32 位回绕)、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是 SDKdimm.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 处理。
- EterBase 的
- ScriptLib、EterPythonLib、UserInterface 的 StdAfx 会 include Python 头,要等 2P 把 CPython 加进来之后再照抄。
- 门禁中每个头文件前先 include 本库的
-
已完成(步骤 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>.cpp95 个,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的方法等)。这些在对应逻辑单元移植时手工补桩。
- 平台层头文件(同名 40250
-
已完成(步骤 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 不做) |
| 不做(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。
-
类型统计(Index 注册到的 103 个包加
root,54,891 条,52,609 个不同路径):类型 文件数 存储 解码后 NONE20,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。 -
密钥来源:这个客户端只用编译进客户端的两组 TEA 密钥(
s_adwEterPackKey解索引,s_adwEterPackSecurityKey解 SECURITY 数据)。没有 PANAMA 和 HybridCrypt 条目,所以不需要服务器下发 IV/密钥,也不需要真服抓包。CAccountConnector的RegisterPack(..., abIV)和GC_HYBRIDCRYPT_KEYS/SDB照抄移植即可,但这批资源用不到。 移植后的EterPack.cpp需要这两组常量;按"密钥不进仓库",由构建时从参考树生成一个 gitignore 的头文件,不直接写进源码。 -
注册顺序与覆盖:
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按解码后的内容算。
- Ymir 格式:
- 在第二种格式下,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 拒绝。
-
路径归一化:
ConvertFileName只做两件事:ASCIItolower,以及\转/。CP949 字节原样保留。- 包内名字全部已经是小写、只用
/;52,609 个路径没有大小写冲突,filename_crc与crc32(name)全部一致。 d:/ymir work/...是包内的真实路径,一共 26,955 个。40250 的相对路径映射已经注释掉,所以调用方必须使用 40250 原样的 路径名,不能再用 m2devassets/的相对路径。- 包文件名与
Index大小写不一致,例如Etc对应磁盘上的ETC.eix。NTFS 下能打开;在移动端和 Linux 上,按包名查找 磁盘文件时要做大小写不敏感匹配。
-
移动端交付(已注册包的存储大小共 1337 MiB):
分组 大小 metin2_patch_*/patch1/patch2/season3_eu692.7 MiB 核心 d:/ymir work(pc/monster/zone/effect/npc/item…)+ icon/property/textureset405.7 MiB sound/bgm 87.6 MiB season1/2 71.5 MiB 其他 14 种语言的 locale 49.9 MiB 地图包 25.9 MiB locale_en3.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。
- 设备上直接读 EPK,用移植的
-
2D 的资源根切换:
- 新增 pack 后端:
asset_io在MT_ASSETS之外,接上移植的CEterPackManager,按pack/Index注册。 - 渲染和 GDScript 的资源请求改用 40250 路径,经过
ConvertFileName查找。 - m2dev
assets/的散文件保留作开发期覆盖,直到对应读取方切到新路径。 - 严格门禁(第 5 节步骤 3)的来源清单改为记录各个
.eix/.epk的 sha256。
- 新增 pack 后端:
批次 2P:内嵌 Python 集成
-
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(ctestpython.embed)。三处 vendor 补丁见docs/THIRD-PARTY.md。 目标平台是 macOS arm64 和 Android arm64;Linux/Windows 不做(见第 1 节)。 -
静态链接进
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(ctestport.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)。 -
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把 vendoredLib/打成<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。 -
两个目标平台分别验证:编译、链接无未定义符号、
Py_Initialize、静态 C 模块逐个import、在 app 进程里跑system.py→prototype.RunApp()(对照结果:74 个模块中 66 个加载成功,引导期调用 33 个 C++ 函数)。进程内入口(2026-09-23):
extension/src/platform/ScriptLib/PythonBoot.{h,cpp}是 40250RunMainScript的移植(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()要等 2V0Android 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 并重验内建模块表。
将来动手时分两步:
- 探测条件从「头文件在不在」改成「这个平台的 iconv 能不能用」——
#if __has_include(<iconv.h>) && !defined(__ANDROID__), Android 一律走回退分支,先把交叉编译修绿。不要写成__ANDROID_API__ >= 28。 - 补真正的转换:Android 需要自带 GBK↔Unicode 表(约 22k 码位)。40250 原客户端走 Windows
MultiByteToWideChar(CP949/CP936),跨平台没有对应物;macOS 能用只是因为系统 libiconv 恰好带 GBK。
批次 2V0–2V3:纵向切片
2A 先根据真实 #include 和 Python import 生成每个切片的机器可读单元清单;清单提交到 audit/,进入切片前不得仍有
“后续批次才实现”的隐式依赖。未列出的 native 模块可以使用行为可观察、会记录调用的桩,但桩函数不能计为移植完成。
-
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-f2V0-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_DEVICE2V0-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 的逐像素对比未做。 -
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*/安全密钥部分原样放进 platformPythonApplication.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按 40250CPythonApplication成员顺序创建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 按 40250desc.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属 2V22V1-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)、登录失败弹窗、服务器对空文件名版本包的处理(客户端侧不可观测) -
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_live2026-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_live2026-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_live2026-09-23 对 192.168.21.203 连跑 8 次通过、script/port_gate.sh macosPASS,怪物种族实例随真实SetRace创建。偶发(约 1/11)Loading 阶段报头 7 未处理(疑包长失步),测试现打印g_iLastPacket并判失败,未再复现2V2-d InstanceBase+ActorInstance+CGraphicThingInstanceadapter: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_assert1531,假服 Loading 阶段补发SKILL_LEVEL回归。port.login_flow断言主角实例存在且种族 0、无TPacketGCCharacterAdditionalInfo报错;port.login_live2026-09-23 对 192.168.21.203 连跑 8 次通过,剩余 SYSERR 只有 gr2NOT 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(40250PythonApplication.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 离线与实服通过 -
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):
- msm 查找路径加上
root/<cls>_<m/w>.msm。 - proto 读取按 40250 移植:
EterBase/tea.cpp、GameLib/ItemData.h的TItemTable、CPythonNonPlayer的 mob 表,放进镜像文件(使用 2A 的定宽类型和static_assert),替换extension/src/proto/proto.cpp里的 m2dev 格式。 - 资源测试严格模式:
- 统一资源环境变量:C++ 测试(
extension/CMakeLists.txt目前只透传M2_ASSETS)和 GDScript(MT_ASSETS)统一读MT_ASSETS,过渡期两者都透传; - 新增
MT_ASSETS_STRICT=1:该模式下MT_ASSETS未设置、来源清单缺失、sha256 不匹配或必需文件缺失都必须失败,不能跳过; - 为 40250 的 root、locale、proto 和关键包生成来源清单(路径 + sha256),提交到
audit/,测试开始时核对。
- 统一资源环境变量:C++ 测试(
- 新增并在 CI/发布前强制运行
script/run_40250_asset_gate.sh;它设置/校验MT_ASSETS_STRICT=1,运行 native proto/pack/formats 测试和 Godot root/locale/render 测试。脚本在没有 40250 资源时必须失败,不能把跳过报告成 PASS。 - 资源根切换按 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)除外。
- 新增 namespace 块内缩进的定义(
- 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 节)更新状态并提交。