# Research Notes: CFB27-Jersey-ReNumber-Tool (deep analysis)

Analyzed: `reference/CFB27-Jersey-ReNumber-Tool` (v1.6, author "Ace" / ArtVsTheWorld, MIT).
Repo files read in full: `jersey-renumber.js`, `lib/openSave.js`, `lib/rules.js`, `lib/numberRules.js`,
`lib/candidateGenerator.js`, `lib/duplicateResolver.js`, `lib/playerFilter.js`, `lib/playerSorter.js`,
`lib/validator.js`, `README.md`, `package.json`.
Library internals verified against the vendored `madden-franchise` (dist/index.cjs) in
`reference/force-commit-recruits/node_modules/madden-franchise`.
Schema facts verified by decompressing the tool's own `engine-data/C27_468_2.gz` (32 MB JSON:
`{meta:{major:468,minor:2,gameYear:27}, schemas:[...], schemaMap:{...}}`) and dumping the `Player`
and `Team` schema entries.

Package: `"type": "module"` (ESM), deps: `@inquirer/prompts ^8.5.2`, `madden-franchise ^4.3.1`,
`prompt-sync ^4.2.0` (unused in main flow). Entry `jersey-renumber.js`, launched via
`CFB27_JerseyRenumberTool.bat`.

---

## 1. Player table

### Identification — by header uniqueId, not by name

```js
// jersey-renumber.js
const PLAYER_TABLE_UID = 1612938518;
const TEAM_TABLE_UID   = 3359508968;
```

```js
// lib/openSave.js
export function tableByUniqueId(file, uniqueId) {
    const table = file.tables.find(t => t.uniqueId === uniqueId || t.header?.uniqueId === uniqueId);
    if (!table) throw new Error(`Table ${uniqueId} not found`);
    return table;
}
export async function readTable(file, uniqueId) {
    const t = tableByUniqueId(file, uniqueId);
    await t.readRecords();
    return t;
}
```

- VERIFIED (grep of madden-franchise dist): `FranchiseFileTable` has **no** top-level `uniqueId`
  getter, so only the `t.header?.uniqueId` branch ever matches. The library itself ships
  `file.getTableByUniqueId(id)` which does exactly `table.header.uniqueId === id`.
- Cross-verified against CFB27-Dynamic-Pipeline-Tool (`io/saveFile.js`):
  `Player -> tableId 4244, uniqueId 1612938518` and `Team -> tableId 6334, uniqueId 3359508968`.
  So the table NAMES are `Player` and `Team`; `uniqueId` is a stable header value hardcoded
  identically by three independent tools (jersey tool, pipeline tool, coaching-carousel tool),
  whereas the sequential `tableId` (used inside 32-bit references) shifts between saves.
  Practical takeaway: uniqueId lookup is a valid, arguably safer alternative to the
  name + largest-recordCapacity heuristic.

### Player fields used (schema-verified types/bit info from C27_468_2)

Player schema has **288 attributes** total. Fields the tool touches:

| Field | Schema def (from C27_468_2 `Player` entry) | Tool usage |
|---|---|---|
| `JerseyNum` | `{"index":"51","type":"int","minValue":"0","maxValue":"127","default":"0"}` | READ + **WRITTEN** (`player.JerseyNum = n`, 0–99 only) |
| `Position` | `{"index":"119","type":"PositionE","default":"PositionE:FirstKeyOffense_"}` — enum `PositionE`, 6 bits (`_maxLength: 6`), assetId 6321119 | READ only, compared as name string (`"QB"`, `"HB"`, ...) |
| `TeamIndex` | `{"index":"252","type":"int","minValue":"0","maxValue":"255","default":"255"}` | READ only; roster grouping key; 255 = unassigned/free agent (it is the schema default) |
| `FirstName` | `{"index":"27","type":"string","maxLength":"17"}` | READ only (logging + placeholder detection) |
| `LastName` | `{"index":"57","type":"string","maxLength":"21"}` | READ only |
| `OverallRating` | `{"index":"81","type":"int","minValue":"0","maxValue":"100","default":"0"}` | READ only (sort tiebreaker, logging) |
| `SchoolYear` | `{"index":"230","type":"SchoolYear","default":"SchoolYear:Freshman"}` — enum, 3 bits | READ only (sort priority) |
| `RedshirtStatus` | `{"index":"222","type":"RedshirtStatus","default":"RedshirtStatus:Eligible"}` — enum, 2 bits | READ only (`=== "Previous"` gives +1 sort priority) |
| `IsNIL` | `{"index":"49","type":"bool","default":"False"}` | READ only; NIL players are never renumbered but DO claim numbers |

