rakata_generics/fac.rs
1//! FAC (`.fac`) typed generic wrapper.
2//!
3//! A FAC resource is the whole session's faction table: the roster of factions
4//! and how each one feels about every other. There is one per session, keyed on
5//! the resref `REPUTE`, not one per creature. Objects (`.utc`, `.utd`, ...)
6//! carry only a faction id that indexes into this table.
7//!
8//! ## Field Layout
9//! ```text
10//! FAC root struct
11//! +-- FactionList (List<Struct>)
12//! | +-- FactionName / FactionParentID / FactionGlobal
13//! +-- RepList (List<Struct>)
14//! +-- FactionID1 / FactionID2 / FactionRep
15//! ```
16//!
17//! ## The sparse matrix
18//!
19//! Reputation is stored sparsely, and this is the one thing to get right.
20//! **An absent pair means `100`, which is friendly, not `0`, which is
21//! hostile.** Reading `RepList` directly and treating misses as zero turns the
22//! entire galaxy against the player. Use [`Fac::reputation`], which applies the
23//! baseline.
24//!
25//! That baseline applies to a pair with no entry at all. A present entry
26//! missing only its `FactionRep` is a different case and lands on `0`, which
27//! is what [`FacReputation::standing`] declares.
28//!
29//! `docs/src/formats/gff/fac.md` has the rule and how the engine rebuilds the
30//! full matrix from it.
31
32use std::io::{Cursor, Read, Write};
33
34use rakata_formats::gff::upsert_field;
35use rakata_formats::gff_label;
36use rakata_formats::schema::FromGff;
37use rakata_formats::GENERIC_FILE_TYPE;
38use rakata_formats::{
39 read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffModel, GffStruct, GffValue,
40};
41use thiserror::Error;
42
43/// Standing applied to any faction pair with no stored `RepList` entry.
44///
45/// The top of the friendly band, not a midpoint.
46pub const DEFAULT_REPUTATION: u8 = 100;
47
48/// Highest standing the engine will hold after its load-time clamp.
49const MAX_REPUTATION: i64 = 100;
50
51/// Typed FAC model built from/to [`Gff`] data.
52#[derive(Debug, Clone, PartialEq, GffModel)]
53pub struct Fac {
54 /// Faction roster (`FactionList`). A faction's id is its index here.
55 #[gff(FactionList, unexamined, list = FacFaction, element_id = positional)]
56 pub factions: Vec<FacFaction>,
57 /// Stored pairwise standings (`RepList`). Sparse: see [`Fac::reputation`].
58 ///
59 /// The write is hand-written because the filter runs on the typed element,
60 /// where the standing it tests is reachable. The read generates, and
61 /// reaches the element's own hand-written codec through it.
62 #[gff(
63 RepList,
64 not_a_constant,
65 list = FacReputation,
66 element_id = positional,
67 omit = elements(
68 "a pair already at the friendly baseline says nothing, since it \
69 overrides that baseline with itself, and CFactionManager::SaveReputations \
70 emits an entry only for a pair that is not at it. A toolset-authored \
71 .fac carries entries the engine would never have written",
72 16
73 ),
74 manual_write
75 )]
76 pub reputations: Vec<FacReputation>,
77}
78
79impl Fac {
80 /// Creates an empty faction table.
81 pub fn new() -> Self {
82 Self::default()
83 }
84
85 /// Returns how `source` regards `target`.
86 ///
87 /// Applies the sparse-matrix rule: a pair with no stored entry is
88 /// [`DEFAULT_REPUTATION`]. Ids are faction indices into
89 /// [`Self::factions`]; an id no faction occupies still answers with the
90 /// baseline, matching the engine rebuilding its matrix before applying
91 /// overrides.
92 pub fn reputation(&self, source: u32, target: u32) -> u8 {
93 self.reputations
94 .iter()
95 .find(|entry| entry.source == source && entry.target == target)
96 .map_or(DEFAULT_REPUTATION, FacReputation::effective_standing)
97 }
98
99 /// Returns how `source` reacts to `target`.
100 pub fn reaction(&self, source: u32, target: u32) -> FacReaction {
101 FacReaction::from_standing(self.reputation(source, target))
102 }
103
104 /// Returns the faction at `id`, when the roster has one.
105 pub fn faction(&self, id: u32) -> Option<&FacFaction> {
106 usize::try_from(id).ok().and_then(|i| self.factions.get(i))
107 }
108
109 /// Builds typed FAC data from a parsed GFF container.
110 ///
111 /// # Errors
112 ///
113 /// Returns an error when the container is not a FAC (or generic GFF).
114 pub fn from_gff(gff: &Gff) -> Result<Self, FacError> {
115 if gff.file_type != <Fac as FromGff>::MAGIC && gff.file_type != GENERIC_FILE_TYPE {
116 return Err(FacError::UnsupportedFileType(gff.file_type));
117 }
118
119 Ok(Self::read_declared(&gff.root))
120 }
121
122 /// Converts this typed FAC value into a GFF container.
123 ///
124 /// Writes the table the way the engine's own writer does, which means
125 /// pairs at the default standing are dropped rather than emitted. The two
126 /// forms are identical to the engine, but other tools read these files
127 /// too, and a diff against a vanilla save should not show pairs the game
128 /// would never have written.
129 ///
130 /// Only pairs whose *effective* standing is the default are dropped, so a
131 /// deliberately out-of-range value is still written out.
132 ///
133 /// # Panics
134 ///
135 /// Panics where the reputation list holds more than [`i32::MAX`] entries,
136 /// since an element's struct id is its own index. A table that large
137 /// cannot be built from any file this crate reads.
138 pub fn to_gff(&self) -> Gff {
139 let mut root = GffStruct::new(-1);
140 self.write_declared(&mut root);
141
142 upsert_field(
143 &mut root,
144 gff_label!("RepList"),
145 GffValue::List(
146 self.reputations
147 .iter()
148 .filter(|entry| entry.effective_standing() != DEFAULT_REPUTATION)
149 .enumerate()
150 .map(|(index, entry)| {
151 let mut structure = GffStruct::new(
152 i32::try_from(index).expect("reputation index fits i32"),
153 );
154 entry.write_element(&mut structure);
155 structure
156 })
157 .collect(),
158 ),
159 );
160
161 Gff::new(*b"FAC ", root)
162 }
163}
164
165/// One faction from the `FactionList`.
166///
167/// The faction's own id is its position in the list and is not stored on the
168/// element.
169#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
170pub struct FacFaction {
171 /// Display/lookup name (`FactionName`).
172 #[gff(FactionName, stamped)]
173 pub name: String,
174 /// Parent faction id (`FactionParentID`).
175 ///
176 /// Kept as a raw id. Every one of the factions in all four committed save
177 /// fixtures carries `0xFFFFFFFF`, which looks like a no-parent sentinel,
178 /// but that reading has not been confirmed against the binary and the
179 /// engine audit does not record a meaning for it. Modelling this as an
180 /// absent parent would encode the guess; widening to an `Option` later
181 /// costs nothing if an audit confirms it.
182 #[gff(FactionParentID, stamped)]
183 pub parent_id: u32,
184 /// Whether this is one of the standard shared factions (`FactionGlobal`).
185 ///
186 /// Absent on load means `true`. The file holds it as a `WORD`, which is
187 /// the one case the declared Rust type does not settle the encoding.
188 #[gff(FactionGlobal, stamped = true, wire = u16)]
189 pub global: bool,
190}
191
192/// One stored standing from the `RepList`.
193#[derive(Debug, Clone, PartialEq, Eq, GffModel)]
194pub struct FacReputation {
195 /// Source faction id (`FactionID1`).
196 #[gff(FactionID1, stamped)]
197 pub source: u32,
198 /// Target faction id (`FactionID2`).
199 #[gff(FactionID2, stamped)]
200 pub target: u32,
201 /// How `source` regards `target` (`FactionRep`), as stored.
202 ///
203 /// Kept as written rather than clamped, so a file carrying an
204 /// out-of-range value still shows it. The engine clamps to `0`-`100` when
205 /// it loads; [`Self::effective_standing`] applies that.
206 ///
207 /// Both halves are hand-written: the file holds a `DWORD` and the engine
208 /// reads it signed, so neither direction is one call.
209 #[gff(FactionRep, required, stamped, wire = u32, manual_read, manual_write)]
210 pub standing: i32,
211}
212
213impl FacReputation {
214 /// Returns the standing the engine would hold after its load-time clamp.
215 ///
216 /// Values at or above `101` become `100`, negatives become `0`.
217 pub fn effective_standing(&self) -> u8 {
218 u8::try_from(i64::from(self.standing).clamp(0, MAX_REPUTATION))
219 .unwrap_or(DEFAULT_REPUTATION)
220 }
221
222 /// Reads one `RepList` element.
223 ///
224 /// The derive supplies no `read_element` for a type with a hand-written
225 /// read half, so this is where a list of these picks the hatch up.
226 fn read_element(structure: &GffStruct) -> Self {
227 let mut entry = Self::read_declared(structure);
228 if let Some(standing) = read_standing(structure) {
229 entry.standing = standing;
230 }
231 entry
232 }
233
234 /// Writes one `RepList` element.
235 fn write_element(&self, structure: &mut GffStruct) {
236 self.write_declared(structure);
237 upsert_field(
238 structure,
239 gff_label!("FactionRep"),
240 // Back to the bit pattern it was read from, so a stored negative
241 // survives the trip unchanged.
242 GffValue::UInt32(u32::from_ne_bytes(self.standing.to_ne_bytes())),
243 );
244 }
245}
246
247/// Reads `FactionRep` with the engine's signedness.
248///
249/// The field is written as a DWORD, but the engine interprets it as signed.
250/// That distinction changes the answer: a stored `-1` reaches us as
251/// `0xFFFFFFFF`, and reading it unsigned would make it four billion, which
252/// clamps *up* to `100` (friendly) when the engine clamps it *down* to `0`
253/// (hostile). Signedness is how the field is read correctly; the range clamp
254/// is a separate step and belongs to [`FacReputation::effective_standing`].
255///
256/// `None` where the label is absent, which leaves the entry at the `0` its
257/// declaration carries. `fac.md` is explicit that this is not the `100`
258/// baseline: that one applies to a pair with no entry at all, and a present
259/// entry missing only its standing writes hostile.
260fn read_standing(structure: &GffStruct) -> Option<i32> {
261 match structure.field("FactionRep")? {
262 GffValue::UInt32(value) => Some(i32::from_ne_bytes(value.to_ne_bytes())),
263 GffValue::Int32(value) => Some(*value),
264 GffValue::UInt16(value) => Some(i32::from(*value)),
265 GffValue::Int16(value) => Some(i32::from(*value)),
266 GffValue::UInt8(value) => Some(i32::from(*value)),
267 GffValue::Int8(value) => Some(i32::from(*value)),
268 _ => None,
269 }
270}
271
272/// How a standing reads at the script layer.
273///
274/// The same `10` and `90` boundaries decide whether an NPC treats you as an
275/// enemy, whether a mine arms against you, and whether a faction-owned
276/// placeable, door or trigger is usable.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
278pub enum FacReaction {
279 /// `0`-`10`: treated as an enemy.
280 Hostile,
281 /// `11`-`89`: neither friend nor enemy.
282 Neutral,
283 /// `90`-`100`: treated as a friend.
284 Friendly,
285}
286
287impl FacReaction {
288 /// Classifies a `0`-`100` standing into its reaction band.
289 pub fn from_standing(standing: u8) -> Self {
290 match standing {
291 0..=10 => Self::Hostile,
292 11..=89 => Self::Neutral,
293 _ => Self::Friendly,
294 }
295 }
296}
297
298/// Errors produced while reading or writing typed FAC data.
299#[derive(Debug, Error)]
300pub enum FacError {
301 /// Source file type is not supported by this parser.
302 #[error("unsupported FAC file type: {0:?}")]
303 UnsupportedFileType([u8; 4]),
304 /// Underlying GFF parser/writer error.
305 #[error(transparent)]
306 Gff(#[from] GffBinaryError),
307}
308
309/// Reads typed FAC data from a reader at the current stream position.
310///
311/// # Errors
312///
313/// [`FacError::Gff`] when the stream is not a readable GFF, and
314/// [`FacError::UnsupportedFileType`] when it is a GFF of some other format,
315/// carrying the fourcc that was found.
316#[cfg_attr(
317 feature = "tracing",
318 tracing::instrument(level = "debug", skip(reader))
319)]
320pub fn read_fac<R: Read>(reader: &mut R) -> Result<Fac, FacError> {
321 let gff = read_gff(reader)?;
322 Fac::from_gff(&gff)
323}
324
325/// Reads typed FAC data directly from bytes.
326///
327/// # Errors
328///
329/// [`FacError::Gff`] when `bytes` are not a readable GFF, and
330/// [`FacError::UnsupportedFileType`] when they are a GFF of some other format,
331/// carrying the fourcc that was found.
332#[cfg_attr(
333 feature = "tracing",
334 tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
335)]
336pub fn read_fac_from_bytes(bytes: &[u8]) -> Result<Fac, FacError> {
337 let gff = read_gff_from_bytes(bytes)?;
338 Fac::from_gff(&gff)
339}
340
341/// Authors the FAC file the typed view describes, into a writer.
342///
343/// # Errors
344///
345/// [`FacError::Gff`] when the writer fails or a value will not encode. The
346/// typed view fixes the file type, so `UnsupportedFileType` cannot arise on
347/// this side.
348#[cfg_attr(
349 feature = "tracing",
350 tracing::instrument(level = "debug", skip(writer, fac))
351)]
352pub fn author_fac<W: Write>(writer: &mut W, fac: &Fac) -> Result<(), FacError> {
353 let gff = fac.to_gff();
354 write_gff(writer, &gff)?;
355 Ok(())
356}
357
358/// Authors the FAC file the typed view describes, as bytes.
359///
360/// # Errors
361///
362/// [`FacError::Gff`] when a value will not encode. Writing into a `Vec` has no
363/// I/O to fail at.
364#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(fac)))]
365pub fn author_fac_to_vec(fac: &Fac) -> Result<Vec<u8>, FacError> {
366 let mut cursor = Cursor::new(Vec::new());
367 author_fac(&mut cursor, fac)?;
368 Ok(cursor.into_inner())
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 fn sample() -> Fac {
376 Fac {
377 factions: vec![
378 FacFaction {
379 name: "PLAYER".to_string(),
380 ..FacFaction::default()
381 },
382 FacFaction {
383 name: "Hostile".to_string(),
384 parent_id: 0,
385 global: false,
386 },
387 ],
388 reputations: vec![FacReputation {
389 source: 0,
390 target: 1,
391 standing: 5,
392 }],
393 }
394 }
395
396 #[test]
397 fn round_trips_through_gff() {
398 let fac = sample();
399
400 let bytes = author_fac_to_vec(&fac).expect("writes");
401 let parsed = read_fac_from_bytes(&bytes).expect("reads back");
402
403 assert_eq!(parsed, fac);
404 }
405
406 #[test]
407 fn an_absent_pair_is_friendly_not_hostile() {
408 let fac = sample();
409
410 // Stored the other way round only; 1 -> 0 has no entry.
411 assert_eq!(fac.reputation(0, 1), 5);
412 assert_eq!(fac.reputation(1, 0), DEFAULT_REPUTATION);
413 assert_eq!(fac.reaction(1, 0), FacReaction::Friendly);
414 assert_eq!(fac.reaction(0, 1), FacReaction::Hostile);
415 }
416
417 #[test]
418 fn an_unknown_faction_id_still_answers_with_the_baseline() {
419 let fac = sample();
420
421 assert_eq!(fac.reputation(99, 42), DEFAULT_REPUTATION);
422 assert!(fac.faction(99).is_none());
423 }
424
425 #[test]
426 fn reaction_bands_match_the_documented_boundaries() {
427 assert_eq!(FacReaction::from_standing(0), FacReaction::Hostile);
428 assert_eq!(FacReaction::from_standing(10), FacReaction::Hostile);
429 assert_eq!(FacReaction::from_standing(11), FacReaction::Neutral);
430 assert_eq!(FacReaction::from_standing(89), FacReaction::Neutral);
431 assert_eq!(FacReaction::from_standing(90), FacReaction::Friendly);
432 assert_eq!(FacReaction::from_standing(100), FacReaction::Friendly);
433 }
434
435 #[test]
436 fn a_missing_faction_global_defaults_to_true() {
437 let mut structure = GffStruct::new(0);
438 structure.push_field(gff_label!("FactionName"), GffValue::String("X".to_string()));
439
440 assert!(FacFaction::read_declared(&structure).global);
441 }
442
443 #[test]
444 fn a_negative_standing_reads_as_negative_not_as_four_billion() {
445 // Written as a DWORD, -1 arrives as 0xFFFFFFFF. Read unsigned it would
446 // become u32::MAX and clamp up to friendly.
447 let mut structure = GffStruct::new(0);
448 structure.push_field(gff_label!("FactionRep"), GffValue::UInt32(u32::MAX));
449
450 assert_eq!(read_standing(&structure), Some(-1));
451
452 // An absent label is not a value, so the entry keeps the 0 its
453 // declaration carries rather than the 100 sparse-matrix baseline.
454 assert_eq!(read_standing(&GffStruct::new(0)), None);
455 assert_eq!(FacReputation::default().standing, 0);
456 }
457
458 #[test]
459 fn out_of_range_standings_are_kept_but_clamp_when_asked() {
460 let hostile = FacReputation {
461 source: 0,
462 target: 1,
463 standing: -1,
464 };
465 let over = FacReputation {
466 source: 0,
467 target: 2,
468 standing: 500,
469 };
470
471 // Stored as written...
472 assert_eq!(hostile.standing, -1);
473 assert_eq!(over.standing, 500);
474 // ...and clamped only where the engine would.
475 assert_eq!(hostile.effective_standing(), 0);
476 assert_eq!(over.effective_standing(), 100);
477 }
478
479 #[test]
480 fn a_pair_at_the_default_is_not_written() {
481 let fac = Fac {
482 factions: vec![FacFaction::default()],
483 reputations: vec![
484 FacReputation {
485 source: 0,
486 target: 0,
487 standing: i32::from(DEFAULT_REPUTATION),
488 },
489 FacReputation {
490 source: 0,
491 target: 1,
492 standing: 5,
493 },
494 ],
495 };
496
497 let bytes = author_fac_to_vec(&fac).expect("writes");
498 let parsed = read_fac_from_bytes(&bytes).expect("reads back");
499
500 // The default pair is dropped the way the engine's writer drops it;
501 // reading it back still answers with the baseline.
502 assert_eq!(parsed.reputations.len(), 1);
503 assert_eq!(parsed.reputations[0].target, 1);
504 assert_eq!(parsed.reputation(0, 0), DEFAULT_REPUTATION);
505 }
506
507 #[test]
508 fn an_out_of_range_standing_survives_a_round_trip() {
509 let fac = Fac {
510 factions: vec![FacFaction::default()],
511 reputations: vec![FacReputation {
512 source: 0,
513 target: 0,
514 standing: -12,
515 }],
516 };
517
518 let bytes = author_fac_to_vec(&fac).expect("writes");
519 let parsed = read_fac_from_bytes(&bytes).expect("reads back");
520
521 assert_eq!(parsed.reputations[0].standing, -12);
522 }
523
524 #[test]
525 fn a_non_list_faction_field_reads_as_empty() {
526 let mut root = GffStruct::new(-1);
527 root.push_field(gff_label!("FactionList"), GffValue::UInt32(7));
528 let gff = Gff::new(*b"FAC ", root);
529
530 let fac = Fac::from_gff(&gff).expect("a mistyped list is not a read failure");
531
532 assert!(fac.factions.is_empty());
533 }
534
535 #[test]
536 fn an_unrelated_file_type_is_rejected() {
537 let gff = Gff::new(*b"UTC ", GffStruct::new(-1));
538
539 assert!(matches!(
540 Fac::from_gff(&gff),
541 Err(FacError::UnsupportedFileType(_))
542 ));
543 }
544
545 #[test]
546 fn an_empty_table_reads_as_empty_rather_than_failing() {
547 let gff = Gff::new(*b"FAC ", GffStruct::new(-1));
548
549 let fac = Fac::from_gff(&gff).expect("empty FAC is valid");
550
551 assert!(fac.factions.is_empty());
552 assert!(fac.reputations.is_empty());
553 }
554}