# madden-franchise v4.3.1 — API notes for CFB27 (College Football 27 dynasty saves)

Source inspected: vendored copy at
`E:/Games/EA SPORTS College Football 27.SteamGG.NET/Mods/CoachCarouselRecruitTool/reference/force-commit-recruits/node_modules/madden-franchise`
(README.md, package.json, all `dist/*.d.ts`, targeted reads of the `dist/index.cjs` bundle, `data/` tree).
All line numbers below refer to `dist/index.cjs` unless stated otherwise.

---

## 0. Package facts

- Version **4.3.1**, author bep713/matthewpanetta, MIT. `"type": "module"`, but ships a **dual build**: `main: dist/index.cjs` (CommonJS) and `module: dist/index.mjs`. So despite the README note that "v4 migrated to ESM", **`require('madden-franchise')` works fine from CJS** — this is exactly what the reference tools do:
  ```js
  const { FranchiseFile } = require('madden-franchise');
  ```
  (also exported: `create`, `FranchiseFileTable`, `FranchiseFileRecord`, `FranchiseFileField`, `FranchiseFileTable2Field`, `FranchiseFileTable3Field`, `FranchiseSchema`, `FranchiseEnum`, `FranchiseEnumValue`, `FranchiseFileSettings`, `IsonProcessor`, `readChviRecord`, `schemaGenerator`, `generateSchemaV2`, `schemaPicker`, `utilService`; `exports.default = FranchiseFile`).
- Runtime deps: `bit-buffer` (BitView bit-level access), `fast-xml-parser`, `node-xml-stream-parser`. **No zstd dependency — it uses Node's built-in `zlib.zstdCompressSync/zstdDecompressSync`**, hence `"engines": { "node": ">=22.19.0" }`. Node v26.4.0 on this machine satisfies it.
- Supported games incl. **College Football 27: Full** (README table). Game type constants: `Constants.GAME_TYPE.MADDEN = 'madden'`, `Constants.GAME_TYPE.COLLEGE = 'college'`; formats `Constants.FORMAT.FRANCHISE = 'franchise'`, `FRANCHISE_COMMON = 'franchise-common'` (FTC = pre-baked common data file inside game files; a dynasty save is `franchise`).

### data/ directory layout (bundled with the package)

```
data/
  schemas/
    19/M19_95_7.gz … 26/M26_682_1.gz
    27/C27_468_2.gz          <- CFB27 schema (2,938,595 bytes; gzip'd JSON)
    27/M27_525_0.gz          <- Madden 27 schema (11,049,951 bytes)
    extra-schemas.json
  interned-strings/
    25/lookup.json  26/lookup.json  27/lookup.json
    c27/lookup.json           <- CFB27 ISON interned-string table (1,287,128 bytes)
    c27/lookup-cga.json       <- CFB27 CharacterGameplay-specific lookup (471 bytes)
  zstd-dicts/
    26/dict.bin (20,480 B)  27/dict.bin (20,480 B)
    c27/dict.bin (13,300 B)   <- zstd dictionary for CFB27 table3 blobs (CharacterVisuals etc.)
    c27/dict-cga.bin (20,480 B) <- zstd dictionary for the CFB27 CharacterGameplay table only
  lookup-files/
    enumLookup.json fieldLookup.json internedStringLookup.json slotsLookup.json
    (used by the TDB2/CharacterVisuals converter `readChviRecord` for M24-era blobs)
```

Directory key logic (lines 6421, 6859): `dirKey = (gameType === 'college' ? 'c' : '') + gameYear` → CFB27 = `c27`.

---

## 1. FranchiseFile

### Construction / open pattern

```js
// modern
const file = await FranchiseFile.create(savePath, settings);   // resolves on 'ready' (only when autoParse)
// classic (used by all reference tools)
const file = new FranchiseFile(savePath, settings);
file.on('ready', () => { ... });
file.on('error', err => { ... });
```

- The constructor **synchronously reads the whole file** (`fs.readFileSync`), detects file type / game year / game type, extracts schema metadata, and — if compressed — inflates the DB payload. If `settings.autoParse` (default **true**) it immediately kicks off `parse()`.
- `FranchiseFile.create` only wires `ready`/`error` when `autoParse` is on; with `autoParse: false` it resolves immediately and you must call `await`-less `file.parse()` yourself then listen for `ready` (parse itself returns a Promise-shaped flow via events; the method signature is `parse(): Promise<void>` but the implementation resolves via the `'ready'` event, not a returned promise — treat events as the source of truth).

### FranchiseFileSettings (constructor defaults, lines 7258-7307)

