diff --git a/audit/history.jsonl b/audit/history.jsonl index 4fa238ca..377c4bb5 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -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: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-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."} diff --git a/docs/PORT-PLAN.md b/docs/PORT-PLAN.md index 342e9d54..69a89252 100644 --- a/docs/PORT-PLAN.md +++ b/docs/PORT-PLAN.md @@ -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-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 场景替换留后续地图生命周期批次。 4. **2V3 — 本地移动**:加入 `PythonPlayer`、`PythonPlayerInput*`、`PythonPlayerEventHandler`、 `InstanceBaseMovement`、`ActorInstance{Motion,Event,Position,Rotation,CollisionDetection}` 的实际依赖闭包。验收为输入经过新路径 改变位置/朝向、发送与 40250 相同的移动封包,并在离线边界测试和真服 smoke 中通过。接通时删除对应 legacy 玩法逻辑。 diff --git a/extension/src/environment_builder.cpp b/extension/src/environment_builder.cpp index 9865ee6a..1463285e 100644 --- a/extension/src/environment_builder.cpp +++ b/extension/src/environment_builder.cpp @@ -166,7 +166,19 @@ Ref load_texture(const std::string &name, const fmt::AssetResolver image = godot::Image::create_from_data(decoded.w, decoded.h, false, godot::Image::FORMAT_RGBA8, pixels); } else { - image = godot::Image::load_from_file(godot_path); + // 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 { + image = godot::Image::load_from_file(godot_path); + } } if (image.is_null() || image->is_empty()) return Ref(); diff --git a/extension/src/metin2_world.cpp b/extension/src/metin2_world.cpp index f14fede9..38b8fae4 100644 --- a/extension/src/metin2_world.cpp +++ b/extension/src/metin2_world.cpp @@ -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("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_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("set_auto_load", "v"), &Metin2World::set_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("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_collision_enabled", "v"), &Metin2World::set_collision_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("get_tree_shadows"), &Metin2World::get_tree_shadows); @@ -401,7 +403,7 @@ bool Metin2World::build_chunk(int tx, int ty) { if (!ss.layers.empty()) { String smpath; String sm = String(dir.c_str()) + "/shadowmap.dds"; - if (FileAccess::file_exists(sm)) + if (mtgodot::file_exists(sm)) smpath = sm; Ref tm = build_chunk_terrain_material( ss, texture_set, *resolver, smpath); @@ -766,6 +768,12 @@ bool Metin2World::load_map() { return false; } 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 共用的资源索引。 // 即使关闭 splat,也要保留这条链路,避免环境声依赖渲染开关。 @@ -785,16 +793,23 @@ bool Metin2World::load_map() { 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; - 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; else 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; - { + if (root != "pack://") { // resolver 已建:用它的文件清单,避免再 std::filesystem 扫盘(PCK 里扫不了)。 bool ok = resolver ? 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_crcs"] = (int)registry.by_crc.size(); 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["objects_placed"] = objects_placed; d["objects_skipped"] = objects_skipped; diff --git a/extension/src/metin2_world.h b/extension/src/metin2_world.h index c7e4773a..24691738 100644 --- a/extension/src/metin2_world.h +++ b/extension/src/metin2_world.h @@ -60,6 +60,7 @@ public: void set_load_radius_tiles(int r) { load_radius = r; } int get_load_radius_tiles() const { return load_radius; } 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); } void set_auto_load(bool v) { auto_load = v; } bool get_auto_load() const { return auto_load; } @@ -72,6 +73,7 @@ public: void set_env_enabled(bool v) { env_enabled = v; } bool get_env_enabled() const { return env_enabled; } 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; } // §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). @@ -123,6 +125,7 @@ private: godot::String map_path = "OutdoorA1/metin2_map_a1"; int load_radius = -1; // <0 = 全图 int focus_tx = 0, focus_ty = 0; + godot::Vector2 initial_focus_cm; bool auto_load = true; bool splat_enabled = true; // §3.5: 每区块地形拆成 N×N patch(各自 MeshInstance),启用逐 patch 视锥剔除 + diff --git a/formats/asset_resolver.cpp b/formats/asset_resolver.cpp index f7a7ec26..f53c7a3a 100644 --- a/formats/asset_resolver.cpp +++ b/formats/asset_resolver.cpp @@ -144,6 +144,18 @@ bool AssetResolver::build(const std::string& root, const std::vector& prio, std::string* err) { + if (root == "pack://") { + assets_root = root; + return true; + } std::string text; if (read_file(root + "/asset_index.txt", text) && text.rfind("MTIDX1", 0) == 0) { return load_index(root, text, err); diff --git a/formats/tests/map_formats_test.cpp b/formats/tests/map_formats_test.cpp index fc4bc745..7697b3d5 100644 --- a/formats/tests/map_formats_test.cpp +++ b/formats/tests/map_formats_test.cpp @@ -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", "resolver normalize . and .."); 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() { diff --git a/project/python_3d_surface.gd b/project/python_3d_surface.gd index ac3fb9cc..c9db087b 100644 --- a/project/python_3d_surface.gd +++ b/project/python_3d_surface.gd @@ -8,10 +8,8 @@ # at the origin with the fov/near/far read back from the projection. No axis conversion is involved. # D3D's default front face (clockwise on screen, D3DCULL_CCW culls the rest) is also Godot's. # -# PORT: until the map is ported, CMapManager has no environment data (LoadMap → SetEnvironmentData), -# so CPythonBackground::SetCharacterDirLight sets no light and D3DRS_AMBIENT stays 0: D3D would draw -# every lit mesh black. Such draws (lighting on, no light, no ambient, no emissive) are shown unshaded, -# texture only, and counted in unlit_stand_in_count. Delete this with the environment port. +# Terrain and lighting come from the same 40250 map pack as the actor textures. The map is placed +# in the recorded D3D camera's coordinate frame, and its .msenv supplies the character light. extends Node3D const UiAssets = preload("res://ui/ui_assets.gd") @@ -26,14 +24,20 @@ const D3DBLEND_INVSRCALPHA := 6 var camera: Camera3D var light: DirectionalLight3D 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] = [] # Materials keyed by texture and state, so an unchanged draw keeps its material from frame to frame. var _materials := {} # The draws the last update_frame() showed (for tests and reports). var draw_count := 0 -# How many of them used the no-environment stand-in (see the note at the top). -var unlit_stand_in_count := 0 +var unlit_stand_in_count := 0 # Compatibility report field; real lighting keeps this at zero. func _ready() -> void: camera = Camera3D.new() @@ -46,6 +50,7 @@ func _ready() -> void: light = DirectionalLight3D.new() light.name = "Light0" light.visible = false + light.light_cull_mask = 1 << 1 add_child(light) environment = WorldEnvironment.new() environment.environment = Environment.new() @@ -80,7 +85,6 @@ static func multiply(a: PackedFloat32Array, b: PackedFloat32Array) -> PackedFloa func update_frame() -> void: var draws: Array = Metin2PythonHost.render3d_draws() var shown := 0 - var stand_ins := 0 var camera_set := false var light_set := false for draw in draws: @@ -95,11 +99,16 @@ func update_frame() -> void: _apply_projection(proj) camera_set = true 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"]: _apply_light(draw, view) light_set = true - if _environment_missing(draw): - stand_ins += 1 + 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) @@ -111,7 +120,106 @@ func update_frame() -> void: if not light_set: light.visible = false 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: # 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. var ambient: Color = draw["ambient"] var light_ambient: Color = draw["light0_ambient"] - environment.environment.ambient_light_color = Color(minf(ambient.r + light_ambient.r, 1.0), - minf(ambient.g + light_ambient.g, 1.0), minf(ambient.b + light_ambient.b, 1.0)) - -static func _environment_missing(draw: Dictionary) -> bool: - var ambient: Color = draw["ambient"] - var emissive: Color = draw["material_emissive"] - return draw["lighting"] and not draw["light0"] and ambient.r + ambient.g + ambient.b == 0.0 \ - and emissive.r + emissive.g + emissive.b == 0.0 + 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)) func _mesh_instance(index: int) -> MeshInstance3D: while _meshes.size() <= index: var instance := MeshInstance3D.new() instance.name = "Draw%d" % _meshes.size() instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + instance.layers = 1 << 1 add_child(instance) _meshes.append(instance) return _meshes[index] @@ -170,7 +279,7 @@ func _build_mesh(draw: Dictionary) -> ArrayMesh: return mesh 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. 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), diff --git a/project/python_game_render_test.gd b/project/python_game_render_test.gd index bd211467..b2e81ef9 100644 --- a/project/python_game_render_test.gd +++ b/project/python_game_render_test.gd @@ -107,6 +107,24 @@ func run() -> void: await process_frame report["draw_count"] = world.draw_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_near"] = world.camera.near report["camera_far"] = world.camera.far @@ -138,6 +156,7 @@ func run() -> void: textures.append(draw["texture0"]) report["textures"] = textures var surfaces_textured := 0 + var lit_surfaces := 0 var distinct_textures := {} for instance in world.find_children("Draw*", "MeshInstance3D", false, false): if instance.visible and instance.mesh: @@ -145,7 +164,10 @@ func run() -> void: if material and material.albedo_texture: surfaces_textured += 1 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["lit_surfaces"] = lit_surfaces report["distinct_textures"] = distinct_textures.size() # Every texture name gets its own texture (face, body, hair are not one material). _check(distinct_textures.size() == textures.size(), @@ -156,6 +178,7 @@ func run() -> void: bounds.append(str(instance.transform * instance.mesh.get_aabb())) report["camera_space_bounds"] = bounds _check(not drawn or surfaces_textured > 0, "character surfaces have their diffuse textures (%d)" % surfaces_textured) + _check(lit_surfaces > 0, "character surfaces use directional lighting (%d)" % lit_surfaces) if DisplayServer.get_name() != "headless": await RenderingServer.frame_post_draw var image := root.get_texture().get_image() @@ -170,6 +193,15 @@ 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