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.0or 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_gffare honest projections that model only the enumerated fields; byte-exact preservation is the rawGfftree’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.
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
13distinct 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
-
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.
-
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 inrakata-generics(Utc,Uti,Are, …) are explicitly honest projections that model only the fields they enumerate; byte-exact preservation stays with the rawGfftree. See Typed Views and Raw GFF below for the full rule.
-
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::Windows1252since that’s what the engine actually uses under the hood. No silently stripping weird characters with lossless backups.
- All text decoding goes through
(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 inrakata-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 thethiserrorcrate. Do not use generic stringly-typed errors orBox<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 viaResultor 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-formatsare 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 kindResourceTypecarries, so it lives inrakata-core. A 2DA parser has no business knowingappearance.2daexists. - Capability – something can hand me a table by name: the
TwoDaSourcetrait. It returns&TwoDa, sorakata-formatsis the lowest crate that can name the return type. A home in core would needcore -> 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 inrakata-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:
- Primitives: Grabbing a file out of a single archive (like unpacking a standalone ERF or BIF file).
- Composition: Treating related archive sets as a single “Module” (like grouping a
.modfile with its matching_s.rimand_dlg.erffiles so they load transparently together). - Game-wide: A
GameVfsrooted at one install that owns each tier (chitin / BIFs, theOverride/directory, caller-pushed extra overrides, the single activeCompositeModule, 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
Gfffromrakata-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
Tagis read; theTagon 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[].TemplateResRefappears 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[].Subtypeis read at its path, so it is modelled, even though which property kinds actually consume it varies;decoded/uti.rskeepingsubtype_idon 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. ForSubtype, yes. ForWaypointList[].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&selfborrow-free reads against that cache.Uti::resolve(&mut impl TwoDaSource) -> UtiResolved<'_>. Single-scope shortcut forproject(...).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.mdbefore 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):
- Reference Policy: Treat existing tools (like PyKotor) as behavioral references, not copy sources.
- No Copy-Paste: Do not copy source code blocks, large comments, or docstrings from third-party sources into Rust files.
- Re-Derivation: Derive implementation logic from format documentation, observed behavior (hex dumps), and black-box fixture analysis.
- 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 runscargo clippyacross all targets. - pre-push: Runs
cargo test --workspace --all-featuresto 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 rawaskeyword; lean onFrom,TryFrom, or.into(). If an unsafe cast is truly unavoidable (like anf32down to ani32), 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]orStringprimitives around. - Strict Error Handling: We explicitly forbid
.unwrap()and.unwrap_unchecked()in library code. Everything must propagate cleanly viaResultusing typed error enums (managed viathiserror). - 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_gffsilently drops unmodelled fields andto_gffwrites only the modelled ones. Do not add anextra_fieldsaccumulator on the struct; callers that need byte-exact preservation work with the rawGfftree 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&selfborrow-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 inforloops. - Zero-cost Features: Optional functionality (like
serdeserialization ortracingtelemetry) 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; runcargo test --test gen_fixtures -- --ignoredto 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 printstest result: ok. Hencescripts/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 throughchildren: Nonepassed 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.mdlfiles) 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.
Legal Basis for Reverse Engineering
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
| Format | Status | Notes |
|---|---|---|
| BIF | Full | Supports variable/fixed tables. Deterministic 4-byte payload alignment. BZF compression feature-gated. |
| KEY | Full | First-match lookup semantics (native verified). Duplicate key insertions ignored. |
| ERF | Full | Supports ERF/MOD/SAV. Optional blank-block emission for MODs is explicit opt-in. |
| RIM | Full | Supports V1.0. Offset fallback handled. Tight packing. |
GFF & Blueprints
| Format | Status | Notes |
|---|---|---|
| GFF Structure | Full | Core binary parity for structs/lists/fields. Localized strings supported. Stable list ordering. |
| Generics | Generics | 13 typed blueprints completed: ARE, DLG, GIT, IFO, UTC, UTD, UTE, UTI, UTM, UTP, UTS, UTT, UTW. Tied into rakata-lint. |
| FAC | Documented | Faction & reputation table. Engine-audited spec page; not yet wrapped in rakata-generics. |
| BIC | Reference | Aurora 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
| Format | Status | Notes |
|---|---|---|
| MDL/MDX | Full | Binary reader/writer with full geometry, node hierarchy, controllers, and MDX vertex data. ASCII reader/writer for modder interop. In-game verified. |
| BWM / WOK | Full | V1.0 binary tables (vertices, faces, materials, etc.). Strict bounds validation. |
Texture Formats
| Format | Status | Notes |
|---|---|---|
| TPC | Full | Container header/payload/footer. Canonical pixel-type mapping (DXT5 for type 4). Mip payload sizing matches native right-shift. |
| DDS | Full | Supports standard D3D headers and K1-specific CResDDS prefix (20-byte metadata). |
| TGA | Full | Reader normalizes to RGBA8888. Canonical mode rejects grayscale RLE. Lossless passthrough when source pixels are unmodified. |
| TXI | Full | ASCII format. Case-insensitive command tokens (native verified). Coordinate block support. |
Text & Data Formats
| Format | Status | Notes |
|---|---|---|
| 2DA | Full | Binary V2.b. |
| TLK | Full | Strict language-aware decode/encode. Validated against test.tlk. |
| VIS | Full | ASCII format. Case-insensitive room normalization. Deterministic ordering. |
| LYT | Full | ASCII format. Strict Windows-1252 text handling. Count-driven parsing. |
| LTR | Full | V1.0 headers. 28-char probability tables. |
Audio Formats
| Format | Status | Notes |
|---|---|---|
| WAV | Full | Standard RIFF + KotOR SFX/VO obfuscation wrappers. MP3-in-WAV unwrapping support. |
| LIP | Full | V1.0 header + keyframes. Deterministic writer. |
| SSF | Full | V1.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.
| Format | Status | Notes |
|---|---|---|
| NCS / NSS | Deferred | NWScript Source and Compiled bytecode. NCS decompilation is slated for future work via an independent pipeline. |
| GUI | Deferred | Graphical User Interface layout blueprints (GFF). |
| JRL | Deferred | Journal and quest tracking blueprints (GFF). The in-save journal is documented on the partytable page. |
| PTH | Deferred | Pathfinding graphs and navigation waypoints (GFF). |
| ITP | Deferred | Item Palette definitions (GFF). |
| BIK | Deferred | Bink 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.bifis mapped usingchitin.keyas 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
| Format | Name | Layout & Purpose |
|---|---|---|
| BIF | Binary Information File | Massive binary payload silos containing raw game assets packed end-to-end. |
| KEY | Global Index File | Master lookup table mapping precise file names directly to their internal BIF payload offset block. |
| ERF | Encapsulated Resource File | Extremely versatile package format utilized heavily for modules (.mod), stateful save games (.sav), and generic archives (.erf). |
| RIM | Resource Image | Stripped-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
| Property | Value |
|---|---|
| Extension(s) | .bif, .bzf (compressed; mobile ports only) |
| Magic Signatures | BIFF (version V1 ) for both |
| Type | Archive Blob Payload |
| Rust Reference | View 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
KeyFileAPI (rakata_extract::keyfile::KeyFile), which automatically ties.keyindex files to their.bifdata 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.bifalone 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.
| Property | Uncompressed | Compressed |
|---|---|---|
| Signature | BIFF / V1 | BIFF / V1 (identical) |
| Header and tables | as documented above | byte-for-byte the same shape |
data_size in the table | the resource’s length | the uncompressed length |
| How the KEY names it | data\2da.bif | data\2da.bif (still .bif) |
| On-disk filename | 2da.bif | 2da.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 theBIFFsignature. 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
BZFsignature; 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.bifand 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 Event | Engine Behavior & Result |
|---|---|
| Signature Check | The 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 Loading | The 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 Bypass | The 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 Extraction | When 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_offsetinteger directly into a raw Cfseek(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
| Property | Value |
|---|---|
| Extension(s) | .key |
| Magic Signatures | KEY (version V1 ) |
| Type | Archive Global Index |
| Rust Reference | View 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_entriesbounding archive paths and sizes, alongside a massive array ofKeyResourceEntrystructures fusing a standardResRefstring and a formatTypeCodeto a bit-packed numericResourceId. - 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 utilizesor_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.
| Action | Engine Behavior |
|---|---|
| Signature Check | Validates exactly for the KEY magic and the explicit V1 version signature. |
| Version Branching | There 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 Mapping | Extrapolates 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
KEYtable loading extremely early in the application lifecycle duringCExoBase::InitObject. If a globalKEYfails 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
| Property | Value |
|---|---|
| Extension(s) | .erf, .mod, .hak, .sav |
| Magic Signatures | ERF , MOD , HAK , SAV (version V1.0) |
| Type | Self-Contained Archive |
| Rust Reference | View 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::ErfIndexparses 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.RimIndexis the RIM equivalent. They stay separate types because their tables genuinely differ. - Reading a module:
rakata_extract::CompositeModuleis what makes the container invisible. It merges a module’s.rim,_s.rim,_dlg.erfand.modparts 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.
| Action | Engine Behavior |
|---|---|
| Signature Check | Explicitly validates the header against exactly matching ERF , MOD , or HAK signatures, paired with the mandatory V1.0 version string. |
| Unchecked Saves | The 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 Truncation | The 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
0x2Cdown to0xA0inside 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
| Property | Value |
|---|---|
| Extension(s) | .rim |
| Magic Signatures | RIM (version V1.0) |
| Type | Lightweight Archive |
| Rust Reference | View 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::RimIndexparses the header and entry table once and reads entry bytes on demand.ErfIndexis the ERF equivalent. They stay separate types because the tables genuinely differ, even though the job is the same. - Reading a module:
rakata_extract::CompositeModulemerges a module’s.rim,_s.rim,_dlg.erfand.modparts 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.
| Action | Engine Behavior |
|---|---|
| Signature Check | Explicitly validates the exact RIM magic and the V1.0 version string implicitly upon loading. |
| Header Evaluation | The 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
ERFdead zone, RIM files feature a massive 96 bytes of completely inert padding sitting physically between offsets0x18and0x77inside 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
| Property | Value |
|---|---|
| Extension(s) | .gff, .utc, .uti, .utp, .ute, .utd, .dlg, .are, .ifo, etc. |
| Magic Signature | Target type (e.g. UTC ) / V3.2 |
| Type | Generic Hierarchical Data |
| Rust Reference | View 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::GffValuemirrors 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 Order | Section Component | Memory Footprint / Quirk |
|---|---|---|
| Phase 1 | Root Header | Exactly 56 bytes (0x38). |
| Phase 2 | Struct Array | 12B × struct_count |
| Phase 3 | Field Array | 12B × field_count |
| Phase 4 | Label Array | 16B × label_count |
| Phase 5 | Field Data Blob | Arbitrary bounds constraint. |
| Phase 6 | Field Indices | Dynamic array bounds. |
| Phase 7 | List Indices | Dynamic 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 byCResGFF::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 requestV2.0, but it never reaches disk). Read theV3.2you observe; the version is not a per-resource signal.
Note
Field-label lookup is case-sensitive. Every
CResGFF::ReadField*wrapper resolves its label throughCResGFF::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 –FortBonusversus the engine’sfortbonus, for instance – never matches, full stop; it isn’t a fallback path, it’s a different, unmatched string. This holds for the wholeReadField*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. Eachfrom_gffextracts only the documented fields and silently drops anything else;to_gffwrites only those documented fields. The rawGfftree 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.
| Ext | Type | Core Function |
|---|---|---|
.are | Area Static Blueprint | Defines overarching static world properties (weather, day/night limits, physics constraints). |
.dlg | Dialogue | Encapsulates the conversation graph, branching logic, and cinematic execution sequences. |
.git | Game Instance Template | The physical object manifest. Orchestrates exact placement, vector orientations, and template spawning. |
.ifo | Module Info | Root environment metadata bridging modules together and orchestrating spawn states. |
.utc | Creature | Instantiates NPCs, stat-blocks, and character body configurations. |
.utd | Door | Configures transitions, linked bounds, and structural barriers. |
.ute | Encounter | Orchestrates dynamic boundary triggers and valid enemy spawning constraints. |
.uti | Item | Unifies structural stats across weapons, armors, and consumables. |
.utm | Store | Limits merchant arrays and details markup/markdown behaviors. |
.utp | Placeable | Standardizes interactive storage boxes, unusable statues, and deployable traps. |
.uts | Sound | Configures local dynamic audio emitters and distance volume calculations. |
.utt | Trigger | Plots physical interactive polygons tracking spatial events. |
.utw | Waypoint | Anchors 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
| Property | Value |
|---|---|
| Extension(s) | .are |
| Magic Signature | ARE / V3.2 |
| Type | Area Static Blueprint |
| Rust Reference | View 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.
| Category | Covers | Representative fields |
|---|---|---|
| Core Identity & State | The area’s tag, localized name, and interior/exterior state flags | Tag, Name, Flags, RestrictMode |
| Weather & Terrain | Rain, snow, and lightning chances, wind strength, and grass rendering | ChanceRain, WindPower, Grass_TexName |
| Lighting & Fog | Separate sun and moon ambient/diffuse tints, fog ranges, and shadow limits | SunAmbientColor, MoonFogNear, ShadowOpacity |
| Stealth XP | The stealth-run XP pool an area can award | StealthXPMax, StealthXPCurrent, StealthXPLoss |
| Event Hooks | The area-level event scripts | OnEnter, OnExit, OnHeartbeat, OnUserDefined |
| Map & Rooms | Minimap projection data and the per-room sound list | Map, Rooms |
| Minigame | The optional nested swoop or turret minigame configuration | MiniGame |
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::SaveModuleFinishhands the area’s staticAREresource 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 Category | Engine Property & Behavioral Quirk |
|---|---|
| Identity | Name (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. |
| Identity | Tag (String) -> Lowercased on load (via CExoString::LowerCase). The only tag to behave this way! |
| Scripts | OnHeartbeat, OnUserDefined, ... -> CResRef script payloads. |
| State Flags | Flags (DWord) -> Bit 0 explicitly marks an Interior environment. |
| State Flags | RestrictMode (Byte) -> Hardcoded Event: Changing this to a non-zero value during gameplay forces CSWPartyTable::UnstealthParty. |
| Identity | CameraStyle (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
| Field | Type | Engine Evaluation |
|---|---|---|
ChanceFog | INT | Stored 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, WindPower | INT | Warning: 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_TexName | ResRef | If empty or invalid, the engine forces a hard fallback to "grass". |
AlphaTest | FLOAT | Defaults to 0.2 (older tools commonly assume 0.0). |
ModSpotCheck, ModListenCheck | INT | Perception-check modifiers. Both default to 0 if missing, unconditional. |
Grass_Density, Grass_QuadSize, Grass_Prob_LL/LR/UL/UR | FLOAT | Grass 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 Groups | Type | Engine Evaluation |
|---|---|---|
Fog Ranges (MoonFogNear/Far, SunFogNear/Far) | FLOAT | Defaults to an immense distance of 10000.0. The engine aggressively clamps values to be ≥0.0. |
Tints (*AmbientColor, *DiffuseColor, *FogColor) | DWORD | Processed 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) | BYTE | Basic 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
DayNightCycledoesn’t inherit its own constructed default. The area constructor setsday_night_cycle = 1(cycle on) before any GFF read happens, but the read itself uses a hardcoded literal0as its fallback, not the constructed value – so an area missingDayNightCycleloads 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 means0, not1.IsNightandLightingSchemeshare the ordinary unconditional-0pattern with no such mismatch.
NoRest,TransPending,TransPendNextID, andTransPendCurrIDalso carry over a constructed value (all0/false), same practical outcome as an unconditional default given the single-call-path caveat above.
Note
Grass_Emissiveand the entireDirty*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 inswkotor.exe’s string table, verified against a binary-wide search that does find every neighboringGrass_*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 asDisableTransit/NoHangBack/PlayerOnly/PlayerVsPlayerbelow – toolset-only, invisible to K1’s engine.
Map Transitions & Saving states
| Feature Category | Engine Evaluation & Triggers |
|---|---|
| Minimap Logic | Geographic 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 Type | If 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 Bias | Area maps evaluate MapZoom to a default scaling scalar of 1, not 0! |
| Stealth Save-States | The 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:
| Field | Injection Default / Constraint |
|---|---|
LateralAccel | Defaults safely to 60.0. |
MovementPerSec | Scales to 6.0 (Swoops), 90.0 (Turrets), or 0.0 otherwise! |
Bump_Plane | Bounds are heavily clamped to 0..3. |
| Nested Arrays | The 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.
- ARE-001 (Context Discards): Warns when interior areas (
Flags & 1) carry non-zeroChanceRain,ChanceSnow,ChanceLightning, orWindPower; the engine discards weather for interiors. - ARE-002 (Weather Truncation): Warns when
ChanceRain,ChanceSnow,ChanceLightning, orWindPowerexceed 255; the engine truncates these to bytes at runtime. - ARE-003 (Fog Clamping): Warns when
MoonFogNear/FarorSunFogNear/Farare negative; the engine clamps fog distances to >= 0.0. - ARE-004 (Tag Lowercasing): Warns when
Tagcontains uppercase characters; the engine lowercases area tags on load. - ARE-005 (Toolset Fields): Informs when
DisableTransit,NoHangBack,PlayerOnly, orPlayerVsPlayerare set; never read by the K1 engine.
Phase 2 (resource existence, requires LintContext)
Implemented under rakata_lint::rules::are_range.
- ARE-006 (Resref Existence): Warns when any of
OnEnter,OnExit,OnHeartbeat, orOnUserDefined(.ncs) does not resolve, or when anyRooms[i].PartSounds[j].Sound(.wav) does not resolve in the configured resource sources.
Pending
- Grass Texture Fallback: Informs when
Grass_TexNameis 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
| Property | Value |
|---|---|
| Extension(s) | .dlg |
| Magic Signature | DLG / V3.2 |
| Type | Dialogue Blueprint |
| Rust Reference | View 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.
| Category | Covers | Representative fields |
|---|---|---|
| Root Configuration | Conversation-wide rules: skippability, pacing delays, and cinematic-versus-computer type | Skippable, DelayEntry, ConversationType, ComputerType |
| Termination Hooks | Scripts fired when the conversation ends or aborts, plus the ambient audio bed | EndConversation, EndConverAbort, AmbientTrack |
| Node Graph | The NPC entry and player reply nodes, plus the entry points into the graph | EntryList, ReplyList, StartingList |
| Per-Node Delivery | Each node’s localized line, voice-over, camera framing, fades, and follow-up links | Text, VO_ResRef, CameraAngle, RepliesList |
| Cutscene Casting | Stunt-model substitution and animation loops for cinematic participants | StuntList, 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 Category | Engine Property & Type | Notable Default or Behavioral Quirk |
|---|---|---|
| Identity & Rules | CameraModel (ResRef), DelayEntry/Reply (DWord) | CameraModel defaults to an empty resref. DelayEntry and DelayReply safely default to 0 if missing. |
| Identity & Rules | Skippable (Byte) | Explicitly defaults to 1 (True) if missing. |
| Logic Hooks | EndConversation, EndConverAbort (ResRefs), AmbientTrack | Fire when the dialogue terminates abruptly or via conclusion. Fallback to empty strings "" if missing. |
| Hardware Interfacing | ConversationType (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 Interfacing | ComputerType (Byte) | Only evaluated if ConversationType is 1. Otherwise, standard camera positioning and animations are bypassed. |
| Equipment & Actions | UnequipItems, UnequipHItem, AnimatedCut, OldHitCheck | AnimatedCut 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.
| Field | Type | Engine Evaluation |
|---|---|---|
Text | LocString | The spoken localized string. |
Script, Speaker, Quest | Strings/ResRefs | Standard execution scripts and entity mapping. Speaker and Quest both default to an empty string if missing. |
WaitFlags, QuestEntry | DWord | Defaults to 0 if missing; WaitFlags is separately mutated by the Delay special case below, a later write, not its own absent-value. |
Sound, VO_ResRef | ResRef | Sound 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. |
Delay | DWord | Delay Special Case: If value is 0xFFFFFFFF, the engine explicitly reads from the root DelayEntry/DelayReply field instead and modulates WaitFlags! |
FadeType | Byte | Determines 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.
PlotIndexreads with a fallback of0, not the-1rakata’s typed view currently defaults to.PlotXPPercentagereads with a fallback of0.0, not1.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), not0or1. That’s what the field resolves to when absent and neither the runtime-downgrade condition (SoundandVO_ResRefboth invalid, which forces it to0) nor an explicit file value overrides it.FadeColor,FadeDelay, andFadeLengtheach default to zero (black,0.0,0.0respectively) at their own read site – distinct from the laterFadeType == 0pass that zeroes them again regardless of what was just read.
Viewport Framing (LoadDialogCamera)
| Field | Type | Engine Evaluation |
|---|---|---|
CameraID | INT | Dependent Field: Only permitted when CameraAngle = 6 (Placeable Camera). Otherwise, the engine forces the ID to -1 regardless of the static binary value. |
CamFieldOfView | FLOAT | Aggressively validated. If the property is entirely missing or is explicitly negative, the engine forces the perspective to -1.0. |
CamHeightOffset, TarHeightOffset | FLOAT | Standard float deltas. Both default to 0.0 if missing. |
Listener | CExoString | Defaults to an empty string if missing. |
CameraAngle | DWord | Defaults 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. |
CameraAnimation | WORD | Defaults to 0 if missing. |
CamVidEffect | INT | Defaults to -1 if missing, confirmed against the binary and matching rakata’s current code. Never read again after the store. |
Link Fields: Active and Index
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)
Activegenuinely 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
Indexresolves to0and is bounds-checked exactly like an explicit0– the already-documented fatal-bounds-check behaviour runs against whatever value ends up stored, absent or not. Since0is a valid index into every target list, an absentIndexdoesn’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 (
RepliesListwithin an Entry Node): Maps theIndex(DWORD) to the overarching.ReplyListbounds. Unique in that it exclusively parses theDisplayInactiveByte. - Reply -> Entry Links (
EntriesListwithin a Reply Node): Maps theIndexto the.EntryListbounds. - Start Indices (
StartingList): Uses the exact same linkage schema as a Reply->Entry link. ValidatesIndexagainstentry_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
Indexpaths are strictly evaluated against the internal array bounds prior to traversing. If a node tries to link out of bounds, it immediately triggers a fatalLoad Failurewithin the engine.
DisplayInactive Gates Whether a Failing Link Is Hidden or Shown Disabled
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 overTextexists 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 ownVO_ResRefplayback resref. Never consumed by the runtime.Comment: the same dead-authoring-metadata pattern already documented for UTC and UTD’sCommentfields, 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.IsChildandLinkComment: the corpus counts line up exactly (LinkCommentpresent in exactly the 542 files whereIsChildcarries 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.dlgauthoring format, invisible to and unenforced by the shipped game.
Ancillary Configuration Lists
- AnimList: Defines custom
Participantmodels and their accompanyingAnimation(WORD) action index to loop. - StuntList: Dictates which
StuntModelshould proxy standard rendering behavior for a givenParticipant.
Implemented Linter Rules (Rakata-Lint)
Phase 1 (intra-resource, no context)
Implemented under rakata_lint::rules::dlg.
- DLG-001 (Camera Angle Compliance): Warns when
CameraIDis populated whileCameraAngle != 6; the engine forces the ID to -1. - DLG-002 (Conversation Type Mismatch): Warns when
ComputerTypeis set butConversationType != 1(Computer Dialog); ComputerType is dead data otherwise. - 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. - DLG-004 (Fatal Bounds Checking): Errors when any
Indexin a node’s link list, the starting list, or a reply list exceeds the target array bounds; this triggers a fatal engine load failure. - DLG-005 (Context Zeroing): Warns when
FadeDelay,FadeLength, orFadeColorare configured butFadeType=0; the engine discards the timings.
Phase 2 (resource existence, requires LintContext)
Implemented under rakata_lint::rules::dlg_range.
- DLG-006 (Resref Existence): Warns when any of
EndConversation/EndConverAbort(.ncs),CameraModel(.mdl),AmbientTrack(.wav), per-stuntStuntList[i].StuntModel(.mdl), or per-nodeScript(.ncs),Sound/VO_ResRef(.wav), andLinks[j].Activecondition 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
| Property | Value |
|---|---|
| Extension(s) | .fac |
| Magic Signature | FAC / V3.2 |
| Type | Faction & Reputation Table |
| Rust Reference | Not 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:
| Function | Address | Covers |
|---|---|---|
CFactionManager::SaveFactions | 0x0052b790 | FactionList write |
CFactionManager::SaveReputations | 0x0052b830 | RepList write (only non-100 pairs emitted) |
CFactionManager::LoadFactionsFromSaveGame | 0x0052b5c0 | FactionList read |
CFactionManager::LoadReputationsFromSaveGame | 0x0052bbe0 | RepList read; the 100-baseline rebuild and 0-100 clamp |
CFactionManager::GetIsNPCFaction | 0x0052b280 | Global vs personal faction model |
CFactionManager::CreateDefaultFactions | 0x0052bce0 | Hardcoded default set used when no table loads |
CFactionManager::LoadFactions | 0x0052b490 | Fresh-game path (repute.2da); origin of the FactionParentID sentinel, see below |
ExecuteCommandGetNearestObject | 0x0054b550 | Reaction bands (0-10 / 11-89 / 90-100), corroborated by placeable/door/trigger usability checks |
FactionList fields
| Field | Type | Meaning |
|---|---|---|
FactionName | CExoString | Display/lookup name of the faction. |
FactionParentID | DWORD | Read and round-tripped, but never consulted: see FactionParentID is a dead sentinel below. |
FactionGlobal | WORD | Whether 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.
| Field | Type | Meaning |
|---|---|---|
FactionID1 | DWORD | Source faction id. |
FactionID2 | DWORD | Target faction id. |
FactionRep | DWORD | Standing 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 at100), then applies theRepListentries as overrides.FactionRepis clamped to0-100on load (values at or above101snap to100, negatives snap to0). A reader that treats absent pairs as0will 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:
FactionRep | Reaction |
|---|---|
0-10 | Hostile (treated as an enemy) |
11-89 | Neutral |
90-100 | Friendly (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.facandrepute.2daare two different resources, and only one of them is this format.repute.facis the FAC GFF: the runtime/saved faction table (resrefREPUTE, typeFAC). It is never a loose file. It lives bundled insideSAVEGAME.savand inside some module archives.repute.2dais the static definition table the engine reads to build factions for a fresh game, and it is thereputeentry you will find inchitin.key/2da.bif. Go looking forrepute.facon disk and you will not find it. Onlyrepute.2daturns 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
| Property | Value |
|---|---|
| Extension(s) | .git |
| Magic Signature | GIT / V3.2 |
| Type | Instance Blueprint |
| Rust Reference | View 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.
| Category | Covers | Representative fields |
|---|---|---|
| Root Behavior | Template-versus-inline loading mode and the live weather state | UseTemplates, CurrentWeather, WeatherStarted |
| Object Instance Lists | One list per entity class, placing creatures, doors, placeables, triggers, sounds, encounters, waypoints, stores, items, cameras, and area effects | Creature List, Door List, TriggerList, SoundList |
| Per-Instance Placement | Each element’s template reference, position, and orientation (field naming varies by entity class; see below) | TemplateResRef, XPosition, Bearing, ObjectId |
| Saved Snapshots | The full inline object each list holds instead when UseTemplates = 0, as a savegame GIT stores it | SavedCreature, SavedDoor, SavedPlaceable, SavedTrigger |
| Area Singletons | The stealth and ambient-audio state struct, plus the save-only minimap exploration blob | AreaProperties, 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
| Field | Type | Engine Evaluation |
|---|---|---|
UseTemplates | BYTE | Controls whether object arrays read TemplateResRef to construct entities, or fall back to inline evaluation. |
CurrentWeather | BYTE | Standard 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. |
WeatherStarted | BYTE | Standard 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
UseTemplatesis the discriminator. A module’s static.gitsetsUseTemplates = 1: each object element is a sparse placement that carries aTemplateResRef, and the engine loads the matching blueprint (.utc/.utd/.utp/.utt/…) and overlays the few instance fields the element holds. A savegame GIT (bundled insideSAVEGAME.sav) instead setsUseTemplates = 0: each element is a full self-contained snapshot read field by field, with noTemplateResRefand no blueprint load. A field missing from aUseTemplates = 0element 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-006TemplateResRefset 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.
| List | Static form | Saved form |
|---|---|---|
Creature List | GitCreature | SavedCreature |
Door List | GitDoor | SavedDoor |
Placeable List | GitPlaceable | SavedPlaceable |
TriggerList | GitTrigger | SavedTrigger |
StoreList | GitStore | SavedStore |
SoundList | GitSound | SavedSound |
List (items) | GitItem | SavedItem |
Three lists sit outside that shape:
WaypointListis a plainVec<GitWaypoint>, becauseLoadWaypointsignores the flag entirely and there is only ever one form to read.AreaEffectListis a plainVec<GitAreaEffect>, because these objects have no blueprint at all, so the saved form is the only form.Encounter Listis 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 Lists | Position Paradigm | Orientation Paradigm |
|---|---|---|
| Creatures, Triggers, Items, Waypoints, Stores | XPosition, YPosition, ZPosition | XOrientation, YOrientation, ZOrientation (vector) |
| Doors, Placeables | X, Y, Z | Bearing (single float angle) |
| Area Effects | PositionX, PositionY, PositionZ | OrientationX, OrientationY, OrientationZ (vector) |
| Sounds, Encounters | XPosition, 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
StoreListorAreaEffectList) inadvertently resolves to0.0unconditionally, 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 Name | Struct Target | Engine Triggers & Fallbacks |
|---|---|---|
| Creature List | LoadCreatures | Positions are explicitly validated defensively through ComputeSafeLocation bounds. |
| Door List | LoadDoors | Save states trigger LoadObjectState. External templates dynamically route to LoadDoorExternal. |
| WaypointList | LoadWaypoints | Completely ignores UseTemplates–it solely relies on inline data! Z-height is shifted dynamically via ComputeHeight. |
| TriggerList | LoadTriggers | Geometry 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, theLoadCreature/LoadFromTemplatepair viaReadStatsFromGff,LoadDataFromGff) readsTagunconditionally 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 – neverTag. - Trigger: same pattern, and
Tagsits in the identical family as the door overlay –CSWSArea::LoadTriggersoverlaysTransitionDestination/LinkedTo/LinkedToModule/LinkedToFlagsplus position/geometry back from the GIT instance after a template load, butTagis conspicuously not among them. - Store: uses
ResRefrather thanTemplateResReffor templating (already documented elsewhere on this page), but does carry a genuine, separateTagfield, read unconditionally byLoadStorethe 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 Target | Description & 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.
| Field | Type | Default | Engine Evaluation |
|---|---|---|---|
Tag | CExoString | "" | |
AreaEffectId | INT | 0 | A freshly constructed object leaves this member uninitialized; the constant load default masks that gap. |
SpellId | DWORD | 0 | A 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. |
Shape | BYTE | 0 | 0 = circle, 1 = rectangle. Any other value skips both dimension fields entirely, so the effect gets no shape geometry at all. |
MetaMagicType | BYTE | 0 | |
SpellSaveDC | INT | 0 | Fresh objects start at 14; the 0 default only applies when a save’s GFF genuinely omits the field. |
SpellLevel | INT | 0 | |
Radius | FLOAT | 0.0 | Only read/written when Shape == 0. |
Length / Width | FLOAT | 0.0 each | Only 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 / LastLeft | DWORD | 0 each | Fresh objects use the 0x7F000000 placeholder instead; 0 is only the fallback for a field genuinely absent from the save. |
Duration | DWORD | 0 | |
DurationType | BYTE | 0 | Fresh objects start at 2. |
LastHrtbtDay / LastHrtbtTime | DWORD | 0 each | |
PositionX / PositionY / PositionZ | FLOAT | 0.0 each | Read last, by the area-effect list loader, and passed straight into placement. |
OrientationX / OrientationY / OrientationZ | FLOAT | 0.0 each | Normalized 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, andOnObjExitare 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 fromAreaEffectIdorSpellIdon the load path. That 2DA-driven derivation does exist (vfx_persistent.2da, keyed byAreaEffectId, supplyingOnHeartbeat/OnObjEnter/OnObjExitscript 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.OnUserDefinedgoes 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/AmbientSndNitVoland explicitly truncates theirINTdeclarations into a single native runtime byte value. The loader and the save writer disagree on where several of these fields actually live: the writer nestsRestrictMode,StealthXPMax,StealthXPCurrent,StealthXPLoss,StealthXPEnabled, andSunFogColorinside theAreaPropertiesstruct, but the reader actually pulls those specific fields from the GIT’s top level instead – onlyUnescapableis genuinely read from insideAreaProperties. 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/TransPendCurrIDare written in both places (the GIT top level directly, and a redundant copy insideAreaProperties), but only the top-level copy is ever consulted, so theAreaPropertiescopy 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. ItsMusicDelay/MusicDay/MusicNight/MusicBattle/AmbientSndDay/AmbientSndNight/AmbientSndDayVol/AmbientSndNitVolfields (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:MusicDelay5000,MusicDay2,MusicNight3,MusicBattle1,AmbientSndDay1,AmbientSndNight2,AmbientSndDayVol/AmbientSndNitVol0each. - AreaMap: Strict binary blobs evaluating rendering properties (
AreaMapData). It is absolutely bypassed during fresh loads, only executed conditionally during save-game states. CameraList(GitCameraentries): Read byLoadPlaceableCameras(already documented above for its 51-entry rejection limit).Positiondefaults to the literal zero vector(0, 0, 0)andOrientationto 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.
- GIT-001 (Weather Zeroing): Informs when
CurrentWeather != 0xFForWeatherStarted=trueis configured; if the area is an interior, the engine forcibly zeros these on load. - GIT-002 (Camera Array Bounds): Errors when
CameraListcontains 51 or more entries; triggers an immediate engine-level loader failure. - GIT-003 (Stealth Clamping): Warns when
StealthXPCurrent > StealthXPMax; the engine clamps on evaluation. - GIT-004 (Ambient Volume Truncation): Warns when
AmbientSndDayVolorAmbientSndNitVolare outside0..=255; the engine truncates to an 8-bit byte. - GIT-005 (Sound GeneratedType Truncation): Warns when any sound’s
GeneratedTypeexceeds 255; the engine truncates to an 8-bit byte on save. Both forms ofSoundListcarry 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.
- GIT-006 (Template Resref Existence): Warns when any per-instance
TemplateResRefdoes 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), andEncounter List[].TemplateResRef(.ute). Waypoint instances are genuinely inlined and have no template – confirmed by decompilation,LoadWaypointnever reads a field namedTemplateResRefunder any circumstance. Doors are not, and this rule excluded them on that assumption until the gap was found: a static (UseTemplates = 1) door placement resolvesTemplateResRefagainst a.utdblueprint exactly like a creature or placeable does (see UTD’s “Save versus Template Load Paths”).GitDoornow carries the field and the rule checks it, so a door pointing at a missing blueprint no longer passes clean. TriggerLinkedToModuleis deferred to Phase 3 cross-resource checks. The rule only looks at the static form of each list, since aUseTemplates = 0snapshot 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
| Property | Value |
|---|---|
| Extension(s) | .ifo |
| Magic Signature | IFO / V3.2 |
| Type | Module Blueprint |
| Rust Reference | View 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.
| Category | Covers | Representative fields |
|---|---|---|
| Module Identity | The module’s tag, localized name, and description | Mod_Tag, Mod_Name, Mod_Description |
| Entry Point | The spawn area, position, and facing used on module entry | Mod_Entry_Area, Mod_Entry_X, Mod_Entry_Dir_X |
| Time & Calendar | Day/night pacing and the module’s starting clock | Mod_MinPerHour, Mod_DawnHour, Mod_StartYear |
| Global Event Scripts | The 15 module-wide event hooks | Mod_OnModLoad, Mod_OnClientEntr, Mod_OnHeartbeat |
| Area & Cutscene Rosters | The areas belonging to the module, plus cutscene and expansion metadata | Mod_Area_list, Mod_CutSceneList |
| Save-Only State | The runtime snapshot a save adds: party roster, tokens, id allocators, and the live clock | Mod_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_Version – SavePlayers (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.
| Field | Type | Engine Evaluation |
|---|---|---|
Mod_ID | VOID (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_ID | INT | Written unconditionally alongside Mod_ID. |
Mod_Version | DWORD | Written unconditionally alongside Mod_Creator_ID. |
Mod_IsSaveGame | BYTE | Defaults 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_IsNWMFile | BYTE | Same carry-over mechanism as Mod_IsSaveGame: constructor sets false first, absence leaves it there. |
Mod_NWMResName | CExoString | Only 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_Tag | CExoString | Defaults 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_Name | LocalizedString | Defaults to an empty localized string if missing, unconditional. |
Mod_Description | LocalizedString | Same as Mod_Name: empty localized string if missing, unconditional. |
Mod_Expan_List | List of Struct | Expansion 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_CutSceneList | List of Struct | Cutscene 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
| Field | Type | Engine Evaluation |
|---|---|---|
Mod_Entry_Area | ResRef | The primary spawning area ResRef. |
Mod_Entry_X / Mod_Entry_Y / Mod_Entry_Z | FLOAT | Exact spawning XYZ coordinates. |
Mod_Entry_Dir_X / Mod_Entry_Dir_Y | FLOAT | Entry 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_XPScale | BYTE | Module 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_StartMovie | ResRef | Read 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
| Field | Type | Description |
|---|---|---|
Mod_DawnHour | BYTE | Dawn 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_DuskHour | BYTE | Dusk hour integer marker. Defaults to 0 if missing, same as Mod_DawnHour. |
Mod_MinPerHour | BYTE | Configuration 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 thecurrent_hour. This dynamically updates an internal state flag denoting:1=Day,2=Night,3=Dawn,4=Dusk.
Warning
Mod_MinPerHour/Mod_DawnHour/Mod_DuskHourdoc comments in rakata’s own code are wrong.crates/rakata-generics/src/ifo.rscurrently claims these three default to2,6, and18respectively. None of that is true against the binary – all three read with a literal default of0, confirmed directly. The code’s actual behavior (.unwrap_or(0)and theDefaultimpl) 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:
| Field | Fires when |
|---|---|
Mod_OnModLoad | the module is loaded |
Mod_OnModStart | the module starts (first client entry) |
Mod_OnClientEntr | a player enters the module |
Mod_OnClientLeav | a player leaves the module |
Mod_OnHeartbeat | the module heartbeat ticks |
Mod_OnUsrDefined | a user-defined event is signalled |
Mod_OnAcquirItem | an item is acquired |
Mod_OnUnAqreItem | an item is unacquired (dropped or removed) |
Mod_OnActvtItem | an item is activated |
Mod_OnEquipItem | an item is equipped |
Mod_OnPlrDeath | a player dies |
Mod_OnPlrDying | a player drops to dying |
Mod_OnPlrLvlUp | a player levels up |
Mod_OnPlrRest | a player rests |
Mod_OnSpawnBtnDn | a respawn is requested (a multiplayer-era Aurora event) |
- Asymmetric I/O (equipping).
Mod_OnEquipItemis read during module startup (LoadModuleStart), butSaveModuleIFOStartnever 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_OnEquipItemincluded, 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_IsNWMFilemarks 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 withMod_NWMResNameand skips re-saving the areaAREstatic into the module’s save ERF. The skip is narrow:SaveModuleFinishgates theAREstatic write behindis_nwm_file == 0, while theGITis written unconditionally inSaveModuleInProgress. So an NWM save still gets its dynamicGIT, just not a re-copied staticARE.
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, andMod_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 entireLoadModuleStartcall, not a per-entry skip.
| Engine Target | Description |
|---|---|
| Player / Mod Variables | Structures like Mod_PlayerList, Mod_Tokens, VarTable, and the EventQueue are strictly bypassed unless natively evaluated under is_save_game conditions. |
| Player List Structure | Mod_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 Overrides | The 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 Counters | A 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 Snapshot | Beyond 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 Asymmetry | Mod_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 Tokens | Mod_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 Creatures | Creatures 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-deadVO_IDfield: 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 always0. This is NWN/Aurora expansion-selector residue, consistent with theMod_IsNWMFile/Mod_Hakprecedents on this same format – a real, unrelatedExpansion_ID/Expansion_Namepair exists in the binary (used byMod_Expan_List, already modeled), butExpansion_Packitself 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-scopedGLOBALVARS.res(GVT), a completely separate system with no code path connecting it back to this module-scoped field.Mod_GVar_Listis 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.
- IFO-001 (Direction Fallback): Warns when
Mod_Entry_Dir_XandMod_Entry_Dir_Yare both0.0. The engine substitutes a hard fallback heading of(1.0, 0.0)only whenMod_Entry_Dir_Yis absent from the GFF; a value that is present but(0.0, 0.0)is left as a degenerate heading with no facing. - IFO-002 (XP Dead-Scaling): Warns when
Mod_XPScale == 0. Caveat: a Ghidra trace of K1 shows the engine parsesMod_XPScalebut never applies it to awarded XP (the field is inert inswkotor.exe), so a zero has no in-engine effect in K1. The rule only matters if the value is meaningful to another tool. - 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 to1(Day), so the module is stuck in perpetual daylight. - IFO-004 (Void Area Initialization): Errors when
Mod_Area_listis empty; directly faults the load cycle. - IFO-005 (Dangling NWM Structure): Warns when
Mod_IsNWMFile=truewithoutMod_NWMResName; evaluates to an unstable execution state.
Phase 2 (resource existence, requires LintContext)
Implemented under rakata_lint::rules::ifo_range.
- IFO-006 (Resref Existence): Warns when
Mod_Entry_Area(.are), anyMod_Area_list[i].Area_Name(.are), or any of the 15Mod_On*script hooks (.ncs) does not resolve in the configured resource sources.
Pending
- Mod_StartMovie (.bik): No
ResourceTypeCodevariant 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
| Property | Value |
|---|---|
| Extension(s) | .utc |
| Magic Signature | UTC / V3.2 |
| Type | Creature Blueprint |
| Rust Reference | View 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.
| Category | Covers | Representative fields |
|---|---|---|
| Core Statistics | The base stats that define the creature’s physical capabilities | Strength, Dexterity, HitPoints |
| Identity & Graphics | Who the creature is and which 3D model it uses | Tag, Appearance_Type, Conversation |
| Class & Skill Progression | The creature’s level, classes, and skills | ClassList, SkillList |
| Combat Capabilities | The feats and Force powers the creature can use | FeatList, SpellList |
| Inventory & Equipment | The items the creature spawns with, both equipped gear and inventory drops | Equip_ItemList, ItemList |
| Event Hooks | The behavior scripts that fire when the creature reacts to the world, such as taking damage or noticing an enemy | OnNotice, 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
| Function | Size | Behavior |
|---|---|---|
ReadStatsFromGff | 7835 B | The massive initial pass that parses 57 basic creature scalars including strength, dexterity, and physical appearance. |
LoadCreature | – | Sets up how the creature physically sits in the world, handling their stealth states, collision size, and idle animations. |
CSWSCreature::ReadScriptsFromGff | – | Attaches 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. |
ReadItemsFromGff | – | Pulls 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. |
ReadSpellsFromGff | – | Specifically extracts the list of any Force powers or combat feats the creature is allowed to use. |
Note
Zeroed Data Elements
TailandWingsare stronger than “bypassed”:ReadStatsFromGffnever looks them up in the GFF struct at all – there’s noReadFieldBYTEcall for either label anywhere in the function. Instead it performs a flat, unconditional assignment of0to both members, overwriting whatever the object already held, regardless of whether the file even contains the fields. This is identical on the.utcblueprint path (LoadFromTemplate) and the save-instance path (LoadCreature) – both call the sameReadStatsFromGff, with no branch anywhere in it that distinguishes the two callers. This isn’t merely inert legacy data, though:SaveStatsstill 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.,0x5f7or0x5f4). The rules below track the specific scenarios where the game will crash.
| Engine Rule | Runtime Behavior |
|---|---|
| Class Limits | The engine expects a strict limit of 2 discrete class types. Providing duplicate class configuration completely crashes the game (Engine Error 0x5f7). |
| Race Bounds | The engine compares Race against the compiled row count of racialtypes.2da. Exceeding this boundary fatally crashes the map loader (Engine Error 0x5f4). |
| Saves Calculation | Pre-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 Faults | A 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 Fallbacks | If a unique MovementRate isn’t declared, the engine logic falls back directly to default WalkRate parameters. |
| Hard Clamping | The 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 Shifting | If 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) | Meaning | Read on the .utc blueprint path too? |
|---|---|---|
CurrentHitPoints | Live 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. |
MaxHitPoints | Computed 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. |
PregameCurrent | Nominally a current-HP mirror. | No. Same exhaustive check: exactly two references, both writers, zero readers, on any path. |
ForcePoints | Live Force-point pool. | Yes – unconditional, carries over the object’s own constructed value (0) when absent. |
CurrentForce | Live current Force. | Yes – unconditional, but sibling-derived from ForcePoints when absent (see below), not carried over independently. |
MaxForcePoints | Computed 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, FortSaveThrow | Computed saving-throw totals (base + ability modifier + active effects). Distinct from the template’s ignored SaveWill / SaveFortitude dead fields. |
ArmorClass | Computed AC snapshot. |
Experience, Gold | Runtime 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, NotReorienting | Runtime 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. |
MClassLevUpIn | Multiclass 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 total | Rebuilt on load from |
|---|---|
MaxHitPoints | Class 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) |
ArmorClass | Per-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 / FortSaveThrow | The 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 when | Absent on load resolves to |
|---|---|---|
PM_Appearance | PM_IsDisguised == 1 | 0; the loader only attempts the read at all if PM_IsDisguised decoded true |
CombatRoundData contents | Combat was mid-round at save time | The 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, ActionList | List/struct headers are always written; contents reflect however many entries currently exist | Empty 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 Type | Explanation |
|---|---|
| Legacy Engine Artifacts | A 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 Absence | TemplateList (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 (0–131), 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.
- UTC-001 (Appearance Correction): Warns when
Appearance_Head == 0; the engine forces this to 1 at runtime. - UTC-002 (Class Limit): Warns when more than 2 entries appear in
ClassList; the engine ignores classes beyond the second. - UTC-003 (Class Duplications): Errors when duplicate class IDs exist in
ClassList; causes a fatal engine crash (0x5f7) on load. - UTC-004 (Dead Save Fields): Informs when
SaveWillorSaveFortitudeare populated; the engine readswillbonus/fortbonusinstead. - UTC-005 (Gender Clamp): Warns when
Gender > 4; the engine clamps to a maximum of 4. - UTC-006 (GoodEvil Clamp): Warns when
GoodEvil > 100; the engine clamps to a maximum of 100. - UTC-007 (Toolset / Legacy Fields): Informs when any of
Comment,Morale*,PaletteID,BodyVariation,TextureVar,BlindSpot,MultiplierSet,NoPermDeath,IgnoreCrePath,Hologram,WillNotRender, orLawfulChaoticare set; never read by the K1 engine.
Phase 2 (range / 2DA / resref existence, requires LintContext)
Implemented under rakata_lint::rules::utc_range.
- UTC-008 (Race Bounds): Errors when
Racedoes not resolve to a row inracialtypes.2da; engine crash0x5f4on load. - UTC-009 (Class Bounds): Errors when any
ClassList[].Classdoes not resolve to a row inclasses.2da(or is negative); engine load failure. - UTC-010 (Appearance Bounds): Errors when
Appearancedoes not resolve to a row inappearance.2da; engine renders missing model. - UTC-011 (Portrait Bounds): Errors when
PortraitId(when not the0xFFFE“use string Portrait” sentinel) does not resolve to a row inportraits.2da. - UTC-012 (Resref Existence): Warns when
Conversation(.dlg),Portrait(.tga), any of the 14Script*hooks (.ncs),Equip_ItemList[i].EquippedRes(.uti), orItemList[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
| Property | Value |
|---|---|
| Extension(s) | .utd |
| Magic Signature | UTD / V3.2 |
| Type | Door Blueprint |
| Rust Reference | View 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.
| Category | Covers | Representative fields |
|---|---|---|
| Core Identity & Geometry | What the door looks like, its faction, and the text displayed when targeted | Appearance, TemplateResRef, LocName |
| Lock & Trap Mechanics | Whether the door is locked, which key opens it, and the rules for attached traps | Locked, KeyName, TrapType, DisarmDC |
| Transition Pathways | The linked destination used when the door acts as a loading zone to another area | LinkedTo, LinkedToFlags |
| Behavioral Hooks | The scripts that run when a player opens, destroys, or fails to unlock the door | OnOpen, 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.
| Domain | Sub-fields Evaluated | Purpose |
|---|---|---|
| Scales & State | 22 | Reads the physical health, visual appearance, and base traits determining whether the door is locked or indestructible. |
| Hooks | 15 | Attaches custom event scripts that fire when the door is opened, forced, unlocked, or trapped. |
| Mechanical | 9 | Configures the lock difficulty tiers and the specific skill hurdles required to detect and disarm any attached traps. |
| Transitions | 4 | Links 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 Rule | Runtime Behavior |
|---|---|
| Appearance Truncation | The 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 Enforcement | If the door is marked Static, the engine automatically forces plot = 1. This safely guarantees that static level architecture cannot be destroyed by players. |
| Portrait Shadowing | If 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 Fallback | If 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 Synchronization | CurrentHP 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 Omissions | Aside 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 Type | Explanation |
|---|---|
| Legacy Engine Artifacts | Confirmed 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.
- UTD-001 (Static Parity): Warns when
Static=truebutPlot=false; the engine forces Plot to true at runtime. - UTD-002 (HP Bounds): Errors when
CurrentHP > HP; the engine clamps toHPon template load. - UTD-003 (Portrait Shadowing): Warns when
PortraitId < 0xFFFEandPortraitresref is set; the resref is ignored at runtime.
Phase 2 (range / 2DA / resref existence, requires LintContext)
Implemented under rakata_lint::rules::utd_range.
- UTD-004 (Generic Door Type Bounds): Errors when
GenericTypedoes not resolve to a row ingenericdoors.2da; engine renders missing model. - UTD-005 (Portrait Bounds): Errors when
PortraitId(when not the0xFFFE“use string Portrait” sentinel) does not resolve to a row inportraits.2da. - UTD-006 (Resref Existence): Warns when
Conversation(.dlg),Portrait(.tga), or any of the 15On*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"OnTrapTriggeredreferences that silently invoke thetraps.2dafallback. - Portrait Zero Hardcode: Detects
PortraitId == 0mappings since the engine hardcodes lookup to0x22E.
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
| Property | Value |
|---|---|
| Extension(s) | .ute |
| Magic Signature | UTE / V3.2 |
| Type | Encounter Blueprint |
| Rust Reference | View 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.
| Category | Covers | Representative fields |
|---|---|---|
| Spawn Population | The creature blueprints the encounter can spawn | CreatureList |
| Difficulty & Limits | How many creatures spawn at once and how hard they are relative to the player | MaxCreatures, DifficultyIndex |
| Trigger Boundaries | The coordinates that trace the tripwire that fires the spawn | Geometry |
| Behavioral Hooks | The scripts that run when a player enters or exits the trigger, or when the spawn pool runs dry | OnEntered, 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.
| Function | Size | Behavior |
|---|---|---|
ReadEncounterFromGff (0x00592430) | 3445 B | The 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. |
ReadEncounterScriptsFromGff | 567 B | Attaches scripts that trigger when players enter, exit, or exhaust the spawn pool. |
LoadEncounterSpawnPoints (0x00590410) | 364 B | Reads 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. |
LoadEncounterGeometry | 651 B | Reads 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:
| Field | Constructed default carried over on absence |
|---|---|
LocalizedName | Empty localized string |
Active | true – the one boolean on this struct that constructs to nonzero; Reset/PlayerOnly/Started/Exhausted all construct to false |
Reset | false |
ResetTime | 60 |
Respawns | 0 |
SpawnOption | 0 |
MaxCreatures | 8 |
RecCreatures | 2 |
PlayerOnly | false |
Faction | 1 |
OnEntered, OnExit, OnHeartbeat, OnExhausted, OnUserDefined | Empty 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
.utefile 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 Rule | Runtime Behavior |
|---|---|
| Tag Overrides | The engine forcefully converts any Tag to all-lowercase via CSWSObject::SetTag. Any static casing is lost immediately upon load. |
| Geometry Integrity | If 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 Synthesis | If 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 Resolution | The 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 Sorting | Upon 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 Instantiation | AreaList buffer allocation size is strictly dictated by AreaListMaxSize. If the real list exceeds this size, the buffer will silently overrun. |
| Structural List Omission | CreatureList, 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 Orientation | Each 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 Type | Explanation |
|---|---|
| Passive Legacy Artifacts | Unused fields left over from older tools or Odyssey branches (e.g., TemplateResRef, Comment, PaletteID) are completely dark. The engine inherently ignores them. |
Toolset-Only Appearance | Nearly 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 Fields | The 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.
- UTE-001 (Dead Difficulty Traces): Warns when
Difficulty > 0whileDifficultyIndex >= 0; the engine ignores the staticDifficultyin favor of the 2DA lookup. - UTE-002 (Deficient Spawn Loops): Warns when an encounter is marked
Active=truebutCreatureListis empty. - UTE-003 (Dead Field Evaluation): Informs when
TemplateResRef,Comment, orPaletteIDare populated; never read by the K1 engine. - UTE-004 (Geometry Integrity Risk): Warns when
Geometryhas 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.
- UTE-005 (Resref Existence): Warns when any of
OnEntered,OnExit,OnHeartbeat,OnExhausted, orOnUserDefined(.ncs) does not resolve, or when anyCreatureList[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
| Property | Value |
|---|---|
| Extension(s) | .uti |
| Magic Signature | UTI / V3.2 |
| Type | Item Blueprint |
| Rust Reference | View 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.
| Category | Covers | Representative fields |
|---|---|---|
| Core Identity | The item’s name and description, in both identified and unidentified states | TemplateResRef, LocName, Description |
| Economic & Charge Mechanics | The item’s value and the charges left for consumable abilities | Cost, Charges |
| Visual Geometry | What the item looks like when dropped on the floor or equipped | ModelVariation, TextureVar |
| Combat & Upgrade Properties | The stat buffs, damage modifiers, and abilities bound to the item, plus workbench upgrade slots | PropertiesList |
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.
| Function | Size | Behavior |
|---|---|---|
LoadDataFromGff | – | The 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. |
LoadItem | – | The constructor that decides whether to load the item onto a character or leave it idle in an inventory. |
LoadFromTemplate | – | A fallback used when spawning an item dynamically from a script instead of off a character. |
SaveItem / SaveItemProperties | – | The 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 Rule | Runtime Behavior |
|---|---|
| Description Cross-Swap | If 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 Truncation | If 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 Chaining | MaxCharges 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 Gating | ItemList 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 Fields | SaveItem 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 Hooks | The 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 Fallback | The 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 Enforcement | During 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 Capabilities | Item 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 Kinds | The 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 Defaults | When 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 Application | The 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 Regardless | Identified’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):
| Step | 2DA | Indexed By | Column Read | Purpose |
|---|---|---|---|---|
| 1 | itempropdef.2da | PropertyName | Name (INT) | TLK strref for the property’s display name (e.g. “Damage Bonus”). |
| 2 | itempropdef.2da | PropertyName | SubTypeResRef (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) | Subtype | Name (INT) | TLK strref for the subtype’s display name (e.g. “Acid”). |
Cost-table dispatch (resolved eagerly at startup inside LoadIPRPCostTables at 0x005c4730):
| Step | 2DA | Indexed By | Column Read | Purpose |
|---|---|---|---|---|
| 1 | iprp_costtable.2da | CostTable | Name (string) | Resref of the cost-specific 2DA (e.g. iprp_meleecost). Used as a resref despite the column name suggesting a label. |
| 2 | iprp_costtable.2da | CostTable | ClientLoad (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):
| Step | 2DA | Indexed By | Column Read | Purpose |
|---|---|---|---|---|
| 1 | iprp_paramtable.2da | Param1 | TableResRef (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.2daandiprp_paramtable.2darow counts are stored asbyte(u8) inCTwoDimArrays. 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/GetCExoStringEntrycompare verbatim). The exact spellings the engine uses areName,SubTypeResRef,TableResRef,Label, andClientLoad. - The subtype 2DA listed in
SubTypeResRefis loaded lazily on display viaGetPropertyStrings, not eagerly at startup. A missing subtype 2DA fails only the call that needs it, not the whole game load. - The
Namecolumn on every level of the dispatch is a TLK strref. TheLabelcolumn 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:
| Index | Name (resref of per-cost 2DA) | Label | ClientLoad |
|---|---|---|---|
| 0 | IPRP_BASE1 | Base1 | 0 |
| 1 | IPRP_BONUSCOST | Bonus | 0 |
| 2 | IPRP_MELEECOST | Melee | 1 |
| 3 | IPRP_CHARGECOST | SpellUse | 0 |
| 4 | IPRP_DAMAGECOST | Damage | 0 |
| 5 | IPRP_IMMUNCOST | Immune | 0 |
| 6 | IPRP_SOAKCOST | DamageSoak | 0 |
| 7 | IPRP_RESISTCOST | DamageResist | 0 |
| 8 | IPRP_BLADECOST | DancingScimitar | 0 |
| 9 | IPRP_SLOTSCOST | Slots | 0 |
| 10 | IPRP_WEIGHTCOST | Weight | 0 |
| 11 | IPRP_SRCOST | SpellResist | 0 |
| 12 | IPRP_STAMINACOST | Stamina | 0 |
| 13 | IPRP_SPELLLVCOST | SpellLevel | 0 |
| 14 | IPRP_AMMOCOST | Ammo | 0 |
| 15 | IPRP_REDCOST | WeightReduction | 0 |
| 16 | IPRP_SPELLCOST | Spells | 0 |
| 17 | IPRP_TRAPCOST | Traps | 0 |
| 18 | IPRP_LIGHTCOST | Light | 1 |
| 19 | IPRP_MONSTCOST | Monster_Cost | 0 |
| 20 | IPRP_NEG5COST | Negative_Modifiers | 0 |
| 21 | IPRP_NEG10COST | Negative_Modifiers | 0 |
| 22 | IPRP_DAMVULCOST | Damage_vulnerability | 0 |
| 23 | IPRP_SPELLLVLIMM | Spell_Level_Immunity | 0 |
| 24 | IPRP_ONHITCOST | OnHitCosts | 0 |
| 25 | IPRP_ONHITDC | OnHitDC_saves | 0 |
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.
| Handler | CostTable index | Per-cost 2DA | Column | Post-processing |
|---|---|---|---|---|
ApplyAbilityBonus | 1 | iprp_bonuscost | Value | – |
ApplyACBonus | 1 | iprp_bonuscost | Value | – |
ApplyImprovedSavingThrow | 1 | iprp_bonuscost | Value | – |
ApplyDamageReduction | 6 | iprp_soakcost | Amount | – |
ApplyDamageResistance | 7 | iprp_resistcost | Amount | – |
ApplyImprovedForceResistance | 11 (0xB) | iprp_srcost | Value | – |
ApplyAttackPenalty | 20 (0x14) | iprp_neg5cost | Value | negate |
ApplyDamagePenalty | 20 (0x14) | iprp_neg5cost | Value | negate |
ApplyReducedSavingThrows | 20 (0x14) | iprp_neg5cost | Value | none (table holds negatives) |
ApplyDecreasedAC | 20 (0x14) | iprp_neg5cost | Value | negate |
ApplyDecreasedAbilityScore | 21 (0x15) | iprp_neg10cost | Value | negate |
ApplyDecreasedSkillModifier | 21 (0x15) | iprp_neg10cost | Value | negate |
ApplyDamageVulnerability | 22 (0x16) | iprp_damvulcost | Value | – |
ApplyDamageImmunity | dynamic (property.cost_table) | per-property | Value | – |
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(coversPropertyName11Damage, 12DamageAlignmentGroup, and 13DamageRacialGroupin one switch) readsCostValuestraight as the damage amount. There is no per-cost 2DA lookup. Theiprp_damagecost.2datable is used for cost calculation (GetCost), not for damage-magnitude resolution.ApplyEnhancementBonusandApplyAttackBonusread(Rules->internal).all_2DAs->iprp_meleecostvia direct struct-field access (not throughGetIPRPCostTable), then read columnValue. Equivalent to a cost-table-index2(iprp_meleecost) dispatch, just inlined.ApplySkillBonusandApplyBonusFeatread the magnitude / feat id from the property struct directly.ApplyImmunityswitches on the subtype id and assigns one of ten hardcoded engine constants; no 2DA is consulted.ApplyRegenerationusesCostValueas the regen amount and a hardcoded6000ms tick interval; no 2DA.
Implications for decoded magnitude resolution. A decoder that resolves property magnitudes should:
- 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, columnValueorAmount, with the documented post-processing. - If the property kind is on the bypass list, the magnitude is
CostValuedirectly (or, forApplyImmunity, hardcoded per subtype). - For
ApplyDamageImmunity, the cost-table index is read from the property’s ownCostTablefield 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.
| Row | Label | Subtype 2DA | Notes |
|---|---|---|---|
| 0 | Ability | iprp_abilities | |
| 1 | Armor | – | AC base bonus |
| 2 | ArmorAlignmentGroup | iprp_aligngrp | |
| 3 | ArmorDamageType | iprp_combatdam | |
| 4 | ArmorRacialGroup | racialtypes | |
| 5 | Enhancement | – | Enhancement bonus to weapons |
| 6 | EnhancementAlignmentGroup | iprp_aligngrp | |
| 7 | EnhancementRacialGroup | racialtypes | |
| 8 | AttackPenalty | – | |
| 9 | BonusFeats | feat | |
| 10 | CastSpell | spells | active |
| 11 | Damage | iprp_damagetype | |
| 12 | DamageAlignmentGroup | iprp_aligngrp | |
| 13 | DamageRacialGroup | racialtypes | |
| 14 | DamageImmunity | iprp_damagetype | |
| 15 | DamagePenalty | – | |
| 16 | DamageReduced | iprp_protection | |
| 17 | DamageResist | iprp_damagetype | |
| 18 | Damage_Vulnerability | iprp_damagetype | |
| 19 | DecreaseAbilityScore | iprp_abilities | |
| 20 | DecreaseAC | iprp_acmodtype | |
| 21 | DecreasedSkill | skills | |
| 22 | DamageMelee | iprp_combatdam | |
| 23 | DamageRanged | iprp_combatdam | |
| 24 | Immunity | iprp_immunity | |
| 25 | ImprovedMagicResist | – | |
| 26 | ImprovedSavingThrows | iprp_saveelement | |
| 27 | ImprovedSavingThrowsSpecific | iprp_savingthrow | |
| 28 | Keen | – | |
| 29 | Light | – | |
| 30 | Mighty | – | |
| 31 | DamageNone | – | |
| 32 | OnHit | iprp_onhit | |
| 33 | ReducedSavingThrows | iprp_saveelement | |
| 34 | ReducedSpecificSavingThrow | iprp_savingthrow | |
| 35 | Regeneration | – | |
| 36 | Skill | skills | |
| 37 | ThievesTools | – | active |
| 38 | AttackBonus | – | |
| 39 | AttackBonusAlignmentGroup | iprp_aligngrp | |
| 40 | AttackBonusRacialGroup | racialtypes | |
| 41 | ToHitPenalty | – | |
| 42 | UnlimitedAmmo | iprp_ammotype | |
| 43 | UseLimitationAlignmentGroup | iprp_aligngrp | |
| 44 | UseLimitationClass | classes | |
| 45 | UseLimitationRacial | racialtypes | |
| 46 | Trap | traps | active |
| 47 | True_Seeing | – | |
| 48 | OnMonsterHit | iprp_monsterhit | |
| 49 | Massive_Criticals | – | |
| 50 | Freedom_of_Movement | – | |
| 51 | Monster_damage | – | |
| 52 | Special_Walk | iprp_walk | |
| 53 | Computer_Spike | – | active |
| 54 | Regeneration_Force_Points | – | |
| 55 | Blaster_Bolt_Deflect_Increase | – | |
| 56 | Blaster_Bolt_Defect_Decrease | – | Vanilla typo (Defect not Deflect); decoder must match the file spelling exactly. |
| 57 | Use_Limitation_Feat | feat | |
| 58 | Droid_Repair_Kit | – | |
| 59 | Disguise | appearance |
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 Type | Explanation |
|---|---|
| Superseded Legacy Fields | Directly 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 Artifacts | General nodes left over from older tools (like TemplateResRef, Comment, PaletteID, and explicitly UpgradeLevel) are bypassed on load entirely. |
| Cross-Format Dead Fields | The 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.
- UTI-001 (Model Truncation Safety): Warns when
ModelVariation == 0; the engine forces this to 1 at runtime. - UTI-002 (Dead Cost Fields): Informs when
Costis set; the engine ignores this and computes item cost dynamically. - UTI-003 (Dead Body Overrides): Informs when
BodyVariationis set; the engine queriesbaseitems.2dainstead. - UTI-004 (Toolset-Only Fields): Informs when any of
TemplateResRef,Comment,PaletteID, orUpgradeLevelare set; never read by the K1 engine. - UTI-005 (Conditional TextureVar): Informs when
TextureVaris set; only evaluated if the base item’s 2DAmodel_typeis exactly 1.
Phase 2 (range / 2DA, requires LintContext)
Implemented under rakata_lint::rules::uti_range.
- UTI-006 (Base Item Bounds): Errors when
BaseItemdoes not resolve to a row inbaseitems.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. - UTI-007 (Valid Capability Bounds): Errors per
PropertiesListentry whenPropertyNamedoes not resolve to a row initempropdef.2da, or whenSubtypedoes not resolve to a row in the per-propertyiprp_*.2danamed byitempropdef[PropertyName].SubTypeResRef(skipped when the row has noSubTypeResRef, i.e. the property kind has no subtype dimension).UpgradeTypeandUsesPerDayuse the engine’s0xFF“not set” sentinel; both the absent-field and explicit-0xFFforms 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
| Property | Value |
|---|---|
| Extension(s) | .utm |
| Magic Signature | UTM / V3.2 |
| Type | Merchant Blueprint |
| Rust Reference | View 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.
| Category | Covers | Representative fields |
|---|---|---|
| Core Identity | The shop’s name and tag | Tag, LocName |
| Economic Metrics | Price scaling when buying or selling, plus basic shop rules | MarkUp, MarkDown, BuySellFlag |
| Store Inventory | The items in stock, including rules for infinite restocking | ItemList |
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
| Function | Size | Behavior |
|---|---|---|
LoadStore | 1341 B | The primary parser that pulls the merchant’s basic identity, economic constraints (MarkUp/MarkDown), and buying capabilities. |
ItemList Read | – | Iterates through the list of store stock, actively pulling either explicitly saved item instances or generating them freshly from templates (InventoryRes). |
AddItemToInventory | – | Pushes the fully sorted loot stack into the physical storefront container so the player can actually interact with and purchase them. |
Core Structural Findings
| Engine Rule | Runtime Behavior |
|---|---|
| Cost Sorting | When 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 Economics | The 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 Flags | BuySellFlag 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 Fallback | Unlike 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 Stacking | If 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 Inventory | On 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 Defaults | Tag 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’t | Infinite 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 Type | Explanation |
|---|---|
| Legacy Interface Configurations | Some 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.
- UTM-001 (Legacy Grid Coordinates): Informs when inventory items contain non-zero
Repos_PosXorRepos_PosY; the engine builds its shop UI dynamically and ignores these coordinates. - UTM-002 (Unknown Buy/Sell Flags): Warns when
BuySellFlaghas bits set outside the canonical buy (bit 0) and sell (bit 1) toggles. - UTM-003 (Legacy Store UI Fallback): Warns when
BuySellFlag == 0(missing or empty); the engine falls back to legacy UI behaviors and forcefully clampsMarkUpto 100.
Phase 2 (resource existence, requires LintContext)
Implemented under rakata_lint::rules::utm_range.
- UTM-004 (Resref Existence): Warns when
OnOpenStore(.ncs) or anyItemList[i].InventoryRes(.uti) does not resolve in the configured resource sources. The toolset-only top-levelResRef(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
| Property | Value |
|---|---|
| Extension(s) | .utp |
| Magic Signature | UTP / V3.2 |
| Type | Placeable Blueprint |
| Rust Reference | View 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.
| Category | Covers | Representative fields |
|---|---|---|
| Core Identity & Geometry | What the placeable looks like, its faction, and the text displayed when targeted | Appearance, TemplateResRef, LocName |
| Interactive State & Dialogue | Whether the placeable can be clicked, starts a conversation or computer sequence, or acts as a loot container | Useable, Conversation, HasInventory |
| Lock & Trap Mechanics | Whether it is locked, which key opens it, and the rules for attached traps | Locked, KeyName, TrapType, DisarmDC |
| Health & Destruction | Whether the object can be destroyed and its defensive thresholds | HP, Hardness, Static, Plot |
| Behavioral Hooks | The scripts that run when a player explores, attacks, or opens the placeable | OnOpen, 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
| Function | Size | Behavior |
|---|---|---|
LoadPlaceable | 5092 B | The 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 Rule | Runtime Behavior |
|---|---|
| Appearance Truncation | The 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 Chaining | Just 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 Check | If 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 Shadowing | 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. |
| Ground Pile Forcing | The 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 Hooks | Toolsets 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 Fallback | If 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 Level | A 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 Omission | ItemList 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) andCurrentHP: the constructor sets both to1(a placeable nominally starts alive). The reads for both pass a hardcoded0, 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 to1(armed/on). Same hardcoded-0fallback, 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
Openresolves non-zero:AnimationandAnimationStateare never read at all. The engine unconditionally applies a fixed sentinel,10075, as the placeable’s animation state. - If
Openresolves to0: the engine readsAnimation(INT, default0). If present, its raw value is applied directly as the animation id, no validation. - If
Animationis absent, the engine falls through toAnimationState(BYTE, default0). IfAnimationStateis 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. IfAnimationStateis present, it indexes into six preset animation-id sentinels; any value greater than5collapses 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 Type | Explanation |
|---|---|
| Legacy Engine Artifacts | Placeable 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 Binary | IsComputer 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.
- UTP-001 (Plot Chaining Context): Warns when
Static=truebutPlot=false; the engine forces Plot to true at runtime. - UTP-002 (Ghost Value Detection): Informs when
GroundPile=falsesince the engine immediately overwrites this to true on load. - UTP-003 (Dead Hook Pruning): Flags
OnFailToOpeninstances because placeables ignore this event hook (it is door-exclusive). - UTP-004 (HP Health Ceiling): Errors when
CurrentHP > HP; the engine clamps toHPon template load. - UTP-005 (Portrait Shadowing): Warns when
PortraitId < 0xFFFEandPortraitresref is set; the resref is ignored at runtime.
Phase 2 (range / 2DA / resref existence, requires LintContext)
Implemented under rakata_lint::rules::utp_range.
- UTP-006 (Appearance Bounds): Errors when
Appearancedoes not resolve to a row inplaceables.2da; engine renders missing model. - UTP-007 (Portrait Bounds): Errors when
PortraitId(when not the0xFFFE“use string Portrait” sentinel) does not resolve to a row inportraits.2da. - UTP-008 (Resref Existence): Warns when
Conversation(.dlg),Portrait(.tga), any of the 16On*script hooks (.ncs), orItemList[i].InventoryRes(.uti) does not resolve in the configured resource sources.OnFailToOpenis intentionally NOT included – UTP-003 already flags it as door-exclusive dead data.
Pending
- Appearance Truncation: Warns when
Appearanceexceeds 255 (engine truncates to a single byte before lookup, distinct from the row-count check in UTP-006). - Animation Conditional Limits: Verifies that custom
AnimationStateindices are strictly guarded byOpen==0closures.
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
| Property | Value |
|---|---|
| Extension(s) | .uts |
| Magic Signature | UTS / V3.2 |
| Type | Sound Object Blueprint |
| Rust Reference | View 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.
| Category | Covers | Representative fields |
|---|---|---|
| Audio Emitters | The .wav clips the engine sequences or shuffles through | Sounds |
| Spatial Geometry | The distance boundaries that decide where the sound is audible | MinDistance, MaxDistance |
| Playback Automation | How the sound loops and strings together | Continuous, Random, Active, Looping |
| Algorithmic Variation | Runtime distortion of pitch and volume | PitchVariation, FixedVariance, VolumeVrtn |
| Procedural Generators | Marks the sound as engine-generated ambiance such as crowd chatter or combat noise | GeneratedType |
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
| Function | Size | Behavior |
|---|---|---|
Load | 1345 B | The primary physical parser evaluating 24 core audio metric bounds, defining spatial positioning, volume variation, pitch scales, and active looping capabilities. |
Sounds List | – | Iterates through the list of associated audio clips, actively loading sound resrefs into memory sequentially for playback. |
Core Structural Findings
| Engine Rule | Runtime Behavior |
|---|---|
| Generated Type Truncation | The 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 Defaults | If 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 Origin | XPosition/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 Context | When 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 Lists | When 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 Fragility | The 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 Type | Explanation |
|---|---|
| Legacy Engine Artifacts | Some 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.
- UTS-001 (Volume Ceiling): Warns when
Volume > 127; values outside the engine’s byte threshold cause distortion or clipping. - UTS-002 (Audio Integrity): Warns when the
Soundslist contains blank entries; the engine skips them silently. - UTS-003 (Emitter Verification): Errors when the
Soundslist is empty; the object loads as a dead audio node. - UTS-004 (GeneratedType Truncation): Errors when
GeneratedType > 255; the engine truncates to a single byte and corrupts intended behavior. - UTS-005 (Legacy Engine Artifacts): Informs when
TemplateResRef,Elevation,Priority, orPaletteIDare populated; never natively evaluated by the K1 engine.
Phase 2 (resource existence, requires LintContext)
Implemented under rakata_lint::rules::uts_range.
- UTS-006 (Sound Resref Existence): Warns when any non-blank
Sounds[i].Sounddoes not resolve to a.wavresource 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
| Property | Value |
|---|---|
| Extension(s) | .utt |
| Magic Signature | UTT / V3.2 |
| Type | Trigger Blueprint |
| Rust Reference | View 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.
| Category | Covers | Representative fields |
|---|---|---|
| Core Identity & Geometry | What the trigger is and where it sits on the ground | Tag, Geometry |
| Interactive State & Sub-types | Whether the trigger acts as a loading zone, a trap, or a generic scripting boundary | Type, Cursor, HighlightHeight |
| Trap Mechanics | Trap visibility and the skill checks required to disarm | TrapType, TrapOneShot |
| Transition & Behavioral Hooks | The event scripts that fire on enter, click, leave, or disarm, plus the destination area when the trigger is a loading zone | ScriptOnEnter, 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
| Function | Size | Behavior |
|---|---|---|
LoadTrigger | 3381 B | The main constructor. It reads the trigger’s properties, scripts, and trap rules. |
LoadTriggerGeometry | 743 B | Reads 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 Rule | Runtime Behavior |
|---|---|
| Behavior Derived from Type | The 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 Bug | The 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 Fallback | If 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 Clamping | The 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 Geometry | When 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-Gated | Unlike 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 Loading | Fields like LinkedTo, LinkedToModule, AutoRemoveKey, Tag, and Faction are only loaded into memory when the Trigger is processed from a .git area layout file. |
| Portrait Shadowing | 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 Flag Fresh-Object Asymmetry | TrapDisarmable 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 Orientation | SetPosition 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 Value | LinkedTo, 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 LoadTrigger – UTD’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 Type | Explanation |
|---|---|
| Legacy Engine Artifacts | As with other templates, older asset revisions include TemplateResRef, Comment, PaletteID, and PartyRequired. The engine completely ignores these. |
| Superseded Legacy Fields | Older 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.
- UTT-001 (Transition Enforcement): Warns when
Type==1(Transition) but no destination (LinkedTo,LinkedToModule, orTransitionDestin) is configured. - UTT-002 (Trap Consistency): Informs when
TrapDetectDC/DisarmDCare set (engine reads fromtraps.2da); also warns whenTrapFlag=truebutType != 2. - UTT-003 (Geometry Safety): Warns when the trigger’s geometry contains fewer than 3 vertices.
- UTT-004 (OnClick on Generic Trigger): Informs when
OnClickis set on a Generic trigger (Type==0); the event only fires for Transition triggers. - UTT-005 (Highlight Bounding): Informs when
HighlightHeight <= 0.0; the engine falls back to a default of0.1. - UTT-006 (Portrait Shadowing): Warns when
PortraitId < 0xFFFEandPortraitresref is set; the resref is ignored at runtime. - UTT-007 (PartyRequired Dead Data): Informs when
PartyRequiredis set; the K1 engine never reads this field.
Phase 2 (range / 2DA / resref existence, requires LintContext)
Implemented under rakata_lint::rules::utt_range.
- UTT-008 (Portrait Bounds): Errors when
PortraitId(when not the0xFFFE“use string Portrait” sentinel) does not resolve to a row inportraits.2da. - UTT-009 (Resref Existence): Warns when any of
OnDisarm,OnTrapTriggered,OnClick,OnHeartbeat,OnEnter,OnExit, orOnUserDefined(.ncs), orPortrait(.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"OnTrapTriggeredentries that silently invoke thetraps.2dafallback.
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
| Property | Value |
|---|---|
| Extension(s) | .utw |
| Magic Signature | UTW / V3.2 |
| Type | Waypoint Blueprint |
| Rust Reference | View 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.
| Category | Covers | Representative fields |
|---|---|---|
| Core Identity | The waypoint’s name and the tag that scripts target | Tag, LocalizedName |
| Spatial Geometry | The map coordinates and facing that creatures or cameras reference | XPosition, XOrientation |
| Map Navigation Notes | Whether the waypoint draws a pin on the player’s mini-map, and the pin’s text | HasMapNote, 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
| Function | Size | Behavior |
|---|---|---|
LoadWaypoint | 682 B | The main constructor. It loads the waypoint’s identity, map geometry, and checks for mini-map pins. |
LoadFromTemplate (0x005c83b0) | 134 B | A 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 Rule | Runtime Behavior |
|---|---|
| Map Note Two-Gate Pattern | The 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 Normalization | The 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 Override | When 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 Identification | Waypoints 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 Waypoints | The 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-Exclusive | LoadWaypoint 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 Stamps | Both 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 Type | Explanation |
|---|---|
| Superseded Legacy Fields | Older 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 Behaviour | A 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 All | Git.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.
- UTW-001 (Map Note Double-Gating): Warns when
MapNoteorMapNoteEnabledare populated butHasMapNote=false; this data is silently discarded by the engine. - 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
Tagvalues 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::Readpipeline 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.
| Format | Name | Layout & Purpose |
|---|---|---|
| MDL | Model Hierarchy | The 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). |
| MDX | Vertex Data | The 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. |
| BWM | Walkmeshes | The 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). |
| Math | TriMesh Derivations | Documentation 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
| Property | Value |
|---|---|
| Extension(s) | .mdl |
| Magic Signature | Text (filedependancy) or Binary (\0 byte header) |
| Type | 3D Hierarchical Mesh |
| Rust Reference | View 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-Type | Description |
|---|---|
| Base | A pure structure node (Dummy) acting strictly as an invisible visual group or spatial pivot. |
| Light | Projects localized dynamic lighting, lens flares, and shading priorities. |
| Emitter | Configures particle spawning systems (fountains, single-shots, lightning, explosions). |
| Camera | An empty node serving as a static viewport anchor for dialogue cinematics. |
| Reference | An anchor point explicitly linking an external 3D model asset to a point. |
| TriMesh | A rigid standard triangle geometry boundary carrying static vertex arrays. |
| SkinMesh | A procedural mesh utilizing skeleton bone-weights and vectors to calculate organic deformations. |
| AnimMesh | A mesh carrying hardcoded, explicitly sampled vertex coordinate animation loops. |
| DanglyMesh | A sub-mesh evaluated through swinging physics constraints (displacement, tightness, period). |
| AABB | A strict spatial collision tree structurally defining an internal walkmesh barrier. |
| Saber | Allocates 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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Binary vs ASCII Detection | The 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 Mapping | The 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 Dump | The 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 Property | Engine Behavior |
|---|---|
| Sub-node Allocation Sizes | Nodes 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 Resolution | Engine 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 Property | Ghidra Provenance & Engine Behavior |
|---|---|
| LOD Suffix Generation | The 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 Binding | When 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 Keyframes | Unlike 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:
- Skeleton / Animation Tracing: Flags animation nodes where the internal skeletal
node_numberbinding parameter implicitly equals0, ensuring the mesh does not hard freeze via pointing to the rigid root spine. - 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). - Emitter Detonation Allocation: Flags interactive
Emitternodes attempting to bind thedetonatekey (Controller502) 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. - 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
.pwkand.wokmodels into.mdlnodes 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
| Property | Value |
|---|---|
| Extension(s) | .mdx |
| Magic Signature | Raw binary stream (No explicit signature block) |
| Type | Interleaved Vertex Payload Array |
| Rust Reference | View 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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Memory Wrapping | Triggered immediately alongside the .mdl. The wrapper dynamically outlines the exact byte-count of .mdx data required (wrapper + 0x08). |
| Buffer Liberation | MDX 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 Property | Ghidra Provenance & Engine Behavior |
|---|---|
| Array Slicing | Every 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 Constraints | Vanilla 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:
- Mesh Slice Verification: Enforces explicit iteration seeking. Validates
.mdxvector boundaries by explicitly jumping pointers down the file according to individualmdx_data_offsetassignments mapped on explicitly boundTriMeshheaders, 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
| Property | Value |
|---|---|
| Extension(s) | .bwm, .wok |
| Magic Signature | None standard header block |
| Type | Memory-Mapped Collision Net |
| Rust Reference | View 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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Pointer Jumping | The 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 Extraction | The 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 Offsets | The 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 ID | Magic 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 Format | One-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
| Property | Value |
|---|---|
| Extension(s) | .bwm (ASCII formatted) |
| Magic Signature | ASCII Text Directives |
| Type | Uncompiled 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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Searching for Keywords | The engine scans the text file reading line-by-line to look for the specific keywords node, verts, faces, and aabb. |
| Strict Face Formatting | Every 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 Limits | The engine will aggressively truncate or glitch if any single text line stretches beyond 256 characters (0x100 bytes). |
| Face Reordering | Using 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 Boundaries | When 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
| Property | Value |
|---|---|
| Extension(s) | .mdl |
| Domain | Geometry Math / Model Reconstruction |
| Rust Reference | View 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-formatsAPI 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
vertindexesdarray. 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:
- Compute AABB of all face centroids.
- Choose split axis (longest AABB dimension).
- Sort faces by centroid along split axis.
- Split at median into left/right subsets.
- 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:
| Field | Source |
|---|---|
| Vertex positions, normals, UVs, tangent space | 3D modeller |
| Vertex colors | 3D modeller or material editor |
| Texture names (texture_0, texture_1) | Material assignment |
| Diffuse/ambient colors | Material properties |
| Transparency hint, light_mapped, beaming, etc. | Material flags |
| Surface ID per face | Surface type assignment |
| Vertex indices per face | Mesh topology |
| Controller keyframes | Animation data |
| Bone weights, indices, bonemap | Rigging tool |
| Emitter properties | Particle editor |
7. Tool Cross-Reference: CExoArrayList Naming
The naming across tools is wildly inconsistent:
| Offset | Engine (Ghidra) | rakata | mdledit | mdlops | PyKotor | xoreos |
|---|---|---|---|---|---|---|
| +0x98 | vertex_indices | vertex_indices_array | cTexture3 | pntr_to_vert_num | indices_counts | (skip) |
| +0xA4 | left_over_faces | left_over_faces_array | cTexture4 | pntr_to_vert_loc | indices_offsets | offOffVerts |
| +0xB0 | vertex_indices_count | vertex_indices_count_array | IndexCounterArray | array3 | counters | (skip) |
| +0xBC | mdx_offsets | mdx_offsets_array | IndexLocationArray | (backpatch only) | (not modeled) | offOffVerts |
| +0xC8 | index_buffer_pools | index_buffer_pools_array | MeshInvertedCounterArray | inv_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:
| Property | MDL Face Adjacency | BWM Walkmesh Adjacency |
|---|---|---|
| Storage | u16 per edge | i32 per edge |
| Encoding | Plain face index | face_index * 3 + edge_index |
| No-neighbor | 0xFFFF | -1 (0xFFFFFFFF) |
| Purpose | GL rendering hints | Pathfinding / 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:
- Face array (32 bytes per face)
vertex_indices_countdata (single u32:face_count * 3)- Content vertex positions (12 bytes per vertex, only for MDL content blob)
mdx_offsetsdata (single u32: placeholder, backpatched)index_buffer_poolsdata (single u32: inverted counter value)- Packed u16 vertex indices (
face_count * 3u16 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.
| Format | Name | Layout & Purpose |
|---|---|---|
| TPC | Texture Pack Compressed | A proprietary BioWare wrapper around native DXT-compressed OpenGL texture data. This is the primary format used for all base-game environment and character textures. |
| DDS | DirectDraw Surface | A proprietary BioWare variation of the standard Microsoft DDS format. Rather than utilizing standard headers, the legacy engine requires a bespoke 20-byte magic wrapper. |
| TGA | Truevision Targa | An uncompressed, lossless visual format. Used for rendering crisp UI elements, visual effects (VFX), etc. |
| TXI | Texture Extensions | Plaintext 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
| Property | Value |
|---|---|
| Extension(s) | .tpc |
| Magic Signature | None |
| Type | Compressed Texture Pack |
| Rust Reference | View 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 robustTpcHeaderPixelFormatenumeration (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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Format Byte Mapping | The 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 Dispatch | The 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 Calculations | Rather 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 Binding | When 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
| Property | Value |
|---|---|
| Extension(s) | .dds |
| Magic Signature | None (Proprietary 20-Byte Prefix) |
| Type | BioWare DirectDraw Wrapper |
| Rust Reference | View 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
DDSmagic 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 K1CResDDS20-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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Prefix Stripping | The 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 Calculation | The 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
+0x09to+0x0Bin the header prefix are entirely ignored by theGetDDSAttribread 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
| Property | Value |
|---|---|
| Extension(s) | .tga |
| Magic Signature | Truevision Standard |
| Type | Uncompressed RGB/A Raster |
| Rust Reference | View 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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Header Stripping | Function: 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 Validation | Function: 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 Generation | Function: ImageWriteTGAThe 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
| Property | Value |
|---|---|
| Extension(s) | .txi |
| Magic Signature | None |
| Type | ASCII Configuration Strings |
| Rust Reference | View 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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Invalid Commands | Function: 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 Agnosticism | Function: CAurTextureBasic::ParseField (0x00422390)Field matching acts strictly case-insensitive (e.g. cMgTxi == cmgtxi). |
| Line Normalization | Function: 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 Parsing | Function: 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. Thefirstword()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 toParse_bool(). Ansscanfstrips the whitespace and evaluates"1"totrue.- Argument-less Flags: Passing just a flag (
"decal") triggers the branch, butParse_boolphysically 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
| Specification | Core 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
| Property | Value |
|---|---|
| Extension(s) | .2da |
| Magic Signature | 2DA / V2.b (Binary) or V2.0 (Text) |
| Type | Tabular Data |
| Rust Reference | View 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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Magic/Version Gate | The 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_sizeu16is completely bypassed. The engine skips it with+2and 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
| Property | Value |
|---|---|
| Extension(s) | .tlk |
| Magic Signature | TLK / V3.0 |
| Type | Localized String Bundle |
| Rust Reference | View 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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Magic Check | Function: 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 Dispatching | Function: 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 Dialects | Function: CTlkFile::AddFileWhen 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
| Property | Value |
|---|---|
| Extension(s) | .vis |
| Magic Signature | None |
| Type | Room Graph |
| Rust Reference | View 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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Text Loading | Function: 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 Forgiveness | Function: 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 Application | Function: Scene::SetVisibilityCalling 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 Generation | Function: Scene::SaveVisibilityWhen 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
| Property | Value |
|---|---|
| Extension(s) | .lyt |
| Magic Signature | None |
| Type | Plain Text Layout |
| Rust Reference | View 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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Newline Bounds | The 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 Skipping | All file lines existing prior to the beginlayout execution marker (such as the ubiquitous #MAXLAYOUT ASCII header) are deliberately skipped and ignored. |
| Sequential Parsing | The structure mandates a rigid sequential ingestion. Data collections must explicitly appear geographically in the exact order: roomcount → trackcount → obstaclecount → doorhookcount → donelayout. |
Warning
Boundary Oversight While the engine systematically verifies
donelayoutboundaries separating the primary collections, the underlying parse loop functionally neglects to verify the finaldonelayoutsignature upon closing thedoorhookssegment.
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
| Property | Value |
|---|---|
| Extension(s) | .ltr |
| Magic Signature | LTR / V1.0 |
| Type | Naming State Matrix |
| Rust Reference | View 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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Magic Validation | The 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 Ingestion | Memory 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 Check | Upon 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
| Specification | Core 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
| Property | Value |
|---|---|
| Extension(s) | .wav |
| Magic Signature | RIFF |
| Type | Streamed / Buffered Audio |
| Rust Reference | View 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 Event | Ghidra 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-off | The 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
| Property | Value |
|---|---|
| Extension(s) | .lip |
| Magic Signature | LIP V1.0 |
| Type | Facial Animation Keyframes |
| Rust Reference | View 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
| Offset | Type | Description |
|---|---|---|
0x00 | CHAR[8] | Signature (LIP V1.0) |
0x08 | FLOAT | Animation Length |
0x0C | DWORD | Entry Count |
0x10 | Struct[] | 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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Zero-Copy Loading | The 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 Assignment | The 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
| Property | Value |
|---|---|
| Extension(s) | .ssf |
| Magic Signature | None |
| Type | Enum-String Mapping |
| Rust Reference | View 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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Finding the Table | The 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 Slots | Starting 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 Blanks | Obviously, 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
1behind the scenes to correctly navigate the literal0-indexedarray 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 Rule | Runtime Behavior |
|---|---|
| Verbatim Byte Copy | Every 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 Whitelist | The 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 Cap | Inputs longer than 16 bytes are silently truncated. The remaining buffer is zero-padded. |
| No UTF-8 Awareness | The 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 Construction | The 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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Global Callback | When 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 Independence | Because 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.exesupports pulling a TXI from the/overridefolder even if the parent texture was sourced natively from aKEY/BIFpackage. Rakata maintains this independent sidecar lookup model natively viarakata_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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| First-Match Exit | When 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 Checking | During 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) →RIM→ERF(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-extractcrate natively replicates this exact priority order through theCompositeModulestruct. When you pass a directory path toCompositeModule::load_from_directory, it automatically scans the folder and merges the_dlg,_s,_a/_adx, and base.modfiles 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 Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Primary MOD Search | The game natively attempts to load the highest-level package by explicitly targeting the MODULES:<root>.mod path first. |
| RIM Fallback Chains | If the .mod file doesn’t exist, the system catches the failure and immediately shifts to look for the <root>_s.rim fallback. |
| Area Extension Probes | Throughout 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.
Why Tiers, Not a Flat Search
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 content | Source | Consumed by |
|---|---|---|
| Modules (areas / planets) | LIVE%d:MODULES\ | module loader (LoadModule, PopulateModules) |
| Movies | LIVE%d:movies\ | movie playback (AddMovieToExoArrayList) |
| Talk table | LIVE%d:live%d | AddDownloadedResources |
| Key table | LIVE%d:live%d | AddDownloadedResources |
| RIM archives | LIVE%d:RIMSXBOX\live%d, ...\live%ddx | AddDownloadedResources |
| ERF (encapsulated) | LIVE%d:live%d | AddDownloadedResources |
| Override textures | LIVE%d:OVERRIDE\textures | AddDownloadedResources |
A save records which slots are present through savenfo’s LIVECONTENT bitmask and the parallel LIVE1-LIVE6 name fields (see savenfo).
Why it 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%dalias 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 teachingLoadAliasesto readLIVE1-LIVE6from the ini) and the slot activates: a self-contained tier that can add modules, movies, textures, and archived resources without touchingOverride. 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:
| Function | Address | Covers |
|---|---|---|
AddDownloadedResources | 0x005f4180 | The startup mount walk, gated on the slot-count global (= 7) |
LoadAliases | 0x005e7a90 | Registers the fixed alias-name list from the ini [Alias] section (omits LIVE1-LIVE6) |
AddAlias | 0x005e7760 | Reads one named [Alias] entry into the alias list |
GetAliasPath | 0x005e6890 | Resolves a LIVE%d alias at mount time |
LoadModule | 0x004b95b0 | Probes LIVE%d:MODULES\ for module content |
AddMovieToExoArrayList | 0x005fbbf0 | Probes LIVE%d:movies\ for movie content |
StallEventSaveGame | 0x004b3110 | Writes 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.savaccumulates 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
.resfiles are plain GFFs holding the global session state (menu metadata, party, campaign globals). The per-module state (areas, live objects) lives nested insideSAVEGAME.sav.- Every GFF the engine writes is stamped
V3.2no matter what version the caller asks for (see GFF).
The save folder
| File | Format | Role |
|---|---|---|
SAVEGAME.sav | ERF (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.res | GFF (NFO ) | Menu metadata: name, area, last module, play time, portraits |
PARTYTABLE.res | GFF (PT ) | Party roster, gold, XP, journal, available companions, pazaak, galaxy map |
GLOBALVARS.res | GFF (GVT ) | Campaign global variables (booleans, numbers, locations, strings) |
Screen.tga | TGA | Save-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 resrefModule) carrying the saved module clock, runtime id counters, and the party/limbo creature lists (see IFO); - the area static (
ARE, type2012/0x7dc), skipped for modules flaggedMod_IsNWMFile; - the dynamic game-instance state (
GIT, type2023/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>.rsvfile in theGAMEINPROGRESSworking directory. The.rsvextension maps to resource type0x0bc1; the content is identical. The deep dive covers when each is written, how the engine prefersRSVoverSAVat 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 type2027), one per recruited companion, wherenis the companion’snpc.2darow (AVAILNPC0-AVAILNPC8for 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 taggedINVholding a singleItemListof item snapshots. TheINVtag appears only here, and the one list is its whole schema. Note it is stored under the generic resource type0, 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 form | sparse placement | full self-contained snapshot |
TemplateResRef | present; engine loads the blueprint | absent; no blueprint loaded |
| A missing field resolves to | the UTC/UTD/UTP/UTT blueprint | the 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:
| Object | Position | Orientation |
|---|---|---|
| Door, Placeable | X, Y, Z | Bearing (single angle) |
| Creature, Trigger, Waypoint, Store | XPosition, YPosition, ZPosition | XOrientation, YOrientation, ZOrientation (vector) |
| Sound, Encounter | XPosition, YPosition, ZPosition | none at object level |
| Area-of-effect | PositionX, PositionY, PositionZ | OrientationX, OrientationY, OrientationZ (vector) |
Naming and storage quirks:
Bearingis 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
Geometryvertices (PointX/PointY/PointZ) are stored relative to the trigger position; encounterGeometryvertices (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-savecrate 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
0xffffffffis a mode selector, not a member index. It switches the load source to the transientpifoparty-info file and reads back the slot each player recorded on itself whenStorePlayerCharacterspacked the party intopifo. 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
| Property | Value |
|---|---|
| Filename | savenfo.res |
| Magic Signature | NFO / V3.2 |
| Type | Save Metadata Block |
| Rust Reference | Handled 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.
| Field | Type | Meaning | Save type |
|---|---|---|---|
SAVEGAMENAME | CExoString | Display name of the save. | manual |
AREANAME | CExoString | Localized display name of the current area. | both |
LASTMODULE | CExoString | Resref of the module the engine restores first. | both |
TIMEPLAYED | DWORD | Running play time, in seconds. | both |
CHEATUSED | BYTE | Cheat flag; mirrors the party table’s cheat state. | both |
GAMEPLAYHINT | BYTE | Loading-screen hint state. | both |
STORYHINT | BYTE | Loading-screen hint state. | both |
LIVE1 .. LIVE6 | CExoString | Downloadable-content slot names (six); empty on a vanilla PC install. | manual |
LIVECONTENT | BYTE | Bitmask of which of the six LIVE%d slots are installed; 0 when none. | manual |
PORTRAIT0 .. PORTRAITN | CResRef | One portrait resref per active party member. | both |
PCAUTOSAVE | BYTE | Always 1; its presence marks the file as an autosave. | autosave |
REBOOTAUTOSAVE | BYTE | Read by the load menu’s slot parser, but no write site exists anywhere in this build; see the note below. | none (dead on PC) |
SCREENSHOT | CExoString | Loading-screen resref (load_<module>) used as the slot preview, in place of a Screen.tga. | autosave |
AUTOSAVEPARAMS | Struct | Pending 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.
CHEATUSEDis 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-LIVE6name fields plus theLIVECONTENTbitmask unconditionally, whether or not content is installed. This is the Xbox Live download mechanism (LIVE%daliases resolving toRIMSXBOX\live%dRIMs). A vanilla PC install has nothing aliased in, so a real quicksave carries all sixLIVE%das empty strings andLIVECONTENT = 0(verified against a K1 GOG save). The Yavin Station DLC on PC is not delivered through this path.REBOOTAUTOSAVEis read unconditionally by the slot parser and folded into the same bit-field asPCAUTOSAVE, but no code path in this build ever writes it –DoPCAutosaveonly setsPCAUTOSAVE. The read path is live regardless: the save-list preview code checks this bit together withPCAUTOSAVEwhen it decides where a slot’s screenshot comes from, soREBOOTAUTOSAVEjust stays permanently0. 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
LASTMODULEis what selects the first module to restore.
Important
Two write paths, two field sets.
savenfocomes fromStallEventSaveGame(manual saves and quicksaves) orDoPCAutosave(autosaves); the Save type column above marks which fields each produces.PCAUTOSAVEis the reliable discriminator. Autosaves also differ at the folder level (a loosepifo.ifo, noScreen.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.
| Field | Type | Source |
|---|---|---|
LOADMUSIC | CExoString | The 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. |
STARTWAYPOINT | CExoString | The 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 .. MOVIE6 | CExoString | Drained 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_YEAR | DWORD | The 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_HOUR | BYTE | The module’s live calendar fields (current_month/current_day/current_hour), read through the module’s own time accessor. |
TIME_MINUTE / TIME_SECOND / TIME_MILLISECOND | WORD | Not 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_PAUSETIME | DWORD | The 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. |
STATUSSUMMARY | Struct | See below. |
STATUSSUMMARY is a “since you last saw a loading screen” delta accumulator, not a snapshot of current totals:
| Field | Type | Behaviour |
|---|---|---|
CREDITS / XP / STEALTHXP | INT | Running totals added to by every credit/XP/stealth-XP gain since the popup last displayed. |
CREDITSNET | BYTE | Set when credits moved in both directions since the last display (gained and lost), distinguishing a net change from a one-way one. |
LIGHTSHIFT / DARKSHIFT | BYTE | Alignment-shift deltas, same accumulate-then-drain pattern. |
DISPLAYSPENDING / ITEMRECEIVED / ITEMLOST / JOURNAL | BYTE | Pending-event flags set by the corresponding gameplay hooks (item give/take, journal updates). |
SOUNDPENDING / LEVELUPSOUND / NEWQUESTSOUND / COMPLETESOUND | BYTE | Selects which stinger, if any, plays alongside the popup. |
SUPPRESSED | INT | Not 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
| Property | Value |
|---|---|
| Filename | PARTYTABLE.res |
| Magic Signature | PT / V3.2 (two trailing spaces in the tag) |
| Type | Party Table |
| Rust Reference | Handled 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
| Field | Type | Meaning |
|---|---|---|
PT_GOLD | DWORD | Party 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_POOL | INT | Shared experience pool; benched companions are topped up toward their npc.2da PercentXP share of it when they rejoin. |
PT_PLAYEDSECONDS | DWORD | Running play time, in seconds. |
PT_CHEAT_USED | BYTE | Cheat flag; savenfo’s CHEATUSED carries the same value. |
PT_SOLOMODE | BYTE | Solo-mode flag. |
PT_CONTROLLED_NPC | INT | Currently controlled party member. |
Roster
PT_NUM_MEMBERS (BYTE) plus PT_MEMBERS, a list with one struct per active member:
| Field | Type | Meaning |
|---|---|---|
PT_MEMBER_ID | INT | Companion id (npc.2da row) of the member. |
PT_IS_LEADER | BYTE | Whether 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):
| Field | Type | Meaning |
|---|---|---|
PT_NPC_AVAIL | BYTE | Whether the companion has been unlocked. |
PT_NPC_SELECT | BYTE | Whether 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
| Field | Type | Meaning |
|---|---|---|
PT_AISTATE | INT | Party combat-AI state. |
PT_FOLLOWSTATE | INT | Party follow/formation state. |
Galaxy map
| Field | Type | Meaning |
|---|---|---|
GlxyMapNumPnts | DWORD | Number of known map points. |
GlxyMapPlntMsk | DWORD | Planet unlock bitmask. |
GlxyMapSelPnt | INT | Currently selected map point. |
Pazaak
| Field | Type | Meaning |
|---|---|---|
PT_PAZAAKCARDS | list | Owned-card counts: a fixed 18 elements, each { PT_PAZAAKCOUNT: INT } (one per card). |
PT_PAZSIDELIST | list | Chosen side deck: a fixed 10 elements, each { PT_PAZSIDECARD: INT }. |
Feedback and dialog logs
| Field | Type | Meaning |
|---|---|---|
PT_FB_MSG_LIST | list | On-screen feedback messages, each { PT_FB_MSG_MSG: CExoString, PT_FB_MSG_TYPE: DWORD, PT_FB_MSG_COLOR: BYTE }. |
PT_DLG_MSG_LIST | list | Dialog message log, each { PT_DLG_MSG_SPKR: CExoString, PT_DLG_MSG_MSG: CExoString }. |
PT_COST_MULT_LIST | list | Store cost multipliers, each { PT_COST_MULT_VALUE: FLOAT }. |
UI state
| Field | Type | Meaning |
|---|---|---|
PT_TUT_WND_SHOWN | VOID | Tutorial-window-shown flags (opaque byte blob). |
PT_LAST_GUI_PNL | INT | Last 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:
| Field | Type | Meaning |
|---|---|---|
JNL_PlotID | CExoString | Quest/plot identifier. |
JNL_State | INT | Current quest state. |
JNL_Date | DWORD | In-game date stamp. |
JNL_Time | DWORD | In-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_Entriesas “no active quests”, not as malformed data.PT_TUT_WND_SHOWNis a GFFVOIDfield (an opaque byte blob), not an integer.PT_PAZAAKCARDShas a stowaway element: after the 18 INT card-count entries, the writer appends a 19th entry whosePT_PAZAAKCOUNTis 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-levelPT_CHEAT_USEDfield, which does round-trip. The trailing byte is a dead write (aBYTEamong 18INTs, 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
| Property | Value |
|---|---|
| Filename | GLOBALVARS.res |
| Magic Signature | GVT / V3.2 |
| Type | Global Variable Table |
| Rust Reference | Handled 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:
| Type | Holds | Cap | What scripts use it for |
|---|---|---|---|
| Boolean | a single bit | 900 | Plot switches and one-shot guards: has this happened? The most common kind of global. |
| Number | a single unsigned byte (0-255) | 500 | Small counters and quest-stage enumerations. It is a byte, not a 32-bit integer, so it cannot hold an arbitrary count. |
| Location | a position and orientation | 100 | A remembered spot to send, spawn, or move an object to later. |
| String | a short text value | 5 | A 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 block | Encoding |
|---|---|---|
CatBoolean | ValBoolean | VOID, bit-packed. Boolean i is bit 7 - (i & 7) of byte i >> 3 (most-significant bit first). Block length is (count >> 3) + 1 bytes. |
CatNumber | ValNumber | VOID, one unsigned byte per number. Number i is byte i; values are 0-255. |
CatLocation | ValLocation | VOID, 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. |
CatString | ValString | LIST, 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/CatBooleansilently drops everyLocationandStringglobal. 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.
| Field | Type | Meaning |
|---|---|---|
Position | Vector (three float32, LE) | The stored point (X, Y, Z), bytes 0x00-0x0b. |
Orientation | Vector (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:
| Function | Address | Covers |
|---|---|---|
CSWGlobalVariableTable::WriteTable | 0x005299b0 | Value-block encoding on write |
CSWGlobalVariableTable::ReadTableWithCatalogue | 0x0052a280 | Encoding and per-type caps on read |
CSWGlobalVariableTable::GetValueBoolean | 0x00529110 | Boolean value read (the script get) |
CSWGlobalVariableTable::GetValueNumber | 0x00529240 | Number value read |
CSWGlobalVariableTable::GetValueLocation | 0x00529350 | Location value read (slot copy) |
CSWGlobalVariableTable::GetValueString | 0x00529460 | String value read |
CSWSObject::GetScriptLocation | 0x004cb7b0 | Location 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/SetGlobalBooleanand theNumber/Location/Stringpairs) 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
| Topic | Description |
|---|---|
| MDL & MDX Deep Dive | Deep 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 Corruption | Case study analyzing out-of-bounds GFF list behavior in the Odyssey engine vs. loose community tooling abstractions. |
| Save Game Deep Dive | Ghidra 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 Dive | Ghidra 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:
- A relative offset into the
list_indicestable. - At that offset:
count(u32)countstruct 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:
- Starts writing a parent list.
- Recursively builds child structs.
- 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:
- Write list count.
- Reserve contiguous slots for all struct indices up front.
- Build each child struct recursively.
- 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.rsreserves 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.rsincludes 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:
.mdland.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 ofswkotor.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:
| Property | Value |
|---|---|
| Extensions | .mdl, .mdx |
| Magic | Binary: first u32 == 0. ASCII: text (filedependancy, newmodel, …) |
| Type | Hierarchical scene graph + animation + vertex data |
| Resource type ID | 2002 (MDL), 3008 (MDX) in KEY/BIF |
| Rust reference | View 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:
- Allocate a buffer exactly the size of the model data.
- Copy the whole file into that buffer in one
memcpy. - 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
MdlNodeTriMeshis 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:
| Offset | Type | Field | Notes |
|---|---|---|---|
| +0x00 | u32 | zero marker | Always 0. Used to tell binary from ASCII. |
| +0x04 | u32 | MDL content size | Bytes of model data that follow. |
| +0x08 | u32 | MDX file size | Size 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:
- Record
mdl_content_sizeandmdx_file_sizefrom the wrapper. - Allocate a heap buffer the size of the MDL content;
memcpythe model data into it. - If MDX size is non-zero, allocate a second buffer and
memcpythe MDX file into it. - 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:
- MDL-relative offsets – the vast majority. Relocated to absolute pointers by
Reset*functions. On re-serialization, they must be rewritten back to relative offsets. - MDX-file byte offsets – used by a few fields (e.g. per-mesh
mdx_data_offsetat +0x144) to locate vertex data in the separate MDX file. - 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:
| Offset | Field | Notes |
|---|---|---|
| +0x00 | ModelDestructor vptr | Populated at load time. |
| +0x04 | ModelParseField vptr | Populated at load time. |
| +0x28 | root node offset | Relocated. ResetMdlNode recurses from here. |
| +0x48 | resource handle | Populated at load time. |
| +0x4C | type byte | `GetType() |
| +0x50 | classification | 0=Other, 1=Effect, 2=Tile, 4=Character, 8=Door. |
| +0x54 | ref count | |
| +0x58 | animations array ptr | Relocated; count at +0x5C. |
| +0x64 | supermodel pointer | Populated via FindModel(buf+0x88). |
| +0x68..+0x80 | bbox min/max | Vector bmin, bmax. |
| +0x80 | radius | f32, default 7.0. |
| +0x84 | animation scale | f32, default 1.0. ASCII: setanimationscale. |
| +0x88 | supermodel name | char[36], null-terminated. Drives recursive model load. |
| +0xA8 | node array (secondary) | Relocated if non-zero. |
| +0xAC | MDX vertex pool offset | Source offset into MDX data (consumed into a GL pool). |
| +0xB0 | MDX data size | Size of the vertex-pool copy. |
| +0xB8 | name offsets array ptr | Relocated; 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
Resetpass – it’s carried through as part of the memory-mapped blob and consulted at runtime. Cross-validated against hex dumps:File +0x50 Category c_dewback.mdl0x04 Character ✓ dor_lhr01.mdl0x08 Door ✓ m01aa_01a.mdl0x00 Other ✓ -
+0x88 supermodel name is a 32-byte (plus 4 padding) ASCII name. Loading a model with a supermodel triggers a recursive
FindModelcall 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:
| Offset | Size | Field | Notes |
|---|---|---|---|
| +0x00 | u16 | node_type | Flag bitmask. Drives type dispatch. |
| +0x02 | u16 | node_id | Sequential 0..N-1. |
| +0x04 | u16 | node_id_dup | Identical copy of node_id. Never read. |
| +0x06 | u16 | padding | Always zero. |
| +0x08 | u32 | name pointer | Relocated. Points into the string table. |
| +0x0C | u32 | parent pointer | Relocated if non-zero. |
| +0x10 | 12 | position | Vector{x, y, z} as 3×f32. |
| +0x1C | 16 | orientation | Quaternion{w, x, y, z} as 4×f32. |
| +0x2C | 12 | children array | CExoArrayList of MdlNode*. |
| +0x38 | 12 | controller keys array | CExoArrayList of NewController (16B each). |
| +0x44 | 12 | controller data array | CExoArrayList 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 viaGob::GetOrientationat0x004499a0which 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_type | Handler | Kind |
|---|---|---|
0x0001 | ResetMdlNodeParts only | Dummy / base |
0x0003 | ResetLight | Light |
0x0005 | ResetMdlNodeParts only | Emitter |
0x0009 | ResetMdlNodeParts only | Camera |
0x0011 | ResetMdlNodeParts only | Reference |
0x0021 | ResetTriMesh → ResetTriMeshParts | TriMesh |
0x0061 | ResetSkin | Skin mesh |
0x00A1 | ResetAnim | AnimMesh |
0x0121 | ResetDangly | Dangly mesh (cloth) |
0x0221 | ResetAABBTree + ResetTriMeshParts | Walkmesh with AABB |
0x0401 | (no-op) | Trigger / unused |
0x0821 | ResetLightsaber | Saber 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:
| Flag | Type | Total | Base | Extra | Extends |
|---|---|---|---|---|---|
| 0x0001 | Base | 80 | 80 | 0 | – |
| 0x0003 | Light | 172 | 80 | 92 | MdlNode |
| 0x0005 | Emitter | 304 | 80 | 224 | MdlNode |
| 0x0009 | Camera | 80 | 80 | 0 | MdlNode |
| 0x0011 | Reference | 116 | 80 | 36 | MdlNode |
| 0x0021 | TriMesh | 412 | 80 | 332 | MdlNode |
| 0x0061 | Skin | 512 | 412 | 100 | TriMesh |
| 0x00A1 | AnimMesh | 468 | 412 | 56 | TriMesh |
| 0x0121 | Dangly | 440 | 412 | 28 | TriMesh |
| 0x0221 | AABB | 416 | 412 | 4 | TriMesh |
| 0x0401 | Trigger | 80 | 80 | 0 | MdlNode |
| 0x0821 | Saber | 432 | 412 | 20 | TriMesh |
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 offset | Field | Layout | Runtime relocation |
|---|---|---|---|
| +0x04 | texture SafePointers | 12-byte array header | Zeroed on disk |
| +0x10 | flaresizes | CExoArrayList<float> | ptr relocated |
| +0x1C | flarepositions | CExoArrayList<float> | ptr relocated |
| +0x28 | flarecolorshifts | CExoArrayList<Vector> | ptr relocated |
| +0x34 | texturenames | CExoArrayList<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.InternalCreateInstanceonly allocates the detonation memory for that branch, so adetonatecontroller on a"Fountain"emitter reads unallocated memory at runtime and crashes. This is a known flaw inmdlops-based exporters (KotorMax);rakata-lintwill 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::GetMinimumSpherehierarchically 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) andtexture_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 byanimate_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:
| Bit | Component | Size |
|---|---|---|
| 0x01 | position | 3×f32 (12B) – always set |
| 0x02 | UV1 / tverts0 | 2×f32 (8B) |
| 0x04 | UV2 / tverts1 | 2×f32 (8B) |
| 0x08 | UV3 / tverts2 | 2×f32 (8B) |
| 0x10 | UV4 / tverts3 | 2×f32 (8B) |
| 0x20 | normal | 3×f32 (12B) – always set |
| 0x80 | tangent space | 3×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:
| Slot | Extra offset | Field | Evidence |
|---|---|---|---|
| 0 | +0x104 | position | LightPartTriMesh reads 3×f32, world-transforms |
| 1 | +0x108 | normal | LightPartTriMesh reads 3×f32, rotation only |
| 2 | +0x10C | vertex color | Checked != -1, reads RGB only. Alpha unused. |
| 3 | +0x110 | UV1 | PartTriMesh reads 2×f32 |
| 4 | +0x114 | UV2 | Structural: tverts1 in InternalGenVertices |
| 5 | +0x118 | UV3 | Structural: tverts2 |
| 6 | +0x11C | UV4 | Structural: tverts3 |
| 7 | +0x120 | tangent space | Filled by CalculateTangentSpaceBasis |
| 8–10 | +0x124..+0x12C | reserved | Always -1 across 215 surveyed vanilla meshes |
Note
Vertex colour alpha is unused (confirmed 2026-04-04).
LightPartTriMeshreads only bytes [0], [1], [2] (RGB). Byte [3] is stored but never read. The rendered output hardcodes alpha to0xFF. The fourth byte exists purely for alignment.
Note
The engine doesn’t trust any of these values on load.
InternalPostProcessat0x0043cf00recomputes the flags, stride, per-attribute offsets, andmdx_data_offsetfrom 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 offset | Field | Layout | Notes |
|---|---|---|---|
| +0x00 | weights | CExoArrayList<SkinVertexWeight> | Always zero in binary files. |
| +0x14 | bone_weight_data | ptr | Relocated if count at +0x18 > 0. |
| +0x1C | qbone_ref_inv | CExoArrayList<Quaternion> | Inverse-bind rotations. |
| +0x28 | tbone_ref_inv | CExoArrayList<Vector> | Inverse-bind translations. |
| +0x34 | bone_constant_indices | CExoArrayList<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:
| Offset | Size | Field | Notes |
|---|---|---|---|
| +0x00 | 12 | box_min | 3×f32 AABB minimum corner |
| +0x0C | 12 | box_max | 3×f32 AABB maximum corner |
| +0x18 | 4 | right_child | Content-relative offset (0 = no child) |
| +0x1C | 4 | left_child | Content-relative offset (0 = no child) |
| +0x20 | 4 | face_index | i32. Leaves: ≥ 0. Internal: −1. |
| +0x24 | 4 | split_direction_flags | Axis 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 offset | Field | Notes |
|---|---|---|
| +0x00 | saber vert data | Relocated pointer |
| +0x04 | saber UV data | Relocated pointer |
| +0x08 | saber normal data | Relocated pointer |
| +0x0C | GL vertex pool ID | Runtime-only (set by RequestPool) |
| +0x10 | GL index pool ID | Runtime-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:
| Offset | Size | Field | Notes |
|---|---|---|---|
| +0x00 | u32 | type_code | Byte offset of the target property in the Part struct. |
| +0x04 | i16 | supermodel_link | Additive-blending property offset; -1 = no blending. |
| +0x06 | u16 | row_count | Number of keyframes. |
| +0x08 | u16 | time_data_offset | Float-array index for time values. |
| +0x0A | u16 | data_offset | Float-array index for value data. |
| +0x0C | u8 | value_type_and_flags | Low nibble: 1=float, 2/4=quaternion, 3=vector. Bit 4=0x10=Bezier. |
| +0x0D | 3 | padding | Alignment 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,2or4=quaternion,3=vector. Selects the interpolation path (Lerp/Slerp/VectorLerp). - High nibble (
& 0xF0) – flags.0x10signals 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 singleu32, 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 name | Code | Columns | Meaning |
|---|---|---|---|
position | 8 | 3 | x, y, z |
orientation | 20 | 4 | x, y, z, angle (compressed axis-angle) |
scale | 36 | 1 | uniform 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:
- Read the MDX file into a buffer.
- Call
Reset(mdl_content, mdx_content, resource). Resetpasses the MDX pointer as a third parameter through the whole reset chain (ResetMdlNode,ResetTriMeshParts, …). Every downstream function accepts it.- No function ever reads it.
ResetTriMeshPartseven overwrites its copy to use as a loop counter. - 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.
ResetLitepath. There’s a separate “lightweight” loader (InputBinary::ResetLiteat0x004a11b0) that may use MDX for a reduced in-memory representation – unverified.
For Rakata, this has two consequences:
- 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.
- 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 type | Sentinel value | Hex (f32 LE) |
|---|---|---|
Non-skin (type & 0x40 == 0) | 10,000,000.0 | 00 96 18 4B |
Skin (type & 0x40 != 0) | 1,000,000.0 | 00 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 afterInternalPostProcessoverwrites 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.ResetTriMeshPartsrelocates 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:
| Offset | Size | Field | Type | Notes |
|---|---|---|---|---|
| +0x00 | 12 | plane_normal | 3×f32 | Face plane normal. |
| +0x0C | 4 | plane_distance | f32 | Plane equation: n·p = d. |
| +0x10 | 4 | surface_id | u32 | Walkability / material identifier. |
| +0x14 | 6 | adjacent | 3×u16 | Indices of adjacent faces (for AABB/pathfinding). |
| +0x1A | 6 | vertex_indices | 3×u16 | Triangle 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_COUNToffset was 0x9E → actually 0x130MDX_OFFSETwas 0xB8 → actually two separate fields at 0x144 and 0x148VERTEX_STRUCT_SIZEwas 0xBC → actually 0xFCMESH_EXTRA_SIZEwas 200 bytes → actually 332 (0x14C)RENDERboolean was missing entirely → added at 0x139SHADOWboolean 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 of0x13= 19 columns, not 9. - Integral orientation: ORIENTATION controllers with raw byte
== 2mean “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:
- NaN ≠ NaN (IEEE 754): 1559 false failures – floats containing NaN don’t equal themselves. Fixed with bitwise
f32::to_bits()comparison. - 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.
- 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 offset | Extra offset | Field | Type |
|---|---|---|---|
| +0x50 | +0x00 | deadspace | f32 |
| +0x54 | +0x04 | blast_radius | f32 |
| +0x58 | +0x08 | blast_length | f32 |
| +0x5C | +0x0C | num_branches | i32 |
| +0x60 | +0x10 | control_pt_smoothing | i32 |
| +0x64 | +0x14 | x_grid | i32 |
| +0x68 | +0x18 | y_grid | i32 |
| +0x6C | +0x1C | spawn_type | i32 |
| +0x70 | +0x20 | update | char[32] |
| +0x90 | +0x40 | render | char[32] |
| +0xB0 | +0x60 | blend | char[32] |
| +0xD0 | +0x80 | texture | char[32] |
| +0xF0 | +0xA0 | chunk_name | char[16] |
| +0x100 | +0xB0 | two_sided_tex | i32 |
| +0x104 | +0xB4 | loop | i32 |
| +0x108 | +0xB8 | render_order | u16 |
| +0x10A | +0xBA | frame_blending | u8 |
| +0x10B | +0xBB | depth_texture_name | char[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
| Format | Resource type |
|---|---|
| MDL | 2002 (0x7D2) |
| MDX | 3008 (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:
| Function | Callers |
|---|---|
AsModel | 34 |
AsMdlNodeTriMesh | 14 |
AsMdlNodeEmitter | 11 |
AsAnimation | 7 |
AsMdlNodeLightsaber | 5 |
AsMdlNodeSkin | 4 |
AsMdlNodeAABB | 3 |
AsMdlNodeDanglyMesh | 3 |
AsMdlNodeLight | 3 |
AsMdlNodeAnimMesh | 2 |
AsMdlNodeCamera | 2 |
AsMdlNodeReference | 2 |
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):
| Function | Address |
|---|---|
Input::Read | 0x004a14b0 |
InputBinary::Read | 0x004a1260 |
InputBinary::Reset | 0x004a1030 |
InputBinary::ResetMdlNode | 0x004a0900 |
InputBinary::ResetMdlNodeParts | 0x004a0b60 |
InputBinary::ResetTriMeshParts | 0x004a0c00 |
InputBinary::ResetAABBTree | 0x004a0260 |
InputBinary::ResetLight | 0x004a05e0 |
InputBinary::ResetSkin | 0x004a01b0 |
InputBinary::ResetDangly | 0x004a0100 |
InputBinary::ResetAnim | 0x004a0060 |
InputBinary::ResetLightsaber | 0x004a0460 |
InputBinary::ResetAnimation | 0x004a0fb0 |
MdlNodeTriMesh::InternalPostProcess | 0x0043cf00 |
MdlNodeTriMesh::InternalGenVertices | 0x00439df0 |
MdlNodeTriMesh::InternalParseField | 0x004658b0 |
MdlNodeEmitter::InternalParseField | 0x004658b0 |
MdlNodeEmitter::InternalCreateInstance | 0x0049d5c0 |
PartTriMesh::GetMinimumSphere | 0x00443330 |
LightPartTriMesh | 0x0046a9e0 |
NewController::Control | 0x00483330 |
NewController::GetFloatValue | 0x00482bf0 |
Model constructor | 0x0044aa70 |
MaxTree constructor | 0x0044a900 |
ParseNode | 0x004680e0 |
| Node type flag table | 0x00740a18 |
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
| Property | Value |
|---|---|
| On-disk unit | A directory under the SAVES: alias, named NNNNNN - <name> (for example, 000231 - Game230); slots 000000 and 000001 are reserved, see below |
| Main archive | SAVEGAME.sav, an ERF with version tag MOD V1.0 |
| Loose sidecars | savenfo.res, PARTYTABLE.res, GLOBALVARS.res (each a GFF), plus Screen.tga |
| Rust reference | rakata-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:
| File | Format | Role |
|---|---|---|
SAVEGAME.sav | ERF (MOD V1.0) | The bundle: every per-module archive plus the global session resources |
savenfo.res | GFF (NFO ) | Menu metadata: name, area, last module, play time, portraits |
PARTYTABLE.res | GFF (PT ) | Party roster, gold, XP, journal, available companions, pazaak, galaxy map |
GLOBALVARS.res | GFF (GVT ) | Campaign global variables (booleans, numbers, locations, strings) |
Screen.tga | TGA | Save-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:
| Resource | Type | What it holds |
|---|---|---|
IFO (resref Module) | module info | The 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 (GetNPCObjectapplies 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’sPercentXPfor their row, applied toPT_XP_POOLfrom 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:
- Open
SAVEGAME.sav. - Find the resource named after the module.
- Parse that resource as its own ERF, then read the
GITinside 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:
- If the module exists as an
RSVresource, the engine resolves the filename through theGAMEINPROGRESS:alias and opens it as typeRSV(0x0bc1). - If
RSVis not found, it falls back toSAV(type0x0809, decimal2057– 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_fileis not affected by whether a module loaded asRSVorSAV. It comes from one place only: the module’s ownMod_IsNWMFileIFO field, read the same way regardless of resource type (see the NWM note above). ARE loading is unconditional too – the area object always demands itsAREresource as the first step of loading, with no code path that skips it for any resource type. A hand-staged.rsvstill needs a validAREto load successfully; it cannot get by onIFOandGITalone.
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 / quicksave | Autosave | |
|---|---|---|
| Writer | StallEventSaveGame | DoPCAutosave |
| Fires when | you save to a slot, or quicksave | you cross into a new module (StartNewModule) |
savenfo-only fields | SAVEGAMENAME, LIVE1-LIVE6, LIVECONTENT | PCAUTOSAVE (=1), SCREENSHOT, AUTOSAVEPARAMS |
| Slot thumbnail | Screen.tga (captured frame) | none; SCREENSHOT holds a load_<module> resref |
pifo.ifo | absent | present |
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.
| Slot | Folder | Kind |
|---|---|---|
000000 | 000000 - QUICKSAVE | Quicksave |
000001 | 000001 - AUTOSAVE | Autosave |
000002 and up | 000002 - 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:
| Name | Slot number | Writer |
|---|---|---|
QUICKSAVE | 0 (literal) | CClientExoAppInternal::DoQuickSave, via the generic SaveGame backend |
AUTOSAVE | 1 (literal) | DoPCAutosave (self-contained), and separately MainLoop’s periodic-autosave branch, which calls the same generic SaveGame backend directly |
| the player-entered save name | 2 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
REBOOTAUTOSAVEnever reaches the slot-name formatter at all – it isn’t a folder name. It’s a boolean byte field insidesavenfo.resitself (alongsidePCAUTOSAVE), 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;DoPCAutosaveonly ever setsPCAUTOSAVE. The save-list preview code checks this bit together withPCAUTOSAVEwhen it decides where to pull a slot’s screenshot from, so the read path is live. With no PC producer, though, the field is permanently0in 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-documentedLIVE%dXbox content mounts. Two other REBOOT/AUTOSAVE-adjacent strings turned up during this trace and were ruled out as unrelated:CB_AUTOSAVEis an options-screen checkbox control id, andAutoSave/AutoSaveOnEnterare 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.ifoexists because at a transition the party is staged in the transientpifoparty-info file rather than a module roster (the same file the0xffffffffload path reads, see The party roster);DoPCAutosavecopies it into the folder. It is a GFF taggedIFOholding aMod_PlayerListof the party.- No
Screen.tgabecause there is no gameplay frame to capture mid-loading-screen, so the autosave records the loading-screen resref inSCREENSHOTinstead. (The slot thumbnail is independent of theEnableScreenShotini 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 label | Object | Notes |
|---|---|---|
Creature List | creatures | player characters are split out into a separate player list, not this one |
List | item instances | items in the area use the bare label List |
Door List | doors | |
TriggerList | triggers | |
Encounter List | encounters | |
WaypointList | waypoints | |
SoundList | sounds | |
Placeable List | placeables | corpses are excluded |
StoreList | stores | |
AreaEffectList | area-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.
| Aspect | UseTemplates = 1 (static .git) | UseTemplates = 0 (savegame GIT) |
|---|---|---|
| Object element | sparse placement | full self-contained snapshot |
TemplateResRef | present | absent (not read) |
| Blueprint load | yes, via the object’s LoadFromTemplate (UTC/UTD/UTP/UTT/…), then instance fields overlaid | none; the engine reads every field directly (CSWSCreature::LoadCreature, CSWSDoor::LoadDoor, …) |
| Where the data lives | mostly in the blueprint | entirely in the GIT element |
| A field missing from the element | comes from the blueprint | comes 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 = 0object 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 byUseTemplates. 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 behaviour | What the engine does |
|---|---|
| Field names vary by object type | No 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 things | A 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 full | Creatures, 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-coupled | Trigger 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 restored | MaxHitPoints, 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 reload | Written 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:
- The load menu reads the loose
savenfo.resstraight from each slot for its name, area, play time, and thumbnail; no archive is opened. When you pick a slot, itsLASTMODULEnames the first module to restore. CopyGameToFutureGameunpacks the chosen slot, theSAVEGAME.savERF plus the loose sidecars, into theFUTUREGAME:staging area.- The engine clears
GAMEINPROGRESS:and renamesFUTUREGAME:onto it. The live session runs from this unpacked copy. CSWSModule::LoadModulereplays the module against the working directory, runningLoadModuleStart/LoadModuleInProgress/LoadModuleFinish(the inverse ofStoreCurrentModule).LoadModuleStartreads the per-moduleIFOincluding its save-only fields, loads the globalREPUTEfaction table (LoadFactionsFromSaveGame/LoadReputationsFromSaveGame), and reads theGITwithUseTemplates = 0, so every object comes from its full snapshot.- 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
CORRUPTmarker 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.0ERF 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 theGITinside it. - Check
UseTemplatesfirst. A savegame GIT (UseTemplates = 0) is self-contained, so read objects directly. A module’s static.git(UseTemplates = 1) is template-relative, so resolve eachTemplateResRefagainst 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
pifoparty-info file is a working GFF taggedIFO.LoadCharacterFromIFOreads it when the requested member index is0xffffffff. - A
Player.bichas no dedicated reader. The engine loads it through the ordinaryMod_PlayerListcreature-load path, keyed byObjectId. IncludeModuleInSavedecides which visited modules are written into the save, usingmodulesave.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):
| Function | Address |
|---|---|
CServerExoAppInternal::StallEventSaveGame | 0x004b3110 |
CServerExoAppInternal::SaveGame | 0x004b58a0 |
CServerExoAppInternal::DoPCAutosave | 0x004b8300 |
CServerExoAppInternal::StartNewModule | 0x004ba920 |
CServerExoAppInternal::StoreCurrentModule | 0x004b2e70 |
IncludeModuleInSave | 0x004b20e0 |
CSWSModule::SaveModuleStart | 0x004c8960 |
CSWSModule::SaveModuleInProgress | 0x004c3b10 |
CSWSModule::SaveModuleFinish | 0x004ca680 |
CSWSModule::SavePrimaryPlayerInfo | 0x004c3c70 |
CSWSModule::SavePlayers | 0x004c7870 |
CSWPartyTable::Save | 0x005665c0 |
CSWPartyTable::SaveTableInfo | 0x005648c0 |
CSWPartyTable::SaveJournal | 0x00563d90 |
CSWPartyTable::AddNPC | 0x00564300 |
CSWPartyTable::SaveMember | 0x00563e80 |
CSWPartyTable::UpdateInventory | 0x00564030 |
CSWPartyTable::GetNPCObject | 0x00564700 |
CSWPartyTable::CreateParty | 0x00565760 |
CSWPartyTable::SpawnNPC | 0x00565130 |
CSWGlobalVariableTable::Save | 0x0052ad10 |
CSWGlobalVariableTable::WriteTable | 0x005299b0 |
CSWSArea::SaveGIT | 0x0050ba00 |
CSWSArea::SaveCreatures | 0x00507680 |
CSWSArea::SaveDoors | 0x00507810 |
CSWSArea::SaveTriggers | 0x005078d0 |
CSWSArea::SavePlaceables | 0x00507bd0 |
CSWSDoor::SaveDoor | 0x00588ad0 |
CSWSPlaceable::SavePlaceable | 0x00586a70 |
CSWSTrigger::SaveTrigger | 0x0058e660 |
CSWSCreature::SaveCreature | 0x00500610 |
CSWSCreatureStats::SaveStats | 0x005b1b90 |
CSWSWaypoint::SaveWaypoint | 0x005c8230 |
CSWSStore::SaveStore | 0x005c6cd0 |
CSWSSoundObject::Save | 0x005c86d0 |
CSWSEncounter::SaveEncounter | 0x00591350 |
CSWSAreaOfEffectObject::SaveEffect | 0x00594d80 |
CResGFF::CreateGFFFile | 0x00411260 |
CSWSArea::LoadGIT | 0x0050dd80 |
CSWSArea::LoadCreatures | 0x00504a70 |
CSWSCreature::LoadCreature | 0x00500350 |
CSWSCreature::LoadFromTemplate | 0x005026d0 |
CSWSTrigger::LoadTrigger | 0x0058da80 |
LoadTriggers | 0x0050a350 |
LoadTriggerGeometry | 0x0058d060 |
CSWGuiSaveLoad::UnpackGame | 0x006caaf0 |
CopyGameToFutureGame | 0x006c9a90 |
CSWSModule::LoadModule | 0x004b95b0 |
CSWSModule::LoadModuleStart | 0x004c9050 |
CServerExoAppInternal::LoadPrimaryPlayer | 0x004b5f50 |
CServerExoAppInternal::LoadCharacterStart | 0x004b7470 |
CServerExoAppInternal::LoadCharacterFinish | 0x004b5c50 |
CServerExoAppInternal::StorePlayerCharacters | 0x004b2ba0 |
CSWSPlayer::LoadCharacterFromIFO | 0x00561e30 |
CSWGlobalVariableTable::ReadTableWithCatalogue | 0x0052a280 |
CSWGlobalVariableTable::GetValueLocation | 0x00529350 |
CSWSObject::GetScriptLocation | 0x004cb7b0 |
CFactionManager::LoadFactionsFromSaveGame | 0x0052b5c0 |
CFactionManager::LoadReputationsFromSaveGame | 0x0052bbe0 |
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:
| Function | Address |
|---|---|
CSWSCreatureStats::ReadStatsFromGff | 0x005afce0 |
CSWSCreatureStats::ReadSpellsFromGff | 0x005aeb30 |
CSWSCreatureStats::SaveClassInfo | 0x005aec90 |
CSWSCreatureStats::CSWSCreatureStats (constructor) | 0x005aca80 |
CCombatInformation::LoadData | 0x00552350 |
CCombatInformation::SaveData | 0x00550f30 |
CSWSCombatRound::LoadCombatRound | 0x004d5120 |
CSWSPlayer::LoadCreatureData | 0x00560e60 |
CSWSMessage::SendServerToPlayerUpdateCharResponse | 0x00570c60 |
CSWSCreature::ReadScriptsFromGff | 0x004ebf20 |
CSWSCreature::LoadFollowInfo | 0x004fb180 |
CSWSCreaturePartyFollowInfo::Load | 0x004eb020 |
CSWSCreaturePartyFollowInfo::Save | 0x004eaf70 |
CSWSCreaturePartyFollowInfo::CSWSCreaturePartyFollowInfo (constructor) | 0x004f79e0 |
CSWSObject::LoadListenData | 0x004d0480 |
CSWSObject::SaveListenData | 0x004cca50 |
CSWSObject::LoadObjectState | 0x004d1cf0 |
CSWSObject::SaveObjectState | 0x004cec50 |
CSWSObject::LoadEffectList | 0x004d1be0 |
CSWSObject::SaveEffectList | 0x004cc9d0 |
CGameEffect::LoadGameEffect | 0x005043a0 |
CSWSObject::LoadActionQueue | 0x004cecb0 |
CSWSObject::SaveActionQueue | 0x004cc7e0 |
CSWSObject::CSWSObject (base constructor) | 0x004cfcb0 |
CSWSScriptVarTable::LoadVarTable | 0x0059aa80 |
CSWSScriptVarTable::SaveVarTable | 0x0059adb0 |
CSWVarTable::LoadVarTable | 0x0059b0f0 |
CSWVarTable::SaveVarTable | 0x0059b250 |
CSWSCreature::ReadItemsFromGff | 0x004ffda0 |
CSWSCreature::CSWSCreature (constructor) | 0x004f7a10 |
CSWSCreature::SetDetectMode | 0x0050ee30 |
CSWSCreature::SetStealthMode | 0x0050ee50 |
CSWSModule::LoadLimboCreatures | 0x004c8c70 |
CSWSModule::SaveLimboCreatures | 0x004c5bb0 |
CSWSModule::LoadModuleInProgress | 0x004c5720 |
CSWSArea::LoadArea | 0x0050e190 |
CItemRepository::GetItemRepository | 0x004ef770 |
CSWSItem::LoadItem | 0x00560970 |
CSWSItem::LoadFromTemplate | 0x005608b0 |
CSWSItem::LoadDataFromGff | 0x0055fcd0 |
CSWSItem::CSWSItem (constructor) | 0x005530a0 |
CSWItem::CSWItem (base constructor) | 0x005b4660 |
CSWSItem::SetPossessor | 0x00553210 |
CSWSItem::SaveItem | 0x0055ccd0 |
CSWSItem::SaveItemProperties | 0x00555790 |
CSWSItem::SaveContainerItems | 0x0055cfa0 |
CSWSItem::ReadContainerItemsFromGff | 0x0055f0f0 |
CSWSArea::LoadItems | 0x00504de0 |
CSWSArea::SaveItems | 0x00507750 |
CSWSDoor::LoadDoor | 0x0058a1f0 |
CSWSDoor::LoadFromTemplate | 0x0058b3d0 |
CSWSDoor::LoadDoorExternal | 0x0058c5f0 |
CSWSDoor::CSWSDoor (constructor) | 0x00589ee0 |
CSWSDoor::PostProcess | 0x00589d40 |
CSWSArea::LoadDoors | 0x0050a0e0 |
CSWSPlaceable::LoadPlaceable | 0x00585670 |
CSWSPlaceable::LoadFromTemplate | 0x00587a70 |
CSWSPlaceable::CSWSPlaceable (constructor) | 0x005877e0 |
CSWSPlaceable::LoadBodyBag | 0x005864b0 |
CSWSPlaceable::SpawnBodyBag | 0x004ce220 |
CSWSPlaceable::AcquireItem | 0x00584b10 |
CSWSPlaceable::PostProcess | 0x00584870 |
CSWSArea::LoadPlaceables | 0x0050a7b0 |
ExecuteCommandCreateObject | 0x0052f820 |
CSWSTrigger::CSWSTrigger (constructor) | 0x0058eae0 |
CSWSTrigger::LoadFromTemplate | 0x0058ed70 |
CSWSTrigger::AddToArea | 0x0058f030 |
CSWSWaypoint::LoadWaypoint | 0x005c7f30 |
CSWSWaypoint::CSWSWaypoint (constructor) | 0x005c7e70 |
CSWSArea::LoadWaypoints | 0x00505360 |
CSWSStore::LoadStore | 0x005c7180 |
CSWSStore::LoadFromTemplate | 0x005c7760 |
CSWSStore::CSWSStore (constructor) | 0x005c6ab0 |
CSWSStore::AddItemToInventory | 0x005c70c0 |
CSWSArea::LoadStores | 0x005057a0 |
CSWSSoundObject::Load | 0x005c9040 |
CSWSSoundObject::LoadFromTemplate | 0x005c94e0 |
CSWSSoundObject::CSWSSoundObject (constructor) | 0x005c8f30 |
CSWSArea::LoadSounds | 0x00505560 |
CSWSEncounter::ReadEncounterFromGff | 0x00592430 |
CSWSEncounter::ReadEncounterScriptsFromGff | 0x00590820 |
CSWSEncounter::LoadEncounter | 0x00593830 |
CSWSEncounter::LoadFromTemplate | 0x00593a90 |
CSWSEncounter::LoadEncounterGeometry | 0x00590580 |
CSWSEncounter::LoadEncounterSpawnPoints | 0x00590410 |
CSWSEncounter::CSWSEncounter (constructor) | 0x00593c70 |
CSWSArea::LoadEncounters | 0x00505060 |
CSWSAreaOfEffectObject::LoadEffect | 0x00594b00 |
CSWSAreaOfEffectObject::CSWSAreaOfEffectObject (constructor) | 0x00594480 |
CSWSArea::LoadAreaEffects | 0x00505af0 |
CSWSArea::LoadProperties | 0x00507490 |
CSWSArea::SaveProperties | 0x00506090 |
CSWSArea::LoadMaps | 0x00505da0 |
CSWSArea::SaveMaps | 0x005061d0 |
CSWSArea::LoadPlaceableCameras | 0x00505eb0 |
CSWSArea::SavePlaceableCameras | 0x005062a0 |
CSWSModule::SaveModuleIFOStart | 0x004c7050 |
CSWSModule::SaveModuleIFOFinish | 0x004c8b90 |
CSWSModule::SaveStatic | 0x004c5980 |
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):
| Function | Address |
|---|---|
CSWSCreatureStats::GetFortSavingThrow | 0x005ab810 |
CSWSCreatureStats::GetWillSavingThrow | 0x005ab880 |
CSWSCreatureStats::GetReflexSavingThrow | 0x005ab8f0 |
CSWSCreatureStats::GetBaseFortSavingThrow | 0x005aa1b0 |
CSWSCreatureStats::GetBaseWillSavingThrow | 0x005aa2f0 |
CSWSCreatureStats::GetBaseReflexSavingThrow | 0x005aa430 |
CSWSCreature::GetArmorClass | 0x004ed1d0 |
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::GetMaxHitPoints | 0x004d01a0 |
CSWSCreature::GetMaxHitPoints | 0x004ed310 |
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::GetGold | 0x004edd60 |
CSWSCreature::SetGold | 0x004edda2 |
CSWSCreature::AddGold | 0x004f3dc8 |
CSWSCreature::RemoveGold | 0x004f3eea |
CSWSCreature::TransferGold | 0x004fd769 |
CSWSCreature::SetInParty | 0x004fdb2d |
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::LoadLocalCharacter | 0x00561d70 |
ExecuteCommandAddPartyMember | 0x0052de70 |
ExecuteCommandRemovePartyMember | 0x00541c00 |
SwitchPlayerCharacter | 0x005667c0 |
TransferInventory | 0x005641e0 |
CSWSAreaOfEffectObject::LoadAreaEffect (singular; the vfx_persistent.2da-driven definition lookup, wired only to fresh spell-cast creation, never to a save load) | 0x005947b0 |
CSWSEffectListHandler::OnApplyAreaOfEffect | 0x004dade0 |
ApplyEffect | 0x0050c6b0 |
CSWSAreaOfEffectObject::AIUpdate (heartbeat tick) | 0x00595d10 |
CSWSAreaOfEffectObject::EventHandler (enter/exit collision events) | 0x005964e0 |
CVirtualMachineInternal::RunScript | 0x005d45d0 |
CVirtualMachine::RunScript (thin forwarder) | 0x005d0fc0 |
CSWSAreaOfEffectObject::GetEffectSpellId / SetEffectSpellId | 0x005945d0 / 0x005945e0 |
Located while auditing save-slot numbering (QUICKSAVE/AUTOSAVE/REBOOTAUTOSAVE):
| Function | Address |
|---|---|
CClientExoAppInternal::DoQuickSave | 0x005f4b50 |
CSWGuiSaveLoadEntry::LoadData | 0x006c8e50 |
CSWGuiSaveLoad::LoadPCAutoSave | 0x006ca250 |
CSWGuiMainMenu::OnPanelAdded (disk-space probe reusing the reserved AUTOSAVE name) | 0x0067b6c0 |
CSWGuiSaveLoad::PopulateGameList | 0x006cc160 |
CSWGuiSaveLoad::HandleSaveButton | 0x006cbb60 |
CSWGuiSaveLoad::PromptForSaveName | 0x006cb820 |
CSWGuiSaveLoad::WriteGame | 0x006c8790 |
CSWGuiSaveLoad::ShowGame | 0x006c89d0 |
CSWGuiSaveLoadEntry::SetXboxTitle | 0x006c9780 |
CGuiInGame::DoQuickLoad | 0x00633c50 |
CSWGuiSaveLoadEntry::CSWGuiSaveLoadEntry (constructor) | 0x006cb940 |
Located while enumerating AUTOSAVEPARAMS:
| Function | Address |
|---|---|
KOTOR_AUTOSAVE_PARAMS::SaveToGFF | 0x004b28e0 |
CStatusSummary::SaveToGFF | 0x004b26c0 |
CGuiInGame::GetStatusSummary / SetStatusSummary | 0x0062f0a0 / 0x0062f040 |
CGuiInGame::SuppressStatusSummary | 0x0062f0c0 |
CGuiInGame::GetPendingStatusSummary | 0x0062ef70 |
CGuiInGame::ShowStatusSummary | 0x0062ef90 |
CGuiInGame::UpdateStatus | 0x0062eeb0 |
CSWGuiStatusSummary::AddAlignmentShift | 0x00624a70 |
CSWGuiStatusSummary::AddCredits | 0x00624ab0 |
CSWGuiStatusSummary::AddXp | 0x0062b580 |
CSWGuiStatusSummary::AddStealthXp | 0x0062b5a0 |
CSWVirtualMachineCommands::ExecuteCommandSuppressStatusSummaryEntry | 0x00547e50 |
CSWVirtualMachineCommands::ExecuteCommandStartNewModule | 0x00544390 |
CClientExoApp::GetMoveToModuleMovies | 0x005edb60 |
CClientExoApp::AddMoveToModuleMovie | 0x005edb50 |
CClientExoApp::RemoveMoveToModuleMovies | 0x005ee380 |
CServerExoApp::GetMoveToModuleStartWaypoint / SetMoveToModuleStartWaypoint | 0x004aed40 / 0x004aed30 |
CServerExoApp::SetMoveToModulePending | 0x004aecc0 |
CServerExoApp::SetMoveToModuleString | 0x004aecd0 |
CClientExoApp::SetLoadScreenByModuleName | 0x005edcf0 |
CClientExoApp::GetLoadMusicByModuleName (thunk) / CClientExoAppInternal::GetLoadMusicByModuleName (implementation) | 0x005edd00 / 0x005f3650 |
CWorldTimer::GetWorldTime | 0x004ade40 |
CWorldTimer::ConvertFromTimeOfDay | 0x004add90 |
CSWSModule::GetTime | 0x004c4100 |
Located while tracing the CORRUPT.res marker:
| Function | Address |
|---|---|
CGuiInGame::UnpackQuickSaveGame | 0x006323a0 |
CopyQuickSaveGameToFutureGame (quicksave counterpart to CopyGameToFutureGame; not fully decompiled, assumed the same shape by symmetry) | 0x0062fbe0 |
CERFFile::Read | 0x005dce50 |
CERFFile::ReadHeaderVariance | 0x005dd3c0 |
CERFFile::ExportFilesFromERF | 0x005dd710 |
CERFRes::CopyToFile | 0x005dd170 |
CExoFile::FileOpened / Read / Write | 0x005e6a10 / 0x005e6960 / 0x005e69a0 |
CExoFileInternal::Read / Write | 0x005eba40 / 0x005ebc60 |
CSWGuiSaveLoad::VerifyLoadGame | 0x006cc0e0 |
CSWGuiSaveLoad::LoadGame | 0x006cb0e0 |
CExoResMan::CleanDirectory | 0x00409460 |
CExoResMan::WipeDirectory | 0x00408e90 |
CExoAliasListInternal::ResolveFileName | 0x005eb6b0 |
CExoBaseInternal::GetResourceExtension | 0x005e7a00 |
Located while closing the reserved-name class (proving no fourth slot name reaches the formatter):
| Function | Address |
|---|---|
CSWGuiSaveLoadEntry::GetGameDirectory | 0x006c8250 |
CServerExoApp::SaveGame (thin wrapper over CServerExoAppInternal::SaveGame, dispatched from network message handlers) | 0x004ae6e0 |
HandlePlayerToServerModuleMessage / HandleServerAdminToServerMessage | 0x00524800 / 0x00528380 |
CSWGuiSaveLoad::DeleteGame | 0x006caa90 |
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):
| Function | Address |
|---|---|
ExecuteCommandSwitchPlayerCharacter (nwscript action dispatch, sole caller of SwitchPlayerCharacter) | 0x00544910 |
CSWPartyTable::GetFilename (computes AVAILNPC%d; ruled out as the PC source) | 0x00563620 |
CSWPartyTable::UpdateMembers | 0x00565530 |
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):
| Function | Address |
|---|---|
CExoBaseInternal::CreateResourceExtensionTable | 0x005e6d20 |
CExoBaseInternal::GetResTypeFromExtension | 0x005e7a40 |
CExoBase::GetResTypeFromExtension (thin forwarder to the Internal version above) | 0x005e6670 |
CServerExoAppInternal::SetEstimatedSaveSize | 0x004b5f90 |
CExoResMan::GetResTypeFromFile | 0x00406650 |
Located while tracing the Gold read gate through the benched-companion rejoin path:
| Function | Address |
|---|---|
CSWPartyTable::AddMember | 0x00565620 |
CSWGuiPartySelection::AcceptParty | 0x006be560 |
CSWVirtualMachineCommands::ExecuteCommandSpawnAvailableNPC | 0x00543ed0 |
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:
Obstaclesare the lightest of the three. Each entry is matched by itsNameresref to an already-placed object (CSWMiniGameObjectArray::GetMiniGameObjectByName,0x0066bfb0), andCSWMGObstacle::Load(0x0066d0b0) reads only a nestedScriptsstruct – no weapon, lifecycle, or geometry data at all.Playerand eachEnemiesentry are both backed by the same underlying object,CSWTrackFollower.CSWMiniPlayer::Load(0x006702f0) andCSWMiniEnemy::Load(0x006705f0) both delegate first toCSWTrackFollower::Load(0x0066fff0) on an embeddedfollowersub-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:
| Field | Type | Absent-field behaviour |
|---|---|---|
Hit_Points, Max_HPs | DWORD | Default 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_Radius | FLOAT | Default sentinel -1.0; applied only if the read value is >= 0.0. |
Invince_Period | FLOAT | Default 0.0, applied whenever the read value is >= 0.0 – trivially true, so this one effectively always writes. |
Bump_Damage | INT | Default 0, written unconditionally with no gate. |
Num_Loops | INT | Default 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.
| Field | Owner | Notes |
|---|---|---|
OnCreate, OnHitBullet, OnHitFollower, OnAnimEvent, OnHeartbeat | Base (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, OnTrackLoop | Override (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.
| Field | Owner | Absent-field behaviour |
|---|---|---|
BankID | Bank entry | Read 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_Model | Bank entry | Default empty, gated by resref validity; invalid or absent aborts the whole bank. |
Damage, Lifespan, Rate_Of_Fire, Speed, Target_Type | Bullet struct | Each 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_Sound | Bullet struct | Default empty; read unconditionally once Target_Type has succeeded, no further gating. |
Fire_Sound | Bank 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.
| Field | Type | Absent-field behaviour |
|---|---|---|
Minimum_Speed | FLOAT | Default sentinel -1.0; applied only if >= 0.0. |
Maximum_Speed | FLOAT | Default 100.0; applied only if >= 0.0 (effectively always). |
Accel_Secs | FLOAT | Default 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) | FLOAT | Default 0.0 each, written unconditionally – no carry-over gate. |
TunnelInfinite | Vector | Read via the vector default path, {0, 0, 0}, unconditional. |
Start_Offset_X / Start_Offset_Y / Start_Offset_Z | FLOAT (assembled into one Vector) | Default 0.0 each, fed to SetOrigin unconditionally. |
Target_Offset_X / Target_Offset_Y / Target_Offset_Z | FLOAT (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_Bankslist nested on the vehicle (Player or Enemy). Each bank isBankID/Gun_Model/Fire_Soundplus a nestedBulletstruct 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 nestedScriptsandSoundsstructs. - Movement / track geometry – Player-struct-only flat fields (
Tunnel*,*_Offset_*, theAccel_Secs/Minimum_Speed/Maximum_Speedderivation). 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.