Savegame system

August 17, 2026 · View on GitHub

Savegame files (.lba) store game state: scene, position, inventory, holomap, screenshot, and more. This doc covers the engine lifecycle, save/load pipelines, binary layout, and version / compatibility behaviour. Layout is verified against SaveContexte / LoadContexte in SOURCES/SAVEGAME.CPP.

Truth hierarchy: code > this document > external sources.

File paths

NameConstantPurpose
current.lbaCURRENTSAVE_FILENAMEQuick-save; enables Resume in main menu
autosave.lbaAUTOSAVE_FILENAMEAuto-save during gameplay
*.lbaUser-chosenNamed saves (player name → filename)

Path resolution: GetSavePath(outPath, pathMaxSize, saveFilename)<userDir>/save/ + filename (SOURCES/DIRECTORIES.CPP).

Bug saves (save/bugs/)

Purpose: Repro / QA snapshots kept separate from player slots. Same .lba binary format and SaveGame() / LoadGame() machinery as normal saves (SOURCES/SAVEGAME.CPP); only path, who triggers the write, and default compression differ.

TopicRegular savesBug saves
DirectoryGetSavePath(..., "<file>.lba")<userDir>/save/ (root) for named slots; current.lba / autosave.lba for quick/autoGetBugPath(..., "<file>.lba")<userDir>/save/bugs/ (SOURCES/DIRECTORIES.CPP GetBugPath)
CompressionAutoSaveGame / CurrentSaveGame: NumVersion = NUM_VERSION only (uncompressed). CompressSave in lba2.cfg: merged at startup into NumVersion (SOURCES/PERSO.CPP ReadConfigFile). Menu “Save Game” (slot): sets the compression high bit on NumVersion before SaveGame (SOURCES/GAMEMENU.CPP).DEBUG menu bug save and console savebug: set NumVersion to NUM_VERSION with SAVE_COMPRESS before SaveGame (SOURCES/GAMEMENU.CPP case 2001; SOURCES/CONSOLE/CONSOLE_CMD.CPP cmd_savebug).
Entry pointsMain menu save/load, autosave, argv/console loadDEBUG_TOOLS: in-game G / menu load case 2000 / menu save case 2001 (SOURCES/GAMEMENU.CPP). Always-on console: savebug, loadbug, listbugs (CONSOLE.md).

Console savebug: requires an in-game loaded scene (same gate as give); loadbug only checks that bugs/<name>.lba exists. See CONSOLE.md.

Lifecycle

Save

TriggerFunctionNotes
Menu SaveSaveGame(TRUE)User-initiated
Quick-save (MENUS)CurrentSaveGame()Saves to current.lba; player name set to "CURRENT"
Auto-saveAutoSaveGame()Saves to autosave.lba at scene transitions
Bug saveSaveGame(TRUE)save/bugs/ via DEBUG menu (DEBUG_TOOLS), in-game G, or console savebug

Code: SOURCES/SAVEGAME.CPPSaveGame, CurrentSaveGame, AutoSaveGame

Load

TriggerFunctionNotes
Menu Load / ResumeLoadGame()Full load; sets NewCube, PlayerName, restores state
Load screenshot onlyLoadGameScreen()Reads header + 160×120 image; used for menu preview
Load player name onlyLoadGamePlayerName()Reads header + player name; used when listing saves
Cube index only (lightweight)LoadGameNumCube()Used when FlagLoadGame is set before a cube transition (e.g. startup argv save, console load / loadbug) — reads header, optional decompress, skips screenshot, restores ListVarGame only

Code: SOURCES/SAVEGAME.CPPLoadGame, LoadGameNumCube, LoadGameScreen, LoadGamePlayerName. Full game load from disk is wired through FlagLoadGame in SOURCES/OBJECT.CPP ChangeCube (see Version compatibility).

Save pipeline (SaveGame)

Order in SOURCES/SAVEGAME.CPP (retail path):

  1. Build stream at PtrSave (from BufSpeak + 50000 in the non-editor build).
  2. Write version byte (NUM_VERSION in low 7 bits; high bit set if CompressSave requests compression — see Version byte on disk), cube (NumCube), null-terminated PlayerName.
  3. If compressing: reserve 4 bytes for plaintext size (sizefile), remember memoptr.
  4. Write 160×120 screenshot bytes, then SaveContexte(savetimerrefhr) (full game state).
  5. Append ValidePos / LastValidePos; if !ValidePos, append ValideCube, SizeOfBufferValidePos, and BufferValidePos.
  6. If compressing: LZSS-compress the block from memoptr onward, patch sizefile, write file with Save().

