Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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

Field Schema

The format’s field families, as an orientation before the full list.

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

FunctionSizeBehaviour
LoadPlaceable5092 BThe 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 RuleRuntime Behaviour
Appearance TruncationThe 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 ChainingAs with doors, a placeable marked Static=1 behaves as though Plot=1, so it cannot be destroyed whatever HP it carries.
Default Usability CheckAn absent Static is derived from Useable: the placeable is static when it is not useable.
Portrait ShadowingWhere 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 ForcingGroundPile is read and the result discarded, and the field is forced to 1 in memory. Setting it in a file changes nothing.
Missing Door HooksToolsets expose OnFailToOpen for placeables, but it belongs to .utd doors. LoadPlaceable never reads it.
Trap Hook FallbackIf 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 LevelA placeable’s own save routine never skips itself, no matter its corpse state. The “skip corpses” behaviour actually lives one level up, in the area’s own placeable-list saver, which omits the entire list entry for any placeable flagged as a corpse before the placeable’s save routine is ever invoked. Doors have no equivalent skip: every tracked door is always written to the save.
Empty Inventory OmissionItemList is only added to the written struct when the placeable’s item repository holds at least one item. An empty inventory produces no ItemList field at all, not even an empty one.

HasInventory and DieWhenEmpty Are Cross-Wired

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.

FieldConstructedAbsent resolves to
HP (maximum), CurrentHP10
TrapDetectable, TrapDisarmable, TrapOneShot10

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.

OpenAnimationAnimationStateAnimation state applied
non-zeronot readnot readthe sentinel 10075
0presentnot reachedthe raw value, unvalidated
0absentpresentindexed into six preset sentinels; above 5 collapses to 10000
0absentabsentnone: 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.2da columns differ by object type A door or placeable reads its default trap script from MineScript, where a trigger reads TrapScript. Doors and placeables also take their disarm and detect DCs from their own GFF fields rather than from the table’s DisarmDCMod and DetectDCMod, 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 EquippedRes nor InventoryRes, it falls through to LoadDataFromGff’s unconditional hardcoded 0.
  • 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 to true.

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 TypeExplanation
Legacy Engine ArtifactsPlaceable 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 BinaryIsComputer appears in a handful of .utp files, always 0. Its field-name string doesn’t exist anywhere in swkotor.exe, so no code path can read it, confirmed the same way as several unmodeled DLG fields rather than by it being always zero in the sample.
Live Somewhere Else, Dead HereInfinite 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 ForThe 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.

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

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

Implemented under rakata_lint::rules::utp_range.

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

Pending

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

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

FieldTypeEngineWhen absent
CommentCExoStringnever reads it: legacy metrics from older tools or other Odyssey games, listed among the placeable fields the engine never readsnot one constant; we substitute ""
OpenLockDiffBYTEnever reads it: legacy metrics from older tools or other Odyssey games, listed among the placeable fields the engine never readsnot one constant; we substitute 0
OpenLockDiffModCHARnever reads it: legacy metrics from older tools or other Odyssey games, listed among the placeable fields the engine never readsnot one constant; we substitute 0
NotBlastableBYTEnever reads it: read by nothing on the placeable pathnot one constant; we substitute 0
GroundPileBYTEnever reads it: the loader reads this and throws the result away, then forces the placeable to a ground pile regardlessNOT EXAMINED; we substitute 1
PaletteIDBYTEnever reads it: legacy metrics from older tools or other Odyssey games, listed among the placeable fields the engine never readsnot one constant; we substitute 0
ItemList[].InfiniteBYTEnever 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 itnot one constant; we substitute 0
ItemList[].Repos_PosYWORDnever reads it: no Repos_PosY string exists in swkotor.exe on any object type; the engine reads Repos_PosX and lowercase Repos_Posy onlynot one constant; we substitute 0
LoadScreenIDWORDnever reads it: utp.md never names the field, and the engine reads it on doors, triggers and areas rather than placeablesnot one constant; we substitute 0
OnFailToOpenCResRefnever reads it: this hook belongs to doors; a placeable never reads itNOT 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.

FieldTypeWhen absent
TemplateResRefCResRefNOT EXAMINED; we substitute ""
TagCExoStringstamps ""
LocNameCExoLocStringstamps empty
DescriptionCExoLocStringstamps empty
ConversationCResRefstamps ""
FactionDWORDstamps 0
AppearanceDWORDNOT EXAMINED; we substitute 0
AnimationStateBYTEstamps 0
AnimationINTstamps 0
OpenBYTEstamps 0
LockableBYTEstamps 0
LockedBYTEstamps 0
KeyRequiredBYTEstamps 0
KeyNameCExoStringstamps ""
AutoRemoveKeyBYTEstamps 0
OpenLockDCBYTEstamps 0
CloseLockDCBYTEstamps 0
CurrentHPSHORTstamps 0
HPSHORTstamps 0
HardnessBYTEstamps 0
FortBYTEstamps 0
RefBYTEstamps 0
WillBYTEstamps 0
PlotBYTEkeeps 0
InvulnerableBYTEkeeps 0
Min1HPBYTEkeeps 0
StaticBYTEnot one constant; our reader works it out from other fields
UseableBYTEstamps 0
PartyInteractBYTEstamps 0
HasInventoryBYTEstamps 0
DieWhenEmptyBYTEstamps 0
LightStateBYTEnot one constant; we substitute 0
InterruptableBYTENOT EXAMINED; we substitute 0
PortraitIdWORDstamps 65535
PortraitCResRefstamps ""
BodyBagBYTEkeeps 0
TypeBYTENOT EXAMINED; we substitute 0
IsBodyBagBYTEkeeps 0
IsCorpseBYTEkeeps 0
TrapDetectableBYTEstamps 0
TrapDetectDCBYTEstamps 0
TrapDisarmableBYTEstamps 0
DisarmDCBYTEstamps 0
TrapFlagBYTEstamps 0
TrapOneShotBYTEstamps 0
TrapTypeBYTEkeeps 255
OnClosedCResRefstamps ""
OnDamagedCResRefstamps ""
OnDeathCResRefstamps ""
OnDisarmCResRefstamps ""
OnHeartbeatCResRefstamps ""
OnInvDisturbedCResRefstamps ""
OnLockCResRefstamps ""
OnMeleeAttackedCResRefstamps ""
OnOpenCResRefstamps ""
OnSpellCastAtCResRefstamps ""
OnUnlockCResRefstamps ""
OnUsedCResRefstamps ""
OnUserDefinedCResRefstamps ""
OnDialogCResRefstamps ""
OnEndDialogueCResRefstamps ""
OnTrapTriggeredCResRefstamps ""
ItemListListnot one constant; we substitute container
ItemList[].InventoryResCResRefNOT EXAMINED; we substitute ""
ItemList[].DropableBYTEstamps 0
ItemList[].Repos_PosXWORDstamps 255
ItemList[].Repos_PosyWORDstamps 255
ItemList[].ObjectIdDWORDstamps 2130706432