//! Conformance-corpus generator + verifier for the interactive-worlds
//! extension (`world-conformance/` at the repo root).
//!
//!   wai_world_vectors gen <dir>      regenerate the vector set
//!   wai_world_vectors verify <dir>   replay every vector, compare hashes
//!
//! Everything is deterministic — fixed actor/publisher keys, no clock,
//! no entropy — so `gen` is reproducible byte-for-byte and `verify`
//! passes by hash equality with **no tolerance parameter** (extension
//! §7.3). An independent implementation conforms iff `verify` passes
//! against vectors it did not generate.

use ed25519_dalek::SigningKey;
use std::path::Path;
use wai::world::replay::Ingest;
use wai::world::sim::{
    bal_mutation, kin_mutation, pack_fx, BalNode, KinNode, CAP_SIM_BALLISTIC0, CAP_SIM_KINEMATIC0,
};
use wai::world::truss::{pack_member, truss_mutation, CAP_SIM_TRUSS0};
use wai::world::{
    mint_node_id, run_sealed_dispatch, Ballistic0, CausalOp, Fx, Kinematic0, LiveWorld, Sim,
    SimContract, Truss0, TrussNode, WorldContainer, WorldState, Xoshiro256pp, NUMERIC_FIXED64,
    RESOLVE_LWW,
};

fn key(seed: u8) -> SigningKey {
    SigningKey::from_bytes(&[seed; 32])
}

const PUBLISHER_SEED: u8 = 9;

fn contract() -> SimContract {
    SimContract {
        tick_rate: 60,
        numeric: NUMERIC_FIXED64.into(),
        rng_alg: "xoshiro256++".into(),
        rng_seed: [0x42; 32],
        sim: CAP_SIM_KINEMATIC0.into(),
        resolve_default: RESOLVE_LWW.into(),
        checkpoint_every: 100,
    }
}

fn scene() -> WorldContainer {
    let c = contract();
    WorldContainer {
        contract_bytes: c.to_canonical_json(),
        contract: c,
        graph_bytes: WorldState::<KinNode>::new().canonical_bytes(&Kinematic0),
        assets: vec![],
        ops: vec![],
        seal: None,
    }
}

fn scene_truss() -> WorldContainer {
    let mut c = contract();
    c.sim = CAP_SIM_TRUSS0.into();
    WorldContainer {
        contract_bytes: c.to_canonical_json(),
        contract: c,
        graph_bytes: WorldState::<TrussNode>::new().canonical_bytes(&Truss0),
        assets: vec![],
        ops: vec![],
        seal: None,
    }
}

fn scene_ballistic() -> WorldContainer {
    let mut c = contract();
    c.sim = CAP_SIM_BALLISTIC0.into();
    WorldContainer {
        contract_bytes: c.to_canonical_json(),
        contract: c,
        graph_bytes: WorldState::<BalNode>::new().canonical_bytes(&Ballistic0),
        assets: vec![],
        ops: vec![],
        seal: None,
    }
}

fn create_op(k: &SigningKey, lamport: u64, tick: u64, vel: [i32; 3]) -> CausalOp {
    let target = mint_node_id(&k.verifying_key().to_bytes(), lamport);
    CausalOp::sign(
        k, lamport, tick, vec![], target, kin_mutation::CREATE,
        pack_fx(&[
            Fx::ZERO, Fx::ZERO, Fx::ZERO,
            Fx::from_int(vel[0]), Fx::from_int(vel[1]), Fx::from_int(vel[2]),
            Fx::ZERO,
        ]),
    )
}

