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

The Odyssey Engine’s resource resolution dictates exactly how the game searches for files when it needs to render a texture, load a module, or mount a script – including the exact precedence logic when multiple mods attempt to overwrite the same asset.


ResRef Validation

A ResRef is the engine’s primary resource identifier: a fixed 16-byte buffer used everywhere a resource needs to be named (KEY/BIF entries, RIM keys, GFF resref fields, save-game resource handles, network messages, etc.).

Engine Audits & Decompilation

The following documents how swkotor.exe constructs and stores CResRef instances.

(Decompilation logic for this section was audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from the constructor family CResRef::CResRef at 0x00405ed0, 0x00405ef0, 0x00406d60, 0x00406d80, 0x00406da0, and the network read path CSWMessage::ReadCResRef at 0x004d6180.)

Engine RuleRuntime Behavior
Verbatim Byte CopyEvery CResRef constructor performs a straight memcpy of up to 16 bytes from the source into the internal buffer. There is no character classification, no rejection of “invalid” bytes, and no separate ValidResRef helper anywhere in the binary.
No Character WhitelistThe engine recognizes no [A-Za-z0-9_-] style restriction. The binary contains no “invalid resref” or “bad resref” error strings. Real vanilla content depends on this freedom: chitin.key includes upgrade-modifier resrefs containing +, RIM key tables include !, and similar punctuation appears in script and texture names across the vanilla corpus.
16-Byte CapInputs longer than 16 bytes are silently truncated. The remaining buffer is zero-padded.
No UTF-8 AwarenessThe engine treats the buffer as raw bytes. There is no encoding-aware comparison. Resources stored under a non-ASCII byte sequence in the source data would be looked up by exact byte equality (after the engine’s case-insensitive comparison logic kicks in elsewhere).
Default ConstructionThe empty constructor (0x00405ed0) zeros all 16 bytes. A null-pointer source falls through to the same all-zero state.

How Rakata Models This

rakata_core::ResRef accepts any single ASCII byte (0..=0x7F) up to 16 bytes and lowercases ASCII letters for canonicalization (matching the engine’s case-insensitive lookup). Multi-byte UTF-8 input is rejected because Rust’s &str API forces inputs to be UTF-8 valid, and a multi-byte sequence in a &str cannot represent the same byte the engine would store under Windows-1252 conventions; round-tripping such input would silently corrupt the lookup key. Vanilla content is exclusively single-byte and unaffected.

The 16-byte cap and case-insensitive canonicalization match the engine. The ASCII-only restriction is a Rust-side safety net rather than an engine constraint.


TXI Sidecar Lookup

Texture Extensions (TXIs) are independent ascii text configurations used to override material instructions for specific graphics.

Engine Audits & Decompilation

The following documents the engine’s exact load sequence for TXI sidecar files mapped from swkotor.exe.

(Decompilation logic for this section was audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from AurResGet at 0x0044c740.)

Pipeline EventGhidra Provenance & Engine Behavior
Global CallbackWhen the game needs a TXI file, it always routes through a global helper calling AurResGet(name, ".txi", ..., true). Three different rendering systems use this exact same path to hunt for TXIs: CAurTextureBasic::Init, Gob::EnableRenderBumpedOut, and Material::Init.
Total IndependenceBecause AurResGet only checks the raw filename and the .txi extension, it performs a totally fresh, global search through the game’s file systems. It does not know or care where the parent texture actually came from (like a specific BIF archive).

Note

Because it is entirely independent from the parent texture handle, swkotor.exe supports pulling a TXI from the /override folder even if the parent texture was sourced natively from a KEY/BIF package. Rakata maintains this independent sidecar lookup model natively via rakata_extract::GameVfs::resolve_texture_with_txi, which walks each tier looking for the texture and then walks the tiers again from the top for the sidecar.


Key/BIF Resolution Mapping

The engine has a strict hierarchical override order when hunting for identical overlapping resource identifiers across multiple virtual disk mounts.

Engine Audits & Decompilation

The following documents the engine’s exact resource directory search order mapped from swkotor.exe.

(Decompilation logic for this section was audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from CExoKeyTable::FindKey at 0x0040ec50.)

Pipeline EventGhidra Provenance & Engine Behavior
First-Match ExitWhen hunting for a file, the key table loops through standard folders in a hardcoded order. The second it finds a matching file name, FindKey returns success and completely ignores any duplicates hiding deeper in other archives.
Duplicate CheckingDuring startup, the engine’s AddKey function actually scans for duplicates. If it finds one, it ignores it, permanently locking in the file that had the higher resolution priority.

