render: extract cached M2 animation resource observer

This commit is contained in:
2026-07-18 10:23:03 +04:00
parent 3519f183bb
commit df87619220
17 changed files with 1065 additions and 197 deletions
@@ -0,0 +1,238 @@
class_name M2CachedAnimationResourceObserver
extends RefCounted
## Observes or requests the cached-GLB animated phase of one M2 build step.
const M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT := preload(
"res://src/render/m2/m2_build_resource_snapshot.gd"
)
## Returns a snapshot containing the exact cached prototype or pending state.
## Invalid composition and terminal failures produce a non-pending snapshot.
func observe(
normalized_relative_path: String,
cache_directory: String,
maximum_primitive_count: int,
allowlist_patterns: PackedStringArray,
denylist_patterns: PackedStringArray,
prototype_cache_state: RefCounted,
load_pipeline_state: RefCounted,
debug_logging_enabled: bool = false
) -> RefCounted:
var empty_snapshot: RefCounted = M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
normalized_relative_path,
null,
false
)
if (
normalized_relative_path.is_empty()
or prototype_cache_state == null
or load_pipeline_state == null
):
return empty_snapshot
var cached_prototype := prototype_cache_state.call(
"find_animated_prototype",
normalized_relative_path
) as Node3D
if cached_prototype != null:
return M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
normalized_relative_path,
cached_prototype,
false
)
if bool(prototype_cache_state.call(
"is_animation_static",
normalized_relative_path
)):
return empty_snapshot
if bool(load_pipeline_state.call("has_request", normalized_relative_path)):
return M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
normalized_relative_path,
null,
true
)
var cache_resource_path := find_eligible_glb_cache_path(
normalized_relative_path,
cache_directory,
maximum_primitive_count,
allowlist_patterns,
denylist_patterns,
debug_logging_enabled
)
if cache_resource_path.is_empty():
prototype_cache_state.call(
"mark_animation_static",
normalized_relative_path
)
return empty_snapshot
var request_error := ResourceLoader.load_threaded_request(
cache_resource_path,
"",
false,
ResourceLoader.CACHE_MODE_REUSE
)
if request_error == OK or request_error == ERR_BUSY:
load_pipeline_state.call(
"remember_request",
normalized_relative_path,
cache_resource_path
)
return M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
normalized_relative_path,
null,
true
)
prototype_cache_state.call("mark_animation_static", normalized_relative_path)
return empty_snapshot
## Returns the first eligible historical `.glb` cache candidate.
func find_eligible_glb_cache_path(
normalized_relative_path: String,
cache_directory: String,
maximum_primitive_count: int,
allowlist_patterns: PackedStringArray,
denylist_patterns: PackedStringArray,
debug_logging_enabled: bool = false
) -> String:
if not is_animation_path_allowed(
normalized_relative_path,
allowlist_patterns,
denylist_patterns
):
return ""
for cache_resource_path in cache_resource_paths(
cache_directory,
normalized_relative_path
):
if not ResourceLoader.exists(cache_resource_path):
continue
if glb_cache_is_safe_for_runtime_animation(
cache_resource_path,
maximum_primitive_count,
debug_logging_enabled
):
return cache_resource_path
return ""
## Applies the historical stripped, case-insensitive allow/deny substring policy.
func is_animation_path_allowed(
normalized_relative_path: String,
allowlist_patterns: PackedStringArray,
denylist_patterns: PackedStringArray
) -> bool:
if normalized_relative_path.is_empty() or allowlist_patterns.is_empty():
return false
var lowercase_path := normalized_relative_path.to_lower()
var allowed := false
for pattern in allowlist_patterns:
var needle := String(pattern).strip_edges().to_lower()
if not needle.is_empty() and lowercase_path.contains(needle):
allowed = true
break
if not allowed:
return false
for pattern in denylist_patterns:
var needle := String(pattern).strip_edges().to_lower()
if not needle.is_empty() and lowercase_path.contains(needle):
return false
return true
## Returns historical nested/lowercase/basename `.glb` candidates without I/O.
func cache_resource_paths(
cache_directory: String,
relative_path: String
) -> PackedStringArray:
var normalized := relative_path.replace("\\", "/")
var lowercase := normalized.to_lower()
var stems := [
normalized.get_basename(),
lowercase.get_basename(),
normalized.get_file().get_basename(),
lowercase.get_file().get_basename(),
]
var result := PackedStringArray()
for stem in stems:
if stem.is_empty():
continue
var cache_resource_path := cache_directory.path_join(stem + ".glb")
if not result.has(cache_resource_path):
result.append(cache_resource_path)
return result
## Checks animation presence, primitive limit and accepted OpenWC schema.
func glb_cache_is_safe_for_runtime_animation(
cache_resource_path: String,
maximum_primitive_count: int,
debug_logging_enabled: bool = false
) -> bool:
var gltf := read_glb_json(cache_resource_path)
if gltf.is_empty():
return false
var animations: Array = gltf.get("animations", [])
if animations.is_empty():
return false
var primitive_count := glb_primitive_count(gltf)
if maximum_primitive_count > 0 and primitive_count > maximum_primitive_count:
return false
var schema := glb_animation_schema(gltf)
if schema == "pivot_prefix_v1" or schema.is_empty():
return true
if debug_logging_enabled:
print(
"M2_ANIM_REJECT schema=%s primitives=%d cache=%s"
% [schema, primitive_count, cache_resource_path]
)
return false
## Reads the JSON chunk of a version-2 GLB or returns an empty Dictionary.
func read_glb_json(cache_resource_path: String) -> Dictionary:
var absolute_path := ProjectSettings.globalize_path(cache_resource_path)
if not FileAccess.file_exists(absolute_path):
return {}
var file := FileAccess.open(absolute_path, FileAccess.READ)
if file == null or file.get_length() < 20:
return {}
var magic := file.get_32()
var version := file.get_32()
file.get_32()
if magic != 0x46546c67 or version != 2:
return {}
var json_length := int(file.get_32())
var chunk_type := file.get_32()
if json_length <= 0 or chunk_type != 0x4e4f534a:
return {}
var parsed: Variant = JSON.parse_string(
file.get_buffer(json_length).get_string_from_utf8()
)
return parsed as Dictionary if parsed is Dictionary else {}
## Counts all glTF Mesh primitives using the historical safety rule.
func glb_primitive_count(gltf: Dictionary) -> int:
var count := 0
var meshes: Array = gltf.get("meshes", [])
for mesh_variant in meshes:
if mesh_variant is Dictionary:
var primitives: Array = (mesh_variant as Dictionary).get(
"primitives",
[]
)
count += primitives.size()
return count
## Returns the OpenWC animation schema marker from parsed glTF metadata.
func glb_animation_schema(gltf: Dictionary) -> String:
var asset: Dictionary = gltf.get("asset", {})
var extras: Dictionary = asset.get("extras", {})
return String(extras.get("openwc_m2_anim_schema", ""))
@@ -0,0 +1 @@
uid://d0ptej41k2smt
+33 -138
View File
@@ -73,6 +73,9 @@ const M2_STATIC_BATCH_MATERIALIZER_SCRIPT := preload(
const M2_STATIC_BUILD_RESOURCE_OBSERVER_SCRIPT := preload(
"res://src/render/m2/m2_static_build_resource_observer.gd"
)
const M2_CACHED_ANIMATION_RESOURCE_OBSERVER_SCRIPT := preload(
"res://src/render/m2/m2_cached_animation_resource_observer.gd"
)
const M2_RUNTIME_MESH_FINALIZER_SCRIPT := preload(
"res://src/render/m2/m2_runtime_mesh_finalizer.gd"
)
@@ -283,6 +286,9 @@ var _m2_build_dispatch_planner := M2_BUILD_DISPATCH_PLANNER_SCRIPT.new()
var _m2_static_build_resource_observer := (
M2_STATIC_BUILD_RESOURCE_OBSERVER_SCRIPT.new()
)
var _m2_animation_resource_observer := (
M2_CACHED_ANIMATION_RESOURCE_OBSERVER_SCRIPT.new()
)
var _m2_unique_placement_registry := (
M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT.new()
)
@@ -4350,20 +4356,34 @@ func _process_m2_build_jobs() -> void:
var groups := _m2_build_queue_state.groups_for(key)
var transforms: Array = groups.get(rel_path, [])
var offset := _m2_build_queue_state.transform_offset_for(key)
var animated_prototype: Node3D = null
var resource_snapshot: RefCounted
if enable_m2_animated_instances:
animated_prototype = _get_or_load_m2_native_animated_prototype(rel_path)
if animated_prototype == null:
animated_prototype = _get_or_load_m2_animated_prototype(rel_path)
var animation_request_pending := (
enable_m2_animated_instances
and _m2_animation_load_pipeline_state.has_request(normalized_rel)
)
var resource_snapshot: RefCounted = M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
normalized_rel,
animated_prototype,
animation_request_pending
)
var native_animated_prototype := (
_get_or_load_m2_native_animated_prototype(rel_path)
)
if native_animated_prototype != null:
resource_snapshot = M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
normalized_rel,
native_animated_prototype,
false
)
else:
resource_snapshot = _m2_animation_resource_observer.observe(
normalized_rel,
m2_cache_dir,
m2_animated_max_primitives,
m2_animated_allowlist_patterns,
m2_animated_denylist_patterns,
_m2_prototype_cache_state,
_m2_animation_load_pipeline_state,
debug_streaming
)
else:
resource_snapshot = M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(
normalized_rel,
null,
false
)
var batch_plan: Dictionary = {}
var batch_count := 0
if not bool(resource_snapshot.call("animation_request_pending")):
@@ -4875,75 +4895,6 @@ func _is_m2_native_animation_candidate(normalized_rel: String) -> bool:
return normalized_rel.to_lower().contains("gryphonroost")
func _get_or_load_m2_animated_prototype(rel_path: String) -> Node3D:
var normalized_rel := _normalize_m2_rel_path(rel_path)
if normalized_rel.is_empty():
return null
var cached_animated := _m2_prototype_cache_state.find_animated_prototype(
normalized_rel
)
if cached_animated != null:
return cached_animated
if _m2_prototype_cache_state.is_animation_static(normalized_rel):
return null
if _m2_animation_load_pipeline_state.has_request(normalized_rel):
return null
_request_m2_animation_load(normalized_rel)
return null
func _request_m2_animation_load(normalized_rel: String) -> void:
if normalized_rel.is_empty() or _m2_animation_load_pipeline_state.has_request(normalized_rel):
return
var cache_res_path := _find_m2_animated_glb_cache_path(normalized_rel)
if cache_res_path.is_empty():
_m2_prototype_cache_state.mark_animation_static(normalized_rel)
return
var err := ResourceLoader.load_threaded_request(
cache_res_path,
"",
false,
ResourceLoader.CACHE_MODE_REUSE)
if err == OK or err == ERR_BUSY:
_m2_animation_load_pipeline_state.remember_request(normalized_rel, cache_res_path)
else:
_m2_prototype_cache_state.mark_animation_static(normalized_rel)
func _find_m2_animated_glb_cache_path(normalized_rel: String) -> String:
if not _is_m2_animation_allowed(normalized_rel):
return ""
if _is_m2_animation_denied(normalized_rel):
return ""
for cache_res_path in _get_m2_cache_resource_paths(normalized_rel, [".glb"]):
if not ResourceLoader.exists(cache_res_path):
continue
if _glb_cache_is_safe_for_runtime_animation(cache_res_path):
return cache_res_path
return ""
func _is_m2_animation_allowed(normalized_rel: String) -> bool:
if m2_animated_allowlist_patterns.is_empty():
return false
var lower := normalized_rel.to_lower()
for pattern in m2_animated_allowlist_patterns:
var needle := String(pattern).strip_edges().to_lower()
if not needle.is_empty() and lower.contains(needle):
return true
return false
func _is_m2_animation_denied(normalized_rel: String) -> bool:
var lower := normalized_rel.to_lower()
for pattern in m2_animated_denylist_patterns:
var needle := String(pattern).strip_edges().to_lower()
if not needle.is_empty() and lower.contains(needle):
return true
return false
func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D:
if normalized_rel.is_empty():
return null
@@ -4967,62 +4918,6 @@ func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D:
return null
func _glb_cache_is_safe_for_runtime_animation(cache_res_path: String) -> bool:
var abs_path := ProjectSettings.globalize_path(cache_res_path)
if not FileAccess.file_exists(abs_path):
return false
var file := FileAccess.open(abs_path, FileAccess.READ)
if file == null or file.get_length() < 20:
return false
var magic := file.get_32()
var version := file.get_32()
file.get_32()
if magic != 0x46546c67 or version != 2:
return false
var json_len := int(file.get_32())
var chunk_type := file.get_32()
if json_len <= 0 or chunk_type != 0x4e4f534a:
return false
var json_text := file.get_buffer(json_len).get_string_from_utf8()
var parsed = JSON.parse_string(json_text)
if not (parsed is Dictionary):
return false
var gltf := parsed as Dictionary
var animations: Array = gltf.get("animations", [])
if animations.is_empty():
return false
var primitive_count := _glb_primitive_count(gltf)
if m2_animated_max_primitives > 0 and primitive_count > m2_animated_max_primitives:
return false
var schema := _glb_animation_schema(gltf)
if schema == "pivot_prefix_v1":
return true
# Older simple critter GLBs were generated before the schema marker existed.
# Accept them only after allowlist/denylist filtering and primitive-count checks.
if schema.is_empty():
return true
if debug_streaming:
print("M2_ANIM_REJECT schema=%s primitives=%d cache=%s" % [schema, primitive_count, cache_res_path])
return false
func _glb_primitive_count(gltf: Dictionary) -> int:
var count := 0
var meshes: Array = gltf.get("meshes", [])
for mesh_variant in meshes:
if not (mesh_variant is Dictionary):
continue
var primitives: Array = (mesh_variant as Dictionary).get("primitives", [])
count += primitives.size()
return count
func _glb_animation_schema(gltf: Dictionary) -> String:
var asset: Dictionary = gltf.get("asset", {})
var extras: Dictionary = asset.get("extras", {})
return String(extras.get("openwc_m2_anim_schema", ""))
func _get_or_load_wmo_prototype(rel_path: String) -> Node3D:
var normalized_rel := rel_path.replace("\\", "/")
if normalized_rel.is_empty():
@@ -5,6 +5,9 @@ extends SceneTree
const PIPELINE_SCRIPT := preload("res://src/render/m2/m2_animation_load_pipeline_state.gd")
const PIPELINE_PATH := "res://src/render/m2/m2_animation_load_pipeline_state.gd"
const OBSERVER_PATH := (
"res://src/render/m2/m2_cached_animation_resource_observer.gd"
)
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
@@ -80,14 +83,19 @@ func _verify_discard_metrics_clear_and_diagnostics(failures: Array[String]) -> v
func _verify_ownership_boundaries(failures: Array[String]) -> void:
var pipeline_source := FileAccess.get_file_as_string(PIPELINE_PATH)
var observer_source := FileAccess.get_file_as_string(OBSERVER_PATH)
var loader_source := FileAccess.get_file_as_string(LOADER_PATH)
_expect_true(loader_source.contains("M2_ANIMATION_LOAD_PIPELINE_STATE_SCRIPT.new()"), "loader composes pipeline state", failures)
_expect_false(loader_source.contains("var _m2_animation_load_requests:"), "legacy request field removed", failures)
_expect_false(loader_source.contains("var _m2_animation_finalize_queue:"), "legacy finalize field removed", failures)
_expect_equal(loader_source.count("_m2_animation_load_pipeline_state.total_work_count()"), 3, "three existing metrics delegate", failures)
_expect_equal(loader_source.count("_m2_animation_load_pipeline_state.clear()"), 2, "two existing clear sites delegate", failures)
_expect_true(
observer_source.contains("ResourceLoader.load_threaded_request("),
"cached observer owns request admission",
failures
)
for retained_loader_rule in [
"ResourceLoader.load_threaded_request(",
"ResourceLoader.load_threaded_get_status(path)",
"ResourceLoader.load_threaded_get(path)",
"RENDER_BUDGET_SCHEDULER_SCRIPT.M2_ANIMATION_FINALIZE",
@@ -9,6 +9,9 @@ const SNAPSHOT_SCRIPT := preload(
const SNAPSHOT_PATH := "res://src/render/m2/m2_build_resource_snapshot.gd"
const DISPATCH_PATH := "res://src/render/m2/m2_build_dispatch_planner.gd"
const STATIC_OBSERVER_PATH := "res://src/render/m2/m2_static_build_resource_observer.gd"
const CACHED_ANIMATION_OBSERVER_PATH := (
"res://src/render/m2/m2_cached_animation_resource_observer.gd"
)
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
@@ -136,6 +139,9 @@ func _verify_loader_and_dependency_boundaries(failures: Array[String]) -> void:
var loader_source := FileAccess.get_file_as_string(LOADER_PATH)
var dispatch_source := FileAccess.get_file_as_string(DISPATCH_PATH)
var static_observer_source := FileAccess.get_file_as_string(STATIC_OBSERVER_PATH)
var cached_observer_source := FileAccess.get_file_as_string(
CACHED_ANIMATION_OBSERVER_PATH
)
var snapshot_source := FileAccess.get_file_as_string(SNAPSHOT_PATH)
for delegated_token in [
"M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(",
@@ -152,6 +158,11 @@ func _verify_loader_and_dependency_boundaries(failures: Array[String]) -> void:
"static observer adopts snapshot",
failures
)
_expect_true(
cached_observer_source.contains("M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new("),
"cached animation observer produces snapshot",
failures
)
_expect_true(
dispatch_source.contains("resource_snapshot.call(\"animation_request_pending\")")
and dispatch_source.contains("resource_snapshot.call(\"has_static_mesh\")"),
@@ -0,0 +1,406 @@
extends SceneTree
const OBSERVER_SCRIPT := preload(
"res://src/render/m2/m2_cached_animation_resource_observer.gd"
)
const PROTOTYPE_CACHE_SCRIPT := preload(
"res://src/render/m2/m2_prototype_cache_state.gd"
)
const PIPELINE_SCRIPT := preload(
"res://src/render/m2/m2_animation_load_pipeline_state.gd"
)
const OBSERVER_PATH := (
"res://src/render/m2/m2_cached_animation_resource_observer.gd"
)
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
const FIXTURE_DIRECTORY := "user://verify_m2_cached_animation_observer"
var _fixture_paths: PackedStringArray = PackedStringArray()
func _initialize() -> void:
var failures: Array[String] = []
_verify_observation_lifecycle(failures)
_verify_path_policy_and_order(failures)
_verify_glb_safety(failures)
_verify_source_boundaries(failures)
var elapsed_milliseconds := _verify_bounded_timing(failures)
_remove_fixtures()
if not failures.is_empty():
for failure in failures:
push_error("M2_CACHED_ANIMATION_RESOURCE_OBSERVER: %s" % failure)
quit(1)
return
print(
"M2_CACHED_ANIMATION_RESOURCE_OBSERVER PASS "
+ "cases=33 iterations=20000 elapsed_ms=%.3f" % elapsed_milliseconds
)
quit(0)
func _verify_observation_lifecycle(failures: Array[String]) -> void:
var observer: RefCounted = OBSERVER_SCRIPT.new()
var prototype_cache: RefCounted = PROTOTYPE_CACHE_SCRIPT.new()
var pipeline: RefCounted = PIPELINE_SCRIPT.new()
var invalid_snapshot: RefCounted = observer.call(
"observe", "", "res://none", 3, PackedStringArray(),
PackedStringArray(), prototype_cache, pipeline
)
_expect_false(
bool(invalid_snapshot.call("animation_request_pending")),
"invalid path is not pending",
failures
)
var prototype := Node3D.new()
prototype_cache.call(
"adopt_animated_prototype",
"creature/fish/a.m2",
prototype
)
var cached_snapshot: RefCounted = observer.call(
"observe", "creature/fish/a.m2", "res://none", 3,
PackedStringArray(["creature/fish/"]), PackedStringArray(),
prototype_cache, pipeline
)
_expect_true(
is_same(cached_snapshot.call("animated_prototype"), prototype),
"cached prototype identity",
failures
)
prototype_cache.call("mark_animation_static", "creature/fish/static.m2")
var static_snapshot: RefCounted = observer.call(
"observe", "creature/fish/static.m2", "res://none", 3,
PackedStringArray(["creature/fish/"]), PackedStringArray(),
prototype_cache, pipeline
)
_expect_false(
bool(static_snapshot.call("animation_request_pending")),
"static outcome is not pending",
failures
)
pipeline.call(
"remember_request",
"creature/fish/pending.m2",
"res://pending.glb"
)
var pending_snapshot: RefCounted = observer.call(
"observe", "creature/fish/pending.m2", "res://none", 3,
PackedStringArray(["creature/fish/"]), PackedStringArray(),
prototype_cache, pipeline
)
_expect_true(
bool(pending_snapshot.call("animation_request_pending")),
"existing request remains pending",
failures
)
var unavailable_snapshot: RefCounted = observer.call(
"observe", "creature/fish/unavailable.m2", "res://none", 3,
PackedStringArray(["creature/fish/"]), PackedStringArray(),
prototype_cache, pipeline
)
_expect_false(
bool(unavailable_snapshot.call("animation_request_pending")),
"missing candidate is not pending",
failures
)
_expect_true(
bool(prototype_cache.call(
"is_animation_static",
"creature/fish/unavailable.m2"
)),
"missing candidate marks static",
failures
)
pipeline.call("clear")
prototype_cache.call("clear_and_release")
func _verify_path_policy_and_order(failures: Array[String]) -> void:
var observer: RefCounted = OBSERVER_SCRIPT.new()
_expect_false(
bool(observer.call(
"is_animation_path_allowed", "creature/fish/a.m2",
PackedStringArray(), PackedStringArray()
)),
"empty allowlist rejects",
failures
)
_expect_true(
bool(observer.call(
"is_animation_path_allowed", "Creature/Fish/A.M2",
PackedStringArray([" creature/fish/ "]), PackedStringArray()
)),
"allowlist strips and ignores case",
failures
)
_expect_false(
bool(observer.call(
"is_animation_path_allowed", "creature/fish/boss/a.m2",
PackedStringArray(["creature/fish/"]),
PackedStringArray([" FISH/BOSS "])
)),
"denylist wins",
failures
)
var paths: PackedStringArray = observer.call(
"cache_resource_paths",
"res://cache",
"World/Critter/Foo.M2"
)
_expect_true(
paths == PackedStringArray([
"res://cache/World/Critter/Foo.glb",
"res://cache/world/critter/foo.glb",
"res://cache/Foo.glb",
"res://cache/foo.glb",
]),
"historical GLB candidate order",
failures
)
func _verify_glb_safety(failures: Array[String]) -> void:
var observer: RefCounted = OBSERVER_SCRIPT.new()
_expect_false(
bool(observer.call(
"glb_cache_is_safe_for_runtime_animation",
"user://missing.glb",
3
)),
"missing GLB rejects",
failures
)
var pivot_path := _write_glb_fixture(
"pivot.glb",
_make_gltf(2, "pivot_prefix_v1", true)
)
_expect_true(
bool(observer.call(
"glb_cache_is_safe_for_runtime_animation", pivot_path, 3
)),
"pivot-prefix schema accepts",
failures
)
var legacy_path := _write_glb_fixture(
"legacy.glb",
_make_gltf(1, "", true)
)
_expect_true(
bool(observer.call(
"glb_cache_is_safe_for_runtime_animation", legacy_path, 3
)),
"legacy empty schema accepts",
failures
)
var unknown_path := _write_glb_fixture(
"unknown.glb",
_make_gltf(1, "future_schema", true)
)
_expect_false(
bool(observer.call(
"glb_cache_is_safe_for_runtime_animation", unknown_path, 3
)),
"unknown schema rejects",
failures
)
var complex_path := _write_glb_fixture(
"complex.glb",
_make_gltf(4, "pivot_prefix_v1", true)
)
_expect_false(
bool(observer.call(
"glb_cache_is_safe_for_runtime_animation", complex_path, 3
)),
"primitive limit rejects",
failures
)
_expect_true(
bool(observer.call(
"glb_cache_is_safe_for_runtime_animation", complex_path, 0
)),
"non-positive primitive limit disables cap",
failures
)
var static_path := _write_glb_fixture(
"static.glb",
_make_gltf(1, "pivot_prefix_v1", false)
)
_expect_false(
bool(observer.call(
"glb_cache_is_safe_for_runtime_animation", static_path, 3
)),
"GLB without animations rejects",
failures
)
_expect_equal(
int(observer.call("glb_primitive_count", _make_gltf(5, "", true))),
5,
"primitive count retained",
failures
)
_expect_string_equal(
String(observer.call(
"glb_animation_schema",
_make_gltf(1, "pivot_prefix_v1", true)
)),
"pivot_prefix_v1",
"schema retained",
failures
)
func _verify_source_boundaries(failures: Array[String]) -> void:
var loader_source := FileAccess.get_file_as_string(LOADER_PATH)
var observer_source := FileAccess.get_file_as_string(OBSERVER_PATH)
_expect_true(
loader_source.contains("_m2_animation_resource_observer.observe("),
"loader delegates cached observation",
failures
)
for removed_function in [
"func _get_or_load_m2_animated_prototype",
"func _request_m2_animation_load",
"func _find_m2_animated_glb_cache_path",
"func _glb_cache_is_safe_for_runtime_animation",
]:
_expect_false(
loader_source.contains(removed_function),
"loader omits %s" % removed_function,
failures
)
_expect_true(
loader_source.contains("func _get_or_load_m2_native_animated_prototype"),
"loader retains native observation",
failures
)
_expect_true(
observer_source.contains("ResourceLoader.load_threaded_request("),
"observer owns request admission",
failures
)
for forbidden_token in [
"queue_free",
"RenderingServer",
"WorkerThreadPool",
"M2_NATIVE_ANIMATED_BUILDER",
"M2RawModelRepository",
"RenderBudgetScheduler",
]:
_expect_false(
observer_source.contains(forbidden_token),
"observer omits %s" % forbidden_token,
failures
)
func _verify_bounded_timing(failures: Array[String]) -> float:
var observer: RefCounted = OBSERVER_SCRIPT.new()
var allowlist := PackedStringArray(["creature/fish/", "world/critter/"])
var denylist := PackedStringArray(["boss"])
var started_microseconds := Time.get_ticks_usec()
for iteration in range(20000):
var path := "Creature/Fish/model_%d.m2" % (iteration % 64)
observer.call(
"is_animation_path_allowed",
path,
allowlist,
denylist
)
observer.call("cache_resource_paths", "res://cache", path)
var elapsed_milliseconds := (
float(Time.get_ticks_usec() - started_microseconds) / 1000.0
)
_expect_true(
elapsed_milliseconds < 1000.0,
"20,000 policy/path observations remain bounded",
failures
)
return elapsed_milliseconds
func _make_gltf(
primitive_count: int,
schema: String,
has_animation: bool
) -> Dictionary:
var primitives: Array[Dictionary] = []
for _index in range(primitive_count):
primitives.append({})
var gltf := {
"asset": {"version": "2.0", "extras": {}},
"meshes": [{"primitives": primitives}],
"animations": [{}] if has_animation else [],
}
if not schema.is_empty():
(gltf["asset"]["extras"] as Dictionary)[
"openwc_m2_anim_schema"
] = schema
return gltf
func _write_glb_fixture(file_name: String, gltf: Dictionary) -> String:
var absolute_directory := ProjectSettings.globalize_path(FIXTURE_DIRECTORY)
DirAccess.make_dir_recursive_absolute(absolute_directory)
var resource_path := FIXTURE_DIRECTORY.path_join(file_name)
var absolute_path := ProjectSettings.globalize_path(resource_path)
var json_bytes := JSON.stringify(gltf).to_utf8_buffer()
while json_bytes.size() % 4 != 0:
json_bytes.append(0x20)
var file := FileAccess.open(absolute_path, FileAccess.WRITE)
if file == null:
return resource_path
file.store_32(0x46546c67)
file.store_32(2)
file.store_32(20 + json_bytes.size())
file.store_32(json_bytes.size())
file.store_32(0x4e4f534a)
file.store_buffer(json_bytes)
file.close()
_fixture_paths.append(absolute_path)
return resource_path
func _remove_fixtures() -> void:
for absolute_path in _fixture_paths:
if FileAccess.file_exists(absolute_path):
DirAccess.remove_absolute(absolute_path)
var absolute_directory := ProjectSettings.globalize_path(FIXTURE_DIRECTORY)
if DirAccess.dir_exists_absolute(absolute_directory):
DirAccess.remove_absolute(absolute_directory)
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_string_equal(
actual: String,
expected: String,
label: String,
failures: Array[String]
) -> void:
if actual != expected:
failures.append("%s expected=%s actual=%s" % [label, expected, actual])
@@ -0,0 +1 @@
uid://cbviicmix3kb0