Constants: NUM_VERSION, SAVE_COMPRESS, MASK_NUM_VERSION in SOURCES/COMMON.H.

Full load pipeline (LoadGame)

  1. LoadSize — reads into Screen up to 640×480+RECOVER_AREA640 \times 480 + \text{RECOVER\_AREA} (see SOURCES/MEM.CPP); larger files are rejected.
  2. Read NumVersion, NewCube, PlayerName (NUL-terminated in the file; reader is bounded by MAX_SIZE_PLAYER_NAME).
  3. If NumVersion & SAVE_COMPRESS: read sizefile, validate staging, copy compressed tail, ExpandLZ(..., mode 2) (matches Compress_LZSS).
  4. PtrSave += 160 * 120 — skip screenshot so the next reads align with game context offsets in this doc.
  5. LoadContexte(&savetimerrefhr) — restores globals through camera / buggy / ardoise / VueCamera; returns flaginit (0/1) or -1 on stream/bounds failure (see Version compatibility).
  6. Read valid-position tail: ValidePos / LastValidePos, optional BufferValidePos blob.
  7. Restart music, palette, CameraCenter(0), restore timer from save, SaveTimer(). LoadGame returns flaginit to ChangeCube, which stores it in flagload.

Partial loaders (LoadGameScreen, LoadGamePlayerName, LoadGameNumCube) use the same header (+ optional decompress where applicable) but only consume a prefix of the stream.

File format – header and screenshot

Header (before decompression):

OffsetSizeContent
01Version byte: 0x24 (uncompressed) or 0xA4 (compressed). High bit = SAVE_COMPRESS (0x80); low 7 bits = NUM_VERSION (36).
14Scene/cube index (NumCube or NewCube)
5varPlayer name (null-terminated string)
4(if compressed) Decompressed size
var(if compressed) LZSS-compressed payload

After decompression (or if uncompressed):

OffsetSizeContent
019200160×120 screenshot (raw 8-bit indexed, game palette)
19200varGame context (see below)

Compression: Compress_LZSS / ExpandLZ (mode 2). The payload (screenshot + game context) is compressed as one block; the header (version, cube, name) is always uncompressed.

Version byte on disk

The first byte combines two ideas (macros in SOURCES/COMMON.H):

ConstantValueRole
NUM_VERSION36Low 7 bits: layout revision of the serialized game context (branches in LoadContexte / SaveContexte).
SAVE_COMPRESS0x80High bit: screenshot + context stored LZSS-compressed after the header.
MASK_NUM_VERSION~(SAVE_COMPRESS)Strip compression when comparing layout (e.g. (NumVersion & MASK_NUM_VERSION) != NUM_VERSION in OBJECT.CPP).

Typical values: 0x24 = uncompressed layout 36; 0xA4 = compressed (0x80 | 36).

Not an ABI tag: the same first byte can appear on original 32-bit retail saves and 64-bit community builds. It does not identify pointer width or compiler. Pointer-sized blobs in the stream still differ by platform (see Version compatibility).

File format – game context (after screenshot)

Layout as written by SaveContexte() (SAVEGAME.CPP ~706). All offsets below are relative to the start of the game context (byte 0 = first byte after the 19200-byte screenshot).

Game vars and cube vars

OffsetSizeContentCode
0512ListVarGame[0..255] (S16 each)256 × 2 bytes
51280ListVarCube[0..79] (U8 each)80 × 1 byte

Globals

OffsetSizeContentCode
5921Comportement (behavior mode)LbaWriteByte
5934(NbZlitosPieces<<16)+(NbGoldPieces&0xFFFF)LbaWriteLong
5971MagicLevel (0–4)LbaWriteByte
5981MagicPoint (0–80)LbaWriteByte
5991NbLittleKeysLbaWriteByte
6002NbCloverBoxLbaWriteWord
6024SceneStartXLbaWriteLong
6064SceneStartYLbaWriteLong
6104SceneStartZLbaWriteLong
6144StartXCubeLbaWriteLong
6184StartYCubeLbaWriteLong
6224StartZCubeLbaWriteLong
6261WeaponLbaWriteByte
6274savetimerrefhr (game timestamp)LbaWriteLong
6311NumObjFollow (actor ID, usually Twinsen)LbaWriteByte
6321SaveComportementHeroLbaWriteByte
6331SaveBodyHeroLbaWriteByte

Holomap (TabArrow)

OffsetSizeContentCode
634305TabArrow[0..304].FlagHolo (U8 each)MAX_OBJECTIF+MAX_CUBE = 50+255 (HOLO.H)

