BFME2 LuaDEVELOPER WIKI
Guide / Lua 5.4.9

BFME2 Lua wiki

The scripting reference for this project's Lua integration with The Battle for Middle-earth II. Browse the pages on the left, press / to search every section, and copy examples straight into the console. The wiki works offline with no server.

BFME2Lua embeds Lua 5.4.9 in the game process. Scripts can read and change templates, players and live objects; react to deaths, selection, saves and other events; drive menus, command buttons, the camera and named engine actions; open native dialogs; and sync saves to your own server. Every engine call is made on the game's own update thread and is guarded by byte signatures for the installed executable.

First steps

  1. Start the game normally with lotrbfme2.exe (see Installation & launch if autoload is not installed).
  2. Press backtick, or Ctrl+Shift+C, to open the in-game console.
  3. Start a skirmish before inspecting players or live units.
lua
print(bfme.getLiveDataStatus().ready, bfme.getLiveDataStatus().localPlayerId)
print(bfme.players.localPlayer and bfme.players.localPlayer.money)
print(bfme.getMenuContext())

The console prints returned values. Globals persist until the game exits; Shift+Enter adds a line. The Log tab shows output from every script.

Find a type, then inspect an instance

lua
for id, u in pairs(bfme.unitTypes) do
    if u.faction == "Mordor" then
        print(id, u.displayName, u.cost)
    end
end
lua
selected = bfme.getSelectedObject()
if selected then
    print(selected, selected.typeId, selected.ownerPlayer, selected.level)
    print(selected.position.x, selected.position.y, selected.health)
end

Actual unit IDs and faction spellings come from the loaded game data. Example names are illustrative; enumerate the catalogs before relying on one.

Change data

lua
ticket = bfme.players.localPlayer:set('money', 10000)
print(ticket)

On a later game update:

lua
print(bfme.getMutationStatus(ticket)) -- "applied"
print(bfme.players.localPlayer.money)

Writes are queued for the native game update. A ticket is not a completed operation. applied means the mutation ran; for training it means the request entered the production queue.

Write a script

Save this as dev/hello.lua, then press Ctrl+Shift+F5 in the game window:

lua
local bfme = import("bfme")
return {
    onGameStarted = function()
        bfme.log("Match started")
    end,
    onUnitKilled = function(unit, owner)
        bfme.log(unit.displayName .. " died (player " .. unit.playerId .. ")")
    end,
    onInput = function(event)
        if event.key == "F8" and event.pressed and not event.repeatKey then
            local p = bfme.players.localPlayer
            if p then p.money = p.money + 1000 end
        end
    end,
}

Every .lua file directly in dev/ is an entry script with its own VM. Put shared helpers in dev/lib/ and load them with require. See Scripts & communication and Callbacks & events.

Read the right ID

IdentifierExampleUsed by
Template ID"GondorSoldierHorde"unitTypes[id], building:spawnUnit(id)
Live object ID1234unitInstances[id], buildingInstances[id], event id
Engine player slot0..19Players, money, owner filtering, playerId
Team number1..19player.team, defeat(team), victory(team)
Controller index0..3getGamepad(index)
Menu action ID"mainmenu.Skirmish", "AptOptions::Cancel"pressMenuButton(id)
Command button IDOpaque integer from the current snapshotPress, hover and highlight
Command definition IDINI namebfme.commandButtons[id]
Builder preview IDOpaque integer from the construction snapshotsetBuilderBuilding(id)
Power ID or nameInteger or SCIENCE_... nameaddPower, removePower
Dialog request IDIntegergetDialogResult(id), onDialogResult
Mutation ticketIntegergetMutationStatus(ticket)

unitInstances[playerId] looks up an object, not a player; call unitInstances(playerId) to filter by owner. Controller indices and engine player slots are unrelated. defeat(2) means team 2, not player slot 2.

What is where

NeedPage
Every function in one listFunction reference
Callback order, event fields, queue limitsCallbacks & events
Templates, players, money, CP, alliancesTemplates & players
Units and buildings on the mapLive units & buildings
Build time, siege-only, levels, CommandButton INI dataDefinitions, levels & command data
Pause, powers, AI, defeat/victoryMatch control, powers & defeat
Message boxes, text input, JSONDialogs & JSON
Keyboard bindings and named actionsKey bindings & native actions
Controller mappingController & input
Save syncEncrypted save sync
Install, launch options, startup logInstallation & launch
Build on Windows, macOS or LinuxBuilding from source

Current scope

This wiki documents implemented bindings, not planned engine access. Lua 5.4.9 runs in a 32-bit DLL; LuaJIT is not used. Each subsystem is enabled only when its signatures match the installed executable, so check BFME2Lua.log if a function reports that it is unavailable.

Features are built and fixture-tested, but some are not yet confirmed in a live match. Hero/building command UI highlighting and activation through the controller's RT (Palantir) menu is the main known problem. See Verification status.

Guide / Lua 5.4.9

BFME2Lua mod change list

Snapshot: 2026-10-08. This inventories the current workspace, bundled scripts and recent content work; it is not a comparison against an identified release tag. Implementation, installation and live verification are separate. Existing BT2DC/1.09 balance changes are dependencies, not changes authored by this mod.

Controller and camera

  • Xbox-style controller layer with editable contextual bindings and a master enable switch.
  • Native pointer messages for selection, orders, placement and interface clicks.
  • Left-stick camera pan, right-stick camera rotation and zoom; adjustable speeds,

inversion, dead zones and alternate stick modes.

  • Xbox 360 cursor artwork and a centered in-match reticle; normal cursor navigation

returns in menus. Cursor size and animation rate are configurable.

  • Native contextual A/B behavior, unit double-click and attack-move gestures.
  • Selection shortcuts for matching units, all units, builders, heroes and control groups.
  • Home-base, last-radar-event, follow-selection and camera-reset actions.
  • Shoulder-button selection modifiers and waypoint behavior.
  • RT Palantir navigation for units, selected buildings and builder construction choices.
  • D-pad command navigation, command highlights, activation and queued-unit cancellation.
  • Optional stick-sector navigation; right-stick camera movement remains available.
  • Controller building-command row and stock/native layout switch.
  • Controller UI suppression for stock command windows and the builder side bar.
  • Menu-aware input routing so RT commands do not leak into map clicks.
  • In-game Escape-menu navigation through native menu actions, including return,

save/load-related actions when available, and Start handling.

  • Double-Start exit gesture, restricted by menu/match context.
  • Game focus and console capture clear held input to prevent stuck commands.
  • F8 native next-builder selection and editable keyboard bindings.

Menus and local settings

  • Native menu-context inspection, named menu actions and button dispatch.
  • Custom in-game settings/cheats menus navigable through the controller layer.
  • Persistent selection assistance, camera rotation speed, controller enable,

rotation inversion and centered-cursor settings, with restore-defaults support.

  • Selection assistance radius is configurable, including off.
  • Offline cheat actions include resources, power points, command-point limits,

selected-unit levels, type build limits/cost/production speed, self-building and opposing AI control. Availability depends on context and bindings.

  • Native message/input dialogs with asynchronous results and cancellation.
  • UI layout controls and standalone command-button primitives exposed to Lua.

Walls and construction

  • Native walls, hubs, gates, wall upgrades and defensive-wall types default to

siegeOnly = true; other building types default to false.

  • Explicit per-building-type siegeOnly = false overrides the wall default.
  • Default attacker classification is CAN_ATTACK_WALLS. Wall-capable rams,

artillery, monsters and Battlewagons can therefore damage protected walls.

  • setWallDamageMode('siegeEngine') selects strict SIEGEENGINE classification;

setWallDamageMode('canAttackWalls') restores the default. Getter provided.

  • Healing, unresistable damage and damage without a resolvable unit source retain

their native handling. Allowed attacks still use native armor/damage rules.

  • Separate health multipliers for walls, hubs and gates through

setWallHealthMultipliers(walls, hubs, gates) and a getter.

  • Health values default to 1.0, accept 0.01..100 and scale original template

maximum/explicit initial health without compounding. The native -1 initial health default is preserved. Existing objects are not healed or resized.

  • Gates take precedence over hubs when flags overlap. Other wall pieces include

cliff caps and wall upgrades. Shared native body data shares resulting values.

  • Editable startup health settings in dev/wall_health.lua.
  • Intermediate hubs can be disabled in new spans; the bundled wall_segments.lua

currently disables them. Existing spans are unchanged.

  • Optional removal of the spare-segment clearance requirement beside wall gates.

Actual gate-width obstruction, other blockers and upgrade prerequisites remain.

  • Shared building location checks can be disabled/restored, with a getter.

This bypasses terrain/obstruction/clearance checks, not costs or prerequisites, and does not remove independent wall-span checks.

  • Native construction requests: startBuild, worker :build, expandWall and

building :expandWall, with request tickets and rejection reasons.

  • Construction state snapshots, start/finish events and builder identity.
  • Self-building and object destruction primitives support Lua builder-consumption

rules while using native construction completion behavior.

  • The current bundled construction.lua sets started building types' buildTime

to 5 seconds and consumes builders for names containing Barracks, then requests self-building. This is active script behavior, not merely an API capability.

  • Preview permission checks suppress unavailable construction choices while

retaining native placement feedback for allowed choices.

  • Wall-health, attacker-mode, hub-pattern and gate-gap setting changes are

restricted during network matches; configure identical clients beforehand.

Temporary Isengard wall tower

  • Added a separate BFME2LuaWallTower.asset.dat and native supplemental cache

loader. New model assets are registered after the stock index without rewriting it; logs report parsing and all five registrations. The stock index lacked the custom model, explaining the null creation result. Live rendering remains to be confirmed after restart.

  • New wall-segment upgrade option using the fortress arrow-tower icon in slot 3.
  • Native OBJECT_UPGRADE / ReplaceSelfUpgrade replaces a completed segment with

BFME2LuaIsengardWallTower; outer wall segments inherit the option.

  • Existing hub/gate upgrades remain and are mutually exclusive with the tower.

This option is for wall segments, not existing hubs or gates.

  • Uses the existing tower's weapon, armor and base health; installed data constants

give a 400 cost and 20-second construction time.

  • Fortress-foundation requirement and CastleMemberBehavior are removed from the

temporary clone. The regular IsengardTowerExpansion is unchanged.

  • Retains a wall-segment collision box and is classified WALL_UPGRADE, so the

automatic wall damage policy and wall health multiplier apply.

  • Combined static W3D merges IBWallN and IBFITower. The tower assembly is rotated

90 degrees and its footprint centered on the wall segment.

  • Original color texture/UVs retained; editable Blender file embeds the texture.
  • External BFME2LuaWallTower.big now includes and references the merged model.
  • Construction, damage and rubble visual variants are not merged: the temporary

draw module uses the intact static model across visual states.

  • Data overlay is based on installed 1.09v3 wall definitions and 1.09v3.01 command,

command-set and upgrade files; it must be rebuilt for other data versions.

  • Corrected package is installed as its own !!!!bfme2lua-isengard-wall-tower.big

beside game.dat for normal launches. Existing game archives are not modified.

  • An external copy and optional -mod launcher also remain under

C:\BFME2Lua\mods\WallTower; normal launches do not require that launcher.

  • Same content BIG is required on multiplayer peers; Lua/DLL room comparison does

not compare game archives. Saved matches containing the custom object may require retaining this package.

Textures and other content

  • BFME1 Gondor build-plot textures extracted with a preview gallery and mappings

for matching Men building types.

  • Lua floor/flat-plot texture override bindings, including clearing overrides.
  • Gondor plot replacements are currently disabled in dev/gondor_plots.lua

following purple/missing texture failures. Native floors remain the default.

  • Video start notifications and play/stop/status bindings.
  • Bundled skip_videos.lua stops current playback and subsequent videos.

Runtime and Lua API

  • Native 32-bit DLL with statically linked Lua 5.4.9 and signature-gated hooks.
  • Normal-startup autoload, legacy injection/attach launchers and safe restart-based

