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. How do they merge into one?
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

When the key table is asked for a name it walks a hardcoded folder order and returns the first match, ignoring any duplicate deeper down.

OrderTier
1resource_directory, the Override folder
2ERF, first pass
3RIM
4ERF, second pass
5Fixed archives and BIFs

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.

Duplicates are settled once, at startup rather than per lookup: AddKey notices a name it already holds and drops the newcomer, so whichever archive got there first keeps the slot for the rest of the session.

Read from CExoKeyTable::FindKey at 0x0040ec50. Provenance: traced. The five tiers and their order were read from the function, and no per-resource-type branch exists on that path.


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. Composing them is a fixed precedence, highest first:

PriorityArchiveHolds
1<root>_dlg.erfDialogue overrides
2<root>_s.rimSupplemental properties
3<root>_a.rim, else <root>_adx.rim, else <root>.rimThe area itself
4<root>.modSingle-file module archive

The engine works the other way round when loading: it asks for MODULES:<root>.mod first, and when that is absent it falls back to <root>_s.rim and probes for <root>_a.rim and <root>_adx.rim to merge the area geometry in.

Tip

In Rakata: CompositeModule::load_from_directory scans a folder and merges the _dlg, _s, _a/_adx and base archives in the order above.

Read from CExoResMan::AsyncLoad at 0x004094a0. Provenance: derived, not attested.


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
0Mounted save, when one is mountedn/a
1Caller-pushed extra overrides, last pushed winsn/a
2The Override/ directorytraced
3The active module, mounted via load_moduletraced
4chitin.key and the BIFstraced

Override/ beats the active module, and that is the engine’s order rather than a convenience. The traced key-table walk probes the override directory first, then ERF, then RIM, then ERF again, then the fixed and BIF tier, 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.

The top two 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. So three of the five tiers carry engine evidence and two carry a judgement call.

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.

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 the opposite: every resource of a type, across every module under modules/, with no winner. for_each_resource(type, callback) and for_each_resource_pair(primary, companion, callback) do that, walking every tier and every module’s archives transiently and yielding a ResourceOrigin per hit so the caller knows which tier the bytes came from.

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.

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