Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

NFO Format (Save Metadata Block)

The savenfo (savenfo.res) file is a save’s load-menu metadata block (type NFO ). It sits loose in the save folder so the load screen can show a name, area, play time, and portraits for every slot without opening SAVEGAME.sav, an archive that grows with every module you visit. The engine builds it field by field at save time.

At a Glance

PropertyValue
Filenamesavenfo.res
Magic SignatureNFO / V3.2
TypeSave Metadata Block
Rust ReferenceHandled by rakata-save (mid-refactor).

File Layout

A flat GFF of scalar fields, no lists (autosaves add one nested struct). The Save type column marks which write path emits each field: both, manual (manual saves and quicksaves), or autosave.

FieldTypeMeaningSave type
SAVEGAMENAMECExoStringDisplay name of the save, capped at 16 characters. Empty where the player never typed one; see below.manual
AREANAMECExoStringLocalized display name of the current area.both
LASTMODULECExoStringResref of the module the engine restores first.both
TIMEPLAYEDDWORDRunning play time, in seconds.both
CHEATUSEDBYTECheat flag; mirrors the party table’s cheat state.both
GAMEPLAYHINTBYTELoading-screen hint state.both
STORYHINTBYTELoading-screen hint state.both
LIVE1 .. LIVE6CExoStringDownloadable-content slot names (six); empty on a vanilla PC install.manual
LIVECONTENTBYTEBitmask of which of the six LIVE%d slots are installed; 0 when none.manual
PORTRAIT0 .. PORTRAITNCResRefOne portrait resref per active party member.both
PCAUTOSAVEBYTEAlways 1; its presence marks the file as an autosave.autosave
REBOOTAUTOSAVEBYTERead by the load menu’s slot parser, but no write site exists anywhere in this build; see the note below.none (dead on PC)
SCREENSHOTCExoStringLoading-screen resref (load_<module>) used as the slot preview, in place of a Screen.tga.autosave
AUTOSAVEPARAMSStructPending move-to-module state; see AUTOSAVEPARAMS Fields below.autosave

SAVEGAMENAME is one of two labels, and it is usually empty

The load menu shows a slot label of its own, Game <n>, and shows SAVEGAMENAME underneath it. They are two independent lines, not a value and a fallback, which is why an unnamed save is still usable in the menu and why the field being empty is the ordinary case rather than a defect.

The engine writes that same slot label into the save folder’s own name. A manual save’s folder reads NNNNNN - Game<n>, where <n> is the slot number less one, the reserved slots 000000 and 000001 taking the two below it.

So the suffix carries nothing the slot number does not. A folder whose suffix disagrees with its number was renamed by something other than the game.

A writer should cap the name at 16 characters. Typing a longer one into the game truncates it there, and no name in either save corpus exceeds it.

Nothing enforces the cap on the file side, so a tool that writes more produces a name the engine would never have written.

Measured across a full manual-save corpus plus a modded playthrough’s saves and the four committed fixtures. Provenance: measured, and the two-line display is the game’s own behaviour rather than a trace.

Engine Audits & Decompilation

(Documented from Ghidra decompilation of swkotor.exe (K1 GOG build). Manual-save writer: CServerExoAppInternal::StallEventSaveGame at 0x004b3110. The autosave variant is written by CServerExoAppInternal::DoPCAutosave at 0x004b8300. Provenance: derived, not attested, so these rows sit on the reverse-engineering queue.)

Note

savenfo quirks.

  • CHEATUSED is not tracked here independently. The engine writes the same cheat flag the party table serializes, so the menu shows the right state without opening the party table.
  • PORTRAIT0, PORTRAIT1, … is a numbered field series, not a GFF list: one field per active member, suffixed by index.
  • Downloadable (“live”) content always leaves a footprint. The manual/quicksave path writes all six LIVE1-LIVE6 name fields plus the LIVECONTENT bitmask unconditionally, whether or not content is installed. This is the Xbox Live download mechanism (LIVE%d aliases resolving to RIMSXBOX\live%d RIMs). A vanilla PC install has nothing aliased in, so a real quicksave carries all six LIVE%d as empty strings and LIVECONTENT = 0 (verified against a K1 GOG save). The Yavin Station DLC on PC is not delivered through this path.
  • REBOOTAUTOSAVE is read unconditionally by the slot parser and folded into the same bit-field as PCAUTOSAVE, but no code path in this build ever writes it, since DoPCAutosave only sets PCAUTOSAVE. The read path is live regardless: the save-list preview code checks this bit together with PCAUTOSAVE when it decides where a slot’s screenshot comes from, so REBOOTAUTOSAVE just stays permanently 0. See the Save Game Deep Dive for what the field likely inherits from.
  • This is the one file read straight from the slot for the load menu, and LASTMODULE is what selects the first module to restore.

Important

