Releases: loganw234/mercs2-lua-essentials
Release list
Ess 0.6.1
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/statetake a name
(hashed via the engine's ownString.GetHash) or a bare0xHASH. 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 globalOnStateChangeand 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, andsetis 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(_Gpersistence) andEss.Human.setState(posture). Covered by a
checkpure.pyMachinegroup andsamples/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.GetHashreturns the exact vocabulary hashes
(CollapseState→0x694683EB,PristineState→0xACB51200, …) andname()reverses them;.set()drove
a real building's 8 structural nodes toDestroyedState(returned true for all 8) and the engine reported
each transition back through.onChange— chained onto the world's ownOnStateChange, 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 fromresident/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 hash —pandemic_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 asEss.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.Inspectreads the components the engine exposes via getters today. - Generated from
data/ecs_registry.tsv(the Mercs2 reflection RE) bybuild/ecs.py; covered by a
checkpure.pyEcsgroup andsamples/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)(orEss.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 its0xHASHthroughSys.GuidToString, andEss.Namesreverses the hash.
Verified live: a spawned Veyron's model handle →0xB4FE2B80→civ_veh_car_veyron. Without the names
table it degrades to the bare0x…. - Engine getters return
1/0for booleans (and0is 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 acheckpure.pyInspectgroup andsamples/recipes/inspect.lua.
- Recovers what nothing else can: a readable name and model.
-
Ess.Names— turn a0xHASHback into the name it was hashed from. The engine addresses everything by
a one-way 32-bitpandemic_hash_m2, soEss.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 anEss.Log.Ess.Names.installed()/.count()/.load(table).Ess.Named(guid)—Ess.Namewith 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 into1_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 trapEss.RNGexists to avoid. - Built by
build/names.pyfrom the committed...
Ess 0.6.0
Ess.Spawn — bulk spawning.
Added
Ess.Spawn— put many things in the world in one call.Ess.Object.spawnplaces 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.enemieswas 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)—templatesis one name or a roster array. A roster is a group's
composition written once:{ "AL Soldier", "AL Soldier", "AL Heavy", "AL Sniper" }. Omitcountand 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 theEss.UI.Menuaction 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++ andpcallcannot 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. scattersectors 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 withgrid, then
Ess.Squad.setFormationif you want it maintained. - A default cap of 64, refused loudly via
Ess.Safe.rejectrather than attempted.Ess.Spawn.many(t, 5000)
is a plausible typo and the engine will genuinely try. Raise it withopts.maxwhen you mean it. - Fully backwards compatible: nothing existing changed.
Ess.Easy.Spawn.enemiesis 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 fromEss.Spawn's default centre 20 units ahead, while the
argument names and the docs both say "from the player". All four Easy verbs andctx:spawnArraynow centre
on the player (ahead = 0). Re-verified in game: a20..60request lands at22.0..56.6, a25..45
request at25.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 forunits/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
Install this if you are on 0.5.1 — the UI kit could not draw at all in that release.
Fixed
-
ess_ui.gfxwas missing from the shippedvz-patch.wad.Ess.UIrenders 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.ThemeandEss.UI.setScaletherefore 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.pyonly 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_uiis added
alongside them, so the wad now carries 12 assets.
Added
build/package.pynow reads the wad's asset table instead of trusting it.check_wad()parses the
FFCS ASET and fails the build if any movie named inEss.UI.FILESis missing, so this cannot ship again.
The names come fromEss.UI.FILESitself, 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
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_globalonnatives.jsonnamespaces. 0.5.0 correctly reclassifiedHud.*,Pda.*,
Cheat,MapLabel,MessageBox,Minimap,ObjectiveTrayandSubtitleBufferasgame_script—
they are resident Lua with readable source, not C++ natives. Butgame_scriptconflates two things.
Most resident scripts are reached byimport()ing the module that owns them; these are assigned straight
into_G(_G.Hud = HudInterfaceinmrxguiinterface.lua), so they exist from load with nothing to
import, andimport("Hud")is meaningless because no module has that name.Consumers had no way to tell the two apart. The web IDE maps
game_scriptto "modules" and its linter
tells you to import one, so after 0.5.0 it began advisingimport("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 thekindsblock documents what it means.
Ess 0.5.0
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.Hudgrewtitle(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, andtimer(), the only on-screen countdown
the game exposes, with real HUD chrome and a callback on expiry.Ess.Minimap— the minimap widget, which noHud.*function reaches.lockRangeowns 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.Soundgained cue validation (duration/isCue/isLooping— a mistyped cue is otherwise
completely silent) and the category mixer.tools/checksyntax.py— compiles everysrc/file plus the built dist offline.checkpure.pycovers
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.Themeis ~36 plain values with seven
presets. Async load is handled throughSetSwfFile'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.KINDSnow names all three for every kind.natives.jsonis honest about what is native.Hud.*andPda.*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.jsso a browser editor can load the node set from a
file://page.
Fixed
icon_yellow_mcdraws 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 whyEss.Mark's PDA blips were
never visible. Earlier diagnosis blamed the missing label; that was only half of it.Ess.Object.angularImpulsedefaulted to world space while.impulsedefaults to local, under a
comment promising "same argument shape". Aligned.Ess.Human.setStatenow 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.setScaleno 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.DEBUGchannel instead of returning a barefalse.
Notes
- Not wrapped, because they are dead:
Pda.Database.AddHelpEntry(writes a table nothing reads),
Hud.FactionDisplay.RemoveMeter/RemoveAllMetersandShowAll(empty bodies), andHud.Tutorial's two
ShowTutorial*functions (broken for any explicit player). Ess.Hud.Faction.levelsis 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
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— mergesdist/ess.json(what exists) withapi/nodes.overlay.json(what it means)
intodist/nodes.json, plusdist/ess-nodes.generated.js, a working litegraph consumer that proves the data
is sufficient.--checkis a drift gate;--reportshows coverage.api/nodes.overlay.json— the hand-authored half. The overlay cannot invent anything: every entry is
validated againstess.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 aguidparameter is
spliced raw rather than quoted: a quoted handle produces Lua that runs, logs nothing and does nothing —
exactly the silenceEss.DEBUGexists to fight, and not something a beginner should have to diagnose.api/README.md— what each manifest answers, the type vocabulary, and how to consumenodes.json.- CI gates for all of it, and
api/nodes.jsonin 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.hexwould have silently deliveredrand droppedgandb. 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 toEss.RNG.int(self, n), so a
dottedEss.RNG.int(5)passes 5 asselfand leaves the real argument nil — no error, just a wrong answer.
21 functions acrossEss.Track,Ess.RNGandEss.SaveVar. They now get a synthetic receiver input and
emitEss.RNG.new():int(5). Both have regression tests.
Notes
- Node type ids are prefixed
essgen/, neveress/. The editor's hand-written nodes owness/; 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_shortfor a tooltip,descfor 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 spillgate was added to--checkafter 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
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.ymlnever ranbuild/manifest.py.ci.ymldid, so CI went green while the published zip was
missing the manifest:dist/is gitignored, soess.jsondoesn't exist in a fresh checkout, and
package.pyskipped 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.pynow 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.jsonmoved from gitignoreddist/to committedapi/natives.json. It's captured from a
live game bytools/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.jsonis derived fromsrc/and so is
regenerated every build to guarantee it's never stale;api/natives.jsonis captured from outside this
repo and so is committed. Both are documented that way at every reference.
Ess 0.4.0
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(defaultfalse) — 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
pcallswallowed 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 byEss.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, returningnilor, 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 thepcallguards, 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. - Thrown failures — an engine call raised a Lua error and a
-
Ess.Safe.reject(label, reason)— the guard-rejection recorder. Always returnsnil, 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, ...)—.quietwith 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)isnilon this engine — the debug library is absent, not
merely unused (zero occurrences in the decompiled corpus), so adebug.getinfofallback 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 }, ornil. -
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/.quietnow 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.quietnow means "quiet unless you asked to hear it", not "invisible". Its failures are always
counted, and log whenEss.DEBUGis 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 whileEss.DEBUGis 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 tablesGraphics.CameraandGraphics.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.stopis 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 discriminatorsEss.Mark.clear/Ess.Relations.restorealready use, with real
methods checked first; string ids are resolved by asking each registry which one owns the id rather than
guessing.niland unrecognised input are safe no-ops returningfalse— teardown never throws. -
Ess.RNG:picknow works on a plain array. Entries that aren't tables weigh 1, giving a uniform pick.
Previously every entry was indexed ase[weightKey]unconditionally, sorng:pick({guidA, guidB})— the
obvious reading of a function called "pick" — threwattempt to index a userdata value. Weighted
behaviour for table entries is unchanged (verified: aw = 0entry 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 theEss.DEBUGworkflow 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 fromsrc/itself, which is authoritative — a doc mentioning a function never conjures one into
existence. Ships in the release zip asapi/ess.json. -
build/manifest.py --check, the API drift gate, now running in CI.ess.jsoncan't drift fromsrc/
(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 flaggedEss.Squad.onand
Ess.Time.sinceas 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
apairs(_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_MODULESregistry 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.MrxUtilisMrxUtil, and
oPdaisPda— 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 asapi/natives.json.
Fixed
Ess.Event.on's failure log saidnil. The one place thepcall→Ess.Safeconversion silently
degraded something: it logged its second return value as the error message, which underEss.Safeis a bare
false/nilby design. Now reads the message fromEss.lastError(). Found by sweeping every converted site
for a second-return read inside its own failure branch (10 others turned out to beif not ok or not val
nil-tests, which behave identically).
Documented (confirmed live, previously unrecorded)
Object.Removeis DEFERRED, exactly likeObject.Kill.Ess.Object.alive(g)still readstrueon the
same tick you remove something and flips false roughly half a second later.11_object.luadocumented this
forKillonly. Worse,Ess.Object.valid(g)staystrueeven afteralive()has flipped — the guid
handle outlives the object, sovalidis not a usable "is it gone yet" test at all. Found by a new recipe
asserting removal synchrono...
Ess 0.3.4
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 overEss.Followersfor 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 newEss.Followers._orderScopedcoreorder()itself now calls) — no
new native calls, no separate roster.Ess.Easy.SquadmirrorsEss.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 neitherEss.On, engine-signal-specific, norEss.Event, raw engine handles, provided).
"onRecruit","onDismiss"(guid, wasKilled),"onFollowerDown"(awasKilleddismiss, fired
immediately alongsideonDismiss) fire today;Ess.Squad.onforwards 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 newEss.Followers._issue(the raw
order-issuing core_orderScopeditself 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 (onCompletefor move/non-looping patrol,Ess.On.deathfor attack, polling
Ess.Object.vehicleOffor enter), and EVERY step also gets a timeout watchdog regardless — CONFIRMED
LIVE this matters: a single unit's silently-failedAi.Goal(see themove/patrolfix below) would
otherwise hang the entire sequence forever, not just that one step.cancelQueuereverts 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'sassignRole(guid, "driver")'d boards first, as
driver; everyone else as passenger/opts.passengerRole) and disembark-then-defend. CONFIRMED LIVE:
Ai.Deployonly ejects PASSENGERS — a vehicle's driver stayed seated straight through it in testing, so
dismountAndSecurealso explicitlyVehicle.Exits whoever's still driving (the corpus's own
resident/mrxsupportcopterdelivery.luaconfirms this exact "make the driver get out" call shape).
mountUpfires"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 asopts.leader(default the local player) moves. Deliberately opt-in
and explicitly "visual sugar," not a precision tactical system: nativeAi.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 loopEss.Followers.startFollowLoopalready 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 theMissionForgesample'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 (roledefaults to"driver", notEss.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 correctAIGuidfor a laterorder()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-MoveToescort 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 bareAi.Goalcall still returnednilfor it; switched to the same
reused-TinyGeometry-anchor +"MoveTo"trickmove/defend/patrol/fleealready 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.recruititself 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 plainmove) snapped back
hostile toward its target within 1-3 seconds on its own, and reissuingAi.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-timeAi.LivingWorld/Ai.SetState("Vip")/feeling setup
recruit()already does. Native Follow turns out to be reliable ONLY on its first engagement, straight
fromrecruit()(left unchanged); every RESUME (order("follow", ...)/auto-resume/Ess.Squad.orderTeam)
now goes throughstartFollowLoopinstead 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 explicitlyorderEnter()'d again — a freshrecruit()still gets it. Ess.AIOrders.command(..., "move"/"patrol", { onComplete = ... })could hang forever for an ENTIRE
group over a single unit —Ai.Goalcan silently refuse to register at all (no handle, no error), and
when that happens for even one guid, no nativeCallbackever arrives for it, so the group's own
fan-out completion counter never reaches zero andonCompletenever fires — not even for guids who
finished fine. Confirmed live: a 2-unit team'smoveorder 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 aCallbackthat's never coming.Ess.Followers.list()/.count()could report stale, already-dead followers no furtherdismiss()
call could clear — confirmed live: a death-triggered auto-dismiss racing a manualdismissAll()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 idiomEss.Squad.team()already uses over this same roster.Ess.AIOrders.command(..., "enter", ...)'stargethad the exact same gapattack'stargetdid —
only ever resolved a registered group name or a string name viaPg.GetGuidByName, so a raw vehicle uGuid
(e.g. from the neworderEnter) silently resolved toniland the whole behavior no-op'd, no error.
targetnow accepts a raw uGuid directly too.
Ess 0.3.3
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.commandis 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 viaEss.On.death, no polling. The actual
payoff isEss.Followers.order(behavior, opts)— command the WHOLE current roster (any ofEss.AIOrders'
11 behaviors) with no guid list to re-thread through your own script every call.Ess.Easy.Followersadds
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 whateverorder()'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):
attackresumes Follow the instant
its target dies; a non-loopingmove/patrolresumes once every follower finishes its route. Guard/
hold/a looping patrol have no natural "done" and stay on that order untilorder("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, andForce=truealone doesn't
reliably preempt it —order()now clears it first withAi.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"/HiPriworked consistently, soorder()now defaults every order's priority
to"hi"(not changed inEss.AIOrders.commanditself, whose other callers never had a Role to preempt
in the first place).
- Markers, ON by default: a floating world-space icon over every follower's head (each in its own
Ess.Loop.stats(id)/Ess.Loop.list()— introspection into the shared heartbeat registry: each
loop'sinterval,ticks(count since laststart()),lastDuration/avgDuration(real wall-clock
tick cost, viaEss.Time.stamp()/.elapsed(), EMA-smoothed), andlastError. 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 handedAi.Goala"MoveToPos"/Location={x,y,z}table — confirmed
LIVE to be rejected by the engine (Ai.Goalreturnsnil, no error, since it'spcall-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 disposableTinyGeometry
at the destination and issuing"MoveTo"targeting THAT (the confirmed-working substitute —defend
already did this exact trick for its ownAi.Anchorradius) instead of a raw coordinate.Ess.Easy.AIOrders.attack'stargetsilently attacked the PLAYER instead of the given guid.
BEHAVIORS.attackonly ever resolvedo.targetthrough theEss.AIOrders.setGroupregistry
(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.targetnow 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 addingForce = 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 freshlyEss.Object.spawn'd vehicle —
confirmed live:Ai.Goalaccepted the goal (truthy handle) onceVehicle.Usable(veh, true)was called on
it first, matching a confirmed real-game sequence (oilcon002.lua).enternow 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=truegap"face"did — a unit coming off a prior
"defend"/guard order (which leaves anAi.Anchorlock 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-maintainsMinDistance/MaxDistanceon 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 hostileAi.Feelingtoward the target,
disable the unit's ambientAi.LivingWorldbehaviour (it fights the Follow role for control otherwise),
and setAi.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.