Skip to main content

rakata_generics/
utm.rs

1//! UTM (`.utm`) typed generic wrapper.
2//!
3//! Merchants are storefronts. A shop behaves as a container interface that buys
4//! and sells `.uti` items, so a `.utm` is mostly economic markups and inventory
5//! parameters rather than item data of its own.
6//!
7//! ## Field Layout
8//! ```text
9//! UTM root struct
10//! +-- ResRef / Tag / LocName / Comment
11//! +-- MarkUp / MarkDown / OnOpenStore / BuySellFlag / ID
12//! +-- ItemList                         (List<Struct>)
13//!     +-- InventoryRes / Infinite / Dropable
14//!     +-- Repos_PosX / Repos_Posy
15//! ```
16
17use std::io::{Cursor, Read, Write};
18
19use rakata_core::ResRef;
20use rakata_formats::gff::get_u16;
21use rakata_formats::schema::FromGff;
22use rakata_formats::GENERIC_FILE_TYPE;
23use rakata_formats::{
24    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffLocalizedString, GffModel,
25    GffStruct,
26};
27use thiserror::Error;
28
29/// Bit 0 of `BuySellFlag`: the store buys from the player.
30const BUY_BIT: u8 = 0b0000_0001;
31/// Bit 1 of `BuySellFlag`: the store sells to the player.
32const SELL_BIT: u8 = 0b0000_0010;
33
34/// Typed UTM model built from/to [`Gff`] data.
35#[derive(Debug, Clone, PartialEq, GffModel)]
36pub struct Utm {
37    /// Merchant template resref (`ResRef`).
38    #[gff(ResRef, unexamined)]
39    pub resref: ResRef,
40    /// Merchant tag (`Tag`).
41    #[gff(Tag, stamped)]
42    pub tag: String,
43    /// Localized merchant name (`LocName`).
44    #[gff(LocName, stamped)]
45    pub name: GffLocalizedString,
46    /// Markup percentage (`MarkUp`).
47    #[gff(MarkUp, stamped)]
48    pub mark_up: i32,
49    /// Markdown percentage (`MarkDown`).
50    #[gff(MarkDown, stamped)]
51    pub mark_down: i32,
52    /// On-open-store script (`OnOpenStore`).
53    #[gff(OnOpenStore, stamped)]
54    pub on_open_store: ResRef,
55    /// Toolset comment (`Comment`).
56    #[gff(Comment, unexamined)]
57    pub comment: String,
58    /// Deprecated ID field (`ID`).
59    #[gff(ID, unexamined)]
60    pub id: u8,
61    /// What the store will trade (`BuySellFlag`), as the byte the file holds.
62    ///
63    /// The whole byte, rather than a member per bit. Bit 0 is buy and bit 1 is
64    /// sell; the rest are unaccounted for and are preserved by being here at
65    /// all. [`Self::can_buy`] and [`Self::can_sell`] read the two that mean
66    /// something, which keeps the declared field the label's actual value the
67    /// way every other entry in the tree is.
68    #[gff(BuySellFlag, constructed = 3)]
69    pub buy_sell_flag: u8,
70    /// Inventory entries (`ItemList`).
71    #[gff(ItemList, not_a_constant, list = UtmInventoryItem, element_id = positional)]
72    pub inventory: Vec<UtmInventoryItem>,
73}
74
75impl Utm {
76    /// Creates an empty UTM value.
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    /// Whether the store buys from the player.
82    pub fn can_buy(&self) -> bool {
83        self.buy_sell_flag & BUY_BIT != 0
84    }
85
86    /// Whether the store sells to the player.
87    pub fn can_sell(&self) -> bool {
88        self.buy_sell_flag & SELL_BIT != 0
89    }
90
91    /// The bits of `BuySellFlag` nothing is known to read.
92    pub fn unknown_flag_bits(&self) -> u8 {
93        self.buy_sell_flag & !(BUY_BIT | SELL_BIT)
94    }
95
96    /// Builds typed UTM data from a parsed GFF container.
97    ///
98    /// # Errors
99    ///
100    /// Returns [`UtmError::UnsupportedFileType`] for a container that is
101    /// neither `UTM ` nor the generic `GFF ` form.
102    pub fn from_gff(gff: &Gff) -> Result<Self, UtmError> {
103        if gff.file_type != <Utm as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
104            return Err(UtmError::UnsupportedFileType(gff.file_type));
105        }
106
107        Ok(Self::read_declared(&gff.root))
108    }
109
110    /// Converts this typed UTM value into a GFF container.
111    pub fn to_gff(&self) -> Gff {
112        let mut root = GffStruct::new(-1);
113        self.write_declared(&mut root);
114        Gff::new(*b"UTM ", root)
115    }
116}
117
118/// One UTM inventory entry from the `ItemList` field.
119///
120/// `ObjectId` and `Repos_PosY` are declared without members. The first is a
121/// value the engine resolves that no consumer has asked for; the second is a
122/// spelling of the row below that only non-binary sources produce.
123#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
124#[gff_entry(ObjectId, wire = u32, stamped = 0x7F00_0000)]
125#[gff_entry(
126    Repos_PosY,
127    wire = u16,
128    read_only_dead = "an older tool's shop-grid coordinate; the engine builds its shop UI dynamically when opened and never reads these",
129    not_a_constant
130)]
131pub struct UtmInventoryItem {
132    /// Inventory item resref (`InventoryRes`).
133    #[gff(InventoryRes, unexamined)]
134    pub inventory_res: ResRef,
135    /// Repository position X (`Repos_PosX`). Legacy field completely ignored by the engine, which builds the UI dynamically.
136    #[gff(
137        Repos_PosX,
138        read_only_dead = "an older tool's shop-grid coordinate; the engine builds its shop UI dynamically when opened and never reads these",
139        not_a_constant
140    )]
141    pub repos_pos_x: u16,
142    /// Repository position Y (`Repos_Posy`). Legacy field completely ignored by the engine, which builds the UI dynamically.
143    ///
144    /// The read is hand-written: it takes the `Repos_PosY` spelling too, which
145    /// a binary GFF cannot carry but other sources produce. The write is an
146    /// ordinary upsert of the canonical one.
147    #[gff(
148        Repos_Posy,
149        read_only_dead = "an older tool's shop-grid coordinate; the engine builds its shop UI dynamically when opened and never reads these",
150        not_a_constant,
151        manual_read
152    )]
153    pub repos_pos_y: u16,
154    /// Droppable flag (`Dropable`).
155    /// `LoadStore`'s own item loop never reads this; it comes from the item
156    /// load chain, through the same function at the same address a placeable's
157    /// contained items reach. So the resolution transfers by identity: an
158    /// absent label lands on the read's unconditional literal zero.
159    #[gff(Dropable, stamped, omit = audited_constant(38))]
160    pub droppable: bool,
161    /// Infinite-stock flag (`Infinite`).
162    #[gff(Infinite, stamped)]
163    pub infinite: bool,
164}
165
166impl UtmInventoryItem {
167    /// Reads one `ItemList` element.
168    fn read_element(structure: &GffStruct) -> Self {
169        let mut item = Self::read_declared(structure);
170        if let Some(found) =
171            get_u16(structure, "Repos_Posy").or_else(|| get_u16(structure, "Repos_PosY"))
172        {
173            item.repos_pos_y = found;
174        }
175        item
176    }
177}
178
179/// Errors produced while reading or writing typed UTM data.
180#[derive(Debug, Error)]
181pub enum UtmError {
182    /// Source file type is not supported by this parser.
183    #[error("unsupported UTM file type: {0:?}")]
184    UnsupportedFileType([u8; 4]),
185    /// Underlying GFF parser/writer error.
186    #[error(transparent)]
187    Gff(#[from] GffBinaryError),
188}
189
190/// Reads typed UTM data from a reader at the current stream position.
191///
192/// # Errors
193///
194/// [`UtmError::Gff`] when the stream is not a readable GFF, and
195/// [`UtmError::UnsupportedFileType`] when it is a GFF of some other format,
196/// carrying the fourcc that was found.
197#[cfg_attr(
198    feature = "tracing",
199    tracing::instrument(level = "debug", skip(reader))
200)]
201pub fn read_utm<R: Read>(reader: &mut R) -> Result<Utm, UtmError> {
202    let gff = read_gff(reader)?;
203    Utm::from_gff(&gff)
204}
205
206/// Reads typed UTM data directly from bytes.
207///
208/// # Errors
209///
210/// [`UtmError::Gff`] when `bytes` are not a readable GFF, and
211/// [`UtmError::UnsupportedFileType`] when they are a GFF of some other format,
212/// carrying the fourcc that was found.
213#[cfg_attr(
214    feature = "tracing",
215    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
216)]
217pub fn read_utm_from_bytes(bytes: &[u8]) -> Result<Utm, UtmError> {
218    let gff = read_gff_from_bytes(bytes)?;
219    Utm::from_gff(&gff)
220}
221
222/// Authors the UTM file the typed view describes, into a writer.
223///
224/// # Errors
225///
226/// [`UtmError::Gff`] when the writer fails or a value will not encode. The
227/// typed view fixes the file type, so `UnsupportedFileType` cannot arise on
228/// this side.
229#[cfg_attr(
230    feature = "tracing",
231    tracing::instrument(level = "debug", skip(writer, utm))
232)]
233pub fn author_utm<W: Write>(writer: &mut W, utm: &Utm) -> Result<(), UtmError> {
234    let gff = utm.to_gff();
235    write_gff(writer, &gff)?;
236    Ok(())
237}
238
239/// Authors the UTM file the typed view describes, as bytes.
240///
241/// # Errors
242///
243/// [`UtmError::Gff`] when a value will not encode. Writing into a `Vec` has no
244/// I/O to fail at.
245#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(utm)))]
246pub fn author_utm_to_vec(utm: &Utm) -> Result<Vec<u8>, UtmError> {
247    let mut cursor = Cursor::new(Vec::new());
248    author_utm(&mut cursor, utm)?;
249    Ok(cursor.into_inner())
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use rakata_formats::schema::{HasSchema, Shape};
256    use rakata_formats::{gff_label, GffValue};
257
258    const TEST_UTM: &[u8] = include_bytes!(concat!(
259        env!("CARGO_MANIFEST_DIR"),
260        "/../../fixtures/test.utm"
261    ));
262
263    #[test]
264    fn reads_core_utm_fields_from_fixture() {
265        let utm = read_utm_from_bytes(TEST_UTM).expect("fixture must parse");
266
267        assert_eq!(utm.resref, "dan_droid");
268        assert_eq!(utm.tag, "dan_droid");
269        assert_eq!(utm.name.string_ref.raw(), 33_399);
270        assert_eq!(utm.mark_up, 100);
271        assert_eq!(utm.mark_down, 25);
272        assert_eq!(utm.on_open_store, "onopenstore");
273        assert_eq!(utm.comment, "comment");
274        assert_eq!(utm.id, 5);
275        assert!(utm.can_buy());
276        assert!(utm.can_sell());
277        assert_eq!(utm.unknown_flag_bits(), 0);
278
279        assert_eq!(utm.inventory.len(), 2);
280        assert_eq!(utm.inventory[0].inventory_res, "g_i_drdltplat001");
281        assert!(!utm.inventory[0].droppable);
282        assert!(!utm.inventory[0].infinite);
283        assert_eq!(utm.inventory[0].repos_pos_x, 0);
284
285        assert_eq!(utm.inventory[1].inventory_res, "g_i_drdltplat002");
286        assert!(!utm.inventory[1].droppable);
287        assert!(utm.inventory[1].infinite);
288        assert_eq!(utm.inventory[1].repos_pos_x, 1);
289    }
290
291    #[test]
292    fn all_fields_survive_typed_roundtrip() {
293        let utm = read_utm_from_bytes(TEST_UTM).expect("fixture must parse");
294        let bytes = author_utm_to_vec(&utm).expect("write succeeds");
295        let reparsed = read_utm_from_bytes(&bytes).expect("reparse succeeds");
296
297        assert_eq!(reparsed, utm);
298    }
299
300    #[test]
301    fn buy_sell_unknown_bits_are_preserved() {
302        let mut gff = read_gff_from_bytes(TEST_UTM).expect("fixture must parse");
303        gff.root.fields.retain(|field| field.label != "BuySellFlag");
304        gff.root
305            .push_field(gff_label!("BuySellFlag"), GffValue::UInt8(0b0000_0100));
306
307        let utm = Utm::from_gff(&gff).expect("typed parse");
308        assert!(!utm.can_buy());
309        assert!(!utm.can_sell());
310        assert_eq!(utm.unknown_flag_bits(), 0b0000_0100);
311
312        let rebuilt = utm.to_gff();
313        assert_eq!(
314            rebuilt.root.field("BuySellFlag"),
315            Some(&GffValue::UInt8(0b0000_0100))
316        );
317    }
318
319    #[test]
320    fn typed_edits_roundtrip_through_gff_writer() {
321        let mut utm = read_utm_from_bytes(TEST_UTM).expect("fixture must parse");
322        utm.tag = "dan_droid_rust".into();
323        utm.buy_sell_flag = 0b0000_0010;
324        utm.inventory[0].droppable = true;
325
326        let bytes = author_utm_to_vec(&utm).expect("write succeeds");
327        let reparsed = read_utm_from_bytes(&bytes).expect("reparse succeeds");
328
329        assert_eq!(reparsed.tag, "dan_droid_rust");
330        assert!(!reparsed.can_buy());
331        assert!(reparsed.can_sell());
332        assert!(reparsed.inventory[0].droppable);
333    }
334
335    #[test]
336    fn read_utm_from_reader_matches_bytes_path() {
337        let mut cursor = Cursor::new(TEST_UTM);
338        let via_reader = read_utm(&mut cursor).expect("reader parse succeeds");
339        let via_bytes = read_utm_from_bytes(TEST_UTM).expect("bytes parse succeeds");
340
341        assert_eq!(via_reader, via_bytes);
342    }
343
344    #[test]
345    fn rejects_non_utm_file_type() {
346        let mut gff = read_gff_from_bytes(TEST_UTM).expect("fixture must parse");
347        gff.file_type = *b"UTI ";
348
349        let err = Utm::from_gff(&gff).expect_err("UTI must be rejected as UTM input");
350        assert!(matches!(
351            err,
352            UtmError::UnsupportedFileType(file_type) if file_type == *b"UTI "
353        ));
354    }
355
356    #[test]
357    fn a_mistyped_item_list_reads_as_empty() {
358        let mut gff = read_gff_from_bytes(TEST_UTM).expect("fixture must parse");
359        gff.root.fields.retain(|field| field.label != "ItemList");
360        gff.root
361            .push_field(gff_label!("ItemList"), GffValue::UInt32(99));
362
363        let utm = Utm::from_gff(&gff).expect("a mistyped list is not a read failure");
364
365        assert!(utm.inventory.is_empty());
366    }
367
368    #[test]
369    fn write_utm_matches_direct_gff_writer() {
370        let utm = read_utm_from_bytes(TEST_UTM).expect("fixture must parse");
371
372        let via_typed = author_utm_to_vec(&utm).expect("typed write succeeds");
373
374        let mut direct = Cursor::new(Vec::new());
375        write_gff(&mut direct, &utm.to_gff()).expect("direct write succeeds");
376
377        assert_eq!(via_typed, direct.into_inner());
378    }
379
380    #[test]
381    fn schema_field_count() {
382        assert_eq!(Utm::schema().len(), 10); // 6 engine + 1 list + 3 toolset
383    }
384
385    #[test]
386    fn schema_no_duplicate_labels() {
387        let mut labels: Vec<&str> = Utm::schema().iter().map(|f| f.label.as_str()).collect();
388        labels.sort_unstable();
389        let before = labels.len();
390        labels.dedup();
391        assert_eq!(before, labels.len(), "duplicate labels in UTM schema");
392    }
393
394    #[test]
395    fn schema_item_list_carries_its_element() {
396        let item_list = Utm::schema()
397            .iter()
398            .find(|f| f.label.as_str() == "ItemList")
399            .expect("ItemList is declared");
400        let Shape::List { element, .. } = item_list.shape else {
401            panic!("ItemList is a list");
402        };
403        // Five members and the two labels no member holds.
404        assert_eq!(element.iter().map(|p| p.len()).sum::<usize>(), 7);
405    }
406}