40250 classic W2: EntityStore data-layer only + net_world visibility (增量 50)

CLIENT-GAP-FIX §2.1/§2.3/§2.4/§2.5 (+ §2.2 steps 4-5):

- §2.1 two-layer model: EntityStore is data-only. Entries are created only by
  mut_spawn / mut_spawn_full / mut_spawn_main and removed only by mut_despawn /
  main-vid table clear. Every other mut_* now does find-or-return with a
  MT_NET_TRACE-gated debug log; no pending / replay buffer. Visibility moves to
  net_world.gd::_update_visibility() — per frame it walks client.get_entities()
  and mirrors is_main / AFFECT_SHOW_ALWAYS(bit 7) / IsWall(14201-14204) /
  planar-distance < CHAR_STAGE_VIEW_BOUND (200*100 cm), building a node on entry
  and fading + freeing it past +10 while keeping the data row (_fading guard).
  Gate is fail-open when the data layer is not queryable (no get_entities /
  _main_vid == 0 / no pos_cm) so existing fake-client tests are unaffected.
- §2.3 one overwrite rule: mut_spawn_full() does a clean whole-row value replace
  (m_ents[vid] = e), no preservation of prior move state on VID reuse; mut_spawn()
  now delegates to it. net_world._on_spawn destroys + recreates a node when the
  vid already has one; mount/dismount rebuild keeps the old global_position.
- §2.4 points bounds: mut_set_points() clamps counts > POINT_MAX_NUM (255) and
  zeroes the tail of a short snapshot; mut_set_point() rejects type >= 255.
- §2.5 field apply order + affect flags: new EntityStore::mut_affect_flags(vid,
  lo, hi) assembles v = lo | (hi << 32) and queues a dirty tick; mut_char_update
  guild/alignment/pk changes now also push Info. net_world._apply_field_updates()
  applies fields in the reference call order (NetworkActorManager.cpp:470).
- §2.2 steps 4-5: mut_char_info() / mut_shop_sign() drop the touch()-creates-
  entity path. Steps 1-3 (m_pending_actor two-packet merge) remain W1-owned.

Tests: ./build.sh Debug exit 0; ctest 16/16 (net.entity_store gains a §2.1
data-layer assertion block). New project/net_world_vis_test.gd for scene-node
visibility. GDScript regressions green: netbridge_test, gamescene_test,
netplay_test, p9_test, combat_fx_test, player_motion_test, net_world_vis_test.

