// SP1_ScenData.js — Steel Panthers 1 Scenario Data Extractor
//
// Reverse-engineered from SP_Module_SP1.py (original Python by the project author).
// Reads a raw SP1 scenario ArrayBuffer and returns structured JavaScript objects.
//
// Exported functions:
//   readSP1ScenarioData(arrayBuffer)  → scenarioData object
//   readSP1Units(arrayBuffer)         → array of 199 unit objects
//   readSP1Leaders(arrayBuffer)       → array of 399 leader objects
//   readSP1VLocs(arrayBuffer)         → array of 21 victory-location objects
//   readSP1Bombardment(arrayBuffer)   → array of 199 bombardment objects
//   readSP1All(arrayBuffer)           → { scenario, units, leaders, vlocs, bombardment }

"use strict";

// ─── Lookup Tables ────────────────────────────────────────────────────────────

const SP1_MONTHS = [
    '', "January","February","March","April","May","June",
    "July","August","September","October","November","December"
];

//added dead zero in list above to index names correctly to month numbers.

const SP1_WEATHER = ["Desert", "Summer", "Winter"];

const SP1_NATIONALITIES = [
    "Germany","Finland","Italy","Romania","Hungary","Japan",
    "France","Great Britain","Belgium","Netherlands","Poland",
    "Soviet Union","US Army","US Marines","China","Greece","Norway","Yugoslavia"
];

const SP1_BATTLE_TYPES = {
    "0,3": "Player 1 Advances",
    "1,2": "Player 1 Assaults",
    "1,1": "Meeting Engagement",
    "2,1": "Player 2 Advances",
    "3,0": "Player 2 Assaults",
};

// Hex facing labels — clockwise from East (validated against in-game rotation)
const SP1_FACINGS = ["E", "SE", "SW", "W", "NW", "NE"];

const SP1_UNIT_CLASSES = [
    "Fort",              //  0
    "Infantry",          //  1
    "Inf-AT",            //  2
    "Cavalry",           //  3
    "Machinegun",        //  4
    "Mortar",            //  5
    "Anti-Tank Gun",     //  6
    "Infantry Gun",      //  7
    "Flak",              //  8
    "Howitzer",          //  9
    "Artillery",         // 10
    "Armored Car",       // 11
    "Light Tank",        // 12
    "Medium Tank",       // 13
    "Heavy Tank",        // 14
    "CS-Tank",           // 15
    "Inf-Tank",          // 16
    "Flame-Tank",        // 17
    "Assault Gun",       // 18
    "TD",                // 19
    "Engineer",          // 20
    "SP-Artillery",      // 21
    "SP-Flak",           // 22
    "APC",               // 23
    "Halftrack",         // 24
    "Heavy Halftrack",   // 25
    "Light Truck",       // 26
    "Medium Truck",      // 27
    "Heavy Truck",       // 28
    "Hvy TD",            // 29
    "Cruiser Tank",      // 30
    "Rocket",            // 31
    "Command Tank",      // 32
    "Amphib",            // 33
    "Heavy Amphib",      // 34
    "Amphib Tank",       // 35
    "Mineclear Tank",    // 36
    "Engineer Tank",     // 37
    "Heavy AT-Gun",      // 38
    "SP-Gun",            // 39
    "Heavy Infantry",    // 40
    "Landing Barge",     // 41
    "Assault Boat",      // 42
    "LCS",               // 43
    "Aircraft",          // 44
    "Sniper",            // 45
    "LCG",               // 46
    "Helicoptor",        // 47
    "Barge-Carrier",     // 48
    "Motorcycle",        // 49
    "Attack Helicoptor", // 50
];

const SP1_LEADER_RANKS = [
    "KIA","Private","Corporal","Sergeant",
    "2nd Lieutenant","1st Lieutenant","Captain",
    "Major","Lt Colonel","Colonel","General"
];

const SP1_BOMB_TYPES = {
    0:   "No Mission",
    201: "HE Bombardment Mission",
    202: "SMOKE Bombardment Mission",
};

// ─── Obstacle (mines / dragon's teeth) constants ─────────────────────────────
// Obstacles are stored as 19-byte records at absolute file offset 0x49854 (301236).
// The array has ~800 records spanning multiple "blocks" of 100 slots each.
// Blocks 4-5 (records 400-599) hold dragon's teeth and mines with strength values.
// Records with x,y = 0xFFFF or strength = 0xFFFF are empty/shadow entries.
const SP1_OBSTACLE_BASE    = 301236;   // absolute file offset of first record
const SP1_OBSTACLE_RECSIZE = 19;       // bytes per record
const SP1_OBSTACLE_SLOTS   = 800;      // total records to scan

const SP1_OBSTACLE_TYPES = {
    0: "Dragon's Teeth",
    1: "Mines",
};

// ─── Entrenchment (foxholes / sandbag emplacements) constants ────────────────
// Entrenchments are read from paired blocks in the obstacle array:
//   Block 2 (recs 200-299) = P1 position markers  (tileID ~6103, strength=0xFFFF)
//   Block 3 (recs 300-399) = P2 position markers  (tileID ~6100, strength=0xFFFF)
//   Block 6 (recs 600-699) = P1 graphic/type records (same slot index as Block 2)
//   Block 7 (recs 700-799) = P2 graphic/type records (same slot index as Block 3)
// The Block 6/7 tileID identifies the emplacement type.
const SP1_ENTRENCH_TILES = {
    1430: "Sandbag Emplacement (Var A)",
    1431: "Sandbag Emplacement (Var B)",
    1432: "Sandbag Emplacement (Var C)",
    1433: "Sandbag Emplacement (Var D)",
    1464: "Foxhole (E)",
    1465: "Foxhole (NE)",
    1466: "Foxhole (NW)",
};

