This is the detailed capability reference, including engine constraints and known limitations. It records what HAF can do; it is not an ordered tutorial. Start with Getting started to build something, or use the Factory Manual to look up controls.
Animated custom models — a first, now one-click. A quadcopter drone injected onto a land-vehicle unit renders
full-size and textured and spins its own propellers from its own baked animation — no engine mod, no GPU-skinning
hang. Authored in the Animation Lab (Tools ▸ HAF ▸ Animation Lab, docked beside the Factory — the Factory owns the
model, the Lab owns the animation) and a single Bake does it all: Blender slims the rigged model (keep armature +
chosen clip, strip to the chosen bones, auto-clamp the frame range), then it bakes an Amplitude Skeleton +
ClipCollection + atlas and writes the registry; at runtime the clip is registered and a PawnManager.AddPawnEntry
hook drives the pawn’s pose onto it — normalized by clip duration so it plays at real speed. Works for any number of
instances. Clip/bone/hide-donor fields are Pick-driven (read from the model’s glTF + the plugin log).
IsMoving/IsAnyPawnMoving (the wait-to-idle/turn settle after stopping reads as “moving” and
drops the deployed pose) — the settle doesn’t move the tile, so a position check is instant to fold and settle-immune; (2)
the pose sampler does Mathf.Repeat(Time,1), so poseTime exactly 1.0 wraps to 0.0 = the folded frame — the deploy target
is clamped to 0.999 (and bake deployPoseTime ≤ 0.99) so it holds the last real frame. Gradual + tunable: the
deploy ramps at the clip’s authored speed × a Deploy speed slider; Deployed pose time sets how far it opens (also the
live barrel-angle knob when the clip is baked with an over-range elevation). Real deploy clips from rigid-part-animated
models: Tools/deploy_convert.py converts a model animated by moving parts (node transforms, no skinning — common in
Maya/Sketchfab exports) into a bone-per-part skinned armature the bake can consume: strips soft-skinned crew (they collapse the
bake), retargets the trail-leg spread (scale) and barrel elevation (amplify past the source’s max), and — critically — binds
the mesh at the rest frame so it isn’t baked pre-posed and double-deformed. Args: in out start end strip readyFrame legScale
barrelScale [recoilSrcStart recoilSrcEnd step mag arcR] (all recoil-shape knobs are script args, not Factory sliders — only
Recoil speed is in the GUI). Donor-aim override: artillery donors aim their barrel via a procedural
PawnEntry.BoneRotation layer that twisted the injected barrel; the pose hook zeros it so only our clip drives the skeleton.
(Known limitation: the Factory’s static preview shows the folded bind pose, not the deployed pose — judge the result
in-game.) See Firing-On-Attack.md.deploy [0 .. deployPoseTime] + recoil tail [deployPoseTime .. 1]; at rest it holds the
deployed pose, and when the unit bombards the pose hook sweeps once through the recoil tail (per-instance — only the gun that
fired), then returns to the deployed hold. The Recoil speed slider (recoilSpeed, runtime) tunes how fast the kick plays.
The hard limit you will hit: the clip bake keeps per-bone rotation only — it discards per-bone translation, so a real
hydro-pneumatic slide cannot be baked directly (verified: animating a bone to slide left its baked position bbox unchanged).
deploy_convert.py works around it with an FK-arc — a hidden far-pivot RecoilArm bone the tube hangs off, rotated a few
degrees so the tube swings on a long arc that reads as a near-straight backward slide (the arm’s rotation bakes; FK rebuilds
the motion). It keeps a slight swing — a perfectly straight glide is NOT reachable (counter-rotating to straighten it needs
translation → the bake drops it → the model explodes). A plain rotation muzzle-jolt is the simpler fallback. See Firing-On-Attack.md.SimulationEvent_ArtilleryStrikeStarted), matches the firing unit’s
UnitDefinition to the injected model, and triggers a single 0→1 playthrough of its clip — re-entrant, so rapid fire
restarts cleanly. Author the clip to start and end at rest. Extensible to bombers (AirStrikeStarted) and melee
(BattleStarted) the same way. See Firing-On-Attack.md.glbconv converter emits per-material usemtl groups + a .mtl (and an
8×8 solid-colour swatch for any flat, textureless material) so a multi-material GLB keeps its per-material split,
just like FBX. The animated path supports this too now (it was single-material only before — an open model like a
towed gun would texture its wheels/legs/barrel wrong): rig_anim.py keeps the material slots, the atlas packs them, the
skinned mesh’s UVs are remapped per-submesh then merged to one draw. A Material mode (Auto/Single/Multi) control
forces or skips it. Near-black UV dead-zones are filled neutral so unused regions don’t render black; tick Keep black
for a genuinely dark material (rubber tyre, glossy canopy) so it isn’t lightened.MeshWithSkeletonParticleIndexBuffer) is ~1,000,000 vertices / 6,500,000 indices / 2,500 meshes as
measured live (the 100000 in the decompiled source is only a default initializer — the runtime sizes it 10×
larger; don’t trust the constant). Each unique mesh is stored once and drawn for every pawn via GPU instancing
(DrawMeshInstancedIndirect) — so copies are free: 1 or 100 of the same unit cost one mesh. The budget is
Σ vertices of each distinct loaded model type (not units on screen, not the whole catalog — only loaded types).
Overflow doesn’t crash — it logs "Unable to store mesh … vertex buffer is not large enough" and silently drops the
mesh (the vanished-rotor-mast bug). Because file size compresses (~5:1 in the shipped bundle) but vertices don’t,
lean meshes = more model types fit, not smaller files. Full details, the live-measurement tool (F8 / Shift+F8),
and the Industrial/Contemporary era-clustering budget → Vertex-Budget.md..blend (auto-converted via an auto-detected Blender install).glbconv by flipping V (1 - v) on OBJ
write. glbconv also normalizes non-[0,1] UV tiles: a model that maps into a higher tile (e.g. the whole Zeppelin
hull sits in V 1→2, relying on texture wrap to repeat its skin) has its UVs integer-shifted back into [0,1]
before the flip — because the atlas packs each texture into a fixed rect and can’t wrap, so un-shifted tiled UVs would
sample outside the rect and the skin would vanish (fine in Blender, blank in-engine). Integer shift, so tile-crossing
triangles never tear. glbconv’s shift is a single global offset (right when the whole model shares one tile), so
the atlas remapper also folds per-vertex (u -= floor(u)) as each sub-mesh’s UVs are placed into its rect — this
catches a multi-material model whose materials each sit in a different tile, which no single global shift can
gather. Proven on the AH-1 Cobra: 51 materials spread across U 0→23 / V −11→0 (100% outside [0,1]) baked black until
the per-vertex fold; an island wholly in one tile subtracts a uniform integer (lossless), and only a triangle straddling
a tile edge smears. Genuine repeat-tiling (a small texture spanning [0,N]) remains outside what an atlas can do.
Two special cases are recognised before packing: a tiled material (its UV span exceeds 1.5 tiles) gets its
texture repeated inside its cell and its span mapped linearly across it, and a point-UV material (every UV
inside one texel of its texture — a ripped model using the texture as a colour picker, like the Romanic’s deck)
packs as an 8 px swatch of that texel pinned to the cell centre, so neither the cell edge nor the mip average of
the whole image darkens it.FxOutputLayer clone, so its skin never bleeds onto the vanilla donor
unit — proven on screen with a custom cruiser and its donor corvette side-by-side, each keeping its own skin.desaturate paints a desaturated copy of the unit’s own atlas with the civ-colour tint neutralised (a
bland grey Common copy of an emblematic unit — proven on GreyStealthCorvette), and textureFile hot-loads a
hand-painted PNG from BepInEx\config\haf_skins\ (paint over the unit’s own atlas dump from the in-game F8 ▸ Dump
Atlases tool). Managed by the Unit Retexture editor window; no bake, no mod rebuild, original unit untouched.
(2026-07-20) textureFile + adjustments work on custom (baked) model entries too — the plugin hot-loads the
PNG in place of the baked atlas, so a custom model is recoloured without a re-bake (adjust-only needs a PNG: the
baked atlas isn’t CPU-readable) — and the window gained a live preview of the exact skin it will inject (same
pixel math as the plugin’s AdjustSkin). (2026-07-21) A Brightness (gamma) adjustment (brightness, 1 =
unchanged) joins desaturate/tint — multiplicative, endpoint-pinned, the knob that actually lightens a dark skin
(the additive RGB offsets wash out first). See the manual’s §12.Assets/FactorySource/,
which is not part of the shipped mod — so licensed source models are never redistributed. The baked atlas is capped
by a configurable Atlas size (256 / 512 / 1024 / 2048, default 512) and block-compressed (DXT1 opaque; DXT5 when
static multi-material source alpha must survive), so each shipped skin is
~0.1–2 MB (a big airship wants 1024; a small unit is fine at 512).unitScales rule (Resize Lab,
Tools ▸ HAF ▸ Resize Lab) names a pawn definition and a factor, and that unit renders at the new size with its
animation intact — an Era-1 Bireme at ×2 keeps hull, oars and mast in proportion and still rows. It works by
scaling the unit’s vertex data in the live Fx content buffer (once per unit type) plus ObjectSpace.Scale per
pawn for part placement — the two things the GPU actually honours, established by disassembling the game’s own
shaders (tools/ShaderDump): the animation pass writes bone scale as a literal 1.0 and the draw shader applies
scale only to bind-pose offsets, so no transform can ever grow geometry. Free on the vertex budget (it edits
geometry already loaded, no clone). Human-presentation units are excluded by design. Per unit type, not per
instance. Units age with the world (Global Era Lab): a grid of (unit era × world era) modifiers multiplies each
ruled unit’s scale, so an Ancient hull and an Industrial one recede differently once the Contemporary age arrives.
The era comes from Sandbox.Timeline.GetGlobalEraIndex() (the game-wide era across all empires); scaling is applied
as a ratio against what a mesh already carries, so an era change resizes the unit live instead of compounding.
Grid defaults are 1.0 — the runtime invents no curve — and only units with a Resize Lab rule are ever touched.
Verified: a ×4 bireme rule rendered ×0.8 in era 5. Full detail: Unit-Size.md.BepInEx\config automatically.The injection layer is built to be cheap and safe by construction — the properties below hold for every model, and the mechanics are detailed in Animated-Runtime §3b.
PawnEntry fields per animated pawn (via a cached-reflection funnel); the actual bone skinning is done by the
engine’s GPU sampler, which is instanced. All expensive detection (movement/state, deploy/recoil ramps,
formation and respawn scans, audio) is throttled to ~10–20×/s, not run every frame. Clips resolve to cached
int ids once per session — no hot-path clip lookups.PruneGone), so state can’t accumulate; per-hit FX one-shots self-destruct on clip
end. There’s no per-instance asset streaming to leak or fragment.ObjectSpace, atlases,
audio) — never the simulation model or serialized save data. So it does not corrupt saved games or alter the
deterministic simulation, and uninstalling the plugin returns every unit to vanilla. (It is still a runtime patch: a
plugin bug can throw or, rarely, crash the process — every injection path is try/catch-isolated to keep that rare
and localized — but it can’t silently rewrite your save.) By the same token it should not cause multiplayer
desync — it changes what a unit looks like, not what the deterministic simulation computes (Humankind combat is
tile/data-based, not mesh-raycast). Treat that as an architectural expectation, not a tested guarantee: it hasn’t
been stress-tested across asymmetric host/client pack setups.GameBinding) resolves a catalog of 47 core game types + their hot-path members (including the army-walk
root that respawn / facing / class-scan / census all hang off) and logs exactly what’s missing —
[GameBinding] … type(s) + member(s) NOT FOUND (game update?), naming each one — stamped with the running game
version against the last verified build (currently 1.30). With per-hook fail-soft degradation on top, a
game-update break is localized and named: the log tells you which binding drifted, instead of a silent malfunction.
(Rationale and full arc in Framework-Review — the “reflection fragility” entries.)Assets/FactorySource/<name>/ is case-insensitively matched
by Windows/Unity — if a differently-cased asset with that name already exists (e.g. a vanilla attackHelicopter512.png
portrait), a new AttackHelicopter/ folder inherits the existing lowercase spelling. Bake-time only; the baked assets,
the registry, and in-game loading (by GUID) are all correctly cased. Pick a non-colliding resourceName if it bothers you.deploy_convert.py).360,0,0 identity trick for no-net-rotation conversions; Rotation is just a rotation again).