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

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 of swkotor.exe (K1 GOG build), cross-checked against a full K1 install’s vanilla .uti corpus. The tables below are lookup surfaces, meant to be searched rather than read start to end.

At a Glance

PropertyValue
Extension(s).uti
Magic SignatureUTI / V3.2
TypeItem Blueprint
Rust ReferenceView 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. Cost is recomputed from the properties. BodyVariation is overwritten from baseitems.2da. Neither reaches an engine decision.
  • The tables load once, at startup. A mod’s baseitems.2da takes effect when the game launches, not when an item loads, so nothing about a .uti read consults the file on disk.
  • Nothing is hardcoded, so mods extend the vocabulary. Property kinds come from a Label column 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.

CategoryCoversRepresentative fields
Core IdentityThe item’s name and description, in both identified and unidentified statesTemplateResRef, LocName, Description
Economic & Charge MechanicsThe item’s value and the charges left for consumable abilitiesCost, Charges
Visual GeometryWhat the item looks like when dropped on the floor or equippedModelVariation, TextureVar
Combat & Upgrade PropertiesThe stat buffs, damage modifiers, and abilities bound to the item, plus workbench upgrade slotsPropertiesList

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

FunctionBehaviour
LoadDataFromGffThe 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.
LoadItemThe constructor that decides whether to load the item onto a character or leave it idle in an inventory.
LoadFromTemplateA fallback used when spawning an item dynamically from a script rather than off a character.
SaveItem / SaveItemPropertiesThe write path. Forces the item to be flagged as identified; see below.

Rules the engine enforces

RuleRuntime behaviour
Description cross-swapIf either Description or DescIdentified is missing, the engine duplicates the provided string into the missing one.
Charge fallback chainingMaxCharges 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 gatingProvenance: 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 fieldsSaveItem 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 fallbackThe 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 capabilitiesProperties 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 kindsThere 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 defaultsAn 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 enforcementSaveItem 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.

ColumnSupplies
ModelTypeThe model type the page calls model_type. Same name exactly.
EquipableSlotsWhich slots the item can occupy.
BodyVarThe body_var the item ends up with, applied only under model_type == 1.
BaseACThe item’s base_ac, also gated on model_type == 1; forced to 0 otherwise.
ContainerWhether 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 valueFields
30BaseItem
emptyLocalizedName, Tag
1StackSize
falsePlot, Stolen, NonEquippable, NewItem, DELETING
0AddCost, 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:

Step2DAIndexed byColumn readPurpose
1itempropdef.2daPropertyNameName (INT)TLK strref for the property’s display name, for example “Damage Bonus”.
2itempropdef.2daPropertyNameSubTypeResRef (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)SubtypeName (INT)TLK strref for the subtype’s display name, for example “Acid”.

Cost-table dispatch, resolved eagerly at startup inside LoadIPRPCostTables at 0x005c4730:

