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