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

IFO Format (Module Info Blueprint)

Description: The Module Info (.ifo) is the absolute root metadata file for any environment. It dictates global module behavior, handling everything from the starting spawn location, to the local calendar and time-of-day progression, to script execution for global module events.

At a Glance

PropertyValue
Extension(s).ifo
Magic SignatureIFO / V3.2
TypeModule Blueprint
Rust ReferenceView rakata_generics::Ifo in Rustdocs

Data Model Structure

Rakata maps a Module Info into the rakata_generics::Ifo struct. The struct’s Rustdocs document every field’s binary schema and GFF mapping; the table below is the high-level anatomy.

CategoryCoversRepresentative fields
Module IdentityThe module’s tag, localized name, and descriptionMod_Tag, Mod_Name, Mod_Description
Entry PointThe spawn area, position, and facing used on module entryMod_Entry_Area, Mod_Entry_X, Mod_Entry_Dir_X
Time & CalendarDay/night pacing and the module’s starting clockMod_MinPerHour, Mod_DawnHour, Mod_StartYear
Global Event ScriptsThe 15 module-wide event hooksMod_OnModLoad, Mod_OnClientEntr, Mod_OnHeartbeat
Area & Cutscene RostersThe areas belonging to the module, plus cutscene and expansion metadataMod_Area_list, Mod_CutSceneList
Save-Only StateThe runtime snapshot a save adds: party roster, tokens, id allocators, and the live clockMod_PlayerList, Mod_Tokens, Mod_NextObjId0

rakata-lint validates these fields against the engine constraints documented below.

Engine Audits & Decompilation

The following documents the engine’s exact load sequence and field requirements for .ifo files mapped from swkotor.exe.

(Documented from Ghidra decompilation of swkotor.exe. Load path: CSWSModule::LoadModuleStart (0x004c9050). Save-side writers referenced below: SaveModuleFinish (0x004ca680) – which calls SaveModuleIFOStart (0x004c7050), the function that actually writes Mod_ID/Mod_Creator_ID/Mod_VersionSavePlayers (0x004c7870), SaveLimboCreatures (0x004c5bb0).)

Module Identity & Structural Rosters

These fields are written on every module save regardless of save-vs-fresh state – they aren’t part of the save-only state covered further down.

FieldTypeEngine Evaluation
Mod_IDVOID (variable length)Opaque, write-only round-trip data; see Mod_ID is inert, and the length split is incidental below. Written unconditionally on every save, whether resuming or freshly starting a module.
Mod_Creator_IDINTWritten unconditionally alongside Mod_ID.
Mod_VersionDWORDWritten unconditionally alongside Mod_Creator_ID.
Mod_IsSaveGameBYTEDefaults to false when absent, carried over from the object’s own constructed value (which the constructor itself sets to 0 immediately before the read runs) rather than a separately-chosen literal.
Mod_IsNWMFileBYTESame carry-over mechanism as Mod_IsSaveGame: constructor sets false first, absence leaves it there.
Mod_NWMResNameCExoStringOnly read at all if the resolved Mod_IsNWMFile (from the read above, present or defaulted) is true – if false, this field is never touched regardless of what the file contains. When the gate is open and the field itself is absent, it carries over the object’s constructed empty string, the same nested pattern as Mod_IsNWMFile gating Mod_NWMResName’s read.
Mod_TagCExoStringDefaults to a literal empty string if missing (not carried over – the read’s own default is a fresh empty string, independent of whatever the constructor set). The result always passes through SetTag, which lowercases it, so tags land lowercase whether read from the file or defaulted.
Mod_NameLocalizedStringDefaults to an empty localized string if missing, unconditional.
Mod_DescriptionLocalizedStringSame as Mod_Name: empty localized string if missing, unconditional.
Mod_Expan_ListList of StructExpansion pack metadata (Expansion_Name, Expansion_ID per entry). Always written, though the list may legitimately be empty. Each entry is freshly allocated and both fields are unconditional literal stamps if absent: Expansion_Name to an empty localized string, Expansion_ID to 0.
Mod_CutSceneListList of StructCutscene name/id pairs (CutScene_Name, CutScene_ID per entry). Always written, though the list may legitimately be empty. Same shape as Mod_Expan_List: CutScene_Name defaults to an empty resref, CutScene_ID to 0, both unconditional.

