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
| Name | Constant | Purpose |
|---|---|---|
current.lba | CURRENTSAVE_FILENAME | Quick-save; enables Resume in main menu |
autosave.lba | AUTOSAVE_FILENAME | Auto-save during gameplay |
*.lba | User-chosen | Named 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.
| Topic | Regular saves | Bug saves |
|---|---|---|
| Directory | GetSavePath(..., "<file>.lba") → <userDir>/save/ (root) for named slots; current.lba / autosave.lba for quick/auto | GetBugPath(..., "<file>.lba") → <userDir>/save/bugs/ (SOURCES/DIRECTORIES.CPP GetBugPath) |
| Compression | AutoSaveGame / 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 points | Main menu save/load, autosave, argv/console load | DEBUG_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
| Trigger | Function | Notes |
|---|---|---|
| Menu Save | SaveGame(TRUE) | User-initiated |
| Quick-save (MENUS) | CurrentSaveGame() | Saves to current.lba; player name set to "CURRENT" |
| Auto-save | AutoSaveGame() | Saves to autosave.lba at scene transitions |
| Bug save | SaveGame(TRUE) | save/bugs/ via DEBUG menu (DEBUG_TOOLS), in-game G, or console savebug |
Code: SOURCES/SAVEGAME.CPP – SaveGame, CurrentSaveGame, AutoSaveGame
Load
| Trigger | Function | Notes |
|---|---|---|
| Menu Load / Resume | LoadGame() | Full load; sets NewCube, PlayerName, restores state |
| Load screenshot only | LoadGameScreen() | Reads header + 160×120 image; used for menu preview |
| Load player name only | LoadGamePlayerName() | 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.CPP – LoadGame, 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):
- Build stream at
PtrSave(fromBufSpeak + 50000in the non-editor build). - Write version byte (
NUM_VERSIONin low 7 bits; high bit set ifCompressSaverequests compression — see Version byte on disk), cube (NumCube), null-terminatedPlayerName. - If compressing: reserve 4 bytes for plaintext size (
sizefile), remembermemoptr. - Write 160×120 screenshot bytes, then
SaveContexte(savetimerrefhr)(full game state). - Append
ValidePos/LastValidePos; if!ValidePos, appendValideCube,SizeOfBufferValidePos, andBufferValidePos. - If compressing: LZSS-compress the block from
memoptronward, patchsizefile, write file withSave().
Constants: NUM_VERSION, SAVE_COMPRESS, MASK_NUM_VERSION in SOURCES/COMMON.H.
Full load pipeline (LoadGame)
LoadSize— reads intoScreenup to (see SOURCES/MEM.CPP); larger files are rejected.- Read
NumVersion,NewCube,PlayerName(NUL-terminated in the file; reader is bounded byMAX_SIZE_PLAYER_NAME). - If
NumVersion & SAVE_COMPRESS: readsizefile, validate staging, copy compressed tail,ExpandLZ(..., mode 2)(matchesCompress_LZSS). PtrSave += 160 * 120— skip screenshot so the next reads align with game context offsets in this doc.LoadContexte(&savetimerrefhr)— restores globals through camera / buggy / ardoise /VueCamera; returnsflaginit(0/1) or-1on stream/bounds failure (see Version compatibility).- Read valid-position tail:
ValidePos/LastValidePos, optionalBufferValidePosblob. - Restart music, palette,
CameraCenter(0), restore timer from save,SaveTimer().LoadGamereturnsflaginittoChangeCube, which stores it inflagload.
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):
| Offset | Size | Content |
|---|---|---|
| 0 | 1 | Version byte: 0x24 (uncompressed) or 0xA4 (compressed). High bit = SAVE_COMPRESS (0x80); low 7 bits = NUM_VERSION (36). |
| 1 | 4 | Scene/cube index (NumCube or NewCube) |
| 5 | var | Player name (null-terminated string) |
| — | 4 | (if compressed) Decompressed size |
| — | var | (if compressed) LZSS-compressed payload |
After decompression (or if uncompressed):
| Offset | Size | Content |
|---|---|---|
| 0 | 19200 | 160×120 screenshot (raw 8-bit indexed, game palette) |
| 19200 | var | Game 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):
| Constant | Value | Role |
|---|---|---|
NUM_VERSION | 36 | Low 7 bits: layout revision of the serialized game context (branches in LoadContexte / SaveContexte). |
SAVE_COMPRESS | 0x80 | High 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
| Offset | Size | Content | Code |
|---|---|---|---|
| 0 | 512 | ListVarGame[0..255] (S16 each) | 256 × 2 bytes |
| 512 | 80 | ListVarCube[0..79] (U8 each) | 80 × 1 byte |
Globals
| Offset | Size | Content | Code |
|---|---|---|---|
| 592 | 1 | Comportement (behavior mode) | LbaWriteByte |
| 593 | 4 | (NbZlitosPieces<<16)+(NbGoldPieces&0xFFFF) | LbaWriteLong |
| 597 | 1 | MagicLevel (0–4) | LbaWriteByte |
| 598 | 1 | MagicPoint (0–80) | LbaWriteByte |
| 599 | 1 | NbLittleKeys | LbaWriteByte |
| 600 | 2 | NbCloverBox | LbaWriteWord |
| 602 | 4 | SceneStartX | LbaWriteLong |
| 606 | 4 | SceneStartY | LbaWriteLong |
| 610 | 4 | SceneStartZ | LbaWriteLong |
| 614 | 4 | StartXCube | LbaWriteLong |
| 618 | 4 | StartYCube | LbaWriteLong |
| 622 | 4 | StartZCube | LbaWriteLong |
| 626 | 1 | Weapon | LbaWriteByte |
| 627 | 4 | savetimerrefhr (game timestamp) | LbaWriteLong |
| 631 | 1 | NumObjFollow (actor ID, usually Twinsen) | LbaWriteByte |
| 632 | 1 | SaveComportementHero | LbaWriteByte |
| 633 | 1 | SaveBodyHero | LbaWriteByte |
Holomap (TabArrow)
| Offset | Size | Content | Code |
|---|---|---|---|
| 634 | 305 | TabArrow[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)
| Offset | Size | Content | Code |
|---|---|---|---|
| 939 | 400 | TabInv[0..39] – 40 items × 10 bytes each | LbaWrite 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
| Offset | Size | Content |
|---|---|---|
| 1339 | 4 | Checksum – engine validates; mismatch can trigger fallback position |
| 1343 | 47 | Input / magic / movement block (LastMyFire … PingouinActif) — see LoadContexte |
| 1390 | 4 | PtrZoneClimb stored as U32 (load casts to T_ZONE *) |
| 1394 | 84 | ListDart[0..2] — 3 darts × 7×S32 each (MAX_DARTS, SAVEGAME.CPP) |
| 1478 | 4 | NbObjets (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.
| Index | Constant | Item |
|---|---|---|
| 0 | FLAG_HOLOMAP | Holomap |
| 1 | FLAG_BALLE_MAGIQUE | Magic ball |
| 2 | FLAG_DART | Darts |
| 3 | FLAG_BOULE_SENDELL | Sendell's ball |
| 4 | FLAG_TUNIQUE | Tunic and Sendell's medallion |
| 5 | FLAG_PERLE | Incandescent pearl |
| 6 | FLAG_CLEF_PYRAMID | Pyramid shaped key |
| 7 | FLAG_VOLANT | Part for the car |
| 8 | FLAG_MONEY | Kashes (TwinSun) or Zlitos (Zeelich) |
| 9 | FLAG_PISTOLASER | Pisto-Laser |
| 10 | FLAG_SABRE | Emperor's sword |
| 11 | FLAG_GANT | Wannie's glove |
| 12 | FLAG_PROTOPACK | Proto-Pack |
| 13 | FLAG_TICKET_FERRY | Ferry Ticket |
| 14 | FLAG_MECA_PINGOUIN | Nitro-Meca-Penguin |
| 15 | FLAG_GAZOGEM | Can of GazoGem |
| 16 | FLAG_DEMI_MEDAILLON | Dissidents' Ring |
| 17 | FLAG_ACIDE_GALLIQUE | Gallic acid |
| 18 | FLAG_CHANSON | Ferryman's song |
| 19 | FLAG_ANNEAU_FOUDRE | Ring of lightning |
| 20 | FLAG_PARAPLUIE | Customer's umbrella |
| 21 | FLAG_GEMME | Gems |
| 22 | FLAG_CONQUE | Horn of the Blue Triton |
| 23 | FLAG_SARBACANE | Blowgun |
| 24 | FLAG_DISQUE_ROUTE / FLAG_VISIONNEUSE | Itinerary token / Visionneuse |
| 25 | FLAG_TART_LUCI | Slice of tart |
| 26 | FLAG_RADIO | Portable radio |
| 27 | FLAG_FLEUR | Garden Balsam |
| 28 | FLAG_ARDOISE | Magic slate |
| 29 | FLAG_TRADUCTEUR | Translator |
| 30 | FLAG_DIPLOME | Wizard's diploma |
| 31 | FLAG_DMKEY_KNARTA | Fragment of the Francos |
| 32 | FLAG_DMKEY_SUP | Fragment of the Sups |
| 33 | FLAG_DMKEY_MOSQUI | Fragment of the Mosquibees |
| 34 | FLAG_DMKEY_BLAFARD | Fragment of the Wannies |
| 35 | FLAG_CLE_REINE | Key for the passage to CX |
| 36 | FLAG_PIOCHE | Pick-ax |
| 37 | FLAG_CLEF_BOURGMESTRE | Franco Burgermaster's key |
| 38 | FLAG_NOTE_BOURGMESTRE | Franco Burgermaster's notes |
| 39 | FLAG_PROTECTION | Protective spell |
| 40 | FLAG_SCAPHANDRE | (Space suit – scenario var) |
| 79 | FLAG_CELEBRATION | Celebration crystal |
| 94 | FLAG_DINO_VOYAGE | Dino-Fly travel |
| 251 | FLAG_CLOVER | Clover leaves |
| 252 | FLAG_VEHICULE_PRIS | Vehicle taken |
| 253 | FLAG_CHAPTER | Chapter |
| 254 | FLAG_PLANETE_ESMER | Planet Esmer |
Code: SOURCES/COMMON.H (FLAG_*), SOURCES/GLOBAL.CPP (ListVarGame).
Comportement (behavior mode)
| Value | Constant | Meaning |
|---|---|---|
| 0 | C_NORMAL | Normal |
| 1 | C_SPORTIF | Athletic |
| 2 | C_AGRESSIF | Aggressive |
| 3 | C_DISCRET | Stealth |
| 4 | C_PROTOPACK | Protopack |
| 5 | C_DOUBLE | Twinsen and Zoé |
| 6 | C_CONQUE | Horn of the Blue Triton |
| 7 | C_SCAPH_INT_NORM | Diving suit interior (normal) |
| 8 | C_JETPACK | Super jetpack |
| 9 | C_SCAPH_INT_SPOR | Diving suit interior (athletic) |
| 10 | C_SCAPH_EXT_NORM | Diving suit exterior (normal) |
| 11 | C_SCAPH_EXT_SPOR | Diving suit exterior (athletic) |
| 12 | C_BUGGY | Buggy |
| 13 | C_SKELETON | Skeleton disguise |
Code: SOURCES/COMMON.H (C_* constants).
Screenshot
Generated at save time in SaveGame():
ScaleBox(0, 0, 639, 479, Log, 0, 0, 159, 119, BufSpeak+50000L)– downsample 640×480 frame to 160×120SaveBlock– copy to temp bufferRemapPicture– remap palette to game paletteLbaWrite– 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)
| Version | Change |
|---|---|
| < 34 | Extra 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. |
| ≥ 35 | Per-object SampleAlways (S32) added. |
| ≥ 36 | Per-object SampleVolume (U8) added. |
Code: SOURCES/SAVEGAME.CPP — LoadContexte / 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:
SceneStartXis forced to-1. A warning may be shown in DEBUG_TOOLS / TEST_TOOLS. The loader then follows theif (SceneStartX == -1)branch inLoadContexteand skips restoring the large “extended” blob (objects, patches, flows, camera, …) — see theif (SceneStartX == -1)vselsestructure in SOURCES/SAVEGAME.CPP. LoadContextereturn value (flaginitinsideLoadGame):TRUEwhen the checksum /SceneStartX == -1short path ran (hero and globals repaired without consuming the extended blob),FALSEafter 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()→LoadContexteis always used. If the save’s layout revision ≠NUM_VERSION, the byte stream misaligns — expect corruption or crashes; there is no automatic fallback. DEBUG_TOOLSorTEST_TOOLS: InChangeCube, if(NumVersion & MASK_NUM_VERSION) != NUM_VERSION, the engine shows the French warning (Sauvegarde trop ancienne…) and callsLoadGameOldVersion()instead ofLoadGame().
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.CPP — ChangeCube, 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_VERSIONsaves via theLoadContexteversion branches (release builds) orLoadGameOldVersion(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 / region | Limit | Behavior on violation | Notes |
|---|---|---|---|
Whole .lba file | 640×480 + RECOVER_AREA (SaveLoadScreenBufferBytes) | Reject | LoadSize caps the read; oversize files never touch the parser. |
| Player name (header) | MAX_SIZE_PLAYER_NAME + NUL | Reject | Bounded scan; missing NUL → fail clean. |
sizefile + compressed tail | Buffer geometry | Reject | SaveLoadValidateCompressedStaging runs before ExpandLZ staging memcpy. |
LoadContexte cursor | SaveLoadSetReadLimit(end) | Reject with SAVELOAD_CTX_ERR (-1) | Bounded LbaRead* macros propagate the error out. |
NbObjets | MAX_OBJETS (100) | Reject | Range check; pre-check room for nb * max(wire_stride, native_stride) so neither read below can overrun. |
| Per-object stride | 304 native or 276 wire | Native-first | Read 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 bytes | Migrate | T_OBJ_3D_WIRE32 decode + SavegameObj3dFromWire32 field-by-field copy with pointer fields zeroed. |
NbPatches | MAX_PATCHES (500) | Reject | Range check before patch loop. |
| Patch apply | PtrSceneMem | Reject | Per-patch Offset ≥ 0, Size ≥ 0, Offset + Size within scene buffer. |
| Extras count byte | MAX_EXTRAS (50) | Reject | Upper bound. |
T_EXTRA blob (32-bit wire) | 68 bytes | Migrate | T_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. |
NbZones | MAX_ZONES (255) | Reject | Range check. |
| Incrust count | MAX_INCRUST_DISP (10) | Reject + bugfix | Upper bound; rain-init loop now n++ only (was incrementing the wrong pointer). |
| Flow count | MAX_FLOWS (10) | Reject | Upper bound. |
S_PART_FLOW blob (32-bit wire) | 60 bytes | Migrate | S_PART_FLOW_WIRE32 decode + SavegameFlowFromWire32. Native sizeof 64 → 4-byte over-run per flow; chosen at the same wire32_abi flag. |
Flow dots wbyte2 | MAX_FLOW_DOTS (100) | Reject | Upper bound. |
Valid-pos tail SizeOfBufferValidePos | SIZE_BUFFER_VALIDE_POS | Reject | Checked in LoadGame. |
Checkpoint LoadContexte | BufferValidePos allocation | Reject | SOURCES/VALIDPOS.CPP sets the read limit before calling. |
| Post-load branch | OBJECT.CPP::ChangeCube | Fail safe | flagload == 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:
- Pair the read with the existing matching write in
SaveContexte↔LoadContexte(same order, same widths). Reordering or inserting in the middle breaks every existing save — bumpNUM_VERSIONif the change is incompatible with the previous layout. - For counts: add a
MAX_<THING>constant (or reuse an existing one) and gate the loop with the matching range check immediately afterLbaReadLong(NbThing). Add a row to the Format-hardening table above. The boundedLbaRead*macros inLoadContextealready propagateSAVELOAD_CTX_ERRon 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). - For offset-into-buffer fields (like patch
Offset/Size): validate against the destination buffer size (e.g.,PtrSceneMem) before any write into the buffer. - For pointer-bearing structs: prefer not to
LbaRead/LbaWritewhole-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, likeT_OBJ_3D_WIRE32. - 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/LoadContexteround-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 EDITLBA2branches 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) andChecksum. Checksum mismatch setsSceneStartX=-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:
- Start from a known-good save – Load a normal save, play to the desired scenario, save. Use that
.lbaas template. - Modify ListVarGame – Set inventory flags (0/1) and quantities at the offsets in the table above. Ensure consistency (e.g.
FLAG_CHAPTERmatches story progress). - Modify Holomap – TabArrow
FlagHolobytes; bit 0 = active, bit 1 = asked. - Modify position –
SceneStartX/Y/Z,StartXCube/Y/Z,NumCube(in header) must be consistent. - Preserve header – Version byte, player name, compression flag. Keep
Checksumor accept possible fallback on load. - Test – Load in the engine; watch for
SceneStartX=-1fallback (checksum mismatch).
Future: console commands
A complete save format doc enables console commands such as:
save_var <index> <value>– setListVarGame[index]save_holomap <index> <0|1|2|3>– setTabArrow[index].FlagHolosave_inv <slot> <ptmagie> <flaginv> <idobj3d>– set inventory slotsave_behavior <0–13>– setComportementsave_magic <0–4>– setMagicLevelsave_pos– print current positionsave_load <path>/save_save <path>– load/save from consolequest_load <name>– load fromquests/directory (future)
Code reference
| Concept | File | Function/Symbol |
|---|---|---|
| Save | SAVEGAME.CPP | SaveGame, CurrentSaveGame, AutoSaveGame |
| Load | SAVEGAME.CPP | LoadGame, LoadGameNumCube, LoadGameScreen, LoadGamePlayerName |
| Context | SAVEGAME.CPP | SaveContexte, LoadContexte |
| Compression | SAVEGAME.CPP, LZSS.CPP, LIB386/SYSTEM/LZ.CPP | Compress_LZSS, ExpandLZ |
| Whole-file read | LIB386/SYSTEM/LOADSAVE.CPP | LoadSize (capped load for save paths; Load still used elsewhere) |
Screen buffer size | MEM.CPP | SmartMalloc entry for Screen |
| Post-load branch | OBJECT.CPP | ChangeCube (FlagLoadGame, flagload, InitLoadedGame, LoadFile3dObjects) |
| Screenshot capture | SAVEGAME.CPP | ScaleBox, SaveBlock, RemapPicture |
| Screenshot display | GAMEMENU.CPP | LoadGameScreen, DrawScreenSave |
| Menu / listing | GAMEMENU.CPP | Player listing, FindPlayerFile, etc. |
| Console load | CONSOLE/CONSOLE_CMD.CPP | load command (FlagLoadGame, LoadGameNumCube) |
| Startup argv save | PERSO.CPP | Resolves path, sets FlagLoadGame, LoadGameNumCube in init flow |
| Paths | DIRECTORIES.CPP | GetSavePath |
Tooling (save_probe / save_decompress / --save-load-test)
-
scripts/save_probe.py — Header, optional LZSS decompression via
save_decompress,NbObjetsat game-context offset 1478, heuristicobj3d_abi(32 vs 64) usingNbPatches, strict patch-blob fit, and extras count bound (reportsambiguouswhen neither stride wins clearly — use--obj3d-abi 32|64to force). Also walks the fullLoadContextefield sequence (objects → patches → extras → zones → incrust → flows → flow-dots) under each ABI hypothesis using the engine's actualMAX_constants and emitspredicted_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. EnvironmentLBA2_SAVE_PROBE_ABI=32|64applies when--obj3d-abi auto(CLI overrides env).--json-linesprints NDJSON (includesversion_byte_hex,dump_linesas a string array when--dump).num_version< 34 setslayout_warning. Flags:--dump,--json-lines,--compare,--recursive,--summary(counts on stderr, safe with--json-lines). SetLBA2_SAVE_DECOMPRESSto the helper if it is not found underbuild/tools/orout/build/*/tools/(orPATH). -
save_decompress(CMake target) — stdin = compressed tail, argv[1] = decompressed byte count, stdout = raw payload. UsesExpandLZ(..., MinBloc=2)from LIB386/SYSTEM/LZ.CPP. Build:cmake --build <dir> --target save_decompress(optionLBA2_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-selftestconfigures withLBA2_BUILD_SAVE_TOOLS=ON, buildssave_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()), printsSAVE_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-dirorLBA2_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-testagainst every save in the manifest under bothautoandLBA2_SAVE_LOAD_ABI=32, records per-save outcomes, and emits a probe-vs-harness consistency check at the end: any save where the probe'spredicted_outcomedisagreed 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;
NumVersionhere is save layout,DistribVersionis 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
IdObj3Dand 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.
| Tool | Purpose | Notes |
|---|---|---|
| LBA2SD | Save de/compressor | LZSS decompression works; compressor not yet implemented. Confirms 0x24 / 0xA4. |
| LBA2R2I | Screenshot extractor | Reads decompressed saves, extracts 160×120 image to PNG/JPEG/etc. |
| LBA2I2R | Custom screenshot writer | Writes 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.