| option | type | default | notes |
| ------ | ---- | ------- | ----- |
| `saveOnChange` | bool | `false` | any change → `packFile()` (overwrites the opened file!) |
| `schemaOverride` | `{major, minor, gameYear, path}` \| false | `false` | if `path` set, picker is bypassed entirely |
| `schemaDirectory` | string \| false | `false` | extra dir searched **before** bundled `data/schemas` |
| `autoParse` | bool | `true` | |
| `autoUnempty` | bool | `false` | see §2 empty records; "may have unintended side-effects if you batch import" |
| `useNewSchemaGeneration` | bool | `false` | only for building schemas from .ftx/.xml |
| `schemaFileMap` | object | `{}` | v2 schema gen input files |
| `extraSchemas` | array | `undefined` | v2 schema gen |
| `gameYearOverride` | number | `null` | force year (FTC files etc.) |
| `gameTypeOverride` | `'madden'\|'college'` | `null` | force game type |

Proven CFB27 open pattern (from `reference/force-commit-recruits/engine/rg/openSave.js`):

```js
new FranchiseFile(savePath, {
  autoParse: true,
  schemaDirectory: SCHEMA_DIR,                       // dir containing C27_468_2.gz
  schemaOverride: { major: 468, minor: 2, gameYear: 27, path: path.join(SCHEMA_DIR, 'C27_468_2.gz') },
});
```
Note: the package **bundles** `data/schemas/27/C27_468_2.gz` itself, so auto-pick would also work for a CFB27 save; the override just makes it deterministic.

### File-format detection (lines 7980-8138)

- `isCompressed`: first 4 bytes `46 72 54 6b` ("FrTk") ⇒ *de*compressed; anything else ⇒ compressed. Dynasty saves are compressed.
- `getFormat`: compressed + zlib header `78 9c` at offset 0 ⇒ `franchise-common` (FTC); otherwise `franchise`. (FTC has the zlib stream at offset 0; a franchise save has a 0x52-byte header first.)
- `getGameYear` (compressed franchise): year identifier char near 0x22..0x25 / 0x2a / 0x2b. **CFB27: `data[0x2b] === 0x37` ("7", one byte later than Madden because "College" is longer)** ⇒ year 27.
- `getGameType` (lines 8115-8138): year ≤ 26 ⇒ always `madden`. Year 27: `data[0x22] === 0x43` ('C') ⇒ `college`; or year-27 indicator at 0x2b but not 0x2a ⇒ `college`; else `madden`.
- `getSchemaMetadata` (compressed franchise): `major = data.readUInt32LE(0x3e)`, `minor = data.readUInt32LE(0x42)` — read from the **packed** header. For a CFB27 save these are 468/2 (matches C27_468_2).

### Compression — what is zstd, what is zlib (IMPORTANT)

- **The franchise DB payload itself is zlib DEFLATE, not zstd.** `unpackFile` (line 7933): for `franchise` format, `zlib.inflateSync(data.slice(0x52))` (`COMPRESSED_DATA_OFFSET = 0x52`). Repacking uses `zlib.deflate(data, { windowBits: 15 })` (line 7940).
- **zstd + the c27 dictionaries are used ONLY for table3 (binaryblob) field payloads** in M26/M27/CFB27 saves — e.g. `CharacterVisuals` ISON blobs — via `FranchiseZstdTable3FieldStrategy` (lines 6835-7001). See §4b.
- (So the earlier project shorthand "zstd-compressed franchise DB" is imprecise: file-level = zlib deflate; per-blob table3 = zstd with dictionary.)

### parse() internals (lines 7543-7677)

1. Picks the game strategy: `StrategyPicker.pick(type)` — **CFB27 (franchise, college, year 27) resolves to `M26Strategy`** (`pickCollegeFranchiseStrategy` just defers to the Madden picker; year 26/27 ⇒ M26Strategy, line 7211-7213): file=M20FileStrategy, table=M24TableStrategy, table2Field=M20Table2Strategy, table3Field=M26Table3Strategy (the zstd one).
2. Loads the schema: `schemaOverride.path` if given, else `schemaPicker.pick(gameYear, major, minor, settings, gameType)`. The picker parses schema filenames with regex `/(?:(M|C)(\d+)_(\d+)_(\d+)|(?!M)(\d+)_(\d+))/i` — the `C` prefix marks `gameType: 'college'` — filters by gameYear+gameType, then picks exact major / closest minor (or closest major). Custom `schemaDirectory` is searched first.
3. Finds tables by **scanning the unpacked buffer** for magic 4-byte markers: `SPBF` (0x53 0x50 0x42 0x46), `ASTO`, or `SPEX`. Table start = marker index − 0x94 (for year ≥ 20). Each slice between consecutive markers becomes a `FranchiseFileTable` (last table excludes trailing 8 bytes). `table.index` = order in file.
4. Parses the **asset table**: offset at `unpacked.readUInt32BE(4)`, entry count at `readUInt32BE(36)`; entries are `{assetId: u32BE, reference: u32BE}` pairs.
5. Assigns each table its schema (`schemaList.getSchema(table.name)`); mismatch in attribute count vs `header.numMembers` ⇒ schema setter silently ignores it (table keeps no schema; readRecords will then warn and use a generic all-int schema — dangerous for editing).
6. Emits `'ready'` (sets `isLoaded = true`) or `'error'` (also `console.log`s the error).

