Skip to content

Repository files navigation

cncstats

A replay parser for Command and Conquer: Generals: Zero Hour. Includes mappings of most commands and support for parsing INI files to get unit/building info.

Features

  • Complete Replay Parsing: Parse entire replay files and extract all game events
  • Streaming Replay Parsing: Stream replay events as they're being written to a file (useful for live game monitoring)
  • INI Data Integration: Parse CNC INI files to get unit, building, upgrade, and power information
  • Web API: HTTP endpoint for uploading and parsing replay files
  • Command Line Tool: Process individual replay files locally

Usage

Command Line Usage

# Process a single replay file
./cncstats -local -file replay.rep

# Process with custom INI data path
./cncstats -local -file replay.rep -objdata /path/to/ini/data

# Run web server
./cncstats

INI data trees

Replay commands reference objects, upgrades, and powers by IDs that the engine deals out in INI parse order, so the parser needs the same data the recording client loaded:

  • inizh/Data/INI — retail 1.04 data. Used for retail replays and zulu releases through 1.5.4.
  • inizh/Data/INI_v155 — zulu 1.5.5+ data: the community balance patch restructured Object into per-unit files (reordering object IDs wholesale), and 1.5.5+ clients record upgrades as stable ids (mask bits, first Upgrade.ini entry = 4) instead of raw name keys. The tree holds the effective load set: retail Object/FactionUnit.ini (the one retail file the patch does not blank) plus the nested tree from the zulu Zulu.big.

initializeStores loads both and registers the 1.5.5+ pair via iniparse.RegisterCommunityStores; zhreplay then picks per replay based on the header version. Override the second tree's location with CNC_INI_155 (defaults to INI_v155 next to the main INI directory). If the tree is missing, 1.5.5+ replays fall back to legacy data (and decode wrong, as before).

Library Usage

Complete Replay Parsing

package main

import (
    "os"
    "github.com/bill-rich/cncstats/pkg/bitparse"
    "github.com/bill-rich/cncstats/pkg/iniparse"
    "github.com/bill-rich/cncstats/pkg/zhreplay"
)

func main() {
    // Initialize stores
    objectStore, _ := iniparse.NewObjectStore("./inizh/Data/INI")
    powerStore, _ := iniparse.NewPowerStore("./inizh/Data/INI")
    upgradeStore, _ := iniparse.NewUpgradeStore("./inizh/Data/INI")
    
    // Open replay file
    file, _ := os.Open("replay.rep")
    defer file.Close()
    
    // Create parser
    bp := &bitparse.BitParser{
        Source:       file,
        ObjectStore:  objectStore,
        PowerStore:   powerStore,
        UpgradeStore: upgradeStore,
    }
    
    // Parse complete replay
    replay := zhreplay.NewReplay(bp)
    
    // Access parsed data
    fmt.Printf("Map: %s\n", replay.Header.Metadata.MapFile)
    fmt.Printf("Events: %d\n", len(replay.Body))
}

Streaming Replay Parsing

package main

import (
    "context"
    "fmt"
    "time"
    "github.com/bill-rich/cncstats/pkg/iniparse"
    "github.com/bill-rich/cncstats/pkg/zhreplay"
)

func main() {
    // Initialize stores
    objectStore, _ := iniparse.NewObjectStore("./inizh/Data/INI")
    powerStore, _ := iniparse.NewPowerStore("./inizh/Data/INI")
    upgradeStore, _ := iniparse.NewUpgradeStore("./inizh/Data/INI")
    
    // Create context with timeout
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    
    // Configure streaming options
    options := &zhreplay.StreamReplayOptions{
        PollInterval: 100 * time.Millisecond,
        MaxWaitTime:  30 * time.Second,
        BufferSize:   100,
    }
    
    // Start streaming
    bodyChan, streamingReplay, err := zhreplay.StreamReplay(
        ctx, "replay.rep", objectStore, powerStore, upgradeStore, options)
    if err != nil {
        log.Fatal(err)
    }
    
    // Print header info
    fmt.Printf("Map: %s\n", streamingReplay.Header.Metadata.MapFile)
    
    // Process events as they arrive
    for chunk := range bodyChan {
        fmt.Printf("Event: %s at time %d\n", chunk.OrderName, chunk.TimeCode)
        
        // Check for EndReplay command
        if chunk.OrderCode == 27 {
            fmt.Println("Game ended")
            break
        }
    }
}

Web API Usage

Start the web server:

./cncstats

Upload a replay file:

curl -X POST -F "file=@replay.rep" http://localhost:8080/replay

Authentication

The write endpoints (POST /replay, POST /stats, POST /add_map) can be protected with API keys. The read endpoints (/map_exists, /get_map, /get_map_file, /list_map_assets) and the docs are always open, since game peers need them mid-lobby.

Auth is controlled by two environment variables:

