Reverse-Engineering Dossier · Toolchain V5

# How Steel Panthers Stores a Battle

A consolidated engineering study of the binary scenario formats used by Steel Panthers 1, Steel Panthers 2, and the modern SPCAMO (SPWW2 / SPMBT) engine — the containers, the records, the coordinate systems, and the conversion and import pipelines that move data between them.

3 game formats 20 source modules Scope: scenarios, units, leaders, maps, obstacles, entrenchments Basis: the V5 reader / editor codebase
SECTION 00

## Overview — three games, one lineage

All three games describe the same kind of object — a tactical scenario: a hex map, two orders of battle, leaders, victory locations, and battlefield obstacles. What differs is how those records are physically laid out on disk, and that difference grew more sophisticated with each generation.

##### SP1

Steel Panthers (1995)

A single flat binary blob (~510 KB). No header, no compression — every field lives at a hard-coded absolute offset. Data is stored as parallel arrays: all names together, all positions together, all armor together.

no magic · fixed offsets

##### SP2

Steel Panthers 2

A transitional two-headed format. Early v1.0 files are flat (like SP1); later v1.1 files adopt a sectioned, RLE-compressed container. The reader auto-detects which one it is holding.

STEEL2_SAVE_V100 · or flat

##### SPCAMO

SPWW2 / SPMBT (CAMO engine)

The fully modern SPCTS v3 container — sectioned, RLE-compressed, with explicit per-record structural fields (side, validity, formation links). This is the format the editor reads and writes.

SPCTS_SAVE_V100

The toolchain's job is to read the two legacy formats, normalize them into a portable JSON intermediate, and then write that JSON into a live SPCAMO file — converting the map geometry and translating every nation, unit class, and weapon index between the games' incompatible numbering schemes along the way.

Reading note Every offset and record size in this document is taken directly from the reverse-engineered readers in the V5 codebase. Sections still marked “unknown / TBD” in the source are flagged as such here rather than guessed at.
SECTION 01

## Common building blocks

SP2 (compressed) and SPCAMO share the same container DNA — an artifact of the SSI → CAMO engine lineage. Understanding these three primitives unlocks both formats at once.

### The section container

A compressed file is a 20-byte file header followed by a stream of self-describing sections. There is no central directory; you parse sequentially until you run out of bytes.

0x00 16 B
MAGIC ASCII id
0x10 4 B
0x00 reserved
0x14 sections →

#### Per-section header — 9 bytes

+0 u32 LE
sec #number
+4 u32 LE
len bytes·*
+8 1 B
cmp 0=raw 1=RLE
+9 len B
data payload

* The length field is stored signed and is sometimes negative; readers take the absolute value. After the payload, the next section header begins immediately.

### The RLE codec

Section payloads marked compressed use a trivially simple run-length scheme. The single lead byte decides everything:

    // decompress: lead byte selects run vs. literal
    while (i < data.length) {
      lead = data[i++];
      if (lead > 0x80) {            // RUN
        run = lead - 0x80;
        val = data[i++];
        emit run copies of val;
      } else {                     // LITERAL
        emit next lead bytes verbatim;
      }
    }

The encoder is the mirror image: runs of identical bytes (up to 127) become a `0x80+n` / value pair, everything else is copied as literal packets of up to 128 bytes. Because the editor must round-trip files, it ships both halves of this codec and rebuilds only the sections it touched, copying all others through byte-for-byte.

### The hex coordinate system

Maps are stored column-major. The constant that ties everything together in SPCAMO is the stride `MAPSIZE = 200`: any per-hex array is indexed as `hex = x · 200 + y`. Two map grids coexist — the standalone map file is 100 columns wide, while a full scenario grid is 160 columns × 200 rows, which is why map import has to expand geometry (see §07).

Off-map sentinels A unit that isn't on the map (off-map artillery, a passenger in a transport) is marked by a sentinel coordinate: 255 in SP1, 0xFFFF in SP2 and SPCAMO. Empty victory-location slots use 0xDE (SPCAMO) or 0xFF (SP2).
SECTION 02