// SP1 weapon ID → name.
// Sourced from SP1_Weapon_List.htm (IDs 1-245, sparse — gaps are unnamed).
// ID 0 = no weapon / empty slot.
const SP1_WEAPON_NAMES = {
      0: "None",
      1: "Rifle",           2: "SMG",             3: "MG-34 LMG",       4: "MG-34 MMG",
      5: "MG-42 MMG",       6: "MG-34 TMG",        7: "20mm Quad",        8: "20L55 Gun",
      9: "25L72 Gun",       10: "28L61 Gun",       11: "37mm Flak",       12: "37L21 Gun",
     13: "37L33 Gun",       14: "37L40 Gun",       15: "37L46 Gun",       16: "37L53 Gun",
     17: "40mm Flak",       18: "45L46 Gun",       19: "45L66 Gun",       20: "2 Pounder",
     21: "47L32 Gun",       22: "47L34 Gun",       23: "47L40 Gun",       24: "47L43 Gun",
     25: "50L42 Gun",       26: "50L60 Gun",       27: "57L52 Gun",       28: "57L73 Gun",
     29: "6 Pounder",       30: "60mm Mortar",     31: "65L17 Gun",       32: "75L12 Gun",
     33: "75mm FH",         34: "76L16 Gun",       35: "3in Howitzer",    36: "76mm FH",
     37: "3in Mortar",      38: "17 Pounder",      39: "75L17 Gun",       40: "50mm Mortar",
     41: "75L24 Gun",       42: "75L31/m2 Gun",    43: "290mm Spigot",    44: "75L34 Gun",
     45: "75L38 Gun",       46: "75L43 Gun",       47: "75L48 Gun",       48: "75L70 Gun",
     49: "76L26 Gun",       50: "76L35 Gun",       51: "76L41 Gun",       52: "76L51 Gun",
     53: "76L54 Gun",       54: "77L49 Gun",       55: "75L36 Gun",       56: "81mm Mortar",
     57: "82mm Mortar",     58: "85L53 Gun",       59: "88mm Flak-18",    60: "88L56 Gun",
     61: "88L71/kwk Gun",   62: "88L71/Pak Gun",   63: "25 Pounder",      64: "90L50 Gun",
     65: "90L53 Gun",       66: "152L20 Gun",      67: "95L22 Gun",       68: "105L30 Gun",
     69: "100L60 Gun",      70: "100mm FH",        71: "105mm FH",        72: "4.2in Mortar",
     73: "120mm Mortar",    74: "122mm FH",        75: "122L43 Gun",      76: "122L22 Gun",
     77: "128L58 Gun",      78: "150L10 Gun",      79: "150mm FH",        80: "152mm FH",
     81: "152L32 Gun",      82: "132mm Rocket",    83: "Flamethrower",    84: "Satchel Charge",
     85: "Molotov",         86: "AT Mine",         87: "Hand Grenade",    88: "PzB 39 AT-Rifle",
     89: "Panzerfaust",     90: "PIAT",            91: "Bazooka",         92: "Panzerschreck",
     93: "Rifle-Grenade",   94: "Breda LMG",       95: "FM 24/29 LMG",   96: "Lahti LMG",
     97: "Bren LMG",        98: "DP LMG",          99: "MG-42 LMG",      100: "Hotchkiss HMG",
    101: "15mm Besa TMG",  102: "4.5in FH",       103: "8in Howitzer",   104: "155mm Gun",
    105: "75L52 Gun",      106: "50 Cal HMG",     107: "2in Mortar",     108: "Flamethrower",
    109: "4.6in Rocket",   110: "155mm FH",       111: "149mm FH",       112: "Semi-Auto Rifle",
    113: "BAR",            114: "Pistol",          115: "Maxim AAMG",     116: "49mm Mortar",
    117: "150mm Rocket",   118: "Vickers HMG",    119: "Breda HMG",      120: "30 Cal MMG",
    121: "30 Cal HMG",     122: "Maxim HMG",      123: "SGM MMG",        124: "DShK 12.7mm HMG",
    125: "MG-15 TMG",      126: "MG-37(t) TMG",   127: "Breda TMG",      128: "FM 31 TMG",
    129: "DT TMG",         130: "7.9mm Besa TMG", 131: "30 Cal TMG",     132: "MG-42 AAMG",
    133: "Lahti AT-Rifle", 134: "Boys AT-Rifle",  135: "Wz 35 AT-Rifle", 136: "PTRD AT-Rifle",
    137: "Vickers TMG",    138: "MG-34 AAMG",     139: "30 Cal AAMG",    140: "50 Cal AAMG",
    141: "DShK 12.7 AAMG", 142: "Solothurn ATR",  143: "Hotchkiss ATR",  144: "Carbine",
    145: "MG-42 TMG",      146: "Sniper Rifle",   147: "20mm Flak",      148: "Bren AAMG",
    149: "FM 24/29 AAMG",  150: "88L71/Pak43 Gun",151: "NbW92",          152: "DShK 12.7mm TMG",
    153: "Assault Rifle",
    159: "7.62mm MG",      160: "7.7mm MG",       161: "7.92mm MG",      162: "30 Cal MG",
    163: "12.7mm MG",      164: "50 Cal MG",       165: "13mm MG",        166: "20mm Cannon",
    167: "23mm Cannon",    168: "30mm Cannon",    169: "37mm Cannon",    170: "75mm Cannon",
    173: "82mm Rocket",    174: "3in Rocket",     175: "5in Rocket",
    180: "110lb Bomb",     181: "250lb Bomb",     182: "500lb Bomb",     183: "1000lb Bomb",
    187: "4.7in Naval",    188: "5in Naval",      189: "130mm Naval",    190: "5.5in Naval",
    191: "6in Naval",      192: "180mm Naval",    193: "8in Naval",      194: "12in Naval",
    195: "305mm Naval",    196: "14in Naval",     197: "15in Naval",     198: "16in Naval",
    199: "18in Naval",     200: "127mm Naval",    201: "150mm Naval",    202: "203mm Naval",
    203: "280mm Naval",
    205: "Rifle",          206: "Taisho 11 LMG",  207: "Type 96 LMG",    208: "Type 92 HMG",
    209: "Type 97 TMG",    210: "25mm Flak",      211: "37L21 Gun",      212: "37L33 Gun",
    213: "47L40 Gun",      214: "57L17 Gun",      215: "75mm FH",        216: "75L17 Gun",
    217: "Knee Mortar",    218: "75L34 Gun",      219: "70mm FH",        220: "70mm Mortar",
    221: "90mm Mortar",    222: "105mm FH",       223: "150L10 Gun",     224: "150mm FH",
    225: "Flamethrower",   226: "Satchel Charge", 227: "Hand Grenade",   228: "Type 97 ATR",
    229: "Pole Mine",      230: "Type 91 TMG",    231: "Type 99 LMG",    232: "Pistol",
    233: "13mm TMG",       234: "Type 92 AAMG",   235: "Sniper Rifle",   236: "7.7mm MG",
    237: "12.7mm MG",      238: "20mm Cannon",    239: "50kg Bomb",      240: "120kg Bomb",
    241: "250kg Bomb",     242: "5in Naval",      243: "8in Naval",      244: "14in Naval",
    245: "18in Naval",
};

