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 |