## SP1 — the flat-file era

SP1 has no container at all. The scenario is one large fixed-size image of the game's memory, and the reader simply reaches into known absolute offsets. Records are stored as parallel arrays rather than structs: to assemble unit #7 you gather its name, class, position, weapons, ammo and armor from six different regions of the file.

File size ≈ 509–510 KB Unit slots 199 (100 P1 + 99 P2) Leaders up to 500 Victory hexes 21

### Scenario header block

The scenario parameters sit in a tight cluster just past offset 37,500. Battle type is encoded as a two-byte pair `(bf1,bf2)`; e.g. `(1,2)` = “Player 1 Assaults”, `(1,1)` = “Meeting Engagement”.

SP1 scenario fields (absolute offsets)
Offset | Field | Notes
--- | --- | ---
37513 | month | 1–12 index into month table
37514 | year | stored as year − 1900
37515 | weather | 0=Desert 1=Summer 2=Winter
37518 / 37519 | P1 / P2 nationality | index into 18-entry nation table
37522 / 37523 | battle bf1 / bf2 | pair encodes advance/assault/meeting
37524 | max turns | —
37612 | visibility | hex range
37626 | location text | 19-byte ASCIIZ, leading-null tolerant

### Unit parallel arrays

Each domain of unit data lives in its own striped region. Positions are 19-byte records split by side; soft units (infantry, guns) carry all-zero armor.

SP1 unit data regions
Region base | Stride | Holds
--- | --- | ---
293674 (P1) · 295574 (P2) | 19 B | position: x@+0, y@+2, hull facing@+10, turret@+12
18104 | 4 B | weapon slot type IDs (×4)
18908 | 8 B | per-weapon HE/AP ammo counts
20514 | 14 B | armor: FH,SH,RH,FT,ST,RT,TOP + pad
30501 / 30701 / 30901 | 1 B | smoke / HVAP / HEAT ammo

The “dead slot” trap A slot having a non-empty name does not mean the unit is live. SP1 stops loading a side at the first formation byte of `0`; slots past that gap are orphaned — real-looking data the game never instantiates. The reader exposes an `isDead` flag so consumers filter on it rather than on name presence.

### Leaders & obstacles

Leaders are striped much like units: names at offset 0 (15 B each), ranks at 8500, rally at 9000, skills (infantry/artillery/armor) at 9500. Battlefield obstacles live in a 19-byte record array beginning at offset 301236 (0x49854), organized into blocks of 100 slots.

Entrenchments are SP1's most baroque structure: they are reconstructed from paired blocks — a position-marker record in one block and a graphic/type record in a parallel block share the same slot index. Block 2/3 hold P1/P2 position markers; block 6/7 hold the matching graphic tiles that name the emplacement (foxhole vs. sandbag).

SECTION 03

## SP2 — one reader, two formats

SP2 sits on the fault line between eras. The reader sniffs the first 16 bytes and the file size to decide which layout it is dealing with, then presents a unified view either way.

##### v1.1 Compressed

magic “STEEL2_SAVE_V100”

The sectioned + RLE container from §01. This is already the SPWW2 v2.2 binary shape, which is why SP2 map conversion can hand it almost directly to the v2→v3 converter.

##### v1.0 Uncompressed

no header · ≈ 690–705 KB

Flat binary; sections concatenated at fixed absolute offsets. Detected when the file is ≥ 600 KB and lacks the magic. The reader synthesizes section views over the raw bytes.

#### Uncompressed section anchors

Section | Abs. offset | Record | Count
--- | --- | --- | ---
1 · units | 0 | 220 B | 399
17 · positions | 464009 | 19 B | 399
34 · leaders | 687058 | 30 B | 399
35 · formations | 699058 | 36 B | 150
37 · scenario | 703998 | 135 B | 1
38 / 39 · map W/H | 705144 / 705148 | 4 B | byte[0]

