Status: WORKING (2026-07-17, refined 2026-07-18) — a custom mesh flies as a unit’s projectile in-game. The fourth injection axis, after units, districts, and pawn props. Proven end-to-end: a Humankind unit now fires a custom FPV kamikaze drone (a free Sketchfab model) instead of its vanilla projectile — it cruises level with rotors up and nose-dives onto the target. 2026-07-18 landed the big pieces: the firing-count / wave mechanic (fully decompiled), the single-pawn-vehicle fix that gives one clean drone per launch, and the finding that the skin colour is model/UV dependent (not a fixed “brown curse”). One open item: the launch audio plays a gunshot (see Limits & open items).
The skin still can’t show the mesh’s own texture (it wears whichever atlas region its UVs sample), but that’s a choose-the-model / choose-the-orientation knob, not a hard limit — see below.
Like pawn props, this rides the game’s own data path: a unit’s presentation pawn definition already names a
Projectile asset, and that field is ordinary moddable data. No plugin is required to wire it (a plugin fallback exists
for units you can’t edit — below).
A ProjectileAsset (Amplitude.Mercury.Data.World.ProjectileAsset) has no mesh field. Its visuals are three HgFx
particle effects, plus flight params:
ProjectileAsset
.speed / .slowingFactor / .missProbability / .missSpread
.muzzle (FxEvolverMaterial GUID) — the launch flash
.trail (FxEvolverMaterial GUID) — WHAT YOU SEE FLYING
.defaultImpact (FxEvolverMaterial GUID) — hit effect
.materialToImpact[] { MaterialName ("Ground"/"Metal"/"Wood"/"Leather"/"Plate"/"Shield"/…), Fx, Orientation }
— per-TARGET-SURFACE hit effect; the game matches the DEFENDER's material hash
HgFx is a GPU compute-particle system, but one render mode is MESH particles, backed by the same FxMesh +
content-layer machinery the unit/district/prop bakes already feed. The flying mesh lives on the trail:
trail → FxEvolverMaterialDrawer
.mesh [FakeAssetReference(FxMesh)] ← the swappable leaf (our drone FxMesh)
.visualOutput (output layer + subshader) ← the shader + ATLAS = the skin
.texture0/1 (GUID KEYS into that atlas) ← NOT free textures; GUIDToIndex → a UV rect
.rotateAxis=z, .moveOption=Velocity ← welds mesh +Z to the velocity vector
.importSize, orientation/size tables, .color (uniform tint — ignored for textured meshes)
The drawer auto-loads its mesh by GUID at load (ResolveDependencies → AsynchEnsureMeshLoaded(mesh) →
GetMeshIndex), and an FxMesh in our built mod bundle resolves by GUID — so, unlike pawn props, no runtime
registration is needed. It’s pure asset authoring.
speed is consumed (presentationSubPawn.Projectile.Speed → GetProjectileSpeed(), *= battle timescale), so drop
it (15 → 1–4) to actually see a short-range munition; watch at normal battle timescale.
Authoring a GPU compute evolver from scratch is a rabbit hole. Instead clone a donor projectile’s trail drawer and
swap only its mesh — the same borrow-a-donor pattern as districts/props.
Only a subset of projectiles are usable donors. A donor’s trail must be a FxEvolverMaterialDrawer with a
non-null mesh. A sprite drawer (mesh = null, renders texture0 as a billboard) has no mesh layer, so our
injected mesh renders nothing in flight. Projectile Lab’s Dump prints a VERDICT so you never waste a bake:
✓ USABLE MESH DONOR — trail draws a real mesh (safe to bake)✗ SPRITE DONOR — trail is a billboard/particle; our mesh would be invisibleSurvey (2026-07-17): every modern/siege/naval projectile is a sprite — CanonObusier, Torpedo, Mortar,
MissileCruise all ✗ (the devs use cheap sprites for fast projectiles no one watches closely). The only mesh donors
are the thrown solids — ThrownSpear, ThrownAxe — and both share one mesh GUID and one brown atlas, so there is no
darker/steel skin. ThrownAxe additionally tumbles end-over-end (its drawer spins the mesh), which a drone
shouldn’t. ⇒ ThrownSpear is the best donor: brown but stable.
The impacts are plain FX refs, independent of whether the trail is a mesh or a sprite. So a mesh donor (visible
drone) can borrow an explosive sprite donor’s impacts. Projectile Lab’s Impact donor GUID (opt.) copies
defaultImpact + materialToImpact (+muzzle) from a second projectile. Best boom: Mortar (explosion +
subEvolverAudio) or CanonObusier. So the shippable drone = ThrownSpear trail + Mortar impacts = a stable
brown drone that explodes (instead of a spear-thunk).
ProjectileBaker.cs)Tools ▸ HAF ▸ Projectile Lab authors the whole chain from a model file:
DistrictBaker.BakeFxMesh) → CopySerialized-clone the
donor drawer with our mesh swapped in → clone the ProjectileAsset with trail → the clone (+ optional impact
donor, + optional speed). Writes Projectile_<name>.asset, <name>_TrailDrawer.asset, <name>_FxMesh.color. Does nothing for a textured mesh (the shader samples the atlas directly and
ignores particle color — verified twice); kept only for donors whose shader respects it.Re-bakes use WriteAssetKeepingGuid (CopySerialized onto the existing asset) so the ProjectileAsset keeps its GUID
— the unit’s Projectile reference survives a re-bake (delete+create would blank it → the unit fires nothing, an easy
red herring).
The drawer welds mesh +Z to the velocity vector, so whatever is on +Z leads travel — you cannot aim the nose
independently (forcing “nose-down” just knocks it off the flight axis → sideways/backwards flight). Tune roll/heading
with the FxMesh ImportAngles (draw-time, no re-bake; rebuild the bundle to see it). For the drone: (0, −90, −90)
= nose-first, rotors up, and it nose-dives on the trajectory’s terminal descent for free. Avoid X = 90 — it’s the
Unity Z-X-Y gimbal pole (produces surprise 180° flips).
Inspector (data): set the unit’s presentation pawn definition Projectile field
(PresentationPawnAbstractDefinition.Projectile, a ProjectileAssetReference) to Projectile_<name> in the SDK Asset
Picker, rebuild the mod. ENC ships modded pawn defs, so it travels with the bundle. This is the whole wiring — scoped to
that one pawn def (only that unit changes).
Plugin fallback ([Projectiles] ProjectileOverrides): for units whose pawn def you can’t edit, Hk_ProjectileOverride
(a postfix on AnimationManager.AnimationLoad) re-points the field by GUID at runtime:
ProjectileOverrides = <pawnDefGuid>=<projectileGuid>;… (each side four ints). Off by default (blank).
When the projectile is a slow, big MESH (a drone) instead of a fast vanilla sprite, the game’s normal multi-fire becomes visible as a wall/swarm. The count is combat-driven, and it is NOT the animator, formation, or projectile:
pawnDef.Projectile != null (Assembly-CSharp ~74410). A pawn whose def
has Projectile = None fires nothing.ceil(defendersToKill / attackerPawnCount) (UnitActionRangedFightSequence.StartUnitAction). A wave = every
soldier throws once; if the unit’s damage kills more enemies than it has soldiers, it loops (while(num2>0)) and
throws again until the kill count is met. So a 3-soldier squad that kills ~9 → 3 waves of 3 = the wall; a 9-soldier
(or low-kill Era-1 spear thrower) → 1 wave. Total drones ≈ defendersToKill.Animator = None).Force Ranged Multi Kill (pawn-def flag) collapses to ONE wave (if(flag) return skips the extra-wave loop) BUT the
multi-kill path is an instant kill that skips FireProjectile → no drone spawns. So no data combo gives one-wave +
visible drone — that split would need a plugin cap (patch out the while(num2>0) loop). Designed, not built.THE FIX THAT SHIPPED: use a SINGLE-PAWN (vehicle) unit as the base. A car/tank/Humvee is 1 pawn with low kills →
ceil(1/1) = 1 wave → one clean drone. Proven in-game (a Humvee launches a single kamikaze drone). Reskinning that
vehicle to a soldier model is then a pure Model Factory swap (the vehicle fire-logic launches the drone, so no throw
animation is needed).
Related data levers on the unit: SpawnWeight (default 10, 0 = never) = weighted-random which pawn def fills each
formation slot when several are listed — the lever to mix firing (Projectile set) + non-firing (Projectile = None)
pawns in one squad. Formation Dummies[] = soldier count (Formation_Wedge_3 = 3 rows). Visual Affinity =
the empire’s cultural art style, selecting which pawn set (PawnDefinitionsPerVisualAffinities) renders — appearance only,
no effect on the count.
drone_clean.glb rolled a grey/metallic region (tan at some
angles). So reroll the skin by swapping the model (or nudging its UVs) — no custom atlas needed. A uniform tint
still does nothing (shader ignores particle colour for textured meshes). A chosen colour would still need a custom FX
atlas (not done).drone_clean.glb as a projectile: (0, 0, 180) (natural frame + one Z-roll).
Avoid X = ±90 (gimbal pole → surprise flips). Model legibility improved by swapping to the leaner drone_clean.glb at
size ~1.Muzzle, else trace the Wwise event with the plugin’s F8 Audio Trace and
repoint it (there’s a drone-fly wav to use). Not yet done.✓ USABLE MESH DONOR (trail is a drawer with a non-null mesh) vs ✗ SPRITE DONOR (our mesh
would be invisible) — so you never waste a bake on a sprite donor. A successful dump auto-fills the donor field.ProjectileOverrides unrelated) — optional field copies defaultImpact + materialToImpact (+muzzle)
from a second projectile, so a mesh donor (visible) can borrow an explosive sprite donor’s boom (e.g. Mortar).WriteAssetKeepingGuid — re-bakes preserve the ProjectileAsset’s GUID so the unit’s Projectile reference never
silently blanks.[SerializeField] their form so they survive a domain reload
(matching the Model Factory / Unit Retexture windows) instead of closing on every recompile.Verdict: a working, reusable 4th axis (any model → any editable unit’s projectile, with a real explosion), shipped brown and legibility-limited. Good enough to ship as a proof; the skin is the open frontier.