Use this page as the Model Factory reference. For a first model, follow Getting started instead and return here when a field or failed bake needs explanation.
The Factory is a Unity editor window (Tools ▸ HAF ▸ Model Factory). You choose a source model and target unit, configure the bake, then rebuild the mod so the game can load the resulting assets.
Starting with a static vehicle or helicopter that needs moving parts? Follow the shorter Vehicle Lab quickstart first.
Shipping a standalone pack? See Multi-Mod for the pack format and the
haf_packs/folder.
| Task | Section |
|---|---|
| Bake a first static model | Quick start |
| Look up a Factory control | The window, field by field |
| Bake an existing rig | Animated model workflow |
| Decide whether to Save, Bake, Build, or relaunch | After baking and the action matrix |
| Diagnose a bad result | Troubleshooting |
| Use texture-only or sound tools | Unit Retexture · unit sounds · Sound Studio |
| Configure clips and runtime animation behavior | Animation Lab |
| Convert a difficult character rig | Converting an animated model |
Sections 1–8 cover the normal Factory workflow. Sections 9–18 are reference and advanced workflows; read only the part that matches your task.
.glb, .gltf, .obj, .fbx, or .blend. It must be UV-mapped and textured if you want
the original skin..blend import, part-stripping, and Reduce-to-tris decimation. Static GLB,
glTF, OBJ, and FBX bakes need no Blender. HAF auto-detects it under C:\Program Files\Blender Foundation; use the
Factory’s Settings panel if it is installed elsewhere.<Humankind>\BepInEx\config.Era6_Common_Hovercrafts_01). A Resource
name is suggested; keep or edit it..glb/.obj/.fbx.[Factory] <name> DONE. skeleton=… atlas=….That’s the whole loop. Everything below is detail and the animated workflow.
<Humankind>\BepInEx\config (where the registry is written). Override if detection
misses your install. A ⚠ on the header means it wasn’t found.blender.exe, or ⚠ not detected. Set the Override (or
EditorPrefs 'HAF.BlenderPath') if Blender is elsewhere or only on PATH. It matters for animated models,
.blend import, part-stripping, and Reduce-to-tris.<New>). Picking one loads its settings so you can re-bake with tweaks._removed_<timestamp>_<name>/ in the backup root (the remove ABORTS if the snapshot
can’t be taken). An Undo remove button then appears next to Remove — one click restores the registry entry +
baked assets and selects the restored entry. The same snapshots are also fully restorable from the
Backup & Restore window’s “Factory remove snapshots” section (see Backup.md).The source — git-tracked, the one you (or any tool) edit — is Assets/Pack/<PackName>/pack.json. The
deployed copy under <Humankind>\BepInEx\config\haf_packs\<PackName>\pack.json is a build artifact,
exactly like the deployed DLLs: regenerated atomically on every Save/Bake, recreated on load if the game was
reinstalled, and never read by the editor. A hand-edit to the deployed copy is detected and warned about in the
Console — it will be overwritten by the next Save; edit the source instead. (Historically the deployed copy was
authoritative with the project file as a dual-written shadow; the split surprised every external tool and is
gone. A one-time migration adopts a machine’s deployed state into the source on first load after the change.) In the
ENC home project <PackName> defaults to ENCReload; an installed package derives the guest project’s identity
through HafPackageContext.
The Factory shows the same yellow Form ≠ registry banner the Lab has when its form and the registry
disagree (after a compile or an outside edit of the source), with an explicit choice: ↻ Reload entry (take
the registry) or Save/Bake (keep the form).
Refresh checks on demand: it re-reads the registry, updates the dropdown, and raises (or clears) the banner
right away — so after e.g. Browsing a model file you don’t want, press Refresh → banner → ↻ Reload entry to
revert. The form itself is never overwritten without that explicit choice.
The game floats naval pawns with the water surface above the model origin (its own ship meshes anchor at the waterline; HAF bakes anchor at the keel — two conventions meeting at the water). The vessel preview draws its water plane at the calibrated height, and the preview header shows a numeric keel/top readout — the drawn geometry’s lowest/highest point vs the waterline — so depth is dialed by numbers, not eyeballs.
The HAF water standard is waterLevel: 0.16 — mean surface ~0.05 plus ~0.11 wave allowance, measured
stepwise (0.5 → 0.1 → 0.05 → +waves) against a cruiser whose hull paint marks the waterline, the way the
6.93u tile spacing was measured. It is pack configuration: waterLevel in the registry header (next to
unitScales/eraGrid) — versioned, dual-written, backed up; the preview shows it read-only and no editor UI
can change it (an earlier same-day dial was retired: three copies of one number is the split-brain pattern).
Every vessel’s position Z is calibrated against it and was verified in-game (cruiser: water on the
boot-topping; submarine: deck-awash, swell claiming the deck). Changing the standard = a deliberate pack.json
edit plus recalibrating every vessel’s Z.
Two things the flat preview plane cannot show:
Ref man & Ruler (preview toggles, Factory and Animation Lab alike): a human figure at game pawn height beside the model — 1.85u, calibrated stepwise (0.9 → 1.1 → 1.85) against a human-scale soldier model and verified head-to-head — with X/Y position fields to walk him around, plus a vertical measuring stick (ticks every 0.5 game units, long ticks at whole units). Deliberately in units, not meters: every bake picks its own world scale, so units are the one honest common measure; the man is the human anchor beside it.
Combat height offset (Flight character section): raise or lower the unit while its army is locked in a battle, eased ~2s both ways — a submarine with a negative value fights submerged and resurfaces afterwards (the ENCReload sub runs −0.13: snorkel-only). Runtime field — Save settings + relaunch, no re-bake — and it works for static and animated models alike. Calibrate with the preview’s “In combat” checkbox: the model draws at its battle-locked height and the keel/top readout follows, so “only the snorkel above the waves” is a number (top +0.05u), not a squint.
Also remember the tank-destroyer lesson: a compensating dial outlives the defect it compensated for. If a
model was dialed up/down to fix a sinking bake in the past, a later bake-level fix turns that dial into a float.
The keel readout + ground/water reference makes such stale dials visible at a glance. The 2026-09-20 placement
fix — animated bakes now centre and ground the model exactly as static ones do — retires a whole generation of
such dials at once: an animated entry with a horizontal Position offset was most likely cancelling the very
miscentring the bake now removes (the Gatling guns carry y = -3.70 against a measured 3.34-unit miscentring).
Re-check it after the first re-bake.
For the complete Generate rig / Save / Bake / Build / Relaunch decision table, see Authoring state and deployment.
Baked assets (the _Skeleton/_Atlas/_ModelMesh/_Mat/_Model outputs in Assets/Resources) reach the game only
through a mod build — saved registry settings, skins and sounds are read directly from BepInEx/config on the next
runtime load and do not need that bundle build. A fresh bake is therefore invisible in-game (the boot pre-flight warns
about its unresolved GUIDs) until
the next build. Two surfaces keep this honest (2026-08-18, born from a real catch: a submarine re-baked five
minutes after the last mod build):
haf_districts.json), props (haf_props.json) and hand-prop references, each row labelled with its kind —
against the newest build’s timestamp: BAKED, NOT BUILT / BAKE MISSING / ORPHANED BAKE (outputs no
registry owns — dead weight that still ships) / TEST ARTIFACT (__convgate__ scratch) / shipped / no
bake needed. Problems sort to the top; hover any row for the explanation. Fix is always the same: run the
mod build, then relaunch the game.The list is also a cleanup tool: every row with baked outputs is selectable — plain click selects one, Ctrl-click toggles, Shift-click selects a range, the checkbox and Tick all drive the same state — and Delete selected removes the ticked rows’ baked outputs. The delete-guard snapshots every file first (restorable from Backup & Restore), and a registry-owned entry is only un-baked: the entry stays and shows as bake-missing until re-baked. Removing an entry itself remains the Factory’s Remove.
<name>_Skeleton, <name>_Atlas, …).PresentationPawnDefinition..glb/.gltf/.obj/.fbx/.blend. Leave empty when re-baking an existing resource to reuse the
already-imported model with new settings (fast iteration).Since 2026-07-18 the Factory no longer edits animation settings itself. The two windows are mutually exclusive in their settings and work together:
animated flag silently re-baked the
howitzer static — tipped over, no behaviors): the flag is self-healed from the entry’s own config (a named clip,
behaviors, bone filter, or a baked clip GUID ⇒ the entry is animated, whatever the stored bool says), and baking a
rigged model without any animation config asks for confirmation before producing a static bake.0,0,0 = the exact legacy pipeline (no rig manipulation whatsoever — a model that renders
correctly stays correct on every re-bake); any non-zero value = the rotation is baked into the rig in the
Blender step (rotate + transform_apply into vertices + bone rests, object-level anim fcurves stripped) — the fix
for raw glTF rigs that round-trip lying down (the Sketchfab Combine soldier ships a -90°X armature node and needs
90, 0, 0). Semantics — the same on the static and animated paths since the 2026-09-12 axis unification,
field-verified on every axis and both signs: X = pitch, Y = heading/yaw, Z = roll, and one value faces the
model identically whichever way the entry bakes. When a bake surprises you, probe one axis at a time in 90°
steps, and for animated models judge IN-GAME (⚠ the embedded preview’s orientation is meaningless for animated
models — fixed display flips; it happened to match the soldier and contradicted the howitzer — chasing it
re-baked a working gun onto its side, twice). Rotation needs the Model file set; the Blender step re-runs
automatically when any of its settings changed (see the Reuse note in §5). deploy_convert.py-prepared
rigs (the howitzer) are correct at 0,0,0 — never give them a rotation.KeepModel (artist’s normals) / Recalculate (hard edges via the Smoothing angle slider) /
Faceted (fully flat).…_ModelMesh_B, …, up to 8), each drawn as its own fragment on
the unit, so the whole model renders instead of the tail silently clipping. Every split bake raises a budget
warning (console + dialog) with the total — “N quads across K fragments, K.K× the normal per-unit budget” — because
more fragments are more draw work: reduce first, split only when further reduction would visibly hurt. Unchecked
(default) keeps the classic behavior: one mesh, the over-ceiling dialog, overflow clipped. See the
quickstart’s draw-ceiling section.
The second cost is vertices, and it is the one that bites: every chunk duplicates the vertices along its seam,
in the shared pawn vertex buffer (1,000,000 vanilla). How much depends on where the cuts fall — the same steam
frigate measured 99,676 → 125,369 verts (+26%) split static, and 99,676 → 100,648 (+1%) split animated, so
don’t carry one path’s figure to the other; read the BAKED MESH lines. Fill that buffer and the game stops
uploading any further mesh: units and districts stop drawing at once, with no error (2026-09-20 — a single
static re-bake of that frigate was the straw, on a buffer already doubled to 2,000,000 by BufferOverrides). F8
shows the fill; raise it with BufferOverrides = MeshWithSkeleton:verts=+N. See Vertex-Budget.doubleSided in the pack entry.1.0 = unchanged). The injection
path ships a flat albedo — the donor’s PBR normal/metallic/roughness maps are neutralized so its camo can’t bleed
onto your model — so a skin that relied on shiny metal, or a dark/washed-out texture, reads muddy in-game.
Brightness multiplies RGB (>1 lifts a dark skin); saturation scales colour around per-pixel luminance
(0 = greyscale, >1 = punchier). Baked into the atlas, so re-bake to apply (no re-import needed — quick to iterate).
Cheaper and repeatable vs hand-editing the extracted albedo. Note: the Factory preview is dim, so judge the final
amount in-game (brighter lighting) and dial back if over-warm._Atlas.asset
could be 128 MB. DXT1 sizes: 2048 ≈ 2 MB, 1024 ≈ 0.5 MB, 512 ≈ 0.1 MB, 256 ≈ 32 KB.) Re-bake to apply.verts=/tris= lines to make the final trade-off. No Blender? Use Weld & simplify instead
(below).Helix main + Helix_back
tail), so stripping both your main and tail rotor gets you a spinning main rotor and a spinning shrouded fantail.
Pick reads the object names straight from the model file (GLB/glTF; for FBX/OBJ/.blend, type them by hand — open in
Blender to see names). Case-insensitive substring match; needs Blender (it runs a delete-and-export step). Proven on the
RAH-66 Comanche (Cylinder06,Cylinder07 removed its main-rotor blades).BepInEx\LogOutput.log — so launch the
game once with the model injected, then Pick. Runtime-only (takes effect on reload, no re-bake). Can’t hide a
donor’s animated sub-parts (a rotor, spinning wheels) — those are baked at pawn spawn; pick a cleaner donor instead.Factory/RespawnDelayFrames, default 1 frame) if a slow machine shows
the low rotor briefly before it corrects.[Uni] freeze: '<name>' donor pose time pinned line in BepInEx\LogOutput.log. Note: it holds frame 0 of the donor
clip; if that frame isn’t a neutral pose the model may rest at a slight static offset (no wobble, just held) — report it if
so. It lives in Runtime (not the Animation section) because it’s a runtime flag that acts on the donor, and the
Animation section is disabled for the static models that use it.0 = faithful (keeps UV seams — use for textured models). >0 = vertex-cluster
decimation without Blender (coarser; averages UVs across seams, so only for heavy untextured meshes)._albedo.png on every
bake regardless of the checkbox. Keeping an extraction that no longer matches the source logs a warning (untick
for one bake to re-extract fresh).An interactive 3D preview is embedded near the bottom of the window (drag to orbit, scroll to zoom). It auto-updates
after every Bake and when you pick a resource from the 3D resource dropdown — so you see the baked result in the
window, no hunting in the Project view. It renders the baked prefab: <name>_Preview.prefab for animated models (a
static, textured, upright copy of the injected mesh — not itself injected) or <name>_Model.prefab for static models.
Use it to judge geometry and texture mapping/material boundaries, and — with the Reduce to ~tris field + the
Console vert/tri count — to dial in the lowest triangle count with no visible loss: drop the count → Bake → watch the
model degrade live, then step back up. Do not use its Standard-shader lighting to judge final brightness, gloss, or
in-game colour; inspect the baked atlas and verify in-game for those.
Animated multi-material safety pass. A Vehicle Lab output changes the source into one skinned mesh with separate material slots. The animated bake then optionally quadric-decimates that mesh before building one atlas and remapping each material’s UVs. That makes reduction a texture variable as well as a geometry variable. For a new animated vehicle:
0 to prove the source/material pipeline, then choose the
lowest known-good ceiling. Do not compensate with brightness or saturation.Your model must be rigged with a skeletal animation (an armature + at least one clip). glTF/GLB is easiest (the clip and bone pickers read it directly).
Raw / auto-rigged download (Sketchfab etc.)? Read §16 — Converting an animated model first: it explains the engine’s hard constraints (rotation-only clips, scale-1 skeletons, name-ordered bones), the automatic conversion the Factory performs, and the troubleshooting map for floating heads, smearing parts, and wrong orientations.
Only a STATIC vehicle model (no rig at all)?
Tools ▸ HAF ▸ Vehicle Labbuilds the rigged Spin GLB for you — probe the parts, mark wheels & turret (keyboard review, recipes, a Verify report), press Generate rig, and the output feeds this exact workflow. Full guide: Animated-Models.md → “Authoring the spin rig”. Verified in-game: the shipped ArmouredCar runs a Lab-generated rig. Tanks: mark the tread loop C (Caterpillar) and the barrel G (Gun) — the Lab instances the track as rigid links riding a measured belt path (see “Caterpillar tracks — treadize” there; bake with Keep bone translations ✓). UX (2026-08-01): recipes load from the top-row Edit existing dropdown (<new model>starts fresh); Wave rock (idle sway for floating hulls, e.g. the canoe) has an Enable checkbox, off by default — leave it off for wheeled/tracked units. After Generate rig, the Animation Lab’s Auto-detect button fills the bake config from the generatedSpinclip in one click. Part isolation: the height bracket (Hide below/above) is joined by a left/right slice (Hide left/right of (side) on the width axis) — bracket both to isolate one wheel, then mark it. All slider defaults are padded so nothing hides until you drag.
.glb/.fbx. When detection finds animation you’ll see “Animation detected” with an
Open Animation Lab button — press it (the Lab opens docked next to the Factory, pre-filled with this model).hover). Not the exploded/assembly clips.prop). This keeps only those bones animated and
freezes the rest, killing camera/body wobble. Leave empty for a fully-animated model (a walker, a turret).4. The Console logs
the computed Scale Factor.[Factory] <name> FBX scale factor … (native longest … -> <Size> units)
[Factory] <name> ANIMATED DONE. skeleton=… clip=… atlas=…
A non-zero clip= means the animation baked correctly.
Iterating on an animated model (2026-07-18 — geometry caching is AUTOMATIC): the Blender re-slim runs exactly when one of its inputs changed — Rotation, Reduce-to-tris, Clip, Animate-only-bones, Material mode, or the Model file — and is skipped otherwise (fast). You never manage this. The checkbox (now labelled “Keep extracted texture (hand-edits)”) has ONE job on the animated path: protect a hand-edited extracted albedo from being regenerated when the re-slim runs. (Earlier today it also gated the geometry, which made Rotation silently unresponsive while ticked — that trap is gone. And since 0.5.6 the protection actually works for single-material sources too — it previously hinged on a file only multi-material extractions have.) For static models it additionally reuses the extracted OBJ, as before.
Baking writes assets into the Unity project + the registry, but the game loads your mod’s built AssetBundle. Your baked skeleton / clip / atlas won’t reach the game until you rebuild/export the mod (your normal Humankind mod build step). Then relaunch. This is the #1 “why didn’t my change show up” cause.
A few game-data touches (in Databases, not the Factory) that make an injected model land better on the battlefield.
These are PresentationUnitDefinition / UnitDefinition edits — independent of the model bake, and they need a mod
rebuild.
Formation = a swarm (visibility + presence). The unit’s PresentationUnitDefinition → Presentation Formation
Definition controls how many dummy figures are drawn for the one unit. Switch a recon drone to Formation_Wedge_3 and
it renders as three drones in a wedge — far more visible on the map, and it reads as less fragile (a coordinated
swarm vs a lone scout). It composes for free with an injected animated model: every formation dummy is the same pawn
descriptor, so the plugin’s multi-instance handling gives each one our skeleton + clip — all of them render and spin.
Tune spacing with Dummy Offset Position / Angle.
Purely visual — not stronger. Formation dummies are cosmetic; they add zero HP or damage. It’s still one unit (one
health bar, one combat roll; the figures move / fight / die together). If you want it to be as sturdy as it looks, edit
the gameplay stats in its LandUnitDefinition (combat strength / health / defense) — a separate Databases edit.
Altitude (animated only). The registry position.z raises an animated model at runtime (§5) — set it high enough
that the swarm clears tall city buildings and stays above the terrain. Combined, formation + altitude make the unit read
clearly at every zoom: close-up spinning props, mid-range city-skyline flyover, and an identifiable banner on the
strategic map.
| Symptom | Cause → fix |
|---|---|
| Model invisible / see-through | Single-sided/CAD mesh (backface-culled). Animated: tick Double-sided in the Vehicle Lab and regenerate the rig. Static: Winding fix. Or it overflowed the shared buffer → lower Reduce to ~tris. |
| Model tiny (a speck) or huge | Size is the world length — set it to what looks right; the Console logs the scale. |
| Model upright on the static bake but upside-down (or pitched) on the animated bake — or vice versa | The entry was last baked on the pre-unification legacy axis frame (static path: raw Y-up data + longest-axis auto-align, so Rotation meant something different per path). Since the 2026-09-12 axis unification there is ONE frame: just re-bake — the extraction re-runs automatically — and re-dial Rotation once (typically (0, 0, 0)); the same value then faces the model identically on the static and animated paths. |
| ANIMATED model bakes huge & floats high in the sky (fine in the Factory preview, wrong only in-game) | The rig’s FBX embeds a metre→centimetre unit scale the SDK skeleton over-applies → ~100× oversize. Tick “Fix 100× oversize (FBX unit scale)” (Animation section) and re-bake at the real Size — the baker measures the FBX at true scale then bakes with the unit scale on, so Size = in-game units. It’s a per-model toggle (no universal rule: some exports need it, some break with it). |
| ANIMATED model vanishes / shrinks to a speck after ticking “Fix 100× oversize” | That model’s FBX does not carry the metre→cm scale, so the fix over-shrinks it. Untick “Fix 100× oversize” and re-bake — most rigs (e.g. the drone) bake correctly with it off. |
DEPLOY-CONVERT model (deployConvert ON): which way for Fix 100×? |
deploy_convert auto-selects a path by part count: small rigs (≤124 parts — the m114 howitzer class) take the LEGACY path (DeployArm, cm verts) → Fix 100× ON; huge rigs (T-62 class) take the CONTRACT path (DeployArmV2, meter verts) → Fix 100× OFF. The Console logs DEPLOY path: LEGACY/CONTRACT. If a deploy model is a speck with Fix 100× ON it’s on the contract path (turn it OFF) and vice-versa. The contract path also runs bone-slimming + a delta-form rebase, gated to big rigs — applying them to a small rig re-breaks it (invisible / crossed legs), which is what the deploy golden diff guard (§11) protects against. |
| Model looks dark / grey / washed-out in-game | Expected for skins that relied on PBR shine or a dark texture — the injection path ships flat albedo (donor PBR neutralized). Raise Albedo brightness and/or Albedo saturation and re-bake. Judge the amount in-game, not in the dim preview. |
| A black part (glass canopy, cockpit) renders grey in-game (multi-material model) | The near-black→grey neutralize step (which hides UV dead-zones) is flattening an intentionally black material. Tick Keep black (glass/cockpit) and re-bake. |
| Change didn’t show in-game | You didn’t rebuild the mod (§6) — baked assets only reach the game through the bundle; a registry-only change (runtime flags) needs just a relaunch. (Since 2026-07-18 the re-slim runs automatically when a Blender-step setting changed — the “checkbox swallowed my change” failure mode no longer exists.) |
| Bake fails: “glbconv albedo extraction failed…” | glbconv couldn’t extract the albedos (missing dotnet/glbconv install, or a broken GLB) — see the [glbconv] Console error for the specific cause, fix it, and re-bake. Since 0.5.6 this fails the bake instead of silently shipping a flat-grey model as a success; the previous baked assets are rolled back automatically and the registry is untouched. |
| Re-bake failed (“re-bake FAILED — restored the previous N baked asset(s)”) | Normal rollback: the previous bake’s assets were restored from the automatic backup, your working model is intact, the registry unchanged. Fix the logged cause and re-bake. |
| “re-bake RESTORE failed mid-copy — the backup is KEPT at …” | The rollback itself hit a locked file (antivirus/indexer). Your previous bake survives ONLY in the named backup folder (%TEMP%\haf_rebake_backup\<name>_<timestamp>) — close whatever holds the lock, copy that folder’s contents (assets and .meta) into Assets/Resources overwriting, and let Unity refresh. Retrying the bake is safe: each attempt backs up into its own directory and never touches a kept one (since 0.5.6). |
| Animated toggle greyed out | The model has no animation the probe can see (OBJ, or a glTF with no animations). Use a rigged glb/fbx. FBX/.blend can’t be probed cheaply, so the toggle stays enabled — type the clip/bones by hand. |
| “No clips readable from this model” | Clip/Bone Pick works for glTF/GLB only. For FBX/.blend, type the clip name and bone prefixes manually. |
| Animated model plays the wrong motion (parts assemble/explode) | You baked the wrong clip → set Clip name to the loop (e.g. hover), not exploded_view. |
| “Fire on attack” clip loops constantly (won’t rest) | The Fire on attack toggle isn’t set on this model → tick it and rebuild the mod. If it is set, confirm the firing unit matches (the plugin logs [Fire] *** OUR MODEL '<name>' FIRED on bombard) — only artillery/bombard units raise the event. |
| “Fire on attack” model never moves when it fires | The clip must start and end at rest (the single pass returns to frame 0). Re-check the rig’s keyframes. Also confirm [Fire] *** OUR MODEL … FIRED appears in BepInEx\LogOutput.log when it bombards — no log = the unit didn’t raise the artillery event (it’s melee/air, not a bombard). |
| Animated model tears apart / arms fly out | The clip source wasn’t isolated (an extra FBX in the folder → a multi-clip collection). Fixed in the current Factory (it bakes into a per-model anim/ subfolder). Re-bake; if it persists, remove stray .fbx from the resource folder. |
| ~1s stall each loop | A padded frozen tail in the clip. The Factory auto-clamps the frame range now — re-bake. |
| Body wobbles / “unbalanced flywheel” | The clip animates the whole body → set Animate only bones to just the spinning group (e.g. prop). |
| A donor part shows through (rotor, extra mesh) | Hide donor meshes → Pick it (after one launch so it’s logged). If it’s an animated donor sub-part, it can’t be hidden — pick a cleaner donor. |
| Model bobs / wiggles / wobbles (a rigid model on a hovering donor — e.g. an airship on the Recon-Drone donor) | Your static mesh is inheriting the donor’s idle/move animation. Tick Freeze donor animation (Runtime section) — the plugin pins the donor’s pose so the mesh holds rigid, and it still glides tile-to-tile. Static models only; no re-bake. Different from the “unbalanced flywheel” row (that’s a model’s own clip animating its whole body — use Animate only bones). |
| First unit’s borrowed rotor sits ~1 low (after a load, or on a freshly built/spawned unit; other instances fine) | An engine spawn race on the first pawn of the model, at creation. Tick Respawn after load on that model — the plugin rebuilds the unit’s pawns right after it renders and the rotor comes out right (tune Factory/RespawnDelayFrames in the plugin cfg if it’s briefly visible). Registry flag, no re-bake. |
| Bake fails: “needs Blender” | Install Blender or set its path in Settings. For static decimation without Blender, use Weld & simplify instead of Reduce-to-tris. |
| Re-baked static model is 90° off / tipped up in-game (preview looks fine) | An older Factory shipped a stale skeleton on re-bake (the static outputs were overwritten in place, so the skeleton baked from cached geometry). Fixed now — the static path deletes its outputs and force-reimports before baking the skeleton, so a re-bake matches a first bake. Just re-bake → rebuild → relaunch. |
| Texture looks stale in the editor | A Unity texture-residency quirk after a multi-material bake — open the source textures in the Project view and back. The in-game result is correct. |
| Vehicle Lab preview has a checker skin or does not show the final texture | The Vehicle Lab is a rigging preview. Checker intentionally overrides the source materials so motion is readable, and neither state represents the packed HAF atlas. Use it for part assignment, pivots, axes, and animation; use the post-Bake Model Factory preview, the exported <name>_Atlas, and finally the game for materials. |
| Animated multi-material model is one flat colour / entirely dark after Vehicle Lab | First set Material mode = Auto or Multi; Single collapses all polygons to material slot 0. Then set Reduce to ~tris = 0 and re-bake. If that restores the mapping, decimation crossed a topology/material threshold: increase the ceiling gradually (20k failed while 24k worked on one rotorcraft). Turn on Keep black only to preserve intentional black; it cannot repair wrong UV/material mapping. |
| Flat-colour (untextured) parts bake pure RED — or grey on the static path | Fixed in editor 0.5.2 — update the tools and re-bake; nothing in your model is wrong. The full story is in the Textures.md failure catalog. |
| Multi-material GLB comes out untextured / grey | Fixed — glbconv now emits usemtl groups + a .mtl (and solid-colour swatches for flat parts), so a multi-material GLB atlases like FBX. Re-bake an older GLB model to pick this up (it was baked before the converter preserved materials). Keep Weld & simplify = 0 (faithful mode; material grouping only runs there). |
| Baked skin is flat / missing in-game but looks perfect in Blender (no error) | The model UV-maps into a non-[0,1] tile (e.g. the whole hull sits in V 1→2) and leans on the shader’s texture wrap to repeat the skin. Blender wraps, so it looks right there; the atlas baker packs each texture into a fixed rect and can’t wrap, so out-of-range UVs sample outside the rect and the skin vanishes. Fixed — glbconv now integer-shifts each island’s UVs back into [0,1] before the V-flip (integer shift, so it never tears tile-crossing triangles). Re-bake with “Keep extracted texture” unticked (static path: that also re-extracts the OBJ) to pick it up. Diagnose by checking the extracted FactorySource/<name>/<name>.obj vt range — if U/V aren’t within [0,1], that was it. Note: genuine repeat-tiling (a small texture meant to span [0,N] and repeat N×) still isn’t atlas-supportable — none of the shipped models need it. |
| Multi-material model bakes all-grey (at 512) or near-black (Keep black on) — camo/markings gone (no error) | Same non-[0,1] UV cause as the row above, but the materials each sit in a different tile, so glbconv’s single global shift can’t gather them. The atlas remapper now also folds per-vertex (u -= floor(u)) when placing each sub-mesh into its rect, which does cover the per-material case. Re-bake to pick it up. (If you saw grey it was the near-black→grey neutralize masking the miss; black is the raw miss with Keep black on.) Proven on the AH-1 Cobra (51 materials, U 0→23). Diagnose per-material with awk grouping the OBJ’s vt by usemtl. Once mapped correctly, remaining softness is just Atlas size — bump 512→1024/2048 for crisp markings. |
Bake FAILED: IndexOutOfRangeException in MeshCollection.ImportMeshes (any animated model) |
The animated skeleton bake (Skeleton.Reimport) reads tangents off the skinned mesh; with none, Amplitude indexes an empty tangent array and throws. Fixed: the animated path always keeps tangents (importTangents = CalculateMikk), regardless of material count — the tangent-strip size optimization is static-path-only. (It bit twice: the multi-material howitzer, then the single-material drone.) The baker now also dumps a SKMESH … bones=/bindposes=/maxBoneIdxUsed=/tangents= line before the bake and flags a bone-index mismatch, so the opaque crash becomes a readable cause. Related: the weld pre-pass is single-material and single-bone only (welding across bone-part seams corrupts skinning). If you hit this after a baker change, check the SKMESH line — tangents=0 on an animated model is the tell. |
| A part/model is missing in-game but present in the Factory preview (mast, antenna, or an entire mesh) | Check BepInEx/LogOutput.log for vertex buffer is not large enough and F8’s GPU mesh buffer (live) readout. The pawn layer is a shared ~1,000,000-vertex pool and rejects a mesh that no longer fits; the preview is immune because it renders the asset directly. Lower Reduce to ~tris, remove invisible interior parts, or deliberately raise the buffer override. Budget by the bake’s actual verts= count—not by a mythical 25k per-model cap. See Vertex-Budget.md. |
| Re-bake has no effect in-game (still the old shape/orientation; preview shows the new one) | You re-baked but didn’t rebuild the mod. A re-bake keeps the same skeleton GUID, so the registry doesn’t change and the game silently keeps rendering the old geometry from the previously built bundle — no error, nothing to see in the log. Every geometry change needs bake → rebuild mod → relaunch. (Quick sanity test: bake with a wild rotation offset — if the game doesn’t tilt, your build isn’t reaching it.) |
| Deployed registry gone after a game reinstall / “verify files” | The git-tracked project source Assets/Pack/<PackName>/pack.json is authoritative. Open the Factory window; it recreates the missing BepInEx/config/haf_packs/<PackName>/pack.json artifact from that source. See §10 “Registry safety net”. |
Source folder created with the wrong case (e.g. attackHelicopter/ not AttackHelicopter/) |
Cosmetic, bake-time only. Windows/Unity is case-insensitive + case-preserving, so the folder inherits the spelling of any pre-existing differently-cased asset of that name (e.g. a vanilla attackHelicopter512.png). Baked assets, registry, and in-game loading are all correctly cased. Use a non-colliding resourceName (e.g. AH1Cobra) if you want the folder capitalised. |
Diagnostic tip — trust the atlas, not the preview. The Factory preview lights the mesh with a Standard (PBR) shader, so a smooth hull reads dark/glossy even when the baked skin is light and correct — don’t judge textures from it. To see the actual baked skin, select the
<name>_Atlas.assetin the Project view and run Tools ▸ HAF ▸ Export selected atlas to PNG (writes toC:/tmpand logs the average RGB). For UV problems,awkthe extracted OBJ’svtlines for the U/V range. Both beat staring at the preview.
Resources layout (reorganized 2026-07-28, in-game verified): the
Assets/ResourcesROOT now contains EXCLUSIVELY bake outputs of registered assets (<name>_Skeleton/_Atlas/_Clips*/_Mat/_ModelMesh/_Model+ district_DistrictMesh/_FxMesh, prop_Collection/EQ_*_Fragment, projectileProjectile_*/_TrailDrawer). ALL UI images — unit-card portraits (every size), technology/constructible icons, narrative event JPGs — live inAssets/Resources/Images/. They are referenced from the databases by (nibble-swapped) GUID, so the path is free; keeping them out of the bake-output namespace exists because a prefix-wildcard cleanup once deleted a unit’s portrait (see the Remove flow’s curated cleanup + Textures.md). Retired experiment files go toAssets/FactorySource/Attic/(on disk, not shipped) — nothing is ever just deleted.
Assets/Resources/<name>_Skeleton.asset, _Atlas.asset, _Mat.mat, _ModelMesh.asset
(static); animated adds _Clips.asset and a <name>/anim/<name>_anim.fbx.Assets/FactorySource/<name>/, kept out
of the built mod so licensed source models aren’t redistributed. Safe to delete to reclaim space (a re-bake re-extracts).Assets/Pack/<PackName>/pack.json — git-tracked and read/written by the editor. It holds
the pack header plus model entries (pawn description, asset GUIDs, transforms, runtime flags, and animation settings).<Humankind>\BepInEx\config\haf_packs\<PackName>\pack.json — regenerated from the
project source on Save/Bake. Do not hand-edit it. See “Registry safety net” below.<Humankind>\BepInEx\LogOutput.log — [Uni] … lines show what was injected (and the donor fragment
names the Hide-donor Pick reads).The registry has one authoritative copy: the git-tracked project file
Assets/Pack/<PackName>/pack.json. The copy in the game folder is only a deployed artifact. Four layers protect the
source and make recovery explicit:
.tmp file and swaps it in (File.Replace). An interrupted or locked write can
never leave a truncated registry. If the swap itself fails (antivirus / indexer / the running game holding the file),
the bake status says “Baked, but REGISTRY SAVE FAILED” — the asset is baked; close the lock and re-bake to write
the entry.pack.json won’t parse, it is preserved beside the source as a timestamped
.corrupt-YYYYMMDD_HHMMSS.json file. Save/Bake stays locked, and the red Factory banner reports the JSON line/column
instead of replacing the unreadable registry with an empty one.pack.json from the
project source. If somebody hand-edited the deployed copy, the editor warns that it differs and the next Save
overwrites it from the source. A missing project source can adopt a valid deployed artifact as a last-resort recovery.Bakes are manual and the roster is growing, so a baker change can silently break a model you don’t happen to re-bake
until much later. These guards catch that at the integration seam unit tests can’t reach — run them after any change to the
baker, rig_anim.py, deploy_convert.py, glbconv, or the registry schema.
All of them run from one window: Tools ▸ HAF ▸ Bake Tests… (since 2026-08-20 — it replaced seven bare menu
items). Each test is a row with a plain-language explanation, a checkbox, and Quick/Everything presets; the run
shows live per-row PASS/FAIL (failures unfold their detail lines) and writes a durable report to
Logs/haf_bake_tests_report.txt.
A run is one synchronous, fire-and-forget pass (deliberately: a tick-driven queue used to stop silently when the editor lost focus), and its progress shows in three places, because no single surface survives every phase:
Run: 1/3 · Does the baker still work?) and
the step level (Baking AttackHelicopter (2/5)…) — live during the run, with each row marked RUNNING… /
queued below. (The window forces synchronous repaints for this; a blocked main thread never paints otherwise.)Importing…, Hold on…) cover everything editor-drawn while they run — most of
a real model bake’s wall clock. Nothing can draw into them, so the run position rides in the one thing they do
display: the fixture path. __smoketest__04of05_ReconDrone in the import dialog is the run telling you where it
is through Unity’s bar.The report file is rewritten after every row, so a cancelled or interrupted run still leaves a record of what finished.
A row has three verdicts, and SKIPPED is a first-class answer, not a soft failure: it means the row had nothing it could legitimately test, and its detail line names why. The distinction that decides which you get is where the tools are running (they detect this themselves — see Installation.md):
What a fresh install looks like (verified live in a real second project, 2026-08-24): smoke rows SKIP —
"this project's pack ('<YourMod>') has no models yet" — because your pack starts empty; the registry-driven
rows (every converted model, golden snapshot) SKIP for the same reason; the synthetic rows run for real.
PASS — 16 passed, 0 failed, 4 skipped is the healthy fresh-install result with Blender present. Every SKIP
converts itself to real coverage as you bake: your first model puts the smoke rows to work, your first
Convert raw rig model feeds the conversion rows.
Blender is the one external install the tests (and the baker) can need; the other helpers — the conversion
scripts and the glbconv GLB/glTF importer — ship inside the package since 0.4.0, so there is nothing else to
set up. Blender is auto-detected under Program Files; a winget/Steam/portable install needs the EditorPrefs key
HAF.BlenderPath pointed at blender.exe (discovery doesn’t probe PATH).
| Row | Needs Blender? | Without Blender (installed package) |
|---|---|---|
| Do the bake options do what they claim? (synthetic cubes) | No | runs in full — 13 assertions |
| Does the baker still work? / whole catalog | only for animated / tri-reduced / stripped entries | static entries still bake (glbconv ships); rows SKIP while your pack is empty |
| Do the Blender + animation options work? (real rigs) | Yes | SKIP — "Blender was not found" |
| Is rig conversion still correct? (control rig) | Yes | SKIP — names Blender (or, pre-0.4.0, the missing scripts) |
| every converted model / golden snapshot | Yes | SKIP — and also skip while no such models exist in your pack |
The same boundary applies to baking itself: a static model at targetTris 0 with no strip-parts — including the
whole projectile path — needs no Blender at all, from .glb, .gltf, or .obj. Tri-reduction, part-stripping,
.fbx/.blend import, and every animated path shell out to Blender and will tell you before the bake if it is
missing.
The guards, in pyramid order:
animated × material mode) through the same config route as the Bake button and asserts each completes
without throwing and produces non-empty _Skeleton/_Atlas (+ _ModelMesh for static). Non-destructive: it bakes
reuseExtracted=false models under a throwaway __smoketest__ name (your real assets + registry are untouched) and
validates existing assets for reuseExtracted=true models (forcing a fresh extraction you never run gives false
failures). It’s SLOW (real Blender bakes) — a pre-commit check, not an every-save one. This is not theoretical: it
caught a same-day tangent-strip regression that had broken every animated bake. Known fidelity limit: a throwaway
bake can’t regenerate an animated multi-material model’s per-material albedos (they’re keyed to the real name), so the
howitzer’s throwaway bake exercises its skeleton path, not its texture packing — the multi-material atlas code is
covered instead by the static multi-material AttackHelicopter, whose albedos glbconv does regenerate. Texture-only
entries (Unit Retexture Retex_*: no model file, nothing to bake by design) report SKIP, not a failure.doubleSided doubles the triangle count, Faceted unwelds
(vertexCount == triangles.Length), heightUV maps V to height, atlasMaxDim caps the atlas, size/positionOffset
land where configured, albedoBrightness/albedoSaturation change the atlas (best-effort pixel read — SKIP if the DXT1
atlas isn’t CPU-readable). Tier 2 (slower, real Blender): targetTris decimates a generated high-poly grid,
stripParts drops a generated named object, and the animated pipeline (BuildAnimated → _Skeleton + _Clips) is
exercised by borrowing up to two rigged models from the registry (SKIP if none on disk — a rigged FBX can’t be
synthesized); since 2026-07-19 those fixtures clone the registry entry and route through ConfigFor, so
convertRig/rotation carry and each model is tested on the pipeline it actually ships on. Both non-destructive
(throwaway __feat_* names, cleaned up). Benign console noise during Tier 2: ImportFBX Warnings: Can't import
normals, because mesh 'default' doesn't have any — the synthetic OBJ fixtures carry no normals and Unity
recalculates them; shading is irrelevant to what these fixtures assert.animId + boneIndex), and a real FrameCount. Two fixtures: the deterministic litmus
rig (Tools/make_litmus.py, synthesized via Blender on demand — rigged fixtures CAN be synthesized now), and
the real converted rigs from the registry (the Combine soldier’s location-keyed ValveBiped: 62 bones, 342
frames, the full rest-normalization — the strongest net; needs the source model files on disk, skips with a
warning otherwise). Both bake under throwaway __convgate__ names through the same ConfigFor route as the Bake
button, then clean up. Slow (real Blender bakes) — run after touching rig_anim.py or UniversalBaker.
Also since 2026-07-19: the Smoke Test selects an extra representative per animated path (legacy vs conversion,
keyed on the convertRig flag since the 2026-07-18 gate refactor — they’re different pipelines; tags
animated-legacy / animated-conv) and requires the _Clips asset + _ClipsPoseData.bytes for animated
models (an empty-clip bake used to pass silently). The full suite re-verified green after the gate refactor:
smoke 14/14 (soldier fresh-baked as animated-conv via the flag at Rotation 0,0,0; the legacy drone + howitzer
byte-identical with the flag off) and ConvGate full-conversion PASS on the real soldier rig.bash Tools/deploy_regression.sh (2026-08-01). The two variants above test convertRig rigs against
invariants; this covers the deploy-convert models (deployConvert, not convertRig — the m114 howitzers, the
T-62) with a golden-master diff, because an invariant pass can’t catch a per-model regression (a crossed-legs bake
is still a valid rotation-only clip). It re-runs deploy_convert on every model’s recorded args
(Assets/FactorySource/<res>/deploy_converted.args.txt) and diffs a deterministic bone snapshot (deploy_bonedump.py:
armature name = legacy/contract path, bone count, per-bone rot+loc at start/mid/end) against
Tools/deploy_golden/<res>.txt. Goldens cover both path classes (m114 DeployArm/legacy/29 bones, T-62
DeployArmV2/contract/126). A FAIL on a model you didn’t mean to touch is the regression — that is precisely the
T-62 “engine contract” silently breaking the m114 (invisible / microscopic / crossed legs) that this guard now catches.
The CLI form prints the line-level diff and re-blesses goldens with --capture (only after re-verifying that model
in-game — review the git diff Tools/deploy_golden/). Menu item and CLI share the same scripts + goldens.bash tools/check_schema_parity.sh. The registry is written by the baker (ModelDef, JsonUtility)
and read by the plugin two ways (ModelEntry via Newtonsoft, plus a regex fallback) in this repository. The shared
schema is compiler-enforced; the guard makes the remaining hand-maintained shapes loud: it asserts (1) the Newtonsoft and regex read paths read the same key set,
(2) every read key is a field the baker writes (plugin ⊆ ModelDef, with an allowlist for deliberate plugin-only overrides
like scale), and (3) each read cast’s type matches ModelDef’s declared type; bake-time-only fields are listed as INFO.
Catches a silent rename/drop/type-change that would otherwise make a feature quietly default-off.Sometimes the vanilla model is fine and only the skin is wrong — a Common copy that should look distinct from its emblematic original, a colour test, a themed variant. For that there’s a separate window — Tools ▸ HAF ▸ Unit Retexture — that reskins an existing unit without baking a model: the vanilla mesh is kept, and the runtime plugin paints your texture onto an isolated clone of the unit’s output layer, so the original unit (and every other unit sharing that layer) is untouched.
Everything is a plain registry entry (no assets, no mod rebuild). Section 3 of the window is Replace / adjust skin:
textureFile: a PNG filename in BepInEx\config\haf_skins\. The plugin hot-loads
it at runtime. Leave it empty to adjust the unit’s OWN atlas (or, when editing an entry, to keep its current skin).brightness.Proven on the grey corvette. (These sliders replace the earlier single grey/darken toggle.)
AdjustSkin — luminance pull + per-channel offset), rebuilt live as you drag the sliders. It previews the atlas
image, not a posed 3D unit — units are GPU crowd-rendered with no editor-side GameObject, so there is nothing to
pose (see §10 / the native “Open in Editor” preview for the mesh itself).Custom models too (2026-07-20). A textureFile + adjustments on a baked model entry now recolour that model:
the plugin hot-loads the PNG in place of the baked atlas (falling back to the atlas if the PNG is missing) and
applies the Desaturate/RGB to it — so a custom model gets a new skin without a re-bake. Paint over the model’s own
atlas dump exactly as with a vanilla unit. One caveat: the baked atlas itself isn’t CPU-readable at runtime, so an
adjust-only recolour (sliders with no PNG) does nothing on a model entry — always route a custom-model recolour
through a PNG (dump → repaint → assign). Previously a textureFile on a model entry silently diverted the entry down
the vanilla texture-only path — painting the donor’s layer and never even swapping the mesh in.
Sound moved out of this window. Engine events and custom WAVs are configured exclusively in the dedicated Unit Sound window (§13–§14, with per-clip volume and ▶ preview); this window is skins only. Apply here never touches an entry’s sound fields — whatever audio the entry already carries is preserved.
Workflow:
_Common_..._01 copy, not the emblematic original — the isolation
clone is what leaves the original untouched; they share an output layer). The entry name defaults to Retex_<pawn>.BepInEx\config\haf_atlas_dump\), then paint over the unit’s PNG in any editor. It’s the unit’s real UV layout, so
what you paint is what wraps.config\haf_skins\ and writes
the registry entry (or tick Grey for the desaturate mode). Relaunch/reload the game to see it.Because the mesh is untouched this can’t fix silhouettes — it’s for palettes, markings, camo. And since the plugin hot-loads from the game’s config folder, iterating is repaint → overwrite the PNG → reload; no editor round-trip at all.
Cost — essentially free on the vertex budget. A reskin keeps the unit’s existing mesh, so it adds no vertices,
indices, or meshes to the GPU pawn-layer buffer — that buffer is budgeted per distinct mesh type (instances are free;
see Vertex-Budget.md), and a reskin introduces no new type. It costs only an output-layer clone (a render slot) and one
texture. So unlike a baked custom model — which does add its own distinct mesh’s verts — you can stack many reskinned
variants without approaching the mesh ceiling. (Recolouring a single custom model without a re-bake is built — see
above. The remaining extension is one baked mesh shared across different base units as textured variants: the plugin
already dedups a shared skeleton’s mesh on upload, but that still needs work on the per-donor rename; not built yet.)
Injected/retextured units are silent on move by default: the per-ship engine sound (Play_UNIT_Vehicles_<Type>_Start
/_Stop) rides an audio-service path tied to the vanilla unit’s move state, which our re-loaded units never trigger. (Full
diagnosis in the unit-movement-audio-investigation memory — the emitter, its Wwise registration and its 3D position are
all fine; only the trigger is missing.) The plugin restores the sound by firing it itself.
Enable it — tick the Wwise engine event option in the Unit Sound window (§14; or set engineSound: true in
the registry — sound config used to live in the Unit Retexture window, but is owned by the Sound window now).
The plugin then watches each of that unit’s instances and, on a movement start/stop transition (render-position delta,
like deploy-on-stop), posts the engine event onto the pawn’s AudioEmitter: a rev on departure, a settle on stop.
The one-click path (2026-07-25): “Use donor engine sound”. The Sound Studio’s Wwise section has a button that does
the whole job: it reads the unit’s DONOR from the plugin’s load log (the donor Skeleton='Unit_…' line — so launch the
game once with the unit loaded), extracts the family word (AntiAirGuns), fuzzy-matches the catalog for its Movement
pair (plural-trimmed contains, prefers Move/Movement, Stop = the _Start→_Stop sibling) and fills both fields — e.g.
the ArmouredCar → Play_UNIT_Vehicle_AntiAirGun_Movement_Start/Stop. Vanilla units (no donor line) fall back to their
own family name. Every miss reports in the status bar; the fields below remain the manual path.
Manual path — searchable Pick (2026-07-25): both event fields have a Pick button opening a searchable dropdown
over the full dumped catalog (no more hand-copying from the text file); picking a *_Start auto-fills the matching
*_Stop when it exists. The button is disabled with a how-to tooltip until the catalog has been dumped once.
Name the sound (works for the FIRST unit, no capture): fill Start event / Stop event with Wwise event names.
The plugin posts them by name (AkSoundEngine.PostEvent(name, emitterGuid)), so a named sound plays for the very first
unit at load — no dependency on anything else having moved. Leave them blank and the plugin falls back to a handle
auto-captured from any same-family vehicle that moved this session (fine mid-game, but the first unit stays quiet until
then) — so name the events for a shipping mod.
Extract every sound — the catalog: F8 window ▸ Dump Sound Catalog writes every Wwise event name in the game (~800+)
to BepInEx\config\haf_sound_catalog.txt — this file also FEEDS the Pick dropdowns and the donor auto-button above,
so dump it once per machine and the UI takes over. Examples:
| Unit family | Start event | Stop event |
|---|---|---|
| Modern warship (corvette, destroyer, stealth) | Play_UNIT_Vehicles_StealthCorvette_Start |
Play_UNIT_Vehicles_StealthCorvette_Stop |
| Aircraft carrier | Play_UNIT_Vehicles_AircraftCarrier_Start |
Play_UNIT_Vehicles_AircraftCarrier_Stop |
| Steam-era frigate | Play_UNIT_Vehicles_SteamFrigate_Start |
Play_UNIT_Vehicles_SteamFrigate_Stop |
| Submarine | Play_UNIT_Vehicles_Submarine_Start_Modern (or _Old) |
Play_UNIT_Vehicles_Submarine_Stop_Modern (or _Old) |
| Landing craft / hovercraft | Play_UNIT_LandingCraft_Start |
Play_UNIT_LandingCraft_Stop |
| Helicopter | Play_UNIT_Helicopter_Move |
Play_UNIT_Helicopter_Stop |
| Towed howitzer | Play_UNIT_CanonObusier_Move_Start |
Play_UNIT_CanonObusier_Move_Stop |
| Wheeled gun / mortar | Play_UNIT_Vehicles_Mortar_Move_Start |
Play_UNIT_Vehicles_Mortar_Move_Stop |
| AT gun | Play_UNIT_AntiTankGun_Move_Start |
Play_UNIT_AntiTankGun_Move_Stop |
Air units (planes, zeppelins, drones) have no engine-loop event in the game — only takeoff/shoot — so they can’t be given a continuous move sound this way; leave engineSound off for them.
It posts any named event, so this isn’t limited to engines — attach any sound in the catalog to a unit’s movement. (In the ENC mod, all the naval/ground/heli units are pre-configured with the events above; the three air units are left silent.)
Audio diagnostics (F8 window), kept for future sound work: Dump Audio logs each unit’s emitter (registration,
position, idle/free-event state); Audio Trace toggles a live log of every sound the game posts (how the event names
were discovered — it patches the service sink AudioManager.PostEvent); Play Audio (test) posts the captured engine
handle onto the filtered units to confirm audibility.
Limits (honest): the Wwise-event path fires Start/Stop accents only (no sustained loop between) — for a continuous engine/hover loop, use the custom WAV path (§14). The auto-capture fallback uses the last-seen vehicle Start, so a named event is preferred; a shipped registry with names Just Works from the first unit, every launch.
Tools ▸ HAF ▸ Sound Studio collects a unit’s whole audio profile into one dialog with collapsible sections — Silence inherited donor sound, Idle growl (file, volume, interval, one-voice radius), Attack sound, Death sound, Battle start war cry (each: file, volume, start offset), Movement (start/travel/stop), and Wwise engine event (with the searchable event Pick dropdowns and the one-click Use donor engine sound button — §13). The Pawn-description field has its own Pick (2026-07-25): a searchable dropdown over EVERY pawn descriptor in the loaded databases — ENC and mounted vanilla alike (first open scans the assets once per session) — and picking loads that pawn’s existing audio config. Pick a pawn (or Edit one from the “Units with audio” list, which fills the window’s remaining height) and every knob round-trips; each WAV row has a ▶ preview. Folded headers summarize their configuration (2026-07-25): while a section is collapsed, its configured WAV/event shows right-aligned in green on the header row — growl file + interval, one-shots with their start offsets, the movement trio, the shortened Wwise event, “silenced” — so a fully-collapsed window reads as the unit’s complete audio dossier and configured-vs-empty is visible at a glance. Everything below is one of those sections.
When the game has no suitable sound (drones, zeppelins) or you want a bespoke engine, drop in your own audio. Tools ▸ HAF ▸ Sound Studio — the Movement section — assigns up to three WAVs, each with its own volume —
These play through Unity’s own AudioSource (not Wwise), so any WAV works — no soundbank needed. Requirements: 16-bit
PCM WAV (convert mp3/ogg first); mono for true 3-D positioning. Files are copied into BepInEx\config\haf_sounds\ and
referenced by the registry (soundStartFile/soundFile/soundStopFile + soundStartVolume/soundVolume/soundStopVolume).
It writes onto the unit’s existing registry entry, so a unit still has one entry. (A Wwise engine event option in the
same window covers the §13 game-sound path.)
Volume is perceptual in the window — the slider tracks perceived loudness (√ curve) and the ×N label shows the real
linear amplitude stored (hearing is logarithmic, so e.g. slider 0.4 ≈ amplitude 0.16). Seamless loops: if a raw Travel
clip clicks at its wrap, crossfade a copy first (blend ~0.1–0.3 s of the tail into the head).
Audition in the editor: each clip row has a ▶ preview button that plays it at its set volume (with ■ Stop), and Edit on a unit in the “Units with audio” list loads its current Start/Travel/Stop files + volumes — so you can pick and tune a sound without launching the game.
Performance note: the runtime audio driver polls only our units’ sub-pawns and refreshes its scene lookup on a ~2 s
cache — never a per-frame FindObjectsOfType (an early version did, and it visibly cut FPS). If you extend it, keep any
full scene scan off the hot path.
silenceDonorAudio)A custom creature that reuses a donor inherits the donor’s sounds as well as its animation. The Abomination borrows a
bear, so it played the bear’s idle growl and combat maul/scratch — and neither is a data field you can blank:
the idle loop is PresentationPawnDescription.IdleAudioEvent posted at spawn, and the attack SFX is baked into the borrowed
animator’s MecanimEventData (resolved by animator GUID). Both, however, funnel through the same chokepoint —
AudioEmitter.PostEvent on the pawn’s Wwise emitter.
Tick “Silence the borrowed donor’s inherited sounds” in the Sound Studio window (or set silenceDonorAudio: true in the
registry). At runtime the plugin registers each of the unit’s pawn emitters and drops every Wwise post on them (a
Harmony prefix on AudioEmitter.PostEvent), plus a one-time StopAll to cut an idle loop already running since spawn. It
silences only Wwise — your own custom WAVs (below) go through Unity’s AudioSource, so they still play. Confirm it
engaged in LogOutput.log: [Audio] '<resource>' donor audio silenced (emitter <id>) (one line per pawn). Reusable on
any unit stuck with an unwanted inherited sound (e.g. a borrowed-rotor helicopter that drones forever).
soundIdleFile)Pair the silence above with your own idle vocalisation: the Idle growl row in the Sound Studio window assigns a WAV
played as a one-shot occasionally while the unit stands still (not moving). The plugin fires it on a per-pawn timer at
soundIdleInterval seconds (default 11), jittered 0.6–1.4× so a pack doesn’t growl in unison, and suppressed
while moving (the cadence reschedules when it stops). This mirrors how the game’s own idle vocalisations play — periodic,
not a continuous wall of sound. Registry keys: soundIdleFile / soundIdleVolume / soundIdleInterval (≤0 disables).
Same WAV requirements as §14 (16-bit PCM; mono = 3-D).
One voice per unit (soundIdleGroupRadius, default 10). A unit is many pawns (a 5-monster stack is 5 pawns), so
without de-dup all five snarl at once — a chaotic overlapping wall. When a pawn growls, any packmate within
soundIdleGroupRadius world-units stays quiet until the interval passes, so a clustered formation speaks with one voice
per interval and the “voice” rotates among its pawns as their timers come due. Units farther apart than the radius growl
independently; a massed horde within it shares one occasional voice (usually what you want). ≤0 = per-pawn (every pawn
growls — the old behaviour). Verified in-game on a 5-Abomination stack (2026-07-23): the wall-of-sound collapsed to one
periodic snarl.
soundAttackFile)A distinct, violent one-shot for when the unit strikes — separate from the idle growl. soundAttackFile /
soundAttackVolume / soundAttackOffset (a WAV in haf_sounds/). Three things make it land right:
UnitActionFaceEnemy.StartUnitAction — the moment
the attacker turns to face its target, before the strike choreography. (Earlier attempts fired per-swing, which landed
the roar near the END of the animation; and even the fight-start hook was barely earlier than the swing. FaceEnemy is the
earliest clean “this unit is attacking” signal the presentation exposes — as close to “the moment you order it” as we get.)
A per-attacker min-gap stops a re-fire from doubling.PlayClipAtPoint (minDistance 1, log rolloff) attenuated
to silence at battle-camera distance — the log showed it firing but you couldn’t hear it. The attack cue instead plays at
spatialBlend 0.35 with a 60-unit minDistance, so it reads loud and clear at any zoom (it’s a dramatic focal moment, not
ambience). Verified in-game 2026-07-23.soundAttackOffset (the start offset (s) slider in the window, attack sound
only) starts playback that many seconds into the WAV — clamped inside the clip; the per-attacker min-gap keys off what
actually plays. The window’s ▶ preview honors the offset, so trim it by ear before ever launching the game.soundDeathFile)A one-shot rattle/scream when a pawn of the unit dies — closing the unit’s audio arc (alive → fighting → gone).
soundDeathFile / soundDeathVolume / soundDeathOffset. The trigger is PresentationPawn.TriggerDeath — the exact
presentation-side moment a pawn’s death animation starts, once per dying pawn. A wiped stack dies pawn-by-pawn in a
burst, so the runtime enforces a short per-entry gap (~0.6 s): one rattle per beat instead of five at once. Plays at the
dying pawn’s position through the same camera-audible one-shot path as the attack roar.
soundBattleFile)A one-shot war cry the moment a battle begins with this unit on either side. soundBattleFile / soundBattleVolume /
soundBattleOffset. The trigger is the sim event SimulationEvent_BattleStarted: the hook walks the battle’s
attacker/defender groups for our unit definitions on the sim thread (managed reads only) and queues one cry per
matching entry; the main thread plays it camera-anchored (like the attack cue) so it opens the battle audibly at any
zoom. A per-entry gap keeps back-to-back battles from stacking cries.
So the full “replace a creature’s voice” recipe is: silenceDonorAudio: true + a soundIdleFile growl (interval + group
radius to taste) + a soundAttackFile roar + a soundDeathFile rattle + a soundBattleFile war cry. All are set per-unit
in the Sound Studio window (§14), every one with volume + start offset + ▶ preview, and persist in the registry. The
Abomination ships the trio: bear silenced, an occasional bear/croc snarl at idle, a beam-roar on the strike.
Tools ▸ HAF ▸ Animation Lab (2026-07-18). Docks as a tab next to the Model Factory, so the pair
presents as one tabbed dialog. The design rule: the Factory owns the MODEL (identity, pawn, model file, transform,
size, geometry/shading, static runtime flags), the Lab owns the ANIMATION — every setting lives in exactly one of
the two windows, and jump buttons hand context across (“Edit in Animation Lab” in the Factory loads the entry here).
Both windows Bake through the identical pipeline (ConfigFor → UniversalBaker.BuildAnimated → ModelRegistry.Upsert),
so it does not matter where you press Bake.
Auto-detect settings (2026-08-01) — a button at the top of the Clip section reads the model’s clips + rig and
fills the whole animation config in one click, then explains its choice in the status bar (review-only, nothing bakes):
a Vehicle Lab Spin rig → State-driven with Idle/reference = Spin[0..0] (still) and Movement = Spin (rolls),
Convert-raw-rig + Keep-translations ON, Fix 100× OFF — the exact recipe the Vehicle Lab prints; a
Vehicle Lab FLAG/SAIL rig (a Furl clip is present) → the same, but Idle/reference = Furl[0..0] — the
DEPLOYED frame (Spin holds its strike/fold on every frame, so referencing it bakes the hidden pose into the rest
skeleton and the grounding sky-lifts the model — which since 2026-09-20 is every animated bake, not only an
auto-grounded one, and the bake logs the lift as a percentage of the model height when it looks like this;
2026-09-12, the TOW). Stance/Pre/After clips stay yours to fill; a
character (an idle + a run/walk/move clip) → State-driven with idle/movement guessed from the names; a
single clip → continuous loop; a deploy clip → a hint (deploy frame-ranges can’t be inferred from a baked clip).
A failed bake keeps your settings (2026-08-01) — both windows snapshot the form before baking and restore it if the bake errors, so a failure no longer reverts the config you just entered (RebaseOnRegistry + the field trims mutate the form in place otherwise). The status reads “Bake FAILED (settings kept): …”.
Edit existing lists the animated entries only. The model identity (Resource / Target pawn) shows
read-only — change those in the Factory; the Model file row has a Browse… button (2026-07-19) for
repointing an entry at a new source, and a ▶ Play clip button — THE recipe-authoring workflow: it plays the
RAW model file’s entire source animation (every take, full length, no conversion, no slicing) with
play/scrub, single-frame stepping (|◄ ►| buttons, ←/→ keys, Shift = ±10 frames), and set current frame
capture. This is where every frame number in a recipe comes from — deploy start/end, barrel ready-frame, recoil
range: step through the source, capture the frames with set current (each field also has a go ► jump back to
its frame). A Speed /N field (2026-07-20) sits after the End field: the frame-skip step baked into the slice
(/2 = every 2nd frame = 2× faster — this is where walk pace is authored, since pacing is bake-only), and ► Play
previews at that stepped pace, so a gait is judged in the dialog before any bake. Confirm writes the full spec —
clip[start..end/N] — straight into the clip field it was opened from; Cancel just closes. After every compile/domain reload the window RE-LOADS the selected entry
from the registry (2026-07-19) — the file is the single source of truth; unsaved form edits from before a compile
are deliberately dropped (Save (no bake) first if you want to keep them). The ↻ Reload button does the same
explicitly — the escape hatch from any stale window copy (re-selecting the same entry in the dropdown does
NOT reload it). This closed the recurring “stale Lab clobber” trap where a pre-reload form silently overwrote
registry edits at the next Save/Bake. ↻ Reload only reloads the DATA — it does NOT rebuild the model preview
(2026-08-01): the preview-rebuild path corrupts the texture on some models (a tiling-UV rig — UVs outside
0–1 that tile a seamless source texture — sampled against the packed atlas shows a scrambled checkerboard),
whereas the correct preview from the initial open/bake is fine to keep for the same entry. Selecting a different
entry, or a Bake, still rebuilds it. (The scramble is preview-only — the shipped unit uses atlas-remapped UVs
and is unaffected; the real fix, a preview mesh with atlas UVs, is a backlog item.)
For Level-2 sources (Sketchfab vehicles/artillery animated by moving parts, not a skeleton): tick Deploy
conversion, point the Model file at the raw original, and the bake runs Tools/deploy_convert.py
automatically first — into FactorySource/<res>/deploy_converted.glb, cached on an args+source+tool fingerprint
(a knob change reconverts and re-slims; nothing else does). Every knob is registry data — the full pipeline
reproduces from the entry alone:
| Field | Meaning |
|---|---|
| Deploy frames … End | the deploy motion’s source-frame range (scrub the raw file in ▶ to find it). Required. |
| Also remove (adds to defaults) | extra parts to delete on top of the default crew/prop kill-list — build it with the Pick parts… dialog (a ticklist of the source model’s parts; no typing, no re-carrying the defaults). A ticked name also removes any sub-part whose name contains it. (deployStripExtra; the M114’s contrl_wheel,ctrl_wheel2 control hand-wheels live here.) |
| Strip parts (replace defaults) | advanced — name substrings that REPLACE the default kill-list wholesale (usually leave empty; the Dugout Canoe’s camera is the rare case). To ADD parts, use Also remove above — it keeps the defaults |
| Barrel ready frame | source frame of the fully-elevated barrel; re-keys barrel/cannon parts to rise over the deploy’s back half (empty = leave as authored) |
| Leg spread scale | empty = source leg curves verbatim; a number re-keys *leg* parts as pure travel→spread rotation (1 = full width). Required for legs that slide — see the rotation-only law in Animated-Models.md |
| Barrel elevation scale | >1 exaggerates past the source’s max (empty = 1) |
| Recoil frames (a..b) | the fire cycle’s source range, remapped onto the deployed pose as the recoil clip. MULTI-SEGMENT (2026-07-26): comma-separate extra windows appended PRISTINE as epilogue, each with an optional /N speed step (every Nth frame = N× faster) — the M114’s shipped recipe is 442..530,305..441/2: the full fire+kick+reload window, then the source’s own aiming raise (which only exists before the fire) replayed at double speed to bring the barrel back up. Segment 1 alone drives the slide/slam analysis. Set the Attack clip to plain recoil. Empty = none |
| Return slow | the SYNTHESIZED palindrome return: the window replayed backward at this multiple (empty = 4; 0 = none). With Keep bone translations the source’s own run-out plays — set 0, or the kick fires twice (the rotation-only era needed the synthesis; the translation era doesn’t) |
| Slam (deg) | the ROTATION-FAKED kick pitch in degrees (the arc trick from the rotation-only era — see the revised Laws in Animation-Pitfalls.md). With Keep bone translations set 0: the real translation slide replaces the fake (they stack if you want both). POSITIVE = muzzle-down dip, NEGATIVE = muzzle-up |
| Recoil step | arc sampling fineness. (Slide scale and raw Arc R are hidden from the UI: the former self-cancels under Slam-degrees, the latter is superseded — both registry fields survive for legacy recipes) |
THE REAL KICKBACK (2026-07-26, verified on the M114): tick
Keep bone translations(Animation Lab) and the source’s authored recoil SLIDE bakes and plays as true translation — the tube slams back and glides home exactly as animated. Recipe: Recoil frames442..530,305..441/2, Return slow 0, Slam 0, Keep ✓. Translations are kept ONLY in the attack clip (deploy/stance stay rotation-only — keeping them there displaced the assembly) and are delta-rebased (pure motion, no pose offsets) + ×100 sandwich-compensated on the legacy path. The old Slam arc remains for models whose source has no real slide.
The conversion generates the state clips — deployed, folded, unfold, fold, recoil — from those frame
numbers; the Pick dropdowns and the ▶ picker inspect the converted file once it exists, so you just assign the
five names to the five roles. (The clip names live in the converted GLB; the bake resolves them by name, isolates
one clip per role folder, and the game loads the resulting ClipCollections by GUID.)
CombatIdle1 works — the bake auto-pads 1-frame clips so
Unity’s importer can’t drop them). Priority: attack > movement > after > combat-idle > idle. Each role gets the
same Pick-from-model dropdown; Movement is required, the rest optional. All roles bake in ONE Blender pass
against ONE shared skeleton (every clip is rebaked against the primary/Idle clip’s frame-0 rest — separate
per-role rests would rigidly displace the non-primary clips), into per-role ClipCollections (_Clips /
_ClipsMove / _ClipsAfter / _ClipsAttack / _ClipsCombat). Mutually exclusive with Fire-on-attack /
Deploy-when-stopped (those are ignored while State-driven is ON). The runtime polls each unit ~20×/s
(render-position delta — settle-immune), sampling map armies AND battle-deployed units, and switches the pawn’s
Pose0 clip per state.shootAR2s
= 0.17 s) reads as a blip at 1; 18 ≈ 3 s of sustained automatic fire (verified in-game). RUNTIME-ONLY:
Save (no bake) + game relaunch is enough — no re-bake or mod build.clipName[start..end]: the range is
cut from the source clip at bake time, start>end plays it REVERSED, a single frame becomes a held stance
(auto-padded). A speed step clipName[start..end/N] keeps every Nth frame — the slice plays N× faster
(deploy[179..0/12] folds in ~0.6 s; pacing is BAKED, the runtime has no speed knobs). Always lands exactly on
the end frame. See the artillery worked recipe in Animated-Models.md and the traps in
Animation-Pitfalls.md.animClipIdleAlt/animClipIdleAlt2 →
clipIdleAlt/clipIdleAlt2) played occasionally while plain-idle: every ~idleAltInterval seconds (jittered
0.6–1.4×, the idle-growl cadence) ONE pawn of the unit performs one clamped pass — the tiger stands, then howls,
then stands again; with both clips set each firing picks randomly (howl now, eat later), so the unit never
metronomes. Never fires during move/attack/after/combat. The interval is runtime-only (Save + rebuild retunes it;
0 disables without unbaking); the clips themselves bake as roles like any other. Pairs naturally with the idle-growl
sound for a full ambient presence.deploy[179..180]). Required for stance idles: the primary Clip defines the skeleton’s reference
pose, so a stance baked as the primary encodes ~identity and renders as the TRAVEL pose in-game (Pitfalls
Law 2). With State-driven ON the primary field is labelled Idle / reference clip — keep the FULL source
motion there, put the pose here. Empty = idle plays the primary (characters with a real idle loop).hover,
exploded_view, …). Pick lists the clips read from the model (glb/gltf). Empty = the model’s first/assigned clip.
With State-driven ON this field is the Idle clip.prop). Strips
everything else (camera pans, body bob) that would make the model wobble. Empty = keep the whole clip.convertRig). ON = the raw-rig
conversion of §16 (rest-normalize + visual rebake, root collapse, topological rename, clean-unit export — what made
the Combine soldier work; usually paired with Fix-100× OFF). OFF = the byte-identical legacy pipeline for
purpose-made rigs (drone, howitzer) — with it off, Rotation and every other setting bake exactly as they always
did, so re-baking a working legacy model is safe. Old registries that used the rotation-triggered conversion
(the 360,0,0 identity trick) migrate to the flag automatically on load.[Deployed pose time .. 1]; plays once per shot, only on the gun that fired. The recoil’s shape is baked into
the GLB by Tools/deploy_convert.py (run by hand before baking — see its full usage in §5’s source-prep notes);
this slider only scales playback speed._removed_<ts>/ — undo via the Undo remove button that appears, or from the Backup & Restore window.The animated flag is derived, not trusted: an entry carrying animation config (named clip, behaviors, bone
filter, or a baked clip GUID) is re-marked ANIMATED automatically on load and before every bake. This ended the
“howitzers on their side” failure mode — a stale unticked checkbox once re-baked the howitzer STATIC, which strips the
clip + behaviors and bakes the (animated-path-ignored) Rotation offset into the mesh, shipping tipped-over guns. Baking
a rigged model that truly has no animation config still asks for confirmation first.
Enforced field ownership. Bake/Save from EITHER window now rebases on the freshest registry entry and contributes only the fields that window owns (Factory: model/transform/size/…; Lab: clip/bones/Fix-100×/behaviors). Before this, whichever window held a stale copy silently clobbered the other’s values at bake time — it cost three bakes on the Combine soldier (a Factory bake dropped the Lab’s Fix-100× → 100× giant; the Lab’s stale copy then dropped rotation AND size). You can now edit in one window and bake from the other safely.
How the game turns pawns (found via the fixed-compass soldier). The engine orients a pawn’s visual through the
procedural BoneRotation layer (PawnEntry.BoneRotation0-3), not only ObjectSpace.Rotation. The plugin used to
zero that layer on every animated model (added so artillery aiming couldn’t twist the howitzer’s barrel) — which
pinned the soldier to one compass facing forever. It now clears the layer only for models with the artillery
behaviors (fire-on-attack / deploy-on-stop); everything else keeps the game’s layers and turns normally.
The raw-rig conversion (2026-07-18/19 — how the soldier’s “torn head” was actually solved). A Sketchfab/auto-rigged
model can ship a scrambled rest pose that the clip’s location keys ASSEMBLE into the body every frame (the Combine
soldier’s frame-0 posed bones sat up to 91 units from their rests on a 73-unit rig — 129 location curves were
structural, not decorative). Amplitude plays rotation-only clips, so such a rig can never work as-is. rig_anim.py
now performs, on the conversion path: (a) REST NORMALIZATION — snapshot every bone’s visual matrix on every
frame, apply the armature modifier at frame 0 (the assembled body becomes the bind mesh), Apply-Pose-As-Rest (the
assembled pose becomes the rest), re-bind, then re-derive the whole clip as pure rotations against the new rest
(verified in-bake: frame-0 residual = 0.000167); (b) strip residual location curves; (c) collapse no-op root
bones; (d) topological bone rename (Amplitude sorts alphabetically, parents must precede children); (e) fold
rotation+scale into the data and export unit-clean (global_scale=0.01, no ×100 root Lcl Scaling, skeleton bakes
all-Scale-1). Verified end-to-end with a litmus rig (Tools/make_litmus.py: a 12-deep bone chain of colored
cubes — renders straight in-game, exonerating the runtime for clean rigs). Raw-FBX inspectors used for the diagnosis:
Tools/fbx_binddump.py, Tools/fbx_lclscale.py. Gate refactor (2026-07-18): the conversion is now selected by
the explicit “Convert raw rig” checkbox in the Animation Lab (registry field convertRig) — it originally
triggered on a non-zero Rotation, which made the Rotation field a hidden pipeline switch (the soldier shipped with a
360,0,0 identity trick; a rotation edit on a legacy model silently rerouted its bake). Rotation is just a rotation
again (applied only on the conversion path); flag OFF remains the byte-identical legacy pipeline, and old registries
migrate automatically on load.
RESOLVED with the clean rig (2026-07-19): the “drone projectile invisible on attack” symptom disappeared once the
soldier’s rig was properly converted — the corrupted skeleton state was evidently disrupting the attack presentation
too (mechanism not separately traced; if it regresses, the designed fix was a Pose0 pass-through window during the
attack). The temporary [Uni][facing] periodic log can be removed on the next plugin change.
New here? Read Animated-Models.md first — the plain-language “what kinds of animated models can HAF import” page (three levels, what each needs, current limits). This section is the deep technical treatment behind it.
Everything learned getting the first humanoid (a raw Sketchfab ValveBiped) fully working in-game, distilled into the practical guide. The Factory does the conversion automatically — this section explains what it does, how to drive it, and how to read the safeguards. (For the other half — how the game consumes the baked result every frame — see Animated-Runtime.md.)
These are hard engine constraints (established by decompiling the bake — ClipEntry.Reimport,
Skeleton.Reimport — and the runtime — AnimationManager.GetBoneTRS/ApplyPose/GetPoseTRS):
| Constraint | Consequence for your model |
|---|---|
| Clips are rotation-only (translations are dropped or mis-scaled) | Any rig whose animation moves bones (location keys) must be converted |
| Uniform scale, and effectively scale 1 | The skeleton must bake with every BindPose/Local scale = 1 — no unit-conversion “sandwiches” on nodes |
| Bones are sorted ALPHABETICALLY, parents must sort before children | Rigs whose names don’t sort topologically (e.g. _014 < _02) get scrambled chains |
| Bounded chain depth (CPU cap 15) | Pass-through root bones waste depth budget |
| One clip per ClipCollection folder | The bake isolates your FBX in anim/ automatically |
Amplitude’s own art pipeline satisfies all of these silently, by convention. Raw downloaded rigs usually violate several at once — and the failures MASK each other, which is what makes hand-debugging brutal (it took five stacked defects to get one soldier standing).
deploy_convert.py) — rotation-driven animation, sane rest
pose. Bake with “Convert raw rig” OFF = the untouched legacy path. If it works, never touch it.pose.bones[...].location curves, and body parts that float/detach in-game while Unity’s own preview plays the
FBX perfectly (Unity supports location keys; Amplitude doesn’t). These NEED the conversion path — tick
“Convert raw rig” in the Animation Lab (add a Rotation only if the converted bake comes out mis-oriented).Everything below runs inside the Bake’s Blender step (Tools/rig_anim.py) whenever “Convert raw rig” is ticked
in the Animation Lab.
Three rig repairs run first (mech findings, 2026-07-20) — each a no-op for a rig that doesn’t need it:
N verts with no weight -> assigned to bone #0) and the
model collapses onto the root in-game. The script walks each part’s parent chain to its governing bone and converts
the parenting into a full-weight vertex group on that bone (bound at rest, so the pose isn’t double-applied).
Console proof: RIGANIM bone-parent->skin: bound N rigidly-hung mesh(es)….transform_apply only bakes an object’s OWN transform, so
the wrapper survived to export as a scaled root node — Unity folds it into the mesh but Amplitude reads bind poses
without it, leaving the skeleton ~100× off the mesh (rigid verts fling into a “wing”). The rig is un-parented with
the transform kept and the empties deleted, so export nodes are identity._end
markers, detail nodes) are removed iteratively, and the deploy path additionally pair-merges instanced link chains
(→ ≤126), until every vert’s bone index is under the wall — removing an unweighted bone never moves a vertex, and
weighted limb/gun bones are never leaves, so the animation is untouched. Proven rigs under the wall (soldier 62,
howitzer ~27) stay byte-identical.Then the conversion proper:
RIGANIM rest-normalized + rebaked N frames x M bones ...
frame-0 residual = 0.0001… — the residual must be ~0. (It used to trigger on location-key presence even with
the flag off, quietly reaching into legacy re-bakes of deploy_convert.py outputs; now the flag alone decides.)_rootJoint) are deleted for depth.b###_ in hierarchy order, so alphabetical = topological.global_scale=0.01 cancels the FBX exporter’s m→cm ×100 root scaling, so the file carries
no scale annotations (UnitScaleFactor 1.0, all bind clusters 1.0).The Unity side then imports with useFileScale off (non-Fix-100× path), so the baked skeleton reads Scale: 1 on
every bone — verify with a text editor on Assets/Resources/<name>_Skeleton.asset if in doubt.
90,0,0, one axis at a time) → Bake → check the console for the conversion lines and the
residual ≈ 0 → rebuild → judge in-game. Two or three probes typically suffice.RIGANIM lines tell you exactly which steps ran, and the bake
fails loudly rather than shipping a broken skeleton.| In-game symptom | Cause | Fix |
|---|---|---|
| Model lies down / faces wrong, plays fine otherwise | Orientation (type 2) | Tick “Convert raw rig” + Rotation probe, one axis at a time |
| A body part (head, hands) floats rigidly detached; Unity preview plays the FBX fine | Type-3 rig: structural location keys, scrambled rest | The conversion path (tick “Convert raw rig”) does it all |
| Parts smear/stretch, “movement looks exaggerated” | Translation keys playing unscaled | Same — conversion path |
| Whole model 100× too big | FBX unit scale | Fix-100× toggle (per-model; the conversion path usually makes it unnecessary) |
| Deep chains (fingers, head) scrambled, shallow parts fine | Bone-name sort order | Automatic (topological rename) on the conversion path |
| Model collapses flat onto one point, limbs flung; Unity console warns “verts with no weight → bone #0” | Rig has NO skin weights — parts rigidly bone-parented (common downloaded mech/vehicle rigs) | Automatic on the conversion path (bone-parenting → full-weight groups) |
| Skeleton ~100× off the mesh; rigid parts fling into a “wing” | Wrapper empty with non-identity scale survives to export | Automatic on the conversion path (wrapper-empty flatten) |
| Huge stretch spikes on a detailed rig (fine in Blender’s preview) | Over the 128-bone-INDEX GPU wall (bone index >127; NOT 256) | Automatic (zero-weight leaf bones removed; deploy path pair-merges link chains to ≤126) |
| Model won’t turn with movement | The plugin was clearing the game’s facing layer | Fixed globally — cleared only for artillery models now |
| “Is it my model or the pipeline?” | — | Bake Tools/make_litmus.py’s chain-of-cubes rig; if it renders straight in-game, the pipeline is fine — it’s the model |
Tools/fbx_binddump.py — raw-parses an FBX: unit scale factor + every bind cluster’s scale.Tools/fbx_lclscale.py — raw-parses an FBX: non-identity node scales (the ×100 root tell).Tools/make_litmus.py — generates the diagnostic chain rig.<name>_Skeleton.asset for Scale: values and parent ordering,
<name>_Clips.asset for per-bone EncodingFormat (1 = rotation-only, the target).Run any of them with blender -b --python <script> -- <args>.
A vehicle model injected onto a weapon-carrying donor inherits the donor’s FIRE PRESENTATION — and every piece of it anchors to the DONOR’s rig: the aim rotation streams at donor bone indices, and the muzzle flash / launch smoke / projectile origin all resolve donor SOCKET NAMES that don’t exist on your renamed skeleton (the lookups fall back to the pawn root + the donor’s socket-local offset — the classic “flash in a corner”). This section is the complete, in-game-verified system that puts all of it back on YOUR model. Reference implementation: the ENC ArmouredCar (Ehrhardt) on the AntiAirGuns donor — tracking turret, flash, smoke and tracers all on the gun.
| Knob | When | What it does |
|---|---|---|
turretBone + turretAxis |
runtime | TARGET TRACKING: the game’s streamed aim rotation is remapped onto YOUR bone (substring), around the given local axis (0/1/2 = X/Y/Z; a turret wants its YAW axis, an artillery barrel its PITCH; -1 = the stream’s own axis). Save + relaunch. |
socketBones |
bake | DONOR SOCKETS: "DonorName=OurBoneSubstr[@x,y,z];..." — bakes zero-weight bones with the EXACT names the donor’s fire events look up, parented to your bone, so flash/smoke/tracer origin resolve NATIVELY and follow it (a tracking turret). Socketed models rename bones A###_ instead of b###_ (donor names are capitalized; Amplitude sorts alphabetically and needs parents first). Re-BAKE to apply. |
muzzleOffset |
runtime | WORLD-SPACE DIAL "x,y,z" added to the pinned fire origin — the empirical fix when a rig’s gun-bone HEAD sits somewhere unhelpful (the Ehrhardt’s is at the model base). Iterate value → relaunch; no bake, no rebuild. |
muzzleBone |
runtime | LEGACY REDIRECT (pre-socket): reroute a MISSING socket lookup to your bone. Superseded by socketBones for re-baked models, but keep it set — it gates the donor-offset compensation below. |
silenceDonorVfx |
runtime | SUPPRESS instead: drop the donor’s animation-driven VFX entirely (Factory checkbox). Sounds untouched. The fallback when relocation isn’t wanted. |
Update 2026-07-25 — the shipped ArmouredCar now runs a Vehicle-Lab-GENERATED rig (bones
Root/Wheel_00…/Turret; the hand rig’sMW_*bones no longer exist in it). Its live calibration:socketBones: "Canon_Up_left=Turret;Move_bloc=Turret",muzzleBone: "Turret",muzzleOffset: "0.5,1,0"(dialed from the turret’s CENTER — the generated bone sits there, not at the gun), turret aim axis = 1 (Y) — generated rigs always yaw on Y because the bone is built tail-up. AsocketBonesnaming a missing bone now fails the bake LOUDLY with the rig’s bone list (it used to hide behind “produced no FBX”). TheMW_Trecipe below remains the reference for hand/ripped rigs that carry weapon bones.
Edit… button on the Donor-sockets row opens the guided
mapping dialog — it harvests the hardpoint names from the [Muzzle] log for this unit automatically, offers your
model’s full bone list in searchable pickers (per-bone precision), takes optional offsets, and has a one-click
“Map ALL hardpoints to one bone…” for the common everything-on-the-gun recipe. Steps 1–2 below describe what it
automates.[Muzzle] GetBoneTRS('...') log lines —
the AA-gun donor asks for Canon_Up_left and Move_bloc. The names LIE about their roles: decode them from
the per-shot pin log — Move_bloc turned out to be the fire POSITION anchor (its events carry offsets reaching
from a base block up to each gun) and Canon_Up_left the ROTATION/direction socket.socketBones: "Canon_Up_left=MW_T;Move_bloc=MW_T" — BOTH on the weapon bone,
so position and direction ride the tracking turret. Watch the bake console for the RIGANIM socket ... lines and
the A###_ prefix note; the skeleton grows by the socket count (14 → 16 bones here).muzzleOffset: "0,2.6,0". Why needed at all: the Ehrhardt’s MW_T bone head sits at the
model BASE (the gun mesh is skinned to it, but the bone origin is low) — and, an OPEN engine question, the
socket’s provably-correct bind height did not reach the runtime pose either. The dial closes the gap
empirically: value → relaunch → look; two iterations converged (1.3 = halfway, 2.6 = on the gun).Reading the offsets — a multi-barrel donor shows itself here. The pin log’s donorOff= values varied per shot
(0.80, 0.85, 1.20): that’s the AA gun’s MULTIPLE BARRELS, expressed not as separate sockets but as different
per-event displacements from the one Move_bloc anchor. The compensation flattens them all onto your single muzzle
point — correct and verified, but it means every flash shares one spot. (Also don’t confuse the two quads: the
Muzzle-bone Pick dropdown’s MW (4 parts) is your model’s four MG-mount bones grouped by name prefix —
MW_B/F/L/T on the Ehrhardt — not the donor’s barrels.) Two unbuilt refinements ride this observation — see the
Review-Backlog: barrel variation (subtract the MEAN donor offset instead of each event’s own, so flashes scatter
naturally around the muzzle) and multi-mount fire (rotate successive fire events across several of your gun bones).
Assets/FactorySource/<res>/anim*/*_anim.fbx, then Bake.Assets/Resources/...; the GAME loads the BUILT mod — no
rebuild = you test the old skeleton. Verify from the pin log’s T= height, not from vibes.T= vs pawnWorld in the [Muzzle] pin line tells you
where the engine actually put the origin (ground = 7.3 vs gun = +2.6 in the field case).GetBoneTRS — any prefix that invokes it with the SAME name
must guard reentrancy or the game stack-overflows to desktop. Guarded in MuzzleRedirect since 2026-07-24.)Textures have their own field guide now — the atlas pipeline, every texture knob (Atlas size, Keep black, Material mode, reuseExtracted…), the symptom-indexed failure catalog (pale wash, silver panels, white parts, vanished skins), and the runtime re-skin system: Textures.md.
Three workflow tools born from the T-62 marathon’s aftermath — the first two user-designed:
Clone (Factory, next to Refresh). Duplicates the loaded entry’s ENTIRE recipe into a NEW, unsaved form:
blank Resource name (an unnamed clone can’t Bake or Save, so the source can never be overwritten by accident),
blank Pawn description (Pick the new target), baked GUIDs cleared (a clone owns no assets until its own bake),
bake lock and disabled never travel. The fast path for re-pointing a proven recipe at another unit — the first
production use built the Universal Tank in minutes: load AntiTankHalftrack → Clone → name UniversalTanks
→ Pick the new pawn → Bake. The Factory→Lab handoff carries the full unsaved form, so “Edit in Animation Lab”
on a fresh clone arrives with the complete animation recipe intact (deploy conversion, clips, flags — the whole
point of cloning). The databases side (unit stats, tech unlock, UI card/portrait, presentation definitions) is
authored separately as usual — Clone covers the MODEL side only.
Bake lock (Lab checkbox above Bake; the Factory respects it read-only). While ticked, Bake is disabled in
BOTH windows (“Bake (locked)”). Use it on entries whose baked assets are in-game VERIFIED and whose recipe the
shared tooling has moved past — the m114 sits locked until its engine-contract migration, because a rebake
would silently reconvert under rules its choreography machinery predates (proven divergent by headless
comparison). Unlocking is a deliberate act: untick → bake → RE-VERIFY IN-GAME. Related defense: conversions
self-identify their contract via the armature name (DeployArmV2), so rig_anim gives pre-rework cached
conversions their exact legacy export even after an unlock.
Coherence fixes. The Lab preview now follows the entry selection (it used to keep showing the previous
model). And the long-hunted keepTranslations self-reverting trap is dead: the field was simply missing from
RebaseLabOwnedOnRegistry, so every Factory bake overwrote the Lab’s tick with its stale copy — if a checkbox
ever “unticks itself” again, check that rebase list first.