# Project MEGAdriveHARDJOB — a season mode inside the ROM (option A)

Goal: a full regular season with standings, then playoffs, built into the ROM itself so it works in any emulator and on
real hardware. Working copy: `~/.config/retroarch/roms/claude/MEGAdriveHARDJOB/NHL94-season.bin` (copy of the patched ROM,
md5 bc92d62b…, 2026-10-21). Not installed for play; the normal ROM is untouched.

## What exists already (found 2026-10-21)

- **Battery RAM (SRAM):** 8 KB, declared in the header (`RA`, $200001–$203FFF, odd bytes). Bank switch with `$A130F1`
  (1 = SRAM on, 0 = ROM).
  - At boot the hack copies all of SRAM to RAM `$FF0000–$FF1FFF` (`$1F9A00`, called via `$1A050`). A checksum is kept in the
    last two bytes (`$1FFE/$1FFF`, check `$1A14E`, write `$1A206`). Bad checksum → SRAM cleared (`$1A19C`).
  - Write: `$1A1E4` → `$1F9B28` (d0 = SRAM offset, d1 = byte count, a0 = source). Read: `$1A244` → `$1F9B5A` (a0 = destination).
  - In use by the hack: `$0000–` about `$0DA0` (User Records, playoff data; e.g. `$0D20` 128 bytes from `$FFCF36`, `$0DA0` from
    `$FFD45A`), `$1EF6–$1FF5` (256 bytes from `$FFD076`), checksum `$1FFE`. **Free: about `$0E00–$1EF0` (≈ 4 KB).**
- **Menu:** Play Mode has Regular Season (one game), New Playoffs, New Playoffs/7 game, Continue Playoffs, Shootout.
  The playoff code already has a bracket ("Semifinals", "Finals", "Champions" at `$17706`).
- **Game stats in RAM:** two team blocks, `$FFC6CE` and `$FFCA32` (= +$364). Goals at `+$0C`, goals per period at
  `+$342 + 2·period`. Goal routine `$1481E`. Home/away team index `$FFC331/$FFC333`. Referee messages "Period Over" /
  "Game Over" at `$18E50` (indexed table, caller not found yet).
- **Test rig:** MAME keeps SRAM per ROM name; `run.sh` now clears it unless `KEEP_NVRAM=1`.

## Plan

1. **Result capture** — find the end-of-game point (the "Game Over" call or the switch to the post-game screens) and hook it:
   add the final score to a standings table in free SRAM (`$0E00–`): per team GP, W, L, OTL, GF, GA (32 × 12 bytes) plus
   game number. Test: two CPU games in a row with `KEEP_NVRAM=1`, check SRAM.
