Skip to main content

rakata_generics/
uti.rs

1//! UTI (`.uti`) typed generic wrapper.
2//!
3//! Items cover all loot, weapons, armour and usable gear: how the item appears
4//! on a character, the properties and stat bonuses it applies, its cost, and
5//! how it behaves once dropped into the world.
6//!
7//! ## Scope
8//! - Typed access for all engine-read and toolset item fields.
9//! - Typed handling for `PropertiesList` item-property entries.
10//!
11//! ## Field Layout (simplified)
12//! ```text
13//! UTI root struct
14//! +-- TemplateResRef / Tag / Comment
15//! +-- BaseItem / Charges / Cost / StackSize / AddCost
16//! +-- MaxCharges / Upgrades
17//! +-- LocalizedName / Description / DescIdentified
18//! +-- ModelVariation / BodyVariation / TextureVar
19//! +-- Plot / Stolen / Identified / UpgradeLevel
20//! +-- Dropable / Pickpocketable / NonEquippable / NewItem / DELETING
21//! `-- PropertiesList                 (List<Struct>)
22//!     +-- PropertyName / Subtype
23//!     +-- CostTable / CostValue
24//!     +-- Param1 / Param1Value
25//!     +-- ChanceAppear
26//!     `-- UpgradeType (optional)
27//! ```
28//!
29//! ## Two fields our reader works out from a sibling
30//!
31//! `MaxCharges` falls back to `Charges` and `ModelVariation` to `ModelPart1`.
32//! Their entries record that a value is produced without modelling how, for
33//! the reason the engine side already refuses the same thing: a derivation in
34//! a static table would be a small expression language serving two fields.
35//! Both derivations are in [`Uti::from_gff`], where they read as code.
36
37use std::io::{Cursor, Read, Write};
38
39use rakata_core::ResRef;
40use rakata_formats::gff::{get_u8, upsert_field};
41use rakata_formats::gff_label;
42use rakata_formats::schema::FromGff;
43use rakata_formats::GENERIC_FILE_TYPE;
44use rakata_formats::{
45    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffModel,
46    GffStruct, GffValue,
47};
48use thiserror::Error;
49
50/// Returns `true` when `base_item` belongs to the canonical armor base-item set.
51///
52/// Derived from the base-item classification in `baseitems.2da` (KotOR I/II).
53/// PyKotor arrives at the same set independently, which is a useful check on
54/// the reading but not where these values come from.
55pub const fn is_armor_base_item(base_item: i32) -> bool {
56    match base_item {
57        // Contiguous core armor block.
58        35..=43 => true,
59        // Non-contiguous armor-classified base items.
60        53 | 58 | 63..=65 | 69 | 71 | 85 | 89 | 98 | 100 | 102 | 103 => true,
61        _ => false,
62    }
63}
64
65/// Typed UTI model built from/to [`Gff`] data.
66///
67/// `ModelPart1` is declared without a member: it is the older spelling
68/// `ModelVariation` falls back to, so the value lands in that field and the
69/// label still has to be recognised.
70#[derive(Debug, Clone, PartialEq, GffModel)]
71#[gff_entry(
72    ModelPart1,
73    wire = u8,
74    not_a_constant,
75    from_siblings
76)]
77pub struct Uti {
78    /// `TemplateResRef`. Never read by the K1 engine.
79    #[gff(TemplateResRef, unexamined)]
80    pub template_resref: ResRef,
81    /// Base item identifier (`BaseItem`).
82    #[gff(BaseItem, constructed = 30)]
83    pub base_item: i32,
84    /// Localized item name (`LocalizedName`).
85    #[gff(LocalizedName, constructed)]
86    pub name: GffLocalizedString,
87    /// Localized description shown before identification (`Description`).
88    #[gff(Description, constructed)]
89    pub description_unidentified: GffLocalizedString,
90    /// Localized description shown when identified (`DescIdentified`).
91    #[gff(DescIdentified, constructed)]
92    pub description_identified: GffLocalizedString,
93    /// Item tag (`Tag`).
94    #[gff(Tag, constructed)]
95    pub tag: String,
96    /// Charges (`Charges`).
97    #[gff(Charges, stamped = 50)]
98    pub charges: u8,
99    /// Item cost (`Cost`). Ignored by the engine, which computes item cost dynamically.
100    #[gff(Cost, unexamined)]
101    pub cost: u32,
102    /// Maximum stack size (`StackSize`).
103    #[gff(StackSize, constructed = 1)]
104    pub stack_size: u16,
105    /// Plot item flag (`Plot`).
106    #[gff(Plot, constructed)]
107    pub plot: bool,
108    /// Additional cost modifier (`AddCost`).
109    #[gff(AddCost, constructed)]
110    pub add_cost: u32,
111    /// Palette identifier (`PaletteID`). Never read by the K1 engine.
112    #[gff(
113        PaletteID,
114        read_only_dead = "UTI-004 records this as a toolset-only field bypassed on load entirely and never read by the K1 engine",
115        not_a_constant
116    )]
117    pub palette_id: u8,
118    /// Toolset comment (`Comment`). Never read by the K1 engine.
119    #[gff(
120        Comment,
121        read_only_dead = "UTI-004 records this as a toolset-only field bypassed on load entirely and never read by the K1 engine",
122        not_a_constant
123    )]
124    pub comment: String,
125    /// Body variation (`BodyVariation`). Ignored by the engine, which reads from `baseitems.2da` instead.
126    #[gff(
127        BodyVariation,
128        read_only_dead = "read by nothing on the item path",
129        not_a_constant
130    )]
131    pub body_variation: u8,
132    /// Texture variation (`TextureVar`). Only evaluated when the item's 2DA `model_type` is exactly 1.
133    #[gff(TextureVar, stamped = 1)]
134    pub texture_variation: u8,
135    /// Upgrade level (`UpgradeLevel`). Never read by the K1 engine.
136    #[gff(
137        UpgradeLevel,
138        read_only_dead = "UTI-004 records this as a toolset-only field bypassed on load entirely and never read by the K1 engine",
139        not_a_constant
140    )]
141    pub upgrade_level: u8,
142    /// Stolen flag (`Stolen`).
143    #[gff(Stolen, constructed)]
144    pub stolen: bool,
145    /// Identified flag (`Identified`). `SaveItem` hardcodes this to `true`, so any other value is dead once a save is written.
146    #[gff(Identified, stamped = true)]
147    pub identified: bool,
148    /// Droppable flag (`Dropable`). Sets bit 3 of the item's internal memory flags.
149    #[gff(Dropable, stamped, omit = audited_constant(993))]
150    pub droppable: bool,
151    /// Pickpocketable flag (`Pickpocketable`). Sets bit 4 of the item's internal memory flags.
152    #[gff(Pickpocketable, stamped, omit = audited_constant(993))]
153    pub pickpocketable: bool,
154    /// Non-equippable flag (`NonEquippable`).
155    #[gff(NonEquippable, constructed, omit = audited_constant(993))]
156    pub non_equippable: bool,
157    /// New-item flag (`NewItem`).
158    #[gff(NewItem, constructed, omit = audited_constant(993))]
159    pub new_item: bool,
160    /// Deleting flag (`DELETING`).
161    #[gff(DELETING, constructed, omit = audited_constant(993))]
162    pub deleting: bool,
163    /// Upgrade bitfield (`Upgrades`).
164    #[gff(Upgrades, constructed, omit = audited_constant(993))]
165    pub upgrades: u32,
166    /// Item property entries (`PropertiesList`).
167    #[gff(PropertiesList, not_a_constant, list = UtiProperty, element_id = 0)]
168    pub properties: Vec<UtiProperty>,
169    /// Maximum charges (`MaxCharges`).
170    ///
171    /// Absent, this is whatever `Charges` resolved to rather than a literal,
172    /// so `from_gff` reads it and the declaration only records the schema.
173    #[gff(MaxCharges, from_siblings, manual_read, manual_write, not_a_constant, omit = matches("Charges", 993))]
174    pub max_charges: u8,
175    /// Model variation (`ModelVariation`).
176    ///
177    /// Absent entirely, the engine falls back to the older `ModelPart1` label
178    /// before applying the same zero-check, so an ancient file carrying only
179    /// `ModelPart1` still gets a model. That chain is why `from_gff` reads it.
180    #[gff(
181        ModelVariation,
182        from_siblings,
183        manual_read,
184        manual_write,
185        not_a_constant
186    )]
187    pub model_variation: u8,
188}
189
190impl Uti {
191    /// Creates an empty UTI value.
192    pub fn new() -> Self {
193        Self::default()
194    }
195
196    /// Returns `true` when this item's `BaseItem` belongs to the armor family.
197    #[must_use]
198    pub fn is_armor(&self) -> bool {
199        is_armor_base_item(self.base_item)
200    }
201
202    /// Builds typed UTI data from a parsed GFF container.
203    ///
204    /// # Errors
205    ///
206    /// Returns [`UtiError::UnsupportedFileType`] for a container that is
207    /// neither `UTI ` nor the generic `GFF ` form.
208    pub fn from_gff(gff: &Gff) -> Result<Self, UtiError> {
209        if gff.file_type != <Uti as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
210            return Err(UtiError::UnsupportedFileType(gff.file_type));
211        }
212
213        let root = &gff.root;
214        let mut uti = Self::read_declared(root);
215
216        uti.model_variation = get_u8(root, "ModelVariation")
217            .or_else(|| get_u8(root, "ModelPart1"))
218            .unwrap_or(0);
219        uti.max_charges = get_u8(root, "MaxCharges").unwrap_or(uti.charges);
220
221        Ok(uti)
222    }
223
224    /// Converts this typed UTI value into a GFF container.
225    pub fn to_gff(&self) -> Gff {
226        let mut root = GffStruct::new(-1);
227        self.write_declared(&mut root);
228
229        // The engine's own writer leaves `MaxCharges` out when it matches the
230        // sibling it chains off. That test needs both fields, so it is here
231        // rather than on the entry, which sees one.
232        if self.max_charges != self.charges {
233            upsert_field(
234                &mut root,
235                gff_label!("MaxCharges"),
236                GffValue::UInt8(self.max_charges),
237            );
238        }
239        upsert_field(
240            &mut root,
241            gff_label!("ModelVariation"),
242            GffValue::UInt8(self.model_variation),
243        );
244
245        Gff::new(*b"UTI ", root)
246    }
247}
248
249/// One entry of an item's `PropertiesList`.
250#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
251pub struct UtiProperty {
252    /// Cost table index (`CostTable`).
253    #[gff(CostTable, required, undefined)]
254    pub cost_table: u8,
255    /// Cost table value (`CostValue`).
256    #[gff(CostValue, required, undefined)]
257    pub cost_value: u16,
258    /// Parameter table index (`Param1`).
259    #[gff(Param1, required, undefined)]
260    pub param1: u8,
261    /// Parameter value (`Param1Value`).
262    #[gff(Param1Value, required, undefined)]
263    pub param1_value: u8,
264    /// Property identifier (`PropertyName`).
265    #[gff(PropertyName, required, undefined)]
266    pub property_name: u16,
267    /// Property subtype (`Subtype`).
268    #[gff(Subtype, required, undefined)]
269    pub subtype: u16,
270    /// Chance the property appears (`ChanceAppear`).
271    #[gff(ChanceAppear, required, undefined = 100)]
272    pub chance_appear: u8,
273    /// Usable flag (`Useable`), when the entry carries one.
274    #[gff(Useable, not_a_constant, optional = bool)]
275    pub useable: Option<bool>,
276    /// Uses per day (`UsesPerDay`).
277    ///
278    /// No vanilla `.uti` carries this label on any property entry, across all
279    /// 993 of them, so the omission stands in for the absence and the model
280    /// does not have to.
281    #[gff(UsesPerDay, stamped, omit = audited_constant(993))]
282    pub uses_per_day: u8,
283    /// Upgrade type (`UpgradeType`), when the entry carries one.
284    ///
285    /// Held optionally because absence and an explicit `0` are both real: of
286    /// 2040 property entries in the install, 1118 omit the label, 890 carry a
287    /// value and 32 carry a zero. An omission keyed on the engine's own
288    /// absent-value would drop those 32.
289    ///
290    /// The entry says the engine stamps `0` and our reader hands back `None`,
291    /// which the vocabulary cannot express in one place: `Absent::Resolves`
292    /// has no substitute slot, because it was shaped for the case where our
293    /// value is the engine's. Recorded here until that is settled.
294    #[gff(UpgradeType, stamped, optional = u8)]
295    pub upgrade_type: Option<u8>,
296}
297
298/// Errors produced while reading or writing typed UTI data.
299#[derive(Debug, Error)]
300pub enum UtiError {
301    /// Source file type is not supported by this parser.
302    #[error("unsupported UTI file type: {0:?}")]
303    UnsupportedFileType([u8; 4]),
304    /// Underlying GFF parser/writer error.
305    #[error(transparent)]
306    Gff(#[from] GffBinaryError),
307}
308
309/// Reads typed UTI data from a reader at the current stream position.
310///
311/// # Errors
312///
313/// [`UtiError::Gff`] when the stream is not a readable GFF, and
314/// [`UtiError::UnsupportedFileType`] when it is a GFF of some other format,
315/// carrying the fourcc that was found.
316#[cfg_attr(
317    feature = "tracing",
318    tracing::instrument(level = "debug", skip(reader))
319)]
320pub fn read_uti<R: Read>(reader: &mut R) -> Result<Uti, UtiError> {
321    let gff = read_gff(reader)?;
322    Uti::from_gff(&gff)
323}
324
325/// Reads typed UTI data directly from bytes.
326///
327/// # Errors
328///
329/// [`UtiError::Gff`] when `bytes` are not a readable GFF, and
330/// [`UtiError::UnsupportedFileType`] when they are a GFF of some other format,
331/// carrying the fourcc that was found.
332#[cfg_attr(
333    feature = "tracing",
334    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
335)]
336pub fn read_uti_from_bytes(bytes: &[u8]) -> Result<Uti, UtiError> {
337    let gff = read_gff_from_bytes(bytes)?;
338    Uti::from_gff(&gff)
339}
340
341/// Authors the UTI file the typed view describes, into a writer.
342///
343/// # Errors
344///
345/// [`UtiError::Gff`] when the writer fails or a value will not encode. The
346/// typed view fixes the file type, so `UnsupportedFileType` cannot arise on
347/// this side.
348#[cfg_attr(
349    feature = "tracing",
350    tracing::instrument(level = "debug", skip(writer, uti))
351)]
352pub fn author_uti<W: Write>(writer: &mut W, uti: &Uti) -> Result<(), UtiError> {
353    let gff = uti.to_gff();
354    write_gff(writer, &gff)?;
355    Ok(())
356}
357
358/// Authors the UTI file the typed view describes, as bytes.
359///
360/// # Errors
361///
362/// [`UtiError::Gff`] when a value will not encode. Writing into a `Vec` has no
363/// I/O to fail at.
364#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(uti)))]
365pub fn author_uti_to_vec(uti: &Uti) -> Result<Vec<u8>, UtiError> {
366    let mut cursor = Cursor::new(Vec::new());
367    author_uti(&mut cursor, uti)?;
368    Ok(cursor.into_inner())
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use rakata_formats::schema::{HasSchema, Shape};
375
376    use rakata_formats::GffLocalizedString;
377
378    const TEST_UTI: &[u8] = include_bytes!(concat!(
379        env!("CARGO_MANIFEST_DIR"),
380        "/../../fixtures/test.uti"
381    ));
382
383    #[test]
384    fn model_variation_falls_back_to_the_legacy_model_part_label() {
385        // An ancient file carries `ModelPart1` and no `ModelVariation`, and
386        // the engine reads the former in that case. A round-trip cannot show
387        // this: the view writes the canonical label, so the old one is gone
388        // on the second pass whether or not it was ever read.
389        let mut root = GffStruct::new(-1);
390        root.push_field(gff_label!("ModelPart1"), GffValue::UInt8(3));
391        let uti = Uti::from_gff(&Gff::new(*b"UTI ", root)).expect("parses");
392        assert_eq!(uti.model_variation, 3, "the legacy label must be read");
393
394        // Present but zero is not absent, so no fallback: the engine's own
395        // zero-check handles that case and would otherwise be bypassed here.
396        let mut both = GffStruct::new(-1);
397        both.push_field(gff_label!("ModelVariation"), GffValue::UInt8(0));
398        both.push_field(gff_label!("ModelPart1"), GffValue::UInt8(7));
399        let uti = Uti::from_gff(&Gff::new(*b"UTI ", both)).expect("parses");
400        assert_eq!(
401            uti.model_variation, 0,
402            "a present zero wins over the fallback"
403        );
404
405        // Written canonically: the legacy spelling is not re-emitted.
406        let written = uti.to_gff();
407        assert!(written.root.field("ModelVariation").is_some());
408        assert!(written.root.field("ModelPart1").is_none());
409    }
410
411    #[test]
412    fn reads_core_uti_fields_from_fixture() {
413        let uti = read_uti_from_bytes(TEST_UTI).expect("fixture must parse");
414
415        assert_eq!(uti.template_resref, "g_a_class4001");
416        assert_eq!(uti.base_item, 38);
417        assert_eq!(uti.name.string_ref.raw(), 5632);
418        assert_eq!(uti.description_unidentified.string_ref.raw(), 456);
419        assert_eq!(uti.description_identified.string_ref.raw(), 5633);
420        assert_eq!(uti.tag, "G_A_CLASS4001");
421        assert_eq!(uti.charges, 13);
422        assert_eq!(uti.max_charges, 13);
423        assert_eq!(uti.cost, 50);
424        assert_eq!(uti.stack_size, 1);
425        assert!(uti.plot);
426        assert_eq!(uti.add_cost, 50);
427        assert_eq!(uti.palette_id, 1);
428        assert_eq!(uti.comment, "itemo");
429        assert_eq!(uti.model_variation, 2);
430        assert_eq!(uti.body_variation, 3);
431        assert_eq!(uti.texture_variation, 1);
432        assert_eq!(uti.upgrade_level, 0);
433        assert!(uti.is_armor());
434        assert!(uti.stolen);
435        assert!(uti.identified);
436        assert!(!uti.droppable);
437        assert!(!uti.pickpocketable);
438        assert!(!uti.non_equippable);
439        assert!(!uti.new_item);
440        assert!(!uti.deleting);
441        assert_eq!(uti.upgrades, 0);
442
443        assert_eq!(uti.properties.len(), 2);
444        assert_eq!(uti.properties[0].property_name, 45);
445        assert_eq!(uti.properties[0].subtype, 6);
446        assert_eq!(uti.properties[0].cost_table, 1);
447        assert_eq!(uti.properties[0].cost_value, 1);
448        assert_eq!(uti.properties[0].param1, 255);
449        assert_eq!(uti.properties[0].param1_value, 1);
450        assert_eq!(uti.properties[0].chance_appear, 100);
451        assert_eq!(uti.properties[0].useable, None);
452        assert_eq!(uti.properties[0].uses_per_day, 0);
453        assert_eq!(uti.properties[0].upgrade_type, None);
454        assert_eq!(uti.properties[1].upgrade_type, Some(24));
455    }
456
457    #[test]
458    fn all_fields_survive_typed_roundtrip() {
459        let uti = read_uti_from_bytes(TEST_UTI).expect("fixture must parse");
460        let bytes = author_uti_to_vec(&uti).expect("write succeeds");
461        let reparsed = read_uti_from_bytes(&bytes).expect("reparse succeeds");
462        assert_eq!(reparsed, uti);
463    }
464
465    #[test]
466    fn typed_edits_roundtrip_through_gff_writer() {
467        let mut uti = read_uti_from_bytes(TEST_UTI).expect("fixture must parse");
468        uti.tag = "g_a_class4001_mod".into();
469        uti.cost = 777;
470        uti.plot = false;
471        uti.max_charges = 55;
472        uti.droppable = true;
473        uti.pickpocketable = true;
474        uti.non_equippable = true;
475        uti.new_item = true;
476        uti.deleting = true;
477        uti.upgrades = 0xABCD;
478        uti.properties[0].chance_appear = 25;
479        uti.properties[0].useable = Some(true);
480        uti.properties[0].uses_per_day = 5;
481        uti.properties[0].upgrade_type = Some(5);
482
483        let encoded = author_uti_to_vec(&uti).expect("encode");
484        let reparsed = read_uti_from_bytes(&encoded).expect("decode");
485
486        assert_eq!(reparsed.tag, "g_a_class4001_mod");
487        assert_eq!(reparsed.cost, 777);
488        assert!(!reparsed.plot);
489        assert_eq!(reparsed.max_charges, 55);
490        assert!(reparsed.droppable);
491        assert!(reparsed.pickpocketable);
492        assert!(reparsed.non_equippable);
493        assert!(reparsed.new_item);
494        assert!(reparsed.deleting);
495        assert_eq!(reparsed.upgrades, 0xABCD);
496        assert_eq!(reparsed.properties[0].chance_appear, 25);
497        assert_eq!(reparsed.properties[0].useable, Some(true));
498        assert_eq!(reparsed.properties[0].uses_per_day, 5);
499        assert_eq!(reparsed.properties[0].upgrade_type, Some(5));
500    }
501
502    #[test]
503    fn applies_runtime_defaults_for_missing_charge_and_flag_fields() {
504        let mut root = GffStruct::new(-1);
505        root.push_field(
506            gff_label!("TemplateResRef"),
507            GffValue::resref_lit("g_i_test"),
508        );
509        root.push_field(gff_label!("BaseItem"), GffValue::Int32(1));
510        root.push_field(
511            gff_label!("LocalizedName"),
512            GffValue::LocalizedString(GffLocalizedString::new(1)),
513        );
514        root.push_field(
515            gff_label!("Description"),
516            GffValue::LocalizedString(GffLocalizedString::new(2)),
517        );
518        root.push_field(
519            gff_label!("DescIdentified"),
520            GffValue::LocalizedString(GffLocalizedString::new(3)),
521        );
522        root.push_field(gff_label!("PropertiesList"), GffValue::List(Vec::new()));
523        let gff = Gff::new(*b"UTI ", root);
524
525        let uti = Uti::from_gff(&gff).expect("must parse");
526        assert_eq!(uti.charges, 50);
527        assert_eq!(uti.max_charges, 50);
528        assert!(uti.identified);
529        assert!(!uti.droppable);
530        assert!(!uti.pickpocketable);
531        assert!(!uti.non_equippable);
532        assert!(!uti.new_item);
533        assert!(!uti.deleting);
534        assert_eq!(uti.upgrades, 0);
535    }
536
537    #[test]
538    fn reads_runtime_state_fields_from_gff() {
539        let mut root = GffStruct::new(-1);
540        root.push_field(
541            gff_label!("TemplateResRef"),
542            GffValue::resref_lit("g_i_test"),
543        );
544        root.push_field(gff_label!("BaseItem"), GffValue::Int32(1));
545        root.push_field(
546            gff_label!("LocalizedName"),
547            GffValue::LocalizedString(GffLocalizedString::new(1)),
548        );
549        root.push_field(
550            gff_label!("Description"),
551            GffValue::LocalizedString(GffLocalizedString::new(2)),
552        );
553        root.push_field(
554            gff_label!("DescIdentified"),
555            GffValue::LocalizedString(GffLocalizedString::new(3)),
556        );
557        root.push_field(gff_label!("Charges"), GffValue::UInt8(9));
558        root.push_field(gff_label!("MaxCharges"), GffValue::UInt8(12));
559        root.push_field(gff_label!("Identified"), GffValue::UInt8(0));
560        root.push_field(gff_label!("Dropable"), GffValue::UInt8(1));
561        root.push_field(gff_label!("Pickpocketable"), GffValue::UInt8(1));
562        root.push_field(gff_label!("NonEquippable"), GffValue::UInt8(1));
563        root.push_field(gff_label!("NewItem"), GffValue::UInt8(1));
564        root.push_field(gff_label!("DELETING"), GffValue::UInt8(1));
565        root.push_field(gff_label!("Upgrades"), GffValue::UInt32(0x1234_5678));
566        root.push_field(gff_label!("PropertiesList"), GffValue::List(Vec::new()));
567        let gff = Gff::new(*b"UTI ", root);
568
569        let uti = Uti::from_gff(&gff).expect("must parse");
570        assert_eq!(uti.charges, 9);
571        assert_eq!(uti.max_charges, 12);
572        assert!(!uti.identified);
573        assert!(uti.droppable);
574        assert!(uti.pickpocketable);
575        assert!(uti.non_equippable);
576        assert!(uti.new_item);
577        assert!(uti.deleting);
578        assert_eq!(uti.upgrades, 0x1234_5678);
579    }
580
581    #[test]
582    fn rejects_non_uti_file_type() {
583        let gff = Gff::new(*b"UTC ", GffStruct::new(-1));
584        let err = Uti::from_gff(&gff).expect_err("must fail");
585        assert!(matches!(err, UtiError::UnsupportedFileType(file_type) if file_type == *b"UTC "));
586    }
587
588    #[test]
589    fn read_uti_from_reader_matches_bytes_path() {
590        let mut cursor = Cursor::new(TEST_UTI);
591        let via_reader = read_uti(&mut cursor).expect("reader parse");
592        let via_bytes = read_uti_from_bytes(TEST_UTI).expect("bytes parse");
593        assert_eq!(via_reader.template_resref, via_bytes.template_resref);
594        assert_eq!(via_reader.properties.len(), via_bytes.properties.len());
595    }
596
597    #[test]
598    fn a_mistyped_properties_list_reads_as_empty() {
599        let mut root = GffStruct::new(-1);
600        root.push_field(gff_label!("PropertiesList"), GffValue::UInt32(7));
601        let gff = Gff::new(*b"UTI ", root);
602
603        let uti = Uti::from_gff(&gff).expect("a mistyped list is not a read failure");
604
605        assert!(uti.properties.is_empty());
606    }
607
608    #[test]
609    fn write_uti_matches_direct_gff_writer() {
610        let uti = read_uti_from_bytes(TEST_UTI).expect("fixture parse");
611        let from_uti = author_uti_to_vec(&uti).expect("uti encode");
612
613        let gff = uti.to_gff();
614        let from_gff = rakata_formats::write_gff_to_vec(&gff).expect("gff encode");
615        assert_eq!(from_uti, from_gff);
616    }
617
618    #[test]
619    fn armor_base_item_helper_matches_known_values() {
620        assert!(is_armor_base_item(38));
621        assert!(is_armor_base_item(103));
622        assert!(!is_armor_base_item(1));
623        assert!(!is_armor_base_item(-1));
624    }
625
626    #[test]
627    fn schema_field_count() {
628        assert_eq!(Uti::schema().len(), 28); // 20 engine + 1 list + 7 toolset
629    }
630
631    #[test]
632    fn schema_no_duplicate_labels() {
633        let mut labels: Vec<&str> = Uti::schema().iter().map(|f| f.label.as_str()).collect();
634        labels.sort_unstable();
635        let before = labels.len();
636        labels.dedup();
637        assert_eq!(before, labels.len(), "duplicate labels in UTI schema");
638    }
639
640    #[test]
641    fn schema_properties_list_carries_its_element() {
642        let props = Uti::schema()
643            .iter()
644            .find(|f| f.label.as_str() == "PropertiesList")
645            .expect("PropertiesList is declared");
646        let Shape::List { element, .. } = props.shape else {
647            panic!("PropertiesList is a list");
648        };
649        assert_eq!(element.iter().map(|p| p.len()).sum::<usize>(), 10);
650    }
651}