Skip to main content

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}