Enum member tables (full, from schema):

- `SchoolYear` (3 bits): `Freshman=0, Sophomore=1, Junior=2, Senior=3` (+ markers `First_`,
  `Last_`, `Count_=4`, `Invalid_=5`).
- `RedshirtStatus` (2 bits): `Eligible=0, Ineligible=1, Current=2, Previous=3`.
- `PositionE`: see section 5 (full dump).

### How names are read/written

- `FirstName` / `LastName` are schema type `string` with `maxLength` → stored in **table2**
  (the string blob), NOT inline. VERIFIED in madden-franchise dist: when
  `offset.valueInSecondTable` is true, the field constructor builds a
  `FranchiseFileTable2Field` from a UInt32BE offset read out of the record's inline bytes:

  ```js
  if (offset.valueInSecondTable) {
      this.secondTableField = new FranchiseFileTable2Field(
          this._recordBuffer.readUInt32BE(offset.offset / 8),
          offset.maxLength
      );
  ```

  Reads go through `_parseFieldValue`: `if (offset.valueInSecondTable) return this.secondTableField.value;`
  Writes go through the field's value setter: `if (this.offset.valueInSecondTable) this.secondTableField.value = value.toString();`

- So from tool code, names are plain properties: `player.FirstName`, `player.LastName` —
  the `FranchiseFileRecord` **Proxy** routes them to field values (see section 3). The jersey
  tool only READS names; it never writes them. If we ever write a name, it is length-capped by
  `maxLength` (17 for FirstName, 21 for LastName) and updates the table2 blob transparently.
