Fix: avatars are capsules in the exe -- use load().instantiate(), not runtime GLTFDocument
checkpoint / checkpoint (push) Successful in 1m47s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This commit is contained in:
2026-09-06 00:58:49 +00:00
parent 44bfcb1871
commit 6ad91b895c
2 changed files with 50 additions and 57 deletions
+33 -57
View File
@@ -119,14 +119,9 @@ func load_base_body(base_body_id: String) -> Node3D:
return null return null
var path: String = asset_meta.path var path: String = asset_meta.path
var glb_data: PackedByteArray = _load_file(path) avatar_node = _instance_glb_as_avatar(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)
if avatar_node == null: 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 return null
skeleton = avatar_node.get_node_or_null("Skeleton3D") as Skeleton3D 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 return null
var path: String = asset_meta.path var path: String = asset_meta.path
var glb_data: PackedByteArray = _load_file(path) var attachment_node = _instance_glb_as_attachment(path, slot_id)
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)
if attachment_node == null: 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 return null
# If the attachment carries its own skin, retarget it to the canonical # If the attachment carries its own skin, retarget it to the canonical
@@ -296,50 +286,44 @@ func reset() -> void:
# Internal: GLB Parsing # 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) ## Avatar (Node3D)
## Skeleton3D -- the canonical rig, with skinned body meshes as children ## Skeleton3D -- the canonical rig + skin-bound body meshes
## BodyMeshes (Node3D) -- references to the skinned body meshes, for slot hiding ## BodyMeshes (Node3D) -- unused container, kept for compatibility
## Attachments (Node3D)-- equipped attachment nodes (added by load_attachment) ## Attachments (Node3D)-- equipped attachment nodes (added by load_attachment)
## ##
## Rather than hand-copying the GLB skeleton bone-by-bone (which loses the skin ## Uses load().instantiate() rather than the runtime GLTFDocument path: the
## binding), this uses GLTFDocument.generate_scene() -- which already produces a ## imported .scn is the one that actually ends up in an exported build, with
## correctly skin-bound Skeleton3D + MeshInstance3D tree -- and lifts that ## the skeleton + skin set up by Godot's import pipeline. (The raw-bytes
## skeleton into our container structure intact. ## GLTFDocument path -- for future user-uploaded models -- misbehaved in a
func _parse_glb_as_avatar(glb_data: PackedByteArray, path: String) -> Node3D: ## release build: no Skeleton3D produced, so the player fell back to a capsule.)
var gltf_document := GLTFDocument.new() func _instance_glb_as_avatar(path: String) -> Node3D:
var gltf_state := GLTFState.new() var packed = load(path)
if not (packed is PackedScene):
var parse_result := gltf_document.append_from_buffer(glb_data, "", gltf_state) push_error("Base body is not an importable scene: %s" % path)
if parse_result != OK:
push_error("GLB parse error: %s" % error_string(parse_result))
return null return null
var scene_root: Node = packed.instantiate()
var scene_root := gltf_document.generate_scene(gltf_state)
if not (scene_root is Node3D): if not (scene_root is Node3D):
push_error("Failed to generate scene from GLB: %s" % path) push_error("Base body scene root is not a Node3D: %s" % path)
if scene_root: scene_root.free()
scene_root.queue_free()
return null return null
var src_skel := _find_first_skeleton(scene_root) var src_skel := _find_first_skeleton(scene_root)
if src_skel == null: if src_skel == null:
push_error("No Skeleton3D in generated scene for base body: %s" % path) push_error("No Skeleton3D in base body scene: %s" % path)
scene_root.queue_free() scene_root.free()
return null return null
var avatar := Node3D.new() var avatar := Node3D.new()
avatar.name = "Avatar" 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.get_parent().remove_child(src_skel)
src_skel.name = "Skeleton3D" src_skel.name = "Skeleton3D"
src_skel.transform = Transform3D.IDENTITY src_skel.transform = Transform3D.IDENTITY
_clear_owner_recursive(src_skel) _clear_owner_recursive(src_skel)
avatar.add_child(src_skel) avatar.add_child(src_skel)
scene_root.queue_free() scene_root.free()
var body_meshes := Node3D.new() var body_meshes := Node3D.new()
body_meshes.name = "BodyMeshes" body_meshes.name = "BodyMeshes"
@@ -363,34 +347,26 @@ func _find_first_skeleton(node: Node) -> Skeleton3D:
return null return null
## Parse a GLB byte array into an attachment Node3D. ## Instantiate an attachment .glb (imported PackedScene) into a single named
func _parse_glb_as_attachment(glb_data: PackedByteArray, path: String, ## Node3D the caller can mount to a bone as one unit.
slot_id: String) -> Node3D: func _instance_glb_as_attachment(path: String, slot_id: String) -> Node3D:
var packed = load(path)
var gltf_document := GLTFDocument.new() if not (packed is PackedScene):
var gltf_state := GLTFState.new() push_error("Attachment is not an importable scene: %s" % path)
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))
return null return null
var scene_root: Node = packed.instantiate()
var scene_root := gltf_document.generate_scene(gltf_state)
if not (scene_root is Node3D): if not (scene_root is Node3D):
push_error("Failed to generate scene from attachment GLB: %s" % path) push_error("Attachment scene root is not a Node3D: %s" % path)
if scene_root: scene_root.free()
scene_root.queue_free()
return null 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() var attachment := Node3D.new()
attachment.name = "%s_%s" % [slot_id, path.get_file().get_basename()] attachment.name = "%s_%s" % [slot_id, path.get_file().get_basename()]
for child in scene_root.get_children().duplicate(): for child in scene_root.get_children().duplicate():
scene_root.remove_child(child) scene_root.remove_child(child)
_clear_owner_recursive(child) _clear_owner_recursive(child)
attachment.add_child(child) attachment.add_child(child)
scene_root.queue_free() scene_root.free()
return attachment return attachment
+17
View File
@@ -191,6 +191,23 @@ def main():
r = run([godot_bin, "--headless", "--path", str(ROOT), r = run([godot_bin, "--headless", "--path", str(ROOT),
"--script", "res://scripts/verify_export_contents.gd"], env=godot_env) "--script", "res://scripts/verify_export_contents.gd"], env=godot_env)
results["export packaging"] = r.returncode == 0 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: finally:
server_proc.terminate() server_proc.terminate()
try: try: