A Go implementation of an ArmA 3 native extension that records gameplay to PostgreSQL, SQLite, or in-memory JSON. Captures unit positions, combat events, markers, and more for mission replay and analytics.
ArmA 3 Game
↓ callExtension("ocap_recorder", [":COMMAND:", args])
RVExtensionArgs() [CGo boundary]
↓
Dispatcher.Dispatch(Event)
↓
├─ [Sync] → Handler directly
└─ [Buffered] → Channel → Goroutine → Handler
↓
Parser (args → core types)
↓
EntityCache (validate/enrich)
↓
Storage Backend
├─ Memory → in-memory append → JSON export on save
├─ Postgres → Queue → DB writer (batch insert every 2s) → PostgreSQL
└─ SQLite → Queue → DB writer (batch insert every 2s) → SQLite
Buffered handlers are gated on :STORAGE:INIT: — events queue in channels until the storage backend is ready.
The extension exposes CGo-exported functions that ArmA 3 calls:
| Function | Purpose |
|---|---|
RVExtensionArgs() |
Main entry point; receives command and arguments |
RVExtension() |
Legacy simple command handler |
RVExtensionVersion() |
Returns version string |
The dispatcher routes commands to handlers with optional buffering:
- Sync handlers: Execute immediately (entity creation)
- Buffered handlers: Queue events in channels for async processing (high-volume state updates)
- Metrics: OpenTelemetry integration for queue sizes, events processed/dropped
cmd/ocap_recorder/main.go Entry point, initialization, lifecycle commands
pkg/a3interface/ CGo exports (RVExtension*)
internal/
├── dispatcher/ Event routing with async buffering
├── parser/ Command parsing (args → core types)
├── worker/ Handler registration and DB writer loop
├── queue/ Thread-safe queues for batch writes
├── cache/ Entity lookup caching (ObjectID → model)
├── model/ Database models + converters
├── storage/ Storage backends (memory, postgres, sqlite)
└── geo/ Coordinate/geometry utilities
- Low latency: Async buffered handlers don't block ArmA's game loop
- High throughput: Batch writes every 2 seconds instead of per-event
- Entity caching: Sync entity creation → cache → async state updates use cached FK
- Pluggable storage: Memory (JSON export), PostgreSQL, or SQLite (in-memory with periodic disk dump)
- Observability: OpenTelemetry metrics and structured logging (slog)
Requires Docker with Linux containers.
docker pull x1unix/go-mingw:1.24
docker run --rm -v ${PWD}:/go/work -w /go/work x1unix/go-mingw:1.24 \
go build -buildvcs=false -o dist/ocap_recorder_x64.dll -buildmode=c-shared ./cmd/ocap_recorderdocker run --rm -v ${PWD}:/go/work -w /go/work golang:1.24-bullseye \
go build -buildvcs=false -o dist/ocap_recorder_x64.so -buildmode=c-shared ./cmd/ocap_recorderUses Debian Bullseye (glibc 2.31) for broad compatibility with Linux game servers.
To move mission processing and recording storage to the web service, use:
{
"api": {
"serverUrl": "https://ocap.example/ocap",
"apiKey": "same-secret",
"uploadTimeout": "10m"
},
"storage": { "type": "websocket" }
}Enable streaming.enabled on the web service and match its secret. The proxy
connects to the server URL plus /api/v1/stream, preserving any deployment prefix.
It forwards the shared protocol without a local recording database, JSON export,
or separate upload. uploadTimeout controls the final save acknowledgement wait.
The existing memory default and file-upload path remain available.
With a web service that supports resume (protocol version 2), network failures are lossless. Every message is numbered and held in memory (up to 256 MiB) until the web service acknowledges it. When the connection drops, the proxy keeps recording, reconnects with backoff and jitter until it succeeds, resumes the mission, and resends what the web service did not receive. Resent messages keep their capture frames, so the timeline is unaffected. The proxy stops reconnecting only when the web service rejects the secret (HTTP 401/403).
Every message is also spooled to disk until the web service saves the mission. If the web service lost the mission (for example it restarted mid-mission), the proxy resends the whole mission from the spool, and the web service replaces the partial segment with the complete recording. During a long outage memory keeps the newest messages and the rest are resent from the spool. The spool is deleted once the mission is saved. It is kept on disk if the save is not confirmed.
"storage": {
"type": "websocket",
"websocket": { "bufferMB": 256, "spoolDir": "./ocapspool", "spoolMaxMB": 4096, "resendMBps": 8 }
}resendMBps paces catch-up traffic: the backlog resent after a reconnect, a
mission resent in full from the spool, and missions recovered after a crash.
Without it, several game servers recovering at once after a web restart would
all resend at full speed and overload the web service. Live traffic is never
paced. 0 disables pacing.
spoolDir is relative to the Arma server's working directory, like
memory.outputDir; empty disables spooling. spoolMaxMB limits the disk used
per mission (0 = unlimited). Once the limit is reached, spooling stops for that
mission, and a later full resend is partial.
The spool also survives the game server. If it crashes mid-mission, or a
mission ends without a confirmed save, the mission stays in spoolDir. The
extension checks spoolDir at startup and then every minute, resends such
missions to the web service, and deletes each spool once the web service has
saved its mission. A mission resent in full is marked as recovered, so a web
service at protocol version 3 does not take it for the producer's next
mission and save the mission it records now as partial. An older web service
does, so with one the running process resends its own missions in full only
while it records none:
- A mission cut short by a crash is ended on the crashed process' behalf and
saved as
partial, with everything recorded up to the last flush (at most about a second is lost). - A mission whose save was not confirmed is saved as recorded.
- A mission whose discard was not confirmed is not resent; the web service is
only told again to drop it, including any
partialrecording it saved of the mission in the meantime. This needs the spool: withoutspoolDir, or with a spool that hit its limit or failed, nothing is kept, and the web service saves the mission aspartialonce its resume grace expires.
When the game moves on to the next mission without saving or discarding the
current one, for example after an admin #missions, the proxy ends it as
interrupted as soon as the next mission loads (:STORAGE:INIT:), and the web
service saves it as partial. Only if that is not confirmed within 30 seconds
does its spool stay behind for recovery. One that recorded nothing after its
start is discarded instead, when the web service supports it, and so is one
the addon would not save yet: it starts such a recording with "discard" and
sends :MISSION:KEEP: once it meets OCAP_settings_minMissionTime, so a
mission an admin only loaded and left with #missions or #restart leaves
no partial behind. A web service
without resume support is disconnected instead, which saves the mission as
partial there.
When the addon ends a recording that does not meet its save limits (for
example one shorter than OCAP_settings_minMissionTime), it sends
:MISSION:DISCARD:, and the proxy tells the web service to drop the recording
(protocol version 3) and deletes its spool. A web service that predates
discard keeps saving such recordings as partial.
Game servers can share one spoolDir, for example several instances started
from the same directory. Each spool has an operating system file lock
(flock on Linux, LockFileEx on Windows):
- The process recording a mission holds its lock from start to end, so its live mission is never taken.
- A process recovering a mission holds the lock while it resends, so two processes never recover the same mission.
- The operating system releases the lock of a process that dies, so a crashed mission is recovered about 15 seconds later, with no stale claim to wait out.
Spools older than seven days are discarded. A web service without resume support cannot take recovered missions: their spools stay on disk, and the proxy asks again only every 30 minutes instead of every minute. As soon as a mission start shows that the web service supports resume (it was upgraded), the waiting spools are recovered.
While a mission records, the stream's state is logged every minute. The log
line lists messages sent and acknowledged, the memory buffer, messages moved
to the spool or refused, and the reconnects, resumes and restarts so far. A
disconnected stream logs at warning level
(WebSocket stream disconnected, buffering), so a struggling stream shows in
the logs before the mission ends.
With an older web service, a reconnect replays mission metadata and the web service saves the received segments as partial recordings, as before. A full buffer and a closed proxy are returned as recording errors.
Integration tests exercise the real proxy against a disposable local web server
with streaming enabled. TestWebResumeIntegration cuts the connection three
times mid-mission and expects one complete recording. TestWebRestartIntegration
also needs OCAP_TEST_WEB_RESTART_CMD, a shell command that restarts the web
server; it restarts the server mid-mission and expects one complete recording:
OCAP_TEST_WEB_URL=http://127.0.0.1:5000 OCAP_TEST_WEB_SECRET=same-secret \
go test ./internal/storage/websocket -run 'TestWeb' -count=1 -vCopy ocap_recorder.cfg.json.example to ocap_recorder.cfg.json alongside the DLL and edit as needed.
All commands follow a :RESOURCE:ACTION: naming convention. New commands must use this pattern — resource noun first, then verb/qualifier (e.g., :SOLDIER:CREATE:, :EVENT:KILL:, :SYS:INIT:).
| Command | Buffer | Purpose |
|---|---|---|
:SOLDIER:CREATE: |
Sync | Register new unit |
:SOLDIER:STATE: |
10,000 | Update unit position/state |
:SOLDIER:DELETE: |
500 | Mark unit as removed (disconnect, respawn corpse) |
:VEHICLE:CREATE: |
Sync | Register new vehicle |
:VEHICLE:STATE: |
10,000 | Update vehicle position/state |
:VEHICLE:DELETE: |
500 | Mark vehicle as removed |
| Command | Buffer | Purpose |
|---|---|---|
:EVENT:PROJECTILE: |
5,000 | Projectile tracking (positions + hits) |
:EVENT:KILL: |
2,000 | Kill event |
| Command | Buffer | Purpose |
|---|---|---|
:EVENT:GENERAL: |
1,000 | General gameplay event |
:EVENT:SECTOR: |
1,000 | Sector state change (captured/contested) |
:EVENT:ENDMISSION: |
100 | End-of-mission event with side and message |
:EVENT:CHAT: |
1,000 | Chat message |
:EVENT:RADIO: |
1,000 | Radio transmission |
:TELEMETRY:FRAME: |
100 | Server telemetry (FPS, entity counts, weather, player network stats) |
:TIME:STATE: |
100 | Mission time/date tracking |
| Command | Buffer | Purpose |
|---|---|---|
:MARKER:CREATE: |
Sync | Create map marker (needs immediate DB ID) |
:MARKER:STATE: |
1,000 | Update marker position/appearance (pos, dir, alpha, text, color, size, type, brush, shape) |
:MARKER:DELETE: |
500 | Delete marker |
| Command | Buffer | Purpose |
|---|---|---|
:PLACED:CREATE: |
Sync | Register placed object (mine, explosive) |
:PLACED:EVENT: |
1,000 | Placed object lifecycle event (detonated/deleted) |
| Command | Buffer | Purpose |
|---|---|---|
:ACE3:DEATH: |
1,000 | ACE3 death event |
:ACE3:UNCONSCIOUS: |
1,000 | ACE3 unconscious event |
| Command | Purpose |
|---|---|
:SYS:INIT: |
Initialize extension, send :SYS:READY: callback |
:STORAGE:INIT: |
Initialize storage backend once per process (later calls reuse it), ungate buffered handlers; answers with a :STORAGE:OK: callback, or :STORAGE:ERROR: with the error |
:MISSION:START: |
Start recording mission; answers with a :MISSION:OK: callback, or :MISSION:FAILED: with the error. An optional third argument, a registration token, is echoed as the callback's last value. An optional fourth, discard, drops the recording if the game moves on from it before :MISSION:KEEP: (websocket) |
:MISSION:KEEP: |
The recording met the addon's save limits: if the game moves on from it now, it is kept as partial (websocket) |
:MISSION:SAVE: |
End recording, flush data, upload if configured |
:MISSION:DISCARD: |
End recording without saving it (memory, websocket); answers with a :MISSION:DISCARDED: callback |
:MISSION:FOCUS_START: |
Set playback focus start frame |
:MISSION:FOCUS_END: |
Set playback focus end frame |
:SYS:VERSION: |
Get extension version |