FAC Format (Faction & Reputation Table)
Description: The Faction (.fac) file is a single global table describing every faction in the game and how each faction feels about every other. The engine keys it on the resref REPUTE (type FAC ), and a save game stores the live, mutated copy under that resref. It is a flat GFF, not a per-object blueprint: there is one faction table for the whole session, not one per creature. Do not confuse it with repute.2da, a separate 2DA holding the static faction definitions a fresh game is seeded from (see Save-game context below).
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .fac |
| Magic Signature | FAC / V3.2 |
| Type | Faction & Reputation Table |
| Rust Reference | Not yet modelled in rakata-generics. |
Data Model Structure
A FAC file is two top-level lists:
FactionList: the roster of factions, in faction-id order (a faction’s id is its index in this list).RepList: a sparse set of pairwise reputation overrides between factions.
Individual objects (.utc/.utd/.utp/.ute) do not embed faction data; they carry a single Faction id that indexes into this shared table.
Engine Audits & Decompilation
Documented from Ghidra decompilation of swkotor.exe (K1 GOG build); see the Provenance Policy. The field tables, the sparse-matrix rule, the reaction bands, and the global/personal model are read from:
| Function | Address | Covers |
|---|---|---|
CFactionManager::SaveFactions | 0x0052b790 | FactionList write |
CFactionManager::SaveReputations | 0x0052b830 | RepList write (only non-100 pairs emitted) |
CFactionManager::LoadFactionsFromSaveGame | 0x0052b5c0 | FactionList read |
CFactionManager::LoadReputationsFromSaveGame | 0x0052bbe0 | RepList read; the 100-baseline rebuild and 0-100 clamp |
CFactionManager::GetIsNPCFaction | 0x0052b280 | Global vs personal faction model |
CFactionManager::CreateDefaultFactions | 0x0052bce0 | Hardcoded default set used when no table loads |
CFactionManager::LoadFactions | 0x0052b490 | Fresh-game path (repute.2da); origin of the FactionParentID sentinel, see below |
ExecuteCommandGetNearestObject | 0x0054b550 | Reaction bands (0-10 / 11-89 / 90-100), corroborated by placeable/door/trigger usability checks |
FactionList fields
| Field | Type | Meaning |
|---|---|---|
FactionName | CExoString | Display/lookup name of the faction. |
FactionParentID | DWORD | Read and round-tripped, but never consulted: see FactionParentID is a dead sentinel below. |
FactionGlobal | WORD | Whether the faction is global. On load, a missing FactionGlobal defaults to 1. |
A faction’s own id is its position in FactionList; it is not stored on the element.
Global versus personal factions. FactionGlobal flags whether a faction is one of the standard, shared factions (the set seeded from repute.2da) or a non-global one; when the field is absent on load it defaults to 1 (global). At the script layer, ChangeToStandardFaction moves a creature into a standard faction (the engine validates the target id with CFactionManager::GetIsNPCFaction and refuses otherwise), while ChangeFaction moves it into another creature’s faction; neither can change a player character’s faction. When no faction table loads at all, the engine falls back to a small hardcoded default set (CFactionManager::CreateDefaultFactions) rather than reading repute.2da.
FactionName defaults to a literal empty string when absent, unconditionally. An empty name still leaves the faction in the general roster – it’s just never wired up as one of the manager’s special named roles (player, hostile_1, friendly_1, hostile_2, friendly_2, neutral, insane), since an empty string can’t match any of those six comparisons.
FactionParentID is a dead sentinel
Every faction in a real save carries 0xFFFFFFFF in this field, which looks like a “no parent” sentinel – and it is one, but not a live one. LoadFactionsFromSaveGame reads FactionParentID unconditionally and copies it straight onto the in-memory faction record with no comparison, no branch, and no special case for 0xFFFFFFFF. SaveFactions writes that same stored value straight back out: a pure round-trip, not a use. Nothing in the faction subsystem – reputation lookups, faction-membership changes, the script-layer faction commands, anything with a CFactionManager* or CSWSFaction* in its signature – ever reads the stored value back out for a lookup or a decision. There is no parent-child traversal anywhere in this engine build.
The sentinel’s origin is mundane: LoadFactions, the fresh-game path that seeds factions from repute.2da (as opposed to restoring them from a save), hardcodes 0xFFFFFFFF for every faction it creates. Nothing downstream ever sets it to anything else, so it survives unchanged through every subsequent save and load. FactionParentID is best understood as vestigial infrastructure for a faction hierarchy the engine never implements, in the same family as this codebase’s other confirmed-dead fields.
RepList fields and the sparse-matrix rule
Reputation is conceptually an N x N matrix (every faction’s standing toward every other faction), but it is stored sparsely. The default standing is 100 (the top of the friendly band, see below), and the writer emits a RepList entry only for pairs whose reputation is not 100.
| Field | Type | Meaning |
|---|---|---|
FactionID1 | DWORD | Source faction id. |
FactionID2 | DWORD | Target faction id. |
FactionRep | DWORD | Standing of faction 1 toward faction 2, 0-100. |
Important
A missing pair means
100, not zero. On load the engine first rebuilds the full reputation matrix at its default baseline (every pair starts at100), then applies theRepListentries as overrides.FactionRepis clamped to0-100on load (values at or above101snap to100, negatives snap to0). A reader that treats absent pairs as0will make the whole galaxy hostile.
FactionID1 and FactionID2 Share a Default, Not a Consequence
Both fields default to a literal 0 when absent from a present RepList entry – but that shared 0 behaves completely differently on each side, because of an asymmetric bounds check the write is gated on (FactionID2 must be strictly greater than 0; FactionID1 only needs to be a valid index, and 0 qualifies). An absent FactionID2 defaults to 0, fails that strict check, and the whole entry’s write is silently skipped – functionally the same as if the entry weren’t there at all. An absent FactionID1, on the other hand, also defaults to 0, but 0 passes as a legitimate faction index – so a RepList entry with a present, valid FactionID2 but a missing FactionID1 doesn’t get dropped. It gets written using faction index 0 as the row, silently overwriting whatever reputation pair happens to sit at (faction 0, FactionID2) instead of doing nothing. Same field type, same literal default, opposite outcomes.
FactionRep itself also defaults to a literal 0 when absent from a present, otherwise-valid entry – not the 100 sparse-matrix baseline documented above, which only applies when the whole pair is missing from RepList. A present entry with valid ids but no FactionRep writes a standing of 0 (hostile) into the matrix, clamped as a no-op since 0 is already in range, overwriting whatever baseline was sitting there.
There’s a second-order hazard worth knowing if you’re hand-editing a RepList: the loader reuses one found-flag variable across all three fields in an entry, and only checks it after FactionRep – the last of the three reads. A present FactionID1/FactionID2 with an absent FactionRep clears that shared flag, and the loader reads it as “this entry failed,” silently truncating every RepList entry that follows, not just the one with the missing field.
What the numbers mean
FactionRep is a 0-100 standing, and the engine reads it in three bands:
FactionRep | Reaction |
|---|---|
0-10 | Hostile (treated as an enemy) |
11-89 | Neutral |
90-100 | Friendly (treated as a friend) |
The same 10 and 90 boundaries turn up all over the engine: whether an NPC counts you as an enemy, whether a placed mine arms against you, and whether you may use a placeable, door, or trigger that a faction owns. So the default of 100 lands an unmodified pair squarely in the friendly band, not a literal midpoint.
Save-game context
There is no per-module faction file. When the engine stores a module it writes the entire global faction manager to a single REPUTE resource (type FAC ) in the GAMEINPROGRESS: working directory, which is then bundled into SAVEGAME.sav. So a save’s faction state lives at the resref REPUTE inside the main save archive, carrying the FactionList / RepList structure described above.
On load, LoadModuleStart probes for a REPUTE resource of type FAC . If one resolves (the save’s bundled copy, or a repute.fac that ships inside a module archive) it restores the table with LoadFactionsFromSaveGame / LoadReputationsFromSaveGame. If none is found, it builds the table from repute.2da instead and seeds the default reputations.
Note
repute.facandrepute.2daare two different resources, and only one of them is this format.repute.facis the FAC GFF: the runtime/saved faction table (resrefREPUTE, typeFAC). It is never a loose file. It lives bundled insideSAVEGAME.savand inside some module archives.repute.2dais the static definition table the engine reads to build factions for a fresh game, and it is thereputeentry you will find inchitin.key/2da.bif. Go looking forrepute.facon disk and you will not find it. Onlyrepute.2daturns up.
See the Save Game Deep Dive for how the save archive bundles its resources.
Implemented Linter Rules (Rakata-Lint)
None yet. The format is documented here ahead of any dedicated rakata-lint rules.