Skip to content

Factory Manual

github-actions[bot] edited this page Sep 20, 2026 · 31 revisions

Auto-generated from the repo docs by tools/sync_wiki.sh — edit the source Markdown in the repo, not this wiki page.

Model Factory — User Manual

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.

Find the section you need

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.


1. Prerequisites

  • A Humankind modding project open in Unity 2021.3.1f1, with the ModTools SDK and the HAF Authoring Tools package installed.
  • A model file: .glb, .gltf, .obj, .fbx, or .blend. It must be UV-mapped and textured if you want the original skin.
  • Blender for animated models, .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.
  • The game installed (Steam auto-detected). The registry the plugin reads is written into <Humankind>\BepInEx\config.

2. Quick start (static model)

  1. Tools ▸ HAF ▸ Model Factory.
  2. Pawn description → Pick — choose the unit your model replaces (e.g. Era6_Common_Hovercrafts_01). A Resource name is suggested; keep or edit it.
  3. Model file → Browse — pick your .glb/.obj/.fbx.
  4. Set Size (world length of the longest axis) and, if needed, Rotation / Position.
  5. Bake. Watch the Console for [Factory] <name> DONE. skeleton=… atlas=….
  6. Rebuild the mod (see §6) and relaunch.

That's the whole loop. Everything below is detail and the animated workflow.


3. The window, field by field

Settings — game & Blender path (foldout at the top)

  • Game path — auto-detected <Humankind>\BepInEx\config (where the registry is written). Override if detection misses your install. A on the header means it wasn't found.
  • Blender — the detected 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.

3D resource

  • Dropdown of existing baked models (or <New>). Picking one loads its settings so you can re-bake with tweaks.
  • Refresh re-reads the registry. Remove drops the selected entry from the registry — one dialog offers Remove + delete files / Cancel / Remove, keep files, so you either delete its baked assets in the same step (whitelisted outputs only — unit portraits and other unit-side files are never touched) or keep them on disk; the plugin stops injecting it either way.
  • Remove is a recycle bin (2026-08-17): before anything is touched, the entry's JSON + the exact baked-output whitelist are snapshotted to _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 registry is ONE file (collapsed 2026-08-19)

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.