FlagHolo bitfield: ........ (bit 0 = activation, bit 1 = asked about, bit 2 = inside/outside for exterior scenes). Only low 2 bits are persisted; LoadContexte masks with & 3.

Inventory (TabInv)

OffsetSizeContentCode
939400TabInv[0..39] – 40 items × 10 bytes eachLbaWrite per item

Per item (10 bytes): PtMagie (s32), FlagInv (s32), IdObj3D (s16). PtMagie = magic points or value; FlagInv = flags; IdObj3D = 3D model variant.

Checksum and extended payload

OffsetSizeContent
13394Checksum – engine validates; mismatch can trigger fallback position
134347Input / magic / movement block (LastMyFirePingouinActif) — see LoadContexte
13904PtrZoneClimb stored as U32 (load casts to T_ZONE *)
139484ListDart[0..2]3 darts × S32 each (MAX_DARTS, SAVEGAME.CPP)
14784NbObjets (S32) — count for the following per-object records

| 1482 | var | Object array: NbObjets × (fixed T_OBJET prefix + T_OBJ_3D without CurrentFrame) — size depends on 32 vs 64-bit ABI |

The full SaveContexte / LoadContexte continues with patches, extras, zones, incrust, flows, camera, and more. See SAVEGAME.CPP lines 706–1076 (save) and 1080–1548 (load).

ListVarGame – inventory and quest flags

ListVarGame holds 256 S16 values. Indices 0–39 map to inventory flags; others are quest/scenario state. Values: 0 = don't have; 1 = have (flag); 1–65535 = quantity for stackable items.

IndexConstantItem
0FLAG_HOLOMAPHolomap
1FLAG_BALLE_MAGIQUEMagic ball
2FLAG_DARTDarts
3FLAG_BOULE_SENDELLSendell's ball
4FLAG_TUNIQUETunic and Sendell's medallion
5FLAG_PERLEIncandescent pearl
6FLAG_CLEF_PYRAMIDPyramid shaped key
7FLAG_VOLANTPart for the car
8FLAG_MONEYKashes (TwinSun) or Zlitos (Zeelich)
9FLAG_PISTOLASERPisto-Laser
10FLAG_SABREEmperor's sword
11FLAG_GANTWannie's glove
12FLAG_PROTOPACKProto-Pack
13FLAG_TICKET_FERRYFerry Ticket
14FLAG_MECA_PINGOUINNitro-Meca-Penguin
15FLAG_GAZOGEMCan of GazoGem
16FLAG_DEMI_MEDAILLONDissidents' Ring
17FLAG_ACIDE_GALLIQUEGallic acid
18FLAG_CHANSONFerryman's song
19FLAG_ANNEAU_FOUDRERing of lightning
20FLAG_PARAPLUIECustomer's umbrella
21FLAG_GEMMEGems
22FLAG_CONQUEHorn of the Blue Triton
23FLAG_SARBACANEBlowgun
24FLAG_DISQUE_ROUTE / FLAG_VISIONNEUSEItinerary token / Visionneuse
25FLAG_TART_LUCISlice of tart
26FLAG_RADIOPortable radio
27FLAG_FLEURGarden Balsam
28FLAG_ARDOISEMagic slate
29FLAG_TRADUCTEURTranslator
30FLAG_DIPLOMEWizard's diploma
31FLAG_DMKEY_KNARTAFragment of the Francos
32FLAG_DMKEY_SUPFragment of the Sups
33FLAG_DMKEY_MOSQUIFragment of the Mosquibees
34FLAG_DMKEY_BLAFARDFragment of the Wannies
35FLAG_CLE_REINEKey for the passage to CX
36FLAG_PIOCHEPick-ax
37FLAG_CLEF_BOURGMESTREFranco Burgermaster's key
38FLAG_NOTE_BOURGMESTREFranco Burgermaster's notes
39FLAG_PROTECTIONProtective spell
40FLAG_SCAPHANDRE(Space suit – scenario var)
79FLAG_CELEBRATIONCelebration crystal
94FLAG_DINO_VOYAGEDino-Fly travel
251FLAG_CLOVERClover leaves
252FLAG_VEHICULE_PRISVehicle taken
253FLAG_CHAPTERChapter
254FLAG_PLANETE_ESMERPlanet Esmer

Code: SOURCES/COMMON.H (FLAG_*), SOURCES/GLOBAL.CPP (ListVarGame).

Comportement (behavior mode)