### Properties

| property | type | notes |
| -------- | ---- | ----- |
| `rawContents` | Buffer | the original **packed** bytes as read from disk (getter for `_rawContents`) |
| `packedFileContents` | Buffer | same as raw for compressed files |
| `unpackedFileContents` | Buffer | inflated DB; regenerated during save |
| `tables` | `FranchiseFileTable[]` | after parse |
| `assetTable` | `{assetId, reference}[]` | |
| `schemaList` / `schema` (alias getter) | FranchiseSchema | |
| `expectedSchemaVersion` | `{gameYear, major, minor}` | from the file header |
| `gameYear` | number | 27 for CFB27 |
| `gameType` | `'madden' \| 'college'` | `'college'` for CFB27 |
| `type` | `{format, year, compressed, gameType}` | |
| `settings` | FranchiseFileSettings | setter re-wraps in a new settings object |
| `filePath` | string | settable (changes default save destination) |
| `isLoaded` | bool | |
| `strategy` | `{name, file, table, table2Field, table3Field}` | `name === 'M26Strategy'` for CFB27 |

### Methods

- `getTableByName(name)` → **first** match only. Multiple tables share names in CFB27 saves — use `getAllTablesByName(name)` (or `file.tables.filter(...)`) and pick by `header.recordCapacity` (largest wins, the established pattern).
- `getTableByUniqueId(id)` → matches `header.uniqueId` (= `tablePad1`). README calls this the *best* lookup because uniqueIds are stable across saves/years. (Our project convention is name-based; both work.)
- `getTableById(id)` → matches `header.tableId` (shifts per file — avoid persisting these).
- `getTableByIndex(i)`.
- `getReferencedRecord(binaryRefString)` → decodes the 32-char binary string and returns `this.getTableById(tableId)?.records[rowNumber]` — **returns a `FranchiseFileRecord`, not `{tableId,rowNumber}`** (the .d.ts return type annotation is wrong); the target table must already have had `readRecords()` called or `records` is empty ⇒ `undefined`.
- `getReferenceFromAssetId(assetId)` → `{tableId, rowNumber}` from the asset table. NOTE (verified in code, line 7853): it converts with `.toString(2).padStart(32)` — **missing the `'0'` pad character, so it pads with spaces**; `bin2dec`→`parseInt(x,2)` still parses because parseInt trims, but the tableId portion of short references can decode wrong. UNVERIFIED whether any CFB27 asset reference is short enough to trigger this; treat this helper with suspicion and prefer decoding `assetEntry.reference` yourself: `tableId = ref >>> 17`, `rowNumber = ref & 0x1FFFF`.
- `getReferencesToRecord(tableId, recordIndex)` → scans every table whose schema could hold that record type (attribute type equals the table name or `'record'`, plus array tables named `<Type>[]`) for the hex form of the reference; returns `[{tableId, name, table}]`. Brute-force buffer scan — slow on large saves, fine for one-off audits.
- `save(outputFilePath?, options?)` / `packFile(...)` — see §6.
- `parse()`.

### Events

| event | when | args to listener |
| ----- | ---- | ---------------- |
| `ready` | tables + schemas parsed | none |
| `error` | parse failure | `err` |
| `change` | any table changed | the changed `FranchiseFileTable` (first arg) |
| `saving` | at start of `packFile` | none |
| `saved` | write completed | none |
| `save-error` | write failed | none (the promise also rejects) |

---

## 2. FranchiseFileTable

### Key properties

