1use std::io::{Cursor, Read, Write};
16
17use crate::gff_helpers::{
18 get_bool, get_i32, get_locstring, get_resref, get_string, get_u16, get_u8, upsert_field,
19};
20use crate::shared::InventoryGridPosition;
21use rakata_core::{ResRef, StrRef};
22use rakata_formats::{
23 gff_schema::{AbsentDefault, FieldLife, FieldSchema, GffSchema, GffType},
24 read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffStruct,
25 GffValue,
26};
27use thiserror::Error;
28
29#[derive(Debug, Clone, PartialEq)]
31pub struct Utm {
32 pub resref: ResRef,
34 pub tag: String,
36 pub name: GffLocalizedString,
38 pub mark_up: i32,
40 pub mark_down: i32,
42 pub on_open_store: ResRef,
44 pub comment: String,
46 pub id: u8,
48 pub can_buy: bool,
50 pub can_sell: bool,
52 pub buy_sell_unknown_bits: u8,
54 pub inventory: Vec<UtmInventoryItem>,
56}
57
58impl Default for Utm {
59 fn default() -> Self {
60 Self {
61 resref: ResRef::blank(),
62 tag: String::new(),
63 name: GffLocalizedString::new(StrRef::invalid()),
64 mark_up: 0,
65 mark_down: 0,
66 on_open_store: ResRef::blank(),
67 comment: String::new(),
68 id: 0,
69 can_buy: false,
70 can_sell: false,
71 buy_sell_unknown_bits: 0,
72 inventory: Vec::new(),
73 }
74 }
75}
76
77impl Utm {
78 pub fn new() -> Self {
80 Self::default()
81 }
82
83 pub fn from_gff(gff: &Gff) -> Result<Self, UtmError> {
85 if gff.file_type != *b"UTM " && gff.file_type != *b"GFF " {
86 return Err(UtmError::UnsupportedFileType(gff.file_type));
87 }
88
89 let root = &gff.root;
90
91 let inventory = match root.field("ItemList") {
92 Some(GffValue::List(item_structs)) => item_structs
93 .iter()
94 .map(UtmInventoryItem::from_struct)
95 .collect::<Vec<_>>(),
96 Some(_) => {
97 return Err(UtmError::TypeMismatch {
98 field: "ItemList",
99 expected: "List",
100 });
101 }
102 None => Vec::new(),
103 };
104
105 let buy_sell_flag = get_u8(root, "BuySellFlag").unwrap_or(0);
108
109 Ok(Self {
110 resref: get_resref(root, "ResRef").unwrap_or_default(),
111 tag: get_string(root, "Tag").unwrap_or_default(),
112 name: get_locstring(root, "LocName")
113 .cloned()
114 .unwrap_or_else(|| GffLocalizedString::new(StrRef::invalid())),
115 mark_up: get_i32(root, "MarkUp").unwrap_or(0),
116 mark_down: get_i32(root, "MarkDown").unwrap_or(0),
117 on_open_store: get_resref(root, "OnOpenStore").unwrap_or_default(),
118 comment: get_string(root, "Comment").unwrap_or_default(),
119 id: get_u8(root, "ID").unwrap_or(0),
120 can_buy: (buy_sell_flag & 0b0000_0001) != 0,
121 can_sell: (buy_sell_flag & 0b0000_0010) != 0,
122 buy_sell_unknown_bits: buy_sell_flag & !0b0000_0011,
123 inventory,
124 })
125 }
126
127 pub fn to_gff(&self) -> Gff {
129 let mut root = GffStruct::new(-1);
130
131 upsert_field(&mut root, "ResRef", GffValue::ResRef(self.resref));
132 upsert_field(&mut root, "Tag", GffValue::String(self.tag.clone()));
133 upsert_field(
134 &mut root,
135 "LocName",
136 GffValue::LocalizedString(self.name.clone()),
137 );
138 upsert_field(&mut root, "MarkUp", GffValue::Int32(self.mark_up));
139 upsert_field(&mut root, "MarkDown", GffValue::Int32(self.mark_down));
140 upsert_field(
141 &mut root,
142 "OnOpenStore",
143 GffValue::ResRef(self.on_open_store),
144 );
145 upsert_field(&mut root, "Comment", GffValue::String(self.comment.clone()));
146 upsert_field(&mut root, "ID", GffValue::UInt8(self.id));
147
148 let buy_sell_flag =
149 self.buy_sell_unknown_bits | u8::from(self.can_buy) | (u8::from(self.can_sell) << 1);
150 upsert_field(&mut root, "BuySellFlag", GffValue::UInt8(buy_sell_flag));
151
152 let item_structs = self
153 .inventory
154 .iter()
155 .enumerate()
156 .map(|(index, item)| item.to_struct(index))
157 .collect::<Vec<GffStruct>>();
158 upsert_field(&mut root, "ItemList", GffValue::List(item_structs));
159
160 Gff::new(*b"UTM ", root)
161 }
162}
163
164#[derive(Debug, Clone, PartialEq)]
166pub struct UtmInventoryItem {
167 pub inventory_res: ResRef,
169 pub infinite: bool,
171 pub droppable: bool,
173 pub repos_pos_x: u16,
175 pub repos_pos_y: u16,
177}
178
179impl UtmInventoryItem {
180 fn from_struct(structure: &GffStruct) -> Self {
181 Self {
182 inventory_res: get_resref(structure, "InventoryRes").unwrap_or_default(),
183 infinite: get_bool(structure, "Infinite").unwrap_or(false),
184 droppable: get_bool(structure, "Dropable").unwrap_or(false),
185 repos_pos_x: get_u16(structure, "Repos_PosX").unwrap_or(0),
186 repos_pos_y: get_u16(structure, "Repos_PosY")
188 .or_else(|| get_u16(structure, "Repos_Posy"))
189 .unwrap_or(0),
190 }
191 }
192
193 fn to_struct(&self, index: usize) -> GffStruct {
194 let mut structure =
195 GffStruct::new(i32::try_from(index).expect("store item index fits i32"));
196
197 upsert_field(
198 &mut structure,
199 "InventoryRes",
200 GffValue::ResRef(self.inventory_res),
201 );
202 upsert_field(
203 &mut structure,
204 "Repos_PosX",
205 GffValue::UInt16(self.repos_pos_x),
206 );
207 upsert_field(
208 &mut structure,
209 "Repos_PosY",
210 GffValue::UInt16(self.repos_pos_y),
211 );
212 upsert_field(
213 &mut structure,
214 "Dropable",
215 GffValue::UInt8(u8::from(self.droppable)),
216 );
217 upsert_field(
218 &mut structure,
219 "Infinite",
220 GffValue::UInt8(u8::from(self.infinite)),
221 );
222
223 structure
224 }
225
226 pub fn position(&self) -> InventoryGridPosition {
228 InventoryGridPosition {
229 x: self.repos_pos_x,
230 y: self.repos_pos_y,
231 }
232 }
233
234 pub fn set_position(&mut self, position: InventoryGridPosition) {
236 self.repos_pos_x = position.x;
237 self.repos_pos_y = position.y;
238 }
239}
240
241#[derive(Debug, Error)]
243pub enum UtmError {
244 #[error("unsupported UTM file type: {0:?}")]
246 UnsupportedFileType([u8; 4]),
247 #[error("UTM field `{field}` has incompatible type (expected {expected})")]
249 TypeMismatch {
250 field: &'static str,
252 expected: &'static str,
254 },
255 #[error(transparent)]
257 Gff(#[from] GffBinaryError),
258}
259
260#[cfg_attr(
262 feature = "tracing",
263 tracing::instrument(level = "debug", skip(reader))
264)]
265pub fn read_utm<R: Read>(reader: &mut R) -> Result<Utm, UtmError> {
266 let gff = read_gff(reader)?;
267 Utm::from_gff(&gff)
268}
269
270#[cfg_attr(
272 feature = "tracing",
273 tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
274)]
275pub fn read_utm_from_bytes(bytes: &[u8]) -> Result<Utm, UtmError> {
276 let gff = read_gff_from_bytes(bytes)?;
277 Utm::from_gff(&gff)
278}
279
280#[cfg_attr(
282 feature = "tracing",
283 tracing::instrument(level = "debug", skip(writer, utm))
284)]
285pub fn write_utm<W: Write>(writer: &mut W, utm: &Utm) -> Result<(), UtmError> {
286 let gff = utm.to_gff();
287 write_gff(writer, &gff)?;
288 Ok(())
289}
290
291#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utm)))]
293pub fn write_utm_to_vec(utm: &Utm) -> Result<Vec<u8>, UtmError> {
294 let mut cursor = Cursor::new(Vec::new());
295 write_utm(&mut cursor, utm)?;
296 Ok(cursor.into_inner())
297}
298
299static ITEM_LIST_CHILDREN: &[FieldSchema] = &[
301 FieldSchema {
302 label: "InventoryRes",
303 expected_type: GffType::ResRef,
304 life: FieldLife::Live,
305 required: false,
306 absent: AbsentDefault::Unverified,
307 children: None,
308 constraint: None,
309 },
310 FieldSchema {
311 label: "Infinite",
312 expected_type: GffType::UInt8,
313 life: FieldLife::Live,
314 required: false,
315 absent: AbsentDefault::Unverified,
316 children: None,
317 constraint: None,
318 },
319 FieldSchema {
320 label: "ObjectId",
321 expected_type: GffType::UInt32,
322 life: FieldLife::Live,
323 required: false,
324 absent: AbsentDefault::Unverified,
325 children: None,
326 constraint: None,
327 },
328 FieldSchema {
329 label: "Dropable",
330 expected_type: GffType::UInt8,
331 life: FieldLife::Live,
332 required: false,
333 absent: AbsentDefault::Unverified,
334 children: None,
335 constraint: None,
336 },
337 FieldSchema {
338 label: "Repos_PosX",
339 expected_type: GffType::UInt16,
340 life: FieldLife::Live,
341 required: false,
342 absent: AbsentDefault::Unverified,
343 children: None,
344 constraint: None,
345 },
346 FieldSchema {
347 label: "Repos_PosY",
348 expected_type: GffType::UInt16,
349 life: FieldLife::Live,
350 required: false,
351 absent: AbsentDefault::Unverified,
352 children: None,
353 constraint: None,
354 },
355 FieldSchema {
356 label: "Repos_Posy",
357 expected_type: GffType::UInt16,
358 life: FieldLife::Live,
359 required: false,
360 absent: AbsentDefault::Unverified,
361 children: None,
362 constraint: None,
363 },
364];
365
366impl GffSchema for Utm {
367 fn schema() -> &'static [FieldSchema] {
368 static SCHEMA: &[FieldSchema] = &[
369 FieldSchema {
371 label: "Tag",
372 expected_type: GffType::String,
373 life: FieldLife::Live,
374 required: false,
375 absent: AbsentDefault::Unverified,
376 children: None,
377 constraint: None,
378 },
379 FieldSchema {
380 label: "LocName",
381 expected_type: GffType::LocalizedString,
382 life: FieldLife::Live,
383 required: false,
384 absent: AbsentDefault::Unverified,
385 children: None,
386 constraint: None,
387 },
388 FieldSchema {
389 label: "MarkDown",
390 expected_type: GffType::Int32,
391 life: FieldLife::Live,
392 required: false,
393 absent: AbsentDefault::Unverified,
394 children: None,
395 constraint: None,
396 },
397 FieldSchema {
398 label: "MarkUp",
399 expected_type: GffType::Int32,
400 life: FieldLife::Live,
401 required: false,
402 absent: AbsentDefault::Unverified,
403 children: None,
404 constraint: None,
405 },
406 FieldSchema {
407 label: "OnOpenStore",
408 expected_type: GffType::ResRef,
409 life: FieldLife::Live,
410 required: false,
411 absent: AbsentDefault::Unverified,
412 children: None,
413 constraint: None,
414 },
415 FieldSchema {
416 label: "BuySellFlag",
417 expected_type: GffType::UInt8,
418 life: FieldLife::Live,
419 required: false,
420 absent: AbsentDefault::Unverified,
421 children: None,
422 constraint: None,
423 },
424 FieldSchema {
426 label: "ItemList",
427 expected_type: GffType::List,
428 life: FieldLife::Live,
429 required: false,
430 absent: AbsentDefault::Unverified,
431 children: Some(ITEM_LIST_CHILDREN),
432 constraint: None,
433 },
434 FieldSchema {
436 label: "ResRef",
437 expected_type: GffType::ResRef,
438 life: FieldLife::Live,
439 required: false,
440 absent: AbsentDefault::Unverified,
441 children: None,
442 constraint: None,
443 },
444 FieldSchema {
445 label: "Comment",
446 expected_type: GffType::String,
447 life: FieldLife::Live,
448 required: false,
449 absent: AbsentDefault::Unverified,
450 children: None,
451 constraint: None,
452 },
453 FieldSchema {
454 label: "ID",
455 expected_type: GffType::UInt8,
456 life: FieldLife::Live,
457 required: false,
458 absent: AbsentDefault::Unverified,
459 children: None,
460 constraint: None,
461 },
462 ];
463 SCHEMA
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 const TEST_UTM: &[u8] = include_bytes!(concat!(
472 env!("CARGO_MANIFEST_DIR"),
473 "/../../fixtures/test.utm"
474 ));
475
476 #[test]
477 fn reads_core_utm_fields_from_fixture() {
478 let utm = read_utm_from_bytes(TEST_UTM).expect("fixture must parse");
479
480 assert_eq!(utm.resref, "dan_droid");
481 assert_eq!(utm.tag, "dan_droid");
482 assert_eq!(utm.name.string_ref.raw(), 33_399);
483 assert_eq!(utm.mark_up, 100);
484 assert_eq!(utm.mark_down, 25);
485 assert_eq!(utm.on_open_store, "onopenstore");
486 assert_eq!(utm.comment, "comment");
487 assert_eq!(utm.id, 5);
488 assert!(utm.can_buy);
489 assert!(utm.can_sell);
490 assert_eq!(utm.buy_sell_unknown_bits, 0);
491
492 assert_eq!(utm.inventory.len(), 2);
493 assert_eq!(utm.inventory[0].inventory_res, "g_i_drdltplat001");
494 assert!(!utm.inventory[0].droppable);
495 assert!(!utm.inventory[0].infinite);
496 assert_eq!(utm.inventory[0].repos_pos_x, 0);
497
498 assert_eq!(utm.inventory[1].inventory_res, "g_i_drdltplat002");
499 assert!(!utm.inventory[1].droppable);
500 assert!(utm.inventory[1].infinite);
501 assert_eq!(utm.inventory[1].repos_pos_x, 1);
502 }
503
504 #[test]
505 fn all_fields_survive_typed_roundtrip() {
506 let utm = read_utm_from_bytes(TEST_UTM).expect("fixture must parse");
507 let bytes = write_utm_to_vec(&utm).expect("write succeeds");
508 let reparsed = read_utm_from_bytes(&bytes).expect("reparse succeeds");
509
510 assert_eq!(reparsed, utm);
511 }
512
513 #[test]
514 fn buy_sell_unknown_bits_are_preserved() {
515 let mut gff = read_gff_from_bytes(TEST_UTM).expect("fixture must parse");
516 gff.root.fields.retain(|field| field.label != "BuySellFlag");
517 gff.root
518 .push_field("BuySellFlag", GffValue::UInt8(0b0000_0100));
519
520 let utm = Utm::from_gff(&gff).expect("typed parse");
521 assert!(!utm.can_buy);
522 assert!(!utm.can_sell);
523 assert_eq!(utm.buy_sell_unknown_bits, 0b0000_0100);
524
525 let rebuilt = utm.to_gff();
526 assert_eq!(
527 rebuilt.root.field("BuySellFlag"),
528 Some(&GffValue::UInt8(0b0000_0100))
529 );
530 }
531
532 #[test]
533 fn typed_edits_roundtrip_through_gff_writer() {
534 let mut utm = read_utm_from_bytes(TEST_UTM).expect("fixture must parse");
535 utm.tag = "dan_droid_rust".into();
536 utm.can_buy = false;
537 utm.can_sell = true;
538 utm.inventory[0].droppable = true;
539
540 let bytes = write_utm_to_vec(&utm).expect("write succeeds");
541 let reparsed = read_utm_from_bytes(&bytes).expect("reparse succeeds");
542
543 assert_eq!(reparsed.tag, "dan_droid_rust");
544 assert!(!reparsed.can_buy);
545 assert!(reparsed.can_sell);
546 assert!(reparsed.inventory[0].droppable);
547 }
548
549 #[test]
550 fn read_utm_from_reader_matches_bytes_path() {
551 let mut cursor = Cursor::new(TEST_UTM);
552 let via_reader = read_utm(&mut cursor).expect("reader parse succeeds");
553 let via_bytes = read_utm_from_bytes(TEST_UTM).expect("bytes parse succeeds");
554
555 assert_eq!(via_reader, via_bytes);
556 }
557
558 #[test]
559 fn rejects_non_utm_file_type() {
560 let mut gff = read_gff_from_bytes(TEST_UTM).expect("fixture must parse");
561 gff.file_type = *b"UTI ";
562
563 let err = Utm::from_gff(&gff).expect_err("UTI must be rejected as UTM input");
564 assert!(matches!(
565 err,
566 UtmError::UnsupportedFileType(file_type) if file_type == *b"UTI "
567 ));
568 }
569
570 #[test]
571 fn type_mismatch_on_item_list_is_error() {
572 let mut gff = read_gff_from_bytes(TEST_UTM).expect("fixture must parse");
573 gff.root.fields.retain(|field| field.label != "ItemList");
574 gff.root.push_field("ItemList", GffValue::UInt32(99));
575
576 let err = Utm::from_gff(&gff).expect_err("type mismatch must be rejected");
577 assert!(matches!(
578 err,
579 UtmError::TypeMismatch {
580 field: "ItemList",
581 expected: "List",
582 }
583 ));
584 }
585
586 #[test]
587 fn write_utm_matches_direct_gff_writer() {
588 let utm = read_utm_from_bytes(TEST_UTM).expect("fixture must parse");
589
590 let via_typed = write_utm_to_vec(&utm).expect("typed write succeeds");
591
592 let mut direct = Cursor::new(Vec::new());
593 write_gff(&mut direct, &utm.to_gff()).expect("direct write succeeds");
594
595 assert_eq!(via_typed, direct.into_inner());
596 }
597
598 #[test]
599 fn schema_field_count() {
600 assert_eq!(Utm::schema().len(), 10); }
602
603 #[test]
604 fn schema_no_duplicate_labels() {
605 let schema = Utm::schema();
606 let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
607 labels.sort();
608 let before = labels.len();
609 labels.dedup();
610 assert_eq!(before, labels.len(), "duplicate labels in UTM schema");
611 }
612
613 #[test]
614 fn schema_item_list_has_children() {
615 let item_list = Utm::schema()
616 .iter()
617 .find(|f| f.label == "ItemList")
618 .expect("test fixture must be valid");
619 assert!(item_list.children.is_some());
620 assert_eq!(
621 item_list
622 .children
623 .expect("test fixture must be valid")
624 .len(),
625 7
626 );
627 }
628}