# ufftools
Reader, writer, and CLI inspector for the **UFF** (Universal Fighter Frame) character package format (`.uff`).
- [library installation & usage](#library)
- [defined types](#types)
- [cli usage](#cli--uff-tools)
- [uff specification](#umib-fighter-factory-export-format-specification-universal-fighter-frame-uff)
---
### Library
Add to your `Cargo.toml`:
```shell
cargo install uff-tools
```
or manually:
```toml
[dependencies]
ufftools = { path = "path/to/ufftools" }
```
```toml
[dependencies]
ufftools = "0.1.0"
```
### Reading a file
```rust
use ufftools::read_uff;
let data = std::fs::read("character.uff")?;
let pkg = read_uff(&data)?;
println!("{} — {} animations", pkg.char_name, pkg.animations.len());
for anim in &pkg.animations {
println!(" {} ({} frames)", anim.name, anim.sprite_table.len());
}
```
### Writing a file
```rust
use ufftools::{write_uff, UffPackage, UffAnimation, LoopMode};
let pkg = UffPackage {
char_name: "MyChar".into(),
floor_y: 12,
animations: vec![
UffAnimation {
name: "idle".into(),
default_fps: 12,
loop_mode: LoopMode::Loop,
// ...
}
],
};
let bytes = write_uff(&pkg)?;
std::fs::write("character.uff", &bytes)?;
```
### Types
| `UffPackage` | Top-level container: `char_name`, `floor_y`, `animations` |
| `UffAnimation` | One animation: sprite table, hitbox frames, optional pixel data |
| `SpriteEntry` | Per-frame sprite sheet coordinates, pivot, flags, duration |
| `HitboxFrame` | Per-frame combat data: label, STAX program, tags, hitboxes, audio cues |
| `Hitbox` | One combat box: type, position, size, damage, stun, knockback |
| `AudioCue` | Audio event on a frame: clip ID, volume, pitch |
| `BoxType` | Enum of 12 box types (`Attack`, `Hurt`, `Grab`, `Invincible`, …) |
| `LoopMode` | `Once`, `Loop`, `PingPong` |
| `UffError` | Parse/write error type |
---
## CLI — `uff-tools`
### Install
```sh
cargo install --path .
```
### Subcommands
#### `inspect` — print the contents of a `.uff` file
```sh
# summary (package header + one line per animation)
uff-tools inspect character.uff
# full frame, hitbox, and audio cue data
uff-tools inspect --verbose character.uff
# focus on one animation
uff-tools inspect --anim punch character.uff
uff-tools inspect -v --anim punch character.uff
```
Example output:
```
Character : Ryu
floor_y : 12 px
Animations: 2
[ 0] "idle" 12 fps loop sheet 256×128 frames 2 floor_y inherit
hitboxes: 1 across 1 frames audio_cues: 0 pixel_data: 64 bytes
[ 1] "punch" 24 fps once sheet 256×128 frames 3 floor_y 10 px (override)
hitboxes: 4 across 3 frames audio_cues: 2 pixel_data: none
```
#### `generate` — write a sample fixture `.uff` to disk
Useful for testing and verifying a downstream parser:
```sh
uff-tools generate sample.uff
```
---
## Format
`.uff` is the engine-consumable export of a `.uffp` project. All integers are **big-endian**.
See the next section for the full binary spec.
# UMIB Fighter Factory Export Format Specification: Universal Fighter Frame (.uff)
**Universal Fighter Frame** — the universal 2D game character binary character format.
Originally created for fighting games, it can be used for any 2D characters that needs comprehensive hitbox and animation authoring.
Derived from a `.uffp` project at export time. All hitbox and frame data is re-serialized into
a universal, language-agnostic binary layout — 0 runtime dependencies.
> For the editor-native project format, see [uffp_format.md](uffp_format.md).
---
## Relationship to `.uffp`
The container structure is identical. Only three things change on export:
| `magic` | `UFFP` (`0x55464650`) | `UFF\x00` (`0x55464600`) |
| `version` | `2`–`4` | `1` |
| `floor_y` | v4 only (0x10 in PackageHeader) | always present (0x10 in PackageHeader) |
| `floor_y_override` | v4 only (0x18 in AnimHeader) | always present (0x18 in AnimHeader) |
| AnimHeader size | 24 bytes (v2/v3) / 26 (v4) | 26 bytes (always) |
| HitboxBlock encoding | AMF3 `writeObject()` blob | flat binary (see below) |
| Everything else | unchanged | identical |
An exporter walks the live `UFFPackage` in memory and writes the same bytes for everything
except the HitboxBlock, where it emits the flat layout instead of calling `writeObject()`.
---
## File Layout
```
┌─────────────────────────────────────────────┐
│ PackageHeader 24 bytes (fixed) │
├─────────────────────────────────────────────┤
│ CharName 2 + nameLen bytes │
├─────────────────────────────────────────────┤
│ AnimOffsetTable animCount × 4 bytes │
├─────────────────────────────────────────────┤
│ AnimBlock[0] variable │
│ AnimBlock[1] variable │
│ … │
└─────────────────────────────────────────────┘
```
All multi-byte integers are **big-endian**.
---
## PackageHeader — 24 bytes
| 0x00 | 4 | `magic` | `UFF\x00` (`0x55464600`) |
| 0x04 | 1 | `version` | `1` |
| 0x05 | 1 | `pkg_flags` | reserved, `0` |
| 0x06 | 2 | `anim_count` | uint16 |
| 0x08 | 4 | `dir_offset` | absolute offset to AnimOffsetTable |
| 0x0C | 4 | `reserved` | zeroed |
| 0x10 | 2 | `floor_y` | uint16 — pixel distance from frame **bottom** to ground contact point (0 = bottom edge) |
| 0x12 | 6 | `reserved` | zeroed |
---
## AnimOffsetTable
`uint32[anim_count]` — absolute byte offsets to each AnimBlock within the file.
---
## AnimBlock
Four sequential sections per animation. AnimHeader, AnimName, SpriteTable, and PixelBlock are
identical to `.uffp`. Only the HitboxBlock encoding changes.
### AnimHeader — 26 bytes
| 0x00 | 2 | `name_len` | byte length of AnimName |
| 0x02 | 2 | `frame_count` | number of frames |
| 0x04 | 2 | `default_fps` | playback rate hint |
| 0x06 | 1 | `loop_mode` | `0`=once, `1`=loop, `2`=ping-pong |
| 0x07 | 1 | `pixel_flags` | see Pixel Flags in uffp_format.md |
| 0x08 | 2 | `sheet_w` | pixel width of the spritesheet |
| 0x0A | 2 | `sheet_h` | pixel height of the spritesheet |
| 0x0C | 2 | `frame_w` | uniform frame width (0 = variable, use entry srcW) |
| 0x0E | 2 | `frame_h` | uniform frame height (0 = variable, use entry srcH) |
| 0x10 | 4 | `hitbox_size` | byte size of HitboxBlock (0 = absent) |
| 0x14 | 4 | `pixel_size` | byte size of PixelBlock (0 = hitbox-only animation) |
| 0x18 | 2 | `floor_y_override` | per-animation ground anchor override (px from bottom); `0` = inherit from pkg `floor_y` |
### AnimName
`name_len` raw UTF-8 bytes — no null terminator.
### SpriteTable — `frame_count × 16` bytes
One entry per frame:
| 0x00 | 2 | `src_x` | x origin in spritesheet |
| 0x02 | 2 | `src_y` | y origin in spritesheet |
| 0x04 | 2 | `src_w` | frame width (0 → use `frame_w`) |
| 0x06 | 2 | `src_h` | frame height (0 → use `frame_h`) |
| 0x08 | 2 | `pivot_x` | int16 hotspot x offset |
| 0x0A | 2 | `pivot_y` | int16 hotspot y offset |
| 0x0C | 1 | `entry_flags` | bit 0 = FLIP_H, bit 1 = FLIP_V |
| 0x0D | 1 | `tag` | animation group/tag ID |
| 0x0E | 2 | `duration_ms` | per-frame hold time override (0 = use fps) |
### HitboxBlock — `hitbox_size` bytes
`frame_count` sequential FrameEntry records in frame-index order. No count prefix — the count
comes from AnimHeader. Absent when `hitbox_size = 0`.
#### FrameEntry
| `id` | uint16 | frame index (0-based) |
| `label` | uint16 + UTF-8 bytes | empty frame = `0x0000` (zero length) |
| `program` | uint16 + UTF-8 bytes | STAX source; empty = `0x0000` |
| `tags` | uint16 + UTF-8 bytes | `key=val key=val ...`; empty = `0x0000` |
| `hitbox_count` | uint16 | |
| `hitbox[0..N]` | HitboxRecord × N | |
| `cue_count` | uint16 | audio cues on this frame (0 = none) |
| `cue[0..N]` | AudioCue × N | absent when `cue_count = 0` |
#### HitboxRecord
| `name` | uint16 + UTF-8 bytes | variable | |
| `type` | uint8 | 1 | 0–11; see box type constants below |
| `flags` | uint8 | 1 | bit 0 = enabled, bit 1 = knockback |
| `x` | float32 | 4 | big-endian |
| `y` | float32 | 4 | |
| `width` | float32 | 4 | |
| `height` | float32 | 4 | |
| `damage` | uint16 | 2 | |
| `hitstun` | uint16 | 2 | |
| `blockstun` | uint16 | 2 | |
| `knockback_angle` | float32 | 4 | |
| `knockback_strength` | float32 | 4 | |
| `rotation` | float32 | 4 | normalized −180..180 |
Fixed portion (after name string): **36 bytes**.
#### AudioCue
| `clip_id` | uint16 + UTF-8 bytes | references a clip by name in the audio pool |
| `volume` | float32 | 0.0–1.0 |
| `pitch` | float32 | 1.0 = normal |
`cue_count` and `cue[]` are always present in the FrameEntry (a frame with no cues writes
`0x0000` for `cue_count`). Readers on format version 1 should always read `cue_count`; a value
of `0` costs two bytes and leaves the door open for audio without a version bump.
### PixelBlock — `pixel_size` bytes
Identical to `.uffp`. See [uffp_format.md](uffp_format.md) for pixel flag meanings and
decompressed layouts.
---
## Box Type Constants
| 0 | `ATTACK` |
| 1 | `ATTACK_INACTIVE` |
| 2 | `HURT` |
| 3 | `STUN` |
| 4 | `PHYSICAL_EXTENT` |
| 5 | `GRAB` |
| 6 | `GUARD_AIR` |
| 7 | `GUARD_HIGH` |
| 8 | `GUARD_MED` |
| 9 | `GUARD_LOW` |
| 10 | `THROW_INVINCIBLE` |
| 11 | `INVINCIBLE` |
---
## Versioning
The `version` field in PackageHeader gates HitboxBlock layout expectations.
| 1 | flat binary | yes (`cue_count` always present) |
Future additions to FrameEntry or HitboxRecord append new fields at the tail. Readers should
skip to `hitbox_size` bytes past the start of the HitboxBlock if they encounter an unknown
version, rather than failing.
---
## Parsing Pseudocode
Minimal reference for engine implementors:
```
read magic[4] assert == "UFF\x00"
read version[1] assert == 1
read pkg_flags[1]
read anim_count[2]
read dir_offset[4]
skip 4 // reserved
read floor_y[2] // uint16, px from bottom (0 = bottom edge)
skip 6 // reserved
seek dir_offset
read offsets[anim_count * 4]
for each anim_offset:
seek anim_offset
read AnimHeader (26 bytes)
// AnimHeader: name_len, frame_count, default_fps, loop_mode, pixel_flags,
// sheet_w, sheet_h, frame_w, frame_h, hitbox_size, pixel_size,
// floor_y_override (0 = inherit pkg floor_y)
read AnimName (name_len bytes)
read SpriteTable (frame_count * 16 bytes)
if hitbox_size > 0:
for i in 0..frame_count:
read id[2]
read label_len[2], label[label_len]
read program_len[2], program[program_len]
read tags_len[2], tags[tags_len]
read hitbox_count[2]
for j in 0..hitbox_count:
read name_len[2], name[name_len]
read type[1], flags[1]
read x[4], y[4], w[4], h[4] // float32 big-endian
read damage[2], hitstun[2], blockstun[2]
read knockback_angle[4], knockback_strength[4], rotation[4]
read cue_count[2]
for k in 0..cue_count:
read clip_id_len[2], clip_id[clip_id_len]
read volume[4], pitch[4]
if pixel_size > 0:
read PixelBlock (pixel_size bytes) // decompress per uffp_format.md
```
---
## Rust Struct Sketch
```rust
#[derive(Debug)]
struct UffPackage {
char_name: String,
animations: Vec<UffAnimation>,
}
#[derive(Debug)]
struct UffAnimation {
name: String,
fps: u16,
loop_mode: u8,
frame_w: u16,
frame_h: u16,
sprite_table: Vec<SpriteEntry>,
frames: Vec<HitboxFrame>,
pixel_data: Option<Vec<u8>>, // zlib-compressed, decompress per pixel_flags
}
#[derive(Debug)]
struct HitboxFrame {
id: u16,
label: String,
program: String,
tags: String,
hitboxes: Vec<Hitbox>,
audio_cues: Vec<AudioCue>,
}
#[derive(Debug)]
struct Hitbox {
name: String,
box_type: u8,
enabled: bool,
knockback: bool,
x: f32, y: f32, width: f32, height: f32,
damage: u16, hitstun: u16, blockstun: u16,
knockback_angle: f32,
knockback_strength: f32,
rotation: f32,
}
#[derive(Debug)]
struct AudioCue {
clip_id: String,
volume: f32,
pitch: f32,
}
```
---
## JS Struct Sketch
```js
// reading with DataView (big-endian)
function readUtf16Str(view, offset) {
const len = view.getUint16(offset);
offset += 2;
const bytes = new Uint8Array(view.buffer, offset, len);
return [new TextDecoder().decode(bytes), offset + len];
}
// HitboxRecord fixed fields start after the name string:
// type(1) flags(1) x(4) y(4) w(4) h(4) dmg(2) hs(2) bs(2) ka(4) ks(4) rot(4) = 36 bytes
```