refactor(M03): extract M2 runtime mesh finalizer
Work-Package: M03-RND-M2-RUNTIME-MESH-FINALIZER-001 Agent: sindo-main-codex Tests: 37 headless renderer/coordinate contracts pass; checkpoint dry-run 7/7; documentation and coordination gates pass Fidelity: preserves refresh version 2, classifier, rebuild and fallback transitions; no visual parity claim
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
class_name M2RuntimeMeshFinalizer
|
||||
extends RefCounted
|
||||
|
||||
## Finalizes stale cached M2 Meshes using caller-supplied raw data. The caller
|
||||
## owns raw-file I/O, cache adoption, missing state and materialization.
|
||||
|
||||
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
|
||||
const M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_runtime_mesh_rebuild_classifier.gd"
|
||||
)
|
||||
const M2_MESH_RESOURCE_EXTRACTOR_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_mesh_resource_extractor.gd"
|
||||
)
|
||||
|
||||
const MATERIAL_REFRESH_VERSION := 2
|
||||
const MATERIAL_REFRESH_VERSION_META := "wow_m2_material_refresh_version"
|
||||
|
||||
var _runtime_mesh_rebuild_classifier := M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT.new()
|
||||
var _mesh_resource_extractor := M2_MESH_RESOURCE_EXTRACTOR_SCRIPT.new()
|
||||
|
||||
|
||||
## Returns whether one Mesh is stale and the caller should load raw M2 data.
|
||||
func requires_raw_data_for_refresh(mesh: Mesh) -> bool:
|
||||
return (
|
||||
mesh != null
|
||||
and int(mesh.get_meta(MATERIAL_REFRESH_VERSION_META, 0)) < MATERIAL_REFRESH_VERSION
|
||||
)
|
||||
|
||||
|
||||
## Marks, rebuilds or falls back one stale Mesh using already-loaded raw M2
|
||||
## data. Current Meshes return unchanged and never inspect raw data.
|
||||
func finalize_mesh(
|
||||
normalized_relative_path: String,
|
||||
mesh: Mesh,
|
||||
raw_m2_data: Dictionary,
|
||||
extracted_directory: String) -> Mesh:
|
||||
if mesh == null:
|
||||
return null
|
||||
if not requires_raw_data_for_refresh(mesh):
|
||||
return mesh
|
||||
if raw_m2_data.is_empty() or not _runtime_mesh_rebuild_classifier.needs_runtime_mesh_rebuild(
|
||||
normalized_relative_path,
|
||||
raw_m2_data):
|
||||
_mark_mesh_current(mesh)
|
||||
return mesh
|
||||
var rebuilt_mesh := _rebuild_mesh(raw_m2_data, extracted_directory)
|
||||
if rebuilt_mesh != null:
|
||||
return rebuilt_mesh
|
||||
_mark_mesh_current(mesh)
|
||||
return mesh
|
||||
|
||||
|
||||
## Clears memoized per-path rebuild decisions at the existing map/shutdown sites.
|
||||
func clear() -> void:
|
||||
_runtime_mesh_rebuild_classifier.clear()
|
||||
|
||||
|
||||
## Returns the memoized rebuild-decision count for diagnostics and verification.
|
||||
func cached_rebuild_decision_count() -> int:
|
||||
return _runtime_mesh_rebuild_classifier.cached_path_count()
|
||||
|
||||
|
||||
func _mark_mesh_current(mesh: Mesh) -> void:
|
||||
mesh.set_meta(MATERIAL_REFRESH_VERSION_META, MATERIAL_REFRESH_VERSION)
|
||||
|
||||
|
||||
func _rebuild_mesh(raw_m2_data: Dictionary, extracted_directory: String) -> Mesh:
|
||||
var temporary_prototype: Node3D = M2_BUILDER_SCRIPT.build(
|
||||
raw_m2_data,
|
||||
extracted_directory
|
||||
)
|
||||
if temporary_prototype == null:
|
||||
return null
|
||||
var rebuilt_mesh := _mesh_resource_extractor.find_first_mesh_in_subtree(
|
||||
temporary_prototype
|
||||
)
|
||||
temporary_prototype.free()
|
||||
return rebuilt_mesh
|
||||
@@ -0,0 +1 @@
|
||||
uid://bn6vvtmqo0don
|
||||
@@ -60,8 +60,8 @@ const M2_PLACEMENT_GROUPER_SCRIPT := preload(
|
||||
const M2_BUILD_BATCH_PLANNER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_build_batch_planner.gd"
|
||||
)
|
||||
const M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_runtime_mesh_rebuild_classifier.gd"
|
||||
const M2_RUNTIME_MESH_FINALIZER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_runtime_mesh_finalizer.gd"
|
||||
)
|
||||
const M2_MESH_LOAD_PIPELINE_STATE_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_mesh_load_pipeline_state.gd"
|
||||
@@ -78,7 +78,6 @@ const RENDER_BUDGET_SCHEDULER_SCRIPT := preload("res://src/render/streaming/rend
|
||||
const REQUIRED_BAKED_TILE_FORMAT_VERSION := 5
|
||||
const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1
|
||||
const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3
|
||||
const M2_MATERIAL_REFRESH_VERSION := 2
|
||||
const WMO_MATERIAL_REFRESH_VERSION := 10
|
||||
const RENDER_GROUND_QUERY_RAY_HEIGHT_UNITS := 5000.0
|
||||
|
||||
@@ -259,9 +258,7 @@ var _m2_unique_placement_registry := (
|
||||
var _m2_placement_transform_resolver := M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT.new()
|
||||
var _m2_placement_grouper := M2_PLACEMENT_GROUPER_SCRIPT.new()
|
||||
var _m2_build_batch_planner := M2_BUILD_BATCH_PLANNER_SCRIPT.new()
|
||||
var _m2_runtime_mesh_rebuild_classifier := (
|
||||
M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT.new()
|
||||
)
|
||||
var _m2_runtime_mesh_finalizer := M2_RUNTIME_MESH_FINALIZER_SCRIPT.new()
|
||||
var _m2_mesh_resource_cache_state := M2_MESH_RESOURCE_CACHE_STATE_SCRIPT.new()
|
||||
var _m2_mesh_resource_extractor := M2_MESH_RESOURCE_EXTRACTOR_SCRIPT.new()
|
||||
var _m2_mesh_load_pipeline_state := M2_MESH_LOAD_PIPELINE_STATE_SCRIPT.new()
|
||||
@@ -2355,7 +2352,7 @@ func _wait_for_tile_tasks() -> void:
|
||||
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
||||
ResourceLoader.load_threaded_get(path)
|
||||
_m2_mesh_load_pipeline_state.clear()
|
||||
_m2_runtime_mesh_rebuild_classifier.clear()
|
||||
_m2_runtime_mesh_finalizer.clear()
|
||||
|
||||
for pending in _m2_animation_load_requests.values():
|
||||
var path: String = String(pending.get("path", ""))
|
||||
@@ -3005,7 +3002,7 @@ func _clear_streamed_world() -> void:
|
||||
_m2_build_queue.clear()
|
||||
_m2_unique_placement_registry.clear()
|
||||
_m2_mesh_load_pipeline_state.clear()
|
||||
_m2_runtime_mesh_rebuild_classifier.clear()
|
||||
_m2_runtime_mesh_finalizer.clear()
|
||||
_m2_animation_load_requests.clear()
|
||||
_m2_animation_finalize_queue.clear()
|
||||
_wmo_build_jobs.clear()
|
||||
@@ -4654,19 +4651,15 @@ func _request_m2_mesh_load(normalized_rel: String) -> void:
|
||||
func _prepare_m2_mesh_for_runtime(normalized_rel: String, mesh: Mesh) -> Mesh:
|
||||
if mesh == null:
|
||||
return null
|
||||
if int(mesh.get_meta("wow_m2_material_refresh_version", 0)) >= M2_MATERIAL_REFRESH_VERSION:
|
||||
if not _m2_runtime_mesh_finalizer.requires_raw_data_for_refresh(mesh):
|
||||
return mesh
|
||||
var data := _load_m2_raw_data_for_refresh(normalized_rel)
|
||||
if data.is_empty() or not _m2_runtime_mesh_rebuild_classifier.needs_runtime_mesh_rebuild(
|
||||
return _m2_runtime_mesh_finalizer.finalize_mesh(
|
||||
normalized_rel,
|
||||
data):
|
||||
mesh.set_meta("wow_m2_material_refresh_version", M2_MATERIAL_REFRESH_VERSION)
|
||||
return mesh
|
||||
var rebuilt := _rebuild_m2_mesh_from_data(data)
|
||||
if rebuilt != null:
|
||||
return rebuilt
|
||||
mesh.set_meta("wow_m2_material_refresh_version", M2_MATERIAL_REFRESH_VERSION)
|
||||
return mesh
|
||||
mesh,
|
||||
data,
|
||||
extracted_dir
|
||||
)
|
||||
|
||||
|
||||
func _prepare_m2_node_for_runtime(normalized_rel: String, root: Node3D) -> void:
|
||||
@@ -4693,17 +4686,6 @@ func _load_m2_raw_data_for_refresh(normalized_rel: String) -> Dictionary:
|
||||
return data if data is Dictionary else {}
|
||||
|
||||
|
||||
func _rebuild_m2_mesh_from_data(data: Dictionary) -> Mesh:
|
||||
if data.is_empty():
|
||||
return null
|
||||
var prototype: Node3D = M2_BUILDER_SCRIPT.build(data, extracted_dir)
|
||||
if prototype == null:
|
||||
return null
|
||||
var rebuilt := _m2_mesh_resource_extractor.find_first_mesh_in_subtree(prototype)
|
||||
prototype.free()
|
||||
return rebuilt
|
||||
|
||||
|
||||
func _get_or_load_m2_mesh(rel_path: String) -> Mesh:
|
||||
var normalized_rel := _normalize_m2_rel_path(rel_path)
|
||||
if _m2_mesh_resource_cache_state.has_mesh(normalized_rel):
|
||||
|
||||
@@ -117,8 +117,8 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void:
|
||||
)
|
||||
_expect_equal(
|
||||
loader_source.count("_m2_mesh_resource_extractor.find_first_mesh_in_subtree("),
|
||||
2,
|
||||
"rebuild and synchronous fallback delegate",
|
||||
1,
|
||||
"synchronous fallback delegates",
|
||||
failures
|
||||
)
|
||||
for retained_loader_rule in [
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
extends SceneTree
|
||||
|
||||
## Synthetic current/stale/ordinary/rebuild/fallback/clear/boundary/timing
|
||||
## regression for runtime M2 Mesh material finalization.
|
||||
|
||||
const FINALIZER_SCRIPT := preload("res://src/render/m2/m2_runtime_mesh_finalizer.gd")
|
||||
const FINALIZER_PATH := "res://src/render/m2/m2_runtime_mesh_finalizer.gd"
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
const REFRESH_META := "wow_m2_material_refresh_version"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_current_and_empty_raw_data(failures)
|
||||
_verify_ordinary_and_rebuild_paths(failures)
|
||||
_verify_rebuild_failure_fallback_and_clear(failures)
|
||||
_verify_ownership_boundaries(failures)
|
||||
var elapsed_milliseconds := _verify_bounded_timing(failures)
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("M2_RUNTIME_MESH_FINALIZER: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
print(
|
||||
"M2_RUNTIME_MESH_FINALIZER PASS cases=11 iterations=100 elapsed_ms=%.3f"
|
||||
% elapsed_milliseconds
|
||||
)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_current_and_empty_raw_data(failures: Array[String]) -> void:
|
||||
var finalizer: RefCounted = FINALIZER_SCRIPT.new()
|
||||
_expect_same(finalizer.call("finalize_mesh", "world/null.m2", null, {}, ""), null, "null Mesh returns null", failures)
|
||||
var current_mesh := ArrayMesh.new()
|
||||
current_mesh.set_meta(REFRESH_META, 2)
|
||||
_expect_false(
|
||||
finalizer.call("requires_raw_data_for_refresh", current_mesh),
|
||||
"current Mesh skips raw data",
|
||||
failures
|
||||
)
|
||||
_expect_same(
|
||||
finalizer.call("finalize_mesh", "world/current.m2", current_mesh, _rebuild_raw_data(), ""),
|
||||
current_mesh,
|
||||
"current Mesh returns unchanged",
|
||||
failures
|
||||
)
|
||||
_expect_equal(int(finalizer.call("cached_rebuild_decision_count")), 0, "current Mesh skips classifier", failures)
|
||||
var stale_mesh := ArrayMesh.new()
|
||||
_expect_true(
|
||||
finalizer.call("requires_raw_data_for_refresh", stale_mesh),
|
||||
"stale Mesh requests raw data",
|
||||
failures
|
||||
)
|
||||
_expect_same(
|
||||
finalizer.call("finalize_mesh", "world/stale.m2", stale_mesh, {}, ""),
|
||||
stale_mesh,
|
||||
"empty raw data falls back to original",
|
||||
failures
|
||||
)
|
||||
_expect_equal(int(stale_mesh.get_meta(REFRESH_META, 0)), 2, "empty raw fallback marks current", failures)
|
||||
|
||||
|
||||
func _verify_ordinary_and_rebuild_paths(failures: Array[String]) -> void:
|
||||
var finalizer: RefCounted = FINALIZER_SCRIPT.new()
|
||||
var ordinary_mesh := ArrayMesh.new()
|
||||
var ordinary_raw_data := {"batches": [{"has_billboard": false}]}
|
||||
_expect_same(
|
||||
finalizer.call("finalize_mesh", "world/rock.m2", ordinary_mesh, ordinary_raw_data, ""),
|
||||
ordinary_mesh,
|
||||
"ordinary raw data retains original",
|
||||
failures
|
||||
)
|
||||
_expect_equal(int(ordinary_mesh.get_meta(REFRESH_META, 0)), 2, "ordinary Mesh marks current", failures)
|
||||
var stale_billboard_mesh := ArrayMesh.new()
|
||||
var rebuilt_mesh: Mesh = finalizer.call(
|
||||
"finalize_mesh",
|
||||
"world/tree.m2",
|
||||
stale_billboard_mesh,
|
||||
_rebuild_raw_data(),
|
||||
""
|
||||
)
|
||||
_expect_true(rebuilt_mesh != null, "billboard raw data rebuilds Mesh", failures)
|
||||
_expect_false(is_same(rebuilt_mesh, stale_billboard_mesh), "rebuild replaces original Mesh", failures)
|
||||
_expect_equal(int(rebuilt_mesh.get_meta(REFRESH_META, 0)), 2, "rebuilt Mesh carries refresh version", failures)
|
||||
|
||||
|
||||
func _verify_rebuild_failure_fallback_and_clear(failures: Array[String]) -> void:
|
||||
var finalizer: RefCounted = FINALIZER_SCRIPT.new()
|
||||
var fallback_mesh := ArrayMesh.new()
|
||||
var incomplete_billboard_data := {"batches": [{"has_billboard": true}]}
|
||||
_expect_same(
|
||||
finalizer.call(
|
||||
"finalize_mesh",
|
||||
"world/incomplete.m2",
|
||||
fallback_mesh,
|
||||
incomplete_billboard_data,
|
||||
""
|
||||
),
|
||||
fallback_mesh,
|
||||
"failed rebuild falls back to original",
|
||||
failures
|
||||
)
|
||||
_expect_equal(int(fallback_mesh.get_meta(REFRESH_META, 0)), 2, "failed rebuild marks fallback current", failures)
|
||||
_expect_equal(int(finalizer.call("cached_rebuild_decision_count")), 1, "classifier decision retained", failures)
|
||||
finalizer.call("clear")
|
||||
finalizer.call("clear")
|
||||
_expect_equal(int(finalizer.call("cached_rebuild_decision_count")), 0, "clear drops decisions", failures)
|
||||
|
||||
|
||||
func _verify_ownership_boundaries(failures: Array[String]) -> void:
|
||||
var finalizer_source := FileAccess.get_file_as_string(FINALIZER_PATH)
|
||||
var loader_source := FileAccess.get_file_as_string(LOADER_PATH)
|
||||
_expect_true(
|
||||
loader_source.contains("M2_RUNTIME_MESH_FINALIZER_SCRIPT.new()"),
|
||||
"loader composes runtime Mesh finalizer",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
loader_source.count("_m2_runtime_mesh_finalizer.clear()"),
|
||||
2,
|
||||
"two existing clear sites delegate",
|
||||
failures
|
||||
)
|
||||
_expect_false(loader_source.contains("const M2_MATERIAL_REFRESH_VERSION"), "refresh version leaves loader", failures)
|
||||
_expect_false(loader_source.contains("func _rebuild_m2_mesh_from_data("), "legacy rebuild adapter removed", failures)
|
||||
for retained_loader_rule in [
|
||||
"func _load_m2_raw_data_for_refresh(",
|
||||
"FileAccess.file_exists(abs_path)",
|
||||
"ClassDB.instantiate(\"M2Loader\")",
|
||||
"loader.call(\"load_m2\", abs_path)",
|
||||
"_m2_mesh_resource_cache_state.store_mesh(",
|
||||
"_m2_missing_cache[normalized_rel]",
|
||||
]:
|
||||
_expect_true(loader_source.contains(retained_loader_rule), "loader retains %s" % retained_loader_rule, failures)
|
||||
for required_finalizer_rule in [
|
||||
"const MATERIAL_REFRESH_VERSION := 2",
|
||||
"M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT.new()",
|
||||
"M2_MESH_RESOURCE_EXTRACTOR_SCRIPT.new()",
|
||||
"M2_BUILDER_SCRIPT.build(",
|
||||
"temporary_prototype.free()",
|
||||
]:
|
||||
_expect_true(finalizer_source.contains(required_finalizer_rule), "finalizer owns %s" % required_finalizer_rule, failures)
|
||||
for forbidden_dependency in [
|
||||
"ResourceLoader.",
|
||||
"FileAccess.",
|
||||
"ClassDB.",
|
||||
"load_m2",
|
||||
"_m2_mesh_resource_cache_state",
|
||||
"_m2_missing_cache",
|
||||
"MultiMesh",
|
||||
]:
|
||||
_expect_false(
|
||||
finalizer_source.contains(forbidden_dependency),
|
||||
"finalizer omits %s ownership" % forbidden_dependency,
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_bounded_timing(failures: Array[String]) -> float:
|
||||
var finalizer: RefCounted = FINALIZER_SCRIPT.new()
|
||||
var meshes: Array[Mesh] = []
|
||||
for _path_index in range(256):
|
||||
meshes.append(ArrayMesh.new())
|
||||
var ordinary_raw_data := {"batches": [{"has_billboard": false}]}
|
||||
var started_microseconds := Time.get_ticks_usec()
|
||||
for _iteration in range(100):
|
||||
for path_index in range(256):
|
||||
var mesh := meshes[path_index]
|
||||
mesh.set_meta(REFRESH_META, 0)
|
||||
finalizer.call(
|
||||
"finalize_mesh",
|
||||
"world/model_%d.m2" % path_index,
|
||||
mesh,
|
||||
ordinary_raw_data,
|
||||
""
|
||||
)
|
||||
finalizer.call("clear")
|
||||
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
|
||||
_expect_true(elapsed_milliseconds < 1000.0, "100 by 256 ordinary finalizations under 1 second", failures)
|
||||
return elapsed_milliseconds
|
||||
|
||||
|
||||
func _rebuild_raw_data() -> Dictionary:
|
||||
return {
|
||||
"vertices": PackedVector3Array([
|
||||
Vector3(0.0, 0.0, 0.0),
|
||||
Vector3(1.0, 0.0, 0.0),
|
||||
Vector3(0.0, 1.0, 0.0),
|
||||
]),
|
||||
"indices": PackedInt32Array([0, 1, 2]),
|
||||
"batches": [{
|
||||
"index_start": 0,
|
||||
"index_count": 3,
|
||||
"material_id": -1,
|
||||
"texture_combo_index": -1,
|
||||
"has_billboard": true,
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
func _expect_true(condition: bool, label: String, failures: Array[String]) -> void:
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _expect_false(condition: bool, label: String, failures: Array[String]) -> void:
|
||||
_expect_true(not condition, label, failures)
|
||||
|
||||
|
||||
func _expect_equal(actual: int, expected: int, label: String, failures: Array[String]) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s expected=%d actual=%d" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_same(actual: Variant, expected: Variant, label: String, failures: Array[String]) -> void:
|
||||
if not is_same(actual, expected):
|
||||
failures.append(label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://r1yv88u0rykp
|
||||
@@ -7,6 +7,7 @@ const CLASSIFIER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_runtime_mesh_rebuild_classifier.gd"
|
||||
)
|
||||
const CLASSIFIER_PATH := "res://src/render/m2/m2_runtime_mesh_rebuild_classifier.gd"
|
||||
const FINALIZER_PATH := "res://src/render/m2/m2_runtime_mesh_finalizer.gd"
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
|
||||
|
||||
@@ -152,16 +153,17 @@ func _verify_memoization_clear_and_diagnostics(failures: Array[String]) -> void:
|
||||
|
||||
func _verify_ownership_boundaries(failures: Array[String]) -> void:
|
||||
var classifier_source := FileAccess.get_file_as_string(CLASSIFIER_PATH)
|
||||
var finalizer_source := FileAccess.get_file_as_string(FINALIZER_PATH)
|
||||
var loader_source := FileAccess.get_file_as_string(LOADER_PATH)
|
||||
_expect_true(
|
||||
loader_source.contains("M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT.new()"),
|
||||
"loader composes classifier",
|
||||
finalizer_source.contains("M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT.new()"),
|
||||
"runtime Mesh finalizer composes classifier",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
loader_source.count("_m2_runtime_mesh_rebuild_classifier.clear()"),
|
||||
loader_source.count("_m2_runtime_mesh_finalizer.clear()"),
|
||||
2,
|
||||
"loader retains two classifier clear sites",
|
||||
"loader retains two finalizer clear sites",
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
|
||||
Reference in New Issue
Block a user