ValueConstantMeaning
0C_NORMALNormal
1C_SPORTIFAthletic
2C_AGRESSIFAggressive
3C_DISCRETStealth
4C_PROTOPACKProtopack
5C_DOUBLETwinsen and Zoé
6C_CONQUEHorn of the Blue Triton
7C_SCAPH_INT_NORMDiving suit interior (normal)
8C_JETPACKSuper jetpack
9C_SCAPH_INT_SPORDiving suit interior (athletic)
10C_SCAPH_EXT_NORMDiving suit exterior (normal)
11C_SCAPH_EXT_SPORDiving suit exterior (athletic)
12C_BUGGYBuggy
13C_SKELETONSkeleton disguise

Code: SOURCES/COMMON.H (C_* constants).

Screenshot

Generated at save time in SaveGame():

  1. ScaleBox(0, 0, 639, 479, Log, 0, 0, 159, 119, BufSpeak+50000L) – downsample 640×480 frame to 160×120
  2. SaveBlock – copy to temp buffer
  3. RemapPicture – remap palette to game palette
  4. LbaWrite – write 19200 bytes to file

Shown at load time: LoadGameScreen() returns pointer to image data; DrawScreenSave() (GAMEMENU.CPP) draws it. Used for main menu preview, save slot thumbnails, and delete confirmation. See MENU.md.

Config integration

LastSave in lba2.cfg stores the last-used player name for quick load. CompressSave (0/1) controls compression. See CONFIG.md.

Version compatibility

The layout revision is the low 7 bits of the first byte (NUM_VERSION, currently 36). Branches in LoadContexte / SaveContexte change what is read and written after the fixed prefix (through checksum). The compression bit is independent (see Version byte on disk).

Version-specific layout (LoadContexte)

VersionChange
< 34Extra input fields (LastStepFalling, LastStepShifting); objects use TempoRealAngle instead of BoundAngle. Only compiled in DEBUG_TOOLS, TEST_TOOLS, or EDITLBA2 — not in a default release player build.
≥ 35Per-object SampleAlways (S32) added.
≥ 36Per-object SampleVolume (U8) added.

Code: SOURCES/SAVEGAME.CPPLoadContexte / SaveContexte (object loop ~1216–1356).

Checksum, SceneStartX, and flaginit

After reading the stored Checksum, the engine compares it to the runtime Checksum (scenario-dependent).

  • Mismatch: SceneStartX is forced to -1. A warning may be shown in DEBUG_TOOLS / TEST_TOOLS. The loader then follows the if (SceneStartX == -1) branch in LoadContexte and skips restoring the large “extended” blob (objects, patches, flows, camera, …) — see the if (SceneStartX == -1) vs else structure in SOURCES/SAVEGAME.CPP.
  • LoadContexte return value (flaginit inside LoadGame): TRUE when the checksum / SceneStartX == -1 short path ran (hero and globals repaired without consuming the extended blob), FALSE after a normal full restore of objects, patches, flows, etc.

LoadGame returns that value into flagload in ChangeCube (SOURCES/OBJECT.CPP):

if (flagload < 0 || !flagload) InitLoadedGame(); else LoadFile3dObjects();

