# Hacking guide: NHL 2026 - 94 Edition v5.1W (Genesis)

Reference for changing the game further. First written 2026-10-20 after the pass fix, goalie passes and blue-line shots.
What each change does and the measurements are in `CHANGELOG.md`. This file is about *how* to make the next change.

## Files and where everything is (since 2026-10-21)

The working folder is the **web folder** `~/mount/truenas/www/dela.jdc.se/nhl94/tools/` (public: https://dela.jdc.se/nhl94/tools/).
ROM files and test states are never put there.

| Where | What |
|---|---|
| `tools/build.sh` | Builds from the base ROM with `patch.py` and always installs to `~/.config/retroarch/roms/claude/NHL 2026 - 94 Edition v5.1W.bin`. Backs up the previous one to `work/backups/`. Rewrites `download/*.ips` |
| `tools/update_rosters.sh` | Fetches NHL rosters, prospects, stats and DailyFaceoff lines, runs `gen.py` (skips teams with `"manual": true`, e.g. EDM), builds |
| `tools/make_site_data.py` | Reads the installed ROM → `../teams.json` (the team pages) |
| `tools/patch.py` | All the changes. Refuses if the original bytes don't match |
| `tools/rosters/*.json` | One file per team (fix 7) |
| `tools/gen.py`, `fetch.py`, `data/` | Roster generator + downloaded NHL/DFO data |
| `tools/model.py`, `feat.py`, `romp.py`, `eap.py` | Analysis: kNN ratings, ROM reading, EA ratings (top 300 only) |
| `tools/run.sh`, `h.lua`, `lua/*.lua` | Headless MAME rig and test scripts |
| `tools/mkips.py`, `blueline_image.py`, `disasm.py` | IPS maker, blue-line zone picture, disassembler |
| `tools/config.sh` | Paths (ROMDIR, BASE_ROM, ROM, WORK) |
| `~/.config/retroarch/roms/claude/NHL 2026 - 94 Edition v5.1W.bin.orig.bak` | Base ROM, md5 8f863ecb108c1d213953bada9b98f4f2. ALWAYS build from this |
| `~/.config/retroarch/roms/claude/work/` | Private: `sta/` (saved states), `snap/`, `backups/`, build output, `old-passfix/` (the original working folder) |

Build and install:

    cd ~/mount/truenas/www/dela.jdc.se/nhl94/tools && ./build.sh          # default flags r=20
    python3 make_site_data.py                                             # refresh the team pages

## How a change is built (the pattern in patch.py)

1. Find the instruction in the original code where the change goes (see Routine map).
2. Replace at least 6 bytes (whole instructions!) with `jsr $2FFxxx` (6 bytes, `4EB9 + address`) plus `nop` (`4E71`) as padding.
   Use `jmp` (`4EF9`) when the hook has to return to different places.
3. Put the new code in free ROM. Always run the replaced original instructions in the hook.
   - If the original code after the hook uses flags (beq/bne/bgt…) from a replaced instruction: run that instruction **last** before `rts`/`jmp`. Neither `rts` nor `jmp` changes the flags.
   - Save registers you overwrite (`move.w d1,-(a7)` … `move.w (a7)+,d1`) unless you know they are free.
   - With `jsr` the return address is at 0(a7), so the original's stack values are shifted by 4.
4. `put(addr, code, expected_original_bytes)` checks the original. Always give the expected bytes at the hook.
5. `asm(base, [...])` is a minimal assembler: `W(word,...)` for raw words, `L(long)`, `('lbl','name')`,
   `('bra', opcode, 'name')` for 16-bit branches (opcodes: `6000` bra, `6700` beq, `6600` bne, `6D00` blt, `6C00` bge,
   `6E00` bgt, `6F00` ble, `6200` bhi, `6300` bls, `6A00` bpl, `6B00` bmi, `51CB` dbra d3).
6. Disassemble the result and read it through: `python3 disasm.py 2FF300 2FF3E0 out.bin` (needs python-capstone).
   Start disassembling on an instruction boundary, otherwise you get garbage.

## Free space

**ROM:** `$20963E–$2FFFBF` is `$FF` in the original (`$2FFFC0–$2FFFDF` holds a signature, don't touch).
In use:

| Address | Change |
|---|---|
| `$2F0000–` | Team records (fix 7), 16-byte aligned, about 27 KB for 32 teams |
| `$2FE000–$2FE025` | Fix 8: Fighting N/A when Penalties Off |
| `$2FE040–$2FE1xx` | Fix 9: Y takes your goalie (`ygoalie.s`, reader hook at `$2FE040`, handler at `$2FE100`) |
| `$2F8000–$2FCFFF` | Season ROM only (project MEGAdriveHARDJOB): `season.s` `$2F8000`, `standings.s` `$2F9000`, `seasonmenu.s` `$2F9800`, `leaders.s` `$2FA000`, `newseason.s` `$2FA800`, pre-game list copy `$2FAC00`, `teamstats.s` `$2FB000`, `table.s` `$2FC000` (league table) |
| `$2FF000–$2FF029` | Pass target, human preferred |
| `$2FF080–$2FF0F1` | Receive radius |
| `$2FF140–$2FF163` | Goalie passes, flight time |
| `$2FF180–$2FF207` | Goalie passes, never blind |
| `$2FF300–$2FF333` | Blue line: shot hook |
| `$2FF340–$2FF35F` | Blue line: goalie reach |
| `$2FF380–$2FF3A3` | Blue line: pickup |
| `$2FF3C0–$2FF3DF` | Blue line: lower lift |

**Next free in the normal ROM: `$2FE200`** (keep `$2F8000–$2FCFFF` for the season project; `$2FF400–` is free again). `patch.py` asserts the areas are `$FF` before writing. Add a new assert for new areas.

**RAM:** `$FFE000–$FFF7FF` was never written during a full period with a watchpoint (the stack is above about `$FFFEB0`).
In use: `$FFF000.w` = last shot distance, `$FFF002.b` = goalie-miss countdown, `$FFF004.w` = Fighting setting (0 off, 1 on, 2 arcade).
`$FFF0E0–$FFF0E7` = Y goalie (fix 9), `$FFF0F0` = season DELTA, `$FFF100–` season rows/team screen.
Next free: `$FFF006`.

**6-button pads:** the hack reads them at `$1FFBC4` (from `$1A49A`): four TH cycles via `$1FFC52`; the third TH-high byte has
Z/Y/X/Mode in bits 0–3 (active low). Test with MAME `-ctrl1 md6button` (`h.lua` finds the `md6button` port; buttons `X`, `Y`, `Z`, `Mode`).

Note: `$FFDEF4/$FFDEF6…` is the hack's event log (14 bytes per entry, written at `$1EFC12`) and grows upward over `$DF00`.
Never put your own variables just above `$DEF6`.

## Routine map (ROM addresses in the original)

| Address | What |
|---|---|
| `$BCF8–$BD5A` | Pass target choice: loop over teammates, cost = distance² + angle penalty (±1 octant) |
| `$BD5E` | "No receiver" → blind pass |
| `$BF54` | Pass flight time d2 (truncated, max $18) |
| `$BFC6–$C08C` | Pass receiver / switching the controlled player |
| `$C0F8` | Switch which player P1/P2 controls |
| `$C224` | Shot charging (`$5A(a3)`) |
| `$C2F2–$C540` | **Shot routine.** `$BEDE` = power. Target x from table `$C542` (8 directions × (x, height)): x 0/±16, height 12/6/0. Target y ±$108. Spread depends on distance d3 (>200 always, ≤250 halved, max $88) and rating `$6D`. `$B772/$B774` = puck speed x/y, `$B776` = lift (vz) |
| `$C408` | d3 = distance shooter→net (blue-line hook) |
| `$C51A` | Lift written to `$B776` (lower-lift hook) |
| `$14D8C–$14E0E` | Puck collision sweep over the y-sorted lists `$B84A/$B86A/$B88A` |
| `$14E10` | Puck against a player: pickup (height ≤5, radius 14, `$14E84`) else stick/body |
| `$14F78–$150C2` | **Goalie reach.** Box ±`$BF64` and radius² `$BF66` from tables `$14F28`/`$14F50` (12–15 px by rating `$68`). No save if puck height `$B762` > 15. Hit → `$15466` |
| `$15466–$1562C` | Save: catch (slow puck) or rebound |
| `$150E4` | **Pickup** (puck owner changes). `$150F6` hook |
| `$153CA` | Deflection off a stick |
| `$2001DE–$2003BE` | **Fight start** (called on a body check). Needs: Fighting on, both `$32` ≥ 10, no goalie, play live, `$C466` ≤ 2. Threshold = table `$200100` (by `$C46C`) − `$C46E`, /4, +10 (arcade +6) against the fighting rating `+$74`; then `$20042A` with penalty minutes (`$C6CE`+`$11C`), Penalties ≠ Off (`$D056`) and 25 % chance (VDP HV counter) |
| `$200544–` | The fight itself (punches, energy `$44`) |
| `$209596–$20963C` | Menu values: number of choices per option at `$2095AE` and `$2095C2`, strings from `$2091FE` + offsets at `$2091FC…`, settings at `$D048` + 2·index (index 9 = Fighting, via +`$EA6`) |
| `$164D6–$165BA` | **Edge arrow** for players off screen (e.g. the goalie in shootouts). Screen x = (x − camera `$BD1C`) + `$100`; fix 5 makes it `$120` (the hack's screen is 320 px, the original 256). Edge test ±`$74`/±`$64`, edge positions in table `$165BC` |
| `$14620–$14682` | **The net:** no interaction if puck height > 13 (= the crossbar). Half-width 16+`$BD22`(5), depth 2+`$BD24`(5) around the net object |
| `$14682–$1477C` | Goal or post ("ding" at the edge) |
| `$1481E` | Goal recorded: goal log `$FFC474` (+6 bytes per goal, index `$C472`) |
| `$11086` | Random: d0 in → 0..d0−1 out, touches only d0 |
| `$1107A` | Random: ±d0 |
| `$110BE` | sqrt(d0.l) |
| `$10676` | Direction (octant) from dx/dy |

## RAM map

- Objects: `$FFB04A + i*$80`. 0–4 team 1 skaters, 5 team 1 goalie, 6–10 team 2, 11 team 2 goalie, 12–13 the nets (y ±268), 14 the puck (`$FFB74A`).
  - `+$00` x, `+$14` y, `+$18` height (puck: `$B762`), `+$28/$2A` speed, `+$34` = 0 for goalie/net,
    `+$52` index, `+$54` direction, `+$5E` pickup lock, `+$62` bit 3 = human-controlled, bit 7 = team/attack direction,
    `+$66…+$75` ratings (expanded), `+$68` goalie rating, `+$6C` shot power, `+$6D` accuracy, `+$6F` jersey (BCD), `+$74` fighting.
- `$FFB7AA` puck owner (negative = loose). `$FFC320/$FFC322` object controlled by P1/P2.
- `$FFBEDC` stick/aim direction (0 = up, clockwise, 8 = none). `$FFBEDE` shot power. `$FFBEDA` last shooter. `$FFBEE0` pass target.
- `$FFC6CF` shots on goal (a counter). `$FFC474…` goal log (changes on every goal). `$FFC331/$FFC333` home/away team index.
- Coordinates: goal line ≈ y ±261, blue line about 180 from the net, centre 0. Screen = 1:1 with game pixels.

## Testing (headless MAME + Lua)

    cd tools
    EVOUT=out.txt ROM_TEST=file.bin GK=blev2.lua ./run.sh bt.txt   # bt.txt: 900000 exit; GK is looked up in lua/; runs in work/

- `run.sh` runs `mame genesis` **without a display**: `env -u DISPLAY -u WAYLAND_DISPLAY SDL_VIDEODRIVER=dummy`.
  `-video none` is not enough, windows still appear on jdc's screen. Run with `nice -n 19`.
  Check afterwards: `journalctl --user --since -2min | grep -c mame` should be 0.
- Battery RAM (SRAM, 8 KB): MAME saves it per ROM name. `run.sh` uses `work/nvram/` and clears it before every run, so menu
  settings from earlier tests don't leak in. Set `KEEP_NVRAM=1` to keep it (e.g. to test saving between games).
- Stop a long MAME run with an `exit` line in the command file. Killing MAME (SIGTERM via `pkill`/`timeout`) does
  **not** save the battery file — a 21-minute season run was lost that way.
- Start MAME **through run.sh**. Started directly with `timeout` it died silently.
- `-seconds_to_run` must be large (run.sh: `SECS`, default 100000). When a saved state is loaded MAME's clock jumps to the
  state's time, and if that is past the limit MAME exits without a message.
- `h.lua` reads the command file (`<frame> press|snap|save|load|dump|poke|exit`) and loads `GK=script.lua`. Load states no
  earlier than about frame 20.
- Screenshots: `manager.machine.video:snapshot()` → `work/snap/genesis/NNNN.png`. Works headless. `run.sh` clears the folder on start.
- Never move players by writing x/y in RAM: it breaks the y-sorted collision list.
- Finding code: watchpoint in Lua, run with `-debug -debugger none`:
  `cpu.debug:wpset(space,"w",addr,len,"1",'printf "pc=%x",pc ; go')` and read `manager.machine.debugger.consolelog`.
  The PC is the address *after* the instruction that wrote.
- Same state + same input = same result (deterministic). Variation comes from different states/timings.
- Always compare against the original with the same test script before installing, and run a full CPU period as a stability check.
- Menu navigation for demo games (CPU vs CPU): Start; Down; Right ×4 = Demo; Down; Right ×N = home team counted from
  Washington; Down; Right ×N = away team; Start ×3. A game lasts about frames 1500–68000.
  Shootout: Play Mode Right ×3. In-game pause menu: Down ×3 = Edit Lines.

## Ideas that were tried and didn't work

- Shrinking the goalie's reach for long shots (−3/−5 px): the shots got past the goalie but over the bar. Almost no goals.
- Moving the corner aim 3 px inside the post: the spread from distance is too large for it to matter.
- Only lowering the lift: more saves and blocks.

## Teams and players (fix 7)

- Team table: 32 long pointers at `$30E` (index 0 Anaheim … 10 Edmonton … 30 Washington, 31 Winnipeg) → team record.
- Team record: 6-word header: `+0` offset to players (`$92`, Winnipeg `$16C`), `+2` palette (`$0C`), `+4` offset to the name
  strings (= end of players + 2; the word `$0002` sits before them), `+6` lines (`$52`), `+8`/`+10` small fields
  (`+$4C` = `00 00 00 0F FF F0` for teams with 3 goalies, `…FF 00` with 2 — except MTL — not decoded; keep each team's number of goalies).
- Lines: 8 × 8 bytes `{G, LD, RD, LW, C, RW, extra, 0}`, 1-based player indices. Order: ?, Sc1, Sc2, Chk, PP1, PP2, PK1, PK2
  (verified in the Line Editor).
- Players (26, goalies → forwards → defence): word length (2 + name, even), name, jersey BCD, 7 bytes = 14 nibbles:
  weight (1–14 ≈ 7.26 + 0.116·(lb − 202)), agility, speed, off. awareness, def. awareness, shot power, checking,
  handedness+fighting (bit0 right, bits 1–3 fighting 0–7), stickhandling, shot accuracy, endurance, unknown (1–5), passing,
  aggressiveness (others 1–6). Goalies: nibbles 6–9 = 0, the rest not fully mapped (4–5 follow save % most closely).
- Player-card labels and bitmasks: `$209100`.
- The F/D split does not matter to the game (teams with the same mask have different splits), the goalie count is kept.
- Change a team: put `rosters/<TEAM>.json` (see `rosters/EDM.json`), run build.sh. `"rom"` = take the ratings of the player with
  the same name anywhere in the original ROM, otherwise 14 hex digits.

## All teams (2026-10-21)

- `gen.py` picks 26 players per team with the original's number of goalies (forwards/defence may shift by a slot if there
  aren't enough defencemen). Priority: in the DFO lines → rest of the roster → injured → prospects with NHL games → other
  prospects → the team's old ROM players who are on no NHL list → the best free agent from the old ROM.
- Ratings: `rosters/overrides.json` wins first. Then a player found anywhere in the original ROM keeps the hack's ratings,
  unless they are the hack's placeholder (all nibbles 1 except weight). Otherwise kNN (k=12) on NHL stats 2024–27,
  pulled toward a "depth player" template under 20 games; no NHL games → the template. Goalies: average of the 8 ROM goalies
  with the closest save % (shrunk toward .895). A note per player in `rating_notes` in the team file.
- Lines: DFO's trios/pairs, each trio turned to official NHL positions (C in the middle, L/R on the wings; DFO's slot breaks ties).
  Sc1/Sc2/Chk = DFO lines 1–3 + pairs 1–3, PP/PK from DFO's units. Goalie = DFO's starter.
- DFO: `https://www.dailyfaceoff.com/teams/<slug>/line-combinations`, data in the page's `__NEXT_DATA__` (groupIdentifier
  f1–f4, d1–d3, g, pp1/pp2, pk1/pk2, ir; positionIdentifier lw/c/rw/ld/rd/g1/g2/sk1…).
- NHL: `api-web.nhle.com/v1/roster/<TEAM>/current`, `/v1/prospects/<TEAM>`, stats `api.nhle.com/stats/rest/en/skater/{summary,realtime,bios}`
  and `goalie/summary` with `cayenneExp=seasonId=… and gameTypeId=2`.
- EA's ratings site (NHL 27) only exposes the top 300 players; the data API needs the header `drop-referrer: <page url>`.
- Tested: all 32 read back exactly; four full CPU games (BOS–CAR, CBJ–OTT, NSH–TOR, SEA–ANA) without errors.
