# CFB27-Dynamic-Pipeline-Tool — Deep Analysis Notes

Source analyzed: `E:/Games/EA SPORTS College Football 27.SteamGG.NET/Mods/CoachCarouselRecruitTool/reference/CFB27-Dynamic-Pipeline-Tool`
Analysis date: 2026-07-14. All code excerpts are verbatim from the repo.

The tool is an Electron GUI that recomputes each school's 10 recruiting-pipeline
regions/tiers each preseason and writes them back into a **copy** of the dynasty
save. Its README states the whole flow was validated end-to-end against a real
dynasty (write persists, survives a season + preseason transition, produces a
measurable recruiting effect in-game).

---

## 1. Save open / parse / save — library and schema

### Library

`package.json` dependency: `"madden-franchise": "^4.3.1"` (package-lock resolves
exactly **4.3.1**). Same library as all other reference tools — consistent with
ESTABLISHED FACTS.

### Open pattern (simpler than our established one)

`io/saveFile.js`:

```js
const Franchise = require('madden-franchise');
async function openSave(savePath) {
  return Franchise.create(savePath);
}
```

That's it — **no `schemaDirectory`, no `schemaOverride`, no options object at
all.** Verified against the vendored madden-franchise 4.3.1 (in
`reference/force-commit-recruits/node_modules/madden-franchise`):

- `FranchiseFile.create(filePath, settings)` is a static promise wrapper: it
  constructs `new FranchiseFile(filePath, settings)` and, because
  `autoParse` defaults to true, resolves on the `'ready'` event and rejects on
  `'error'` (dist/index.cjs line ~7471). So `await Franchise.create(path)` is
  equivalent to the ESTABLISHED-FACTS event-listener pattern.
- madden-franchise 4.3.1 **bundles the CFB27 schema itself**:
  `data/schemas/27/C27_468_2.gz` (and `M27_525_0.gz` for Madden 27) ship inside
  the npm package. Schema auto-detection therefore works with zero options for
  a CFB27 save.

**Comparison with ESTABLISHED FACTS:** the explicit
`schemaDirectory`/`schemaOverride` open pattern is *not required* when using
madden-franchise >= 4.3.1, because C27_468_2 is bundled. The override pattern
remains a valid belt-and-braces choice (and is what force-commit-recruits
does), but this tool proves plain `Franchise.create(savePath)` parses a CFB27
dynasty save fine. Schema in play is the same: **C27 major 468 minor 2,
gameYear 27**.

### Table lookup: by uniqueId, NOT by name, NOT by numeric tableId

This is the tool's "hard-won discovery" and it *contradicts/refines* the
ESTABLISHED-FACTS name-based approach. Header comment of `io/saveFile.js`:

```
Tables are looked up by UNIQUE ID, never by name and never by numeric
table ID. Name-based lookup is unreliable in this schema (proved early
in this project -- Team in particular only found 9/143 records by name,
but all 143 by ID). Numeric table ID is NOT safe either ... that number
is assigned per game build and can shift on a patch ... Unique ID (found
in a table's header, distinct from its table ID) is the community-
recommended stable identifier ...
```

Root cause of the "9/143 by name" failure (verified in madden-franchise
source): `getTableByName(name)` uses `Array.find()` and returns the **first**
table with that name; the CFB27 file contains **multiple tables named `Team`**
and the first one is a small one. The ESTABLISHED-FACTS mitigation
("filter by name, pick largest `header.recordCapacity`") addresses the same
problem a different way. The uniqueId approach is stronger: it also survives
game-patch renumbering of tableIds.

```js
const TABLE_UNIQUE_IDS = {
  team: 3359508968,
  schoolPipelineInfluenceList: 3284177001,
  schoolPipelineInfluence: 4261714800,
  player: 1612938518,
  coach: 1860529246,
  franchise: 2226370608,
  seasonInfo: 3123991521,
};
// usage:
const table = franchise.getTableByUniqueId(TABLE_UNIQUE_IDS.team);
await table.readRecords();          // or readRecords(['LeagueID']) for one field
```