Tip

Resolution Priority:
resource_directory (Override folder) → ERF (Pass 1) → RIMERF (Pass 2) → Fixed / Archive


Module Loading Priorities

Modules orchestrate KOTOR’s area hubs. They are layered collections of ERF/RIM files functioning as a localized state.

Composition Loading Precedence

Because KOTOR modules are often fragmented into multiple discrete archive files (e.g., separating rigid layouts from variable area dialog), it uses the following concrete precedence when constructing a single “virtual” module (the order below lists the highest priority target first).

1. <root>_dlg.erf (Dialog overrides)
2. <root>_s.rim (Supplemental properties)
3. <root>_a.rim (Base Area) if present, ELSE <root>_adx.rim (Extended Area) if present, ELSE <root>.rim (Main/Vanilla)
4. <root>.mod (Single-file Mod archive)

Tip

Rust Integration The rakata-extract crate natively replicates this exact priority order through the CompositeModule struct. When you pass a directory path to CompositeModule::load_from_directory, it automatically scans the folder and merges the _dlg, _s, _a / _adx, and base .mod files together using the engine’s strict precedence hierarchy.

Engine Audits & Decompilation

The following documents the engine’s exact load sequence for module assemblies mapped from swkotor.exe.

(Decompilation logic for this section was audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from CExoResMan::AsyncLoad at 0x004094a0.)

Pipeline EventGhidra Provenance & Engine Behavior
Primary MOD SearchThe game natively attempts to load the highest-level package by explicitly targeting the MODULES:<root>.mod path first.
RIM Fallback ChainsIf the .mod file doesn’t exist, the system catches the failure and immediately shifts to look for the <root>_s.rim fallback.
Area Extension ProbesThroughout the module loading process, the engine actively probes for the <root>_a.rim and <root>_adx.rim extension files to violently merge in the physical area geometry.

Tiered Resolution: GameVfs

Module composition handles one module’s archives. The next layer up is the install-wide resolution order: when the engine asks for a single resource by name, which tier wins?

rakata-extract models this through GameVfs. It owns the install and exposes a single resolve(resref, type) entry point that walks tiers in engine precedence order.

Tier Order

From highest priority to lowest:

0. Mounted save (mount_save, when a save is mounted)
1. Caller-pushed extra overrides (in push order, last pushed wins)
2. Active module (the single CompositeModule mounted via load_module)
3. Override/ directory (loose files under the install root)
4. chitin.key / BIFs (the base game corpus)

The Save Tier

Saved state is the player’s actual world, so it outranks anything the install ships. Inside the tier, the loaded module’s saved copies are checked first, then the save’s flat session resources.

The tier only shadows what a save actually stores, and only for the module currently loaded. A save carries state for every module the player visited, but the engine only puts a module’s saved state in play once that module loads, so serving all of it at once would shadow install content for modules the player never entered. Everything else a module needs – scripts, dialogue, layouts, blueprints – keeps resolving from the module tier and below exactly as it would with no save mounted.

Two layout details matter when reading the tier’s code. The nested archive is keyed by module resref while the ARE and GIT inside it are named after the area, and the area name is not derivable from the module name: 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, then probes it with whatever resref the caller asked for. And the flat session set is open rather than fixed: both save writers sweep the whole working directory into the archive, so incidental files land there too (see the save game pages).

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 the VFS free of save-loading policy.

The engine maintains a single active module at a time, mounted explicitly when the player enters an area and unmounted when they leave. A flat “search every archive on disk” model would let a resource from one module bleed into another. GameVfs::resolve only consults the currently-mounted module, matching engine semantics.

Resolution vs Enumeration

resolve answers the engine question: one resref, one winner, tier precedence applies. Catalogue tooling (asset browsers, corpus surveys) needs the opposite shape: every resource of a given type, including every module under modules/, with no false “one answer wins” precedence. GameVfs::for_each_resource(type, callback) and for_each_resource_pair(primary, companion, callback) cover that case. They walk every tier including every module’s archives transiently, yielding a typed ResourceOrigin for each hit so the caller knows which tier (save, chitin, override, extra override, or which module) the bytes came from.

Enumeration’s contract is the set of resources resolve can currently return, which is what keeps the two from drifting apart. With a save mounted that means the save’s flat session resources appear, and the loaded module’s saved copies appear instead of the install’s – one entry per resource, never both.

