1use 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
29const BUY_BIT: u8 = 0b0000_0001;
31const SELL_BIT: u8 = 0b0000_0010;
33
34#[derive(Debug, Clone, PartialEq, GffModel)]
36pub struct Utm {
37 #[gff(ResRef, unexamined)]
39 pub resref: ResRef,
40 #[gff(Tag, stamped)]
42 pub tag: String,
43 #[gff(LocName, stamped)]
45 pub name: GffLocalizedString,
46 #[gff(MarkUp, stamped)]
48 pub mark_up: i32,
49 #[gff(MarkDown, stamped)]
51 pub mark_down: i32,
52 #[gff(OnOpenStore, stamped)]
54 pub on_open_store: ResRef,
55 #[gff(Comment, unexamined)]
57 pub comment: String,
58 #[gff(ID, unexamined)]
60 pub id: u8,
61 #[gff(BuySellFlag, constructed = 3)]
69 pub buy_sell_flag: u8,
70 #[gff(ItemList, not_a_constant, list = UtmInventoryItem, element_id = positional)]
72 pub inventory: Vec<UtmInventoryItem>,
73}
74
75impl Utm {
76 pub fn new() -> Self {
78 Self::default()
79 }
80
81 pub fn can_buy(&self) -> bool {
83 self.buy_sell_flag & BUY_BIT != 0
84 }
85
86 pub fn can_sell(&self) -> bool {
88 self.buy_sell_flag & SELL_BIT != 0
89 }
90
91 pub fn unknown_flag_bits(&self) -> u8 {
93 self.buy_sell_flag & !(BUY_BIT | SELL_BIT)
94 }
95
96 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 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#[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 #[gff(InventoryRes, unexamined)]
134 pub inventory_res: ResRef,
135 #[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 #[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 #[gff(Dropable, stamped, omit = audited_constant(38))]
160 pub droppable: bool,
161 #[gff(Infinite, stamped)]
163 pub infinite: bool,
164}
165
166impl UtmInventoryItem {
167 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#[derive(Debug, Error)]
181pub enum UtmError {
182 #[error("unsupported UTM file type: {0:?}")]
184 UnsupportedFileType([u8; 4]),
185 #[error(transparent)]
187 Gff(#[from] GffBinaryError),
188}
189
190#[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#[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#[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#[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); }
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 assert_eq!(element.iter().map(|p| p.len()).sum::<usize>(), 7);
405 }
406}