function _getWeaponName(idx) {
    if (idx === 0) return "None";
    return SP1_WEAPON_NAMES[idx] || ("Wpn#" + idx);
}

// ─── Low-level helpers ───────────────────────────────────────────────────────

/**
 * Read one unsigned byte from a Uint8Array at a given offset.
 */
function _u8(bytes, offset) {
    return bytes[offset];
}

/**
 * Read one unsigned 16-bit little-endian value from a Uint8Array.
 */
function _u16le(bytes, offset) {
    return bytes[offset] | (bytes[offset + 1] << 8);
}

/**
 * Read a null-terminated ASCII string of up to maxLen bytes.
 * Commas are replaced with periods (CSV safety, mirrors original Python).
 */
function _str(bytes, offset, maxLen) {
    let end = offset;
    while (end < offset + maxLen && bytes[end] !== 0) end++;
    let s = "";
    for (let i = offset; i < end; i++) s += String.fromCharCode(bytes[i]);
    return s.replace(/,/g, ".").trim();
}

/**
 * Read a location text string that may have a leading null byte
 * (mirrors the Python split logic for BattleLocationText).
 */
function _locStr(bytes, offset, maxLen) {
    // If first byte is null, skip it and take the next run
    const start = bytes[offset] === 0 ? offset + 1 : offset;
    return _str(bytes, start, maxLen - (start - offset));
}

// ─── Formation helpers ───────────────────────────────────────────────────────

const _FORM_ALPHA = [
    "NULL","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T",
           "A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T"
];

/**
 * Build the FormationList for all 199 unit slots.
 * Each entry is a string like "A0", "B3", etc.
 */
function _buildFormationList(bytes, count) {
    const list = [];
    let counter = 0;
    let prior = "NULL";
    for (let i = 0; i < count; i++) {
        const num   = _u8(bytes, 17101 + i);
        const alpha = _FORM_ALPHA[num] || "?";
        if (alpha === prior) {
            counter++;
        } else {
            counter = 0;
        }
        prior = alpha;
        list.push(alpha + counter);
    }
    return list;
}

// ─── Public: Scenario Header ─────────────────────────────────────────────────

/**
 * Read top-level scenario metadata.
 *
 * Returns:
 * {
 *   month:        "August",
 *   year:         1941,         // 1900 + raw byte
 *   weather:      "Summer",
 *   turns:        15,
 *   battleType:   "Player 1 Assaults",
 *   visibility:   5,
 *   location:     "Balta",
 *   p1Nation:     "Germany",
 *   p2Nation:     "Soviet Union",
 * }
 */
