Skip to main content

rakata_generics/
uti.rs

1//! UTI (`.uti`) typed generic wrapper.
2//!
3//! UTI resources are GFF-backed item templates.
4//!
5//! ## Scope
6//! - Typed access for all engine-read and toolset item fields.
7//! - Typed handling for `PropertiesList` item-property entries.
8//! - Lossless typed roundtrip - all fields are written unconditionally.
9//!
10//! ## Field Layout (simplified)
11//! ```text
12//! UTI root struct
13//! +-- TemplateResRef / Tag / Comment
14//! +-- BaseItem / Charges / Cost / StackSize / AddCost
15//! +-- MaxCharges / Upgrades
16//! +-- LocalizedName / Description / DescIdentified
17//! +-- ModelVariation / BodyVariation / TextureVar
18//! +-- Plot / Stolen / Identified / UpgradeLevel
19//! +-- Dropable / Pickpocketable / NonEquippable / NewItem / DELETING
20//! `-- PropertiesList                 (List<Struct>)
21//!     +-- PropertyName / Subtype
22//!     +-- CostTable / CostValue
23//!     +-- Param1 / Param1Value
24//!     +-- ChanceAppear
25//!     `-- UpgradeType (optional)
26//! ```
27
28use std::io::{Cursor, Read, Write};
29
30use crate::gff_helpers::{
31    get_bool, get_i32, get_locstring, get_resref, get_string, get_u16, get_u32, get_u8,
32    upsert_field,
33};
34use rakata_core::{ResRef, StrRef};
35use rakata_formats::{
36    gff_schema::{AbsentDefault, FieldLife, FieldSchema, GffSchema, GffType},
37    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffStruct,
38    GffValue,
39};
40use thiserror::Error;
41
42/// Returns `true` when `base_item` belongs to the canonical armor base-item set.
43///
44/// Derived from the base-item classification in `baseitems.2da` (KotOR I/II).
45/// PyKotor arrives at the same set independently, which is a useful check on
46/// the reading but not where these values come from.
47pub const fn is_armor_base_item(base_item: i32) -> bool {
48    match base_item {
49        // Contiguous core armor block.
50        35..=43 => true,
51        // Non-contiguous armor-classified base items.
52        53 | 58 | 63..=65 | 69 | 71 | 85 | 89 | 98 | 100 | 102 | 103 => true,
53        _ => false,
54    }
55}
56
57/// Typed UTI model built from/to [`Gff`] data.
58#[derive(Debug, Clone, PartialEq)]
59pub struct Uti {
60    /// Item template resref (`TemplateResRef`). Never read by the K1 engine.
61    pub template_resref: ResRef,
62    /// Base item identifier (`BaseItem`).
63    pub base_item: i32,
64    /// Localized item name (`LocalizedName`).
65    pub name: GffLocalizedString,
66    /// Localized description shown before identification (`Description`). If `description_identified` is missing, the engine duplicates this string into it so identification mechanics never crash.
67    pub description_unidentified: GffLocalizedString,
68    /// Localized description shown when identified (`DescIdentified`). If `description_unidentified` is missing, the engine duplicates this string into it so identification mechanics never crash.
69    pub description_identified: GffLocalizedString,
70    /// Item tag (`Tag`).
71    pub tag: String,
72    /// Charges (`Charges`).
73    pub charges: u8,
74    /// Maximum charges (`MaxCharges`).
75    pub max_charges: u8,
76    /// Item cost (`Cost`). Ignored by the engine, which computes item cost dynamically.
77    pub cost: u32,
78    /// Maximum stack size (`StackSize`).
79    pub stack_size: u16,
80    /// Plot item flag (`Plot`).
81    pub plot: bool,
82    /// Additional cost modifier (`AddCost`).
83    pub add_cost: u32,
84    /// Palette identifier (`PaletteID`). Never read by the K1 engine.
85    pub palette_id: u8,
86    /// Toolset comment (`Comment`). Never read by the K1 engine.
87    pub comment: String,
88    /// Model variation (`ModelVariation`). If 0, the engine forces this to 1 at runtime.
89    ///
90    /// Read tolerantly and written canonically. The engine's read has a
91    /// legacy fallback baked into it: when `ModelVariation` is absent
92    /// entirely, rather than merely zero, it falls back to the older
93    /// `ModelPart1` label before applying the same zero-check. So an ancient
94    /// file carrying only `ModelPart1` still gets a model, and this field
95    /// carries whichever of the two the file supplied.
96    ///
97    /// `ModelPart2` and `ModelPart3` are not part of that fallback and are
98    /// read nowhere: their label strings are absent from the binary. Files
99    /// carrying all three are authoring-tool habit, and only `ModelPart1`
100    /// reaches the engine.
101    pub model_variation: u8,
102    /// Body variation (`BodyVariation`). Ignored by the engine, which reads from `baseitems.2da` instead.
103    pub body_variation: u8,
104    /// Texture variation (`TextureVar`). Only evaluated conditionally if the item's 2DA `model_type` is exactly 1.
105    pub texture_variation: u8,
106    /// Upgrade level (`UpgradeLevel`). Never read by the K1 engine.
107    pub upgrade_level: u8,
108    /// Stolen flag (`Stolen`).
109    pub stolen: bool,
110    /// Identified flag (`Identified`). The engine unconditionally hardcodes this to `true` during `SaveItem` serialization, so any value other than `true` is dead data once a save is written.
111    pub identified: bool,
112    /// Droppable flag (`Dropable`). Explicitly sets bit 3 of the item's internal memory flags.
113    pub droppable: bool,
114    /// Pickpocketable flag (`Pickpocketable`). Explicitly sets bit 4 of the item's internal memory flags.
115    pub pickpocketable: bool,
116    /// Non-equippable flag (`NonEquippable`).
117    pub non_equippable: bool,
118    /// New-item flag (`NewItem`).
119    pub new_item: bool,
120    /// Deleting flag (`DELETING`).
121    pub deleting: bool,
122    /// Upgrade bitfield (`Upgrades`).
123    pub upgrades: u32,
124    /// Item property entries (`PropertiesList`).
125    pub properties: Vec<UtiProperty>,
126}
127
128impl Default for Uti {
129    fn default() -> Self {
130        Self {
131            template_resref: ResRef::blank(),
132            base_item: 0,
133            name: GffLocalizedString::new(StrRef::invalid()),
134            description_unidentified: GffLocalizedString::new(StrRef::invalid()),
135            description_identified: GffLocalizedString::new(StrRef::invalid()),
136            tag: String::new(),
137            charges: 0,
138            max_charges: 0,
139            cost: 0,
140            stack_size: 0,
141            plot: false,
142            add_cost: 0,
143            palette_id: 0,
144            comment: String::new(),
145            model_variation: 0,
146            body_variation: 0,
147            texture_variation: 0,
148            upgrade_level: 0,
149            stolen: false,
150            identified: false,
151            droppable: false,
152            pickpocketable: false,
153            non_equippable: false,
154            new_item: false,
155            deleting: false,
156            upgrades: 0,
157            properties: Vec::new(),
158        }
159    }
160}
161
162impl Uti {
163    /// Creates an empty UTI value.
164    pub fn new() -> Self {
165        Self::default()
166    }
167
168    /// Returns `true` when this item's `BaseItem` belongs to the armor family.
169    pub fn is_armor(&self) -> bool {
170        is_armor_base_item(self.base_item)
171    }
172
173    /// Builds typed UTI data from a parsed GFF container.
174    pub fn from_gff(gff: &Gff) -> Result<Self, UtiError> {
175        if gff.file_type != *b"UTI " && gff.file_type != *b"GFF " {
176            return Err(UtiError::UnsupportedFileType(gff.file_type));
177        }
178
179        let root = &gff.root;
180
181        let properties = match root.field("PropertiesList") {
182            Some(GffValue::List(property_structs)) => property_structs
183                .iter()
184                .map(UtiProperty::from_struct)
185                .collect::<Vec<_>>(),
186            Some(_) => {
187                return Err(UtiError::TypeMismatch {
188                    field: "PropertiesList",
189                    expected: "List",
190                });
191            }
192            None => Vec::new(),
193        };
194
195        // TODO(rakata-generics/uti): Extend typed UTI coverage to additional
196        // runtime/toolset-specific fields once fixture-backed parity targets are
197        // defined (for example `ModelPart1` fallback behavior and legacy
198        // runtime-only flag derivations).
199        let charges = get_u8(root, "Charges").unwrap_or(50);
200        let max_charges = get_u8(root, "MaxCharges").unwrap_or(charges);
201        Ok(Self {
202            template_resref: get_resref(root, "TemplateResRef").unwrap_or_default(),
203            base_item: get_i32(root, "BaseItem").unwrap_or(0),
204            name: get_locstring(root, "LocalizedName")
205                .cloned()
206                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
207            description_unidentified: get_locstring(root, "Description")
208                .cloned()
209                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
210            description_identified: get_locstring(root, "DescIdentified")
211                .cloned()
212                .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
213            tag: get_string(root, "Tag").unwrap_or_default(),
214            charges,
215            max_charges,
216            cost: get_u32(root, "Cost").unwrap_or(0),
217            stack_size: get_u16(root, "StackSize").unwrap_or(0),
218            plot: get_bool(root, "Plot").unwrap_or(false),
219            add_cost: get_u32(root, "AddCost").unwrap_or(0),
220            palette_id: get_u8(root, "PaletteID").unwrap_or(0),
221            comment: get_string(root, "Comment").unwrap_or_default(),
222            model_variation: get_u8(root, "ModelVariation")
223                .or_else(|| get_u8(root, "ModelPart1"))
224                .unwrap_or(0),
225            body_variation: get_u8(root, "BodyVariation").unwrap_or(0),
226            texture_variation: get_u8(root, "TextureVar").unwrap_or(0),
227            upgrade_level: get_u8(root, "UpgradeLevel").unwrap_or(0),
228            stolen: get_bool(root, "Stolen").unwrap_or(false),
229            identified: get_bool(root, "Identified").unwrap_or(true),
230            droppable: get_bool(root, "Dropable").unwrap_or(false),
231            pickpocketable: get_bool(root, "Pickpocketable").unwrap_or(false),
232            non_equippable: get_bool(root, "NonEquippable").unwrap_or(false),
233            new_item: get_bool(root, "NewItem").unwrap_or(false),
234            deleting: get_bool(root, "DELETING").unwrap_or(false),
235            upgrades: get_u32(root, "Upgrades").unwrap_or(0),
236            properties,
237        })
238    }
239
240    /// Converts this typed UTI value into a GFF container.
241    pub fn to_gff(&self) -> Gff {
242        let mut root = GffStruct::new(-1);
243
244        upsert_field(
245            &mut root,
246            "TemplateResRef",
247            GffValue::ResRef(self.template_resref),
248        );
249        upsert_field(&mut root, "BaseItem", GffValue::Int32(self.base_item));
250        upsert_field(
251            &mut root,
252            "LocalizedName",
253            GffValue::LocalizedString(self.name.clone()),
254        );
255        upsert_field(
256            &mut root,
257            "Description",
258            GffValue::LocalizedString(self.description_unidentified.clone()),
259        );
260        upsert_field(
261            &mut root,
262            "DescIdentified",
263            GffValue::LocalizedString(self.description_identified.clone()),
264        );
265        upsert_field(&mut root, "Tag", GffValue::String(self.tag.clone()));
266        upsert_field(&mut root, "Charges", GffValue::UInt8(self.charges));
267        upsert_field(&mut root, "MaxCharges", GffValue::UInt8(self.max_charges));
268        upsert_field(&mut root, "Cost", GffValue::UInt32(self.cost));
269        upsert_field(&mut root, "StackSize", GffValue::UInt16(self.stack_size));
270        upsert_field(&mut root, "Plot", GffValue::UInt8(u8::from(self.plot)));
271        upsert_field(&mut root, "AddCost", GffValue::UInt32(self.add_cost));
272        upsert_field(&mut root, "PaletteID", GffValue::UInt8(self.palette_id));
273        upsert_field(&mut root, "Comment", GffValue::String(self.comment.clone()));
274        upsert_field(
275            &mut root,
276            "ModelVariation",
277            GffValue::UInt8(self.model_variation),
278        );
279        upsert_field(
280            &mut root,
281            "BodyVariation",
282            GffValue::UInt8(self.body_variation),
283        );
284        upsert_field(
285            &mut root,
286            "TextureVar",
287            GffValue::UInt8(self.texture_variation),
288        );
289        upsert_field(
290            &mut root,
291            "UpgradeLevel",
292            GffValue::UInt8(self.upgrade_level),
293        );
294        upsert_field(&mut root, "Stolen", GffValue::UInt8(u8::from(self.stolen)));
295        upsert_field(
296            &mut root,
297            "Identified",
298            GffValue::UInt8(u8::from(self.identified)),
299        );
300        upsert_field(
301            &mut root,
302            "Dropable",
303            GffValue::UInt8(u8::from(self.droppable)),
304        );
305        upsert_field(
306            &mut root,
307            "Pickpocketable",
308            GffValue::UInt8(u8::from(self.pickpocketable)),
309        );
310        upsert_field(
311            &mut root,
312            "NonEquippable",
313            GffValue::UInt8(u8::from(self.non_equippable)),
314        );
315        upsert_field(
316            &mut root,
317            "NewItem",
318            GffValue::UInt8(u8::from(self.new_item)),
319        );
320        upsert_field(
321            &mut root,
322            "DELETING",
323            GffValue::UInt8(u8::from(self.deleting)),
324        );
325        upsert_field(&mut root, "Upgrades", GffValue::UInt32(self.upgrades));
326
327        let property_structs = self
328            .properties
329            .iter()
330            .map(UtiProperty::to_struct)
331            .collect::<Vec<GffStruct>>();
332        upsert_field(
333            &mut root,
334            "PropertiesList",
335            GffValue::List(property_structs),
336        );
337
338        Gff::new(*b"UTI ", root)
339    }
340}
341
342/// One UTI property entry from the `PropertiesList` field.
343#[derive(Debug, Clone, PartialEq, Default)]
344pub struct UtiProperty {
345    /// Cost table identifier (`CostTable`).
346    pub cost_table: u8,
347    /// Cost value identifier (`CostValue`).
348    pub cost_value: u16,
349    /// Param1 identifier (`Param1`).
350    pub param1: u8,
351    /// Param1 value (`Param1Value`).
352    pub param1_value: u8,
353    /// Property identifier (`PropertyName`). The engine routes IDs `10`, `37`, `46`, and `53` (Cast Power, Trap, etc.) into the active player-ability table; all other IDs are silently applied as passive stat modifiers.
354    pub property_name: u16,
355    /// Property subtype (`Subtype`).
356    pub subtype: u16,
357    /// Appearance chance (`ChanceAppear`).
358    pub chance_appear: u8,
359    /// Useable flag (`Useable`, optional).
360    pub useable: Option<bool>,
361    /// Uses-per-day value (`UsesPerDay`, optional).
362    pub uses_per_day: Option<u8>,
363    /// Upgrade type (`UpgradeType`, optional).
364    pub upgrade_type: Option<u8>,
365}
366
367impl UtiProperty {
368    pub(crate) fn from_struct(structure: &GffStruct) -> Self {
369        Self {
370            cost_table: get_u8(structure, "CostTable").unwrap_or(0),
371            cost_value: get_u16(structure, "CostValue").unwrap_or(0),
372            param1: get_u8(structure, "Param1").unwrap_or(0),
373            param1_value: get_u8(structure, "Param1Value").unwrap_or(0),
374            property_name: get_u16(structure, "PropertyName").unwrap_or(0),
375            subtype: get_u16(structure, "Subtype").unwrap_or(0),
376            chance_appear: get_u8(structure, "ChanceAppear").unwrap_or(100),
377            useable: get_bool(structure, "Useable"),
378            uses_per_day: get_u8(structure, "UsesPerDay"),
379            upgrade_type: get_u8(structure, "UpgradeType"),
380        }
381    }
382
383    pub(crate) fn to_struct(&self) -> GffStruct {
384        let mut structure = GffStruct::new(0);
385
386        upsert_field(
387            &mut structure,
388            "CostTable",
389            GffValue::UInt8(self.cost_table),
390        );
391        upsert_field(
392            &mut structure,
393            "CostValue",
394            GffValue::UInt16(self.cost_value),
395        );
396        upsert_field(&mut structure, "Param1", GffValue::UInt8(self.param1));
397        upsert_field(
398            &mut structure,
399            "Param1Value",
400            GffValue::UInt8(self.param1_value),
401        );
402        upsert_field(
403            &mut structure,
404            "PropertyName",
405            GffValue::UInt16(self.property_name),
406        );
407        upsert_field(&mut structure, "Subtype", GffValue::UInt16(self.subtype));
408        upsert_field(
409            &mut structure,
410            "ChanceAppear",
411            GffValue::UInt8(self.chance_appear),
412        );
413        if let Some(value) = self.useable {
414            upsert_field(&mut structure, "Useable", GffValue::UInt8(u8::from(value)));
415        }
416        if let Some(value) = self.uses_per_day {
417            upsert_field(&mut structure, "UsesPerDay", GffValue::UInt8(value));
418        }
419        if let Some(value) = self.upgrade_type {
420            upsert_field(&mut structure, "UpgradeType", GffValue::UInt8(value));
421        }
422
423        structure
424    }
425}
426
427/// Errors produced while reading or writing typed UTI data.
428#[derive(Debug, Error)]
429pub enum UtiError {
430    /// Source file type is not supported by this parser.
431    #[error("unsupported UTI file type: {0:?}")]
432    UnsupportedFileType([u8; 4]),
433    /// A required container field had an unexpected runtime type.
434    #[error("UTI field `{field}` has incompatible type (expected {expected})")]
435    TypeMismatch {
436        /// Field label where mismatch occurred.
437        field: &'static str,
438        /// Expected runtime value kind.
439        expected: &'static str,
440    },
441    /// Underlying GFF parser/writer error.
442    #[error(transparent)]
443    Gff(#[from] GffBinaryError),
444}
445
446/// Reads typed UTI data from a reader at the current stream position.
447#[cfg_attr(
448    feature = "tracing",
449    tracing::instrument(level = "debug", skip(reader))
450)]
451pub fn read_uti<R: Read>(reader: &mut R) -> Result<Uti, UtiError> {
452    let gff = read_gff(reader)?;
453    Uti::from_gff(&gff)
454}
455
456/// Reads typed UTI data directly from bytes.
457#[cfg_attr(
458    feature = "tracing",
459    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
460)]
461pub fn read_uti_from_bytes(bytes: &[u8]) -> Result<Uti, UtiError> {
462    let gff = read_gff_from_bytes(bytes)?;
463    Uti::from_gff(&gff)
464}
465
466/// Writes typed UTI data to an output writer.
467#[cfg_attr(
468    feature = "tracing",
469    tracing::instrument(level = "debug", skip(writer, uti))
470)]
471pub fn write_uti<W: Write>(writer: &mut W, uti: &Uti) -> Result<(), UtiError> {
472    let gff = uti.to_gff();
473    write_gff(writer, &gff)?;
474    Ok(())
475}
476
477/// Serializes typed UTI data into a byte vector.
478#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(uti)))]
479pub fn write_uti_to_vec(uti: &Uti) -> Result<Vec<u8>, UtiError> {
480    let mut cursor = Cursor::new(Vec::new());
481    write_uti(&mut cursor, uti)?;
482    Ok(cursor.into_inner())
483}
484
485/// UTI `PropertiesList` entry child schema.
486static PROPERTIES_LIST_CHILDREN: &[FieldSchema] = &[
487    FieldSchema {
488        label: "PropertyName",
489        expected_type: GffType::UInt16,
490        life: FieldLife::Live,
491        required: false,
492        absent: AbsentDefault::Unverified,
493        children: None,
494        constraint: None,
495    },
496    FieldSchema {
497        label: "Subtype",
498        expected_type: GffType::UInt16,
499        life: FieldLife::Live,
500        required: false,
501        absent: AbsentDefault::Unverified,
502        children: None,
503        constraint: None,
504    },
505    FieldSchema {
506        label: "CostTable",
507        expected_type: GffType::UInt8,
508        life: FieldLife::Live,
509        required: false,
510        absent: AbsentDefault::Unverified,
511        children: None,
512        constraint: None,
513    },
514    FieldSchema {
515        label: "CostValue",
516        expected_type: GffType::UInt16,
517        life: FieldLife::Live,
518        required: false,
519        absent: AbsentDefault::Unverified,
520        children: None,
521        constraint: None,
522    },
523    FieldSchema {
524        label: "Param1",
525        expected_type: GffType::UInt8,
526        life: FieldLife::Live,
527        required: false,
528        absent: AbsentDefault::Unverified,
529        children: None,
530        constraint: None,
531    },
532    FieldSchema {
533        label: "Param1Value",
534        expected_type: GffType::UInt8,
535        life: FieldLife::Live,
536        required: false,
537        absent: AbsentDefault::Unverified,
538        children: None,
539        constraint: None,
540    },
541    FieldSchema {
542        label: "ChanceAppear",
543        expected_type: GffType::UInt8,
544        life: FieldLife::Live,
545        required: false,
546        absent: AbsentDefault::Unverified,
547        children: None,
548        constraint: None,
549    },
550    FieldSchema {
551        label: "Useable",
552        expected_type: GffType::UInt8,
553        life: FieldLife::Live,
554        required: false,
555        absent: AbsentDefault::Unverified,
556        children: None,
557        constraint: None,
558    },
559    FieldSchema {
560        label: "UsesPerDay",
561        expected_type: GffType::UInt8,
562        life: FieldLife::Live,
563        required: false,
564        absent: AbsentDefault::Unverified,
565        children: None,
566        constraint: None,
567    },
568    FieldSchema {
569        label: "UpgradeType",
570        expected_type: GffType::UInt8,
571        life: FieldLife::Live,
572        required: false,
573        absent: AbsentDefault::Unverified,
574        children: None,
575        constraint: None,
576    },
577];
578
579impl GffSchema for Uti {
580    fn schema() -> &'static [FieldSchema] {
581        static SCHEMA: &[FieldSchema] = &[
582            // --- Engine-read scalars (20) ---
583            FieldSchema {
584                label: "BaseItem",
585                expected_type: GffType::Int32,
586                life: FieldLife::Live,
587                required: false,
588                absent: AbsentDefault::Unverified,
589                children: None,
590                constraint: None,
591            },
592            FieldSchema {
593                label: "Tag",
594                expected_type: GffType::String,
595                life: FieldLife::Live,
596                required: false,
597                absent: AbsentDefault::Unverified,
598                children: None,
599                constraint: None,
600            },
601            FieldSchema {
602                label: "Identified",
603                expected_type: GffType::UInt8,
604                life: FieldLife::Live,
605                required: false,
606                absent: AbsentDefault::Unverified,
607                children: None,
608                constraint: None,
609            },
610            FieldSchema {
611                label: "Description",
612                expected_type: GffType::LocalizedString,
613                life: FieldLife::Live,
614                required: false,
615                absent: AbsentDefault::Unverified,
616                children: None,
617                constraint: None,
618            },
619            FieldSchema {
620                label: "DescIdentified",
621                expected_type: GffType::LocalizedString,
622                life: FieldLife::Live,
623                required: false,
624                absent: AbsentDefault::Unverified,
625                children: None,
626                constraint: None,
627            },
628            FieldSchema {
629                label: "LocalizedName",
630                expected_type: GffType::LocalizedString,
631                life: FieldLife::Live,
632                required: false,
633                absent: AbsentDefault::Unverified,
634                children: None,
635                constraint: None,
636            },
637            FieldSchema {
638                label: "StackSize",
639                expected_type: GffType::UInt16,
640                life: FieldLife::Live,
641                required: false,
642                absent: AbsentDefault::Unverified,
643                children: None,
644                constraint: None,
645            },
646            FieldSchema {
647                label: "Stolen",
648                expected_type: GffType::UInt8,
649                life: FieldLife::Live,
650                required: false,
651                absent: AbsentDefault::Unverified,
652                children: None,
653                constraint: None,
654            },
655            FieldSchema {
656                label: "Upgrades",
657                expected_type: GffType::UInt32,
658                life: FieldLife::Live,
659                required: false,
660                absent: AbsentDefault::Unverified,
661                children: None,
662                constraint: None,
663            },
664            FieldSchema {
665                label: "Dropable",
666                expected_type: GffType::UInt8,
667                life: FieldLife::Live,
668                required: false,
669                absent: AbsentDefault::Unverified,
670                children: None,
671                constraint: None,
672            },
673            FieldSchema {
674                label: "Pickpocketable",
675                expected_type: GffType::UInt8,
676                life: FieldLife::Live,
677                required: false,
678                absent: AbsentDefault::Unverified,
679                children: None,
680                constraint: None,
681            },
682            FieldSchema {
683                label: "NonEquippable",
684                expected_type: GffType::UInt8,
685                life: FieldLife::Live,
686                required: false,
687                absent: AbsentDefault::Unverified,
688                children: None,
689                constraint: None,
690            },
691            FieldSchema {
692                label: "ModelVariation",
693                expected_type: GffType::UInt8,
694                life: FieldLife::Live,
695                required: false,
696                absent: AbsentDefault::Unverified,
697                children: None,
698                constraint: None,
699            },
700            FieldSchema {
701                label: "TextureVar",
702                expected_type: GffType::UInt8,
703                life: FieldLife::Live,
704                required: false,
705                absent: AbsentDefault::Unverified,
706                children: None,
707                constraint: None,
708            },
709            FieldSchema {
710                label: "Charges",
711                expected_type: GffType::UInt8,
712                life: FieldLife::Live,
713                required: false,
714                absent: AbsentDefault::Unverified,
715                children: None,
716                constraint: None,
717            },
718            FieldSchema {
719                label: "MaxCharges",
720                expected_type: GffType::UInt8,
721                life: FieldLife::Live,
722                required: false,
723                absent: AbsentDefault::Unverified,
724                children: None,
725                constraint: None,
726            },
727            FieldSchema {
728                label: "NewItem",
729                expected_type: GffType::UInt8,
730                life: FieldLife::Live,
731                required: false,
732                absent: AbsentDefault::Unverified,
733                children: None,
734                constraint: None,
735            },
736            FieldSchema {
737                label: "DELETING",
738                expected_type: GffType::UInt8,
739                life: FieldLife::Live,
740                required: false,
741                absent: AbsentDefault::Unverified,
742                children: None,
743                constraint: None,
744            },
745            FieldSchema {
746                label: "AddCost",
747                expected_type: GffType::UInt32,
748                life: FieldLife::Live,
749                required: false,
750                absent: AbsentDefault::Unverified,
751                children: None,
752                constraint: None,
753            },
754            FieldSchema {
755                label: "Plot",
756                expected_type: GffType::UInt8,
757                life: FieldLife::Live,
758                required: false,
759                absent: AbsentDefault::Unverified,
760                children: None,
761                constraint: None,
762            },
763            // --- Engine-read list ---
764            FieldSchema {
765                label: "PropertiesList",
766                expected_type: GffType::List,
767                life: FieldLife::Live,
768                required: false,
769                absent: AbsentDefault::Unverified,
770                children: Some(PROPERTIES_LIST_CHILDREN),
771                constraint: None,
772            },
773            // --- Toolset-only fields (7) ---
774            FieldSchema {
775                label: "TemplateResRef",
776                expected_type: GffType::ResRef,
777                life: FieldLife::Live,
778                required: false,
779                absent: AbsentDefault::Unverified,
780                children: None,
781                constraint: None,
782            },
783            FieldSchema {
784                label: "Comment",
785                expected_type: GffType::String,
786                life: FieldLife::Live,
787                required: false,
788                absent: AbsentDefault::Unverified,
789                children: None,
790                constraint: None,
791            },
792            FieldSchema {
793                label: "PaletteID",
794                expected_type: GffType::UInt8,
795                life: FieldLife::Live,
796                required: false,
797                absent: AbsentDefault::Unverified,
798                children: None,
799                constraint: None,
800            },
801            FieldSchema {
802                label: "Cost",
803                expected_type: GffType::UInt32,
804                life: FieldLife::Live,
805                required: false,
806                absent: AbsentDefault::Unverified,
807                children: None,
808                constraint: None,
809            },
810            FieldSchema {
811                label: "BodyVariation",
812                expected_type: GffType::UInt8,
813                life: FieldLife::Live,
814                required: false,
815                absent: AbsentDefault::Unverified,
816                children: None,
817                constraint: None,
818            },
819            FieldSchema {
820                label: "UpgradeLevel",
821                expected_type: GffType::UInt8,
822                life: FieldLife::Live,
823                required: false,
824                absent: AbsentDefault::Unverified,
825                children: None,
826                constraint: None,
827            },
828            // Live, and the reason is the fallback rather than a read of its
829            // own: when `ModelVariation` is absent the engine reads this
830            // instead. `ModelPart2` and `ModelPart3` are not part of that and
831            // are declared dead.
832            FieldSchema {
833                label: "ModelPart1",
834                expected_type: GffType::UInt8,
835                life: FieldLife::Live,
836                required: false,
837                absent: AbsentDefault::Unverified,
838                children: None,
839                constraint: None,
840            },
841        ];
842        SCHEMA
843    }
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849
850    const TEST_UTI: &[u8] = include_bytes!(concat!(
851        env!("CARGO_MANIFEST_DIR"),
852        "/../../fixtures/test.uti"
853    ));
854
855    #[test]
856    fn model_variation_falls_back_to_the_legacy_model_part_label() {
857        // An ancient file carries `ModelPart1` and no `ModelVariation`, and
858        // the engine reads the former in that case. A round-trip cannot show
859        // this: the view writes the canonical label, so the old one is gone
860        // on the second pass whether or not it was ever read.
861        let mut root = GffStruct::new(-1);
862        root.push_field("ModelPart1", GffValue::UInt8(3));
863        let uti = Uti::from_gff(&Gff::new(*b"UTI ", root)).expect("parses");
864        assert_eq!(uti.model_variation, 3, "the legacy label must be read");
865
866        // Present but zero is not absent, so no fallback: the engine's own
867        // zero-check handles that case and would otherwise be bypassed here.
868        let mut both = GffStruct::new(-1);
869        both.push_field("ModelVariation", GffValue::UInt8(0));
870        both.push_field("ModelPart1", GffValue::UInt8(7));
871        let uti = Uti::from_gff(&Gff::new(*b"UTI ", both)).expect("parses");
872        assert_eq!(
873            uti.model_variation, 0,
874            "a present zero wins over the fallback"
875        );
876
877        // Written canonically: the legacy spelling is not re-emitted.
878        let written = uti.to_gff();
879        assert!(written.root.field("ModelVariation").is_some());
880        assert!(written.root.field("ModelPart1").is_none());
881    }
882
883    #[test]
884    fn reads_core_uti_fields_from_fixture() {
885        let uti = read_uti_from_bytes(TEST_UTI).expect("fixture must parse");
886
887        assert_eq!(uti.template_resref, "g_a_class4001");
888        assert_eq!(uti.base_item, 38);
889        assert_eq!(uti.name.string_ref.raw(), 5632);
890        assert_eq!(uti.description_unidentified.string_ref.raw(), 456);
891        assert_eq!(uti.description_identified.string_ref.raw(), 5633);
892        assert_eq!(uti.tag, "G_A_CLASS4001");
893        assert_eq!(uti.charges, 13);
894        assert_eq!(uti.max_charges, 13);
895        assert_eq!(uti.cost, 50);
896        assert_eq!(uti.stack_size, 1);
897        assert!(uti.plot);
898        assert_eq!(uti.add_cost, 50);
899        assert_eq!(uti.palette_id, 1);
900        assert_eq!(uti.comment, "itemo");
901        assert_eq!(uti.model_variation, 2);
902        assert_eq!(uti.body_variation, 3);
903        assert_eq!(uti.texture_variation, 1);
904        assert_eq!(uti.upgrade_level, 0);
905        assert!(uti.is_armor());
906        assert!(uti.stolen);
907        assert!(uti.identified);
908        assert!(!uti.droppable);
909        assert!(!uti.pickpocketable);
910        assert!(!uti.non_equippable);
911        assert!(!uti.new_item);
912        assert!(!uti.deleting);
913        assert_eq!(uti.upgrades, 0);
914
915        assert_eq!(uti.properties.len(), 2);
916        assert_eq!(uti.properties[0].property_name, 45);
917        assert_eq!(uti.properties[0].subtype, 6);
918        assert_eq!(uti.properties[0].cost_table, 1);
919        assert_eq!(uti.properties[0].cost_value, 1);
920        assert_eq!(uti.properties[0].param1, 255);
921        assert_eq!(uti.properties[0].param1_value, 1);
922        assert_eq!(uti.properties[0].chance_appear, 100);
923        assert_eq!(uti.properties[0].useable, None);
924        assert_eq!(uti.properties[0].uses_per_day, None);
925        assert_eq!(uti.properties[0].upgrade_type, None);
926        assert_eq!(uti.properties[1].upgrade_type, Some(24));
927    }
928
929    #[test]
930    fn all_fields_survive_typed_roundtrip() {
931        let uti = read_uti_from_bytes(TEST_UTI).expect("fixture must parse");
932        let bytes = write_uti_to_vec(&uti).expect("write succeeds");
933        let reparsed = read_uti_from_bytes(&bytes).expect("reparse succeeds");
934        assert_eq!(reparsed, uti);
935    }
936
937    #[test]
938    fn typed_edits_roundtrip_through_gff_writer() {
939        let mut uti = read_uti_from_bytes(TEST_UTI).expect("fixture must parse");
940        uti.tag = "g_a_class4001_mod".into();
941        uti.cost = 777;
942        uti.plot = false;
943        uti.max_charges = 55;
944        uti.droppable = true;
945        uti.pickpocketable = true;
946        uti.non_equippable = true;
947        uti.new_item = true;
948        uti.deleting = true;
949        uti.upgrades = 0xABCD;
950        uti.properties[0].chance_appear = 25;
951        uti.properties[0].useable = Some(true);
952        uti.properties[0].uses_per_day = Some(5);
953        uti.properties[0].upgrade_type = Some(5);
954
955        let encoded = write_uti_to_vec(&uti).expect("encode");
956        let reparsed = read_uti_from_bytes(&encoded).expect("decode");
957
958        assert_eq!(reparsed.tag, "g_a_class4001_mod");
959        assert_eq!(reparsed.cost, 777);
960        assert!(!reparsed.plot);
961        assert_eq!(reparsed.max_charges, 55);
962        assert!(reparsed.droppable);
963        assert!(reparsed.pickpocketable);
964        assert!(reparsed.non_equippable);
965        assert!(reparsed.new_item);
966        assert!(reparsed.deleting);
967        assert_eq!(reparsed.upgrades, 0xABCD);
968        assert_eq!(reparsed.properties[0].chance_appear, 25);
969        assert_eq!(reparsed.properties[0].useable, Some(true));
970        assert_eq!(reparsed.properties[0].uses_per_day, Some(5));
971        assert_eq!(reparsed.properties[0].upgrade_type, Some(5));
972    }
973
974    #[test]
975    fn applies_runtime_defaults_for_missing_charge_and_flag_fields() {
976        let mut root = GffStruct::new(-1);
977        root.push_field("TemplateResRef", GffValue::resref_lit("g_i_test"));
978        root.push_field("BaseItem", GffValue::Int32(1));
979        root.push_field(
980            "LocalizedName",
981            GffValue::LocalizedString(GffLocalizedString::new(1)),
982        );
983        root.push_field(
984            "Description",
985            GffValue::LocalizedString(GffLocalizedString::new(2)),
986        );
987        root.push_field(
988            "DescIdentified",
989            GffValue::LocalizedString(GffLocalizedString::new(3)),
990        );
991        root.push_field("PropertiesList", GffValue::List(Vec::new()));
992        let gff = Gff::new(*b"UTI ", root);
993
994        let uti = Uti::from_gff(&gff).expect("must parse");
995        assert_eq!(uti.charges, 50);
996        assert_eq!(uti.max_charges, 50);
997        assert!(uti.identified);
998        assert!(!uti.droppable);
999        assert!(!uti.pickpocketable);
1000        assert!(!uti.non_equippable);
1001        assert!(!uti.new_item);
1002        assert!(!uti.deleting);
1003        assert_eq!(uti.upgrades, 0);
1004    }
1005
1006    #[test]
1007    fn reads_runtime_state_fields_from_gff() {
1008        let mut root = GffStruct::new(-1);
1009        root.push_field("TemplateResRef", GffValue::resref_lit("g_i_test"));
1010        root.push_field("BaseItem", GffValue::Int32(1));
1011        root.push_field(
1012            "LocalizedName",
1013            GffValue::LocalizedString(GffLocalizedString::new(1)),
1014        );
1015        root.push_field(
1016            "Description",
1017            GffValue::LocalizedString(GffLocalizedString::new(2)),
1018        );
1019        root.push_field(
1020            "DescIdentified",
1021            GffValue::LocalizedString(GffLocalizedString::new(3)),
1022        );
1023        root.push_field("Charges", GffValue::UInt8(9));
1024        root.push_field("MaxCharges", GffValue::UInt8(12));
1025        root.push_field("Identified", GffValue::UInt8(0));
1026        root.push_field("Dropable", GffValue::UInt8(1));
1027        root.push_field("Pickpocketable", GffValue::UInt8(1));
1028        root.push_field("NonEquippable", GffValue::UInt8(1));
1029        root.push_field("NewItem", GffValue::UInt8(1));
1030        root.push_field("DELETING", GffValue::UInt8(1));
1031        root.push_field("Upgrades", GffValue::UInt32(0x1234_5678));
1032        root.push_field("PropertiesList", GffValue::List(Vec::new()));
1033        let gff = Gff::new(*b"UTI ", root);
1034
1035        let uti = Uti::from_gff(&gff).expect("must parse");
1036        assert_eq!(uti.charges, 9);
1037        assert_eq!(uti.max_charges, 12);
1038        assert!(!uti.identified);
1039        assert!(uti.droppable);
1040        assert!(uti.pickpocketable);
1041        assert!(uti.non_equippable);
1042        assert!(uti.new_item);
1043        assert!(uti.deleting);
1044        assert_eq!(uti.upgrades, 0x1234_5678);
1045    }
1046
1047    #[test]
1048    fn rejects_non_uti_file_type() {
1049        let gff = Gff::new(*b"UTC ", GffStruct::new(-1));
1050        let err = Uti::from_gff(&gff).expect_err("must fail");
1051        assert!(matches!(err, UtiError::UnsupportedFileType(file_type) if file_type == *b"UTC "));
1052    }
1053
1054    #[test]
1055    fn read_uti_from_reader_matches_bytes_path() {
1056        let mut cursor = Cursor::new(TEST_UTI);
1057        let via_reader = read_uti(&mut cursor).expect("reader parse");
1058        let via_bytes = read_uti_from_bytes(TEST_UTI).expect("bytes parse");
1059        assert_eq!(via_reader.template_resref, via_bytes.template_resref);
1060        assert_eq!(via_reader.properties.len(), via_bytes.properties.len());
1061    }
1062
1063    #[test]
1064    fn type_mismatch_on_properties_list_is_error() {
1065        let mut root = GffStruct::new(-1);
1066        root.push_field("PropertiesList", GffValue::UInt32(7));
1067        let gff = Gff::new(*b"UTI ", root);
1068        let err = Uti::from_gff(&gff).expect_err("must fail");
1069        assert!(matches!(
1070            err,
1071            UtiError::TypeMismatch {
1072                field: "PropertiesList",
1073                expected: "List"
1074            }
1075        ));
1076    }
1077
1078    #[test]
1079    fn write_uti_matches_direct_gff_writer() {
1080        let uti = read_uti_from_bytes(TEST_UTI).expect("fixture parse");
1081        let from_uti = write_uti_to_vec(&uti).expect("uti encode");
1082
1083        let gff = uti.to_gff();
1084        let from_gff = rakata_formats::write_gff_to_vec(&gff).expect("gff encode");
1085        assert_eq!(from_uti, from_gff);
1086    }
1087
1088    #[test]
1089    fn armor_base_item_helper_matches_known_values() {
1090        assert!(is_armor_base_item(38));
1091        assert!(is_armor_base_item(103));
1092        assert!(!is_armor_base_item(1));
1093        assert!(!is_armor_base_item(-1));
1094    }
1095
1096    #[test]
1097    fn schema_field_count() {
1098        assert_eq!(Uti::schema().len(), 28); // 20 engine + 1 list + 7 toolset
1099    }
1100
1101    #[test]
1102    fn schema_no_duplicate_labels() {
1103        let schema = Uti::schema();
1104        let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
1105        labels.sort();
1106        let before = labels.len();
1107        labels.dedup();
1108        assert_eq!(before, labels.len(), "duplicate labels in UTI schema");
1109    }
1110
1111    #[test]
1112    fn schema_properties_list_has_children() {
1113        let props = Uti::schema()
1114            .iter()
1115            .find(|f| f.label == "PropertiesList")
1116            .expect("test fixture must be valid");
1117        assert!(props.children.is_some());
1118        assert_eq!(
1119            props.children.expect("test fixture must be valid").len(),
1120            10
1121        );
1122    }
1123}