fn hex(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

fn from_hex32(s: &str) -> [u8; 32] {
    let mut out = [0u8; 32];
    for (i, c) in s.as_bytes().chunks(2).enumerate() {
        out[i] = u8::from_str_radix(std::str::from_utf8(c).unwrap(), 16).unwrap();
    }
    out
}

// ---------------------------------------------------------------------------
// Vector construction (shared by gen + verify-time expectations)
// ---------------------------------------------------------------------------

/// Two actors, creates + cross-impulse + scalar set + reposition + delete.
fn ops_basic() -> Vec<CausalOp> {
    let a = key(1);
    let b = key(2);
    let n1 = mint_node_id(&a.verifying_key().to_bytes(), 1);
    let n2 = mint_node_id(&b.verifying_key().to_bytes(), 1);
    vec![
        create_op(&a, 1, 1, [1, 0, 0]),
        create_op(&b, 1, 1, [0, 1, 0]),
        CausalOp::sign(&a, 2, 50, vec![], n2, kin_mutation::IMPULSE, pack_fx(&[Fx::from_int(2), Fx::ZERO, Fx::ZERO])),
        CausalOp::sign(&b, 2, 50, vec![], n1, kin_mutation::SET_SCALAR, pack_fx(&[Fx::from_int(7)])),
        CausalOp::sign(&a, 3, 120, vec![], n1, kin_mutation::SET_POS, pack_fx(&[Fx::ZERO, Fx::ZERO, Fx::from_int(5)])),
        CausalOp::sign(&b, 3, 180, vec![], n2, kin_mutation::DELETE, vec![]),
    ]
}

/// Same-tick same-node SET_SCALAR from two actors — LWW battery.
fn ops_conflict_lww() -> Vec<CausalOp> {
    let a = key(1);
    let b = key(2);
    let n1 = mint_node_id(&a.verifying_key().to_bytes(), 1);
    vec![
        create_op(&a, 1, 1, [0, 0, 0]),
        CausalOp::sign(&a, 2, 10, vec![], n1, kin_mutation::SET_SCALAR, pack_fx(&[Fx::from_int(1)])),
        CausalOp::sign(&b, 3, 10, vec![], n1, kin_mutation::SET_SCALAR, pack_fx(&[Fx::from_int(2)])),
    ]
}

/// Same-tick same-node opposite IMPULSE — SUM battery (both act, net 0).
fn ops_conflict_sum() -> Vec<CausalOp> {
    let a = key(1);
    let b = key(2);
    let n1 = mint_node_id(&a.verifying_key().to_bytes(), 1);
    vec![
        create_op(&a, 1, 1, [0, 0, 0]),
        CausalOp::sign(&a, 2, 10, vec![], n1, kin_mutation::IMPULSE, pack_fx(&[Fx::from_int(3), Fx::ZERO, Fx::ZERO])),
        CausalOp::sign(&b, 2, 10, vec![], n1, kin_mutation::IMPULSE, pack_fx(&[Fx::from_int(-3), Fx::ZERO, Fx::ZERO])),
    ]
}

/// Dependent op listed BEFORE its dep — live sinks must buffer, then drain.
fn ops_dep_gap() -> Vec<CausalOp> {
    let a = key(1);
    let create = create_op(&a, 1, 1, [0, 0, 0]);
    let n1 = mint_node_id(&a.verifying_key().to_bytes(), 1);
    let dependent = CausalOp::sign(
        &a, 2, 5, vec![create.id()], n1, kin_mutation::SET_SCALAR, pack_fx(&[Fx::from_int(9)]),
    );
    vec![dependent, create] // arrival order: dep-gap first
}

/// An op for tick 30 arriving after ops for tick 90 — past-tick battery.
/// (Arrival order is the file order; live sinks must re-derive.)
fn ops_past_tick() -> Vec<CausalOp> {
    let a = key(1);
    let n1 = mint_node_id(&a.verifying_key().to_bytes(), 1);
    vec![
        create_op(&a, 1, 1, [1, 0, 0]),
        CausalOp::sign(&a, 2, 90, vec![], n1, kin_mutation::SET_SCALAR, pack_fx(&[Fx::from_int(4)])),
        CausalOp::sign(&a, 3, 30, vec![], n1, kin_mutation::IMPULSE, pack_fx(&[Fx::from_int(1), Fx::ZERO, Fx::ZERO])),
    ]
}

/// The tug-of-war: two free particles joined by a yield-10% member;
/// two actors apply equal-opposite impulses at the same tick (the SUM
/// rule feeding the solver); the member's constraint violation exceeds
/// yield and it fractures — structural failure as an emergent,
/// bit-exact solver consequence.
fn ops_truss_fracture() -> Vec<CausalOp> {
    let a = key(1);
    let b = key(2);
    let pa = mint_node_id(&a.verifying_key().to_bytes(), 1);
    let pb = mint_node_id(&b.verifying_key().to_bytes(), 1);
    let tenth = Fx::ONE / Fx::from_int(10);
    let mk_particle = |k: &SigningKey, lamport: u64, x: i32| {
        let target = mint_node_id(&k.verifying_key().to_bytes(), lamport);
        CausalOp::sign(
            k, lamport, 1, vec![], target, truss_mutation::CREATE_PARTICLE,
            pack_fx(&[
                Fx::from_int(x), Fx::from_int(5), Fx::ZERO,
                Fx::ZERO, Fx::ZERO, Fx::ZERO,
                Fx::ONE,
            ]),
        )
    };
    vec![
        mk_particle(&a, 1, -1),
        mk_particle(&b, 1, 1),
        CausalOp::sign(
            &a, 2, 2, vec![], mint_node_id(&a.verifying_key().to_bytes(), 2),
            truss_mutation::CREATE_MEMBER, pack_member(&pa, &pb, tenth),
        ),
        CausalOp::sign(&a, 3, 10, vec![], pa, truss_mutation::IMPULSE, pack_fx(&[Fx::from_int(-20), Fx::ZERO, Fx::ZERO])),
        CausalOp::sign(&b, 3, 10, vec![], pb, truss_mutation::IMPULSE, pack_fx(&[Fx::from_int(20), Fx::ZERO, Fx::ZERO])),
    ]
}

fn seal_from_live<M: Sim>(sim: M, mut c: WorldContainer, ops: &[CausalOp], final_tick: u64) -> WorldContainer {
    let mut live = LiveWorld::open(sim, c.clone()).expect("open");
    for op in ops {
        let outcome = live.ingest(op.clone()).expect("ingest");
        assert!(matches!(outcome, Ingest::Applied | Ingest::Buffered));
    }
    let final_hash = live.advance_to(final_tick).expect("advance");
    c.ops = live.canonical_ops();
    c.seal(&key(PUBLISHER_SEED), final_tick, final_hash);
    c
}

/// One ball dropped from y=10 (radius 1, restitution ½), nudged
/// sideways mid-flight; 300 ticks of gravity + ground bounces.
fn ops_ballistic() -> Vec<CausalOp> {
    let a = key(1);
    let target = mint_node_id(&a.verifying_key().to_bytes(), 1);
    let half = Fx::ONE / Fx::from_int(2);
    vec![
        CausalOp::sign(
            &a, 1, 1, vec![], target, bal_mutation::CREATE,
            pack_fx(&[
                Fx::ZERO, Fx::from_int(10), Fx::ZERO, // pos
                Fx::ZERO, Fx::ZERO, Fx::ZERO,         // vel
                Fx::ONE,                              // radius
                half,                                 // restitution
            ]),
        ),
        CausalOp::sign(&a, 2, 30, vec![], target, bal_mutation::IMPULSE, pack_fx(&[Fx::from_int(2), Fx::ZERO, Fx::ZERO])),
    ]
}

fn write_ops(path: &Path, ops: &[CausalOp]) {
    let bytes: Vec<u8> = ops.iter().flat_map(|o| o.to_bytes()).collect();
    std::fs::write(path, bytes).expect("write ops");
}

/// Deterministic shuffles of the basic op set (seeded xoshiro — the
/// corpus has no entropy anywhere).
fn shuffles(ops: &[CausalOp], n: usize) -> Vec<Vec<CausalOp>> {
    let mut rng = Xoshiro256pp::from_seed([5u8; 32]);
    (0..n)
        .map(|_| {
            let mut s = ops.to_vec();
            for i in (1..s.len()).rev() {
                let j = (rng.next_u64() % (i as u64 + 1)) as usize;
                s.swap(i, j);
            }
            s
        })
        .collect()
}

// ---------------------------------------------------------------------------

fn generate(dir: &Path) {
    let vectors = dir.join("vectors");
    std::fs::create_dir_all(&vectors).expect("mkdir");
    let mut manifest = serde_json::Map::new();

    // Sealed replay vectors: (name, sealed container)
    let sealed: Vec<(&str, WorldContainer)> = vec![
        ("basic_kinematics", seal_from_live(Kinematic0, scene(), &ops_basic(), 200)),
        ("conflict_lww", seal_from_live(Kinematic0, scene(), &ops_conflict_lww(), 100)),
        ("conflict_sum", seal_from_live(Kinematic0, scene(), &ops_conflict_sum(), 100)),
        ("ballistic_bounce", seal_from_live(Ballistic0, scene_ballistic(), &ops_ballistic(), 300)),
        ("truss_fracture", seal_from_live(Truss0, scene_truss(), &ops_truss_fracture(), 120)),
    ];
    for (name, c) in &sealed {
        let file = format!("{name}.wwld");
        std::fs::write(vectors.join(&file), c.to_bytes()).expect("write wwld");
        let cps = run_sealed_dispatch(c).expect("replay");
        manifest.insert(
            file,
            serde_json::json!({
                "type": "sealed",
                "publisher": hex(&key(PUBLISHER_SEED).verifying_key().to_bytes()),
                "checkpoints": cps.iter()
                    .map(|cp| serde_json::json!({"tick": cp.tick, "hash": hex(&cp.hash)}))
                    .collect::<Vec<_>>(),
            }),
        );
    }

    // Live-ingestion vectors: scene + an op stream in a fixed (adversarial)
    // arrival order + the hash every conformant sink must reach.
    let scene_file = "scene.wwld";
    std::fs::write(vectors.join(scene_file), scene().to_bytes()).expect("write scene");

    let mut live_vectors: Vec<(String, Vec<CausalOp>, u64)> = vec![
        ("dep_gap".into(), ops_dep_gap(), 10),
        ("past_tick".into(), ops_past_tick(), 100),
    ];
    for (i, s) in shuffles(&ops_basic(), 3).into_iter().enumerate() {
        live_vectors.push((format!("basic_shuffled_{}", i + 1), s, 200));
    }
    for (name, arrival, final_tick) in &live_vectors {
        let file = format!("{name}.ops");
        write_ops(&vectors.join(&file), arrival);
        let mut live = LiveWorld::open(Kinematic0, scene()).expect("open");
        for op in arrival {
            live.ingest(op.clone()).expect("ingest");
        }
        let h = live.advance_to(*final_tick).expect("advance");
        manifest.insert(
            file,
            serde_json::json!({
                "type": "live",
                "scene": scene_file,
                "final_tick": final_tick,
                "final_hash": hex(&h),
            }),
        );
    }

    let json = serde_json::to_string_pretty(&serde_json::Value::Object(manifest)).unwrap();
    std::fs::write(dir.join("expected_hashes.json"), json + "\n").expect("write manifest");
    println!("generated vectors into {}", dir.display());
}

/// Ingest an op stream in file order, advance, return the final hash —
/// `None` if any ingest fails or any op is left dep-gated.
fn live_final_hash<M: Sim>(
    sim: M,
    scene: WorldContainer,
    ops: Vec<CausalOp>,
    final_tick: u64,
) -> Option<[u8; 32]> {
    let mut live = LiveWorld::open(sim, scene).ok()?;
    for op in ops {
        live.ingest(op).ok()?;
    }
    if live.pending_len() != 0 {
        return None;
    }
    live.advance_to(final_tick).ok()
}

fn verify(dir: &Path) {
    let manifest: serde_json::Value = serde_json::from_slice(
        &std::fs::read(dir.join("expected_hashes.json")).expect("read expected_hashes.json"),
    )
    .expect("parse manifest");
    let vectors = dir.join("vectors");
    let mut failures = 0usize;

    for (file, spec) in manifest.as_object().expect("manifest object") {
        let kind = spec["type"].as_str().unwrap_or("");
        let ok = match kind {
            "sealed" => {
                let bytes = std::fs::read(vectors.join(file)).expect("read vector");
                let c = WorldContainer::from_bytes(&bytes).expect("parse wwld");
                let publisher = ed25519_dalek::VerifyingKey::from_bytes(&from_hex32(
                    spec["publisher"].as_str().expect("publisher"),
                ))
                .expect("publisher key");
                c.verify_seal(&publisher).is_ok()
                    && match run_sealed_dispatch(&c) {
                        Ok(cps) => {
                            let expected: Vec<(u64, String)> = spec["checkpoints"]
                                .as_array()
                                .expect("checkpoints")
                                .iter()
                                .map(|c| (c["tick"].as_u64().unwrap(), c["hash"].as_str().unwrap().into()))
                                .collect();
                            cps.len() == expected.len()
                                && cps.iter().zip(&expected).all(|(got, (t, h))| {
                                    got.tick == *t && hex(&got.hash) == *h
                                })
                        }
                        Err(_) => false,
                    }
            }
            "live" => {
                let scene_bytes =
                    std::fs::read(vectors.join(spec["scene"].as_str().expect("scene"))).expect("read scene");
                let scene = WorldContainer::from_bytes(&scene_bytes).expect("parse scene");
                let ops_bytes = std::fs::read(vectors.join(file)).expect("read ops");
                let ops = CausalOp::parse_log(&ops_bytes).expect("parse ops");
                let final_tick = spec["final_tick"].as_u64().expect("final_tick");
                let got = match scene.contract.sim.as_str() {
                    CAP_SIM_KINEMATIC0 => live_final_hash(Kinematic0, scene, ops, final_tick),
                    CAP_SIM_BALLISTIC0 => live_final_hash(Ballistic0, scene, ops, final_tick),
                    CAP_SIM_TRUSS0 => live_final_hash(Truss0, scene, ops, final_tick),
                    other => {
                        eprintln!("{file}: unknown sim capability `{other}`");
                        None
                    }
                };
                got.map(|h| hex(&h)) == Some(spec["final_hash"].as_str().expect("final_hash").to_string())
            }
            other => {
                eprintln!("{file}: unknown vector type `{other}`");
                false
            }
        };
        if ok {
            println!("PASS {file}");
        } else {
            println!("FAIL {file}");
            failures += 1;
        }
    }

    if failures > 0 {
        eprintln!("{failures} vector(s) failed — NOT conformant");
        std::process::exit(1);
    }
    println!("all vectors pass: replay-equivalence holds");
}

fn main() {
    let args: Vec<String> = std::env::args().collect();
    match (args.get(1).map(String::as_str), args.get(2)) {
        (Some("gen"), Some(dir)) => generate(Path::new(dir)),
        (Some("gen-playground"), Some(file)) => gen_playground(Path::new(file)),
        (Some("gen-gallery"), Some(dir)) => gen_gallery(Path::new(dir)),
        (Some("verify"), Some(dir)) => verify(Path::new(dir)),
        _ => {
            eprintln!("usage: wai_world_vectors <gen|verify> <corpus-dir>");
            std::process::exit(2);
        }
    }
}

// ---------------------------------------------------------------------------
// Playground scene (demo, not a conformance vector): a live truss tower
// for the multiplayer browser sink. Two anchors, three braced levels,
// yield 20% — shove it until the bracing snaps. No seal: wai.world.live.
// ---------------------------------------------------------------------------

fn gen_playground(path: &Path) {
    use wai::world::truss::TrussNode;
    use wai::world::Fx;

    let publisher = key(PUBLISHER_SEED);
    let pub_actor = publisher.verifying_key().to_bytes();
    let mut lamport = 0u64;
    let mut mint = || {
        lamport += 1;
        mint_node_id(&pub_actor, lamport)
    };

    let mut world: WorldState<TrussNode> = WorldState::new();
    let fx = |v: f64| Fx::from_bits((v * 4294967296.0).round() as i64);

    // Particles: two ground anchors + three braced levels.
    let mut particle = |world: &mut WorldState<TrussNode>, x: f64, y: f64, anchored: bool| {
        let id = mint();
        world.nodes.insert(
            id,
            TrussNode::Particle {
                pos: [fx(x), fx(y), Fx::ZERO],
                vel: [Fx::ZERO; 3],
                inv_mass: if anchored { Fx::ZERO } else { Fx::ONE },
            },
        );
        id
    };
    let a0 = particle(&mut world, -2.0, 0.0, true);
    let a1 = particle(&mut world, 2.0, 0.0, true);
    let l1l = particle(&mut world, -2.0, 2.5, false);
    let l1r = particle(&mut world, 2.0, 2.5, false);
    let l2l = particle(&mut world, -2.0, 5.0, false);
    let l2r = particle(&mut world, 2.0, 5.0, false);
    let l3l = particle(&mut world, -2.0, 7.5, false);
    let l3r = particle(&mut world, 2.0, 7.5, false);

    // Members: verticals, horizontals, X-bracing. Yield strain 12.5% —
    // playable demolition: an axially-aligned point-blank shove (the
    // page caps impulse at 34 u/s ≈ 0.57 u/tick) out-runs a vertical's
    // 0.31 violation budget; off-axis shoves just sway the tower.
    let yield_strain = Fx::ONE / Fx::from_int(8);
    let dist = |world: &WorldState<TrussNode>, a: &[u8; 16], b: &[u8; 16]| {
        let (pa, pb) = match (world.nodes.get(a), world.nodes.get(b)) {
            (Some(TrussNode::Particle { pos: pa, .. }), Some(TrussNode::Particle { pos: pb, .. })) => (*pa, *pb),
            _ => unreachable!("playground members reference existing particles"),
        };
        let d = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]];
        (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
    };
    let mut member = |world: &mut WorldState<TrussNode>, a: [u8; 16], b: [u8; 16]| {
        let rest = dist(world, &a, &b);
        let id = mint();
        world.nodes.insert(id, TrussNode::Member { a, b, rest, yield_strain });
    };
    for (a, b) in [
        (a0, l1l), (a1, l1r),           // ground verticals
        (l1l, l2l), (l1r, l2r),         // mid verticals
        (l2l, l3l), (l2r, l3r),         // top verticals
        (l1l, l1r), (l2l, l2r), (l3l, l3r), // horizontals
        (a0, l1r), (a1, l1l),           // bay 1 X-brace
        (l1l, l2r), (l1r, l2l),         // bay 2 X-brace
        (l2l, l3r), (l2r, l3l),         // bay 3 X-brace
    ] {
        member(&mut world, a, b);
    }

    let mut c = scene_truss();
    c.graph_bytes = world.canonical_bytes(&Truss0);
    std::fs::write(path, c.to_bytes()).expect("write playground");
    println!(
        "playground: {} nodes ({} bytes) -> {}",
        world.nodes.len(),
        c.to_bytes().len(),
        path.display()
    );
}