Follow-up for W1: call mut_affect_flags(vid, affect_flag[0], affect_flag[1])
from classic_parser.cpp after GC_CHARACTER_ADD[2] / GC_CHARACTER_UPDATE
(mut_char_update has no affect param). wire_classic.h already has the wanted
static_assert on GCPoints.points.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
This commit is contained in:
shenlei
2026-09-02 16:06:06 +09:00
co-authored by Claude Sonnet 5
parent f32064c74a
commit 3487df2a16
8 changed files with 597 additions and 50 deletions
+168 -2
View File
@@ -39,6 +39,22 @@ const FUNC_SKILL := 0x80
const DAMAGE_DODGE := 1 << 2
const DAMAGE_CRITICAL := 1 << 5
# CLIENT-GAP §2.1 two-layer model. The data layer (M2Client / EntityStore) always
# holds every known entity; a scene node under `parent` exists only while the
# entity is visible: it is the main character, force-visible, a wall, or within
# CHAR_STAGE_VIEW_BOUND of the main character.
# CPythonCharacterManager.cpp:13 -> CHAR_STAGE_VIEW_BOUND = 200*100 (cm; px==cm)
# PythonCharacterManager.cpp:182 -> cull past (CHAR_STAGE_VIEW_BOUND + 10)
const CHAR_STAGE_VIEW_BOUND := 200 * 100
# CInstanceBase AFFECT enum, ordinal 7 (bit 7) == AFFECT_SHOW_ALWAYS. The 40250
# ClientVS22 tree keeps the enum in AffectFlagContainer.h and does not spell the
# member out; this mirrors the m2dev sibling source (InstanceBase.h) and is the
# force-visible bit checked by CNetworkActorManager::__IsVisibleActor.
const AFFECT_SHOW_ALWAYS := 1 << 7
# CInstanceBase::IsWall race ids (InstanceBase.cpp) — always mirrored regardless
# of distance so map geometry never pops.
const WALL_RACES := [14201, 14202, 14203, 14204]
var client: Node # M2Client
var parent: Node3D # 实体挂这下面
var world: Node # Metin2World(可空,用 sample_height 贴地)
@@ -54,6 +70,7 @@ var name_resolver: Callable = Callable() # func(entity_dict) -> String(怪名
var _pvp_relations := {} # unordered pair key -> {src_vid,dst_vid,mode}
var _duel_opponents := {} # server-provided duel opponent VID set
var _duel_cannot_attack := false
var _fading := {} # vid:int -> true while a culled node tweens out (not re-created until done)
func setup(m2client: Node, mount: Node3D) -> void:
client = m2client
@@ -127,6 +144,7 @@ func clear_for_map_change() -> void:
node.queue_free()
entity_removed.emit(int(vid))
_by_vid.clear()
_fading.clear()
_main_vid = 0
_pvp_relations.clear()
_duel_opponents.clear()
@@ -142,7 +160,31 @@ func main_node() -> Node3D:
func _on_spawn(d: Dictionary) -> void:
var vid := int(d.get("vid", 0))
if vid == 0 or _by_vid.has(vid) or vid == _local_vid:
if vid == 0 or vid == _local_vid:
return
# §2.1: a node that is blending out is still "present" until the tween frees
# it — don't rebuild it mid-fade (mirrors GetInstancePtr/UpdateDeleting).
if _fading.has(vid):
return
# §2.3 one-overwrite rule: a fresh Spawn for a VID that already has a node
# destroys and recreates the node (the data row was whole-replaced). The one
# exception is a mount / dismount driven rebuild, which keeps the old world
# position (reference: kCreateData.m_lPosX = oldPos.x; m_lPosY = -oldPos.y —
# the Y flip is the server<->pixel frame, already applied, so in Godot world
# space this is just the old node's global_position).
var keep_pos: Variant = null
var existing: Node3D = _by_vid.get(vid, null)
if is_instance_valid(existing):
var old_mount := int(existing.get_meta("mount_vnum", 0))
var new_mount := int(d.get("mount_vnum", 0))
if (old_mount == 0) != (new_mount == 0):
keep_pos = existing.global_position
_by_vid.erase(vid)
existing.queue_free()
elif not _is_visible_entity(d):
# §2.1: the data row lives in EntityStore; the scene node is only built
# once the entity is in view. _update_visibility() creates it later when
# the main character moves into CHAR_STAGE_VIEW_BOUND.
return
var node: Node3D = null
if _model_factory.is_valid():
@@ -153,12 +195,17 @@ func _on_spawn(d: Dictionary) -> void:
_attach_nameplate(node, d) # 真模型也要头顶名字 + HP 条
node.name = "e_%d" % vid
parent.add_child(node)
node.position = _grounded(d.get("pos", Vector3.ZERO))
if keep_pos is Vector3:
node.global_position = keep_pos
else:
node.position = _grounded(d.get("pos", Vector3.ZERO))
node.set_meta("vid", vid)
node.set_meta("func", int(d.get("func", FUNC_WAIT)))
node.set_meta("mount_vnum", int(d.get("mount_vnum", 0)))
_by_vid[vid] = node
_refresh_shop_sign(node, str(d.get("shop_sign", "")))
_refresh_pvp_tag(vid)
_apply_field_updates(node, d)
entity_added.emit(node, vid)
if vid == _main_vid:
main_entity_ready.emit(node, vid)
@@ -175,6 +222,123 @@ func _on_info(vid: int, d: Dictionary) -> void:
elif n.has_method("set_display_name"):
n.set_display_name(nm)
_refresh_shop_sign(n, str(d.get("shop_sign", "")))
_apply_field_updates(n, d)
# §2.5: push the mutable entity fields onto a live view in the exact order the
# reference client uses (CNetworkActorManager refresh -> NetworkActorManager.cpp
# :470): ChangeArmor -> ChangeWeapon -> ChangeHair -> ChangeGuild ->
# SetAffectFlagContainer -> SetMoveSpeed -> SetAttackSpeed -> SetAlignment ->
# SetPKMode -> SetStateFlags. Every setter is optional: the placeholder capsule
# and views that do not model a field simply skip it.
func _apply_field_updates(n: Node3D, d: Dictionary) -> void:
var parts: Variant = d.get("parts", [])
if parts is Array and parts.size() >= 3:
_set_wear_slot(n, "set_armor", 0, int(parts[0]))
_set_wear_slot(n, "set_weapon", 1, int(parts[1]))
_set_wear_slot(n, "set_hair", 2, int(parts[2]))
if n.has_method("set_guild"):
n.call("set_guild", int(d.get("guild", 0)))
if n.has_method("set_affect_flags"):
n.call("set_affect_flags", int(d.get("affect_flags", 0)))
elif n.has_method("set_affects"):
n.call("set_affects", int(d.get("affect_flags", 0)))
if n.has_method("set_move_speed"):
n.call("set_move_speed", int(d.get("moving_speed", 0)))
elif n.has_method("set_server_speed"):
n.call("set_server_speed", int(d.get("moving_speed", 0)))
if n.has_method("set_attack_speed"):
n.call("set_attack_speed", int(d.get("attack_speed", 0)))
if n.has_method("set_alignment"):
n.call("set_alignment", int(d.get("alignment", 0)))
if n.has_method("set_pk_mode"):
n.call("set_pk_mode", int(d.get("pk_mode", 0)))
if n.has_method("set_state_flags"):
n.call("set_state_flags", int(d.get("state_flags", 0)))
# keep the §2.3 mount-toggle rebuild key current
n.set_meta("mount_vnum", int(d.get("mount_vnum", n.get_meta("mount_vnum", 0))))
func _set_wear_slot(n: Node3D, dedicated: String, slot: int, vnum: int) -> void:
if n.has_method(dedicated):
n.call(dedicated, vnum)
elif n.has_method("set_wear"):
n.call("set_wear", slot, vnum)
# --- §2.1 per-frame visibility manager ------------------------------------------
# Mirrors CNetworkActorManager::__IsVisibleActor + the CPythonCharacterManager
# cull loop: every data-layer entity is checked each frame; a node is built when
# the entity comes into view and blended out when it leaves. Runs only once the
# main character's position is known (reference cull needs GetMainInstancePtr);
# until then the entity_spawned signal path keeps building nodes as before.
func _update_visibility() -> void:
if not client.has_method("get_entities") or _main_vid == 0:
return
var main: Dictionary = client.get_entity(_main_vid)
if main.is_empty() or not (main.get("pos_cm", null) is Vector3):
return
var mc: Vector3 = main["pos_cm"]
var main_cm := Vector2(mc.x, mc.y)
var cull := float(CHAR_STAGE_VIEW_BOUND + 10)
for e in client.get_entities():
var vid := int(e.get("vid", 0))
if vid == 0 or vid == _local_vid or _fading.has(vid):
continue
var has_node := _by_vid.has(vid)
var forced := vid == _main_vid or bool(e.get("is_main", false)) \
or _show_always(e) or _is_wall(int(e.get("race", 0)))
var dist := INF
if e.get("pos_cm", null) is Vector3:
dist = _planar_cm(e).distance_to(main_cm)
if forced or dist < CHAR_STAGE_VIEW_BOUND:
if not has_node:
_on_spawn(e)
elif has_node and dist > cull:
_fade_and_free(vid)
# Distance test used both by the per-frame manager and the _on_spawn gate.
func _is_visible_entity(e: Dictionary) -> bool:
var vid := int(e.get("vid", 0))
if vid == _main_vid or bool(e.get("is_main", false)):
return true
if _show_always(e) or _is_wall(int(e.get("race", 0))):
return true
# Fail open when the data layer can't be queried or the main character has not
# been located yet — keeps the spawn burst / fake-client tests behaving as
# before; real gating kicks in once M2Client reports pos_cm + a main vid.
if client == null or not client.has_method("get_entities") or _main_vid == 0:
return true
var main: Dictionary = client.get_entity(_main_vid)
if main.is_empty() or not (main.get("pos_cm", null) is Vector3) \
or not (e.get("pos_cm", null) is Vector3):
return true
var mc: Vector3 = main["pos_cm"]
return _planar_cm(e).distance_to(Vector2(mc.x, mc.y)) < CHAR_STAGE_VIEW_BOUND
func _planar_cm(e: Dictionary) -> Vector2:
var p: Variant = e.get("pos_cm", null)
return Vector2(p.x, p.y) if p is Vector3 else Vector2.ZERO
func _show_always(e: Dictionary) -> bool:
return (int(e.get("affect_flags", 0)) & AFFECT_SHOW_ALWAYS) != 0
func _is_wall(race: int) -> bool:
return race in WALL_RACES
# §2.1 cull: keep the data row, blend the node out. It stays discoverable via
# _fading (so it is not rebuilt mid-blend) until the tween frees it.
func _fade_and_free(vid: int) -> void:
var n: Node3D = _by_vid.get(vid, null)
_by_vid.erase(vid)
if not is_instance_valid(n):
_fading.erase(vid)
return
_fading[vid] = true
entity_removed.emit(vid)
var tw := create_tween()
tw.tween_property(n, "scale", Vector3.ONE * 0.01, 0.3)
tw.tween_callback(func() -> void:
if is_instance_valid(n):
n.queue_free()
_fading.erase(vid))
func _refresh_shop_sign(root: Node3D, sign: String) -> void:
var tag := root.get_node_or_null("ShopSign") as Label3D
@@ -204,6 +368,7 @@ func _display_name(d: Dictionary) -> String:
func _on_despawn(vid: int) -> void:
_duel_opponents.erase(vid)
_fading.erase(vid)
var n: Node3D = _by_vid.get(vid, null)
if n:
_by_vid.erase(vid)
@@ -522,6 +687,7 @@ func _on_damage(vid: int, amount: int, flag: int) -> void:
func _process(dt: float) -> void:
if client == null or parent == null:
return
_update_visibility()
for vid in _by_vid.keys():
var n: Node3D = _by_vid[vid]
if not is_instance_valid(n):
+126
View File
@@ -0,0 +1,126 @@
# net_world_vis_test —— CLIENT-GAP §2.1 两层模型 / 可见性剔除的 headless 自检。
# godot --headless --path project --script net_world_vis_test.gd
# 用带 pos_cm 的假 client 驱动 net_world.gd 的每帧可见性管理:
# - 数据层始终有全部实体;场景节点只在进入 CHAR_STAGE_VIEW_BOUND 时创建
# - 主角走近 -> 远端实体补建;主角走远 -> 节点淡出(数据保留)
# - AFFECT_SHOW_ALWAYS / IsWall 的实体无视距离常驻
# 退出码 0 = 全过。
extends SceneTree
const NetWorld = preload("res://net_world.gd")
const BOUND := 200 * 100 # CHAR_STAGE_VIEW_BOUND
const NEAR_CM := Vector2(100 * 100, 0) # 10000 cm < BOUND
const FAR_CM := Vector2(500 * 100, 0) # 50000 cm > BOUND + 10
class FakeClient extends Node:
signal entity_spawned(entity: Dictionary)
signal entity_despawned(vid: int)
signal entity_moved(vid: int)
signal entity_main_set(vid: int)
signal entity_info(vid: int, entity: Dictionary)
signal chat(type: int, vid: int, text: String)
signal vitals_changed(vid: int)
signal entity_dead(vid: int)
signal damage(vid: int, amount: int, flag: int)
var ents := {}
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
func get_entities() -> Array: return ents.values()
func get_pvp_relations() -> Array: return []
func get_duel() -> Dictionary: return {}
func add(vid: int, cm: Vector2, extra := {}) -> Dictionary:
var d := {"vid": vid, "name": "e%d" % vid, "is_main": false,
"func": 0, "moving": false, "angle_deg": 0.0,
"hp": 100, "max_hp": 100, "dead": false, "race": 0,
"affect_flags": 0, "parts": [0, 0, 0, 0],
"pos": Vector3(cm.x * 0.01, 0.0, -cm.y * 0.01),
"pos_cm": Vector3(cm.x, cm.y, 0.0)}
d.merge(extra, true)
ents[vid] = d
return d
func move_main(cm: Vector2) -> void:
ents[1]["pos_cm"] = Vector3(cm.x, cm.y, 0.0)
ents[1]["pos"] = Vector3(cm.x * 0.01, 0.0, -cm.y * 0.01)
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
await _run()
if _fail == 0:
print("PASS: net_world_vis_test (§2.1 two-layer visibility cull / re-show)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var mount := Node3D.new()
get_root().add_child(mount)
var fc := FakeClient.new()
get_root().add_child(fc)
var nw: Node = NetWorld.new()
get_root().add_child(nw)
nw.setup(fc, mount)
var added := {}
var removed := {}
nw.entity_added.connect(func(_n, v): added[v] = int(added.get(v, 0)) + 1)
nw.entity_removed.connect(func(v): removed[v] = int(removed.get(v, 0)) + 1)
# main character at origin
fc.add(1, Vector2.ZERO, {"is_main": true})
fc.entity_main_set.emit(1)
# 1) a near entity spawns a node immediately (signal path, in view)
var near := fc.add(2, NEAR_CM)
fc.entity_spawned.emit(near)
_ck(nw.node_for(2) != null, "near entity (10000cm) -> node built on spawn")
# 2) a far entity is data-only: the spawn signal must NOT build a node
var far := fc.add(3, FAR_CM)
fc.entity_spawned.emit(far)
_ck(nw.node_for(3) == null, "far entity (50000cm) -> spawn signal builds no node (§2.1)")
await process_frame
_ck(nw.node_for(3) == null, "far entity still nodeless after a visibility pass")
_ck(nw.node_for(2) != null, "near entity keeps its node while main is close")
# 3) main walks over to the far entity -> per-frame manager builds its node
fc.move_main(FAR_CM)
await process_frame
await process_frame
_ck(nw.node_for(3) != null, "main in range -> _update_visibility builds the far node")
_ck(int(added.get(3, 0)) == 1, "far node built exactly once")
# 4) entity 2 is now out of range -> node blends out, data row stays
_ck(nw.node_for(2) == null, "entity 2 out of range -> node culled")
_ck(int(removed.get(2, 0)) == 1, "cull emits entity_removed once")
_ck(fc.get_entity(2).has("vid"), "culled entity still present in the data layer")
await process_frame
_ck(nw.node_for(2) == null, "culled entity is not rebuilt while it stays out of range")
# 5) AFFECT_SHOW_ALWAYS (bit 7) -> force-visible regardless of distance
var beacon := fc.add(4, FAR_CM + Vector2(90000, 0), {"affect_flags": 1 << 7})
fc.entity_spawned.emit(beacon)
_ck(nw.node_for(4) != null, "AFFECT_SHOW_ALWAYS entity -> node built though far")
await process_frame
_ck(nw.node_for(4) != null, "force-visible entity survives the cull loop")
# 6) IsWall race -> always mirrored
var wall := fc.add(5, FAR_CM + Vector2(120000, 0), {"race": 14202})
fc.entity_spawned.emit(wall)
await process_frame
_ck(nw.node_for(5) != null, "wall race (14202) -> node built and kept regardless of distance")
# 7) main walks back -> entity 2 comes into view again and is rebuilt
fc.move_main(NEAR_CM)
# entity 2's fade tween (0.3s) must finish before it can be rebuilt
await create_tween().tween_interval(0.5).finished
await process_frame
_ck(nw.node_for(2) != null, "main back in range -> culled entity rebuilt after its fade")
_ck(int(added.get(2, 0)) == 2, "entity 2 rebuilt exactly once on re-entry")
+1
View File
@@ -0,0 +1 @@
uid://bhriuw4uo5d2b