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

Rakata

Rakata is a clean-room Rust implementation of Knights of the Old Republic (KotOR) data formats and tooling. It provides a modular workspace designed for robust, type-safe, and canonical handling of Odyssey Engine game data.

This Wiki serves as the definitive reference manual for KOTOR Formats and Engine Behaviors, designed to decouple format knowledge from the underlying Rust source code.

Requirements

  • Rust Version: 1.85.0 or newer.

Documentation Domains

Rakata’s documentation operates on two tiers: the Software API and the Format Specifications.

1. The Workspace (Code API)

The workspace is organized into focused crates and tools. If you are developing against Rakata and need to know the semantic layout of types, functions, and data structures, refer to the respective Rustdocs:

Libraries (crates/)

  • rakata-core: Foundational primitives (ResRef, ResourceType, ResourceId) and core utilities (encoding, filesystem, detection).
  • rakata-formats: Binary and text format readers/writers for 19 KotOR formats including GFF, ERF, RIM, KEY/BIF, MDL/MDX, TPC, TGA, and more.
  • rakata-generics: Typed wrappers around GFF-backed resources (all 13 types: UTW, UTC, UTI, etc.). from_gff / to_gff are honest projections that model only the enumerated fields; byte-exact preservation is the raw Gff tree’s job.
  • rakata-extract: Tiered resource VFS (GameVfs) mirroring engine precedence (mounted save, extra overrides, active module, Override/, chitin/BIFs), plus composite module handling (.mod + _s.rim + _dlg.erf) and install-wide enumeration helpers.
  • rakata-lint: Comprehensive resource validation against engine-derived field schemas. Catches crash-causing mod errors across all formats before they hit the engine.
  • rakata-save: Save game parsing and modification logic.
  • rakata: Facade crate re-exporting the ecosystem.

Tools (tools/)

  • rakata-saveeditor: Desktop GUI application for editing save games.
  • vanilla-inspector: Corpus validation tool for testing format implementations against all vanilla game assets.

🔗 View Rakata Rustdocs

2. Format Specifications (This Wiki)

The entire formats/ specification manual effectively serves as Rakata’s formal Evidence Log. If you need to understand binary structure, historical context, or how the original swkotor.exe engine interprets byte bounds under the hood (via Ghidra-backed engine constraints), you are in the right place!

Navigate through the sidebar to explore our exhaustive, decoupled format libraries:

  • Archive Formats – Detailed overviews of encapsulated containers (BIF, KEY, ERF, RIM).
  • GFF Structure – The bedrock of KOTOR’s data, exposing the 13 distinct blueprint constraints (Creatures, Dialogues, Triggers, etc.).
  • 3D Models & Mesh – MDL/MDX structures and binary walkmesh topologies.
  • Textures & Audio – Overviews detailing graphic compression (TPC, DDS) and MP3/Miles Sound System wrappers.
  • Text & Data Formats – Localized Talk Tables (TLK), rule mappings (2DA), and hierarchical layout geometries (LYT, VIS).

Ready to dive in? Head over to the Goals & Roadmap to see where the project is heading, or look into the Architecture logic that powers the Rakata suite.

Project Roadmap

This document outlines what we’re tinkering with in rakata and where the project is heading.

Note

For day-to-day progress, bug fixes, and specific technical tasks, check out the Codeberg Issues tracker instead.

The End Goal

Right now, our libraries are mostly just good at reading and writing individual game files - like extracting a 3D model, opening a save file, or decoding audio. But the real dream for rakata is to build a full, modern KOTOR engine integration.

Eventually, it would be cool to tie all these isolated pieces together into an actual rendering pipeline. For example: dropping a vanilla model into an active window and have the engine stream the textures and background audio straight from the game data.

How We Get There

Since this is a passion project, we try to match the original game behavior down to the exact byte before building higher-level abstractions on top of it. It takes a bit longer, but it keeps us from having to constantly rewrite core parsers when we stumble into weird edge cases.

1. Laying the Foundation (Mostly Done)

Our core libraries (rakata-formats, rakata-save, etc.) can currently read, write, and safely roundtrip over 17 different KOTOR file formats. We’ve tackled a lot of the weird legacy archives (BIF), models (MDL/MDX), and raw textures (TPC), ensuring they line up with vanilla behavior.

However, the foundation is still growing! We still have a handful of outstanding data formats to map out and implement, including Pathfinding (PTH), UI Layouts (GUI), and Walkmeshes (WOK/DWK/PWK).

Additionally, formatting and bytecode support for NCS (Compiled Scripts) is actively being prioritized (see Issue #19) to allow rakata to interface natively with upcoming Rust-based community compilers and decompilers.

2. Building Real Tools (Our Active Focus)

Now that we can parse the data reliably, we are building stuff the community can actually use:

  • Mod Linter: A tool to scan modded files and point out if they break the game’s actual data constraints, catching crashes before you load them in-game.
  • Save Editor: A basic offline save editor (rakata-saveeditor) built directly on top of our stable format parsers.
  • Audio Streaming: Updating the generic audio logic (rakata-audio) so we can natively stream game music and voice lines instead of loading giant buffers into memory.
  • Drop-in Replacements: Providing modern, reliable drop-in replacements for legendary (but aging) community tools. By backing these with rakata’s strict parsing rules, we can offer faster, safer, cross-platform native tools for unpacking archives, compiling models, and building mods. (Note: While we aim to replace these tools, we will not inherit their legacy bugs or non-vanilla API quirks. When in doubt, the original game engine is our only source of truth).

3. KOTOR 2 (TSL) Support

We are strictly focusing on KOTOR 1 right now, but extending parsing support for TSL via compatibility flags is a planned enhancement for further down the line once K1 is completely stabilized.

4. The Runtime Engine (The dream but probably a few years away)

Once our standalone tools prove that our format parsers are perfectly stable, we have a pipedream to one day start weaving them together into a natively synchronized rendering loop.

Architecture Guide

This document outlines how the rakata workspace is structured and the design principles we try to stick to.

Core Principles

  1. Vanilla K1 First

    • By default, we target the original vanilla behavior of KotOR 1.
    • Compatibility for TSL or community tools is strictly opt-in behind feature flags, not the default assumption.
    • When deciding how to parse something, the original game engine is our ultimate source of truth. We use local fixtures and original game data to prove our parsers work, rather than just copying how older community tools did things.
  2. Aim for Lossless

    • We want to be able to read a file and write it back out to the exact same bytes. We’ve largely achieved this for standard archives and data formats (GFF, ERF, RIM, KEY, TLK, etc.).
    • For highly complex formats (like MDL/MDX models), there are some known divergences where achieving a byte-exact roundtrip is essentially impossible due to how the original compilers ordered geometry blocks. We track these exceptions, but the output still safely runs in-game.
    • No Lazy Pass-throughs: If a file has undocumented fields, we don’t just read them as an opaque Vec<u8> blob and blindly pass them through. Our goal is to properly reverse-engineer and map every single struct boundary. However, if we identify defined “reserved” fields in the binary layout that we haven’t cracked the meaning of yet, we will map them as properly sized reserved values so we don’t accidentally drop data the engine might rely on. (Note: explicit blank padding bytes aren’t stored in memory at all - we just recalculate those dynamically on write).
    • Layer scope: this lossless guarantee applies to the byte-level format layer (rakata-formats). Typed views in rakata-generics (Utc, Uti, Are, …) are explicitly honest projections that model only the fields they enumerate; byte-exact preservation stays with the raw Gff tree. See Typed Views and Raw GFF below for the full rule.
  3. Strict Text Handling

    • All text decoding goes through rakata-core::text.
    • Localized text (TLK entries, strings) uses language-aware encodings (Windows-1252, Shift-JIS, etc.) to match what the engine expects.
    • Binary strings (like node names or texture paths) use TextEncoding::Windows1252 since that’s what the engine actually uses under the hood. No silently stripping weird characters with lossless backups.

(For day-to-day coding rules around iterators, zero-cost abstractions, and memory safety, see the Idiomatic Rust section in the contributing.md guide!)

Workspace Boundaries

Note: This layout is a living target! rakata-saveeditor is under active development, and rakata-audio is planned but does not exist yet. As we tackle our near-term roadmap goals – like building out the rakata-lint validation engine – expect these crates to flesh out, alongside brand new sibling crates being added to the ecosystem.

The workspace is organized in a clean dependency chain. Crates can only depend on crates listed “above” them:

rakata-core          (no workspace deps)
  rakata-formats     (depends on: core)
    rakata-extract   (depends on: core, formats)
    rakata-generics  (depends on: core, formats)
    rakata-lint      (depends on: core, formats, extract, generics)
    rakata-save      (depends on: core, formats)
rakata               (facade: re-exports all library crates)

  rakata-audio       (planned, not yet created)

Library Crates (crates/)

  • rakata-core: The absolute basics (ResRef, IDs) and core utilities like file streams and text encoding.
  • rakata-formats: Our massive library of parsers and writers (GFF, ERF, BIF, MDL, TPC, etc.). This parses bytes into objects, but doesn’t know anything about how the game actually uses them.
  • rakata-audio (planned): Audio streaming and decoding for the engine’s various sound formats (WAV, ADPCM, MP3). Not yet created; WAV reading currently lives in rakata-formats.
  • rakata-generics: Strongly-typed Rust models for all the different GFF files (like Doors, Items, Characters).
  • rakata-extract: The logic for hunting down actual game files in the wild. It knows how to look inside ERFs, check the Override folder, and resolve files just like the engine does.
  • rakata-lint: Our rule engine for scanning modded files and checking them against vanilla schema constraints.
  • rakata-save: High-level logic for safely reading, editing, and backing up save files.
  • rakata: A handy facade crate that re-exports everything so you only need to add one dependency.

Tool Crates (tools/)

  • rakata-saveeditor: The actual desktop application for editing save files.
  • vanilla-inspector: A testing utility for validating our parsers against the actual mass of game files.

Format API Guidelines

Public API Shape

Every format parser in rakata-formats generally provides the same clean interface:

  • read_<fmt><R: Read>(reader: &mut R) -> Result<T, E>
  • read_<fmt>_from_bytes(bytes: &[u8]) -> Result<T, E>
  • write_<fmt><W: Write>(writer: &mut W, data: &T) -> Result<(), E>
  • write_<fmt>_to_vec(data: &T) -> Result<Vec<u8>, E>

Formats with multiple output modes (like exporting models to ASCII text or JSON) just use variations of these names (read_mdl_ascii()).

  • Generic Traits: We strongly prefer accepting generic I/O trait bounds (Read, BufRead, Write, Seek) over concrete types. Accept the narrowest trait that covers your API’s needs so callers aren’t forced to jump through hoops.

Error Handling

Robust parsing means strict error boundaries:

  • Each format module must define its own domain-specific error enum (e.g., GffError, ErfError) using the thiserror crate. Do not use generic stringly-typed errors or Box<dyn Error>.
  • Low-level read failures (like sudden bounds exhaustion or bad magic numbers) should wrap our shared BinaryLayoutError.
  • Never unwrap() at an API boundary! Only fail explicitly via Result or use .expect() with a hardcoded rationale if it is impossible to fail.

Memory & Ownership

While we try to avoid deep cloning and heavy allocations behind the scenes, we default to owned data types when crossing public API boundaries. Unless a module is explicitly built and documented as a zero-copy “View” type, you should avoid passing nasty lifetimes into the caller’s lap.

Keeping Concerns Separated

  • Dumb Parsers: Format modules in rakata-formats are intentionally “dumb”. They solely translate between raw byte streams and Rust structs without any awareness of game architecture, filesystems, or what a “module” is.
  • Smart Extractors: All the messy environment logic – hunting down loose files, enforcing vanilla precedence rules (e.g., checking the Override folder before extracting from a BIF archive), and assembling composite files – lives safely isolated inside rakata-extract. This separation guarantees our parsers can cleanly process isolated test files just as well as they operate in a massive live-game workflow.

How 2DA Tables Reach a Decoded View

Decoded views resolve file-native values against 2DA tables, which means something has to hand them a table. For a long time that something was TwoDaCache itself, and since the cache is GameVfs-backed, rakata-generics depended on rakata-extract to get it. That edge pointed backwards: generics is foundational and extract sits above it, so every consumer that only wanted to parse and resolve bytes got the whole VFS along for the ride.

The fix was to notice that twoda_cache.rs had three separable things fused into one file:

  • Identity – what tables exist and what they are called: TwoDaName, tables::*. This is game-content knowledge of the same kind ResourceType carries, so it lives in rakata-core. A 2DA parser has no business knowing appearance.2da exists.
  • Capability – something can hand me a table by name: the TwoDaSource trait. It returns &TwoDa, so rakata-formats is the lowest crate that can name the return type. A home in core would need core -> formats, which would be a worse inversion than the one being fixed.
  • Implementation – the VFS hands me tables and I remember them: TwoDaCache, TwoDaCacheError. The cache holds a &GameVfs, and nothing below extract knows what a VFS is, so this stays in rakata-extract.

Generics needs identity and capability. It never needed implementation; it was taking implementation in order to get the other two. resolve() now takes &mut impl TwoDaSource, and rakata-extract appears nowhere in the generics manifest – not in [dependencies], and not in [dev-dependencies] either. Tests that span both crates live in the rakata facade, which is the umbrella over all four members and the natural home for tests that cross them.

TwoDaSource::twoda returns Option<&TwoDa> rather than a Result. Every consumer discarded the error, so an error type would have carried a variant nothing reads. A caller that genuinely needs to tell “no such table” from “the bytes would not parse” asks the cache directly through its inherent method, which still carries the full TwoDaCacheError.

rakata-lint also depends on rakata-extract, but not under the same rule, and the two should not be cited together. Lint is a leaf: nothing depends on it, so a concrete dependency propagates to nobody and there is nothing to revisit. Generics is foundational, and that is the whole reason its case stayed open long after it was first flagged. Weigh this kind of call by where the crate sits in the graph, not by whether the edge looks tidy in isolation.

One process note worth keeping. This was flagged as an architectural error once and correctly deferred, on the grounds that every real consumer already depended on rakata-extract anyway, so building the trait then would have been the speculative abstraction the rest of this guide warns against. The deferral named a condition to revisit on. That condition quietly came and went and nothing prompted anyone to look, because a trigger only works if something is watching it. The second time it came up it became a ticket instead.

Tracing & Telemetry

We strongly encourage instrumenting format parsers with tracing::instrument spans to help pinpoint exactly where a badly formed file breaks during a parse. However, this telemetry must remain entirely zero-cost for consumers who don’t need it! We achieve this by wrapping public parser entry points in conditional attributes: #[cfg_attr(feature = "tracing", tracing::instrument(...))]. If a user doesn’t explicitly opt-in via their Cargo.toml, the Rust compiler strips the instrumentation entirely.

Serialization (Serde)

Just like tracing, serde support for exporting our parsed files to JSON or YAML must be treated as a zero-cost, opt-in feature. Format structs and types should generously derive Serialize and Deserialize when the serde feature flag is enabled. This allows downstream utilities (like the Save Editor) to effortlessly convert memory layouts into text formats, while ensuring the core parsers stay extremely light for purely binary-focused applications.

Beyond Basic Parsing

While rakata-formats gives us the ability to parse isolated bytes, the game engine is much more complicated. Our higher-level crates exist to bridge that gap between “dumb bytes” and “actual game logic”.

Finding Files (rakata-extract)

rakata-extract handles the messy reality of finding files scattered across a massive KOTOR installation. It mirrors the vanilla engine’s lookup hierarchy in three distinct layers:

  1. Primitives: Grabbing a file out of a single archive (like unpacking a standalone ERF or BIF file).
  2. Composition: Treating related archive sets as a single “Module” (like grouping a .mod file with its matching _s.rim and _dlg.erf files so they load transparently together).
  3. Game-wide: A GameVfs rooted at one install that owns each tier (chitin / BIFs, the Override/ directory, caller-pushed extra overrides, the single active CompositeModule, and a mounted save above all of them) and resolves resrefs through them in engine precedence order. See Resource System & Resolution for the full tier order and what the save tier does and does not shadow.

Because we want our extraction to perfectly mirror vanilla behavior, lookups are strictly case-insensitive, and loading precedence is explicitly designed to mirror how the original game works (so a file in the Override folder automatically beats a file buried in a BIF archive).

Strongly-Typed Data (rakata-generics)

When we parse a .utc Character file, rakata-formats just hands us a raw GFF tree of untyped labels and values. rakata-generics wraps those raw data blobs in strongly-typed Rust structs (Utc, Uti, Are, Git, Dlg, Ifo, and friends). This guarantees that if a developer needs to access a character’s “Strength” stat, they get a guaranteed u8 property rather than blindly guessing string handles inside a raw binary tree.

Typed Views and Raw GFF

These typed structs sit beside the raw Gff tree, not on top of it. They are projections, not replacements. You construct one with Uti::from_gff(&gff) and round-trip back with uti.to_gff(); the original Gff stays accessible the whole time.

The projection layer follows one load-bearing rule: model what’s enumerated; drop what isn’t. from_gff extracts the fields each typed view documents and silently ignores anything else; to_gff writes only those documented fields. There is intentionally no extra_fields: Vec<GffField> accumulator on Utc / Uti / Are / etc. that would round-trip unmodelled fields through the typed layer.

The reason is correctness. Unmodelled fields often depend semantically on neighbouring fields (a savegame’s animation state only makes sense at the exact moment of save; a toolset’s custom annotations describe a specific revision). If the typed view silently preserved them while a caller edited a modelled field, the output would be internally inconsistent. The staleness contract is real, but it belongs explicitly with whoever needs byte-exact preservation, not buried inside a layer whose only job is type-safe access to known fields.

This naturally splits into two audiences served by one storage layer:

  • Tools (save editor, mod linter, format inspector) reach for the typed views. They want type-safe access to known fields and don’t care about unmodelled bytes.
  • Engines or byte-fidelity workflows (a future engine shim, a roundtrip auditor, anyone preserving toolset annotations) work directly with the raw Gff from rakata-formats. They own the staleness contract explicitly.
What gets enumerated: the engine has to read it

“Model what’s enumerated” only helps once you know what earns a place in the enumeration. The criterion is narrow on purpose:

A typed view models a field if and only if the engine reads it at that GFF path.

A field the engine never reads is a dead field. It gets recorded as dead where a reader will meet it, with the evidence, and it gets diagnosed by a Phase 1 lint rule walking the raw Gff. It does not go in the typed view. That is not a hole in the projection. crates/rakata-lint/ARCHITECTURE.md’s first design principle already puts schema and intra-resource checks on the raw tree for exactly this reason: they exist to validate the fields typed views drop.

Three clarifications carry most of the weight:

  • “At that path” is the whole test. A door blueprint’s Tag is read; the Tag on a door placement in a GIT is not, because the engine takes a templated door’s tag from the blueprint. Same label, same concept, one live copy and one dead one. A concept being live somewhere else says nothing about this copy.
  • Prevalence is not a criterion. WaypointList[].TemplateResRef appears in every waypoint in a retail install and no load path reads it. The corpus tells you where to look, never what to model.
  • “Path” means the GFF path, not a decode-time variant. The rule stops at the structural position and does not reach inside a decoded enum. PropertyList[].Subtype is read at its path, so it is modelled, even though which property kinds actually consume it varies; decoded/uti.rs keeping subtype_id on the kinds that ignore it is shape parity across variants, and correct. The question to ask is whether there is any configuration in which the engine reads this label at this structural position. For Subtype, yes. For WaypointList[].TemplateResRef, never.

The point of the lint rule is that a dead field is a modder trap: someone sets it, expects an effect, and gets nothing. Silence is the worst answer, and the typed view is the wrong place to break that silence.

Ifo.Mod_Hak, Utc.SaveWill and Utc.SaveFortitude are modelled and documented dead. They predate this rule and round-trip harmlessly, so they stay. Treat them as legacy exceptions rather than precedent; the next field’s case has to stand on the criterion above rather than on their existence.

The one place where projection meets enumeration-by-design is rakata_generics::decoded::DecodedProperty. UTI item properties carry a PropertyName that indexes into itempropdef.2da, a table mods can extend with new rows. The enum has an Unknown variant that preserves the raw fields for one entry within an enumerated list, so an unrecognized property kind still surfaces through the decode pass instead of being dropped. It is a per-entry catch-all, not a struct-level accumulator, and the staleness risk is low because property entries are independent records.

When in doubt: if you need byte-exact preservation across a parse-then-write cycle, work with the raw Gff. If you need ergonomic, type-safe access to the fields Rakata has audited, work with the typed view.

Decoded Views: Projection and Resolution

The typed structs (Uti, Utc, etc.) bring file-native fields into Rust types. A second layer on top, the decoded view, resolves those file-native fields against external context. For UTI, that context is the 2DA tables the engine consults at item-property evaluation time: itempropdef.2da for property-kind dispatch, baseitems.2da for combat / equip metadata, the iprp_* cost tables for magnitude resolution.

A decoded view splits into two stages so cross-scope analysis is a first-class operation:

  • Uti::project(itempropdef) -> UtiProjection<'_>. File-native typed-variant dispatch. Cheap, scope-free, takes only the minimal context (the property-kind dispatch table) needed to pick variants. The projection is the intermediate from which one or many resolutions are built.
  • UtiProjection::resolve(&mut impl TwoDaSource) -> UtiResolved<'_>. Resolves the projection against a full per-scope context. Loads every table the resolved view’s query methods could need and caches the values. All query methods on the resolved view are &self borrow-free reads against that cache.
  • Uti::resolve(&mut impl TwoDaSource) -> UtiResolved<'_>. Single-scope shortcut for project(...).resolve(...). Most callers want this.

The split exists because tools, the linter, and a future engine shim want to ask “what does this UTI look like under condition X” without re-running the file-native dispatch step for each context. Mod conflict analysis (does this item resolve differently with mod A loaded?), vanilla-vs-modded diffs, and reading a resource as a mounted save sees it all reduce to “build one projection, resolve under several contexts, compare.” The projection step is shared across resolutions; only the per-scope resolution repeats.

A resolved view does not retain the cache borrow once constructed. To query under a different scope, call projection.resolve(&mut other_cache) again on the same projection. The typed-variant dispatch is not redone.

The cost-table magnitude resolution recipe each resolved UTI view bakes in is documented in the Cost-Table Magnitude Resolution subsection of the UTI engine audit: which iprp_costtable.2da index every typed property kind dispatches through, which column carries the magnitude, and which handlers bypass the dispatch chain entirely.

UTC follows the same shape with format-specific differences: Utc::project() takes no minimal context (UTC has no single dispatch table; typed list dispatch happens at resolve time against per-list 2DAs), while UtcProjection::resolve(&mut impl TwoDaSource) loads racialtypes.2da / appearance.2da / portraits.2da / soundset.2da / classes.2da / spells.2da and caches scalar-id resolutions, typed DecodedClass variants, and typed DecodedSpecialAbility variants. UtcResolved exposes the same &self borrow-free query surface (race_label, classes, total_level, is_force_user, is_droid, has_class, special_abilities, equipment, inventory, etc.). Any future generic that grows a decoded view follows the same two-stage rule.

High-Level Interaction (rakata-save & rakata-lint)

Finally, crates at the top of the stack use our extraction logic and strongly typed generic structs to actually do things. rakata-lint compares typed structs against vanilla constraints to catch modding errors, while rakata-save gracefully handles unpacking, editing, and re-compressing massive save-game directories without corrupting the player’s campaign!

Contributing Guide

Welcome to the Rakata workspace! This guide outlines how we build, how we test, and the core rules for keeping our code clean, compliant, and maintainable.

License Policy

  • License: All workspace crates use GPL-3.0-or-later.
  • Third-Party Components: New dependencies must be compatible (MIT, Apache-2.0, BSD). Add them to THIRD_PARTY_NOTICES.md before merge.

Clean Room Implementation

To ensure everything we build is 100% our own original work and we aren’t accidentally borrowing from other community tools (if you’re curious about why we’re so strict about this, check out docs/src/legal.md):

  1. Reference Policy: Treat existing tools (like PyKotor) as behavioral references, not copy sources.
  2. No Copy-Paste: Do not copy source code blocks, large comments, or docstrings from third-party sources into Rust files.
  3. Re-Derivation: Derive implementation logic from format documentation, observed behavior (hex dumps), and black-box fixture analysis.
  4. Reverse Engineering:
    • Behavior verification via disassembly tools (e.g., Ghidra) is allowed for interoperability analysis.
    • Do not copy decompiled code into source files.
    • Record findings as paraphrased behavior notes natively within the relevant format specification under docs/src/formats/.

What belongs in the Engine Audits

The entire Rakata format specifications manual (docs/src/formats/) serves as the engine audit layer between reverse engineering and implementation. All Rust code is written strictly from these engine audits (specifically the Engine Audits & Decompilation sections embedded in each format’s blueprint), not from raw decompilation output.

  • Record: Field names, data types, default values, error conditions, and observable behavioral rules (e.g., “field X is clamped to range 0–100”, “list is sorted ascending by field Y”).
  • Do not record: Step-by-step algorithmic sequences, control flow structure, or implementation details that go beyond what is needed for interoperability. The test is: could someone implement correct behavior from this note without it dictating a specific code structure?

Format Work vs Engine Reimplementation

Right now, this workspace is exclusively focused on format parsing, linting, and modding tools - reading, writing, and validating the game’s actual data files. We are fundamentally just mapping out how the original game structures its data so we can build cool tools around it.

Building an actual game engine replacement (with gameplay logic, AI, and rendering pipelines) is a completely different beast for another day. But that’s exactly why these format blueprints are so critical: if someone wants to build an engine later, they can just use our shared engine audits to understand the data, rather than having to dig through raw decompiled binaries themselves!

Code Style & Linting

Pre-commit Hooks

We use pre-commit to keep the codebase consistently formatted without anyone having to manually police it. After cloning the repository, it’s highly recommended to set up the hooks:

pre-commit install
pre-commit install --hook-type pre-push

This registers two quick automated stages:

  • pre-commit: Formats your code via cargo fmt --all (auto-fixing it for you) and runs cargo clippy across all targets.
  • pre-push: Runs cargo test --workspace --all-features to ensure tests are green before you push.

Try to avoid skipping hooks using --no-verify. If a hook catches something, it’s usually just a helpful clippy suggestion or a quick formatting tweak!

Manual Checks

If you don’t like automated hooks and prefer running things manually from the workspace root before committing, you absolutely can:

cargo fmt --all
cargo clippy --workspace --all-targets --all-features
cargo test --workspace --all-features

Note: Passing --all-features to clippy and test is important so it catches optional code paths like serde and tracing! We just ask that fmt and clippy run cleanly before you open a Pull Request.

Idiomatic Rust

To keep the codebase consistently safe, lean, and fast, we heavily rely on a few core Rust principles:

  • Safe Numeric Casts: To prevent silent truncation bugs, we enforce #![warn(clippy::as_conversions)]. Avoid the raw as keyword; lean on From, TryFrom, or .into(). If an unsafe cast is truly unavoidable (like an f32 down to an i32), use a scoped #[allow(clippy::as_conversions)] and drop an inline comment explaining why it’s safe.
  • No Primitive Obsession: We heavily utilize strongly-typed wrappers (like ResRef) rather than passing raw [u8; 16] or String primitives around.
  • Strict Error Handling: We explicitly forbid .unwrap() and .unwrap_unchecked() in library code. Everything must propagate cleanly via Result using typed error enums (managed via thiserror).
  • Composition over Hierarchy: We prefer lean, flat structs and trait combinators over deep, messy object-oriented class hierarchies.
  • Honest Projections in Typed Views: Typed views over GFF in rakata-generics (Utc, Uti, Are, Git, Dlg, Ifo, Utd, Ute, Utm, Utp, Uts, Utt, Utw) model only the fields they enumerate. from_gff silently drops unmodelled fields and to_gff writes only the modelled ones. Do not add an extra_fields accumulator on the struct; callers that need byte-exact preservation work with the raw Gff tree directly. See Typed Views and Raw GFF for the rationale.
  • Project-Then-Resolve Decoded Views: Decoded views (UtiResolved, UtcResolved, etc.) split into a scope-free projection (format.project(...)) and a per-scope resolution (projection.resolve(&mut cache)). Uti::resolve(&mut cache) is the single-scope shortcut. All queries on a resolved view are &self borrow-free reads against eager-resolved cached state; the view does not retain the cache borrow. To query under a different scope, resolve the same projection again against a different context. See Decoded Views: Projection and Resolution for the rationale.
  • Iterators over Loops: We prefer functional iterator chains (map, filter, fold) over maintaining manual mutable state in for loops.
  • Zero-cost Features: Optional functionality (like serde serialization or tracing telemetry) must introduce absolutely zero overhead when disabled.
  • Safe by Default: We use #![forbid(unsafe_code)] across all core parser crates to enforce strict memory safety boundaries.

Testing & Quality

Our testing approach is a Gray Box strategy: we use our hard-earned white-box knowledge of the game engine (via Ghidra audits) to build extremely strictly-validated black-box test cases for our parsers. We want to test against how the real game engine behaves, not against artificial mocks.

When adding a brand new format, please make sure your PR includes:

  • Fixture-Backed Tests: Full roundtrip coverage using synthetic test files (stored in fixtures/). We never commit real game assets; run cargo test --test gen_fixtures -- --ignored to safely generate them! Byte-exact roundtrip assertions are the gold standard for any format where the engine consumes bytes exactly as written.
  • Mutation Tests: A quick pass to verify the parser safely rejects malformed or corrupted inputs without panicking (usually wired up via corruption_matrix.rs).
  • Module Documentation: A clean rustdoc block showing the basic format layout.

What Did This Green Actually Verify?

A passing test is not automatically evidence. Two failure modes turn up here often enough that they’re worth naming, and they answer the same question badly: what did this green actually verify?

It shares the code’s blind spot. A test written from the same understanding as the implementation verifies self-consistency, not correctness. When the minigame nesting was wrong, the reader, the are.rs module diagram and the round-trip test all encoded the same misreading — three sources agreeing was one error with three copies, and every enemy and obstacle in the game read as absent for as long as the type existed. The corpus was the only independent source, because it was the only one nobody wrote.

It never reached its subject. The check ran, reported success, and its target was never touched. Every one of these looked identical to a real pass:

  • cargo test <filter> matching zero tests still prints test result: ok. Hence scripts/test-filter.sh, which fails when a filter matches nothing.
  • A value comparison that walked one side’s fields skipped every label the other side lacked — so renaming a field on both sides passed.
  • Schema guards descend only where a schema entry declares children, so levels reached through children: None passed by never being visited.
  • A test for diagnostic aggregation tripped a type-mismatch rule first and returned before reaching the aggregation at all.
  • A guard written to catch skipped levels walked a default() value whose lists were empty, so it stopped above every level it existed to cover.

That last one is the shape in miniature: a coverage guard should prove it arrived somewhere, not just that it didn’t complain. If a test’s job is to reach a place and look, assert that it got there.

So falsify before trusting. Reintroduce the defect, run the test, and watch the right assertion fail. Four of the five above were caught this way and not otherwise. Where a check keys on something with two halves, falsify both — when a fold keyed on (path, reason), breaking the path half and breaking the reason half had to fail differently, and that’s what made the key defensible rather than merely plausible.

None of this argues for more tests. It argues for knowing what the ones you have actually looked at.

The Reserved Field Rule

Game engines are weird, and sometimes they leave mysterious “padding” or “reserved” sections in their binary formats. Every struct field that corresponds to a reserved region must be:

  • Stored strictly as a named array (e.g., reserved: [u8; N]) in the format struct.
  • Read directly from the source bytes verbatim.
  • Written back verbatim during a roundtrip.

If a writer zeroes out or silently drops a reserved field you parsed, we consider that a “lossless bug” – even if the engine doesn’t explicitly seem to use those bytes. If you’re constructing a brand new file from scratch, you can safely write zeroes for reserved regions, but the struct must be capable of storing exactly what it read off disk.

Release Process

(TODO: We haven’t cut an official production release yet! Right now we are aggressively building out the rakata-lint engine rules and expanding our format coverage. Once we officially stabilize v0.3.0 to crates.io, we’ll formalize our exact release checklist, dependency license refreshes, and CI pipelines here.)

Legal & Compliance

Disclaimer: We aren’t lawyers! The following information references specific legal statutes regarding software interoperability and reverse engineering simply to clearly demonstrate our commitment to strictly lawful development.

Project Intent

Rakata is an open-source research project and software library strictly designed to build interoperability with the data formats used by Star Wars: Knights of the Old Republic (KOTOR).

  • Our Goal: We want to empower users to access, read, edit, and safely modify their own legally purchased game files on modern operating systems using open-source tools.
  • No DRM Circumvention: This project completely avoids the game executable. We do not bypass, strip, or defeat any Digital Rights Management (DRM) or software encryption. We solely parse static data files (like .rim, .bif, and .mdl files) for the pure purpose of compatibility.
  • No Pirated Assets: This repository does not contain, distribute, or host any copyrighted game assets (art, sound, proprietary code, or binaries) owned by the original rights holders. You must supply your own legally obtained copy of the game to do anything useful with this software.

This project operates under the specific “Interoperability” exceptions provided by copyright law in major jurisdictions:

🇨🇦 Canada (Jurisdiction of Maintainer)

Under the Copyright Act (R.S.C., 1985, c. C-42), this project relies on Section 30.61, which permits the reproduction of a computer program for the purpose of:

  • (a) obtaining information that is necessary to allow the computer program to be compatible with another computer program; or
  • (b) correcting errors in the computer program.

🇺🇸 United States

Under the Digital Millennium Copyright Act (DMCA), this project operates under the 17 U.S.C. § 1201(f) exception for Reverse Engineering, which states:

  • (1) … a person who has lawfully obtained the right to use a copy of a computer program may circumvent a technological measure… for the sole purpose of identifying and analyzing those elements of the program that are necessary to achieve interoperability of an independently created computer program with other programs…

🇪🇺 European Union (Host Jurisdiction - Codeberg)

Under Directive 2009/24/EC (Legal Protection of Computer Programs), this project adheres to Article 6 (Decompilation), which allows for the reproduction of code and translation of its form when:

  • (a) these acts are performed by the licensee or by another person having a right to use a copy of a program…
  • (b) the information necessary to achieve interoperability has not previously been readily available…
  • (c) these acts are confined to the parts of the original program which are necessary to achieve interoperability.

Acknowledgements

Portions of the initial file format logic were originally derived from research by the awesome PyKotor project (licensed under LGPL-3.0-or-later) and verified against original game binaries using clean-room reverse engineering techniques (via Ghidra and ret-sync).

  • This project is open-source and licensed under GPL-3.0-or-later.
  • Star Wars: Knights of the Old Republic is a trademark of its respective owners. This passion project is not affiliated with, endorsed by, or connected to Bioware, LucasArts, or Disney in any way.

Format Implementation Reference

This launchpad tracks the implementation status of KotOR file formats across our parsing libraries (rakata-formats) and our strongly-typed wrappers (rakata-generics).

Status Legend:

  • Full: Binary reader/writer implemented with roundtrip tests.
  • Generics: Strongly-typed wrappers and linting schemas implemented.
  • Partial: Basic parsing support, advanced features deferred.
  • Canonical: Validated against vanilla KotOR (K1) runtime behavior.

Archive Formats

FormatStatusNotes
BIFFullSupports variable/fixed tables. Deterministic 4-byte payload alignment. BZF compression feature-gated.
KEYFullFirst-match lookup semantics (native verified). Duplicate key insertions ignored.
ERFFullSupports ERF/MOD/SAV. Optional blank-block emission for MODs is explicit opt-in.
RIMFullSupports V1.0. Offset fallback handled. Tight packing.

GFF & Blueprints

FormatStatusNotes
GFF StructureFullCore binary parity for structs/lists/fields. Localized strings supported. Stable list ordering.
GenericsGenerics13 typed blueprints completed: ARE, DLG, GIT, IFO, UTC, UTD, UTE, UTI, UTM, UTP, UTS, UTT, UTW. Tied into rakata-lint.
FACDocumentedFaction & reputation table. Engine-audited spec page; not yet wrapped in rakata-generics.
BICReferenceAurora player-character record: a header (Mod_CommntyName, Mod_IsPrimaryPlr, ObjectId) around a UTC creature snapshot, structurally one IFO Mod_PlayerList entry. Used by character generation and transport; on disk only as the gated Player.bic, which normal K1 saves never produce. Reference-only, not separately modelled.

The save-only GFFs (NFO savenfo, PT partytable, GVT globalvars) each have a page under Save Games rather than here; they exist only inside a save folder and are handled by rakata-save. FAC keeps its own page because repute.fac is a real module and save resource. BIC gets only the reference row above: it is structurally an IFO/UTC record with no routine on-disk presence to model, and its gated Player.bic save behaviour is covered in the Save Game Deep Dive.

3D Models & Walkmeshes

FormatStatusNotes
MDL/MDXFullBinary reader/writer with full geometry, node hierarchy, controllers, and MDX vertex data. ASCII reader/writer for modder interop. In-game verified.
BWM / WOKFullV1.0 binary tables (vertices, faces, materials, etc.). Strict bounds validation.

Texture Formats

FormatStatusNotes
TPCFullContainer header/payload/footer. Canonical pixel-type mapping (DXT5 for type 4). Mip payload sizing matches native right-shift.
DDSFullSupports standard D3D headers and K1-specific CResDDS prefix (20-byte metadata).
TGAFullReader normalizes to RGBA8888. Canonical mode rejects grayscale RLE. Lossless passthrough when source pixels are unmodified.
TXIFullASCII format. Case-insensitive command tokens (native verified). Coordinate block support.

Text & Data Formats

FormatStatusNotes
2DAFullBinary V2.b.
TLKFullStrict language-aware decode/encode. Validated against test.tlk.
VISFullASCII format. Case-insensitive room normalization. Deterministic ordering.
LYTFullASCII format. Strict Windows-1252 text handling. Count-driven parsing.
LTRFullV1.0 headers. 28-char probability tables.

Audio Formats

FormatStatusNotes
WAVFullStandard RIFF + KotOR SFX/VO obfuscation wrappers. MP3-in-WAV unwrapping support.
LIPFullV1.0 header + keyframes. Deterministic writer.
SSFFullV1.1 header + 28-slot sound table.

Missing / Deferred Formats

These formats are currently unimplemented or do not yet have strongly-typed wrappers in rakata-generics.

FormatStatusNotes
NCS / NSSDeferredNWScript Source and Compiled bytecode. NCS decompilation is slated for future work via an independent pipeline.
GUIDeferredGraphical User Interface layout blueprints (GFF).
JRLDeferredJournal and quest tracking blueprints (GFF). The in-save journal is documented on the partytable page.
PTHDeferredPathfinding graphs and navigation waypoints (GFF).
ITPDeferredItem Palette definitions (GFF).
BIKDeferredBink Video container (proprietary video format). Unlikely to be implemented natively.

Provenance Policy

Because this project seeks to achieve strict interoperability with a two-decade-old engine, mere “correctness” is insufficient. We guarantee canonical behavior.

  • Target: Canonical vanilla Star Wars: Knights of the Old Republic 1 (2003).
  • Engine Audits: We do not guess how the engine behaves. Code is written exclusively from observed engine evidence notes derived from clean-room reverse engineering (via Ghidra/ret-sync). Every implementation choice is documented directly inside that format’s specific page on this site.
  • Verification: Behaviors are locked via deep integration tests against synthetic fixtures. If a parser perfectly round-trips an invalid file but the game engine rejects it, it is treated as a critical bug.

Archive Formats

At the heart of the Odyssey Engine is its virtual file system. Instead of loading tens of thousands of tiny loose files straight from the local disk, the engine efficiently streams them from large, concatenated archive blobs. You can think of these formats as extremely specialized zip files used to store binary models, compiled scripts, textures, and UI data.

Note

KOTOR utilizes a highly strict two-tier architecture. BIF & KEY act as the core foundational registry for all base-game assets (e.g. data/models.bif is mapped using chitin.key as the absolute global lookup index). Meanwhile, ERF & RIM files act as completely independent, self-contained archives used aggressively for loading localized module levels, stateful save games, and community mods.


Implementation Blueprints

FormatNameLayout & Purpose
BIFBinary Information FileMassive binary payload silos containing raw game assets packed end-to-end.
KEYGlobal Index FileMaster lookup table mapping precise file names directly to their internal BIF payload offset block.
ERFEncapsulated Resource FileExtremely versatile package format utilized heavily for modules (.mod), stateful save games (.sav), and generic archives (.erf).
RIMResource ImageStripped-down, fast-loading, highly compact localized module containers (often used to split up geometry models vs dynamic entity layouts).

BIF (Binary Information File)

BIFs are essentially giant, uncompressed data silos. Because they act as the raw storage tier of the KOTOR engine, they don’t waste bytes on complex metadata or internal filenames – they are simply pure, tightly packed continuous byte arrays for game resources. They are designed to be randomly accessed extremely quickly at runtime strictly via their companion KEY index file.

At a Glance

PropertyValue
Extension(s).bif, .bzf (compressed; mobile ports only)
Magic SignaturesBIFF (version V1 ) for both
TypeArchive Blob Payload
Rust ReferenceView rakata_formats::Bif in Rustdocs

Data Model Structure

The rakata-formats crate handles raw Bif parsing for you by reading the internal offset tables. However, developers very rarely interact with a raw Bif file on its own.

  • Unified Access: Typically, you’ll use the KeyFile API (rakata_extract::keyfile::KeyFile), which automatically ties .key index files to their .bif data payloads so you don’t have to map them yourself.
  • Seek Performance: To prevent loading 100MB+ binary files completely into memory just to read a tiny script, Rakata parses the archive’s entry table once, caches it, then jumps straight to the coordinate of the single resource you asked for. models.bif alone is nearly a gigabyte, so this is the difference between a lookup costing a hash probe and it costing the whole file.

The Compressed Variant (.bzf)

Compression did not exist in the original 2003 PC release. Aspyr added it for the modern mobile ports to save storage, and it is the one place the BIF format gets genuinely confusing, because nothing inside the file says it is compressed.

PropertyUncompressedCompressed
SignatureBIFF / V1 BIFF / V1 (identical)
Header and tablesas documented abovebyte-for-byte the same shape
data_size in the tablethe resource’s lengththe uncompressed length
How the KEY names itdata\2da.bifdata\2da.bif (still .bif)
On-disk filename2da.bif2da.bzf

The extension is the only discriminator that exists in shipping data. Everything else matches. That has a direct consequence for tooling: compression has to be stated, not sniffed. Rakata reads it from the path when opening a file, and requires it as an argument when reading from a window that has no filename attached.

Payload Layout

Each resource is stored as its own LZMA-alone stream:

0x00        properties byte     packs (pb * 5 + lp) * 9 + lc
0x01..0x05  dictionary size     u32, little endian
0x05..      compressed data     terminated by an end-of-stream marker

There is no length field in the stream, because the uncompressed length already lives in the entry table. And there is no packed length recorded anywhere: an entry’s compressed extent runs from its offset to wherever the next entry begins, with the last one running to the end of the file. Trailing alignment inside that span is harmless, since the decoder stops at the end-of-stream marker.

Note

Ground truth and its limits. The facts above are read from a shipping Android bundle (com.aspyr.swkotor), whose 26 compressed archives every one carry the BIFF signature. Rakata decodes all 26, each entry to its exact declared length. iOS and Switch are unverified and simply assumed to match.

Two things worth flagging as inference rather than fact. Community references describe a BZF signature; no file in the bundle uses it, so Rakata does not look for one. And the engine presumably swaps the extension when resolving a KEY entry on mobile, since the KEY says .bif and the disk says .bzf, but that path has not been traced in a mobile binary. The Ghidra project carries the Android builds, so it is auditable whenever someone wants to.

Engine Audits & Decompilation

The following documents the engine’s exact load sequence and field requirements for .bif archive headers mapped from swkotor.exe.

(Decompilation logic for this section was entirely audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from CExoResFile::LoadHeader (0x0040d910) and CExoResFile::ReadResource (0x0040da20).)

Archive Initialization (CExoResFile::LoadHeader)

Mapped from 0x0040d910.

Pipeline EventEngine Behavior & Result
Signature CheckThe engine strictly validates both the BIFF magic and the exact V1 version. It does not actively process any files that deviate from this signature pair.
Variable Table LoadingThe system extracts the variable_count value from the header and physically reads variable_count * 16 bytes from the variable_table_offset to map the resource keys.
Fixed Table BypassThe fixed_count header scalar is entirely decorative. It is not part of the active runtime read path (files with nonzero values are accepted but never mapped).
Direct Asset ExtractionWhen reading a physical asset out of the .bif, the engine isolates the entry_index using (resource_id & 0x3fff) * 0x10. It then calls a direct C fseek(SEEK_SET) strictly matching the raw data_offset extracted from the 16-byte variable table entry. No alignment or structural normalization is applied—the data is dumped entirely blindly.

Caution

Because the engine passes the internal data_offset integer directly into a raw C fseek(SEEK_SET), any custom BIF files must meticulously guarantee byte-perfect offset tables. If the offset is even slightly misaligned, the engine will read garbage data into the stream, inevitably crashing the game.

KEY (Global Index)

Think of the KEY file as the absolute master table of contents governing the entire game directory. Because uncompressed BIF archives are completely blind payloads that contain no internal filenames, the KEY file acts as the singular, authoritative index that tells the engine exactly which BIF holds which file, and precisely where to seek inside that BIF to find it.

At a Glance

PropertyValue
Extension(s).key
Magic SignaturesKEY (version V1 )
TypeArchive Global Index
Rust ReferenceView rakata_formats::Key in Rustdocs

Data Model Structure

The rakata-formats crate evaluates the .key file as the holy grail mapping for global engine initialization.

  • Indices Hierarchy: Internally, the format houses an array of bif_entries bounding archive paths and sizes, alongside a massive array of KeyResourceEntry structures fusing a standard ResRef string and a format TypeCode to a bit-packed numeric ResourceId.
  • Conflict Resolution: Because the game engine relies on a strict override hierarchy, multiple KEYs might accidentally declare the same resource! When constructing the active dictionary out of a KEY file (KeyFile::build_key_resource_index), Rakata explicitly utilizes or_insert() to strictly ensure only the first defined entry for a conflict is honored, perfectly mimicking the engine’s aggressive linear-scan precedence rules.

Engine Audits & Decompilation

The following information documents the KOTOR engine’s exact load sequence and field constraints for genuine .key files. All behavior was mapped natively from swkotor.exe during clean-room reverse engineering.

Key Table Registration (CExoKeyTable::AddKeyTableContents)

Mapped from 0x0040fb80.

ActionEngine Behavior
Signature CheckValidates exactly for the KEY magic and the explicit V1 version signature.
Version BranchingThere is absolutely zero logic handling any speculative V1.1 version branch in vanilla K1. It is currently unknown if a V1.1 KEY format actually exists in the wild, but the engine certainly wouldn’t load it.
Payload MappingExtrapolates the file location natively by tearing apart the ResourceId bitmask to locate both the target BIF file index and the internal struct array offset.

Note

The engine handles KEY table loading extremely early in the application lifecycle during CExoBase::InitObject. If a global KEY fails to mount due to malformed headers, the engine immediately aborts execution.

ERF (Encapsulated Resource File)

ERFs are the heavy lifters for standard game modules (.mod) and save game architectures (.sav). Unlike BIFs, which rely entirely on an external KEY file to resolve their resource identities, ERFs are completely self-contained entities that carry their own internal file tables, localized descriptions, and asset payloads.

At a Glance

PropertyValue
Extension(s).erf, .mod, .hak, .sav
Magic SignaturesERF , MOD , HAK , SAV (version V1.0)
TypeSelf-Contained Archive
Rust ReferenceView rakata_formats::Erf in Rustdocs

Data Model Structure

ERF files share the same structural responsibility as RIM files: both act as self-contained module wrappers. Rakata hides that difference at the module level rather than the archive level.

  • Reading one archive: rakata_formats::erf::ErfIndex parses the header and entry tables once and reads entry bytes on demand, so opening a large archive costs table parsing rather than its full size. RimIndex is the RIM equivalent. They stay separate types because their tables genuinely differ.
  • Reading a module: rakata_extract::CompositeModule is what makes the container invisible. It merges a module’s .rim, _s.rim, _dlg.erf and .mod parts behind one lookup, so callers ask for a resource by name and type and never name the archive it came from.

Engine Audits & Decompilation

The following information documents the KOTOR engine’s exact load sequence and field requirements for genuine .erf capsule variants. All behavior was mapped natively from swkotor.exe during clean-room reverse engineering.

Capsule Header Initialization (CExoEncapsulatedFile::LoadHeader)

Mapped from 0x0040e1f0.

ActionEngine Behavior
Signature CheckExplicitly validates the header against exactly matching ERF , MOD , or HAK signatures, paired with the mandatory V1.0 version string.
Unchecked SavesThe engine completely lacks a validation branch for .sav files. If a file is loaded as a Save Game (param flag 1), the engine falls through the validation tree and explicitly mandates the file use the MOD magic string natively. An ERF file with SAV magic will physically crash or reject here!
Header TruncationThe loader explicitly pulls the entire 160-byte header into scope (CExoFile::Read(..., 0xa0)), but only evaluates offsets 0x00 through 0x1C. Offset 0x18 (Key List) and anything beyond 0x1C is entirely ignored during initialization.

Tip

The 116-Byte “Dead Zone” The giant block of bytes stretching from physical offsets 0x2C down to 0xA0 inside the 160-byte header is formally loaded into the engine’s active memory stack… and then completely discarded immediately. It is totally inert data containing old Bioware build metadata.

RIM (Resource Image)

RIM files operate as a radically leaner alternative to ERFs. They are used exclusively by the game engine for distributing absolutely essential or lightweight modules without the hefty structural metadata overhead of an ERF file. They provide rapid, self-contained loading for core engine environments.

At a Glance

PropertyValue
Extension(s).rim
Magic SignaturesRIM (version V1.0)
TypeLightweight Archive
Rust ReferenceView rakata_formats::Rim in Rustdocs

Data Model Structure

RIM files are a lightweight twin to the ERF format, and Rakata treats the pair the same way: separate readers per container, one merged surface per module.

  • Reading one archive: rakata_formats::rim::RimIndex parses the header and entry table once and reads entry bytes on demand. ErfIndex is the ERF equivalent. They stay separate types because the tables genuinely differ, even though the job is the same.
  • Reading a module: rakata_extract::CompositeModule merges a module’s .rim, _s.rim, _dlg.erf and .mod parts behind one lookup, so a caller asking for a resource never has to know which container answered.

Engine Audits & Decompilation

The following information documents the KOTOR engine’s exact load sequence and field requirements for genuine .rim capsule variants. All behavior was mapped natively from swkotor.exe during clean-room reverse engineering.

Resource Image Overrides (CExoKeyTable::AddResourceImageContents)

Mapped from 0x0040f990.

ActionEngine Behavior
Signature CheckExplicitly validates the exact RIM magic and the V1.0 version string implicitly upon loading.
Header EvaluationThe engine physically reads the entry_count (offset 0x0C) and the keys_offset (offset 0x10) from the header to explicitly navigate the file structures.

Tip

The 96-Byte “Dead Zone” Exactly like the ERF dead zone, RIM files feature a massive 96 bytes of completely inert padding sitting physically between offsets 0x18 and 0x77 inside the 120-byte header. The engine blindly sweeps right past it during initialization. It is perfectly safe to zero out this region when generating new synthetic fixtures.

GFF (Generic File Format)

The Generic File Format (GFF) is BioWare’s core binary serialization format, functioning like a binary JSON object or XML tree. It holds arbitrarily nested structures, typed fields, and lists, powering UI layouts, character sheets, dialogues, and area descriptions.

At a Glance

PropertyValue
Extension(s).gff, .utc, .uti, .utp, .ute, .utd, .dlg, .are, .ifo, etc.
Magic SignatureTarget type (e.g. UTC ) / V3.2
TypeGeneric Hierarchical Data
Rust ReferenceView rakata_formats::Gff in Rustdocs

Data Model Structure

The rakata-formats crate maps the GFF struct/field/list indexing graph into an in-memory model (rakata_formats::Gff).

  • Typed values: GFF fields carry discrete types (BYTE, SHORT, VOID, STRUCT, LIST, …). rakata_formats::GffValue mirrors them one-to-one, so callers never touch raw byte layouts or indirect index arrays.
  • Label deduplication: GFF caps field labels at 16 characters and deduplicates them in a contiguous LabelTable. The writer reproduces this layout exactly, so serialized binaries are deterministic and byte-compatible with what the engine emits.

Engine Audits & Decompilation

Binary: swkotor.exe

Serialization Architecture (WriteGFFFile)

Derived from 0x00413030 / 0x004113d0.

The engine allocates the output buffer entirely in-memory and serializes exactly 7 contiguous sections in an absolutely strict order. No inter-section padding or reserved alignment bytes are inserted anywhere natively. Each section’s byte-offset is dynamically snapshotted into the 56-byte header, operating as the canonical write path utilized for save games and area extraction.

Phasing OrderSection ComponentMemory Footprint / Quirk
Phase 1Root HeaderExactly 56 bytes (0x38).
Phase 2Struct Array12B × struct_count
Phase 3Field Array12B × field_count
Phase 4Label Array16B × label_count
Phase 5Field Data BlobArbitrary bounds constraint.
Phase 6Field IndicesDynamic array bounds.
Phase 7List IndicesDynamic array bounds.

Warning

Because BioWare enforces fixed 16-byte elements inside the Label arrays, any label that exceeds 16 characters is strictly truncated by the engine array bounds.

Note

The GFF version is always V3.2. The header’s version field is written by CResGFF::CreateGFFFile (0x00411260) from a single global value, and the version string a caller passes in is ignored. So the on-disk version never varies, even where the calling code asks for something else (several save-game writers request V2.0, but it never reaches disk). Read the V3.2 you observe; the version is not a per-resource signal.

Note

Field-label lookup is case-sensitive. Every CResGFF::ReadField* wrapper resolves its label through CResGFF::GetFieldByLabel (0x00411630), which copies the requested label into a fixed 16-byte buffer with no case-folding and compares it against each field’s stored label with an inlined byte-for-byte comparison, not a case-insensitive string function. A label that differs from the one the engine’s own code constructs only in capitalization – FortBonus versus the engine’s fortbonus, for instance – never matches, full stop; it isn’t a fallback path, it’s a different, unmatched string. This holds for the whole ReadField* family (every scalar and string type), so exact-string field matching in a reader is the behaviorally-correct model of this engine, not a shortcut that happens to work on vanilla data.


Engine Blueprints: Specialized GFF Containers

While the gff.md reference explains the layout of raw GFF nodes, the engine frequently uses GFF as a structural wrapper to serialize completely deterministic entities known as Blueprints. These blueprints operate as the strict layouts defining creatures, dialogue trees, placeables, and area parameters.

Because rakata-lint provides deep behavioral validation over these blueprints natively, we have comprehensively audited how the K1 GOG executable (swkotor.exe) maps these layouts into active memory via its Load*FromGFF functions.

Note

The typed blueprint structs documented below (Utc, Uti, Are, Git, Dlg, Ifo, Utd, Ute, Utm, Utp, Uts, Utt, Utw) are projections over raw GFF, not replacements. Each from_gff extracts only the documented fields and silently drops anything else; to_gff writes only those documented fields. The raw Gff tree stays alongside the typed view for callers that need byte-exact fidelity. See Typed Views and Raw GFF in the architecture guide for the full rationale and the choose-which-layer guidance.

The Blueprint Engine Audits

The audits listed in this section’s navigation bar are formal, decompilation-backed blueprints cataloging KOTOR’s physical constraints. They document the exact fields, load phrasing, and engine rule evaluations that supersede any generic structural validity.

If a field exists in GFF but breaks the engine, our Linter rules will flag it using these documentation audits as the source of truth.

ExtTypeCore Function
.areArea Static BlueprintDefines overarching static world properties (weather, day/night limits, physics constraints).
.dlgDialogueEncapsulates the conversation graph, branching logic, and cinematic execution sequences.
.gitGame Instance TemplateThe physical object manifest. Orchestrates exact placement, vector orientations, and template spawning.
.ifoModule InfoRoot environment metadata bridging modules together and orchestrating spawn states.
.utcCreatureInstantiates NPCs, stat-blocks, and character body configurations.
.utdDoorConfigures transitions, linked bounds, and structural barriers.
.uteEncounterOrchestrates dynamic boundary triggers and valid enemy spawning constraints.
.utiItemUnifies structural stats across weapons, armors, and consumables.
.utmStoreLimits merchant arrays and details markup/markdown behaviors.
.utpPlaceableStandardizes interactive storage boxes, unusable statues, and deployable traps.
.utsSoundConfigures local dynamic audio emitters and distance volume calculations.
.uttTriggerPlots physical interactive polygons tracking spatial events.
.utwWaypointAnchors spatial float positions for navigation grids and area transitions.

ARE Format (Area Static Blueprint)

The Area (.are) blueprint format operates as the static environmental foundation of any game module. It establishes the rigid, overarching properties of a level, orchestrating the terrain’s grass rendering definitions, dynamic sunlight and fog constraints, ambient audio scale, and the primary interior/exterior state configurations. It effectively constructs the structural ‘stage’ that dynamic entities (like creatures and doors) populate later on.

At a Glance

PropertyValue
Extension(s).are
Magic SignatureARE / V3.2
TypeArea Static Blueprint
Rust ReferenceView rakata_generics::Are in Rustdocs

Data Model Structure

Rakata maps an Area into the rakata_generics::Are struct. The struct’s Rustdocs document every field’s binary schema and GFF mapping; the table below is the high-level anatomy.

CategoryCoversRepresentative fields
Core Identity & StateThe area’s tag, localized name, and interior/exterior state flagsTag, Name, Flags, RestrictMode
Weather & TerrainRain, snow, and lightning chances, wind strength, and grass renderingChanceRain, WindPower, Grass_TexName
Lighting & FogSeparate sun and moon ambient/diffuse tints, fog ranges, and shadow limitsSunAmbientColor, MoonFogNear, ShadowOpacity
Stealth XPThe stealth-run XP pool an area can awardStealthXPMax, StealthXPCurrent, StealthXPLoss
Event HooksThe area-level event scriptsOnEnter, OnExit, OnHeartbeat, OnUserDefined
Map & RoomsMinimap projection data and the per-room sound listMap, Rooms
MinigameThe optional nested swoop or turret minigame configurationMiniGame

rakata-lint validates these fields against the engine constraints documented below.

Note

The ARE never gets re-saved – it gets re-copied. When a module is saved, the engine doesn’t parse or rewrite a single ARE field. CSWSModule::SaveModuleFinish hands the area’s static ARE resource to a generic “copy this resource type verbatim” helper, which looks up whatever ARE the resource manager currently has bound for that area and copies it byte-for-byte into the save archive. Every engine rule and clamping behaviour documented on this page (weather truncation, fog clamping, tag lowercasing, and so on) applies only to the initial parse when a module is freshly loaded, never to the save/resume cycle.

The area’s actual dynamic, session-changeable state – weather, stealth XP, map exploration, cameras, transition flags – lives entirely in the module’s GIT resource instead, not in a re-saved ARE. If you’re building a save-editing tool, patch the GIT; don’t expect to patch an ARE inside a save archive.

Engine Audits & Decompilation

The following documents the engine’s exact load sequence and field requirements for .are files mapped from swkotor.exe.

(Decompilation logic for this section was entirely audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from CSWSArea::LoadArea at 0x0050e190.)

The initial LoadArea dispatch branches out to parse the .are GFF, .lyt layout, .git instance tracking, and .pth bounds. The engine processes roughly 61 scalar fields, 4 scripts, 3 lists, and a nested minigame struct natively within the LoadAreaHeader subroutine.

Core Environmental Identity

Field CategoryEngine Property & Behavioral Quirk
IdentityName (LocString), Comments (String), ID (Int) -> Standard definition strings. All three default unconditionally when absent: Name to an empty localized string, Comments to an empty string, ID to 0. Creator_ID (Int) and Version (DWord) share the same unconditional-0 default.
IdentityTag (String) -> Lowercased on load (via CExoString::LowerCase). The only tag to behave this way!
ScriptsOnHeartbeat, OnUserDefined, ... -> CResRef script payloads.
State FlagsFlags (DWord) -> Bit 0 explicitly marks an Interior environment.
State FlagsRestrictMode (Byte) -> Hardcoded Event: Changing this to a non-zero value during gameplay forces CSWPartyTable::UnstealthParty.
IdentityCameraStyle (Int), DefaultEnvMap (ResRef), LoadScreenID (WORD) -> All three default unconditionally when absent: CameraStyle to 0, DefaultEnvMap to an empty resref, LoadScreenID to 0.

Note

Internal Weather Truncation If Flags (Bit 0) marks the area as an interior space, the engine zeros out all weather properties upon load, actively discarding any prior weather assignments.

Weather & Terrain Generation

FieldTypeEngine Evaluation
ChanceFogINTStored persistently as an integer. Defaults to 0 if missing, unconditional – the same read shape as ChanceRain/ChanceSnow/ChanceLightning/WindPower (all default 0 too), just not narrowed to a byte. When the area’s interior flag is set, ChanceFog gets zeroed a second time in the post-process pass alongside the other four, so an interior area’s fog chance is forced to 0 twice over rather than once.
ChanceRain, ChanceSnow, ChanceLightning, WindPowerINTWarning: The engine explicitly truncates these INT properties to 8-bit bytes at runtime. Values over 255 silently wrap around. All four default to 0 if missing, unconditional.
Grass_TexNameResRefIf empty or invalid, the engine forces a hard fallback to "grass".
AlphaTestFLOATDefaults to 0.2 (older tools commonly assume 0.0).
ModSpotCheck, ModListenCheckINTPerception-check modifiers. Both default to 0 if missing, unconditional.
Grass_Density, Grass_QuadSize, Grass_Prob_LL/LR/UL/URFLOATGrass rendering density and per-corner spawn probabilities. All six default to 0.0 if missing, unconditional, identical read shape across the set.

Area Lighting & Sun/Moon Tracking

KOTOR handles dynamic sunlight constraints separately between Sun and Moon.

Property GroupsTypeEngine Evaluation
Fog Ranges (MoonFogNear/Far, SunFogNear/Far)FLOATDefaults to an immense distance of 10000.0. The engine aggressively clamps values to be ≥0.0.
Tints (*AmbientColor, *DiffuseColor, *FogColor)DWORDProcessed seamlessly as standard DWORD color masks. All nine (MoonAmbientColor, MoonDiffuseColor, MoonFogColor, SunAmbientColor, SunDiffuseColor, SunFogColor, DynAmbientColor, Grass_Ambient, Grass_Diffuse) default to 0 if missing, unconditional, identical read shape across the set.
Environment Shadows (ShadowOpacity, *Shadows)BYTEBasic toggles and opacities orchestrating render limits. SunFogOn, SunShadows, MoonFogOn, MoonShadows default to 0/false if missing, unconditional. Unescapable, StealthXPEnabled, and StealthXPLoss are the one group in this table that genuinely carries over a constructed value rather than stamping a fresh literal – functionally identical to 0/false in practice, since LoadAreaHeader only ever runs once, immediately after construction, on a single call path.

Note

DayNightCycle doesn’t inherit its own constructed default. The area constructor sets day_night_cycle = 1 (cycle on) before any GFF read happens, but the read itself uses a hardcoded literal 0 as its fallback, not the constructed value – so an area missing DayNightCycle loads with the cycle forced off, silently overriding what the object was built with. This looks like it could be an engine oversight (the constructor’s own default is never actually reachable through this load path), but intent aside, the mechanism is unambiguous: absent means 0, not 1. IsNight and LightingScheme share the ordinary unconditional-0 pattern with no such mismatch.

NoRest, TransPending, TransPendNextID, and TransPendCurrID also carry over a constructed value (all 0/false), same practical outcome as an unconditional default given the single-call-path caveat above.

Note

Grass_Emissive and the entire Dirty* overlay set (12 fields) are confirmed dead in this K1 build, not merely unread. None of their field-name strings – Grass_Emissive, DirtyARGBOne/Two/Three, DirtySizeOne/Two/Three, DirtyFormulaOne/Two/DirtyFormulaThre, DirtyFuncOne/Two/Three – exist anywhere in swkotor.exe’s string table, verified against a binary-wide search that does find every neighboring Grass_* field. This is stronger than “the loader doesn’t consume it”: no code path in this build can even look these fields up. Treat them the same as DisableTransit/NoHangBack/PlayerOnly/PlayerVsPlayer below – toolset-only, invisible to K1’s engine.

Map Transitions & Saving states

Feature CategoryEngine Evaluation & Triggers
Minimap LogicGeographic vectors (MapResX, spatial coordinate structs like WorldPt1X) are only loaded if an actual Minimap TGA/TPC asset matching the level name exists on disk! Two further gates sit behind that one: the Map sub-struct must itself be present in the GFF, and MapResX’s own resolved value must be nonzero. MapResX reads with an unconditional literal default of 0 – so an absent MapResX isn’t just “zero,” it’s a genuine gating sentinel, since that same 0 is then tested directly and, if it holds, skips reading NorthAxis/MapPt*/WorldPt* entirely and falls through to a fully disabled map initialization with MapZoom fixed at 1.
Parsing TypeIf read, the engine parses MapPt along a dual-path logic checking if it is formally a FLOAT or INT type. An absent MapPt1X/MapPt1Y/MapPt2X/MapPt2Y resolves to 0 either way the type check goes – the INT branch reads a literal 0 directly, and the FLOAT branch’s own 0.0 default survives its floor-conversion step landing on 0 too. NorthAxis defaults to 0 if missing, unconditional. WorldPt1X/WorldPt1Y/WorldPt2X/WorldPt2Y are independent FLOAT reads, each defaulting to 0.0, unconditional, no gating between them.
Zoom BiasArea maps evaluate MapZoom to a default scaling scalar of 1, not 0!
Stealth Save-StatesThe stealth framework leverages the .are struct to snapshot .StealthXPMax and .StealthXPCurrent directly as DWORDs when parsing the layout.

Per-Room EnvAudio

Each entry in the Rooms list carries its own EnvAudio INT, read by LoadAreaHeader alongside that room’s AmbientScale, defaulting to 0 when absent. It’s a genuine per-room reverb/environment-audio zone selector, not inert data: CSWRoom::SetRoomEnvAudioProps matches each room by name and copies its EnvAudio/AmbientScale pair into the client-side audio setup that runs when an area loads. (The exact downstream reverb table or effect it selects wasn’t traced past that per-room handoff.)

Don’t confuse this with a same-named field on the GIT’s own area-level AreaProperties struct (see GIT’s AreaProperties.EnvAudio) – that one is a different struct entirely, and unlike this per-room field, it’s never read by anything.

Rooms and PartSounds: Remaining Field Defaults, and Two Confirmed-Dead Fields

No entry-level presence-chain abort exists in either list: the room loop and the nested PartSounds loop both process every index regardless of whether an individual GetListElement call succeeds, so a partially-specified entry is always kept, with each field defaulting independently.

RoomName defaults to an empty string if missing, unconditional – the same name SetRoomEnvAudioProps matches against a live room, so an empty RoomName simply never matches anything. AmbientScale defaults to 0.0, unconditional and independently gated from EnvAudio’s own default (the two are read back-to-back but neither gates the other). ForceRating and DisableWeather are confirmed dead the same decisive way as Grass_Emissive/Dirty* above: neither field-name string exists anywhere in swkotor.exe, confirmed by a binary-wide search. Toolset-only in this build.

PartSounds entries: Looping defaults to 0/false, unconditional. ModelPart defaults to an empty string, unconditional. OmenEvent is read as a CExoString, not the INT its name might suggest – confirmed by its sole cross-reference in the binary, landing on a ReadFieldCExoString call inside this exact loop – and defaults to an empty string, unconditional. Sound defaults to an empty resref, unconditional; the (possibly-default) result is appended to the room’s sound list regardless of whether the file actually supplied one.

Expansion_List Defaults

Expansion_Name defaults to an empty localized string if missing, unconditional. Expansion_ID defaults to 0, unconditional. The list itself is only processed at all if the Expansion_List field is present in the GFF – that’s a list-level gate, not a per-entry one – but once inside, no entry is ever dropped for a missing field, same as Rooms/PartSounds above.

The Minigame Struct

Read via CSWMiniGame::Load (0x006723d0). If a minigame context triggers, the .are reads the nested Type (DWORD mapping 1=Swoop, 2=Turret). It injects highly specialized float properties modifying basic terrain speeds:

FieldInjection Default / Constraint
LateralAccelDefaults safely to 60.0.
MovementPerSecScales to 6.0 (Swoops), 90.0 (Turrets), or 0.0 otherwise!
Bump_PlaneBounds are heavily clamped to 0..3.
Nested ArraysThe struct natively requires sub-struct Player arrays (Models, Camera, Axes) and Enemy/Obstacles lists to operate properly.

The Player struct, each Enemies list entry, and each Obstacles list entry are three genuinely different shapes, not siblings sharing one flat layout – Player and Enemies share a common vehicle base (hitpoints, a nested weapon Gun_Banks list, Scripts, Sounds) with Player adding its own movement/track-boundary fields on top, while Obstacles are a much lighter leaf with only a Scripts struct. See the Swoop & Turret Minigame Deep Dive for the full field-by-field breakdown and absent-field defaults.


Implemented Linter Rules (Rakata-Lint)

Phase 1 (intra-resource, no context)

Implemented under rakata_lint::rules::are.

  1. ARE-001 (Context Discards): Warns when interior areas (Flags & 1) carry non-zero ChanceRain, ChanceSnow, ChanceLightning, or WindPower; the engine discards weather for interiors.
  2. ARE-002 (Weather Truncation): Warns when ChanceRain, ChanceSnow, ChanceLightning, or WindPower exceed 255; the engine truncates these to bytes at runtime.
  3. ARE-003 (Fog Clamping): Warns when MoonFogNear/Far or SunFogNear/Far are negative; the engine clamps fog distances to >= 0.0.
  4. ARE-004 (Tag Lowercasing): Warns when Tag contains uppercase characters; the engine lowercases area tags on load.
  5. ARE-005 (Toolset Fields): Informs when DisableTransit, NoHangBack, PlayerOnly, or PlayerVsPlayer are set; never read by the K1 engine.

Phase 2 (resource existence, requires LintContext)

Implemented under rakata_lint::rules::are_range.

  1. ARE-006 (Resref Existence): Warns when any of OnEnter, OnExit, OnHeartbeat, or OnUserDefined (.ncs) does not resolve, or when any Rooms[i].PartSounds[j].Sound (.wav) does not resolve in the configured resource sources.

Pending

  • Grass Texture Fallback: Informs when Grass_TexName is empty; the engine treats this as the literal string "grass".
  • Texture / MiniGame Resref Existence: DefaultEnvMap, Grass_TexName, and the nested MiniGame model / track / music graph – ResourceTypeCode mapping for engine-specific texture and model packs is still being audited.

DLG Format (Dialogue Blueprint)

Description: The Dialogue (.dlg) format is the beating heart of KOTOR’s storytelling. It acts as the master “script” for every conversation, cutscene, and cinematic sequence. Rather than just holding localized text, it acts as a branching storyboard that tells the engine exactly what the characters should say in audio, what animations they should perform, which camera angles to use, and when to fire off scripts that impact the plot.

At a Glance

PropertyValue
Extension(s).dlg
Magic SignatureDLG / V3.2
TypeDialogue Blueprint
Rust ReferenceView rakata_generics::Dlg in Rustdocs

Data Model Structure

Rakata maps a Dialogue into the rakata_generics::Dlg struct. The struct’s Rustdocs document every field’s binary schema and GFF mapping; the table below is the high-level anatomy.

CategoryCoversRepresentative fields
Root ConfigurationConversation-wide rules: skippability, pacing delays, and cinematic-versus-computer typeSkippable, DelayEntry, ConversationType, ComputerType
Termination HooksScripts fired when the conversation ends or aborts, plus the ambient audio bedEndConversation, EndConverAbort, AmbientTrack
Node GraphThe NPC entry and player reply nodes, plus the entry points into the graphEntryList, ReplyList, StartingList
Per-Node DeliveryEach node’s localized line, voice-over, camera framing, fades, and follow-up linksText, VO_ResRef, CameraAngle, RepliesList
Cutscene CastingStunt-model substitution and animation loops for cinematic participantsStuntList, AnimList

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 .dlg files mapped from swkotor.exe.

(Decompilation logic for this section was entirely audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from CSWSDialog::LoadDialog (0x005a2ae0), cascading through LoadDialogBase (0x0059f5f0) and LoadDialogCamera (0x0059eaa0).)

The LoadDialog subroutine processes the root-level conversation configuration before iterating over the heavily nested EntryList and ReplyList. For each of those conversational nodes, it delegates parsing to LoadDialogBase (for text and scripts) and LoadDialogCamera (for viewport directions).

Additionally, StartingList provides the dialogue entry points, while the StuntList associates cutscene actor models.

Root Conversation Configuration

Field CategoryEngine Property & TypeNotable Default or Behavioral Quirk
Identity & RulesCameraModel (ResRef), DelayEntry/Reply (DWord)CameraModel defaults to an empty resref. DelayEntry and DelayReply safely default to 0 if missing.
Identity & RulesSkippable (Byte)Explicitly defaults to 1 (True) if missing.
Logic HooksEndConversation, EndConverAbort (ResRefs), AmbientTrackFire when the dialogue terminates abruptly or via conclusion. Fallback to empty strings "" if missing.
Hardware InterfacingConversationType (Int)0 = Cinematic, 1 = Computer, 2 = Special. Cinematic explicitly unstealths the party. Defaults to 0 if missing, and that default is itself the sentinel that decides the branch: an absent field takes the identical Cinematic path as an explicit 0, indistinguishable at runtime.
Hardware InterfacingComputerType (Byte)Only evaluated if ConversationType is 1. Otherwise, standard camera positioning and animations are bypassed.
Equipment & ActionsUnequipItems, UnequipHItem, AnimatedCut, OldHitCheckAnimatedCut forces a global unpauseable state if non-zero. All four default to 0 if missing.

Shared Dialogue Node Properties (LoadDialogBase)

These fields apply to both entries (NPC spoken) and replies (Player spoken), and are parsed via LoadDialogBase.

FieldTypeEngine Evaluation
TextLocStringThe spoken localized string.
Script, Speaker, QuestStrings/ResRefsStandard execution scripts and entity mapping. Speaker and Quest both default to an empty string if missing.
WaitFlags, QuestEntryDWordDefaults to 0 if missing; WaitFlags is separately mutated by the Delay special case below, a later write, not its own absent-value.
Sound, VO_ResRefResRefSound Fallback: If Sound fails to execute, the engine will attempt to play VO_ResRef. If both fail, the bitmask SoundExists is forcibly downgraded to 0.
DelayDWordDelay Special Case: If value is 0xFFFFFFFF, the engine explicitly reads from the root DelayEntry/DelayReply field instead and modulates WaitFlags!
FadeTypeByteDetermines the FadeDelay and FadeLength. If set to 0 or missing, all fade configurations are zeroed inherently.

Warning

Two field defaults in rakata’s code don’t match the binary. PlotIndex reads with a fallback of 0, not the -1 rakata’s typed view currently defaults to. PlotXPPercentage reads with a fallback of 0.0, not 1.0. Neither field is referenced again after its read, so there’s no downstream consumption masking the discrepancy – both are plain misses that should be corrected in code.

Note

SoundExists’s own absent default is a genuine oddity: 0x80 (128), not 0 or 1. That’s what the field resolves to when absent and neither the runtime-downgrade condition (Sound and VO_ResRef both invalid, which forces it to 0) nor an explicit file value overrides it. FadeColor, FadeDelay, and FadeLength each default to zero (black, 0.0, 0.0 respectively) at their own read site – distinct from the later FadeType == 0 pass that zeroes them again regardless of what was just read.

Viewport Framing (LoadDialogCamera)

FieldTypeEngine Evaluation
CameraIDINTDependent Field: Only permitted when CameraAngle = 6 (Placeable Camera). Otherwise, the engine forces the ID to -1 regardless of the static binary value.
CamFieldOfViewFLOATAggressively validated. If the property is entirely missing or is explicitly negative, the engine forces the perspective to -1.0.
CamHeightOffset, TarHeightOffsetFLOATStandard float deltas. Both default to 0.0 if missing.
ListenerCExoStringDefaults to an empty string if missing.
CameraAngleDWordDefaults to 0 if missing – a plain, ungated default; it’s the value the engine later checks against 6 to gate CameraID, but the default itself carries no special meaning.
CameraAnimationWORDDefaults to 0 if missing.
CamVidEffectINTDefaults to -1 if missing, confirmed against the binary and matching rakata’s current code. Never read again after the store.

Active and Index (on RepliesList/EntriesList/StartingList entries alike) both default unconditionally when absent: Active to an empty resref, Index to 0. Neither default is a placeholder that gets special-cased later – both feed directly into real behaviour:

  • An absent (empty) Active genuinely means “always active,” not just “no condition configured that happens to evaluate true.” CSWSDialog::CheckScript, the function that evaluates a link’s condition at runtime, opens with an explicit check for an empty resref and returns true immediately without ever touching the script virtual machine. Only a non-empty resref gets compiled and run for real. Absence and “always true” are the same code path by construction.
  • An absent Index resolves to 0 and is bounds-checked exactly like an explicit 0 – the already-documented fatal-bounds-check behaviour runs against whatever value ends up stored, absent or not. Since 0 is a valid index into every target list, an absent Index doesn’t trigger the fatal path; it silently links to the first element of the target list instead.

Relational Data Trees

Dialogues operate as highly interconnected link-lists.

  • Entry -> Reply Links (RepliesList within an Entry Node): Maps the Index (DWORD) to the overarching .ReplyList bounds. Unique in that it exclusively parses the DisplayInactive Byte.
  • Reply -> Entry Links (EntriesList within a Reply Node): Maps the Index to the .EntryList bounds.
  • Start Indices (StartingList): Uses the exact same linkage schema as a Reply->Entry link. Validates Index against entry_count.

All three link-list variants read only Active (a CResRef condition script) and Index; RepliesList additionally reads DisplayInactive, and no link-list variant reads anything else.

Warning

Corrupted Link Constraints Index paths are strictly evaluated against the internal array bounds prior to traversing. If a node tries to link out of bounds, it immediately triggers a fatal Load Failure within the engine.

DisplayInactive defaults to 0 when absent from a RepliesList entry. At runtime, SendDialogReplies (0x005a3820) evaluates each reply link’s Active condition script; when that condition is false, DisplayInactive decides what happens next: a nonzero value still builds and sends the reply to the client (shown as a disabled option), while a zero value drops the reply from the outgoing list entirely, so the client never sees it at all. EntriesList and StartingList links have no equivalent field and are always dropped outright when their condition fails.

No vanilla .dlg file contains DisplayInactive at all, which means every vanilla dialogue takes the drop-entirely branch for every conditionally-failing reply – the shipped game never exercises the “show as disabled” behavior. Writing an explicit DisplayInactive = 0 is behaviorally identical to omitting the field outright (both resolve to the same default), so rakata’s writer emitting it costs nothing functionally, but it does diverge from vanilla’s own convention of never writing the field at all.

Fields the Loader Never Reads: NumWords, VO_ID, IsChild, Comment, LinkComment

A corpus scan of vanilla .dlg files turns up five fields present and carrying real values that no loader function ever reads. This isn’t inferred from tracing every call site – it’s confirmed directly: CResGFF::ReadField* always takes the field’s name as a literal string argument, and none of these five label strings, NumWords, VO_ID, IsChild, Comment, LinkComment, exist anywhere in the compiled binary at all (verified against a working string search that does find neighboring labels like VO_ResRef and DisplayInactive). A field whose name string doesn’t exist in the binary cannot be read by any code path, so this is a stronger result than “untraced” – these fields are structurally inert at runtime, full stop.

Because none of the five is ever handed to a reader, the usual “what happens when the field is absent” question doesn’t apply to them the way it does to fields the engine actually reads: there’s no default-substitution logic and no carried-over-value behavior to describe, because there’s no reader code path to hit in the first place. Presence or absence in the file makes no difference to engine state.

What each field appears to be, going by name and by the patterns already documented for other formats in this codebase:

  • NumWords: reads like toolset-computed word-count metadata (translation/VO-scheduling bookkeeping), never read back by the engine. No word-counting logic over Text exists anywhere in the binary either, so it isn’t silently recomputed and re-verified on load – it’s pure dead round-trip data.
  • VO_ID: reads like an authoring-side lookup key into an external voice-over production database, distinct from the engine’s own VO_ResRef playback resref. Never consumed by the runtime.
  • Comment: the same dead-authoring-metadata pattern already documented for UTC and UTD’s Comment fields, just more absolute here – those formats at least read the field into an unused struct member; DLG’s loader doesn’t reference the string at all.
  • IsChild and LinkComment: the corpus counts line up exactly (LinkComment present in exactly the 542 files where IsChild carries a value), suggesting the two are one editor-side subsystem rather than independent fields. This can’t be confirmed or refuted from the compiled engine, though, because the runtime loader never parses either field on any struct – not the node, not the link-entry struct, nowhere. Whatever relationship they have is entirely a property of the original toolset’s .dlg authoring format, invisible to and unenforced by the shipped game.

Ancillary Configuration Lists

  • AnimList: Defines custom Participant models and their accompanying Animation (WORD) action index to loop.
  • StuntList: Dictates which StuntModel should proxy standard rendering behavior for a given Participant.

Implemented Linter Rules (Rakata-Lint)

Phase 1 (intra-resource, no context)

Implemented under rakata_lint::rules::dlg.

  1. DLG-001 (Camera Angle Compliance): Warns when CameraID is populated while CameraAngle != 6; the engine forces the ID to -1.
  2. DLG-002 (Conversation Type Mismatch): Warns when ComputerType is set but ConversationType != 1 (Computer Dialog); ComputerType is dead data otherwise.
  3. DLG-003 (Ghost Delay Flags): Warns when an entry delay is maxed (0xFFFFFFFF) but no sound/VO is configured and the parent fallback delay is 0; the node terminates instantly.
  4. DLG-004 (Fatal Bounds Checking): Errors when any Index in a node’s link list, the starting list, or a reply list exceeds the target array bounds; this triggers a fatal engine load failure.
  5. DLG-005 (Context Zeroing): Warns when FadeDelay, FadeLength, or FadeColor are configured but FadeType=0; the engine discards the timings.

Phase 2 (resource existence, requires LintContext)

Implemented under rakata_lint::rules::dlg_range.

  1. DLG-006 (Resref Existence): Warns when any of EndConversation / EndConverAbort (.ncs), CameraModel (.mdl), AmbientTrack (.wav), per-stunt StuntList[i].StuntModel (.mdl), or per-node Script (.ncs), Sound / VO_ResRef (.wav), and Links[j].Active condition scripts (.ncs) do not resolve in the configured resource sources.

FAC Format (Faction & Reputation Table)

Description: The Faction (.fac) file is a single global table describing every faction in the game and how each faction feels about every other. The engine keys it on the resref REPUTE (type FAC ), and a save game stores the live, mutated copy under that resref. It is a flat GFF, not a per-object blueprint: there is one faction table for the whole session, not one per creature. Do not confuse it with repute.2da, a separate 2DA holding the static faction definitions a fresh game is seeded from (see Save-game context below).

At a Glance

PropertyValue
Extension(s).fac
Magic SignatureFAC / V3.2
TypeFaction & Reputation Table
Rust ReferenceNot yet modelled in rakata-generics.

Data Model Structure

A FAC file is two top-level lists:

  • FactionList: the roster of factions, in faction-id order (a faction’s id is its index in this list).
  • RepList: a sparse set of pairwise reputation overrides between factions.

Individual objects (.utc/.utd/.utp/.ute) do not embed faction data; they carry a single Faction id that indexes into this shared table.

Engine Audits & Decompilation

Documented from Ghidra decompilation of swkotor.exe (K1 GOG build); see the Provenance Policy. The field tables, the sparse-matrix rule, the reaction bands, and the global/personal model are read from:

FunctionAddressCovers
CFactionManager::SaveFactions0x0052b790FactionList write
CFactionManager::SaveReputations0x0052b830RepList write (only non-100 pairs emitted)
CFactionManager::LoadFactionsFromSaveGame0x0052b5c0FactionList read
CFactionManager::LoadReputationsFromSaveGame0x0052bbe0RepList read; the 100-baseline rebuild and 0-100 clamp
CFactionManager::GetIsNPCFaction0x0052b280Global vs personal faction model
CFactionManager::CreateDefaultFactions0x0052bce0Hardcoded default set used when no table loads
CFactionManager::LoadFactions0x0052b490Fresh-game path (repute.2da); origin of the FactionParentID sentinel, see below
ExecuteCommandGetNearestObject0x0054b550Reaction bands (0-10 / 11-89 / 90-100), corroborated by placeable/door/trigger usability checks

FactionList fields

FieldTypeMeaning
FactionNameCExoStringDisplay/lookup name of the faction.
FactionParentIDDWORDRead and round-tripped, but never consulted: see FactionParentID is a dead sentinel below.
FactionGlobalWORDWhether the faction is global. On load, a missing FactionGlobal defaults to 1.

A faction’s own id is its position in FactionList; it is not stored on the element.

Global versus personal factions. FactionGlobal flags whether a faction is one of the standard, shared factions (the set seeded from repute.2da) or a non-global one; when the field is absent on load it defaults to 1 (global). At the script layer, ChangeToStandardFaction moves a creature into a standard faction (the engine validates the target id with CFactionManager::GetIsNPCFaction and refuses otherwise), while ChangeFaction moves it into another creature’s faction; neither can change a player character’s faction. When no faction table loads at all, the engine falls back to a small hardcoded default set (CFactionManager::CreateDefaultFactions) rather than reading repute.2da.

FactionName defaults to a literal empty string when absent, unconditionally. An empty name still leaves the faction in the general roster – it’s just never wired up as one of the manager’s special named roles (player, hostile_1, friendly_1, hostile_2, friendly_2, neutral, insane), since an empty string can’t match any of those six comparisons.

FactionParentID is a dead sentinel

Every faction in a real save carries 0xFFFFFFFF in this field, which looks like a “no parent” sentinel – and it is one, but not a live one. LoadFactionsFromSaveGame reads FactionParentID unconditionally and copies it straight onto the in-memory faction record with no comparison, no branch, and no special case for 0xFFFFFFFF. SaveFactions writes that same stored value straight back out: a pure round-trip, not a use. Nothing in the faction subsystem – reputation lookups, faction-membership changes, the script-layer faction commands, anything with a CFactionManager* or CSWSFaction* in its signature – ever reads the stored value back out for a lookup or a decision. There is no parent-child traversal anywhere in this engine build.

The sentinel’s origin is mundane: LoadFactions, the fresh-game path that seeds factions from repute.2da (as opposed to restoring them from a save), hardcodes 0xFFFFFFFF for every faction it creates. Nothing downstream ever sets it to anything else, so it survives unchanged through every subsequent save and load. FactionParentID is best understood as vestigial infrastructure for a faction hierarchy the engine never implements, in the same family as this codebase’s other confirmed-dead fields.

RepList fields and the sparse-matrix rule

Reputation is conceptually an N x N matrix (every faction’s standing toward every other faction), but it is stored sparsely. The default standing is 100 (the top of the friendly band, see below), and the writer emits a RepList entry only for pairs whose reputation is not 100.

FieldTypeMeaning
FactionID1DWORDSource faction id.
FactionID2DWORDTarget faction id.
FactionRepDWORDStanding of faction 1 toward faction 2, 0-100.

Important

A missing pair means 100, not zero. On load the engine first rebuilds the full reputation matrix at its default baseline (every pair starts at 100), then applies the RepList entries as overrides. FactionRep is clamped to 0-100 on load (values at or above 101 snap to 100, negatives snap to 0). A reader that treats absent pairs as 0 will make the whole galaxy hostile.

FactionID1 and FactionID2 Share a Default, Not a Consequence

Both fields default to a literal 0 when absent from a present RepList entry – but that shared 0 behaves completely differently on each side, because of an asymmetric bounds check the write is gated on (FactionID2 must be strictly greater than 0; FactionID1 only needs to be a valid index, and 0 qualifies). An absent FactionID2 defaults to 0, fails that strict check, and the whole entry’s write is silently skipped – functionally the same as if the entry weren’t there at all. An absent FactionID1, on the other hand, also defaults to 0, but 0 passes as a legitimate faction index – so a RepList entry with a present, valid FactionID2 but a missing FactionID1 doesn’t get dropped. It gets written using faction index 0 as the row, silently overwriting whatever reputation pair happens to sit at (faction 0, FactionID2) instead of doing nothing. Same field type, same literal default, opposite outcomes.

FactionRep itself also defaults to a literal 0 when absent from a present, otherwise-valid entry – not the 100 sparse-matrix baseline documented above, which only applies when the whole pair is missing from RepList. A present entry with valid ids but no FactionRep writes a standing of 0 (hostile) into the matrix, clamped as a no-op since 0 is already in range, overwriting whatever baseline was sitting there.

There’s a second-order hazard worth knowing if you’re hand-editing a RepList: the loader reuses one found-flag variable across all three fields in an entry, and only checks it after FactionRep – the last of the three reads. A present FactionID1/FactionID2 with an absent FactionRep clears that shared flag, and the loader reads it as “this entry failed,” silently truncating every RepList entry that follows, not just the one with the missing field.

What the numbers mean

FactionRep is a 0-100 standing, and the engine reads it in three bands:

FactionRepReaction
0-10Hostile (treated as an enemy)
11-89Neutral
90-100Friendly (treated as a friend)

The same 10 and 90 boundaries turn up all over the engine: whether an NPC counts you as an enemy, whether a placed mine arms against you, and whether you may use a placeable, door, or trigger that a faction owns. So the default of 100 lands an unmodified pair squarely in the friendly band, not a literal midpoint.

Save-game context

There is no per-module faction file. When the engine stores a module it writes the entire global faction manager to a single REPUTE resource (type FAC ) in the GAMEINPROGRESS: working directory, which is then bundled into SAVEGAME.sav. So a save’s faction state lives at the resref REPUTE inside the main save archive, carrying the FactionList / RepList structure described above.

On load, LoadModuleStart probes for a REPUTE resource of type FAC . If one resolves (the save’s bundled copy, or a repute.fac that ships inside a module archive) it restores the table with LoadFactionsFromSaveGame / LoadReputationsFromSaveGame. If none is found, it builds the table from repute.2da instead and seeds the default reputations.

Note

repute.fac and repute.2da are two different resources, and only one of them is this format. repute.fac is the FAC GFF: the runtime/saved faction table (resref REPUTE, type FAC ). It is never a loose file. It lives bundled inside SAVEGAME.sav and inside some module archives. repute.2da is the static definition table the engine reads to build factions for a fresh game, and it is the repute entry you will find in chitin.key / 2da.bif. Go looking for repute.fac on disk and you will not find it. Only repute.2da turns up.

See the Save Game Deep Dive for how the save archive bundles its resources.

Implemented Linter Rules (Rakata-Lint)

None yet. The format is documented here ahead of any dedicated rakata-lint rules.

GIT Format (Game Instance Template)

Description: The Game Instance Template (.git) orchestrates the exact placement of every single entity within an environment. If the .are file is the underlying “stage”, the .git file acts as the blueprint for its “actors”–defining exactly where creatures initially spawn, where placeables sit, the physical rotation of doors, and the bounds of any active sound emitters.

At a Glance

PropertyValue
Extension(s).git
Magic SignatureGIT / V3.2
TypeInstance Blueprint
Rust ReferenceView rakata_generics::Git in Rustdocs

Data Model Structure

Rakata maps a Game Instance Template into the rakata_generics::Git struct. The struct’s Rustdocs document every field’s binary schema and GFF mapping; the table below is the high-level anatomy.

CategoryCoversRepresentative fields
Root BehaviorTemplate-versus-inline loading mode and the live weather stateUseTemplates, CurrentWeather, WeatherStarted
Object Instance ListsOne list per entity class, placing creatures, doors, placeables, triggers, sounds, encounters, waypoints, stores, items, cameras, and area effectsCreature List, Door List, TriggerList, SoundList
Per-Instance PlacementEach element’s template reference, position, and orientation (field naming varies by entity class; see below)TemplateResRef, XPosition, Bearing, ObjectId
Saved SnapshotsThe full inline object each list holds instead when UseTemplates = 0, as a savegame GIT stores itSavedCreature, SavedDoor, SavedPlaceable, SavedTrigger
Area SingletonsThe stealth and ambient-audio state struct, plus the save-only minimap exploration blobAreaProperties, AreaMap

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 .git files mapped from swkotor.exe.

(Decompilation logic for this section was entirely audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from CSWSArea::LoadGIT at 0x0050dd80.)

The LoadGIT subroutine is a massive dispatcher. It evaluates 3 immediate root scalars before handing off evaluation to 13 distinct object-list loaders mapping entities. Crucially, the flag UseTemplates dominates this process by dictating whether these lists refer to external files or contain fully inline entity data.

Root Behavior Properties

FieldTypeEngine Evaluation
UseTemplatesBYTEControls whether object arrays read TemplateResRef to construct entities, or fall back to inline evaluation.
CurrentWeatherBYTEStandard BYTE. Zeroed to 0xFF on Interior Areas. Absent-field default (before that interior override runs) is a fresh literal 0, unconditional – it overwrites whatever the object held, not a carry-over.
WeatherStartedBYTEStandard BYTE. Zeroed to 0 on Interior Areas. Same mechanism as CurrentWeather: absent-field default is a fresh literal 0, unconditional, applied before the interior override.

(The engine validates weather fields against the .are properties immediately during load).

Notably, UseTemplates itself has no save-writer path at all: SaveGIT never emits this field, confirmed by inspecting a real save file’s GIT struct directly (no UseTemplates field present). Every save relies entirely on the loader’s own hardcoded default of 0.

Note

The same schema serves two roles, and UseTemplates is the discriminator. A module’s static .git sets UseTemplates = 1: each object element is a sparse placement that carries a TemplateResRef, and the engine loads the matching blueprint (.utc/.utd/.utp/.utt/…) and overlays the few instance fields the element holds. A savegame GIT (bundled inside SAVEGAME.sav) instead sets UseTemplates = 0: each element is a full self-contained snapshot read field by field, with no TemplateResRef and no blueprint load. A field missing from a UseTemplates = 0 element resolves to the engine’s hardcoded default, not to a blueprint. See the Save Game Deep Dive for how savegames bundle and read these GITs.

One object type is effectively savegame-only: area-of-effect objects (AreaEffectList) are runtime spell/ability effects with no template (note their absence from the GIT-006 TemplateResRef set below), so they appear in savegame GITs rather than static module layouts.

How Rakata Models the Two Forms

Each dispatching list is a GitObjects<Static, Saved>: Static for UseTemplates = 1 placements, Saved for UseTemplates = 0 snapshots. The flag is not kept as a separate field on Git, because the enum already records the choice and storing both would let them disagree.

ListStatic formSaved form
Creature ListGitCreatureSavedCreature
Door ListGitDoorSavedDoor
Placeable ListGitPlaceableSavedPlaceable
TriggerListGitTriggerSavedTrigger
StoreListGitStoreSavedStore
SoundListGitSoundSavedSound
List (items)GitItemSavedItem

Three lists sit outside that shape:

  • WaypointList is a plain Vec<GitWaypoint>, because LoadWaypoints ignores the flag entirely and there is only ever one form to read.
  • AreaEffectList is a plain Vec<GitAreaEffect>, because these objects have no blueprint at all, so the saved form is the only form.
  • Encounter List is still read as static placements whatever the flag says, which is wrong for a savegame GIT. It is the one list whose saved field set is not modelled: no save in the fixture corpus carries an encounter, and unlike area effects its saved layout is not written down here either, so there would be nothing to check a model against.

Saved snapshots are checked against the fixture save corpus rather than synthetic fixtures: every object of every modelled type is parsed, written back, and compared field by field against what the engine originally wrote. AreaEffectList is the exception, since no fixture save has one; its field set comes from the load and save behaviour documented below, and a test asserts the list is still empty so a corpus that grows one does not go unchecked.

Field Naming Inconsistencies

Due to legacy asset sprawl, the engine evaluates vectors explicitly according to vastly different naming conventions depending entirely on the entity class. This is hardcoded into swkotor.exe.

Target ListsPosition ParadigmOrientation Paradigm
Creatures, Triggers, Items, Waypoints, StoresXPosition, YPosition, ZPositionXOrientation, YOrientation, ZOrientation (vector)
Doors, PlaceablesX, Y, ZBearing (single float angle)
Area EffectsPositionX, PositionY, PositionZOrientationX, OrientationY, OrientationZ (vector)
Sounds, EncountersXPosition, YPosition, ZPosition(none at the object level)

Bearing does not mean the same thing for the two classes that use it: a door stores its scalar bearing verbatim, while a placeable derives Bearing from the yaw of its orientation at save time (lossy). Encounter spawn points (inside SpawnPointList) carry a single-float Orientation per point, distinct from both the Bearing scalar and the orientation vector. Sounds have no orientation at all; they use Positional / RandomPosition flags plus RandomRangeX / RandomRangeY instead.

Warning

Orientation Normalization The engine strictly evaluates 3D orientation logic. If a normalized orientation vector (like in StoreList or AreaEffectList) inadvertently resolves to 0.0 unconditionally, the engine catches the math fault and applies a hard fallback vector to (0, 1, 0).

Standard Instance Arrays

Standard loaders evaluate the generic ObjectId, process the localized position/orientation floats, and dispatch behavior mapping logic.

List NameStruct TargetEngine Triggers & Fallbacks
Creature ListLoadCreaturesPositions are explicitly validated defensively through ComputeSafeLocation bounds.
Door ListLoadDoorsSave states trigger LoadObjectState. External templates dynamically route to LoadDoorExternal.
WaypointListLoadWaypointsCompletely ignores UseTemplates–it solely relies on inline data! Z-height is shifted dynamically via ComputeHeight.
TriggerListLoadTriggersGeometry properties reuse native UTT formatting. Contains unique linkage arrays: LinkedToModule, TransitionDestination, LinkedTo.

ObjectId Has One Default Across Every List, Static or Saved

ObjectId isn’t read by any of the per-type field loaders (LoadDoor, LoadPlaceable, LoadTrigger, the sound/store/encounter/item loaders, LoadWaypoint). It’s read exactly once per element, by the area-level list dispatcher (CSWSArea::LoadDoors, LoadPlaceables, LoadTriggers, LoadSounds, LoadStores, LoadEncounters, LoadItems, LoadWaypoints, LoadAreaEffects), before that dispatcher branches into the static-template path or the full-instance path. Every one of these nine dispatchers uses the identical default: 0x7F000000 (OBJECT_INVALID). Since the read happens once, ahead of the branch, and both branches receive the same already-assigned id, there is no static-versus-saved distinction for this field anywhere in the engine – one default covers every list, in both forms.

Creature is a structural exception, and on the static path the field is dead, not defaulted. CSWSArea::LoadCreatures branches on UseTemplates before touching ObjectId, not after. The full/save branch reads ObjectId the same way as every other type – CResGFF::ReadFieldDWORD(..., "ObjectId", ..., 0x7F000000) – so that branch genuinely defaults an absent field, same as everywhere else. The static/template branch is different in kind, not just mechanism: confirmed by decompilation, it never issues a ReadField* call against "ObjectId" at all, anywhere in the branch. 0x7F000000 reaches the CSWSCreature constructor as a bare literal, not a value derived from any read. Both branches land on the same number, but only one of them is “defaulting an absent field” – the static branch never looks at the field to begin with. Per this codebase’s modeling rule (model a field only where the engine reads it at that path), ObjectId is genuinely dead on a static creature placement, not merely defaulted: whatever a static .git creature entry carries there has zero effect, the same “never looked up” status already established for other confirmed-dead fields like .utc’s Tail/Wings. This is specific to Creature – the other nine dispatchers read ObjectId once, ahead of their own static/save branch, so the field is genuinely read-with-default on both of their paths.

One corollary worth flagging for rakata’s own code: the field-level default documented for AreaEffectList below isn’t a special case, it’s the general rule – and rakata’s typed views currently only apply OBJECT_INVALID to the ten static (UseTemplates = 1) types. The seven Saved* types (the UseTemplates = 0 full-snapshot forms) currently default ObjectId to plain 0 in code, which this trace shows is wrong: the full/save branch uses the identical 0x7F000000-default read as the static branch, for every type. module.ifo’s Mod_Area_list has the same gap – its ObjectId read (gated behind the save-game flag, confirming the existing “only read inside a save state flow” note) also defaults to 0x7F000000, not the 0 currently documented and coded there.

Appearance Is a Real Instance Override on Doors and Placeables

Both CSWSDoor::LoadDoor and CSWSPlaceable::LoadPlaceable read a field literally named Appearance as a DWORD and truncate it to a single byte – the same truncation pattern already documented for the .utd/.utp blueprint reads. For doors, the resolved byte immediately keys doortypes.2da’s Model/VisibleModel columns to pick the door’s mesh, so this is a genuine “which visual model represents this object” field, not inert data, and it shares that meaning with the blueprint-level Appearance documented on UTD/UTP. It defaults to 0 when absent (a hardcoded literal on the placeable path; the door struct’s own zero-initialized member on the door path).

Appearance is already modeled on rakata’s SavedDoor/SavedPlaceable types (the UseTemplates = 0 save-snapshot form). It is not currently modeled on the sparse GitDoor/GitPlaceable types (UseTemplates = 1, template placements), which only carry Bearing/position/ObjectId. LoadDoor/LoadPlaceable are the same shared field readers regardless of which path calls them, so a sparse, template-referencing door or placeable placement that also carries its own Appearance value would have that value read as an override on top of whatever the referenced blueprint supplies – worth verifying against real static (UseTemplates = 1) .git entries before deciding whether GitDoor/GitPlaceable need the field added.

Compare UTE’s Appearance, which shares the label but is never read by the engine at all – confirming the two are unrelated despite the shared name. A placed WaypointList[].Appearance follows the same dead pattern as UTE’s, not Door/Placeable’s: LoadWaypoint’s fully-decompiled field list has no room for it, confirmed directly (see UTW’s Core Structural Findings). A waypoint has no rendered model to select in the first place, so this is the expected outcome, not a surprising one.

Description in a GIT Struct Is Toolset Residue, Not an Override

Description is the opposite case from Appearance: it’s a real, blueprint-level field on both UTD and UTP, but the area loader never reads it back out of a placed instance’s own GIT entry. Traced through both CSWSDoor::LoadDoorExternal and CSWSArea::LoadPlaceables: the ordinary GIT-instance path resolves Description (and every other non-instance field) entirely from the referenced .utd/.utp blueprint via LoadFromTemplate, and the four-field door overlay (TransitionDestin/LinkedTo/LinkedToFlags/LinkedToModule, see UTD) doesn’t include Description. A Description value sitting in a GIT file’s own Door or Placeable entry is written by the toolset but never consulted at load time – consistent with the corpus finding that 99 files carry the field and only one holds a non-empty value. It also isn’t area-level metadata: CSWSArea::LoadProperties, the function that reads the GIT’s own AreaProperties struct, has no Description field at all.

This was confirmed for Door and Placeable entries specifically; Creature, Item/Store, Trigger, Encounter, Sound, and Camera entries weren’t individually checked, so treat the same conclusion as likely but unproven for those list types. Waypoints are a related but distinct case, not just an unchecked one – see UTW’s Core Structural Findings, where a placed waypoint’s Description isn’t toolset residue with a source that goes unread, it has no source at all, because waypoints never resolve a TemplateResRef in the first place.

AreaProperties.EnvAudio Is Toolset-Only Duplication

The GIT’s own AreaProperties struct carries an EnvAudio INT in most vanilla files (present with a real value in 99 of 117 GIT files in a full install), but it’s a different field entirely from ARE’s per-room EnvAudio – same name, different struct, and this one has no engine consumer at all. CSWSArea::LoadProperties (the function that reads GIT’s AreaProperties) and its ambient-sound delegate, CSWSAmbientSound::Load (which reads the already-modeled MusicDelay/MusicDay/MusicNight/MusicBattle/AmbientSndDay/AmbientSndNight/AmbientSndDayVol/AmbientSndNitVol off that same struct), read neither field named EnvAudio between them. A binary-wide check confirms this isn’t a missed function: the "EnvAudio" string exists exactly once in the whole executable, with exactly one cross-reference, and that one reference is the ARE per-room reader, not anything reachable from AreaProperties.

So a GIT-level EnvAudio value has zero engine consumers, full stop. It’s plausibly a toolset habit – the area’s default or primary room EnvAudio value duplicated onto AreaProperties by the editor UI, never kept in sync with the real per-room values and never read back by the engine – but that’s the strongest claim the evidence supports, not a confirmed mechanism. There is no absent-field default to report, since nothing ever looks for the field in the first place.

The Blueprint’s Tag Always Wins

UTD’s “Save versus Template Load Paths” documents that a templated door’s Tag comes from its .utd blueprint, with no per-instance overlay – so two doors sharing one blueprint would share one tag. That turns out to be general engine behavior, not a door-specific gap: every templated GIT object type follows the identical pattern. Confirmed directly for Placeable, Trigger, Sound, Store, and Encounter, and for Creature and Item as time allowed:

  • Placeable, Sound, Encounter, Creature, Item: the shared field-reading routine (LoadPlaceable, CSWSSoundObject::Load, ReadEncounterFromGff, the LoadCreature/LoadFromTemplate pair via ReadStatsFromGff, LoadDataFromGff) reads Tag unconditionally from whichever struct it’s handed – the placed instance’s own struct on a direct load, the blueprint’s top-level struct on a template load. The area-level dispatcher’s post-template overlay, where one exists at all, only ever covers position/orientation/geometry – never Tag.
  • Trigger: same pattern, and Tag sits in the identical family as the door overlay – CSWSArea::LoadTriggers overlays TransitionDestination/LinkedTo/LinkedToModule/LinkedToFlags plus position/geometry back from the GIT instance after a template load, but Tag is conspicuously not among them.
  • Store: uses ResRef rather than TemplateResRef for templating (already documented elsewhere on this page), but does carry a genuine, separate Tag field, read unconditionally by LoadStore the same way. LoadStores’ post-template overlay covers only orientation and position.

So there’s no engine-side protection anywhere against two placements that share one blueprint ending up with the same Tag – for any templated object type, not just doors. Vanilla avoids the collision purely by authoring convention (effectively one blueprint per placed instance), the same workaround already documented for doors. A lint rule for this should be written generically across every templating GIT object type, not scoped to doors.

Specialized Struct Parsings

Engine Dispatch TargetDescription & Findings
LoadSounds (0x00505560)Discard logic: Translates GeneratedType via DWord, but physically truncates it to an 8-bit byte on save, silently discarding the upper 24 bits!
LoadEncounters (0x00505060)Highly nested structural array reusing both Geometry and SpawnPointList formats natively built for UTE boundaries.
LoadPlaceableCameras (0x00505eb0)Client-side only struct that reads composite GFF spatial types correctly natively! Camera Limit: If it hits 51 camera entries, the loader formally rejects it.
“List” (Items) (0x00504de0)Bizarrely, the generic parent entity list List is used specifically to orchestrate Item instances!

Area-of-Effect Objects (Save-Only)

AreaEffectList holds runtime spell/ability effect objects (CSWSAreaOfEffectObject). As noted above, these have no blueprint file of their own – no loader anywhere in the binary ever opens a template for one, so every field is read straight off the GIT struct itself. Elements must carry struct type id 13 or the loader skips them; ObjectId defaults to the engine-wide 0x7F000000 (OBJECT_INVALID) placeholder – see above for why this is the general rule across every list, not a special case for area effects.

FieldTypeDefaultEngine Evaluation
TagCExoString""
AreaEffectIdINT0A freshly constructed object leaves this member uninitialized; the constant load default masks that gap.
SpellIdDWORD0A fresh object uses an internal 0xFFFFFFFF sentinel, and the writer emits the value through an accessor rather than the raw member – but a field genuinely missing from a save still resolves to 0 on load.
ShapeBYTE00 = circle, 1 = rectangle. Any other value skips both dimension fields entirely, so the effect gets no shape geometry at all.
MetaMagicTypeBYTE0
SpellSaveDCINT0Fresh objects start at 14; the 0 default only applies when a save’s GFF genuinely omits the field.
SpellLevelINT0
RadiusFLOAT0.0Only read/written when Shape == 0.
Length / WidthFLOAT0.0 eachOnly read/written when Shape == 1; the pair round-trips symmetrically (the same members map to the same labels on both load and save).
CreatorId / LinkedToObject / LastEntered / LastLeftDWORD0 eachFresh objects use the 0x7F000000 placeholder instead; 0 is only the fallback for a field genuinely absent from the save.
DurationDWORD0
DurationTypeBYTE0Fresh objects start at 2.
LastHrtbtDay / LastHrtbtTimeDWORD0 each
PositionX / PositionY / PositionZFLOAT0.0 eachRead last, by the area-effect list loader, and passed straight into placement.
OrientationX / OrientationY / OrientationZFLOAT0.0 eachNormalized on load; if the vector’s squared magnitude is negligibly small (<= 0.0001) it falls back to (0, 1, 0).

Note

Write-only script fields, confirmed dead on restore. OnHeartbeat, OnUserDefined, OnObjEnter, and OnObjExit are written by the save writer but never read back by any loader in the binary. A restored area-of-effect object genuinely never fires these four events again: nothing re-derives them from AreaEffectId or SpellId on the load path. That 2DA-driven derivation does exist (vfx_persistent.2da, keyed by AreaEffectId, supplying OnHeartbeat/OnObjEnter/OnObjExit script resrefs plus shape/size defaults), but it’s wired exclusively into the fresh spell-cast creation path, not into loading a save. The GIT loader has no equivalent “look this back up” step. OnUserDefined goes further still: it’s never populated by any path, including fresh creation, so it’s effectively dead in this engine build regardless of load versus save.

This doesn’t mean a restored effect goes inert, though: duration countdown, position tracking, and collision-based area membership all keep working after a reload, since those run off the object’s own stored data (Duration, DurationType, position) rather than off scripts. It’s specifically the four scripted event hooks that go silent.

Singular Structs

  • AreaProperties: Orchestrates stealth behavior state tracking and dynamic audio states. It physically reads AmbientSndDayVol / AmbientSndNitVol and explicitly truncates their INT declarations into a single native runtime byte value. The loader and the save writer disagree on where several of these fields actually live: the writer nests RestrictMode, StealthXPMax, StealthXPCurrent, StealthXPLoss, StealthXPEnabled, and SunFogColor inside the AreaProperties struct, but the reader actually pulls those specific fields from the GIT’s top level instead – only Unescapable is genuinely read from inside AreaProperties. The practical effect: those six fields are permanently dead in every save this engine’s own writer produces. They’re always written to a location the reader never checks, so they always resolve to whatever value the object already had in memory. TransPending / TransPendNextID / TransPendCurrID are written in both places (the GIT top level directly, and a redundant copy inside AreaProperties), but only the top-level copy is ever consulted, so the AreaProperties copy is a harmless duplicate. This finding is grounded directly in decompiled code with reasonably high confidence, though it’s inferred from variable-usage patterns rather than a byte-level disassembly proof. Its MusicDelay/MusicDay/MusicNight/MusicBattle/AmbientSndDay/AmbientSndNight/AmbientSndDayVol/AmbientSndNitVol fields (read by the ambient-sound delegate, CSWSAmbientSound::Load) each carry over their constructor’s pre-armed value when absent – confirmed as a genuine carry-over with no divergence between the constructed value and the read’s own fallback, unlike the constructor/literal-default mismatches found elsewhere in this codebase’s format audits. Absent defaults: MusicDelay 5000, MusicDay 2, MusicNight 3, MusicBattle 1, AmbientSndDay 1, AmbientSndNight 2, AmbientSndDayVol/AmbientSndNitVol 0 each.
  • AreaMap: Strict binary blobs evaluating rendering properties (AreaMapData). It is absolutely bypassed during fresh loads, only executed conditionally during save-game states.
  • CameraList (GitCamera entries): Read by LoadPlaceableCameras (already documented above for its 51-entry rejection limit). Position defaults to the literal zero vector (0, 0, 0) and Orientation to the literal identity quaternion (w=1, x=0, y=0, z=0), both unconditional – the read helpers always populate the field from either the GFF data or the supplied default, and the loader never branches on which one it got. Absence of either field doesn’t drop the camera entry; it’s always kept and registered, with each field defaulting independently. No presence-chain abort exists in this loop beyond the already-documented 51-entry count cap.

Implemented Linter Rules (Rakata-Lint)

Phase 1 (intra-resource, no context)

Implemented under rakata_lint::rules::git.

  1. GIT-001 (Weather Zeroing): Informs when CurrentWeather != 0xFF or WeatherStarted=true is configured; if the area is an interior, the engine forcibly zeros these on load.
  2. GIT-002 (Camera Array Bounds): Errors when CameraList contains 51 or more entries; triggers an immediate engine-level loader failure.
  3. GIT-003 (Stealth Clamping): Warns when StealthXPCurrent > StealthXPMax; the engine clamps on evaluation.
  4. GIT-004 (Ambient Volume Truncation): Warns when AmbientSndDayVol or AmbientSndNitVol are outside 0..=255; the engine truncates to an 8-bit byte.
  5. GIT-005 (Sound GeneratedType Truncation): Warns when any sound’s GeneratedType exceeds 255; the engine truncates to an 8-bit byte on save. Both forms of SoundList carry the field, so this one applies to savegame content as well as static.

Phase 2 (resource existence, requires LintContext)

Implemented under rakata_lint::rules::git_range.

  1. GIT-006 (Template Resref Existence): Warns when any per-instance TemplateResRef does not resolve to its expected typed template file: CreatureList[].TemplateResRef (.utc), List[].TemplateResRef (.uti, item instances), Door List[].TemplateResRef (.utd), Placeable List[].TemplateResRef (.utp), SoundList[].TemplateResRef (.uts), TriggerList[].TemplateResRef (.utt), StoreList[].ResRef (.utm), and Encounter List[].TemplateResRef (.ute). Waypoint instances are genuinely inlined and have no template – confirmed by decompilation, LoadWaypoint never reads a field named TemplateResRef under any circumstance. Doors are not, and this rule excluded them on that assumption until the gap was found: a static (UseTemplates = 1) door placement resolves TemplateResRef against a .utd blueprint exactly like a creature or placeable does (see UTD’s “Save versus Template Load Paths”). GitDoor now carries the field and the rule checks it, so a door pointing at a missing blueprint no longer passes clean. Trigger LinkedToModule is deferred to Phase 3 cross-resource checks. The rule only looks at the static form of each list, since a UseTemplates = 0 snapshot has no resref to resolve in the first place.

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

PropertyValue
Extension(s).ifo
Magic SignatureIFO / V3.2
TypeModule Blueprint
Rust ReferenceView 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.

CategoryCoversRepresentative fields
Module IdentityThe module’s tag, localized name, and descriptionMod_Tag, Mod_Name, Mod_Description
Entry PointThe spawn area, position, and facing used on module entryMod_Entry_Area, Mod_Entry_X, Mod_Entry_Dir_X
Time & CalendarDay/night pacing and the module’s starting clockMod_MinPerHour, Mod_DawnHour, Mod_StartYear
Global Event ScriptsThe 15 module-wide event hooksMod_OnModLoad, Mod_OnClientEntr, Mod_OnHeartbeat
Area & Cutscene RostersThe areas belonging to the module, plus cutscene and expansion metadataMod_Area_list, Mod_CutSceneList
Save-Only StateThe runtime snapshot a save adds: party roster, tokens, id allocators, and the live clockMod_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_VersionSavePlayers (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.

FieldTypeEngine Evaluation
Mod_IDVOID (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_IDINTWritten unconditionally alongside Mod_ID.
Mod_VersionDWORDWritten unconditionally alongside Mod_Creator_ID.
Mod_IsSaveGameBYTEDefaults 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_IsNWMFileBYTESame carry-over mechanism as Mod_IsSaveGame: constructor sets false first, absence leaves it there.
Mod_NWMResNameCExoStringOnly 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_TagCExoStringDefaults 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_NameLocalizedStringDefaults to an empty localized string if missing, unconditional.
Mod_DescriptionLocalizedStringSame as Mod_Name: empty localized string if missing, unconditional.
Mod_Expan_ListList of StructExpansion 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_CutSceneListList of StructCutscene 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

FieldTypeEngine Evaluation
Mod_Entry_AreaResRefThe primary spawning area ResRef.
Mod_Entry_X / Mod_Entry_Y / Mod_Entry_ZFLOATExact spawning XYZ coordinates.
Mod_Entry_Dir_X / Mod_Entry_Dir_YFLOATEntry 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_XPScaleBYTEModule 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_StartMovieResRefRead 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

FieldTypeDescription
Mod_DawnHourBYTEDawn 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_DuskHourBYTEDusk hour integer marker. Defaults to 0 if missing, same as Mod_DawnHour.
Mod_MinPerHourBYTEConfiguration 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 the current_hour. This dynamically updates an internal state flag denoting: 1=Day, 2=Night, 3=Dawn, 4=Dusk.

Warning

Mod_MinPerHour/Mod_DawnHour/Mod_DuskHour doc comments in rakata’s own code are wrong. crates/rakata-generics/src/ifo.rs currently claims these three default to 2, 6, and 18 respectively. None of that is true against the binary – all three read with a literal default of 0, confirmed directly. The code’s actual behavior (.unwrap_or(0) and the Default impl) 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:

FieldFires when
Mod_OnModLoadthe module is loaded
Mod_OnModStartthe module starts (first client entry)
Mod_OnClientEntra player enters the module
Mod_OnClientLeava player leaves the module
Mod_OnHeartbeatthe module heartbeat ticks
Mod_OnUsrDefineda user-defined event is signalled
Mod_OnAcquirIteman item is acquired
Mod_OnUnAqreIteman item is unacquired (dropped or removed)
Mod_OnActvtIteman item is activated
Mod_OnEquipIteman item is equipped
Mod_OnPlrDeatha player dies
Mod_OnPlrDyinga player drops to dying
Mod_OnPlrLvlUpa player levels up
Mod_OnPlrResta player rests
Mod_OnSpawnBtnDna respawn is requested (a multiplayer-era Aurora event)
  • Asymmetric I/O (equipping). Mod_OnEquipItem is read during module startup (LoadModuleStart), but SaveModuleIFOStart never 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_OnEquipItem included, 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_IsNWMFile marks 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 with Mod_NWMResName and skips re-saving the area ARE static into the module’s save ERF. The skip is narrow: SaveModuleFinish gates the ARE static write behind is_nwm_file == 0, while the GIT is written unconditionally in SaveModuleInProgress. So an NWM save still gets its dynamic GIT, just not a re-copied static ARE .

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, and Mod_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 entire LoadModuleStart call, not a per-entry skip.

Engine TargetDescription
Player / Mod VariablesStructures like Mod_PlayerList, Mod_Tokens, VarTable, and the EventQueue are strictly bypassed unless natively evaluated under is_save_game conditions.
Player List StructureMod_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 OverridesThe 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 CountersA 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 SnapshotBeyond 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 AsymmetryMod_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 TokensMod_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 CreaturesCreatures 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-dead VO_ID field: 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 always 0. This is NWN/Aurora expansion-selector residue, consistent with the Mod_IsNWMFile/Mod_Hak precedents on this same format – a real, unrelated Expansion_ID/Expansion_Name pair exists in the binary (used by Mod_Expan_List, already modeled), but Expansion_Pack itself 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-scoped GLOBALVARS.res (GVT ), a completely separate system with no code path connecting it back to this module-scoped field. Mod_GVar_List is 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.

  1. IFO-001 (Direction Fallback): Warns when Mod_Entry_Dir_X and Mod_Entry_Dir_Y are both 0.0. The engine substitutes a hard fallback heading of (1.0, 0.0) only when Mod_Entry_Dir_Y is absent from the GFF; a value that is present but (0.0, 0.0) is left as a degenerate heading with no facing.
  2. IFO-002 (XP Dead-Scaling): Warns when Mod_XPScale == 0. Caveat: a Ghidra trace of K1 shows the engine parses Mod_XPScale but never applies it to awarded XP (the field is inert in swkotor.exe), so a zero has no in-engine effect in K1. The rule only matters if the value is meaningful to another tool.
  3. 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 to 1 (Day), so the module is stuck in perpetual daylight.
  4. IFO-004 (Void Area Initialization): Errors when Mod_Area_list is empty; directly faults the load cycle.
  5. IFO-005 (Dangling NWM Structure): Warns when Mod_IsNWMFile=true without Mod_NWMResName; evaluates to an unstable execution state.

Phase 2 (resource existence, requires LintContext)

Implemented under rakata_lint::rules::ifo_range.

  1. IFO-006 (Resref Existence): Warns when Mod_Entry_Area (.are), any Mod_Area_list[i].Area_Name (.are), or any of the 15 Mod_On* script hooks (.ncs) does not resolve in the configured resource sources.

Pending

  • Mod_StartMovie (.bik): No ResourceTypeCode variant for the Bink movie format yet.
  • Mod_CutSceneList[i].CutScene_Name: Engine resolution is .dlg or .bik depending on context (audit deferred).

UTC Format (Creature Blueprint)

Description: The Creature (.utc) blueprint format defines the attributes, stats, and behavior of all in-scene NPCs and monsters. It covers a creature’s identity, class/level, appearance, equipment, and event scripts. Because they hold so much state, Creatures are one of the most dynamic and memory-heavy templates processed by the Odyssey Engine.

At a Glance

PropertyValue
Extension(s).utc
Magic SignatureUTC / V3.2
TypeCreature Blueprint
Rust ReferenceView rakata_generics::Utc in Rustdocs

Data Model Structure

Rakata maps a Creature into the rakata_generics::Utc struct. The struct’s Rustdocs document every field’s binary schema and GFF mapping; the table below is the high-level anatomy.

CategoryCoversRepresentative fields
Core StatisticsThe base stats that define the creature’s physical capabilitiesStrength, Dexterity, HitPoints
Identity & GraphicsWho the creature is and which 3D model it usesTag, Appearance_Type, Conversation
Class & Skill ProgressionThe creature’s level, classes, and skillsClassList, SkillList
Combat CapabilitiesThe feats and Force powers the creature can useFeatList, SpellList
Inventory & EquipmentThe items the creature spawns with, both equipped gear and inventory dropsEquip_ItemList, ItemList
Event HooksThe behavior scripts that fire when the creature reacts to the world, such as taking damage or noticing an enemyOnNotice, OnDamaged

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 .utc files mapped from swkotor.exe.

(Documented from Ghidra decompilation of swkotor.exe, pulling from CSWSCreatureStats::ReadStatsFromGff at 0x005afce0 and its save-side counterpart CSWSCreatureStats::SaveStats at 0x005b1b90.)

Structural Load Phasing

FunctionSizeBehavior
ReadStatsFromGff7835 BThe massive initial pass that parses 57 basic creature scalars including strength, dexterity, and physical appearance.
LoadCreatureSets up how the creature physically sits in the world, handling their stealth states, collision size, and idle animations.
CSWSCreature::ReadScriptsFromGffAttaches all the custom event scripts that fire when the creature notices an enemy, takes damage, dies, or simply stands around (heartbeat). A genuine member of CSWSCreature (confirmed by its decompiled __thiscall signature), not a free function – correcting the “unnamespaced free function” framing this page previously carried for this specific function; ReadItemsFromGff wasn’t re-checked in this pass.
ReadItemsFromGffPulls all loot into memory, structuring items into equipped slots or the backpack. The “dropped entirely if a creature spawns dead” framing doesn’t hold up against the decompile – no branch anywhere in this call graph inspects hit points or a dead/alive state. See “Item Lists” below for what actually causes an entry to be dropped.
ReadSpellsFromGffSpecifically extracts the list of any Force powers or combat feats the creature is allowed to use.

Note

Zeroed Data Elements Tail and Wings are stronger than “bypassed”: ReadStatsFromGff never looks them up in the GFF struct at all – there’s no ReadFieldBYTE call for either label anywhere in the function. Instead it performs a flat, unconditional assignment of 0 to both members, overwriting whatever the object already held, regardless of whether the file even contains the fields. This is identical on the .utc blueprint path (LoadFromTemplate) and the save-instance path (LoadCreature) – both call the same ReadStatsFromGff, with no branch anywhere in it that distinguishes the two callers. This isn’t merely inert legacy data, though: SaveStats still writes both fields out unconditionally on every save. Whatever values a creature’s tail or wings hold in a save file are silently discarded the moment it’s loaded back in, a genuine round-trip loss rather than a “never populated” field.

Core Structural Findings

The engine strictly validates parameters when loading a .utc file. Improper formatting will trigger some of KOTOR’s most notorious game crashes.

Warning

Understanding Fatal Crash Codes (0x5fX) When the game engine parses a file and hits an invalid stat, it completely aborts loading. Instead of recovering gracefully, the engine deliberately triggers a fatal crash to your desktop and returns a specific hexadecimal error code (e.g., 0x5f7 or 0x5f4). The rules below track the specific scenarios where the game will crash.

Engine RuleRuntime Behavior
Class LimitsThe engine expects a strict limit of 2 discrete class types. Providing duplicate class configuration completely crashes the game (Engine Error 0x5f7).
Race BoundsThe engine compares Race against the compiled row count of racialtypes.2da. Exceeding this boundary fatally crashes the map loader (Engine Error 0x5f4).
Saves CalculationPre-computed saving throws (SaveWill, SaveFortitude) in the .utc file are completely ignored dead data. The engine overrides them exclusively by reading willbonus and fortbonus.
Perception FaultsA non-PC PerceptionRange initiates a read against appearance.2da for PERCEPTIONDIST. Failing to resolve this distance fails the entire creature load (Engine Error 0x5f5).
Movement FallbacksIf a unique MovementRate isn’t declared, the engine logic falls back directly to default WalkRate parameters.
Hard ClampingThe engine strictly limits specific numeric bounds upon load: Gender is clamped structurally at a maximum of 4, and GoodEvil is fiercely clamped so that it cannot exceed 100.
Appearance ShiftingIf Appearance_Head is 0, the engine overrides it to 1 to prevent rendering bugs. This correction checks the resolved value only, never whether the field was actually present – so it fires identically whether the 0 came from an explicit GFF byte or from an absent field’s carried-over default. That matters in practice: Appearance_Head is present in only 1 of 1958 vanilla .utc files. The other 1957 carry over the constructor’s own default of 0, which the correction then bumps to 1 every time – there is no way for a creature to end up stored at 0, whether the omission is universal (as it is in practice) or the field is explicitly zeroed.

Save-Game Snapshot Fields

A creature serialized into a save game carries live runtime state that a static .utc blueprint does not usually populate – but “usually” is doing real work in that sentence. The save writer (CSWSCreatureStats::SaveStats, the counterpart to ReadStatsFromGff) emits these alongside the template fields. They appear on the creature structs inside a save’s module GIT (UseTemplates = 0); see the Save Game Deep Dive. Since ReadStatsFromGff has no branch anywhere that distinguishes a .utc blueprint struct from a save-instance struct, several of these “snapshot” fields are genuinely read on the blueprint path too, the same “runtime fields read on the blueprint path” pattern already documented for .ute encounters – it’s just that vanilla .utc files never happen to populate them. Others are confirmed write-only on every path, blueprint included. The table below distinguishes the two.

Field(s)MeaningRead on the .utc blueprint path too?
CurrentHitPointsLive current HP.Yes – unconditional single read site, no UseTemplates-style gate. Absent-field default is derived from HitPoints (see below), not carried over from any prior “current HP” state.
MaxHitPointsComputed HP ceiling.No. Confirmed exhaustively: the field-name string has exactly two cross-references in the whole binary, both writers (SaveStats, SaveCharGenCreature). Zero readers anywhere – genuinely write-only, matching the already-documented “recomputed, not restored” rule below.
PregameCurrentNominally a current-HP mirror.No. Same exhaustive check: exactly two references, both writers, zero readers, on any path.
ForcePointsLive Force-point pool.Yes – unconditional, carries over the object’s own constructed value (0) when absent.
CurrentForceLive current Force.Yes – unconditional, but sibling-derived from ForcePoints when absent (see below), not carried over independently.
MaxForcePointsComputed Force-point ceiling.No. Same exhaustive write-only check as MaxHitPoints.

The remaining snapshot fields weren’t individually re-verified for blueprint-path readability in this pass, so treat their status as the original framing (save-side emphasis, not a blueprint/save distinction):

Field(s)Meaning
RefSaveThrow, WillSaveThrow, FortSaveThrowComputed saving-throw totals (base + ability modifier + active effects). Distinct from the template’s ignored SaveWill / SaveFortitude dead fields.
ArmorClassComputed AC snapshot.
Experience, GoldRuntime progression. Experience is present in 0 of 1958 vanilla .utc files – every creature in the game omits it, and every one of them carries over the constructor’s default of 0. That carried-over 0 is then passed through SetExperience, which refuses to lower a creature’s XP (it compares the incoming value against the member’s current value and only stores it if the incoming value isn’t smaller); since both sides of that comparison are 0 at this point, the call is a harmless no-op and Experience settles at 0 for every vanilla creature. No sentinel range exists for this field – it’s a plain, uncapped counter.
AIState, NotReorientingRuntime behaviour and orientation state. Both are confirmed genuinely read on the blueprint path too (see “Identity, Appearance, and State Fields” below), unconditional carry-over for each.
MClassLevUpInMulticlass level-up bookkeeping (class_count - 1).

Combat state (the active combat round and equipped-weapon data) is written separately through CCombatInformation::SaveData (0x00550f30; read back by CCombatInformation::LoadData at 0x00552350). The class/skill/feat/power progression is written by SaveClassInfo (0x005aec90) and reflects the creature’s current leveled state, which for a played character diverges from the blueprint. SaveClassInfo is a genuine member of CSWSCreatureStats itself, confirmed against the binary’s own class layout; CCombatInformation is a separate class with no members in common with CSWSCreatureStats, reached through a nested object the creature owns rather than through inheritance.

Does CSWSCreatureStats name the whole shared block?

Short answer: no. ReadStatsFromGff reads the bulk of a creature’s identity, six abilities, HP/FP pools, appearance and portrait fields, faction, challenge rating, AI state, and perception range directly inline, plus ClassList/LvlStatList inline too – no separate “read class info” delegate exists on the load side, even though the save side modularizes the equivalent work into SaveClassInfo. The two structured exceptions on load are CombatRoundData, delegated to CSWSCombatRound::LoadCombatRound, and the nested CombatInfo struct, delegated to CCombatInformation::LoadData.

That accounts for a large, coherent chunk of the 86-field shared set, but not all of it. A comparable amount lives one level up, owned directly by CSWSCreature::SaveCreature / CSWSCreature::LoadCreature rather than by CSWSCreatureStats: DetectMode, StealthMode, CreatureSize, IsDestroyable, IsRaiseable, DeadSelectable, AmbientAnimState, Animation, CreatnScrptFird, PM_IsDisguised, PM_Appearance, Listening, the full set of Script* event-hook resrefs, position/orientation, AreaId, and JoiningXP are all read and written inline in those two functions, with no CSWSCreatureStats involvement at all. Scripts are handled by CSWSCreature::ReadScriptsFromGff, a genuine member of CSWSCreature rather than a free function (correcting an earlier pass’s claim otherwise); items are handled by ReadItemsFromGff, not re-checked in this later pass so its namespacing is unconfirmed. Both are called from LoadCreature, not from CSWSCreatureStats, regardless of which one turns out to be a member. FollowInfo goes further still, delegated from SaveCreature to a fourth class, CSWSCreaturePartyFollowInfo::Save.

PerceptionList is the one field confirmed to split across classes in different directions: ReadStatsFromGff reads it, but SaveCreature writes it, directly and without delegating back to CSWSCreatureStats.

So CSWSCreatureStats is a real class boundary, not an invented one, and it does own everything under “Save-Game Snapshot Fields” above plus the class/skill/feat/power progression – but the 86-field shared set as a whole spans at least four engine classes (CSWSCreatureStats, CSWSCreature, CCombatInformation, CSWSCombatRound, plus CSWSCreaturePartyFollowInfo for FollowInfo) and two unnamespaced free functions. No single engine name covers the whole set; a Rust type modelling the full shared block needs a rakata-invented name, not a borrowed one. One corner is left untraced: CSWSObject::SaveObjectState / LoadObjectState and SaveListenData / LoadListenData run at the tail of SaveCreature / LoadCreature and were not decompiled here, so there may be a further split at the base CSWSObject layer this pass didn’t reach.

Write-Only Fields

Seven of the fields above look like round-trip state but are strictly one-way: SaveStats writes them on every save, and ReadStatsFromGff never reads a single one back. (Tail and Wings are in the same club; see the note above.) They’re one-way for two very different reasons, though, and the difference matters if you’re editing saves.

MaxHitPoints, ArmorClass, and the three saving-throw totals are recomputed, not restored. Each is written from a live getter (the same getters that combat rolls and UI displays call at runtime), and on load the engine simply rebuilds the number from inputs that already round-tripped:

Snapshot totalRebuilt on load from
MaxHitPointsClass levels and the Constitution modifier (non-PC creatures: the template’s own HitPoints, confirmed to be the literal raw value this same field feeds into CurrentHitPoints’s own sibling-derived default, see “Save-Game Snapshot Fields” above)
ArmorClassPer-class armour-bonus tables, natural AC, the Dexterity modifier, feat bonuses, and the active effect list (reapplied as the last step of LoadCreature)
RefSaveThrow / WillSaveThrow / FortSaveThrowThe class/feat base, the ability modifier, the effect bonus, and a permanent-bonus byte that does round-trip – under the lowercase labels refbonus / willbonus / fortbonus, not the capitalized totals

So editing any of these five totals in a save changes nothing; the engine derives the real numbers from the inputs in the right-hand column. The totals exist as convenience snapshots for external tooling, and no state is actually lost.

A corpus scan turns up capitalized variants of these labels – FortBonus, RefBonus, WillBonus – in a handful of .utc files, always holding 0. These are dead by construction, not merely dead in practice: GFF field-label lookup is case-sensitive, and the only literal strings ReadStatsFromGff ever constructs to search for are the lowercase fortbonus/refbonus/willbonus already documented above. A capitalized variant simply never matches during the field-lookup pass; it isn’t found, read, discarded, or compared against anything. The same applies to SubRace versus the modeled Subrace – the engine only ever looks up Subrace, so a SubRace-spelled field is unreachable regardless of what value it holds.

MClassLevUpIn and PregameCurrent are the genuine dead writes: no reader exists anywhere in the binary, not even in the character-generation export path (SaveCharGenCreature) that also writes them. The class count MClassLevUpIn supposedly bookkeeps is derived from the restored ClassList’s length instead, and PregameCurrent – despite the name – behaves as a continuously-refreshed current-HP mirror that nothing ever reads back.

TemplateResRef Isn’t Read by ReadStatsFromGff at All

TemplateResRef never appears anywhere in ReadStatsFromGff – it’s read one level up, by CSWSArea::LoadCreatures, and only on the branch that resolves a GIT Creature List entry’s blueprint reference (never on the direct save-instance path, which has no use for it). The read itself defaults to an empty resref, but the loader explicitly checks the presence flag: if TemplateResRef is absent, it destroys the just-allocated creature object and drops the entry entirely – LoadFromTemplate, and therefore ReadStatsFromGff, never runs for that entry at all. This is a genuine presence-chain abort, and the identical pattern (same field, same behavior) governs blueprint resolution for triggers, placeables, items, doors, encounters, and sounds – a templated GIT entry missing its own TemplateResRef is dropped outright, not defaulted.

Six Core Abilities Default to 0, Not a D&D-Style 10

Str, Dex, Con, Int, Wis, and Cha all follow one identical mechanism: each is read with the object’s own current value as the fallback and stamped back unconditionally, a carry-over in effect even though the store itself always executes. The constructor initializes all six to a literal 0 before any read happens, so a .utc file missing an ability score doesn’t fall back to a sensible tabletop default – it resolves to 0, not 10.

Identity, Appearance, and State Fields

Most of the remaining identity and appearance fields follow that same carry-over mechanism – the read’s own fallback is the object’s current value, stamped back unconditionally, so an absent field is a practical no-op on a freshly constructed blueprint load: Tag, Conversation, Deity, Description, Age, StartingPackage, Subrace (the free-text name – see the naming note below), SubraceIndex (the numeric id), Color_Skin/Color_Hair/Color_Tattoo1/Color_Tattoo2, Phenotype, Appearance_Type, Gender’s own default (distinct from the already-documented clamp-to-4), DuplicatingHead, UseBackupHead, FactionID, AIState (read via the INT reader despite being a WORD field, truncated on store), GoodEvil’s own default (distinct from the already-documented clamp-to-100), ChallengeRating, NaturalAC, Min1HP, PartyInteract, Disarmable, Portrait (only reached conditionally, see PortraitId below), and WalkRate’s own default (the object’s current movement_rate, distinct from the already-documented MovementRate-falls-back-to-WalkRate rule). SoundSetFile is stored on the owning CSWSCreature, not CSWSCreatureStats – consistent with the class-boundary split already documented above. SkillPoints is genuinely read twice, at two different struct scopes: once flat on the top-level struct, and separately once per entry inside LvlStatList (same label, a nested struct) – a typed view needs both reads, not one.

Naming corrections worth recording precisely. The color fields are underscored (Color_Skin, not ColorSkin), and the type field is Phenotype, not PhenoType. SubraceName does not exist as a GFF label at all – the two real, distinct labels are Subrace (the free-text name) and SubraceIndex (the numeric id); rakata’s own struct already maps these correctly.

FirstName/LastName do NOT carry over – they’re an unconditional blank stamp, unlike the structurally identical Description field a few lines away. Description’s fallback is the object’s own current value (&this->description); FirstName and LastName are each read against a freshly default-constructed, empty localized string instead, with no reference to the object’s prior name at all. An absent FirstName/LastName unconditionally overwrites whatever name the object held with an empty one – it does not leave a prior name in place, the way Description (and nearly everything else on this page) does.

Plot has an undocumented legacy-label fallback, the same shape as MovementRate->WalkRate. The loader first tries a field literally named Invulnerable; only if that’s absent does it fall back to trying Plot by name. Either way the result lands in the same plot member, written unconditionally, with a final fallback (if neither label is present) of the object’s own prior plot value. Invulnerable is a real, distinct GFF label – the same one already documented as read by LoadDoor/LoadPlaceable for their own objects – and it takes priority over Plot for creatures specifically.

NotReorienting round-trips through a polarity inversion, not a bug. The GFF-visible field is the logical negation of the internal reorienting member on both read and write; the double negation cancels out algebraically, so this isn’t a defect, just worth knowing if you’re ever comparing the label’s sense to the internal state directly.

PortraitId uses a literal sentinel default of 0xFFFF, not a carry-over. Unlike most fields on this page, PortraitId’s fallback is a fresh literal, not the object’s current value. 0xFFFF fails the same < 0xFFFE check documented for the explicit 0xFFFE sentinel, so an absent PortraitId behaves identically to writing 0xFFFE explicitly – both route to the Portrait-resref path, which itself carries over the object’s current value when absent.

Comment and the Legacy-Field Batch: Fully Confirmed Dead, With Two Corrections

A binary-wide string-existence check – the same decisive test already used for TemplateList/CRAdjust/SaveReflex/MemorizedList0 – confirms Comment, Morale, MoraleRecovery, MoraleBreakpoint, PaletteID, BlindSpot, MultiplierSet, NoPermDeath, IgnoreCrePath, Hologram, WillNotRender, and LawfulChaotic don’t exist as field-name strings anywhere in swkotor.exe. Comment specifically settles as “not read at all,” not merely “read and ignored.” TextureVar and BodyVariation are a related but distinct case: both strings genuinely exist in the binary, but their only cross-references are the item (.uti) loader and saver – a real field name on a different format, never read by any creature-related function, functionally just as dead for UTC purposes.

Two fields need pulling back out of the “confirmed dead” framing: BodyBag and Interruptable are genuinely read. Both are read live by ReadStatsFromGff via an ordinary carry-over ReadFieldBYTE call and stored into real members (creature->body_bag, this->interruptable). Neither is in this page’s own UTC-007 lint list, so there’s no existing contradiction to fix – but don’t assume UTC’s Interruptable shares the fate of the identically-named, confirmed-dead Interruptable field already documented on UTD/UTP; it’s a different field on a different format, and this one is live. Whether the values these two store are ever consumed downstream (combat/AI logic) wasn’t traced in this pass.

The 14 Script Hooks Default to "default", Matching UTD/UTT

All 14 (ScriptHeartbeat, ScriptOnNotice, ScriptSpellAt, ScriptAttacked, ScriptDamaged, ScriptDisturbed, ScriptEndRound, ScriptDialogue, ScriptSpawn, ScriptRested, ScriptDeath, ScriptUserDefine, ScriptOnBlocked – not ScriptBlocked – and ScriptEndDialogue, truncated on-disk to ScriptEndDialogu by the 16-byte GFF label limit) follow the identical mechanism already confirmed for doors and triggers: CSWSCreature’s constructor pre-arms all 14 script slots to the literal string "default" before any GFF read happens, and each read’s own fallback is the slot’s current value – so an absent hook resolves to "default", not empty. Confirmed for both entry points: LoadFromTemplate (fresh .utc blueprint load) and LoadCreature (save-reload), with no re-arming between construction and either load path.

Skills, Classes, and Powers: Presence Chains Down to the Individual Entry

UtcSkills. SkillList isn’t 8 individually-labelled fields – it’s a GFF list of 8 positional entries, each carrying one Rank byte, matching skills.2da row order. If SkillList is entirely absent, the whole block is skipped and the object’s skill ranks stay at whatever they already held (all-zero on a fresh blueprint load). If SkillList is present at all – even as an empty list – every one of the 8 positions is first force-zeroed, then each position missing its own list entry gets a default computed live from the engine’s own skill-check function: ability modifier plus any already-applied feat bonus, not a flat 0 (the raw component is 0 at this point, so that’s what the derived default reduces to in practice, but the mechanism is genuinely sibling-derived, not a literal). Absent and present-but-empty look identical on a fresh template, but diverge on an object with pre-existing nonzero ranks – absent leaves them untouched, present-but-empty wipes them.

ClassList entries. Class carries over the slot’s existing class id, applied only if the field was present and doesn’t resolve to the -1/NONE sentinel – otherwise the slot’s existing id stands untouched. A resolved-but-out-of-range class id crashes with the same 0x5f7 error code already documented for duplicate classes, not a separate failure mode – worth folding into the existing crash-code table. ClassLevel also carries over, and its own read is gated: it’s only attempted if the slot already resolved to a valid (non-NONE) class earlier in the same pass. Freshly-constructed baseline values: slot 0 defaults to Soldier at level 1, slot 1 to NONE at level 0.

Per-class powers (KnownList0) are read by a separate function, not inside the ClassList loop. CSWSCreatureStats::ReadSpellsFromGff runs after ReadStatsFromGff returns, from both LoadFromTemplate and LoadCreature, and re-walks ClassList independently. It confirms directly (not just as an observed file convention) that the per-class known-power list label is always literally built as "KnownList" + 0, regardless of which class index is being processed. An absent or empty KnownList0 simply leaves that class with zero known powers – no abort. Within a present list, each power’s Spell field defaults to the sentinel 0xFFFF; when a power resolves to that sentinel (explicit or absent), the whole power entry is skipped and never appended – a presence-chain abort at the individual-power level, not a defaulted 0 power. This is a real divergence from rakata’s current code: parse_known_list in crates/rakata-generics/src/utc.rs currently defaults a missing Spell to 0 and still pushes that as a power, where the engine would have silently dropped the entry instead.

A related but distinct read lives inside the same ClassList loop (not ReadSpellsFromGff): a Jedi-only SpellsPerDayList/NumSpellsLeft block for uses-per-day bookkeeping. An absent list is a soft no-op, and only the first entry of that list is ever actually applied – any further entries are read but discarded.

SpecAbilityList entries. Spell, SpellFlags, and SpellCasterLevel each independently default to a literal 0, unconditional, no presence gate on any of the three. An entry is appended as soon as the list-element fetch itself succeeds – there’s no scenario where a kept entry gets dropped for missing scalar fields, matching and sharpening the page’s existing “unconditionally appends… no deduplication” description with the actual defaults.

FeatList entries. Feat defaults to 0, but the add is gated on presence: AddFeat is only called if the field was actually present, so an absent Feat contributes nothing at all for that list position – a presence-chain abort scoped to the single entry, confirmed unambiguously via an identical read idiom used elsewhere in the same function (the per-level FeatList inside the PC LvlStatList loop), even though the top-level call site’s own presence-flag register couldn’t be resolved with full certainty from the decompiler output alone.

Item Lists: EquippedRes/InventoryRes Absence Drops the Whole Entry

Equip_ItemList is one flat GFF list, not per-slot numbered fields (Equip_ItemList0/1/… don’t exist) – the equip slot itself is the list element’s own struct-id, read structurally off the GFF element header rather than any field, so it has no “absent” state to document; rakata’s existing slot_id modeling is already correct.

Both EquippedRes (on Equip_ItemList) and InventoryRes (on ItemList) share the same fate on absence: presence-gated, and if the field is missing, or present but doesn’t resolve to a real .uti blueprint, the freshly-allocated item object is destroyed on the spot and the loop moves on – a genuine presence-chain abort. A resref-less entry is dropped entirely, not kept with an empty resref. Dropable is unconditional on both lists: a literal 0 (not droppable) stamped regardless of presence, no gating.

One thing that looks like an absent-field question but isn’t: if an equipped item fails the CanEquipItem slot check after loading successfully, it isn’t discarded – it’s rerouted into the creature’s backpack instead. That’s a post-load routing decision, not a defaults question.

Repos_PosX/Repos_PosY are never read anywhere in the creature item-loading call graph – confirmed by decompiling every function that touches Equip_ItemList/ItemList entries for a creature, and by a binary-wide string search. The only code that reads either label at all is a different function entirely (ReadContainerItemsFromGff, serving placeable/store containers, i.e. UTP/UTM), not anything reachable from a creature load. So on the creature path specifically, these two fields are structurally inert – stronger than “usually absent,” genuinely unread regardless of what a hand-authored file supplies. Worth knowing too: the only casing that exists as a string anywhere in the binary is Repos_PosX and lowercase Repos_Posy – there is no Repos_PosY (uppercase Y) string in swkotor.exe at all, on any object type.

What actually can drop an item entry entirely, on the save-reload path only (LoadCreature, unreachable from a standalone .utc blueprint load): each entry can carry an ObjectId pointing at an already-instantiated item object, and the loader silently skips the entry if that item’s live possessor doesn’t match the creature currently being loaded – no equip, no backpack add, nothing. This plausibly the origin of the “spawns dead” framing (items reassigned to a corpse/loot container after death would trigger exactly this mismatch), but the actual mechanism is narrower and different: a stale-possessor check specific to save-reloads, not a hit-points-driven rule, and it never fires on a fresh .utc blueprint spawn at all.

Gold: Party Members Don’t Round-Trip It

Gold is written for every creature, but on load the engine skips the read for anyone currently in the party: a party member’s wealth lives in the shared PT_GOLD pool in PARTYTABLE.res, and the per-member snapshots are frozen copies the loader deliberately ignores. Editing a party member’s Gold in a save does nothing – edit PT_GOLD instead. Ordinary NPCs, merchants, and corpses round-trip Gold normally. The full routing design, including how the writer freezes those per-member copies, is covered in Gold and the party pool.

DetectMode: A Genuine Round-Trip Bug

Unlike the write-only fields above, DetectMode looks like it should round-trip and doesn’t, and this one reads like an honest bug rather than a design choice. SaveCreature writes the live detect-mode value faithfully. On load, LoadCreature reads a DetectMode byte from the save struct just to advance past it; the value is never assigned to anything, and construction-time logic resets every restored creature to detect mode 1 regardless of what the save contained. This is an engine quirk to be aware of, not something rakata should “correct” on read; the on-disk value is real and byte-accurate, the engine’s own loader simply never consumes it.

JoiningXP: Only Restored on Fresh Spawns

JoiningXP shares the same shape as the DetectMode bug. SaveCreature writes it on every save, but only LoadFromTemplate (the fresh-spawn path used for template-based creatures) reads it back. LoadCreature, the loader used for ordinary save-game continuation, never reads JoiningXP at all, so it silently resets to 0 every time a save is reloaded.

Structural Fields Written Only When Live

FollowInfo (party-follow state) and ExpressionList (listen/expression data) are only written by SaveCreature when the corresponding live pointer or list is actually populated. Their absence from a save isn’t a defaults question so much as a statement that there was no runtime state to save in the first place; on load, an absent struct just means that piece of party-follow or listen-data state stays unallocated.

A few more fields depend on runtime conditions at save time rather than always being present:

Field(s)Emitted whenAbsent on load resolves to
PM_AppearancePM_IsDisguised == 10; the loader only attempts the read at all if PM_IsDisguised decoded true
CombatRoundData contentsCombat was mid-round at save timeThe struct header is always present, but SaveStats itself has no writer counterpart for this data at all; it’s the outer SaveCreature that writes the struct shell, and whether the roughly two dozen combat-round scalars inside it were actually populated depends entirely on whether the game happened to be captured mid-round
EffectList, VarTable, SWVarTable, ActionListList/struct headers are always written; contents reflect however many entries currently existEmpty containers simply restore no effects, script variables, or queued actions

As a minor aside: the per-level and per-class known-spell lists are always labelled KnownList0 / KnownRemoveList0 in the GFF, literally suffixed with the digit zero rather than substituting the level or class index. Reader and writer agree on this, so it’s internally consistent rather than a bug, just an oddity worth knowing if you’re ever diffing raw GFF structs by hand.

Legacy & Ignored Data

Finding TypeExplanation
Legacy Engine ArtifactsA staggering 17 .utc fields (such as Morale, SaveWill, BlindSpot, PaletteID) present in older files are actually Neverwinter Nights superset metrics that the K1 engine natively ignores.
Confirmed-Dead by String AbsenceTemplateList (a List, present but empty in every .utc in a full install – the single highest-prevalence unmodeled label found in the corpus), CRAdjust, SaveReflex, and MemorizedList0 don’t exist as field-name strings anywhere in swkotor.exe at all, the same decisive test already used to confirm several dead DLG fields. No code path can branch on TemplateList’s presence or read its contents; there’s no reason for a canonical writer to start emitting an empty one where rakata currently omits it, since nothing reads it either way. SaveReflex follows the same dead pattern already documented for SaveWill/SaveFortitude – none of the three raw saving-throw fields exist as strings to trace an override from, though the mechanism itself (a live refbonus-style computation superseding all three) is inferred by analogy rather than directly confirmed for SaveReflex.

Vanilla Data Anomalies

Corpus surveys of the K1 GOG .utc set surface two anomalies in the SpecAbilityList field. Both are vanilla data quirks rather than decoder bugs; the structural reader represents them faithfully.

Stacked SpecAbilityList entries on the Bastila variants

Six Bastila .utc templates (bastila00c, p_bastilla, p_bastilla001, p_bastilla003, p_bastilla005, p_bastilla006) each carry 99 identical entries of Spell = 52 (SPECIAL_ABILITY_BODY_FUEL) in their SpecAbilityList. The engine’s loader (the SpecAbilityList block of CSWSCreatureStats::ReadStatsFromGff at 0x005afce0) walks each list element and unconditionally appends (Spell, SpellFlags, SpellCasterLevel) to the in-memory special_abilities_ array; there is no deduplication step. Each of the 99 entries occupies its own array slot with independent SpellFlags and SpellCasterLevel, so the stacking is faithfully preserved at runtime.

The loop iteration count itself is taken from CResGFF::GetListCount masked down to a single byte, so any UTC with more than 255 SpecAbilityList entries would have its tail silently truncated at load. Bastila’s 99 sits comfortably below that cap.

Out-of-range Spell id on the partymember template

The partymember.utc template references Spell = 299. Vanilla K1 spells.2da has 132 rows (0131), so 299 does not resolve to any row. The SpecAbilityList loader does not validate Spell against spells.2da at load time; the value is stored verbatim in the in-memory entry. spells.2da is itself read into a per-row struct array sized exactly to row_count (CSWClass::LoadSpellsTable at 0x005be4c0), so a use-time lookup of Spell = 299 indexes past the end of that array. The realised behaviour depends on heap layout at runtime and is not deterministic from the load path alone.

Both anomalies are candidate targets for future Phase 2 / Phase 3 UTC lint rules (e.g., “SpecAbilityList[].Spell must resolve to a row in spells.2da”; optionally “warn on stacked-duplicate SpecAbilityList entries unless explicitly whitelisted as a known vanilla pattern”).


Implemented Linter Rules (Rakata-Lint)

Phase 1 (intra-resource, no context)

Implemented under rakata_lint::rules::utc.

  1. UTC-001 (Appearance Correction): Warns when Appearance_Head == 0; the engine forces this to 1 at runtime.
  2. UTC-002 (Class Limit): Warns when more than 2 entries appear in ClassList; the engine ignores classes beyond the second.
  3. UTC-003 (Class Duplications): Errors when duplicate class IDs exist in ClassList; causes a fatal engine crash (0x5f7) on load.
  4. UTC-004 (Dead Save Fields): Informs when SaveWill or SaveFortitude are populated; the engine reads willbonus/fortbonus instead.
  5. UTC-005 (Gender Clamp): Warns when Gender > 4; the engine clamps to a maximum of 4.
  6. UTC-006 (GoodEvil Clamp): Warns when GoodEvil > 100; the engine clamps to a maximum of 100.
  7. UTC-007 (Toolset / Legacy Fields): Informs when any of Comment, Morale*, PaletteID, BodyVariation, TextureVar, BlindSpot, MultiplierSet, NoPermDeath, IgnoreCrePath, Hologram, WillNotRender, or LawfulChaotic are set; never read by the K1 engine.

Phase 2 (range / 2DA / resref existence, requires LintContext)

Implemented under rakata_lint::rules::utc_range.

  1. UTC-008 (Race Bounds): Errors when Race does not resolve to a row in racialtypes.2da; engine crash 0x5f4 on load.
  2. UTC-009 (Class Bounds): Errors when any ClassList[].Class does not resolve to a row in classes.2da (or is negative); engine load failure.
  3. UTC-010 (Appearance Bounds): Errors when Appearance does not resolve to a row in appearance.2da; engine renders missing model.
  4. UTC-011 (Portrait Bounds): Errors when PortraitId (when not the 0xFFFE “use string Portrait” sentinel) does not resolve to a row in portraits.2da.
  5. UTC-012 (Resref Existence): Warns when Conversation (.dlg), Portrait (.tga), any of the 14 Script* hooks (.ncs), Equip_ItemList[i].EquippedRes (.uti), or ItemList[i].InventoryRes (.uti) does not resolve in the configured resource sources.

UTD Format (Door Blueprint)

Description: The Door (.utd) blueprint defines interactive pathways on a level map. Beyond acting as physical barriers or transitions between areas, doors house lock mechanics, trap configurations, script hooks, and basic visual states (open, destroyed, jammed).

At a Glance

PropertyValue
Extension(s).utd
Magic SignatureUTD / V3.2
TypeDoor Blueprint
Rust ReferenceView rakata_generics::Utd in Rustdocs

Data Model Structure

Rakata maps a Door into the rakata_generics::Utd struct. The struct’s Rustdocs document every field’s binary schema and GFF mapping; the table below is the high-level anatomy.

CategoryCoversRepresentative fields
Core Identity & GeometryWhat the door looks like, its faction, and the text displayed when targetedAppearance, TemplateResRef, LocName
Lock & Trap MechanicsWhether the door is locked, which key opens it, and the rules for attached trapsLocked, KeyName, TrapType, DisarmDC
Transition PathwaysThe linked destination used when the door acts as a loading zone to another areaLinkedTo, LinkedToFlags
Behavioral HooksThe scripts that run when a player opens, destroys, or fails to unlock the doorOnOpen, OnFailToOpen, OnMeleeAttacked

rakata-lint validates these fields against the engine constraints documented below.

Engine Audits & Decompilation

The following information documents the engine’s exact load sequence and field requirements for .utd files mapped from swkotor.exe.

(Decompilation logic for this section was entirely audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from the shared field-reading routine CSWSDoor::LoadDoor at 0x0058a1f0; see “Save versus Template Load Paths” below for the actual top-level dispatcher.)

Structural Load Phasing

The engine processes a Door structurally by mapping its sub-fields into distinct operational constraints.

DomainSub-fields EvaluatedPurpose
Scales & State22Reads the physical health, visual appearance, and base traits determining whether the door is locked or indestructible.
Hooks15Attaches custom event scripts that fire when the door is opened, forced, unlocked, or trapped.
Mechanical9Configures the lock difficulty tiers and the specific skill hurdles required to detect and disarm any attached traps.
Transitions4Links the door strictly to another area (.are), turning it into a physical loading screen transition node.

Save versus Template Load Paths

A single UseTemplates flag, forwarded down from the area loader, decides how a door’s fields get populated on load. The actual branch point is CSWSDoor::LoadDoorExternal (0x0058c7ca), the true top-level dispatcher; LoadDoor (0x0058a1f0) is the shared field-reading routine both branches ultimately call. When the flag is clear, LoadDoorExternal calls LoadDoor directly on the door’s full instance snapshot out of the savegame; every field is already sitting in that struct. When the flag is set, LoadDoorExternal calls CSWSDoor::LoadFromTemplate (0x0058b468), which reads TemplateResRef, opens the referenced .utd blueprint, and calls the same LoadDoor against the blueprint’s own struct. Because a blueprint has no idea which specific instance it belongs to, four instance-only fields – TransitionDestin (the on-disk, 16-byte-truncated label for what the engine’s own source calls TransitionDestination; both names refer to the same field, see the note below), LinkedTo, LinkedToFlags, and LinkedToModule – are overlaid back onto the freshly-loaded door from the original save instance immediately afterward, back in LoadDoorExternal. This overlay is unconditional whenever the template branch is taken and the blueprint load succeeds; the only way to skip it is for the blueprint load itself to fail (an empty or unresolvable TemplateResRef), which aborts the whole door load rather than merely skipping the overlay.

LoadDoor itself makes no distinction between the two callers: its read of TransitionDestin runs unconditionally regardless of whether the struct it was handed is the blueprint’s or the save instance’s. So on a template-backed door, the field genuinely is read off the blueprint – it just never gets a chance to matter, because LoadDoorExternal’s overlay overwrites it with the save instance’s value immediately after LoadDoor returns. A hand-authored .utd carrying TransitionDestin would have it read the same way, and then discarded the same way.

Tag is a fifth field with instance-only stakes, but it isn’t overlaid. LoadDoor contains the only Tag read in the entire door-load call graph, so on a templated door Tag comes from the blueprint, exactly like Appearance or HP – and unlike the four fields above, nothing in LoadDoorExternal re-reads Tag from the placed instance afterward. That’s a real gap in the instance-overlay mechanism, not a documentation omission: multiple doors sharing one .utd blueprint would share one Tag, which would break any script that targets a door by tag. In practice this doesn’t bite, because the vanilla toolset works around it at the content level rather than the engine level – real modules generally give each placed door its own dedicated .utd, one blueprint per instance, rather than truly sharing a single template across multiple doors. A corpus scan bears this out: every door instance in a full install carries a real, distinct TemplateResRef value. The mechanism is technically “templated,” but the authoring convention makes it behave like one blueprint per door in practice.

This isn’t a door-specific quirk: every templated GIT object type behaves identically. See GIT’s “The Blueprint’s Tag Always Wins” for the general pattern across Placeables, Triggers, Sounds, Stores, Encounters, Creatures, and Items.

Core Structural Findings

The CSWSDoor parser natively guarantees strict state adjustments upon parsing.

Engine RuleRuntime Behavior
Appearance TruncationThe engine reads Appearance as a 32-bit integer but forcefully truncates it to a single byte. Any ID above 255 automatically wraps to 0 and breaks the physical door model.
Static EnforcementIf the door is marked Static, the engine automatically forces plot = 1. This safely guarantees that static level architecture cannot be destroyed by players.
Portrait ShadowingIf PortraitId is 0, the engine hardcodes it to 0x22E. If PortraitId is < 0xFFFE, the engine completely ignores the Portrait string ResRef and relies entirely on the ID. Any value in the Portrait ResRef field is treated as dead data.
Trap Hook FallbackIf the OnTrapTriggered script is left empty, set to null, or literally named "default", the engine pulls the default standard script from traps.2da instead.
HP SynchronizationCurrentHP is clamped against the door’s maximum HP, but only on the template load path. A direct savegame load takes the raw saved CurrentHP value with no clamp applied.
No Other OmissionsAside from the Portrait/PortraitId fork above, the save routine writes every door field unconditionally. No other door field is ever left out of a vanilla save.

Absent-Field Defaults: Most Are Simple, A Few Are Not

The governing mechanism across LoadDoor is a CResGFF::ReadField* call that takes a fallback argument: sometimes that fallback is the object’s own current member value (a true carry-over from whatever the constructor set), sometimes it’s a fresh literal constructed at the read site that ignores the current member entirely. Both look identical in the decompiled store, so which one applies has to be checked per field, not assumed from the value alone.

All 15 script hooks default to the literal string "default", not an empty resref. This is the standout finding on this page: the CSWSDoor constructor explicitly loops over all 15 script-slot members (OnClosed, OnDamaged, OnDeath, OnDisarm, OnHeartbeat, OnLock, OnMeleeAttacked, OnOpen, OnSpellCastAt, OnTrapTriggered, OnUnlock, OnUserDefined, OnClick, OnFailToOpen, OnDialog) and assigns each the literal string "default", not an empty string. Every hook read is a carry-over of that constructed value: an absent hook resolves to whatever the member currently holds, which on a fresh load is "default". This is the actual mechanism behind the already-documented OnTrapTriggered fallback rule (“empty, null, or literally "default"” routes to traps.2da) – it exists specifically because an absent OnTrapTriggered naturally becomes "default" through this carry-over, not because the engine special-cases three different absent-value spellings. The other 14 hooks have no such secondary lookup: they simply keep the literal resref "default", which won’t resolve to a real .ncs unless a module happens to ship one named exactly that.

TrapType’s absent default is the sentinel 0xFF (255), not 0, and it can chain into an out-of-range lookup. Combined with the OnTrapTriggered-absent-becomes-"default" carry-over above: a door missing both TrapType and OnTrapTriggered ends up looking up row 255 of traps.2da, which almost certainly doesn’t exist in vanilla data. That’s an out-of-range 2DA lookup, not a clean “no trap configured” state – worth a lint rule of its own, distinct from the already-flagged OnTrapTriggered fallback.

Lockable defaults to 0 – a door is not lockable unless the file says so. Same carry-over mechanism as most fields here (constructor sets lockable = 0), but worth calling out explicitly since “lockable by default” is the more intuitive assumption for a door.

HP (maximum) carries over a constructed default of 1, not 0. A freshly constructed door object nominally has 1 hit point before any GFF read touches it; an absent HP field leaves it there.

Hardness, Static, and LoadScreenID use a fresh literal 0, not carry-over – distinct mechanism, same value. Where most numeric fields on this page pass the object’s current member as the read’s fallback, these three pass a hardcoded 0 regardless of what the constructor set (which also happens to be 0 for Hardness/Static, so the observable value is identical either way – only the mechanism differs, and it would matter if the constructed defaults ever diverged from 0).

Plot and Invulnerable are entangled, and the gating condition genuinely couldn’t be resolved. LoadDoor reads a field literally named "Invulnerable" first (carry-over fallback: this->object.plot, constructor default 0), then conditionally reads a field named "Plot" with the identical carry-over fallback, potentially overwriting the first result. The condition gating that second read traces to a stack value with no discoverable prior write inside LoadDoor or its two known callers – genuinely unresolved, not just unchecked. What’s certain regardless of which branch fires: both reads share the same fallback, so an absent Plot and an absent Invulnerable both resolve to 0 (not plot) – unless Static is present and true, which forces the final value to 1 regardless, per the already-documented Static-enforcement rule. Worth flagging on its own: Invulnerable is not part of the documented UTD schema and isn’t written by the vanilla toolset, but the engine genuinely reads it if a file supplies it – a live field with no prior mention on this page.

OpenState carries a dead-in-practice override. The field itself carries over normally (constructor default 0), but the read result then feeds a check that can force it to a hardcoded 3 – gated on an internal flag the constructor sets to 1 and that nothing observed inside LoadDoor ever resets to 0 before a load runs. Under the normal construct-then-load flow this override never fires, so OpenState absent resolves to the plain carried-over 0 in practice; flagged here in case some other, unexamined path resets that flag first.

The remaining fields all follow the ordinary carry-over pattern with unremarkable constructed defaults: Faction/GenericType/AutoRemoveKey to 0, Bearing to 0.0, KeyRequired/OpenLockDC/CloseLockDC/SecretDoorDC/Fort/Ref/Will/DisarmDC/TrapDetectDC/TrapFlag/Min1HP to 0, Locked to 0 (unlocked), TrapDetectable/TrapDisarmable/TrapOneShot to 1 (true) – and a handful default to a fresh literal rather than carrying over, with the same practical value: LocName/Description to an empty localized string (a throwaway default-constructed value at the read site, not this->name/this->description), Conversation to an empty resref, Tag to an empty string (the one string field routed through SetTag rather than a plain assignment, carry-over of the constructor’s own "").

Legacy & Ignored Data

Finding TypeExplanation
Legacy Engine ArtifactsConfirmed by full-text search of LoadDoor’s decompiled body: exactly seven fields are never read anywhere in the function, and this is the complete list, not a sample – AnimationState, NotBlastable, OpenLockDiff, OpenLockDiffMod, Comment, Interruptable, PaletteID. Whatever storage (if any) the struct carries for these stays at whatever the constructor set, regardless of on-disk GFF content.

Implemented Linter Rules (Rakata-Lint)

Phase 1 (intra-resource, no context)

Implemented under rakata_lint::rules::utd.

  1. UTD-001 (Static Parity): Warns when Static=true but Plot=false; the engine forces Plot to true at runtime.
  2. UTD-002 (HP Bounds): Errors when CurrentHP > HP; the engine clamps to HP on template load.
  3. UTD-003 (Portrait Shadowing): Warns when PortraitId < 0xFFFE and Portrait resref is set; the resref is ignored at runtime.

Phase 2 (range / 2DA / resref existence, requires LintContext)

Implemented under rakata_lint::rules::utd_range.

  1. UTD-004 (Generic Door Type Bounds): Errors when GenericType does not resolve to a row in genericdoors.2da; engine renders missing model.
  2. UTD-005 (Portrait Bounds): Errors when PortraitId (when not the 0xFFFE “use string Portrait” sentinel) does not resolve to a row in portraits.2da.
  3. UTD-006 (Resref Existence): Warns when Conversation (.dlg), Portrait (.tga), or any of the 15 On* script hooks (.ncs) does not resolve in the configured resource sources. LinkedToModule (area transition) is deferred to Phase 3.

Pending

  • Appearance Truncation: Flags legacy Appearance (u32) values above 255 (engine truncates to a single byte).
  • Trap Hook Fallback Detection: Scans for empty / null / literally-named "default" OnTrapTriggered references that silently invoke the traps.2da fallback.
  • Portrait Zero Hardcode: Detects PortraitId == 0 mappings since the engine hardcodes lookup to 0x22E.

UTE Format (Encounter Blueprint)

Description: The Encounter (.ute) blueprint defines interactive spawn points and boundary triggers across a level map. Instead of acting merely as a spatial zone, encounters handle complex difficulty scaling, bubble-sort creature limits, and explicit coordinate vertices to dynamically deploy combatants when a player crosses their geometry bounds.

At a Glance

PropertyValue
Extension(s).ute
Magic SignatureUTE / V3.2
TypeEncounter Blueprint
Rust ReferenceView rakata_generics::Ute in Rustdocs

Data Model Structure

Rakata maps an Encounter into the rakata_generics::Ute struct. The struct’s Rustdocs document every field’s binary schema and GFF mapping; the table below is the high-level anatomy.

CategoryCoversRepresentative fields
Spawn PopulationThe creature blueprints the encounter can spawnCreatureList
Difficulty & LimitsHow many creatures spawn at once and how hard they are relative to the playerMaxCreatures, DifficultyIndex
Trigger BoundariesThe coordinates that trace the tripwire that fires the spawnGeometry
Behavioral HooksThe scripts that run when a player enters or exits the trigger, or when the spawn pool runs dryOnEntered, OnExhausted

rakata-lint validates these fields against the engine constraints documented below.

Engine Audits & Decompilation

The following information documents the engine’s exact load sequence and field requirements for .ute files mapped from swkotor.exe.

(Decompilation logic for this section was audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from the primary dispatcher CSWSEncounter::LoadEncounter at 0x00593830.)

Structural Load Phasing

The engine processes an Encounter structurally across several chunked subroutines, each responsible for unique spatial and logic bindings.

FunctionSizeBehavior
ReadEncounterFromGff (0x00592430)3445 BThe initial pass that sets up the encounter’s identity, difficulty limits, and the spawn list. It also reads the runtime spawn-tracking fields, AreaList, and SpawnList inline – see “Runtime Fields Are Read on the Blueprint Path Too” below.
ReadEncounterScriptsFromGff567 BAttaches scripts that trigger when players enter, exit, or exhaust the spawn pool.
LoadEncounterSpawnPoints (0x00590410)364 BReads the coordinates so the engine knows exactly where to spawn the creatures. Called from inside ReadEncounterFromGff itself, gated only on whether the source struct has a non-empty SpawnPointList.
LoadEncounterGeometry651 BReads the coordinates that trace the trigger’s boundaries on the floor.

ReadEncounterFromGff and ReadEncounterScriptsFromGff are shared verbatim between two callers: the save-game path reads them straight off the area’s GIT struct, while the blueprint/template path reads the same fields off the .ute file’s own top-level struct. There is no UseTemplates branch inside the field readers themselves; the fork only decides which file supplies the struct.

Runtime Fields Are Read on the Blueprint Path Too

NumberSpawned, HeartbeatDay, HeartbeatTime, LastSpawnDay, LastSpawnTime, LastEntered, LastLeft, Started, Exhausted, CurrentSpawns, CustomScriptId, AreaListMaxSize, SpawnPoolActive, AreaPoints, plus the SpawnPointList, AreaList, and SpawnList lists, all read as one contiguous, unconditional block inside ReadEncounterFromGff. None of them sit behind a source-type check; the function reads whatever the struct it was handed contains, blueprint or GIT instance alike.

The actual blueprint-versus-instance fork lives two calls up, in CSWSArea::LoadEncounters (0x00505060): with no template, it calls CSWSEncounter::LoadEncounter (0x00593830) directly on the GIT instance struct; with a template, it opens the .ute named by the instance’s TemplateResRef and calls CSWSEncounter::LoadFromTemplate (0x00593a90), which runs ReadEncounterFromGff against the blueprint’s own struct. Afterward, LoadEncounters re-reads position, Geometry, and SpawnPointList a second time from the GIT instance as overrides – but the runtime-tracking scalars, AreaList, and SpawnList are not among the re-read fields, so whatever a template supplies for those would stand as read, with no instance-level override mechanism at all.

Practically: the corpus-observed absence of these fields from every vanilla .ute is an authoring-tool habit, not an engine restriction. A hand-authored .ute carrying them would have every one read and applied on the ordinary template-load path. One authoring hazard worth flagging: AreaList’s read allocates its buffer using AreaListMaxSize, itself one of the fields read from the same struct just above it – a file that supplies AreaList entries without a large enough AreaListMaxSize would size the destination buffer too small, with nothing in the loader stopping the read.

Absent-Field Defaults

Every scalar field ReadEncounterFromGff/ReadEncounterScriptsFromGff reads shares one idiom: the read call’s own “default if absent” argument is the field’s already-constructed value on the object, and the result is stored back with no visible branch on presence. Functionally this is a carry-over, not a fresh literal stamp, even though the assignment itself always executes – an absent field always resolves to whatever a freshly constructed CSWSEncounter already held for that member:

FieldConstructed default carried over on absence
LocalizedNameEmpty localized string
Activetrue – the one boolean on this struct that constructs to nonzero; Reset/PlayerOnly/Started/Exhausted all construct to false
Resetfalse
ResetTime60
Respawns0
SpawnOption0
MaxCreatures8
RecCreatures2
PlayerOnlyfalse
Faction1
OnEntered, OnExit, OnHeartbeat, OnExhausted, OnUserDefinedEmpty resref/script, all five read in that order with the identical mechanism

XPosition/YPosition/ZPosition follow the same self-default read, but with an extra step: the constructor sets the encounter’s position to the origin via SetPosition, each coordinate is individually read against its own current value, and the assembled vector is then unconditionally passed through SetPosition again. So an absent position doesn’t just leave the origin untouched in isolation – it’s carried over and then re-stamped, landing on the same origin either way. (This is the read LoadEncounters’ own instance-level position override, mentioned above, layers on top of.)

Core Structural Findings

The engine rigorously evaluates geometric and spatial boundaries. Improper definitions break the spawn mapping algorithm.

Warning

Understanding Fatal Log Drops While minor coordinate math errors usually just cause creatures to spawn inside walls, failing strict geometry constraints causes KOTOR to abruptly abort parsing the Encounter. Specifically, if a .ute file declares it has geometry boundaries but fails to provide the actual coordinate vertices, the engine dumps a fatal error to its trace log and refuses to spawn the encounter at all.

Engine RuleRuntime Behavior
Tag OverridesThe engine forcefully converts any Tag to all-lowercase via CSWSObject::SetTag. Any static casing is lost immediately upon load.
Geometry IntegrityIf Geometry is explicitly defined but has 0 vertices, the engine logs a “has geometry, but no vertices” error and aborts loading the encounter entirely.
Geometry SynthesisIf the Geometry list is completely omitted from the blueprint, the engine falls back and safely synthesizes a default 4-vertex spatial box. In practice this branch is dead code: it only fires under a spawn-position-override mode that the encounter loader’s sole caller never enables, so no real load path can reach it. Every encounter that actually spawns needs genuine, non-empty geometry.
Difficulty ResolutionThe engine prioritizes using DifficultyIndex to look up the difficulty in encdifficulty.2da. The static Difficulty field is read back only if the 2DA table itself fails to load altogether, a rare installation-level failure rather than a per-encounter fallback; a valid table always wins.
Bubble SortingUpon loading the CreatureList, the engine runs a Bubble Sort algorithm to firmly re-order the encounter’s spawn pool by ascending CR (Challenge Rating), completely overriding any custom static display order.
Area InstantiationAreaList buffer allocation size is strictly dictated by AreaListMaxSize. If the real list exceeds this size, the buffer will silently overrun.
Structural List OmissionCreatureList, SpawnPointList, AreaList, and SpawnList (a pending/scheduled spawn pool distinct from CreatureList) are each only reloaded if present and non-empty. Omitting any of them leaves that list exactly as it already was, empty on a freshly built encounter, rather than raising an error.
Spawn Point OrientationEach SpawnPointList entry stores its facing as a single raw heading float, not a direction vector. The value is stored and reloaded unmodified with no normalization step, unlike vector-based orientation elsewhere in the engine’s placement schema.

Legacy & Ignored Data

Finding TypeExplanation
Passive Legacy ArtifactsUnused fields left over from older tools or Odyssey branches (e.g., TemplateResRef, Comment, PaletteID) are completely dark. The engine inherently ignores them.
Toolset-Only AppearanceNearly every .ute file carries an Appearance INT, but ReadEncounterFromGff – the one function that parses every encounter field, blueprint or GIT instance alike – never queries a field by that name, and no other function in the binary does either. It’s the level editor’s own icon/model pick for the encounter’s spawn marker in the 3D view, invisible to swkotor.exe. Compare GIT’s Appearance, which is the same label but a genuinely engine-read field on a different object type.
Superseded Legacy FieldsThe static Difficulty field is a completely inactive legacy metric as long as DifficultyIndex maps to a valid row inside encdifficulty.2da.

Implemented Linter Rules (Rakata-Lint)

Phase 1 (intra-resource, no context)

Implemented under rakata_lint::rules::ute.

  1. UTE-001 (Dead Difficulty Traces): Warns when Difficulty > 0 while DifficultyIndex >= 0; the engine ignores the static Difficulty in favor of the 2DA lookup.
  2. UTE-002 (Deficient Spawn Loops): Warns when an encounter is marked Active=true but CreatureList is empty.
  3. UTE-003 (Dead Field Evaluation): Informs when TemplateResRef, Comment, or PaletteID are populated; never read by the K1 engine.
  4. UTE-004 (Geometry Integrity Risk): Warns when Geometry has 0 vertices; an explicitly defined empty geometry array crashes the engine on load.

Phase 2 (resource existence, requires LintContext)

Implemented under rakata_lint::rules::ute_range.

  1. UTE-005 (Resref Existence): Warns when any of OnEntered, OnExit, OnHeartbeat, OnExhausted, or OnUserDefined (.ncs) does not resolve, or when any CreatureList[i].ResRef (.utc) does not resolve in the configured resource sources.

UTI Format (Item Blueprint)

The Item (.uti) blueprint serves as the central data model for all tangible loot, weapons, armor, and usable gear in the game. It defines how an item physically appears on characters, what custom properties or stat bonuses it applies through specific upgrade hierarchies, its intrinsic monetary cost, and exactly what its runtime state behaves like when dropped into the world map.

At a Glance

PropertyValue
Extension(s).uti
Magic SignatureUTI / V3.2
TypeItem Blueprint
Rust ReferenceView rakata_generics::Uti in Rustdocs

Data Model Structure

Rakata maps an Item into the rakata_generics::Uti struct. The struct’s Rustdocs document every field’s binary schema and GFF mapping; the table below is the high-level anatomy.

CategoryCoversRepresentative fields
Core IdentityThe item’s name and description, in both identified and unidentified statesTemplateResRef, LocName, Description
Economic & Charge MechanicsThe item’s value and the charges left for consumable abilitiesCost, Charges
Visual GeometryWhat the item looks like when dropped on the floor or equippedModelVariation, TextureVar
Combat & Upgrade PropertiesThe stat buffs, damage modifiers, and abilities bound to the item, plus workbench upgrade slotsPropertiesList

rakata-lint validates these fields against the engine constraints documented below.

Engine Audits & Decompilation

The following information documents the engine’s exact load sequence and field requirements for .uti files mapped from swkotor.exe.

(Decompilation logic for this section was entirely audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from the primary dispatcher CSWSItem::LoadDataFromGff at 0x0055fcd0, the active-property predicate CSWSItem::IsFriendlyUsableItem at 0x00553900, the property-string resolver CSWSItem::GetPropertyStrings at 0x00554e00, and the IPRP table loaders CTwoDimArrays::LoadIPRPCostTables at 0x005c4730 / LoadIPRPParamTables at 0x005c49c0.)

Structural Load Phasing

The engine processes an Item structurally across multi-pass capabilities mappings.

FunctionSizeBehavior
LoadDataFromGffThe main parser that sets what the item is, how many charges it holds, its descriptions, and – inlined into the same function, not a separate routine – the property list itself, in two back-to-back passes (active properties, then passive). There is no standalone LoadItemPropertiesFromGff symbol in this binary; earlier documentation implying one was a naming assumption, not a confirmed function.
LoadItemThe constructor that decides whether to load the item onto a character or leave it idle in an inventory.
LoadFromTemplateA fallback used when spawning an item dynamically from a script instead of off a character.
SaveItem / SaveItemPropertiesThe opposite pipeline that writes the item into a save game, which notoriously forces the item to always be flagged as “Identified”.

Core Structural Findings

The engine rigorously evaluates base-item mapping constraints from 2DA arrays and aggressively overrides improperly defined models.

Engine RuleRuntime Behavior
Description Cross-SwapIf either Description or DescIdentified is missing, the engine automatically duplicates the provided string into the missing field so item identification mechanics never crash the game.
Model TruncationIf an older tool incorrectly configures ModelVariation to 0, the engine forcefully bumps it to 1 upon load, ensuring the item always has visible geometry instead of rendering an invisible weapon or armor piece. The field itself has a legacy fallback baked into the same read: if ModelVariation is absent entirely (not merely zero), the engine falls back to the older ModelPart1 label before applying the same zero-check, so genuinely ancient files still get the same protection. ModelPart2 and ModelPart3 are not part of this fallback, or read anywhere at all – their field-name strings don’t exist anywhere in the binary. Real files that carry all three alongside ModelPart1 are authoring-tool habit; only ModelPart1 ever reaches the engine.
Charge Fallback ChainingMaxCharges has no default of its own. When missing, the engine simply reuses whatever value Charges just resolved to (current value, or the constant 50 fallback if Charges was itself absent) rather than falling back to the item’s own prior max-charges value.
Container Contents GatingItemList on an item (its nested contents, for container-type items like backpacks) is only read or written at all when the base item’s baseitems.2da row marks it as a container. A non-container item never has this list looked for; a genuine container item with the list absent simply loads with an empty inner inventory.
Dead Placement FieldsSaveItem unconditionally writes XPosition/YPosition/ZPosition and XOrientation/YOrientation/ZOrientation for every item, in every container. Only the area’s placed-item loader (CSWSArea::LoadItems) ever reads them back; creature inventories, stores, the party’s shared stash, and nested container contents all write these six fields and then never look at them again.
Model & Body Variation HooksThe engine completely ignores the .uti’s BodyVariation field, opting instead to enforce the exact body_var value predefined in baseitems.2da. Additionally, TextureVar is unconditionally bypassed unless the item’s base type is strictly configured as Model Type 1.
Cost Generation FallbackThe physical Cost integer provided in the file is dead data. The engine strictly computes economic value actively via GetCost() calculations based on its properties, completely ignoring your defined value.
Identifier EnforcementDuring explicit serializing via SaveItem (when the player creates a save game), the engine actively forces and hardcodes Identified to 1 unconditionally. This was cross-checked against 136 real items pulled from two separate vanilla save files (area creature/placeable loot, and a late-game party stash); every single one came back Identified = 1, confirming the decompiled behaviour holds in practice.
Property CapabilitiesItem properties are structurally split into Active and Passive memory tables at load. The engine evaluates every PropertyName index: any ID strictly mapping to 10, 37, 46, or 53 (e.g., Cast Power, Trap) is actively hooked as a usable player ability, while all other integers are silently applied as passive stat modifiers.
Data-Driven Property KindsThe engine does not hardcode a “PropertyName N -> semantic kind” table. Property-kind classification (Damage Bonus, Ability Bonus, Save Bonus, etc.) is resolved entirely by reading the Label column of itempropdef.2da at the row indexed by PropertyName. Mods that add new rows surface as new property kinds without engine changes.
Property Field DefaultsWhen a PropertiesList entry omits a field, the engine fills it from a fixed table: Useable=1 for active properties (PropertyName 10/37/46/53), Useable=0 for passive ones; UsesPerDay=0xFF and UpgradeType=0xFF – both 0xFF values function as “not set” sentinels rather than valid row indices.
Bit Flags ApplicationThe Dropable boolean explicitly sets bit 3 of the item’s internal memory flags, while Pickpocketable sets bit 4. Missing fields safely default to 0 – and that 0 genuinely is a literal, not the constructor’s own starting value: CSWSItem’s constructor actually flips both bits on by default (droppable and pickpocketable, before any GFF is touched), but the read for each field is a hardcoded-literal-0 read with no presence check, so it unconditionally overwrites that constructed true back to false on every load, present or absent. The field never gets a chance to carry the constructor’s default forward.
Identified Absent Means Identified, and Can Be Forced RegardlessIdentified’s own load-side default is a hardcoded 1 (identified), stamped unconditionally with no presence check – a different mechanism from most of this item’s boolean flags, which default to a carried-over constructed value instead of a fresh literal. There’s a second override on top: once the property list finishes loading, if the item ends up with zero active and zero passive properties, the loader unconditionally forces the identified bit back to 1 regardless of what the file said – even an explicit Identified = 0 on a property-less item gets overwritten to identified on load.

Most of the remaining top-level fields default to a carried-over constructed value rather than a fresh literal, in the same “read’s own default argument is the object’s current member” mechanism documented elsewhere on this page: BaseItem carries over to 30, LocalizedName/Tag to empty, StackSize to 1, Plot/Stolen/NonEquippable/NewItem/DELETING to false, and AddCost/Upgrades to 0. TextureVar is the one exception among these – when it’s actually consulted at all (base item’s model_type == 1), it’s read with a hardcoded literal default of 1, unconditional, not a carry-over.

PropertiesList Scalars: Six of Seven Default to Uninitialized Memory, Not Zero

PropertyName, Subtype, CostTable, CostValue, Param1, Param1Value, and ChanceAppear each get read with a literal default of 0 passed to the read call – but that 0 is never actually committed if the field is absent. Each of the seven writes is individually gated on its own presence flag, checked field-by-field right before the property struct member is set. Because the property array itself comes from a raw, unzeroed allocator, an absent field on an otherwise-populated PropertiesList entry doesn’t resolve to 0 at all – it leaves that struct member holding whatever was already sitting in that heap memory. This is a real correctness hazard, not just a documentation nuance: a linter (or a decoder) that assumes “absent property field reads as 0” is wrong for all seven of these, and should instead flag partially-specified property entries as producing undefined values.

(Note also: rakata models PropertyName as an INT-typed field, but the engine actually reads it with a 16-bit WORD read, same as Subtype and CostValue.)

Useable, UsesPerDay, and UpgradeType (already documented above) are the exception: those three are stamped unconditionally with no presence gate on the write, so they’re the only fields in a PropertiesList entry that are genuinely deterministic when absent.

A related edge case at the entry level, distinct from the per-field gating above: if a PropertiesList entry’s own struct is completely empty (zero fields at all, not just one missing label), the engine’s counting pass folds it into the passive tally, but the populate pass then skips writing it entirely once it reaches that entry. The property array was sized assuming that entry would be populated, so the slot it would have occupied is left as the same kind of uninitialized heap memory described above – an entirely empty entry doesn’t drop cleanly, it leaves a hole.

Property Table Dispatch

A UtiProperty carries three indices that point through three separate registry-of-registries chains. The engine holds no hardcoded mapping for any of them; every dispatch is a 2DA cell read, so mods that extend the underlying tables surface without engine modification.

Per-property subtype dispatch (resolved at display time inside GetPropertyStrings at 0x00554e00):

Step2DAIndexed ByColumn ReadPurpose
1itempropdef.2daPropertyNameName (INT)TLK strref for the property’s display name (e.g. “Damage Bonus”).
2itempropdef.2daPropertyNameSubTypeResRef (string)Resref of the per-property subtype 2DA (e.g. iprp_damagecost). Empty/missing means the property has no subtype dimension.
3(subtype 2DA from step 2)SubtypeName (INT)TLK strref for the subtype’s display name (e.g. “Acid”).

Cost-table dispatch (resolved eagerly at startup inside LoadIPRPCostTables at 0x005c4730):

Step2DAIndexed ByColumn ReadPurpose
1iprp_costtable.2daCostTableName (string)Resref of the cost-specific 2DA (e.g. iprp_meleecost). Used as a resref despite the column name suggesting a label.
2iprp_costtable.2daCostTableClientLoad (INT, optional)When set and the engine is running in client mode, the loader skips loading this row’s cost 2DA. Treated as server-only.
3(cost 2DA from step 1)CostValue(table-specific)The row at CostValue carries the cost effect for this property; column layout varies per cost table.

Param-table dispatch (resolved eagerly at startup inside LoadIPRPParamTables at 0x005c49c0):

Step2DAIndexed ByColumn ReadPurpose
1iprp_paramtable.2daParam1TableResRef (string)Resref of the param-specific 2DA.
2(param 2DA from step 1)Param1Value(table-specific)The row at Param1Value carries the parameter value for this property; column layout varies per param table.

Engine constraints:

  • Both iprp_costtable.2da and iprp_paramtable.2da row counts are stored as byte (u8) in CTwoDimArrays. Rows past index 255 are silently truncated by the loader and the affected per-property tables never get loaded into memory.
  • Column-name lookups in 2DAs are case-sensitive at the engine API (C2DA::GetINTEntry / GetCExoStringEntry compare verbatim). The exact spellings the engine uses are Name, SubTypeResRef, TableResRef, Label, and ClientLoad.
  • The subtype 2DA listed in SubTypeResRef is loaded lazily on display via GetPropertyStrings, not eagerly at startup. A missing subtype 2DA fails only the call that needs it, not the whole game load.
  • The Name column on every level of the dispatch is a TLK strref. The Label column on the same row holds a developer-readable identifier (e.g. Damage_Bonus) that does not require talktable resolution.

Cost-Table Magnitude Resolution

The cost-table dispatch chain documented above ends at “the row at CostValue carries the cost effect for this property; column layout varies per cost table.” This section pins down the column layout for vanilla K1’s iprp_costtable.2da entries and how each Apply<PropertyKind> handler reads from them, sourced from the CSWSItemPropertyHandler::Apply* family in swkotor.exe (handlers cluster around 0x004e5490-0x004e7e80 and 0x004e9230-0x004e9390).

iprp_costtable.2da (vanilla K1) — index to per-cost 2DA mapping:

IndexName (resref of per-cost 2DA)LabelClientLoad
0IPRP_BASE1Base10
1IPRP_BONUSCOSTBonus0
2IPRP_MELEECOSTMelee1
3IPRP_CHARGECOSTSpellUse0
4IPRP_DAMAGECOSTDamage0
5IPRP_IMMUNCOSTImmune0
6IPRP_SOAKCOSTDamageSoak0
7IPRP_RESISTCOSTDamageResist0
8IPRP_BLADECOSTDancingScimitar0
9IPRP_SLOTSCOSTSlots0
10IPRP_WEIGHTCOSTWeight0
11IPRP_SRCOSTSpellResist0
12IPRP_STAMINACOSTStamina0
13IPRP_SPELLLVCOSTSpellLevel0
14IPRP_AMMOCOSTAmmo0
15IPRP_REDCOSTWeightReduction0
16IPRP_SPELLCOSTSpells0
17IPRP_TRAPCOSTTraps0
18IPRP_LIGHTCOSTLight1
19IPRP_MONSTCOSTMonster_Cost0
20IPRP_NEG5COSTNegative_Modifiers0
21IPRP_NEG10COSTNegative_Modifiers0
22IPRP_DAMVULCOSTDamage_vulnerability0
23IPRP_SPELLLVLIMMSpell_Level_Immunity0
24IPRP_ONHITCOSTOnHitCosts0
25IPRP_ONHITDCOnHitDC_saves0

Per-handler magnitude resolution. Each Apply<Kind> handler that needs a cost-table magnitude calls CTwoDimArrays::GetIPRPCostTable(<index>) then C2DA::GetINTEntry(table, row=CostValue, column, out). The integer that comes back is the engine-side magnitude (in the units appropriate to the property kind: bonus number, damage soak amount, save delta, etc.). The column name is read case-sensitively; the only two columns the vanilla handlers consult are Value and Amount.

HandlerCostTable indexPer-cost 2DAColumnPost-processing
ApplyAbilityBonus1iprp_bonuscostValue
ApplyACBonus1iprp_bonuscostValue
ApplyImprovedSavingThrow1iprp_bonuscostValue
ApplyDamageReduction6iprp_soakcostAmount
ApplyDamageResistance7iprp_resistcostAmount
ApplyImprovedForceResistance11 (0xB)iprp_srcostValue
ApplyAttackPenalty20 (0x14)iprp_neg5costValuenegate
ApplyDamagePenalty20 (0x14)iprp_neg5costValuenegate
ApplyReducedSavingThrows20 (0x14)iprp_neg5costValuenone (table holds negatives)
ApplyDecreasedAC20 (0x14)iprp_neg5costValuenegate
ApplyDecreasedAbilityScore21 (0x15)iprp_neg10costValuenegate
ApplyDecreasedSkillModifier21 (0x15)iprp_neg10costValuenegate
ApplyDamageVulnerability22 (0x16)iprp_damvulcostValue
ApplyDamageImmunitydynamic (property.cost_table)per-propertyValue

Handlers that bypass the cost-table dispatch. A surprising number of vanilla handlers do not call GetIPRPCostTable at all and instead consume CostValue (or another property field) directly as the magnitude:

  • ApplyDamageBonus (covers PropertyName 11 Damage, 12 DamageAlignmentGroup, and 13 DamageRacialGroup in one switch) reads CostValue straight as the damage amount. There is no per-cost 2DA lookup. The iprp_damagecost.2da table is used for cost calculation (GetCost), not for damage-magnitude resolution.
  • ApplyEnhancementBonus and ApplyAttackBonus read (Rules->internal).all_2DAs->iprp_meleecost via direct struct-field access (not through GetIPRPCostTable), then read column Value. Equivalent to a cost-table-index 2 (iprp_meleecost) dispatch, just inlined.
  • ApplySkillBonus and ApplyBonusFeat read the magnitude / feat id from the property struct directly.
  • ApplyImmunity switches on the subtype id and assigns one of ten hardcoded engine constants; no 2DA is consulted.
  • ApplyRegeneration uses CostValue as the regen amount and a hardcoded 6000 ms tick interval; no 2DA.

Implications for decoded magnitude resolution. A decoder that resolves property magnitudes should:

  1. First check whether the property kind is on the cost-table list above; if yes, read the resolved magnitude from the listed cost 2DA at row CostValue, column Value or Amount, with the documented post-processing.
  2. If the property kind is on the bypass list, the magnitude is CostValue directly (or, for ApplyImmunity, hardcoded per subtype).
  3. For ApplyDamageImmunity, the cost-table index is read from the property’s own CostTable field rather than being hardcoded per handler; mod-extended cost tables resolve through the same path.

Vanilla itempropdef.2da Label Reference

The following table lists every label in the vanilla K1 itempropdef.2da. The decoder in rakata_generics::decoded matches on the Label column at the row indexed by UtiProperty::property_name. The Subtype 2DA column is the file’s SubTypeResRef cell verbatim (lowercased per the engine’s case-insensitive resref handling); an empty cell means the property has no subtype dimension.

The four rows the engine treats as active (loaded into the per-character usable-ability table per IsFriendlyUsableItem) are marked. Every other row is passive.

RowLabelSubtype 2DANotes
0Abilityiprp_abilities
1ArmorAC base bonus
2ArmorAlignmentGroupiprp_aligngrp
3ArmorDamageTypeiprp_combatdam
4ArmorRacialGroupracialtypes
5EnhancementEnhancement bonus to weapons
6EnhancementAlignmentGroupiprp_aligngrp
7EnhancementRacialGroupracialtypes
8AttackPenalty
9BonusFeatsfeat
10CastSpellspellsactive
11Damageiprp_damagetype
12DamageAlignmentGroupiprp_aligngrp
13DamageRacialGroupracialtypes
14DamageImmunityiprp_damagetype
15DamagePenalty
16DamageReducediprp_protection
17DamageResistiprp_damagetype
18Damage_Vulnerabilityiprp_damagetype
19DecreaseAbilityScoreiprp_abilities
20DecreaseACiprp_acmodtype
21DecreasedSkillskills
22DamageMeleeiprp_combatdam
23DamageRangediprp_combatdam
24Immunityiprp_immunity
25ImprovedMagicResist
26ImprovedSavingThrowsiprp_saveelement
27ImprovedSavingThrowsSpecificiprp_savingthrow
28Keen
29Light
30Mighty
31DamageNone
32OnHitiprp_onhit
33ReducedSavingThrowsiprp_saveelement
34ReducedSpecificSavingThrowiprp_savingthrow
35Regeneration
36Skillskills
37ThievesToolsactive
38AttackBonus
39AttackBonusAlignmentGroupiprp_aligngrp
40AttackBonusRacialGroupracialtypes
41ToHitPenalty
42UnlimitedAmmoiprp_ammotype
43UseLimitationAlignmentGroupiprp_aligngrp
44UseLimitationClassclasses
45UseLimitationRacialracialtypes
46Traptrapsactive
47True_Seeing
48OnMonsterHitiprp_monsterhit
49Massive_Criticals
50Freedom_of_Movement
51Monster_damage
52Special_Walkiprp_walk
53Computer_Spikeactive
54Regeneration_Force_Points
55Blaster_Bolt_Deflect_Increase
56Blaster_Bolt_Defect_DecreaseVanilla typo (Defect not Deflect); decoder must match the file spelling exactly.
57Use_Limitation_Featfeat
58Droid_Repair_Kit
59Disguiseappearance

Mod content extends this table with rows past index 59. The decoder’s typed-variant dispatch matches by Label, so a mod-added kind surfaces as DecodedProperty::Unknown { property_label: Some("ModLabel"), .. } instead of as a dispatch hole.

Legacy & Ignored Data

Finding TypeExplanation
Superseded Legacy FieldsDirectly supplying static Cost or BodyVariation values is a byproduct of older file versions; these remain inherently unused overhead compared to the physical runtime 2DA evaluation.
Passive Legacy ArtifactsGeneral nodes left over from older tools (like TemplateResRef, Comment, PaletteID, and explicitly UpgradeLevel) are bypassed on load entirely.
Cross-Format Dead FieldsThe container item loader also reads Repos_PosX/Repos_Posy per contained item, same as the store side documented on the UTM page, and discards the result immediately; no writer for either field turned up anywhere in the item or container save code either.

Implemented Linter Rules (Rakata-Lint)

Phase 1 (intra-resource, no context)

Implemented under rakata_lint::rules::uti.

  1. UTI-001 (Model Truncation Safety): Warns when ModelVariation == 0; the engine forces this to 1 at runtime.
  2. UTI-002 (Dead Cost Fields): Informs when Cost is set; the engine ignores this and computes item cost dynamically.
  3. UTI-003 (Dead Body Overrides): Informs when BodyVariation is set; the engine queries baseitems.2da instead.
  4. UTI-004 (Toolset-Only Fields): Informs when any of TemplateResRef, Comment, PaletteID, or UpgradeLevel are set; never read by the K1 engine.
  5. UTI-005 (Conditional TextureVar): Informs when TextureVar is set; only evaluated if the base item’s 2DA model_type is exactly 1.

Phase 2 (range / 2DA, requires LintContext)

Implemented under rakata_lint::rules::uti_range.

  1. UTI-006 (Base Item Bounds): Errors when BaseItem does not resolve to a row in baseitems.2da (or is negative); the engine indexes the table directly to look up model type, equip slot, and weapon class – an invalid id either crashes the load or produces a corrupt item.
  2. UTI-007 (Valid Capability Bounds): Errors per PropertiesList entry when PropertyName does not resolve to a row in itempropdef.2da, or when Subtype does not resolve to a row in the per-property iprp_*.2da named by itempropdef[PropertyName].SubTypeResRef (skipped when the row has no SubTypeResRef, i.e. the property kind has no subtype dimension). UpgradeType and UsesPerDay use the engine’s 0xFF “not set” sentinel; both the absent-field and explicit-0xFF forms decode as “not set” and the rule does not flag either.

Pending

  • Resref Existence: UTI’s only ResRef field is the toolset-only TemplateResRef (never read by the engine), so the per-format resref-existence rule from B6 was deliberately omitted.

UTM Format (Merchant Blueprint)

Description: The Merchant (.utm) blueprint natively handles the interactive storefront data for merchants and shops. Because shops strictly behave as container interfaces that dynamically buy, sell, and map economic value onto spawned .uti items, the structure of a .utm is highly compact, primarily consisting of economic markups and inventory sorting parameters.

At a Glance

PropertyValue
Extension(s).utm
Magic SignatureUTM / V3.2
TypeMerchant Blueprint
Rust ReferenceView rakata_generics::Utm in Rustdocs

Data Model Structure

Rakata maps a Merchant into the rakata_generics::Utm struct. The struct’s Rustdocs document every field’s binary schema and GFF mapping; the table below is the high-level anatomy.

CategoryCoversRepresentative fields
Core IdentityThe shop’s name and tagTag, LocName
Economic MetricsPrice scaling when buying or selling, plus basic shop rulesMarkUp, MarkDown, BuySellFlag
Store InventoryThe items in stock, including rules for infinite restockingItemList

rakata-lint validates these fields against the engine constraints documented below.

Engine Audits & Decompilation

Because .utm evaluating is structurally straightforward, the engine bypasses heavy memory allocations and maps fields in an incredibly fast iteration.

(Decompilation logic for this section was entirely audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from CSWSStore::LoadStore at 0x005c7180.)

Structural Load Phasing

FunctionSizeBehavior
LoadStore1341 BThe primary parser that pulls the merchant’s basic identity, economic constraints (MarkUp/MarkDown), and buying capabilities.
ItemList ReadIterates through the list of store stock, actively pulling either explicitly saved item instances or generating them freshly from templates (InventoryRes).
AddItemToInventoryPushes the fully sorted loot stack into the physical storefront container so the player can actually interact with and purchase them.

Core Structural Findings

Engine RuleRuntime Behavior
Cost SortingWhen building the store inventory, the engine actively sorts the merchant’s final stock from cheapest to most expensive by checking the cost of each item. This completely overrides whatever custom display order you try to dictate statically.
Dynamic EconomicsThe engine relies entirely on the MarkUp and MarkDown integers to control shop prices. These act as simple percentages that mathematically bump or slash the base cost of every item the merchant sells or buys.
Buy/Sell Bit FlagsBuySellFlag is split into basic toggles: bit 0 controls whether you are allowed to sell your gear to the merchant, and bit 1 controls whether the merchant will actually sell anything to you.
BuySellFlag FallbackUnlike most of the merchant’s fields, BuySellFlag falls back to whatever value the store already holds when the field is missing, rather than resetting to a fixed literal. A freshly constructed store (never loaded from any file) starts at 3 – buy and sell both allowed.
Infinite StackingIf an item is flagged as Infinite, the engine specifically locks that item in memory so that no matter how many times a player buys it, the shop never physically runs out of stock.
Save vs. Template InventoryOn the savegame load path, every ItemList entry is a fully self-contained item snapshot – the item’s complete field set, not a reference. InventoryRes is only consulted on the template/blueprint load path, where it names a .uti blueprint the engine expands via CSWSItem::LoadFromTemplate; that resref is never written back into a save.
Remaining Absent-Field DefaultsTag and LocName are unconditional literal stamps (empty string, empty localized string) with no presence check consulted afterward. MarkUp and MarkDown are unconditional literal 0 – a missing markup/markdown is price-neutral, not an error state. OnOpenStore is an unconditional empty-resref default, the standard “no script” sentinel. Comment and ID are dead: neither field-name string exists anywhere in swkotor.exe, so LoadStore cannot read either under any circumstance – ID is dead more thoroughly than the earlier “deprecated” framing suggested, and Comment doesn’t even have the Repos_PosX/Repos_PosY-style “read but ignored” fate below, it’s simply never looked up.
Infinite vs. Dropable: One Belongs to the Store Entry, One Doesn’tInfinite is read directly off each ItemList entry with an unconditional literal 0 default, setting a bit the store loader owns exclusively – the underlying .uti item has no competing say in it. Dropable is different: LoadStore’s own ItemList loop never reads it at all. It’s entirely inherited from the item load chain instead. A freshly constructed item defaults droppable to true, but CSWSItem::LoadDataFromGff always re-reads Dropable with an unconditional literal 0 default, overwriting that true regardless of source. For inventory entries that resolve a linked .uti (via EquippedRes/InventoryRes), there’s a second, narrower read directly off the store entry afterward that CAN override the item’s own value – but only if the store entry supplies it; if absent, whatever the item’s own load already set (already false by that point, not the original constructor default) stands. On the pure template-store path, the ItemList entry is never asked for Dropable at all – it comes solely from the linked .uti.

Legacy & Ignored Data

Finding TypeExplanation
Legacy Interface ConfigurationsSome older tools expose positional values like Repos_PosX or Repos_PosY inherited from other Odyssey games, but the engine completely ignores them. The game physically builds its shop UI dynamically when you open it, rendering those grid coordinates totally useless.

Implemented Linter Rules (Rakata-Lint)

Phase 1 (intra-resource, no context)

Implemented under rakata_lint::rules::utm.

  1. UTM-001 (Legacy Grid Coordinates): Informs when inventory items contain non-zero Repos_PosX or Repos_PosY; the engine builds its shop UI dynamically and ignores these coordinates.
  2. UTM-002 (Unknown Buy/Sell Flags): Warns when BuySellFlag has bits set outside the canonical buy (bit 0) and sell (bit 1) toggles.
  3. UTM-003 (Legacy Store UI Fallback): Warns when BuySellFlag == 0 (missing or empty); the engine falls back to legacy UI behaviors and forcefully clamps MarkUp to 100.

Phase 2 (resource existence, requires LintContext)

Implemented under rakata_lint::rules::utm_range.

  1. UTM-004 (Resref Existence): Warns when OnOpenStore (.ncs) or any ItemList[i].InventoryRes (.uti) does not resolve in the configured resource sources. The toolset-only top-level ResRef (merchant template) is intentionally skipped – it is never read by the engine.

UTP Format (Placeable Blueprint)

Description: The Placeable (.utp) blueprint dictates the configuration of universally interactive scenery and containers within a map. Ranging from simple locked footlockers to rigged command consoles and explodable starship barricades, .utp structs blend physical static properties (like structural HP and lock difficulties) with heavy dynamic script bindings.

At a Glance

PropertyValue
Extension(s).utp
Magic SignatureUTP / V3.2
TypePlaceable Blueprint
Rust ReferenceView rakata_generics::Utp in Rustdocs

Data Model Structure

Rakata maps a Placeable into the rakata_generics::Utp struct. The struct’s Rustdocs document every field’s binary schema and GFF mapping; the table below is the high-level anatomy.

CategoryCoversRepresentative fields
Core Identity & GeometryWhat the placeable looks like, its faction, and the text displayed when targetedAppearance, TemplateResRef, LocName
Interactive State & DialogueWhether the placeable can be clicked, starts a conversation or computer sequence, or acts as a loot containerUseable, Conversation, HasInventory
Lock & Trap MechanicsWhether it is locked, which key opens it, and the rules for attached trapsLocked, KeyName, TrapType, DisarmDC
Health & DestructionWhether the object can be destroyed and its defensive thresholdsHP, Hardness, Static, Plot
Behavioral HooksThe scripts that run when a player explores, attacks, or opens the placeableOnOpen, OnInvDisturbed, OnDamaged

rakata-lint validates these fields against the engine constraints documented below.

Engine Audits & Decompilation

(Decompilation logic for this section was entirely audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from CSWSPlaceable::LoadPlaceable at 0x00585670.)

Because Placeables act as physical junctions for event hooking, they expose a massive suite of script triggers natively.

Structural Load Phasing

FunctionSizeBehavior
LoadPlaceable5092 BThe primary physical parser evaluating 46 core metrics including health, conversation dialogues, basic trap bindings, and physical alignment states. All 16 script hooks are read directly inside this same function too, as hand-unrolled, byte-for-byte identical boilerplate – there’s no separate script-reading function for placeables. ReadScriptsFromGff (documented on other pages) is not it: its only callers are creature-related loaders, never LoadPlaceable.

Core Structural Findings

Engine RuleRuntime Behavior
Appearance TruncationThe engine reads Appearance as a 32-bit integer but forcefully truncates it to a single byte. Any ID above 255 automatically wraps to 0 and physically breaks the placeable model rendering.
Static vs. Plot ChainingJust like Doors, if a Placeable is marked Static=1, the engine completely overrides all other behaviors and acts as if Plot=1 is true, making the placeable totally indestructible even if it has an HP value defined.
Default Usability CheckIf the Static toggle is completely missing from the binary file, the engine automatically derives it by actively checking if the Placeable is marked as usable (!Useable).
Portrait ShadowingIf PortraitId is < 0xFFFE, the engine completely ignores the Portrait string ResRef and relies entirely on the ID. Any value in the Portrait ResRef field is treated as dead data.
Ground Pile ForcingThe engine reads whatever value you place in GroundPile, but the read result is discarded entirely and the field is unconditionally forced to 1 in memory. It’s a pure dead read; native configuration of this field is decorative.
Missing Door HooksToolsets erroneously expose OnFailToOpen for Placeables, but the engine specifically treats this as a Door-exclusive (.utd) script hook and completely ignores it here.
Trap Hook FallbackIf a trap bounds check fails or the OnTrapTriggered script is left blank, the engine automatically attempts to read the traps.2da table and pulls the default script based on the specific TrapType.
Corpse Exclusion Lives at the Area LevelA placeable’s own save routine never skips itself, no matter its corpse state. The “skip corpses” behaviour actually lives one level up, in the area’s own placeable-list saver, which omits the entire list entry for any placeable flagged as a corpse before the placeable’s save routine is ever invoked. Doors have no equivalent skip: every tracked door is always written to the save.
Empty Inventory OmissionItemList is only added to the written struct when the placeable’s item repository holds at least one item. An empty inventory produces no ItemList field at all, not even an empty one.

HasInventory and DieWhenEmpty Are Cross-Wired

The GFF field labelled HasInventory is read (BYTE, default 0, unconditional) into an internal member that nothing else in LoadPlaceable reads or acts on meaningfully. The member the engine actually treats as “has inventory” is instead populated from the DieWhenEmpty GFF field. Both individual reads are ordinary – BYTE, unconditional literal 0 default – the surprise is entirely in the label-to-member wiring, which looks like an artifact of a field being renamed on the engine side at some point without the GFF label following. Practical consequence: writing HasInventory on a .utp has no observable effect on whether the engine treats the placeable as carrying inventory; that behavior is actually driven by whatever DieWhenEmpty resolves to.

HP, CurrentHP, and the Trap Flags: Absence Silently Un-Arms a “Fresh” Placeable

Five fields diverge sharply between what a freshly constructed placeable already holds and what the loader stamps when the GFF omits them – worth calling out together, since all five follow the identical trap: the constructor pre-arms a meaningful nonzero value, but the read’s own fallback is a hardcoded literal 0 that ignores it entirely.

  • HP (maximum) and CurrentHP: the constructor sets both to 1 (a placeable nominally starts alive). The reads for both pass a hardcoded 0, unconditional – an absent field leaves a “fresh” placeable at 0 max and 0 current HP, not the 1/1 the constructor set up.
  • TrapDetectable, TrapDisarmable, TrapOneShot: the constructor pre-arms all three to 1 (armed/on). Same hardcoded-0 fallback, unconditional, for all three – an absent trap flag set produces a “dead,” non-functional trap rather than the constructor’s default-armed one.

None of these five reference the object’s current value at all; each is a plain unconditional-literal-0 store that happens to silently overwrite a deliberately nonzero constructed default. A hand-authored or legacy .utp missing these fields loads noticeably weaker than a freshly-built placeable would suggest.

Plot and Invulnerable Share One Member, and Presence Alone Decides Which Wins

CSWSPlaceable has no member called invulnerable at all – both the Invulnerable and Plot GFF labels feed the same underlying plot flag, and which one actually gets consulted is a presence check, not a value comparison. The loader always tries Invulnerable first (fallback: the object’s current plot value, 0 from construction). If Invulnerable is present in the file at all, Plot is never read – not defaulted, skipped outright. Only when Invulnerable is absent does the loader go on to read Plot, using the same carried-over fallback. Either way, the result then feeds the already-documented Static-forces-Plot=1 override. So Plot’s own absent-field default (with Invulnerable also absent, Static not forcing it) is a carry-over of 0 – but the more important finding is that Invulnerable, while undocumented on this page until now and never written by the vanilla toolset, is a real, live field the engine reads and lets pre-empt Plot entirely if a file supplies it.

LightState Is Derived From Appearance Through a 2DA Lookup, Not a Raw Carry-Over

The constructor sets a light-state member to 1 and a separate “is the light actually on” flag to 0. LightState’s own read doesn’t fall back to either directly – its default is the return value of a helper that looks up the placeable’s already-resolved (and already-truncated) Appearance in placeables.2da’s LightColor column. If that column has no entry for the appearance, the default resolves to 0 (light off) regardless of the constructed value; only if the column entry exists does the default become the constructed 1 (light on). This is a genuine sibling-derived default, and unusually, the sibling is consulted through 2DA table data rather than another GFF field directly.

Open, Animation, and AnimationState Form a Fully Gated Chain

Open itself is an ordinary unconditional-literal-0 field. Its resolved value then drives a chain worth documenting field by field, since it directly substantiates the “Animation Conditional Limits” pending rule below:

  • If Open resolves non-zero: Animation and AnimationState are never read at all. The engine unconditionally applies a fixed sentinel, 10075, as the placeable’s animation state.
  • If Open resolves to 0: the engine reads Animation (INT, default 0). If present, its raw value is applied directly as the animation id, no validation.
  • If Animation is absent, the engine falls through to AnimationState (BYTE, default 0). If AnimationState is also absent, the animation-setting call is skipped entirely for this placeable – a presence-chain abort scoped to this one derived step, not the whole struct. If AnimationState is present, it indexes into six preset animation-id sentinels; any value greater than 5 collapses to a different fallback sentinel, 10000.

Remaining Absent-Field Defaults

The rest of LoadPlaceable’s fields are unremarkable unconditional literals, no divergence between the read’s fallback and the constructed value: Tag and KeyName to an empty string, LocName/Description to an empty localized string, Conversation to an empty resref, Faction/AutoRemoveKey/KeyRequired/Lockable/Locked/OpenLockDC/CloseLockDC/Hardness/Fort/Will/Ref/PartyInteract/TrapDetectDC/DisarmDC/TrapFlag/Useable to 0. Min1HP, BodyBag, IsBodyBag, and IsCorpse carry over the object’s own constructed value (all 0/false) rather than using a fresh literal – mechanically distinct from the group above, same observable outcome. TrapType also carries over, and the constructed value there is the sentinel 0xFF (255), the same sentinel already documented for doors and triggers, feeding the same traps.2da fallback lookup.

The 15 remaining script hooks (OnClosed, OnDamaged, OnDeath, OnDisarm, OnHeartbeat, OnInvDisturbed, OnLock, OnMeleeAttacked, OnOpen, OnSpellCastAt, OnUnlock, OnUsed, OnUserDefined, OnDialog, OnEndDialogue) default to an empty resref, unconditional, no exceptions across all 15. This is worth contrasting directly with doors and triggers: UTD’s and UTT’s script hooks all carry over a constructor-seeded literal string "default" on absence, but placeables have no such seeding – their constructor never pre-arms the script slots to anything but empty, so an absent placeable script hook is genuinely empty, not the string "default". (OnTrapTriggered and OnFailToOpen are documented separately above.)

Legacy & Ignored Data

Finding TypeExplanation
Legacy Engine ArtifactsPlaceable binaries are littered with legacy metrics from older tools or other Odyssey games (Comment, OpenLockDiff, Interruptable, Type, PaletteID). The physical KOTOR engine constructor entirely ignores these. OpenLockDiff, OpenLockDiffMod, and NotBlastable are confirmed dead the same decisive way as IsComputer below – none of the three field-name strings exist anywhere in swkotor.exe, so no code path can read them at all, not merely “read and ignored.”
IsComputer Doesn’t Exist in the BinaryIsComputer appears in a handful of .utp files, always 0. Its field-name string doesn’t exist anywhere in swkotor.exe, so no code path can read it – confirmed dead the same way as several unmodeled DLG fields, not merely “always zero in the sample.”

Implemented Linter Rules (Rakata-Lint)

Phase 1 (intra-resource, no context)

Implemented under rakata_lint::rules::utp.

  1. UTP-001 (Plot Chaining Context): Warns when Static=true but Plot=false; the engine forces Plot to true at runtime.
  2. UTP-002 (Ghost Value Detection): Informs when GroundPile=false since the engine immediately overwrites this to true on load.
  3. UTP-003 (Dead Hook Pruning): Flags OnFailToOpen instances because placeables ignore this event hook (it is door-exclusive).
  4. UTP-004 (HP Health Ceiling): Errors when CurrentHP > HP; the engine clamps to HP on template load.
  5. UTP-005 (Portrait Shadowing): Warns when PortraitId < 0xFFFE and Portrait resref is set; the resref is ignored at runtime.

Phase 2 (range / 2DA / resref existence, requires LintContext)

Implemented under rakata_lint::rules::utp_range.

  1. UTP-006 (Appearance Bounds): Errors when Appearance does not resolve to a row in placeables.2da; engine renders missing model.
  2. UTP-007 (Portrait Bounds): Errors when PortraitId (when not the 0xFFFE “use string Portrait” sentinel) does not resolve to a row in portraits.2da.
  3. UTP-008 (Resref Existence): Warns when Conversation (.dlg), Portrait (.tga), any of the 16 On* script hooks (.ncs), or ItemList[i].InventoryRes (.uti) does not resolve in the configured resource sources. OnFailToOpen is intentionally NOT included – UTP-003 already flags it as door-exclusive dead data.

Pending

  • Appearance Truncation: Warns when Appearance exceeds 255 (engine truncates to a single byte before lookup, distinct from the row-count check in UTP-006).
  • Animation Conditional Limits: Verifies that custom AnimationState indices are strictly guarded by Open==0 closures.

UTS Format (Sound Object Blueprint)

Description: The Sound Object (.uts) blueprint defines dynamic, positional, and ambient audio emitters placed throughout a game map. Ranging from environmental hums and randomized crowd chatter to highly localized looping sound effects, .uts files act as physical sound nodes combining strict spatial coordinates with randomized pitch, interval, and varying volume matrices.

At a Glance

PropertyValue
Extension(s).uts
Magic SignatureUTS / V3.2
TypeSound Object Blueprint
Rust ReferenceView rakata_generics::Uts in Rustdocs

Data Model Structure

Rakata maps a Sound Object into the rakata_generics::Uts struct. The struct’s Rustdocs document every field’s binary schema and GFF mapping; the table below is the high-level anatomy.

CategoryCoversRepresentative fields
Audio EmittersThe .wav clips the engine sequences or shuffles throughSounds
Spatial GeometryThe distance boundaries that decide where the sound is audibleMinDistance, MaxDistance
Playback AutomationHow the sound loops and strings togetherContinuous, Random, Active, Looping
Algorithmic VariationRuntime distortion of pitch and volumePitchVariation, FixedVariance, VolumeVrtn
Procedural GeneratorsMarks the sound as engine-generated ambiance such as crowd chatter or combat noiseGeneratedType

rakata-lint validates these fields against the engine constraints documented below.

Engine Audits & Decompilation

(Decompilation logic for this section was entirely audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from CSWSSoundObject::Load at 0x005c9040.)

Sound Objects represent one of the most streamlined parsers in the engine. They completely lack script triggers and rely almost entirely on mathematically calculating randomized positional matrices and variations natively.

Structural Load Phasing

FunctionSizeBehavior
Load1345 BThe primary physical parser evaluating 24 core audio metric bounds, defining spatial positioning, volume variation, pitch scales, and active looping capabilities.
Sounds ListIterates through the list of associated audio clips, actively loading sound resrefs into memory sequentially for playback.

Core Structural Findings

Engine RuleRuntime Behavior
Generated Type TruncationThe engine reads GeneratedType as a massive 32-bit integer from the file, but forcefully truncates it and stores only the bottom single byte in memory. Setting this number astronomically high physically corrupts the expected generator type.
Constructor DefaultsIf fields are missing from the .uts binary, the engine physically relies on its internal C++ constructor to populate default values, completely avoiding hardcoded literal checks during parse time. Nearly every playback-tuning scalar (Active, Positional, Looping, Volume, VolumeVrtn, Times, PitchVariation, Hours, GeneratedType, Interval, IntervalVrtn, MinDistance, MaxDistance, Continuous, Random, FixedVariance, RandomPosition, RandomRangeX, RandomRangeY) actually falls back to whatever value the object already holds, not a fixed literal – a freshly constructed sound object starts at Active=1, Positional=1, Looping=0, Volume=127, VolumeVrtn=0, Times=3, PitchVariation=0.0, Hours=0, GeneratedType=0, Interval=0, IntervalVrtn=0, MinDistance=10.0, MaxDistance=20.0, Continuous=0, Random=0, FixedVariance=1.0, RandomPosition=0, RandomRangeX=0.0, RandomRangeY=0.0. Tag follows the identical mechanism: the read’s own fallback argument is the object’s current tag, which starts as an empty string on a freshly constructed sound object, and the result is unconditionally re-applied through SetTag either way.
Position Defaults to the OriginXPosition/YPosition/ZPosition are the one trio in Load that falls back to a fixed literal (0.0 each) instead of the constructor’s carried-over value, and the result is applied through SetPosition unconditionally regardless of source. This is the read that “Spatial Loading Context” below refers to: for a sound placed via the area’s .git layout, the .git instance’s own position values win in practice, so a .uts blueprint’s 0.0 fallback only surfaces for a sound opened outside that placement path.
Spatial Loading ContextWhen loaded globally via a static map (CSWSArea::LoadSounds), the engine skips reading positional coordinates from the .uts file entirely and strictly enforces the XPosition / YPosition / ZPosition coordinates defined in the area’s .git file. A sound instance carries no orientation; it uses Positional / RandomPosition flags plus RandomRangeX / RandomRangeY for placement.
Silent Sound ListsWhen pulling the list of sounds, the engine actively ignores missing entries. It only pushes a sound struct into playable memory if the file actually provided a valid Sound reference string.
Return-Value FragilityThe loader’s return value doubles as the found-flag of whichever field it happened to read last: either the final Sound resref in the Sounds list, or the object’s ZPosition if the list was absent or empty. The area-level save loader deletes the sound object outright if that flag comes back false. A vanilla sound object always carries a ZPosition, so this never bites real saves, but a hand-authored .git sound entry lacking both a populated Sounds list and a ZPosition would be silently dropped on load.

Legacy & Ignored Data

Finding TypeExplanation
Legacy Engine ArtifactsSome older tools and legacy file revisions include values like TemplateResRef, LocName, Comment, Elevation, Priority, and PaletteID. These are artifacts from other Odyssey Engine branches (like Neverwinter Nights) and the KOTOR engine never evaluates them natively.

Implemented Linter Rules (Rakata-Lint)

Phase 1 (intra-resource, no context)

Implemented under rakata_lint::rules::uts.

  1. UTS-001 (Volume Ceiling): Warns when Volume > 127; values outside the engine’s byte threshold cause distortion or clipping.
  2. UTS-002 (Audio Integrity): Warns when the Sounds list contains blank entries; the engine skips them silently.
  3. UTS-003 (Emitter Verification): Errors when the Sounds list is empty; the object loads as a dead audio node.
  4. UTS-004 (GeneratedType Truncation): Errors when GeneratedType > 255; the engine truncates to a single byte and corrupts intended behavior.
  5. UTS-005 (Legacy Engine Artifacts): Informs when TemplateResRef, Elevation, Priority, or PaletteID are populated; never natively evaluated by the K1 engine.

Phase 2 (resource existence, requires LintContext)

Implemented under rakata_lint::rules::uts_range.

  1. UTS-006 (Sound Resref Existence): Warns when any non-blank Sounds[i].Sound does not resolve to a .wav resource in the configured sources. Blank entries are skipped (UTS-002 already covers them).

UTT Format (Trigger Blueprint)

Description: The Trigger (.utt) blueprint defines invisible zones placed across level maps. While encounters spawn creatures, triggers operate as tripwires – firing scripts, acting as loading zones to new areas, or springing mechanical traps when a character crosses them.

At a Glance

PropertyValue
Extension(s).utt
Magic SignatureUTT / V3.2
TypeTrigger Blueprint
Rust ReferenceView rakata_generics::Utt in Rustdocs

Data Model Structure

Rakata maps a Trigger into the rakata_generics::Utt struct. The struct’s Rustdocs document every field’s binary schema and GFF mapping; the table below is the high-level anatomy.

CategoryCoversRepresentative fields
Core Identity & GeometryWhat the trigger is and where it sits on the groundTag, Geometry
Interactive State & Sub-typesWhether the trigger acts as a loading zone, a trap, or a generic scripting boundaryType, Cursor, HighlightHeight
Trap MechanicsTrap visibility and the skill checks required to disarmTrapType, TrapOneShot
Transition & Behavioral HooksThe event scripts that fire on enter, click, leave, or disarm, plus the destination area when the trigger is a loading zoneScriptOnEnter, LinkedTo

rakata-lint validates these fields against the engine constraints documented below.

Engine Audits & Decompilation

(Decompilation logic for this section was entirely audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from CSWSTrigger::LoadTrigger at 0x0058da80.)

Structural Load Phasing

FunctionSizeBehavior
LoadTrigger3381 BThe main constructor. It reads the trigger’s properties, scripts, and trap rules.
LoadTriggerGeometry743 BReads the PointX / PointY / PointZ vertices that draw the trigger’s boundary on the floor. The vertices are stored relative to the trigger position (each PointX is vertex.x - XPosition), so world-space geometry is recovered by adding the trigger position back.
LoadFromTemplate (0x0058ee06)The blueprint-loading entry point: opens the .utt resource named by the placed instance’s TemplateResRef and calls LoadTrigger against the blueprint’s own top-level struct.
CSWSArea::LoadTriggers (0x0050a350)The area-level dispatcher that decides, per placed trigger, whether to call LoadTrigger directly on the .git instance struct (no template) or LoadFromTemplate (templated) – and, in the templated case, re-applies several instance-only fields afterward. See “Geometry and the Instance Overlay” below.

Core Structural Findings

Engine RuleRuntime Behavior
Behavior Derived from TypeThe engine determines the trigger’s behavior and UI cursor based on the Type field. Type 1 makes it a map transition zone. Type 2 makes it a trap.
OnClick Duplication BugThe engine has a known bug where it copies the ScriptOnEnter value and uses it to overwrite the OnClick listener by default, unless explicitly overridden. The copy happens after ScriptOnEnter has already resolved its own absent-value carry-over, so OnClick’s fallback is whatever ScriptOnEnter ended up as (present value or ScriptOnEnter’s own carried-over default, see below) – not the raw constructed value directly.
Trap Hook FallbackIf the OnTrapTriggered script is left empty, set to null, or named "default", the engine ignores it and pulls the default script from traps.2da based on the TrapType. TrapType’s own absent default is the sentinel 0xFF (255, see the trap-flag row above); a trigger missing both TrapType and OnTrapTriggered looks up row 255 of traps.2da, which almost certainly doesn’t exist – an out-of-range lookup, not a clean “no trap” fallback.
Highlight ClampingThe trigger’s HighlightHeight is ignored by the engine unless it is greater than 0.0. If it is exactly zero or negative, the engine falls back to a default rendering height of 0.1.
Orientation Drives GeometryWhen a trigger instance supplies an orientation, the engine re-rotates its geometry vertices about the trigger position by the yaw difference between the geometry’s prior frame and the new orientation (new_vertex = new_pos + R(yaw_new) * inverse(R(yaw_old)) * (vertex - old_pos)). Only the yaw is used; pitch and roll are forced to zero, and the orientation vector is normalized if not unit length. If the instance also carries an explicit Geometry list, that list is applied directly instead. Net: a trigger’s shape is position + yaw + position-relative geometry, and rewriting orientation without re-baking geometry desyncs the two.
Geometry Is Presence-Gated, Not Context-GatedUnlike LinkedTo/Tag/Faction below, the Geometry read inside LoadTrigger is gated only on whether the struct it was handed contains a Geometry list at all – there is no check for whether that struct came from a .utt blueprint or a .git instance. A hand-authored .utt blueprint that carried a Geometry field would have it read on the ordinary template-load path, the same way a placed instance’s would. See “Geometry and the Instance Overlay” below for how a placed instance’s own geometry, when present, ends up taking precedence anyway.
Contextual LoadingFields like LinkedTo, LinkedToModule, AutoRemoveKey, Tag, and Faction are only loaded into memory when the Trigger is processed from a .git area layout file.
Portrait ShadowingIf PortraitId is < 0xFFFE, the engine completely ignores the Portrait string ResRef and relies entirely on the ID. Any value in the Portrait ResRef field is treated as dead data.
Trap Flag Fresh-Object AsymmetryTrapDisarmable and TrapDetectable both default to 1 (disarmable, detectable) on a freshly constructed trigger, but the GFF reader’s own missing-field fallback for each is a hardcoded literal 0, ignoring the constructed value entirely. A hand-edited or third-party file that simply omits these fields loads as non-disarmable and non-detectable, the opposite of what a “fresh” trigger would suggest. TrapOneShot doesn’t share this asymmetry: its read genuinely carries over the object’s current value, and the constructor sets that value to 1 (true) before the read runs, so an absent TrapOneShot correctly resolves to 1 – the odd one out is TrapDisarmable/TrapDetectable’s literal-0 override, not a general rule about trap flags. TrapType also carries over rather than using a literal, and the constructor’s value there is the sentinel 0xFF (255), which feeds directly into the Trap Hook Fallback row below.
Presence-Gated Position and OrientationSetPosition and SetOrientation are only actually applied if the corresponding position and orientation fields were present in the file at all. Every vanilla writer always emits both, so this only matters for hand-edited files that omit them entirely – the trigger then keeps whatever position or orientation it already held instead of resetting to the origin.
Found-Flag Preserves Prior ValueLinkedTo, LinkedToModule, AutoRemoveKey, Tag, and Faction each fall back to a genuine constant (empty string or 0) while reading, but that constant is only used to populate the read itself. If the field is absent from the file, nothing is committed to the trigger at all, so it keeps whatever value it already held rather than being reset to the constant. LinkedToFlags does not follow this pattern, despite sitting in the same “instance overlay” family documented below – it’s read with a hardcoded literal 0 default and stamped unconditionally, with no found-flag gate at all. Confirmed as a deliberate, consistent choice rather than an oversight: UTD’s LoadDoor reads its own LinkedToFlags the exact same unconditional-literal way.

Remaining Absent-Field Defaults

All 7 script slots default to the literal string "default", not an empty resref – shared with UTD. OnDisarm, ScriptHeartbeat, ScriptOnEnter, ScriptOnExit, ScriptUserDefine, OnTrapTriggered, and OnClick are each read as a carry-over of the object’s own current script-slot value, and the CSWSTrigger constructor pre-arms every one of those slots to the literal string "default", not empty. This is the identical mechanism already confirmed for doors – the OnTrapTriggered fallback rule above (“empty, null, or literally "default"”) exists because an absent field naturally becomes "default" through this carry-over, not because the engine independently special-cases that spelling. The other six hooks have no equivalent secondary lookup: an absent one simply keeps the literal resref "default", which won’t resolve to a real script unless a module happens to ship one named exactly that.

TransitionDestin reads unconditionally regardless of source struct, matching UTD’s mechanism exactly. The field carries over the object’s own current value (an empty localized string from construction) with no found-flag gate at all – confirmed at the mechanism level, not just the observed outcome, to be the identical pattern already documented for UTD’s LoadDoor. Whatever “instance wins” behavior a templated trigger shows for this field comes entirely from CSWSArea::LoadTriggers’s own overlay step (see below), which re-reads it unconditionally off the .git struct after the template load – there’s no found-flag check at that overlay site either.

PortraitId defaults to the sentinel 0xFFFF, and Portrait is read twice. PortraitId is an unconditional literal 0xFFFF on absence, which lands squarely in the already-documented >= 0xFFFE “use string Portrait” range – so an absent PortraitId behaves identically to an explicit 0xFFFE. Portrait itself defaults to an empty resref, unconditional – but LoadTrigger reads it twice: once conditionally (only when PortraitId resolved >= 0xFFFE), and again unconditionally right after the Cursor read. The second call always runs and is what actually determines the final value, so the practical absent-field answer is simply an empty resref regardless of what PortraitId did. This double-read is specific to LoadTriggerUTD’s LoadDoor reads Portrait only once.

The remaining fields are unremarkable unconditional literals: LocalizedName to an empty localized string, KeyName to an empty string, Cursor and Type to 0 (an absent Type satisfies neither the Transition nor Trap branch, so the Cursor override those branches can apply never fires either), SetByPlayerParty to 0, and LoadScreenID to 0.

Geometry and the Instance Overlay

Geometry’s vanilla absence from every .utt blueprint in a full install is an authoring-tool habit, not an engine restriction. CSWSArea::LoadTriggers (0x0050a350), the area-level dispatcher that walks a .git area’s placed triggers, is where the real fork happens: for a template-backed trigger it calls LoadFromTemplate (0x0058ee06), which opens the referenced .utt and calls LoadTrigger against the blueprint’s own top-level struct – exactly the same call LoadTrigger receives for a non-templated, fully-inline .git trigger. Nothing inside LoadTrigger itself treats a blueprint struct differently from an instance struct when it comes to Geometry.

What actually produces the documented “instance geometry wins” behavior is a second, explicit step: after LoadFromTemplate returns, LoadTriggers re-reads LinkedToModule, TransitionDestin (see UTD’s equivalent field for the same truncated-label pattern), LinkedTo, LinkedToFlags, position, and Geometry a second time, straight off the .git instance struct, and calls LoadTriggerGeometry directly a second time if the instance struct supplies its own Geometry list. So a template-backed trigger’s final geometry is an overlay, not a rejection: the blueprint’s geometry is read and would take effect if nothing overrode it, but a placed instance’s own Geometry – when present – is applied afterward and wins.

Legacy & Ignored Data

Finding TypeExplanation
Legacy Engine ArtifactsAs with other templates, older asset revisions include TemplateResRef, Comment, PaletteID, and PartyRequired. The engine completely ignores these.
Superseded Legacy FieldsOlder asset revisions typically map TrapDetectDC and DisarmDC in the .utt file itself, but the engine ignores them – it calculates DCs dynamically using the rules in the .2da files instead.

Implemented Linter Rules (Rakata-Lint)

Phase 1 (intra-resource, no context)

Implemented under rakata_lint::rules::utt.

  1. UTT-001 (Transition Enforcement): Warns when Type==1 (Transition) but no destination (LinkedTo, LinkedToModule, or TransitionDestin) is configured.
  2. UTT-002 (Trap Consistency): Informs when TrapDetectDC/DisarmDC are set (engine reads from traps.2da); also warns when TrapFlag=true but Type != 2.
  3. UTT-003 (Geometry Safety): Warns when the trigger’s geometry contains fewer than 3 vertices.
  4. UTT-004 (OnClick on Generic Trigger): Informs when OnClick is set on a Generic trigger (Type==0); the event only fires for Transition triggers.
  5. UTT-005 (Highlight Bounding): Informs when HighlightHeight <= 0.0; the engine falls back to a default of 0.1.
  6. UTT-006 (Portrait Shadowing): Warns when PortraitId < 0xFFFE and Portrait resref is set; the resref is ignored at runtime.
  7. UTT-007 (PartyRequired Dead Data): Informs when PartyRequired is set; the K1 engine never reads this field.

Phase 2 (range / 2DA / resref existence, requires LintContext)

Implemented under rakata_lint::rules::utt_range.

  1. UTT-008 (Portrait Bounds): Errors when PortraitId (when not the 0xFFFE “use string Portrait” sentinel) does not resolve to a row in portraits.2da.
  2. UTT-009 (Resref Existence): Warns when any of OnDisarm, OnTrapTriggered, OnClick, OnHeartbeat, OnEnter, OnExit, or OnUserDefined (.ncs), or Portrait (.tga), does not resolve in the configured resource sources. LinkedToModule (area transition) is deferred to Phase 3.

Pending

  • Default Script Identification: Identifies empty / null / literally-named "default" OnTrapTriggered entries that silently invoke the traps.2da fallback.

UTW Format (Waypoint Blueprint)

Description: The Waypoint (.utw) blueprint defines static reference coordinates within an area map. Unlike functional triggers or physical placeables, waypoints act exclusively as invisible logic markers. They provide coordinate anchors for creature patrol routes, spawn locations, camera focal points, or visible map pins in the player’s UI.

At a Glance

PropertyValue
Extension(s).utw
Magic SignatureUTW / V3.2
TypeWaypoint Blueprint
Rust ReferenceView rakata_generics::Utw in Rustdocs

Data Model Structure

Rakata maps a Waypoint into the rakata_generics::Utw struct. The struct’s Rustdocs document every field’s binary schema and GFF mapping; the table below is the high-level anatomy.

CategoryCoversRepresentative fields
Core IdentityThe waypoint’s name and the tag that scripts targetTag, LocalizedName
Spatial GeometryThe map coordinates and facing that creatures or cameras referenceXPosition, XOrientation
Map Navigation NotesWhether the waypoint draws a pin on the player’s mini-map, and the pin’s textHasMapNote, MapNote

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 .utw files mapped from swkotor.exe.

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

Structural Load Phasing

FunctionSizeBehavior
LoadWaypoint682 BThe main constructor. It loads the waypoint’s identity, map geometry, and checks for mini-map pins.
LoadFromTemplate (0x005c83b0)134 BA fallback used when dynamically spawning a waypoint from a script. It is a thin wrapper: open the .utw file’s own GFF, fetch its top-level struct, and hand off to LoadWaypoint – there is no separate field-reading logic for the script-spawn path.

Core Structural Findings

Engine RuleRuntime Behavior
Map Note Two-Gate PatternThe skip covers more than the note text. If HasMapNote is 0 or missing, the engine never even attempts the read for MapNoteEnabled or MapNote – there’s no fallback fetch for either, they’re simply not asked for. If HasMapNote resolves to 1, both MapNoteEnabled and MapNote are read (each with its own default if individually absent), but none of the three values – not HasMapNote, not MapNoteEnabled, not MapNote – actually lands on the waypoint unless MapNote itself was genuinely present in the file. Absent MapNote on an otherwise-HasMapNote=1 file discards the whole trio silently, leaving the waypoint at its constructed defaults (HasMapNote=0, MapNoteEnabled=0, MapNote empty) exactly as if none of the three fields had been touched.
Orientation NormalizationThe engine computes the true magnitude of the orientation vector (not just the squared value) and calls Vector::Normalize() whenever it isn’t exactly 1.0. That function has its own epsilon guard: below a magnitude of 1e-9 it doesn’t divide at all – it snaps straight to a sentinel facing of (1.0, 0.0, 0.0). So a waypoint with all three orientation fields absent (a zero vector) doesn’t produce garbage or a divide-by-zero; it lands on that exact sentinel. This is a different code path and a different fallback than the (0, 1, 0) sentinel documented elsewhere in this codebase for area-effect objects’ orientation – the two object types don’t share the normalization call, and their epsilon thresholds and fallback vectors both differ, so don’t assume one from the other.
Position OverrideWhen a waypoint is loaded from a .git area layout via LoadWaypoints, the engine re-reads the X and Y coordinates directly from the .git file, completely overriding the .utw. It also forcefully calculates the Z height based on the terrain collision mesh via ComputeHeight. This is a second read layered on top of the one below, not a replacement for it: LoadWaypoint itself always reads position and orientation first, from whichever struct it’s handed, blueprint or GIT instance alike.
Dynamic IdentificationWaypoints never pull an ObjectId from their own .utw file. It is always forcibly assigned by the .git list element (defaulting to 0x7f000000).
No Template Path for Placed WaypointsThe area’s UseTemplates flag – which switches triggers, stores, and sounds between a save-snapshot read and a template-blueprint read – is accepted by LoadWaypoints but never inspected. Every waypoint placed in a .git layout is always a full inline read of the waypoint struct. Confirmed by decompilation: LoadWaypoint never reads a field named TemplateResRef at all, not even a discarded read – unlike doors (see UTD), a placed waypoint genuinely never resolves a blueprint. LoadFromTemplate only matters for a waypoint a script spawns dynamically at runtime, and even there the resref it opens comes from the script’s own CreateObject() argument (its sole caller is ExecuteCommandCreateObject), not from a TemplateResRef GFF field – that field name is never looked up anywhere in the waypoint-loading code, under any circumstance.
Blueprint Placement Fields Are Real, Not GIT-ExclusiveLoadWaypoint unconditionally reads XPosition, YPosition, ZPosition, XOrientation, YOrientation, and a sixth field rakata does not currently model, ZOrientation – a full 3-component orientation vector, not the 2-component pair the typed struct exposes – and applies all of them via SetPosition/SetOrientation before it reads anything else. Each of the six falls back to a literal 0.0 if individually absent, with no presence check consulted afterward; see “Orientation Normalization” above for what a fully-absent orientation vector resolves to once normalization runs. This runs identically whether the source struct came from a .git instance or a .utw blueprint opened through LoadFromTemplate. A script-spawned waypoint (LoadFromTemplate) has no GIT instance to override it afterward, so a hand-authored .utw carrying these fields would have its placement taken from the blueprint directly and durably, not transiently. Only a waypoint placed in a .git area layout gets the X/Y/Z override described above; vanilla blueprints simply never author the fields because there’s no vanilla workflow that needs a script-spawned waypoint’s own position baked into the template.
Tag and LocalizedName Are Unconditional StampsBoth are read with an empty-value literal default (empty string for Tag, an empty LocalizedString for LocalizedName) and applied unconditionally – LoadWaypoint’s own presence flag for each read is captured and then never inspected. An absent Tag doesn’t leave a prior value in place; it overwrites with a literal empty string every time, the same for LocalizedName.

Legacy & Ignored Data

Finding TypeExplanation
Superseded Legacy FieldsOlder asset revisions pad the file with fields like TemplateResRef, Appearance, PaletteID, Comment, LinkedTo, and Description. The KOTOR engine completely ignores these.
LinkedToModule Shares a Label With UTD’s, Not Its BehaviourA handful of .utw files carry a LinkedToModule CResRef, always empty. LoadWaypoint and LoadFromTemplate were both fully decompiled and read only Tag, LocalizedName, position, orientation, and the map-note fields – nothing resembling an area-transition field. The LinkedToModule string that does exist in the binary is referenced exclusively from Door and Trigger code (LoadDoor, LoadDoorExternal, SaveDoor, LoadTrigger, LoadTriggers, SaveTrigger), never from waypoint code. Waypoints have no area-transition capability in this engine at all; the two fields merely share interned label bytes, not a concept.
A Placed Waypoint’s Appearance and Description Have No Source At AllGit.WaypointList[].Appearance (a BYTE) is present with a real value in every waypoint entry in a full install, and Description shows up in a handful too – but LoadWaypoint’s full field list, confirmed above, has no room for either. Compare Door and Placeable, where a placed instance’s Description is toolset residue but the value at least comes from somewhere (the referenced blueprint) – waypoints resolve no TemplateResRef at all, so there’s no blueprint to fall back to either. A placed waypoint’s Appearance and Description are read from nowhere, full stop: not the instance, not a template, because neither field name is ever looked up by this loader under any circumstance.

Implemented Linter Rules (Rakata-Lint)

These diagnostics are implemented under rakata_lint::rules::utw.

  1. UTW-001 (Map Note Double-Gating): Warns when MapNote or MapNoteEnabled are populated but HasMapNote=false; this data is silently discarded by the engine.
  2. UTW-002 (Orientation Warnings): Informs when the orientation vector magnitude is not within ~0.001 of 1.0; the engine forcibly normalizes at load.

Pending

  • Tag Enforcement: Flags empty Tag values since waypoints are primarily targeted by name from scripts.

3D Geometry & Models

At the heart of the Odyssey Engine’s visual presentation is a proprietary structural design for interpreting and rendering 3D geometry. Modern formats like .glTF or .fbx bundle all visual and physical data into a single asset. KotOR however, splits this data across several distinct files. The engine strictly decouples the node hierarchy tree, the raw vertex buffers, and the mathematical collision boundaries.

Note

If you are looking for the exact underlying Ghidra-derived notes detailing the K1 Engine’s InputBinary::Read pipeline and structural layout bytes, please refer to the MDL & MDX Deep Dive.


Implementation Blueprints

This section documents the primary pillars of KOTOR geometry and their mathematical foundations, backed by swkotor.exe clean-room reverse engineering.

FormatNameLayout & Purpose
MDLModel HierarchyThe architectural scaffold holding the model together. It defines the scene bounding volumes, spatial rotations, embedded animations, engine rendering parameters, and a deep recursive tree of typed Nodes (e.g., Lights, Bones, Emitters, Trimeshes).
MDXVertex DataThe abstract mathematical arrays defining the actual rendering payload. It directly encodes interleaved array blocks mapping exact spatial coordinates (X, Y, Z), texture UV layouts, and Lighting Normals.
BWMWalkmeshesThe raw mathematical graph of AABB bounds and face intersections that serve as physics collision boxes for area environments (.wok), placeables (.pwk), and interactive doors (.dwk).
MathTriMesh DerivationsDocumentation explaining exactly how variables like coordinate bounds and face offsets are mathematically derived across both visual Trimeshes and collision Walkmeshes.

MDL Format (Model Hierarchy)

The .mdl format serves as the overarching structural spine for 3D model geometry. Rather than storing literal vertex positions directly, it recursively structures a tree of generalized nodes (Bones, Trimeshes, Lights, Emitters) into a unified visual mesh. It delegates vertex geometry out, binds textures, links dynamic controllers (keyframe transformations), and maps bounding sphere matrices directly to the model’s rigid physical space.

At a Glance

PropertyValue
Extension(s).mdl
Magic SignatureText (filedependancy) or Binary (\0 byte header)
Type3D Hierarchical Mesh
Rust ReferenceView rakata_formats::Mdl in Rustdocs

Data Model Structure

Rakata maps the .mdl binary tree exactly into rakata_formats::Mdl.

Because a model intrinsically utilizes 11 distinct struct sub-types, Rakata resolves the pointer-based tree structure into a secure Rust Vec<MdlNode>. Native file pointer offsets which are normally resolved inside KOTOR via an explicit raw memory relocation dump are converted into safe recursive structures at parse time.

Node Sub-Types

The engine determines exact node allocations using a rigid bitflag header.

Sub-TypeDescription
BaseA pure structure node (Dummy) acting strictly as an invisible visual group or spatial pivot.
LightProjects localized dynamic lighting, lens flares, and shading priorities.
EmitterConfigures particle spawning systems (fountains, single-shots, lightning, explosions).
CameraAn empty node serving as a static viewport anchor for dialogue cinematics.
ReferenceAn anchor point explicitly linking an external 3D model asset to a point.
TriMeshA rigid standard triangle geometry boundary carrying static vertex arrays.
SkinMeshA procedural mesh utilizing skeleton bone-weights and vectors to calculate organic deformations.
AnimMeshA mesh carrying hardcoded, explicitly sampled vertex coordinate animation loops.
DanglyMeshA sub-mesh evaluated through swinging physics constraints (displacement, tightness, period).
AABBA strict spatial collision tree structurally defining an internal walkmesh barrier.
SaberAllocates dynamic 3D quad arrays utilized exclusively to generate stretching lightsaber swing trails.

Engine Audits & Decompilation

Deep Dive: For an exhaustive archive of the Ghidra decompilation notes detailing the exact byte-level layout of the binary MDL format and engine loading pipeline, refer to the MDL & MDX Deep Dive.

The following information documents the engine’s exact load sequence for genuine Binary MDL models. All behavior was mapped from natively analyzing swkotor.exe execution pipelines via Ghidra.

Loading and Wrapper Validation

Read initially via Input::Read (0x004a14b0).

Pipeline EventGhidra Provenance & Engine Behavior
Binary vs ASCII DetectionThe engine checks the exact first byte of the file. If it hits a \0 (NULL), it dispatches the asset entirely to the InputBinary track. If it hits text ("filedependancy" or "newmodel"), it loops into the FuncInterp ASCII parser track.
Wrapper MappingThe Binary format evaluates the initial 12 bytes as an abstract Wrapper block defining explicit sizes for the .MDL and the associated .MDX geometry.
In-Memory Heap DumpThe engine allocates the sizes noted in the wrapper, runs memcpy on both the .MDL and .MDX assets blindly into memory, and then runs the recursive Reset path to relocate spatial internal pointer offsets to absolute memory addresses.

Node Dispatch Architecture

Read initially via InputBinary::ResetMdlNode (0x004a0900). The engine recursively navigates downwards matching against a constant 16-bit node-type flag lookup spanning from 0x0001 (Base Node) to 0x0821 (Lightsaber).

Mapped PropertyEngine Behavior
Sub-node Allocation SizesNodes are dynamically allocated varying byte lengths strictly based on their type-mask. A root Base node only evaluates 80 contiguous bytes, but an Emitter allocates 304, and a Skin allocates 512.
Parent/Child Graph ResolutionEngine structures evaluate nodes continuously downward via embedded raw pointer arrays. These arrays branch a group of distinct sub-children implicitly off their master parent. At load time, the engine must safely rewrite all relative file offsets into absolute physical memory locations, otherwise the entire hierarchy will instantly detach.

Mapped Behavior Quirks

Mapped PropertyGhidra Provenance & Engine Behavior
LOD Suffix GenerationThe engine natively evaluates if the cullWithLOD property is set. If true, it explicitly triggers string concatenations for FindModel(name + "_x") and FindModel(name + "_z") sequentially to dynamically attach lower-quality auxiliary geometry instances based on viewport distance.
Animation Bone BindingWhen building the live hierarchy tree for a rendering sequence, the engine explicitly ignores the node’s textual string name. Instead, it rigidly evaluates physical pairings against a mapped node_id integer. If the bone isn’t properly sequenced to that numeric ID array, it detaches from the runtime arrays entirely.
Self-Describing KeyframesUnlike older properties that rely on rigid dictionaries, KOTOR determines how an animation was saved dynamically by reading the keyframe’s controller type integer. It applies a bitwise AND check against the type’s lowest hex digit (& 0x0F) to instantly dictate whether the loaded keyframe is a single float (like scaling), 3 floats (like an XYZ positional vector), or 4 floats (for a Slerp quaternion rotation).

Proposed Linter Rules (Rakata-Lint)

While rakata-lint currently only evaluates GFF formats and does not yet parse .mdl models dynamically, the engine behaviors above hint at some suggested lint diagnostics:

Planned Lint Diagnostics:

  1. Skeleton / Animation Tracing: Flags animation nodes where the internal skeletal node_number binding parameter implicitly equals 0, ensuring the mesh does not hard freeze via pointing to the rigid root spine.
  2. Controller Mask Encoding: Validates that generic Controller properties properly bit-mask against the Bezier indicator (0x10) rather than reading explicitly raw quaternion values (which causes cascading loop failures through the rest of the array block).
  3. Emitter Detonation Allocation: Flags interactive Emitter nodes attempting to bind the detonate key (Controller 502) while structurally mis-identifying as "Fountain". The engine native only maps controller 502 data to strict "Explosion" memory paths, resulting in an aggressive Access Violation engine crash otherwise.
  4. Name Graph Sanitization: Notifies developers if the node graph contains artificially un-referenced graph pointers mapped under the unified Name Table. (BioWare notoriously shipped identical shared name tables compiling .pwk and .wok models into .mdl nodes natively throughout the 2003 pipeline).

MDX Format (Vertex Data)

The .mdx format is a companion file that always pairs tightly with a .mdl model. While the .mdl file handles the complex math, skeletal hierarchy, and animation logic, the .mdx file acts as bulk storage; holding the massive lists of raw 3D coordinates (vertices) that make up the physical shape of the model.

Architecturally, the swkotor.exe engine treats these two files as a single combined asset: the .mdl dictates where and how the model moves, and the .mdx provides the points to physically draw on the screen.

At a Glance

PropertyValue
Extension(s).mdx
Magic SignatureRaw binary stream (No explicit signature block)
TypeInterleaved Vertex Payload Array
Rust ReferenceView rakata_formats::Mdx in Rustdocs

Data Model Structure

Rakata safely consumes the unindexed byte sequences into a typed geometry definition mapped within rakata_formats::Mdx.

At the raw binary level, .mdx data is strictly an interleaved buffer. Variables (like positional 3D XYZ vectors, Texture Parameter UV planes, and light-calculating Normals) are sequentially woven directly across the byte stream.

Engine Audits & Decompilation

Deep Dive: For an exhaustive archive of the Ghidra decompilation notes detailing the exact byte-level layout of the binary MDL/MDX format and engine loading pipeline, refer to the MDL & MDX Deep Dive.

The following documents the engine’s exact load sequence and structure for .mdx interleaved data pipelines mapped from swkotor.exe.

(Decompilation logic for this section was entirely audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from InputBinary::Read (0x004a1230) and InputBinary::ResetMdlNode (0x004a0900).)

Loading and Lifecycle

Pipeline EventGhidra Provenance & Engine Behavior
Memory WrappingTriggered immediately alongside the .mdl. The wrapper dynamically outlines the exact byte-count of .mdx data required (wrapper + 0x08).
Buffer LiberationMDX arrays are entirely stateless. Once InputBinary::ResetMdlNode computes the geometry arrays and translates the buffer directly into the OpenGL hardware render-pools during loading, the engine immediately calls free() wiping the MDX byte arrays from physical memory entirely.

TriMesh Structural Addressing

The KOTOR Engine avoids parsing the MDX data by scanning through it block-for-block. Instead, traversing the actual MDL hierarchy drives vertex payload requests explicitly.

Mapped PropertyGhidra Provenance & Engine Behavior
Array SlicingEvery distinct TriMesh instantiated in the parent MDL tree explicitly registers an mdx_data_offset pointer (TriMesh + 0x144). This dictates exactly where the engine explicitly seeks within the interleaved .mdx payload array to fetch this mesh’s native points.
Node Alignment ConstraintsVanilla assets maintain extremely strict alignment formats. Meshes are dynamically sorted prior to hardware parsing: static rendering models fall to the top of the index chain, whereas dynamic procedural meshes (like character .Skin nodes) are specifically dumped sequentially to the rear of the .mdx.

Note

Ghost Payload Sentinels During memory extraction, the engine implicitly pads geometric mesh payloads out to distinct 16-byte aligned boundaries using Terminator Rows. Any mesh vertex iteration falling slightly out of stride will be explicitly back-filled with ghost/sentinel float arrays ([0.0, 0.0, 0.0]) to ensure OpenGL buffer calculations remain strictly uniform without overflowing pointer indexes during hardware streaming.

Proposed Linter Rules (Rakata-Lint)

Incorrectly calculated .mdx offset spans or payload array lengths can cause the engine to read misaligned bytes or overflow data bounds. Providing a linter rule to validate these payload alignments helps prevent geometry corruption and potential engine/gpu crashes.

While rakata-lint currently only evaluates GFF formats and does not yet parse .mdx buffers dynamically, the engine behaviors above hint at the foundational requirements for .mdx stability:

Planned Lint Diagnostics:

  1. Mesh Slice Verification: Enforces explicit iteration seeking. Validates .mdx vector boundaries by explicitly jumping pointers down the file according to individual mdx_data_offset assignments mapped on explicitly bound TriMesh headers, rather than assuming unverified sequential payload lengths.

Walkmesh (BWM / WOK)

Walkmeshes govern physical collision and pathfinding across an area. They dictate exactly where a character can stand, what slopes they can climb, and what physical materials block their path.


BWM Binary

The binary implementation of the Walkmesh is entirely designed to be dumped straight into memory. Instead of smoothly parsing the file piece-by-piece, the engine constantly jumps around the file using a complex array of offsets located at the very top.

At a Glance

PropertyValue
Extension(s).bwm, .wok
Magic SignatureNone standard header block
TypeMemory-Mapped Collision Net
Rust ReferenceView rakata_formats::Bwm in Rustdocs

Engine Audits & Decompilation

The following documents the engine’s exact load sequence and field requirements for Binary Walkmeshes mapped from swkotor.exe.

(Decompilation logic for this section was entirely audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from CSWCollisionMesh::LoadMeshBinary at 0x00597120.)

Pipeline EventGhidra Provenance & Engine Behavior
Pointer JumpingThe engine doesn’t read the file linearly from top to bottom. Instead, it uses direct memory math (pointer arithmetic) to aggressively jump between the header and the raw data payload.
Offset ExtractionThe beginning of the file contains exact byte locations the engine uses to orient itself:
+0x08 yields the total vertex_count
+0x0C..+0x18 provides the maximum limits for faces, materials, and walk-edges
+0x18..+0x24 yields adjacency boundaries
+0x3C..+0x48 stores the direct starting addresses for the geometry data
Bounding Box OffsetsThe spans immediately following (+0x48..+0x6C and +0x6C..+0x84) are reserved specifically for tracking offsets that point to the Axis-Aligned Bounding Box (AABB) collision trees.
Ignoring the Magic IDMagic bypass: Magic and version identifiers (BWM ) are actually ignored natively during the LoadMeshBinary process. It relies on a different system entirely to verify file signatures beforehand.
Read-Only FormatOne-Way Flow: Vanilla KOTOR contains strictly read-only capabilities for BWM binaries. Developers removed any functionality needed to compile or save collision data dynamically!

Tip

Orphaned Memory Gaps: The engine entirely skips reading two massive blocks of bytes off the disk: +0x24..+0x3C (24 bytes) and +0x64..+0x6C (8 bytes). For a byte-perfect roundtrip toolset, these gaps must absolutely be preserved verbatim!


BWM ASCII

For tooling purposes, BioWare engine modules support a raw ASCII readable version of the walkmesh that can be dynamically parsed at runtime at a massive performance cost.

At a Glance

PropertyValue
Extension(s).bwm (ASCII formatted)
Magic SignatureASCII Text Directives
TypeUncompiled Collision Text

Engine Audits & Decompilation

The following documents the engine’s exact load sequence and constraints for ASCII text walkmeshes mapped from swkotor.exe.

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

Pipeline EventGhidra Provenance & Engine Behavior
Searching for KeywordsThe engine scans the text file reading line-by-line to look for the specific keywords node, verts, faces, and aabb.
Strict Face FormattingEvery defined face string must strictly format exactly 8 numbers separated by spaces. Interestingly, while the engine reads the adjacency input, it immediately deletes it! The engine forces adjacency math to be physically recomputed from scratch post-load to prevent geometric errors from old assets.
Line Length LimitsThe engine will aggressively truncate or glitch if any single text line stretches beyond 256 characters (0x100 bytes).
Face ReorderingUsing the surfacemat.2da file, the engine completely shuffles the order of the faces while loading. It essentially groups every geometry face marked “walkable” at the absolute top of the array, and pushes all non-walkable geometry straight to the bottom.
Fudging the BoundariesWhen figuring out the Axis-Aligned Bounding Box (AABB) limits, the text loader artificially stretches the box outwards by roughly 0.01 across every axis. Due to the face reordering mentioned above, the engine also has to build a temporary remap table under the hood just to keep track of where everything moved!

Warning

Because the ASCII face-reordering mechanism radically shuffles the root array indexes from walkable to unwalkable clusters via the LoadMeshText routine, it is impossible to do a clean 1-to-1 binary-to-ASCII-to-binary round trip of a KOTOR walkmesh without completely losing the original face indexing format!

TriMesh Derived & Computed Fields Reference

This document catalogs derived or computable fields specifically impacting TriMesh generation for MDL/MDX structures.

At a Glance

PropertyValue
Extension(s).mdl
DomainGeometry Math / Model Reconstruction
Rust ReferenceView rakata_formats::MdlNodeTriMesh in Rustdocs

Data Model Structure

Rakata attempts to make building a TriMesh as painless as possible by handling the complex math under the hood.

  • Derived Fields: Rakata explicitly understands the difference between data you must supply (like static 3D coordinates) and data that can safely be calculated on the fly (like bounding limits, spherical radii, or adjacency maps). The rakata-formats API automatically calculates all of these required boundaries for you seamlessly whenever you serialize the file!

Engine Audits & Decompilation

This document catalogues every field on MdlMesh and MdlFace that can be derived from geometry, documenting what each field means, how community tools handle it, and what algorithm is needed to recompute it. This is the reference for future model-editing API work.

Field Categories

  • User-authored: Provided by the modeller. Never recomputed.
  • Derivable: Can be recomputed from geometry. Tools recompute on ASCII import / model rebuild; preserve verbatim on binary roundtrip.
  • Runtime-only: Written by the engine at load time. On-disk values are meaningless stubs.

1. Internal CExoArrayList Fields (+0x98 .. +0xC8)

The five CExoArrayList slots in the TriMesh header form a coordinated GL index buffer submission system. Each stores a 12-byte header (ptr/count/alloc) in the mesh header plus a single u32 data value in the content blob.

1.1 vertex_indices (+0x98) – Dead in KotOR

What it is: A legacy engine array block. In BioWare’s older titles (like Neverwinter Nights), this block pointed to vertex index data. In KOTOR, the engine never actually looks at this field at all.

Community tools:

  • mdledit: Misidentifies as cTexture3 (12-byte string). Byte-exact preserve.
  • mdlops: Reads as raw bytes via darray struct. Byte-exact preserve.
  • PyKotor: Reads as indices_counts. Byte-exact preserve.
  • xoreos/reone: Skip entirely.

Vanilla values: Always zeros (ptr=0, count=0, alloc=0).

Rakata Processing Rule: Store as [u8; 12] for lossless preservation, or zero on write. No computation needed.

1.2 left_over_faces (+0xA4) – Dead in KotOR

What it is: Another legacy array block. In NWN, this stored “left over” face geometry. In KOTOR, the engine updates the pointer location dynamically but completely forgets to actually use or read the data during the OpenGL rendering cycle. rendering loop.

Community tools:

  • mdledit: Misidentifies as cTexture4 (12-byte string). Byte-exact preserve.
  • mdlops: Reads as raw bytes via darray struct. Points to the packed u16 vertex index data (mdlops uses this as the indirection to find face indices).
  • PyKotor: Reads as indices_offsets. Byte-exact preserve.
  • xoreos: Only field it actually follows – reads the pointer to find packed u16 face vertex indices.
  • reone: Reads as indicesOffsetArrayDef. Uses first element as pointer to u16 index data.

Vanilla values: Typically non-zero. The pointer value points to the packed u16 face vertex index data. Count is 1, alloc is 1.

Rakata Processing Rule: Store the raw pointer and count variables. The pointer is content-relative and must be explicitly backpatched on write to point to the packed u16 face index data block.

1.3 vertex_indices_count (+0xB0) – Derivable

What it is: Single u32 value = total number of u16 vertex indices in the face index buffer.

Formula: face_count * 3

Community tools:

  • mdledit: Recomputes on every write (nVertIndicesCount = Faces.size() * 3).
  • mdlops: Recomputes on ASCII import.
  • PyKotor: Preserves from binary, creates empty for new models.

Rakata Processing Rule: Dynamically derive from faces.len() * 3. Never store a static value in the struct.

1.4 mdx_offsets (+0xBC) – Derivable (pointer)

What it is: Single u32 value = content-relative offset to the packed u16 face vertex index data in the MDL content blob.

Community tools:

  • mdledit: Writes placeholder, backpatches when VertIndices data is written.
  • mdlops: Same approach.
  • PyKotor: Same approach.

Rakata Processing Rule: Compute strictly at serialization time via the binary writer. Never store a static value in the struct.

1.5 index_buffer_pools / Inverted Counter (+0xC8) – Preserve or Derive

What it is: A standard 32-bit number. On the physical hard drive, this acts exclusively as a sequence counter that numbers meshes using a bizarre “inverted” counting pattern. However, the moment the engine loads the file into memory, it deletes this number and overwrites the exact memory space with an OpenGL hardware connection handle.

The inverted counter formula (from mdledit asciipostprocess.cpp:1024):

mesh_counter: sequential 1-based index across all mesh nodes in DFS tree order.
              Saber meshes consume TWO increments (one per inverted counter).

Quo = mesh_counter / 100
Mod = mesh_counter % 100
inverted_counter = (2^Quo) * 100 - mesh_counter
                 + (Mod != 0 ? Quo * 100 : 0)
                 + (Quo != 0 ? 0 : -1)

Example sequence: 98, 97, 96, …, 1, 0, 100, 199, 198, …, 101, 200, …

Community tools:

  • mdledit: Preserves from binary. Recomputes from formula only for ASCII import when value is missing (!nMeshInvertedCounter.Valid()).
  • mdlops: Recomputes on ASCII import using same formula.
  • PyKotor: Preserves from binary.

Rakata Processing Rule: Map as a static u32 field to perfectly preserve binary roundtripping. When natively constructing new models, dynamically compute the inverted sequence according to the formula using a DFS mesh counter.


2. Packed u16 Face Vertex Indices

What it is: A tightly packed list of u16 index triplets (yielding exactly 6 bytes per face). Each 3-piece triplet tells the renderer which three vertex dots to connect to draw one flat triangle. This entire block is physically uploaded straight to the graphics card to render the final model.

Relationship to MdlFace: The packed u16 data is identical to MdlFace.vertex_indices for each face, laid out sequentially. It is fully redundant with the face array.

Community tools:

  • mdledit: Reads from binary into nVertIndices (3 u16 per face, stored alongside face data). Writes from face data.
  • mdlops: Reads as vertindexes darray. Writes from face data on ASCII import.
  • xoreos/reone: Read from the pointer at +0xA4 or +0xBC.

Rakata Processing Rule: Always dynamically derive identical copies directly from faces[i].vertex_indices during binary emission. Never map a redundant array inside the Rakata struct.


3. Face Fields (MdlFace, 32 bytes per face)

3.1 plane_normal ([f32; 3]) – Derivable

What it is: The geometric direction the triangle’s flat surface is facing (a unit normal vector).

Formula:

edge1 = positions[v1] - positions[v0]
edge2 = positions[v2] - positions[v0]
normal = normalize(cross(edge1, edge2))

Community tools: All tools that recompute adjacency also recompute normals.

3.2 plane_distance (f32) – Derivable

What it is: The raw distance measured straight from the physical center of the world (origin) to the face’s flat surface along its normal vector.

Formula: plane_distance = -dot(plane_normal, positions[v0])

Note: some tools negate this differently. Verify against vanilla data.

3.3 surface_id (u32) – User-authored

What it is: Material/surface type identifier. Determines footstep sounds, walkability, etc. in walkmeshes; material properties in render meshes.

Not derivable – assigned by the modeller or inherited from the source asset.

3.4 adjacent ([u16; 3]) – Derivable

What it is: For each edge of the triangle, the index of the face sharing that edge. 0xFFFF means no adjacent face (boundary edge).

Edge-to-adjacent mapping:

  • adjacent[0]: face sharing edge (v0, v1)
  • adjacent[1]: face sharing edge (v1, v2)
  • adjacent[2]: face sharing edge (v2, v0)

Rakata Hash-Map Adjacency Algorithm:

1. Build position_key(v) = format!("{:.4e},{:.4e},{:.4e}", pos[0], pos[1], pos[2])

2. Build vertex_group: HashMap<String, Vec<usize>>
   For each vertex index i:
       vertex_group[position_key(i)].push(i)

3. Build vertex_to_faces: HashMap<usize, Vec<usize>>
   For each face f, for each vertex v in face.vertex_indices:
       vertex_to_faces[v].push(f)

4. Build face_set(vertex_index) -> HashSet<usize>:
   Collect all faces touching any vertex in the same position group:
       group = vertex_group[position_key(vertex_index)]
       union of vertex_to_faces[g] for all g in group

5. For each face f:
   For each edge (va, vb) in [(v0,v1), (v1,v2), (v2,v0)]:
       candidates = face_set(va) & face_set(vb) - {f}
       adjacent[edge] = if candidates.is_empty() { 0xFFFF }
                        else { min(candidates) }

Complexity: O(F * V_avg) where V_avg is the average number of faces per vertex group. Effectively O(F) for well-behaved meshes.

No-neighbor sentinel: 0xFFFF (u16::MAX). All tools agree except PyKotor which incorrectly uses 0 (bug – face 0 is a valid index).

Non-manifold edges: When more than 2 faces share an edge, tools differ:

  • mdledit: First match wins, logs a warning.
  • mdlops: Arbitrary (hash iteration order).
  • PyKotor: Smallest face index wins (min(candidates)).

Rakata Processing Rule: Always use min(candidates) internally so evaluation remains deterministic and aligns with PyKotor output. If non-manifold geometric edges are detected, the formatter must throw a logger warning.

Important: Vertex matching must be position-based, not index-based. Meshes commonly have duplicate vertices at the same position with different normals/UVs (hard edges, UV seams). Index-based matching would miss adjacency across these seams.

3.5 vertex_indices ([u16; 3]) – User-authored

What it is: The three vertex indices forming this triangle.

Not derivable – defines the mesh topology.


4. Mesh Bounding Geometry – Derivable

4.1 bounding_min / bounding_max ([f32; 3])

What it is: A perfect, square box drawn tightly around every single vertex dot in the model (an Axis-Aligned Bounding Box).

Formula:

bounding_min = [min of all positions[i][0], min of [1], min of [2]]
bounding_max = [max of all positions[i][0], max of [1], max of [2]]

4.2 bsphere_center / bsphere_radius ([f32; 3], f32)

What it is: Minimum bounding sphere enclosing all vertices. Used by the engine for frustum culling (PartTriMesh::GetMinimumSphere at 0x00443330).

Engine algorithm (from Ghidra, confirmed in mdl_mdx.md):

center = average of all vertex positions (centroid)
radius = max distance from center to any vertex

This is NOT the true minimum bounding sphere (Welzl’s algorithm), but a simpler centroid-based approximation. Matches what vanilla files contain.

4.3 total_surface_area (f32)

What it is: Sum of all triangle areas in the mesh.

Formula:

For each face:
    edge1 = positions[v1] - positions[v0]
    edge2 = positions[v2] - positions[v0]
    area += 0.5 * length(cross(edge1, edge2))
total_surface_area = sum of all face areas

5. AABB Tree – Derivable (complex)

What it is: A mathematical collision-detection tree (Binary Space Partition) built over the faces of the mesh. It recursively slices the physics block into smaller and smaller floating boxes so the engine can quickly determine if a player bumps into a wall, saving it from checking collision against every single polygon.

When needed: Only for MdlNodeData::Aabb nodes (walkmesh-like collision geometry). Regular render meshes don’t have AABB trees.

Node layout: 40 bytes (see mdl_mdx.md for full struct).

Build algorithm: Recursive spatial partition:

  1. Compute AABB of all face centroids.
  2. Choose split axis (longest AABB dimension).
  3. Sort faces by centroid along split axis.
  4. Split at median into left/right subsets.
  5. Recurse on each subset until single-face leaves.

Community tools generally don’t rebuild AABB trees from scratch – they preserve the existing tree or require external tooling to generate it.


6. Fields That Are NOT Derivable

These distinct fields are explicitly user-authored or carried over from tooling. Rakata must treat them strictly as rigid payload endpoints. They are never mathematically recomputed across the pipeline:

FieldSource
Vertex positions, normals, UVs, tangent space3D modeller
Vertex colors3D modeller or material editor
Texture names (texture_0, texture_1)Material assignment
Diffuse/ambient colorsMaterial properties
Transparency hint, light_mapped, beaming, etc.Material flags
Surface ID per faceSurface type assignment
Vertex indices per faceMesh topology
Controller keyframesAnimation data
Bone weights, indices, bonemapRigging tool
Emitter propertiesParticle editor

7. Tool Cross-Reference: CExoArrayList Naming

The naming across tools is wildly inconsistent:

OffsetEngine (Ghidra)rakatamdleditmdlopsPyKotorxoreos
+0x98vertex_indicesvertex_indices_arraycTexture3pntr_to_vert_numindices_counts(skip)
+0xA4left_over_facesleft_over_faces_arraycTexture4pntr_to_vert_locindices_offsetsoffOffVerts
+0xB0vertex_indices_countvertex_indices_count_arrayIndexCounterArrayarray3counters(skip)
+0xBCmdx_offsetsmdx_offsets_arrayIndexLocationArray(backpatch only)(not modeled)offOffVerts
+0xC8index_buffer_poolsindex_buffer_pools_arrayMeshInvertedCounterArrayinv_count(not modeled)(skip)

Note: mdledit’s identification of +0x98/+0xA4 as texture name slots is incorrect for KotOR. In NWN, the mesh header has 4 texture name slots (64 bytes each) at this region. KotOR reduced to 2 texture names (32 bytes each at +0x58/+0x78) and repurposed the remaining space as CExoArrayList headers. The CExoArrayLists are always empty (all zeros) in vanilla KotOR, so mdledit’s string-based read/write produces byte-identical results.


8. MDL vs BWM Adjacency Encoding

A critical distinction for anyone working with both formats:

PropertyMDL Face AdjacencyBWM Walkmesh Adjacency
Storageu16 per edgei32 per edge
EncodingPlain face indexface_index * 3 + edge_index
No-neighbor0xFFFF-1 (0xFFFFFFFF)
PurposeGL rendering hintsPathfinding / collision

BWM’s edge-encoded adjacency tells you not just WHICH face is adjacent, but WHICH EDGE of that face connects – needed for the pathfinding walk algorithm. MDL only needs to know which face, not which edge.


9. Write-Order Dependencies

When writing a mesh node, fields must be emitted in a specific order because some fields are content-relative pointers that must be backpatched. The canonical order (from mdledit binarywrite.cpp) is:

  1. Face array (32 bytes per face)
  2. vertex_indices_count data (single u32: face_count * 3)
  3. Content vertex positions (12 bytes per vertex, only for MDL content blob)
  4. mdx_offsets data (single u32: placeholder, backpatched)
  5. index_buffer_pools data (single u32: inverted counter value)
  6. Packed u16 vertex indices (face_count * 3 u16 values)

After step 6, backpatch the mdx_offsets pointer to point to the start of step 6’s data.

CExoArrayList headers at +0x98..+0xC8 are written as part of the mesh extra header (332 bytes), with pointer values backpatched after the data is written.

Texture Formats

KOTOR handles graphics via multiple tailored texture formats. It uses hardware-accelerated DXT compression techniques natively supported by its OpenGL backend.


Implementation Blueprints

This section details the primary texture architectures parsed natively by rakata-formats.

FormatNameLayout & Purpose
TPCTexture Pack CompressedA proprietary BioWare wrapper around native DXT-compressed OpenGL texture data. This is the primary format used for all base-game environment and character textures.
DDSDirectDraw SurfaceA proprietary BioWare variation of the standard Microsoft DDS format. Rather than utilizing standard headers, the legacy engine requires a bespoke 20-byte magic wrapper.
TGATruevision TargaAn uncompressed, lossless visual format. Used for rendering crisp UI elements, visual effects (VFX), etc.
TXITexture ExtensionsPlaintext routing files that accompany primary textures. They direct the engine how to apply advanced rendering hints, such as procedural animations or bump-mapping.

TPC (Texture Pack Compressed)

TPC is the proprietary bundled texture format created by BioWare. It contains the raw DXT-compressed texture data, pre-computed mipmaps, and potentially appended TXI configuration data all in one blob.

At a Glance

PropertyValue
Extension(s).tpc
Magic SignatureNone
TypeCompressed Texture Pack
Rust ReferenceView rakata_formats::Tpc in Rustdocs

Data Model Structure

The rakata-formats crate provides a formally mapped Tpc container that completely shields you from managing pixel type bitmasks.

  • Pixel Enum Decoding: Instead of raw integer flag codes, calling known_pixel_format() instantly resolves the byte code into a robust TpcHeaderPixelFormat enumeration (e.g., Dxt1, Dxt5, Rgb, Greyscale).
  • Footer Management: Trailing TXI text is seamlessly maintained, and can be cleanly updated via .set_txi_text_strict().

Engine Audits & Decompilation

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

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

Pipeline EventGhidra Provenance & Engine Behavior
Format Byte MappingThe single header format byte acts as a strict bitmask. The engine explicitly checks bit0, bit1, and bit2 to generate internal format codes: 1, 3, and 4.
Compression DispatchThe runtime fundamentally ignores other variants. It strictly requires Code 3 to process 8-byte geometry chunks (standard S3TC DXT1) or Code 4 to process 16-byte chunks (standard S3TC DXT5).
Mipmap CalculationsRather than parsing explicit counts, the engine calculates mipmap storage dimensions by blindly right-shifting the base dimensions for each depth level without natively clamping the integer to 1. Because of this, extremely deep architectural mip levels can produce 0 geometry bytes!
OpenGL Hardware BindingWhen aggressively pushing the TPC bytes into OpenGL video memory, the engine natively maps Code 3 directly to OpenGL constant 0x83F0 (DXT1) and Code 4 straight to 0x83F3 (DXT5). Technically, there is zero branching logic to support native DXT3 (0x83F2) inside the vanilla engine’s parser.

DDS (DirectDraw Surface)

The .dds extension in KOTOR does not represent a standard Microsoft DirectDraw Surface file. Instead, the engine strictly expects a proprietary format consisting of a bespoke 20-byte configuration prefix followed by raw DXT compression blocks. The vanilla parsing logic completely ignores standard 124-byte DDS magic headers.

At a Glance

PropertyValue
Extension(s).dds
Magic SignatureNone (Proprietary 20-Byte Prefix)
TypeBioWare DirectDraw Wrapper
Rust ReferenceView rakata_formats::Dds in Rustdocs

Data Model Structure

rakata-formats is built to natively parse both standard Microsoft DDS architecture and KotOR’s proprietary CResDDS format transparently. When evaluating a .dds file via rakata_formats::Dds:

  • Bilateral Read Path: If the file begins with the standard Microsoft DDS magic bytes, Rakata leverages a standard pipeline to extract the payload. If those magic bytes are missing, Rakata immediately pivots and parses the data natively as a proprietary K1 CResDDS 20-byte payload.
  • Strict Serialization: Regardless of which variation is ingested from the disk, Rakata will strictly emit valid 20-byte KotOR-compliant payloads during binary serialization.

Engine Audits & Decompilation

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

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

Pipeline EventGhidra Provenance & Engine Behavior
Prefix StrippingThe engine’s parser explicitly expects and strips a proprietary 20-byte magic header wrapper prepended to the DDS buffer: width (+0x00), height (+0x04), byte code (+0x08), base-size (+0x0C), and an alpha_mean FLOAT (+0x10).
Block CalculationThe runtime completely mimics the TPC logic for memory block sizing. Fundamentally, the algorithm determines the 3D dimensions via the formula: (pixel_type == 4) * 8 + 8. Code 3 explicitly evaluates into 8-byte texture blocks, while Code 4 evaluates to 16-byte blocks.

Tip

Reserved Gaps: The bytes spanning +0x09 to +0x0B in the header prefix are entirely ignored by the GetDDSAttrib read path. We preserve them strictly for round-trip fidelity.


TGA (Truevision Targa)

TGA is the standard uncompressed image format utilized by the engine, typically reserved for UI elements, icons, or high-fidelity models that demand lossless alpha channels.

At a Glance

PropertyValue
Extension(s).tga
Magic SignatureTruevision Standard
TypeUncompressed RGB/A Raster
Rust ReferenceView rakata_formats::Tga in Rustdocs

Data Model Structure

rakata-formats natively emulates the engine’s parsing logic. When evaluating a .tga file, Rakata ignores non-essential Truevision header flags (such as image_type and id_len) and strictly validates the payload against the engine’s natively supported pixel_depth thresholds.

Engine Audits & Decompilation

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

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

Pipeline EventGhidra Provenance & Engine Behavior
Header StrippingFunction: ImageReadTGAHeader (0x0045e2e0)
The native engine parser is exceptionally loose. Standard Truevision fields such as image_type (offset +0x02), image_descriptor (offset +0x11 governing the origin bit), and the id_len field are completely ignored and never validated during a read sequence.
Depth ValidationFunction: ImageReadTGAHeader (0x0045e2e0)
The sole structural validation check performed before memory allocation dictates that the pixel_depth must strictly equal 8, 24, or 32. Any other depth integer triggers an immediate process failure.
Write GenerationFunction: ImageWriteTGA
The engine’s in-memory rasterization is strictly top-left, but its canonical on-disk .tga format is entirely bottom-left. When saving screenshot files or extracting buffers to disk, the engine forcefully accommodates this by hardcoding image_type=2, id_len=0, and image_descriptor=0, explicitly triggering an ImageFlipY vertical inversion on the memory payload before pushing the image to disk.

TXI (Texture Extensions)

TXI files (or TPC appended arrays) are highly forgiving plain-text metadata blocks applied adjacent to graphical files to enforce custom mipmap, bumpmap, or animation shaders.

At a Glance

PropertyValue
Extension(s).txi
Magic SignatureNone
TypeASCII Configuration Strings
Rust ReferenceView rakata_formats::Txi in Rustdocs

Data Model Structure

rakata-formats inherently pairs TXI payload access alongside its target texture. When querying through GameVfs, textures are natively returned as a combined TextureWithTxiResult object. This architecture guarantees that the raw graphic bytes and their exact applied TXI rule block are inextricably tracked as a coupled pair throughout the virtual environment.

Engine Audits & Decompilation

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

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

Pipeline EventGhidra Provenance & Engine Behavior
Invalid CommandsFunction: CAurTextureBasic::ParseField (0x00422390)
Unknown or unsupported TXI commands are safely bypassed. If the parsed string evaluation fails to match an explicit configuration branch, the subroutine immediately exits without throwing any logger alarms or terminating texture load.
Case AgnosticismFunction: CAurTextureBasic::ParseField (0x00422390)
Field matching acts strictly case-insensitive (e.g. cMgTxi == cmgtxi).
Line NormalizationFunction: CAurTextureBasic::ParseField (0x00422390)
The native internal engine scanner searches exclusively for LF (\n) bounds. However, if the read targets an active disk file, the underlying standard C fgets call automatically handles CRLF normalization before handing strings to the regex evaluator.
Boolean ParsingFunction: Parse_bool (0x00463680)
The native Parse_bool validation explicitly performs lowercase scans evaluating against exact variants of "true", "false", "1", or "0".

Note

Boolean Parsing Nuance Modding documentation often warns against specific formats or keywords (like decal). Decompilation reveals the universal behavior applied to all boolean flags:

  • Missing Space: Keys merged with their arguments (e.g. "decal1", "mipmap0") silently abort. The firstword() extractor pulls the merged string, completely failing the target evaluation list.
  • Separated Numbers: Space-separated numbers (e.g. "decal 1") are completely structurally valid. firstword() pulls "decal" and hands " 1" off to Parse_bool(). An sscanf strips the whitespace and evaluates "1" to true.
  • Argument-less Flags: Passing just a flag ("decal") triggers the branch, but Parse_bool physically finds no argument. It fails to match "true", "false", "1", or "0", silently safely leaving the boolean integer unchanged from its previous memory allocation.

Text & Data Formats

KOTOR heavily relies on structured text and data layouts to manage everything from stat numbers to map meshes. Engine-native evidence for these varied structures (2DA, TLK, VIS, LYT, LTR) is documented below.


Implementation Blueprints

SpecificationCore Focus
2DA (2D Array)Binary/text relational database format managing core engine rules, constants, and stats.
TLK (Talk Table)Centralized localized string dictionary managing all in-game dialogue and UI text.
VIS (Visibility Graph)Binary topology mapping the rendering culling relationships between area geometry rooms.
LYT (Layout File)ASCII configuration defining spatial positioning and linking of a module’s room geometry.
LTR (Letter Frequency)Character-frequency matrices supporting the in-game random name generator algorithms.

2DA (2D Array)

2DAs are data tables defining the engine’s core rules and constraints (such as item costs and Force powers, which the engine internally stores as spells.2da). They bridge the gap between human-readable text for modding and fast-loading binaries for the final game.

At a Glance

PropertyValue
Extension(s).2da
Magic Signature2DA / V2.b (Binary) or V2.0 (Text)
TypeTabular Data
Rust ReferenceView rakata_formats::TwoDa in Rustdocs

Data Model Structure

The rakata-formats crate parses 2DAs so that binary and text formats look identical to the rest of the application. The TwoDa container lets developers simply retrieve cells using twoda.cell(row, "Label"), completely hiding the inner offset calculations and padding differences between text and binary structures.

Engine Audits & Decompilation

The following documents the engine’s exact load sequence for 2D Arrays mapped from swkotor.exe.

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

Pipeline EventGhidra Provenance & Engine Behavior
Magic/Version GateThe engine first checks for the "2DA " signature. It then branches down a binary parsing path for "V2.b" or a text parsing path for "V2.0". Any other version string triggers an instant load failure.
Binary Load (V2.b)The parser starts with an 8-byte skip into the file (data_ptr = raw_data_ptr + 8), jumping right past the header to the starting newline character. Column headers are a tab-separated, null-terminated block. The cell offsets are then parsed as an array of u16 integers (rows × cols) in row-major order.
Text Load (V2.0)The text parser strips whitespace and newlines, specifically hunting for "DEFAULT:" or "DEFAULT" blocks. When parsing individual cells, the literal text "****" is converted into an empty string "" to signal the fallback rule. Finally, it runs _strlwr on all column headers to immediately convert them to lowercase.

Tip

Orphaned Size Field: In binary row blocks, the 2-byte cell_data_size u16 is completely bypassed. The engine skips it with +2 and performs no reading or validation.


TLK (Talk Table)

The Talk Table is a massive localized string repository. Every item description, line of dialogue, and UI text in KOTOR references an index (a StrRef) pointing into this master dictionary file.

At a Glance

PropertyValue
Extension(s).tlk
Magic SignatureTLK / V3.0
TypeLocalized String Bundle
Rust ReferenceView rakata_formats::Tlk in Rustdocs

Data Model Structure

The entire Talk Table format maps to the rakata_formats::Tlk struct. Each entry fuses the separated audio and text flags into a single TlkEntry. The struct safely handles missing text flags natively, preventing out-of-bounds string lookups if an entry contains audio parameters but no valid string text offset.

Engine Audits & Decompilation

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

(Decompilation logic for this section was audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from CTlkFile::ReadHeader at 0x0041d890 and CTlkFile::AddFile.)

Pipeline EventGhidra Provenance & Engine Behavior
Magic CheckFunction: CTlkFile::ReadHeader (0x0041d890)
The parser requires a "TLK " signature. However, strict version validation is entirely absent. The engine accepts essentially any version tag without raising a failure.
Size DispatchingFunction: CTlkFile::ReadHeader (0x0041d890)
While the version isn’t used for rejection, it dynamically determines memory block sizing. A "V3.0" tag dictates 40 bytes (0x28) per entry, whereas any other version tag automatically falls back to 36 bytes (0x24).
Feminine DialectsFunction: CTlkFile::AddFile
When mounting the primary archive, the engine systematically queries the directory for a secondary <basename>F.tlk (e.g., dialogF.tlk) specifically to supply overriding feminine vocabulary strings for character-gendered text queries.

VIS (Visibility Graph)

VIS is an ASCII graph structure used extensively by the rendering engine to calculate occlusion culling. It plots mathematical relationships defining which room meshes are visible from any given observer room.

At a Glance

PropertyValue
Extension(s).vis
Magic SignatureNone
TypeRoom Graph
Rust ReferenceView rakata_formats::Vis in Rustdocs

Data Model Structure

The rakata-formats crate parses raw VIS text blocks into a strongly typed Vis structure. Rather than storing flat arrays of strings, Vis models room visibility as an adjacency list using BTreeMap<String, BTreeSet<String>>. This structural choice guarantees deterministic lookups while automatically mimicking the engine’s internal deduplication algorithms.

Engine Audits & Decompilation

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

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

Pipeline EventGhidra Provenance & Engine Behavior
Text LoadingFunction: Scene::LoadVisibility (0x004568d0)
The .vis file is executed purely as raw text. The engine continuously extracts observer and child string pairs by looping AurResGetNextLine() over the file buffer.
Silent ForgivenessFunction: Scene::LoadVisibility (0x004568d0)
If the parser extracts a room reference (either observer or child) that does not exist in the active area layout (which it verifies via a FindRoom call), the visibility entry is quietly dropped without crashing or generating logs.
Bidirectional ApplicationFunction: Scene::SetVisibility
Calling SetVisibility(room_a, room_b, 1) inherently maps both visualization paths. The function inserts room_b into room_a’s visibility list, and immediately mirrors by adding room_a to room_b’s list while executing native deduplication.
Write GenerationFunction: Scene::SaveVisibility
When generating a .vis file natively, the engine relies on an _sscanf block structure mapping to "%s%d" and uniformly pads a dual-space indent onto all child elements beneath observer headers.

LYT (Layout File)

LYT files are ASCII configuration arrays that define the spatial 3D placement and orientation of independent room models to construct a complete area map.

At a Glance

PropertyValue
Extension(s).lyt
Magic SignatureNone
TypePlain Text Layout
Rust ReferenceView rakata_formats::Lyt in Rustdocs

Data Model Structure

The rakata-formats crate parses LYT files into the strongly-typed Lyt container. The parser segregates the raw nested lines into distinct rooms, tracks, obstacles, and doorhooks collections, natively mapping coordinate strings into engine-standard Vec3 and Quaternion structs for immediate mathematical interoperability.

Engine Audits & Decompilation

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

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

Pipeline EventGhidra Provenance & Engine Behavior
Newline BoundsThe parser heavily expects explicit \r\n (CRLF) endings. Scanning extracts target strings utilizing _sscanf("%[^\r\n]", ...) patterns and frequently relies on blind +2 byte pointer leaps to manually clear the terminators.
Preamble SkippingAll file lines existing prior to the beginlayout execution marker (such as the ubiquitous #MAXLAYOUT ASCII header) are deliberately skipped and ignored.
Sequential ParsingThe structure mandates a rigid sequential ingestion. Data collections must explicitly appear geographically in the exact order: roomcounttrackcountobstaclecountdoorhookcountdonelayout.

Warning

Boundary Oversight While the engine systematically verifies donelayout boundaries separating the primary collections, the underlying parse loop functionally neglects to verify the final donelayout signature upon closing the doorhooks segment.


LTR (Letter Frequency)

LTR files contain matrices defining the probabilistic sequence groupings of letters used by the engine’s random name generator.

At a Glance

PropertyValue
Extension(s).ltr
Magic SignatureLTR / V1.0
TypeNaming State Matrix
Rust ReferenceView rakata_formats::Ltr in Rustdocs

Data Model Structure

The rakata-formats crate maps character frequency architectures directly into the strongly-typed Ltr container, safely abstracting away the fallible raw string-parsing logic for downstream implementations.

Engine Audits & Decompilation

The following documents the engine’s exact load sequence for Letter Frequency structures mapped from swkotor.exe.

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

Pipeline EventGhidra Provenance & Engine Behavior
Magic ValidationThe native parser enforces a mandatory "LTR " signature and strictly validates the "V1.0" format tag. These parameters collectively structure a rigid 9-byte header block. The sequence natively defines the letter_count variable as a single byte resting exactly at offset +0x08.
Contiguous IngestionMemory buffer extraction initiates immediately at offset +0x09. The parser algorithm sequentially extracts natively chained string arrays grouping start, middle, and end blocks to map against procedural probability matrices.
Payload Bounds CheckUpon closing the read operations, the memory allocator immediately verifies a structural bounding condition asserting that the terminal parsing offset explicitly matches the buffer array’s total byte allocation length.

Audio Formats

KOTOR handles audio via specialized implementations of the Miles Sound System, utilizing specific prefix wrappers for streaming dialogue, sound effects, and lip-syncing animations.


Implementation Blueprints

SpecificationCore Focus
WAV (Waveform Audio)Modified audio streams typically utilizing a proprietary Miles Sound System prefix wrapper.
LIP (Lip Synching)Timed phonetic animation sequence data mapped explicitly to character speech tracks.
SSF (Sound Set File)Mapping configuration assigning specific audio events to standard creature interaction triggers (e.g., attacking or dying).

WAV (Waveform Audio)

While standard RIFF WAV files are supported, KOTOR utilizes a multi-tiered routing structure to evaluate audio buffers dynamically based on whether the file encapsulates voice-overs (VO), ambient sound effects (SFX), or unmodified bytes.

At a Glance

PropertyValue
Extension(s).wav
Magic SignatureRIFF
TypeStreamed / Buffered Audio
Rust ReferenceView rakata_formats::Wav in Rustdocs

Engine Audits & Decompilation

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

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

Pipeline EventGhidra Provenance & Engine Behavior
Standard Audio (WAV)If the payload begins with the exact "RIFF" 4-byte signature and evaluates dynamically as a non-MP3 track, the parser initiates at offset 0 and transmits the contiguous buffer to the Miles Sound System without execution modification.
Ambient Audio (SFX)When evaluated as an SFX structure, the "RIFF" signature is deliberately absent from offset 0. The engine interprets a custom proprietary configuration prefix that displaces the standard "RIFF" block exactly 470 bytes into the payload buffer (+0x01d6). The execution structure calculates size = file_size - 0x1d6 and strictly extracts the resulting sub-segment.
Voice Audio (VO)For streaming voice-over tracks, the .wav wrapper successfully begins with a "RIFF" tag. However, structural logic asserting riff_size + 8 < file_size effectively succeeds. The memory engine immediately seeks to byte offset riff_size + 8 and subsequently pipes the remaining data exclusively as a literal .mp3 stream.
Delegation Hand-offThe main executable natively acts as a dispatch router, executing almost zero internal chunk structural parsing routines. Total specialization for deep RIFF chunk deserialization is deferred unconditionally to the external Miles Sound System layer.

LIP (Lip Synching)

LIP files provide keyframed facial morph data directly bound to audio streams, instructing character models how to physically animate their mouths to match speech.

At a Glance

PropertyValue
Extension(s).lip
Magic SignatureLIP V1.0
TypeFacial Animation Keyframes
Rust ReferenceView rakata_formats::Lip in Rustdocs

Data Model Structure

The rakata-formats crate maps LIP binaries into the Lip structure. It extracts the raw 5-byte sequential keyframe array and cleanly projects it into a format that pairs each chronological float timestamp directly with its localized mouth shape.

Structural Layout

OffsetTypeDescription
0x00CHAR[8]Signature (LIP V1.0)
0x08FLOATAnimation Length
0x0CDWORDEntry Count
0x10Struct[]Keyframe Array (5 bytes per entry)

Engine Audits & Decompilation

The following documents the engine’s exact load sequence for Lip Synching animations mapped from swkotor.exe.

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

Pipeline EventGhidra Provenance & Engine Behavior
Zero-Copy LoadingThe engine handles LIP files as completely flat structures. Instead of parsing the variables out individually, it simply verifies the "LIP V1.0" signature and pulls the animation length and entry count directly from offsets +0x08 and +0x0C.
Direct Array AssignmentThe keyframes are packed into identical 5-byte chunks (a 4-byte float for the timestamp, and a 1-byte integer determining the mouth shape). Because of this flat layout, the engine never loops through the data to read it. It simply points its internal animations memory pointer perfectly to file offset +0x10 and natively runs the animation straight off the raw file buffer.

SSF (Sound Set File)

Sound sets map specific generic triggers (e.g. “Battle Cry”, “Agony”, “Selected”) to physical sound references by mapping enum hooks to strings.

At a Glance

PropertyValue
Extension(s).ssf
Magic SignatureNone
TypeEnum-String Mapping
Rust ReferenceView rakata_formats::Ssf in Rustdocs

Data Model Structure

The rakata-formats crate maps SSF files into the Ssf structure. It parses the raw table offset and builds a collection of 28 nullable sound reference integers mapped directly back to their standard gameplay triggers.

Engine Audits & Decompilation

The following documents the engine’s exact load sequence for Sound Set mappings mapped from swkotor.exe.

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

Pipeline EventGhidra Provenance & Engine Behavior
Finding the TableThe parser reads a single 4-byte integer (DWORD) at offset +0x08. This number acts as a direct distance pointer, telling the game explicitly where the audio mapping table begins inside the file payload.
Reading the SlotsStarting directly at that pointer, the engine grabs exactly 28 continuous integers. Each position in this span represents a hardcoded character action (e.g. slot 1 is always ‘Battle Cry’, slot 2 is always ‘Agony’).
Handling BlanksObviously, not all characters have recorded audio for every obscure trigger. If a sound slot is supposed to be empty, it utilizes the default sentinel value 0xFFFFFFFF (-1) to let the engine know to skip playback.

Note

1-Indexed Triggers When modders fire off audio events using gameplay scripts, the event identifiers are natively 1-indexed (1 to 28). To find the matching audio string underneath, the engine simply subtracts 1 behind the scenes to correctly navigate the literal 0-indexed array in memory.

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

Save Games

A KotOR save is a folder, not a single file. Inside it: one bundle archive (SAVEGAME.sav), three loose GFF sidecars, and a thumbnail. The bundle holds the per-module runtime state; the loose sidecars hold the global session state that the load menu and the engine read directly.

Each loose sidecar has its own page; the bundle archive is described below. These pages are the field reference the rakata-save and rakata-extract crates are written from. For the assembly flow, the nested per-module archive layout, the function addresses, and the per-object serialization quirks, see the Save Game Deep Dive.

Important

Three facts shape everything below.

  • A save is a folder, and SAVEGAME.sav accumulates the runtime state of every module you have visited, not just the current one. A module is snapshotted on the way out, before the next one loads.
  • The three loose .res files are plain GFFs holding the global session state (menu metadata, party, campaign globals). The per-module state (areas, live objects) lives nested inside SAVEGAME.sav.
  • Every GFF the engine writes is stamped V3.2 no matter what version the caller asks for (see GFF).

The save folder

FileFormatRole
SAVEGAME.savERF (MOD V1.0)The bundle: one nested ERF per visited module, plus the flat session resources (REPUTE factions, AVAILNPC companion snapshots, the party INVENTORY)
savenfo.resGFF (NFO )Menu metadata: name, area, last module, play time, portraits
PARTYTABLE.resGFF (PT )Party roster, gold, XP, journal, available companions, pazaak, galaxy map
GLOBALVARS.resGFF (GVT )Campaign global variables (booleans, numbers, locations, strings)
Screen.tgaTGASave-slot preview thumbnail

Tooling identifies each resource by the type tags above. Note two quirks: the PT party-table tag has two trailing spaces, and the ERF container’s tag is the full MOD V1.0. The sidecar filename casing is the engine’s own mixed pattern (savenfo.res lowercase, PARTYTABLE.res / GLOBALVARS.res uppercase, Screen.tga capitalized): read case-insensitively, write it exactly.

The three loose sidecars are save-only GFFs: they exist inside a save folder and nowhere else. The GFFs bundled inside SAVEGAME.sav (IFO, ARE, GIT) and the global REPUTE (FAC) also appear in ordinary modules, so they are documented under GFF, not here.


SAVEGAME.sav

The bundle archive (ERF, version tag MOD V1.0). Inside, every visited module is bundled as its own nested ERF (also MOD V1.0), keyed by the module resref and stored under resource type 2057 (sav). Each per-module ERF holds:

  • the module info (IFO , under the resref Module) carrying the saved module clock, runtime id counters, and the party/limbo creature lists (see IFO);
  • the area static (ARE , type 2012 / 0x7dc), skipped for modules flagged Mod_IsNWMFile;
  • the dynamic game-instance state (GIT , type 2023 / 0x7e7): the live creatures, doors, placeables, triggers, and so on.

Reading a module’s saved objects is a two-level walk: open SAVEGAME.sav, find the resource named after the module, parse that as an ERF, and read its GIT.

Note

Before packaging, each per-module ERF lives as a standalone <module>.rsv file in the GAMEINPROGRESS working directory. The .rsv extension maps to resource type 0x0bc1; the content is identical. The deep dive covers when each is written, how the engine prefers RSV over SAV at load time, and the implications for tooling.

Faction state is not per-module: a single global REPUTE resource (type FAC , see FAC) holds the whole-session faction table.

Two more flat resources sit beside the module ERFs and REPUTE:

  • AVAILNPCn (UTC, resource type 2027), one per recruited companion, where n is the companion’s npc.2da row (AVAILNPC0-AVAILNPC8 for a full late-game crew). Each is a full creature snapshot; the partytable availability flags decide whether the engine instantiates it.
  • INVENTORY, the party’s shared item stash: a GFF tagged INV holding a single ItemList of item snapshots. The INV tag appears only here, and the one list is its whole schema. Note it is stored under the generic resource type 0, not a dedicated type: find it by name, not by type.

The deep dive covers when each is written and read.

Note

This list is the resources you can rely on, not a closed inventory. The archive is built by sweeping an entire working directory wholesale, so an unrelated leftover file can occasionally ride along – a PC (UTC) resource in particular, a party-leader-swap artifact with nothing to do with saving. It’s covered as its own case on the deep dive so it doesn’t get mistaken for a fourth deliberate flat resource.

Templates versus snapshots

The same GIT schema is read two ways, chosen by a single UseTemplates BYTE in the GIT’s top-level struct:

UseTemplates = 1 (static .git)UseTemplates = 0 (savegame)
Object formsparse placementfull self-contained snapshot
TemplateResRefpresent; engine loads the blueprintabsent; no blueprint loaded
A missing field resolves tothe UTC/UTD/UTP/UTT blueprintthe engine’s hardcoded default

So a savegame object is the whole truth: the engine reads it directly, and a template lookup would supply data the engine never uses.

Object position and orientation fields

Position and orientation field names in the GIT depend on the object type, not the file. There are three position-naming styles and four orientation-naming styles; no single field name is shared across all object types:

ObjectPositionOrientation
Door, PlaceableX, Y, ZBearing (single angle)
Creature, Trigger, Waypoint, StoreXPosition, YPosition, ZPositionXOrientation, YOrientation, ZOrientation (vector)
Sound, EncounterXPosition, YPosition, ZPositionnone at object level
Area-of-effectPositionX, PositionY, PositionZOrientationX, OrientationY, OrientationZ (vector)

Naming and storage quirks:

  • Bearing is two things. A door stores its scalar verbatim; a placeable’s is derived from its orientation yaw at save time (lossy).
  • Geometry coordinate space differs by type. Trigger Geometry vertices (PointX/PointY/PointZ) are stored relative to the trigger position; encounter Geometry vertices (X/Y/Z) are absolute.
  • Trigger orientation re-bakes geometry. Supplying a trigger orientation re-rotates its geometry by the yaw delta on load.

See the Save Game Deep Dive for the full treatment.

Tip

Rust integration. The rakata-save crate is where this structure is modelled for parsing, validation, and writing without managing the ERF layer by hand. Check its Rustdocs for the current types: the crate is mid-refactor, so this page describes the on-disk format rather than a specific API.


Engine behaviour

A few behaviours span the whole save rather than any single sidecar. All are read from Ghidra decompilation of swkotor.exe (K1 GOG build); the Save Game Deep Dive lists the function addresses, and the Provenance Policy covers how engine evidence is gathered.

Character loading order

LoadCharacterFromIFO takes a Mod_PlayerList slot index and normally reads that slot from the active module’s Module IFO.

Important

0xffffffff is a mode selector, not a member index. It switches the load source to the transient pifo party-info file and reads back the slot each player recorded on itself when StorePlayerCharacters packed the party into pifo. A reader that treats it as a literal slot reads the wrong record.

This is the module-transition path. The party is staged to pifo on the way out of a module and restored on the way in, before the destination module’s roster exists, then rebuilt by CreateParty. pifo is a GFF tagged IFO carrying the same Mod_PlayerList of full creature snapshots as a module roster.

There is no separate .bic reader. A standalone Player record rides this same path, keyed by ObjectId.

Module inclusion

IncludeModuleInSave gates which visited modules are written into the bundle. It reads an INT entry from modulesave.2da for the module and excludes it only when that row exists and is 0. A missing row, or a modulesave.2da that cannot be loaded, includes the module (fail-open).

Orientation storage

Creatures, waypoints, and stores load their orientation as a full (X, Y, Z) vector. CSWSObject::SetOrientation stores those three floats verbatim on the object and flags it dirty; it does not reduce the vector to a yaw.

Area restoration

To rebuild the dynamic state of the room you were standing in (open or locked doors, moved placeables), the engine restores the area by targeting the GIT resource type (0x7e7) matched against the module’s core resref.

NFO Format (Save Metadata Block)

Description: The savenfo (savenfo.res) file is a save’s load-menu metadata block (type NFO ). It sits loose in the save folder so the load screen can show a name, area, play time, and portraits for every slot without opening SAVEGAME.sav, an archive that grows with every module you visit. The engine builds it field by field at save time.

At a Glance

PropertyValue
Filenamesavenfo.res
Magic SignatureNFO / V3.2
TypeSave Metadata Block
Rust ReferenceHandled by rakata-save (mid-refactor).

Data Model Structure

A flat GFF of scalar fields, no lists (autosaves add one nested struct). The Save type column marks which write path emits each field: both, manual (manual saves and quicksaves), or autosave.

FieldTypeMeaningSave type
SAVEGAMENAMECExoStringDisplay name of the save.manual
AREANAMECExoStringLocalized display name of the current area.both
LASTMODULECExoStringResref of the module the engine restores first.both
TIMEPLAYEDDWORDRunning play time, in seconds.both
CHEATUSEDBYTECheat flag; mirrors the party table’s cheat state.both
GAMEPLAYHINTBYTELoading-screen hint state.both
STORYHINTBYTELoading-screen hint state.both
LIVE1 .. LIVE6CExoStringDownloadable-content slot names (six); empty on a vanilla PC install.manual
LIVECONTENTBYTEBitmask of which of the six LIVE%d slots are installed; 0 when none.manual
PORTRAIT0 .. PORTRAITNCResRefOne portrait resref per active party member.both
PCAUTOSAVEBYTEAlways 1; its presence marks the file as an autosave.autosave
REBOOTAUTOSAVEBYTERead by the load menu’s slot parser, but no write site exists anywhere in this build; see the note below.none (dead on PC)
SCREENSHOTCExoStringLoading-screen resref (load_<module>) used as the slot preview, in place of a Screen.tga.autosave
AUTOSAVEPARAMSStructPending move-to-module state; see AUTOSAVEPARAMS Fields below.autosave

Engine Audits & Decompilation

(Documented from Ghidra decompilation of swkotor.exe (K1 GOG build). Manual-save writer: CServerExoAppInternal::StallEventSaveGame at 0x004b3110. The autosave variant is written by CServerExoAppInternal::DoPCAutosave at 0x004b8300.)

Note

savenfo quirks.

  • CHEATUSED is not tracked here independently. The engine writes the same cheat flag the party table serializes, so the menu shows the right state without opening the party table.
  • PORTRAIT0, PORTRAIT1, … is a numbered field series, not a GFF list: one field per active member, suffixed by index.
  • Downloadable (“live”) content always leaves a footprint. The manual/quicksave path writes all six LIVE1-LIVE6 name fields plus the LIVECONTENT bitmask unconditionally, whether or not content is installed. This is the Xbox Live download mechanism (LIVE%d aliases resolving to RIMSXBOX\live%d RIMs). A vanilla PC install has nothing aliased in, so a real quicksave carries all six LIVE%d as empty strings and LIVECONTENT = 0 (verified against a K1 GOG save). The Yavin Station DLC on PC is not delivered through this path.
  • REBOOTAUTOSAVE is read unconditionally by the slot parser and folded into the same bit-field as PCAUTOSAVE, but no code path in this build ever writes it – DoPCAutosave only sets PCAUTOSAVE. The read path is live regardless: the save-list preview code checks this bit together with PCAUTOSAVE when it decides where a slot’s screenshot comes from, so REBOOTAUTOSAVE just stays permanently 0. See the Save Game Deep Dive for what the field likely inherits from.
  • This is the one file read straight from the slot for the load menu, and LASTMODULE is what selects the first module to restore.

Important

Two write paths, two field sets. savenfo comes from StallEventSaveGame (manual saves and quicksaves) or DoPCAutosave (autosaves); the Save type column above marks which fields each produces. PCAUTOSAVE is the reliable discriminator. Autosaves also differ at the folder level (a loose pifo.ifo, no Screen.tga); the Save Game Deep Dive covers why.

AUTOSAVEPARAMS Fields

AUTOSAVEPARAMS snapshots the module transition that was in flight when the autosave fired, so the engine can resume it once the save is reloaded. Every field traces to a live piece of transition or world-clock state; none of it is autosave-invented data.

FieldTypeSource
LOADMUSICCExoStringThe destination module’s loadscreens.2da row, MusicResRef column. Falls back to a load_<modulename> resref if that cell is empty and a matching .mp3 exists, then to the 2DA row literally named DEFAULT if neither resolves. Same fallback chain (different column) as the ordinary loading-screen background picker, CClientExoApp::SetLoadScreenByModuleName.
STARTWAYPOINTCExoStringThe arrival waypoint tag passed to the StartNewModule script action by whatever door or trigger initiated the transition – where the player lands once the autosave resolves.
MOVIE1 .. MOVIE6CExoStringDrained from the pending movie queue, filled by calls to AddMoveToModuleMovie from the same StartNewModule action, one movie per slot. Unfilled slots write as empty strings. There’s no queue-overflow handling to speak of: StartNewModule’s own script signature only accepts six movie arguments, so a seventh movie never reaches the autosave writer in the first place.
TIME_YEARDWORDThe destination module’s start_year, fixed at module load rather than a running counter – this is why a real sample often reads 0.
TIME_MONTH / TIME_DAY / TIME_HOURBYTEThe module’s live calendar fields (current_month/current_day/current_hour), read through the module’s own time accessor.
TIME_MINUTE / TIME_SECOND / TIME_MILLISECONDWORDNot from the module’s calendar at all – derived by converting the world timer’s raw time-of-day tick count into minute/second/millisecond components. One snapshot, two different accessors: the module object supplies year/month/day/hour, the lower-level world timer supplies the rest.
TIME_PAUSEDAY / TIME_PAUSETIMEDWORDThe paused day/time halves of the same world timer’s tick-pair snapshot – the identical live clock that seeds the module IFO’s Mod_PauseDay/Mod_PauseTime on an ordinary module-save, just captured at a mid-transition autosave instead.
STATUSSUMMARYStructSee below.

STATUSSUMMARY is a “since you last saw a loading screen” delta accumulator, not a snapshot of current totals:

FieldTypeBehaviour
CREDITS / XP / STEALTHXPINTRunning totals added to by every credit/XP/stealth-XP gain since the popup last displayed.
CREDITSNETBYTESet when credits moved in both directions since the last display (gained and lost), distinguishing a net change from a one-way one.
LIGHTSHIFT / DARKSHIFTBYTEAlignment-shift deltas, same accumulate-then-drain pattern.
DISPLAYSPENDING / ITEMRECEIVED / ITEMLOST / JOURNALBYTEPending-event flags set by the corresponding gameplay hooks (item give/take, journal updates).
SOUNDPENDING / LEVELUPSOUND / NEWQUESTSOUND / COMPLETESOUNDBYTESelects which stinger, if any, plays alongside the popup.
SUPPRESSEDINTNot a display toggle – a countdown. While positive, incoming gameplay events (credits, items, journal updates, and anything else listed above) are dropped rather than accumulated, and the counter ticks down by one per dropped event. Driven by the SuppressStatusSummaryEntry script command, presumably so a scripted sequence of item grants doesn’t leave a stale or inflated delta behind.

The dispatcher (CGuiInGame::UpdateStatus) is the one place gameplay events get folded in; the drain point (CGuiInGame::ShowStatusSummary) zeroes the running totals and pending flags once the popup has been queued. Confirmed autosave-exclusive: StallEventSaveGame (the manual save/quicksave writer) never constructs this struct at all – there’s no in-flight transition for a manual save to preserve.

Implemented Linter Rules (Rakata-Lint)

None yet. Documented here ahead of any dedicated rakata-lint rules.

PT Format (Party Table)

Description: The partytable (PARTYTABLE.res) file is a live snapshot of the adventuring group plus a grab-bag of session state (type PT , two trailing spaces). It sits loose in the save folder, written at save time.

At a Glance

PropertyValue
FilenamePARTYTABLE.res
Magic SignaturePT / V3.2 (two trailing spaces in the tag)
TypeParty Table
Rust ReferenceHandled by rakata-save (mid-refactor).

Data Model Structure

The top-level struct groups party state into scalar flags plus several lists: the roster, available companions, the pazaak decks, the feedback and dialog logs, and the journal. Each group is a field table below.

Engine Audits & Decompilation

(Documented from Ghidra decompilation of swkotor.exe (K1 GOG build). Writer: CSWPartyTable::SaveTableInfo at 0x005648c0. The journal is written by CSWPartyTable::SaveJournal at 0x00563d90, which SaveTableInfo invokes.)

Resources and flags

FieldTypeMeaning
PT_GOLDDWORDParty gold. The authoritative value on load: party members’ own Gold fields are deliberately skipped in favour of this one (see Gold and the party pool).
PT_XP_POOLINTShared experience pool; benched companions are topped up toward their npc.2da PercentXP share of it when they rejoin.
PT_PLAYEDSECONDSDWORDRunning play time, in seconds.
PT_CHEAT_USEDBYTECheat flag; savenfo’s CHEATUSED carries the same value.
PT_SOLOMODEBYTESolo-mode flag.
PT_CONTROLLED_NPCINTCurrently controlled party member.

Roster

PT_NUM_MEMBERS (BYTE) plus PT_MEMBERS, a list with one struct per active member:

FieldTypeMeaning
PT_MEMBER_IDINTCompanion id (npc.2da row) of the member.
PT_IS_LEADERBYTEWhether this member is the party leader.

Available companions

PT_AVAIL_NPCS, a list with one struct per recruitable companion, indexed by npc.2da row (nine slots in K1):

FieldTypeMeaning
PT_NPC_AVAILBYTEWhether the companion has been unlocked.
PT_NPC_SELECTBYTEWhether the companion is selectable for the active party.

These flags are only the index. The actual creature snapshot for each recruited companion is a standalone AVAILNPCn UTC bundled inside SAVEGAME.sav, written at recruit time and refreshed at save time; PT_NPC_AVAIL gates whether the engine will instantiate it at all. See the companion pool in the deep dive.

Party AI

FieldTypeMeaning
PT_AISTATEINTParty combat-AI state.
PT_FOLLOWSTATEINTParty follow/formation state.

Galaxy map

FieldTypeMeaning
GlxyMapNumPntsDWORDNumber of known map points.
GlxyMapPlntMskDWORDPlanet unlock bitmask.
GlxyMapSelPntINTCurrently selected map point.

Pazaak

FieldTypeMeaning
PT_PAZAAKCARDSlistOwned-card counts: a fixed 18 elements, each { PT_PAZAAKCOUNT: INT } (one per card).
PT_PAZSIDELISTlistChosen side deck: a fixed 10 elements, each { PT_PAZSIDECARD: INT }.

Feedback and dialog logs

FieldTypeMeaning
PT_FB_MSG_LISTlistOn-screen feedback messages, each { PT_FB_MSG_MSG: CExoString, PT_FB_MSG_TYPE: DWORD, PT_FB_MSG_COLOR: BYTE }.
PT_DLG_MSG_LISTlistDialog message log, each { PT_DLG_MSG_SPKR: CExoString, PT_DLG_MSG_MSG: CExoString }.
PT_COST_MULT_LISTlistStore cost multipliers, each { PT_COST_MULT_VALUE: FLOAT }.

UI state

FieldTypeMeaning
PT_TUT_WND_SHOWNVOIDTutorial-window-shown flags (opaque byte blob).
PT_LAST_GUI_PNLINTLast GUI panel the player had open.

Journal

The journal is folded into the same file: JNL_SortOrder (INT) plus JNL_Entries, a list with one struct per active quest:

FieldTypeMeaning
JNL_PlotIDCExoStringQuest/plot identifier.
JNL_StateINTCurrent quest state.
JNL_DateDWORDIn-game date stamp.
JNL_TimeDWORDIn-game time stamp.

Note

partytable quirks.

  • The journal block is omitted entirely when the party journal is empty. A reader must treat a missing JNL_Entries as “no active quests”, not as malformed data.
  • PT_TUT_WND_SHOWN is a GFF VOID field (an opaque byte blob), not an integer.
  • PT_PAZAAKCARDS has a stowaway element: after the 18 INT card-count entries, the writer appends a 19th entry whose PT_PAZAAKCOUNT is a BYTE carrying the cheat-used flag, not a card count. The loader reads back only the first 18 (as INT) and ignores the rest, so that trailing byte never round-trips. Read 18 elements; ignore any trailing one. This is not a hidden store: the cheat flag’s real home is the top-level PT_CHEAT_USED field, which does round-trip. The trailing byte is a dead write (a BYTE among 18 INTs, sourced from the cheat-flag field), most likely leftover code.

Implemented Linter Rules (Rakata-Lint)

None yet. Documented here ahead of any dedicated rakata-lint rules.

GVT Format (Global Variable Table)

Description: The globalvars (GLOBALVARS.res) file is the campaign’s plot state (type GVT ): the flags and counters scripts read and set across the whole playthrough. It sits loose in the save folder, written at save time.

At a Glance

PropertyValue
FilenameGLOBALVARS.res
Magic SignatureGVT / V3.2
TypeGlobal Variable Table
Rust ReferenceHandled by rakata-save (mid-refactor).

Data Model Structure

Globals come in four types, one for each kind of variable a script can stash between sessions. Each type has its own get/set accessor (the engine’s GetValueBoolean / SetValueBoolean, and the Number, Location, and String variants), so what a type is for is what shapes how it is stored and how many of it the engine keeps room for:

TypeHoldsCapWhat scripts use it for
Booleana single bit900Plot switches and one-shot guards: has this happened? The most common kind of global.
Numbera single unsigned byte (0-255)500Small counters and quest-stage enumerations. It is a byte, not a 32-bit integer, so it cannot hold an arbitrary count.
Locationa position and orientation100A remembered spot to send, spawn, or move an object to later.
Stringa short text value5A handful of named text tokens; scripts rarely need one.

The caps are hard limits: any identifier past a type’s cap is dropped on load with a “won’t fit” log.

Each type pairs a catalogue list of names with a positional value block: the name of global i is Cat<Type> element i, and its value is position i of the matching Val<Type> block. The catalogue maps each value slot back to its name.

Catalogue (names)Value blockEncoding
CatBooleanValBooleanVOID, bit-packed. Boolean i is bit 7 - (i & 7) of byte i >> 3 (most-significant bit first). Block length is (count >> 3) + 1 bytes.
CatNumberValNumberVOID, one unsigned byte per number. Number i is byte i; values are 0-255.
CatLocationValLocationVOID, a fixed 2400-byte array of 100 slots of 24 bytes each. Location i is slot i; unused slots are zero, and the block is written whole.
CatStringValStringLIST, one struct per string carrying a String (CExoString).

Each Cat* element is a struct with a Name (CExoString). Because the Val* blocks are positional, dropping or reordering a catalogue entry silently reassigns every later value.

Warning

There are four global types, not two. A model that handles only CatNumber / CatBoolean silently drops every Location and String global. They are simple to miss, but they are real campaign state that has to round-trip.

Each 24-byte location slot is a CScriptLocation: a position Vector followed by an orientation Vector, with no area reference in K1.

FieldTypeMeaning
PositionVector (three float32, LE)The stored point (X, Y, Z), bytes 0x00-0x0b.
OrientationVector (three float32, LE)The stored facing (X, Y, Z), bytes 0x0c-0x17.

Engine Audits & Decompilation

Documented from Ghidra decompilation of swkotor.exe (K1 GOG build); see the Provenance Policy. The encoding, the per-type accessors, and the location layout are read from:

FunctionAddressCovers
CSWGlobalVariableTable::WriteTable0x005299b0Value-block encoding on write
CSWGlobalVariableTable::ReadTableWithCatalogue0x0052a280Encoding and per-type caps on read
CSWGlobalVariableTable::GetValueBoolean0x00529110Boolean value read (the script get)
CSWGlobalVariableTable::GetValueNumber0x00529240Number value read
CSWGlobalVariableTable::GetValueLocation0x00529350Location value read (slot copy)
CSWGlobalVariableTable::GetValueString0x00529460String value read
CSWSObject::GetScriptLocation0x004cb7b0Location field order (position then orientation)

Note

The script layer is not documented yet. These are the engine’s internal per-type accessors. The NCS/NSS-facing script functions (GetGlobalBoolean / SetGlobalBoolean and the Number / Location / String pairs) that call them are still to be mapped and documented.

Implemented Linter Rules (Rakata-Lint)

None yet. Documented here ahead of any dedicated rakata-lint rules.

Engine Internals

This section contains notes and breakdowns of the Odyssey engine’s execution pipelines, case studies on community tooling bugs, and other engine-level logic or behaviors that are discovered during clean-room reverse engineering. These notes partially serve as the foundational research powering rakata-lint.

Research Notes

TopicDescription
MDL & MDX Deep DiveDeep dive into the Ghidra decompilation notes detailing the exact byte-level layout of the binary MDL/MDX format and the engine loading pipeline.
GFF List CorruptionCase study analyzing out-of-bounds GFF list behavior in the Odyssey engine vs. loose community tooling abstractions.
Save Game Deep DiveGhidra notes on the save folder layout, the nested per-module archive model, and the per-object-type position/orientation field divergence in save GFFs.
Swoop & Turret Minigame Deep DiveGhidra notes on the MiniGame struct nested in ARE: the shared vehicle base behind the swoop-racing Player and Enemies, the nested weapon/Gun_Banks subsystem, and per-field defaults.

GFF List Index Corruption

Summary

A binary GFF writer can silently corrupt list mapping if it writes list index entries in a way that allows recursive nested-list writes to interleave with the parent list’s index block.

This is a compatibility-critical issue for KOTOR data because many resources depend on stable list ordering and correct struct index mapping.

How GFF Lists Work

In binary GFF, a List field stores:

  1. A relative offset into the list_indices table.
  2. At that offset:
    1. count (u32)
    2. count struct indices (u32 each), each pointing into the struct table.

If these indices are wrong, the parser will load the wrong list structs.

Failure Mode

The bug class occurs when a writer:

  1. Starts writing a parent list.
  2. Recursively builds child structs.
  3. Appends list indices directly while recursion is still producing nested list index data.

Because nested lists also write into the same list_indices buffer, parent and child index blocks can interleave and the parent list can point at unintended structs.

Observable Symptoms

  • Struct IDs in list entries change after roundtrip.
  • Expected fields are missing from entries after roundtrip.
  • Mod compatibility breaks for list-heavy resources due to reordered/remapped entries.

Correct Writer Strategy

For each list field:

  1. Write list count.
  2. Reserve contiguous slots for all struct indices up front.
  3. Build each child struct recursively.
  4. Backfill each reserved slot with the final struct index.

This guarantees parent list index layout is stable even when nested lists write their own index blocks.

Implementation Status

In this repository:

  • rakata-formats/src/gff/writer.rs reserves list index slots and backfills them.
  • Regression tests cover:
    • synthetic list order + struct-id stability
    • UTC fixture roundtrip stability on lists like FeatList, ItemList, ClassList.
  • rakata-generics/src/utc.rs includes a no-op rebuild test to ensure typed conversion does not drift list order/IDs.

The MDL/MDX Format

BioWare’s Aurora/Odyssey engine stores 3D models in a pair of files: .mdl and .mdx. This page documents what’s inside them, how the engine consumes them, and – occasionally – why they look the way they do. Evidence throughout is drawn from Ghidra decompilation of swkotor.exe (K1 GOG build), cross-checked against hex dumps of vanilla assets and community references (kotorblender, mdledit, mdlops, pykotor, reone, xoreos).

Overview

At a glance:

PropertyValue
Extensions.mdl, .mdx
MagicBinary: first u32 == 0. ASCII: text (filedependancy, newmodel, …)
TypeHierarchical scene graph + animation + vertex data
Resource type ID2002 (MDL), 3008 (MDX) in KEY/BIF
Rust referenceView rakata_formats::Mdl in Rustdocs

A model is a tree of nodes. Each node carries a transform (position + orientation), an animation track (“controllers”), and – depending on its type – geometry, light parameters, particle-emitter configuration, a skinning skeleton, a lightsaber blade, and so on. One MDL file can carry multiple named animations that operate on that tree.

One design choice explains the format’s surprising shape, so that comes first.

The core idea: load-and-fixup

The binary MDL is not a parsed format in the usual sense. The engine does not walk a byte stream field by field, calling read_u32, read_string, read_float. Instead, it does this:

  1. Allocate a buffer exactly the size of the model data.
  2. Copy the whole file into that buffer in one memcpy.
  3. Walk the now-in-memory structure and convert relative offsets into absolute pointers.

That’s it. The “parser” is a pointer rewriter. Every engine Reset* function (InputBinary::Reset, ResetMdlNode, ResetTriMeshParts, …) takes a buffer base pointer and a struct pointer, and its job is essentially struct->field += base for every relocatable pointer in the struct, recursing into children as it goes.

An analogy: think of IKEA instructions that say “screw part A into the hole next to part B” rather than giving exact millimetre coordinates. The instructions are valid anywhere you choose to assemble the furniture. The MDL blob is identical: every pointer is expressed relative to the blob’s origin, so the engine can drop the blob anywhere in memory and then do a one-time pass to convert those relative offsets to real addresses.

This design choice ripples through everything:

  • On-disk layout matches in-memory layout exactly. If a MdlNodeTriMesh is 412 bytes in RAM, it’s 412 bytes on disk. Struct field offsets you see in a Ghidra decompilation are the file offsets.
  • Binary files are architecture-bound. This format is a snapshot of a specific compiler’s struct layout on 32-bit Windows. Field alignment, pointer size (4 bytes), endianness (little), and even padding bytes all match that ABI.
  • “Parsing” is really validation + relocation. A Rust reader doesn’t need to convert a byte stream into a Rust struct; it needs to interpret a memory image as a struct overlay, following pointers to walk the tree.
  • The engine never writes binary MDL. The shipping engine only has code to emit ASCII MDL. Binary MDL is produced exclusively by BioWare’s model compiler (a build-time tool). The runtime reads it but never round-trips it.

With that frame in place, the rest of the format falls into shape.

File structure

The 12-byte wrapper

The file begins with a tiny header:

OffsetTypeFieldNotes
+0x00u32zero markerAlways 0. Used to tell binary from ASCII.
+0x04u32MDL content sizeBytes of model data that follow.
+0x08u32MDX file sizeSize of the accompanying .mdx file.

Input::Read at 0x004a14b0 is the dispatcher: it peeks at the first byte, and if it’s \0 the file is binary (the first u32 is always zero). Otherwise the file starts with ASCII tokens like filedependancy or newmodel, and processing hands off to a line-based interpreter.

For binary files, InputBinary::Read at 0x004a1260 does the rest:

  1. Record mdl_content_size and mdx_file_size from the wrapper.
  2. Allocate a heap buffer the size of the MDL content; memcpy the model data into it.
  3. If MDX size is non-zero, allocate a second buffer and memcpy the MDX file into it.
  4. Call Reset(mdl_buf, mdx_buf, resource_handle).

Note: the wrapper is not part of the model data. Byte 12 of the on-disk file is byte 0 of the in-memory MDL blob. All internal offsets are relative to the in-memory origin.

Three kinds of pointer

The MDL blob uses three distinct flavours of “pointer”. Keep them straight:

  1. MDL-relative offsets – the vast majority. Relocated to absolute pointers by Reset* functions. On re-serialization, they must be rewritten back to relative offsets.
  2. MDX-file byte offsets – used by a few fields (e.g. per-mesh mdx_data_offset at +0x144) to locate vertex data in the separate MDX file.
  3. String pointers – themselves MDL-relative, but pointing into a string table at the end of the blob, pointed to by the name-offsets array at model +0xB8.

Each mesh node carries two similarly named fields: mdx_data_offset at +0x144 (an MDX file offset) and vert_array_offset at +0x148 (a content-relative pointer to embedded position data). Conflating them produced one of the nastier bugs in this reader’s history (see War stories below).

Model header

Once the blob is in memory, InputBinary::Reset at 0x004a1030 walks the model header. Here’s the relevant field map:

OffsetFieldNotes
+0x00ModelDestructor vptrPopulated at load time.
+0x04ModelParseField vptrPopulated at load time.
+0x28root node offsetRelocated. ResetMdlNode recurses from here.
+0x48resource handlePopulated at load time.
+0x4Ctype byte`GetType()
+0x50classification0=Other, 1=Effect, 2=Tile, 4=Character, 8=Door.
+0x54ref count
+0x58animations array ptrRelocated; count at +0x5C.
+0x64supermodel pointerPopulated via FindModel(buf+0x88).
+0x68..+0x80bbox min/maxVector bmin, bmax.
+0x80radiusf32, default 7.0.
+0x84animation scalef32, default 1.0. ASCII: setanimationscale.
+0x88supermodel namechar[36], null-terminated. Drives recursive model load.
+0xA8node array (secondary)Relocated if non-zero.
+0xACMDX vertex pool offsetSource offset into MDX data (consumed into a GL pool).
+0xB0MDX data sizeSize of the vertex-pool copy.
+0xB8name offsets array ptrRelocated; count at +0xBC. Array entries also relocated.

Two fields deserve special mention:

  • +0x50 classification is the model’s high-level category (Character, Door, Tile, …). It’s never read during the Reset pass – it’s carried through as part of the memory-mapped blob and consulted at runtime. Cross-validated against hex dumps:

    File+0x50Category
    c_dewback.mdl0x04Character ✓
    dor_lhr01.mdl0x08Door ✓
    m01aa_01a.mdl0x00Other ✓
  • +0x88 supermodel name is a 32-byte (plus 4 padding) ASCII name. Loading a model with a supermodel triggers a recursive FindModel call for that name – think of supermodels as CSS-style inheritance, where animation data and bones defined on the parent are available to the child.

The node tree

The root node sits at model +0x28. From there, children are reached through a standard in-memory array layout: ptr + count_used + count_allocated at offsets +0x2C, +0x30, +0x34. This three-u32 pattern is BioWare’s CExoArrayList and shows up everywhere in the format – any time you see “12 bytes of array header”, this is what it is.

Base node layout (80 bytes)

All node types begin with the same 80-byte header:

OffsetSizeFieldNotes
+0x00u16node_typeFlag bitmask. Drives type dispatch.
+0x02u16node_idSequential 0..N-1.
+0x04u16node_id_dupIdentical copy of node_id. Never read.
+0x06u16paddingAlways zero.
+0x08u32name pointerRelocated. Points into the string table.
+0x0Cu32parent pointerRelocated if non-zero.
+0x1012positionVector{x, y, z} as 3×f32.
+0x1C16orientationQuaternion{w, x, y, z} as 4×f32.
+0x2C12children arrayCExoArrayList of MdlNode*.
+0x3812controller keys arrayCExoArrayList of NewController (16B each).
+0x4412controller data arrayCExoArrayList of float (packed key data).

The two bytes at +0x04 are a redundant duplicate of node_id – identical to +0x02 across 209 nodes in four vanilla files, zero mismatches. No known engine function reads it. Best guess: legacy field or exporter artifact. It’s preserved for round-trip fidelity but has no semantic meaning.

A few conventions worth noting:

  • Quaternion order is (w, x, y, z). Confirmed via Gob::GetOrientation at 0x004499a0 which copies fields in that order. Identity quaternion is [1.0, 0.0, 0.0, 0.0]. The Rust API uses the same convention.
  • Position and orientation are read directly from the blob. They’re not relocated – they’re inline values, not pointers.
  • Only two fields need relocation in the base header: name pointer at +0x08 and parent pointer at +0x0C.

InputBinary::ResetMdlNodeParts at 0x004a0b60 handles the base relocations and then recurses: for each entry in the children array, relocate the child pointer and call ResetMdlNode on it.

Type dispatch

InputBinary::ResetMdlNode at 0x004a0900 reads the node_type field and dispatches:

node_typeHandlerKind
0x0001ResetMdlNodeParts onlyDummy / base
0x0003ResetLightLight
0x0005ResetMdlNodeParts onlyEmitter
0x0009ResetMdlNodeParts onlyCamera
0x0011ResetMdlNodeParts onlyReference
0x0021ResetTriMeshResetTriMeshPartsTriMesh
0x0061ResetSkinSkin mesh
0x00A1ResetAnimAnimMesh
0x0121ResetDanglyDangly mesh (cloth)
0x0221ResetAABBTree + ResetTriMeshPartsWalkmesh with AABB
0x0401(no-op)Trigger / unused
0x0821ResetLightsaberSaber mesh

The type values are stored as a lookup table in the executable at 0x00740a18 (12 × u32).

Though the type codes are shaped like a bitmask – HEADER=0x01, LIGHT=0x02|HEADER, EMITTER=0x04|HEADER, TRIMESH=0x20|HEADER, SKIN=0x40|TRIMESH, SABER=0x800|TRIMESH, and so on – the dispatch is an exact value match, not individual bit checks. The bitmask structure is meaningful (skin is a superset of trimesh, for instance), it’s just not how the engine branches.

Size summary

Every node type has a known fixed size, both on disk and in memory:

FlagTypeTotalBaseExtraExtends
0x0001Base80800
0x0003Light1728092MdlNode
0x0005Emitter30480224MdlNode
0x0009Camera80800MdlNode
0x0011Reference1168036MdlNode
0x0021TriMesh41280332MdlNode
0x0061Skin512412100TriMesh
0x00A1AnimMesh46841256TriMesh
0x0121Dangly44041228TriMesh
0x0221AABB4164124TriMesh
0x0401Trigger80800MdlNode
0x0821Saber43241220TriMesh

Verified via ParseNode’s operator_new(size) calls and Ghidra struct definitions. All mesh subtypes extend MdlNodeTriMesh – their extra data begins at node offset +0x19C, immediately after the TriMesh block.

Node types in depth

The lightweight types

Camera (0x009) has no extra data. Same 80-byte footprint as the base node. ResetMdlNode dispatches to ResetMdlNodeParts only. There are no camera-specific ASCII fields either – the ASCII parser also falls through to the base handler.

Reference (0x011) carries just two fields in 36 extra bytes: a 32-byte ref_model name and a 4-byte reattachable flag. Both inline (no pointers to relocate).

Trigger (0x401) – the decompiled ResetMdlNode explicitly returns void without calling any reset function for this type. In practice it appears to be unused in shipping content.

Light (0x003)

Lights carry 92 bytes of extra data. Most of the scalar fields are straightforward (priority, shadow flag, ambient-only flag, flare radius, etc.), but lights are the most complex non-mesh type because of their array fields:

Extra offsetFieldLayoutRuntime relocation
+0x04texture SafePointers12-byte array headerZeroed on disk
+0x10flaresizesCExoArrayList<float>ptr relocated
+0x1CflarepositionsCExoArrayList<float>ptr relocated
+0x28flarecolorshiftsCExoArrayList<Vector>ptr relocated
+0x34texturenamesCExoArrayList<char*> (each ptr too!)all ptrs relocated

Lights also drive their colour, radius, shadow radius, vertical displacement, and multiplier via controllers (types 0x4C, 0x58, 0x60, 0x64, 0x8C) – these live in the base node’s controller arrays, not in the light-specific block.

Emitter (0x005)

Emitters are 304 bytes and – pleasantly – contain no relocatable pointers. Everything is inline: a fistful of floats and ints, four 32-byte name fields (update, render, blend, texture), and a 16-byte chunk_name. The full field map is in the appendix.

The most important field is update at extra offset +0x20. It’s the emitter type string, a case-sensitive selector against:

  • "Fountain" → steady particle stream (most common)
  • "Explosion" → one-shot burst
  • "Single" → single particle
  • "Lightning" → lightning-bolt effect

MdlNodeEmitter::InternalCreateInstance at 0x0049d5c0 branches on this string to instantiate the appropriate runtime emitter class.

Warning

Engine-level footgun. Controller 502 (detonate) is only valid on "Explosion" emitters. InternalCreateInstance only allocates the detonation memory for that branch, so a detonate controller on a "Fountain" emitter reads unallocated memory at runtime and crashes. This is a known flaw in mdlops-based exporters (KotorMax); rakata-lint will validate this.

TriMesh (0x021)

This is the big one. 332 bytes of extra data, encoding everything you’d expect in a mesh plus many things you wouldn’t.

Inline fields

At a high level:

  • Runtime function pointers (+0x00, +0x04): written by the constructor. Zero on disk; never consumed from a file.
  • Faces array (+0x08): CExoArrayList of MaxFace (32 bytes each). See Face layout below.
  • Bounding volumes (+0x14..+0x38): bbox min, bbox max, bounding sphere (radius + centre xyz). The sphere is the one actually consumed at runtime – PartTriMesh::GetMinimumSphere hierarchically unions it with children’s spheres for culling. These sphere fields have no ASCII-parser equivalent; they’re exclusively binary-format fields written by the BioWare toolset.
  • Material (+0x3C..+0x54): diffuse RGB, ambient RGB, transparencyhint.
  • Textures (+0x58..+0x98): texture_0 (primary/diffuse) and texture_1 (secondary/lightmap), each a 32-byte null-terminated string, plus 32 bytes of padding up to +0xE8.
  • UV animation (+0xEC..+0xF8): uv_direction_x, uv_direction_y, uv_jitter, uv_jitter_speed. Gated by animate_uv (+0xE8).
  • MDX vertex layout (+0x100..+0x12F): flags bitmask plus 11 per-attribute byte offsets. Described in the next subsection.
  • Counts and flags (+0x130..+0x13B): vertex_count (u16), texture_channel_count (u16), six 1-byte booleans (light_mapped, rotate_texture, is_background_geometry, shadow, beaming, render).
  • Tail (+0x13C..+0x14B): total_surface_area, one unresolved reserved slot, mdx_data_offset, vertex_data_ptr.

Out of 332 bytes, 61 fields are fully confirmed through Ghidra cross-referencing, 5 are confirmed-unused, 1 is “very likely” (the always-3 indices_per_face), and exactly 1 remains unresolved (the 4 bytes at +0x140, which the constructor initializes to zero and no known function ever touches).

MDX vertex layout

The flags field at extra +0x100 is a bitmask describing what each MDX vertex record contains:

BitComponentSize
0x01position3×f32 (12B) – always set
0x02UV1 / tverts02×f32 (8B)
0x04UV2 / tverts12×f32 (8B)
0x08UV3 / tverts22×f32 (8B)
0x10UV4 / tverts32×f32 (8B)
0x20normal3×f32 (12B) – always set
0x80tangent space3×3×f32 (36B) – bump-mapped meshes

Common patterns in vanilla K1: 0x21 (pos+norm only, 24B stride), 0x23 (+UV1, 32B), 0x27 (+UV2, 40B), 0xA7 (+tangent, 76B).

Note that vertex colours have no flag bit. Their presence is signalled by the per-attribute offset slot being != -1. The 11 offset slots are:

SlotExtra offsetFieldEvidence
0+0x104positionLightPartTriMesh reads 3×f32, world-transforms
1+0x108normalLightPartTriMesh reads 3×f32, rotation only
2+0x10Cvertex colorChecked != -1, reads RGB only. Alpha unused.
3+0x110UV1PartTriMesh reads 2×f32
4+0x114UV2Structural: tverts1 in InternalGenVertices
5+0x118UV3Structural: tverts2
6+0x11CUV4Structural: tverts3
7+0x120tangent spaceFilled by CalculateTangentSpaceBasis
8–10+0x124..+0x12CreservedAlways -1 across 215 surveyed vanilla meshes

Note

Vertex colour alpha is unused (confirmed 2026-04-04). LightPartTriMesh reads only bytes [0], [1], [2] (RGB). Byte [3] is stored but never read. The rendered output hardcodes alpha to 0xFF. The fourth byte exists purely for alignment.

Note

The engine doesn’t trust any of these values on load. InternalPostProcess at 0x0043cf00 recomputes the flags, stride, per-attribute offsets, and mdx_data_offset from scratch, based on which vertex components are actually present in the node’s arrays. It also recomputes vertex normals via edge cross products, and re-derives the bounding box and sphere. The on-disk values preserve the compiler’s original output, but they’re cosmetic from the engine’s perspective.

This has a consequence for tooling: you can largely get away with wrong values in these fields as long as your mesh is otherwise valid, because the engine will fix them up at load time. But a correct writer should still populate them – community tools (kotorblender, mdledit) depend on them, and the BioWare build pipeline does too.

Skin mesh (0x061)

100 extra bytes beyond TriMesh. Skinning data (bone weights, inverse-bind-pose rotation and translation, bone-index mapping) sits here, along with several padding regions:

Skin offsetFieldLayoutNotes
+0x00weightsCExoArrayList<SkinVertexWeight>Always zero in binary files.
+0x14bone_weight_dataptrRelocated if count at +0x18 > 0.
+0x1Cqbone_ref_invCExoArrayList<Quaternion>Inverse-bind rotations.
+0x28tbone_ref_invCExoArrayList<Vector>Inverse-bind translations.
+0x34bone_constant_indicesCExoArrayList<int>Bone-index remap.

The weights array deserves a call-out. A 52-byte SkinVertexWeight struct exists and is fully specified by the ASCII parser – 4 bone names, 4 weights, some metadata – but in the binary path, ResetSkin never relocates its pointer, and a corpus scan of all 968 skin nodes across 2832 vanilla models found zero non-empty weights arrays. Binary models store per-vertex bone data exclusively in MDX (via dedicated bone-weight and bone-index offsets), and the weights CExoArray is just a 12-byte zero blob on disk.

AnimMesh (0x0A1)

56 extra bytes. Carries a sample_period scalar and two CExoArrayList fields (anim_verts, anim_t_verts) for time-sampled vertex animation. The remaining six fields (three pointers + three counts + some padding) are runtime-only and zero on disk. No community tool (kotorblender, mdledit, kotormax, reone, xoreos, pykotor) parses AnimMesh nodes – this may be the first structured reader for the type.

One peculiarity: ResetAnim processes the extra data before calling ResetTriMeshParts, the reverse of every other mesh subtype. The reason is not obvious.

Dangly mesh (0x121)

The simplest mesh subtype, 28 extra bytes. Four fields: a per-vertex constraints CExoArrayList, and three inline floats (displacement, tightness, period) that parameterize the soft-body simulation. A single conditional pointer at the tail is relocated only when the TriMesh vertex count is non-zero.

Dangly meshes are BioWare’s hack for cloth and hair – rigged to the skeleton like a skin mesh, but with simulation parameters that let parts of the geometry lag and swing.

AABB walkmesh (0x221)

4 extra bytes: a single pointer to the root of an AABB tree stored inline in the MDL blob.

The AABB tree is a flattened binary search tree written in DFS preorder. Each node is 40 bytes:

OffsetSizeFieldNotes
+0x0012box_min3×f32 AABB minimum corner
+0x0C12box_max3×f32 AABB maximum corner
+0x184right_childContent-relative offset (0 = no child)
+0x1C4left_childContent-relative offset (0 = no child)
+0x204face_indexi32. Leaves: ≥ 0. Internal: −1.
+0x244split_direction_flagsAxis bitmask: 1=+X, 2=+Y, 4=+Z, 8=−X, 16=−Y, 32=−Z

Note that right_child comes before left_child in the struct – this is the actual field order, not a typo. Matches Ghidra and the mdledit/mdlops implementations.

Leaf nodes have left = 0, right = 0, face_index ≥ 0, split_direction_flags = 0. Internal nodes have both children non-zero, face_index = -1, and flags computed from the child bounding-box separation. The format is the classic spatial subdivision tree used for fast triangle lookups during pathfinding and collision queries.

ResetAABBTree at 0x004a0260 recurses the tree, relocating each child pointer. It manually unrolls to depth 4 before recursing (the engine’s author was clearly worried about stack depth on a modest C++ compiler).

Lightsaber (0x821)

20 extra bytes – small but architecturally notable:

Saber offsetFieldNotes
+0x00saber vert dataRelocated pointer
+0x04saber UV dataRelocated pointer
+0x08saber normal dataRelocated pointer
+0x0CGL vertex pool IDRuntime-only (set by RequestPool)
+0x10GL index pool IDRuntime-only

Three arrays of exactly 176 vertices each (NUM_SABER_VERTS = 176, confirmed by kotorblender): position, UV, normal. The saber blade is a fixed-topology mesh – BioWare pre-baked the geometry as a flexible band that can be animated by swinging the endpoint controllers.

Unlike Skin/Dangly/AnimMesh, the saber uses the base TriMesh gen_vertices and remove_temporary_array callbacks. Its geometry doesn’t morph dynamically at the vertex-processing level – the animation is in the controller track.

Controllers and animation

The controller header

Controllers are the keyframe-animation primitive. Each node has an array of 16-byte NewController headers (at node +0x38) plus a shared pool of float data (at +0x44). Each header describes one animatable property of that node:

OffsetSizeFieldNotes
+0x00u32type_codeByte offset of the target property in the Part struct.
+0x04i16supermodel_linkAdditive-blending property offset; -1 = no blending.
+0x06u16row_countNumber of keyframes.
+0x08u16time_data_offsetFloat-array index for time values.
+0x0Au16data_offsetFloat-array index for value data.
+0x0Cu8value_type_and_flagsLow nibble: 1=float, 2/4=quaternion, 3=vector. Bit 4=0x10=Bezier.
+0x0D3paddingAlignment to 16 bytes. Never read.

The type_code is not an enum: it is literally the byte offset into the Part struct where the animated value lives. NewController::Control dereferences it as *(float*)(part_ptr + type_code). So type_code = 8 means “position” because position sits at Part+0x08; type_code = 20 means “orientation” because orientation sits at Part+0x14 (as a compressed axis-angle quaternion); and so on. This collapses what would otherwise be a switch over property IDs into direct pointer arithmetic.

The value_type_and_flags byte at +0x0C has a compound encoding that is easy to misread:

  • Low nibble (& 0x0F) – value-type discriminator: 1=float, 2 or 4=quaternion, 3=vector. Selects the interpolation path (Lerp/Slerp/VectorLerp).
  • High nibble (& 0xF0) – flags. 0x10 signals Bezier interpolation, which triples the per-keyframe value count (each keyframe is value + in-tangent + out-tangent).
  • Special case: for orientation controllers (type code 20) with raw byte value == 2, the keyframe is a compressed quaternion packed into a single u32, not two f32 values.

The low nibble happens to coincide with the “number of floats per keyframe row” for simple cases (1, 3, 4), which is why the earlier interpretation of this byte as column_count mostly worked – until it didn’t. See the controller bug below.

Self-describing rows

Because value_type_and_flags is inline in each controller header, the binary format is entirely self-describing for animation data. The reader doesn’t need a lookup table mapping type codes to column counts – it reads the flags byte and knows how many floats to consume per row.

This is useful because vanilla K1 contains controller type codes (0x68, 0x188) that aren’t documented in any community reference. Trying to parse these with a closed enum caused 517 of 2832 vanilla MDLs (18.3%) to fail. MdlControllerType is therefore a newtype struct MdlControllerType(u32) with named constants for the three universally-confirmed base types (POSITION = 8, ORIENTATION = 20, SCALE = 36) and accepts any other u32 losslessly.

Base vs type-specific controllers

Three controllers are universal – they exist on every node type:

ASCII nameCodeColumnsMeaning
position83x, y, z
orientation204x, y, z, angle (compressed axis-angle)
scale361uniform scale factor

Type-specific codes live at higher numbers: light controllers start at 76 (color), emitter controllers are at 88+. All three base codes also support a Bezier variant (signalled by the flag bit, not a separate type code).

The MDX file: a mystery

Now for the strangest part of the format.

The MDX file contains interleaved vertex data – positions, normals, UVs, tangent space, colours – packed into records of width given by the mesh vertex_stride field, aligned into per-mesh blocks with sentinel-float terminators separating them. It looks exactly like what you’d expect a GPU vertex buffer to look like.

And the K1 engine never reads it.

Here’s the complete trace through InputBinary::Read:

  1. Read the MDX file into a buffer.
  2. Call Reset(mdl_content, mdx_content, resource).
  3. Reset passes the MDX pointer as a third parameter through the whole reset chain (ResetMdlNode, ResetTriMeshParts, …). Every downstream function accepts it.
  4. No function ever reads it. ResetTriMeshParts even overwrites its copy to use as a loop counter.
  5. Back in InputBinary::Read, the MDX buffer is freed.

At no point does any vertex-related code path consume MDX data. InternalGenVertices builds vertex buffers from verts_arrays, which lives in the MDL content blob. ProcessVerts recomputes normals from geometry. LightPartTriMesh reads from the GL pool populated at +0xAC of the model header – which is sourced from the MDL content, not the MDX file.

So where does the vertex data actually come from? From a parallel set of position-only arrays stored inside the MDL content blob, pointed to by vert_array_offset at mesh +0x148 (content-relative), with additional UV/colour/normal data in the MdlNodeTriMeshVertArrays structures.

The MDX file, in short, is a redundant interleaved copy of data that the K1 engine could reconstruct from the MDL alone. Most likely theories for why it exists:

  • Build-pipeline artifact. BioWare’s Aurora engine (Neverwinter Nights) may have used the MDX format directly, and the K1 pipeline inherited the file-layout convention without the consuming code path.
  • Toolset requirement. Third-party editors and the BioWare toolset itself may still parse MDX for authoring workflows.
  • ResetLite path. There’s a separate “lightweight” loader (InputBinary::ResetLite at 0x004a11b0) that may use MDX for a reduced in-memory representation – unverified.

For Rakata, this has two consequences:

  1. Engine-functional MDX is near-trivial. Any MDX file the K1 engine happily ignores is a valid MDX file. You could write all zeros and the game would run.
  2. Round-trip-accurate MDX requires the per-mesh terminator convention (described next), because community tools do read MDX, and byte-identical round-trip is a useful correctness check.

Per-mesh terminators and alignment

Empirically, vanilla MDX files are larger than sum(vertex_count × stride). Across 2832 vanilla K1 models, 2445 have MDX files with excess bytes, totalling 3,278,456 bytes corpus-wide.

The excess has structure. After each mesh’s vertex data, there’s a terminator row of exactly one stride’s worth of bytes, beginning with three sentinel floats and padded with zeros:

Mesh typeSentinel valueHex (f32 LE)
Non-skin (type & 0x40 == 0)10,000,000.000 96 18 4B
Skin (type & 0x40 != 0)1,000,000.000 24 74 49

Corpus sentinel detection: 6,973 non-skin sentinels, 6 skin sentinels, 0 unknown patterns.

Between meshes, the cursor is padded to the next 16-byte boundary. The last mesh has no trailing alignment:

cursor = 0
for each mesh in MDX order:
    cursor += vertex_count × stride   # vertex data
    cursor += stride                   # terminator row
    if not last mesh:
        cursor = (cursor + 15) & ~15   # 16-byte alignment
mdx_file_size = cursor

For stride-24 meshes, the gap between meshes is either 24 or 32 bytes depending on current alignment. For stride-32 and stride-64 meshes, it’s always exactly stride because the stride is already a multiple of 16.

Mesh ordering in MDX

Non-skin meshes come first, then skin meshes. Within each group, the order is DFS-traversal-of-the-tree – mostly. About 27% of vanilla models exhibit a compiler-specific permutation that defers “second children” of paired parents until after all their siblings’ first children. This is reproducible for our own output (if we write DFS, we read DFS), but not for byte-identical round-trip of every BioWare file.

Writing in standard DFS order (non-skin first, skin second) produces semantically identical MDX data with the correct total size. 1784 of 2444 models match byte-for-byte; the remaining 660 have the non-standard compiler ordering.

What this means for mdx_data_offset

The mesh header has two adjacent u32 fields at +0x144 and +0x148:

  • +0x144 mdx_data_offset: per-mesh byte offset into the MDX file. Used by community tools to seek directly to that mesh’s vertex block. The engine also uses this after InternalPostProcess overwrites it with a GL-pool offset.
  • +0x148 vert_array_offset: content-relative pointer to the position-only vertex data embedded in the MDL content blob. Used by the engine during load. ResetTriMeshParts relocates it by adding the MDL content base – not the MDX base – to the stored offset.

An earlier revision of this implementation conflated the two fields under a single MDX_OFFSET = 0x148 constant for several months, which caused the reader to lose the MDX offset entirely and the writer to overwrite the content pointer with an MDX offset. Full story in War stories.

Face layout

Faces are 32-byte records (MaxFace) stored in the TriMesh faces CExoArray:

OffsetSizeFieldTypeNotes
+0x0012plane_normal3×f32Face plane normal.
+0x0C4plane_distancef32Plane equation: n·p = d.
+0x104surface_idu32Walkability / material identifier.
+0x146adjacent3×u16Indices of adjacent faces (for AABB/pathfinding).
+0x1A6vertex_indices3×u16Triangle vertex indices.

The plane normal and distance are pre-computed by the BioWare toolset. They can be re-derived from the geometry but the binary format preserves them. The adjacency graph is what makes AABB walkmesh lookups fast – each triangle points to its neighbours, enabling constant-time stepping during pathfinding.

An early version of our reader assumed 12-byte faces (just the vertex indices). This led to every 2.67th “face” being interpreted from garbage bytes belonging to the next face’s plane normal. It was masked by synthetic round-trip tests – write wrong, read wrong, match! – and only caught when vanilla-file validation found vertex indices exceeding the mesh’s vertex count.

War stories and implementation history

A brief chronicle of the bugs found while building the Rust reader/writer, because the “how we know this” is often as useful as the “what we know”.

The 12-byte face bug

Described above. The MaxFace stride is 32 bytes, not 12. Caught by vertex-index bounds checking against vanilla files.

Mesh header size corrections

The whole mesh extra-header was misunderstood for a long time. A sample of the corrections, all fixed in late February 2026:

  • VERTEX_COUNT offset was 0x9E → actually 0x130
  • MDX_OFFSET was 0xB8 → actually two separate fields at 0x144 and 0x148
  • VERTEX_STRUCT_SIZE was 0xBC → actually 0xFC
  • MESH_EXTRA_SIZE was 200 bytes → actually 332 (0x14C)
  • RENDER boolean was missing entirely → added at 0x139
  • SHADOW boolean was missing entirely → added at 0x137

All of these stemmed from extrapolating offsets from partial hex dumps rather than decompiling the struct. Ghidra’s MdlNodeTriMesh struct definition settled the whole thing – once the Ghidra type was aligned, the field offsets fell out directly.

Controller column-count encoding

Our reader initially used the raw value_type_and_flags byte (at controller +0x0C) directly as a float count per row. This worked for the common case (position=3, orientation=4, scale=1) but broke in two scenarios:

  • Bezier controllers set bit 0x10, turning raw=3 (Bezier position) into a byte value of 0x13 = 19 columns, not 9.
  • Integral orientation: ORIENTATION controllers with raw byte == 2 mean “compressed quaternion packed into one u32 per row”, not “2 f32 values per row”.

The integral-orientation case was the more painful bug: a c_dewback scan showed 876 integral-orientation controllers; c_rancor had 1,212. Reading 2 floats instead of 1 consumed double the expected data, desynchronizing every subsequent controller in the data array. Every node’s animation after the first compressed-quaternion keyframe was reading from a shifted window of garbage.

Fix: decode the raw byte with & 0x0F masking plus the two special cases (Bezier multiplies by 3; integral orientation uses 1 u32 per row regardless). The raw byte is preserved in a raw_column_count field for round-trip fidelity.

Animation node_number at +0x02

The 80-byte node header’s first 8 bytes are type_flags (u16), node_number (u16), name_index (u16), padding (u16). Our offset map had NODE_ID = 0x04, which pointed to name_index, not node_number.

For animation nodes specifically, node_number is the engine’s key for matching animation keyframe nodes to their geometry-side skeleton bones. Writing zeros at +0x02 and stuffing the name_index at +0x04 meant every animation node had node_number = 0, so every keyframe targeted the root bone. Visually: characters froze in T-pose with no skeletal motion whatsoever.

Fix: read node_number from +0x02 explicitly; derive name_index from the name map at +0x04.

MDX per-mesh seeking

Our MDX reader used a cumulative cursor assuming non-skin-first DFS ordering. For the ~51% of vanilla models where MDX layout doesn’t match that assumption, vertex data was assigned to the wrong mesh nodes. Self-round-trip tests couldn’t detect this – we were reading and writing the same wrong assignment, which is a consistency check for the tool’s own output, not for correctness against vanilla.

Fix: seek to info.mdx_data_offset (the +0x144 field) for each mesh, matching kotorblender and mdledit behaviour. The cumulative-cursor logic remains in the writer, which produces its own layout and backpatches the offset field; the reader trusts whatever the file says.

Name-table dead entries

220 vanilla K1 models have name tables containing entries that no node references. These turn out to be walkmesh node names (*_wok, *_pwk, *_dwk variants) from BioWare’s build pipeline, which apparently shared a single name table across the MDL and WOK outputs.

The engine only performs indexed lookups via name_index; it never iterates the full table or validates the count. Extra entries are harmless dead weight.

Decision: not preserved. Our writer builds the name table from the node tree (matching kotorblender and mdledit), producing files that are functionally identical but 20–80 bytes shorter. This is a known, benign size delta – not a parity bug.

Emitter controller code verification

All 48 emitter controller type codes were independently verified against the engine binary via Ghidra. For each, we located the __stricmp call for the ASCII field name and traced the controller type value stored on match. Every code matched mdledit’s ReturnControllerName table exactly – no additions, no omissions.

One naming correction: the engine’s canonical string for code 200 is "lightningZigzag" (camelCase Z). mdledit has "lightningzigzag" (all lowercase). Functionally identical because the engine uses __stricmp (case-insensitive), but the engine’s capitalization is now what we emit.

Corpus validation status

As of 2026-02-24: 2832/2832 (100%) structural round-trip success (parse → write → parse → compare). This was achieved after fixing three comparison issues in the test harness:

  1. NaN ≠ NaN (IEEE 754): 1559 false failures – floats containing NaN don’t equal themselves. Fixed with bitwise f32::to_bits() comparison.
  2. Parent index ordering: 135 mismatches from depth-first vs. original node ordering. The binary format preserves node ordering but our parent-index reconstruction uses DFS. Semantically equivalent, numerically different – skipped in comparison.
  3. Face NaN values: exactly one model (w_dblsbr_001) has NaN in its pre-computed plane_normal/plane_distance, because one of its faces is degenerate. Round-trips correctly once NaN-aware comparison is used.

Byte-level MDL/MDX equality is a separate target – 1784 of 2444 MDX files match byte-for-byte, with the remaining 660 showing the non-standard BioWare compiler traversal discussed earlier.

Appendix

Emitter field map

304 bytes total (80 base + 224 extra). Emitter-specific data:

Node offsetExtra offsetFieldType
+0x50+0x00deadspacef32
+0x54+0x04blast_radiusf32
+0x58+0x08blast_lengthf32
+0x5C+0x0Cnum_branchesi32
+0x60+0x10control_pt_smoothingi32
+0x64+0x14x_gridi32
+0x68+0x18y_gridi32
+0x6C+0x1Cspawn_typei32
+0x70+0x20updatechar[32]
+0x90+0x40renderchar[32]
+0xB0+0x60blendchar[32]
+0xD0+0x80texturechar[32]
+0xF0+0xA0chunk_namechar[16]
+0x100+0xB0two_sided_texi32
+0x104+0xB4loopi32
+0x108+0xB8render_orderu16
+0x10A+0xBAframe_blendingu8
+0x10B+0xBBdepth_texture_namechar[16]
+0x11B+0xCB(reserved)21 bytes

LOD naming convention

When a model has cullWithLOD set, the engine searches for LOD variants by appending suffixes to the model name:

  • <name>_x – medium LOD
  • <name>_z – far LOD

Loaded via FindModel(name + "_x") and FindModel(name + "_z") as separate Model instances linked to the primary. Not relevant to format parsing, but useful for model validation and lint rules.

Resource type IDs

FormatResource type
MDL2002 (0x7D2)
MDX3008 (0xBC0)

These map to the KEY/BIF resource type system. CAuroraInterface::RequestModel at 0x0070d8d0 resolves models through a sorted requestedModelList.

Dynamic type casts

The engine exposes As* functions for type-checked downcasts. Caller counts indicate runtime usage frequency:

FunctionCallers
AsModel34
AsMdlNodeTriMesh14
AsMdlNodeEmitter11
AsAnimation7
AsMdlNodeLightsaber5
AsMdlNodeSkin4
AsMdlNodeAABB3
AsMdlNodeDanglyMesh3
AsMdlNodeLight3
AsMdlNodeAnimMesh2
AsMdlNodeCamera2
AsMdlNodeReference2

TriMesh (14) and Emitter (11) are the most-queried node types – useful signal for prioritizing implementation completeness.

Binary MDL call graph

For reference when reading Ghidra decompilations:

NewCAurObject (0x00449cc0)
└── FindModel (0x00464110)           [by name; checks cache via BinarySearchModel]
    └── LoadModel (0x00464200)       [on cache miss]
        └── IODispatcher::ReadSync (0x004a15d0)
            └── Input::Read (0x004a14b0)          ← format dispatcher
                ├── InputBinary::Read (0x004a1260)   if first_byte == 0x00
                │   └── Reset / ResetLite                (pointer rewriting)
                │       ├── ResetMdlNode                  (per-node dispatch)
                │       │   ├── ResetMdlNodeParts         (base fields)
                │       │   ├── ResetTriMesh              (mesh subtypes)
                │       │   ├── ResetLight                (light extras)
                │       │   ├── ResetSkin, ResetAnim, ...
                │       │   └── ResetAABBTree             (recursive tree walk)
                │       └── ResetAnimation                (per-animation)
                └── FuncInterp loop                 otherwise (ASCII MDL)
    └── CreateInstanceTreeR (0x00449200)  [builds runtime Part tree from MdlNode tree]

Key Ghidra addresses

For anyone continuing this archaeology, the foundation set of function addresses in swkotor.exe (K1 GOG build):

FunctionAddress
Input::Read0x004a14b0
InputBinary::Read0x004a1260
InputBinary::Reset0x004a1030
InputBinary::ResetMdlNode0x004a0900
InputBinary::ResetMdlNodeParts0x004a0b60
InputBinary::ResetTriMeshParts0x004a0c00
InputBinary::ResetAABBTree0x004a0260
InputBinary::ResetLight0x004a05e0
InputBinary::ResetSkin0x004a01b0
InputBinary::ResetDangly0x004a0100
InputBinary::ResetAnim0x004a0060
InputBinary::ResetLightsaber0x004a0460
InputBinary::ResetAnimation0x004a0fb0
MdlNodeTriMesh::InternalPostProcess0x0043cf00
MdlNodeTriMesh::InternalGenVertices0x00439df0
MdlNodeTriMesh::InternalParseField0x004658b0
MdlNodeEmitter::InternalParseField0x004658b0
MdlNodeEmitter::InternalCreateInstance0x0049d5c0
PartTriMesh::GetMinimumSphere0x00443330
LightPartTriMesh0x0046a9e0
NewController::Control0x00483330
NewController::GetFloatValue0x00482bf0
Model constructor0x0044aa70
MaxTree constructor0x0044a900
ParseNode0x004680e0
Node type flag table0x00740a18

Save Game Deep Dive

KotOR stores a save as a folder, not a single file. This page documents what the folder contains, how the engine assembles it, and the serialization quirks that make save GFFs disagree with their static template counterparts. Evidence throughout is drawn from Ghidra decompilation of swkotor.exe (K1 GOG build), cross-checked against a vanilla save folder on disk. Findings are recorded as behaviour notes, not as transcribed engine code.

Overview

PropertyValue
On-disk unitA directory under the SAVES: alias, named NNNNNN - <name> (for example, 000231 - Game230); slots 000000 and 000001 are reserved, see below
Main archiveSAVEGAME.sav, an ERF with version tag MOD V1.0
Loose sidecarssavenfo.res, PARTYTABLE.res, GLOBALVARS.res (each a GFF), plus Screen.tga
Rust referencerakata-save (mid-refactor); see Save Games for the field tables

A save is the engine’s snapshot of the player’s whole session. That state spans every module you have visited, so the engine does not assemble it in one pass. It keeps a working directory while you play and packages that directory into the save folder when you save.

The staged working directory

The engine maintains a live working directory under the GAMEINPROGRESS: alias. As you play, two things accumulate there:

  • Per-module runtime state, written every time you leave a module.
  • Global session state (party table, globals), refreshed at save time.

When you save to a slot, the engine packages that working directory into SAVEGAME.sav and writes a few small metadata files loose alongside it. Loading reverses the process: unpack the archive back into a working directory and replay it.

Two consequences of this staging follow, and both are covered below: per-module state ends up as an archive nested inside SAVEGAME.sav, and the menu-facing metadata is kept loose, outside it.

The save folder layout

A vanilla save folder contains:

FileFormatRole
SAVEGAME.savERF (MOD V1.0)The bundle: every per-module archive plus the global session resources
savenfo.resGFF (NFO )Menu metadata: name, area, last module, play time, portraits
PARTYTABLE.resGFF (PT )Party roster, gold, XP, journal, available companions, pazaak, galaxy map
GLOBALVARS.resGFF (GVT )Campaign global variables (booleans, numbers, locations, strings)
Screen.tgaTGASave-slot preview thumbnail

These metadata files sit loose, and the load menu reads them straight from the slot: a name, area, play time, and thumbnail for every save. SAVEGAME.sav grows with every module you visit, so a late-game archive can run to megabytes, while savenfo.res stays a few hundred bytes. Reading only the tiny sidecar keeps the menu cheap no matter how large the archive has grown, and the thumbnail rides along beside it.

Two tag details matter to tooling: the PT party-table tag has two trailing spaces, and the ERF container’s tag is the full MOD V1.0. Every loose GFF is version V3.2, like every GFF the engine writes (see GFF for why the version never varies).

The filename casing is the engine’s own, and it is not uniform: SAVEGAME.sav, savenfo.res, PARTYTABLE.res, GLOBALVARS.res, and Screen.tga appear in exactly this mixed casing across every folder of a 104-save corpus and, independently, across saves written months apart on a second install. Read the names case-insensitively, but emit this exact casing when writing a save folder.

Per-module state: archives within the archive

A save is an archive of archives. Each module you visit is saved as its own ERF, and the engine bundles those per-module ERFs inside SAVEGAME.sav.

StoreCurrentModule writes a module’s ERF in two cases: when you save to a slot, and on every module transition, where it snapshots the module you are leaving before the next one loads. So a save holds the runtime state of every module you have visited, not just the current one. An IncludeModuleInSave gate decides which modules qualify: it reads modulesave.2da and excludes a module only when that table has a row for it set to 0. A missing row, or a modulesave.2da that fails to load, includes the module.

What a per-module ERF holds

Each per-module ERF carries the MOD V1.0 tag (created by CSWSModule::SaveModuleStart, finalized by CSWSModule::SaveModuleFinish) and holds three resources:

ResourceTypeWhat it holds
IFO (resref Module)module infoThe saved module clock, runtime id counters, and the party and limbo creature lists. See IFO for the save-only fields.
ARE 2012 (0x7dc)The area static. Skipped for Mod_IsNWMFile modules.
GIT 2023 (0x7e7)The dynamic object state: live creatures, doors, placeables, triggers, and the rest. Covered below.

Note

NWM is a “NeverWinter Module” (.nwm), a type the Odyssey engine inherited from BioWare’s Aurora engine (the one behind Neverwinter Nights). For an NWM module, the engine does not re-save the area static.

Faction state is global, not per-module

When the engine stores a module, it also rewrites the whole-session faction table to a single global REPUTE file (type FAC ; see FAC). That file ends up as the one REPUTE resource in SAVEGAME.sav, never duplicated per module.

The party roster

The active module’s IFO carries the party roster in Mod_PlayerList: one full creature snapshot per member, written by SavePlayers (structure in IFO).

Between modules the party can also live in a transient pifo (party-info) file: a GFF tagged IFO , written to the working directory by StorePlayerCharacters with the same Mod_PlayerList-of-creatures shape as a module roster. When it packs the party, StorePlayerCharacters stamps each player with the roster slot it was written to. LoadCharacterFromIFO normally reads a slot from the module’s Module IFO, but the sentinel index 0xffffffff switches it to pifo and reads back each player’s stamped slot. That is how the party crosses a module boundary: staged to pifo on the way out, restored from it on the way in (before the destination module’s roster exists), then rebuilt by CreateParty. The one call site that passes the sentinel is LoadCharacterStart; the module-roster path passes a real slot index instead.

The engine can also write the primary player character as a standalone Player file in the BIC character format (a SaveCreature snapshot behind a small header, written by SavePrimaryPlayerInfo; the format is Aurora’s character record, also used by character generation and transport). That path is gated behind a global flag and does not run for ordinary single-player saves, so do not rely on a Player.bic being present. There is no matching .bic reader: on load, the primary player is pulled from the module roster (LoadPrimaryPlayer takes the module’s primary-player index and hands off to LoadCharacterFinish), and any Player record is consumed through the same Mod_PlayerList creature-load path as a roster member, keyed by ObjectId. Don’t confuse this with the unrelated PC file covered below – different format, different trigger, different purpose; they only share the same underlying SaveCreature serializer.

The companion pool and the shared inventory

Mod_PlayerList only carries the members standing in the module. The rest of the recruited crew lives in the working directory as standalone UTC resources, one per companion, named AVAILNPC%d with the companion’s npc.2da row as the index (nine slots, AVAILNPC0-AVAILNPC8). Each is a full SaveCreature snapshot in its own GFF (typed UTC ), so every recruited companion lands inside SAVEGAME.sav whether or not they are in the active party. Slots never recruited have no file: an early-game save carries one or two AVAILNPC entries, a late-game save close to all nine.

The write path has two triggers. CSWPartyTable::AddNPC writes the snapshot the moment a companion is recruited (it also moves their inventory into the party stash, computes a joining-XP top-up, and adds them to the player’s faction). After that, SaveMember refreshes any slot that still has a live creature object: CSWPartyTable::Save loops all nine slots at save time, and party-member switches (SwitchPlayerCharacter) and the SaveNPCState script command hit the same function.

Reading back is gated by the party table. GetNPCObject refuses a slot unless its PT_NPC_AVAIL flag is set in PARTYTABLE.res, then instantiates the companion through the ordinary template loader (LoadFromTemplate) with the resref pointed at the save’s AVAILNPC%d resource, caching the live object id so the file is read at most once per session. The partytable flags are the index and the AVAILNPC files are the data: clearing a flag hides a companion whose snapshot still sits in the archive.

Two rejoin behaviours ride this path through SpawnNPC:

  • Placement. A member spawns at the position already on its creature snapshot. When that position resolves to a room of the current area, the engine adjusts it through ComputeSafeLocation (20-unit search radius) before adding the creature; a position that resolves to no room skips the adjustment and the member is added at the origin. In practice a member restored into the module it was saved in comes back where it stood. A member loaded back dead is resurrected on rejoin (GetNPCObject applies a resurrection effect when asked to).
  • XP catch-up. A rejoining member is topped up toward their share of the party XP pool: npc.2da’s PercentXP for their row, applied to PT_XP_POOL from the party table, with an auto-level-up when that client option is on. That is what the pool is for: benched companions do not earn XP live, they settle up when they rejoin.

The party’s shared item stash is a sibling resource: INVENTORY, a GFF typed INV holding a single ItemList of item snapshots. CSWPartyTable::UpdateInventory writes it right after the member loop at save time, and CreateParty reads it back when the party is rebuilt.

Gold and the party pool

Party wealth is a single number – PT_GOLD in PARTYTABLE.res – but every creature’s save block still carries its own Gold field, and on every load the two would fight if the engine weren’t careful. The routing that keeps them apart is deliberate, and it runs on both sides of the save cycle.

On the way out, SaveCreature briefly clears the creature’s in-party flag (the one SetInParty maintains) around its call into SaveStats. The gold accessors check that flag to decide whether “this creature’s gold” means its own private ledger or the shared pool, so clearing it makes the write capture the member’s frozen personal value instead of the live party total. On the way back in, ReadStatsFromGff uses the same flag as a read gate: a creature currently in the party skips its Gold field entirely, so each member’s stale personal snapshot can’t clobber PT_GOLD as their blocks load one after another. The pool is authoritative; the per-member snapshots are just along for the ride.

None of this applies to a creature that was never in the party. An ordinary NPC, a merchant, or a corpse has the flag clear throughout, and its Gold round-trips like any other field.

A benched companion’s AVAILNPCn snapshot falls into that same clear-flag case, but the reason is worth spelling out: GetNPCObject constructs a brand-new CSWSCreature (the in-party flag starts clear, same as any fresh object) and loads the companion’s AVAILNPCn GFF through LoadFromTemplate before anything downstream has a chance to set the flag. So a rejoining companion’s Gold reads back normally, not skipped the way an active member’s is. It doesn’t linger as personal currency once they’re active, though: AddMember immediately calls TransferInventory on the freshly-spawned creature, which folds that just-loaded Gold straight into PT_GOLD (and moves their items into the party stash in the same call) before the in-party flag is set to 1. First-time recruitment works the same way – AddNPC calls the identical TransferInventory, so whatever starting gold a companion’s .utc template carries gets folded into the pool the moment they’re recruited, not kept as a standing balance. AVAILNPCn’s Gold field isn’t a companion’s personal account; it’s a one-shot amount consumed the instant they go active. (Worth flagging, not confirmed as a bug: TransferInventory doesn’t appear to zero the source creature’s own gold member after folding it in, which would matter if the same live object were folded twice without an intervening reload – not traced further here.)

The stray PC file: a party-leader-swap artifact, not a save artifact

A save can carry a fourth flat resource that has nothing to do with the deliberate ones below it: a bare PC (UTC, SaveCreature-shaped) GFF, in the same raw format as an AVAILNPCn companion snapshot. CSWPartyTable::SwitchPlayerCharacter writes it to GAMEINPROGRESS:PC the moment module content invokes the SwitchPlayerCharacter script action to hand control away from the born player character, and reads it back through the same path when control returns to the PC. It’s a transient swap buffer for that one script action, not a save mechanism.

It only ends up inside SAVEGAME.sav as a side effect of how the archive gets built: StallEventSaveGame (manual saves and quicksaves) and DoPCAutosave both import the entire GAMEINPROGRESS: working directory into the ERF, unconditionally, sweeping up whatever loose files happen to be sitting there. Nothing clears GAMEINPROGRESS: between saves within a session – the whole directory is only wiped at session teardown (CServerExoAppInternal::StopServices, when you quit to the main menu or load a different game). So once a SwitchPlayerCharacter swap has fired anywhere in a play session, the PC file stays staged and gets archived into every save taken afterward: quicksave, manual save, or autosave alike, with no distinction between them.

That rules PC out as a save-type discriminator. Its presence tracks session history – has a leader-swap happened since the last full reset – not which writer produced the file, and it is entirely unrelated to the pifo roster-staging file or the gated Player BIC record covered above; the three just happen to reuse the same SaveCreature serializer for otherwise-unconnected purposes. Don’t read PC’s presence in a save as a sign of anything about how that save was made.

Building and reading SAVEGAME.sav

At save time, the engine imports the entire GAMEINPROGRESS: working directory into one ERF (StallEventSaveGame, via CERFFile::ImportFiles). The per-module ERFs already live in that working directory, so they land inside SAVEGAME.sav as nested resources, each keyed by its module resref under resource type 2057 (sav) — alongside the flat session resources that accumulate there too: the REPUTE faction table, the AVAILNPC companion snapshots, and the party INVENTORY (which is stored under the generic resource type 0, so look it up by name). That import is a blanket sweep of the whole working directory, not a curated list, so an incidental leftover like the PC file above can ride along too; treat the three named resources as the resources you can rely on, not as a closed inventory of everything a SAVEGAME.sav might contain.

Reading a module’s state back is a two-level walk:

  1. Open SAVEGAME.sav.
  2. Find the resource named after the module.
  3. Parse that resource as its own ERF, then read the GIT inside it.

The party table and global variables go into the same working directory at save time (CSWPartyTable::Save, CSWGlobalVariableTable::Save), which is why PARTYTABLE.res and GLOBALVARS.res also sit loose in the folder. Those loose copies are what the engine reads back for global session state.

The .rsv intermediate format: module state before packaging

The GAMEINPROGRESS working directory holds more than the flat session resources. Each visited module’s runtime state lives there as a standalone .rsv file before it is bundled into SAVEGAME.sav.

The .rsv extension maps to resource type RSV (0x0bc1), registered in the engine’s extension table (CExoBaseInternal::CreateResourceExtensionTable). It sits alongside the other extensions (.sav, .nwm, .mod, and dozens more) in a linear lookup table that GetResTypeFromExtension and GetResourceExtension walk.

Where they come from. When you leave a module, StoreCurrentModule snapshots the module state into GAMEINPROGRESS. The engine writes a per-module ERF through the same save pipeline (CSWSModule::SaveModuleStart / SaveModuleInProgress / SaveModuleFinish) that produces the nested ERFs inside SAVEGAME.sav. These ERFs land as <resref>.rsv files in the working directory, one per visited module, carrying the same three resources (IFO, ARE, GIT) as the packaged copies. A file dropped into GAMEINPROGRESS between transitions lands in the snapshot the same way.

How the engine uses them. At load time, CServerExoAppInternal::LoadModule checks for RSV before SAV, for any module that IncludeModuleInSave (the same modulesave.2da-driven gate the save side uses) says belongs in the save at all:

  1. If the module exists as an RSV resource, the engine resolves the filename through the GAMEINPROGRESS: alias and opens it as type RSV (0x0bc1).
  2. If RSV is not found, it falls back to SAV (type 0x0809, decimal 2057 – the same type the packaged archive copies use), the committed-archive copy.

This is a priority check, not a validation. The engine does not verify that a .rsv file came from its own save pipeline: it only checks whether one exists. A file dropped into GAMEINPROGRESS takes priority over the committed archive with no provenance check.

The RSV and SAV types are treated identically for the load-bar stall event: both trigger a type-3 (save-game) stall, where every other module type (MOD/RIM/NWM) triggers a type-1 (module) stall.

Important

is_nwm_file is not affected by whether a module loaded as RSV or SAV. It comes from one place only: the module’s own Mod_IsNWMFile IFO field, read the same way regardless of resource type (see the NWM note above). ARE loading is unconditional too – the area object always demands its ARE resource as the first step of loading, with no code path that skips it for any resource type. A hand-staged .rsv still needs a valid ARE to load successfully; it cannot get by on IFO and GIT alone.

During save size estimation. SetEstimatedSaveSize walks every file in GAMEINPROGRESS, maps each extension to its resource type, and estimates the eventual archive size. The padding isn’t type-keyed the way it might sound: any file that fails to open (regardless of its type) gets a flat 2.5 MB stand-in size rather than being skipped. Separately, once every file is summed, the estimate gets one more flat 2.5 MB added on top – but only when the type of the module currently being loaded is something other than RSV or SAV; loading from either of those is exempt from that particular top-up. An empty GAMEINPROGRESS directory skips both of these and uses a fixed 3.75 MB baseline instead. The whole total is then scaled up by roughly 11% before being stored as the estimate.

To packaging. When a save is committed, StallEventSaveGame (manual / quicksave) or DoPCAutosave calls CERFFile::ImportFiles to sweep the entire GAMEINPROGRESS directory into SAVEGAME.sav. The .rsv files land inside the archive as nested ERF resources, keyed by module resref under resource type 2057 (sav). A staged .rsv is indistinguishable from an engine-written one inside the archive. The .rsv extension is the working format; the archive uses the sav type. A tool that reads SAVEGAME.sav never sees the .rsv extension at all: it sees nested ERF resources, and their on-disk origin as .rsv files is an implementation detail of the staging directory.

Why this matters for tooling. If you are implementing a save reader that works from the GAMEINPROGRESS directory (for example, reading a session-in-progress without a committed save), the module files are .rsv, not .sav. The content is the same, but the filename extension and resource type differ. A tool that opens GAMEINPROGRESS directly needs to look for <module>.rsv rather than expecting a pre-built SAVEGAME.sav.

savenfo: the menu metadata block

savenfo.res is a small GFF (type NFO ) built field by field in StallEventSaveGame. Its field table lives on the savenfo page. The one behaviour worth recording here: CHEATUSED is not tracked independently. The engine writes the same cheat flag the party table serializes, so the load menu stays in sync without opening the party table.

The global sidecars: partytable and globalvars

The other two loose GFFs hold session-wide state that is not tied to any single module. Full field tables live on the partytable and globalvars pages; the engine-side notes worth recording here:

PARTYTABLE.res (type PT ) is written by CSWPartyTable::SaveTableInfo. The journal is folded into the same file by a helper (CSWPartyTable::SaveJournal) that SaveTableInfo invokes, and the entire journal block is omitted when the party journal is empty.

GLOBALVARS.res (type GVT ) is written by CSWGlobalVariableTable::WriteTable. It carries four global types, not two: boolean, number, location, and string, each a catalogue-name list paired with its own value block (VOID-packed for booleans, numbers, and locations; a list for strings). Tooling that models only numbers and booleans silently drops every location and string global. The globalvars page documents the byte-exact encoding of each value block (bit-packed booleans, one byte per number, a fixed 100-slot location array) and the per-type capacity limits.

Save types: manual, quicksave, and autosave

A save folder can come from either of two write paths, and they do not produce the same thing. A tool that parses a save should know which made it. The partytable/globalvars sidecars and the SAVEGAME.sav bundle are identical across types; the differences are confined to savenfo and two loose files:

Manual / quicksaveAutosave
WriterStallEventSaveGameDoPCAutosave
Fires whenyou save to a slot, or quicksaveyou cross into a new module (StartNewModule)
savenfo-only fieldsSAVEGAMENAME, LIVE1-LIVE6, LIVECONTENTPCAUTOSAVE (=1), SCREENSHOT, AUTOSAVEPARAMS
Slot thumbnailScreen.tga (captured frame)none; SCREENSHOT holds a load_<module> resref
pifo.ifoabsentpresent

PCAUTOSAVE is the reliable tell for an autosave: its presence means the file came from DoPCAutosave. AUTOSAVEPARAMS is a nested struct holding the pending move-to-module state; the savenfo page has the full field-by-field breakdown.

It does not separate a quicksave from a manual save, though. Both come from StallEventSaveGame and emit the same field set, so nothing inside the files tells them apart. The folder name does: every save folder is NNNNNN - <name>, and the first two slot numbers are reserved.

SlotFolderKind
000000000000 - QUICKSAVEQuicksave
000001000001 - AUTOSAVEAutosave
000002 and up000002 - Game1, …Manual saves, in creation order

There is no literal QUICKSAVE or AUTOSAVE directory; the words are the name half of the ordinary slot format. This is also why a save folder listing from a played-through install starts at 000002. To classify a slot: read the folder’s slot number, and corroborate with PCAUTOSAVE.

This isn’t just an observed pattern; the two reserved numbers are hardcoded literals, not something derived at runtime from folder scanning. The quicksave writer (CClientExoAppInternal::DoQuickSave) passes slot 0 alongside the literal string "QUICKSAVE". DoPCAutosave and a second, separate autosave trigger inside the server’s per-frame tick (CServerExoAppInternal::MainLoop) both pass slot 1 alongside "AUTOSAVE". The autosave loader, the save-menu filter, and the quickload finder each independently compare a folder’s parsed slot number against these same two literals.

The set of names that can ever reach this formatter is closed, established by walking every caller down to the shared write backend (CServerExoAppInternal::SaveGame) and the shared unpack/copy helpers:

NameSlot numberWriter
QUICKSAVE0 (literal)CClientExoAppInternal::DoQuickSave, via the generic SaveGame backend
AUTOSAVE1 (literal)DoPCAutosave (self-contained), and separately MainLoop’s periodic-autosave branch, which calls the same generic SaveGame backend directly
the player-entered save name2 and up (allocation mechanism below)The manual-save flow, through the same generic SaveGame backend

No fourth name reaches the formatter. Manual saves beginning at 000002 is confirmed, not just observed: nothing else in the traced call graph reserves a number below it.

Note

REBOOTAUTOSAVE never reaches the slot-name formatter at all – it isn’t a folder name. It’s a boolean byte field inside savenfo.res itself (alongside PCAUTOSAVE), read unconditionally by the load-menu’s slot parser, and its single cross-reference in the whole binary is that read. No code path writes it, quotes it as a save name, or feeds it to the formatter above; DoPCAutosave only ever sets PCAUTOSAVE. The save-list preview code checks this bit together with PCAUTOSAVE when it decides where to pull a slot’s screenshot from, so the read path is live. With no PC producer, though, the field is permanently 0 in every save this build creates. The name suggests a hard-reset or dashboard-return autosave, most likely inherited console-SKU logic tolerantly parsed here for compatibility, the same shape as the already-documented LIVE%d Xbox content mounts. Two other REBOOT/AUTOSAVE-adjacent strings turned up during this trace and were ruled out as unrelated: CB_AUTOSAVE is an options-screen checkbox control id, and AutoSave/AutoSaveOnEnter are module/area property names – neither is a save-folder name.

Allocating a new manual-save slot number happens in the Save menu’s list builder (CSWGuiSaveLoad::PopulateGameList), which filters out any folder parsed as slot 0 or 1 before manual saves are ever considered (the Load menu skips this filter, so quicksave/autosave still show up there). Over what’s left, the engine tracks both the highest existing manual-save number and the lowest unused one starting from 2. The normal case appends after the highest number found; if that would push past 999, the commit step (CSWGuiSaveLoad::WriteGame) falls back to the first gap instead, and if even the gap search comes up empty – all of 2-999 occupied with no room – the save is rejected outright. 999 manual saves is a hard ceiling, not a soft one.

Both loose-file differences trace to autosaves firing mid-transition (StartNewModule), while a loading screen is up:

  • pifo.ifo exists because at a transition the party is staged in the transient pifo party-info file rather than a module roster (the same file the 0xffffffff load path reads, see The party roster); DoPCAutosave copies it into the folder. It is a GFF tagged IFO holding a Mod_PlayerList of the party.
  • No Screen.tga because there is no gameplay frame to capture mid-loading-screen, so the autosave records the loading-screen resref in SCREENSHOT instead. (The slot thumbnail is independent of the EnableScreenShot ini option, which governs only the manual F12 screenshot.)

Treat a stray pifo.ifo, or a missing Screen.tga, as an expected autosave artifact rather than malformed data.

The GIT: dynamic object state

A module’s live objects (creatures, doors, placeables, triggers, items, and so on) are serialized into a GIT GFF inside the module ERF (CSWSArea::SaveGIT). The engine walks the area’s object array, buckets each object by runtime type, and emits one list per type:

GIT list labelObjectNotes
Creature Listcreaturesplayer characters are split out into a separate player list, not this one
Listitem instancesitems in the area use the bare label List
Door Listdoors
TriggerListtriggers
Encounter Listencounters
WaypointListwaypoints
SoundListsounds
Placeable Listplaceablescorpses are excluded
StoreListstores
AreaEffectListarea-of-effect objects

The list labels are gleefully inconsistent: some are spaced (Creature List, Door List, Encounter List, Placeable List), some are jammed together (TriggerList, WaypointList, SoundList, StoreList, AreaEffectList), and item instances get the bare word List. There is no rule to derive them; they are simply the literal strings the engine hardcodes.

Alongside the object lists, the GIT struct carries area-level state: CurrentWeather, WeatherStarted, TransPending, TransPendNextID, TransPendCurrID (all BYTE), plus script variable tables.

Templates versus snapshots: the UseTemplates flag

The most important thing to know about a save GIT: the same GIT schema is read two completely different ways, chosen by a single UseTemplates BYTE in the GIT’s top-level struct. The area loader (CSWSArea::LoadGIT) reads UseTemplates once and hands it to every per-type loader.

AspectUseTemplates = 1 (static .git)UseTemplates = 0 (savegame GIT)
Object elementsparse placementfull self-contained snapshot
TemplateResRefpresentabsent (not read)
Blueprint loadyes, via the object’s LoadFromTemplate (UTC/UTD/UTP/UTT/…), then instance fields overlaidnone; the engine reads every field directly (CSWSCreature::LoadCreature, CSWSDoor::LoadDoor, …)
Where the data livesmostly in the blueprintentirely in the GIT element
A field missing from the elementcomes from the blueprintcomes from the engine’s hardcoded default

The consequence for any field that is absent from a savegame element: it falls back to the engine’s hardcoded default, not to the blueprint. The per-object loaders read each field with a default argument (for example a missing trigger TrapType defaults to the value already on the object, and most BYTE fields default to zero). So a savegame instance carries everything the saver wrote, and nothing more: anything the saver left out comes from an engine default, never from the template.

Important

When reading a savegame, do not reach for the blueprint. A UseTemplates = 0 object is the whole truth; any field it leaves out comes from the engine’s hardcoded default, never the .utc / .utd / … template. Synthesizing a template lookup for a savegame instance invents data the engine never used.

The two GIT roles now line up cleanly. A module’s static layout is the sparse, template-relative form; a savegame is the already-flattened form with runtime overrides baked in. To read “the object as it exists in this save,” a tool reads the savegame GIT directly under UseTemplates = 0; only a module’s own .git needs the blueprint flatten. Evidence: CSWSArea::LoadGIT, CSWSArea::LoadCreatures, CSWSCreature::LoadCreature, and CSWSCreature::LoadFromTemplate.

Note

Area-level state (weather, the script variable tables) is gated separately, by LoadGIT’s own caller flag, not by UseTemplates. It is restored only on a full area load.

Object fields

Save objects use the ordinary GIT object schema, so the full per-object field map, the per-type position/orientation naming, and the geometry conventions live in the GIT format spec and the per-object specs (for example, UTT for trigger geometry). This page doesn’t repeat those tables. It records only the points the save path adds or makes clearer:

Save-path behaviourWhat the engine does
Field names vary by object typeNo generic position or orientation field exists. Positions come in three spellings: X/Y/Z, XPosition/YPosition/ZPosition, and PositionX/PositionY/PositionZ (area-of-effects only). Orientations come in four: the Bearing scalar, the XOrientation/… vector, the OrientationX/… vector, and a single-float Orientation on encounter spawn points. Sounds and encounters store none at all.
Bearing is two different thingsA door stores its Bearing verbatim. A placeable derives it from its orientation yaw at save time, so the stored value is lossy. Confirmed in CSWSDoor::SaveDoor and CSWSPlaceable::SavePlaceable.
Vector orientation is stored in fullCreatures, triggers, waypoints, and stores keep the whole orientation vector; nothing reduces it to a yaw at save time. On load the vector goes to SetOrientation, normalized first if it is not unit length (confirmed for triggers). Only the trigger geometry re-bake consumes the yaw alone.
Trigger geometry is position-relative and orientation-coupledTrigger vertices are offsets from the trigger’s position, not absolute points. Supplying an orientation on load re-rotates the geometry by the yaw delta.
Creature stat totals are recomputed, not restoredMaxHitPoints, ArmorClass, and the saving-throw totals in a creature block are write-only snapshots; the engine rebuilds them on load from inputs that round-trip through other fields. Details on the UTC page.
DetectMode never survives a reloadWritten faithfully, read only to skip past, then reset to 1 by construction. One of a small family of creature round-trip quirks catalogued on the UTC page.

Loading a save: the unpack flow

Loading mirrors the staged working-directory model in reverse, and the engine never runs a session from the slot itself. CSWGuiSaveLoad::UnpackGame drives the sequence:

  1. The load menu reads the loose savenfo.res straight from each slot for its name, area, play time, and thumbnail; no archive is opened. When you pick a slot, its LASTMODULE names the first module to restore.
  2. CopyGameToFutureGame unpacks the chosen slot, the SAVEGAME.sav ERF plus the loose sidecars, into the FUTUREGAME: staging area.
  3. The engine clears GAMEINPROGRESS: and renames FUTUREGAME: onto it. The live session runs from this unpacked copy.
  4. CSWSModule::LoadModule replays the module against the working directory, running LoadModuleStart / LoadModuleInProgress / LoadModuleFinish (the inverse of StoreCurrentModule). LoadModuleStart reads the per-module IFO including its save-only fields, loads the global REPUTE faction table (LoadFactionsFromSaveGame / LoadReputationsFromSaveGame), and reads the GIT with UseTemplates = 0, so every object comes from its full snapshot.
  5. The party table and global variables come back from their loose sidecars.

Note

A successful load never modifies the slot; only the working directory changes. If the unpack fails partway, the engine drops a CORRUPT marker file into the slot and abandons the load.

The CORRUPT.res marker

The marker is a real file, CORRUPT.res, written straight into the slot folder with the literal ASCII text "CORRUPT" as its entire content – a sentinel, not a structured GFF. UnpackGame (and the equivalent quicksave path, CGuiInGame::UnpackQuickSaveGame) writes it whenever the archive-copy step (CopyGameToFutureGame / CopyQuickSaveGameToFutureGame) fails.

Tracing that copy step down to the raw file I/O turned up something worth flagging: none of the ordinary return-code paths in the ERF-reading chain (CERFFile::Read, ReadHeaderVariance, ExportFilesFromERF, down to the raw file read/write calls) actually signal failure for a missing, truncated, or garbled file – they tolerate short reads with a retry-and-log, and CopyGameToFutureGame itself has only one return path, an unconditional success. So the marker-writing branch isn’t reachable through a checked validation at all. It can only fire if a genuine exception unwinds out of the copy, and the functions in this chain do install real C++ exception handling. The most plausible trigger, going by what the code does with the data, is a corrupted archive header’s entry/language counts driving an allocation or a read past what’s actually there – but there’s no explicit corruption check anywhere in this path to point to as “the” validation. Treat that as the likely mechanism, not a confirmed one.

On the read side, CSWGuiSaveLoadEntry::LoadData (the per-slot load-menu populator) checks for the marker by trying to open it; if present, the entry skips reading savenfo.res entirely (area name, last module, and play time stay blank) and unmounts the slot’s resource directory. A corrupted slot still shows up in the load-menu list, though, with the same click and delete handlers as any other entry – the marker only blanks the preview (area name, screenshot, and party portraits) and disables the Delete button when that entry is hovered or selected. Nothing stops the player from trying to load it anyway; if the underlying fault reproduces, the attempt silently abandons again with no error dialog.

Nothing clears the marker by name – there’s no code that specifically deletes CORRUPT.res. It does disappear as a side effect of a normal save, though: writing to an existing slot wipes every recognized resource file in that folder before laying down the new SAVEGAME.sav/savenfo.res/screenshot, and .res is a recognized extension, so CORRUPT.res gets swept up in that generic cleanup along with everything else. A subsequent successful save to a marked slot clears it, but only incidentally.

The read side is symmetric with the documented write side. The load-only behaviours are noted where they occur: the reputation default-baseline restore (FAC), the FactionGlobal default, and the trigger geometry re-bake (UTT).

What this means for tooling

  • Treat a save as a folder: one MOD V1.0 ERF plus three loose GFF sidecars and a thumbnail.
  • Read a module’s runtime objects with a two-level archive walk: open SAVEGAME.sav, find the module’s nested ERF, then read the GIT inside it.
  • Check UseTemplates first. A savegame GIT (UseTemplates = 0) is self-contained, so read objects directly. A module’s static .git (UseTemplates = 1) is template-relative, so resolve each TemplateResRef against the module’s blueprints and overlay the instance fields.
  • Resolve a savegame object’s missing fields to engine defaults, never to a blueprint. A template lookup supplies values the engine does not use.
  • Branch position and orientation field access on the object type; no single field name covers every type.
  • Preserve the raw GFF tree to round-trip placeable bearing and trigger geometry byte-exactly; on disk those values are derived, lossy, or position-relative.

Miscellaneous engine notes

Several engine behaviours touch more than one part of a save. Each is covered in full on the linked pages or in the sections above; this section summarizes them.

  • Global value blocks use a fixed byte layout: bit-packed booleans (most-significant bit first), one byte per number, and a fixed 100-slot location array. Each location slot is a 24-byte CScriptLocation: a position vector (X, Y, Z) followed by an orientation vector (X, Y, Z), six little-endian floats with no area reference.
  • The pifo party-info file is a working GFF tagged IFO . LoadCharacterFromIFO reads it when the requested member index is 0xffffffff.
  • A Player.bic has no dedicated reader. The engine loads it through the ordinary Mod_PlayerList creature-load path, keyed by ObjectId.
  • IncludeModuleInSave decides which visited modules are written into the save, using modulesave.2da.
  • A saved orientation is written as a full (X, Y, Z) vector, never reduced to a yaw.

Key Ghidra addresses

For anyone continuing this archaeology, the foundation set of function addresses in swkotor.exe (K1 GOG build):

FunctionAddress
CServerExoAppInternal::StallEventSaveGame0x004b3110
CServerExoAppInternal::SaveGame0x004b58a0
CServerExoAppInternal::DoPCAutosave0x004b8300
CServerExoAppInternal::StartNewModule0x004ba920
CServerExoAppInternal::StoreCurrentModule0x004b2e70
IncludeModuleInSave0x004b20e0
CSWSModule::SaveModuleStart0x004c8960
CSWSModule::SaveModuleInProgress0x004c3b10
CSWSModule::SaveModuleFinish0x004ca680
CSWSModule::SavePrimaryPlayerInfo0x004c3c70
CSWSModule::SavePlayers0x004c7870
CSWPartyTable::Save0x005665c0
CSWPartyTable::SaveTableInfo0x005648c0
CSWPartyTable::SaveJournal0x00563d90
CSWPartyTable::AddNPC0x00564300
CSWPartyTable::SaveMember0x00563e80
CSWPartyTable::UpdateInventory0x00564030
CSWPartyTable::GetNPCObject0x00564700
CSWPartyTable::CreateParty0x00565760
CSWPartyTable::SpawnNPC0x00565130
CSWGlobalVariableTable::Save0x0052ad10
CSWGlobalVariableTable::WriteTable0x005299b0
CSWSArea::SaveGIT0x0050ba00
CSWSArea::SaveCreatures0x00507680
CSWSArea::SaveDoors0x00507810
CSWSArea::SaveTriggers0x005078d0
CSWSArea::SavePlaceables0x00507bd0
CSWSDoor::SaveDoor0x00588ad0
CSWSPlaceable::SavePlaceable0x00586a70
CSWSTrigger::SaveTrigger0x0058e660
CSWSCreature::SaveCreature0x00500610
CSWSCreatureStats::SaveStats0x005b1b90
CSWSWaypoint::SaveWaypoint0x005c8230
CSWSStore::SaveStore0x005c6cd0
CSWSSoundObject::Save0x005c86d0
CSWSEncounter::SaveEncounter0x00591350
CSWSAreaOfEffectObject::SaveEffect0x00594d80
CResGFF::CreateGFFFile0x00411260
CSWSArea::LoadGIT0x0050dd80
CSWSArea::LoadCreatures0x00504a70
CSWSCreature::LoadCreature0x00500350
CSWSCreature::LoadFromTemplate0x005026d0
CSWSTrigger::LoadTrigger0x0058da80
LoadTriggers0x0050a350
LoadTriggerGeometry0x0058d060
CSWGuiSaveLoad::UnpackGame0x006caaf0
CopyGameToFutureGame0x006c9a90
CSWSModule::LoadModule0x004b95b0
CSWSModule::LoadModuleStart0x004c9050
CServerExoAppInternal::LoadPrimaryPlayer0x004b5f50
CServerExoAppInternal::LoadCharacterStart0x004b7470
CServerExoAppInternal::LoadCharacterFinish0x004b5c50
CServerExoAppInternal::StorePlayerCharacters0x004b2ba0
CSWSPlayer::LoadCharacterFromIFO0x00561e30
CSWGlobalVariableTable::ReadTableWithCatalogue0x0052a280
CSWGlobalVariableTable::GetValueLocation0x00529350
CSWSObject::GetScriptLocation0x004cb7b0
CFactionManager::LoadFactionsFromSaveGame0x0052b5c0
CFactionManager::LoadReputationsFromSaveGame0x0052bbe0

Located during the per-object savegame-defaults audit (creature, item, door, placeable, trigger, waypoint, store, sound, encounter, area-of-effect, and module/area/GIT top-level fields), covering the loaders, writers, and constructors behind the defaults recorded on each type’s page:

FunctionAddress
CSWSCreatureStats::ReadStatsFromGff0x005afce0
CSWSCreatureStats::ReadSpellsFromGff0x005aeb30
CSWSCreatureStats::SaveClassInfo0x005aec90
CSWSCreatureStats::CSWSCreatureStats (constructor)0x005aca80
CCombatInformation::LoadData0x00552350
CCombatInformation::SaveData0x00550f30
CSWSCombatRound::LoadCombatRound0x004d5120
CSWSPlayer::LoadCreatureData0x00560e60
CSWSMessage::SendServerToPlayerUpdateCharResponse0x00570c60
CSWSCreature::ReadScriptsFromGff0x004ebf20
CSWSCreature::LoadFollowInfo0x004fb180
CSWSCreaturePartyFollowInfo::Load0x004eb020
CSWSCreaturePartyFollowInfo::Save0x004eaf70
CSWSCreaturePartyFollowInfo::CSWSCreaturePartyFollowInfo (constructor)0x004f79e0
CSWSObject::LoadListenData0x004d0480
CSWSObject::SaveListenData0x004cca50
CSWSObject::LoadObjectState0x004d1cf0
CSWSObject::SaveObjectState0x004cec50
CSWSObject::LoadEffectList0x004d1be0
CSWSObject::SaveEffectList0x004cc9d0
CGameEffect::LoadGameEffect0x005043a0
CSWSObject::LoadActionQueue0x004cecb0
CSWSObject::SaveActionQueue0x004cc7e0
CSWSObject::CSWSObject (base constructor)0x004cfcb0
CSWSScriptVarTable::LoadVarTable0x0059aa80
CSWSScriptVarTable::SaveVarTable0x0059adb0
CSWVarTable::LoadVarTable0x0059b0f0
CSWVarTable::SaveVarTable0x0059b250
CSWSCreature::ReadItemsFromGff0x004ffda0
CSWSCreature::CSWSCreature (constructor)0x004f7a10
CSWSCreature::SetDetectMode0x0050ee30
CSWSCreature::SetStealthMode0x0050ee50
CSWSModule::LoadLimboCreatures0x004c8c70
CSWSModule::SaveLimboCreatures0x004c5bb0
CSWSModule::LoadModuleInProgress0x004c5720
CSWSArea::LoadArea0x0050e190
CItemRepository::GetItemRepository0x004ef770
CSWSItem::LoadItem0x00560970
CSWSItem::LoadFromTemplate0x005608b0
CSWSItem::LoadDataFromGff0x0055fcd0
CSWSItem::CSWSItem (constructor)0x005530a0
CSWItem::CSWItem (base constructor)0x005b4660
CSWSItem::SetPossessor0x00553210
CSWSItem::SaveItem0x0055ccd0
CSWSItem::SaveItemProperties0x00555790
CSWSItem::SaveContainerItems0x0055cfa0
CSWSItem::ReadContainerItemsFromGff0x0055f0f0
CSWSArea::LoadItems0x00504de0
CSWSArea::SaveItems0x00507750
CSWSDoor::LoadDoor0x0058a1f0
CSWSDoor::LoadFromTemplate0x0058b3d0
CSWSDoor::LoadDoorExternal0x0058c5f0
CSWSDoor::CSWSDoor (constructor)0x00589ee0
CSWSDoor::PostProcess0x00589d40
CSWSArea::LoadDoors0x0050a0e0
CSWSPlaceable::LoadPlaceable0x00585670
CSWSPlaceable::LoadFromTemplate0x00587a70
CSWSPlaceable::CSWSPlaceable (constructor)0x005877e0
CSWSPlaceable::LoadBodyBag0x005864b0
CSWSPlaceable::SpawnBodyBag0x004ce220
CSWSPlaceable::AcquireItem0x00584b10
CSWSPlaceable::PostProcess0x00584870
CSWSArea::LoadPlaceables0x0050a7b0
ExecuteCommandCreateObject0x0052f820
CSWSTrigger::CSWSTrigger (constructor)0x0058eae0
CSWSTrigger::LoadFromTemplate0x0058ed70
CSWSTrigger::AddToArea0x0058f030
CSWSWaypoint::LoadWaypoint0x005c7f30
CSWSWaypoint::CSWSWaypoint (constructor)0x005c7e70
CSWSArea::LoadWaypoints0x00505360
CSWSStore::LoadStore0x005c7180
CSWSStore::LoadFromTemplate0x005c7760
CSWSStore::CSWSStore (constructor)0x005c6ab0
CSWSStore::AddItemToInventory0x005c70c0
CSWSArea::LoadStores0x005057a0
CSWSSoundObject::Load0x005c9040
CSWSSoundObject::LoadFromTemplate0x005c94e0
CSWSSoundObject::CSWSSoundObject (constructor)0x005c8f30
CSWSArea::LoadSounds0x00505560
CSWSEncounter::ReadEncounterFromGff0x00592430
CSWSEncounter::ReadEncounterScriptsFromGff0x00590820
CSWSEncounter::LoadEncounter0x00593830
CSWSEncounter::LoadFromTemplate0x00593a90
CSWSEncounter::LoadEncounterGeometry0x00590580
CSWSEncounter::LoadEncounterSpawnPoints0x00590410
CSWSEncounter::CSWSEncounter (constructor)0x00593c70
CSWSArea::LoadEncounters0x00505060
CSWSAreaOfEffectObject::LoadEffect0x00594b00
CSWSAreaOfEffectObject::CSWSAreaOfEffectObject (constructor)0x00594480
CSWSArea::LoadAreaEffects0x00505af0
CSWSArea::LoadProperties0x00507490
CSWSArea::SaveProperties0x00506090
CSWSArea::LoadMaps0x00505da0
CSWSArea::SaveMaps0x005061d0
CSWSArea::LoadPlaceableCameras0x00505eb0
CSWSArea::SavePlaceableCameras0x005062a0
CSWSModule::SaveModuleIFOStart0x004c7050
CSWSModule::SaveModuleIFOFinish0x004c8b90
CSWSModule::SaveStatic0x004c5980
CSWSAmbientSound::CSWSAmbientSound (constructor located; Load/Save not individually decompiled)0x005c95a0
MainLoop (two call sites feed LoadModuleStart/limbo-creature handling)0x004babb0, 0x004ae860

Located while resolving four open questions left by the defaults audit above (creature stat recomputation on load, the Gold read gate, Mod_StartMovie, and area-of-effect script re-derivation):

FunctionAddress
CSWSCreatureStats::GetFortSavingThrow0x005ab810
CSWSCreatureStats::GetWillSavingThrow0x005ab880
CSWSCreatureStats::GetReflexSavingThrow0x005ab8f0
CSWSCreatureStats::GetBaseFortSavingThrow0x005aa1b0
CSWSCreatureStats::GetBaseWillSavingThrow0x005aa2f0
CSWSCreatureStats::GetBaseReflexSavingThrow0x005aa430
CSWSCreature::GetArmorClass0x004ed1d0
CSWCCreatureStats::GetArmorClass (client-side display cache, not the save source of truth)0x00647720
CSWSItem::ComputeArmorClass (an item’s own base AC contribution, unrelated to the creature-level getter above)0x00553cc0
CSWSObject::GetMaxHitPoints0x004d01a0
CSWSCreature::GetMaxHitPoints0x004ed310
CSWCCreatureStats::GetMaxHitPoints (client-side display cache)0x00647a80
SaveCharGenCreature (character-generation/BIC export path; also writes MClassLevUpIn and PregameCurrent, with no reader for either)0x006123e0
CSWSCreature::GetGold0x004edd60
CSWSCreature::SetGold0x004edda2
CSWSCreature::AddGold0x004f3dc8
CSWSCreature::RemoveGold0x004f3eea
CSWSCreature::TransferGold0x004fd769
CSWSCreature::SetInParty0x004fdb2d
CSWSCreature::CSWSCreature (a second constructor address located in this pass; the address recorded earlier in this table, 0x004f7a10, was found in an earlier session – likely a different overload, not reconciled)0x004f7b47
CSWSPlayer::LoadLocalCharacter0x00561d70
ExecuteCommandAddPartyMember0x0052de70
ExecuteCommandRemovePartyMember0x00541c00
SwitchPlayerCharacter0x005667c0
TransferInventory0x005641e0
CSWSAreaOfEffectObject::LoadAreaEffect (singular; the vfx_persistent.2da-driven definition lookup, wired only to fresh spell-cast creation, never to a save load)0x005947b0
CSWSEffectListHandler::OnApplyAreaOfEffect0x004dade0
ApplyEffect0x0050c6b0
CSWSAreaOfEffectObject::AIUpdate (heartbeat tick)0x00595d10
CSWSAreaOfEffectObject::EventHandler (enter/exit collision events)0x005964e0
CVirtualMachineInternal::RunScript0x005d45d0
CVirtualMachine::RunScript (thin forwarder)0x005d0fc0
CSWSAreaOfEffectObject::GetEffectSpellId / SetEffectSpellId0x005945d0 / 0x005945e0

Located while auditing save-slot numbering (QUICKSAVE/AUTOSAVE/REBOOTAUTOSAVE):

FunctionAddress
CClientExoAppInternal::DoQuickSave0x005f4b50
CSWGuiSaveLoadEntry::LoadData0x006c8e50
CSWGuiSaveLoad::LoadPCAutoSave0x006ca250
CSWGuiMainMenu::OnPanelAdded (disk-space probe reusing the reserved AUTOSAVE name)0x0067b6c0
CSWGuiSaveLoad::PopulateGameList0x006cc160
CSWGuiSaveLoad::HandleSaveButton0x006cbb60
CSWGuiSaveLoad::PromptForSaveName0x006cb820
CSWGuiSaveLoad::WriteGame0x006c8790
CSWGuiSaveLoad::ShowGame0x006c89d0
CSWGuiSaveLoadEntry::SetXboxTitle0x006c9780
CGuiInGame::DoQuickLoad0x00633c50
CSWGuiSaveLoadEntry::CSWGuiSaveLoadEntry (constructor)0x006cb940

Located while enumerating AUTOSAVEPARAMS:

FunctionAddress
KOTOR_AUTOSAVE_PARAMS::SaveToGFF0x004b28e0
CStatusSummary::SaveToGFF0x004b26c0
CGuiInGame::GetStatusSummary / SetStatusSummary0x0062f0a0 / 0x0062f040
CGuiInGame::SuppressStatusSummary0x0062f0c0
CGuiInGame::GetPendingStatusSummary0x0062ef70
CGuiInGame::ShowStatusSummary0x0062ef90
CGuiInGame::UpdateStatus0x0062eeb0
CSWGuiStatusSummary::AddAlignmentShift0x00624a70
CSWGuiStatusSummary::AddCredits0x00624ab0
CSWGuiStatusSummary::AddXp0x0062b580
CSWGuiStatusSummary::AddStealthXp0x0062b5a0
CSWVirtualMachineCommands::ExecuteCommandSuppressStatusSummaryEntry0x00547e50
CSWVirtualMachineCommands::ExecuteCommandStartNewModule0x00544390
CClientExoApp::GetMoveToModuleMovies0x005edb60
CClientExoApp::AddMoveToModuleMovie0x005edb50
CClientExoApp::RemoveMoveToModuleMovies0x005ee380
CServerExoApp::GetMoveToModuleStartWaypoint / SetMoveToModuleStartWaypoint0x004aed40 / 0x004aed30
CServerExoApp::SetMoveToModulePending0x004aecc0
CServerExoApp::SetMoveToModuleString0x004aecd0
CClientExoApp::SetLoadScreenByModuleName0x005edcf0
CClientExoApp::GetLoadMusicByModuleName (thunk) / CClientExoAppInternal::GetLoadMusicByModuleName (implementation)0x005edd00 / 0x005f3650
CWorldTimer::GetWorldTime0x004ade40
CWorldTimer::ConvertFromTimeOfDay0x004add90
CSWSModule::GetTime0x004c4100

Located while tracing the CORRUPT.res marker:

FunctionAddress
CGuiInGame::UnpackQuickSaveGame0x006323a0
CopyQuickSaveGameToFutureGame (quicksave counterpart to CopyGameToFutureGame; not fully decompiled, assumed the same shape by symmetry)0x0062fbe0
CERFFile::Read0x005dce50
CERFFile::ReadHeaderVariance0x005dd3c0
CERFFile::ExportFilesFromERF0x005dd710
CERFRes::CopyToFile0x005dd170
CExoFile::FileOpened / Read / Write0x005e6a10 / 0x005e6960 / 0x005e69a0
CExoFileInternal::Read / Write0x005eba40 / 0x005ebc60
CSWGuiSaveLoad::VerifyLoadGame0x006cc0e0
CSWGuiSaveLoad::LoadGame0x006cb0e0
CExoResMan::CleanDirectory0x00409460
CExoResMan::WipeDirectory0x00408e90
CExoAliasListInternal::ResolveFileName0x005eb6b0
CExoBaseInternal::GetResourceExtension0x005e7a00

Located while closing the reserved-name class (proving no fourth slot name reaches the formatter):

FunctionAddress
CSWGuiSaveLoadEntry::GetGameDirectory0x006c8250
CServerExoApp::SaveGame (thin wrapper over CServerExoAppInternal::SaveGame, dispatched from network message handlers)0x004ae6e0
HandlePlayerToServerModuleMessage / HandleServerAdminToServerMessage0x00524800 / 0x00528380
CSWGuiSaveLoad::DeleteGame0x006caa90

Located while tracing the stray PC resource to SwitchPlayerCharacter (the writer/reader itself, CSWPartyTable::SwitchPlayerCharacter at 0x005667c0, was already in this table from an earlier pass):

FunctionAddress
ExecuteCommandSwitchPlayerCharacter (nwscript action dispatch, sole caller of SwitchPlayerCharacter)0x00544910
CSWPartyTable::GetFilename (computes AVAILNPC%d; ruled out as the PC source)0x00563620
CSWPartyTable::UpdateMembers0x00565530
CSWSPlayer::SaveServerCharacter (multiplayer server-vault BIC writer; ruled out)0x005624c0
CSWPartyTable::AddGameInProgress / RemoveGameInProgress (reference-counted mount/unmount of the GAMEINPROGRESS: scratch directory)0x005638d0 / 0x00563950
CServerExoAppInternal::StopServices (nukes GAMEINPROGRESS: at session teardown – the only place a stray PC file is ever cleared)0x004b7e25
CServerExoAppInternal::DoModuleEnterSaveCleanup (prunes stale nested-module entries in GAMEINPROGRESS:; unrelated to PC, recorded to clarify what does and doesn’t get cleaned there)0x004b23ea
CServerExoAppInternal::LoadModule (a second LoadModule address found in this pass; the address recorded earlier in this table for CSWSModule::LoadModule, 0x004b95b0, was found in an earlier session under a different class-qualified name – likely a caller/callee pair rather than a conflict, not reconciled)0x004b98e0

Located while tracing the .rsv intermediate module format through the extension table, save-size estimation, and the load path (CExoBaseInternal::GetResourceExtension was already in this table from an earlier pass, at the same address, confirmed as the same function):

FunctionAddress
CExoBaseInternal::CreateResourceExtensionTable0x005e6d20
CExoBaseInternal::GetResTypeFromExtension0x005e7a40
CExoBase::GetResTypeFromExtension (thin forwarder to the Internal version above)0x005e6670
CServerExoAppInternal::SetEstimatedSaveSize0x004b5f90
CExoResMan::GetResTypeFromFile0x00406650

Located while tracing the Gold read gate through the benched-companion rejoin path:

FunctionAddress
CSWPartyTable::AddMember0x00565620
CSWGuiPartySelection::AcceptParty0x006be560
CSWVirtualMachineCommands::ExecuteCommandSpawnAvailableNPC0x00543ed0

Swoop & Turret Minigame Deep Dive

The MiniGame struct nested inside an ARE’s top-level GFF configures the optional swoop-racing or turret minigame an area can host, gated by a Type field (1 = Swoop, 2 = Turret). A corpus scan of vanilla .are files turned up 53 GFF labels inside this struct that no typed view modelled, concentrated in the handful of area files that actually ship a minigame. This page documents what the loader does with all of them, and the shape they nest in.

(Documented from Ghidra decompilation of swkotor.exe. Entry point: CSWMiniGame::Load (0x006723d0).)

Shape: Player, Enemies, and Obstacles Are Not Siblings

CSWMiniGame::Load reads three things off the top-level MiniGame struct: a single Player struct, an Enemies list, and an Obstacles list. The three diverge sharply in what they carry:

  • Obstacles are the lightest of the three. Each entry is matched by its Name resref to an already-placed object (CSWMiniGameObjectArray::GetMiniGameObjectByName, 0x0066bfb0), and CSWMGObstacle::Load (0x0066d0b0) reads only a nested Scripts struct – no weapon, lifecycle, or geometry data at all.
  • Player and each Enemies entry are both backed by the same underlying object, CSWTrackFollower. CSWMiniPlayer::Load (0x006702f0) and CSWMiniEnemy::Load (0x006705f0) both delegate first to CSWTrackFollower::Load (0x0066fff0) on an embedded follower sub-object, which is where the bulk of the 53 fields actually live, then each reads its own extra fields on top.

So this isn’t one flat struct with 53 siblings – it’s three distinct object shapes sharing a common base, plus movement fields that only make sense on the vehicle the player actually drives.

The Shared Vehicle Base (CSWTrackFollower)

CSWTrackFollower::Load reads the following directly onto the Player or Enemy entry, before any weapon or script data:

FieldTypeAbsent-field behaviour
Hit_Points, Max_HPsDWORDDefault 0; only applied if the read value is greater than 0 – otherwise the object’s already-constructed value is left untouched (carried over, not reset).
Sphere_RadiusFLOATDefault sentinel -1.0; applied only if the read value is >= 0.0.
Invince_PeriodFLOATDefault 0.0, applied whenever the read value is >= 0.0 – trivially true, so this one effectively always writes.
Bump_DamageINTDefault 0, written unconditionally with no gate.
Num_LoopsINTDefault sentinel -10, passed unconditionally into a virtual setter whose own absence handling wasn’t traced further.

Then it reads a Gun_Banks list (covered below), a nested Scripts struct via CSWTrackFollower::LoadScripts (0x0066c740), and a nested Sounds struct via CSWTrackFollower::LoadSounds (0x0066f7e0).

CSWTrackFollower::LoadScripts overrides the base CSWMiniGameObject::LoadScripts (0x0066c420, the same one Obstacles use for their own, smaller Scripts struct) and adds five fields on top of the base set, listed in the override row below. An earlier draft of this sentence said three, which contradicted the page’s own table. Every script field, base and override alike, follows the same pattern: default empty CResRef, and always written into an indexed script-slot array via a virtual setter regardless of whether the file supplied a value – an absent field overwrites with empty rather than leaving a prior value in place.

FieldOwnerNotes
OnCreate, OnHitBullet, OnHitFollower, OnAnimEvent, OnHeartbeatBase (CSWMiniGameObject::LoadScripts)Shared by Obstacles’ own Scripts struct too. Confirmed by direct decompilation: the function reads exactly these five fields, in this order, OnHeartbeat trailing OnAnimEvent as the fifth and last read.
OnDamage, OnDeath, OnFire, OnHitObstacle, OnTrackLoopOverride (CSWTrackFollower::LoadScripts)Player/Enemy only.

OnHeartbeat was absent from an earlier draft of this page, and the reason is worth keeping. The corpus scan that produced the field inventory keyed on bare labels rather than paths, and the ARE root has an OnHeartbeat of its own that a typed view already models, so the nested one was counted as covered and never surfaced. Every vanilla player carries a real script name in it. Path-keyed scans do not have this blind spot, which is why the tooling moved to them. It was initially re-added to the base set by inference (five labels in every Obstacles Scripts struct, four documented reads, obstacles reaching only the base loader) rather than by audit; that inference has since been confirmed directly against the decompiled function, so the base set stands at five fields, audited rather than inferred.

CSWTrackFollower::LoadSounds reads Engine and Death, both defaulting to empty CResRef and always written (same overwrite-on-absence pattern as scripts). A non-empty Engine sound is additionally forced into looping playback.

The Weapon Subsystem (Gun_Banks)

Gun_Banks is a list nested on the Player or an Enemy entry, not a flat field. Each entry is read by a class-specific virtual: CSWMiniPlayer::LoadGun (0x0066f890) or CSWMiniEnemy::LoadGun (0x0066fb20). Both read BankID and Gun_Model directly on the bank entry, then a nested Bullet struct, then Fire_Sound back on the bank entry itself – Fire_Sound is a sibling of Bullet, not a field inside it.

FieldOwnerAbsent-field behaviour
BankIDBank entryRead with a default of 0xffffffff if absent. The gate that decides whether to build the bank at all checks the resolved value against that same literal, not whether the field was present in the file – confirmed identical in both CSWMiniPlayer::LoadGun and CSWMiniEnemy::LoadGun. So an absent BankID and one explicitly written as 0xffffffff are indistinguishable to the loader: both skip bank creation the same way. There is no separate “field was present” check anywhere in this gate.
Gun_ModelBank entryDefault empty, gated by resref validity; invalid or absent aborts the whole bank.
Damage, Lifespan, Rate_Of_Fire, Speed, Target_TypeBullet structEach defaults to 0/0.0, but each read also reports a presence flag that gates whether the next field in this chain is even attempted. If any one of these five is genuinely absent, the chain truncates silently and the bank is never created – there’s no partial bank built from defaults.
Bullet_Model, Collision_SoundBullet structDefault empty; read unconditionally once Target_Type has succeeded, no further gating.
Fire_SoundBank entry (sibling of Bullet)Default empty, read unconditionally after the Bullet struct completes.

CSWMiniEnemy::LoadGun additionally reads four AI-targeting fields directly on the bank entry (also siblings of Bullet, bundled into a CSWMGTargettingParameters value): Sensing_Radius, Horiz_Spread, Vert_Spread, Inaccuracy. These are enemy-only – the player’s own guns don’t carry them. Each defaults to 0.0 and follows the same presence-gate chain as the Bullet fields: absence of any one aborts the read before Bullet is even fetched.

Enemy-Only: Trigger

CSWMiniEnemy::Load reads one more field directly on the Enemy entry itself, not on a gun bank: Trigger (BYTE), stored on the shared CSWTrackFollower base. It defaults to 0 when absent, and that default is applied unconditionally – the read’s own presence flag is never inspected, so a missing Trigger stamps 0 on load exactly as if the file had written it explicitly, rather than leaving the object’s already-constructed value in place.

Despite carrying a nonzero value in most vanilla enemy entries, Trigger looks write-only in this build: an exhaustive check of CSWTrackFollower’s and CSWMiniGame’s own behavior methods (Update, Go, Hit, OnDamage, OnDeath, OnHitObstacle, hit-check dispatch) and the script-facing per-follower accessor (which switches over hitpoints, max hitpoints, loop count, gun-bank count, and invulnerability) turned up no read of this value anywhere. Nothing observed gates on it, animates from it, or exposes it to scripts. This closes out the last previously-unmodeled field in this struct: all 53 are now traced.

Movement and Track Geometry (Player-Only)

A block of fields sit flat on the Player struct itself, read directly by CSWMiniPlayer::Load, and never appear on Enemies or Obstacles at all – they describe the track boundaries and the player vehicle’s own acceleration curve, not anything an enemy or a static obstacle needs.

FieldTypeAbsent-field behaviour
Minimum_SpeedFLOATDefault sentinel -1.0; applied only if >= 0.0.
Maximum_SpeedFLOATDefault 100.0; applied only if >= 0.0 (effectively always).
Accel_SecsFLOATDefault sentinel -1.0. If the read value is exactly 0.0, acceleration derives as (max_speed - min_speed). If it’s negative (and not the sentinel path), the whole acceleration derivation is skipped. Otherwise, (max_speed - min_speed) is divided by the read value. The raw field is never stored; only the derived acceleration is kept.
TunnelXPos / TunnelXNeg, TunnelZPos / TunnelZNeg (a Vector pair)FLOATDefault 0.0 each, written unconditionally – no carry-over gate.
TunnelInfiniteVectorRead via the vector default path, {0, 0, 0}, unconditional.
Start_Offset_X / Start_Offset_Y / Start_Offset_ZFLOAT (assembled into one Vector)Default 0.0 each, fed to SetOrigin unconditionally.
Target_Offset_X / Target_Offset_Y / Target_Offset_ZFLOAT (three independent floats, not assembled into a Vector)Default 0.0 each, unconditional.

The “Present But Never Live” 13 Are Genuinely Read

Unlike the unmodelled DLG field set, where a binary-wide string search settled the question outright (none of those five labels exist in the executable at all), that shortcut does not apply here: every one of the 53 labels in this subsystem, including the 13 that never carried a real value across the four scanned area files, exists in the binary and is read by real loader code with real default-handling, per the tables above (Bump_Damage, Engine, Maximum_Speed, Minimum_Speed, OnAnimEvent, OnHitBullet, OnTrackLoop, Start_Offset_Y, Start_Offset_Z, Target_Offset_X, Target_Offset_Y, TunnelYNeg, TunnelYPos). Nothing in the read path distinguishes them from their “live” siblings in the same functions – they simply never happened to diverge from the engine default in the four vanilla files that carry a minigame at all. None of the 53 are toolset-only scaffolding.

One Subsystem, Composed From Several Concerns

The field set is best understood as three genuinely separate concerns sharing a common vehicle base, not one flat bag of fields:

  • Weapon – a Gun_Banks list nested on the vehicle (Player or Enemy). Each bank is BankID / Gun_Model / Fire_Sound plus a nested Bullet struct for ballistics, and, enemy-only, sibling AI-targeting spread fields.
  • Lifecycle – flat fields directly on the shared CSWTrackFollower-backed vehicle (Hit_Points / Max_HPs / Invince_Period / Bump_Damage / Sphere_Radius / Num_Loops), plus the nested Scripts and Sounds structs.
  • Movement / track geometry – Player-struct-only flat fields (Tunnel*, *_Offset_*, the Accel_Secs/Minimum_Speed/Maximum_Speed derivation). Meaningless on an Enemy or Obstacle, and never read there.

Obstacles sit outside this hierarchy entirely, as a distinct, much lighter leaf with only a Scripts struct and nothing else.