// ---------------------------------------------------------------------------
// Gallery scene (demo): a static world that NAMES a wai.mesh.glb asset in
// its asset table (extension §4.1). The asset is a content-addressed WAI
// envelope wrapping a real glTF 2.0 Binary cube; the sink fetches it,
// verifies the content hash, decodes it, and renders it — while the state
// hash stays identical to the same scene with no asset (presentation only).
// ---------------------------------------------------------------------------

fn gen_gallery(dir: &Path) {
    use wai::world::sim::KinNode;
    use wai::world::{asset_content_hash, AssetEntry};
    use wai::{Conditioning, Manifest, ModelRequirement, Wai};

    let assets_dir = dir.join("assets");
    std::fs::create_dir_all(&assets_dir).expect("mkdir assets");

    // One static node at the origin the mesh binds to.
    let mut world: WorldState<KinNode> = WorldState::new();
    let id = mint_node_id(&key(PUBLISHER_SEED).verifying_key().to_bytes(), 1);
    world.nodes.insert(id, KinNode::default());

    // Wrap the cube glTF as a WAI envelope and content-address it (§4.1).
    let glb = cube_glb();
    let manifest = Manifest::new("mesh", "replicate", ModelRequirement::new("wai.mesh.glb"), Conditioning::new("glb"))
        .with_target(serde_json::json!({ "name": "cube" }));
    let envelope = Wai::new(manifest, glb).to_bytes().expect("wrap glb");
    let hash = asset_content_hash(&envelope);
    std::fs::write(assets_dir.join("cube.glb.wai"), &envelope).expect("write asset");

    let mut assets = vec![AssetEntry { hash, role: 0, name: "cube.glb".into() }];
    // If a real SPZ asset has been generated alongside (wai-spz-gen),
    // include it in the table too — same scene, a splat presentation.
    let spz_wai = dir.join("assets/nebula.spz.wai");
    if let Ok(env) = std::fs::read(&spz_wai) {
        assets.push(AssetEntry {
            hash: asset_content_hash(&env),
            role: 1,
            name: "nebula.spz".into(),
        });
        println!("  + nebula.spz asset in table (hash {}…)", &hex(&asset_content_hash(&env))[..12]);
    }
    let k = contract(); // kinematic0: a static node, no sim motion
    let c = WorldContainer {
        contract_bytes: k.to_canonical_json(),
        contract: k,
        graph_bytes: world.canonical_bytes(&Kinematic0),
        assets,
        ops: vec![],
        seal: None,
    };
    std::fs::write(dir.join("gallery.wwld"), c.to_bytes()).expect("write gallery");
    println!(
        "gallery: 1 node + 1 asset ({} B envelope, hash {}…) -> {}",
        envelope.len(),
        &hex(&hash)[..12],
        dir.join("gallery.wwld").display()
    );
}

