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 conceptually every faction's standing toward every other, but
20//! it is stored sparsely: the default standing is `100`, and only pairs that
21//! differ from it are written. On load the engine rebuilds the full matrix at
22//! that baseline and then applies the stored entries as overrides.
23//!
24//! This is the one thing to get right. **An absent pair means `100`, which is
25//! friendly, not `0`, which is hostile.** Reading the list directly and
26//! treating misses as zero turns the entire galaxy against the player. Use
27//! [`Fac::reputation`], which applies the baseline.
28
29use std::io::{Cursor, Read, Write};
30
31use crate::gff_helpers::{get_bool, get_string, get_u32, upsert_field};
32use rakata_formats::{
33    gff_schema::{AbsentDefault, FieldLife, FieldSchema, GffSchema, GffType},
34    read_gff, read_gff_from_bytes, write_gff, Gff, GffBinaryError, GffStruct, GffValue,
35};
36use thiserror::Error;
37
38/// Standing applied to any faction pair with no stored `RepList` entry.
39///
40/// The top of the friendly band, not a midpoint.
41pub const DEFAULT_REPUTATION: u8 = 100;
42
43/// Highest standing the engine will hold after its load-time clamp.
44const MAX_REPUTATION: i64 = 100;
45
46/// Typed FAC model built from/to [`Gff`] data.
47#[derive(Debug, Clone, PartialEq, Default)]
48pub struct Fac {
49    /// Faction roster (`FactionList`). A faction's id is its index here.
50    pub factions: Vec<FacFaction>,
51    /// Stored pairwise standings (`RepList`). Sparse: see [`Fac::reputation`].
52    pub reputations: Vec<FacReputation>,
53}
54
55impl Fac {
56    /// Creates an empty faction table.
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Returns how `source` regards `target`.
62    ///
63    /// Applies the sparse-matrix rule: a pair with no stored entry is
64    /// [`DEFAULT_REPUTATION`]. Ids are faction indices into
65    /// [`Self::factions`]; an id no faction occupies still answers with the
66    /// baseline, matching the engine rebuilding its matrix before applying
67    /// overrides.
68    pub fn reputation(&self, source: u32, target: u32) -> u8 {
69        self.reputations
70            .iter()
71            .find(|entry| entry.source == source && entry.target == target)
72            .map_or(DEFAULT_REPUTATION, FacReputation::effective_standing)
73    }
74
75    /// Returns how `source` reacts to `target`.
76    pub fn reaction(&self, source: u32, target: u32) -> FacReaction {
77        FacReaction::from_standing(self.reputation(source, target))
78    }
79
80    /// Returns the faction at `id`, when the roster has one.
81    pub fn faction(&self, id: u32) -> Option<&FacFaction> {
82        usize::try_from(id).ok().and_then(|i| self.factions.get(i))
83    }
84
85    /// Builds typed FAC data from a parsed GFF container.
86    ///
87    /// # Errors
88    ///
89    /// Returns an error when the container is not a FAC (or generic GFF), or
90    /// when `FactionList` / `RepList` are present but are not lists.
91    pub fn from_gff(gff: &Gff) -> Result<Self, FacError> {
92        if gff.file_type != *b"FAC " && gff.file_type != *b"GFF " {
93            return Err(FacError::UnsupportedFileType(gff.file_type));
94        }
95
96        let root = &gff.root;
97        Ok(Self {
98            factions: read_list(root, "FactionList", FacFaction::from_struct)?,
99            reputations: read_list(root, "RepList", FacReputation::from_struct)?,
100        })
101    }
102
103    /// Converts this typed FAC value into a GFF container.
104    ///
105    /// Writes the table the way the engine's own writer does, which means
106    /// pairs at the default standing are dropped rather than emitted. The two
107    /// forms are identical to the engine, but other tools read these files
108    /// too, and a diff against a vanilla save should not show pairs the game
109    /// would never have written.
110    ///
111    /// Only pairs whose *effective* standing is the default are dropped, so a
112    /// deliberately out-of-range value is still written out.
113    pub fn to_gff(&self) -> Gff {
114        let mut root = GffStruct::new(-1);
115
116        upsert_field(
117            &mut root,
118            "FactionList",
119            GffValue::List(
120                self.factions
121                    .iter()
122                    .enumerate()
123                    .map(|(index, faction)| faction.to_struct(index))
124                    .collect(),
125            ),
126        );
127        upsert_field(
128            &mut root,
129            "RepList",
130            GffValue::List(
131                self.reputations
132                    .iter()
133                    .filter(|entry| entry.effective_standing() != DEFAULT_REPUTATION)
134                    .enumerate()
135                    .map(|(index, entry)| entry.to_struct(index))
136                    .collect(),
137            ),
138        );
139
140        Gff::new(*b"FAC ", root)
141    }
142}
143
144/// Reads one list field, mapping each element through `build`.
145fn read_list<T>(
146    root: &GffStruct,
147    label: &'static str,
148    build: impl Fn(&GffStruct) -> T,
149) -> Result<Vec<T>, FacError> {
150    match root.field(label) {
151        Some(GffValue::List(entries)) => Ok(entries.iter().map(build).collect()),
152        Some(_) => Err(FacError::TypeMismatch {
153            field: label,
154            expected: "List",
155        }),
156        None => Ok(Vec::new()),
157    }
158}
159
160/// One faction from the `FactionList`.
161///
162/// The faction's own id is its position in the list and is not stored on the
163/// element.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct FacFaction {
166    /// Display/lookup name (`FactionName`).
167    pub name: String,
168    /// Parent faction id (`FactionParentID`).
169    ///
170    /// Kept as a raw id. Every one of the 21 factions in all four committed
171    /// save fixtures carries `0xFFFFFFFF`, which looks like a no-parent
172    /// sentinel, but that reading has not been confirmed against the binary
173    /// and the engine audit does not record a meaning for it. Modelling this
174    /// as an absent parent would encode the guess; widening to an `Option`
175    /// later costs nothing if an audit confirms it.
176    pub parent_id: u32,
177    /// Whether this is one of the standard shared factions (`FactionGlobal`).
178    ///
179    /// Absent on load means `true`.
180    pub global: bool,
181}
182
183impl Default for FacFaction {
184    fn default() -> Self {
185        Self {
186            name: String::new(),
187            parent_id: u32::MAX,
188            // The engine's load-time default for a missing field.
189            global: true,
190        }
191    }
192}
193
194impl FacFaction {
195    fn from_struct(structure: &GffStruct) -> Self {
196        Self {
197            name: get_string(structure, "FactionName").unwrap_or_default(),
198            parent_id: get_u32(structure, "FactionParentID").unwrap_or(u32::MAX),
199            global: get_bool(structure, "FactionGlobal").unwrap_or(true),
200        }
201    }
202
203    fn to_struct(&self, index: usize) -> GffStruct {
204        let mut structure = GffStruct::new(i32::try_from(index).expect("faction index fits i32"));
205        upsert_field(
206            &mut structure,
207            "FactionName",
208            GffValue::String(self.name.clone()),
209        );
210        upsert_field(
211            &mut structure,
212            "FactionParentID",
213            GffValue::UInt32(self.parent_id),
214        );
215        upsert_field(
216            &mut structure,
217            "FactionGlobal",
218            GffValue::UInt16(u16::from(self.global)),
219        );
220        structure
221    }
222}
223
224/// One stored standing from the `RepList`.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct FacReputation {
227    /// Source faction id (`FactionID1`).
228    pub source: u32,
229    /// Target faction id (`FactionID2`).
230    pub target: u32,
231    /// How `source` regards `target` (`FactionRep`), as stored.
232    ///
233    /// Kept as written rather than clamped, so a file carrying an
234    /// out-of-range value still shows it. The engine clamps to `0`-`100` when
235    /// it loads; [`Self::effective_standing`] applies that.
236    pub standing: i32,
237}
238
239impl FacReputation {
240    /// Returns the standing the engine would hold after its load-time clamp.
241    ///
242    /// Values at or above `101` become `100`, negatives become `0`.
243    pub fn effective_standing(&self) -> u8 {
244        u8::try_from(i64::from(self.standing).clamp(0, MAX_REPUTATION))
245            .unwrap_or(DEFAULT_REPUTATION)
246    }
247
248    fn from_struct(structure: &GffStruct) -> Self {
249        Self {
250            source: get_u32(structure, "FactionID1").unwrap_or(0),
251            target: get_u32(structure, "FactionID2").unwrap_or(0),
252            standing: read_standing(structure),
253        }
254    }
255
256    fn to_struct(&self, index: usize) -> GffStruct {
257        let mut structure =
258            GffStruct::new(i32::try_from(index).expect("reputation index fits i32"));
259        upsert_field(&mut structure, "FactionID1", GffValue::UInt32(self.source));
260        upsert_field(&mut structure, "FactionID2", GffValue::UInt32(self.target));
261        upsert_field(
262            &mut structure,
263            "FactionRep",
264            // Back to the bit pattern it was read from, so a stored negative
265            // survives the trip unchanged.
266            GffValue::UInt32(u32::from_ne_bytes(self.standing.to_ne_bytes())),
267        );
268        structure
269    }
270}
271
272/// Reads `FactionRep` with the engine's signedness.
273///
274/// The field is written as a DWORD, but the engine interprets it as signed.
275/// That distinction changes the answer: a stored `-1` reaches us as
276/// `0xFFFFFFFF`, and reading it unsigned would make it four billion, which
277/// clamps *up* to `100` (friendly) when the engine clamps it *down* to `0`
278/// (hostile). Signedness is how the field is read correctly; the range clamp
279/// is a separate step and belongs to [`FacReputation::effective_standing`].
280fn read_standing(structure: &GffStruct) -> i32 {
281    match structure.field("FactionRep") {
282        Some(GffValue::UInt32(value)) => i32::from_ne_bytes(value.to_ne_bytes()),
283        Some(GffValue::Int32(value)) => *value,
284        Some(GffValue::UInt16(value)) => i32::from(*value),
285        Some(GffValue::Int16(value)) => i32::from(*value),
286        Some(GffValue::UInt8(value)) => i32::from(*value),
287        Some(GffValue::Int8(value)) => i32::from(*value),
288        _ => i32::from(DEFAULT_REPUTATION),
289    }
290}
291
292/// How a standing reads at the script layer.
293///
294/// The same `10` and `90` boundaries decide whether an NPC treats you as an
295/// enemy, whether a mine arms against you, and whether a faction-owned
296/// placeable, door or trigger is usable.
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
298pub enum FacReaction {
299    /// `0`-`10`: treated as an enemy.
300    Hostile,
301    /// `11`-`89`: neither friend nor enemy.
302    Neutral,
303    /// `90`-`100`: treated as a friend.
304    Friendly,
305}
306
307impl FacReaction {
308    /// Classifies a `0`-`100` standing into its reaction band.
309    pub fn from_standing(standing: u8) -> Self {
310        match standing {
311            0..=10 => Self::Hostile,
312            11..=89 => Self::Neutral,
313            _ => Self::Friendly,
314        }
315    }
316}
317
318/// Errors produced while reading or writing typed FAC data.
319#[derive(Debug, Error)]
320pub enum FacError {
321    /// Source file type is not supported by this parser.
322    #[error("unsupported FAC file type: {0:?}")]
323    UnsupportedFileType([u8; 4]),
324    /// A container field had an unexpected runtime type.
325    #[error("FAC field `{field}` has incompatible type (expected {expected})")]
326    TypeMismatch {
327        /// Field label where the mismatch occurred.
328        field: &'static str,
329        /// Expected runtime value kind.
330        expected: &'static str,
331    },
332    /// Underlying GFF parser/writer error.
333    #[error(transparent)]
334    Gff(#[from] GffBinaryError),
335}
336
337/// Reads typed FAC data from a reader at the current stream position.
338#[cfg_attr(
339    feature = "tracing",
340    tracing::instrument(level = "debug", skip(reader))
341)]
342pub fn read_fac<R: Read>(reader: &mut R) -> Result<Fac, FacError> {
343    let gff = read_gff(reader)?;
344    Fac::from_gff(&gff)
345}
346
347/// Reads typed FAC data directly from bytes.
348#[cfg_attr(
349    feature = "tracing",
350    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
351)]
352pub fn read_fac_from_bytes(bytes: &[u8]) -> Result<Fac, FacError> {
353    let gff = read_gff_from_bytes(bytes)?;
354    Fac::from_gff(&gff)
355}
356
357/// Writes typed FAC data to an output writer.
358#[cfg_attr(
359    feature = "tracing",
360    tracing::instrument(level = "debug", skip(writer, fac))
361)]
362pub fn write_fac<W: Write>(writer: &mut W, fac: &Fac) -> Result<(), FacError> {
363    let gff = fac.to_gff();
364    write_gff(writer, &gff)?;
365    Ok(())
366}
367
368/// Serializes typed FAC data into a byte vector.
369#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(fac)))]
370pub fn write_fac_to_vec(fac: &Fac) -> Result<Vec<u8>, FacError> {
371    let mut cursor = Cursor::new(Vec::new());
372    write_fac(&mut cursor, fac)?;
373    Ok(cursor.into_inner())
374}
375
376impl GffSchema for Fac {
377    fn schema() -> &'static [FieldSchema] {
378        static FACTION_CHILDREN: &[FieldSchema] = &[
379            FieldSchema {
380                label: "FactionName",
381                expected_type: GffType::String,
382                life: FieldLife::Live,
383                required: false,
384                absent: AbsentDefault::Unverified,
385                children: None,
386                constraint: None,
387            },
388            FieldSchema {
389                label: "FactionParentID",
390                expected_type: GffType::UInt32,
391                life: FieldLife::Live,
392                required: false,
393                absent: AbsentDefault::Unverified,
394                children: None,
395                constraint: None,
396            },
397            FieldSchema {
398                label: "FactionGlobal",
399                expected_type: GffType::UInt16,
400                life: FieldLife::Live,
401                required: false,
402                absent: AbsentDefault::Unverified,
403                children: None,
404                constraint: None,
405            },
406        ];
407        static REP_CHILDREN: &[FieldSchema] = &[
408            FieldSchema {
409                label: "FactionID1",
410                expected_type: GffType::UInt32,
411                life: FieldLife::Live,
412                required: false,
413                absent: AbsentDefault::Unverified,
414                children: None,
415                constraint: None,
416            },
417            FieldSchema {
418                label: "FactionID2",
419                expected_type: GffType::UInt32,
420                life: FieldLife::Live,
421                required: false,
422                absent: AbsentDefault::Unverified,
423                children: None,
424                constraint: None,
425            },
426            FieldSchema {
427                label: "FactionRep",
428                expected_type: GffType::UInt32,
429                life: FieldLife::Live,
430                required: false,
431                absent: AbsentDefault::Unverified,
432                children: None,
433                constraint: None,
434            },
435        ];
436        static SCHEMA: &[FieldSchema] = &[
437            FieldSchema {
438                label: "FactionList",
439                expected_type: GffType::List,
440                life: FieldLife::Live,
441                required: false,
442                absent: AbsentDefault::Unverified,
443                children: Some(FACTION_CHILDREN),
444                constraint: None,
445            },
446            FieldSchema {
447                label: "RepList",
448                expected_type: GffType::List,
449                life: FieldLife::Live,
450                required: false,
451                absent: AbsentDefault::Unverified,
452                children: Some(REP_CHILDREN),
453                constraint: None,
454            },
455        ];
456        SCHEMA
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    fn sample() -> Fac {
465        Fac {
466            factions: vec![
467                FacFaction {
468                    name: "PLAYER".to_string(),
469                    ..FacFaction::default()
470                },
471                FacFaction {
472                    name: "Hostile".to_string(),
473                    parent_id: 0,
474                    global: false,
475                },
476            ],
477            reputations: vec![FacReputation {
478                source: 0,
479                target: 1,
480                standing: 5,
481            }],
482        }
483    }
484
485    #[test]
486    fn round_trips_through_gff() {
487        let fac = sample();
488
489        let bytes = write_fac_to_vec(&fac).expect("writes");
490        let parsed = read_fac_from_bytes(&bytes).expect("reads back");
491
492        assert_eq!(parsed, fac);
493    }
494
495    #[test]
496    fn an_absent_pair_is_friendly_not_hostile() {
497        let fac = sample();
498
499        // Stored the other way round only; 1 -> 0 has no entry.
500        assert_eq!(fac.reputation(0, 1), 5);
501        assert_eq!(fac.reputation(1, 0), DEFAULT_REPUTATION);
502        assert_eq!(fac.reaction(1, 0), FacReaction::Friendly);
503        assert_eq!(fac.reaction(0, 1), FacReaction::Hostile);
504    }
505
506    #[test]
507    fn an_unknown_faction_id_still_answers_with_the_baseline() {
508        let fac = sample();
509
510        assert_eq!(fac.reputation(99, 42), DEFAULT_REPUTATION);
511        assert!(fac.faction(99).is_none());
512    }
513
514    #[test]
515    fn reaction_bands_match_the_documented_boundaries() {
516        assert_eq!(FacReaction::from_standing(0), FacReaction::Hostile);
517        assert_eq!(FacReaction::from_standing(10), FacReaction::Hostile);
518        assert_eq!(FacReaction::from_standing(11), FacReaction::Neutral);
519        assert_eq!(FacReaction::from_standing(89), FacReaction::Neutral);
520        assert_eq!(FacReaction::from_standing(90), FacReaction::Friendly);
521        assert_eq!(FacReaction::from_standing(100), FacReaction::Friendly);
522    }
523
524    #[test]
525    fn a_missing_faction_global_defaults_to_true() {
526        let mut structure = GffStruct::new(0);
527        structure.push_field("FactionName", GffValue::String("X".to_string()));
528
529        assert!(FacFaction::from_struct(&structure).global);
530    }
531
532    #[test]
533    fn a_negative_standing_reads_as_negative_not_as_four_billion() {
534        // Written as a DWORD, -1 arrives as 0xFFFFFFFF. Read unsigned it would
535        // become u32::MAX and clamp up to friendly.
536        let mut structure = GffStruct::new(0);
537        structure.push_field("FactionRep", GffValue::UInt32(u32::MAX));
538
539        assert_eq!(read_standing(&structure), -1);
540    }
541
542    #[test]
543    fn out_of_range_standings_are_kept_but_clamp_when_asked() {
544        let hostile = FacReputation {
545            source: 0,
546            target: 1,
547            standing: -1,
548        };
549        let over = FacReputation {
550            source: 0,
551            target: 2,
552            standing: 500,
553        };
554
555        // Stored as written...
556        assert_eq!(hostile.standing, -1);
557        assert_eq!(over.standing, 500);
558        // ...and clamped only where the engine would.
559        assert_eq!(hostile.effective_standing(), 0);
560        assert_eq!(over.effective_standing(), 100);
561    }
562
563    #[test]
564    fn a_pair_at_the_default_is_not_written() {
565        let fac = Fac {
566            factions: vec![FacFaction::default()],
567            reputations: vec![
568                FacReputation {
569                    source: 0,
570                    target: 0,
571                    standing: i32::from(DEFAULT_REPUTATION),
572                },
573                FacReputation {
574                    source: 0,
575                    target: 1,
576                    standing: 5,
577                },
578            ],
579        };
580
581        let bytes = write_fac_to_vec(&fac).expect("writes");
582        let parsed = read_fac_from_bytes(&bytes).expect("reads back");
583
584        // The default pair is dropped the way the engine's writer drops it;
585        // reading it back still answers with the baseline.
586        assert_eq!(parsed.reputations.len(), 1);
587        assert_eq!(parsed.reputations[0].target, 1);
588        assert_eq!(parsed.reputation(0, 0), DEFAULT_REPUTATION);
589    }
590
591    #[test]
592    fn an_out_of_range_standing_survives_a_round_trip() {
593        let fac = Fac {
594            factions: vec![FacFaction::default()],
595            reputations: vec![FacReputation {
596                source: 0,
597                target: 0,
598                standing: -12,
599            }],
600        };
601
602        let bytes = write_fac_to_vec(&fac).expect("writes");
603        let parsed = read_fac_from_bytes(&bytes).expect("reads back");
604
605        assert_eq!(parsed.reputations[0].standing, -12);
606    }
607
608    #[test]
609    fn a_non_list_faction_field_is_rejected() {
610        let mut root = GffStruct::new(-1);
611        root.push_field("FactionList", GffValue::UInt32(7));
612        let gff = Gff::new(*b"FAC ", root);
613
614        let error = Fac::from_gff(&gff).expect_err("not a list");
615
616        assert!(matches!(
617            error,
618            FacError::TypeMismatch {
619                field: "FactionList",
620                ..
621            }
622        ));
623    }
624
625    #[test]
626    fn an_unrelated_file_type_is_rejected() {
627        let gff = Gff::new(*b"UTC ", GffStruct::new(-1));
628
629        assert!(matches!(
630            Fac::from_gff(&gff),
631            Err(FacError::UnsupportedFileType(_))
632        ));
633    }
634
635    #[test]
636    fn an_empty_table_reads_as_empty_rather_than_failing() {
637        let gff = Gff::new(*b"FAC ", GffStruct::new(-1));
638
639        let fac = Fac::from_gff(&gff).expect("empty FAC is valid");
640
641        assert!(fac.factions.is_empty());
642        assert!(fac.reputations.is_empty());
643    }
644}