diff --git a/audit/history.jsonl b/audit/history.jsonl index 76266b33..0e86b482 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -518,3 +518,4 @@ {"time": "2026-09-23T10:56:00Z", "event": "port_round", "unit": "PORT-PLAN 2V2-d.1: ActorInstance/InstanceBase closure, main character instance", "ported": ["GameLib/ActorInstance.cpp", "GameLib/ActorInstanceAttach.cpp", "GameLib/ActorInstanceBattle.cpp", "GameLib/ActorInstanceBlend.cpp", "GameLib/ActorInstanceCollisionDetection.cpp", "GameLib/ActorInstanceEvent.cpp", "GameLib/ActorInstanceFly.cpp", "GameLib/ActorInstanceMotion.cpp", "GameLib/ActorInstanceMotionEvent.cpp", "GameLib/ActorInstancePosition.cpp", "GameLib/ActorInstanceRender.cpp", "GameLib/ActorInstanceRotation.cpp", "GameLib/ActorInstanceSync.cpp", "GameLib/ActorInstanceWeaponTrace.cpp", "UserInterface/InstanceBase.cpp", "UserInterface/InstanceBaseBattle.cpp", "UserInterface/InstanceBaseEffect.cpp", "UserInterface/InstanceBaseEvent.cpp", "UserInterface/InstanceBaseMotion.cpp", "UserInterface/InstanceBaseTransform.cpp", "GameLib/GameUtil.cpp", "GameLib/FlyTarget.cpp", "GameLib/PhysicsObject.cpp", "GameLib/GameEventManager.cpp", "GameLib/WeaponTrace.cpp", "GameLib/MapUtil.cpp", "EterLib/lineintersect_utils.cpp"], "adapted": ["Packet.h TPlayerSkill::tNextRead LONG (32-bit time_t on the wire) + static_assert 1531", "MapUtil.cpp include", "CGameEventManager / CPythonSystem created in PythonBoot in CPythonApplication member order", "D3D8Types.h full D3DTA/D3DTEXTUREOP/D3DCMPFUNC/D3DBLEND/D3DCULL"], "divergent": [], "needs_live": [], "tests": ["port.login_flow: pass (main instance exists, race 0, no AdditionalInfo error, SKILL_LEVEL in Loading)", "port.login_live: 8/8 pass against 192.168.21.203, no unhandled packet", "script/port_gate.sh macos: PASS"], "todo": ["2V2-d.2 gr2/mdatr resource factories + CGraphicThing/CGrannyModel/CGrannyMotion/CGraphicThingInstance on libgr2", "2V2-e render"]} {"time": "2026-09-23T10:36:40Z", "event": "port_round", "unit": "PORT-PLAN 2V2-d.2: Granny runtime on libgr2 + EterGrnLib, main character model animates", "ported": ["EterGrnLib/LODController.cpp", "EterGrnLib/Material.cpp", "EterGrnLib/Mesh.cpp", "EterGrnLib/Model.cpp", "EterGrnLib/ModelInstance.cpp", "EterGrnLib/ModelInstanceCollisionDetection.cpp", "EterGrnLib/ModelInstanceModel.cpp", "EterGrnLib/ModelInstanceMotion.cpp", "EterGrnLib/ModelInstanceRender.cpp", "EterGrnLib/ModelInstanceUpdate.cpp", "EterGrnLib/Motion.cpp", "EterGrnLib/Thing.cpp", "EterGrnLib/ThingInstance.cpp", "EterGrnLib/Util.cpp", "EterLib/GrpObjectInstance.cpp", "EterLib/GrpCollisionObject.cpp", "EterLib/CollisionData.cpp"], "adapted": ["platform/EterGrnLib/GrannyRuntime.cpp: Granny SDK 2.11 API on libgr2 (file sections, type-definition vertex conversion incl. half floats, material texture by Usage, mesh/bone bindings, deformer, control clock/loop/ease, weighted SRT sampling, composite matrices)", "shim/sdk/granny.h declared from the SDK 2.11 header", "CPU vertex/index buffers (platform/EterLib/CpuBuffer.h)", "Resource.cpp gr2 factory", "CPythonApplication::UpdateGame/RenderGame/GetMousePosition through singletons (no app object); CPythonGraphic in GameSingletons; host cursor", "libgr2: skeleton/mesh/anim/fileinfo accessors", "D3D8Types texture-stage enums, D3DXVec4Transform"], "divergent": [], "needs_live": [], "tests": ["port.login_flow: pass (PART_MAIN model has meshes, material 0 diffuse image loaded, WAIT motion changes bone matrices between frames)", "port.login_live: pass against 192.168.21.203", "script/port_gate.sh macos: PASS"], "todo": ["2V2-e Godot render adapter: recognizable standing character offline + live", "mdatr factory with CAttributeData resource"]} {"time": "2026-09-23T11:09:12Z", "event": "port_round", "unit": "PORT-PLAN 2V2-e.1: 3D draw-command channel (recording D3D8 device, StateManager, GrpBase, camera)", "ported": ["EterLib/StateManager.cpp", "EterLib/GrpBase.cpp"], "adapted": ["platform/EterLib/RecordingDevice.cpp: IDirect3DDevice8 that records every Draw* as Render3DDraw (RenderCommands3D.h)", "CGraphicDevice::Create on the recording device, CPU default IB/PDT VBs", "D3DXMath PerspectiveFovRH/OrthoRH/OrthoOffCenterRH + ID3DXMatrixStack", "PythonApplicationCamera.cpp: __UpdateCamera and camera API verbatim, members in a file-static struct", "Process: __UpdateCamera, OnCameraUpdate, Begin/SetInterfaceRenderState/OnUIRender/End", "CScreen Begin/End/Set*Operation, CPythonGraphic Set{Game,Interface}RenderState/PushState/PopState/GetOrthoDepth, CCullingManager::Process view/proj (no frustum culling yet)", "pending CPythonPlayer main-actor functions (no event handler), CMapManager::IsMapReady"], "divergent": [], "needs_live": [], "tests": ["port.login_flow: pass (textured lit triangle draw, RH perspective, all vertices on screen)", "port.login_live: pass against 192.168.21.203", "script/port_gate.sh macos: PASS"], "todo": ["2V2-e.2 Godot consumer of Render3DDraws + offline screenshot", "2V2-e.3 live smoke", "SphereLib frustum culling", "PythonPlayerEventHandler"]} +{"time": "2026-09-23T11:33:24Z", "event": "port_round", "unit": "PORT-PLAN 2V2-e.2: Godot consumer of Render3DDraws + offline screenshot", "ported": ["UserInterface/PythonBackground.cpp: CPythonBackground::GetFarClip", "GameLib/MapManager.cpp: CMapManager::CMapManager"], "adapted": ["python_host_node render3d_draws binding", "project/python_3d_surface.gd: draw -> MeshInstance3D (world*view), camera from the RH projection, materials from render state, textures from the 40250 packs (UiAssets.load_pack_tex keeps the d: drive)", "no-environment stand-in: lit draws without light/ambient/emissive shown unshaded (unlit_stand_in_count) until the map/environment port", "port_fake_login_server CLI + script/python_game_render_test.sh (offline / --live via MT_LIVE_*)"], "divergent": [], "needs_live": ["2V2-e.3 live smoke on the real server"], "tests": ["script/python_game_render_test.sh offline: pass (4 textured draws, character centred)", "script/port_gate.sh macos: PASS"], "todo": ["2V2-e.3 live smoke", "environment port (CMapManager::LoadMap / SetCharacterDirLight) removes the unlit stand-in"]} diff --git a/docs/PORT-PLAN.md b/docs/PORT-PLAN.md index 7bd38dac..2265f3a9 100644 --- a/docs/PORT-PLAN.md +++ b/docs/PORT-PLAN.md @@ -423,7 +423,7 @@ extension/third_party/cpython-2.7.18/ # 静态库(2P 批次 | 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
<符号...> [--ctor]` 按链接缺失符号生成到 `platform/pending//`,返回 `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 消费端 | + | 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 真服冒烟 | 4. **2V3 — 本地移动**:加入 `PythonPlayer`、`PythonPlayerInput*`、`PythonPlayerEventHandler`、 `InstanceBaseMovement`、`ActorInstance{Motion,Event,Position,Rotation,CollisionDetection}` 的实际依赖闭包。验收为输入经过新路径 改变位置/朝向、发送与 40250 相同的移动封包,并在离线边界测试和真服 smoke 中通过。接通时删除对应 legacy 玩法逻辑。 diff --git a/extension/src/platform/pending/GameLib/MapManager.cpp b/extension/src/platform/pending/GameLib/MapManager.cpp index 4a8e9307..bbf9be52 100644 --- a/extension/src/platform/pending/GameLib/MapManager.cpp +++ b/extension/src/platform/pending/GameLib/MapManager.cpp @@ -12,9 +12,12 @@ auto CMapManager::GetHeight(float, float) -> float return mt_platform_stub_return(); } -CMapManager::CMapManager() +CMapManager::CMapManager() : mc_pcurEnvironmentData(NULL) { - MT_PLATFORM_STUB(); + m_pkMap = NULL; + m_isSoftwareTilingEnableReserved=false; + +// Initialize(); } CMapManager::~CMapManager() diff --git a/extension/src/platform/pending/UserInterface/PythonBackground.cpp b/extension/src/platform/pending/UserInterface/PythonBackground.cpp index 37d85840..e5c544fc 100644 --- a/extension/src/platform/pending/UserInterface/PythonBackground.cpp +++ b/extension/src/platform/pending/UserInterface/PythonBackground.cpp @@ -76,6 +76,9 @@ auto CPythonBackground::GetWarpMapName() -> const char * CPythonBackground::CPythonBackground() { MT_PLATFORM_STUB(); + // PORT: the one member the stand-in's ported GetFarClip reads (PythonBackground.cpp:210); the + // view distance sets and Initialize() come with the unit. + m_eViewDistanceNum=0; } CPythonBackground::~CPythonBackground() @@ -95,10 +98,20 @@ auto CPythonBackground::GetPickingPointWithRayOnlyTerrain(const CRay &, D3DXVECT return mt_platform_stub_return(); } -auto CPythonBackground::GetFarClip() -> float +// 40250 PythonBackground.cpp:173 (CPythonApplication::RenderGame's SetPerspective); m_pkMap stays null +// until the map is ported, so this is the 50000 far clip. +float CPythonBackground::GetFarClip() { - MT_PLATFORM_STUB(); - return mt_platform_stub_return(); + if (!m_pkMap) + return 50000.0f; + + if (m_ViewDistanceSet[m_eViewDistanceNum].m_fFarClip==0.0f) + { + TraceError("CPythonBackground::GetFarClip m_eViewDistanceNum=%d", m_eViewDistanceNum); + m_ViewDistanceSet[m_eViewDistanceNum].m_fFarClip=25600.0f; + } + + return m_ViewDistanceSet[m_eViewDistanceNum].m_fFarClip; } auto CPythonBackground::Render() -> void diff --git a/extension/src/port/CMakeLists.txt b/extension/src/port/CMakeLists.txt index c1b28ecb..534fee4a 100644 --- a/extension/src/port/CMakeLists.txt +++ b/extension/src/port/CMakeLists.txt @@ -219,6 +219,12 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME) add_test(NAME port.login_flow COMMAND $ ${MT_40250_CLIENT} ${MT_PYTHON_STDLIB_ZIP}) set_tests_properties(port.login_flow PROPERTIES SKIP_RETURN_CODE 77) + # 2V2-e: the same fake server as a process, for the Godot-driven render run + # (script/python_game_render_test.sh). + add_executable(port_fake_login_server ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_fake_login_server_main.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_login_flow_server.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../net/classic/classic_cipher.cpp) + target_link_libraries(port_fake_login_server PRIVATE port_platform) # 2V1-e: the same path against a live 40250 server; skipped unless MT_LIVE_* is set (see the test). add_test(NAME port.login_live COMMAND $ ${MT_40250_CLIENT} ${MT_PYTHON_STDLIB_ZIP} live) diff --git a/extension/src/python_host_node.cpp b/extension/src/python_host_node.cpp index de0f4dad..485930fe 100644 --- a/extension/src/python_host_node.cpp +++ b/extension/src/python_host_node.cpp @@ -9,12 +9,18 @@ // unit, which has to stay compilable next to godot-cpp. #include "platform/ScriptLib/PythonBoot.h" #include "platform/EterLib/UIRenderCommands.h" +#include "platform/EterLib/RenderCommands3D.h" #include #endif #include #include #include +#include +#include +#include +#include +#include using namespace godot; @@ -117,6 +123,107 @@ Ref Metin2PythonHost::memory_texture(const String &name, int64_t known_re return Image::create_from_data(texture.width, texture.height, false, Image::FORMAT_RGBA8, rgba); } +namespace { + +PackedFloat32Array floats(const float *values, int count) { + PackedFloat32Array out; + out.resize(count); + for (int i = 0; i < count; ++i) + out[i] = values[i]; + return out; +} + +Color argb_color(std::uint32_t argb) { + return Color(((argb >> 16) & 255) / 255.0f, ((argb >> 8) & 255) / 255.0f, (argb & 255) / 255.0f, + ((argb >> 24) & 255) / 255.0f); +} + +} // namespace + +Array Metin2PythonHost::render3d_draws() { + Array out; + for (const Render3DDraw &draw : Render3DDraws()) { + Dictionary item; + item["world"] = floats(draw.world, 16); + item["view"] = floats(draw.view, 16); + item["proj"] = floats(draw.proj, 16); + item["texture0"] = String::utf8(draw.texture0.c_str()); + item["texture1"] = String::utf8(draw.texture1.c_str()); + item["pretransformed"] = draw.pretransformed; + item["lines"] = draw.lines; + + PackedVector3Array positions; + positions.resize(static_cast(draw.positions.size() / 3)); + for (int64_t i = 0; i < positions.size(); ++i) + positions[i] = Vector3(draw.positions[i * 3], draw.positions[i * 3 + 1], draw.positions[i * 3 + 2]); + item["positions"] = positions; + if (!draw.rhw.empty()) + item["rhw"] = floats(draw.rhw.data(), static_cast(draw.rhw.size())); + if (!draw.normals.empty()) { + PackedVector3Array normals; + normals.resize(static_cast(draw.normals.size() / 3)); + for (int64_t i = 0; i < normals.size(); ++i) + normals[i] = Vector3(draw.normals[i * 3], draw.normals[i * 3 + 1], draw.normals[i * 3 + 2]); + item["normals"] = normals; + } + auto uvs = [](const std::vector &values) { + PackedVector2Array out; + out.resize(static_cast(values.size() / 2)); + for (int64_t i = 0; i < out.size(); ++i) + out[i] = Vector2(values[i * 2], values[i * 2 + 1]); + return out; + }; + if (!draw.uv0.empty()) + item["uv0"] = uvs(draw.uv0); + if (!draw.uv1.empty()) + item["uv1"] = uvs(draw.uv1); + if (!draw.diffuse.empty()) { + PackedColorArray colors; + colors.resize(static_cast(draw.diffuse.size())); + for (int64_t i = 0; i < colors.size(); ++i) + colors[i] = argb_color(draw.diffuse[i]); + item["diffuse"] = colors; + } + PackedInt32Array indices; + indices.resize(static_cast(draw.indices.size())); + for (int64_t i = 0; i < indices.size(); ++i) + indices[i] = static_cast(draw.indices[i]); + item["indices"] = indices; + + item["alpha_blend"] = static_cast(draw.alpha_blend); + item["src_blend"] = static_cast(draw.src_blend); + item["dest_blend"] = static_cast(draw.dest_blend); + item["alpha_test"] = static_cast(draw.alpha_test); + item["alpha_ref"] = static_cast(draw.alpha_ref); + item["alpha_func"] = static_cast(draw.alpha_func); + item["cull_mode"] = static_cast(draw.cull_mode); + item["z_enable"] = static_cast(draw.z_enable); + item["z_write"] = static_cast(draw.z_write); + item["z_func"] = static_cast(draw.z_func); + item["lighting"] = static_cast(draw.lighting); + item["texture_factor"] = static_cast(draw.texture_factor); + item["fog_enable"] = static_cast(draw.fog_enable); + item["color_op"] = static_cast(draw.color_op[0]); + item["alpha_op"] = static_cast(draw.alpha_op[0]); + item["material_diffuse"] = Color(draw.material_diffuse[0], draw.material_diffuse[1], + draw.material_diffuse[2], draw.material_diffuse[3]); + item["material_ambient"] = Color(draw.material_ambient[0], draw.material_ambient[1], + draw.material_ambient[2], draw.material_ambient[3]); + item["material_emissive"] = Color(draw.material_emissive[0], draw.material_emissive[1], + draw.material_emissive[2], draw.material_emissive[3]); + item["light0"] = draw.light0; + item["light0_direction"] = Vector3(draw.light0_direction[0], draw.light0_direction[1], + draw.light0_direction[2]); + item["light0_diffuse"] = Color(draw.light0_diffuse[0], draw.light0_diffuse[1], draw.light0_diffuse[2], + draw.light0_diffuse[3]); + item["light0_ambient"] = Color(draw.light0_ambient[0], draw.light0_ambient[1], draw.light0_ambient[2], + draw.light0_ambient[3]); + item["ambient"] = argb_color(draw.ambient); + out.push_back(item); + } + return out; +} + void Metin2PythonHost::stop() { PythonBoot::Stop(); } @@ -149,6 +256,7 @@ void Metin2PythonHost::ui_key(int, bool) {} void Metin2PythonHost::ui_update() {} Array Metin2PythonHost::ui_render_commands() { return Array(); } Ref Metin2PythonHost::memory_texture(const String &, int64_t) { return Ref(); } +Array Metin2PythonHost::render3d_draws() { return Array(); } void Metin2PythonHost::stop() { } @@ -171,6 +279,7 @@ void Metin2PythonHost::_bind_methods() { ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("ui_render_commands"), &Metin2PythonHost::ui_render_commands); ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("memory_texture", "name", "known_revision"), &Metin2PythonHost::memory_texture, DEFVAL(-1)); + ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("render3d_draws"), &Metin2PythonHost::render3d_draws); ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("stop"), &Metin2PythonHost::stop); } diff --git a/extension/src/python_host_node.h b/extension/src/python_host_node.h index 3c5677b6..63ade742 100644 --- a/extension/src/python_host_node.h +++ b/extension/src/python_host_node.h @@ -43,6 +43,10 @@ public: // The pixels of a "mem:@" image command (the CGraphicFontTexture glyph pages) as an // RGBA8 Image; null when the texture is gone or its revision is still `known_revision`. static godot::Ref memory_texture(const godot::String &name, int64_t known_revision); + // The 3D draw calls of the last ui_update() (RenderCommands3D.h), one Dictionary each: "world", + // "view", "proj" (PackedFloat32Array, D3D row-vector layout), vertex arrays, "indices", "texture0" + // and the blend/depth/cull/lighting state. + static godot::Array render3d_draws(); static void stop(); protected: diff --git a/extension/tests/port_fake_login_server_main.cpp b/extension/tests/port_fake_login_server_main.cpp new file mode 100644 index 00000000..cec0f7dd --- /dev/null +++ b/extension/tests/port_fake_login_server_main.cpp @@ -0,0 +1,34 @@ +// The fake 40250 auth + game server of port_login_flow_test as its own process, for runs that drive the +// real client from Godot (script/python_game_render_test.sh). Prints "ports " once it +// listens and serves until SIGTERM or SIGINT; the milestones go to stderr on the way out. +#include "port_login_flow_server.h" + +#include +#include +#include + +static volatile std::sig_atomic_t g_stop = 0; + +static void on_signal(int) { g_stop = 1; } + +int main() +{ + std::signal(SIGTERM, on_signal); + std::signal(SIGINT, on_signal); + FakeLoginServer server; + if (!server.Start()) + { + std::fprintf(stderr, "port_fake_login_server: cannot listen on loopback\n"); + return 1; + } + std::printf("ports %d %d\n", server.AuthPort(), server.GamePort()); + std::fflush(stdout); + while (!g_stop) + pause(); + std::fprintf(stderr, "port_fake_login_server events:"); + for (const auto& e : server.Events()) + std::fprintf(stderr, " %s", e.c_str()); + std::fprintf(stderr, "\nport_fake_login_server error: %s\n", server.Error().c_str()); + server.Stop(); + return server.Failed() ? 1 : 0; +} diff --git a/project/python_3d_surface.gd b/project/python_3d_surface.gd new file mode 100644 index 00000000..f998eabb --- /dev/null +++ b/project/python_3d_surface.gd @@ -0,0 +1,208 @@ +# 3D adapter for the ported game render (docs/PORT-PLAN.md 2V2-e): every frame it turns the draw calls +# CPythonApplication::RenderGame issued through CStateManager (Metin2PythonHost.render3d_draws, recorded +# by the platform IDirect3DDevice8) into meshes under a camera. +# +# The draws carry 40250's own D3D matrices. CGraphicBase::SetPerspective builds a right-handed +# projection (D3DXMatrixPerspectiveFovRH), so 40250's view space already is Godot's camera space +# (x right, y up, looking down -z): each mesh gets world * view as its transform and the Camera3D stays +# at the origin with the fov/near/far read back from the projection. No axis conversion is involved. +# D3D's default front face (clockwise on screen, D3DCULL_CCW culls the rest) is also Godot's. +# +# PORT: until the map is ported, CMapManager has no environment data (LoadMap → SetEnvironmentData), +# so CPythonBackground::SetCharacterDirLight sets no light and D3DRS_AMBIENT stays 0: D3D would draw +# every lit mesh black. Such draws (lighting on, no light, no ambient, no emissive) are shown unshaded, +# texture only, and counted in unlit_stand_in_count. Delete this with the environment port. +extends Node3D + +const UiAssets = preload("res://ui/ui_assets.gd") + +# D3DCULL / D3DBLEND / D3DCMPFUNC values used below (D3D8Types.h). +const D3DCULL_NONE := 1 +const D3DCULL_CW := 2 +const D3DBLEND_ONE := 2 +const D3DBLEND_SRCALPHA := 5 +const D3DBLEND_INVSRCALPHA := 6 + +var camera: Camera3D +var light: DirectionalLight3D +var environment: WorldEnvironment +var _meshes: Array[MeshInstance3D] = [] +# Materials keyed by texture and state, so an unchanged draw keeps its material from frame to frame. +var _materials := {} + +# The draws the last update_frame() showed (for tests and reports). +var draw_count := 0 +# How many of them used the no-environment stand-in (see the note at the top). +var unlit_stand_in_count := 0 + +func _ready() -> void: + camera = Camera3D.new() + camera.name = "GameCamera" + camera.keep_aspect = Camera3D.KEEP_HEIGHT + camera.current = true + add_child(camera) + # 40250's directional light (CPythonBackground::SetCharacterDirLight) arrives with the draws in + # world space; it is placed in camera space each frame like the meshes. + light = DirectionalLight3D.new() + light.name = "Light0" + light.visible = false + add_child(light) + environment = WorldEnvironment.new() + environment.environment = Environment.new() + environment.environment.background_mode = Environment.BG_COLOR + environment.environment.background_color = Color.BLACK + environment.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + environment.environment.ambient_light_color = Color.BLACK + environment.environment.tonemap_mode = Environment.TONE_MAPPER_LINEAR + add_child(environment) + +func _process(_delta: float) -> void: + if Metin2PythonHost.is_running(): + update_frame() + +# D3D row-vector matrix (v' = v * M) as a Godot Transform3D (v' = T * v): the rows of M are the basis +# columns, row 3 is the origin. +static func d3d_transform(m: PackedFloat32Array) -> Transform3D: + return Transform3D(Basis(Vector3(m[0], m[1], m[2]), Vector3(m[4], m[5], m[6]), Vector3(m[8], m[9], m[10])), + Vector3(m[12], m[13], m[14])) + +static func multiply(a: PackedFloat32Array, b: PackedFloat32Array) -> PackedFloat32Array: + var out := PackedFloat32Array() + out.resize(16) + for r in 4: + for c in 4: + var sum := 0.0 + for k in 4: + sum += a[r * 4 + k] * b[k * 4 + c] + out[r * 4 + c] = sum + return out + +func update_frame() -> void: + var draws: Array = Metin2PythonHost.render3d_draws() + var shown := 0 + var stand_ins := 0 + var camera_set := false + var light_set := false + for draw in draws: + # XYZRHW (screen-space) draws belong to the 2D pass; lines are debug geometry. + if draw["pretransformed"] or draw["lines"] or draw["indices"].is_empty(): + continue + var proj: PackedFloat32Array = draw["proj"] + # _34 == -1: a perspective projection (the game pass). Ortho draws are UI-space. + if proj[11] != -1.0: + continue + if not camera_set: + _apply_projection(proj) + camera_set = true + var view: PackedFloat32Array = draw["view"] + if not light_set and draw["light0"]: + _apply_light(draw, view) + light_set = true + if _environment_missing(draw): + stand_ins += 1 + var instance := _mesh_instance(shown) + instance.transform = d3d_transform(multiply(draw["world"], view)) + instance.mesh = _build_mesh(draw) + instance.visible = true + shown += 1 + for i in range(shown, _meshes.size()): + _meshes[i].visible = false + _meshes[i].mesh = null + if not light_set: + light.visible = false + draw_count = shown + unlit_stand_in_count = stand_ins + +func _apply_projection(proj: PackedFloat32Array) -> void: + # D3DXMatrixPerspectiveFovRH: _22 = cot(fovy / 2), _33 = zf / (zn - zf), _43 = zn * zf / (zn - zf). + camera.fov = rad_to_deg(2.0 * atan(1.0 / proj[5])) + camera.near = proj[14] / proj[10] + camera.far = proj[14] / (proj[10] + 1.0) + +func _apply_light(draw: Dictionary, view: PackedFloat32Array) -> void: + var view_basis := d3d_transform(view).basis + var direction: Vector3 = view_basis * draw["light0_direction"] + if direction.length_squared() < 1e-8: + light.visible = false + return + light.visible = true + # DirectionalLight3D shines along its -Z. + light.transform = Transform3D(Basis.looking_at(direction.normalized(), + Vector3.UP if absf(direction.normalized().y) < 0.99 else Vector3.RIGHT), Vector3.ZERO) + var diffuse: Color = draw["light0_diffuse"] + light.light_color = Color(diffuse.r, diffuse.g, diffuse.b) + light.light_energy = 1.0 + # D3D lighting: ambient = D3DRS_AMBIENT + light ambient, both times the material ambient. + var ambient: Color = draw["ambient"] + var light_ambient: Color = draw["light0_ambient"] + environment.environment.ambient_light_color = Color(minf(ambient.r + light_ambient.r, 1.0), + minf(ambient.g + light_ambient.g, 1.0), minf(ambient.b + light_ambient.b, 1.0)) + +static func _environment_missing(draw: Dictionary) -> bool: + var ambient: Color = draw["ambient"] + var emissive: Color = draw["material_emissive"] + return draw["lighting"] and not draw["light0"] and ambient.r + ambient.g + ambient.b == 0.0 \ + and emissive.r + emissive.g + emissive.b == 0.0 + +func _mesh_instance(index: int) -> MeshInstance3D: + while _meshes.size() <= index: + var instance := MeshInstance3D.new() + instance.name = "Draw%d" % _meshes.size() + instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(instance) + _meshes.append(instance) + return _meshes[index] + +func _build_mesh(draw: Dictionary) -> ArrayMesh: + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = draw["positions"] + if draw.has("normals"): + arrays[Mesh.ARRAY_NORMAL] = draw["normals"] + if draw.has("uv0"): + arrays[Mesh.ARRAY_TEX_UV] = draw["uv0"] + if draw.has("diffuse"): + arrays[Mesh.ARRAY_COLOR] = draw["diffuse"] + arrays[Mesh.ARRAY_INDEX] = draw["indices"] + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + mesh.surface_set_material(0, _material(draw)) + return mesh + +func _material(draw: Dictionary) -> StandardMaterial3D: + var lit: bool = draw["lighting"] and not _environment_missing(draw) + var key := "%s|%d|%d|%d|%d|%d|%d|%d|%s|%s" % [draw["texture0"], draw["alpha_blend"], draw["src_blend"], + draw["dest_blend"], draw["alpha_test"], draw["alpha_ref"], draw["cull_mode"], lit, + draw["material_diffuse"], draw["material_emissive"]] + if _materials.has(key): + return _materials[key] + var material := StandardMaterial3D.new() + var texture_name: String = draw["texture0"] + if not texture_name.is_empty(): + # The ported client reads its models from 40250's packs; the texture must come from there too. + material.albedo_texture = UiAssets.load_pack_tex(texture_name) + material.albedo_color = draw["material_diffuse"] if lit else Color.WHITE + material.emission_enabled = lit and draw["material_emissive"] != Color(0, 0, 0, 0) + if material.emission_enabled: + material.emission = draw["material_emissive"] + material.shading_mode = BaseMaterial3D.SHADING_MODE_PER_VERTEX if lit \ + else BaseMaterial3D.SHADING_MODE_UNSHADED + material.specular_mode = BaseMaterial3D.SPECULAR_DISABLED + material.roughness = 1.0 + material.vertex_color_use_as_albedo = draw.has("diffuse") + match int(draw["cull_mode"]): + D3DCULL_NONE: + material.cull_mode = BaseMaterial3D.CULL_DISABLED + D3DCULL_CW: + material.cull_mode = BaseMaterial3D.CULL_FRONT + _: + material.cull_mode = BaseMaterial3D.CULL_BACK + if draw["alpha_blend"]: + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + if int(draw["dest_blend"]) == D3DBLEND_ONE: + material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD + elif draw["alpha_test"]: + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR + material.alpha_scissor_threshold = float(draw["alpha_ref"]) / 255.0 + _materials[key] = material + return material diff --git a/project/python_game_render_test.gd b/project/python_game_render_test.gd new file mode 100644 index 00000000..a34aa87f --- /dev/null +++ b/project/python_game_render_test.gd @@ -0,0 +1,172 @@ +# python_game_render_test — 2V2-e:Godot 里跑 40250 的 system.py,按 port_login_flow_test 的同一条路径 +# 登录 → 选人 → 进游戏,把 RenderGame 的绘制命令(python_3d_surface.gd)画出来并截图。 +# +# godot --path project --script res://python_game_render_test.gd +# +# 服务器与账号只从环境变量读(MT_LIVE_HOST / MT_LIVE_LOGIN / MT_LIVE_PASSWORD,可选 MT_LIVE_AUTH_PORT +# 11000、MT_LIVE_GAME_PORT 13000、MT_LIVE_SLOT 0);Python 侧也用 os.environ 取,不进源码文本。 +# 离线由 script/python_game_render_test.sh 起 port_fake_login_server 并把这些变量指向它。 +# 截图与 report.json 写到 MT_RENDER_OUTPUT(默认 build/rendering/python-game-<时间>)。 +extends SceneTree + +const PythonUISurface = preload("res://python_ui_surface.gd") +const Python3DSurface = preload("res://python_3d_surface.gd") + +var failed := false +var ui: Control +var world: Node3D + +func _initialize() -> void: + call_deferred("run") + +func _check(ok: bool, message: String) -> void: + if ok: + print("ok: " + message) + else: + failed = true + printerr("FAIL: " + message) + +func _py(source: String) -> bool: + return Metin2PythonHost.run_line(source) == "" + +func _pump_until(seconds: float, condition: Callable) -> bool: + var deadline := Time.get_ticks_msec() + int(seconds * 1000.0) + while Time.get_ticks_msec() < deadline: + if condition.call(): + return true + await process_frame + return condition.call() + +func _finish(output: String, report: Dictionary) -> void: + report["passed"] = not failed + var file := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE) + if file: + file.store_string(JSON.stringify(report, " ")) + file.close() + print("python_game_render_test: %s (%s)" % ["FAIL" if failed else "PASS", output]) + ui.queue_free() + await process_frame + quit(1 if failed else 0) + +func run() -> void: + root.size = Vector2i(1024, 768) + var output := OS.get_environment("MT_RENDER_OUTPUT") + if output.is_empty(): + output = ProjectSettings.globalize_path("res://../build/rendering/python-game-%d" % Time.get_unix_time_from_system()) + DirAccess.make_dir_recursive_absolute(output) + var report := {"output": output, "headless": DisplayServer.get_name() == "headless"} + + var host := OS.get_environment("MT_LIVE_HOST") + if host.is_empty() or OS.get_environment("MT_LIVE_LOGIN").is_empty() or OS.get_environment("MT_LIVE_PASSWORD").is_empty(): + printerr("python_game_render_test: needs MT_LIVE_HOST, MT_LIVE_LOGIN, MT_LIVE_PASSWORD") + quit(2) + return + + world = Python3DSurface.new() + world.name = "Python3DSurface" + ui = PythonUISurface.new() + ui.name = "PythonUISurface" + # The UI surface runs CPythonApplication::Process in its _process; the 3D surface reads the draws + # of that frame after it, so it comes second in the tree. + root.add_child(ui) + root.add_child(world) + var err: String = ui.run_app() + _check(err == "", "system.py reached app.Loop() (%s)" % err) + if err != "": + await _finish(output, report) + return + + _check(_py("import __main__, os, networkModule, introLogin, introSelect, introLoading, game\n" + + "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"), + "found networkModule.MainStream") + var at_login := await _pump_until(20, func(): return _py( + "assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)")) + _check(at_login, "login window") + + # LoginWindow.Connect with the account from the environment. + _check(_py("_env = os.environ\n" + + "_slot = int(_env.get('MT_LIVE_SLOT', '0'))\n" + + "_stream.SetConnectInfo(_env['MT_LIVE_HOST'], int(_env.get('MT_LIVE_GAME_PORT', '13000')), " + + "_env['MT_LIVE_HOST'], int(_env.get('MT_LIVE_AUTH_PORT', '11000')))\n" + + "_stream.curPhaseWindow.Connect(_env['MT_LIVE_LOGIN'], _env['MT_LIVE_PASSWORD'])\n" + + "del _env"), "LoginWindow.Connect") + var selecting := await _pump_until(30, func(): return _py( + "assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)")) + _check(selecting, "character select") + if selecting: + _check(_py("_stream.curPhaseWindow.SelectSlot(_slot)\n_stream.curPhaseWindow.StartGame()"), "StartGame") + var in_game := selecting and await _pump_until(60, func(): return _py( + "assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()")) + _check(in_game, "game window") + + # The main character's draws reach the 3D surface. + var drawn := in_game and await _pump_until(10, func(): return world.draw_count > 0) + _check(drawn, "RenderGame draws shown (%d)" % world.draw_count) + # Let the WAIT motion run a little before the picture. + for i in 30: + await process_frame + report["draw_count"] = world.draw_count + report["unlit_stand_in_count"] = world.unlit_stand_in_count + report["camera_fov"] = world.camera.fov + report["camera_near"] = world.camera.near + report["camera_far"] = world.camera.far + var textures := [] + var draws: Array = Metin2PythonHost.render3d_draws() + if not draws.is_empty(): + report["proj"] = Array(draws[0]["proj"]) + report["view"] = Array(draws[0]["view"]) + # Each draw's render state (everything but the arrays and matrices). + var states := [] + for draw in draws: + var state := {} + for key in draw: + if typeof(draw[key]) in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_COLOR, TYPE_VECTOR3]: + state[key] = str(draw[key]) + state["vertices"] = draw["positions"].size() + if draw.has("uv0"): + var lo := Vector2(INF, INF) + var hi := -lo + for uv in draw["uv0"]: + lo = lo.min(uv) + hi = hi.max(uv) + state["uv0_range"] = str([lo, hi]) + state["indices"] = draw["indices"].size() + states.append(state) + report["draw_states"] = states + for draw in draws: + if not draw["texture0"].is_empty() and not textures.has(draw["texture0"]): + textures.append(draw["texture0"]) + report["textures"] = textures + var surfaces_textured := 0 + for instance in world.find_children("Draw*", "MeshInstance3D", false, false): + if instance.visible and instance.mesh: + var material: StandardMaterial3D = instance.mesh.surface_get_material(0) + if material and material.albedo_texture: + surfaces_textured += 1 + report["surfaces_textured"] = surfaces_textured + var bounds := [] + for instance in world.find_children("Draw*", "MeshInstance3D", false, false): + if instance.visible and instance.mesh: + bounds.append(str(instance.transform * instance.mesh.get_aabb())) + report["camera_space_bounds"] = bounds + _check(not drawn or surfaces_textured > 0, "character surfaces have their diffuse textures (%d)" % surfaces_textured) + if DisplayServer.get_name() != "headless": + await RenderingServer.frame_post_draw + var image := root.get_texture().get_image() + _check(image.save_png(output.path_join("game.png")) == OK, "screenshot saved") + # The character is centred by __UpdateCamera: the middle of the picture is not background. + var centre := Rect2i(image.get_width() * 2 / 5, image.get_height() * 3 / 10, + image.get_width() / 5, image.get_height() * 2 / 5) + var lit := 0 + for y in range(centre.position.y, centre.end.y, 4): + for x in range(centre.position.x, centre.end.x, 4): + if image.get_pixel(x, y).get_luminance() > 0.05: + lit += 1 + report["centre_lit_samples"] = lit + _check(lit > 50, "character pixels in the middle of the screen (%d samples)" % lit) + # The 3D pass alone, for looking at the character without the UI. + ui.visible = false + await RenderingServer.frame_post_draw + root.get_texture().get_image().save_png(output.path_join("game_3d.png")) + ui.visible = true + await _finish(output, report) diff --git a/project/ui/ui_assets.gd b/project/ui/ui_assets.gd index af4c5294..e07d5b89 100644 --- a/project/ui/ui_assets.gd +++ b/project/ui/ui_assets.gd @@ -37,6 +37,19 @@ static func load_tex(assets_root: String, vpath: String) -> Texture2D: _cache[key] = tex return tex +# 只从 40250 Client/pack(Metin2Pack)取,不看 assets/ 散文件:移植的客户端从包里读模型, +# 贴图也必须是同一份(assets/ 里同名的 fork 贴图 UV 布局可能不同)。 +static func load_pack_tex(vpath: String) -> Texture2D: + if vpath == "": + return null + var key := "pack|" + vpath + if _cache.has(key): + return _cache[key] + # 40250 的包名带盘符(d:/ymir work/...),原样交给 CEterPackManager。 + var tex: Texture2D = _load_pack_image(vpath.replace("\\", "/")) + _cache[key] = tex + return tex + static func _strip_drive(p: String) -> String: var s := p.replace("\\", "/") if s.length() >= 2 and s[1] == ":": diff --git a/script/python_game_render_test.sh b/script/python_game_render_test.sh new file mode 100755 index 00000000..62c0776b --- /dev/null +++ b/script/python_game_render_test.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# 2V2-e: the ported client (system.py in Godot) logs in, enters the game and draws RenderGame's +# commands; project/python_game_render_test.gd takes the screenshot and writes report.json. +# +# script/python_game_render_test.sh offline: port_fake_login_server on loopback +# script/python_game_render_test.sh --live live: MT_LIVE_HOST / MT_LIVE_LOGIN / MT_LIVE_PASSWORD +# (+ MT_LIVE_AUTH_PORT / MT_LIVE_GAME_PORT / MT_LIVE_SLOT) +# +# Credentials are only ever read from the caller's environment; nothing here prints them. Do not add +# `set -x`. Needs a windowed Godot (a headless run draws nothing and skips the screenshot checks). +# exit: 0 PASS, 1 FAIL, 2 precondition missing. +set -euo pipefail +set +x + +cd "$(dirname "$0")/.." +repo="$PWD" +godot_bin="${GODOT:-godot}" +output="${MT_RENDER_OUTPUT:-$repo/build/rendering/python-game-$(date +%Y%m%d-%H%M%S)-$$}" +mkdir -p "$output" +export MT_RENDER_OUTPUT="$output" + +server_pid="" +cleanup() { + if [ -n "$server_pid" ]; then + kill -TERM "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + fi +} +trap cleanup EXIT + +if [ "${1:-}" = "--live" ]; then + if [ -z "${MT_LIVE_HOST:-}" ] || [ -z "${MT_LIVE_LOGIN:-}" ] || [ -z "${MT_LIVE_PASSWORD:-}" ]; then + echo "python_game_render_test: --live needs MT_LIVE_HOST, MT_LIVE_LOGIN, MT_LIVE_PASSWORD" >&2 + exit 2 + fi +else + server="$repo/build/extension/src/port/port_fake_login_server" + if [ ! -x "$server" ]; then + echo "python_game_render_test: build port_fake_login_server first ($server)" >&2 + exit 2 + fi + fifo="$output/server.ports" + "$server" > "$fifo" 2> "$output/server.log" & + server_pid=$! + for _ in $(seq 50); do + [ -s "$fifo" ] && break + sleep 0.1 + done + read -r word auth_port game_port < "$fifo" || true + if [ "${word:-}" != "ports" ]; then + echo "python_game_render_test: the fake server did not start (see $output/server.log)" >&2 + exit 2 + fi + export MT_LIVE_HOST=127.0.0.1 MT_LIVE_LOGIN=fakeuser MT_LIVE_PASSWORD=fakepass + export MT_LIVE_AUTH_PORT="$auth_port" MT_LIVE_GAME_PORT="$game_port" MT_LIVE_SLOT=0 +fi + +status=0 +"$godot_bin" --path "$repo/project" --script res://python_game_render_test.gd > "$output/godot.log" 2>&1 || status=$? +grep -E "^(ok|FAIL):|python_game_render_test:" "$output/godot.log" || true +exit "$status"