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::{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 required: false,
305 children: None,
306 constraint: None,
307 },
308 FieldSchema {
309 label: "Infinite",
310 expected_type: GffType::UInt8,
311 required: false,
312 children: None,
313 constraint: None,
314 },
315 FieldSchema {
316 label: "ObjectId",
317 expected_type: GffType::UInt32,
318 required: false,
319 children: None,
320 constraint: None,
321 },
322 FieldSchema {
323 label: "Dropable",
324 expected_type: GffType::UInt8,
325 required: false,
326 children: None,
327 constraint: None,
328 },
329 FieldSchema {
330 label: "Repos_PosX",
331 expected_type: GffType::UInt16,
332 required: false,
333 children: None,
334 constraint: None,
335 },
336 FieldSchema {
337 label: "Repos_PosY",
338 expected_type: GffType::UInt16,
339 required: false,
340 children: None,
341 constraint: None,
342 },
343 FieldSchema {
344 label: "Repos_Posy",
345 expected_type: GffType::UInt16,
346 required: false,
347 children: None,
348 constraint: None,
349 },
350];
351
352impl GffSchema for Utm {
353 fn schema() -> &'static [FieldSchema] {
354 static SCHEMA: &[FieldSchema] = &[
355 FieldSchema {
357 label: "Tag",
358 expected_type: GffType::String,
359 required: false,
360 children: None,
361 constraint: None,
362 },
363 FieldSchema {
364 label: "LocName",
365 expected_type: GffType::LocalizedString,
366 required: false,
367 children: None,
368 constraint: None,
369 },
370 FieldSchema {
371 label: "MarkDown",
372 expected_type: GffType::Int32,
373 required: false,
374 children: None,
375 constraint: None,
376 },
377 FieldSchema {
378 label: "MarkUp",
379 expected_type: GffType::Int32,
380 required: false,
381 children: None,
382 constraint: None,
383 },
384 FieldSchema {
385 label: "OnOpenStore",
386 expected_type: GffType::ResRef,
387 required: false,
388 children: None,
389 constraint: None,
390 },
391 FieldSchema {
392 label: "BuySellFlag",
393 expected_type: GffType::UInt8,
394 required: false,
395 children: None,
396 constraint: None,
397 },
398 FieldSchema {
400 label: "ItemList",
401 expected_type: GffType::List,
402 required: false,
403 children: Some(ITEM_LIST_CHILDREN),
404 constraint: None,
405 },
406 FieldSchema {
408 label: "ResRef",
409 expected_type: GffType::ResRef,
410 required: false,
411 children: None,
412 constraint: None,
413 },
414 FieldSchema {
415 label: "Comment",
416 expected_type: GffType::String,
417 required: false,
418 children: None,
419 constraint: None,
420 },
421 FieldSchema {
422 label: "ID",
423 expected_type: GffType::UInt8,
424 required: false,
425 children: None,
426 constraint: None,
427 },
428 ];
429 SCHEMA
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 const TEST_UTM: &[u8] = include_bytes!(concat!(
438 env!("CARGO_MANIFEST_DIR"),
439 "/../../fixtures/test.utm"
440 ));
441
442 #[test]
443 fn reads_core_utm_fields_from_fixture() {
444 let utm = read_utm_from_bytes(TEST_UTM).expect("fixture must parse");
445
446 assert_eq!(utm.resref, "dan_droid");
447 assert_eq!(utm.tag, "dan_droid");
448 assert_eq!(utm.name.string_ref.raw(), 33_399);
449 assert_eq!(utm.mark_up, 100);
450 assert_eq!(utm.mark_down, 25);
451 assert_eq!(utm.on_open_store, "onopenstore");
452 assert_eq!(utm.comment, "comment");
453 assert_eq!(utm.id, 5);
454 assert!(utm.can_buy);
455 assert!(utm.can_sell);
456 assert_eq!(utm.buy_sell_unknown_bits, 0);
457
458 assert_eq!(utm.inventory.len(), 2);
459 assert_eq!(utm.inventory[0].inventory_res, "g_i_drdltplat001");
460 assert!(!utm.inventory[0].droppable);
461 assert!(!utm.inventory[0].infinite);
462 assert_eq!(utm.inventory[0].repos_pos_x, 0);
463
464 assert_eq!(utm.inventory[1].inventory_res, "g_i_drdltplat002");
465 assert!(!utm.inventory[1].droppable);
466 assert!(utm.inventory[1].infinite);
467 assert_eq!(utm.inventory[1].repos_pos_x, 1);
468 }
469
470 #[test]
471 fn all_fields_survive_typed_roundtrip() {
472 let utm = read_utm_from_bytes(TEST_UTM).expect("fixture must parse");
473 let bytes = write_utm_to_vec(&utm).expect("write succeeds");
474 let reparsed = read_utm_from_bytes(&bytes).expect("reparse succeeds");
475
476 assert_eq!(reparsed, utm);
477 }
478
479 #[test]
480 fn buy_sell_unknown_bits_are_preserved() {
481 let mut gff = read_gff_from_bytes(TEST_UTM).expect("fixture must parse");
482 gff.root.fields.retain(|field| field.label != "BuySellFlag");
483 gff.root
484 .push_field("BuySellFlag", GffValue::UInt8(0b0000_0100));
485
486 let utm = Utm::from_gff(&gff).expect("typed parse");
487 assert!(!utm.can_buy);
488 assert!(!utm.can_sell);
489 assert_eq!(utm.buy_sell_unknown_bits, 0b0000_0100);
490
491 let rebuilt = utm.to_gff();
492 assert_eq!(
493 rebuilt.root.field("BuySellFlag"),
494 Some(&GffValue::UInt8(0b0000_0100))
495 );
496 }
497
498 #[test]
499 fn typed_edits_roundtrip_through_gff_writer() {
500 let mut utm = read_utm_from_bytes(TEST_UTM).expect("fixture must parse");
501 utm.tag = "dan_droid_rust".into();
502 utm.can_buy = false;
503 utm.can_sell = true;
504 utm.inventory[0].droppable = true;
505
506 let bytes = write_utm_to_vec(&utm).expect("write succeeds");
507 let reparsed = read_utm_from_bytes(&bytes).expect("reparse succeeds");
508
509 assert_eq!(reparsed.tag, "dan_droid_rust");
510 assert!(!reparsed.can_buy);
511 assert!(reparsed.can_sell);
512 assert!(reparsed.inventory[0].droppable);
513 }
514
515 #[test]
516 fn read_utm_from_reader_matches_bytes_path() {
517 let mut cursor = Cursor::new(TEST_UTM);
518 let via_reader = read_utm(&mut cursor).expect("reader parse succeeds");
519 let via_bytes = read_utm_from_bytes(TEST_UTM).expect("bytes parse succeeds");
520
521 assert_eq!(via_reader, via_bytes);
522 }
523
524 #[test]
525 fn rejects_non_utm_file_type() {
526 let mut gff = read_gff_from_bytes(TEST_UTM).expect("fixture must parse");
527 gff.file_type = *b"UTI ";
528
529 let err = Utm::from_gff(&gff).expect_err("UTI must be rejected as UTM input");
530 assert!(matches!(
531 err,
532 UtmError::UnsupportedFileType(file_type) if file_type == *b"UTI "
533 ));
534 }
535
536 #[test]
537 fn type_mismatch_on_item_list_is_error() {
538 let mut gff = read_gff_from_bytes(TEST_UTM).expect("fixture must parse");
539 gff.root.fields.retain(|field| field.label != "ItemList");
540 gff.root.push_field("ItemList", GffValue::UInt32(99));
541
542 let err = Utm::from_gff(&gff).expect_err("type mismatch must be rejected");
543 assert!(matches!(
544 err,
545 UtmError::TypeMismatch {
546 field: "ItemList",
547 expected: "List",
548 }
549 ));
550 }
551
552 #[test]
553 fn write_utm_matches_direct_gff_writer() {
554 let utm = read_utm_from_bytes(TEST_UTM).expect("fixture must parse");
555
556 let via_typed = write_utm_to_vec(&utm).expect("typed write succeeds");
557
558 let mut direct = Cursor::new(Vec::new());
559 write_gff(&mut direct, &utm.to_gff()).expect("direct write succeeds");
560
561 assert_eq!(via_typed, direct.into_inner());
562 }
563
564 #[test]
565 fn schema_field_count() {
566 assert_eq!(Utm::schema().len(), 10); }
568
569 #[test]
570 fn schema_no_duplicate_labels() {
571 let schema = Utm::schema();
572 let mut labels: Vec<&str> = schema.iter().map(|f| f.label).collect();
573 labels.sort();
574 let before = labels.len();
575 labels.dedup();
576 assert_eq!(before, labels.len(), "duplicate labels in UTM schema");
577 }
578
579 #[test]
580 fn schema_item_list_has_children() {
581 let item_list = Utm::schema()
582 .iter()
583 .find(|f| f.label == "ItemList")
584 .expect("test fixture must be valid");
585 assert!(item_list.children.is_some());
586 assert_eq!(
587 item_list
588 .children
589 .expect("test fixture must be valid")
590 .len(),
591 7
592 );
593 }
594}