UTI Format (Item Blueprint)
The Item (.uti) blueprint serves as the central data model for all tangible loot, weapons, armor, and usable gear in the game. It defines how an item physically appears on characters, what custom properties or stat bonuses it applies through specific upgrade hierarchies, its intrinsic monetary cost, and exactly what its runtime state behaves like when dropped into the world map.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .uti |
| Magic Signature | UTI / V3.2 |
| Type | Item Blueprint |
| Rust Reference | View rakata_generics::Uti in Rustdocs |
Data Model Structure
Rakata maps an Item into the rakata_generics::Uti 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 | The item’s name and description, in both identified and unidentified states | TemplateResRef, LocName, Description |
| Economic & Charge Mechanics | The item’s value and the charges left for consumable abilities | Cost, Charges |
| Visual Geometry | What the item looks like when dropped on the floor or equipped | ModelVariation, TextureVar |
| Combat & Upgrade Properties | The stat buffs, damage modifiers, and abilities bound to the item, plus workbench upgrade slots | PropertiesList |
rakata-lint validates these fields against the engine constraints documented below.
Engine Audits & Decompilation
The following information documents the engine’s exact load sequence and field requirements for .uti files mapped from swkotor.exe.
(Decompilation logic for this section was entirely audited and verified via native Ghidra pipeline against swkotor.exe, explicitly pulling from the primary dispatcher CSWSItem::LoadDataFromGff at 0x0055fcd0, the active-property predicate CSWSItem::IsFriendlyUsableItem at 0x00553900, the property-string resolver CSWSItem::GetPropertyStrings at 0x00554e00, and the IPRP table loaders CTwoDimArrays::LoadIPRPCostTables at 0x005c4730 / LoadIPRPParamTables at 0x005c49c0.)
Structural Load Phasing
The engine processes an Item structurally across multi-pass capabilities mappings.
| Function | Size | Behavior |
|---|---|---|
LoadDataFromGff | – | The main parser that sets what the item is, how many charges it holds, its descriptions, and – inlined into the same function, not a separate routine – the property list itself, in two back-to-back passes (active properties, then passive). There is no standalone LoadItemPropertiesFromGff symbol in this binary; earlier documentation implying one was a naming assumption, not a confirmed function. |
LoadItem | – | The constructor that decides whether to load the item onto a character or leave it idle in an inventory. |
LoadFromTemplate | – | A fallback used when spawning an item dynamically from a script instead of off a character. |
SaveItem / SaveItemProperties | – | The opposite pipeline that writes the item into a save game, which notoriously forces the item to always be flagged as “Identified”. |
Core Structural Findings
The engine rigorously evaluates base-item mapping constraints from 2DA arrays and aggressively overrides improperly defined models.
| Engine Rule | Runtime Behavior |
|---|---|
| Description Cross-Swap | If either Description or DescIdentified is missing, the engine automatically duplicates the provided string into the missing field so item identification mechanics never crash the game. |
| Model Truncation | If an older tool incorrectly configures ModelVariation to 0, the engine forcefully bumps it to 1 upon load, ensuring the item always has visible geometry instead of rendering an invisible weapon or armor piece. The field itself has a legacy fallback baked into the same read: if ModelVariation is absent entirely (not merely zero), the engine falls back to the older ModelPart1 label before applying the same zero-check, so genuinely ancient files still get the same protection. ModelPart2 and ModelPart3 are not part of this fallback, or read anywhere at all – their field-name strings don’t exist anywhere in the binary. Real files that carry all three alongside ModelPart1 are authoring-tool habit; only ModelPart1 ever reaches the engine. |
| Charge Fallback Chaining | MaxCharges has no default of its own. When missing, the engine simply reuses whatever value Charges just resolved to (current value, or the constant 50 fallback if Charges was itself absent) rather than falling back to the item’s own prior max-charges value. |
| Container Contents Gating | ItemList on an item (its nested contents, for container-type items like backpacks) is only read or written at all when the base item’s baseitems.2da row marks it as a container. A non-container item never has this list looked for; a genuine container item with the list absent simply loads with an empty inner inventory. |
| Dead Placement Fields | SaveItem unconditionally writes XPosition/YPosition/ZPosition and XOrientation/YOrientation/ZOrientation for every item, in every container. Only the area’s placed-item loader (CSWSArea::LoadItems) ever reads them back; creature inventories, stores, the party’s shared stash, and nested container contents all write these six fields and then never look at them again. |
| Model & Body Variation Hooks | The engine completely ignores the .uti’s BodyVariation field, opting instead to enforce the exact body_var value predefined in baseitems.2da. Additionally, TextureVar is unconditionally bypassed unless the item’s base type is strictly configured as Model Type 1. |
| Cost Generation Fallback | The physical Cost integer provided in the file is dead data. The engine strictly computes economic value actively via GetCost() calculations based on its properties, completely ignoring your defined value. |
| Identifier Enforcement | During explicit serializing via SaveItem (when the player creates a save game), the engine actively forces and hardcodes Identified to 1 unconditionally. This was cross-checked against 136 real items pulled from two separate vanilla save files (area creature/placeable loot, and a late-game party stash); every single one came back Identified = 1, confirming the decompiled behaviour holds in practice. |
| Property Capabilities | Item properties are structurally split into Active and Passive memory tables at load. The engine evaluates every PropertyName index: any ID strictly mapping to 10, 37, 46, or 53 (e.g., Cast Power, Trap) is actively hooked as a usable player ability, while all other integers are silently applied as passive stat modifiers. |
| Data-Driven Property Kinds | The engine does not hardcode a “PropertyName N -> semantic kind” table. Property-kind classification (Damage Bonus, Ability Bonus, Save Bonus, etc.) is resolved entirely by reading the Label column of itempropdef.2da at the row indexed by PropertyName. Mods that add new rows surface as new property kinds without engine changes. |
| Property Field Defaults | When a PropertiesList entry omits a field, the engine fills it from a fixed table: Useable=1 for active properties (PropertyName 10/37/46/53), Useable=0 for passive ones; UsesPerDay=0xFF and UpgradeType=0xFF – both 0xFF values function as “not set” sentinels rather than valid row indices. |
| Bit Flags Application | The Dropable boolean explicitly sets bit 3 of the item’s internal memory flags, while Pickpocketable sets bit 4. Missing fields safely default to 0 – and that 0 genuinely is a literal, not the constructor’s own starting value: CSWSItem’s constructor actually flips both bits on by default (droppable and pickpocketable, before any GFF is touched), but the read for each field is a hardcoded-literal-0 read with no presence check, so it unconditionally overwrites that constructed true back to false on every load, present or absent. The field never gets a chance to carry the constructor’s default forward. |
Identified Absent Means Identified, and Can Be Forced Regardless | Identified’s own load-side default is a hardcoded 1 (identified), stamped unconditionally with no presence check – a different mechanism from most of this item’s boolean flags, which default to a carried-over constructed value instead of a fresh literal. There’s a second override on top: once the property list finishes loading, if the item ends up with zero active and zero passive properties, the loader unconditionally forces the identified bit back to 1 regardless of what the file said – even an explicit Identified = 0 on a property-less item gets overwritten to identified on load. |
Most of the remaining top-level fields default to a carried-over constructed value rather than a fresh literal, in the same “read’s own default argument is the object’s current member” mechanism documented elsewhere on this page: BaseItem carries over to 30, LocalizedName/Tag to empty, StackSize to 1, Plot/Stolen/NonEquippable/NewItem/DELETING to false, and AddCost/Upgrades to 0. TextureVar is the one exception among these – when it’s actually consulted at all (base item’s model_type == 1), it’s read with a hardcoded literal default of 1, unconditional, not a carry-over.
PropertiesList Scalars: Six of Seven Default to Uninitialized Memory, Not Zero
PropertyName, Subtype, CostTable, CostValue, Param1, Param1Value, and ChanceAppear each get read with a literal default of 0 passed to the read call – but that 0 is never actually committed if the field is absent. Each of the seven writes is individually gated on its own presence flag, checked field-by-field right before the property struct member is set. Because the property array itself comes from a raw, unzeroed allocator, an absent field on an otherwise-populated PropertiesList entry doesn’t resolve to 0 at all – it leaves that struct member holding whatever was already sitting in that heap memory. This is a real correctness hazard, not just a documentation nuance: a linter (or a decoder) that assumes “absent property field reads as 0” is wrong for all seven of these, and should instead flag partially-specified property entries as producing undefined values.
(Note also: rakata models PropertyName as an INT-typed field, but the engine actually reads it with a 16-bit WORD read, same as Subtype and CostValue.)
Useable, UsesPerDay, and UpgradeType (already documented above) are the exception: those three are stamped unconditionally with no presence gate on the write, so they’re the only fields in a PropertiesList entry that are genuinely deterministic when absent.
A related edge case at the entry level, distinct from the per-field gating above: if a PropertiesList entry’s own struct is completely empty (zero fields at all, not just one missing label), the engine’s counting pass folds it into the passive tally, but the populate pass then skips writing it entirely once it reaches that entry. The property array was sized assuming that entry would be populated, so the slot it would have occupied is left as the same kind of uninitialized heap memory described above – an entirely empty entry doesn’t drop cleanly, it leaves a hole.
Property Table Dispatch
A UtiProperty carries three indices that point through three separate registry-of-registries chains. The engine holds no hardcoded mapping for any of them; every dispatch is a 2DA cell read, so mods that extend the underlying tables surface without engine modification.
Per-property subtype dispatch (resolved at display time inside GetPropertyStrings at 0x00554e00):
| Step | 2DA | Indexed By | Column Read | Purpose |
|---|---|---|---|---|
| 1 | itempropdef.2da | PropertyName | Name (INT) | TLK strref for the property’s display name (e.g. “Damage Bonus”). |
| 2 | itempropdef.2da | PropertyName | SubTypeResRef (string) | Resref of the per-property subtype 2DA (e.g. iprp_damagecost). Empty/missing means the property has no subtype dimension. |
| 3 | (subtype 2DA from step 2) | Subtype | Name (INT) | TLK strref for the subtype’s display name (e.g. “Acid”). |
Cost-table dispatch (resolved eagerly at startup inside LoadIPRPCostTables at 0x005c4730):
| Step | 2DA | Indexed By | Column Read | Purpose |
|---|---|---|---|---|
| 1 | iprp_costtable.2da | CostTable | Name (string) | Resref of the cost-specific 2DA (e.g. iprp_meleecost). Used as a resref despite the column name suggesting a label. |
| 2 | iprp_costtable.2da | CostTable | ClientLoad (INT, optional) | When set and the engine is running in client mode, the loader skips loading this row’s cost 2DA. Treated as server-only. |
| 3 | (cost 2DA from step 1) | CostValue | (table-specific) | The row at CostValue carries the cost effect for this property; column layout varies per cost table. |
Param-table dispatch (resolved eagerly at startup inside LoadIPRPParamTables at 0x005c49c0):
| Step | 2DA | Indexed By | Column Read | Purpose |
|---|---|---|---|---|
| 1 | iprp_paramtable.2da | Param1 | TableResRef (string) | Resref of the param-specific 2DA. |
| 2 | (param 2DA from step 1) | Param1Value | (table-specific) | The row at Param1Value carries the parameter value for this property; column layout varies per param table. |
Engine constraints:
- Both
iprp_costtable.2daandiprp_paramtable.2darow counts are stored asbyte(u8) inCTwoDimArrays. Rows past index 255 are silently truncated by the loader and the affected per-property tables never get loaded into memory. - Column-name lookups in 2DAs are case-sensitive at the engine API (
C2DA::GetINTEntry/GetCExoStringEntrycompare verbatim). The exact spellings the engine uses areName,SubTypeResRef,TableResRef,Label, andClientLoad. - The subtype 2DA listed in
SubTypeResRefis loaded lazily on display viaGetPropertyStrings, not eagerly at startup. A missing subtype 2DA fails only the call that needs it, not the whole game load. - The
Namecolumn on every level of the dispatch is a TLK strref. TheLabelcolumn on the same row holds a developer-readable identifier (e.g.Damage_Bonus) that does not require talktable resolution.
Cost-Table Magnitude Resolution
The cost-table dispatch chain documented above ends at “the row at CostValue carries the cost effect for this property; column layout varies per cost table.” This section pins down the column layout for vanilla K1’s iprp_costtable.2da entries and how each Apply<PropertyKind> handler reads from them, sourced from the CSWSItemPropertyHandler::Apply* family in swkotor.exe (handlers cluster around 0x004e5490-0x004e7e80 and 0x004e9230-0x004e9390).
iprp_costtable.2da (vanilla K1) — index to per-cost 2DA mapping:
| Index | Name (resref of per-cost 2DA) | Label | ClientLoad |
|---|---|---|---|
| 0 | IPRP_BASE1 | Base1 | 0 |
| 1 | IPRP_BONUSCOST | Bonus | 0 |
| 2 | IPRP_MELEECOST | Melee | 1 |
| 3 | IPRP_CHARGECOST | SpellUse | 0 |
| 4 | IPRP_DAMAGECOST | Damage | 0 |
| 5 | IPRP_IMMUNCOST | Immune | 0 |
| 6 | IPRP_SOAKCOST | DamageSoak | 0 |
| 7 | IPRP_RESISTCOST | DamageResist | 0 |
| 8 | IPRP_BLADECOST | DancingScimitar | 0 |
| 9 | IPRP_SLOTSCOST | Slots | 0 |
| 10 | IPRP_WEIGHTCOST | Weight | 0 |
| 11 | IPRP_SRCOST | SpellResist | 0 |
| 12 | IPRP_STAMINACOST | Stamina | 0 |
| 13 | IPRP_SPELLLVCOST | SpellLevel | 0 |
| 14 | IPRP_AMMOCOST | Ammo | 0 |
| 15 | IPRP_REDCOST | WeightReduction | 0 |
| 16 | IPRP_SPELLCOST | Spells | 0 |
| 17 | IPRP_TRAPCOST | Traps | 0 |
| 18 | IPRP_LIGHTCOST | Light | 1 |
| 19 | IPRP_MONSTCOST | Monster_Cost | 0 |
| 20 | IPRP_NEG5COST | Negative_Modifiers | 0 |
| 21 | IPRP_NEG10COST | Negative_Modifiers | 0 |
| 22 | IPRP_DAMVULCOST | Damage_vulnerability | 0 |
| 23 | IPRP_SPELLLVLIMM | Spell_Level_Immunity | 0 |
| 24 | IPRP_ONHITCOST | OnHitCosts | 0 |
| 25 | IPRP_ONHITDC | OnHitDC_saves | 0 |
Per-handler magnitude resolution. Each Apply<Kind> handler that needs a cost-table magnitude calls CTwoDimArrays::GetIPRPCostTable(<index>) then C2DA::GetINTEntry(table, row=CostValue, column, out). The integer that comes back is the engine-side magnitude (in the units appropriate to the property kind: bonus number, damage soak amount, save delta, etc.). The column name is read case-sensitively; the only two columns the vanilla handlers consult are Value and Amount.
| Handler | CostTable index | Per-cost 2DA | Column | Post-processing |
|---|---|---|---|---|
ApplyAbilityBonus | 1 | iprp_bonuscost | Value | – |
ApplyACBonus | 1 | iprp_bonuscost | Value | – |
ApplyImprovedSavingThrow | 1 | iprp_bonuscost | Value | – |
ApplyDamageReduction | 6 | iprp_soakcost | Amount | – |
ApplyDamageResistance | 7 | iprp_resistcost | Amount | – |
ApplyImprovedForceResistance | 11 (0xB) | iprp_srcost | Value | – |
ApplyAttackPenalty | 20 (0x14) | iprp_neg5cost | Value | negate |
ApplyDamagePenalty | 20 (0x14) | iprp_neg5cost | Value | negate |
ApplyReducedSavingThrows | 20 (0x14) | iprp_neg5cost | Value | none (table holds negatives) |
ApplyDecreasedAC | 20 (0x14) | iprp_neg5cost | Value | negate |
ApplyDecreasedAbilityScore | 21 (0x15) | iprp_neg10cost | Value | negate |
ApplyDecreasedSkillModifier | 21 (0x15) | iprp_neg10cost | Value | negate |
ApplyDamageVulnerability | 22 (0x16) | iprp_damvulcost | Value | – |
ApplyDamageImmunity | dynamic (property.cost_table) | per-property | Value | – |
Handlers that bypass the cost-table dispatch. A surprising number of vanilla handlers do not call GetIPRPCostTable at all and instead consume CostValue (or another property field) directly as the magnitude:
ApplyDamageBonus(coversPropertyName11Damage, 12DamageAlignmentGroup, and 13DamageRacialGroupin one switch) readsCostValuestraight as the damage amount. There is no per-cost 2DA lookup. Theiprp_damagecost.2datable is used for cost calculation (GetCost), not for damage-magnitude resolution.ApplyEnhancementBonusandApplyAttackBonusread(Rules->internal).all_2DAs->iprp_meleecostvia direct struct-field access (not throughGetIPRPCostTable), then read columnValue. Equivalent to a cost-table-index2(iprp_meleecost) dispatch, just inlined.ApplySkillBonusandApplyBonusFeatread the magnitude / feat id from the property struct directly.ApplyImmunityswitches on the subtype id and assigns one of ten hardcoded engine constants; no 2DA is consulted.ApplyRegenerationusesCostValueas the regen amount and a hardcoded6000ms tick interval; no 2DA.
Implications for decoded magnitude resolution. A decoder that resolves property magnitudes should:
- First check whether the property kind is on the cost-table list above; if yes, read the resolved magnitude from the listed cost 2DA at row
CostValue, columnValueorAmount, with the documented post-processing. - If the property kind is on the bypass list, the magnitude is
CostValuedirectly (or, forApplyImmunity, hardcoded per subtype). - For
ApplyDamageImmunity, the cost-table index is read from the property’s ownCostTablefield rather than being hardcoded per handler; mod-extended cost tables resolve through the same path.
Vanilla itempropdef.2da Label Reference
The following table lists every label in the vanilla K1 itempropdef.2da. The decoder in rakata_generics::decoded matches on the Label column at the row indexed by UtiProperty::property_name. The Subtype 2DA column is the file’s SubTypeResRef cell verbatim (lowercased per the engine’s case-insensitive resref handling); an empty cell means the property has no subtype dimension.
The four rows the engine treats as active (loaded into the per-character usable-ability table per IsFriendlyUsableItem) are marked. Every other row is passive.
| Row | Label | Subtype 2DA | Notes |
|---|---|---|---|
| 0 | Ability | iprp_abilities | |
| 1 | Armor | – | AC base bonus |
| 2 | ArmorAlignmentGroup | iprp_aligngrp | |
| 3 | ArmorDamageType | iprp_combatdam | |
| 4 | ArmorRacialGroup | racialtypes | |
| 5 | Enhancement | – | Enhancement bonus to weapons |
| 6 | EnhancementAlignmentGroup | iprp_aligngrp | |
| 7 | EnhancementRacialGroup | racialtypes | |
| 8 | AttackPenalty | – | |
| 9 | BonusFeats | feat | |
| 10 | CastSpell | spells | active |
| 11 | Damage | iprp_damagetype | |
| 12 | DamageAlignmentGroup | iprp_aligngrp | |
| 13 | DamageRacialGroup | racialtypes | |
| 14 | DamageImmunity | iprp_damagetype | |
| 15 | DamagePenalty | – | |
| 16 | DamageReduced | iprp_protection | |
| 17 | DamageResist | iprp_damagetype | |
| 18 | Damage_Vulnerability | iprp_damagetype | |
| 19 | DecreaseAbilityScore | iprp_abilities | |
| 20 | DecreaseAC | iprp_acmodtype | |
| 21 | DecreasedSkill | skills | |
| 22 | DamageMelee | iprp_combatdam | |
| 23 | DamageRanged | iprp_combatdam | |
| 24 | Immunity | iprp_immunity | |
| 25 | ImprovedMagicResist | – | |
| 26 | ImprovedSavingThrows | iprp_saveelement | |
| 27 | ImprovedSavingThrowsSpecific | iprp_savingthrow | |
| 28 | Keen | – | |
| 29 | Light | – | |
| 30 | Mighty | – | |
| 31 | DamageNone | – | |
| 32 | OnHit | iprp_onhit | |
| 33 | ReducedSavingThrows | iprp_saveelement | |
| 34 | ReducedSpecificSavingThrow | iprp_savingthrow | |
| 35 | Regeneration | – | |
| 36 | Skill | skills | |
| 37 | ThievesTools | – | active |
| 38 | AttackBonus | – | |
| 39 | AttackBonusAlignmentGroup | iprp_aligngrp | |
| 40 | AttackBonusRacialGroup | racialtypes | |
| 41 | ToHitPenalty | – | |
| 42 | UnlimitedAmmo | iprp_ammotype | |
| 43 | UseLimitationAlignmentGroup | iprp_aligngrp | |
| 44 | UseLimitationClass | classes | |
| 45 | UseLimitationRacial | racialtypes | |
| 46 | Trap | traps | active |
| 47 | True_Seeing | – | |
| 48 | OnMonsterHit | iprp_monsterhit | |
| 49 | Massive_Criticals | – | |
| 50 | Freedom_of_Movement | – | |
| 51 | Monster_damage | – | |
| 52 | Special_Walk | iprp_walk | |
| 53 | Computer_Spike | – | active |
| 54 | Regeneration_Force_Points | – | |
| 55 | Blaster_Bolt_Deflect_Increase | – | |
| 56 | Blaster_Bolt_Defect_Decrease | – | Vanilla typo (Defect not Deflect); decoder must match the file spelling exactly. |
| 57 | Use_Limitation_Feat | feat | |
| 58 | Droid_Repair_Kit | – | |
| 59 | Disguise | appearance |
Mod content extends this table with rows past index 59. The decoder’s typed-variant dispatch matches by Label, so a mod-added kind surfaces as DecodedProperty::Unknown { property_label: Some("ModLabel"), .. } instead of as a dispatch hole.
Legacy & Ignored Data
| Finding Type | Explanation |
|---|---|
| Superseded Legacy Fields | Directly supplying static Cost or BodyVariation values is a byproduct of older file versions; these remain inherently unused overhead compared to the physical runtime 2DA evaluation. |
| Passive Legacy Artifacts | General nodes left over from older tools (like TemplateResRef, Comment, PaletteID, and explicitly UpgradeLevel) are bypassed on load entirely. |
| Cross-Format Dead Fields | The container item loader also reads Repos_PosX/Repos_Posy per contained item, same as the store side documented on the UTM page, and discards the result immediately; no writer for either field turned up anywhere in the item or container save code either. |
Implemented Linter Rules (Rakata-Lint)
Phase 1 (intra-resource, no context)
Implemented under rakata_lint::rules::uti.
- UTI-001 (Model Truncation Safety): Warns when
ModelVariation == 0; the engine forces this to 1 at runtime. - UTI-002 (Dead Cost Fields): Informs when
Costis set; the engine ignores this and computes item cost dynamically. - UTI-003 (Dead Body Overrides): Informs when
BodyVariationis set; the engine queriesbaseitems.2dainstead. - UTI-004 (Toolset-Only Fields): Informs when any of
TemplateResRef,Comment,PaletteID, orUpgradeLevelare set; never read by the K1 engine. - UTI-005 (Conditional TextureVar): Informs when
TextureVaris set; only evaluated if the base item’s 2DAmodel_typeis exactly 1.
Phase 2 (range / 2DA, requires LintContext)
Implemented under rakata_lint::rules::uti_range.
- UTI-006 (Base Item Bounds): Errors when
BaseItemdoes not resolve to a row inbaseitems.2da(or is negative); the engine indexes the table directly to look up model type, equip slot, and weapon class – an invalid id either crashes the load or produces a corrupt item. - UTI-007 (Valid Capability Bounds): Errors per
PropertiesListentry whenPropertyNamedoes not resolve to a row initempropdef.2da, or whenSubtypedoes not resolve to a row in the per-propertyiprp_*.2danamed byitempropdef[PropertyName].SubTypeResRef(skipped when the row has noSubTypeResRef, i.e. the property kind has no subtype dimension).UpgradeTypeandUsesPerDayuse the engine’s0xFF“not set” sentinel; both the absent-field and explicit-0xFFforms decode as “not set” and the rule does not flag either.
Pending
- Resref Existence: UTI’s only ResRef field is the toolset-only
TemplateResRef(never read by the engine), so the per-format resref-existence rule from B6 was deliberately omitted.