FAC Format (Faction & Reputation Table)
A .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. |
File Layout
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.
Field Schema
Every label the schema declares is tabulated at the end of this page, generated from the schema rather than written by hand.
Engine Audits & Decompilation
Read from the functions named below in swkotor.exe (K1 GOG build). Provenance: derived, not attested. The rows have not been separately re-derived, so they sit on the reverse-engineering queue. 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 is 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
Write 0xFFFFFFFF and never read it. Every faction in a real save carries that value, and nothing in the engine consults it: LoadFactionsFromSaveGame copies it onto the record with no comparison or branch, SaveFactions writes it straight back, and no reputation lookup, membership change or script-layer faction command touches it, nor anything else taking a CFactionManager* or CSWSFaction*. There is no parent-child traversal anywhere in this build.
It comes from LoadFactions, the fresh-game path that seeds factions out of repute.2da, which hardcodes 0xFFFFFFFF for every faction it creates. Nothing downstream changes it, so it survives every save and load after that: vestigial infrastructure for a hierarchy the engine never implements.
A file that omits the field loads as 0 instead, because the save-restore path reads through the ordinary ReadFieldDWORD with a 0 default while LoadFactions never calls a field reader at all. Since nothing reads either value the difference is cosmetic, but 0xFFFFFFFF is what every real save holds.
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 default to a literal 0 when absent from a present RepList entry. The write is gated on an asymmetric bounds check, so that shared 0 does opposite things:
| Missing field | Check | Outcome |
|---|---|---|
FactionID2 | must be strictly greater than 0 | 0 fails, and the entry’s write is skipped silently, as though the entry were not there |
FactionID1 | only needs to be a valid index | 0 passes, and the entry is written using faction index 0 as the row |
So an entry with a valid FactionID2 and a missing FactionID1 is not dropped. It overwrites whatever reputation pair sits at (faction 0, FactionID2). Same type, same default, opposite consequences.
FactionRep itself also defaults to a literal 0 when absent from a present, otherwise-valid entry, rather than 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.
Every label the schema declares
Generated from the schema, so no label can be quietly left out. How to read these tables.
Fields nobody has examined
Whether the engine reads these has not been established, which is not the same as establishing that it does not. Where When absent carries an answer, that half is settled.
| Field | Type | When absent |
|---|---|---|
FactionList | List | NOT EXAMINED; we substitute container |
FactionList[].FactionName | CExoString | stamps "" |
FactionList[].FactionParentID | DWORD | stamps 0 |
FactionList[].FactionGlobal | WORD | stamps 1 |
RepList | List | not one constant; we substitute container |
RepList[].FactionID1 | DWORD | stamps 0 |
RepList[].FactionID2 | DWORD | stamps 0 |
RepList[].FactionRep (required) | DWORD | stamps 0 |