# CFB27-Coaching-Carousel — Deep Analysis Notes

Source repo: `E:/Games/EA SPORTS College Football 27.SteamGG.NET/Mods/CoachCarouselRecruitTool/reference/CFB27-Coaching-Carousel`
Analyzed: 2026-07-14. Every first-party source file was read in full. All schema facts below (table names, field types, bit widths, enum members) were **verified empirically** by loading a scratchpad copy of the user's `DYNASTY-QQ2` save with the vendored `madden-franchise` (schema C27_468_2) and dumping the tables the tool touches — see `scratchpad/inspect-carousel-tables.js` / `carousel-tables.json` (session scratchpad). Anything not verified is prefixed **UNVERIFIED**.

The tool is an Electron GUI (`electron ^43`, `madden-franchise ^4.3.0`) that edits the in-game **coaching carousel candidate lists**: per-(team, position) lists of pending `StaffPersonContractOffer` rows. It lets the user edit interest numbers, reorder candidates, and swap which coach a pending offer points at. It does **not** directly execute hires/fires — the game engine resolves the offers when the user advances the week in-game.

---

## 0. Architecture map

| File | Role |
|---|---|
| `src/app.js` | Electron main process; one `FranchiseSession`; IPC handlers `app:get-info`, `app:select-directory`, `dynasty:load`, `candidate:update-interest`, `candidate:move`, `candidate:replace-coach`, `dynasty:save` |
| `src/preload.js` | `contextBridge` exposes `window.carouselApi.{getAppInfo,loadDynasty,moveCandidate,replaceCoach,selectDirectory,save,updateInterest}` |
| `src/franchise/franchiseLoader.js` | Locates saves dir, resolves dynasty path, **creates timestamped backup before opening**, opens via `Franchise.create()` |
| `src/franchise/franchiseSession.js` | Lifecycle/state hub: load → repositories → mutate → save; builds the full UI state DTO after every mutation |
| `src/franchise/tableUtils.js` | Field-alias helpers, ref parsing, enum normalization, table resolution by uniqueId |
| `src/franchise/carouselRepository.js` | `StaffPersonContractOffer` table (the candidate/offer rows) |
| `src/franchise/contractOfferRepository.js` | `StaffPersonContractOffer[]` array table (authoritative per-opening candidate ordering) |
| `src/franchise/jobOpeningRepository.js` | `JobOpening` table (read-only; opening reason: fired/retired/pro) |
| `src/franchise/coachRepository.js` | `Coach` table (read mostly; writes only `NumContractOffers`) |
| `src/franchise/teamRepository.js` | `Team`, `Conference`, `Team[]` (TeamSlots) tables (read-only) |
| `src/services/saveService.js` | Save pipeline: re-sort → write ordering arrays → recount offers → `franchise.save()` |
| `src/services/sortingService.js` | Sort/group/move-by-interest logic |
| `src/services/replacementService.js` | Validation for coach replacement (duplicates, coach-in-other-offers) |
| `src/services/formatService.js` | Position/enum/name label formatting |
| `src/services/assetService.js` | Team/conference logo PNG lookup from `Resources/` (UI only) |
| `src/ui/*` | Renderer: filter panel, grouped candidate grid, replace-coach dialog |

---

## 1. Save tables used (all verified against a real CFB27 dynasty save)

The tool resolves every table by **`franchise.getTableByUniqueId(uid)`** — NOT by name. The `uniqueId` lives in `table.header.uniqueId` and is stable across saves (the numeric `tableId` is not; in my save the tableIds were 4298/4173/6334/4291/6336/4151/5180 but those shift between saves).

| Constant (file) | uniqueId | Verified table name | recordCapacity (my save) | isArray |
|---|---|---|---|---|
| `CAROUSEL_TABLE_UNIQUE_ID` (carouselRepository.js) | **674348040** | `StaffPersonContractOffer` | 804 | no |
| `CONTRACT_OFFER_TABLE_UNIQUE_ID` (contractOfferRepository.js) | **4119397260** | `StaffPersonContractOffer[]` | 408 | yes (6 slots/row) |
| `JOB_OPENING_TABLE_UNIQUE_ID` (jobOpeningRepository.js) | **263453863** | `JobOpening` | 408 | no |
| `COACH_TABLE_UNIQUE_ID` (coachRepository.js) | **1860529246** | `Coach` | 632 | no |
| `TEAM_TABLE_UNIQUE_ID` (teamRepository.js) | **3359508968** | `Team` | 143 | no |
| `CONFERENCE_TABLE_UNIQUE_ID` (teamRepository.js) | **3820706130** | `Conference` | 12 | no |
| `TEAM_SLOTS_TABLE_UNIQUE_ID` (teamRepository.js) | **2477738738** | `Team[]` (conference team slots) | 21 | yes (20 slots/row) |