### The unit record (Section 1, 220 bytes)

Unlike SP1's parallel arrays, SP2 packs a unit into one contiguous record. Crucially, records are not split by side — the side is read from a single control byte at `+178`.

SP2 Section-1 unit record — selected fields
Off | Size | Field
--- | --- | ---
0 | 16 | name (ASCIIZ)
20 / 21 | 1+1 | formation id / sub-id (P2 ids start at 64)
45–60 | 4×4 | per-weapon ammo (HE/AP/Sabot/HEAT)
118 / 119 | 1+1 | experience / morale
121 | 1 | status (Ready…Destroyed, 0–9)
122 | 2 | bombardment ammo type
145 / 147 | 2+2 | hull / turret facing (degrees)
171 | 2 | transporter unit id (0xFFFF = none)
178 | 1 | sideCtrl — 0=P1, 1=P2
211 | 1 | altitude (0=Landed 1=NoE 2=High)

### Obstacles & entrenchments in Section 17

SP2 folds both unit positions and battlefield obstacles into Section 17 — 5000 records of 19 bytes, organized as ten blocks of 500. Obstacle type is the byte at `+14`; the side at `+15`.

Block | Records | Contents | Discriminator
--- | --- | --- | ---
2 | 1000–1499 | Dragon's Teeth | `typ=0`, str = count×10
6 | 3000–3499 | Mines | `typ=1`, str = count×10
— | (shared) | Entrenchments | `typ=1` & str = 0xFFFF

The `strength = 0xFFFF` marker is what distinguishes a foxhole/sandbag emplacement from a destructive obstacle sharing the same block. Tile IDs name the type — sandbags `1430–1433`, foxholes `1464–1466` — a numbering that SPCAMO inherits unchanged.

SECTION 04

## SPCAMO — the SPCTS v3 container

This is the live format: the editor both reads and writes it, so its structure is documented in the most detail. It is the §01 container with magic `SPCTS_SAVE_V100`, and its records carry explicit structural metadata — side, validity, formation parentage — that the legacy formats only implied by position.

### Section inventory

SPCAMO / SPCTS v3 sections (RLE unless noted)
# | Purpose | Layout
--- | --- | ---
1 | Unit records | 261 B × 1000 (500 P1 + 500 P2)
8 | Per-hex terrain | 15 B / hex × 200 × width
17 | Unit positions + obstacles + entrenchments | 19 B records, blocks of 1000
34 | Leader data | 40 B × 999
35 | Formation data | 60 B × 400
37 | Scenario metadata | 594 B — primary edit target
38 / 39 | Map width / height | 4 B each (raw); byte[0] = count
9 / 52 / 56 | Per-hex flags: mine / dragon's-teeth / barbed-wire | 1 B / hex
54 | Map label text | 6400 B (100 × 64-byte records)
11 · 36 · 40 · 41 · 57 · 59 | Misc / partially-mapped blocks | documented as TBD in source

### The unit record (Section 1, 261 bytes)

Where SP1 implied side by slot range and SP2 used one control byte, SPCAMO makes everything explicit and self-consistent — which is what lets the editor insert, delete and re-side units safely.

SPCAMO Section-1 unit record — key fields
Off | Sz | Field | Meaning
--- | --- | --- | ---
0 | 15 | name | ASCIIZ
16 | 2 | classID | unit class index
20 / 22 | 2 / 1 | formID / subID | formation membership
146 | 2 | picture | icon id
148 / 150 | 2 / 2 | rotation 1/2 | hull / turret facing (degrees)
161 | 1 | statModified | editor-dirty / anti-cheat flag
162 / 163 | 1 / 2 | obatNationID / obatID | order-of-battle linkage
165 | 1 | unitMode | bitmask: bit2=side, bits0-1=type
175 | 2 | carrier | 0xFFFF = on map independently
177 | 1 | valid | slot in use
178 | 1 | entr | per-unit dug-in flag (see §06)
180 | 1 | isOffMap | 1 = off-map support
182 | 1 | side | 0=P1 1=P2 (authoritative)
215 | 1 | altitude | landed / NoE / high