DLL updates. Lua reload is available through Ctrl+Shift+F5.

  • Script loading from sorted top-level dev/*.lua files with modular libraries.
  • In-game console, command history, Log view and named-pipe terminal console.
  • Copied snapshots rather than exposing native pointers; writes/actions queued

for native update and revalidated before execution.

  • Template catalogs, building/unit definitions and durability editing.
  • Runtime cost, production time, build limits, initial levels and command data

editing; effects depend on native support and the target field.

  • Native player resources, command points, handicap, power points and diplomacy.
  • Live unit/building handles, selection, owner, position, health, level, construction

state and native supported actions. Arbitrary position/health setters are not implemented simply because those fields can be inspected.

  • Science/power inspection and unlock/lock controls; unlocking a foreign science

does not manufacture missing spellbook buttons or ability modules.

  • Native pause, AI strategy suspension/resumption, defeat/victory primitives.
  • Offline defeat policy in dev/victory.lua; campaign/network behavior stays native.
  • Death/destruction, construction, video, selection, pause, defeat, save/data and

shared-value notifications. Event arguments are copied snapshots.

  • Shared VM values/messages, JSON utilities and mutation-status tickets.
  • Partial, separately guarded BFME1/RotWK profiles; full BFME2 feature parity is

not claimed for those games.

Multiplayer, online services and saves

  • Simulation-boundary Lua callbacks and queued multiplayer mutations/messages

for consistent native execution across clients.

  • Room compatibility check compares SHA-256 of the loaded Lua DLL and all dev

Lua scripts, including libraries and relative filenames.

  • Lua hash normalizes CRLF/LF. INI/text settings, BIG assets and patch versions

are not part of this comparison.

  • LAN room mismatch warnings identify DLL/Lua differences; unanswered peers

become unverified after eight seconds. Warnings do not block match start.

  • Version fingerprint badge on relevant frontend network screens.
  • Network interface selection/restoration and diagnostics for native endpoints.
  • Online service gateway/TLS relay and server-side service infrastructure.

Native online login/lobby and internet-match compatibility remain separate acceptance gates; infrastructure is not a general desync fix.

  • Native match/CRC monitoring and diagnostics to investigate divergence.
  • Optional encrypted save/data synchronization on a worker thread, configuration

required. Bundled script pulls when enabled and queues uploads after changes.

  • Conflict/validation handling and server-side storage; local Python is not

required by the game client.

Performance, FPS and rendering stability

  • Native experimental 60 FPS client target on the supported BFME2 executable.

Scheduler mapping preserves the original full logic-frame rate.

  • getFrameRateStatus exposes installation status, target and update counters;

frame-rate.txt containing 30 restores stock timing after restart.

  • Always-visible top-left measured FPS counter, refreshed every half second;

restarts measurement after long rendering pauses. Log overlay remains optional.

  • Detailed hook/frame/Present timings, cumulative totals, peaks and memory-query

counts; reset function and periodic performance reports.

  • Demand-driven catalog, instance, selection, command and preview scans with

leases, cached data and reduced redundant scans/allocations.

  • Wine keyboard fallback capped at 125 Hz while controller motion remains per

rendered frame. Disconnected controller slots retry once per second.

  • Memory validation shared within eligible read scopes and reduced redundant

network-interface protection queries/writes.

  • TLS relay batches already-queued frames without a batching delay, preserves

order/framing, retains buffers and wakes workers on queue transitions.

  • Datagram worker wakeups and incremental framed-stream/TLS parser compaction.

Encryption and certificate pinning remain enabled.

  • Cached D3D device/window state and reused overlay state blocks.
  • Native render-buffer recovery after loading transitions/device reset.
  • Additional terrain-pass recovery for the null index-buffer lock found in an

alt-tab crash dump; failed allocation skips/retries that render pass.

  • D3D overlay/font resources participate in device-loss/reset handling.

Validation and outstanding issues

  • Recent native definition tests cover attacker-mode selection, wall classification,

health scaling, repeated calls, category priority and baseline restoration.

  • Live-data regression tests pass after wall health integration.
  • Construction fixtures/native guards cover gate-gap changes. Buffer-recovery

fixtures/native guards cover the diagnosed alt-tab paths.

  • Merged W3D reimport preserves 1,262 triangles and its bounds; archive/model

references and native replacement wiring are checked.

  • These checks do not establish in-game wall-tower construction, attack behavior,

visual states or multiplayer synchronization. Live testing remains required.

  • Repeated in-game alt-tab and 30/60 FPS game-speed/multiplayer validation remain.
  • Wall leakage near a Men end hub/terrain connection is unresolved. Live snapshots

show completed western cliff caps, but the exact leaking endpoint was not selected.

  • Selected-building tooltip and controller wall-hub expansion issues remain

reported investigations; no blanket claim that those issues are resolved.

  • Comprehensive Lua docs rebuilt, with wall APIs, frame-rate/performance fields,

buffer recovery and source-linked reference pages.

  • Several recent source changes are still uncommitted in the current workspace.

Installed DLL/package versions and the running process can differ until restart.

Host-authoritative wall and fortress balance

  • Added dev/gameplay.ini with wall and fortress sections, replacing Lua defaults.
  • Fortress cores support normal, siege-engine-only, and CAN_ATTACK_WALLS-only damage.
  • LAN clients adopt the native room host rules before play, acknowledge them before

the host starts, and restore local settings on exit. Different INIs are allowed.

  • DLL and Lua compatibility checks remain. Live two-PC verification is pending.
Source: MOD-CHANGES.md · Documentation for the current workspace bindings.
Guide / Lua 5.4.9

Scripts and communication

Entry scripts

Every .lua file directly in dev/ is an entry script. Entry scripts load in sorted filename order when the game window is first hooked, and again on Ctrl+Shift+F5. Each one runs in its own Lua 5.4 VM with its own globals and module cache, and must return a table of optional callbacks (see Callbacks & events):

lua
local bfme = import("bfme")
local count = 0
return {
    onFrame = function(dt)
        -- dt: seconds between rendered frames, not a simulation tick
    end,
    onInput = function(event)
        if event.key == "F8" and event.pressed and not event.repeatKey then
            count = count + 1
            bfme.log("F8 pressed " .. count .. " times")
        end
    end,
}
  • A syntax error, runtime error during load, or a non-table return skips that file and logs why. Other scripts still load.
  • Each callback call has a budget of one million Lua instructions; exceeding it raises Script instruction budget exceeded. The budget cannot interrupt a blocking native call.
  • Ctrl+Shift+F5 destroys every entry VM and loads them again, clearing globals. It also releases held controller/pointer input and clears key-binding routes until dev/bindings.lua reinstalls them. Shared values and mailboxes survive.
  • The standard libraries (io, os, debug and so on) are available. This is a trusted developer environment, not a sandbox.
  • require searches dev/?.lua and dev/?/init.lua; binary modules are disabled. Put helper modules in subfolders so they are not loaded as entry scripts.

For multiplayer gameplay, use onSimulationTick and synchronized messages as described in Multiplayer Lua. Existing shared values and mailboxes communicate within one game process; they do not synchronize clients.

File layout

Files below dev/lib are require modules and are not loaded as independent scripts. require shares a module only within one VM.

FileResponsibility
dev/controller.luaController coordinator: one snapshot, input routing and module update order
dev/bindings.luaConfigurable keyboard action matching and native input ownership
dev/lib/keybinds.luaEditable keyboard overrides, controller action names, custom Lua functions and session rebind helpers
dev/lib/commandmap_defaults.lua93 keyboard defaults, including six bare modifier transitions
dev/lib/console.luaConsole library startup, command evaluation, formatting and reload
dev/victory.luaOffline skirmish defeat policy using onBuildingDestroyed and the engine's remaining-eligibility result
dev/lib/controller/config.luaController index, speeds and deadzones
dev/lib/controller/controls.luaNative A/B pointer state, RT, named actions, Skirmish navigation and double-Start
dev/lib/controller/motion.luaAnalog stick response, cursor movement and camera pan
dev/lib/controller/palantir.luaRT (Palantir) navigation, sector selection, D-pad stepping and shortcuts, hover/highlight, A activation and X/Y/B shortcuts
dev/builder.luaBuilder hotkey entry point
dev/lib/builder_hotkey.luaCurrent custom F8 resource/command-point callback
dev/lib/command_buttons.luaVisible-command inspection and activation wrapper
dev/examples/menu_context.luaInspect the focused native menu and its registered action callbacks
dev/lib/messaging.luaCross-state communication wrapper
dev/lib/json.luaJSON parse/stringify, loaded as the global JSON in every VM
dev/save_sync.luaStarts encrypted save sync and uploads detected saves
dev/examples/definitions.luaProduction time, siege-only and command-definition edits (not auto-loaded)
dev/examples/dialogs.luaNative input/message box on F9 (not auto-loaded)
dev/examples/json.luaJSON round trip (not auto-loaded)
dev/examples/video.luaLog video starts; F11 plays and Ctrl+F11 stops a video (not auto-loaded)
dev/examples/construction.luaLua policy: builders vanish into the buildings they start (not auto-loaded)
dev/third.luaExisting placeholder/scratch entry script

The controller calls the Palantir (RT) module first and ordinary controls second. Both receive the same pad snapshot, so RT navigation cannot accidentally generate competing map clicks. Helpers do not run callbacks automatically; their entry point calls them. Edit config.lua for speeds, controls.lua for ordinary mappings and palantir.lua for RT menu behavior. Ctrl+Shift+F5 recreates all entry-script states and reloads their required modules.

Bare CTRL, SHIFT and ALT bindings now route their down/up transitions through Lua, including native paired release on focus loss, console capture or reload. Their exact single-modifier semantics match CommandMap; combined masks release the prior single-modifier mode. Contextual unit/building hotkeys and LookAt camera shortcuts remain native. Their verified paths are documented in NATIVE_INPUT_MAP.md beside the project sources.

Shared named values

Use shared values for current state that multiple scripts or the console need to inspect. Every read and write copies the value; there are no cross-VM table references.

lua
local bus = require("lib.messaging")
assert(bus.set("my_mod.settings", {enabled = true, speed = 600}))

In another entry script or the console:

lua
bus = require("lib.messaging")
settings = bus.get("my_mod.settings")
if settings then print(settings.enabled, settings.speed) end

Mutating settings does not update the stored value. Call bus.set again to replace it. bus.set(name, nil) deletes a key; bus.get returns nil for a missing key. False is a valid stored value. Replacement of one key is atomic; a get/change/set sequence is not an atomic transaction across scripts.

Message mailboxes

Use mailboxes for commands or one-time events. Give the receiving script a namespaced inbox. A successful send stores the message for later receipt; it does not execute a callback.

lua
local bus = require("lib.messaging")
local ok, reason = bus.send("my_mod.worker.inbox", {
    action = "inspect_unit",
    objectId = 1234, -- use an ID from the current instance registry
})
if not ok then import("bfme").log(reason) end

The receiving entry script:

lua
local bfme = import("bfme")
local bus = require("lib.messaging")
return {
    onFrame = function(dt)
        for _, message in ipairs(bus.receive("my_mod.worker.inbox")) do
            local payload = message.payload
            if payload.action == "inspect_unit" then
                local unit = bfme.unitInstances[payload.objectId]
                if unit then bfme.log(tostring(unit)) end
            end
        end
    end,
}

receive drains all currently queued messages for that channel in FIFO order and returns a dense list. Each record is {id=integer, payload=value}; id is a process-wide increasing sequence number. Empty mailboxes return an empty list. Multiple readers of the same channel compete: this is a mailbox, not a broadcast subscription. Send to separate inboxes for separate receivers, or use shared values for state many readers need.

Entry callbacks execute in sorted filename order. A send before the receiver's callback can be handled in that frame; a later send waits for its next callback. Console sends can arrive concurrently. No Lua function executes while a native mailbox lock is held.

Functions

Native functionWrapperReturns
bfme.setShared(name, value)bus.set(name, value)true or false, reason
bfme.getShared(name)bus.get(name)Copied value or nil
bfme.sendMessage(channel, payload)bus.send(channel, payload)true or false, reason
bfme.receiveMessages(channel)bus.receive(channel)Dense list of Message records; drains mailbox

bfme.getSharedRevision(name) returns a key's revision (0 when absent) without copying the value, and every change fires onSharedChanged(event) with event.key and event.revision in every entry script. React to that instead of polling in onFrame.

Values, limits and lifetime

Supported values are nil (shared-value deletion only), booleans, finite numbers, strings and plain nested tables with string/integer keys. Lua integers retain their integer representation. Functions, threads, userdata, tables with metatables, cyclic tables and non-finite numbers are rejected. Send unit/building IDs, template names or player slots and reacquire live objects in the receiver.

Names are 1..128 bytes without NUL. Each copied value allows up to 16 nesting levels, 1024 value nodes (including table keys) and 65,536 total string bytes. The shared store holds 128 named values. Mailboxes permit 128 total pending messages and 64 active channels. Full/oversized writes return false with a reason rather than overwriting or dropping a queued message. Receiving frees mailbox capacity. Invalid names or missing arguments raise Lua errors.

Values and messages are process-wide, shared by all entry scripts and the console. They survive Ctrl+Shift+F5, console reconnects and match transitions, and clear when the game process ends. Namespace keys/channels, delete obsolete shared values explicitly and drain old mailboxes if needed. Stored object IDs may become stale across matches.

Built-in shared state

The controller publishes controller.settings when loaded. Its Palantir module publishes controller.menu when active state, command list or selection changes. The builder hotkey publishes builder.lastRequest on F8. These values are informational snapshots; editing them does not reconfigure the controller or issue a builder request.

lua
bus = require("lib.messaging")
menu = bus.get("controller.menu")
if menu then print(menu.active, menu.kind, menu.index, menu.count, menu.selectedId) end
settings = bus.get("controller.settings")
if settings then print(settings.player, settings.cursorSpeed, settings.cameraSpeed) end

controller.menu has active (boolean), optional kind, index/count (numbers) and optional selectedId. The selectedId belongs to the current command/builder snapshot and can expire. builder.lastRequest contains queued (boolean), optional reason (string) and timeMs (integer).

Events

Death, selection, pause, defeat, dialog, save and shared-value notifications are documented on Callbacks & events. The shipped scripts use them instead of onFrame polling: dev/bindings.lua reacts to onSharedChanged, and dev/victory.lua to onBuildingDestroyed. The controller (dev/controller.lua: sticks, triggers and the RT Palantir menu) is the only shipped onFrame user.

Guide / Lua 5.4.9

Multiplayer Lua

BFME II's supported retail executable now has a trust-based Lua multiplayer bridge. Every participant needs the same BFME2Lua DLL, entry scripts, libraries, game data and mod configuration. There is no authentication, script attestation or cheat prevention. Unsupported native signatures leave the bridge disabled; check the startup log for Synchronized multiplayer Lua simulation ready and inspect bfme.getMultiplayerState().

Simulation and local input

onSimulationTick(frame, dt) runs after each advancing native stage-1 simulation update, with the native frame number and a fixed dt of 1 / 30. Rendering, console visibility and different frame rates do not drive this callback. During a multiplayer match, game-start, construction, death, defeat and pause events are dispatched on this simulation path too. Gameplay changes queued by these callbacks execute locally on every participant at that shared simulation boundary.

onFrame, onInput, dialogs, selection, shared-value notifications and save-sync notifications remain local. Gameplay edits queued from these local callbacks or the console are transmitted through the native synchronized command stream. Each peer, including the sender, applies the edit when the complete command arrives. Player, object and definition IDs cross the wire; pointers, local handle generations and ticket identities do not identify objects on another client. Existing ticket APIs still report the originating client's result.

Move continuous gameplay logic from onFrame to onSimulationTick. Use local callbacks for presentation and input. Do not base shared simulation decisions on local selection, players.localPlayer, frame-rate delta, wall-clock time, file contents that differ between peers, or local shared/mailbox values. Lua random generators are seeded identically per entry-script filename before loading libraries; table hash seeds are fixed by both build systems. Each script has separate persistent math.random streams for local callbacks and synchronized simulation/network/gameplay-event callbacks. Cached references to math.random use the appropriate stream too. Local random draws and local math.randomseed calls cannot advance or reseed the simulation stream. A no-argument math.randomseed() inside a synchronized callback uses a fixed seed; explicit seeds there must agree on every peer. Initialization-time seeding affects the local stream; seed the simulation stream from a synchronized callback if a custom seed is required. Keep gameplay random draws in synchronized callbacks. Both clients must reload scripts together between matches; a local reload during a match changes that client's Lua state.

Native controller/keyboard orders already use the game's command stream. Issue them once from the local player's input, rather than issuing the same native order on every peer's simulation callback.

Custom synchronized messages

Send a copied Lua payload from the local player's input:

lua
local bfme = import("bfme")
return {
    onInput = function(e)
        if e.key == "F8" and e.pressed and not e.repeatKey then
            local ok, error = bfme.sendNetworkMessage("my-mod.grant", {
                playerId = bfme.players.localPlayer.id,
                amount = 100,
            })
            if not ok then bfme.log(error) end
        end
    end,
    onNetworkMessage = function(e)
        if e.channel == "my-mod.grant" then
            local player = bfme.players[e.payload.playerId]
            if player then player.money = player.money + e.payload.amount end
        end
    end,
    onSimulationTick = function(frame, dt)
        -- Identical gameplay logic on every participant.
    end,
}

onNetworkMessage(event) receives playerId (the native sender), frame, channel and payload. It executes on every client's synchronized dispatcher. The channel uses the existing ScriptBus name rules; payloads use copied plain Lua values, with no functions, userdata or cyclic tables. Encoded commands are limited to 16 KiB. sendNetworkMessage returns true when queued, or false, reason when unavailable or rejected. Do not resend from the receive callback unless intentionally creating another command.

getMultiplayerState() returns supported, active, frame, sentFragments and receivedCommands. The transport uses small, reserved-prefix MSG_SET_BEACON_TEXT packets; ordinary beacon messages continue through native handling. Fragment sequences are scoped to each sender and reset for a new match. The native network determines delivery order and frame assignment.

Lua skirmish defeat/victory policy is available in supported multiplayer too. Its candidate notifications use simulation frames instead of elapsed wall time. Campaign and unsupported builds retain native rules.

Verification

The automated multiplayer test runs two simulated peers with different engine pointers and handle generations. It checks player edits, object remapping, duplicate/reordered fragments, malformed payload rejection, normal-message passthrough, copied Lua payloads, identical random sequences and simulation callbacks without render callbacks. It does not replace an end-to-end match over the retail game's network.

For a real match, install the same built runtime and scripts on both machines, host a LAN skirmish, and confirm that both logs report the bridge ready. Send the example F8 request on one machine. Both clients should gain exactly 100 for that player after network latency, and each should increment receivedCommands once. Repeat with different frame-rate limits and the sender's console open; compare resources, object creation/destruction, ownership, construction and defeat behavior over a full match. This live two-machine check remains required before calling the integration fully verified.

Automatic LAN room compatibility check

Joining or hosting a LAN room automatically compares SHA-256 fingerprints with every human player in that room. The DLL is hashed at startup; all .lua files under dev, including libraries, are hashed when Lua loads or reloads. Relative filenames and file contents participate in the Lua hash. CRLF and LF line endings compare equally; edits, missing files and renamed files do not. Local text/INI configuration files do not participate.

A mismatch displays a warning identifying the peer and whether the DLL, Lua scripts or both differ. Matching fingerprints are recorded in BFME2Lua.log. An unanswered check becomes unverified after eight seconds. Both players need the new DLL and UDP port 64488 reachable; an older DLL cannot answer the check. The check warns but does not block starting a match. It checks BFME2Lua code, not game archives, patch versions or simulation state, and currently applies to LAN rooms, not the online service.

Use import('bfme').getRoomCompatibility() in the Lua console to inspect local hashes and each peer's status. A Lua reload starts a fresh comparison, and leaving or changing room membership discards previous results. Install matching DLLs and scripts and restart both games before retrying a mismatched room.

The native regression test exercises a two-address loopback UDP exchange, membership filtering, mismatch warnings, timeout handling, stale session replies and Lua hashing across different line endings. A two-PC room check is still required to verify network reachability on the installed systems.

Network interface console commands

The console loads the network helper automatically:

lua
network.interfaces()             -- adapter index, name, IPv4, up/down state
network.status()                 -- preferences and actual observed UDP bindings
network.use("192.168.1.20")       -- choose an IPv4 shown by interfaces()
network.use(12)                   -- alternatively choose its adapter index
network.use("Wi-Fi")              -- or its displayed name / adapter ID
network.use("auto")               -- prefer an active 192.168.* address automatically

After switching, leave the Multiplayer/LAN screens and reopen them so the game creates fresh sockets. Existing bound sockets keep their original address; network.status() distinguishes that address from the newly configured choice. Switching during a running multiplayer match is rejected. Adapter choices are local to each client and are saved in network-interface.txt beside the Lua project setup. If a saved IPv4 disappears (for example after DHCP changes), startup falls back to automatic selection and logs the reason.

Under Wine, names and addresses come from the Windows networking view exposed to the game. Status associates observed UDP bind addresses with those names. A wildcard 0.0.0.0 socket listens on all interfaces and leaves the outgoing route to the OS; it does not identify one physical interface. TCP and explicit loopback helper sockets retain their existing binding behavior. The override updates both the native LAN and online address getters and future multiplayer UDP binds. It does not change macOS routing or a VPN's own configuration.

Scripts can use bfme.getNetworkInterfaces(), bfme.getNetworkInterface() and bfme.setNetworkInterface(selector) directly. The setter returns true, instructions or false, reason. These settings are process-local and are never broadcast through the Lua multiplayer stream.

Automatic interface selection prefers active 192.168.* IPv4 addresses. If several exist, the lowest adapter index wins, then the lowest address. If none exist, the game retains its native choice. Explicit selections override this preference. Startup and network.use("auto") refresh the automatic choice; network.status() shows the selected address separately from bound sockets.

The native update thread also keeps GlobalData's cached LAN/online addresses consistent with the selected interface, because lobby packet handling reads those fields directly. network.status() includes Advertised LAN address; compare it with the selected address and bound sockets when diagnosing ready/start messages.

Loading transition render-buffer crash

Retail 2.01 can reach the effect-render pass at 0x50d92c with its dynamic vertex buffer (this + 0x254) and index buffer (this + 0x258) absent. The observed Mac dump fails at 0x5390c3, dereferencing a null buffer passed from 0x50d052. BFME2Lua guards the outer pass before native locks or draw state change. If both resources are absent, it calls the retail allocator (0x507f8a); if either remains absent, it returns zero rendered effects for that pass. Partial allocations are retained rather than overwritten.

This recovery changes rendering only. It does not modify objects, simulation frames, multiplayer commands, or gameplay rules. The log reports whether resources were restored or the effect pass was skipped. This is a targeted mitigation of the observed null-buffer failure, not proof of why the native loading transition left those resources absent.

Guide / Lua 5.4.9

Callbacks and events

An entry script under dev/ returns a table of callbacks. Every key is optional. A script is rejected at load when it returns something other than a table, or when one of the recognized keys below holds a value other than a function or nil. Unrecognized keys are ignored, so a script can also export data.

lua
local bfme = import("bfme")
return {
    onFrame = function(dt) end,
    onInput = function(event) end,
    onGameStarted = function(event) bfme.log("match started") end,
    onBuildingDestroyed = function(building, owner, hasEligibleBuildings) end,
}

All callbacks

CallbackArgumentsFires when
onSimulationTick(frame, dt)Native simulation frame, fixed 1/30 second stepEach advancing simulation update; independent of rendering. See Multiplayer Lua.
onNetworkMessage(event)Sender, frame, channel and copied payloadA complete Lua command arrives on the synchronized native dispatcher.
onFrame(dt)Seconds since the previous rendered frameEvery D3D9 Present on the game's render thread. Suspended while the console is open.
onInput(event)InputEventKeyboard transition, mouse button, gamepad button, or captured key binding. See InputEvent.
onGameOpened(event)Common event recordOnce per game process, on the first frame after scripts load. Ctrl+Shift+F5 does not repeat it.
onGameStarted(event)Common event recordA match becomes active: first observation of a new GameLogic/session. Repeats for each new match.
onBuildingDestroyed(building, owner, hasEligibleBuildings)Copied building, owner, Boolean or nilA structure dies in combat, or is destroyed/sold without dying first.
onUnitKilled(unit, owner)Copied unit, ownerA selectable non-structure object dies. Ordinary despawn does not fire.
onBuildingStart(building, builder)Copied building, copied builder or nilConstruction actually begins: when the builder reaches a placed foundation, or immediately for builds that start in place.
onBuildingFinish(building, builder)Copied building, copied builder or nilConstruction completes.
onVideoStart(event)event.nameA video starts playing: fullscreen, palantir/HUD, menu or intro.
onSelectionChanged(event)event.idsThe local selection changes. Includes clearing (empty list).
onPauseChanged(event)event.pausedThe native pause flag flips.
onPlayerDefeated(event, player)event.playerId, playerA player's native defeated flag becomes true.
onDefeatCandidate(event, player)event.playerId, playerThe engine's survival rules say a player has lost in a Lua-managed skirmish. Repeats at most once per second until handled.
onCommandButtonClicked(event){id, button="left"}A Lua-owned native command button is clicked; only its creating script receives this event.
onDialogResult(result)DialogResultA request from showInputBox/showMessageBox completes.
onSharedChanged(event)event.key, event.revisionA process-wide shared value was set or deleted.
onGameSaved(event)event.file, event.status="detected"Save sync saw a completed write in the save folder. Requires a running sync worker.
onDataChanged(event)event.file, event.status="detected"Save sync saw a completed write in the optional data folder.
onSaveSync(event)event.file, event.status, event.errorSave sync finished a transfer, found a conflict, failed, or completed startup synchronization.

Callbacks run in the script's own VM with the one-million-instruction budget. A Lua error is written to the log and the Log tab; other scripts still receive the event, and the failing script still receives later events.

Frame order

All callbacks run on the render thread inside the D3D9 Present hook, in this order on each frame:

  1. Mouse messages, native captured bindings and async keyboard transitions are collected. Ctrl+Shift+F5 and Ctrl+F9 are handled.
  2. onInput for every collected event, in arrival order.
  3. Gamepad polling. Gamepad button edges are delivered to onInput here.
  4. onSharedChanged for every key changed since the previous dispatch.
  5. onGameOpened (first frame only), then game events: deaths, selection, pause, defeat, onGameStarted, dialog results, save-sync events and defeat candidates.
  6. onFrame(dt), unless the console is open.

Within each event, scripts are called in sorted filename order. Events (steps 4–5) are still dispatched while the console is open; only onFrame and input are suspended. A message sent by an earlier script in the same frame can be read by a later script's onFrame in that frame.

Common event record

Every event table passed to callbacks other than onFrame, onInput and onSharedChanged has these fields. Fields that do not apply keep their default.

FieldTypeDefault / meaning
eventStringThe callback name, such as "onUnitKilled"
idIntegerObject ID for deaths; dialog request ID for dialog results; 1/0 for pause; otherwise 0
playerIdIntegerEngine player slot; -1 when not applicable
typeIdStringTemplate ID for deaths; empty otherwise
displayNameStringLocalized template name for deaths; empty otherwise
factionStringDefinition Side for deaths; empty otherwise
positionTable{x, y, z} world position for deaths; zeros otherwise

Event-specific fields are added on top: ids (selection), paused (pause), file/status/error (save sync), and status/button/buttonId/error/value (dialogs).

Owner argument

Death, defeat and defeat-candidate callbacks receive the owning player as their second argument:

  • A live Player[...] proxy when that player still exists in the current live-data generation.
  • Otherwise a copied table {id=slot, displayName=name}, for example after the match has ended.
  • nil when the owner could not be resolved.

Every event table also carries playerId, so a one-argument handler still works.

Deaths and destruction

onBuildingDestroyed and onUnitKilled receive copied snapshots, because the native object may be freed before Lua runs. The copy has id, typeId, displayName, faction, playerId and position. Look up bfme.buildingInstances[id] only if you expect the object to still exist; usually it does not.

  • A building that dies and is then removed by the engine produces exactly one event.
  • Selling or explicitly destroying a building (including building:destroy()) produces an event without a death.
  • Units fire only on death. Objects removed without dying, such as garrisoned or despawned objects, do not fire.

hasEligibleBuildings is true or false in a Lua-managed offline skirmish, and nil elsewhere or when unavailable. It is computed on the native update thread after the engine finishes its destruction bookkeeping, using the game's own configured survival rules (the same predicate that decides defeat). Building events wait for that result before delivery. All deaths for the same owner in one update share one predicate call. See Match control, powers & defeat for how the shipped dev/victory.lua uses it.

Construction

onBuildingStart(building, builder) fires when a structure begins construction, and onBuildingFinish(building, builder) when it completes. Both arguments are copied records (common event fields), so they stay valid even if the builder has since been removed.

FieldOnMeaning
building.id, typeId, displayName, faction, playerId, positionBuildingThe new structure
building.selfBuiltBuildingFinish only: the building completed by itself after bfme.selfBuild
building.rebuildBuildingFinish only: the engine reported a rebuild
builder.id, typeId, displayName, faction, playerId, positionBuilderThe worker, dozer or structure that started it; for a self-built building, the snapshot taken at start

Placing a building with a worker first creates a phantom foundation while the worker walks to the site; onBuildingStart fires when the worker arrives and construction begins, and builder is the worker that started it. A cancelled placement fires nothing. builder is nil when nothing built the structure (script-created buildings). Every native construction route reports these events: workers and dozers, instant AI builds, castle and fortress expansions. Structures that already exist complete on the map, such as pre-placed bases, do not fire them. An instant build fires start and finish in the same update. A building destroyed before completion fires only onBuildingStart; its destruction arrives as onBuildingDestroyed.

typeId is the template of the object actually placed. Mods and build variations can make it differ from the building type you selected, so match on the event's value. To make builders vanish into their buildings, handle it here; see Builder consumption.

Videos

onVideoStart(event) fires whenever the game successfully opens a video for playback, whatever started it: intro and logo movies, campaign and script cinematics, the palantir/HUD window, menu backgrounds, or bfme.playVideo. The table has event = "onVideoStart" and name, the video's internal name from Video.ini (for example EALogoMovie). A name that does not exist logs an engine error and fires nothing. Videos that start before scripts load (such as the EA logo) are delivered once scripts are running.

lua
return {
    onVideoStart = function(event)
        bfme.log('Video started: ' .. event.name)
        if event.name == 'EALogoMovie' then bfme.stopVideo() end
    end,
}

Observed state changes

Selection, pause, defeat and match-start notifications come from a 10 Hz observer on the game update thread. The first observation of a match sets a baseline and emits only onGameStarted; the initial selection, pause state and already-defeated players are not reported. Each event reflects the state at observation time, so changes that revert within 100 ms may not be reported.

onSelectionChanged reports the local player's selected object IDs in native selection order, capped at 128.

Shared-value notifications

onSharedChanged fires once per changed key per dispatch, with the key's latest revision, even if it was set several times. Deletion (setShared(key, nil)) also notifies. The native journal keeps the last 256 changes; a script that falls further behind misses older keys. Read the value with bfme.getShared(event.key), or compare bfme.getSharedRevision(key) to detect changes without a callback.

lua
return {
    onSharedChanged = function(event)
        if event.key == "my_mod.settings" then
            settings = bfme.getShared(event.key)
        end
    end,
}

Queues, limits and match isolation

SourceCapacityWhen full
Death/destruction, construction and observed events2048 pendingNew events are dropped
Defeat candidates128 pendingNew candidates are dropped
Dialog results256 pendingNew results are dropped (still readable with getDialogResult)
Save-sync events256 pendingOldest event is discarded
Shared-change journal256 changesOldest change is discarded
onInput queue1024 eventsNew events are dropped

Game events carry the match/session they were captured in. Events from a previous match are discarded when polled, so a new match never receives stale deaths. Dialog and save-sync events are independent of matches and survive menu transitions.

Guide / Lua 5.4.9

In-game Lua console

Press the unmodified backtick key (above Tab on a US keyboard) or Ctrl+Shift+C to open the console inside the game window. Press either again, or Escape, to close it. Shift+backtick still types a tilde. The backtick character is reserved for the toggle, so paste it when you need it in Lua source.

The console is built from Windows Rich Edit controls. Output is above, input below; both support Unicode, multiline text, mouse selection, scrolling and the clipboard. The input highlights Lua keywords, strings, numbers and -- comments. Highlighting covers the first 16,384 characters and is not a full Lua parser.

The Command tab holds the editor and command results. The Log tab shows print(), bfme.log() and errors from every Lua VM, including startup messages logged before the console was first opened. Log text is read-only and selectable. Clear empties the visible tab; history is kept.

Controls

InputAction
Backtick or Ctrl+Shift+COpen/close the console
EscapeClose the console
Enter or RunExecute the whole input field
Shift+EnterInsert a newline
Up / Down in inputPrevious/next history entry; moving past the newest restores the unsent draft
Tab in inputInsert four spaces
Ctrl+A / C / X / V / ZSelect all / copy / cut / paste / undo in the focused field
ClearClear the visible tab
Command / LogSwitch tabs

Output is editable, so you can paste notes there, but it is never executed. New results append without disturbing an existing selection; when the caret is at the end, output scrolls to follow.

Evaluating Lua

lua
bfme.players.localPlayer.money
=1 + 2
for id, unit in pairs(bfme.unitTypes) do
    if unit.faction == "Mordor" then print(id, unit.cost) end
end

Each command is first tried as an expression (return <input>), then as statements. A leading = is accepted and ignored. Returned values are printed tab-separated using tostring, so live objects show labels such as Player[1]. Errors appear in the output; they do not close the console.

Globals persist for the life of the game process. The in-game editor and the external terminal share one console VM, so a variable set in either is visible in the other. Entry-script globals are separate; use shared values and mailboxes to talk to scripts. Ctrl+Shift+F5 reloads entry scripts but does not reset the console VM.

The console VM loads these globals at startup: bfme, commands (lib.command_buttons), messaging (lib.messaging), JSON, ShowInputBox and ShowMessageBox.

Customizing with console.lua

dev/lib/console.lua controls the console's Lua side. Native code hosts the editor, history, worker thread and input capture.

MemberPurpose
M.librariesMap of global name to module name, loaded by startup
M.startup()Runs once when the console VM is created
M.evaluate(source)Compiles and runs one command; returns its values
M.format(...)Turns returned values into output text
M.reload()Reloads console.lua and runs startup again; call console.reload()

If the console global is removed or its functions are missing, the native fallback evaluator and formatter are used.

History

Accepted commands, including ones that error, are saved to console-history.bin beside the DLL. A multiline command is one entry; consecutive duplicates are collapsed. History keeps at most 256 commands and 1 MiB of text. History is written on the worker thread before the command runs, using a temporary file that replaces the old one. If saving fails, the command still runs and its output starts with a warning. A malformed history file is ignored. Opening the console never runs saved commands. Commands from the external terminal are not added to history.

Limits

LimitValue
Command size65,536 UTF-8 bytes
Result text per command65,536 bytes, then [output truncated]
Pending in-game commandsOne; Run is disabled until its result arrives
Shared worker queue (in-game and terminal)32 commands
Command transcriptOldest 200,000 characters are removed past 800,000
Log tab1 MiB of text; up to 2,048 lines or 512 KiB are buffered before the console first opens
Instruction budgetOne million Lua instructions per command

Game input while open

While the console is open:

  • DirectInput keyboard and mouse reads return neutral state, and game key/mouse messages are swallowed.
  • onInput and onFrame are suspended; game events still dispatch.
  • Controller snapshots are neutral. Held pointer buttons, camera motion and queued key pulses are released.
  • Cursor, camera, keyboard and command-button requests from Lua are rejected.
  • The simulation keeps running, so queued template, player and instance mutations still apply.

Escape that closes the console is not passed through to the game, so it does not also open the game menu. Opening a native dialog closes the console automatically.

External terminal

Console.cmd (or Console.ps1) opens a terminal console connected through the local named pipe BFME2Lua-<game PID>. It waits for the game if necessary. Use Console.ps1 -GameProcessId <PID> to choose between several games. Only one terminal can connect at a time, and it must run at the game's privilege level.

Terminal inputEffect
Any lineEvaluate it, same rules as the editor
:begin ... :endEnter a multiline block
:quitDisconnect; the console VM and its globals remain

The pipe rejects remote clients. Commands run on the same worker as the in-game editor.

Rendering and compatibility

The console uses the game's existing borderless/windowed mode and Windows' included Msftedit.dll; no third-party UI library is installed. Each frame while the console is open, its controls are captured and drawn into the game's back buffer, so it stays visible on Wine and macOS, where the D3D surface would otherwise cover child windows. The caret blinks in that composed image. Exclusive fullscreen rendering is not validated.

Native fixture tests cover Rich Edit creation, highlighting, selection preservation, multiline execution, shared terminal state, history, draft recall, toggling and DirectInput suppression. In-game rendering and typing on each platform still need a manual check after restarting.

Multiplayer interface

Use network.interfaces() to list adapters, network.status() to inspect the configured preference and actual UDP binds, and network.use("192.168.1.20") to select an address from that list. network.use("auto") prefers active 192.168.* addresses. Reopen Multiplayer/LAN after changing the selection. See Multiplayer Lua.

Guide / Lua 5.4.9

Controller and input

dev/controller.lua coordinates focused modules in dev/lib/controller. Change named button actions in dev/lib/keybinds.lua, analog settings in config.lua, contextual functions in controls.lua, and Palantir (RT) navigation in palantir.lua. Ctrl+Shift+F5 reloads scripts. See BINDINGS.md for keyboard bindings and native actions.

Default mapping

The layout follows the Xbox 360 release; xbox360/CONTROLS.md documents the original. A and B stay native pointer presses, so the game resolves their context (select, move, attack, capture, garrison, place, interface icons). Lua adds the 360 modifiers and gestures.

InputAction
Left stickRT held: mouse cursor and edge pan; RT released: camera pan
Right stickRotate camera (left/right), zoom (forward/back)
ANative left pointer: select, command, drag, place
A, AOver a unit: native double click. Over ground with units selected: attack-move. Nothing selected: select all
BNative right pointer: deselect/cancel; OPTIONS or menu Back outside an active match
XMain menu: open Skirmish; Skirmish: StartGame; match: jump to the selection and FOLLOW it
YVIEW_LAST_RADAR_EVENT (jump to the mini-map alert)
StartOPTIONS; two presses within 400 ms immediately terminate the game
BackDIPLOMACY (the 360 opens objectives)
Left-stick clickVIEW_HOME_BASE
Right-stick clickCAMERA_RESET
D-pad up / downCycle your heroes / builders: select the next one and centre the camera (cycleHeroes / cycleBuilders)
D-pad leftCycle control groups 1–9, 0, skipping groups that select nothing (360 bookmarks)
D-pad rightSPELL_STORE (powers)
LT + ASELECT_ALL, without a click
LB (held)Native Shift + Alt: LB + A over a unit adds it to the selection; over ground it sets a waypoint
RB + AClick the unit, then SELECT_MATCHING_UNITS (its type)
RT (held)Open the Palantir: command navigation below

Every button press is context sensitive: keybinds.controller maps each button per context (mainmenu, palantir while RT is held, game, shell for other menus, any), tried in keybinds.contextOrder. Edit those tables to rebind; a name is a native action or a handler exported by the controller modules. A button a binding takes does not also click the pointer. Button actions use the native message dispatcher. A/B use native pointer messages. The bundled controller generates no keyboard pulses or SendInput mouse clicks. Ctrl+F9 independently toggles the integration's log overlay.

Controller switch and Xbox 360 cursor

enabled in lib/controller/config.lua switches the whole controller layer: input, the Palantir, the bottom building-command row, RT-only commands and the cursor. With enabled = false the game keeps the stock PC interface.

In a match the mouse cursor is replaced by the Xbox 360 cursor (centerCursor): it stays in the middle of the screen, the left stick pans the camera to aim it and the right stick rotates and zooms. It is the 360's green four-diamond reticle and shows the green A button over its centre when A would select, move, attack or enter. In menus the normal cursor returns and the right stick (or the left) moves it. cursorSize tunes it. The images come from the 360 in-game UI atlas (apt_IG_userInterface_1.xpr) via xbox360/tools/export_cursors.py.

Palantir (hold RT)

Hold RT to open command navigation. The left stick moves the mouse cursor by default; D-pad left/right selects commands. The selected command uses a static dimmed appearance; its actual enabled state is preserved. A activates the current option. D-pad left/right steps along the options. D-pad up/down walks keybinds.palantirShortcuts in the Xbox 360 order (builders, heroes, bookmarks), and the command list follows the new selection. With RT held, B right-clicks the selected option (the 360's decrement, for example to cancel a queued unit); X and Y keep their normal meanings. Powers stay on D-pad right without RT; add SPELL_STORE to the shortcut list to include them. For builders, pointing changes the chosen construction preview; release RT, then place it on the map with A. Presses made while RT is held never reach the map after RT is released.

Hero/unit portrait slots 1..6 occupy equal sectors of the right semicircle from up to down. Structure radial options and builder options use the full circle starting up, clockwise. Use D-pad left/right to select hero and building commands or entries in the builder column. Set leftStickMenuNavigation = true to restore the original stick sectors; while RT is held, menu selection then takes priority over mouse/camera movement. The right stick keeps rotating and zooming.

The intended structure panel is radial, the menu positioned over the selected building. It must not fall back to portrait commands. The side panel supplies builder construction commands. Snapshot polling runs at 10 Hz while RT is held. Holding A while moving to another option activates it once; holding the same direction does not repeatedly activate it.

This navigation remains incomplete in gameplay: the last reports described no selection overlays and no activation for heroes/buildings. The native APIs exist, but their rendering/dispatch behavior requires further investigation. Builder previews, portrait commands and world-relative structure menus are separate interfaces.

Analog settings

SettingDefaultMeaning
player0XInput controller index, not engine player slot
leftStickMenuNavigationfalseRestore left-stick selection in hero/building menus and the builder column; suppress mouse/camera movement while navigating
swapLeftStickModesfalseSwap left-stick modes: true means mouse with RT released and camera with RT held
cursorSpeed850Pixels/second at full deflection
cursorDeadzone0.20Radial cursor deadzone
edgePantrueLeft stick pans when the cursor is against a screen edge
edgePanMargin2Pixels from the window edge that count as the edge
cameraSpeed600Camera and edge-pan world units/second at full deflection
cameraRotationSpeed1.5Radians/second, right stick horizontal
cameraZoomSpeed300Camera height units/second, right stick vertical
cameraDeadzone0.22Radial camera deadzone
invertCameraRotatefalseThe 360 "Invert Camera Rotate" option
doubleTapMs300Window for A, A
triggerPress / triggerRelease0.35 / 0.20Trigger hysteresis for LT and RT

A,A decides "unit or ground" from whether the first tap changed the selection (onSelectionChanged or the selection count). A double tap faster than the roughly 100 ms selection update can be read as ground. While a building preview is attached, A only places it. Holding A anchors the building, as on the Xbox 360: the left stick points it (the building faces the stick's screen direction), letting go of the stick turns it back to face down the screen, and releasing A places it. Neither stick moves the camera meanwhile; a quick tap keeps the default angle. Native edge scrolling may also respond when the cursor touches the edge; set edgePan = false if both pan.

Stick deadzones rescale remaining travel and cap diagonal magnitude. Frame dt is capped at 50 ms to prevent jumps after pauses. Held mouse input must be renewed each frame; disconnect, focus loss and reload release it. Camera displacement expires after 250 ms.

Script example

lua
local bfme = import("bfme")
local pad = {buttons = {}}
return {
    onFrame = function(dt)
        bfme.getGamepad(0, pad)
        if not pad.connected then return end
        if math.abs(pad.rightX) > 0.22 then
            bfme.moveCamera(pad.rightX * 100 * math.min(dt, 0.05), 0)
        end
    end,
}

Put helper modules in dev/lib. Every Lua file directly under dev is an entry script with its own VM, so running another controller script can issue competing input requests. Modify the existing controller when replacing its behavior.

Guide / Lua 5.4.9

Encrypted save synchronization

Python runs only on the server. The game DLL handles Windows HTTP, AES-256-GCM, file access and background monitoring; dev/save_sync.lua starts startup sync and requests uploads when onGameSaved / onDataChanged arrive. There is no local Python helper and no Lua onFrame polling.

Server setup

Copy sync/server.py and sync/requirements.txt to your server. Python 3.11 or newer is recommended for the pinned dependency. In that directory:

sh
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python server.py --key save-sync.key --generate-key
.venv/bin/python server.py --key save-sync.key --database saves.sqlite3

On Windows, use .venv/Scripts/python.exe instead. Key generation creates a random 32-byte binary file, refuses to overwrite an existing file, and uses owner-only permissions on Unix. Copy that exact key privately to each client; do not convert it to a password, hex string or text file. All clients holding this key share the same save collection and can read/write it.

When the server is hosted on the same Windows PC as the game, Start-SaveServer.ps1 reads the host, port and key from save-sync.ini and launches the server. Its default Python path uses the development server environment in build-portable/sync-venv; pass -Python to use a different server interpreter. After a PC restart, run this script again before opening the game. The game client itself does not launch Python.

The default server binds only 127.0.0.1:8765. For remote access, deploy behind a maintained HTTPS reverse proxy with request-size limits and timeouts, or provide a certificate/key to the standalone server:

sh
.venv/bin/python server.py --key save-sync.key --database saves.sqlite3 \
  --host 0.0.0.0 --port 8765 --cert server.crt --tls-key server-private.key

The native client checks HTTPS certificates normally. Use a hostname matching the certificate and a certificate trusted by the client's Windows/Wine setup. No certificate-validation bypass is provided. The service is a small standalone HTTP server rather than a production web framework; use a reverse proxy when exposing it publicly. Its only application endpoint is POST /v1/sync.

Back up both saves.sqlite3 and the secret. Losing the key prevents recovery of the encrypted database. Changing the key requires migrating/recreating the store; this version does not implement key rotation or separate user accounts.

Game setup

  1. Copy save-sync.example.ini to save-sync.ini next to BFME2Lua.dll.
  2. Copy the server's binary key to save-sync.key beside it, or specify an absolute KeyFile path in the INI.
  3. Set Enabled=1, Host, Port, and TLS for your server. Host is a hostname, not a URL. Use TLS=1 for HTTPS. The example uses TLS=0 solely for local testing.
  4. Set SaveDirectory to your game's actual save folder, or leave it empty to use %APPDATA%\My Battle for Middle-earth II Files\Save.
  5. Optionally set an absolute DataDirectory to sync other data files. Leave it empty to disable other-data synchronization. Use a dedicated directory, not the entire mod folder; it should contain only data you intend to share.
  6. Open the game normally. Lua starts background synchronization. For an INI edit while playing, stop the worker and wait for outstanding requests to finish, then reload scripts, or simply restart the game.

Native paths must refer to the Windows filesystem visible to the game. Under Wine, configure paths inside its prefix (including an appropriate drive mapping for host directories). No Python installation is needed in that prefix.

save-sync.ini, the standard key paths, local revision state, temporary files, conflict backups and the default server database are ignored by Git. Protect other custom key paths separately. Configuration and key files are excluded from the watched inventory when their paths are encountered.

Behavior and conflicts

Startup sync fetches the remote catalog and downloads differing/missing files. If a local file differs and the server revision has changed (or the client has no previous state), the local copy is preserved under save-sync-conflicts before the remote file is installed. If only the local file changed since the known server revision, the local edit is uploaded instead. Existing files not on the server are uploaded after the initial pull. Matching files are not transferred.

Every upload includes the last known server revision. The server rejects stale updates rather than overwriting a newer revision. Such a conflict retains the local file and reports onSaveSync with status conflict; run a pull to preserve that local copy and fetch the remote version. There is no automatic conflict merge.

The native background worker checks size/last-write changes roughly once per second. A changed file must remain stable for at least two seconds and permit a read handle that denies concurrent writers before a save event is delivered. Uploads hold that read handle while obtaining bytes, avoiding partial reads from open writers. Downloads use temporary files and an atomic replacement; a local file changed during a download is not replaced.

This detects completed file writes, not a reverse-engineered engine SaveGame function. Any nonempty changed file in SaveDirectory produces onGameSaved, including autosaves if that game mode writes them there. It does not distinguish manual saves from autosaves or promise that every mode supports autosaving. Profile files such as MyHero.dat in that folder are included. Zero-byte files are skipped. Other-data changes produce onDataChanged.

Failed network operations retry after 15 seconds, then 30 seconds. Errors and conflicts appear in the Lua log. Conflict rejections are not repeatedly retried. Completed downloads reset the local watcher baseline so they do not trigger a save-upload loop. Deletions are not propagated in either direction. The server keeps the latest revision of each file, not a historical archive. Transfers are limited to 64 MiB per file. Pending uploads cannot be guaranteed if the game exits immediately; the next startup reconciles remaining local changes.

Lua API

lua
local bfme = import('bfme')
local enabled, reason = bfme.startSaveSync() -- false, "disabled" by default
if enabled then bfme.syncSaves('pull') end   -- queue startup synchronization

return {
    onGameOpened = function(event) -- once per game process, after scripts load
    end,
    onGameStarted = function(event) -- match becomes active
    end,
    onGameSaved = function(event)
        -- event.file is a relative server ID such as saves/Autosave.BfME2Save
        bfme.syncSaves('upload', event.file)
    end,
    onDataChanged = function(event)
        bfme.syncSaves('upload', event.file)
    end,
    onSaveSync = function(event)
        print(event.file, event.status, event.error)
    end,
}

syncSaves returns whether the request was queued; it does not block for HTTP. onSaveSync statuses are ready, uploaded, downloaded, conflict, error. onGameSaved / onDataChanged have status detected. stopSaveSync() asks the worker to stop after its current operation; HTTP requests have bounded timeouts. Script reload does not spawn a second worker; the shipped script requests another pull. Sync events are independent of engine session IDs and remain deliverable while a match or menu changes. Keep the shipped script enabled to automate uploads.

The native startup/match events are available even when syncing is disabled. In menus, asynchronous requests still run; notifications reach Lua when frames are being presented. The system does not pause game startup for a download, so wait for the startup synchronization complete log before loading a remote save.

Encryption and validation

Requests, responses and server records use authenticated AES-256-GCM with random 12-byte nonces and separate associated-data domains. The shared key authenticates both parties. Request IDs bind responses to their originating request; the server rejects repeated request IDs and timestamps more than two minutes out of date. Keep client/server clocks synchronized. Server file contents, names and revisions are encrypted at rest; index IDs are keyed hashes. HTTP headers, traffic lengths and timing remain visible without TLS. Local game files remain ordinary readable saves so BFME can use them; the local revision state contains filenames/hashes.

Tests cover CNG/Python AES-GCM interoperability, modified ciphertext, wrong keys, replays, expired requests, path rejection, revisions, conflicts, atomic writes, Lua startup/save delivery, and an actual native HTTP upload/download plus a completed-save watcher upload. Windows builds and the complete regression suite pass. Actual game-save behavior and Wine HTTP/CNG support still need live testing.

Source: SAVE_SYNC.md · Documentation for the current workspace bindings.
Guide / Lua 5.4.9

Runtime & troubleshooting

Threads and data flow

ThreadRunsRules
Render (D3D9 Present)Local frame callbacks, input dispatch, overlay, console drawingLua reads copied snapshots and queues requests
Game update (main view / menu updates)Mutations, instance actions, menu presses, named actions, dialogs, observersThe only place engine functions are called
Simulation (GameLogic stage 1 / command dispatcher)Multiplayer gameplay callbacks and mutations, onSimulationTick, synchronized messagesSame simulation boundary on every peer; see Multiplayer Lua
Console workerConsole VM commands from the editor and terminalSame snapshot/queue rules as scripts
Save-sync workerHTTP, encryption, file watchingNever touches engine memory

Lua never receives engine pointers. Reads return copies made on the update thread, and writes become queued requests that the update thread revalidates (object identity, generation, focus, freshness) before running. That is why most calls return a ticket or true for "queued" rather than a final result.

Startup, launch options, display modes and the startup log are on Installation & launch. Entry-script rules are on Scripts & communication, and callback order on Callbacks & events.

Snapshot timing and performance

Template catalogs rebuild when their registry changes; reads no longer trigger repeated full-catalog scans. Successful writes refresh affected cached values during the same native update. External template changes made outside this API are not continuously rescanned. Shared durability data updates all cached definitions using that body data.

Player snapshots refresh at most every 100 ms independently of catalog inspection. Instance inspection renews a two-second lease and scans at most every 100 ms. Command UI inspection renews a two-second lease, refreshes at most every 50 ms, and drops snapshots older than 250 ms. Builder previews have a separate demand lease. First reads after inactivity can be empty or stale; read again after an update.

Local view-update callbacks skip busy console snapshot locks; synchronized simulation work waits for those locks so it cannot skip a frame on one peer. Queued mutations/actions are retained for a later frame. This avoids blocking the game while the console allocates result tables. Large scans and native operations still cost work; avoid repeatedly enumerating full catalogs in every onFrame callback.

lua
for name, stats in pairs(bfme.getPerformanceStats()) do
    if stats.averageUs then
        print(name, stats.calls, stats.averageUs, stats.peakUs)
    end
end

Timing records include calls, averageUs, totalUs and peakUs in microseconds. frame measures Present-to-Present spacing, hook measures our Present work and present measures the native Present call (including GPU/vsync waits). Other records cover Lua, input, selection, command UI, live data, instances, construction and native update work. memoryQueries.calls counts memory queries; queriesByHook[name] contains calls and queries, without timing fields. Use bfme.resetPerformanceStats() to start a fresh measurement; restarting also resets counters. LuaJIT has not replaced Lua 5.4.9.

Native frame rate

A top-left overlay shows measured rendered FPS, refreshed every half second. It remains visible when the diagnostic log is hidden and restarts its measurement after long rendering pauses.

The supported BFME2 executable uses a native 60 FPS client target. This is a target, so CPU/GPU load can reduce the achieved rate. BFME1 and RotWK are unchanged. The patch retains the original simulation rate; live validation of game speed, rendering and multiplayer synchronization is still required.

bfme.getFrameRateStatus() returns enabled, targetFPS, clientUpdates and logicStages. The counters are cumulative native client updates and dispatched logic stages, not measured FPS. There is no Lua setter. To use stock timing, create frame-rate.txt in the BFME2Lua project directory containing 30, then restart. Remove the file and restart to restore the 60 FPS target. Use the same DLL on every multiplayer client.

Alt-tab buffer recovery

The DLL recreates missing native render buffers after graphics-device resets, including the terrain pass that could crash when locking a missing index buffer after alt-tab. If allocation still fails, that pass is skipped and retried on a later frame. Check for Render buffer recovery ready and Terrain buffer recovery ready in the startup log; signature mismatches disable the corresponding guard. Restart the game after installing a DLL update. Native signature and fixture checks pass, but repeated in-game alt-tab testing remains necessary.

Common problems

SymptomCheck
Gold/player unavailableEnter a match; inspect getLiveDataStatus().localPlayerId and players. Slots include neutrals.
Empty instance listInspect again after a native update; check ready and snapshotAgeMs.
Setter still reads old valueCheck its mutation ticket on a later update. Ensure the game is actually updating.
Stale/expired object errorReacquire the handle from the current catalog/selection.
UI action returns falseCheck foreground focus, current menu state, enabled flag and freshness; acquire a new button ID.
Tooltip strings emptyHover after enabling inspection, wait for the UI update, then inspect captured.
Building menu absentUse radial, not portrait. Native collection/highlight/activation is still under investigation.
spawnUnit rejectedVerify unit template ID, supported ProductionUpdate, resources, population, prerequisites and queue capacity.
No effect editing position/inspectThese are detached tables; instance coordinates/health have no implemented setter.
Ctrl+Shift+F5 did not load DLL changesRestart the game; Ctrl+Shift+F5 reloads Lua only.
Camera does not moveCheck getCameraStatus(), game focus, nonzero mode and whether updates/applied counters advance.
A function is nil or returns "unavailable"Find its subsystem line in BFME2Lua.log; a "disabled" line means the executable signature did not match.
Console will not open (Mac keyboard)Use Ctrl+Shift+C. Check the log for Lua console opened or a creation error.
Players never lose a skirmishdev/victory.lua must authorize defeat. Check it loaded and that onBuildingDestroyed receives false.
Dialog never appearsAnother game popup is open, or 16 requests are queued. Check getDialogResult(id).status.
Script change has no effectPress Ctrl+Shift+F5 in the game window (not in the console) and look for Loaded <file> in the Log tab.

Native compatibility and evidence

Current verified game.dat SHA256:

text
01ad4ce0d6177d23f6d5055ea83512311c4412d23e4b8cb1b1e273f06dd2465f

Subsystems enable only when their native signatures match. Startup messages in BFME2Lua.log report availability. Successful DLL injection does not prove that every binding is ready. Ghidra projects and exports live under decomp; Analyze.cmd drives analysis. The Open-BFME-2 reference helped identify engine structures, but its offsets differ from this installed binary.

Template edits are runtime-only; restarting reloads definitions from game data. Changing template durability affects new bodies, not existing units' current health. Armor setters, arbitrary instance movement/rotation/health setters, remote-player selection and comprehensive widget enumeration are not implemented.

Native heap fixtures and signature checks test the bindings, not actual UI rendering or every gameplay effect. Separate implemented behavior from in-game verification when extending this integration.

Verification status

Every feature on this wiki is implemented, built, and covered by fixture tests and signature checks against the installed game.dat. These behaviors are not yet confirmed in a live match:

AreaStill to confirm in game
Command UIHero/building RT (Palantir) navigation: highlight and activation have been reported as failing; under investigation
ConsoleCommand/Log tab rendering and physical bare-modifier (Ctrl/Shift/Alt) release
Match controlSkirmish defeat/victory screens, AI suspend/resume, pause/unpause and power UI after real mutations
InputContextual command hotkeys and LookAt numpad/arrow camera behavior
DialogsInput field placement within the animated APT frame, and physical typing
DefinitionsUnit/building level effects and save/load; production timing with modifiers; siege-only filtering in combat; icon refresh
ConstructionPreview availability transitions in a live match; start/finish events on every route; Lua builder removal plus self-building speed, health growth, completion visuals and AI behavior
VideosonVideoStart for every source; fullscreen and HUD play/stop in menus and matches
Save syncReal game saves, remote HTTPS deployment, Wine networking/CNG
Wine/macOSFrame-time gains and short key presses under the 125 Hz keyboard cap; macOS Space mode
PerformanceFPS in a large match

Model-loading diagnostics

The startup log reports Model loading diagnostics ready when the verified native hooks install. Model diagnostics: lines capture model creation results and native W3D filename resolution, including the requested name, primary path, final path, file-existence flag and model scale. A file found with a failed creation result distinguishes model/registration problems from missing assets; it does not identify a specific rejected W3D chunk by itself.

Default logging records all failures and successful wall-tower requests. Identical messages are deduplicated, with at most 4096 unique records per process. Set BFME_MODEL_DIAGNOSTICS=2 before launch for all model requests, or 0 to turn logging off. Restart to apply DLL or environment changes. Logs go to BFME2Lua.log; the hooks preserve native arguments, return values and asset data.

Supplemental wall-tower asset index

New W3D names require asset-cache registration, in addition to a mounted BIG. The temporary wall-tower package ships BFME2LuaWallTower.asset.dat beside game.dat, separately from the game's original asset.dat. The DLL reads this index with the native cache parser after the stock cache loads, only when !!!!bfme2lua-isengard-wall-tower.big is present. The stock index and archives are not rewritten. Existing registered asset names keep native first-wins precedence.

Supplemental assets: logs report index parsing and registration of the tower hierarchy, three meshes and combined HLOD. A missing/invalid index is reported before parsing. Removing the separate archive disables supplementary loading. Rebuild the index whenever the W3D changes: asset records store byte offsets and lengths into that exact model file. The package builder generates both files.

Reference / Lua 5.4.9

Function reference

Every native binding, grouped by area. Functions belong to the bfme table unless shown otherwise. Square brackets mark optional arguments. Topic pages linked from each section have the full behavior, limits and examples.

Globals in every VM

Entry scripts, the persistent console VM and the external terminal all get these globals:

NameMeaning
bfmeThe API table. Also returned by import("bfme") and require("bfme").
import(name)Returns the API table for "bfme"; any other name raises Unknown module.
print(...)Tab-joins tostring of each argument and writes it to the log (log file, Log tab, overlay, debugger). Console calls also echo it. Each argument is cut to 64 KiB.
JSONJSON module; see Dialogs & JSON.
ShowInputBox, ShowMessageBoxAliases of bfme.showInputBox / bfme.showMessageBox.
require(name)Searches dev/?.lua and dev/?/init.lua. Native binary modules (cpath) are disabled.

The persistent console VM additionally defines console (from dev/lib/console.lua), commands (lib.command_buttons) and messaging (lib.messaging).

Return conventions

PatternUsed by
true / false, reasonQueued UI and input requests: menus, command buttons, camera, pointer, actions, shared values
Ticket / nil, reasonGame mutations: pause, powers, AI, defeat, instance actions
ticket (errors raise)Field writes through object:set(field, value)
Value / nil, reasonLookups that can be unavailable: selection getters, dialogs, power lists
Lua errorBad argument types, unknown fields or names, out-of-range values, expired handles

bfme.getMutationStatus(ticket) returns "pending", "applied", a failure reason, or nil for an unknown or evicted ticket. 1024 results are retained. A ticket is a queued request, not a completed operation: read the status on a later update.

Multiplayer

See Multiplayer Lua for simulation callbacks, synchronization and required peer setup.

SignatureReturnsBehavior
bfme.sendNetworkMessage(channel, payload)true or false, reasonQueue copied plain Lua data for all peers on the native synchronized command stream. Maximum encoded command: 16 KiB.
bfme.getMultiplayerState()Tablesupported, active, frame, sentFragments, receivedCommands.
bfme.getNetworkInterfaces()Adapter list or false, reasonIPv4 index, ID, name, address, up/selected flags visible to Windows/Wine.
bfme.getNetworkInterface()TableConfigured choice, native LAN/online preferences, hook availability and observed UDP bind addresses/ports.
bfme.getRoomCompatibility()TableLAN room DLL/Lua fingerprints, script count, availability/error and peer addresses with pending, match, mismatch or unverified status.
bfme.setNetworkInterface(selector)Boolean, messageSelect an active adapter by address, index, name or ID; auto prefers active 192.168.* addresses. Reopen Multiplayer to apply. Active matches reject switching.

Logging, time and process

SignatureReturnsBehavior
bfme.log(text)NothingWrite to BFME2Lua.log, the console's Log tab, the overlay and the debugger. Console calls also echo it.
bfme.getTimeMs()IntegerWindows monotonic uptime in milliseconds.
bfme.closeGame()false when unfocusedImmediately terminate the focused game process. This is the controller's double-Start exit, not a menu exit; nothing is saved.
bfme.getPerformanceStats()TableCumulative timing records (including totalUs), memoryQueries.calls and per-hook query counts in queriesByHook. See Runtime.
bfme.resetPerformanceStats()NothingReset timing and memory-query counters.
bfme.getFrameRateStatus()FrameRateStatusNative client target and update counters; see Runtime.

Shared values and messages

SignatureReturnsBehavior
bfme.setShared(name, value)true or false, reasonStore a copied value visible to all VMs; nil deletes. Fires onSharedChanged.
bfme.getShared(name)Copy or nilRead a shared value. Editing the copy does not change the store.
bfme.getSharedRevision(name)IntegerRevision of a key, 0 when absent. Cheap change detection without copying.
bfme.sendMessage(channel, payload)true or false, reasonAppend a copied non-nil message to a mailbox.
bfme.receiveMessages(channel)List of {id, payload}Drain a mailbox in FIFO order.

Limits, value rules and examples: Scripts & communication.

Templates and players

NameKindBehavior
bfme.templatesCollectionEvery loaded object definition, by template ID
bfme.unitTypesCollectionSelectable non-structure definitions
bfme.buildingTypesCollectionSTRUCTURE definitions
bfme.playersCollectionActive player slots; .localPlayer, .neutral aliases
bfme.getLiveDataStatus()LiveDataStatusready, generation, templateCount, optional localPlayerId
bfme.getMutationStatus(ticket)String or nilStatus of any ticket
bfme.swapPlayers(a, b)Ticket or nil, reasonExchange two players' armies, buildings, money and command points
object:inspect()TableDetached {label, kind, id, values} snapshot
object:set(field, value)TicketQueue a field write; assignment does the same and discards the ticket

Fields, ranges and alliance behavior: Templates & players. Build/train time, siege-only and starting level: Definitions.

BFME1 supports the existing player collection and these fields:

FieldsBFME1 access
id, displayName, username, isAI, aiEnabledRead
money, goldRead/write integer 0..2147483647
commandPoints, commandPointsUsedRead/write integer 0..1000000
commandPointsRemainingRead
powerPoints, powerpointRead/write integer 0..1000000

Snapshots refresh about every 100 ms. Assignments and player:set(...) use native BFME1 routines on the game thread. BFME1 also supports pause, unpause, isPaused, disableAI, enableAI, setAIEnabled and configureKeyBindings. Pause and AI writes return mutation tickets. Pause rejects network modes; AI suspension requires an AI player. Other player mutations and template collections remain unavailable. See BFME1 port notes.

lua
p = bfme.players.localPlayer
print(p.displayName, p.money, p.commandPoints, p.powerPoints)
p.commandPoints = 500
powerTicket = p:set("powerPoints", 20)
-- On a later frame:
print(bfme.getMutationStatus(powerTicket))

Building type flat plot textures

Configure the flat ground graphic by building type before starting a match:

lua
local bfme = import('bfme')
assert(bfme.setBuildingFlatPlotTexture('GondorBarracks', 'MyPlot.tga'))
print(bfme.getBuildingFlatPlotTexture('GondorBarracks'))

String type IDs work before the template registry has loaded. Once it is available, bfme.buildingTypes.GondorBarracks:getFlatPlotTexture(), :setFlatPlotTexture(name), :set('flatPlotTexture', name), and the writable flatPlotTexture property provide the same type-wide override.

The getter returns the configured override, or nil to use the game's authored model/weather texture. It does not extract a texture embedded in a W3D model. Set nil to clear the override. Setters return true, or nil, reason when the native signatures are unavailable. Changes apply to every live floor of that type on the next game update and to future floor loads, including subsequent matches. They do not change other building types, shared model assets, or INI files. Overrides last for the game process; put configuration in a startup Lua script to repeat it on each launch. Use an existing texture asset in the game's virtual file system; names are limited to 259 bytes and missing assets are handled by the engine. A type without W3DFloorDraw has no floor graphic to change. Unsupported executable builds fail the native guards instead of installing the binding.

Live units, buildings and selection

SignatureReturnsBehavior
bfme.unitInstances([playerId])List of UnitCall to filter by owner slot; omit for all owners
bfme.buildingInstances([playerId])List of BuildingSame, for structures
bfme.unitInstances[objectId]Unit or nilIndex by object ID (not player)
bfme.getSelectedObjects([viewer])List, or nil, reasonLocal selection, including multiselection and inspected enemies
bfme.getSelectedObject([viewer])Unit/Building or nilFirst selected object
bfme.getSelectedUnit([viewer])Unit or nilFirst selected unit
bfme.getSelectedBuilding([viewer])Building or nilFirst selected structure
bfme.getInstanceStatus()InstanceStatusCounts, snapshot age, readiness
bfme.getSelection()SelectionStateLightweight count/center/preview snapshot for input code
bfme.clearSelection()true or false, reasonQueue clearing the construction preview attached to the cursor (not the unit selection)
bfme.deselectAll()true or nil, reasonDeselect all units and buildings, as the game's UI does
instance:select([add])Ticket or nil, reasonSelect a unit or building, replacing the selection, or adding to it when add is true
unit:kill()Ticket or nil, reasonNormal death with unresistable damage
instance:destroy() (alias destory)Ticket or nil, reasonEngine destruction and cleanup, units or buildings, no death animation
building:spawnUnit(templateId)Ticket or nil, reasonQueue normal training: cost, time, prerequisites, rally point
instance.ownerPlayer = player�Queue an ownership transfer to that player's default team
instance.level = n�Set the native experience rank
building.underConstruction, constructionPercent, builderIdFieldsNative construction state
bfme.selfBuild(idOrHandle) / building:selfBuild()Ticket or nil, reasonLet an under-construction building finish without a builder
bfme.destroyObject(idOrHandle) / unit:destroy()Ticket or nil, reasonSilent engine removal of any unit or building

The optional selection argument is the viewing player, not an owner filter; only the local player works. Details: Live units & buildings.

Construction and wall policies

SignatureReturnsBehavior
bfme.startBuild(typeId, x, y [, builder [, options]]) / unit:build(typeId, x, y [, options])Ticket or nil, reasonStart native construction. See Construction for options and builder-free construction.
bfme.expandWall(hub, x, y [, offset]) / building:expandWall(x, y [, offset])Ticket or nil, reasonExtend a wall from an existing hub toward the map position.
bfme.setBuildingRestrictions(enabled)true or nil, reasonToggle native location checks; costs, prerequisites and limits still apply.
bfme.getBuildingRestrictions()BooleanLocation checks enabled; default true.
bfme.setWallGapRequirementEnabled(enabled)true or nil, reasonToggle spare clearance between a gate and adjoining end hubs; default true. Change between matches.
bfme.getWallGapRequirementEnabled()BooleanCurrent wall-gap requirement.
bfme.setWallHealthMultipliers(walls, hubs, gates)true or nil, reasonScale original template health separately for walls, hubs and gates; defaults 1, range 0.01..100. Applies to new objects.
bfme.getWallHealthMultipliers()TableConfigured walls, hubs and gates multipliers.
bfme.setWallDamageMode(mode)true or nil, reasonChoose "canAttackWalls" (default) or "siegeEngine" for all siegeOnly buildings. Change between network matches.
bfme.setFortressDamageMode(mode)true or nil, reasonChoose "normal", "siegeEngine", or "canAttackWalls" for fortress cores; change between matches.
bfme.getFortressDamageMode()stringCurrent fortress damage mode.
bfme.getWallDamageMode()StringCurrent attacker classification mode.
bfme.setIntermediateWallHubsEnabled(enabled)true or nil, reasonToggle intermediate hubs in new wall spans; default true. Change between matches. See Definitions.

Match control, powers, AI and defeat

SignatureReturnsBehavior
bfme.pause(), bfme.unpause()TicketOffline matches only
bfme.isPaused()BooleanNative pause flag
bfme.getPowers([player])List of PowerWhole science catalog; with a player, adds unlocked/available
bfme.getUnlockedPowers(player)List of PowerSciences the player owns
bfme.getAvailablePowers(player)List of PowerSciences the player could buy now
bfme.addPower(player, idOrName)TicketUnlock without spending points
bfme.removePower(player, idOrName)TicketRemove without refunding
bfme.disableAI(player), bfme.enableAI(player)TicketSuspend/resume AI strategy updates
bfme.setAIEnabled(player, enabled)TicketBoolean form
bfme.getDefeatCandidates()List of player IDsPlayers the survival rules mark as lost but Lua has not authorized
bfme.defeat(player)TicketAuthorize defeat of one player (Player proxy)
bfme.defeat(team)TicketAuthorize defeat of every eligible player on a team (integer 1..19)
bfme.victory(team)TicketAuthorize defeat of all eligible players not on that team

Details, including how offline skirmish defeat is delegated to dev/victory.lua: Match control, powers & defeat.

Definitions and command data

NameKindBehavior
bfme.commandButtonsCollectionCommandButton INI definitions by ID; writable trains/object and icon
definition.buildTime / trainTimeFieldBase production seconds (aliases)
building.siegeOnlyFieldReject damage from non-siege attackers; defaults to true for native walls, hubs, gates and wall upgrades. Explicit false overrides the default.
definition.levelFieldStarting experience rank, when the type already grants one

Details: Definitions, levels & command data.

Menus

SignatureReturnsBehavior
bfme.getMenuContext()StringFocused menu, e.g. "mainmenu", "skirmish", "options", "ingame", "frontend", or "unknown"
bfme.getMenuContextInfo()MenuContextInfo{name, mode, movie, level, state, available}
bfme.getMenuButtons([screen])List of MenuActionLegacy main-menu/Skirmish catalog plus every registered APT callback
bfme.invokeMenuClip(path, event)true or false, reasonQueue onRollOver, onRollOut or onPress for an authored button in the focused movie
bfme.pressMenuButton(id [, argument])true or false, reasonQueue a native menu callback, revalidated on the UI thread

Menu context refreshes at most every 50 ms; a snapshot older than 500 ms reports "unknown". Registry discovery runs when the context changes and while getMenuButtons has been called in the last two seconds. Requests expire after one second. Only the focused menu's actions are active; a modal reports its own movie, not the screen behind it. The argument is a string of at most 256 bytes without NUL. Original IDs and state rules: Menu action catalog.

lua
local context = bfme.getMenuContextInfo()
print(context.name, context.mode, context.movie, context.level)
for _, button in ipairs(bfme.getMenuButtons(context.name)) do
    if button.active then print(button.id, button.name) end
end
bfme.pressMenuButton("AptOptions::Cancel") -- when Options is focused

Registry entries describe callbacks, including menu lifecycle helpers. They do not prove that a particular Flash button is visible or enabled; the native callback keeps responsibility for its own rules. dev/examples/menu_context.lua lists the current menu's actions.

Builders and construction

SignatureReturnsBehavior
bfme.selectBuilder()true or false, reasonQueue native SELECT_NEXT_WORKER
bfme.getBuilderBuildings()List of BuilderOptionThe selected local builder's construction choices, unique templates in command-set order
bfme.setBuilderBuilding(id)true or false, reasonStart the placement preview for that choice; placing still happens on the map

Reading builder options starts a two-second lease. Builder snapshots older than 250 ms are treated as unavailable, IDs expire when the selection changes, and requests expire after one second. Unavailable construction never shows a preview; see Construction previews.

Visible command buttons

SignatureReturnsBehavior
bfme.getCommandButtons([panel])List of CommandButton"portrait", "building", "radial", or all
bfme.getCommandTooltip([id])Tooltip or nil, reasonCaptured tooltip for a button, or the current/latest tooltip
bfme.hoverCommandButton(id)true or false, reasonAsk the game to build that button's tooltip; disabled buttons allowed
bfme.pressCommandButton(id [, "right"])true or false, reasonActivate a displayed, enabled button; "right" right-clicks it (e.g. cancels a queued unit), also when disabled
bfme.highlightCommandButton([id])trueDim the chosen button as a selection marker; 0 or omitted clears. Renew while highlighting.
bfme.getCommandUIStatus()CommandUIStatusHook readiness and panel counters

require("lib.command_buttons") wraps these: list, status, slot(panel, slot), tooltip, hover, press, highlight, pressSlot(panel, slot). Each accepts a button table or its ID. Requests need foreground input, use fresh IDs and expire after 250 ms. Details: Command buttons & tooltips.

Named actions and keyboard routing

SignatureReturnsBehavior
bfme.runAction(name)true or false, reasonQueue one of 121 verified named engine commands, such as "STOP" or "SELECT_ALL"
bfme.getActions()List of {name, messageId, available}The action catalog
bfme.getGameProfile()"BFME2" or "BFME1"Executable profile; BFME1 provides native actions, keyboard routing, player data, pause and AI suspension (BFME1.md)
bfme.getActionStatus(){ready, queued, applied, suppressed, dropped}Dispatcher counters
bfme.configureKeyBindings(routes, ownedActions)BooleanAtomic native capture/ownership table used by dev/bindings.lua
bfme.setPointerButton("left" or "right", down)BooleanNative pointer press/drag/release at the cursor; renew each held frame
bfme.setPlacementEnd(x, y)BooleanWhile a building placement is anchored, turn it towards this client point (applied on the game thread; a request expires after 250 ms)

Editable bindings, chords and the full action list: Key bindings & native actions.

Controller, cursor and camera

SignatureReturnsBehavior
bfme.getGamepad([index [, out]])GamepadStateXInput controller 0..3 (default 0). Reuses out and out.buttons when given.
bfme.moveCursor(dx, dy)BooleanMove the cursor by client pixels; each finite, within �4096; +Y is down
bfme.getCursor(){x, y, width, height} or nilCursor position and window size in client pixels; nil while the game is unfocused
bfme.moveCamera(dx, dy)true or false, reasonPan along map axes; each finite, within �100 world units per call
bfme.setCameraPosition(x, y)true or false, reasonCenter on world X/Y; absolute values below 10,000,000
bfme.getCameraStatus()CameraStatusReadiness, queue/update/applied counters and camera mode
bfme.setMouseButton("left" or "right", pressed)BooleanLegacy SendInput mouse adapter
bfme.pressKey(name [, "CTRL"])true or false, reasonLegacy DirectInput key pulse

pressKey accepts only SPACE, ESC, TAB, H, B, Q, F9, NUMPAD5 and TICK, with an optional CTRL modifier, and queues at most 16 presses. The bundled controller uses runAction and setPointerButton instead of the legacy adapters. Camera displacement expires after 250 ms and is suppressed in nonzero (scripted/cinematic) camera modes. Input functions reject requests while the console is open or the game is unfocused. Details: Controller & input.

Dialogs

SignatureReturnsBehavior
bfme.showMessageBox(text [, title [, buttons]])Request ID or nil, reason"ok", "okcancel" or "yesno"
bfme.showInputBox(prompt [, title [, default [, buttonId]]])Request ID or nil, reasonOK/Cancel with a text field
bfme.getDialogResult(id)DialogResult or nil, reasonstatus, button, buttonId, error, value
bfme.cancelDialog(id)BooleanCancel a pending or open request

Details: Dialogs & JSON.

Videos

SignatureReturnsBehavior
bfme.playVideo(name [, "fullscreen" or "hud"])true or false, reasonPlay a video by its Video.ini name. fullscreen (default) uses the display's movie player exactly as the game's fullscreen-movie script action does; hud plays it in the palantir/HUD window. Starting a video stops the one already playing in that place.
bfme.stopVideo(["fullscreen" or "hud" or "all"])true or false, reasonStop playback. Omitted means all.
bfme.isVideoPlaying()BooleanWhether a fullscreen video is playing.

Requests run on the game thread on the next update, in menus as well as in matches, and expire after two seconds. Each start fires onVideoStart with the video's name. An unknown name produces no video and no event. Example: dev/examples/video.lua.

Save synchronization

SignatureReturnsBehavior
bfme.startSaveSync()true, or false, "disabled"Start the background worker when save-sync.ini has Enabled=1. Repeated calls are harmless.
bfme.syncSaves("pull")BooleanQueue a full reconciliation with the server
bfme.syncSaves("upload", file)BooleanQueue one upload; file is a server ID such as "saves/Autosave.BfME2Save"
bfme.stopSaveSync()NothingStop the worker after its current operation

syncSaves returns false when the worker is not running or 128 requests are queued; duplicate requests are merged. Events: onGameSaved, onDataChanged, onSaveSync. Setup and conflict rules: Encrypted save sync.

Standalone command buttons

bfme.setUIElementVisible(element, visible) returns true, or nil, reason. Supported elements are buildingCommands (native selected-building command buttons), heroList, builderList, and heroBuilderList (both bottom lists). Pass false to hide and true to restore normal engine visibility. Showing does not force empty lists or commands without a selection to appear. Policies persist across selection changes and matches, and reset on script reload. The standalone custom builder button has its own visible field.

bfme.setControllerCursor{enabled=, center=, size=, fps=} replaces the game's Windows cursor with the Xbox 360 cursor drawn from dev/cursors/*.tga. While enabled the system cursor is hidden; center=true also keeps it at the middle of the game window. It is the 360 reticle; the A-button variant (reticle_a) shows when the game's own cursor is select, move, attack or enter. bfme.getControllerCursor() returns {enabled, center, native, sprite}. dev/controller.lua manages it.

bfme.playSound(path [, volume]) plays a WAV file through the game's own audio driver at the in-game SFX volume, optionally scaled by volume (0..1). Relative paths start in dev/. Up to four sounds overlap (a fifth replaces the oldest); bfme.stopSound() stops them all. Files are cached after first use. Returns true, or false and a reason.

bfme.setUIElementLayout("buildingCommands", "bottom", options) presents the selected-building and builder construction commands in a horizontal row of standalone custom buttons. Original controls are hidden while the row is active; clicks forward to their native command handlers. Options are {size=64, gap=6, x=16, y=16}. The row is centred at the bottom of the screen; x is the minimum side margin and y the bottom margin, in client pixels. The row shrinks buttons if necessary to fit the screen. It preserves native command ownership, enabled/cooldown state and tooltip callbacks, and makes room by suppressing the hero/builder list while the row is displayed. Switch to "native" to restore the game's positioning. Selecting a unit or deselecting the building removes the custom row and restores the normal list policy. Requesting "bottom" shows building commands if they were explicitly hidden.

dev/building_commands.lua enables the bottom layout by default. Edit dev/lib/building_commands_config.lua and reload with Ctrl+Shift+F5 to adjust it. Native fixture tests cover custom button creation, reuse, resize and cleanup; the running-game appearance, mouse hit testing, tooltips and cooldown rendering still need verification.

lua
bfme.setUIElementLayout("buildingCommands", "bottom", {size=64, gap=6, x=16, y=16})
bfme.setUIElementLayout("buildingCommands", "native")
lua
bfme.setUIElementVisible("buildingCommands", false)
bfme.setUIElementVisible("heroBuilderList", false)
-- Restore either later:
bfme.setUIElementVisible("buildingCommands", true)
bfme.setUIElementVisible("heroBuilderList", true)

Changes run in native UI handlers; no Lua onFrame callback is needed. Command visibility reconciles at most every 125 ms while suppressed; the default idle path does no UI scans. Native fixtures verify policy and restoration; visual layout and mouse interaction still need a running-game check.

SignatureReturnsBehavior
bfme.createCommandButton(spec)ID or nil, reasonQueue a native button owned by this script
bfme.updateCommandButton(id, changes)true or nil, reasonChange presentation, position, visibility or enabled state
bfme.removeCommandButton(id)true or nil, reasonRemove its desired state and native window
bfme.getCustomCommandButtonStatus(id){state, error} or nil, reasonCreation/visibility status for this script's button

Spec fields: x=16, y=16, size=64, anchor="topright", command="Command_ConstructMenPorter", icon="", title="Select Builder", description="Selects one of your builders at random.", enabled=true, visible=true. Anchors also accept topleft, bottomleft, bottomright and center; coordinates are client pixels and follow resolution changes. Size is 16..512, coordinates -32768..32768, text at most 4096 UTF-8 bytes without NUL. Limit: 32 buttons. The command supplies border/style art. Optional icon supplies a MappedImage name (the INI ButtonImage value); empty uses the command's original icon. This overrides only the custom window's icon. Its normal gameplay action is replaced by onCommandButtonClicked({id, button="left"}) delivered only to the owning script. Native windows recreate across matches and are removed on script reload/VM close. Status states: pending, waiting for match, visible and hidden.

The bundled dev/builder_button.lua selects and centers a random local builder. Configure it in dev/lib/builder_button_config.lua. Native appearance/input still requires in-game verification; see COMMAND_BUTTONS.md for the checklist.

Reference / Lua 5.4.9

Data types and records

Live objects versus ordinary tables

Template, Player, Durability and instance objects are native userdata with labeled string representations. They support lookup through collections and equality within their generation. Collections are also userdata; entries cannot be replaced. API result lists and inspection records are ordinary detached Lua tables.

TypeConsole labelAccess
TemplateTemplate[id]bfme.templates[id]
UnitTypeUnitType[id]bfme.unitTypes[id]
BuildingTypeBuildingType[id]bfme.buildingTypes[id]
DurabilityDurability[id]definition.durability or nil
PlayerPlayer[slot]bfme.players[slot]
UnitUnit[objectId]bfme.unitInstances[objectId]
BuildingBuilding[objectId]bfme.buildingInstances[objectId]

Template id is a string; instance id is an integer. Player proxy id, playerId arguments and instance.playerId are integer engine slots. None of these proxies exposes native pointers.

Template and player fields

Template identity and metadata: id, name (same internal ID), faction/side (native Side spelling), displayName, and optional durability. Writable fields are cost, refund, buildTime/trainTime, siegeOnly (buildings), level (when the type has a starting-rank behavior), visionRange, shroudClearingRange, bounty and displayName; see Definitions for production time, siege-only and levels. Durability exposes writable maxHealth and initialHealth. Player exposes writable money (gold alias), team, commandPoints, commandPointsBase, commandPointsBonus, commandPointsCap and commandPointsUsed. commandPointsRemaining is derived and read-only.

Writable fieldAccepted value
cost, refundInteger 0..65535
buildTime / trainTime, visionRange, shroudClearingRangeFinite number 0..100,000,000
bountyInteger 0..100,000,000
displayNameValid UTF-8, at most 4096 bytes, no embedded NUL
durability.maxHealthFinite number greater than 0, at most 100,000,000
durability.initialHealthFinite number 0..100,000,000, or -1 for maximum
money, goldInteger 0..2,147,483,647
commandPoints, commandPointsBase, commandPointsCap, commandPointsUsedInteger 0..1,000,000
commandPointsBonusInteger -1,000,000..1,000,000
teamInteger 0..19; runtime alliance group, 0 is neutral
handicapInteger 0..100; percentage, 0 means no handicap
powerPoints, powerpointInteger 0..1,000,000; shared alias for unspent spellbook points
siegeOnly (building definitions)Boolean
level (definitions)Integer 1..1000; only when the type already has ExperienceLevelCreate
level (instances)Integer 1..1000; must be defined for that type in the ExperienceLevel INI

Player also exposes read-only boolean isAI and aiEnabled. Use the AI functions to change strategy execution.

See Templates & players for numeric ranges, shared body data, faction filtering and the distinction between template changes and existing unit health.

Unit and building fields

FieldTypeWritable? / meaning
id, objectIdIntegerRead-only engine object ID
typeIdStringRead-only template ID
type, templateTemplate proxy or nilRead-only reference to matching definition
isBuilderBoolean or nilGuarded native DOZER flag; nil when detection is unavailable
isHeroBooleanNative KindOf HERO (bit 90); includes a few hero summons such as Crebain
playerId, playerInteger or nilRead-only engine owner slot
ownerPlayerPlayer or nilWritable: assign engine slot integer or current Player object
factionStringRead-only definition faction, independent of owner
displayNameStringRead-only localized template name snapshot
positionPosition tableDetached copy; writing its fields does not move the object
x, y, zNumberRead-only world coordinates
rotationNumberRead-only radians
health, maxHealthNumber or nilRead-only; nil for unsupported body modules
levelIntegerWritable native experience rank; 1 when the object has no tracker
buildTime, trainTime, siegeOnlyNumber / BooleanThe shared type's values; writing changes the type for every player
underConstruction, constructionPercent, builderIdBoolean / Number / IntegerBuildings only, read-only construction state

Position is {x=number, y=number, z=number} in game world coordinates. Instance inspection returns id, optional playerId, rotation, position, optional health/maxHealth, typeId, faction and displayName. It omits ownerPlayer and template proxies. Editing any inspection result has no game effect.

MenuAction

FieldTypeMeaning
idStringExact action ID: legacy mainmenu.Skirmish or native AptOptions::Cancel / level-qualified popup callback
nameStringNative callback name without screen prefix
screenStringNormalized menu context, e.g. mainmenu, skirmish, options, quitmenu or saveload
sourceStringnative callback (legacy catalog) or native APT registry
supportedBooleanCallback contract mapped by this integration
activeBooleanCurrent state and fresh snapshot permit queuing
stateIntegerNative menu state; see Menu action catalog
levelIntegerAPT level for level-qualified callbacks or a focused movie matched in the native all-focus mode, otherwise -1; present on registry entries

This record is a callback catalog entry. It is not a visible Flash widget and has no coordinates, screen label or highlight field.

CommandButton

FieldTypeMeaning
idIntegerTemporary opaque command/selection identity
panelStringportrait, building or radial
slotInteger1-based displayed slot; gaps preserved
sourceSlotInteger0-based engine command slot
objectIdIntegerAssociated engine object ID
nameStringNative command name
visibleBooleanTrue for included entries; hidden slots are omitted
enabledBooleanCurrently actionable state
stateIntegerNative visual state; numeric meanings not fully cataloged
percentIntegerNative progress value; raw rather than a normalized fraction
titleKey, descriptionKeyStringLocalization keys available before tooltip capture
tooltipTooltipCaptured tooltip snapshot
screenX, screenYIntegerButton center in game-window client pixels; present only for radial/world buttons with a native window position
width, heightIntegerButton size in pixels; present with screenX/screenY

Tooltip

title, description, cost and shortcut are UTF-8 strings. Cost is display text, not a numeric price. captured is boolean; false means localized tooltip text has not been captured. ageMs is integer milliseconds (0 when never captured).

The no-argument getCommandTooltip additionally returns active and an optional buttonId when matched. Cached tooltip text may need a fresh hover after resources, upgrades or cooldowns change. Hover requests are asynchronous; read the tooltip on a later update.

BuilderOption and SelectionState

BuilderOption: {id=integer, slot=integer, selected=boolean}. Its slot is a 0-based command slot and its id is an opaque preview identity, not a template ID or command-button ID.

SelectionState: {active=boolean, count=integer, preview=boolean, building=boolean, x=number, y=number}. active reports an active match selection snapshot, not necessarily a nonempty selection. Count is zero when inactive. x/y are selection coordinates used to center the camera; building identifies structure selection and preview identifies construction preview.

GamepadState and InputEvent

GamepadState: connected boolean; leftX, leftY, rightX, rightY normalized -1..1 (Y up); leftTrigger/rightTrigger normalized 0..1; buttons map of booleans.

Known button keys: A, B, X, Y, Start, Back, LeftStick, RightStick, LeftShoulder, RightShoulder, DpadUp, DpadDown, DpadLeft, DpadRight. Reserved10/Reserved11 are present but not mapped. Triggers are axes, not button-event names. Disconnected/unfocused snapshots are neutral.

InputEvent has key, pressed, repeatKey, code and message. Gamepad events also have device="gamepad" and controller player 0..3. Keyboard events have device="keyboard" and Boolean ctrl/shift/alt fields. binding=true identifies native captured command events; their context is game or shell. Other notifications have binding=false and context="unknown". Keyboard names are stable uppercase API names such as ESC, ENTER, NUMPAD5 and F1, independent of Windows' localized key display labels. Mouse button events include x/y client coordinates and MouseLeft/MouseRight/MouseMiddle key names. Do not use event.code as a universal device-independent key ID. No mouse motion/wheel callback is provided.

SharedValue and Message

SharedValue is a copied boolean, finite number, string or plain nested table with string/integer keys. Native userdata and functions cannot cross Lua states; send IDs and look up live objects in the receiver. A nil shared assignment deletes a key. Message is {id=integer, payload=SharedValue}; payload cannot be nil. Mailbox reads return a dense FIFO list and remove the returned messages. See Modules & communication for size limits and lifetime.

MenuContextInfo

{name, mode, movie, level, available}. name is the normalized context, as from getMenuContext(). mode is "frontend", "ingame" or "unknown". movie is the native APT movie filename, or empty. level is the focused APT level 0..13, or -1. available is false when the snapshot is missing or older than 500 ms; the other fields then read "unknown", "" and -1.

Power

{id, name, cost, purchasable, prerequisites} plus unlocked and available when a player was given. prerequisites is a list of groups; any one group satisfies the requirement, and every ID within that group is required. See Match control, powers & defeat.

DialogResult

{status, button, buttonId, error} plus value for an accepted input box. onDialogResult also includes id and the common event fields. See Dialogs & JSON.

Event records

Callback arguments are described in Callbacks & events. Every game event table has event, id, playerId, typeId, displayName, faction and position, plus event-specific fields.

Status records

RecordFields
LiveDataStatusready (boolean), generation (integer), templateCount (integer), optional localPlayerId (integer)
InstanceStatusready, actionsReady, builderDetectionReady (booleans), unitCount, buildingCount (numbers), optional snapshotAgeMs (number)
CameraStatusready (boolean), queued, updates, applied, mode (integers); mode starts at -1 before observation
CommandUIStatusready, buildingVisible (booleans), build (string), portraitFrames, sideFrames, radialFrames, worldButtonFrames, worldDrawFrames, buildingWindowScans (integers)
PerformanceStatcalls (integer), averageUs, totalUs, peakUs (numbers); microseconds of measured work
FrameRateStatusenabled (boolean), targetFPS (integer: 60 when enabled, otherwise 30), clientUpdates, logicStages (integer counters)

Lifetime and return conventions

Handles expire when native registries are replaced or an observed object disappears. Reusing an observed-deleted object ID yields a different handle. Exact pointer/ID reuse between polls cannot be guaranteed detectable. Do not retain handles across match transitions.

Functions generally use false, reason for queued input/UI failures and nil, reason for unavailable resources or instance actions. Bad arguments, invalid field writes and stale object field access can raise Lua errors. Use pcall around optional access when a retained handle might have expired.

Mutation status is a string: pending, applied, or an explanatory failure. The engine retains up to 1024 results; template/player writes allow 128 pending mutations and instance actions allow 128 pending requests. UI queues have their own limits.

Reference / Lua 5.4.9

Implemented Lua live data

Available through import("bfme"). This implementation targets the installed game.dat with SHA256 01ad4ce0d6177d23f6d5055ea83512311c4412d23e4b8cb1b1e273f06dd2465f. Native signatures and parser offsets are checked before enabling writes. Signatures that do not match leave these APIs disabled; see the startup log.

lua
local bfme = import("bfme")
local state = bfme.getLiveDataStatus()
print(state.ready, state.templateCount, state.localPlayerId)

-- Enumerate the engine's loaded definitions, including unspawned types.
for id, definition in pairs(bfme.unitTypes) do
    print(id, definition.displayName, definition.cost)
end

local soldier = bfme.unitTypes["GondorSoldier"] -- use an enumerated ID
print(soldier) -- UnitType[GondorSoldier]
soldier.cost = 50
soldier.buildTime = 5
soldier.displayName = "Veteran Soldier"
if soldier.durability then
    print(soldier.durability) -- Durability[GondorSoldier]
    soldier.durability.maxHealth = 500
    soldier.durability.initialHealth = -1 -- use maximum health
end

local playerId = state.localPlayerId
if playerId then
    print(bfme.players[playerId]) -- Player[actual engine slot]
    print(bfme.players[playerId].money)
    local ticket = bfme.players[playerId]:set('money', 10000)
    -- Check on a subsequent frame; the main game update applies the write.
    print(ticket)
end

Collections are native userdata: bfme.templates, bfme.unitTypes, bfme.buildingTypes, and bfme.players. They support lookup, pairs, and #. The full template catalog includes nonselectable definitions; the unit catalog filters selectable nonstructure templates, and the building catalog filters STRUCTURE templates. Collection entries cannot be replaced. Repeated lookups compare equal with == within a registry generation.

Definitions expose read-only faction (also side) from the native Side string, so heroes are grouped by faction without guessing their ID prefix:

lua
for id, u in pairs(bfme.unitTypes) do
    if u.faction == "Mordor" then print(id, u.displayName, u.cost) end
end
for id, b in pairs(bfme.buildingTypes) do
    if b.faction == "Mordor" then print(id, b.displayName, b.cost) end
end

Faction strings use the game's own spelling; print u.faction to discover it. An empty string means that definition has no Side. Shared/captured units retain their definition's faction, irrespective of the player currently owning them.

Players, alliances, money and command points

bfme.players[id] enumerates active engine slots, including neutral slot 0. Use pairs(bfme.players); ipairs skips neutral. #bfme.players is the active player count. bfme.players.neutral aliases slot 0 and bfme.players.localPlayer resolves the current local slot; unavailable slots return nil. Player IDs are integers. Collection entries cannot be replaced. The old money and CP getter/setter commands have been removed.

lua
for id, player in pairs(bfme.players) do
    print(id, player.team, player.money, player.commandPoints,
          player.commandPointsUsed, player.commandPointsRemaining)
end
local player = bfme.players.localPlayer
if player then
    player.money = 10000
    player.commandPoints = 2000
    player.team = 2
    player.commandPointsBase = 1500
    player.commandPointsBonus = 100
    player.commandPointsCap = 2000
    player.commandPointsUsed = 50
end
bfme.players.neutral.money = 500
-- Use :set when you need a mutation ticket:
local ticket = bfme.players[1]:set("money", 10000)
-- Check after a native game update:
print(bfme.getMutationStatus(ticket))
Player fieldWritableMeaning / range
money, goldYesSame engine balance; integer 0..2,147,483,647, native deposit/withdraw bookkeeping
teamYesAlliance group 0..19; 0 means neutral relations
commandPointsYesEffective capacity; integer 0..1,000,000, native base/cap setter
commandPointsBaseYesUnmodified base capacity, integer 0..1,000,000
commandPointsBonusYesNative additive bonus, integer -1,000,000..1,000,000
commandPointsCapYesHard capacity ceiling, integer 0..1,000,000
commandPointsUsedYesCurrent usage counter, integer 0..1,000,000
commandPointsRemainingNoDerived capacity minus usage; can be negative
idNoEngine slot identity
displayName, usernameNoNative UTF-8 player name: profile name or AI label (for example "Easy"); empty when unnamed
handicapYesInteger percentage 0..100; 0 means no handicap
powerPoints, powerpointYesUnspent spellbook points, integer 0..1,000,000, applied through the native point routine
isAINoBoolean: the player has an AI controller
aiEnabledNoBoolean: AI controller present and not suspended by disableAI

The engine stores alliances as directed player/team relationships, rather than one live team-number scalar. On registry creation, this API groups mutually allied players under the lowest allied engine slot; neutral starts at 0. These are runtime alliance labels, not lobby team numbers. Assigning team changes both directions of native player relationships: equal nonzero labels become allies, different nonzero labels become enemies, and either label 0 becomes neutral. Overrides for the affected players' default engine teams are removed so they cannot mask the assignment. Unit ownership is unchanged. Labels persist for the registry generation; external script diplomacy changes are not automatically converted back into new group labels.

Snapshots refresh every 100 ms. All writes run on the main game update thread. CP base, bonus, cap and usage edits mark the native CP holder dirty. Setting commandPoints adjusts base and cap to reach the requested effective capacity while retaining farm/upgrade modifiers. Later modifier changes can change capacity. Direct edits to commandPointsUsed are allowed, but native unit creation/destruction can subsequently change the counter.

Each object has a label, read-only identity (id, and template name), :inspect() for a detached {label, kind, id, values} snapshot, and pairs for its writable scalar values. Editing an inspection snapshot does not change the game. Console expressions and print show the object label.

ObjectWritable valuesBehavior
Definitioncost, refundInteger 0–65535; used by subsequent purchases/refunds
DefinitionbuildTime / trainTime, visionRange, shroudClearingRangeFinite nonnegative floats; native template values. Build and train time are aliases.
Building definitionsiegeOnlyBoolean; see Definitions
DefinitionlevelStarting experience rank, when the type already grants one
DefinitionbountyNonnegative integer up to 100,000,000
DefinitiondisplayNameUTF-8 text; engine UTF-16 string setter manages storage
.durabilitymaxHealth, initialHealthVerified ActiveBody and derived module data; affects newly created bodies
PlayergoldInteger 0–2,147,483,647; engine deposit/withdraw updates its bookkeeping

maxHealth must be positive. initialHealth accepts nonnegative values or -1. Durability is nil for templates without a verified supported body module. Inherited definitions can share module data; editing shared health data affects each definition that uses that module. Vision values cached by existing objects are not refreshed by this template API. Display-name changes update the template; an already open tooltip may need reopening.

Assignments enqueue writes. object:set("cost", 50) returns a mutation ticket; normal assignments have the same behavior but discard the ticket. Use bfme.getMutationStatus(ticket) for pending, applied, or a failure reason. Tickets retain up to 1024 results and the queue accepts 128 outstanding writes. Reads return committed snapshots. Template catalogs rebuild when the native registry changes; console reads reuse the catalog rather than rescanning it. Successful writes refresh affected cached values in that update, including definitions sharing edited durability data. External template edits made outside this API are not continuously rescanned. Players refresh at most every 100 ms. Player IDs are engine slot IDs, including neutral slots; use the reported localPlayerId rather than assuming player 0 or 1.

Lua threads read snapshots and enqueue commands. The native main-view update callback performs mutations. Registry replacement expires handles; queued writes check generation and object identity before applying. Invalid values, unknown fields, and expired handles produce errors rather than silently editing a detached table. Unavailable player lookups return nil; invalid field writes raise a Lua error.

Remaining native work

Read-only spawned-object registries are now available as bfme.unitInstances and bfme.buildingInstances; see INSTANCES.md. The broader design in LIVE_DATA_DESIGN.md includes armor and propagation to existing units' health. Those setters are not implemented. There is no armor setter yet, and this implementation does not change an existing unit's current health. Catalog data is runtime-only and is reloaded from the game's data on a fresh game launch.

tests/live_data.cpp checks catalog filtering, labels, field validation, detached inspection, UTF-16 name mutation, health module writes, deferred money changes, stale handles, console output and native signatures against the actual PE file. These tests use a simulated native heap; in-game propagation remains to be checked after restarting with the new DLL. bfme.swapPlayers(a, b) accepts two active non-neutral Player proxies or engine player IDs and returns a mutation ticket. It swaps existing armies, buildings, money and CP counters on the game thread. Names, alliances, human/AI identity, faction upgrades, powers and AI strategy stay with each player. Call it again with the same players to swap back.

Source: LIVE_DATA.md · Documentation for the current workspace bindings.
Reference / Lua 5.4.9

Live units and buildings

Enter a match, then:

lua
bfme = import("bfme")
print(bfme.getInstanceStatus().ready)

The API requests native snapshots when inspected. If the first command returns an empty collection, try again after a game update. Snapshots refresh at most every 100 ms while the API is in use; scanning stops two seconds after the last inspection. Lua threads read copies and never traverse live engine pointers.

lua
-- Dense lists for ipairs; omit playerId to include all owners.
local playerId = bfme.getLiveDataStatus().localPlayerId
local units = bfme.unitInstances(playerId)
local buildings = bfme.buildingInstances(playerId)
for _, u in ipairs(units) do
    print(u, u.id, u.typeId, u.playerId, u.faction,
          u.position.x, u.position.y, u.position.z,
          u.rotation, u.health, u.maxHealth)
end

-- Full collections, indexed by the actual engine object ID.
for id, building in pairs(bfme.buildingInstances) do
    print(id, building.displayName, building.health)
end
print(#bfme.unitInstances, #bfme.buildingInstances)

-- Use an ID from the lists above.
local unit = bfme.unitInstances[1234]
if unit then
    print(unit) -- Unit[1234]; buildings display Building[1234]
    print(unit.type) -- matching UnitType[...] template object
    for key, value in pairs(unit) do print(key, value) end
    local snapshot = unit:inspect()
end

unitInstances[playerId] looks up an object ID, not a player. Use unitInstances(playerId) to filter by owner, and unitInstances() for all units. Engine player slot IDs include neutral players. Passing nil means all players, so check that your local player ID is available before filtering.

FieldMeaning
id, objectIdEngine instance ID, distinct from its template ID
typeIdInternal template name
type, templateLive template proxy; nil if its catalog is unavailable
playerId, playerControlling player's slot; nil for unowned objects
ownerPlayerCurrent Player[...] object; assigning an ID or Player object queues an ownership transfer
faction, displayNameDefinition's Side and localized display name
positionDetached {x, y, z} in world coordinates
x, y, zDirect coordinate access
rotationNative heading in radians
health, maxHealthCurrent body values; nil for unsupported body modules
levelWritable native experience rank; see Definitions, levels & command data
buildTime, trainTime, siegeOnlyThe shared type's values; assigning changes the type for every player
underConstruction, constructionPercent, builderIdBuildings: native construction state; see Definitions, levels & command data

Collections contain STRUCTURE objects and selectable nonstructure objects, matching the template catalogs' classification. This includes individual troops, horde objects, heroes and builders when selectable; it excludes nonselectable effects and projectiles. Destroyed objects disappear when removed from the engine ID registry. A corpse still registered by the engine can remain in the list with zero health. Contained and offscreen objects are included.

Position, rotation and health fields remain read-only; ownerPlayer and level are the writable instance fields. Editing position or :inspect() changes a detached copy. Template setters remain available through instance.type. The native body pointer is now correctly read from Object+25C (the prior instance reader incorrectly used +26C).

Actions and selection

unit:select() and building:select() select that object, replacing the current selection; object:select(true) adds it instead. This runs the same steps as the game's own select-unit script action: deselect (unless adding), send the "create selected group" game message so orders go to it, and mark it selected in the UI. Objects owned by another player are only shown as selected (inspected), as with a mouse click. The ticket fails with "object is not selectable" or "object has no drawable" when the game would not allow it.

lua
bfme = import("bfme")
building = bfme.getSelectedBuilding()
unit = bfme.getSelectedUnit()
selected = bfme.getSelectedObject() -- first selected building or unit
selection = bfme.getSelectedObjects() -- dense list, including multiselection

-- Pass the local player's engine slot explicitly if desired.
selection = bfme.getSelectedObjects(bfme.getLiveDataStatus().localPlayerId)

if building then
    ticket, reason = building:spawnUnit("GondorSoldierHorde") -- use an enumerated unitTypes ID
end
-- On a later update:
print(bfme.getMutationStatus(ticket))

if unit then
    print(unit.ownerPlayer, unit.playerId)
    unit.ownerPlayer = 2 -- transfer to engine slot 2
    -- Alternatively: unit.ownerPlayer = bfme.players[2]
    killTicket = unit:kill()
end
if building then
    destroyTicket = building:destroy()
    -- building:destory() is also accepted as an alias.
end

spawnUnit(templateId) queues normal training through the building's ProductionUpdate interface. Training takes its normal time, charges the normal cost, and uses the engine's doors, exit positioning and rally-point behavior. The native validation enforces resources, prerequisites, population and queue capacity. Buildings without a mapped production module reject the request. This does not create a unit at arbitrary coordinates or bypass training. The ticket reports whether the training request entered the queue; applied does not mean the unit has finished training, and no instance ID is returned yet.

:kill() invokes the normal unit death routine with UNRESISTABLE damage and normal death. :destroy() queues engine destruction, including cleanup, rather than simulating a combat death. It works on units and buildings; on units it removes them silently, without a death animation or onUnitKilled. All actions execute on the game's main native update and recheck the instance and ID before running. Methods return a ticket or nil, reason; check getMutationStatus(ticket) on a subsequent update. Normal ownerPlayer assignment discards its ticket; read the refreshed playerId to verify the transfer. Ownership uses the engine's team setter and the destination player's default team, including notifications and bookkeeping. Other instance properties reject assignment.

Selections come from the local player's native UI selected-drawable list and can include inspected enemy objects. The optional player parameter identifies the viewing player, rather than filtering by the object's owner. Remote or AI player selections are unavailable and return nil, reason; their units are still available through the player-filtered instance collections. No selection returns nil for the singular getters and an empty table for the plural getter.

bfme.getInstanceStatus().actionsReady reports native action availability. Native signatures are checked for kill, destroy, ownership and production. Action tests use fake native callbacks; actual gameplay needs an in-game check.

Handles expire when objects disappear from a sampled registry or the GameLogic singleton changes. Reusing an object ID after an observed deletion produces a different handle. If the engine deletes and reuses exactly the same pointer and ID between snapshots, polling cannot detect that reuse; lifecycle hooks would be required to guarantee detection in that case.

Native mapping evidence is in decomp/instances: installed GameLogic::findObjectByID at 00449681, its hash lookup at 006B4E8F, ownership via 0068B678/0079FD6F, and the ActiveBody constructor 008C3841. The portable build tests a simulated registry with multiple owners, a building, body health, object deletion and ID reuse, and checks signatures against the installed PE. Actual match behavior still needs in-game verification.

Source: INSTANCES.md · Documentation for the current workspace bindings.
Reference / Lua 5.4.9

Definitions, levels and command data

This page covers runtime changes to shared game data: production times, siege-only buildings, construction state and builder consumption, experience levels, the command-button definition database and construction previews. All of these edit or read the engine's existing data. None adds a save-game field, and definition edits last only for the current game process. Put them in an entry script to reapply them after a restart.

A worked example is in dev/examples/definitions.lua. Files under dev/examples are not loaded automatically; copy one into dev/ or run its lines from the console.

Production time

Unit and building definitions expose buildTime and trainTime. They are aliases for the same native INI BuildTime value, in seconds, so writing either changes both.

lua
local soldier = bfme.unitTypes.GondorFighter -- use an enumerated ID
if soldier then soldier.trainTime = 15 end
local barracks = bfme.buildingTypes.GondorBarracks
if barracks then barracks.buildTime = 20 end
RuleDetail
RangeFinite number 0..100,000,000
ScopeThe shared template, so every player training that type is affected
TimingLater production calculations; elapsed time in an existing queue is not rewritten
ModifiersNative player and faction production modifiers still apply on top
Instancesunit.buildTime / building.trainTime read and write the instance's shared type

Siege-only buildings

Building definitions expose a writable Boolean siegeOnly. It defaults to true for native wall segments, hubs, gates, wall upgrades and defensive walls, and false for other building types. When true, an additional damage filter rejects damage to that building type unless the attacker's template has KindOf CAN_ATTACK_WALLS (the default mode). This includes rams, siege engines, wall-capable monsters and Battlewagons. Use bfme.setWallDamageMode('siegeEngine') for strict SIEGEENGINE classification, or bfme.setWallDamageMode('canAttackWalls') to restore the default. bfme.getWallDamageMode() returns the current mode. The wall mode applies to buildings with siegeOnly = true; fortress keeps use their separate fortress mode when enabled. explicit siegeOnly = false still bypasses the filter. Setters return true or nil, reason; change modes between network matches and use the same configuration on every client.

lua
bfme.buildingTypes.GondorBarracks.siegeOnly = true
DamageResult when siegeOnly = true
From an identified attacker with the selected KindOf flagNormal armor and damage rules
From an identified attacker without the selected KindOf flagRejected before armor processing
HealingUnchanged
Unresistable scripted deathUnchanged
No resolvable attacker (environment, removed source)Unchanged

The filter is a runtime policy, not an INI armor change, and it is not saved. Setting the property on an instance edits the shared building type. Assigning false explicitly disables the default for that wall type. Overrides survive player-list and match transitions while the template factory remains valid; replacing the factory clears them and restores wall defaults. Wall classification is checked directly at damage time, so new wall types and objects need no Lua polling or setup script. If the damage hook's signature does not match the executable, the property still reads false but writes are rejected.

Construction state

Live buildings expose read-only construction fields:

FieldTypeMeaning
underConstructionBooleanThe native UNDER_CONSTRUCTION status
constructionPercentNumber0..100 while under construction; 100 when complete
builderIdInteger or nilObject ID of the builder currently assigned, as the engine records it

Construction start and completion are reported by onBuildingStart(building, builder) and onBuildingFinish(building, builder); see Callbacks & events.

Builder consumption

Builder consumption is a Lua policy built from two primitives. Decide in onBuildingStart, remove the builder, and let the building finish alone:

lua
return {
    onBuildingStart = function(building, builder)
        if builder and building.typeId:find('Barracks') then
            bfme.destroyObject(builder.id) -- builder vanishes for good
            bfme.selfBuild(building.id)    -- building finishes without it
        end
    end,
}
FunctionReturnsBehavior
bfme.destroyObject(idOrHandle)Ticket or nil, reasonRemove any unit or building silently with the engine's destroy routine: no death animation, no onUnitKilled, no refund. Buildings still report onBuildingDestroyed. Also unit:destroy() / building:destroy().
bfme.selfBuild(idOrHandle)Ticket or nil, reasonLet an under-construction building finish without a builder. Also building:selfBuild(). Fails with "building is not under construction", "object is not a building" or "building unavailable".

Requests from one script run in order on the game thread, so destroyObject then selfBuild in the same callback act together. Both accept object IDs, so they work directly with the event records.

How self-building works, using the game's own routines:

  • Each logic frame the building advances by 100 / buildTime percent and gains the matching share of maximum health, using the engine's own build-time calculation (player and faction modifiers included). Progress stops while the game is paused.
  • It only advances while no living builder is assigned. If a worker is still building, or another worker resumes it, that worker drives construction natively and self-building waits, so progress is never doubled.
  • At 100% the native completion steps run: construction status and model conditions are cleared, the body and modules get their build-complete notifications, and the player's completion handler runs with no builder, as for script-built structures. onBuildingFinish fires with building.selfBuilt = true and the builder snapshot from the start.

Self-building is not part of save games: after loading a save, call bfme.selfBuild(id) again or send a worker to finish the building. The "construction complete" voice line is not played for self-built buildings. An example policy is in dev/examples/construction.lua.

Starting construction

lua
-- A worker walks to the site and builds it, as if the player had placed it.
bfme.startBuild('GondorBarracks', 1200, 860, worker, { angle = math.pi / 2 })
-- No builder: the foundation appears at once and builds itself.
bfme.startBuild('GondorBarracks', 1200, 860)
bfme.startBuild('GondorFarm', 900, 700, nil, { player = 2, free = true })
-- Extend a wall from a hub toward a point, stopping 20 units short of the hub's limit.
bfme.expandWall(hub, 1500, 860, 20)
FunctionReturnsBehavior
bfme.startBuild(typeId, x, y [, builder [, options]])Ticket or nil, reasonStart a building at map position x, y (placed on the ground). With a builder (a worker handle or object ID), the order goes to that worker, which walks to the site and builds natively; its owner pays when the foundation is placed. Without one, the building is created under construction for options.player, paid for, reported by onBuildingStart with no builder, and finished by bfme.selfBuild. Also unit:build(typeId, x, y [, options]).
bfme.expandWall(hub, x, y [, offset])Ticket or nil, reasonBuild a wall span from a wall hub toward x, y, as dragging a wall from that hub does. If the hub's MaxBuildoutDistance would not reach, the end is pulled back along the same line to that distance minus offset (default 0). Also building:expandWall(x, y [, offset]).

startBuild options:

OptionDefaultMeaning
angle0Building orientation in radians
playerLocal playerEngine player slot or Player object that owns the building. Only without a builder.
freefalseSkip the cost. Only without a builder.
forcefalseSkip the prerequisite, structure-limit and location checks. Resources are still checked when a builder pays.

Both use the player's own build rules unless force is set. Rejections set the ticket's status to the reason, for example "insufficient resources", "objects in the way", "invalid terrain or outside the map", "player cannot build this structure", "structure limit reached", "object cannot construct buildings" (the builder is not a worker) or "object is not a wall hub". A wall span the game rejects reports "wall span blocked (status N)". A wall hub cannot be started without a builder, because the game measures hub placement from the builder's player; extend walls with expandWall.

A builder's order is a normal command: the worker can be interrupted, and onBuildingStart fires when it arrives and begins building. The angle, player, free and force choices are not saved separately; the building itself is saved like any other.

Experience levels

Live units and buildings expose writable object.level. It is the engine's existing ExperienceLevel rank, not separate Lua data, so native level effects, starting ranks and save/load serialization behave as usual.

lua
local object = bfme.getSelectedObject()
if object then
    print(object.level)
    object.level = 3
end
RuleDetail
ValuesInteger 1..1000
Defined levelsThe rank must exist for that object type in the ExperienceLevel INI; otherwise the ticket fails with "level is not defined for this object type in ExperienceLevel INI"
Missing trackerObjects without an experience tracker report level 1 and reject writes
EffectThe native rank setter applies rank effects and stores the matching XP threshold
PersistenceSaved and loaded by native save games

Definitions also expose level: the starting rank granted by the type's existing ExperienceLevelCreate behavior (LevelToGrant), default 1. It is writable only when that behavior already exists on the template; the live catalog does not add behavior modules. Definition changes affect objects created afterwards and last for the session.

Command-button definitions

bfme.commandButtons is a read-only collection of CommandButton definitions from the game's INI, indexed by INI ID. It describes command data, not what is on screen; use getCommandButtons() for currently displayed buttons (Command buttons & tooltips).

lua
for id, button in pairs(bfme.commandButtons) do
    if button.trains == 'GondorFighter' then
        print(id, button.icon, button.commandType)
    end
end
print(#bfme.commandButtons)

Entries appear after the native ControlBar catalog loads. Overrides are resolved the same way the engine's own lookup does. Reading the collection starts a two-second lease; while leased, the catalog refreshes at most every 100 ms. If the control bar is rebuilt, existing handles expire and their queued writes are rejected.

Writable fields

FieldValueEffect
trains, objectExisting unit/building template ID, or "" to clearThe template that construction/training commands produce. Kind, prerequisites, price rules and CommandSet membership are unchanged.
iconExisting mapped-image ID (non-empty)Replaces the active icon variant. If the definition had no icons, the image is added as its first.

Write by assignment or button:set(field, value). set returns a mutation ticket; check it with bfme.getMutationStatus. Errors raised immediately: read-only field, empty or over-1023-byte value, unknown template ID, expired handle, or a full queue (128 pending). An unknown image ID fails on the engine thread with "unknown mapped image", and the existing icon is kept.

Changes apply to every use of that shared definition. A button already on screen may show the new icon only after the game refreshes it, such as on reselection.

Read-only fields

GroupFields
Identity and commandid, commandType, options
TexttitleKey, textLabels (list), descriptionLabels (list), purchasedLabel, conflictingLabel, lacksPrerequisiteLabel, toggleButtonName
Cursor and appearancecursorName, invalidCursorName, radiusCursorType, buttonBorderType, variantIndex, doubleClick, radial, inPalantir, icons (list of every resolved variant)
Weapons and powersweaponSlot, weaponSlotToggle1, weaponSlotToggle2, weaponSlotToggle3, maxShotsToFire, sciences (list of science IDs)
Conditions and visibilityneededUpgradeAny, requireLevel, requiresValidContainer, showProductionCount, isClickable, showButton
Automatic abilitiesautoAbility, triggerWhenReady, presetRange, autoDelay, needDamagedTarget, commandTriggers (list), commandRangeStart

Flag fields are Booleans, numeric fields are numbers, and labels are native string keys. button:inspect() and pairs(button) return a detached copy of all fields.

Construction previews

When a player chooses a building to construct, the integration checks the engine's construction permissions before showing the placement preview:

  • the player's build permissions and the template's prerequisites/buildable status
  • builder availability, money, population and object limits (BuildAssistant validation)

If construction is not allowed, the preview is cleared instead of following the cursor. The check repeats every 100 ms while a preview is active, so a preview disappears when resources or permissions change after selection. Reselect the command once construction becomes available.

The check takes no map coordinates. Normal red/green placement feedback for terrain and location is unchanged. This applies to every preview: mouse, keyboard hotkeys, controller and setBuilderBuilding.

Building placement restrictions

bfme.setBuildingRestrictions(false) disables the shared native location check used by building previews, orders and builder AI, including obstruction, terrain, slope, shroud and clearance checks. bfme.setBuildingRestrictions(true) restores normal checks. bfme.getBuildingRestrictions() returns whether they are enabled. Restrictions default to enabled each game process; the setter returns true or nil, reason when the native binding is unavailable. Costs, prerequisites and building limits remain in effect. This does not disable separate wall-span checks.

lua
bfme.setBuildingRestrictions(false) -- allow otherwise blocked building locations
bfme.setBuildingRestrictions(true)  -- restore normal placement

bfme.setWallGapRequirementEnabled(false) removes the spare wall segment requirement between a gate and its adjoining end hubs. A three-piece gate can then replace the middle of a three-piece wall span. Hubs inside the gate's actual width, other obstructions, terrain, costs and upgrade prerequisites still apply. bfme.setWallGapRequirementEnabled(true) restores normal clearance checks; bfme.getWallGapRequirementEnabled() returns the current setting (default true). The setter returns true or nil, reason if the binding is unavailable. Set this between matches and use the same Lua and DLL on every multiplayer client.

lua
bfme.setWallGapRequirementEnabled(false)

Intermediate wall hubs

bfme.setIntermediateWallHubsEnabled(false) substitutes a normal wall segment for intermediate hub templates in newly built wall spans. Existing walls are not changed. If the hub's segment list has no verified normal segment, its original template is retained. Pass true to restore intermediate hubs (the default). The setter returns true or nil, reason when the native binding is unavailable or a changed setting is requested during a network match. Set this between matches and use the same scripts and DLL on every client.

lua
bfme.setIntermediateWallHubsEnabled(false)

Wall health multipliers

bfme.setWallHealthMultipliers(walls, hubs, gates) configures separate health multipliers for native wall pieces, wall hubs and wall gates. Defaults are 1.0; each argument must be finite and in 0.01..100. Gates take precedence over hubs, and hubs over other wall flags. Other wall pieces include segments, cliff caps and wall upgrades classified by the native wall flags.

lua
bfme.setWallHealthMultipliers(2.0, 3.0, 1.5)
local health = bfme.getWallHealthMultipliers()
print(health.walls, health.hubs, health.gates)

The setter returns true for a setting queued for the native catalog update, or nil, reason if unavailable or invalid. The getter returns the configured values. Maximum and explicit initial template health are scaled from their original baseline; repeated calls do not compound. The native -1 initial-health default is retained. Setting all values to 1 restores that baseline. Changes affect newly created objects, not existing objects' current health. Edit dev/gameplay.ini to configure multipliers at startup. LAN clients adopt the host settings for the match. Change settings between matches and use the same DLL and Lua files on all clients. Like other durability edits, templates sharing native body data share the resulting health values.

Wall and fortress balance INI

Edit dev/gameplay.ini and restart the game. [WallRebalancing] contains DamageMode (canAttackWalls or siegeEngine), WallHealthMultiplier, HubHealthMultiplier, GateHealthMultiplier (0.01–100), IntermediateHubs (0/1), and GateGapRequirement (0/1). Health changes affect new pieces. The old wall_health.lua and wall_segments.lua no longer override these values.

[FortressRebalancing] has DamageMode=normal by default. Set it to siegeEngine to require the attacker's SIEGEENGINE flag, or canAttackWalls to require CAN_ATTACK_WALLS. This applies to native CASTLE_KEEP buildings (the six factions' fortress cores), not their expansions. Native armor still applies; healing, unresistable damage and damage without a resolved unit source retain native behavior. Explicit siegeOnly=false opts a definition out. bfme.setFortressDamageMode(mode) and bfme.getFortressDamageMode() expose the same setting to Lua; change settings between matches.

In LAN rooms, only the host needs this INI. Clients may omit it or keep different settings: they adopt the verified room host's rules before play and restore their own settings when leaving the match. The host's start action waits until every room client acknowledges the rules. INI differences do not count as Lua mismatches; the DLL and Lua files must still match. Settings are frozen during the match. This exchange uses the existing room compatibility UDP channel, port 64488. Two-PC delivery and gameplay remain to be verified in game.

Reference / Lua 5.4.9

Match control, powers and defeat

These functions change match state through the engine's own routines. Every mutating function queues its work for the native game update and returns a ticket, or nil, reason when it cannot queue. Check the outcome on a later update:

lua
local ticket, reason = bfme.pause()
if not ticket then print(reason) end
-- later:
print(bfme.getMutationStatus(ticket)) -- "pending", "applied" or a failure reason

Player arguments accept an engine slot integer (0..19) or a current Player[...] proxy. An expired proxy raises a Lua error. All queued requests share the live-data mutation queue (128 pending) and ticket history (1024 results).

Pause

SignatureReturnsBehavior
bfme.pause()Ticket or nil, reasonQueue the native pause used by the in-game menu.
bfme.unpause()Ticket or nil, reasonQueue native unpause.
bfme.isPaused()BooleanNative pause flag, refreshed on every game update.

Pause works only in offline matches. Network and online modes complete with "pause unavailable in network games". If the engine refuses the change, the ticket reports "native pause rejected". onPauseChanged reports transitions, whether caused by Lua or by the player.

AI control

SignatureBehavior
bfme.disableAI(player)Suspend that player's AI strategy update.
bfme.enableAI(player)Resume it.
bfme.setAIEnabled(player, enabled)Boolean form; enabled must be a Boolean.

Suspension skips the AI controller's per-update strategy routine, so that player stops making new strategic decisions. Orders already given to its units continue. The ticket fails with "player has no supported AI controller" for human players. Suspensions reset when the match ends.

Read the state with player.isAI (has an AI controller) and player.aiEnabled (has one and it is not suspended). Both are read-only.

lua
for id, player in pairs(bfme.players) do
    if player.isAI then print(id, player.displayName, player.aiEnabled) end
end
bfme.disableAI(bfme.players[2])

Powers (sciences)

The power catalog is the engine's science store: every faction's spellbook powers plus other sciences, not only those of your faction.

SignatureReturns
bfme.getPowers()Every catalog entry.
bfme.getPowers(player)Every entry, with that player's unlocked and available flags; nil, reason if that player's data is not ready.
bfme.getUnlockedPowers(player)Entries the player currently has. Player is required.
bfme.getAvailablePowers(player)Entries passing the engine's purchase check right now: prerequisites met and enough points. Player is required.
bfme.addPower(player, idOrName)Ticket. Unlock directly, without spending points.
bfme.removePower(player, idOrName)Ticket. Remove from the unlocked list, without refunding points.

A power entry is a copied table:

FieldTypeMeaning
idIntegerNative science ID
nameStringNative name, such as a SCIENCE_... identifier
costIntegerSpellbook point cost from the engine
purchasableBooleanWhether the science can be bought from the spellbook
prerequisitesList of listsOR of groups; each group lists IDs that are all required
unlocked, availableBooleanPresent only when a player was given

idOrName accepts the integer id or the exact name. An unknown value raises "unknown power; see bfme.getPowers()". Spellbook points are the writable player.powerPoints field (alias powerpoint).

The catalog is demand-driven: reading it starts a two-second lease, and it refreshes every 250 ms while leased. The first call after a quiet period can return an empty list; call again after an update. A player's unlocked and available sets are rebuilt in the same pass.

Limits:

  • Unlocking another faction's science records it as owned, but does not add a spellbook button or the ability module that faction's units carry.
  • Removing a prerequisite leaves dependent sciences unlocked.
  • removePower notifies the engine's script system of the removal and marks the in-game UI for refresh.
lua
local me = bfme.players.localPlayer
for _, power in ipairs(bfme.getAvailablePowers(me) or {}) do
    print(power.id, power.name, power.cost)
end
-- Unlock by exact native name or ID, taken from getPowers():
local first = bfme.getPowers()[1]
if first then print(bfme.addPower(me, first.name)) end

Swapping players

bfme.swapPlayers(a, b) exchanges two active non-neutral players' current units and buildings, money, and command-point base/bonus/cap/usage, then returns a ticket. Names, alliances, human/AI identity, faction templates, upgrades, unlocked powers and AI strategy stay with each player slot. Calling it again with the same players swaps back. See Templates & players.

Defeat and victory

SignatureBehavior
bfme.getDefeatCandidates()Dense list of player IDs that the engine's survival rules currently consider defeated but that Lua has not yet authorized.
bfme.defeat(player)With a Player[...] proxy: authorize defeat of that one player.
bfme.defeat(team)With an integer 1..19: authorize defeat of every eligible player whose team equals it.
bfme.victory(team)Authorize defeat of every eligible player not on that team, so the native winner flow completes.

An integer argument to defeat is always a team number, never a player slot. Pass bfme.players[slot] to defeat a single player.

Tickets fail with:

  • "Lua defeat unavailable outside offline skirmish or supported multiplayer" in campaign or unsupported modes.
  • "team unavailable" when no player has that team number.
  • "no eligible players" when nobody qualifies (for example, everyone is already defeated).

How Lua-managed defeat works

In an offline skirmish or a multiplayer match with the synchronized Lua bridge ready, the engine's automatic defeat check is gated. The engine still evaluates its configured survival rules (required buildings and mode-specific conditions), but a player is not marked defeated until Lua authorizes it with defeat or victory. Once authorized, the native defeat flow runs unchanged: cleanup, alliance victory checks, result screens and statistics. Both the survival predicate and an early-out in the enclosing check are gated, so no native path bypasses Lua.

The shipped policy is dev/victory.lua:

lua
local pending = {}
local function onBuildingDestroyed(building, player, hasEligibleBuildings)
    if not player or hasEligibleBuildings ~= false then return end
    local previous = pending[player.id]
    if not previous or bfme.getMutationStatus(previous) ~= 'pending' then
        pending[player.id] = bfme.defeat(player)
    end
end
return {onBuildingDestroyed = onBuildingDestroyed}

It requests defeat only on an explicit false from the engine's survival rules, and never for nil. Edit this file to change the policy, for example to add a grace period or ignore particular buildings.

If no script authorizes defeat, players in these managed matches never lose automatically. That is useful for sandbox testing, but deleting or breaking victory.lua changes normal play. After editing it, reload scripts with Ctrl+Shift+F5. Start a fresh match if a defeat was already authorized; an applied defeat cannot be undone.

Campaign, unsupported network builds and explicit surrender keep native behavior. Multiplayer gameplay events run on synchronized simulation updates; see Multiplayer Lua.

Defeat candidates

The older candidate interface is still available for custom policies. When the survival rules report a loss that Lua has not yet authorized, the player appears in getDefeatCandidates() and onDefeatCandidate(event, player) fires. The callback repeats at most once per second per player while the condition persists. The default policy does not use candidates.

lua
return {
    onDefeatCandidate = function(event, player)
        if player then bfme.defeat(player) end
    end,
}

onPlayerDefeated(event, player) fires after the native defeated flag becomes true, whatever caused it.

Reference / Lua 5.4.9

Dialogs and JSON

Native dialogs

Lua can open the game's own APT message box, optionally with a text-entry field. The dialog uses BFME2's built-in modal frame, Albertus font and OK/Cancel/Yes/No buttons. Native windows run on the game thread and Lua VMs do not, so every dialog is asynchronous: a call returns a request ID, and the result arrives later.

SignatureReturnsBehavior
bfme.showMessageBox(text [, title [, buttons]])Request ID or nil, reasonQueue a message box. buttons is "ok" (default), "okcancel" or "yesno". Default title "Message".
bfme.showInputBox(prompt [, title [, default [, buttonId]]])Request ID or nil, reasonQueue an OK/Cancel box with a text field. Default title "Input". buttonId is your own tag, echoed in the result.
bfme.getDialogResult(id)DialogResult or nil, reasonCurrent status of a request.
bfme.cancelDialog(id)BooleanCancel a pending or open request; false if unknown or already finished.

ShowMessageBox and ShowInputBox are global aliases in every VM, including the console.

lua
local id = ShowInputBox("Enter a name:", "Rename Unit", "New Name", "rename")
return {
    onDialogResult = function(result)
        if result.id ~= id then return end
        if result.status == "accepted" then
            ShowMessageBox("Entered: " .. result.value, "Message")
        end
    end,
}

DialogResult

FieldTypeMeaning
idIntegerRequest ID (in onDialogResult only; getDialogResult takes it as the argument)
statusStringpending, accepted, cancelled or error
buttonStringok, yes, no or cancel; empty while pending or on error
buttonIdStringThe tag passed to showInputBox, or empty
valueStringEntered text; present only when status == "accepted" for an input box (may be empty)
errorStringReason when status == "error", otherwise empty

The onDialogResult table is a common event record (see Callbacks & events) with these fields added. Choosing No in a yes/no box is status="accepted", button="no"; only Cancel, Escape, cancelDialog or the modal being closed elsewhere produce cancelled.

Limits and behavior

LimitValue
Message/prompt textUTF-8, at most 4096 bytes, no NUL
TitleAt most 512 bytes
Input default valueAt most 256 bytes; the native field permits 256 UTF-16 code units
buttonIdAt most 128 bytes
Queue16 requests waiting behind the active dialog
Retained results256 completed IDs; older ones become unknown
  • Requests wait while another engine modal (such as the game's own popups) is open, and until the APT modal controller exists.
  • Opening a dialog closes the Lua console so it releases keyboard capture.
  • In an input box, Enter accepts and Escape cancels.
  • "yesnocancel" is accepted as an argument but completes with an error, because the native controller has no such layout.
  • The text field looks like the game's "New Profile" name field. The native Window/APT/TextEntry.wnd gadget draws the text and caret; behind it the dialog draws the same frame the Skirmish profile popup uses (frameInputField300px from MenuExport.apt), read at runtime from the installed apt/MenuExport.big, centred and sized around the text area as in that popup. If that archive cannot be read, a plain dark box with a border is drawn instead. The field is scaled and centered in the game viewport.
  • An input box only collects text. It does not invoke the profile-creation workflow and cannot create or rename a profile.

An example is in dev/examples/dialogs.lua. It binds F9 to an input box; examples are not loaded automatically.

JSON

JSON is a global in every VM, including the console. require("lib.json") returns the same module.

FunctionBehavior
JSON.parse(text)JSON text to Lua values. Raises an error with the byte position on malformed input.
JSON.stringify(value)Lua value to compact JSON text. Raises an error on unsupported values.
JSON.nullSentinel for JSON null, so null array elements and object members survive a round trip.
JSON.array(table)Mark a table as an array, including an empty one. Returns the table.
JSON.object(table)Mark a table as an object, including an empty one. Returns the table.
lua
local data = JSON.parse('{"name":"Eowyn","level":3,"optional":null,"tags":[]}')
assert(data.optional == JSON.null)
print(JSON.stringify(data))
print(JSON.stringify({values = JSON.array({1, 2, 3}), empty = JSON.object({})}))

Shapes and types

  • Parsed arrays and objects keep their shape when stringified again.
  • Unmarked non-empty tables with dense integer keys 1..n become arrays; unmarked empty tables become objects.
  • Strings are UTF-8. Escaped surrogate pairs decode to the correct code point.
  • JSON numbers written without a fraction or exponent become Lua integers when they fit; 3.0 and 1e2 become floats. Integers too large for 64 bits lose precision; store exact identifiers as strings.
  • Object keys are written in sorted order, so output is deterministic. JSON.array() and JSON.object() with no argument create a new empty marked table.

Rejected input

  • Parsing: malformed JSON, duplicate object keys, more than 128 nested containers, input over 16 MiB. Parsing never executes code.
  • Stringifying: cycles, sparse or mixed-key tables, functions/userdata/threads, NaN or infinity, invalid UTF-8, more than 128 nesting levels, output over 16 MiB.

An example is in dev/examples/json.lua.

Reference / Lua 5.4.9

Bindings and native actions

Keyboard bindings are owned by dev/bindings.lua. A physical key reaches the native input translator, which queues an event for Lua. Lua matches the key, modifiers, press/release edge, and game/shell context, then calls bfme.runAction(name). The engine receives its named command message on a native update callback. The migrated stock CommandMap action is suppressed, including after its Lua binding changes or is disabled. Controller actions call the same functions directly.

The default migration contains 93 keyboard bindings from 94 active CommandMap records, including six bare Ctrl/Shift/Alt transitions. There are 121 verified named message IDs. This covers selection, control groups, saved views, stances, stop/scatter, attack-move mode, menus, chat, camera reset and other CommandMap actions. Availability depends on game state and selection. The byte guards match installed game.dat SHA256 01ad4ce0d6177d23f6d5055ea83512311c4412d23e4b8cb1b1e273f06dd2465f; other executable builds disable the dispatcher.

Change bindings

Edit dev/lib/keybinds.lua for persistent changes. keyboard starts with lib.commandmap_defaults; use the override loop in keybinds.lua to change an action's key. Set key=false to disable an action. A binding has action, key, edge (down or up), and context (game, shell, or all). Chords use names such as CTRL+V, SHIFT+UP, or ALT+1. Matching requires the exact modifier combination.

Bare chords use CTRL, SHIFT or ALT, with separate down/up records. Native low-level routes represent them as key='NONE' plus one modifier flag. These follow CommandMap's exact modifier mask, including release when switching to a combined mask. Focus loss, console capture, reload and reconfiguration queue paired native cleanup releases, which do not expire while the game is unfocused.

The console can change a binding for the current process:

lua
keys = require("lib.keybinds")
keys.set("STOP", "V")
keys.set("SELECT_ALL", "CTRL+Q")
keys.unbind("SELL")
keys.reset() -- restore file defaults and clear session overrides

Overrides are applied within 100 ms. Edit the file to keep them after restarting. Session overrides survive a script reload through shared state. Duplicate bindings are rejected and the last valid configuration remains active. Every Lua entry has its own VM: use this shared binding service rather than requiring another entry script.

To bind a custom Lua function, add it to M.custom in keybinds.lua, then add a keyboard record using that name. Return a Boolean status just like a native action:

lua
M.custom.clearSelection = function() return bfme.clearSelection() end
M.keyboard[#M.keyboard+1] = {action="clearSelection", key="CTRL+R", edge="down", context="game"}

Custom functions execute in the binding script's VM. Their return values and arguments can compose the existing instance, player, camera, menu, and command-button APIs. Functions are defined in source, since Lua closures cannot cross the shared-value bus.

Direct actions work from scripts and either console:

lua
bfme = import("bfme")
bfme.runAction("STOP")
bfme.runAction("SELECT_ALL")
bfme.runAction("SELECT_NEXT_WORKER")
for _, action in ipairs(bfme.getActions()) do print(action.name, action.messageId, action.available) end
print(bfme.getActionStatus().applied)

Native API

FunctionResultContract
bfme.runAction(name)true; or false, reasonQueue a verified named engine action. Unknown names raise a Lua error. Success means queued, not confirmation that game state allowed an effect.
bfme.getActions()List of {name,messageId,available}Available means native dispatcher installed. Numeric IDs are diagnostic; dispatch uses names.
bfme.getActionStatus(){ready,queued,applied,suppressed,dropped}Counts queued calls, native dispatches, intercepted stock bindings, and expired/full events.
bfme.configureKeyBindings(routes, ownedActions)BooleanLow-level atomic replacement used by bindings.lua. Route: {key,ctrl,shift,alt,context}. ownedActions lists migrated native action names, including disabled ones.
bfme.setPointerButton("left" or "right", down)BooleanNative pointer press/release at the current cursor. No SendInput or synthetic keyboard pulse. Renew held state each frame.
bfme.selectBuilder()BooleanCompatibility function now dispatches SELECT_NEXT_WORKER; no B-key injection.

Keyboard onInput records add binding, ctrl, shift, alt, and context. binding=true identifies a command event captured by the native router. Ordinary keyboard notifications remain available to other scripts. The binding entry executes only captured events, so async notifications do not execute actions twice. Repeated key-downs are swallowed for owned chords; actions fire once on the configured edge. A captured release retains its press modifiers and context even if Ctrl/Shift/Alt was released first or a menu opened in between.

Queue limit: 128 native actions, 512 captured input events, 256 routes/owned actions. Ordinary action requests expire after one second. Native calls happen on game/menu update threads; Lua callbacks never call engine functions on console/render threads. Pointer input releases on disconnect, focus loss, console capture, reload or loss of renewal after 250 ms.

Integration shortcuts

BindingBehaviorOwner
Backtick, without modifiersOpen/close Lua consolesrc/in_game_console.hpp; reserved before game DirectInput
Ctrl+Shift+COpen/close Lua console (alternative for Mac layouts)src/in_game_console.hpp; the C key is withheld from the game while this chord is held
Ctrl+Shift+F5Reload dev scriptssrc/hook.cpp; changed from F5 so saved-view F5 remains usable
Ctrl+F9Toggle log overlaysrc/hook.cpp; changed from F9 so native control-bar F9 remains usable
Escape in consoleClose consoleNative Rich Edit console
Enter in console inputExecute LuaNative Rich Edit console
Shift+Enter in console inputInsert newlineNative Rich Edit console
Up / Down in console inputPrevious / next persisted commandNative Rich Edit console
Tab in console inputInsert four spacesNative Rich Edit console
Ctrl+A / C / X / V, Ctrl+ZSelect / copy / cut / paste / undoStandard Rich Edit editing
F8 in builder_hotkey.luaCurrent custom script sets gold to 1,000,000 and command points to 100,000Existing dev script; also receives the F8 saved-view notification

The current builder entry's load message still describes builder selection; its edited callback changes resources. This is a separate custom script binding, not the default worker action. Its callback can be edited independently.

Controller defaults and Lua special actions

InputAction / owner
Left stickCursor movement; pushing against a screen edge pans the camera; controller/motion.lua
Right stickRotate (X) / zoom (Y); controller/motion.lua
A / BNative pointer left / right hold; keybinds.pointer
A, AUnit: native double click; ground with units selected: TOGGLE_ATTACKMOVE then click; nothing selected: SELECT_ALL
B outside a matchActive skirmish Back/profile cancel/stats exit or CreditsExit callback; otherwise OPTIONS
X in main/skirmish menupressMenuButton("mainmenu.Skirmish") / pressMenuButton("skirmish.StartGame")
X in matchsetCameraPosition(selection.x, selection.y), then FOLLOW
YVIEW_LAST_RADAR_EVENT
StartOPTIONS; twice within 400 ms calls closeGame() immediately
BackDIPLOMACY
Left-stick click / right-stick clickVIEW_HOME_BASE / CAMERA_RESET
D-pad up / down / leftCycle heroes / builders (select and centre; native SELECT_HERO/SELECT_NEXT_WORKER do nothing in BFME2 and are only a fallback) / control groups 1–9, 0 (skips empty)
LT + Akeybinds.selectAll (SELECT_ALL); no click
LB (held)Native Shift + Alt: BEGIN/END_PREFER_SELECTION, ORDERMODE_WAYPOINT/IMMEDIATE; LB + A adds a unit or sets a waypoint
RB + ANative click, then keybinds.selectType (SELECT_MATCHING_UNITS)
RT (held)Palantir: command navigation, controller/palantir.lua; thresholds 0.35 press / 0.20 release
RT + left stick / APoint at / activate a command; builder placement remains a native pointer click
RT + D-pad left / rightStep along the commands
RT + D-pad up / downkeybinds.palantirShortcuts (360 shortcut bar): SELECT_NEXT_WORKER / SELECT_HERO / bookmarks
RT + BRight-click the selected command (360 decrement, e.g. cancel a queued unit)
RT + X / YUnchanged: jump to selection / mini-map event

Every button press goes through one context-sensitive dispatcher (dev/controller.lua). keybinds.controller maps buttons per context: mainmenu, palantir (RT held), game (in a match), shell (other menus) and any; keybinds.contextOrder sets priority, and a handler may decline so the press falls through to the next context. Values are native actions or handler names exported by controls.lua, palantir.lua and main_menu.lua; unknown names are logged at load. A button a binding takes does not also click the pointer while held. Held modifiers and gestures (LT/LB/RB + A, A,A) and stick axes remain in controls.lua/motion.lua. None calls pressKey or setMouseButton.

Native bindings retained and investigation limits

The six KEY_NONE modifier transitions are now Lua-owned: Ctrl starts/ends force attack; Shift starts/ends prefer-selection; Alt changes immediate/waypoint order mode. They describe exact modifier-state transitions rather than an ordinary physical key. The console owns backtick, so the default SPELL_STORE keyboard binding is withheld; bind that action to another key or use D-pad right.

Per-unit/building command-button hotkeys are data-driven and depend on the selected CommandSet. They are not globally captured by this migration. RT (Palantir) activation uses pressCommandButton, builder preview uses setBuilderBuilding, and menu buttons use pressMenuButton. The contextual hotkey resolver and both ordinary/radial registration paths are mapped in NATIVE_INPUT_MAP.md.

Mouse hit testing, dragging, double click, wheel zoom, edge scrolling and the LookAt translator's camera shortcuts retain native processing. The INI's commented-out keypad camera rotate/zoom bindings are obsolete and explicitly say LookAtXlat handles them. They are not active CommandMap entries. Direct camera movement and named begin/end camera messages are available, but their held-key behavior is not automatically migrated.

This inventory covers every active cached CommandMap record and every binding found in this integration's current sources. The contextual hotkey and LookAt branches are mapped in NATIVE_INPUT_MAP.md and remain native. Additional retail input branches may still exist. pressKey and setMouseButton remain legacy compatibility APIs for outside scripts; the bundled controller uses neither.

The legacy pressKey adapter's hardcoded whitelist is SPACE, ESC, TAB, H, B, Q, F9, NUMPAD5 and TICK, with CTRL as its only optional modifier. Its old dedicated builder pulse uses DIK_B. These are adapter capabilities, not active controller bindings. setMouseButton supports only left/right through SendInput. New scripts should use named actions and native pointer messages.

Evidence and regeneration

The installed binary's MetaEvent translator is 0x005DA83D, MessageStream append is 0x00710E88, message arguments are 0x00710C9E, pixel argument append is 0x00711179, and integer argument append is 0x007111E5. The stream singleton is 0x00DE6398. These are virtual addresses for this verified image, relocated at runtime. Decompilation evidence is in decomp/actions-native, actions-stream and actions-args.

Native pointer messages follow the Mouse stream contract: press/release carries pixel position, modifier flags, and timestamp; drag carries position, pixel delta, and modifiers. The controller renews pointer state and emits drag messages as its cursor moves. Keyboard repeat flags and combined modifiers follow the installed MetaEvent translator rather than OS key display names.

The game's GUI input funnels run at priorities 4 and 10, before MetaEvent priority 20. Their keyboard branch calls GameWindowManager's key processor and consumes handled messages. The Lua hook preserves this ordering, allowing native chat/text fields to handle their input first. Installed-binary evidence: decomp/actions-register/00646771.asm and decomp/actions-funnel/812c89.c. Actual gameplay remains to be checked after restarting with this DLL.

The Open-BFME source helped identify message/list layouts; offsets were verified in this game's executable, rather than copied from its different reference build. Reference revision: Open-BFME-2 245e23ac.

Run powershell -NoProfile -ExecutionPolicy Bypass -File decomp/Export-Bindings.ps1 from the BFME2Lua folder to regenerate native ID names, default Lua bindings and the complete CommandMap table from cached evidence.

Installed CommandMap defaults

These are INI defaults, not executable hardcodes. Comments and obsolete bindings are excluded. Source: decomp/english-commandmap.ini. Lua migration includes bare modifier transitions and excludes the reserved console key.

ActionKeyModifiersEdgeContextReplacement
SAVE_VIEW1F1CTRLDOWNGAMELua -> native action
SAVE_VIEW2F2CTRLDOWNGAMELua -> native action
SAVE_VIEW3F3CTRLDOWNGAMELua -> native action
SAVE_VIEW4F4CTRLDOWNGAMELua -> native action
SAVE_VIEW5F5CTRLDOWNGAMELua -> native action
SAVE_VIEW6F6CTRLDOWNGAMELua -> native action
SAVE_VIEW7F7CTRLDOWNGAMELua -> native action
SAVE_VIEW8F8CTRLDOWNGAMELua -> native action
VIEW_VIEW1F1NONEDOWNGAMELua -> native action
VIEW_VIEW2F2NONEDOWNGAMELua -> native action
VIEW_VIEW3F3NONEDOWNGAMELua -> native action
VIEW_VIEW4F4NONEDOWNGAMELua -> native action
VIEW_VIEW5F5NONEDOWNGAMELua -> native action
VIEW_VIEW6F6NONEDOWNGAMELua -> native action
VIEW_VIEW7F7NONEDOWNGAMELua -> native action
VIEW_VIEW8F8NONEDOWNGAMELua -> native action
CREATE_TEAM00CTRLDOWNGAMELua -> native action
CREATE_TEAM11CTRLDOWNGAMELua -> native action
CREATE_TEAM22CTRLDOWNGAMELua -> native action
CREATE_TEAM33CTRLDOWNGAMELua -> native action
CREATE_TEAM44CTRLDOWNGAMELua -> native action
CREATE_TEAM55CTRLDOWNGAMELua -> native action
CREATE_TEAM66CTRLDOWNGAMELua -> native action
CREATE_TEAM77CTRLDOWNGAMELua -> native action
CREATE_TEAM88CTRLDOWNGAMELua -> native action
CREATE_TEAM99CTRLDOWNGAMELua -> native action
SELECT_TEAM00NONEDOWNGAMELua -> native action
SELECT_TEAM11NONEDOWNGAMELua -> native action
SELECT_TEAM22NONEDOWNGAMELua -> native action
SELECT_TEAM33NONEDOWNGAMELua -> native action
SELECT_TEAM44NONEDOWNGAMELua -> native action
SELECT_TEAM55NONEDOWNGAMELua -> native action
SELECT_TEAM66NONEDOWNGAMELua -> native action
SELECT_TEAM77NONEDOWNGAMELua -> native action
SELECT_TEAM88NONEDOWNGAMELua -> native action
SELECT_TEAM99NONEDOWNGAMELua -> native action
ADD_TEAM00SHIFTDOWNGAMELua -> native action
ADD_TEAM11SHIFTDOWNGAMELua -> native action
ADD_TEAM22SHIFTDOWNGAMELua -> native action
ADD_TEAM33SHIFTDOWNGAMELua -> native action
ADD_TEAM44SHIFTDOWNGAMELua -> native action
ADD_TEAM55SHIFTDOWNGAMELua -> native action
ADD_TEAM66SHIFTDOWNGAMELua -> native action
ADD_TEAM77SHIFTDOWNGAMELua -> native action
ADD_TEAM88SHIFTDOWNGAMELua -> native action
ADD_TEAM99SHIFTDOWNGAMELua -> native action
VIEW_TEAM00ALTDOWNGAMELua -> native action
VIEW_TEAM11ALTDOWNGAMELua -> native action
VIEW_TEAM22ALTDOWNGAMELua -> native action
VIEW_TEAM33ALTDOWNGAMELua -> native action
VIEW_TEAM44ALTDOWNGAMELua -> native action
VIEW_TEAM55ALTDOWNGAMELua -> native action
VIEW_TEAM66ALTDOWNGAMELua -> native action
VIEW_TEAM77ALTDOWNGAMELua -> native action
VIEW_TEAM88ALTDOWNGAMELua -> native action
VIEW_TEAM99ALTDOWNGAMELua -> native action
SELECT_MATCHING_UNITSENONEDOWNGAMELua -> native action
SELECT_NEXT_UNITRIGHTSHIFTDOWNGAMELua -> native action
SELECT_PREV_UNITLEFTSHIFTDOWNGAMELua -> native action
SELECT_NEXT_WORKERUPSHIFTDOWNGAMELua -> native action
SELECT_PREV_WORKERDOWNSHIFTDOWNGAMELua -> native action
SELECT_HEROHCTRLDOWNGAMELua -> native action
VIEW_HOME_BASEHNONEDOWNGAMELua -> native action
VIEW_LAST_RADAR_EVENTSPACENONEDOWNGAMELua -> native action
SELECT_ALLQNONEDOWNGAMELua -> native action
SCATTERXNONEDOWNGAMELua -> native action
STOPSNONEDOWNGAMELua -> native action
TOGGLE_ATTACKMOVEANONEDOWNGAMELua -> native action
AUTO_SAVESALTDOWNGAMELua -> native action
CREATE_FORMATIONFCTRLDOWNGAMELua -> native action
CHAT_ALLIESBACKSPACENONEDOWNGAMELua -> native action
CHAT_EVERYONEENTERNONEDOWNGAMELua -> native action
CHAT_BUDDIESENTERCTRLUPGAMELua -> native action
DIPLOMACYTABNONEUPGAMELua -> native action
PLACE_BEACONBCTRLDOWNGAMELua -> native action
OPTIONSESCNONEUPGAMELua -> native action
TOGGLE_CONTROL_BARF9NONEDOWNGAMELua -> native action
BEGIN_FORCEATTACKNONECTRLDOWNGAMELua -> native action (modifier transition)
END_FORCEATTACKNONECTRLUPGAMELua -> native action (modifier transition)
BEGIN_PREFER_SELECTIONNONESHIFTDOWNGAMELua -> native action (modifier transition)
END_PREFER_SELECTIONNONESHIFTUPGAMELua -> native action (modifier transition)
TAKE_SCREENSHOTF12NONEDownGAME SHELLLua -> native action
ALL_CHEERCCTRLDownGAMELua -> native action
CAMERA_RESETNUMPAD5NONEDOWNGAMELua -> native action
SPELL_STORETICKNONEUPGAMEReserved for Lua console
TOGGLE_FAST_FORWARD_MODESLASHCTRLDOWNGAMELua -> native action
ORDERMODE_WAYPOINTNONEALTDOWNGAMELua -> native action (modifier transition)
ORDERMODE_IMMEDIATENONEALTUPGAMELua -> native action (modifier transition)
STANCE_AGGRESSIVEDNONEDOWNGAMELua -> native action
STANCE_BATTLEFNONEDOWNGAMELua -> native action
STANCE_HOLDGROUNDGNONEDOWNGAMELua -> native action
DEBUG_TOGGLE_STRINGTAGSSPACECTRLDOWNGAME SHELLLua -> native action
TOGGLE_PLANNING_MODEZNONEDOWNGAMELua -> native action
SELLDELETENONEDOWNGAMELua -> native action
Source: BINDINGS.md · Documentation for the current workspace bindings.
Known game data / Lua 5.4.9

Menu action catalog

These 30 registered callbacks are extracted from src/engine_catalog.hpp. They describe supported native actions, not every visible Flash widget. Names below are exact API IDs, not translated button labels. Some callbacks lead to screens/popups rather than a standalone button.

lua
bfme = import("bfme")
for _, action in ipairs(bfme.getMenuButtons("mainmenu")) do
    print(action.id, action.active, action.state)
end
lua
-- Queue from an active game script callback, with a fresh menu snapshot:
local ok, reason = bfme.pressMenuButton("mainmenu.Skirmish")
if not ok then bfme.log(reason) end
-- Wait for the Skirmish screen before requesting skirmish.StartGame.

Do not queue Skirmish and StartGame back-to-back expecting a screen transition in the same command. Read the new screen's active flag after the transition. Background/stale menu snapshots can reject actions. Menu commands expire after one second and execute on the appropriate native menu update.

Exact action IDAllowed native stateArgument / notesVerified VA
mainmenu.OnTutorial0 (main menu)Argument defaults to empty string0x91B825
mainmenu.BattleSchool0 (main menu)Argument defaults to empty string0x91B540
mainmenu.OnlineButtonPressed0 (main menu)Native online action; service availability not guaranteed0x91C876
mainmenu.LAN0 (main menu)Native LAN action0x91C743
mainmenu.LevelSelect0 (main menu)Registered callback; visible widget behavior not established0x9F3A3C
mainmenu.LoadReplay0 (main menu)Argument defaults to empty string0x91B0AD
mainmenu.LoadGame0 (main menu)Argument defaults to empty string0x91AF01
mainmenu.ExitGame0 (main menu)Normal native exit action0x91B7B3
mainmenu.CreditsExit4 (credits)Argument defaults to empty string0x91B6FD
mainmenu.Credits0 (main menu)Argument defaults to empty string0x91B5E9
mainmenu.CreateAHero0 (main menu)Argument defaults to empty string0x91AFD2
mainmenu.Options0 (main menu)Omitted/empty string: normal path; "true": alternate native path0x91AF18
mainmenu.Skirmish0 (main menu)Argument defaults to empty string0x91B096
mainmenu.LoadCampaign0 (main menu)Argument defaults to empty string0x91AFFB
mainmenu.ContinueCampaign0 (main menu)Argument defaults to empty string0x91B50D
mainmenu.WarOfTheRing0 (main menu)Argument defaults to empty string0x91AFE4
mainmenu.BonusCampaign0 (main menu)Requires explicit side string; forwarded unchanged0x91AFAF
mainmenu.Expansion1Campaign0 (main menu)Requires explicit side string; forwarded unchanged0x91AF8C
Exact action IDAllowed native stateArgument / notesVerified VA
skirmish.OnProfilePopupCancel2, 3 or 4Argument defaults to empty string0x92810A
skirmish.OnAddProfileAccept2Argument defaults to empty string0x928510
skirmish.OnExitStatsScreen8 or 9Argument defaults to empty string0x9281C0
skirmish.OnChangeProfile4Argument defaults to empty string0x9293F5
skirmish.OnChangeProfileMenu6 or 7Argument defaults to empty string0x9290D5
skirmish.OnDeleteProfileMenu6 or 7Argument defaults to empty string0x9290BF
skirmish.OnNewProfileMenu6 or 7Argument defaults to empty string0x92884E
skirmish.OnStatsMenu6 or 7Argument defaults to empty string0x9280FD
skirmish.OnDeleteProfile3Argument defaults to empty string0x929255
skirmish.StartGame6 or 7Argument defaults to empty string0x9280F0
skirmish.Back6 or 7Argument defaults to empty string0x9280E8
skirmish.Exit6 or 7Alias callback address of skirmish.Back0x9280E8

All listed actions are marked supported in the native catalog. Active additionally requires installed hooks, a non-null screen object and a snapshot no older than 250 ms. Main menu states recognized are 0 and 4; Skirmish's recognized range is 2..9. Callback-specific state checks are in the tables above. These numbers are internal guards, not a complete menu state-machine specification.

The optional argument is a string of at most 256 bytes. Only the contracts stated above are known; no dropdown-value or campaign-side enumeration is exposed. Callback addresses apply to the verified installed executable and are included as reverse-engineering evidence, not callable Lua pointers.

portrait is the six-slot unit/hero command panel beside the portrait. building is the side command panel, including builder construction commands. radial is the world-relative command menu over a selected fortress, farm, barracks or other structure, including training, upgrades and submenus.

These panels are distinct from mainmenu and skirmish callback catalogs. Enumerate their currently displayed commands with getCommandButtons, and their text with the tooltip API. Panel button IDs are transient and cannot be substituted for the string action IDs on this page.

Known game data / Lua 5.4.9

Visible command buttons and tooltips

The standalone button is enabled. Lua creation and clicks use events; there is no builder-button onFrame callback. Native changes use the existing request queue, with a 250 ms context check for match/cinematic transitions. Global window manager update/reset hooks are no longer installed. dev/perf_report.lua logs performance counters for checking the new implementation in game.

The optional icon field accepts a MappedImage name (INI ButtonImage), independently of command, which supplies border/style art. Empty icon keeps the source command's icon. Set it in dev/lib/builder_button_config.lua.

Selection changes do not refresh the builder inventory. Clicks and existing match/unit/building events request snapshots; those scans cache memory-region validation for their duration. Custom click, background and tooltip behavior only applies to registered Lua-owned windows. Ordinary draws bypass snapshot inspection unless a controller/API consumer has requested it.

Standalone Lua-owned native buttons are implemented. dev/builder_button.lua creates a top-right �Select Builder� button using the porter command's native art and border. It disables itself when you have no builders, selects a random local builder on click, avoids the selected builder when alternatives exist, and queues the camera move to its position. Configure it in [dev/lib/builder_button_config.lua](dev/lib/builder_button_config.lua), then reload scripts with Ctrl+Shift+F5. Restart the game after changing the DLL.

The native implementation builds and passes fixture tests. Appearance, tooltip placement, native sound and map input consumption still need a running-game check. See native evidence.

Standalone buttons

lua
local bfme = import("bfme")
local id = assert(bfme.createCommandButton {
    x=16, y=16, size=64, anchor="topright",
    command="Command_ConstructMenPorter",
    title="Select Builder", description="Selects one of your builders at random.",
})
return {
    onCommandButtonClicked = function(event)
        if event.id == id then bfme.log("Clicked " .. event.button) end
    end,
}
APIResult
createCommandButton(spec)New opaque ID, or nil, reason
updateCommandButton(id, changes)true, or nil, reason
removeCommandButton(id)true, or nil, reason
getCustomCommandButtonStatus(id){state, error}, or nil, reason

Defaults match the example; enabled and visible default to true. Positions are integers from -32768 to 32768; size is an integer from 16 to 512. By default, size and both offsets are authored at referenceHeight=1080 and scale uniformly with the client height (resolutionScale=true). A 64-unit button is 32 pixels at 540p, 64 at 1080p and 128 at 2160p. Set resolutionScale=false for literal client pixels. referenceHeight accepts integers from 1 to 8192. Anchors are topleft, topright, bottomleft, bottomright, and center. Corner offsets point inward; center offsets add to the centered position. Bounds are clamped inside the client area and recomputed after resolution changes. Text is UTF-8, at most 4096 bytes per field without NUL. command supplies native art and styling; clicking the custom button never executes that command.

Requests are consumed by the native request queue. A destruction handler tracks window lifetime directly, without scanning the window tree. Idle buttons do not resend geometry, enable or visibility notifications. Status is pending, waiting for match, visible, or hidden; error explains the latest failed creation attempt. An unknown command stays pending and retries; change it with updateCommandButton. At most 32 buttons can exist. Only the creating Lua VM can update/remove/read status or receive its onCommandButtonClicked({id, button="left"}) event. Native callbacks queue events and never enter Lua. IDs are not reused after reload.

Desired state survives match changes; native windows are recreated for the next match. Reload, script VM destruction and removal discard requests and pending clicks. Buttons hide while unfocused, the console is open, a native modal is active, a fullscreen video plays, or the camera is in a scripted mode. Disabled and hidden buttons cannot queue clicks. The native command gadget supplies drawing, hover, press state and pointer routing; controller A uses the existing pointer mapping. Custom tooltip title/description are built with native strings and rendering, with blank cost and shortcut. Ordinary command tooltips keep their original path.

unit.isBuilder and unit:inspect().isBuilder use the guarded native DOZER flag (template KindOf bit 14). They return nil if detection is unavailable; getInstanceStatus().builderDetectionReady reports this. The builder script then uses its configurable type-name fallback. A native false is authoritative.

In-game verification

With the new DLL loaded, the log should say Custom native command buttons ready. The following checks remain pending because no game process was available here:

  • Start a match: porter art, BUILD border, position and hover highlight are correct.
  • Hover: custom title/description, blank cost/hotkey and sensible tooltip placement.
  • Mouse click and controller A: native press/sound, one builder selected, camera moves,

and no click reaches the map or recruits a porter.

  • Several builders: consecutive clicks avoid the currently selected builder;

enemy builders and ordinary units are excluded. With none, the button is disabled.

  • End/restart a match, resize/change resolution, reload scripts: exactly one button

reappears in the expected position; old IDs cannot generate events.

  • Open console, menus and cinematics: hidden buttons receive no input.
  • Ordinary porter recruitment and tooltips continue to work.

Existing command inspection

Current gameplay limitation: hero/building modifier navigation has been reported as failing to display a selection overlay or activate commands. These APIs are implemented and tested with fixtures, but actual collection, rendering and native dispatch remain under investigation. Do not treat a queued success as proof that the button was activated.

The startup log should say Visible command-button and tooltip APIs ready.

lua
local ui = require("lib.command_buttons")
local heroButtons = ui.list("portrait") -- six slots beside the unit portrait
local buildingButtons = ui.list("building") -- displayed side command panel
local structureButtons = ui.list("radial") -- world-relative menu over a selected building
for _, button in ipairs(heroButtons) do
    print(button.slot, button.name, button.enabled, button.descriptionKey)
end
local button = ui.slot("portrait", 1)
if button then
    ui.hover(button) -- ask the game's tooltip callback to show this tooltip
    -- Read on a later frame, after the native UI processes the request:
    local tooltip = ui.tooltip(button)
    print(tooltip.title, tooltip.description, tooltip.cost, tooltip.shortcut)
    local ok, reason = ui.press(button)
end

ui.list() returns all panels. ui.pressSlot("portrait", 2) resolves the current slot before queuing its command. The portrait panel includes ordinary units as well as heroes. The side panel can include production, upgrades and construction commands; these are the slots the game's Apt UI actually populates.

The radial panel comes from the separate world-relative RadialCommandUI update at 009e2034. It collects the six native slot records attached to that menu, including visible disabled commands. Structures use this panel in the controller; they never substitute portrait buttons while radial snapshots are empty. Native press/hover requests revalidate radial IDs in the radial update callback. Entering a submenu changes its command IDs and resets controller selection on the next poll.

Inspection is demand-driven: calling list or tooltip enables collection for two seconds. The first call after inactivity may return an empty/stale snapshot; read again on the next native UI update. While used, panels refresh at most every 50 ms. Queued actions bypass that interval and revalidate immediately. Tooltip capture runs during the same inspection lease; hover after enabling inspection.

Each button contains id, panel, slot (1-based displayed slot), sourceSlot (0-based native command slot), objectId, name, visible, enabled, state (native visual state), percent (native progress), titleKey, descriptionKey, and tooltip. Radial and other world buttons with a native window also have screenX/screenY (center, client pixels) and width/height. Slot numbers preserve gaps. Empty/hidden slots are omitted; disabled and charging buttons are included. A button's ID changes when its command or selection changes. No engine pointers are exposed.

Tooltip text is captured when the game builds a command tooltip, including normal mouse hovers over building buttons. Before a hover, tooltip.captured is false; the localization keys are already available. Captured fields are UTF-8: title, description, cost, shortcut, captured, and ageMs. Cached text describes the most recent hover and may need another hover after a resource, upgrade or cooldown change. ui.tooltip() without an argument returns the current/latest command tooltip, with active and, when matched, buttonId.

press and hover return true when queued, or false, reason. They execute on the corresponding native UI update, recheck the displayed command/selection, and expire after 250 ms. Presses reject disabled/cooldown buttons; hover requests can inspect them. The game must be foreground to queue an action. Snapshot reads also expire after 250 ms. Losing focus, opening the console or Ctrl+Shift+F5 clears pending requests. A successful queue does not guarantee execution if the UI changes before the next update. Pressing a targeting power or a construction command starts the normal native target/placement interaction; confirm its map location normally.

bfme.highlightCommandButton(id) asks the integration to dim one displayed button as a selection marker without changing its enabled state; pass 0 or nothing to clear it. Renew it while highlighting. The LB controller menu uses it.

The underlying bindings are bfme.getCommandButtons([panel]), bfme.getCommandTooltip([id]), bfme.hoverCommandButton(id), bfme.pressCommandButton(id), and bfme.getCommandUIStatus().

Mapping evidence

The Open-BFME-2 repository helped identify the Apt/native window boundary, command-button input messages and string representation: https://github.com/Open-BFME/Open-BFME-2 Reference snapshot: 245e23ac6582f6892227c783c19e35e3754c5460. Its vanilla 1.06 executable hash is f008b587…, whereas this installed executable is 01ad4ce0d6177d23f6d5055ea83512311c4412d23e4b8cb1b1e273f06dd2465f. Offsets were traced in the installed binary, rather than copied from that build.

The Ghidra exports in decomp/palantir/ show:

Installed VAVerified role
0093089BPortrait command UI update; six records at +0x64, stride 0x14
0092FF5CMatch portrait records to native windows and command+0x102 visibility
0092F27DBuilding side panel update
009E2034World-relative radial command menu update
0092F082Populate side slots; command+0x101, source indices and visible count
009D2B49Native command-window visual state/progress
009D2814Native click: owner system message 0x4008 with window and ID
0071C321Command tooltip callback installed on the native window
00807A81Construct contextual command tooltip request
00974EEBStore localized title, cost, shortcut and description
008076CDHide/reset command tooltip

The native library checks hook signatures before enabling any of these calls. Build, native snapshot/ID/UTF-8 tests, Lua wrapper tests and injection/console smoke tests validate the plumbing. All four MinHook trampolines were also created and removed successfully against a mapped copy of the actual installed PE, without executing game code. Actual hero/building rendering and activation need an in-game check; the automated session cannot create a D3D9 device.

Known game data / Lua 5.4.9

Contextual keyboard and LookAt paths

Verified by disassembly/decompilation of installed game.dat SHA-256 01ad4ce0d6177d23f6d5055ea83512311c4412d23e4b8cb1b1e273f06dd2465f. Addresses below are preferred image virtual addresses, not offsets from another build. These contextual branches remain native; no specific training/hero command is special-cased.

Contextual unit/building CommandSet hotkeys

StageNative addressEvidence / behavior
Hotkey translator registration0x646a31..0x646a54Message stream priority 25, vtable 0xc046b8; after CommandMap priority 20 and before later selection/LookAt translators.
Translator0x75b23bForwards to HotKeyManager singleton 0xde7870, when present.
Key interpretation0x75b068Handles raw key-down message 0x16; reads scan argument 0 and modifier argument 1. Uses native keyboard conversion 0x63f14d, preserving keyboard layout. Accepts no modifier or Shift alone; Ctrl and Alt exclude contextual matching.
Contextual resolver0x75aefaRejects paused/inactive/blocked game UI. Searches lowercase then uppercase text in the manager's two hotkey maps at +0xc and +0x18; invokes entry virtual checks/activation at +4, +8, +0xc, with Shift state. Successful activation consumes the message.
Ordinary ControlBar registration0x71cf3eResolves the current CommandButton's text label through 0x75ce47, extracts its hotkey using 0x75a7cb, creates the displayed-window action (0x9444f3), and registers it with 0x75ae14.
Building/radial menu registration0x96f890Iterates displayed menu button slots (window +0x234/+0x238), resolves CommandButton data with 0x75d324/0x75ce90, extracts localized hotkey text, and registers contextual action entries with 0x75ae14(..., true).

The key belongs to the currently registered display action. A command's internal ID, such as a training command or submenu command, is not itself the keyboard binding. Selection, submenu, localized label, native enabled/visibility checks and Shift all affect which entry can activate. 0x71d8be clears registrations through manager vtable +0x24 when the control bar rebuilds.

This is the same family of displayed command buttons exposed by lib.command_buttons. Controller selection/press stays generic and does not hardcode fortress heroes or Gondor soldier commands. Mapping contextual hotkeys does not claim a live keyboard test of every faction's command set.

LookAt camera shortcuts

StageNative addressBehavior
Registration0x646b9f..0x646bd1Constructor 0x83ab84, priority 60, vtable 0xc53d3c; singleton 0xde8cb0.
Translator0x83ac4aHandles raw keyboard, wheel and pointer messages plus saved-camera CommandMap messages.
Raw key branch0x83b2fc onwardMessages 0x15/0x16; argument 0 is scan, argument 1 bit 0 distinguishes release/down. Shell-active check 0xde7890 +0x5c rejects the branch.
Numpad held statesTranslator +0x154..+0x157Scan 0x4b (Numpad4) -> +154, 0x4d (Numpad6) -> +155, 0x48 (Numpad8) -> +156, 0x50 (Numpad2) -> +157. Both edges write held-state flags. These bypass the obsolete/commented INI rotation/zoom bindings.
Arrow held states0xde8cb4..0xde8cb7Extended scans Up 0xc8, Down 0xd0, Left 0xcb, Right 0xcd update native camera direction state.
WheelMessage 0x13Argument 0 is signed wheel step count; repeated native view +0x134/+0x138 zoom calls.
Saved viewsMessages 0x24..0x2b, 0x2c..0x33Native view +0x170 save and +0x174 restore. CommandMap bindings are Lua-owned, while camera execution stays native.
Pointer cameraMessages 0x0a/0x0c, 0x0e/0x10, movement 3Native held rotation/drag flags, cursor restoration and view changes; retained.

The numpad rotation/zoom states and arrows remain native. Numpad5 camera reset is a separate CommandMap action already routed by Lua. The raw branch's held fields are verified; physical rotation/zoom behavior still needs an in-game check after restart.

Bare modifier migration

The installed CommandMap translator 0x5da83d compares its previous modifier mask at self +8 with the current mask. Its zero-key entries match an exact single modifier mask, rather than independently testing each physical key.

ChordDown actionPaired release
CtrlBEGIN_FORCEATTACKEND_FORCEATTACK (0x75)
ShiftBEGIN_PREFER_SELECTIONEND_PREFER_SELECTION (0x7b)
AltORDERMODE_WAYPOINTORDERMODE_IMMEDIATE (0x96)

Lua bindings use key='CTRL', 'SHIFT' or 'ALT'; native routing represents the zero-key entry as key='NONE' plus modifier flags. Multiple physical keys producing the same mask do not duplicate transitions. Changing to a combined mask releases the prior exact single-modifier state. Focus loss, console capture, reload and configuration changes queue a paired release; cleanup releases do not expire while the game is unfocused. Native previous-state bookkeeping remains coherent.

Fixture tests cover rapid down/up/down, duplicate routes, all three paired releases, Lua chord parsing and executable signature checks.

Setup / Lua 5.4.9

Installation and launch

BFME2Lua is a 32-bit Windows DLL (BFME2Lua.dll) loaded into the game's game.dat process. Lua 5.4.9 and MinHook are linked in statically, so no separate Lua DLL is needed. The game's own executables are unchanged except for a small startup shim when autoload is installed. The project folder (normally C:\BFME2Lua) holds the DLL, the dev scripts, logs and settings; keep it in place after installing.

Normal startup (autoload)

With autoload installed, start the game normally with lotrbfme2.exe. Its launcher and game.dat each call a bootstrap (BFME2LuaBoot.dll) before their original entry point:

  1. The bootstrap reads BFME2Lua.ini next to the game executable.
  2. Pending updates in the project folder are applied (see Updates).
  3. When Borderless=1, it appends -win -xres <width> -yres <height> for the primary monitor and sets BFME2_BORDERLESS=1.
  4. In game.dat, loading BFME2Lua.dll is deferred until the game's runtime library has started, then it waits up to 30 seconds for Lua to initialize.
  5. If loading fails, the game shows a message box and the reason is in BFME2Lua.log.

BFME2Lua.ini (in the game folder):

ini
[Lua]
ProjectRoot=C:\BFME2Lua
Borderless=1

ProjectRoot must be an absolute path to a folder containing dev. Set Borderless=0 to keep the game's normal display mode.

Install with Prepare-Autoload.ps1 (patch copies of the executables) and Install-Autoload.ps1 (back up and install). Installation hashes and backups are recorded in autoload-install.json. To restore the original executables, close the game and run:

powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Install-Autoload.ps1 -Restore

Test-Autoload.ps1 -Zig <zig.exe> verifies the startup shim against disposable executable copies.

Launcher and attach

Without autoload, use the injection launcher.

CommandEffect
Launch.cmdApply a pending BFME2Lua.next.dll, start C:\BFME2\lotrbfme2.exe with --borderless, inject into its game.dat child, and open the external console
Attach.cmdInject into an already running game. Stops with a message if a .next update is pending, because updates cannot be applied while the game runs.
Console.cmdOpen the external Lua terminal; waits for the game and connects
Documentation.cmdOpen this wiki
BFME2LuaLauncher.exe [lotrbfme2.exe] [--borderless] [game args]Start and inject. Default path C:\BFME2\lotrbfme2.exe. Arguments after the path are forwarded.
BFME2LuaLauncher.exe --attach [lotrbfme2.exe]Inject into the running game.dat that belongs to that installation

The launcher matches the full game.dat path and, for a fresh launch, its parent process. It refuses to start a second copy while one is running. Run it at the same privilege level as the game; an elevated game needs an elevated launcher and console. A successful message means only that the DLL loaded. Check BFME2Lua.log for which subsystems are active. Never unload the DLL during a session.

Display modes

ModeHowResult
Borderless fullscreenBorderless=1 (autoload) or --borderless (launcher)Windowed D3D device at desktop resolution; the DLL removes the frame and covers the primary monitor, including the taskbar. Not always-on-top.
NormalBorderless=0, or launcher without --borderlessThe game's own window/fullscreen behavior from Options.ini
macOS SpaceEnvironment BFME_MACOS_SPACE=1 under WineThe game window becomes a resizable, captioned top-level window so macOS can put it in its own fullscreen Space. D3D stays windowed.

Borderless mode does not rewrite Options.ini. Restart the game after changing desktop resolution. If the game creates a non-windowed device anyway, the log says Borderless fullscreen unavailable.

Environment variables

VariableValuesEffect
BFME2_BORDERLESS1Apply borderless placement to the game window. Set automatically by autoload and --borderless.
BFME2_WINE_OPTIMIZE0 / 1 / unsetForce the Wine keyboard-polling optimization off or on. Unset means automatic Wine detection. See Wine & macOS performance.
BFME_MACOS_SPACE1Make the window resizable for a native macOS fullscreen Space (see above).

macOS and Wine

The DLL runs unchanged under Wine and CrossOver. Differences:

  • The in-game console's Rich Edit controls are drawn into the game's back buffer each frame, because Wine's accelerated surface would otherwise cover child windows. The console still uses native controls for editing and clipboard.
  • Ctrl+Shift+C opens and closes the console in addition to backtick, for Mac keyboard layouts where backtick is awkward. The toggle is also detected through DirectInput, so it works when Wine delivers no window keyboard messages.
  • Async keyboard polling is capped at 125 Hz under Wine. See Wine & macOS performance.
  • Paths in BFME2Lua.ini, save-sync.ini and scripts are Windows paths inside the Wine prefix.

You can build the DLL on macOS with Python and Zig; see Building from source.

Updates

Pending fileApplied byResult
BFME2Lua.next.dll in the project folderAutoload bootstrap or Launch.cmd, when the game is not runningReplaces BFME2Lua.dll. Autoload keeps backups under backups/autoload-update-*; Launch.cmd/Attach.cmd keep them under backups/lua-migration-*.
game.next.dat in the project folderAutoload bootstrap at launcher startMust match game-migration.json and contain the startup integration, otherwise startup stops with an error rather than silently removing Lua

Ctrl+Shift+F5 reloads Lua scripts only. Native changes need a full game restart.

Project folder

PathContents
BFME2Lua.dll, BFME2LuaBoot.dll, BFME2LuaLauncher.exeNative components
dev/*.luaEntry scripts, loaded in sorted filename order
dev/lib/, dev/examples/Modules for require; optional examples (not loaded)
BFME2Lua.logStartup status, script errors, print and bfme.log output
console-history.binIn-game console history
save-sync.ini, save-sync.key, save-sync-state.bin, save-sync-conflicts/Save synchronization (optional)
docs/This wiki and its sources
decomp/Reverse-engineering evidence and Ghidra scripts

Startup log

Each subsystem checks the installed executable's byte signatures before installing hooks, and logs one line. A disabled line means that feature's API stays unavailable; the rest keep working.

Log line when readyAPIs it enables
Native menu and builder APIs readyMenu catalog, builder previews, selection snapshot; required by most others
Menu context and shared APT button dispatcher readygetMenuContext, registry menu actions
Render buffer recovery readyNative render-buffer recovery after device resets
Terrain buffer recovery readyTerrain-pass buffer recovery after alt-tab
60 FPS client: twelve render stages, six original logic stagesNative 60 FPS target and getFrameRateStatus
Building siege-only damage filter readysiegeOnly writes
Command-button definition database readybfme.commandButtons
Live catalog and player gold APIs readyTemplates, players, mutations
Live unit and building instance APIs readyInstances, selection objects, instance actions
Native object levels readylevel writes
Powers and pause APIs readyPause, powers, AI control
Construction preview permission guard readyPreview suppression
Native Lua dialogs readyDialog functions
Building destruction and unit death events readyDeath events, selection/pause/defeat/start observers
Video start events and playback control readyonVideoStart, playVideo, stopVideo, isVideoPlaying
Construction start/finish events and self-build readyonBuildingStart, onBuildingFinish, selfBuild, destroyObject
Construction start/finish events, self-build and build placement readyAlso startBuild, expandWall, unit:build, building:expandWall; without "and build placement" those return nil, "build placement bindings unavailable"
Lua skirmish defeat policy readydefeat, victory, candidates
Native action dispatcher and Lua keyboard routing readyrunAction, bindings, pointer
Analog camera primitive readymoveCamera, setCameraPosition
Visible command-button and tooltip APIs readygetCommandButtons and related
DirectInput builder adapter readyLegacy pressKey
D3D9 Present hook installed; waiting for game windowAll frame callbacks
Frame and input hooks active; ...Scripts load immediately after this line
Setup / Lua 5.4.9

Building from Windows, macOS or Linux

The build host can be macOS (Apple Silicon or Intel), Linux, or Windows. The output is always Windows x86: BFME2Lua.dll, BFME2LuaBoot.dll, and BFME2LuaLauncher.exe, matching BFME2's engine and Windows APIs.

Install Python 3.8 or newer and Zig 0.14.1 for your host OS and CPU. Python installers are available at https://www.python.org/downloads/macos/; Zig releases are at https://ziglang.org/download/. Extract Zig and either add its directory to PATH or pass its executable with --zig. Use the macOS Zig executable on a Mac, rather than the ignored Windows copy in tools. No Visual Studio, Windows SDK, CMake, PowerShell or Wine is required for this build.

The checked-in dependencies are tools/lua-5.4.9/src and tools/minhook-1.3.4. To use another copy of those same versions, pass --dependencies /path/to/dependencies, retaining those directory names.

From the repository root on macOS/Linux:

sh
python3 BFME2Lua/build.py --zig /path/to/zig

From inside BFME2Lua:

sh
python3 build.py --zig /path/to/zig

On Windows use python instead of python3, or pass your Python executable's full path. The same arguments work on all hosts. Paths with spaces should be quoted in your shell; the build driver passes arguments without a shell.

By default, artifacts go into BFME2Lua/build-portable/bin; caches, static libraries and test executables stay in build-portable. Use --build-directory and --binary-directory to choose other locations. The build does not install or patch the game. Existing Windows autoload and installation scripts remain the deployment workflow.

Tests

The default build also compiles all Windows test executables and LuaRunner. Windows runs the full suite automatically. On macOS/Linux, execution is explicitly skipped unless a Windows runner is supplied; compilation still checks every test target. For an artifact-only build, use --skip-tests.

Optional Wine execution requires a Wine installation capable of running 32-bit Windows programs, with both wine and winepath on PATH:

sh
python3 BFME2Lua/build.py --zig /path/to/zig --test-runner wine

The driver translates script and game paths with winepath before giving them to Windows executables. Some tests create Win32 windows; a working graphical Wine session is necessary. Wine execution has not been verified on macOS in this workspace.

For additional signature checks against your supported executable:

sh
python3 BFME2Lua/build.py --zig /path/to/zig --test-runner wine \
  --game-executable '/path/to/game.dat'

Without this option, fixture tests still run and no installed game is required.

The Python build-driver tests run directly on any host without Zig or Wine:

sh
python3 -m unittest discover -s BFME2Lua/tests -p test_build.py

Existing Windows commands

Build-Portable.ps1 delegates to build.py and retains its original required -Zig / -Dependencies parameters, project-root binary output default, and automatic checks against C:\BFME2\game.dat when present. It also accepts -Python, -SkipTests, -TestRunner and -GameExecutable. The existing Visual Studio/CMake build remains supported as documented in README.

Source: BUILDING.md · Documentation for the current workspace bindings.
Setup / Lua 5.4.9

Wine performance changes

The Lua DLL detects Wine through ntdll!wine_get_version. Under Wine, the async keyboard fallback scans at most once every 8 milliseconds (125 Hz). Native input events, gamepad polling, Lua callbacks and game updates keep their existing cadence. Focus loss clears keyboard history; regaining focus forces an immediate scan. Normal frame intervals of 8 ms or more are unchanged. At higher frame rates, fallback-only keys may take an extra few milliseconds to appear in Lua, and a very short press between scans can be missed.

The render hook also caches immutable D3D device creation parameters and reuses the frame's foreground-window result and sampled modifier keys, reducing repeated API queries. These changes apply on Windows too.

The startup log reports whether the Wine keyboard optimization is enabled. To compare behavior or restore per-frame keyboard fallback, launch with:

sh
BFME2_WINE_OPTIMIZE=0 wine /path/to/lotrbfme2.exe

Use BFME2_WINE_OPTIMIZE=1 to force the mode for testing on Windows. Bottle/CrossOver users can set the same environment variable through their launcher configuration. The default is automatic detection.

Build the DLL on macOS with the procedure in BUILDING.md, and replace the DLL in the existing Lua installation while the game is closed. The game's executable instructions and imports do not change. The inspected installed game.dat already has the Large Address Aware flag; setting it again would not improve that executable.

The build, full Windows regression suite and Wine polling-policy tests pass. Actual Wine/macOS frame-time gains have not been measured. This reduces hook overhead; rendering translation, game simulation and script workloads may still dominate. Compare the same map, players, scripts and renderer before and after the update; do not infer an FPS gain solely from fewer API calls.

Source: WINE.md · Documentation for the current workspace bindings.