UTP Format (Placeable Blueprint)
Description: The Placeable (.utp) blueprint dictates the configuration of universally interactive scenery and containers within a map. Ranging from simple locked footlockers to rigged command consoles and explodable starship barricades, .utp structs blend physical static properties (like structural HP and lock difficulties) with heavy dynamic script bindings.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .utp |
| Magic Signature | UTP / V3.2 |
| Type | Placeable Blueprint |
| Rust Reference | View rakata_generics::Utp in Rustdocs |
Data Model Structure
Rakata maps a Placeable into the rakata_generics::Utp struct. The struct’s Rustdocs document every field’s binary schema and GFF mapping; the table below is the high-level anatomy.
| Category | Covers | Representative fields |
|---|---|---|
| Core Identity & Geometry | What the placeable looks like, its faction, and the text displayed when targeted | Appearance, TemplateResRef, LocName |
| Interactive State & Dialogue | Whether the placeable can be clicked, starts a conversation or computer sequence, or acts as a loot container | Useable, Conversation, HasInventory |
| Lock & Trap Mechanics | Whether it is locked, which key opens it, and the rules for attached traps | Locked, KeyName, TrapType, DisarmDC |
| Health & Destruction | Whether the object can be destroyed and its defensive thresholds | HP, Hardness, Static, Plot |
| Behavioral Hooks | The scripts that run when a player explores, attacks, or opens the placeable | OnOpen, OnInvDisturbed, OnDamaged |
rakata-lint validates these fields against the engine constraints documented below.
Engine Audits & Decompilation
(Decompilation logic for this section was entirely audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from CSWSPlaceable::LoadPlaceable at 0x00585670.)
Because Placeables act as physical junctions for event hooking, they expose a massive suite of script triggers natively.
Structural Load Phasing
| Function | Size | Behavior |
|---|---|---|
LoadPlaceable | 5092 B | The primary physical parser evaluating 46 core metrics including health, conversation dialogues, basic trap bindings, and physical alignment states. All 16 script hooks are read directly inside this same function too, as hand-unrolled, byte-for-byte identical boilerplate – there’s no separate script-reading function for placeables. ReadScriptsFromGff (documented on other pages) is not it: its only callers are creature-related loaders, never LoadPlaceable. |
Core Structural Findings
| Engine Rule | Runtime Behavior |
|---|---|
| Appearance Truncation | The engine reads Appearance as a 32-bit integer but forcefully truncates it to a single byte. Any ID above 255 automatically wraps to 0 and physically breaks the placeable model rendering. |
| Static vs. Plot Chaining | Just like Doors, if a Placeable is marked Static=1, the engine completely overrides all other behaviors and acts as if Plot=1 is true, making the placeable totally indestructible even if it has an HP value defined. |
| Default Usability Check | If the Static toggle is completely missing from the binary file, the engine automatically derives it by actively checking if the Placeable is marked as usable (!Useable). |
| Portrait Shadowing | If PortraitId is < 0xFFFE, the engine completely ignores the Portrait string ResRef and relies entirely on the ID. Any value in the Portrait ResRef field is treated as dead data. |
| Ground Pile Forcing | The engine reads whatever value you place in GroundPile, but the read result is discarded entirely and the field is unconditionally forced to 1 in memory. It’s a pure dead read; native configuration of this field is decorative. |
| Missing Door Hooks | Toolsets erroneously expose OnFailToOpen for Placeables, but the engine specifically treats this as a Door-exclusive (.utd) script hook and completely ignores it here. |
| Trap Hook Fallback | If a trap bounds check fails or the OnTrapTriggered script is left blank, the engine automatically attempts to read the traps.2da table and pulls the default script based on the specific TrapType. |
| Corpse Exclusion Lives at the Area Level | A placeable’s own save routine never skips itself, no matter its corpse state. The “skip corpses” behaviour actually lives one level up, in the area’s own placeable-list saver, which omits the entire list entry for any placeable flagged as a corpse before the placeable’s save routine is ever invoked. Doors have no equivalent skip: every tracked door is always written to the save. |
| Empty Inventory Omission | ItemList is only added to the written struct when the placeable’s item repository holds at least one item. An empty inventory produces no ItemList field at all, not even an empty one. |
HasInventory and DieWhenEmpty Are Cross-Wired
The GFF field labelled HasInventory is read (BYTE, default 0, unconditional) into an internal member that nothing else in LoadPlaceable reads or acts on meaningfully. The member the engine actually treats as “has inventory” is instead populated from the DieWhenEmpty GFF field. Both individual reads are ordinary – BYTE, unconditional literal 0 default – the surprise is entirely in the label-to-member wiring, which looks like an artifact of a field being renamed on the engine side at some point without the GFF label following. Practical consequence: writing HasInventory on a .utp has no observable effect on whether the engine treats the placeable as carrying inventory; that behavior is actually driven by whatever DieWhenEmpty resolves to.
HP, CurrentHP, and the Trap Flags: Absence Silently Un-Arms a “Fresh” Placeable
Five fields diverge sharply between what a freshly constructed placeable already holds and what the loader stamps when the GFF omits them – worth calling out together, since all five follow the identical trap: the constructor pre-arms a meaningful nonzero value, but the read’s own fallback is a hardcoded literal 0 that ignores it entirely.
HP(maximum) andCurrentHP: the constructor sets both to1(a placeable nominally starts alive). The reads for both pass a hardcoded0, unconditional – an absent field leaves a “fresh” placeable at 0 max and 0 current HP, not the 1/1 the constructor set up.TrapDetectable,TrapDisarmable,TrapOneShot: the constructor pre-arms all three to1(armed/on). Same hardcoded-0fallback, unconditional, for all three – an absent trap flag set produces a “dead,” non-functional trap rather than the constructor’s default-armed one.
None of these five reference the object’s current value at all; each is a plain unconditional-literal-0 store that happens to silently overwrite a deliberately nonzero constructed default. A hand-authored or legacy .utp missing these fields loads noticeably weaker than a freshly-built placeable would suggest.
Plot and Invulnerable Share One Member, and Presence Alone Decides Which Wins
CSWSPlaceable has no member called invulnerable at all – both the Invulnerable and Plot GFF labels feed the same underlying plot flag, and which one actually gets consulted is a presence check, not a value comparison. The loader always tries Invulnerable first (fallback: the object’s current plot value, 0 from construction). If Invulnerable is present in the file at all, Plot is never read – not defaulted, skipped outright. Only when Invulnerable is absent does the loader go on to read Plot, using the same carried-over fallback. Either way, the result then feeds the already-documented Static-forces-Plot=1 override. So Plot’s own absent-field default (with Invulnerable also absent, Static not forcing it) is a carry-over of 0 – but the more important finding is that Invulnerable, while undocumented on this page until now and never written by the vanilla toolset, is a real, live field the engine reads and lets pre-empt Plot entirely if a file supplies it.
LightState Is Derived From Appearance Through a 2DA Lookup, Not a Raw Carry-Over
The constructor sets a light-state member to 1 and a separate “is the light actually on” flag to 0. LightState’s own read doesn’t fall back to either directly – its default is the return value of a helper that looks up the placeable’s already-resolved (and already-truncated) Appearance in placeables.2da’s LightColor column. If that column has no entry for the appearance, the default resolves to 0 (light off) regardless of the constructed value; only if the column entry exists does the default become the constructed 1 (light on). This is a genuine sibling-derived default, and unusually, the sibling is consulted through 2DA table data rather than another GFF field directly.
Open, Animation, and AnimationState Form a Fully Gated Chain
Open itself is an ordinary unconditional-literal-0 field. Its resolved value then drives a chain worth documenting field by field, since it directly substantiates the “Animation Conditional Limits” pending rule below:
- If
Openresolves non-zero:AnimationandAnimationStateare never read at all. The engine unconditionally applies a fixed sentinel,10075, as the placeable’s animation state. - If
Openresolves to0: the engine readsAnimation(INT, default0). If present, its raw value is applied directly as the animation id, no validation. - If
Animationis absent, the engine falls through toAnimationState(BYTE, default0). IfAnimationStateis also absent, the animation-setting call is skipped entirely for this placeable – a presence-chain abort scoped to this one derived step, not the whole struct. IfAnimationStateis present, it indexes into six preset animation-id sentinels; any value greater than5collapses to a different fallback sentinel,10000.
Remaining Absent-Field Defaults
The rest of LoadPlaceable’s fields are unremarkable unconditional literals, no divergence between the read’s fallback and the constructed value: Tag and KeyName to an empty string, LocName/Description to an empty localized string, Conversation to an empty resref, Faction/AutoRemoveKey/KeyRequired/Lockable/Locked/OpenLockDC/CloseLockDC/Hardness/Fort/Will/Ref/PartyInteract/TrapDetectDC/DisarmDC/TrapFlag/Useable to 0. Min1HP, BodyBag, IsBodyBag, and IsCorpse carry over the object’s own constructed value (all 0/false) rather than using a fresh literal – mechanically distinct from the group above, same observable outcome. TrapType also carries over, and the constructed value there is the sentinel 0xFF (255), the same sentinel already documented for doors and triggers, feeding the same traps.2da fallback lookup.
The 15 remaining script hooks (OnClosed, OnDamaged, OnDeath, OnDisarm, OnHeartbeat, OnInvDisturbed, OnLock, OnMeleeAttacked, OnOpen, OnSpellCastAt, OnUnlock, OnUsed, OnUserDefined, OnDialog, OnEndDialogue) default to an empty resref, unconditional, no exceptions across all 15. This is worth contrasting directly with doors and triggers: UTD’s and UTT’s script hooks all carry over a constructor-seeded literal string "default" on absence, but placeables have no such seeding – their constructor never pre-arms the script slots to anything but empty, so an absent placeable script hook is genuinely empty, not the string "default". (OnTrapTriggered and OnFailToOpen are documented separately above.)
Legacy & Ignored Data
| Finding Type | Explanation |
|---|---|
| Legacy Engine Artifacts | Placeable binaries are littered with legacy metrics from older tools or other Odyssey games (Comment, OpenLockDiff, Interruptable, Type, PaletteID). The physical KOTOR engine constructor entirely ignores these. OpenLockDiff, OpenLockDiffMod, and NotBlastable are confirmed dead the same decisive way as IsComputer below – none of the three field-name strings exist anywhere in swkotor.exe, so no code path can read them at all, not merely “read and ignored.” |
IsComputer Doesn’t Exist in the Binary | IsComputer appears in a handful of .utp files, always 0. Its field-name string doesn’t exist anywhere in swkotor.exe, so no code path can read it – confirmed dead the same way as several unmodeled DLG fields, not merely “always zero in the sample.” |
Implemented Linter Rules (Rakata-Lint)
Phase 1 (intra-resource, no context)
Implemented under rakata_lint::rules::utp.
- UTP-001 (Plot Chaining Context): Warns when
Static=truebutPlot=false; the engine forces Plot to true at runtime. - UTP-002 (Ghost Value Detection): Informs when
GroundPile=falsesince the engine immediately overwrites this to true on load. - UTP-003 (Dead Hook Pruning): Flags
OnFailToOpeninstances because placeables ignore this event hook (it is door-exclusive). - UTP-004 (HP Health Ceiling): Errors when
CurrentHP > HP; the engine clamps toHPon template load. - UTP-005 (Portrait Shadowing): Warns when
PortraitId < 0xFFFEandPortraitresref is set; the resref is ignored at runtime.
Phase 2 (range / 2DA / resref existence, requires LintContext)
Implemented under rakata_lint::rules::utp_range.
- UTP-006 (Appearance Bounds): Errors when
Appearancedoes not resolve to a row inplaceables.2da; engine renders missing model. - UTP-007 (Portrait Bounds): Errors when
PortraitId(when not the0xFFFE“use string Portrait” sentinel) does not resolve to a row inportraits.2da. - UTP-008 (Resref Existence): Warns when
Conversation(.dlg),Portrait(.tga), any of the 16On*script hooks (.ncs), orItemList[i].InventoryRes(.uti) does not resolve in the configured resource sources.OnFailToOpenis intentionally NOT included – UTP-003 already flags it as door-exclusive dead data.
Pending
- Appearance Truncation: Warns when
Appearanceexceeds 255 (engine truncates to a single byte before lookup, distinct from the row-count check in UTP-006). - Animation Conditional Limits: Verifies that custom
AnimationStateindices are strictly guarded byOpen==0closures.