Vessels and the waterline (calibrated 2026-08-18)

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:

  • Waves. The game's chop and long swell ride roughly ±0.1 around the mean. Tall hulls don't care; a low-riding hull (submarine, canoe) visibly loses that much freeboard at crests. Trick: temporarily set water @ ~0.1 higher to preview the crest state, then set it back.
  • Position-offset semantics differ by entry type. For animated entries the offset is runtime-applied (preview shows it live, no bake needed). For static entries it is baked into the mesh — editing Z does nothing anywhere until you Bake. (The runtime-fields help text says "applies on load"; for static entries' position that is wrong — a known label lie, on the audit list.)

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.

Baked ≠ built: Ship Status (full page)

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):

  • the Factory shows an inline "Baked, but NOT in the mod build yet" notice on the selected entry;
  • Tools ▸ HAF ▸ Ship Status lists every baked thing across all three registries — units, districts (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.

Resource name / Pawn description / Model file

  • Resource name — unique id; names all the baked assets (<name>_Skeleton, <name>_Atlas, …).
  • Pawn description — the target unit. Pick opens a searchable list of every PresentationPawnDefinition.
  • Model file.glb/.gltf/.obj/.fbx/.blend. Leave empty when re-baking an existing resource to reuse the already-imported model with new settings (fast iteration).

Animation (summary only — settings live in the Animation Lab, §15)

Since 2026-07-18 the Factory no longer edits animation settings itself. The two windows are mutually exclusive in their settings and work together:

  • For an animated entry this section shows a read-only summary — e.g. ANIMATED — clip 'deploy', fire-on-attack, deploy-on-stop (pose 0.72, speed 4), recoil (speed 3) — plus an Edit in Animation Lab button that opens the Lab (docked as a tab next to the Factory) with this entry loaded. Bake in either window — both go through the identical pipeline, and a Factory bake uses the animation settings as saved by the Lab.
  • For a fresh rigged model (animation detected in the file, nothing configured yet): a hint + Open Animation Lab button; configure the clip there and it bakes as ANIMATED from then on.
  • Safety nets (born from the "howitzers on their side" incident, where a lost 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.

Transform

  • Rotation offset (XYZ) — degrees. Static models bake this into the mesh. For animated models (2026-07-18) the contract is: 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.
  • Position offset (x, y, z = height) — Static models bake it in (z = waterline; negative sinks a ship). For animated models it's applied at runtime, in the pawn's own frame (2026-07-18): x = sideways, y = fore/aft, z = altitude (world-up). The planar part is rotated by the unit's facing each frame, so the nudge turns with the unit — same semantic as the static bake. (Before this it was added on fixed world axes, so the offset drifted around the model as the unit turned.) Use y to push the model clear of its crew pawns — the clearance now holds in every facing; the sign of "forward" depends on the rig, so if +y moves it the wrong way, flip it. Runtime = change it, Save (no bake) in the Animation Lab (or re-bake here), and just relaunch — no mod rebuild. This also decouples altitude from Size (keep a small drone but fly it high).
  • Size (units) — the world length of the model's longest axis. This is what you tune to make the model the right size next to other units. The Factory computes the scale for you; the Console logs it.

Mesh / shading (ignored in Animated mode)

  • NormalsKeepModel (artist's normals) / Recalculate (hard edges via the Smoothing angle slider) / Faceted (fully flat).
  • Height-gradient UVs — override UVs with V = normalized height, so a vertical-gradient albedo maps by height (black skirt low, grey hull high). For untextured CAD models that just need a simple gradient skin.
  • Winding fix (CAD/convex) — rewind faces outward so single-sided / CAD "sketch" meshes render instead of culling to invisible. Lightest fix; assumes a roughly convex hull (vehicles/ships).
  • Multi-fragment split (over-ceiling bake)opt-in; both bake paths (static since 0.5.7, animated since 2026-09-20). A bake over the engine's 16,320-quad per-fragment draw ceiling splits into spatial chunks (…_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.
  • Double-sided — for animated (rigged) vehicles this moved to the Vehicle Lab in 0.5.4 (its Double-sided (fix see-through parts) checkbox, applied to the source Spin GLB when you generate the rig). The Factory no longer has the checkbox for animated models. For static single-sided/CAD shells, use Winding fix above (the light repair); genuinely non-convex static shells that need a real back face are the one case the retired static double-sided path still covers via a hand-set doubleSided in the pack entry.
  • Albedo brightness / Albedo saturation — tone-correct the baked skin (both 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.
  • Keep black (glass/cockpit)multi-material models only. By default the bake repaints near-black atlas regions neutral grey, to hide UV dead-zones and packing gaps that would otherwise render as black patches. That also flattens an intentionally black material (a glossy canopy, a dark cockpit) to grey. Tick this to keep true black on such a model. Off by default (existing behaviour). Re-bake to apply.
  • Material mode (Auto / Single / Multi) — how a model with more than one material is textured. Auto packs a multi-material atlas when the model has >1 material, else one texture (right for most). Single forces one texture — correct for CLOSED models (tanks, planes) sharing a skin. Multi forces the multi-material atlas — needed for OPEN kit (a towed gun's wheels/legs/barrel each on their own material) where otherwise every part samples the wrong region (e.g. the wheel comes out scrambled). Costs atlas space. (Works for both static and ANIMATED models now — the animated path was single-material only before.) If a dark part (rubber tyre) shows light patches, also tick Keep black. Re-bake to apply.
  • Atlas size (256 / 512 / 1024 / 2048) — longest side of the baked atlas. Opaque atlases are DXT1-compressed; a static multi-material atlas with meaningful source alpha uses DXT5, so smaller = smaller mod bundle. A unit is ~80 px at map zoom and its info card uses your 2D portrait (not the model), so 512–1024 is ample (256 for very simple units); pick 2048 only for a unit you zoom in on closely. Default 512. (Before this existed, atlases baked uncompressed at up to 4096×8192 — a single _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.
  • Reduce to ~tris (0 = off) — quadric-decimate a heavy model to about this many triangles (via Blender) to fit the engine's shared mesh buffer. The value is a triangle ceiling, not a vertex count or quota: a model already under it passes through untouched, and the imported vertex count may be higher because UV/material seams split vertices. There is no fixed per-model engine ceiling; the current pawn layer is a shared ~1,000,000-vertex pool for all loaded model types. The 24,000 default is a roster-friendly starting point, not a correctness requirement. Use the F8 mesh-budget readout and the bake's verts=/tris= lines to make the final trade-off. No Blender? Use Weld & simplify instead (below).
  • Strip parts (names) (Pick) — comma-separated object-name substrings to DELETE from your model before baking (each match takes its children too). The mirror of Hide-donor, but on your source mesh: use it to drop a part you don't want baked in — most importantly a helicopter's own rotor(s), so the donor's animated rotor spins through in its place (also crew figures, weapon pods…). The donor helicopter animates two rotor bones (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).
  • Hide donor meshes (Pick) — comma-separated name substrings of the donor unit's extra parts to hide (e.g. a leftover rotor). Pick reads the donor fragment names the plugin logged to 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.
  • Respawn after load — fixes a spawn quirk: the engine draws the first borrowed-rotor pawn of a model, when it's created, with its rotor ~1 unit low (every other instance is fine). Tick this and the plugin watches for any such unit appearing — on a save-load, built in a city, or dev-spawned — and near-instantly re-runs the game's own pawn rebuild on it, so the rotor comes out right every time (a brief one-time flicker as it rebuilds, no unit affected). Tick ONLY for models that borrow a donor's animated sub-part (a spinning rotor, i.e. you used Strip parts to drop your own rotor); pointless flicker on any other model. Runtime-only (no re-bake needed — it's a registry flag). The delay before the re-spawn is tunable in the plugin cfg (Factory/RespawnDelayFrames, default 1 frame) if a slow machine shows the low rotor briefly before it corrects.
  • Freeze donor animation — stops the donor's idle/move animation from bobbing your static mesh. A borrowed mesh is skinned to a rig the donor animates, so if you ride a donor with a hover/idle wiggle (e.g. the Recon-Drone donor), a large rigid model like an airship visibly wobbles. Tick this and the plugin pins every pawn pose's time to frame 0 each frame, so the donor clip can't advance — the mesh holds rigid while the pawn still glides tile-to-tile (that motion is transform-driven, not animation). Static models only (an animated model plays its own baked clip — leave it off). Runtime-only (no re-bake — it's a registry flag; Bake just re-writes the registry). Confirm it took with the [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.
  • Weld & simplify — GLB/glTF only. 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).

Texture / import

  • Keep extracted texture (hand-edits) (formerly "Reuse extracted files") — its ONLY effect on the animated path: protect a hand-edited extracted albedo from being regenerated. Geometry re-processing is automatic there — the Blender step re-runs exactly when one of its inputs changed (Rotation, Reduce-to-tris, Clip, Animate-only-bones, Material mode, Model file) and is skipped otherwise, so your changes always take effect with no checkbox management (the old "ticked box silently ignores your Rotation change" trap is gone). For static models it additionally reuses the extracted OBJ (skip re-import, fast iteration), as before. Since 0.5.6 the protection also covers single-material sources — before that the extraction hygiene deleted the hand-edited _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).

Bake / Reset

  • Bake runs the pipeline and writes the registry. Reset clears the form.

Preview panel

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:

  1. Bake once with Material mode = Auto (or Multi), Reduce to ~tris = 0, and Keep black on when black glass, rubber, or cockpit materials are intentional.
  2. If the mapping is correct, lower the triangle ceiling gradually and re-bake after each change. A small threshold change can matter: a real rotorcraft mapped incorrectly at 20,000 and correctly at 24,000.
  3. If the whole vehicle takes one material's colour, check Material mode before editing textures: Single deliberately collapses every polygon to material slot 0. If only intentional black became grey, that is Keep black, not mapping.
  4. If a reduced bake scrambles or flattens materials, return to 0 to prove the source/material pipeline, then choose the lowest known-good ceiling. Do not compensate with brightness or saturation.

4. Static model workflow

  1. Pawn description (Pick) + Resource name.
  2. Model file (Browse).
  3. Textured model? leave Weld & simplify = 0 (preserves UV seams). Untextured CAD model? consider Height-based UVs or a Weld & simplify > 0.
  4. Renders invisible / see-through in-game? it's a single-sided/CAD mesh. For an animated vehicle, tick Double-sided in the Vehicle Lab and regenerate the rig (it's baked into the source GLB). For a static model, enable Winding fix.
  5. Heavy model? set Reduce to ~tris (default 24000), then check F8's shared pawn-layer headroom. Overflow rejects a mesh at registration; there is no universal per-model triangle limit.
  6. Set Size / Rotation / Position. Bake → rebuild mod → relaunch. Tweak and re-bake (Model file empty) as needed.

5. Animated model workflow

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 Lab builds 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 generated Spin clip 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.

  1. In the Factory: Pawn description — choose a donor with no animated sub-parts and a full idle/move set (a land vehicle is ideal; an attack-helicopter donor forces its rotor onto your model). Resource name.
  2. Model file — the rigged .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).
  3. In the Animation Lab: Clip name → Pick → choose the loop you want (e.g. hover). Not the exploded/assembly clips.
  4. Animate only bones → Pick → choose the spinning group (e.g. 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).
  5. Size (back in the Factory — it owns the model's transform/size) — the drone is small; try 4. The Console logs the computed Scale Factor.
  6. Make sure Blender is detected (Settings). Bake (either window). Watch for:
    [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.
  7. Rebuild the mod (§6) and relaunch. The model should render and play its clip at real speed.

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.


6. After baking: rebuild the mod (don't skip this)

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.


7. Presentation tips — make the unit read well

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 PresentationUnitDefinitionPresentation 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.


8. Troubleshooting

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.asset in the Project view and run Tools ▸ HAF ▸ Export selected atlas to PNG (writes to C:/tmp and logs the average RGB). For UV problems, awk the extracted OBJ's vt lines for the U/V range. Both beat staring at the preview.


9. Where things land

Resources layout (reorganized 2026-07-28, in-game verified): the Assets/Resources ROOT now contains EXCLUSIVELY bake outputs of registered assets (<name>_Skeleton/_Atlas/_Clips*/_Mat/_ModelMesh/_Model + district _DistrictMesh/_FxMesh, prop _Collection/EQ_*_Fragment, projectile Projectile_*/_TrailDrawer). ALL UI images — unit-card portraits (every size), technology/constructible icons, narrative event JPGs — live in Assets/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 to Assets/FactorySource/Attic/ (on disk, not shipped) — nothing is ever just deleted.

  • Baked assets (shipped): Assets/Resources/<name>_Skeleton.asset, _Atlas.asset, _Mat.mat, _ModelMesh.asset (static); animated adds _Clips.asset and a <name>/anim/<name>_anim.fbx.
  • Bake inputs (NOT shipped): the imported model + extracted OBJ/albedo sit in 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).
  • Registry source of truth: 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).
  • Deployed registry artifact: <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.
  • Runtime log: <Humankind>\BepInEx\LogOutput.log[Uni] … lines show what was injected (and the donor fragment names the Hide-donor Pick reads).

10. Registry safety net

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:

  • Atomic writes. Every Save fills a .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.
  • Corrupt-source guard. If the project 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.
  • One-click recovery. The corruption banner offers Restore last deploy (usually the freshest valid artifact) and Restore last commit. Each candidate is parsed and required to contain models before it can replace the source; the corrupt copy remains available for hand-merging.
  • Artifact recreation and drift warning. Opening the Factory recreates a missing deployed 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.

11. Regression guards (run before committing baker changes)

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.

Following a run — where the progress shows

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:

  1. Two bars at the top of the Bake Tests window — the run level (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.)
  2. The cancellable modal bar carries the same two levels in its title and text, plus elapsed time.
  3. Unity's own native dialogs (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.

Reading the results — PASS, FAIL, and SKIPPED

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):

  • In the home project (the tools' own development checkout), a missing prerequisite is a broken dev machine, so it FAILs loudly — a run that verified nothing must never report PASS.
  • In an installed package (added by git URL into your own mod project), a missing prerequisite is a fact about your setup, not a broken pipeline, so the row SKIPs and names the fact. A clean install cannot fail a bake test — this is deliberate, and it was earned: the first outside install FAILed five models the author had never heard of (the tools were reading another mod's deployed registry — fixed, packages now own their own pack) and reported prerequisite gaps as pipeline regressions.

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.

What needs Blender — and what happens without it

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:

  • Bake Smoke Test — rows Does the baker still work? (one model per path) / Does every model still bake? (whole catalog). Bakes one representative per bake-path (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.
  • Bake Feature Test — rows Do the bake options do what they claim? (synthetic cubes) (Tier 1) and Do the Blender + animation options work? (real rigs) (Tier 2). Complements the smoke test: where that proves models bake, this proves each baker feature knob does what it claims, by baking a fixture with one knob toggled at a time and asserting a feature-specific invariant on the baked mesh/atlas. Tier 1 (fast, self-contained synthetic cube): 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.
  • Conversion Gate Test — rows Is rig conversion still correct? (control rig) and Do the real rigs still convert correctly? (every converted model) (2026-07-19). Asserts the raw-rig CONVERSION invariants the animated runtime silently requires — each was once violated and each cost hours of blind in-game debugging (the Combine-soldier campaign): every baked bone's BindPose/Local scale == 1 (the file-scale sandwich), every bone's ParentIndex < its own index (alphabetical-sort/topology contract), every clip curve entry rotation-only, curve-entry count == bone count (the runtime addresses 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.
  • Conversion Gate Test (deploy golden diff) — row Did a deploy model change unexpectedly? (golden snapshot), or CLI 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.
  • Schema paritybash 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.

12. Texture-only reskins — the Unit Retexture window (no bake)

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:

  • Replacement PNG (optional)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).
  • Adjustments (applied on top of whichever skin above — the PNG or the own atlas):
    • Brightness (gamma) (2026-07-21, applied FIRST; 1 = unchanged, >1 lighter, <1 darker) — a gamma lift: multiplicative along a curve that raises dark/mid tones most while pinning black and white, so a dark skin actually lightens without washing out. This is the knob for "reads too dark in-game" — the RGB sliders are additive (every pixel shifted equally) and go flat-grey long before they meaningfully lighten a dark atlas (+30 lifts a near-black 18 to only 48; gamma 1.5 lifts it ~2.4×). Registry field brightness.
    • Desaturate (0–1) — pull each pixel toward its brightness; 1 = full grey. Also neutralises the civ-colour tint.
    • Red / Green / Blue (−255…+255 each) — additive per-channel colour offset. Equal negatives = darken, equal positives = brighten, one channel = tint (e.g. "Desaturate 1 + Blue +40" = a cool steel-grey). All 0 = no change.

Proven on the grey corvette. (These sliders replace the earlier single grey/darken toggle.)

  • Live preview (2026-07-20) — the window renders the skin it will inject: the browsed Replacement PNG (or, when editing a saved entry, its current skin) with your Desaturate/RGB applied by the plugin's exact pixel math (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:

  1. Pawn — pick the pawn descriptor (use the _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>.
  2. Download the skin to paint — dump the unit atlases in-game first (F8 window ▸ Dump Atlases; files land in 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.
  3. Replace — point the window at your painted PNG and Apply: it copies the PNG into 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.)

13. Unit sounds — engine audio & the sound catalog

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.

14. Custom sound files & per-clip volume — the Sound Studio window

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 —

  • Start — a one-shot spool-up played on move-begin.
  • Travellooped while the unit moves (held off until the Start one-shot finishes, so it isn't masked).
  • Stop — a one-shot spool-down played on move-end.

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.

14a. Silence an inherited donor sound (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).

14b. Occasional idle growl (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.

14c. Attack sound (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:

  • Timing — fired at attack commit, not mid-swing. The trigger is 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.
  • Audibility — plays mostly-2D, camera-anchored. A normal 3-D 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.
  • Start offset (2026-07-23) — skip the WAV's lead-in. Found sounds often open with silence or a slow windup, which delays the impact past the swing. 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.

14d. Death sound (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.

14e. Battle-start war cry (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.


15. The Animation Lab window — a model's animation, in one place

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.)

Deploy conversion (rigid-parts source → bone-per-part rig, 2026-07-19)

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 frames 442..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 clipsdeployed, 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.)

Clip (bake-time — changing these needs a re-Bake)

  • State-driven (idle / move / after / attack) (2026-07-19) — OFF = the single-clip modes below (continuous loop or the Behavior flags). ON = a state machine for characters: the Idle clip plays standing, the Movement clip loops while the unit travels (fixes the idle-slide), the optional After-movement clip plays once on stopping before settling into Idle, the optional Attack clip plays when the unit fires a ranged attack (the runtime hooks the game's per-pawn fire sequence — the exact shooting pawn animates), and the optional Combat-idle clip replaces Idle while the army is locked in a battle (deployment through resolution; a single-frame weapon-raised stance like the soldier's 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.
  • Attack repeats (shown when an Attack clip is set) — how many times the Attack clip replays per trigger (window = repeats × clip duration). The sim fires ONCE per attack, so a short recoil-pop source clip (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.
  • Clip slicing — every clip field (the primary Clip/Idle included) accepts 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.
  • Idle-alt (occasional flavor one-shots, 2026-07-23) — up to TWO extra clips (animClipIdleAlt/animClipIdleAlt2clipIdleAlt/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.
  • Idle stance (override) (2026-07-19) — a held STANCE played while standing instead of the reference clip (e.g. 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).
  • Clear aim layer (artillery) — runtime-only toggle: clears the game's procedural bone-rotation layer for this model. Artillery donors stream aim/wheel junk that twists the rig (the legacy Fire/Deploy behaviors cleared it implicitly); characters must leave it OFF — the layer carries their facing.
  • Clip name (Pick) — which clip to bake when the model has several (a Sketchfab model often ships 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.
  • Animate only bones (Pick) — comma-separated bone-name prefixes to keep animation on (e.g. prop). Strips everything else (camera pans, body bob) that would make the model wobble. Empty = keep the whole clip.
  • Fix 100× oversize (FBX unit scale) — some rigged exports embed a metre→centimetre unit scale that makes the model bake ~100× too big and float high. Per-model: huge/floating → tick; vanishes when ticked → untick. (Drone off, howitzer on.) Auto-prefilled on the Factory's Browse for GLB/glTF.
  • Convert raw rig (auto-rigged models) — the explicit PIPELINE switch (registry 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.

Behavior (runtime — Save (no bake) + game relaunch applies them, no re-bake, no mod rebuild)

  • Fire on attack (play once) — play the baked clip once when the unit attacks instead of looping; rests at frame 0 otherwise (a howitzer barrel that elevates only when it bombards). Author the clip to start and end at rest. Off = continuous loop (a drone's spinning prop). See Firing-On-Attack.md.
  • Deploy when stopped (+ Deployed pose time, Deploy speed) — play the clip forward when the unit stops (spread the trail legs) and snap folded the instant it moves. Frame 0 = travelling; the deployed pose sits at Deployed pose time (0..1, keep ≤ 0.99 — exactly 1.0 wraps to frame 0). Deploy speed multiplies the ramp; folding is always instant. Held per-unit, driven by the unit's render-position change (settle-immune).
  • Recoil speed (needs both of the above) — the deployed gun also kicks when it bombards, from the same clip's tail [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.

Buttons

  • Bake — re-run the animated pipeline (Blender slim → skeleton + clip + atlas) with the settings above, then write the registry entry.
  • Save (no bake) — write the registry entry only (assets untouched): the way to tweak Behavior flags/sliders. Relaunch the game to see it; the mod bundle is unchanged.
  • Remove — drop the entry from the registry; the dialog lets you also delete its baked assets (whitelisted outputs only) or keep them on disk. Every Remove first snapshots the entry + outputs to _removed_<ts>/ — undo via the Undo remove button that appears, or from the Backup & Restore window.

Safety nets

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.

Cross-window safety & the facing mechanism (2026-07-18)

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.


16. Converting an animated model — from raw rig to Amplitude-ready

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.)

16.1 What Amplitude's animation system can actually play

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).

16.2 The three kinds of source rig

  1. Clean, authored rig (our drone; anything from 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.
  2. Clean rig, wrong orientation — rotation-driven but bakes lying down/facing wrong. Tick "Convert raw rig" and set a Rotation (probe one axis at a time in 90° steps, judge IN-GAME) — rotation is only applied on the conversion path.
  3. Auto-rigged / "assembled-by-animation" rig (the Combine soldier; typical of Sketchfab auto-rigs) — the REST POSE IS NOT THE BODY: the clip's location keys assemble the model every frame. Diagnostic tell: lots of 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).

16.3 What the conversion path does (automatic, in this order)

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:

  • Bone-parenting → skin weights (conversion path). Many downloaded mech/vehicle rigs never skin their meshes: each part is rigidly bone-parented (often through intermediate empties/parent meshes). Blender animates that fine, but the pipeline joins everything into ONE skinned mesh and rebinds via vertex weights — bone-parenting carries none, so every vert falls to bone #0 (Unity warns 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)….
  • Wrapper-empty flatten (conversion path). glTF/FBX sources often wrap the rig in a parent empty carrying a non-identity scale (the Light Assault Mech's was 0.010). 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.
  • Bone-index wall = 128 (both paths; only fires over the limit). Amplitude's GPU crowd-skinning breaks past bone index 127 — NOT 256 (that figure is stale; see The 128-bone-index GPU wall in Animation-Pitfalls). Verts weighted to a higher index get garbage transforms (huge stretch spikes in-game, invisible in Blender — the mech's 222 bones put its wing verts past index 127 and flung them). Zero-weight leaf bones (IK targets, _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:

  1. Rest normalization + visual rebake (the key step for type-3 rigs; conversion-path ONLY since 2026-07-19) — snapshots every bone's visual matrix on every frame, applies the armature modifier at frame 0 (the assembled body becomes the bind mesh), applies the frame-0 pose as the new rest, re-binds, then re-derives the ENTIRE clip as pure rotations against the new rest. Console proof: 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.)
  2. Location-curve strip — whatever translations remain are removed (Amplitude can't play them anyway). Runs on BOTH paths, deliberately: every verified legacy bake (drone, howitzer) went through it, and un-stripping could re-introduce the drone's unscaled-translation wobble — so "legacy byte-identical" means no rig manipulation (no fold / rename / collapse / scale-fold), with this strip as the one shared step.
  3. No-op root collapse — unanimated, unweighted, single-child root bones (_rootJoint) are deleted for depth.
  4. Topological bone rename — every bone prefixed b###_ in hierarchy order, so alphabetical = topological.
  5. Rotation + scale folded into the data — your Rotation value and all node scales are baked into vertices + bone rests; nodes export as identity.
  6. Unit-clean exportglobal_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.

16.4 The workflow, start to finish

  1. Factory: pick the target pawn, Browse the model, set Size. Animation Lab (auto-opens via the jump button): pick the Clip. Bake once with "Convert raw rig" OFF — maybe you have a type-1 rig and you're done.
  2. Rebuild the mod, look in-game (the game is the only honest judge — the previews' orientation is meaningless for animated models).
  3. Wrong orientation or floating parts → tick "Convert raw rig" (+ a Rotation probe if it's an orientation problem: start 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.
  4. Watch the Console safeguards on every bake: the RIGANIM lines tell you exactly which steps ran, and the bake fails loudly rather than shipping a broken skeleton.

16.5 Troubleshooting map (symptoms → cause)

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

16.6 Inspection tools (when you need to see for yourself)

  • 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.
  • The baked assets are plain YAML: grep <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>.

17. Turrets & fire effects — target tracking, muzzle flash, smoke, tracers (verified 2026-07-24)

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.

17.1 The knobs (all per-model registry fields)

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.

17.2 The recipe (what actually shipped on the ArmouredCar)

Update 2026-07-25 — the shipped ArmouredCar now runs a Vehicle-Lab-GENERATED rig (bones Root/Wheel_00…/ Turret; the hand rig's MW_* 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. A socketBones naming a missing bone now fails the bake LOUDLY with the rig's bone list (it used to hide behind "produced no FBX"). The MW_T recipe below remains the reference for hand/ripped rigs that carry weapon bones.

  1. Or skip the string entirely (2026-07-25): the Lab's 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.
  2. Discover the donor's socket names. Fire the unit once and read the [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.
  3. Bake the sockets onto the gun: 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).
  4. Dial the height: 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).
  5. The plugin does the rest at runtime: on a fire event it detects the native socket hit and pre-compensates the donor's socket-local offset (the AA gun's barrel-length displacement would otherwise fling flash AND tracer start off the gun — they share one startPosition), then adds your dial.

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).

17.3 Traps (each cost a test cycle — don't repeat them)

  • A registry edit made OUTSIDE the editor does not re-slim. The Blender-step cache compares the form vs the saved file; edit the file externally (script/hand) + ↻ Reload = "no change" = your socket edit silently NOT baked. Force it: delete Assets/FactorySource/<res>/anim*/​*_anim.fbx, then Bake.
  • The project asset is not the game. Bake updates 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.
  • Judge socket placement by the log, not the name. 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).
  • (Internal, for hook authors: the fire path re-enters 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.

18. Clone, Bake lock & entry-state coherence (2026-07-27)

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.

Clone this wiki locally