# Save-Diff Research Notes — How CFB27 Dynasty State Evolves Across Saves

Stage-3 empirical research: five of the user's real dynasty saves were copied to the scratchpad
(read-only; `.save()` never called) and diffed logically with
`research/scripts/diff-saves.js` (vendored `madden-franchise` 4.3.1, schema C27_468_2).
Full machine-readable output: `research/dumps/save-diff.json`.
Follow-up probe: `research/scripts/probe-stale-carousel.js`.

Everything below is **observed on real saves** unless prefixed UNVERIFIED.

---

## 0. The five saves and where they sit on the dynasty timeline

All are the same dynasty (user coach "Ryan VanVuren", Coach row 433, `IsUserControlled=true`).

| Save | CurrentStage | CurrentWeekType | CurrentWeek | CurrentSeasonYear | CurrentYear | CurrentOffseasonStage | NumberOffseasonAdvances | User team |
|---|---|---|---|---|---|---|---|---|
| DYNASTY-QQ | PreSeason | PreSeason | 0 | 2026 | 0 | 0 | 0 | Arizona State (TeamIndex 4) |
| DYNASTY-QQ-AUTOSAVE | PreSeason | PreSeason | 0 | 2026 | 0 | 0 | 0 | Arizona State |
| DYNASTY-QQ2b | PreSeason | PreSeason | 0 | 2028 | 2 | **9** | 0 | Arizona State |
| DYNASTY-QQ2b-PIPELINES-… | PreSeason | PreSeason | 0 | 2028 | 2 | 9 | 0 | Arizona State |
| DYNASTY-QQ2 | **OffSeason** | OffSeason | **1** | **2029** | **3** | **2** | **2** | **LSU (TeamIndex 43)** |

Chronology: QQ (dynasty start, preseason 2026) → QQ2b (preseason 2028, one game-save later re-exported by the
Pipeline tool as the PIPELINES file) → QQ2 (offseason after the 2028 season = "2029" offseason, stage 2 =
transfer-portal stage — **exactly the stage our tool targets**). Between QQ2b and QQ2 a full coaching carousel
ran (the user himself moved Arizona State → LSU).

---

## 1. Calendar encoding (SeasonInfo, uniqueId 3123991521, exactly 1 record)

### 1.1 Fields that actually move between saves

- `CurrentStage` (enum string): observed `PreSeason`, `OffSeason`. (`CurrentWeekType` mirrors it in these saves.)
- `CurrentWeek` (int): 0 in preseason, 1 at offseason stage 2 week 1.
- `CurrentYear` (int): dynasty year counter starting at 0. Observed 0 / 2 / 3.
- `CurrentSeasonYear` (int): **= BaseCalendarYear + CurrentYear** (2026 + 3 = 2029). `BaseCalendarYear` = 2026, constant.
- `CurrentOffseasonStage` (int 0..9): **0** in a dynasty that has never entered an offseason; **2** during the
  transfer-portal stage; **9 (= OffseasonNumStages) persists through the following preseason** — i.e. a
  *completed* offseason parks the counter at 9; it does not reset to 0 until (UNVERIFIED exactly when) the next
  offseason begins.
- `NumberOffseasonAdvances` (int): 2 at offseason stage 2; **reset to 0 by preseason**. Tracks advances within the
  current offseason only.
- `IsAnnualAwardPeriodActive`: false at dynasty start, true from the first offseason onward (still true in
  the 2028 preseason and 2029 offseason).

### 1.2 Period flags at offseason stage 2 (the transfer-portal stage) — QQ2, verbatim

True: `IsCommittmentPeriodActive, IsTransferPortalNewlyAvailable, IsTransferSignPeriodActive,
IsVisitingPeriodActive, IsSigningPeriodActive, IsScholarshipPeriodActive, IsRecruitingPeriodActive,
IsPitchingPeriodActive, IsScoutingPeriodActive, IsAnnualAwardPeriodActive, IsTradingActive, IsLeagueStarted`

False (notably): `IsCarouselPeriodActive, IsStaffHiringPeriodActive, IsStaffHiringCreateOfferPeriodActive,
IsStaffHiringEvaluateOfferPeriodActive, IsGoalsPeriodActive, IsCoachDemandReleasePeriodActive`

