# Empirical Save-Dump Notes (Stage 2) — CFB27 Dynasty Saves

Date: 2026-07-14. Author: research subagent (read-only pass — **no `.save()` was ever called; all work done on scratchpad copies**).

## 0. Method, saves, and artifacts

- Library: vendored `madden-franchise@4.3.1` at
  `E:/Games/EA SPORTS College Football 27.SteamGG.NET/Mods/CoachCarouselRecruitTool/reference/force-commit-recruits/node_modules/madden-franchise`
  (package `main` = `dist/index.cjs`; `require(...)` exposes named exports incl. `FranchiseFile`). Opened with the ESTABLISHED-FACTS pattern (`autoParse:true`, `schemaDirectory` + `schemaOverride {major:468, minor:2, gameYear:27}` pointing at `.../force-commit-recruits/engine-data/C27_468_2.gz`), waiting on the `ready` event. Node v26.4.0.
- Scripts written (all read-only):
  - `E:/Games/EA SPORTS College Football 27.SteamGG.NET/Mods/CoachCarouselRecruitTool/research/scripts/dump-save.js` — modes `inventory | sample | scan`.
  - `E:/Games/EA SPORTS College Football 27.SteamGG.NET/Mods/CoachCarouselRecruitTool/research/scripts/commit-analysis.js` — commit-binding walk on a mid-dynasty save.
  - `E:/Games/EA SPORTS College Football 27.SteamGG.NET/Mods/CoachCarouselRecruitTool/research/scripts/misc-checks.js` — user team, league settings, conferences, coach-history aggregates.
- Dumps (cite-able JSON):
  - `research/dumps/tables-inventory.json` — full 2269-table inventory of DYNASTY-QQ.
  - `research/dumps/QQ/*.sample.json`, `research/dumps/QQ/scan.json` — DYNASTY-QQ samples.
  - `research/dumps/QQ2b/*.sample.json`, `research/dumps/QQ2b/scan.json`, `research/dumps/QQ2b/misc-checks.json`.
  - `research/dumps/QQ2/scan.json`, `research/dumps/QQ2/commit-analysis.json`, `research/dumps/QQ2/misc-checks.json`.
- Saves examined (copied to scratchpad first; originals untouched):

| Save | SeasonInfo state | What it evidences |
|---|---|---|
| DYNASTY-QQ | `CurrentStage=PreSeason`, `CurrentWeek=0`, `CurrentYear=0`, `CurrentSeasonYear=2026`, all `Is*PeriodActive=false` except Goals/Scouting/Trading | Brand-new dynasty; fresh recruit class, boards empty |
| DYNASTY-QQ2b | `PreSeason`, wk 0, `CurrentYear=2` (2028), `CurrentOffseasonStage=9` (finished) | One full carousel in history table (SeasonYear 1); boards reset |
| DYNASTY-QQ2 | `OffSeason`, `CurrentWeek=1`, `CurrentYear=3` (2029), `CurrentOffseasonStage=2`, `IsRecruiting/Committment/Signing/Scholarship/TransferPortalNewlyAvailable/TransferSign = true`, `IsCarouselPeriodActive=false` | Mid-recruiting-cycle: 1557 Signed + 32 committed recruits, live boards, year-3 carousel history |

Note: DYNASTY-QQ2 was already present as a scratchpad copy from an earlier stage; it was analyzed in addition to the two requested saves because QQ and QQ2b both turned out to be PreSeason-week-0 saves with freshly generated (uncommitted) recruiting classes. QQ2 is the same user's dynasty two years on and supplied all committed-recruit evidence.

- The full text schema is also extracted at scratchpad `C27_468_2.xml` (from a prior stage) if raw bit widths are needed beyond what is below.

## 1. Table inventory highlights (dumps/tables-inventory.json)

2269 tables in a CFB27 dynasty save. Verified name / uniqueId / tableId(2026-07 build) / capacity / count / hasTable2:

