class_name M2RuntimeMeshRebuildClassifier extends RefCounted ## Memoizes whether raw M2 billboard or UV-rotation metadata requires replacing ## a stale cached runtime mesh. Owns no parser, Mesh, material, Node or RID. const UV_ROTATION_SPEED_EPSILON := 0.000001 const MAXIMUM_TEXTURE_STAGES := 4 var _rebuild_required_by_normalized_path: Dictionary = {} ## Returns the first memoized rebuild decision for [param normalized_relative_path]. ## [param raw_m2_data] is borrowed and not mutated. The decision remains cached ## until [method clear], matching the loader map/shutdown reset lifetime. func needs_runtime_mesh_rebuild( normalized_relative_path: String, raw_m2_data: Dictionary) -> bool: if _rebuild_required_by_normalized_path.has(normalized_relative_path): return bool(_rebuild_required_by_normalized_path[normalized_relative_path]) var rebuild_required := ( _raw_data_has_billboards(raw_m2_data) or _raw_data_has_uv_rotation(raw_m2_data) ) _rebuild_required_by_normalized_path[normalized_relative_path] = rebuild_required return rebuild_required ## Clears every memoized path decision. No Mesh or resource is retained or freed. func clear() -> void: _rebuild_required_by_normalized_path.clear() ## Returns the number of memoized normalized paths. func cached_path_count() -> int: return _rebuild_required_by_normalized_path.size() ## Returns a detached normalized-path to boolean decision snapshot. func diagnostic_snapshot() -> Dictionary: return _rebuild_required_by_normalized_path.duplicate() func _raw_data_has_billboards(raw_m2_data: Dictionary) -> bool: for batch_variant in raw_m2_data.get("batches", []): if not (batch_variant is Dictionary): continue var batch: Dictionary = batch_variant if ( bool(batch.get("has_billboard", false)) or int(batch.get("billboard_vertex_count", 0)) > 0 ): return true return false func _raw_data_has_uv_rotation(raw_m2_data: Dictionary) -> bool: var texture_transforms: Array = raw_m2_data.get("texture_transforms", []) if texture_transforms.is_empty(): return false var texture_transform_combos: PackedInt32Array = raw_m2_data.get( "texture_transform_combos", PackedInt32Array() ) if texture_transform_combos.is_empty(): return false for batch_variant in raw_m2_data.get("batches", []): if not (batch_variant is Dictionary): continue var batch: Dictionary = batch_variant var texture_count := maxi(1, int(batch.get("texture_count", 1))) var base_combo_index := int(batch.get("texture_transform_combo_index", -1)) for texture_stage in mini(texture_count, MAXIMUM_TEXTURE_STAGES): var combo_index := base_combo_index + texture_stage if combo_index < 0 or combo_index >= texture_transform_combos.size(): continue var transform_index := int(texture_transform_combos[combo_index]) if transform_index < 0 or transform_index >= texture_transforms.size(): continue var texture_transform: Dictionary = ( texture_transforms[transform_index] if texture_transforms[transform_index] is Dictionary else {} ) var rotation: Vector4 = texture_transform.get( "rotation", Vector4(1.0, 0.0, 0.0, 1.0) ) if not rotation.is_equal_approx(Vector4(1.0, 0.0, 0.0, 1.0)): return true if absf(float(texture_transform.get("rotation_speed", 0.0))) > UV_ROTATION_SPEED_EPSILON: return true return false