Skip to main content

rakata_generics/
utm.rs

1//! UTM (`.utm`) typed generic wrapper.
2//!
3//! UTM resources are GFF-backed merchant/store templates.
4//!
5//! ## Field Layout
6//! ```text
7//! UTM root struct
8//! +-- ResRef / Tag / LocName / Comment
9//! +-- MarkUp / MarkDown / OnOpenStore / BuySellFlag / ID
10//! +-- ItemList                         (List<Struct>)
11//!     +-- InventoryRes / Infinite / Dropable
12//!     +-- Repos_PosX / Repos_PosY
13//! ```
14
15use 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/// Typed UTM model built from/to [`Gff`] data.
30#[derive(Debug, Clone, PartialEq)]
31pub struct Utm {
32    /// Merchant template resref (`ResRef`).
33    pub resref: ResRef,
34    /// Merchant tag (`Tag`).
35    pub tag: String,
36    /// Localized merchant name (`LocName`).
37    pub name: GffLocalizedString,
38    /// Markup percentage (`MarkUp`).
39    pub mark_up: i32,
40    /// Markdown percentage (`MarkDown`).
41    pub mark_down: i32,
42    /// On-open-store script (`OnOpenStore`).
43    pub on_open_store: ResRef,
44    /// Toolset comment (`Comment`).
45    pub comment: String,
46    /// Deprecated ID field (`ID`).
47    pub id: u8,
48    /// Whether store can buy from the player (bit 0 of `BuySellFlag`).
49    pub can_buy: bool,
50    /// Whether store can sell to the player (bit 1 of `BuySellFlag`).
51    pub can_sell: bool,
52    /// Preserved unknown bits from `BuySellFlag`. Only bit 0 (buy) and bit 1 (sell) are standard.
53    pub buy_sell_unknown_bits: u8,
54    /// Inventory entries (`ItemList`).
55    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    /// Creates an empty UTM value.
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    /// Builds typed UTM data from a parsed GFF container.
84    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        // K1 `LoadStore` reads `BuySellFlag` as raw bits and runtime behavior
106        // is controlled by bit 0 (buy) + bit 1 (sell).
107        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    /// Converts this typed UTM value into a GFF container.
128    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/// One UTM inventory entry from the `ItemList` field.
165#[derive(Debug, Clone, PartialEq)]
166pub struct UtmInventoryItem {
167    /// Inventory item resref (`InventoryRes`).
168    pub inventory_res: ResRef,
169    /// Infinite-stock flag (`Infinite`).
170    pub infinite: bool,
171    /// Droppable flag (`Dropable`).
172    pub droppable: bool,
173    /// Repository position X (`Repos_PosX`). Legacy field completely ignored by the engine, which builds the UI dynamically.
174    pub repos_pos_x: u16,
175    /// Repository position Y (`Repos_PosY`). Legacy field completely ignored by the engine, which builds the UI dynamically.
176    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            // Normalize legacy `Repos_Posy` (lowercase y) to canonical `Repos_PosY`.
187            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    /// Returns this item's repository/grid position as a shared typed value.
227    pub fn position(&self) -> InventoryGridPosition {
228        InventoryGridPosition {
229            x: self.repos_pos_x,
230            y: self.repos_pos_y,
231        }
232    }
233
234    /// Applies repository/grid position from a shared typed value.
235    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/// Errors produced while reading or writing typed UTM data.
242#[derive(Debug, Error)]
243pub enum UtmError {
244    /// Source file type is not supported by this parser.
245    #[error("unsupported UTM file type: {0:?}")]
246    UnsupportedFileType([u8; 4]),
247    /// A required container field had an unexpected runtime type.
248    #[error("UTM field `{field}` has incompatible type (expected {expected})")]
249    TypeMismatch {
250        /// Field label where mismatch occurred.
251        field: &'static str,
252        /// Expected runtime value kind.
253        expected: &'static str,
254    },
255    /// Underlying GFF parser/writer error.
256    #[error(transparent)]
257    Gff(#[from] GffBinaryError),
258}
259
260/// Reads typed UTM data from a reader at the current stream position.
261#[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/// Reads typed UTM data directly from bytes.
271#[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/// Writes typed UTM data to an output writer.
281#[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/// Serializes typed UTM data into a byte vector.
292#[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
299/// UTM `ItemList` entry child schema.
300static 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            // --- Engine-read scalars (6) ---
370            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            // --- Engine-read list ---
425            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            // --- Toolset-only fields (3) ---
435            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); // 6 engine + 1 list + 3 toolset
601    }
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}