| Table | uniqueId | tableId | cap | nonEmpty (QQ / QQ2 / QQ2b) | t2 | Notes |
|---|---|---|---|---|---|---|
| Recruit | 1873209313 | 4269 | 7600 | 4101 / 4101 / 4101 | no | includes 1 sentinel row 0 (`RecruitStage=Invalid`) |
| RecruitTarget | 59043175 | 4288 | 4870 | 0 / 4795 / 0 | no | AI board entries; EMPTY outside recruiting cycle |
| RecruitTarget[] | 2412159097 | 5847 | 138 | 138 always | no | 35 slots per row; row index == **TeamIndex** |
| RecruitingBoard | 220276943 | 4251 | 138 | 138 | no | per-school wrapper {Recruits→RecruitTarget[], hours} |
| UserRecruitTarget | 3987156317 | 4168 | 1120 | 0 / 31 / 0 | no | HUMAN user's board entries |
| ProspectTargetSchool | 3789266353 | 5840 | 41010 | 41000 | no | {TeamId:int, TeamInfluence:int} — 10 per recruit |
| ProspectTargetSchool (pool #2) | 3843719174 | 5841 | 35000 | 0 | no | spare pool, unused in all three saves |
| ProspectTargetSchool[] | 2332540366 | 5842 | 7600 | 4100 | no | 10 refs per row; row parallel to Recruit usage |
| Player | 1612938518 | 4244 | 16500 | 16257 | yes | recruits AND rostered players share it |
| Coach | 1860529246 | 4173 | 632 | 497 | yes | |
| Team | 3359508968 | 6334 | 143 | 143 | yes | 8 other tiny `Team` tables exist (cap 1–6) — must resolve by uniqueId |
| Conference | 3820706130 | 4291 | 12 | 12 | yes | |
| SeasonInfo | 3123991521 | 4141 | 1 | 1 | no | |
| JobOpening | 263453863 | 4151 | 408 | 0 in all three | no | but QQ2b header `nextRecordToUse=251` → 251 rows were used and re-emptied |
| StaffPersonContractOffer | 674348040 | 4298 | 804 | 1 (junk row 0) in all three | no | |
| StaffPersonContractOffer[] | 4119397260 | 5180 | 408 | 0 in all three | no | second same-name table uid 1063701971 cap 1 |
| **CoachTransactionHistoryEntry** | 2701814500 | 4271 | 300 | 103 (QQ2b) / 135 (QQ2) | no | **complete carousel event log — see §6** |
| Story | 53507767 | 4277 | 250 | 19 (QQ2b) / 0 (QQ2) | yes | news items; cleared in offseason |
| MySchoolTrackingTable | 349376083 | 4218 | 138 | 138 | no | per-school motivation grades (see §8) |
| LeagueSetting | 87558994 | 4310 | 4 | — | yes | record 0 = league settings |
| SchoolOffer | 3367540198 | 4108 | 138 | 0 | no | {Team ref, HasOffer, OfferType, EstimatedPlayerDepth, OfferInterestLevel, AdjustedOVR, InterestLevel} |
| StaffPerson[] | 234515779 | 6332 | 138 | 138 | no | 16 slots per school (staff lists) |
| RecruitSummaryEntry | 3883582324 | 4184 | 640 | 0 | no | {Recruit, UserEntity, TransactionUpdate} |
| UserRecruitTarget[] | — (not dumped) | — | — | — | — | UNVERIFIED whether one exists; user slots are referenced directly from RecruitTarget[] |

Other carousel-adjacent single-row "engine plumbing" tables that exist (all cap 1, no data payload of interest): `CoachCarouselStartEvent`, `CoachCarousel_PostSeasonWeekStartReaction/-EndReaction`, `RecruitingEval_*` reactions, `AddToTransferPortalEvent`, `EarlySigningDayStartEvent`, `NationalSigningDayStartEvent`, `EncourageTransfersStart/EndEvent`, `CreateCoachContractOffersRequest` (cap 32), `CreateCoachResignContractOffersRequest` (cap 140), `MakeContractOffersRequest` (cap 32), `OfferManagementRequest` (cap 32).

There are 179 distinct `Player[]` array tables (rosters, depth charts, award lists...) — always resolve by uniqueId or by following the owning ref (e.g. `Team.Roster` → tableId 6120), never by name.

## 2. Recruit table (uid 1873209313) — dumps/QQ*/Recruit.*.sample.json, scan.json

15 fields (bit widths read live from `offsetTable`):

| Field | Type | Bits | Range | Notes |
|---|---|---|---|---|
| TopSchoolsList | ref → ProspectTargetSchool[] | 32 | | offset 0 |
| Player | ref → Player | 32 | | |
| RecruitStage | enum RecruitStage | 4 | | see enum below |
| NationalRank | int | 13 | 0–4500 | |
| SurnameAudioID | int | | | |
| ProductionGrade | int | | 0–? (obs 90) | |
| PositionRank | int | | | |
| StateRank | int | | | |
| Class | enum RecruitingClass | | | |
| AlternatePosition1/2 | enum DraftPositionE | 5 | | |
| TotalScholarshipOffers | int | 6 | 0–63 | |
| CommitScore | int | 10 | 0–1023 | |
| QualityModifier | enum GemBust | 3 | | |
| RecruitStageAdvance | enum RecruitStageAdvance | 3 | | |

**RecruitStage enum, dumped verbatim from the save schema** (formatted name = index, raw bits in parens):
`First_=0(0000), Top10=1(0000), Top5=2(0001), Top3=3(0010), Battle=4(0011), SoftCommitted=5(0100), HardCommitted=6(0101), Last_=7(0110), Signed=8(0110), Count_=9(0111), Invalid=10(1000)`
- **There is NO 'uncommitted/None/Open' literal. The base/uncommitted state IS `Top10` (raw 0000).** Every one of the 4100 freshly generated recruits in QQ and QQ2b reads `Top10`. `Signed` shares raw value 0110 with alias `Last_`; `First_` aliases `Top10`. A decommit writer should set `RecruitStage` back to `'Top10'` (or Top5/Top3/Battle for a softer effect).
- Observed distribution in QQ2 (mid-cycle, offseason stage 2 wk1): Top10=1965, Top5=363, Top3=154, Battle=29, SoftCommitted=27, HardCommitted=5, Signed=1557, Invalid=1 (sentinel row 0).

**RecruitStageAdvance enum**: `First_=0(000), None=1(000), Advance=2(001), Decommit=3(010), InstantCommit=4(011), Last_=5(011), Count_=6(100), Invalid=7(101)`
- `Decommit` (raw 010) **exists as a first-class engine action** — strong candidate for triggering native decommit processing on week advance (UNVERIFIED behaviorally).
- Observed live values in QQ2: Invalid=4054 (the default resting value — raw 101), Advance=11, None=36. Fresh classes read all-`Invalid`. So the field is a one-shot command consumed by the engine, then left at Invalid/None.

**RecruitingClass enum**: `HighSchool=0(0000), JuniorCollege_Sophomore=1, JuniorCollege_Junior=2, JuniorCollege_Senior=3, Transfer_Freshman=4, Transfer_Sophomore=5, Transfer_Junior=6, Transfer_Senior=7, Count_=8, Invalid=9`. Observed QQ2: HighSchool=3740, JUCO soph/jr/sr = 219/141/1. No Transfer_* rows at offseason stage 2 in this save (portal players presumably enter later or via another path — UNVERIFIED).

**GemBust enum**: `First_=0(000), NORMAL=1(000), GEM=2(001), BUST=3(010), Last_=4(010), Count_=5(011), HIDDEN=6(011), Invalid=7(100)`. QQ shows GEM=277/BUST=246 at preseason; QQ2/QQ2b show all-NORMAL (gem/bust status appears assigned per-class at generation and normalized later — UNVERIFIED semantics).

**Sentinel row 0**: `RecruitStage=Invalid`, null TopSchoolsList, Player ref set, all numerics 0 (dumps/QQ/Recruit sample, row 0). Skip row 0 in scans.

### Empty rows / free list (task 5f)
- QQ: 4101 non-empty of 7600; empties are the contiguous tail 4101–7599; `header.nextRecordToUse=4101`; the library `table.emptyRecords` Map chains `{previous, next}`: 4101→4102→4103…
- QQ2/QQ2b: same non-empty count (4101) but the free list is **scattered** (first physically-empty rows 5, 8, 9, 11, 14 …) and `nextRecordToUse=5064` — i.e. after a season of churn the head of the free chain is mid-table and hops around. **Never assume empties are the tail; use `record.isEmpty` and `header.nextRecordToUse`.**
- On-disk, an empty record's first 32 bits are repurposed as the next-free-row pointer: QQ empty row 4101 reads its first (ref) field raw as `00000000000000000001000000000110` = row 4102 in the low 17 bits. Fields of an empty record still "parse" (garbage/defaults) — `isEmpty` is the only reliable flag.

## 3. Top-schools list: ProspectTargetSchool (task open question — CONFIRMED name)

- `Recruit.TopSchoolsList` → `ProspectTargetSchool[]` (uid 2332540366) row with fields `ProspectTargetSchool0..9` (10 slots, `numMembers=10`).
- Each slot → `ProspectTargetSchool` row (uid 3789266353), exactly two fields: **`TeamId:int` (= Team.TeamIndex, NOT the Team-table row) and `TeamInfluence:int`**.
- 41000 of 41010 rows used = 4100 recruits × 10 — every recruit always has 10 populated slots.
- Slots are sorted by `TeamInfluence` descending; slot 0 = current leader/committed school.
- Real examples (dumps/QQ2/commit-analysis.json):
  - Signed CB "Jordan Jacobs" (Recruit row 5): CommitScore=431; slot0 = TeamId 16 "California" TeamInfluence **561**; slots 1–9 all influence 0 (zeroed once signed).
  - Uncommitted Top10 WR (row 7): CommitScore=459; influences 67, 61, 61, 56, 55, 54, 54, 54, 52, 51 (dense, sorted).
- TeamInfluence scale: same units as `CommitScore` (10-bit, 0–1023). The signed example (561 ≥ 431) is consistent with "commit when leader influence ≥ CommitScore"; mid-race values (67 vs 459) are consistent with force-commit's %-of-CommitScore thresholds. Exact threshold math UNVERIFIED here.
- A second, completely empty ProspectTargetSchool pool (uid 3843719174, cap 35000) exists; no sampled TopSchoolsList slot pointed into it. Validate slot refs against the resolved primary table's tableId (5840 in this build).

## 4. Boards: RecruitingBoard → RecruitTarget[] → RecruitTarget / UserRecruitTarget

Chain (verified in dumps/QQ2/commit-analysis.json):
`Team.RecruitingBoard (ref) → RecruitingBoard row → .Recruits (ref) → RecruitTarget[] row → RecruitTarget0..34 (refs) → RecruitTarget rows`

- **RecruitingBoard** (uid 220276943, 138 rows): `Recruits: ref→RecruitTarget[]`, `RecruitingHoursProcessed:int`, `RecruitingHoursTotal:int` (=350 at QQ2b preseason), `RecruitingHoursAssigned:int`.
- RecruitingBoard row index tracked Team **row** in samples (Team row 0→board 0 … TeamIndex 3/team row 4→board 4), while the `RecruitTarget[]` row it references equals the team's **TeamIndex** (boards 0,1,2,4,5,6,7 → RT[] rows 0,1,2,3,4,5,6; board 3 → RT[] row 125). This re-confirms stage-1's "RecruitTarget[] row == TeamIndex" **but the safe pattern is to follow the refs** (`Team.RecruitingBoard` exists precisely for this) rather than trust either index identity.
- **RecruitTarget[]** (uid 2412159097): 138 rows × **35** slots (`numMembers=35`). At PreSeason (QQ, QQ2b) every row has `arraySize=0` and all-null refs — **boards are wiped between cycles**. In QQ2 (mid-cycle) 4795 RecruitTarget rows are live.
- **User-school detection CONFIRMED**: in QQ2, exactly one RT[] row (row **43**) has slot refs whose tableId is not the RecruitTarget table — 31 refs into **UserRecruitTarget (tableId 4168, uid 3987156317)**. TeamIndex 43 = LSU = the school coached by the sole `Coach.IsUserControlled=true` coach ("Ryan VanVuren", Coach row 433). So the human board's entries live in UserRecruitTarget; AI boards in RecruitTarget.
- **RecruitTarget fields** (20): `ScheduledVisit:ref ActiveVisitInfo`, `Recruit:ref Recruit`, `ActivePitches:ref ActiveRecruitingPitch[]`, `ProspectHoursSpentCurrent:int`, `ProspectInfluenceDelta:s_int`, `UnlockedIntelBitfield:int`, `SendTheHouse:bool`, `VisitRecruitsSchool:bool`, `NILExpectation:int`, `ProspectInfluenceTotal:int`, `ProspectInfluenceTotalLastWeek:int`, `SwayPitch:RecruitingPitchType`, `CurrentScholarshipBonus:s_int`, `OriginalNILExpectation:int`, `CurrentNILOffer:int`, `ContactFriendsAndFamily/ContactHighSchoolCoaches/SearchSocialMedia:bool`, `ScholarshipStatus:ScholarshipStatus`, `CommittedWeekNumber:int`.
- **UserRecruitTarget fields** = same set **plus** `RecruitingFeedback:ref RecruitingActionFeedbackEntry[]`, `ImmediateRecruitingFeedback:ref …`, `IsFavorite:bool`. Structurally editable exactly like RecruitTarget (capacity 1120 = 32 users × 35).
- **ScholarshipStatus enum (full domain — answers open question)**: `First_=0(000), None=1(000), Revoked=2(001), New=3(010), Offered=4(011), Committed=5(100), Last_=6(100), Count_=7(101), Invalid=8(110)`.
  Observed live: QQ2 AI boards None=2031 / Offered=2764; **`Committed` was never observed on any row, even for Signed recruits** (e.g. Signed recruit row 5's winning California entry reads `ScholarshipStatus='None'`, another signed one reads `'Offered'` with `CommittedWeekNumber=13`). `Revoked`/`New` also not observed live.
- **RecruitingPitchType enum (21 real values)**: CollegeExperience=0, TeamPlayer, CampusPersonality, ItsGameTime, Prestigious, StudentOfTheGame, HometownHero, ProveYourself, TheClutch, TVTime, CoachsFavorite, Aspirational, ToTheHouse, FootballInfluencer, TimeToGetToWork, Starter, Grassroots, ConferenceSpotlight, SundayBound, WorkHorse(=Last_ alias), Invalid=23. (Superset of the 10 the force-commit tool writes.)

### Commit binding — empirical answer (task 5b)
- Commitment is **recruit-side**: `RecruitStage ∈ {SoftCommitted, HardCommitted, Signed}` + TopSchools **slot 0 TeamId** = the school. Verified on sampled rows: every Signed/HardCommitted sample's slot-0 school matches the board(s) recruiting them hardest.
- Board entries are NOT authoritative: a SoftCommitted recruit (row 674) had **zero** RecruitTarget rows anywhere; HardCommitted recruits still sat on 2 different schools' boards simultaneously (rows 942, 3820, 4019 — e.g. Liberty influence 349 + Nevada 294); `CommittedWeekNumber` was 0 on most committed entries (only one Signed sample had 13); winning-school entries can read ScholarshipStatus `None`.
- **Signed ≠ rostered**: all 400 checked Signed recruits' `Player.TeamIndex` = **255** (free agent) — roster assignment happens later (season rollover). A signed recruit's slot0 influence stays high (561) while slots 1–9 zero out.
- Gotcha for protection heuristics: a **Signed** recruit had `TotalScholarshipOffers=0` — the field is not a lifetime counter; do not rely on it alone to detect "recruited" players post-signing.

## 5. Coach table (uid 1860529246) — task 5a (dumps/QQ/Coach.*.sample.json, scan.json)

- 497/632 non-empty, 136 fields, table2-backed strings.
- **Coach→team link**: `TeamIndex:int` (e.g. 77, 83, 61, 91) joining `Team.TeamIndex`. Unemployed = UNVERIFIED (likely 255 like `PrevTeamIndex=255` default; the 68 FreeAgent coaches were not sampled for TeamIndex).
- **Role**: `Position` enum CoachPosition — full dump: `First_=0(00000000), HeadCoach=1(00000000), OffensiveCoordinator=2(00000001), DefensiveCoordinator=3(00000010), NumCollegeCoaches=4(00000011), SpecialTeams=5(00000011), Owner=6(00000100), Scout=7(00000101), Trainer=8(00000110), GeneralManager=9(00000111), PlayerPersonnel=10(00001000), Max_=11(00001001), Invalid_=12(11111111)` (raw values: HC=0, OC=1, DC=2, ST=3 — formatted names are what the library returns; `NumCollegeCoaches` is the alias returned for raw 3).
  QQ distribution: HeadCoach=144, OC=161, DC=191, NumCollegeCoaches=1 (a single shared placeholder Special-Teams coach — every sampled Team's `SpecialTeamsCoach` ref points to the SAME Coach row 375).
- **Names**: `FirstName`/`LastName`/`Name` ("S. Abell") — table2 strings.
- **Prestige**: `CoachPrestige` LetterGrade (obs. Aplus…F + a bogus `COUNT`=4 rows) and `CoachPrestigeScore:int` (obs. 0–660; schema max 10000).
- **Alma mater**: `AlmaMater` is an **int** (obs. 88, 42, 67, 103) — UNVERIFIED which id space, plausibly TeamIndex.
- **Contract/security**: `ContractStatus` StaffPersonContractStatus (full enum with aliases: `First_Active=0(0000), Signed=1(0000), Expiring=2(0001), First_Pending=3(0010), PendingFire=4(0010), PendingNFL=5(0011), PendingRenewal=6(0100), Last_Active=7(0101), PendingRetire=8(0101), Last_Pending=9(0110), PendingHire=10(0110), FreeAgent=11(0111), Retired=12(1000), Deleted=13(1001), None=14(1010)`) — QQ live: First_Active=429, FreeAgent=68. **The library returns alias names**: raw 0000 reads back `First_Active` (not `Signed`), raw 0010 → `First_Pending` (=PendingFire), raw 0101 → `Last_Active` (=PendingRetire), raw 0110 → `Last_Pending` (=PendingHire). Compare on raw index sets, not single names.
  `ContractYearsRemaining`, `ContractLength`, `CurrentJobSecurityPercentage` (0–100, obs. 25–99), `CurrentJobSecurityStatus` (Safe=344/SafeForNow=46/Low=40/HotSeat=63/Invalid=4 in QQ).
- **Useless-as-signal**: `COACH_FIREREPORTED` and `COACH_RESIGNREPORTED` are `true` for ALL 497 coaches in a brand-new dynasty — they are not change flags in practice. `COACH_LASTTEAMFIRED/RESIGNED/LASTCONTRACTTEAM` all 0 in QQ (no events yet); their id space remains UNVERIFIED.
- Misc: `Level` (obs. 11–39), `SeasonsWithTeam`, `YearsCoaching`, `Age`, `PrevPosition` (=`Invalid_` when never moved), `PrevTeamIndex` (=255 default), `IsUserControlled` (exactly 1 true), `NumContractOffers` (all 0 outside carousel). Ref fields include `OffensiveScheme`/`DefensiveScheme` (refs into a table2-range id 16433 that `getTableById` does NOT resolve — see gotchas), playbooks, `CharacterVisuals`, `SeasonStats`, `CareerStats`, `ActiveTalentTree`, `WeeklyGoals`, `ContractYearSummaries`.

## 6. CoachTransactionHistoryEntry (uid 2701814500) — task 5e ★ the carousel history table

12 fields: `Coach:ref Coach`, `OldTeam:ref Team`, `NewTeam:ref Team`, `OldCoachPosition:CoachPosition`, `NewCoachPosition:CoachPosition`, `TransactionId:int`, `SeasonStage:SeasonStage`, `SeasonYear:int`, `SeasonWeek:int`, `ContractSalary:int` (always 0 obs.), `ContractLength:int`, `ContractStatus:StaffPersonContractStatus`.

Row 0 is a null sentinel (all-zero refs, TransactionId 0). Real rows encode every carousel outcome:

| Pattern (OldTeam / NewTeam / ContractStatus raw) | Meaning | QQ2b yr-1 count | QQ2 yr-3 count |
|---|---|---|---|
| oldTeam / — / `First_Pending` (=PendingFire) | **Fired** | 32 | 55 (28 HC, 13 OC, 14 DC) |
| oldTeam / newTeam / `Last_Pending` (=PendingHire) | **Hired away / moved schools** (incl. promotions: DC→HC ×9, OC→HC ×7; demotions HC→OC ×3, HC→DC ×2; lateral HC→HC ×17) | 42 | 46 |
| — / newTeam / `Last_Pending` | **Hired from unemployed pool** (incl. cross-role OC→DC ×6, DC→OC ×6, DC→HC ×1) | 28 | 26 |
| oldTeam / — / `PendingNFL` | **Left for the NFL** | 0 | 2 |
| oldTeam / — / `Last_Active` (=PendingRetire) | **Retired** | 0 | 5 |

- Old/NewCoachPosition give the exact role change (promotion tracking for free).
- Stamps: all real rows are `SeasonStage='NFLSeason'` (raw 01 — the regular+post season stage; SeasonStage enum = `PreSeason=0(00), NFLSeason=1(01), OffSeason=2(10)`), `SeasonWeek` 15–20 (bulk at 18), `SeasonYear` = the dynasty year whose season just ended.
- **Persistence**: QQ2b (year-2 PreSeason) still holds ALL of year 1's 102 transactions; QQ2 (year-3 OffSeason stage 2) holds ONLY year-3 rows (135) — i.e. the table is cleared/rewritten at each new carousel and survives through the entire following offseason+season. **This is a far better data source for our coaching-change tracker than JobOpening**: it persists into the transfer-portal window (offseason stage 2) where our tool runs, while JobOpening is already empty there.
- What it does NOT carry: an explicit fired-vs-resigned-vs-contract-expired distinction beyond the ContractStatus patterns above (JobOpening.Reason has the richer `CoachLeaveReason` enum: `None=0, Fired=1, Retired=2, Pro=3, NewJob=4, ContractEnding=5` — but only during the carousel window).

Timeline model (empirical): dynasty year N = `PreSeason`(wk0) → `NFLSeason` wks 0–20 (regular season last scheduled wk 15, conf champ wk16, 4 postseason wks; **carousel fires during wks 15–20** and writes this table; `JobOpening`/offer tables populate then too) → `OffSeason` stages 0–8 (recruiting/portal flags all true at stage 2; `JobOpening` rows already emptied — QQ2b's JobOpening header `nextRecordToUse=251` proves 251 rows were used and freed) → year N+1 `PreSeason` (boards wiped, new recruit class present).

## 7. Team (uid 3359508968) — task 5c

408 fields; 143/143 rows. Verified:
- Identity: `LongName` ("Air Force"), `DisplayName`, `ShortName` ("AFA"), `NickName` ("Falcons"); table2 strings.
- **`TeamIndex != row`** (Team row 35 = FIU has TeamIndex 115). All joins (Coach.TeamIndex, ProspectTargetSchool.TeamId, RecruitTarget[] row) use **TeamIndex**.
- Prestige: `TeamPrestige:int` 0–10 (Air Force 3, California 6, LSU 7→9 across saves — it moves year to year), `PrestigeRank:int` (1=best; LSU 24/10), `TeamPrestigeBias:int` max 50 (obs 0), `PrestigeDisplay:string` (empty in saves), `TeamRank`. For the follow-coach rule, `TeamPrestige` (coarse, 0–10) + `PrestigeRank` (fine, unique-ish ordering) are both live and populated.
- Coach back-refs verified live: `HeadCoach`/`OffensiveCoordinator`/`DefensiveCoordinator`/`SpecialTeamsCoach` → Coach rows (ST is the shared row-375 placeholder for every sampled team).
- Other verified refs: `Roster → Player[]` (tableId 6120), `RecruitingBoard → RecruitingBoard` table, `MySchoolTrackingTable → MySchoolTrackingTable` (uid 349376083), `SchoolPipelineInfluenceList → SchoolPipelineInfluence[]`. Note the ref target rows do NOT track TeamIndex or Team row consistently (FIU team row 35 → Roster row 38, MySchool row 30) — always follow refs.
- Conference membership via `Conference.TeamSlots` confirmed live (dumps/QQ2/misc-checks.json): 12 rows — ACC(16 incl. Fresno State & UTSA — this dynasty has custom realignment), American(12), Big 12(14), Big Ten(16 incl. Notre Dame & Colorado), CUSA(10), one empty-name row with 0 teams, Independent(1: UConn), MAC(13), MWC(10 incl. NDSU), Pac-12(16), SEC(16), Sun Belt(14).

## 8. MySchoolTrackingTable (uid 349376083) — open question answered

138 rows, 36 fields. Per-school recruiting-motivation data, far beyond ten grades:
- Grades (LetterGrade enum): `AcademicPrestigeGrade, AthleticFacilitiesGrade, BrandExposureGrade, CampusLifestyleGrade, ChampionshipContenderGrade, CoachPrestigeGrade, CoachStabilityGrade, ConferencePrestigeGrade, ProgramTraditionGrade, StadiumAtmosphereGrade` + positional `ProPotentialGradeQB/RB/WR/TE/OL/DL/LB/DB/K/P`.
- Numerics: `AthleticFacilitiesScore` (obs 510), `CampusLifestyleScore`, `BrandExposureNationalTVWins/Played`, `BrandExposureStreamingWins/Played`, `BrandExposureGamesOfTheWeekWins/Played`, `ChampionshipContenderCurrentYearRank`, `...YearPlus1/2/3Rank`.
- Refs: `PlayingStyleStatByPlayerTypeTable`, `PlayingStyleRankByPlayerTypeTable`, `PlayingStyleGradeByPlayerTypeTable` (record refs).
- **`CoachStabilityGrade` and `CoachPrestigeGrade` are the game's own post-carousel recruiting inputs** — our tool could read (or even adjust) these to align with its decommit logic; row 0 example: CoachStabilityGrade=F, CoachPrestigeGrade=Dplus.

## 9. Player (uid 1612938518) — 16257/16500 rows, 282 fields

- Recruits and rostered players share the table. Recruit-linked rows have `TeamIndex=255`; rostered have a real TeamIndex (e.g. row 8128 → 35).
- `Position` returns member names like WR/QB/TE/ROLB — **depth-chart-style names (ROLB) DO appear on real rows**, so position→offense/defense mapping must handle the full PositionE domain, not just 0–18.
- `ProspectStarRating`: ONE_STAR…THREE_STAR observed; `SchoolYear`: Freshman/Sophomore/Senior observed; `RedshirtStatus`: Eligible/Previous observed.
- `RecruitingDealbreaker` live values observed: ProximityToHome, CoachPrestige, PlayingStyle, BrandExposure, ChampionshipContender, ProPotential; **`Invalid` on non-recruit veterans**. `IdealRecruitingPitch` live: Grassroots, Aspirational, HometownHero, TimeToGetToWork; `Invalid` on veterans.
- `PLYR_HOME_STATE` formatted values confirmed live (Texas, Wisconsin, Virginia…); `HomePipeline` live values include state names AND sub-state regions ("Tidewater", "SouthFlorida") and `Invalid` (confirming the region enum includes Invalid).
- **Gotcha**: `Motivation1/2/3` read back as the RAW 4-bit string `'0000'` on every sampled row — the schema enum apparently has no member matching the stored value, so madden-franchise falls back to the raw bit string. Any motivation-based logic must handle raw-bits fallback (or write via unformatted values).
- `IsNIL=true` example seen (row 13547, Age reads 0 on that row — NIL placeholder rows have odd data). `BaseNILValue` can be negative (obs. -20; it's the stored offset noted in stage 1).

## 10. Carousel tables in-window state (JobOpening / offers)

- All three saves: `JobOpening` 0 rows, `StaffPersonContractOffer` exactly 1 junk row (row 0: all refs null/zero, `Status='Pending'` raw, `ContractPosition='Invalid_'`, `ContractExpectationsByYear` → an `enum[]` table), `StaffPersonContractOffer[]` 0 rows. This **re-confirms**: these tables live only during weeks 15–20 of the season; by offseason stage 2 (our tool's window) they are gone. QQ2b's JobOpening `nextRecordToUse=251` is residual proof of in-window usage (≈251 openings in year 1's carousel).
- Field bit widths verified live (JobOpening): Team/SelectedCoach/PrevCoach/InterestedUserTeamsList/ContractOfferList = 32-bit refs; `Filled`/`IsEmergentJobOpening` 1-bit bools; `Position` 8-bit CoachPosition; `FinalContractProgramPoints`/`HighestOfferedProgramPoints` 11-bit 0–2000; **`Reason` is stored 32-bit** (not 3-bit) with enum CoachLeaveReason.
- Offer row widths confirm stage 1 exactly: Length 5b 0–15, OfferedContractProgramPoints 11b, ExperiencePoints 16b 0–50000, Status 3b ContractOfferStatus, Base/TeamInterest 9b 0–280, ExpectedContractProgramPoints 11b, OfferIndex 17b 0–6, AdjustedStaffPersonInterestInOffer 7b 0–100, ContractPosition 8b.

## 11. SeasonInfo (uid 3123991521) — task 5d

59 fields; all three saves dumped (dumps/*/SeasonInfo.*.sample.json). Values observed:

| Field | QQ | QQ2b | QQ2 |
|---|---|---|---|
| CurrentStage / CurrentWeekType | PreSeason / PreSeason | PreSeason / PreSeason | OffSeason / OffSeason |
| CurrentWeek | 0 | 0 | 1 |
| CurrentYear | 0 | 2 | 3 |
| CurrentSeasonYear | 2026 | 2028 | 2029 |
| BaseCalendarYear | 2026 | 2026 | 2026 |
| CurrentOffseasonStage | 0 | 9 | 2 |
| NumberOffseasonAdvances | 0 | 0 | 2 |
| IsCarouselPeriodActive | false | false | false |
| IsRecruiting/Committment/Signing/Scholarship PeriodActive | false | false | **true** |
| IsTransferPortalNewlyAvailable / IsTransferSignPeriodActive | false | false | **true** |
| HSRecruitingCurrentMaxTopSchools | 10 | 10 | 10 |

- `CurrentSeasonYear = BaseCalendarYear + CurrentYear` holds in all three.
- `CurrentOffseasonStage=9` (=OffseasonNumStages) at the following PreSeason = "offseason complete"; stage counter is not reset until the next offseason.
- The transfer-portal/recruiting window our tool targets is `CurrentStage='OffSeason'` + `CurrentOffseasonStage=2` + the four recruiting bools true. `IsCarouselPeriodActive` is false there — carousel is long over. Constants: RegularSeasonLastWeekScheduled=15, ConfChampWeek=16, PostSeasonNumWeeks=4, OffseasonNumStages=9, MaxYears=30.

## 12. LeagueSetting (uid 87558994, record 0)

141 fields. Verified values (QQ2/QQ2b): `CoachXPSpeedSetting='Normal'` — full enum **CoachExperienceSpeed: `Slowest=0(000), Slower=1(001), Normal=2(010), Faster=3(011), Fastest=4(100), Count=5(101)`** (answers open question). `CoachFiring='ON'` — enum CoachFiringSetting `ON=0, CPU_ONLY=1, OFF=2` (relevant: if OFF, no carousel firings). Also present and relevant: **`IsRecruitFlippingEnabled:bool`**, `CoachStartingLevel`, `IsAllowCoachRespecEnabled`, `SkillLevel='HEISMAN'` (GameSkillLevelSetting), `LeagueType='ALL'`, `FranchiseStyle='Classic'`. Every setting has a paired `*Description` string field.

## 13. Story table (uid 53507767) — news

34 fields incl. `Tag:string` (full sentence), `Header:string`, `Category:NewsCategory`, `Team:ref`, `Character1..4:ref` (can point to Player OR other tables), `SeasonYear/SeasonWeek/CurrentStage`, `IsTopStory/IsBreaking/IsNew`, `Priority`, HubPanel* enums. QQ2b (preseason): 19 rows, all preseason All-American announcements. QQ2 (offseason): 0 rows — stories are purged; not a reliable carousel history source. `next=61` in QQ2b shows heavy in-season churn.

## 14. Empirically answered stage-1 open questions

1. **Uncommitted RecruitStage literal**: none exists — `Top10` (raw 0) is the uncommitted base state; full enum dumped (§2).
2. **RecruitStageAdvance domain**: None/Advance/Decommit/InstantCommit/Invalid — `Decommit` exists; resting value is `Invalid`; live `Advance`/`None` observed (§2).
3. **ScholarshipStatus domain**: None/Revoked/New/Offered/Committed/Invalid; live saves only ever show None/Offered (§4).
4. **ProspectTargetSchool**: literal table name confirmed; `{TeamId, TeamInfluence}`; TeamInfluence shares the CommitScore 0–1023 scale; slot0=leader; signed recruits keep slot0 influence, others zeroed (§3).
5. **User boards**: `UserRecruitTarget` (uid 3987156317), referenced from the user school's RecruitTarget[] row; same fields + IsFavorite/feedback refs; structurally editable identically (§4).
6. **MySchoolTrackingTable**: full 36-field structure incl. CoachStabilityGrade/CoachPrestigeGrade and much more (§8).
7. **CoachXPSpeedSetting values**: Slowest/Slower/Normal/Faster/Fastest (§12).
8. **JobOpening/offer clearing**: rows exist only during carousel (season weeks 15–20); already empty by offseason stage 2; JobOpening free-list head (251) is residue. Tools running at the portal stage MUST use CoachTransactionHistoryEntry instead — it persists until the next carousel (§6, §10).
9. **Recruits vs players**: recruits live in the same `Player` table (TeamIndex 255) linked from the separate `Recruit` table; same PositionE enum, and depth-chart-style names (ROLB) appear on real rows (§9).
10. **Coach staff assignment table**: `Team.HeadCoach/OC/DC/SpecialTeamsCoach` refs verified live against Coach rows; plus `StaffPerson[]` (uid 234515779, 138 rows × 16 slots) exists as per-school staff lists (contents unsampled — UNVERIFIED).
11. **Region enum includes Invalid**: confirmed — live `HomePipeline='Invalid'` values (§9).
12. **uniqueId stability across patches**: consistent between all three saves of this build and stage-1's uid list (Team 3359508968 etc.); cross-patch stability still UNVERIFIED.
13. **PLYR_HOME_STATE / prestige fields / TeamInfluence semantics**: live values documented (§7, §9, §3). Exact commit-threshold math still UNVERIFIED.
14. Still open: JobOpening in-window row lifecycle details (needs a save captured during weeks 15–20 — none of the user's current saves is in-window); whether the engine reads OfferIndex vs array order; whether writes >100 to TeamInterest are safe; NumContractOffers overflow behavior; whether writing `RecruitStageAdvance='Decommit'` triggers native decommit processing on advance (top candidate for our decommit mechanism — needs a write test on a scratch copy).

## 15. Gotchas collected

- `getTableById` fails to resolve ref tableIds ≥ ~16000 (e.g. Coach.OffensiveScheme → tableId 16433): these are refs into table2/blob space, not row tables. Treat unresolvable tableIds as opaque.
- Formatted enum reads return ALIAS member names for shared raw values (`First_Active` for Signed, `Last_Pending` for PendingHire, `NumCollegeCoaches` for SpecialTeams, `First_`/`Last_` markers). Normalize via raw index sets, never string-equality against a single name.
- `Motivation1/2/3` return raw `'0000'` bit strings (unmatched enum) — handle non-name reads.
- Empty records still expose parseable fields (their first 32 bits are the free-list next pointer); ALWAYS gate on `record.isEmpty`, and remember free rows can be scattered mid-table with `header.nextRecordToUse` pointing anywhere.
- Row-0 sentinels exist in Recruit, StaffPersonContractOffer, CoachTransactionHistoryEntry (all-zero refs) — skip them.
- 9 tables named `Team`, 179 named `Player[]`, 2 named `ProspectTargetSchool`, 3 named `StaffPersonContractOffer[]`-family — resolve strictly by `getTableByUniqueId`.
- Conference list contains an empty-name row and this dynasty has custom realignment — don't hardcode conference compositions.
- A Signed recruit can have `TotalScholarshipOffers=0` and winning-board `ScholarshipStatus='None'`; `CommittedWeekNumber` is usually 0 even for committed recruits. None of these are reliable commit indicators — only `RecruitStage` + TopSchools slot0 are.
- Inventory dump of ALL tables takes ~15 s; `Player.readRecords()` (16.5k × 282 fields) is the slow path (~tens of seconds) — read selective fields where possible.