function readSP1ScenarioData(arrayBuffer) {
    const b = new Uint8Array(arrayBuffer);

    const monthIdx   = _u8(b, 37513);
    const yearRaw    = _u8(b, 37514);
    const weatherIdx = _u8(b, 37515);
    const bf1        = _u8(b, 37522);
    const bf2        = _u8(b, 37523);
    const turns      = _u8(b, 37524);
    const p1NatIdx   = _u8(b, 37518);
    const p2NatIdx   = _u8(b, 37519);
    const visibility = _u8(b, 37612);
    const location   = _locStr(b, 37626, 19);

    return {
        month:      SP1_MONTHS[monthIdx]       || ("Month#" + monthIdx),
        year:       1900 + yearRaw,
        weather:    SP1_WEATHER[weatherIdx]    || ("Weather#" + weatherIdx),
        turns,
        battleType: SP1_BATTLE_TYPES[bf1 + "," + bf2] || ("Type#" + bf1 + "," + bf2),
        visibility,
        location,
        p1Nation:   SP1_NATIONALITIES[p1NatIdx] || ("Nation#" + p1NatIdx),
        p2Nation:   SP1_NATIONALITIES[p2NatIdx] || ("Nation#" + p2NatIdx),
    };
}

// ─── Public: Units ───────────────────────────────────────────────────────────

/**
 * Read all 199 unit slots (0–99 = P1, 100–198 = P2).
 *
 * Each returned object:
 * {
 *   index:       0,               // 0-based slot index
 *   unitNum:     1,               // 1-based (as shown in-game)
 *   side:        "P1",            // "P1" or "P2"
 *   isDead:      false,           // true when slot has no OOB type (oobNum 0) —
 *                                 // an empty/deleted slot the game never loads.
 *                                 // Filter on !isDead for a valid-only list.
 *   formation:   "A0",
 *   name:        "T-34/76",
 *   oobNum:      42,              // OOB type number within the OOB file
 *   unitClassIdx: 13,             // raw unit class index
 *   unitClass:   "Medium Tank",   // resolved from SP1_UNIT_CLASSES
 *   country:     "Germany",       // resolved from SIDE_x_CNUMBERS
 *   countryIdx:  0,               // raw country index
 *   xloc:        12,              // 255 = off-map / passenger
 *   yloc:        7,
 *   mapLocation: "12-7",          // "OFF MAP" or "Passenger on: ..."
 *   hullFacing:   "E",            // "E","SE","SW","W","NW","NE"
 *   turretFacing: "E",
 *   experience:  75,
 *   morale:      80,
 *   weapons:     ["75L48 Gun","MG-34 TMG","None","None"],
 *   ammo: {
 *     w1he, w1ap, w2he, w2ap, w3he, w3ap, w4he, w4ap,
 *     smoke, hvap, heat
 *   },
 *   hasArmor:    true,            // false for soft units (all armor values == 0)
 *   armor: {
 *     frontHull, sideHull, rearHull,
 *     frontTurret, sideTurret, rearTurret,
 *     top
 *   }
 * }
 *
 * Filter on isDead === false to get only units the game actually loaded.
 * (Liveness is keyed on oobNum, not name or formation byte: deleted slots can
 * retain a leftover name with no OOB type, and live units can sit after an
 * empty gap — both cases the older "first formation byte 0" rule got wrong.)
 */