In both preseason saves the recruiting/transfer flags above are all **false** and `IsGoalsPeriodActive` +
`IsFacilitiesIncludedInFreeingUpBudgetPeriodActive` are **true**.

Full 60-field dumps of the SeasonInfo row for every save are in `save-diff.json → snapshots.<save>.calendar`.

### 1.3 Season-structure fields are NOT constants

`RegularSeasonLastWeekScheduled` = 15 and `RegularSeasonWeekConferenceChampionship` = 16 in year 0, but
**14 / 15** in years 2–3 of the same dynasty. Do not hardcode these.

`IsCarouselPeriodActive` was false in all five saves (none was captured inside the carousel window), so its
in-window value remains UNVERIFIED-by-us (stage-1 code analysis says it flips true during the window).

---

## 2. What a carousel does to the save (QQ2b → QQ2, one full carousel apart)

### 2.1 Team side — Team (uid 3359508968) HC/OC/DC refs ARE rewritten by the engine

**106 of 143 Team rows changed HC, OC, DC and/or TeamPrestige** across the carousel. Examples (a = QQ2 2029, b = QQ2b 2028):

```json
{"team":"Alabama","hc":{"a":"Lane Kiffin","b":"Kalen DeBoer"},"prestige":{"a":8,"b":10}}
{"team":"Arizona State","hc":{"a":"Mick Cloud","b":"Ryan VanVuren"},"prestige":{"a":9,"b":8}}
{"team":"LSU","hc":{"a":"Ryan VanVuren","b":"Lane Kiffin"},"oc":{"a":"Sonny Cumbie","b":"Charlie Weis Jr."},"dc":{"a":"Ryan Walters","b":"Blake Baker"},"prestige":{"a":7,"b":9}}
{"team":"Oregon","hc":{"a":"Kalen DeBoer","b":"Dan Lanning"}}
```

Answer to stage-1 open question "does the engine update Team.HeadCoach/OC/DC and Coach.TeamIndex/Position?":
**yes — by offseason stage 2 all hires are fully propagated on both the Team side and the Coach side, and no
coach is left in a Pending\* ContractStatus** (0 pending in every save). The engine also rewrites
`TeamPrestige` for dozens of schools every season (Alabama 10→8, Tennessee 9→10, Buffalo 3→1, …) — the
"similar-or-better prestige" follow-coach rule must read live values.

### 2.2 Coach side — Coach (uid 1860529246, cap 632, 497 in use in every save)

204 of 497 coach rows changed between QQ2b and QQ2. **Coach row index is a stable identity within a dynasty**
(Ryan VanVuren is row 433 in all five saves; Kiffin row 222, DeBoer row 108, etc.).

Observed movement signature (QQ2 = immediately after a carousel, at offseason stage 2):

| Situation | ContractStatus | TeamIndex | PrevTeamIndex | SeasonsWithTeam | Example |
|---|---|---|---|---|---|
| Hired elsewhere this carousel | `First_Active` (alias of Signed=0) | new team | **old team** | **0** (34 movers show 1, see below) | Kiffin: Team 2 (Bama), Prev 43 (LSU), sw 0 |
| Left / not rehired ("fired or contract ended") | `FreeAgent` | **255** | old team | 0 | Tim Albin: Prev 126 (Charlotte) |
| Retired this offseason | `Retired` | 255 | **team retired from** | NOT reset (2–5) | Steve Cooper: Prev 17 (UCF), sw 5 |
| Never moved | `First_Active` | team | **255** | ≥1 | DeBoer in QQ2b: Prev 255, sw 4 |

Counts in QQ2: 46 active coaches with `SeasonsWithTeam==0 && PrevTeamIndex!=255` (+34 with sw==1),
19 FreeAgents with PrevTeamIndex set, 7 Retired. In QQ (year 0, pre-carousel-ever):
**zero coaches have PrevTeamIndex != 255** — 255 is the pristine "never moved / unassigned" sentinel.