- These are per-record table2 strings, not an interned/shared string pool from the tool's
  perspective. (UNVERIFIED whether the game dedups identical strings internally; the library
  treats each field's table2 slot independently.)

### Scratch properties tacked onto records

The tool freely stores its own JS-only bookkeeping on the record proxies:
`player.wasRenumberedThisRun`, `player.originalJerseyNum`, `player.mustRenumberDuplicate`,
`player.isPromotionAttempt`. This works because the record Proxy's `set` trap falls through to
the plain object for names that are not schema fields (VERIFIED, see trap code in section 3).
These never touch save data. Handy pattern for our tool (e.g. tagging recruits "decommitted"
in-memory before writing).

---

## 2. Team grouping / team identity

### Team table fields used (schema-verified)

| Field | Schema def (from C27_468_2 `Team` entry, 424 attributes total) | Tool usage |
|---|---|---|
| `TeamIndex` | `{"index":"387","type":"int","minValue":"0","maxValue":"255","default":"255"}` | join key against `Player.TeamIndex` |
| `DisplayName` | `{"index":"72","type":"string","maxLength":"15"}` | team display + sort order |
| `UserCharacter` | `{"index":"413","type":"UserEntity"}` — a **reference** field | user-controlled-team detection |

Other identity fields present on Team (not used by this tool, useful for ours):
`ShortName` (string, maxLength 6), `LongName` (string, maxLength 23), `NickName` (string,
maxLength 18). Prestige-related Team fields spotted in the schema dump (names only, details
unexplored here): `TeamPrestige`, `PrestigeDisplay`, `PrestigeRank`, `TeamPrestigeBias`,
`ConferencePrestigeProgramPoints`, `ProgramPointsConferencePrestigeGrade` — relevant to our
"similar-or-better prestige" follow-coach rule.

### Grouping mechanics (all from jersey-renumber.js)

Players are joined to teams via the plain integer `TeamIndex` — **no binary references needed**:

```js
function getTeamNames(teamTable) {
    const map = new Map();
    for (const team of teamTable.records) map.set(team.TeamIndex, team.DisplayName);
    return map;
}

function getUserControlledTeams(teamTable) {
    const set = new Set();
    for (const team of teamTable.records) if (team.UserCharacter.includes("1")) set.add(team.TeamIndex);
    return set;
}

function getSortedTeamIndexes(teamTable) {
    return [...teamTable.records].sort((a, b) => a.DisplayName.localeCompare(b.DisplayName)).map(team => team.TeamIndex);
}
```

- `UserCharacter` is type `UserEntity` → reads back as the 32-char binary reference string;
  `.includes("1")` = "reference is non-null" = a user controls this team. (Same null-ref
  convention as everywhere else: all-zeros = null.)
- Roster build: pre-seed a `Map(teamIndex -> [])` for every team row, then bucket players:

```js
function buildTeamRosters(players, userControlledTeams, renumberUserTeams, sortedTeamIndexes) {
    const teamRosters = new Map();
    for (const teamIndex of sortedTeamIndexes) teamRosters.set(teamIndex, []);
    for (const player of players.records) {
        if (!shouldProcess(player)) continue;
        if (!renumberUserTeams && userControlledTeams.has(player.TeamIndex)) continue;
        const roster = teamRosters.get(player.TeamIndex);
        if (roster) roster.push(player);   // players with a TeamIndex not present in Team table silently drop out
    }
    return teamRosters;
}
```

- Each roster is then split into offense/defense via hardcoded position sets (OL included in
  OFFENSE for grouping, though OL is never renumbered because of the playerFilter whitelist):

```js
const OFFENSE = new Set(["QB","HB","FB","WR","TE","LT","LG","C","RG","RT"]);
const DEFENSE = new Set(["LE","RE","DT","LOLB","MLB","ROLB","CB","FS","SS"]);
```

Note K/P/LS match neither set and vanish from processing entirely — the README acknowledges a
team can legitimately end up with 3 players sharing a number (offense / defense / specialist).

---

## 3. Row iteration & empty/free-row handling

### Iteration

Plain full-array iteration, no free-list walking:

```js
const players = await readTable(franchise, PLAYER_TABLE_UID);   // await t.readRecords() inside
...
for (const player of players.records) player.wasRenumberedThisRun = false;
const teamRosters = buildTeamRosters(players, ...);
```

### Empty/garbage rows are excluded by VALUE filters, not by `record.isEmpty`

```js
// lib/playerFilter.js — complete file logic
const VALID_POSITIONS = new Set(["QB","HB","FB","WR","TE","LE","RE","DT","LOLB","MLB","ROLB","CB","FS","SS"]);

function isPlaceholderPlayer(player) {
    return player.FirstName === "Omar" && player.LastName === "Omar" && player.Position === "QB" && player.JerseyNum === 0;
}

export function shouldProcess(player) {
    if (player.TeamIndex < 0 || player.TeamIndex === 255) return false;  // free agent / unassigned (255 = schema default)
    if (isPlaceholderPlayer(player)) return false;                       // engine placeholder rows
    if (!VALID_POSITIONS.has(player.Position)) return false;            // OL/K/P/LS etc. excluded by design
    return true;
}
```

Gotchas worth stealing:
- **"Omar Omar"** is the tell-tale identity of placeholder/filler Player rows in CFB27 saves
  (QB, jersey 0). Any tool iterating Player must skip these.
- `TeamIndex === 255` = not on a team (matches the schema default `255`).
- madden-franchise records DO expose an `isEmpty` boolean (VERIFIED in
  `FranchiseFileRecord.d.ts`), but this tool never consults it — it relies purely on value
  filters. The force-commit tool's openSave comment mentions "free-list-safe row iteration",
  and it ships an `sf()` safe accessor; the jersey tool copied `sf` into its openSave.js but
  never calls it in the main flow.

### Record property access mechanics (library-level, VERIFIED)

`FranchiseFileRecord`'s constructor returns a Proxy:

```js
return new Proxy(this, {
    get: function (target, prop) {
        return target.fields[prop] !== undefined
            ? target.fields[prop].value
            : target[prop] !== undefined ? target[prop] : null;
    },
    set: function (target, prop, receiver) {
        if (target.fields[prop] !== undefined) {
            target.fields[prop].value = receiver;   // schema field -> real write
        } else {
            target[prop] = receiver;                // anything else -> plain JS prop
        }
        return true;
    }
});
```

- Reading an unknown prop returns `null` (not `undefined`) — beware `??`/truthiness logic.
- Enum fields read back as the member **name** string; when several enum members share a bit
  value, the library prefers the member whose name does NOT end in `_` (so a TE reads as
  `"TE"`, never `"LastKeyOffense_"` even though both encode `000100`). `First_`/`Last_` are
  excluded outright. VERIFIED in `FranchiseEnum.getMemberByUnformattedValue` /
  `getMemberByValue`.
- Bool fields read back as real `true`/`false` (last bit of the field).
- Enum writes accept the member name (negative-value enums get special binary handling).

### Differences from the force-commit openSave pattern

| Aspect | force-commit (`engine/rg/openSave.js`) | jersey tool (`lib/openSave.js`) |
|---|---|---|
| Module system | CommonJS | ESM (`type: "module"`), interop shim: `const Franchise = maddenPkg.default \|\| maddenPkg;` |
| Open call | `new FranchiseFile(path, { autoParse: true, schemaDirectory, schemaOverride })` + manual `f.on('ready')` / `f.on('error')` promise wrapper | `Franchise.create(savePath, { schemaDirectory: SCHEMA_DIR, schemaOverride: SCHEMA_OVERRIDE() })` |
| autoParse | explicit `true` | omitted — but VERIFIED default is `true` in `FranchiseFileSettings`, and `FranchiseFile.create` internally does exactly the ready/error promise wrapping. **Functionally identical.** |
| Table resolution | by NAME + largest `header.recordCapacity` | by `header.uniqueId` (`Player=1612938518`, `Team=3359508968`) |
| Schema | same: `{ major: 468, minor: 2, gameYear: 27, path: <dir>/C27_468_2.gz }`, dir overridable via `RG_SCHEMA_DIR` env var | same (identical constants, identical env var) |
| Helpers | `parseRef` / `makeRef` / `sf` | identical copies present, but main script imports only `openSave`/`readTable` — refs never used (TeamIndex join makes them unnecessary) |

`FranchiseFile.create` (VERIFIED, dist/index.cjs):

```js
static create(filePath, settings) {
    return new Promise((resolve, reject) => {
        const file = new FranchiseFile(filePath, settings);
        if (file.settings.autoParse) {
            file.on('ready', () => resolve(file));
            file.on('error', (err) => reject(err));
        } else { resolve(file); }
    });
}
```

---

## 4. Write / save / backup flow & validation

Order of operations in `runTool()`:

1. Prompt for save path (validated: exists + is a file; strips surrounding quotes for
   drag-and-drop).
2. Confirm prompts (`continue?`, `renumber user-controlled teams too?`).
3. **Backup BEFORE opening the save with the library**:

```js
function createBackup(savePath) {
    const now = new Date();
    const timestamp = `${MM}${DD}${HH}${mm}`;           // month/day/hour/minute, zero-padded
    const backupPath = `${savePath}b${timestamp}`;       // e.g. "SAVE_FILEb07141459" — same directory
    fs.copyFileSync(savePath, backupPath);
    return backupPath;
}
```

   README/changelog gotcha: if the backup filename gets **too long, the game refuses to load
   it** — they shortened the backup naming twice because of this. Our tool should keep backup
   names short or back up to a subfolder.
4. `openSave(savePath)` → `readTable(franchise, PLAYER_TABLE_UID)` → `readTable(franchise, TEAM_TABLE_UID)`.
5. All mutations are plain in-memory record writes: `player.JerseyNum = number;` (Proxy →
   `field.value` setter → sets bits in the record buffer and marks record changed). Only
   `JerseyNum` is ever written to save data.
6. Validation is **advisory only** and runs BEFORE save, but the save is unconditional —
   validation results never block saving:

```js
const validationResults = validateTeamGroups(teamGroups, teamNames);
printValidationResults(validationResults);
...
console.log("\nSaving dynasty...");
await franchise.save();          // no args -> overwrites the original filePath in place
```

   `franchise.save()` (VERIFIED) = `packFile(outputFilePath, options)`; with no argument the
   destination defaults to `this.filePath`, i.e. in-place overwrite; the whole file is
   regenerated (`generateUnpackedContents(this.tables, ...)`) and re-packed (zstd for CFB27).
   `save(path)` could redirect output — worth using for a write-to-copy workflow.
7. No post-save verification, no dry-run mode.

### Pre-save validation logic

```js
// lib/validator.js — read-only reporting
export function validateRoster(teamName, sideName, roster) {
    let duplicates = 0, suboptimal = 0;
    const grouped = new Map();
    for (const player of roster) { ...group by JerseyNum... }
    for (const [number, players] of grouped)
        if (players.length > 1) { duplicates += players.length - 1; ...log each... }
    for (const player of roster)
        if (getRenumberReason(player).suboptimal) suboptimal++;
    return { duplicates, suboptimal };
}
```

Duplicate detection is per team per side-of-ball; "suboptimal" = jersey not in the position's
preferred+fallback list.

---

## 5. Position enum — FULL PositionE member list (extracted from C27_468_2 schema)

Enum `PositionE`, assetId 6321119, **6-bit** storage. `_value` = numeric enum value,
`_unformattedValue` = raw 6 bits. Members whose names end in `_` are range markers/sentinels
sharing values with real members (the library never returns them on reads when a real name
shares the value). Full dump, in schema `_index` order:

| Name | Value | Bits | Kind |
|---|---|---|---|
| `FirstKeyOffense_` / `FirstNormal_` / `FirstOffense_` / `First_` | 0 | 000000 | markers |
| `QB` | 0 | 000000 | offense |
| `HB` | 1 | 000001 | offense |
| `FB` | 2 | 000010 | offense |
| `WR` | 3 | 000011 | offense |
| `TE` (= `LastKeyOffense_`) | 4 | 000100 | offense |
| `LT` (= `FirstOffenseLine_`) | 5 | 000101 | offense (OL) |
| `LG` | 6 | 000110 | offense (OL) |
| `C` | 7 | 000111 | offense (OL) |
| `RG` | 8 | 001000 | offense (OL) |
| `RT` (= `LastOffenseLine_` = `LastOffense_`) | 9 | 001001 | offense (OL) |
| `LE` (= `FirstDefenseLine_` = `FirstDefense_`) | 10 | 001010 | defense (DL) |
| `RE` | 11 | 001011 | defense (DL) |
| `DT` (= `LastDefenseLine_`) | 12 | 001100 | defense (DL) |
| `LOLB` (= `FirstDefenseLB_` = `FirstDefenseSec_`) | 13 | 001101 | defense (LB) |
| `MLB` | 14 | 001110 | defense (LB) |
| `ROLB` (= `LastDefenseLB_`) | 15 | 001111 | defense (LB) |
| `CB` (= `FirstDefenseDB_`) | 16 | 010000 | defense (DB) |
| `FS` | 17 | 010001 | defense (DB) |
| `SS` (= `LastDefenseDB_` = `LastDefenseSec_` = `LastDefense_`) | 18 | 010010 | defense (DB) |
| `K` (= `FirstKP_`) | 19 | 010011 | special teams |
| `P` (= `LastKP_` = `LastNormalNoLS_`) | 20 | 010100 | special teams |
| `LS` (= `LastNormal_`) | 21 | 010101 | special teams |
| `Count_Normal_` / `MaxNormal_` markers; `KR` (= `FirstKPReturn_` = `FirstSpecial_`) | 22 | 010110 | depth-chart pseudo-position |
| `PR` (= `LastKPReturn_`) | 23 | 010111 | depth-chart pseudo-position |
| `KOS` | 24 | 011000 | pseudo (kickoff specialist) |
| `3DRB` | 25 | 011001 | pseudo (3rd-down RB) |
| `GAD` | 26 | 011010 | pseudo (goal-line/gadget) |
| `PWHB` | 27 | 011011 | pseudo (power HB) |
| `SLWR` | 28 | 011100 | pseudo (slot WR) |
| `RLE` | 29 | 011101 | pseudo (rush LE) |
| `RRE` | 30 | 011110 | pseudo (rush RE) |
| `RDT` | 31 | 011111 | pseudo (rush DT) |
| `NT` | 32 | 100000 | pseudo (nose tackle) |
| `SUBLB` | 33 | 100001 | pseudo (sub LB) |
| `SLCB` (= `LastSpecial_` = `Last_`) | 34 | 100010 | pseudo (slot CB) |
| `HC_CFM` (= `Count_` = `Max_`) | 35 | 100011 | **coach role: Head Coach** |
| `OC_CFM` | 36 | 100100 | **coach role: Offensive Coordinator** |
| `DC_CFM` | 37 | 100101 | **coach role: Defensive Coordinator** |
| `Owner_CFM` | 38 | 100110 | **coach role: Owner** |
| `Invalid_` | 63 | 111111 | sentinel |

Actual on-roster player positions are the 22 base ones: QB HB FB WR TE LT LG C RG RT (offense),
LE RE DT LOLB MLB ROLB CB FS SS (defense), K P LS (special). Values 22–34 are formation/depth
pseudo-positions (UNVERIFIED whether they ever appear in `Player.Position`; the game presumably
uses them in depth-chart contexts). **`HC_CFM`/`OC_CFM`/`DC_CFM`/`Owner_CFM` (35–38) are coach
staff roles living in the same enum** — directly relevant to our carousel logic; the Coach
table's position field presumably uses these names (UNVERIFIED which table/field, but the enum
existence is confirmed).