function readSP1Units(arrayBuffer) {
    const b         = new Uint8Array(arrayBuffer);
    const SLOTS     = 199;
    const formList  = _buildFormationList(b, SLOTS);

    // A slot is a real, game-loaded unit when it references an OOB unit type
    // (oobNum != 0). SP1 unit lists are NOT null-terminated: empty or deleted
    // slots (oobNum 0, usually with a 0 formation byte) can sit BETWEEN live
    // units — e.g. transport halftracks parked after a gap — and the game still
    // loads everything that has a valid type. The earlier "stop at the first
    // formation byte == 0" rule discarded those trailing units, so we now test
    // each slot individually against its OOB number (built just below).

    // Unit names
    const names = [];
    for (let i = 0; i < SLOTS; i++) {
        names.push(_str(b, 13515 + 15 * i, 14));
    }

    // OOB unit-type number (identifies the unit type within the OOB file)
    // Side 0: SIDE_0_UNUMBERS = 0x413D = 16701, 100 entries
    // Side 1: SIDE_1_UNUMBERS = 0x41A1 = 16801, 99 entries
    const oobNums = [];
    for (let i = 0; i < 100; i++) oobNums.push(_u8(b, 16701 + i)); // P1
    for (let i = 0; i <  99; i++) oobNums.push(_u8(b, 16801 + i)); // P2

    // Unit class type (index into SP1_UNIT_CLASSES)
    // SIDE_0_UCLASS = 0x4075 = 16501, 100 entries
    // SIDE_1_UCLASS = 0x40D9 = 16601, 99 entries
    const unitClasses = [];
    for (let i = 0; i < 100; i++) unitClasses.push(_u8(b, 16501 + i)); // P1
    for (let i = 0; i <  99; i++) unitClasses.push(_u8(b, 16601 + i)); // P2

    // Country number per unit (identifies which OOB file the unit belongs to)
    // SIDE_0_CNUMBERS = 0x1F41 = 8001, SIDE_1_CNUMBERS = 0x203B = 8251
    // Only first 50 per side are individually stored; 50+ share unit[0]'s country.
    const countryNums = [];
    for (let i = 0; i < 100; i++)
        countryNums.push(i < 50 ? _u8(b, 8001 + i) : _u8(b, 8001));
    for (let i = 0; i <  99; i++)
        countryNums.push(i < 50 ? _u8(b, 8251 + i) : _u8(b, 8251));

    // Load list (passenger carrier index, 1-based; 0 = not a passenger)
    // SIDE_0_TRANSPORT = 0x6DC4 = 28100, loop uses [28100+i] for i=1..100
    const loadList = [];
    for (let i = 0; i < SLOTS; i++) {
        loadList.push(_u8(b, 28101 + i));
    }

    // 19-byte position record layout (UNIT_POSITION_SIZE = 0x13 = 19):
    //   [+0x00] XLOC         255 = off-map or passenger
    //   [+0x02] YLOC
    //   [+0x0A] hull facing   0=E 1=SE 2=SW 3=W 4=NW 5=NE (clockwise from East)
    //   [+0x0C] turret facing same encoding
    //   [+0x0E] U1 (always 1)
    //   [+0x0F] SIDE (0 or 1)
    //   [+0x12] U2 (always 1)
    //
    // SIDE_0_POSITION = 0x47B2A = 293674  (unit[0] at base+0, unit[1] at base+19, ...)
    // SIDE_1_POSITION = 0x48296 = 295574
    const xlocs         = [];
    const ylocs         = [];
    const hullFacings   = [];
    const turretFacings = [];
    for (let i = 0; i < 100; i++) {         // P1 units 0-99
        const base19 = 293674 + 19 * i;
        xlocs.push(_u8(b, base19 + 0));
        ylocs.push(_u8(b, base19 + 2));
        hullFacings.push(_u8(b, base19 + 10));
        turretFacings.push(_u8(b, base19 + 12));
    }
    for (let i = 0; i < 99; i++) {          // P2 units 0-98 (→ slots 100-198)
        const base19 = 295574 + 19 * i;
        xlocs.push(_u8(b, base19 + 0));
        ylocs.push(_u8(b, base19 + 2));
        hullFacings.push(_u8(b, base19 + 10));
        turretFacings.push(_u8(b, base19 + 12));
    }

    const units = [];
    for (let i = 0; i < SLOTS; i++) {
        const side = i <= 99 ? "P1" : "P2";
        const x    = xlocs[i];
        const y    = ylocs[i];
        const load = loadList[i];

        // Resolve map location / passenger string
        let mapLocation;
        if (x === 255 && load >= 1) {
            const carrierIdx = load - 1;
            mapLocation = "Passenger on: " + formList[carrierIdx] + ": " +
                          names[carrierIdx] + " at " +
                          xlocs[carrierIdx] + "-" + ylocs[carrierIdx];
        } else if (x === 255 && names[i] !== "") {
            mapLocation = "OFF MAP";
        } else {
            mapLocation = x + "-" + y;
        }

        // Weapons
        const weapons = [
            _getWeaponName(_u8(b, 18104 + 4 * i)),
            _getWeaponName(_u8(b, 18105 + 4 * i)),
            _getWeaponName(_u8(b, 18106 + 4 * i)),
            _getWeaponName(_u8(b, 18107 + 4 * i)),
        ];

        // Ammo
        const base8 = 18908 + 8 * i;
        const ammo = {
            w1he:  _u8(b, base8 + 0),
            w1ap:  _u8(b, base8 + 1),
            w2he:  _u8(b, base8 + 2),
            w2ap:  _u8(b, base8 + 3),
            w3he:  _u8(b, base8 + 4),
            w3ap:  _u8(b, base8 + 5),
            w4he:  _u8(b, base8 + 6),
            w4ap:  _u8(b, base8 + 7),
            smoke: _u8(b, 30501 + i),
            hvap:  _u8(b, 30701 + i),
            heat:  _u8(b, 30901 + i),
        };

        // Armor values — SP1_ARMOR_BASE = 0x5022 = 20514
        // 14-byte records: [FH, SH, RH, FT, ST, RT, TOP, 0,0,0,0,0,0,0]
        // Soft units (infantry, guns, etc.) have all zeros.
        const armorBase = 20514 + 14 * i;
        const armor = {
            frontHull:   _u8(b, armorBase + 0),
            sideHull:    _u8(b, armorBase + 1),
            rearHull:    _u8(b, armorBase + 2),
            frontTurret: _u8(b, armorBase + 3),
            sideTurret:  _u8(b, armorBase + 4),
            rearTurret:  _u8(b, armorBase + 5),
            top:         _u8(b, armorBase + 6),
        };
        const hasArmor = armor.frontHull > 0;

        // isDead: true when this slot has no OOB unit type (oobNum 0) — an
        // empty or deleted slot the game never instantiates. Real units always
        // reference a type, so this correctly keeps units that sit after a gap.
        const isDead = oobNums[i] === 0;

        units.push({
            index:        i,
            unitNum:      i + 1,
            side,
            isDead,
            formation:    formList[i],
            name:         names[i],
            oobNum:       oobNums[i],
            unitClassIdx: unitClasses[i],
            unitClass:    SP1_UNIT_CLASSES[unitClasses[i]] || ("Class#" + unitClasses[i]),
            country:      SP1_NATIONALITIES[countryNums[i]] || ("Nation#" + countryNums[i]),
            countryIdx:   countryNums[i],
            xloc:         x,
            yloc:         y,
            mapLocation,
            hullFacing:   SP1_FACINGS[hullFacings[i]] || ("Facing#" + hullFacings[i]),
            turretFacing: SP1_FACINGS[turretFacings[i]] || ("Facing#" + turretFacings[i]),
            // Exp/morale: side-split arrays
            // SIDE_0_EXP=26501 (100 entries), SIDE_1_EXP=26601 (99 entries)
            // SIDE_0_MORALE=26701, SIDE_1_MORALE=26801
            experience:   i < 100 ? _u8(b, 26501 + i) : _u8(b, 26601 + (i - 100)),
            morale:       i < 100 ? _u8(b, 26701 + i) : _u8(b, 26801 + (i - 100)),
            weapons,
            ammo,
            armor,
            hasArmor,
        });
    }

    return units;
}

