Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

PropertyValue
Extension(s).fac
Magic SignatureFAC / V3.2
TypeFaction & Reputation Table
Rust ReferenceNot 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:

FunctionAddressCovers
CFactionManager::SaveFactions0x0052b790FactionList write
CFactionManager::SaveReputations0x0052b830RepList write (only non-100 pairs emitted)
CFactionManager::LoadFactionsFromSaveGame0x0052b5c0FactionList read
CFactionManager::LoadReputationsFromSaveGame0x0052bbe0RepList read; the 100-baseline rebuild and 0-100 clamp
CFactionManager::GetIsNPCFaction0x0052b280Global vs personal faction model
CFactionManager::CreateDefaultFactions0x0052bce0Hardcoded default set used when no table loads
CFactionManager::LoadFactions0x0052b490Fresh-game path (repute.2da); origin of the FactionParentID sentinel, see below
ExecuteCommandGetNearestObject0x0054b550Reaction bands (0-10 / 11-89 / 90-100), corroborated by placeable/door/trigger usability checks

FactionList fields

FieldTypeMeaning
FactionNameCExoStringDisplay/lookup name of the faction.
FactionParentIDDWORDRead and round-tripped, but never consulted: see FactionParentID is a dead sentinel below.
FactionGlobalWORDWhether 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.

FieldTypeMeaning
FactionID1DWORDSource faction id.
FactionID2DWORDTarget faction id.
FactionRepDWORDStanding 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 at 100), then applies the RepList entries as overrides. FactionRep is clamped to 0-100 on load (values at or above 101 snap to 100, negatives snap to 0). A reader that treats absent pairs as 0 will 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:

FactionRepReaction
0-10Hostile (treated as an enemy)
11-89Neutral
90-100Friendly (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.fac and repute.2da are two different resources, and only one of them is this format. repute.fac is the FAC GFF: the runtime/saved faction table (resref REPUTE, type FAC ). It is never a loose file. It lives bundled inside SAVEGAME.sav and inside some module archives. repute.2da is the static definition table the engine reads to build factions for a fresh game, and it is the repute entry you will find in chitin.key / 2da.bif. Go looking for repute.fac on disk and you will not find it. Only repute.2da turns 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.