makepad/libs/game/assets/tests/rigged.rs
Admin 0ac21bdc9d A real cast: 36 rigged characters across 3 shared rigs — and Kenney IS rigged
THE FINDING THAT CHANGES THE PREMISE: "1 rigged model in 4,442" was measured
with the BROKEN GLB probe (the gLUF magic bug). With the probe fixed the
library holds 36 rigged models across three rigs:
  41 joints — 9 KayKit heroes + undead, up to 95 clips
   7 joints — 22 KENNEY civilians (male/female a-f, orc, human, archer, shop
              employees, skaters, soldiers), 32 clips
   6 joints — 5 Kenney platformer characters, 25 clips
So a village can be populated with 22 visually distinct civilians TODAY, with
no third-party pack at all. Every conclusion drawn from that probe before it
was fixed needs re-checking, not just this one.

KayKit: 9 characters fetched (Adventurers + Skeletons), pinned by commit +
sha256, 37 MB, gitignored. CC0 verified by READING LICENSE.txt at each pinned
commit, recorded in the script header and CREDITS.toml.

THE SHARED RIG HOLDS ACROSS PACKS, proven rather than assumed: hashing the
joint-name list of all nine files yields the SAME digest — 41 joints, same
names, same order — despite two separate repositories. Skeleton clips are a
strict superset (95 = the adventurers' 76 + 19 undead extras: awaken,
resurrect, spawn, taunt). So a clip authored for the knight plays on the
skeleton warrior and one animation path drives the cast. A test pins this,
including that both packs are present, so a version bump cannot silently break
it.

The texture trap that cost the Kenney fetch three attempts does NOT apply:
KayKit GLBs EMBED their atlas (image/png in a bufferView), verified by parsing
all nine.

tests/rigged.rs parses all 36 rigged models through makepad_game_render::skin
— the loader the app actually runs — and asserts the index's joint and clip
counts match it. Deliberate: the index's own probe was wrong for the entire
library once and survived because the fixture encoded the same error.

find_cast groups by JOINT COUNT rather than pack, because the valuable fact is
cross-pack interchangeability. Cast states are the INTERSECTION, not the union
— advertising a state one member cannot perform is worse than a shorter list.
Added the state words the skeletons needed (spawn/resurrect/taunt/use):
Skeletons_Awaken_Floor previously matched nothing, so "an undead that rises
from the ground" was unfindable.

One bug found in its own work: casts_to_json emitted a doubled closing brace —
malformed JSON that still looked fine in a log. Fixed with a structural test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 11:43:42 +02:00

256 lines
9.1 KiB
Rust

//! The rigged cast, verified against the loader that will actually load it.
//!
//! The index's own GLB probe is a lightweight reader — and it was silently
//! wrong for the entire library until recently (its magic constant spelled
//! "gLUF", so every file failed the check and every model indexed as
//! unrigged, unanimated and sizeless). That bug survived because the test
//! fixture wrote the same wrong magic, so probe and test agreed with each
//! other while both disagreed with reality.
//!
//! These tests exist so that cannot happen again: they parse each character
//! with `makepad_game_render::skin`, the code the app runs, and assert the
//! index's claims match it. A character that indexes but will not load is
//! worse than one that is absent, because only the second is visible.
use std::path::{Path, PathBuf};
use makepad_game_assets::AssetIndex;
fn resources() -> Option<PathBuf> {
let p = Path::new("../../../apps/arcade/resources");
let p = if p.is_dir() {
p.to_path_buf()
} else {
PathBuf::from("apps/arcade/resources")
};
p.is_dir().then_some(p)
}
/// Every entry the index calls rigged must parse through the real skin loader,
/// and report the same joint and clip counts the index advertises.
#[test]
fn every_rigged_entry_loads_and_matches_the_index() {
let Some(root) = resources() else { return };
let idx = AssetIndex::build(&root);
let rigged: Vec<_> = idx.entries().iter().filter(|e| e.rigged).collect();
if rigged.is_empty() {
// No packs downloaded — run apps/arcade/download_assets.sh.
return;
}
for e in &rigged {
let bytes = match std::fs::read(&e.path) {
Ok(b) => b,
Err(err) => panic!("{}: indexed but unreadable: {err}", e.id),
};
let model = match makepad_game_render::skin::SkinnedModel::parse_glb(&bytes) {
Ok(m) => m,
Err(err) => panic!("{}: indexed as rigged but the loader rejects it: {err}", e.id),
};
assert_eq!(
model.joint_count() as u32,
e.joints,
"{}: index says {} joints, loader found {}",
e.id,
e.joints,
model.joint_count()
);
assert_eq!(
model.clips.len(),
e.clips.len(),
"{}: index says {} clips, loader found {}",
e.id,
e.clips.len(),
model.clips.len()
);
assert!(
model.joint_count() > 0,
"{}: rigged with zero joints is not a rig",
e.id
);
}
}
/// The KayKit cast shares one skeleton ACROSS TWO SEPARATE DOWNLOADS, which is
/// the property that makes it worth having: a clip authored for the knight
/// plays on the skeleton warrior, so an AI can recast a part without touching
/// animation code. If a future pack bump breaks that, the whole "one animation
/// path serves the cast" assumption goes with it.
#[test]
fn the_kaykit_cast_shares_one_rig_across_both_packs() {
let Some(root) = resources() else { return };
let idx = AssetIndex::build(&root);
let kaykit: Vec<_> = idx
.entries()
.iter()
.filter(|e| e.rigged && e.id.starts_with("kaykit/"))
.collect();
if kaykit.is_empty() {
return;
}
let joints = kaykit[0].joints;
for e in &kaykit {
assert_eq!(
e.joints, joints,
"{} has {} joints, expected the shared {joints}",
e.id, e.joints
);
}
// Adventurers and skeletons are different repositories; both must be here,
// or the test is only checking one pack against itself.
assert!(
kaykit.iter().any(|e| e.id.contains("skeleton_")),
"no skeletons — the cross-pack half of the shared-rig claim is untested"
);
assert!(
kaykit.iter().any(|e| !e.id.contains("skeleton_")),
"no adventurers"
);
// Skeletons carry the adventurers' clips plus undead extras, so the
// superset direction is what must hold.
let adv: Vec<&String> = kaykit
.iter()
.find(|e| e.id.ends_with("/knight"))
.map(|e| e.clips.iter().collect())
.unwrap_or_default();
if let Some(skel) = kaykit.iter().find(|e| e.id.ends_with("/skeleton_warrior")) {
for clip in &adv {
assert!(
skel.clips.contains(clip),
"skeleton_warrior is missing the knight's {clip:?} — the casts are no longer interchangeable"
);
}
}
}
/// A cast advertises only the states EVERY member can perform.
#[test]
fn cast_states_are_the_intersection_not_the_union() {
let Some(root) = resources() else { return };
let idx = AssetIndex::build(&root);
for cast in idx.casts() {
for id in &cast.members {
let e = idx.entries().iter().find(|e| &e.id == id).unwrap();
let mine = makepad_game_assets::states::states_from_clips(&e.clips);
for s in &cast.shared_states {
assert!(
mine.contains(s),
"cast of {} joints claims {s:?}, but {id} cannot do it",
cast.joints
);
}
}
}
}
/// The states a game actually asks for by name must resolve to real characters.
#[test]
fn characters_are_findable_by_what_they_can_do() {
let Some(root) = resources() else { return };
let idx = AssetIndex::build(&root);
if idx.entries().iter().all(|e| !e.rigged) {
return;
}
for query in [
"a character that can attack",
"someone who can run and jump",
"a skeleton enemy",
"a character that can die",
] {
let hits = idx.find(query);
assert!(
hits.iter().take(8).any(|h| h.entry.rigged),
"{query:?} returned no rigged character in its top hits"
);
}
}
/// The cast tool's JSON must actually parse.
///
/// Hand-rolled JSON emission is where brace arithmetic goes wrong silently —
/// this exact function shipped a doubled closing brace, and nothing noticed
/// because the string still *looked* like JSON in a log. A structural check is
/// cheap; a malformed tool result costs the AI a whole turn.
#[test]
fn cast_json_is_well_formed_and_small() {
let Some(root) = resources() else { return };
let idx = AssetIndex::build(&root);
let casts = makepad_game_assets::agent::execute_cast(&idx, None);
if casts.is_empty() {
return;
}
let json = makepad_game_assets::agent::casts_to_json(&casts, 6);
// Balance and nesting, without pulling in a JSON parser.
let mut depth_obj = 0i32;
let mut depth_arr = 0i32;
let mut in_str = false;
for ch in json.chars() {
match ch {
'"' => in_str = !in_str,
'{' if !in_str => depth_obj += 1,
'}' if !in_str => depth_obj -= 1,
'[' if !in_str => depth_arr += 1,
']' if !in_str => depth_arr -= 1,
_ => {}
}
assert!(depth_obj >= 0 && depth_arr >= 0, "unbalanced close in: {json}");
}
assert_eq!(depth_obj, 0, "unclosed/extra object brace in: {json}");
assert_eq!(depth_arr, 0, "unclosed/extra array bracket in: {json}");
assert!(!in_str, "unterminated string in: {json}");
assert!(!json.contains("}}"), "doubled closing brace in: {json}");
// Every turn pays for this, so it must stay a summary rather than a dump:
// the 41-joint cast alone has 95 clips per member.
assert!(json.len() < 4096, "cast JSON is {} bytes — too fat for a prompt", json.len());
}
/// A character's texture must be resolvable, or it renders untextured — the
/// failure mode that cost three attempts on the Kenney packs, where an atlas
/// existed on disk at a path no model referenced.
///
/// KayKit differs from Kenney: its GLBs EMBED the atlas, so there is no
/// external path to get wrong. The sidecar PNGs are fetched anyway because the
/// skin loader deliberately ignores materials and the app binds its own
/// texture. This asserts both halves hold.
#[test]
fn character_textures_resolve() {
let Some(root) = resources() else { return };
let idx = AssetIndex::build(&root);
let kaykit: Vec<_> = idx
.entries()
.iter()
.filter(|e| e.rigged && e.id.starts_with("kaykit/"))
.collect();
if kaykit.is_empty() {
return;
}
let dir = root.join("characters");
for e in &kaykit {
let bytes = std::fs::read(&e.path).expect("readable");
let model = makepad_game_render::skin::SkinnedModel::parse_glb(&bytes).expect("parses");
assert!(
model.vertex_count() > 0,
"{}: no vertices, so nothing to texture",
e.id
);
}
// The app loads <name>_texture.png beside the GLB; every character must
// have one it can reach (Rogue_Hooded re-skins rogue's).
for name in [
"knight_texture.png",
"barbarian_texture.png",
"mage_texture.png",
"rogue_texture.png",
"skeleton_texture.png",
] {
assert!(
dir.join(name).is_file(),
"{name} missing — characters would render untextured"
);
}
}