Mod_ID is inert, and the length split is incidental

A vanilla module’s own .ifo (as shipped inside a .mod archive) carries a 16-byte Mod_ID. Every save-game’s bundled module.ifo carries 32 bytes. This isn’t two encodings of one concept, or a save-only extension of the field with meaningful extra data – it’s an artifact of how the engine reads and re-writes an opaque blob it never interprets.

LoadModuleStart reads Mod_ID into a fixed 32-byte destination inside the module object, using a read call capped at 32 bytes regardless of how long the field actually is. A shorter field, like a vanilla module’s 16 bytes, only overwrites the first 16 bytes of that destination – the read does not zero-pad the rest. The destination buffer itself is allocated without any zero-initialization, so whatever the remaining 16 bytes held at allocation time (uninitialized heap memory) is what stays there. Mod_Creator_ID and Mod_Version live immediately adjacent to Mod_ID in this same allocation, purely as a memory-layout convenience; they are distinct GFF fields, not part of Mod_ID itself.

On save, the writer always emits exactly 32 bytes from that same in-memory buffer, unconditionally, regardless of how many of those bytes came from the original file versus leftover heap contents. That is the entire explanation for the observed split: a vanilla .ifo’s 16-byte Mod_ID, once loaded and saved even once, becomes a 32-byte field whose upper half is incidental garbage, not a second logical sub-field with any meaning.

Nothing else in the engine ever reads Mod_ID back out. There is no comparison against the target module’s own file, no hash check, no “does this save belong to this module” validation anywhere – confirmed by an exhaustive check of every reference to the field and the buffer it’s read into. It’s pure write-only round-trip data. Because it’s inert, a tool rewriting module info does not need to preserve its bytes for correctness. But matching the engine’s own behavior exactly means treating it as a genuinely variable-length blob on read, and not attempting to reconstruct a fixed 32-byte shape on write – the engine’s own 32-byte output for a resaved vanilla module is a side effect of an uninitialized buffer, not a format requirement.

Global State Configurations

FieldTypeEngine Evaluation
Mod_Entry_AreaResRefThe primary spawning area ResRef.
Mod_Entry_X / Mod_Entry_Y / Mod_Entry_ZFLOATExact spawning XYZ coordinates.
Mod_Entry_Dir_X / Mod_Entry_Dir_YFLOATEntry Direction Fallback: If Mod_Entry_Dir_Y is absent from the GFF, the engine forces a fallback facing of (X=1.0, Y=0.0).
Mod_XPScaleBYTEModule XP scale, default 10. The K1 engine reads this field and writes it back on save, but never consumes it: nothing in the XP award path multiplies by it. It is inert in swkotor.exe.
Mod_StartMovieResRefRead on module load with a constant empty-ResRef default. A binary-wide search turns up exactly one reference to the Mod_StartMovie label in the whole engine, the read inside LoadModuleStart itself; no write exists anywhere in swkotor.exe. This field is load-only, full stop.

Time & Cycle Management

FieldTypeDescription
Mod_DawnHourBYTEDawn hour integer marker. Defaults to 0 if missing – a plain literal, not the object’s constructed value (the constructor doesn’t initialize this field to a meaningful hour before the read runs).
Mod_DuskHourBYTEDusk hour integer marker. Defaults to 0 if missing, same as Mod_DawnHour.
Mod_MinPerHourBYTEConfiguration for exactly how many real-time active gameplay minutes constitute a module hour limit. Defaults to 0 if missing.

Note

Day/Night Cycle Computations The engine continuously computes localized day/night phases explicitly against Mod_DawnHour, Mod_DuskHour, and the current_hour. This dynamically updates an internal state flag denoting: 1=Day, 2=Night, 3=Dawn, 4=Dusk.

Warning

Mod_MinPerHour/Mod_DawnHour/Mod_DuskHour doc comments in rakata’s own code are wrong. crates/rakata-generics/src/ifo.rs currently claims these three default to 2, 6, and 18 respectively. None of that is true against the binary – all three read with a literal default of 0, confirmed directly. The code’s actual behavior (.unwrap_or(0) and the Default impl) already matches the engine; only the doc-comment prose is stale and should be corrected to stop asserting values the implementation doesn’t use.

