Skip to content

Releases: loganw234/mercs2-lua-essentials

Ess 0.6.1

Choose a tag to compare

@github-actions github-actions released this 05 Aug 02:22

Four community contributions from @headless-rebase, merged together. Every one of them attacks the same
underlying problem from a different side: this engine addresses almost everything by a one-way 32-bit hash, so
the things you can reach at runtime are mostly opaque numbers. These make them legible.

Ess.Names reverses a hash to its name. Ess.Inspect uses that to turn an entity into a readable, typed
record. Ess.Machine does it for the destruction state vocabulary. Ess.Ecs catalogues the component classes
an entity is assembled from. Together they are the read side of the framework growing up.

Fully backwards compatible: all four are new namespaces, nothing existing changed.

Data verified independently, not taken on trust

All three contributions that ship hash tables claim the hashes are engine-verified. Rather than believe the
claim, pandemic_hash_m2 (FNV-1a, |0x20 case-fold, (^0x2A) * prime finaliser) was reimplemented from the
documentation and run over every row:

table rows mismatches
Ess.Names name map 23,110 0
Ess.Ecs component registry 232 (9 families, no duplicates) 0
Ess.Machine state vocabulary 13 0

Every name provably hashes to its own key, across all three tables, using one shared hash function. The
"never a fabricated name" property these namespaces promise is therefore demonstrable rather than asserted.
The Ess.Ecs anchors called out as RE-verified (Health=0x06BE1ABF, RuntimeHealth=0xF9B9B2A5,
RuntimeNodeHealth=0x76927BF5) all reproduce exactly.

Known follow-up (non-blocking, agreed before merge)

Ess.Machine.onChange chains the global OnStateChange correctly (prior handler preserved, every handler
pcalled so one cannot break the chain or the mission). But ensureDispatcher guards on the persistent
boolean Ess.Machine._installed, and Ess.Machine survives a level reload via or {}. If a resident mission
script later defines its own OnStateChange and displaces the dispatcher, the flag stays true and it can
never reinstall, so handlers go quiet permanently even if re-armed. Comparing _G.OnStateChange against the
dispatcher itself, instead of a flag, would let re-arming recover.

A note for anyone rebasing work across these

All four touch CHANGELOG.md, tools/checkpure.py and CAPABILITIES.md, so the later ones needed rebasing.
The checkpure.py conflict is worth knowing about: both sides open a TESTS block and share its closing
tail, so a plain keep-both-sides resolution leaves one block unterminated, silently swallows the other's tests
into a Lua string, and still reports green. It was resolved properly here; all 15 groups are present.

