refactor(M03): extract WMO placement resolver

This commit is contained in:
2026-07-17 00:22:53 +04:00
parent f01302fd44
commit 05e216a953
9 changed files with 530 additions and 32 deletions
+32
View File
@@ -0,0 +1,32 @@
class_name WmoPlacementResolver
extends RefCounted
## Stateless WMO cache-key, placement-identity and world-transform rules.
## Returns the historical lowercase slash-normalized relative WMO cache key.
func normalize_relative_path(relative_wmo_path: String) -> String:
return relative_wmo_path.replace("\\", "/").to_lower()
## Returns the positive MODF unique-ID key or the historical tile/index fallback.
func resolve_unique_key(
placement: Dictionary,
tile_key: String,
placement_index: int
) -> String:
var unique_id := int(placement.get("unique_id", -1))
if unique_id > 0:
return "uid:%d" % unique_id
return "tile:%s:%d" % [tile_key, placement_index]
## Returns the world-space Godot transform used by every WMO instance path.
func resolve_world_transform(placement: Dictionary) -> Transform3D:
var world_position: Vector3 = placement.get("pos", Vector3.ZERO)
var rotation_radians: Vector3 = placement.get("rot", Vector3.ZERO)
var scale_value := float(placement.get("scale", 1.0))
return Transform3D(
Basis.from_euler(rotation_radians).scaled(Vector3.ONE * scale_value),
world_position
)
@@ -0,0 +1 @@
uid://02omydj0sxmo
+17 -30
View File
@@ -9,6 +9,9 @@ const SPLAT_TILE_SCRIPT := preload("res://src/resources/splat_adt_tile.gd")
const CONTROL_SPLAT_TILE_SCRIPT := preload("res://src/resources/control_splat_adt_tile.gd")
const WMO_STREAMING_SCRIPT := preload("res://src/resources/wmo_streaming_resource.gd")
const WMO_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/wmo_builder.gd")
const WMO_PLACEMENT_RESOLVER_SCRIPT := preload(
"res://src/render/wmo/wmo_placement_resolver.gd"
)
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
const M2_NATIVE_ANIMATED_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd")
const M2_NATIVE_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native_animator.gd")
@@ -277,6 +280,7 @@ var _wmo_scene_resource_cache: Dictionary = {}
var _wmo_scene_cache_missing: Dictionary = {}
var _wmo_scene_load_requests: Dictionary = {}
var _wmo_missing_cache: Dictionary = {}
var _wmo_placement_resolver := WMO_PLACEMENT_RESOLVER_SCRIPT.new()
var _m2_scene_cache: Dictionary = {}
var _m2_animated_scene_cache: Dictionary = {}
var _m2_static_animation_cache: Dictionary = {}
@@ -3557,7 +3561,11 @@ func _process_wmo_build_jobs() -> void:
var placement: Dictionary = placement_variant
var name_id: int = int(placement.get("name_id", -1))
if name_id >= 0 and name_id < wmo_names.size():
var unique_key := _wmo_unique_key(placement, tile_key, index)
var unique_key := _wmo_placement_resolver.resolve_unique_key(
placement,
tile_key,
index
)
if _wmo_registry.has(unique_key):
var entry: Dictionary = _wmo_registry[unique_key]
var entry_refs: Dictionary = entry["refs"]
@@ -3578,7 +3586,9 @@ func _process_wmo_build_jobs() -> void:
refs.append(unique_key)
_start_wmo_render_build(unique_key, instance, render_resource)
else:
var normalized_rel := _normalize_wmo_rel_path(rel_path)
var normalized_rel := _wmo_placement_resolver.normalize_relative_path(
rel_path
)
if _wmo_render_load_requests.has(normalized_rel):
advance_job = false
else:
@@ -3676,12 +3686,8 @@ func _drain_wmo_render_loads() -> void:
_wmo_render_missing_cache[normalized_rel] = true
func _normalize_wmo_rel_path(rel_path: String) -> String:
return rel_path.replace("\\", "/").to_lower()
func _get_wmo_render_or_request(rel_path: String) -> Resource:
var normalized_rel := _normalize_wmo_rel_path(rel_path)
var normalized_rel := _wmo_placement_resolver.normalize_relative_path(rel_path)
if normalized_rel.is_empty():
return null
if _wmo_render_cache.has(normalized_rel):
@@ -3706,7 +3712,7 @@ func _get_wmo_render_or_request(rel_path: String) -> Resource:
func _get_wmo_scene_or_request(rel_path: String) -> PackedScene:
var normalized_rel := _normalize_wmo_rel_path(rel_path)
var normalized_rel := _wmo_placement_resolver.normalize_relative_path(rel_path)
if normalized_rel.is_empty():
return null
if _wmo_scene_resource_cache.has(normalized_rel):
@@ -3748,10 +3754,7 @@ func _get_wmo_scene_or_request(rel_path: String) -> PackedScene:
func _instantiate_wmo_render_root(rel_path: String, placement: Dictionary) -> Node3D:
var root := Node3D.new()
root.name = rel_path.get_file().get_basename()
root.position = placement.get("pos", Vector3.ZERO)
root.rotation = placement.get("rot", Vector3.ZERO)
var scale_value: float = float(placement.get("scale", 1.0))
root.scale = Vector3.ONE * scale_value
root.transform = _wmo_placement_resolver.resolve_world_transform(placement)
return root
@@ -3893,10 +3896,7 @@ func _instantiate_wmo_scene(rel_path: String, scene: PackedScene, placement: Dic
instance.free()
return null
instance.name = rel_path.get_file().get_basename()
instance.position = placement.get("pos", Vector3.ZERO)
instance.rotation = placement.get("rot", Vector3.ZERO)
var scale_value: float = float(placement.get("scale", 1.0))
instance.scale = Vector3.ONE * scale_value
instance.transform = _wmo_placement_resolver.resolve_world_transform(placement)
return instance
@@ -4020,16 +4020,6 @@ func _unregister_tile_wmos(state: Dictionary) -> void:
state["wmo_building"] = false
func _wmo_unique_key(placement: Dictionary, tile_key: String, idx: int) -> String:
var uid: int = int(placement.get("unique_id", -1))
# uniqueId is always set in vanilla/3.3.5a MODF data, but older baked caches
# predating the ADTLoader unique_id field will miss it. Fall back to a
# per-tile synthetic key — no cross-tile dedup, but also no collisions.
if uid > 0:
return "uid:%d" % uid
return "tile:%s:%d" % [tile_key, idx]
func _clear_wmo_registry() -> void:
_wmo_render_build_jobs.clear()
_wmo_render_build_queue.clear()
@@ -4898,10 +4888,7 @@ func _instantiate_wmo_world(rel_path: String, placement: Dictionary) -> Node3D:
instance.name = rel_path.get_file().get_basename()
# WMOs are parented to _terrain_root, whose position absorbs the editor
# offset — use world-space placement.pos directly (no tile_origin subtraction).
instance.position = placement.get("pos", Vector3.ZERO)
instance.rotation = placement.get("rot", Vector3.ZERO)
var scale_value: float = float(placement.get("scale", 1.0))
instance.scale = Vector3.ONE * scale_value
instance.transform = _wmo_placement_resolver.resolve_world_transform(placement)
if not enable_occlusion_culling:
var occluders := instance.get_node_or_null("Occluders")
if occluders != null:
+266
View File
@@ -0,0 +1,266 @@
extends SceneTree
## Asset-free contract, dependency and timing regression for WMO placements.
const RESOLVER_SCRIPT := preload("res://src/render/wmo/wmo_placement_resolver.gd")
const RESOLVER_PATH := "res://src/render/wmo/wmo_placement_resolver.gd"
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
func _initialize() -> void:
var failures: Array[String] = []
_verify_path_normalization(failures)
_verify_positive_unique_id(failures)
_verify_synthetic_unique_keys(failures)
_verify_default_transform(failures)
_verify_position_rotation_and_scale(failures)
_verify_unclamped_scale(failures)
_verify_node_property_equivalence(failures)
_verify_source_boundaries(failures)
_verify_fresh_stateless_results(failures)
var elapsed_milliseconds := _verify_bounded_timing(failures)
if not failures.is_empty():
for failure in failures:
push_error("WMO_PLACEMENT_RESOLVER: %s" % failure)
quit(1)
return
print(
"WMO_PLACEMENT_RESOLVER PASS cases=9 iterations=20000 elapsed_ms=%.3f"
% elapsed_milliseconds
)
quit(0)
func _verify_path_normalization(failures: Array[String]) -> void:
var resolver := RESOLVER_SCRIPT.new()
_expect_equal_string(
resolver.normalize_relative_path("World\\WMO\\Stormwind\\Keep.WMO"),
"world/wmo/stormwind/keep.wmo",
"slash and case normalization",
failures
)
_expect_equal_string(resolver.normalize_relative_path(""), "", "empty path", failures)
func _verify_positive_unique_id(failures: Array[String]) -> void:
var resolver := RESOLVER_SCRIPT.new()
_expect_equal_string(
resolver.resolve_unique_key({"unique_id": 11785}, "31:49", 6),
"uid:11785",
"positive unique ID",
failures
)
func _verify_synthetic_unique_keys(failures: Array[String]) -> void:
var resolver := RESOLVER_SCRIPT.new()
for placement in [{}, {"unique_id": 0}, {"unique_id": -4}]:
_expect_equal_string(
resolver.resolve_unique_key(placement, "31:49", 6),
"tile:31:49:6",
"synthetic unique key",
failures
)
func _verify_default_transform(failures: Array[String]) -> void:
var resolver := RESOLVER_SCRIPT.new()
var transform: Transform3D = resolver.resolve_world_transform({})
_expect_basis_near(transform.basis, Basis.IDENTITY, "default basis", failures)
_expect_vector_near(transform.origin, Vector3.ZERO, "default origin", failures)
func _verify_position_rotation_and_scale(failures: Array[String]) -> void:
var resolver := RESOLVER_SCRIPT.new()
var position := Vector3(10.0, 20.0, 30.0)
var rotation := Vector3(0.2, -0.4, 0.6)
var transform: Transform3D = resolver.resolve_world_transform({
"pos": position,
"rot": rotation,
"scale": 2.5,
})
_expect_basis_near(
transform.basis,
Basis.from_euler(rotation).scaled(Vector3.ONE * 2.5),
"rotated scaled basis",
failures
)
_expect_vector_near(transform.origin, position, "world origin", failures)
func _verify_unclamped_scale(failures: Array[String]) -> void:
var resolver := RESOLVER_SCRIPT.new()
var zero_transform: Transform3D = resolver.resolve_world_transform({"scale": 0.0})
_expect_basis_near(
zero_transform.basis,
Basis.IDENTITY.scaled(Vector3.ZERO),
"zero scale",
failures
)
var negative_transform: Transform3D = resolver.resolve_world_transform({"scale": -2.0})
_expect_basis_near(
negative_transform.basis,
Basis.IDENTITY.scaled(Vector3.ONE * -2.0),
"negative scale",
failures
)
func _verify_node_property_equivalence(failures: Array[String]) -> void:
var resolver := RESOLVER_SCRIPT.new()
for scale_value in [2.5, 0.0, -2.0]:
var placement := {
"pos": Vector3(10.0, 20.0, 30.0),
"rot": Vector3(0.2, -0.4, 0.6),
"scale": scale_value,
}
var historical_node := Node3D.new()
historical_node.position = placement["pos"]
historical_node.rotation = placement["rot"]
historical_node.scale = Vector3.ONE * scale_value
var historical_transform := historical_node.transform
historical_node.free()
var resolved_transform: Transform3D = resolver.resolve_world_transform(placement)
_expect_basis_near(
resolved_transform.basis,
historical_transform.basis,
"Node3D property equivalence scale %s" % scale_value,
failures
)
_expect_vector_near(
resolved_transform.origin,
historical_transform.origin,
"Node3D origin equivalence scale %s" % scale_value,
failures
)
func _verify_source_boundaries(failures: Array[String]) -> void:
var loader_source := _read_text(LOADER_PATH, failures)
var resolver_source := _read_text(RESOLVER_PATH, failures)
_expect_true(
loader_source.contains("WMO_PLACEMENT_RESOLVER_SCRIPT.new()"),
"loader composes resolver",
failures
)
_expect_equal_int(
loader_source.count("_wmo_placement_resolver.normalize_relative_path("),
3,
"three normalization adapters",
failures
)
_expect_equal_int(
loader_source.count("_wmo_placement_resolver.resolve_unique_key("),
1,
"one identity adapter",
failures
)
_expect_equal_int(
loader_source.count("_wmo_placement_resolver.resolve_world_transform("),
3,
"three transform adapters",
failures
)
_expect_true(
not loader_source.contains("func _normalize_wmo_rel_path("),
"loader omits normalization formula",
failures
)
_expect_true(
not loader_source.contains("func _wmo_unique_key("),
"loader omits identity formula",
failures
)
for forbidden_dependency in [
"Node",
"ResourceLoader",
"RenderingServer",
"WorkerThreadPool",
"Mutex",
"FileAccess",
]:
_expect_true(
not resolver_source.contains(forbidden_dependency),
"resolver omits %s" % forbidden_dependency,
failures
)
func _verify_fresh_stateless_results(failures: Array[String]) -> void:
var resolver := RESOLVER_SCRIPT.new()
var placement := {"pos": Vector3(1.0, 2.0, 3.0), "scale": 4.0}
var first: Transform3D = resolver.resolve_world_transform(placement)
first.origin = Vector3.ZERO
var second: Transform3D = resolver.resolve_world_transform(placement)
_expect_vector_near(second.origin, Vector3(1.0, 2.0, 3.0), "stateless result", failures)
func _verify_bounded_timing(failures: Array[String]) -> float:
var resolver := RESOLVER_SCRIPT.new()
var placement := {
"unique_id": 11785,
"pos": Vector3(10.0, 20.0, 30.0),
"rot": Vector3(0.1, 0.2, 0.3),
"scale": 1.5,
}
var started_microseconds := Time.get_ticks_usec()
for iteration in range(20000):
resolver.normalize_relative_path("World\\WMO\\Stormwind\\Keep.WMO")
resolver.resolve_unique_key(placement, "31:49", iteration)
resolver.resolve_world_transform(placement)
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
_expect_true(elapsed_milliseconds < 1000.0, "20000 placement triples bounded", failures)
return elapsed_milliseconds
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_basis_near(
actual_basis: Basis,
expected_basis: Basis,
label: String,
failures: Array[String]
) -> void:
if not actual_basis.is_equal_approx(expected_basis):
failures.append("%s basis mismatch" % label)
func _expect_vector_near(
actual_value: Vector3,
expected_value: Vector3,
label: String,
failures: Array[String]
) -> void:
if not actual_value.is_equal_approx(expected_value):
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
func _expect_equal_string(
actual_value: String,
expected_value: String,
label: String,
failures: Array[String]
) -> void:
if actual_value != expected_value:
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
func _expect_equal_int(
actual_value: int,
expected_value: int,
label: String,
failures: Array[String]
) -> void:
if actual_value != expected_value:
failures.append("%s expected %d, got %d" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
@@ -0,0 +1 @@
uid://c5xnyoqrtceex