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 |