Global Event Scripts

Each event is a single ResRef field naming a compiled script (.ncs) the engine fires when that event occurs. K1 defines 15 module events:

FieldFires when
Mod_OnModLoadthe module is loaded
Mod_OnModStartthe module starts (first client entry)
Mod_OnClientEntra player enters the module
Mod_OnClientLeava player leaves the module
Mod_OnHeartbeatthe module heartbeat ticks
Mod_OnUsrDefineda user-defined event is signalled
Mod_OnAcquirIteman item is acquired
Mod_OnUnAqreIteman item is unacquired (dropped or removed)
Mod_OnActvtIteman item is activated
Mod_OnEquipIteman item is equipped
Mod_OnPlrDeatha player dies
Mod_OnPlrDyinga player drops to dying
Mod_OnPlrLvlUpa player levels up
Mod_OnPlrResta player rests
Mod_OnSpawnBtnDna respawn is requested (a multiplayer-era Aurora event)
  • Asymmetric I/O (equipping). Mod_OnEquipItem is read during module startup (LoadModuleStart), but SaveModuleIFOStart never writes it back out, so a save-game round-trip silently drops it. In the binary its label sits apart from the other fourteen (which are stored contiguously), matching the one-off handling.
  • Absent-field default, all 15. Every script hook, Mod_OnEquipItem included, follows one uniform pattern: read with a locally-constructed empty resref as the default and unconditionally stamped into the module’s script table, no presence check consulted afterward. Mod_OnEquipItem’s asymmetry above is entirely a write-side omission – on the read side it’s handled identically to its 14 siblings.

Note

NWM = NeverWinter Module. Mod_IsNWMFile marks a module as a .nwm-type module, a format the Odyssey engine inherited from BioWare’s Aurora engine (the one behind Neverwinter Nights). When the flag is set, the engine pairs it with Mod_NWMResName and skips re-saving the area ARE static into the module’s save ERF. The skip is narrow: SaveModuleFinish gates the ARE static write behind is_nwm_file == 0, while the GIT is written unconditionally in SaveModuleInProgress. So an NWM save still gets its dynamic GIT, just not a re-copied static ARE .

Safe-State Injection (Save Games Only)

Certain blocks of data inside the .ifo are deliberately evaluated only when the engine is mounting a module directly from a loaded .sav archive block.

Note

No list in this format drops a partially-specified entry. Across Mod_Area_list, Mod_Expan_List, Mod_CutSceneList, Mod_PlayerList, and Mod_Tokens, a struct entry missing one or more of its fields is always kept – each missing field is independently defaulted to a literal (never carried over from a prior entry or a constructed value), and the entry itself is still appended. The only thing that can abort mid-list is a heap-allocation failure, which is an out-of-memory condition that terminates the entire LoadModuleStart call, not a per-entry skip.