### Offense/defense classification for our OC/DC decommit logic

The jersey tool's own split (excluding special teams):
- OFFENSE: `QB, HB, FB, WR, TE, LT, LG, C, RG, RT` — enum values 0–9 (contiguous:
  `FirstOffense_`=0 .. `LastOffense_`=9).
- DEFENSE: `LE, RE, DT, LOLB, MLB, ROLB, CB, FS, SS` — enum values 10–18 (contiguous:
  `FirstDefense_`=10 .. `LastDefense_`=18).
- SPECIAL: `K`=19, `P`=20, `LS`=21.

So classification can be done either by name sets or numerically:
`0 <= v <= 9` offense, `10 <= v <= 18` defense, `19 <= v <= 21` special teams. (For recruits,
whichever position field the Recruit/Player rows expose should read back as these same name
strings, since reads return enum member names.) NOTE: if pseudo-positions ever surface
(SLWR/RLE/etc.), classify them explicitly — values 22+ are not covered by the range rule.

---

## 6. Renumbering algorithm details (for completeness; not needed for carousel logic)

- `lib/rules.js`: per-position `preferred` (array of ordered groups), `fallback` (ordered),
  optional `promoteChance` (HB .25, WR .65, TE .15, LE/RE .15, LOLB .30, MLB .35, ROLB .30,
  CB .50, FS .35, SS .25; QB/FB/DT have none). Only the 14 whitelisted positions have rules.
