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::{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`.
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`).
174    pub repos_pos_x: u16,
175    /// Repository position Y (`Repos_PosY`).
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        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            // --- Engine-read scalars (6) ---
356            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            // --- Engine-read list ---
399            FieldSchema {
400                label: "ItemList",
401                expected_type: GffType::List,
402                required: false,
403                children: Some(ITEM_LIST_CHILDREN),
404                constraint: None,
405            },
406            // --- Toolset-only fields (3) ---
407            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); // 6 engine + 1 list + 3 toolset
567    }
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}