-
Notifications
You must be signed in to change notification settings - Fork 0
Firing On Attack
Auto-generated from the repo docs by
tools/sync_wiki.sh— edit the source Markdown in the repo, not this wiki page.
Status: COMPLETE (2026-07-11) — shipping. A custom injected model plays its own clip when the unit attacks — a howitzer's barrel raises the moment it bombards, then returns to rest — driven end-to-end off Humankind's combat event bus. Enabled per-model by the Factory's Fire on attack (play once) toggle. This document records the research + build.
How it works (all three pieces proven in-game):
- Detect the shot. A bombard raises
SimulationEvent_ArtilleryStrikeStarted(a unit-vs-unit bombard, not just siege — verified via a multi-event probe: only this event fired).Patches/CombatEventPatch.cshooks its staticRaise, readsArtilleryStrike.StrikerUnit.UnitDefinition(=LandUnit_Era6_Common_TowedGunHowitzers), and matches it to the registry entry (pawnDescriptionEra6_Common_TowedGunHowitzers_01) by their shared core viaUniversalInject.FindEntryForUnitDefinition. On a match it enqueues the firer'sSimulationEntityGUID(s) (both the striker unit's and army's) and logs[Fire] *** OUR MODEL '<name>' FIRED.- Resolve the firing pawn (per-instance). The combat event runs on the sim thread (no Unity access), so
Plugin.Update(main thread) drains the queue: it walks the presentation armies to thePresentationUnitwhoseGUIDmatches, and records each of its pawns' render positions (PresentationPawn.Transform.position) as "active fires." This is what makes only the firing howitzer animate instead of every howitzer of the type.- Play once, on the matching pawn. The
PawnManager.AddPawnEntrypose hook, for afireOnAttackmodel, plays a single0→1pass only on the pawn whoseObjectSpaceposition is nearest an active fire (both are Unity render coords; match radius ~4u); every other pawn of that model rests at frame 0. The pass is real-time (clamped by clip duration), then rest; finished fires are pruned inUpdate. Non-fireOnAttackanimated models (a drone's prop) keep looping.- The clip. A 2-bone DIY barrel-elevation rig (root + barrel pivoting at the trunnion, rigid weights, one "Fire" action authored to start and end at rest), baked via the Animated path.
Two build gotchas (both now handled): (a) the animated bake keeps the mesh's rig origin (no static "keel→z=0"), so center the model on the origin in the rig; (b) the 100× unit-conversion oversize is now fixed in the baker (measure at true scale / bake at file scale — see below), so an animated model uses its normal Size like the static path.
The animated injection path (see the drone) plays a baked clip on a continuous loop via the PawnManager.AddPawnEntry pose hook. "Link to attack" needs the clip triggered once, on the combat action, not looped — a looping barrel (a howitzer pumping its barrel nonstop) looks worse than static. So the machinery to play a clip already exists; the missing piece was a reliable signal for when a unit fires and which unit.
Two routes were considered:
- Route A — borrow the donor's attack motion. A model skinned to the donor's rig inherits whatever the donor plays (same mechanism as the idle bob the Freeze donor animation flag suppresses). Free, but it's the donor's generic motion, and a static model baked on the single-bone vehicle rig can't inherit a barrel-only elevation (the barrel isn't a separate bone in that bake).
- Route B — play our own clip on the attack event. Bake a bespoke clip, detect the attack, trigger it. This doc is about proving Route B's unknown: is there a hookable attack event?
Humankind exposes a designed pub/sub event bus, not just combat methods to Harmony-patch. Pattern (from the decompiled game):
// subscribe:
SimulationEvent<SimulationEvent_ArtilleryStrikeStarted>.Raised
+= new Action<object, SimulationEvent_ArtilleryStrikeStarted>(handler);
// the game raises it:
SimulationEvent_ArtilleryStrikeStarted.Raise(artilleryStrike, artilleryStrike);Each event is a class with a static Self, public data fields, and a static Raise(sender, …) that fills the fields and calls SimulationEvent<T>.Raise(sender, Self). The plugin can subscribe to .Raised and be notified — cleaner and more update-stable than patching internal combat logic.
| Event | Fires when | Fit |
|---|---|---|
SimulationEvent_ArtilleryStrikeStarted |
a unit begins an artillery/bombard strike | best — the unit stays on its tile to animate (howitzer) |
SimulationEvent_AirStrikeStarted / …Terminated
|
a bomber runs an air strike | good (bombers) |
SimulationEvent_NuclearWeaponFired |
a nuke/missile launches | poor for unit animation — the missile flies away, nothing persistent to animate |
SimulationEvent_BattleStarted / BattleReady / BattleTerminated
|
melee/tactical battle | possible (melee units) |
SimulationEvent_UnitDamageReceived, UnitKilled, FortificationDamaged
|
receiving/lethal outcomes | for hit/death reactions, not firing |
To regenerate the full event menu:
grep -aoE "class SimulationEvent_[A-Za-z]+"on the decompiled Firstpass.
internal class SimulationEvent_ArtilleryStrikeStarted : SimulationEvent<SimulationEvent_ArtilleryStrikeStarted>
{
internal int AttackerEmpireIndex;
internal int TargetTileIndex;
internal ArtilleryStrike ArtilleryStrike; // full order/state object
public static void Raise(object sender, ArtilleryStrike artilleryStrike) { … }
}So on every bombard we get the attacker's empire, the target tile, and the whole ArtilleryStrike object — enough to know that an artillery unit fired and roughly where.
The scary unknown ("is there a hookable attack event?") was answered: yes, and the feature is built and shipping (per-model Fire on attack toggle).
Getting the clip visible surfaced a scale gotcha worth recording. Some rigged FBX exports embed a metre→centimetre unit scale that the SDK Skeleton bake over-applies, so the model bakes ~100× too big and floats high (fine in the preview, wrong in-game). The howitzer's rig does this; the drone's does not — and the two need opposite handling, so there is no single rule. The fix is a per-model toggle, ModelDef.animUnitFix / the Factory's Fix 100× oversize (FBX unit scale) checkbox:
-
On (
UniversalBaker.BuildAnimated): measure the FBX withuseFileScaleoff (true native size, matches glbconv) then bake with it on, so the size factor issize / true_longestand Size means in-game units. The howitzer needs this. - Off (default): Unity's normal import — the drone bakes correctly this way.
⚠️ Cautionary tale: this started as a "universal root fix" (always measure-off/bake-on). It fixed the howitzer but made the drone vanish — the drone's FBX measured a wildnative longest 25.365where the howitzer's read a clean2.0, because their exporters embed different unit scales. One model is not a proof. The honest answer was a per-model switch, not a global constant.
-
Detect —
Patches/CombatEventPatch.cs(Hk_ArtilleryStrike) Harmony-Postfixes the staticSimulationEvent_ArtilleryStrikeStarted.Raise(patched via the explicit hook list inPlugin.cs, notPatchAll). It readsArtilleryStrike.StrikerUnit.UnitDefinition. -
Match the model —
UniversalInject.FindEntryForUnitDefinitionmatches the firing unit to the registry entry by their sharedpawnDescriptioncore (strips a trailing_\d+). -
Match the instance — the fire flag can't live on the shared registry entry, or every pawn of that model fires together. So the combat hook enqueues the firer's
SimulationEntityGUID(s) (unit + army — the on-map presentation entity is an army, so match either), andUniversalInject.ProcessFireQueues(inPlugin.Update, main thread) resolves the matchingPresentationUnitand records its pawns'Transform.positionas active fires. Two gotchas conquered: (a) the combat event is on the sim thread — touching Unity Transforms there is unsafe, hence the queue + main-thread drain; (b)SimulationEntityGUID.ToInt64()throwsInvalidCastException— read the GUID viaToString()parse (UniversalInject.GuidToLong), the same way on both sides so the values compare equal. -
One-shot playback on the firer — the
OnPawnAddedpose hook, for afireOnAttackentry, plays a single duration-clamped0→1pass only on the pawn nearest an active fire (ObjectSpaceposition vs the recorded fire position, ~4u radius); all other pawns rest at frame 0. Finished fires are pruned inUpdate. Non-fireOnAttackanimated models keep looping. (The model↔pawn match still uses the same descriptor + forced-skeleton path as Freeze.) - The clip — a 2-bone DIY barrel-elevation rig (root + barrel at the trunnion) on the crew-stripped gun GLB, fully weighted, one "Fire" action authored to start and end at rest, baked via the Animated path. Why 2-bone DIY: the store model's full crew rig broke the animated bake (53 bones, 97% unweighted → bone-#0 collapse). A clean 2-bone rig sidesteps it.
-
The toggle —
ModelDef.fireOnAttack+ the Factory's Fire on attack (play once) checkbox (Animation section) write it to the registry, so it survives re-bakes.
Sim→presentation bridge (reusable): Presentation.PresentationEntityFactoryController.PresentationArmyEntities[].PresentationUnit gives, per unit, its SimulationEntityGUID GUID, its List<PresentationPawn> Pawns, and each pawn's Unity Transform — the same walk the borrowed-rotor respawn fix uses. That's how any per-instance sim event can be mapped to specific pawns.
-
Non-artillery units — the same pattern extends to
AirStrikeStarted(bombers) andBattleStarted(melee): re-add a probe (see the removed discovery probes inCombatEventPatch.csgit history), read that event's attacker, match the model, and enqueue its GUID(s) ontofireGuidQueue— the per-instance resolution + pose-hook playback are already shared. -
Clip timing — the event fires at strike start; if a clip needs to run longer than the strike visual,
…StrikeTerminatedbounds it. - Rapid fire — already handled: the trigger is per-entry and re-entrant (a shot mid-clip restarts the single pass cleanly).
-
Decompiler:
ilspycmd8.2 at~/.dotnet/tools/ilspycmd. -
Game assemblies:
…/Steam/steamapps/common/Humankind/Humankind_Data/Managed/— combat lives inAmplitude.Mercury.Firstpass.dll(alsoAmplitude.Mercury.dll). -
Decompile + search:
ilspycmd -o <outdir> Amplitude.Mercury.Firstpass.dll grep -aoE "class SimulationEvent_[A-Za-z]+" <outdir>/*.cs | sort -u # the event menu grep -nA20 "class SimulationEvent_ArtilleryStrikeStarted" <outdir>/*.cs # its payload + Raise
See also: the animated-model pose hook and the Freeze donor animation note in Capabilities.md / Factory-Manual.md (the pose machinery this feature extends), and the "mostly-static rigged model breaks the animated bake → bake static / DIY-rig" trap.
Get started
- Getting Started
- Installation
- Troubleshooting
- Authoring State and Deployment
- Mod Editor version.xml Recovery
- Building
- Backup
Author models and behavior
- Editor Tools
- Factory Manual
- Vehicle Lab Quickstart
- Animated Models
- Animation Pitfalls
- Textures
- Unit Size
- Unit Combat Behavior
- Formations
- Pawn Props
- Projectiles
- Game Sound Lab
- Firing on Attack
- Turn Ease
- Facing Persistence
- Donor Clip Flight
Districts and wonders
Ship and operate
Internals and project