1use 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
50pub const fn is_armor_base_item(base_item: i32) -> bool {
56 match base_item {
57 35..=43 => true,
59 53 | 58 | 63..=65 | 69 | 71 | 85 | 89 | 98 | 100 | 102 | 103 => true,
61 _ => false,
62 }
63}
64
65#[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 #[gff(TemplateResRef, unexamined)]
80 pub template_resref: ResRef,
81 #[gff(BaseItem, constructed = 30)]
83 pub base_item: i32,
84 #[gff(LocalizedName, constructed)]
86 pub name: GffLocalizedString,
87 #[gff(Description, constructed)]
89 pub description_unidentified: GffLocalizedString,
90 #[gff(DescIdentified, constructed)]
92 pub description_identified: GffLocalizedString,
93 #[gff(Tag, constructed)]
95 pub tag: String,
96 #[gff(Charges, stamped = 50)]
98 pub charges: u8,
99 #[gff(Cost, unexamined)]
101 pub cost: u32,
102 #[gff(StackSize, constructed = 1)]
104 pub stack_size: u16,
105 #[gff(Plot, constructed)]
107 pub plot: bool,
108 #[gff(AddCost, constructed)]
110 pub add_cost: u32,
111 #[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 #[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 #[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 #[gff(TextureVar, stamped = 1)]
134 pub texture_variation: u8,
135 #[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 #[gff(Stolen, constructed)]
144 pub stolen: bool,
145 #[gff(Identified, stamped = true)]
147 pub identified: bool,
148 #[gff(Dropable, stamped, omit = audited_constant(993))]
150 pub droppable: bool,
151 #[gff(Pickpocketable, stamped, omit = audited_constant(993))]
153 pub pickpocketable: bool,
154 #[gff(NonEquippable, constructed, omit = audited_constant(993))]
156 pub non_equippable: bool,
157 #[gff(NewItem, constructed, omit = audited_constant(993))]
159 pub new_item: bool,
160 #[gff(DELETING, constructed, omit = audited_constant(993))]
162 pub deleting: bool,
163 #[gff(Upgrades, constructed, omit = audited_constant(993))]
165 pub upgrades: u32,
166 #[gff(PropertiesList, not_a_constant, list = UtiProperty, element_id = 0)]
168 pub properties: Vec<UtiProperty>,
169 #[gff(MaxCharges, from_siblings, manual_read, manual_write, not_a_constant, omit = matches("Charges", 993))]
174 pub max_charges: u8,
175 #[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 pub fn new() -> Self {
193 Self::default()
194 }
195
196 #[must_use]
198 pub fn is_armor(&self) -> bool {
199 is_armor_base_item(self.base_item)
200 }
201
202 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 pub fn to_gff(&self) -> Gff {
226 let mut root = GffStruct::new(-1);
227 self.write_declared(&mut root);
228
229 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#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
251pub struct UtiProperty {
252 #[gff(CostTable, required, undefined)]
254 pub cost_table: u8,
255 #[gff(CostValue, required, undefined)]
257 pub cost_value: u16,
258 #[gff(Param1, required, undefined)]
260 pub param1: u8,
261 #[gff(Param1Value, required, undefined)]
263 pub param1_value: u8,
264 #[gff(PropertyName, required, undefined)]
266 pub property_name: u16,
267 #[gff(Subtype, required, undefined)]
269 pub subtype: u16,
270 #[gff(ChanceAppear, required, undefined = 100)]
272 pub chance_appear: u8,
273 #[gff(Useable, not_a_constant, optional = bool)]
275 pub useable: Option<bool>,
276 #[gff(UsesPerDay, stamped, omit = audited_constant(993))]
282 pub uses_per_day: u8,
283 #[gff(UpgradeType, stamped, optional = u8)]
295 pub upgrade_type: Option<u8>,
296}
297
298#[derive(Debug, Error)]
300pub enum UtiError {
301 #[error("unsupported UTI file type: {0:?}")]
303 UnsupportedFileType([u8; 4]),
304 #[error(transparent)]
306 Gff(#[from] GffBinaryError),
307}
308
309#[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#[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#[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#[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 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 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 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); }
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}