IPC API
The IPC API connects a module to other programs on the same PC through a Windows named pipe. The module runs the pipe server; your program connects as a client. Messages are strings, usually JSON.
Functions
| Function | Signature | Returns | Description |
|---|---|---|---|
IPC.StartServer |
(pipeName) |
boolean |
Opens the pipe \\.\pipe\<pipeName> for this module instance. See Pipe Names. |
IPC.StopServer |
() |
nil |
Stops this module instance's server and drops its unread messages. CONTROL calls it for you when the module unloads. |
IPC.Send |
(message) |
boolean |
Sends message to every connected client, wrapped in an envelope. A string is sent as is; any other Lua value is first converted to JSON as ToJSON does. Returns false for nil. Does not wait for the clients to read it. |
IPC.HasMessages |
() |
boolean |
Returns whether messages are waiting for this module instance. |
IPC.GetMessages |
() |
string[] |
Returns all waiting messages, oldest first, and removes them. Returns an empty table when there are none. |
IPC.WaitForMessage |
([timeoutMs]) |
string or nil |
Returns the oldest waiting message and removes it. If none is waiting, waits up to timeoutMs milliseconds (default and maximum 500) for one. Returns nil on timeout, without a server, and while Multithreading is enabled. The wait counts toward the callback's 1-second limit. |
IPC.GetStats |
() |
table or nil |
Returns this module instance's counters (see Limits), or nil without a server. |
IPC.Send, IPC.HasMessages and IPC.GetMessages return at once, so you can call them in every Update().
Pipe Names
Pass the name without the \\.\pipe\ prefix, for example "AoE_ML_Pipe"; a leading \\.\pipe\ is removed. IPC.StartServer returns false when the name is empty, longer than 200 characters, contains \ or /, or starts with AoE2Control (in any letter case).
- Each module instance has one server. Calling
IPC.StartServeragain with the same name keeps the server and its waiting messages. Calling it with another name stops the old server first. - Several module instances can use the same pipe name. They share the pipe and its clients; use routing to address one of them. The pipe closes when the last of them stops.
truemeans the server was started. If Windows then refuses to create the pipe, CONTROL writes the error to its log and retries every second.
Connection Security
The pipe accepts clients on the same PC only. Only processes running as the same Windows user as the game can open it. Clients open it with the ordinary path \\.\pipe\<pipeName>.
JSON Helpers
| Function | Signature | Returns | Description |
|---|---|---|---|
ParseJSON |
(str) |
any |
Parses a JSON string into Lua values: objects and arrays become tables, null becomes nil. Returns nil when the string is not valid JSON. |
ToJSON |
(value) |
string |
Converts a Lua value to JSON. A non-empty table with the keys 1 to n becomes an array; any other table, including an empty one, becomes an object. Whole numbers become integers. Returns "{}" when the value cannot be converted. |
Routing Model
A message from a client goes to every module instance that uses the pipe, unless it names a target. These fields name a target:
| Field | Matches |
|---|---|
instanceId or assignedInstanceId |
The module instance id (integer, or a string of digits). |
assignedPlayerId or playerId |
The player the module instance controls (integer, or a string of digits). |
moduleName |
The module name (string). |
settingsGroup |
The module instance's settings group (string). |
Read the values for your modules from the source of the messages they send. A message reaches only the module instances that match every field it sets.
Put the fields in a root target object, or at the root of the message when there is no target. When target is present, routing fields at the root are not used for routing.
What Lua receives:
- If the root object has a
payloadfield, Lua receives only the payload: a string payload as is, any other payload as JSON text. - Otherwise Lua receives the root object as JSON text, without
targetand the routing fields. - Text that is not JSON is delivered unchanged, minus trailing line breaks, to every module instance on the pipe.
A message whose target is not an object, or whose routing field has the wrong type, is dropped and counted in receiveDroppedInvalidRouting.
Outgoing Envelope
IPC.Send(message) wraps your message before sending it to the clients:
{
"type": "module_message",
"pipeName": "AoE_ML_Pipe",
"source": {
"instanceId": 3,
"assignedPlayerId": 2,
"moduleName": "my_module",
"settingsGroup": "my_module [P2]"
},
"payload": {
"...": "your data"
}
}
If you send a string that is valid JSON, payload holds the parsed JSON value. Any other string stays a JSON string.
Messages
- Each
IPC.Sendis one pipe message: the envelope as JSON, followed by\n. - Each pipe message a client writes is one inbound message. Empty messages are ignored.
- Clients should open the pipe in message read mode and keep reading while
ReadFilereportsERROR_MORE_DATA(234), as in the Python example. A client that reads in byte mode must split the stream at each\n.
Limits
| Limit | Value | What happens when it is reached |
|---|---|---|
Outbound envelope size, including the \n |
1 MiB | IPC.Send returns false. |
| Messages waiting for one client | 1024 messages or 8 MiB | IPC.Send returns false; clients with room still get the message. |
| Time for a client to read one message | 5 s | The client is disconnected and its unsent messages are dropped. |
| Inbound message size | 1 MiB | The message is dropped and the client is disconnected. |
| Inbound messages waiting for Lua | 1024 per module instance | Further messages for that instance are dropped until Lua reads them. |
| Clients per pipe | 16 | Further clients cannot connect until one disconnects. |
IPC.Send returns true when the message was queued for every connected client, and false when no client is connected.
IPC.GetStats() returns these counters: connectedClients, sentMessages, sentBytes, sendFailedNoClient, sendFailedTooLarge, sendFailedQueueFull, receivedMessages, receiveDroppedQueueFull, receiveDroppedTooLarge, receiveDroppedInvalidRouting and slowClientDisconnects. connectedClients and the last three count the whole pipe; the others count only this module instance.
Snapshot Buffers For IPC / ML
GetMapTilesPtr() and GetObjectsPtr() are game API functions, not part of IPC. They pack all map tiles or all objects into a buffer in the game's memory, so an external program can read thousands of entries with one ReadProcessMemory call instead of receiving them as JSON.
Each function returns two values: ptr, the buffer's address in the game process, and count, the number of entries (not bytes). An empty buffer is returned as 0, 0.
- Each call builds a new buffer.
- The buffers a module instance requests during one callback stay valid until it requests a buffer in a later callback, or unloads. Copy the data before then.
- Tiles and objects follow the same fog-of-war rules as the rest of the Lua API. Unexplored tiles are left out, so
countcan be smaller than the map's tile count. GetObjectsPtr()includes dead objects. Check the alive flag.
Entries are packed without padding, little-endian.
Tile (8 bytes):
| Offset | Type | Field | Meaning |
|---|---|---|---|
| 0 | uint16 |
x |
Tile x. |
| 2 | uint16 |
y |
Tile y. |
| 4 | uint8 |
terrain |
Terrain id (Terrain enum). |
| 5 | uint8 |
elevation |
Elevation level. |
| 6 | uint8 |
isVisible |
1 if the tile is visible now, 0 if explored but under fog. |
| 7 | uint8 |
flags |
Bit 0: walkable (passable terrain, not blocked by an object). Bit 1: passable terrain for land units. |
Object (12 bytes):
| Offset | Type | Field | Meaning |
|---|---|---|---|
| 0 | uint32 |
id |
Object id. |
| 4 | uint16 |
unitObjectType |
Object type (UnitObjectType enum). |
| 6 | uint16 |
x |
Tile x the object stands on. |
| 8 | uint16 |
y |
Tile y the object stands on. |
| 10 | uint8 |
playerId |
Owner player id, or 255 without an owner. |
| 11 | uint8 |
flags |
Bit 0: alive. |
The module sends the pointers through IPC:
function Update()
local tilesPtr, tileCount = GetMapTilesPtr()
local objectsPtr, objectCount = GetObjectsPtr()
IPC.Send({
type = "snapshot_meta",
tilesPtr = tilesPtr,
tileCount = tileCount,
objectsPtr = objectsPtr,
objectCount = objectCount
})
end
The client reads the buffers with Python ctypes. It needs a handle to the game process opened with PROCESS_VM_READ access.
import ctypes
from ctypes import wintypes
class Tile(ctypes.Structure):
_pack_ = 1
_fields_ = [
("x", ctypes.c_uint16),
("y", ctypes.c_uint16),
("terrain", ctypes.c_uint8),
("elevation", ctypes.c_uint8),
("isVisible", ctypes.c_uint8),
("flags", ctypes.c_uint8),
]
class Object(ctypes.Structure):
_pack_ = 1
_fields_ = [
("id", ctypes.c_uint32),
("unitObjectType", ctypes.c_uint16),
("x", ctypes.c_uint16),
("y", ctypes.c_uint16),
("playerId", ctypes.c_uint8),
("flags", ctypes.c_uint8),
]
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.ReadProcessMemory.argtypes = [
wintypes.HANDLE, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)]
kernel32.ReadProcessMemory.restype = wintypes.BOOL
def read_snapshot(process_handle, ptr, count, struct_type):
entries = (struct_type * count)()
if count == 0:
return entries
bytes_read = ctypes.c_size_t()
if not kernel32.ReadProcessMemory(process_handle, ptr, entries, ctypes.sizeof(entries), ctypes.byref(bytes_read)):
raise ctypes.WinError(ctypes.get_last_error())
return entries
Lua Example
The module answers ping messages with pong.
local pipeName = "AoE_ML_Pipe"
function Init()
IPC.StartServer(pipeName)
Log("IPC online for player " .. tostring(GetAssignedPlayerId()))
end
function Update()
if not IPC.HasMessages() then
return
end
for _, raw in ipairs(IPC.GetMessages()) do
local msg = ParseJSON(raw)
if type(msg) == "table" and msg.action == "ping" then
IPC.Send({
action = "pong",
assignedPlayerId = GetAssignedPlayerId(),
time = GetGameTime()
})
end
end
end
Python Example
The client sends ping to the module my_module that controls player 2, and prints every reply. It needs pywin32.
import json
import time
import pywintypes
import win32file
import win32pipe
PIPE_NAME = r"\\.\pipe\AoE_ML_Pipe"
def connect():
while True:
try:
handle = win32file.CreateFile(
PIPE_NAME,
win32file.GENERIC_READ | win32file.GENERIC_WRITE,
0,
None,
win32file.OPEN_EXISTING,
0,
None,
)
except pywintypes.error as exc:
# 2: the server is not running yet. 231: all pipe instances are busy.
if exc.winerror in (2, 231):
time.sleep(0.5)
continue
raise
# Message mode: each read returns (part of) exactly one message.
win32pipe.SetNamedPipeHandleState(handle, win32pipe.PIPE_READMODE_MESSAGE, None, None)
return handle
def read_message(handle):
"""Reads one whole message from CONTROL, however large."""
parts = []
while True:
result, data = win32file.ReadFile(handle, 64 * 1024)
parts.append(data)
if result == 0: # 234 (ERROR_MORE_DATA) means the message continues
return json.loads(b"".join(parts).decode("utf-8"))
handle = connect()
targeted_ping = {
"target": {
"assignedPlayerId": 2,
"moduleName": "my_module"
},
"payload": {
"action": "ping"
}
}
# One WriteFile is one message for Lua.
win32file.WriteFile(handle, json.dumps(targeted_ping).encode("utf-8"))
while True:
envelope = read_message(handle)
print(envelope["payload"])