Step2DAIndexed byColumn readPurpose
1iprp_costtable.2daCostTableName (string)Resref of the cost-specific 2DA, for example iprp_meleecost. Used as a resref despite the column name suggesting a label.
2iprp_costtable.2daCostTableClientLoad (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:

Step2DAIndexed byColumn readPurpose
1iprp_paramtable.2daParam1TableResRef (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.2da and iprp_paramtable.2da row counts are stored as a byte (u8) in CTwoDimArrays. 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-insensitive at the engine API, unlike GFF’s own case-sensitive field-label lookup. C2DA::GetINTEntry and GetCExoStringEntry both resolve through a shared GetColumnIndex that 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 _strlwr pass 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 are Name, SubTypeResRef, TableResRef, Label and ClientLoad, but any casing resolves identically.
  • The subtype 2DA named in SubTypeResRef is loaded lazily on display via GetPropertyStrings, not eagerly at startup. A missing subtype 2DA fails only the call that needs it, not the whole game load.
  • The Name column at every level of the dispatch is a TLK strref. The Label column on the same row holds a developer-readable identifier, for example Damage_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:

IndexName (resref of per-cost 2DA)LabelClientLoad
0IPRP_BASE1Base10
1IPRP_BONUSCOSTBonus0
2IPRP_MELEECOSTMelee1
3IPRP_CHARGECOSTSpellUse0
4IPRP_DAMAGECOSTDamage0
5IPRP_IMMUNCOSTImmune0
6IPRP_SOAKCOSTDamageSoak0
7IPRP_RESISTCOSTDamageResist0
8IPRP_BLADECOSTDancingScimitar0
9IPRP_SLOTSCOSTSlots0
10IPRP_WEIGHTCOSTWeight0
11IPRP_SRCOSTSpellResist0
12IPRP_STAMINACOSTStamina0
13IPRP_SPELLLVCOSTSpellLevel0
14IPRP_AMMOCOSTAmmo0
15IPRP_REDCOSTWeightReduction0
16IPRP_SPELLCOSTSpells0
17IPRP_TRAPCOSTTraps0
18IPRP_LIGHTCOSTLight1
19IPRP_MONSTCOSTMonster_Cost0
20IPRP_NEG5COSTNegative_Modifiers0
21IPRP_NEG10COSTNegative_Modifiers0
22IPRP_DAMVULCOSTDamage_vulnerability0
23IPRP_SPELLLVLIMMSpell_Level_Immunity0
24IPRP_ONHITCOSTOnHitCosts0
25IPRP_ONHITDCOnHitDC_saves0

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.

HandlerCostTable indexPer-cost 2DAColumnPost-processing
ApplyAbilityBonus1iprp_bonuscostValue
ApplyACBonus1iprp_bonuscostValue
ApplyImprovedSavingThrow1iprp_bonuscostValue
ApplyDamageReduction6iprp_soakcostAmount
ApplyDamageResistance7iprp_resistcostAmount
ApplyImprovedForceResistance11 (0xB)iprp_srcostValue
ApplyAttackPenalty20 (0x14)iprp_neg5costValuenegate
ApplyDamagePenalty20 (0x14)iprp_neg5costValuenegate
ApplyReducedSavingThrows20 (0x14)iprp_neg5costValuenone; the table holds negatives
ApplyDecreasedAC20 (0x14)iprp_neg5costValuenegate
ApplyDecreasedAbilityScore21 (0x15)iprp_neg10costValuenegate
ApplyDecreasedSkillModifier21 (0x15)iprp_neg10costValuenegate
ApplyDamageVulnerability22 (0x16)iprp_damvulcostValue
ApplyDamageImmunitydynamic (property.cost_table)per-propertyValue

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, covering PropertyName 11 Damage, 12 DamageAlignmentGroup and 13 DamageRacialGroup in one switch, reads CostValue straight as the damage amount. There is no per-cost 2DA lookup. iprp_damagecost.2da is used for cost calculation in GetCost, not for damage-magnitude resolution.
  • ApplyEnhancementBonus and ApplyAttackBonus read (Rules->internal).all_2DAs->iprp_meleecost by direct struct-field access rather than through GetIPRPCostTable, then read column Value. Equivalent to a cost-table index 2 dispatch, inlined.
  • ApplySkillBonus and ApplyBonusFeat read the magnitude or feat id from the property struct directly.
  • ApplyImmunity switches on the subtype id and assigns a hardcoded engine constant per subtype; no 2DA is consulted.
  • ApplyRegeneration uses CostValue as the regen amount and a hardcoded 6000 ms tick interval; no 2DA.

What a decoder should do. Resolving a property magnitude takes three steps:

  1. If the property kind is on the cost-table list above, read the magnitude from the listed cost 2DA at row CostValue, column Value or Amount, applying the documented post-processing.
  2. If it is on the bypass list, the magnitude is CostValue directly, or for ApplyImmunity a hardcoded constant per subtype.
  3. For ApplyDamageImmunity, read the cost-table index from the property’s own CostTable field 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.

RowLabelSubtype 2DANotes
0Abilityiprp_abilities
1ArmorAC base bonus
2ArmorAlignmentGroupiprp_aligngrp
3ArmorDamageTypeiprp_combatdam
4ArmorRacialGroupracialtypes
5EnhancementEnhancement bonus to weapons
6EnhancementAlignmentGroupiprp_aligngrp
7EnhancementRacialGroupracialtypes
8AttackPenalty
9BonusFeatsfeat
10CastSpellspellsactive
11Damageiprp_damagetype
12DamageAlignmentGroupiprp_aligngrp
13DamageRacialGroupracialtypes
14DamageImmunityiprp_damagetype
15DamagePenalty
16DamageReducediprp_protection
17DamageResistiprp_damagetype
18Damage_Vulnerabilityiprp_damagetype
19DecreaseAbilityScoreiprp_abilities
20DecreaseACiprp_acmodtype
21DecreasedSkillskills
22DamageMeleeiprp_combatdam
23DamageRangediprp_combatdam
24Immunityiprp_immunity
25ImprovedMagicResist
26ImprovedSavingThrowsiprp_saveelement
27ImprovedSavingThrowsSpecificiprp_savingthrow
28Keen
29Light
30Mighty
31DamageNone
32OnHitiprp_onhit
33ReducedSavingThrowsiprp_saveelement
34ReducedSpecificSavingThrowiprp_savingthrow
35Regeneration
36Skillskills
37ThievesToolsactive
38AttackBonus
39AttackBonusAlignmentGroupiprp_aligngrp
40AttackBonusRacialGroupracialtypes
41ToHitPenalty
42UnlimitedAmmoiprp_ammotype
43UseLimitationAlignmentGroupiprp_aligngrp
44UseLimitationClassclasses
45UseLimitationRacialracialtypes
46Traptrapsactive
47True_Seeing
48OnMonsterHitiprp_monsterhit
49Massive_Criticals
50Freedom_of_Movement
51Monster_damage
52Special_Walkiprp_walk
53Computer_Spikeactive
54Regeneration_Force_Points
55Blaster_Bolt_Deflect_Increase
56Blaster_Bolt_Defect_DecreaseVanilla typo, Defect not Deflect; a decoder must match the file spelling exactly.
57Use_Limitation_Featfeat
58Droid_Repair_Kit
59Disguiseappearance

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 typeExplanation
Superseded legacy fieldsA static Cost or BodyVariation in the file is a byproduct of older file versions. The runtime 2DA evaluation supersedes both.
Passive legacy artifactsNodes left over from older tools, TemplateResRef, Comment, PaletteID and UpgradeLevel, are bypassed on load entirely.
Cross-format dead fieldsThe 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:

RuleLevelFires when
UTI-001 Model truncation safetyWarnModelVariation == 0; the engine forces this to 1 at runtime.
UTI-002 Dead cost fieldsInfoCost is set; the engine ignores it and computes item cost dynamically. Not an instruction to drop the field; see above.
UTI-003 Dead body overridesInfoBodyVariation is set; the engine queries baseitems.2da instead.
UTI-004 Toolset-only fieldsInfoAny of TemplateResRef, Comment, PaletteID or UpgradeLevel is set; never read by the K1 engine.
UTI-005 Conditional TextureVarInfoTextureVar is set; only evaluated if the base item’s model_type is exactly 1.
UTI-008 Partially-specified propertyErrorA 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 entryErrorA 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:

RuleLevelFires when
UTI-006 Base item boundsErrorBaseItem 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 boundsErrorA 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. WeaponType and WeaponWield are both INT columns in baseitems.2da and both cached independently. No consumer was found mapping either to what this page has informally called weapon class, and CheckProficiencies (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 ItemList is 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 ReadContainerItemsFromGff nor SaveContainerItems was 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

FieldTypeEngineWhen absent
PaletteIDBYTEnever reads it: UTI-004 records this as a toolset-only field bypassed on load entirely and never read by the K1 enginenot one constant; we substitute 0
CommentCExoStringnever reads it: UTI-004 records this as a toolset-only field bypassed on load entirely and never read by the K1 enginenot one constant; we substitute ""
BodyVariationBYTEnever reads it: read by nothing on the item pathnot one constant; we substitute 0
UpgradeLevelBYTEnever reads it: UTI-004 records this as a toolset-only field bypassed on load entirely and never read by the K1 enginenot 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.

FieldTypeWhen absent
TemplateResRefCResRefNOT EXAMINED; we substitute ""
BaseItemINTkeeps 30
LocalizedNameCExoLocStringkeeps empty
DescriptionCExoLocStringkeeps empty
DescIdentifiedCExoLocStringkeeps empty
TagCExoStringkeeps ""
ChargesBYTEstamps 50
CostDWORDNOT EXAMINED; we substitute 0
StackSizeWORDkeeps 1
PlotBYTEkeeps 0
AddCostDWORDkeeps 0
TextureVarBYTEstamps 1
StolenBYTEkeeps 0
IdentifiedBYTEstamps 1
DropableBYTEstamps 0
PickpocketableBYTEstamps 0
NonEquippableBYTEkeeps 0
NewItemBYTEkeeps 0
DELETINGBYTEkeeps 0
UpgradesDWORDkeeps 0
PropertiesListListnot one constant; we substitute container
PropertiesList[].CostTable (required)BYTEwhatever the memory held; we substitute 0
PropertiesList[].CostValue (required)WORDwhatever the memory held; we substitute 0
PropertiesList[].Param1 (required)BYTEwhatever the memory held; we substitute 0
PropertiesList[].Param1Value (required)BYTEwhatever the memory held; we substitute 0
PropertiesList[].PropertyName (required)WORDwhatever the memory held; we substitute 0
PropertiesList[].Subtype (required)WORDwhatever the memory held; we substitute 0
PropertiesList[].ChanceAppear (required)BYTEwhatever the memory held; we substitute 100
PropertiesList[].UseableBYTEnot one constant; the field holds the absence
PropertiesList[].UsesPerDayBYTEstamps 0
PropertiesList[].UpgradeTypeBYTEstamps 0; we keep the absence instead
MaxChargesBYTEnot one constant; our reader works it out from other fields
ModelVariationBYTEnot one constant; our reader works it out from other fields
ModelPart1BYTEnot one constant; our reader works it out from other fields