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)

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

PropertyValue
Extension(s).utp
Magic SignatureUTP / V3.2
TypePlaceable Blueprint
Rust ReferenceView 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.

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
Behavioral HooksThe scripts that run when a player explores, attacks, or opens the placeableOnOpen, 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

FunctionSizeBehavior
LoadPlaceable5092 BThe 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 RuleRuntime Behavior
Appearance TruncationThe 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 ChainingJust 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 CheckIf 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 ShadowingIf 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 ForcingThe 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 HooksToolsets 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 FallbackIf 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 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

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) and CurrentHP: the constructor sets both to 1 (a placeable nominally starts alive). The reads for both pass a hardcoded 0, 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 to 1 (armed/on). Same hardcoded-0 fallback, 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 Open resolves non-zero: Animation and AnimationState are never read at all. The engine unconditionally applies a fixed sentinel, 10075, as the placeable’s animation state.
  • If Open resolves to 0: the engine reads Animation (INT, default 0). If present, its raw value is applied directly as the animation id, no validation.
  • If Animation is absent, the engine falls through to AnimationState (BYTE, default 0). If AnimationState is 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. If AnimationState is present, it indexes into six preset animation-id sentinels; any value greater than 5 collapses 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 TypeExplanation
Legacy Engine ArtifactsPlaceable 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 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 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.

  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 of the 16 On* script hooks (.ncs), or ItemList[i].InventoryRes (.uti) does not resolve in the configured resource sources. OnFailToOpen is intentionally NOT included – 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.