Two write paths, two field sets. savenfo comes from StallEventSaveGame (manual saves and quicksaves) or DoPCAutosave (autosaves); the Save type column above marks which fields each produces. PCAUTOSAVE is the reliable discriminator. Autosaves also differ at the folder level (a loose pifo.ifo, no Screen.tga); the Save Game Deep Dive covers why.

AUTOSAVEPARAMS Fields

AUTOSAVEPARAMS snapshots the module transition that was in flight when the autosave fired, so the engine can resume it once the save is reloaded. Every field traces to a live piece of transition or world-clock state; none of it is autosave-invented data.

FieldTypeSource
LOADMUSICCExoStringThe destination module’s loadscreens.2da row, MusicResRef column. Falls back to a load_<modulename> resref if that cell is empty and a matching .mp3 exists, then to the 2DA row literally named DEFAULT if neither resolves. Same fallback chain (different column) as the ordinary loading-screen background picker, CClientExoApp::SetLoadScreenByModuleName.
STARTWAYPOINTCExoStringThe arrival waypoint tag passed to the StartNewModule script action by whatever door or trigger initiated the transition. Where the player lands once the autosave resolves.
MOVIE1 .. MOVIE6CExoStringDrained from the pending movie queue, filled by calls to AddMoveToModuleMovie from the same StartNewModule action, one movie per slot. Unfilled slots write as empty strings. There’s no queue-overflow handling to speak of: StartNewModule’s own script signature only accepts six movie arguments, so a seventh movie never reaches the autosave writer in the first place.
TIME_YEARDWORDThe destination module’s start_year, fixed at module load rather than a running counter, which is why a real sample often reads 0.
TIME_MONTH / TIME_DAY / TIME_HOURBYTEThe module’s live calendar fields (current_month/current_day/current_hour), read through the module’s own time accessor.
TIME_MINUTE / TIME_SECOND / TIME_MILLISECONDWORDNot from the module’s calendar at all, but derived by converting the world timer’s raw time-of-day tick count into minute/second/millisecond components. One snapshot, two different accessors: the module object supplies year/month/day/hour, the lower-level world timer supplies the rest.
TIME_PAUSEDAY / TIME_PAUSETIMEDWORDThe paused day/time halves of the same world timer’s tick-pair snapshot, the identical live clock that seeds the module IFO’s Mod_PauseDay/Mod_PauseTime on an ordinary module-save, just captured at a mid-transition autosave instead.
STATUSSUMMARYStructSee below.

STATUSSUMMARY is a “since you last saw a loading screen” delta accumulator, not a snapshot of current totals:

FieldTypeBehaviour
CREDITS / XP / STEALTHXPINTRunning totals added to by every credit/XP/stealth-XP gain since the popup last displayed.
CREDITSNETBYTESet when credits moved in both directions since the last display (gained and lost), distinguishing a net change from a one-way one.
LIGHTSHIFT / DARKSHIFTBYTEAlignment-shift deltas, same accumulate-then-drain pattern.
DISPLAYSPENDING / ITEMRECEIVED / ITEMLOST / JOURNALBYTEPending-event flags set by the corresponding gameplay hooks (item give/take, journal updates).
SOUNDPENDING / LEVELUPSOUND / NEWQUESTSOUND / COMPLETESOUNDBYTESelects which stinger, if any, plays alongside the popup.
SUPPRESSEDINTA countdown rather than a display toggle. While positive, incoming gameplay events (credits, items, journal updates, and anything else listed above) are dropped rather than accumulated, and the counter ticks down by one per dropped event. Driven by the SuppressStatusSummaryEntry script command, presumably so a scripted sequence of item grants doesn’t leave a stale or inflated delta behind.

The dispatcher (CGuiInGame::UpdateStatus) is the one place gameplay events get folded in; the drain point (CGuiInGame::ShowStatusSummary) zeroes the running totals and pending flags once the popup has been queued. Confirmed autosave-exclusive: StallEventSaveGame (the manual save/quicksave writer) never constructs this struct at all, since there is no in-flight transition for a manual save to preserve.

Absent-value behaviour

What the engine holds when a label is missing. The list menu reads a slot’s savenfo.res through a single function, CSWGuiSaveLoadEntry::LoadData (0x006c8e50), which is also the only reader of LASTMODULE anywhere in the binary, so there is no second, restore-specific reader with its own defaults to reconcile against. Nor is there a display layer between the read and the screen: every label this function touches is written straight into a member of the list entry before it returns, so what the entry holds is what the row shows. That covers AREANAME, LASTMODULE, TIMEPLAYED, SAVEGAMENAME, CHEATUSED, REBOOTAUTOSAVE, PCAUTOSAVE, SCREENSHOT, GAMEPLAYHINT, STORYHINT, LIVECONTENT, LIVE1-LIVE6 and PORTRAIT0-PORTRAIT2, every label this page attributes to the list-UI read, with nothing parked in a local for a later formatting step to pick up. Provenance: derived, not attested.

