2V2-e.2: Godot consumer of the 3D draw commands, offline screenshot
python_3d_surface.gd turns RenderGame's recorded draws into meshes under a camera read back from SetPerspective; textures come from the 40250 packs. GetFarClip and the CMapManager ctor ported verbatim. Until the environment is ported, lit draws with no light/ambient are shown unshaded (stand-in). script/python_game_render_test.sh runs system.py login -> select -> game against port_fake_login_server (or --live via MT_LIVE_*) and screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5bfa13c5cc
commit
1fe8a4b643
@@ -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 <float.h> 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"]}
|
||||
|
||||
+1
-1
@@ -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 <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 消费端 |
|
||||
| 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 玩法逻辑。
|
||||
|
||||
@@ -12,9 +12,12 @@ auto CMapManager::GetHeight(float, float) -> float
|
||||
return mt_platform_stub_return<float>();
|
||||
}
|
||||
|
||||
CMapManager::CMapManager()
|
||||
CMapManager::CMapManager() : mc_pcurEnvironmentData(NULL)
|
||||
{
|
||||
MT_PLATFORM_STUB();
|
||||
m_pkMap = NULL;
|
||||
m_isSoftwareTilingEnableReserved=false;
|
||||
|
||||
// Initialize();
|
||||
}
|
||||
|
||||
CMapManager::~CMapManager()
|
||||
|
||||
@@ -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<bool>();
|
||||
}
|
||||
|
||||
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<float>();
|
||||
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
|
||||
|
||||
@@ -219,6 +219,12 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
|
||||
add_test(NAME port.login_flow
|
||||
COMMAND $<TARGET_FILE:port_login_flow_test> ${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 $<TARGET_FILE:port_login_flow_test> ${MT_40250_CLIENT} ${MT_PYTHON_STDLIB_ZIP} live)
|
||||
|
||||
@@ -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 <string>
|
||||
#endif
|
||||
|
||||
#include <godot_cpp/classes/image.hpp>
|
||||
#include <godot_cpp/variant/dictionary.hpp>
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <godot_cpp/variant/packed_color_array.hpp>
|
||||
#include <godot_cpp/variant/packed_float32_array.hpp>
|
||||
#include <godot_cpp/variant/packed_int32_array.hpp>
|
||||
#include <godot_cpp/variant/packed_vector2_array.hpp>
|
||||
#include <godot_cpp/variant/packed_vector3_array.hpp>
|
||||
|
||||
using namespace godot;
|
||||
|
||||
@@ -117,6 +123,107 @@ Ref<Image> 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<int64_t>(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<int>(draw.rhw.size()));
|
||||
if (!draw.normals.empty()) {
|
||||
PackedVector3Array normals;
|
||||
normals.resize(static_cast<int64_t>(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<float> &values) {
|
||||
PackedVector2Array out;
|
||||
out.resize(static_cast<int64_t>(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<int64_t>(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<int64_t>(draw.indices.size()));
|
||||
for (int64_t i = 0; i < indices.size(); ++i)
|
||||
indices[i] = static_cast<int32_t>(draw.indices[i]);
|
||||
item["indices"] = indices;
|
||||
|
||||
item["alpha_blend"] = static_cast<int64_t>(draw.alpha_blend);
|
||||
item["src_blend"] = static_cast<int64_t>(draw.src_blend);
|
||||
item["dest_blend"] = static_cast<int64_t>(draw.dest_blend);
|
||||
item["alpha_test"] = static_cast<int64_t>(draw.alpha_test);
|
||||
item["alpha_ref"] = static_cast<int64_t>(draw.alpha_ref);
|
||||
item["alpha_func"] = static_cast<int64_t>(draw.alpha_func);
|
||||
item["cull_mode"] = static_cast<int64_t>(draw.cull_mode);
|
||||
item["z_enable"] = static_cast<int64_t>(draw.z_enable);
|
||||
item["z_write"] = static_cast<int64_t>(draw.z_write);
|
||||
item["z_func"] = static_cast<int64_t>(draw.z_func);
|
||||
item["lighting"] = static_cast<int64_t>(draw.lighting);
|
||||
item["texture_factor"] = static_cast<int64_t>(draw.texture_factor);
|
||||
item["fog_enable"] = static_cast<int64_t>(draw.fog_enable);
|
||||
item["color_op"] = static_cast<int64_t>(draw.color_op[0]);
|
||||
item["alpha_op"] = static_cast<int64_t>(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<Image> Metin2PythonHost::memory_texture(const String &, int64_t) { return Ref<Image>(); }
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,10 @@ public:
|
||||
// The pixels of a "mem:<id>@<revision>" 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<godot::Image> 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:
|
||||
|
||||
@@ -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 <auth> <game>" 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 <csignal>
|
||||
#include <cstdio>
|
||||
#include <unistd.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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] == ":":
|
||||
|
||||
Executable
+61
@@ -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"
|
||||
Reference in New Issue
Block a user