2V2-e.5b: feed native map height and light into game render

This commit is contained in:
shenlei
2026-09-23 22:10:29 +09:00
parent 702fef7b47
commit 5a1df57e69
13 changed files with 678 additions and 76 deletions
+2
View File
@@ -522,3 +522,5 @@
{"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."}
{"time":"2026-09-23T12:54:00Z","event":"port_round","unit":"PORT-PLAN 2V2-e.5: native map height and character light transition adapter","ported":[],"adapted":["CMapManager Warp-facing AtlasInfo/Setting.txt/height.raw/.msenv loader and 40250 triangle height interpolation","CPythonBackground native map selection/local coordinates/character and background SetLight","Python 3D surface consumes native map name/local frame, removes initial terrain elevation shift and rebuilds on map change","EterBase/Utils MIN/MAX/MINMAX and fMIN/fMAX/fMINMAX exact 40250 pure functions"],"divergent":["CMapOutdoor and placed-object height, attributes, fog, minimap and portal lifecycle remain unported; native map functions are marked ADAPTED/TODO rather than PORTED"],"needs_live":["MT_LIVE_* credentials absent; real-server terrain and cross-map warp screenshots pending"],"tests":["script/python_game_render_test.sh offline: PASS (4/4 native light0, finite view, native map, actor foot-ground delta 0.1 cm, textured ground screenshot)","extension/tests/port_utils_test.cpp: PASS","port_map.py check: 0 errors"],"todo":["full CMapOutdoor/MapManager/PythonBackground unit port","real-server and cross-map visual smoke","2V3 player/input/event/movement chain"]}
{"time":"2026-09-23T13:09:00Z","event":"test_run","id":"PORT-PLAN 2V2-e.5","result":"PASS","tests":["script/port_gate.sh macos: PASS (12 port tests; port.login_live skipped without MT_LIVE_*)","script/python_game_render_test.sh: PASS (4/4 native light0, finite camera matrix, 0.1 cm foot-ground difference, 559 terrain texture contrasts)","port_map.py check: 0 errors","git diff --check: PASS"],"reason":"The first sandboxed port gate run denied localhost sockets; loopback-enabled rerun passed. Full CMapOutdoor and live/cross-map evidence remain outstanding."}
+185
View File
@@ -0,0 +1,185 @@
{
"reference": "GameLib/MapManager.cpp",
"reference_sha256": "6e20512dcbe73aeb9d49300667ce460bbd21bf4697a75a0d96fa1b57e7c8a4ff",
"priority": "P4",
"contracts": [],
"functions": {
"CMapManager::IsMapOutdoor": {
"status": "TODO"
},
"CMapManager::GetMapOutdoorRef": {
"status": "TODO"
},
"CMapManager::CMapManager": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/GameLib/MapManager.cpp:CMapManager::CMapManager"],
"note": "Constructor matches 40250; kept in pending file until CMapOutdoor is ported.",
"test": "project/python_game_render_test.gd"
},
"CMapManager::~CMapManager": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/GameLib/MapManager.cpp:CMapManager::~CMapManager"],
"note": "Calls transition adapter Destroy, which frees pack-backed map state.",
"test": "project/python_game_render_test.gd"
},
"CMapManager::IsSoftwareTilingEnable": {
"status": "TODO"
},
"CMapManager::ReserveSoftwareTilingEnable": {
"status": "TODO"
},
"CMapManager::Initialize": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/GameLib/MapManager.cpp:CMapManager::Initialize"],
"note": "40250 AtlasInfo loading order; pack text parsed in transition adapter.",
"test": "project/python_game_render_test.gd"
},
"CMapManager::Create": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/GameLib/MapManager.cpp:CMapManager::Create"],
"note": "No CMapOutdoor allocation yet; Metin2World renders terrain.",
"test": "project/python_game_render_test.gd"
},
"CMapManager::Destroy": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/GameLib/MapManager.cpp:CMapManager::Destroy"],
"note": "Frees transition map and environment data; CMapOutdoor cleanup remains TODO.",
"test": "project/python_game_render_test.gd"
},
"CMapManager::Clear": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/GameLib/MapManager.cpp:CMapManager::Clear"],
"note": "Clears pack-backed terrain state, not CMapOutdoor.",
"test": "project/python_game_render_test.gd"
},
"CMapManager::AllocMap": {
"status": "TODO"
},
"CMapManager::LoadProperty": {
"status": "TODO"
},
"CMapManager::LoadMap": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/GameLib/MapManager.cpp:CMapManager::LoadMap"],
"note": "Loads Setting.txt, initial height tile and .msenv from 40250 packs for reachable Warp chain; outdoor scene lifecycle remains TODO.",
"test": "project/python_game_render_test.gd"
},
"CMapManager::IsMapReady": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/GameLib/MapManager.cpp:CMapManager::IsMapReady"],
"note": "Reports pack-backed transition map readiness instead of CMapOutdoor::IsReady.",
"test": "project/python_game_render_test.gd"
},
"CMapManager::UnloadMap": {
"status": "TODO"
},
"CMapManager::UpdateMap": {
"status": "TODO"
},
"CMapManager::UpdateAroundAmbience": {
"status": "TODO"
},
"CMapManager::GetHeight": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/GameLib/MapManager.cpp:CMapManager::GetHeight"],
"note": "Uses the 40250 terrain triangle interpolation on packed height.raw; placed-object height remains TODO.",
"test": "project/python_game_render_test.gd"
},
"CMapManager::GetTerrainHeight": {
"status": "TODO"
},
"CMapManager::GetWaterHeight": {
"status": "TODO"
},
"CMapManager::BeginEnvironment": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/GameLib/MapManager.cpp:CMapManager::BeginEnvironment"],
"note": "Applies .msenv material/background light through RecordingDevice; fog and full CMapOutdoor environment remain TODO.",
"test": "project/python_game_render_test.gd"
},
"CMapManager::EndEnvironment": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/GameLib/MapManager.cpp:CMapManager::EndEnvironment"],
"note": "Restores recorded lighting state; full environment state restoration remains TODO.",
"test": "project/python_game_render_test.gd"
},
"CMapManager::SetEnvironmentData": {
"status": "TODO"
},
"CMapManager::SetEnvironmentDataPtr": {
"status": "TODO"
},
"CMapManager::ResetEnvironmentDataPtr": {
"status": "TODO"
},
"CMapManager::BlendEnvironmentData": {
"status": "TODO"
},
"CMapManager::RegisterEnvironmentData": {
"status": "TODO"
},
"CMapManager::GetCurrentEnvironmentData": {
"status": "TODO"
},
"CMapManager::GetEnvironmentData": {
"status": "TODO"
},
"CMapManager::RefreshPortal": {
"status": "TODO"
},
"CMapManager::ClearPortal": {
"status": "TODO"
},
"CMapManager::AddShowingPortalID": {
"status": "TODO"
},
"CMapManager::AllocEnvironmentData": {
"status": "TODO"
},
"CMapManager::DeleteEnvironmentData": {
"status": "TODO"
},
"CMapManager::LoadEnvironmentData": {
"status": "TODO"
},
"CMapManager::GetShadowMapColor": {
"status": "TODO"
},
"CMapManager::GetRenderedSplatNum": {
"status": "TODO"
},
"CMapManager::GetRenderedGraphicThingInstanceNum": {
"status": "TODO"
},
"CMapManager::GetNormal": {
"status": "TODO"
},
"CMapManager::isPhysicalCollision": {
"status": "TODO"
},
"CMapManager::isAttrOn": {
"status": "TODO"
},
"CMapManager::GetAttr": {
"status": "TODO"
},
"CMapManager::SetTerrainRenderSort": {
"status": "TODO"
},
"CMapManager::SetTransparentTree": {
"status": "TODO"
},
"CMapManager::GetTerrainRenderSort": {
"status": "TODO"
},
"CMapManager::GetBaseXY": {
"status": "TODO"
},
"CMapManager::__LoadMapInfoVector": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/GameLib/MapManager.cpp:CMapManager::__LoadMapInfoVector"],
"note": "Reads packed AtlasInfo bounds with 40250 terrain tile size; parser is transitional.",
"test": "project/python_game_render_test.gd"
}
}
}
@@ -0,0 +1,209 @@
{
"reference": "UserInterface/PythonBackground.cpp",
"reference_sha256": "810ed94a182af71973ce3fa41f77173506db71f5c6bb1f3c6b56879bfcea4f0e",
"priority": "P4",
"contracts": [],
"functions": {
"CPythonBackground::GetRenderShadowTime": {
"status": "TODO"
},
"CPythonBackground::SetVisiblePart": {
"status": "TODO"
},
"CPythonBackground::EnableTerrainOnlyForHeight": {
"status": "TODO"
},
"CPythonBackground::SetSplatLimit": {
"status": "TODO"
},
"CPythonBackground::CreateCharacterShadowTexture": {
"status": "TODO"
},
"CPythonBackground::ReleaseCharacterShadowTexture": {
"status": "TODO"
},
"CPythonBackground::RefreshShadowLevel": {
"status": "TODO"
},
"CPythonBackground::SetShadowLevel": {
"status": "TODO"
},
"CPythonBackground::SelectViewDistanceNum": {
"status": "TODO"
},
"CPythonBackground::SetViewDistanceSet": {
"status": "TODO"
},
"CPythonBackground::GetFarClip": {
"status": "TODO"
},
"CPythonBackground::GetDistanceSetInfo": {
"status": "TODO"
},
"CPythonBackground::CPythonBackground": {
"status": "TODO"
},
"CPythonBackground::~CPythonBackground": {
"status": "TODO"
},
"CPythonBackground::Initialize": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/UserInterface/PythonBackground.cpp:CPythonBackground::Initialize"],
"note": "40250 locale AtlasInfo setup, backed by transitional MapManager loader.",
"test": "project/python_game_render_test.gd"
},
"CPythonBackground::__CreateProperty": {
"status": "TODO"
},
"CPythonBackground::GetPickingPoint": {
"status": "TODO"
},
"CPythonBackground::GetPickingPointWithRay": {
"status": "TODO"
},
"CPythonBackground::GetPickingPointWithRayOnlyTerrain": {
"status": "TODO"
},
"CPythonBackground::GetLightDirection": {
"status": "TODO"
},
"CPythonBackground::Destroy": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/UserInterface/PythonBackground.cpp:CPythonBackground::Destroy"],
"note": "Native map state cleanup; SnowEnvironment destruction remains TODO.",
"test": "project/python_game_render_test.gd"
},
"CPythonBackground::Create": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/UserInterface/PythonBackground.cpp:CPythonBackground::Create"],
"note": "Calls transition MapManager::Create; property and snow initialization remain TODO.",
"test": "project/python_game_render_test.gd"
},
"CPythonBackground::Update": {
"status": "TODO"
},
"CPythonBackground::__IsSame": {
"status": "TODO"
},
"CPythonBackground::Render": {
"status": "TODO"
},
"CPythonBackground::RenderSnow": {
"status": "TODO"
},
"CPythonBackground::RenderPCBlocker": {
"status": "TODO"
},
"CPythonBackground::RenderCollision": {
"status": "TODO"
},
"CPythonBackground::RenderCharacterShadowToTexture": {
"status": "TODO"
},
"Interpolate": {
"status": "TODO"
},
"CPythonBackground::CheckAdvancing": {
"status": "TODO"
},
"CPythonBackground::RenderSky": {
"status": "TODO"
},
"CPythonBackground::RenderCloud": {
"status": "TODO"
},
"CPythonBackground::RenderWater": {
"status": "TODO"
},
"CPythonBackground::RenderEffect": {
"status": "TODO"
},
"CPythonBackground::RenderBeforeLensFlare": {
"status": "TODO"
},
"CPythonBackground::RenderAfterLensFlare": {
"status": "TODO"
},
"CPythonBackground::ClearGuildArea": {
"status": "TODO"
},
"CPythonBackground::RegisterGuildArea": {
"status": "TODO"
},
"CPythonBackground::SetCharacterDirLight": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/UserInterface/PythonBackground.cpp:CPythonBackground::SetCharacterDirLight"],
"note": "Statement-equivalent 40250 function, kept in pending translation unit until the whole unit is ported; reachable RenderGame draws carry native light0.",
"test": "project/python_game_render_test.gd"
},
"CPythonBackground::SetBackgroundDirLight": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/UserInterface/PythonBackground.cpp:CPythonBackground::SetBackgroundDirLight"],
"note": "Statement-equivalent 40250 light setter, still in transitional pending translation unit.",
"test": "project/python_game_render_test.gd"
},
"CPythonBackground::GlobalPositionToLocalPosition": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/UserInterface/PythonBackground.cpp:CPythonBackground::GlobalPositionToLocalPosition"],
"note": "40250 base subtraction, kept in transitional pending translation unit.",
"test": "project/python_game_render_test.gd"
},
"CPythonBackground::LocalPositionToGlobalPosition": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/UserInterface/PythonBackground.cpp:CPythonBackground::LocalPositionToGlobalPosition"],
"note": "40250 base addition, kept in transitional pending translation unit.",
"test": "project/python_game_render_test.gd"
},
"CPythonBackground::RegisterDungeonMapName": {
"status": "TODO"
},
"CPythonBackground::GlobalPositionToMapInfo": {
"status": "TODO"
},
"CPythonBackground::Warp": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/UserInterface/PythonBackground.cpp:CPythonBackground::Warp"],
"note": "Native AtlasInfo map selection and local coordinates reach pack-backed map; minimap, dungeon portals, target effects and full CMapOutdoor lifecycle remain TODO.",
"test": "project/python_game_render_test.gd"
},
"CPythonBackground::VisibleGuildArea": {
"status": "TODO"
},
"CPythonBackground::DisableGuildArea": {
"status": "TODO"
},
"CPythonBackground::GetWarpMapName": {
"status": "ADAPTED",
"impl": ["extension/src/platform/pending/UserInterface/PythonBackground.cpp:CPythonBackground::GetWarpMapName"],
"note": "Returns current native Warp map name; still in pending translation unit.",
"test": "project/python_game_render_test.gd"
},
"CPythonBackground::ChangeToDay": {
"status": "TODO"
},
"CPythonBackground::ChangeToNight": {
"status": "TODO"
},
"CPythonBackground::EnableSnowEnvironment": {
"status": "TODO"
},
"CPythonBackground::DisableSnowEnvironment": {
"status": "TODO"
},
"CPythonBackground::SetXMaxTree": {
"status": "TODO"
},
"CPythonBackground::CreateTargetEffect": {
"status": "TODO"
},
"CPythonBackground::DeleteTargetEffect": {
"status": "TODO"
},
"CPythonBackground::CreateSpecialEffect": {
"status": "TODO"
},
"CPythonBackground::DeleteSpecialEffect": {
"status": "TODO"
}
}
}
+1
View File
@@ -425,6 +425,7 @@ extension/third_party/cpython-2.7.18/ # 静态库(2P 批次
| 2V2-d | `InstanceBase` + `ActorInstance` + `CGraphicThingInstance` adapter:`GC_CHARACTER_ADD` 创建主角实例(msm 模型 + 待机动作) | 完成。d.1:`ActorInstance*.cpp`(Attach/Battle/Blend/CollisionDetection/Event/Fly/Motion/MotionEvent/Position/Render/Rotation/Sync/WeaponTrace)、`InstanceBase*.cpp`(Battle/Effect/Event/Motion/Transform)、`GameUtil`、`FlyTarget`、`PhysicsObject`、`GameEventManager`、`WeaponTrace`、`MapUtil`、`lineintersect_utils` 原样移植,`pending/GameLib/{ActorInstance,FlyTarget,PhysicsObject,MapUtil}.cpp`、`pending/UserInterface/InstanceBase.cpp` 替身删除,`ActorInstance.cpp`/`InstanceBase.cpp` 随主角创建进入运行时(port-map 标 PORTED);`CGameEventManager` 与 `CPythonSystem`(最后一个成员)按 `CPythonApplication` 成员顺序在 `PythonBoot` 创建;`D3D8Types.h` 补全 `D3DTA`/`D3DTEXTUREOP`/`D3DCMPFUNC`/`D3DBLEND`/`D3DCULL`。假服 `CHARACTER_ADD` 的 `bType` 改为 `CHAR_TYPE_PC`(6,原 0 使 `RecvCharacterAppendPacket` 不记主角、`CHAR_ADDITIONAL_INFO` 报错)。2V2-c 的偶发 Loading 头 7 根因:`TPlayerSkill::tNextRead` 是 `time_t`,40250 用 `_USE_32BIT_TIME_T`,LP64 下 `TPacketGCSkillLevelNew` 变成 2551 字节(线上 1531),`CInputDB::PlayerLoad` 在 `PointsPacket` 后发的 `SKILL_LEVEL`(76)读多吞掉后续包;`Packet.h` 改 `LONG` 并 `static_assert` 1531,假服 Loading 阶段补发 `SKILL_LEVEL` 回归。`port.login_flow` 断言主角实例存在且种族 0、无 `TPacketGCCharacterAdditionalInfo` 报错;`port.login_live` 2026-09-23 对 192.168.21.203 连跑 8 次通过,剩余 SYSERR 只有 gr2 `NOT SUPPORT FILE`。d.2:Granny SDK 2.11 运行时在 libgr2 上实现(`platform/EterGrnLib/GrannyRuntime.cpp`,`shim/sdk/granny.h` 按 SDK 头声明所用类型与函数:文件/段、按类型定义的顶点转换含 half、材质贴图按 Usage 查找、网格绑定、变形器、控制器时钟/循环/缓入缓出、加权 SRT 采样与复合矩阵),`EterGrnLib/*.cpp`(LODController/Material/Mesh/Model/ModelInstance*/Motion/Thing/ThingInstance/Util)与 `EterLib/{GrpObjectInstance,GrpCollisionObject,CollisionData}.cpp` 原样移植,对应 platform 骨架与 pending 替身删除(port-map 标 PORTED);`Resource.cpp` 注册 gr2 工厂(mdatr 等 `CAttributeData` 资源化时再注册);VB/IB 为 CPU 缓冲(`platform/EterLib/CpuBuffer.h`),渲染 adapter 属 2V2-e。`app.UpdateGame`/`RenderGame`(40250 `PythonApplication.cpp:321/168`)接入,成员经单例访问(平台没有 `CPythonApplication` 对象),`CPythonGraphic`(含 `CCullingManager`)按成员顺序进 `GameSingletons`;`GetMousePosition` 读 host 光标。`port.login_flow` 断言主角 PART_MAIN 模型有网格、材质漫反射贴图已加载、WAIT 动作使骨骼矩阵逐帧变化;离线与 `port.login_live`(192.168.21.203)2026-09-23 通过 |
| 2V2-e | Godot 显示主角(渲染 adapter),离线 + 真服 smoke:进游戏看到可辨认的静止角色 | 完成。e.1 完成:3D 绘制命令通道。`EterLib/StateManager.cpp`、`EterLib/GrpBase.cpp` 原样移植(platform 骨架删除,port-map 标 PORTED);`CGraphicDevice::Create` 建平台 `IDirect3DDevice8`(`platform/EterLib/RecordingDevice.cpp`:记住 transform/渲染状态/贴图阶段/材质/灯光/FVF/流/索引,按 FVF 解码顶点、展开 strip/fan,每次 Draw* 记成一条 `Render3DDraw`,`RenderCommands3D.h`),默认 IB/PDT VB 为 CPU 缓冲;`D3DXMath` 补 `PerspectiveFovRH`/`OrthoRH`/`OrthoOffCenterRH` 与 `ID3DXMatrixStack`。相机:`PythonApplicationCamera.cpp` 原样移植 `__UpdateCamera`/`SetCamera`/`SetCenterPosition`/`GetCenterPosition` 等(成员放文件内静态结构,平台没有 `CPythonApplication` 对象),`Process` 按 40250 顺序调 `__UpdateCamera`→`OnCameraUpdate`→`OnUIUpdate`→`Begin`/`SetInterfaceRenderState`/`OnUIRender`/`End`;`CScreen::Begin/End/Set*Operation`、`CPythonGraphic::Set{Game,Interface}RenderState/PushState/PopState/GetOrthoDepth`、`CCullingManager::Process` 的 view/proj 更新(视锥剔除等 SphereLib)原样;`pending` 补 `CPythonPlayer::NEW_GetMainActorPosition/NEW_GetMainActorPtr/NEW_FindActorPtr/SetMainCharacterIndex`(事件处理器留待 `PythonPlayerEventHandler`)、`CMapManager::IsMapReady`。`port.login_flow` 断言某帧有贴图、带法线/UV 的三角形绘制,投影为 `SetPerspective` 的 RH 透视(_34=-1),且顶点全部落在屏幕内(相机对准主角);离线与 `port.login_live`(192.168.21.203)2026-09-23 通过。e.2 完成:Godot 消费端 `project/python_3d_surface.gd`(`Metin2PythonHost.render3d_draws` 绑定):每条透视绘制成一个 `MeshInstance3D`,变换为 world·view(D3D 行向量矩阵的行即基列),`Camera3D` 在原点,fov/near/far 从 `SetPerspective` 投影反推;材质按贴图/混合/alpha test/剔除/光照状态缓存,贴图只从 40250 包取(`UiAssets.load_pack_tex`,包名保留 `d:` 盘符)。`CPythonBackground::GetFarClip` 原样移植(`m_pkMap` 为空 → 50000),`CMapManager` 构造原样(`m_pkMap=NULL`)。PORT 替身:地图未移植前没有环境数据(`LoadMap`→`SetEnvironmentData`→`SetCharacterDirLight`),D3D 会把受光网格画成全黑;「开光照、无灯、环境光与自发光为 0」的绘制按无光照贴图显示并计入 `unlit_stand_in_count`,随环境移植删除。`script/python_game_render_test.sh`(离线起 `port_fake_login_server`,`--live` 读 `MT_LIVE_*`)跑 `python_game_render_test.gd`:system.py 登录→选人→进游戏,4 条绘制(脸、Object05、身体、头发)全部带贴图,截图中心有角色;2026-09-23 离线通过。e.3 完成:实服冒烟 `MT_LIVE_*` 指向 192.168.21.203,`--live` 进游戏后主角与周围狼群带正确贴图、HUD 在上(246 条绘制、10 张贴图);修 `python_3d_surface.gd` 材质缓存键把 bool 交给 `%d`(格式化失败使所有绘制共用第一个材质即脸部贴图),测试加「每个贴图名一张贴图」断言;2026-09-23 离线与实服通过 |
2V2-e.4(2026-09-23):Python 3D 表面接上 40250 包内真实 A1 地图。`Metin2World` 的高度图、tile/splat、地表 DDS、`.msenv` 和 `AtlasInfo.txt` 经 `pack://` 读取;相机逆矩阵选当前 3×3 区块,地表随 D3D view 进入同一相机空间。角色材质不再使用 `unlit_stand_in_count` 的无光照替身,方向光取地图 `.msenv` 的 Character 参数,地形由同一环境的 Background 参数照明。离线截图能看到脚下石砖/沙地,9/9 区块带 splat,角色受光;`python_game_render_test.sh` 检查真实地图、光和地表像素差异。`CMapManager::LoadMap`、`CPythonBackground::SetCharacterDirLight` 的 native 链仍待逐单元移植;当前平台适配将地形一次性对齐到角色初始海拔(native `GetHeight` 尚为替身),所以不把这一步记作该 native 单元的 PORTED。地图内流式更新已接,跨图 warp 的 3D 场景替换留后续地图生命周期批次。
2V2-e.5(2026-09-23):原生 `CPythonNetworkStream::Warp` → `CPythonBackground::Warp` → `CMapManager::LoadMap/GetHeight` 已在运行路径接上包内 AtlasInfo、Setting.txt、height.raw 和 `.msenv`。地形高度使用 40250 的三角插值;`RenderGame` 调 `SetCharacterDirLight` 后录制绘制的 light0 来自该环境。Godot 地表按 native map 名和地图本地坐标放置,移除初始海拔平移,换图时重建地表;离线角色脚底与地表高度差约 0.1 cm,4/4 绘制带 light0。地图后端仍是过渡适配:`CMapOutdoor`、放置物件高度/属性、雾与完整环境状态、迷你地图/传送门等未移植,port-map 保持 ADAPTED/TODO,不宣称整个 native 地图单元 PORTED。修正 `EterBase/Utils` 的 MIN/MAX/fMIN/fMAX 系列返回零桩:地图就绪后相机 `Update` 会调用 fMIN/fMAX,否则距离被压成 0、view 矩阵变 NaN。离线测试增加有限矩阵、native map 一致性、脚底高度和 light0 检查;实服凭据当前环境缺失,换图和实服截图尚待 live 验证。
4. **2V3 — 本地移动**:加入 `PythonPlayer`、`PythonPlayerInput*`、`PythonPlayerEventHandler`、
`InstanceBaseMovement`、`ActorInstance{Motion,Event,Position,Rotation,CollisionDetection}` 的实际依赖闭包。验收为输入经过新路径
改变位置/朝向、发送与 40250 相同的移动封包,并在离线边界测试和真服 smoke 中通过。接通时删除对应 legacy 玩法逻辑。
+2 -1
View File
@@ -10,7 +10,8 @@ if(NOT TARGET mtpython)
list(FILTER MT_PLATFORM_SOURCES EXCLUDE REGEX "/ScriptLib/")
endif()
add_library(port_platform STATIC ${MT_PLATFORM_SOURCES})
target_link_libraries(port_platform PUBLIC port_logic PRIVATE mt3p::freetype xrender::libgr2)
target_link_libraries(port_platform PUBLIC port_logic PRIVATE mt3p::freetype xrender::libgr2 xrender::formats)
target_include_directories(port_platform PRIVATE ${CMAKE_SOURCE_DIR}/formats)
foreach(src IN LISTS MT_PLATFORM_SOURCES)
mt_port_apply_defines(${src})
endforeach()
@@ -206,6 +206,12 @@ void init_2v0_gameplay_stubs();
namespace PythonBoot
{
std::string CurrentMapName()
{
if (!g_game_singletons)
return {};
return CPythonBackground::Instance().GetWarpMapName();
}
bool IsRunning()
{
@@ -36,6 +36,8 @@ bool IsRunning();
bool RunMainScript(const char* lpCmdLine, std::string* error);
bool IsAppLooping();
bool AppFrame();
// Native CPythonBackground map selection, in the local coordinate frame of RenderGame.
std::string CurrentMapName();
// Used by the platform CPythonApplication: Loop() calls AppLoopWait() before each Process() and stops
// when it returns false; Exit()/Abort() call AppRequestQuit() (40250: PostQuitMessage).
@@ -3,13 +3,81 @@
// (docs/PORT-PLAN.md).
#include "GameLib/StdAfx.h"
#include "GameLib/MapManager.h"
#include "EterPack/EterPackManager.h"
#include "EterBase/MappedFile.h"
#include "EterLib/StateManager.h"
#include "GameLib/MapUtil.h"
#include <map_setting.h>
#include <terrain_files.h>
#include <terrain_mesh.h>
#include <cmath>
#include <map>
#include <memory>
#include <sstream>
#include <string>
#include "../../PlatformStub.h"
auto CMapManager::GetHeight(float, float) -> float
namespace {
// PORT: The Godot terrain adapter owns drawing until CMapOutdoor is ported. This
// pack-backed state supplies its 40250 callers with the same height and .msenv
// data during that transition; it is not a CMapOutdoor port.
struct NativeMapState {
fmt::MapSetting setting;
std::string name;
std::map<std::pair<int, int>, fmt::HeightMap> heights;
bool ready = false;
};
std::map<const CMapManager *, NativeMapState> s_native_maps;
bool pack_bytes(const std::string &name, std::string &out)
{
MT_PLATFORM_STUB();
return mt_platform_stub_return<float>();
CMappedFile file;
LPCVOID data = nullptr;
if (!CEterPackManager::Instance().Get(file, name.c_str(), &data))
return false;
out.assign(static_cast<const char *>(data), file.Size());
return true;
}
bool load_height_tile(NativeMapState &state, int tx, int ty)
{
if (tx < 0 || ty < 0 || tx >= state.setting.map_size_x || ty >= state.setting.map_size_y)
return false;
const auto key = std::make_pair(tx, ty);
if (state.heights.find(key) != state.heights.end())
return true;
char tile[16];
snprintf(tile, sizeof(tile), "%03d%03d", tx, ty);
std::string bytes;
if (!pack_bytes(state.name + "/" + tile + "/height.raw", bytes) || bytes.size() != 131 * 131 * 2)
return false;
fmt::HeightMap map;
map.raw.resize(131 * 131);
for (size_t i = 0; i < map.raw.size(); ++i)
map.raw[i] = static_cast<unsigned char>(bytes[i * 2]) |
(static_cast<unsigned char>(bytes[i * 2 + 1]) << 8);
state.heights.emplace(key, std::move(map));
return true;
}
}
auto CMapManager::GetHeight(float fx, float fy) -> float
{
auto found = s_native_maps.find(this);
if (found == s_native_maps.end() || !found->second.ready)
return 0.0f;
NativeMapState &state = found->second;
const double x = fx, y = std::abs(double(fy));
if (x < 0 || y < 0)
return 0.0f;
const int tx = int(x / 25600.0), ty = int(y / 25600.0);
if (!load_height_tile(state, tx, ty))
return 0.0f;
const float height = float(fmt::terrain_height_at(state.heights.at({tx, ty}), x - tx * 25600.0,
y - ty * 25600.0, state.setting.height_scale));
return height;
}
CMapManager::CMapManager() : mc_pcurEnvironmentData(NULL)
@@ -22,12 +90,51 @@ CMapManager::CMapManager() : mc_pcurEnvironmentData(NULL)
CMapManager::~CMapManager()
{
MT_PLATFORM_STUB();
Destroy();
}
void CMapManager::Initialize()
{
mc_pcurEnvironmentData = NULL;
__LoadMapInfoVector();
}
void CMapManager::Create()
{
// PORT: CMapOutdoor is still rendered by Metin2World, not this native object.
}
void CMapManager::Destroy()
{
Clear();
for (auto &entry : m_EnvironmentDataMap)
delete entry.second;
m_EnvironmentDataMap.clear();
}
void CMapManager::__LoadMapInfoVector()
{
std::string bytes;
if (!pack_bytes(m_stAtlasInfoFileName, bytes) && !pack_bytes("AtlasInfo.txt", bytes))
return;
std::istringstream lines(bytes);
std::string line;
m_kVct_kMapInfo.clear();
while (std::getline(lines, line)) {
std::istringstream fields(line);
TMapInfo info{};
if (!(fields >> info.m_strName >> info.m_dwBaseX >> info.m_dwBaseY >> info.m_dwSizeX >> info.m_dwSizeY))
continue;
info.m_dwEndX = info.m_dwBaseX + info.m_dwSizeX * 25600;
info.m_dwEndY = info.m_dwBaseY + info.m_dwSizeY * 25600;
m_kVct_kMapInfo.push_back(info);
}
}
auto CMapManager::Clear() -> void
{
MT_PLATFORM_STUB();
s_native_maps.erase(this);
mc_pcurEnvironmentData = NULL;
}
auto CMapManager::AllocMap() -> CMapBase *
@@ -36,10 +143,37 @@ auto CMapManager::AllocMap() -> CMapBase *
return mt_platform_stub_return<CMapBase *>();
}
auto CMapManager::LoadMap(const std::string &, float, float, float) -> bool
auto CMapManager::LoadMap(const std::string &name, float x, float y, float) -> bool
{
MT_PLATFORM_STUB();
return mt_platform_stub_return<bool>();
NativeMapState next;
next.name = name;
std::string bytes, error;
if (!pack_bytes(name + "/setting.txt", bytes) || !fmt::parse_map_setting(bytes, next.setting, &error)) {
TraceError("native map setting failed: %s (%s)", name.c_str(), error.c_str());
return false;
}
if (!load_height_tile(next, int(x / 25600.0f), int(y / 25600.0f))) {
TraceError("native map height tile failed: %s (%d,%d)", name.c_str(), int(x / 25600.0f), int(y / 25600.0f));
return false;
}
for (auto &entry : m_EnvironmentDataMap)
delete entry.second;
m_EnvironmentDataMap.clear();
mc_pcurEnvironmentData = NULL;
if (!next.setting.environment.empty()) {
auto env = std::make_unique<TEnvironmentData>();
Environment_Init(*env);
const std::string env_path = "d:/ymir work/environment/" + next.setting.environment;
if (Environment_Load(*env, env_path.c_str())) {
m_EnvironmentDataMap[0] = env.release();
mc_pcurEnvironmentData = m_EnvironmentDataMap[0];
} else {
TraceError("native map environment failed: %s", env_path.c_str());
}
}
next.ready = true;
s_native_maps[this] = std::move(next);
return true;
}
auto CMapManager::isPhysicalCollision(const D3DXVECTOR3 &) -> bool
@@ -74,19 +208,23 @@ auto CMapManager::isAttrOn(int, int, BYTE) -> bool
auto CMapManager::BeginEnvironment() -> void
{
MT_PLATFORM_STUB();
if (!IsMapReady() || !mc_pcurEnvironmentData)
return;
STATEMANAGER.SaveRenderState(D3DRS_LIGHTING, TRUE);
STATEMANAGER.SetMaterial(&mc_pcurEnvironmentData->Material);
STATEMANAGER.SetLight(0, &mc_pcurEnvironmentData->DirLights[ENV_DIRLIGHT_BACKGROUND]);
// PORT: RecordingDevice stores SetLight but has no D3D LightEnable slot yet.
}
auto CMapManager::EndEnvironment() -> void
{
MT_PLATFORM_STUB();
if (IsMapReady() && mc_pcurEnvironmentData)
STATEMANAGER.RestoreRenderState(D3DRS_LIGHTING);
}
// 40250 MapManager.cpp:144 (CPythonApplication::OnCameraUpdate); m_pkMap stays null until LoadMap is ported.
// PORT: MapOutdoor is not allocated yet; report the pack-backed transition map's readiness.
bool CMapManager::IsMapReady()
{
if (!m_pkMap)
return false;
return m_pkMap->IsReady();
auto found = s_native_maps.find(this);
return found != s_native_maps.end() && found->second.ready;
}
@@ -3,27 +3,31 @@
// (docs/PORT-PLAN.md).
#include "UserInterface/StdAfx.h"
#include "UserInterface/PythonBackground.h"
#include "EterLib/StateManager.h"
#include "../../PlatformStub.h"
auto CPythonBackground::Destroy() -> void
{
MT_PLATFORM_STUB();
CMapManager::Destroy();
m_bVisibleGuildArea = FALSE;
}
auto CPythonBackground::Create() -> void
{
MT_PLATFORM_STUB();
CMapManager::Create();
}
auto CPythonBackground::GlobalPositionToLocalPosition(LONG &, LONG &) -> void
auto CPythonBackground::GlobalPositionToLocalPosition(LONG &x, LONG &y) -> void
{
MT_PLATFORM_STUB();
x -= m_dwBaseX;
y -= m_dwBaseY;
}
auto CPythonBackground::LocalPositionToGlobalPosition(LONG &, LONG &) -> void
auto CPythonBackground::LocalPositionToGlobalPosition(LONG &x, LONG &y) -> void
{
MT_PLATFORM_STUB();
x += m_dwBaseX;
y += m_dwBaseY;
}
auto CPythonBackground::RefreshShadowLevel() -> void
@@ -61,24 +65,41 @@ auto CPythonBackground::DeleteTargetEffect(DWORD) -> void
MT_PLATFORM_STUB();
}
auto CPythonBackground::Warp(DWORD, DWORD) -> void
auto CPythonBackground::Warp(DWORD x, DWORD y) -> void
{
MT_PLATFORM_STUB();
if (m_kVct_kMapInfo.empty())
Initialize();
auto found = std::find_if(m_kVct_kMapInfo.begin(), m_kVct_kMapInfo.end(),
[x, y](const TMapInfo &info) {
return x >= info.m_dwBaseX && x < info.m_dwEndX &&
y >= info.m_dwBaseY && y < info.m_dwEndY;
});
if (found == m_kVct_kMapInfo.end()) {
TraceError("NOT_FOUND_GLOBAL_POSITION(%u, %u)", x, y);
return;
}
if (!LoadMap(found->m_strName, float(x - found->m_dwBaseX),
float(y - found->m_dwBaseY), 0.0f)) {
TraceError("CPythonBackground::Warp map load failed: %s", found->m_strName.c_str());
return;
}
m_dwBaseX = found->m_dwBaseX;
m_dwBaseY = found->m_dwBaseY;
m_strMapName = found->m_strName;
}
auto CPythonBackground::GetWarpMapName() -> const char *
{
MT_PLATFORM_STUB();
// PORT: Keep the not-yet-ported map lookup safe for GamePhase's ShowMapName call.
return "";
return m_strMapName.c_str();
}
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;
m_dwBaseX=0;
m_dwBaseY=0;
m_bVisibleGuildArea=FALSE;
m_iXMasTreeGrade=0;
}
CPythonBackground::~CPythonBackground()
@@ -166,10 +187,22 @@ auto CPythonBackground::RenderWater() -> void
auto CPythonBackground::SetBackgroundDirLight() -> void
{
MT_PLATFORM_STUB();
if (!IsMapReady() || !mc_pcurEnvironmentData)
return;
STATEMANAGER.SetLight(0, &mc_pcurEnvironmentData->DirLights[ENV_DIRLIGHT_BACKGROUND]);
}
auto CPythonBackground::SetCharacterDirLight() -> void
{
MT_PLATFORM_STUB();
if (!IsMapReady() || !mc_pcurEnvironmentData)
return;
STATEMANAGER.SetLight(0, &mc_pcurEnvironmentData->DirLights[ENV_DIRLIGHT_CHARACTER]);
}
void CPythonBackground::Initialize()
{
std::string atlas(LocaleService_GetLocalePath());
atlas += "/AtlasInfo.txt";
SetAtlasInfoFileName(atlas.c_str());
CMapManager::Initialize();
}
+6
View File
@@ -224,6 +224,10 @@ Array Metin2PythonHost::render3d_draws() {
return out;
}
String Metin2PythonHost::current_map_name() {
return String::utf8(PythonBoot::CurrentMapName().c_str());
}
void Metin2PythonHost::stop() {
PythonBoot::Stop();
}
@@ -257,6 +261,7 @@ 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(); }
String Metin2PythonHost::current_map_name() { return String(); }
void Metin2PythonHost::stop() {
}
@@ -280,6 +285,7 @@ void Metin2PythonHost::_bind_methods() {
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("current_map_name"), &Metin2PythonHost::current_map_name);
ClassDB::bind_static_method("Metin2PythonHost", D_METHOD("stop"), &Metin2PythonHost::stop);
}
+1
View File
@@ -47,6 +47,7 @@ public:
// "view", "proj" (PackedFloat32Array, D3D row-vector layout), vertex arrays, "indices", "texture0"
// and the blend/depth/cull/lighting state.
static godot::Array render3d_draws();
static godot::String current_map_name();
static void stop();
protected:
+15 -33
View File
@@ -30,7 +30,6 @@ 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] = []
# Materials keyed by texture and state, so an unchanged draw keeps its material from frame to frame.
var _materials := {}
@@ -84,6 +83,13 @@ static func multiply(a: PackedFloat32Array, b: PackedFloat32Array) -> PackedFloa
func update_frame() -> void:
var draws: Array = Metin2PythonHost.render3d_draws()
var native_map: String = Metin2PythonHost.current_map_name()
if terrain != null and not native_map.is_empty() and native_map != map_path:
terrain.queue_free()
terrain = null
terrain_report = {}
_terrain_attempted = false
_last_terrain_focus = Vector2(INF, INF)
var shown := 0
var camera_set := false
var light_set := false
@@ -106,9 +112,6 @@ func update_frame() -> void:
if not light_set and draw["light0"]:
_apply_light(draw, view)
light_set = true
elif not light_set and not terrain_report.is_empty():
_apply_map_light(view)
light_set = light.visible
var instance := _mesh_instance(shown)
instance.transform = d3d_transform(multiply(draw["world"], view))
instance.mesh = _build_mesh(draw)
@@ -130,7 +133,8 @@ func _load_terrain(draw: Dictionary) -> void:
if focus.distance_to(_last_terrain_focus) < 1000.0:
return
_last_terrain_focus = focus
map_path = _map_for_position(focus)
var native_map: String = Metin2PythonHost.current_map_name()
map_path = native_map if not native_map.is_empty() else _map_for_position(focus)
terrain = ClassDB.instantiate("Metin2World")
terrain.name = "PackTerrain"
terrain.set("auto_load", false)
@@ -142,7 +146,9 @@ func _load_terrain(draw: Dictionary) -> void:
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)
# Native RenderGame coordinates are map-local; Metin2World's initial_focus_cm
# expects global AtlasInfo coordinates, so set the local tile directly.
terrain.call("set_focus_tile", Vector2i(int(floor(focus.x / 25600.0)), int(floor(focus.y / 25600.0))))
add_child(terrain)
if not terrain.call("load_map"):
terrain_report = terrain.call("get_load_report")
@@ -159,7 +165,6 @@ func _load_terrain(draw: Dictionary) -> void:
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
@@ -190,37 +195,14 @@ func _map_for_position(global_cm: Vector2) -> String:
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
var local_x := camera_world.x / 100.0
var local_y := -camera_world.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))
Vector3(0, -100, 0)), Vector3.ZERO)
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:
# 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]))
+47 -11
View File
@@ -109,9 +109,17 @@ func run() -> void:
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")
_check(not Metin2PythonHost.current_map_name().is_empty(), "native AtlasInfo chooses the current map")
if world.terrain:
report["terrain_base_cm"] = str(world.terrain.call("get_map_base_cm"))
report["native_map_name"] = Metin2PythonHost.current_map_name()
_check(world.map_path == Metin2PythonHost.current_map_name(), "terrain uses native Warp map")
var first_draws: Array = Metin2PythonHost.render3d_draws()
if not first_draws.is_empty():
var camera_world: Vector3 = world.d3d_transform(first_draws[0]["view"]).affine_inverse().origin
report["camera_world_cm"] = str(camera_world)
report["terrain_height_at_camera_cm"] = float(world.terrain.call("sample_height",
camera_world.x / 100.0, -camera_world.y / 100.0)) * 100.0
var tile_bounds := []
for tile in world.terrain.find_children("Terrain*", "MeshInstance3D", true, false):
if tile_bounds.size() >= 4:
@@ -130,6 +138,33 @@ func run() -> void:
report["camera_far"] = world.camera.far
var textures := []
var draws: Array = Metin2PythonHost.render3d_draws()
var native_lit_draws := 0
var view_finite := true
var actor_foot_cm := INF
var foot_xy := Vector2.ZERO
for draw in draws:
if draw["light0"]:
native_lit_draws += 1
for value in draw["view"]:
if not is_finite(value):
view_finite = false
var world_xform: Transform3D = world.d3d_transform(draw["world"])
for vertex in draw["positions"]:
var p: Vector3 = world_xform * vertex
if p.z < actor_foot_cm:
actor_foot_cm = p.z
foot_xy = Vector2(p.x, -p.y)
report["native_lit_draws"] = native_lit_draws
report["view_finite"] = view_finite
_check(view_finite, "native camera view stays finite")
if world.terrain and not is_inf(actor_foot_cm):
var ground_cm := float(world.terrain.call("sample_height", foot_xy.x / 100.0, foot_xy.y / 100.0)) * 100.0
report["actor_foot_cm"] = actor_foot_cm
report["ground_at_foot_cm"] = ground_cm
_check(absf(actor_foot_cm - ground_cm) < 120.0,
"native actor feet meet map height (delta %.1f cm)" % absf(actor_foot_cm - ground_cm))
_check(not drawn or native_lit_draws == draws.size(),
"native RenderGame light0 comes from map .msenv (%d/%d)" % [native_lit_draws, draws.size()])
if not draws.is_empty():
report["proj"] = Array(draws[0]["proj"])
report["view"] = Array(draws[0]["view"])
@@ -193,18 +228,19 @@ func run() -> void:
lit += 1
report["centre_lit_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.
ui.visible = false
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png(output.path_join("game_3d.png"))
var game_image := root.get_texture().get_image()
game_image.save_png(output.path_join("game_3d.png"))
var textured_pairs := 0
for y in range(game_image.get_height() * 3 / 4, game_image.get_height() - 20, 13):
for x in range(30, game_image.get_width() - 35, 17):
var left := game_image.get_pixel(x, y).get_luminance()
var right := game_image.get_pixel(x + 5, y).get_luminance()
if absf(left - right) > 0.04:
textured_pairs += 1
report["ground_texture_pairs"] = textured_pairs
_check(textured_pairs > 20, "textured ground visible in screenshot (%d local contrasts)" % textured_pairs)
ui.visible = true
await _finish(output, report)