Grouped by shape, since the shape is the useful part:

Read with a literal empty-string or zero default. AREANAME, LASTMODULE, SCREENSHOT (all CExoString, default ""); TIMEPLAYED, CHEATUSED, LIVECONTENT, PCAUTOSAVE, REBOOTAUTOSAVE (default 0). REBOOTAUTOSAVE specifically: this confirms the existing note above. The field has no writer anywhere in this build, and now the read side is traced too, so it doesn’t merely trend toward 0 in practice, it is contractually 0 on every load.

SAVEGAMENAME is a literal-default read overridden by a second check. The CResGFF read itself uses the ordinary "" default, but LoadData inspects the read’s own found-flag afterward, and a genuinely absent label takes the branch that assigns the literal "Old Save Game" into the entry’s own name member. It goes in through the same assignment every other field in this function uses for its read result, so the substitute is the value the loaded entry carries rather than a flourish the list row adds on top. Absent and present-but-empty are two different states with two different answers: a missing label reads back as "Old Save Game", an empty one reads back empty.

GAMEPLAYHINT and STORYHINT are pure skips. Both are read with this->gameplayhint / this->storyhint as their own default argument, so an absent field leaves whatever the destination object already held rather than substituting a fixed value. On the one call site that matters (a freshly constructed list entry), that resolves to 0, but the mechanism is “unchanged”, not “zero”.

PORTRAIT0-PORTRAIT2 only, not one per member. LoadData reads exactly three portrait slots (PORTRAIT0 through PORTRAIT2) regardless of actual party size, each defaulting to an empty ResRef when absent. The field-series description above describes the write side, which does emit one per active member; the list-UI read side is capped at three.

LIVE1-LIVE6 are read, but the read almost never matters. The whole loop is gated behind LIVECONTENT != 0, so when LIVECONTENT is absent or 0, the case for every real PC save per the note above, none of the six labels are read at all, whatever the file carries under them.

When LIVECONTENT is nonzero each label is read and the value discarded immediately, unless the matching content alias fails to resolve on the current install. Then the read value survives just long enough to name the missing content in an error path, and the slot is flagged unavailable.

AUTOSAVEPARAMS absent means the whole struct is skipped, not defaulted field-by-field. LoadData never reads this field at all. It is consumed only by the autosave continuation path (CSWGuiSaveLoad::LoadPCAutoSave), which checks for the struct’s presence before calling KOTOR_AUTOSAVE_PARAMS::LoadFromGFF at all. When the struct is missing, that call is skipped entirely and the KOTOR_AUTOSAVE_PARAMS object keeps whatever its constructor set. See the AUTOSAVEPARAMS section below, since that is the same state a whole-struct absence and every individual nested default converge on.

AUTOSAVEPARAMS and STATUSSUMMARY absent-value behaviour

Traced from KOTOR_AUTOSAVE_PARAMS::LoadFromGFF (0x006c9de0), its constructor (0x004b2840), the constructor’s own Reset() (0x004b1400), and CStatusSummary::LoadFromGFF (0x006c8490).

  • LOADMUSIC, STARTWAYPOINT, MOVIE1-MOVIE6: pure skips, each read with this->fieldname as its own default. On the one traced call site, the object was just constructed and Reset() (which explicitly sets all seven to "") ran immediately beforehand, so in practice an absent label reads as empty. But the mechanism is “unchanged”, the same shape as GAMEPLAYHINT/STORYHINT above, and would preserve a different value if some other caller ever populated the object first.
  • TIME_YEAR, TIME_MONTH, TIME_DAY, TIME_HOUR, TIME_MINUTE, TIME_SECOND, TIME_MILLISECOND, TIME_PAUSEDAY, TIME_PAUSETIME: all nine read with a literal 0 default, a true fixed default rather than a skip, unlike their string siblings above.
  • STATUSSUMMARY itself: read as a nested struct lookup (GetStructFromStruct) gating a call to CStatusSummary::LoadFromGFF. When the struct is absent, that call never happens and every one of its fifteen fields keeps whatever the constructor’s zeroing loop set (all zero/false, part of the same Reset() that also zeroes the TIME_* fields).
  • Every STATUSSUMMARY field, when the struct is present: CREDITS, XP, STEALTHXP, CREDITSNET, LIGHTSHIFT, DARKSHIFT, DISPLAYSPENDING, ITEMRECEIVED, ITEMLOST, JOURNAL, SOUNDPENDING, LEVELUPSOUND, NEWQUESTSOUND, COMPLETESOUND and SUPPRESSED are pure skips without exception, each read with its own current value as the default. No STATUSSUMMARY field has a fixed literal default; every one is “leave it as it was” when absent.

Implemented Linter Rules (Rakata-Lint)

None yet. Documented here ahead of any dedicated rakata-lint rules.