Model Assets: Artist and Modder Guide
Manifold treats Blender and Blockbench as the editable source tools. The game ships portable GLB plus a readable recipe:
assets/
my_mod/
models/
mobs/clockwork_golem.glb
mobs/clockwork_golem.model.ron
The runtime ID is my_mod:mobs/clockwork_golem. Install the workspace CLI once
from the repository root, then run the runtime validator before packaging:
cargo install --path tools/cargo-mod --locked
cargo mod validate path/to/assets
cargo mod validate path/to/assets --model my_mod:mobs/clockwork_golem
cargo mod validate path/to/mod.toml --json
cargo mod build and cargo mod publish run model validation automatically.
See Validation for --model and the JSON document.
DCC workflow
- Blockbench: load
tools/blockbench-manifold/manifold_model.js, then use Tools → Export Manifold Model. Scripts and agents can callManifoldModelTools.export_to(dir, {id})(plugin README (tools/blockbench-manifold/README.md)). The exporter samples your clips, makes names unique, and turns tagged groups, locators and timeline keyframes into sockets and events (Exporting from Blockbench). - Blender: install
tools/blender-manifoldas an add-on, configure the asset root and ID in the Manifold sidebar, then choose Export and Validate. - Both exporters write the GLB and merge the recipe; they never overwrite what you typed into it (Recipe merging).
- Preserve
.bbmodel/.blendseparately. They are working files, not runtime dependencies. - Export embedded binary glTF 2.0. External buffers/images, morph targets,
non-triangle primitives, and unknown extensions are rejected with repair
instructions. Cubic-spline animation is rejected under
profile_version: 1and resampled under 2. Blockbench’s standardKHR_materials_unlitmarker is accepted and mapped into Manifold toon lighting. - Blockbench group/bone node animation and Blender skin animation both compile into the same runtime (Animation runtime). A model has one skin, at most 256 nodes under profile 1 or 1,024 under profile 2, and at most 128 compiled skeleton joints by default; split more complex rigs into separate model assets.
Recipe
(
profile_version: 1,
scale: 1.0,
filter: Nearest,
material_overrides: {
"eyes": (emissive_factor: Some((1.0, 0.25, 0.05))),
},
clip_aliases: {"idle": "Idle", "walk": "Walk"},
sockets: {
"hand_right": (node: "hand.R"),
"weapon_muzzle": (node: "muzzle", translation: (0.0, 0.0, -0.1)),
},
events: [
(clip: "walk", time: 0.18, event: "footstep_left"),
(clip: "walk", time: 0.68, event: "footstep_right"),
],
animation_graph: Some((
initial: "idle",
states: {
"idle": (
clip: "idle",
transitions: [(to: "walk", conditions: [FloatGreater(parameter: "speed", value: 0.1)])],
),
"walk": (
clip: "walk",
transitions: [(to: "idle", conditions: [FloatLess(parameter: "speed", value: 0.1)])],
),
},
)),
budget_override: None,
)
Sockets and animation events are generic. Audio, particles, lights, camera effects, equipment, and validated gameplay requests consume them independently; the model platform does not depend on a particle implementation.
Fixed-size numbers such as translation, rotation, scale and colour
factors are written as tuples, (x, y, z); RON rejects [x, y, z] for them.
Animation runtime
The importer accepts profile_version: 1 and 2 (the import profile; it is
unrelated to any recipe-format version). Both compile into the
manifold-anim runtime, so every package gets the same behaviour:
- Only animated nodes become joints. A node is a joint when a clip
animates it, it is a skin joint, or the recipe keeps it
(
skeleton.keep_joints,skeleton.root_motion_joint). Blockbench’s static cubes and pivot groups fold into their bone, and their names survive as parts, so sockets, the Asset Lab hierarchy and/modelnode names keep working. A static group with non-uniform scale above a rotating bone stays a joint (MODEL024, info). - Clips are resampled to 30 samples per second (
clips.rate, or per clip inclips.overrides). A track whose keys do not sit on that grid closely enough keeps its exact source keys instead, so the motion you authored is preserved; MODEL022 (performance) lists every track resampling would change beyond tolerance and the rate that would make it uniform. Step channels keep their exact key times. Tracks that never move are dropped. - Logic runs on the 40 Hz simulation tick. Graph state and clip playback advance in whole ticks, so animation speed does not depend on frame rate; poses are drawn at the interpolated instant, one tick behind the newest logic. An event at time 0 fires when its clip starts, and a clip fading out of a transition still emits its events.
Profile 2 adds optional recipe fields; a profile 1 recipe that sets
profile_version: 2 and nothing else stays valid:
(
profile_version: 2,
skeleton: (
root_motion_joint: Some("marlton"),
keep_joints: [],
),
clips: (
rate: 30.0,
overrides: {
"rifle_reload": (rate: Some(60.0)),
"mantle": (root_motion: Some((vertical: true))),
"aim_sweep": (additive: Some((base: FirstFrame, space: Mesh))),
},
),
markers: [(clip: "run", time: 0.12, name: "foot_l_down")],
curves: [(clip: "rifle_reload", name: "ik_release_l", keys: [(0.0, 0.0), (0.5, 1.0)])],
derived_curves: [(name: "foot_l_height", source: JointHeight("foot_l"))],
events: [
(clip: "run", time: 0.12, event: "footstep", payload: {"foot": "left"}),
(clip: "melee", time: 0.30, event: "window_open.swing", kind: Gameplay),
(clip: "melee", time: 0.45, event: "window_close.swing", kind: Gameplay),
(clip: "walk", time: 0.25, event: "footstep", payload: {"loudness": "8"}, kind: Gameplay),
],
)
root_motion_jointsplits each clip into an in-place pose and a root-motion curve (horizontal translation and yaw, plus height withvertical: true) stored at the tick rate.additivestores a clip as deltas againstRest, itsFirstFrameorClip("name", time), inLocalorMeshspace.curvesare float curves layers and pose modifiers read;derived_curvesare computed from joint motion (JointHeight,JointSpeed).kind: Gameplaymarks an event the server derives from logic; it must lie inside its clip (MODEL026 error; a cosmetic event outside is a warning).graphnames a layered graph asset (Animation graphs).clip_librariesis accepted for a later Pygmalion phase and reported, but not loaded yet.skeleton.profilebinds the skeleton to a profile (Characters and held items).
| Code | Severity | Meaning |
|---|---|---|
| MODEL022 | Performance | Resampling would change a track beyond tolerance; it keeps its source keys |
| MODEL023 | Error | root_motion_joint, keep_joints or a derived curve names no joint |
| MODEL024 | Info | A static node was kept as a joint because folding would shear |
| MODEL025 | Warning | Clip libraries are listed but not bound yet |
| MODEL026 | Error or warning | A marker or event lies outside its clip |
| MODEL027 | Error | An animation channel is missing or has mismatched keys |
| MODEL109 | Performance | Compiled clip memory exceeds max_clip_bytes (default 4 MiB) |
Animation graphs
A model’s animation logic is a graph: a RON asset that says which clips play, how they blend and layer, and which one-shots can interrupt them. One graph serves every model with the same skeleton profile; each model supplies its own clips under the names the graph uses.
Files and ids
assets/<ns>/animations/<path>.graph.ron ↔ id <ns>:animations/<path>
assets/flagship/animations/humanoid_blocky.graph.ron is
flagship:animations/humanoid_blocky. One extension holds three kinds, told
apart by the RON struct name:
| Kind | What it is |
|---|---|
Graph(...) | A complete graph: parameters, machines, layers, slots, plays, events |
Interface(...) | A named contract a held item (or a mod) implements: its entries and the host parameters it may read |
Implementation(...) | One implementation of an interface: the rifle hold, the unarmed default |
Graphs load from the same sources as models (the bundled content, mods and
MANIFOLD_ASSET_DEV_DIR), so a mod ships graphs next to its models. The
engine’s manifold:animations/interfaces/weapon interface and its default,
manifold:animations/humanoid/weapon_unarmed, are built in.
A recipe picks its graph by id:
(
profile_version: 2,
skeleton: (profile: Some("manifold:humanoid_blocky_v1")),
graph: Some("flagship:animations/humanoid_blocky"),
)
graph and the inline animation_graph are mutually exclusive (MODEL203). An
inline graph keeps working: it converts to a one-machine layered graph when
the model loads, its transitions crossfade as before, and its errors keep
their MODEL020 code with the graph path in the message. Two behaviours
change on purpose: a transition interrupted mid-blend now blends from the
pose on screen instead of popping, and a trigger fires for one tick instead
of waiting until something consumes it.
Parameters
params: {
"speed": Float(default: 0.0, min: 0.0, max: 12.0, smooth_ms: 80),
"grounded": Bool(default: true),
"stance": Enum(variants: ["stand", "crouch"], default: "stand"),
"ammo": Int(default: 30, min: 0, max: 30),
"shots": Trigger(), // a counter: each change fires once
},
derived: {
"moving": "v.speed > 0.15",
"aim_alpha": "math.clamp(v.aim_pitch / 90, -1, 1)",
},
- Parameters are written from gameplay each tick (for players, from replicated movement, view and equipment); unknown names in expressions are errors (MODEL206), not silent zeros.
- Floats clamp to
[min, max];smooth_mssmooths a float on the tick clock, identically on every machine. NaN and infinities become the default. - A trigger reads 1 in expressions for
window_ticksticks (default 1) after its counter changes, or until a transition that reads it is taken. - Expressions are the Molang subset the particle system uses (
v.name,math.*,?:,&&,||), plus queries:q.state_time,q.anim_time,q.anim_length,q.anim_finished,q.state_normalized_time,q.transition_progress(in a machine’s transitions and state nodes),q.slot_weight('slot'),q.slot_playing('slot'),q.layer_weight('layer'),q.state_tag('tag'),q.blend_speedandq.delta_time. An enum compares with a quoted variant:v.stance == 'crouch'.
Machines, blend spaces and transitions
nodes: {
"stand_moves": BlendSpace1D(
input: "v.speed",
samples: [
(clip: "idle", at: 0.0, sync: false),
(clip: "walk", at: 4.0, speed: Some(4.0)),
(clip: "run", at: 5.2, speed: Some(5.2)),
],
sync: Normalized,
rate: MatchSpeed(input: "v.speed", clamp: (0.7, 1.4)),
),
},
machines: {
"locomotion": (
initial: "stand",
global: [(to: "stand", when: "v.reset", priority: 10, blend: Cut)],
states: {
"stand": (node: Ref("stand_moves"),
transitions: [(to: "crouch", when: "v.crouching", blend: Inertialize(0.2))]),
"crouch": (node: Clip(clip: "crouch_idle"),
transitions: [(to: "stand", when: "!v.crouching")]),
},
),
},
- A state plays a node:
Clip(clip, loop, rate, start), aBlendSpace1D, aBlend(a, b, weight), aPose(Rest | Input | Identity)or aRefto a named node. - A 1D blend space weights the two samples around its input.
Normalizedsync keeps every sample on one shared phase so feet stay aligned across the blend;MatchSpeedplays the samples atspeed / (weighted sample speed), so the feet cover the ground the character covers. Give samples the speeds the game actually moves at: the player walks at 4.0 m/s, sprints at 5.2 and sneaks at 1.2. - Each tick a machine takes at most one transition: global transitions
first, then the active state’s, highest
priorityfirst, ties in the order written.min_timedelays checking;interrupt: HigherPriorityorNeverprotects a transition while it blends. blend:Inertialize(seconds)(the default, 0.15 s) blends from the source pose’s position and velocity into the target without evaluating the source again;Crossfade(seconds)blends both;Cutswitches at once.
Layers, masks, slots and one-shots
masks: {
"upper_body": (weights: {"spine": 0.35, "chest": 1.0}),
},
slots: { "upper": (group: "body") },
plays: {
"rifle_reload": (clip: "rifle_reload", slot: "upper", priority: 1,
blend_in: 0.15, blend_out: 0.2),
},
layers: [
(name: "base", node: Machine("locomotion")),
(name: "upper_actions", blend: Override(mask: Some("upper_body"), space: Mesh),
node: Slot("upper")),
(name: "aim", blend: Additive(space: Mesh), weight: "v.aiming ? 1 : 0.6",
smooth_ms: 80, node: Interface(interface: "manifold:animations/interfaces/weapon", entry: "aim")),
],
- Layers apply in order over the first (base) layer.
Overridereplaces the pose where the mask allows,Additiveadds a clip’s deltas (the clip must be marked additive in the recipe’sclips.overrides, in the same space: MODEL213).Meshspace keeps the layer’s authored orientation in character space however the base twists the pelvis, which is what an upper-body weapon hold and an aim offset want. - A mask gives each bone a weight; bones without one inherit their parent’s.
Name skeleton-profile roles (
spine,chest,upper_arm_l) so the mask works on every conforming model;ramps: [(from, to, start, end)]fades along a chain. An optional role the model lacks is ignored (MODEL210 warning). - A slot is where one-shots play. Gameplay requests a play by id with a
start tick; late arrivals start part-way through, early ones wait. In a
slot group one play is active: a new one interrupts it when the active play
is
interruptibleand the new priority is at least as high, and is rejected otherwise. Plays blend in overblend_inand out overblend_outbefore the clip ends.
The weapon interface
A held item’s animation is a layer implementation. The reference graph calls
the manifold:animations/interfaces/weapon interface in two layers, and the
held item’s grip category decides which implementation fills it:
// crates/manifold-model/assets/manifold/animations/humanoid_blocky/rifle.graph.ron (built in)
Implementation(
version: 2,
implements: "manifold:animations/interfaces/weapon",
derived: { "sprinting": "v.speed > 4.6 && !v.aiming" },
machines: {
"hold": (initial: "ready", states: {
"ready": (node: Clip(clip: "rifle_idle"),
transitions: [(to: "lowered", when: "v.sprinting")]),
"lowered": (node: Clip(clip: "rifle_run"),
transitions: [(to: "ready", when: "!v.sprinting")]),
}),
},
entries: {
"upper_body": Machine("hold"),
"aim": BlendSpace1D(input: "v.aim_alpha", sync: None, samples: [
(clip: "rifle_aim_down", at: -1.0),
(clip: "rifle_aim_center", at: 0.0),
(clip: "rifle_aim_up", at: 1.0),
]),
},
)
An implementation provides a node for every entry (upper_body overrides
the pose, aim is a Mesh-space additive), may declare its own derived
parameters, machines and masks, and reads only the host parameters the
interface lists (speed, aiming, aim_alpha, crouching, moving,
shots; the host must declare them, MODEL215). The manifold:two_hand_rifle
grip category names manifold:animations/humanoid_blocky/rifle, so every
rifle gets this hold. An implementation whose clips the model lacks is left
out with a MODEL215 warning and the unarmed default plays instead; changing
the held item swaps implementations at once.
Events
events: {
"footstep": Cosmetic(policy: Dominant),
"shot": Cosmetic(policy: Active),
"mag_out": Cosmetic(policy: Always),
"reload_commit": Gameplay,
},
Recipe events fire as clips play. Cosmetic events (sounds, particles) come
from the frames you see: Dominant fires from the playback with most of the
weight, and within a blend space only from its heaviest sample, so a
walk-to-run blend plays one footstep, not two; Active fires only from the
active state or play; Always fires from anything above min_weight.
Gameplay events are derived on the tick from active playbacks only, the
same on the server and every client; a recipe event marked
kind: Gameplay cannot be declared Cosmetic here (MODEL222). Names not in
the table are cosmetic with Dominant.
Two gameplay-event conventions reach gameplay (Pygmalion E §14):
window_open.<name> and window_close.<name> open and close a named
gameplay window, which also closes when its play ends or is interrupted
(Talos B’s WaitWindow and windowed SweepVolume time melee by it); and a
gameplay event with a loudness payload, in blocks, is audible to AI hearing
(footstep on locomotion clips).
Root motion. A state or play declares who moves the character:
root_motion: Capsule (the default: movement drives it and the clip’s
extracted root motion is discarded) or root_motion: Animation((on_blocked: Stop | Abort | Continue)), where the clip’s root-motion curve moves the
character through the same collision as walking, keys ignored while it
plays. A curve with vertical: true suspends gravity (a mantle). When a wall
holds the character to less than half the motion for two ticks, Stop
freezes the play in place, Abort ends it, and Continue keeps pushing;
graphs can also read the bound Motion(RootBlocked). warp: targets are
accepted but play unwarped until motion warping lands.
Pose modifiers
After the graph’s layers produce a pose, an ordered list of pose
modifiers edits it: the head follows the view, the off hand stays on the
rifle. Their changes last one frame; the next frame starts again from the
graph (Pygmalion C §3 (docs/superpowers/specs/2026-09-27-pygmalion-c-procedural-physics-design.md)).
A graph lists them in modifiers:, in the order they run, with Insert
points that other files fill:
// The character graph (humanoid_blocky.graph.ron):
modifiers: [
LookAt(chain: ["spine", "chest", "neck", "head"],
target: Params(yaw: "look_yaw", pitch: "aim_pitch"), class: Gameplay),
Insert("weapon"), // the bound weapon implementation's modifiers
Insert("model"), // the recipe's own modifiers (a later phase)
]
// The rifle implementation (humanoid_blocky/rifle.graph.ron):
modifiers: [
HandToGrip(chain: "arm_l", target: ItemSupport, weight: "1 - q.curve('ik_release_l')"),
]
An interface names the point its implementations fill (insert: "weapon");
equipping an item binds its grip category’s implementation, whose modifiers
then run there. Bones, chains and roles resolve by skeleton-profile role
(arm_l is the profile’s upper_arm_l → forearm_l → hand_l chain) or by
bone name. A model that lacks a chain’s bone, or the socket that holds items,
simply runs without that modifier (an Info diagnostic).
Classes. Every modifier is Cosmetic (the default) or Gameplay. The
server runs only gameplay modifiers when it rebuilds a pose for hit tests, so
anything that moves hitboxes the players see — the head turning to the view
— must be Gameplay. Distant characters (the mid tier) also run only
gameplay modifiers; hero characters run everything. A gameplay modifier may
read parameters, sockets and the held item, never a world point or state
kept from earlier frames.
Weights. weight is a number or an expression over graph parameters
(v.grounded) and float curves (q.curve('ik_release_l')), clamped to
0–1. 0 skips the modifier; values in between blend its result with the pose
it received. Other queries (q.slot_weight('action')) are evaluated by the
graph.
Conventions. Characters face -Z with +Y up, so +X is their right.
look_yaw and aim_pitch are degrees relative to the body: positive yaw
turns left, positive pitch looks up. The blocky profile gives elbows and
knees a hinge (hinge_axis: forearms +X, shins -X, signed so a positive turn
bends the limb): IK bends them only about it, so a blocky forearm never
twists. An IK chain’s hand reaches with the socket named like its last role
(hand_l), so HandToGrip places the character’s hand_l socket on the
item’s support node, just as the item’s grip sits on hand_r.
| Kind | Fields (defaults) |
|---|---|
LookAt | chain (bones root to tip); target: Params(yaw, pitch) (parameter names) or Point("name") (cosmetic only); class; weight; limits: (yaw: 75, pitch: 60) degrees from forward; bone_limits (one per bone, default the totals); weights per bone (default spine 0.15, chest 0.25, neck 0.25, head 0.35); forward (default -Z); smoothing seconds (cosmetic Point targets, default 0.08) |
HandToGrip | chain (a profile chain such as "arm_l", or three bones); target: ItemSupport (default), ItemNode("name") or Socket("name"); weight; rotation: true (the hand takes the target’s rotation); pole: Animated; hinge: Profile; softness: 0.05; effector; class |
TwoBoneIk | chain; target: Socket("name"), ItemSupport, ItemNode("name") or Point("name"); pole: Animated (the clip’s elbow or knee direction), Direction((x, y, z)) or Free; hinge: Profile, Axis((x, y, z)) or Free; softness (fraction of the limb’s length over which it eases to full stretch, 0.05); end: Keep (model-space rotation, a foot stays level), Target or Local; effector; class; weight |
A target out of reach stretches the limb towards it, easing into full
extension rather than snapping straight. A cosmetic look-at may not turn a
bone that carries a hit volume (MODEL315): make it Gameplay with a Params
target. Other kinds (FootPlacement, spring chains, …) arrive in a later
phase: they still parse and report MODEL225.
| Code | Severity | Meaning |
|---|---|---|
| MODEL301 | Error or info | A modifier names a bone, role, chain, socket or parameter that does not resolve (info: the model lacks a profile bone or the holding socket, so the modifier is off) |
| MODEL302 | Error | Modifiers are contributed at an Insert point the graph does not have |
| MODEL303 | Warning | A gameplay modifier reads bones a cosmetic one before it writes |
| MODEL304 | Error | A modifier declared Gameplay that cannot be (a Point target, smoothing) |
| MODEL305 | Error | An IK chain that is not a parent-child chain of the right length, or has a zero-length segment; per-bone lists of the wrong length |
| MODEL315 | Error | A cosmetic look-at over bones with hit volumes (E’s 3 cm hit fidelity bound) |
A weight expression that does not compile is MODEL207.
Limits of this version
Nested machines, conduits, 2D blend spaces, marker sync, play sections,
reactions, ByParam play clips, implementation plays, spring and dead
blending and blended interface swaps arrive in a later phase: a graph that
uses one reports MODEL225 (an error, or a warning where a fallback exists:
marker sync plays normalised, spring and dead blends inertialize, pose
modifiers other than LookAt, HandToGrip and TwoBoneIk are skipped).
| Code | Severity | Meaning |
|---|---|---|
| MODEL201 | Error | A graph file does not parse, or has an unknown kind or version |
| MODEL202 | Error | A recipe’s graph (or a graph’s interface) does not exist or does not compile |
| MODEL203 | Error | A recipe sets both graph and animation_graph |
| MODEL204 | Error | A clip the graph names is not on this model |
| MODEL205 | Error | An unknown state, machine, node, slot, play, layer or mask |
| MODEL206 | Error | An unknown parameter in an expression |
| MODEL207 | Error | An expression does not compile (the message carries the span) |
| MODEL208 | Error or warning | A trigger used in arithmetic (error); a number used as a condition (warning) |
| MODEL209 | Error | Derived parameters form a cycle |
| MODEL210 | Error or warning | A mask bone or role does not resolve (an absent optional role is a warning) |
| MODEL211 | Error | A blend space has no samples, more than 32, or two at the same point |
| MODEL212 | Warning | Marker sync is not available; normalised time is used |
| MODEL213 | Error | A layer’s blend does not match its clips (additive or not, space) |
| MODEL214 | Error or warning | A play targets a missing slot; a slot no layer uses |
| MODEL215 | Error or warning | An interface is not implemented correctly, or an implementation does not fit this model |
| MODEL216 | Warning | A state or machine is unreachable |
| MODEL217 | Warning | A condition is always false |
| MODEL218 | Error | A structural limit is exceeded (expression size, graph operations) |
| MODEL219 | Error | The model’s skeleton profile differs from the graph’s |
| MODEL220 | Performance | Logic state above 128 words, or an implementation above its 16-word region |
| MODEL222 | Error | A graph declares Cosmetic for an event its source marks Gameplay |
| MODEL223 | Warning | A gameplay event no clip of the model emits |
| MODEL225 | Error or warning | A feature a later phase delivers |
Characters and held items
A character names a skeleton profile so clips, graphs, held items and hit
volumes written for the profile work on it. The built-in profile is
manifold:humanoid_blocky_v1: 30 bone roles (root, hips, spine,
chest, neck, head, eyes, per side upper_arm, forearm, hand,
index, index_tip, fingers, fingers_tip, thumb, thigh, shin,
foot, and grip_r), of which root, chest, head and both upper arms
and thighs are required. Profile rigs stand on +Y and face -Z, so the
character’s left side is -X.
(
profile_version: 2,
skeleton: (profile: Some("manifold:humanoid_blocky_v1"), root_motion_joint: Some("marlton")),
naming: [Blockbench],
bone_map: { "marlton": "root" },
sockets: {
"hand_r": (node: "grip_r"), // the bone frame is the weapon frame
"hand_l": (node: "grip_l"),
},
socket_locators: true,
body_regions: ProfileDefault, // or Map({ "neck": "head", "tail": "mymod:tail" })
hit_volumes: FromParts(exclude_parts: [], merge: Auto),
)
- Binding. Each role binds one bone:
bone_map(bone name → role) first, then a bone named exactly like the role, then an alias of the families innaming(Blockbench’sbody/torsoforchest,arm_rorrightArmforupper_arm_r,fingertips_rforfingers_tip_r,rightItemforgrip_r, …), then a normalised name (Arm_L,left_armandmixamorig:LeftArmall readarm_l). The single top-level animated bone isroot. Only groups, locators and animated nodes bind; a cube named like a role (Marlton’supper_arm_runder itsarm_rbone) never does. A bound bone is always a joint, even when nothing animates it. - Virtual roles. A rig without
hips,spineorneckgets an identity joint for each (__hips,__spine,__neck), inserted above the bones the profile puts under it without moving anything, so a shared clip’s hip bob or neck turn still moves the body and head. A Bedrock-style six-part rig (head and arms underbody, legs under the root) conforms. - Sockets. A socket’s world frame is
model_root · global(joint) · offset; the joint’s global already contains its rest rotation, so never apply it again. Sockets come from the profile’s standard sockets (hand_r,hand_l,head,eyes,back,hip_l,hip_r,mag_l, placed from the bones’ part boxes when the rig has no such bone), then locators namedsocket_<name>whensocket_locators: true, then the recipe’ssockets, each overriding the previous by name. The Blockbench exporter already turns tagged locators into recipe sockets and leaves them out of the GLB;socket_locatorsis for GLBs from other tools. - Hit volumes.
FromPartsturns each bone’s parts into exact oriented boxes: one per bone when their union wastes at most 25 %, else up to four.exclude_partsnames parts (joint balls) that should not be hit. Each volume carries the body region of its bone (manifold:head,torso,arm_l/arm_r,hand_l/hand_r,leg_l/leg_r,foot_l/foot_rby default); a bare region name meansmanifold:<name>, and other namespaces are your own regions. The server also gets one vertical capsule that holds every volume in every clip, for its broadphase.
A held item declares its grip; the engine places it so its grip node
lands on the wearer’s socket (world(item) = world(socket) · grip⁻¹), so
neither the character nor the item needs to know the other:
(
profile_version: 2,
item: Some((
grip: "grip", // the default
category: "manifold:two_hand_rifle",
support: Some("grip_l"), // where the off hand goes
)),
)
manifold:two_hand_rifle holds the item in hand_r, links the rifle layer
(manifold:animations/humanoid_blocky/rifle) and sends the left arm to the
item’s grip_l node. Model a weapon in the weapon frame (-Z muzzle, +Y up,
origin at the grip) and put its grip node where the hand closes.
| Code | Severity | Meaning |
|---|---|---|
| MODEL018 | Error or warning | item.grip or item.support names a missing node (a warning when the category’s default grip_l is missing) |
| MODEL401 | Error | A required role has no bone |
| MODEL402 | Error | A role’s bone is not under its parent role’s bone |
| MODEL403 | Error | Two bones match one role |
| MODEL404 | Warning | A left or right bone is on the other side (the profile faces -Z) |
| MODEL405 | Error | A bound bone’s rest scale is non-positive or non-uniform beyond 1 % |
| MODEL406 | Warning | The model is not +Y up and facing -Z |
| MODEL407 | Info | A bound bone rests more than 45° from the profile’s rest |
| MODEL408 | Info | A clip library animates an optional role the model lacks |
| MODEL409 | Error | skeleton.profile names an unknown profile |
| MODEL412 | Error | More than 64 hit volumes |
| MODEL413 | Warning | A bone with geometry gets no hit volume (no region, or all parts excluded) |
| MODEL414 | Error | A body region name is not declared |
| MODEL415 | Info | Some parts are not single cubes; their volumes use their bounds |
bone_map naming a role the profile lacks is MODEL015, and a bone_map or
body_regions key naming no bone is MODEL023.
Binding tables
A binding table drives an animated character’s graph from replicated
state, on the server and on every client, on the tick (Pygmalion E §4; the
runtime is described in animated characters (docs/architecture/animated-characters.md)).
It sits beside its graph, assets/<ns>/animations/<path>.bind.ron, with the
id <ns>:animations/<path>. The flagship’s player table:
(
graph: "flagship:animations/humanoid_blocky",
bindings: [
(param: "speed", source: Motion(PlanarSpeed)), // m/s
(param: "move_x", source: Motion(LocalPlanarVelocity(X)), map: Scale(0.25)),
(param: "move_z", source: Motion(LocalPlanarVelocity(Z)), map: Scale(0.25)),
(param: "grounded", source: Motion(Grounded)),
(param: "aim_pitch", source: View(Pitch), map: Scale(57.2958)), // degrees, + up
(param: "look_yaw", source: View(YawOffset), map: Scale(57.2958)),
(param: "shots", source: Counter("manifold-gameplay:presentation", "flagship-game:shots")),
],
layer_bindings: [
(interface: "manifold:animations/interfaces/weapon",
from: HeldItem(component: "manifold-gameplay:equipment", field: "flagship-game:main_hand")),
],
)
- Sources.
Motion(PlanarSpeed | VerticalSpeed | LocalPlanarVelocity(X | Z) | Grounded | RootBlocked)from the replicated position, velocity, yaw and thegroundedandroot_blockedflags;View(Pitch | YawOffset)from the view angles (radians);Field(component, field)andCounter(component, field)from replicated components the host supplies. ACounterbinds only a trigger: each change fires it once, and a late joiner adopts the count without firing. - Which component fields. Every host lists the same fields, from the
engine’s component metadata (Talos A §14.5): each top-level numeric or
boolean field of a public replicated component is a
Field/Countersource, and each string field (an item id) aHeldItemsource. Nested fields are not bindable. The source’s component must be public (MODEL502) and replicate at rate classCritical(MODEL505), so every observer binds the value the server bound. - Maps.
Identity(default),Scale(k),AtLeast(t)andEquals(c)(1 or 0),Range(min, max)(normalised and clamped). - Layer bindings.
HeldItemreads an item model id; the item’s recipe names its grip category, and the category names the implementation the interface binds (manifold:two_hand_riflebindsmanifold:animations/humanoid_blocky/rifle). An empty hand binds the interface’s default. - Only replicated values are bindable: a smoothed or derived value belongs in
the graph (
smooth_ms,derived), where every host computes it.
| Code | Severity | Meaning |
|---|---|---|
| MODEL206 | Error | A binding names a parameter the graph does not declare |
| MODEL208 | Error | A derived parameter bound, a Counter bound to a non-trigger, or a trigger bound to a value |
| MODEL501 | Error | A source that is not a replicated field the host supplies |
| MODEL502 | Error | A source whose component’s audience is not public |
| MODEL503 | Error | A layer binding names an interface the graph does not use, or an unknown item component |
| MODEL504 | Error | The table does not parse, or binds a different graph than the model runs |
| MODEL505 | Error | A source whose component is not rate class Critical |
The player model
The game names its player model and table in AppMetadata
(player_model, player_bindings; the flagship:
flagship:player/marlton with flagship:animations/humanoid_blocky). The
servers take the same presentation. When the catalog has the model, remote
players are drawn animated: walking and running through the blend space,
aiming with pitch, the held item at hand_r with the off hand on its
grip_l, and one-shots such as the reload in the masked upper-body slot.
Without it they keep the rest-pose model or the cube avatar. The local body
is not drawn. player_event_sounds maps the model’s cosmetic events to sound
cues (the flagship plays flagship:player/footstep on footstep,
footstep_left and footstep_right).
Hit regions
Shots report the body region and bone of the exact box they hit, measured
against the pose the shooter saw (the server rewinds to the shooter’s render
tick and rebuilds the gameplay pose: graph layers and Gameplay modifiers
only). Region names come from body_regions (above). A cosmetic modifier
must not move a hit-volume bone by more than 3 cm, or what a player sees and
what the server tests differ; declare it Gameplay if it must.
Creatures (profile-less graphs)
A creature without a skeleton profile runs a graph with profile: "", whose
masks, LookAt chains and events name the model’s own bones. The flagship’s
slice creatures are the example (Talos overview step 3):
flagship:creatures/boar and flagship:creatures/pet (a small dog), with
graphs and binding tables flagship:animations/boar and
flagship:animations/pet.
- Bones:
root,body,head,tailandleg_fl,leg_fr,leg_bl,leg_br; the pet addsear_landear_r. Socketsheadandeyes, andmouthon the pet. - Regions:
manifold:head,flagship-game:bodyandflagship-game:legs, mapped by bone inbody_regions. - Clips: the boar has
idle,walk,run,graze,alert,charge_windup,charge_loop,dazedanddeath. The pet hasidle,walk,run,jump,sniffandcarry. - Footsteps: the walk and run clips, and the boar’s
charge_loop, carryfootstepevents markedGameplay, with aloudnesspayload in blocks. The graph declares themBoth, so the server derives them for hearing and clients play the sound. - Plays:
charge_windupandsniffare one-shots, which a gameplay ability or an AI task issues. - Bindings: walk and run come from
Motion(PlanarSpeed)through aMatchSpeedblend space. The boar reads the charge, daze and death states frommanifold-gameplay:presentation:flagship-game:charge_phaseequals 2 for the charge and 3 for the daze, andmanifold:deadis death. It reads graze and alert frommanifold-ai:ai-presentation:activityequals 1 for graze, andawarenessat least 2 is alert. The pet readscarryingfrom the same component’sactivity, which equals 1 for carry. A table compiles only once the host supplies those fields.
The GLBs are generated by manifold-model-fixture-gen. The recipes are
authored by hand, and the generator reads them from the committed files. To
regenerate the GLBs, run
cargo run -p manifold-model-fixture-gen -- --flagship game/flagship-game/assets.
crates/manifold-model/tests/flagship_creatures.rs fails when a committed GLB
drifts from its generator.
Their drops come from the same generator: flagship:items/loot_sack, the
model every loot drop without its own shows (the flagship sets it on the
manifold:item_drop archetype), and flagship:items/boar_tusk, the tusk
item kind’s model. Both are small rigid models without a recipe, with a
grip node that a carrier’s socket holds (the pet carries a drop by its grip
in its mouth).
Test controls
Players spawn holding the M14 (the player archetype’s loadout, Talos B
§12.5). F (flagship-game:fire) fires it every 0.2 s while held (the
flagship-game:m14_fire ability); each hit sends a server-confirmed hit
marker to both players, logged as [gameplay] hit <region> <amount>.
T (flagship-game:reload) plays the reload (predicted by the owner,
refused while one plays). /entity equip @self flagship-game:main_hand <item|clear> (Admin) changes the held item; an item kind names the model it
shows (gameplay).
Exporting from Blockbench
Every export, from the dialog or export_to, runs the same steps: it captures
the project once (switching tabs mid-export cannot swap models), checks names
and socket sources, resets to the rest pose, compiles geometry, samples the
clips into the GLB, derives the recipe fragment, merges and validates, writes
atomically, puts the timeline back where it was and lists the diagnostics in
the Manifold Diagnostics panel.
- Rest pose. The GLB’s node transforms are the rest pose whatever the Animate tab shows. A node the codec wrote from another pose is corrected and reported (MODEL607).
- Clips are sampled, not converted. The plugin evaluates each animation in
Blockbench at the clip rate (the recipe’s
clips.rate, the dialog’s clip rate, or 30 Hz) on exactly the frames the importer resamples to: ⌈length × rate⌉ + 1 frames spread evenly, the last at the clip’s length. It reads each bone’s local transform from the viewport, so catmull-rom, bezier, quaternion and linear keys export as what you see, and resampling at import changes nothing (no MODEL022). Rotations stay in one quaternion hemisphere; a channel that never leaves its rest value is not written. A channel keyed only with step keyframes exports as STEP at its key times. A Molang value in a keyframe is baked with default variable values and reported (MODEL604): move that logic into the graph instead. A clip that moves nothing still exports with its length. - Only real nodes are exported. Export-disabled objects (preview props, reference geometry) and everything inside them are never sampled, even when keyed. IK null objects and socket locators never become nodes; IK motion is baked into the bones it moves.
- Unique names. Every exported group, cube, mesh, locator and clip needs a
unique name (MODEL017 at import). The export refuses duplicates (MODEL605)
unless Fix duplicate names (or
fix_names: true) is on: a part named like its bone becomes<bone>_mesh(then<bone>_body), other repeats get_2,_3, as one undoable step saved with the project, so later exports keep the names.
Sockets from groups and locators
Select a group or locator and set Manifold socket in the element panel, or
name a locator socket_<name>:
- A group with a socket name becomes
(node: "<group>"): the bone’s own frame, rest rotation included. That is how a weapon-frame bone works: taggrip_r(Blockbench’srightItem) ashand_r. - A locator becomes a socket on the bone it sits in, with its offset:
(node: "<bone>", translation: …, rotation: …). The locator itself is not exported, so sockets never add joints. A socket locator outside every group has no bone to follow (MODEL018). - A socket on an export-disabled object, or inside an export-disabled group, is an error (MODEL610) and is left out.
- A group with Manifold preview item (such as an export-disabled
m14_previewprop) shows that item on the socket of its parent bone in the Asset Lab (editor.preview_items); it is never used in play.
Each exported socket carries its object’s UUID as its key, so renaming or moving the object updates its entry, and deleting it removes the entry.
Events from keyframes
Add keyframes to the Effects row of the animation’s timeline. A timeline keyframe’s script holds one instruction per line:
| Keyframe | Becomes |
|---|---|
footstep_left, footstep_right | footstep with payload foot: left or foot: right |
shot (any other bare name) | Cosmetic event shot |
event mag_out or event footstep foot=left loudness=4 | Event with a payload (loudness is in blocks: how far AI hears it) |
gameplay window_open reach=2 | Gameplay event (kind: Gameplay), for the server’s logic |
| Sound keyframe | sound with payload effect: <sound> |
| Particle keyframe | particle with payloads effect: <effect> and, when its locator is a socket locator, socket: <socket> |
Lines that are not one of these (Bedrock Molang such as v.recoil = 1;) are
ignored, and marker <name> lines are kept for sync markers, which a later
exporter update writes. Moving a keyframe updates its event; deleting it
removes the event. Character and weapon events that drive a reaction on
another model, such as the rifle’s fire_mechanism on the wearer’s shot,
stay cosmetic.
A recipe written before this exporter may already have hand-typed events such
as footstep_right. Those are yours and are never removed; the exported
footstep events are added beside them. Delete the hand-typed ones once
nothing listens for the old names.
Diagnostics in Blockbench
Tools → Manifold Diagnostics opens a panel with one row per diagnostic from the export, the merge and the validator (errors first). Clicking a row selects the object it names and frames it: a group, cube or locator, the source of a socket, a clip, or a keyframe (the clip is selected and the timeline moved to the keyframe). Errors never block writing: the files are written and the game keeps its last good version until you fix them.
| Code | Severity | Meaning |
|---|---|---|
| MODEL018 | Error | A socket locator is not inside a group |
| MODEL604 | Warning | A keyframe’s Molang value was baked with default variables |
| MODEL605 | Error | Two exported objects or clips share a name; the export is refused |
| MODEL607 | Warning | A node was exported from a non-rest pose and corrected |
| MODEL610 | Error | A socket’s object is export-disabled |
Templates
tools/templates/rifle is the held-item starting
point: rifle.bbmodel in the weapon frame (-Z muzzle, +Y up, origin at the
grip, 16 units per metre) with a grip group tagged grip, a grip_l group
for the off hand, socket_muzzle and socket_mag_well locators, a moving bolt
and trigger, and a fire_mechanism clip (no root motion) with muzzle_flash
and shell_eject keyframes; and rifle.model.ron, the recipe its export
produces, with the item fields of Characters and held items.
Copy both, rename, and export. The template’s GLB is not checked in: CI
exports it through the plugin and validates it.
Recipe merging
The GLB belongs to the modelling tool and is replaced on every export. The recipe belongs mostly to you: exporters merge into it and never overwrite your work. Every entry an exporter writes carries its provenance:
sockets: {
"hand_r": (node: "grip_r", origin: Export(key: "bb:4e0d6c1a-…")),
"muzzle_flash": (node: "hand_r_mesh", translation: (0.0, 0.1, -0.4)), // yours
},
editor: (
source: Some((tool: "blockbench", file: "marlton.bbmodel")),
),
- An entry without
originis yours (Authored). Exporters never change, move or delete it. origin: Export(key: …)marks an entry the exporter derived from a source object (a locator, group, empty or keyframe) with that stable key. The next export updates it to match the object, renames it with the object, and removes it when the object is deleted (MODEL603, informational). Editing such an entry by hand while leavingorigin: Exportin place means the next export replaces your edit.- To keep an exported entry as edited, change its origin to
Adopted(key: …)(the Asset Lab’s “adopt” action does this once it can edit). The exporter then leaves it alone and reports MODEL602 when its source object differs. - When an exported entry would collide with one of yours (the same socket name, or the same event in the same clip within 1 ms), yours wins and the export reports MODEL601.
- Everything else is copied byte for byte: comments, blank lines, formatting,
and fields this importer does not know (for example a block model’s
blockandpropsections). An export with nothing new leaves the recipe byte-identical, and exporting twice changes nothing the second time. - When the exporter creates a recipe, it seeds
profile_version,scaleandfilter; it never changes them in an existing recipe. editorholds tool-only metadata the game ignores: the source file used for “open in tool” navigation and socket preview items for the Asset Lab.
The Blockbench exporter writes sockets from tagged groups and locators,
events from effect keyframes, preview items and editor.source
(Exporting from Blockbench); the Blender add-on
writes editor.source until its sockets and markers land. Both use exactly
these rules.
Exporters merge through the game’s own code, cargo mod model merge:
cargo mod model merge --fragment export.fragment.json \
--recipe assets/my_mod/models/mobs/clockwork_golem.model.ron
A fragment is what one export derived, each entry with a stable key:
{
"tool": "blockbench",
"id": "my_mod:mobs/clockwork_golem",
"sockets": [{"key": "bb:4e0d…", "name": "hand_r", "node": "grip_r",
"translation": [0.0, 0.1, -0.4]}],
"events": [{"key": "bb:kf-91…", "clip": "walk", "time": 0.5,
"event": "footstep", "payload": {"foot": "left"}}],
"editor": {"source": {"tool": "blockbench", "file": "golem.bbmodel"}},
"create_defaults": {"filter": "Nearest"}
}
The command creates a missing recipe, writes only when the text changed, and
never writes a recipe it cannot parse (MODEL015; fix the RON and export
again). --dry-run merges without writing, --json prints the merged text,
changed, the diagnostics report and whether it written, and - reads
the fragment from stdin. If an exporter cannot find cargo mod, it refuses
to touch an existing recipe and writes <name>.fragment.json beside it with
the command to merge it.
Exporters and cargo mod write every file through <file>.tmp and a rename,
GLB first, so the game never reads a half-written package.
Validation
cargo mod validate imports models with the exact runtime importer and exits
non-zero when any model has an error. --model <id> imports only that
package, so an exporter validating a save sees only its own model.
--json prints a versioned document:
{ "schema": 1, "import_profile": 1,
"models": [ { "id": "flagship:player/marlton", "ok": false,
"diagnostics": [ { "severity": "Error", "code": "MODEL017",
"object": "node/arm_r", "source": { "kind": "node", "name": "arm_r" },
"message": "duplicate node name \"arm_r\"",
"repair": "give every node a stable unique name in the DCC" } ] } ] }
codeis the stable code (MODEL017), never an internal name.objectpaths use names, not indices: a channel problem readsclip/walk/node/arm_r.sourcenames the object in the modelling tool (kindisnode,mesh,material,clip,socketorevent; channel objects also carryclip), so a tool can select it.import_profileis the importer’s profile version, distinct from a recipe’sprofile_version.graphs(present only when non-empty) lists graph asset files with diagnostics of their own, by graph id, in the same shape asmodels; a model that uses a graph also repeats that graph’s diagnostics. A graph file with an error fails validation.
Exporter and merge diagnostics use MODEL601–MODEL699:
| Code | Severity | Meaning |
|---|---|---|
| MODEL601 | Warning | An exported entry collides with yours by name; yours is kept |
| MODEL602 | Info | An adopted entry differs from its source object |
| MODEL603 | Info | Exported entries were removed because their source objects are gone |
| MODEL605 | Error | Two exported objects share a name or key; the first is kept |
Asset Lab
Press F7 in native or browser builds. Asset Lab uses the real game renderer
and exposes catalog diagnostics, budgets, hierarchy, materials, clips, timeline
scrubbing, sockets, events, and animation graph definitions. Spawn preview places
the model where /model spawn would (the block under the crosshair, else three
meters ahead of the camera); Replace preview swaps the model in place. Both use
the same GPU model path as entities. With MANIFOLD_ASSET_DEV_DIR pointing
at an asset root, native builds poll GLB/recipe changes and atomically swap only
successful imports; rejected saves retain the last good model.
Hot reload works per model. A package is re-imported once its GLB and recipe
have stopped changing for one poll (250 ms), so a save in progress is never
read; a save that did not change the bytes is skipped; and only the changed
model is re-imported and re-uploaded. Placed instances of other models keep
their animation state; instances of the changed model restart their graph.
Reload re-imports the selected model from MANIFOLD_ASSET_DEV_DIR at
once; bundled and packaged models cannot change while the game runs, so
without a dev directory (and in the browser, until the dev channel lands) it
has nothing to reload.
The player model is the game’s AppMetadata::player_model
(flagship:player/marlton for the flagship). Until that asset is in the
catalog, the cube head remains solely as a missing-asset and browser
render-gate fallback. Dropping a valid Blockbench/Blender export at that ID
switches multiplayer avatars onto the public model path without renderer
code; with its graph and the game’s binding table
(binding tables) they animate from replicated movement.
In the browser, a page can supply development assets before joining as
window.__MANIFOLD_DEV_ASSETS__ = { "<ns>/models/<path>.glb": Uint8Array, … },
the counterpart of native’s MANIFOLD_ASSET_DEV_DIR.
Models are drawn from their compiled form: static parts fold into their bone,
so a Blockbench character is one draw per material, skinned with one palette
entry per animated bone (models without clips draw with no palette at all).
Each instance is culled against the camera and each shadow cascade using
bounds that contain every clip’s poses, drawn camera-relative, and writes
motion vectors, so animated characters stay sharp under TAA and upscaling.
Single-sided materials cull back faces (mirrored instances included).
Profile v1 models receive the real HDR/post stack. They cast sun shadows into the shadow cascades their bounds reach (opaque and alpha-masked materials; blended materials cast none, like glass) and receive them: within shadow range the sun on a model follows the shadow map and the stained-glass colored map, as on terrain and physics bodies, so a model half under a roof is half shaded. Ambient, block light and, beyond shadow range, the sun’s gate come from the voxel light sampled at the centre of their bounds. Sunlight is Lambertian, like terrain.
Quick in-world test
Create a loose asset root using the same layout a mod archive uses:
mkdir -p dev-assets/my_mod/models
cp /path/to/robot.glb dev-assets/my_mod/models/robot.glb
MANIFOLD_TEST_LAUNCH=1 MANIFOLD_ASSET_DEV_DIR="$PWD/dev-assets" cargo run -p flagship
Open the console with Backquote, aim at a block face, then run:
/model list
/model spawn my_mod:robot
/model spawn my_mod:robot 0.5 --clip walk
/model play 1 idle
/model pause 1
/model seek 1 0.5
/model speed 1 1.5
/model speed 1 -1
/model loop 1 off
/model state 1 locomotion
/model spawn my_mod:rifle
/model attach 2 1 hand_r
/model detach 2
/model param 1 moving true
/model param 1 speed 0.8
/model param 1 attack trigger
/model remove 1
/model clear my_mod:robot
/model clear
attach holds one placement at another’s socket (hand_r here), gripping
with the held model’s grip (or the grip its recipe’s item declares, or a
socket or node named as a fourth argument); the held model follows the
owner’s hand in the same frame and keeps playing its own clips. detach
leaves it where it is. Attachments are local to your client.
/anim inspects and drives animated instances. A reference names the
target: m:<handle> (a placement from /model list; #<handle> and a bare
number also work), @look (the animated instance under the crosshair,
64 m), @last (the previous /anim target), @self, #<id> and
player:<name>:
/anim stats
/anim inspect m:1
/anim params @last
/anim param m:1 speed 4
/anim param m:1 crouching true
/anim param m:1 shots trigger
/anim play m:1 upper rifle_reload
/anim play @look upper rifle_reload 1.5
/anim stop m:1 upper
/anim hit @look
/anim debug hitboxes
/anim debug hitboxes m:1
/anim debug off
/anim stats prints the animation runtime’s counters (placements stepped,
evaluation time, palette bytes, cosmetic events, resident clip memory).
/anim inspect prints one instance’s layers (state path, clip time, blend
input, weight), slot plays, modifiers with their class, parameters and the
latest events; /anim params prints the parameter table with each kind and
source. param, play and stop become animation ops that apply at the
start of the next simulation tick: param takes true/false, a number,
an enum variant name or trigger; play names a slot and a play the graph
declares for it (by play or clip name). A mistake (an unknown parameter,
slot or clip, or a value of the wrong type) is reported at once. On a
placement the ops apply on your own client, because placements animate on
each client; a placement playing a single clip switches to its model’s
graph. Other targets go to the server, which needs the server animation
runtime (a later Pygmalion phase) and says so until then. /anim hit on a
placement tests your view ray against its posed hit boxes and prints the
region, bone and point; /anim debug hitboxes draws every animated
instance’s hit boxes (or one instance’s) in the world, coloured by region,
for models whose recipe declares hit_volumes. Every /anim leaf is Admin.
The optional spawn number is uniform scale. If no clip is named, an animated
model automatically plays its first clip. Negative speed gives reverse clip
playback; graph playback clamps negative speed to paused. state forces a
state of the graph’s base machine (with a short inertialized blend), while
param sets a graph parameter by name: true/false for booleans, a number
for floats, ints and enum indices, trigger to fire a trigger once (it stays
for one release as an alias of /anim param, which it prints). /model list reports playback state and the latest named
animation events. Placement uses the adjacent block under the crosshair,
falling back to three meters ahead of the camera.
Placements are server-authoritative on native and in browser multiplayer.
spawn, remove and clear (every placement, or only one model ID’s) are
Admin requests: the server applies them, saves them with the world and
replicates them to every connected player; players who join later receive the
current set. A placement appears or disappears on your client only when the
server’s confirmation arrives. Handles are assigned by the server, so use the
numbers /model list shows. play, pause, seek, speed, loop, state
and param change playback only on your own client and are not saved. Browser
singleplayer keeps placements local to that tab; they are not saved.
Servers load models too (native, singleplayer’s embedded server and the
browser worker): the same packages from the same sources (bundled assets, mods,
MANIFOLD_ASSET_DEV_DIR), imported with ImportProfile::server(), which skips
textures. The server keeps the compiled runtime (skeleton, clips, graph,
sockets) and drops meshes and import-form clips; server animation and hit tests
read it through ServerModelCatalog. A model a server lacks is simply absent.
The flagship player model id is flagship:player/marlton.
F7 opens the same asset in Asset Lab for animation scrubbing,
hierarchy/material/socket inspection, diagnostics, and hot reload. When a GLB,
a .model.ron or a .graph.ron changes under MANIFOLD_ASSET_DEV_DIR, native
reloads the affected models and keeps the last good version if validation
fails. Placed models keep playing through a reload: their graph state is
remapped onto the new graph by machine, state, layer, slot and parameter
name, keeping clip times where a state’s nodes kept their shape.
Test fixtures
Tests never use the reference characters. manifold-model-fixture-gen
(crates/manifold-model/fixture-gen) builds synthetic packages in code: a
Marlton-shaped rigid rig named in Blockbench’s alias family (30 bones, 57
parts, the weapon-frame rightItem, the slice’s twelve clips from idle to
the additive rifle_aim_down/center/up sweep, footstep events with a
foot payload, shot, the reload’s ik_release_l curve and the
flagship:animations/humanoid_blocky graph), a rig with Marlton’s own bone
names bound to humanoid_blocky_v1 with hit volumes, a six-part cube mob, a
small skinned rig, the rifle template and a rifle with a fitted grip (both
held items), the flagship’s creatures (above), and one invalid package per
importer and profile diagnostic.
Write them to a folder to try the tools:
cargo run -p manifold-model-fixture-gen -- /tmp/model-fixtures
cargo mod validate /tmp/model-fixtures --json
The Blockbench plugin runs under Node against a Blockbench API shim
(node --test tools/blockbench-manifold/test/exporter.test.mjs): exported
clips must match the viewport within 1e-5 on catmull-rom, bezier and step
keys, plus names, sockets, events, rest pose and the panel. The shim’s
Marlton-shaped scene and the rifle template export exactly the fixture
recipes’ exported entries, which crates/manifold-model/tests/blockbench_export.rs
checks with the runtime’s merge and importer (it skips without node).
Golden recipe-merge cases live in crates/manifold-model/tests/fixtures/merge/
and run natively and as WebAssembly (scripts/test-model-wasm.sh), so the
merge produces the same bytes on both. Tests against the owner’s local
reference models run only when MANIFOLD_REFERENCE_MODELS names their folder.