ufftools 0.1.0

reader/writer and inspect CLI for the UFF character package format. includes the uff decoder lib for use in other projects.
Documentation
use std::{fs, process};
use ufftools::{read_uff, BoxType, LoopMode, SpriteEntry, UffAnimation, UffPackage};

fn loop_label(m: LoopMode) -> &'static str {
    match m {
        LoopMode::Once     => "once",
        LoopMode::Loop     => "loop",
        LoopMode::PingPong => "ping-pong",
    }
}

fn box_label(t: BoxType) -> &'static str {
    match t {
        BoxType::Attack          => "ATTACK",
        BoxType::AttackInactive  => "ATTACK_INACTIVE",
        BoxType::Hurt            => "HURT",
        BoxType::Stun            => "STUN",
        BoxType::PhysicalExtent  => "PHYSICAL_EXTENT",
        BoxType::Grab            => "GRAB",
        BoxType::GuardAir        => "GUARD_AIR",
        BoxType::GuardHigh       => "GUARD_HIGH",
        BoxType::GuardMed        => "GUARD_MED",
        BoxType::GuardLow        => "GUARD_LOW",
        BoxType::ThrowInvincible => "THROW_INVINCIBLE",
        BoxType::Invincible      => "INVINCIBLE",
    }
}

fn flag_str(e: &SpriteEntry) -> String {
    let mut f = String::new();
    if e.entry_flags & 0x01 != 0 { f.push_str("FLIP_H "); }
    if e.entry_flags & 0x02 != 0 { f.push_str("FLIP_V "); }
    if f.is_empty() { f.push('-'); }
    f.trim_end().to_string()
}

fn print_package_header(pkg: &UffPackage) {
    println!("Character : {}", pkg.char_name);
    println!("floor_y   : {} px", pkg.floor_y);
    println!("Animations: {}", pkg.animations.len());
}

fn print_anim_summary(idx: usize, a: &UffAnimation) {
    let floor = if a.floor_y_override > 0 {
        format!("{} px (override)", a.floor_y_override)
    } else {
        "inherit".to_string()
    };
    println!(
        "  [{idx:>3}] \"{name}\"  {fps} fps  {lm}  sheet {sw}×{sh}  frames {fc}  floor_y {floor}",
        idx   = idx,
        name  = a.name,
        fps   = a.default_fps,
        lm    = loop_label(a.loop_mode),
        sw    = a.sheet_w,
        sh    = a.sheet_h,
        fc    = a.sprite_table.len(),
        floor = floor,
    );
    let hb_frames  = a.frames.iter().filter(|f| !f.hitboxes.is_empty()).count();
    let total_hb: usize   = a.frames.iter().map(|f| f.hitboxes.len()).sum();
    let total_cues: usize = a.frames.iter().map(|f| f.audio_cues.len()).sum();
    let px = a.pixel_data.as_ref().map_or("none".into(), |d| format!("{} bytes", d.len()));
    println!(
        "        hitboxes: {total_hb} across {hb_frames} frames  audio_cues: {total_cues}  pixel_data: {px}"
    );
}

fn print_anim_verbose(a: &UffAnimation) {
    println!("    Sprite table ({} entries):", a.sprite_table.len());
    for (i, e) in a.sprite_table.iter().enumerate() {
        println!(
            "      [{i:>3}]  src ({sx},{sy}) {sw}×{sh}  pivot ({px},{py})  tag {tag}  dur {dur}ms  flags {flags}",
            i     = i,
            sx    = e.src_x, sy = e.src_y,
            sw    = e.src_w, sh = e.src_h,
            px    = e.pivot_x, py = e.pivot_y,
            tag   = e.tag,
            dur   = e.duration_ms,
            flags = flag_str(e),
        );
    }

    if a.frames.is_empty() {
        println!("    Hitbox data: absent");
        return;
    }

    println!("    Frames ({}):", a.frames.len());
    for f in &a.frames {
        let label   = if f.label.is_empty()   { "-".into() } else { format!("\"{}\"", f.label) };
        let tags    = if f.tags.is_empty()     { "-".into() } else { f.tags.clone() };
        let program = if f.program.is_empty()  { "-".into() } else { format!("{} line(s)", f.program.lines().count()) };
        println!(
            "      frame {id}  label {label}  tags [{tags}]  program {program}",
            id = f.id, label = label, tags = tags, program = program,
        );
        if !f.program.is_empty() {
            for line in f.program.lines() {
                println!("        | {line}");
            }
        }
        for hb in &f.hitboxes {
            let flags = format!(
                "{}{}",
                if hb.enabled   { "enabled " } else { "disabled " },
                if hb.knockback { "knockback" } else { "" },
            );
            println!(
                "        hb \"{name}\"  {typ}  {flags}",
                name  = hb.name,
                typ   = box_label(hb.box_type),
                flags = flags.trim(),
            );
            println!(
                "           pos ({x:.2},{y:.2})  size {w:.2}×{h:.2}  rot {rot:.1}°",
                x = hb.x, y = hb.y, w = hb.width, h = hb.height, rot = hb.rotation,
            );
            if hb.damage > 0 || hb.hitstun > 0 || hb.blockstun > 0 {
                println!(
                    "           dmg {d}  hitstun {hs}  blockstun {bs}  kb {ks:.2}@{ka:.1}°",
                    d  = hb.damage, hs = hb.hitstun, bs = hb.blockstun,
                    ks = hb.knockback_strength, ka = hb.knockback_angle,
                );
            }
        }
        for cue in &f.audio_cues {
            println!(
                "        cue \"{}\"  vol {:.2}  pitch {:.2}",
                cue.clip_id, cue.volume, cue.pitch,
            );
        }
    }
}

pub fn inspect_package(file: &str, verbose: bool, anim: Option<&str>) {
    let data = fs::read(file).unwrap_or_else(|e| {
        eprintln!("error: cannot read \"{file}\": {e}");
        process::exit(1);
    });
    let pkg = read_uff(&data).unwrap_or_else(|e| {
        eprintln!("error: parse failed: {e}");
        process::exit(1);
    });

    print_package_header(&pkg);

    let anims: Vec<(usize, &UffAnimation)> = if let Some(name) = anim {
        let matches: Vec<_> = pkg.animations.iter()
            .enumerate()
            .filter(|(_, a)| a.name == name)
            .collect();
        if matches.is_empty() {
            eprintln!("error: no animation named \"{name}\"");
            eprintln!("available:");
            for a in &pkg.animations { eprintln!("  {}", a.name); }
            process::exit(1);
        }
        matches
    } else {
        pkg.animations.iter().enumerate().collect()
    };

    println!();
    for (idx, a) in &anims {
        print_anim_summary(*idx, a);
        if verbose {
            println!();
            print_anim_verbose(a);
            println!();
        }
    }
}