TableId ↔ uniqueId pairs confirmed by the author against a real 2026-07-build
save (tableIds are per-build and may shift; uniqueIds are stable):

| Table | tableId (2026-07 build) | uniqueId |
|---|---|---|
| Team | 6334 | 3359508968 |
| SchoolPipelineInfluence[] (list) | 5919 | 3284177001 |
| SchoolPipelineInfluence | 4306 | 4261714800 |
| Player | 4244 | 1612938518 |
| Coach | 4173 | 1860529246 |
| Franchise | 4553 | 2226370608 |
| SeasonInfo | 4141 | 3123991521 |

GOTCHA (verified in library source): `getTableByUniqueId()` does **not**
literally throw on a miss — it's `this.tables.find(...)` and returns
`undefined`; the saveFile.js comment saying it "throws" is only true indirectly
because the next `undefined.readRecords()` blows up with a TypeError. If you
adopt this pattern, add an explicit null-check with a clear error message.

GOTCHA: `readRecords()` optionally takes an array of field names to load only
those fields (`await table.readRecords(['LeagueID'])`) — cheaper for
single-field reads like dynasty code / season year.

### Reference-field resolution: `.referenceData`, no manual bit math

Instead of hand-decoding the 32-bit binary reference strings (15-bit tableId +
17-bit row — the ESTABLISHED-FACTS encoding), the tool uses the field's
built-in getter:

```js
const listField = teamRecord.getFieldByKey('SchoolPipelineInfluenceList');
const listRef = listField.referenceData; // { tableId, rowNumber } or null
```

Verified in madden-franchise source: `get referenceData()` returns
`utilService.getReferenceDataFromBitview(...)` when `offset.isReference`, else
`null`. The tool validates `listRef.tableId` against the *freshly resolved*
target table's own `header.tableId` (never a hardcoded constant), since
reference pointers embed the per-build numeric tableId:

```js
const listTableId = listTable.header.tableId;   // resolved via uniqueId lookup
if (!listRef || listRef.tableId !== listTableId) continue;
```

This is the recommended pattern for our tool too: resolve tables by uniqueId,
then compare reference `tableId`s against `table.header.tableId` at runtime.

### Save/write pattern — always to a new copy

`writeUpdatedSave(savePath, updatesByRow4306, outputDir)` in `io/saveFile.js`:

```js
fs.mkdirSync(outputDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const base = path.basename(savePath);
const outputPath = path.join(outputDir, `${base}-PIPELINES-${timestamp}`);

fs.copyFileSync(savePath, outputPath);

const franchise = await Franchise.create(outputPath);
const pipelineInfluenceTable = franchise.getTableByUniqueId(TABLE_UNIQUE_IDS.schoolPipelineInfluence);
await pipelineInfluenceTable.readRecords();

for (const [rowStr, update] of Object.entries(updatesByRow4306)) {
  const record = pipelineInfluenceTable.records[Number(rowStr)];
  if (!record) continue;
  record.InfluenceLevel = update.InfluenceLevel;   // assign formatted enum string
  record.Pipeline      = update.Pipeline;          // assign formatted enum string
  record.InfluenceValue = update.InfluenceValue;   // assign number
}
await franchise.save();
```

Key points:

- **Never writes in place.** The original is `copyFileSync`'d to the output
  dir first; a *fresh* Franchise instance is opened on the copy; edits and
  `franchise.save()` happen only on the copy. The original path is never
  opened by a Franchise instance that calls `.save()`.
- Record mutation is plain property assignment on the record proxy
  (`record.FieldName = value`), including enum fields assigned their
  **formatted string value** (e.g. `'HouseholdName'`). `franchise.save()`
  with no argument saves to the file it was opened from (the copy).
- **Post-write verification** (added v0.5.0): re-open the written copy as a
  completely separate `Franchise.create(outputPath)` read, re-read the table,
  compare every intended field value; returns
  `{ outputPath, verified: bool, verificationError: string|null }`. A cheap,
  high-value pattern our tool should copy for its decommit/follow writes.

---

## 2. Tables and fields read/written