### Scenario metadata (Section 37)

594 decompressed bytes hold the whole scenario envelope. Victory locations are four parallel 21-entry arrays; an empty slot is `0xDE`.

Off | Field
--- | ---
17 / 38 / 59 / 80 | VLOC x[] / y[] / value[] / control[] — 21 entries each
104 | wind direction (raw, decode TBD)
Month, year, weather, nations and battle-type pair occupy the leading bytes, mirroring the SP2 §37 layout.

Why writes are safe The editor never rebuilds a whole file. It decompresses only the sections it changes, patches the exact bytes, re-compresses just those, and copies every other section through verbatim. Unit edits flow through an in-memory model written back to Section 1; position edits patch Section 17 block 0 while leaving obstacle/entrenchment blocks untouched.
SECTION 05

## Cross-format comparison

Read sideways, the same conceptual record takes three very different shapes. This is the table to keep open when writing a converter.

The same data domain across all three formats
Domain | SP1 | SP2 | SPCAMO
--- | --- | --- | ---
Container | flat, no header | flat or STEEL2 sections | SPCTS sections + RLE
Unit storage | parallel arrays | 220 B record × 399 | 261 B record × 1000
Side encoding | slot range (0–99 / 100–198) | byte @ +178 | byte @ +182 (+ unitMode bit)
Positions | 19 B, split by side | Section 17, 19 B | Section 17 block 0, 19 B
Leaders | striped arrays (name/rank/skill) | Section 34, 30 B | Section 34, 40 B
Formations | implicit (formation byte) | Section 35, 36 B | Section 35, 60 B
Victory locs | 21 slots near 0x9286 | §37 arrays, empty=0xFF | §37 arrays, empty=0xDE
Obstacles | 19 B array @ 301236 | §17 blocks 2 / 6 | §17 block 2 / 6 + per-hex flags
Entrenchments | paired pos + gfx blocks | §17, str=0xFFFF | 3-way write (see §06)
Off-map marker | x = 255 | x = 0xFFFF | x = 0xFFFF / carrier

Two invariants survive every generation and are worth committing to memory: the 19-byte obstacle/position record and the entrenchment tile numbering (sandbags 1430–1433, foxholes 1464–1466). Everything else — container, striping, side encoding — was redesigned at least once.

SECTION 06

## Entrenchment deep-dive

Unit-level entrenchment is the one place where a single game concept is spread across three independent sections that must agree. It was the hardest part of the format to pin down, and it was settled empirically by diffing matched “entrenched / not-entrenched” save pairs for each side.

The core finding Setting the per-unit flag alone does nothing visible in-game. A unit that the engine treats as dug in is the product of three coordinated writes. A save where the flag was set but the emplacement and hex marker were absent rendered the unit as not entrenched.

### The three writes (per entrenched unit)

##### ① Section 1

the unit flag

`S1[slot·261 + 178] = 1`. The per-unit dug-in boolean. Necessary, but not sufficient on its own.

##### ② Section 17

the emplacement

A 19-byte record appended in block 2 at the unit's hex: tile = foxhole/sandbag id, `active=5`, `strength=0xFFFF`, `typ=1`, `side`. Two records if a hex stacks both types.

##### ③ Section 8

the hex marker

`S8[(x·200+y)·15 + 8] |= bit` — OR in `0x40` for a foxhole, `0x80` for a sandbag. A hex carrying both ends at `0xC0` (192).

#### Section-8 hex byte is a bitfield

0x40 64 · foxhole
0x80 128 · sandbag
0xC0 192 · both

Because the byte is OR-combined rather than overwritten, the importer must read-modify-write: a hex that already holds a foxhole and gains a sandbag must end at 192, and a single hex may legitimately own two emplacement records in Section 17.

