Why Humanoid NPCs can't shoot in Portal

Today, I decided to make my first post in almost two years on this blog, by investigating something that's bugged me for a long time. That is, when you spawn humanoid NPCs from Half-Life 2 in Portal (or a Portal mod), they are incapable of firing their weapons, with some very limited exceptions. This limitation was documented almost ten years ago in the video below:

I remember trying to work around this issue in an early build of Aperture Ireland (with a very different concept from the final mod), in which Metrocops were supposed to shoot at the player. The entity npc_metropolice can't shoot with any weapon, but npc_citizen can use the pistol, which is a natural choice of weapon for a Metrocop. Thus, I figured I could force an npc_citizen to spawn with the Metrocop model and use a bunch of ai_relationship entities to make them act like cops. It turned out that that didn't cut the mustard though - these fake Metrocops were just as incapable of firing as real ones! At that point I realized the model is somehow involved in the problem as much as the underlying code. Not having access to said underlying code, I couldn't really go much further with that. This was only one of many reasons for throwing out that old Aperture Ireland concept.

Much more recently, the code for Portal did become available in mod form, via Project-Beta. I have previously compiled and played through that mod myself, and I did check whether or not it still had this issue. It does. It wasn't until today that I became hell-bent on diagnosing and resolving this thing.

I started by spawning an npc_combine_s with a weapon_ar2 in one of Project-Beta's test maps and turning on developer mode in the console. Nothing. I then opened up the code, and tried to see how exactly the Combine decides to fire his weapon. Now, I must say that since I haven't worked with Source for years, and never really properly learned how humanoid NPCs do their thing, this whole process really confused me. I uncommented some DevMsg calls under TASK_RANGE_ATTACK1, which is the AI task associated with firing a weapon, and all seemed to be functioning correctly. I then figured out that the activity ACT_RANGE_ATTACK1 gets translated into a weapon-specific one, which then gets translated into a sequence for the model to play. I spent a lot of time looking into how "Ideal Activities" filtered down to actual activities, uncommenting messages such as this one from CAI_BaseNPC::AdvanceToIdealActivity:

DevMsg("%s: IDEAL %s -> %s\n", GetClassname(), GetSequenceName(GetSequence()), GetSequenceName(m_nIdealSequence));

It all seemed to check out - Combine Soldiers were playing the right sequences at the appropriate times.

I eventually realized that the method FireNPCPrimaryAttack on the weapon_ar2 is supposed to make its owner fire bullets from its muzzle, and is supposed to be called via HandleAnimEvent on the NPC. This "AnimEvent", specifically EVENT_WEAPON_AR2 would be triggered as part of the above-mentioned sequence. More digging revealed that HandleAnimEvent is supposed to be called by DispatchAnimEvents, which aborts if it can't find any events associated with the current sequence:

void CBaseAnimating::DispatchAnimEvents ( CBaseAnimating *eventHandler )
     // ...

     // skip this altogether if there are no events
     if (pstudiohdr->pSeqdesc( GetSequence() ).numevents == 0)
     // ...

I added in some messages here, which revealed that it was indeed finding zero events on the sequence shootAR2s on a Combine Soldier (or indeed shoot_ar2 on a Citizen). On the other hand, when I spawned an npc_citizen with a weapon_pistol, it was finding one event, on the sequence shootp1. I tried commenting out the check for numevents == 0 but that didn't help. Now, this information was being pulled straight from the mdl file header, so at this stage I was wondering if the Portal code was somehow corrupting the data loaded from the model files! There's so little difference between the Portal and HL2 code though, and I certainly couldn't find anything that would do that.

I decided to try compiling an episodic binary from the very same codebase, just to verify that it does work... (This was simply a matter of using a different Makefile.) I compiled and ran the alternative binary, and, yes, the weapons all worked, but it turns out that the number of events on the offending sequences was still zero! At this, I began to wonder if Portal was doing the right thing, and Episodic/HL2 had some voodoo that was making the NPCs react to non-existent anim-events... I continued looking into it, turning over stones in obscure places such as CBaseAnimatingOverlay and CAI_BehaviorBase. However, it seemed that no matter how many diagnostic messages I added, I got this with a Portal binary:

Diagnostic messages in a Portal binary

And this with an Episodic binary, built from the very same codebase:

Diagnostic messages in an Episodic binary

Yes, at this stage I had decided to switch to investigating npc_citizen rather than npc_combine_s for some reason - probably to see more clearly the difference between weapon_ar2 and weapon_pistol. 3007 is the number corresponding to EVENT_WEAPON_AR2.

At this point I was completely baffled, and I even attempted to find ways of doing some kind of backtrace in C++. The solution here looked promising, but I realized that implementing it would involve changing HandleAnimEvent's function signature everywhere it occurs in the code... Eventually I noticed something I had overlooked - DispatchAnimEvent is usually called by a CBaseAnimating on itself - however, CBaseCombatWeapon also calls it on its owner:

void CBaseCombatWeapon::Operator_FrameUpdate( CBaseCombatCharacter *pOperator )
     StudioFrameAdvance( ); // animate

     // ...

     // Animation events are passed back to the weapon's owner/operator
     DispatchAnimEvents( pOperator );

     // ...

Now, I was finally getting somewhere! By adding an extra diagnostic message to DispatchAnimEvents, I got this:

Diagnostic messages in an Episodic binary with extra output from DispatchAnimEvents

So the event is actually coming from the fire sequence of the weapon's world-model, not from anything on the NPC's model itself! I then checked what happened on a Portal binary:

Diagnostic messages in a Portal binary with extra output from DispatchAnimEvents

Here you can see what happens with both weapon_ar2 and weapon_pistol. In the case of weapon_ar2, the sequence fire is replaced by IR_fire, for some reason, while on weapon_pistol, the event comes directly from the sequence shootp1 on the NPC itself.

Now I was really getting somewhere. I had noticed references to activities in CBaseCombatWeapon::Operator_FrameUpdate so I added a diagnostic message to verify that the correct activity was used in both Portal and Episodic:

// Animation events are passed back to the weapon's owner/operator
Msg("%s about to dispatch anim-events for activity \"%s\" to %s\n", GetClassname(), CAI_BaseNPC::GetActivityName(GetActivity()), pOperator->GetClassname());
DispatchAnimEvents( pOperator );

In both cases, it was ACT_RANGE_ATTACK_AR2, so that wasn't the issue. Seemingly, Portal and HL2/Episodic handled activities on combat weapons differently. A bit more searching showed up the smoking gun:

Smoking gun

The server code ends up finding sequences on the view-model when it should be looking at the world-model! This hack is to make sure the player sees themselves firing the portal-gun properly through a portal. The fix ended up being really simple: just check whether or not the owner is a player before applying the hack. I did this and, sure enough, now citizens and Combine can shoot each other in a Portal binary!

In fact, this solution is so simple that it was already documented, albeit in the context of HL2 multiplayer rather than Portal. I found that by searching online for the words "Adrian", "oh man" and "CBaseCombatWeapon", since I figured I couldn't be the first person to have fixed it. Rather an anticlimactic conclusion, I suppose...

Anyway, if you do happen to be modding Portal 1, and want to make humanoid NPCs from HL2 shoot, this is how to do it!