- `name` (string, from header; array tables end in `[]`), `isArray`, `index` (position in file), `offset` (byte offset of the table in the unpacked file), `data` (Buffer — the table's raw slice), `lengthAtLastSave`.
- `header: FranchiseFileTableHeader` — full field list (all parsed big-endian from the table buffer by `M20TableHeaderStrategy.parseHeader` + `M24TableHeaderStrategy` add-ons, lines 6163-6336):
  `name, isArray, tableId (u32 @0x80), tablePad1/uniqueId (u32 @0x84), tableUnknown1, tableUnknown2, data1Id, data1Type, data1Unknown1, data1Flag1..4 (bytes), tableStoreLength, tableStoreName, data1Offset, data1TableId, data1RecordCount, data1Pad2, table1Length, table2Length, data1Pad3, data1Pad4, headerSize, headerOffset (0xE8), record1SizeOffset, record1SizeLength, record1Size (= recordWords*4 bytes per row), offsetStart (0xE8 + tableStoreLength), data2Id, table1Length2, tableTotalLength, hasSecondTable (tableTotalLength > table1Length), table1StartIndex, table2StartIndex, recordWords, recordCapacity, numMembers, nextRecordToUse` — plus (M24+, i.e. CFB27): `table3Length (= data1Pad3), hasThirdTable (table3Length > 0), table3StartIndex (= table2StartIndex + table2Length)`.
  - `recordCapacity` = max rows; `data1RecordCount` = rows physically present (records array length); `numMembers` = column count; `nextRecordToUse` = head of the empty-record free list (== recordCapacity when the table is full/has no empties).
- `records: FranchiseFileRecord[]` (empty until `readRecords()`), `table2Records`, `table3Records`, `arraySizes` (array tables: element count per row), `emptyRecords: Map<number, {previous, next}>`, `recordsRead`, `isChanged`, `loadedOffsets`, `offsetTable`, `schema` (setter re-derives header attribs and **resets records/offsets/emptyRecords**), `strategy` (table strategy), `strategyBase` (whole game strategy), `_gameYear`, `_gameType`.
- `hexData` getter = calls `updateBuffer()` then returns `data` (this is how saving pulls fresh bytes).

### readRecords(attribsToLoad?)

```js
await table.readRecords();                 // all fields
await table.readRecords(['FirstName']);    // partial: only these offsets get FranchiseFileField entries
```
- Idempotent: re-resolves only if never read or new attributes requested (already-loaded offsets are kept).
- Non-array tables: builds the **offset table** by pairing `schema.attributes` with the 4-byte little-endian `indexOffset` words starting at `header.offsetStart`. Each entry: `{index, originalIndex, name, type, isReference, valueInSecondTable (hasSecondTable && type==='string'), valueInThirdTable (hasThirdTable && type==='binaryblob'), isSigned (min/max < 0 → type prefixed 's_'), minValue, maxValue, maxLength, final, indexOffset, enum, const, offset (bit offset within the record), length (bit width, computed from the next offset, capped at 32)}`. `final`/`const`/function-typed offsets are skipped.
- Array tables (`Foo[]`): synthesizes 32-bit reference columns named `Foo0, Foo1, …` (`record1Size/4` of them) and reads `arraySizes` from the words after the header.
- If the table has no schema: warns and generates `Field_0..n` generic int columns — **do not write through a generic schema**.
- Rows: `data.slice(table1StartIndex + i*record1Size, …)` for `i in [0, data1RecordCount)`; each becomes a `FranchiseFileRecord`.
- Then table2 values (strings) and table3 values (blobs) are attached to the matching fields, `emptyRecords` map is parsed, `record.isEmpty` set for rows in the map, `record.arraySize` set for array tables.
- Returns the table itself.

### Dirty tracking / change propagation

Field set → `record.onEvent('change', field)` → `table.onEvent('change', record)` → sets `record.isChanged = true`, handles un-emptying (below), emits table `'change'` → FranchiseFile handler sets `table.isChanged = true`, optionally auto-saves (`saveOnChange`), re-emits file-level `'change'`. Nothing is written to `table.data` until `updateBuffer()` runs (implicitly during save via the `hexData` getter).

### updateBuffer() (lines 4609-4710)

Rebuilds `table.data`: splices each changed record's `record.hexData` (its 4-byte-aligned record buffer) at `table1StartIndex + index*record1Size`; regenerates table2/table3 sections via the strategy; **updates `header.table2Length` (u32BE @ offsetStart−44), `header.table3Length` (@ offsetStart−40) and `tableTotalLength` (@ offsetStart−24) only when `recordsRead`**; rewrites array sizes if an array table changed. Record `isChanged` flags are cleared here.

### Empty records (free list)

- Format: an empty row's **first 4 bytes = u32BE index of the next empty row** (or `recordCapacity` for the tail); `header.nextRecordToUse` is the head. `table.emptyRecords` maps `rowIndex -> {previous, next}` (parsed by walking the chain from `nextRecordToUse` in `_parseEmptyRecords`).
- `record.isEmpty` boolean is set at read time for chained rows.
- `record.empty()` → `_onRecordEmpty`: appends the record to the free-list tail (updates prior tail's pointer + buffers, or makes it the head via `setNextRecordToUse`). **The rest of the record's bytes are NOT zeroed** — the `fill(0)` call is commented out (line 5062); only the first 4 bytes get the chain pointer. If you need EA-clean empties, zero bytes 4..record1Size yourself before calling `empty()`.
- Writing to a field of an empty record (table.onEvent 'change' logic, lines 5087-5203):
  - `autoUnempty: false` (default): the row is un-emptied **only if a changed field overlaps the first 32 bits** (`offset.indexOffset < 32`). Changing only later fields leaves the row chained as empty — the game will still treat it as free and may overwrite it. Classic footgun.
  - `autoUnempty: true`: any field change un-empties the row; if the changed fields don't touch the first 4 bytes, those bytes are zeroed for you (and affected fields' caches invalidated).
  - Un-emptying repairs the linked list (`previous.next = next` in both the map and the buffers) and updates `nextRecordToUse` (header + u32BE @ `offsetStart − 4`) — falling back to `recordCapacity` when no empties remain. It also runs `recalculateStringOffsets`/`recalculateBlobOffsets` so the row's table2/table3 pointers become `rowIndex * bytesPerRecord + runningOffset` (franchise layout: every row owns a fixed-size string block).
- `setNextRecordToUse(index, resetEmptyRecordMap?)` — manually set the head (writes header + buffer, optionally re-parses the map). Advanced.
- `recalculateEmptyRecordReferences()` — rebuilds `isEmpty` flags by scanning rows whose first 4 bytes look like `{tableId: 0, rowNumber: != 0}` (skipped if column 0 is a string), finds the unreachable head, resets `nextRecordToUse`. Warns loudly if >1 unreachable empties (game-crash state) and then does NOT touch the header.
- Adding a new row = write your data into `records[header.nextRecordToUse]` (must be < `recordCapacity`); the un-empty machinery advances the free list. **You cannot grow a table beyond `recordCapacity`.**

### Other methods

- `getBinaryReferenceToRecord(index)` → 32-char binary reference string `dec2bin(header.tableId,15) + dec2bin(index,17)` — feed this to reference fields of other tables.
- `replaceRawData(buf, shouldReadRecords)` — wholesale table swap (used for table-copy tooling); resets everything and re-parses the header.
- Table event: `'change'` (no payload).

---

## 3. FranchiseFileRecord and FranchiseFileField

### Record = Proxy (lines 4286-4322)

`new FranchiseFileRecord(...)` returns a `Proxy`; property access checks **fields first, then instance properties**:
```js
rec.FirstName            // -> fields['FirstName'].value  (formatted)
rec.FirstName = 'John';  // -> fields['FirstName'].value = 'John'
rec.index                // instance property (no field named 'index' normally)
```
Gotcha: a schema field named like an instance member (`index`, `data`, `fields`, `parent`, `isEmpty`, …) shadows the member for *reads and writes*. Use `getFieldByKey`/`getValueByKey` when unsure. Unknown keys read as `null`.

Record members: `index` (row), `isEmpty`, `isChanged` (setting false cascades to all fields), `arraySize` (array tables; auto-grows/shrinks when you write references past/at the boundary — writing all-zero ref at position < arraySize shrinks it, writing a non-zero ref at position ≥ arraySize grows it), `fields` (name→field map), `fieldsArray`, `data`/`hexData` (the row Buffer; setting `data` re-binds every field's BitView without change events), `parent` (table).
Methods: `getFieldByKey(key)`, `getValueByKey(key)`, `getReferenceDataByKey(key)` → `{tableId, rowNumber}|null`, `empty()` (see §2).

### Field (lines 3910-4272)

- `key`, `offset` (its OffsetTableEntry), `parent` (record), `isChanged`, `isReference` (= `offset.isReference`; true when the schema type starts uppercase, contains `[]`, or is `record`, and has no enum).
- `unformattedValue` — a **BitView over the whole record buffer** (`bigEndian = true`); lazily created. Field bits live at bit offset `offset.offset`, width `offset.length`. Setting `unformattedValue` requires a BitView instance and fires a change event; `setUnformattedValueWithoutChangeEvent` skips the event.
- `value` — formatted, lazily parsed and cached. `clearCachedValues()` wipes both caches.
- `referenceData` → `{tableId (15 bits), rowNumber (17 bits)}` read straight off the BitView. Null for non-references.
- `getValueAs(offsetEntry)` — reinterpret the raw bits under a different offset spec.
- `secondTableField` / `thirdTableField` — present when the schema type is `string` / `binaryblob`; the table1 cell then holds a u32BE **byte offset into the table2/table3 section** (`_recordBuffer.readUInt32BE(offset.offset/8)`).

### Formatting rules (get: `_parseFieldValue`, set: `set value`, lines 3980-4270)

| schema type | read (`value`) | write (`value =`) |
| ----------- | -------------- | ----------------- |
| reference (`Team`, `Coach`, `Player[]`, `record`, …) | 32-char binary string, e.g. `'000000010101100' + '00000000000000101'` | must be a 32-char 0/1 string (throws otherwise); tableId bits 0-14, row bits 15-31. All-zero = null ref (`'0'.repeat(32)`) |
| enum | member **name** (string). If the raw bits match no member, returns the raw padded binary string | accepts member name, numeric value, or unformatted binary; invalid → throws unless the input is pure binary digits (then it's written raw — supports empty-record refs) |
| `int` | `getBits(offset,length)` (schema `minValue`/`maxValue` present ⇒ plain unsigned) ; when *no* min/max (int[] array tables): `0` stays `0`, else `raw − 2^(length−1)` | `parseInt(value)`; no-min/max variant writes `value + 2^(length−1)` |
| `s_int` (signed: schema min or max < 0) | `getBits(...) + offset.minValue` | writes `value − offset.minValue` |
| `bool` | last bit of the field (`offset.offset + length − 1`), returns true/false | truthy = `1`/`'true'` |
| `float` | `BitView.getFloat32(offset.offset)` | `setFloat32` |
| `string` | `secondTableField.value` (NUL-trimmed) | `secondTableField.value = value.toString()` |
| `binaryblob` | `thirdTableField.value` (JSON string) | object ⇒ auto `JSON.stringify`; string passed through |

No range clamping is performed on int writes (values beyond the schema max just overflow the bit width) — validate yourself against `offset.minValue`/`maxValue`.

### utilService reference helpers (lines 737-758)

```js
utilService.getReferenceData(binStr)            // {tableId, rowNumber} from 32-char string
utilService.getBinaryReferenceData(tableId,row) // 15-bit + 17-bit binary string
utilService.getReferenceDataFromBuffer(buf)     // from raw 4 bytes
utilService.dec2bin / bin2dec / bin2hex / hex2bin / float2Bin / bin2Float ...
```

---

## 4. Strings (table2) and blobs (table3)

### 4a. FranchiseFileTable2Field — strings

- Created per string field at readRecords time; `index`/`offset`/`rawIndex` = byte offset of this string inside the table2 section; `maxLength` = schema `maxLength` (bytes).
- **Franchise-file strategy (CFB27 uses `M20Table2Strategy` = `FranchiseTable2FieldStrategy`)**:
  - read: `data.slice(index, index + maxLength)`; `value` getter decodes and trims at the first NUL (`.toString().replace(/\0.*$/g,'')`).
  - write (`value = str`): JS-side truncation to `maxLength` **characters**, then `Buffer.alloc(maxLength).write(str, 0, maxLength, 'utf-8')` — fixed-size, zero-padded buffer. **Writes can never change the table2 section length in a franchise save**; multi-byte UTF-8 gets truncated at the byte limit by `buffer.write`.
- FTC files differ (strings unpadded, variable length) — irrelevant for dynasty saves.
- `unformattedValue`/`hexData` = the padded Buffer; `lengthAtLastSave` supports the file-level splice algorithm.
- Layout invariant: each row owns `sum(maxLength of all string columns)` bytes in table2 at `rowIndex * blockSize` (this is what `recalculateStringOffsets` restores after un-emptying).
- Setting a table2 field's `offset` property rewrites the owning table1 cell bits (u32 pointer) and marks the field changed.

### 4b. FranchiseFileTable3Field — binary blobs (ISON/zstd on CFB27)

- Table1 cell = u32BE byte offset into table3; the blob cell layout is `[u16LE compressedSize][compressed bytes][zero padding to maxLength]` (so a cell physically occupies `maxLength + 2` bytes).
- CFB27 strategy = `M26Table3Strategy` = `FranchiseZstdTable3FieldStrategy` (lines 6835-7001):
  - **read**: locate zstd magic `28 B5 2F FD`, take `readUInt16LE(0)` bytes from there, `zlib.zstdDecompressSync(buf, { dictionary })` where dictionary = `data/zstd-dicts/c27/dict.bin` (or `dict-cga.bin` when `tableName === 'CharacterGameplay'`); the decompressed ISON is converted to JSON via `IsonProcessor`; `field.value` returns a **JSON string**.
  - **write**: JSON → ISON → `zlib.zstdCompressSync(isonBuf, { dictionary })`; if the result exceeds `maxLength`, retried at compression level 19 (`ZSTD_c_compressionLevel: 19`); if still too big, the surplus spills into an **overflow record**: the record's `Overflow` reference field is pointed at a fresh row in the same table (taken from `nextRecordToUse`) and the extra bytes are written to that row's table3 cell (`populateOverflowRecord`, lines 3830-3874). Shrinking back clears + empties the overflow row (`clearOverflowRecord`).
  - `strategyContext = {gameYear, gameType, tableName}` is attached per field by `_parseTable3Values`, which is how the c27 dict/lookup get selected.
- Dictionary/lookup fallback: missing `zstd-dicts/<dirKey>/…` falls back to `zstd-dicts/26/dict.bin`; missing interned-strings dir falls back to Madden 25's lookup.
- Older years for contrast: M24/M25 blobs are gzip (`FranchiseTable3FieldStrategy$1`), optionally TDB2-encoded (`readChviRecord`).

### Interned strings (`data/interned-strings/c27/lookup.json`, 1.29 MB)

- Used exclusively by **IsonProcessor** when decoding/encoding ISON blobs (table3). ISON is EA's binary JSON: token bytes `0x0d` header, `0x0f/0x13` object, `0x0e/0x12` array, `0x10` key-value, `0x0b` inline string, **`0x0a` interned string** (an integer id), `0x09` double, `0x03` byte, `0x11` end.
- `lookup.json` maps `id -> string` (e.g. `"0": "slotType"`, `"1": "itemAssetName"`, `"5": "Base"`, `"6": "ThighPad_Regular"` — gear/loadout/appearance vocabulary for CharacterVisuals). The reverse map (lower-cased string → id) is used when re-encoding JSON→ISON, so **any string you write into a visuals blob that exists in the lookup is re-interned automatically; unknown strings are emitted as inline strings**.
- `lookup-cga.json` (471 bytes) is a tiny separate vocabulary used only for the **`CharacterGameplay`** table (`CGA_TABLE_NAME = 'CharacterGameplay'`): animation/stance keys (`wrStance`, `qbThrowStyle`, `bcLocomotionStyle`, …). Same table also gets its own zstd dictionary `dict-cga.bin`. This table appears to be the CFB-specific "gameplay style" blob store; UNVERIFIED whether Madden 26/27 has an equivalent (no `dict-cga.bin` exists outside `c27/`).
- None of this touches table2 strings — plain string fields never use interning.

---

## 5. FranchiseEnum / FranchiseEnumValue

- Obtained from `field.offset.enum`, or `file.schemaList.getEnum(name)` (linear search of parsed enums; gz schemas expose `schemaList.enumMap[name]` too).
- `FranchiseEnum`: `name`, `assetId`, `isRecordPersistent`, `members: FranchiseEnumValue[]`, `_maxLength` (bit width of the member encoding).
  - Enumerate: `theEnum.members.map(m => ({ name: m.name, value: m.value, index: m.index, unformatted: m.unformattedValue }))`.
  - Lookups: `getMemberByName(name)` (returns null if absent), `getMemberByValue(v)` and `getMemberByUnformattedValue(bin)` (**throw** on no match; both prefer members not ending in `_` and skip `First_`/`Last_` sentinels).
- `FranchiseEnumValue`: `name`, `index`, `value` (number; can be negative), `unformattedValue` (binary string; negative values are sign-encoded EA-style: `-1` with length 4 = `1000` — i.e. `1` + `dec2bin(-v-1)` left-padded).
- Reading an enum field yields the member **name**; if raw bits match nothing you get the raw binary string back (some CFB27 enum fields hold out-of-range values on empty rows).

---

## 6. Save flow (`save` / `packFile`, lines 7684-7726 + 5398-5479 + 7940-7966)

```js
await file.save();                    // overwrite opened file, async zlib
await file.save(outPath);             // save-as
await file.save(outPath, { sync: true }); // synchronous deflate + writeFileSync
```

Pipeline:
1. emit `'saving'`.
2. `unpackedFileContents = strategy.file.generateUnpackedContents(tables, unpackedFileContents)` — this is `CommonAlgorithms.save`: sorts tables by index, and for each `isChanged` table pulls `table.hexData` (which runs `updateBuffer()`), splicing it into the old unpacked buffer with offset-difference accounting (`lengthAtLastSave` per table). Unchanged tables are copied byte-identical. Tables never read are passed through untouched.
3. `_packFile`: `zlib.deflate(unpacked, { windowBits: 15 })` (whole DB in one stream).
4. `postPackFile` (`FranchiseFileStrategy.postPackFile`): output = `[first 0x52 bytes of the ORIGINAL packed file] + [new deflate stream] + [original bytes from offset (newLen + 0x52) onward]`, with the 3-byte little-endian compressed length patched at header bytes **0x4a..0x4c**. **No checksum/hash is recomputed anywhere** — the game accepts the patched header (community-proven).
5. write file, emit `'saved'` (or `'save-error'` + reject). Resolves with the string `'saved'`.

### Known pitfalls (all verified in code)

1. **References are written as 32-char binary strings** (`'0'`-and-`'1'`), never numbers. Build them with `utilService.getBinaryReferenceData(tableId,row)` or `table.getBinaryReferenceToRecord(row)`. Writing a non-binary string throws.
2. **Unloaded tables are fine** (their buffers pass through save untouched), but *partially* loaded tables only regenerate what was read: if `readRecords(['A'])` was used and you edit `A`, only changed record row bytes + table2/3 records that were parsed are rebuilt. table2 data for **unread string fields is preserved verbatim** (guard at lines 4690-4699 and the `table2Records.length > 0` check in `getTable2BinaryData`).
3. **table2 string length can never change in a franchise save** — fixed `maxLength` cell, zero padded, silent truncation. Don't expect longer names to fit.
4. **Empty-record traps**: (a) editing only fields outside the first 4 bytes of an empty row without `autoUnempty` leaves it on the free list; (b) `record.empty()` doesn't zero the record body; (c) corrupting the chain (two unreachable heads) crashes the game — `recalculateEmptyRecordReferences()` detects this.
5. `header.nextRecordToUse == header.recordCapacity` ⇒ table full; you cannot add rows past capacity.
6. `getTableByName` returns the first name match — always disambiguate by `header.recordCapacity` (or use `getTableByUniqueId`).
7. `saveOnChange: true` writes to the **currently opened path** on every field change — never enable against the user's real save.
8. Reads of `field.value` on empty rows can return garbage (e.g. string fields of empty rows point at table2 offset from the free-list pointer bytes). Check `record.isEmpty` first; wrap reads in try/catch for schema-mismatch edge cases (the reference tools' `sf()` helper does this).
9. Enum writes of invalid names throw (`Argument is not a valid enum value…`); only pure-binary strings bypass validation.
10. `getReferenceFromAssetId` has the space-padding quirk described in §1 — decode asset references manually.
11. The whole file is held in memory (multiple copies during save); CFB27 saves are large — budget RAM accordingly.
12. UNVERIFIED edge: `postPackFile` length patching uses `substr` on the hex string of the compressed length and assumes it is ≥ 5 hex digits (≥ 0x10000 bytes compressed). True for any real dynasty save.

---

## 7. CFB27 / college-specific summary

- Detection: year 27 + 'C' at 0x22 (or year byte at 0x2b) ⇒ `gameType 'college'`; `file.gameYear === 27`, `file.gameType === 'college'`.
- Strategy: **M26Strategy** (shared with Madden 26/27) — there is no dedicated college strategy class; college-ness only affects (a) schema pick (C-prefixed schema files, `C27_468_2.gz` bundled) and (b) the `c27` data dirs for zstd dicts + interned strings.
- `c27/dict.bin` (13.3 KB) — zstd dictionary for regular table3 blobs (CharacterVisuals-class data).
- `c27/dict-cga.bin` + `c27/lookup-cga.json` — used only for the **CharacterGameplay** table (player animation/stance styles: `wrStance`, `qbThrowStyle`, `dbAndLBStance`, `bcLocomotionStyle`, …). "CGA" = CharacterGameplay. This pairing exists only under `c27`.
- `c27/lookup.json` (1.29 MB) — CFB27 interned-string vocabulary for visuals blobs (equipment/loadout/body-type names).
- FTC common files for year 27 use `M27FTCStrategy` (only relevant if we ever open game-install FTC data, not dynasty saves).
- Bundled `data/schemas/27/` holds both `C27_468_2.gz` and `M27_525_0.gz`; the picker distinguishes them by the `C`/`M` filename prefix + gameType.

---

## 8. Minimal end-to-end recipe for our tool

```js
const path = require('path');
const { FranchiseFile } = require('madden-franchise');

const file = await new Promise((res, rej) => {
  const f = new FranchiseFile(savePath, {
    autoParse: true,
    schemaDirectory: SCHEMA_DIR,
    schemaOverride: { major: 468, minor: 2, gameYear: 27, path: path.join(SCHEMA_DIR, 'C27_468_2.gz') },
  });
  f.on('ready', () => res(f));
  f.on('error', rej);
});

const t = file.tables
  .filter(t => t.name === 'Recruit')          // exact-name matches
  .sort((a, b) => b.header.recordCapacity - a.header.recordCapacity)[0];
await t.readRecords();

for (const rec of t.records) {
  if (rec.isEmpty) continue;                   // skip free-list rows
  const teamRef = rec.getFieldByKey('CommittedTo')?.value;  // 32-char binary or all zeros
  // decode: parseInt(teamRef.slice(0,15),2) / parseInt(teamRef.slice(15),2)
}

rec.SomeIntField = 3;
rec.SomeRefField = someTable.getBinaryReferenceToRecord(rowIdx);
rec.SomeEnumField = 'MemberName';

await file.save(outPath);                      // NEVER omit outPath when the source is the user's real save
```
