UTT Format (Trigger Blueprint)
A .utt file is a trigger: an invisible polygon on the floor that does something when a character walks into it. That something is one of three things, running a script, moving the party to another area, or springing a trap.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .utt |
| Magic Signature | UTT / V3.2 |
| Type | Trigger Blueprint |
| Rust Reference | View rakata_generics::Utt 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 trigger is and where it sits on the ground | Tag, Geometry |
| Interactive State & Sub-types | Whether the trigger acts as a loading zone, a trap, or a generic scripting boundary | Type, Cursor, HighlightHeight |
| Trap Mechanics | Trap visibility and the skill checks required to disarm | TrapType, TrapOneShot |
| Transition & Behavioural Hooks | The event scripts that fire on enter, click, leave, or disarm, plus the destination area when the trigger is a loading zone | ScriptOnEnter, LinkedTo |
Engine Audits & Decompilation
Read from CSWSTrigger::LoadTrigger at 0x0058da80 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 |
|---|---|---|
LoadTrigger | 3381 B | The main constructor. It reads the trigger’s properties, scripts, and trap rules. |
LoadTriggerGeometry | 743 B | Reads the PointX / PointY / PointZ vertices that draw the trigger’s boundary on the floor. The vertices are stored relative to the trigger position (each PointX is vertex.x - XPosition), so world-space geometry is recovered by adding the trigger position back. |
LoadFromTemplate (0x0058ee06) | n/a | The blueprint-loading entry point: opens the .utt resource named by the placed instance’s TemplateResRef and calls LoadTrigger against the blueprint’s own top-level struct. |
CSWSArea::LoadTriggers (0x0050a350) | n/a | The area-level dispatcher. Per placed trigger it calls LoadTrigger directly on the .git instance struct, or LoadFromTemplate where one applies, re-applying several instance-only fields afterward. See “Geometry and the Instance Overlay” below. |
Both load paths start from the same constructed object. Every placed trigger is allocated and run through CSWSTrigger’s real constructor, which seeds all seven script slots to "default" and TrapType to 0xFF, before the dispatcher looks at whether a template applies. LoadTrigger takes no caller-context parameter, so the save-instance call and the templated call invoke it identically.
So an absent script hook or trap field on a save-restored trigger resolves to the same constructed default as on the blueprint path, with no divergence between them.
Rules the engine enforces
| Engine Rule | Runtime Behaviour |
|---|---|
| Behaviour Derived from Type | The Type field decides the trigger’s behaviour and UI cursor. Type 1 makes it a map transition zone. Type 2 makes it a trap. |
| OnClick Duplication Bug | The engine copies the ScriptOnEnter value over the OnClick listener by default, unless OnClick is explicitly overridden. The copy happens after ScriptOnEnter has already resolved its own absent-value carry-over, so OnClick’s fallback is whatever ScriptOnEnter ended up as, a present value or ScriptOnEnter’s own carried-over default (see below), rather than the raw constructed value. |
| Trap Hook Fallback | If the OnTrapTriggered script is left empty, set to null, or named "default", the engine ignores it and pulls the default script from traps.2da based on the TrapType. TrapType’s own absent default is the sentinel 0xFF (255, see the trap-flag row above), so a trigger missing both TrapType and OnTrapTriggered looks up row 255 of traps.2da, which almost certainly doesn’t exist. That is an out-of-range lookup, not a clean “no trap” fallback. |
| Highlight Clamping | The trigger’s HighlightHeight is ignored by the engine unless it is greater than 0.0. If it is exactly zero or negative, the engine falls back to a default rendering height of 0.1. |
| Orientation Drives Geometry | An instance orientation re-rotates the geometry vertices about the trigger position. See below. |
Geometry Is Presence-Gated, Not Context-Gated | Unlike LinkedTo/Tag/Faction below, the Geometry read inside LoadTrigger is gated only on whether the struct it was handed contains a Geometry list at all, with no check for whether that struct came from a .utt blueprint or a .git instance. A hand-authored .utt blueprint that carried a Geometry field would have it read on the ordinary template-load path, the same way a placed instance’s would. See “Geometry and the Instance Overlay” below for how a placed instance’s own geometry, when present, ends up taking precedence anyway. |
| Contextual Loading | Fields like LinkedTo, LinkedToModule, AutoRemoveKey, Tag, and Faction are only loaded into memory when the Trigger is processed from a .git area layout file. |
| Portrait Shadowing | If PortraitId is < 0xFFFE, the ID decides and the Portrait string ResRef is dead data. |
| Presence-Gated Position and Orientation | SetPosition and SetOrientation are applied only where the corresponding fields were present. Every vanilla writer emits both, so this matters for hand-edited files: the trigger keeps whatever it already held rather than resetting to the origin. |
Orientation and geometry are two halves of one shape
An instance orientation re-rotates the geometry vertices about the trigger position by the yaw difference between the geometry’s prior frame and the new orientation:
new_vertex = new_pos + R(yaw_new) * inverse(R(yaw_old)) * (vertex - old_pos)
Only the yaw is used. Pitch and roll are forced to zero, and the orientation vector is normalised where it is not unit length. Where the instance also carries an explicit Geometry list, that list is applied directly instead.
So a trigger’s shape is position plus yaw plus position-relative geometry, and rewriting the orientation without re-baking the geometry desyncs the two.
Two trap flags ignore their constructed value; one does not
TrapDisarmable and TrapDetectable are both 1 on a freshly constructed trigger, but each read’s missing-field fallback is a hardcoded literal 0 that ignores the constructed value. A file omitting them loads as non-disarmable and non-detectable, the opposite of what a fresh trigger suggests.
TrapOneShot does not share that. Its read carries over the object’s current value, which the constructor set to 1, so an absent TrapOneShot resolves to 1.
TrapType also carries over, from a constructed 0xFF, which is what feeds the trap-hook fallback above. So the literal-0 override is specific to TrapDisarmable and TrapDetectable, not a rule about trap flags.
An absent field usually commits nothing, and LinkedToFlags is the exception
LinkedTo, LinkedToModule, AutoRemoveKey, Tag and Faction each name a constant fallback while reading, but that constant only populates the read. Where the field is absent nothing is committed, so the trigger keeps whatever it already held.
LinkedToFlags does not follow that, despite sitting in the same instance-overlay family below: it reads with a hardcoded literal 0 and is stamped unconditionally, with no found-flag gate. That is deliberate rather than an oversight: UTD’s LoadDoor reads its own LinkedToFlags the same unconditional way.
Remaining Absent-Field Defaults
Note
Which
traps.2dacolumns, since it is not one column and it differs by object type A trigger reads its default script fromTrapScript; a door or a placeable readsMineScript. The two are separate columns and the wrong one silently supplies the wrong script.The DC columns split the same way. Only triggers consult
DisarmDCModandDetectDCMod; doors and placeables take their disarm and detect DCs from their own GFF fields and never touch those columns at all.So all three blueprint types route to this table and none of them routes to the same place.
Every script slot defaults to the literal string "default" rather than an empty resref, shared with UTD. OnDisarm, ScriptHeartbeat, ScriptOnEnter, ScriptOnExit, ScriptUserDefine, OnTrapTriggered, and OnClick are each read as a carry-over of the object’s own current script-slot value, and the CSWSTrigger constructor pre-arms every one of those slots to the literal string "default", not empty. This is the identical mechanism already confirmed for doors. The OnTrapTriggered fallback rule above (“empty, null, or literally "default"”) exists because an absent field naturally becomes "default" through this carry-over, not because the engine independently special-cases that spelling. The rest have no equivalent secondary lookup: an absent one simply keeps the literal resref "default", which won’t resolve to a real script unless a module happens to ship one named exactly that.
TransitionDestin
It reads unconditionally regardless of source struct, matching UTD’s mechanism exactly. The field carries over the object’s own current value (an empty localized string from construction) with no found-flag gate at all, confirmed at the mechanism level rather than from the observed outcome, to be the identical pattern already documented for UTD’s LoadDoor. Whatever “instance wins” behaviour a templated trigger shows for this field comes entirely from CSWSArea::LoadTriggers’s own overlay step (see below), which re-reads it unconditionally off the .git struct after the template load, with no found-flag check at that overlay site either.
PortraitId and Portrait
PortraitId defaults to the sentinel 0xFFFF, and Portrait is read twice. PortraitId is an unconditional literal 0xFFFF on absence, which lands squarely in the already-documented >= 0xFFFE “use string Portrait” range, so an absent PortraitId behaves identically to an explicit 0xFFFE. Portrait itself defaults to an empty resref, unconditional, but LoadTrigger reads it twice: once conditionally (only when PortraitId resolved >= 0xFFFE), and again unconditionally right after the Cursor read. The second call always runs and is what actually determines the final value, so the practical absent-field answer is simply an empty resref regardless of what PortraitId did. This double-read is specific to LoadTrigger, where UTD’s LoadDoor reads Portrait only once.
The remaining literals
These are unconditional:
| Field | Absent value |
|---|---|
LocalizedName | empty localized string |
KeyName | empty string |
Cursor, Type | 0 |
SetByPlayerParty, LoadScreenID | 0 |
CreatorId | 0x7F000000 |
An absent Type satisfies neither the transition nor the trap branch, so the Cursor override those branches can apply never fires either.
CreatorId is an object reference, not a plain integer, defaulting to the same 0x7F000000 sentinel as AreaId and GIT’s LastEntered/LastLeft. CSWSTrigger’s constructor sets that same literal independently of the read. Unlike AreaId, where the constructor and the read-call literal disagree, a trigger’s CreatorId lands on the sentinel either way.
Geometry and the Instance Overlay
Geometry’s absence from every vanilla .utt blueprint is an authoring-tool habit, not an engine restriction. LoadTrigger gets the same call whether its struct came from a blueprint or from a fully inline .git trigger, and reads Geometry identically either way.
The fork is one level up, in CSWSArea::LoadTriggers (0x0050a350). For a template-backed trigger it calls LoadFromTemplate (0x0058ee06), which opens the .utt and runs LoadTrigger against the blueprint. On return it re-reads a set of fields straight off the .git instance struct: LinkedToModule, TransitionDestin (see UTD’s equivalent field for the same truncated-label pattern), LinkedTo, LinkedToFlags, position and Geometry, calling LoadTriggerGeometry a second time where the instance carries its own list.
So instance geometry is an overlay, not a rejection. The blueprint’s geometry is read and would stand if nothing replaced 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 | As with other templates, older asset revisions include TemplateResRef, Comment, PaletteID and PartyRequired. LoadTrigger reads none of them. |
| Superseded Legacy Fields | Older asset revisions typically map TrapDetectDC and DisarmDC in the .utt file itself, but LoadTrigger never reads them. The DCs come from the traps.2da columns above instead. |
Implemented Linter Rules (Rakata-Lint)
Phase 1 (intra-resource, no context)
Implemented under rakata_lint::rules::utt.
- UTT-001 (Transition Enforcement): Warns when
Type==1(Transition) but no destination (LinkedTo,LinkedToModule, orTransitionDestin) is configured. - UTT-002 (Trap Consistency): Informs when
TrapDetectDC/DisarmDCare set (engine reads fromtraps.2da); also warns whenTrapFlag=truebutType != 2. - UTT-003 (Geometry Safety): Warns when the trigger’s geometry contains fewer than 3 vertices.
- UTT-004 (OnClick on Generic Trigger): Informs when
OnClickis set on a Generic trigger (Type==0); the event only fires for Transition triggers. - UTT-005 (Highlight Bounding): Informs when
HighlightHeight <= 0.0; the engine falls back to a default of0.1. - UTT-006 (Portrait Shadowing): Warns when
PortraitId < 0xFFFEandPortraitresref is set; the resref is ignored at runtime. - UTT-007 (PartyRequired Dead Data): Informs when
PartyRequiredis set; the K1 engine never reads this field.
Phase 2 (range / 2DA / resref existence, requires LintContext)
Implemented under rakata_lint::rules::utt_range.
- UTT-008 (Portrait Bounds): Errors when
PortraitId(when not the0xFFFE“use string Portrait” sentinel) does not resolve to a row inportraits.2da. - UTT-009 (Resref Existence): Warns when any of
OnDisarm,OnTrapTriggered,OnClick,OnHeartbeat,OnEnter,OnExit, orOnUserDefined(.ncs), orPortrait(.tga), does not resolve in the configured resource sources.LinkedToModule(area transition) is deferred to Phase 3.
Pending
- Default Script Identification: Identifies empty / null / literally-named
"default"OnTrapTriggeredentries that silently invoke thetraps.2dafallback.
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 |
|---|---|---|---|
LinkedTo | CExoString | never reads it: LoadTriggers re-reads this straight off the .git instance struct after the template load, with no found-flag check at the overlay site unlike Geometry beside it, and a .utt blueprint only ever reaches LoadTrigger through that branch | NOT EXAMINED; we substitute "" |
LinkedToFlags | BYTE | never reads it: LoadTriggers re-reads this straight off the .git instance struct after the template load, with no found-flag check at the overlay site unlike Geometry beside it, and a .utt blueprint only ever reaches LoadTrigger through that branch | NOT EXAMINED; we substitute 0 |
LinkedToModule | CResRef | never reads it: LoadTriggers re-reads this straight off the .git instance struct after the template load, with no found-flag check at the overlay site unlike Geometry beside it, and a .utt blueprint only ever reaches LoadTrigger through that branch | NOT EXAMINED; we substitute "" |
TransitionDestin | CExoLocString | never reads it: LoadTriggers re-reads this straight off the .git instance struct after the template load, with no found-flag check at the overlay site unlike Geometry beside it, and a .utt blueprint only ever reaches LoadTrigger through that branch | NOT EXAMINED; we substitute empty |
PartyRequired | BYTE | never reads it: read by nothing on the trigger path | not one constant; we substitute 0 |
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 |
|---|---|---|
Tag | CExoString | keeps "" |
LocalizedName | CExoLocString | stamps empty |
Faction | DWORD | keeps 0 |
Cursor | BYTE | stamps 0 |
KeyName | CExoString | stamps "" |
PortraitId | WORD | stamps 65535 |
Portrait | CResRef | stamps "" |
ScriptHeartbeat | CResRef | keeps "default" |
ScriptOnEnter | CResRef | keeps "default" |
ScriptOnExit | CResRef | keeps "default" |
ScriptUserDefine | CResRef | keeps "default" |
OnTrapTriggered | CResRef | keeps "default" |
OnDisarm | CResRef | keeps "default" |
OnClick | CResRef | keeps "default" |
TrapType | BYTE | keeps 255 |
TrapOneShot | BYTE | keeps 1 |
TrapDisarmable | BYTE | stamps 0 |
TrapDetectable | BYTE | stamps 0 |
AutoRemoveKey | BYTE | keeps 0 |
Type | INT | stamps 0 |
HighlightHeight | FLOAT | NOT EXAMINED; we substitute 0.0 |
LoadScreenID | WORD | stamps 0 |
SetByPlayerParty | BYTE | stamps 0 |
Geometry | List | not one constant; we substitute container |
Geometry[].PointX | FLOAT | NOT EXAMINED; we substitute 0.0 |
Geometry[].PointY | FLOAT | NOT EXAMINED; we substitute 0.0 |
Geometry[].PointZ | FLOAT | NOT EXAMINED; we substitute 0.0 |
TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
Comment | CExoString | NOT EXAMINED; we substitute "" |
PaletteID | BYTE | NOT EXAMINED; we substitute 0 |
TrapDetectDC | BYTE | stamps 0 |
DisarmDC | BYTE | stamps 0 |
TrapFlag | BYTE | stamps 0 |
CreatorId | DWORD | stamps 2130706432 |
XPosition | FLOAT | not one constant; we substitute 0.0 |
YPosition | FLOAT | not one constant; we substitute 0.0 |
ZPosition | FLOAT | not one constant; we substitute 0.0 |
XOrientation | FLOAT | not one constant; we substitute 0.0 |
YOrientation | FLOAT | not one constant; we substitute 0.0 |
ZOrientation | FLOAT | not one constant; we substitute 0.0 |