2. **Schedule** — generated in the ROM from a seed (e.g. 82 games for the user's team, other games simulated by a quick
   formula from team ratings when the user's game is played), or a fixed table in ROM built by a tool.
3. **Menu** — a "Season" choice in Play Mode: new season / continue, pick your team; the next opponent is set automatically.
4. **Standings screen** — reuse the hack's text drawing (menus, "Other Scores" panel) to show the table.
5. **Playoffs** — seed the top 16 into the existing playoff mode.

Hardest parts: 3 and 4 (new screens in 68000 assembly). 1 and 2 are straightforward.

## Step 1 — results into battery RAM (done 2026-10-21)

- Code: `tools/season/season.s` (GNU as, 68000), assembled with `tools/m68kasm.sh` (SGDK docker image
  `ghcr.io/stephane-d/sgdk`, `m68k-elf-as/ld/objcopy`). Built into the season ROM with
  `python3 tools/season/build_season.py <patched rom> <out>`; code at `$2FF400` (324 bytes), hook `$7C2E`.
- End of game: `$7BE0` compares `$C6DA` (home goals) with `$CA3E` (away); not tied → `$7C16` game over
  (`bset #0/#6,$C2EA`, `jsr $FF88E`, message 4). Tied after overtime (`$C466` = 3) in Regular Season (`$D048` = 0) → also game over.
- Highlights from other games during intermissions run the same path; they have bit 4 of `$FFC2EA` set and are skipped.
- **Don't call the hack's `$1A206`** (checksum): it reloads all 8 KB of SRAM into RAM `$FF0000`, which the game uses for
  replay data → "ADDRESS ERROR" on the post-game screen. The hook updates the checksum itself (old sum − old block + new block).
- Table: SRAM offset `$1000`, 'SESN', games played, 32 × (GP, W, L, OTL, GF, GA, T). Read it from a save file with
  `python3 tools/season/readsram.py file.nv` (MAME `.nv` = 16 KB with data on even bytes).
- Tested: 2 CPU games in one run (EDM–CBJ 4–1, 4–2), restart with the battery RAM kept, a third game (CBJ won in overtime):
  EDM 3 GP, 2 W, 1 OTL, 10–6; CBJ 3 GP, 1 W, 2 L, 6–10. Checksum OK after every write, no crashes, highlights not counted.

## Step 2 (part) — the rest of the league plays too (done 2026-10-21)

- After every finished game the other 30 teams are shuffled into 15 pairs and each pair gets a simulated result
  (`simleague` in `season.s`). Goals per team: 8 tries, each scores if `rnd(256) < 60 + strength/4` (game RNG `$11086`).
  A tie goes to overtime: one extra goal, coin flip, the loser gets an OT loss. So every team has the same number of games.
- Strength 0–255 per team is computed by `build_season.py` from the ROM's ratings (top 12 skaters + best goalie) and
  written to `strength.inc`.
- Tested: after 2 games all 32 teams had 2 GP with plausible scores; checksum OK; the game returns to the menu normally.

## Step 4 — "Season Standings" screen (done 2026-10-21)

- `standings.s` at `$2FF800`. Replaces "Other Scores" in all 7 pause / post-game menus (entry = word $14, 18 chars, long handler).
- Built like the game's own screens: `jsr $9BD8` (panel), `$11B92`/`$119B8`/`$11DE2` (title, inline text), then one string
  printed with `$11A48` (a1 = word length + text). Text codes: `F8 06 03 col row` start, `FC row`, `FD column`,
  `F9 01`/`F9 00` colour on/off, `[` and `]` show as arrows. Pad strings to even length *before* `F9 00`.
- Pad loop like Record Holders: `jsr $80BA`, `jsr $A41E` (d3 = pressed, d1 = direction), `jsr $11318`;
  d3 bit 7 Start → `jmp $9CD8` (back to the menu); d1 bit 3 right / bit 2 left; d3 bits 6+5 = A+C.
- Sorted by points, then goal difference. 16 teams per page, Left/Right for the page, **A+C starts a new season**
  (clears the table and fixes the checksum).

## Step 3 — your team and the schedule (done 2026-10-21)

- Season header in the SRAM block: `+6` your team + 1 (0 = none), `+8` your games played (= next game index), `+10` length (82).
- **Starting a season:** the first Regular Season game a human plays (Players One - Home / One - Visitor) sets your team.
  Before that (jdc: "you should only be able to pick one team") only Home = your team can be changed; Away follows as
  game 1 of that team's schedule and Players is locked too (`FORCED` = 2). During the season all three are locked (`FORCED` = 1).
  Small glitch: the away logo can show the previous opponent for one redraw after Home changes. A+C on the standings screen clears the season, so the next game starts a new one.
- While a season runs, only Regular Season games with your team count (`season.s`); demo/other games are ignored.
- **Schedule:** game k: opponent = (your team + `sched[k mod 31]`) mod 32, a fixed shuffle of 1..31 (`sched.inc`, seed 1994);
  even k at home, odd k away. 82 games = every opponent 2–3 times.
- **Menu lock** (`seasonmenu.s` at `$2FFD00`): hook `$F7BAA` (value printer, runs for every row on every redraw) calls `force`
  before fix 8: reads the header from SRAM and writes Home `$D04C`, Away `$D04E` and Players (`$D04A`, only if it is
  One - Home/One - Visitor) for the next game. Hook `$F7ABA` (value writer) ignores Left/Right on Players/Home/Away while forced.
- Tested: season set to Edmonton → menu shows EDM home vs NYR, Left/Right on Home/Players do nothing; the game (1–1 after OT)
  was recorded, game counter 1, and the menu then showed game 2: at Calgary. Test helper: `season/mknv.py` makes a MAME
  battery file with a season for a given team.

## "Whole Season" in Play Mode (jdc's idea, 2026-10-21)

- Regular Season is a normal single game again (free choices, nothing recorded). The season is its own Play Mode choice
  **Whole Season**, after Shootout (Right from Shootout, Left from Regular Season).
- Not a real 6th value: the game sees Regular Season (`$D048` = 0) and header `+12` = 1 in battery RAM says Whole Season.
  `seasonmenu.s` shows "Whole Season" for row 0 and maps Left/Right. The menu writes every row again after a change
  (d7 = `$FFFF00xx`), so only a real change of value counts.
- In Whole Season: Players = One - Home / One - Visitor and Away follow the schedule (locked); Home = your team can be picked
  until the first game is played, then it is locked too. Only games played in Whole Season are recorded.
- Menu in Whole Season (jdc 2026-10-21: "One or Two - Teammates is enough, remove the Away row"):
  Players shows **One** (= One - Home / One - Visitor by the schedule) or **Two - Teammates**; Left/Right toggles.
  Two - Teammates always controls the home team, so in co-op every game is played at home.
  The "Home" label reads **Team** and always shows your team; the "Away" label and value are empty (the opponent is in the
  logos at the top). Hooks: `$F7C72` (team rows, `teamrow`), `$F80BC` (label loop, `labelhook`, labels redrawn via `$F8070`
  after Play Mode changes). The cursor still stops on the empty row (the cursor position wasn't found in RAM).
- Lessons: the menu writes **every row again** after a change (d7 = `$FFFF00xx`) — a hook that changes a value on those
  rewrites makes the menu loop forever; and the value printer needs **a0 = `$FFD048`** going into `$20961A` (clobbering it
  crashed into the exception loop at `$18CD8`).
- Code moved to `$2F8000` (season.s), `$2F8800` (standings.s), `$2F9000` (seasonmenu.s); `$2FF400–` was too small.

## Locked settings, "Game" row, scoring leaders (2026-10-21)

- jdc: after the first game nothing should be changeable. While a season runs (`FORCED` = 1) the value writer blocks every
  row except Play Mode (so you can still switch to a normal Regular Season game; the season stays saved).
- The empty Away row is now **Game**: "2/82 at CGY" / "1/82 vs NYR" (next game, home/away, opponent). Not changeable.
- **Player stats:** SRAM `$1220`, 32 teams × 26 roster slots × (goals, assists) bytes. Header `+14` = 1 when valid.
  - Real games: the game's team stat blocks hold per-player bytes indexed by roster slot: goals `+$B4`, assists `+$CE`,
    shots `+$E8`, checks `+$102` (home `$C6CE`, away `$CA32`). **Slot 0 of the goals array holds the team total** — skip it
    (slot 0 is always a goalie).
  - Simulated games: each goal gets a scorer (weight ≈ (off+acc+pwr+stk)²/6) and up to two assists (82 % each,
    weight ≈ (pas+off+stk+agi)²/6) from `pweights.inc`, generated by `build_season.py` from the ROM ratings.
- **Scoring Leaders** screen (`leaders.s` at `$2FA000`) replaces "Record Holders" in all 9 menus: top 32 by points (goals
  break ties), 16 per page, names read from the team records via the table at `$30E` (max 16 characters).
- Where to find it: from the main menu press Start → (Matchups screen, Start again) → the pre-game menu has
  Start Game / … / Season Standings / Scoring Leaders. Also in the pause menu and after the game.
- Layout now: `season.s` `$2F8000` (~2.9 KB incl. weights), `standings.s` `$2F9000`, `seasonmenu.s` `$2F9800`, `leaders.s` `$2FA000`.
- Assembler gotcha: in GNU as `02:` is the same local label as `2:` — use named labels for anything not tiny.
- Tested: season EDM, CPU game EDM–NYR 1–1 recorded (McDavid goal, Hyman assist), 92 goals / 108 assists league-wide
  after one round, checksum OK; Per. Length can't be changed after the first game; leaders and standings open from the
  pre-game menu.

## Stats from the main menu (tried 2026-10-21)

- jdc asked for standings/stats straight from the main menu. Main menu facts: loop at `$F7464` (`bsr $F8304` reads the pad:
  d1 bit 7 Start → `$F843E` → `jmp $17D80`; bits 1/0 Down/Up → cursor `d7` moved by `$F76D4` with skip rules; bits 3/2
  Right/Left → `$F75AE`). The whole menu is built by `$F739E` (called from `$17312`; labels `$F8070`, values `$F7B20`).
- **Tried:** Right/Left on the Game row jumping into the standings/leaders screens and returning with `jmp $F739E`.
  Returning works, but the screens draw garbage over the main menu: they need the scoreboard screen setup (VDP/planes/font)
  that the pause and pre-game menus have; loading `$A892A` with `$11738` was not enough. Dropped.
- **Done instead:** after Start, `$17312` → `jsr $FBB88` → `jsr $FCC76` = the Matchups screen. In Whole Season that call
  (`$1732C`) is skipped, so Start goes straight to the pre-game menu: Start Game, Change Goalie, Edit Lines, Team Roster,
  Season Standings, Scoring Leaders, Player Cards. Tested headless.

## "New Season" in the pre-game menu (2026-10-21)

- jdc: a menu choice to start over after games have been played. The Regular Season pre-game list (`$19A84`, pointer at
  `$131CC` entry 0) is copied to `$2FAC00` with an 8th entry **New Season** → `newseason.s` at `$2FA800`.
  Menu lists: 8-byte header `00 04 FE 05 00 04 FE 04`, entries word `$14` + 18 chars + long handler, end `00 04 FF 00`.
- The screen asks first: A+C = yes (clears team, results and player stats, keeps Whole Season chosen, fixes the checksum),
  Start = back. Yes → `jmp $172FA` (fade, clear game state, build the main menu — `$172F0` would show the title screen first).
- A+C on the standings screen still does the same without the question.
- Tested: Start backs out unchanged; A+C → main menu with Game 1/82 and the team choosable again; header cleared, checksum OK.

## Regular Season is the season mode; Continue / New Season (2026-10-21)

- jdc: no separate Whole Season; Regular Season *is* the season. Play Mode row 0 shows **Continue Season** when a season
  is saved and, one step right, **New Season**; with no season only **New Season**. Header `+12` = 1 means "New Season
  chosen": the menu is then like before the first game (team choosable) and the **next finished game clears the old season**
  (`season.s`) and starts the new one. Going back to Continue Season before playing keeps the old one.
- While New Season is chosen (+12 = 1) the standings, leaders and team screens show an empty season (jdc saw the old
  season's table after choosing New Season — fixed 2026-10-21).
- Every Regular Season game is recorded (no separate on/off flag any more). The "New Season" entry in the pre-game menu
  (asks first) still clears at once.

## Player stats format 2: goals, assists, shots (2026-10-21)

- SRAM `$1220`: 32 rows of 100 bytes (team stride 100), slots 1..25 × (goals byte, assists byte, shots word). Header
  `+14` = 2 when valid; anything else → all rows cleared first. RAM was too small for the whole block (and `$FFF000-$FFF005`
  are the blue-line variables), so `season.s` reads/writes one row at a time (`rowload`/`rowsave`, row buffer `$FFF100`) and
  keeps the checksum change in `DELTA` (`$FFF0F0`).
- Real games: goals `+$B4`, assists `+$CE`, shots `+$E8` per slot from the game's team blocks (slot 0 = totals, skipped).
- Simulated games: 22–37 shots per team, each goal is a shot by its scorer, the rest by the scorer weights; the overtime
  goal gets a scorer too, so player goals always add up to the team's GF.
- `readsram.py` checks: player goals = team GF for every team, assists ≤ 2 × goals, shots ≥ goals per player.
- **Standings → team:** Up/Down moves an arrow (`]`) over the teams (edge-detected: one step per press — the pad routine
  repeats held directions), C opens `teamstats.s` (`$2FB000`): the team's skaters sorted by points with #, name, G, A, PTS, SOG,
  16 per page; Start returns to the standings. Goalies are left out (no goalie stats yet).
- Leaders (`leaders.s`) read the rows into 800 keys (points×256 + goals + 1) at `$FFE400` and pick the top 32.
- Tested: 5 CPU games from an empty battery file (4 recorded in time): 64 team games, 372 goals, 513 assists, 3730 shots,
  0 problems in the checks; standings arrow, C → Seattle's players (2 pages), Start back.

## Divisions (2026-10-21)

- Season Standings shows one NHL division per page: 0 Atlantic (BOS BUF DET FLA MTL OTT TBL TOR), 1 Metropolitan
  (CAR CBJ NJD NYI NYR PHI PIT WSH), 2 Central (CHI COL DAL MIN NSH STL UTA WPG), 3 Pacific (ANA CGY EDM LAK SJS SEA VAN VGK).
  Table `divs` (per team ANA..WPG) in `standings.s`. Opens on your team's division; Left/Right = division (wraps),
  Up/Down = team (0..7 in the division), C = that team's players. Conferences for the playoffs: East = 0+1, West = 2+3.

## Build and site (2026-10-21)

- `tools/build.sh` now builds the plain patched ROM, then the season ROM (`season/build.sh`), then the team pages and the
  IPS — **the IPS on the site is the season ROM** (base v5.1W → `MEGAdriveHARDJOB/NHL94-season.bin`). `teams.json` has
  `rom_md5` (season ROM) and `plain_md5`.
- Season code layout: `season.s` `$2F8000`, `standings.s` `$2F9000`, `seasonmenu.s` `$2F9800`, `leaders.s` `$2FA000`,
  `newseason.s` `$2FA800`, pre-game list copy `$2FAC00`, `teamstats.s` `$2FB000`.

## Division table after every game (2026-10-22)

- First built as one list of all 32 teams with Up/Down scrolling; jdc: after a game you should see the teams in their
  divisions, not one long list. Now one division per screen (exactly 8 teams, no scrolling), opens on your division,
  Left/Right = division, gap = to the division leader. The rest below still describes the screen.

## League table after every game (2026-10-22, first version)

- jdc: after a game, show the whole league like the game's playoff tree screen (team banners), without the cup, with the
  points and how far ahead / behind, scrollable. `season/table.s` at `$2FC000`, hook `$17336`.
- `$17336` is the playoff tree screen; it is called after a game (`$172E0`, returns to `$172E4`: fade, then the main menu) and
  before one (`$1731E`). In Regular Season it only did `rts` (`$15464`). The hook shows the table when Play Mode is Regular
  Season, the return address is `$172E4` (only after a game) and the season block is valid with games played.
- Set-up copied from the tree (`$1733E–$17432`): plane addresses `$B000–$B00E`, `$11594`, `$11B92` codes FF/FD/CE, fills with
  `$1197E`, `$11F20`/`$11F04`, background map from ROM `$2000` via `$1169A`, palettes from `$F3098`. The window plane (`$9202`)
  is turned off (`$9200`) and the horizontal scroll set to 0 (`$D5AC` = `$160`, the VBlank handler `$17692` writes
  `$FEA0 + $D5AC`).
- Banner: `$1169A` with the map at `$1DD366` (+ its offset at +4), rows 2·team and 2·team+1, 2 rows high, tile offset 2, at
  (`$B028`, `$B02A`) in tiles (`$17652` without its highlight test). Text: `$11BA4` with a1 = length word + text at the same
  position. Wait for the frame: `$175D8` (also blinks palette 3). Pads: `$11340`/`$11358` (d1 = new presses).
- Text plane: `$11BA4` writes one map cell per character into the plane picked by `$B02E` (`$B004 + $B02E`: 0 = `$E000`,
  4 = `$C000`); the `$11B92` control code FF sets plane 0, CE plane 4. At first the rank went to one plane and the rest to the
  other, so after a scroll old digits showed through (jdc: numbers on top of each other, blurry). Now every text goes to
  plane 4 (solid dark cells that a redraw replaces) and the banners to plane 0.
- Layout: title row 1, heads row 2, 8 teams at rows 4 + 3i (banner one row above), help row 26. Rank, `]` arrow at your team,
  banner (cols 4–13), abbreviation (both New York banners say NEW YORK), GP, PTS, gap (leader: + lead over number 2,
  others: − behind the leader). Opens with your team on screen. Up/Down scroll one team, Start = on.
- Test without playing: `mknv.py out.nv TEAM GAMES fill` makes a season with random results; a test copy of the ROM with the
  return-address check removed (`bne` after `cmpi.l #$172E4,(sp)` → `nop`) shows the table before the game too. The copy
  must be named `NHL94-season.bin` (MAME names the battery file after the ROM).

## OTW / OTL columns (2026-10-22)

- jdc: the standings should say OTW and OTL. The team row's spare word `+14` now counts overtime wins (your games:
  winner when `PERIOD` = 3; simulated: winner when `OTF`). W still includes them (points = 2 × W + OTL + T, as in the NHL).
- Standings columns: GP, W (regulation = W − OTW), OTW, OTL (+ ties), L, GF, GA, PTS; columns 4 apart from col 8,
  rank at col 1 with the selection marker drawn over its blank, team at col 5. Seasons started before this have no OTW
  for their earlier games.

## Goalie stats (2026-10-22)

- **Where the game keeps them:** in the team stat blocks (`$C6CE` home, `$CA32` away) slot 0 of the per-player arrays is the
  goalie: goals `+$B4` = goals against, shots `+$E8` = shots against (checked mid-game: home slot 0 shots = the away team's
  shot sum). So slot 0 is not a team total, and roster index 0 (the first goalie) has no row in the player block.
- **Goalie in net:** line 1's G in the team block (`+$16A`, 1-based roster index; the RAM copy of the lines, `+$16A` = 8 lines × 8).
- **Storage:** SRAM `$E20–$FFF` (the hack uses `$0–$E1F` and `$1EF6–$1FFF`: records `$0`, `$B60`, `$D20` 8×16, `$DA0` 128 bytes,
  settings `$1EF6` 256 bytes). 32 teams × 3 goalies (roster index 0..2) × 5 bytes: GP, W, GA (12 bits), SA (12 bits).
  Header `+14` = 3 means it is valid; a format-2 season keeps its skater stats and gets an empty goalie table on its next game.
- **Your games:** both goalies get GP, W (team won, overtime included), GA and SA from slot 0.
- **Simulated games:** the starter (line 1 G) plays 4 games in 5, the backup the rest (`gk.inc`, made by `build_season.py`);
  SA = the other team's simulated shots (`dist` leaves them in `SHOTSV` `$FFF0F2`), GA = its goals.
- **Team screen** (C on a team): page 2 has the rest of the skaters and then the goalies: GP, W, GA, SV% (".912", "---" before
  the first shot). Two pages always.
- `readsram.py` prints the goalies and checks per team: goalie GP = team GP, W = team W, GA = team GA, SA ≥ GA.

## Y button for the goalie

Done 2026-10-22 as fix 9 in `patch.py` (works in both ROMs), see CHANGELOG 12.

## How to play it now

1. Start `MEGAdriveHARDJOB/NHL94-season.bin`. Play Mode **Whole Season** (Right from Shootout), Home = the team you want.
   Play the first game: that team is now your season team.
2. From then on the menu shows your next game (opponent, home/away) and locks those rows; press Start to play it.
   Every finished game is recorded, and the rest of the league plays one round at the same time.
3. Start in the main menu → pre-game menu: **Season Standings**, **Scoring Leaders**, **New Season** (asks first), Start Game.
4. The table is kept in the game's battery save (`.srm` in RetroArch), so it survives turning the game off.

Build: `tools/season/build.sh` (on top of the installed patched ROM). Read a save: `python3 tools/season/readsram.py <file>`.

## Status

- 2026-10-21: steps 1, 2 (league simulation) and 4 done and tested headless.
- Step 3 done too (your team, 82-game schedule, locked menu).
- Scoring leaders, game N of 82, locked settings, Continue/New Season, shots, team screens and divisions done.
- Y = goalie done (fix 9), goalie stats done. Next: playoffs after game 82 (top 8 per conference).