Verification Re-deriving an entrenched file from its plain baseline + a JSON description reproduced the game's own output byte-for-byte across four matched save pairs (P1 and P2, 26 to 121 units), in Section 1, Section 8, and the Section 17 emplacement set. The flag at offset 178 flips cleanly `0→1` on a genuine entrench; cases where it appeared pre-set turned out to be a deploy-time artifact, with the emplacement + hex marker remaining the authoritative signal.
SECTION 07

## The map conversion chain

Maps are not converted in one hop. They are walked up a version ladder — SP1/SP2 geometry is first lifted into the intermediate SPWW2 v2.2 shape, then into v3, then wrapped into a full playable scenario.

SOURCE

###### SP1 / SP2 map

Raw scenario; map lives in tile-layer sections.

▶
STAGE 1

###### SPWW2 v2.2

`SP1_Map.js` / `SP2_Map.js` emit a STEEL2_SAVE_V100 intermediate.

▶
STAGE 2

###### SPCTS v3 map

`SPCAMO_Map.js · convertV2toV3()` rewrites tile records to v3.

▶
STAGE 3

###### Full scenario

`SP_ScenStub.js` expands & wraps in a blank template.

The two source modules feed the same converter from different starting points. SP2 compressed files are already in v2.2 shape and pass through almost directly; SP2 uncompressed and all SP1 files have their map sections extracted and assembled into a synthetic STEEL2 blob first.

#### Geometry the stub must reconcile

- The v3 map file is 100 columns wide; a playable scenario grid is 160 × 200. `SP_ScenStub.js` expands each tile layer from 100 → 160 columns, column-major.
- v2.2 tile layers are 16,000 bytes each (sections 2–7); the elevation/extra layers use 12-, 13-, and 1-byte-per-hex records (sections 8, 28, 29).
- The expanded map sections are merged with blank non-map sections from an embedded template so the result is a complete, loadable SPCTS scenario rather than a bare map.

SECTION 08

## JSON import & cross-game translation

The reader app exports any SP1/SP2/SPCAMO scenario to a portable JSON. The editor's import handlers replay that JSON into a live SPCAMO file — and the difficult part is not the bytes, it is reconciling three games' incompatible numbering schemes.

##### Import surface

editor entry points

`importScenJSON()` — scenario parameters · `importVlocJSON()` — victory locations · `importUnitsFromJSON()` — units, leaders & formations · `importEntrenchJSON()` — obstacles, then optionally unit-personal entrenchments.

##### Translation tables

SPCAMO_Translation.js

Maps source-game indices to engine indices: `SP1→SPWW2` and `SP2→SPMBT` for nations, with reverse lookups and human-readable name tables, plus unit-class mapping into the SPWW2/SPMBT OBAT id space.

### Why translation is unavoidable

Nation, unit-class and weapon indices are per-game enumerations. SP2 nation 0 (“West Germany”) must become SPMBT nation 44 (“Germany”); an SP1 unit class must resolve to a concrete SPWW2 OBAT slot before its weapons and armor can be looked up. The unit importer therefore does more than copy fields — it re-homes each unit into the target game's order of battle, writes new Section 35 formation records, wires leader ranges, and lets the editor's patched writer lay down all structural Section-1 fields explicitly.

### Entrenchment import — the two-phase flow

