# 01 — CFB27 Dynasty Save File Format & the madden-franchise API

Documentation suite for **CoachCarouselRecruitTool**. Synthesized 2026-07-14 from the project research notes
(`research/notes-*.md`) and the empirical dumps they reference (`research/dumps/*`). Every claim carries a
source tag; claims that were never verified locally keep their **UNVERIFIED** prefix (do not build on those
without testing).

Source tag legend:
- `[api]` = `research/notes-madden-franchise-api.md` (library source verified line-by-line in vendored madden-franchise 4.3.1)
- `[fc]` = `research/notes-force-commit.md` · `[pipe]` = `research/notes-pipeline-tool.md` · `[jersey]` = `research/notes-jersey-tool.md` · `[carousel]` = `research/notes-carousel-tool.md`
- `[web]` = `research/notes-web.md` (web research; sub-tags [CODE]/[2SRC]/[1SRC] preserved)
- `[schema]` = `research/notes-schema.md` (C27_468_2.gz bundle mining)
- `[dump]` = `research/notes-save-dump.md` + `research/dumps/*` · `[diff]` = `research/notes-save-diff.md` + `research/dumps/save-diff.json`

---

## Table of contents

1. [Two container layers at a glance](#1-two-container-layers-at-a-glance)
2. [FBCHUNKS outer container](#2-fbchunks-outer-container)
3. [Compression: what is zlib, what is zstd](#3-compression-what-is-zlib-what-is-zstd)
4. [Opening a save with madden-franchise](#4-opening-a-save-with-madden-franchise)
5. [Table anatomy](#5-table-anatomy)
6. [Reference encoding (15-bit table id + 17-bit row)](#6-reference-encoding-15-bit-table-id--17-bit-row)
7. [Empty rows and the free list](#7-empty-rows-and-the-free-list)
8. [Saving, and everything that can go wrong](#8-saving-and-everything-that-can-go-wrong)
9. [Save discovery and file-level empirical facts](#9-save-discovery-and-file-level-empirical-facts)
10. [Sources](#sources)

---

## 1. Two container layers at a glance

```
DYNASTY-XXXX (9,646,981 bytes, fixed size in this build)          [diff §5]
└─ FBCHUNKS container
   ├─ 0x00..0x51  header (magic, version, DB name, schema version, chunk-1 size)
   ├─ 0x52        CHUNK 1: zlib(deflate) stream = the FrTk franchise DB   } "the save"
   │              └─ FrTk DB: ~2,269 tables found by SPBF/ASTO/SPEX markers
   │                 ├─ per-table: header + table1 (fixed-size records)
   │                 ├─ table2 (fixed-slot string blobs)
   │                 └─ table3 (zstd-compressed ISON binary blobs)
   ├─ zero padding (growth buffer)
   └─ CHUNK 2 (fixed absolute offset): uncompressed FrTk-style CharacterVisuals table  [web §4, 1SRC]
```

- madden-franchise only ever re-writes chunk 1 (plus the 3-byte size field); chunk 2 rides along untouched
  — *if* the recompressed stream still fits (see §8.2). `[api §6][web §1a]`
- All five of the user's real saves are **exactly 9,646,981 bytes** — the container is fixed-size/padded, so
  file size is useless as a change signal. `[diff §5]`

## 2. FBCHUNKS outer container

### 2.1 Header layout (as understood today)

| Offset | Content | Source / status |
|---|---|---|
| 0x00 | magic `"FBCHUNKS"` (8 bytes, latin1) | `[fc §9]` (used as save validator) + `[web §4, 1SRC]` |
| 0x0A-ish | version u16le = 1, timestamp, DB name string `"College-27-RL1-9039126"` | `[web §4, 1SRC]` — brooksg357 docs; not independently byte-verified locally |
| 0x22 | `'C'` (0x43) — how madden-franchise detects gameType `college` | `[api §1]` (verified in library code) |
| 0x2B | `'7'` (0x37) — year-27 indicator (one byte later than Madden because "College" is longer) | `[api §1]` |
| 0x3E (u32le) / 0x42 (u32le) | schema **major=468 / minor=2** as read by madden-franchise `getSchemaMetadata` | `[api §1]` (verified in code; matches C27_468_2) |
| 0x4A (u32le) | compressed size of chunk 1 (library patches only the low 3 bytes 0x4A..0x4C on save) | `[api §6]` + `[web §4, 1SRC]` |
| 0x52 | start of chunk 1 (zlib stream); `COMPRESSED_DATA_OFFSET = 0x52` | `[api §1]` |

**Schema-version discrepancy (open question):** brooksg357's docs claim the FBCHUNKS header carries schema
**809/0** and they extracted their own `CFB27_809_0.gz` from game CAS files, while madden-franchise reads
**468/2** from offsets 0x3E/0x42 and all four reference tools successfully use `C27_468_2.gz`. The two numbers
are presumably different version namespaces (different header offsets); UNVERIFIED which bytes brooksg357 read.
`[web §1b]` Practical consequence: none — 468/2 + C27_468_2.gz is the locally proven combination.

### 2.2 Chunk 2 (CharacterVisuals) — [web §4], all 1SRC unless noted

- Reference save: chunk 2 at absolute offset 0x52D426; an **uncompressed** FrTk-style table holding
  `CharacterVisuals` (fields `Overflow` + `RawData` maxLength 375). `RawData` cells are fixed 377-byte
  "table3" slots: 2 LE bytes of zstd frame length + zstd frame + zero padding; zstd dictionary id `0x65FC508B`.
- **No checksum anywhere; no pointer to chunk 2** — it sits at a fixed absolute offset after the zlib stream.
  This is why a chunk-1 overflow can *silently* clobber it (§8.2).
- Dynasty **autosaves drop the plaintext `"CharacterVisuals"` marker** — locate chunk 2 by stream-end
  detection (first non-zero byte after `0x52 + u32@0x4A`), not by marker scan. RTG saves report 5 chunks and
  need separate handling (out of scope).

### 2.3 FrTk inner DB

- Decompressed chunk 1 starts with magic `FrTk`; big-endian headers; table markers `SPBF`, `ASTO`, `SPEX`
  (brooksg357 also lists `BSFT` `[web §4, 1SRC]`; madden-franchise scans for SPBF/ASTO/SPEX only `[api §1]`).
- madden-franchise finds tables by scanning for the 4-byte markers; table start = marker index − 0x94
  (year ≥ 20); each slice between markers becomes a `FranchiseFileTable`; the last table excludes the trailing
  8 bytes. An **asset table** (offset at `unpacked.readUInt32BE(4)`, count at `readUInt32BE(36)`, entries
  `{assetId: u32BE, reference: u32BE}`) is parsed too. `[api §1]`
- Observed table count: **2,269 in a year-0 dynasty, growing to 2,471/2,472 by year 3** — new tables are
  appended over a dynasty's life (e.g. `Team[]` instances 9 → 10). `[dump §1][diff §5]`

## 3. Compression: what is zlib, what is zstd

This distinction matters; the project's early shorthand "zstd-compressed franchise DB" was imprecise. `[api §1]`

| Layer | Codec | Details | Source |
|---|---|---|---|
| Chunk 1 (whole FrTk DB) | **zlib DEFLATE** | read: `zlib.inflateSync(data.slice(0x52))`; write: `zlib.deflate(data, { windowBits: 15 })` | `[api §1]` verified in code |
| table3 blob cells (CharacterVisuals-class data) | **zstd with dictionary** | `zlib.zstdDecompressSync(buf, { dictionary })`; dict = `data/zstd-dicts/c27/dict.bin` (13,300 B); `CharacterGameplay` table uses its own `c27/dict-cga.bin` + `lookup-cga.json` | `[api §4b]` |
| table2 strings | none (raw, zero-padded fixed slots) | | `[api §4a]` |

- madden-franchise ≥ 4.x has **no zstd npm 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. `[api §0]`
- table3 blob payloads are **ISON** (EA's binary JSON). The `IsonProcessor` converts ISON↔JSON using the
  interned-string vocabulary `data/interned-strings/c27/lookup.json` (1.29 MB; id→string map of
  gear/loadout/appearance vocabulary). Strings you write that exist in the lookup are re-interned
  automatically; unknown strings are emitted inline. Plain table2 string fields never use interning. `[api §4b]`
- Directory-key logic: `dirKey = (gameType === 'college' ? 'c' : '') + gameYear` → CFB27 = `c27`. `[api §0]`

## 4. Opening a save with madden-franchise

### 4.1 Package facts

- Library: `madden-franchise` (bep713). Reference tools pin `^4.2.0`–`^4.3.1`; the vendored analysis copy is
  **4.3.1**. Dual build: `main: dist/index.cjs` + `module: dist/index.mjs`, so **`require()` works from CJS**
  despite the "v4 is ESM" README note. `[api §0]`
- CFB27 support is official: "PC Dynasty file saves from College Football 27+ are supported" `[web §1, CODE]`.
- The package **bundles the CFB27 schema**: `data/schemas/27/C27_468_2.gz` (2,938,595 B gzip'd JSON) — so a
  bare open with zero options works on ≥ 4.3.x. `[api §0][pipe §1]`

### 4.2 The three proven open patterns

```js
// (A) Constructor + events — force-commit-recruits, and our project's established pattern  [fc §1]
const { FranchiseFile } = require('madden-franchise');
const file = await new Promise((resolve, reject) => {
  const f = 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') },
  });
  f.on('ready', () => resolve(f));
  f.on('error', reject);
});

// (B) Promise factory with overrides — KivJoy carousel tool  [carousel §4]
const file = await Franchise.create(filePath, {
  gameTypeOverride: 'college',
  gameYearOverride: 27,
  schemaOverride: { major: 468, minor: 2, gameYear: 27, path: '<...>/C27_468_2.gz' },
});

// (C) Bare — Dynamic Pipeline Tool; works because 4.3.1 bundles C27_468_2.gz  [pipe §1]
const file = await Franchise.create(savePath);
```

`FranchiseFile.create` is a thin wrapper: constructs the object and (when `autoParse`, the default) resolves
on `'ready'` / rejects on `'error'` — functionally identical to (A). Verified in dist source. `[jersey §3][api §1]`

### 4.3 FranchiseFileSettings (all options, with defaults) — [api §1]

| option | type | default | notes |
|---|---|---|---|
| `saveOnChange` | bool | `false` | any field change triggers `packFile()` **overwriting the opened file** — never enable on a user's real save |
| `schemaOverride` | `{major, minor, gameYear, path}` \| false | `false` | with `path` set, the schema picker is bypassed entirely |
| `schemaDirectory` | string \| false | `false` | extra dir searched **before** bundled `data/schemas` |
| `autoParse` | bool | `true` | |
| `autoUnempty` | bool | `false` | see §7; "may have unintended side-effects if you batch import" |
| `useNewSchemaGeneration` | bool | `false` | schema-building only |
| `schemaFileMap` / `extraSchemas` | object / array | `{}` / undef | schema-gen inputs |
| `gameYearOverride` | number | `null` | force year (FTC files etc.) |
| `gameTypeOverride` | `'madden'\|'college'` | `null` | force game type |

### 4.4 parse() internals (what happens on open) — [api §1]

1. Strategy pick: CFB27 (franchise, college, 27) resolves to **M26Strategy** — there is no dedicated college
   strategy; college-ness only affects schema pick (C-prefix) and the `c27` data dirs.
2. Schema load: `schemaOverride.path` if given, else picker (regex `/(M|C)(\d+)_(\d+)_(\d+)/` on filenames,
   filter by gameYear+gameType, exact major / closest minor). `schemaDirectory` searched first.
3. Buffer scan for table markers (§2.3); each table gets its schema by name via `schemaList.getSchema(table.name)`.
   **If the schema's attribute count mismatches `header.numMembers` the schema is silently dropped** and
   readRecords later falls back to generic all-int `Field_0..n` columns — *never write through a generic schema*.
4. Emits `'ready'` / `'error'`.

Key `FranchiseFile` members: `tables`, `assetTable`, `schemaList`, `expectedSchemaVersion`, `gameYear` (27),
`gameType` (`'college'`), `filePath` (settable = default save destination), `rawContents` / `unpackedFileContents`,
`strategy` (`name === 'M26Strategy'`). Events: `ready`, `error`, `change` (per changed table), `saving`,
`saved`, `save-error`. `[api §1]`

### 4.5 Table lookup — resolve by uniqueId

- `getTableByName(name)` returns the **first** match; CFB27 saves contain **9 tables named `Team`, 2 named
  `Coach`, 179 named `Player[]`, 2 named `ProspectTargetSchool`** — name lookup hits decoys (the pipeline
  tool's famous "9/143 Team records" bug). `[pipe §1][dump §15][diff §5]`
- `getTableByUniqueId(id)` matches `table.header.uniqueId` — stable across saves of this build and the
  README-documented "best way". Note (verified): it returns `undefined` on a miss, it does **not** throw —
  null-check it yourself. `[pipe §1][api §1]`
- Fallback heuristic (also proven): `file.tables.filter(t => t.name === name)` and pick largest
  `header.recordCapacity`. Would have picked correctly in every ambiguous case observed. `[fc §1][diff §5]`
- `getTableById(id)` matches the per-build numeric `tableId` — do not persist these across builds (they did
  stay stable across all five saves of this build/dynasty `[diff §5]`, but cross-build stability is UNVERIFIED).

## 5. Table anatomy

### 5.1 Header fields you will actually use — [api §2]

| Field | Meaning |
|---|---|
| `name` | table name (array tables end `[]`) |
| `tableId` (u32 @0x80) | per-build numeric id; embedded in references |
| `uniqueId` / `tablePad1` (u32 @0x84) | schema-stable id — the resolver key |
| `recordCapacity` | max rows (physical); tables cannot grow past it |
| `data1RecordCount` | rows physically present = `records.length` |
| `numMembers` | column count |
| `nextRecordToUse` | head of the empty-record free list (== recordCapacity when full/no empties) |
| `record1Size` | bytes per row (4-byte aligned) |
| `table1StartIndex` / `table2StartIndex` / `table3StartIndex` | section offsets |
| `hasSecondTable` / `hasThirdTable`, `table2Length` / `table3Length` | string/blob sections present |

### 5.2 readRecords and the offset table — [api §2]

```js
await table.readRecords();                 // all fields
await table.readRecords(['FirstName']);    // partial read: cheaper for wide tables (Player = 16,500 × 282!)
```

- Builds `table.offsetTable`; each entry: `{index, name, type, isReference, valueInSecondTable (string),
  valueInThirdTable (binaryblob), isSigned, minValue, maxValue, maxLength, enum, offset (bit offset in
  record), length (bit width = distance to next offset, capped 32)}`.
- Array tables (`Foo[]`) synthesize 32-bit ref columns `Foo0, Foo1, …` (`record1Size/4` of them) and read
  per-row `arraySizes` from words after the header.
- Idempotent; partial reads only add newly requested offsets. madden-franchise tolerates unknown names in the
  projection list (the KivJoy tool asks for a nonexistent `Conference` field on Team without error). `[carousel §1e]`
- **Physical bit width vs schema-derived width**: the offset table's `length` is *physical* (distance to next
  field). It can exceed the range-implied width — e.g. `StaffPersonContractOffer.OfferIndex` has schema range
  0–6 (3 bits would do) but physically occupies 17 bits in the live save. Use the live offsetTable for exact
  packing, the schema for value ranges. `[schema §1][dump §10]`
- Performance: full-table inventory of all 2,269 tables ≈ 15 s; `Player.readRecords()` all-fields is the slow
  path (tens of seconds) — use field projections. `[dump §15]`

### 5.3 Records are Proxies — [api §3][jersey §3]

```js
rec.FirstName            // -> fields['FirstName'].value (formatted)
rec.FirstName = 'John';  // -> field value setter (marks record + table changed)
rec.index                // instance property
rec.someUnknownKey       // -> null   (NOT undefined — beware ?? / truthiness)
rec.myScratchFlag = 1;   // non-schema keys become plain JS props — safe in-memory tagging  [jersey §1]
```

- Gotcha: a schema field named like an instance member (`index`, `data`, `fields`, `parent`, `isEmpty`)
  shadows it — use `getFieldByKey`/`getValueByKey` when unsure.
- Record members: `index`, `isEmpty`, `isChanged`, `arraySize` (array rows; auto-grows when you write a
  non-null ref at the boundary, shrinks when you null a ref below it), `fields`, `fieldsArray`, `data`/`hexData`.
- Methods: `getFieldByKey(k)`, `getValueByKey(k)`, `getReferenceDataByKey(k)` → `{tableId, rowNumber}|null`, `empty()`.

### 5.4 Field formatting rules (read/write by schema type) — [api §3]

| schema type | read (`field.value`) | write (`field.value =`) |
|---|---|---|
| reference | 32-char `'0'/'1'` string | must be a 32-char binary string (throws otherwise); all-zeros = null |
| enum | member **name** string; raw padded binary string if bits match no member | member name, numeric value, or raw binary; invalid names throw |
| `int` | plain unsigned when schema min/max present; no-min/max (int[]) variant is offset by `2^(len-1)` | `parseInt`; **no clamping** — out-of-range overflows the bit width silently |
| `s_int` (min<0) | `raw + minValue` | `value − minValue` |
| `bool` | true/false (last bit of the field) | truthy |
| `float` | `getFloat32` | `setFloat32` |
| `string` | table2 field value (NUL-trimmed) | table2 write, silently truncated to `maxLength` |
| `binaryblob` | JSON string (decoded ISON) | object auto-`JSON.stringify`d, or string passed through |

Enum alias trap: when multiple enum members share a raw value the library returns the first non-`_`-suffixed
member, which may be an alias — observed reads: `'First_Active'` for Signed(0), `'Last_Pending'` for
PendingHire, `'NumCollegeCoaches'` for SpecialTeams(3), `'First_'`/`'Top10'` interchange. **Compare enums on
raw value sets, never single-name string equality.** `[dump §15][schema §14][jersey §3]`

Int fields with a positive `minValue` (e.g. `Coach.AlmaMater`, schema range 1100–1300 in 8 bits): the library
applies no offset for min > 0, so reads return the raw stored value (observed 88, 42, 67, 103). UNVERIFIED
whether EA semantics are `raw + minValue`; treat these reads as raw. `[schema §5][dump §5]`

### 5.5 table2 strings — [api §4a]

- A `string` field's table1 cell holds a u32BE **byte offset into the table2 section**; the field object is a
  `FranchiseFileTable2Field` with `maxLength` from the schema.
- Franchise-save strategy: fixed-size zero-padded slots. Writes truncate to `maxLength` and **can never change
  the table2 section length**. Layout invariant: each row owns `sum(maxLength of all string columns)` bytes at
  `rowIndex * blockSize` (this is what `recalculateStringOffsets` restores after un-emptying).
- Player-table slot layout (reference save): 138-byte slots — +0 FirstName (17 B), +17 GenericHeadAssetName
  (33 B), +50 LastName (21 B), +71 AssetName (41 B), +112 HomeTown (26 B). `[web §4, 1SRC]`
- These are per-record slots, not an interned pool. UNVERIFIED whether the game dedups identical strings
  internally. `[jersey §1]`

### 5.6 table3 blobs — [api §4b]

- table1 cell = u32BE offset into table3; cell layout `[u16LE compressedSize][zstd frame][zero pad to maxLength]`
  (cell occupies `maxLength + 2` bytes).
- Write path: JSON → ISON → zstd(dictionary); if too big, retried at compression level 19; if still too big
  the surplus spills into an **overflow record** — the record's `Overflow` ref field is pointed at a fresh row
  (taken from `nextRecordToUse`) in the same table. Shrinking clears + empties the overflow row.

## 6. Reference encoding (15-bit table id + 17-bit row)

- A reference is a 32-bit value: **first 15 bits = tableId, last 17 bits = row index**; equivalently
  `(tableId << 17) | rowIndex`. In the API it always surfaces as a 32-char `'0'/'1'` string. All-zeros = null.
  `[fc §1][api §3]` (independently corroborated `[web §4]`)

```js
// decode                                                     [fc §1]
function parseRef(bin) {
  if (typeof bin !== 'string' || bin.length < 32 || !/[1-9]/.test(bin)) return null;
  return { tableId: parseInt(bin.slice(0, 15), 2), row: parseInt(bin.slice(15), 2) };
}
// encode — prefer the library helpers over hand-rolling:
table.getBinaryReferenceToRecord(rowIndex)          // uses the table's own header.tableId  [carousel §6]
utilService.getBinaryReferenceData(tableId, row)    // [api §3]
field.referenceData                                 // -> {tableId, rowNumber} without bit math  [pipe §1]
record.getReferenceDataByKey('Team')                // same, by key  [api §3]
file.getReferencedRecord(binStr)                    // -> the target FranchiseFileRecord (target table must be readRecords()'d first)  [api §1]
```

- Validate a decoded ref's `tableId` against a **freshly resolved** target table's `header.tableId`, never a
  hardcoded constant (tableIds are per-build). `[pipe §1]`
- Empty-ref constant: `'0'.repeat(32)`. KivJoy's stricter normalizer treats any non-`/^[01]{32}$/` value as
  null. `[carousel §6]`
- **Refs with tableId ≥ ~16000 do not resolve** via `getTableById` (e.g. `Coach.OffensiveScheme` → 16433):
  they point into table2/blob space, not row tables. Treat unresolvable tableIds as opaque. `[dump §15]`
- `getReferenceFromAssetId(assetId)` has a verified bug: it pads the binary string with **spaces** not zeros
  (`padStart(32)` missing the `'0'`), so short references can decode wrong — decode `assetEntry.reference`
  yourself (`tableId = ref >>> 17; row = ref & 0x1FFFF`). `[api §1]`
- `getReferencesToRecord(tableId, recordIndex)` does a brute-force scan for inbound references — slow but
  useful for one-off audits ("who points at this coach"). `[api §1]`

## 7. Empty rows and the free list

- Empty rows form a linked **free list**: 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` is a
  `Map<row, {previous, next}>`. `[api §2]` (corroborated by README `[web §1, CODE]`)
- Live observation: in a fresh dynasty the Recruit free list is the contiguous tail (4101→4102→…); after a
  season of churn it is **scattered mid-table** (head 488 → 4620 → 400 → 1267 → 5765 → …). Never assume
  empties are the tail. `[dump §2, dumps/QQ/scan.json, dumps/QQ2/scan.json]`
- **Freed rows retain stale data** except their first 32 bits (overwritten by the chain pointer). Fields of
  an empty record still "parse" (garbage/defaults) — `record.isEmpty` is the only reliable flag. This is
  occasionally useful (stale JobOpening rows still carry `Reason` histograms `[diff §2.4]`) but the first
  field is always garbage.
- Row-0 sentinels (all-zero refs) exist in `Recruit`, `StaffPersonContractOffer`,
  `CoachTransactionHistoryEntry` — skip row 0 in scans. `[dump §15]`
- Un-empty traps (verified in code, `[api §2]`):
  - `autoUnempty: false` (default): writing a field un-empties the row **only if the changed field overlaps
    the first 32 bits**. Changing only later fields leaves the row chained as free — the game may overwrite it.
  - `autoUnempty: true`: any write un-empties; first 4 bytes zeroed for you; chain repaired; table2/table3
    pointers recalculated to the row's canonical slots.
  - `record.empty()` appends to the free-list tail but **does NOT zero the record body** (the `fill(0)` is
    commented out in the library) — zero bytes 4..record1Size yourself if you want EA-clean empties.
  - Corrupting the chain (two unreachable heads) is a game-crash state; `recalculateEmptyRecordReferences()`
    detects (warns) but does not fix the header in that case.
- **Adding a row** = write into `records[header.nextRecordToUse]` (must be < `recordCapacity`); the un-empty
  machinery advances the list. You cannot grow a table beyond `recordCapacity`. `[api §2]`
- brooksg357's `openCollegeSave(path, { autoUnempty: true })` is their standard row-allocation mode. `[web §4, 1SRC]`

## 8. Saving, and everything that can go wrong

### 8.1 Save pipeline — [api §6]

```js
await file.save();                        // overwrite the opened file
await file.save(outPath);                 // save-as (ALWAYS use this against user saves)
await file.save(outPath, { sync: true }); // synchronous deflate + writeFileSync
```

1. emit `'saving'`.
2. Regenerate the unpacked buffer: for each `isChanged` table splice `table.hexData` (runs `updateBuffer()`:
   changed record rows re-spliced, table2/table3 sections regenerated, header lengths patched, array sizes
   rewritten). Unchanged/never-read tables pass through byte-identical.
3. `zlib.deflate(unpacked, { windowBits: 15 })` — the whole DB in one stream.
4. `postPackFile`: output = `[first 0x52 bytes of the ORIGINAL file] + [new deflate stream] + [original bytes
   from offset (newLen + 0x52) onward]`, patching the 3-byte little-endian compressed length at **0x4A..0x4C**.
   **No checksum is recomputed anywhere** — the game accepts the patched header (community-proven).
5. write, emit `'saved'` (resolves `'saved'`) or `'save-error'`.

### 8.2 THE open risk: chunk-2 clobbering — [web §1a]

- **[1SRC]** brooksg357: "Never call stock `file.save()` on a CFB27 save — corruption." Mechanism: Node zlib
  can produce a *larger* stream than EA's (measured 5,441,483 B vs EA 5,401,081 B), overflowing chunk 1's
  padded slot and silently clobbering the head of chunk 2 (CharacterVisuals). No pointer, no checksum ⇒ silent.
  Their fix: recompress with libdeflate level 12 (4,769,145 B, always fits), patch 0x4A, keep chunk 2
  byte-identical, hard-fail on overflow, refuse in-place writes.
- **Tension**: all four local reference tools + RO27 + cfb-offline use stock `save()` and users report working
  saves; the pipeline tool's written save was diffed locally and round-tripped cleanly with the game loading
  it fine. `[diff §4]` So the risk is **latent and data-dependent** — it materializes only when the
  recompressed stream outgrows the slot.
- **ACTION for our tool** (UNVERIFIED until tested): after `save(outPath)`, verify chunk-2 integrity — locate
  the first non-zero byte after `0x52 + u32@0x4A` in both files and byte-compare the tail region; if it ever
  moves/changes, adopt a libdeflate re-wrap. `[web §1a]`

### 8.3 Write pitfalls checklist (all verified in code unless tagged) — [api §6]

1. References must be written as 32-char binary strings — never numbers.
2. Partially-read tables regenerate only what was read; table2 data of **unread** string fields is preserved
   verbatim (safe).
3. table2 string length can never change — silent truncation at `maxLength`.
4. Empty-record traps (§7): un-empty rules, non-zeroed `empty()`, chain corruption.
5. `nextRecordToUse == recordCapacity` ⇒ table full.
6. Name-lookup decoys — resolve by uniqueId.
7. `saveOnChange: true` writes to the opened path on every field change.
8. Reads on empty rows return garbage — gate on `record.isEmpty`; wrap risky reads in try/catch (the `sf()`
   helper pattern: `const sf = (rec, f) => { try { return rec[f]; } catch { return undefined; } }`). `[fc §1]`
9. Invalid enum-name writes throw; only pure-binary strings bypass validation.
10. `getReferenceFromAssetId` space-padding bug (§6).
11. Whole file held in memory (multiple copies during save) — budget RAM.
12. UNVERIFIED edge: `postPackFile` assumes the compressed length is ≥ 5 hex digits (≥ 0x10000 B) — true for
    any real dynasty save.
13. No range clamping on int writes — validate against `offset.minValue/maxValue` yourself.
14. Array-table writes must keep three things in sync: slot values, `record.arraySize`, and
    `table.arraySizes[row]`, plus `isChanged` on record and table. Pad unused slots with the 32-zero string.
    `[carousel §3.4]`

## 9. Save discovery and file-level empirical facts

- **Save validator**: first 8 bytes equal `"FBCHUNKS"` (latin1). force-commit's picker offers a file iff:
  name starts with `dynasty` (case-insensitive), contains **no dot**, doesn't end `-autosave`, is a regular
  file, and passes the magic check. Candidate dirs: `%USERPROFILE%\Documents` plus every
  `%USERPROFILE%\OneDrive*\Documents`, then `EA SPORTS College Football*/saves`. `[fc §9]`
- Saves dir on this machine is OneDrive-redirected: `C:/Users/rvanv/OneDrive/Documents/EA SPORTS College
  Football 27/saves` (the KivJoy tool's homedir-based default misses it). `[carousel §4]`
- **Byte diffs are useless**: two logically identical saves (QQ vs its autosave) differ in 5,500,054 bytes —
  compression is nondeterministic. Always diff logically (parse both, compare formatted values). `[diff §5]`
- Autosaves are logical clones of the same moment — either can be read. `[diff §5]`
- Backup naming gotcha: the game **refuses to load saves with too-long filenames** — the jersey tool shortened
  its backup scheme twice because of this. Keep backups short or put them in a subfolder. `[jersey §4][web §2]`
- Observed backup conventions: `<save>_backup_YYYY-MM-DD_HH-mm-ss` (KivJoy), `<save>.backup-<epoch-ms>`
  (force-commit), `<save>bMMDDHHmm` (jersey), `RecruitOverhaulBackups/<name>.backup-<ts>` (RO27). `[carousel §4][fc §8][jersey §4][web §3]`

## Sources

| Tag | File |
|---|---|
| [api] | `research/notes-madden-franchise-api.md` |
| [fc] | `research/notes-force-commit.md` |
| [pipe] | `research/notes-pipeline-tool.md` |
| [jersey] | `research/notes-jersey-tool.md` |
| [carousel] | `research/notes-carousel-tool.md` |
| [web] | `research/notes-web.md` |
| [schema] | `research/notes-schema.md` (+ `research/schema-relevant-tables.json`, `research/schema-all-tables.txt`) |
| [dump] | `research/notes-save-dump.md` + `research/dumps/tables-inventory.json`, `research/dumps/QQ*/**.sample.json`, `research/dumps/*/scan.json` |
| [diff] | `research/notes-save-diff.md` + `research/dumps/save-diff.json` |