// ─── Public: Leaders ─────────────────────────────────────────────────────────

/**
 * Read all 399 leader slots.
 * Slots 0–99 = P1 leaders, slots 250–398 = P2 leaders.
 * Slots 100–249 are structurally present but unused by the game — included raw.
 *
 * Each returned object:
 * {
 *   index:     0,
 *   side:      "P1",        // "P1", "P2", or "—"
 *   name:      "Rommel",
 *   rank:      "General",
 *   unitId:    5,           // 1-based index of commanded unit (198 = null/unassigned)
 *   unitDesc:  "A0: PzKpfw III",
 *   rally:     4,
 *   infantry:  2,
 *   artillery: 1,
 *   armor:     5,
 * }
 */
function readSP1Leaders(arrayBuffer) {
    const b      = new Uint8Array(arrayBuffer);
    const SLOTS  = 399;

    // We need unit names and formation list to resolve unitDesc
    const unitNames = [];
    for (let i = 0; i < 199; i++) {
        unitNames.push(_str(b, 13515 + 15 * i, 14));
    }
    const formList = _buildFormationList(b, 199);

    // Track used unit IDs per side for formation-leader detection (mirrors Python logic)
    const usedP1 = new Set();
    const usedP2 = new Set();

    const leaders = [];

    for (let i = 0; i < SLOTS; i++) {
        // SP1 leader layout (all offsets absolute within the SP1 file):
        //   SP1_LEADER_NAMES  = 0x0000 = 0     (15 bytes/leader, 500 max)
        //   SP1_LEADER_RANK   = 0x2134 = 8500  (1 byte/leader)
        //   SP1_LEADER_RALLY  = 0x2328 = 9000  (1 byte/leader)
        //   SP1_LEADER_SKILL  = 0x251C = 9500  (3 bytes/leader: inf, arty, arm)
        //
        // Side split: P1 = leaders 0–249, P2 = leaders 250–499
        // (C source comment says "side 2 beginning at 251" but code uses 250)

        let side   = "—";
        let unitId = 198; // null default

        if (i <= 99) {
            side   = "P1";
            unitId = _u8(b, 7501 + i);
            if (unitId === 0) unitId = 198;
        } else if (i >= 250) {
            side   = "P2";
            unitId = _u8(b, 7751 + (i - 250));
        }

        // Resolve unit description
        const uidIdx = unitId - 1; // convert 1-based to 0-based
        let unitDesc = "";
        if (uidIdx >= 0 && uidIdx < 199) {
            const raw = formList[uidIdx] + ": " + unitNames[uidIdx];
            const usedSet = side === "P1" ? usedP1 : usedP2;
            if (usedSet.has(raw)) {
                unitDesc = raw[0] + " Formation Leader";
            } else {
                unitDesc = raw;
                usedSet.add(raw);
            }
        }

        const rankIdx = _u8(b, 8500 + i);   // SP1_LEADER_RANK  = 0x2134

        leaders.push({
            index:     i,
            side,
            name:      _str(b, 0 + 15 * i, 14),  // SP1_LEADER_NAMES = 0x0000
            rank:      SP1_LEADER_RANKS[rankIdx] || ("Rank#" + rankIdx),
            unitId,
            unitDesc,
            rally:     _u8(b, 9000 + i),           // SP1_LEADER_RALLY = 0x2328
            infantry:  _u8(b, 9500 + 3 * i),       // SP1_LEADER_SKILL = 0x251C
            artillery: _u8(b, 9501 + 3 * i),
            armor:     _u8(b, 9502 + 3 * i),
        });
    }

    return leaders;
}

// ─── Public: Victory Locations ───────────────────────────────────────────────

/**
 * Read all 21 victory-hex (VLOC) slots.
 *
 * Each returned object:
 * {
 *   index:   0,
 *   xloc:    14,
 *   yloc:    7,
 *   value:   3,
 *   control: 1,   // 0 = P1, 1 = P2, 2 = Neutral
 *   controlLabel: 'P2',  // resolved string
 *   isEmpty: false,      // true when xloc === 255 or 0
 * }
 */
function readSP1VLocs(arrayBuffer) {
    const b     = new Uint8Array(arrayBuffer);
    const SLOTS = 21;
    const CTRL_LABEL = ['P1', 'P2', 'Neutral'];
    const vlocs = [];

    for (let i = 0; i < SLOTS; i++) {
        const xloc    = _u8(b, 37526 + i);
        const yloc    = _u8(b, 37547 + i);
        const value   = _u8(b, 37568 + i);
        const control = _u8(b, 37589 + i);
        const isEmpty = (xloc === 255 || xloc === 0);
        vlocs.push({
            index: i, xloc, yloc, value, control,
            controlLabel: isEmpty ? '' : (CTRL_LABEL[control] || 'Unknown'),
            isEmpty,
        });
    }

    return vlocs;
}