- UNVERIFIED: why 34 movers show `SeasonsWithTeam==1` right after the carousel (DeBoer, Mullen). Best guess:
  they were hired during the **in-season** carousel window (weeks 14–16) and the counter ticked at season end,
  while `SeasonsWithTeam==0` movers were hired in the offseason wave. Either way `PrevTeamIndex != 255` is the
  reliable marker; treat sw <= 1 as "moved in the most recent cycle".
- `PrevTeamIndex` persists at least through the following preseason (Cumbie still had Prev 41 = La Tech in the
  2028 preseason, from the previous carousel) — UNVERIFIED when/if it ever resets to 255; it is overwritten by
  the next move.
- `PrevPosition` is set for movers (usually old role) but is noisy: many coaches who did NOT move got
  `PrevPosition=HeadCoach` stamped between the 2028 preseason and 2029 offseason (contract renewals?
  UNVERIFIED), and some genuine movers show `PrevPosition=Invalid_`. Use PrevTeamIndex, not PrevPosition,
  as the primary marker.
- FreeAgents' `Position` is unreliable as "last role held" (Piatkowski: Akron OC → FA with Position=HeadCoach).
- Retirement: `ContractStatus='Retired'` rows exist at offseason stage 2 (7 of them). Whether they persist into
  the next season or get recycled to FreeAgent/Deleted is UNVERIFIED (our only later-than-carousel save is
  QQ2 itself).

### 2.3 Dead fields — answers to stage-1 open questions

- `COACH_FIREREPORTED` and `COACH_RESIGNREPORTED` are **true for all 497 coaches in every save** (even the
  never-carouseled year-0 save). Useless as signals.
- `COACH_LASTTEAMFIRED` / `COACH_LASTTEAMRESIGNED` are **0 for every coach in every save**. Dead fields in CFB27.

### 2.4 JobOpening / StaffPersonContractOffer lifecycle

Non-empty rows in all five saves: **0 JobOpening rows, 1 junk Pending offer row**. But the free-list headers
tell the real story:

| Save | JobOpening next/cap | Offer next/cap | OfferArr next |
|---|---|---|---|
| QQ (no carousel ever) | **0**/408 | 1/804 | 0 |
| QQ2b (after 2027→28 carousel) | **251**/408 | 170/804 | 251 |
| QQ2 (after 2028→29 carousel) | **235**/408 | 73/804 | 235 |

So the carousel allocates ~235–251 JobOpening rows (and an equal number of 6-slot offer arrays) per cycle and
**frees them all before offseason stage 2**. `nextRecordToUse` is the free-list head, not a monotonic
counter — but it *changes value* after every carousel (251 ≠ 235), and it is 0 only if a carousel has never run.

**Freed rows retain stale data.** In QQ2, 252 freed JobOpening rows still read (mixing the last two carousels,
since 235 < 251 the tail rows 235–250 are one cycle older):

- `Reason` histogram: `ContractEnding` 119, `Fired` 99, `Retired` 13, `Pro` 11, `NewJob` 10 — all six
  CoachLeaveReason values confirmed live in real data.
- `Position` histogram: HeadCoach 90, OC 73, DC 89.
- `Filled` = true on all used rows; `IsEmergentJobOpening` = false on all sampled rows.
- **The `Team` ref is destroyed** — it is the record's first 32 bits, which the free-list overwrites with the
  next-empty-row pointer (row 0 reads `…0001`, row 1 `…0010`, …). Stale rows cannot be attributed to teams.
- **`PrevCoach` and `SelectedCoach` read as all-zero (null) in every stale row** — either cleared by the engine
  on resolution or never persisted. A cross-check scanning all 408 rows for refs to known movers (DeBoer,
  Kiffin, VanVuren, Mullen) found nothing. So post-window, JobOpening gives aggregate stats only, not
  who/where.
- Freed offer rows keep `Status`: histogram over all 804 rows = Pending 389 / Accepted 415.

**Consequence for our tool:** to know *which team lost which coach and why*, either (a) snapshot JobOpening
while `IsCarouselPeriodActive` / rows are non-empty (weeks 14–16), or (b) reconstruct movement from the Coach
table signature of §2.2, which works fine at the transfer-portal stage but cannot distinguish Fired vs
ContractEnding vs NewJob for departures (Retired and Pro are distinguishable via ContractStatus).

