From 6ad91b895cce2bf61b5ddc7e21d9f61cf9e523ae Mon Sep 17 00:00:00 2001 From: daddy Date: Sun, 6 Sep 2026 00:58:49 +0000 Subject: [PATCH] Fix: avatars are capsules in the exe -- use load().instantiate(), not runtime GLTFDocument Playtest log had the smoking gun: WARNING: player.gd: no avatar to show; keeping the placeholder capsule ERROR: Parameter "data.tree" is null. at: get_tree avatar_loader.gd read raw .glb bytes and ran GLTFDocument.append_from_buffer + generate_scene() at runtime. In a release export that path produced NO Skeleton3D (works fine in the editor / headless-from-source), so load_base_body() returned null and the player fell back to the capsule. The queue_free() calls in that path also errored (loader runs detached from any tree). Fix: load(path).instantiate() the Godot-imported PackedScene (the .scn that actually ships in the pack, with the skeleton + skin set up by the import pipeline). The raw-bytes GLTFDocument path stays available for future user-uploaded models but isn't used for the bundled catalog. - run_checkpoint_tests.py: also runs the avatar smoke against the EXPORTED pack, not just source -- that's what catches release-only resource differences. Verified: 14/14 against export/game.pck. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ --- scripts/avatar/avatar_loader.gd | 90 ++++++++++++--------------------- scripts/run_checkpoint_tests.py | 17 +++++++ 2 files changed, 50 insertions(+), 57 deletions(-) diff --git a/scripts/avatar/avatar_loader.gd b/scripts/avatar/avatar_loader.gd index c8242c8..0430890 100644 --- a/scripts/avatar/avatar_loader.gd +++ b/scripts/avatar/avatar_loader.gd @@ -119,14 +119,9 @@ func load_base_body(base_body_id: String) -> Node3D: return null var path: String = asset_meta.path - var glb_data: PackedByteArray = _load_file(path) - if glb_data.is_empty(): - load_error.emit("Failed to read GLB file: %s" % path) - return null - - avatar_node = _parse_glb_as_avatar(glb_data, path) + avatar_node = _instance_glb_as_avatar(path) if avatar_node == null: - load_error.emit("Failed to parse GLB: %s" % path) + load_error.emit("Failed to load base body scene: %s" % path) return null skeleton = avatar_node.get_node_or_null("Skeleton3D") as Skeleton3D @@ -182,14 +177,9 @@ func load_attachment(slot_id: String, asset_id: String) -> Node3D: return null var path: String = asset_meta.path - var glb_data: PackedByteArray = _load_file(path) - if glb_data.is_empty(): - load_error.emit("Failed to read GLB file: %s" % path) - return null - - var attachment_node = _parse_glb_as_attachment(glb_data, path, slot_id) + var attachment_node = _instance_glb_as_attachment(path, slot_id) if attachment_node == null: - load_error.emit("Failed to parse attachment GLB: %s" % path) + load_error.emit("Failed to load attachment scene: %s" % path) return null # If the attachment carries its own skin, retarget it to the canonical @@ -296,50 +286,44 @@ func reset() -> void: # Internal: GLB Parsing # --------------------------------------------------------------------------- -## Parse a GLB byte array into an avatar Node3D structure: +## Instantiate a base-body .glb (a Godot-imported PackedScene) into an avatar +## structure: ## Avatar (Node3D) -## Skeleton3D -- the canonical rig, with skinned body meshes as children -## BodyMeshes (Node3D) -- references to the skinned body meshes, for slot hiding +## Skeleton3D -- the canonical rig + skin-bound body meshes +## BodyMeshes (Node3D) -- unused container, kept for compatibility ## Attachments (Node3D)-- equipped attachment nodes (added by load_attachment) ## -## Rather than hand-copying the GLB skeleton bone-by-bone (which loses the skin -## binding), this uses GLTFDocument.generate_scene() -- which already produces a -## correctly skin-bound Skeleton3D + MeshInstance3D tree -- and lifts that -## skeleton into our container structure intact. -func _parse_glb_as_avatar(glb_data: PackedByteArray, path: String) -> Node3D: - var gltf_document := GLTFDocument.new() - var gltf_state := GLTFState.new() - - var parse_result := gltf_document.append_from_buffer(glb_data, "", gltf_state) - if parse_result != OK: - push_error("GLB parse error: %s" % error_string(parse_result)) +## Uses load().instantiate() rather than the runtime GLTFDocument path: the +## imported .scn is the one that actually ends up in an exported build, with +## the skeleton + skin set up by Godot's import pipeline. (The raw-bytes +## GLTFDocument path -- for future user-uploaded models -- misbehaved in a +## release build: no Skeleton3D produced, so the player fell back to a capsule.) +func _instance_glb_as_avatar(path: String) -> Node3D: + var packed = load(path) + if not (packed is PackedScene): + push_error("Base body is not an importable scene: %s" % path) return null - - var scene_root := gltf_document.generate_scene(gltf_state) + var scene_root: Node = packed.instantiate() if not (scene_root is Node3D): - push_error("Failed to generate scene from GLB: %s" % path) - if scene_root: - scene_root.queue_free() + push_error("Base body scene root is not a Node3D: %s" % path) + scene_root.free() return null var src_skel := _find_first_skeleton(scene_root) if src_skel == null: - push_error("No Skeleton3D in generated scene for base body: %s" % path) - scene_root.queue_free() + push_error("No Skeleton3D in base body scene: %s" % path) + scene_root.free() return null var avatar := Node3D.new() avatar.name = "Avatar" - # Reparent the real (skin-bound) skeleton into our structure, renamed so - # downstream get_node("Skeleton3D") lookups work regardless of the GLB's - # own naming. src_skel.get_parent().remove_child(src_skel) src_skel.name = "Skeleton3D" src_skel.transform = Transform3D.IDENTITY _clear_owner_recursive(src_skel) avatar.add_child(src_skel) - scene_root.queue_free() + scene_root.free() var body_meshes := Node3D.new() body_meshes.name = "BodyMeshes" @@ -363,34 +347,26 @@ func _find_first_skeleton(node: Node) -> Skeleton3D: return null -## Parse a GLB byte array into an attachment Node3D. -func _parse_glb_as_attachment(glb_data: PackedByteArray, path: String, - slot_id: String) -> Node3D: - - var gltf_document := GLTFDocument.new() - var gltf_state := GLTFState.new() - - var parse_result := gltf_document.append_from_buffer(glb_data, "", gltf_state) - if parse_result != OK: - push_error("GLB parse error: %s" % error_string(parse_result)) +## Instantiate an attachment .glb (imported PackedScene) into a single named +## Node3D the caller can mount to a bone as one unit. +func _instance_glb_as_attachment(path: String, slot_id: String) -> Node3D: + var packed = load(path) + if not (packed is PackedScene): + push_error("Attachment is not an importable scene: %s" % path) return null - - var scene_root := gltf_document.generate_scene(gltf_state) + var scene_root: Node = packed.instantiate() if not (scene_root is Node3D): - push_error("Failed to generate scene from attachment GLB: %s" % path) - if scene_root: - scene_root.queue_free() + push_error("Attachment scene root is not a Node3D: %s" % path) + scene_root.free() return null - # Wrap the generated attachment geometry in a single named Node3D so the - # caller can mount it to a bone as one unit. var attachment := Node3D.new() attachment.name = "%s_%s" % [slot_id, path.get_file().get_basename()] for child in scene_root.get_children().duplicate(): scene_root.remove_child(child) _clear_owner_recursive(child) attachment.add_child(child) - scene_root.queue_free() + scene_root.free() return attachment diff --git a/scripts/run_checkpoint_tests.py b/scripts/run_checkpoint_tests.py index a022246..8732dd9 100755 --- a/scripts/run_checkpoint_tests.py +++ b/scripts/run_checkpoint_tests.py @@ -191,6 +191,23 @@ def main(): r = run([godot_bin, "--headless", "--path", str(ROOT), "--script", "res://scripts/verify_export_contents.gd"], env=godot_env) results["export packaging"] = r.returncode == 0 + + # Run a key smoke against the EXPORTED pack, not just source -- + # release-build resources differ (e.g. the runtime GLTFDocument + # path produced no skeleton in a real export; load().instantiate() + # of the imported .scn does). This runs the avatar smoke on + # exactly what ships. + exported_pck = ROOT / "export" / "_ci_check.pck" + exported_pck.parent.mkdir(exist_ok=True) + ep = run([godot_bin, "--headless", "--path", str(ROOT), + "--export-pack", "Linux", str(exported_pck)], env=godot_env) + if ep.returncode == 0 and exported_pck.exists(): + r = run([godot_bin, "--headless", "--main-pack", str(exported_pck), + "--script", "res://scripts/smoke_test_avatar_system.gd"], env=godot_env) + results["avatar smoke (exported pack)"] = r.returncode == 0 + exported_pck.unlink(missing_ok=True) + else: + results["avatar smoke (exported pack)"] = False finally: server_proc.terminate() try: