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>
256 lines
9.1 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|