---

## 3. Recruits across the season

Recruit table: resolved **by name, exactly one table** — `Recruit`, **uniqueId 1873209313**, tableId 4269
(same id in all five saves), recordCapacity 7600.

| Save | non-empty Recruit rows | byStage | Recruit next/cap | RecruitTarget next/cap |
|---|---|---|---|---|
| QQ (pre 2026) | 4101 | Top10 4100, Invalid 1 | 4101/7600 | **0**/4870 |
| QQ2b (pre 2028) | 3467 | Top10 3466, Invalid 1 | 5064/7600 | (not captured) |
| QQ2 (off stage 2) | 270 | **Signed 106, Top10 128, Top5 23, Top3 9, Battle 1, SoftCommitted 2, Invalid 1** | 488/7600 | 4795/4870 |

- **A fresh recruiting class spawns with every recruit at `RecruitStage='Top10'`** — that is the "uncommitted"
  literal at class creation (answers a stage-1 open question; deeper stages Top5/Top3/Battle appear as
  recruiting progresses). Row 0 is a junk `Invalid` row in every save.
- `RecruitStageAdvance` at rest: `Invalid` 266 / `Advance` 2 / `None` 2 in QQ2 — `Invalid` is the normal parked
  value; `Advance`/`None` linger on a few rows.
- By offseason stage 2 most of the signed class's rows are already **freed** (nextRecordToUse dropped from 5064
  to 488); the 106 remaining `Signed` rows still carry full data.
- Commit binding confirmed on live data: every sampled Signed recruit's `TopSchoolsList` slot 0 is the signing
  school, with `TeamInfluence >= CommitScore` (e.g. Pete Sails IV: CommitScore 771, slot-0 TeamId 4 = Arizona
  State, TeamInfluence 1000; Jordan Jacobs: 431 vs 561). `TeamId` stores TeamIndex, not Team row.
- `byClass` at stage 2: HighSchool 242, JuniorCollege_* 28, **zero `Transfer`-class rows even though
  IsTransferPortalNewlyAvailable / IsTransferSignPeriodActive are true** (week 1 of the stage). UNVERIFIED
  where/when transfer-portal players materialize — possibly deeper into stage 2; needs a later-week save.
- `RecruitTarget` (uid **59043175**, cap 4870): **nextRecordToUse = 0 in preseason year 0** — recruiting-board
  rows don't exist until recruiting begins. A force-commit-style "hijack an existing RecruitTarget row"
  strategy is impossible in a brand-new dynasty's preseason.
- `ProspectTargetSchool` — the top-school element pool — is **two tables by name**:
  uid **3789266353** (tableId 5840, cap 41010, next 41000) and uid **3843719174** (tableId 5841, cap 35000,
  next 23589), both with fields `{TeamId, TeamInfluence}`. Sampled recruit TopSchoolsList elements resolved
  into a table named ProspectTargetSchool. UNVERIFIED which pool serves which purpose (sizes suggest
  10-per-recruit HS pool vs a second pool — 41010 ≈ 10 × 4101).
- QQ vs QQ-AUTOSAVE: recruit stats, samples, coaches, calendar — **completely identical**. The autosave is a
  logical clone of the same moment.

---

## 4. What the Pipeline tool's write actually changed (QQ2b vs QQ2b-PIPELINES)

- Calendar diff: **none**. Coach diff (both sides): **none**. Recruit diff: **none**. Team-side: none.
- `SchoolPipelineInfluence` (uid 4261714800): **980 of 1500 rows differ**. The tool both re-ordered the region
  values within each school's 10 rows and upgraded tiers:
  `Popular 234→305, HouseholdName 111→124, CulturalPillar 63→77, Respected 438→418, NicheInterest 642→564`.
  Example row diffs: `0: Colorado|Popular → Colorado|HouseholdName`, `1: EastTexas|Respected → Alabama|Respected`.