Engine TargetDescription
Player / Mod VariablesStructures like Mod_PlayerList, Mod_Tokens, VarTable, and the EventQueue are strictly bypassed unless natively evaluated under is_save_game conditions.
Player List StructureMod_PlayerList (written by SavePlayers) holds one struct per party member: Mod_CommntyName, Mod_IsPrimaryPlr (BYTE), Mod_FirstName / Mod_LastName (localized), ObjectId, plus the member’s full creature serialization (SaveCreature). Members not present in the active area are carried forward from the previous module’s Mod_PlayerList rather than re-derived, so the roster persists across module transitions – that carry-forward happens at the level of which members get rebuilt into the roster at all, not within an individual entry’s own field reads. Within an entry that IS being (re)built, each node is placement-constructed empty first, so every field is an unconditional literal stamp on top of that: Mod_CommntyName defaults to an empty string, Mod_FirstName/Mod_LastName to empty localized strings, Mod_IsPrimaryPlr to 0 (not primary).
Area OverridesThe Mod_Area_list technically supports arrays (for NWN legacy), but KOTOR strictly enforces a single active area boundary: the loader only ever takes element 0, reading Area_Name directly (unconditional empty-ResRef default) rather than through a per-entry loop. The secondary ObjectId within this specific array is only ever read natively inside a save state flow (gated behind the same Mod_IsSaveGame flag), and when absent it defaults to 0x7F000000 (OBJECT_INVALID) – the same engine-wide default used everywhere else ObjectId is read, not the plain 0 this codebase currently uses for it.
Legacy Hak De-sync“Hak Packs” are custom override archives natively used in Neverwinter Nights (the engine’s predecessor). While KOTOR’s save routine (SaveModuleIFOStart) blindly writes a Mod_Hak string into save-games as leftover legacy behavior, the actual load cycle (LoadModuleStart) completely ignores it. Modders cannot use this field to hook custom archives.
Runtime ID CountersA save persists the engine’s id allocators so a resumed session keeps handing out fresh ids without collision: Mod_NextCharId0 / Mod_NextCharId1, Mod_NextObjId0 / Mod_NextObjId1, and Mod_Effect_NxtId. These are meaningless in a static module and are written only by SaveModuleIFOStart. They also don’t write onto the module object at all. Mod_NextCharId0/1 and Mod_NextObjId0/1 write into fixed offsets on the engine’s own shared, global object-id allocator (fetched via CServerExoApp::GetObjectArray), and Mod_Effect_NxtId writes a genuine global symbol – neither is a per-CSWSModule field. All five read with a literal 0 default when absent (not carried over), gated behind the same Mod_IsSaveGame check documented above (both instances of the check in the decompiled function test the identical condition, most likely an inlining artifact rather than two independent gates). Because the counters they populate are engine-wide, not per-module, an absent id-counter label on a save load doesn’t just leave one module’s bookkeeping at a placeholder – it zeroes the live, shared id allocator mid-load, a materially larger blast radius than an ordinary per-module default gap.
Live Clock SnapshotBeyond the authored Mod_StartYear / Month / Day / Hour, a save records the exact live time of day (Mod_StartMinute, Mod_StartSecond, Mod_StartMiliSec), the paused world clock (Mod_PauseDay, Mod_PauseTime), and Mod_Transition, so the world time resumes where it left off rather than at the module’s start time. This block’s real gate is narrower than “is a save game.” LoadModuleStart takes a separate parameter set by the client specifically for “the player picked Load Game,” independent of the Mod_IsSaveGame GFF field that gates the ID-counter/player-list block above – in practice the two agree, but they’re structurally two different conditions. When that load-game parameter is set, all ten calendar fields read with their own literal defaults (Mod_StartYear 1340, Mod_StartMonth 6, Mod_StartDay 1, Mod_StartHour 23, Mod_StartMinute/Mod_StartSecond/Mod_StartMiliSec/Mod_Transition/Mod_PauseTime/Mod_PauseDay all 0), unconditional. When it’s clear – an ordinary module-to-module transition within a running session, not a save load – none of the ten fields are read from the GFF at all, and no hardcoded default applies either; the engine instead carries the live clock forward from the previous module’s own state (CServerExoApp::GetMoveToWorldTime and its paused-time/day counterparts, snapshotted just before the transition). So “the default” for these ten fields only exists as a concept on a genuine save-game load; on any other module entry, the values are session-carried, not file-derived or defaulted.
Start Time Naming AsymmetryMod_StartMonth, Mod_StartDay, and Mod_StartHour have a reader/writer naming mismatch: the save writer sources these values from wherever gameplay time currently stands (the module’s “current” month/day/hour), while the loader treats them as “start” values on the next load. Not a bug, just an asymmetric naming convention worth knowing if you’re implementing a compatible writer.
Custom TLK TokensMod_Tokens holds runtime overrides for custom TLK string tokens. Only entries with a token index greater than 9 are ever written back out – indices 0-9 are reserved/built-in and never re-emitted. Each entry carries Mod_TokensNumber (the token index, defaults to 0) and Mod_TokensValue (the replacement string, defaults to empty), both unconditional literal stamps, and both are applied unconditionally to the live token table regardless of what the file actually supplied. That load-side symmetry creates a real hazard the write-side reservation doesn’t protect against: SetCustomToken, the function that installs each entry, does a plain indexed insert/update with no reserved-range check at all. A Mod_Tokens entry with an absent Mod_TokensNumber defaults to index 0 and gets installed there without complaint, silently overwriting slot 0 – a slot the save writer itself treats as reserved and never re-emits. The protection only exists on the write side; the loader will happily accept and act on a hand-authored or corrupted entry that lands on a reserved index.
Limbo CreaturesCreatures held in limbo (party members not in the active area) are serialized by a separate pass (SaveLimboCreatures) into the module IFO itself, each as an ObjectId plus a full SaveCreature blob. This list reuses the label Creature List, the same label the GIT uses for area creatures, so the two share a name but live in different containers (the IFO top level versus the area GIT).