### Read

**Franchise** (uniqueId 2226370608)
- `LeagueID` — numeric, stable per dynasty. Used as the "dynasty code" that
  keys local history (`String(records[0].LeagueID)`). Confirmed by the author
  against a real save. THIS IS THE KEY FIELD for cross-save dynasty identity —
  our carousel tracker should key its DB by this too.

**SeasonInfo** (uniqueId 3123991521)
- `CurrentSeasonYear` — the displayed calendar year of the current season.
  Author confirmed it equals `BaseCalendarYear + CurrentYear` (two more
  SeasonInfo fields that exist but are not read by this tool).

**Team** (uniqueId 3359508968) — 143 rows: 138 real FBS teams + 5 placeholder rows
- `DisplayName` — string; blank on placeholder rows.
- `TeamIndex` — int; **255 = placeholder sentinel**. Real teams filtered with
  `if (!teamRecord.DisplayName || teamRecord.TeamIndex === 255) continue;`
- `SchoolPipelineInfluenceList` — **reference field** → one row of the
  SchoolPipelineInfluence[] list table.

**SchoolPipelineInfluence[]** (array/list table, uniqueId 3284177001)
- Fields `SchoolPipelineInfluence0` … `SchoolPipelineInfluence9` — ten
  **reference fields**, each → one row of the SchoolPipelineInfluence table.
  So: Team → (1 ref) → list row → (10 refs) → 10 pipeline rows. Rows are
  fetched via `listRecord.getFieldByKey(`SchoolPipelineInfluence${i}`).referenceData`.

**SchoolPipelineInfluence** (uniqueId 4261714800) — one row per (team, slot)
- `InfluenceLevel` — enum, formatted as one of the 6 tier names (below).
- `Pipeline` — enum, formatted as one of the 43 region names (below).
- `InfluenceValue` — number, observed 0–1000 scale.
- Placeholder-slot noise pattern: `InfluenceLevel === 'Unrecognized' &&
  InfluenceValue === 0` rows are stripped before use.

**Player** (uniqueId 1612938518)
- `TeamIndex` — int, groups players onto teams (matches Team.TeamIndex).
- `HomePipeline` — enum: the player's home pipeline **region** (same 43-value
  region enum as SchoolPipelineInfluence.Pipeline). This is how pipelines are
  encoded on players: a region enum, not a state code.
