Getting Started Modding Mercenaries 2
This wiki documents the Lua scripting surface of Mercenaries 2: World in Flames (PC) for modders. If you’re new here, start on this page — it covers the only supported way to get your own Lua code running in the live game right now: lua-bridge.
Rapid overview
Already comfortable installing ASI mods and don’t need the walkthrough? Here’s the whole thing:
- Patch the game once. Mercenaries 2’s SecuROM check has to be bypassed before any ASI loader can run at all. Use mercs2-securom-bypass to patch
Mercenaries2.exe— a one-time step, and not something any tool below does for you. - Get
pmc_bb.dll, the actual ASI loader lua-bridge plugs into — built by pmc-blackbox. Easiest path: install mercs2-modkit (a desktop app for managing WAD-asset mods) — it installs pmc-blackbox for you as part of its own setup, no separate step needed. Skipping modkit’s own asset-mod features entirely?pmc-blackbox’s own repo has manual install steps. - Get lua-bridge. No stable release exists yet —
lua-bridge-DEVin github.com/loganw234/Merc2-Mods-Exp is the current active build. Drop the built.asi/.iniintoscripts/— see Install below. - Run code one of two ways:
- Interactively:
py tools/lua_console.py, a small GUI REPL client — write Lua, hit F5, see the result. Fastest way to poke at game state. - Automatically: drop a
.luafile intoscripts/OnLoad/(runs once per level load — use this for almost everything) orscripts/OnKey/(runs on a hotkey press — declare the key withlocal KEYVAL = "insert"in the script’s first 10 lines).
- Interactively:
- That’s it — no compilation step, no ASI-mod boilerplate. If something’s not working, jump to Troubleshooting at the bottom of this page.
New to this entirely, or want the reasoning behind each step? Keep reading below.
Why lua-bridge
Mercenaries 2 ships with a statically-linked Lua 5.1.2 runtime driving nearly everything — world objects, missions, GUI, AI. There’s no official modding API. lua-bridge hooks into that runtime at the binary level and exposes it to you two ways:
- A live REPL over a localhost TCP socket — send it a chunk of Lua, get the result back immediately.
- A script loader — drop
.luafiles into watched folders and they run automatically at specific points in the game’s lifecycle (boot, level load, or a hotkey press).
At this early stage of the modding scene, lua-bridge is the only injection tool available, so this page is also the closest thing to an “API contract” for how mods are structured — until something else comes along, build against this.
Install
Prerequisite: pmc_bb.dll
lua-bridge is an ASI plugin — it doesn’t run standalone, and needs pmc_bb.dll (the actual ASI loader) already in place first. pmc_bb.dll is a separate project from lua-bridge, built by pmc-blackbox: a DRM-bypass + debug DLL for Mercenaries 2 that, among other things, discovers and loads .asi plugin files — lua-bridge is one such plugin.
- Patch the SecuROM check. Use mercs2-securom-bypass to patch
Mercenaries2.exeso it’ll run without the retail DRM check getting in the way of ASI loading at all. One-time, and independent of everything below — see that repo for exact steps. - Install
pmc_bb.dll. The easiest path is mercs2-modkit — a desktop app primarily for managing WAD-asset mods, which installs pmc-blackbox’spmc_bb.dllfor you as part of its own setup (see that repo for its own instructions). If you don’t want modkit’s asset-mod features,pmc-blackbox’s own repo documents installingpmc_bb.dlldirectly: copy it next toMercenaries2.exe.
Installing lua-bridge itself
Once pmc_bb.dll is in place:
- Build or download
lua_bridge.asiand its companionlua_bridge.ini. - Drop both into your game’s
scripts/folder (next toMercenaries2.exe). - Launch the game —
pmc_bb.dll’s ASI loader picks uplua_bridge.asiautomatically. - On first successful launch, lua-bridge auto-creates
scripts/OnBoot/,scripts/OnLoad/, andscripts/OnKey/. It also generates alua_loader.iniconfig file inscripts/, next to the.asi— as of v0.5.0, that file is no longer shipped in the release zip. The generator itself isn’t new (it only runs when no ini is present yet), but shipping a stub ini alongside it used to silently suppress that check, so a fresh install previously got a stripped ini referencing sample scripts that weren’t even in the zip, and never showed the generator’s commented VK-code reference. A fresh v0.5.0 install now gets that full, fully-commented ini instead. This only affects fresh installs — an existinglua_loader.inifrom an earlier version is left untouched.
Verified working against pmc_bb.dll v0.2.0. If a newer loader breaks compatibility, that’ll get called out here.
Two ways to run code
1. The REPL (fastest for iterating)
lua-bridge listens on 127.0.0.1:27050 by default (configurable in lua_bridge.ini). It only accepts loopback connections — this is a hard security restriction, not just a default, so don’t expect to reach it from another machine.
Protocol: open a TCP connection, write your Lua chunk, then write the literal line <<<RUN>>> to mark the end of the chunk. The bridge answers [queued] straight away, runs the chunk on the next engine frame it gets a pump opportunity on, and writes the result back followed by a literal <<<END>>> line. Those two replies come from different threads — the acknowledgement from the socket thread, the result from the game thread once the chunk actually executes — which is what made the pre-v0.5.1 transport bug described below possible.
You don’t need to hand-roll this — use tools/lua_console.py (interactive) or tools/lua_repl.py (scriptable) from the parent project. But it’s worth knowing the wire format if you’re writing your own tooling (e.g. driving the game from a build script or a bot).
If you’re working in the Ess/lua-bridge ecosystem specifically, mercs2-lua-essentials/tools/lua_repl.py is a separate, newer script with the same name and a different design: instead of reading the answer off the socket, it wraps your chunk in a pcall that reports its own result through Loader.Printf tagged with a per-call random nonce, then polls lua_loader_printf.log for that tag. The socket is used only to send the code; whatever comes back on it immediately is surfaced as advisory. It also adds --probe (bridge up/down), --log-size, and --wait-log TEXT (block until a string like "[Ess]" appears, to confirm OnLoad actually ran). See that repo’s own tools/README.md for the full flag list.
That design was a response to a real bug — the tool’s own header calls anything read off the socket “advisory, possibly-stale” because, on the builds it was written against, it genuinely was. On lua-bridge v0.5.1 and later that rationale is historical: the socket is a trustworthy result channel again (see the upgrade note below). The tool still works and is still worth using — --probe reports whether the bridge is accepting connections at all without running a chunk, --wait-log reports whether OnLoad has actually finished (something no reply on the result channel could ever have told you), and a chunk’s own Loader.Printf output lands in that log regardless — but read its log-polling as a deliberate feature now rather than as a workaround for a live defect.
That same repo’s tools/webrepl.py + tools/webrepl.html turn this raw-TCP protocol into a browser tool: since a browser page can’t open a raw TCP socket itself, webrepl.py runs a tiny local HTTP relay (reusing lua_repl.py’s own request/response handling) that webrepl.html talks to — a page with a grid of one-click actions plus a free-form Lua box and a live bridge-status indicator, bound to localhost only. This is a different mechanism from the browser-based Lua Web IDE or the WebSocket transport it uses — webrepl stays entirely on the original raw-TCP wire format above, just relayed through HTTP so a browser can reach it. The HTTP↔bridge relay path itself is verified end-to-end (the page serves, /probe//exec respond correctly); an actual live game round-trip through it needs the game running to exercise.
Reading the response:
| Prefix | Meaning |
|---|---|
[ok] | Your chunk ran and returned normally (pcall succeeded). Return values follow, tab-separated. |
[runtime] | Your chunk ran but errored at runtime. Return values (usually just the error message) follow, space-separated. |
[compile] | Your chunk failed to compile — syntax error. The message follows. |
[bridge] ... | Something went wrong in the bridge itself before your code ran at all (see table below). |
Return values are formatted per-type: nil, true/false, numbers via %g, strings in "quotes", tables as <table>, functions as <function>. Anything else shows as <tt=N val=0xADDRESS> — you’re looking at a raw engine type the formatter doesn’t special-case, use type() / Loader.Printf(tostring(...)) from within your chunk if you need more detail on it.
If you’re upgrading from before v0.5.0: two REPL result bugs were fixed
Two long-standing correctness bugs in the executor were fixed in lua-bridge v0.5.0, and this page — since it documents the raw wire protocol’s result format directly — is exactly where you’d run into their symptoms.
Every chunk execution corrupted the hooked function’s stack frame, from when the executor first shipped until v0.5.0. This build’s luaB_pcall writes its pcall status to the wrong stack slot — saved_base[0], the base of the frame belonging to the hooked C function the bridge is executing inside, not the chunk’s own frame. Because each hooked function calls the real original afterward, that original then read a corrupted argument. Concretely, and confirmed live: a type(x) call routed through the hooked type function would answer "boolean" regardless of what x actually was, for any stock game script unlucky enough to share a detour call with a chunk execution around the same moment. This was proven across 8 live samples, and the fix’s own commit calls it “a plausible contributor to the intermittent misbehavior the watchdog was built to survive.” Fixed by snapshotting and restoring the affected frame slots around every execution.
The [ok] label above was unreachable — every result, including successful ones, was mislabeled [runtime] with a junk <table> prefix, on every single execution since the executor shipped. The code was looking for the pcall status at the wrong memory location (one that always held a table on this build, matching neither the boolean nor the number branch the label logic checked), so the “did this succeed” signal was never actually read correctly. v0.5.0 recovers it from the correct location, confirmed by correlating against known-good and known-bad chunks. Smaller side effect: the result formatter separates values with a tab on success and a space on failure — with success permanently mis-detected as failure before the fix, every result used a space; from v0.5.0 on, a genuinely successful result uses a tab as intended. If you were parsing this output by splitting on whitespace generically, that doesn’t affect you; if you were specifically splitting on tab, behavior changed.
If you were running any lua-bridge build before v0.5.0, treat a type() result or an [ok]/[runtime] label from that period as potentially unreliable. Both are fixed and verified live as of v0.5.0 — 12 stack-layout probes plus a known-good/known-bad correlation pass. This isn’t a warning about code running now — v0.5.0 fixes both going forward — it’s here so any intermittent-seeming misbehavior you saw on an older build has an honest explanation, and so anyone still on an older build knows to upgrade.
New to Lua? Click to expand
That list (nil, true/false, numbers, strings, tables, functions) is the entire set of value types in Lua — there’s nothing else. A few that trip people up coming from other languages:
nilisn’t the same asfalse.nilmeans “nothing here” (an unset variable, a missing table field);falseis a real boolean value. Both make anifstatement take the “else” branch, which is why they’re easy to conflate, buttype(nil)andtype(false)are different strings.- Numbers are just “numbers.” No separate
intvsfloattypes to worry about — see the float-vs-double gotcha below for how this specific game stores them internally, but at the Lua language level,5and5.0are the same kind of thing. - A table can hold anything, including functions. That’s why
MrxPmc.AddCashQty(...)works the way it does —MrxPmcis a table,AddCashQtyis a function stored as one of its fields, and.reads that field before calling it.
If a chunk you write returns a table and you want to see what’s actually in it (not just the useless <table> the REPL shows), loop over it yourself: for k, v in pairs(t) do Loader.Printf(tostring(k) .. " = " .. tostring(v)) end.
One gotcha worth knowing: this build of the engine’s Lua uses float, not double, for lua_Number. Precision-sensitive math (large integers, tight epsilon comparisons) can behave differently than you’d expect from a stock Lua 5.1 interpreter. If a number “should” be exact but isn’t, this is usually why.
[bridge] errors mean the bridge couldn’t even get your code to Lua:
| Message | What it means |
|---|---|
no L | The bridge hasn’t captured a live Lua state yet (too early in boot). Wait and retry. |
L failed validation | The bridge has a Lua state pointer but it doesn’t look like a valid one right now — usually a transient timing issue, retry. |
empty chunk | You sent nothing before <<<RUN>>>. |
chunk too large | Your chunk is at or past the 1MB buffer limit. Split it up. |
executor fn pointers not resolved | The bridge couldn’t resolve the engine’s internal Lua exec functions for this game binary — a build-compatibility problem, not something you can fix from Lua. |
If you’re upgrading from before v0.5.1: the socket result channel was one execution behind
On every build before lua-bridge v0.5.1, a result read straight off the raw socket could belong to the previous chunk — and once a connection was one behind, it stayed one behind for every request it made after that. This is the bug mercs2-lua-essentials’ lua_repl.py was rewritten around.
The cause is the thread split described under Protocol above. g_outBuf, the bridge’s single raw-TCP output buffer, carried no association between a result and the connection that asked for it, and results are produced asynchronously, later, on the game thread. v0.4.0 added a buffer clear at accept time, which could not fix it — the race isn’t stale data sitting in the buffer when a client connects, it’s a result arriving after the next client has already connected:
conn A submits chunk N, reads [queued], disconnects
conn B is accepted -> g_outBuf cleared (nothing pending yet)
the pump finally runs N -> result_N appended to g_outBuf
conn B's flush -> B receives A's result, then its own
B reads to the first <<<END>>>, takes A’s answer as its own, and is one execution behind from then on.
v0.5.1 fixes it at the source. Each queued chunk is now tagged with the raw-TCP session id of the connection that submitted it — a counter incremented on every accept. The pump writes a result to the socket only if that session is still the current one (i.e. no newer connection has been accepted since); a result whose requester has gone is dropped and logged — dropped result for session N (client gone; current session N+1) — rather than misdelivered to whoever connected next. The accept-time clear stays, and the two cover different halves of the same problem: the clear handles output already sitting in the buffer, the session id handles output that hasn’t been produced yet. Verified live by reproduction: four chunks submitted with lua_repl.py’s close-early pattern produced exactly two of those drop lines — the precise condition that used to cause a misdelivery — with zero cross-request leakage.
Behavior change worth knowing: OnKey results no longer reach the raw-TCP channel. Pressing a hotkey while a REPL was connected used to inject an unsolicited result into that client’s stream, which is the same desync by a different route. A loader-fired OnKey chunk is queued with no session id at all, so from v0.5.1 its result is simply not written to the socket (silently — the drop line above is logged only for a stale client session). An OnKey script’s result still appears in lua_bridge_DEV.log, and anything the script prints for itself with Loader.Printf still reaches lua_loader_printf.log and the WebSocket {"type":"log"} feed exactly as before. OnBoot/OnLoad are unaffected in practice: they run synchronously through the loader rather than through the chunk queue, so their results never travelled this channel — since v0.5.0 they go to the Loader.Printf log and the WebSocket feed.
2. The script loader (for anything that should run automatically)
Drop .lua files into one of three folders under scripts/:
scripts/OnBoot/— runs once, as early as possible, the moment the bridge captures a live Lua state. Good for one-time global overrides, injecting your own helper functions into_G, etc.scripts/OnLoad/— runs once per level load, at the point the game reaches theGlobalExit - Completemilestone (control has returned to the player). Good for HUD tweaks, spawning things, starting per-level logic.scripts/OnKey/— not run automatically; each script is bound to a hotkey and runs (once, edge-triggered) every time you press it. Good for debug toggles and manual triggers.
Scripts in OnBoot/OnLoad run in an order controlled by lua_loader.ini, auto-populated (in increments of 10, alphabetical by default) the first time the loader sees a new script — edit the numbers in that file to reorder. scripts/OnKey/*.lua = <keyname> in the same file binds hotkeys; a script can also declare its own default by putting local KEYVAL = "keyname" somewhere in its first 10 lines, which the loader reads before ever running it.
OnKey scripts are re-read from disk on every keypress (via a dedicated background thread polling at 30Hz, so it doesn’t stall the game’s main thread) — edit-and-repress works without restarting.
As of lua-bridge v0.2.1, rapid double-presses of the same hotkey (within 250ms by default) are automatically throttled to one run instead of queuing two back-to-back — see Loader: OnKey dispatch behavior for the full mechanics and how to disable it.
Loader.Printf — debug output that doesn’t get lost
Don’t reach for the engine’s own Debug.Printf to print your own debug messages. It’s the game’s original debug-print function, called thousands of times a second from all over the base game’s own scripts — and it’s also the exact function lua-bridge itself hooks into as one of its capture points. Anything you print through it is buried in that noise with no clean way to filter it back out.
Every script instead has access to a global:
Loader.Printf(message)
Same idea as Debug.Printf, but it writes only to its own dedicated file — lua_loader_printf.log, in scripts/ next to the .asi — instead of the shared, noisy engine log. Everything in that file is something a script explicitly asked to log; nothing from the base game leaks in. Use this for anything you actually want to find again later.
Tcp.Send — fire-and-forget telemetry
Every script (REPL or loader) has access to a global:
Tcp.Send(host, port, message)
Fires a one-way TCP message. Restricted to 127.0.0.0/8 (localhost) destinations — this is intentional, to stop a script from port-scanning your LAN or phoning home over the internet. Useful for piping game state to a local companion tool (a logger, an overlay, a second bridge instance) without building that plumbing into the engine hook itself.
What can I actually call?
Once you can get code running, the next question is what’s there to call. That’s what the rest of this wiki is for — reference docs for the game’s own Lua modules (resident/), covering the object model, lifecycle hooks, and the engine’s built-in namespaces (Object, Event, Player, Marker, etc. — always global, no setup needed) as well as the resident/ modules themselves (MrxPmc, MrxTransit, and 226 others — these need an import("Name") call before use outside their own file, see the Glossary). Start with Resident Modules — its landing page explains the game’s module/inheritance pattern before you hit the per-module pages — or jump straight to a specific module if you already know what you’re looking for.
Troubleshooting checklist
- Nothing happens when I connect to the REPL → confirm
lua_bridge.asiis actually loaded (check the pmc_bb.dll loader’s own log) and thatlua_bridge.ini’s[repl]section wasn’t edited to a different port. [bridge] no Lforever → the bridge hasn’t seen the Lua VM yet. Get further into the game (past the main menu) and retry.- The REPL keeps answering my previous command → you’re on a build before v0.5.1. Upgrade; see the note above.
- My
OnLoadscript isn’t running → it only fires once theGlobalExit - Completemilestone is hit, i.e. after a level has actually finished loading, not on menu load. If you editedlua_loader.iniby hand, make sure the section header is exactly[OnLoad]. - My hotkey isn’t firing → check
lua_loader.ini’s[OnKey]section has your exact filename mapped to a recognized key name, or thatlocal KEYVAL = "..."is within the first 10 lines of the script.