More Confirmed-Dead NWN Residue: Mod_VO_ID, Expansion_Pack, Mod_GVar_List

A corpus scan across 117 real module.ifo files (vanilla and save-bundled) turned up three more fields alongside the already-documented Mod_Hak/Mod_IsNWMFile legacy residue, none of which the engine reads or writes anywhere. Confirmed by the same decisive test used elsewhere in this codebase: none of the three field-name strings exist anywhere in swkotor.exe at all, which settles it outright – GFF field access here is string-literal lookup, so a label the binary never spells out can’t be read by LoadModuleStart or written by SaveModuleIFOStart, full stop. There’s no read call site to trace and no absent-field default to give, because there’s no read at all in any of the three cases.

  • Mod_VO_ID (CExoString) is the most notable of the three: it carries a value in 98 of the 117 files scanned, the highest live-value density of any unmodeled field found in this codebase’s completeness audits, yet the engine never reads it under any code path. It shares the exact fate of .uti’s already-confirmed-dead VO_ID field: both look like an authoring-side voice-over production lookup key, and neither has a runtime consumer in K1. Model it as dead/toolset-only rather than a live field with a meaningful default.
  • Expansion_Pack (WORD) is present in every file and always 0. This is NWN/Aurora expansion-selector residue, consistent with the Mod_IsNWMFile/Mod_Hak precedents on this same format – a real, unrelated Expansion_ID/Expansion_Name pair exists in the binary (used by Mod_Expan_List, already modeled), but Expansion_Pack itself is a different, unread label.
  • Mod_GVar_List (List) is present in every file and always empty. K1’s actual campaign-global mechanism lives exclusively in the save-scoped GLOBALVARS.res (GVT ), a completely separate system with no code path connecting it back to this module-scoped field. Mod_GVar_List is NWN module-format residue that happens to share vocabulary with the real mechanism, nothing more.

Implemented Linter Rules (Rakata-Lint)

Phase 1 (intra-resource, no context)

Implemented under rakata_lint::rules::ifo.

  1. IFO-001 (Direction Fallback): Warns when Mod_Entry_Dir_X and Mod_Entry_Dir_Y are both 0.0. The engine substitutes a hard fallback heading of (1.0, 0.0) only when Mod_Entry_Dir_Y is absent from the GFF; a value that is present but (0.0, 0.0) is left as a degenerate heading with no facing.
  2. IFO-002 (XP Dead-Scaling): Warns when Mod_XPScale == 0. Caveat: a Ghidra trace of K1 shows the engine parses Mod_XPScale but never applies it to awarded XP (the field is inert in swkotor.exe), so a zero has no in-engine effect in K1. The rule only matters if the value is meaningful to another tool.
  3. IFO-003 (Eternal Day/Night Bounds): Warns when Mod_DawnHour == Mod_DuskHour. When the two are equal the engine skips the entire dawn/dusk/night computation and locks the phase to 1 (Day), so the module is stuck in perpetual daylight.
  4. IFO-004 (Void Area Initialization): Errors when Mod_Area_list is empty; directly faults the load cycle.
  5. IFO-005 (Dangling NWM Structure): Warns when Mod_IsNWMFile=true without Mod_NWMResName; evaluates to an unstable execution state.

Phase 2 (resource existence, requires LintContext)

Implemented under rakata_lint::rules::ifo_range.

  1. IFO-006 (Resref Existence): Warns when Mod_Entry_Area (.are), any Mod_Area_list[i].Area_Name (.are), or any of the 15 Mod_On* script hooks (.ncs) does not resolve in the configured resource sources.

Pending

  • Mod_StartMovie (.bik): No ResourceTypeCode variant for the Bink movie format yet.
  • Mod_CutSceneList[i].CutScene_Name: Engine resolution is .dlg or .bik depending on context (audit deferred).