Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,19 @@ GORM models for PostgreSQL/SQLite:

### Commands

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 | Purpose |
|---------|---------|
| `:NEW:SOLDIER:`, `:NEW:VEHICLE:` | Register new units/vehicles |
| `:NEW:SOLDIER:STATE:`, `:NEW:VEHICLE:STATE:` | Update position/state data |
| `:PROJECTILE:`, `:KILL:` | Combat events |
| `:EVENT:`, `:CHAT:`, `:RADIO:`, `:TELEMETRY:` | General gameplay events |
| `:NEW:MARKER:`, `:NEW:MARKER:STATE:`, `:DELETE:MARKER:` | Marker operations |
| `:NEW:PLACED:`, `:PLACED:EVENT:` | Placed object (mine/explosive) lifecycle |
| `:SOLDIER:CREATE:`, `:VEHICLE:CREATE:` | Register new units/vehicles |
| `:SOLDIER:STATE:`, `:VEHICLE:STATE:` | Update position/state data |
| `:EVENT:PROJECTILE:`, `:EVENT:KILL:` | Combat events |
| `:EVENT:GENERAL:`, `:EVENT:CHAT:`, `:EVENT:RADIO:`, `:TELEMETRY:FRAME:` | General gameplay events |
| `:MARKER:CREATE:`, `:MARKER:STATE:`, `:MARKER:DELETE:` | Marker operations |
| `:PLACED:CREATE:`, `:PLACED:EVENT:` | Placed object (mine/explosive) lifecycle |
| `:ACE3:DEATH:`, `:ACE3:UNCONSCIOUS:` | ACE3 integration events |
| `:INIT:`, `:INIT:STORAGE:` | Initialize extension and storage |
| `:NEW:MISSION:`, `:SAVE:MISSION:` | Begin/end recording |
| `:SYS:INIT:`, `:STORAGE:INIT:` | Initialize extension and storage |
| `:MISSION:START:`, `:MISSION:SAVE:` | Begin/end recording |

### Data Flow

Expand Down
44 changes: 23 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Dispatcher.Dispatch(Event)
└─ SQLite → Queue → DB writer (batch insert every 2s) → SQLite
```

Buffered handlers are gated on `:INIT:STORAGE:` — events queue in channels until the storage backend is ready.
Buffered handlers are gated on `:STORAGE:INIT:` — events queue in channels until the storage backend is ready.

### DLL Entry Points

Expand Down Expand Up @@ -102,45 +102,47 @@ Copy `ocap_recorder.cfg.json.example` to `ocap_recorder.cfg.json` alongside the

## Supported Commands

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:`).

### Entity Commands

| Command | Buffer | Purpose |
|---------|--------|---------|
| `:NEW:SOLDIER:` | Sync | Register new unit |
| `:NEW:VEHICLE:` | Sync | Register new vehicle |
| `:NEW:SOLDIER:STATE:` | 10,000 | Update unit position/state |
| `:NEW:VEHICLE:STATE:` | 10,000 | Update vehicle position/state |
| `:SOLDIER:CREATE:` | Sync | Register new unit |
| `:VEHICLE:CREATE:` | Sync | Register new vehicle |
| `:SOLDIER:STATE:` | 10,000 | Update unit position/state |
| `:VEHICLE:STATE:` | 10,000 | Update vehicle position/state |

### Combat Commands

| Command | Buffer | Purpose |
|---------|--------|---------|
| `:PROJECTILE:` | 5,000 | Projectile tracking (positions + hits) |
| `:KILL:` | 2,000 | Kill event |
| `:EVENT:PROJECTILE:` | 5,000 | Projectile tracking (positions + hits) |
| `:EVENT:KILL:` | 2,000 | Kill event |

### General Commands

