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

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:

LayerThe question it answers
The nameWhat may a resource be called, and how are two names compared?
The archive searchGiven a name, which archive answers first?
Module compositionA module is several archives. Which does the engine mount, and which wins?
Install-wide tiersOverride, 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:

ByteWhere 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:

PassTablesWhat’s in it
1DirectoriesOverride, and in KotOR the top level of streammusic\, streamwaves\ and movies\
2ERFs tagged 1the install-wide patch archives: patch.erf, and in KotOR OVERRIDE:textures
3Every RIMthe module’s RIMs, rims\global.rim for the whole session, and rims\mainmenu.rim and rims\chargen.rim while their screens are up
4ERFs tagged 2everything 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
5Fixedchitin.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: GameVfs searches the top level of KotOR’s three folders in the order of the first game after launch: streammusic\, streamwaves\, movies\, then Override. 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.

#ArchiveKindMounted when
1players at the install root, KotOR onlysession ERFalways tried, and a missing file is ignored
2lips\<root>_loc.modsession ERFalways tried
3lips\localization.modsession ERFalways tried
4rims\<root>_a.rimRIMit exists
5rims\<root>_adx.rimRIMit exists
6modules\<root>.modsession ERFit exists
or modules\<root>_s.rimRIMthere’s no .mod, and it exists
and in KotOR II, modules\<root>_dlg.erfsession ERFthere’s no .mod, tried without checking it exists
7the module’s copy under CURRENTGAME:RIMa fresh load
or the save’s state for the modulesession ERFa 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 .mod replaces _s.rim and the base .rim, and in KotOR II _dlg.erf too. None of them is mounted, so everything the module needs has to be inside the .mod. _a and _adx still mount beside it.
  • A .mod loses to every RIM. It’s a session ERF, searched after all the RIMs, the global ones in rims\ included. So an _a.rim beside a .mod wins any resource both carry.
  • _a.rim and _adx.rim come from rims\, never modules\. Each mounts whenever it’s there, both can, and _adx outranks _a. One in modules\ is never seen.
  • Without a .mod, the base .rim outranks _s.rim, which outranks _adx and then _a.
  • KotOR II’s _dlg.erf ranks below every RIM, the module’s own included, and above chitin.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: CompositeModule mounts step 2 and steps 4 to 6 by these rules, for the game GameVfs is given, and stands in for step 7 with modules\<root>.rim. GameVfs mounts players and lips\localization.mod once 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.

BitSet when
0x01The module’s copy under CURRENTGAME: is mounted as a RIM, .rsv tried before .rim, on a load that is not from a save
0x02MODULES:<root> is mounted as an ERF from modules\, meaning a .mod exists
0x04CURRENTGAME:<root> is mounted as an ERF, the save’s state for the module
0x08MODULES:<root>_s is mounted as a RIM, reached only when there is no .mod
0x10RIMS:<root>_a is mounted as a RIM
0x20RIMS:<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.

TierSourceEngine warrant
SaveModule, SaveMounted save, when one is mountedn/a
ExtraOverrideCaller-pushed extra overrides, last pushed winsn/a
StreamFolderThe top level of KotOR’s streammusic\, streamwaves\ and movies\traced
OverrideThe Override/ directorytraced
Patchpatch.erf, then in KotOR Override\texturestraced
ActiveModuleRimsThe active module’s RIMs, mounted via load_moduletraced
GlobalRimrims\global.rimtraced
ActiveModuleErfsThe active module’s .mod, or in KotOR II its _dlg.erftraced
Localizationlips\localization.mod, while a module is loadedtraced
ActiveModuleLipsThe active module’s lips\<root>_loc.modtraced
PlayersKotOR’s players, while a module is loadedtraced
TexturePackThe texture packstraced
Chitinchitin.key and the BIFstraced

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 GameVfs doesn’t model: rims\mainmenu.rim and rims\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 ARE and GIT inside it are named after the area, and you cannot derive one from the other. In a late-game save ebo_m41aa holds area m12aa, liv_m99aa holds m50aa, and unk_m41ag holds 152unk. So the tier looks the archive up by module and then probes it with whatever resref the caller asked for.
  • The IFO inside one of these archives is always called module, 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.

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 resolve call 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 named m12aa, 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.

CallReturns
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:

ContentSourceConsumed by
ModulesLIVE%d:MODULES\LoadModule, PopulateModules
MoviesLIVE%d:movies\AddMovieToExoArrayList
Talk tableLIVE%d:live%dAddDownloadedResources
Key tableLIVE%d:live%dAddDownloadedResources
RIM archivesLIVE%d:RIMSXBOX\live%d, ...\live%ddxAddDownloadedResources
ERFLIVE%d:live%dAddDownloadedResources
Override texturesLIVE%d:OVERRIDE\texturesAddDownloadedResources

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%d alias into the alias list. Supply one, either a startup loader calling the alias-add path or a patch teaching LoadAliases to read LIVE1-LIVE6 from the ini, and the slot activates: a self-contained tier that adds modules, movies, textures and archived resources without touching Override.

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.

FunctionAddressCovers
AddDownloadedResources0x005f4180The startup mount walk, gated on the slot-count global
LoadAliases0x005e7a90Registers the fixed alias-name list, which omits LIVE1-LIVE6
AddAlias0x005e7760Reads one named [Alias] entry into the alias list
GetAliasPath0x005e6890Resolves a LIVE%d alias at mount time
LoadModule0x004b95b0Probes LIVE%d:MODULES\
AddMovieToExoArrayList0x005fbbf0Probes LIVE%d:movies\
StallEventSaveGame0x004b3110Writes the savenfo LIVE%d / LIVECONTENT fields