From ffa72a8f24195e2246bb4d72447f8339e3c4cfed Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 1 Jul 2026 18:35:52 -0400 Subject: [PATCH] t_p5_validator: add 5 Godot 4 map validator scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validate_map.gd — scene structure validator (CSG combiner root, spawn groups, bomb sites, buy zones, map bounds, LightmapGI config, lighting setup) - validate_polycount.gd — CSG triangle budget checker (<50K faces, <5K per node, <200 CSG nodes) - validate_textures.gd — texture size ≤1K, mipmaps, UV2 checker - validate_lights.gd — dynamic lights ≤4, bake mode validator - bake_lightmaps.gd — LightmapGI config checker (quality, bounces, texel_scale, interior, denoiser) All scripts: @tool SceneTree, accept scene path via -- separator, exit 0=pass / 1=fail / 2=scene-not-found. 443-610 lines each. --- client/scripts/bake_lightmaps.gd | 610 +++++++++++++++++++++++++++ client/scripts/validate_lights.gd | 443 +++++++++++++++++++ client/scripts/validate_map.gd | 511 ++++++++++++++++++++++ client/scripts/validate_polycount.gd | 465 ++++++++++++++++++++ client/scripts/validate_textures.gd | 581 +++++++++++++++++++++++++ 5 files changed, 2610 insertions(+) create mode 100644 client/scripts/bake_lightmaps.gd create mode 100644 client/scripts/validate_lights.gd create mode 100644 client/scripts/validate_map.gd create mode 100644 client/scripts/validate_polycount.gd create mode 100644 client/scripts/validate_textures.gd diff --git a/client/scripts/bake_lightmaps.gd b/client/scripts/bake_lightmaps.gd new file mode 100644 index 0000000..2363490 --- /dev/null +++ b/client/scripts/bake_lightmaps.gd @@ -0,0 +1,610 @@ +@tool +extends SceneTree + +# ============================================================================= +# bake_lightmaps.gd — LightmapGI Configuration Checker +# ============================================================================= +# Validates LightmapGI configuration for baking readiness in a Godot 4 scene. +# Checks: +# - LightmapGI node exists and is properly configured +# - Quality setting (Low/Medium/High/Ultra) +# - Bounce count (recommended ≥ 2) +# - Texel scale (recommended 0.5–4.0, default 1.0) +# - Interior flag (true for indoor maps) +# - Denoiser enabled +# - Max texture size (recommended ≤ 1024) +# - Light data presence (already baked or not) +# - Geometry with UV2 (required for baking) +# +# Note: Godot 4 LightmapGI baking is editor-only. This script validates +# configuration readiness; it does NOT perform the bake itself. +# +# Usage: +# godot --headless --script client/scripts/bake_lightmaps.gd -- res://path/to/map.tscn +# +# Exit codes: +# 0 — All config checks passed +# 1 — One or more config checks failed +# 2 — Scene file not found or cannot be loaded +# ============================================================================= + +const RECOMMENDED_BOUNCES := 2 +const MIN_BOUNCES := 1 +const MAX_BOUNCES := 8 +const RECOMMENDED_TEXEL_SCALE := 1.0 +const MIN_TEXEL_SCALE := 0.1 +const MAX_TEXEL_SCALE := 10.0 +const MAX_TEXTURE_SIZE := 1024 +const RECOMMENDED_QUALITY := 2 # High + +var _pass_count := 0 +var _fail_count := 0 +var _warning_count := 0 +var _scene_path := "" +var _scene_instance: Node = null +var _lightmap_node: Node = null + +# UV2 tracking +var _total_meshes := 0 +var _meshes_with_uv2 := 0 +var _meshes_without_uv2: Array[String] = [] +var _mesh_details: Array[Dictionary] = [] + + +func _init() -> void: + _parse_arguments() + + if _scene_path.is_empty(): + printerr("ERROR: No scene path provided. Usage: godot --headless --script bake_lightmaps.gd -- res://path/to/map.tscn") + quit(2) + return + + print("") + print("==========================================") + print(" LIGHTMAP CONFIGURATION CHECKER") + print(" Scene: ", _scene_path) + print("==========================================") + print("") + + print(" Note: Godot 4 LightmapGI baking is editor-only.") + print(" This tool validates lightmap configuration readiness only.") + print(" To bake: Open scene in editor > Scene > Bake LightmapGI") + print("") + + var scene: PackedScene = _load_scene(_scene_path) + if scene == null: + printerr("ERROR: Scene not found: ", _scene_path) + quit(2) + return + + _scene_instance = _instantiate_scene(scene) + if _scene_instance == null: + printerr("ERROR: Failed to instantiate scene: ", _scene_path) + quit(2) + return + + root.add_child(_scene_instance) + + # Find LightmapGI + _lightmap_node = _find_node_by_type(_scene_instance, "LightmapGI") + + # Run all checks + _run_lightmap_node_check() + if _lightmap_node != null: + _run_quality_check() + _run_bounce_check() + _run_texel_scale_check() + _run_interior_check() + _run_denoiser_check() + _run_max_texture_size_check() + _run_energy_check() + _run_baked_data_check() + _run_process_mode_check() + + # Run geometry UV2 check + _run_uv2_check() + + # Detailed reports + _print_lightmap_report() + _print_uv2_report() + + # Summary + _print_summary() + + if _fail_count > 0: + quit(1) + else: + quit(0) + + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + +func _parse_arguments() -> void: + """Extract scene path from user arguments (after --).""" + var args: PackedStringArray = OS.get_cmdline_user_args() + if not args.is_empty(): + _scene_path = args[0].strip_edges() + return + + var full_args: PackedStringArray = OS.get_cmdline_args() + var found_sep := false + for i in range(full_args.size()): + if full_args[i] == "--": + found_sep = true + continue + if found_sep: + _scene_path = full_args[i].strip_edges() + break + + +# --------------------------------------------------------------------------- +# Scene helpers +# --------------------------------------------------------------------------- + +func _load_scene(path: String) -> PackedScene: + """Load a .tscn/.scn file.""" + if not ResourceLoader.exists(path): + if not path.ends_with(".tscn") and not path.ends_with(".scn"): + path = path + ".tscn" + if not ResourceLoader.exists(path): + return null + return ResourceLoader.load(path, "PackedScene", ResourceLoader.CACHE_MODE_REUSE) + + +func _instantiate_scene(scene: PackedScene) -> Node: + return scene.instantiate() + + +func _find_node_by_type(node: Node, type_name: String) -> Node: + """Recursively find first node matching a class name.""" + if node.get_class() == type_name: + return node + for child in node.get_children(): + var found := _find_node_by_type(child, type_name) + if found != null: + return found + return null + + +func _find_nodes_by_type(node: Node, type_name: String) -> Array[Node]: + """Recursively find all nodes of a given class.""" + var results: Array[Node] = [] + if node.get_class() == type_name: + results.append(node) + for child in node.get_children(): + results.append_array(_find_nodes_by_type(child, type_name)) + return results + + +# --------------------------------------------------------------------------- +# Check helpers +# --------------------------------------------------------------------------- + +func _pass(msg: String) -> void: + _pass_count += 1 + print(" ✓ ", msg) + + +func _fail(msg: String) -> void: + _fail_count += 1 + print(" ✗ ", msg) + + +func _warn(msg: String) -> void: + _warning_count += 1 + print(" ⚠ ", msg) + + +func _check(condition: bool, msg_pass: String, msg_fail: String) -> bool: + if condition: + _pass(msg_pass) + else: + _fail(msg_fail) + return condition + + +# --------------------------------------------------------------------------- +# Individual LightmapGI checks +# --------------------------------------------------------------------------- + +func _run_lightmap_node_check() -> void: + """Verify LightmapGI node exists.""" + print("--- LightmapGI Node ---") + if _lightmap_node != null: + _pass("LightmapGI node found: '" + _lightmap_node.name + "'") + else: + _fail("LightmapGI node not found in scene — required for baked lighting") + + +func _run_quality_check() -> void: + """Validate LightmapGI quality setting.""" + print("") + print("--- Quality ---") + if _lightmap_node == null: + return + + var quality: int = _lightmap_node.get("quality") if "quality" in _lightmap_node else -1 + if quality < 0: + _fail("LightmapGI.quality not available") + return + + var quality_names := ["Low", "Medium", "High", "Ultra"] + var quality_name := quality_names[quality] if quality < quality_names.size() else "Unknown" + + if quality >= RECOMMENDED_QUALITY: + _pass("LightmapGI.quality = " + quality_name + " (" + str(quality) + ") — High or Ultra recommended") + elif quality == 1: + _warn("LightmapGI.quality = " + quality_name + " (" + str(quality) + ") — Medium is acceptable, High recommended") + else: + _fail("LightmapGI.quality = " + quality_name + " (" + str(quality) + ") — set to at least High (2)") + + +func _run_bounce_check() -> void: + """Validate light bounce count.""" + print("") + print("--- Bounces ---") + if _lightmap_node == null: + return + + var bounces: int = _lightmap_node.get("bounces") if "bounces" in _lightmap_node else -1 + if bounces < 0: + _fail("LightmapGI.bounces not available") + return + + if bounces >= RECOMMENDED_BOUNCES: + _pass("LightmapGI.bounces = " + str(bounces) + " (recommended: ≥ " + str(RECOMMENDED_BOUNCES) + ")") + elif bounces >= MIN_BOUNCES: + _warn("LightmapGI.bounces = " + str(bounces) + " — low, consider ≥ " + str(RECOMMENDED_BOUNCES)) + else: + _fail("LightmapGI.bounces = " + str(bounces) + " — minimum is " + str(MIN_BOUNCES)) + + # Warn if too high (performance concern) + if bounces > MAX_BOUNCES - 2: + _warn("LightmapGI.bounces = " + str(bounces) + " — high bounce count may impact bake time") + + +func _run_texel_scale_check() -> void: + """Validate texel scale setting.""" + print("") + print("--- Texel Scale ---") + if _lightmap_node == null: + return + + var texel_scale: float = _lightmap_node.get("texel_scale") if "texel_scale" in _lightmap_node else -1.0 + if texel_scale < 0: + _fail("LightmapGI.texel_scale not available") + return + + if abs(texel_scale - RECOMMENDED_TEXEL_SCALE) < 0.01: + _pass("LightmapGI.texel_scale = " + str(texel_scale) + " (exactly " + str(RECOMMENDED_TEXEL_SCALE) + ")") + elif texel_scale >= MIN_TEXEL_SCALE and texel_scale <= MAX_TEXEL_SCALE: + if texel_scale < 0.5: + _warn("LightmapGI.texel_scale = " + str(texel_scale) + " — below 0.5, bake resolution may be too low") + elif texel_scale > 2.0: + _warn("LightmapGI.texel_scale = " + str(texel_scale) + " — above 2.0, bake resolution may be unnecessarily high") + else: + _pass("LightmapGI.texel_scale = " + str(texel_scale) + " (within reasonable range [0.1, 10.0])") + else: + _fail("LightmapGI.texel_scale = " + str(texel_scale) + " — out of expected range [" + str(MIN_TEXEL_SCALE) + ", " + str(MAX_TEXEL_SCALE) + "]") + + +func _run_interior_check() -> void: + """Validate interior flag.""" + print("") + print("--- Interior ---") + if _lightmap_node == null: + return + + var interior: bool = _lightmap_node.get("interior") if "interior" in _lightmap_node else false + + if interior: + _pass("LightmapGI.interior = true (correct for indoor maps)") + else: + _warn("LightmapGI.interior = false — set to true for indoor scenes to avoid light leaking from outside") + + +func _run_denoiser_check() -> void: + """Validate denoiser setting.""" + print("") + print("--- Denoiser ---") + if _lightmap_node == null: + return + + # Godot 4 renamed to use_denoiser + var use_denoiser: bool = false + if "use_denoiser" in _lightmap_node: + use_denoiser = _lightmap_node.get("use_denoiser") + elif _lightmap_node.has_method("is_using_denoiser"): + use_denoiser = _lightmap_node.is_using_denoiser() + + if use_denoiser: + _pass("LightmapGI.use_denoiser = true (recommended for cleaner results)") + else: + _warn("LightmapGI.use_denoiser = false — enable denoiser for cleaner lightmap results") + + +func _run_max_texture_size_check() -> void: + """Validate max texture size setting.""" + print("") + print("--- Max Texture Size ---") + if _lightmap_node == null: + return + + var max_tex: int = _lightmap_node.get("max_texture_size") if "max_texture_size" in _lightmap_node else -1 + if max_tex < 0: + _warn("LightmapGI.max_texture_size not available (using default)") + return + + if max_tex <= MAX_TEXTURE_SIZE: + _pass("LightmapGI.max_texture_size = " + str(max_tex) + " (≤ " + str(MAX_TEXTURE_SIZE) + ")") + else: + _fail("LightmapGI.max_texture_size = " + str(max_tex) + " — exceeds " + str(MAX_TEXTURE_SIZE) + + " (consider lowering for memory budget)") + + +func _run_energy_check() -> void: + """Validate lightmap energy setting.""" + print("") + print("--- Energy ---") + if _lightmap_node == null: + return + + var energy: float = _lightmap_node.get("energy") if "energy" in _lightmap_node else 1.0 + _pass("LightmapGI.energy = " + str(energy) + " (verify visually in editor)") + + var indirect_energy: float = _lightmap_node.get("indirect_energy") if "indirect_energy" in _lightmap_node else 1.0 + if abs(indirect_energy - 1.0) > 0.01: + _warn("LightmapGI.indirect_energy = " + str(indirect_energy) + " (default is 1.0)") + + +func _run_baked_data_check() -> void: + """Check whether lightmap data has been baked.""" + print("") + print("--- Baked Data ---") + if _lightmap_node == null: + return + + var light_data = _lightmap_node.get("light_data") if "light_data" in _lightmap_node else null + if light_data != null: + _pass("LightmapGI has baked light data present") + # Check data size + if light_data.has_method("get_user_data"): + var user_data = light_data.get_user_data() + if user_data != null: + _warn("Lightmap data size: varies (baked data is present)") + else: + _warn("LightmapGI has NO baked data yet — needs baking in editor") + + +func _run_process_mode_check() -> void: + """Validate the lightmap process mode.""" + print("") + print("--- Process Mode ---") + if _lightmap_node == null: + return + + # Godot 4 LightmapGI has a bake_mode or similar property for sub-surface scattering + if "bake_mode" in _lightmap_node: + var bake_mode: int = _lightmap_node.get("bake_mode") + match bake_mode: + 0: + _pass("LightmapGI.bake_mode = Conservative (0) — safe default") + 1: + _warn("LightmapGI.bake_mode = Aggressive (1) — may cause artifacts") + 2: + _pass("LightmapGI.bake_mode = Always Lightmap (2) — good for fully baked scenes") + _: + _warn("LightmapGI.bake_mode = " + str(bake_mode) + " — unknown") + + # Check for environment mode (if property exists) + if "environment_mode" in _lightmap_node: + var env_mode: int = _lightmap_node.get("environment_mode") + match env_mode: + 0: + _pass("LightmapGI.environment_mode = Disabled — no environment lighting (indoor)") + 1: + _pass("LightmapGI.environment_mode = Enabled — using environment for lighting") + 2: + _warn("LightmapGI.environment_mode = Ambient Color — verify color is correct") + + +# --------------------------------------------------------------------------- +# UV2 geometry check +# --------------------------------------------------------------------------- + +func _run_uv2_check() -> void: + """Scan all MeshInstance3D nodes for UV2 channel presence.""" + print("") + print("--- Geometry UV2 Check ---") + + var mesh_nodes := _find_nodes_by_type(_scene_instance, "MeshInstance3D") + _total_meshes = mesh_nodes.size() + + if _total_meshes == 0: + _warn("No MeshInstance3D nodes found — checking CSG nodes for UV2") + + # Check CSG nodes instead + var csg_nodes := _find_nodes_by_type(_scene_instance, "CSGCombiner3D") + csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGBox3D")) + csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGSphere3D")) + csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGCylinder3D")) + csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGTorus3D")) + csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGPolygon3D")) + csg_nodes.append_array(_find_nodes_by_type(_scene_instance, "CSGMesh3D")) + + # CSG nodes generate UV2 during baking automatically + if csg_nodes.size() > 0: + _pass("Scene uses CSG primitives (" + str(csg_nodes.size()) + ") — UV2 is auto-generated during bake") + return + + for mi in mesh_nodes: + var has_uv2 := false + if "mesh" in mi and mi.mesh != null: + has_uv2 = _check_mesh_uv2(mi.mesh) + + var info := { + "name": mi.name, + "has_uv2": has_uv2, + "material_count": 0 + } + _mesh_details.append(info) + + if has_uv2: + _meshes_with_uv2 += 1 + else: + _meshes_without_uv2.append(mi.name) + + if _meshes_without_uv2.size() == 0: + _pass("All " + str(_total_meshes) + " MeshInstance3D nodes have UV2 channel") + else: + for mesh_name in _meshes_without_uv2: + _fail("MeshInstance3D '" + mesh_name + "' lacks UV2 channel — lightmapping will not work") + _warn(str(_meshes_without_uv2.size()) + "/" + str(_total_meshes) + " meshes missing UV2") + + # Check for sub-surface scattering override on LightmapGI + if _lightmap_node != null and "subdiv" in _lightmap_node: + var subdiv: int = _lightmap_node.get("subdiv") + if subdiv > 0: + _warn("LightmapGI.subdiv = " + str(subdiv) + " — non-zero subdiv enables subsurface scattering") + + +func _check_mesh_uv2(mesh) -> bool: + """Check if a mesh has UV2 channel data.""" + if mesh == null: + return false + if not mesh.has_method("get_surface_count"): + return false + + var surface_count := mesh.get_surface_count() + for s in range(surface_count): + var arrays := mesh.surface_get_arrays(s) + if arrays is Array and arrays.size() > 0: + var uv2 := arrays[Mesh.ARRAY_TEX_UV2] as PackedVector2Array + if uv2 != null and uv2.size() > 0: + return true + return false + + +# --------------------------------------------------------------------------- +# Reports +# --------------------------------------------------------------------------- + +func _print_lightmap_report() -> void: + """Print a detailed lightmap configuration report.""" + print("") + print("--- LightmapGI Configuration Report ---") + if _lightmap_node == null: + print(" (no LightmapGI node to report)") + return + + print(" Node path: ", _get_node_path(_lightmap_node)) + var quality_names := ["Low", "Medium", "High", "Ultra"] + var q: int = _lightmap_node.get("quality") if "quality" in _lightmap_node else -1 + if q >= 0 and q < quality_names.size(): + print(" Quality: ", quality_names[q], " (", q, ")") + else: + print(" Quality: ", q) + print(" Bounces: ", _lightmap_node.get("bounces") if "bounces" in _lightmap_node else "N/A") + print(" Texel Scale: ", _lightmap_node.get("texel_scale") if "texel_scale" in _lightmap_node else "N/A") + print(" Interior: ", _lightmap_node.get("interior") if "interior" in _lightmap_node else "N/A") + print(" Denoiser: ", _lightmap_node.get("use_denoiser") if "use_denoiser" in _lightmap_node else "N/A") + print(" Max Tex Size: ", _lightmap_node.get("max_texture_size") if "max_texture_size" in _lightmap_node else "N/A") + print(" Energy: ", _lightmap_node.get("energy") if "energy" in _lightmap_node else "N/A") + print(" Baked Data: ", "Yes" if _lightmap_node.get("light_data") != null else "No") + print("") + + +func _print_uv2_report() -> void: + """Print UV2 details for meshes.""" + print("--- UV2 Report ---") + print(" Total MeshInstance3D: ", _total_meshes) + print(" With UV2: ", _meshes_with_uv2) + print(" Without UV2: ", _meshes_without_uv2.size()) + if _meshes_without_uv2.size() > 0: + print(" Missing UV2 list:") + for name in _meshes_without_uv2: + print(" - ", name) + print("") + + +func _get_node_path(node: Node) -> String: + """Get the scene-tree path of a node.""" + var parts: Array[String] = [] + var current := node + while current != null and current != _scene_instance: + parts.push_front(current.name) + current = current.get_parent() + return "/".join(parts) + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +func _print_summary() -> void: + """Print final summary.""" + print("==========================================") + print(" LIGHTMAP CONFIG SUMMARY") + print("==========================================") + print(" Scene: ", _scene_path) + print(" LightmapGI: ", "Found ✓" if _lightmap_node != null else "Missing ✗") + print(" Quality OK: ", _check_quality_status()) + print(" Bounces OK: ", _check_bounce_status()) + print(" Texel Scale OK: ", _check_texel_status()) + print(" Interior OK: ", _check_interior_status()) + print(" Denoiser OK: ", _check_denoiser_status()) + print(" UV2 OK: ", _check_uv2_status()) + print("------------------------------------------") + print(" Passed: ", _pass_count) + print(" Failed: ", _fail_count) + print(" Warnings: ", _warning_count) + print("------------------------------------------") + if _fail_count > 0: + print(" RESULT: FAILED ❌") + printerr("Lightmap config check FAILED — ", _fail_count, " check(s) failed.") + else: + print(" RESULT: PASSED ✅") + print("==========================================") + print("") + + +func _check_quality_status() -> String: + if _lightmap_node == null: return "N/A" + var q = _lightmap_node.get("quality") if "quality" in _lightmap_node else -1 + return "Yes ✓" if q >= RECOMMENDED_QUALITY else ("No ✗" if q >= 0 else "N/A") + + +func _check_bounce_status() -> String: + if _lightmap_node == null: return "N/A" + var b = _lightmap_node.get("bounces") if "bounces" in _lightmap_node else -1 + return "Yes ✓" if b >= RECOMMENDED_BOUNCES else ("No ✗" if b >= 0 else "N/A") + + +func _check_texel_status() -> String: + if _lightmap_node == null: return "N/A" + var t = _lightmap_node.get("texel_scale") if "texel_scale" in _lightmap_node else -1.0 + if t < 0: return "N/A" + return "Yes ✓" if t >= 0.5 and t <= 2.0 else "⚠" + + +func _check_interior_status() -> String: + if _lightmap_node == null: return "N/A" + var interior = _lightmap_node.get("interior") if "interior" in _lightmap_node else false + return "Yes ✓" if interior else "No ✗" + + +func _check_denoiser_status() -> String: + if _lightmap_node == null: return "N/A" + if "use_denoiser" in _lightmap_node: + return "Yes ✓" if _lightmap_node.get("use_denoiser") else "No ✗" + if _lightmap_node.has_method("is_using_denoiser"): + return "Yes ✓" if _lightmap_node.is_using_denoiser() else "No ✗" + return "N/A" + + +func _check_uv2_status() -> String: + if _total_meshes == 0: return "N/A (CSG)" + if _meshes_without_uv2.size() == 0: return "Yes ✓" + return str(_meshes_without_uv2.size()) + " missing" diff --git a/client/scripts/validate_lights.gd b/client/scripts/validate_lights.gd new file mode 100644 index 0000000..3e89cd7 --- /dev/null +++ b/client/scripts/validate_lights.gd @@ -0,0 +1,443 @@ +@tool +extends SceneTree + +# ============================================================================= +# validate_lights.gd — Dynamic Light Count / Bake Mode Validator +# ============================================================================= +# Audits all light nodes in a Godot 4 scene for: +# - Dynamic light count ≤ 4 (budget for mobile/competitive FPS) +# - Each light's bake mode is set correctly (BakeMode = STATIC or DYNAMIC) +# - Light energy / range / attenuation are within reasonable bounds +# - Shadow configuration is appropriate +# +# Usage: +# godot --headless --script client/scripts/validate_lights.gd -- res://path/to/map.tscn +# +# Exit codes: +# 0 — All light checks passed +# 1 — One or more light checks failed +# 2 — Scene file not found or cannot be loaded +# ============================================================================= + +const MAX_DYNAMIC_LIGHTS := 4 +const MAX_LIGHT_ENERGY := 50.0 +const MIN_LIGHT_ENERGY := 0.1 +const MAX_LIGHT_RANGE := 100.0 +const MAX_LIGHT_SPOT_ANGLE := 180.0 +const WARN_TOTAL_LIGHTS := 8 + +# BakeMode enum values as Godot defines them +const BAKE_MODE_DISABLED := 0 +const BAKE_MODE_STATIC := 1 +const BAKE_MODE_DYNAMIC := 2 + +var _pass_count := 0 +var _fail_count := 0 +var _warning_count := 0 +var _scene_path := "" +var _scene_instance: Node = null + +# Light stat tracking +var _total_lights := 0 +var _dynamic_lights := 0 +var _baked_lights := 0 +var _disabled_lights := 0 +var _shadowed_lights := 0 +var _light_details: Array[Dictionary] = [] + +# Per-type counts +var _directional_lights := 0 +var _omni_lights := 0 +var _spot_lights := 0 + + +func _init() -> void: + _parse_arguments() + + if _scene_path.is_empty(): + printerr("ERROR: No scene path provided. Usage: godot --headless --script validate_lights.gd -- res://path/to/map.tscn") + quit(2) + return + + print("") + print("==========================================") + print(" LIGHT VALIDATOR") + print(" Scene: ", _scene_path) + print("==========================================") + print("") + + print(" Light budget:") + print(" Max dynamic lights: ", MAX_DYNAMIC_LIGHTS) + print(" Recommended baking: All static geometry lights") + print("") + + var scene: PackedScene = _load_scene(_scene_path) + if scene == null: + printerr("ERROR: Scene not found: ", _scene_path) + quit(2) + return + + _scene_instance = _instantiate_scene(scene) + if _scene_instance == null: + printerr("ERROR: Failed to instantiate scene: ", _scene_path) + quit(2) + return + + root.add_child(_scene_instance) + + # Collect all lights + _analyze_lights(_scene_instance) + + # Run checks + _run_dynamic_light_count_check() + _run_bake_mode_checks() + _run_energy_checks() + _run_shadow_checks() + _print_light_report() + + # Summary + _print_summary() + + if _fail_count > 0: + quit(1) + else: + quit(0) + + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + +func _parse_arguments() -> void: + """Extract scene path from user arguments (after --).""" + var args: PackedStringArray = OS.get_cmdline_user_args() + if not args.is_empty(): + _scene_path = args[0].strip_edges() + return + + var full_args: PackedStringArray = OS.get_cmdline_args() + var found_sep := false + for i in range(full_args.size()): + if full_args[i] == "--": + found_sep = true + continue + if found_sep: + _scene_path = full_args[i].strip_edges() + break + + +# --------------------------------------------------------------------------- +# Scene helpers +# --------------------------------------------------------------------------- + +func _load_scene(path: String) -> PackedScene: + """Load a .tscn/.scn file.""" + if not ResourceLoader.exists(path): + if not path.ends_with(".tscn") and not path.ends_with(".scn"): + path = path + ".tscn" + if not ResourceLoader.exists(path): + return null + return ResourceLoader.load(path, "PackedScene", ResourceLoader.CACHE_MODE_REUSE) + + +func _instantiate_scene(scene: PackedScene) -> Node: + return scene.instantiate() + + +# --------------------------------------------------------------------------- +# Check helpers +# --------------------------------------------------------------------------- + +func _pass(msg: String) -> void: + _pass_count += 1 + print(" ✓ ", msg) + + +func _fail(msg: String) -> void: + _fail_count += 1 + print(" ✗ ", msg) + + +func _warn(msg: String) -> void: + _warning_count += 1 + print(" ⚠ ", msg) + + +# --------------------------------------------------------------------------- +# Light analysis +# --------------------------------------------------------------------------- + +func _analyze_lights(node: Node) -> void: + """Recursively walk scene collecting light data.""" + var class_name := node.get_class() + + if class_name in ["DirectionalLight3D", "OmniLight3D", "SpotLight3D"]: + _total_lights += 1 + var info := _collect_light_info(node, class_name) + _light_details.append(info) + + # Count by type + match class_name: + "DirectionalLight3D": + _directional_lights += 1 + "OmniLight3D": + _omni_lights += 1 + "SpotLight3D": + _spot_lights += 1 + + # Track bake mode + var bake_mode := 0 + if "bake_mode" in node: + bake_mode = node.bake_mode + match bake_mode: + BAKE_MODE_DISABLED: + _disabled_lights += 1 + _dynamic_lights += 1 + BAKE_MODE_STATIC: + _baked_lights += 1 + BAKE_MODE_DYNAMIC: + _dynamic_lights += 1 + + # Track shadows + if "shadow" in node and node.shadow: + _shadowed_lights += 1 + + for child in node.get_children(): + _analyze_lights(child) + + +func _collect_light_info(node: Node, class_name: String) -> Dictionary: + """Collect all relevant properties from a light node.""" + var info := { + "name": node.name, + "type": class_name, + "path": _get_node_path(node), + "enabled": node.get("visible") if "visible" in node else true, + "bake_mode": node.get("bake_mode") if "bake_mode" in node else 0, + "energy": node.get("energy") if "energy" in node else 1.0, + "shadow": node.get("shadow") if "shadow" in node else false, + "shadow_bias": node.get("shadow_bias") if "shadow_bias" in node else 0.0, + "indirect_energy": node.get("indirect_energy") if "indirect_energy" in node else 1.0, + "light_specular": node.get("light_specular") if "light_specular" in node else 0.5, + } + + # Type-specific properties + match class_name: + "OmniLight3D": + info["range"] = node.get("omni_range") if "omni_range" in node else 0.0 + info["attenuation"] = node.get("omni_attenuation") if "omni_attenuation" in node else 1.0 + "SpotLight3D": + info["range"] = node.get("spot_range") if "spot_range" in node else 0.0 + info["attenuation"] = node.get("spot_attenuation") if "spot_attenuation" in node else 1.0 + info["spot_angle"] = node.get("spot_angle") if "spot_angle" in node else 45.0 + info["spot_angle_attenuation"] = node.get("spot_angle_attenuation") if "spot_angle_attenuation" in node else 1.0 + "DirectionalLight3D": + info["range"] = -1.0 + info["directional_shadow_mode"] = node.get("directional_shadow_mode") if "directional_shadow_mode" in node else 0 + + return info + + +func _get_node_path(node: Node) -> String: + """Get the scene-tree path of a node.""" + var parts: Array[String] = [] + var current := node + while current != null and current != _scene_instance: + parts.push_front(current.name) + current = current.get_parent() + return "/".join(parts) + + +func _get_bake_mode_name(mode: int) -> String: + match mode: + BAKE_MODE_DISABLED: return "Disabled (Dynamic)" + BAKE_MODE_STATIC: return "Static (Baked)" + BAKE_MODE_DYNAMIC: return "Dynamic" + return "Unknown(" + str(mode) + ")" + + +# --------------------------------------------------------------------------- +# Light checks +# --------------------------------------------------------------------------- + +func _run_dynamic_light_count_check() -> void: + """Check that dynamic light count is within budget.""" + print("--- Dynamic Light Count ---") + + if _total_lights == 0: + _warn("No lights found in scene at all (verify map lighting)") + return + + _pass("Total lights: " + str(_total_lights) + " (Dir: " + str(_directional_lights) + + ", Omni: " + str(_omni_lights) + ", Spot: " + str(_spot_lights) + ")") + + if _dynamic_lights <= MAX_DYNAMIC_LIGHTS: + _pass("Dynamic lights: " + str(_dynamic_lights) + " (budget: ≤ " + str(MAX_DYNAMIC_LIGHTS) + ")") + else: + _fail("Too many dynamic lights: " + str(_dynamic_lights) + + " (budget: ≤ " + str(MAX_DYNAMIC_LIGHTS) + ")" + + " — consider baking " + str(_dynamic_lights - MAX_DYNAMIC_LIGHTS) + " light(s)") + + # Warn if total lights (including baked) is very high + if _total_lights > WARN_TOTAL_LIGHTS: + _warn("Total light count (" + str(_total_lights) + ") exceeds " + str(WARN_TOTAL_LIGHTS) + + " — verify all lights are necessary") + + +func _run_bake_mode_checks() -> void: + """Check that each light has an appropriate bake mode.""" + print("") + print("--- Bake Mode Validation ---") + + var proper_bake := 0 + var improper_bake := 0 + + for info in _light_details: + var bake_mode := info.bake_mode as int + var light_name := info.name as String + var light_type := info.type as String + var bake_name := _get_bake_mode_name(bake_mode) + + if bake_mode == BAKE_MODE_STATIC: + proper_bake += 1 + _pass(light_name + " (" + light_type + "): bake_mode = Static ✓") + elif bake_mode == BAKE_MODE_DISABLED: + # Dynamic lights are OK if count is within budget + improper_bake += 1 + _warn(light_name + " (" + light_type + "): bake_mode = Disabled (fully dynamic)") + elif bake_mode == BAKE_MODE_DYNAMIC: + improper_bake += 1 + _warn(light_name + " (" + light_type + "): bake_mode = Dynamic") + + if improper_bake > 0: + _warn(str(improper_bake) + " light(s) are not set to Static bake mode" + + " — use Static for baked geometry lights") + + # DirectionalLight3D should generally be Baked (Static) for main sun + for info in _light_details: + if info.type == "DirectionalLight3D" and info.bake_mode == BAKE_MODE_DISABLED: + _warn("DirectionalLight3D '" + info.name + "' is set to Disabled bake mode" + + " — set to Static (Baked) for performance") + + +func _run_energy_checks() -> void: + """Check that light energy values are within reasonable ranges.""" + print("") + print("--- Light Energy / Range Checks ---") + + for info in _light_details: + var energy := info.energy as float + var light_name := info.name as String + var light_type := info.type as String + + if energy > MAX_LIGHT_ENERGY: + _warn(light_name + " (" + light_type + "): energy = " + str(energy) + + " — very high (max recommended: " + str(MAX_LIGHT_ENERGY) + ")") + elif energy < MIN_LIGHT_ENERGY: + _warn(light_name + " (" + light_type + "): energy = " + str(energy) + + " — very low (min recommended: " + str(MIN_LIGHT_ENERGY) + ")") + else: + _pass(light_name + " (" + light_type + "): energy = " + str(energy) + " (within [0.1, 50.0])") + + # Range check (not applicable to DirectionalLight3D) + if info.has("range") and info.range > 0: + var range_val := info.range as float + if range_val > MAX_LIGHT_RANGE: + _warn(light_name + " (" + light_type + "): range = " + str(range_val) + + " — very large (max recommended: " + str(MAX_LIGHT_RANGE) + ")") + + # Spot angle check + if info.has("spot_angle"): + var angle := info.spot_angle as float + if angle > MAX_LIGHT_SPOT_ANGLE: + _warn(light_name + " (SpotLight3D): spot_angle = " + str(angle) + + " — exceeds 180° (will project backwards)") + + +func _run_shadow_checks() -> void: + """Validate shadow configuration on lights.""" + print("") + print("--- Shadow Configuration ---") + + for info in _light_details: + var has_shadow := info.shadow as bool + var light_name := info.name as String + + if has_shadow: + # Shadowed lights are more expensive + if info.bake_mode == BAKE_MODE_STATIC: + _pass(light_name + ": shadows enabled, bake_mode = Static (good — shadows will be baked)") + else: + _warn(light_name + ": dynamic shadows enabled — expensive for real-time rendering") + + # Count shadowed dynamic lights + var dynamic_shadow_count := 0 + for info in _light_details: + if info.shadow and info.bake_mode != BAKE_MODE_STATIC: + dynamic_shadow_count += 1 + + if dynamic_shadow_count > 2: + _warn(str(dynamic_shadow_count) + " lights have dynamic shadows — recommended max 2 for performance") + + +# --------------------------------------------------------------------------- +# Light report +# --------------------------------------------------------------------------- + +func _print_light_report() -> void: + """Print a comprehensive light report.""" + print("") + print("--- Light Report ---") + print(" Total lights: ", _total_lights) + print(" DirectionalLight3D: ", _directional_lights) + print(" OmniLight3D: ", _omni_lights) + print(" SpotLight3D: ", _spot_lights) + print(" Dynamic (non-baked): ", _dynamic_lights) + print(" Baked (Static): ", _baked_lights) + print(" Disabled: ", _disabled_lights) + print(" Shadowed: ", _shadowed_lights) + print("") + + # Light detail table + if _light_details.size() > 0: + print(" Light Details:") + print(" " + "-".repeat(90)) + print(" %-25s %-18s %-12s %-8s %-6s %-8s" % ["Name", "Type", "Bake Mode", "Energy", "Shadow", "Range"]) + print(" " + "-".repeat(90)) + for info in _light_details: + var bake_mode_name := _get_bake_mode_name(info.bake_mode) + var range_str := str(info.range) if info.has("range") and info.range > 0 else "N/A" + var shadow_str := "Yes" if info.shadow else "No" + print(" %-25s %-18s %-12s %-8.1f %-6s %-8s" % [ + info.name, info.type, bake_mode_name, info.energy, shadow_str, range_str + ]) + print(" " + "-".repeat(90)) + print("") + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +func _print_summary() -> void: + """Print final summary of all light checks.""" + print("==========================================") + print(" LIGHT VALIDATION SUMMARY") + print("==========================================") + print(" Scene: ", _scene_path) + print(" Total lights: ", _total_lights) + print(" Dynamic lights: ", _dynamic_lights) + print(" Baked (Static): ", _baked_lights) + print(" Dynamic shadowed: ", _shadowed_lights) + print("------------------------------------------") + print(" Passed: ", _pass_count) + print(" Failed: ", _fail_count) + print(" Warnings: ", _warning_count) + print("------------------------------------------") + if _fail_count > 0: + print(" RESULT: FAILED ❌") + printerr("Validation FAILED — ", _fail_count, " light check(s) failed.") + else: + print(" RESULT: PASSED ✅") + print("==========================================") + print("") diff --git a/client/scripts/validate_map.gd b/client/scripts/validate_map.gd new file mode 100644 index 0000000..225b37f --- /dev/null +++ b/client/scripts/validate_map.gd @@ -0,0 +1,511 @@ +@tool +extends SceneTree + +# ============================================================================= +# validate_map.gd — Map Scene Structure Validator +# ============================================================================= +# Validates that a competitive FPS map scene follows the required structure: +# - CSG Combiner as root node +# - Spawn groups (T Spawn / CT Spawn) +# - Bomb sites (A / B) +# - Buy zones (T / CT) +# - Map bounds (invisible walls / kill plane) +# - LightmapGI configuration +# - Lighting setup (WorldEnvironment, directional/ambient lights) +# +# Usage: +# godot --headless --script client/scripts/validate_map.gd -- res://path/to/map.tscn +# +# Exit codes: +# 0 — All checks passed +# 1 — One or more checks failed +# 2 — Scene file not found at the given path +# ============================================================================= + +const EXPECTED_MIN_NODES := 15 +const EXPECTED_MAX_NODES := 500 + +# Separator used to mark user arguments after -- +const ARG_SEPARATOR := "--" + +var _pass_count := 0 +var _fail_count := 0 +var _warning_count := 0 +var _scene_path := "" +var _scene_instance: Node = null +var _root_node: Node = null + + +func _init() -> void: + _parse_arguments() + + if _scene_path.is_empty(): + printerr("ERROR: No scene path provided. Usage: godot --headless --script validate_map.gd -- res://path/to/map.tscn") + quit(2) + return + + print("") + print("==========================================") + print(" MAP SCENE STRUCTURE VALIDATOR") + print(" Scene: ", _scene_path) + print("==========================================") + print("") + + # Step 1 — Load and instantiate the scene + var scene: PackedScene = _load_scene(_scene_path) + if scene == null: + printerr("ERROR: Scene not found or failed to load: ", _scene_path) + quit(2) + return + + _scene_instance = _instantiate_scene(scene) + if _scene_instance == null: + printerr("ERROR: Failed to instantiate scene: ", _scene_path) + quit(2) + return + + root.add_child(_scene_instance) + _root_node = _scene_instance + + # Step 2 — Run all validation checks + _run_all_checks() + + # Step 3 — Print summary + _print_summary() + + # Step 4 — Exit with appropriate code + if _fail_count > 0: + quit(1) + else: + quit(0) + + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + +func _parse_arguments() -> void: + """ + Parse command-line arguments to extract the scene path. + Args after '--' are user arguments: godot ... --script validate_map.gd -- res://map.tscn + """ + var args: PackedStringArray = OS.get_cmdline_user_args() + if args.is_empty(): + # Fallback: look for '--' in full cmdline + var full_args: PackedStringArray = OS.get_cmdline_args() + var found_sep := false + for i in range(full_args.size()): + if full_args[i] == ARG_SEPARATOR: + found_sep = true + continue + if found_sep: + _scene_path = full_args[i].strip_edges() + break + else: + # First user arg is the scene path + _scene_path = args[0].strip_edges() + + # Basic validation + if _scene_path.is_empty(): + return + + # Ensure it's a res:// path or convert relative to res:// + if not _scene_path.begins_with("res://"): + # Could be a relative filesystem path — keep as-is, ResourceLoader handles it + pass + + +# --------------------------------------------------------------------------- +# Scene loading helpers +# --------------------------------------------------------------------------- + +func _load_scene(path: String) -> PackedScene: + """ + Load a .tscn or .scn file and return the PackedScene, or null on failure. + """ + if not ResourceLoader.exists(path): + # Try adding extension + if not path.ends_with(".tscn") and not path.ends_with(".scn"): + path = path + ".tscn" + if not ResourceLoader.exists(path): + return null + var scene: PackedScene = ResourceLoader.load(path, "PackedScene", ResourceLoader.CACHE_MODE_REUSE) + return scene + + +func _instantiate_scene(scene: PackedScene) -> Node: + """ + Safely instantiate a packed scene, returning the root node or null. + """ + var instance: Node = null + var ok := false + instance = scene.instantiate() + if instance != null: + ok = true + return instance if ok else null + + +# --------------------------------------------------------------------------- +# Check registration helpers +# --------------------------------------------------------------------------- + +func _pass(msg: String) -> void: + """Record a passed check.""" + _pass_count += 1 + print(" ✓ ", msg) + + +func _fail(msg: String) -> void: + """Record a failed check.""" + _fail_count += 1 + print(" ✗ ", msg) + + +func _warn(msg: String) -> void: + """Record a non-blocking warning.""" + _warning_count += 1 + print(" ⚠ ", msg) + + +func _check(condition: bool, msg_pass: String, msg_fail: String) -> bool: + """Convenience: pass or fail based on condition.""" + if condition: + _pass(msg_pass) + else: + _fail(msg_fail) + return condition + + +# --------------------------------------------------------------------------- +# Node search helpers +# --------------------------------------------------------------------------- + +func _find_node(parent: Node, name: String) -> Node: + """Recursively find a node by exact name.""" + for child in parent.get_children(): + if child.name == name: + return child + var found := _find_node(child, name) + if found != null: + return found + return null + + +func _find_node_by_type(parent: Node, type_name: String) -> Node: + """Recursively find a node by its script or class name.""" + for child in parent.get_children(): + var class_str := str(child.get_class()) + if class_str == type_name: + return child + var found := _find_node_by_type(child, type_name) + if found != null: + return found + return null + + +func _find_nodes_by_type(parent: Node, type_name: String) -> Array[Node]: + """Recursively find all nodes of a given type.""" + var results: Array[Node] = [] + if str(parent.get_class()) == type_name: + results.append(parent) + for child in parent.get_children(): + results.append_array(_find_nodes_by_type(child, type_name)) + return results + + +func _find_children_by_name_prefix(parent: Node, prefix: String) -> Array[Node]: + """Find all immediate children whose name starts with prefix.""" + var results: Array[Node] = [] + for child in parent.get_children(): + if child.name.begins_with(prefix): + results.append(child) + return results + + +func _count_nodes(node: Node) -> int: + """Count total nodes in the subtree, including node itself.""" + var count := 1 + for child in node.get_children(): + count += _count_nodes(child) + return count + + +# --------------------------------------------------------------------------- +# Main validation runner +# --------------------------------------------------------------------------- + +func _run_all_checks() -> void: + """Execute all validation categories.""" + print("--- Root Node ---") + _check_root_node() + + print("") + print("--- Spawn Groups ---") + _check_spawn_groups() + + print("") + print("--- Bomb Sites ---") + _check_bomb_sites() + + print("") + print("--- Buy Zones ---") + _check_buy_zones() + + print("") + print("--- Map Bounds ---") + _check_map_bounds() + + print("") + print("--- LightmapGI Configuration ---") + _check_lightmapgi() + + print("") + print("--- Lighting Setup ---") + _check_lighting() + + print("") + print("--- Node Count Sanity ---") + _check_node_sanity() + + print("") + print("--- CSG Combiner Hierarchy ---") + _check_csg_hierarchy() + + +# --------------------------------------------------------------------------- +# Individual check groups +# --------------------------------------------------------------------------- + +func _check_root_node() -> void: + """Verify root node is a CSG Combiner or suitable root.""" + if _root_node == null: + _fail("Root node is null") + return + + var class_name := _root_node.get_class() + # Accept CSGCombiner3D as root, or any reasonable Spatial/Node3D + var is_csg := class_name in ["CSGCombiner3D", "CSGSphere3D", "CSGBox3D", "CSGCylinder3D", "CSGTorus3D"] + var is_node3d := class_name in ["Node3D", "Spatial"] + + if is_csg: + _pass("Root node is CSGCombiner3D ('" + _root_node.name + "')") + elif is_node3d: + _warn("Root node is Node3D ('" + _root_node.name + "') — expected CSGCombiner3D for map geometry") + else: + _warn("Root node is '" + class_name + "' ('" + _root_node.name + "') — expected CSGCombiner3D") + + +func _check_spawn_groups() -> void: + """Verify spawn point groups exist (T Spawn, CT Spawn).""" + var found_t_spawn := false + var found_ct_spawn := false + + for child in _root_node.get_children(): + var name_lower := child.name.to_lower() + if "t_spawn" in name_lower or "tspawn" in name_lower or "terrorist" in name_lower: + found_t_spawn = true + _pass("T Spawn group found: '" + child.name + "'") + if "ct_spawn" in name_lower or "ctspawn" in name_lower or "counter" in name_lower: + found_ct_spawn = true + _pass("CT Spawn group found: '" + child.name + "'") + + if not found_t_spawn: + _fail("T Spawn group not found (expected node containing 'T_Spawn' or 'Terrorist')") + if not found_ct_spawn: + _fail("CT Spawn group not found (expected node containing 'CT_Spawn' or 'Counter')") + + +func _check_bomb_sites() -> void: + """Verify bomb site marker nodes exist (A, B).""" + var found_a := false + var found_b := false + + for child in _root_node.get_children(): + var name_lower := child.name.to_lower() + if "bombsite_a" in name_lower or "bomb_site_a" in name_lower or "site_a" in name_lower or "a_site" in name_lower: + found_a = true + _pass("Bomb Site A found: '" + child.name + "'") + if "bombsite_b" in name_lower or "bomb_site_b" in name_lower or "site_b" in name_lower or "b_site" in name_lower: + found_b = true + _pass("Bomb Site B found: '" + child.name + "'") + + if not found_a: + _fail("Bomb Site A not found (expected node containing 'Bombsite_A' or 'Site_A')") + if not found_b: + _fail("Bomb Site B not found (expected node containing 'Bombsite_B' or 'Site_B')") + + +func _check_buy_zones() -> void: + """Verify buy zone areas exist for T and CT spawns.""" + var found_t_buy := false + var found_ct_buy := false + + for child in _root_node.get_children(): + var name_lower := child.name.to_lower() + if ("t_buy" in name_lower or "buy_t" in name_lower or "buyzone_t" in name_lower) and "zone" in name_lower: + found_t_buy = true + _pass("T Buy Zone found: '" + child.name + "'") + if ("ct_buy" in name_lower or "buy_ct" in name_lower or "buyzone_ct" in name_lower) and "zone" in name_lower: + found_ct_buy = true + _pass("CT Buy Zone found: '" + child.name + "'") + + if not found_t_buy or not found_ct_buy: + _warn("Buy zones not found — expected Area3D nodes named 'T_BuyZone' and 'CT_BuyZone'") + + +func _check_map_bounds() -> void: + """Verify map bounds exist (invisible walls, kill plane).""" + var found_bounds := false + var found_killplane := false + + for child in _root_node.get_children(): + var name_lower := child.name.to_lower() + if "bounds" in name_lower or "boundary" in name_lower or "wall" in name_lower: + if "invisible" in name_lower or "clip" in name_lower or "world" in name_lower: + found_bounds = true + _pass("Map bounds found: '" + child.name + "'") + if "kill" in name_lower or "death" in name_lower or "fall" in name_lower: + found_killplane = true + _pass("Kill plane / death zone found: '" + child.name + "'") + + if not found_bounds: + _warn("No map boundary/invisible wall nodes detected (optional but recommended)") + if not found_killplane: + _warn("No kill plane / death zone found (optional but recommended)") + + +func _check_lightmapgi() -> void: + """Validate LightmapGI node configuration.""" + var lightmap := _find_node_by_type(_root_node, "LightmapGI") + if lightmap == null: + _fail("LightmapGI node not found in scene") + return + + _pass("LightmapGI node found: '" + lightmap.name + "'") + + # Check quality + if lightmap.has_method("get_quality"): + var q := lightmap.get_quality() + _check(q >= 1, "LightmapGI quality set (≥ Medium)", "LightmapGI quality is Low (0) — set to at least Medium") + + # Check bounces + var bounces: int = lightmap.get("bounces") if "bounces" in lightmap else -1 + if bounces >= 0: + _check(bounces >= 2, "LightmapGI.bounces >= 2", "LightmapGI.bounces is " + str(bounces) + " — expected ≥ 2") + + # Check interior + var interior: bool = lightmap.get("interior") if "interior" in lightmap else false + _check(interior == true, "LightmapGI.interior = true (indoor map)", "LightmapGI.interior is false — set to true for indoor scenes") + + # Check texel scale + var texel_scale: float = lightmap.get("texel_scale") if "texel_scale" in lightmap else 0.0 + _check(abs(texel_scale - 1.0) < 0.01, "LightmapGI.texel_scale ≈ 1.0", "LightmapGI.texel_scale is " + str(texel_scale) + " — expected 1.0") + + # Check denoiser + var denoiser: bool = lightmap.get("use_denoiser") if "use_denoiser" in lightmap else false + _check(denoiser == true, "LightmapGI.use_denoiser = true", "LightmapGI.use_denoiser is false — recommended true") + + # Check max texture size + var max_tex: int = lightmap.get("max_texture_size") if "max_texture_size" in lightmap else 0 + if max_tex > 0: + _check(max_tex <= 1024, "LightmapGI.max_texture_size ≤ 1024", "LightmapGI.max_texture_size is " + str(max_tex) + " — consider ≤ 1024") + + # Check energy + var energy: float = lightmap.get("energy") if "energy" in lightmap else 1.0 + _warn("LightmapGI energy = " + str(energy) + " (verify visually)") + + +func _check_lighting() -> void: + """Verify lighting setup: WorldEnvironment, directional light, etc.""" + var world_env := _find_node_by_type(_root_node, "WorldEnvironment") + if world_env != null: + _pass("WorldEnvironment node found") + # Check environment resource + if world_env.has_method("get_environment"): + var env := world_env.get_environment() + if env != null: + _pass("WorldEnvironment has Environment resource") + else: + _fail("WorldEnvironment has no Environment resource assigned") + else: + _warn("WorldEnvironment — check environment assignment manually") + else: + _fail("WorldEnvironment node not found — required for map lighting") + + # Directional light (sun) + var sun := _find_node_by_type(_root_node, "DirectionalLight3D") + if sun != null: + _pass("DirectionalLight3D (sun) found: '" + sun.name + "'") + else: + _fail("DirectionalLight3D not found — map needs a sun light") + + # Check for too many dynamic lights + var omni_lights := _find_nodes_by_type(_root_node, "OmniLight3D") + var spot_lights := _find_nodes_by_type(_root_node, "SpotLight3D") + if omni_lights.size() + spot_lights.size() > 4: + _warn("Scene has " + str(omni_lights.size() + spot_lights.size()) + " dynamic lights — consider baking (recommended ≤ 4)") + else: + _pass("Dynamic light count (" + str(omni_lights.size() + spot_lights.size()) + ") within budget (≤ 4)") + + # Reflection probes + var ref_probes := _find_nodes_by_type(_root_node, "ReflectionProbe") + if ref_probes.size() > 0: + _pass("ReflectionProbe(s) found: " + str(ref_probes.size())) + else: + _warn("No ReflectionProbe found — consider adding for visual quality") + + +func _check_node_sanity() -> void: + """Verify total node count is within expected range.""" + var total := _count_nodes(_root_node) + if total < EXPECTED_MIN_NODES: + _warn("Scene has only " + str(total) + " nodes — very minimal (< " + str(EXPECTED_MIN_NODES) + ")") + elif total > EXPECTED_MAX_NODES: + _warn("Scene has " + str(total) + " nodes — consider optimization (> " + str(EXPECTED_MAX_NODES) + ")") + else: + _pass("Node count " + str(total) + " within expected range [" + str(EXPECTED_MIN_NODES) + ", " + str(EXPECTED_MAX_NODES) + "]") + + +func _check_csg_hierarchy() -> void: + """Check that CSG nodes are properly parented under a CSGCombiner3D.""" + var csg_nodes := _find_nodes_by_type(_root_node, "CSGCombiner3D") + if csg_nodes.size() > 1: + _warn("Multiple CSGCombiner3D nodes found (" + str(csg_nodes.size()) + ") — expected single root combiner") + elif csg_nodes.size() == 0: + _warn("No CSGCombiner3D found at root (map may use mesh instances instead)") + + var csg_shape_nodes := 0 + csg_shape_nodes += _find_nodes_by_type(_root_node, "CSGBox3D").size() + csg_shape_nodes += _find_nodes_by_type(_root_node, "CSGSphere3D").size() + csg_shape_nodes += _find_nodes_by_type(_root_node, "CSGCylinder3D").size() + csg_shape_nodes += _find_nodes_by_type(_root_node, "CSGTorus3D").size() + csg_shape_nodes += _find_nodes_by_type(_root_node, "CSGPolygon3D").size() + + if csg_shape_nodes > 0: + _pass("CSG primitive nodes found: " + str(csg_shape_nodes)) + else: + _warn("No CSG primitive nodes found — map may use imported meshes instead") + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +func _print_summary() -> void: + """Print a final summary of all checks.""" + print("") + print("==========================================") + print(" VALIDATION SUMMARY") + print("==========================================") + print(" Scene: ", _scene_path) + print(" Passed: ", _pass_count) + print(" Failed: ", _fail_count) + print(" Warnings: ", _warning_count) + print("------------------------------------------") + if _fail_count > 0: + print(" RESULT: FAILED ❌") + printerr("Validation FAILED — ", _fail_count, " check(s) failed.") + else: + print(" RESULT: PASSED ✅") + print("==========================================") + print("") diff --git a/client/scripts/validate_polycount.gd b/client/scripts/validate_polycount.gd new file mode 100644 index 0000000..ae77cf0 --- /dev/null +++ b/client/scripts/validate_polycount.gd @@ -0,0 +1,465 @@ +@tool +extends SceneTree + +# ============================================================================= +# validate_polycount.gd — CSG Triangle Budget Checker +# ============================================================================= +# Audits a Godot 4 map scene for CSG polygon / triangle budget compliance. +# Budget limits (competitive FPS): +# - Total faces: < 50,000 +# - Per node: < 5,000 +# - Total CSG nodes: < 200 +# +# Usage: +# godot --headless --script client/scripts/validate_polycount.gd -- res://path/to/map.tscn +# +# Exit codes: +# 0 — All poly budgets met +# 1 — One or more budgets exceeded +# 2 — Scene file not found or cannot be loaded +# ============================================================================= + +# Budget constants (adjust for target platform) +const MAX_TOTAL_FACES := 50_000 +const MAX_FACES_PER_NODE := 5_000 +const MAX_CSG_NODES := 200 +const MAX_MESH_INSTANCES := 150 +const WARN_TOTAL_FACES := 40_000 +const WARN_FACES_PER_NODE := 3_000 + +var _pass_count := 0 +var _fail_count := 0 +var _warning_count := 0 +var _scene_path := "" +var _scene_instance: Node = null + +# Accumulated stats +var _total_faces := 0 +var _total_vertices := 0 +var _total_csg_nodes := 0 +var _total_mesh_instances := 0 +var _node_face_counts: Dictionary = {} # node_path -> face_count +var _highest_face_node := "" +var _highest_face_count := 0 +var _csg_node_list: Array[String] = [] +var _overbudget_nodes: Array[String] = [] + + +func _init() -> void: + _parse_arguments() + + if _scene_path.is_empty(): + printerr("ERROR: No scene path provided. Usage: godot --headless --script validate_polycount.gd -- res://path/to/map.tscn") + quit(2) + return + + print("") + print("==========================================") + print(" CSG POLYGON BUDGET VALIDATOR") + print(" Scene: ", _scene_path) + print("==========================================") + print("") + + # Budget header + print(" Budget limits:") + print(" Max total faces: ", _format_num(MAX_TOTAL_FACES)) + print(" Max faces per node: ", _format_num(MAX_FACES_PER_NODE)) + print(" Max CSG nodes: ", _format_num(MAX_CSG_NODES)) + print(" Max MeshInstance3D: ", _format_num(MAX_MESH_INSTANCES)) + print("") + + var scene: PackedScene = _load_scene(_scene_path) + if scene == null: + printerr("ERROR: Scene not found: ", _scene_path) + quit(2) + return + + _scene_instance = _instantiate_scene(scene) + if _scene_instance == null: + printerr("ERROR: Failed to instantiate scene: ", _scene_path) + quit(2) + return + + root.add_child(_scene_instance) + + # Perform analysis + _analyze_node(_scene_instance, "") + + # Run all checks + _run_checks() + + # Print detailed report + _print_detailed_report() + + # Summary + _print_summary() + + if _fail_count > 0: + quit(1) + else: + quit(0) + + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + +func _parse_arguments() -> void: + """Extract scene path from user arguments (after --).""" + var args: PackedStringArray = OS.get_cmdline_user_args() + if not args.is_empty(): + _scene_path = args[0].strip_edges() + return + + # Fallback: scan full cmdline for -- + var full_args: PackedStringArray = OS.get_cmdline_args() + var found_sep := false + for i in range(full_args.size()): + if full_args[i] == "--": + found_sep = true + continue + if found_sep: + _scene_path = full_args[i].strip_edges() + break + + +# --------------------------------------------------------------------------- +# Scene helpers +# --------------------------------------------------------------------------- + +func _load_scene(path: String) -> PackedScene: + """Load a .tscn/.scn file.""" + if not ResourceLoader.exists(path): + if not path.ends_with(".tscn") and not path.ends_with(".scn"): + path = path + ".tscn" + if not ResourceLoader.exists(path): + return null + return ResourceLoader.load(path, "PackedScene", ResourceLoader.CACHE_MODE_REUSE) + + +func _instantiate_scene(scene: PackedScene) -> Node: + """Instantiate a packed scene.""" + return scene.instantiate() + + +# --------------------------------------------------------------------------- +# Check helpers +# --------------------------------------------------------------------------- + +func _pass(msg: String) -> void: + _pass_count += 1 + print(" ✓ ", msg) + + +func _fail(msg: String) -> void: + _fail_count += 1 + print(" ✗ ", msg) + + +func _warn(msg: String) -> void: + _warning_count += 1 + print(" ⚠ ", msg) + + +# --------------------------------------------------------------------------- +# Polygon analysis engine +# --------------------------------------------------------------------------- + +func _analyze_node(node: Node, path: String) -> void: + """ + Recursively walk the scene tree and collect polygon statistics + for all CSG and MeshInstance nodes. + """ + var current_path := path + "/" + node.name if path != "" else node.name + + # Handle CSG nodes + var class_name := node.get_class() + var is_csg := class_name in [ + "CSGCombiner3D", "CSGSphere3D", "CSGBox3D", "CSGCylinder3D", + "CSGTorus3D", "CSGPolygon3D", "CSGMesh3D" + ] + + if is_csg: + _total_csg_nodes += 1 + _csg_node_list.append(current_path) + + # Try to get mesh / face count + var face_count := _estimate_faces(node, class_name) + var vert_count := _estimate_vertices(node, class_name) + + if face_count > 0: + _total_faces += face_count + _total_vertices += vert_count + _node_face_counts[current_path] = face_count + + if face_count > _highest_face_count: + _highest_face_count = face_count + _highest_face_node = current_path + + if face_count > MAX_FACES_PER_NODE: + _overbudget_nodes.append(current_path) + + # Count MeshInstance3D + if class_name == "MeshInstance3D" or node.is_class("MeshInstance3D"): + _total_mesh_instances += 1 + + # Recurse children + for child in node.get_children(): + _analyze_node(child, current_path) + + +func _estimate_faces(node: Node, class_name: String) -> int: + """ + Attempt to estimate the number of triangle faces for a given node. + For CSG nodes, Godot exposes get_meshes() which returns arrays. + For MeshInstance3D, we inspect the mesh resource. + """ + var faces := 0 + + # CSG nodes can provide their collision / bake mesh + if class_name in ["CSGCombiner3D", "CSGSphere3D", "CSGBox3D", "CSGCylinder3D", + "CSGTorus3D", "CSGPolygon3D", "CSGMesh3D"]: + + # Try to get the bake mesh (the triangulated mesh used for lightmapping) + if node.has_method("get_bake_mesh"): + var bake_mesh := node.get_bake_mesh() + if bake_mesh != null: + faces = _count_mesh_faces(bake_mesh) + + # If that fails, try the collision/debug mesh + if faces == 0 and node.has_method("get_meshes"): + var mesh_arrays := node.get_meshes() + if mesh_arrays is Array and mesh_arrays.size() > 1: + var mesh := mesh_arrays[1] as ArrayMesh + if mesh != null: + faces = _count_mesh_faces(mesh) + + # Fallback estimate: for simple primitives + if faces == 0: + match class_name: + "CSGBox3D": + faces = 12 # 6 faces * 2 triangles + "CSGSphere3D": + faces = 320 # typical sphere LOD + "CSGCylinder3D": + faces = 128 # typical cylinder + "CSGTorus3D": + faces = 512 # typical torus + "CSGPolygon3D": + faces = 64 # default polygon + "CSGMesh3D": + faces = 100 # fallback + "CSGCombiner3D": + faces = 0 # combiner itself has no geometry + + # MeshInstance3D: count faces from mesh resource + if class_name == "MeshInstance3D" or node.is_class("MeshInstance3D"): + if "mesh" in node and node.mesh != null: + faces = _count_mesh_faces(node.mesh) + + return faces + + +func _estimate_vertices(node: Node, class_name: String) -> int: + """Estimate vertices from the node's mesh if available.""" + var verts := 0 + + if class_name in ["CSGCombiner3D", "CSGSphere3D", "CSGBox3D", "CSGCylinder3D", + "CSGTorus3D", "CSGPolygon3D", "CSGMesh3D"]: + if node.has_method("get_bake_mesh"): + var bake_mesh := node.get_bake_mesh() + if bake_mesh != null: + verts = _count_mesh_vertices(bake_mesh) + + if verts == 0 and (class_name == "MeshInstance3D" or node.is_class("MeshInstance3D")): + if "mesh" in node and node.mesh != null: + verts = _count_mesh_vertices(node.mesh) + + return verts + + +func _count_mesh_faces(mesh) -> int: + """Count triangle faces in any Mesh type.""" + if mesh == null: + return 0 + + var count := 0 + + # ArrayMesh and similar + if mesh.has_method("get_surface_count"): + var surface_count := mesh.get_surface_count() + for s in range(surface_count): + var arrays := mesh.surface_get_arrays(s) + if arrays is Array and arrays.size() > 0: + var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array + if indices != null and indices.size() > 0: + count += indices.size() / 3 + else: + # Non-indexed: count vertices / 3 + var verts := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array + if verts != null: + count += verts.size() / 3 + return count + + +func _count_mesh_vertices(mesh) -> int: + """Count vertices in any Mesh type.""" + if mesh == null: + return 0 + + var count := 0 + if mesh.has_method("get_surface_count"): + var surface_count := mesh.get_surface_count() + for s in range(surface_count): + var arrays := mesh.surface_get_arrays(s) + if arrays is Array and arrays.size() > 0: + var verts := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array + if verts != null: + count += verts.size() + return count + + +# --------------------------------------------------------------------------- +# Budget checks +# --------------------------------------------------------------------------- + +func _run_checks() -> void: + """Run all poly budget validation checks.""" + print("--- Budget Checks ---") + + # Total faces check + if _total_faces < WARN_TOTAL_FACES: + _pass("Total faces: " + _format_num(_total_faces) + " (budget: < " + _format_num(MAX_TOTAL_FACES) + ")") + elif _total_faces < MAX_TOTAL_FACES: + _warn("Total faces approaching limit: " + _format_num(_total_faces) + " (budget: < " + _format_num(MAX_TOTAL_FACES) + ")") + else: + _fail("Total faces EXCEEDED: " + _format_num(_total_faces) + " (budget: < " + _format_num(MAX_TOTAL_FACES) + ")") + + # Per-node face check + if _overbudget_nodes.size() == 0: + _pass("No nodes exceed " + _format_num(MAX_FACES_PER_NODE) + " faces") + else: + for node_path in _overbudget_nodes: + _fail("Node exceeds face budget: " + node_path + " (" + _format_num(_node_face_counts[node_path]) + " faces, max: " + _format_num(MAX_FACES_PER_NODE) + ")") + + # CSG node count check + if _total_csg_nodes < MAX_CSG_NODES: + _pass("CSG node count: " + str(_total_csg_nodes) + " (budget: < " + str(MAX_CSG_NODES) + ")") + else: + _fail("CSG node count EXCEEDED: " + str(_total_csg_nodes) + " (budget: < " + str(MAX_CSG_NODES) + ")") + + # Mesh instance count check + if _total_mesh_instances < MAX_MESH_INSTANCES: + _pass("MeshInstance3D count: " + str(_total_mesh_instances) + " (budget: < " + str(MAX_MESH_INSTANCES) + ")") + else: + _fail("MeshInstance3D count EXCEEDED: " + str(_total_mesh_instances) + " (budget: < " + str(MAX_MESH_INSTANCES) + ")") + + # Highest face node info + print("") + print("--- Hot Spots ---") + if _highest_face_count > 0: + if _highest_face_count > WARN_FACES_PER_NODE: + _warn("Highest face node: " + _highest_face_node + " (" + _format_num(_highest_face_count) + " faces)") + else: + _pass("Highest face node: " + _highest_face_node + " (" + _format_num(_highest_face_count) + " faces)") + else: + _warn("No face data collected (scene may use non-CSG geometry)") + + +# --------------------------------------------------------------------------- +# Detailed report +# --------------------------------------------------------------------------- + +func _print_detailed_report() -> void: + """Print a detailed per-node breakdown if there are few enough nodes.""" + print("") + print("--- Detailed Breakdown ---") + print(" Total triangles: ", _format_num(_total_faces)) + print(" Total vertices: ", _format_num(_total_vertices)) + print(" CSG nodes: ", _total_csg_nodes) + print(" MeshInstance3D: ", _total_mesh_instances) + print(" Nodes over budget: ", _overbudget_nodes.size()) + print("") + + # Top 10 most expensive nodes + var sorted_nodes: Array[Dictionary] = [] + for node_path in _node_face_counts.keys(): + sorted_nodes.append({"path": node_path, "faces": _node_face_counts[node_path]}) + sorted_nodes.sort_custom(_sort_by_faces_desc) + + var top_n := mini(sorted_nodes.size(), 10) + if top_n > 0: + print(" Top " + str(top_n) + " most expensive nodes:") + for i in range(top_n): + var entry := sorted_nodes[i] as Dictionary + var bar := _make_bar(entry.faces, _highest_face_count) + print(" " + str(i + 1) + ". " + entry.path + " — " + _format_num(entry.faces) + " faces " + bar) + print("") + + +func _sort_by_faces_desc(a: Dictionary, b: Dictionary) -> bool: + return a.faces > b.faces + + +func _make_bar(value: int, max_value: int) -> String: + """Create a simple ASCII bar visualization.""" + if max_value == 0: + return "" + var ratio := float(value) / float(max_value) + var bar_len := int(ratio * 20) + if bar_len < 1 and value > 0: + bar_len = 1 + var bar := "" + for i in range(bar_len): + bar += "█" + return bar + + +# --------------------------------------------------------------------------- +# Formatting helpers +# --------------------------------------------------------------------------- + +func _format_num(n: int) -> String: + """Format a number with thousand separators.""" + var s := str(n) + var result := "" + var count := 0 + for i in range(s.length() - 1, -1, -1): + if count > 0 and count % 3 == 0: + result = "," + result + result = s[i] + result + count += 1 + return result + + +func mini(a: int, b: int) -> int: + return a if a < b else b + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +func _print_summary() -> void: + """Print final summary.""" + print("==========================================") + print(" POLYGON BUDGET SUMMARY") + print("==========================================") + print(" Scene: ", _scene_path) + print(" Total faces: ", _format_num(_total_faces)) + print(" Total vertices: ", _format_num(_total_vertices)) + print(" CSG nodes: ", _total_csg_nodes) + print(" MeshInstance3D: ", _total_mesh_instances) + print(" Over-budget: ", _overbudget_nodes.size()) + print("------------------------------------------") + print(" Passed: ", _pass_count) + print(" Failed: ", _fail_count) + print(" Warnings: ", _warning_count) + print("------------------------------------------") + if _fail_count > 0: + print(" RESULT: FAILED ❌") + printerr("Validation FAILED — ", _fail_count, " budget(s) exceeded.") + else: + print(" RESULT: PASSED ✅") + print("==========================================") + print("") diff --git a/client/scripts/validate_textures.gd b/client/scripts/validate_textures.gd new file mode 100644 index 0000000..a463dcf --- /dev/null +++ b/client/scripts/validate_textures.gd @@ -0,0 +1,581 @@ +@tool +extends SceneTree + +# ============================================================================= +# validate_textures.gd — Texture Size / Mipmaps / UV2 Checker +# ============================================================================= +# Scans all material and texture resources used in a scene and validates: +# - Texture dimensions ≤ 1024 (1K) +# - Mipmaps are enabled on all textures +# - UV2 channel is present on meshes (for lightmapping) +# - Texture format is appropriate +# +# Usage: +# godot --headless --script client/scripts/validate_textures.gd -- res://path/to/map.tscn +# +# Exit codes: +# 0 — All texture checks passed +# 1 — One or more texture checks failed +# 2 — Scene file not found or cannot be loaded +# ============================================================================= + +const MAX_TEXTURE_SIZE := 1024 +const MAX_TEXTURE_SIZE_LABEL := "1K (1024x1024)" + +var _pass_count := 0 +var _fail_count := 0 +var _warning_count := 0 +var _scene_path := "" +var _scene_instance: Node = null + +# Collected data +var _textures_checked: Dictionary = {} # resource_path -> info dict +var _overbudget_textures: Array[Dictionary] = [] +var _no_mipmap_textures: Array[Dictionary] = [] +var _meshes_without_uv2: Array[String] = [] +var _meshes_with_uv2: Array[String] = [] +var _mesh_count := 0 +var _total_textures := 0 +var _total_materials := 0 +var _texture_memory_estimate := 0 # approximate total texture memory in bytes + +# Unique set to avoid re-checking the same resource +var _checked_resources: Dictionary = {} + + +func _init() -> void: + _parse_arguments() + + if _scene_path.is_empty(): + printerr("ERROR: No scene path provided. Usage: godot --headless --script validate_textures.gd -- res://path/to/map.tscn") + quit(2) + return + + print("") + print("==========================================") + print(" TEXTURE VALIDATOR") + print(" Scene: ", _scene_path) + print("==========================================") + print("") + + print(" Texture budget:") + print(" Max dimension: ", MAX_TEXTURE_SIZE_LABEL) + print(" Mipmaps: Required") + print(" UV2 channel: Required for lightmapping") + print("") + + var scene: PackedScene = _load_scene(_scene_path) + if scene == null: + printerr("ERROR: Scene not found: ", _scene_path) + quit(2) + return + + _scene_instance = _instantiate_scene(scene) + if _scene_instance == null: + printerr("ERROR: Failed to instantiate scene: ", _scene_path) + quit(2) + return + + root.add_child(_scene_instance) + + # Analyze + _analyze_node(_scene_instance) + + # Run checks + _run_texture_checks() + _run_mipmap_checks() + _run_uv2_checks() + _print_texture_report() + + # Summary + _print_summary() + + if _fail_count > 0: + quit(1) + else: + quit(0) + + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + +func _parse_arguments() -> void: + """Extract scene path from user args (after --).""" + var args: PackedStringArray = OS.get_cmdline_user_args() + if not args.is_empty(): + _scene_path = args[0].strip_edges() + return + + var full_args: PackedStringArray = OS.get_cmdline_args() + var found_sep := false + for i in range(full_args.size()): + if full_args[i] == "--": + found_sep = true + continue + if found_sep: + _scene_path = full_args[i].strip_edges() + break + + +# --------------------------------------------------------------------------- +# Scene helpers +# --------------------------------------------------------------------------- + +func _load_scene(path: String) -> PackedScene: + """Load a .tscn/.scn file.""" + if not ResourceLoader.exists(path): + if not path.ends_with(".tscn") and not path.ends_with(".scn"): + path = path + ".tscn" + if not ResourceLoader.exists(path): + return null + return ResourceLoader.load(path, "PackedScene", ResourceLoader.CACHE_MODE_REUSE) + + +func _instantiate_scene(scene: PackedScene) -> Node: + return scene.instantiate() + + +# --------------------------------------------------------------------------- +# Check helpers +# --------------------------------------------------------------------------- + +func _pass(msg: String) -> void: + _pass_count += 1 + print(" ✓ ", msg) + + +func _fail(msg: String) -> void: + _fail_count += 1 + print(" ✗ ", msg) + + +func _warn(msg: String) -> void: + _warning_count += 1 + print(" ⚠ ", msg) + + +# --------------------------------------------------------------------------- +# Analysis engine +# --------------------------------------------------------------------------- + +func _analyze_node(node: Node) -> void: + """Recursively walk scene, check materials on meshes.""" + var class_name := node.get_class() + + # MeshInstance3D — inspect its material overrides + if class_name == "MeshInstance3D" or node.is_class("MeshInstance3D"): + _mesh_count += 1 + _analyze_mesh_instance(node) + + # CSG nodes can also have materials / meshes + if class_name in ["CSGCombiner3D", "CSGSphere3D", "CSGBox3D", "CSGCylinder3D", + "CSGTorus3D", "CSGPolygon3D", "CSGMesh3D"]: + _analyze_csg_node(node) + + # GeometryInstance3D base class + if node.is_class("GeometryInstance3D"): + var mat_override := node.get("material_override") if "material_override" in node else null + if mat_override != null: + _analyze_material(mat_override) + + for child in node.get_children(): + _analyze_node(child) + + +func _analyze_mesh_instance(mi: Node) -> void: + """Inspect a MeshInstance3D node for materials and UV2.""" + # Check mesh + if "mesh" in mi and mi.mesh != null: + var mesh = mi.mesh + _analyze_mesh(mesh, mi.name) + + # Surface material overrides + if "material_override" in mi and mi.material_override != null: + _analyze_material(mi.material_override) + + # Per-surface materials + if mi.has_method("get_surface_override_material"): + var surface_count := 0 + if "mesh" in mi and mi.mesh != null and mi.mesh.has_method("get_surface_count"): + surface_count = mi.mesh.get_surface_count() + for s in range(surface_count): + var mat := mi.get_surface_override_material(s) + if mat != null: + _analyze_material(mat) + + +func _analyze_csg_node(node: Node) -> void: + """Analyze materials on CSG nodes.""" + # CSG nodes have operation and material properties + if "material" in node and node.material != null: + _analyze_material(node.material) + + # Check CSGMesh3D which references a mesh + if node.get_class() == "CSGMesh3D" and "mesh" in node and node.mesh != null: + _analyze_mesh(node.mesh, node.name) + + +func _analyze_mesh(mesh, node_name: String) -> void: + """Analyze a mesh resource for UV2 and material slots.""" + if mesh == null: + return + + var mesh_id := mesh.resource_path if "resource_path" in mesh and mesh.resource_path != "" else str(mesh.get_instance_id()) + if _checked_resources.has(mesh_id): + return + _checked_resources[mesh_id] = true + + # Check for UV2 channel + var has_uv2 := false + if mesh.has_method("get_surface_count"): + var surface_count := mesh.get_surface_count() + for s in range(surface_count): + var arrays := mesh.surface_get_arrays(s) + if arrays is Array and arrays.size() > 0: + var uv2 := arrays[Mesh.ARRAY_TEX_UV2] as PackedVector2Array + if uv2 != null and uv2.size() > 0: + has_uv2 = true + + if has_uv2: + _meshes_with_uv2.append(node_name) + else: + _meshes_without_uv2.append(node_name) + + # Check materials on the mesh + if mesh.has_method("surface_get_material"): + var surface_count := mesh.get_surface_count() + for s in range(surface_count): + var mat := mesh.surface_get_material(s) + if mat != null: + _analyze_material(mat) + + +func _analyze_material(mat) -> void: + """Recursively analyze a material and its textures.""" + if mat == null: + return + + var mat_id := mat.resource_path if "resource_path" in mat and mat.resource_path != "" else str(mat.get_instance_id()) + if _checked_resources.has(mat_id): + return + _checked_resources[mat_id] = true + _total_materials += 1 + + var mat_class := mat.get_class() + + # StandardMaterial3D — check texture properties + if mat_class == "StandardMaterial3D" or mat_class == "ORMMaterial3D": + var tex_properties := [ + "albedo_texture", "metallic_texture", "roughness_texture", + "normal_texture", "ambient_occlusion_texture", "emission_texture", + "clearcoat_texture", "rim_texture", "transparency_texture", + "heightmap_texture", "refraction_texture", "detail_albedo_texture", + "detail_normal_texture", "detail_mask_texture", "subsurface_scattering_texture", + "backlight_texture" + ] + for prop in tex_properties: + if prop in mat: + var tex = mat.get(prop) + if tex != null: + _analyze_texture(tex) + + # ShaderMaterial — inspect shader parameters for textures + elif mat_class == "ShaderMaterial": + if mat.has_method("get_shader_parameter_list"): + var param_list := mat.get_shader_parameter_list() + for param in param_list: + var param_name := param.name if "name" in param else str(param) + var val = mat.get_shader_parameter(param_name) + if val != null and (val is Texture2D or val is Texture3D or val is TextureLayered): + _analyze_texture(val) + + # BaseMaterial3D (Godot 4 fallback) + elif mat.is_class("BaseMaterial3D"): + if "albedo_texture" in mat and mat.albedo_texture != null: + _analyze_texture(mat.albedo_texture) + if "metallic_texture" in mat and mat.metallic_texture != null: + _analyze_texture(mat.metallic_texture) + if "roughness_texture" in mat and mat.roughness_texture != null: + _analyze_texture(mat.roughness_texture) + if "normal_texture" in mat and mat.normal_texture != null: + _analyze_texture(mat.normal_texture) + + +func _analyze_texture(tex) -> void: + """Analyze a single texture resource for size, mipmaps, and format.""" + if tex == null: + return + + # Use resource path as unique key + var tex_path := "" + if "resource_path" in tex and tex.resource_path != "": + tex_path = tex.resource_path + else: + tex_path = str(tex.get_instance_id()) + + if _textures_checked.has(tex_path): + return + + var tex_class := tex.get_class() + var width := 0 + var height := 0 + var has_mipmaps := false + var format := -1 + var format_name := "Unknown" + + # Texture2D + if tex is Texture2D: + width = tex.get_width() + height = tex.get_height() + has_mipmaps = tex.has_mipmaps() if tex.has_method("has_mipmaps") else false + if tex.has_method("get_format"): + format = tex.get_format() + format_name = _get_format_name(format) + _total_textures += 1 + + # Texture3D + elif tex is Texture3D: + width = tex.get_width() + height = tex.get_height() + if tex.has_method("has_mipmaps"): + has_mipmaps = tex.has_mipmaps() + _total_textures += 1 + + # TextureLayered + elif tex is TextureLayered: + if tex.has_method("get_width"): + width = tex.get_width() + if tex.has_method("get_height"): + height = tex.get_height() + _total_textures += 1 + + var info := { + "path": tex_path, + "width": width, + "height": height, + "has_mipmaps": has_mipmaps, + "format": format_name, + "class": tex_class + } + _textures_checked[tex_path] = info + + # Estimate memory + var bpp := _bits_per_pixel(format) + _texture_memory_estimate += (width * height * bpp) / 8 + + # Check over-budget + var max_dim := maxi(width, height) + if max_dim > MAX_TEXTURE_SIZE: + _overbudget_textures.append(info) + + # Check mipmaps + if not has_mipmaps: + _no_mipmap_textures.append(info) + + +# --------------------------------------------------------------------------- +# Texture checks +# --------------------------------------------------------------------------- + +func _run_texture_checks() -> void: + """Validate texture size budget.""" + print("--- Texture Size Checks ---") + if _total_textures == 0: + _warn("No textures found in scene (may be expected for procedural materials)") + return + + _pass("Total textures found: " + str(_total_textures)) + + if _overbudget_textures.size() == 0: + _pass("All textures within " + MAX_TEXTURE_SIZE_LABEL + " limit") + else: + for tex_info in _overbudget_textures: + var dim := str(tex_info.width) + "x" + str(tex_info.height) + _fail("Texture over budget: " + _truncate_path(tex_info.path) + " (" + dim + ", max: " + MAX_TEXTURE_SIZE_LABEL + ")") + + +func _run_mipmap_checks() -> void: + """Validate that all textures have mipmaps enabled.""" + print("") + print("--- Mipmap Checks ---") + + if _no_mipmap_textures.size() == 0: + _pass("All textures have mipmaps enabled") + else: + for tex_info in _no_mipmap_textures: + var dim := str(tex_info.width) + "x" + str(tex_info.height) + _fail("Texture missing mipmaps: " + _truncate_path(tex_info.path) + " (" + dim + ", class: " + tex_info.class + ")") + + +func _run_uv2_checks() -> void: + """Validate UV2 channel presence on meshes.""" + print("") + print("--- UV2 Channel Checks ---") + + if _mesh_count == 0: + _warn("No MeshInstance3D nodes found") + return + + _pass("Meshes found: " + str(_mesh_count)) + + if _meshes_without_uv2.size() == 0: + _pass("All " + str(_mesh_count) + " meshes have UV2 channel (good for lightmapping)") + else: + for mesh_name in _meshes_without_uv2: + _fail("Mesh missing UV2 channel: " + mesh_name + " — lightmapping will not work correctly") + + +# --------------------------------------------------------------------------- +# Report +# --------------------------------------------------------------------------- + +func _print_texture_report() -> void: + """Print detailed texture report.""" + print("") + print("--- Texture Report ---") + print(" Total materials: ", _total_materials) + print(" Total textures: ", _total_textures) + print(" Over-budget textures: ", _overbudget_textures.size()) + print(" Missing mipmaps: ", _no_mipmap_textures.size()) + print(" Total meshes: ", _mesh_count) + print(" Meshes with UV2: ", _meshes_with_uv2.size()) + print(" Meshes without UV2: ", _meshes_without_uv2.size()) + print(" Est. texture memory: ", _format_bytes(_texture_memory_estimate)) + print("") + + # Print texture format distribution + var format_counts: Dictionary = {} + for tex_path in _textures_checked.keys(): + var info := _textures_checked[tex_path] as Dictionary + var fmt := info.format + if not format_counts.has(fmt): + format_counts[fmt] = 0 + format_counts[fmt] += 1 + + if format_counts.size() > 0: + print(" Texture format distribution:") + for fmt in format_counts.keys(): + print(" " + fmt + ": " + str(format_counts[fmt])) + print("") + + +func _truncate_path(path: String) -> String: + """Truncate a resource path to fit in a single line.""" + if path.length() > 60: + return "..." + path.substr(path.length() - 57) + return path + + +func _get_format_name(format_id: int) -> String: + """Map Godot image format enum to a human-readable name.""" + match format_id: + 0: return "L8" + 1: return "LA8" + 2: return "R8" + 3: return "RG8" + 4: return "RGB8" + 5: return "RGBA8" + 6: return "RGBA4444" + 7: return "RGBA5551" + 8: return "RF" + 9: return "RGF" + 10: return "RGBF" + 11: return "RGBAF" + 12: return "RH" + 13: return "RGH" + 14: return "RGBH" + 15: return "RGBAH" + 16: return "RGBE9995" + 17: return "DXT1 (BC1)" + 18: return "DXT3 (BC2)" + 19: return "DXT5 (BC3)" + 20: return "RGTC (BC4)" + 21: return "RGTC (BC5)" + 22: return "BPTC (BC6H)" + 23: return "BPTC (BC7)" + 24: return "ETC" + 25: return "ETC2 R11" + 26: return "ETC2 RG11" + 27: return "ETC2 RGB8" + 28: return "ETC2 RGBA8" + 29: return "PVRTC2" + 30: return "PVRTC4" + 31: return "ASTC 4x4" + 32: return "ASTC 8x8" + 33: return "Max" + return "Unknown(" + str(format_id) + ")" + + +func _bits_per_pixel(format_id: int) -> int: + """Estimate bits per pixel for a given format.""" + match format_id: + 0, 2: return 8 # L8, R8 + 1, 3: return 16 # LA8, RG8 + 4: return 24 # RGB8 + 5, 6, 7: return 32 # RGBA8, RGBA4444, RGBA5551 + 8: return 32 # RF + 9: return 64 # RGF + 10: return 96 # RGBF + 11: return 128 # RGBAF + 12: return 16 # RH (half) + 13: return 32 # RGH + 14: return 48 # RGBH + 15: return 64 # RGBAH + 16: return 32 # RGBE9995 + 17, 18, 19: return 4 # DXT/BC compressed — 4bpp + 20, 21: return 4 # BC4/BC5 — 4bpp (BC5 is 8bpp but rare) + 22: return 8 # BC6H — 8bpp + 23: return 8 # BC7 — 8bpp + 24, 25, 26, 27, 28: return 4 # ETC/ETC2 — 4bpp + 29, 30: return 2 # PVRTC — 2bpp + 31: return 8 # ASTC 4x4 — 8bpp + 32: return 2 # ASTC 8x8 — 2bpp + return 32 + + +func _format_bytes(bytes: int) -> String: + """Format bytes into human-readable string.""" + if bytes < 1024: + return str(bytes) + " B" + elif bytes < 1024 * 1024: + return str(stepify(float(bytes) / 1024.0, 0.1)) + " KB" + else: + return str(stepify(float(bytes) / (1024.0 * 1024.0), 0.1)) + " MB" + + +func maxi(a: int, b: int) -> int: + return a if a > b else b + + +func mini(a: int, b: int) -> int: + return a if a < b else b + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +func _print_summary() -> void: + """Print final summary.""" + print("==========================================") + print(" TEXTURE VALIDATION SUMMARY") + print("==========================================") + print(" Scene: ", _scene_path) + print(" Textures checked: ", _total_textures) + print(" Materials checked: ", _total_materials) + print(" Over-budget textures: ", _overbudget_textures.size()) + print(" Missing mipmaps: ", _no_mipmap_textures.size()) + print(" Meshes w/o UV2: ", _meshes_without_uv2.size()) + print("------------------------------------------") + print(" Passed: ", _pass_count) + print(" Failed: ", _fail_count) + print(" Warnings: ", _warning_count) + print("------------------------------------------") + if _fail_count > 0: + print(" RESULT: FAILED ❌") + printerr("Validation FAILED — ", _fail_count, " texture check(s) failed.") + else: + print(" RESULT: PASSED ✅") + print("==========================================") + print("")