2V2-e.4: render 40250 pack terrain with map lighting
This commit is contained in:
@@ -520,3 +520,5 @@
|
|||||||
{"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: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"]}
|
{"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"]}
|
||||||
{"time": "2026-09-23T11:40:26Z", "event": "port_round", "unit": "PORT-PLAN 2V2-e.3: live smoke of the Godot render adapter", "ported": [], "adapted": ["python_3d_surface.gd material key: bools as ints (formatting error made every draw share the first material)", "python_game_render_test.gd: one texture per texture name check"], "divergent": [], "needs_live": [], "tests": ["script/python_game_render_test.sh offline: pass (3 textures, character dressed)", "script/python_game_render_test.sh --live against 192.168.21.203: pass (246 draws, 10 textures, main character + mobs)", "script/port_gate.sh macos: PASS"], "todo": ["environment port (CMapManager::LoadMap / SetCharacterDirLight) removes the unlit stand-in", "terrain/map rendering", "SphereLib frustum culling", "PythonPlayerEventHandler"]}
|
{"time": "2026-09-23T11:40:26Z", "event": "port_round", "unit": "PORT-PLAN 2V2-e.3: live smoke of the Godot render adapter", "ported": [], "adapted": ["python_3d_surface.gd material key: bools as ints (formatting error made every draw share the first material)", "python_game_render_test.gd: one texture per texture name check"], "divergent": [], "needs_live": [], "tests": ["script/python_game_render_test.sh offline: pass (3 textures, character dressed)", "script/python_game_render_test.sh --live against 192.168.21.203: pass (246 draws, 10 textures, main character + mobs)", "script/port_gate.sh macos: PASS"], "todo": ["environment port (CMapManager::LoadMap / SetCharacterDirLight) removes the unlit stand-in", "terrain/map rendering", "SphereLib frustum culling", "PythonPlayerEventHandler"]}
|
||||||
|
{"time":"2026-09-23T12:08:00Z","event":"port_round","unit":"PORT-PLAN 2V2-e.4: 40250 pack terrain and .msenv lighting in Python 3D surface","ported":[],"adapted":["Metin2World pack:// path for Setting.txt, height.raw, tile.raw, textureset DDS and .msenv","AssetResolver virtual pack root preserving d:/ paths","environment_builder decodes pack TGA/PNG/JPG from bytes","python_3d_surface maps terrain into recorded D3D camera frame and lights character from map .msenv"],"divergent":["CMapManager::LoadMap/GetHeight and CPythonBackground::SetCharacterDirLight remain native stubs; terrain is aligned once to the actor's initial height in the render adapter"],"needs_live":["2026-09-23 live smoke not rerun: MT_LIVE_* credentials unavailable in this environment"],"tests":["script/python_game_render_test.sh offline: pass (9/9 terrain chunks splatted, 4/4 character materials lit, screenshot textured ground)","build/formats/formats_map_test: pass","godot --headless --path project --script res://environment_test.gd: pass"],"todo":["native CMapManager/CPythonBackground environment and height chain","cross-map warp rebuild of the 3D terrain scene"]}
|
||||||
|
{"time":"2026-09-23T12:14:00Z","event":"test_run","id":"PORT-PLAN 2V2-e.4","result":"PASS","tests":["script/port_gate.sh macos: PASS (11 port tests, port.login_live skipped without MT_LIVE_*; loopback-enabled run)","script/python_game_render_test.sh: PASS (9 terrain chunks, 9 splats, 4 lit character surfaces, textured ground screenshot)","build/formats/formats_map_test: PASS","godot --headless --path project --script res://environment_test.gd: PASS","git diff --check: PASS"],"reason":"The first sandboxed port gate run denied loopback sockets (port.net and port.login_flow); rerunning with local socket access passed. Live smoke remains unrun because credentials are unavailable."}
|
||||||
|
|||||||
@@ -424,6 +424,7 @@ extension/third_party/cpython-2.7.18/ # 静态库(2P 批次
|
|||||||
| 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-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-d | `InstanceBase` + `ActorInstance` + `CGraphicThingInstance` adapter:`GC_CHARACTER_ADD` 创建主角实例(msm 模型 + 待机动作) | 完成。d.1:`ActorInstance*.cpp`(Attach/Battle/Blend/CollisionDetection/Event/Fly/Motion/MotionEvent/Position/Render/Rotation/Sync/WeaponTrace)、`InstanceBase*.cpp`(Battle/Effect/Event/Motion/Transform)、`GameUtil`、`FlyTarget`、`PhysicsObject`、`GameEventManager`、`WeaponTrace`、`MapUtil`、`lineintersect_utils` 原样移植,`pending/GameLib/{ActorInstance,FlyTarget,PhysicsObject,MapUtil}.cpp`、`pending/UserInterface/InstanceBase.cpp` 替身删除,`ActorInstance.cpp`/`InstanceBase.cpp` 随主角创建进入运行时(port-map 标 PORTED);`CGameEventManager` 与 `CPythonSystem`(最后一个成员)按 `CPythonApplication` 成员顺序在 `PythonBoot` 创建;`D3D8Types.h` 补全 `D3DTA`/`D3DTEXTUREOP`/`D3DCMPFUNC`/`D3DBLEND`/`D3DCULL`。假服 `CHARACTER_ADD` 的 `bType` 改为 `CHAR_TYPE_PC`(6,原 0 使 `RecvCharacterAppendPacket` 不记主角、`CHAR_ADDITIONAL_INFO` 报错)。2V2-c 的偶发 Loading 头 7 根因:`TPlayerSkill::tNextRead` 是 `time_t`,40250 用 `_USE_32BIT_TIME_T`,LP64 下 `TPacketGCSkillLevelNew` 变成 2551 字节(线上 1531),`CInputDB::PlayerLoad` 在 `PointsPacket` 后发的 `SKILL_LEVEL`(76)读多吞掉后续包;`Packet.h` 改 `LONG` 并 `static_assert` 1531,假服 Loading 阶段补发 `SKILL_LEVEL` 回归。`port.login_flow` 断言主角实例存在且种族 0、无 `TPacketGCCharacterAdditionalInfo` 报错;`port.login_live` 2026-09-23 对 192.168.21.203 连跑 8 次通过,剩余 SYSERR 只有 gr2 `NOT SUPPORT FILE`。d.2:Granny SDK 2.11 运行时在 libgr2 上实现(`platform/EterGrnLib/GrannyRuntime.cpp`,`shim/sdk/granny.h` 按 SDK 头声明所用类型与函数:文件/段、按类型定义的顶点转换含 half、材质贴图按 Usage 查找、网格绑定、变形器、控制器时钟/循环/缓入缓出、加权 SRT 采样与复合矩阵),`EterGrnLib/*.cpp`(LODController/Material/Mesh/Model/ModelInstance*/Motion/Thing/ThingInstance/Util)与 `EterLib/{GrpObjectInstance,GrpCollisionObject,CollisionData}.cpp` 原样移植,对应 platform 骨架与 pending 替身删除(port-map 标 PORTED);`Resource.cpp` 注册 gr2 工厂(mdatr 等 `CAttributeData` 资源化时再注册);VB/IB 为 CPU 缓冲(`platform/EterLib/CpuBuffer.h`),渲染 adapter 属 2V2-e。`app.UpdateGame`/`RenderGame`(40250 `PythonApplication.cpp:321/168`)接入,成员经单例访问(平台没有 `CPythonApplication` 对象),`CPythonGraphic`(含 `CCullingManager`)按成员顺序进 `GameSingletons`;`GetMousePosition` 读 host 光标。`port.login_flow` 断言主角 PART_MAIN 模型有网格、材质漫反射贴图已加载、WAIT 动作使骨骼矩阵逐帧变化;离线与 `port.login_live`(192.168.21.203)2026-09-23 通过 |
|
||||||
| 2V2-e | Godot 显示主角(渲染 adapter),离线 + 真服 smoke:进游戏看到可辨认的静止角色 | 完成。e.1 完成:3D 绘制命令通道。`EterLib/StateManager.cpp`、`EterLib/GrpBase.cpp` 原样移植(platform 骨架删除,port-map 标 PORTED);`CGraphicDevice::Create` 建平台 `IDirect3DDevice8`(`platform/EterLib/RecordingDevice.cpp`:记住 transform/渲染状态/贴图阶段/材质/灯光/FVF/流/索引,按 FVF 解码顶点、展开 strip/fan,每次 Draw* 记成一条 `Render3DDraw`,`RenderCommands3D.h`),默认 IB/PDT VB 为 CPU 缓冲;`D3DXMath` 补 `PerspectiveFovRH`/`OrthoRH`/`OrthoOffCenterRH` 与 `ID3DXMatrixStack`。相机:`PythonApplicationCamera.cpp` 原样移植 `__UpdateCamera`/`SetCamera`/`SetCenterPosition`/`GetCenterPosition` 等(成员放文件内静态结构,平台没有 `CPythonApplication` 对象),`Process` 按 40250 顺序调 `__UpdateCamera`→`OnCameraUpdate`→`OnUIUpdate`→`Begin`/`SetInterfaceRenderState`/`OnUIRender`/`End`;`CScreen::Begin/End/Set*Operation`、`CPythonGraphic::Set{Game,Interface}RenderState/PushState/PopState/GetOrthoDepth`、`CCullingManager::Process` 的 view/proj 更新(视锥剔除等 SphereLib)原样;`pending` 补 `CPythonPlayer::NEW_GetMainActorPosition/NEW_GetMainActorPtr/NEW_FindActorPtr/SetMainCharacterIndex`(事件处理器留待 `PythonPlayerEventHandler`)、`CMapManager::IsMapReady`。`port.login_flow` 断言某帧有贴图、带法线/UV 的三角形绘制,投影为 `SetPerspective` 的 RH 透视(_34=-1),且顶点全部落在屏幕内(相机对准主角);离线与 `port.login_live`(192.168.21.203)2026-09-23 通过。e.2 完成:Godot 消费端 `project/python_3d_surface.gd`(`Metin2PythonHost.render3d_draws` 绑定):每条透视绘制成一个 `MeshInstance3D`,变换为 world·view(D3D 行向量矩阵的行即基列),`Camera3D` 在原点,fov/near/far 从 `SetPerspective` 投影反推;材质按贴图/混合/alpha test/剔除/光照状态缓存,贴图只从 40250 包取(`UiAssets.load_pack_tex`,包名保留 `d:` 盘符)。`CPythonBackground::GetFarClip` 原样移植(`m_pkMap` 为空 → 50000),`CMapManager` 构造原样(`m_pkMap=NULL`)。PORT 替身:地图未移植前没有环境数据(`LoadMap`→`SetEnvironmentData`→`SetCharacterDirLight`),D3D 会把受光网格画成全黑;「开光照、无灯、环境光与自发光为 0」的绘制按无光照贴图显示并计入 `unlit_stand_in_count`,随环境移植删除。`script/python_game_render_test.sh`(离线起 `port_fake_login_server`,`--live` 读 `MT_LIVE_*`)跑 `python_game_render_test.gd`:system.py 登录→选人→进游戏,4 条绘制(脸、Object05、身体、头发)全部带贴图,截图中心有角色;2026-09-23 离线通过。e.3 完成:实服冒烟 `MT_LIVE_*` 指向 192.168.21.203,`--live` 进游戏后主角与周围狼群带正确贴图、HUD 在上(246 条绘制、10 张贴图);修 `python_3d_surface.gd` 材质缓存键把 bool 交给 `%d`(格式化失败使所有绘制共用第一个材质即脸部贴图),测试加「每个贴图名一张贴图」断言;2026-09-23 离线与实服通过 |
|
| 2V2-e | Godot 显示主角(渲染 adapter),离线 + 真服 smoke:进游戏看到可辨认的静止角色 | 完成。e.1 完成:3D 绘制命令通道。`EterLib/StateManager.cpp`、`EterLib/GrpBase.cpp` 原样移植(platform 骨架删除,port-map 标 PORTED);`CGraphicDevice::Create` 建平台 `IDirect3DDevice8`(`platform/EterLib/RecordingDevice.cpp`:记住 transform/渲染状态/贴图阶段/材质/灯光/FVF/流/索引,按 FVF 解码顶点、展开 strip/fan,每次 Draw* 记成一条 `Render3DDraw`,`RenderCommands3D.h`),默认 IB/PDT VB 为 CPU 缓冲;`D3DXMath` 补 `PerspectiveFovRH`/`OrthoRH`/`OrthoOffCenterRH` 与 `ID3DXMatrixStack`。相机:`PythonApplicationCamera.cpp` 原样移植 `__UpdateCamera`/`SetCamera`/`SetCenterPosition`/`GetCenterPosition` 等(成员放文件内静态结构,平台没有 `CPythonApplication` 对象),`Process` 按 40250 顺序调 `__UpdateCamera`→`OnCameraUpdate`→`OnUIUpdate`→`Begin`/`SetInterfaceRenderState`/`OnUIRender`/`End`;`CScreen::Begin/End/Set*Operation`、`CPythonGraphic::Set{Game,Interface}RenderState/PushState/PopState/GetOrthoDepth`、`CCullingManager::Process` 的 view/proj 更新(视锥剔除等 SphereLib)原样;`pending` 补 `CPythonPlayer::NEW_GetMainActorPosition/NEW_GetMainActorPtr/NEW_FindActorPtr/SetMainCharacterIndex`(事件处理器留待 `PythonPlayerEventHandler`)、`CMapManager::IsMapReady`。`port.login_flow` 断言某帧有贴图、带法线/UV 的三角形绘制,投影为 `SetPerspective` 的 RH 透视(_34=-1),且顶点全部落在屏幕内(相机对准主角);离线与 `port.login_live`(192.168.21.203)2026-09-23 通过。e.2 完成:Godot 消费端 `project/python_3d_surface.gd`(`Metin2PythonHost.render3d_draws` 绑定):每条透视绘制成一个 `MeshInstance3D`,变换为 world·view(D3D 行向量矩阵的行即基列),`Camera3D` 在原点,fov/near/far 从 `SetPerspective` 投影反推;材质按贴图/混合/alpha test/剔除/光照状态缓存,贴图只从 40250 包取(`UiAssets.load_pack_tex`,包名保留 `d:` 盘符)。`CPythonBackground::GetFarClip` 原样移植(`m_pkMap` 为空 → 50000),`CMapManager` 构造原样(`m_pkMap=NULL`)。PORT 替身:地图未移植前没有环境数据(`LoadMap`→`SetEnvironmentData`→`SetCharacterDirLight`),D3D 会把受光网格画成全黑;「开光照、无灯、环境光与自发光为 0」的绘制按无光照贴图显示并计入 `unlit_stand_in_count`,随环境移植删除。`script/python_game_render_test.sh`(离线起 `port_fake_login_server`,`--live` 读 `MT_LIVE_*`)跑 `python_game_render_test.gd`:system.py 登录→选人→进游戏,4 条绘制(脸、Object05、身体、头发)全部带贴图,截图中心有角色;2026-09-23 离线通过。e.3 完成:实服冒烟 `MT_LIVE_*` 指向 192.168.21.203,`--live` 进游戏后主角与周围狼群带正确贴图、HUD 在上(246 条绘制、10 张贴图);修 `python_3d_surface.gd` 材质缓存键把 bool 交给 `%d`(格式化失败使所有绘制共用第一个材质即脸部贴图),测试加「每个贴图名一张贴图」断言;2026-09-23 离线与实服通过 |
|
||||||
|
2V2-e.4(2026-09-23):Python 3D 表面接上 40250 包内真实 A1 地图。`Metin2World` 的高度图、tile/splat、地表 DDS、`.msenv` 和 `AtlasInfo.txt` 经 `pack://` 读取;相机逆矩阵选当前 3×3 区块,地表随 D3D view 进入同一相机空间。角色材质不再使用 `unlit_stand_in_count` 的无光照替身,方向光取地图 `.msenv` 的 Character 参数,地形由同一环境的 Background 参数照明。离线截图能看到脚下石砖/沙地,9/9 区块带 splat,角色受光;`python_game_render_test.sh` 检查真实地图、光和地表像素差异。`CMapManager::LoadMap`、`CPythonBackground::SetCharacterDirLight` 的 native 链仍待逐单元移植;当前平台适配将地形一次性对齐到角色初始海拔(native `GetHeight` 尚为替身),所以不把这一步记作该 native 单元的 PORTED。地图内流式更新已接,跨图 warp 的 3D 场景替换留后续地图生命周期批次。
|
||||||
4. **2V3 — 本地移动**:加入 `PythonPlayer`、`PythonPlayerInput*`、`PythonPlayerEventHandler`、
|
4. **2V3 — 本地移动**:加入 `PythonPlayer`、`PythonPlayerInput*`、`PythonPlayerEventHandler`、
|
||||||
`InstanceBaseMovement`、`ActorInstance{Motion,Event,Position,Rotation,CollisionDetection}` 的实际依赖闭包。验收为输入经过新路径
|
`InstanceBaseMovement`、`ActorInstance{Motion,Event,Position,Rotation,CollisionDetection}` 的实际依赖闭包。验收为输入经过新路径
|
||||||
改变位置/朝向、发送与 40250 相同的移动封包,并在离线边界测试和真服 smoke 中通过。接通时删除对应 legacy 玩法逻辑。
|
改变位置/朝向、发送与 40250 相同的移动封包,并在离线边界测试和真服 smoke 中通过。接通时删除对应 legacy 玩法逻辑。
|
||||||
|
|||||||
@@ -165,9 +165,21 @@ Ref<ImageTexture> load_texture(const std::string &name, const fmt::AssetResolver
|
|||||||
std::copy(decoded.rgba.begin(), decoded.rgba.end(), pixels.ptrw());
|
std::copy(decoded.rgba.begin(), decoded.rgba.end(), pixels.ptrw());
|
||||||
image = godot::Image::create_from_data(decoded.w, decoded.h, false,
|
image = godot::Image::create_from_data(decoded.w, decoded.h, false,
|
||||||
godot::Image::FORMAT_RGBA8, pixels);
|
godot::Image::FORMAT_RGBA8, pixels);
|
||||||
|
} else {
|
||||||
|
// Image::load_from_file cannot open the pack:// virtual filesystem.
|
||||||
|
if (godot_path.begins_with("pack://")) {
|
||||||
|
PackedByteArray bytes = mtgodot::read_file(godot_path);
|
||||||
|
image.instantiate();
|
||||||
|
if (godot_path.get_extension().to_lower() == "tga")
|
||||||
|
image->load_tga_from_buffer(bytes);
|
||||||
|
else if (godot_path.get_extension().to_lower() == "png")
|
||||||
|
image->load_png_from_buffer(bytes);
|
||||||
|
else if (godot_path.get_extension().to_lower() == "jpg")
|
||||||
|
image->load_jpg_from_buffer(bytes);
|
||||||
} else {
|
} else {
|
||||||
image = godot::Image::load_from_file(godot_path);
|
image = godot::Image::load_from_file(godot_path);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (image.is_null() || image->is_empty())
|
if (image.is_null() || image->is_empty())
|
||||||
return Ref<ImageTexture>();
|
return Ref<ImageTexture>();
|
||||||
image->generate_mipmaps();
|
image->generate_mipmaps();
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ void Metin2World::_bind_methods() {
|
|||||||
ClassDB::bind_method(D_METHOD("set_load_radius_tiles", "r"), &Metin2World::set_load_radius_tiles);
|
ClassDB::bind_method(D_METHOD("set_load_radius_tiles", "r"), &Metin2World::set_load_radius_tiles);
|
||||||
ClassDB::bind_method(D_METHOD("get_load_radius_tiles"), &Metin2World::get_load_radius_tiles);
|
ClassDB::bind_method(D_METHOD("get_load_radius_tiles"), &Metin2World::get_load_radius_tiles);
|
||||||
ClassDB::bind_method(D_METHOD("set_focus_tile", "t"), &Metin2World::set_focus_tile);
|
ClassDB::bind_method(D_METHOD("set_focus_tile", "t"), &Metin2World::set_focus_tile);
|
||||||
|
ClassDB::bind_method(D_METHOD("set_initial_focus_cm", "position"), &Metin2World::set_initial_focus_cm);
|
||||||
ClassDB::bind_method(D_METHOD("get_focus_tile"), &Metin2World::get_focus_tile);
|
ClassDB::bind_method(D_METHOD("get_focus_tile"), &Metin2World::get_focus_tile);
|
||||||
ClassDB::bind_method(D_METHOD("set_auto_load", "v"), &Metin2World::set_auto_load);
|
ClassDB::bind_method(D_METHOD("set_auto_load", "v"), &Metin2World::set_auto_load);
|
||||||
ClassDB::bind_method(D_METHOD("get_auto_load"), &Metin2World::get_auto_load);
|
ClassDB::bind_method(D_METHOD("get_auto_load"), &Metin2World::get_auto_load);
|
||||||
@@ -290,6 +291,7 @@ void Metin2World::_bind_methods() {
|
|||||||
ClassDB::bind_method(D_METHOD("set_env_enabled", "v"), &Metin2World::set_env_enabled);
|
ClassDB::bind_method(D_METHOD("set_env_enabled", "v"), &Metin2World::set_env_enabled);
|
||||||
ClassDB::bind_method(D_METHOD("get_env_enabled"), &Metin2World::get_env_enabled);
|
ClassDB::bind_method(D_METHOD("get_env_enabled"), &Metin2World::get_env_enabled);
|
||||||
ClassDB::bind_method(D_METHOD("set_water_enabled", "v"), &Metin2World::set_water_enabled);
|
ClassDB::bind_method(D_METHOD("set_water_enabled", "v"), &Metin2World::set_water_enabled);
|
||||||
|
ClassDB::bind_method(D_METHOD("set_collision_enabled", "v"), &Metin2World::set_collision_enabled);
|
||||||
ClassDB::bind_method(D_METHOD("get_water_enabled"), &Metin2World::get_water_enabled);
|
ClassDB::bind_method(D_METHOD("get_water_enabled"), &Metin2World::get_water_enabled);
|
||||||
ClassDB::bind_method(D_METHOD("set_tree_shadows", "v"), &Metin2World::set_tree_shadows);
|
ClassDB::bind_method(D_METHOD("set_tree_shadows", "v"), &Metin2World::set_tree_shadows);
|
||||||
ClassDB::bind_method(D_METHOD("get_tree_shadows"), &Metin2World::get_tree_shadows);
|
ClassDB::bind_method(D_METHOD("get_tree_shadows"), &Metin2World::get_tree_shadows);
|
||||||
@@ -401,7 +403,7 @@ bool Metin2World::build_chunk(int tx, int ty) {
|
|||||||
if (!ss.layers.empty()) {
|
if (!ss.layers.empty()) {
|
||||||
String smpath;
|
String smpath;
|
||||||
String sm = String(dir.c_str()) + "/shadowmap.dds";
|
String sm = String(dir.c_str()) + "/shadowmap.dds";
|
||||||
if (FileAccess::file_exists(sm))
|
if (mtgodot::file_exists(sm))
|
||||||
smpath = sm;
|
smpath = sm;
|
||||||
Ref<ShaderMaterial> tm = build_chunk_terrain_material(
|
Ref<ShaderMaterial> tm = build_chunk_terrain_material(
|
||||||
ss, texture_set, *resolver, smpath);
|
ss, texture_set, *resolver, smpath);
|
||||||
@@ -766,6 +768,12 @@ bool Metin2World::load_map() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
setting_ok = true;
|
setting_ok = true;
|
||||||
|
if (load_radius >= 0 && initial_focus_cm != Vector2()) {
|
||||||
|
focus_tx = (int)std::floor((initial_focus_cm.x - setting.base_position_x) /
|
||||||
|
fmt::m2coord::CHUNK_CM);
|
||||||
|
focus_ty = (int)std::floor((initial_focus_cm.y - setting.base_position_y) /
|
||||||
|
fmt::m2coord::CHUNK_CM);
|
||||||
|
}
|
||||||
|
|
||||||
// AssetResolver 是地图对象、环境声属性和 splat 共用的资源索引。
|
// AssetResolver 是地图对象、环境声属性和 splat 共用的资源索引。
|
||||||
// 即使关闭 splat,也要保留这条链路,避免环境声依赖渲染开关。
|
// 即使关闭 splat,也要保留这条链路,避免环境声依赖渲染开关。
|
||||||
@@ -785,16 +793,23 @@ bool Metin2World::load_map() {
|
|||||||
c = '/';
|
c = '/';
|
||||||
c = (char)std::tolower((unsigned char)c);
|
c = (char)std::tolower((unsigned char)c);
|
||||||
}
|
}
|
||||||
|
// Setting.txt may already name the textureset/ directory. The pack
|
||||||
|
// virtual root ends in '/', while a loose asset root does not.
|
||||||
|
if (ts_rel.rfind("textureset/", 0) == 0)
|
||||||
|
ts_rel.erase(0, sizeof("textureset/") - 1);
|
||||||
std::string tse;
|
std::string tse;
|
||||||
if (fmt::parse_texture_set_file(root + "/textureset/" + ts_rel, texture_set, &tse))
|
const std::string texture_path = root == "pack://"
|
||||||
|
? root + "textureset/" + ts_rel : root + "/textureset/" + ts_rel;
|
||||||
|
if (fmt::parse_texture_set_file(texture_path, texture_set, &tse))
|
||||||
splat_ready = true;
|
splat_ready = true;
|
||||||
else
|
else
|
||||||
UtilityFunctions::push_warning(String("[Metin2World] TextureSet: ") + tse.c_str());
|
UtilityFunctions::push_warning(String("[Metin2World] TextureSet: ") + tse.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Property CRC 注册表(对象和环境声共用;不依赖 objects_enabled)。
|
// A 40250 pack is a virtual filesystem, not a directory to scan. The terrain
|
||||||
|
// and its textures resolve by pack path; property objects are a later port.
|
||||||
registry_ok = false;
|
registry_ok = false;
|
||||||
{
|
if (root != "pack://") {
|
||||||
// resolver 已建:用它的文件清单,避免再 std::filesystem 扫盘(PCK 里扫不了)。
|
// resolver 已建:用它的文件清单,避免再 std::filesystem 扫盘(PCK 里扫不了)。
|
||||||
bool ok = resolver
|
bool ok = resolver
|
||||||
? registry.scan_list(root, resolver->all_rel(), &re)
|
? registry.scan_list(root, resolver->all_rel(), &re)
|
||||||
@@ -1108,6 +1123,13 @@ Dictionary Metin2World::get_load_report() const {
|
|||||||
d["registry_ok"] = registry_ok;
|
d["registry_ok"] = registry_ok;
|
||||||
d["registry_crcs"] = (int)registry.by_crc.size();
|
d["registry_crcs"] = (int)registry.by_crc.size();
|
||||||
d["env_ok"] = env_ok;
|
d["env_ok"] = env_ok;
|
||||||
|
d["character_light_enabled"] = env.dir_light.ch_enable;
|
||||||
|
d["character_light_direction"] = Vector3(env.dir_light.direction[0],
|
||||||
|
env.dir_light.direction[1], env.dir_light.direction[2]);
|
||||||
|
d["character_light_diffuse"] = Color(env.dir_light.ch_diffuse[0],
|
||||||
|
env.dir_light.ch_diffuse[1], env.dir_light.ch_diffuse[2], env.dir_light.ch_diffuse[3]);
|
||||||
|
d["character_light_ambient"] = Color(env.dir_light.ch_ambient[0],
|
||||||
|
env.dir_light.ch_ambient[1], env.dir_light.ch_ambient[2], env.dir_light.ch_ambient[3]);
|
||||||
d["fog_level"] = env.fog.fog_level;
|
d["fog_level"] = env.fog.fog_level;
|
||||||
d["objects_placed"] = objects_placed;
|
d["objects_placed"] = objects_placed;
|
||||||
d["objects_skipped"] = objects_skipped;
|
d["objects_skipped"] = objects_skipped;
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ public:
|
|||||||
void set_load_radius_tiles(int r) { load_radius = r; }
|
void set_load_radius_tiles(int r) { load_radius = r; }
|
||||||
int get_load_radius_tiles() const { return load_radius; }
|
int get_load_radius_tiles() const { return load_radius; }
|
||||||
void set_focus_tile(godot::Vector2i t);
|
void set_focus_tile(godot::Vector2i t);
|
||||||
|
void set_initial_focus_cm(godot::Vector2 position) { initial_focus_cm = position; }
|
||||||
godot::Vector2i get_focus_tile() const { return godot::Vector2i(focus_tx, focus_ty); }
|
godot::Vector2i get_focus_tile() const { return godot::Vector2i(focus_tx, focus_ty); }
|
||||||
void set_auto_load(bool v) { auto_load = v; }
|
void set_auto_load(bool v) { auto_load = v; }
|
||||||
bool get_auto_load() const { return auto_load; }
|
bool get_auto_load() const { return auto_load; }
|
||||||
@@ -72,6 +73,7 @@ public:
|
|||||||
void set_env_enabled(bool v) { env_enabled = v; }
|
void set_env_enabled(bool v) { env_enabled = v; }
|
||||||
bool get_env_enabled() const { return env_enabled; }
|
bool get_env_enabled() const { return env_enabled; }
|
||||||
void set_water_enabled(bool v) { water_enabled = v; }
|
void set_water_enabled(bool v) { water_enabled = v; }
|
||||||
|
void set_collision_enabled(bool v) { collision_enabled = v; }
|
||||||
bool get_water_enabled() const { return water_enabled; }
|
bool get_water_enabled() const { return water_enabled; }
|
||||||
// §3.3: static building/tree shadows are already baked into shadowmap.dds, so
|
// §3.3: static building/tree shadows are already baked into shadowmap.dds, so
|
||||||
// they don't cast realtime sun shadows by default (avoids double-darkening).
|
// they don't cast realtime sun shadows by default (avoids double-darkening).
|
||||||
@@ -123,6 +125,7 @@ private:
|
|||||||
godot::String map_path = "OutdoorA1/metin2_map_a1";
|
godot::String map_path = "OutdoorA1/metin2_map_a1";
|
||||||
int load_radius = -1; // <0 = 全图
|
int load_radius = -1; // <0 = 全图
|
||||||
int focus_tx = 0, focus_ty = 0;
|
int focus_tx = 0, focus_ty = 0;
|
||||||
|
godot::Vector2 initial_focus_cm;
|
||||||
bool auto_load = true;
|
bool auto_load = true;
|
||||||
bool splat_enabled = true;
|
bool splat_enabled = true;
|
||||||
// §3.5: 每区块地形拆成 N×N patch(各自 MeshInstance),启用逐 patch 视锥剔除 +
|
// §3.5: 每区块地形拆成 N×N patch(各自 MeshInstance),启用逐 patch 视锥剔除 +
|
||||||
|
|||||||
@@ -144,6 +144,18 @@ bool AssetResolver::build(const std::string& root, const std::vector<std::string
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string AssetResolver::resolve(const std::string& vp, std::string* reason) const {
|
std::string AssetResolver::resolve(const std::string& vp, std::string* reason) const {
|
||||||
|
// The 40250 pack manager already resolves its own virtual paths and patch priority.
|
||||||
|
// Keep the drive prefix: CEterPackManager indexes "d:/ymir work/..." as such.
|
||||||
|
if (assets_root == "pack://") {
|
||||||
|
std::string name = lower(vp);
|
||||||
|
std::replace(name.begin(), name.end(), '\\', '/');
|
||||||
|
if (name.rfind("pack://", 0) == 0) name.erase(0, 7);
|
||||||
|
if (name.empty()) {
|
||||||
|
if (reason) *reason = "empty pack path";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return assets_root + name;
|
||||||
|
}
|
||||||
auto join = [this](const std::string& rel) {
|
auto join = [this](const std::string& rel) {
|
||||||
if (rel.empty()) return std::string();
|
if (rel.empty()) return std::string();
|
||||||
if (assets_root.empty()) return rel;
|
if (assets_root.empty()) return rel;
|
||||||
@@ -224,6 +236,10 @@ bool AssetResolver::load_index(const std::string& root, const std::string& text,
|
|||||||
|
|
||||||
bool AssetResolver::build_or_load(const std::string& root,
|
bool AssetResolver::build_or_load(const std::string& root,
|
||||||
const std::vector<std::string>& prio, std::string* err) {
|
const std::vector<std::string>& prio, std::string* err) {
|
||||||
|
if (root == "pack://") {
|
||||||
|
assets_root = root;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
std::string text;
|
std::string text;
|
||||||
if (read_file(root + "/asset_index.txt", text) && text.rfind("MTIDX1", 0) == 0) {
|
if (read_file(root + "/asset_index.txt", text) && text.rfind("MTIDX1", 0) == 0) {
|
||||||
return load_index(root, text, err);
|
return load_index(root, text, err);
|
||||||
|
|||||||
@@ -332,6 +332,11 @@ static void test_asset_resolver_norm() {
|
|||||||
CHECK(AssetResolver::normalize("D:/ymir work/env/./x/../y.tga") == "ymir work/env/y.tga",
|
CHECK(AssetResolver::normalize("D:/ymir work/env/./x/../y.tga") == "ymir work/env/y.tga",
|
||||||
"resolver normalize . and ..");
|
"resolver normalize . and ..");
|
||||||
CHECK(AssetResolver::normalize("/foo//bar") == "foo/bar", "resolver normalize // and lead /");
|
CHECK(AssetResolver::normalize("/foo//bar") == "foo/bar", "resolver normalize // and lead /");
|
||||||
|
AssetResolver pack;
|
||||||
|
std::string error;
|
||||||
|
CHECK(pack.build_or_load("pack://", {}, &error), "40250 virtual pack root needs no directory scan");
|
||||||
|
CHECK(pack.resolve("D:\\Ymir Work\\Environment\\A.msenv") ==
|
||||||
|
"pack://d:/ymir work/environment/a.msenv", "pack lookup preserves drive prefix");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void test_spt_tokens() {
|
static void test_spt_tokens() {
|
||||||
|
|||||||
+127
-18
@@ -8,10 +8,8 @@
|
|||||||
# at the origin with the fov/near/far read back from the projection. No axis conversion is involved.
|
# 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.
|
# 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),
|
# Terrain and lighting come from the same 40250 map pack as the actor textures. The map is placed
|
||||||
# so CPythonBackground::SetCharacterDirLight sets no light and D3DRS_AMBIENT stays 0: D3D would draw
|
# in the recorded D3D camera's coordinate frame, and its .msenv supplies the character light.
|
||||||
# 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
|
extends Node3D
|
||||||
|
|
||||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||||
@@ -26,14 +24,20 @@ const D3DBLEND_INVSRCALPHA := 6
|
|||||||
var camera: Camera3D
|
var camera: Camera3D
|
||||||
var light: DirectionalLight3D
|
var light: DirectionalLight3D
|
||||||
var environment: WorldEnvironment
|
var environment: WorldEnvironment
|
||||||
|
var terrain: Node3D
|
||||||
|
var terrain_report := {}
|
||||||
|
var map_path := "metin2_map_a1"
|
||||||
|
var _atlas: Array = []
|
||||||
|
var _terrain_attempted := false
|
||||||
|
var _last_terrain_focus := Vector2(INF, INF)
|
||||||
|
var _ground_offset_cm := NAN
|
||||||
var _meshes: Array[MeshInstance3D] = []
|
var _meshes: Array[MeshInstance3D] = []
|
||||||
# Materials keyed by texture and state, so an unchanged draw keeps its material from frame to frame.
|
# Materials keyed by texture and state, so an unchanged draw keeps its material from frame to frame.
|
||||||
var _materials := {}
|
var _materials := {}
|
||||||
|
|
||||||
# The draws the last update_frame() showed (for tests and reports).
|
# The draws the last update_frame() showed (for tests and reports).
|
||||||
var draw_count := 0
|
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 # Compatibility report field; real lighting keeps this at zero.
|
||||||
var unlit_stand_in_count := 0
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
camera = Camera3D.new()
|
camera = Camera3D.new()
|
||||||
@@ -46,6 +50,7 @@ func _ready() -> void:
|
|||||||
light = DirectionalLight3D.new()
|
light = DirectionalLight3D.new()
|
||||||
light.name = "Light0"
|
light.name = "Light0"
|
||||||
light.visible = false
|
light.visible = false
|
||||||
|
light.light_cull_mask = 1 << 1
|
||||||
add_child(light)
|
add_child(light)
|
||||||
environment = WorldEnvironment.new()
|
environment = WorldEnvironment.new()
|
||||||
environment.environment = Environment.new()
|
environment.environment = Environment.new()
|
||||||
@@ -80,7 +85,6 @@ static func multiply(a: PackedFloat32Array, b: PackedFloat32Array) -> PackedFloa
|
|||||||
func update_frame() -> void:
|
func update_frame() -> void:
|
||||||
var draws: Array = Metin2PythonHost.render3d_draws()
|
var draws: Array = Metin2PythonHost.render3d_draws()
|
||||||
var shown := 0
|
var shown := 0
|
||||||
var stand_ins := 0
|
|
||||||
var camera_set := false
|
var camera_set := false
|
||||||
var light_set := false
|
var light_set := false
|
||||||
for draw in draws:
|
for draw in draws:
|
||||||
@@ -95,11 +99,16 @@ func update_frame() -> void:
|
|||||||
_apply_projection(proj)
|
_apply_projection(proj)
|
||||||
camera_set = true
|
camera_set = true
|
||||||
var view: PackedFloat32Array = draw["view"]
|
var view: PackedFloat32Array = draw["view"]
|
||||||
|
if terrain == null and not _terrain_attempted:
|
||||||
|
_load_terrain(draw)
|
||||||
|
if terrain != null:
|
||||||
|
_place_terrain(view)
|
||||||
if not light_set and draw["light0"]:
|
if not light_set and draw["light0"]:
|
||||||
_apply_light(draw, view)
|
_apply_light(draw, view)
|
||||||
light_set = true
|
light_set = true
|
||||||
if _environment_missing(draw):
|
elif not light_set and not terrain_report.is_empty():
|
||||||
stand_ins += 1
|
_apply_map_light(view)
|
||||||
|
light_set = light.visible
|
||||||
var instance := _mesh_instance(shown)
|
var instance := _mesh_instance(shown)
|
||||||
instance.transform = d3d_transform(multiply(draw["world"], view))
|
instance.transform = d3d_transform(multiply(draw["world"], view))
|
||||||
instance.mesh = _build_mesh(draw)
|
instance.mesh = _build_mesh(draw)
|
||||||
@@ -111,7 +120,106 @@ func update_frame() -> void:
|
|||||||
if not light_set:
|
if not light_set:
|
||||||
light.visible = false
|
light.visible = false
|
||||||
draw_count = shown
|
draw_count = shown
|
||||||
unlit_stand_in_count = stand_ins
|
unlit_stand_in_count = 0
|
||||||
|
|
||||||
|
func _load_terrain(draw: Dictionary) -> void:
|
||||||
|
if not ClassDB.class_exists("Metin2World"):
|
||||||
|
return
|
||||||
|
var camera_world := d3d_transform(draw["view"]).affine_inverse().origin
|
||||||
|
var focus := Vector2(camera_world.x, -camera_world.y)
|
||||||
|
if focus.distance_to(_last_terrain_focus) < 1000.0:
|
||||||
|
return
|
||||||
|
_last_terrain_focus = focus
|
||||||
|
map_path = _map_for_position(focus)
|
||||||
|
terrain = ClassDB.instantiate("Metin2World")
|
||||||
|
terrain.name = "PackTerrain"
|
||||||
|
terrain.set("auto_load", false)
|
||||||
|
terrain.set("assets_root", "pack://")
|
||||||
|
terrain.set("map_path", map_path)
|
||||||
|
terrain.set("load_radius_tiles", 1)
|
||||||
|
terrain.set("objects_enabled", false)
|
||||||
|
terrain.set("water_enabled", false)
|
||||||
|
terrain.call("set_collision_enabled", false)
|
||||||
|
# A skinned mesh's world matrix can be local to a bone. The inverse view
|
||||||
|
# locates the camera beside the actor and selects the correct 3x3 map tiles.
|
||||||
|
terrain.call("set_initial_focus_cm", focus)
|
||||||
|
add_child(terrain)
|
||||||
|
if not terrain.call("load_map"):
|
||||||
|
terrain_report = terrain.call("get_load_report")
|
||||||
|
push_error("40250 terrain failed: %s" % terrain_report.get("last_error", ""))
|
||||||
|
terrain.queue_free()
|
||||||
|
terrain = null
|
||||||
|
return
|
||||||
|
terrain_report = terrain.call("get_load_report")
|
||||||
|
if int(terrain_report.get("chunks_built", 0)) == 0:
|
||||||
|
# Loading's first frame can still contain the previous camera. Retry when
|
||||||
|
# __UpdateCamera moves to the main actor's map position.
|
||||||
|
terrain.queue_free()
|
||||||
|
terrain = null
|
||||||
|
terrain_report = {}
|
||||||
|
return
|
||||||
|
_terrain_attempted = true
|
||||||
|
_ground_offset_cm = NAN
|
||||||
|
# The map's own character light uses legacy Godot coordinates; the recorded game pass
|
||||||
|
# needs one light in camera space, derived from the same .msenv values below.
|
||||||
|
var map_character_light := terrain.get_node_or_null("CharacterLight") as DirectionalLight3D
|
||||||
|
if map_character_light:
|
||||||
|
map_character_light.visible = false
|
||||||
|
# WorldEnv from .msenv replaces the black environment created before map load.
|
||||||
|
if terrain_report.get("env_ok", false):
|
||||||
|
environment.environment = null
|
||||||
|
|
||||||
|
func _map_for_position(global_cm: Vector2) -> String:
|
||||||
|
if _atlas.is_empty():
|
||||||
|
for path in ["locale/en/AtlasInfo.txt", "AtlasInfo.txt", "root/atlasinfo.txt"]:
|
||||||
|
var bytes: PackedByteArray = Metin2Pack.get_bytes(path)
|
||||||
|
if bytes.is_empty():
|
||||||
|
continue
|
||||||
|
for line in bytes.get_string_from_utf8().split("\n"):
|
||||||
|
var fields := line.strip_edges().replace("\t", " ").split(" ", false)
|
||||||
|
if fields.size() < 5:
|
||||||
|
continue
|
||||||
|
_atlas.append({"name": fields[0].to_lower(), "base": Vector2(float(fields[1]), float(fields[2])),
|
||||||
|
"size": Vector2(float(fields[3]), float(fields[4])) * 25600.0})
|
||||||
|
break
|
||||||
|
for entry in _atlas:
|
||||||
|
var local: Vector2 = global_cm - entry["base"]
|
||||||
|
if local.x >= 0.0 and local.y >= 0.0 and local.x < entry["size"].x \
|
||||||
|
and local.y < entry["size"].y and Metin2Pack.exists(entry["name"] + "/setting.txt"):
|
||||||
|
return entry["name"]
|
||||||
|
return map_path
|
||||||
|
|
||||||
|
func _place_terrain(view: PackedFloat32Array) -> void:
|
||||||
|
var base: Vector2 = terrain.call("get_map_base_cm")
|
||||||
|
var camera_world := d3d_transform(view).affine_inverse().origin
|
||||||
|
var local_x := (camera_world.x - base.x) / 100.0
|
||||||
|
var local_y := (-camera_world.y - base.y) / 100.0
|
||||||
|
terrain.call("set_focus_position", local_x, local_y)
|
||||||
|
# Until the native CMapManager::GetHeight path is ported, the actor enters at
|
||||||
|
# z=0. Align the map once to its actual sampled starting elevation.
|
||||||
|
if is_nan(_ground_offset_cm):
|
||||||
|
_ground_offset_cm = float(terrain.call("sample_height", local_x, local_y)) * 100.0
|
||||||
|
var from_map := Transform3D(Basis(Vector3(100, 0, 0), Vector3(0, 0, 100),
|
||||||
|
Vector3(0, -100, 0)), Vector3(base.x, -base.y, -_ground_offset_cm))
|
||||||
|
terrain.transform = d3d_transform(view) * from_map
|
||||||
|
|
||||||
|
func _apply_map_light(view: PackedFloat32Array) -> void:
|
||||||
|
if not terrain_report.get("env_ok", false) or not terrain_report.get("character_light_enabled", false):
|
||||||
|
light.visible = false
|
||||||
|
return
|
||||||
|
var direction: Vector3 = d3d_transform(view).basis * terrain_report["character_light_direction"]
|
||||||
|
if direction.length_squared() < 1e-8:
|
||||||
|
light.visible = false
|
||||||
|
return
|
||||||
|
light.visible = true
|
||||||
|
light.transform = Transform3D(Basis.looking_at(direction.normalized(),
|
||||||
|
Vector3.UP if absf(direction.normalized().y) < 0.99 else Vector3.RIGHT), Vector3.ZERO)
|
||||||
|
light.light_color = terrain_report["character_light_diffuse"]
|
||||||
|
light.light_energy = 1.0
|
||||||
|
var world_env := terrain.get_node_or_null("WorldEnv") as WorldEnvironment
|
||||||
|
if world_env and world_env.environment:
|
||||||
|
world_env.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||||
|
world_env.environment.ambient_light_color = terrain_report["character_light_ambient"]
|
||||||
|
|
||||||
func _apply_projection(proj: PackedFloat32Array) -> void:
|
func _apply_projection(proj: PackedFloat32Array) -> void:
|
||||||
# D3DXMatrixPerspectiveFovRH: _22 = cot(fovy / 2), _33 = zf / (zn - zf), _43 = zn * zf / (zn - zf).
|
# D3DXMatrixPerspectiveFovRH: _22 = cot(fovy / 2), _33 = zf / (zn - zf), _43 = zn * zf / (zn - zf).
|
||||||
@@ -135,20 +243,21 @@ func _apply_light(draw: Dictionary, view: PackedFloat32Array) -> void:
|
|||||||
# D3D lighting: ambient = D3DRS_AMBIENT + light ambient, both times the material ambient.
|
# D3D lighting: ambient = D3DRS_AMBIENT + light ambient, both times the material ambient.
|
||||||
var ambient: Color = draw["ambient"]
|
var ambient: Color = draw["ambient"]
|
||||||
var light_ambient: Color = draw["light0_ambient"]
|
var light_ambient: Color = draw["light0_ambient"]
|
||||||
environment.environment.ambient_light_color = Color(minf(ambient.r + light_ambient.r, 1.0),
|
var active_environment: Environment = environment.environment
|
||||||
|
if terrain:
|
||||||
|
var map_environment := terrain.get_node_or_null("WorldEnv") as WorldEnvironment
|
||||||
|
if map_environment:
|
||||||
|
active_environment = map_environment.environment
|
||||||
|
if active_environment:
|
||||||
|
active_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))
|
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:
|
func _mesh_instance(index: int) -> MeshInstance3D:
|
||||||
while _meshes.size() <= index:
|
while _meshes.size() <= index:
|
||||||
var instance := MeshInstance3D.new()
|
var instance := MeshInstance3D.new()
|
||||||
instance.name = "Draw%d" % _meshes.size()
|
instance.name = "Draw%d" % _meshes.size()
|
||||||
instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||||
|
instance.layers = 1 << 1
|
||||||
add_child(instance)
|
add_child(instance)
|
||||||
_meshes.append(instance)
|
_meshes.append(instance)
|
||||||
return _meshes[index]
|
return _meshes[index]
|
||||||
@@ -170,7 +279,7 @@ func _build_mesh(draw: Dictionary) -> ArrayMesh:
|
|||||||
return mesh
|
return mesh
|
||||||
|
|
||||||
func _material(draw: Dictionary) -> StandardMaterial3D:
|
func _material(draw: Dictionary) -> StandardMaterial3D:
|
||||||
var lit: bool = draw["lighting"] and not _environment_missing(draw)
|
var lit: bool = draw["lighting"]
|
||||||
# alpha_blend / alpha_test / lit are bools: %d needs them as ints.
|
# alpha_blend / alpha_test / lit are bools: %d needs them as ints.
|
||||||
var key := "%s|%d|%d|%d|%d|%d|%d|%d|%s|%s" % [draw["texture0"], int(draw["alpha_blend"]), draw["src_blend"],
|
var key := "%s|%d|%d|%d|%d|%d|%d|%d|%s|%s" % [draw["texture0"], int(draw["alpha_blend"]), draw["src_blend"],
|
||||||
draw["dest_blend"], int(draw["alpha_test"]), draw["alpha_ref"], draw["cull_mode"], int(lit),
|
draw["dest_blend"], int(draw["alpha_test"]), draw["alpha_ref"], draw["cull_mode"], int(lit),
|
||||||
|
|||||||
@@ -107,6 +107,24 @@ func run() -> void:
|
|||||||
await process_frame
|
await process_frame
|
||||||
report["draw_count"] = world.draw_count
|
report["draw_count"] = world.draw_count
|
||||||
report["unlit_stand_in_count"] = world.unlit_stand_in_count
|
report["unlit_stand_in_count"] = world.unlit_stand_in_count
|
||||||
|
report["terrain"] = world.terrain_report
|
||||||
|
report["atlas_entries"] = world._atlas.size()
|
||||||
|
_check(world._atlas.size() > 0, "40250 AtlasInfo chooses the current map")
|
||||||
|
if world.terrain:
|
||||||
|
report["terrain_base_cm"] = str(world.terrain.call("get_map_base_cm"))
|
||||||
|
var tile_bounds := []
|
||||||
|
for tile in world.terrain.find_children("Terrain*", "MeshInstance3D", true, false):
|
||||||
|
if tile_bounds.size() >= 4:
|
||||||
|
break
|
||||||
|
tile_bounds.append(str(tile.global_transform * tile.mesh.get_aabb()))
|
||||||
|
report["terrain_camera_bounds"] = tile_bounds
|
||||||
|
_check(world.terrain != null and int(world.terrain_report.get("chunks_built", 0)) > 0,
|
||||||
|
"40250 terrain height tiles loaded")
|
||||||
|
_check(int(world.terrain_report.get("chunks_splatted", 0)) > 0,
|
||||||
|
"40250 terrain textures applied")
|
||||||
|
_check(bool(world.terrain_report.get("env_ok", false)) and world.light.visible,
|
||||||
|
"map .msenv lights the character")
|
||||||
|
_check(world.unlit_stand_in_count == 0, "unlit stand-in removed")
|
||||||
report["camera_fov"] = world.camera.fov
|
report["camera_fov"] = world.camera.fov
|
||||||
report["camera_near"] = world.camera.near
|
report["camera_near"] = world.camera.near
|
||||||
report["camera_far"] = world.camera.far
|
report["camera_far"] = world.camera.far
|
||||||
@@ -138,6 +156,7 @@ func run() -> void:
|
|||||||
textures.append(draw["texture0"])
|
textures.append(draw["texture0"])
|
||||||
report["textures"] = textures
|
report["textures"] = textures
|
||||||
var surfaces_textured := 0
|
var surfaces_textured := 0
|
||||||
|
var lit_surfaces := 0
|
||||||
var distinct_textures := {}
|
var distinct_textures := {}
|
||||||
for instance in world.find_children("Draw*", "MeshInstance3D", false, false):
|
for instance in world.find_children("Draw*", "MeshInstance3D", false, false):
|
||||||
if instance.visible and instance.mesh:
|
if instance.visible and instance.mesh:
|
||||||
@@ -145,7 +164,10 @@ func run() -> void:
|
|||||||
if material and material.albedo_texture:
|
if material and material.albedo_texture:
|
||||||
surfaces_textured += 1
|
surfaces_textured += 1
|
||||||
distinct_textures[material.albedo_texture] = true
|
distinct_textures[material.albedo_texture] = true
|
||||||
|
if material and material.shading_mode == BaseMaterial3D.SHADING_MODE_PER_VERTEX:
|
||||||
|
lit_surfaces += 1
|
||||||
report["surfaces_textured"] = surfaces_textured
|
report["surfaces_textured"] = surfaces_textured
|
||||||
|
report["lit_surfaces"] = lit_surfaces
|
||||||
report["distinct_textures"] = distinct_textures.size()
|
report["distinct_textures"] = distinct_textures.size()
|
||||||
# Every texture name gets its own texture (face, body, hair are not one material).
|
# Every texture name gets its own texture (face, body, hair are not one material).
|
||||||
_check(distinct_textures.size() == textures.size(),
|
_check(distinct_textures.size() == textures.size(),
|
||||||
@@ -156,6 +178,7 @@ func run() -> void:
|
|||||||
bounds.append(str(instance.transform * instance.mesh.get_aabb()))
|
bounds.append(str(instance.transform * instance.mesh.get_aabb()))
|
||||||
report["camera_space_bounds"] = bounds
|
report["camera_space_bounds"] = bounds
|
||||||
_check(not drawn or surfaces_textured > 0, "character surfaces have their diffuse textures (%d)" % surfaces_textured)
|
_check(not drawn or surfaces_textured > 0, "character surfaces have their diffuse textures (%d)" % surfaces_textured)
|
||||||
|
_check(lit_surfaces > 0, "character surfaces use directional lighting (%d)" % lit_surfaces)
|
||||||
if DisplayServer.get_name() != "headless":
|
if DisplayServer.get_name() != "headless":
|
||||||
await RenderingServer.frame_post_draw
|
await RenderingServer.frame_post_draw
|
||||||
var image := root.get_texture().get_image()
|
var image := root.get_texture().get_image()
|
||||||
@@ -170,6 +193,15 @@ func run() -> void:
|
|||||||
lit += 1
|
lit += 1
|
||||||
report["centre_lit_samples"] = lit
|
report["centre_lit_samples"] = lit
|
||||||
_check(lit > 50, "character pixels in the middle of the screen (%d samples)" % lit)
|
_check(lit > 50, "character pixels in the middle of the screen (%d samples)" % lit)
|
||||||
|
var ground_low := 1.0
|
||||||
|
var ground_high := 0.0
|
||||||
|
for y in range(image.get_height() * 3 / 4, image.get_height() - 20, 17):
|
||||||
|
for x in range(30, image.get_width() - 30, 23):
|
||||||
|
var luminance := image.get_pixel(x, y).get_luminance()
|
||||||
|
ground_low = minf(ground_low, luminance)
|
||||||
|
ground_high = maxf(ground_high, luminance)
|
||||||
|
report["ground_luminance_range"] = ground_high - ground_low
|
||||||
|
_check(ground_high - ground_low > 0.08, "textured ground visible in screenshot")
|
||||||
# The 3D pass alone, for looking at the character without the UI.
|
# The 3D pass alone, for looking at the character without the UI.
|
||||||
ui.visible = false
|
ui.visible = false
|
||||||
await RenderingServer.frame_post_draw
|
await RenderingServer.frame_post_draw
|
||||||
|
|||||||
Reference in New Issue
Block a user