// ─── Public: Bombardment Missions ────────────────────────────────────────────

/**
 * Read all 199 bombardment mission slots.
 * Only slots where missionType !== "No Mission" have active missions.
 *
 * Each returned object:
 * {
 *   index:        0,
 *   unitNum:      1,
 *   unitDesc:     "A0: Stuka",
 *   missionType:  "HE Bombardment Mission",   // or "No Mission"
 *   xloc:         10,
 *   yloc:         5,
 *   spotterIdx:   3,    // 0-based unit index of spotter (0 = none)
 *   spotterDesc:  "B1: PzGren",
 * }
 */
function readSP1Bombardment(arrayBuffer) {
    const b      = new Uint8Array(arrayBuffer);
    const SLOTS  = 199;

    const unitNames = [];
    for (let i = 0; i < SLOTS; i++) {
        unitNames.push(_str(b, 13515 + 15 * i, 14));
    }
    const formList = _buildFormationList(b, SLOTS);

    const missions = [];

    for (let i = 0; i < SLOTS; i++) {
        // Match readSP1Units: a slot is live when it references an OOB type.
        const oob        = i < 100 ? _u8(b, 16701 + i) : _u8(b, 16801 + (i - 100));
        const isDead     = oob === 0;
        const typeRaw    = _u8(b, 27301 + i);
        const spotterIdx = _u8(b, 30101 + i);

        missions.push({
            index:       i,
            isDead,
            unitNum:     i + 1,
            unitDesc:    formList[i] + ": " + unitNames[i],
            missionType: SP1_BOMB_TYPES[typeRaw] || ("BombType#" + typeRaw),
            xloc:        _u8(b, 29501 + i),
            yloc:        _u8(b, 29701 + i),
            spotterIdx,
            spotterDesc: formList[spotterIdx] + ": " + unitNames[spotterIdx],
        });
    }

    return missions;
}

// ─── Public: Obstacles (Mines / Dragon's Teeth) ─────────────────────────────

/**
 * Read all obstacle records (mines and dragon's teeth).
 *
 * 19-byte record layout at base 0x49854 (301236):
 *   +0   u16 LE   xloc           hex column (0xFFFF = empty)
 *   +2   u16 LE   yloc           hex row    (0xFFFF = empty)
 *   +4   u16      padding        always 0xFFFF
 *   +6   u16 LE   tileID         graphic / sprite ID
 *   +8   u16      padding        always 0xFFFF
 *  +10   u16 LE   activeFlag     5 = placed obstacle
 *  +12   u16 LE   strength       count × 10 (0xFFFF = shadow/no strength)
 *  +14   u8       type           0 = Dragon's Teeth, 1 = Mines
 *  +15   u8       side           0 = P1, 1 = P2
 *  +16   u8       zero           always 0
 *  +17   u16      padding        0xFFFF
 *
 * Returns array of obstacle objects (only records with valid x,y and strength).
 * Each object:
 * {
 *   index:    498,               // record index within the array
 *   xloc:     25,
 *   yloc:     7,
 *   tileID:   1424,
 *   strength: 20,                // raw value (count × 10)
 *   count:    2,                 // strength / 10
 *   type:     0,                 // 0 = Dragon's Teeth, 1 = Mines
 *   typeName: "Dragon's Teeth",
 *   side:     0,                 // 0 = P1, 1 = P2
 *   sideLabel: "P1",
 * }
 */
function readSP1Obstacles(arrayBuffer) {
    const b = new Uint8Array(arrayBuffer);
    const obstacles = [];
    const SIDE_LABELS = ['P1', 'P2'];

    for (let i = 0; i < SP1_OBSTACLE_SLOTS; i++) {
        const off = SP1_OBSTACLE_BASE + i * SP1_OBSTACLE_RECSIZE;
        if (off + SP1_OBSTACLE_RECSIZE > b.length) break;

        const xloc     = _u16le(b, off + 0);
        const yloc     = _u16le(b, off + 2);
        const strength = _u16le(b, off + 12);

        // Skip empty records: x,y both 0xFFFF or strength = 0xFFFF
        if (xloc === 0xFFFF && yloc === 0xFFFF) continue;
        if (strength === 0xFFFF) continue;
        // Skip map-inherent features (strength 0, typically side=2)
        if (strength === 0) continue;

        const tileID = _u16le(b, off + 6);
        const type   = _u8(b, off + 14);
        const side   = _u8(b, off + 15);

        obstacles.push({
            index:     i,
            xloc,
            yloc,
            tileID,
            strength,
            count:     Math.floor(strength / 10),
            type,
            typeName:  SP1_OBSTACLE_TYPES[type] || ('ObstType#' + type),
            side,
            sideLabel: SIDE_LABELS[side] || ('Side#' + side),
        });
    }

    return obstacles;
}

// ─── Public: Entrenchments (Foxholes / Sandbag Emplacements) ────────────────

