How to put your own 3D model onto a Humankind unit, step by step. This is the practical guide.
The Factory is a Unity editor window (Tools ▸ Model Factory). You give it a model file and a target unit, set a few options, press Bake, then rebuild the mod. The in-game plugin reads what you baked and renders it.
Shipping a standalone pack? The runtime is a multi-mod host — you can distribute your models as their own pack (a config file + assets) that merges on top of ENC without editing it. See Multi-Mod.md for the pack format and the
haf_packs/drop folder.
Assets/Scripts/Editor/)..glb, .gltf, .obj, .fbx, or .blend. It must be UV-mapped + textured if you want a skin..blend import, and Reduce-to-tris decimation. Static
GLB/OBJ/FBX bakes need no Blender. It’s auto-detected under C:\Program Files\Blender Foundation; if it’s elsewhere,
set the path in the Factory’s Settings panel.<Humankind>\BepInEx\config.Era6_Common_Hovercrafts_01). A Resource
name is suggested; keep or edit it..glb/.obj/.fbx.[Factory] <name> DONE. skeleton=… atlas=….That’s the whole loop. Everything below is detail and the animated workflow.
<Humankind>\BepInEx\config (where the registry is written). Override if detection
misses your install. A ⚠ on the header means it wasn’t found.blender.exe, or ⚠ not detected. Set the Override (or EditorPrefs 'ENC.blenderPath')
if Blender is elsewhere or only on PATH. Only matters for animated / .blend / Reduce-to-tris.<New>). Picking one loads its settings so you can re-bake with tweaks.<name>_Skeleton, <name>_Atlas, …).PresentationPawnDefinition..glb/.gltf/.obj/.fbx/.blend. Leave empty when re-baking an existing resource to reuse the
already-imported model with new settings (fast iteration).Since 2026-07-18 the Factory no longer edits animation settings itself. The two windows are mutually exclusive in their settings and work together:
animated flag silently re-baked the
howitzer static — tipped over, no behaviors): the flag is self-healed from the entry’s own config (a named clip,
behaviors, bone filter, or a baked clip GUID ⇒ the entry is animated, whatever the stored bool says), and baking a
rigged model without any animation config asks for confirmation before producing a static bake.0,0,0 = the exact legacy pipeline (no rig manipulation whatsoever — a model that renders
correctly stays correct on every re-bake); any non-zero value = the rotation is baked into the rig in the
Blender step (rotate + transform_apply into vertices + bone rests, object-level anim fcurves stripped) — the fix
for raw glTF rigs that round-trip lying down (the Sketchfab Combine soldier ships a -90°X armature node and needs
90, 0, 0). Nominal semantics: x ≈ stand-up pitch, y ≈ heading, z ≈ roll — but the mapping crosses multiple axis
conversions, so probe one axis at a time in 90° steps and judge IN-GAME ONLY: ⚠ the embedded preview’s
orientation is meaningless for animated models (fixed display flips; it happened to match the soldier and
contradicted the howitzer — chasing it re-baked a working gun onto its side, twice). Rotation needs the Model
file set; the Blender step re-runs automatically when any of its settings changed (see the Reuse note in §5).
deploy_convert.py-prepared rigs (the howitzer) are correct at 0,0,0 — never give them a rotation.KeepModel (artist’s normals) / Recalculate (hard edges via the Smoothing angle slider) /
Faceted (fully flat).1.0 = unchanged). The injection
path ships a flat albedo — the donor’s PBR normal/metallic/roughness maps are neutralized so its camo can’t bleed
onto your model — so a skin that relied on shiny metal, or a dark/washed-out texture, reads muddy in-game.
Brightness multiplies RGB (>1 lifts a dark skin); saturation scales colour around per-pixel luminance
(0 = greyscale, >1 = punchier). Baked into the atlas, so re-bake to apply (no re-import needed — quick to iterate).
Cheaper and repeatable vs hand-editing the extracted albedo. Note: the Factory preview is dim, so judge the final
amount in-game (brighter lighting) and dial back if over-warm._Atlas.asset, 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.Helix main + Helix_back
tail), so stripping both your main and tail rotor gets you a spinning main rotor and a spinning shrouded fantail.
Pick reads the object names straight from the model file (GLB/glTF; for FBX/OBJ/.blend, type them by hand — open in
Blender to see names). Case-insensitive substring match; needs Blender (it runs a delete-and-export step). Proven on the
RAH-66 Comanche (Cylinder06,Cylinder07 removed its main-rotor blades).BepInEx\LogOutput.log — so launch the
game once with the model injected, then Pick. Runtime-only (takes effect on reload, no re-bake). Can’t hide a
donor’s animated sub-parts (a rotor, spinning wheels) — those are baked at pawn spawn; pick a cleaner donor instead.Factory/RespawnDelayFrames, default 1 frame) if a slow machine shows
the low rotor briefly before it corrects.[Uni] freeze: '<name>' donor pose time pinned line in BepInEx\LogOutput.log. Note: it holds frame 0 of the donor
clip; if that frame isn’t a neutral pose the model may rest at a slight static offset (no wobble, just held) — report it if
so. It lives in Runtime (not the Animation section) because it’s a runtime flag that acts on the donor, and the
Animation section is disabled for the static models that use it.0 = faithful (keeps UV seams — use for textured models). >0 = vertex-cluster
decimation without Blender (coarser; averages UVs across seams, so only for heavy untextured meshes).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, skin, 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.
Your model must be rigged with a skeletal animation (an armature + at least one clip). glTF/GLB is easiest (the clip and bone pickers read it directly).
Raw / auto-rigged download (Sketchfab etc.)? Read §16 — Converting an animated model first: it explains the engine’s hard constraints (rotation-only clips, scale-1 skeletons, name-ordered bones), the automatic conversion the Factory performs, and the troubleshooting map for floating heads, smearing parts, and wrong orientations.
Only a STATIC vehicle model (no rig at all)?
Tools ▸ HAF ▸ Vehicle Labbuilds the rigged Spin GLB for you — probe the parts, mark wheels & turret (keyboard review, recipes, a Verify report), press Vehicleize, 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 Vehicleize, the Animation Lab’s Auto-detect button fills the bake config from the generatedSpinclip in one click. Part isolation: the height bracket (Hide below/above) is joined by a left/right slice (Hide left/right of (side) on the width axis) — bracket both to isolate one wheel, then mark it. All slider defaults are padded so nothing hides until you drag.
.glb/.fbx. When detection finds animation you’ll see “Animation detected” with an
Open Animation Lab button — press it (the Lab opens docked next to the Factory, pre-filled with this model).hover). Not the exploded/assembly clips.prop). This keeps only those bones animated and
freezes the rest, killing camera/body wobble. Leave empty for a fully-animated model (a walker, a turret).4. The Console logs
the computed Scale Factor.[Factory] <name> FBX scale factor … (native longest … -> <Size> units)
[Factory] <name> ANIMATED DONE. skeleton=… clip=… atlas=…
A non-zero clip= means the animation baked correctly.
Iterating on an animated model (2026-07-18 — geometry caching is AUTOMATIC): the Blender re-slim runs exactly when one of its inputs changed — Rotation, Reduce-to-tris, Clip, Animate-only-bones, Material mode, or the Model file — and is skipped otherwise (fast). You never manage this. The checkbox (now labelled “Keep extracted texture (hand-edits)”) has ONE job on the animated path: protect a hand-edited extracted albedo from being regenerated when the re-slim runs. (Earlier today it also gated the geometry, which made Rotation silently unresponsive while ticked — that trap is gone.) For static models it additionally reuses the extracted OBJ, as before.
Baking writes assets into the Unity project + the registry, but the game loads your mod’s built AssetBundle. Your baked skeleton / clip / atlas won’t reach the game until you rebuild/export the mod (your normal Humankind mod build step). Then relaunch. This is the #1 “why didn’t my change show up” cause.
A few game-data touches (in Databases, not the Factory) that make an injected model land better on the battlefield.
These are PresentationUnitDefinition / UnitDefinition edits — independent of the model bake, and they need a mod
rebuild.
Formation = a swarm (visibility + presence). The unit’s PresentationUnitDefinition → Presentation Formation
Definition controls how many dummy figures are drawn for the one unit. Switch a recon drone to Formation_Wedge_3 and
it renders as three drones in a wedge — far more visible on the map, and it reads as less fragile (a coordinated
swarm vs a lone scout). It composes for free with an injected animated model: every formation dummy is the same pawn
descriptor, so the plugin’s multi-instance handling gives each one our skeleton + clip — all of them render and spin.
Tune spacing with Dummy Offset Position / Angle.
Purely visual — not stronger. Formation dummies are cosmetic; they add zero HP or damage. It’s still one unit (one
health bar, one combat roll; the figures move / fight / die together). If you want it to be as sturdy as it looks, edit
the gameplay stats in its LandUnitDefinition (combat strength / health / defense) — a separate Databases edit.
Altitude (animated only). The registry position.z raises an animated model at runtime (§5) — set it high enough
that the swarm clears tall city buildings and stays above the terrain. Combined, formation + altitude make the unit read
clearly at every zoom: close-up spinning props, mid-range city-skyline flyover, and an identifiable banner on the
strategic map.
| Symptom | Cause → fix |
|---|---|
| Model invisible / see-through | Single-sided/CAD mesh (backface-culled) → Winding fix or Double-sided. 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. |
| 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.) |
| 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 Re-spawn 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 Convert grid 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. |
| 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 Convert grid = 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 is missing in-game but present in the Factory preview (a mast, antenna — typically small parts, no error anywhere) | Shared vertex-buffer overflow. All injected models + the game’s own fx meshes share one ~100k-vertex GPU buffer; overflow is silently truncated from the tail of the last-registered model’s mesh — so late-order parts of your newest model vanish while its body renders fine. The preview is immune (it renders the mesh asset directly). Diagnosed live: a 48k-vert bake lost its rotor mast; visible again after Reduce to ~tris brought it down. Watch the bake log’s verts= line — stay well under ~25k verts per model (UV-seam splitting means verts ≈ 2× tris on textured models, so a 12000-tri target ≈ 24k verts). |
| 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.) |
| Registry gone after a game reinstall / “verify files” | Open the Factory window — it auto-restores from the git-tracked backup and writes it back to BepInEx\config (console: restored N model(s) from the project backup). See §10 “Registry safety net”. |
Source folder created with the wrong case (e.g. attackHelicopter/ not AttackHelicopter/) |
Cosmetic, bake-time only. Windows/Unity is case-insensitive + case-preserving, so the folder inherits the spelling of any pre-existing differently-cased asset of that name (e.g. a vanilla attackHelicopter512.png). Baked assets, registry, and in-game loading are all correctly cased. Use a non-colliding resourceName (e.g. AH1Cobra) if you want the folder capitalised. |
Diagnostic tip — trust the atlas, not the preview. The Factory preview lights the mesh with a Standard (PBR) shader, so a smooth hull reads dark/glossy even when the baked skin is light and correct — don’t judge textures from it. To see the actual baked skin, select the
<name>_Atlas.assetin the Project view and run Tools ▸ HAF ▸ Export selected atlas to PNG (writes toC:/tmpand logs the average RGB). For UV problems,awkthe extracted OBJ’svtlines for the U/V range. Both beat staring at the preview.
Resources layout (reorganized 2026-07-28, in-game verified): the
Assets/ResourcesROOT now contains EXCLUSIVELY bake outputs of registered assets (<name>_Skeleton/_Atlas/_Clips*/_Mat/_ModelMesh/_Model+ district_DistrictMesh/_FxMesh, prop_Collection/EQ_*_Fragment, projectileProjectile_*/_TrailDrawer). ALL UI images — unit-card portraits (every size), technology/constructible icons, narrative event JPGs — live inAssets/Resources/Images/. They are referenced from the databases by (nibble-swapped) GUID, so the path is free; keeping them out of the bake-output namespace exists because a prefix-wildcard cleanup once deleted a unit’s portrait (see the Remove flow’s curated cleanup + Textures.md). Retired experiment files go toAssets/FactorySource/Attic/(on disk, not shipped) — nothing is ever just deleted.
Assets/Resources/<name>_Skeleton.asset, _Atlas.asset, _Mat.mat, _ModelMesh.asset
(static); animated adds _Clips.asset and a <name>/anim/<name>_anim.fbx.Assets/FactorySource/<name>/, kept out
of the built mod so licensed source models aren’t redistributed. Safe to delete to reclaim space (a re-bake re-extracts).<Humankind>\BepInEx\config\haf_models.json — one entry per model (pawn description, skel/atlas GUIDs,
transform, flags; animated adds clip + animated/animClip/animateBones).Assets/Databases/haf_models.backup.json — a git-tracked shadow copy, rewritten on
every Save/bake. See “Registry safety net” below.<Humankind>\BepInEx\LogOutput.log — [Uni] … lines show what was injected (and the donor fragment
names the Hide-donor Pick reads).The registry is the one Factory artifact that lives in the game folder, so it’s the one a game reinstall or a Steam “verify files” can wipe. Three layers protect it — all automatic, nothing to configure:
.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.haf_models.json.corrupt.json and Save refuses to run until you fix or delete the original — so a bake
can never overwrite your unreadable-but-recoverable registry with a fresh empty list.Assets/Databases/haf_models.backup.json inside the mod
repo (git-tracked → full history, survives anything that happens to the game folder). If the game registry is
missing — fresh install, verified files — the next Load() restores it from this backup and writes it back to
BepInEx\config automatically. Just opening the Factory window triggers this (the console logs
restored N model(s) from the project backup); no re-bake needed. The restore covers the registry only — BepInEx,
the plugin DLL, and the built mod (which carries the baked assets) come from your normal install/build steps.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.
Tools ▸ HAF ▸ Tests ▸ Bake Smoke Test (one per path) (or (ALL models)). 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.Tools ▸ HAF ▸ Tests ▸ Bake Feature Test (Tier 1) and … (Tier 2 — Blender + animated). 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.Tools ▸ HAF ▸ Tests ▸ Bake Conversion Gate Test (litmus) and … (registry converted models)
(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.Tools ▸ HAF ▸ Tests ▸ Bake Conversion Gate Test (deploy golden diff),
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.bash Tools/check_schema_parity.sh. The registry is written by the baker (ModelDef, JsonUtility)
and read by the plugin two ways (ModelEntry via Newtonsoft, plus a regex fallback) across two separate repos, kept in
sync by hand. The guard makes drift loud: it asserts (1) the Newtonsoft and regex read paths read the same key set,
(2) every read key is a field the baker writes (plugin ⊆ ModelDef, with an allowlist for deliberate plugin-only overrides
like scale), and (3) each read cast’s type matches ModelDef’s declared type; bake-time-only fields are listed as INFO.
Catches a silent rename/drop/type-change that would otherwise make a feature quietly default-off.Sometimes the vanilla model is fine and only the skin is wrong — a Common copy that should look distinct from its emblematic original, a colour test, a themed variant. For that there’s a separate window — Tools ▸ HAF ▸ Unit Retexture — that reskins an existing unit without baking a model: the vanilla mesh is kept, and the runtime plugin paints your texture onto an isolated clone of the unit’s output layer, so the original unit (and every other unit sharing that layer) is untouched.
Everything is a plain registry entry (no assets, no mod rebuild). Section 3 of the window is Replace / adjust skin:
textureFile: a PNG filename in BepInEx\config\haf_skins\. The plugin hot-loads
it at runtime. Leave it empty to adjust the unit’s OWN atlas (or, when editing an entry, to keep its current skin).brightness.Proven on the grey corvette. (These sliders replace the earlier single grey/darken toggle.)
AdjustSkin — luminance pull + per-channel offset), rebuilt live as you drag the sliders. It previews the atlas
image, not a posed 3D unit — units are GPU crowd-rendered with no editor-side GameObject, so there is nothing to
pose (see §10 / the native “Open in Editor” preview for the mesh itself).Custom models too (2026-07-20). A textureFile + adjustments on a baked model entry now recolour that model:
the plugin hot-loads the PNG in place of the baked atlas (falling back to the atlas if the PNG is missing) and
applies the Desaturate/RGB to it — so a custom model gets a new skin without a re-bake. Paint over the model’s own
atlas dump exactly as with a vanilla unit. One caveat: the baked atlas itself isn’t CPU-readable at runtime, so an
adjust-only recolour (sliders with no PNG) does nothing on a model entry — always route a custom-model recolour
through a PNG (dump → repaint → assign). Previously a textureFile on a model entry silently diverted the entry down
the vanilla texture-only path — painting the donor’s layer and never even swapping the mesh in.
Sound moved out of this window. Engine events and custom WAVs are configured exclusively in the dedicated Unit Sound window (§13–§14, with per-clip volume and ▶ preview); this window is skins only. Apply here never touches an entry’s sound fields — whatever audio the entry already carries is preserved.
Workflow:
_Common_..._01 copy, not the emblematic original — the isolation
clone is what leaves the original untouched; they share an output layer). The entry name defaults to Retex_<pawn>.BepInEx\config\haf_atlas_dump\), then paint over the unit’s PNG in any editor. It’s the unit’s real UV layout, so
what you paint is what wraps.config\haf_skins\ and writes
the registry entry (or tick Grey for the desaturate mode). Relaunch/reload the game to see it.Because the mesh is untouched this can’t fix silhouettes — it’s for palettes, markings, camo. And since the plugin hot-loads from the game’s config folder, iterating is repaint → overwrite the PNG → reload; no editor round-trip at all.
Cost — essentially free on the vertex budget. A reskin keeps the unit’s existing mesh, so it adds no vertices,
indices, or meshes to the GPU pawn-layer buffer — that buffer is budgeted per distinct mesh type (instances are free;
see Vertex-Budget.md), and a reskin introduces no new type. It costs only an output-layer clone (a render slot) and one
texture. So unlike a baked custom model — which does add its own distinct mesh’s verts — you can stack many reskinned
variants without approaching the mesh ceiling. (Recolouring a single custom model without a re-bake is built — see
above. The remaining extension is one baked mesh shared across different base units as textured variants: the plugin
already dedups a shared skeleton’s mesh on upload, but that still needs work on the per-donor rename; not built yet.)
Injected/retextured units are silent on move by default: the per-ship engine sound (Play_UNIT_Vehicles_<Type>_Start
/_Stop) rides an audio-service path tied to the vanilla unit’s move state, which our re-loaded units never trigger. (Full
diagnosis in the unit-movement-audio-investigation memory — the emitter, its Wwise registration and its 3D position are
all fine; only the trigger is missing.) The plugin restores the sound by firing it itself.
Enable it — tick the Wwise engine event option in the Unit Sound window (§14; or set engineSound: true in
the registry — sound config used to live in the Unit Retexture window, but is owned by the Sound window now).
The plugin then watches each of that unit’s instances and, on a movement start/stop transition (render-position delta,
like deploy-on-stop), posts the engine event onto the pawn’s AudioEmitter: a rev on departure, a settle on stop.
The one-click path (2026-07-25): “Use donor engine sound”. The Sound Studio’s Wwise section has a button that does
the whole job: it reads the unit’s DONOR from the plugin’s load log (the donor Skeleton='Unit_…' line — so launch the
game once with the unit loaded), extracts the family word (AntiAirGuns), fuzzy-matches the catalog for its Movement
pair (plural-trimmed contains, prefers Move/Movement, Stop = the _Start→_Stop sibling) and fills both fields — e.g.
the ArmouredCar → Play_UNIT_Vehicle_AntiAirGun_Movement_Start/Stop. Vanilla units (no donor line) fall back to their
own family name. Every miss reports in the status bar; the fields below remain the manual path.
Manual path — searchable Pick (2026-07-25): both event fields have a Pick button opening a searchable dropdown
over the full dumped catalog (no more hand-copying from the text file); picking a *_Start auto-fills the matching
*_Stop when it exists. The button is disabled with a how-to tooltip until the catalog has been dumped once.
Name the sound (works for the FIRST unit, no capture): fill Start event / Stop event with Wwise event names.
The plugin posts them by name (AkSoundEngine.PostEvent(name, emitterGuid)), so a named sound plays for the very first
unit at load — no dependency on anything else having moved. Leave them blank and the plugin falls back to a handle
auto-captured from any same-family vehicle that moved this session (fine mid-game, but the first unit stays quiet until
then) — so name the events for a shipping mod.
Extract every sound — the catalog: F8 window ▸ Dump Sound Catalog writes every Wwise event name in the game (~800+)
to BepInEx\config\haf_sound_catalog.txt — this file also FEEDS the Pick dropdowns and the donor auto-button above,
so dump it once per machine and the UI takes over. Examples:
| Unit family | Start event | Stop event |
|---|---|---|
| Modern warship (corvette, destroyer, stealth) | Play_UNIT_Vehicles_StealthCorvette_Start |
Play_UNIT_Vehicles_StealthCorvette_Stop |
| Aircraft carrier | Play_UNIT_Vehicles_AircraftCarrier_Start |
Play_UNIT_Vehicles_AircraftCarrier_Stop |
| Steam-era frigate | Play_UNIT_Vehicles_SteamFrigate_Start |
Play_UNIT_Vehicles_SteamFrigate_Stop |
| Submarine | Play_UNIT_Vehicles_Submarine_Start_Modern (or _Old) |
Play_UNIT_Vehicles_Submarine_Stop_Modern (or _Old) |
| Landing craft / hovercraft | Play_UNIT_LandingCraft_Start |
Play_UNIT_LandingCraft_Stop |
| Helicopter | Play_UNIT_Helicopter_Move |
Play_UNIT_Helicopter_Stop |
| Towed howitzer | Play_UNIT_CanonObusier_Move_Start |
Play_UNIT_CanonObusier_Move_Stop |
| Wheeled gun / mortar | Play_UNIT_Vehicles_Mortar_Move_Start |
Play_UNIT_Vehicles_Mortar_Move_Stop |
| AT gun | Play_UNIT_AntiTankGun_Move_Start |
Play_UNIT_AntiTankGun_Move_Stop |
Air units (planes, zeppelins, drones) have no engine-loop event in the game — only takeoff/shoot — so they can’t be given a continuous move sound this way; leave engineSound off for them.
It posts any named event, so this isn’t limited to engines — attach any sound in the catalog to a unit’s movement. (In the ENC mod, all the naval/ground/heli units are pre-configured with the events above; the three air units are left silent.)
Audio diagnostics (F8 window), kept for future sound work: Dump Audio logs each unit’s emitter (registration,
position, idle/free-event state); Audio Trace toggles a live log of every sound the game posts (how the event names
were discovered — it patches the service sink AudioManager.PostEvent); Play Audio (test) posts the captured engine
handle onto the filtered units to confirm audibility.
Limits (honest): the Wwise-event path fires Start/Stop accents only (no sustained loop between) — for a continuous engine/hover loop, use the custom WAV path (§14). The auto-capture fallback uses the last-seen vehicle Start, so a named event is preferred; a shipped registry with names Just Works from the first unit, every launch.
Tools ▸ HAF ▸ Sound Studio collects a unit’s whole audio profile into one dialog with collapsible sections — Silence inherited donor sound, Idle growl (file, volume, interval, one-voice radius), Attack sound, Death sound, Battle start war cry (each: file, volume, start offset), Movement (start/travel/stop), and Wwise engine event (with the searchable event Pick dropdowns and the one-click Use donor engine sound button — §13). The Pawn-description field has its own Pick (2026-07-25): a searchable dropdown over EVERY pawn descriptor in the loaded databases — ENC and mounted vanilla alike (first open scans the assets once per session) — and picking loads that pawn’s existing audio config. Pick a pawn (or Edit one from the “Units with audio” list, which fills the window’s remaining height) and every knob round-trips; each WAV row has a ▶ preview. Folded headers summarize their configuration (2026-07-25): while a section is collapsed, its configured WAV/event shows right-aligned in green on the header row — growl file + interval, one-shots with their start offsets, the movement trio, the shortened Wwise event, “silenced” — so a fully-collapsed window reads as the unit’s complete audio dossier and configured-vs-empty is visible at a glance. Everything below is one of those sections.
When the game has no suitable sound (drones, zeppelins) or you want a bespoke engine, drop in your own audio. Tools ▸ HAF ▸ Sound Studio — the Movement section — assigns up to three WAVs, each with its own volume —
These play through Unity’s own AudioSource (not Wwise), so any WAV works — no soundbank needed. Requirements: 16-bit
PCM WAV (convert mp3/ogg first); mono for true 3-D positioning. Files are copied into BepInEx\config\haf_sounds\ and
referenced by the registry (soundStartFile/soundFile/soundStopFile + soundStartVolume/soundVolume/soundStopVolume).
It writes onto the unit’s existing registry entry, so a unit still has one entry. (A Wwise engine event option in the
same window covers the §13 game-sound path.)
Volume is perceptual in the window — the slider tracks perceived loudness (√ curve) and the ×N label shows the real
linear amplitude stored (hearing is logarithmic, so e.g. slider 0.4 ≈ amplitude 0.16). Seamless loops: if a raw Travel
clip clicks at its wrap, crossfade a copy first (blend ~0.1–0.3 s of the tail into the head).
Audition in the editor: each clip row has a ▶ preview button that plays it at its set volume (with ■ Stop), and Edit on a unit in the “Units with audio” list loads its current Start/Travel/Stop files + volumes — so you can pick and tune a sound without launching the game.
Performance note: the runtime audio driver polls only our units’ sub-pawns and refreshes its scene lookup on a ~2 s
cache — never a per-frame FindObjectsOfType (an early version did, and it visibly cut FPS). If you extend it, keep any
full scene scan off the hot path.
silenceDonorAudio)A custom creature that reuses a donor inherits the donor’s sounds as well as its animation. The Abomination borrows a
bear, so it played the bear’s idle growl and combat maul/scratch — and neither is a data field you can blank:
the idle loop is PresentationPawnDescription.IdleAudioEvent posted at spawn, and the attack SFX is baked into the borrowed
animator’s MecanimEventData (resolved by animator GUID). Both, however, funnel through the same chokepoint —
AudioEmitter.PostEvent on the pawn’s Wwise emitter.
Tick “Silence the borrowed donor’s inherited sounds” in the Sound Studio window (or set silenceDonorAudio: true in the
registry). At runtime the plugin registers each of the unit’s pawn emitters and drops every Wwise post on them (a
Harmony prefix on AudioEmitter.PostEvent), plus a one-time StopAll to cut an idle loop already running since spawn. It
silences only Wwise — your own custom WAVs (below) go through Unity’s AudioSource, so they still play. Confirm it
engaged in LogOutput.log: [Audio] '<resource>' donor audio silenced (emitter <id>) (one line per pawn). Reusable on
any unit stuck with an unwanted inherited sound (e.g. a borrowed-rotor helicopter that drones forever).
soundIdleFile)Pair the silence above with your own idle vocalisation: the Idle growl row in the Sound Studio window assigns a WAV
played as a one-shot occasionally while the unit stands still (not moving). The plugin fires it on a per-pawn timer at
soundIdleInterval seconds (default 11), jittered 0.6–1.4× so a pack doesn’t growl in unison, and suppressed
while moving (the cadence reschedules when it stops). This mirrors how the game’s own idle vocalisations play — periodic,
not a continuous wall of sound. Registry keys: soundIdleFile / soundIdleVolume / soundIdleInterval (≤0 disables).
Same WAV requirements as §14 (16-bit PCM; mono = 3-D).
One voice per unit (soundIdleGroupRadius, default 10). A unit is many pawns (a 5-monster stack is 5 pawns), so
without de-dup all five snarl at once — a chaotic overlapping wall. When a pawn growls, any packmate within
soundIdleGroupRadius world-units stays quiet until the interval passes, so a clustered formation speaks with one voice
per interval and the “voice” rotates among its pawns as their timers come due. Units farther apart than the radius growl
independently; a massed horde within it shares one occasional voice (usually what you want). ≤0 = per-pawn (every pawn
growls — the old behaviour). Verified in-game on a 5-Abomination stack (2026-07-23): the wall-of-sound collapsed to one
periodic snarl.
soundAttackFile)A distinct, violent one-shot for when the unit strikes — separate from the idle growl. soundAttackFile /
soundAttackVolume / soundAttackOffset (a WAV in haf_sounds/). Three things make it land right:
UnitActionFaceEnemy.StartUnitAction — the moment
the attacker turns to face its target, before the strike choreography. (Earlier attempts fired per-swing, which landed
the roar near the END of the animation; and even the fight-start hook was barely earlier than the swing. FaceEnemy is the
earliest clean “this unit is attacking” signal the presentation exposes — as close to “the moment you order it” as we get.)
A per-attacker min-gap stops a re-fire from doubling.PlayClipAtPoint (minDistance 1, log rolloff) attenuated
to silence at battle-camera distance — the log showed it firing but you couldn’t hear it. The attack cue instead plays at
spatialBlend 0.35 with a 60-unit minDistance, so it reads loud and clear at any zoom (it’s a dramatic focal moment, not
ambience). Verified in-game 2026-07-23.soundAttackOffset (the start offset (s) slider in the window, attack sound
only) starts playback that many seconds into the WAV — clamped inside the clip; the per-attacker min-gap keys off what
actually plays. The window’s ▶ preview honors the offset, so trim it by ear before ever launching the game.soundDeathFile)A one-shot rattle/scream when a pawn of the unit dies — closing the unit’s audio arc (alive → fighting → gone).
soundDeathFile / soundDeathVolume / soundDeathOffset. The trigger is PresentationPawn.TriggerDeath — the exact
presentation-side moment a pawn’s death animation starts, once per dying pawn. A wiped stack dies pawn-by-pawn in a
burst, so the runtime enforces a short per-entry gap (~0.6 s): one rattle per beat instead of five at once. Plays at the
dying pawn’s position through the same camera-audible one-shot path as the attack roar.
soundBattleFile)A one-shot war cry the moment a battle begins with this unit on either side. soundBattleFile / soundBattleVolume /
soundBattleOffset. The trigger is the sim event SimulationEvent_BattleStarted: the hook walks the battle’s
attacker/defender groups for our unit definitions on the sim thread (managed reads only) and queues one cry per
matching entry; the main thread plays it camera-anchored (like the attack cue) so it opens the battle audibly at any
zoom. A per-entry gap keeps back-to-back battles from stacking cries.
So the full “replace a creature’s voice” recipe is: silenceDonorAudio: true + a soundIdleFile growl (interval + group
radius to taste) + a soundAttackFile roar + a soundDeathFile rattle + a soundBattleFile war cry. All are set per-unit
in the Sound Studio window (§14), every one with volume + start offset + ▶ preview, and persist in the registry. The
Abomination ships the trio: bear silenced, an occasional bear/croc snarl at idle, a beam-roar on the strike.
Tools ▸ HAF ▸ Animation Lab (2026-07-18). Docks as a tab next to the Model Factory, so the pair
presents as one tabbed dialog. The design rule: the Factory owns the MODEL (identity, pawn, model file, transform,
size, geometry/shading, static runtime flags), the Lab owns the ANIMATION — every setting lives in exactly one of
the two windows, and jump buttons hand context across (“Edit in Animation Lab” in the Factory loads the entry here).
Both windows Bake through the identical pipeline (ConfigFor → UniversalBaker.BuildAnimated → ModelRegistry.Upsert),
so it does not matter where you press Bake.
Auto-detect settings (2026-08-01) — a button at the top of the Clip section reads the model’s clips + rig and
fills the whole animation config in one click, then explains its choice in the status bar (review-only, nothing bakes):
a Vehicle Lab Spin rig → State-driven with Idle/reference = Spin[0..0] (still) and Movement = Spin (rolls),
Convert-raw-rig + Auto-ground + Keep-translations ON, Fix 100× OFF — the exact recipe the Vehicle Lab prints; a
character (an idle + a run/walk/move clip) → State-driven with idle/movement guessed from the names; a
single clip → continuous loop; a deploy clip → a hint (deploy frame-ranges can’t be inferred from a baked clip).
A failed bake keeps your settings (2026-08-01) — both windows snapshot the form before baking and restore it if the bake errors, so a failure no longer reverts the config you just entered (RebaseOnRegistry + the field trims mutate the form in place otherwise). The status reads “Bake FAILED (settings kept): …”.
Edit existing lists the animated entries only. The model identity (Resource / Target pawn) shows
read-only — change those in the Factory; the Model file row has a Browse… button (2026-07-19) for
repointing an entry at a new source, and a ▶ Play clip button — THE recipe-authoring workflow: it plays the
RAW model file’s entire source animation (every take, full length, no conversion, no slicing) with
play/scrub, single-frame stepping (|◄ ►| buttons, ←/→ keys, Shift = ±10 frames), and set current frame
capture. This is where every frame number in a recipe comes from — deploy start/end, barrel ready-frame, recoil
range: step through the source, capture the frames with set current (each field also has a go ► jump back to
its frame). A Speed /N field (2026-07-20) sits after the End field: the frame-skip step baked into the slice
(/2 = every 2nd frame = 2× faster — this is where walk pace is authored, since pacing is bake-only), and ► Play
previews at that stepped pace, so a gait is judged in the dialog before any bake. Confirm writes the full spec —
clip[start..end/N] — straight into the clip field it was opened from; Cancel just closes. After every compile/domain reload the window RE-LOADS the selected entry
from the registry (2026-07-19) — the file is the single source of truth; unsaved form edits from before a compile
are deliberately dropped (Save (no bake) first if you want to keep them). The ↻ Reload button does the same
explicitly — the escape hatch from any stale window copy (re-selecting the same entry in the dropdown does
NOT reload it). This closed the recurring “stale Lab clobber” trap where a pre-reload form silently overwrote
registry edits at the next Save/Bake. ↻ Reload only reloads the DATA — it does NOT rebuild the model preview
(2026-08-01): the preview-rebuild path corrupts the texture on some models (a tiling-UV rig — UVs outside
0–1 that tile a seamless source texture — sampled against the packed atlas shows a scrambled checkerboard),
whereas the correct preview from the initial open/bake is fine to keep for the same entry. Selecting a different
entry, or a Bake, still rebuilds it. (The scramble is preview-only — the shipped unit uses atlas-remapped UVs
and is unaffected; the real fix, a preview mesh with atlas UVs, is a backlog item.)
For Level-2 sources (Sketchfab vehicles/artillery animated by moving parts, not a skeleton): tick Deploy
conversion, point the Model file at the raw original, and the bake runs Tools/deploy_convert.py
automatically first — into FactorySource/<res>/deploy_converted.glb, cached on an args+source+tool fingerprint
(a knob change reconverts and re-slims; nothing else does). Every knob is registry data — the full pipeline
reproduces from the entry alone:
| Field | Meaning |
|---|---|
| Deploy frames … End | the deploy motion’s source-frame range (scrub the raw file in ▶ to find it). Required. |
| Also remove (adds to defaults) | extra parts to delete on top of the default crew/prop kill-list — build it with the Pick parts… dialog (a ticklist of the source model’s parts; no typing, no re-carrying the defaults). A ticked name also removes any sub-part whose name contains it. (deployStripExtra; the M114’s contrl_wheel,ctrl_wheel2 control hand-wheels live here.) |
| Strip parts (replace defaults) | advanced — name substrings that REPLACE the default kill-list wholesale (usually leave empty; the Dugout Canoe’s camera is the rare case). To ADD parts, use Also remove above — it keeps the defaults |
| Barrel ready frame | source frame of the fully-elevated barrel; re-keys barrel/cannon parts to rise over the deploy’s back half (empty = leave as authored) |
| Leg spread scale | empty = source leg curves verbatim; a number re-keys *leg* parts as pure travel→spread rotation (1 = full width). Required for legs that slide — see the rotation-only law in Animated-Models.md |
| Barrel elevation scale | >1 exaggerates past the source’s max (empty = 1) |
| Recoil frames (a..b) | the fire cycle’s source range, remapped onto the deployed pose as the recoil clip. MULTI-SEGMENT (2026-07-26): comma-separate extra windows appended PRISTINE as epilogue, each with an optional /N speed step (every Nth frame = N× faster) — the M114’s shipped recipe is 442..530,305..441/2: the full fire+kick+reload window, then the source’s own aiming raise (which only exists before the fire) replayed at double speed to bring the barrel back up. Segment 1 alone drives the slide/slam analysis. Set the Attack clip to plain recoil. Empty = none |
| Return slow | the SYNTHESIZED palindrome return: the window replayed backward at this multiple (empty = 4; 0 = none). With Keep bone translations the source’s own run-out plays — set 0, or the kick fires twice (the rotation-only era needed the synthesis; the translation era doesn’t) |
| Slam (deg) | the ROTATION-FAKED kick pitch in degrees (the arc trick from the rotation-only era — see the revised Laws in Animation-Pitfalls.md). With Keep bone translations set 0: the real translation slide replaces the fake (they stack if you want both). POSITIVE = muzzle-down dip, NEGATIVE = muzzle-up |
| Recoil step | arc sampling fineness. (Slide scale and raw Arc R are hidden from the UI: the former self-cancels under Slam-degrees, the latter is superseded — both registry fields survive for legacy recipes) |
THE REAL KICKBACK (2026-07-26, verified on the M114): tick
Keep bone translations(Animation Lab, under Auto-ground) and the source’s authored recoil SLIDE bakes and plays as true translation — the tube slams back and glides home exactly as animated. Recipe: Recoil frames442..530,305..441/2, Return slow 0, Slam 0, Keep ✓. Translations are kept ONLY in the attack clip (deploy/stance stay rotation-only — keeping them there displaced the assembly) and are delta-rebased (pure motion, no pose offsets) + ×100 sandwich-compensated on the legacy path. The old Slam arc remains for models whose source has no real slide.
The conversion generates the state clips — deployed, folded, unfold, fold, recoil — from those frame
numbers; the Pick dropdowns and the ▶ picker inspect the converted file once it exists, so you just assign the
five names to the five roles. (The clip names live in the converted GLB; the bake resolves them by name, isolates
one clip per role folder, and the game loads the resulting ClipCollections by GUID.)
CombatIdle1 works — the bake auto-pads 1-frame clips so
Unity’s importer can’t drop them). Priority: attack > movement > after > combat-idle > idle. Each role gets the
same Pick-from-model dropdown; Movement is required, the rest optional. All roles bake in ONE Blender pass
against ONE shared skeleton (every clip is rebaked against the primary/Idle clip’s frame-0 rest — separate
per-role rests would rigidly displace the non-primary clips), into per-role ClipCollections (_Clips /
_ClipsMove / _ClipsAfter / _ClipsAttack / _ClipsCombat). Mutually exclusive with Fire-on-attack /
Deploy-when-stopped (those are ignored while State-driven is ON). The runtime polls each unit ~20×/s
(render-position delta — settle-immune), sampling map armies AND battle-deployed units, and switches the pawn’s
Pose0 clip per state.shootAR2s
= 0.17 s) reads as a blip at 1; 18 ≈ 3 s of sustained automatic fire (verified in-game). RUNTIME-ONLY:
Save (no bake) + rebuild the mod is enough — no re-bake.clipName[start..end]: the range is
cut from the source clip at bake time, start>end plays it REVERSED, a single frame becomes a held stance
(auto-padded). A speed step clipName[start..end/N] keeps every Nth frame — the slice plays N× faster
(deploy[179..0/12] folds in ~0.6 s; pacing is BAKED, the runtime has no speed knobs). Always lands exactly on
the end frame. See the artillery worked recipe in Animated-Models.md and the traps in
Animation-Pitfalls.md.animClipIdleAlt/animClipIdleAlt2 →
clipIdleAlt/clipIdleAlt2) played occasionally while plain-idle: every ~idleAltInterval seconds (jittered
0.6–1.4×, the idle-growl cadence) ONE pawn of the unit performs one clamped pass — the tiger stands, then howls,
then stands again; with both clips set each firing picks randomly (howl now, eat later), so the unit never
metronomes. Never fires during move/attack/after/combat. The interval is runtime-only (Save + rebuild retunes it;
0 disables without unbaking); the clips themselves bake as roles like any other. Pairs naturally with the idle-growl
sound for a full ambient presence.deploy[179..180]). Required for stance idles: the primary Clip defines the skeleton’s reference
pose, so a stance baked as the primary encodes ~identity and renders as the TRAVEL pose in-game (Pitfalls
Law 2). With State-driven ON the primary field is labelled Idle / reference clip — keep the FULL source
motion there, put the pose here. Empty = idle plays the primary (characters with a real idle loop).hover,
exploded_view, …). Pick lists the clips read from the model (glb/gltf). Empty = the model’s first/assigned clip.
With State-driven ON this field is the Idle clip.prop). Strips
everything else (camera pans, body bob) that would make the model wobble. Empty = keep the whole clip.convertRig). ON = the raw-rig
conversion of §16 (rest-normalize + visual rebake, root collapse, topological rename, clean-unit export — what made
the Combine soldier work; usually paired with Fix-100× OFF). OFF = the byte-identical legacy pipeline for
purpose-made rigs (drone, howitzer) — with it off, Rotation and every other setting bake exactly as they always
did, so re-baking a working legacy model is safe. Old registries that used the rotation-triggered conversion
(the 360,0,0 identity trick) migrate to the flag automatically on load.[Deployed pose time .. 1]; plays once per shot, only on the gun that fired. The recoil’s shape is baked into
the GLB by Tools/deploy_convert.py (run by hand before baking — see its full usage in §5’s source-prep notes);
this slider only scales playback speed.The animated flag is derived, not trusted: an entry carrying animation config (named clip, behaviors, bone
filter, or a baked clip GUID) is re-marked ANIMATED automatically on load and before every bake. This ended the
“howitzers on their side” failure mode — a stale unticked checkbox once re-baked the howitzer STATIC, which strips the
clip + behaviors and bakes the (animated-path-ignored) Rotation offset into the mesh, shipping tipped-over guns. Baking
a rigged model that truly has no animation config still asks for confirmation first.
Enforced field ownership. Bake/Save from EITHER window now rebases on the freshest registry entry and contributes only the fields that window owns (Factory: model/transform/size/…; Lab: clip/bones/Fix-100×/behaviors). Before this, whichever window held a stale copy silently clobbered the other’s values at bake time — it cost three bakes on the Combine soldier (a Factory bake dropped the Lab’s Fix-100× → 100× giant; the Lab’s stale copy then dropped rotation AND size). You can now edit in one window and bake from the other safely.
How the game turns pawns (found via the fixed-compass soldier). The engine orients a pawn’s visual through the
procedural BoneRotation layer (PawnEntry.BoneRotation0-3), not only ObjectSpace.Rotation. The plugin used to
zero that layer on every animated model (added so artillery aiming couldn’t twist the howitzer’s barrel) — which
pinned the soldier to one compass facing forever. It now clears the layer only for models with the artillery
behaviors (fire-on-attack / deploy-on-stop); everything else keeps the game’s layers and turns normally.
The raw-rig conversion (2026-07-18/19 — how the soldier’s “torn head” was actually solved). A Sketchfab/auto-rigged
model can ship a scrambled rest pose that the clip’s location keys ASSEMBLE into the body every frame (the Combine
soldier’s frame-0 posed bones sat up to 91 units from their rests on a 73-unit rig — 129 location curves were
structural, not decorative). Amplitude plays rotation-only clips, so such a rig can never work as-is. rig_anim.py
now performs, on the conversion path: (a) REST NORMALIZATION — snapshot every bone’s visual matrix on every
frame, apply the armature modifier at frame 0 (the assembled body becomes the bind mesh), Apply-Pose-As-Rest (the
assembled pose becomes the rest), re-bind, then re-derive the whole clip as pure rotations against the new rest
(verified in-bake: frame-0 residual = 0.000167); (b) strip residual location curves; (c) collapse no-op root
bones; (d) topological bone rename (Amplitude sorts alphabetically, parents must precede children); (e) fold
rotation+scale into the data and export unit-clean (global_scale=0.01, no ×100 root Lcl Scaling, skeleton bakes
all-Scale-1). Verified end-to-end with a litmus rig (Tools/make_litmus.py: a 12-deep bone chain of colored
cubes — renders straight in-game, exonerating the runtime for clean rigs). Raw-FBX inspectors used for the diagnosis:
Tools/fbx_binddump.py, Tools/fbx_lclscale.py. Gate refactor (2026-07-18): the conversion is now selected by
the explicit “Convert raw rig” checkbox in the Animation Lab (registry field convertRig) — it originally
triggered on a non-zero Rotation, which made the Rotation field a hidden pipeline switch (the soldier shipped with a
360,0,0 identity trick; a rotation edit on a legacy model silently rerouted its bake). Rotation is just a rotation
again (applied only on the conversion path); flag OFF remains the byte-identical legacy pipeline, and old registries
migrate automatically on load.
RESOLVED with the clean rig (2026-07-19): the “drone projectile invisible on attack” symptom disappeared once the
soldier’s rig was properly converted — the corrupted skeleton state was evidently disrupting the attack presentation
too (mechanism not separately traced; if it regresses, the designed fix was a Pose0 pass-through window during the
attack). The temporary [Uni][facing] periodic log can be removed on the next plugin change.
New here? Read Animated-Models.md first — the plain-language “what kinds of animated models can HAF import” page (three levels, what each needs, current limits). This section is the deep technical treatment behind it.
Everything learned getting the first humanoid (a raw Sketchfab ValveBiped) fully working in-game, distilled into the practical guide. The Factory does the conversion automatically — this section explains what it does, how to drive it, and how to read the safeguards. (For the other half — how the game consumes the baked result every frame — see Animated-Runtime.md.)
These are hard engine constraints (established by decompiling the bake — ClipEntry.Reimport,
Skeleton.Reimport — and the runtime — AnimationManager.GetBoneTRS/ApplyPose/GetPoseTRS):
| Constraint | Consequence for your model |
|---|---|
| Clips are rotation-only (translations are dropped or mis-scaled) | Any rig whose animation moves bones (location keys) must be converted |
| Uniform scale, and effectively scale 1 | The skeleton must bake with every BindPose/Local scale = 1 — no unit-conversion “sandwiches” on nodes |
| Bones are sorted ALPHABETICALLY, parents must sort before children | Rigs whose names don’t sort topologically (e.g. _014 < _02) get scrambled chains |
| Bounded chain depth (CPU cap 15) | Pass-through root bones waste depth budget |
| One clip per ClipCollection folder | The bake isolates your FBX in anim/ automatically |
Amplitude’s own art pipeline satisfies all of these silently, by convention. Raw downloaded rigs usually violate several at once — and the failures MASK each other, which is what makes hand-debugging brutal (it took five stacked defects to get one soldier standing).
deploy_convert.py) — rotation-driven animation, sane rest
pose. Bake with “Convert raw rig” OFF = the untouched legacy path. If it works, never touch it.pose.bones[...].location curves, and body parts that float/detach in-game while Unity’s own preview plays the
FBX perfectly (Unity supports location keys; Amplitude doesn’t). These NEED the conversion path — tick
“Convert raw rig” in the Animation Lab (add a Rotation only if the converted bake comes out mis-oriented).Everything below runs inside the Bake’s Blender step (Tools/rig_anim.py) whenever “Convert raw rig” is ticked
in the Animation Lab.
Three rig repairs run first (mech findings, 2026-07-20) — each a no-op for a rig that doesn’t need it:
N verts with no weight -> assigned to bone #0) and the
model collapses onto the root in-game. The script walks each part’s parent chain to its governing bone and converts
the parenting into a full-weight vertex group on that bone (bound at rest, so the pose isn’t double-applied).
Console proof: RIGANIM bone-parent->skin: bound N rigidly-hung mesh(es)….transform_apply only bakes an object’s OWN transform, so
the wrapper survived to export as a scaled root node — Unity folds it into the mesh but Amplitude reads bind poses
without it, leaving the skeleton ~100× off the mesh (rigid verts fling into a “wing”). The rig is un-parented with
the transform kept and the empties deleted, so export nodes are identity._end markers, detail nodes) are
removed iteratively until under budget — 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 limit (soldier 62, howitzer ~27) stay
byte-identical.Then the conversion proper:
RIGANIM rest-normalized + rebaked N frames x M bones ...
frame-0 residual = 0.0001… — the residual must be ~0. (It used to trigger on location-key presence even with
the flag off, quietly reaching into legacy re-bakes of deploy_convert.py outputs; now the flag alone decides.)_rootJoint) are deleted for depth.b###_ in hierarchy order, so alphabetical = topological.global_scale=0.01 cancels the FBX exporter’s m→cm ×100 root scaling, so the file carries
no scale annotations (UnitScaleFactor 1.0, all bind clusters 1.0).The Unity side then imports with useFileScale off (non-Fix-100× path), so the baked skeleton reads Scale: 1 on
every bone — verify with a text editor on Assets/Resources/<name>_Skeleton.asset if in doubt.
90,0,0, one axis at a time) → Bake → check the console for the conversion lines and the
residual ≈ 0 → rebuild → judge in-game. Two or three probes typically suffice.RIGANIM lines tell you exactly which steps ran, and the bake
fails loudly rather than shipping a broken skeleton.| In-game symptom | Cause | Fix |
|---|---|---|
| Model lies down / faces wrong, plays fine otherwise | Orientation (type 2) | Tick “Convert raw rig” + Rotation probe, one axis at a time |
| A body part (head, hands) floats rigidly detached; Unity preview plays the FBX fine | Type-3 rig: structural location keys, scrambled rest | The conversion path (tick “Convert raw rig”) does it all |
| Parts smear/stretch, “movement looks exaggerated” | Translation keys playing unscaled | Same — conversion path |
| Whole model 100× too big | FBX unit scale | Fix-100× toggle (per-model; the conversion path usually makes it unnecessary) |
| Deep chains (fingers, head) scrambled, shallow parts fine | Bone-name sort order | Automatic (topological rename) on the conversion path |
| Model collapses flat onto one point, limbs flung; Unity console warns “verts with no weight → bone #0” | Rig has NO skin weights — parts rigidly bone-parented (common downloaded mech/vehicle rigs) | Automatic on the conversion path (bone-parenting → full-weight groups) |
| Skeleton ~100× off the mesh; rigid parts fling into a “wing” | Wrapper empty with non-identity scale survives to export | Automatic on the conversion path (wrapper-empty flatten) |
| Huge stretch spikes on a detailed rig (fine in Blender’s preview) | Over Amplitude’s 256-bone GPU skinning cap | Automatic (zero-weight leaf bones removed to ≤240) |
| Model won’t turn with movement | The plugin was clearing the game’s facing layer | Fixed globally — cleared only for artillery models now |
| “Is it my model or the pipeline?” | — | Bake Tools/make_litmus.py’s chain-of-cubes rig; if it renders straight in-game, the pipeline is fine — it’s the model |
Tools/fbx_binddump.py — raw-parses an FBX: unit scale factor + every bind cluster’s scale.Tools/fbx_lclscale.py — raw-parses an FBX: non-identity node scales (the ×100 root tell).Tools/make_litmus.py — generates the diagnostic chain rig.<name>_Skeleton.asset for Scale: values and parent ordering,
<name>_Clips.asset for per-bone EncodingFormat (1 = rotation-only, the target).Run any of them with blender -b --python <script> -- <args>.
A vehicle model injected onto a weapon-carrying donor inherits the donor’s FIRE PRESENTATION — and every piece of it anchors to the DONOR’s rig: the aim rotation streams at donor bone indices, and the muzzle flash / launch smoke / projectile origin all resolve donor SOCKET NAMES that don’t exist on your renamed skeleton (the lookups fall back to the pawn root + the donor’s socket-local offset — the classic “flash in a corner”). This section is the complete, in-game-verified system that puts all of it back on YOUR model. Reference implementation: the ENC ArmouredCar (Ehrhardt) on the AntiAirGuns donor — tracking turret, flash, smoke and tracers all on the gun.
| Knob | When | What it does |
|---|---|---|
turretBone + turretAxis |
runtime | TARGET TRACKING: the game’s streamed aim rotation is remapped onto YOUR bone (substring), around the given local axis (0/1/2 = X/Y/Z; a turret wants its YAW axis, an artillery barrel its PITCH; -1 = the stream’s own axis). Save + relaunch. |
socketBones |
bake | DONOR SOCKETS: "DonorName=OurBoneSubstr[@x,y,z];..." — bakes zero-weight bones with the EXACT names the donor’s fire events look up, parented to your bone, so flash/smoke/tracer origin resolve NATIVELY and follow it (a tracking turret). Socketed models rename bones A###_ instead of b###_ (donor names are capitalized; Amplitude sorts alphabetically and needs parents first). Re-BAKE to apply. |
muzzleOffset |
runtime | WORLD-SPACE DIAL "x,y,z" added to the pinned fire origin — the empirical fix when a rig’s gun-bone HEAD sits somewhere unhelpful (the Ehrhardt’s is at the model base). Iterate value → relaunch; no bake, no rebuild. |
muzzleBone |
runtime | LEGACY REDIRECT (pre-socket): reroute a MISSING socket lookup to your bone. Superseded by socketBones for re-baked models, but keep it set — it gates the donor-offset compensation below. |
silenceDonorVfx |
runtime | SUPPRESS instead: drop the donor’s animation-driven VFX entirely (Factory checkbox). Sounds untouched. The fallback when relocation isn’t wanted. |
Update 2026-07-25 — the shipped ArmouredCar now runs a Vehicle-Lab-GENERATED rig (bones
Root/Wheel_00…/Turret; the hand rig’sMW_*bones no longer exist in it). Its live calibration:socketBones: "Canon_Up_left=Turret;Move_bloc=Turret",muzzleBone: "Turret",muzzleOffset: "0.5,1,0"(dialed from the turret’s CENTER — the generated bone sits there, not at the gun), turret aim axis = 1 (Y) — generated rigs always yaw on Y because the bone is built tail-up. AsocketBonesnaming a missing bone now fails the bake LOUDLY with the rig’s bone list (it used to hide behind “produced no FBX”). TheMW_Trecipe below remains the reference for hand/ripped rigs that carry weapon bones.
Edit… button on the Donor-sockets row opens the guided
mapping dialog — it harvests the hardpoint names from the [Muzzle] log for this unit automatically, offers your
model’s full bone list in searchable pickers (per-bone precision), takes optional offsets, and has a one-click
“Map ALL hardpoints to one bone…” for the common everything-on-the-gun recipe. Steps 1–2 below describe what it
automates.[Muzzle] GetBoneTRS('...') log lines —
the AA-gun donor asks for Canon_Up_left and Move_bloc. The names LIE about their roles: decode them from
the per-shot pin log — Move_bloc turned out to be the fire POSITION anchor (its events carry offsets reaching
from a base block up to each gun) and Canon_Up_left the ROTATION/direction socket.socketBones: "Canon_Up_left=MW_T;Move_bloc=MW_T" — BOTH on the weapon bone,
so position and direction ride the tracking turret. Watch the bake console for the RIGANIM socket ... lines and
the A###_ prefix note; the skeleton grows by the socket count (14 → 16 bones here).muzzleOffset: "0,2.6,0". Why needed at all: the Ehrhardt’s MW_T bone head sits at the
model BASE (the gun mesh is skinned to it, but the bone origin is low) — and, an OPEN engine question, the
socket’s provably-correct bind height did not reach the runtime pose either. The dial closes the gap
empirically: value → relaunch → look; two iterations converged (1.3 = halfway, 2.6 = on the gun).Reading the offsets — a multi-barrel donor shows itself here. The pin log’s donorOff= values varied per shot
(0.80, 0.85, 1.20): that’s the AA gun’s MULTIPLE BARRELS, expressed not as separate sockets but as different
per-event displacements from the one Move_bloc anchor. The compensation flattens them all onto your single muzzle
point — correct and verified, but it means every flash shares one spot. (Also don’t confuse the two quads: the
Muzzle-bone Pick dropdown’s MW (4 parts) is your model’s four MG-mount bones grouped by name prefix —
MW_B/F/L/T on the Ehrhardt — not the donor’s barrels.) Two unbuilt refinements ride this observation — see the
Review-Backlog: barrel variation (subtract the MEAN donor offset instead of each event’s own, so flashes scatter
naturally around the muzzle) and multi-mount fire (rotate successive fire events across several of your gun bones).
Assets/FactorySource/<res>/anim*/*_anim.fbx, then Bake.Assets/Resources/...; the GAME loads the BUILT mod — no
rebuild = you test the old skeleton. Verify from the pin log’s T= height, not from vibes.T= vs pawnWorld in the [Muzzle] pin line tells you
where the engine actually put the origin (ground = 7.3 vs gun = +2.6 in the field case).GetBoneTRS — any prefix that invokes it with the SAME name
must guard reentrancy or the game stack-overflows to desktop. Guarded in MuzzleRedirect since 2026-07-24.)Textures have their own field guide now — the atlas pipeline, every texture knob (Atlas size, Keep black, Material mode, reuseExtracted…), the symptom-indexed failure catalog (pale wash, silver panels, white parts, vanished skins), and the runtime re-skin system: Textures.md.
Three workflow tools born from the T-62 marathon’s aftermath — the first two user-designed:
Clone (Factory, next to Refresh). Duplicates the loaded entry’s ENTIRE recipe into a NEW, unsaved form:
blank Resource name (an unnamed clone can’t Bake or Save, so the source can never be overwritten by accident),
blank Pawn description (Pick the new target), baked GUIDs cleared (a clone owns no assets until its own bake),
bake lock and disabled never travel. The fast path for re-pointing a proven recipe at another unit — the first
production use built the Universal Tank in minutes: load AntiTankHalftrack → Clone → name UniversalTanks
→ Pick the new pawn → Bake. The Factory→Lab handoff carries the full unsaved form, so “Edit in Animation Lab”
on a fresh clone arrives with the complete animation recipe intact (deploy conversion, clips, flags — the whole
point of cloning). The databases side (unit stats, tech unlock, UI card/portrait, presentation definitions) is
authored separately as usual — Clone covers the MODEL side only.
Bake lock (Lab checkbox above Bake; the Factory respects it read-only). While ticked, Bake is disabled in
BOTH windows (“Bake (locked)”). Use it on entries whose baked assets are in-game VERIFIED and whose recipe the
shared tooling has moved past — the m114 sits locked until its engine-contract migration, because a rebake
would silently reconvert under rules its choreography machinery predates (proven divergent by headless
comparison). Unlocking is a deliberate act: untick → bake → RE-VERIFY IN-GAME. Related defense: conversions
self-identify their contract via the armature name (DeployArmV2), so rig_anim gives pre-rework cached
conversions their exact legacy export even after an unlock.
Coherence fixes. The Lab preview now follows the entry selection (it used to keep showing the previous
model). And the long-hunted keepTranslations self-reverting trap is dead: the field was simply missing from
RebaseLabOwnedOnRegistry, so every Factory bake overwrote the Lab’s tick with its stale copy — if a checkbox
ever “unticks itself” again, check that rebase list first.