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
| Property | Value |
|---|---|
| Extension(s) | .ifo |
| Magic Signature | IFO / V3.2 |
| Type | Module Blueprint |
| Rust Reference | View 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.
| Category | Covers | Representative fields |
|---|---|---|
| Module Identity | The module’s tag, localized name, and description | Mod_Tag, Mod_Name, Mod_Description |
| Entry Point | The spawn area, position, and facing used on module entry | Mod_Entry_Area, Mod_Entry_X, Mod_Entry_Dir_X |
| Time & Calendar | Day/night pacing and the module’s starting clock | Mod_MinPerHour, Mod_DawnHour, Mod_StartYear |
| Global Event Scripts | The 15 module-wide event hooks | Mod_OnModLoad, Mod_OnClientEntr, Mod_OnHeartbeat |
| Area & Cutscene Rosters | The areas belonging to the module, plus cutscene and expansion metadata | Mod_Area_list, Mod_CutSceneList |
| Save-Only State | The runtime snapshot a save adds: party roster, tokens, id allocators, and the live clock | Mod_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_Version – SavePlayers (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.
| Field | Type | Engine Evaluation |
|---|---|---|
Mod_ID | VOID (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_ID | INT | Written unconditionally alongside Mod_ID. |
Mod_Version | DWORD | Written unconditionally alongside Mod_Creator_ID. |
Mod_IsSaveGame | BYTE | Defaults 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_IsNWMFile | BYTE | Same carry-over mechanism as Mod_IsSaveGame: constructor sets false first, absence leaves it there. |
Mod_NWMResName | CExoString | Only 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_Tag | CExoString | Defaults 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_Name | LocalizedString | Defaults to an empty localized string if missing, unconditional. |
Mod_Description | LocalizedString | Same as Mod_Name: empty localized string if missing, unconditional. |
Mod_Expan_List | List of Struct | Expansion 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_CutSceneList | List of Struct | Cutscene 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
| Field | Type | Engine Evaluation |
|---|---|---|
Mod_Entry_Area | ResRef | The primary spawning area ResRef. |
Mod_Entry_X / Mod_Entry_Y / Mod_Entry_Z | FLOAT | Exact spawning XYZ coordinates. |
Mod_Entry_Dir_X / Mod_Entry_Dir_Y | FLOAT | Entry 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_XPScale | BYTE | Module 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_StartMovie | ResRef | Read 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
| Field | Type | Description |
|---|---|---|
Mod_DawnHour | BYTE | Dawn 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_DuskHour | BYTE | Dusk hour integer marker. Defaults to 0 if missing, same as Mod_DawnHour. |
Mod_MinPerHour | BYTE | Configuration 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 thecurrent_hour. This dynamically updates an internal state flag denoting:1=Day,2=Night,3=Dawn,4=Dusk.
Warning
Mod_MinPerHour/Mod_DawnHour/Mod_DuskHourdoc comments in rakata’s own code are wrong.crates/rakata-generics/src/ifo.rscurrently claims these three default to2,6, and18respectively. None of that is true against the binary – all three read with a literal default of0, confirmed directly. The code’s actual behavior (.unwrap_or(0)and theDefaultimpl) 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:
| Field | Fires when |
|---|---|
Mod_OnModLoad | the module is loaded |
Mod_OnModStart | the module starts (first client entry) |
Mod_OnClientEntr | a player enters the module |
Mod_OnClientLeav | a player leaves the module |
Mod_OnHeartbeat | the module heartbeat ticks |
Mod_OnUsrDefined | a user-defined event is signalled |
Mod_OnAcquirItem | an item is acquired |
Mod_OnUnAqreItem | an item is unacquired (dropped or removed) |
Mod_OnActvtItem | an item is activated |
Mod_OnEquipItem | an item is equipped |
Mod_OnPlrDeath | a player dies |
Mod_OnPlrDying | a player drops to dying |
Mod_OnPlrLvlUp | a player levels up |
Mod_OnPlrRest | a player rests |
Mod_OnSpawnBtnDn | a respawn is requested (a multiplayer-era Aurora event) |
- Asymmetric I/O (equipping).
Mod_OnEquipItemis read during module startup (LoadModuleStart), butSaveModuleIFOStartnever 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_OnEquipItemincluded, 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_IsNWMFilemarks 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 withMod_NWMResNameand skips re-saving the areaAREstatic into the module’s save ERF. The skip is narrow:SaveModuleFinishgates theAREstatic write behindis_nwm_file == 0, while theGITis written unconditionally inSaveModuleInProgress. So an NWM save still gets its dynamicGIT, just not a re-copied staticARE.
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, andMod_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 entireLoadModuleStartcall, not a per-entry skip.
| Engine Target | Description |
|---|---|
| Player / Mod Variables | Structures like Mod_PlayerList, Mod_Tokens, VarTable, and the EventQueue are strictly bypassed unless natively evaluated under is_save_game conditions. |
| Player List Structure | Mod_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 Overrides | The 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 Counters | A 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 Snapshot | Beyond 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 Asymmetry | Mod_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 Tokens | Mod_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 Creatures | Creatures 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-deadVO_IDfield: 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 always0. This is NWN/Aurora expansion-selector residue, consistent with theMod_IsNWMFile/Mod_Hakprecedents on this same format – a real, unrelatedExpansion_ID/Expansion_Namepair exists in the binary (used byMod_Expan_List, already modeled), butExpansion_Packitself 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-scopedGLOBALVARS.res(GVT), a completely separate system with no code path connecting it back to this module-scoped field.Mod_GVar_Listis 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.
- IFO-001 (Direction Fallback): Warns when
Mod_Entry_Dir_XandMod_Entry_Dir_Yare both0.0. The engine substitutes a hard fallback heading of(1.0, 0.0)only whenMod_Entry_Dir_Yis absent from the GFF; a value that is present but(0.0, 0.0)is left as a degenerate heading with no facing. - IFO-002 (XP Dead-Scaling): Warns when
Mod_XPScale == 0. Caveat: a Ghidra trace of K1 shows the engine parsesMod_XPScalebut never applies it to awarded XP (the field is inert inswkotor.exe), so a zero has no in-engine effect in K1. The rule only matters if the value is meaningful to another tool. - 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 to1(Day), so the module is stuck in perpetual daylight. - IFO-004 (Void Area Initialization): Errors when
Mod_Area_listis empty; directly faults the load cycle. - IFO-005 (Dangling NWM Structure): Warns when
Mod_IsNWMFile=truewithoutMod_NWMResName; evaluates to an unstable execution state.
Phase 2 (resource existence, requires LintContext)
Implemented under rakata_lint::rules::ifo_range.
- IFO-006 (Resref Existence): Warns when
Mod_Entry_Area(.are), anyMod_Area_list[i].Area_Name(.are), or any of the 15Mod_On*script hooks (.ncs) does not resolve in the configured resource sources.
Pending
- Mod_StartMovie (.bik): No
ResourceTypeCodevariant for the Bink movie format yet. - Mod_CutSceneList[i].CutScene_Name: Engine resolution is .dlg or .bik depending on context (audit deferred).