/**
 * Read all entrenchment records (foxholes and sandbag emplacements).
 *
 * Entrenchments are stored as paired 19-byte records in the obstacle array:
 *   Block 2 (recs 200-299): P1 position markers (tileID ~6103)
 *   Block 3 (recs 300-399): P2 position markers (tileID ~6100)
 *   Block 6 (recs 600-699): P1 graphic records (tileID identifies type)
 *   Block 7 (recs 700-799): P2 graphic records (tileID identifies type)
 *
 * For each side, the position block and graphic block share the same slot
 * index (e.g. Block 2 slot 5 pairs with Block 6 slot 5).
 *
 * Returns array of entrenchment objects:
 * {
 *   index:       5,                  // slot within the per-side block (2-99)
 *   xloc:        4,
 *   yloc:        3,
 *   positionTileID: 6103,            // from position block (Block 2/3)
 *   graphicTileID:  1465,            // from graphic block (Block 6/7)
 *   typeName:    "Foxhole (NE)",     // resolved from graphicTileID
 *   side:        0,                  // 0 = P1, 1 = P2
 *   sideLabel:   "P1",
 * }
 */
function readSP1Entrenchments(arrayBuffer) {
    const b = new Uint8Array(arrayBuffer);
    const entrenchments = [];
    const SIDE_LABELS = ['P1', 'P2'];

    // Process each side: P1 = blocks 2+6, P2 = blocks 3+7
    const sides = [
        { side: 0, posBlock: 200, gfxBlock: 600 },
        { side: 1, posBlock: 300, gfxBlock: 700 },
    ];

    sides.forEach(({ side, posBlock, gfxBlock }) => {
        for (let slot = 0; slot < 100; slot++) {
            const posOff = SP1_OBSTACLE_BASE + (posBlock + slot) * SP1_OBSTACLE_RECSIZE;
            const gfxOff = SP1_OBSTACLE_BASE + (gfxBlock + slot) * SP1_OBSTACLE_RECSIZE;
            if (posOff + SP1_OBSTACLE_RECSIZE > b.length) break;
            if (gfxOff + SP1_OBSTACLE_RECSIZE > b.length) break;

            // Position record must have valid x,y
            const xloc = _u16le(b, posOff + 0);
            const yloc = _u16le(b, posOff + 2);
            if (xloc === 0xFFFF && yloc === 0xFFFF) continue;

            const positionTileID = _u16le(b, posOff + 6);

            // Graphic record at matching slot — read tileID for type identification
            const gfxX = _u16le(b, gfxOff + 0);
            const gfxY = _u16le(b, gfxOff + 2);
            const graphicTileID = _u16le(b, gfxOff + 6);

            // Validate: graphic record should reference the same hex
            // (it might be 0xFFFF if no graphic block was written — skip)
            if (gfxX === 0xFFFF && gfxY === 0xFFFF) continue;

            const typeName = SP1_ENTRENCH_TILES[graphicTileID]
                          || ('Emplacement (tile ' + graphicTileID + ')');

            entrenchments.push({
                index: slot,
                xloc,
                yloc,
                positionTileID,
                graphicTileID,
                typeName,
                side,
                sideLabel: SIDE_LABELS[side] || ('Side#' + side),
            });
        }
    });

    return entrenchments;
}

// ─── Public: Read All ────────────────────────────────────────────────────────

/**
 * Convenience wrapper — reads everything in one call.
 *
 * Returns:
 * {
 *   scenario:    { ... },      // readSP1ScenarioData()
 *   units:       [ ... ],      // readSP1Units()        199 entries
 *   leaders:     [ ... ],      // readSP1Leaders()      399 entries
 *   vlocs:       [ ... ],      // readSP1VLocs()         21 entries
 *   bombardment: [ ... ],      // readSP1Bombardment()  199 entries
 *   obstacles:   [ ... ],      // readSP1Obstacles()    variable count
 *   entrenchments: [ ... ],   // readSP1Entrenchments() variable count
 * }
 */
function readSP1All(arrayBuffer) {
    return {
        scenario:       readSP1ScenarioData(arrayBuffer),
        units:          readSP1Units(arrayBuffer),
        leaders:        readSP1Leaders(arrayBuffer),
        vlocs:          readSP1VLocs(arrayBuffer),
        bombardment:    readSP1Bombardment(arrayBuffer),
        obstacles:      readSP1Obstacles(arrayBuffer),
        entrenchments:  readSP1Entrenchments(arrayBuffer),
    };
}

// ─── Node/test export ────────────────────────────────────────────────────────

if (typeof module !== "undefined" && module.exports) {
    module.exports = {
        readSP1ScenarioData,
        readSP1Units,
        readSP1Leaders,
        readSP1VLocs,
        readSP1Bombardment,
        readSP1Obstacles,
        readSP1Entrenchments,
        readSP1All,
        // Lookup tables exported for testing / display use
        SP1_MONTHS,
        SP1_WEATHER,
        SP1_NATIONALITIES,
        SP1_BATTLE_TYPES,
        SP1_LEADER_RANKS,
        SP1_BOMB_TYPES,
        SP1_OBSTACLE_TYPES,
        SP1_ENTRENCH_TILES,
        SP1_FACINGS,
        SP1_UNIT_CLASSES,
        SP1_WEAPON_NAMES,   // sparse object, keyed by weapon ID
    };
}
