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.
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
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 offsetsSP2
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 flatSPCAMO
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_V100The 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.
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.
Per-section header — 9 bytes
* 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).
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.
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”.
| 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.
| 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 |
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).
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
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
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.
| 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.
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
| # | 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.
| 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. | |
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.
| 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.
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 three writes (per entrenched unit)
① Section 1
S1[slot·261 + 178] = 1. The per-unit dug-in boolean. Necessary, but not sufficient on its own.
② Section 17
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
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
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.
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.
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.
SP1 / SP2 map
Raw scenario; map lives in tile-layer sections.
SPWW2 v2.2
SP1_Map.js / SP2_Map.js emit a STEEL2_SAVE_V100 intermediate.
SPCTS v3 map
SPCAMO_Map.js · convertV2toV3() rewrites tile records to v3.
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.jsexpands 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.
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
importScenJSON() — scenario parameters · importVlocJSON() — victory locations · importUnitsFromJSON() — units, leaders & formations · importEntrenchJSON() — obstacles, then optionally unit-personal entrenchments.
Translation tables
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.
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.
| 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. |
| 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. |
| 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). |
| 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. |
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.