Saved copies belonging to modules that are not loaded are left out, permanently. No resolve call returns them, so reporting them would not make enumeration more complete; it would answer a different question. That question is also not expressible in this shape, because resrefs are not unique inside a save: one late-game save holds two visited modules that each store a different area named m12aa, and a flat (resref, bytes) stream can only carry one of them. Per-module saved state is a structural question, and the save-domain crate’s mounted-save view is where it belongs. GameVfs::enumeration_exclusions() names what is being skipped so a caller can tell “the save has nothing for that module” from “enumeration did not look”.

Catalogue Primitives

GameVfs also exposes a small set of read-only catalogue helpers for tools that need to inspect the module catalogue without committing to a mount: list_modules() (sorted module roots), has_module(name), module_files(name) (engine-precedence-ordered list of the archives that compose one module), open_module(name) (open a CompositeModule without touching active_module), and load_module_at(directory, name) (mount from an arbitrary directory for testing or mod-development workflows).


Downloadable Content Mounts (Xbox)

Note

Platform: Xbox. Inert on K1 PC, not yet modelled in Rakata. Rakata starts with K1 PC and plans to support the Xbox builds later, so this tier is documented now rather than left to be rediscovered. It has no effect on a PC install.

The engine has a built-in downloadable (“live”) content system: numbered content slots, each layered on top of the base game as its own self-contained tier. It is how the Xbox build delivered the Yavin Station bundle. Slots are addressed by a LIVE%d filesystem alias, and a single global fixes how many exist (the K1 GOG build reports six, LIVE1-LIVE6).

A slot can carry a full content stack. At startup AddDownloadedResources mounts each slot’s archives, and the module and movie subsystems probe the same slots on demand:

Contributed contentSourceConsumed by
Modules (areas / planets)LIVE%d:MODULES\module loader (LoadModule, PopulateModules)
MoviesLIVE%d:movies\movie playback (AddMovieToExoArrayList)
Talk tableLIVE%d:live%dAddDownloadedResources
Key tableLIVE%d:live%dAddDownloadedResources
RIM archivesLIVE%d:RIMSXBOX\live%d, ...\live%ddxAddDownloadedResources
ERF (encapsulated)LIVE%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 is dormant on PC

The mount walk itself is not gated off on PC. The slot-count global is 7, so AddDownloadedResources runs every startup and iterates all six slots. Each iteration calls GetAliasPath("LIVE%d") and simply does nothing when the alias does not resolve.

The catch is that the LIVE%d aliases are never registered. Aliases come from LoadAliases, which asks for a fixed list of names (HD0, OVERRIDE, TEMP, MODULES, …) and reads each from the ini [Alias] section. LIVE1-LIVE6 are not in that list, so adding them to swkotor.ini has no effect: the engine never looks them up. On Xbox the Live subsystem registered the aliases itself; the stock PC build has no code path that does. That is the only missing link, and a real K1 GOG save confirms the result: all six LIVE%d fields are empty and LIVECONTENT is 0. The Yavin Station DLC ships as ordinary game data on PC rather than through this path.

Reactivation as a modding tier

Tip

This is a dormant, engine-native expansion mechanism. Because the mount walk already runs, the sole blocker is registering a LIVE%d alias into the alias list, which the stock PC build never does. Supply one (a startup loader that calls 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 can add modules, movies, textures, and archived resources without touching Override. That makes it a natural home for a content bundle or total conversion, and a candidate delivery format for Rakata’s planned patcher / install tooling.

Caveats: a new planet still needs the usual galaxy-map and travel wiring (2DA plus scripts) to be reachable; 2DA delivered through override replaces rather than merges; and the exact per-slot file layout wants a full mapping pass before anyone builds a real recipe.

How Rakata Models This

Not yet. This tier is inert on a stock K1 PC install, Rakata’s current target. Xbox-build support, and a possible content-bundle format built on this mechanism, is future work; the mount table above is the tier to add to GameVfs when it lands.

Engine Audits & Decompilation

Documented from Ghidra decompilation of swkotor.exe (K1 GOG build); see the Provenance Policy. The mount walk, the alias registration, and the save linkage are read from:

FunctionAddressCovers
AddDownloadedResources0x005f4180The startup mount walk, gated on the slot-count global (= 7)
LoadAliases0x005e7a90Registers the fixed alias-name list from the ini [Alias] section (omits LIVE1-LIVE6)
AddAlias0x005e7760Reads one named [Alias] entry into the alias list
GetAliasPath0x005e6890Resolves a LIVE%d alias at mount time
LoadModule0x004b95b0Probes LIVE%d:MODULES\ for module content
AddMovieToExoArrayList0x005fbbf0Probes LIVE%d:movies\ for movie content
StallEventSaveGame0x004b3110Writes the savenfo LIVE%d / LIVECONTENT fields