- `lib/numberRules.js`: `isLegalNumber` = jersey ∈ preferred∪fallback (positions without a
  rule are always "legal"); `needsRenumber` mutates `player.isPromotionAttempt` via
  `Math.random() < promoteChance` when the player sits in the secondary preferred group;
  `getRenumberReason` returns `{ suboptimal, promotion, duplicate }`.
- `lib/candidateGenerator.js`: builds ordered candidates; "similar-looking number" bias —
  for a player wearing e.g. #84 it tries digits-derived numbers first
  (`onesDigit, tensDigit, onesDigit+10, tensDigit+10`; DT special-cases into the 90s:
  `90+onesDigit, 90+tensDigit`). Promotion attempts restrict candidates to `preferred[0]` and
  exclude numbers within ±2 of the current one.
- `lib/duplicateResolver.js`: first claimant keeps the number; NIL players always win ties
  (a non-NIL claimant is displaced if an NIL player arrives later); later conflicting players
  get `mustRenumberDuplicate = true`. `isNumberAvailable` = nobody else on that side wears it.
- `lib/playerSorter.js`: pick order = Senior 6 / Junior 4 / Sophomore 2 / Freshman 0,
  `+1` if `RedshirtStatus === "Previous"`, `QB +100`, `FB -100`, tiebreak by `OverallRating`
  descending.