- (Its LeagueSetting.CoachXPSpeedSetting write was not captured in this snapshot schema — not disproven.)
- A third-party madden-franchise `save()` therefore round-trips a CFB27 save cleanly: every other table
  re-read bit-identical in the formatted-value sense, and the game had already loaded this PIPELINES save fine
  per the user's history.

---

## 5. Save-file-level facts (container, ids, byte diffs)

- **All five saves are exactly 9,646,981 bytes.** Nearly every save in the user's folder is this size —
  the container is fixed-size/padded. File size is useless as a change signal.
- **Byte comparison is useless even for identical content:** QQ vs QQ-AUTOSAVE differ in 5,500,054 bytes
  starting at offset 0x1a (header area) despite being logically identical — the zstd-compressed stream is
  nondeterministic. QQ2b vs PIPELINES differ in 1,261,179 bytes from offset 0x4a. Always diff logically.
- **tableIds did NOT shift between any of these five saves** (Team 6334, Coach 4173, Player 4244, SeasonInfo
  4141, JobOpening 4151, Recruit 4269, RecruitTarget 4288 everywhere), even though the **table count grows as
  the dynasty progresses: 2269 (year 0) → 2471/2472 (years 2–3)** — e.g. `Team[]` instances go 9 → 10. New
  tables are appended; existing ids appear stable within a game build + dynasty lineage. Cross-build stability
  remains UNVERIFIED, so `getTableByUniqueId` is still the right resolver.
- **Name-resolution advice verified:** 9 tables named `Team` (the real one is uid 3359508968 cap 143; 8 decoys
  of cap 1–6), 2 named `Coach` (real: uid 1860529246 cap 632; decoy: uid 2796679390 cap 1), 2 named
  `ProspectTargetSchool`. But `Recruit`, `RecruitTarget`, `Player`, `JobOpening`, `SeasonInfo`,
  `StaffPersonContractOffer` are unique by name. "Largest recordCapacity" would have picked correctly in all
  ambiguous cases here, but uniqueId is strictly safer.
