Resource System & Resolution
Every asset in the game is fetched by name, never by path. This page is about what happens between a name and the bytes.
There are four layers, and they stack. Knowing which one you are in answers most questions:
| Layer | The question it answers |
|---|---|
| The name | What may a resource be called, and how are two names compared? |
| The archive search | Given a name, which archive answers first? |
| Module composition | A module is several archives. Which does the engine mount, and which wins? |
| Install-wide tiers | Override, module, save, base game. Which tier wins? |
A fifth exists in the engine and does nothing on PC: the downloadable content mounts.
ResRef: what a name is
A ResRef is a fixed 16-byte buffer, used everywhere a resource is named: KEY and BIF entries, RIM keys, GFF resref fields, save-game handles, network messages.
The engine validates nothing. Every constructor copies up to 16 bytes straight into the buffer. There is no character check, no rejection of odd bytes, and no ValidResRef helper anywhere in the binary, which also carries no “invalid resref” error string to print. Longer input is truncated at 16 bytes and the rest of the buffer is zero-filled. The empty constructor zeroes all 16, and a null source lands in the same place.
Nothing is encoding-aware either. The buffer is compared as raw bytes, so a name stored under a non-ASCII byte is found only by that exact byte.
What names are actually in use
The freedom above is wider than the content that uses it, and the real range is worth knowing before you write a validator.
Across every key entry in chitin.key, the module RIMs, the static ERFs and a save corpus, the only non-alphanumeric bytes that occur at all are _, -, + and !.
Three of those four turn up in script names and nowhere else:
| Byte | Where it occurs |
|---|---|
_ | Not restricted to scripts. The one separator general content uses |
- | Script names |
+ | Script names. A handful of chitin.key entries, all one script in compiled and source form, plus several dozen RIM entries such as k_pdan_state1+ |
! | Script names, in RIM key tables alone |
No texture resref in any of those populations carries punctuation beyond _.
How Rakata models it
rakata_core::ResRef takes any single ASCII byte up to 16 bytes and lowercases letters, matching the engine’s case-insensitive lookup. Multi-byte UTF-8 is rejected: Rust’s &str forces valid UTF-8, and a multi-byte sequence cannot stand for the single byte the engine would store under Windows-1252, so accepting it would corrupt the lookup key quietly.
The 16-byte cap and the case folding match the engine. The ASCII-only rule does not. It is a Rust-side guard, and vanilla content never tests it: no resref in any of those populations carries a byte above 0x7F or exceeds sixteen bytes.
Read from the CResRef::CResRef constructor family at 0x00405ed0, 0x00405ef0, 0x00406d60, 0x00406d80 and 0x00406da0, and the network path CSWMessage::ReadCResRef at 0x004d6180. Provenance: derived, not attested, so these sit on the reverse-engineering queue. The name-content measurement above is measured.
The search order inside a key table
Every archive the engine searches is a key table, and each table sits in one of four lists: directories (a folder of loose files), ERFs, RIMs, and the fixed archives. A lookup makes five passes over them and returns the first match, ignoring any duplicate deeper down:
| Pass | Tables | What’s in it |
|---|---|---|
| 1 | Directories | Override, and in KotOR the top level of streammusic\, streamwaves\ and movies\ |
| 2 | ERFs tagged 1 | the install-wide patch archives: patch.erf, and in KotOR OVERRIDE:textures |
| 3 | Every RIM | the module’s RIMs, rims\global.rim for the whole session, and rims\mainmenu.rim and rims\chargen.rim while their screens are up |
| 4 | ERFs tagged 2 | everything tied to a session: a module’s .mod, a save’s module state, the lips\ archives, KotOR’s players and KotOR II’s _dlg.erf, and below them the texture packs, which are mounted before any module |
| 5 | Fixed | chitin.key and its BIFs |
Within a pass, the table added last is searched first. Every new table goes to the head of its list, so inside one kind, mount order is the whole rule. The tag is a number an ERF is given when it’s added, and it’s why ERFs fall on both sides of the RIMs: a .mod loses to every RIM, the global ones included.
The order does not branch on resource type. There is no separate path for textures, or 2DAs, or anything else, so a RIM beats the base archives for every kind of resource alike. That has a consequence worth following: where an archive higher in this order ships its own copy of a base-game resource, that copy is what the game reads, and the base copy is never consulted. The 2DA page documents thirteen tables where the two copies are not identical.
Two archives carrying the same name both keep it. Nothing is settled when they’re mounted: the five passes decide on every lookup, so the archive added last wins. The one duplicate check, in AddKey, works inside a single table: an archive that lists the same name and type twice keeps the first entry and drops the second.
Three global RIMs, and only these. rims\global.rim is mounted when the client starts and stays for the whole session, as the oldest RIM, so a module’s own RIMs beat it and it beats the session ERFs and chitin.key. rims\mainmenu.rim mounts with the main menu and rims\chargen.rim with character generation, and each goes away shortly after its screen closes. Each mounts only if the file is there. The other RIMs a KotOR install ships in rims\, miniglobal, subglobal, legal and every dx twin, are never opened, and KotOR II’s rims\ is empty.
Read from CExoKeyTable::FindKey at 0x0040ec50, the five-pass GetKeyEntry that Exists, GetResObject and SetResObject share, and AddKeyTable. KotOR II’s GetKeyEntry (0x00407300) makes the same five passes in the same order. Provenance: traced. No per-resource-type branch exists on that path.
Stream and movie directories
Override is not the only directory the resource manager searches. StartServices registers HD0:STREAMWAVES, HD0:STREAMMUSIC and HD0:MOVIES through AddResourceDirectory, which adds a key table of the directory type, the same kind Override is. So a file at the top level of streamwaves\, streammusic\ or movies\ is a resource, keyed by its name and the type its extension maps to. streamsounds\ is not registered.
The listing behind each is one pass that does not descend into subfolders. The sharded voice folders under streamwaves\ are never indexed, so only the flat names there can be found as resources.
Override isn’t the first folder searched until a second game starts. Directories are searched newest first like every other list, and client and server share one set of lists. The client starts at launch and registers TEMPCLIENT:, OVERRIDE:, ERRORTEX:, RIMS:, SERVERVAULT:, PORTRAITS:, then movies\, streamwaves\ and streammusic\. Newest first, that puts streammusic\ ahead of streamwaves\, streamwaves\ ahead of movies\, and all of them ahead of Override on the first game. Each time a game is started or loaded after that, the server takes Override down and adds it back at the head, ahead of the stream, movie and RIMS: folders.
In practice only a .wav can be shadowed. A folder answers only for the files it holds, and in vanilla those are .wav at the top of the stream folders, .bik in movies\ and the RIMs themselves in rims\. So the one real collision is a flat-named .wav that’s also in Override: the stream folder’s copy wins on the first game after launch, and Override’s after that.
KotOR II registers none of the stream folders as resource directories. Its voice folder is StreamVoice\, not streamwaves\, and only TEMPCLIENT:, OVERRIDE:, RIMS: and HD0:MOVIES are registered as folders on its client.
Playback is a separate path. Streamed voice, ambient sound and music are opened by path and never come through these tables: see streamed audio.
Tip
In Rakata:
GameVfssearches the top level of KotOR’s three folders in the order of the first game after launch:streammusic\,streamwaves\,movies\, thenOverride. A KotOR II install gets none of them.
Read from StartServices and AddKeyTable, the server’s start and stop routines and CreateServer at 0x00401380, and KotOR II’s client start routine at 0x00643f70. Provenance: traced. The folder contents are measured on both vanilla installs.
TXI sidecars are looked up independently
A .txi is a small text file carrying material settings for a texture. The thing to know is that the engine does not fetch it alongside its texture. It goes and looks for it from scratch.
Every request routes through one helper, AurResGet(name, ".txi", ..., true), and three rendering paths use it: CAurTextureBasic::Init, Gob::EnableRenderBumpedOut and Material::Init. That helper knows only the name and the extension. It has no idea where the parent texture came from.
So the two can come from different places. A texture loaded out of a BIF can take its TXI from Override/, because nothing ties the sidecar to the parent’s source. Rakata keeps that behaviour in rakata_extract::GameVfs::resolve_texture_with_txi, which walks the tiers for the texture and then walks them again from the top for the sidecar.
Read from AurResGet at 0x0044c740. Provenance: derived, not attested.
Module composition
A module is an area and everything in it, and it ships as several archives rather than one. The engine doesn’t merge them. It mounts a fixed set in a fixed order, and the search order does the rest: RIMs beat session ERFs, and within each, the archive mounted last wins.
| # | Archive | Kind | Mounted when |
|---|---|---|---|
| 1 | players at the install root, KotOR only | session ERF | always tried, and a missing file is ignored |
| 2 | lips\<root>_loc.mod | session ERF | always tried |
| 3 | lips\localization.mod | session ERF | always tried |
| 4 | rims\<root>_a.rim | RIM | it exists |
| 5 | rims\<root>_adx.rim | RIM | it exists |
| 6 | modules\<root>.mod | session ERF | it exists |
or modules\<root>_s.rim | RIM | there’s no .mod, and it exists | |
and in KotOR II, modules\<root>_dlg.erf | session ERF | there’s no .mod, tried without checking it exists | |
| 7 | the module’s copy under CURRENTGAME: | RIM | a fresh load |
| or the save’s state for the module | session ERF | a save load |
Step 7’s copy is made just before the mount. The engine clears its CURRENTGAME: working folder and copies one file into it, the first of: the module’s saved state in GAMEINPROGRESS:, as .rsv then .sav, when modulesave.2da lets the module into the save; a .nwm; modules\<root>.mod; modules\<root>.rim. For an ordinary module that’s <root>.rim, byte for byte, so reading modules\<root>.rim gives the same resources. Step 7 opens the copy as .rsv before .rim.
What that means for a module you ship:
- A
.modreplaces_s.rimand the base.rim, and in KotOR II_dlg.erftoo. None of them is mounted, so everything the module needs has to be inside the.mod._aand_adxstill mount beside it. - A
.modloses to every RIM. It’s a session ERF, searched after all the RIMs, the global ones inrims\included. So an_a.rimbeside a.modwins any resource both carry. _a.rimand_adx.rimcome fromrims\, nevermodules\. Each mounts whenever it’s there, both can, and_adxoutranks_a. One inmodules\is never seen.- Without a
.mod, the base.rimoutranks_s.rim, which outranks_adxand then_a. - KotOR II’s
_dlg.erfranks below every RIM, the module’s own included, and abovechitin.key’s dialogues.
Vanilla exercises none of the precedence. Every KotOR module is exactly <root>.rim and <root>_s.rim, every KotOR II module adds <root>_dlg.erf, and rims\ holds only the global archives (none at all in KotOR II). No resource appears in two of a module’s archives. The base .rim carries the area, its info and its git, and _s.rim everything else, KotOR’s dialogues included; KotOR II moves its dialogues into _dlg.erf.
Tip
In Rakata:
CompositeModulemounts step 2 and steps 4 to 6 by these rules, for the gameGameVfsis given, and stands in for step 7 withmodules\<root>.rim.GameVfsmountsplayersandlips\localization.modonce with the install and searches them while a module is loaded.
Read from CExoResMan::AsyncLoad at 0x004094a0, CServerExoAppInternal::LoadModule at 0x004b95b0 and CopyModuleToCurrentGame at 0x004b1680, and in KotOR II from their counterparts at 0x004096b0, 0x004f20d0 and 0x004e9d10, on the KotOR II build the manual traces. Provenance: traced. The vanilla layouts are measured over every module archive of both installs.
Module-resource bits
As it mounts a module, AsyncLoad records which archives it used as a set of bits, and one of them reaches gameplay. CSWSModule::AddModuleResources copies the field to the client once the mount finishes, through a setter that only ever sets bits. CSWSModule::RemoveModuleResources clears the byte as its last step, after unmounting everything the module mounted, and on a switch LoadModuleStart removes the old module’s resources before adding the new one’s. So the byte always describes the module currently loaded. Reloading the same module skips both calls and keeps the bits, which describe it either way.
| Bit | Set when |
|---|---|
0x01 | The module’s copy under CURRENTGAME: is mounted as a RIM, .rsv tried before .rim, on a load that is not from a save |
0x02 | MODULES:<root> is mounted as an ERF from modules\, meaning a .mod exists |
0x04 | CURRENTGAME:<root> is mounted as an ERF, the save’s state for the module |
0x08 | MODULES:<root>_s is mounted as a RIM, reached only when there is no .mod |
0x10 | RIMS:<root>_a is mounted as a RIM |
0x20 | RIMS:<root>_adx is mounted as a RIM |
KotOR II’s _dlg.erf sets no bit.
The only reader is GetModuleResourceLoaded, and its only caller is CSWSDialog::SendDialogEntry, which tests 0x08 to choose how it decides whether a line has a voice: see dlg.md. The other five bits are set and never read by anything found.
Read from CExoResMan::AsyncLoad at 0x004094a0, and from every instruction that addresses the byte: the client constructor, the setter, ResetModuleResourcesLoaded and the getter. Provenance: traced. A write through a pointer computed elsewhere would not show in that search, and none of its hits suggest one.
Tiered Resolution: GameVfs
Module composition settles one module’s archives. The layer above is install-wide: asked for one resource by name, which tier answers?
rakata-extract models this as GameVfs, which owns the install and exposes one resolve(resref, type) walking the tiers below.
| Tier | Source | Engine warrant |
|---|---|---|
SaveModule, Save | Mounted save, when one is mounted | n/a |
ExtraOverride | Caller-pushed extra overrides, last pushed wins | n/a |
StreamFolder | The top level of KotOR’s streammusic\, streamwaves\ and movies\ | traced |
Override | The Override/ directory | traced |
Patch | patch.erf, then in KotOR Override\textures | traced |
ActiveModuleRims | The active module’s RIMs, mounted via load_module | traced |
GlobalRim | rims\global.rim | traced |
ActiveModuleErfs | The active module’s .mod, or in KotOR II its _dlg.erf | traced |
Localization | lips\localization.mod, while a module is loaded | traced |
ActiveModuleLips | The active module’s lips\<root>_loc.mod | traced |
Players | KotOR’s players, while a module is loaded | traced |
TexturePack | The texture packs | traced |
Chitin | chitin.key and the BIFs | traced |
Override/ beats the active module, and that is the engine’s order rather than a convenience. Directories are the first of the five passes, with no per-resource-type branching. A module is composed of ERF and RIM archives, so all of it sits below Override/. Dropping a file there to replace one a module ships is the case the directory exists for, and the walk honours it.
A module’s archives are split across three tiers because the engine’s own archives fall between them. rims\global.rim is the oldest RIM, so it loses to the module’s RIMs and beats its .mod, and lips\localization.mod is mounted between the module’s .mod and its own lip sync. localization.mod and players are the same file for every module, so they’re mounted once with the install and searched only while a module is loaded.
The save and caller-pushed tiers are not part of that order and should not be read as if they were. Neither a mounted save nor a folder a caller pushed in appears in the engine’s walk at all; both are rakata-extract’s own. They sit on top because saved state is the player’s actual world and because a caller pushing a folder is asking for it to win.
One placement inside a pass is GameVfs’s own. This page doesn’t record which of patch.erf and Override\textures mounts last, so patch.erf is placed first.
Every archive but the base .rim is opened by name. Where the tables above say an archive mounts only if it exists, that’s tested on the name they give. Past that, GameVfs opens the first file the engine tries: .nwm, .mod, .sav, .erf and .hak for an ERF, as the ERF page describes, and .rsv then .rim for a RIM, as the RIM page does. The base .rim is read as modules\<root>.rim, standing in for the engine’s working copy.
Tip
What
GameVfsdoesn’t model:rims\mainmenu.rimandrims\chargen.rim, which are mounted only while their screens are up.
The save tier
Saved state is the player’s actual world, so it outranks anything the install ships. Within the tier the loaded module’s saved copies come first, then the save’s flat session resources.
The engine ranks it lower. To it, a save’s module state is a session ERF, searched after every RIM. Vanilla can’t tell the two apart: on a save load the base .rim isn’t mounted, and nothing else a module mounts carries an area, its info or its git.
It shadows narrowly, and deliberately. A save carries state for every module the player has visited, but the engine only brings a module’s saved state into play once that module loads. Serving all of it at once would shadow install content for modules the player never entered. Everything else a module needs, meaning its scripts, dialogue, layouts and blueprints, resolves from the override directory and below exactly as it would with no save mounted.
Two layout details matter when reading this code:
- The archive is keyed by module; the
AREandGITinside it are named after the area, and you cannot derive one from the other. In a late-game saveebo_m41aaholds aream12aa,liv_m99aaholdsm50aa, andunk_m41agholds152unk. So the tier looks the archive up by module and then probes it with whatever resref the caller asked for. - The
IFOinside one of these archives is always calledmodule, never the module’s name, because the archive is a snapshot of a working directory where that is simply the file’s name. It identifies nothing. Module identity lives only in the name of the archive containing it. - The flat session set is open, not a fixed list. Both save writers sweep the whole working directory into the archive, so incidental files land there too. See the save game pages.
Those first two together rule out addressing saved module content by resref. A (resref, type) key collides in any save holding more than one module, which is nearly all of them, because every one of those modules contributes a module IFO. Area resrefs collide too, in roughly a third of saves, from the ordinary case of one area name being reused across two modules. So saved module state is reached module-first or not at all.
Measured over three save corpora: the committed fixtures, a full manual-save set, and an in-progress modded playthrough. Every multi-module save collided on the IFO, without exception.
Mounting a save does not load a module, and unmount_save is independent of unload_module. Pairing them is the caller’s decision, which keeps save-loading policy out of the VFS.
Why tiers rather than one flat search
The engine has a single active module at a time, mounted when the player enters an area and unmounted when they leave. Searching every archive on disk would let one module’s resources bleed into another, so GameVfs::resolve consults only the mounted module.
Resolving one name versus listing everything
These are different questions and the API keeps them apart.
resolve answers the engine’s question: one resref, one winner, precedence applies.
Catalogue tooling wants every resource of a type, across every module under modules/. for_each_resource(types, tiers, callback) and for_each_resource_pair(primary, companion, tiers, callback) report each resource once, as the copy that wins. for_each_resource_copy(types, tiers, callback) reports every copy, with no winner. All of them walk the tiers asked for, open every module’s archives transiently, once for all the types asked for, and yield a ResourceOrigin per hit so the caller knows which tier the bytes came from.
Walked over every tier, enumeration’s contract is the set of resources resolve could currently return, which is what stops the two drifting apart. With a save mounted, the save’s session resources appear, and the loaded module’s saved copies appear instead of the install’s, one entry per resource rather than both.
Note
Saved state for modules that are not loaded is left out, permanently No
resolvecall can return it, so listing it would not make enumeration more complete. It would answer a different question, and one this shape cannot express: resrefs are not unique inside a save. One late-game save holds two visited modules that each store an area namedm12aa, and a flat(resref, bytes)stream can only carry one of them.Per-module saved state is structural, and it belongs to the save-domain crate’s mounted-save view.
GameVfs::enumeration_exclusions()names what is skipped, so a caller can tell “the save has nothing for that module” from “enumeration did not look”.
Catalogue helpers
Read-only, for tools inspecting the module catalogue without mounting anything.
| Call | Returns |
|---|---|
list_modules() | Every module root, sorted |
has_module(name) | Whether that root exists |
module_files(name) | The archives composing one module, in precedence order |
open_module(name) | A CompositeModule, without touching active_module |
load_module_at(directory, name) | The same from an arbitrary directory, for testing and mod development |
Downloadable Content Mounts (Xbox)
Note
Xbox only. Inert on K1 PC and not modelled in Rakata. Documented now rather than left to be rediscovered, since Xbox support is planned. It has no effect on a PC install.
The engine has a built-in downloadable content system: numbered slots, each layered on the base game as its own tier. It is how the Xbox build delivered the Yavin Station bundle. Slots are addressed by a LIVE%d filesystem alias, and a global fixes how many exist, and the K1 GOG build reports six, LIVE1 through LIVE6.
A slot can carry a full content stack. AddDownloadedResources mounts each slot’s archives at startup, and the module and movie subsystems probe the same slots on demand:
| Content | Source | Consumed by |
|---|---|---|
| Modules | LIVE%d:MODULES\ | LoadModule, PopulateModules |
| Movies | LIVE%d:movies\ | AddMovieToExoArrayList |
| Talk table | LIVE%d:live%d | AddDownloadedResources |
| Key table | LIVE%d:live%d | AddDownloadedResources |
| RIM archives | LIVE%d:RIMSXBOX\live%d, ...\live%ddx | AddDownloadedResources |
| ERF | LIVE%d:live%d | AddDownloadedResources |
| Override textures | LIVE%d:OVERRIDE\textures | AddDownloadedResources |
A save records which slots are present through savenfo’s LIVECONTENT bitmask and the parallel LIVE1-LIVE6 name fields (see savenfo).
Why it does nothing on PC
The mount walk is not disabled. The slot-count global is 7, so AddDownloadedResources runs at every startup and iterates all six slots. Each iteration asks GetAliasPath("LIVE%d") and does nothing when the alias does not resolve.
The aliases are never registered, and that is the whole reason. Aliases come from LoadAliases, which reads a fixed list of names out of the ini [Alias] section: HD0, OVERRIDE, TEMP, MODULES and a handful of others. The ini is swKotor.ini, read by WinMain from the working directory before anything else starts. An entry replaces that alias’s built-in default, and its value is joined to the rest of a path with no separator added, so it has to end in a backslash. HD0 is the root every streamed audio path is built from.
LIVE1 through LIVE6 are not on that list.
So adding them to swkotor.ini does nothing. The engine never asks for them, and an entry it never asks for cannot be found. On Xbox the Live subsystem registered the aliases itself; the stock PC build has no code path that does.
What the saves show
The saves corroborate the result rather than establishing it, and the distinction matters: they show the tier carrying nothing, not why.
Across a corpus of K1 save folders, every folder carrying the block has all six LIVE%d fields empty and LIVECONTENT at 0, without exception.
One folder lacks the block entirely, and it is an autosave. That supports the two-write-path split recorded on the savenfo page from a second direction: the block is written on a manual save and PCAUTOSAVE on an automatic one, and exactly one folder has the second without the first.
The Yavin Station content ships as ordinary game data on PC rather than through this path.
Reactivation as a modding tier
Tip
A dormant, engine-native expansion mechanism. The mount walk already runs, so the only blocker is getting a
LIVE%dalias into the alias list. Supply one, either a startup loader calling the alias-add path or a patch teachingLoadAliasesto readLIVE1-LIVE6from the ini, and the slot activates: a self-contained tier that adds modules, movies, textures and archived resources without touchingOverride.That makes it a candidate home for a content bundle or total conversion, and a possible delivery format for Rakata’s planned patcher.
Three caveats before anyone builds on it:
- A new planet still needs the usual galaxy-map and travel wiring, in 2DA and scripts, before it is reachable.
- A 2DA delivered through override replaces rather than merges.
- The per-slot file layout has not been mapped in full, and a recipe needs that first.
How Rakata models it
Not yet. The tier is inert on a stock K1 PC install, which is the current target. The mount table above is what to add to GameVfs when Xbox support lands.
Read from the functions below. Provenance: derived, not attested, except the save-corpus observation, which is measured.
| Function | Address | Covers |
|---|---|---|
AddDownloadedResources | 0x005f4180 | The startup mount walk, gated on the slot-count global |
LoadAliases | 0x005e7a90 | Registers the fixed alias-name list, which omits LIVE1-LIVE6 |
AddAlias | 0x005e7760 | Reads one named [Alias] entry into the alias list |
GetAliasPath | 0x005e6890 | Resolves a LIVE%d alias at mount time |
LoadModule | 0x004b95b0 | Probes LIVE%d:MODULES\ |
AddMovieToExoArrayList | 0x005fbbf0 | Probes LIVE%d:movies\ |
StallEventSaveGame | 0x004b3110 | Writes the savenfo LIVE%d / LIVECONTENT fields |