> Gotcha: there are multiple `Team[]` array tables in a save; uniqueId 2477738738 is specifically the one Conference.TeamSlots points into. Resolving array tables by name alone is ambiguous — use uniqueId or follow the ref.

### 1a. `StaffPersonContractOffer` (uniqueId 674348040) — the carousel candidate rows

One row = one candidate offer: "team X considers coach Y for position Z". Complete verified field list (schema types/bit widths from `field.offset`):

| Field | Type | Bits | Range | Tool R/W | Meaning |
|---|---|---|---|---|---|
| `Team` | ref → Team | 32 | — | R | **Hiring** school |
| `StaffPersonTeam` | ref → Team | 32 | — | R/**W** | Candidate coach's current school (written on replace) |
| `StaffPerson` | ref → StaffPerson (Coach rows in practice) | 32 | — | R/**W** | The candidate coach (written on replace) |
| `ContractExpectationsByYear` | ref → enum[] | 32 | — | listed in read-set, never used | UNVERIFIED: per-year contract expectation enums |
| `Length` | int | 5 | 0–15 | listed in read-set, never used | UNVERIFIED: offered contract length (years) |
| `OfferedContractProgramPoints` | int | 11 | 0–2000 | listed, unused | UNVERIFIED: salary in "program points" |
| `ExperiencePoints` | int | 16 | 0–50000 | listed, unused | — |
| `Status` | enum `ContractOfferStatus` | 3 | — | R (filter only) | **`Accepted=0, Declined=1, Pending=2, Withdrawn=3, NoOffer=4, Invalid_=7`**. madden-franchise returns the string name (`'Pending'`) |
| `BaseStaffPersonInterestInOffer` | int | 9 | **0–280** | R/**W** | Coach's base interest in the job ("Coach Interest (Base)") |
| `TeamInterestInStaffPerson` | int | 9 | **0–280** | R/**W** | Team's interest in the coach — the primary ranking key |
| `ExpectedContractProgramPoints` | int | 11 | 0–2000 | listed, unused | — |
| `OfferIndex` | int | 17 | **0–6** | R/**W** | Rank of this offer within its (team, position) group; rewritten to match interest-sorted order |
| `AdjustedStaffPersonInterestInOffer` | int | 7 | 0–100 | R/**W** | Coach interest after adjustments ("Coach Interest (Adjusted)") |
| `ContractPosition` | enum `CoachPosition` | 8 | — | R (group key) | See CoachPosition enum below |

**`CoachPosition` enum (verified, shared by Coach.Position, JobOpening.Position, ContractPosition):**
`First_=0, HeadCoach=0, OffensiveCoordinator=1, DefensiveCoordinator=2, NumCollegeCoaches=3, SpecialTeams=3, Owner=4, Scout=5, Trainer=6, GeneralManager=7, PlayerPersonnel=8, Max_=9, Invalid_=255`.
Values come back as **strings** (`'HeadCoach'`, `'OffensiveCoordinator'`, …). Aliased values mean a read may return the alias name (`First_` for 0, `NumCollegeCoaches` for 3) — the tool's `formatService.js` maps both `SpecialTeams` and `NumCollegeCoaches` to "ST" for exactly this reason.

Empirical: in my offseason-stage-2 save the table had **1** non-empty row and it was junk (`Status:'Pending'` but all refs `EMPTY_REFERENCE`, `ContractPosition:'Invalid_'`). Real rows only exist while the carousel is running (see §6). The tool defends against junk rows via its `isValidTeam` filter.

### 1b. `StaffPersonContractOffer[]` (uniqueId 4119397260) — authoritative candidate ordering

Array table, 408 rows, each row has exactly **6 slots**: `StaffPersonContractOffer0` … `StaffPersonContractOffer5` (each a 32-bit ref to a `StaffPersonContractOffer` row). One array row = the ordered candidate list for one job opening (referenced from `JobOpening.ContractOfferList`). `OfferIndex` max of 6 matches the 6 (UNVERIFIED: possibly 7 counting index 6) slot design.

Gotcha the tool works around: it never follows `JobOpening.ContractOfferList` to find the right array row. Instead it **reverse-matches**: builds `carouselTable.getBinaryReferenceToRecord(row)` for every pending offer, scans every array row's slots for those refs, and picks the array row containing the most refs from the group (`findArrayRecordForGroup`, majority vote, cached per group key). Your tool can do it more simply by following `JobOpening.ContractOfferList` directly.

### 1c. `JobOpening` (uniqueId 263453863) — why the job is open

Complete verified field list (11 fields):

| Field | Type | Bits | Meaning |
|---|---|---|---|
| `Team` | ref → Team | 32 | School with the opening |
| `SelectedCoach` | ref → Coach | 32 | Coach chosen to fill the opening (UNVERIFIED: set when engine resolves the hire) |
| `PrevCoach` | ref → Coach | 32 | The coach who vacated the job — **key for "recruits follow departing coach"** |
| `InterestedUserTeamsList` | ref → Team[] | 32 | UNVERIFIED: user teams interested in the opening |
| `ContractOfferList` | ref → StaffPersonContractOffer[] | 32 | The ordered candidate list for this opening (§1b) |
| `Filled` | bool | 1 | Opening has been filled |
| `IsEmergentJobOpening` | bool | 1 | UNVERIFIED: opening created mid-carousel (e.g., by a coach leaving for another job) |
| `Position` | enum `CoachPosition` | 8 | HC/OC/DC/ST — same enum as §1a |
| `FinalContractProgramPoints` | int | 11 (0–2000) | UNVERIFIED: final agreed contract points |
| `HighestOfferedProgramPoints` | int | 11 (0–2000) | UNVERIFIED: highest bid so far |
| `Reason` | enum `CoachLeaveReason` (offset.length reported 32; member values fit in 3 bits) | — | **`None=0, Fired=1, Retired=2, Pro=3, NewJob=4, ContractEnding=5`** |

`Reason` is exactly the fired/retired/went-pro/hired-elsewhere signal the CoachCarouselRecruitTool needs. Read-only in this tool. Table had **0 non-empty rows** in my offseason save — rows exist only during the carousel window.

Note: `jobOpeningRepository.js` never hardcodes these field names — it fuzzy-matches keys containing "team"/"school" and "position"/"contract" and reason aliases `['Reason','OpeningReason','JobOpeningReason','VacancyReason','ReasonType','Cause']`, then regex-classifies the value (`/fired|fire/`, `/retired|retire/`, `/pro|went pro|professional/`, `/contract ending|.../`, `/resign|resigned/` → keys `fired|retired|pro|contract-ending|resigned|unknown`). The real schema (above) makes all of that unnecessary: read `Reason` directly; string values will be the enum names (`'Fired'`, `'Retired'`, `'Pro'`, `'NewJob'`, `'ContractEnding'`, `'None'`).

### 1d. `Coach` (uniqueId 1860529246)

632-row table, **136 fields** total. Fields in the tool's `readRecords()` projection (all verified present):

| Field | Type | Bits | Range/Enum | Meaning |
|---|---|---|---|---|
| `FirstName` / `LastName` / `Name` | string | 32 (offset) | — | `Name` used as fallback when First/Last empty |
| `Age` | int | 7 | 0–127 | |
| `TeamIndex` | int | 8 | 0–255 | Index matched against `Team.TeamIndex` (fallback team resolution; 255-ish = no team, UNVERIFIED sentinel) |
| `Position` | enum `CoachPosition` | 8 | see §1a | **This is how role is encoded**: `HeadCoach`/`OffensiveCoordinator`/`DefensiveCoordinator`/`SpecialTeams`(alias `NumCollegeCoaches`) |
| `PrevPosition` | enum `CoachPosition` | 8 | | Previous role |
| `ContractStatus` | enum `StaffPersonContractStatus` | 4 | **`First_Active=0, Signed=0, Expiring=1, First_Pending=2, PendingFire=2, PendingNFL=3, PendingRenewal=4, Last_Active=5, PendingRetire=5, Last_Pending=6, PendingHire=6, FreeAgent=7, Retired=8, Deleted=9, None=10`** | Read may return alias (`'First_Active'` observed for value 0). `PendingFire/PendingRetire/PendingNFL/PendingHire` are the in-carousel transitional states — highly relevant to detecting coaching changes |
| `ContractYearsRemaining` | int | 5 | 0–31 | |
| `ContractLength` | int | 3 | 0–7 | |
| `CurrentJobSecurityPercentage` | int | 7 | 0–100 | |
| `CurrentJobSecurityPercentageRank` | int | 9 | 0–500 | |
| `CurrentJobSecurityStatus` / `SeasonStartJobSecurityStatus` | enum `JobSecurityStatus` | 3 | **`Safe=0, SafeForNow=1, Low=2, HotSeat=3, Invalid=4`** | |
| `COACH_FIREREPORTED` | bool | 1 | | "Fired" already reported (news) — tool surfaces as badge |
| `COACH_RESIGNREPORTED` | bool | 1 | | "Resigned" reported |
| `COACH_LASTTEAMFIRED` | int | 10 | 0–1023 | Team (UNVERIFIED: TeamIndex) that last fired this coach |
| `COACH_LASTTEAMRESIGNED` | int | 10 | 0–1023 | Team the coach last resigned from |
| `IsUserControlled` | bool | 1 | | |
| `Level` | int | 7 | 0–100 | Coach level |
| `SeasonsWithTeam` | int | 7 | 0–127 | |
| `YearsCoaching` | int | 7 | 0–127 | |
| `CoachPrestige` | enum `LetterGrade` | 4 | **`Aplus=0(alias First_), A=1, Aminus=2, Bplus=3, B=4, Bminus=5, Cplus=6, C=7, Cminus=8, Dplus=9, D=10, Dminus=11, F=12(alias Last_), COUNT=13/Incomplete=13`** | Coach prestige letter grade (observed `'Cminus'`) |
| `CoachPrestigeScore` | int | 14 | 0–10000 | Numeric prestige |
| `NumContractOffers` | int | 4 | 0–12 | **Written by the tool** — count of pending offers naming this coach (see §4.3) |

Coach identity ref: `coachRepository` uses `coachTable.getBinaryReferenceToRecord(record.index)` and this ref equals what carousel rows store in `StaffPerson` (schema type of that field is the base class `StaffPerson`, but the refs point at Coach rows — verified by the tool's working byRef lookups).

Fields the task asked about but the tool does NOT read — they exist on the Coach record (names verified in the 136-field dump; semantics UNVERIFIED): `AlmaMater`, `OffensiveScheme`, `DefensiveScheme`, `OffensivePlaybook`, `DefensivePlaybook`, `Portrait`, `Portrait_Swappable_Library_Path`, `Portrait_Force_Silhouette`, `GenericHeadAssetName`, `AssetName`, `CharacterVisuals` (ref), `PrevTeamIndex`, `COACH_LASTCONTRACTTEAM`, `COACH_RETIREYRSLEFT`, `Probation`, `PrimaryPipeline`, `LegacyScore`, `ContractSalary`, `HomeTown`, `HomeState`, position-group ratings `COACH_QB/RB/WR/OL/DL/LB/DB/K/P/S/OFFENSE/DEFENSE`, `COACH_RATING`, tendencies (`COACH_OFFTENDENCYRUNPASS` etc.), `TalentTree`/`ActiveTalentTree`, `SeasonalGoal`, `CoachBackstory`.

### 1e. `Team` (uniqueId 3359508968)

143 rows (all non-empty), **408 fields** total. Fields in the tool's projection:

| Field | Type | Bits | Range | Meaning |
|---|---|---|---|---|
| `LongName` / `DisplayName` / `ShortName` / `NickName` / `NickNameAlt` | string | 32 | — | Name fallback chain: LongName → DisplayName → TEAM_PREFIX_NAME → ShortName → NickName → AssetName |
| `AssetName`, `TEAM_DBASSETNAME`, `TEAM_LOGO_ASSETNAME`, `TEAM_PREFIX_NAME` | string | 32 | — | Asset/logo lookup names |
| `TEAM_VISIBLE` | bool | 1 | — | |
| `TeamIndex` | int | 8 | 0–255 | Stable team index (joins to `Coach.TeamIndex`) |
| `TeamRank` | int | 8 | 0–255 | |
| `TeamPrestige` | int | 4 | **0–10** | Displayed as stars = value/2 (0–5.0) |
| `HeadCoach` | ref → Coach | 32 | — | **Current HC** |
| `OffensiveCoordinator` | ref → Coach | 32 | — | **Current OC** |
| `DefensiveCoordinator` | ref → Coach | 32 | — | **Current DC** |
| `SpecialTeamsCoach` | ref → Coach | 32 | — | Current ST coach |

Important: the tool asked `readRecords()` for a field literally named `Conference` — **it does not exist on the Team record** (verified NOT FOUND). Conference membership is derived instead (next section). `madden-franchise` tolerates unknown names in the `readRecords` projection list.

### 1f. `Conference` (uniqueId 3820706130) + `Team[]` TeamSlots (uniqueId 2477738738)

- `Conference` (12 rows, 42 fields). Used: `Name` (string; the repo tries `Name`,`name`,`LongName`,`DisplayName`,`ConferenceName` case-insensitively — actual field is `Name`) and `TeamSlots` (ref → `Team[]`, 32 bits). Other conference fields present but unused: `AssetName`, `StyleName`, `ConfChampGameName`, `WhiteLogoId`, `CONF_LOGO`, `ConfChampGameTrophyID`, `ConfChampGameLogoID`, `PresentationId`.
- `Team[]` uid 2477738738 (21 rows, 10 non-empty in my save): each row has `Team0`…`Team19` refs.
- Conference resolution algorithm (`mapTeamsToConferences`): for each Conference row → follow `TeamSlots` ref → find the `Team[]` row whose `getBinaryReferenceToRecord` equals that ref → each non-empty `TeamN` ref identifies a member Team → stamp `team.conference = conferenceName`. Teams never referenced by any slot ⇒ labeled `"Independents"`.

---

## 2. How "role" is encoded (summary answer)

`Coach.Position` (and `JobOpening.Position`, `StaffPersonContractOffer.ContractPosition`) is the 8-bit `CoachPosition` enum: **HeadCoach=0, OffensiveCoordinator=1, DefensiveCoordinator=2, SpecialTeams=3** (Owner/Scout/Trainer/GM/PlayerPersonnel = 4–8 are NFL-side leftovers; `NumCollegeCoaches` is an alias for 3). madden-franchise reads/writes the **string member name**. Additionally, the Team row carries direct per-role coach refs (`HeadCoach`/`OffensiveCoordinator`/`DefensiveCoordinator`/`SpecialTeamsCoach`), and the tool builds its coach→(team, role) map from the **Team side** first (`teamRepository.indexCoachAssignments()`), falling back to `Coach.TeamIndex` + `Coach.Position`. First assignment found wins (a ref appearing on multiple teams is ignored after the first).

---

## 3. Mutation recipes (what the tool actually writes)

The tool touches exactly **three tables** when writing: `StaffPersonContractOffer` (values), `StaffPersonContractOffer[]` (ordering slots + arraySize), `Coach` (`NumContractOffers` only). It never writes `Team.HeadCoach`, `Coach.Position`, `Coach.TeamIndex`, `JobOpening.*`, or contract fields — outcome changes are made **indirectly** by editing who is in the candidate list and how interested each side is; the game engine performs the actual hire/fire resolution on week advance.

### 3.1 Edit interest (`candidate:update-interest`)

`carouselRepository.updateInterest(rowIndex, key, value)`:
```js
const field = record.getFieldByKey(fieldName);
const min = Number.isFinite(field && Number(field.offset.minValue)) ? Number(field.offset.minValue) : configured.min;
const max = Number.isFinite(field && Number(field.offset.maxValue)) ? Number(field.offset.maxValue) : configured.max;
record[fieldName] = clampInteger(rawValue, min, max);
```
- Field written: one of `BaseStaffPersonInterestInOffer`, `AdjustedStaffPersonInterestInOffer`, `TeamInterestInStaffPerson`.
- Clamp bounds come from the **schema** (`field.offset.minValue/maxValue`) when available, falling back to hardcoded `{adjustedInterest:0-100, baseInterest:0-280, teamInterest:0-100}`. Gotcha: schema max for `TeamInterestInStaffPerson` is **280**, so the effective clamp is 0–280 even though the UI input caps at 100.
- Side effect: if the edited field was `teamInterest`, the session immediately runs `syncOrdering()` (see 3.4) since team interest drives rank.

### 3.2 Reorder candidate (`candidate:move`, sortingService.moveRecordByInterest)

Reordering is implemented purely by **swapping/adjusting `TeamInterestInStaffPerson`** between the record and its neighbor in the interest-sorted group, guaranteeing strict ordering (bumps neighbor+1 on tie, clamped 0..100 with 99/1 fallback at the extremes). Then `syncOrdering()`.

### 3.3 Replace coach on an offer (`candidate:replace-coach`)

Validation first (`replacementService.validateReplacement`): reject if replacement already appears in the same (team,position) group (`DUPLICATE_IN_GROUP`); warn + require `force:true` confirm if the coach appears in other pending offers (`COACH_EXISTS_ELSEWHERE`). Then:
```js
// carouselRepository.replaceCoach
this.set(record, 'staffPerson', coachRef);                       // StaffPerson = coach's 32-bit binary ref
this.set(record, 'staffPersonTeam', coachTeamRef || EMPTY_REFERENCE); // StaffPersonTeam = coach's current Team ref
// then, in franchiseSession.replaceCoach:
this.coachRepository.recalculateContractOfferCounts(this.carouselRepository.table.records);
```
`recalculateContractOfferCounts` recounts pending offers per `StaffPerson` ref across the whole carousel table and writes `Coach.NumContractOffers` for every coach (0 if none). Interest values on the offer row are left as-is (inherited from the replaced coach).

### 3.4 Ordering sync (`syncOrdering` / save pipeline) — the trickiest recipe

Runs after teamInterest edits, moves, and always at save:
1. `applyAuthoritativeOrdering`: group pending records by `groupKey = Team-ref + '|' + ContractPosition`; sort each group by (**TeamInterest desc → BaseInterest desc → AdjustedInterest desc → OfferIndex asc → row index asc**); write `OfferIndex = 0..n-1` on each offer row in sorted order.
2. `contractOfferRepository.updateOrderingForGroups`: for each group locate its `StaffPersonContractOffer[]` array row (majority-vote reverse match, §1b) and rewrite it:
```js
// writeArrayRecord — canonical array-table write pattern
for (let i = 0; i < slotFields.length; i += 1) {
  arrayRecord[slotFields[i].key] = i < writeCount ? offerRefs[i] : EMPTY_REFERENCE;
}
arrayRecord.arraySize = writeCount;
if (this.table.arraySizes && this.table.arraySizes.length > arrayRecord.index) {
  this.table.arraySizes[arrayRecord.index] = writeCount;      // keep table-level mirror in sync
}
arrayRecord.isChanged = true;
this.table.isChanged = true;                                   // force madden-franchise to serialize the table
```
Slots are `StaffPersonContractOffer0..5` sorted by `field.offset.index`; refs come from `carouselTable.getBinaryReferenceToRecord(offerRow.index)`. If a group has more offers than 6 slots, the extras are silently dropped from the array and a warning is emitted.

> Takeaways for our tool: (a) when writing array tables you must set slot values AND `record.arraySize` AND `table.arraySizes[row]`, and mark `isChanged` on record + table; (b) padding unused slots with the 32-zero string is the null convention; (c) `OfferIndex` on the offer rows and slot order in the array row must agree — the game treats them together.

---

## 4. franchiseSession.js lifecycle (open → cache → mutate → save → backup)

1. **Open** (`load`): `reset()` state → `loadDynasty(name, customDir)`:
   - Resolve path: literal path if it contains `/ \ :`, else `<saves>/<name>` (case-insensitive fallback scan). Default saves dir = `os.homedir()/Documents/EA SPORTS College Football 27/saves` (note: does NOT handle the OneDrive-redirected Documents folder unless homedir Documents is junctioned; the UI offers "Change Save Directory" for that).
   - **Backup before parse**: `fs.copyFileSync(filePath, filePath + '_backup_' + 'YYYY-MM-DD_HH-mm-ss')`. Backup happens once per load, never at save time. (Evidence it works: `DYNASTY-QQ2_backup_2026-07-14_16-40-19` exists in the user's saves dir.)
   - **Open** via static factory, not constructor: 
     ```js
     await Franchise.create(filePath, {
       gameTypeOverride: 'college',
       gameYearOverride: 27,
       schemaOverride: { major: 468, minor: 2, gameYear: 27, path: <node_modules/madden-franchise/data/schemas/27/C27_468_2.gz> }
     });
     ```
     Falls back to the same call without `schemaOverride` if it throws (madden-franchise **4.3.0 ships the C27_468_2.gz schema itself** under `data/schemas/27/`). `Franchise.create` resolves when parsing is done — no manual `'ready'` event needed (equivalent to our constructor + ready pattern).
2. **Cache**: constructs repositories; `readRecords(projection)` on Team/Carousel/ContractOffer-array/JobOpening in `Promise.all`, then Coach (needs TeamRepository first). Coach/Team repos build in-memory maps: `byRef` (binary-ref string → object), `byRecordIndex`, `byTeamIndex`, `assignmentByCoachRef` (from Team coach ref fields), `conferences`. Records themselves are the live madden-franchise record objects — mutations go straight to them (`record[fieldName] = value`), so "cache" is only for lookups/DTOs, never a shadow copy.
3. **Mutate**: IPC handlers mutate live records (recipes §3), set `this.dirty = true`, and return a full re-rendered state DTO (`getState()`), which re-derives pending records, ranks, filters, and stats every call.
4. **Save** (`dynasty:save` → `saveService.saveCarousel`): re-runs the full ordering pipeline (authoritative OfferIndex write + array rewrite + NumContractOffers recount) and then **`await franchise.save()`** — no arguments, writes compressed save back to the original `filePath`. `dirty` reset to false; warnings from array writes surfaced to UI. No post-save verification/reload.
5. There is no explicit close; loading another dynasty just `reset()`s and re-opens (previous backup path is dropped).

---

## 5. When does carousel data exist? (dynasty calendar)

- **The tool performs zero stage/week checks in code.** There is no read of SeasonInfo or any calendar table. The only gating is data-driven:
  - `getPendingRecords()` keeps rows where `Status` is pending: `isPendingStatus` accepts `'pending'` case-insensitively or any string ending in `':pending'` (defensive; observed values are plain `'Pending'`).
  - `toCandidateDto(...).isValidTeam` drops rows whose hiring team resolves to "Unknown School"/"No School" (i.e., `Team` ref empty/dangling — junk rows like the one observed outside carousel).
- README instruction (author's operational knowledge): *"Launch during any week of the coaching carousel (typically Weeks 14-16 in-season, or offseason weeks when coaching changes occur)."*
- My empirical check of an **offseason stage-2** save: `JobOpening` 0 non-empty rows, `StaffPersonContractOffer` 1 junk row → outside the carousel window both tables are empty and the tool would show nothing to edit.
- Bonus (verified, not used by this tool, directly useful for ours): the `SeasonInfo` table (uniqueId **3123991521**) carries `CurrentStage` (observed `'OffSeason'`), `CurrentWeekType` (`'OffSeason'`), `CurrentOffseasonStage` (observed 2; `OffseasonNumStages` = 9), `CurrentWeek`, `CurrentSeasonYear`, `CurrentYear`, and **`IsCarouselPeriodActive` (bool, observed false)** — the cleanest programmatic signal for "carousel is running right now".

---

## 6. tableUtils.js helpers vs our established parseRef/makeRef pattern

- `EMPTY_REFERENCE = '00000000000000000000000000000000'` (32 zeros) — same null convention as established facts.
- `parseReference(value)` → `{ tableId: parseInt(ref.slice(0,15), 2), rowNumber: parseInt(ref.slice(15), 2) }` — **identical 15/17 bit split** to our established parseRef. (Exported but actually unused by the app; the tool compares refs as opaque strings and *creates* them with the library method instead.)
- makeRef equivalent: **`table.getBinaryReferenceToRecord(rowIndex)`** — the madden-franchise built-in; prefer this over hand-rolled bit packing since it uses the table's own header tableId.
- `normalizeReference(v)`: valid iff `/^[01]{32}$/`, else coerced to EMPTY_REFERENCE; `isEmptyReference(v)` = normalize→equals-empty. (Slightly stricter than "non-numeric = null": any malformed string is treated as null.)
- Empty-row detection: always `record && !record.isEmpty` (madden-franchise per-record flag); no custom heuristics. Row iteration is plain `for (const record of table.records)`.
- Field access is **alias-tolerant**: `findFieldName(record, names[])` first tries `record.getFieldByKey(name)` for each candidate, then case-insensitive match over `record.fieldsArray.map(f => f.key)`. `getRecordValue`/`setRecordValue` wrap this; `setRecordValue` throws if no alias resolves. This exists because of field-name case wobble across schema versions (e.g., `BaseStaffPersonInterestInOffer` vs `BaseStaffPersonInterestinOffer`).
- `resolveTable(franchise, uniqueId, label)` — uniqueId-based table resolution (§1), throwing a descriptive error naming the label. This is a **more robust alternative** to our name+recordCapacity heuristic: `header.uniqueId` is schema-stable and unambiguous even when multiple tables share a name (`Team[]`!).
- Enum plumbing: values arrive as strings; `normalizeEnum` prettifies (`'HeadCoach'` → `'Head Coach'`, underscores → spaces); `isPendingStatus` handles a possible `'X:Pending'` form.
- `numberValue` = parseInt-with-fallback; `clampInteger(min,max)`; `hasUsableString` treats `'none'`/`'null'`/empty as unusable (EA uses the literal string `'none'` for empty name fields).
- `getTableDescriptor(table)` → `{name, tableId, uniqueId, recordCapacity, isArray}` (debug/UI info).

---

## 7. Misc facts & gotchas worth carrying forward

1. **`Franchise.create(path, {gameTypeOverride:'college', gameYearOverride:27, schemaOverride})`** is an alternative, promise-based open pattern; madden-franchise ≥4.3.0 bundles the CFB27 schema at `data/schemas/27/C27_468_2.gz`, so a vendored schema copy may be unnecessary if we depend on ≥4.3.0.
2. Enum reads can return **alias member names** (`'First_Active'` for ContractStatus 0 instead of `'Signed'`; `'NumCollegeCoaches'` possible for position 3). Compare enums by underlying value or accept alias sets.
3. `StaffPersonContractOffer.StaffPerson` is schema-typed `StaffPerson` (base class) but in practice holds Coach-table refs; matching against `coachTable.getBinaryReferenceToRecord(i)` works.
4. Coach↔Team linkage is redundant: Team-side refs (`HeadCoach`/`OffensiveCoordinator`/`DefensiveCoordinator`/`SpecialTeamsCoach`) AND Coach-side (`TeamIndex` + `Position`). The tool trusts the Team side first. Any hire/fire writer we build must keep BOTH sides consistent (this tool avoids the problem by not writing them).
5. Conference membership: `Conference.TeamSlots` → `Team[]` row → `Team0..19` refs; unassigned teams = Independents. There is no `Conference` field on Team.
6. `Team.TeamPrestige` is 0–10 (displayed /2 as stars). `Coach.CoachPrestige` is a LetterGrade enum; `CoachPrestigeScore` 0–10000 is the numeric version — for our "similar-or-better prestige" follow rule, `Team.TeamPrestige` comparison is the natural fit.
7. UI grid teaches the offer semantics: rows grouped by hiring school → position group (HC/OC/DC/ST) with the JobOpening reason pill (Fired/Retired/Went Pro), ranked by `rank` derived from the interest sort; per-row editable Base(0–280)/Adjusted(0–100)/Team(0–100) interest inputs.
8. The tool ships lucide-static icons + `Resources/{teams,conferences}` PNGs; asset lookup is name-slug based (irrelevant to save format).
9. Electron packaging tidbit: excludes Madden schemas from the bundle except CFB27's (`!node_modules/madden-franchise/data/schemas/{19..26}/**`, `!.../27/M27_525_0.gz`) — confirms `C27_468_2.gz` is the only schema CFB27 needs.
10. `NumContractOffers` max is 12 (4 bits) while a coach could theoretically appear in more pending offers; the tool writes the raw count without clamping — UNVERIFIED whether madden-franchise clamps or overflows on >12 (avoid writing >12).

## 8. Direct applicability to CoachCarouselRecruitTool

- **Detecting coaching changes after carousel**: primary source = `JobOpening` rows (`Team`, `Position`, `Reason` = Fired/Retired/Pro/NewJob/ContractEnding, `PrevCoach`, `SelectedCoach`, `Filled`) — but they exist only during/around the carousel window, so snapshot them while `SeasonInfo.IsCarouselPeriodActive` is true (or at the carousel stage) into our own DB before the game clears them. Secondary/late signals: `Coach.ContractStatus` transitional values (PendingFire/PendingRetire/PendingNFL/PendingHire), `COACH_FIREREPORTED`/`COACH_RESIGNREPORTED`, `COACH_LASTTEAMFIRED/RESIGNED`, and simple before/after diffs of `Team.HeadCoach/OffensiveCoordinator/DefensiveCoordinator`.
- **"Coach hired elsewhere" destination**: `JobOpening.SelectedCoach`+`Team` (once `Filled`), or diff of `Coach.TeamIndex`/Team coach refs across saves.
- **Scholarship-space/prestige checks for followers**: `Team.TeamPrestige` (0–10) verified here; recruit-side tables are out of this repo's scope (see notes-force-commit.md).