- **Team.TeamIndex is NOT unique:** all five FCS pseudo-teams (rows 30–34, "FCS East/Midwest/Northwest/
  Southeast/West") share **TeamIndex 255** — the same value used as Coach.TeamIndex's "unassigned" sentinel.
  Key teams by **row**, never by TeamIndex, and expect TeamIndex→name maps to collide on 255. (This produced a
  phantom-diff bug in our first diff run.)
- FCS pseudo-teams have real HC/OC/DC refs into real Coach rows (placeholder staff, e.g. FCS West HC "Seth
  Vela" row 399); TEAM_VISIBLE=false does *not* mark them — visibility semantics UNVERIFIED, don't filter on it.

---

## 6. Conclusions for the CoachCarouselRecruitTool

### (a) Which fields identify the dynasty's current stage/week

Read SeasonInfo (uid 3123991521) record 0:

```js
const si = file.getTableByUniqueId(3123991521); await si.readRecords();
const r = si.records[0];
const state = {
  stage: r.getValueByKey('CurrentStage'),              // 'PreSeason' | 'OffSeason' | (in-season values unobserved here)
  week: r.getValueByKey('CurrentWeek'),                // 0-based
  dynastyYear: r.getValueByKey('CurrentYear'),         // 0-based
  displayYear: r.getValueByKey('CurrentSeasonYear'),   // BaseCalendarYear + CurrentYear
  offseasonStage: r.getValueByKey('CurrentOffseasonStage'), // 0 never-offseasoned; 2 = transfer portal; 9 = completed (persists into preseason)
  offseasonAdvances: r.getValueByKey('NumberOffseasonAdvances'), // resets to 0 by preseason
  portalOpen: r.getValueByKey('IsTransferPortalNewlyAvailable') && r.getValueByKey('IsTransferSignPeriodActive'),
  carouselLive: r.getValueByKey('IsCarouselPeriodActive'),
};
```

"Our tool should run now" gate for the transfer-portal stage:
`CurrentStage==='OffSeason' && CurrentOffseasonStage===2 && IsTransferSignPeriodActive===true`.

### (b) Detecting "the carousel just happened"

**From one save (works at the transfer-portal stage — verified on QQ2):** scan Coach (uid 1860529246):
- Moved & rehired: `ContractStatus in {'Signed','First_Active'} && PrevTeamIndex !== 255 && SeasonsWithTeam <= 1`
  → old team `PrevTeamIndex`, new team `TeamIndex`, new role `Position` (old role `PrevPosition` when not `Invalid_`).
- Departed, unhired: `ContractStatus==='FreeAgent' && PrevTeamIndex !== 255` (fired/contract-ended — cause
  indistinguishable post-window).
- Retired: `ContractStatus==='Retired'` → old team `PrevTeamIndex`.
- Cross-check the Team side: Team.HC/OC/DC refs are already updated; a mover's old team's current coach at that
  role is his replacement.
- Sanity signal that a carousel ever ran: `JobOpening(263453863).header.nextRecordToUse > 0`.

**From two snapshots (most robust; also catches cause):** diff Coach rows by row index on
TeamIndex/Position/ContractStatus, and JobOpening.nextRecordToUse (value change ⇒ a carousel ran in between).
If snapshot A is taken **inside** the window (weeks 14–16, JobOpening rows non-empty / IsCarouselPeriodActive),
JobOpening.Reason gives the exact per-team cause (all six enum values confirmed live: None/Fired/Retired/Pro/
NewJob/ContractEnding) — after the window the Team ref in freed rows is destroyed by the free-list pointer and
PrevCoach/SelectedCoach are zeroed, so only aggregate stats survive.

**Ordering note:** by the time our tool runs (portal stage 2), all hires are final and no Pending statuses
remain — we do not need to handle mid-carousel states unless we choose to snapshot in-window.

### (c) Save-file-level surprises (checklist for the GUI tool)

1. Fixed 9,646,981-byte container; nondeterministic compression ⇒ never compare bytes, never trust size.
2. Autosaves are logical clones — either can be read.
3. Table count grows over dynasty life (+203 tables in 3 years); resolve by uniqueId; ids stable per build but
   name lookups hit decoys (`Team`×9, `Coach`×2, `ProspectTargetSchool`×2).
4. TeamIndex 255 is both "coach unassigned" and the shared index of all 5 FCS pseudo-teams; key teams by row.
5. Freed rows keep stale data except their first field (free-list pointer) — reading "empty" rows can be
   useful (JobOpening Reason stats) but the first field is always garbage; always check `record.isEmpty`.
6. `COACH_FIREREPORTED`/`COACH_RESIGNREPORTED` always true; `COACH_LASTTEAMFIRED`/`COACH_LASTTEAMRESIGNED`
   always 0 — do not build on them.
7. A brand-new dynasty (preseason year 0) has RecruitTarget.nextRecordToUse=0 and JobOpening high-water 0 —
   tools must handle "tables never populated".
8. TeamPrestige and season-structure constants (RegularSeasonLastWeekScheduled 15 vs 14) drift year to year.

### New verified uniqueIds (add to the tool's table registry)

| Table | uniqueId | tableId (this build) | capacity | note |
|---|---|---|---|---|
| Recruit | 1873209313 | 4269 | 7600 | unique by name |
| RecruitTarget | 59043175 | 4288 | 4870 | unique by name |
| ProspectTargetSchool (pool A) | 3789266353 | 5840 | 41010 | {TeamId, TeamInfluence} |
| ProspectTargetSchool (pool B) | 3843719174 | 5841 | 35000 | {TeamId, TeamInfluence} |
| Coach decoy (do NOT use) | 2796679390 | 6110 | 1 | name collision with real Coach |

### Remaining unknowns worth one more save capture

- A save **inside** the carousel window (in-season week 14–16) to see live JobOpening rows with Team/PrevCoach/
  SelectedCoach populated, IsCarouselPeriodActive=true, and the emergent-opening flag in action.
- A save at transfer-portal stage weeks 2+ to see whether `Class='Transfer'` recruits appear in the Recruit table.
- Whether offseason stages 0–1 (before portal) still show non-empty JobOpening rows — i.e. the exact free point.