- `PLYR_HOME_STATE` — enum: home *state* (finer-grained than pipeline).
  Values are US state names without spaces plus `NonUS` (see
  `data/stateToPipeline.json` key list: `Texas, Georgia, ..., NewYork,
  NewJersey, RhodeIsland, ..., NonUS`). Read but not used by the engine math;
  the state→region mapping file is used by the map renderer.
  UNVERIFIED: exact enum spelling of every state value (inferred from the
  mapping file's keys, which the author derived from real save data).
- `ProspectStarRating` — enum with formatted values `ONE_STAR`, `TWO_STAR`,
  `THREE_STAR`, `FOUR_STAR`, `FIVE_STAR` (the engine's STAR_WEIGHT map keys
  off exactly these strings).
- Note: `readPlayers()` does **not** filter placeholder/empty player rows; it
  groups every row by TeamIndex and irrelevant groups just never get looked up.

**Coach** (uniqueId 1860529246)
- `Position` — enum; relevant formatted values `'HeadCoach'`,
  `'OffensiveCoordinator'`, `'DefensiveCoordinator'` (other positions exist
  and are skipped). Exactly the three roles our carousel tool cares about.
- `TeamIndex` — int, links coach to team.
- `PrimaryPipeline` — enum (same region enum): the coach's own recruiting
  pipeline. **Directly relevant to us**: a coach carries a personal pipeline
  region that follows him between jobs.
- `SeasonsWithTeam` — int; seasons of tenure at current team (used for the
  new-coach ramp-up). **Directly relevant to us**: a freshly-hired coach has a
  low/zero SeasonsWithTeam — potentially usable as a carousel-change signal,
  though we track changes ourselves.
- `FirstName`, `LastName` — strings.
- `IsUserControlled` — boolean. The user's team = Coach row with
  `Position === 'HeadCoach' && IsUserControlled === true`, then match
  `TeamIndex` into the Team table (this is `readUserTeam()`; author confirmed
  it's "the same field the game itself uses to know who you are").

### Written

Only **SchoolPipelineInfluence** rows are ever written, and only 3 fields:
`InfluenceLevel`, `Pipeline`, `InfluenceValue`. The 10 slot rows per team are
reused **in place** — `commit-changes` zips `rows4306[i]` with the engine's
i-th ranked new entry (`result.after[i]`); no rows are allocated or freed, no
reference fields are rewritten. Team/Player/Coach are read-only in this tool.

### Pipeline region enum (43 values, from `engine/pipelineEngine.js` ALL_REGIONS)

```
Alabama, Arizona, Arkansas, BigApple, BigSky, CentralFlorida, Colorado,
EastTexas, Hawaii, Illinois, Indiana, Iowa, Kansas, Kentucky, Louisiana,
MetroAtlanta, Michigan, Minnesota, Mississippi, Missouri, Nebraska, Nevada,
NewEngland, NewMexico, NorthCarolina, NorthFlorida, NorthTexas,
NorthernCalifornia, Ohio, Oklahoma, PacificNorthwest, Pennsylvania,
SouthCarolina, SouthFlorida, SouthGeorgia, SouthernCalifornia,
SouthwestTexas, Tennessee, Tidewater, Utah, WestVirginia, Wisconsin,
International
```

(`data/regionCentroids.json` additionally contains an `"Invalid"` key — so the
save-side enum UNVERIFIED-probably has an `Invalid` member too.)

### Tier enum (6 values, ordered)

```
Unrecognized < NicheInterest < Respected < Popular < HouseholdName < CulturalPillar
```

Score→tier cutoffs observed from live save data (0–1000 scale):
`[0, 40, 80, 150, 250]` → i.e. score ≥250 = CulturalPillar, ≥150 =
HouseholdName, ≥80 = Popular, ≥40 = Respected, ≥0 (but above nothing) =
NicheInterest; the game's map pins render tiers 1–5 with exact colors
`#8e5435, #9c9c9c, #cba14b, #62aec5, #bd5fbb` (pulled from the in-game
"PIPELINE TIERS" legend — `renderer/map.js` GAME_TIER_COLORS).

### Where the values matter in-game

README: recomputed pipeline values only surface in the **recruiting UIs**
(team recruiting board, recruit's school interest/pipeline breakdown), not on
the team-select screen — that screen uses unrelated data.

---

## 3. `io/pipelineHistory.js` — the cross-season local-state pattern (MODEL FOR OUR CAROUSEL DB)

This is exactly the problem our tool has (persist tool-side state across runs
and across save-file copies), solved simply:

- **Location:** `path.join(app.getPath('userData'), 'pipeline-history.json')`
  — Electron per-user app-data dir. NOT inside the save, NOT next to the exe,
  so it survives app updates/reinstalls and isn't tied to any save file's
  folder. (On Windows this is `%APPDATA%/<appName>`; UNVERIFIED exact folder
  name — dev runs would use package.json `name` = `cfb27-pipeline-tool`,
  packaged runs the `productName` = `Dynamic Recruiting Pipeline Tool`.)
- **Format:** one pretty-printed JSON file, read fully / written fully each
  time (`JSON.stringify(history, null, 2)`); corrupt file ⇒ log + start fresh
  (`{}`) rather than crash.
- **Keying (critical):** `dynastyCode → teamName → season → region → data`
  where **dynastyCode = String(Franchise.LeagueID)** read from the save. This
  is what makes history follow the *dynasty* rather than a particular file
  path — the user can rename/move/copy saves and history still attaches
  correctly. Multiple dynasties never mix.

```js
// Structure (current):
// {
//   "<dynastyCode>": {
//     "Bowling Green": {
//       "2026": { "Ohio": { "tier": "HouseholdName", "value": 220 }, ... },
//       "2027": { ... }
//     }, ...
//   },
//   "<other-dynasty-code>": { ... }
// }
function recordSnapshot(app, dynastyCode, season, teamName, afterEntries) {
  const history = loadHistory(app);
  if (!history[dynastyCode]) history[dynastyCode] = {};
  if (!history[dynastyCode][teamName]) history[dynastyCode][teamName] = {};
  const byRegion = {};
  for (const [tier, region, value] of afterEntries) byRegion[region] = { tier, value };
  history[dynastyCode][teamName][String(season)] = byRegion;
  saveHistory(app, history);
}
```

Behavioral rules worth copying:

- **Idempotent per (dynasty, team, season):** re-applying the same season
  overwrites that season's entry instead of duplicating — "history is always
  the last Apply for this team, this season."
- **Schema evolution handled at read time, no migration:** older entries are a
  flat `{ region: "TierString" }`; newer ones `{ region: { tier, value } }`; a
  scrapped experiment briefly wrote `{ tiers, coaches }` wrappers. The reader
  (`extractSeasonData()` in renderer/app.js) sniffs the shape (string vs
  object, `'tiers' in entry`) and normalizes. Old seasons simply lack scores —
  documented in README as unrecoverable, by design.
- **Out-of-order-save detection:** before recording, `commit-changes` scans
  the dynasty's existing history for the max known season; if the save being
  applied reports an *earlier* season, the user gets a friendly warning
  (`dynastyHistorySeasonWarning`) that they're applying against an older copy
  — the entry for that older season is still updated. Our carousel tool WILL
  hit this scenario (users juggling save copies) and should adopt the same
  max-season sanity check.
- **History failure ≠ save failure:** recordSnapshot is wrapped in its own
  try/catch; if it fails the user is told "the save wrote fine, only History
  tracking failed" (`historyWarning`) instead of a silent failure.

Adaptation notes for our carousel DB: same pattern, but keyed
`dynastyCode → season → teamIndex/teamName → { role → {oldCoachId, newCoachId,
changeType} }`, plus per-recruit consequence log. LeagueID + CurrentSeasonYear
give us both keys straight from any save.

---

## 4. Electron architecture (main / preload / renderer)

- **Entry:** `main.js` (package.json `"main"`). `npm start` → `electron .`.
  Packaging: `electron-builder --win portable` (portable exe, appId
  `com.ball14.pipelinetool`, productName "Dynamic Recruiting Pipeline Tool").
- **Window:** single `BrowserWindow` 1200×800, `contextIsolation: true`,
  `nodeIntegration: false`, preload script. Renderer is plain static HTML/JS
  (`renderer/index.html` + `app.js` + `map.js` + vendored d3/topojson — no
  bundler, no framework).
- **All privileged work lives in the main process** (madden-franchise, fs,
  dialogs, engine). The renderer talks only through `window.api`.

### Preload — the entire IPC surface (`preload.js`, all `ipcRenderer.invoke`)

```js
contextBridge.exposeInMainWorld('api', {
  selectSaveFile:        () => ipcRenderer.invoke('select-save-file'),
  selectOutputDir:       () => ipcRenderer.invoke('select-output-dir'),
  getSettings:           () => ipcRenderer.invoke('get-settings'),
  saveSettings:   (settings) => ipcRenderer.invoke('save-settings', settings),
  applyPreset: (settings, presetName) => ipcRenderer.invoke('apply-preset', { settings, presetName }),
  getPresets:            () => ipcRenderer.invoke('get-presets'),
  getTeamColors:         () => ipcRenderer.invoke('get-team-colors'),
  getStateToPipeline:    () => ipcRenderer.invoke('get-state-to-pipeline'),
  getLogosDir:           () => ipcRenderer.invoke('get-logos-dir'),
  getHistory:            () => ipcRenderer.invoke('get-history'),
  getDynastyCodeForSave: (savePath) => ipcRenderer.invoke('get-dynasty-code-for-save', { savePath }),
  getSaveInfo:    (savePath) => ipcRenderer.invoke('get-save-info', { savePath }),
  runEngine: (savePath, settings) => ipcRenderer.invoke('run-engine', { savePath, settings }),
  commitChanges: (savePath, engineResults, teamNamesToApply, outputDir) =>
    ipcRenderer.invoke('commit-changes', { savePath, engineResults, teamNamesToApply, outputDir }),
});
```

Everything is request/response (`ipcMain.handle` ↔ `invoke`); there are **no
push events** from main to renderer (`webContents.send` is never used).

### Save-path selection

`select-save-file` handler opens a native `dialog.showOpenDialog` with
`properties: ['openFile']`, `defaultPath` = last-used folder. The chosen
path's **directory** is persisted as `lastSaveFolder` inside
`userData/pipeline-tool-settings.json` so the next open starts there. The
renderer keeps the chosen `savePath` in a plain variable and passes it back
into every subsequent call — main is stateless about the current save.
`select-output-dir` similarly picks the destination folder
(`['openDirectory', 'createDirectory']`) at Apply time.

### Settings persistence

`userData/pipeline-tool-settings.json`; loaded with
`{ ...defaultSettings(), ...JSON.parse(raw) }` (missing keys fall back to
defaults — cheap forward-compatible settings migration), saved on every UI
change (every slider/checkbox writes immediately; README bills it as
"everything saves automatically").

### Progress & error surfacing

- **Progress:** minimal — the Run button becomes `Running…` + disabled during
  `runEngine`, restored in `finally`. No progress bar, no streaming updates.
  (For our tool, if we want real progress we'd need `webContents.send` events
  — this codebase gives no pattern for that.)
- **Errors:**
  - `getSaveInfo` is wrapped in renderer try/catch → inline "Could not read
    dynasty info from this save."
  - Handler exceptions reject the `invoke` promise. GOTCHA: the Run and Apply
    click handlers have **no catch** around the awaits (Run has only
    try/finally), so a main-process throw becomes an unhandled promise
    rejection with no user-visible message — a weakness, not a pattern to copy.
  - Rich results-as-data instead of throws for the risky path:
    `commit-changes` returns `{ outputPath, verified, verificationError,
    dynastyHistorySeasonWarning, historyWarning }` and the renderer renders
    green check / red severe warning / yellow warnings from those fields.
  - Pre-flight guards via `alert()` (no save selected) and `confirm()` before
    writing.
- **Save-info bar** (v0.5.0): after picking a save, one `get-save-info` call
  returns `{ dynastyCode, season, userTeam: { teamIndex, displayName } }` and
  the UI shows "Season X — Playing as Y — Dynasty Z" with team logo/color
  swatch. Nice trust-building touch worth copying.

### File-map of responsibilities

| File | Role |
|---|---|
| `main.js` | Electron main: window, all IPC handlers, settings io, orchestration |
| `preload.js` | contextBridge `window.api` (14 invoke wrappers) |
| `io/saveFile.js` | All madden-franchise access (open, read tables, write+verify copy) |
| `io/pipelineHistory.js` | Local cross-season JSON history (userData) |
| `engine/pipelineEngine.js` | Pure computation, no IO (port of validated Python prototype) |
| `renderer/app.js` | UI state, settings sliders/presets, preview list, history modal, apply flow |
| `renderer/map.js` | D3/topojson US county map, tier coloring, exposes `window.PipelineMap` |
| `renderer/main.js` | **STALE DUPLICATE of an older root main.js** (pre-v0.5.0: no get-save-info/readUserTeam/historyWarning). Never loaded — index.html loads only vendor/d3, vendor/topojson, map.js, app.js. Ignore it; do not confuse it with the real main.js. |
| `data/stateToPipeline.json` | 52-entry map: PLYR_HOME_STATE-style state name (incl. `NonUS`) → pipeline region (e.g. `"Texas": "EastTexas"`, `"NonUS": "International"`). Served to renderer for map coloring. |
| `data/regionCentroids.json` | region → [lat, lng] empirical centroids (built from real player hometown data); includes an `"Invalid"` key |
| `data/teamColors.json` | teamName → [primaryHex, secondaryHex] for all 138 teams |
| `data/schoolCoordinates.js` | `SCHOOL_COORDS` campus [lat,lng] for all 138 FBS teams + `haversineMiles` |

---

## 5. Output filename & write-in-place question

Generated in `io/saveFile.js` `writeUpdatedSave()` (see excerpt in §1):

```
<outputDir>/<original basename>-PIPELINES-<ISO timestamp with ':' and '.' → '-'>
```

Example shape: `CAREER-MYDYNASTY-PIPELINES-2026-07-14T18-30-05-123Z`.

- **Never in place.** New file = `copyFileSync(original → outputPath)` first;
  all edits + `franchise.save()` target the copy. The original is only ever
  opened by read-only Franchise instances (for engine runs, history keying,
  and the info bar).
- `outputDir` is user-chosen per Apply via the `select-output-dir` dialog.
- No extension is appended — the timestamp suffix becomes the "extension".
  The game apparently doesn't care (README instructs loading the copy
  in-game). UNVERIFIED: whether the in-game load browser requires the file to
  live in the saves folder — README just says "Load that new copy in-game".

---

## 6. Engine details relevant to us (skim)

`engine/pipelineEngine.js` is pure/deterministic (port of a validated Python
prototype, "byte-for-byte identical output"). Score model per region:
`decay * priorScore + (1-decay) * 1000 * (wRoster*rosterShare + wStar*starShare
+ wCoach*coachShare + wGeo*geoShare)`, top 10 kept. Bits we can reuse:

- `STAR_WEIGHT = { ONE_STAR: 1, TWO_STAR: 4, THREE_STAR: 16, FOUR_STAR: 64, FIVE_STAR: 256 }`
  — exponential star curve validated against real rosters; also documents the
  exact `ProspectStarRating` enum strings.
- `coachComponent()` — HC/OC/DC weighting (`coachWeight` defaults HC 0.6 /
  OC 0.2 / DC 0.2, renormalized over the included set) with tenure ramp
  `min(1, SeasonsWithTeam / coachRampSeasons)` — a ready-made template for our
  "coach influence phases in after a hire" logic.
- Tier assignment: `tierFor(score, [0,40,80,150,250])` → TIER_NAMES index+1.

---

## 7. Gotchas checklist for our tool (accumulated)

1. Use `getTableByUniqueId` with the uniqueIds table above; null-check the
   result yourself (library returns `undefined`, doesn't throw).
2. If several tables share a name, name-based lookup returns the FIRST —
   either use uniqueIds or the largest-recordCapacity heuristic.
3. `Franchise.create(path)` (madden-franchise ≥4.3.1) needs no schema options
   for CFB27; C27_468_2.gz is bundled in the package.
4. Resolve reference fields via `field.referenceData` → `{tableId, rowNumber}`
   and validate `tableId` against the freshly-resolved target
   `table.header.tableId`, never a hardcoded number.
5. Team table: skip rows with blank `DisplayName` or `TeamIndex === 255`
   (5 placeholder rows among 143).
6. SchoolPipelineInfluence placeholder slots: `Unrecognized` + value 0.
7. Never write the original save: copy → open copy → mutate → `save()` →
   re-open copy read-only and verify field-by-field.
8. Key tool-local persistent state by `String(Franchise.LeagueID)` (dynasty
   code) + `SeasonInfo.CurrentSeasonYear`, stored as JSON in
   `app.getPath('userData')`; overwrite-per-season semantics; shape-sniffing
   readers instead of migrations; warn (don't block) when applying against a
   save older than recorded history.
9. User's team: Coach row `Position === 'HeadCoach' && IsUserControlled ===
   true` → `TeamIndex` → Team row.
10. `record.EnumField = 'FormattedString'` is a valid write for enum fields.
11. Minor tool quirk (don't copy): `main.js` checks
    `writeResult.success !== false` but `writeUpdatedSave` never returns a
    `success` key (it returns `verified`), so that guard is always true.
12. package.json still says version 0.1.0 while the README changelog is at
    v0.5.0 — trust the README/code, not the version field.