So flagload == 0 (falsy → InitLoadedGame()) is the usual full load path; flagload < 0 means LoadContexte hit a stream truncation / bounds error (issue #62) and also falls back to InitLoadedGame(); non-zero positive flagload skips InitLoadedGame and uses LoadFile3dObjects() only (extended state was already reconciled inside LoadContexte). LoadGameOldVersion returns TRUE (non-zero), so it always takes the LoadFile3dObjects() branch after the minimal restore.

Version mismatch and LoadGameOldVersion

  • Release build: When loading a save, LoadGame()LoadContexte is always used. If the save’s layout revision NUM_VERSION, the byte stream misaligns — expect corruption or crashes; there is no automatic fallback.
  • DEBUG_TOOLS or TEST_TOOLS: In ChangeCube, if (NumVersion & MASK_NUM_VERSION) != NUM_VERSION, the engine shows the French warning (Sauvegarde trop ancienne…) and calls LoadGameOldVersion() instead of LoadGame().

LoadGameOldVersion (SOURCES/SAVEGAME.CPP): same header + optional decompress + skip screenshot, then restores only the early context (ListVarGame, ListVarCube, comportement, money/magic/keys, start positions, holomap, inventory). It does not restore darts, objects, patches, extras, zones, incrust, flows, camera, etc. Returns TRUE.

Call site: SOURCES/OBJECT.CPPChangeCube, if (FlagLoadGame) block (search for LoadGameOldVersion / FlagLoadGame).

32-bit vs 64-bit and pointers

Three payload structs contain pointers, so their raw sizeof differs by word size: T_OBJ_3D (minus CurrentFrame) is 136 B on 32-bit but 164 B on 64-bit, T_EXTRA 68 vs 80, S_PART_FLOW 60 vs 64. The 1997 game wrote them with a plain memcpy, so a 64-bit build's bytes would not match what retail wrote.

Writer (portable). SaveContexte now converts each of the three through a fixed 32-bit wire mirror before writing: SavegameObj3dToWire32 / SavegameExtraToWire32 / SavegameFlowToWire32 in SOURCES/SAVEGAME_WIRE.CPP (T_OBJ_3D_WIRE32 136 B, T_EXTRA_WIRE32 68 B, S_PART_FLOW_WIRE32 60 B, all pack(1), sizes locked at compile time in the header). So a 64-bit build emits the same object/extra/flow bytes a 32-bit build did, independent of host. Discarded-on-read pointer slots (Texture, NextTexture, PtrBody, PtrListDot) are written as 0 for determinism; the reader refills them at load. Preserved pointer-typed offsets (LastOfsFrame, NextOfsFrame) keep their low 32 bits. The per-object on-disk stride is 276 B = a 140 B field-by-field scalar prefix + the 136 B wire object. (The old 142/278 figures were off by two and were removed with the sniff.)

Reader (native-first). A v36 save carries no version signal for wire-vs-native, so LoadContexte picks the stride by trying one and validating per-object IndexFile3D. It reads the native (wider) stride first: reading a 304-byte record from shorter 276-byte wire data over-runs into the next record, so a genuine wire save reliably fails native validation and is retried as wire, while a legacy native save reads correctly. The reverse order is unsafe: reading the shorter wire record from wider native data stops early inside still-valid bytes, so a native save can spuriously pass the wire read (the offline probe confirms some multi-object native saves are ambiguous under both hypotheses), commit the wrong layout, and LoadFile3D(garbage)-SIGSEGV in InitLoadedGame. Reading the larger record first is the robust discriminator; it loads all 237 corpus wire saves with zero misreads and legacy native saves (including single-object) correctly. A native read on a non-forced load warns that the save will migrate to the portable layout on the next save. LBA2_SAVE_LOAD_ABI forces a path for tests (32 = wire only, 64 = native only). A wire32_abi flag from the object loop selects the matching T_EXTRA / S_PART_FLOW read (single-writer assumption: one program wrote the whole file, so all three share the same ABI). Regression fixtures: tests/savegame/corpus/saves/native_port64/.

Consequence. A save written by any build of this port loads on any other; retail 32-bit saves load through the canonical path directly; the native fallback exists only for pre-fix port-64 saves. The offline forward-simulating probe in scripts/save_probe.py still walks the empirical 276/304 strides for its static analysis (it cannot run the engine's IndexFile3D check); keep it in sync if the layout ever changes.

PtrZoneClimb: stored and restored as a 32-bit value (LbaWriteLong / read as U32); it does not round-trip a true 64-bit pointer. See SaveContexte / LoadContexte in SOURCES/SAVEGAME.CPP.

Recommendations

  • Cross-word-size: automatic. The writer emits the portable 32-bit wire on every host, so a save made by any build of this port loads on any other (32- or 64-bit), and retail 32-bit saves load directly.
  • Older layout revisions: A newer engine reads older NUM_VERSION saves via the LoadContexte version branches (release builds) or LoadGameOldVersion (DEBUG/TEST builds). An older engine loading a newer revision misaligns the stream; avoid.
  • Legacy port-64 saves: A save written by a pre-portable-writer 64-bit build loads via the native fallback and is rewritten to the portable layout the next time it is saved.

Format hardening (issue #62)

Goals: portable saves (a 64-bit build writes the same 32-bit wire a 32-bit build did) and no SIGSEGV on corrupt or legacy-layout streams. Implemented in SOURCES/SAVEGAME.CPP, SOURCES/SAVEGAME_WIRE.CPP, and SOURCES/SAVEGAME_LOAD_BOUNDS.CPP. Reference table, every guard at a glance:

Field / regionLimitBehavior on violationNotes
Whole .lba file640×480 + RECOVER_AREA (SaveLoadScreenBufferBytes)RejectLoadSize caps the read; oversize files never touch the parser.
Player name (header)MAX_SIZE_PLAYER_NAME + NULRejectBounded scan; missing NUL → fail clean.
sizefile + compressed tailBuffer geometryRejectSaveLoadValidateCompressedStaging runs before ExpandLZ staging memcpy.
LoadContexte cursorSaveLoadSetReadLimit(end)Reject with SAVELOAD_CTX_ERR (-1)Bounded LbaRead* macros propagate the error out.
NbObjetsMAX_OBJETS (100)RejectRange check; pre-check room for nb * max(wire_stride, native_stride) so neither read below can overrun.
Per-object stride304 native or 276 wireNative-firstRead the native (wider) stride first; on IndexFile3D validation failure rewind and retry wire. Reading the wider record first fails-safe on shorter wire data (a native-first read never spuriously commits wire, avoiding the LoadFile3D(garbage) SIGSEGV a wire-first read hits on ambiguous native saves). Native read warns + migrates on next save. Both fail: SAVELOAD_CTX_ERR. LBA2_SAVE_LOAD_ABI (32/64) forces a path.
T_OBJ_3D blob (32-bit wire)136 bytesMigrateT_OBJ_3D_WIRE32 decode + SavegameObj3dFromWire32 field-by-field copy with pointer fields zeroed.
NbPatchesMAX_PATCHES (500)RejectRange check before patch loop.
Patch applyPtrSceneMemRejectPer-patch Offset ≥ 0, Size ≥ 0, Offset + Size within scene buffer.
Extras count byteMAX_EXTRAS (50)RejectUpper bound.
T_EXTRA blob (32-bit wire)68 bytesMigrateT_EXTRA_WIRE32 decode + SavegameExtraFromWire32. Native sizeof 80 → reading native size from a 32-bit-written stream over-runs cursor by 12 B per extra; chosen at the same wire32_abi flag the object loop set.
NbZonesMAX_ZONES (255)RejectRange check.
Incrust countMAX_INCRUST_DISP (10)Reject + bugfixUpper bound; rain-init loop now n++ only (was incrementing the wrong pointer).
Flow countMAX_FLOWS (10)RejectUpper bound.
S_PART_FLOW blob (32-bit wire)60 bytesMigrateS_PART_FLOW_WIRE32 decode + SavegameFlowFromWire32. Native sizeof 64 → 4-byte over-run per flow; chosen at the same wire32_abi flag.
Flow dots wbyte2MAX_FLOW_DOTS (100)RejectUpper bound.
Valid-pos tail SizeOfBufferValidePosSIZE_BUFFER_VALIDE_POSRejectChecked in LoadGame.
Checkpoint LoadContexteBufferValidePos allocationRejectSOURCES/VALIDPOS.CPP sets the read limit before calling.
Post-load branchOBJECT.CPP::ChangeCubeFail safeflagload == LOADGAME_ERR_CONTEXT → skip InitLoadedGame and CheckProtoPack. Other negative / falsy returns still take the legacy InitLoadedGame path.

DEBUG / NumVersion < 34 extra fields are unchanged (still only in DEBUG / TEST / EDIT builds).

Adding a new field

To extend the save format with a new global / count / sub-array, the pattern is:

  1. Pair the read with the existing matching write in SaveContexteLoadContexte (same order, same widths). Reordering or inserting in the middle breaks every existing save — bump NUM_VERSION if the change is incompatible with the previous layout.
  2. For counts: add a MAX_<THING> constant (or reuse an existing one) and gate the loop with the matching range check immediately after LbaReadLong(NbThing). Add a row to the Format-hardening table above. The bounded LbaRead* macros in LoadContexte already propagate SAVELOAD_CTX_ERR on overrun, so you don't need a manual room check unless you're sizing a multi-element read in advance (e.g., the per-object room pre-check before the object read).
  3. For offset-into-buffer fields (like patch Offset/Size): validate against the destination buffer size (e.g., PtrSceneMem) before any write into the buffer.
  4. For pointer-bearing structs: prefer not to LbaRead/LbaWrite whole-struct memcpy — those are exactly the fields the 32-bit-vs-64-bit ABI hazard (above) bites. If you must, document the exact field layout next to the read, like T_OBJ_3D_WIRE32.
  5. Test it. For a change to one of the wire structs, add golden / round-trip coverage in tests/save_wire/ (the compile-time size locks there break the build on a size drift). For a new pure bounds helper, add a Layer-1 case to tests/savegame/test_load_bounds.cpp. For end-to-end, drive a real save through lba2cc --save-load-test <path> (see Tooling below) before and after the change and confirm the corpus matrix is unchanged.

Still future / larger change

  • Layer-2 host fixture test: a full-payload SaveContexte / LoadContexte round-trip driven on synthetic state (no retail data, no engine boot). The three wire converters already have unit + fuzz coverage in tests/save_wire/; this would close the remaining gap between Layer-1 (helpers only) and Layer-3 (full engine, retail-bound; see tests/savegame/corpus/) so CI catches whole-payload parse regressions.

The portable canonical wire format that this section used to list as future work has landed; see 32-bit vs 64-bit and pointers.

For save editors

  • EDITLBA2: The tree still contains #ifdef EDITLBA2 / #ifndef EDITLBA2 branches around save/load and tools; the classic community build does not ship that editor configuration. A future cleanup may fold or remove those dead paths; behaviour documented here is the non-editor path unless stated otherwise.
  • Offsets: All byte offsets in this doc are relative to the stated base (header, screenshot, or game context).
  • Endianness: Little-endian (x86).
  • Validation: Engine checks NUM_VERSION (low 7 bits of version byte) and Checksum. Checksum mismatch sets SceneStartX=-1 (fallback to scene start) and, in DEBUG_TOOLS/TEST_TOOLS, shows: "Warning: The save doesn't match the scenario!" (French: La sauvegarde ne correspond pas au scénario !).
  • Scene codes: Hex scene codes (e.g. 0x00 = Twinsen's house, 0x5D = Celebration temple) are documented in LBA File Info – Savegame (scene codes table by island).

Creating quest saves

To create a valid quest save for testing scenarios:

  1. Start from a known-good save – Load a normal save, play to the desired scenario, save. Use that .lba as template.
  2. Modify ListVarGame – Set inventory flags (0/1) and quantities at the offsets in the table above. Ensure consistency (e.g. FLAG_CHAPTER matches story progress).
  3. Modify Holomap – TabArrow FlagHolo bytes; bit 0 = active, bit 1 = asked.
  4. Modify positionSceneStartX/Y/Z, StartXCube/Y/Z, NumCube (in header) must be consistent.
  5. Preserve header – Version byte, player name, compression flag. Keep Checksum or accept possible fallback on load.
  6. Test – Load in the engine; watch for SceneStartX=-1 fallback (checksum mismatch).

Future: console commands

A complete save format doc enables console commands such as:

  • save_var <index> <value> – set ListVarGame[index]
  • save_holomap <index> <0|1|2|3> – set TabArrow[index].FlagHolo
  • save_inv <slot> <ptmagie> <flaginv> <idobj3d> – set inventory slot
  • save_behavior <0–13> – set Comportement
  • save_magic <0–4> – set MagicLevel
  • save_pos – print current position
  • save_load <path> / save_save <path> – load/save from console
  • quest_load <name> – load from quests/ directory (future)

Code reference

ConceptFileFunction/Symbol
SaveSAVEGAME.CPPSaveGame, CurrentSaveGame, AutoSaveGame
LoadSAVEGAME.CPPLoadGame, LoadGameNumCube, LoadGameScreen, LoadGamePlayerName
ContextSAVEGAME.CPPSaveContexte, LoadContexte
CompressionSAVEGAME.CPP, LZSS.CPP, LIB386/SYSTEM/LZ.CPPCompress_LZSS, ExpandLZ
Whole-file readLIB386/SYSTEM/LOADSAVE.CPPLoadSize (capped load for save paths; Load still used elsewhere)
Screen buffer sizeMEM.CPPSmartMalloc entry for Screen
Post-load branchOBJECT.CPPChangeCube (FlagLoadGame, flagload, InitLoadedGame, LoadFile3dObjects)
Screenshot captureSAVEGAME.CPPScaleBox, SaveBlock, RemapPicture
Screenshot displayGAMEMENU.CPPLoadGameScreen, DrawScreenSave
Menu / listingGAMEMENU.CPPPlayer listing, FindPlayerFile, etc.
Console loadCONSOLE/CONSOLE_CMD.CPPload command (FlagLoadGame, LoadGameNumCube)
Startup argv savePERSO.CPPResolves path, sets FlagLoadGame, LoadGameNumCube in init flow
PathsDIRECTORIES.CPPGetSavePath

Tooling (save_probe / save_decompress / --save-load-test)

  • scripts/save_probe.py — Header, optional LZSS decompression via save_decompress, NbObjets at game-context offset 1478, heuristic obj3d_abi (32 vs 64) using NbPatches, strict patch-blob fit, and extras count bound (reports ambiguous when neither stride wins clearly — use --obj3d-abi 32|64 to force). Also walks the full LoadContexte field sequence (objects → patches → extras → zones → incrust → flows → flow-dots) under each ABI hypothesis using the engine's actual MAX_ constants and emits predicted_abi + predicted_outcome (ok / ctxerr_at_<field> / corrupt). The forward-simulator is what disambiguates most "ambiguous" cases the score-based heuristic flagged; both signals are kept side-by-side for traceability. Environment LBA2_SAVE_PROBE_ABI=32|64 applies when --obj3d-abi auto (CLI overrides env). --json-lines prints NDJSON (includes version_byte_hex, dump_lines as a string array when --dump). num_version < 34 sets layout_warning. Flags: --dump, --json-lines, --compare, --recursive, --summary (counts on stderr, safe with --json-lines). Set LBA2_SAVE_DECOMPRESS to the helper if it is not found under build/tools/ or out/build/*/tools/ (or PATH).

  • save_decompress (CMake target) — stdin = compressed tail, argv[1] = decompressed byte count, stdout = raw payload. Uses ExpandLZ(..., MinBloc=2) from LIB386/SYSTEM/LZ.CPP. Build: cmake --build <dir> --target save_decompress (option LBA2_BUILD_SAVE_TOOLS, default ON).

  • scripts/save_probe_lz_selftest.py — Golden LZ vectors aligned with tests/SYSTEM/test_lz.cpp. make save-probe-lz-selftest configures with LBA2_BUILD_SAVE_TOOLS=ON, builds save_decompress, runs the script.

  • lba2cc --save-load-test <path> — single-shot load harness. Boots the engine to the menu (skips logos in this mode), replicates the "Load Game" path on the given save (InitGame(-1) + ChangeCube()), prints SAVE_LOAD_TEST: stage=<name> … lines to stdout, and exits. Useful when investigating any save-loading bug — much faster iteration than navigating to the menu manually. Requires retail game data (use --game-dir or LBA2_GAME_DIR). Layer-3 driver scripts in tests/savegame/corpus/ wrap this for full-corpus regression matrices.

  • tests/savegame/corpus/run_harness.py — full-corpus driver. Runs --save-load-test against every save in the manifest under both auto and LBA2_SAVE_LOAD_ABI=32, records per-save outcomes, and emits a probe-vs-harness consistency check at the end: any save where the probe's predicted_outcome disagreed with the engine's actual outcome is flagged. Today: 0 divergences across the bundled Steam-classic corpus and the original retail/native corpus. The check turns the probe into a falsifiable contract — a probe regression or an engine regression both surface as a divergence here. The bundled reference corpus lives in tests/savegame/corpus/saves/steam_classic_2023/ (50 anonymized saves; provenance documented in its README).

Cross-references

  • MENU.md for Save/Load menu flow and screenshot display
  • CONFIG.md for LastSave and CompressSave
  • VERSIONS.md for the other fields named version in the engine; NumVersion here is save layout, DistribVersion is release identity, and the two are unrelated
  • DEBUG.md for DEBUG_TOOLS bug save/load (G/L keys, menu cases 2000/2001)
  • CONSOLE.md for savebug / loadbug / listbugs
  • GLOSSARY.md for Comportement, GenBody, GenAnim

External resources

  • LBA File Info – Savegame – Community reverse-engineering: full binary layout with fan names, holomap location list, scene codes by island. This doc’s layout is verified against the engine; LBA File Info provides additional context and scene code tables.
  • LBALab/metadata – JSON metadata for HQR/asset files (indices, names). Useful for mapping IdObj3D and inventory IDs to human-readable names; does not cover savegame format.

LBALab save tools (community)

LBALab hosts open-source LBA2 save utilities (Rust). Engine code remains the source of truth; these tools reflect community reverse-engineering.

ToolPurposeNotes
LBA2SDSave de/compressorLZSS decompression works; compressor not yet implemented. Confirms 0x24 / 0xA4.
LBA2R2IScreenshot extractorReads decompressed saves, extracts 160×120 image to PNG/JPEG/etc.
LBA2I2RCustom screenshot writerWrites images into decompressed saves.

Screenshot palette (LBA2R2I): The save stores 8-bit indexed pixels; the engine remaps to the game palette at save time. LBA2R2I includes a hardcoded 256-color RGB palette for converting raw pixels to viewable images. Useful when extracting screenshots without the game’s palette. Attribution: LBALab/LBA2R2I r2i.rs.

Header interpretation: LBALab tools treat bytes 1–4 as “system_hour” (1 byte) + “zeros” (3 bytes). The engine uses bytes 1–4 as the 4-byte scene/cube index (NumCube). For de/compression and screenshot extraction the offset to the payload can still align; for full-format tools, use the engine layout in this doc.