From 5a1df57e69a919b74f1af3cd50a8653a8f943cbe Mon Sep 17 00:00:00 2001 From: shenlei Date: Wed, 23 Sep 2026 22:10:29 +0900 Subject: [PATCH] 2V2-e.5b: feed native map height and light into game render --- audit/history.jsonl | 2 + audit/port-map/GameLib/MapManager.cpp.json | 185 ++++++++++++++++ .../UserInterface/PythonBackground.cpp.json | 209 ++++++++++++++++++ docs/PORT-PLAN.md | 1 + extension/src/platform/CMakeLists.txt | 3 +- .../src/platform/ScriptLib/PythonBoot.cpp | 6 + extension/src/platform/ScriptLib/PythonBoot.h | 2 + .../platform/pending/GameLib/MapManager.cpp | 168 ++++++++++++-- .../UserInterface/PythonBackground.cpp | 65 ++++-- extension/src/python_host_node.cpp | 6 + extension/src/python_host_node.h | 1 + project/python_3d_surface.gd | 48 ++-- project/python_game_render_test.gd | 58 ++++- 13 files changed, 678 insertions(+), 76 deletions(-) create mode 100644 audit/port-map/GameLib/MapManager.cpp.json create mode 100644 audit/port-map/UserInterface/PythonBackground.cpp.json diff --git a/audit/history.jsonl b/audit/history.jsonl index 377c4bb5..6e59da17 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -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."} diff --git a/audit/port-map/GameLib/MapManager.cpp.json b/audit/port-map/GameLib/MapManager.cpp.json new file mode 100644 index 00000000..5abd971c --- /dev/null +++ b/audit/port-map/GameLib/MapManager.cpp.json @@ -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" + } + } +} diff --git a/audit/port-map/UserInterface/PythonBackground.cpp.json b/audit/port-map/UserInterface/PythonBackground.cpp.json new file mode 100644 index 00000000..acd3723a --- /dev/null +++ b/audit/port-map/UserInterface/PythonBackground.cpp.json @@ -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" + } + } +} diff --git a/docs/PORT-PLAN.md b/docs/PORT-PLAN.md index 69a89252..80b6f322 100644 --- a/docs/PORT-PLAN.md +++ b/docs/PORT-PLAN.md @@ -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 玩法逻辑。 diff --git a/extension/src/platform/CMakeLists.txt b/extension/src/platform/CMakeLists.txt index 72241ba9..53c1d636 100644 --- a/extension/src/platform/CMakeLists.txt +++ b/extension/src/platform/CMakeLists.txt @@ -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() diff --git a/extension/src/platform/ScriptLib/PythonBoot.cpp b/extension/src/platform/ScriptLib/PythonBoot.cpp index 882fa82c..bff49da6 100644 --- a/extension/src/platform/ScriptLib/PythonBoot.cpp +++ b/extension/src/platform/ScriptLib/PythonBoot.cpp @@ -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() { diff --git a/extension/src/platform/ScriptLib/PythonBoot.h b/extension/src/platform/ScriptLib/PythonBoot.h index 9824f9e6..e2317846 100644 --- a/extension/src/platform/ScriptLib/PythonBoot.h +++ b/extension/src/platform/ScriptLib/PythonBoot.h @@ -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). diff --git a/extension/src/platform/pending/GameLib/MapManager.cpp b/extension/src/platform/pending/GameLib/MapManager.cpp index bbf9be52..7fc77bfb 100644 --- a/extension/src/platform/pending/GameLib/MapManager.cpp +++ b/extension/src/platform/pending/GameLib/MapManager.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include #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, fmt::HeightMap> heights; + bool ready = false; +}; +std::map s_native_maps; + +bool pack_bytes(const std::string &name, std::string &out) { - MT_PLATFORM_STUB(); - return mt_platform_stub_return(); + CMappedFile file; + LPCVOID data = nullptr; + if (!CEterPackManager::Instance().Get(file, name.c_str(), &data)) + return false; + out.assign(static_cast(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(bytes[i * 2]) | + (static_cast(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(); } -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(); + 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(); + 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; } diff --git a/extension/src/platform/pending/UserInterface/PythonBackground.cpp b/extension/src/platform/pending/UserInterface/PythonBackground.cpp index e5c544fc..9ae07ff7 100644 --- a/extension/src/platform/pending/UserInterface/PythonBackground.cpp +++ b/extension/src/platform/pending/UserInterface/PythonBackground.cpp @@ -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(); } diff --git a/extension/src/python_host_node.cpp b/extension/src/python_host_node.cpp index 485930fe..847d22a4 100644 --- a/extension/src/python_host_node.cpp +++ b/extension/src/python_host_node.cpp @@ -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 Metin2PythonHost::memory_texture(const String &, int64_t) { return Ref(); } 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); } diff --git a/extension/src/python_host_node.h b/extension/src/python_host_node.h index 63ade742..b696e5b3 100644 --- a/extension/src/python_host_node.h +++ b/extension/src/python_host_node.h @@ -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: diff --git a/project/python_3d_surface.gd b/project/python_3d_surface.gd index c9db087b..d0335382 100644 --- a/project/python_3d_surface.gd +++ b/project/python_3d_surface.gd @@ -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])) diff --git a/project/python_game_render_test.gd b/project/python_game_render_test.gd index b2e81ef9..30c6eab9 100644 --- a/project/python_game_render_test.gd +++ b/project/python_game_render_test.gd @@ -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)