Skip to content

Harbinger (APMF) v0.9.2 -- The equip deny no longer disarms the follower

Pre-release
Pre-release

Choose a tag to compare

@marthofdoom marthofdoom released this 06 Sep 07:22
· 215 commits to main since this release

ℹ️ NOT FIELD-TESTED

.

Fixes the v0.9.1 follower freeze

A follower could stop fighting. While a cast claim stood, the equip deny took every spell and staff the
actor owned, on both hands, for the whole life of the claim. A follower being healed repeatedly lost
their own attack spells and appeared to freeze. The deny is now limited to the claim's own hand, so the
other hand goes back to the NPC's own AI. The claimed spell still wins its slot, which is what makes the
cast work in the first place.

Offense casts now work through the same path as heals

The engine seats were installed on the Restore caster only, so a client claiming a hostile spell got
nothing at all -- the claim stood and no cast ever happened. They now cover the Offensive caster too, still
gated per call on the claim naming that exact actor and that exact spell.

Two safety fixes came with it. A hostile spell is no longer re-classified (the classification fix
exists only because the game cannot describe a heal aimed at someone else; applying it to a hostile spell
would move it to a row the game never uses and could break casting that already worked). And a latent
crash risk was closed: one seat read a value that exists only on the Restore caster's layout and would have
read garbage off the Offensive one.


API: ABI v5 (APMF_API_v5), intent kIntent_Cast (ch.8b), call RequestCast

📘 API guide -- Docs/INTEGRATION.md -- start here. See "Making an NPC cast (ABI v5)".
📄 APMF_API.h -- the single header to copy into your plugin.
🗺️ Channel map -- every facet and its hook site.


Harbinger (APMF) is a per-facet AI arbitration layer for Skyrim NPCs. A client mod claims one facet of an actor, Harbinger routes that facet to the winner and denies everyone else, and the rest of the NPC keeps running normally. No package substitution, no frozen bodies.

What is new in 0.9.1

A client can ask Harbinger to make an NPC cast a chosen spell at a chosen target, and the NPC's OWN combat AI performs it. Harbinger makes no equip call, no animation call and no cast call. It answers the questions the engine's own cast logic asks, so the charge, the aim, the animation style and the channel are all the game's own.

This makes a heal-other cast possible for the first time. The vanilla combat AI cannot classify a healing spell aimed at someone else, so it never builds one as a candidate and never considers casting it. That is why followers have only ever healed themselves through the game's AI. Harbinger supplies the one classification decision the engine is missing and the engine does the rest.


Using it

Copy native/APMF_API.h from this repo into your plugin. It is a single header, byte-shared and append-only. Keep your copy identical to this one.

1. Get the interface once, after SKSE load

Harbinger exports one undecorated C function. Fetch it in kPostLoad or kDataLoaded and keep the pointer.

#include "APMF_API.h"

const APMF_API::APMF_API_v5* g_apmf = nullptr;

if (HMODULE h = GetModuleHandleA("APMF.dll")) {
    auto fn = reinterpret_cast<APMF_API::GetInterface_t>(
        GetProcAddress(h, APMF_API::kGetInterfaceExport));   // "APMF_GetInterface"
    if (fn) {
        if (auto* base = fn(APMF_API::kABIVersion)) {        // nullptr on ABI mismatch
            if (base->abiVersion >= 5)                       // RequestCast is a v5 slot
                g_apmf = reinterpret_cast<const APMF_API::APMF_API_v5*>(base);
        }
    }
}

g_apmf null means Harbinger is absent or too old. Guard every call and fall back to whatever you did before. Never read past the end of an older interface struct.

2. Claim the cast

APMF_API::APMF_CastRequest req{};
req.spell  = spellFormID;     // the spell you want cast
req.proxy  = 0;               // 0 = let APMF mint a delivery-flip proxy if the spell is Self-delivery
req.target = targetActorID;   // 0 = self. Load-bearing: this is where the cast lands.
req.flags  = APMF_API::kCastFlag_LeftHand          // put it in the left hand
           | APMF_API::kCastFlag_Concentration     // set for a held/channelled stream
           | APMF_API::MakeStopPct(80);            // stop a channel at 80% of the target's AV
req.ttlMs  = 4000;

APMF_API::Handle h = g_apmf->RequestCast(actorFormID, basis, &req);
if (h == APMF_API::kInvalidHandle) {
    // lost arbitration, or the cast channel is not registered -> do your own thing
}

req is copied synchronously inside the call, so a stack temporary is fine. RequestCast is safe to call from any thread.

3. Hold it, then release it

The claim is always bounded. ttlMs = 0 means the 4000 ms default, and any value is clamped to the maximum, so a crashed or forgetful client can never leave an NPC stuck. Re-request while you still want the cast, and call g_apmf->Release(h) as soon as you do not.

Field notes

  • Hand policy. If the NPC is holding a weapon from another source, put the spell in the left hand. Contesting the weapon hand means the other source re-equips over you a moment later and the cast dies.
  • Self-delivery spells. A kSelf heal applied at an ally lands on the caster instead. Leave proxy = 0 and Harbinger mints a delivery-flipped copy for you.
  • stopPct is optional. Left at 0 a channel stops at full restoration. Without it the engine stops where its own combat style says, which for a heal is roughly a quarter-second pulse.
  • Failure is not masked. If the engine refuses the cast (no magicka, hand busy, spell unknown) nothing happens and nothing is faked. That is deliberate. Check APMF.log.

Requirements and limits

  • Skyrim AE 1.6.1170. The cast path refuses to install on other runtimes and on VR rather than guessing at offsets.
  • Kill switches in Data/SKSE/Plugins/APMF.ini: [CastSeats] EnableSeat0Classify, [EquipGate] EnableEquipDenyComplete.
  • Beta. Field-proven for heal-other on a follower. Offense casts through this path are still being ported in the reference client.
  • Reference client: MFO v2.0.1. Broader framework docs: Docs/INTEGRATION.md, Docs/CHANNEL-MAP.md, design.md.