Rakata
Rakata is a clean-room Rust implementation of Knights of the Old Republic (KotOR) data formats and tooling: a workspace of crates for reading, writing and validating Odyssey Engine game data.
This manual documents the formats themselves, and how swkotor.exe reads them, separately from the Rust that implements them. The format knowledge is meant to stand on its own.
Requirements
- Rust Version:
1.88.0or newer. The workspace resolves against that floor rather than around it, so a dependency needing more than it changes what cargo picks instead of quietly raising the real requirement.
Documentation Domains
Two: the Software API and the Format Specifications.
1. The Workspace (Code API)
If you are developing against Rakata and need the layout of types, functions and data structures, the Rustdocs are the reference. The crates are:
Libraries (crates/)
rakata-core: Foundational primitives (ResRef,ResourceType,ResourceId) and core utilities (encoding, filesystem, detection).rakata-formats: Readers and writers for KotOR’s binary and text formats, including GFF, ERF, RIM, KEY/BIF, MDL/MDX, TPC and TGA.rakata-generics: Typed wrappers around the GFF-backed resources (UTC, UTI, UTW and the rest).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) resolving mounted save, extra overrides,Override/, active module, then chitin/BIFs. The last three follow the engine’s traced order; the first two are the crate’s own. Plus composite module handling (.mod+_s.rim+_dlg.erf) and install-wide enumeration helpers.rakata-lint: Resource validation against engine-derived field schemas, catching crash-causing mod errors before they reach the game.rakata-save: Save game parsing and modification logic.rakata-install: Installation discovery. Reports candidates with what is needed to tell them apart, and never picks one. The only crate that touches OS-specific paths; nothing in core, formats or generics depends on it.rakata-patcher: Installing a loose-file mod and taking it back. Reads a payload as a folder or as the zip, 7z or rar it arrived in, works out what installing it would replace, and keeps a copy of each overwritten file beside a record naming them.rakata-ui: What the GUI tools share: colours named for what they mean, the controls somebody should recognise between tools, the two window shapes, and each tool’s log.rakata: Facade crate re-exporting the ecosystem.
Tools (tools/)
rakata-modinstaller: Desktop GUI application for installing loose-file mods where the filesystem tellsOverrideandoverrideapart.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 formats/ manual is Rakata’s evidence log. It covers each format’s binary structure and what swkotor.exe does with it, with every engine claim carrying its own provenance.
- Archive Formats: the containers,
BIF,KEY,ERFandRIM. - GFF Structure: the labelled tree under most of KotOR’s data, and the blueprint types built on it (creatures, dialogues, triggers and the rest).
- 3D Models & Mesh: MDL/MDX structures and binary walkmeshes.
- Textures and Audio:
TPCandDDScompression, MP3 and Miles Sound System wrappers. - Text & Data Formats: talk tables (
TLK), rule tables (2DA), and layout geometry (LYT,VIS).
The Goals & Roadmap covers where the project is heading, and Architecture covers how the workspace fits together.
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
There are two of these, and they sit at very different distances.
The near one: be the reference implementation. Plenty of tools can read KOTOR’s formats. What almost none of them tell you is what the engine actually does with a field your file leaves out, and that is the difference between a document you can read and one you can build from. Every format page here is written against swkotor.exe itself rather than against folklore, and we are working toward the point where somebody can implement a conforming reader and writer from the page alone, without ever opening our Rust. That one is close enough to be a plan rather than a wish.
The far one: an actual engine integration. It would be very cool to tie all these isolated pieces together into a real rendering pipeline - dropping a vanilla model into an active window and having it stream textures and background audio straight out of the game data. That is a pipedream and we are honest about it, but the closer the foundation gets to exact, the less mad it sounds.
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 19 different KOTOR file formats. We’ve tackled a lot of the weird legacy archives (BIF), models (MDL/MDX), raw textures (TPC), and the binary walkmeshes behind WOK, DWK and PWK, 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 Journals (JRL).
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. Writing It All Down (Also Active)
The formats/ manual isn’t a side effect of the code, it’s a deliverable in its own right. Every page is written from Ghidra work against the real executable, and every claim is meant to trace back to either a decompilation finding or a measurement across the vanilla corpus.
The part nothing else has is absent-field behaviour: for each field, what value the engine is actually holding when a file doesn’t carry it. That matters more than it sounds. An absent TrapType resolves to 0xFF, meaning no trap at all - read it as 0 and you have just named a real row in traps.2da. An encounter missing its Geometry field loads perfectly well, while one carrying an empty Geometry field gets refused outright. That isn’t trivia, it’s the difference between a mod that works and one that dies on load.
What’s missing, because a roadmap that only lists wins isn’t much use: compiled scripts (.ncs) have no page at all, and they are the most cross-referenced format in the whole manual, since nearly every blueprint points at one. Until Issue #19 lands, every other page carries a reference into a hole. PTH, GUI and JRL are smaller gaps. And a fair chunk of binary layout detail still lives in Rust source comments rather than on the pages where a reader would go looking for it, which we are partway through moving out.
3. 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).
4. 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.
5. 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: undocumented fields don’t get read as an opaque
Vec<u8>and passed through. The goal is to map every struct boundary properly. - Reserved fields are the exception: where the binary layout defines a reserved field whose meaning we haven’t cracked, we map it as a correctly-sized reserved value rather than dropping data the engine might rely on. Explicit blank padding isn’t stored at all; it’s recalculated 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 moving target. rakata-audio is planned and does not exist yet, and the tools are at very different stages, so expect these crates to fill out and new siblings to appear as the roadmap goals land.
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-patcher (depends on: core, formats, extract)
rakata-save (depends on: core, formats)
rakata-install (depends on: core)
rakata (facade: re-exports all library crates)
rakata-ui (no workspace deps; the GUI tools only)
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-formats-derive: The proc macro behind#[derive(GffModel)]. Onlyrakata-formatsdepends on it, but it is where a field’s declaration is turned into a reader, a writer and a schema entry at once, and where several contracts are enforced as build errors rather than as tests.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-install: Finds the game on this machine. It reports every copy it can see rather than picking one, because anybody who mods has several. The only crate that knows what operating system it is on.rakata-patcher: Installing a loose-file mod and taking it back. It reads a payload as a folder or as the archive it downloaded as, works out what installing it would replace, and keeps a copy of every file it overwrites beside a record naming them.rakata-ui: What the GUI tools share. Colours named for what they mean, the controls somebody should recognise between tools, the two window shapes, and the log each tool writes.rakata: A handy facade crate that re-exports everything so you only need to add one dependency.
Tool Crates (tools/)
rakata-modinstaller: The desktop application for installing loose-file mods where the filesystem tellsOverrideandoverrideapart.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: The environment logic lives in
rakata-extract: finding loose files, enforcing precedence (checkingOverride/before extracting from a BIF), and assembling composite files. That separation is why the parsers work on an isolated test fixture and on a live install alike.
How 2DA Tables Reach a Decoded View
Decoded views resolve file-native values against 2DA tables, so something has to hand them a table. resolve() takes &mut impl TwoDaSource, and the three concerns that twoda_cache.rs once fused into one file live in three crates:
| Concern | What it is | Crate |
|---|---|---|
| Identity | TwoDaName, tables::*, meaning which tables exist and what they are called | rakata-core, beside ResourceType. A 2DA parser has no business knowing appearance.2da exists. |
| Capability | the TwoDaSource trait: something can hand me a table by name | rakata-formats, the lowest crate that can name &TwoDa in the return type. Core would need core -> formats, a worse inversion than the one being fixed. |
| Implementation | TwoDaCache, TwoDaCacheError: the VFS hands me tables and I remember them | rakata-extract, since the cache holds a &GameVfs |
Generics needs identity and capability, not implementation, so rakata-extract appears nowhere in the generics manifest, neither in [dependencies] nor [dev-dependencies]. Tests spanning both crates live in the rakata facade.
TwoDaSource::twoda returns Option<&TwoDa> rather than a Result, because every consumer discarded the error. A caller that 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, and that is not the same call. Lint is a leaf, so a concrete dependency propagates to nobody. Generics is foundational, so its dependencies reach everything above it. Weigh this by where a crate sits in the graph rather than by whether the edge looks tidy on its own.
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 splits by what a caller is doing to the file, which is not the same question as what kind of tool it is:
- Reading it, to inspect or report. A linter or an inspector wants the typed view: type-safe access to known fields, and unmodelled bytes are nobody’s concern because nothing goes back to disk.
- Authoring a new file. The typed view again, and reconstruction is correct here because there is no original to lose.
- Editing a file that already exists. The raw
Gffand its path API, never the typed view. A projection written back drops every field it does not model, and in a savegame that is most of them: another area’s object state, a mod’s own labels, anything unaudited. Nothing warns you, because a dropped field and a field the file never had look identical on the way out.
The verb decides it, not the program. A save editor reads through a typed view and edits through the tree, and it is the same save editor doing both. Any tool preserving toolset annotations or auditing round trips is in the third case for the same reason.
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.
Four clarifications carry most of the weight.
“At that path” is the whole test. A door blueprint’s Tag is read; the Tag on a door placement in a GIT is not, because the engine takes a templated door’s tag from the blueprint. Same label, same concept, one live copy and one dead one. A concept being live somewhere else says nothing about this copy.
Prevalence is not a criterion, and neither is carrying a real value. WaypointList[].TemplateResRef appears in every waypoint in a retail install and no load path reads it. The counts mislead in every direction, and all three have happened:
| Field | Prevalence | Value | Read? |
|---|---|---|---|
.utc TemplateList | nearly every creature | empty in all of them | no |
swoop-track .are rate-of-fire | present | at their defaults | yes |
module.ifo Mod_VO_ID | most modules | a real string | no, the label is not in the executable |
The corpus tells you where to look, never what to model.
“The blueprint wins” is not the rule, and assuming it gets half the cases backwards. The canonical dead field is a placement’s copy losing to the blueprint’s, and there is a family running the other way. A door’s Tag is read only on the blueprint, so the placement’s copy is dead. A door’s TransitionDestin and its LinkedTo siblings are read on the blueprint and then overlaid from the placement immediately afterward, so it is the blueprint’s copy that is dead. Triggers repeat both halves.
Same engine, same load, adjacent fields, opposite winner. What decides it is whether an overlay step exists for that field, not which struct the read was handed. So the question to ask of a new field is “does anything write over this after the read”, and a finding reporting only which struct a value came from has not answered it.
“Path” means the GFF path, not a decode-time variant. The rule stops at the structural position and does not reach inside a decoded enum. PropertyList[].Subtype is read at its path, so it is modelled, even though which property kinds actually consume it varies.
Ask whether there is any configuration in which the engine reads this label at this structural position. For Subtype, yes. For WaypointList[].TemplateResRef, never.
The point of the lint rule is that a dead field is a modder trap: someone sets it, expects an effect, and gets nothing. Silence is the worst answer, and the typed view is the wrong place to break that silence.
Ifo.Mod_Hak, Utc.SaveWill and Utc.SaveFortitude are modelled and documented dead. They predate this rule and round-trip harmlessly, so they stay. Treat them as legacy exceptions rather than precedent; the next field’s case has to stand on the criterion above rather than on their existence.
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: Removed, Pending a Consumer
A second layer used to sit on top of the typed structs. A decoded view resolved file-native ids against the 2DA tables the engine consults, so a caller could ask a creature for its race label rather than its race id. It covered UTI and UTC, and it has been removed.
Nothing irreplaceable went with it. A decoded view resolved through ContentSource at run time, so the interpretation lived in the game’s own tables rather than in our source; rebuilding it re-derives from data we still read. What made it worth removing is that it was built against an architecture we have since replaced, it reached two of the fourteen typed views, and no consumer ever stated what it needed. It returns once one can, which is after MountedSave and the write path.
Two things the rebuild inherits rather than re-deciding:
- Projection and resolution are separate stages. File-native variant dispatch is scope-free and runs once; resolving against a context runs per scope. Mod-conflict analysis, vanilla-versus-modded diffs, and “read this the way a mounted save sees it” all reduce to one projection resolved several ways.
- A mod-extensible table needs an
Unknownvariant. Mods add rows toitempropdef.2da, so a decode that cannot name a kind has to surface the raw entry instead of dropping it. Per entry, rather than a struct-level accumulator.
ContentSource, NoSource and InstallContent stay put for it.
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
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 behavioural rules (e.g., “field X is clamped to
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 versus engine reimplementation
Right now this workspace is only about format parsing, linting and modding tools: reading, writing and validating the game’s data files.
An engine replacement, with gameplay logic, AI and rendering, is a job for another day. That is part of why the format blueprints matter: somebody building an engine later can work from the engine audits rather than digging through decompiled binaries themselves.
Code style and 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 prefer running things manually from the workspace root before committing:
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
A few principles the codebase leans on:
- 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 flat structs and trait combinators over deep 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. - 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 no overhead when disabled. - Safe by Default: We use
#![forbid(unsafe_code)]across all core parser crates to enforce strict memory safety boundaries.
Testing
Tests here are gray box: white-box knowledge of the engine, taken from the audits under formats/, drives strictly-validated black-box cases. See Testing for what to include with a new format, and for the ways a passing test can fail to check anything.
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 where the engine appears not 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 building out the rakata-lint engine rules and expanding 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.)
Testing
How this workspace tests its parsers, and the habits that make a passing test worth something.
A test here has one job: prove that what we wrote matches what the engine does. Most of the ways a test fails at that job still look like success, so the practices below are mostly about arranging for a check to be capable of failing before you rely on it passing.
Strategy
We use a gray-box approach. White-box knowledge of the engine, gathered from the audits under formats/, is used to build strictly-validated black-box tests. Tests target how the engine actually behaves, not a mock of it.
When you add a new format, include:
- Fixture-backed tests. Full round-trip coverage over synthetic files in
fixtures/. Never commit real game assets; runcargo test --test gen_fixtures -- --ignoredto generate them. Byte-exact round-trip assertions are the bar for any format the engine reads byte for byte. - Mutation tests. A pass confirming the parser rejects malformed and truncated input without panicking, usually through
corruption_matrix.rs. - Module documentation. A rustdoc block showing the format layout.
Reference a fixture with concat!(env!("CARGO_MANIFEST_DIR"), "/../../fixtures/name.ext") rather than a path relative to the source file, so moving the file does not break it.
Writing a check
Make it prove it arrived
Assert on what the check examined, not only on what it found. Count the subjects it compared and assert the count is not zero.
This is the single highest-value habit on the page, because the default failure of a check is silence rather than noise. cargo test <filter> prints test result: ok when the filter matches no tests at all, which is why scripts/test-filter.sh exists and fails when a filter matches nothing.
The same shape recurs at every level. A value comparison walked one side’s fields and skipped any label the other side lacked, so renaming a field on both sides passed. A test for diagnostic grouping hit a type-mismatch rule first and returned before reaching the grouping. Schema checks recurse only where an entry declares child fields, so anything below a childless entry was never visited; three separate checks did this independently, and descending by default is what stops the fourth.
Count what the check compared, not what it visited. Those are different numbers, and the gap between them is where this hides.
Take the input from somewhere the subject does not control
If the thing under test also supplies the test’s input, whatever is missing from the subject is missing from the input that would reveal it. Adding more cases cannot break that loop.
A check that a view writes no label its schema fails to declare built its input from that same schema. A saved object writes either a Portrait resref or a PortraitId row index, and Portrait was declared nowhere. No Portrait was generated, so the read always took the id branch, so the missing declaration was never written, so the check never saw it.
The weaker version of this is a generated fixture. A generated fixture exercises only what its generator produced. Child fields it does not build, lists it leaves empty, and branches it does not select are all outside reach, and none of them look like gaps from inside the test:
- A check written to catch skipped levels built its input with
Default::default(), whose lists are empty, so it stopped above every level it existed to cover. - A coverage check on
Gitbuilt its input from the schema, which filledUseTemplateswith an arbitrary non-zero value. That flag selects between two forms of the file, so every run took the same branch and theSaved*types were never called.
That last one deserves care, because it leaves no trace. Nothing is missing, nothing is empty, and the walk completes normally. It goes one way at a fork and the other way is invisible.
Compare in a vocabulary the code under test did not choose
Two things have to be made comparable before a check can compare them. When the code under test is what makes them comparable, the check inherits its blind spot exactly.
A typed view models a field only where the engine reads it at that path, which is right for reading and wrong for writing anything back. A round-trip guard read a file into a view, wrote it out, and compared the two views. Every field it compared agreed, because every field the view does not model was missing from both sides. A writer that lost a save’s entire session state would have passed.
The tell is that the check never leaves the model. “Read a Uti, write a Uti, compare the Utis” is a closed loop, and no fact from outside can get in to fail it. That is visible while you are writing it, which is what separates this from a check that turns out afterwards to have been looking at the wrong thing.
Here the outside vocabulary is the file’s own bytes: read a real file, write the parsed tree back, compare against what was read. One such check runs over a single saved area carrying labels no view models, another over every GFF the install ships.
Break it on purpose before you trust it
Reintroduce the defect, run the check, and confirm the assertion you expected is the one that fails. Most of the problems on this page were caught this way and by nothing else. A test you have never seen fail has not yet earned anything.
Break each half of a compound check separately. A grouping keyed on both a field’s path and its reason had to fail differently for each half, which is what made the key defensible rather than merely plausible.
Two specific shapes need more than one break:
- A fallback with several preconditions needs a case that removes all of them. A portrait falls back only when
PortraitandPortraitIdare both missing, so dropping either one left the other in the file and the read took a branch rather than its fallback. List the reachable states before trusting a probe that varies one input. - If a check runs over several subjects, breaking one subject must fail it. A fix that declared a label on four types and asserted both branches were produced was satisfied by any one of them, so reverting a single declaration left three siblings covering for it.
Choosing your evidence
Prefer real files, and know what yours cannot show
Real game files are the only source nobody on this project wrote, which makes them the only source that can disagree with us.
The minigame types read their enemy and obstacle lists off the wrong parent. The reader had it wrong, the module diagram in are.rs had it wrong, and the round-trip test had it wrong, because one person wrote all three from one reading of the format. Three sources agreeing was one mistake stored three times, and every enemy and obstacle in the game parsed as absent for as long as the type existed: 95 enemies and 67 obstacles across the four minigame areas.
A corpus still has to be large enough to tell two explanations apart. A waypoint reader keyed its map note off the note text and derived the HasMapNote flag back from it, defended by a comment observing that the two never disagree across the fixture saves. They do not disagree, and that is not what the fixtures showed: across a larger local corpus the flag is present on every waypoint while the text is present on a minority. What looked like two fields tracking each other was one field always being written.
A corpus too small to hold a counter-example cannot tell a relationship from a constant, and the reading that assumes a relationship is the one that then justifies ignoring a field.
Know which populations no corpus can reach
Ranking work by how often a fallback fires in the corpus puts saved forms last, because their fallbacks never fire: the engine’s saver writes saved objects exhaustively, so every field is present on every saved entry.
So the population with no corpus evidence is exactly the one where documentation is the only oracle that will ever exist, and a firing-rate ranking sends it to the back of the queue. A fallback no corpus can reach is not low priority, it is unfalsifiable by measurement, and it needs the reading rather than less of it.
When a check built on documentation disagrees with the code, the documentation decides
Unless you can say from the documentation why it is wrong. Settling it the other way turns the check into a comparison between the code and itself.
A schema entry’s absent-value records what the engine holds for a field a file omits, populated from the audit pages under docs/src/formats/. A check compared each reader’s fallback against it, and that check is worth more than a round-trip precisely because its two sides come from different places and can disagree.
Utd’s Invulnerable fell back to the file’s Plot value, and the schema entry was changed to match on the reasoning that the reader looked right. It was not: the engine reads Invulnerable before Plot, so its fallback sees the constructor’s zero and never the file’s value, which is what the page says. Every disagreement settled that way turns one more entry into a copy of the reader.
Counting and measuring
Ask what the population can exhibit
A count can be wrong while its arithmetic is right, because the error is in the population. Three counts in one audit each needed a whole category subtracted after the fact:
| Count | Corrected to | What could not exhibit the property |
|---|---|---|
| 275 agreeing labels | 114 | 117 labels had exactly one copy anybody had examined and 44 had none; “agrees” is not a property a single voice can have |
| 93 unconditionally-written fields | 83 | Ten had been given a write-site condition earlier in the same session |
| 58 divergent labels | 42 | A path that only declares a field and never writes it constrains nothing about sharing |
The shared cause: the inflating members were structurally unable to exhibit the property being counted. You cannot agree without an opinion, cannot be unconditional if you are conditional, cannot constrain a merge if you never write. In each case the measure looked at the label while the property lived at the label and its context.
The third was predicted from the first two before it was found, which is what makes this a pattern to apply rather than three anecdotes. It is three-for-three rather than a law, so check the fourth.
Recompute derived properties after you subset
The same error runs the other way. Having crossed those divergences against the write set, the survivors were reported with the axis each had been classified under before the crossing. Two changed: OnHeartbeat and OnUserDefined diverged on liveness only because of a mount that declares them and never writes them, so removing non-writers left them diverging on default alone.
Membership changed, so every property derived from membership changed with it. One version of this error is a property held by members that cannot exhibit it; this is a property computed on a population that no longer exists.
Spot-check a probe, and say which way it errs
Two probes written for those counts had a predicate that did not match the property. One used “is this path present when the view is default-constructed” as a proxy for “is this field written unconditionally”. It returned 31 against a true 83, because a list child is absent at default when the list is empty, not when the write is conditional. The other missed the multi-line form of a helper whose label is its second string argument, so it under-counted writers.
Both were caught by checking a handful of cases the author already knew the answer to. Neither would have been caught by reading the code again.
Note the direction as well as the size. Under-counting writers removes real constraints, so that probe failed toward “safe to share”, the direction that produces a bad merge rather than a noisy report. A probe’s error direction belongs alongside its result, because a conservative probe and an optimistic one with the same error rate are not equally usable.
Check the catalogues before calling a finding new
Several findings in one session were reported as fresh and turned out to be on record already. A liveness condition sat in an issue comment while the issue body listed the others. An exclusion’s reasoning sat in a comment on the issue it constrained. A modelling gap was already carried by the schema, at the declaration for the path it was about.
The records were good. None were reachable from where the question got asked, which is the part worth fixing. Before flagging an oddity, check the schema tables, the declaration for the path itself, and the issue comments rather than only the issue bodies.
Reading a result
A pass can mean the check compared nothing
Two shapes produce this, and neither looks like a skip.
The data is not there. A check comparing a type’s Default against the engine’s constructor values reached a nested block through its parent, whose field for that block is an Option defaulting to None. Nothing is written under it, so every path resolves to nothing, so there is nothing to compare, and having nothing to compare reads exactly like agreement. An optional container that defaults to absent hides its children from anything reaching them through the parent. Check the child type directly rather than making the parent write something it otherwise would not.
The entry opted out. Schema entries carrying Unexamined were skipped deliberately, since nobody has read the page and failing them would report unread pages as reader defects. The effect is not “unknown” but exempt from every guard: an unpopulated declaration and a verified-correct one become indistinguishable, while the entry is still walked, still counted as reached, and still passes the coverage assertion meant to catch checks that stop short. Thirteen saved-form script hooks and two trap fields read empty where the engine holds "default" and an armed flag, under a guard with a test named after that exact type and green results throughout.
A skip condition on the data is a coverage hole that reports as coverage. Treat a per-entry opt-out as a bucket to be reported, never as a state to be skipped past.
A failure can be the guard’s fault
A list of labels the typed views deliberately stop writing needs a guard, because a wrong entry silently drops a field from files that carry it. The obvious guard asks whether each listed label is still in the corpus validator’s “written but in no file” set, and it passes today. Then the omission lands, the views stop writing those labels, and a label nobody writes is no longer in that set. Every successfully omitted label reports as a violation.
A check derived from a set the change mutates cannot stay true after the change it guards. The tell is a guard asking whether X is still in S when the change is precisely what removes X from S. It reads as solid right up to the moment it matters.
Derive the guard from the invariant that survives the change, not the state that holds now. Here that invariant is “no real file carries this label”, a fact about the corpus alone, so the guard asks the corpus and means the same thing on both sides of the change.
This one was caught immediately because it was total: every label in the first batch failed at once, which is obviously a broken check rather than that many broken fields. The dangerous version is partial. A mix of already-omitted and not-yet-omitted labels would have produced a handful of reds that looked like findings, and findings get explained.
A comment that explains a finding away is a claim
validate gff compares the typed views against the vanilla corpus in both directions: labels real files carry that no view models, and labels a view writes that appear in no file. Repos_Posy sat in the first list and Repos_PosY in the second, which is one field read under one spelling and written under another, reported twice from two angles. The tool’s own module documentation named the pair and explained it as the label-normalization policy working as intended. Every container item this library wrote lost its grid position for as long as that note stood.
The note was not lazy. It was written from the same reading of the format that produced the writer, which is a check repeating the code’s mistake wearing different clothes. What makes it worth separating is where it lands: not in a test that agrees with the code, but in prose that removes a disagreeing result from consideration. Running the tests cannot falsify a sentence.
Two habits follow. Write down what you checked rather than what you concluded, because “known false positive” is unfalsifiable while “these 4215 files carry the label and none carry the other spelling” is something a later reader can re-run and disagree with. And treat a matched pair as a question: a label the corpus has and the view lacks, alongside a label the view has and the corpus lacks, is the shape a rename leaves behind.
None of this argues for writing more tests. It argues for knowing what the ones you have have actually looked at.
Linting
rakata-lint reads a GFF resource and tells you what the engine will do with it that you probably did not intend. Not “is this valid GFF” (that is the parser’s job) but “will this crash, get silently truncated, or sit there doing nothing”.
Every rule traces back to a Ghidra audit of swkotor.exe, written up per format under formats/gff/. If a rule cannot point at a page, it does not exist yet.
The two entry points
lint(&gff) takes a parsed GFF and nothing else. It picks a schema off the file type, walks the tree against it, and runs the intra-resource rules. No filesystem, no game install, no 2DAs. Everything it knows is in the bytes you handed it.
lint_with_context(&gff, &mut LintContext) does all of that and then some. The context is what lets a rule ask a question about the world outside the file.
Both collect everything rather than stopping at the first problem. You get a Vec<LintDiagnostic>, because a mod with eleven issues should take one run to find out, not eleven.
LintContext
A LintContext holds a borrow of a GameVfs and a 2DA cache. That is the whole type, and both halves earn their place.
The VFS is how a rule answers “does this resref resolve?” against the install the user actually has, overrides and all, the same way the engine would. The cache is how twelve rules across four hundred files avoid loading baseitems.2da twelve hundred times.
The important part is whose install it reads. The linter never checks against a vanilla baseline. A range check bounds against however many rows the loaded 2DA has, not against the row count that shipped in 2003, because the engine does not know the difference either and a mod that adds appearances is not thereby broken. When a table is missing or fails to parse, the rule emits a LINT-CTX-* diagnostic and skips, so you find out a rule did not run instead of reading its silence as a pass.
The three phases
The phases are not a schedule. They are a statement about what a rule needs in order to answer.
Phase 1, intra-resource. Everything answerable from the file alone. Forced defaults, sentinel handling, truncation behaviour, dead data, and the schema walk itself. GroundPile is always forced to 1 no matter what you wrote, so writing it is decorative and worth saying so. These rules work on the raw Gff tree rather than a typed view, deliberately: a typed view drops toolset-only and dead fields, and those are exactly what several of these rules exist to find.
Phase 2, range and reference. Everything that needs a table or the resource system. Is this BaseItem a real row? Does this script exist? Is Gender inside the range the engine tolerates? These take a typed view, because by the time you are checking a value against baseitems.2da the typed view has already done the dispatch and resolution work you would otherwise write twice.
Phase 3, cross-resource. Everything that needs other files to be in the room. Does this transition trigger name an area tag that exists? Does the creature’s equipment resolve to real items? Does the conversation tree hang together? Planned rather than built.
Phases 1 and 2 are complete for all fourteen generic types. Phase 3 is the interesting one and is where the mod validation tool starts.
Reading a diagnostic
Rule ids carry their origin in the prefix.
SCHEMA-* comes from the generic walk and means something structural: wrong type, missing required field, a label the schema does not declare, a value outside a declared range, or a field the engine never reads.
A format prefix like UTI-006 or UTW-001 is a hand-written behavioural rule, numbered within that format’s set. These are functions rather than data because the logic varies too much to declare.
LINT-CTX-* means the linter could not answer, not that the answer was fine.
Severity splits three ways and the split is about the engine, not about how annoyed you should be. Error is a crash or corrupted state. Warning is the engine silently truncating, clamping, or ignoring you. Info is dead data and forced defaults, which is to say the field does nothing and you may as well know.
For the crate’s internal layout and the full rule inventory, see crates/rakata-lint/ARCHITECTURE.md in the repository.
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).
Transcribed material in the MDL surface
Behavioural referencing
We observe what they emit so our output round-trips through them, which is the ordinary practice described in Contributing and needs no special mention. One item is different in kind, being content taken across rather than behaviour observed, and it is named here because that distinction is the one worth being honest about.
The eight MDL classification name strings in mdl/ascii_names.rs come from mdledit’s ReturnClassificationName (by bead-v, source). The numeric codes are independently ours, attested against retail models; only the human-readable names are borrowed.
mdledit states no licence: no licence file, and no licence text in any source header. We are not treating that silence as a grant, and it cannot be settled upstream either, since the author has been out of contact for years. We would rather remove a dependency than reason our way to a comfortable conclusion about someone else’s work.
This one cannot be removed by re-deriving it. swkotor.exe carries no ASCII name for any classification bit: no such string anywhere, no adjacent table of eight, and the words that do occur belong to unrelated subsystems. There is nothing on the engine side to compare these strings against in either direction, so they are unattestable rather than pending.
The MDL controller tables are no longer on this list. All 58 codes and their name strings are traced directly from swkotor.exe, from four named functions covering the base node, emitters, lights and meshes, and the set is closed rather than sampled: every call site of the three controller-registration routines in the binary falls inside those four. mdl/ascii_names.rs records the trace address against each table. The per-mesh sequence value and the mesh block write order are likewise ours, derived from the retail model corpus.
Behavioural referencing, which is not borrowing
Called out so its absence from the list above is not read as an oversight. Our ASCII writer matches mdledit’s field ordering and formatting conventions so files round-trip through it. Our reader tolerates node orderings that mdlops and PyKotor produce. Cross-checks against kotorblender’s reader confirmed several offsets that were independently traced, and reone’s saber-segment constants agree with a vertex count read from vanilla models. models/mesh_derived_fields.md compares how six tools each handle the same ambiguous fields, which is documentation about those tools rather than anything taken from them.
Where xoreos, kotorblender or reone has its own name for a field we also model, the source records theirs beside ours for cross-reference. Our identifiers are our own.
reone publishes GPL-3.0. KotorBlender exists as two maintained forks, ndixUR’s at GPL-2.0-or-later inherited from NeverBlender and seedhartha’s at GPL-3.0-or-later. All are compatible with this project’s own GPL-3.0-or-later. We are not lawyers and this is not legal advice; if any author would prefer different handling, we would rather hear it and act on it than defend a position.
- 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
Every file format KotOR uses, what the engine does with it, and how much of that Rakata implements.
Start here if you are looking for a format. The tables below link every page and say how far each is implemented. If you are reading a page and want to know how far to trust it, or what a convention means, that is further down.
A format page runs in a fixed order: At a Glance for the extension and magic, File Layout for the on-disk structure, Field Schema where the format is GFF-backed, Engine Audits & Decompilation for what the loader does, and Implemented Linter Rules. Knowing the order is the fastest way to find one fact on an unfamiliar page.
On the GFF-backed pages the full generated field table sits at the very end rather than inline, because it runs to hundreds of rows on the larger formats and everything worth reading would otherwise sit behind it.
Status Legend:
Full: Binary reader/writer implemented with roundtrip tests.Generics: Strongly-typed wrappers and linting schemas implemented.Documented: Engine-audited spec page exists; not yet wrapped inrakata-genericsor given a standalone reader/writer.Read-only: Reader implemented; the writer cannot yet emit a form the engine reads.Reference: Structurally covered by another format’s page; not separately modelled.Deferred: Currently unimplemented, with no strongly-typed wrapper yet.
Archive Formats
| Format | Status | Notes |
|---|---|---|
| BIF | Full | Supports variable/fixed tables. BZF compression feature-gated. Note our writer aligns payloads to 4 bytes where all 26 vanilla archives pack contiguously. |
| 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. Note our writer packs tightly where vanilla archives pad; see the page. |
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. |
| GUI | Documented | Interface panel layouts. A file restyles a panel the binary already knows about; it cannot describe a new one. |
| JRL | Documented | The quest journal. Only global.jrl is ever opened, so a module’s own journal is dead content. |
| PTH | Documented | Area path networks. Two-dimensional, with the walkmesh supplying height. |
| 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. Three pixel types: DXT1, DXT5 and an uncompressed one-byte-per-pixel form. Mip payload sizing matches the engine’s unclamped right-shift, verified by tiling against the shipped textures. |
| DDS | Read-only | Reads both the standard D3D header and the K1 CResDDS 20-byte prefix. Writes only the standard form, which the engine does not read, so Rakata cannot currently emit a .dds the game loads. Deliberate: emitting the prefixed variant means reproducing a container no other tool reads. |
| TGA | Full | Reader normalizes to RGBA8888. 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
No page and no dedicated reader. The two GFF-backed ones parse as a generic GFF tree today; what is missing is the type schema saying what the labels mean.
| Format | Status | Notes |
|---|---|---|
| NCS / NSS | Deferred | NWScript Source and Compiled bytecode. NCS decompilation is slated for future work via an independent pipeline. |
| ITP | Deferred | Item Palette definitions (GFF). |
| BIK | Deferred | Bink Video container (proprietary video format). Unlikely to be implemented natively. |
Resource Type Codes
Every archive in the game identifies its contents by a numeric type code rather than by a filename extension. KEY and ERF store it as a u16; RIM and BIF give the field four bytes and use the low two. The table is the same everywhere, and nothing but this table connects a code to a format.
Each code is marked against the four archive families that have been read end to end. ● means the type was found there, — that it was not.
| Column | Covers |
|---|---|
| KEY | The BIFs indexed by chitin.key |
| RIM | The module archives under modules/ and rims/ |
| Save | A corpus of save folders, including the per-module ERFs nested inside them |
| Tex | The four ERF archives under TexturePacks/ |
TexturePacks/ is not indexed by chitin.key. An enumeration that starts from the KEY file walks past the game’s entire texture library with no hint that it exists, so the two codes that live there, 3007 and 2022, read as attested nowhere until those archives are opened directly.
| Code | Ext | KEY | RIM | Save | Tex |
|---|---|---|---|---|---|
0 | (generic) | — | — | ● | — |
3 | tga | ● | — | ● | — |
4 | wav | ● | ● | — | — |
2002 | mdl | ● | ● | — | — |
2009 | nss | ● | — | — | — |
2010 | ncs | ● | ● | — | — |
2011 | mod | — | — | — | — |
2012 | are | — | ● | ● | — |
2014 | ifo | — | ● | ● | — |
2015 | bic | ● | — | — | — |
2016 | wok | ● | — | — | — |
2017 | 2da | ● | ● | — | — |
2022 | txi | ● | — | — | ● |
2023 | git | — | ● | ● | — |
2024 | bti | ● | — | — | — |
2025 | uti | ● | ● | — | — |
2026 | btc | ● | — | — | — |
2027 | utc | ● | ● | ● | — |
2029 | dlg | ● | ● | — | — |
2030 | itp | ● | — | — | — |
2032 | utt | ● | ● | — | — |
2033 | dds | — | — | — | — |
2035 | uts | — | ● | — | — |
2036 | ltr | ● | — | — | — |
2037 | gff | — | — | — | — |
2038 | fac | — | ● | ● | — |
2040 | ute | ● | ● | — | — |
2042 | utd | ● | ● | — | — |
2044 | utp | ● | ● | — | — |
2047 | gui | ● | — | — | — |
2051 | utm | — | ● | — | — |
2052 | dwk | ● | — | — | — |
2053 | pwk | ● | — | — | — |
2056 | jrl | ● | ● | — | — |
2057 | sav | — | — | ● | — |
2058 | utw | ● | ● | — | — |
2060 | ssf | ● | ● | — | — |
3000 | lyt | ● | — | — | — |
3001 | vis | ● | — | — | — |
3002 | rim | — | — | — | — |
3003 | pth | — | ● | — | — |
3004 | lip | — | — | — | — |
3005 | bwm | — | — | — | — |
3007 | tpc | — | — | — | ● |
3008 | mdx | ● | ● | — | — |
9997 | erf | — | — | — | — |
9998 | bif | — | — | — | — |
9999 | key | — | — | — | — |
26000 | bzf | — | — | — | — |
A dash means these four families were opened and the type was not in them, not that it never occurs. For mod, erf, bif, key, rim and bzf that is expected: those name containers rather than things stored inside one.
Six codes were confirmed by opening a sample rather than by convention. 2026, 2030 and 2047 carry the GFF fourccs BTC , ITP and GUI in their own first four bytes, all at version V3.2; 2009 is NWScript source text; 2052 and 2053 carry walkmesh magic.
are, ifo and git occur exactly once per module in the RIM archives. ncs outnumbers everything else there by a wide margin.
Note
A nine-byte ASCII read of any GFF yields
<fourcc>V3.28, and the8is not part of the version The header is 56 bytes, sostruct_offsetholds0x38, which is ASCII8. Read eight bytes and you get the fourcc andV3.2; read nine and the low byte of the next field joins the string. There is noV3.28.
Note
Rakata names 34 of these and carries the rest as raw numbers.
ResourceTypeCodeis a transparent wrapper over theu16, so a code the crate has no name for is read, stored and written back unchanged — an unknown type is never a parse failure. What it loses is the extension: extracting a2052resource yields the right bytes with no.dwkon the end. Worth knowing if you are enumerating an archive rather than fetching a known resource.
Conventions that apply everywhere
These hold for every page in this section, so no page repeats them. The first three are facts about the formats themselves. The last two are about how far to trust a claim on any page.
Important
Every multi-byte integer and float in every format here is little-endian, and no page repeats it. KotOR shipped on x86 and the engine reads its structures without byte-swapping anywhere, so the on-disk representation is the platform’s. Take this as read for every offset table in this manual: a
u32at0x08is four bytes low-order first. The only exceptions are called out where they occur, and there are currently none in the KotOR-native formats. The notes elsewhere about endianness concern an embedded LZMA sub-header inBZFand the MDL float ABI, neither of which is a departure from this rule.
“The engine ignores this” is not “you may leave it out”
These are different questions, and knowing the answer to one tells you nothing about the other. A field the engine never reads still has a right answer for a writer, and it is not always “anything”.
Four cases occur, and which one applies is a per-field fact:
| Case | What a writer should do | Example |
|---|---|---|
| Fixed-position and unread | Write the canonical value. Omission is not available at all, since the byte exists whatever you put in it. | TGA’s id_len, image_type and image_descriptor |
| Reserved region, unread | Zero it. | RIM’s 96-byte dead zone |
| Unread by the traced loader, still required | Write it correctly. Something outside the traced path consumes it. | ERF’s keys_offset |
| Recomputed at load | Write it correctly anyway. The engine will not care; other tools will. | MDL’s derived mesh fields |
A fifth case is the genuinely free one: a field present in vanilla, read by nothing, and carrying no positional obligation, so a writer may drop it and produce a file the engine treats identically. Fields the engine never reads is where those live, and it says what dropping them costs.
The row that catches people is the first. “The engine does not read this byte” and “any value passes” are the same statement, and “you may write anything” and “vanilla writes one specific value” are both true at once. Which one matters depends on whether you are aiming for a file the engine loads or a file that matches what shipped.
Counted, terminated, or neither
Every repeated run in these formats is delimited one of three ways. Which one a format uses is on its own page, because guessing wrong is not a graceful failure.
- Counted. A field earlier in the file says how many follow. Most of the binary formats, and LYT among the text ones.
- Terminated. A marker ends the run. TXI’s coordinate lists use
endlist. - Both, per instance. TXI again: a list takes either form, and a reader implementing only the counted one will consume the rest of the file as coordinates the first time it meets a terminated list.
- Length-delimited. A byte count rather than an element count, which is not the same thing. TLK’s text blob, and every variable-length GFF value.
- Neither. Extent comes from the file or section boundary. VIS.
The failure mode is asymmetric. Assuming counted where the file is terminated over-reads, usually to end of file. Assuming terminated where the file is counted stops early and silently drops data.
Naming a population
When a page says a field is always something, or never anything, it names the archives that were opened: “every GFF in chitin.key”, “the module RIM archives”, “the ERFs inside a save”.
Category words like vanilla, static, shipped and the corpus look like scope and are not. A category includes archives nobody opened, so a claim worded that way is wider than its evidence.
An absence therefore carries its scope: “not present in the archives we read” and “does not occur” are different claims, and only the first is established by reading files. Populations are given in words rather than as a count, because how many files matched depends on how they were sieved.
A number that is itself the finding stays. The rule is about denominators, not digits. That .pth files outnumber the modules holding them is how a reader learns the format is per-area rather than per-module, and a per-label prevalence column is most of what tells a reader which GUI fields are optional. Neither survives being reworded into “many”.
Provenance
Evidence here is not all of one strength, so every Engine Audits section names its source and its provenance level:
| Level | Means |
|---|---|
| traced | Read instruction by instruction, in the named function. The strongest thing this manual offers. |
| measured | Checked against real files, with the archives named. |
| inferred | Concluded by comparing decompilations or reasoning across functions, rather than read off one. |
| paraphrase | General knowledge about the format or the compression, not something this binary was observed doing. |
| derived, not attested | Taken from a decompilation of the named function, with the individual rows never separately re-derived. |
“Derived, not attested” describes most audit tables here, and those rows are the reverse-engineering queue. The level is not a formality: both tables that have since been checked against something other than the decompilation behind them lost rows. The walkmesh table put vertex_count at +0x08 and gave +0x48 to +0x6C to AABB trees; files refute both. Re-auditing DDS found a citation naming the wrong function, an encoding claim spanning two different domains, and a formula that appears nowhere in the binary.
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)
A BIF is bulk uncompressed storage: tightly packed resource bytes with no metadata and no filenames. It is built to be read at a known offset, which is what the companion KEY index supplies.
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 |
File Layout
A fixed 20-byte header, one or two resource tables, and the payload. The variable table is found by variable_table_offset; the fixed table, when a file has one, follows it directly. Payload bytes are addressed per entry.
| Block | Size | Located by |
|---|---|---|
| Header | 20 bytes | Always at 0x00 |
| Variable resource table | 16 bytes per entry | variable_table_offset |
| Fixed resource table | 20 bytes per entry | Follows the variable table |
| Resource payload | remainder of the file | Each entry’s own data_offset |
There are no names anywhere in here. A BIF entry knows its own id, type, offset and size, and nothing else. The resref lives in the KEY, which reaches in by position. That is what makes a BIF unreadable on its own and why the two formats are always discussed together.
Header (20 bytes)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | magic | fourcc | BIFF. No trailing space on this one. |
0x04 | version | fourcc | V1 , two trailing spaces. |
0x08 | variable_count | u32 | |
0x0C | fixed_count | u32 | Zero in every shipped archive. See below. |
0x10 | variable_table_offset | u32 |
Variable Resource Entry (16 bytes each)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | resource_id | u32 | The KEY’s packed word for this resource, repeated on the BIF side. Derivable from position. See below. |
0x04 | data_offset | u32 | Absolute, from the start of the file. |
0x08 | data_size | u32 | For a .bzf, the uncompressed length. |
0x0C | type_id | u32 | Four bytes holding one of the two-byte resource type codes, as in RIM. Rakata rejects anything that will not fit in a u16; that is our strictness, not traced engine behaviour. |
Fixed Resource Entry (20 bytes each)
| Offset | Field | Type |
|---|---|---|
0x00 | resource_id | u32 |
0x04 | data_offset | u32 |
0x08 | part_count | u32 |
0x0C | data_size | u32 |
0x10 | type_id | u32 |
Note
resource_idis positional, and it is the KEY’s word repeated A variable entry’sresource_idis the same packed value the KEY carries for that resource: the entry’s own index within this archive in the low twenty bits, this archive’s index in the KEY file table in the high twelve. Both halves hold in every variable entry of every archive a retail install’schitin.keynames.The two files agree exactly. Every BIF entry is named by one key, no key points at an entry that is not there, and no two keys carry the same value, so the correspondence is one-to-one in both directions rather than merely consistent where it was checked.
That makes the field carry nothing a reader does not already have from position. A writer, though, has to compute it rather than copy it, because the high half is not a property of the BIF at all: it depends on where the archive lands in the KEY’s file table, which the BIF cannot see.
The fixed table’s
resource_idis untested, since nothing shipped populates that table at all.
Note
The fixed table is a format feature nothing uses
fixed_countis0in all 26 archives of a full vanilla install, without exception. The engine reads the scalar and then ignores it entirely, as the audit below records: files declaring fixed entries are accepted and those entries are never mapped. Rakata parses the table when one is present, so a file carrying it round-trips, but nothing in the game will ever look at what it holds.
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
Read from CExoResFile::LoadHeader (0x0040d910) and CExoResFile::ReadResource (0x0040da20) in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
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. The index is bounds-checked against variable_count before use, so an out-of-range resource_id is rejected rather than read past the table. What is not applied is any alignment or structural normalization to the data_offset itself, which goes to fseek exactly as stored. |
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 |
File Layout
Four blocks, and only three of them are found the usual way. The header sits at 0x00, the BIF file table and the key table are each located by their own header offset, and the BIF filenames are not a located block at all: every file entry carries its own offset and length pointing into the filename region, so the strings are addressed individually rather than as a table.
| Block | Size | Located by |
|---|---|---|
| Header | 64 bytes | Always at 0x00 |
| BIF file table | 12 bytes per BIF | file_table_offset |
| BIF filenames | variable | Each file entry’s own filename_offset and filename_size |
| Key table | 22 bytes per key | key_table_offset |
Header (64 bytes)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | magic | fourcc | KEY , trailing space included. |
0x04 | version | fourcc | V1 , with two trailing spaces. See the version note below. |
0x08 | bif_count | u32 | |
0x0C | key_count | u32 | |
0x10 | file_table_offset | u32 | |
0x14 | key_table_offset | u32 | |
0x18 | build_year | u32 | Years since 1900. |
0x1C | build_day | u32 | Day of the year. |
0x20 | reserved | 32 bytes | Preserved verbatim on round-trip. |
BIF File Entry (12 bytes each)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | file_size | u32 | Size of the BIF this entry names. |
0x04 | filename_offset | u32 | Absolute, from the start of the KEY. |
0x08 | filename_size | u16 | Includes the terminating NUL: the count is the string length plus one, in every entry of a retail chitin.key. |
0x0A | drives | u16 | A bitfield naming which install medium holds the BIF. Nothing in the audits below traces what swkotor.exe does with it, so treat it as unexamined rather than inert. |
Key Entry (22 bytes each)
| Offset | Field | Type |
|---|---|---|
0x00 | resref | char[16], null-padded |
0x10 | type_id | u16 (see the resource type codes) |
0x12 | resource_id | u32 |
The resource_id bitmask
This is the whole point of the format, and it is what the engine’s payload mapping below is tearing apart. A resource_id is not an index into anything on its own. It is two numbers packed into one word:
| Bits | Meaning |
|---|---|
31..20 | Index into the BIF file table |
19..0 | Index of the resource within that BIF |
So resolving a resource is: find the key entry by resref and type, split its resource_id, use the high twelve bits to pick a BIF from the file table, and the low twenty bits to pick an entry inside it.
Warning
The format allocates twenty bits to the index and the loader reads fourteen, so a BIF holds 16,384 resources at most Both halves are confirmed and they do not conflict. The twenty-bit split above is what the field carries: across every key entry in a full install, the low twenty bits equal the resource’s index within its BIF and the high twelve equal that BIF’s position in the file table, without a single exception. The loader is narrower. The BIF audit records
CExoResFile::ReadResourcemasking with0x3fffbefore scaling by the 16-byte entry stride, which is fourteen bits, and nothing on that path reads or tests bits 14 through 31 at all.Bits 14 to 19 are therefore allocated by the format and thrown away by the reader, and the consequence lands on a writer rather than a reader. A BIF carrying more than 16,384 resources yields a KEY the engine resolves to the wrong entry, quietly, because index 16,384 masks down to zero. The bounds check on that path does not help: it compares the masked index against
variable_count, so an aliased index is in range and reads as a legitimate hit. Rakata implements the full twenty bits and is thus more permissive than the engine. Treat 16,384 as the ceiling.Nothing shipped comes near it. The largest vanilla archive,
models.bif, holds well under half that many resources, so no vanilla lookup can reach the divergence and a tool that only ever reads retail data will never see it.
Note
The version field has two trailing spaces It is
V1, notV1.0. AV1.1constant exists in our reader and is accepted only in compatibility mode; the canonical K1 mode takesV1alone, matching the engine. As the audit below records, there is noV1.1branch anywhere in vanilla K1 and it is not established that such a file exists at all.
Engine Audits & Decompilation
Read from CExoKeyTable::AddKeyTableContents at 0x0040fb80 in swkotor.exe, with each subsection naming its own function below. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
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)
An ERF is a self-contained archive: it carries its own table of what is inside it, so unlike a BIF it needs no external KEY index to resolve a resource. Modules (.mod) and save games (.sav) are both ERFs.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .erf, .mod, .hak, .sav |
| Magic Signatures | ERF , MOD , HAK (version V1.0). Not SAV , see below. |
| Type | Self-Contained Archive |
| Rust Reference | View rakata_formats::Erf in Rustdocs |
File Layout
An ERF is five blocks, and only the header sits at a fixed address. Everything else is found by reading a pointer back out of the header, so while the blocks are laid out contiguously in every file you’ll ever meet, that ordering is a convention rather than a rule the format enforces.
| Block | Size | Located by |
|---|---|---|
| Header | 160 bytes | Always at 0x00 |
| Localized string block | localized_string_size bytes | localized_strings_offset |
| Key table | 24 bytes per entry | keys_offset |
| Resource table | 8 bytes per entry | resources_offset |
| Resource payload | remainder of the file | Each entry’s own data_offset |
Note that entry_count governs both tables at once. The key table and the resource table are parallel arrays: key n describes what resource n is, and resource n says where its bytes live.
Header (160 bytes)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | file_type | fourcc | ERF , MOD or HAK . The trailing space is part of the signature. |
0x04 | version | fourcc | V1.0 for anything KotOR reads. See below. |
0x08 | localized_string_count | u32 | |
0x0C | localized_string_size | u32 | Byte length of the whole string block, not a count of entries. |
0x10 | entry_count | u32 | Governs the key table and the resource table together. |
0x14 | localized_strings_offset | u32 | |
0x18 | keys_offset | u32 | |
0x1C | resources_offset | u32 | |
0x20 | build_year | u32 | Years since 1900. |
0x24 | build_day | u32 | Day of the year. |
0x28 | description_strref | i32 | |
0x2C | reserved | 116 bytes | The dead zone described further down. |
Key Entry (24 bytes each)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | resref | char[16] | Null-padded, not null-terminated. |
0x10 | resource_id | u32 | Selects the resource-table row. Not the packed word KEY uses. See below. |
0x14 | type_id | u16 | See the resource type codes. |
0x16 | unused | u16 |
Resource Entry (8 bytes each)
| Offset | Field | Type |
|---|---|---|
0x00 | data_offset | u32 |
0x04 | data_size | u32 |
What the two ERF populations agree on
Two sets of ERF archives have been read byte by byte. Naming them separately matters because one is much narrower than “vanilla ERFs” suggests.
- Shipped archives: every
.modunderlips/, pluspatch.erf. Almost all lip-sync data, so an invariant measured only here describes one content type. - Save archives: every
SAVEGAME.savin a save corpus, plus the per-module ERFs nested inside them.
Five invariants hold in both:
| Invariant | Shipped | Saves |
|---|---|---|
| Localized string block empty | ● | ● |
| 116-byte reserved zone all-zero | ● | ● |
Key entry unused u16 at +0x16 zero | ● | ● |
Key entry resource_id equals the entry’s index | ● | ● |
| Blocks tile with zero gaps and no tail | ● | ● |
resource_id shares a name with KEY’s packed word without sharing its meaning. A KEY entry has to say which archive as well as which entry, so it packs two numbers. An ERF resolves inside itself, leaving nothing to pack.
But it is the pairing key, not a restatement of the row it sits beside. The engine mounts an archive by walking the key table in file order and, for each entry, taking the resource row named by that entry’s resource_id. The loop’s own counter bounds the walk and selects nothing. So an entry’s position in the key table decides only when it is read, and its resource_id decides what it is paired with.
That distinction is invisible in every archive anyone has: the invariant above holds in both populations, so the two readings agree everywhere and only diverge on a file no writer produces. It matters to a writer regardless, because emitting the field by counting is correct by accident rather than by rule.
And the engine does not pair them the same way everywhere. Mounting an archive into the resource system uses resource_id, as above. Unpacking one walks both tables under a shared index and pairs by position instead. Two paths, two rules, and nothing reconciles them.
So an archive whose resource_id values are not its indices would mount as one set of resources and unpack as a different one, from the same bytes, with neither path complaining. Which is the practical reason to keep emitting the field by counting even though the mount path would accept anything: it is the only value both readings agree on.
A duplicate resref and type is dropped, and the first one wins
The key table is mounted into a hash table keyed on resref and type together. A second entry whose resref and type already occupy a matching slot is detected as a duplicate and discarded without touching the table, so the entry that appears first in file order is the one that resolves and the later one is unreachable rather than merged or overriding.
Uniform across every archive kind. .mod, .erf, .hak, .sav and .nwm all mount through the same path and there is no per-kind branch for this to differ across.
The engine identifies the offending resource by name internally when it happens, building a diagnostic that names it. Whether that message reaches anywhere a person would see is unconfirmed, so do not expect a duplicate to announce itself. The drop is silent as far as anyone has established.
Quirks
Each of these changes what a reader or a writer has to do.
A .sav file does not contain SAV magic
The extension and the signature come apart, and conflating them produces a file the engine refuses. A save archive is an ERF-family container carrying MOD in file_type, as the save page records independently. No validation branch for a SAV signature exists anywhere in the loader, so such a file is not read leniently, it fails.
Measured, not reasoned from the loader: every SAVEGAME.sav in a save corpus and every per-module ERF inside them carries MOD with version V1.0, at both levels.
Rakata accepts SAV magic in compatibility mode only, for inspecting whatever produced such a file, and never emits it.
build_year and build_day are written on save and unread on load
The audit below folds both into the engine’s unread set. That is true of the loader and misleading about the format: every save archive carries a real date, build_year as years since 1900 and build_day as a day-of-year, both taking many distinct values across a save corpus.
So “the engine never reads it” and “a writer may leave it zero” are different claims here, and only the first is established. These are the case in the engine ignores this is not you may leave it out where something outside the traced loader consumes the value. description_strref is the one field in the group that really is zero everywhere.
The localized string block has no documented entry layout
Nothing in either population carries one. localized_string_count and localized_string_size are zero in every shipped archive and every save archive, at both nesting levels, so the gap spans both producers and a writer emitting a description has nothing to work from.
One trap follows. localized_strings_offset and keys_offset are both 160 across the save archives, and with the block empty they name the same byte. A reader deriving the key table by adding localized_string_size to the string offset gets the right answer for the wrong reason, and breaks on the first archive that populates one.
Some .mod files carry a blank block
An extra unused block sits between the key table and the resource table in some archives, left behind by older tooling. Both tables are located by their own header offsets rather than by following on from each other, so a reader that trusts those offsets handles the padded and unpadded variants alike. One that assumes the tables are adjacent reads garbage.
V1.1 exists and KotOR will not touch it
The version field has a second value in the wild, V1.1, from later Aurora-family games. The KotOR engine validates for V1.0 exactly, so a V1.1 archive is not a KotOR archive whatever its extension says. Rakata’s strict reader matches the engine; its compatibility mode accepts V1.1 for inspecting foreign archives, not for producing anything the game loads.
Engine Audits & Decompilation
Read from CExoEncapsulatedFile::LoadHeader at 0x0040e1f0 in swkotor.exe, with each subsection naming its own function below. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
Capsule Header Initialization (CExoEncapsulatedFile::LoadHeader)
Mapped from 0x0040e1f0.
| Action | Engine Behaviour |
|---|---|
| Signature Check | The header must match ERF , MOD or HAK exactly, paired with the V1.0 version string. |
| Unchecked Saves | There is no validation branch for a SAV signature. A file loaded as a save game (param flag 1) falls through the same tree and is still required to carry MOD , so an archive with SAV magic fails validation here rather than being read leniently. |
| Header Truncation | The loader pulls the entire 160-byte header into scope (CExoFile::Read(..., 0xa0)) and then reads a set of offsets rather than a range: 0x00, 0x04, 0x08, 0x0C, 0x10, 0x14 and 0x1C. 0x18 (keys_offset) is skipped even though it sits inside that span, traced by reading every dereference of the buffer in this function, where 0x18 never appears as a load, seek or comparison. Nothing from 0x20 onward is touched either. The buffer is retained for the object’s lifetime rather than discarded, it is simply never read past 0x1C. |
Tip
The dead zone is larger than the reserved block, and it is not discarded The obvious dead region is
0x2Cto0xA0, 116 bytes of old BioWare build metadata. The engine’s actual unread set is bigger:0x18is skipped, and so is everything from0x20on, which foldsbuild_year,build_dayanddescription_strrefinto it. Unread is not the same as unwritten: see the note above on the dates the save writer puts there. Nor is any of it discarded, since the whole 160-byte buffer lives as long as the object does and is simply never read past0x1C.How
keys_offsetbeing unread squares with the archive working. Resource lookups in this class index the resource-list array built from0x1Cby raw numeric position, never by name, so nothing in it needs the key table.keys_offsetis correct in every vanilla file and should be written correctly regardless, because the engine’s own writer uses it to place entries even though no reader consults it.
The header and the three tables are read sequentially, not by their offsets
A save archive is read by CERFFile, not the class above, and it never seeks by any of the three block offsets in the header. CERFFile::ReadHeaderVariance starts at 0xa0, the fixed end of the header, and takes the localized string block, then the key table, then the resource table in that order, sizing each from localized_string_count and entry_count alone. CERFFile::Read does parse keys_offset and resources_offset into memory beforehand; nothing then reads them back.
So those four regions have to be contiguous and in that order. A file that puts them somewhere else and says so in the header is one this reader mis-parses, with every offset field in it correct.
The payload is the exception, and the only part of the layout that is genuinely free: each entry’s bytes are found through its own data_offset, which is read. So a writer may place the payload where it likes and must not move the four regions above it.
Warning
Key entry
nand resource entrynmust describe the same resource, and nothing checks that they do This is the unpack path specifically, and it is not how the same archive pairs when it is mounted. Unpacking walks both tables under one index: the output filename comes from the key entry, the bytes come from the resource entry at the same position, the two are never compared, and the resource is never found by name. Mounting pairs by the key entry’sresource_idinstead, so the two paths only agree because every real archive has those two numbers equal.So a resource’s identity is its name and the bytes carrying that name are positional. Sort one table without the other and the result is a correctly named file holding a different resource’s contents, with nothing reporting a problem.
The engine’s own writer cannot produce that:
CERFFile::WriteResourceemits both entries for one file in a single call under a shared index. The correspondence is a property of the writer, and the reader assumes it.
Order within the two tables is free, since nothing matches a key against anything by name. A writer may order entries however it likes as long as both tables are ordered together.
These rules apply to a nested archive as much as a top-level one, because nothing opens a nested archive in place. CExoEncapsulatedFile::OpenFile branches on a stored type tag and every branch opens a named file from disk; no path in either class opens a container at an offset inside an already-open parent. An archive carried inside another is extracted to its own file first and reopened from the top, which is how a save’s per-module archives are read. Reading one in place instead yields the same bytes, and is a thing the engine never does.
Read from CERFFile::Read (0x005dce50), ReadHeaderVariance (0x005dd3c0), ExportFilesFromERF (0x005dd710) and WriteResource (0x005ddbc0), plus CExoEncapsulatedFile::OpenFile (0x0040dc30). Provenance: traced. Scoped to those two classes; a third consumer of this format, if one exists, was not looked for.
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 |
File Layout
Three blocks: a fixed 120-byte header, a single key table, and the payload. The key table is found by keys_offset out of the header, and each entry carries its own payload offset, so nothing here depends on the blocks sitting next to each other.
| Block | Size | Located by |
|---|---|---|
| Header | 120 bytes | Always at 0x00 |
| Key table | 32 bytes per entry | keys_offset |
| Resource payload | remainder of the file | Each entry’s own data_offset |
Warning
Vanilla RIMs are padded, not tightly packed, and none of it is optional-looking Measured across every static RIM in a retail install, a shipped archive has structure between its payloads that a naive writer will not reproduce:
- Every
data_offsetis 4-byte aligned, without exception.- Consecutive resources follow
next_offset == round_up_4(prev_end) + 16in all but a handful of non-empty archives, a mandatory 16-byte gap on top of the alignment padding.- Interior gaps are zero-filled, never left as stale bytes.
- An 8-byte zero gap separates the key table from the first payload, widening to 72 bytes in two outliers.
- Every archive ends with exactly 10 zero bytes past the last payload.
A writer that packs tightly produces a structurally valid file the engine will read, and one that resembles no archive BioWare shipped. Since offsets are read from the table rather than inferred, the padding is not load-bearing for a reader. It is what a byte-comparison against vanilla trips on, and what tells you a file was generated rather than shipped.
The leanness compared to ERF is structural rather than cosmetic. An ERF splits its bookkeeping across two parallel tables, one saying what each resource is and another saying where its bytes are. A RIM has one table that does both jobs, which is why its entries are 32 bytes where an ERF’s are 24 plus 8. There is also no localized string block and no description, which is the metadata a RIM gives up in exchange.
Header (120 bytes)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | magic | fourcc | RIM , trailing space included. |
0x04 | version | fourcc | V1.0. |
0x08 | reserved | u32 | Preserved verbatim on round-trip. |
0x0C | entry_count | u32 | |
0x10 | keys_offset | u32 | |
0x14 | reserved | u32 | Preserved verbatim on round-trip. |
0x18 | reserved | 96 bytes | The dead zone described below. |
Key Entry (32 bytes each)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | resref | char[16] | Null-padded, not null-terminated. |
0x10 | type_id | u32 | Four bytes on disk holding one of the resource type codes. See the note below. |
0x14 | resource_id | u32 | The entry’s own index. Not the packed word KEY uses. See below. |
0x18 | data_offset | u32 | Absolute, from the start of the file. |
0x1C | data_size | u32 |
Note
resource_idis the entry’s index, not the KEY’s packed word The field shares a name with KEY’s and does not share its meaning. A KEY packs an archive number and an entry number into one word, because a KEY entry has to name which BIF it is talking about. A RIM answers only for itself, so there is nothing to pack: the value is the entry’s own position in the key table, matching in every entry of every RIM under a retail install’smodules/andrims/.It is therefore redundant with the array index a reader already has, and a writer produces it by counting. The high twelve bits, which are the archive half in a KEY, are zero throughout.
Note
type_idis four bytes wide holding a two-byte value Resource type codes are 16-bit everywhere else in the engine, including inKEYandERF, but a RIM key entry gives the field a full four bytes.The engine truncates.
CExoKeyTable::AddResourceImageContents(0x0040f990) copies all four bytes out of the entry unmasked, then passes the value toAddKey(0x0040e990) through an explicit two-byte cast. Everything after that point sees the truncated value: it is what gets hashed, what gets compared against existing entries, and what is stored into the live table’s two-byte type field. There is no bounds check anywhere on either side of the cast and no rejection path at all, so an out-of-range value does not fail. It silently aliases to whatever survives the truncation.No vanilla file exercises it. Across every key entry in every static RIM archive the upper half is zero, and every type value present is a real resource type code. That means the corpus is silent on the question rather than agreeing with the engine: it never produces a value the truncation would change. The population is a retail install’s
modules/andrims/; saves contain no RIMs and were not searched.Rakata’s reader rejects any value that does not fit a
u16. That remains our own strictness rather than a match for engine behaviour, and now in a specific direction: the engine would have accepted such a file and quietly mangled the type, where we refuse it. Keeping the rejection is the right call, but it is a choice to be stricter, not conformance.
Engine Audits & Decompilation
Read from CExoKeyTable::AddResourceImageContents at 0x0040f990 in swkotor.exe, with each subsection naming its own function below. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
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 |
File Layout
A 56-byte header holding an offset-and-count pair for each of six sections, and the sections themselves. The write order below is fixed and every section is located by its own header entry, so a reader never has to assume adjacency. (The audit further down counts seven contiguous sections, which is the same file: it counts the header as the first.)
Header (56 bytes)
| Offset | Field | Offset | Field |
|---|---|---|---|
0x00 | file_type (fourcc) | 0x04 | version (fourcc, always V3.2) |
0x08 | struct_offset | 0x0C | struct_count |
0x10 | field_offset | 0x14 | field_count |
0x18 | label_offset | 0x1C | label_count |
0x20 | field_data_offset | 0x24 | field_data_count |
0x28 | field_indices_offset | 0x2C | field_indices_count |
0x30 | list_indices_offset | 0x34 | list_indices_count |
The last three counts are byte lengths, not element counts, unlike the first three. Field data is a heterogeneous blob and both index arrays are u32 runs whose element boundaries only make sense once you know which struct or field is pointing into them.
Struct record (12 bytes)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | struct_id | i32 | Not a free tag. Several formats read it as a discriminator. See below. |
0x04 | data_or_offset | u32 | Overloaded. See below. |
0x08 | field_count | u32 | How many fields this struct has. |
Warning
struct_idcarries real meaning in several formats, and a writer that invents one breaks the file silently The container itself does nothing with the value. Per-format readers do, and they read it instead of a field, so there is no label to search for and nothing reports a wrong value.Two attested cases. A UTC’s
Equip_ItemListelement carries the equipment slot as its struct id, as a single bit: across every creature inchitin.keyand the module RIM archives the ids that occur are1,2,8,16,32,128,256,512,1024,16384,32768,65536and131072, and nothing else. A GIT object list uses one fixed id per list, which is how an element knows what kind of object it is.So a writer copying a struct from one place to another must carry its id, and a writer creating one must know which value the format expects. Emitting
-1everywhere produces a file that parses and loses every equipped item.A GIT’s
Creature Listis the case traced end to end. The loader compares each element’s id against the one value it expects and skips the element entirely when it differs, silently, moving straight to the next index. The creature does not load, nothing is defaulted, and nothing fails.One accessor is the only way to reach an element’s id,
CResGFF::GetElementType, and the reader class hands no raw struct pointer outside itself: every method takes and returns a wrapper carrying an index. So a list whose loader never calls that accessor has ids nothing can be reading, and the lists below are settled rather than merely unobserved.A UTC’s
ItemListnever reads its element ids, in the same function that readsEquip_ItemList’s. Neither doesClassList, norFeatList, nor any list in a DLG, nor a GIT’sCameraList. The sequential ids anItemListcarries and the constant everyFeatListelement carries are whatever a writer happened to put there.
-1genuinely means nothing where a format does not use the field, which is most structs and every one this page describes generically.
Which record is the root
Struct record 0. Nothing in the header says so, which is the gap: a reader holding the struct array and the field array has no stated place to begin.
Measured rather than assumed, and by the property that defines a root rather than by position: across every UTC and GIT in chitin.key and the module RIM archives, no struct field and no list element ever points at index 0. Every other record is reachable from it and it is reachable from nothing.
Field record (12 bytes)
| Offset | Field | Type |
|---|---|---|
0x00 | field_type | u32 |
0x04 | label_index | u32 |
0x08 | data_or_offset | u32 |
label_index indexes the label section, which is a flat array of 16-byte NUL-padded names. Labels are shared, so two fields with the same name in different structs point at one entry.
Important
Two fields with the same label in one struct: the first wins and the second is unreachable Lookup is a linear scan across the struct’s fields in stored order, comparing the label bytes and returning the first match (
CResGFF::GetFieldByLabel). A duplicate is neither an error nor a replacement. The later field is dead weight nothing ever reads.Which makes on-disk order load-bearing, because “first” means first in the file and nothing else. A tool that changes a field by appending a fresh copy of the label has written a file where its own edit does not take effect, and no part of the format reports it.
Important
A one-field struct stores its field differently, so growing one to two fields is a rewrite
field_countselects between two physical shapes. A struct with exactly one field puts that field’s index straight into its owndata_or_offset, with no indirection at all. A struct with two or more puts a byte offset there instead, pointing into the shared field indices array.Reading this is easy enough, since the count tells you which shape you are looking at. Writing into an existing file is where it bites: adding a field to a struct that already has two or more appends to that shared array and nothing else moves, but adding a second field to a struct that has one has to lift the existing index out of
data_or_offset, put both indices into a fresh span of the array, and grow the header’s count to match. Same operation from the caller’s side, two different things underneath.
Field type codes
Eighteen codes, and the first ten are the ordinary scalars. The Storage column is the part a reader has to get right: it says whether data_or_offset holds the value itself or points somewhere, and where.
| Code | Type | Storage |
|---|---|---|
0 | BYTE | Value, in the low byte |
1 | CHAR | Value, signed, in the low byte |
2 | WORD | Value, in the low two bytes |
3 | SHORT | Value, signed, in the low two bytes |
4 | DWORD | Value |
5 | INT | Value, signed |
6 | DWORD64 | Offset into field data; 8 bytes |
7 | INT64 | Offset into field data; 8 bytes, signed |
8 | FLOAT | Value, reinterpreted as f32 |
9 | DOUBLE | Offset into field data; 8 bytes |
10 | CExoString | Offset into field data |
11 | CResRef | Offset into field data |
12 | CExoLocString | Offset into field data |
13 | VOID | Offset into field data |
14 | Struct | A struct-array index, not an offset |
15 | List | Byte offset into the list indices array |
16 | Vector4 | Offset into field data; four f32 |
17 | Vector3 | Offset into field data; three f32 |
18 | StrRef | Encoding not established. Defined by the format and present in none of the four archive families surveyed below, so nothing attests how it stores its value. See the note under the table. |
The four-byte scalars sit in data_or_offset directly rather than being pointed at. Anything wider than four bytes is in the field-data blob, including DWORD64, INT64 and DOUBLE, which is easy to miss because their siblings are immediate.
Warning
Code
18has no storage rule here WhetherStrRefholds its value immediately like the other four-byte scalars, or points into the field-data blob, is not established: it occurs in none of the four archive families, so no file demonstrates either reading.INT64andDOUBLEare also absent from the static archives but do have rules, taken from the codes around them and from saves;18has neither.A reader that meets one should surface it rather than guess. Picking the wrong branch reads a plausible number out of the wrong region, failing as quietly as the
Structcase below.
The complex encodings
Each is written at the offset data_or_offset gives, counted from field_data_offset.
| Type | Layout at that offset |
|---|---|
CExoString | u32 byte length, then exactly that many bytes. Not NUL-terminated. |
CResRef | u8 byte length, then that many bytes. The length is one byte, not four, and the value is capped at 16. |
CExoLocString | u32 total size of everything after this word, then u32 StrRef, then u32 substring count, then that many substrings. Each substring is a u32 language-and-gender id, a u32 length, and that many bytes. See below for what the id holds. |
VOID | u32 byte length, then that many raw bytes. |
Vector3 | Three f32: x, y, z. |
Vector4 | Four f32: w, x, y, z. Scalar first. |
Note
Only one language-and-gender id is attested The conventional encoding is
language * 2 + gender,0being masculine, so an English feminine substring is1. (Provenance: paraphrase. General knowledge about the format, not something this binary was seen doing.)Every substring in
chitin.keyand the module RIM archives carries id0, across the handful of localised strings holding inline text at all. The multiplier and the gender bit are unattested here, so the formula is worth treating as a convention rather than a measurement.Language codes themselves, and the one the engine special-cases, are on the TLK page with their codepage mapping.
Important
A
Structfield holds an index, not an offset Code14puts a direct index into the struct array indata_or_offset. Every other pointer-like field in the format holds a byte offset, so this one reads as an inconsistency and is easy to implement as an offset by analogy with its neighbours.The two readings do not fail loudly against each other. Struct records are 12 bytes, so index
nand byte offset12nare both plausible numbers landing inside the same section, and a reader using the wrong one produces a file that parses into the wrong tree rather than one that errors.
List is the other indirection. data_or_offset is a byte offset into the list indices array, where a u32 count is followed by that many struct-array indices, one per element.
Important
An empty list is not the same thing as an absent field Three states are distinguishable in the file: the label is absent, present with a count of zero, or present with elements. An empty list is an ordinary field record of type
15whosedata_or_offsetpoints at au32zero, with no struct indices after it.Offset
0is legal and used, so a reader must not read a zero offset as a missing field. Empty lists are ordinary rather than exceptional across every GFF inchitin.keyand the module RIM archives.A writer that drops a list rather than emitting a zero count has changed the file’s meaning. Four format pages turn on this, and UTC records a case where absent and present-but-empty behave identically on a fresh template and diverge on an object that already holds values.
A CExoLocString in chitin.key is almost always a bare StrRef pointing into the TLK, with no inline text at all: only a handful carry any, and each of those carries exactly one substring, with language-and-gender id 0.
That is a fact about chitin.key and nothing else. Module RIMs and saves were not in it, and a save is where player-entered text would have to live, so whether either exercises the substring loop is unchecked rather than settled. Implement the loop and test it against something other than the base archives.
Important
The substring wins and the StrRef is the fallback, not the other way round Reading one of these, the engine looks for the inline substring matching the running language and gender first, unconditionally, and only consults the StrRef when that lookup comes back empty (
CExoLocString::GetString). A field carrying a valid StrRef and a matching substring displays the substring.The obvious reading is the reverse, because
0xFFFFFFFFin the StrRef looks like a “use the inline text instead” marker. It is not one. The engine never inspects the StrRef’s validity to make this choice, so the invalid value is simply the case where the fallback has nothing to offer either.The rule lives on the type rather than on any field. Dialogue lines, item property descriptions, map notes and the save’s stat block all read through the same accessor.
Language 0 ignores the gender bit. The packed substring id is language * 2 + gender, but the gender term is only honoured when the language is non-zero. For language 0 the lookup always probes the ungendered slot, whatever gender was asked for. So a gendered language-0 substring is text the engine will never display.
The write side collapses it identically: asked to add a gendered substring under language 0, the engine lands it in the ungendered slot. So the engine’s own code cannot produce two language-0 substrings split by gender, and their absence from any corpus is structural rather than a gap in what has been looked at. A tool writing one is writing something the engine never would. Why the default language is special is not established, and it is not visible from the code either way.
How these were established
(Provenance: measured.) The encodings are derived from the bytes rather than inherited from another project’s header, and checked by tiling: decoding every field with the rules above, the field-data blob closes exactly in every GFF in a retail chitin.key and in a save corpus, with no gap, no overlap, and the last span ending on field_data_count. The six sections likewise tile from byte 56 to end of file in every GFF of both.
A wrong length rule for any variable-length type leaves a hole or an overrun, and a wrong reading of Struct or List puts an index outside its array, so a corpus that tiles with no slack constrains every rule at once. Saves are included because they carry type codes chitin.key does not.
Which codes actually appear, and where
Reported per archive, because each family exercises a different part of the code space. Four have been opened: the GFFs in chitin.key, the module RIM archives under modules/ and rims/, the static ERF archives (lips/ plus patch.erf), and a save corpus.
| Code | chitin.key | Module RIMs | Static ERFs | Saves |
|---|---|---|---|---|
0 2 3 4 5 8 10 11 12 14 15 17 | ● | ● | partial | ● |
13 VOID | — | ● | — | ● |
16 Vector4 | — | ● | — | ● |
1 CHAR, 6 DWORD64 | — | — | — | ● |
7 INT64, 9 DOUBLE | — | — | — | — |
VOID and Vector4 are not save-only. Both are absent from chitin.key and present in module RIMs, so a reader working from the base BIFs alone will not meet them and a reader working from any module will.
Every Vector4 in either population is a GIT Orientation, which corroborates rather than complicates the audit below: GIT’s camera list really is the format’s only Vector4 call site, and the field is written by the toolset into static GITs as well as by the engine into saved ones. VOID in a module RIM is likewise narrow; in saves it carries the packed value arrays in GLOBALVARS.res and a flag block in PARTYTABLE.res.
CHAR and DWORD64 are the codes that really are save-only: absent from chitin.key, from every module RIM and from the static ERFs, and abundant in saves. INT64 and DOUBLE appear in none of the four, which is an absence with a named scope rather than a reason to think the codes are unreal. A reader must still handle them.
Note
Why
Vector4’s component order can be read off the files Thew, x, y, zorder is documented from the engine below, and saved GITs confirm it independently. Across everyVector4in the save fixtures the second and third slots are zero and the first and fourth vary, and all of them are unit-length. A camera that only turns on the spot has a non-zero scalar and one non-zero axis term, which underw, x, y, zis exactly the first and fourth slots. Under anx, y, z, wreading the non-zero pair would have to be the third and fourth instead.The identity value settles it from the other side. One camera carries
(1, 0, 0, 0), the identity underw, x, y, z; under the alternative that same value is a half-turn aboutx, which is not an orientation a resting camera holds.
Text encoding
GFF stores text as bytes and declares no encoding anywhere in the file, so a reader cannot learn it from the container. There is no lookup in the engine either: it hands the bytes to the process’s ANSI codepage, which is Windows-1252 on a western install. The TLK page documents the one place the engine special-cases a language and what follows from that.
In practice the text in chitin.key is almost entirely ASCII. The only byte outside that range anywhere in its strings and resrefs is the plus-or-minus sign, in the Pazaak card names. Treating GFF text as single-byte and decoding it as Windows-1252 reproduces that archive exactly, and nothing in it requires a multi-byte sequence. Module RIMs and saves were not scanned for this, and player-entered text lives in the latter.
The overloaded word
data_or_offset is the mechanic the whole format turns on, and it means different things depending on context rather than carrying a discriminator of its own:
- On a field whose type fits in four bytes, it is the value. For anything larger, so strings, resrefs, doubles, structs and lists, it is an offset into the field data blob or into the index arrays.
- On a struct with exactly one field, it is that field’s index directly. With any other count it is a byte offset into the field indices array, where
field_countconsecutive indices live.
A struct with field_count == 0 has no defined meaning for the word, and no vanilla file provides one: not a single zero-field struct occurs in any GFF in chitin.key or the module RIM archives. The rule above still applies literally, so the word is an offset into the field indices array naming zero consecutive indices, and nothing dereferences it. A reader must not follow it, and a writer creating one has no vanilla precedent to match. UTI describes what the engine does with an empty entry in a PropertiesList, which is a code path a hand-authored file can reach even though no shipped file does.
So the same u32 is variously an immediate value, an offset into one of three different regions, or an index. What it is at any point follows from the field’s type and the struct’s field count, never from anything stored beside it. A reader that guesses wrong here does not fail loudly; it reads a plausible number out of the wrong region.
Engine Audits & Decompilation
Binary: swkotor.exe
Serialization Architecture (WriteGFFFile)
Derived from 0x00413030 / 0x004113d0.
The writer builds the whole file in memory and emits seven sections back to back in a fixed order, with no padding or alignment bytes between them. It records each section’s byte offset into the 56-byte header as it goes. This is the path save games and area extraction both write through.
| Order | Section | Size |
|---|---|---|
| 1 | Header | 56 bytes (0x38) |
| 2 | Struct array | 12B × struct_count |
| 3 | Field array | 12B × field_count |
| 4 | Label array | 16B × label_count |
| 5 | Field data blob | Variable |
| 6 | Field indices | Variable |
| 7 | List indices | Variable |
Warning
The label array holds fixed 16-byte elements, so a label longer than 16 characters is truncated on write.
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 never matches:FortBonusagainst the engine’sfortbonusis a different string, not a fallback path. This holds for the wholeReadField*family, every scalar and string type, so exact-string field matching in a reader is the behaviourally correct model of this engine, not a shortcut that happens to work on vanilla data.
Important
A
Vector4field’s on-disk component order isw, x, y, z. Slot0isw, slot1x, slot2y, slot3z, so the identity quaternion is[1.0, 0.0, 0.0, 0.0]and not[0.0, 0.0, 0.0, 1.0].
CResGFF::ReadFieldQuaternion(0x004121b0) andCResGFF::WriteFieldQuaternion(0x00412ca0) both assign field by field rather than blitting memory, so the order is legible rather than inferred.Confirmed at the format’s only
Vector4-typed call site, GIT’sCameraList[].Orientation, and cross-checked against the engine’sQuaternionstruct layout, which is the same struct the MDL format already documents.
Note
Carry-over versus fresh-literal is a property of the read call, not of the field, and it recurs on every format page in this section. Every GFF scalar read takes a “default if absent” argument, and what sits in that argument is the whole distinction:
Mechanism The default argument holds Carry-over The field’s already-constructed value on the object, so an absent field leaves it where it was. Fresh literal An unrelated literal baked into the call site, stamped over whatever the object held. Either way the assignment always executes, with no visible branch on presence, which is why the two are indistinguishable from the resulting value alone.
They can diverge sharply. UTC’s
LoadCreaturefields include five where the read literal disagrees with the constructor outright. Naming which mechanism a field uses is the most repeated question these pages answer, so it is defined once here.
Engine Blueprints: Specialized GFF Containers
The sections above describe raw GFF nodes. The engine also uses GFF as a wrapper for a family of fixed layouts called blueprints: creatures, dialogue trees, placeables, area parameters, and the rest.
Each blueprint page documents how the K1 GOG executable (swkotor.exe) maps that layout into memory through its Load*FromGFF functions, which is what rakata-lint validates against.
How far to trust any given claim is a per-page question, and each page answers it. Every Engine Audits section names the function it was read from and its provenance level, and those levels differ: some rows are traced instruction by instruction, some are measured against real files, and most are derived from one decompilation pass that nothing has separately re-checked.
Reading the field tables
Every blueprint page ends with a field table generated from the schema itself, so no label can be quietly left out. It sits at the end because it runs long, and the page’s prose comes first. Each table splits in two.
What the engine does with each field holds the labels where the engine’s behaviour is established, with what it does and what it holds when a file omits the label.
Fields nobody has examined holds the rest. That is a gap, not a finding: nobody has established whether the engine reads them, which is not the same as establishing that it does not. Many still have an answer under When absent, because the two questions were settled separately.
A bracketed number sends you to the finding under the table. The same number on many rows means one passage settles all of them, which is worth seeing before you rely on any single row.
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
Each audit in this section’s navigation bar records the fields a format declares, how the engine loads them, and the rules it enforces beyond what the container’s own structure guarantees.
A field can be structurally valid GFF and still break the engine. rakata-lint flags those cases, and these audits are what it validates against.
| Ext | Type | Core Function |
|---|---|---|
.are | Area Static Blueprint | An area’s static properties: weather, day/night limits, and physics constraints. |
.dlg | Dialogue | One conversation: the branching graph of lines and replies, with the cinematic actions attached to them. |
.git | Game Instance Template | Where everything in an area is: placement, orientation, and which template each object spawns from. |
.ifo | Module Info | Module-level metadata: the entry point, the module’s scripts, and its spawn state. |
.utc | Creature | A creature: every NPC, enemy, droid and companion, with its stats and appearance. |
.utd | Door | A door: the transition it opens onto, plus its lock and trap state. |
.ute | Encounter | An encounter: the boundary and spawn points deciding what appears, and when. |
.uti | Item | An item: every weapon, suit of armour, medpac and upgrade, with its properties. |
.utm | Store | A merchant’s store: the inventory it offers and its buy and sell markup. |
.utp | Placeable | A placeable: containers, scenery, and traps the player can interact with. |
.uts | Sound | A positional sound emitter: its clips, and how volume falls off with distance. |
.utt | Trigger | A trigger: an invisible polygon that fires scripts when something enters it. |
.utw | Waypoint | A waypoint: a named position used for navigation and area transitions. |
ARE Format (Area Static Blueprint)
An .are file is an area’s static setup: its name, whether it counts as interior or exterior, its weather and grass, its sun and moon lighting and fog, and the scripts that fire at area level. Everything that moves around in the area, creatures and doors included, lives in the module’s GIT rather than here.
This page documents ARE’s field defaults, the confirmed-dead legacy fields still written by older tools, and the 52-field swoop-racing minigame subsystem. Evidence is drawn from Ghidra decompilation of
swkotor.exe(K1 GOG build), cross-checked against a full K1 install’s vanilla.arecorpus. The tables below are lookup surfaces, meant to be searched rather than read start to end.
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 |
Field Schema
The format’s field families, as an orientation before the full list.
| 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 |
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 dynamic, session-changeable state, meaning weather, stealth XP, map exploration, cameras and 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
Read from CSWSArea::LoadArea at 0x0050e190 in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
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 within the LoadAreaHeader subroutine.
Core Environmental Identity
| Field Category | Engine Property & Behavioural 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 that is. Absent, it stamps an empty string and that empty string goes through SetTag all the same, so the case pass runs whether or not the file supplied anything. |
| 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. RestrictMode follows the carry-over mechanism, and the constructor zero-initializes it before LoadAreaHeader’s single pass runs, so an absent field is functionally 0. That resolved 0 also explains why a missing field can never trigger the hardcoded event on its own: UnstealthParty only fires on a byte that’s both non-zero and different from the previous stored value, and a defaulted 0 satisfies neither. |
| Identity | CameraStyle (Int), DefaultEnvMap (ResRef), LoadScreenID (WORD) -> All three default unconditionally when absent: CameraStyle to 0, DefaultEnvMap to an empty resref, LoadScreenID to 0. |
Interior areas lose their weather If
Flags(Bit 0) marks the area as interior, the engine zeroes every weather property on load, discarding whatever the file set.
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 | Default 10000.0. The engine clamps values to >= 0.0. |
Tints (*AmbientColor, *DiffuseColor, *FogColor) | DWORD | Read as DWORD colour 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 | Toggles and opacities for 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 follows the carry-over mechanism 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. |
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.
Grass_Emissiveand the entireDirty*overlay set 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/DirtyFormulaThreandDirtyFuncOne/Two/Three, exist anywhere inswkotor.exe’s string table, verified against a binary-wide search that does find every neighbouringGrass_*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 and invisible to K1’s engine.
Map Transitions & Saving states
| Feature Category | Engine Evaluation & Triggers |
|---|---|
| Zoom Bias | MapZoom evaluates to a default scaling scalar of 1, not 0. |
The map block sits behind three gates
The geographic vectors, MapResX and the coordinate structs like WorldPt1X, are read only when all three hold:
- A minimap TGA or TPC asset matching the level name exists on disk.
- The
Mapsub-struct is present in the GFF. MapResX’s resolved value is nonzero.
MapResX reads with an unconditional literal default of 0, so an absent MapResX is a gating sentinel rather than merely a zero. That 0 is tested directly, and where it holds the engine skips NorthAxis, MapPt* and WorldPt* entirely and falls through to a disabled map with MapZoom fixed at 1.
Where the block is read, MapPt goes through a dual path checking whether the field is formally FLOAT or INT. An absent MapPt1X, MapPt1Y, MapPt2X or MapPt2Y resolves to 0 either way: the INT branch reads a literal 0, and the FLOAT branch’s 0.0 survives its floor conversion. NorthAxis defaults to 0. WorldPt1X, WorldPt1Y, WorldPt2X and WorldPt2Y are independent FLOAT reads defaulting to 0.0, with no gating between them.
StealthXPCurrent chains its default off StealthXPMax
Both are snapshotted as DWORDs, and they default by different mechanisms. StealthXPMax uses the ordinary carry-over, its own prior value, zero-initialised by the constructor. StealthXPCurrent defaults to whatever StealthXPMax just resolved to, not to its own prior value.
On a fresh load both land on 0 either way, so only the mechanism differs. After the read the engine clamps StealthXPCurrent down to StealthXPMax where it exceeds it.
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. It is 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, a list-level gate rather than a per-entry one. 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), then the float properties governing movement:
| Field | Default / Constraint |
|---|---|
LateralAccel | Defaults to 60.0. |
MovementPerSec | 6.0 for swoops, 90.0 for turrets, 0.0 otherwise. |
Bump_Plane | Clamped to 0..3. |
| Nested Arrays | The struct needs sub-struct Player arrays (Models, Camera, Axes) and Enemy/Obstacles lists to work. |
The Player struct, each Enemies list entry, and each Obstacles list entry are three genuinely different shapes rather than 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.
Every label the schema declares
Generated from the schema, so no label can be quietly left out. How to read these tables.
What the engine does with each field
| Field | Type | Engine | When absent |
|---|---|---|---|
Rooms[].ForceRating | INT | never reads it: neither field-name string exists anywhere in swkotor.exe, confirmed by a binary-wide search; toolset-only in this build | not one constant; we substitute 0 |
Rooms[].DisableWeather | BYTE | never reads it: neither field-name string exists anywhere in swkotor.exe, confirmed by a binary-wide search; toolset-only in this build | not one constant; we substitute 0 |
DisableTransit | BYTE | never reads it: ARE-005 records DisableTransit, NoHangBack and PlayerVsPlayer as toolset fields the K1 engine never reads | not one constant; we substitute 0 |
NoHangBack | BYTE | never reads it: ARE-005 records DisableTransit, NoHangBack and PlayerVsPlayer as toolset fields the K1 engine never reads | not one constant; we substitute 0 |
PlayerVsPlayer | BYTE | never reads it: ARE-005 records DisableTransit, NoHangBack and PlayerVsPlayer as toolset fields the K1 engine never reads | not one constant; we substitute 0 |
Grass_Emissive | DWORD | never reads it: Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist | not one constant; we substitute 0 |
DirtyARGBOne | INT | never reads it: Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist | not one constant; we substitute 0 |
DirtySizeOne | INT | never reads it: Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist | not one constant; we substitute 0 |
DirtyFormulaOne | INT | never reads it: Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist | not one constant; we substitute 0 |
DirtyFuncOne | INT | never reads it: Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist | not one constant; we substitute 0 |
DirtyARGBTwo | INT | never reads it: Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist | not one constant; we substitute 0 |
DirtySizeTwo | INT | never reads it: Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist | not one constant; we substitute 0 |
DirtyFormulaTwo | INT | never reads it: Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist | not one constant; we substitute 0 |
DirtyFuncTwo | INT | never reads it: Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist | not one constant; we substitute 0 |
DirtyARGBThree | INT | never reads it: Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist | not one constant; we substitute 0 |
DirtySizeThree | INT | never reads it: Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist | not one constant; we substitute 0 |
DirtyFormulaThre | INT | never reads it: Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist | not one constant; we substitute 0 |
DirtyFuncThree | INT | never reads it: Grass_Emissive and the entire Dirty* overlay set are confirmed dead in this K1 build rather than merely unread; none of their field-name strings exist | not one constant; we substitute 0 |
Fields nobody has examined
Whether the engine reads these has not been established, which is not the same as establishing that it does not. Where When absent carries an answer, that half is settled.
| Field | Type | When absent |
|---|---|---|
ID | INT | stamps 0 |
Creator_ID | INT | stamps 0 |
Version | DWORD | stamps 0 |
Comments | CExoString | stamps "" |
Name | CExoLocString | stamps empty |
Tag | CExoString | stamps "" |
OnHeartbeat | CResRef | NOT EXAMINED; we substitute "" |
OnUserDefined | CResRef | NOT EXAMINED; we substitute "" |
OnEnter | CResRef | NOT EXAMINED; we substitute "" |
OnExit | CResRef | NOT EXAMINED; we substitute "" |
Flags | DWORD | stamps 0 |
CameraStyle | INT | stamps 0 |
DefaultEnvMap | CResRef | stamps "" |
Unescapable | BYTE | keeps 0 |
RestrictMode | BYTE | keeps 0 |
ChanceRain | INT | stamps 0 |
ChanceSnow | INT | stamps 0 |
ChanceLightning | INT | stamps 0 |
WindPower | INT | stamps 0 |
ChanceFog | INT | stamps 0 |
MoonAmbientColor | DWORD | stamps 0 |
MoonDiffuseColor | DWORD | stamps 0 |
MoonFogColor | DWORD | stamps 0 |
SunAmbientColor | DWORD | stamps 0 |
SunDiffuseColor | DWORD | stamps 0 |
SunFogColor | DWORD | stamps 0 |
DynAmbientColor | DWORD | stamps 0 |
MoonFogNear | FLOAT | stamps 10000.0 |
MoonFogFar | FLOAT | stamps 10000.0 |
SunFogNear | FLOAT | stamps 10000.0 |
SunFogFar | FLOAT | stamps 10000.0 |
MoonFogOn | BYTE | stamps 0 |
SunFogOn | BYTE | stamps 0 |
MoonShadows | BYTE | stamps 0 |
SunShadows | BYTE | stamps 0 |
DayNightCycle | BYTE | stamps 0 |
IsNight | BYTE | stamps 0 |
ShadowOpacity | BYTE | stamps 0 |
LightingScheme | BYTE | stamps 0 |
NoRest | BYTE | keeps 0 |
ModSpotCheck | INT | stamps 0 |
ModListenCheck | INT | stamps 0 |
Grass_Diffuse | DWORD | stamps 0 |
Grass_Ambient | DWORD | stamps 0 |
Grass_Density | FLOAT | stamps 0.0 |
Grass_QuadSize | FLOAT | stamps 0.0 |
Grass_TexName | CResRef | NOT EXAMINED; we substitute "" |
Grass_Prob_LL | FLOAT | stamps 0.0 |
Grass_Prob_LR | FLOAT | stamps 0.0 |
Grass_Prob_UL | FLOAT | stamps 0.0 |
Grass_Prob_UR | FLOAT | stamps 0.0 |
AlphaTest | FLOAT | stamps 0.2 |
StealthXPMax | DWORD | NOT EXAMINED; we substitute 0 |
StealthXPCurrent | DWORD | not one constant; our reader works it out from other fields |
StealthXPLoss | DWORD | keeps 0 |
StealthXPEnabled | BYTE | keeps 0 |
TransPending | BYTE | keeps 0 |
TransPendNextID | BYTE | keeps 0 |
TransPendCurrID | BYTE | keeps 0 |
LoadScreenID | WORD | stamps 0 |
Rooms | List | NOT EXAMINED; we substitute container |
Rooms[].PartSounds | List | not one constant; we substitute container |
Rooms[].PartSounds[].Looping | BYTE | NOT EXAMINED; we substitute 0 |
Rooms[].PartSounds[].ModelPart | CExoString | NOT EXAMINED; we substitute "" |
Rooms[].PartSounds[].OmenEvent | CExoString | NOT EXAMINED; we substitute "" |
Rooms[].PartSounds[].Sound | CResRef | NOT EXAMINED; we substitute "" |
Rooms[].RoomName | CExoString | stamps "" |
Rooms[].EnvAudio | INT | stamps 0 |
Rooms[].AmbientScale | FLOAT | stamps 0.0 |
Expansion_List | List | NOT EXAMINED; we substitute container |
Expansion_List[].Expansion_Name | CExoLocString | stamps empty |
Expansion_List[].Expansion_ID | INT | stamps 0 |
Map | Struct | NOT EXAMINED; we substitute container |
Map.MapResX | INT | stamps 0 |
Map.NorthAxis | INT | stamps 0 |
Map.MapZoom | INT | stamps 1 |
Map.MapPt1X | FLOAT | stamps 0.0 |
Map.MapPt1Y | FLOAT | stamps 0.0 |
Map.MapPt2X | FLOAT | stamps 0.0 |
Map.MapPt2Y | FLOAT | stamps 0.0 |
Map.WorldPt1X | FLOAT | stamps 0.0 |
Map.WorldPt1Y | FLOAT | stamps 0.0 |
Map.WorldPt2X | FLOAT | stamps 0.0 |
Map.WorldPt2Y | FLOAT | stamps 0.0 |
MiniGame | Struct | not one constant; the field holds the absence |
MiniGame.Type | DWORD | NOT EXAMINED; we substitute 0 |
MiniGame.MovementPerSec | FLOAT | NOT EXAMINED; we substitute 0.0 |
MiniGame.LateralAccel | FLOAT | NOT EXAMINED; we substitute 60.0 |
MiniGame.Bump_Plane | DWORD | NOT EXAMINED; we substitute 0 |
MiniGame.DoBumping | BYTE | NOT EXAMINED; we substitute 0 |
MiniGame.UseInertia | BYTE | NOT EXAMINED; we substitute 0 |
MiniGame.DOF | DWORD | NOT EXAMINED; we substitute 0 |
MiniGame.Music | CResRef | NOT EXAMINED; we substitute "" |
MiniGame.Far_Clip | FLOAT | NOT EXAMINED; we substitute 100.0 |
MiniGame.Near_Clip | FLOAT | NOT EXAMINED; we substitute 0.1 |
MiniGame.CameraViewAngle | FLOAT | NOT EXAMINED; we substitute 65.0 |
MiniGame.Player | Struct | NOT EXAMINED; the field holds the absence |
MiniGame.Player.Camera | CResRef | NOT EXAMINED; we substitute "" |
MiniGame.Player.CameraRotate | BYTE | NOT EXAMINED; we substitute 0 |
MiniGame.Player.Minimum_Speed | FLOAT | the page does not say; we substitute -1.0 |
MiniGame.Player.Maximum_Speed | FLOAT | stamps 100.0 |
MiniGame.Player.Accel_Secs | FLOAT | not one constant; we substitute -1.0 |
MiniGame.Player.TunnelXPos | FLOAT | stamps 0.0 |
MiniGame.Player.TunnelXNeg | FLOAT | stamps 0.0 |
MiniGame.Player.TunnelYPos | FLOAT | NOT EXAMINED; we substitute 0.0 |
MiniGame.Player.TunnelYNeg | FLOAT | NOT EXAMINED; we substitute 0.0 |
MiniGame.Player.TunnelZPos | FLOAT | stamps 0.0 |
MiniGame.Player.TunnelZNeg | FLOAT | stamps 0.0 |
MiniGame.Player.TunnelInfinite | Vector3 | NOT EXAMINED; we substitute (0.0, 0.0, 0.0) |
MiniGame.Player.Start_Offset_X | FLOAT | stamps 0.0 |
MiniGame.Player.Start_Offset_Y | FLOAT | stamps 0.0 |
MiniGame.Player.Start_Offset_Z | FLOAT | stamps 0.0 |
MiniGame.Player.Target_Offset_X | FLOAT | stamps 0.0 |
MiniGame.Player.Target_Offset_Y | FLOAT | stamps 0.0 |
MiniGame.Player.Target_Offset_Z | FLOAT | stamps 0.0 |
MiniGame.Player.Models | List | NOT EXAMINED; we substitute container |
MiniGame.Player.Models[].Model | CResRef | NOT EXAMINED; we substitute "" |
MiniGame.Player.Models[].RotatingModel | BYTE | NOT EXAMINED; we substitute 1 |
MiniGame.Player.Track | CResRef | NOT EXAMINED; we substitute "" |
MiniGame.Player.Hit_Points | DWORD | the page does not say; we substitute 0 |
MiniGame.Player.Max_HPs | DWORD | the page does not say; we substitute 0 |
MiniGame.Player.Sphere_Radius | FLOAT | the page does not say; we substitute -1.0 |
MiniGame.Player.Invince_Period | FLOAT | stamps 0.0 |
MiniGame.Player.Bump_Damage | INT | stamps 0 |
MiniGame.Player.Num_Loops | INT | the page does not say; we substitute -10 |
MiniGame.Player.Scripts | Struct | NOT EXAMINED; we substitute container |
MiniGame.Player.Scripts.OnDamage | CResRef | stamps "" |
MiniGame.Player.Scripts.OnDeath | CResRef | stamps "" |
MiniGame.Player.Scripts.OnFire | CResRef | stamps "" |
MiniGame.Player.Scripts.OnHitObstacle | CResRef | stamps "" |
MiniGame.Player.Scripts.OnTrackLoop | CResRef | stamps "" |
MiniGame.Player.Scripts.OnCreate | CResRef | stamps "" |
MiniGame.Player.Scripts.OnHeartbeat | CResRef | stamps "" |
MiniGame.Player.Scripts.OnAnimEvent | CResRef | stamps "" |
MiniGame.Player.Scripts.OnHitBullet | CResRef | stamps "" |
MiniGame.Player.Scripts.OnHitFollower | CResRef | stamps "" |
MiniGame.Player.Sounds | Struct | NOT EXAMINED; we substitute container |
MiniGame.Player.Sounds.Engine | CResRef | stamps "" |
MiniGame.Player.Sounds.Death | CResRef | stamps "" |
MiniGame.Player.Gun_Banks | List | NOT EXAMINED; we substitute container |
MiniGame.Player.Gun_Banks[].BankID (required) | DWORD | stamps 4294967295 |
MiniGame.Player.Gun_Banks[].Gun_Model (required) | CResRef | not one constant; we substitute "" |
MiniGame.Player.Gun_Banks[].Bullet | Struct | NOT EXAMINED; the field holds the absence |
MiniGame.Player.Gun_Banks[].Bullet.Damage (required) | DWORD | not one constant; we substitute 0 |
MiniGame.Player.Gun_Banks[].Bullet.Lifespan (required) | FLOAT | not one constant; we substitute 0.0 |
MiniGame.Player.Gun_Banks[].Bullet.Rate_Of_Fire (required) | FLOAT | not one constant; we substitute 0.0 |
MiniGame.Player.Gun_Banks[].Bullet.Speed (required) | FLOAT | not one constant; we substitute 0.0 |
MiniGame.Player.Gun_Banks[].Bullet.Target_Type (required) | DWORD | not one constant; we substitute 0 |
MiniGame.Player.Gun_Banks[].Bullet.Bullet_Model | CResRef | stamps "" |
MiniGame.Player.Gun_Banks[].Bullet.Collision_Sound | CResRef | stamps "" |
MiniGame.Player.Gun_Banks[].Fire_Sound | CResRef | stamps "" |
MiniGame.Player.Gun_Banks[].Sensing_Radius | FLOAT | not one constant; we substitute 0.0 |
MiniGame.Player.Gun_Banks[].Horiz_Spread | FLOAT | not one constant; we substitute 0.0 |
MiniGame.Player.Gun_Banks[].Vert_Spread | FLOAT | not one constant; we substitute 0.0 |
MiniGame.Player.Gun_Banks[].Inaccuracy | FLOAT | not one constant; we substitute 0.0 |
MiniGame.Mouse | Struct | not one constant; the field holds the absence |
MiniGame.Mouse.AxisX | DWORD | NOT EXAMINED; we substitute 0 |
MiniGame.Mouse.AxisY | DWORD | NOT EXAMINED; we substitute 0 |
MiniGame.Mouse.FlipAxisX | BYTE | NOT EXAMINED; we substitute 0 |
MiniGame.Mouse.FlipAxisY | BYTE | NOT EXAMINED; we substitute 0 |
MiniGame.Enemies | List | NOT EXAMINED; we substitute container |
MiniGame.Enemies[].Trigger | BYTE | stamps 0 |
MiniGame.Enemies[].Models | List | NOT EXAMINED; we substitute container |
MiniGame.Enemies[].Models[].Model | CResRef | NOT EXAMINED; we substitute "" |
MiniGame.Enemies[].Models[].RotatingModel | BYTE | NOT EXAMINED; we substitute 1 |
MiniGame.Enemies[].Track | CResRef | NOT EXAMINED; we substitute "" |
MiniGame.Enemies[].Hit_Points | DWORD | the page does not say; we substitute 0 |
MiniGame.Enemies[].Max_HPs | DWORD | the page does not say; we substitute 0 |
MiniGame.Enemies[].Sphere_Radius | FLOAT | the page does not say; we substitute -1.0 |
MiniGame.Enemies[].Invince_Period | FLOAT | stamps 0.0 |
MiniGame.Enemies[].Bump_Damage | INT | stamps 0 |
MiniGame.Enemies[].Num_Loops | INT | the page does not say; we substitute -10 |
MiniGame.Enemies[].Scripts | Struct | NOT EXAMINED; we substitute container |
MiniGame.Enemies[].Scripts.OnDamage | CResRef | stamps "" |
MiniGame.Enemies[].Scripts.OnDeath | CResRef | stamps "" |
MiniGame.Enemies[].Scripts.OnFire | CResRef | stamps "" |
MiniGame.Enemies[].Scripts.OnHitObstacle | CResRef | stamps "" |
MiniGame.Enemies[].Scripts.OnTrackLoop | CResRef | stamps "" |
MiniGame.Enemies[].Scripts.OnCreate | CResRef | stamps "" |
MiniGame.Enemies[].Scripts.OnHeartbeat | CResRef | stamps "" |
MiniGame.Enemies[].Scripts.OnAnimEvent | CResRef | stamps "" |
MiniGame.Enemies[].Scripts.OnHitBullet | CResRef | stamps "" |
MiniGame.Enemies[].Scripts.OnHitFollower | CResRef | stamps "" |
MiniGame.Enemies[].Sounds | Struct | NOT EXAMINED; we substitute container |
MiniGame.Enemies[].Sounds.Engine | CResRef | stamps "" |
MiniGame.Enemies[].Sounds.Death | CResRef | stamps "" |
MiniGame.Enemies[].Gun_Banks | List | NOT EXAMINED; we substitute container |
MiniGame.Enemies[].Gun_Banks[].BankID (required) | DWORD | stamps 4294967295 |
MiniGame.Enemies[].Gun_Banks[].Gun_Model (required) | CResRef | not one constant; we substitute "" |
MiniGame.Enemies[].Gun_Banks[].Bullet | Struct | NOT EXAMINED; the field holds the absence |
MiniGame.Enemies[].Gun_Banks[].Bullet.Damage (required) | DWORD | not one constant; we substitute 0 |
MiniGame.Enemies[].Gun_Banks[].Bullet.Lifespan (required) | FLOAT | not one constant; we substitute 0.0 |
MiniGame.Enemies[].Gun_Banks[].Bullet.Rate_Of_Fire (required) | FLOAT | not one constant; we substitute 0.0 |
MiniGame.Enemies[].Gun_Banks[].Bullet.Speed (required) | FLOAT | not one constant; we substitute 0.0 |
MiniGame.Enemies[].Gun_Banks[].Bullet.Target_Type (required) | DWORD | not one constant; we substitute 0 |
MiniGame.Enemies[].Gun_Banks[].Bullet.Bullet_Model | CResRef | stamps "" |
MiniGame.Enemies[].Gun_Banks[].Bullet.Collision_Sound | CResRef | stamps "" |
MiniGame.Enemies[].Gun_Banks[].Fire_Sound | CResRef | stamps "" |
MiniGame.Enemies[].Gun_Banks[].Sensing_Radius | FLOAT | not one constant; we substitute 0.0 |
MiniGame.Enemies[].Gun_Banks[].Horiz_Spread | FLOAT | not one constant; we substitute 0.0 |
MiniGame.Enemies[].Gun_Banks[].Vert_Spread | FLOAT | not one constant; we substitute 0.0 |
MiniGame.Enemies[].Gun_Banks[].Inaccuracy | FLOAT | not one constant; we substitute 0.0 |
MiniGame.Obstacles | List | NOT EXAMINED; we substitute container |
MiniGame.Obstacles[].Name | CResRef | NOT EXAMINED; we substitute "" |
MiniGame.Obstacles[].Scripts | Struct | NOT EXAMINED; we substitute container |
MiniGame.Obstacles[].Scripts.OnCreate | CResRef | stamps "" |
MiniGame.Obstacles[].Scripts.OnHeartbeat | CResRef | stamps "" |
MiniGame.Obstacles[].Scripts.OnAnimEvent | CResRef | stamps "" |
MiniGame.Obstacles[].Scripts.OnHitBullet | CResRef | stamps "" |
MiniGame.Obstacles[].Scripts.OnHitFollower | CResRef | stamps "" |
PlayerOnly | BYTE | NOT EXAMINED; we substitute 0 |
DLG Format (Dialogue Blueprint)
A .dlg file is one conversation: a graph of NPC lines and player replies, plus everything the engine needs to stage it. Each node carries its text and voice-over, the camera framing and fades, the animations to play, and any script to run when it fires.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .dlg |
| Magic Signature | DLG / V3.2 |
| Type | Dialogue Blueprint |
| Rust Reference | View rakata_generics::Dlg in Rustdocs |
Field Schema
The format’s field families, as an orientation before the full list.
| 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 |
Engine Audits & Decompilation
Read from CSWSDialog::LoadDialog (0x005a2ae0), cascading through LoadDialogBase (0x0059f5f0) and LoadDialogCamera (0x0059eaa0) in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
The LoadDialog subroutine processes the root-level conversation configuration before iterating over the nested EntryList and ReplyList. For each of those nodes, it delegates parsing to LoadDialogBase (for text and scripts) and LoadDialogCamera (for viewport directions).
StartingList provides the dialogue entry points, while the StuntList associates cutscene actor models.
Root Conversation Configuration
| Field Category | Engine Property & Type | Notable Default or Behavioural Quirk |
|---|---|---|
| Identity & Rules | CameraModel (ResRef), DelayEntry/Reply (DWord) | CameraModel defaults to an empty resref. DelayEntry and DelayReply 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. Both fields independently default to an empty resref at their own read site: each read constructs its own fresh empty default immediately before the call, rather than carrying a value over from the other or from any prior state. |
Delay | DWord | Delay Special Case: If the value is 0xFFFFFFFF, the engine reads from the root DelayEntry/DelayReply field instead and modulates WaitFlags. The field’s own read-time default, confirmed directly against the read call, is a literal 0 rather than 0xFFFFFFFF, so an absent Delay does not by itself trigger the substitution; the file would need to write 0xFFFFFFFF explicitly to reach that path. |
FadeType | Byte | Determines the FadeDelay and FadeLength. If set to 0 or missing, every fade configuration is zeroed. |
Important
PlotIndexandPlotXPPercentageeach fall back to a plain, unconditional literal.PlotIndexreads with a fallback of0, not-1, a value that would look like a deliberate “no plot” sentinel but isn’t what the read call itself falls back to.PlotXPPercentagereads with a fallback of0.0, not1.0.Delay’s own fallback is documented above: a literal0, not0xFFFFFFFF. The confusion there is understandable, since0xFFFFFFFFis a real, meaningful sentinel for that field, just not the one the read call falls back to on absence.
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.
Warning
A node that carries
SoundExistscarries it six times. Where it appears in anEntryListorReplyListelement it is always present exactly six times, never fewer, and the six always agree on their value. It is the only duplicated label anywhere in the corpus: every other repeated label in every other resource type is a list element rather than a repeat within one struct.It is six field records sharing a single label-table entry, not six labels. The label table itself carries no duplicate, so a tool walking labels sees one and a tool walking a struct’s fields sees six.
Reading is unaffected and writing is not. Since the six agree, taking the first is correct for a reader. A writer that updates one copy leaves five holding the old value, and a file in that state has never been observed, so a tool producing one is producing something the game has never written.
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. The field’s own read-time default, confirmed against the read call, is a literal 0 rather than -1. That -1 is purely the CameraAngle != 6 post-processing result, which runs identically whether CameraID was present or fell back to that 0. |
CamFieldOfView | FLOAT | If the property is missing or 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 is 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. 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, since 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 are built out of link-lists pointing at each other.
- Entry -> Reply Links (
RepliesListwithin an Entry Node): Maps theIndex(DWORD) against the.ReplyListbounds. The only variant that parses theDisplayInactiveByte. - Reply -> Entry Links (
EntriesListwithin a Reply Node): Maps theIndexagainst 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, and the one value that gets through
Indexpaths are checked at load, before anything traverses them, and the same idiom covers all three link relationships: aRepliesListlink against the reply count,StartingListand aReplyListentry’sEntriesListagainst the entry count. A link that fails aborts the whole file, not just itself. The loader short-circuits its remaining work, cleans up, and returns failure, so one bad index costs the entire conversation.The bound is off by one, in the permissive direction. The test is
count < index, so an index exactly equal to the count passes: one element past the end of the array, admitted by the check that exists to keep it out.That value is not caught anywhere later either.
SendDialogRepliesreaches a reply by pointer arithmetic straight off the base of the reply block, with no bounds test of its own, and the block is allocated to hold exactlycountelements. Soindex == countreads one whole reply structure past the end of the allocation, and what comes back is whatever the heap has put there.For a tool this is the sharpest case on the page: a file carrying that one value loads cleanly, passes every check the engine makes, and misbehaves later during the conversation itself.
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” behaviour. Writing an explicit DisplayInactive = 0 is behaviourally identical to omitting the field outright, since both resolve to the same default, though it diverges from vanilla’s own convention of never writing the field at all.
Fields the Loader Never Reads: NumWords, VO_ID, IsChild, Comment, LinkComment
These fields appear in vanilla .dlg files carrying real values that no loader ever reads, and the evidence is stronger than “untraced”: none of their label strings exists anywhere in the executable, so no reader can reach them on any code path. They are unmodelled, so a Rakata round trip drops them.
The full write-up lives in Fields Vanilla Writes That the Engine Never Reads, alongside the same finding on other formats.
Ancillary Configuration Lists
- AnimList: Defines custom
Participantmodels and their accompanyingAnimation(WORD) action index to loop. - StuntList: Names which
StuntModelstands in for a givenParticipantduring the cutscene.
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 points past the end of the array it targets; this triggers a fatal engine load failure. - DLG-005 (Context Zeroing): Warns when
FadeDelay,FadeLength, orFadeColorare configured butFadeType=0; the engine discards the timings. - DLG-007 (Admitted Link Index): Errors when an
Indexequals the length of the array it targets. Separate from DLG-004 because the two fail in opposite ways: past the end the file will not open, while exactly at the end it opens, passes every check the loader makes, and reads one element past the allocation during the conversation. The engine’s own test iscount < indexand cannot reject this value.
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.
Every label the schema declares
Generated from the schema, so no label can be quietly left out. How to read these tables.
What the engine does with each field
| Field | Type | Engine | When absent |
|---|---|---|---|
EntryList[].Comment | CExoString | never reads it: the label string does not exist anywhere in the engine binary, so no read path can name it | NOT EXAMINED; we substitute "" |
EntryList[].RepliesList[].IsChild | BYTE | never reads it: the label string does not exist anywhere in the engine binary, so no read path can name it | NOT EXAMINED; we substitute 0 |
EntryList[].RepliesList[].LinkComment | CExoString | never reads it: the label string does not exist anywhere in the engine binary, so no read path can name it | NOT EXAMINED; we substitute "" |
ReplyList[].Comment | CExoString | never reads it: the label string does not exist anywhere in the engine binary, so no read path can name it | NOT EXAMINED; we substitute "" |
ReplyList[].EntriesList[].IsChild | BYTE | never reads it: the label string does not exist anywhere in the engine binary, so no read path can name it | NOT EXAMINED; we substitute 0 |
ReplyList[].EntriesList[].LinkComment | CExoString | never reads it: the label string does not exist anywhere in the engine binary, so no read path can name it | NOT EXAMINED; we substitute "" |
StartingList[].IsChild | BYTE | never reads it: the label string does not exist anywhere in the engine binary, so no read path can name it | NOT EXAMINED; we substitute 0 |
StartingList[].LinkComment | CExoString | never reads it: the label string does not exist anywhere in the engine binary, so no read path can name it | NOT EXAMINED; we substitute "" |
NumWords | DWORD | never reads it: the label string does not exist anywhere in the engine binary, so no read path can name it | NOT EXAMINED; we substitute 0 |
VO_ID | CExoString | never reads it: the label string does not exist anywhere in the engine binary, so no read path can name it | NOT EXAMINED; we substitute "" |
Fields nobody has examined
Whether the engine reads these has not been established, which is not the same as establishing that it does not. Where When absent carries an answer, that half is settled.
| Field | Type | When absent |
|---|---|---|
CameraModel | CResRef | stamps "" |
DelayEntry | DWORD | stamps 0 |
DelayReply | DWORD | stamps 0 |
EndConversation | CResRef | stamps "" |
EndConverAbort | CResRef | stamps "" |
Skippable | BYTE | stamps 1 |
ConversationType | INT | stamps 0 |
ComputerType | BYTE | stamps 0 |
AmbientTrack | CResRef | stamps "" |
UnequipItems | BYTE | stamps 0 |
UnequipHItem | BYTE | stamps 0 |
AnimatedCut | BYTE | stamps 0 |
OldHitCheck | BYTE | stamps 0 |
EntryList | List | NOT EXAMINED; we substitute container |
EntryList[].AnimList | List | NOT EXAMINED; we substitute container |
EntryList[].AnimList[].Participant | CExoString | NOT EXAMINED; we substitute "" |
EntryList[].AnimList[].Animation | WORD | NOT EXAMINED; we substitute 0 |
EntryList[].Text | CExoLocString | NOT EXAMINED; we substitute empty |
EntryList[].Script | CResRef | NOT EXAMINED; we substitute "" |
EntryList[].Speaker | CExoString | stamps "" |
EntryList[].WaitFlags | DWORD | stamps 0 |
EntryList[].Quest | CExoString | stamps "" |
EntryList[].QuestEntry | DWORD | stamps 0 |
EntryList[].PlotIndex | INT | stamps 0 |
EntryList[].PlotXPPercentage | FLOAT | stamps 0.0 |
EntryList[].Delay | DWORD | stamps 0 |
EntryList[].FadeType | BYTE | stamps 0 |
EntryList[].FadeColor | Vector3 | stamps (0.0, 0.0, 0.0) |
EntryList[].FadeDelay | FLOAT | stamps 0.0 |
EntryList[].FadeLength | FLOAT | stamps 0.0 |
EntryList[].Sound | CResRef | NOT EXAMINED; we substitute "" |
EntryList[].VO_ResRef | CResRef | stamps "" |
EntryList[].SoundExists | BYTE | stamps 128 |
EntryList[].Listener | CExoString | stamps "" |
EntryList[].CameraAngle | DWORD | stamps 0 |
EntryList[].CameraID | INT | stamps 0 |
EntryList[].CamHeightOffset | FLOAT | stamps 0.0 |
EntryList[].TarHeightOffset | FLOAT | stamps 0.0 |
EntryList[].CameraAnimation | WORD | stamps 0 |
EntryList[].CamVidEffect | INT | stamps -1 |
EntryList[].CamFieldOfView | FLOAT | stamps -1.0 |
EntryList[].RepliesList | List | NOT EXAMINED; we substitute container |
EntryList[].RepliesList[].Active | CResRef | stamps "" |
EntryList[].RepliesList[].Index | DWORD | stamps 0 |
EntryList[].RepliesList[].DisplayInactive | BYTE | stamps 0 |
ReplyList | List | NOT EXAMINED; we substitute container |
ReplyList[].AnimList | List | NOT EXAMINED; we substitute container |
ReplyList[].AnimList[].Participant | CExoString | NOT EXAMINED; we substitute "" |
ReplyList[].AnimList[].Animation | WORD | NOT EXAMINED; we substitute 0 |
ReplyList[].Text | CExoLocString | NOT EXAMINED; we substitute empty |
ReplyList[].Script | CResRef | NOT EXAMINED; we substitute "" |
ReplyList[].Speaker | CExoString | stamps "" |
ReplyList[].WaitFlags | DWORD | stamps 0 |
ReplyList[].Quest | CExoString | stamps "" |
ReplyList[].QuestEntry | DWORD | stamps 0 |
ReplyList[].PlotIndex | INT | stamps 0 |
ReplyList[].PlotXPPercentage | FLOAT | stamps 0.0 |
ReplyList[].Delay | DWORD | stamps 0 |
ReplyList[].FadeType | BYTE | stamps 0 |
ReplyList[].FadeColor | Vector3 | stamps (0.0, 0.0, 0.0) |
ReplyList[].FadeDelay | FLOAT | stamps 0.0 |
ReplyList[].FadeLength | FLOAT | stamps 0.0 |
ReplyList[].Sound | CResRef | NOT EXAMINED; we substitute "" |
ReplyList[].VO_ResRef | CResRef | stamps "" |
ReplyList[].SoundExists | BYTE | stamps 128 |
ReplyList[].Listener | CExoString | stamps "" |
ReplyList[].CameraAngle | DWORD | stamps 0 |
ReplyList[].CameraID | INT | stamps 0 |
ReplyList[].CamHeightOffset | FLOAT | stamps 0.0 |
ReplyList[].TarHeightOffset | FLOAT | stamps 0.0 |
ReplyList[].CameraAnimation | WORD | stamps 0 |
ReplyList[].CamVidEffect | INT | stamps -1 |
ReplyList[].CamFieldOfView | FLOAT | stamps -1.0 |
ReplyList[].EntriesList | List | NOT EXAMINED; we substitute container |
ReplyList[].EntriesList[].Active | CResRef | stamps "" |
ReplyList[].EntriesList[].Index | DWORD | stamps 0 |
StartingList | List | NOT EXAMINED; we substitute container |
StartingList[].Active | CResRef | stamps "" |
StartingList[].Index | DWORD | stamps 0 |
StuntList | List | NOT EXAMINED; we substitute container |
StuntList[].Participant | CExoString | NOT EXAMINED; we substitute "" |
StuntList[].StuntModel | CResRef | NOT EXAMINED; we substitute "" |
FAC Format (Faction & Reputation Table)
A .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. |
File Layout
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.
Field Schema
Every label the schema declares is tabulated at the end of this page, generated from the schema rather than written by hand.
Engine Audits & Decompilation
Read from the functions named below in swkotor.exe (K1 GOG build). Provenance: derived, not attested. The rows have not been separately re-derived, so they sit on the reverse-engineering queue. 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 is 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
Write 0xFFFFFFFF and never read it. Every faction in a real save carries that value, and nothing in the engine consults it: LoadFactionsFromSaveGame copies it onto the record with no comparison or branch, SaveFactions writes it straight back, and no reputation lookup, membership change or script-layer faction command touches it, nor anything else taking a CFactionManager* or CSWSFaction*. There is no parent-child traversal anywhere in this build.
It comes from LoadFactions, the fresh-game path that seeds factions out of repute.2da, which hardcodes 0xFFFFFFFF for every faction it creates. Nothing downstream changes it, so it survives every save and load after that: vestigial infrastructure for a hierarchy the engine never implements.
A file that omits the field loads as 0 instead, because the save-restore path reads through the ordinary ReadFieldDWORD with a 0 default while LoadFactions never calls a field reader at all. Since nothing reads either value the difference is cosmetic, but 0xFFFFFFFF is what every real save holds.
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 default to a literal 0 when absent from a present RepList entry. The write is gated on an asymmetric bounds check, so that shared 0 does opposite things:
| Missing field | Check | Outcome |
|---|---|---|
FactionID2 | must be strictly greater than 0 | 0 fails, and the entry’s write is skipped silently, as though the entry were not there |
FactionID1 | only needs to be a valid index | 0 passes, and the entry is written using faction index 0 as the row |
So an entry with a valid FactionID2 and a missing FactionID1 is not dropped. It overwrites whatever reputation pair sits at (faction 0, FactionID2). Same type, same default, opposite consequences.
FactionRep itself also defaults to a literal 0 when absent from a present, otherwise-valid entry, rather than 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.
Every label the schema declares
Generated from the schema, so no label can be quietly left out. How to read these tables.
Fields nobody has examined
Whether the engine reads these has not been established, which is not the same as establishing that it does not. Where When absent carries an answer, that half is settled.
| Field | Type | When absent |
|---|---|---|
FactionList | List | NOT EXAMINED; we substitute container |
FactionList[].FactionName | CExoString | stamps "" |
FactionList[].FactionParentID | DWORD | stamps 0 |
FactionList[].FactionGlobal | WORD | stamps 1 |
RepList | List | not one constant; we substitute container |
RepList[].FactionID1 | DWORD | stamps 0 |
RepList[].FactionID2 | DWORD | stamps 0 |
RepList[].FactionRep (required) | DWORD | stamps 0 |
GIT Format (Game Instance Template)
A .git file says where everything in an area is. Creatures, doors,
placeables, triggers, sound emitters, stores, encounters, waypoints, item piles
and cameras all get their position and orientation here.
If the .are file is the stage, the .git is the blueprint for its
actors. It does not describe what a creature is, only where one stands: for
the creature itself you want UTC, and the .git entry points at it.
GIT is built on GFF (Generic File Format), the engine’s labelled key/value tree. If you have not read the GFF page, start there.
This page documents GIT’s dual role, the same schema serving a static module’s sparse template-referencing placements and a savegame’s full inline snapshots, and each dispatcher’s absent-field defaults. Evidence is drawn from Ghidra decompilation of
swkotor.exe(K1 GOG build), cross-checked against a full K1 install’s vanilla.gitcorpus and a corpus of real save games. The tables below are lookup surfaces, meant to be searched rather than read start to end.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .git |
| Magic Signature | GIT / V3.2 |
| Type | Instance Blueprint |
| Rust Reference | View rakata_generics::Git in Rustdocs |
One schema, two jobs
This is the idea the whole page hangs off, and almost every oddity below is a consequence of it.
A GIT is written twice in a KOTOR install, by two different producers, for two different purposes:
- A module’s static
.gitplaces objects sparsely. Each element carries aTemplateResRefnaming a blueprint, plus the handful of fields that differ per placement: position, orientation, an id. The engine loads the blueprint and overlays those few fields on top. - A savegame’s GIT, bundled inside
SAVEGAME.sav, stores each object as a full self-contained snapshot, read field by field. NoTemplateResRef, no blueprint load. A field missing from one of these resolves to the engine’s hardcoded default, not to a blueprint value.
UseTemplates is the discriminator. A static file sets it to 1; a save omits
it entirely, and the loader’s default of 0 selects the snapshot path.
Two things follow. The same reader function often serves both paths, so a field documented as save-only is frequently live on a static placement too. And “absent” means different things on each side: on a static placement it usually means “the blueprint supplies this”, while on a snapshot it means “the engine’s constructor supplies this”.
Static .git files ship inside module archives, one per area, produced by the
Aurora toolset. Saved GITs live inside SAVEGAME.sav, one per module the
player has visited, written by the engine itself. See the
Save Game Deep Dive for how a save bundles
them.
One object type is effectively savegame-only. Area-of-effect objects
(AreaEffectList) are runtime spell and ability effects with no template at
all, so they appear in saved GITs rather than in static module layouts.
Field Schema
The format’s field families, as an orientation before the full list.
| Family | Covers | Representative fields |
|---|---|---|
| Root behaviour | Template-versus-inline mode and live weather state | UseTemplates, CurrentWeather, WeatherStarted |
| Object instance lists | One list per entity class | Creature List, Door List, TriggerList, SoundList |
| Per-instance placement | Template reference, position, orientation; naming varies by class | TemplateResRef, XPosition, Bearing, ObjectId |
| Saved snapshots | The full inline object each list holds when UseTemplates = 0 | SavedCreature, SavedDoor, SavedPlaceable, SavedTrigger |
| Area singletons | Stealth and ambient-audio state, plus the save-only minimap blob | AreaProperties, AreaMap |
Engine Audits & Decompilation
Read from CSWSArea::LoadGIT at 0x0050dd80 in swkotor.exe.
(Provenance: derived, not attested. These rows have not been separately re-derived, so they sit on the reverse-engineering queue. Individual claims say so inline where they rest on less than the rest.)
Reading an area
LoadGIT is a dispatcher. It reads a few root scalars, then hands each object
list to its own loader.
| Field | Type | Engine evaluation |
|---|---|---|
UseTemplates | BYTE | Selects whether object lists read TemplateResRef or read inline data. |
CurrentWeather | BYTE | Forced to 0xFF on interior areas. The absent-field default, applied before that override, is a fresh literal 0, unconditional: it overwrites whatever the object held rather than carrying over. |
WeatherStarted | BYTE | Forced to 0 on interior areas. Same mechanism as CurrentWeather. |
The engine validates the weather fields against the .are properties during
load.
An absent UseTemplates resolves to 0, the inline branch
SaveGIT never emits this field. It is present in every static .git file
from a retail install and absent from every saved GIT struct across a corpus of
real saves, so every save relies on the loader’s own default.
LoadGIT passes a literal 0 as the default at the read site itself, not a
value carried from a constructed field and not a local seeded earlier.
CResGFF::ReadFieldBYTE returns the caller’s default verbatim for an absent,
wrongly-typed or otherwise unresolvable field.
That 0 is widened and forwarded into the list loaders that take it: creatures,
items, doors, triggers, encounters, waypoints, sounds, placeables, stores and
area effects. Properties, maps and placeable cameras do not take it, being
always inline.
Every dispatch site branches on == 0, selecting the inline snapshot path,
with the else taking the template path. Any nonzero byte takes the template
branch, not specifically 1.
The value is not interchangeable. Because the test is == 0 rather than != 1,
any nonzero default would have landed on the template branch, and 0 is what
this loader uses for every other absent BYTE in the same function.
Each object list has its own struct id
An element’s kind is not carried by a field. It is the GFF
struct_id on the element record itself, and each list uses exactly
one value, with no list mixing ids and no id shared between lists.
| List | struct_id |
|---|---|
TriggerList | 1 |
Creature List | 4 |
WaypointList | 5 |
SoundList | 6 |
Encounter List | 7 |
Door List | 8 |
Placeable List | 9 |
StoreList | 11 |
AreaEffectList | 13 |
CameraList | 14, and nothing reads it |
List (item instances) | 0 |
Except where the table says otherwise, each value is the constant that list’s
loader tests against, so it says what the engine demands rather than what
vanilla happens to write. A trigger’s nested Geometry sub-list is gated the
same way, on 3.
The consequence of a wrong id is a silent skip, not a wrong load. Every gated loader has the same shape: compare the element’s id against the constant, and where it differs move to the next index without loading anything. No default, no diagnostic, no failure. The object is simply not in the area, and nothing says why.
A writer that leaves these at a default emits a file the container reads
without complaint and the engine does not load as intended. Note that item
instances legitimately use 0, so “unset” and “an item” are the same value.
Two nested lists are unchecked, and their writers disagree.
LoadEncounterGeometry and LoadEncounterSpawnPoints are reached by two
routes, through ReadEncounterFromGff and again through LoadEncounters’ own
override branch, and neither route asks an element for its type. Both walk by
list position and take every field by label, so it is one reader arrived at
twice rather than two to reconcile.
That leaves the id free, and the two writers spend it differently. A module’s
static .git puts 1 on every Geometry vertex and 2 on every
SpawnPointList entry. A savegame, written by CSWSEncounter::SaveEncounter,
puts each element’s own position there instead. Neither is wrong and neither has
an effect: an encounter restored from a save keeps its geometry.
LoadPlaceableCameras never reads an element’s struct id either. It walks
CameraList by position and takes each field by label, so 14 is what every
vanilla file writes and nothing enforces it. Keep writing it, since there is no
reason to write anything else. What that loader does check is the list’s
total length.
An absent list is a skip, not a clear
Confirmed uniformly across the CSWSArea::Load* dispatchers on this page
(LoadCreatures, LoadDoors, LoadPlaceables, LoadTriggers, LoadSounds,
LoadWaypoints, LoadStores, LoadItems, LoadEncounters,
LoadAreaEffects): each gates its whole per-entry body on the list actually
being found. When the list is absent that block never runs, and no call
anywhere clears, deallocates or resets the area’s existing object collection
first.
LoadPlaceableCameras is the sole exception; see CameraList.
| List | Loader | Engine triggers and fallbacks |
|---|---|---|
Creature List | LoadCreatures | Positions are validated defensively through ComputeSafeLocation bounds. |
Door List | LoadDoors | Save states trigger LoadObjectState. External templates route to LoadDoorExternal. |
WaypointList | LoadWaypoints | Ignores UseTemplates entirely and reads inline data only. Z-height is shifted via ComputeHeight. |
TriggerList | LoadTriggers | Geometry reuses the UTT layout. Carries the linkage arrays LinkedToModule, TransitionDestination, LinkedTo. |
LoadSounds (0x00505560) | Translates GeneratedType as a DWORD, then truncates it to a byte on save, discarding the upper 24 bits. | |
LoadEncounters (0x00505060) | Nested arrays reusing the Geometry and SpawnPointList layouts built for UTE boundaries. | |
LoadPlaceableCameras (0x00505eb0) | Client-side struct reading composite GFF spatial types. Rejects a list of 51 or more entries. | |
List (items) (0x00504de0) | The generically-named parent list carries item instances specifically. |
The saved-form fallbacks a corpus actually caught
Across the saved-GIT corpus, every reader fallback fires uniformly except for a
small set: the only saved-form paths where a scan caught the absent case in the
wild. UseTemplates above is the highest-stakes of
them. The rest are traced and settled on their own format pages, listed here so
the full set is visible from one place.
| Path | Behaviour | Traced on |
|---|---|---|
WaypointList.MapNote / MapNoteEnabled | An absent MapNote silently discards the whole HasMapNote/MapNoteEnabled/MapNote trio, leaving the waypoint at its constructed defaults | UTW |
Creature List.ClassList.KnownList0 | Absent or empty leaves that class with zero known powers, no abort. Within a present list, a power resolving to the 0xFFFF sentinel is dropped rather than stored as a zero power | UTC |
Creature List.ClassList.SpellsPerDayList | An absent list is a soft no-op, and only the first entry of a present list is ever applied | UTC |
Creature List.FeatList | An absent Feat on a present entry contributes nothing: a presence-chain abort scoped to that list position, not a defaulted 0 feat | UTC |
TriggerList.PortraitId | An unconditional literal 0xFFFF, routing to the string-Portrait branch | UTT |
Placeable List.PortraitId | The identical 0xFFFF literal, same branch, same conclusion as Trigger | UTP |
Encounter List.SpawnPointList | Only reloaded if present and non-empty; an absent list leaves spawn points as already built | UTE |
Placement fields
Position and orientation are named differently per class
The labels are hardcoded per entity class in swkotor.exe.
| Lists | Position | Orientation |
|---|---|---|
| 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 |
On several lists a static element carries two orientation components and a
saved one carries three. Across a full install, every Creature List,
WaypointList, StoreList and area List element carries XOrientation and
YOrientation and none carries ZOrientation; across a corpus of savegames,
every element of those same lists carries all three. TriggerList is the
exception and carries all three in both. A writer emitting the full vector onto
a static placement produces a label no shipped module file has.
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, which is 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 and use Positional and
RandomPosition flags plus RandomRangeX and RandomRangeY instead.
Warning
Orientation normalization Where a normalised orientation vector resolves to all zeroes, as it can in
StoreListorAreaEffectList, the engine catches the division and falls back to(0, 1, 0).
ObjectId has one default across every list, static or saved
ObjectId is not read by any per-type field loader (LoadDoor,
LoadPlaceable, LoadTrigger, the sound, store, encounter and item loaders,
LoadWaypoint). It is read exactly once per element by the area-level list
dispatcher, before that dispatcher branches into the static or the full-instance
path.
Every one of those 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.
The field-level default documented for AreaEffectList below is not a special
case, it is this general rule.
module.ifo’s Mod_Area_list
follows it too: its ObjectId read, gated behind the save-game flag, also
defaults to 0x7F000000.
Creatures are the exception, and there the field is dead rather than
defaulted. CSWSArea::LoadCreatures branches on UseTemplates before
touching the field rather than after, and the two branches differ in kind. The
save branch reads it like every other type, with
CResGFF::ReadFieldDWORD(..., "ObjectId", ..., 0x7F000000), so an absent field
genuinely defaults. The static branch issues no ReadField* call against
"ObjectId" anywhere: 0x7F000000 reaches the CSWSCreature constructor as a
bare literal.
Both branches land on the same number and only one of them is defaulting an
absent field. So whatever a static .git creature entry carries there has no
effect, the same never-looked-up status as UTC’s Tail and Wings.
On the write side every area-level list writer (CSWSArea::SaveCreatures,
SaveDoors, SavePlaceables, SaveTriggers, SaveSounds, SaveStores,
SaveItems, SaveWaypoints, SaveEncounters, SaveAreaEffects) adds the
list element, writes ObjectId straight off the live object’s id, and only then
delegates the rest to the per-type save function. The per-type function never
touches the label, just as the per-type loader never reads it.
Creatures are the one list whose two sides disagree. SaveCreatures writes
ObjectId with no branch around it, while the static-placement branch of
LoadCreatures never looks for it. Nothing the engine writes meets both, since
a save carries no UseTemplates and so always takes the branch that does read.
The mismatch is there for a hand-authored static .git.
The base-object fields come from one shared read
Commandable is not a per-type field, and neither are its neighbours.
CSWSObject::LoadObjectState reads them, and every GIT object loader calls it:
creatures, doors, placeables, waypoints, encounters, triggers, stores, items and
area effects, along with the path that restores the party. An absent
Commandable resolves to a literal 1.
That read happens only on the save-instance branch. It is gated the same way
as the effect list and the script variable tables loaded alongside it, so a
static placement’s load path never reaches the function. Which is why a module’s
.git carries none of these labels and a savegame carries all of them:
CSWSObject::SaveObjectState writes them unconditionally whenever anything is
saved, and nothing on the static side ever looks.
LoadSounds is the exception. It is the only dispatcher that never calls
LoadObjectState, so a sound object’s base state is not read back on either
branch. Why it differs has not been traced.
Fields that look like overrides and are not
Three labels on a placed instance invite the same wrong assumption, that the value there overrides the blueprint’s. Only one of them does.
Appearance is a real 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 already documented for the .utd and .utp blueprint reads.
For doors the resolved byte immediately keys doortypes.2da’s Model and
VisibleModel columns to pick the door’s mesh, so this is a genuine “which
visual model represents this object” field. 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.
LoadDoor and LoadPlaceable are the same shared readers regardless of which
path calls them. So a sparse, template-referencing door or placeable placement
carrying its own Appearance has that value read as an override on top of the
blueprint’s. Appearance is live on the static form, not exclusive to the
snapshot form, even though a static placement only sparsely carries Bearing,
position and ObjectId by convention.
Compare UTE’s Appearance, which shares
the label and is never read at all. A placed WaypointList[].Appearance
follows UTE’s dead pattern rather than this one: LoadWaypoint’s fully
decompiled field list has no room for it, confirmed directly (see
UTW). A waypoint has no rendered model to
select, so that is the expected outcome rather than a surprising one.
Description is toolset residue
Description is a real blueprint-level field on both UTD and
UTP, which the area loader never reads out of a placed instance’s own
GIT entry.
Traced through CSWSDoor::LoadDoorExternal and CSWSArea::LoadPlaceables. The
GIT-instance path resolves Description, and every other non-instance field,
from the referenced blueprint through LoadFromTemplate, and the four-field
door overlay (see UTD) does not
include it.
So a Description in a GIT door or placeable entry is written by the toolset
and never consulted, consistent with the corpus finding that almost every file
carries the field and only one holds a non-empty value.
It is not area-level metadata either: CSWSArea::LoadProperties, which reads
the GIT’s own AreaProperties, has no Description field at all.
This was confirmed for door and placeable entries specifically. Creature, item,
store, trigger, encounter, sound and camera entries were not individually
checked, so treat the same conclusion as likely but unproven for those.
Waypoints are a related but distinct case: a placed waypoint’s Description is
not residue with a source that goes unread, it has no source at all, because
waypoints never resolve a TemplateResRef (see
UTW).
The blueprint’s Tag always wins
UTD documents that a templated door’s
Tag comes from its .utd blueprint with no per-instance overlay, so two doors
sharing one blueprint share one tag. That is general engine behaviour rather
than a door-specific gap.
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 is 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 and geometry. - Trigger. Same pattern.
CSWSArea::LoadTriggersoverlaysTransitionDestination,LinkedTo,LinkedToModuleandLinkedToFlagsplus position and geometry back from the GIT instance after a template load, andTagis conspicuously not among them. - Store. Uses
ResRefrather thanTemplateResReffor templating, but does carry a genuine separateTag, read unconditionally byLoadStorethe same way.LoadStores’ post-template overlay covers only orientation and position.
The dead copy is not a rarity a reader can dismiss: every door placement in a
full install carries a Tag holding a real, non-empty value. It is a field
the toolset writes on every placement and the engine reads on none of them,
which is what makes it a trap rather than clutter.
Keep writing it anyway. A placement’s own Tag is dead exactly when the
placement is templated and live when it is not, so this is a conditional rather
than a field a writer may drop. Even where it is dead it is the only
per-placement identifier anything outside the engine has, which is why
fields the engine never reads
records it as the highest-cost drop on that page.
There is no engine-side protection anywhere against two placements sharing one
blueprint ending up with the same Tag, for any templated object type. Vanilla
avoids the collision purely by authoring convention, effectively one blueprint
per placed instance. A lint rule for this should be written generically across
every templating GIT object type rather than scoped to doors.
AreaProperties.EnvAudio has no consumer at all
The GIT’s AreaProperties struct carries an EnvAudio INT in most vanilla
files, and it is a different field from
ARE’s per-room EnvAudio: same name, different
struct, no engine consumer.
Neither CSWSArea::LoadProperties, which reads AreaProperties, nor its
ambient-sound delegate CSWSAmbientSound::Load, which reads MusicDelay,
MusicDay, MusicNight, MusicBattle, AmbientSndDay, AmbientSndNight,
AmbientSndDayVol and AmbientSndNitVol off that same struct, reads a field by
that name.
A binary-wide check rules out a missed function. The "EnvAudio" string
exists exactly once in the executable, with exactly one cross-reference, and
that reference is the ARE per-room reader.
It is plausibly a toolset habit, the area’s default or primary room EnvAudio
duplicated onto AreaProperties by the editor UI and never kept in sync, but
that is the strongest claim the evidence supports rather than a confirmed
mechanism. There is no absent-field default to report, since nothing looks for
the field.
Per-list findings
An encounter reads its whole field set either way
CSWSArea::LoadEncounters branches on UseTemplates the way the other
dispatchers do, into either CSWSEncounter::LoadEncounter or
LoadFromTemplate. Both arms then call ReadEncounterFromGff, the function
that actually reads an element, and it reads the same fields whichever one
called it. Those are its only two callers, neither narrows the field list, and
no branch inside it skips anything.
That field list runs well past the placement set. Alongside TemplateResRef,
position, orientation, Geometry and SpawnPointList, one pass also reads the
activation and difficulty scalars, LocalizedName and Tag, a CreatureList
whose elements carry a resref, a CR and a single-spawn flag, and a long run of
session state: spawn counts, heartbeat and last-spawn timestamps, entered and
left markers, started and exhausted flags, an AreaList and a SpawnList.
The generated table names each one.
So the difference between a module’s encounter and a save’s is not which code
runs over it. It is which labels the file carries. A static .git never has the
session-state labels written into it, so each takes its absent-field default
silently. A save’s GIT has them because the engine’s own writer put them there,
and they load back as live state.
Area-of-effect objects
AreaEffectList holds runtime spell and ability effect objects
(CSWSAreaOfEffectObject). These have no blueprint file: no loader anywhere in
the binary opens a template for one, so every field is read straight off the GIT
struct. Elements must carry struct id 13 or the loader skips them.
| 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. A field genuinely missing from a save still resolves to 0 on load. |
Shape | BYTE | 0 | 0 is a circle, 1 a rectangle. Any other value skips both dimension fields, so the effect gets no shape geometry at all. |
MetaMagicType | BYTE | 0 | |
SpellSaveDC | INT | 0 | Fresh objects start at 14; the 0 applies only when a save genuinely omits the field. |
SpellLevel | INT | 0 | |
Radius | FLOAT | 0.0 | Read and written only when Shape == 0. |
Length / Width | FLOAT | 0.0 each | Read and written only when Shape == 1. The pair round-trips symmetrically. |
CreatorId / LinkedToObject / LastEntered / LastLeft | DWORD | 0 each | Fresh objects use the 0x7F000000 placeholder; 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. Where 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,OnObjEnterandOnObjExitare written by the save writer and never read back by any loader in the binary. A restored area-of-effect object never fires these events again, and nothing re-derives them fromAreaEffectIdorSpellIdon the load path.That 2DA-driven derivation does exist (
vfx_persistent.2da, keyed byAreaEffectId, supplyingOnHeartbeat,OnObjEnterandOnObjExitscript resrefs plus shape and size defaults), but it is wired exclusively into the fresh spell-cast creation path. The GIT loader has no equivalent step.OnUserDefinedgoes further: it is never populated by any path, fresh creation included, so it is dead in this engine build regardless.A restored effect does not go inert. 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 rather than off scripts. It is specifically the scripted event hooks that go silent.
AreaProperties
Tracks stealth behaviour state and dynamic audio state. It reads
AmbientSndDayVol and AmbientSndNitVol and truncates their INT declarations
into a single runtime byte.
The loader and the save writer disagree on where several fields live. The
writer nests RestrictMode, StealthXPMax, StealthXPCurrent, StealthXPLoss,
StealthXPEnabled and SunFogColor inside the AreaProperties struct, but the
reader pulls those from the GIT’s top level instead. Only Unescapable is
genuinely read from inside AreaProperties.
Those fields are permanently dead in every save this engine’s own writer produces. They are always written to a location the reader never checks, so they always resolve to whatever value the object already held in memory.
TransPending, TransPendNextID and TransPendCurrID are written in both
places, the GIT top level and a redundant copy inside AreaProperties, but only
the top-level copy is consulted, so the AreaProperties copy is a harmless
duplicate. This is grounded in decompiled code with reasonably high confidence,
though inferred from variable-usage patterns rather than a byte-level
disassembly proof.
The ambient-sound fields read by CSWSAmbientSound::Load each carry over their
constructor’s pre-armed value when absent. This is a genuine carry-over with no
divergence between the constructed value and the read’s own fallback, unlike the
constructor-versus-literal mismatches found elsewhere in these audits.
| Field | Absent default |
|---|---|
MusicDelay | 5000 |
MusicDay | 2 |
MusicNight | 3 |
MusicBattle | 1 |
AmbientSndDay | 1 |
AmbientSndNight | 2 |
AmbientSndDayVol / AmbientSndNitVol | 0 each |
CameraList
Modelled as GitCamera entries, read by LoadPlaceableCameras. Position
defaults to the literal zero vector (0, 0, 0) and Orientation to the literal
identity quaternion (w=1, x=0, y=0, z=0; see
Vector4’s on-disk component order for which array slot w occupies),
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.
An absent field does not drop the camera entry. It is always kept and registered, each field defaulting independently at its own read site with no carry-over and no derivation from a sibling.
| Field | Absent value |
|---|---|
CameraID | -1 |
FieldOfView | 55.0 |
Pitch, Height, MicRange | 0.0 |
CameraID and FieldOfView are confirmed directly against the read call. An
entry left at CameraID = -1 is not treated as a sentinel by any comparison. It
is simply unreachable by the script-side camera-id lookup, which matches only
real positive ids.
The 51-entry cap costs the whole list, not the entries past it. The count
is checked once before anything is read, and a list of 51 or more makes the
function return having loaded nothing. So an area with 51 cameras has none
rather than fifty. It is the only whole-list validation among the placement
loaders; every other one gates per element and lets the rest through.
Warning
LoadPlaceableCamerasresets state on an absent list where every other dispatcher skips. The others gate their whole per-entry body on the list being found, so an absent list is a no-op. Confirmed independently for each.
LoadPlaceableCamerasnever checks that flag. It passes the resolved entry count,0whenCameraListis absent, intoCGuiInGame::InitializePlaceableCameras, which overwrites the client-side camera counter unconditionally on every call.The blast radius is narrow. That counter is transient session-scoped UI state on the
CGuiInGamesingleton rather than a persistent per-object list, and no per-camera data is touched, since the per-entrySetPlaceableCameracalls do not run at a count of0.The mechanism is still different from every other loader here, which matters if you are reasoning about what an absent list means across GIT generally. It is not uniform.
A placed store’s inventory
This reduces to the blueprint case by identity rather than analogy.
StoreList’s per-instance field reader is CSWSStore::LoadStore, the same
function UTM’s own load path confirms treats
an absent ItemList as a pure skip. There is no separate GIT-side ItemList
read or clear layered on top for a placed store instance.
The AreaMap exploration bitmap
The minimap’s explored-so-far state, bypassed entirely on a fresh load and read only from a save. It is the one struct here a save editor has to author rather than carry across, so the layout is given in full.
The measurements below come from the AreaMap structs in a save corpus of the
committed fixture folders plus a local backup set, read with a GFF navigator
written for the purpose. The install is modded, so nothing here describes
BioWare’s own content, but every result is structural or arithmetic and
transfers regardless.
Size
AreaMapDataSize carries no information the other two fields do not:
size_in_bytes = 4 * ceil( (ResX + 1) * (ResY + 1) / 32 )
That reproduces the declared size in every instance across every resolution pair present, and the declared size equals the blob’s actual length in each one.
The nearest alternative formulas were run on the same data as controls. Of the ten distinct resolution pairs, they matched five, three, one and one respectively, against this formula’s ten. So it is not a formula that fits because many would.
Grid
The cells are (ResX + 1) by (ResY + 1), one bit each. The two fields are
not the cell counts, and a reader sizing from ResX * ResY is short by a full
row and a full column and mis-indexes everything after the first row.
Three independent things say so: the size formula above; the fact that many
partly-explored blobs carry set bits past index ResX * ResY while none
carries one past (ResX+1) * (ResY+1); and the degenerate 1x1 areas, where
four declared cells sit in thirty-two bits of storage and every non-saturated
value puts all its set bits inside the low four positions.
Bit order and axis
Bit n is blob[n >> 3] >> (n & 7) & 1, and cell (col, row) is bit
row * (ResX + 1) + col. Byte-sequential, least significant bit first,
row-major.
That is settled by geometry rather than by arithmetic. Reshaping a partly-explored map row-major produces a single connected region, a corridor opening into a chamber; column-major produces noise with no connected structure. The strongest single case is a one-bit difference between two consecutive saves of the same area, which lands exactly on the ragged edge of the already-explored region and fills a one-cell notch. Under any other reading that bit falls in open space away from the frontier. Most-significant-bit-first was tested and is worse rather than better, pushing more blobs past the cell count.
Growth
Exploration is append-only. No consecutive pair of saves in the same area anywhere in the corpus loses a set bit, and the pairs that differ at all only ever gain them.
Note
Bits past the last cell are saturation, not layout The padding at the end of the allocation is the thing most likely to make a reader doubt the layout above. Some blobs do carry a set bit at or past the cell count. Every one of those is fully saturated, every bit in the allocation set, padding included, and no blob that is not saturated carries a single bit out there.
So a fully explored map is written as an all-ones fill across the whole allocation, which necessarily runs past the last cell. Nothing sits selectively in the padding.
A reader should mask to
(ResX+1) * (ResY+1)and ignore the tail. A writer filling a map may set the padding or not; the engine’s own output sets it.
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, so the saved form is the only form.Encounter Listis a plainVec<GitEncounter>, because the loader reads the same field set on both sides of the flag, so there are not two forms to tell apart. Its session state is declared and not modelled: the schema names those labels so a file carrying them does not read as unrecognised, and stops there, since the committed fixture saves hold no encounter for a model of the fields to check 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 above, and a test asserts the
list is still empty so a corpus that grows one does not go unchecked.
Implemented Linter Rules (Rakata-Lint)
Intra-resource rules needing no context, under rakata_lint::rules::git:
| Rule | Level | Fires when |
|---|---|---|
| GIT-001 Weather zeroing | Info | CurrentWeather != 0xFF or WeatherStarted is true; the engine forcibly zeros these on an interior area. |
| GIT-002 Camera array bounds | Error | CameraList holds 51 or more entries; immediate engine-level loader failure. |
| GIT-003 Stealth clamping | Warn | StealthXPCurrent > StealthXPMax; the engine clamps on evaluation. |
| GIT-004 Ambient volume truncation | Warn | AmbientSndDayVol or AmbientSndNitVol falls outside 0..=255; the engine truncates to a byte. |
GIT-005 Sound GeneratedType truncation | Warn | A sound’s GeneratedType exceeds 255; the engine truncates to a byte on save. Both forms of SoundList carry the field, so this applies to savegame content too. |
| GIT-007 Placement struct id | Error | A placement element’s struct_id is not the constant its list’s loader tests against. Covers the gated lists plus a trigger’s nested Geometry points on 3. CameraList is excluded because its loader never reads the value. Reads the raw GFF rather than the typed view, since a struct id belongs to the element record and does not survive from_gff. Weakest on the item List, whose constant 0 is also what an unset id holds. |
Resource-existence rules requiring a LintContext, under
rakata_lint::rules::git_range:
| Rule | Level | Fires when |
|---|---|---|
| GIT-006 Template resref existence | Warn | A per-instance TemplateResRef does not resolve to its expected typed template file. |
GIT-006 covers Creature List[].TemplateResRef (.utc), List[].TemplateResRef
(.uti), Door List[].TemplateResRef (.utd),
Placeable List[].TemplateResRef (.utp), SoundList[].TemplateResRef
(.uts), TriggerList[].TemplateResRef (.utt), StoreList[].ResRef (.utm)
and Encounter List[].TemplateResRef (.ute).
Waypoints have no template at all, since LoadWaypoint never reads a field
of that name under any circumstance. Doors do: a static door placement
resolves TemplateResRef against a .utd exactly as a creature or placeable
does (see UTD), so GitDoor carries
the field and the rule checks it.
Trigger LinkedToModule is deferred. The rule looks only at the static form of
each list, since a UseTemplates = 0 snapshot has no resref to resolve.
Open questions
-
The
AreaMaporigin corner. Which corner of the in-game minimap holds bit0, and whether the row index grows toward the top or the bottom of the player’s map. Everything measured above is invariant under reflection in either axis, so the saves cannot tell the four possibilities apart however many are read. That bounds what this corpus can settle; it does not bound the question. Tracked on #85.Two routes outside the bitmaps bear on it and neither has been tried. The game’s own minimap art ships in the GUI texture pack,
swpc_tex_gui.erf, as onelbl_map<area>texture per area, so a rendered blob can be laid against the picture the player sees. And the area’s ownMapsub-struct carriesNorthAxis,MapPt1X/MapPt2X,WorldPt1X/WorldPt2XandMapResX, which between them are the world-to-map transform this question is asking about. The art is not indexed bychitin.key, so searching the KEY file and the module archives comes back empty and reads as an absence; the texture packs are the family that search walks past.A save pair closes it outright if those routes fall short. Load an area with a large, mostly unexplored map and save. Walk into one identifiable region only, something describable as “the corridor along the west wall”, without fighting or looting. Save to a different slot. Diff the two, reshape the newly set bits row-major, and compare where the patch sits against where the region appeared on the minimap. That fixes the origin and both axis directions at once, and a second pair is needed only if the first region turns out symmetric in one axis.
What a tool can already do without it: author a map rather than merely preserve one. Size it, set arbitrary cells, and produce a blob the engine accepts and renders as a coherent region. What it cannot guarantee is that the region lands where the author intended rather than mirrored, flipped, or both. For “reveal the whole map”, the common editor feature, the origin does not matter at all, since saturation is what the engine itself writes and the corpus confirms it round-trips.
-
Why
LoadSoundsnever callsLoadObjectState. It is the only dispatcher that skips the shared base-object read, so a sound’sCommandableand its neighbours are not restored on either branch. The behaviour is confirmed; the reason is not traced. -
Descriptionon the unchecked list types. Confirmed toolset residue for doors and placeables. Creature, item, store, trigger, encounter, sound and camera entries were not individually checked. -
AreaPropertiesfield placement rests on variable-usage patterns. The loader-versus-writer disagreement is grounded in decompiled code with reasonably high confidence, but inferred rather than proven at byte level.
Every label the schema declares
Generated from the schema, so no label can be quietly left out. How to read these tables.
What the engine does with each field
| Field | Type | Engine | When absent |
|---|---|---|---|
WaypointList[].Appearance | BYTE | never reads it: a waypoint has no rendered model to select, and no waypoint load path reads the field | NOT EXAMINED; we substitute 0 |
WaypointList[].Description | CExoLocString | never reads it: a waypoint resolves no template, so this value has no source anywhere rather than one that goes unread | NOT EXAMINED; we substitute empty |
WaypointList[].LinkedTo | CExoString | never reads it: a waypoint has no transition capability, so there is nothing for a destination tag to name | NOT EXAMINED; we substitute "" |
WaypointList[].TemplateResRef | CResRef | never reads it: no waypoint load path reads it, including the script-spawn fallback, where the resref comes from the script call | NOT EXAMINED; we substitute "" |
Encounter List[].Active | BYTE | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].AreaListMaxSize | INT | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].AreaPoints | FLOAT | reads it | NOT EXAMINED; we substitute 0.0 |
Encounter List[].CurrentSpawns | INT | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].CustomScriptId | INT | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].Difficulty | INT | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].DifficultyIndex | INT | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].Exhausted | BYTE | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].Faction | DWORD | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].HeartbeatDay | DWORD | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].HeartbeatTime | DWORD | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].LastEntered | DWORD | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].LastLeft | DWORD | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].LastSpawnDay | DWORD | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].LastSpawnTime | DWORD | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].LocalizedName | CExoLocString | reads it | NOT EXAMINED; we substitute empty |
Encounter List[].MaxCreatures | INT | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].NumberSpawned | INT | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].OnEntered | CResRef | reads it | NOT EXAMINED; we substitute "" |
Encounter List[].OnExhausted | CResRef | reads it | NOT EXAMINED; we substitute "" |
Encounter List[].OnExit | CResRef | reads it | NOT EXAMINED; we substitute "" |
Encounter List[].OnHeartbeat | CResRef | reads it | NOT EXAMINED; we substitute "" |
Encounter List[].OnUserDefined | CResRef | reads it | NOT EXAMINED; we substitute "" |
Encounter List[].PlayerOnly | BYTE | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].RecCreatures | INT | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].Reset | BYTE | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].ResetTime | INT | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].Respawns | INT | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].SpawnOption | INT | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].SpawnPoolActive | FLOAT | reads it | NOT EXAMINED; we substitute 0.0 |
Encounter List[].Started | BYTE | reads it | NOT EXAMINED; we substitute 0 |
Encounter List[].Tag | CExoString | reads it | NOT EXAMINED; we substitute "" |
Encounter List[].AreaList | List | reads it | NOT EXAMINED; we substitute container |
Encounter List[].CreatureList | List | reads it | NOT EXAMINED; we substitute container |
Encounter List[].SpawnList | List | reads it | NOT EXAMINED; we substitute container |
Encounter List[].SpawnList[].SpawnResRef | CResRef | reads it | NOT EXAMINED; we substitute "" |
Encounter List[].SpawnList[].SpawnCR | FLOAT | reads it | NOT EXAMINED; we substitute 0.0 |
AreaEffectList[].OnHeartbeat | CResRef | writes it, never reads it back: the save writer emits it and no loader reads it back, so a restored effect never fires this hook again; the 2DA-driven re-derivation exists only on the fresh spell-cast path | not one constant; we substitute "" |
AreaEffectList[].OnUserDefined | CResRef | writes it, never reads it back: no path populates it, fresh creation included, so it is dead in this build regardless of load versus save | not one constant; we substitute "" |
AreaEffectList[].OnObjEnter | CResRef | writes it, never reads it back: the save writer emits it and no loader reads it back, so a restored effect never fires this hook again; the 2DA-driven re-derivation exists only on the fresh spell-cast path | not one constant; we substitute "" |
AreaEffectList[].OnObjExit | CResRef | writes it, never reads it back: the save writer emits it and no loader reads it back, so a restored effect never fires this hook again; the 2DA-driven re-derivation exists only on the fresh spell-cast path | not one constant; we substitute "" |
AreaProperties.StealthXPMax | DWORD | never reads it: the save writer nests it here but the reader pulls it from the GIT’s top level instead, so it always resolves to whatever the object already held | not one constant; we substitute 0 |
AreaProperties.StealthXPCurrent | DWORD | never reads it: the save writer nests it here but the reader pulls it from the GIT’s top level instead, so it always resolves to whatever the object already held | not one constant; we substitute 0 |
AreaProperties.StealthXPLoss | DWORD | never reads it: the save writer nests it here but the reader pulls it from the GIT’s top level instead, so it always resolves to whatever the object already held | not one constant; we substitute 0 |
AreaProperties.StealthXPEnabled | BYTE | never reads it: the save writer nests it here but the reader pulls it from the GIT’s top level instead, so it always resolves to whatever the object already held | not one constant; we substitute 0 |
AreaProperties.TransPending | BYTE | never reads it: written both here and at the GIT’s top level, and only the top-level copy is ever consulted | not one constant; we substitute 0 |
AreaProperties.TransPendNextID | BYTE | never reads it: written both here and at the GIT’s top level, and only the top-level copy is ever consulted | not one constant; we substitute 0 |
AreaProperties.TransPendCurrID | BYTE | never reads it: written both here and at the GIT’s top level, and only the top-level copy is ever consulted | not one constant; we substitute 0 |
AreaProperties.SunFogColor | DWORD | never reads it: the save writer nests it here but the reader pulls it from the GIT’s top level instead, so it always resolves to whatever the object already held | not one constant; we substitute 0 |
AreaProperties.EnvAudio | INT | never reads it: the only EnvAudio the engine reads is the ARE per-room field, a different field sharing the label | NOT EXAMINED; we substitute 0 |
Creature List | List | reads it | not one constant; we substitute container |
Door List[].Appearance | DWORD | reads it | keeps 0 |
Door List[].Tag | CExoString | never reads it: a templated placement takes its tag from the blueprint, which the placement cannot override | NOT EXAMINED; we substitute "" |
Placeable List[].Appearance | DWORD | reads it | stamps 0 |
SoundList[].Commandable | BYTE | writes it, never reads it back: CSWSObject::LoadObjectState is what reads this label and LoadSounds is the only area-level dispatcher that never calls it, so neither branch of a sound’s load reaches the read; the save writer emits it on every saved sound regardless | NOT EXAMINED; we substitute 0 |
TriggerList[].Tag | CExoString | never reads it: a templated placement takes its tag from the blueprint, which the placement cannot override | NOT EXAMINED; we substitute "" |
Fields nobody has examined
Whether the engine reads these has not been established, which is not the same as establishing that it does not. Where When absent carries an answer, that half is settled.
| Field | Type | When absent |
|---|---|---|
AreaMap | Struct | NOT EXAMINED; the field holds the absence |
AreaMap.AreaMapResX | INT | NOT EXAMINED; we substitute 0 |
AreaMap.AreaMapResY | INT | NOT EXAMINED; we substitute 0 |
AreaMap.AreaMapDataSize | DWORD | NOT EXAMINED; we substitute 0 |
AreaMap.AreaMapData | VOID | NOT EXAMINED; we substitute ```` |
CurrentWeather | BYTE | NOT EXAMINED; we substitute 0 |
WeatherStarted | BYTE | NOT EXAMINED; we substitute 0 |
WaypointList | List | not one constant; we substitute container |
WaypointList[].Tag | CExoString | stamps "" |
WaypointList[].LocalizedName | CExoLocString | stamps empty |
WaypointList[].Commandable | BYTE | NOT EXAMINED; the field holds the absence |
WaypointList[].XOrientation | FLOAT | not one constant; we substitute 0.0 |
WaypointList[].YOrientation | FLOAT | not one constant; we substitute 0.0 |
WaypointList[].ZOrientation | FLOAT | not one constant; the field holds the absence |
WaypointList[].ObjectId | DWORD | stamps 2130706432; we keep the absence instead |
WaypointList[].XPosition | FLOAT | stamps 0.0 |
WaypointList[].YPosition | FLOAT | stamps 0.0 |
WaypointList[].ZPosition | FLOAT | stamps 0.0 |
WaypointList[].HasMapNote | BYTE | keeps 0 |
WaypointList[].MapNote | CExoLocString | keeps empty |
WaypointList[].MapNoteEnabled | BYTE | stamps 0 |
Encounter List | List | not one constant; we substitute container |
Encounter List[].Geometry | List | not one constant; we substitute container |
Encounter List[].Geometry[].X | FLOAT | NOT EXAMINED; we substitute 0.0 |
Encounter List[].Geometry[].Y | FLOAT | NOT EXAMINED; we substitute 0.0 |
Encounter List[].Geometry[].Z | FLOAT | NOT EXAMINED; we substitute 0.0 |
Encounter List[].SpawnPointList | List | not one constant; we substitute container |
Encounter List[].SpawnPointList[].X | FLOAT | NOT EXAMINED; we substitute 0.0 |
Encounter List[].SpawnPointList[].Y | FLOAT | NOT EXAMINED; we substitute 0.0 |
Encounter List[].SpawnPointList[].Z | FLOAT | NOT EXAMINED; we substitute 0.0 |
Encounter List[].SpawnPointList[].Orientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
Encounter List[].ObjectId | DWORD | stamps 2130706432; we keep the absence instead |
Encounter List[].TemplateResRef | CResRef | NOT EXAMINED; the field holds the absence |
Encounter List[].XPosition | FLOAT | keeps 0.0 |
Encounter List[].YPosition | FLOAT | keeps 0.0 |
Encounter List[].ZPosition | FLOAT | keeps 0.0 |
Encounter List[].AreaListSize | INT | NOT EXAMINED; we substitute 0 |
Encounter List[].Commandable | BYTE | NOT EXAMINED; we substitute 0 |
Encounter List[].ActionList | List | NOT EXAMINED; we substitute container |
Encounter List[].VarTable | List | NOT EXAMINED; we substitute container |
Encounter List[].SWVarTable | Struct | NOT EXAMINED; we substitute container |
AreaEffectList | List | not one constant; we substitute container |
AreaEffectList[].Tag | CExoString | stamps "" |
AreaEffectList[].AreaEffectId | INT | stamps 0 |
AreaEffectList[].SpellId | DWORD | stamps 0 |
AreaEffectList[].SpellSaveDC | INT | stamps 0 |
AreaEffectList[].SpellLevel | INT | stamps 0 |
AreaEffectList[].MetaMagicType | BYTE | stamps 0 |
AreaEffectList[].CreatorId | DWORD | stamps 0 |
AreaEffectList[].LinkedToObject | DWORD | stamps 0 |
AreaEffectList[].LastEntered | DWORD | stamps 0 |
AreaEffectList[].LastLeft | DWORD | stamps 0 |
AreaEffectList[].Duration | DWORD | stamps 0 |
AreaEffectList[].DurationType | BYTE | stamps 0 |
AreaEffectList[].LastHrtbtDay | DWORD | stamps 0 |
AreaEffectList[].LastHrtbtTime | DWORD | stamps 0 |
AreaEffectList[].ObjectId | DWORD | stamps 2130706432 |
AreaEffectList[].OrientationX | FLOAT | not one constant; we substitute 0.0 |
AreaEffectList[].OrientationY | FLOAT | not one constant; we substitute 0.0 |
AreaEffectList[].OrientationZ | FLOAT | not one constant; we substitute 0.0 |
AreaEffectList[].PositionX | FLOAT | stamps 0.0 |
AreaEffectList[].PositionY | FLOAT | stamps 0.0 |
AreaEffectList[].PositionZ | FLOAT | stamps 0.0 |
AreaEffectList[].Shape | BYTE | stamps 0 |
AreaEffectList[].Radius | FLOAT | not one constant; we substitute 0.0 |
AreaEffectList[].Length | FLOAT | not one constant; we substitute 0.0 |
AreaEffectList[].Width | FLOAT | not one constant; we substitute 0.0 |
AreaProperties | Struct | NOT EXAMINED; the field holds the absence |
AreaProperties.Unescapable | BYTE | NOT EXAMINED; we substitute 0 |
AreaProperties.MusicDelay | INT | keeps 5000 |
AreaProperties.MusicDay | INT | keeps 2 |
AreaProperties.MusicNight | INT | keeps 3 |
AreaProperties.MusicBattle | INT | keeps 1 |
AreaProperties.AmbientSndDay | INT | keeps 1 |
AreaProperties.AmbientSndNight | INT | keeps 2 |
AreaProperties.AmbientSndDayVol | INT | keeps 0 |
AreaProperties.AmbientSndNitVol | INT | keeps 0 |
CameraList | List | not one constant; we substitute container |
CameraList[].CameraID | INT | stamps -1 |
CameraList[].Position | Vector3 | stamps (0.0, 0.0, 0.0) |
CameraList[].Orientation | Vector4 | stamps (1.0, 0.0, 0.0, 0.0) |
CameraList[].Pitch | FLOAT | stamps 0.0 |
CameraList[].Height | FLOAT | stamps 0.0 |
CameraList[].FieldOfView | FLOAT | stamps 55.0 |
CameraList[].MicRange | FLOAT | stamps 0.0 |
Creature List[].XPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
Creature List[].YPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
Creature List[].ZPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
Creature List[].XOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
Creature List[].YOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
Creature List[].ZOrientation | FLOAT | NOT EXAMINED; the field holds the absence |
Creature List[].TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].ObjectId | DWORD | stamps 2130706432 |
Creature List[].AIState | INT | NOT EXAMINED; we substitute 0 |
Creature List[].Age | INT | NOT EXAMINED; we substitute 0 |
Creature List[].AmbientAnimState | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Animation | INT | NOT EXAMINED; we substitute 0 |
Creature List[].Appearance_Head | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Appearance_Type | WORD | NOT EXAMINED; we substitute 0 |
Creature List[].AreaId | DWORD | NOT EXAMINED; we substitute 0 |
Creature List[].ArmorClass | SHORT | NOT EXAMINED; we substitute 0 |
Creature List[].BodyBag | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Cha | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].ChallengeRating | FLOAT | NOT EXAMINED; we substitute 0.0 |
Creature List[].ClassList | List | NOT EXAMINED; we substitute container |
Creature List[].ClassList[].Class | INT | NOT EXAMINED; we substitute 0 |
Creature List[].ClassList[].ClassLevel | SHORT | NOT EXAMINED; we substitute 0 |
Creature List[].ClassList[].KnownList0 | List | not one constant; we substitute container |
Creature List[].ClassList[].KnownList0[].Spell | WORD | NOT EXAMINED; we substitute 0 |
Creature List[].ClassList[].SpellsPerDayList | List | not one constant; we substitute container |
Creature List[].ClassList[].SpellsPerDayList[].NumSpellsLeft | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Color_Hair | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Color_Skin | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Color_Tattoo1 | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Color_Tattoo2 | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Commandable | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Con | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Conversation | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].CreatnScrptFird | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].CreatureSize | INT | NOT EXAMINED; we substitute 0 |
Creature List[].CurrentForce | SHORT | NOT EXAMINED; we substitute 0 |
Creature List[].CurrentHitPoints | SHORT | NOT EXAMINED; we substitute 0 |
Creature List[].DeadSelectable | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Deity | CExoString | NOT EXAMINED; we substitute "" |
Creature List[].Description | CExoLocString | NOT EXAMINED; we substitute empty |
Creature List[].DetectMode | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Dex | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Disarmable | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].DuplicatingHead | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Equip_ItemList | List | NOT EXAMINED; we substitute container |
Creature List[].Equip_ItemList[].TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].Equip_ItemList[].BodyVariation | BYTE | not one constant; the field holds the absence |
Creature List[].Equip_ItemList[].TextureVar | BYTE | not one constant; the field holds the absence |
Creature List[].Equip_ItemList[].Infinite | BYTE | not one constant; the field holds the absence |
Creature List[].Equip_ItemList[].AddCost | DWORD | keeps 0 |
Creature List[].Equip_ItemList[].BaseItem | INT | keeps 30 |
Creature List[].Equip_ItemList[].Charges | BYTE | stamps 50 |
Creature List[].Equip_ItemList[].Cost | DWORD | NOT EXAMINED; we substitute 0 |
Creature List[].Equip_ItemList[].DELETING | BYTE | keeps 0 |
Creature List[].Equip_ItemList[].DescIdentified | CExoLocString | keeps empty |
Creature List[].Equip_ItemList[].Description | CExoLocString | keeps empty |
Creature List[].Equip_ItemList[].Dropable | BYTE | stamps 0 |
Creature List[].Equip_ItemList[].Identified | BYTE | stamps 1 |
Creature List[].Equip_ItemList[].LocalizedName | CExoLocString | keeps empty |
Creature List[].Equip_ItemList[].MaxCharges | BYTE | not one constant; we substitute 0 |
Creature List[].Equip_ItemList[].ModelVariation | BYTE | not one constant; we substitute 0 |
Creature List[].Equip_ItemList[].NewItem | BYTE | keeps 0 |
Creature List[].Equip_ItemList[].NonEquippable | BYTE | keeps 0 |
Creature List[].Equip_ItemList[].Pickpocketable | BYTE | stamps 0 |
Creature List[].Equip_ItemList[].Plot | BYTE | keeps 0 |
Creature List[].Equip_ItemList[].PropertiesList | List | not one constant; we substitute container |
Creature List[].Equip_ItemList[].PropertiesList[].CostTable (required) | BYTE | whatever the memory held; we substitute 0 |
Creature List[].Equip_ItemList[].PropertiesList[].CostValue (required) | WORD | whatever the memory held; we substitute 0 |
Creature List[].Equip_ItemList[].PropertiesList[].Param1 (required) | BYTE | whatever the memory held; we substitute 0 |
Creature List[].Equip_ItemList[].PropertiesList[].Param1Value (required) | BYTE | whatever the memory held; we substitute 0 |
Creature List[].Equip_ItemList[].PropertiesList[].PropertyName (required) | WORD | whatever the memory held; we substitute 0 |
Creature List[].Equip_ItemList[].PropertiesList[].Subtype (required) | WORD | whatever the memory held; we substitute 0 |
Creature List[].Equip_ItemList[].PropertiesList[].ChanceAppear (required) | BYTE | whatever the memory held; we substitute 100 |
Creature List[].Equip_ItemList[].PropertiesList[].Useable | BYTE | not one constant; the field holds the absence |
Creature List[].Equip_ItemList[].PropertiesList[].UsesPerDay | BYTE | stamps 0 |
Creature List[].Equip_ItemList[].PropertiesList[].UpgradeType | BYTE | stamps 0; we keep the absence instead |
Creature List[].Equip_ItemList[].StackSize | WORD | keeps 1 |
Creature List[].Equip_ItemList[].Stolen | BYTE | keeps 0 |
Creature List[].Equip_ItemList[].Tag | CExoString | keeps "" |
Creature List[].Equip_ItemList[].Upgrades | DWORD | keeps 0 |
Creature List[].Equip_ItemList[].ObjectId | DWORD | stamps 2130706432; we keep the absence instead |
Creature List[].Equip_ItemList[].XPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
Creature List[].Equip_ItemList[].YPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
Creature List[].Equip_ItemList[].ZPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
Creature List[].Equip_ItemList[].XOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
Creature List[].Equip_ItemList[].YOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
Creature List[].Equip_ItemList[].ZOrientation | FLOAT | NOT EXAMINED; the field holds the absence |
Creature List[].Experience | DWORD | NOT EXAMINED; we substitute 0 |
Creature List[].FactionID | WORD | NOT EXAMINED; we substitute 0 |
Creature List[].FeatList | List | not one constant; we substitute container |
Creature List[].FeatList[].Feat | WORD | NOT EXAMINED; we substitute 0 |
Creature List[].FirstName | CExoLocString | NOT EXAMINED; we substitute empty |
Creature List[].ForcePoints | SHORT | NOT EXAMINED; we substitute 0 |
Creature List[].FortSaveThrow | CHAR | NOT EXAMINED; we substitute 0 |
Creature List[].Gender | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Gold | DWORD | NOT EXAMINED; we substitute 0 |
Creature List[].GoodEvil | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].HitPoints | SHORT | NOT EXAMINED; we substitute 0 |
Creature List[].Int | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Interruptable | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].IsDestroyable | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].IsPC | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].IsRaiseable | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].ItemList | List | NOT EXAMINED; we substitute container |
Creature List[].ItemList[].TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].ItemList[].BodyVariation | BYTE | not one constant; the field holds the absence |
Creature List[].ItemList[].TextureVar | BYTE | not one constant; the field holds the absence |
Creature List[].ItemList[].Infinite | BYTE | not one constant; the field holds the absence |
Creature List[].ItemList[].AddCost | DWORD | keeps 0 |
Creature List[].ItemList[].BaseItem | INT | keeps 30 |
Creature List[].ItemList[].Charges | BYTE | stamps 50 |
Creature List[].ItemList[].Cost | DWORD | NOT EXAMINED; we substitute 0 |
Creature List[].ItemList[].DELETING | BYTE | keeps 0 |
Creature List[].ItemList[].DescIdentified | CExoLocString | keeps empty |
Creature List[].ItemList[].Description | CExoLocString | keeps empty |
Creature List[].ItemList[].Dropable | BYTE | stamps 0 |
Creature List[].ItemList[].Identified | BYTE | stamps 1 |
Creature List[].ItemList[].LocalizedName | CExoLocString | keeps empty |
Creature List[].ItemList[].MaxCharges | BYTE | not one constant; we substitute 0 |
Creature List[].ItemList[].ModelVariation | BYTE | not one constant; we substitute 0 |
Creature List[].ItemList[].NewItem | BYTE | keeps 0 |
Creature List[].ItemList[].NonEquippable | BYTE | keeps 0 |
Creature List[].ItemList[].Pickpocketable | BYTE | stamps 0 |
Creature List[].ItemList[].Plot | BYTE | keeps 0 |
Creature List[].ItemList[].PropertiesList | List | not one constant; we substitute container |
Creature List[].ItemList[].PropertiesList[].CostTable (required) | BYTE | whatever the memory held; we substitute 0 |
Creature List[].ItemList[].PropertiesList[].CostValue (required) | WORD | whatever the memory held; we substitute 0 |
Creature List[].ItemList[].PropertiesList[].Param1 (required) | BYTE | whatever the memory held; we substitute 0 |
Creature List[].ItemList[].PropertiesList[].Param1Value (required) | BYTE | whatever the memory held; we substitute 0 |
Creature List[].ItemList[].PropertiesList[].PropertyName (required) | WORD | whatever the memory held; we substitute 0 |
Creature List[].ItemList[].PropertiesList[].Subtype (required) | WORD | whatever the memory held; we substitute 0 |
Creature List[].ItemList[].PropertiesList[].ChanceAppear (required) | BYTE | whatever the memory held; we substitute 100 |
Creature List[].ItemList[].PropertiesList[].Useable | BYTE | not one constant; the field holds the absence |
Creature List[].ItemList[].PropertiesList[].UsesPerDay | BYTE | stamps 0 |
Creature List[].ItemList[].PropertiesList[].UpgradeType | BYTE | stamps 0; we keep the absence instead |
Creature List[].ItemList[].StackSize | WORD | keeps 1 |
Creature List[].ItemList[].Stolen | BYTE | keeps 0 |
Creature List[].ItemList[].Tag | CExoString | keeps "" |
Creature List[].ItemList[].Upgrades | DWORD | keeps 0 |
Creature List[].ItemList[].ObjectId | DWORD | stamps 2130706432; we keep the absence instead |
Creature List[].ItemList[].XPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
Creature List[].ItemList[].YPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
Creature List[].ItemList[].ZPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
Creature List[].ItemList[].XOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
Creature List[].ItemList[].YOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
Creature List[].ItemList[].ZOrientation | FLOAT | NOT EXAMINED; the field holds the absence |
Creature List[].JoiningXP | INT | NOT EXAMINED; we substitute 0 |
Creature List[].LastName | CExoLocString | NOT EXAMINED; we substitute empty |
Creature List[].Listening | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].MClassLevUpIn | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].MaxForcePoints | SHORT | NOT EXAMINED; we substitute 0 |
Creature List[].MaxHitPoints | SHORT | NOT EXAMINED; we substitute 0 |
Creature List[].Min1HP | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].MovementRate | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].NaturalAC | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].NotReorienting | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].PM_IsDisguised | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].PartyInteract | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Phenotype | INT | NOT EXAMINED; we substitute 0 |
Creature List[].Plot | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].PortraitId | WORD | stamps 65535 |
Creature List[].PregameCurrent | SHORT | NOT EXAMINED; we substitute 0 |
Creature List[].Race | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].RefSaveThrow | CHAR | NOT EXAMINED; we substitute 0 |
Creature List[].ScriptAttacked | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].ScriptDamaged | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].ScriptDeath | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].ScriptDialogue | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].ScriptDisturbed | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].ScriptEndDialogu | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].ScriptEndRound | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].ScriptHeartbeat | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].ScriptOnBlocked | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].ScriptOnNotice | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].ScriptRested | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].ScriptSpawn | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].ScriptSpellAt | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].ScriptUserDefine | CResRef | NOT EXAMINED; we substitute "" |
Creature List[].SkillList | List | NOT EXAMINED; we substitute container |
Creature List[].SkillList[].Rank | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].SkillPoints | WORD | NOT EXAMINED; we substitute 0 |
Creature List[].SoundSetFile | WORD | NOT EXAMINED; we substitute 0 |
Creature List[].StartingPackage | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].StealthMode | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Str | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Subrace | CExoString | NOT EXAMINED; we substitute "" |
Creature List[].SubraceIndex | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Tag | CExoString | NOT EXAMINED; we substitute "" |
Creature List[].Tail | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].UseBackupHead | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].WillSaveThrow | CHAR | NOT EXAMINED; we substitute 0 |
Creature List[].Wings | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].Wis | BYTE | NOT EXAMINED; we substitute 0 |
Creature List[].fortbonus | SHORT | NOT EXAMINED; we substitute 0 |
Creature List[].refbonus | SHORT | NOT EXAMINED; we substitute 0 |
Creature List[].willbonus | SHORT | NOT EXAMINED; we substitute 0 |
List | List | not one constant; we substitute container |
List[].ObjectId | DWORD | stamps 2130706432; we keep the absence instead |
List[].XPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
List[].YPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
List[].ZPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
List[].XOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
List[].YOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
List[].ZOrientation | FLOAT | NOT EXAMINED; the field holds the absence |
List[].TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
List[].TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
List[].BodyVariation | BYTE | not one constant; the field holds the absence |
List[].TextureVar | BYTE | not one constant; the field holds the absence |
List[].Infinite | BYTE | not one constant; the field holds the absence |
List[].AddCost | DWORD | keeps 0 |
List[].BaseItem | INT | keeps 30 |
List[].Charges | BYTE | stamps 50 |
List[].Cost | DWORD | NOT EXAMINED; we substitute 0 |
List[].DELETING | BYTE | keeps 0 |
List[].DescIdentified | CExoLocString | keeps empty |
List[].Description | CExoLocString | keeps empty |
List[].Dropable | BYTE | stamps 0 |
List[].Identified | BYTE | stamps 1 |
List[].LocalizedName | CExoLocString | keeps empty |
List[].MaxCharges | BYTE | not one constant; we substitute 0 |
List[].ModelVariation | BYTE | not one constant; we substitute 0 |
List[].NewItem | BYTE | keeps 0 |
List[].NonEquippable | BYTE | keeps 0 |
List[].Pickpocketable | BYTE | stamps 0 |
List[].Plot | BYTE | keeps 0 |
List[].PropertiesList | List | not one constant; we substitute container |
List[].PropertiesList[].CostTable (required) | BYTE | whatever the memory held; we substitute 0 |
List[].PropertiesList[].CostValue (required) | WORD | whatever the memory held; we substitute 0 |
List[].PropertiesList[].Param1 (required) | BYTE | whatever the memory held; we substitute 0 |
List[].PropertiesList[].Param1Value (required) | BYTE | whatever the memory held; we substitute 0 |
List[].PropertiesList[].PropertyName (required) | WORD | whatever the memory held; we substitute 0 |
List[].PropertiesList[].Subtype (required) | WORD | whatever the memory held; we substitute 0 |
List[].PropertiesList[].ChanceAppear (required) | BYTE | whatever the memory held; we substitute 100 |
List[].PropertiesList[].Useable | BYTE | not one constant; the field holds the absence |
List[].PropertiesList[].UsesPerDay | BYTE | stamps 0 |
List[].PropertiesList[].UpgradeType | BYTE | stamps 0; we keep the absence instead |
List[].StackSize | WORD | keeps 1 |
List[].Stolen | BYTE | keeps 0 |
List[].Tag | CExoString | keeps "" |
List[].Upgrades | DWORD | keeps 0 |
Door List | List | not one constant; we substitute container |
Door List[].LinkedTo | CExoString | not one constant; we substitute "" |
Door List[].LinkedToFlags | BYTE | not one constant; we substitute 0 |
Door List[].LinkedToModule | CResRef | not one constant; we substitute "" |
Door List[].ObjectId | DWORD | stamps 2130706432; we keep the absence instead |
Door List[].TransitionDestin | CExoLocString | keeps empty |
Door List[].TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
Door List[].X | FLOAT | NOT EXAMINED; we substitute 0.0 |
Door List[].Y | FLOAT | NOT EXAMINED; we substitute 0.0 |
Door List[].Z | FLOAT | NOT EXAMINED; we substitute 0.0 |
Door List[].Bearing | FLOAT | NOT EXAMINED; we substitute 0.0 |
Door List[].TrapDetectable | BYTE | keeps 1 |
Door List[].TrapDisarmable | BYTE | keeps 1 |
Door List[].TrapOneShot | BYTE | keeps 1 |
Door List[].TrapType | BYTE | keeps 255 |
Door List[].TrapDetectDC | BYTE | keeps 0 |
Door List[].DisarmDC | BYTE | keeps 0 |
Door List[].TrapFlag | BYTE | keeps 0 |
Door List[].OnClosed | CResRef | keeps "default" |
Door List[].OnDamaged | CResRef | keeps "default" |
Door List[].OnDeath | CResRef | keeps "default" |
Door List[].OnDisarm | CResRef | keeps "default" |
Door List[].OnHeartbeat | CResRef | keeps "default" |
Door List[].OnLock | CResRef | keeps "default" |
Door List[].OnMeleeAttacked | CResRef | keeps "default" |
Door List[].OnOpen | CResRef | keeps "default" |
Door List[].OnSpellCastAt | CResRef | keeps "default" |
Door List[].OnTrapTriggered | CResRef | keeps "default" |
Door List[].OnUnlock | CResRef | keeps "default" |
Door List[].OnUserDefined | CResRef | keeps "default" |
Door List[].Commandable | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].Conversation | CResRef | NOT EXAMINED; we substitute "" |
Door List[].CurrentHP | SHORT | NOT EXAMINED; we substitute 0 |
Door List[].Description | CExoLocString | NOT EXAMINED; we substitute empty |
Door List[].Faction | DWORD | NOT EXAMINED; we substitute 0 |
Door List[].GenericType | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].HP | SHORT | NOT EXAMINED; we substitute 0 |
Door List[].Hardness | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].LoadScreenID | WORD | NOT EXAMINED; we substitute 0 |
Door List[].LocName | CExoLocString | NOT EXAMINED; we substitute empty |
Door List[].Min1HP | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].OnClick | CResRef | keeps "default" |
Door List[].OnDialog | CResRef | keeps "default" |
Door List[].OnFailToOpen | CResRef | keeps "default" |
Door List[].OpenState | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].Plot | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].SecretDoorDC | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].Static | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].X | FLOAT | NOT EXAMINED; we substitute 0.0 |
Door List[].Y | FLOAT | NOT EXAMINED; we substitute 0.0 |
Door List[].Z | FLOAT | NOT EXAMINED; we substitute 0.0 |
Door List[].Bearing | FLOAT | NOT EXAMINED; we substitute 0.0 |
Door List[].Fort | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].Ref | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].Will | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].Locked | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].Lockable | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].KeyRequired | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].KeyName | CExoString | NOT EXAMINED; we substitute "" |
Door List[].AutoRemoveKey | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].OpenLockDC | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].CloseLockDC | BYTE | NOT EXAMINED; we substitute 0 |
Door List[].Portrait | CResRef | NOT EXAMINED; we substitute "" |
Door List[].PortraitId | WORD | stamps 65535 |
Placeable List | List | not one constant; we substitute container |
Placeable List[].ObjectId | DWORD | stamps 2130706432; we keep the absence instead |
Placeable List[].TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
Placeable List[].X | FLOAT | NOT EXAMINED; we substitute 0.0 |
Placeable List[].Y | FLOAT | NOT EXAMINED; we substitute 0.0 |
Placeable List[].Z | FLOAT | NOT EXAMINED; we substitute 0.0 |
Placeable List[].Bearing | FLOAT | NOT EXAMINED; we substitute 0.0 |
Placeable List[].ItemList | List | not one constant; we substitute container |
Placeable List[].ItemList[].TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
Placeable List[].ItemList[].BodyVariation | BYTE | not one constant; the field holds the absence |
Placeable List[].ItemList[].TextureVar | BYTE | not one constant; the field holds the absence |
Placeable List[].ItemList[].Infinite | BYTE | not one constant; the field holds the absence |
Placeable List[].ItemList[].AddCost | DWORD | keeps 0 |
Placeable List[].ItemList[].BaseItem | INT | keeps 30 |
Placeable List[].ItemList[].Charges | BYTE | stamps 50 |
Placeable List[].ItemList[].Cost | DWORD | NOT EXAMINED; we substitute 0 |
Placeable List[].ItemList[].DELETING | BYTE | keeps 0 |
Placeable List[].ItemList[].DescIdentified | CExoLocString | keeps empty |
Placeable List[].ItemList[].Description | CExoLocString | keeps empty |
Placeable List[].ItemList[].Dropable | BYTE | stamps 0 |
Placeable List[].ItemList[].Identified | BYTE | stamps 1 |
Placeable List[].ItemList[].LocalizedName | CExoLocString | keeps empty |
Placeable List[].ItemList[].MaxCharges | BYTE | not one constant; we substitute 0 |
Placeable List[].ItemList[].ModelVariation | BYTE | not one constant; we substitute 0 |
Placeable List[].ItemList[].NewItem | BYTE | keeps 0 |
Placeable List[].ItemList[].NonEquippable | BYTE | keeps 0 |
Placeable List[].ItemList[].Pickpocketable | BYTE | stamps 0 |
Placeable List[].ItemList[].Plot | BYTE | keeps 0 |
Placeable List[].ItemList[].PropertiesList | List | not one constant; we substitute container |
Placeable List[].ItemList[].PropertiesList[].CostTable (required) | BYTE | whatever the memory held; we substitute 0 |
Placeable List[].ItemList[].PropertiesList[].CostValue (required) | WORD | whatever the memory held; we substitute 0 |
Placeable List[].ItemList[].PropertiesList[].Param1 (required) | BYTE | whatever the memory held; we substitute 0 |
Placeable List[].ItemList[].PropertiesList[].Param1Value (required) | BYTE | whatever the memory held; we substitute 0 |
Placeable List[].ItemList[].PropertiesList[].PropertyName (required) | WORD | whatever the memory held; we substitute 0 |
Placeable List[].ItemList[].PropertiesList[].Subtype (required) | WORD | whatever the memory held; we substitute 0 |
Placeable List[].ItemList[].PropertiesList[].ChanceAppear (required) | BYTE | whatever the memory held; we substitute 100 |
Placeable List[].ItemList[].PropertiesList[].Useable | BYTE | not one constant; the field holds the absence |
Placeable List[].ItemList[].PropertiesList[].UsesPerDay | BYTE | stamps 0 |
Placeable List[].ItemList[].PropertiesList[].UpgradeType | BYTE | stamps 0; we keep the absence instead |
Placeable List[].ItemList[].StackSize | WORD | keeps 1 |
Placeable List[].ItemList[].Stolen | BYTE | keeps 0 |
Placeable List[].ItemList[].Tag | CExoString | keeps "" |
Placeable List[].ItemList[].Upgrades | DWORD | keeps 0 |
Placeable List[].ItemList[].ObjectId | DWORD | stamps 2130706432; we keep the absence instead |
Placeable List[].ItemList[].XPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
Placeable List[].ItemList[].YPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
Placeable List[].ItemList[].ZPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
Placeable List[].ItemList[].XOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
Placeable List[].ItemList[].YOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
Placeable List[].ItemList[].ZOrientation | FLOAT | NOT EXAMINED; the field holds the absence |
Placeable List[].TrapDetectable | BYTE | stamps 0 |
Placeable List[].TrapDisarmable | BYTE | stamps 0 |
Placeable List[].TrapOneShot | BYTE | stamps 0 |
Placeable List[].TrapType | BYTE | keeps 255 |
Placeable List[].TrapDetectDC | BYTE | stamps 0 |
Placeable List[].DisarmDC | BYTE | stamps 0 |
Placeable List[].TrapFlag | BYTE | stamps 0 |
Placeable List[].OnClosed | CResRef | stamps "" |
Placeable List[].OnDamaged | CResRef | stamps "" |
Placeable List[].OnDeath | CResRef | stamps "" |
Placeable List[].OnDisarm | CResRef | stamps "" |
Placeable List[].OnHeartbeat | CResRef | stamps "" |
Placeable List[].OnLock | CResRef | stamps "" |
Placeable List[].OnMeleeAttacked | CResRef | stamps "" |
Placeable List[].OnOpen | CResRef | stamps "" |
Placeable List[].OnSpellCastAt | CResRef | stamps "" |
Placeable List[].OnTrapTriggered | CResRef | stamps "" |
Placeable List[].OnUnlock | CResRef | stamps "" |
Placeable List[].OnUserDefined | CResRef | stamps "" |
Placeable List[].Animation | INT | NOT EXAMINED; we substitute 0 |
Placeable List[].BodyBag | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].Commandable | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].Conversation | CResRef | NOT EXAMINED; we substitute "" |
Placeable List[].CurrentHP | SHORT | NOT EXAMINED; we substitute 0 |
Placeable List[].Description | CExoLocString | NOT EXAMINED; we substitute empty |
Placeable List[].DieWhenEmpty | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].Faction | DWORD | NOT EXAMINED; we substitute 0 |
Placeable List[].GroundPile | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].HP | SHORT | NOT EXAMINED; we substitute 0 |
Placeable List[].Hardness | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].HasInventory | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].IsBodyBag | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].IsBodyBagVisible | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].IsCorpse | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].LightState | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].LocName | CExoLocString | NOT EXAMINED; we substitute empty |
Placeable List[].Min1HP | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].OnDialog | CResRef | stamps "" |
Placeable List[].OnEndDialogue | CResRef | stamps "" |
Placeable List[].OnInvDisturbed | CResRef | stamps "" |
Placeable List[].OnUsed | CResRef | stamps "" |
Placeable List[].Open | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].PartyInteract | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].Plot | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].Static | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].Tag | CExoString | NOT EXAMINED; we substitute "" |
Placeable List[].Useable | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].X | FLOAT | NOT EXAMINED; we substitute 0.0 |
Placeable List[].Y | FLOAT | NOT EXAMINED; we substitute 0.0 |
Placeable List[].Z | FLOAT | NOT EXAMINED; we substitute 0.0 |
Placeable List[].Bearing | FLOAT | NOT EXAMINED; we substitute 0.0 |
Placeable List[].Fort | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].Ref | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].Will | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].Locked | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].Lockable | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].KeyRequired | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].KeyName | CExoString | NOT EXAMINED; we substitute "" |
Placeable List[].AutoRemoveKey | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].OpenLockDC | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].CloseLockDC | BYTE | NOT EXAMINED; we substitute 0 |
Placeable List[].Portrait | CResRef | stamps "" |
Placeable List[].PortraitId | WORD | stamps 65535 |
SoundList | List | not one constant; we substitute container |
SoundList[].GeneratedType | DWORD | NOT EXAMINED; we substitute 0 |
SoundList[].ObjectId | DWORD | stamps 2130706432; we keep the absence instead |
SoundList[].XPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
SoundList[].YPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
SoundList[].ZPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
SoundList[].TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
SoundList[].Active | BYTE | NOT EXAMINED; we substitute 0 |
SoundList[].Continuous | BYTE | NOT EXAMINED; we substitute 0 |
SoundList[].FixedVariance | FLOAT | NOT EXAMINED; we substitute 0.0 |
SoundList[].Hours | DWORD | NOT EXAMINED; we substitute 0 |
SoundList[].Interval | DWORD | NOT EXAMINED; we substitute 0 |
SoundList[].IntervalVrtn | DWORD | NOT EXAMINED; we substitute 0 |
SoundList[].Looping | BYTE | NOT EXAMINED; we substitute 0 |
SoundList[].MaxDistance | FLOAT | NOT EXAMINED; we substitute 0.0 |
SoundList[].MinDistance | FLOAT | NOT EXAMINED; we substitute 0.0 |
SoundList[].PitchVariation | FLOAT | NOT EXAMINED; we substitute 0.0 |
SoundList[].Positional | BYTE | NOT EXAMINED; we substitute 0 |
SoundList[].Random | BYTE | NOT EXAMINED; we substitute 0 |
SoundList[].RandomPosition | BYTE | NOT EXAMINED; we substitute 0 |
SoundList[].RandomRangeX | FLOAT | NOT EXAMINED; we substitute 0.0 |
SoundList[].RandomRangeY | FLOAT | NOT EXAMINED; we substitute 0.0 |
SoundList[].Sounds | List | NOT EXAMINED; we substitute container |
SoundList[].Sounds[].Sound | CResRef | NOT EXAMINED; we substitute "" |
SoundList[].Tag | CExoString | NOT EXAMINED; we substitute "" |
SoundList[].Times | BYTE | NOT EXAMINED; we substitute 0 |
SoundList[].Volume | BYTE | NOT EXAMINED; we substitute 0 |
SoundList[].VolumeVrtn | BYTE | NOT EXAMINED; we substitute 0 |
TriggerList | List | not one constant; we substitute container |
TriggerList[].LinkedTo | CExoString | not one constant; we substitute "" |
TriggerList[].LinkedToFlags | BYTE | not one constant; we substitute 0 |
TriggerList[].LinkedToModule | CResRef | not one constant; we substitute "" |
TriggerList[].ObjectId | DWORD | stamps 2130706432; we keep the absence instead |
TriggerList[].TransitionDestin | CExoLocString | keeps empty |
TriggerList[].XPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
TriggerList[].YPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
TriggerList[].ZPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
TriggerList[].XOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
TriggerList[].YOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
TriggerList[].ZOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
TriggerList[].Geometry | List | not one constant; we substitute container |
TriggerList[].Geometry[].PointX | FLOAT | NOT EXAMINED; we substitute 0.0 |
TriggerList[].Geometry[].PointY | FLOAT | NOT EXAMINED; we substitute 0.0 |
TriggerList[].Geometry[].PointZ | FLOAT | NOT EXAMINED; we substitute 0.0 |
TriggerList[].TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
TriggerList[].TrapDetectable | BYTE | stamps 0 |
TriggerList[].TrapDisarmable | BYTE | stamps 0 |
TriggerList[].TrapOneShot | BYTE | keeps 1 |
TriggerList[].TrapType | BYTE | keeps 255 |
TriggerList[].Geometry | List | not one constant; we substitute container |
TriggerList[].Geometry[].PointX | FLOAT | NOT EXAMINED; we substitute 0.0 |
TriggerList[].Geometry[].PointY | FLOAT | NOT EXAMINED; we substitute 0.0 |
TriggerList[].Geometry[].PointZ | FLOAT | NOT EXAMINED; we substitute 0.0 |
TriggerList[].AutoRemoveKey | BYTE | NOT EXAMINED; we substitute 0 |
TriggerList[].Commandable | BYTE | NOT EXAMINED; we substitute 0 |
TriggerList[].CreatorId | DWORD | NOT EXAMINED; we substitute 0 |
TriggerList[].Cursor | BYTE | NOT EXAMINED; we substitute 0 |
TriggerList[].Faction | DWORD | NOT EXAMINED; we substitute 0 |
TriggerList[].HighlightHeight | FLOAT | NOT EXAMINED; we substitute 0.0 |
TriggerList[].KeyName | CExoString | NOT EXAMINED; we substitute "" |
TriggerList[].LoadScreenID | WORD | NOT EXAMINED; we substitute 0 |
TriggerList[].LocalizedName | CExoLocString | NOT EXAMINED; we substitute empty |
TriggerList[].OnClick | CResRef | keeps "default" |
TriggerList[].OnDisarm | CResRef | keeps "default" |
TriggerList[].OnTrapTriggered | CResRef | keeps "default" |
TriggerList[].ScriptHeartbeat | CResRef | keeps "default" |
TriggerList[].ScriptOnEnter | CResRef | keeps "default" |
TriggerList[].ScriptOnExit | CResRef | keeps "default" |
TriggerList[].ScriptUserDefine | CResRef | keeps "default" |
TriggerList[].SetByPlayerParty | BYTE | NOT EXAMINED; we substitute 0 |
TriggerList[].Type | INT | NOT EXAMINED; we substitute 0 |
TriggerList[].Portrait | CResRef | stamps "" |
TriggerList[].PortraitId | WORD | stamps 65535 |
StoreList | List | not one constant; we substitute container |
StoreList[].ObjectId | DWORD | stamps 2130706432; we keep the absence instead |
StoreList[].XPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
StoreList[].YPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
StoreList[].ZPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
StoreList[].XOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
StoreList[].YOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
StoreList[].ZOrientation | FLOAT | NOT EXAMINED; the field holds the absence |
StoreList[].ResRef | CResRef | NOT EXAMINED; we substitute "" |
StoreList[].BuySellFlag | BYTE | keeps 3 |
StoreList[].Commandable | BYTE | NOT EXAMINED; we substitute 0 |
StoreList[].ItemList | List | not one constant; we substitute container |
StoreList[].ItemList[].TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
StoreList[].ItemList[].BodyVariation | BYTE | not one constant; the field holds the absence |
StoreList[].ItemList[].TextureVar | BYTE | not one constant; the field holds the absence |
StoreList[].ItemList[].Infinite | BYTE | not one constant; the field holds the absence |
StoreList[].ItemList[].AddCost | DWORD | keeps 0 |
StoreList[].ItemList[].BaseItem | INT | keeps 30 |
StoreList[].ItemList[].Charges | BYTE | stamps 50 |
StoreList[].ItemList[].Cost | DWORD | NOT EXAMINED; we substitute 0 |
StoreList[].ItemList[].DELETING | BYTE | keeps 0 |
StoreList[].ItemList[].DescIdentified | CExoLocString | keeps empty |
StoreList[].ItemList[].Description | CExoLocString | keeps empty |
StoreList[].ItemList[].Dropable | BYTE | stamps 0 |
StoreList[].ItemList[].Identified | BYTE | stamps 1 |
StoreList[].ItemList[].LocalizedName | CExoLocString | keeps empty |
StoreList[].ItemList[].MaxCharges | BYTE | not one constant; we substitute 0 |
StoreList[].ItemList[].ModelVariation | BYTE | not one constant; we substitute 0 |
StoreList[].ItemList[].NewItem | BYTE | keeps 0 |
StoreList[].ItemList[].NonEquippable | BYTE | keeps 0 |
StoreList[].ItemList[].Pickpocketable | BYTE | stamps 0 |
StoreList[].ItemList[].Plot | BYTE | keeps 0 |
StoreList[].ItemList[].PropertiesList | List | not one constant; we substitute container |
StoreList[].ItemList[].PropertiesList[].CostTable (required) | BYTE | whatever the memory held; we substitute 0 |
StoreList[].ItemList[].PropertiesList[].CostValue (required) | WORD | whatever the memory held; we substitute 0 |
StoreList[].ItemList[].PropertiesList[].Param1 (required) | BYTE | whatever the memory held; we substitute 0 |
StoreList[].ItemList[].PropertiesList[].Param1Value (required) | BYTE | whatever the memory held; we substitute 0 |
StoreList[].ItemList[].PropertiesList[].PropertyName (required) | WORD | whatever the memory held; we substitute 0 |
StoreList[].ItemList[].PropertiesList[].Subtype (required) | WORD | whatever the memory held; we substitute 0 |
StoreList[].ItemList[].PropertiesList[].ChanceAppear (required) | BYTE | whatever the memory held; we substitute 100 |
StoreList[].ItemList[].PropertiesList[].Useable | BYTE | not one constant; the field holds the absence |
StoreList[].ItemList[].PropertiesList[].UsesPerDay | BYTE | stamps 0 |
StoreList[].ItemList[].PropertiesList[].UpgradeType | BYTE | stamps 0; we keep the absence instead |
StoreList[].ItemList[].StackSize | WORD | keeps 1 |
StoreList[].ItemList[].Stolen | BYTE | keeps 0 |
StoreList[].ItemList[].Tag | CExoString | keeps "" |
StoreList[].ItemList[].Upgrades | DWORD | keeps 0 |
StoreList[].ItemList[].ObjectId | DWORD | stamps 2130706432; we keep the absence instead |
StoreList[].ItemList[].XPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
StoreList[].ItemList[].YPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
StoreList[].ItemList[].ZPosition | FLOAT | NOT EXAMINED; we substitute 0.0 |
StoreList[].ItemList[].XOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
StoreList[].ItemList[].YOrientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
StoreList[].ItemList[].ZOrientation | FLOAT | NOT EXAMINED; the field holds the absence |
StoreList[].LocName | CExoLocString | stamps empty |
StoreList[].MarkDown | INT | stamps 0 |
StoreList[].MarkUp | INT | stamps 0 |
StoreList[].OnOpenStore | CResRef | stamps "" |
StoreList[].Tag | CExoString | stamps "" |
UseTemplates | BYTE | stamps 0 |
VarTable | List | NOT EXAMINED; we substitute container |
GUI Format (Panel Layout)
A .gui file describes one screen of the game’s interface: a panel rectangle
and a flat list of controls with their tags, positions, borders, text and
colours. The in-game HUD, the equipment screen, the main menu and the message
boxes each have one.
GUI is built on GFF (Generic File Format), the engine’s labelled key/value tree. If you have not read the GFF page, start there.
This page documents what the shared panel loader reads, why the rest of the file reaches the engine only by name, and what a corpus census of every shipped
.guishows about which paths are structural. Evidence is drawn from Ghidra decompilation ofswkotor.exe(K1 GOG build), cross-checked against every.guiin a full install.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .gui |
| Magic Signature | GUI / V3.2 |
| Type | Interface panel layout |
| Rust Reference | No typed view. Read as a generic GFF tree via rakata_formats::Gff |
Population. The files live in data/gui.bif, indexed by chitin.key, plus
a handful in patch.erf. Most of the patch copies shadow resrefs that also
exist in gui.bif (abilities, character, equip, inventory,
mainmenu), and two appear nowhere else (mipc212x10, tooltip12x10). The
type occurs in no module RIM, no lips archive and no texture pack.
Every count on this page is measured over that population, and the census at the end gives it as a denominator, so the numbers there are the finding rather than decoration.
A .gui restyles a panel the binary already knows about
It cannot describe a new one. This is the fact the rest of the page depends on, and the format’s name works against it. There is no generic GUI loader that reads a control list and builds an interface from it.
What the shared panel loader does is narrow. It reads the panel’s extent,
colour, border and alpha, stores the CONTROLS list unparsed, and
instantiates nothing from it. Every actual screen in the game is a hand-written
C++ class with its controls declared as typed members at compile time. Each of
those classes asks for the controls it wants one at a time, by literal tag
string, and the lookup scans the stored list for an element whose TAG
matches, then hands that element to the member’s own loader.
So the file supplies values to a structure the executable already fixed:
- Add a control with a new tag and nothing will ask for it. Nothing in the executable is waiting for that name, and nothing walks the list looking for work. Your control is read into memory, never matched, and never drawn.
- Rename a control and the panel loses it. The screen still asks for the old name, finds nothing, and carries on without it.
- You cannot change what kind of control something is. The kind was decided when the game was built. The file does not get a vote.
- What you can change is real and substantial. Move and resize controls, restyle borders and fills, swap fonts, colours, text and alignment, repoint images. That is exactly what interface mods do, and it works because none of it touches the structure.
Important
No two
.guifiles have the same read set, so counting how often a label appears tells you nothing about whether it is used Which labels get read depends entirely on which screen opened the file, and each screen asks only for the controls it knows by name. A label sitting in a file that its screen never asks for is untouched in that file, while the same label is load-bearing in another file opened by another screen.So the census at the end of this page describes what artists wrote, not what the engine reads. The one table you can read as “the engine uses these” is the panel-level one below, because the shared loader takes those from every
.guiwhatever screen it belongs to.
Field Schema
| Family | Covers | Representative paths |
|---|---|---|
| Panel frame | The rectangle, fill and border the shared loader reads from every file | EXTENT, COLOR, BORDER, ALPHA |
| Control list | The flat list every screen looks into by tag | CONTROLS, CONTROLS[]/TAG, CONTROLS[]/ID |
| Per-control styling | Borders, highlight and selection states, text and font | BORDER, HILIGHT, SELECTED, TEXT |
| Image styling | How an image is drawn, flipped and rotated | IMAGE, ROTATE, FLIPSTYLE, DRAWSTYLE |
| Composite slots | Named sub-controls a listbox or scrollbar owns | PROTOITEM, SCROLLBAR, THUMB, PROGRESS |
Every label path any shipped file carries is tabulated at the end of this page. There is no schema to generate it from, so that table is a corpus census rather than a declaration.
Engine Audits & Decompilation
Read from CSWGuiPanel::StartLoadFromLayout at 0x0040a680, with the control
lookup and the listbox dispatch traced separately.
(Provenance: traced, except ALIGNMENT’s bitfield split, which is marked
inferred below. Corpus figures are measured over the population named
above.)
| Pipeline event | Engine behaviour |
|---|---|
| Panel load | Reads extent, colour, border or background, and alpha, then stores the CONTROLS list without walking it. |
| Control lookup | CSWGuiControl::Load (0x00418840) scans the stored list for an element whose TAG matches a requested string and hands it to the caller’s own loader. The requested string is a literal in the panel class. |
| Per-panel classes | Every screen class in the client reaches the control initialiser, each with its own hardcoded set of tags and its own pre-typed members. This is why there is no shared read set. |
| Listbox item template | CSWGuiListBox::LoadProtoItem (0x0041d3e0) is the sole CONTROLTYPE dispatch, with the default case creating nothing. |
| Scrollbar | CSWGuiListBox::Load (0x0041d5b0) loads the scrollbar into an already-typed member, so no dispatch occurs on that path. |
| Extent | CSWGuiExtent::Load (0x00409dc0) stores four plain integers. |
| Image fields | CSWGuiImage::Load (0x00416196) is where DRAWSTYLE, FLIPSTYLE, ROTATESTYLE and the ROTATE precedence live. |
What the shared panel loader reads
These are read for every file, before any panel class gets involved.
| Label | GFF type | Behaviour |
|---|---|---|
EXTENT | Struct | The panel rectangle |
EXTENT/LEFT, TOP, WIDTH, HEIGHT | INT | Default 0 each, kept exactly as written. Plain pixel counts rather than fractions, and nothing scales them on the way in. |
COLOR | Vector3 | |
BORDER | Struct | Falls back to BACKGROUND when absent. No shipped file carries a BACKGROUND; see below. |
ALPHA | FLOAT | 1.0 wherever it appears, which is every file but one. Not a default; see below. |
CONTROLS | List | Kept as-is for the tag lookups that come later. Nothing is built from it here. |
A small set of paths is carried by every file in the population: BORDER with
its CORNER, EDGE, FILL, DIMENSION and FILLSTYLE; CONTROLS;
CONTROLTYPE; EXTENT with its four members; Obj_Locked; and TAG.
Two rows above are observations rather than rules, where every other row gives a value a reader can rely on.
ALPHA has no established default. It holds 1.0 in every file that
carries it, and debug in data/gui.bif is the one file that omits it. So a
single shipped file exercises the case the table cannot answer, and 1.0 is
what the files show rather than what an absent field resolves to.
BACKGROUND is a real label, and it is a flat resref rather than a struct.
Where the panel’s BORDER sub-struct is absent, the loader reads BACKGROUND
as a CResRef defaulting to the empty string and passes it straight in as the
border’s fill image. Where BORDER is present it is never read at all, so this
is strictly the fallback rather than a field consulted alongside a full border
definition.
No shipped file takes that path. BACKGROUND appears nowhere in the census
further down, which covers every .gui in the install. The branch is real and
the data never reaches it, which is a different thing from a label that does not
exist.
CONTROLTYPE decides nothing except in a listbox item template
After all that, the field has one job and it is a narrow one. The only place the
value chooses anything is PROTOITEM, the template a listbox stamps out for
each of its rows. Here the file really does pick the kind of control, because a
listbox has any number of rows and the game cannot have named them all in
advance.
| Value | Produces |
|---|---|
4 | Label |
5 | Label with highlight |
6 | Button |
7 | Toggle button |
8 | Slider |
Anything else silently produces nothing. 0 through 3, and 9 upward,
make no control and no complaint. The template is left as it was, so a listbox
with an unrecognised PROTOITEM type comes out empty rather than broken, and
nothing tells you which of the two you are looking at.
Everywhere else the value does nothing. A SCROLLBAR goes into a slot whose
kind was already decided, so there is nothing for the value to choose. The files
agree: 9 shows up at CONTROLS[]/SCROLLBAR/CONTROLTYPE and nowhere else.
Across the corpus the attested values are 2 at the panel root, 4 through
8, 10 and 11 in the control list, 4 through 7 on PROTOITEM, and 9
on SCROLLBAR. 0, 1 and 3 occur nowhere.
Field results
ALIGNMENT
It packs two axes into one number rather than listing combinations. Every
value in the files, 9, 10, 12, 17, 18, 20 and 34, splits into
exactly one horizontal bit out of {1, 2, 4} plus exactly one vertical bit out
of {8, 16, 32}, and the engine has separate horizontal-only and vertical-only
setters beside the combined one.
The loader passes the whole number along untouched, so (provenance:
inferred) the split comes from the shape of the values and those setters.
Defaults vary by control: 0x12 on images, 9 on text.
TEXT/STRREF and TEXT/TEXT
They are alternatives, and no shipped control uses both. Both labels are
populated across the install, which makes it look as though a writer must know
which wins. Per control rather than per file: some TEXT structs carry a live
STRREF, some a non-empty TEXT string, most neither, and none carries
both. The precedence question is not merely undocumented, it is unexercised.
Populate one. A localized string goes in STRREF, a literal in TEXT.
Setting both puts the file outside anything the game’s own data does.
ROTATE and ROTATESTYLE
ROTATE wins outright where it is present, and ROTATESTYLE is then never
read at all. Where ROTATESTYLE is read, 0, 1, 2 and 3 mean 0, 90, 180
and 270 degrees, and anything else leaves the rotation wherever ROTATE
defaulted. Every shipped file holds 0, so this comes from the code rather than
the data.
ROTATE is in degrees, and the pairing is what proves it. The float is
stored into the image’s angle with no unit conversion anywhere on the path, and
ROTATESTYLE writes the same field using the literals 0.0, 90.0,
180.0 and 270.0. Those are unambiguously degrees and nothing scales between
the two paths, so the shared destination settles the unit by construction rather
than by the usual assumption about angles in a file format.
FLIPSTYLE and DRAWSTYLE
FLIPSTYLE is treated as bits whatever you write in it. The loader masks it
into a four-bit slot of the image’s flag word, which is true of the code rather
than of the values that ship. Shipped files hold 0.
DRAWSTYLE is read and passed along whole, with nothing masking or
splitting it, and is 0 in every file.
Obj_ParentID, and the three labels beside it
Obj_ParentID really does wire controls together. The shared control loader
reads it as an INT defaulting to -1, and where it is anything else it looks
up another control in the same panel by numeric ID and attaches the two as
parent and child. The -1 is an ordinary
equality sentinel, compared as signed. Most files carry it.
Important
No reader can exist for
Obj_Locked,Obj_ParentorObj_LayerSearch the whole executable and you findObj_ParentIDand not one of the other three. The engine’s GFF readers take a field name as a literal string, so a label appearing nowhere in the executable can never be asked for on any path. That is the strong form set out under the standard of evidence.
Obj_Lockedis in every single file, and a field that universal looks important. It is what the toolset writes every time and what the engine has no name for.
What the files look like
The census at the end sounds sprawling and is not. The shape repeats, and most of its length is the same handful of sub-structs recurring under different slots.
Controls nest by named slot, never by list. A panel holds one flat
CONTROLS list and no control holds a nested one. Two of the named slots are
themselves control-shaped, carrying their own CONTROLTYPE, EXTENT, BORDER
and TAG: PROTOITEM and SCROLLBAR. The rest, TEXT, HILIGHT, MOVETO,
PROGRESS, THUMB, and PROTOITEM’s own SELECTED and HILIGHTSELECTED,
carry no CONTROLTYPE and so are never a control in their own right.
Every CONTROLS element in every file has GFF struct_id 0, so the struct
id carries no type information here. Contrast GIT, where it
discriminates object kinds.
Value sets small enough to be enumerations rather than ranges:
| Path | Attested values |
|---|---|
FILLSTYLE | 0, 1, 2 |
BORDER/DIMENSION | 0, 1, 2, 4, 6, 16, 32 |
PULSING | 0, 1, 2 |
Obj_Locked | 0 or 1 |
TEXT/FONT | dialogfont10x10, dialogfont16x16, fnt_console, fnt_d16x16 |
Panel-level COLOR takes (-1, -1, -1) in some files, which has the shape of a
sentinel and is recorded here as a value.
One file carries an empty CONTROLS list, which is why every CONTROLS[]/...
path in the census sits one below the population total or lower.
Open questions
- Which of
TEXT/STRREFandTEXT/TEXTwins when both are set. No shipped control populates both, so the precedence is unexercised by the corpus as well as untraced. On the reverse-engineering queue. - Whether
DRAWSTYLEis an enumeration or a bitfield. It is read and passed along whole with nothing masking or splitting it, and holds0in every file, so neither the code nor the data distinguishes the two readings. - Whether
EXTENTvalues are rescaled downstream.CSWGuiExtent::Loadstores four plain integers and the stored value is a literal pixel count. What a later render stage does with them for the active resolution was not traced. ALIGNMENT’s bitfield split is inferred, from the shape of the attested values and the existence of separate per-axis setters, rather than traced through the loader.
Implemented Linter Rules (Rakata-Lint)
None yet. No rule currently reads this format.
Every label path
The complete set, so a reader does not have to infer it from the examples above.
Measured across every .gui in data/gui.bif and in patch.erf, which is the
population named at the top of this page.
The Files column is how many of those carry the path. One that appears in all of them is structural; one that appears in a single file is that screen’s own peculiarity. Telling those apart is most of what you need before writing a reader, and it is exactly what a list of examples cannot give you.
| Path | Type | Files |
|---|---|---|
ALPHA | FLOAT | 90 |
BORDER | Struct | 91 |
BORDER/COLOR | Vector3 | 85 |
BORDER/CORNER | CResRef | 91 |
BORDER/DIMENSION | INT | 91 |
BORDER/EDGE | CResRef | 91 |
BORDER/FILL | CResRef | 91 |
BORDER/FILLSTYLE | INT | 91 |
BORDER/INNEROFFSET | INT | 86 |
BORDER/PULSING | BYTE | 86 |
COLOR | Vector3 | 90 |
CONTROLS | List | 91 |
CONTROLS[]/BORDER | Struct | 90 |
CONTROLS[]/BORDER/COLOR | Vector3 | 85 |
CONTROLS[]/BORDER/CORNER | CResRef | 90 |
CONTROLS[]/BORDER/DIMENSION | INT | 90 |
CONTROLS[]/BORDER/EDGE | CResRef | 90 |
CONTROLS[]/BORDER/FILL | CResRef | 90 |
CONTROLS[]/BORDER/FILLSTYLE | INT | 90 |
CONTROLS[]/BORDER/INNEROFFSET | INT | 86 |
CONTROLS[]/BORDER/PULSING | BYTE | 86 |
CONTROLS[]/COLOR | Vector3 | 49 |
CONTROLS[]/CONTROLTYPE | INT | 90 |
CONTROLS[]/CURVALUE | INT | 17 |
CONTROLS[]/EXTENT | Struct | 90 |
CONTROLS[]/EXTENT/HEIGHT | INT | 90 |
CONTROLS[]/EXTENT/LEFT | INT | 90 |
CONTROLS[]/EXTENT/TOP | INT | 90 |
CONTROLS[]/EXTENT/WIDTH | INT | 90 |
CONTROLS[]/HILIGHT | Struct | 72 |
CONTROLS[]/HILIGHT/COLOR | Vector3 | 71 |
CONTROLS[]/HILIGHT/CORNER | CResRef | 72 |
CONTROLS[]/HILIGHT/DIMENSION | INT | 72 |
CONTROLS[]/HILIGHT/EDGE | CResRef | 72 |
CONTROLS[]/HILIGHT/FILL | CResRef | 72 |
CONTROLS[]/HILIGHT/FILLSTYLE | INT | 72 |
CONTROLS[]/HILIGHT/INNEROFFSET | INT | 72 |
CONTROLS[]/HILIGHT/PULSING | BYTE | 72 |
CONTROLS[]/HILIGHTSELECTED | Struct | 16 |
CONTROLS[]/HILIGHTSELECTED/COLOR | Vector3 | 16 |
CONTROLS[]/HILIGHTSELECTED/CORNER | CResRef | 16 |
CONTROLS[]/HILIGHTSELECTED/DIMENSION | INT | 16 |
CONTROLS[]/HILIGHTSELECTED/EDGE | CResRef | 16 |
CONTROLS[]/HILIGHTSELECTED/FILL | CResRef | 16 |
CONTROLS[]/HILIGHTSELECTED/FILLSTYLE | INT | 16 |
CONTROLS[]/HILIGHTSELECTED/INNEROFFSET | INT | 16 |
CONTROLS[]/HILIGHTSELECTED/PULSING | BYTE | 16 |
CONTROLS[]/ID | INT | 90 |
CONTROLS[]/ISSELECTED | BYTE | 16 |
CONTROLS[]/LEFTSCROLLBAR | BYTE | 49 |
CONTROLS[]/LOOPING | BYTE | 49 |
CONTROLS[]/MAXVALUE | INT | 17 |
CONTROLS[]/MOVETO | Struct | 70 |
CONTROLS[]/MOVETO/DOWN | INT | 70 |
CONTROLS[]/MOVETO/LEFT | INT | 70 |
CONTROLS[]/MOVETO/RIGHT | INT | 70 |
CONTROLS[]/MOVETO/UP | INT | 70 |
CONTROLS[]/Obj_Layer | INT | 1 |
CONTROLS[]/Obj_Locked | BYTE | 90 |
CONTROLS[]/Obj_Parent | CExoString | 90 |
CONTROLS[]/Obj_ParentID | INT | 83 |
CONTROLS[]/PADDING | INT | 49 |
CONTROLS[]/PARENTID | INT | 1 |
CONTROLS[]/PROGRESS | Struct | 12 |
CONTROLS[]/PROGRESS/COLOR | Vector3 | 12 |
CONTROLS[]/PROGRESS/CORNER | CResRef | 12 |
CONTROLS[]/PROGRESS/DIMENSION | INT | 12 |
CONTROLS[]/PROGRESS/EDGE | CResRef | 12 |
CONTROLS[]/PROGRESS/FILL | CResRef | 12 |
CONTROLS[]/PROGRESS/FILLSTYLE | INT | 12 |
CONTROLS[]/PROGRESS/INNEROFFSET | INT | 12 |
CONTROLS[]/PROGRESS/PULSING | BYTE | 12 |
CONTROLS[]/PROTOITEM | Struct | 49 |
CONTROLS[]/PROTOITEM/BORDER | Struct | 49 |
CONTROLS[]/PROTOITEM/BORDER/COLOR | Vector3 | 47 |
CONTROLS[]/PROTOITEM/BORDER/CORNER | CResRef | 49 |
CONTROLS[]/PROTOITEM/BORDER/DIMENSION | INT | 49 |
CONTROLS[]/PROTOITEM/BORDER/EDGE | CResRef | 49 |
CONTROLS[]/PROTOITEM/BORDER/FILL | CResRef | 49 |
CONTROLS[]/PROTOITEM/BORDER/FILLSTYLE | INT | 49 |
CONTROLS[]/PROTOITEM/BORDER/INNEROFFSET | INT | 47 |
CONTROLS[]/PROTOITEM/BORDER/PULSING | BYTE | 47 |
CONTROLS[]/PROTOITEM/CONTROLTYPE | INT | 49 |
CONTROLS[]/PROTOITEM/EXTENT | Struct | 49 |
CONTROLS[]/PROTOITEM/EXTENT/HEIGHT | INT | 49 |
CONTROLS[]/PROTOITEM/EXTENT/LEFT | INT | 49 |
CONTROLS[]/PROTOITEM/EXTENT/TOP | INT | 49 |
CONTROLS[]/PROTOITEM/EXTENT/WIDTH | INT | 49 |
CONTROLS[]/PROTOITEM/HILIGHT | Struct | 33 |
CONTROLS[]/PROTOITEM/HILIGHT/COLOR | Vector3 | 32 |
CONTROLS[]/PROTOITEM/HILIGHT/CORNER | CResRef | 33 |
CONTROLS[]/PROTOITEM/HILIGHT/DIMENSION | INT | 33 |
CONTROLS[]/PROTOITEM/HILIGHT/EDGE | CResRef | 33 |
CONTROLS[]/PROTOITEM/HILIGHT/FILL | CResRef | 33 |
CONTROLS[]/PROTOITEM/HILIGHT/FILLSTYLE | INT | 33 |
CONTROLS[]/PROTOITEM/HILIGHT/INNEROFFSET | INT | 32 |
CONTROLS[]/PROTOITEM/HILIGHT/PULSING | BYTE | 32 |
CONTROLS[]/PROTOITEM/HILIGHTSELECTED | Struct | 6 |
CONTROLS[]/PROTOITEM/HILIGHTSELECTED/COLOR | Vector3 | 6 |
CONTROLS[]/PROTOITEM/HILIGHTSELECTED/CORNER | CResRef | 6 |
CONTROLS[]/PROTOITEM/HILIGHTSELECTED/DIMENSION | INT | 6 |
CONTROLS[]/PROTOITEM/HILIGHTSELECTED/EDGE | CResRef | 6 |
CONTROLS[]/PROTOITEM/HILIGHTSELECTED/FILL | CResRef | 6 |
CONTROLS[]/PROTOITEM/HILIGHTSELECTED/FILLSTYLE | INT | 6 |
CONTROLS[]/PROTOITEM/HILIGHTSELECTED/INNEROFFSET | INT | 6 |
CONTROLS[]/PROTOITEM/HILIGHTSELECTED/PULSING | BYTE | 6 |
CONTROLS[]/PROTOITEM/ISSELECTED | BYTE | 6 |
CONTROLS[]/PROTOITEM/Obj_Parent | CExoString | 49 |
CONTROLS[]/PROTOITEM/Obj_ParentID | INT | 47 |
CONTROLS[]/PROTOITEM/SELECTED | Struct | 6 |
CONTROLS[]/PROTOITEM/SELECTED/COLOR | Vector3 | 6 |
CONTROLS[]/PROTOITEM/SELECTED/CORNER | CResRef | 6 |
CONTROLS[]/PROTOITEM/SELECTED/DIMENSION | INT | 6 |
CONTROLS[]/PROTOITEM/SELECTED/EDGE | CResRef | 6 |
CONTROLS[]/PROTOITEM/SELECTED/FILL | CResRef | 6 |
CONTROLS[]/PROTOITEM/SELECTED/FILLSTYLE | INT | 6 |
CONTROLS[]/PROTOITEM/SELECTED/INNEROFFSET | INT | 6 |
CONTROLS[]/PROTOITEM/SELECTED/PULSING | BYTE | 6 |
CONTROLS[]/PROTOITEM/TAG | CExoString | 49 |
CONTROLS[]/PROTOITEM/TEXT | Struct | 49 |
CONTROLS[]/PROTOITEM/TEXT/ALIGNMENT | INT | 49 |
CONTROLS[]/PROTOITEM/TEXT/COLOR | Vector3 | 49 |
CONTROLS[]/PROTOITEM/TEXT/FONT | CResRef | 49 |
CONTROLS[]/PROTOITEM/TEXT/PULSING | BYTE | 47 |
CONTROLS[]/PROTOITEM/TEXT/STRREF | DWORD | 49 |
CONTROLS[]/PROTOITEM/TEXT/TEXT | CExoString | 49 |
CONTROLS[]/SCROLLBAR | Struct | 49 |
CONTROLS[]/SCROLLBAR/BORDER | Struct | 49 |
CONTROLS[]/SCROLLBAR/BORDER/COLOR | Vector3 | 47 |
CONTROLS[]/SCROLLBAR/BORDER/CORNER | CResRef | 49 |
CONTROLS[]/SCROLLBAR/BORDER/DIMENSION | INT | 49 |
CONTROLS[]/SCROLLBAR/BORDER/EDGE | CResRef | 49 |
CONTROLS[]/SCROLLBAR/BORDER/FILL | CResRef | 49 |
CONTROLS[]/SCROLLBAR/BORDER/FILLSTYLE | INT | 49 |
CONTROLS[]/SCROLLBAR/BORDER/INNEROFFSET | INT | 47 |
CONTROLS[]/SCROLLBAR/BORDER/PULSING | BYTE | 47 |
CONTROLS[]/SCROLLBAR/CONTROLTYPE | INT | 49 |
CONTROLS[]/SCROLLBAR/CURVALUE | INT | 49 |
CONTROLS[]/SCROLLBAR/DIR | Struct | 49 |
CONTROLS[]/SCROLLBAR/DIR/ALIGNMENT | INT | 49 |
CONTROLS[]/SCROLLBAR/DIR/DRAWSTYLE | INT | 49 |
CONTROLS[]/SCROLLBAR/DIR/FLIPSTYLE | INT | 49 |
CONTROLS[]/SCROLLBAR/DIR/IMAGE | CResRef | 49 |
CONTROLS[]/SCROLLBAR/DIR/ROTATE | FLOAT | 48 |
CONTROLS[]/SCROLLBAR/DIR/ROTATESTYLE | INT | 1 |
CONTROLS[]/SCROLLBAR/DRAWMODE | BYTE | 48 |
CONTROLS[]/SCROLLBAR/EXTENT | Struct | 49 |
CONTROLS[]/SCROLLBAR/EXTENT/HEIGHT | INT | 49 |
CONTROLS[]/SCROLLBAR/EXTENT/LEFT | INT | 49 |
CONTROLS[]/SCROLLBAR/EXTENT/TOP | INT | 49 |
CONTROLS[]/SCROLLBAR/EXTENT/WIDTH | INT | 49 |
CONTROLS[]/SCROLLBAR/MAXVALUE | INT | 49 |
CONTROLS[]/SCROLLBAR/Obj_Parent | CExoString | 49 |
CONTROLS[]/SCROLLBAR/Obj_ParentID | INT | 47 |
CONTROLS[]/SCROLLBAR/TAG | CExoString | 49 |
CONTROLS[]/SCROLLBAR/THUMB | Struct | 49 |
CONTROLS[]/SCROLLBAR/THUMB/ALIGNMENT | INT | 49 |
CONTROLS[]/SCROLLBAR/THUMB/DRAWSTYLE | INT | 49 |
CONTROLS[]/SCROLLBAR/THUMB/FLIPSTYLE | INT | 49 |
CONTROLS[]/SCROLLBAR/THUMB/IMAGE | CResRef | 49 |
CONTROLS[]/SCROLLBAR/THUMB/ROTATE | FLOAT | 48 |
CONTROLS[]/SCROLLBAR/THUMB/ROTATESTYLE | INT | 1 |
CONTROLS[]/SCROLLBAR/VISIBLEVALUE | INT | 49 |
CONTROLS[]/SELECTED | Struct | 16 |
CONTROLS[]/SELECTED/COLOR | Vector3 | 16 |
CONTROLS[]/SELECTED/CORNER | CResRef | 16 |
CONTROLS[]/SELECTED/DIMENSION | INT | 16 |
CONTROLS[]/SELECTED/EDGE | CResRef | 16 |
CONTROLS[]/SELECTED/FILL | CResRef | 16 |
CONTROLS[]/SELECTED/FILLSTYLE | INT | 16 |
CONTROLS[]/SELECTED/INNEROFFSET | INT | 16 |
CONTROLS[]/SELECTED/PULSING | BYTE | 16 |
CONTROLS[]/STARTFROMLEFT | BYTE | 12 |
CONTROLS[]/TAG | CExoString | 90 |
CONTROLS[]/TEXT | Struct | 89 |
CONTROLS[]/TEXT/ALIGNMENT | INT | 89 |
CONTROLS[]/TEXT/COLOR | Vector3 | 88 |
CONTROLS[]/TEXT/FONT | CResRef | 89 |
CONTROLS[]/TEXT/PULSING | BYTE | 86 |
CONTROLS[]/TEXT/STRREF | DWORD | 89 |
CONTROLS[]/TEXT/TEXT | CExoString | 64 |
CONTROLS[]/THUMB | Struct | 5 |
CONTROLS[]/THUMB/ALIGNMENT | INT | 5 |
CONTROLS[]/THUMB/DRAWSTYLE | INT | 5 |
CONTROLS[]/THUMB/FLIPSTYLE | INT | 5 |
CONTROLS[]/THUMB/IMAGE | CResRef | 5 |
CONTROLS[]/THUMB/ROTATE | FLOAT | 5 |
CONTROLTYPE | INT | 91 |
EXTENT | Struct | 91 |
EXTENT/HEIGHT | INT | 91 |
EXTENT/LEFT | INT | 91 |
EXTENT/TOP | INT | 91 |
EXTENT/WIDTH | INT | 91 |
Obj_Layer | INT | 1 |
Obj_Locked | BYTE | 91 |
Obj_ParentID | INT | 83 |
TAG | CExoString | 91 |
IFO Format (Module Info Blueprint)
A .ifo file is a module’s own metadata: where the player enters, what the clock reads there, which areas belong to it, and which scripts fire on module-wide events. Inside a save it carries a second layer, the runtime snapshot of the session.
This page documents IFO’s field defaults, the save-game-only blocks gated behind
Mod_IsSaveGame, and the confirmed-dead NWN-era residue the toolset still writes. Evidence is drawn from Ghidra decompilation ofswkotor.exe(K1 GOG build), cross-checked against a full K1 install’s vanilla.ifocorpus and real save files. The tables below are lookup surfaces, meant to be searched rather than read start to end.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .ifo |
| Magic Signature | IFO / V3.2 |
| Type | Module Blueprint |
| Rust Reference | View rakata_generics::Ifo in Rustdocs |
Field Schema
The format’s field families, as an orientation before the full list.
| 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 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 |
Engine Audits & Decompilation
(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, plus SavePlayers (0x004c7870) and SaveLimboCreatures (0x004c5bb0). Provenance: derived, not attested, so these rows sit on the reverse-engineering queue.)
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, nor a save-only extension of the field with meaningful extra data. It is an artifact of how the engine reads and re-writes an opaque blob it never interprets.
The read is capped at 32 bytes and does not zero-pad
LoadModuleStart reads Mod_ID into a fixed 32-byte destination, with the read capped at 32 bytes whatever the field’s real length. A vanilla module’s 16 bytes therefore overwrite only the first half, and nothing zero-pads the rest. The buffer is allocated without zero-initialization, so the upper 16 bytes keep whatever the heap left there.
Mod_Creator_ID and Mod_Version sit beside Mod_ID in that same allocation. They are distinct GFF fields, adjacent as a memory-layout convenience rather than as part of Mod_ID.
The writer always emits 32 bytes back
It takes them 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 reads it back
Nothing reads Mod_ID back out. No comparison against the target module’s own file, no hash check, no “does this save belong to this module” validation, confirmed by an exhaustive check of every reference to the field and to the buffer it lands in. It is write-only round-trip data.
So a tool rewriting module info need not preserve its bytes for correctness. It should still read the field at whatever length it finds and not synthesize a 32-byte one on write, because the engine’s own 32 bytes are a side effect of that uninitialized buffer rather than a requirement of the format.
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. All three default to a literal 0.0 if missing, unconditional, independent of Mod_IsSaveGame. |
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, since 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 | How many minutes of gameplay make one module hour. Defaults to 0 if missing. |
Note
Day/Night Cycle Computations The engine derives the day/night phase from
Mod_DawnHour,Mod_DuskHourand the current hour, keeping the result in an internal flag:1Day,2Night,3Dawn,4Dusk.
Note
Mod_MinPerHour,Mod_DawnHour, andMod_DuskHoureach default to a literal0when absent, confirmed directly against the read call for all three. Not2,6, and18, values that would look plausible as a real-world minute count and typical dawn/dusk hours but aren’t what the engine actually falls back to.
Global Event Scripts
Each event is a single ResRef field naming a compiled script (.ncs) the engine fires when that event occurs. K1 defines these 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 rest, which are stored contiguously, matching the one-off handling. - Absent-field default, every hook. 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 is handled identically to its 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.
Save-only state
Mod_PlayerList, Mod_Tokens, VarTable, the EventQueue and the id counters sit inside the loader’s is_save_game block, so they are read only when the engine mounts a module out of a loaded .sav. In a static module they are skipped entirely.
Two rules cover the whole block.
No list drops a partially-specified entry. Across Mod_Area_list, Mod_Expan_List, Mod_CutSceneList, Mod_PlayerList and Mod_Tokens, an entry missing fields is still appended, with each missing field independently defaulted to a literal. Only a heap-allocation failure aborts a list, and that terminates the whole LoadModuleStart call.
An absent list container is a skip, not a clear. Mod_PlayerList, Mod_Tokens, VarTable and SWVarTable all resolve an empty count when the field is missing and never enter their read loop, leaving whatever the object already held. None of the four clears its in-memory list first, so omitting a container does not erase state.
VarTable
Read by CSWSScriptVarTable::LoadVarTable. Each entry is Name (CExoString, empty if absent), Type (DWORD, 0 if absent), and Value, whose read type comes from a switch on the resolved Type:
Type | Value read as | Absent default |
|---|---|---|
1 | INT | 0 |
2 | FLOAT | 0.0 |
3 | CExoString | empty |
4 | DWORD | 0 |
5 | nested location struct | its own sub-field defaults |
Type’s own absent default is 0, which matches no case, and the switch has no default arm. An entry whose Type is missing is silently a no-op.
Warning
CSWVarTable::LoadVarTableis a different function reading a different label It reads a nested struct labelledSWVarTableholding two capped lists,BitArrayat three entries andByteArrayat eight, each entry a singleVariablefield defaulting to0. This is legacy bit/byte-var infrastructure, unrelated to theVarTableabove despite the near-identical function name.
EventQueue
Read by CServerAIMaster::LoadEventQueue, each element handed to CServerAIEventNode::LoadNode, which reads five DWORD scalars: Day, Time, ObjectId, CallerId, EventId. All default to a literal 0.
ObjectId defaulting to 0 here is the one exception to the object-reference sentinel used everywhere else, and the cause is allocation rather than intent. CServerAIEventNode has no constructor and is allocated with a bare unzeroed operator new immediately before LoadNode runs, so there is no constructor step to set 0x7F000000 and the read’s own literal is the only initialization the field gets. Every other object-reference field traces back to a real constructor (see UTC’s LoadCreature divergence).
Once EventId resolves, a switch dispatches to type-specific EventData loaders for effects, combat data and script situations. Those nested defaults are not traced.
Mod_PlayerList
One struct per party member, written by SavePlayers: Mod_CommntyName, Mod_IsPrimaryPlr (BYTE), Mod_FirstName and Mod_LastName (localized), ObjectId, plus the member’s full creature serialization.
Each rebuilt node is placement-constructed empty first, so every field is an unconditional literal stamp: empty string, empty localized strings, and 0 for Mod_IsPrimaryPlr.
ObjectId is not read in that rebuild loop at all. It is read in SavePlayers’ carry-forward pass, which reopens the previous module’s Mod_PlayerList to pull limbo members forward. There an absent ObjectId defaults to 0x7F000000, and that default is load-bearing: the carry-forward code compares against that exact sentinel and drops the entry. An absent ObjectId on a carried-forward member means the member does not survive the module transition.
Mod_Area_list
The list supports arrays for NWN compatibility, but KOTOR takes element 0 and nothing else, reading Area_Name directly with an empty-ResRef default rather than looping.
The ObjectId inside this list is read only under the save-game gate, defaulting when absent to 0x7F000000, the engine-wide default for the label.
Runtime id counters
Mod_NextCharId0, Mod_NextCharId1, Mod_NextObjId0, Mod_NextObjId1 and Mod_Effect_NxtId persist the engine’s id allocators so a resumed session keeps handing out fresh ids. All five read with a literal 0 default and are written only by SaveModuleIFOStart.
None of them writes onto the module object. The four id fields write into fixed offsets on the engine’s shared global object-id allocator, reached through CServerExoApp::GetObjectArray, and Mod_Effect_NxtId writes a global symbol.
That makes an absent counter worse than an ordinary default gap: it zeroes the live shared allocator mid-load, affecting the whole session rather than one module.
The live clock
Beyond the authored Mod_StartYear/Month/Day/Hour, a save records the live time of day (Mod_StartMinute, Mod_StartSecond, Mod_StartMiliSec), the paused world clock (Mod_PauseDay, Mod_PauseTime) and Mod_Transition.
This block’s gate is not Mod_IsSaveGame. LoadModuleStart takes a separate parameter the client sets for “the player picked Load Game”. The two agree in practice and are structurally different conditions.
With that parameter set, all ten fields read with literal defaults: Mod_StartYear 1340, Mod_StartMonth 6, Mod_StartDay 1, Mod_StartHour 23, and 0 for the remaining six.
With it clear, on an ordinary module-to-module transition, none of the ten is read from the GFF and no default applies. The engine carries the live clock forward from the previous module through CServerExoApp::GetMoveToWorldTime and its paused-time counterparts. So these fields have a default only on a genuine save load; otherwise their values are session-carried.
Mod_StartMonth, Mod_StartDay and Mod_StartHour are also named asymmetrically: the writer sources them from where gameplay time currently stands, and the loader treats them as start values.
Mod_Tokens
Runtime overrides for custom TLK string tokens. Each entry carries Mod_TokensNumber (the index, default 0) and Mod_TokensValue (the replacement, default empty), both unconditional stamps.
Only entries with an index above 9 are written back out, since 0-9 are reserved.
Warning
The reserved range is protected on write and not on read
SetCustomTokendoes a plain indexed insert with no range check. An entry whoseMod_TokensNumberis absent defaults to index0and is installed there, overwriting a slot the writer itself treats as reserved. A hand-authored or corrupted entry lands on a reserved index without complaint.
Limbo creatures
Party members not in the active area are serialized into the module IFO by SaveLimboCreatures, each an ObjectId plus a full creature blob. The list reuses the label Creature List, which the GIT also uses for area creatures, so the same name appears in two containers. Note it takes that name bare, with no Mod_ prefix, unlike every other list the IFO owns.
Each element’s struct id must be 4 or the engine skips it, the same constant and the same silent skip GIT’s own Creature List uses. A wrong id costs a party member out of a save with nothing recording that anyone went missing, so a tool writing limbo creatures carries the same obligation as one writing area creatures.
The read itself is not save-gated. It sits outside the is_save_game block that guards Mod_PlayerList and its neighbours, and runs on every module load. An ordinary module carries no such list so nothing happens, but that is a fact about what vanilla writes rather than a gate in the loader: a static module that did carry one would have it read.
Mod_Hak
Hak packs are Neverwinter Nights override archives. SaveModuleIFOStart writes a Mod_Hak string into save games and LoadModuleStart never reads it, so the field cannot hook custom archives.
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 beside the documented Mod_Hak/Mod_IsNWMFile residue. None of the three label strings exists anywhere in swkotor.exe. GFF field access is string-literal lookup, so a label the binary never spells out cannot be read by LoadModuleStart or written by SaveModuleIFOStart.
That leaves nothing to document per field: no read call site to trace, and no absent-value default to give, because there is no read.
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_Listand already modelled, 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): Reports at info severity when
Mod_XPScale == 0. The engine parsesMod_XPScaleand never applies it to awarded XP, so the field is inert and a zero halts nothing. Zero is the trigger because it is the value somebody writes when they are trying to switch XP off, and the point of the diagnostic is to tell them the edit does nothing. - 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, which fails the module load. - IFO-005 (Dangling NWM Structure): Warns when
Mod_IsNWMFile=truewithoutMod_NWMResName; the gate opens on a name the file never supplies, leaving the module marked NWM with an empty resource name. - IFO-007 (Limbo Creature Struct Id): Errors when an element of the module’s own
Creature Listdoes not carry struct id4. Same constant and same silent skip as GIT’s area creatures, and a mismatch costs a party member out of a save with nothing recording it. Reads the raw GFF, since a struct id does not survivefrom_gff. Applies to any IFO carrying the list rather than to saves specifically: the read sits outside the save-game gate and runs on every module load.
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 anyMod_On*script hook (.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).
Every label the schema declares
Generated from the schema, so no label can be quietly left out. How to read these tables.
What the engine does with each field
| Field | Type | Engine | When absent |
|---|---|---|---|
Creature List | List | reads it | not one constant; we substitute container |
Fields nobody has examined
Whether the engine reads these has not been established, which is not the same as establishing that it does not. Where When absent carries an answer, that half is settled.
| Field | Type | When absent |
|---|---|---|
Mod_ID | VOID | NOT EXAMINED; we substitute ```` |
Mod_Creator_ID | INT | NOT EXAMINED; we substitute 0 |
Mod_Version | DWORD | NOT EXAMINED; we substitute 0 |
Mod_Name | CExoLocString | NOT EXAMINED; we substitute empty |
Mod_Description | CExoLocString | NOT EXAMINED; we substitute empty |
Mod_Tag | CExoString | stamps "" |
Mod_IsSaveGame | BYTE | keeps 0 |
Mod_IsNWMFile | BYTE | keeps 0 |
Mod_NWMResName | CExoString | keeps "" |
Mod_StartMovie | CResRef | stamps "" |
Mod_Entry_Area | CResRef | NOT EXAMINED; we substitute "" |
Mod_Entry_X | FLOAT | NOT EXAMINED; we substitute 0.0 |
Mod_Entry_Y | FLOAT | NOT EXAMINED; we substitute 0.0 |
Mod_Entry_Z | FLOAT | NOT EXAMINED; we substitute 0.0 |
Mod_Entry_Dir_X | FLOAT | not one constant; we substitute 0.0 |
Mod_Entry_Dir_Y | FLOAT | not one constant; we substitute 0.0 |
Mod_MinPerHour | BYTE | stamps 0 |
Mod_DawnHour | BYTE | stamps 0 |
Mod_DuskHour | BYTE | stamps 0 |
Mod_XPScale | BYTE | stamps 10 |
Mod_StartYear | DWORD | stamps 1340 |
Mod_StartMonth | BYTE | stamps 6 |
Mod_StartDay | BYTE | stamps 1 |
Mod_StartHour | BYTE | stamps 23 |
Mod_Transition | DWORD | stamps 0 |
Mod_StartMinute | WORD | stamps 0 |
Mod_StartSecond | WORD | stamps 0 |
Mod_StartMiliSec | WORD | stamps 0 |
Mod_PauseTime | DWORD | stamps 0 |
Mod_PauseDay | DWORD | stamps 0 |
Mod_Effect_NxtId | DWORD64 | stamps 0 |
Mod_NextCharId0 | DWORD | stamps 0 |
Mod_NextCharId1 | DWORD | stamps 0 |
Mod_NextObjId0 | DWORD | stamps 0 |
Mod_NextObjId1 | DWORD | stamps 0 |
Mod_Hak | CExoString | NOT EXAMINED; we substitute "" |
Mod_OnHeartbeat | CResRef | stamps "" |
Mod_OnUsrDefined | CResRef | stamps "" |
Mod_OnModLoad | CResRef | stamps "" |
Mod_OnModStart | CResRef | stamps "" |
Mod_OnClientEntr | CResRef | stamps "" |
Mod_OnClientLeav | CResRef | stamps "" |
Mod_OnActvtItem | CResRef | stamps "" |
Mod_OnAcquirItem | CResRef | stamps "" |
Mod_OnUnAqreItem | CResRef | stamps "" |
Mod_OnPlrDeath | CResRef | stamps "" |
Mod_OnPlrDying | CResRef | stamps "" |
Mod_OnSpawnBtnDn | CResRef | stamps "" |
Mod_OnPlrRest | CResRef | stamps "" |
Mod_OnPlrLvlUp | CResRef | stamps "" |
Mod_OnEquipItem | CResRef | stamps "" |
Mod_Expan_List | List | NOT EXAMINED; we substitute container |
Mod_Expan_List[].Expansion_Name | CExoLocString | stamps empty |
Mod_Expan_List[].Expansion_ID | INT | stamps 0 |
Mod_CutSceneList | List | the page does not say; we substitute container |
Mod_CutSceneList[].CutScene_Name | CResRef | stamps "" |
Mod_CutSceneList[].CutScene_ID | DWORD | stamps 0 |
Mod_Area_list | List | the page does not say; we substitute container |
Mod_Area_list[].Area_Name | CResRef | stamps "" |
Mod_Area_list[].ObjectId | DWORD | stamps 2130706432 |
Mod_PlayerList | List | not one constant; we substitute container |
Mod_PlayerList[].Mod_CommntyName | CExoString | stamps "" |
Mod_PlayerList[].Mod_FirstName | CExoLocString | stamps empty |
Mod_PlayerList[].Mod_LastName | CExoLocString | stamps empty |
Mod_PlayerList[].Mod_IsPrimaryPlr | BYTE | stamps 0 |
Mod_Tokens | List | not one constant; we substitute container |
Mod_Tokens[].Mod_TokensNumber | DWORD | stamps 0 |
Mod_Tokens[].Mod_TokensValue | CExoString | stamps "" |
SWVarTable | Struct | not one constant; we substitute container |
VarTable | List | not one constant; we substitute container |
JRL Format (Journal)
A .jrl file is the quest journal: a list of categories, each a quest with its own list of numbered entries. Entry text is what the player reads in the journal UI, and the entry number a script sets is what selects which text is shown and whether the quest reads as finished.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .jrl |
| Magic Signature | JRL / V3.2 |
| Type | Quest journal |
| Rust Reference | No typed view. Read as a generic GFF tree via rakata_formats::Gff. |
Population: four files in a retail install, so they are named rather than counted. global in data/_newbif.bif, and a module.jrl in each of modules/tar_m02ab_s.rim, modules/tar_m02ad_s.rim and modules/tar_m04aa_s.rim. The last of those carries an empty Categories list and nothing else.
Warning
A module’s own
.jrlis never opened. Onlyglobal.jrlis. Every quest-state change in the game funnels through one function, and the journal it opens is built from the literal stringGlobal. Not the current module’s resref, not a parameter, not a lookup: a constant. No branch in that function can open any other.jrl.So the three module journals shipped in the Taris archives are dead content, and so is any journal a mod adds to a module. Editing
<modulename>.jrlchanges nothing in the game, and nothing in the toolset or the file will tell you. Quest content belongs inglobal.jrl.There is nothing to say about how the two get merged, because they never do, and nothing to say about module files separately. Everything below describes
global.jrl.
Picture is kept up to date and never read back
Everything about this field says it is live. The engine writes it, and it has a message handler of its own to push it from the server side across to the client, which is real work nobody does for a value that does not matter.
Nothing then reads it. Exactly two functions in the whole executable can hand out a pointer to a journal entry, and between them they have exactly one caller: the quest-state function this page is about, which reads ID, Text, XP_Percentage and End and never touches Picture. There is no third way to reach an entry, so there is nowhere the field could be read from.
This is the strong claim rather than the weak one, earned by reach rather than by name. The name test cannot settle this one: the engine writes and syncs the field, so its label is unmissable in the binary. What is exhaustive here is the reach. Enumerate every function that can produce an entry pointer, enumerate their callers, and the set closes with no reader in it.
So this is not a field nobody touches. It is a field the engine bothers to keep current and then never consults, at least in the shipped client. Keep it on a round trip, because the engine does. Both files that carry it hold 65535, which has the shape of a sentinel, and with nothing reading the field there is nothing that could be testing it, so it stays recorded as a value.
File Layout
One list at the root. Each element is a quest; each quest owns a list of entries.
| Label | Element |
|---|---|
Categories | A quest: identity, priority, planet and plot linkage, and its entries |
Categories[].EntryList | One journal entry: its number, its text, and whether it closes the quest |
Field table
Every read below happens inside the single state-setting function, each with an explicit default supplied at the read site, so an absent field takes that default rather than following a separate code path.
| Label | GFF type | Absent-value default | Read? | Notes |
|---|---|---|---|---|
Categories | List | empty | Yes | |
Categories[].Tag | CExoString | empty string | Yes | What a script names the quest by |
Categories[].Name | CExoLocString | empty | Yes | |
Categories[].Priority | DWORD | 0 | Yes | Shipped values are 0 through 4 |
Categories[].PlanetID | INT | 0 | Yes | -1 means no planet. See below. |
Categories[].PlotIndex | INT | 0 | Yes | A plot.2da row key, not a sentinel. See below. |
Categories[].Comment | CExoString | n/a | No | Design notes left in by the toolset; never read |
Categories[].Picture | WORD | 0 | Written, never read | Maintained and synced, and no code path can read it back. See above. |
Categories[].XP | DWORD | n/a | No | Inert. The award comes from XP_Percentage. |
Categories[].EntryList | List | empty | Yes | |
Categories[].EntryList[].ID | DWORD | 0 | Yes | The entry number a script selects |
Categories[].EntryList[].Text | CExoLocString | empty | Yes | |
Categories[].EntryList[].XP_Percentage | FLOAT | 0.0 | Yes | Drives the award. See below. |
Categories[].EntryList[].End | WORD | 0 | Yes | Tested against bit zero. See below. |
Rules the engine enforces
Important
Endis tested against bit zero, not for non-zero The value is masked with1and only that bit is carried into the entry’s flags. Shipped data holds0or1and behaves as a boolean either way, so the distinction is invisible in the corpus and matters only to a writer choosing a value.It matters because the obvious generalisation is wrong:
2does not close the quest.2 & 1is zero, so a file writing2for “true” produces an entry the engine treats as open. Write1.
The XP an entry awards comes from XP_Percentage and a 2DA row. It does not come from XP. Where an entry carries XP_Percentage, the engine takes the quest’s PlotIndex as a row number into plot.2da, reads the XP column there, multiplies it by the percentage, rounds, and that is the award. The entry’s own XP field is never read at all, so the 0 sitting in every shipped copy of it means nothing either way.
Which also tells you what PlotIndex is: a table key, used as one every time, never checked for a special value. A -1 there does not mean “no plot”. It means row -1 of plot.2da, and the lookup simply fails.
Warning
Setting a state to an entry number that does not exist does not fail This is the format’s central operation, and its failure mode is silence. The state-setting function searches the category’s
EntryListfor an element whoseIDmatches the number a script asked for. If the search runs out without a match, or the list is empty, execution falls through to the same tail a successful match reaches: the category’s changed bit is set and the player gets a journal-updated notification.So the quest appears to update. What is skipped is only the entry-level part:
Text,XP_PercentageandEndare never touched, and whatever they held before, whether construction defaults or a previous call’s values, is what a client displaying that entry shows. Nothing errors and nothing records that the requested number was missing.For a writer this makes a typo in an entry number a bug with no symptom at the point of failure. The quest advances in the journal UI and its text does not change.
Note
Category tags are compared case-insensitively, not stored lowercased Every tag lookup here goes through a case-insensitive string comparison: against categories already tracked at runtime, and against the
Tagcolumn of the journal file’s ownCategorieslist. The stored strings are never modified.That is a different mechanism from the convention
are.mddocuments for area tags, which lowercases once at storage and then compares for plain equality. Both reach “case does not matter” for ordinary tags, so the distinction only shows up at the edges, with unusual casing or non-ASCII bytes, where normalising and comparing leniently are not the same operation.A lowercased copy is made elsewhere in the same function, but only for a network-sync message payload. Nothing matches against it.
No entry is numbered 0, which is what keeps 0 free to mean “not started”. Entry numbers in the .jrl files a retail install ships run from 1 to 150 and never include 0. They also sit overwhelmingly on multiples of ten, which is a habit of the content and not a rule of the format.
That matters because the quest state a save carries is an entry number. Across a local corpus of saves, every JNL_State value in every PARTYTABLE.res is a number that occurs as an entry ID somewhere in the shipped journal, and none of them is 0. So the two ends agree, and a 0 cannot be confused with a quest sitting at its first entry.
Two limits on that. The saves are modded, so they establish the shape of the field rather than anything about vanilla progression. And nothing here establishes what the engine would do with an entry numbered 0, only that no shipped file writes one and no save stores one. A writer adding quests should keep away from 0 for the same reason the shipped content does.
PlanetID’s -1 is a real sentinel, and the test for it lives in the UI rather than where the field is read. The journal checks for -1 before doing anything with the value, and where it finds something else it reads the Name column of planetary.2da at that row to put the planet’s name in front of the quest. The comparison is signed, so this is an ordinary equality sentinel.
Engine Audits & Decompilation
Read from CSWSJournal::SetState at 0x005c5a40 in swkotor.exe. Provenance: traced, and the Picture finding is traced across the whole executable rather than along one path.
| Pipeline Event | Engine Behaviour |
|---|---|
| One funnel for every write | Script actions and save restores alike reach the same function. Its callers are both LoadJournal overloads that replay a save’s PARTYTABLE journal state (CSWSCreature::LoadJournal 0x004f17d0, CSWPartyTable::LoadJournal 0x00563430), the two AddJournalEntry sites, SetInt, and the AddJournalQuestEntry script command. |
| Hardcoded resref | The journal GFF it opens is constructed from the literal Global. This is what makes module-scoped files inert. |
| Reads live with the write | Field-level reading happens inside this same function rather than in a separate load pass, so the field table above and the state machine are one thing. |
| Consumers | CSWGuiInGameJournal::OnControlEntered (0x00645100) is where PlanetID’s sentinel test and the planetary.2da lookup live. |
Implemented Linter Rules (Rakata-Lint)
None yet. A module-scoped .jrl is the obvious candidate, since it is both detectable and always wrong, but no rule currently reads this format.
PTH Format (Path Network)
A .pth file is an area’s pathfinding graph: a flat list of waypoint-like points and a flat list of directed connections between them. The engine walks it to generate movement successors when a creature needs a route across a room. It is authored per area and shares the area’s resref.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .pth |
| Magic Signature | PTH / V3.2 |
| Type | Pathfinding node graph |
| Rust Reference | No typed view. Read as a generic GFF tree via rakata_formats::Gff. |
Population: every .pth in the RIM archives under modules/, which is 132 files across 117 modules. The type occurs nowhere else in a retail install: not in the BIFs indexed by chitin.key, not in rims/, not in lips/, not in patch.erf.
The network is two-dimensional and the world supplies the third dimension
A path point stores X and Y. There is no Z label in any shipped file, the loaded point has no third coordinate, and everywhere the engine needs a point as a 3D position it fills the height in with zero.
This is a design decision rather than a gap in the data. Those flattened positions go straight to the walkmesh line tests, which follow the surface, so a point sits at whatever height the walkmesh under it happens to be. The path network says where you may walk in plan view. The walkmesh answers how high that is.
Two things follow. If you are reading these files you are not missing a field, so do not go hunting for one or work a height out from nearby geometry. And if you are writing them you have no height to supply, which means a path network is only as good as the walkmesh beneath it. Put a point over a hole in the walkmesh and you have not placed it at the wrong height. You have placed it somewhere the line tests cannot reach at all.
Warning
Destinationis an unbounded index, so the bounds check is yours A connection’sDestinationgoes straight into the point array as an index, and nothing anywhere in that routine compares it against the number of points first. Put a value past the end and the engine reads whatever is sitting there. It does not reject the file.This is the same shape as the walkmesh edge
transition, which indexes the area’s room array on the same terms; see “A non-sentineltransitionis used as a room index with no bounds check”. The difference is that walkmesh transitions have-1to mean “nowhere” and path connections have no sentinel at all, so there is no value that safely means “no destination”. EveryDestinationmust be a valid index into that file’s ownPath_Points, unconditionally.Every
Destinationin every shipped file is in range, so nothing in a retail install exercises this.
Warning
The connection list is spelled
Path_Conections, with onenSo is the per-pointConectionscount. The misspelling is in the format, and it is the single most likely thing here to cost you an afternoon: a reader that asks forPath_Connectionsfinds nothing and concludes the file has no connections, which looks exactly like a path network that genuinely has none.
File Layout
Two sibling lists at the root, and nothing else.
| Label | Element |
|---|---|
Path_Points | A point: position, plus a span naming its outgoing connections |
Path_Conections | A connection: the index of the point it leads to |
A point does not carry its own connections. It carries an offset and a count into the shared connection array, so the two lists are read together or not at all.
Field table
Every label this format declares is read by the loader. There are no unread fields in it, and no field the loader asks for that no shipped file writes.
| Label | GFF type | Absent-value default | Notes |
|---|---|---|---|
Path_Points | List | empty | Present in every shipped file; empty in 38 of them |
Path_Points[].X | FLOAT | 0.0 | Stored verbatim, no arithmetic at load |
Path_Points[].Y | FLOAT | 0.0 | Stored verbatim, no arithmetic at load |
Path_Points[].First_Conection | DWORD | 0 | Index of this point’s first connection |
Path_Points[].Conections | DWORD | 0 | How many connections belong to this point |
Path_Conections | List | empty | Not read at all when Path_Points is empty. See below. |
Path_Conections[].Destination | DWORD | 0 | Index into Path_Points. Not bounds-checked. |
Rules the engine enforces
Both lists are gated on their elements’ struct ids: 2 for Path_Points, 3 for Path_Conections. The struct_id is not a free tag here. The loader tests each element’s against the value its list expects and skips the element outright when it differs, silently, moving to the next index. A point with the wrong id is not in the network, and a connection with the wrong id leaves its point’s span short, which matters more than it sounds given the span arithmetic below assumes the array tiles exactly.
This is the same gate GIT puts on its object lists, and it turned up from the same sweep, but a .pth is its own resource rather than anything nested in an area.
First_Conection and Conections are a genuine offset and count. The successor step computes the end of the span as first plus count and walks the connection array between them, so the pair indexes a shared flat array exactly as it looks. This holds on both sides: measured across all 94 shipped files with a populated point list, walking points in file order, each point’s First_Conection equals the running total of the preceding counts and the last span ends precisely at the connection array’s length. The array is fully tiled with no unreferenced entries and no overlap.
Note
An empty path network is a supported state, not a broken file 38 of the 132 shipped files carry an empty
Path_Points, and the loader treats that as a clean exit: it records a point count of zero, allocates nothing, and never readsPath_Conectionsat all even though that list is a sibling it would otherwise read unconditionally. The caller discards the loader’s result outright, so nothing downstream distinguishes “pathing loaded”, “pathing was empty” and “there was no.pth”.Two consequences. A module with no path network is untested rather than degraded. And a file with an empty point list may carry any connection data at all without effect, because nothing will look at it.
Engine Audits & Decompilation
Read from CSWSArea::LoadPathPoints at 0x00508400 in swkotor.exe, with the consumer traced separately. Provenance: traced, and the file-layout claims are independently measured against the module archives.
| Pipeline Event | Engine Behaviour |
|---|---|
| Existence gate | Before anything else the loader asks the resource manager whether a PTH-type resource exists under the area’s own resref. If not, the whole call is a no-op. |
| Module-scoped, genuinely | The GFF it opens is keyed on the area’s own resref rather than a fixed name, so a module’s own .pth is the one that loads. (Contrast JRL, where the equivalent lookup is hardcoded and module files are inert.) |
| Verbatim storage | Both lists are read field by field into the loaded structures with no arithmetic, validation or normalisation at load time. Everything interesting happens at consumption. |
| Result discarded | CSWSArea::LoadArea (0x0050e1e3) calls the loader once and ignores its return value, which is why a missing or empty network is silent. |
| Consumption | CSWSArea::PathPointDFSGenerateSuccessors (0x004bdb00) is where the offset-and-count walk and the unchecked Destination subscript both live. The 3D positions it produces feed the walkmesh line tests documented on the walkmesh page. |
Implemented Linter Rules (Rakata-Lint)
None yet. No rule currently reads this format.
UTC Format (Creature Blueprint)
A .utc file is a creature. Every NPC, enemy, droid and companion in the game
starts life as one of these blueprints: a template that says who the creature
is, what it looks like, how strong it is, what it carries, and which scripts
fire when it notices you or takes a hit.
UTC is one of the formats built on GFF (Generic File Format), the engine’s labelled key/value tree. If you have not read the GFF page yet, start there. This page assumes you know what a field label and a struct are.
You will touch a .utc when you want to change a creature’s stats, swap its
appearance, give it different gear, or hook a new script to it. You will touch
the saved form of one when you edit a character mid-playthrough.
This page documents UTC’s field defaults, the class-boundary split between
CSWSCreatureandCSWSCreatureStats, and the save-versus-template load paths. Evidence is drawn from Ghidra decompilation ofswkotor.exe(K1 GOG build), cross-checked against a full K1 install’s vanilla.utccorpus and real save files. The tables below are lookup surfaces, meant to be searched rather than read start to end.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .utc |
| Magic Signature | UTC / V3.2 |
| Type | Creature Blueprint |
| Rust Reference | View rakata_generics::Utc in Rustdocs |
Why this page is the long one
Two design decisions in the engine account for nearly everything surprising here, and if you hold them in mind the rest of the page stops looking like a list of exceptions.
One loader serves both a blueprint and a save. ReadStatsFromGff has no
branch distinguishing “fresh creature from a template” from “restore this
character”. So a field documented as save-only is often read on the blueprint
path too, and a bug on one path is a bug on both. The differences that do exist
are usually one level up, in the caller, not in the field handling.
A creature is constructed first and the file is overlaid onto it. Nothing here is built from the file. An absent field almost always means “keep what the constructor put there”, which is why so much of this page is about values you never see written down anywhere.
Creatures also hold more state than any other template, so there is simply more surface.
Blueprints ship inside module archives and the BIF files a chitin.key points
at. The Aurora toolset produces them, and every mod tool since has followed its
lead.
The same field set appears again inside a save game. When the engine stores a
module it writes each live creature into that module’s GIT as a full
snapshot, not as a reference back to the blueprint. Those snapshots carry
runtime state a static blueprint never populates. See the
Save Game Deep Dive for how the pieces fit
together.
Field Schema
The format’s field families, as an orientation before the full list.
| Family | Covers | Representative fields |
|---|---|---|
| Core statistics | Base stats defining physical capability | Strength, Dexterity, HitPoints |
| Identity and graphics | Who the creature is, which model it uses | Tag, Appearance_Type, Conversation |
| Class and skill progression | Level, classes, skills | ClassList, SkillList |
| Combat capabilities | Feats and Force powers | FeatList, SpellList |
| Inventory and equipment | Spawn gear, both equipped and carried | Equip_ItemList, ItemList |
| Event hooks | Scripts that fire on world events | OnNotice, OnDamaged |
Engine Audits & Decompilation
The two functions that matter most are
CSWSCreatureStats::ReadStatsFromGff at 0x005afce0 and its save-side
counterpart CSWSCreatureStats::SaveStats at 0x005b1b90.
(Provenance: derived, not attested. These rows sit on the reverse-engineering queue. Where a specific claim is weaker than the rest, we say so inline.)
How the engine loads a creature
These functions do the work. ReadStatsFromGff is the big one.
| Function | Size | What it does |
|---|---|---|
ReadStatsFromGff | 7835 B | Parses the basic creature scalars: strength, dexterity, physical appearance and the rest. |
LoadCreature | Sets up how the creature sits in the world: stealth state, collision size, idle animations. | |
CSWSCreature::ReadScriptsFromGff | Attaches the event scripts that fire on notice, damage, death and heartbeat. A genuine member of CSWSCreature, confirmed by its decompiled __thiscall signature, not a free function. | |
ReadItemsFromGff | Pulls loot into memory, sorting items into equipped slots or the backpack. | |
ReadSpellsFromGff | Extracts the Force powers and combat feats the creature may use. |
You may have read elsewhere that ReadItemsFromGff drops everything if a
creature spawns dead. That framing does not survive the decompile. No branch
anywhere in this call graph inspects hit points or a dead/alive state. See
what actually drops an item entry.
Which class owns which field
A creature’s fields split across several engine classes. That matters because the split decides which function reads a given label, and each function brings its own defaulting rules. Two fields sitting next to each other in the file can behave differently on absence purely because different classes own them.
ReadStatsFromGff reads the bulk of a creature’s identity inline: the
abilities, HP and FP pools, appearance and portrait fields, faction, challenge
rating, AI state, and perception range. It reads ClassList and 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.
Two structured exceptions delegate out on load: CombatRoundData goes to
CSWSCombatRound::LoadCombatRound, and the nested CombatInfo struct goes to
CCombatInformation::LoadData.
That is a large, coherent chunk of the shared field set. It is not all of it. A
comparable amount lives one level up, read and written inline by
CSWSCreature::SaveCreature and LoadCreature with no CSWSCreatureStats
involvement:
DetectMode, StealthMode, CreatureSize, IsDestroyable, IsRaiseable,
DeadSelectable, AmbientAnimState, Animation, CreatnScrptFird,
PM_IsDisguised, PM_Appearance, Listening, the full set of Script* event
hooks, position and orientation, AreaId, and JoiningXP.
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, owning everything under
Save-game snapshot fields plus the class, skill,
feat and power progression. But no single class owns the shared field set: it
spans CSWSCreatureStats, CSWSCreature, CCombatInformation,
CSWSCombatRound and CSWSCreaturePartyFollowInfo, plus two unnamespaced free
functions. That is why a Rust type modelling the whole block needs a
rakata-invented name rather than a borrowed one.
Constructed defaults on the CSWSCreature side
Most CSWSCreature-owned fields construct to a plain literal and stay there.
StealthMode, CreatureSize, AmbientAnimState, CreatnScrptFird,
PM_IsDisguised, PM_Appearance and JoiningXP all zero-initialize in
CSWSCreature’s own constructor.
Two are overridden inside that same constructor and do not end where they
start. Animation takes 10000 from the base CSWSObject constructor and is
immediately re-set through SetAnimation, landing at 10001. DetectMode
zero-initializes and is then bumped to 1 by an internal SetDetectMode call.
The base class CSWSObject, not CSWSCreature, owns several more:
| Field | Constructed value |
|---|---|
IsDestroyable | 1 |
IsRaiseable, DeadSelectable, Listening | 0 |
| position | the origin, (0.0, 0.0, 0.0) |
| orientation | (1.0, 0.0, 0.0), three components rather than two |
AreaId | the object-reference sentinel 0x7F000000, not a plain 0 |
Important
LoadCreaturedoes not use those constructed values as its absent-field fallback. Its defaults are hardcoded literals baked into each read call site rather than live reads of the object’s current field. Several disagree with what the constructor sets:
Field Read fallback Constructed CreatureSize30IsRaiseable10DeadSelectable10Animation1000010001AreaId00x7F000000For a save missing one of those, the creature ends up at the read literal, not at what a fresh
CSWSCreaturewould hold. The read default is the correct absent-field answer there.
DetectMode’s read default is a mismatched0as well. It reconciles only because the constructor’sSetDetectMode(1)runs afterLoadCreaturereturns, not because the read carries the true value.For the rest of the group (
IsDestroyable,StealthMode,AmbientAnimState,CreatnScrptFird,PM_IsDisguised,PM_Appearance,Listening) the literal and the constructed value coincide, so the split is invisible. It is still a distinct mechanism, not a coincidence that generalises.
Position, orientation and JoiningXP are not read by LoadCreature at all.
They come through CSWSArea::LoadCreatures and LoadFromTemplate instead.
Both use their own literal 0.0 position default, matching the constructed
origin, and both apply orientation only when the read value is nonzero. That is
a presence-adjacent gate, not a plain carry-over. A template-spawned creature
with no orientation in the file keeps the constructor’s (1.0, 0.0, 0.0)
facing, but by that separate mechanism rather than by LoadCreature’s own
defaulting.
Rules that crash the game
Most of a creature’s numbers are not values. They are row indices into 2DA
tables: Race indexes racialtypes.2da, Appearance indexes
appearance.2da, a resolved movement rate indexes creaturespeed.2da. The
file supplies the index and the table supplies the meaning.
That is why the failures here are crashes rather than warnings. A row index that does not name a row has no fallback meaning to reach for, so the engine notices and gives up instead of continuing with a creature it cannot describe.
Warning
Fatal crash codes (
0x5fX) When the engine parses a file and hits an invalid stat, it aborts loading entirely. Instead of recovering, it triggers a fatal crash to desktop and returns a hexadecimal error code such as0x5f7or0x5f4. The rules below track the scenarios that produce one.
| Rule | Runtime behaviour |
|---|---|
| Class identity | A duplicate class id in ClassList crashes the game (0x5f7). A resolved-but-out-of-range class id crashes with the same code, not a separate failure mode. |
| Race bounds | The engine compares Race against the compiled row count of racialtypes.2da. Exceeding it fatally crashes the map loader (0x5f4). |
| Saves calculation | Pre-computed saving throws (SaveWill, SaveFortitude) in the file are ignored dead data. The engine reads willbonus and fortbonus instead. |
| Perception faults | A non-PC PerceptionRange triggers a read against appearance.2da for PERCEPTIONDIST. Failing to resolve that distance fails the whole creature load (0x5f5). |
| Hard clamping | Gender clamps structurally at a maximum of 4. GoodEvil clamps so it cannot exceed 100. |
| Appearance shifting | An Appearance_Head of 0 is overridden to 1. |
PerceptionRange takes the fault-prone path by default. The field is never
read at all for a PC. For a non-PC, an absent PerceptionRange defaults to a
hardcoded literal 11, not a carried-over value. That 11 is itself the
sentinel routing into the appearance.2da lookup above. So
an absent field lands on the failure-prone branch by construction, not by
coincidence. Any other resolved value is used directly as a ranges.2da row
index instead.
Appearance_Head fires on the resolved value. The check never asks whether
the field was present, so it behaves the same for an explicit 0 and for an
absent field’s carried-over default. Almost no vanilla .utc carries the
field, so nearly every creature takes the carried-over 0 and is bumped to
1. No creature can end up stored at 0.
WalkRate is read only where MovementRate was absent. Where
MovementRate is present, the WalkRate read is skipped outright rather than
performed as a no-op.
Both movement fields absent lands on creaturespeed.2da row zero
Every shipped creature omits both MovementRate and WalkRate. The
constructor’s zero-initialized value survives both reads and reaches the
movement-rate setter as a literal 0.
That 0 is not a speed. It is a row index into creaturespeed.2da, whose
WALKRATE and RUNRATE columns hold the actual floats.
The setter carries one sentinel. A resolved value of exactly 7, which the
both-absent case cannot reach, redirects to an appearance.2da and
creaturespeed.2da name lookup instead of indexing directly.
Absent-field defaults
The engine does not build a creature from the file. It builds a creature first, then overlays the file onto it.
That is the single idea this whole section rests on. ReadStatsFromGff runs
against an object whose constructor has already set every member to something.
Most reads pass the member’s own current value in as the fallback and then
stamp the result back unconditionally, so an absent field resolves to whatever
construction left there. We call that carry-over.
Two consequences follow:
- Absent does not mean zero. It means “whatever the constructor chose”, and
the constructor does not always choose zero.
HitPointsstarts at1, the script hooks start at"default",AreaIdstarts at a sentinel. - The same file loads differently onto a used object. On a fresh blueprint load carry-over is a no-op, because the construction value is all that was there. On a save reload onto an object that already holds state, an absent field silently preserves that state.
These apply on any load, blueprint or save alike, because ReadStatsFromGff
does not branch on its caller. The fields that break the pattern each get a
heading below.
The core abilities default to 0, not a tabletop 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. The constructor initializes every one of them to
a literal 0 before any read happens.
So a .utc missing an ability score does not fall back to a sensible tabletop
default. It resolves to 0, not 10.
Identity, appearance and state
These all use plain carry-over:
Tag, Conversation, Deity, Description, Age, StartingPackage,
Subrace, SubraceIndex, Color_Skin, Color_Hair, Color_Tattoo1,
Color_Tattoo2, Phenotype, Appearance_Type, DuplicatingHead,
UseBackupHead, FactionID, ChallengeRating, NaturalAC, Min1HP,
PartyInteract, Disarmable.
Some need a qualifier:
| Field | Qualifier |
|---|---|
Gender | This is its own absent default, separate from the clamp to 4 above. |
GoodEvil | Likewise separate from the clamp to 100 above. |
AIState | Read through the INT reader despite being a WORD field, and truncated on store. |
WalkRate | Its own default is the object’s current movement_rate, separate from the MovementRate falls back to WalkRate rule above. |
Portrait | Reached only conditionally. See PortraitId below. |
Naming corrections
The colour fields are underscored: Color_Skin, not ColorSkin. 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).
TemplateResRef is not read by ReadStatsFromGff at all
TemplateResRef never appears anywhere in ReadStatsFromGff. It is read one
level up, by CSWSArea::LoadCreatures, and only on the branch resolving a GIT
Creature List entry’s blueprint reference. The direct save-instance path 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.
This is a genuine presence-chain abort. The identical pattern, same field and
same behaviour, governs blueprint resolution for triggers, placeables, items,
doors, encounters and sounds. A templated GIT entry missing its own
TemplateResRef is dropped outright, not defaulted.
FirstName and LastName
Neither carries over. Both are an unconditional blank stamp, unlike the
structurally identical Description 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.
An absent FirstName or LastName 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.
SoundSetFile
It is not a resref. It reads with ReadFieldWORD as a soundset.2da row
index, and it lives on the owning CSWSCreature rather than
CSWSCreatureStats, matching the class boundary above.
It constructs to -1 (0xFFFF as a WORD), the same “nothing assigned”
sentinel as the WORD-indexed PortraitId and FactionID. That is not the
0x7F000000 object-reference sentinel used elsewhere on this page.
PortraitId
Its default is a literal sentinel 0xFFFF, not a carry-over. Unlike most
fields here, PortraitId’s fallback is a fresh literal rather than 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.
SkillPoints is read twice, at two struct scopes
A typed view needs both reads: one flat on the top-level struct, and one per
entry inside LvlStatList under the same label.
Both construct to 0, by different mechanisms. The flat read is the ordinary
idiom, defaulting to the top-level member’s own constructed value. The
per-entry read borrows that same top-level member rather than the freshly-built
level-up record’s own field, because the loop runs before the flat read while
the top-level member still holds its constructed value.
The level-up record’s field independently constructs to 0 too, so nothing
diverges in practice. The mechanism is still unique on this page: nowhere else
does a sibling’s value stand in for a field’s own.
Plot and Invulnerable
Plot has an undocumented legacy-label fallback, the same shape as
MovementRate to WalkRate. The loader first tries a field literally named
Invulnerable. Only if that is 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 of the object’s own prior plot value if neither label
is present.
Invulnerable is a real, distinct GFF label, the same one already documented
as read by LoadDoor and LoadPlaceable for their own objects. It takes
priority over Plot for creatures specifically.
NotReorienting
It round-trips through a polarity inversion, which is 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.
Worth knowing only if you are comparing the label’s sense to the internal state directly.
The script hooks default to "default"
They follow the mechanism already confirmed for doors and triggers.
CSWSCreature’s constructor pre-arms every script slot 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.
The hooks are ScriptHeartbeat, ScriptOnNotice, ScriptSpellAt,
ScriptAttacked, ScriptDamaged, ScriptDisturbed, ScriptEndRound,
ScriptDialogue, ScriptSpawn, ScriptRested, ScriptDeath,
ScriptUserDefine, ScriptOnBlocked (not ScriptBlocked), and
ScriptEndDialogue, which truncates on disk to ScriptEndDialogu under the
16-byte GFF label limit.
Confirmed for both entry points: LoadFromTemplate for a fresh .utc
blueprint load, and LoadCreature for a save reload, with no re-arming between
construction and either path.
Skills, classes and powers
SkillList
SkillList is not eight labelled fields. It is a GFF list of eight positional
entries, one Rank byte each, in skills.2da row order. rakata models it as
UtcSkills.
Absent and present-but-empty are different, and the difference only shows on an object that already holds ranks:
SkillList | Effect |
|---|---|
| Absent | The block is skipped and existing ranks stay untouched. |
| Present, including empty | All eight positions are force-zeroed first. |
Where the list is present, a position with no entry of its own takes a default
computed live from the engine’s skill-check function: ability modifier plus any
feat bonus already applied, not a flat 0. The raw component is 0 at that
point so it reduces to zero in practice, but the mechanism is sibling-derived
rather than literal.
ClassList
Class carries over the slot’s existing class id, applied only if the field
was present and does not resolve to the -1/NONE sentinel. Otherwise the
slot’s existing id stands untouched.
ClassLevel also carries over, and its own read is gated: it is attempted only
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.
A creature has two class slots, and that is a property of the object rather
than of the file. A ClassList carrying more entries than that has nowhere
to put the extras. Note that this is a different situation from the two
0x5f7 crashes above: a duplicate id and an out-of-range id are both fatal,
while a third valid, distinct entry is not. See
Open questions for how firmly we have established that.
LvlStatList
The per-level ledger: one entry per level the character has taken, extended one
entry at a time by CSWSCreatureStats::LevelUp during ordinary play. For the
player character it is also what the hit point and force pools are summed from,
rather than from ClassLevel. See Write-only fields.
Every field in an entry reads with the entry’s own current value as its fallback, so an absent field leaves whatever the entry already held, and a short or missing list does not abort the load.
| Label | What an absent field leaves behind |
|---|---|
LvlStatAbility | carries over. Constructs to 6 |
LvlStatHitDie | carries over. Constructs to 0 |
LvlStatForce | carries over. Constructs to 0 |
LvlStatClass | carries over. Constructs to 0 |
SkillPoints | the creature’s top-level SkillPoints, not the entry’s own |
SkillList[].Rank | carries over, per skill |
FeatList | untouched. Presence-gated per entry |
KnownList0 | untouched. Append-only, never cleared |
KnownRemoveList0 | untouched. Append-only, never cleared |
LvlStatAbility’s constructed 6 is a sentinel rather than a value. The
valid ability range is 0 through 5, so a 6 matches no case and applies no
bump. SaveClassInfo agrees from the other side: it emits the label only when
the stored value is not 6. Absent and an explicit 6 are the same state at
both ends.
The per-entry SkillPoints borrows its default from outside the entry. It
falls back to the creature’s top-level SkillPoints rather than to the entry’s
own prior value, because that loop runs ahead of the flat read later in the same
pass. Both resolve to 0 today only because the top-level member constructs to
zero, so a file that sets the top-level field while omitting it on an entry
would diverge.
A ledger entry’s nested lists are inert on load. FeatList, KnownList0
and KnownRemoveList0 inside an entry are separate storage from their
same-named counterparts at the top level, and nothing re-applies them to the
creature. They become live only when a live level-up consumes that entry.
Struct ids, for anyone writing this list. SaveClassInfo uses a flat
literal per list rather than a running counter. Every LvlStatList element
carries 0, as does every element of a ledger entry’s own FeatList,
KnownList0 and KnownRemoveList0. At the top level a ClassList element
carries 2 and a FeatList element 1, and a KnownList0 nested inside
ClassList carries 3. So KnownList0 means two different ids depending on
which depth wrote it.
KnownList0 and SpellsPerDayList
Per-class powers 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 built literally as "KnownList" + 0
regardless of which class index is being processed.
An absent or empty KnownList0 leaves that class with zero known powers, with
no abort. Within a present list, each power’s Spell field defaults to the
sentinel 0xFFFF. When a power resolves to that sentinel, explicitly or by
absence, the whole power entry is skipped and never appended. That is a
presence-chain abort at the individual-power level, not a defaulted 0 power.
A related but distinct read lives inside the same ClassList loop, not in
ReadSpellsFromGff: a Jedi-only SpellsPerDayList and 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 applied. Further entries are read and
discarded.
SpecAbilityList
Spell, SpellFlags and SpellCasterLevel each independently default to a
literal 0, unconditional, with no presence gate on any of the three.
An entry is appended as soon as the list-element fetch succeeds. There is no scenario where a kept entry gets dropped for missing scalar fields.
FeatList
Feat defaults to 0, but AddFeat is called only where the field was
present, so an absent Feat contributes nothing for that list position. That
is a presence-chain abort scoped to the single entry.
(We could not resolve the top-level call site’s own presence-flag register
with certainty from the decompiler output. The reading comes from the identical
idiom in the per-level FeatList inside the PC LvlStatList loop.)
At the list level, FeatList does not share SkillList’s absent-versus-empty
asymmetry. The list is iterated only once the field is found and its element
count is nonzero, both checked together. So an absent FeatList and a
present-but-empty one hit the same skip and leave existing feats untouched.
There is no equivalent of SkillList’s force-zero-then-refill anywhere in this
function. That step exists for skills because the eight ranks are a fixed-size
array needing a defined baseline. Feats have no fixed slots to reset. The two
diverge for that structural reason, so the skill behaviour does not transfer.
Item lists
Equip_ItemList is one flat GFF list, not per-slot numbered fields.
Equip_ItemList0, Equip_ItemList1 and so on do not exist. The equip slot is
the list element’s own struct id, read structurally off the GFF element header
through CResGFF::GetElementType rather than from any field. It has no
“absent” state to document.
ItemList does the opposite in the same loader. It is walked purely by
position and its element struct ids are never read. So the sequential values
vanilla writes there carry nothing, and a writer is free with them in a way it
is not with Equip_ItemList’s.
EquippedRes and InventoryRes
Both share the same fate on absence. Each is presence-gated, and if the field
is missing, or present but does not resolve to a real .uti blueprint, the
freshly-allocated item object is destroyed on the spot and the loop moves on.
That is 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, meaning not
droppable, is stamped regardless of presence, with no gating.
One thing that looks like an absent-field question but is not: if an equipped
item fails the CanEquipItem slot check after loading successfully, it is not
discarded. It is rerouted into the creature’s backpack. That is a post-load
routing decision, not a defaults question.
ObjectId, and the one thing that drops an entry
An entry can be dropped outright, and only on the save-reload path
(LoadCreature, which is unreachable from a standalone .utc blueprint load).
Each entry can carry an ObjectId pointing at an already-instantiated item.
The loader silently skips the entry where that item’s live possessor is not the
creature being loaded: no equip, no backpack add, nothing.
That is plausibly the origin of the “spawns dead” framing, since items reassigned to a corpse or loot container after death would trigger exactly this mismatch. The real mechanism is narrower: a stale-possessor check specific to save reloads, not a hit-points rule, and it never fires on a fresh blueprint spawn.
Both Equip_ItemList[].ObjectId and ItemList[].ObjectId use the same
object-reference sentinel 0x7F000000, not plain 0. The read runs only on a
save reload. On a fresh blueprint load the loop’s local is pre-initialized to
that same sentinel, so both paths land identically.
Repos_PosX and Repos_Posy are inert here
Both are unread on the creature path, regardless of what a hand-authored file
supplies. The only code reading either label is ReadContainerItemsFromGff,
which serves placeable and store containers, and nothing reachable from a
creature load calls it.
We established this by decompiling every function that touches a creature’s
Equip_ItemList or ItemList entries, and by a binary-wide string search.
That search also settles the casing. The strings in swkotor.exe are
Repos_PosX and lowercase Repos_Posy. There is no Repos_PosY with an
uppercase Y anywhere in the binary, on any object type.
ItemList[].Infinite is a store field
It is not a creature field at all, unlike its identically-named counterpart on
UTM’s own ItemList.
The "Infinite" label string has exactly two cross-references in the whole
binary, both inside CSWSStore::LoadStore and SaveStore. No function
anywhere in a creature’s item-loading call graph reads it.
There is no absent-field default to give here, the same way there is none for a
field that is never looked up. This is a store-exclusive field that happens to
share a label and a parent-list name with UTC’s own ItemList.
Save-game snapshot fields
A creature serialized into a save carries live runtime state a static .utc
blueprint does not usually populate. CSWSCreatureStats::SaveStats emits these
alongside the template fields, and they appear on the creature structs inside a
save’s module GIT.
“Usually” is doing real work in that sentence. ReadStatsFromGff has no branch
distinguishing a blueprint struct from a save-instance struct, so several of
these snapshot fields are genuinely read on the blueprint path too, the same
pattern .ute encounters show. Vanilla .utc files simply never populate
them. Others are write-only on every path, blueprint included.
| Field | Meaning | Read on the blueprint path too? |
|---|---|---|
CurrentHitPoints | Live current HP. | Yes. Unconditional single read site, no UseTemplates-style gate. The absent-field default derives from HitPoints, not from any prior “current HP” state. |
HitPoints | The HP pool CurrentHitPoints derives from. | Yes. Unconditional, carrying over the object’s own hit points. |
MaxHitPoints | Computed HP ceiling. | No. The field-name string has exactly two cross-references in the whole binary, both writers (SaveStats, SaveCharGenCreature). Zero readers anywhere. |
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, not carried over independently. |
MaxForcePoints | Computed Force-point ceiling. | No. Same exhaustive write-only check as MaxHitPoints. |
The HitPoints carry-over bottoms out at 1. The object constructor sets
hit points to 1 before any GFF field is read. So a blueprint omitting
HitPoints produces a creature on one hit point, and one omitting both fields
produces a creature on one current hit point too.
The remaining snapshot fields:
| Field | Meaning |
|---|---|
RefSaveThrow, WillSaveThrow, FortSaveThrow | Computed saving-throw totals: base plus ability modifier plus active effects. Distinct from the template’s ignored SaveWill and SaveFortitude dead fields. Confirmed write-only by the same exhaustive check as MaxHitPoints. SaveStats computes each fresh at save time, so there is no backing field for an absent-field default to apply to. |
ArmorClass | Computed AC snapshot, via CSWSCreature::GetArmorClass() at save time. Confirmed write-only by the same exhaustive check. |
Experience | Runtime progression, omitted by every vanilla .utc, so every creature carries over the constructor’s 0. That 0 passes through SetExperience, which refuses to lower a creature’s XP by comparing against the current value and storing only where the incoming one is larger. Both sides are 0 here, so the call is a no-op. A plain uncapped counter with no sentinel range. |
Gold | Not a CSWSCreatureStats field. The backing storage is CSWSCreature::gold, proxied through GetGold and SetGold from ReadStatsFromGff and SaveStats. Unlike the fields above, this one genuinely follows the self-referencing carry-over idiom, constructing to 0. |
AIState | Constructs to 0. Stored as a WORD member but read through the INT reader, the truncation quirk already documented. Unconditional carry-over, confirmed genuinely read on the blueprint path too. |
NotReorienting | Not its own field either. The backing storage is CSWSObject::reorienting, and NotReorienting is written and read as its logical inverse. reorienting constructs to 1 (true), so NotReorienting constructs to 0 (false). Confirmed genuinely read on the blueprint path too, unconditional carry-over. |
MClassLevUpIn | Multiclass level-up bookkeeping. Confirmed write-only: computed fresh at save time as class_count - 1 from the live class count, with no backing field to default when the file omits it. A freshly built object would only ever export MClassLevUpIn = 0, because class_count itself constructs to 1. |
Combat state, meaning 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 and power progression is written by SaveClassInfo
(0x005aec90) and reflects the creature’s current levelled 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.
Tail and Wings are a round-trip loss, not inert legacy data
ReadStatsFromGff never looks either label up. There is no ReadFieldBYTE
call for them anywhere in the function. It assigns 0 to both members flat and
unconditionally, overwriting whatever the object held and whatever the file
contains.
That is identical on the blueprint path and the save-instance path, since both
call the same ReadStatsFromGff and nothing in it distinguishes the callers.
SaveStats still writes both out on every save. So whatever a creature’s tail
or wings hold in a save file is discarded the moment it loads back in.
For what a writer should do with them, see
fields the engine never reads.
Dropping them from a blueprint costs nothing. A save writer matching the
engine’s own output writes them as 0.
Write-only fields
Several of the fields above look like round-trip state but are strictly
one-way. SaveStats writes them on every save, and ReadStatsFromGff never
reads one back. (Tail and Wings are in the same club.)
They are one-way for two very different reasons, and the difference decides whether editing one is pointless or actively misleading.
MaxHitPoints, ArmorClass and the three saving-throw totals are
recomputed, not restored. Each is written from a live getter, the same
getters combat rolls and UI displays call at runtime. 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. For non-PC creatures, the template’s own HitPoints, confirmed to be the literal raw value this same field feeds into CurrentHitPoints’s sibling-derived default. |
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 and 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 those totals in a save changes nothing: the engine derives the real numbers from the inputs in the right-hand column. They are snapshots for external tooling, and no state is lost by ignoring them.
The player character’s pools are rebuilt from somewhere else entirely, and
that is the part an editor has to know. The class-levels-and-ability-modifier
rule above is the companion and NPC path. For the actively-controlled player,
MaxHitPoints and MaxForcePoints ignore ClassList’s ClassLevel
completely and sum the per-level LvlStatHitDie and LvlStatForce values out
of LvlStatList, the same ledger CSWSCreatureStats::LevelUp extends one
entry at a time during ordinary play. A LvlStatList shorter than the
character’s level does not abort the load. It pads with zeroed entries,
flooring at one hit point per level.
So raising a player’s ClassLevel without extending LvlStatList to match
gives a character whose pools are wrong from the first load, silently and with
nothing reporting it. The same edit on a companion is harmless, because nothing
on that path consults the ledger. This is the one place where the two creature
kinds diverge on what an input actually is.
The permanent-bonus bytes. refbonus, willbonus and fortbonus are the
raw bytes that do round-trip. Each constructs to 0 on CSWSCreatureStats,
and an absent one on a template load carries that 0 over unconditionally,
the same idiom as ForcePoints and Experience above.
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 and willbonus documented above. A capitalized variant
is never found, read, discarded, or compared against anything.
The same applies to SubRace versus the modelled 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. PregameCurrent, despite the name, behaves as a
continuously-refreshed current-HP mirror that nothing ever reads back.
Gold: party members do not 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 does not. 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 and expression
data) are written by SaveCreature only when the corresponding live pointer or
list is actually populated.
Their absence from a save is less a defaults question than a statement that there was no runtime state to save. On load, an absent struct 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 | Emitted when | Absent on load resolves to |
|---|---|---|
PM_Appearance | PM_IsDisguised == 1 | 0. The loader only attempts the read if PM_IsDisguised decoded true. |
CombatRoundData contents | Combat was mid-round at save time | The struct header is always present, but SaveStats has no writer counterpart for this data at all. The outer SaveCreature writes the struct shell, and whether the roughly two dozen combat-round scalars inside it were populated depends entirely on whether the game happened to be captured mid-round. |
EffectList, VarTable, SWVarTable, ActionList | List and struct headers are always written; contents reflect however many entries exist | Empty containers 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 and 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 is internally consistent rather than a bug.
Worth knowing if you are ever diffing raw GFF structs by hand.
Fields the engine never reads
A field the engine never reads is not automatically a field you may leave out. The engine is one consumer among several: the toolset, save managers and other mod tools read these files too, and a blueprint that round-trips through one of them can lose a field the game itself would have ignored.
So “dead” here means “the K1 engine does not read it”, and what a writer should do about that is a separate question. See “the engine ignores this” is not “you may leave it out”.
| Finding type | Explanation |
|---|---|
| Legacy engine artifacts | Fields present in older files are Neverwinter Nights superset metrics the K1 engine does not read. Morale, SaveWill, BlindSpot and PaletteID have no label string in swkotor.exe, so those four are dead by the same test as the row below rather than merely untraced. |
| Confirmed dead by string absence | TemplateList, CRAdjust, SaveReflex and MemorizedList0. |
Some labels have no string in the binary at all
TemplateList, CRAdjust, SaveReflex and MemorizedList0 do not exist as
field-name strings anywhere in swkotor.exe.
That test is worth understanding, because it is the strongest evidence available on this page. GFF lookup works by matching a label against a literal string the reader constructs. If the string is not in the binary, no code path can branch on the field’s presence or read its contents, whatever the file holds. It is not “we did not find a reader”; it is “a reader cannot exist”. The same test settled several dead DLG fields.
TemplateList is worth naming separately. It is a List, present but empty in
every .utc in a full install, and the highest-prevalence unmodelled label in
the corpus.
SaveReflex follows the dead pattern already documented for SaveWill and
SaveFortitude. None of the three raw saving-throw fields exists as a string
to trace an override from, so the live refbonus-style computation superseding
them is (provenance: inferred) for SaveReflex specifically, by analogy with
its two siblings.
Comment and the legacy batch
A binary-wide string-existence check, the same decisive test used for
TemplateList, CRAdjust, SaveReflex and MemorizedList0, confirms that
Comment, Morale, MoraleRecovery, MoraleBreakpoint, PaletteID,
BlindSpot, MultiplierSet, NoPermDeath, IgnoreCrePath, Hologram,
WillNotRender and LawfulChaotic do not 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. They are real field names on a different format,
never read by any creature-related function, and functionally just as dead for
UTC purposes.
BodyBag and Interruptable are genuinely read
Two fields need pulling back out of the “confirmed dead” framing. 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 UTC-007 lint list, so there is no existing
contradiction to fix. But do not assume UTC’s Interruptable shares the fate
of the identically-named, confirmed-dead Interruptable field documented on
UTD and UTP. It is a different field on a different format,
and this one is live.
Whether the values these two store are ever consumed downstream, in combat or AI logic, was not traced in this pass.
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,
and the structural reader represents them faithfully.
Stacked SpecAbilityList entries on the Bastila variants
The Bastila 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 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 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 to 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 UTC lint rules: for example,
“SpecAbilityList[].Spell must resolve to a row in spells.2da”, and
optionally “warn on stacked-duplicate SpecAbilityList entries unless
explicitly whitelisted as a known vanilla pattern”.
Implemented Linter Rules (Rakata-Lint)
rakata-lint encodes the rules above so you can check a file before the engine
does. The split below is about what a rule needs to run: the first set reads
only the file in front of it, the second needs the install’s 2DA tables and
resource sources to resolve references against.
Where a rule’s wording and this page’s engine notes seem to disagree, the
engine notes win: several rules were written before the decompilation work that
now backs this page. The two class rules are the case worth knowing, because
they look like one rule and are not. UTC-002 counts entries. UTC-003 compares
ids. A ClassList can trip either, both, or neither.
Intra-resource rules needing no context, under rakata_lint::rules::utc:
| Rule | Level | Fires when |
|---|---|---|
| UTC-001 Appearance correction | Warn | Appearance_Head == 0; the engine forces this to 1 at runtime. |
| UTC-002 Class limit | Warn | More than two entries in ClassList, which is more than a creature has slots for. Counts entries, and does not care whether they are distinct. |
| UTC-003 Class duplications | Error | The same class id appears twice in ClassList; fatal engine crash (0x5f7) on load. Fires once per repeat, so a list of three identical ids reports twice. |
| UTC-004 Dead save fields | Info | SaveWill or SaveFortitude populated; the engine reads willbonus and fortbonus instead. |
| UTC-005 Gender clamp | Warn | Gender > 4; the engine clamps to a maximum of 4. |
| UTC-006 GoodEvil clamp | Warn | GoodEvil > 100; the engine clamps to a maximum of 100. |
| UTC-007 Toolset and legacy fields | Info | Any of Comment, Morale*, PaletteID, BodyVariation, TextureVar, BlindSpot, MultiplierSet, NoPermDeath, IgnoreCrePath, Hologram, WillNotRender or LawfulChaotic is set; never read by the K1 engine. |
Range, 2DA and resref-existence rules requiring a LintContext, under
rakata_lint::rules::utc_range:
| Rule | Level | Fires when |
|---|---|---|
| UTC-008 Race bounds | Error | Race does not resolve to a row in racialtypes.2da; engine crash 0x5f4 on load. |
| UTC-009 Class bounds | Error | Any ClassList[].Class does not resolve to a row in classes.2da, or is negative; engine load failure. |
| UTC-010 Appearance bounds | Error | Appearance does not resolve to a row in appearance.2da; engine renders a missing model. |
| UTC-011 Portrait bounds | Error | PortraitId, when not the 0xFFFE “use string Portrait” sentinel, does not resolve to a row in portraits.2da. |
| UTC-012 Resref existence | Warn | Conversation (.dlg), Portrait (.tga), any of the 14 Script* hooks (.ncs), Equip_ItemList[i].EquippedRes (.uti) or ItemList[i].InventoryRes (.uti) does not resolve in the configured resource sources. |
Open questions
- Is a third class entry ignored, or fatal? The two-slot structure under
ClassListis established. What the loader does with a third valid, distinct entry is not: no pass recorded here has walkedReadStatsFromGff’sClassListloop to find out. The0x5f7crashes we have confirmed are for duplicate and out-of-range ids, neither of which is a plain count problem. Tracked on #106. - The base
CSWSObjectlayer is untraced.CSWSObject::SaveObjectStateandLoadObjectState, andSaveListenDataandLoadListenData, run at the tail ofSaveCreatureandLoadCreature. We did not decompile them in this pass, so there may be a further class split at the base layer we have not reached. ReadItemsFromGff’s namespacing. We confirmedCSWSCreature::ReadScriptsFromGffis a genuine class member. We did not re-check whetherReadItemsFromGffis one or a free function.FeatList’s top-level presence flag. We could not resolve the top-level call site’s own presence-flag register with certainty from the decompiler output. The documented reading comes by analogy from the identical idiom in the per-levelFeatListinside the PCLvlStatListloop.SaveReflex’s override path is (provenance: inferred), by analogy withSaveWillandSaveFortitude. No string exists in the binary to trace it from directly.- Downstream use of
BodyBagandInterruptable. Both are genuinely read and stored. Whether combat or AI logic ever consumes the stored values was not traced. - Runtime behaviour of an out-of-range
Spellid.Spell = 299onpartymember.utcindexes past the end of thespells.2dastruct array. What actually happens depends on heap layout and cannot be determined from the load path alone. LoadCreature’s size, and the sizes ofReadScriptsFromGff,ReadItemsFromGffandReadSpellsFromGff, are not recorded. OnlyReadStatsFromGff’s 7835 B is.
Every label the schema declares
Generated from the schema, so no label can be quietly left out. How to read these tables.
What the engine does with each field
| Field | Type | Engine | When absent |
|---|---|---|---|
MaxHitPoints | SHORT | writes it, never reads it back: the field-name string has exactly two cross-references in the binary, both writers, and zero readers anywhere on any path | not one constant; we substitute 0 |
HitPoints | SHORT | reads it | keeps 1 |
CurrentHitPoints | SHORT | reads it | not one constant; we substitute 0 |
ItemList[].Repos_PosX | WORD | never reads it: never read anywhere in the creature item-loading call graph; the only reader of either label is ReadContainerItemsFromGff, serving placeable and store containers, which no creature load reaches | not one constant; the field holds the absence |
ItemList[].Repos_Posy | WORD | never reads it: never read anywhere in the creature item-loading call graph; the only reader of either label is ReadContainerItemsFromGff, serving placeable and store containers, which no creature load reaches | not one constant; the field holds the absence |
ItemList[].Infinite | BYTE | never reads it: the Infinite label has exactly two cross-references in the binary, both inside CSWSStore::LoadStore and SaveStore, so no function in this type’s item-loading call graph reads it | not one constant; we substitute 0 |
ItemList[].Repos_PosY | WORD | never reads it: never read anywhere in the creature item-loading call graph; the only reader of either label is ReadContainerItemsFromGff, serving placeable and store containers, which no creature load reaches | not one constant; we substitute 0 |
Comment | CExoString | never reads it: the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored | not one constant; we substitute "" |
PaletteID | BYTE | never reads it: the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored | not one constant; we substitute 0 |
SaveWill | BYTE | never reads it: the template’s own save-throw fields are ignored; the engine computes saving throws from base plus ability modifier plus active effects and reads these never | not one constant; we substitute 0 |
SaveFortitude | BYTE | never reads it: the template’s own save-throw fields are ignored; the engine computes saving throws from base plus ability modifier plus active effects and reads these never | not one constant; we substitute 0 |
BodyVariation | BYTE | never reads it: the string exists in the binary but its only cross-references are the item loader and saver, so it is a real field name on a different format and no creature function reads it | not one constant; we substitute 0 |
TextureVar | BYTE | never reads it: the string exists in the binary but its only cross-references are the item loader and saver, so it is a real field name on a different format and no creature function reads it | not one constant; we substitute 0 |
Morale | BYTE | never reads it: the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored | not one constant; we substitute 0 |
MoraleRecovery | BYTE | never reads it: the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored | not one constant; we substitute 0 |
MoraleBreakpoint | BYTE | never reads it: the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored | not one constant; we substitute 0 |
BlindSpot | FLOAT | never reads it: the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored | not one constant; we substitute 0.0 |
MultiplierSet | BYTE | never reads it: the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored | not one constant; we substitute 0 |
NoPermDeath | BYTE | never reads it: the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored | not one constant; we substitute 0 |
IgnoreCrePath | BYTE | never reads it: the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored | not one constant; we substitute 0 |
Hologram | BYTE | never reads it: the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored | not one constant; we substitute 0 |
WillNotRender | BYTE | never reads it: the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored | not one constant; we substitute 0 |
LawfulChaotic | BYTE | never reads it: the field-name string does not exist anywhere in swkotor.exe, so no creature function can look it up; a binary-wide string-existence check settles this as never read rather than read and ignored | not one constant; we substitute 0 |
Fields nobody has examined
Whether the engine reads these has not been established, which is not the same as establishing that it does not. Where When absent carries an answer, that half is settled.
| Field | Type | When absent |
|---|---|---|
FirstName | CExoLocString | stamps empty |
LastName | CExoLocString | stamps empty |
Description | CExoLocString | keeps empty |
IsPC | BYTE | NOT EXAMINED; we substitute 0 |
Tag | CExoString | keeps "" |
Conversation | CResRef | keeps "" |
Interruptable | BYTE | keeps 0 |
Age | INT | keeps 0 |
Gender | BYTE | keeps 0 |
StartingPackage | BYTE | keeps 0 |
Race | BYTE | NOT EXAMINED; we substitute 0 |
Subrace | CExoString | keeps "" |
SubraceIndex | BYTE | keeps 0 |
Deity | CExoString | keeps "" |
Str | BYTE | keeps 0 |
Dex | BYTE | keeps 0 |
Int | BYTE | keeps 0 |
Wis | BYTE | keeps 0 |
Con | BYTE | keeps 0 |
Cha | BYTE | keeps 0 |
NaturalAC | BYTE | keeps 0 |
SoundSetFile | WORD | stamps 65535 |
Gold | DWORD | keeps 0 |
Invulnerable | BYTE | keeps 0 |
Plot | BYTE | keeps 0 |
Min1HP | BYTE | keeps 0 |
PartyInteract | BYTE | keeps 0 |
NotReorienting | BYTE | keeps 0 |
Disarmable | BYTE | keeps 0 |
Experience | DWORD | keeps 0 |
PortraitId | WORD | stamps 65535 |
Portrait | CResRef | keeps "" |
GoodEvil | BYTE | keeps 0 |
Color_Skin | BYTE | keeps 0 |
Color_Hair | BYTE | keeps 0 |
Color_Tattoo1 | BYTE | keeps 0 |
Color_Tattoo2 | BYTE | keeps 0 |
Phenotype | INT | keeps 0 |
Appearance_Type | WORD | keeps 0 |
Appearance_Head | BYTE | keeps 0 |
DuplicatingHead | BYTE | keeps 0 |
UseBackupHead | BYTE | keeps 0 |
FactionID | WORD | keeps 0 |
ChallengeRating | FLOAT | keeps 0.0 |
AIState | INT | keeps 0 |
BodyBag | BYTE | keeps 0 |
PerceptionRange | BYTE | stamps 11 |
willbonus | SHORT | keeps 0 |
fortbonus | SHORT | keeps 0 |
refbonus | SHORT | keeps 0 |
ForcePoints | SHORT | keeps 0 |
CurrentForce | SHORT | not one constant; we substitute 0 |
SkillPoints | WORD | keeps 0 |
MovementRate | BYTE | not one constant; our reader works it out from other fields |
WalkRate | INT | not one constant; we substitute 0 |
ScriptHeartbeat | CResRef | keeps "default" |
ScriptOnNotice | CResRef | keeps "default" |
ScriptSpellAt | CResRef | keeps "default" |
ScriptAttacked | CResRef | keeps "default" |
ScriptDamaged | CResRef | keeps "default" |
ScriptDisturbed | CResRef | keeps "default" |
ScriptEndRound | CResRef | keeps "default" |
ScriptDialogue | CResRef | keeps "default" |
ScriptSpawn | CResRef | keeps "default" |
ScriptRested | CResRef | keeps "default" |
ScriptDeath | CResRef | keeps "default" |
ScriptUserDefine | CResRef | keeps "default" |
ScriptOnBlocked | CResRef | keeps "default" |
ScriptEndDialogu | CResRef | keeps "default" |
ClassList | List | NOT EXAMINED; we substitute container |
ClassList[].KnownList0 | List | not one constant; we substitute container |
ClassList[].KnownList0[].Spell | WORD | NOT EXAMINED; we substitute 0 |
ClassList[].KnownList0[].SpellFlags | BYTE | NOT EXAMINED; we substitute 0 |
ClassList[].KnownList0[].SpellMetaMagic | BYTE | NOT EXAMINED; we substitute 0 |
ClassList[].Class | INT | NOT EXAMINED; we substitute 0 |
ClassList[].ClassLevel | SHORT | NOT EXAMINED; we substitute 0 |
ClassList[].SpellsPerDayList | List | not one constant; we substitute container |
FeatList | List | NOT EXAMINED; we substitute container |
FeatList[].Feat (required) | WORD | NOT EXAMINED; we substitute 0 |
SkillList | List | not one constant; we substitute container |
SkillList[].Rank (required) | BYTE | NOT EXAMINED; we substitute 0 |
Equip_ItemList | List | NOT EXAMINED; we substitute container |
Equip_ItemList[].EquippedRes (required) | CResRef | not one constant; we substitute "" |
Equip_ItemList[].Dropable | BYTE | stamps 0 |
Equip_ItemList[].ObjectId | DWORD | stamps 2130706432 |
ItemList | List | not one constant; we substitute container |
ItemList[].InventoryRes (required) | CResRef | not one constant; we substitute "" |
ItemList[].Dropable | BYTE | stamps 0 |
ItemList[].ObjectId | DWORD | stamps 2130706432 |
SpecAbilityList | List | not one constant; we substitute container |
SpecAbilityList[].Spell (required) | WORD | NOT EXAMINED; we substitute 0 |
SpecAbilityList[].SpellFlags | BYTE | NOT EXAMINED; we substitute 0 |
SpecAbilityList[].SpellCasterLevel | BYTE | NOT EXAMINED; we substitute 0 |
TemplateResRef | CResRef | not one constant; we substitute "" |
CreatureSize | INT | stamps 3 |
IsDestroyable | BYTE | stamps 1 |
IsRaiseable | BYTE | stamps 1 |
DeadSelectable | BYTE | stamps 1 |
AmbientAnimState | BYTE | stamps 0 |
Animation | INT | stamps 10000 |
CreatnScrptFird | BYTE | stamps 0 |
PM_IsDisguised | BYTE | stamps 0 |
PM_Appearance | WORD | stamps 0 |
Listening | BYTE | stamps 0 |
AreaId | DWORD | stamps 0 |
DetectMode | BYTE | stamps 0 |
StealthMode | BYTE | stamps 0 |
LvlStatList | List | not one constant; we substitute container |
UTD Format (Door Blueprint)
A .utd file is a door: what it looks like, whether it is locked and what opens it, any trap sitting on it, and where it leads when it doubles as an area transition. Its scripts cover the events a door has, being opened, forced, unlocked or destroyed.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .utd |
| Magic Signature | UTD / V3.2 |
| Type | Door Blueprint |
| Rust Reference | View rakata_generics::Utd in Rustdocs |
Field Schema
The format’s field families, as an orientation before the full list.
| 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 |
| Behavioural Hooks | The scripts that run when a player opens, destroys, or fails to unlock the door | OnOpen, OnFailToOpen, OnMeleeAttacked |
Engine Audits & Decompilation
Read from the shared field-reading routine CSWSDoor::LoadDoor at 0x0058a1f0; see “Save versus Template Load Paths” below for the actual top-level dispatcher in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
The load path
LoadDoor reads the door’s fields in four groups.
| 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 from the area loader, decides how a door’s fields are populated. The branch point is CSWSDoor::LoadDoorExternal (0x0058c5f0); LoadDoor (0x0058a1f0) is the shared field-reading routine both branches call.
UseTemplates | What happens |
|---|---|
| clear | LoadDoorExternal calls LoadDoor on the door’s full instance snapshot out of the savegame. Every field is already in that struct. |
| set | LoadDoorExternal calls CSWSDoor::LoadFromTemplate (0x0058b468), which reads TemplateResRef, opens the blueprint, and calls the same LoadDoor against it. |
Four instance-only fields are overlaid back afterward on the template path, because a blueprint does not know which instance it belongs to: TransitionDestin, LinkedTo, LinkedToFlags and LinkedToModule. (TransitionDestin is the on-disk 16-byte-truncated label for what the engine’s own source calls TransitionDestination.)
The 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 to fail on an empty or unresolvable TemplateResRef, which aborts the whole door load rather than just the overlay.
LoadDoor makes no distinction between callers, so on a templated door TransitionDestin genuinely is read off the blueprint and then immediately overwritten. A hand-authored .utd carrying it is read and discarded the same way.
Both paths start from the same constructed object
CSWSArea::LoadDoors allocates every placed door and runs CSWSDoor’s real constructor before it inspects UseTemplates at all. There is no lighter-weight allocation for a save-restored door that skips the constructor’s seeding.
So an absent script hook on a save-restored door resolves to "default", and absent trap settings resolve to the constructed values documented below (TrapType 0xFF, TrapDetectable/TrapDisarmable/TrapOneShot 1, TrapFlag/TrapDetectDC/DisarmDC 0) identically on both paths.
Tag has instance-only stakes and is not overlaid
LoadDoor holds the only Tag read in the whole door-load call graph, so on a templated door Tag comes from the blueprint like Appearance or HP, and nothing re-reads it from the placed instance.
That is a real gap in the overlay mechanism. Several doors sharing one blueprint would share one Tag, breaking any script targeting a door by tag.
It does not bite in practice, because the convention works around it: every door instance in a full install carries a distinct TemplateResRef, one blueprint per placed door. The mechanism is templated; the authoring is not.
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.
Rules the engine enforces
| Engine Rule | Runtime Behaviour |
|---|---|
| Appearance Truncation | The engine reads Appearance as a 32-bit integer and keeps its low byte alone. An id above 255 therefore arrives as a different row of the appearance table, and the door renders as whatever model that row names. |
| Static Enforcement | A door marked Static has plot forced to 1, so static level architecture cannot be destroyed. |
| 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 fork below, the save routine writes every door field unconditionally. No other door field is ever left out of a vanilla save. |
The portrait fork, and why the fully-absent case still lands on 0x22E
PortraitId selects between two branches:
PortraitId | Behaviour |
|---|---|
0 | Hardcoded to 0x22E. |
< 0xFFFE | The ID is used and the Portrait resref is dead data. |
>= 0xFFFE | The Portrait resref is consulted instead. |
An absent PortraitId defaults to 0xFFFF, matching UTC, UTT and UTP. Doors have a single read site in LoadDoor, shared by both callers, so that is the only default rather than a save-path-specific one.
0xFFFF lands on the string branch. But an absent Portrait then defaults to an empty resref, and an empty resref routes into the same hardcoded-portrait call with the same 0x22E argument. So a door missing both fields reaches 0x22E anyway, by the string branch’s fallback rather than by the ID default diverging from its siblings.
Absent-field defaults
Every read goes through a CResGFF::ReadField* call taking a fallback argument. That fallback is either the object’s own current member, a true carry-over from whatever the constructor set, or a fresh literal at the read site that ignores the member. The two are indistinguishable from the resulting value, so each field is listed with which mechanism applies.
Note
traps.2dacolumns differ by object type A door or placeable reads its default trap script fromMineScript, where a trigger readsTrapScript. Doors and placeables also take their disarm and detect DCs from their own GFF fields rather than from the table’sDisarmDCModandDetectDCMod, which only triggers consult. See UTT for the trigger side.
Every script hook defaults to the literal string "default", not an empty resref. The CSWSDoor constructor loops over every script slot, OnClosed, OnDamaged, OnDeath, OnDisarm, OnHeartbeat, OnLock, OnMeleeAttacked, OnOpen, OnSpellCastAt, OnTrapTriggered, OnUnlock, OnUserDefined, OnClick, OnFailToOpen and OnDialog, assigning that literal, and every hook read carries it over.
That is the mechanism behind the OnTrapTriggered fallback above: an absent hook becomes "default", so the engine is not special-casing three spellings of empty. The rest have no secondary lookup and keep the resref "default", which resolves to nothing unless a module ships an .ncs by that exact name.
TrapType
Its 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, and worth a lint rule of its own distinct from the already-flagged OnTrapTriggered fallback.
Lockable, HP, and the three fresh literals
Lockable defaults to 0, so a door is not lockable unless the file says so. Same carry-over mechanism as most fields here (constructor sets lockable = 0), against the more intuitive assumption that a door would be lockable by default.
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 rather than carry-over: a different mechanism reaching the 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. That is also 0 for Hardness and Static, so the observable value is identical either way. Only the mechanism differs.
Plot and Invulnerable
They are entangled, and the gate is unresolved. LoadDoor reads a field named "Invulnerable" first, then conditionally reads "Plot", which can overwrite the first result. Both use the same carry-over fallback (this->object.plot, constructed 0).
The condition gating that second read traces to a stack value with no discoverable prior write in LoadDoor or either known caller. (Provenance: not traced. Looked and could not settle it.)
Whichever branch fires, both reads share a fallback, so an absent Plot and an absent Invulnerable both resolve to 0, unless Static is true, which forces 1 per the rule above. Note that Invulnerable is not part of the documented UTD schema and the toolset does not write it, but the engine reads it if a file supplies one.
OpenState
It carries an override that never fires in practice. The field carries over from a constructed 0, and the result then feeds a check that can force a hardcoded 3, gated on an internal flag the constructor sets to 1 and nothing in LoadDoor resets. Under the normal construct-then-load flow the override cannot fire, so an absent OpenState resolves to 0. It is recorded in case some unexamined path clears that flag first.
The ordinary carry-overs
Carry-over from the constructor:
| Constructed value | Fields |
|---|---|
0 | Faction, GenericType, AutoRemoveKey, KeyRequired, OpenLockDC, CloseLockDC, SecretDoorDC, Fort, Ref, Will, DisarmDC, TrapDetectDC, TrapFlag, Min1HP, Locked (unlocked) |
0.0 | Bearing |
1 (true) | TrapDetectable, TrapDisarmable, TrapOneShot |
Fresh literal at the read site rather than carry-over, with the same practical value: LocName and Description to an empty localized string, Conversation to an empty resref, and Tag to an empty string, the one string field routed through SetTag.
Fields the engine never reads
What a writer should do with each is a separate question, and it has four possible answers: see the engine ignores this is not you may leave it out.
| Finding Type | Explanation |
|---|---|
| Legacy Engine Artifacts | AnimationState, NotBlastable, OpenLockDiff, OpenLockDiffMod, Comment, Interruptable and PaletteID are never read anywhere in LoadDoor, confirmed by a full-text search of its decompiled body. That is the complete list rather than a sample. Whatever storage the struct carries for these, if any, 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 anyOn*script hook (.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.
Every label the schema declares
Generated from the schema, so no label can be quietly left out. How to read these tables.
What the engine does with each field
| Field | Type | Engine | When absent |
|---|---|---|---|
OpenLockDiff | BYTE | never reads it: a full-text search of LoadDoor’s decompiled body lists exactly seven fields never read anywhere in the function, and this is one of them | not one constant; we substitute 0 |
OpenLockDiffMod | CHAR | never reads it: a full-text search of LoadDoor’s decompiled body lists exactly seven fields never read anywhere in the function, and this is one of them | not one constant; we substitute 0 |
NotBlastable | BYTE | never reads it: a full-text search of LoadDoor’s decompiled body lists exactly seven fields never read anywhere in the function, and this is one of them | not one constant; we substitute 0 |
LinkedToFlags | BYTE | never reads it: LoadDoorExternal overlays this from the save instance immediately after LoadDoor returns, unconditionally whenever the template branch is taken, and a .utd blueprint only ever reaches LoadDoor through that branch | NOT EXAMINED; we substitute 0 |
LinkedTo | CExoString | never reads it: LoadDoorExternal overlays this from the save instance immediately after LoadDoor returns, unconditionally whenever the template branch is taken, and a .utd blueprint only ever reaches LoadDoor through that branch | NOT EXAMINED; we substitute "" |
LinkedToModule | CResRef | never reads it: LoadDoorExternal overlays this from the save instance immediately after LoadDoor returns, unconditionally whenever the template branch is taken, and a .utd blueprint only ever reaches LoadDoor through that branch | NOT EXAMINED; we substitute "" |
TransitionDestin | CExoLocString | never reads it: LoadDoorExternal overlays this from the save instance immediately after LoadDoor returns, unconditionally whenever the template branch is taken, and a .utd blueprint only ever reaches LoadDoor through that branch | NOT EXAMINED; we substitute empty |
Fields nobody has examined
Whether the engine reads these has not been established, which is not the same as establishing that it does not. Where When absent carries an answer, that half is settled.
| Field | Type | When absent |
|---|---|---|
TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
Tag | CExoString | keeps "" |
LocName | CExoLocString | stamps empty |
Description | CExoLocString | stamps empty |
Comment | CExoString | NOT EXAMINED; we substitute "" |
Conversation | CResRef | stamps "" |
Faction | DWORD | keeps 0 |
GenericType | BYTE | keeps 0 |
Appearance | DWORD | NOT EXAMINED; we substitute 0 |
OpenState | BYTE | keeps 0 |
AnimationState | BYTE | NOT EXAMINED; we substitute 0 |
Bearing | FLOAT | keeps 0.0 |
Lockable | BYTE | NOT EXAMINED; we substitute 0 |
Locked | BYTE | keeps 0 |
KeyRequired | BYTE | keeps 0 |
KeyName | CExoString | NOT EXAMINED; we substitute "" |
AutoRemoveKey | BYTE | keeps 0 |
OpenLockDC | BYTE | keeps 0 |
CloseLockDC | BYTE | keeps 0 |
SecretDoorDC | BYTE | keeps 0 |
CurrentHP | SHORT | NOT EXAMINED; we substitute 0 |
HP | SHORT | keeps 1 |
Hardness | BYTE | stamps 0 |
Fort | BYTE | keeps 0 |
Ref | BYTE | keeps 0 |
Will | BYTE | keeps 0 |
Plot | BYTE | keeps 0 |
Invulnerable | BYTE | keeps 0 |
Min1HP | BYTE | keeps 0 |
Static | BYTE | stamps 0 |
Interruptable | BYTE | NOT EXAMINED; we substitute 0 |
PortraitId | WORD | stamps 65535 |
Portrait | CResRef | stamps "" |
PaletteID | BYTE | NOT EXAMINED; we substitute 0 |
TrapDetectable | BYTE | keeps 1 |
TrapDetectDC | BYTE | keeps 0 |
TrapDisarmable | BYTE | keeps 1 |
DisarmDC | BYTE | keeps 0 |
TrapFlag | BYTE | keeps 0 |
TrapOneShot | BYTE | keeps 1 |
TrapType | BYTE | keeps 255 |
OnClosed | CResRef | keeps "default" |
OnDamaged | CResRef | keeps "default" |
OnDeath | CResRef | keeps "default" |
OnDisarm | CResRef | keeps "default" |
OnHeartbeat | CResRef | keeps "default" |
OnLock | CResRef | keeps "default" |
OnMeleeAttacked | CResRef | keeps "default" |
OnOpen | CResRef | keeps "default" |
OnSpellCastAt | CResRef | keeps "default" |
OnTrapTriggered | CResRef | keeps "default" |
OnUnlock | CResRef | keeps "default" |
OnUserDefined | CResRef | keeps "default" |
OnClick | CResRef | keeps "default" |
OnFailToOpen | CResRef | keeps "default" |
OnDialog | CResRef | keeps "default" |
LoadScreenID | WORD | stamps 0 |
UTE Format (Encounter Blueprint)
A .ute file is an encounter: a boundary on the floor plus the pool of creatures to spawn when the party crosses it. It carries where they appear, how many arrive at once, and how the engine scales them against the player.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .ute |
| Magic Signature | UTE / V3.2 |
| Type | Encounter Blueprint |
| Rust Reference | View rakata_generics::Ute in Rustdocs |
Field Schema
The format’s field families, as an orientation before the full list.
| 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 |
| Behavioural Hooks | The scripts that run when a player enters or exits the trigger, or when the spawn pool runs dry | OnEntered, OnExhausted |
Engine Audits & Decompilation
Read from the primary dispatcher CSWSEncounter::LoadEncounter at 0x00593830 in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
The load path
Loading an encounter is split across four subroutines.
| Function | Size | Behaviour |
|---|---|---|
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 blueprint-versus-instance fork lives two calls up, in CSWSArea::LoadEncounters (0x00505060). With no template it calls CSWSEncounter::LoadEncounter (0x00593830) on the GIT instance struct directly. With one it opens the .ute named by TemplateResRef and calls CSWSEncounter::LoadFromTemplate (0x00593a90), which runs ReadEncounterFromGff against the blueprint.
LoadEncounters then re-reads position, Geometry and SpawnPointList off the GIT instance as overrides. The runtime-tracking scalars, AreaList and SpawnList are not among them, so whatever a template supplies for those stands, with no instance-level override 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 rather than 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 |
NumberSpawned | 0 |
HeartbeatDay | 0 |
HeartbeatTime | 0 |
LastSpawnDay | 0 |
LastSpawnTime | 0 |
CurrentSpawns | 0 |
AreaListMaxSize | 16 |
SpawnPoolActive | 0.0, a float rather than the integer its neighbours might suggest, confirmed against both the struct layout and the ReadFieldFLOAT call |
LastEntered | 0x7F000000 (OBJECT_INVALID) |
LastLeft | 0x7F000000 (OBJECT_INVALID) |
PlayerOnly | false |
Faction | 1 |
OnEntered, OnExit, OnHeartbeat, OnExhausted, OnUserDefined | Empty resref/script, all five read in that order with the identical mechanism |
LastEntered and LastLeft are object references, not calendar integers, despite sitting beside HeartbeatDay and LastSpawnDay in both the struct and the field list. The constructor writes 0x7F000000 rather than 0.
GFF has no distinct object-reference field type, so both come through the same ReadFieldDWORD path as every plain DWORD on this struct. The sentinel value is what marks them, not the read.
Every field traced in this pass is absent from every vanilla .ute in a full install, so these constructed defaults are what every shipped encounter runs on.
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 is 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.)
CustomScriptId and AreaPoints Default to Uninitialized Memory, Not a Constructed Value
Every other field here carries over a reproducible constructor default. CSWSEncounter’s constructor never assigns these two at all, confirmed in the decompiled constructor and by walking the raw disassembly for a write to either struct offset: neither appears, while every neighbouring offset does. The object is allocated unzeroed with no memset anywhere in construction.
So an absent CustomScriptId or AreaPoints carries over whatever was already in that heap memory, not a stable value. Same hazard as UTI’s gated PropertiesList scalars, for the same reason.
The two land on opposite sides of whether it matters.
CustomScriptId is consumed. NWScript’s GetUserDefinedEventNumber() reads this member directly off an encounter. CSWSEncounter::EventHandler refreshes it with a real value immediately before running OnUserDefined, so it is always correct inside that handler.
Nothing stops a script calling GetUserDefinedEventNumber() on an encounter outside such a handler, before any event has fired since the object was constructed or loaded. The script then reads whatever sits there, GFF-loaded or heap garbage, and that value can flow into script comparisons, switches or indexing. That is a live correctness hazard.
AreaPoints is scratch space. CSWSEncounter::TallyEnemyRadiusPoints and CalculateSpawnPool, both part of the spawn-pool difficulty math, fully overwrite it before reading it back on every path traced, so garbage on absence is inert.
Note
One condition left open: whether
CalculateSpawnPoolcan run before the encounter has an assigned area, which is whatTallyEnemyRadiusPoints’ own zeroing write is gated on. Static analysis could not settle it. If that path exists,AreaPointsreads as garbage there too.
Rules the engine enforces
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 Behaviour |
|---|---|
| Tag Overrides | CSWSObject::SetTag lowercases any Tag on load, so the casing in the file is lost. |
| 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 omitted entirely, the engine synthesizes a default 4-vertex 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 | Provenance: traced. DifficultyIndex selects a row of encdifficulty.2da, and the engine reads one column from it, VALUE, into the encounter’s runtime difficulty. The lookup lives in CSWSEncounter::ReadEncounterFromGff (0x00592430); the table is pre-cached at startup. The static Difficulty field is read only where that cache is null, meaning the table failed to load at all, which is installation-level rather than a per-encounter fallback. |
| Bubble Sorting | On loading the CreatureList, the engine bubble-sorts the spawn pool by ascending CR (Challenge Rating). A display order set in the file does not survive. |
| 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. |
Fields the engine never reads
What a writer should do with each is a separate question, and it has four possible answers: see the engine ignores this is not you may leave it out.
| Finding Type | Explanation |
|---|---|
| Passive Legacy Artifacts | Fields left over from older tools or earlier BioWare engines (TemplateResRef, Comment, PaletteID). ReadEncounterFromGff reads none of them. |
Toolset-Only Appearance | Nearly every .ute file carries an Appearance INT, but ReadEncounterFromGff, the one function that parses every encounter field whether blueprint or GIT instance, 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 goes unread 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 makes the engine log an error and abandon the encounter, which then never spawns.
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.
Every label the schema declares
Generated from the schema, so no label can be quietly left out. How to read these tables.
Fields nobody has examined
Whether the engine reads these has not been established, which is not the same as establishing that it does not. Where When absent carries an answer, that half is settled.
| Field | Type | When absent |
|---|---|---|
TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
Tag | CExoString | the page does not say; we substitute "" |
LocalizedName | CExoLocString | keeps empty |
Comment | CExoString | NOT EXAMINED; we substitute "" |
PaletteID | BYTE | NOT EXAMINED; we substitute 0 |
Active | BYTE | keeps 1 |
Reset | BYTE | keeps 0 |
ResetTime | INT | keeps 60 |
Respawns | INT | keeps 0 |
SpawnOption | INT | keeps 0 |
MaxCreatures | INT | keeps 8 |
RecCreatures | INT | keeps 2 |
PlayerOnly | BYTE | keeps 0 |
Faction | DWORD | keeps 1 |
DifficultyIndex | INT | the page does not say; we substitute 0 |
Difficulty | INT | the page does not say; we substitute 0 |
XPosition | FLOAT | keeps 0.0 |
YPosition | FLOAT | keeps 0.0 |
ZPosition | FLOAT | keeps 0.0 |
OnEntered | CResRef | keeps "" |
OnExit | CResRef | keeps "" |
OnHeartbeat | CResRef | keeps "" |
OnExhausted | CResRef | keeps "" |
OnUserDefined | CResRef | keeps "" |
NumberSpawned | INT | keeps 0 |
HeartbeatDay | DWORD | keeps 0 |
HeartbeatTime | DWORD | keeps 0 |
LastSpawnDay | DWORD | keeps 0 |
LastSpawnTime | DWORD | keeps 0 |
LastEntered | DWORD | keeps 2130706432 |
LastLeft | DWORD | keeps 2130706432 |
Started | BYTE | keeps 0 |
Exhausted | BYTE | keeps 0 |
CurrentSpawns | INT | keeps 0 |
CustomScriptId | INT | whatever the memory held; we substitute 0 |
AreaListMaxSize | INT | keeps 16 |
SpawnPoolActive | FLOAT | keeps 0.0 |
AreaPoints | FLOAT | whatever the memory held; we substitute 0.0 |
CreatureList | List | not one constant; we substitute container |
CreatureList[].ResRef | CResRef | NOT EXAMINED; we substitute "" |
CreatureList[].CR | FLOAT | NOT EXAMINED; we substitute 0.0 |
CreatureList[].SingleSpawn | BYTE | NOT EXAMINED; we substitute 0 |
Geometry | List | not one constant; we substitute container |
Geometry[].X | FLOAT | NOT EXAMINED; we substitute 0.0 |
Geometry[].Y | FLOAT | NOT EXAMINED; we substitute 0.0 |
Geometry[].Z | FLOAT | NOT EXAMINED; we substitute 0.0 |
SpawnPointList | List | not one constant; we substitute container |
SpawnPointList[].X | FLOAT | NOT EXAMINED; we substitute 0.0 |
SpawnPointList[].Y | FLOAT | NOT EXAMINED; we substitute 0.0 |
SpawnPointList[].Z | FLOAT | NOT EXAMINED; we substitute 0.0 |
SpawnPointList[].Orientation | FLOAT | NOT EXAMINED; we substitute 0.0 |
AreaList | List | not one constant; we substitute container |
AreaList[].AreaObject | DWORD | NOT EXAMINED; we substitute 0 |
SpawnList | List | not one constant; we substitute container |
SpawnList[].SpawnResRef | CResRef | NOT EXAMINED; we substitute "" |
SpawnList[].SpawnCR | FLOAT | NOT EXAMINED; we substitute 0.0 |
UTI Format (Item Blueprint)
A .uti file is an item: every weapon, suit of armour, medpac, upgrade
component and piece of loot in the game. It defines how the item appears on a
character, what stat bonuses and abilities it carries, its cost, and how it
behaves when dropped into the world.
UTI is built on GFF (Generic File Format), the engine’s labelled key/value tree. If you have not read the GFF page, start there.
This page documents UTI’s field defaults, load-order quirks, and the item-property dispatch chain into
iprp_*.2da. Evidence is drawn from Ghidra decompilation ofswkotor.exe(K1 GOG build), cross-checked against a full K1 install’s vanilla.uticorpus. The tables below are lookup surfaces, meant to be searched rather than read start to end.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .uti |
| Magic Signature | UTI / V3.2 |
| Type | Item Blueprint |
| Rust Reference | View rakata_generics::Uti in Rustdocs |
The file is mostly indices, not values
Hold this and the rest of the page follows. A .uti says very little about an
item directly. It says which rows of which 2DA tables describe it, and the
tables carry the meaning.
An item’s damage bonus is not a number in the file. It is a PropertyName
indexing itempropdef.2da to get the kind, a Subtype indexing whatever table
that row names, and a CostValue indexing a cost table to get the magnitude.
Change the tables and the same file means something different.
Three consequences run through everything below:
- Some fields in the file are dead because a table supplies the real value.
Costis recomputed from the properties.BodyVariationis overwritten frombaseitems.2da. Neither reaches an engine decision. - The tables load once, at startup. A mod’s
baseitems.2datakes effect when the game launches, not when an item loads, so nothing about a.utiread consults the file on disk. - Nothing is hardcoded, so mods extend the vocabulary. Property kinds come
from a
Labelcolumn rather than a compiled table, so an install with mods has kinds this page does not list.
The one place that model breaks is PropertiesList, where absent fields do not
default at all. That is the sharpest hazard on the page and has
its own section.
Field Schema
The format’s field families, as an orientation before the full list.
| 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 |
Engine Audits & Decompilation
Read 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 and
LoadIPRPParamTables at 0x005c49c0.
(Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue. Claims resting on less than the rest say so inline.)
The load path
| Function | Behaviour |
|---|---|
LoadDataFromGff | The main parser. Sets what the item is, how many charges it holds, its descriptions, and, inlined into the same function rather than 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. |
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 rather than off a character. |
SaveItem / SaveItemProperties | The write path. Forces the item to be flagged as identified; see below. |
Rules the engine enforces
| Rule | Runtime behaviour |
|---|---|
| Description cross-swap | If either Description or DescIdentified is missing, the engine duplicates the provided string into the missing one. |
| Charge fallback chaining | MaxCharges has no default of its own. When missing it reuses whatever Charges just resolved to, either its own value or the constant 50 if Charges was absent too, rather than the item’s prior max-charges. |
| Container contents gating | Provenance: traced. An item’s nested ItemList is read and written only where the base item’s Container column is non-zero. A non-container never has it looked for; a container missing it loads with an empty inner inventory. Both directions use the same gate, below. |
| Dead placement fields | SaveItem unconditionally writes XPosition, YPosition, ZPosition and XOrientation, YOrientation, ZOrientation for every item everywhere. Only CSWSArea::LoadItems reads them back; creature inventories, stores, the party stash and nested containers write them and never look again. |
| Cost generation fallback | The Cost integer in the file is dead data. GetCost() computes value from the item’s properties instead. See below for what a writer should do with it. |
| Property capabilities | Properties split into active and passive tables at load. PropertyName 10, 37, 46 or 53 hooks as a usable player ability; every other value applies as a passive stat modifier. |
| Data-driven property kinds | There is no hardcoded “PropertyName N means kind K” table. Classification comes from the Label column of itempropdef.2da at the row PropertyName indexes, so new rows surface as new kinds with no engine change. |
| Property field defaults | An omitted PropertiesList field is filled from a fixed table: Useable is 1 for active properties and 0 for passive, while UsesPerDay and UpgradeType are both 0xFF, which act as “not set” sentinels rather than row indices. |
| Identifier enforcement | SaveItem forces Identified to 1 unconditionally. Cross-checked against real items from two vanilla saves, area loot and a late-game party stash: every one came back identified. |
ModelVariation falls back through ModelPart1, then gets bumped off zero
A ModelVariation of 0 is forced to 1 on load, so an item always has
visible geometry rather than rendering as nothing.
Where ModelVariation is absent entirely, the read falls back to the older
ModelPart1 label before the same zero-check applies, so ancient files get
the same protection.
ModelPart1’s own absent default is not a literal. Its fallback argument is
whatever the already-failed ModelVariation read produced, which is the item’s
prior model_variation. Two carry-overs chained, and only then the bump to 1.
ModelPart2 and ModelPart3 are read nowhere. Neither field-name string
exists in the binary. Files carrying all three are following authoring-tool
habit; only ModelPart1 reaches the engine.
Cost is dead, and you should still write it
GetCost() derives an item’s value from its properties, so the integer in the
file reaches no engine decision. That makes it a
recomputed-at-load field
rather than one a writer may drop.
Every other toolset in the ecosystem displays the file’s copy, because deriving
the number means walking the property list and the cost tables. An item written
with no Cost reads as worthless everywhere except in the game itself, and a
modder comparing two items in an editor has nothing to compare.
So compute it if you can and preserve it if you cannot. UTI-002 reporting that
Cost is populated tells you the engine will not read it, which is worth
knowing and is not an instruction to remove it.
BodyVariation is dead and TextureVar is gated
BodyVariation is dead by the strongest test available: no ReadField* or
GetFieldByLabel call for the label exists anywhere in LoadDataFromGff,
confirmed by reading the function in full. It is not “read and ignored” the way
some legacy fields are. The string appears only in SaveItem, serializing a
value that came from elsewhere.
The item’s body_var member is populated from baseitems.2da instead, in the
model_type == 1 block that also gates TextureVar, and that overwrites
whatever a .uti supplies. TextureVar is bypassed entirely unless the base
type is model type 1.
The baseitems.2da columns, and when they are read
None of them are read while a .uti loads. CSWBaseItemArray::Load
(0x005b31d0) reads the whole table into a cache once at startup, and
CSWSItem::LoadDataFromGff (0x0055fcd0) consults that cache. So a
baseitems.2da shipped by a mod takes effect at launch rather than per item.
| Column | Supplies |
|---|---|
ModelType | The model type the page calls model_type. Same name exactly. |
EquipableSlots | Which slots the item can occupy. |
BodyVar | The body_var the item ends up with, applied only under model_type == 1. |
BaseAC | The item’s base_ac, also gated on model_type == 1; forced to 0 otherwise. |
Container | Whether the item holds a nested ItemList. A dedicated column, not derived from ModelType or an EquipableSlots mask. |
BodyVar is a string column rather than an integer. The cache loader
uppercases it, and where the value is a single letter A through J it becomes
that letter’s offset from A, plus one. Anything else defaults to 1. All of
that happens in the cache loader, not at read time.
Container gates on non-zero rather than on a particular value. Both
consumers test the cached field the same way, so any non-zero entry turns the
behaviour on.
No base item in a retail install is a container. Every populated row of
baseitems.2da carries 0, and the one row that does not is a placeholder with
an empty label and almost no cells filled at all. So an item’s nested ItemList
is code the engine reaches and vanilla data never triggers: nothing shipped
exercises the non-zero branch, and what the engine does with an item that
declares contents is untested by shipped content. Containers a player opens are
placeables, and their contents hang off the placeable rather than off an item.
Measured over baseitems.2da as read from the vanilla install’s chitin.key,
by two independent paths through the table reader.
The read and write gates are the same condition, and that is a result rather
than an assumption. CSWSItem::LoadDataFromGff consults the cache and hands
off to ReadContainerItemsFromGff (0x0055f0f0) where Container is non-zero;
CSWSItem::SaveItem (0x0055ccd0) makes the identical test off the identical
lookup before handing off to SaveContainerItems (0x0055cfa0). They were
checked against each other because a divergence would have meant an item could
be written carrying contents its own loader then silently drops. There is no
divergence.
Provenance: traced, per call site. The list transfer behind either handoff was not followed; the question was the gate.
Dropable and Pickpocketable cannot carry their constructed value
Dropable sets bit 3 of the item’s flag word and Pickpocketable sets bit 4.
CSWSItem’s constructor turns both bits on, but each read is a hardcoded
literal 0 with no presence check, so it overwrites that true back to false
on every load whether the field is present or not. Neither ever carries the
constructed default forward.
An absent Identified means identified
Identified defaults to a hardcoded 1, stamped unconditionally with no
presence check. That is a different mechanism from most of the item’s booleans,
which carry over a constructed value.
There is a second override on top: once the property list finishes loading, an
item with no active and no passive properties has the identified bit forced back
to 1 regardless of the file. An explicit Identified = 0 on a
property-less item is overwritten.
The ordinary carry-overs
The remaining top-level fields take the object’s current member as the read’s fallback:
| Constructed value | Fields |
|---|---|
30 | BaseItem |
| empty | LocalizedName, Tag |
1 | StackSize |
false | Plot, Stolen, NonEquippable, NewItem, DELETING |
0 | AddCost, Upgrades |
TextureVar is the exception: where it is consulted at all, it reads with an
unconditional hardcoded literal 1.
PropertiesList scalars hold uninitialized memory, not zero
This is the one place where “absent” does not resolve to anything predictable.
An absent property field does not read as 0. PropertyName, Subtype,
CostTable, CostValue, Param1, Param1Value and ChanceAppear are each
read with a literal 0 passed to the read call, but that 0 is never committed
when the field is absent. Every one of those writes is separately gated on its
own presence flag, checked right before the struct member is set.
The property array comes from a raw unzeroed allocator, so a missing field on an otherwise-populated entry leaves that member holding whatever was already in that heap memory.
That is a correctness hazard rather than a nuance. A linter or decoder assuming absent-reads-as-zero is wrong for every one of them, and should flag a partially-specified property entry as producing undefined values.
PropertyName is read with a 16-bit WORD read, same as Subtype and
CostValue, not a 32-bit INT.
Useable, UsesPerDay and UpgradeType are the exception. Those three are
stamped unconditionally with no presence gate on the write, so they are the only
fields in a PropertiesList entry that are deterministic when absent.
An entirely empty entry leaves a hole rather than dropping cleanly. If an entry’s own struct carries no fields at all, the counting pass folds it into the passive tally, but the populate pass then skips writing it once it reaches that entry. The array was sized assuming the entry would be populated, so the slot it would have occupied is left as the same uninitialized heap memory.
Property table dispatch
A UtiProperty carries three indices pointing 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, for example “Damage Bonus”. |
| 2 | itempropdef.2da | PropertyName | SubTypeResRef (string) | Resref of the per-property subtype 2DA, for example iprp_damagecost. Empty or 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, for example “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, for example 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; column layout varies per param table. |
Constraints on the dispatch
- Both
iprp_costtable.2daandiprp_paramtable.2darow counts are stored as abyte(u8) inCTwoDimArrays. Rows past index255are silently truncated by the loader and the affected per-property tables never get loaded into memory. - Column-name lookups in 2DAs are case-insensitive at the engine API, unlike
GFF’s own case-sensitive field-label lookup.
C2DA::GetINTEntryandGetCExoStringEntryboth resolve through a sharedGetColumnIndexthat compares case-insensitively, whether the table loaded from the binary (V2.b) or text (V2.0) path. That is also why the text loader’s_strlwrpass over column headers never causes a mismatch: a mixed-case lookup key matches a lowercased stored header or a verbatim-cased one either way. The conventional spellings the engine’s own callers use areName,SubTypeResRef,TableResRef,LabelandClientLoad, but any casing resolves identically. - The subtype 2DA named 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 at every level of the dispatch is a TLK strref. TheLabelcolumn on the same row holds a developer-readable identifier, for exampleDamage_Bonus, that needs no talktable resolution.
Cost-table magnitude resolution
The dispatch chain above ends at “the row at CostValue carries the cost effect;
column layout varies per cost table”. This section pins down that layout for
vanilla K1 and how each Apply<PropertyKind> handler reads it, sourced from the
CSWSItemPropertyHandler::Apply* family (handlers cluster around
0x004e5490-0x004e7e80 and 0x004e9230-0x004e9390).
iprp_costtable.2da (vanilla K1), index to per-cost 2DA:
| 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 whatever units suit the property kind:
bonus number, damage soak amount, save delta.
The column name is read case-sensitively here, and the only 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; the 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. Many vanilla handlers never
call GetIPRPCostTable and instead consume CostValue, or another property
field, directly as the magnitude:
ApplyDamageBonus, coveringPropertyName11Damage,12DamageAlignmentGroupand13DamageRacialGroupin one switch, readsCostValuestraight as the damage amount. There is no per-cost 2DA lookup.iprp_damagecost.2dais used for cost calculation inGetCost, not for damage-magnitude resolution.ApplyEnhancementBonusandApplyAttackBonusread(Rules->internal).all_2DAs->iprp_meleecostby direct struct-field access rather than throughGetIPRPCostTable, then read columnValue. Equivalent to a cost-table index2dispatch, inlined.ApplySkillBonusandApplyBonusFeatread the magnitude or feat id from the property struct directly.ApplyImmunityswitches on the subtype id and assigns a hardcoded engine constant per subtype; no 2DA is consulted.ApplyRegenerationusesCostValueas the regen amount and a hardcoded6000ms tick interval; no 2DA.
What a decoder should do. Resolving a property magnitude takes three steps:
- If the property kind is on the cost-table list above, read the magnitude from
the listed cost 2DA at row
CostValue, columnValueorAmount, applying the documented post-processing. - If it is on the bypass list, the magnitude is
CostValuedirectly, or forApplyImmunitya hardcoded constant per subtype. - For
ApplyDamageImmunity, read the cost-table index from the property’s ownCostTablefield rather than hardcoding it per handler. Mod-extended cost tables resolve through the same path.
Vanilla itempropdef.2da label reference
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 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; a decoder must match the file spelling exactly. | |
| 57 | Use_Limitation_Feat | feat | |
| 58 | Droid_Repair_Kit | ||
| 59 | Disguise | appearance |
Mod content extends this table past the last vanilla row, and the added rows
carry labels this list does not have. A reader dispatching on the numeric index
therefore meets kinds it has no case for, on any install with mods. Matching on
Label and carrying an explicit unknown case, one that keeps the label it could
not place, turns that from a dispatch hole into an ordinary value.
Fields the engine never reads
A field the engine never reads is not automatically one you may leave out; the toolset and other mod tools read these files too. See “the engine ignores this” is not “you may leave it out”.
| Finding type | Explanation |
|---|---|
| Superseded legacy fields | A static Cost or BodyVariation in the file is a byproduct of older file versions. The runtime 2DA evaluation supersedes both. |
| Passive legacy artifacts | Nodes left over from older tools, TemplateResRef, Comment, PaletteID and UpgradeLevel, are bypassed on load entirely. |
| Cross-format dead fields | The container item loader reads Repos_PosX and Repos_Posy per contained item, the same as the store side documented on UTM, and discards the result immediately. No writer for either field turned up anywhere in the item or container save code. |
Implemented Linter Rules (Rakata-Lint)
Intra-resource rules needing no context, under rakata_lint::rules::uti:
| Rule | Level | Fires when |
|---|---|---|
| UTI-001 Model truncation safety | Warn | ModelVariation == 0; the engine forces this to 1 at runtime. |
| UTI-002 Dead cost fields | Info | Cost is set; the engine ignores it and computes item cost dynamically. Not an instruction to drop the field; see above. |
| UTI-003 Dead body overrides | Info | BodyVariation is set; the engine queries baseitems.2da instead. |
| UTI-004 Toolset-only fields | Info | Any of TemplateResRef, Comment, PaletteID or UpgradeLevel is set; never read by the K1 engine. |
UTI-005 Conditional TextureVar | Info | TextureVar is set; only evaluated if the base item’s model_type is exactly 1. |
| UTI-008 Partially-specified property | Error | A PropertiesList entry carries at least one field but omits any of the gated scalars. Each omitted member keeps whatever the heap held, so the value the engine uses is not in the file, is not any particular number, and need not repeat between loads. Distinct from SCHEMA-002, which also fires here and reports the omission rather than the consequence. |
| UTI-009 Empty property entry | Error | A PropertiesList entry’s struct carries no fields at all. The counting pass includes it when sizing the property array and the populate pass then skips it, so the slot is left as uninitialized memory instead of the entry being dropped. |
UTI-008 and UTI-009 read the raw GFF tree rather than the typed view, because
both trigger on absent labels and a typed UtiProperty has already had values
substituted. Neither fires on any PropertiesList entry in a retail install:
the toolset that wrote them always fills the fields in, so a hand-edited or
generated file is the only way to reach either.
Range and 2DA rules requiring a LintContext, under
rakata_lint::rules::uti_range:
| Rule | Level | Fires when |
|---|---|---|
| UTI-006 Base item bounds | Error | BaseItem does not resolve to a row in baseitems.2da, or is negative. The engine indexes the cached table directly for ModelType, EquipableSlots, BodyVar and BaseAC, so an invalid id either crashes the load or produces a corrupt item. |
| UTI-007 Valid capability bounds | Error | A PropertiesList entry’s PropertyName does not resolve to a row in itempropdef.2da, or its Subtype does not resolve to a row in the per-property iprp_*.2da named by itempropdef[PropertyName].SubTypeResRef. Skipped where that row has no SubTypeResRef, meaning the property kind has no subtype dimension. UpgradeType and UsesPerDay use the engine’s 0xFF “not set” sentinel; both the absent-field and explicit-0xFF forms decode as “not set” and the rule flags neither. |
No resref-existence rule. UTI’s only ResRef field is the toolset-only
TemplateResRef, which the engine never reads, so there is nothing for the
per-format resref rule to check.
Open questions
- Which column carries the weapon class.
WeaponTypeandWeaponWieldare both INT columns inbaseitems.2daand both cached independently. No consumer was found mapping either to what this page has informally called weapon class, andCheckProficiencies(0x00510e30) uses a separately cached required-feat array rather than either of them. Picking between them is a naming decision rather than an open trace. - Which column drives the container gate. The gate itself is traced and the
read and write sides agree, but the column behind it has not been followed. So
ItemListis documented by what gates it rather than by which column does the gating. - What the engine does with a container item. No base item in a retail install is a container, so the non-zero branch is code nothing shipped exercises. The behaviour is read from the call sites rather than observed.
- The list transfer behind the container handoff. Traced per call site, but
neither
ReadContainerItemsFromGffnorSaveContainerItemswas followed into its body; the question at the time was the gate.
Every label the schema declares
Generated from the schema, so no label can be quietly left out. How to read these tables.
What the engine does with each field
| Field | Type | Engine | When absent |
|---|---|---|---|
PaletteID | BYTE | never reads it: UTI-004 records this as a toolset-only field bypassed on load entirely and never read by the K1 engine | not one constant; we substitute 0 |
Comment | CExoString | never reads it: UTI-004 records this as a toolset-only field bypassed on load entirely and never read by the K1 engine | not one constant; we substitute "" |
BodyVariation | BYTE | never reads it: read by nothing on the item path | not one constant; we substitute 0 |
UpgradeLevel | BYTE | never reads it: UTI-004 records this as a toolset-only field bypassed on load entirely and never read by the K1 engine | not one constant; we substitute 0 |
Fields nobody has examined
Whether the engine reads these has not been established, which is not the same as establishing that it does not. Where When absent carries an answer, that half is settled.
| Field | Type | When absent |
|---|---|---|
TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
BaseItem | INT | keeps 30 |
LocalizedName | CExoLocString | keeps empty |
Description | CExoLocString | keeps empty |
DescIdentified | CExoLocString | keeps empty |
Tag | CExoString | keeps "" |
Charges | BYTE | stamps 50 |
Cost | DWORD | NOT EXAMINED; we substitute 0 |
StackSize | WORD | keeps 1 |
Plot | BYTE | keeps 0 |
AddCost | DWORD | keeps 0 |
TextureVar | BYTE | stamps 1 |
Stolen | BYTE | keeps 0 |
Identified | BYTE | stamps 1 |
Dropable | BYTE | stamps 0 |
Pickpocketable | BYTE | stamps 0 |
NonEquippable | BYTE | keeps 0 |
NewItem | BYTE | keeps 0 |
DELETING | BYTE | keeps 0 |
Upgrades | DWORD | keeps 0 |
PropertiesList | List | not one constant; we substitute container |
PropertiesList[].CostTable (required) | BYTE | whatever the memory held; we substitute 0 |
PropertiesList[].CostValue (required) | WORD | whatever the memory held; we substitute 0 |
PropertiesList[].Param1 (required) | BYTE | whatever the memory held; we substitute 0 |
PropertiesList[].Param1Value (required) | BYTE | whatever the memory held; we substitute 0 |
PropertiesList[].PropertyName (required) | WORD | whatever the memory held; we substitute 0 |
PropertiesList[].Subtype (required) | WORD | whatever the memory held; we substitute 0 |
PropertiesList[].ChanceAppear (required) | BYTE | whatever the memory held; we substitute 100 |
PropertiesList[].Useable | BYTE | not one constant; the field holds the absence |
PropertiesList[].UsesPerDay | BYTE | stamps 0 |
PropertiesList[].UpgradeType | BYTE | stamps 0; we keep the absence instead |
MaxCharges | BYTE | not one constant; our reader works it out from other fields |
ModelVariation | BYTE | not one constant; our reader works it out from other fields |
ModelPart1 | BYTE | not one constant; our reader works it out from other fields |
UTM Format (Merchant Blueprint)
A .utm file is a merchant’s store: what it stocks, and what it charges. The format is small because a store is mostly a list of .uti items plus two markup percentages. The items carry their own stats, so the store only has to name them and price them.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .utm |
| Magic Signature | UTM / V3.2 |
| Type | Merchant Blueprint |
| Rust Reference | View rakata_generics::Utm in Rustdocs |
Field Schema
The format’s field families, as an orientation before the full list.
| 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 |
Engine Audits & Decompilation
Read from CSWSStore::LoadStore at 0x005c7180 in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
The load path
| Function | Size | Behaviour |
|---|---|---|
LoadStore | 1341 B | The main parser. It reads the merchant’s identity, its MarkUp/MarkDown pricing, and its buy and sell permissions. |
ItemList Read | n/a | Walks the store’s stock, taking either a saved item snapshot or a template named by InventoryRes. |
AddItemToInventory | n/a | Adds each item to the store’s inventory so the player can browse and buy it. |
Rules the engine enforces
| Engine Rule | Runtime Behaviour |
|---|---|
| Cost Sorting | The engine sorts the store’s stock by cost, cheapest first, as it builds the inventory. A display order set in the file does not survive. |
| Dynamic Economics | MarkUp and MarkDown are percentages applied to an item’s base cost, one for what the merchant sells and one for what it buys. |
| Buy/Sell Bit Flags | BuySellFlag is two toggles: bit 0 lets the player sell to the merchant, bit 1 lets the merchant sell to the player. |
| 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, with buy and sell both allowed. |
| Infinite Stacking | An item flagged Infinite is never depleted. The player can buy it repeatedly and the stock does not fall. |
| Save vs. Template Inventory | On the savegame path every ItemList entry is a self-contained item snapshot. InventoryRes is consulted only on the template path, where it names a .uti the engine expands via CSWSItem::LoadFromTemplate; that resref is never written back into a save. |
Absent ItemList | A pure skip, not a clear. The whole block is gated on the list being found, and nothing in the function clears existing stock. LoadStore only ever adds items. |
The remaining absent-field defaults
Tag and LocName are unconditional literal stamps, an empty string and an empty localized string, with no presence check afterward. MarkUp and MarkDown are unconditional literal 0, so a missing markup is price-neutral rather than an error. OnOpenStore defaults to an empty resref, the ordinary “no script” sentinel.
Comment and ID are dead outright. Neither field-name string exists anywhere in swkotor.exe, so LoadStore cannot read either under any circumstance. That is a stronger claim than the one made for Repos_PosX/Repos_Posy below. Those strings do exist, and CSWSItem::ReadContainerItemsFromGff reads them for items nested inside a container item. LoadStore never does, so a store’s grid coordinates go unread on this path while the same labels are live on another one.
Infinite belongs to the store entry; Dropable does not
Infinite is read off each ItemList entry with an unconditional literal 0 default, setting a bit the store loader owns outright. The underlying .uti has no say in it.
LoadStore’s ItemList loop never reads Dropable at all. It comes from the item load chain: a freshly constructed item is droppable, and CSWSItem::LoadDataFromGff then re-reads Dropable with an unconditional literal 0, overwriting that regardless of source.
Entries resolving a linked .uti through EquippedRes/InventoryRes get a second, narrower read off the store entry afterward, which can override the item’s value only if the store entry supplies it. Absent, whatever the item’s own load set stands, already false by that point rather than the constructor’s true. On the pure template path the entry is never asked for Dropable.
Pickpocketable has the same two-stage shape
CSWSItem::LoadItem reads Pickpocketable a second time off the store’s list entry, gated on presence, but only in the branch that resolved a linked .uti and only after that branch called LoadDataFromGff, which zeroes the bit unconditionally.
So by the time the gated re-read runs, the constructor’s true is gone. An absent Pickpocketable leaves the bit false. The presence-gated read can push it to true where the store entry supplies it; it cannot restore the constructed default the way a genuine carry-over would.
ItemList[].ObjectId never survives as the sentinel
LoadStore reads it per entry, defaulting to 0x7F000000. The id is checked against the running server’s live object table first: a matching live object means the entry is skipped rather than duplicated, which matters most when loading an in-progress session.
Where no live object matches, the loader allocates a new item and passes the resolved id into CSWSItem’s constructor and on to CSWSObject’s. That base constructor checks for 0x7F000000 specifically and, on a match, discards it and requests a fresh unique id from the global object table.
So an absent ObjectId never lands on the live item as the sentinel. The value exists transiently as the read’s return value, on its way to being replaced.
Fields the engine never reads
What a writer should do with each is a separate question, and it has four possible answers: see the engine ignores this is not you may leave it out.
| Finding Type | Explanation |
|---|---|
| Legacy Interface Configurations | Repos_PosX and Repos_Posy are shop-grid coordinates inherited from other Odyssey games. LoadStore never reads either, and the shop UI is built when the player opens it, so a store’s coordinates go unused. They are not dead everywhere, though: the same two labels are live for items nested inside a container item, as above. |
Note
Repos_PosYwith a capitalYis a third label, and no string for it exists inswkotor.exeon any object type. The engine’s own string is the lowercaseRepos_Posy, and label lookup is case-sensitive, so the two never resolve to each other. Rakata’s reader accepts either spelling and prefers the lowercase one.
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 carry non-zero shop-grid coordinates;
LoadStorebuilds its UI on open and never reads them. - 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 behaviour and 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, since the engine never reads it.
Every label the schema declares
Generated from the schema, so no label can be quietly left out. How to read these tables.
What the engine does with each field
| Field | Type | Engine | When absent |
|---|---|---|---|
ItemList[].Repos_PosX | WORD | never reads it: an older tool’s shop-grid coordinate; the engine builds its shop UI dynamically when opened and never reads these | not one constant; we substitute 0 |
ItemList[].Repos_Posy | WORD | never reads it: an older tool’s shop-grid coordinate; the engine builds its shop UI dynamically when opened and never reads these | not one constant; we substitute 0 |
ItemList[].Repos_PosY | WORD | never reads it: an older tool’s shop-grid coordinate; the engine builds its shop UI dynamically when opened and never reads these | not one constant; we substitute 0 |
Fields nobody has examined
Whether the engine reads these has not been established, which is not the same as establishing that it does not. Where When absent carries an answer, that half is settled.
| Field | Type | When absent |
|---|---|---|
ResRef | CResRef | NOT EXAMINED; we substitute "" |
Tag | CExoString | stamps "" |
LocName | CExoLocString | stamps empty |
MarkUp | INT | stamps 0 |
MarkDown | INT | stamps 0 |
OnOpenStore | CResRef | stamps "" |
Comment | CExoString | NOT EXAMINED; we substitute "" |
ID | BYTE | NOT EXAMINED; we substitute 0 |
BuySellFlag | BYTE | keeps 3 |
ItemList | List | not one constant; we substitute container |
ItemList[].InventoryRes | CResRef | NOT EXAMINED; we substitute "" |
ItemList[].Dropable | BYTE | stamps 0 |
ItemList[].Infinite | BYTE | stamps 0 |
ItemList[].ObjectId | DWORD | stamps 2130706432 |
UTP Format (Placeable Blueprint)
A .utp file is a placeable: the containers, scenery and consoles a player can walk up to and use. It carries the object’s appearance, whether it is locked or trapped, how much damage it takes before breaking, and the scripts that fire when somebody interacts with it.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .utp |
| Magic Signature | UTP / V3.2 |
| Type | Placeable Blueprint |
| Rust Reference | View rakata_generics::Utp in Rustdocs |
Field Schema
The format’s field families, as an orientation before the full list.
| 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 |
| Behavioural Hooks | The scripts that run when a player explores, attacks, or opens the placeable | OnOpen, OnInvDisturbed, OnDamaged |
Engine Audits & Decompilation
Read from CSWSPlaceable::LoadPlaceable at 0x00585670 in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
The load path
| Function | Size | Behaviour |
|---|---|---|
LoadPlaceable | 5092 B | The main parser, reading 46 core fields including health, conversation, trap bindings and placement. All 16 script hooks are read inside this same function as hand-unrolled, byte-for-byte identical boilerplate; there is no separate script-reading function for placeables. ReadScriptsFromGff (documented on other pages) is not it: its only callers are creature-related loaders, never LoadPlaceable. |
CSWSArea::LoadPlaceables allocates every placed entry and runs it through CSWSPlaceable’s real constructor unconditionally, before it looks at whether a template applies, the identical shape already confirmed for doors and triggers. So a save-restored placeable’s fields come off the same freshly-constructed object a templated one does, with no separate, lighter-weight path for the save-instance case. Confirmed directly: an absent script hook on a save-restored placeable resolves to an empty resref, since unlike doors and triggers CSWSPlaceable’s constructor never seeds its script slots to "default" (see below), and the trap-settings fields resolve to the same constructed defaults already documented for the blueprint path, identically on both load paths.
Rules the engine enforces
| Engine Rule | Runtime Behaviour |
|---|---|
| Appearance Truncation | The engine reads Appearance as a 32-bit integer and keeps its low byte alone. An id above 255 therefore arrives as a different row of placeables.2da, and the placeable renders as whatever model that row names. |
| Static vs. Plot Chaining | As with doors, a placeable marked Static=1 behaves as though Plot=1, so it cannot be destroyed whatever HP it carries. |
| Default Usability Check | An absent Static is derived from Useable: the placeable is static when it is not useable. |
| Portrait Shadowing | Where PortraitId is < 0xFFFE, the Portrait resref is dead data and the ID decides. An absent PortraitId defaults to the literal sentinel 0xFFFF, the same unconditional literal UTC and UTT use, which fails that check and routes to the string branch exactly as an explicit 0xFFFE would. Portrait is read once, inside that branch only, where UTT reads it a second time unconditionally, and defaults to an empty resref, so a placeable with neither field ends up with one. |
| Ground Pile Forcing | GroundPile is read and the result discarded, and the field is forced to 1 in memory. Setting it in a file changes nothing. |
| Missing Door Hooks | Toolsets expose OnFailToOpen for placeables, but it belongs to .utd doors. LoadPlaceable never reads it. |
| Trap Hook Fallback | If a trap bounds check fails or the OnTrapTriggered script is blank, the engine reads traps.2da and takes the default script for that 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
Writing HasInventory has no observable effect. The label is read as a BYTE with an unconditional literal 0 into a member nothing else in LoadPlaceable acts on.
The member the engine treats as “has inventory” is populated from DieWhenEmpty instead. Both reads are ordinary; the surprise is entirely in the label-to-member wiring, which looks like a field renamed engine-side without the GFF label following.
HP, CurrentHP and the trap flags overwrite their constructed defaults
Five fields construct to a nonzero value and then read with a hardcoded literal 0, unconditional. An absent field lands on that 0, not on what the constructor set.
| Field | Constructed | Absent resolves to |
|---|---|---|
HP (maximum), CurrentHP | 1 | 0 |
TrapDetectable, TrapDisarmable, TrapOneShot | 1 | 0 |
So a .utp omitting them loads a placeable with no hit points and a trap that neither detects nor disarms.
Plot and Invulnerable are one member, chosen by presence
CSWSPlaceable has no invulnerable member: both labels write the same plot flag. The loader reads Invulnerable first and, where that field is present at all, never reads Plot. Only an absent Invulnerable lets Plot be read. Both fall back to the object’s current plot value, 0 from construction, and either result then feeds the Static-forces-Plot=1 override above.
Invulnerable is live even though the vanilla toolset never writes it. A file supplying one pre-empts its own Plot entirely.
LightState takes its default from placeables.2da
LightState’s absent-value default is not a constant. The read looks the placeable’s already-resolved (and already-truncated) Appearance up in placeables.2da’s LightColor column: an entry there gives 1 (light on), no entry gives 0 (light off).
That makes it a sibling-derived default, unusual in taking its sibling through a 2DA row rather than another GFF field.
Open, Animation and AnimationState form a gated chain
Open is an ordinary unconditional-literal-0 field, and its resolved value decides whether the other two are read at all.
Open | Animation | AnimationState | Animation state applied |
|---|---|---|---|
| non-zero | not read | not read | the sentinel 10075 |
0 | present | not reached | the raw value, unvalidated |
0 | absent | present | indexed into six preset sentinels; above 5 collapses to 10000 |
0 | absent | absent | none: the call is skipped |
That last row is a presence-chain abort scoped to this one step rather than to the whole struct.
Remaining Absent-Field Defaults
Note
traps.2dacolumns differ by object type A door or placeable reads its default trap script fromMineScript, where a trigger readsTrapScript. Doors and placeables also take their disarm and detect DCs from their own GFF fields rather than from the table’sDisarmDCModandDetectDCMod, which only triggers consult. See UTT for the trigger side.
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 remaining script hooks (OnClosed, OnDamaged, OnDeath, OnDisarm, OnHeartbeat, OnInvDisturbed, OnLock, OnMeleeAttacked, OnOpen, OnSpellCastAt, OnUnlock, OnUsed, OnUserDefined, OnDialog, OnEndDialogue) default to an empty resref, unconditional. CSWSPlaceable’s constructor never seeds its script slots, so an absent hook is genuinely empty here where the same field on a door or trigger would carry the literal string "default". (OnTrapTriggered and OnFailToOpen are documented separately above.)
ItemList entries, and the nested container path
LoadPlaceable’s own ItemList loop reads one field per entry: ObjectId (DWORD), defaulting to the 0x7F000000 object-reference sentinel rather than 0. Everything else about an inventory item comes from the shared item-load chain.
Repos_PosX/Repos_Posy are live fields that do not belong to a placeable. Both BYTE, both defaulting to the sentinel 0xFF, they are read by CSWSItem::ReadContainerItemsFromGff, whose one caller (CSWSItem::LoadDataFromGff) fires only when the item being loaded is itself a container, a bag per baseitems.2da. So they position items nested inside a bag that happens to sit in a placeable’s inventory. LoadPlaceable never reads either.
Each contained item, Dropable included, goes through CSWSItem::LoadItem, the same function at the same address UTM’s ItemList entries call, so the resolution transfers by identity rather than analogy. Both of its branches land the same way:
- With neither
EquippedResnorInventoryRes, it falls through toLoadDataFromGff’s unconditional hardcoded0. - With either, it resolves the template, runs
LoadDataFromGff(which zeroes the bit), then re-reads under a presence gate that can only push the bit totrue.
So a container-nested item with Dropable absent ends up false either way.
An absent ItemList is a skip, not a clear, in both readers: LoadPlaceable’s own loop, and ReadContainerItemsFromGff’s read, the latter gated on the item’s repository pointer being non-null and the list resolving. Neither clears or deallocates first, so a placeable or bag that already holds inventory keeps it.
Fields the engine never reads
What a writer should do with each is a separate question, and it has four possible answers: see the engine ignores this is not you may leave it out.
| Finding Type | Explanation |
|---|---|
| Legacy Engine Artifacts | Placeable binaries carry legacy metrics from older tools or other Odyssey games (Comment, OpenLockDiff, Interruptable, Type, PaletteID), none of which LoadPlaceable reads. OpenLockDiff, OpenLockDiffMod and NotBlastable are confirmed dead the same decisive way as IsComputer below: none of the three field-name strings exists anywhere in swkotor.exe, so no code path can read them at all, rather than reading and ignoring them. |
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 the same way as several unmodeled DLG fields rather than by it being always zero in the sample. |
| Live Somewhere Else, Dead Here | Infinite and LoadScreenID both turn up in .utp files, and the engine really does read both, just never on a placeable. Infinite has exactly two cross-references in the binary, both inside CSWSStore::LoadStore and its save counterpart, so nothing in the placeable’s item-loading call graph ever asks for it. LoadScreenID is read on doors, triggers and areas. These are the ones that catch people out: the label is real, the behaviour is real, and neither is wired to this file. |
Repos_PosY Is a Spelling Nothing Looks For | The capital-Y string does not exist anywhere in swkotor.exe, on any object type. What the engine looks up on an ItemList entry is Repos_PosX and lowercase Repos_Posy. The two engine spellings travel together: where a retail entry carries one it carries both. The capital Repos_PosY is mod content, written by an older tool, and turns up in module archives and override blueprints where no shipped file carries it, so a reader matching only Repos_Posy will miss a grid coordinate sitting right there in the struct of a file somebody actually installed. Neither spelling changes what the game does, since the shop UI is built when it opens, but a tool that silently normalizes one to the other is rewriting a label rather than a value. |
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), anyOn*script hook (.ncs), orItemList[i].InventoryRes(.uti) does not resolve in the configured resource sources.OnFailToOpenis intentionally excluded, since 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.
Every label the schema declares
Generated from the schema, so no label can be quietly left out. How to read these tables.
What the engine does with each field
| Field | Type | Engine | When absent |
|---|---|---|---|
Comment | CExoString | never reads it: legacy metrics from older tools or other Odyssey games, listed among the placeable fields the engine never reads | not one constant; we substitute "" |
OpenLockDiff | BYTE | never reads it: legacy metrics from older tools or other Odyssey games, listed among the placeable fields the engine never reads | not one constant; we substitute 0 |
OpenLockDiffMod | CHAR | never reads it: legacy metrics from older tools or other Odyssey games, listed among the placeable fields the engine never reads | not one constant; we substitute 0 |
NotBlastable | BYTE | never reads it: read by nothing on the placeable path | not one constant; we substitute 0 |
GroundPile | BYTE | never reads it: the loader reads this and throws the result away, then forces the placeable to a ground pile regardless | NOT EXAMINED; we substitute 1 |
PaletteID | BYTE | never reads it: legacy metrics from older tools or other Odyssey games, listed among the placeable fields the engine never reads | not one constant; we substitute 0 |
ItemList[].Infinite | BYTE | never reads it: the Infinite label has exactly two cross-references in the binary, both inside CSWSStore::LoadStore and SaveStore, so no function in this type’s item-loading call graph reads it | not one constant; we substitute 0 |
ItemList[].Repos_PosY | WORD | never reads it: no Repos_PosY string exists in swkotor.exe on any object type; the engine reads Repos_PosX and lowercase Repos_Posy only | not one constant; we substitute 0 |
LoadScreenID | WORD | never reads it: utp.md never names the field, and the engine reads it on doors, triggers and areas rather than placeables | not one constant; we substitute 0 |
OnFailToOpen | CResRef | never reads it: this hook belongs to doors; a placeable never reads it | NOT EXAMINED; we substitute "" |
Fields nobody has examined
Whether the engine reads these has not been established, which is not the same as establishing that it does not. Where When absent carries an answer, that half is settled.
| Field | Type | When absent |
|---|---|---|
TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
Tag | CExoString | stamps "" |
LocName | CExoLocString | stamps empty |
Description | CExoLocString | stamps empty |
Conversation | CResRef | stamps "" |
Faction | DWORD | stamps 0 |
Appearance | DWORD | NOT EXAMINED; we substitute 0 |
AnimationState | BYTE | stamps 0 |
Animation | INT | stamps 0 |
Open | BYTE | stamps 0 |
Lockable | BYTE | stamps 0 |
Locked | BYTE | stamps 0 |
KeyRequired | BYTE | stamps 0 |
KeyName | CExoString | stamps "" |
AutoRemoveKey | BYTE | stamps 0 |
OpenLockDC | BYTE | stamps 0 |
CloseLockDC | BYTE | stamps 0 |
CurrentHP | SHORT | stamps 0 |
HP | SHORT | stamps 0 |
Hardness | BYTE | stamps 0 |
Fort | BYTE | stamps 0 |
Ref | BYTE | stamps 0 |
Will | BYTE | stamps 0 |
Plot | BYTE | keeps 0 |
Invulnerable | BYTE | keeps 0 |
Min1HP | BYTE | keeps 0 |
Static | BYTE | not one constant; our reader works it out from other fields |
Useable | BYTE | stamps 0 |
PartyInteract | BYTE | stamps 0 |
HasInventory | BYTE | stamps 0 |
DieWhenEmpty | BYTE | stamps 0 |
LightState | BYTE | not one constant; we substitute 0 |
Interruptable | BYTE | NOT EXAMINED; we substitute 0 |
PortraitId | WORD | stamps 65535 |
Portrait | CResRef | stamps "" |
BodyBag | BYTE | keeps 0 |
Type | BYTE | NOT EXAMINED; we substitute 0 |
IsBodyBag | BYTE | keeps 0 |
IsCorpse | BYTE | keeps 0 |
TrapDetectable | BYTE | stamps 0 |
TrapDetectDC | BYTE | stamps 0 |
TrapDisarmable | BYTE | stamps 0 |
DisarmDC | BYTE | stamps 0 |
TrapFlag | BYTE | stamps 0 |
TrapOneShot | BYTE | stamps 0 |
TrapType | BYTE | keeps 255 |
OnClosed | CResRef | stamps "" |
OnDamaged | CResRef | stamps "" |
OnDeath | CResRef | stamps "" |
OnDisarm | CResRef | stamps "" |
OnHeartbeat | CResRef | stamps "" |
OnInvDisturbed | CResRef | stamps "" |
OnLock | CResRef | stamps "" |
OnMeleeAttacked | CResRef | stamps "" |
OnOpen | CResRef | stamps "" |
OnSpellCastAt | CResRef | stamps "" |
OnUnlock | CResRef | stamps "" |
OnUsed | CResRef | stamps "" |
OnUserDefined | CResRef | stamps "" |
OnDialog | CResRef | stamps "" |
OnEndDialogue | CResRef | stamps "" |
OnTrapTriggered | CResRef | stamps "" |
ItemList | List | not one constant; we substitute container |
ItemList[].InventoryRes | CResRef | NOT EXAMINED; we substitute "" |
ItemList[].Dropable | BYTE | stamps 0 |
ItemList[].Repos_PosX | WORD | stamps 255 |
ItemList[].Repos_Posy | WORD | stamps 255 |
ItemList[].ObjectId | DWORD | stamps 2130706432 |
UTS Format (Sound Object Blueprint)
A .uts file is a sound emitter: one or more .wav clips, the distance they carry, and how the engine varies them each time they play. They cover environmental hums, crowd chatter and localized looping effects.
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 |
Field Schema
The format’s field families, as an orientation before the full list.
| 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 |
Engine Audits & Decompilation
Read from CSWSSoundObject::Load at 0x005c9040 in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
A sound object has no script hooks. Everything it does comes from playback scalars and the randomised placement the Positional and Random* fields drive.
The load path
| Function | Size | Behaviour |
|---|---|---|
Load | 1345 B | The main parser. It reads the emitter’s placement, its distance bounds, and the volume, pitch and looping scalars. |
Sounds List | n/a | Walks the list of clips, loading each Sound resref in turn. |
Rules the engine enforces
| Engine Rule | Runtime Behaviour |
|---|---|
| Generated Type Truncation | GeneratedType is read as a 32-bit integer and stored as its low byte alone. A value above 255 therefore arrives as a different generator type than the one written. |
| Spatial Loading Context | Loaded through the area’s static map (CSWSArea::LoadSounds), the engine skips the .uts coordinates and takes XPosition/YPosition/ZPosition from the .git. A sound carries no orientation; placement comes from the Positional/RandomPosition flags plus RandomRangeX/RandomRangeY. |
| Silent Sound Lists | An entry is pushed into playable memory only where the file supplied a Sound resref. Missing entries are ignored rather than erroring. |
Almost every scalar carries over rather than defaulting to a literal
An absent field falls back to whatever the object already holds, so the constructor supplies the value rather than the read site:
| Field | Constructed | Field | Constructed |
|---|---|---|---|
Active | 1 | Interval | 0 |
Positional | 1 | IntervalVrtn | 0 |
Looping | 0 | MinDistance | 10.0 |
Volume | 127 | MaxDistance | 20.0 |
VolumeVrtn | 0 | Continuous | 0 |
Times | 3 | Random | 0 |
PitchVariation | 0.0 | FixedVariance | 1.0 |
Hours | 0 | RandomPosition | 0 |
GeneratedType | 0 | RandomRangeX | 0.0 |
RandomRangeY | 0.0 |
Tag works the same way, starting empty, and its result is re-applied through SetTag either way.
Position is the exception. XPosition, YPosition and ZPosition fall back to a fixed literal 0.0 rather than the carried-over value, applied through SetPosition unconditionally. For a sound placed through a .git the instance’s own values win, so that 0.0 surfaces only for a sound opened outside the placement path.
The return value is whichever field was read last
Load returns the found-flag of its last read: the final Sound resref in the Sounds list, or ZPosition where the list was absent or empty. The area-level save loader deletes the sound object outright when that flag comes back false.
A vanilla sound always carries a ZPosition, so this never bites real saves. A hand-authored .git sound entry with neither a populated Sounds list nor a ZPosition is silently dropped on load.
Fields the engine never reads
What a writer should do with each is a separate question, and it has four possible answers: see the engine ignores this is not you may leave it out.
| Finding Type | Explanation |
|---|---|
| Legacy Engine Artifacts | TemplateResRef, LocName, Comment, Elevation, Priority and PaletteID are inherited from Aurora, the BioWare engine Odyssey descends from. CSWSSoundObject::Load reads none of them. |
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 keeps the low byte alone, so the stored type is not the one written. - UTS-005 (Legacy Engine Artifacts): Informs when
TemplateResRef,Elevation,Priority, orPaletteIDare populated;CSWSSoundObject::Loadreads none of them.
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).
Every label the schema declares
Generated from the schema, so no label can be quietly left out. How to read these tables.
Fields nobody has examined
Whether the engine reads these has not been established, which is not the same as establishing that it does not. Where When absent carries an answer, that half is settled.
| Field | Type | When absent |
|---|---|---|
TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
Tag | CExoString | NOT EXAMINED; we substitute "" |
LocName | CExoLocString | NOT EXAMINED; we substitute empty |
Comment | CExoString | NOT EXAMINED; we substitute "" |
Active | BYTE | keeps 1 |
Continuous | BYTE | keeps 0 |
Looping | BYTE | keeps 0 |
Positional | BYTE | keeps 1 |
RandomPosition | BYTE | keeps 0 |
Random | BYTE | keeps 0 |
Elevation | FLOAT | NOT EXAMINED; we substitute 0.0 |
MaxDistance | FLOAT | keeps 20.0 |
MinDistance | FLOAT | keeps 10.0 |
RandomRangeX | FLOAT | keeps 0.0 |
RandomRangeY | FLOAT | keeps 0.0 |
Interval | DWORD | keeps 0 |
IntervalVrtn | DWORD | keeps 0 |
PitchVariation | FLOAT | keeps 0.0 |
Priority | BYTE | NOT EXAMINED; we substitute 0 |
Volume | BYTE | keeps 127 |
VolumeVrtn | BYTE | keeps 0 |
Hours | DWORD | keeps 0 |
Times | BYTE | keeps 3 |
PaletteID | BYTE | NOT EXAMINED; we substitute 0 |
FixedVariance | FLOAT | keeps 1.0 |
GeneratedType | DWORD | keeps 0 |
Sounds | List | NOT EXAMINED; we substitute container |
Sounds[].Sound | CResRef | NOT EXAMINED; we substitute "" |
XPosition | FLOAT | stamps 0.0 |
YPosition | FLOAT | stamps 0.0 |
ZPosition | FLOAT | stamps 0.0 |
UTT Format (Trigger Blueprint)
A .utt file is a trigger: an invisible polygon on the floor that does something when a character walks into it. That something is one of three things, running a script, moving the party to another area, or springing a trap.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .utt |
| Magic Signature | UTT / V3.2 |
| Type | Trigger Blueprint |
| Rust Reference | View rakata_generics::Utt in Rustdocs |
Field Schema
The format’s field families, as an orientation before the full list.
| 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 & Behavioural 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 |
Engine Audits & Decompilation
Read from CSWSTrigger::LoadTrigger at 0x0058da80 in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
The load path
| Function | Size | Behaviour |
|---|---|---|
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) | n/a | 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) | n/a | The area-level dispatcher. Per placed trigger it calls LoadTrigger directly on the .git instance struct, or LoadFromTemplate where one applies, re-applying several instance-only fields afterward. See “Geometry and the Instance Overlay” below. |
Both load paths start from the same constructed object. Every placed trigger is allocated and run through CSWSTrigger’s real constructor, which seeds all seven script slots to "default" and TrapType to 0xFF, before the dispatcher looks at whether a template applies. LoadTrigger takes no caller-context parameter, so the save-instance call and the templated call invoke it identically.
So an absent script hook or trap field on a save-restored trigger resolves to the same constructed default as on the blueprint path, with no divergence between them.
Rules the engine enforces
| Engine Rule | Runtime Behaviour |
|---|---|
| Behaviour Derived from Type | The Type field decides the trigger’s behaviour and UI cursor. Type 1 makes it a map transition zone. Type 2 makes it a trap. |
| OnClick Duplication Bug | The engine copies the ScriptOnEnter value over the OnClick listener by default, unless OnClick is 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, a present value or ScriptOnEnter’s own carried-over default (see below), rather than the raw constructed value. |
| 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), so a trigger missing both TrapType and OnTrapTriggered looks up row 255 of traps.2da, which almost certainly doesn’t exist. That is 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 | An instance orientation re-rotates the geometry vertices about the trigger position. See below. |
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, with 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 ID decides and the Portrait string ResRef is dead data. |
| Presence-Gated Position and Orientation | SetPosition and SetOrientation are applied only where the corresponding fields were present. Every vanilla writer emits both, so this matters for hand-edited files: the trigger keeps whatever it already held rather than resetting to the origin. |
Orientation and geometry are two halves of one shape
An instance orientation re-rotates the 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 normalised where it is not unit length. Where the instance also carries an explicit Geometry list, that list is applied directly instead.
So a trigger’s shape is position plus yaw plus position-relative geometry, and rewriting the orientation without re-baking the geometry desyncs the two.
Two trap flags ignore their constructed value; one does not
TrapDisarmable and TrapDetectable are both 1 on a freshly constructed trigger, but each read’s missing-field fallback is a hardcoded literal 0 that ignores the constructed value. A file omitting them loads as non-disarmable and non-detectable, the opposite of what a fresh trigger suggests.
TrapOneShot does not share that. Its read carries over the object’s current value, which the constructor set to 1, so an absent TrapOneShot resolves to 1.
TrapType also carries over, from a constructed 0xFF, which is what feeds the trap-hook fallback above. So the literal-0 override is specific to TrapDisarmable and TrapDetectable, not a rule about trap flags.
An absent field usually commits nothing, and LinkedToFlags is the exception
LinkedTo, LinkedToModule, AutoRemoveKey, Tag and Faction each name a constant fallback while reading, but that constant only populates the read. Where the field is absent nothing is committed, so the trigger keeps whatever it already held.
LinkedToFlags does not follow that, despite sitting in the same instance-overlay family below: it reads with a hardcoded literal 0 and is stamped unconditionally, with no found-flag gate. That is deliberate rather than an oversight: UTD’s LoadDoor reads its own LinkedToFlags the same unconditional way.
Remaining Absent-Field Defaults
Note
Which
traps.2dacolumns, since it is not one column and it differs by object type A trigger reads its default script fromTrapScript; a door or a placeable readsMineScript. The two are separate columns and the wrong one silently supplies the wrong script.The DC columns split the same way. Only triggers consult
DisarmDCModandDetectDCMod; doors and placeables take their disarm and detect DCs from their own GFF fields and never touch those columns at all.So all three blueprint types route to this table and none of them routes to the same place.
Every script slot defaults to the literal string "default" rather than 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 rest 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
It 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 rather than from the observed outcome, to be the identical pattern already documented for UTD’s LoadDoor. Whatever “instance wins” behaviour 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, with no found-flag check at that overlay site either.
PortraitId and Portrait
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, where UTD’s LoadDoor reads Portrait only once.
The remaining literals
These are unconditional:
| Field | Absent value |
|---|---|
LocalizedName | empty localized string |
KeyName | empty string |
Cursor, Type | 0 |
SetByPlayerParty, LoadScreenID | 0 |
CreatorId | 0x7F000000 |
An absent Type satisfies neither the transition nor the trap branch, so the Cursor override those branches can apply never fires either.
CreatorId is an object reference, not a plain integer, defaulting to the same 0x7F000000 sentinel as AreaId and GIT’s LastEntered/LastLeft. CSWSTrigger’s constructor sets that same literal independently of the read. Unlike AreaId, where the constructor and the read-call literal disagree, a trigger’s CreatorId lands on the sentinel either way.
Geometry and the Instance Overlay
Geometry’s absence from every vanilla .utt blueprint is an authoring-tool habit, not an engine restriction. LoadTrigger gets the same call whether its struct came from a blueprint or from a fully inline .git trigger, and reads Geometry identically either way.
The fork is one level up, in CSWSArea::LoadTriggers (0x0050a350). For a template-backed trigger it calls LoadFromTemplate (0x0058ee06), which opens the .utt and runs LoadTrigger against the blueprint. On return it re-reads a set of fields straight off the .git instance struct: LinkedToModule, TransitionDestin (see UTD’s equivalent field for the same truncated-label pattern), LinkedTo, LinkedToFlags, position and Geometry, calling LoadTriggerGeometry a second time where the instance carries its own list.
So instance geometry is an overlay, not a rejection. The blueprint’s geometry is read and would stand if nothing replaced it.
Fields the engine never reads
What a writer should do with each is a separate question, and it has four possible answers: see the engine ignores this is not you may leave it out.
| Finding Type | Explanation |
|---|---|
| Legacy Engine Artifacts | As with other templates, older asset revisions include TemplateResRef, Comment, PaletteID and PartyRequired. LoadTrigger reads none of them. |
| Superseded Legacy Fields | Older asset revisions typically map TrapDetectDC and DisarmDC in the .utt file itself, but LoadTrigger never reads them. The DCs come from the traps.2da columns above 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.
Every label the schema declares
Generated from the schema, so no label can be quietly left out. How to read these tables.
What the engine does with each field
| Field | Type | Engine | When absent |
|---|---|---|---|
LinkedTo | CExoString | never reads it: LoadTriggers re-reads this straight off the .git instance struct after the template load, with no found-flag check at the overlay site unlike Geometry beside it, and a .utt blueprint only ever reaches LoadTrigger through that branch | NOT EXAMINED; we substitute "" |
LinkedToFlags | BYTE | never reads it: LoadTriggers re-reads this straight off the .git instance struct after the template load, with no found-flag check at the overlay site unlike Geometry beside it, and a .utt blueprint only ever reaches LoadTrigger through that branch | NOT EXAMINED; we substitute 0 |
LinkedToModule | CResRef | never reads it: LoadTriggers re-reads this straight off the .git instance struct after the template load, with no found-flag check at the overlay site unlike Geometry beside it, and a .utt blueprint only ever reaches LoadTrigger through that branch | NOT EXAMINED; we substitute "" |
TransitionDestin | CExoLocString | never reads it: LoadTriggers re-reads this straight off the .git instance struct after the template load, with no found-flag check at the overlay site unlike Geometry beside it, and a .utt blueprint only ever reaches LoadTrigger through that branch | NOT EXAMINED; we substitute empty |
PartyRequired | BYTE | never reads it: read by nothing on the trigger path | not one constant; we substitute 0 |
Fields nobody has examined
Whether the engine reads these has not been established, which is not the same as establishing that it does not. Where When absent carries an answer, that half is settled.
| Field | Type | When absent |
|---|---|---|
Tag | CExoString | keeps "" |
LocalizedName | CExoLocString | stamps empty |
Faction | DWORD | keeps 0 |
Cursor | BYTE | stamps 0 |
KeyName | CExoString | stamps "" |
PortraitId | WORD | stamps 65535 |
Portrait | CResRef | stamps "" |
ScriptHeartbeat | CResRef | keeps "default" |
ScriptOnEnter | CResRef | keeps "default" |
ScriptOnExit | CResRef | keeps "default" |
ScriptUserDefine | CResRef | keeps "default" |
OnTrapTriggered | CResRef | keeps "default" |
OnDisarm | CResRef | keeps "default" |
OnClick | CResRef | keeps "default" |
TrapType | BYTE | keeps 255 |
TrapOneShot | BYTE | keeps 1 |
TrapDisarmable | BYTE | stamps 0 |
TrapDetectable | BYTE | stamps 0 |
AutoRemoveKey | BYTE | keeps 0 |
Type | INT | stamps 0 |
HighlightHeight | FLOAT | NOT EXAMINED; we substitute 0.0 |
LoadScreenID | WORD | stamps 0 |
SetByPlayerParty | BYTE | stamps 0 |
Geometry | List | not one constant; we substitute container |
Geometry[].PointX | FLOAT | NOT EXAMINED; we substitute 0.0 |
Geometry[].PointY | FLOAT | NOT EXAMINED; we substitute 0.0 |
Geometry[].PointZ | FLOAT | NOT EXAMINED; we substitute 0.0 |
TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
Comment | CExoString | NOT EXAMINED; we substitute "" |
PaletteID | BYTE | NOT EXAMINED; we substitute 0 |
TrapDetectDC | BYTE | stamps 0 |
DisarmDC | BYTE | stamps 0 |
TrapFlag | BYTE | stamps 0 |
CreatorId | DWORD | stamps 2130706432 |
XPosition | FLOAT | not one constant; we substitute 0.0 |
YPosition | FLOAT | not one constant; we substitute 0.0 |
ZPosition | FLOAT | not one constant; we substitute 0.0 |
XOrientation | FLOAT | not one constant; we substitute 0.0 |
YOrientation | FLOAT | not one constant; we substitute 0.0 |
ZOrientation | FLOAT | not one constant; we substitute 0.0 |
UTW Format (Waypoint Blueprint)
A .utw file is a waypoint: a named position in an area with nothing visible attached to it. Scripts use them as anchors for patrol routes, spawn locations and camera targets, and a waypoint can also draw a pin on the player’s map.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .utw |
| Magic Signature | UTW / V3.2 |
| Type | Waypoint Blueprint |
| Rust Reference | View rakata_generics::Utw in Rustdocs |
Field Schema
The format’s field families, as an orientation before the full list.
| 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 |
Engine Audits & Decompilation
Read from CSWSWaypoint::LoadWaypoint at 0x005c7f30 in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
The load path
| Function | Size | Behaviour |
|---|---|---|
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. |
Rules the engine enforces
Tag and LocalizedName are unconditional stamps: both read with an empty literal default, and the presence flag is captured and never inspected, so an absent field overwrites rather than leaving a prior value. ObjectId never comes from the .utw: the .git list element assigns it, defaulting to 0x7f000000.
The map note is gated twice
If HasMapNote is 0 or missing, MapNoteEnabled and MapNote are never read at all. There is no fallback fetch; they are simply not asked for.
If HasMapNote is 1, both are read with their own defaults. But none of the three values lands on the waypoint unless MapNote itself was genuinely present. An absent MapNote on an otherwise HasMapNote=1 file discards the whole trio silently, leaving the constructed defaults (HasMapNote=0, MapNoteEnabled=0, MapNote empty) as though nothing had been touched.
MapNoteEnabled’s own default, where HasMapNote=1 and MapNote is present, is an unconditional literal 0. Unlike MapNote, its presence is never separately checked; only its value is used.
That discard combination occurs nowhere in a full install’s waypoint placements, so it is hand-authored territory. The path exists and runs as described.
Orientation normalization has its own sentinel
The engine computes the true magnitude of the orientation vector, not the squared value, and calls Vector::Normalize() whenever it is not exactly 1.0. That function guards itself: below a magnitude of 1e-9 it does not divide, and snaps to a facing of (1.0, 0.0, 0.0).
So a waypoint with all three orientation fields absent lands on that exact sentinel rather than producing garbage or a divide by zero.
Warning
This is not the
(0, 1, 0)sentinel used for area-effect orientation. The two object types do not share the normalization call, and their epsilon thresholds and fallback vectors both differ. Neither can be assumed from the other.
Placement is read twice, and the blueprint fields are real
LoadWaypoint unconditionally reads XPosition, YPosition, ZPosition, XOrientation, YOrientation and ZOrientation, applying them through SetPosition/SetOrientation before anything else. Each falls back to a literal 0.0, with no presence check consulted afterward. This runs the same whether the struct came from a .git instance or a .utw blueprint.
A waypoint placed in a .git layout then has its X and Y re-read from the .git, overriding the blueprint, with Z recomputed against the terrain collision mesh through ComputeHeight. That is a second read layered on the first, not a replacement.
A script-spawned waypoint has no GIT instance to override it, so a hand-authored .utw carrying placement fields takes them from the blueprint durably. Vanilla blueprints never author them because no vanilla workflow needs a script-spawned waypoint’s position baked into a template.
A placed waypoint never resolves a blueprint
LoadWaypoints accepts the area’s UseTemplates flag and never inspects it. Every waypoint placed in a .git layout is a full inline read of the waypoint struct.
LoadWaypoint never reads TemplateResRef at all, not even as a discarded read, which is a real difference from doors (see UTD).
LoadFromTemplate matters only for a waypoint a script spawns at runtime, and even there the resref comes from the script’s own CreateObject() argument, its sole caller being ExecuteCommandCreateObject. The TemplateResRef label is never looked up anywhere in waypoint loading.
Fields the engine never reads
What a writer should do with each is a separate question, and it has four possible answers: see the engine ignores this is not you may leave it out.
The engine ignores TemplateResRef, Appearance, PaletteID, Comment, LinkedTo and Description.
Two of those are universal rather than stale. A corpus pass over a full install found TemplateResRef on every waypoint, and LinkedTo on every waypoint, empty in every one. Both are what the toolset writes each time and the engine reads never, so “legacy padding” undersells how consistently they appear.
A writer may drop TemplateResRef and the engine will not notice, but doing so puts a diff on every waypoint in the game. Preserve it on round trip rather than inventing it on creation, since no blueprint exists for it to name. See fields the engine never reads.
LinkedToModule shares a label with UTD’s and nothing else
A handful of .utw files carry a LinkedToModule CResRef, always empty. LoadWaypoint and LoadFromTemplate read only Tag, LocalizedName, position, orientation and the map-note fields.
The LinkedToModule string in the binary is referenced from door and trigger code alone, LoadDoor, LoadDoorExternal, SaveDoor, LoadTrigger, LoadTriggers and SaveTrigger, and never from waypoint code. Waypoints have no area-transition capability at all; the two fields share interned label bytes, not a concept.
A placed waypoint’s Appearance and Description come from nowhere
Git.WaypointList[].Appearance carries a real value in every waypoint entry in a full install, and Description appears in a handful. LoadWaypoint’s field list has no room for either.
Compare Door and Placeable, where a placed instance’s Description is toolset residue but at least comes from the referenced blueprint. Waypoints resolve no TemplateResRef, so there is no blueprint to fall back to either. Neither label is 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.
Every label the schema declares
Generated from the schema, so no label can be quietly left out. How to read these tables.
What the engine does with each field
| Field | Type | Engine | When absent |
|---|---|---|---|
Appearance | BYTE | never reads it: a placed waypoint has no rendered model to select, and LoadWaypoint’s fully-decompiled field list has no room for it | not one constant; we substitute 0 |
Fields nobody has examined
Whether the engine reads these has not been established, which is not the same as establishing that it does not. Where When absent carries an answer, that half is settled.
| Field | Type | When absent |
|---|---|---|
TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
Tag | CExoString | stamps "" |
LocalizedName | CExoLocString | stamps empty |
HasMapNote | BYTE | keeps 0 |
MapNoteEnabled | BYTE | stamps 0 |
MapNote | CExoLocString | keeps empty |
PaletteID | BYTE | NOT EXAMINED; we substitute 0 |
Comment | CExoString | NOT EXAMINED; we substitute "" |
LinkedTo | CExoString | NOT EXAMINED; we substitute "" |
Description | CExoLocString | NOT EXAMINED; we substitute empty |
XPosition | FLOAT | stamps 0.0 |
YPosition | FLOAT | stamps 0.0 |
ZPosition | FLOAT | stamps 0.0 |
XOrientation | FLOAT | not one constant; we substitute 0.0 |
YOrientation | FLOAT | not one constant; we substitute 0.0 |
ZOrientation | FLOAT | not one constant; we substitute 0.0 |
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)
An .mdl file is a model’s structure: a tree of nodes, meaning bones, meshes, lights and emitters, each carrying its transform, its textures, and the controllers that animate it. The vertex data is not here. It lives in the companion .mdx, which each mesh node addresses by offset.
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 |
File Layout
A binary MDL is one contiguous blob behind a 12-byte wrapper, and almost nothing in it sits at a fixed address. Past the two headers, every block is found by reading a pointer back out of an earlier one, so a reader navigates rather than walks.
Two things make this format unlike the archive formats. The pointers are content-relative, counted from the byte after the wrapper rather than from the start of the file, so a reader adds 0x0C to every one of them. And the vertex data is not here at all. It lives in the companion .mdx, addressed by byte offsets that are relative to that file.
| Block | Size | Located by |
|---|---|---|
| Wrapper | 12 bytes | Always at 0x00 |
| Geometry header | 80 bytes | Content +0x00, so file 0x0C |
| Model header | 116 bytes | Content +0x50, immediately after the geometry header |
| Node tree | varies | root_node_ptr at content +0x28, then each node’s own child array |
| Animation headers | 136 bytes each | The animation array at content +0x58 |
| Animation node trees | varies | Each animation header’s own root pointer |
| Name offset array | 4 bytes per entry | name_offsets_ptr at content +0xB8, name_count entries |
| Name strings | NUL-terminated | Each entry of the name offset array |
| Vertex data | mdx_size bytes | The separate .mdx file, per mesh via mdx_data_offset |
That chain is not a guess about how the blocks are usually arranged. Followed on every model indexed by a retail chitin.key, it closes on all of them: the content size accounts for the file exactly, both arrays land inside it, every name offset reaches a terminated string, and the node tree walks to completion without a pointer leaving the file.
Note
What “retail model” means on this page Every measurement here was taken over the models indexed by
chitin.key. The module RIM archives also carry MDL resources, and those were checked separately: each one is a same-named copy of a model already in the base archives, and every one is byte-identical to it. So the module archives contribute no distinct model bytes and the claims below cover the shipped set rather than one archive of it.Saves contain no models at all, so nothing here speaks to them and nothing needs to.
Important
The file and the loaded struct are not the same map. The engine does not parse MDL field by field. It copies the blob into memory and then rewrites relative offsets into absolute pointers in place, which means several offsets hold one thing on disk and a different thing once resident.
+0x4Cis the clearest case: a plain2in the file, andGetType() | 0x80after load.+0x00,+0x04and+0x48carry nothing meaningful on disk at all and are filled in by the loader.Every offset on this page describes the file. The deep dive documents the loaded form and says so in its own heading. Reading one map as though it were the other produces contradictions that are artefacts of the mix-up rather than facts about the format.
Which is awkward, because the per-node-type layouts are only on that page. This page’s tables stop at the node base, so anyone implementing a Light or a Skin has to read the loaded map and use it against a file. That is safe, and here is the rule that makes it safe.
Offsets are identical in both maps. Only the contents of certain words differ. The engine copies the blob whole and then rewrites pointers in place, so nothing moves. Within a node’s type-specific record, a word differs between the two forms only if it is one of these:
- A relocated pointer. On disk it is an offset from the blob’s origin; in memory it is an absolute address. Every
CExoArrayListheader’s pointer word, and every standalone pointer field, is one of these.- A field the loader fills in. Populated at load and meaningless on disk.
Everything else is the same bytes in the same place, scalars included. So a Light’s seven tail scalars or a Skin’s counts read identically from a file and from memory, while the array-header pointers beside them do not. The deep dive’s per-type tables carry a relocation column naming exactly which words fall in the first group, which is what makes them usable against a file.
Wrapper (12 bytes)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | zero_marker | u32 | Always 0. This is what distinguishes binary from ASCII, since an ASCII model starts with a keyword. |
0x04 | mdl_content_size | u32 | Bytes following the wrapper. Plus 12 it is the file length, exactly, in every retail model. |
0x08 | mdx_file_size | u32 | Length of the companion .mdx. |
Geometry header (80 bytes, content +0x00)
| Offset | Field | Type | Notes |
|---|---|---|---|
+0x00 | function pointers | u32 x 2 | Toolset vtable pointers left in the file. Meaningless as stored. |
+0x08 | model_name | char[32] | NUL-terminated. |
+0x28 | root_node_ptr | u32 | Content-relative. Entry point for the whole node tree. |
+0x2C | node_count | u32 | Not the number of nodes in this file. See the note below. |
+0x30 | runtime arrays | 24 bytes | Zero in every retail model. |
+0x48 | ref_count | u32 | Zero on disk; the loader puts a resource handle here. |
+0x4C | model_type | u8 | 2 for geometry in every retail model. Becomes GetType() | 0x80 once loaded. |
+0x4D | padding | 3 bytes | Genuinely uninitialised rather than zeroed, so a writer should not assume it can validate them. |
Model header (116 bytes, content +0x50)
| Offset | Field | Type | Notes |
|---|---|---|---|
+0x50 | classification | u8 | A bit flag. See below. |
+0x51 | subclassification | u8 | |
+0x52 | unknown | u8 | Zero in every retail model. |
+0x53 | affected_by_fog | u8 | 0 or 1. |
+0x54 | num_child_models | u32 | Zero in every retail model. |
+0x58 | animation array | u32 x 3 | Pointer, count, capacity. The capacity is a runtime field. |
+0x64 | supermodel_ref | u32 | A leaked pointer, not a file value. See below. |
+0x68 | bounding_min | f32 x 3 | |
+0x74 | bounding_max | f32 x 3 | |
+0x80 | radius | f32 | Bounding sphere. |
+0x84 | animation_scale | f32 | |
+0x88 | supermodel_name | char[32] | NUL-terminated, spanning +0x88..+0xA8. Uses the literal string NULL for “none”. |
+0xA8 | off_anim_root | u32 | Content-relative. Equals root_node_ptr in the large majority of models. |
+0xAC | mdx_source_offset | u32 | Where in the .mdx the vertex-pool copy starts. Zero in every retail model, so the copy runs from the beginning. InputBinary::Reset reads it and then overwrites the word with the GL pool handle, so the loaded struct holds something else entirely. |
+0xB0 | mdx_size | u32 | Matches the companion file’s real length in every retail model. Read twice: once to size the GL pool, once as the copy length. |
+0xB4 | unread | u32 | Zero in every retail model, and nothing reads it. Reset consumes both neighbours and never touches this word, and ResetLite does not reference it either. |
+0xB8 | name_offsets_ptr | u32 | Content-relative, to an array of u32 string offsets. |
+0xBC | name_count | u32 |
Node header (80 bytes, every node type)
Every node begins with this, and type-specific data follows it.
| Offset | Field | Type | Notes |
|---|---|---|---|
+0x00 | type_flags | u16 | Bit field selecting what follows. See below. |
+0x02 | node_number | u16 | |
+0x04 | name_index | u16 | Index into the name table. |
+0x06 | padding | u16 | |
+0x08 | off_root | u32 | Zero on disk. |
+0x0C | off_parent | u32 | Content-relative. |
+0x10 | position | f32 x 3 | |
+0x1C | orientation | f32 x 4 | Quaternion, w first. |
+0x2C | child array | u32 x 3 | Pointer, count, capacity. Recursing this is how the tree is walked. |
+0x38 | controller keys | u32 x 3 | |
+0x44 | controller data | u32 x 3 |
Node type flags
The low bit marks a node header and the rest select attached data. Retail models use nine combinations and nothing else:
| Flags | Node kind |
|---|---|
0x0001 | Dummy, a bare transform |
0x0003 | Light |
0x0005 | Emitter |
0x0011 | Reference |
0x0021 | Trimesh |
0x0061 | Skin |
0x0121 | Dangly mesh |
0x0221 | AABB walkmesh |
0x0821 | Saber |
Animation node trees are different in kind: every node in one is a bare 0x0001, carrying a transform and controllers and nothing else.
Two defined bits appear in no retail model at all. 0x0008 marks a camera node and 0x0080 an animated mesh; both are real engine flags with no vanilla asset using them, so a reader will not meet either in shipped content and should still handle them rather than reject them.
Classification
classification at +0x50 is a bit flag rather than a small enumeration, which is why its values jump. All eight defined bits are attested in retail models:
| Value | Meaning |
|---|---|
0x00 | Other |
0x01 | Effect |
0x02 | Tile |
0x04 | Character |
0x08 | Door |
0x10 | Lightsaber |
0x20 | Placeable |
0x40 | Flyer |
No retail model combines two of them, so in practice the byte reads as a single selection even though the encoding would permit more.
Warning
node_countcounts the supermodel chain, not this file A reader that validatesnode_countagainst the nodes it can actually reach will reject every retail model that names a supermodel, which is hundreds of correct files.For a model whose
supermodel_nameisNULL, the field does equal the number of reachable nodes, exactly. For a model naming a real supermodel it equals its own reachable nodes plus the supermodel’snode_countplus one, with no exceptions anywhere in retail content. Since the supermodel’s own count is itself cumulative, the value describes the whole resolved inheritance chain rather than anything present in the file holding it.Treat it as a hint about the assembled model, not as a checksum over the bytes in front of you.
Note
supermodel_refat+0x64is not a file field It holds a heap pointer left behind by whatever built the model, non-zero in about one retail model in eight, and it is non-zero for exactly the models that name a real supermodel. There is nothing to validate and nothing to preserve: the loader overwrites it with the result of its own lookup. A writer should emit zero and a reader should ignore it.
Note
NULLis a name, not an absence Every retail model writessupermodel_name, and the overwhelming majority write the four charactersNULLrather than leaving the field empty. A reader testing for an empty string to mean “no supermodel” will try to resolve a model calledNULL.
Node types
Which of these a node is comes from the type flags above, and the flags also decide how many bytes the node occupies. Two are defined by the engine and used by no retail model, marked as such in the table.
| Type | What it is |
|---|---|
| Base | A node with no geometry, used as a group or a pivot. |
| Light | A light source, with lens flare and shadow settings. |
| Emitter | A particle system: fountains, single shots, lightning, explosions. |
| Camera | A viewport anchor for dialogue cinematics. Defined by the engine, used by no retail model. |
| Reference | An attachment point naming another model to load at it. |
| TriMesh | Triangle geometry with static vertex arrays. |
| SkinMesh | A mesh deformed at runtime by skeleton bone weights. |
| AnimMesh | A mesh carrying per-vertex animation sampled into the file. Defined by the engine, used by no retail model. |
| DanglyMesh | A mesh driven by swing constraints: displacement, tightness, period. |
| AABB | A collision tree holding the model’s own walkmesh. |
| Saber | Quad arrays used only for lightsaber blade 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.
Read from Input::Read (0x004a14b0) and InputBinary::ResetMdlNode (0x004a0900) in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
Loading and Wrapper Validation
| Pipeline Event | Ghidra Provenance & Engine Behaviour |
|---|---|
| Binary vs ASCII Detection | The engine checks the file’s first byte. A \0 sends the asset down the InputBinary path; text ("filedependancy" or "newmodel") sends it to the FuncInterp ASCII parser instead. |
| Wrapper Mapping | The first 12 bytes are the wrapper, giving the sizes of the .mdl content and the companion .mdx. |
| In-Memory Heap Dump | The engine allocates the sizes the wrapper gives, memcpys both the .mdl and the .mdx into memory, then runs the recursive Reset path to rewrite the content-relative offsets as absolute addresses. |
Node Dispatch Architecture
InputBinary::ResetMdlNode walks the tree downward, matching each node against a 16-bit type flag running from 0x0001 (base node) to 0x0821 (lightsaber).
| Mapped Property | Engine Behaviour |
|---|---|
| Sub-node Allocation Sizes | A node’s allocation size follows its type mask. A base node takes 80 bytes, an Emitter 304, and a Skin 512. |
| Parent/Child Graph Resolution | Each node reaches its children through a pointer array embedded in the node. Those pointers are content-relative on disk, so the Reset pass has to rewrite every one into an absolute address; a node whose pointer is left unrelocated is unreachable from its parent. |
Mapped Behaviour Quirks
| Mapped Property | Ghidra Provenance & Engine Behaviour |
|---|---|
| LOD Suffix Generation | Where cullWithLOD is set, the engine calls FindModel(name + "_x") and then FindModel(name + "_z"), attaching lower-detail geometry chosen by viewport distance. |
| Animation Bone Binding | Building the live hierarchy, the engine matches bones on the node_id integer and never on the node’s name string. A bone whose id does not appear in that array is not bound to the runtime hierarchy. |
| Self-Describing Keyframes | A keyframe’s width comes from its own controller type rather than a table: the engine masks the type with & 0x0F to decide whether the value is one float (a scale), three (a position), or four (a quaternion). |
Proposed Linter Rules (Rakata-Lint)
rakata-lint reads GFF formats only and does not parse .mdl yet. The behaviours above suggest these diagnostics:
Planned Lint Diagnostics:
- Skeleton / Animation Tracing: Flags animation nodes whose
node_numberis0, since every keyframe then targets the root bone and the model holds its bind pose instead of animating. This is the bug the deep dive records as freezing characters in T-pose. - Controller Mask Encoding: Checks that a controller’s type is masked against the Bezier bit (
0x10) before its rows are read, since taking the raw value misreads the row width and the misalignment carries through the rest of the block. - Emitter Detonation Allocation: Flags an
Emitterbinding thedetonatekey (controller502) while declaring itself a"Fountain". The engine routes controller502only through its"Explosion"path. - Name Graph Sanitization: Reports name-table entries no node references. These are walkmesh node names left over from the build pipeline, and the engine only ever looks the table up by
name_index, so they are inert; see the deep dive for what they are and why Rakata does not preserve them.
MDX Format (Vertex Data)
The .mdx format is a companion file that always pairs with a .mdl model. It holds lists of raw 3D coordinates (vertices), and every mesh in the .mdl carries an offset into it.
InputBinary::Reset copies the whole block into an OpenGL pool at load, and nothing afterwards reads it again. The rest of the reset chain is handed the pointer and never looks inside, which is what a straight upload leaves behind: no CPU-side code has to read a buffer already sitting in GL memory. Write it correctly. See what the trace covers.
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 |
File Layout
There is no index and no table of contents. An .mdx is one interleaved buffer: each vertex’s position, texture coordinates and normal sit next to each other in the stream, and the next vertex follows immediately. Which components a given mesh writes, and therefore how wide one vertex is, comes from the mesh node in the accompanying .mdl rather than from anything in this file.
That is the whole layout as far as reading goes. Writing needs three more rules, because a shipped .mdx is bigger than the vertex data alone and the extra bytes are structured rather than slack:
| Rule | What to emit |
|---|---|
| Terminator row | After each mesh’s vertices, one further row of exactly one stride, starting with three copies of a sentinel float and zero-padded to the stride |
| Sentinel value | 10000000.0 for a non-skin mesh, 1000000.0 for a skin mesh, the two distinguished by bit 0x40 of the mesh type |
| Inter-mesh alignment | Pad up to the next 16-byte boundary after each mesh’s terminator, except after the last mesh, which gets no trailing padding |
So the file size is the sum over meshes of vertex_count * stride, plus one stride per mesh, plus the alignment padding between them. A writer that emits only vertex data produces a file no community tool will match byte for byte.
The derivation of these rules, including how the sentinels were found and how the stride interacts with the alignment, is in the MDL & MDX deep dive.
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.
Read from InputBinary::Read (0x004a1230) and InputBinary::ResetMdlNode (0x004a0900) in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
What the trace covers
InputBinary::Read loads the file into a buffer and hands it to InputBinary::Reset (0x004a1030), which performs the upload inline: GLRender::RequestPool for a pool sized by the model header’s +0xB0, LockPool, a memcpy of that many bytes out of the buffer starting at the offset in +0xAC, UnlockPool, and the handle registered for later freeing. Read then releases the CPU-side buffer, the pool having taken its copy.
Only after that does Reset pass the pointer down the rest of the chain, and nothing below it reads through the pointer: ResetTriMeshParts goes as far as overwriting its copy to reuse the register as a loop counter. That is traced through named functions, and Reset has exactly one call site, so this is the whole load path rather than one of several.
Three findings in the deep dive describe the same operation from other angles: +0xAC and +0xB0 are the copy’s source offset and length; binary models carry per-vertex bone weights nowhere but MDX, so a skinned character deforms on the strength of it; and the mesh header’s per-attribute offset slots are offsets into an MDX vertex record, with LightPartTriMesh and PartTriMesh reading at them.
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 | The buffer is allocated at load and freed before InputBinary::Read returns. Reset has already copied it into a GL pool by then, using the model header’s +0xAC as the source offset and +0xB0 as the length. |
TriMesh Structural Addressing
There is no scan through the file block by block. Whatever reads it is driven by the MDL hierarchy, which supplies both the per-mesh offset and the record layout.
| Mapped Property | Ghidra Provenance & Engine Behavior |
|---|---|
| Array Slicing | Every TriMesh carries an mdx_data_offset at TriMesh + 0x144 naming where that mesh’s records begin in this file. It is what the upload slices on, and community tools follow it too, so a wrong offset misfeeds both. |
| 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)
A walkmesh is where a character may stand. It carries the collision and pathfinding surface for an area, the slopes that can be climbed, and the material under each footstep.
The pieces, and what each is for
A walkmesh is a triangle mesh plus four things layered on top of it. The layout tables below name all of them, so it is worth knowing what they are first.
| Piece | What it is |
|---|---|
| Face | One triangle, given as three vertex indices. Everything else is per-face. |
| Material | A row in surfacemat.2da, one per face. The row’s Walk column decides whether a creature can stand there, which is what walkable means throughout this page. |
| Normal and planar distance | The face’s plane, as a direction plus the d in ax + by + cz + d = 0. Together they answer “which side of this triangle am I on”, which is how the engine tests footing and height. |
| Adjacency | For each edge of each walkable face, which face lies across it. This is what lets pathfinding walk from one triangle to the next without searching. |
| Edge and transition | An edge where the mesh simply stops, annotated with the room on the other side. Walking off the mesh here means moving into another room rather than falling off the world. |
| Perimeter | Where one boundary loop of the mesh ends. The outer edge of a room is one loop; a pillar in the middle of it is another. |
| AABB tree | A tree of axis-aligned bounding boxes over the faces, so a collision query can discard most of the mesh without testing every triangle. |
Three kinds of file share this layout. .wok is a room’s floor, .dwk a door, and .pwk a placeable. They differ in which pieces they populate rather than in structure, and those differences are called out where they matter.
BWM Binary
The binary walkmesh is meant to be read straight into memory. Rather than parsing the file front to back, the engine reaches each block through an offset held in the header.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .bwm, .wok, .dwk, .pwk |
| Magic Signature | BWM / V1.0, present in every file but not checked by LoadMeshBinary |
| Type | Memory-Mapped Collision Net |
| Rust Reference | View rakata_formats::Bwm in Rustdocs |
File Layout
A 136-byte header, then nine data blocks, each located by its own offset out of that header. The blocks are contiguous in practice but nothing requires it, since every one is addressed independently.
| Block | Element | Located by |
|---|---|---|
| Vertices | 3 × f32 (12 B), a position | vertex_offset, counted by vertex_count |
| Face indices | 3 × u32 (12 B), vertex indices, zero-based | face_indices_offset, counted by face_count |
| Face materials | u32 (4 B), a surfacemat.2da row | materials_offset, face_count again |
| Face normals | 3 × f32 (12 B) | normals_offset, face_count |
| Planar distances | f32 (4 B) | planar_distances_offset, face_count |
| AABB nodes | 44-byte record, laid out below | aabb_offset, counted by aabb_count |
| Adjacency | 3 × i32 (12 B), laid out below | adjacency_offset, counted by adjacency_count |
| Edge transitions | u32 + i32 (8 B), laid out below | edges_offset, counted by edge_count |
| Perimeters | u32 (4 B), an end index into the edge block | perimeters_offset, counted by perimeter_count |
Face vertex indices are zero-based, which is measurable rather than assumed: across every walkmesh in chitin.key carrying geometry, the largest index in the block is exactly one below the file’s vertex_count, never equal to it and never negative.
Note that one face_count sizes four separate blocks. Indices, materials, normals and planar distances are parallel arrays over the same faces rather than independent tables.
Adjacency and edge records
| Offset | Field | Type | Notes |
|---|---|---|---|
| Adjacency record | 12 bytes | One per walkable face, three slots, one per edge | |
0x00 | slot 0 | i32 | See below. Not a face index |
0x04 | slot 1 | i32 | |
0x08 | slot 2 | i32 | |
| Edge record | 8 bytes | One per mesh-boundary edge | |
0x00 | index | u32 | An adjacency slot index. Never negative in any file measured. |
0x04 | transition | i32 | Destination room index, or -1 for none. The sentinel lives only here. |
An adjacency slot stores an edge-slot index, not a face index. Any value other than the sentinel is divided by three to obtain the face, so a consumer reading it directly as a face index is off by a factor of three. -1 means the edge has no neighbour, and the engine compares it as signed and passes it through unchanged rather than converting it.
A -1 result terminates a walk rather than skipping it. The caller that consumes an adjacency lookup treats reaching -1 as arriving at the mesh boundary and stops there. A reader that treats it as “no neighbour on this edge, try the next” produces a different traversal from the engine’s.
The edge block has exactly one record per -1 adjacency slot. That holds in every walkmesh in chitin.key that carries adjacency data, with no exception, and every edge index field points inside the adjacency slot space. So the block is not a free-standing list: it is an annotation on precisely the edges where the mesh stops, saying which room lies beyond each one.
Perimeters
Important
A perimeter is a run of edges, and the array holds where each run ends The block is not a list of things. It is a partition of the edge block: perimeter i covers the edges from the previous entry up to but not including its own value, and the first covers everything before entry zero.
Measured across every walkmesh in
chitin.keycarrying perimeters: the values are strictly increasing and the last one always equalsedge_count, without exception. So the array tiles the edge block exactly, and a mesh with one perimeter has a single entry equal toedge_count. Most have one; a few have two to four.Each run is most likely one closed boundary loop, an outer perimeter plus a hole for each additional entry. (Provenance: inferred. The bytes establish the partition, not the geometry.)
The block’s length is not free. The last entry must equal
edge_count, so a mesh whose edges you have laid out already determines its own final perimeter value.
Face ordering and adjacency_count
Important
Walkable faces come first in the face array, and the adjacency index space depends on it Adjacency values index edge slots of the walkable faces, so the range
[0, 3 x adjacency_count)only means anything if those faces are the leading entries of the face block.Measured across every walkmesh in
chitin.keycarrying geometry: in every mesh that has adjacency data at all, the walkable faces are exactly the leading block, and their count equalsadjacency_count.Four shipped meshes interleave walkable and non-walkable faces. All four are door walkmeshes carrying no adjacency, no edges and no perimeters, so nothing indexes into them: the ordering holds wherever it is load-bearing, and is not a property of the face array on its own.
So a writer sorting faces must put the walkable ones first and set
adjacency_countto their number, rather than sorting for tidiness and hoping the counts line up.The convention has a cause, and it is not in the binary format at all: the ASCII loader sorts faces into exactly these two buckets against
surfacemat.2daand writes them in exactly this order. The tool that produced the shipped binaries did the sort once, there, and the binary inherited the result.
Warning
adjacency_countcounts walkable faces, not all faces, and the index space follows Adjacency values do not index the face array. They index edge slots of the walkable faces, so the valid range is[0, 3 x adjacency_count). Bounds-checking them againstface_countaccepts out-of-range data on every mesh that has non-walkable geometry, and every area mesh does.Measured across the base archives,
adjacency_countequals the walkable-face count in almost every walkmesh, and where it does not every face is walkable so the two readings coincide. Every adjacency entry is in range, with-1the only negative value that occurs anywhere.An edge record’s
indexfield indexes the same space and is unique within a file in every mesh carrying them, so it names one specific boundary edge rather than repeating. Itstransitionis-1in most entries and a small non-negative room index in the rest.The bounds check is confirmed at the instruction level rather than inferred from file structure: the engine’s adjacency lookup bounds-checks its entry against
face_countwhile indexing an array sized byadjacency_count. Its caller separately checks a different index againstface_countbefore indexing aface_count-sized array, which is correct, so this is one specific routine checking the wrong count rather than a general confusion about which count governs what.
Transitions
Warning
A non-sentinel
transitionis used as a room index with no bounds check When the direct-line test finds atransitionother than-1, it uses that value immediately to index the area’s room array. Nothing validates it. The only things standing between an out-of-range value and an out-of-bounds read are the-1sentinel and the target room’s own null flag.A writer emitting a walkmesh must therefore keep
transitioneither at-1or inside the room count of the area the mesh belongs to. This is a file-level invariant the format does not enforce and the engine does not check.
Note
The on-disk transitions are what the game trusts, despite appearances The engine contains a full geometric edge-matching routine that walks both rooms’ edges, matches endpoints within a small tolerance, and writes fresh
transitionvalues keyed by load order rather than read from any file. It is called for every room pair on every area load, which makes “the on-disk values are recomputed at runtime” a reasonable first reading.It is wrong, and it is worth recording so nobody re-derives it. The whole body is gated on a flag that the binary load path sets and only the text loader clears. Every retail walkmesh takes the binary path, so for shipped content that recompute is dead code. The flag is a load-source discriminator, not an “adjacency already computed” marker.
Which materials are walkable comes from surfacemat.2da’s walk column. Read from a retail install, the walkable ids are 1, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14, 18 and 30, out of 31 rows.
AABB node (44 bytes each)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | bounding box | 6 × f32 | Min and max corners. |
0x18 | face index | i32 | -1 on an interior node, a real face index on a leaf. |
0x1C | four further indices | 4 × i32 | Two of them are -1 exactly on leaves and hold in-range node indices on interior nodes, which is what makes them the child links. |
The tree is a binary partition over the face table: a node either names a face or names two children, never both. In m01aa_01a, 389 nodes resolve to exactly 195 leaves for 195 faces.
Header, counts and offsets (0x48–0x88)
| Offset | Field | Offset | Field |
|---|---|---|---|
0x48 | vertex_count | 0x4C | vertex_offset |
0x50 | face_count | 0x54 | face_indices_offset |
0x58 | materials_offset | 0x5C | normals_offset |
0x60 | planar_distances_offset | 0x64 | aabb_count |
0x68 | aabb_offset | 0x6C | aabb_root |
0x70 | adjacency_count | 0x74 | adjacency_offset |
0x78 | edge_count | 0x7C | edges_offset |
0x80 | perimeter_count | 0x84 | perimeters_offset |
This half of the header is verified against a real file rather than assumed. In test.wok, vertex_offset is 136, exactly the header size, and every subsequent block begins precisely where the previous one ends: 6 vertices at 12 bytes reach 208, which is face_indices_offset; 4 faces reach 256, which is materials_offset; and the chain continues through all nine blocks to end at byte 440, exactly the file’s length. A misread field map does not close like that.
Header, leading region (0x00–0x48)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | magic | BWM | |
0x04 | version | V1.0 | |
0x08 | walkmesh kind | u32 | 1 for .wok, 0 for .pwk and .dwk |
0x0C, 0x18 | relative use positions 1 and 2 | vec3 each | Read by every walkmesh kind. |
0x24, 0x30 | absolute use positions 1 and 2 | vec3 each | Doors only. See below. |
0x3C | position | vec3 |
Important
These are two runtime slots, not four independent hooks
0x0Cand0x18load unconditionally into the object’s two use-position slots.0x24and0x30are a door-only override: the door loader runs after the shared one and overwrites those same two slots from the absolute pair, but only where the absolute value is nonzero. No placeable-specific override exists at all.That is exactly why the corpus splits the way it does. Placeables populate only the first pair because nothing ever consumes the second for them; doors populate all four because the door loader actively prefers the absolute values when present. A reader treating all four as one array of hooks gets four positions where the engine has two.
They are use positions in the gameplay sense, meaning where a creature stands to interact. A placeable transforms both slots into world space and picks whichever is nearer the querying creature, falling back to its own object position when slot 1 is zero.
Both halves are now measured across every vanilla walkmesh in the base archives, .wok, .pwk and .dwk alike, rather than from the two fixtures.
0x08 is a kind discriminator, not a count: it holds 1 in every .wok and 0 in every .pwk and .dwk, invariant within kind and unrelated to any file’s vertex count. And the hook region is populated in most vanilla walkmeshes, every .dwk, nearly every .pwk, and the large majority of .wok, so the fixtures that show it zeroed are the outliers. A door walkmesh such as dor_lda010 yields five planar vec3s across 0x0C–0x44, exactly the four-hooks-plus-position shape above, and a placeable populates only the first two, which is consistent with a placeable carrying fewer attachment points than a door.
The blocks tile the file exactly, with nothing left over. Every walkmesh carrying geometry is contiguous from byte 136 and ends precisely at the last block. There is no trailing region and no alignment padding anywhere in the format.
The rest are header-only, both .wok and .pwk: every count in the header is zero and the file is exactly 136 bytes, which confirms the header size from a direction the field map does not. Every empty .pwk belongs to a placeable with no collision geometry at all.
Note
An empty walkmesh has two vanilla shapes, and they differ by kind The counts are zero in all of them, but the offsets are not written the same way.
0x4C,0x54,0x58,0x5Cand0x60hold136regardless of kind. The other four,0x68,0x74,0x7Cand0x84, hold136in an empty.wokand0in an empty.pwk.Both shapes ship, so neither is wrong, but a writer emitting an empty walkmesh has a choice to make and vanilla does not make it consistently. Writing
136throughout matches the.wokconvention and keeps every offset pointing at the end of the header, which is the reading a consumer is most likely to survive.
0x6C is aabb_root
Two instruments answer this field and they say different things, both correct, so the page carries both.
Traced: 0x6C holds the root node index for the AABB tree at 0x64/0x68. The base loader CSWCollisionMesh::LoadMeshBinary (0x00597120) reads only through 0x48–0x70; the room-specific override CSWRoomSurfaceMesh::LoadMeshBinary (0x005807c0) reads the rest and stores this word as a plain int. Its consumer is CSWRoomSurfaceMesh::CheckAABBAll (0x00581610), which passes it to CheckAABBNode (0x00580920) as the node to begin descending from. The node array is unordered, so a consumer genuinely needs to be told where the tree starts.
Measured: it is 0 in every static walkmesh carrying geometry, and in every .pwk and .dwk. Vanilla writers emit the root node first and therefore always index zero. The population is a retail install’s base archives; saves were not searched, and a saved walkmesh is not something this measurement speaks to.
The handful of non-zero instances are all header-only .wok and are uninitialised writer memory, not values. They are not one value or one kind of value: several are 0xFFFFFFFF, some are small integers, some read as plausible world coordinates as floats, and two are heap-address shaped. Values recur across unrelated modules, which is what a deterministic build-time allocator leaves behind. The same files prove the field is neither a count nor an offset, since every count around it is 0 and every offset is 136, and it is neither.
A reader should follow the traced meaning rather than the measurement: start the tree walk at aabb_root, which will be zero in every shipped file but is not defined to be. A reader that hardcodes zero happens to work on vanilla and has no reason to.
Note
A placeable’s normals and plane distances are not populated Across every
.pwkthat carries geometry, all face normals have magnitude zero, as do their planar distances. WOK and DWK are unit-length to within1e-4throughout. The layout is identical across all three kinds and this field is not, so a consumer computing lighting or slope from a placeable walkmesh gets zeroes rather than an error.
Engine Audits & Decompilation
Read from CSWCollisionMesh::LoadMeshBinary at 0x00597120 in swkotor.exe. Provenance: derived, not attested unless a claim says otherwise: the rows have not been separately re-derived, so they sit on the reverse-engineering queue. Individual claims below may carry a level of their own, and where one does it overrides this line for that claim.
| Pipeline Event | Ghidra Provenance & Engine Behaviour |
|---|---|
| Pointer Jumping | The engine does not read the file front to back. It reaches each block by pointer arithmetic from the header. |
| Ignoring the Magic ID | LoadMeshBinary does not check the BWM magic or the version. Signature verification happens elsewhere, before this function runs. |
| Read-Only Format | Nothing in the shipped game writes a BWM. The binary path reads only, so collision data cannot be compiled or saved at runtime. |
Note
Nothing has independently confirmed the three rows above They are decompilation-only, and this table is one of the two in the manual whose rows have been refuted by measurement. The offsets and spans that measurement settled are in the header tables further up this page, and the incident is recorded with the provenance ladder. Treat what remains as derived, not attested: the field map above it is the measured half of this page, and these three rows are not.
BWM ASCII
The engine can also read a walkmesh as plain text, parsed line by line at load rather than mapped into memory. No shipped file uses it, so everything below describes what the parser accepts rather than what real files contain.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .bwm (ASCII formatted) |
| Magic Signature | ASCII Text Directives |
| Type | Uncompiled Collision Text |
File Structure
A flat sequence of lines. There is no header, no counts block and no terminator on the file as a whole: structure comes from keywords, and the two array directives carry their own counts.
node trimesh <name>
position <x> <y> <z>
orientation <ax> <ay> <az> <angle>
verts <count>
<x> <y> <z> # repeated <count> times
faces <count>
<v1> <v2> <v3> <a> <b> <c> <d> <material> # repeated <count> times
endnode
node dummy pwk_use01
position <x> <y> <z>
endnode
Lines end at \n and nothing else. The reader copies bytes until it meets a line feed, and a lone carriage return is not a terminator. So a CRLF-authored file parses, with a trailing \r left on the end of every line’s content for whatever consumes it.
A line may be 255 characters plus its terminator, and 256 is a hard failure. The buffer every caller supplies is 0x100 bytes. If it fills before a \n arrives the read returns failure and the loader falls back to a default mesh, so an over-long line does not truncate or corrupt one face. It loses the whole walkmesh.
| Directive | Parsed as | Notes |
|---|---|---|
node trimesh <name> | keyword match on node followed by " trimesh ", both spaces required | Opens a mesh body |
node dummy <name> | as above with " dummy " | See the use-position note below |
node <anything else> | n/a | Skipped, not rejected |
endnode | keyword | Closes a mesh body |
position | three floats | Meaning depends on the enclosing node; see below |
orientation | four floats, axis then angle, built into a quaternion | See the asymmetry note |
verts or vertices | a count, then that many lines of three floats each | Both spellings accepted, and both are case-sensitive |
faces | a count, then that many lines of exactly eight integers | See below |
aabb | n/a | Recognised; the tree is rebuilt rather than trusted |
A face line is eight integers and only four of them survive. Three are the vertex indices and the eighth is the material index. The four in between are parsed into locals and never stored anywhere, and nothing downstream reads them, so a writer emitting zeros there loses nothing.
position is context-sensitive, and one context is a hard error. Inside a trimesh body it sets the mesh’s position. After a use-position dummy node it feeds that hook instead. Reached with neither context open it fails the parse outright rather than being ignored, which makes a stray position line a file-level failure and not a skipped line.
Note
The two runtime use-position slots are a naming convention, not a field The binary half of this page documents two relative use positions in the header and gives no account of where they come from. This is where. A
dummynode whose name begins withpwk_useorpwk_dp_use_, matched without regard to case, has the two digits immediately following that prefix read as a literal pair:01and02select which of the two hooks the node’s ownpositionline fills.So they are not a dedicated structure anywhere. They are named dummy nodes in the source art, carried through the tool chain into two fixed header slots.
Warning
The placeable and door reader parses
orientationand throws it away The room reader stores the quaternion it builds. The placeable and door reader runs the identical parse, builds the identical quaternion, and never writes it anywhere. That is a genuine dead computation rather than an asymmetry with a purpose behind it, and it is worth stating because assuming the two readers are the same is the natural default.The two also disagree on leading whitespace: the placeable and door reader skips spaces and tabs before matching a keyword, and the room reader skips only spaces. A tab-indented file therefore parses as one and not the other.
Important
This is where the binary format’s walkable-first convention comes from The binary half of this page states as a bare fact that walkable faces lead the face array and that
adjacency_countis their number. The reason is here, in the text loader that the binary files were produced from.After parsing all faces, the room reader looks each face’s material index up against the
Walkcolumn ofsurfacemat.2da. Faces reading0go to one bucket and everything else to another, each keeping its original relative order. The final arrays are written walkable bucket first, non-walkable appended, andadjacency_countis set to the walkable bucket’s size.That is the same rule the binary corpus was measured to obey, arrived at from the opposite direction: not “shipped files happen to be sorted this way” but “the tool that produced them sorted them, once, here.” The convention was inherited rather than designed into the binary format.
It also has a consequence for tooling. Because the sort discards the original ordering, a binary walkmesh converted to this text form and back does not come out with its face indices where they started.
Engine Audits & Decompilation
Read from three sibling LoadMeshText overrides, CSWRoomSurfaceMesh::LoadMeshText (0x00582d70), CSWPlaceableSurfaceMesh::LoadMeshText (0x005cdad0, the .dwk/.pwk reader) and the base CSWCollisionMesh::LoadMeshText (0x00596890, a stub that only sets the text-versus-binary flag), plus the shared line reader CSWCollisionMesh::LoadMeshString (0x005968a0). Provenance: traced. The grammar above and the rows below come from reading those functions.
There is still nothing to check any of it against. Every walkmesh resource in a retail install’s archives opens with the binary BWM signature, and none is ASCII, so no shipped file can confirm or refute a claim on this half of the page. That is why the grammar is stated at the level of what the parser does rather than what files look like.
| Pipeline Event | Engine Behaviour |
|---|---|
| Keyword scan | The file is read line by line, each line trimmed of leading whitespace and matched against the directive set in the grammar above. Unrecognised node types are skipped rather than rejected. |
| Face fields | Eight integers per face, of which the parser keeps the three vertex indices and the material index. The four in between are read and dropped. Adjacency is not taken from the file at all; it is recomputed after load. |
| Line length | A hard limit, not a soft one. The line buffer is 0x100 bytes, and a line that fills it without producing a line feed returns failure, at which point the caller loads a default mesh instead. |
| Face reordering | Faces are bucketed by the Walk column of surfacemat.2da and rewritten walkable-first, with adjacency_count set to the walkable count. See the box above for what this explains about the binary format. |
| Bounding box | The AABB limits are expanded outward by roughly 0.01 on every axis. Because the faces have moved, the loader also builds a temporary remap table to track where each one went. |
TriMesh Derived & Computed Fields Reference
This page documents which TriMesh fields the engine recomputes at load time versus which ones a writer must supply correctly, so a hand-built mesh doesn’t silently mismatch what the engine reconstructs. It covers every field on
MdlMeshandMdlFacethat can be derived from geometry: what each one means, how community tools handle it, and the algorithm needed to recompute it. Evidence is drawn from Ghidra decompilation ofswkotor.exe(K1 GOG build), cross-checked against vanilla.mdl/.mdxassets. The tables below are lookup surfaces, meant to be searched rather than read start to end, and they are the reference for future model-editing API work.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .mdl |
| Domain | Geometry Math / Model Reconstruction |
| Rust Reference | View rakata_formats::MdlNodeTriMesh in Rustdocs |
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 as it loads but never reads the data back during the rendering cycle.
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: The 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: All zeros (ptr=0, count=0, alloc=0), in every mesh of every
model indexed by a retail chitin.key.
Note
A description of this slot as typically non-zero belongs to the next three Non-zero pointer, count 1, alloc 1 is exactly what
+0xB0,+0xBCand+0xC8carry.+0xA4is empty throughout, so a source attaching that description here has it on the wrong slot.
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 u32. On disk it is a sequence counter that numbers meshes in an inverted pattern. At load the engine overwrites that same memory with an OpenGL handle, so the stored value never survives into the running model.
The value function, derived from the retail corpus. Read as shape, the sequence is a set of checkpoints at 100 x 2^k where the value equals the counter, separated by runs that descend between them:
c: the 1-based mesh counter
T = 100
while T < c: T = T * 2
if c == T: value = c # checkpoint
elif T == 100: value = 99 - c # the first run, which is the special case
else: value = T + T/2 - c
Example sequence: 98, 97, 96, …, 1, 0, 100, 199, 198, …, 101, 200, …
Two properties hold across the range tested. The function is injective, and the value 99 is never produced: the first run descends to zero and the first checkpoint is 100, so that gap is a real discontinuity rather than an off-by-one.
Warning
mdledit’s formula for this field is wrong above counter 299 The two agree exactly for every counter below 300 and disagree at every counter from 300 onward. On the deepest model with an unambiguous counter, the function above reproduces every stored value and mdledit’s reproduces the first 299 of them.
The divergence only shows on models with at least three hundred meshes, and most have nowhere near that, so a tool carrying the wrong formula can look correct indefinitely. Rakata carried it too: a model that large written by a build of this library older than the function above has wrong values in this field.
Important
Which counter a mesh gets is not recoverable from the file The function above turns a counter into a value. It does not tell you which counter a given mesh takes, and nothing in the file does either.
Stored values are distinct within every model, so the field is a genuine per-model identifier. But the counter is not a function of mesh ordinal: single-mesh models carry twenty-five different values rather than always the first of the sequence, and the counter matches DFS ordinal in only about a fifth of mesh-bearing models. No ordering tried makes the value set contiguous: not DFS, not node offset, not reverse DFS, not name index, not sabers-last. Models exist whose five meshes carry counters 2, 5, 3, 1 and 8, so the sequence numbers more meshes than the file contains.
The economical reading is that the counter indexes the authoring scene’s mesh list, including meshes never exported, which the binary does not carry.
The consequence is a real constraint on any round-tripping tool. A writer producing new models may assign counters freely and use the function above. A writer aiming to reproduce a specific retail model byte-for-byte must preserve the stored value, because it cannot be recomputed from anything in the file.
Note
Saber meshes take no counter at all They write none of the three single-
u32blocks, leaving+0xB0,+0xBCand+0xC8with a count of zero and a stale pointer. The identity is exact in both directions across the corpus: every saber mesh does this and no non-saber mesh does.So a saber consumes no increment. A reading that has them consuming two is a hypothesis rather than a measurement, and the identity above refutes it.
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, exactly 6 bytes per face. Each triplet names the three vertices of one triangle, and the whole block is uploaded to the graphics card as the index buffer.
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 distance from the origin to the face’s plane, measured along the normal.
Formula: plane_distance = -dot(plane_normal, positions[v0])
Tools differ over the sign, so this was checked rather than assumed: sampled
across vanilla faces, the stored value matches -dot(plane_normal, positions[v0]) and never +dot. The handful that match neither are
degenerate faces, the same ones this page records as carrying NaN. The formula
above is what vanilla carries.
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 (a 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. It defines the mesh topology.
4. Mesh Bounding Geometry: Derivable
4.1 bounding_min / bounding_max ([f32; 3])
What it is: The axis-aligned bounding box enclosing every vertex in the mesh.
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, traced and recorded in the MDL deep dive:
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 collision-detection tree built over the faces of the mesh. It recursively subdivides them into nested boxes, so a collision query tests a handful of faces instead of every polygon.
When needed: Only for MdlNodeData::Aabb nodes (walkmesh-like collision
geometry). Regular render meshes don’t have AABB trees.
Node layout: 40 bytes, written in DFS preorder. The full record is in the MDL deep dive.
Warning
This is the MDL tree, not the walkmesh one, and they are different sizes An MDL AABB node is 40 bytes. The node in a BWM walkmesh is 44. Both numbers are right for their own format.
The two trees do the same job and are easy to conflate, and the failure is quiet: read a
.woktree at a 40-byte stride and the fields still land on plausible floats and small integers, so you get a tree that parses and describes nothing. This page separates MDL from BWM adjacency a few sections up for the same reason; the node size needs the same care.
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 fields are user-authored or carried over from tooling. Rakata preserves them verbatim and never recomputes them:
| 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.
Two of those five headers are empty in vanilla, +0x98 and +0xA4, and the
other three are not: +0xB0, +0xBC and +0xC8 each carry a non-zero pointer
with a count and alloc of 1 in almost every mesh. mdledit’s string-based
read/write still produces byte-identical results, because it preserves the
bytes rather than interpreting them, but the reason is preservation rather than
the region being blank.
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, which the pathfinding walk algorithm needs. 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, read off the block pointers of every populated mesh in the retail corpus, 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: the per-mesh sequence 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.
It is a contiguous layout, not merely an ordering. Testing whether each block ends exactly where the next begins, at the sizes above, it abuts with zero gaps in every populated mesh measured. A writer that emits these in order but pads between them produces something no retail mesh resembles.
The only meshes that deviate are sabers, which write none of the three
single-u32 blocks at all and so have nothing to order. They are the exception
throughout this section rather than a separate case.
mdledit’s binarywrite.cpp writes the same order, which corroborates the
derivation rather than supplying it. The corpus is the evidence here, and the
contiguity and the saber exception are things the transcription does not carry.
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 |
File Layout
Population for everything on this page: every .tpc in the four ERF archives under TexturePacks/ in a retail PC install. Three of them, swpc_tex_tpa, tpb and tpc, hold the same resrefs at three resolutions; the fourth, swpc_tex_gui, holds the interface textures. None of the four is indexed by chitin.key, which is worth knowing before writing anything that enumerates game resources by walking the KEY file: do that and you will conclude, wrongly, that the game ships no textures in this format.
A fixed 128-byte header, the texture payload immediately after it, and optionally some TXI text tacked onto the end. Nothing points anywhere; the payload always starts at 0x80.
| Block | Size | Located by |
|---|---|---|
| Header | 128 bytes | Always at 0x00 |
| Texture payload | data_size, plus the mip chain | Always at 0x80 |
| TXI footer | remainder of the file | Whatever follows the payload |
Header (128 bytes)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | data_size | u32 | Not one quantity. 0 means uncompressed; otherwise see below. |
0x04 | alpha_test | f32 | Read and handed downstream. No consumer found; see below. |
0x08 | width | u16 | |
0x0A | height | u16 | |
0x0C | pixel_type | u8 | Three values occur on disk: 1, 2 and 4. See below. |
0x0D | mipmap_count | u8 | Load-bearing. The engine iterates it directly and does not check it. See below. |
0x0E | reserved | 114 bytes |
There is no magic signature. A .tpc is identified by its extension and by the header parsing plausibly, which is worth knowing before writing anything that sniffs file types.
Warning
mipmap_countis the one header field a writer cannot get away with approximating It is not descriptive. The loader uses it as the iteration count of the loop that accumulates the mip chain’s size, on both the compressed and the uncompressed path, and there is nothing else in that function deriving a level count fromwidthandheight. The stored byte is simply trusted, with no bounds check against how many levels the dimensions could actually support.So this value is how the engine learns where the pixel data ends. Everything after the pixel data, meaning the TXI tail this page describes further down, is found by starting where the mip chain stopped. A writer must emit the exact number of levels it actually wrote, all the way down to 1x1. Write a count that is too small and the trailer is read from inside the pixel data; too large and the loop walks off the end of it. Either way every offset past the payload is wrong, and the failure is silent rather than a rejection.
The unclamped arithmetic in the next box is what turns that count into byte offsets, so the two rules are one rule in practice: emit every level, count every level.
(Provenance: traced, at
CResTPC::OnResourceServiced(0x00712ff0).)
Warning
Mip sizes are computed, not read, and the arithmetic does not clamp The engine does not use the stored dimensions per level. It halves the base width and height for each level with a plain right shift and no clamp to a minimum of 1, so once a dimension reaches zero the level contributes no bytes at all. A deep enough mip chain therefore has trailing levels that occupy nothing. Rakata reproduces this exactly rather than the more usual clamp-at-one behaviour, because a writer that clamps produces a payload the engine will read at the wrong offsets from that level onward.
pixel_type, on disk and internally
The engine checks this byte bit by bit to produce an internal format code, and the audit below describes that internal value. An implementer reads the byte, which is one step removed. Three values occur:
| On disk | Format | Payload size per level |
|---|---|---|
2 | DXT1 | 8 bytes per 4x4 block |
4 | DXT5 | 16 bytes per 4x4 block |
1 | uncompressed, one byte per pixel | width x height |
2 and 4 are the bulk of the corpus and split it roughly evenly. 1 is rare, a dozen textures, and it is the value a reader written against the two-value description meets and cannot handle. Its payload reproduces the file length at one byte per pixel, and the audit below already records the engine deriving an internal code 1 from this bit that the DXT dispatch then does not consume.
No DXT3 value appears, matching the absence of any DXT3 path in the parser, so a DXT3 payload is not a variant handled badly. It is one not handled.
Important
One byte per pixel is luminance, not an alpha mask and not a palette Those two are what a reader guesses when it meets a single-byte format, and both are wrong. Following the uncompressed path to the call that actually hands the texture to OpenGL, the internal code this byte produces selects
GL_LUMINANCEwith unsigned-byte components. The single channel is replicated across red, green and blue and the texture draws grey, rather than modulating another image’s transparency or indexing into a colour table.That is read off the instruction choosing the GL internal format, not inferred from the byte count, which is why it can contradict the guess rather than merely differ from it.
A caution about which code space you are in. The disk values are
1,2and4, and the codes that GL dispatch branches on are its own. They overlap numerically without meaning the same thing: disk4is DXT5 and goes down the compressed path, never reaching this branch. The same GL function also handles codes forGL_RGB,GL_RGBAand a packed 16-bit format, reached from callers other than the TPC uncompressed path, so its branch list is not a list of TPC pixel types. Read the disk byte, and treat the mapping above as covering the one disk value that arrives here.
Note
alpha_testis populated and goes nowhere this pass could follow It is a genuinef32and the loader does read it. Both internal consumers reachable from there take it into a local and never mention it again. It also escapes through a public accessor that hands every header attribute back to its caller unexamined, and that caller is reached only through a vtable slot with no static call site in the executable.So this is a bounded negative rather than a dead field: no consumer was found within the depth traced, and the trail ends at an indirection rather than at a conclusion. The distinction matters for a writer, because “nothing reads it” and “you may put anything there” are different claims and only a genuinely dead field licenses the second. Preserve the value on a round trip.
data_size
Warning
Uncompressed is not an edge case here
data_sizeis0in roughly one texture in seven, and those are not one pixel type: the zero appears against1,2and4alike. A reader that treats an uncompressed TPC as a curiosity, or that infers the encoding frompixel_typealone without checkingdata_sizefirst, mis-sizes a substantial slice of the shipped textures.
Warning
data_sizecarries at least three meanings Where it is non-zero it usually is the base level’s byte count, and for most textures reading it that way is correct. Two groups depart from it, and one of them departs by a factor of six.Cubemaps store six faces and
data_sizesizes one. Every texture in this group is namedCM_*and has a height six times its width: six square faces stacked vertically in one image. The base level computed fromwidth x heightis exactly six times the stored value. A reader trustingdata_sizeto bound the base level reads one face and calls it the texture; a reader trusting the dimensions reads six times what the field says.A second group stores more than the base level, close to the whole mip chain rather than the first level of it.
C_HoloDodonnaat 512x512 DXT1 has a 131,072-byte base and adata_sizeof 174,816. What rule produces those figures is unexplained: the group is identifiable by the field exceeding the computed base, and beyond that this page does not know.The way out is that nothing needs this field. The mip arithmetic below derives every level from
width,heightandpixel_type, and it reproduces the payload without consultingdata_sizeat all. Treat the field as something to preserve on round trip rather than something to read.
The mip chain
Note
The mip formula is confirmed against shipped textures, by tiling Summing the chain with the unclamped shift and consulting nothing else, the computed payload overruns none of the textures in the
TexturePacks/archives. It lands exactly on the end of the file in a little under half of them, and in the rest the remainder past it is the TXI tail this page describes, opening onmipmap,envmaptexture,clampor another directive. There is no file the arithmetic overshoots.
That is the same tiling check that settles GFF and LIP.
It discriminates where it matters: on every file whose chain reaches a zero dimension, the no-clamp model reproduces the file’s length and a clamp-at-one model does not. Where the two give different totals, file length refutes the clamping reader.
The 114-byte reserved region is zero in every file tested, at every byte position.
Engine Audits & Decompilation
Read from CAuroraProcessedTexture::ReadProcessedTextureHeader at 0x0070f590 in swkotor.exe. Provenance: derived, not attested for the rows below, which have not been separately re-derived and sit on the reverse-engineering queue. The file layout above is no longer in that class: it is measured, against the archives under TexturePacks/.
| Pipeline Event | Engine Behaviour |
|---|---|
| Format Byte Mapping | The header’s format byte is read as a bitmask. The engine tests bit 0, bit 1 and bit 2 to produce internal format codes 1, 3 and 4. |
| Compression Dispatch | Only two of those reach the compressed path: code 3 reads 8-byte blocks (DXT1) and code 4 reads 16-byte blocks (DXT5). Nothing else dispatches. |
| Mipmap Calculations | Level dimensions are computed by right-shifting the base dimensions, with no clamp to a minimum of 1. A deep enough chain reaches zero and those levels contribute no bytes. |
| OpenGL Hardware Binding | Code 3 maps to 0x83F0 (DXT1) and code 4 to 0x83F3 (DXT5). There is no branch for DXT3 (0x83F2) anywhere in the 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 |
File Layout
Two container variants share the extension, and which one you have is decided by the first four bytes.
| Variant | Discriminator | Structure |
|---|---|---|
| Standard DDS | Begins with the DDS magic | 4-byte magic, then a 124-byte D3D9-era header, then the surface payload |
CResDDS prefixed | No magic at all | A 20-byte proprietary prefix, then the surface payload directly |
The prefixed variant is the one vanilla resource paths use, and it does not contain a DDS header in any form: the twenty bytes replace it rather than preceding it.
The 20-byte prefix
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | width | i32 | |
0x04 | height | i32 | |
0x08 | format code | u8 | 3 for DXT1, 4 for DXT5. A genuine one-byte read; see below. |
0x09 | reserved | 3 bytes | Untouched by the primary reader, read as part of a dword by a sibling. See below. |
0x0C | base_size | i32 | Byte count of the level-0 compressed payload, trusted as written. See below. |
0x10 | alpha_mean | f32 | Read and handed downstream. No consumer found; see below. |
Each width comes from the load instruction rather than from the field’s apparent size: the format code targets a single-byte register while the other four are plain four-byte copies. The payload begins at 0x14 with no further header.
Important
This is the one page in the manual with no file behind it Every other format here has been checked against real bytes. DDS has not, because none was found to check: not in
chitin.key, not in any module archive, not in the four ERF archives underTexturePacks/, and not among the mod assets that once supplied a corpus for TPC. The prefix field map and the reserved+0x09-+0x0Bgap rest wholly on decompilation.That matters because decompilation-only claims are the ones that have failed when checked: this page’s audit table and the walkmesh page’s are the two that lost rows, both recorded with the provenance ladder. Two independent readings of the read path agree on the five-field prefix map exactly as written, which is the most this format can offer, but it is still traced rather than measured.
The
TexturePacks/archives are named explicitly because they are the family a sweep starting fromchitin.keywalks straight past, which is how TPC’s own shipped corpus went unopened for so long. They hold no.dds. Saves were not searched, and nothing here rules out a.ddsappearing in one.
Rakata reads both and writes only the standard form. That is a deliberate limit rather than an oversight: emitting the prefixed variant would mean reproducing a container the engine reads but no other tool does.
Read from CResDDS::GetDDSAttrib at 0x00710ee0 in swkotor.exe. Provenance: derived, not attested unless a claim says otherwise: the rows have not been separately re-derived, so they sit on the reverse-engineering queue. Individual claims below may carry a level of their own, and where one does it overrides this line for that claim.
| 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). Each width was confirmed by reading the load instruction: the byte code is a genuine single-byte read, the other four are plain 4-byte copies. |
| No intermediate struct | CResDDS::OnResourceServiced (0x00710f30) assigns the raw loaded-resource buffer directly, copying nothing, and derives the payload pointer as that same address plus 20. The twenty-byte prefix is the start of the resource bytes rather than a header parsed into anything. |
| Null data pointer | GetDDSAttrib is linear apart from one test: if the object’s stored data pointer is null, because the resource has not been serviced yet, it returns 0 and writes none of the five outputs. A caller that ignores the return value reads whatever its output variables already held. |
The format byte at +0x08
DDS’s on-disk convention is 3 for DXT1 and 4 for DXT5. GetDDSAttrib copies the byte out completely unmodified: no mask, no comparison, no branch anywhere in the function, so whatever is on disk is exactly what downstream code receives.
Important
This is not TPC’s encoding, despite the shared dispatch The two encodings are easy to conflate because they converge.
TPC’s on-disk bytes are
1,2and4, andCAuroraProcessedTexture::ReadProcessedTextureHeader(0x0070f590) remaps them to an internal code before anything downstream sees them: on-disk2becomes internal3, on-disk4stays4. DDS does no remapping at all. The two formats therefore carry different on-disk conventions that arrive at the same internal values by different routes.Where they genuinely do meet is one branch further down, in
AurGetImageWrapper(0x0041eb27): a singlecode == 3gives the DXT1 GL enum,code == 4gives DXT5. That is the same instructions for both formats rather than parallel logic, which is a stronger claim than “mimics TPC” and a narrower one than “same encoding”.
Note
The 8-byte and 16-byte block sizes are general DXT facts, not something traced here DXT1 blocks are 8 bytes and DXT5 blocks are 16, and that is true of the compression formats generally. It is not an arithmetic operation this engine performs: no byte-count computation for block size exists anywhere in the DDS or TPC raster call chain. The engine reads the total compressed size out of the header field at
+0x0Cand trusts it, andAurGetImageWrapperonly ever selects a GL enum, leaving the driver to interpret block geometry from that.A formula of the shape
(pixel_type == 4) * 8 + 8circulates for this field, attributed toGetDDSAttrib. Both halves of that attribution are wrong: the function contains no arithmetic or branching of any kind beyond the null check, and the formula appears nowhere in the binary. It describes DXT correctly and describes the engine not at all, which is why the block sizes are given above as a property of the compression rather than as something computed here.
Reserved gaps
Tip
Reserved gaps, and the scope of “ignored” The bytes spanning
+0x09to+0x0Bare untouched byGetDDSAttrib: the instruction stream goes straight from the single-byte read at+0x08to the dword at+0x0C, referencing none of the three.That claim holds for that function only. A second, independently coded reader of the same twenty bytes,
CAuroraCompressedTexture::ReadTextureHeader(0x00710430), reached throughCAuroraInterface::ReadCompressedRasterHeader(0x0070cdd0), declares the field at+0x08as a full four-byte value and passes all four bytes through to its caller as one word. That caller is reached only through a function-pointer slot in a global interface table with no static caller in the binary, so what becomes of the upper three bytes on that path is untraced.We preserve them for round-trip fidelity.
alpha_mean at +0x10 is a genuine float on two independent routes: GetDDSAttrib moves it through a general-purpose register, and the sibling reader above loads and stores the same offset with x87 FLD/FSTP, which a compiler emits only for a source-level float.
base_size and alpha_mean
Note
base_sizesizes the first level only, and the rest are computed It is the byte count of mip level 0, and it goes straight to the GPU as the length argument of the compressed-texture upload for that level. Every level after it is derived rather than read: dimensions halve and the size scales by the DXT block size, eight or sixteen bytes per four-by-four tile depending on the format the byte code selects.So the field bounds one level, not the payload. A reader treating it as the whole compressed run gets level 0 and calls it the texture, which is the same trap TPC’s
data_sizesets in a different way.The consumer and the field’s role in it are traced. Not traced: the virtual call that returns the value to that consumer, since neither of the two functions that own the header uses it themselves.
Note
alpha_meanis populated and goes nowhere this pass could follow Its type is settled twice over, as above. Its purpose is not. The two nearest consumers both take it and never refer to it again, which is the same shape TPC’salpha_testshows.This is a bounded negative rather than a dead field: no consumer found within the depth traced, with the trail ending at an indirection rather than at a conclusion. That is a weaker claim than the dead fields elsewhere in this manual, which rest on enumerating every accessor and its callers, and it should not be read as licence to write anything there. Preserve it.
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 |
File Layout
The standard Truevision structure: an 18-byte header, then up to four optional or variable-length regions.
| Block | Size | Notes |
|---|---|---|
| Header | 18 bytes | Always at 0x00 |
| Image ID | id_len bytes | Usually absent |
| Colour map | per the colour map spec | Absent for true-colour images |
| Pixel data | derived from dimensions and depth | |
| Footer / extensions | 26 or 521 bytes | On some toolset-written textures, never on engine output. See below. |
Header (18 bytes)
| Offset | Field | Type | Does K1 read it? |
|---|---|---|---|
0x00 | id_len | u8 | No |
0x01 | color_map_type | u8 | No |
0x02 | image_type | u8 | No |
0x03 | colour map spec | 5 bytes | No |
0x08 | x_origin | u16 | No |
0x0A | y_origin | u16 | No |
0x0C | width | u16 | Yes |
0x0E | height | u16 | Yes |
0x10 | pixel_depth | u8 | Yes, and it is the only thing validated |
0x11 | image_descriptor | u8 | No |
Warning
A run-length encoded TGA is not rejected. It renders as garbage.
image_typeis one of the bytes the engine never looks at, so nothing distinguishes an RLE file from an uncompressed one at load. The compressed bytes are read as though they were raw pixels, at the dimensions and depth the header declares, and the result is drawn.This follows from the table above rather than adding to it, and it is worth stating because “the engine ignores the type byte” reads like tolerance until you notice what it means for a format the byte exists to distinguish. Do not write RLE, and do not expect a reader that accepts it to be doing you a favour.
That last column is the point of this page. Of eighteen header bytes the engine reads dimensions and depth and disregards the rest, so a KotOR .tga is far less constrained than a Truevision one, and correspondingly a file that a general-purpose image tool considers well-formed can still be one the engine refuses, since pixel_depth outside 8, 24 or 32 is a hard failure while everything else passes unexamined.
Important
The column answers a reader’s question, not a writer’s These are fixed-position bytes in an eighteen-byte header, so No cannot mean “omit it”: the byte exists whatever you put there. It means any value passes the load.
A writer aiming at canonical output wants the opposite answer, and this page has it further down: the engine’s own writer hardcodes all three, to
0forid_len,2forimage_typeand0forimage_descriptor. So “anything loads” and “vanilla writes one specific value” are both true, and which you want depends on whether you are producing a file the engine accepts or one that matches what shipped. See the general rule.
Pixel data
| Depth | Layout | Notes |
|---|---|---|
| 32 | B, G, R, A | Alpha is the fourth byte, and near-opaque in most vanilla textures. |
| 24 | B, G, R | |
| 8 | one greyscale sample | Every vanilla 8-bit file carries image_type = 3, uncompressed greyscale, so no ordering question arises. |
Blue first. This is standard Truevision order and it is the single easiest thing to get wrong here, because the wrong guess parses perfectly and silently swaps red and blue in every texture in the game.
Note
How the order was established, since the obvious test gives the wrong answer Comparing channel means across the vanilla corpus points at RGBA, and that result is an artifact: lightmaps dominate the shipped textures and are not natural imagery.
The decisive test pairs a
.tgaagainst a.tpcof the same texture at the same dimensions, because DXT stores colour as RGB565 whose bit layout is fixed by the S3TC standard, which is ground truth from outside this manual. Across every such pair the sign of the DXT red-minus-blue difference agrees withb2 - b0. The skin textures settle it outright, since human skin is always R > G > B:p_joleeh01reads(123, 70, 47)from DXT and(36, 51, 87)from the TGA. The largest DXT channel is red; the largest TGA byte isb2. Depth 24 was confirmed the same way.
Warning
Footers depend on which program wrote the file Two producers put
.tgafiles on disk and they behave differently, so a single rule covers neither.Toolset output, meaning the textures shipped in a full install: a small fraction carry a Truevision footer, each ending with the literal
TRUEVISION-XFILE\0. Most of those carry a 495-byte extension area as well, for a 521-byte trailer; the rest carry the 26-byte footer alone.Engine output, meaning the
Screen.tgathe game writes into a save folder: no footer at all, in every screenshot measured. The header plus payload accounts for the file exactly, with nothing after it.This matters in both directions and only for the first producer. A reader that computes the pixel payload’s extent from the header and treats whatever follows as absent mis-handles a slice of the shipped textures. A writer that reproduces header and payload and stops loses the extension area on the files that have one. The region is not addressed by any offset in the header. It is found by looking at the end of the file, so it is easy to write a parser that never notices it exists.
What the engine’s own writer emits
Save screenshots are the only files in this project’s reach that the game itself produced, which makes them the one direct check on the write path documented below. Every claim there was derived from decompilation with no files behind it. All of them hold.
| Property | Engine output |
|---|---|
image_type | 2, uncompressed true-colour, without exception |
id_len | 0, so no image ID block |
image_descriptor | 0, including the origin bit |
| Pixel depth | 24-bit, so no alpha channel |
| Dimensions | 256 x 256 |
color_map_type and colour map spec | zero throughout |
x_origin, y_origin | zero |
| Footer | none |
The file length is exactly the header plus width x height x 3, with no slack, which is what confirms the header fields are being read correctly rather than merely being plausible.
The vertical flip is confirmed too, and by looking rather than by parsing: decoding a screenshot both ways and rendering it, honouring the bottom-left origin puts the ceiling at the top and the figure upright, while a naive top-left read hangs the figure from the ceiling. image_descriptor bit 5 is clear in every one, so the file declares bottom-left as well as being written that way.
Warning
Origin is not negotiable, and nothing in the file will tell you
image_descriptorat0x11is the byte that normally carries the origin bit, and the engine ignores it. Its own writer hardcodes the field to0and flips the image vertically on the way out, because its in-memory raster is top-left while its on-disk convention is bottom-left. So orientation is a convention the format cannot express here: a top-left file written with the descriptor set correctly will still load upside down.
Engine Audits & Decompilation
Read from ImageReadTGAHeader at 0x0045e2e0 in swkotor.exe. Provenance: derived, not attested unless a claim says otherwise: the rows have not been separately re-derived, so they sit on the reverse-engineering queue. Individual claims below may carry a level of their own, and where one does it overrides this line for that claim.
| 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 |
File Structure
One directive per line: a command token, then whatever arguments it takes. There is no header, no terminator, and no required ordering.
<command> <args...>
<command> <args...>
upperleftcoords <count>
<u> <v> <w>
...
lowerrightcoords <count>
<u> <v> <w>
...
Two commands break the one-line rule. upperleftcoords and lowerrightcoords declare a count and are followed by that many coordinate triples on their own lines, which is the only place the format has structure beyond a flat list. A list here takes either that counted form or an endlist terminator, which is the one format in the manual that does both; see counted, terminated, or neither.
Empty lines are ignored, and command matching is case-insensitive. Text is Windows-1252. Rakata offers two write modes: a policy-normalised one that lowercases the command token, and a source-preserving one that keeps whatever casing the input used, for round-tripping someone else’s file without editorialising it. Neither mode consults a list of known commands, because there is no such list: the reader is deliberately a tolerant key-and-arguments parser, matching the engine, which as the audit below records bypasses anything it does not recognise without complaint.
The recognised vocabulary
Three layers run for every line, in order. A directive matching none of them is silently discarded.
Texture directives, matched directly:
| Purpose | Directives |
|---|---|
| Sizing and sampling | defaultwidth, defaultheight, downsamplemax, downsamplemin, mipmap, filter, maptexelstopixels, clamp, filerange |
| Colour and alpha | gamma, alphamean, useglobalalpha, envmapalpha, specularcolor (three floats) |
| Bump mapping | isbumpmap (an int, not a bool), isdiffusebumpmap, isspecularbumpmap, bumpmapscaling, bumpintensity, diffusebumpintensity, specularbumpintensity |
| Environment and animation | cube, isenvironmentmapped, numx, numy, temporary |
| Procedural | proceduretype |
Font directives, delegated on every line regardless of what already matched: numchars, fontheight, baselineheight, texturewidth, spacingR, spacingB, plus the two coordinate lists.
Procedural-controller directives, delegated only once a proceduretype line has appeared earlier in the same file. proceduretype takes one of eight values, each constructing a controller and destroying any previous one, so the last proceduretype line in a file is the one that counts.
Every controller accepts the same base set:
| Directive | Shape |
|---|---|
channelscale, channeltranslate | Multi-line lists of floats, with the same count-or-endlist shape as the coordinate lists |
channelscale0-channelscale3, channeltranslate0-channeltranslate3 | Single values. Writing any one of them for the first time allocates the underlying array as [1.0, 1.0, 1.0, 1.0], so an unwritten channel reads as 1.0 rather than as zero |
distort, distortangle, distortionamplitude, speed | Single values |
Three of the eight add directives of their own, and the other five add nothing:
proceduretype | Adds |
|---|---|
water | forcecyclespeed, anglecyclespeed, waterwidth, waterheight |
arturo | arturowidth, arturoheight |
cycle | fps |
life, perlin, wave, random, ringtexdistort | nothing beyond the base set |
A directive from the wrong controller’s set is not an error. It falls through to the same silent discard as any unrecognised token, so waterwidth under proceduretype arturo does exactly nothing and says exactly nothing.
Important
Four multi-line lists have a second terminator, not two
upperleftcoords,lowerrightcoords,channelscaleandchanneltranslateall normally take a count and then that many lines. If the token after the command fails to parse as an integer, the parser instead reads lines until it meets one whose first word isendlist. So a file can legitimately use either form for any of the four, and a reader that only implements the counted one will consume the rest of the file as list entries when it meets the other.
Tip
An unknown directive is not an error anywhere in the chain The engine bypasses commands it does not recognise without logging or failing the texture load, as the audit below records. That makes TXI unusually safe to extend and unusually easy to get silently wrong: a typo’d command name behaves exactly like a command that does nothing, with no diagnostic on either side. The boolean-parsing note further down is the sharpest case of this.
Engine Audits & Decompilation
Read from CAurTextureBasic::ParseField at 0x00422390 in swkotor.exe. Provenance: derived, not attested unless a claim says otherwise: the rows have not been separately re-derived, so they sit on the reverse-engineering queue. Individual claims below may carry a level of their own, and where one does it overrides this line for that claim.
| 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 |
File Layout (binary V2.b)
Six blocks, none of them located by an offset. Everything is found by parsing sequentially, which is why a malformed block does not produce a wrong lookup so much as a wrong everything after it.
| Block | Contents |
|---|---|
| Signature | 2DA + V2.b + LF. A single LF, not CRLF, in every vanilla table measured. Emit CRLF and every field after it sits one byte off. |
| Column headers | One per delimiter, the run terminated by a NUL. The delimiter is a tab in chitin.key and a NUL in thirteen rims/ copies; see below |
| Row count | A single u32 |
| Row labels | One per delimiter, same delimiter as the headers in that file |
| Cell offset table | u16 per cell, rows x columns, row-major |
| String-table size | A single u16. See below: the engine skips it, and it is not free to a writer |
| String table | NUL-terminated cell strings, referenced by the offsets above |
Three delimiting conventions in one file
The header run is terminated by a NUL, the rows are counted by a u32, and the headers and row labels are delimited one by one. See counted, terminated, or neither. Assuming one convention throughout is the single easiest way to misparse this format.
The string-table size field
Important
The size field is skipped by the engine and is still not yours to choose The
u16between the offset table and the strings is read by nothing. The loader steps over it with a+2and never validates it, which is where the phrase “orphaned size field” comes from, and it is why a file carrying any value at all still loads.It is not arbitrary in practice. Across every 2DA in
chitin.key, the field holds exactly the number of bytes of string table that follow it, without exception, and adding the two bytes for the field itself lands on the end of the file every time.So the engine’s answer and the writer’s answer differ, which is the general case this manual states elsewhere. Write the remaining byte count. A reader should not depend on it, because nothing makes the engine enforce it, and a tool that trusted it would be trusting a field the game never checks.
The delimiter, and the thirteen twinned tables
Warning
Thirteen tables ship twice, and the two copies use different delimiters The delimiter is a tab: one after each column header, one after each row label. That holds for every 2DA in
chitin.key, without exception.Thirteen of those tables also appear in
rims/global.rim, and that copy is byte-identical except that every delimiter tab is a NUL instead. (rims/miniglobal.rimcarries the same thirteen in the same form, and is never mounted: the string does not occur in the executable at all.) Established by diffing the two copies of each: the number of differing bytes equals the column count plus the row count exactly, every difference is a0x09where the other copy has0x00, and the offset table and string table are untouched. The two copies are the same length, so the substitution is one byte for one.The thirteen are
appearance,appearancesndset,baseitems,bodybag,doortypes,genericdoors,heads,inventorysnds,placeableobjsnds,placeables,portraits,soundsetandtraps. Every other 2DA in that archive is byte-identical to itschitin.keycopy, tabs included.The NUL-delimited copy is the one the game reads, so handling both delimiters is required rather than prudent.
global.rimis mounted by name, and the key table’s search order puts RIM archives above the base archives with no per-resource-type branching, so a lookup for any of the thirteen finds therims/copy first and never reacheschitin.key.A reader that assumes a tab therefore does not fail on an obscure variant. It fails on the live copy of thirteen of the most-read tables in the game, reading each header run as a single column name. Both forms terminate that run with a NUL, so a reader that scans for the terminator rather than the separator survives either.
Warning
u16cell offsets cap the string table at 64 KiB Every cell points into the string table with a two-byte offset, so no cell’s text can begin beyond byte 65,535 of that block. Deduplication is what keeps real tables comfortably inside it: repeated strings share one entry, and a 2DA column is usually a handful of distinct values repeated down thousands of rows. A generator that writes each cell’s string separately can overflow a table the game ships without trouble.
The text form (V2.0) carries the same logical table with none of this machinery: no offset matrix and no shared string table, just whitespace-separated cells, with **** standing in for an empty one.
Two ways a text cell comes out blank, and they differ
V2.0 has two conventions that both leave a cell looking empty, and they resolve to different values. Conflating them writes the wrong content into short rows.
An explicit **** becomes the empty string. The row-parsing loop checks each raw token for that literal and stores "" for the cell. This runs unconditionally and consults nothing else.
A row that runs out of tokens gets the table’s default. Where a row carries fewer tab-separated fields than the table has columns, every trailing cell it never supplied is filled with the value from the file’s DEFAULT: block. That is the whole of what DEFAULT: governs.
So **** and a short row are not two spellings of one idea. One says “empty here”, the other says “whatever this table falls back to”.
The DEFAULT: block
It sits on the line after the version, before the column headers, and it is optional. The parser reads that line’s first token, uppercases it, and accepts two spellings: DEFAULT: as a single token, or DEFAULT followed by a separate token beginning with :, which absorbs a stray space before the colon. Either match consumes one further token as the default value.
A line matching neither spelling is not an error. The parser moves straight on to column headers and no default is ever set, which leaves a short row’s trailing cells with nothing to fill them from.
Read from C2DA::Load2DArray at 0x004143b0. Provenance: traced, through the tokenizing path every fresh text-format 2DA takes. A second path through the same function, gated by a flag from the resource helper, skips this logic in favour of an offset scan over an already-processed buffer; what sets that flag was not followed.
Engine Audits & Decompilation
Read from C2DA::Load2DArray at 0x004143b0 in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
| 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 delimited run closed by a NUL; the delimiter itself is a tab in some copies and a NUL in others, as measured above. 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, reads an optional DEFAULT: block off the line after the version, then column headers, then rows. It runs _strlwr on all column headers to convert them to lowercase, but this never breaks a mixed-case column lookup, because column-name resolution is case-insensitive on both load paths, not just on this one. Two separate conventions produce a blank-looking cell; see below. |
Tip
Orphaned Size Field: In binary row blocks, the engine steps over the 2-byte
cell_data_sizeu16with a+2and neither reads nor validates it. That is a statement about the loader only; what a writer should put there is above.
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 |
File Layout
Three blocks, and the middle one is the only table in the file. The entry table starts immediately after the header at a fixed 0x14; the text blob is wherever entries_offset says, and every entry’s text_offset is measured from there rather than from the start of the file.
| Block | Size | Located by |
|---|---|---|
| Header | 20 bytes | Always at 0x00 |
| Entry table | 40 bytes per entry | Always at 0x14, immediately after the header |
| Text blob | remainder of the file | entries_offset, with each entry’s text_offset relative to it |
Header (20 bytes)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | magic | fourcc | TLK , trailing space included. |
0x04 | version | fourcc | V3.0. Not validated by the engine, but it does pick the entry size. See below. |
0x08 | language_id | u32 | Selects the text encoding. |
0x0C | entry_count | u32 | |
0x10 | entries_offset | u32 | Start of the text blob, not of the entry table. |
Entry Record (40 bytes each)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | flags | u32 | Three presence bits and a reject sentinel. See below. |
0x04 | sound_resref | char[16] | Voice-over resource, when the entry has one. |
0x14 | volume variance | u32 | Reserved. Write 0; see below. |
0x18 | pitch variance | u32 | Reserved. Write 0; see below. |
0x1C | text_offset | u32 | Relative to entries_offset, not absolute. |
0x20 | text_length | u32 | The text’s extent. It is not NUL-terminated; see below. |
0x24 | sound_length | f32 |
Note
String text is length-delimited, not NUL-terminated Each entry’s text runs for exactly
text_lengthbytes from itstext_offset, and no terminator follows it. A reader that scans for a NUL runs past the end of one string into the next, which is the failure counted, terminated, or neither describes as the silent direction. Measured on a sample of three thousand entries from a retaildialog.tlk, none of which carries a trailing NUL.
flags
| Bit | Value | Meaning |
|---|---|---|
| 0 | 0x1 | Entry text is present. Clear means the text is left empty rather than read from the blob. |
| 1 | 0x2 | sound_resref is populated. Clear means the consumer receives an empty resref. |
| 2 | 0x4 | sound_length is populated. Clear means the duration is zeroed. |
| 15 | 0x8000 | Reject sentinel, not a presence flag. |
Only three values occur across every entry of a full dialog.tlk: 7 on nearly all of them, with 6 and 0x8000 sharing a small remainder. So 7 is a normal line with text, voice-over and duration, and 6 is an audio-only line carrying no text.
0x8000 is different in kind. The engine tests the high byte early and, if set, drops straight into the path it uses for a missing or unreadable file, so the caller gets the same empty “invalid StrRef” result it would get for a broken table. Nothing else in the binary tests that bit. This is why the entries carrying it have neither text nor sound: the reader never reaches the point of populating either.
Note
The two variance fields have a canonical value, and it is zero “Reserved” says the engine does not use them and leaves a writer with nothing to put there, which is the fixed-position-and-unread case: the bytes have to be emitted whatever they mean, because everything after them is positioned by their width. Both are
0in every entry of thedialog.tlka retail install ships, so the value to write is0rather than anything a reader has to infer.This is a claim over one file, which is the only table the install carries. Nothing establishes what a non-zero value would do.
Warning
The version field is unvalidated but load-bearing The engine accepts any version tag without complaint and then uses it to pick the entry stride: 40 bytes for
V3.0, 36 for anything else. A mistyped version does not fail. It parses at the wrong stride, and every entry after the first is garbage.The install ships exactly one table,
dialog.tlk, atV3.0and 40 bytes. Confirmed arithmetically:20 + 49,265 × 40lands exactly onentries_offset, where 36 falls 197,060 bytes short.So the 36-byte record is a path no KotOR file takes, on a population of one file rather than a corpus. No TLK-magic resource appears inside any archive either, and the feminine-dialect companion the loader probes for is absent from the install, so nothing here exercises that probe.
Important
The engine has no encoding table, and ours is a deliberate divergence There is no encoding declaration in the file, and there is no lookup in the executable either. The engine special-cases exactly one value:
language_id == 5sets a Polish locale explicitly, selecting Windows-1250. Ids at 1000 and above toggle IME support and set no codepage. Everything else, including all five official releases, inherits the process’s default ANSI codepage: Windows-1252 on a western install, something else elsewhere. So the engine’s own decoding is not a property of the file at all; it depends on the machine.Rakata maps
language_idto a codepage explicitly instead. For the ids KotOR shipped this agrees with the engine on a western install, and past them it imposes determinism the engine does not have. That is a deliberate choice rather than an implementation of the format’s rule: a table that decodes differently depending on the reader’s OS is not something a specification can round-trip.
Engine Audits & Decompilation
Read from CTlkFile::ReadHeader at 0x0041d890 and CTlkFile::AddFile in swkotor.exe. Provenance: derived, not attested unless a claim says otherwise: the rows have not been separately re-derived, so they sit on the reverse-engineering queue. Individual claims below may carry a level of their own, and where one does it overrides this line for that claim.
| 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 |
File Layout
Plain ASCII, no header, no magic, no terminator. The whole file is a sequence of blocks, one per observer room:
m01aa_08c 4
m01aa_08a
m01aa_08b
m01aa_07a
m01aa_07b
An unindented line names an observer room and how many rooms it can see. The lines that follow are those rooms, one per line, each indented by exactly two spaces. The next unindented line begins the next block. Room names carry no extension and refer to the rooms in the module’s LYT.
Measured across every .vis in chitin.key: the indent is two spaces in every child line without exception, and no file contains a blank line.
Warning
Do not trust the count, and do not assume the graph is symmetric This is the counted case from counted, terminated, or neither, with the twist that the count is not reliable, so the run has to be read as if it were terminated by the indent.
Both look like invariants and neither is one.
The count disagrees with the block in a handful of shipped files, so a reader that seeks forward by the declared number rather than reading until the indent stops will desynchronise and attribute one room’s visibility to another. Read to the end of the indented run; treat the number as a hint.
A shipped file is not a symmetric graph. Hundreds of edges name a room that does not name the source back. The engine makes the relation symmetric itself, at load, by inserting each direction as it reads (see the audit below), so the loaded result is symmetric and the file is not. A reader reproducing engine behaviour has to mirror; a tool round-tripping the file must not, or it will write back edges BioWare never shipped.
One further shape occurs. A single shipped file carries an observer line with no count at all, just the room name, which is worth tolerating rather than rejecting.
Engine Audits & Decompilation
Read from Scene::LoadVisibility at 0x004568d0 in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
| 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 |
File Structure
Four count-led sections between a beginlayout and donelayout pair. Each section declares how many entries follow, and the entries are whitespace-separated fields on their own lines. Text is Windows-1252. Counted throughout, with no terminator on any run: see counted, terminated, or neither for why guessing the wrong one is not a graceful failure.
beginlayout
roomcount <N>
<room_model> <x> <y> <z>
trackcount <N>
<track_model> <x> <y> <z>
obstaclecount <N>
<obstacle_model> <x> <y> <z>
doorhookcount <N>
<room_name> <door_name> <reserved> <x> <y> <z> <qw> <qx> <qy> <qz>
donelayout
| Section | Fields per entry | Meaning |
|---|---|---|
roomcount | model, then x y z | A room model and where to place it |
trackcount | model, then x y z | Swoop track geometry |
obstaclecount | model, then x y z | Swoop obstacle geometry |
doorhookcount | room, door, reserved, x y z, then qw qx qy qz | Where a door attaches, with a quaternion orientation rather than a bare position |
Doorhook entries
The doorhook reserved field sits third, between the two names and the position, and it is an integer written as 0. That is its value in every doorhook of every .lyt a retail install ships, so a writer emits 0. It cannot be omitted: the fields are positional and whitespace-separated, so dropping it shifts x into its place and the whole entry reads wrong.
Important
The doorhook quaternion is
wfirst The four floats after the position arew, x, y, z, the same order as GFF’sVector4and the MDL quaternion. Decode them asx, y, z, wand you write back a different orientation than you read.
A door hook is a yaw about the vertical axis, so x and y are zero in all but eight rows across a retail install. Many rows read exactly 1.0 0.0 0.0 0.0, the identity, and the yaws that remain land on multiples of 90 degrees. The eight exceptions sit on angled geometry and carry a populated y or all four components.
Read the same rows as x, y, z, w and the zeroes move to the first two slots for one subset only, the half turns, where w is genuinely 0. That covers a minority of the rows. Reading w first covers all but the eight.
Measured over every .lyt indexed by a retail chitin.key and every doorhook row in them. Provenance: measured.
Warning
The engine stores every doorhook field and reads none of them back
CLYT::LoadLayoutparses each doorhook line into five live arrays: room name, door name, the integer third field, the position and the orientation. Nothing reads them back, so no door is linked to a room through this structure. See what can reach the doorhook arrays.
Write the section anyway. Every .lyt a retail install ships declares it, and the ones with no hooks say so with doorhookcount 0 followed by donelayout.
Keep the count matching the rows that follow it. No shipped file disagrees, so the engine’s behaviour on a mismatch is untested, the parse loop does not verify its own terminator (see Boundary Oversight below), and the arrays are sized from the declared count before anything else can intervene.
Omitting the section is survivable but pointless: the parser meets donelayout, never reaches the branch that reads the count, and leaves it at zero.
Reports of memory corruption in modules with the doorhook section stripped come from the parse. Nothing is reading these values back.
Section order
The section order is not a convention. The engine’s parser walks them sequentially, and a file presenting them out of order will not load.
Note
The four sections cascade, and only the tail is optional The parser reads
roomcountthe moment it findsbeginlayout, with nodonelayouttest first, so you can never skip the room section.After that it tests each following line against the literal
donelayoutbefore treating it as the next section’s count. A match ends the file there and the remaining sections go unread. A non-match means the parser takes that line astrackcount Nwhatever it actually says, then runs the same test beforeobstaclecountand again beforedoorhookcount.So a file may stop early, and shipped layouts do exactly that in effect by declaring the later sections empty. What a file may not do is skip a section in the middle: drop
trackcountwhile keepingobstaclecountand the parser reads the obstacle count as the track count, putting everything after it in the wrong section.A count keyword followed by no number is a third case. The read is an unchecked
sscanf: the conversion fails, writes nothing, and returns a value nobody examines, so the count keeps what it already held. Every caller supplies a fresh instance, so that is zero.Provenance: traced.
Unchecked counts
Warning
No count in this format is bounds-checked before it sizes an allocation All four sections behave alike.
roomcount,trackcount,obstaclecountanddoorhookcountare each read as a plain integer and multiplied into a byte size, with no upper-bound check anywhere in the loader. The parse loop then writes that many elements into whatever the allocation turned out to be.A large enough count overflows the multiplication into an allocation smaller than the loop is about to fill. This belongs to the layout parser as a whole rather than to any one section, and it holds whether or not anything downstream reads the values.
Provenance: traced, as far as the missing check. What a given oversized count does past that point was not followed.
Where Rakata differs
Note
Rakata reads more loosely than the engine does The engine skips everything before
beginlayout, which is what makes the ubiquitous#MAXLAYOUT ASCIIpreamble harmless. Our parser goes further and ignores any line it does not recognise as a known count section, wherever it appears, so files carrying comments or dependency metadata between sections still parse. That is deliberate tolerance on the read side; anything written back out is canonical.
Engine Audits & Decompilation
Read from CLYT::LoadLayout at 0x005de900 in swkotor.exe. Individual claims below carry their own provenance where it is known. Rows with no marker are derived, not attested, and sit on the reverse-engineering queue.
| 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. |
| Doorhook data is stored and never read | Provenance: traced. Parsed into five live arrays that nothing reads back. Enumerated in full below. |
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.
What can reach the doorhook arrays
Each doorhook line is parsed with a single sscanf into five separate arrays. The integer third field gets an array of its own, so the parser retains it rather than discarding it.
CLYT exposes getters for room, track and obstacle data and none for any doorhook field. CLYT::LoadLayout has two callers, CSWSArea::LoadRoomInfo (0x005073d0) and CSWCArea::LoadArea (0x00607610). Neither references the doorhook arrays, and CLYT::UnloadLayout (0x005de450) releases the resource without reading them.
Nothing reaches the arrays indirectly either. CLYT offers no path a caller could dispatch through, and the instance never outlives the call that loads it.
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 |
File Layout
A 9-byte header followed by three probability blocks, one per Markov order. Nothing points anywhere: every block’s size falls out of letter_count, so the whole file is positional.
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | magic | fourcc | LTR , trailing space included. |
0x04 | version | fourcc | V1.0, strictly validated. |
0x08 | letter_count | u8 | 28 in every KotOR file. Sizes everything below. |
Writing L for letter_count, the three blocks follow immediately at 0x09:
| Block | Element | Index order | Size at L = 28 |
|---|---|---|---|
| Singles | f32 | [position][next] | 336 bytes |
| Doubles | f32 | [prev][position][next] | 9,408 bytes |
| Triples | f32 | [prev2][prev][position][next] | 263,424 bytes |
Every block is a flat f32 array in C order, with the next letter varying fastest and any preceding-letter context varying slowest. So a run of L consecutive floats is one distribution over the alphabet, and the three blocks differ only in how much context selects which run you read.
What a run of L floats contains
Each run is a cumulative distribution, not per-letter probabilities: values ascend across the run and the last non-zero entry is exactly 1.0 in every populated run in the shipped files. A generator draws a uniform value and takes the first entry at or above it.
0.0 is a sentinel meaning the letter cannot occur in that context, rather than a probability of zero folded into the running total. That distinction is what makes the array look non-monotonic on a naive read: a run like 0.066, 0.115, 0.197, 0.23, 0.0, 0.246 is ascending once the sentinel is skipped, and a reader that treats the 0.0 as a cumulative value will conclude the data is corrupt.
Most runs are entirely zero, which is simply a context that never occurs in the source names, unsurprising at the triples order, where the great majority of three-letter contexts are unattested. A handful of shipped runs are not perfectly ordered even after skipping sentinels; a reader should tolerate that rather than reject the file, since the engine does.
The recurring 3 is the position within a name: start, middle and end each get their own distribution, which is how the generator knows that some letters are plausible openers and others only ever appear inside a word. The three blocks are the first, second and third Markov orders: the singles say what letter comes next given nothing, the doubles given one preceding letter, the triples given two.
The alphabet
Every index in every block refers to a position in this sequence, and nothing in the file states it: a table indexed by letter with the letters left implicit:
| Index | Letter | Index | Letter |
|---|---|---|---|
0–25 | a through z, in order | 26 | ' (apostrophe) |
27 | - (hyphen) |
The two non-alphabetic entries are what let the generator produce names like Bel'aya or Jar-Kai rather than treating them as impossible. A reader that assumes 26 letters reads the tables correctly for a-z and silently misplaces everything after.
Note
Every KotOR
.ltris exactly 273,177 bytes Nothing in the format is variable-length onceletter_countis fixed, so at the 28 letters KotOR uses, the total is9 + 336 + 9,408 + 263,424. The engine closes its read by asserting that the final parse offset equals the buffer length, as the audit below records, which means a file even one byte off that figure is rejected outright rather than partially accepted. A generator producing these has no slack whatsoever.
Engine Audits & Decompilation
Read from CResLTR::OnResourceServiced at 0x00712410 in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
| 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 |
File Layout
There is no single layout here. A .wav in KotOR is one of three different things wearing the same extension, and which one you have is decided entirely by what the first few bytes look like.
| Variant | Discriminator | Payload starts at |
|---|---|---|
| Standard RIFF/WAVE | Begins with RIFF, and the declared RIFF size accounts for the whole file | 0x00; the file is the payload |
| SFX wrapper | No RIFF at 0x00, and RIFF present at 0x1D6 | 0x1D6 (470), where a normal RIFF/WAVE begins |
| MP3-in-WAV wrapper | Begins with RIFF, but riff_size + 8 falls short of the file length | riff_size + 8, where raw MP3 data begins |
Telling the two wrapped forms apart
They are discriminated differently, and it is worth being precise about which is which. The SFX form is caught by the absence of RIFF at the start, not by any magic of its own. The MP3 form does start with RIFF and passes a naive check. What gives it away is arithmetic, not a signature: riff_size + 8 lands short of the file’s end, and the payload starts exactly there. A reader that trusts the RIFF tag and stops will hand the wrapper to a decoder and call the actual audio padding.
Every shipping example declares a riff_size of 50, putting the payload at byte 58, and it is tempting to treat 58 as the rule. It is not: the engine computes the offset rather than assuming it, so a wrapper of any other size resolves correctly for the engine and wrongly for a reader that hardcoded the observed value.
What the engine actually tests
Important
The engine never checks
FF F3 60 C4, and its test is negative Those four bytes are not a KotOR signature. They are an ordinary MPEG frame header (FF F3= MPEG-2 Layer III, no CRC;60= 48 kbps, 22.05 kHz, unpadded;C4= mono), the first header of the MP3 stub the wrapper opens with. They are constant across shipped files only because every stub was encoded the same way.A program-wide search for the constant, in both byte orders, returns zero hits anywhere in
swkotor.exe. It is a prefix observed in shipped files, not a magic value the engine validates.What both load paths actually do is test for the absence of something.
CExoStreamingSoundSourceInternal::InitializeSource(0x005dbd30), the streaming path, reads four bytes and compares them againstRIFF; if that fails it seeks straight to0x1D6and requiresRIFFthere, rejecting the file if that also fails.CResWave::OnResourceService(0x005df230), the resident path, does the same after first checking the first eight bytes against a separateBMU V1.0sub-format signature, then sets a flag thatCResWave::GetData(0x005df370) consumes as a plaindata + 0x1D6.So the discriminator is “not RIFF at the start, and RIFF at 470”. Any file meeting that shape is treated as wrapped regardless of what its first four bytes hold.
The intervening bytes are genuinely untouched. Across both paths the only bytes read inside the 470-byte span are
0–3, plus0–7on the resident path for the unrelatedBMUcheck. Bytes8through0x1D5are never dereferenced, compared, or used as a length or checksum by either function: the skip is a realfseekor pointer add, not a disguised read.Keying on the four-byte prefix instead is stricter than the engine and refuses wrapped files it accepts. Rakata’s reader applies the engine’s own test: no
RIFFat the start,RIFFat0x1D6. Its writer still emits the prefix, deliberately, because every file that takes this form carries exactly the same block and other tools do key on it even though the game does not.
The 470-byte prefix
Note
The 470-byte prefix is three MP3 frames, which is why it is 470 bytes The engine skips the block, but a writer still has to produce it, so it is worth saying what it is rather than leaving it as an opaque run. It is fixed: byte for byte identical in every wrapped file in a retail install, with no variation at all.
It decodes as three MPEG-2 Layer III frames at 48 kbps and 22.05 kHz. Frame headers sit at
0,156and313; the lengths those headers declare are156,157and157; and they total exactly 470. So the payload does not begin at an offset some tool picked, it begins where the third frame ends. Offset13holds the ASCIILAME3.93, and most of the remaining space is LAME’s0x55padding byte.The later two headers read
FF F3 62 C4, differing from the first only in the padding flag (62rather than60), which is exactly why they are 157 bytes against the first frame’s 156. Frame length for MPEG-2 Layer III is72 * bitrate / sample_rateplus the padding byte, and72 x 48000 / 22050truncates to156. That arithmetic is the whole of why the prefix is 470.The wrapper is therefore an MP3 stub with a RIFF/WAVE file glued on behind it. What it was for is not established, and one specific explanation has been checked and did not hold: neither
LAMEnorswkotor.exe, so the stub is not boilerplate the game itself emits or recognises by name. That does not rule out an encoder having produced it upstream in BioWare’s tooling; it rules out the game being the thing that put it there. What it means for a writer is that the block gets reproduced verbatim, and that the frame arithmetic is an independent check that a copy is intact: decode the three headers and the lengths have to land on0x1D6.Where the form occurs. Every file under
streamsounds/, and some but not all ofstreammusic/. No.wavresource inside any archive takes this form, and nothing understreamwaves/does either, since those are all ordinary RIFF/WAVE. So “SFX” here is a shape a few directories use, not a property of the extension.
BMU V1.0, and where the Rust implementation stops
Note
BMU V1.0is a third sub-format, and this manual does not document it The resident load path checks the first eight bytes against aBMU V1.0signature before it tests forRIFFat all. Nothing else in these pages mentions it and Rakata does not handle it. It is recorded here because a reader tracing the engine’s audio dispatch will meet the branch and find nothing about it anywhere else.
Note
Scope of the Rust implementation
rakata-formatshandles the container tier only: identifying which of the three wrappers a file uses, and getting to the payload inside it. Sample decoding is not its job, which mirrors the engine, where the executable routes to Miles Sound System and performs almost no chunk parsing of its own.
Engine Audits & Decompilation
Read from CExoSoundInternal::LoadSoundProvider at 0x005d9140 in swkotor.exe. Provenance: derived, not attested unless a claim says otherwise: the rows have not been separately re-derived, so they sit on the reverse-engineering queue. Individual claims below may carry a level of their own, and where one does it overrides this line for that claim.
| 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) | Reached by failing the "RIFF" test at offset 0, not by matching anything. The engine then requires "RIFF" at +0x01d6 and rejects the file if it is absent. It does not interpret the 470-byte prefix in any way: it seeks or pointer-adds past it, reading none of bytes 8–0x1d5. The payload is the remainder, size = file_size - 0x1d6. |
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 |
File Layout
Two blocks and no offsets to follow: a 16-byte header, then the keyframe array immediately after it. Nothing in the file points anywhere, because nothing needs to.
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | signature | char[8] | LIP V1.0, checked as one eight-byte run rather than as a magic and version pair. |
0x08 | animation length | f32 | Total duration in seconds. |
0x0C | entry_count | u32 | |
0x10 | keyframes | array | 5 bytes each, packed with no padding. |
Keyframe Entry (5 bytes each)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | time | f32 | Timestamp in seconds, from the start of the animation. |
0x04 | shape | u8 | Viseme index 0–15. See below. |
The 16 visemes
Every one of these occurs across a full install’s 1,073,460 keyframes, and no value outside the range appears anywhere. The names are phonetic groupings rather than individual sounds: one mouth shape serves every consonant made the same way.
| # | Shape | # | Shape | # | Shape | # | Shape |
|---|---|---|---|---|---|---|---|
| 0 | Neutral, mouth closed | 4 | “oh” | 8 | “f”, “v” | 12 | “t”, “d” |
| 1 | “ee” | 5 | “ooh” | 9 | “ng” | 13 | “sh” |
| 2 | “eh” | 6 | “y” | 10 | “th” | 14 | “l” |
| 3 | “ah” | 7 | “s”, “t”, “s” cluster | 11 | “m”, “p”, “b” | 15 | “k”, “g” |
Warning
The on-disk layout is the in-memory layout As the audit below records, the engine does not parse the keyframe array at all. It points an internal pointer at file offset
0x10and animates directly off the raw buffer. That makes the five-byte stride load-bearing in a way most formats’ are not: there is no padding to the natural four-byte alignment af32would normally want, and adding any would not produce a slightly different file, it would produce one the engine reads as garbage from the second keyframe onward.
Engine Audits & Decompilation
Read from CLIP::LoadLip at 0x0070c590 in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
| 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 | SSF / V1.1 |
| Type | Enum-String Mapping |
| Rust Reference | View rakata_formats::Ssf in Rustdocs |
File Layout
The smallest container in the set: a 12-byte header and a table of 32-bit StrRef values, with the table located by an offset rather than assumed to follow the header.
| Block | Size | Located by |
|---|---|---|
| Header | 12 bytes | Always at 0x00 |
| Sound table | 4 bytes per slot, 28 slots | sound_table_offset, in practice always 12 |
| Trailing reserved entries | 4 bytes each | Whatever follows the 28 slots |
Header (12 bytes)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | magic | fourcc | SSF , trailing space included. |
0x04 | version | fourcc | V1.1. |
0x08 | sound_table_offset | u32 | Distance to the table. Always 12 in practice, meaning the table abuts the header. |
Each slot is a single i32 holding a TLK string reference, and the slot’s position is its meaning. An unset slot carries -1 (0xFFFFFFFF) rather than 0, which matters because 0 is a perfectly valid StrRef.
The 28 slots
Array index on the left, since that is what the file uses. Scripts address these 1-indexed, so a script firing event 1 reads array index 0; the engine subtracts one on the way in.
| # | Trigger | # | Trigger | # | Trigger | # | Trigger |
|---|---|---|---|---|---|---|---|
| 0 | Battle cry 1 | 7 | Select 2 | 14 | Low health | 21 | Begin search |
| 1 | Battle cry 2 | 8 | Select 3 | 15 | Dead | 22 | Begin unlock |
| 2 | Battle cry 3 | 9 | Attack grunt 1 | 16 | Critical hit | 23 | Unlock failed |
| 3 | Battle cry 4 | 10 | Attack grunt 2 | 17 | Target immune | 24 | Unlock success |
| 4 | Battle cry 5 | 11 | Attack grunt 3 | 18 | Lay mine | 25 | Separated from party |
| 5 | Battle cry 6 | 12 | Pain grunt 1 | 19 | Disarm mine | 26 | Rejoined party |
| 6 | Select 1 | 13 | Pain grunt 2 | 20 | Begin stealth | 27 | Poisoned |
Warning
The trailing count is not uniform, and Rakata currently normalises it Real files carry extra
-1values after the 28 slots, and the count varies: most carry 12 trailing entries and a minority carry 21, giving 172-byte and 208-byte files respectively. Rakata emits twelve regardless, a figure inherited from the PyKotor writer rather than measured, so round-tripping one of the larger ones rewrites it into a shape it does not have. A writer should preserve the count it read.The engine does not care either way, and that is now traced rather than assumed.
CSoundSet::GetStrres(0x00678820) bounds its lookup atindex != 0 && index < 29, so only slot indices 1 through 28 are ever dereferenced, exactly the documented slots and nothing past them. It resolves the slot address by readingsound_table_offsetlive out of the loaded buffer rather than assuming a fixed position. Its only caller,GetSoundSetStrres(0x0060b8a0), andPlaySoundSetSound(0x00611470) add no further indexing or length check.The load and unload hooks parse nothing at all:
CResSSF::OnResourceServiced(0x006db690) checks the data pointer is non-null and flips a flag, reading no header field past offset zero. Nothing anywhere in the traced call graph treats a 12-entry file differently from a 21-entry one, so the trailing bytes are inert to this build regardless of which count a file carries.That makes preserving the count purely a round-trip-fidelity choice on our side, with no engine behaviour riding on it. Why two counts exist at all is not traced, and no struct in the program’s type database models the file beyond the 28 slots.
Engine Audits & Decompilation
Read from CSoundSet::GetStrres at 0x00678820 in swkotor.exe. Provenance: derived, not attested unless a claim says otherwise: the rows have not been separately re-derived, so they sit on the reverse-engineering queue. Individual claims below may carry a level of their own, and where one does it overrides this line for that claim.
| 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.
Sentinel Values
A sentinel is a value inside a field’s ordinary range that the engine treats as meaning “none”, “unset” or “go and look somewhere else”. They are used heavily across the KotOR formats, several appear on more than one page meaning different things, and the pages refer to the convention as though it had a home. This is that home.
The pattern matters more than any single value. KotOR rarely signals absence by omitting a field. It writes an in-range number that a naive reader will accept, use, and get wrong. PortraitId of 0 is portrait row zero, which is a real portrait; TrapType of 0 is a real traps.2da row; ObjectId of 0 is a legitimate object. In each case the sentinel is something else entirely, so a reader that defaults these fields to zero produces a file that parses cleanly and behaves wrongly.
A field absent from here has not been cleared
These are the recurring sentinels, not all of them. A sentinel is a per-field fact, so the full set is spread across the format pages and each states its own. Collected here are the values that turn up on more than one page, that catch people, or that show one of the three shapes below clearly. A blueprint page will name sentinels this table does not.
So use it to learn the shapes and to check a value you have already met, not as a checklist. A field missing from this page has not been cleared. It has not been asked about.
One row in the table carries not established in place of a meaning, which is a third state again: the value is known and its handling is not. It is there rather than left out so that the gap is visible, since a sentinel nobody has followed downstream reads exactly like one nobody has found.
The values
| Value | Where | Means |
|---|---|---|
-1 | SSF sound slots | Slot unset. 0 is a valid StrRef, so this is the only way to say “no sound”. |
-1 | Walkmesh adjacency | Edge has no neighbour. Terminates a walk at the mesh boundary rather than skipping. |
-1 | Walkmesh edge transition | No room beyond this edge. The only value checked before the field is used as a room index. |
-1 | Walkmesh AABB node face index | Interior node. A real face index marks a leaf. |
-10 | ARE MiniGame.Num_Loops | Not established. The engine substitutes it for an absent field, then passes it into a setter whose own handling of it was never traced. |
0xFF | UTD, UTP, UTT TrapType | No trap. 0 is a real traps.2da row, so a reader defaulting to zero arms a trap. |
0xFFFF | MDL face adjacency | No neighbouring face. |
0xFFFE | UTC, UTD, UTP, UTT PortraitId | A threshold, not an equality. Below it the id is used; at or above it the engine consults the Portrait resref instead. |
0x7F000000 | GIT ObjectId, all object lists | No runtime object. 0 is a valid object id, which is why this is not zero. |
0xFFFFFFFF | GIT CurrentWeather | Forced by the engine on interior areas, overriding whatever the file carries. |
0xFFFFFFFF | DLG Delay | Substitute the root DelayEntry/DelayReply instead. Note this is not the field’s absent value, which is a plain 0. |
0x8000 | TLK entry flags | Reject sentinel rather than a presence bit. |
0x22E | UTD PortraitId | Substituted by the engine when the file carries 0. |
10075 | UTP animation | Applied unconditionally when Open resolves non-zero, in place of reading the animation fields at all. |
10000.0 | ARE fog ranges | The absent-value default, an effectively infinite distance. |
Three shapes, and they behave differently
An equality sentinel is one specific value meaning “none”: -1, 0xFF, 0x7F000000. Compare for equality and take the other branch.
A threshold sentinel is a boundary rather than a value, and PortraitId is the one that catches people. The engine tests < 0xFFFE, so 0xFFFE, 0xFFFF and anything above all route to the resref. A reader comparing against 0xFFFF alone handles one of three cases.
A substitution sentinel does not mean “none” at all. It means “the real value is elsewhere”. Delay’s 0xFFFFFFFF sends the engine to a different field; PortraitId of 0 on a door becomes 0x22E. Nothing is absent in either case, and treating them as absence loses the value the engine actually uses.
Important
A sentinel is not the same as an absent-value default, and several fields have both.
Delayis the clearest: its sentinel is0xFFFFFFFFand its absent value is0. A file that omits the field does not get the substitution behaviour, so “the field is missing” and “the field says look elsewhere” are different states with different outcomes.
PortraitIdgoes the other way. Its absent value is the sentinel, so omitting it and writing0xFFFFare indistinguishable to the engine. Which of those two a field does is a per-field fact, recorded on the format pages rather than derivable from the sentinel itself.
For a writer
Use the sentinel rather than omitting the field, unless a format page says otherwise for that specific field. The engine’s own writers do, and a field carrying its sentinel is unambiguous where an absent field depends on the reader agreeing with you about the default.
Zero is almost never the right “nothing” here. Every sentinel in the table above is some other value precisely because zero was already taken.
Resource System & Resolution
Every asset in the game is fetched by name, never by path. This page is about what happens between a name and the bytes.
There are four layers, and they stack. Knowing which one you are in answers most questions:
| Layer | The question it answers |
|---|---|
| The name | What may a resource be called, and how are two names compared? |
| The archive search | Given a name, which archive answers first? |
| Module composition | A module is several archives. How do they merge into one? |
| Install-wide tiers | Override, module, save, base game. Which tier wins? |
A fifth exists in the engine and does nothing on PC: the downloadable content mounts.
ResRef: what a name is
A ResRef is a fixed 16-byte buffer, used everywhere a resource is named: KEY and BIF entries, RIM keys, GFF resref fields, save-game handles, network messages.
The engine validates nothing. Every constructor copies up to 16 bytes straight into the buffer. There is no character check, no rejection of odd bytes, and no ValidResRef helper anywhere in the binary, which also carries no “invalid resref” error string to print. Longer input is truncated at 16 bytes and the rest of the buffer is zero-filled. The empty constructor zeroes all 16, and a null source lands in the same place.
Nothing is encoding-aware either. The buffer is compared as raw bytes, so a name stored under a non-ASCII byte is found only by that exact byte.
What names are actually in use
The freedom above is wider than the content that uses it, and the real range is worth knowing before you write a validator.
Across every key entry in chitin.key, the module RIMs, the static ERFs and a save corpus, the only non-alphanumeric bytes that occur at all are _, -, + and !.
Three of those four turn up in script names and nowhere else:
| Byte | Where it occurs |
|---|---|
_ | Not restricted to scripts. The one separator general content uses |
- | Script names |
+ | Script names. A handful of chitin.key entries, all one script in compiled and source form, plus several dozen RIM entries such as k_pdan_state1+ |
! | Script names, in RIM key tables alone |
No texture resref in any of those populations carries punctuation beyond _.
How Rakata models it
rakata_core::ResRef takes any single ASCII byte up to 16 bytes and lowercases letters, matching the engine’s case-insensitive lookup. Multi-byte UTF-8 is rejected: Rust’s &str forces valid UTF-8, and a multi-byte sequence cannot stand for the single byte the engine would store under Windows-1252, so accepting it would corrupt the lookup key quietly.
The 16-byte cap and the case folding match the engine. The ASCII-only rule does not. It is a Rust-side guard, and vanilla content never tests it: no resref in any of those populations carries a byte above 0x7F or exceeds sixteen bytes.
Read from the CResRef::CResRef constructor family at 0x00405ed0, 0x00405ef0, 0x00406d60, 0x00406d80 and 0x00406da0, and the network path CSWMessage::ReadCResRef at 0x004d6180. Provenance: derived, not attested, so these sit on the reverse-engineering queue. The name-content measurement above is measured.
The search order inside a key table
When the key table is asked for a name it walks a hardcoded folder order and returns the first match, ignoring any duplicate deeper down.
| Order | Tier |
|---|---|
| 1 | resource_directory, the Override folder |
| 2 | ERF, first pass |
| 3 | RIM |
| 4 | ERF, second pass |
| 5 | Fixed archives and BIFs |
The order does not branch on resource type. There is no separate path for textures, or 2DAs, or anything else, so a RIM beats the base archives for every kind of resource alike. That has a consequence worth following: where an archive higher in this order ships its own copy of a base-game resource, that copy is what the game reads, and the base copy is never consulted. The 2DA page documents thirteen tables where the two copies are not identical.
Duplicates are settled once, at startup rather than per lookup: AddKey notices a name it already holds and drops the newcomer, so whichever archive got there first keeps the slot for the rest of the session.
Read from CExoKeyTable::FindKey at 0x0040ec50. Provenance: traced. The five tiers and their order were read from the function, and no per-resource-type branch exists on that path.
TXI sidecars are looked up independently
A .txi is a small text file carrying material settings for a texture. The thing to know is that the engine does not fetch it alongside its texture. It goes and looks for it from scratch.
Every request routes through one helper, AurResGet(name, ".txi", ..., true), and three rendering paths use it: CAurTextureBasic::Init, Gob::EnableRenderBumpedOut and Material::Init. That helper knows only the name and the extension. It has no idea where the parent texture came from.
So the two can come from different places. A texture loaded out of a BIF can take its TXI from Override/, because nothing ties the sidecar to the parent’s source. Rakata keeps that behaviour in rakata_extract::GameVfs::resolve_texture_with_txi, which walks the tiers for the texture and then walks them again from the top for the sidecar.
Read from AurResGet at 0x0044c740. Provenance: derived, not attested.
Module composition
A module is an area and everything in it, and it ships as several archives rather than one. Composing them is a fixed precedence, highest first:
| Priority | Archive | Holds |
|---|---|---|
| 1 | <root>_dlg.erf | Dialogue overrides |
| 2 | <root>_s.rim | Supplemental properties |
| 3 | <root>_a.rim, else <root>_adx.rim, else <root>.rim | The area itself |
| 4 | <root>.mod | Single-file module archive |
The engine works the other way round when loading: it asks for MODULES:<root>.mod first, and when that is absent it falls back to <root>_s.rim and probes for <root>_a.rim and <root>_adx.rim to merge the area geometry in.
Tip
In Rakata:
CompositeModule::load_from_directoryscans a folder and merges the_dlg,_s,_a/_adxand base archives in the order above.
Read from CExoResMan::AsyncLoad at 0x004094a0. Provenance: derived, not attested.
Tiered Resolution: GameVfs
Module composition settles one module’s archives. The layer above is install-wide: asked for one resource by name, which tier answers?
rakata-extract models this as GameVfs, which owns the install and exposes one resolve(resref, type) walking the tiers below.
| Tier | Source | Engine warrant |
|---|---|---|
| 0 | Mounted save, when one is mounted | n/a |
| 1 | Caller-pushed extra overrides, last pushed wins | n/a |
| 2 | The Override/ directory | traced |
| 3 | The active module, mounted via load_module | traced |
| 4 | chitin.key and the BIFs | traced |
Override/ beats the active module, and that is the engine’s order rather than a convenience. The traced key-table walk probes the override directory first, then ERF, then RIM, then ERF again, then the fixed and BIF tier, with no per-resource-type branching. A module is composed of ERF and RIM archives, so all of it sits below Override/. Dropping a file there to replace one a module ships is the case the directory exists for, and the walk honours it.
The top two tiers are not part of that order and should not be read as if they were. Neither a mounted save nor a folder a caller pushed in appears in the engine’s walk at all; both are rakata-extract’s own. They sit on top because saved state is the player’s actual world and because a caller pushing a folder is asking for it to win. So three of the five tiers carry engine evidence and two carry a judgement call.
The save tier
Saved state is the player’s actual world, so it outranks anything the install ships. Within the tier the loaded module’s saved copies come first, then the save’s flat session resources.
It shadows narrowly, and deliberately. A save carries state for every module the player has visited, but the engine only brings a module’s saved state into play once that module loads. Serving all of it at once would shadow install content for modules the player never entered. Everything else a module needs, meaning its scripts, dialogue, layouts and blueprints, resolves from the override directory and below exactly as it would with no save mounted.
Two layout details matter when reading this code:
- The archive is keyed by module; the
AREandGITinside it are named after the area, and you cannot derive one from the other. In a late-game saveebo_m41aaholds aream12aa,liv_m99aaholdsm50aa, andunk_m41agholds152unk. So the tier looks the archive up by module and then probes it with whatever resref the caller asked for. - The
IFOinside one of these archives is always calledmodule, never the module’s name, because the archive is a snapshot of a working directory where that is simply the file’s name. It identifies nothing. Module identity lives only in the name of the archive containing it. - The flat session set is open, not a fixed list. Both save writers sweep the whole working directory into the archive, so incidental files land there too. See the save game pages.
Those first two together rule out addressing saved module content by resref. A (resref, type) key collides in any save holding more than one module, which is nearly all of them, because every one of those modules contributes a module IFO. Area resrefs collide too, in roughly a third of saves, from the ordinary case of one area name being reused across two modules. So saved module state is reached module-first or not at all.
Measured over three save corpora: the committed fixtures, a full manual-save set, and an in-progress modded playthrough. Every multi-module save collided on the IFO, without exception.
Mounting a save does not load a module, and unmount_save is independent of unload_module. Pairing them is the caller’s decision, which keeps save-loading policy out of the VFS.
Why tiers rather than one flat search
The engine has a single active module at a time, mounted when the player enters an area and unmounted when they leave. Searching every archive on disk would let one module’s resources bleed into another, so GameVfs::resolve consults only the mounted module.
Resolving one name versus listing everything
These are different questions and the API keeps them apart.
resolve answers the engine’s question: one resref, one winner, precedence applies.
Catalogue tooling wants the opposite: every resource of a type, across every module under modules/, with no winner. for_each_resource(type, callback) and for_each_resource_pair(primary, companion, callback) do that, walking every tier and every module’s archives transiently and yielding a ResourceOrigin per hit so the caller knows which tier the bytes came from.
Enumeration’s contract is the set of resources resolve could currently return, which is what stops the two drifting apart. With a save mounted, the save’s session resources appear, and the loaded module’s saved copies appear instead of the install’s, one entry per resource rather than both.
Note
Saved state for modules that are not loaded is left out, permanently No
resolvecall can return it, so listing it would not make enumeration more complete. It would answer a different question, and one this shape cannot express: resrefs are not unique inside a save. One late-game save holds two visited modules that each store an area namedm12aa, and a flat(resref, bytes)stream can only carry one of them.Per-module saved state is structural, and it belongs to the save-domain crate’s mounted-save view.
GameVfs::enumeration_exclusions()names what is skipped, so a caller can tell “the save has nothing for that module” from “enumeration did not look”.
Catalogue helpers
Read-only, for tools inspecting the module catalogue without mounting anything.
| Call | Returns |
|---|---|
list_modules() | Every module root, sorted |
has_module(name) | Whether that root exists |
module_files(name) | The archives composing one module, in precedence order |
open_module(name) | A CompositeModule, without touching active_module |
load_module_at(directory, name) | The same from an arbitrary directory, for testing and mod development |
Downloadable Content Mounts (Xbox)
Note
Xbox only. Inert on K1 PC and not modelled in Rakata. Documented now rather than left to be rediscovered, since Xbox support is planned. It has no effect on a PC install.
The engine has a built-in downloadable content system: numbered slots, each layered on the base game as its own tier. It is how the Xbox build delivered the Yavin Station bundle. Slots are addressed by a LIVE%d filesystem alias, and a global fixes how many exist, and the K1 GOG build reports six, LIVE1 through LIVE6.
A slot can carry a full content stack. AddDownloadedResources mounts each slot’s archives at startup, and the module and movie subsystems probe the same slots on demand:
| Content | Source | Consumed by |
|---|---|---|
| Modules | LIVE%d:MODULES\ | LoadModule, PopulateModules |
| Movies | LIVE%d:movies\ | 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 | 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 does nothing on PC
The mount walk is not disabled. The slot-count global is 7, so AddDownloadedResources runs at every startup and iterates all six slots. Each iteration asks GetAliasPath("LIVE%d") and does nothing when the alias does not resolve.
The aliases are never registered, and that is the whole reason. Aliases come from LoadAliases, which reads a fixed list of names out of the ini [Alias] section: HD0, OVERRIDE, TEMP, MODULES and a handful of others.
LIVE1 through LIVE6 are not on that list.
So adding them to swkotor.ini does nothing. The engine never asks for them, and an entry it never asks for cannot be found. On Xbox the Live subsystem registered the aliases itself; the stock PC build has no code path that does.
What the saves show
The saves corroborate the result rather than establishing it, and the distinction matters: they show the tier carrying nothing, not why.
Across a corpus of K1 save folders, every folder carrying the block has all six LIVE%d fields empty and LIVECONTENT at 0, without exception.
One folder lacks the block entirely, and it is an autosave. That supports the two-write-path split recorded on the savenfo page from a second direction: the block is written on a manual save and PCAUTOSAVE on an automatic one, and exactly one folder has the second without the first.
The Yavin Station content ships as ordinary game data on PC rather than through this path.
Reactivation as a modding tier
Tip
A dormant, engine-native expansion mechanism. The mount walk already runs, so the only blocker is getting a
LIVE%dalias into the alias list. Supply one, either a startup loader calling the alias-add path or a patch teachingLoadAliasesto readLIVE1-LIVE6from the ini, and the slot activates: a self-contained tier that adds modules, movies, textures and archived resources without touchingOverride.That makes it a candidate home for a content bundle or total conversion, and a possible delivery format for Rakata’s planned patcher.
Three caveats before anyone builds on it:
- A new planet still needs the usual galaxy-map and travel wiring, in 2DA and scripts, before it is reachable.
- A 2DA delivered through override replaces rather than merges.
- The per-slot file layout has not been mapped in full, and a recipe needs that first.
How Rakata models it
Not yet. The tier is inert on a stock K1 PC install, which is the current target. The mount table above is what to add to GameVfs when Xbox support lands.
Read from the functions below. Provenance: derived, not attested, except the save-corpus observation, which is measured.
| Function | Address | Covers |
|---|---|---|
AddDownloadedResources | 0x005f4180 | The startup mount walk, gated on the slot-count global |
LoadAliases | 0x005e7a90 | Registers the fixed alias-name list, which 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\ |
AddMovieToExoArrayList | 0x005fbbf0 | Probes LIVE%d:movies\ |
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. That skip is traced but unexercised: every per-module archive in the save corpus carries anARE, so no shipped save demonstrates the branch being taken; - 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, which is 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 the IncludeInSave column of modulesave.2da and excludes the module only when that row exists and the value is 0. The row is found by the module’s name as a string, not by an integer row index. 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)
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). |
File Layout
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, capped at 16 characters. Empty where the player never typed one; see below. | 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 |
SAVEGAMENAME is one of two labels, and it is usually empty
The load menu shows a slot label of its own, Game <n>, and shows SAVEGAMENAME underneath it. They are two independent lines, not a value and a fallback, which is why an unnamed save is still usable in the menu and why the field being empty is the ordinary case rather than a defect.
The engine writes that same slot label into the save folder’s own name. A manual save’s folder reads NNNNNN - Game<n>, where <n> is the slot number less one, the reserved slots 000000 and 000001 taking the two below it.
So the suffix carries nothing the slot number does not. A folder whose suffix disagrees with its number was renamed by something other than the game.
A writer should cap the name at 16 characters. Typing a longer one into the game truncates it there, and no name in either save corpus exceeds it.
Nothing enforces the cap on the file side, so a tool that writes more produces a name the engine would never have written.
Measured across a full manual-save corpus plus a modded playthrough’s saves and the four committed fixtures. Provenance: measured, and the two-line display is the game’s own behaviour rather than a trace.
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. Provenance: derived, not attested, so these rows sit on the reverse-engineering queue.)
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, sinceDoPCAutosaveonly 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, which 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, but 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 | A countdown rather than a display toggle. 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, since there is no in-flight transition for a manual save to preserve.
Absent-value behaviour
What the engine holds when a label is missing. The list menu reads a slot’s savenfo.res through a single function, CSWGuiSaveLoadEntry::LoadData (0x006c8e50), which is also the only reader of LASTMODULE anywhere in the binary, so there is no second, restore-specific reader with its own defaults to reconcile against. Nor is there a display layer between the read and the screen: every label this function touches is written straight into a member of the list entry before it returns, so what the entry holds is what the row shows. That covers AREANAME, LASTMODULE, TIMEPLAYED, SAVEGAMENAME, CHEATUSED, REBOOTAUTOSAVE, PCAUTOSAVE, SCREENSHOT, GAMEPLAYHINT, STORYHINT, LIVECONTENT, LIVE1-LIVE6 and PORTRAIT0-PORTRAIT2, every label this page attributes to the list-UI read, with nothing parked in a local for a later formatting step to pick up. Provenance: derived, not attested.
Grouped by shape, since the shape is the useful part:
Read with a literal empty-string or zero default. AREANAME, LASTMODULE, SCREENSHOT (all CExoString, default ""); TIMEPLAYED, CHEATUSED, LIVECONTENT, PCAUTOSAVE, REBOOTAUTOSAVE (default 0). REBOOTAUTOSAVE specifically: this confirms the existing note above. The field has no writer anywhere in this build, and now the read side is traced too, so it doesn’t merely trend toward 0 in practice, it is contractually 0 on every load.
SAVEGAMENAME is a literal-default read overridden by a second check. The CResGFF read itself uses the ordinary "" default, but LoadData inspects the read’s own found-flag afterward, and a genuinely absent label takes the branch that assigns the literal "Old Save Game" into the entry’s own name member. It goes in through the same assignment every other field in this function uses for its read result, so the substitute is the value the loaded entry carries rather than a flourish the list row adds on top. Absent and present-but-empty are two different states with two different answers: a missing label reads back as "Old Save Game", an empty one reads back empty.
GAMEPLAYHINT and STORYHINT are pure skips. Both are read with this->gameplayhint / this->storyhint as their own default argument, so an absent field leaves whatever the destination object already held rather than substituting a fixed value. On the one call site that matters (a freshly constructed list entry), that resolves to 0, but the mechanism is “unchanged”, not “zero”.
PORTRAIT0-PORTRAIT2 only, not one per member. LoadData reads exactly three portrait slots (PORTRAIT0 through PORTRAIT2) regardless of actual party size, each defaulting to an empty ResRef when absent. The field-series description above describes the write side, which does emit one per active member; the list-UI read side is capped at three.
LIVE1-LIVE6 are read, but the read almost never matters. The whole loop is gated behind LIVECONTENT != 0, so when LIVECONTENT is absent or 0, the case for every real PC save per the note above, none of the six labels are read at all, whatever the file carries under them.
When LIVECONTENT is nonzero each label is read and the value discarded immediately, unless the matching content alias fails to resolve on the current install. Then the read value survives just long enough to name the missing content in an error path, and the slot is flagged unavailable.
AUTOSAVEPARAMS absent means the whole struct is skipped, not defaulted field-by-field. LoadData never reads this field at all. It is consumed only by the autosave continuation path (CSWGuiSaveLoad::LoadPCAutoSave), which checks for the struct’s presence before calling KOTOR_AUTOSAVE_PARAMS::LoadFromGFF at all. When the struct is missing, that call is skipped entirely and the KOTOR_AUTOSAVE_PARAMS object keeps whatever its constructor set. See the AUTOSAVEPARAMS section below, since that is the same state a whole-struct absence and every individual nested default converge on.
AUTOSAVEPARAMS and STATUSSUMMARY absent-value behaviour
Traced from KOTOR_AUTOSAVE_PARAMS::LoadFromGFF (0x006c9de0), its constructor (0x004b2840), the constructor’s own Reset() (0x004b1400), and CStatusSummary::LoadFromGFF (0x006c8490).
LOADMUSIC,STARTWAYPOINT,MOVIE1-MOVIE6: pure skips, each read withthis->fieldnameas its own default. On the one traced call site, the object was just constructed andReset()(which explicitly sets all seven to"") ran immediately beforehand, so in practice an absent label reads as empty. But the mechanism is “unchanged”, the same shape asGAMEPLAYHINT/STORYHINTabove, and would preserve a different value if some other caller ever populated the object first.TIME_YEAR,TIME_MONTH,TIME_DAY,TIME_HOUR,TIME_MINUTE,TIME_SECOND,TIME_MILLISECOND,TIME_PAUSEDAY,TIME_PAUSETIME: all nine read with a literal0default, a true fixed default rather than a skip, unlike their string siblings above.STATUSSUMMARYitself: read as a nested struct lookup (GetStructFromStruct) gating a call toCStatusSummary::LoadFromGFF. When the struct is absent, that call never happens and every one of its fifteen fields keeps whatever the constructor’s zeroing loop set (all zero/false, part of the sameReset()that also zeroes theTIME_*fields).- Every
STATUSSUMMARYfield, when the struct is present:CREDITS,XP,STEALTHXP,CREDITSNET,LIGHTSHIFT,DARKSHIFT,DISPLAYSPENDING,ITEMRECEIVED,ITEMLOST,JOURNAL,SOUNDPENDING,LEVELUPSOUND,NEWQUESTSOUND,COMPLETESOUNDandSUPPRESSEDare pure skips without exception, each read with its own current value as the default. NoSTATUSSUMMARYfield has a fixed literal default; every one is “leave it as it was” when absent.
Implemented Linter Rules (Rakata-Lint)
None yet. Documented here ahead of any dedicated rakata-lint rules.
PT Format (Party Table)
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). |
File Layout
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. Provenance: derived, not attested, so these rows sit on the reverse-engineering queue.)
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_NP | 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_LIS | list | Store cost multipliers, each { PT_COST_MULT_VAL: 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.
Note
Three of the labels above are shorter than they read. A GFF label is a fixed sixteen bytes, so
PT_CONTROLLED_NP,PT_COST_MULT_LISandPT_COST_MULT_VALare not typos or abbreviations of longer names. The longer spellings cannot exist on disk, and every save carries the sixteen-byte forms. A reader that asks for the fuller name finds nothing and takes the absent default, which for the first two means a sentinel and an empty list off a file that holds both.
Absent-value behaviour
What the engine holds when a label is missing. Traced from CSWPartyTable::LoadTableInfo (0x00565d20), LoadJournal (0x00563430), and ClearTable (0x00563200), which runs unconditionally before any field is read and so establishes the baseline every “whole list or struct absent” case below falls back to. Provenance: derived, not attested.
Read with a plain literal default. PT_GOLD, PT_XP_POOL, PT_CHEAT_USED, PT_AISTATE, PT_FOLLOWSTATE, PT_NPC_AVAIL, PT_NPC_SELECT, GlxyMapNumPnts, GlxyMapPlntMsk, JNL_State, JNL_Date, JNL_Time, all default 0. PT_CONTROLLED_NP, PT_MEMBER_ID, GlxyMapSelPnt default -1, a real sentinel in each case rather than an arbitrary choice, matching pt_leader_id/galaxy_map_selected_planet’s own ClearTable baseline. PT_IS_LEADER defaults 0, so an absent field never promotes a member to leader. PT_TUT_WND_SHOWN (the VOID blob) defaults to six zero bytes copied in. JNL_PlotID defaults "", same as PT_FB_MSG_MSG, PT_DLG_MSG_SPKR, and PT_DLG_MSG_MSG.
Two fields are pure skips, chained off something other than this. PT_SOLOMODE defaults to its own current value (this->pt_solomode), an unchanged read, though a result of 0 (read or defaulted) always triggers UnstealthParty regardless of which one produced it. PT_LAST_GUI_PNL defaults to the live in-game GUI’s own current panel (CClientExoApp::GetInGameGui()->last_gui_panel), not a party-table field at all. JNL_SortOrder defaults to a process-global (journalSortOrder), also not a party-table field.
PT_PLAYEDSECONDS has an undocumented sibling fallback. When PT_PLAYEDSECONDS is absent, the loader doesn’t stop at a literal default. It reads a second field, PT_PLAYEDMINUTES, and multiplies it by 60. PT_PLAYEDMINUTES is not in the field table above, because it exists only as this fallback’s own source. It defaults to 0 if it is absent too, so the floor is the same 0 every other DWORD here lands on, just reached by a different route.
PT_NUM_MEMBERS is clamped twice, not just capped at the u8 boundary. It is a BYTE, so it cannot exceed 255 on disk whatever a writer intends. The loader then clamps it a second, independent way after the read: it’s brought down to PT_MEMBERS’s own list length if the list has fewer elements than the declared count claims, and never brought up if the list has more. An absent PT_MEMBERS list clamps the count to 0 even if PT_NUM_MEMBERS itself reads back nonzero, and the member-reading loop simply never runs, so pt_member_ids/pt_leader_id keep ClearTable’s baseline (0x7f000000 sentinel ids, leader -1).
PT_AVAIL_NPCS’s nine-slot cap is confirmed the same way, from the other direction. The loader takes the smaller of the list’s actual count and 9, so a list claiming more than nine elements is truncated to nine rather than rejected. An absent list clamps to 0 and the per-slot loop never runs at all, so pt_avail_npcs[] (companion unlocked) keeps ClearTable’s baseline of all-0, but pt_selected_npcs[] (companion selectable) keeps a baseline of all-1. Those two arrays default to opposite states: unlocked defaults closed, selectable defaults open.
GlxyMap absent skips the whole block, and the derived planet array has its own gate on top. If the GlxyMap struct itself is missing, none of its three fields are read and this->selectable_planets[] keeps ClearTable’s all-0 baseline (no planets marked selectable). Even when the struct is present, the derived per-planet unlock array is only populated if GlxyMapNumPnts == 16 exactly. Any other value, including its own absent-default of 0, skips the bitmask-unpacking loop entirely and leaves selectable_planets[] untouched. This is a hard equality gate, not a presence check.
PT_PAZAAKCARDS and PT_PAZSIDELIST are read unconditionally, with no “list found” guard, and that erases a non-zero starting baseline. Both loops run their fixed length (18 and 10 respectively) regardless of whether GetList found anything, reading each element independently with ReadFieldINT’s own default of 0. That matters because ClearTable’s baseline for these two is not zero: it seeds the first five Pazaak card-count slots to 2 each (a starter deck) and every side-deck slot to -1 (empty). A writer that omits either list entirely produces a table read back as zero starter cards and ten side-deck slots holding card index 0, not the starter-deck baseline. That is a real behavioural difference from “the field was never touched”, and invisible until a save is reloaded.
PT_FB_MSG_LIST and PT_DLG_MSG_LIST are the simple case. Both are gated on the list’s own element count; an absent list reads back as zero elements and neither message buffer is touched.
PT_COST_MULT_LIS loops by a count that has nothing to do with the list itself. The loop bound is the live base-item table’s count (baseitems.2da’s row count at runtime), not GetListCount() on this field, and there’s no “list found” guard around it either. An absent list means every base-item type’s cost multiplier reads back at the field’s own default, 1.0, a deliberate “no override” baseline rather than a coincidental zero.
JNL_Entries absent is a true no-op, confirming the existing note above with the mechanism. LoadJournal gates its entire body on GetList succeeding; when it doesn’t, the function returns having touched nothing, not even to clear a pre-existing journal. The existing “no active quests” phrasing describes the outcome for a fresh load correctly, but the mechanism is closer to “the loader does nothing at all” than “explicitly sets empty.”
Implemented Linter Rules (Rakata-Lint)
None yet. Documented here ahead of any dedicated rakata-lint rules.
GVT Format (Global Variable Table)
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). |
The Four Global Types
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
Read from the functions named below in swkotor.exe (K1 GOG build). Provenance: derived, not attested. The rows have not been separately re-derived, so they sit on the reverse-engineering queue. 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.
Absent-value behaviour
What the engine holds when a label is missing. This is the odd one of the three sidecars: the question isn’t “what does field X default to,” because Boolean/Number/Location values aren’t fields, they’re positions inside VOID blobs, and the catalogue/value pairing means an absence can happen on either half independently. Traced from CSWGlobalVariableTable::ReadTableWithCatalogue (0x0052a280). Provenance: derived, not attested.
Everything is zeroed before anything is read. The very first thing this function does, before touching the file at all, is clear its own destination arrays whole: every boolean bit, every number byte, every location’s position and orientation, and every string slot. That baseline matters below, because several absences don’t substitute a value at all. They just leave this zero state untouched.
A whole-block-absent Val* is a producible, handled state, not a special case. For ValBoolean, ValNumber, and ValLocation, the VOID block is read once with ReadFieldVOID, and the entire catalogue walk for that type (both reading Cat* names and applying Val* values) sits behind a check on whether that read found anything. When it’s absent:
- The catalogue (
CatBoolean/CatNumber/CatLocation) is never even consulted, not just the values. - The destination array for that type keeps the whole-table zero from the top of the function: every boolean reads
false, every number reads0, every location’s position and orientation both read(0, 0, 0).
So “what does the engine hold when ValNumber is absent” does have a clean answer: every number global is 0, exactly as if each one had been explicitly written as 0. From the read side, an absent block is indistinguishable from one present and fully zeroed.
ValString is not a VOID read at all, and follows the ordinary list-absent shape. Per the encoding table above, this one is a GFF list, and it’s gated the way any list is: if GetList on ValString fails, the whole string block (value list and catalogue both) is skipped, and every string slot keeps the top-of-function empty-string baseline. Each list element’s own String field, when the element is present but the field itself isn’t, defaults to "" the same as any other CExoString field traced across this project.
The four caps (900/500/100/5) are enforced once, at catalogue registration, not at the value read. Confirming the note already on this page: each type’s identifier count is checked against its cap (boolean_count < 900, number_count < 500, location_count < 100, string_count < 5) at the point a new name is registered from the catalogue. An identifier past the cap is dropped there, logged, and never registered, so its value, wherever it sits in the parallel Val* block, is never applied to anything. Two independent caps exist for strings specifically: the value list is itself truncated to the first 5 elements before the catalogue walk even starts, and the catalogue registration re-checks the same cap independently.
The pairing is name-driven rather than positional, so the two halves cannot desync. The catalogue is what gets walked, and each entry’s Name selects which slot of the matching Val* block is read and applied. Nothing ever walks a Val* block on its own looking for a name to match it.
So an entry whose Name is absent or empty is an ordinary handled case rather than a corruption: the read finds "", the code tests for exactly that before doing anything, and an empty name skips registration. That catalogue position contributes nothing, and whatever sat in the corresponding Val* slot is never surfaced as a global.
Which means a half-present pair is not a state the engine can be handed. It never looks for the second half except by walking the name that identifies it.
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. |
| Fields the Engine Never Reads | Fields vanilla resources carry that reach no engine code path, collected across formats. Includes the standard of evidence: a label absent from the executable’s strings cannot be read by any loader, which is a stronger claim than an untraced one. |
Fields Vanilla Writes That the Engine Never Reads
Vanilla resources are full of fields the shipped game does not look at. Not fields with unremarkable defaults, and not fields whose loader we simply have not traced yet: fields that carry real, deliberate-looking values in the retail data and reach no engine code path at all.
The fields are gathered here rather than one per format page, because a tool author asking whether it is safe to drop Comment on write is asking one question, not fourteen.
Why an unread field still matters
The obvious reading is “harmless leftover”, and for a round trip that is true. It stops being true the moment something reasons about the file’s meaning:
- Editors. A field nobody reads still round-trips, so an editor that silently drops it produces diffs against vanilla for no behavioural reason and makes real changes harder to spot.
- Linters. A rule that tells an author to set a field the engine ignores is worse than no rule. The distinction between “the engine reads this and you got it wrong” and “the engine has never once looked at this” is the whole value of the diagnostic.
- Modders. Setting a dead field and expecting an effect is a trap that fails silently, which is the worst way for it to fail.
The standard of evidence
There are two very different claims available here, and they are worth keeping apart.
The weak claim is “the loader we traced does not read it”. That is always provisional. Tracing covers the paths somebody walked, and a field could be read somewhere nobody looked.
The strong claim is not “we did not find a reader”, it is “no reader can exist”. There are two ways to earn it, and they fail in opposite situations, so it is worth knowing both.
By name: the label appears nowhere in the executable
The engine’s GFF readers take the field name as a literal string argument, so a label that appears nowhere in the binary cannot be handed to a reader on any code path. This is the route most of this page takes.
A string search that finds nothing is only as good as its ability to find something, so the searches behind this page are checked against labels that are read. Absence of NumWords means something because the same search does turn up VO_ResRef beside it.
Where it fails: when the name exists for some other reason. A field the engine writes, or syncs, or reads under a different concept, puts its own label in the binary and the search comes back inconclusive.
By reach: nothing can get to the value
Trace outward from the value instead of inward from the name. Enumerate every function that can hand out a pointer to the structure holding it, then enumerate their callers. If that set closes and no member reads the field, no reader can exist, whatever the name search says.
The journal’s Picture is the worked example, and it is exactly the case the name test cannot settle. The engine reads the field, keeps it, and syncs it from the server side to the client through a handler of its own, so the label is unmissable in the binary. But only two functions in the whole executable can return a journal entry pointer, and between them they have a single caller, which reads four other fields and not this one. See JRL.
Where it fails: when the accessors are numerous, or reached through a virtual call or a function-pointer table, so the set never closes. The name test does not care how a reader would have been reached, which is why the two complement each other rather than ranking.
A field is worth the strong claim under either route. Say which one you used, because the next reader’s field may only be reachable by the other.
DLG: NumWords, VO_ID, IsChild, Comment, LinkComment
A scan of every .dlg in chitin.key and the module RIM archives 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 is 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 behaviour 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.
Measured over every .dlg in chitin.key and the module RIM archives, which is where all of them live; the static ERFs and the save corpus contain none. Prevalence is given per distinct resref, since a handful of dialogues ship in more than one archive.
| Field | Struct | Carried by |
|---|---|---|
NumWords | Entries and replies | Every dialogue, without exception |
Comment | Nodes | Every dialogue, without exception |
VO_ID | Nodes | Every dialogue |
IsChild | Links | About nine in ten |
LinkComment | Links | About half |
NumWords and Comment are on every single file rather than “most”: a reader will meet them universally, and a writer that drops them produces something no shipped dialogue resembles.
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 is 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, where DLG’s loader doesn’t reference the string at all.IsChildandLinkComment: set-equal, the strongest correlation in this document. See below.
IsChild and LinkComment are set-equal
LinkComment is present in exactly the dialogues where IsChild is non-zero, checked in both directions, with no dialogue carrying one without the other, in every population and whether counted per file or per distinct resref.
The non-zero part is load-bearing. IsChild is present far more often than it is non-zero, and the several hundred dialogues carrying an IsChild of 0 carry no LinkComment either. Stated against presence, the correlation would be false.
It suggests the two are one editor-side subsystem rather than independent fields. That cannot be confirmed or refuted from the compiled engine, because the loader never parses either field on any struct: not the node, not the link entry, nowhere. Whatever relationship they have belongs to the original toolset’s authoring format, so settling it would mean finding that toolset rather than reading more of the game.
None of the five is modelled by rakata_generics::Dlg, which is the projection rule working as intended: a typed view carries what the engine reads at that path, so a field with no reader has nothing to project. The consequence for tooling is that a Rakata round trip drops all five.
So: is it safe to drop them? For the engine, yes, without qualification. These are not fixed-position bytes and nothing reads them, so a file without them loads identically to one with them. That is the question this page opened by naming, and it has an answer.
For anything else, no. Dropping them produces a diff against vanilla on every dialogue in the game, which makes real changes harder to spot in a review and harder to find in a bug report. NumWords and Comment are on every shipped dialogue without exception, so a file lacking them resembles nothing BioWare emitted. And an authoring tool that reads them, which is what they exist for, loses whatever its user put there.
The recommendation is therefore to preserve them on round trip and not to invent them on creation. Rakata does the second and not the first, which is a limitation of the typed view rather than a judgement that the fields do not matter.
UTC: Tail and Wings
ReadStatsFromGff carries no read for either label. It assigns 0 to both members flat and unconditionally, overwriting whatever the object held and whatever the file said. Both the blueprint path and the save-instance path call that same function, and nothing in it distinguishes its callers, so this is not a template-only or save-only behaviour.
The strong claim here comes from the overwrite rather than from the absence of a reader. Even if some other subsystem read a creature’s tail or wings, it would read the 0 the loader just wrote. The file’s value cannot reach anything, whatever else in the binary touches those members.
SaveStats writes both on every save regardless, so a save file’s tail and wings are discarded the moment they load back.
Safe to drop? For the engine, yes. Dropping them on a .utc blueprint costs nothing at all, since the loader was going to zero them anyway.
For a save writer aiming to match what the engine emits, write them as 0, because the engine does. This is the one place the two answers come apart: “the engine ignores it” and “vanilla omits it” are different claims, and here only the first holds.
UTW: TemplateResRef
LoadWaypoint never reads TemplateResRef, not even as a discarded read that advances past the value. LoadFromTemplate is the only other candidate and it matters only for a waypoint a script spawns at runtime, where the resref arrives as the script’s own CreateObject() argument rather than from the field. The label is never looked up anywhere in waypoint loading.
A corpus pass over a full install found it on every waypoint. It is what the toolset writes each time and the engine reads never, which makes “legacy padding” too generous a description.
Safe to drop? For the engine, yes. Waypoints resolve no template at all, so unlike a door or a placeable there is not even a blueprint the value could have pointed at, and nothing downstream loses a fallback.
Against vanilla, dropping it puts a diff on every waypoint in the game. Preserve it on round trip; there is nothing to invent it from on creation, since no blueprint exists to name.
GIT: Tag on a templated placement
This is the page’s one conditional entry, and the condition is what makes it useful. Every templated GIT object type reads Tag unconditionally from whichever struct the loader is handed: the placed instance’s own struct on a direct load, and the blueprint’s top-level struct on a template load. The area-level dispatcher’s post-template overlay covers position, orientation and geometry, and never Tag.
So a placement’s own Tag is dead exactly when that placement is templated, and live when it is not. Confirmed across Placeable, Trigger, Sound, Store, Encounter, Creature and Item as well as Door, so it is general engine behaviour rather than a door-specific gap. See GIT.
Every door placement in a full install carries a Tag holding a real, non-empty value. It is not stale padding on a few assets.
Safe to drop? This one you should keep, and it has the highest cost of dropping on this page.
The instance Tag is the only per-placement identifier anything outside the engine has. A GIT full of placements distinguished solely by position is far worse to diff, to review and to write a lint rule against. Worse, because the engine takes the blueprint’s Tag instead, two placements sharing one blueprint end up sharing one tag at runtime, with no engine-side protection anywhere; vanilla avoids that collision purely by authoring one blueprint per placed instance. A tool that drops the instance Tag removes the only record of what the author meant each placement to be.
Related
- DLG Format for the rest of the dialogue format, including the fields the engine very much does read.
- Testing for why “no vanilla file exercises this” is a statement about the corpus and not about the engine.
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.
Where a changed struct ID is not cosmetic
In most lists a struct ID is a position or a constant, so a remap shows up as
entries in the wrong order and someone notices. Equip_ItemList is the one
worth knowing about: a creature’s equipment slot is the list element’s
struct ID, read off the element header, and it is recorded nowhere else. See
UTC.
So a remap there reorders nothing. It moves items between slots, with every field of every entry intact and no missing data to notice. A robe can come back equipped in the creature hide slot.
The same reasoning applies to any list whose struct ID carries meaning rather than position, which is why a writer getting this right is not only about ordering.
And the damage takes two shapes, not one. Where a loader uses the ID, a remap
moves the element somewhere it does not belong, as above. Where a loader
validates it, the element is dropped instead: a GIT
Creature List element whose ID is not the value that list expects is skipped
silently, so the creature is not in the area and nothing says why. Both come
from the same writer defect and they look nothing alike from the outside.
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 such as the 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). They are adjacent, they are both u32, and they point into different files. Conflating them loses the MDX offset on read and overwrites the content pointer on write; see what this means for mdx_data_offset.
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 | Bit flag; all eight values are attested. See the format page. |
| +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[32], null-terminated. Drives recursive model load. |
| +0xA8 | node array (secondary) | Relocated if non-zero. |
| +0xAC | GL vertex pool handle | RequestPool writes it here, over the file’s source offset. |
| +0xB0 | MDX data size | Size of the vertex-pool copy. Read twice by Reset. |
| +0xB4 | unread | Neither Reset nor ResetLite touches it. |
| +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. It is a bit flag rather than a small enumeration, which is why the values jump; a sweep of a retail install attests all eight defined bits. The set and its counts are on the format page. -
+0x88 supermodel name is a 32-byte ASCII name occupying
+0x88..+0xA8. Loading a model with a supermodel triggers a recursiveFindModelcall 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 32-byte width is settled by corpus reasoning alone, with no decompilation, and the method transfers to any field whose length is in doubt. The competing reading is
char[36], which runs to+0xACand swallows the word at+0xA8. The longest supermodel name in a retail install is 15 characters, so under that reading those four bytes fall inside the string and have to be NUL in every model. They are non-zero in every model, and they hold a valid content offset that matches the geometry root pointer in the large majority of files. A 32-byte name followed by a separate pointer field is the only reading that survives.Every vanilla model writes this field, using the literal string
NULLfor “no supermodel” rather than leaving it empty, so an empty name is not the sentinel a reader should test for.
Note
The table above is the loaded struct, not the file.
Resetrelocates pointers and populates fields that carry nothing on disk, so several offsets legitimately read differently in the two forms,+0x4Cholds a plain2in the file andGetType() | 0x80in memory, and+0x00,+0x04and+0x48are meaningless until load. The on-disk map lives incrates/rakata-formats/src/mdl/mod.rs. Comparing the two without tracking which moment each describes invents contradictions.
+0xACis the clearest case on this page. Its file value is the offset the MDX copy starts from, zero in every model indexed by a retailchitin.key, andResetoverwrites that same word with the GL pool handle a few instructions later. A file can only ever show you the first meaning.The radius “default 7.0” is a distribution rather than an observed default: about four models in five carry 7.0, nearly all the rest carry 40.0, and exactly one carries 13000.0.
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. On an animation node this is the key matching a keyframe node to its geometry-side skeleton bone. |
| +0x04 | u16 | name_index | Index into the model’s name table. See below. |
| +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). |
Warning
+0x04is the name index, not a second copy ofnode_idA small sample makes the duplicate reading tempting: across four models the two fields agree on every node. Widening to every node in every model indexed by a retailchitin.key, they disagree in roughly one node in six, so a clean small sample is luck rather than structure.What settles it is not that
+0x04looks like a name index but that it behaves as one without exception: it is always less than the model’sname_count, and it resolves through the name-offsets array to a NUL-terminated string in every node measured. Those two tests are weak on their own, since the always-zero+0x06would pass them too; it is the divergence fromnode_idthat rules the duplicate reading out.Getting this pair backwards is visible rather than subtle. Reading
node_idfrom+0x04gives every animation node the same value, so every keyframe targets the root bone and characters freeze in T-pose with no skeletal motion at all.
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, being inline values rather than 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).
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 still an exact value match rather than individual bit checks. The bitmask structure is meaningful, skin being a superset of trimesh, but it is 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. It is also unattested: counted across every node in every model indexed by a retail chitin.key, it has zero instances, which makes it the third such type beside camera (0x009) and anim mesh (0x0A1).
Light (0x003)
Lights carry 92 bytes of extra data, spanning extra offsets +0x00 to +0x5B. The five array fields below are the complex part and account for +0x04 through +0x3F.
That leaves +0x00-+0x03 and +0x40-+0x5B. The tail has names; the head does not, and the two ends of this record are in different states.
+0x40-+0x5B is seven scalar fields, and none of them was ever unknown: priority, ambient_only, num_dynamic_types, affect_dynamic, shadow, generateflare and fading_light, in that order, ending exactly on the record’s total size. InputBinary::ResetLight (0x004a05e0) touches none of them, which is what you would expect of seven scalars in a pass that exists to fix up pointers. priority has a live consumer corroborating the name and the role: Gob::SetLightPriority (0x00445e30) writes a priority onto the runtime light a Light node spawns.
Level: the fields’ names, order and untouched-by-relocation status are traced. That these particular names line up with this page’s +0x40 is inferred, from the record size and the field widths rather than from a byte-level check against a real model.
Warning
+0x00-+0x03is a live contradiction, and the page states it rather than resolving it Two readings, and they cannot both be right.The span is unnamed. That is what this page’s own accounting says: the table’s first entry is at
+0x04, so four bytes sit ahead of everything documented.The span is
flareradius. Applying the same offset arithmetic that lines the seven tail fields up correctly puts a plainly-namedfloathere, which would mean the span is not a gap at all and this page’s table simply starts one field late.The arithmetic that succeeds at one end of the record therefore fails at the other, and nothing available settles which end is misaligned. What would settle it is this page’s own zero-point convention stated explicitly, or a byte-level diff against a real
.mdl. Until then, a writer should treat these four bytes as carrying something rather than as free space, since one of the two readings says they hold a radius.A further pass over the struct layouts went looking for the missing piece and did not find it. The contradiction stands exactly as stated above, unresolved rather than narrowed.
These offsets are the same in the file and in memory, since the node is copied whole and then relocated. What differs between the two forms is the contents of the pointer words, not where they sit:
| 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). Those live in the base node’s controller arrays rather than 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.
Across the 332-byte extra header, every field is either confirmed through Ghidra cross-referencing or confirmed unused, apart from two. indices_per_face is “very likely”, being always 3, and the four bytes at +0x140 are unresolved: the constructor zeroes them and no known function touches them.
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 | -1 in every mesh of every model in chitin.key |
Note
Vertex colour alpha is unused (confirmed 2026-04-04).
LightPartTriMeshreads only bytes0,1and2(RGB). Byte3is 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, spanning extra offsets +0x00 to +0x63. Skinning data sits here: bone weights, inverse-bind-pose rotation and translation, and bone-index mapping.
| 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 table accounts for +0x00-+0x0B, +0x14-+0x1B and +0x1C-+0x3F. The remaining forty-four bytes are two spans, and both are now accounted for as well.
+0x0C-+0x13 is two consecutive four-byte fields, sitting immediately after the vertex-weights array and immediately before a pointer-and-count pair that the loader does relocate. InputBinary::ResetSkin (0x004a01b0) does not touch either of them: not read, not relocated, not tested.
+0x40-+0x63 is the struct’s final nine fields, and they are unnamed in the recovered layout itself rather than merely undocumented here. ResetSkin leaves all nine alone too.
Note
Neither span is padding, and calling it that would be an assumption rather than a shorthand What is established is narrower and worth keeping narrow: the one function whose whole job is walking every relocatable and counted field in a Skin node has no use for either span. That is a strong negative about the relocation pass and says nothing about the renderer or the skeletal deformation code, which were not chased.
Padding and a field this page has not met produce identical bytes in a file and different obligations for a writer, so the distinction is kept until something settles it.
One positive reason to call it padding was looked for and was not there.
MdlNodeSkintotals 512 bytes, and a round power of two invites the reading that the struct was sized to a pool allocator rather than packed from fields, which would make the untouched spans deliberate slack. The sibling node structs refute it:PartLightsaberis 432 bytes andPartEmitteris 504, neither of them round. So 512 is a coincidence of this one struct, not a sign of a size policy, and the padding reading gains nothing from it. Recorded as a checked negative rather than dropped, because the next person to notice 512 will have the same idea.
The weights array deserves a call-out. A 52-byte SkinVertexWeight struct exists and is fully specified by the ASCII parser, carrying four bone names, four weights and some metadata. In the binary path ResetSkin never relocates its pointer, and a scan of every skin node in the vanilla corpus found zero non-empty weights arrays. Binary models store per-vertex bone data exclusively in MDX, through dedicated bone-weight and bone-index offsets, and the weights CExoArray is 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, so 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. That 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.
That is an exact identity rather than a resemblance, and the two sides were established separately. The controller codes came from the registration site; Part’s field offsets came from recovering the struct layout, without reference to the controller work. Part is 76 bytes: a vtable, a back-pointer to the source node, position at +8, orientation at +0x14, scale at +0x24, a mesh flag, a children list, a parent pointer, an attached object pointer, and a material pointer at +68. The three transform codes are 8, 0x14 and 0x24. Those are not near the field offsets, they are the field offsets.
PartLight carries it past the base struct. It extends Part’s 76 bytes, so its first field of its own sits at absolute offset 76, and Light’s color controller code is 0x4C, which is 76. So the rule survives the step from the base struct to a derived one, which is the step where a coincidence would have broken.
Two families is the extent of it. Both sides were independently traced for the base transforms against Part and for the light colour against PartLight, and nothing here establishes the mapping for the codes those two families do not cover.
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.
Warning
Do not use the raw byte as a float count. The low nibble coincides with the floats-per-row for the simple cases (
1,3,4), so a reader that takes the byte at face value works on ordinary position, orientation and scale tracks and then fails on two shapes that are common in vanilla content.A Bezier controller sets
0x10, so a raw3arrives as0x13. Read as a count that is 19 columns rather than 9. An integral-orientation controller carries raw2, which means one packedu32per row rather than two floats.Both failures desynchronize the shared float pool: consuming the wrong number of values for one controller shifts every controller after it in the same node, so animation past the first affected keyframe reads from a window of unrelated data. Decode with
& 0x0Fplus the two special cases, and keep the raw byte in a field of its own,raw_column_count, if you intend to write the file back.
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 matters because vanilla K1 contains controller type codes (0x68, 0x188) documented in no community reference. A closed enum fails on roughly one vanilla model in six. MdlControllerType is therefore a newtype struct MdlControllerType(u32) with named constants for the three universally-confirmed base types (POSITION = 8, ORIENTATION = 20, SCALE = 36), accepting 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 |
All three base codes also support a Bezier variant, signalled by the flag bit rather than by a separate type code.
Everything above 36 is type-specific, and the rest of this section is the vocabulary.
A code does not name a controller. A code plus a node type does.
Type-specific codes are not partitioned into ranges by node type. They overlap. The same number carries a different name depending on which node owns it, so a decoder that maps code to name without knowing the node type is wrong for five of them:
| Code | On a mesh | On a light | On an emitter |
|---|---|---|---|
| 88 | radius | birthrate | |
| 96 | shadowradius | combinetime | |
| 100 | selfillumcolor | verticaldisplacement | drag |
| 132 | alpha | p2p_bezier3 | |
| 140 | multiplier | randvel |
Code 100 is the one to watch, carrying three meanings. The engine has no ambiguity here because it reaches these tables through the node type’s own parser, so the type is already known before a code is looked up. A reader walking a file has the same information and has to use it.
Mesh controllers
Applies to every mesh-family node. Skin nodes tail-call the mesh parser rather than registering a vocabulary of their own, so trimesh, skin, dangly, AABB, saber and animmesh all share these.
| Code | ASCII name |
|---|---|
| 100 | selfillumcolor |
| 132 | alpha |
Light controllers
| Code | ASCII name |
|---|---|
| 76 | color |
| 88 | radius |
| 96 | shadowradius |
| 100 | verticaldisplacement |
| 140 | multiplier |
Emitter controllers
The largest vocabulary by a wide margin. Names are given in the engine’s own capitalisation; matching is case-insensitive, so lightningZigzag and lightningzigzag both parse, but the mixed-case form is what the engine stores and what a writer aiming at canonical output should emit.
| Code | ASCII name | Code | ASCII name |
|---|---|---|---|
| 80 | alphaEnd | 172 | xsize |
| 84 | alphaStart | 176 | ysize |
| 88 | birthrate | 180 | blurlength |
| 92 | bounce_co | 184 | lightningDelay |
| 96 | combinetime | 188 | lightningRadius |
| 100 | drag | 192 | lightningScale |
| 104 | fps | 196 | lightningSubDiv |
| 108 | frameEnd | 200 | lightningZigzag |
| 112 | frameStart | 216 | alphaMid |
| 116 | grav | 220 | percentStart |
| 120 | lifeExp | 224 | percentMid |
| 124 | mass | 228 | percentEnd |
| 128 | p2p_bezier2 | 232 | sizeMid |
| 132 | p2p_bezier3 | 236 | sizeMid_y |
| 136 | particleRot | 240 | m_fRandomBirthRate |
| 140 | randvel | 252 | targetsize |
| 144 | sizeStart | 256 | numcontrolpts |
| 148 | sizeEnd | 260 | controlptradius |
| 152 | sizeStart_y | 264 | controlptdelay |
| 156 | sizeEnd_y | 268 | tangentspread |
| 160 | spread | 272 | tangentlength |
| 164 | threshold | 284 | colorMid |
| 168 | velocity | 380 | colorEnd |
| 392 | colorStart | ||
| 502 | detonate |
Read left column then right; the numbering is continuous across the two.
detonate at 502 is the one with teeth: see the footgun above, since it crashes on any emitter that is not an "Explosion".
The whole vocabulary is traced, from four functions covering the base node, meshes, lights and emitters. The set is closed rather than sampled: every call site of the three controller-registration routines in the binary falls inside those four, so no other node type has a vocabulary of its own. Addresses are recorded per table in mdl/ascii_names.rs.
The MDX file, and who reads it
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.
InputBinary::Reset uploads the whole buffer to OpenGL, and nothing after that point reads it again.
Level: traced, through InputBinary::Read (0x004a1260) and InputBinary::Reset (0x004a1030), which has exactly one call site.
- Read the MDX file into a buffer.
- Call
Reset(mdl_content, mdx_content, resource). Resetrequests a GL pool sized by the model header’s+0xB0, locks it,memcpys+0xB0bytes out of the MDX buffer starting at the offset in+0xAC, unlocks it, and registers the pool handle for later freeing. The upload is inlined here rather than delegated, sitting between the supermodel resolution and the node-tree recursion.Resetthen passes the MDX pointer down the rest of the chain (ResetMdlNode,ResetTriMeshParts, …), and no function below it reads through the pointer.ResetTriMeshPartsoverwrites its copy to reuse the register as a loop counter.- Back in
InputBinary::Read, the CPU-side buffer is freed, the pool having already taken a copy.
That the downstream chain never dereferences the pointer is what the upload predicts: nothing on the CPU side has to look inside a block already sitting in GL memory. Three findings elsewhere on the page describe the same operation from other angles:
- The model header’s
+0xACand+0xB0are the copy’s source and length, which is what step 3 consumes them as. See the field map for what+0xACbecomes afterwards. - Skinned models have nowhere else to get bone weights. The skin section establishes that the
weightsarray is empty in every vanilla model and that binary models carry per-vertex bone data exclusively in MDX. A skinned character deforms because the data arrives this way. - The per-attribute slots are offsets into an MDX vertex record, and their evidence column cites
LightPartTriMeshandPartTriMeshreading position, normal, colour and UV at those offsets. Those are the attribute offsets the uploaded block is read by.
InputBinary::ResetLite (0x004a11b0) is a second, lighter reset path that skips the vertex pool entirely: it calls FindModel and MaxTree::ResetFreeLists, then recurses ResetMdlNodeLite and UpdateAnimFootprint over the animation array and node tree, with no pool request anywhere in it.
So MDX is load-bearing, and a writer must get it right. An all-zero or malformed MDX is not a file the engine tolerates by ignoring.
Where the engine gets vertex data on the paths that were traced: InternalGenVertices builds vertex buffers from verts_arrays, which lives in the MDL content blob, and ProcessVerts recomputes normals from geometry. There is a parallel set of position-only arrays inside the MDL content blob, pointed to by vert_array_offset at mesh +0x148, with additional UV, colour and normal data in the MdlNodeTriMeshVertArrays structures. How those relate to the MDX upload, whether as a fallback or as data the post-process stage supersedes, is not traced.
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. Our own reader treats MDX as the source of vertex data, which is why read_mdl takes it as a parameter.
Per-mesh terminators and alignment
Empirically, vanilla MDX files are larger than sum(vertex_count × stride). Nearly every model in the corpus has an MDX with excess bytes.
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 |
A corpus sweep finds only those two sentinel values and no unknown pattern, with the non-skin form vastly the more common of the two.
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 a DFS traversal of the tree, mostly. About a quarter of vanilla models show a compiler-specific permutation that defers the “second children” of paired parents until after all their siblings’ first children. That is reproducible for our own output, since writing DFS means reading DFS, but not for a byte-identical round trip of every BioWare file.
Writing in standard DFS order, non-skin first and skin second, produces semantically identical MDX data with the correct total size. Most vanilla models then match byte-for-byte, and the rest differ only in that 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.
A reader should seek to mdx_data_offset per mesh rather than tracking a
cumulative cursor. A cursor assumes the non-skin-first DFS ordering, and a
substantial fraction of vanilla models do not match it, so vertex data lands on
the wrong mesh nodes. Seeking per mesh is also what kotorblender and mdledit do.
The two figures recorded for that fraction disagree; see
MDX per-mesh seeking.
Note that a self-round-trip cannot catch this. Writing and reading the same wrong assumption agrees with itself, which checks the tool against its own output rather than against vanilla. The cumulative-cursor logic is still correct in a writer, which produces its own layout and backpatches the offset field; it is the reader that has to trust what the file says.
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.
The stride is 32 bytes, not the 12 that the vertex indices alone would suggest. Reading 12 puts every third face or so on garbage belonging to the next face’s plane normal. A synthetic round-trip will not catch it, because writing and reading the same wrong stride agrees with itself. What catches it is bounds-checking vertex indices against the mesh’s own vertex count on a vanilla file.
One vanilla model, w_dblsbr_001, carries NaN in a face’s plane_normal and
plane_distance, because one of its faces is degenerate. Any comparison over
this field has to be bitwise, since NaN does not equal itself.
Open questions
- The four bytes at mesh +0x140. The constructor zeroes them and no known function touches them. Every other field in the mesh extra-header is either confirmed through Ghidra cross-referencing or confirmed unused; this one is neither.
- The compiler’s MDX traversal. About a quarter of vanilla models defer the “second children” of paired parents until after all their siblings’ first children. The pattern is described but not derived, so it cannot be reproduced from a node tree.
- Model header
+0xAC. Zero in every model indexed by a retailchitin.key, which is consistent with a runtime-populated field but means no file can confirm what it holds. - Controller code families beyond the base transforms and light colour. The
identity between a controller’s
type_codeand its target field offset in thePartstruct was traced for those two families only. Nothing here establishes it for the rest.
War stories and implementation history
A chronicle of the bugs found while building the Rust reader and writer, because the “how we know this” is often as useful as the “what we know”. Each of these cost real time, and most of them looked like something else first.
The 12-byte face bug
The MaxFace stride is 32 bytes, not the 12 the vertex indices alone suggest.
Reading 12 put every third face or so on garbage belonging to the next face’s
plane normal.
Synthetic round-trip tests masked it completely. Write wrong, read wrong, match. It only surfaced when vanilla-file validation found vertex indices exceeding the mesh’s own vertex count.
Mesh header size corrections
The 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) and 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 vanilla models whose 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 rather than 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 to 80 bytes shorter. This is a known, benign size delta rather than 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, with no additions
and 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, 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, because floats containing NaN
don’t equal themselves. Fixed with bitwise
f32::to_bits()comparison. - Parent index ordering: 135 mismatches from depth-first versus original node ordering. The binary format preserves node ordering but our parent-index reconstruction uses DFS. Semantically equivalent, numerically different, so skipped in comparison.
- Face NaN values: exactly one model (
w_dblsbr_001) has NaN in its pre-computed plane_normal and 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: most MDX files match byte-for-byte, and the rest show 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 | 0x00469700 |
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.
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 so the load menu can read them straight from the slot, with no archive opened: a name, area, play time and thumbnail for every save. SAVEGAME.sav grows with every module you visit and runs to megabytes late in a game, while savenfo.res stays a few hundred bytes, so listing a folder of saves costs the same whenever you do 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 the IncludeInSave column of modulesave.2da, finding the row by the module’s name as a string, and excludes a module only when that row exists and holds 0.
Fail-open holds on every failure path, traced through CServerExoAppInternal::IncludeModuleInSave (0x004b2121). A table that will not allocate, a load that fails, and a row or column that does not resolve all fall through to including 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. It uses the same Mod_PlayerList container as a module roster, and its entries are not the same shape. Both hold the same creature serialization under struct id 48813, but SavePlayers wraps a module-roster entry in the header fields that StorePlayerCharacters never writes. A pifo entry is the bare snapshot.
Each player is stamped with the roster slot it was written to, on the live object rather than in the file. Nothing in a pifo entry records it, so the stamp does not outlive the session that made it.
The sentinel index 0xffffffff is what selects pifo. LoadCharacterFromIFO normally reads a slot out of the module’s own Module IFO; that sentinel switches it to pifo. Its one call site is LoadCharacterStart, and the module-roster path passes a real slot index instead. The branch is reached only when the player object already carries a slot from an earlier write, which is why an ordinary manual load never takes it, and why a manual save folder carries no pifo.ifo to read.
So 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.
Do not rely on a Player.bic being present. The engine can write the primary player as a standalone Player file in the BIC character format, a SaveCreature snapshot behind a small header written by SavePrimaryPlayerInfo, but that path is gated behind a global flag and does not run for ordinary single-player saves.
There is no matching .bic reader. On load the primary player comes from the module roster: LoadPrimaryPlayer takes the module’s primary-player index and hands off to LoadCharacterFinish. Any Player record is consumed through the same Mod_PlayerList creature-load path as a roster member, keyed by ObjectId.
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.
Neither side bounds how many items it holds. The writer walks the repository’s own item array on a full-width index and the reader walks the GFF list count the same way, with no ceiling compared against in either loop. That is a different shape from the party-table counts nearby, which cap at 255 because the count is stored as a byte; an inventory’s count comes from the GFF list header and is never narrowed, so there is no width to run out of. A save carrying exactly 255 items is a coincidence of that player’s stash rather than the engine stopping there.
Gold and the party pool
Party wealth is one number, PT_GOLD in PARTYTABLE.res, but every creature’s save block still carries its own Gold field. The in-party flag that SetInParty maintains is what keeps the two from fighting, and it is used on both sides of the cycle.
Writing. SaveCreature briefly clears the flag around its call into SaveStats. The gold accessors read that flag to decide whether “this creature’s gold” means a private ledger or the shared pool, so clearing it captures the member’s frozen personal value rather than the live party total.
Reading. ReadStatsFromGff uses the same flag as a gate: a creature currently in the party skips its Gold field entirely. Without that, each member’s stale snapshot would clobber PT_GOLD as the blocks loaded one after another. The pool is authoritative and the per-member values ride along.
A creature that was never in the party is unaffected. 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 gold is a one-shot amount
AVAILNPCn snapshots land in that same clear-flag case, by a route worth following. GetNPCObject constructs a fresh CSWSCreature, whose flag starts clear like any new object, and loads the companion’s GFF through LoadFromTemplate before anything sets it. So a rejoining companion’s Gold reads back normally rather than being skipped.
It does not stay personal. AddMember calls TransferInventory on the freshly spawned creature, folding that just-loaded Gold into PT_GOLD and moving their items into the party stash, before the flag is set to 1. First-time recruitment goes the same way through AddNPC, so a companion’s starting gold from their .utc template enters the pool the moment they are recruited.
The stray PC file: a party-leader-swap artifact, not a save artifact
A save can carry a bare PC resource: a UTC-shaped GFF in the same raw format as an AVAILNPCn snapshot. It is a transient swap buffer, not a save mechanism. CSWPartyTable::SwitchPlayerCharacter writes it to GAMEINPROGRESS:PC when module content hands control away from the born player character, and reads it back when control returns.
It reaches SAVEGAME.sav by accident. StallEventSaveGame and DoPCAutosave both import the entire working directory into the ERF unconditionally, sweeping up whatever is sitting there, and nothing clears GAMEINPROGRESS: between saves. The directory is wiped only at session teardown, in CServerExoAppInternal::StopServices, when you quit to the menu or load a different game.
So once a leader swap has fired anywhere in a session, PC is archived into every save taken afterward, quicksave, manual and autosave alike.
That rules it out as a save-type discriminator. Its presence tracks session history rather than which writer produced the file. It is also unrelated to the pifo roster-staging file and the gated Player BIC record above; all three reuse the SaveCreature serializer for otherwise unconnected purposes.
Building and reading SAVEGAME.sav
At save time the engine imports the entire GAMEINPROGRESS: working directory into one ERF, through StallEventSaveGame and CERFFile::ImportFiles. The per-module ERFs already live there, so they land inside SAVEGAME.sav as nested resources keyed by module resref under resource type 2057 (sav).
The flat session resources that accumulate in the same directory come along with them: the REPUTE faction table, the AVAILNPC companion snapshots, and the party INVENTORY, which is stored under the generic resource type 0 and so has to be looked up by name.
That import is a blanket sweep, not a curated list, so an incidental leftover like the PC file above rides along too. Treat those three named resources as what you can rely on rather than as a closed inventory of what a SAVEGAME.sav contains.
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 is not type-keyed the way it sounds:
- A file that fails to open gets a flat 2.5 MB stand-in, whatever its type, rather than being skipped.
- Once every file is summed, a further flat 2.5 MB is added, but only when the module currently loading is neither
RSVnorSAV. Loading from either is exempt from that top-up. - An empty
GAMEINPROGRESSskips both and uses a fixed 3.75 MB baseline. - The total is then scaled up by roughly 11% before being stored.
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), and a staged one is indistinguishable from an engine-written one once it is in there.
So .rsv is the working-directory format and sav is the archive type. A tool reading SAVEGAME.sav never meets the extension. A tool reading GAMEINPROGRESS directly, to inspect a session with no committed save, must look for <module>.rsv: same content, different extension and resource type.
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.
The autosave writer places that pifo.ifo itself, and pifo’s presence is as reliable a tell as PCAUTOSAVE is. The scratch copy at TEMP:pifo is written on every module exit, whatever kind of save follows, so its existence says nothing. What separates the two writers is that DoPCAutosave copies it into the save folder and StallEventSaveGame never touches it. No filter is involved: TEMP: is a separate resource alias from the GAMEINPROGRESS: directory the writers sweep, so the scratch file was never a candidate for the archive.
The scratch copy is consumed on module entry and not deleted. The reader adds TEMP: to the search path, reads the roster out, and removes it again, leaving the file to be overwritten by the next exit. So a manual save taken immediately after a transition still has no pifo.ifo beside it, not because the scratch file is gone but because its writer never copies one.
For a tool that means a folder where pifo’s presence and PCAUTOSAVE disagree has been rearranged by something other than the game. Both come from the same writer, so they cannot legitimately come apart.
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.
The reserved numbers are hardcoded literals rather than anything derived from scanning folders, and the autosave loader, the save-menu filter and the quickload finder each compare a parsed slot number against them independently. The set of names that can reach the 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 the periodic-autosave branch in CServerExoAppInternal::MainLoop, 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 it, and nothing in the traced call graph reserves a number below 2.
Note
REBOOTAUTOSAVEis not a folder name and never reaches the slot-name formatter. It is a boolean byte insidesavenfo.res, besidePCAUTOSAVE, read unconditionally by the load-menu’s slot parser. That read is its only cross-reference in the binary.Nothing writes it.
DoPCAutosavesetsPCAUTOSAVEalone. The read path is live: the save-list preview checks this bit alongsidePCAUTOSAVEwhen deciding where to pull a slot’s screenshot from. But with no producer on PC 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 parsed tolerantly here, the same shape as the
LIVE%dXbox content mounts. (Provenance: inferred.)
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 the gap search also comes up empty, with 2 through 999 all occupied, 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.
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”, read 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. It callsCERFFile::ReadHeaderVarianceand thenExportFilesFromERF, which writes one file per archive entry, taking each filename from the entry’s key and its bytes from the resource at the same table index. See what a writer must preserve.- 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.
No checked validation can reach that branch. None of the ordinary return-code paths in the ERF-reading chain signals failure for a missing, truncated or garbled file. That chain runs CERFFile::Read, ReadHeaderVariance and ExportFilesFromERF down to the raw read and write calls; they tolerate short reads with a retry-and-log, and CopyGameToFutureGame has a single return path, an unconditional success.
So the marker can only be written if a genuine exception unwinds out of the copy, and the functions in this chain do install real C++ exception handling.
(Provenance: inferred.) The likeliest trigger, going by what the code does with the data, is a corrupted archive header’s entry or language counts driving an allocation or a read past what is there. There is no explicit corruption check anywhere in this path to name as the validation.
How the load menu treats a corrupt slot
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: no code deletes CORRUPT.res specifically. 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.
Key Ghidra addresses
Function addresses in swkotor.exe (K1 GOG build), for anyone continuing this archaeology. Where a name appears twice the rows say why.
| 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 |
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 |
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; 0x004f7a10 appears elsewhere in this table under the same name. 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 |
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 |
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 |
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 |
CSWGuiSaveLoadEntry::GetGameDirectory | 0x006c8250 |
CServerExoApp::SaveGame (thin wrapper over CServerExoAppInternal::SaveGame, dispatched from network message handlers) | 0x004ae6e0 |
HandlePlayerToServerModuleMessage / HandleServerAdminToServerMessage | 0x00524800 / 0x00528380 |
CSWGuiSaveLoad::DeleteGame | 0x006caa90 |
ExecuteCommandSwitchPlayerCharacter (nwscript action dispatch, sole caller of SwitchPlayerCharacter) | 0x00544910 |
CSWPartyTable::GetFilename (computes AVAILNPC%d) | 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; CSWSModule::LoadModule at 0x004b95b0 appears elsewhere in this table under that different class-qualified name. Likely a caller/callee pair rather than a conflict, not reconciled) | 0x004b98e0 |
CExoBaseInternal::CreateResourceExtensionTable | 0x005e6d20 |
CExoBaseInternal::GetResTypeFromExtension | 0x005e7a40 |
CExoBase::GetResTypeFromExtension (thin forwarder to the Internal version above) | 0x005e6670 |
CServerExoAppInternal::SetEstimatedSaveSize | 0x004b5f90 |
CExoResMan::GetResTypeFromFile | 0x00406650 |
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). Provenance: derived, not attested, so these rows sit on the reverse-engineering queue.)
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, with 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 is three distinct object shapes sharing a common base, plus movement fields that only make sense on the vehicle the player actually drives.
There is a fourth child, and the trace above does not cover it. Mouse sits on the same MiniGame struct alongside those three, carrying four axis settings: AxisX, AxisY, FlipAxisX and FlipAxisY. It turns up in a single vanilla minigame area. What reads it, if anything, has not been established: the three reads above are what CSWMiniGame::Load was traced doing, not an inventory of everything the struct holds.
Treat an absent Mouse as an open question rather than an empty one. A missing nested struct is a different fact from a missing scalar: a scalar’s absence resolves to whatever the engine substitutes, while a struct’s absence says the thing is not there at all. Filling one in with zeroes would invent structure the file never carried.
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 rather than 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, which is 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. 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, so 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 is the field an inventory of this struct is most likely to miss, and the reason transfers to any field survey. A scan keyed on bare labels rather than paths cannot tell it apart from the ARE root’s own OnHeartbeat, which a typed view already models, so the nested one reads as covered and never surfaces. Every vanilla player carries a real script name in it. Path-keyed scans do not have that blind spot. Its membership in the base set is confirmed against the decompiled function rather than inferred from label counts.
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 is 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 behaviour 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 it anywhere. All 53 fields in this struct are 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, with 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 in any area file carrying a minigame, 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).
Note
“Four scanned area files” was the whole population, not a sample. Exactly four of the area files in the module archives carry a
MiniGamestruct at all, and no area in a save corpus carries one. So the thirteen were checked against every minigame that ships rather than against a handful of them, and there is no wider set left to widen to. 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.