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
| Property | Value |
|---|---|
| Filename | savenfo.res |
| Magic Signature | NFO / V3.2 |
| Type | Save Metadata Block |
| Rust Reference | Handled 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.
| Field | Type | Meaning | Save type |
|---|---|---|---|
SAVEGAMENAME | CExoString | Display name of the save, capped at 16 characters. Empty where the player never typed one; see below. | manual |
AREANAME | CExoString | Localized display name of the current area. | both |
LASTMODULE | CExoString | Resref of the module the engine restores first. | both |
TIMEPLAYED | DWORD | Running play time, in seconds. | both |
CHEATUSED | BYTE | Cheat flag; mirrors the party table’s cheat state. | both |
GAMEPLAYHINT | BYTE | Loading-screen hint state. | both |
STORYHINT | BYTE | Loading-screen hint state. | both |
LIVE1 .. LIVE6 | CExoString | Downloadable-content slot names (six); empty on a vanilla PC install. | manual |
LIVECONTENT | BYTE | Bitmask of which of the six LIVE%d slots are installed; 0 when none. | manual |
PORTRAIT0 .. PORTRAITN | CResRef | One portrait resref per active party member. | both |
PCAUTOSAVE | BYTE | Always 1; its presence marks the file as an autosave. | autosave |
REBOOTAUTOSAVE | BYTE | Read by the load menu’s slot parser, but no write site exists anywhere in this build; see the note below. | none (dead on PC) |
SCREENSHOT | CExoString | Loading-screen resref (load_<module>) used as the slot preview, in place of a Screen.tga. | autosave |
AUTOSAVEPARAMS | Struct | Pending 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.
CHEATUSEDis 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-LIVE6name fields plus theLIVECONTENTbitmask unconditionally, whether or not content is installed. This is the Xbox Live download mechanism (LIVE%daliases resolving toRIMSXBOX\live%dRIMs). A vanilla PC install has nothing aliased in, so a real quicksave carries all sixLIVE%das empty strings andLIVECONTENT = 0(verified against a K1 GOG save). The Yavin Station DLC on PC is not delivered through this path.REBOOTAUTOSAVEis read unconditionally by the slot parser and folded into the same bit-field asPCAUTOSAVE, but no code path in this build ever writes it, sinceDoPCAutosaveonly setsPCAUTOSAVE. The read path is live regardless: the save-list preview code checks this bit together withPCAUTOSAVEwhen it decides where a slot’s screenshot comes from, soREBOOTAUTOSAVEjust stays permanently0. 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
LASTMODULEis what selects the first module to restore.
Important
Two write paths, two field sets.
savenfocomes fromStallEventSaveGame(manual saves and quicksaves) orDoPCAutosave(autosaves); the Save type column above marks which fields each produces.PCAUTOSAVEis the reliable discriminator. Autosaves also differ at the folder level (a loosepifo.ifo, noScreen.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.
| Field | Type | Source |
|---|---|---|
LOADMUSIC | CExoString | The 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. |
STARTWAYPOINT | CExoString | The 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 .. MOVIE6 | CExoString | Drained 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_YEAR | DWORD | The 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_HOUR | BYTE | The module’s live calendar fields (current_month/current_day/current_hour), read through the module’s own time accessor. |
TIME_MINUTE / TIME_SECOND / TIME_MILLISECOND | WORD | Not 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_PAUSETIME | DWORD | The 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. |
STATUSSUMMARY | Struct | See below. |
STATUSSUMMARY is a “since you last saw a loading screen” delta accumulator, not a snapshot of current totals:
| Field | Type | Behaviour |
|---|---|---|
CREDITS / XP / STEALTHXP | INT | Running totals added to by every credit/XP/stealth-XP gain since the popup last displayed. |
CREDITSNET | BYTE | Set when credits moved in both directions since the last display (gained and lost), distinguishing a net change from a one-way one. |
LIGHTSHIFT / DARKSHIFT | BYTE | Alignment-shift deltas, same accumulate-then-drain pattern. |
DISPLAYSPENDING / ITEMRECEIVED / ITEMLOST / JOURNAL | BYTE | Pending-event flags set by the corresponding gameplay hooks (item give/take, journal updates). |
SOUNDPENDING / LEVELUPSOUND / NEWQUESTSOUND / COMPLETESOUND | BYTE | Selects which stinger, if any, plays alongside the popup. |
SUPPRESSED | INT | A 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 withthis->fieldnameas its own default. On the one traced call site, the object was just constructed andReset()(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 asGAMEPLAYHINT/STORYHINTabove, 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 literal0default, a true fixed default rather than a skip, unlike their string siblings above.STATUSSUMMARYitself: read as a nested struct lookup (GetStructFromStruct) gating a call toCStatusSummary::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 sameReset()that also zeroes theTIME_*fields).- Every
STATUSSUMMARYfield, when the struct is present:CREDITS,XP,STEALTHXP,CREDITSNET,LIGHTSHIFT,DARKSHIFT,DISPLAYSPENDING,ITEMRECEIVED,ITEMLOST,JOURNAL,SOUNDPENDING,LEVELUPSOUND,NEWQUESTSOUND,COMPLETESOUNDandSUPPRESSEDare pure skips without exception, each read with its own current value as the default. NoSTATUSSUMMARYfield 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.