- Main loop: per team, per side — Pass 1 fixes duplicates (with displacement of a blocking
  player allowed, moving the blocker to one of ITS candidates), Pass 2 handles suboptimal +
  promotion moves (no displacement), final absolute fallback scans 0–99 for duplicates that
  still cannot land anywhere.

---

## 7. Gotchas checklist for our CoachCarouselRecruitTool

1. `Player` table uniqueId `1612938518`, `Team` uniqueId `3359508968` — uniqueId lookup is
   stable across saves and simpler than name+capacity heuristics; keep both strategies.
2. Skip rows: `TeamIndex === 255` (or `< 0`) and the "Omar Omar" QB #0 placeholder identity.
3. Enum fields read/write as member NAME strings; range-marker names ending in `_` never come
   back from reads; unknown record props read as `null`, and arbitrary scratch props can be
   stored on record proxies without touching save data.
4. Name strings are table2-backed with hard maxLength (FirstName 17, LastName 21) but fully
   transparent via record property get/set.
5. `UserCharacter` on Team is a reference; `.includes("1")` = user-controlled team.
6. Backup by `fs.copyFileSync` BEFORE opening; keep backup filenames SHORT (game refuses
   long filenames).
7. `franchise.save()` overwrites in place; `franchise.save(otherPath)` writes elsewhere.
8. Offense = PositionE 0–9, Defense = 10–18, Special = 19–21; coach staff roles HC_CFM=35,
   OC_CFM=36, DC_CFM=37, Owner_CFM=38 live in the SAME PositionE enum.
9. Team prestige candidates for the follow-coach rule: `TeamPrestige`, `PrestigeDisplay`,
   `PrestigeRank`, `TeamPrestigeBias` (field details not yet dug into).