The entrenchment importer runs map obstacles first (dragon's teeth + mines from the JSON `obstacles` array), then prompts before touching units. On confirmation it reads the `unitPersonalEntrenchments` array, matches each record strictly by hex + side to an on-map unit, and performs the three §06 writes — honoring the source tile id so a foxhole stays a foxhole.

### Scenario packaging

Distribution scenarios travel in ZIP packs. `SP_Zip.js` opens a pack, finds every `.DAT`, and pairs it with its `.CMT` sidecar — a 200-byte fixed record whose leading ASCIIZ string is the human-readable scenario title — matching by case-insensitive filename stem.

SECTION 09

## File inventory

What each module in the V5 release is responsible for. The data is intentionally fragmented across many files so the apps can lazy-load only the tables they need.

Applications
File | Role
--- | ---
1_SP_READER_v18.htm | Reads SP1/SP2/SPCAMO scenarios; renders OOB; exports the JSON + OOB text intermediate.
2_SP_EDITOR_SPCAMO_v51.htm | Loads, edits and re-writes SPCAMO files; hosts every JSON import handler.
0_SPCONVERT_MAP_v5…html | Map-conversion test harness / display.

Format readers & the container
File | Role
--- | ---
SP1_ScenData.js | SP1 flat-file reader — scenario, units, leaders, vlocs, bombardment, obstacles, entrenchments.
SP2_ScenData.js | SP2 dual-format reader (compressed + uncompressed auto-detect).
SPCAMO_ScenData.js | SPCTS v3 reader/writer core: section parser, RLE codec, `_rebuildFile`, all S1/S17/S34/S37 constants.

Map & packaging pipeline
File | Role
--- | ---
SP1_Map.js | SP1 map → SPWW2 v2.2.
SP2_Map.js | SP2 map → v2.2 (direct for compressed, assembled for flat).
SPCAMO_Map.js | v2.2 → v3 (`convertV2toV3`).
SP_ScenStub.js | v3 map → full 160-col scenario via blank template.
SP_Zip.js | ZIP pack reader; DAT↔CMT title pairing (needs fflate).

Import logic & lookup data
File | Role
--- | ---
SPCAMO_Import.js | Scenario-parameter & victory-location import.
SPCAMO_UnitImport.js | Unit / leader / formation import with OBAT re-homing.
SPCAMO_Import_Entrench.js | Obstacle import + the two-phase unit-personal entrenchment import.
SPCAMO_Translation.js | Nation / class cross-game translation + name tables.
SPCAMO_UnitsMBT.js · SPCAMO_UnitsWW2.js | Order-of-battle tables (the bulk of the data weight).
SPCAMO_Weapons_V2.js | Weapon name + max-range by `[nation][slot]`.
SPCAMO_Icons_MBT.js · SPCAMO_Icons_WW2.js | Unit icon glyph data.
SPCAMO_UnitImport_Names.js | Name resolution support for import.

SECTION 10

## Offset appendix

A consolidated quick-reference of the magic numbers most often needed when writing tooling against these formats.

#### Magic & structural constants

Constant | Value | Where
--- | --- | ---
SP2 compressed magic | STEEL2_SAVE_V100 | SP2 v1.1, SPWW2 v2.2
SPCAMO magic | SPCTS_SAVE_V100 | v3 scenario / map
File header size | 0x14 (20) | both sectioned formats
Section header size | 9 | 4+4+1
RLE run threshold | 0x80 | lead > 0x80 ⇒ run
Hex stride (MAPSIZE) | 200 | hex = x·200+y
Scenario grid | 160 × 200 | SP_ScenStub
Obstacle record size | 19 B | all formats

#### Entrenchment write recipe (SPCAMO)

    // for each entrenched unit at (x,y), side s:
    S1[ slot*261 + 178 ] = 1;                       // ① dug-in flag
    S17.block2.append({ x, y,                          // ② emplacement
         tile:pictureID, active:5,
         strength:0xFFFF, typ:1, side:s });
    S8[ (x*200+y)*15 + 8 ] |= isFoxhole ? 0x40 : 0x80;  // ③ hex marker (OR)

#### Entrenchment tile ids (shared SP1 / SP2 / SPCAMO)

Tile | Type | S8 bit
--- | --- | ---
1430–1433 | Sandbag Emplacement (variants A–D) | 0x80
1464 | Foxhole (E) | 0x40
1465 | Foxhole (NE) | 0x40
1466 | Foxhole (NW) | 0x40

Steel Panthers Data Format Engineering Study — compiled from the V5 reader/editor toolchain.
Offsets reflect the reverse-engineered readers as shipped; sections marked TBD in source remain open questions. Game data and formats © their respective authors; this document describes interoperability behavior only.

↑ TOP