2V2-e.4: render 40250 pack terrain with map lighting

This commit is contained in:
shenlei
2026-09-23 21:19:14 +09:00
parent ac4c2aa4e0
commit cb43091f79
9 changed files with 226 additions and 24 deletions
+2
View File
@@ -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."}
+1
View File
@@ -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 玩法逻辑。
+13 -1
View File
@@ -166,7 +166,19 @@ Ref<ImageTexture> 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<ImageTexture>();
+26 -4
View File
@@ -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<ShaderMaterial> 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;
+3
View File
@@ -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 视锥剔除 +
+16
View File
@@ -144,6 +144,18 @@ bool AssetResolver::build(const std::string& root, const std::vector<std::string
}
std::string AssetResolver::resolve(const std::string& vp, std::string* reason) const {
// The 40250 pack manager already resolves its own virtual paths and patch priority.
// Keep the drive prefix: CEterPackManager indexes "d:/ymir work/..." as such.
if (assets_root == "pack://") {
std::string name = lower(vp);
std::replace(name.begin(), name.end(), '\\', '/');
if (name.rfind("pack://", 0) == 0) name.erase(0, 7);
if (name.empty()) {
if (reason) *reason = "empty pack path";
return "";
}
return assets_root + name;
}
auto join = [this](const std::string& rel) {
if (rel.empty()) return std::string();
if (assets_root.empty()) return rel;
@@ -224,6 +236,10 @@ bool AssetResolver::load_index(const std::string& root, const std::string& text,
bool AssetResolver::build_or_load(const std::string& root,
const std::vector<std::string>& 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);
+5
View File
@@ -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() {
+128 -19
View File
@@ -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),
+32
View File
@@ -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