| Command | Buffer | Purpose |
|---------|--------|---------|
| `:EVENT:` | 1,000 | General gameplay event |
| `:CHAT:` | 1,000 | Chat message |
| `:RADIO:` | 1,000 | Radio transmission |
| `:TELEMETRY:` | 1,000 | Server telemetry (FPS, entity counts, weather, player network stats) |
| `:NEW:TIME:STATE:` | 100 | Mission time/date tracking |
| `:EVENT:GENERAL:` | 1,000 | General gameplay event |
| `: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 |

### Marker Commands

| Command | Buffer | Purpose |
|---------|--------|---------|
| `:NEW:MARKER:` | Sync | Create map marker (needs immediate DB ID) |
| `:NEW:MARKER:STATE:` | 1,000 | Update marker position/appearance |
| `:DELETE:MARKER:` | 500 | Delete marker |
| `:MARKER:CREATE:` | Sync | Create map marker (needs immediate DB ID) |
| `:MARKER:STATE:` | 1,000 | Update marker position/appearance |
| `:MARKER:DELETE:` | 500 | Delete marker |

### Placed Object Commands

| Command | Buffer | Purpose |
|---------|--------|---------|
| `:NEW:PLACED:` | Sync | Register placed object (mine, explosive) |
| `:PLACED:CREATE:` | Sync | Register placed object (mine, explosive) |
| `:PLACED:EVENT:` | 1,000 | Placed object lifecycle event (detonated/deleted) |

### ACE3 Integration
Expand All @@ -154,8 +156,8 @@ Copy `ocap_recorder.cfg.json.example` to `ocap_recorder.cfg.json` alongside the

| Command | Purpose |
|---------|---------|
| `:INIT:` | Initialize extension, send `:EXT:READY:` callback |
| `:INIT:STORAGE:` | Initialize storage backend, ungate buffered handlers |
| `:NEW:MISSION:` | Start recording mission |
| `:SAVE:MISSION:` | End recording, flush data, upload if configured |
| `:VERSION:` | Get extension version |
| `:SYS:INIT:` | Initialize extension, send `:SYS:READY:` callback |
| `:STORAGE:INIT:` | Initialize storage backend, ungate buffered handlers |
| `:MISSION:START:` | Start recording mission |
| `:MISSION:SAVE:` | End recording, flush data, upload if configured |
| `:SYS:VERSION:` | Get extension version |
36 changes: 18 additions & 18 deletions cmd/ocap_recorder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,20 +234,20 @@ func init() {

func initExtension() {
// send ready callback to Arma
if err := a3interface.WriteArmaCallback(ExtensionName, ":EXT:READY:"); err != nil {
Logger.Warn("Failed to send EXT:READY callback", "error", err)
if err := a3interface.WriteArmaCallback(ExtensionName, ":SYS:READY:"); err != nil {
Logger.Warn("Failed to send SYS:READY callback", "error", err)
}
// send extension version
if err := a3interface.WriteArmaCallback(ExtensionName, ":VERSION:", BuildVersion); err != nil {
Logger.Warn("Failed to send VERSION callback", "error", err)
if err := a3interface.WriteArmaCallback(ExtensionName, ":SYS:VERSION:", BuildVersion); err != nil {
Logger.Warn("Failed to send SYS:VERSION callback", "error", err)
}
}

func setupA3Interface() (err error) {
a3interface.SetVersion(BuildVersion)

// Create early dispatcher for commands that don't need DB/workers
// This ensures :VERSION:, :INIT:, etc. work immediately when the DLL loads
// This ensures :SYS:VERSION:, :SYS:INIT:, etc. work immediately when the DLL loads
dispatcherLogger := logging.NewDispatcherLogger(Logger)
earlyDispatcher, err := dispatcher.New(dispatcherLogger)
if err != nil {
Expand Down Expand Up @@ -284,7 +284,7 @@ func initAPIClient() {
}
}

// handleNewMission handles the :NEW:MISSION: command: parses mission data,
// handleNewMission handles the :MISSION:START: command: parses mission data,
// delegates DB persistence to the storage backend, and sets runtime state.
func handleNewMission(e dispatcher.Event) (any, error) {
if parserService == nil {
Expand Down Expand Up @@ -323,12 +323,12 @@ func handleNewMission(e dispatcher.Event) (any, error) {
// registerLifecycleHandlers registers system/lifecycle command handlers with the dispatcher
func registerLifecycleHandlers(d *dispatcher.Dispatcher) {
// Simple commands (RVExtension style - no args)
d.Register(":INIT:", func(e dispatcher.Event) (any, error) {
d.Register(":SYS:INIT:", func(e dispatcher.Event) (any, error) {
go initExtension()
return nil, nil
})

d.Register(":INIT:STORAGE:", func(e dispatcher.Event) (any, error) {
d.Register(":STORAGE:INIT:", func(e dispatcher.Event) (any, error) {
go func() {
if err := initStorage(); err != nil {
Logger.Error("Storage initialization failed", "error", err)
Expand All @@ -338,44 +338,44 @@ func registerLifecycleHandlers(d *dispatcher.Dispatcher) {
})

// Simple queries - sync return is sufficient, no callback needed
d.Register(":VERSION:", func(e dispatcher.Event) (any, error) {
d.Register(":SYS:VERSION:", func(e dispatcher.Event) (any, error) {
return []string{BuildVersion, BuildCommit, BuildDate}, nil
})

d.Register(":GETDIR:ARMA:", func(e dispatcher.Event) (any, error) {
d.Register(":SYS:DIR:ARMA:", func(e dispatcher.Event) (any, error) {
return ArmaDir, nil
})

d.Register(":GETDIR:MODULE:", func(e dispatcher.Event) (any, error) {
d.Register(":SYS:DIR:MODULE:", func(e dispatcher.Event) (any, error) {
return ModulePath, nil
})

d.Register(":GETDIR:OCAPLOG:", func(e dispatcher.Event) (any, error) {
d.Register(":SYS:DIR:LOG:", func(e dispatcher.Event) (any, error) {
return OcapLogFilePath, nil
})

// Commands with args (RVExtensionArgs style)
d.Register(":ADDON:VERSION:", func(e dispatcher.Event) (any, error) {
d.Register(":SYS:ADDON_VERSION:", func(e dispatcher.Event) (any, error) {
if len(e.Args) > 0 {
addonVersion = util.FixEscapeQuotes(util.TrimQuotes(e.Args[0]))
Logger.Info("Addon version", "version", addonVersion)
}
return nil, nil
})

// :LOG: is used by the addon to log messages through the extension
d.Register(":LOG:", func(e dispatcher.Event) (any, error) {
// :SYS:LOG: is used by the addon to log messages through the extension
d.Register(":SYS:LOG:", func(e dispatcher.Event) (any, error) {
if len(e.Args) > 0 {
msg := util.FixEscapeQuotes(util.TrimQuotes(e.Args[0]))
Logger.Info("Addon log", "message", msg, "args", e.Args[1:])
}
return nil, nil
})

d.Register(":NEW:MISSION:", handleNewMission, dispatcher.Buffered(1), dispatcher.Blocking(), dispatcher.Gated(storageReady))
d.Register(":MISSION:START:", handleNewMission, dispatcher.Buffered(1), dispatcher.Blocking(), dispatcher.Gated(storageReady))

d.Register(":SAVE:MISSION:", func(e dispatcher.Event) (any, error) {
Logger.Info("Received :SAVE:MISSION: command, ending mission recording")
d.Register(":MISSION:SAVE:", func(e dispatcher.Event) (any, error) {
Logger.Info("Received :MISSION:SAVE: command, ending mission recording")
if storageBackend != nil {
if err := storageBackend.EndMission(); err != nil {
Logger.Error("Failed to end mission in storage backend", "error", err)
Expand Down
2 changes: 1 addition & 1 deletion cmd/ocap_recorder/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
)

func initStorage() error {
Logger.Debug("Received :INIT:STORAGE: call")
Logger.Debug("Received :STORAGE:INIT: call")

storageCfg := config.GetStorageConfig()

Expand Down
30 changes: 15 additions & 15 deletions internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ func (*Addon) TableName() string {
// Soldier is a player or AI unit
// Uses composite primary key (MissionID, ObjectID) - ObjectID is the OCAP-assigned sequential ID
//
// SQF Command: :NEW:SOLDIER:
// SQF Command: :SOLDIER:CREATE:
// Args: [frameNo, ocapId, name, groupId, side, isPlayer, roleDescription, className, displayName, playerUID, squadParams]
type Soldier struct {
MissionID uint `json:"missionId" gorm:"primaryKey;autoIncrement:false"`
Expand Down Expand Up @@ -220,7 +220,7 @@ func (*Soldier) TableName() string {
// SoldierState tracks soldier state at a point in time
// References Soldier by (MissionID, SoldierObjectID) composite FK
//
// SQF Command: :NEW:SOLDIER:STATE:
// SQF Command: :SOLDIER:STATE:
// Args: [ocapId, pos, dir, lifeState, inVehicle, name, isPlayer, role, frameNo, hasStableVitals, isDragged, scores, vehicleRole, vehicleOcapId, stance, groupID, side]
type SoldierState struct {
ID uint `json:"id" gorm:"primarykey;autoIncrement;"`
Expand Down Expand Up @@ -266,7 +266,7 @@ type SoldierScores struct {
// Vehicle is a vehicle or static weapon
// Uses composite primary key (MissionID, ObjectID) - ObjectID is the OCAP-assigned sequential ID
//
// SQF Command: :NEW:VEHICLE:
// SQF Command: :VEHICLE:CREATE:
// Args: [frameNo, ocapId, vehicleClass, displayName, className, customization]
type Vehicle struct {
MissionID uint `json:"missionId" gorm:"primaryKey;autoIncrement:false"`
Expand All @@ -290,7 +290,7 @@ func (*Vehicle) TableName() string {
// VehicleState tracks vehicle state at a point in time
// References Vehicle by (MissionID, VehicleObjectID) composite FK
//
// SQF Command: :NEW:VEHICLE:STATE:
// SQF Command: :VEHICLE:STATE:
// Args: [ocapId, pos, dir, alive, crew, frameNo, fuel, damage, engineOn, locked, side, vectorDir, vectorUp, turretAz, turretEl]
type VehicleState struct {
ID uint `json:"id" gorm:"primarykey;autoIncrement;"`
Expand Down Expand Up @@ -324,7 +324,7 @@ func (*VehicleState) TableName() string {
// ProjectileEvent represents a weapon being fired and its full lifetime tracking
// References Soldier by ObjectID for Firer and ActualFirer (remote controller)
//
// SQF Command: :PROJECTILE:
// SQF Command: :EVENT:PROJECTILE:
// Args: [firedFrame, firedTime, firerID, vehicleID, vehicleRole, remoteControllerID,
//
// weapon, weaponDisplay, muzzle, muzzleDisplay, magazine, magazineDisplay,
Expand Down Expand Up @@ -370,7 +370,7 @@ func (p *ProjectileEvent) TableName() string {
}

// ProjectileHitsSoldier records when a projectile hits a soldier
// Part of hitParts array in :PROJECTILE: command: [hitOcapId, component, "x,y,z", frameNo]
// Part of hitParts array in :EVENT:PROJECTILE: command: [hitOcapId, component, "x,y,z", frameNo]
type ProjectileHitsSoldier struct {
ID uint `json:"id" gorm:"primarykey;autoIncrement;"`
ProjectileEventID uint `json:"projectileEventId" gorm:"index:idx_projectile_hit_soldier_projectile_event_id"`
Expand All @@ -384,7 +384,7 @@ type ProjectileHitsSoldier struct {
}

// ProjectileHitsVehicle records when a projectile hits a vehicle
// Part of hitParts array in :PROJECTILE: command: [hitOcapId, component, "x,y,z", frameNo]
// Part of hitParts array in :EVENT:PROJECTILE: command: [hitOcapId, component, "x,y,z", frameNo]
type ProjectileHitsVehicle struct {
ID uint `json:"id" gorm:"primarykey;autoIncrement;"`
ProjectileEventID uint `json:"projectileEventId" gorm:"index:idx_projectile_hit_vehicle_projectile_event_id"`
Expand All @@ -399,7 +399,7 @@ type ProjectileHitsVehicle struct {

// GeneralEvent is a generic event for player connections, mission end, custom events
//
// SQF Command: :EVENT:
// SQF Command: :EVENT:GENERAL:
// Args: [frameNo, eventType, message, extraDataJSON]
// Common eventTypes: "connected", "disconnected", "endMission"
type GeneralEvent struct {
Expand All @@ -420,7 +420,7 @@ func (g *GeneralEvent) TableName() string {
// KillEvent represents an entity being killed/destroyed
// Stores ObjectIDs directly - victim/killer could be soldier or vehicle
//
// SQF Command: :KILL:
// SQF Command: :EVENT:KILL:
// Args: [frameNo, victimOcapId, killerOcapId, weaponText, distance]
type KillEvent struct {
ID uint `json:"id" gorm:"primarykey;autoIncrement;"`
Expand Down Expand Up @@ -492,7 +492,7 @@ func (a *Ace3UnconsciousEvent) TableName() string {

// ChatEvent records chat messages
//
// SQF Command: :CHAT:
// SQF Command: :EVENT:CHAT:
// Args: [frameNo, senderOcapId, channel, from, name, text, playerUID]
type ChatEvent struct {
ID uint `json:"id" gorm:"primarykey;autoIncrement;"`
Expand All @@ -514,7 +514,7 @@ func (c *ChatEvent) TableName() string {

// RadioEvent records TFAR radio transmissions
//
// SQF Command: :RADIO:
// SQF Command: :EVENT:RADIO:
// Args: [frameNo, senderOcapId, radio, radioType, startEnd, channel, isAdditional, frequency, code]
type RadioEvent struct {
ID uint `json:"id" gorm:"primarykey;autoIncrement;"`
Expand All @@ -538,7 +538,7 @@ func (r *RadioEvent) TableName() string {
}

// ServerFpsEvent records server performance metrics.
// Populated from :TELEMETRY: command data (FPS fields).
// Populated from :TELEMETRY:FRAME: command data (FPS fields).
type ServerFpsEvent struct {
Time time.Time `json:"time" gorm:"type:timestamptz;"` // Server time when measurement taken
MissionID uint `json:"missionId" gorm:"index:idx_serverfpsevent_mission_id"`
Expand All @@ -554,7 +554,7 @@ func (s *ServerFpsEvent) TableName() string {

// TimeState represents mission time synchronization data
//
// SQF Command: :NEW:TIME:STATE:
// SQF Command: :TIME:STATE:
// Args: [frameNo, systemTimeUTC, missionDateTime, timeMultiplier, missionTime]
type TimeState struct {
Time time.Time `json:"time" gorm:"type:timestamptz;"` // Server time when recorded
Expand All @@ -573,7 +573,7 @@ func (t *TimeState) TableName() string {

// Marker represents a map marker
//
// SQF Command: :NEW:MARKER:
// SQF Command: :MARKER:CREATE:
// Args: [markerName, direction, type, text, frameNo, -1, ownerOcapId, color, size, side, position, shape, alpha, brush]
type Marker struct {
ID uint `json:"id" gorm:"primarykey;autoIncrement;"`
Expand Down Expand Up @@ -647,7 +647,7 @@ func (*PlacedObjectEvent) TableName() string {

// MarkerState tracks marker position/property changes over time
//
// SQF Command: :NEW:MARKER:STATE:
// SQF Command: :MARKER:STATE:
// Args: [markerName, frameNo, position, direction, alpha]
type MarkerState struct {
ID uint `json:"id" gorm:"primarykey;autoIncrement;"`
Expand Down
Loading
Loading