UTI Format (Item Blueprint)
A .uti file is an item: every weapon, suit of armour, medpac, upgrade
component and piece of loot in the game. It defines how the item appears on a
character, what stat bonuses and abilities it carries, its cost, and how it
behaves when dropped into the world.
UTI is built on GFF (Generic File Format), the engine’s labelled key/value tree. If you have not read the GFF page, start there.
This page documents UTI’s field defaults, load-order quirks, and the item-property dispatch chain into
iprp_*.2da. Evidence is drawn from Ghidra decompilation ofswkotor.exe(K1 GOG build), cross-checked against a full K1 install’s vanilla.uticorpus. The tables below are lookup surfaces, meant to be searched rather than read start to end.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .uti |
| Magic Signature | UTI / V3.2 |
| Type | Item Blueprint |
| Rust Reference | View rakata_generics::Uti in Rustdocs |
The file is mostly indices, not values
Hold this and the rest of the page follows. A .uti says very little about an
item directly. It says which rows of which 2DA tables describe it, and the
tables carry the meaning.
An item’s damage bonus is not a number in the file. It is a PropertyName
indexing itempropdef.2da to get the kind, a Subtype indexing whatever table
that row names, and a CostValue indexing a cost table to get the magnitude.
Change the tables and the same file means something different.
Three consequences run through everything below:
- Some fields in the file are dead because a table supplies the real value.
Costis recomputed from the properties.BodyVariationis overwritten frombaseitems.2da. Neither reaches an engine decision. - The tables load once, at startup. A mod’s
baseitems.2datakes effect when the game launches, not when an item loads, so nothing about a.utiread consults the file on disk. - Nothing is hardcoded, so mods extend the vocabulary. Property kinds come
from a
Labelcolumn rather than a compiled table, so an install with mods has kinds this page does not list.
The one place that model breaks is PropertiesList, where absent fields do not
default at all. That is the sharpest hazard on the page and has
its own section.
Field Schema
The format’s field families, as an orientation before the full list.
| 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 |
Engine Audits & Decompilation
Read 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 and
LoadIPRPParamTables at 0x005c49c0.
(Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue. Claims resting on less than the rest say so inline.)
The load path
| Function | Behaviour |
|---|---|
LoadDataFromGff | The main parser. Sets what the item is, how many charges it holds, its descriptions, and, inlined into the same function rather than 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. |
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 rather than off a character. |
SaveItem / SaveItemProperties | The write path. Forces the item to be flagged as identified; see below. |
Rules the engine enforces
| Rule | Runtime behaviour |
|---|---|
| Description cross-swap | If either Description or DescIdentified is missing, the engine duplicates the provided string into the missing one. |
| Charge fallback chaining | MaxCharges has no default of its own. When missing it reuses whatever Charges just resolved to, either its own value or the constant 50 if Charges was absent too, rather than the item’s prior max-charges. |
| Container contents gating | Provenance: traced. An item’s nested ItemList is read and written only where the base item’s Container column is non-zero. A non-container never has it looked for; a container missing it loads with an empty inner inventory. Both directions use the same gate, below. |
| Dead placement fields | SaveItem unconditionally writes XPosition, YPosition, ZPosition and XOrientation, YOrientation, ZOrientation for every item everywhere. Only CSWSArea::LoadItems reads them back; creature inventories, stores, the party stash and nested containers write them and never look again. |
| Cost generation fallback | The Cost integer in the file is dead data. GetCost() computes value from the item’s properties instead. See below for what a writer should do with it. |
| Property capabilities | Properties split into active and passive tables at load. PropertyName 10, 37, 46 or 53 hooks as a usable player ability; every other value applies as a passive stat modifier. |
| Data-driven property kinds | There is no hardcoded “PropertyName N means kind K” table. Classification comes from the Label column of itempropdef.2da at the row PropertyName indexes, so new rows surface as new kinds with no engine change. |
| Property field defaults | An omitted PropertiesList field is filled from a fixed table: Useable is 1 for active properties and 0 for passive, while UsesPerDay and UpgradeType are both 0xFF, which act as “not set” sentinels rather than row indices. |
| Identifier enforcement | SaveItem forces Identified to 1 unconditionally. Cross-checked against real items from two vanilla saves, area loot and a late-game party stash: every one came back identified. |
ModelVariation falls back through ModelPart1, then gets bumped off zero
A ModelVariation of 0 is forced to 1 on load, so an item always has
visible geometry rather than rendering as nothing.
Where ModelVariation is absent entirely, the read falls back to the older
ModelPart1 label before the same zero-check applies, so ancient files get
the same protection.
ModelPart1’s own absent default is not a literal. Its fallback argument is
whatever the already-failed ModelVariation read produced, which is the item’s
prior model_variation. Two carry-overs chained, and only then the bump to 1.
ModelPart2 and ModelPart3 are read nowhere. Neither field-name string
exists in the binary. Files carrying all three are following authoring-tool
habit; only ModelPart1 reaches the engine.
Cost is dead, and you should still write it
GetCost() derives an item’s value from its properties, so the integer in the
file reaches no engine decision. That makes it a
recomputed-at-load field
rather than one a writer may drop.
Every other toolset in the ecosystem displays the file’s copy, because deriving
the number means walking the property list and the cost tables. An item written
with no Cost reads as worthless everywhere except in the game itself, and a
modder comparing two items in an editor has nothing to compare.
So compute it if you can and preserve it if you cannot. UTI-002 reporting that
Cost is populated tells you the engine will not read it, which is worth
knowing and is not an instruction to remove it.
BodyVariation is dead and TextureVar is gated
BodyVariation is dead by the strongest test available: no ReadField* or
GetFieldByLabel call for the label exists anywhere in LoadDataFromGff,
confirmed by reading the function in full. It is not “read and ignored” the way
some legacy fields are. The string appears only in SaveItem, serializing a
value that came from elsewhere.
The item’s body_var member is populated from baseitems.2da instead, in the
model_type == 1 block that also gates TextureVar, and that overwrites
whatever a .uti supplies. TextureVar is bypassed entirely unless the base
type is model type 1.
The baseitems.2da columns, and when they are read
None of them are read while a .uti loads. CSWBaseItemArray::Load
(0x005b31d0) reads the whole table into a cache once at startup, and
CSWSItem::LoadDataFromGff (0x0055fcd0) consults that cache. So a
baseitems.2da shipped by a mod takes effect at launch rather than per item.
| Column | Supplies |
|---|---|
ModelType | The model type the page calls model_type. Same name exactly. |
EquipableSlots | Which slots the item can occupy. |
BodyVar | The body_var the item ends up with, applied only under model_type == 1. |
BaseAC | The item’s base_ac, also gated on model_type == 1; forced to 0 otherwise. |
Container | Whether the item holds a nested ItemList. A dedicated column, not derived from ModelType or an EquipableSlots mask. |
BodyVar is a string column rather than an integer. The cache loader
uppercases it, and where the value is a single letter A through J it becomes
that letter’s offset from A, plus one. Anything else defaults to 1. All of
that happens in the cache loader, not at read time.
Container gates on non-zero rather than on a particular value. Both
consumers test the cached field the same way, so any non-zero entry turns the
behaviour on.
No base item in a retail install is a container. Every populated row of
baseitems.2da carries 0, and the one row that does not is a placeholder with
an empty label and almost no cells filled at all. So an item’s nested ItemList
is code the engine reaches and vanilla data never triggers: nothing shipped
exercises the non-zero branch, and what the engine does with an item that
declares contents is untested by shipped content. Containers a player opens are
placeables, and their contents hang off the placeable rather than off an item.
Measured over baseitems.2da as read from the vanilla install’s chitin.key,
by two independent paths through the table reader.
The read and write gates are the same condition, and that is a result rather
than an assumption. CSWSItem::LoadDataFromGff consults the cache and hands
off to ReadContainerItemsFromGff (0x0055f0f0) where Container is non-zero;
CSWSItem::SaveItem (0x0055ccd0) makes the identical test off the identical
lookup before handing off to SaveContainerItems (0x0055cfa0). They were
checked against each other because a divergence would have meant an item could
be written carrying contents its own loader then silently drops. There is no
divergence.
Provenance: traced, per call site. The list transfer behind either handoff was not followed; the question was the gate.
Dropable and Pickpocketable cannot carry their constructed value
Dropable sets bit 3 of the item’s flag word and Pickpocketable sets bit 4.
CSWSItem’s constructor turns both bits on, but each read is a hardcoded
literal 0 with no presence check, so it overwrites that true back to false
on every load whether the field is present or not. Neither ever carries the
constructed default forward.
An absent Identified means identified
Identified defaults to a hardcoded 1, stamped unconditionally with no
presence check. That is a different mechanism from most of the item’s booleans,
which carry over a constructed value.
There is a second override on top: once the property list finishes loading, an
item with no active and no passive properties has the identified bit forced back
to 1 regardless of the file. An explicit Identified = 0 on a
property-less item is overwritten.
The ordinary carry-overs
The remaining top-level fields take the object’s current member as the read’s fallback:
| Constructed value | Fields |
|---|---|
30 | BaseItem |
| empty | LocalizedName, Tag |
1 | StackSize |
false | Plot, Stolen, NonEquippable, NewItem, DELETING |
0 | AddCost, Upgrades |
TextureVar is the exception: where it is consulted at all, it reads with an
unconditional hardcoded literal 1.
PropertiesList scalars hold uninitialized memory, not zero
This is the one place where “absent” does not resolve to anything predictable.
An absent property field does not read as 0. PropertyName, Subtype,
CostTable, CostValue, Param1, Param1Value and ChanceAppear are each
read with a literal 0 passed to the read call, but that 0 is never committed
when the field is absent. Every one of those writes is separately gated on its
own presence flag, checked right before the struct member is set.
The property array comes from a raw unzeroed allocator, so a missing field on an otherwise-populated entry leaves that member holding whatever was already in that heap memory.
That is a correctness hazard rather than a nuance. A linter or decoder assuming absent-reads-as-zero is wrong for every one of them, and should flag a partially-specified property entry as producing undefined values.
PropertyName is read with a 16-bit WORD read, same as Subtype and
CostValue, not a 32-bit INT.
Useable, UsesPerDay and UpgradeType are the exception. Those three are
stamped unconditionally with no presence gate on the write, so they are the only
fields in a PropertiesList entry that are deterministic when absent.
An entirely empty entry leaves a hole rather than dropping cleanly. If an entry’s own struct carries no fields at all, the counting pass folds it into the passive tally, but the populate pass then skips writing it once it reaches that entry. The array was sized assuming the entry would be populated, so the slot it would have occupied is left as the same uninitialized heap memory.
Property table dispatch
A UtiProperty carries three indices pointing 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, for example “Damage Bonus”. |
| 2 | itempropdef.2da | PropertyName | SubTypeResRef (string) | Resref of the per-property subtype 2DA, for example iprp_damagecost. Empty or 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, for example “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, for example 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; column layout varies per param table. |
Constraints on the dispatch
- Both
iprp_costtable.2daandiprp_paramtable.2darow counts are stored as abyte(u8) inCTwoDimArrays. Rows past index255are silently truncated by the loader and the affected per-property tables never get loaded into memory. - Column-name lookups in 2DAs are case-insensitive at the engine API, unlike
GFF’s own case-sensitive field-label lookup.
C2DA::GetINTEntryandGetCExoStringEntryboth resolve through a sharedGetColumnIndexthat compares case-insensitively, whether the table loaded from the binary (V2.b) or text (V2.0) path. That is also why the text loader’s_strlwrpass over column headers never causes a mismatch: a mixed-case lookup key matches a lowercased stored header or a verbatim-cased one either way. The conventional spellings the engine’s own callers use areName,SubTypeResRef,TableResRef,LabelandClientLoad, but any casing resolves identically. - The subtype 2DA named 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 at every level of the dispatch is a TLK strref. TheLabelcolumn on the same row holds a developer-readable identifier, for exampleDamage_Bonus, that needs no talktable resolution.
Cost-table magnitude resolution
The dispatch chain above ends at “the row at CostValue carries the cost effect;
column layout varies per cost table”. This section pins down that layout for
vanilla K1 and how each Apply<PropertyKind> handler reads it, sourced from the
CSWSItemPropertyHandler::Apply* family (handlers cluster around
0x004e5490-0x004e7e80 and 0x004e9230-0x004e9390).
iprp_costtable.2da (vanilla K1), index to per-cost 2DA:
| 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 whatever units suit the property kind:
bonus number, damage soak amount, save delta.
The column name is read case-sensitively here, and the only 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; the 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. Many vanilla handlers never
call GetIPRPCostTable and instead consume CostValue, or another property
field, directly as the magnitude:
ApplyDamageBonus, coveringPropertyName11Damage,12DamageAlignmentGroupand13DamageRacialGroupin one switch, readsCostValuestraight as the damage amount. There is no per-cost 2DA lookup.iprp_damagecost.2dais used for cost calculation inGetCost, not for damage-magnitude resolution.ApplyEnhancementBonusandApplyAttackBonusread(Rules->internal).all_2DAs->iprp_meleecostby direct struct-field access rather than throughGetIPRPCostTable, then read columnValue. Equivalent to a cost-table index2dispatch, inlined.ApplySkillBonusandApplyBonusFeatread the magnitude or feat id from the property struct directly.ApplyImmunityswitches on the subtype id and assigns a hardcoded engine constant per subtype; no 2DA is consulted.ApplyRegenerationusesCostValueas the regen amount and a hardcoded6000ms tick interval; no 2DA.
What a decoder should do. Resolving a property magnitude takes three steps:
- If the property kind is on the cost-table list above, read the magnitude from
the listed cost 2DA at row
CostValue, columnValueorAmount, applying the documented post-processing. - If it is on the bypass list, the magnitude is
CostValuedirectly, or forApplyImmunitya hardcoded constant per subtype. - For
ApplyDamageImmunity, read the cost-table index from the property’s ownCostTablefield rather than hardcoding it per handler. Mod-extended cost tables resolve through the same path.
Vanilla itempropdef.2da label reference
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 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; a decoder must match the file spelling exactly. | |
| 57 | Use_Limitation_Feat | feat | |
| 58 | Droid_Repair_Kit | ||
| 59 | Disguise | appearance |
Mod content extends this table past the last vanilla row, and the added rows
carry labels this list does not have. A reader dispatching on the numeric index
therefore meets kinds it has no case for, on any install with mods. Matching on
Label and carrying an explicit unknown case, one that keeps the label it could
not place, turns that from a dispatch hole into an ordinary value.
Fields the engine never reads
A field the engine never reads is not automatically one you may leave out; the toolset and other mod tools read these files too. See “the engine ignores this” is not “you may leave it out”.
| Finding type | Explanation |
|---|---|
| Superseded legacy fields | A static Cost or BodyVariation in the file is a byproduct of older file versions. The runtime 2DA evaluation supersedes both. |
| Passive legacy artifacts | Nodes left over from older tools, TemplateResRef, Comment, PaletteID and UpgradeLevel, are bypassed on load entirely. |
| Cross-format dead fields | The container item loader reads Repos_PosX and Repos_Posy per contained item, the same as the store side documented on UTM, and discards the result immediately. No writer for either field turned up anywhere in the item or container save code. |
Implemented Linter Rules (Rakata-Lint)
Intra-resource rules needing no context, under rakata_lint::rules::uti:
| Rule | Level | Fires when |
|---|---|---|
| UTI-001 Model truncation safety | Warn | ModelVariation == 0; the engine forces this to 1 at runtime. |
| UTI-002 Dead cost fields | Info | Cost is set; the engine ignores it and computes item cost dynamically. Not an instruction to drop the field; see above. |
| UTI-003 Dead body overrides | Info | BodyVariation is set; the engine queries baseitems.2da instead. |
| UTI-004 Toolset-only fields | Info | Any of TemplateResRef, Comment, PaletteID or UpgradeLevel is set; never read by the K1 engine. |
UTI-005 Conditional TextureVar | Info | TextureVar is set; only evaluated if the base item’s model_type is exactly 1. |
| UTI-008 Partially-specified property | Error | A PropertiesList entry carries at least one field but omits any of the gated scalars. Each omitted member keeps whatever the heap held, so the value the engine uses is not in the file, is not any particular number, and need not repeat between loads. Distinct from SCHEMA-002, which also fires here and reports the omission rather than the consequence. |
| UTI-009 Empty property entry | Error | A PropertiesList entry’s struct carries no fields at all. The counting pass includes it when sizing the property array and the populate pass then skips it, so the slot is left as uninitialized memory instead of the entry being dropped. |
UTI-008 and UTI-009 read the raw GFF tree rather than the typed view, because
both trigger on absent labels and a typed UtiProperty has already had values
substituted. Neither fires on any PropertiesList entry in a retail install:
the toolset that wrote them always fills the fields in, so a hand-edited or
generated file is the only way to reach either.
Range and 2DA rules requiring a LintContext, under
rakata_lint::rules::uti_range:
| Rule | Level | Fires when |
|---|---|---|
| UTI-006 Base item bounds | Error | BaseItem does not resolve to a row in baseitems.2da, or is negative. The engine indexes the cached table directly for ModelType, EquipableSlots, BodyVar and BaseAC, so an invalid id either crashes the load or produces a corrupt item. |
| UTI-007 Valid capability bounds | Error | A PropertiesList entry’s PropertyName does not resolve to a row in itempropdef.2da, or its Subtype does not resolve to a row in the per-property iprp_*.2da named by itempropdef[PropertyName].SubTypeResRef. Skipped where that row has no SubTypeResRef, meaning the property kind has no subtype dimension. UpgradeType and UsesPerDay use the engine’s 0xFF “not set” sentinel; both the absent-field and explicit-0xFF forms decode as “not set” and the rule flags neither. |
No resref-existence rule. UTI’s only ResRef field is the toolset-only
TemplateResRef, which the engine never reads, so there is nothing for the
per-format resref rule to check.
Open questions
- Which column carries the weapon class.
WeaponTypeandWeaponWieldare both INT columns inbaseitems.2daand both cached independently. No consumer was found mapping either to what this page has informally called weapon class, andCheckProficiencies(0x00510e30) uses a separately cached required-feat array rather than either of them. Picking between them is a naming decision rather than an open trace. - Which column drives the container gate. The gate itself is traced and the
read and write sides agree, but the column behind it has not been followed. So
ItemListis documented by what gates it rather than by which column does the gating. - What the engine does with a container item. No base item in a retail install is a container, so the non-zero branch is code nothing shipped exercises. The behaviour is read from the call sites rather than observed.
- The list transfer behind the container handoff. Traced per call site, but
neither
ReadContainerItemsFromGffnorSaveContainerItemswas followed into its body; the question at the time was the gate.
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 |
|---|---|---|---|
PaletteID | BYTE | never reads it: UTI-004 records this as a toolset-only field bypassed on load entirely and never read by the K1 engine | not one constant; we substitute 0 |
Comment | CExoString | never reads it: UTI-004 records this as a toolset-only field bypassed on load entirely and never read by the K1 engine | not one constant; we substitute "" |
BodyVariation | BYTE | never reads it: read by nothing on the item path | not one constant; we substitute 0 |
UpgradeLevel | BYTE | never reads it: UTI-004 records this as a toolset-only field bypassed on load entirely and never read by the K1 engine | 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 |
|---|---|---|
TemplateResRef | CResRef | NOT EXAMINED; we substitute "" |
BaseItem | INT | keeps 30 |
LocalizedName | CExoLocString | keeps empty |
Description | CExoLocString | keeps empty |
DescIdentified | CExoLocString | keeps empty |
Tag | CExoString | keeps "" |
Charges | BYTE | stamps 50 |
Cost | DWORD | NOT EXAMINED; we substitute 0 |
StackSize | WORD | keeps 1 |
Plot | BYTE | keeps 0 |
AddCost | DWORD | keeps 0 |
TextureVar | BYTE | stamps 1 |
Stolen | BYTE | keeps 0 |
Identified | BYTE | stamps 1 |
Dropable | BYTE | stamps 0 |
Pickpocketable | BYTE | stamps 0 |
NonEquippable | BYTE | keeps 0 |
NewItem | BYTE | keeps 0 |
DELETING | BYTE | keeps 0 |
Upgrades | DWORD | keeps 0 |
PropertiesList | List | not one constant; we substitute container |
PropertiesList[].CostTable (required) | BYTE | whatever the memory held; we substitute 0 |
PropertiesList[].CostValue (required) | WORD | whatever the memory held; we substitute 0 |
PropertiesList[].Param1 (required) | BYTE | whatever the memory held; we substitute 0 |
PropertiesList[].Param1Value (required) | BYTE | whatever the memory held; we substitute 0 |
PropertiesList[].PropertyName (required) | WORD | whatever the memory held; we substitute 0 |
PropertiesList[].Subtype (required) | WORD | whatever the memory held; we substitute 0 |
PropertiesList[].ChanceAppear (required) | BYTE | whatever the memory held; we substitute 100 |
PropertiesList[].Useable | BYTE | not one constant; the field holds the absence |
PropertiesList[].UsesPerDay | BYTE | stamps 0 |
PropertiesList[].UpgradeType | BYTE | stamps 0; we keep the absence instead |
MaxCharges | BYTE | not one constant; our reader works it out from other fields |
ModelVariation | BYTE | not one constant; our reader works it out from other fields |
ModelPart1 | BYTE | not one constant; our reader works it out from other fields |