Added

  • Ess.Machine — the object destruction / state machine as a live control surface. Every destructible
    runs a state machine over a global vocabulary of state hashes (PristineState, DamagedState,
    DestroyedState, GoneState, CollapseState, …, shared across all destructibles, not per-object labels);
    damage drives the transitions. This lets you drive and watch it — the "force this building to CollapseState
    and see it" loop.

    • Ess.Machine.set(guid, node, state) — force a node of the machine to a state. node/state take a name
      (hashed via the engine's own String.GetHash) or a bare 0xHASH. A state name outside the global
      vocabulary is refused
      (Ess.DEBUG) rather than issued, because the damage system only ever reaches the
      known set — a novel state ships but is dead.
    • Ess.Machine.onChange(fn)stop()fn(guid, sState, sNode) on every transition, with the state
      and node hashes resolved to names (via the vocabulary + Ess.Names). Installs one dispatcher for the
      engine's global OnStateChange and chains any existing one (both fire) rather than clobbering it.
    • Ess.Machine.link(guid, hardpoint) (ObjectState.GetLinkGuid — a multi-part building's pieces are
      addressed this way, and set is node-keyed), .name(hash) (state hash → its vocabulary name, else the
      bare hash — never a guess), .print(guid) (ObjectState.PrintStateMachine), .STATES/.vocab().
    • The vocabulary is the cracked global state set (9 authoritative + 4 shipped-script names); the two
      uncracked core hashes are deliberately absent so .name() returns their bare hash rather than a label.
    • Distinct from Ess.State (_G persistence) and Ess.Human.setState (posture). Covered by a
      checkpure.py Machine group and samples/recipes/machine.lua.
    • Smoke-tested live over the lua-bridge in a running retail game: every native is present and callable
      (ObjectState.SetState/GetLinkGuid/PrintStateMachine, String.GetHash, Sys.GuidToString/
      StringToGuid, Object.GetHealth); String.GetHash returns the exact vocabulary hashes
      (CollapseState0x694683EB, PristineState0xACB51200, …) and name() reverses them; .set() drove
      a real building's 8 structural nodes to DestroyedState (returned true for all 8) and the engine reported
      each transition back through .onChange — chained onto the world's own OnStateChange, hashes resolved to
      names, uncracked states falling back to the bare hash; and .set() refused an out-of-vocabulary state
      before calling the engine. Note .set() is a logical state change (the object stays alive — visible
      destruction is the damage path, Ess.Object.kill; .set(node, "StartDestroyedState") plays the wreck).
      Call shapes are from resident/oilrig.lua.
  • Ess.Ecs — the engine's ECS component-class registry as a Lua-queryable typed vocabulary. An entity
    is assembled from reflection component classes (RuntimeHealth, StateMachine, Explosive, AiPatrol, …);
    this is the catalogue of all ~232, in 9 families, each with its component hashpandemic_hash_m2(name),
    the value the engine's component resolver keys on (verified against the RE: Health=0x06BE1ABF,
    RuntimeHealth=0xF9B9B2A5, RuntimeNodeHealth=0x76927BF5, …).

    • Ess.Ecs.classes() / .get(name) / .hash(name) / .family(name) / .find(query) (name-or-family
      substring, case-insensitive) / .families(). Misses return nil, never a guess; hashes are the canonical
      "0x…" string form (dodging the Lua-5.1-float trap, same as Ess.Names).
    • Scope: this is the naming half — the "what is a live entity made of" vocabulary. A generic raw
      per-entity component read (dump an arbitrary component's fields off an arbitrary entity) still needs a
      native memory-read verb the bridge doesn't expose; the path is reversed (an object→component resolver and
      the entity's 256-slot component table) and these hashes are its keys, so this ships the vocabulary that
      read will name things with. Ess.Inspect reads the components the engine exposes via getters today.
    • Generated from data/ecs_registry.tsv (the Mercs2 reflection RE) by build/ecs.py; covered by a
      checkpure.py Ecs group and samples/recipes/ecs.lua.
  • Ess.Inspect — a structured, NAMED read of an entity: the "remote inspector" side of the bridge (Plan
    03's "typed reads, not eval"). Ess.Inspect.read(guid) (or Ess.Inspect(guid)) returns a typed record
    grouped the way the engine's components are — identity / transform / health / physics / vehicle / faction —
    each field pulled through its confirmed getter and guarded, so a field the engine won't answer is simply
    absent rather than an error. .print(guid) logs it grouped for the console; .line(guid) is a one-line
    summary.

    • Recovers what nothing else can: a readable name and model. Object.GetName / Object.GetModelName
      return an opaque interned HANDLE, not a string (Ess.Object's own header says you "cannot read it back") —
      but that handle stringifies to its 0xHASH through Sys.GuidToString, and Ess.Names reverses the hash.
      Verified live: a spawned Veyron's model handle → 0xB4FE2B80civ_veh_car_veyron. Without the names
      table it degrades to the bare 0x….
    • Engine getters return 1/0 for booleans (and 0 is truthy in Lua), so the record coerces them to real
      bools. Composed from confirmed Ess wrappers (Ess.Object/Ess.Vehicle/Ess.Probe) + Ess.Names; covered
      by a checkpure.py Inspect group and samples/recipes/inspect.lua.
  • Ess.Names — turn a 0xHASH back into the name it was hashed from. The engine addresses everything by
    a one-way 32-bit pandemic_hash_m2, so Ess.Name(guid) gives you "0x4000563D" and there was no way
    back. This is the reverse side of that bridge — a lookup table, hash-verified against the retail WADs, that
    inverts the ~23k names the game actually ships.

    • Ess.Names.of(hash) → the name, or nil on a miss — never a fabricated name (the hash is one-way and
      only 32 bits; past a few million candidates a "match" is a collision, so a miss is reported honestly).
    • Ess.Names.label(hash)"name (0xHASH)", or the bare hash when unknown — always a string, drops
      straight into an Ess.Log. Ess.Names.installed() / .count() / .load(table).
    • Ess.Named(guid)Ess.Name with the meaning put back: "refinery_doc_warehouse01 (0x4000563D)"
      for a placed, named object (whose guid IS its name hash); the bare hash for a transient spawn handle.
    • The table is optional and shipped separately (scripts/OnLoad/2_EssNames.lua, ~1 MB) because it is
      far too large to fold into 1_Ess.lua. Opt in with one [OnLoad] line (see GETTING_STARTED / the README).
      With it absent, every call degrades cleanly to nil / the bare hash.
    • Keys are the "0x…" string form on purpose: this is Lua 5.1 with 32-bit floats, so a table keyed by
      the numeric hash would silently collide high hashes — the same class of trap Ess.RNG exists to avoid.
    • Built by build/names.py from the committed...
Read more

Ess 0.6.0

Choose a tag to compare

@github-actions github-actions released this 02 Aug 15:45

Ess.Spawn — bulk spawning.

Added

  • Ess.Spawn — put many things in the world in one call. Ess.Object.spawn places one object at one
    coordinate; everything past that (a section, a convoy, a prop field) meant hand-rolling a loop plus
    placement trig, which is where the mistakes live. Ess.Easy.Spawn.enemies was the one bulk verb and it was
    hard-wired to hostile infantry charging the player — this generalises that shape without changing it.
    • .many(templates, count, opts)templates is one name or a roster array. A roster is a group's
      composition written once: { "AL Soldier", "AL Soldier", "AL Heavy", "AL Sniper" }. Omit count and you
      get exactly one of each in the order written; give one and the roster repeats to fill it, keeping the
      ratio exact (pick="cycle", the default) or drawing freely (pick="random").
    • .mixed({{template,count},…}) for exact per-template counts, .at(templates, points) for hand-authored
      coordinate lists.
    • Easy tier: Ess.Easy.Spawn.units/.vehicles/.props(n, template) — each takes one name or a roster —
      plus .roster(templates, qty, minDist, maxDist), the named-section case in the shape it was asked for.
    • ctx:spawnArray(templates, qty, minDist, maxDist) on the Ess.UI.Menu action context, so one menu
      entry spawns a named section: menu:entry("Rifleman Section", function(ctx) ctx:spawnArray(AL_RIFLE_SECTION, 12, 20, 60) end).
  • tools/test_spawn.py — offline behavioural tests, wired into CI. The logic worth proving here is pure
    (validation, cycling, capping, placement), so it is provable without a game.
  • samples/recipes/spawn_a_group.lua — the recipe, ending in the usual [SMOKE] line.

Notes

  • Every template is validated before anything spawns. One bad entry in a roster spawns nothing. A blank
    template hard-CRASHES the engine in native C++ and pcall cannot catch a native crash — and in a bulk
    spawner, validating lazily would place seven units, hit the bad eighth, and take the game down having
    already half-applied the call. All-or-nothing is a call you can retry; a half-applied one is a mess.
  • scatter sectors its angles rather than drawing them freely. Each unit owns a 1/n slice of the circle
    and is jittered inside it, so two can never land on the same spot. The naive fully-random version was
    written first and the offline test caught it immediately (6 spawns, 2 identical positions). For infantry
    that looks sloppy; for vehicles it is worse, since two cars spawned inside each other get violently shoved
    apart by the physics.
  • Placement names deliberately do NOT reuse Ess.Squad.Formation's wedge/column/diamond. Those are
    marching formations, recomputed as a squad moves; these are static placements evaluated once at spawn.
    Sharing the words would imply a formation that holds, which this does not — spawn with grid, then
    Ess.Squad.setFormation if you want it maintained.
  • A default cap of 64, refused loudly via Ess.Safe.reject rather than attempted. Ess.Spawn.many(t, 5000)
    is a plausible typo and the engine will genuinely try. Raise it with opts.max when you mean it.
  • Fully backwards compatible: nothing existing changed. Ess.Easy.Spawn.enemies is untouched.

Fixed before release (found by the live pass)

  • The Easy tier's distance band was measured from the wrong point.
    Ess.Easy.Spawn.roster(section, 12, 20, 60) put units 2–53 from the player instead of 20–60: the band
    was passed through correctly but measured from Ess.Spawn's default centre 20 units ahead, while the
    argument names and the docs both say "from the player". All four Easy verbs and ctx:spawnArray now centre
    on the player (ahead = 0). Re-verified in game: a 20..60 request lands at 22.0..56.6, a 25..45
    request at 25.4..35.1.
  • The offline test had ratified that bug. It measured from the ahead-centre — i.e. from the
    implementation rather than from the promise — so it passed the entire time and would never have caught it.
    Corrected to measure from the player, with the same check added for units/vehicles/props. A test
    written from the code instead of from the contract will confirm whatever the code already does.

Verified in game (2026-07-26)

Hot-loaded against a running game: a blank roster entry spawns nothing and names the offending index;
a count-less roster gives one of each; a count keeps the ratio; zero duplicate positions across 8 scatter
spawns (the sectoring fix, live); mixed and at return exact counts; the cap refuses 5000 with a readable
reason; and one tracker closeAll() removed every test spawn.

Not verified: "Money (large)" does not spawn — nor do Money (Large), Money (small), Money, or
money (large), and it appears nowhere in the decompiled corpus as a Pg.Spawn template (the money strings
there are all HUD/sound/localisation). Props were confirmed with TinyGeometry and Supply Drop (Treasure)
instead. Object.IsTemplate is not a usable name check — it returns nil for known-good "Veyron" too, so it
takes a guid rather than a name.

Known gap (pre-existing, not from this change)

Ess.Object.spawn reports a failed spawn through Ess.Log rather than Ess.Safe.reject, so it stays
invisible to Ess.lastError() even with Ess.DEBUG on — the exact gap Ess.DEBUG exists to close.

Ess 0.5.2

Choose a tag to compare

@github-actions github-actions released this 27 Jul 02:13

Install this if you are on 0.5.1 — the UI kit could not draw at all in that release.

Fixed

  • ess_ui.gfx was missing from the shipped vz-patch.wad. Ess.UI renders every widget through one
    runtime Scaleform movie, and that movie was not in the wad 0.5.1 installed. Menus, panels, toasts, the
    board, chat, Ess.UI.Theme and Ess.UI.setScale therefore did nothing at all on a clean install — and
    did so silently, because the widget host constructs successfully whether or not the asset exists,
    so nothing errored and nothing reached the log.

    The wad had been committed once, before the UI kit was rewritten to use a runtime movie, and was never
    regenerated; build/package.py only checked that the file existed, which a stale wad passes. It did not
    reproduce in development because the dev install had the movie injected by hand.

    The 11 pre-rewrite per-widget movies were present the whole time and are unchanged. ess_ui is added
    alongside them, so the wad now carries 12 assets.

Added

  • build/package.py now reads the wad's asset table instead of trusting it. check_wad() parses the
    FFCS ASET and fails the build if any movie named in Ess.UI.FILES is missing, so this cannot ship again.
    The names come from Ess.UI.FILES itself, so adding a movie to the kit extends the gate automatically.
    Verified against the 0.5.1 wad: the gate rejects it.
  • docs/UI_WAD.md — how the UI wad works and how to inject a movie. Documents the trap behind this
    bug: assets are registered under their bare stem (ess_ui) but loaded with the extension
    (ess_ui.gfx), so injecting under the full filename registers a name the engine never looks up.

Ess 0.5.1

Choose a tag to compare

@github-actions github-actions released this 27 Jul 01:34

Data only — 1_Ess.lua is unchanged apart from the version string. If you only install the framework
there is nothing here for you. It exists because api/natives.json gained a field that downstream tooling
needs.

Fixed

  • published_global on natives.json namespaces. 0.5.0 correctly reclassified Hud.*, Pda.*,
    Cheat, MapLabel, MessageBox, Minimap, ObjectiveTray and SubtitleBuffer as game_script
    they are resident Lua with readable source, not C++ natives. But game_script conflates two things.
    Most resident scripts are reached by import()ing the module that owns them; these are assigned straight
    into _G (_G.Hud = HudInterface in mrxguiinterface.lua), so they exist from load with nothing to
    import, and import("Hud") is meaningless because no module has that name.

    Consumers had no way to tell the two apart. The web IDE maps game_script to "modules" and its linter
    tells you to import one, so after 0.5.0 it began advising import("Cheat") for a global that has always
    simply been there. The classification was right; the data model was too coarse to express it. 38
    namespaces now carry the flag, and the kinds block documents what it means.

Ess 0.5.0

Choose a tag to compare

@github-actions github-actions released this 27 Jul 01:16

The engine-native sweep, a UI kit that draws itself, and the visual editor finally fed from the source.
720 public functions, up from 434 — and every one of them now has a node definition, where before 509 of
them were invisible to the node editor entirely.

Most of this came out of live probing against a running game rather than reading the decompiled scripts, and
the traps below are recorded because none of them are guessable from a function name.

Added

  • Ess.Pda — the mission log, dossier, statistics and map layer. mission()/missionExists() register
    a real trackable PDA mission; a blip naming one becomes a mission blip and inherits its icon and label.
  • Ess.Hud grew title (the stylised animated overlay), location, message (negative duration =
    permanent), tutorial, image, and the cash/fuel readouts — display-only, they move the number on screen
    and not the money.
  • Ess.Hud.Faction — the faction meters, the pursuit gauge, and timer(), the only on-screen countdown
    the game exposes, with real HUD chrome and a callback on expiry.
  • Ess.Minimap — the minimap widget, which no Hud.* function reaches. lockRange owns the update
    handler so a zoom sticks; the game otherwise recomputes it from player speed every update.
  • Ess.Gps, Ess.Shop (the game's real full-screen purchase UI, filled with your own items),
    Ess.Sys, Ess.Atmosphere, Ess.UI.Theme.
  • Ess.On.script(name, fn) — react to the ~28 named events the shipped game posts ("PDA Open",
    "SupportUsed", the Satellite events…). Ess previously had no way to hear any of them.
  • Ess.Sound gained cue validation (duration/isCue/isLooping — a mistyped cue is otherwise
    completely silent) and the category mixer.
  • tools/checksyntax.py — compiles every src/ file plus the built dist offline. checkpure.py covers
    11 pure files; a syntax error in the other 73 was previously invisible until a live load.
  • samples/recipes/theme_the_ui.lua — restyling the kit, and a smoke recipe.

Changed

  • The UI kit draws at runtime from theme data. One movie replaces eight; the 8-line panel, 3-toast and
    5-chat-line caps are gone because rows are built on demand. Ess.UI.Theme is ~36 plain values with seven
    presets. Async load is handled through SetSwfFile's completion callback instead of eight blind repaints.
  • Ess.Mark's three layers agree. The world, radar and PDA surfaces do not share an icon namespace, and
    the kind table only ever named two of the three — so a "destroy" objective drew a destroy icon in the world
    and on the radar, and an anonymous dot on the map. Ess.Mark.KINDS now names all three for every kind.
  • natives.json is honest about what is native. Hud.* and Pda.* are not engine natives — they are
    resident Lua published under a different global, so 143 functions filed as black boxes have readable
    source. Engine surface 1108 → 965.
  • The release zip carries api/ess-nodes.generated.js so a browser editor can load the node set from a
    file:// page.

Fixed

  • icon_yellow_mc draws nothing. It is a registered icon name with no art, it was the engine's own
    last-resort fallback, and it was Ess's default in three places — which is why Ess.Mark's PDA blips were
    never visible. Earlier diagnosis blamed the missing label; that was only half of it.
  • Ess.Object.angularImpulse defaulted to world space while .impulse defaults to local, under a
    comment promising "same argument shape". Aligned.
  • Ess.Human.setState now validates the posture. The native reports nothing for a valid state and for
    garbage, so a wrapper guard is the only place a typo can ever be caught.
  • Ess.UI.setScale no longer builds the whole UI as a side effect of setting a number.
  • The widget rect counted the UI scale twice, so a 520-unit panel covered 61% of the screen instead of
    27%.
  • Several silent-failure paths now report on the Ess.DEBUG channel instead of returning a bare false.

Notes

  • Not wrapped, because they are dead: Pda.Database.AddHelpEntry (writes a table nothing reads),
    Hud.FactionDisplay.RemoveMeter/RemoveAllMeters and ShowAll (empty bodies), and Hud.Tutorial's two
    ShowTutorial* functions (broken for any explicit player).
  • Ess.Hud.Faction.levels is globally destructive — it replaces the game's own faction mood names for
    every faction until the level reloads. restoreLevels() undoes it.

Ess 0.4.2

Choose a tag to compare

@github-actions github-actions released this 25 Jul 23:55

Tooling only — the framework itself is unchanged. 1_Ess.lua is byte-identical to 0.4.1's apart from the
version string and the build stamp; src/ has no functional change in this release. If you only install the
framework, this release gives you nothing new and there is no reason to update. It is versioned at all because
the release zip now carries a new file.

What's new is node definitions for the visual editor, generated from the API and enriched by hand. The
node-graph editor at visual.mercs2.tools is how most beginners will meet this
framework, and its ~200 nodes were hand-written against src/*.lua — accurate, but maintained by memory. This
generates them from ess.json instead, and pairs that with a hand-authored overlay carrying the thing a
signature can never carry: what a parameter is for, what units it's in, and which of them will silently do
nothing.

486 nodes, every one enriched by hand against the real source, with all 896 parameters carrying a
confirmed type, a working default and an explanation — plus 62 functions deliberately skipped, each with a
written reason. 93 of the nodes are the Ess.Easy.* beginner tier; 138 are pure getters that wire into other
nodes' inputs.

Nothing in the editor's own repo is touched. This produces the data; adopting it is a separate, deliberate step.

Added

  • build/nodes.py — merges dist/ess.json (what exists) with api/nodes.overlay.json (what it means)
    into dist/nodes.json, plus dist/ess-nodes.generated.js, a working litegraph consumer that proves the data
    is sufficient. --check is a drift gate; --report shows coverage.
  • api/nodes.overlay.json — the hand-authored half. The overlay cannot invent anything: every entry is
    validated against ess.json, so one naming a function that doesn't exist, or giving a function a parameter it
    doesn't have, fails the build. It adds meaning to a real signature and can never add a signature.
  • build/merge_overlay.py — assembles per-namespace overlay fragments, validating before writing and
    rejecting overlaps, invented names and bad types rather than absorbing them.
  • tools/test_nodes.js — executes the generated nodes against a stubbed editor and asserts the Lua they
    produce
    . No browser, no editor checkout, no game. The load-bearing check is that a guid parameter is
    spliced raw rather than quoted: a quoted handle produces Lua that runs, logs nothing and does nothing —
    exactly the silence Ess.DEBUG exists to fight, and not something a beginner should have to diagnose.
  • api/README.md — what each manifest answers, the type vocabulary, and how to consume nodes.json.
  • CI gates for all of it, and api/nodes.json in the release zip.

Fixed (all in the new tooling, not in the framework)

  • Multi-value returns were structurally impossible as getter nodes. A getter splices its call inline as an
    expression, and Lua truncates a multi-value call to one value unless it is last in a list — so
    Ess.Color.hex would have silently delivered r and dropped g and b. Such functions are now
    auto-promoted to action nodes that capture into one local each. 26 functions were affected. (The
    editor's own convention was to skip these; this recovers them instead.)
  • Method-style calls were emitted wrong. function Ess.RNG:int(n) desugars to Ess.RNG.int(self, n), so a
    dotted Ess.RNG.int(5) passes 5 as self and leaves the real argument nil — no error, just a wrong answer.
    21 functions across Ess.Track, Ess.RNG and Ess.SaveVar. They now get a synthetic receiver input and
    emit Ess.RNG.new():int(5). Both have regression tests.

Notes

  • Node type ids are prefixed essgen/, never ess/. The editor's hand-written nodes own ess/; sharing
    the prefix would silently overwrite hand-tuned nodes depending on script load order. Both coexist, so an
    editor can migrate one namespace at a time on purpose rather than all at once by accident.
  • Descriptions ship in two lengths — desc_short for a tooltip, desc for a details pane — because the full
    ones carry engine traps worth several sentences (a helicopter running combat AI ignores a land order; a
    pursuit cap is one-way for the whole session) and those don't belong in a hover.
  • A multi-return spill gate was added to --check after one batch spotted that a bare
    Ess.Player.targetUnderReticle(0) default would spill three extra arguments into the following parameter.
    It immediately caught two more instances in a later batch that had been missed.

Ess 0.4.1

Choose a tag to compare

@github-actions github-actions released this 25 Jul 20:49

Packaging fix for 0.4.0. The v0.4.0 release asset shipped without api/ess.json or api/natives.json
— the two manifests 0.4.0 was largely about. No framework code is affected; if you only installed
1_Ess.lua, 0.4.0 was fine and this changes nothing for you.

Fixed

  • release.yml never ran build/manifest.py. ci.yml did, so CI went green while the published zip was
    missing the manifest: dist/ is gitignored, so ess.json doesn't exist in a fresh checkout, and
    package.py skipped it exactly as written. Added the generate step to the release workflow. This is a
    reminder that a passing CI job and a correct release artifact are different claims — the zip is now
    inspected, not assumed.
  • build/package.py now WARNS LOUDLY when a manifest is missing instead of quietly shipping a zip without
    it. That silence is the only reason the 0.4.0 gap reached a published release.
  • natives.json moved from gitignored dist/ to committed api/natives.json. It's captured from a
    live game by tools/dump_natives.py, so CI physically cannot regenerate it — a gitignored copy would
    have been absent from every release zip forever, no matter what the workflow did. It also changes
    essentially never (only if the game or bridge changes), which makes committing it the honest option. The
    two files now come from two different places on purpose: dist/ess.json is derived from src/ and so is
    regenerated every build to guarantee it's never stale; api/natives.json is captured from outside this
    repo
    and so is committed. Both are documented that way at every reference.

Ess 0.4.0

Choose a tag to compare

@github-actions github-actions released this 25 Jul 20:45

The diagnosability pass. Ess's oldest structural weakness was that it fails silently on purpose — a
wrapper returns nil instead of propagating a problem, so a mod calling something with a stale guid or a nil
argument produced no log line, no error, and no effect. That is the single most common "why isn't my mod doing
anything" wall, and until now there was no way to see through it. Ess.DEBUG opens it up.

Also: Ess.Safe — documented since 0.1.0 as "the single most duplicated shape in this whole project" — was
being used by the framework itself exactly once. That was an oversight, not a decision. It is now the
mechanism the whole diagnostic layer runs through.

Added

  • Ess.DEBUG (default false) — set it true, from a script or live over the bridge, and everything Ess
    quietly gave up on starts reporting itself. Read at call time, so flipping it mid-session takes effect
    immediately; survives a level reload. Two separate channels, because there are two genuinely different
    silences:

    • Thrown failures — an engine call raised a Lua error and a pcall swallowed it. Recorded by
      Ess.Safe.*.
    • Guard rejections — Ess looked at the arguments, decided the call couldn't work, and returned early
      without ever calling the engine. Recorded by Ess.Safe.reject().

    CONFIRMED LIVE 2026-07-25: 14 deliberately-malformed native calls (nil / garbage / stale guids across
    Object, Player, Vehicle, Human, Ai, Marker, Camera, Sys, Pg) threw zero Lua errors —
    they fail safe, returning nil or, for a stale guid, stale values. So the guard-rejection channel is the
    one that answers a beginner's "nothing happened", and a diagnostic built only on caught errors would have
    been quiet in exactly the case it exists for. This is not a reason to drop the pcall guards, and none
    were dropped: the crash cases in CONTRIBUTING.md were recorded defensively (deliberate breadth over pinpoint
    reproduction), so a rare throw in another location or game state stays plausible. Only the relative
    frequency of the two channels is now known.

  • Ess.Safe.reject(label, reason) — the guard-rejection recorder. Always returns nil, so a wrapper's
    early-out stays one line: if not uGuid then return Ess.Safe.reject("Ess.Object.heal", "no guid") end.
    Unlike a thrown error, a rejection knows why, because Ess is what decided — so the log line is specific
    ("no guid") rather than a generic engine error string.

  • Ess.Safe.named(label, fn, ...).quiet with the label supplied up front, for closures. A closure is
    a fresh function object per call, so it can never appear in the reverse-name map; this is the only way to
    attribute one. CONFIRMED LIVE: type(_G.debug) is nil on this engine — the debug library is absent, not
    merely unused (zero occurrences in the decompiled corpus), so a debug.getinfo fallback was confirmed dead
    code and removed rather than left in looking like it might work.

  • Ess.lastError() — the most recent swallowed failure as { msg, label, count, rejected }, or nil.

  • Ess.Safe.stats() → per-callsite tallies worst-first, plus the unconditional session total as a second
    return. Throws and rejections share one tally, so it reads as a single "what is going wrong" list.

  • Ess.Safe.reset() — clears the tally, total and last error, and drops the cached name map so it
    rebuilds (it is a snapshot of whatever engine globals existed at first use).

Changed

  • Ess.Safe.call / .quiet now pass through 6 return values, up from 4. The widest native return in the
    whole corpus is 4 (Player.GetTargetUnderReticle's x,y,z,guid), so this is headroom rather than a fix — but
    the old ceiling would have silently truncated a wider call. Still fixed-arity and still allocation-free:
    these sit inside per-frame heartbeats, where a throwaway table per engine call would be a real cost.
    Verified live at 6 values through the game's own VM.

  • Ess.Safe.quiet now means "quiet unless you asked to hear it", not "invisible". Its failures are always
    counted, and log when Ess.DEBUG is on. Previously they were unconditionally undiagnosable.

  • Failure-name resolution builds a reverse map (function reference → "Namespace.FnName") by walking the
    engine globals once, lazily, only on the first failure while Ess.DEBUG is on — so it costs nothing in the
    normal debug-off case. CONFIRMED LIVE: 1,889 functions mapped, correct on 4/4 spot checks
    (Object.GetPosition, Player.GetLocalCharacter, Ai.Goal, Pg.Spawn), including the one-level-deep
    nested tables Graphics.Camera and Graphics.Effect. (pairs(Object) returns exactly 87 functions,
    matching the wiki's own live dump.)

  • Ess.stop(x) / Ess.stopAll(t) / Ess.Track:any(x) (src/98_stop.lua) — one teardown verb for any
    handle shape. Ess grew 27 distinct teardown verbs across five structurally different disposal
    idioms — a closure to call (Ess.On.*), a handle table to hand back (Ess.Mark, Ess.Relations), an id
    string you supplied (Ess.Loop, Ess.Sandbox), an object with a method (Ess.Objective:cancel), a tracker
    (Ess.Track:closeAll) — because each namespace picked the word that read best locally. Every one still
    works and none is deprecated; Ess.stop is what you reach for when you're just holding a handle and want it
    gone, and what a teaching example can use without a detour into per-namespace spelling. Dispatch is
    duck-typed on the same discriminators Ess.Mark.clear/Ess.Relations.restore already use, with real
    methods checked first; string ids are resolved by asking each registry which one owns the id rather than
    guessing. nil and unrecognised input are safe no-ops returning false — teardown never throws.

  • Ess.RNG:pick now works on a plain array. Entries that aren't tables weigh 1, giving a uniform pick.
    Previously every entry was indexed as e[weightKey] unconditionally, so rng:pick({guidA, guidB}) — the
    obvious reading of a function called "pick" — threw attempt to index a userdata value. Weighted
    behaviour for table entries is unchanged (verified: a w = 0 entry is still never chosen in 200 draws).
    Found by a new recipe doing exactly that against a list of spawned guids.

  • Seven compose_* recipes (samples/recipes/) — the composition track, and an explicit answer to
    "why write Lua when the visual editor can wire this up?" Every other recipe is a sequence of one-liners,
    which the node editor does better. These demonstrate what a node graph structurally can't hold: a closure
    keeping private state between ticks, iteration over a query result of unknown length, an author-defined
    vocabulary the rest of the mod is then written in, behaviour that keeps reacting after the script has
    finished (without leaking on re-run), an encounter described as validated/scalable data, unified teardown,
    and the Ess.DEBUG workflow itself. All seven pass live.

  • dist/ess.json (python build/manifest.py) — a generated, machine-readable manifest of all 548
    public functions: namespace, tier, params, returns, description, source file+line, and whether documented.
    Parsed from src/ itself, which is authoritative — a doc mentioning a function never conjures one into
    existence. Ships in the release zip as api/ess.json.

  • build/manifest.py --check, the API drift gate, now running in CI. ess.json can't drift from src/
    (it's generated from it), but the hand-written surfaces can: the in-game console's registry,
    CAPABILITIES.md, and every source header comment. The gate fails the build if any of them names a function
    that doesn't exist. It earned its place immediately — its first run flagged Ess.Squad.on and
    Ess.Time.since as documented-but-undefined, which turned out to be a hole in the parser (both are plain
    function-reference aliases, Ess.Time.since = Ess.Time.elapsed), and its second flagged a real one.

  • dist/natives.json (python tools/dump_natives.py, needs a live game) — the whole engine surface, from
    a pairs(_G) walk inside the running VM: 4,316 functions across 81 engine-native namespaces (C++,
    no source anywhere) and 197 resident-game-script ones (ordinary Lua, each with its path in the
    decompiled corpus). Classification is evidence-based, not guessed — the live _MODULES registry is the
    discriminator. Deduped by table identity rather than by name, which matters in both directions: the
    module system puts an imported module's table onto every importer (MrxPmc.MrxUtil is MrxUtil, and
    oPda is Pda — 240 such aliases folded away), while some same-named tables are genuinely distinct
    (SubtitleBuffer ~= Pda.SubtitleBuffer). Each namespace records which of its functions Ess already reaches,
    so the remainder is the coverage gap. Ships as api/natives.json.

Fixed

  • Ess.Event.on's failure log said nil. The one place the pcallEss.Safe conversion silently
    degraded something: it logged its second return value as the error message, which under Ess.Safe is a bare
    false/nil by design. Now reads the message from Ess.lastError(). Found by sweeping every converted site
    for a second-return read inside its own failure branch (10 others turned out to be if not ok or not val
    nil-tests, which behave identically).

Documented (confirmed live, previously unrecorded)

  • Object.Remove is DEFERRED, exactly like Object.Kill. Ess.Object.alive(g) still reads true on the
    same tick you remove something and flips false roughly half a second later. 11_object.lua documented this
    for Kill only. Worse, Ess.Object.valid(g) stays true even after alive() has flipped — the guid
    handle outlives the object, so valid is not a usable "is it gone yet" test at all. Found by a new recipe
    asserting removal synchrono...
Read more

Ess 0.3.4

Choose a tag to compare

@github-actions github-actions released this 25 Jul 13:40

The Ess.Squad team/orchestration pass. A full team/role/queue/tactics/formation layer over
Ess.Followers, plus three more real engine bugs found and fixed via live verification along the way —
on top of the orderEnter/vehicle-aware-follow work already sitting unreleased from the previous pass.

Added

  • Ess.Squad — an opt-in team/role layer over Ess.Followers for scripts managing enough followers
    that "the whole roster" stops being the right unit of command. Ess.Squad.createTeam(name, guids) /
    .team(name) / .teamOf(guid) / .assignRole(guid, roleType) / .roleOf(guid), and
    .orderTeam(name, behavior, opts)Ess.Followers.order() scoped to just that team. Built entirely on
    Ess.Followers (specifically the new Ess.Followers._orderScoped core order() itself now calls) — no
    new native calls, no separate roster. Ess.Easy.Squad mirrors Ess.Easy.Followers' own shape
    (createTeam/assignRole/orderTeamAttack/orderTeamPatrol/orderTeamGuard/orderTeamFollow).
    CONFIRMED LIVE: ordering one team leaves every other follower (in another team or in none) completely
    undisturbed — destination markers and the natural-completion auto-resume-follow callback are tracked PER
    SCOPE (orderMarksByScope, keyed by team name or "__all__" for the whole-roster case), not against one
    shared "last order" slot, specifically so two teams ordered independently can't clear or resume-follow
    each other's still-in-flight order.
  • Ess.Followers.on(eventName, fn) / Ess.Squad.on(...) — a generic string-keyed pub/sub (the one
    piece neither Ess.On, engine-signal-specific, nor Ess.Event, raw engine handles, provided).
    "onRecruit", "onDismiss"(guid, wasKilled), "onFollowerDown" (a wasKilled dismiss, fired
    immediately alongside onDismiss) fire today; Ess.Squad.on forwards to the SAME bus so its own later,
    higher-level events reuse it rather than standing up a second one.
  • Ess.Squad.queue(targetGroup, steps, queueOpts) / .cancelQueue(targetGroup) — an asynchronous
    multi-step sequence (e.g. enter a vehicle → wait until seated → move to the LZ → wait for arrival →
    deploy), for a team name or a raw guid list. Built on the new Ess.Followers._issue (the raw
    order-issuing core _orderScoped itself now layers marker-tracking + auto-resume-follow on top of) —
    deliberately does NOT go through _orderScoped, since auto-resuming Follow the instant one step
    naturally completes is exactly wrong mid-sequence. Step completion reuses whatever signal the behavior
    already provides (onComplete for move/non-looping patrol, Ess.On.death for attack, polling
    Ess.Object.vehicleOf for enter), and EVERY step also gets a timeout watchdog regardless — CONFIRMED
    LIVE this matters: a single unit's silently-failed Ai.Goal (see the move/patrol fix below) would
    otherwise hang the entire sequence forever, not just that one step. cancelQueue reverts the group to
    Follow, its documented safe fallback. Fires "onStepComplete"/"onQueueComplete" on the same event bus.
  • Ess.Squad.Tactics.mountUp(vehGuid, targetGroup, opts) / .dismountAndSecure(targetGroup, atPos, radius) — role-aware vehicle boarding (whoever's assignRole(guid, "driver")'d boards first, as
    driver; everyone else as passenger/opts.passengerRole) and disembark-then-defend. CONFIRMED LIVE:
    Ai.Deploy only ejects PASSENGERS — a vehicle's driver stayed seated straight through it in testing, so
    dismountAndSecure also explicitly Vehicle.Exits whoever's still driving (the corpus's own
    resident/mrxsupportcopterdelivery.lua confirms this exact "make the driver get out" call shape).
    mountUp fires "onVehicleMounted" once every guid in the group is seated in some vehicle, or gives up
    silently past its own timeout (default 20s) — a blocked/full vehicle is a real, expected outcome, not an
    error.
  • Ess.Squad.setFormation(targetGroup, formationType, opts) / .clearFormation(targetGroup) — on-foot
    positional formations ("wedge"/"column"/"line"/"diamond") for a squad operating independently of
    the player, recomputed every tick as opts.leader (default the local player) moves. Deliberately opt-in
    and explicitly "visual sugar," not a precision tactical system: native Ai.Role("Follow") has no notion
    of a per-slot offset, so a formation member is taken off the Role entirely and driven by the same
    reissued-MoveTo-to-an-anchor loop Ess.Followers.startFollowLoop already proved out for vehicle
    escort/on-foot resume (see that file), just with per-slot offset math (Ess.Math.rotateOffset, the same
    right/forward convention the MissionForge sample's own squad grid already uses) instead of a hysteresis
    band. CONFIRMED LIVE: a 4-unit wedge and diamond both converged on their expected slot positions relative
    to the player's facing.
  • Ess.Easy.Followers.orderEnter(vehicleGuid, role) — orders the whole current roster to board a
    vehicle (role defaults to "driver", not Ess.AIOrders' own "passenger" default). CONFIRMED LIVE: no
    secondary "which guid is currently driving which vehicle" tracker is needed — a follower who's currently
    driving IS already the correct AIGuid for a later order() to steer the vehicle through, since
    Ess.Raw.AIOrders.actor() already implements the established "target the driver, not the hull" rule.
  • Vehicle-aware "return to following" — the native Ai.Role("Follow") wants its subject to board a
    vehicle WITH the target, so reissuing it on a follower currently DRIVING their own vehicle (after
    orderEnter, say) made them climb back OUT to go do that instead — confirmed live, the exact "gunner
    runs out the instant an order finishes" bug this closes. resumeFollow/order("follow", ...) now route
    through a vehicle-aware check: a driver gets a reissued-MoveTo escort loop instead of the Role (holding
    10–20 units off by default, hysteresis so it doesn't twitch at the boundary — first tried retargeting the
    stand-off point via "MoveToPos" directly since a vehicle driver was the corpus's one confirmed use of
    that goal, but CONFIRMED LIVE a bare Ai.Goal call still returned nil for it; switched to the same
    reused-TinyGeometry-anchor + "MoveTo" trick move/defend/patrol/flee already use); a
    passenger/gunner is left completely alone (touching their Role/Goal at all risks ejecting them for
    nothing); on foot is the unchanged native Role. Ess.Followers.recruit itself needed the same
    vehicle-awareness — a guid already sitting in a vehicle at recruit time (real game state persists across
    a Lua-side reload) got the native Follow role applied while seated otherwise. The escort loop's own
    "has the driver left?" check is debounced to 3 consecutive misses, not a single reading — a transient bad
    read (e.g. right as another follower was recruited/spawned nearby) was otherwise enough to permanently
    kill a perfectly good escort loop.

Fixed

  • A follower taken off native Ai.Role("Follow") for ANY order (even a plain move) snapped back
    hostile toward its target within 1-3 seconds on its own, and reissuing Ai.Role("Follow") afterward
    returned a valid handle but never actually moved them again
    — both confirmed live side-by-side against
    an untouched follower who stayed on native Follow the whole time and never drifted at all, so the native
    Role itself is what suppresses this, not the one-time Ai.LivingWorld/Ai.SetState("Vip")/feeling setup
    recruit() already does. Native Follow turns out to be reliable ONLY on its first engagement, straight
    from recruit() (left unchanged); every RESUME (order("follow", ...)/auto-resume/Ess.Squad.orderTeam)
    now goes through startFollowLoop instead of trying to re-engage the Role at all — the same
    reissued-MoveTo-plus-hysteresis mechanism this file already used for a vehicle driver's escort,
    generalized to on-foot too, with a per-tick feeling re-pin added to stop the drift. Accepted tradeoff: a
    RESUMED follower loses native Follow's own free vehicle-boarding-with-you convenience (the ContextAction
    prompt is tied to the Role) until explicitly orderEnter()'d again — a fresh recruit() still gets it.
  • Ess.AIOrders.command(..., "move"/"patrol", { onComplete = ... }) could hang forever for an ENTIRE
    group over a single unit
    Ai.Goal can silently refuse to register at all (no handle, no error), and
    when that happens for even one guid, no native Callback ever arrives for it, so the group's own
    fan-out completion counter never reaches zero and onComplete never fires — not even for guids who
    finished fine. Confirmed live: a 2-unit team's move order never triggered auto-resume-follow, while the
    identical order to a lone unit worked every time. Both behaviors now count an immediate registration
    failure as "done" right away instead of waiting on a Callback that's never coming.
  • Ess.Followers.list()/.count() could report stale, already-dead followers no further dismiss()
    call could clear
    — confirmed live: a death-triggered auto-dismiss racing a manual dismissAll() call
    left the ordered roster list holding 2 guids whose actual roster entry was already gone. list() is now
    self-healing (prunes the ordered list in place of any guid missing from the roster on every read), the
    same lazy-prune-on-read idiom Ess.Squad.team() already uses over this same roster.
  • Ess.AIOrders.command(..., "enter", ...)'s target had the exact same gap attack's target did
    only ever resolved a registered group name or a string name via Pg.GetGuidByName, so a raw vehicle uGuid
    (e.g. from the new orderEnter) silently resolved to nil and the whole behavior no-op'd, no error.
    target now accepts a raw uGuid directly too.

Ess 0.3.3

Choose a tag to compare

@github-actions github-actions released this 25 Jul 03:56

The Ess.Followers / Ess.AIOrders live-verification pass. Every fix and addition below was tested
against the running game (cross-checked against the decompiled game script corpus where the live behavior
alone didn't explain it), not just read-reviewed — see the entries themselves for what was actually
confirmed.

Added

  • Ess.Followers — a lifecycle-aware "who's currently assigned to me" roster, built entirely on
    Ess.AIOrders/Ess.On.death/Ess.Mark (no new native calls). Ess.AIOrders.command is stateless — every
    call re-passes an explicit guid list, and nothing remembers who you've already recruited, or reverts the
    Ai.Feeling/Ai.LivingWorld/Ai.SetState("Vip") state "follow" sets when following ends.
    Ess.Followers.recruit(guid, opts) runs that sequence AND remembers the guid; .dismiss(guid) reverts it
    AND forgets it; a dead follower prunes itself automatically via Ess.On.death, no polling. The actual
    payoff is Ess.Followers.order(behavior, opts) — command the WHOLE current roster (any of Ess.AIOrders'
    11 behaviors) with no guid list to re-thread through your own script every call. Ess.Easy.Followers adds
    recruit(guid)/orderAttack(target)/orderPatrol(points)/orderGuard(at) one-liners.
    • Markers, ON by default: a floating world-space icon over every follower's head (each in its own
      color, stepped by the golden angle so any number of followers stay evenly spread with no fixed palette
      to exhaust), plus a temporary marker at whatever order()'s current destination/target is — cleared the
      moment a new order supersedes it or the current one naturally completes. setMarkersEnabled(bool) /
      markersEnabled() toggle it.
    • Auto-resume-follow, on natural completion only (confirmed live): attack resumes Follow the instant
      its target dies; a non-looping move/patrol resumes once every follower finishes its route. Guard/
      hold/a looping patrol have no natural "done" and stay on that order until order("follow", ...) is
      called again.
    • Two more confirmed-live fixes specific to ordering an ALREADY-following unit onto something else: a
      follower can still be mid-goal from a PRIOR order when a new one comes in, and Force=true alone doesn't
      reliably preempt it — order() now clears it first with Ai.RemoveGoal({Handle=0}) (the confirmed
      "whatever's current" wildcard). And the actual root cause of an intermittent "order does nothing" during
      testing turned out to be priority, not timing: Ess.AIOrders' own per-behavior defaults (e.g. attack's
      "med") are not reliably high enough to override a just-released Follow Role's leftover state, even with
      Force=true — only "hi"/HiPri worked consistently, so order() now defaults every order's priority
      to "hi" (not changed in Ess.AIOrders.command itself, whose other callers never had a Role to preempt
      in the first place).
  • Ess.Loop.stats(id) / Ess.Loop.list() — introspection into the shared heartbeat registry: each
    loop's interval, ticks (count since last start()), lastDuration/avgDuration (real wall-clock
    tick cost, via Ess.Time.stamp()/.elapsed(), EMA-smoothed), and lastError. Lets a monitor catch a
    loop whose tick is expensive relative to its own interval — the actual, measurable version of "this
    poller feels heavy" instead of guessing from framerate. Purely additive: start()/stop()/isRunning()
    are unchanged, and every existing call site (20+ files) only ever used that public surface, never
    Ess.Loop._reg's internal shape directly, so extending it is backwards-compatible by construction —
    confirmed by grep before making the change, not assumed.

Fixed

  • Ess.AIOrders: move/defend/patrol/flee/attack's position-fallback all silently no-op'd on an
    on-foot human.
    Every one of them handed Ai.Goal a "MoveToPos"/Location={x,y,z} table — confirmed
    LIVE to be rejected by the engine (Ai.Goal returns nil, no error, since it's pcall-wrapped) for ANY
    raw-coordinate move on a walking human, regardless of distance, while the identical unit accepts "Idle"
    fine. Cross-checked against the full decompiled game script corpus: "MoveToPos" appears in exactly one
    file, and only ever targets a VEHICLE DRIVER, never a human. Fixed by spawning a disposable TinyGeometry
    at the destination and issuing "MoveTo" targeting THAT (the confirmed-working substitute — defend
    already did this exact trick for its own Ai.Anchor radius) instead of a raw coordinate.
  • Ess.Easy.AIOrders.attack's target silently attacked the PLAYER instead of the given guid.
    BEHAVIORS.attack only ever resolved o.target through the Ess.AIOrders.setGroup registry
    (Ess.AIOrders.group(o.target)[1]) — passing a raw guid (a reticle target, say) missed that lookup
    (group() returns {} for an unregistered name, per its own contract) and fell all the way through to
    nearestHero(). o.target now accepts EITHER a registered group name OR a raw uGuid directly.
  • Ess.AIOrders.command(..., "face", ...) silently no-op'd on a unit already holding an
    Ai.Anchor(AnchorRadius=0) lock
    (i.e. after a "hold" order) — confirmed live: the goal was accepted
    (no error) but never visibly turned the unit. Fixed by adding Force = true, matching every other
    movement-ish behavior in this file; no separate "release the anchor" step is needed.
  • Ess.AIOrders.command(..., "enter", ...) silently no-op'd on a freshly Ess.Object.spawn'd vehicle
    confirmed live: Ai.Goal accepted the goal (truthy handle) once Vehicle.Usable(veh, true) was called on
    it first, matching a confirmed real-game sequence (oilcon002.lua). enter now calls this once before
    issuing the goal — a harmless no-op on a vehicle that's already usable (every placed-in-level vehicle
    already is).
  • "attack" and "hold" had the same missing-Force=true gap "face" did — a unit coming off a prior
    "defend"/guard order (which leaves an Ai.Anchor lock active) silently ignored a follow-up "attack"
    goal with no error; confirmed live and fixed the same way, proactively applied to "hold" too once the
    pattern was clear.

Changed

  • Ess.AIOrders.command(..., "follow", ...) now uses Mercenaries 2's own real "recruit" mechanic
    (Ai.Role({Role="Follow", ...}), confirmed live against the decompiled game script corpus's own
    resident/mrxfollow.lua) instead of re-issuing a plain "MoveTo" goal on a dumb timer. The native Follow
    role auto-maintains MinDistance/MaxDistance on its own and follows the target into/out of vehicles for
    free — neither of which the old timer-based approach did at all. Three prerequisites, confirmed live, are
    now applied in order before the role is assigned: neutralize a hostile Ai.Feeling toward the target,
    disable the unit's ambient Ai.LivingWorld behaviour (it fights the Follow role for control otherwise),
    and set Ai.SetState(..., "Vip", true) — confirmed to be the one MISSING piece: without it, Ai.Role
    still returns a truthy handle but the unit never actually follows.