extends SceneTree ## Builds a lightweight WMO render cache for runtime streaming. ## ## Usage: ## godot --headless --path --script res://src/tools/build_wmo_streaming_cache.gd -- \ ## --map Azeroth --extracted res://data/extracted --output res://data/cache/wmo_render_v1 --force const WMO_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/wmo_builder.gd") const WMO_STREAMING_SCRIPT := preload("res://src/resources/wmo_streaming_resource.gd") func _initialize() -> void: var args := OS.get_cmdline_user_args() var map_name := _arg(args, "--map", "Azeroth") var extracted := _res(_arg(args, "--extracted", "res://data/extracted")) var output_dir := _res(_arg(args, "--output", "res://data/cache/wmo_render_v1")) var force := args.has("--force") if not ClassDB.class_exists("ADTLoader") or not ClassDB.class_exists("WMOLoader"): push_error("GDExtension not loaded. Rebuild first.") quit(1) return DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(output_dir)) var unique := _collect_unique_wmos(map_name, extracted) if unique.is_empty(): push_error("No WMO references found for map: %s" % map_name) quit(1) return var wmo_loader = ClassDB.instantiate("WMOLoader") var baked := 0 var skipped := 0 var failed := 0 var total := unique.size() var index := 0 var started_ms := Time.get_ticks_msec() for rel_path_variant in unique.keys(): index += 1 var rel_path := String(rel_path_variant) var out_path := _get_wmo_render_path(output_dir, rel_path) if not force and ResourceLoader.exists(out_path): skipped += 1 continue var abs_wmo := ProjectSettings.globalize_path(extracted.path_join(rel_path)) if not FileAccess.file_exists(abs_wmo): failed += 1 continue var data: Dictionary = wmo_loader.call("load_wmo", abs_wmo) if data.is_empty(): failed += 1 continue var node: Node3D = WMO_BUILDER_SCRIPT.build(data, extracted) if node == null: failed += 1 continue var render_resource: Resource = WMO_STREAMING_SCRIPT.new() render_resource.set("format_version", WMO_STREAMING_SCRIPT.FORMAT_VERSION) render_resource.set("source_path", rel_path) _collect_render_payload(node, render_resource) node.free() if (render_resource.get("meshes") as Array).is_empty() and (render_resource.get("multimeshes") as Array).is_empty(): failed += 1 continue DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(out_path.get_base_dir())) var err := ResourceSaver.save(render_resource, out_path) if err != OK: push_warning("save failed for %s: %d" % [out_path, err]) failed += 1 continue baked += 1 if index % 10 == 0 or index == total: print("[%d/%d] baked=%d skipped=%d failed=%d" % [index, total, baked, skipped, failed]) var elapsed_ms := Time.get_ticks_msec() - started_ms print("WMO render cache finished. baked=%d skipped=%d failed=%d elapsed=%.2fs" % [ baked, skipped, failed, float(elapsed_ms) / 1000.0, ]) WMO_BUILDER_SCRIPT.clear_caches() quit(0 if failed == 0 else 2) func _collect_unique_wmos(map_name: String, extracted: String) -> Dictionary: var unique: Dictionary = {} var adt_loader = ClassDB.instantiate("ADTLoader") var map_dir := extracted.path_join("World/Maps/%s" % map_name) var dir := DirAccess.open(map_dir) if dir == null: push_error("Cannot open map dir: %s" % map_dir) return unique print("Scanning ADT tiles...") var scanned := 0 for file_name in dir.get_files(): if not file_name.ends_with(".adt"): continue var parts := file_name.trim_suffix(".adt").split("_") if parts.size() != 3 or parts[0] != map_name: continue var data: Dictionary = adt_loader.call("load_adt", ProjectSettings.globalize_path(map_dir.path_join(file_name))) if data.is_empty(): continue for rel in data.get("wmo_names", PackedStringArray()): var normalized := str(rel).replace("\\", "/").to_lower() if not normalized.is_empty(): unique[normalized] = true scanned += 1 print("Found %d unique WMO models from %d tiles." % [unique.size(), scanned]) return unique func _collect_render_payload( node: Node, render_resource: Resource, parent_transform: Transform3D = Transform3D.IDENTITY) -> void: if node.name == "Occluders": return var node_transform := parent_transform if node is Node3D: node_transform = parent_transform * (node as Node3D).transform if node is MeshInstance3D: var mesh_instance := node as MeshInstance3D if mesh_instance.mesh != null: var names: PackedStringArray = render_resource.get("mesh_names") var meshes: Array = render_resource.get("meshes") var transforms: Array = render_resource.get("mesh_transforms") names.append(mesh_instance.name) meshes.append(mesh_instance.mesh) transforms.append(node_transform) render_resource.set("mesh_names", names) render_resource.set("meshes", meshes) render_resource.set("mesh_transforms", transforms) elif node is MultiMeshInstance3D: var multimesh_instance := node as MultiMeshInstance3D if multimesh_instance.multimesh != null: var names: PackedStringArray = render_resource.get("multimesh_names") var multimeshes: Array = render_resource.get("multimeshes") var transforms: Array = render_resource.get("multimesh_transforms") names.append(multimesh_instance.name) multimeshes.append(multimesh_instance.multimesh) transforms.append(node_transform) render_resource.set("multimesh_names", names) render_resource.set("multimeshes", multimeshes) render_resource.set("multimesh_transforms", transforms) for child in node.get_children(): _collect_render_payload(child, render_resource, node_transform) func _get_wmo_render_path(output_dir: String, rel_path: String) -> String: var normalized := rel_path.replace("\\", "/") return output_dir.path_join(normalized.get_basename() + ".res") func _arg(args: PackedStringArray, name: String, default: String) -> String: var idx := args.find(name) if idx >= 0 and idx + 1 < args.size(): return args[idx + 1] return default func _res(path: String) -> String: if path.begins_with("res://") or path.begins_with("user://"): return path return "res://" + path.trim_prefix("./").trim_prefix("/")