/// A minimal valid glTF 2.0 Binary (.glb) of a unit cube: 8 positions,
/// 36 indices (12 triangles), one mesh / primitive. Deterministic.
fn cube_glb() -> Vec<u8> {
    // 8 corners of a unit cube centered at the origin.
    let pos: [[f32; 3]; 8] = [
        [-0.5, -0.5, -0.5], [0.5, -0.5, -0.5], [0.5, 0.5, -0.5], [-0.5, 0.5, -0.5],
        [-0.5, -0.5, 0.5], [0.5, -0.5, 0.5], [0.5, 0.5, 0.5], [-0.5, 0.5, 0.5],
    ];
    let idx: [u16; 36] = [
        0, 1, 2, 0, 2, 3, // back
        4, 6, 5, 4, 7, 6, // front
        0, 4, 5, 0, 5, 1, // bottom
        3, 2, 6, 3, 6, 7, // top
        0, 3, 7, 0, 7, 4, // left
        1, 5, 6, 1, 6, 2, // right
    ];

    let mut bin = Vec::new();
    for p in &pos {
        for v in p {
            bin.extend_from_slice(&v.to_le_bytes());
        }
    }
    let pos_len = bin.len() as u32; // 96
    for i in &idx {
        bin.extend_from_slice(&i.to_le_bytes());
    }
    let idx_len = bin.len() as u32 - pos_len; // 72
    while bin.len() % 4 != 0 {
        bin.push(0);
    }

    let json = format!(
        "{{\"asset\":{{\"version\":\"2.0\"}},\"scene\":0,\"scenes\":[{{\"nodes\":[0]}}],\"nodes\":[{{\"mesh\":0}}],\"meshes\":[{{\"primitives\":[{{\"attributes\":{{\"POSITION\":0}},\"indices\":1}}]}}],\"buffers\":[{{\"byteLength\":{total}}}],\"bufferViews\":[{{\"buffer\":0,\"byteOffset\":0,\"byteLength\":{pos_len},\"target\":34962}},{{\"buffer\":0,\"byteOffset\":{pos_len},\"byteLength\":{idx_len},\"target\":34963}}],\"accessors\":[{{\"bufferView\":0,\"componentType\":5126,\"count\":8,\"type\":\"VEC3\",\"min\":[-0.5,-0.5,-0.5],\"max\":[0.5,0.5,0.5]}},{{\"bufferView\":1,\"componentType\":5123,\"count\":36,\"type\":\"SCALAR\"}}]}}",
        total = bin.len(),
        pos_len = pos_len,
        idx_len = idx_len,
    );
    let mut json_bytes = json.into_bytes();
    while json_bytes.len() % 4 != 0 {
        json_bytes.push(b' ');
    }

    let total = 12 + 8 + json_bytes.len() + 8 + bin.len();
    let mut glb = Vec::with_capacity(total);
    glb.extend_from_slice(&0x4654_6C67u32.to_le_bytes()); // "glTF"
    glb.extend_from_slice(&2u32.to_le_bytes());
    glb.extend_from_slice(&(total as u32).to_le_bytes());
    glb.extend_from_slice(&(json_bytes.len() as u32).to_le_bytes());
    glb.extend_from_slice(&0x4E4F_534Au32.to_le_bytes()); // "JSON"
    glb.extend_from_slice(&json_bytes);
    glb.extend_from_slice(&(bin.len() as u32).to_le_bytes());
    glb.extend_from_slice(&0x004E_4942u32.to_le_bytes()); // "BIN\0"
    glb.extend_from_slice(&bin);
    glb
}