Variable Purpose Default
CNC_AUTH_REQUIRED Whether write endpoints require a key. Set to false/0 to disable; any other value (or unset) enforces auth. true
CNC_API_KEYS Comma-separated list of name:key pairs. The name is for logging only; the key is the secret.

Configure one key per client. Add, replace, or remove clients by editing CNC_API_KEYS and restarting — no code change required:

export CNC_AUTH_REQUIRED=true
export CNC_API_KEYS="zulu:$(openssl rand -hex 32),radarvan:$(openssl rand -hex 32),dev:dev-local-key"
./cncstats

When CNC_AUTH_REQUIRED is true but CNC_API_KEYS has no valid entries, the server refuses to start (nobody could authenticate). Malformed entries (missing :, empty name or key) are logged and skipped.

Clients send the key as a bearer token or in the X-API-Key header:

# Authorization: Bearer
curl -X POST -F "file=@replay.rep" \
  -H "Authorization: Bearer <key>" \
  http://localhost:8080/replay

# X-API-Key
curl -X POST --data-binary @stats.json.gz \
  -H "X-API-Key: <key>" \
  -H "X-Game-Seed: 12345" \
  http://localhost:8080/stats

Requests with a missing or invalid key get 401 {"error":"invalid or missing API key"}.

For local development, disable auth entirely:

export CNC_AUTH_REQUIRED=false
./cncstats

Docker

Build the image:

docker build -t onethree111/cncstats .

Push it:

docker push onethree111/cncstats

Pull and run it elsewhere:

docker pull onethree111/cncstats
docker run -p 8080:8080 onethree111/cncstats

To tag a specific version:

docker build -t onethree111/cncstats:v1.0 .
docker push onethree111/cncstats:v1.0

Examples

See examples/example_streaming.go for a complete example of streaming replay parsing.

Useful commands

Filter by playerID, and remove checksum, deselects, and camera movements. jq '.Body[] | select(.OrderCode != 1095 and .OrderCode != 1092 and .OrderCode != 1003 and .OrderCode != 2000 and .OrderCode != 1001 and .OrderCode != 1058 and .OrderCode != 1068)' | less

Annotated outputs/thoghts

{
  "TimeCode": 210,
  "OrderType": 1001,  // Select
  "Number": 2,
  "UniqueOrders": 2,
  "Args": [
    {
      "Type": 2,
      "Count": 1,
      "Args": [
        true
      ]
    },
    {
      "Type": 3,
      "Count": 1,
      "Args": [
        376         // First USA Command Center
      ]
    }
  ]
}
{
  "TimeCode": 240,
  "OrderType": 1047,   // Build
  "Number": 2,
  "UniqueOrders": 1,
  "Args": [
    {
      "Type": 0,
      "Count": 2,
      "Args": [
        135,          // Another dozer (Dozer 2)
        1
      ]
    }
  ]
}

{
  "TimeCode": 412,
  "OrderType": 1001, // Select
  "Number": 2,
  "UniqueOrders": 2,
  "Args": [
    {
      "Type": 2,
      "Count": 1,
      "Args": [
        true
      ]
    },
    {
      "Type": 3,
      "Count": 1,
      "Args": [
        377   // USA first dozer
      ]
    }
  ]
}
{
  "TimeCode": 476,
  "OrderType": 1049, // Build
  "Number": 2,
  "UniqueOrders": 3,
  "Args": [
    {
      "Type": 0,
      "Count": 1,
      "Args": [
        1229    // USA Power Plant
      ]
    },
    {
      "Type": 6,
      "Count": 1,
      "Args": [
        {
          "X": 1144227816, // Probably broken
          "Y": 1158237722,
          "Z": 1106903024
        }
      ]
    },
    {
      "Type": 1,
      "Count": 1,
      "Args": [
        -0.7853982  // Angle??
      ]
    }
  ]
}
{
  "TimeCode": 718,
  "OrderType": 1001,  // Select
  "Number": 2,
  "UniqueOrders": 2,
  "Args": [
    {
      "Type": 2,
      "Count": 1,
      "Args": [
        true
      ]
    },
    {
      "Type": 3,
      "Count": 1,
      "Args": [
        383  // USA Dozer #2
      ]
    }
  ]
}
{
  "TimeCode": 752,
  "OrderType": 1068,  // Move to
  "Number": 2,
  "UniqueOrders": 1,
  "Args": [
    {
      "Type": 6,
      "Count": 1,
      "Args": [
        {
          "X": 1138571963, Where to move (should be nearish power plant, opposite side of command center)
          "Y": 1158466853,
          "Z": 1106903040
        }
      ]
    }
  ]
}
{
  "TimeCode": 796,
  "OrderType": 27,
  "Number": 2,
  "UniqueOrders": 0,
  "Args": []
}

Jan 8, 2026

About

No description, website, or topics provided.

Resources

Stars

10 stars

Watchers

3 watching

Forks

Releases

Packages

Used by

Contributors

Languages