# Changes and measurements — NHL 2026 - 94 Edition v5.1W

Technical reference for further changes (routines, RAM, free space, hook pattern, test rig): see `HACKING.md`.
Picture of where the blue-line bonus applies: `blalinjeskott.png` (Swedish labels) and the English version on the site.

Started 2026-10-13. Installed ROM: `~/.config/retroarch/roms/claude/NHL 2026 - 94 Edition v5.1W.bin`.
Base (unpatched): `NHL 2026 - 94 Edition v5.1W.bin.orig.bak` in the same folder (md5 8f863ecb108c1d213953bada9b98f4f2).
Earlier builds are kept privately in `~/.config/retroarch/roms/claude/work/backups/` (`NHL94-v5.1W-passfixN.bin.bak`, then dated files).

## 1–2. Co-op passing (2026-10-13)

### The problem
In "Players: Two - Teammates", player 1's passes rarely went to player 2. Usually they went to a CPU teammate, who then
became player 1's skater. When a pass did go to player 2 it was often poor.

### Cause
The pass routine (ROM $BCF8–$BD5A) loops over the teammates and picks the one with the lowest "cost" = distance² + a penalty
if the player is not straight in the stick direction. Players outside ±1 of 8 directions (±45°) are not considered at all.
It makes no difference between humans and the CPU, so a closer CPU player almost always wins.

A CPU receiver skates into the pass by itself. A human receiver (P2) doesn't, so passes to P2 look worse than in single player.

### Changes (patch.py)
1. **Pass target** – hook at $BD44 → new code at $2FF000. If the passer is human-controlled and the candidate is too
   (object +$62 bit 3), the cost is set to 0, so the other human is chosen whenever he is roughly in the right direction.
   Single player is unaffected. Works both ways (P2 → P1 too).
2. **Receiving** – hook at $14E84 → new code at $2FF080. The human player who is the pass target ($FFBEE0) may receive the
   puck at distance 20 from the stick instead of 14.

Flags: `nosel` (skip change 1), `norecv` (skip change 2), `r=N` (radius). The script checks the original bytes before
patching, so it refuses if the addresses moved in another version (e.g. v4.1W or a future v5.2).

### Measured (bot test, about 100 passes per version)

| | Original | Patched |
|---|---|---|
| Pass aimed at P2 | 55 % | 86 % |
| P2 received the puck | 30 % | 44 % |
| CPU teammate took the puck | 28 % | 7 % |

Change 2 had no measurable effect in the bot test (the bot P2 never steers toward the puck). Not yet played with real pads.

Test: `run.sh` starts MAME with `h.lua`; `bot2.lua` makes P1 chase the puck and pass toward P2 (with ±35° sloppiness)
while P2 skates around the attacking zone. Needs a saved state `ingame0.sta` from a "Two - Teammates" game.
Never move players by writing x/y in RAM – it breaks the game's y-sorted collision list ($FFB84A/$FFB86A/$FFB88A)
and then nobody receives the puck.

Possible next step: let P2's skater steer toward the puck for the last frames of a pass to him, like CPU players do.

## 3. Goalie passes (2026-10-19)

Applies only when the passer is a goalie (object +$34 = 0):
- The goalie aims with the stick as usual (±45°, the other human is preferred inside the cone).
- Hook $BD5E → $2FF180: if there is no teammate in that direction the goalie no longer fires the puck "blind" straight out.
  He picks the other human (co-op) or else the nearest teammate.
- Hook $BF54 → $2FF140: the flight time (d2) is rounded down in the game, so short passes flew up to twice as fast.
  For the goalie 1 is added and at least 3 is used → softer passes. Flags `gadd=N`, `gmin=N`, `nogk`.

### Measured (`gkev3.lua` / `gkev3s.lua`, 8 positions × 9 stick directions = 72 passes)

| | Original co-op | Now co-op | Original single | Now single |
|---|---|---|---|---|
| "Blind" passes without a receiver | 21 | **0** | – | **0** |
| P2 got the puck | 9 | **32** | – | – |
| An opponent took it | 20 | **12** | 21 | **8** |

Aiming works: from the same position different stick directions give different receivers.
Positions are saved with `gk1b.lua` (gkp1–8) and run with `ROM_TEST=file.bin GK=gkev3.lua ./run.sh commands.txt`.
Earlier builds: passfix1 (P2 fix only), passfix2 (wider cone), passfix3 (goalie ignored the stick completely).

## 4. Shots from the blue line (2026-10-20, adjusted 2026-10-21)

- Background: the goalie stops everything below height 15 within 12–15 px, and the crossbar is at height 13. A goalie in
  position was practically unbeatable from distance (0.3 % of blue-line shots in the test).
- Hook $C408 → $2FF300 (shot routine): the shot distance is stored in free RAM $FFF000. If the distance is ≥ 160
  (140 until 2026-10-21; the blue line is about 180 straight out from the net) the game's own random generator ($11086)
  decides **once per shot**: 15 % chance that the goalie misses the puck ($FFF002 = countdown 40).
- Hook $15032 → $2FF340: while the countdown runs the goalie cannot reach the puck.
- Hook $150F6 → $2FF380: the goalie cannot pick up the low puck meanwhile either. Otherwise both flags are cleared when
  anyone takes the puck.
- Hook $C51A → $2FF3C0: long shots get 1/8 less lift, so fewer sail over the bar.
- The puck still has to go under the bar and between the posts. Applies to both teams, the CPU too.
- Flags: `blp=N` (chance in %, default 15, was 25 until the evening of 2026-10-20), `bld=N` (minimum distance, 160, was 140),
  `blz=N` (lift −1/2^N, 3, 0 = off), `blt=N` (countdown, 40), `nobl` (skip the change).
- The header checksum is recalculated (the game doesn't check it; the original was already wrong).

### Measured (`blev2.lua`, 12 positions × 6 stick directions × 2 shot lengths × 2 timings = 288 shots)

Positions `bl1`–`bl12` (P1 with the puck on the offensive blue line) are saved with `blsave.lua`.
Run: `EVOUT=out.txt ROM_TEST=file.bin GK=blev2.lua ./run.sh bt.txt` (bt.txt = `900000 exit`). Goals are counted via the goal log $FFC474.

| Version | Goals | Saves | Blocked | Wide/over |
|---|---|---|---|---|
| Original | 1 (0.3 %) | 128 | 49 | 110 |
| Only smaller reach (−3/−5 px) | 2–5 | 110–167 | 45–56 | 62–131 |
| 25 % miss + lower lift (passfix5) | **12 (4 %)** | 163 | 59 | 54 |
| 40 % miss | 21 (7 %) | 151 | 59 | 57 |

A full CPU period ran without errors, with about as many goals as the original. Not played with a pad.

### Edmonton test (2026-10-20, 25 % miss, zone from 140)

Ten demo games (CPU vs CPU, 10-minute periods), Edmonton away against WSH, ANA, BOS, CGY, CAR, CHI, COL, CBJ, DAL and WPG.
Scripts: `rung.sh N right-presses` and `edm.lua` (breakpoints on `$2FF32A` and `$14828`), analysis with `ana.py`.
Replays from other games during intermissions are filtered out with the team bytes `$FFC331`/`$FFC333`; the team bit is
`+$62 & $40`, the jersey is at `+$6F` (BCD).

| | Shots green | Goals green | Shots closer | Goals closer |
|---|---|---|---|---|
| McDavid | 25 | 4 (all via the goalie miss) | 64 | 5 |
| Draisaitl (second line) | 6 | 0 | 24 | 2 |
| All Edmonton | 104 | 9 (8.7 %) | 270 | 21 (7.8 %) |

About 14 goals could not be tied to a logged shot (someone took the puck in between) and are not included.

Controlled test with the same 288 blue-line shots (`blev3.lua`, `RATE=` = the shooter's rating bytes `$66–$75`):

| Shooter | Original | Patched |
|---|---|---|
| Average (WPG) | 1 | 12 (4.2 %) |
| McDavid | 0 | 21 (7.3 %) |
| Draisaitl | 2 | 17 (5.9 %) |

Rating bytes from the game: McDavid `0d30181d080d13131d97091d1d0e0009`, Draisaitl `05401a1a0a0b15151a290b1a1e00000b`.

### Lowered to 15 %

jdc felt 10–15 % was about right; 15 % was chosen (about 3 % goals from the point, like the real NHL).
Calculation: in the 25 % test about 17 % of the flagged shots went in → 15 % × 17 % + 0.3 % ≈ 2.8 % goals.
Note: the 288-shot test says little here — the positions are so similar that the random value is nearly the same:
both 12 % and 15 % gave 24 flagged shots and 1 goal. The flag is exactly `rnd(100) < blp` per shot, so calculate instead.

### Zone moved out to 160 (2026-10-21)

jdc wanted it a little harder. `bld` 140 → 160: the zone starts about 20 px inside the blue line straight out from the net
(blue line ≈ 180). The chance per shot is still 15 %.

### Shootouts (2026-10-20)

Play Mode → Shootout (Right ×3 from Regular Season), Players Demo or One. Script: `lua/sbot.lua` (bot P1); positions taken
in the original ROM and loaded in both ROMs.
- CPU vs CPU: original 0 goals on 85 attempts, patched 1 on 17 (a close shot). The CPU almost never scores.
- Bot, 810 attempts per ROM (9 positions × shot distance 100/125/170/205/230 × deke none/left/right × aim × wrist/slap):
  original 20 goals, patched 19. Almost every goal: skate straight, steer left ~15 frames about 60 px before, shoot ~38 px
  from the goal line. No goals from blue-line range in either ROM. So the patch doesn't noticeably change shootouts.
- The goalie stands at y ±247 in shootouts.

## 5. The off-screen arrow (2026-10-21)

Problem (jdc): in shootouts with a manual goalie the arrow at the top says the goalie is to the left, but when the shooter
arrives the goalie is in the middle of the net.
Cause: the arrow is drawn by the original code ($164D6), which centres it around x 128, the middle of the original NHL 94's
256 px wide screen. The hack runs 320 px wide (centre 160), so every edge arrow ended up 32 px too far left.
Fix: `addi.w #$100,d0` → `#$120` at $16590 (flag `noarrow` turns it off). Measured: the arrow for a goalie at x = 0 is now at
screen x 159 instead of 127.

## 6. Fighting Off/On that actually holds (2026-10-21)

The hack's fights: on a body check ($2001DE) it checks the setting, that nobody is a goalie, that play is live, and then a
threshold compared with both players' fighting rating (`+$74`, usually 0–6, enforcers about 14). Tension in the game and
penalty minutes lower the threshold, then 25 % chance. Penalties must not be Off. Arcade mode (value 2) lowers the threshold.
Bug: the setting was stored at $FFDF00, inside the hack's event log ($DEF6 + 14 bytes per entry). After the first entry it
held player data ($0D12, $0315 …), so the menu choice stopped meaning anything mid-game. Value 0 (= no fights) was shown as "Also On".
Fix: the setting moved to $FFF004 (all 6 `$DF00.w` + the menu's 3 `+$EA6` → `+$1FAA`), the menu has 2 choices, Off / On.
Flag `nofight` turns it off.
Test (CPU games, Winnipeg–Washington/Calgary): Off → $FFF004 = 0 the whole game (2422 checks); On → 1 the whole game (2801 + 2467).
No fights with normal ratings in 3 games (the threshold stopped 331 of 332). With every `+$74` set to $1F there was a fight
(Beauvillier–Toews, frame 10594). Scripts: `fc2.lua`, `st.lua`, `fz.lua`.

## 7. Edmonton roster (2026-10-21)

`rosters/EDM.json` (marked `"manual": true`), built by fix 7 in patch.py into `$2F0000` (the team pointer at `$336` is moved).
3 G / 15 F / 8 D as before. Lines (official NHL positions): Sc1 Hyman–McDavid–Podkolzin, Ekholm–Bouchard; Sc2
Formenton–Draisaitl–Kapanen, Shea–Murphy; Chk Howard–Dach–Joseph, Walman–Emberson. PP1 Hyman–McDavid–Draisaitl,
Ekholm–Bouchard; PP2 Howard–Hallander–Kapanen, Walman–Murphy; PK1 Kapanen–McDavid, Shea–Bouchard; PK2 Formenton–Michaels,
Ekholm–Murphy. Goalie Levi (Jarry, Andersen).
New: Levi, Formenton, Michaels, Hallander (ratings per `edm_lineup_2026-10-21.md`). Removed: Ingram, Pickard, Henrique, Lazar,
Roslovic, Nurse, Stastney, Janmark. Not included: Max Jones. Tested: the Line Editor shows the right players, a full CPU
game WPG–EDM 1–2 without errors.

## 8. All 32 rosters (2026-10-21)

All teams rebuilt from the NHL API (rosters, prospects, stats 2024–27) and DailyFaceoff lines with `update_rosters.sh`
(`fetch.py` + `gen.py`). Edmonton is kept as made by hand. Details in `HACKING.md` ("All teams") and `WORKFLOW.md`.
Tested: all 32 read back exactly from the ROM; full CPU games BOS–CAR, CBJ–OTT, NSH–TOR, SEA–ANA without errors.

## 9. Star ratings and placeholder fix (2026-10-21)

- jdc: Anton Frondell (CHI) and Ivar Stenberg (SJS) should have star status. They are set by hand in
  `rosters/overrides.json`, at about Connor Bedard's level: Frondell `75554532555343`, Stenberg `55553420645352`
  (Celebrini `66552420536263` for comparison). `gen.py` applies overrides before anything else, so they survive roster updates.
- Found while doing it: the hack gives some players placeholder ratings (all 1s): Frondell, L'Heureux, Lindstein, Holl,
  Cole Hutson. `gen.py` now treats those as missing and rates them from NHL stats, and leaves them out of the kNN training
  data. That also shifted a few other model-rated players slightly (22 team files changed, only new players' ratings).
- Tested: build OK, all teams read back, full CPU game CHI–SEA without errors.

## 10. Lines: no player in two lines; Fighting N/A when Penalties is Off (2026-10-21)

- jdc saw Draisaitl in both the first and the second line. Cause: each line entry has a 7th slot (extra skater). For
  Edmonton it was Draisaitl on line 1, and the first of the 8 line entries (purpose unknown) had Draisaitl at RW. `gen.py` put
  line 2's centre as line 1's extra on every team. Now the extras are 4th-line players (Sc1 = 4th C, Sc2 = 4th LW,
  Chk = 4th RW) and the first entry is a copy of Scoring 1. Checked: no team has a player in two even-strength lines.
- Fights already needed Penalties on (`$D056` ≠ 0 in `$2001DE`), but the menu still showed Off/On. Fix 8: hook on the menu's
  value printer at `$F7BAA` (code at `$2FE000`): option `$12` with Penalties = 0 prints "N/A" (`$F7CEE`), the same way Per. Length
  shows N/A in shootouts. Flag `nofightna`. Tested in the menu: Penalties Off → Fighting N/A; back on → the value shows again.

## 11. Season mode (2026-10-21) — project MEGAdriveHARDJOB

Built into the ROM in 68000 assembly (`tools/season/`), so it works in any emulator and on hardware. Regular Season is an
82-game season with your team, Continue/New Season in Play Mode, the menu locked after the first game, a Game row with the
next game, a pre-game hub (Start skips Matchups) with Season Standings by division, Scoring Leaders, team screens (G, A, PTS,
SOG per player) and New Season. Your games record the game's own per-player goals/assists/shots; the rest of the league
plays a simulated round with each of your games. Saved in battery RAM (`$1000` table, `$1220` players). Full details,
addresses and the lessons learned are in `SEASON_MODE.md`. Checked: player goals = team goals for all teams, assists ≤ 2 per
goal, shots ≥ goals. The IPS on the site is now this season ROM.

## 12. Y takes your goalie (2026-10-22) — fix 9

jdc: with Goalie Ctrl Manual, Y should pick the goalie when you don't have a player with the puck.
Found: the hack's 6-button reader (`$1A49A` → `$1FFBC4`) turns Y into a B press (tested in MAME with `-ctrl1 md6button`: Y passed
with the puck and switched to the nearest skater without it). The game already has a manual-goalie switch (`$B3E4`, reached through
the B hold timer `$BF06/$BF07`): goalie of the team via `$B86A`, skip if `+$62` bit 3 (other human), then `$C0BC` (P1) / `$C0DA` (P2).
`$D05A`/`$D05C` = Goalie Ctrl per pad, **0 = Manual**.

Fix (`tools/ygoalie.s` at `$2FE040`, hooks `$1FFC02` and `$B258`): when Y goes down on pad 1/2 (not 4-player), Goalie Ctrl is Manual
and your team doesn't have the puck → `YREQ` (`$FFF0E0`/`$FFF0E2`) and no B press; the input handler then does the game's goalie switch
(checks the puck again, the press may come a frame early). Otherwise Y = B as before. Y held keeps what the press did (`$FFF0E4/6`:
$FF goalie, 1 B) so a goalie who catches the puck while Y is still held doesn't pass.
Tested: home (→ object 5), away (→ 11), plain and season ROM, Y with the puck still passes. `noygoalie` skips it.

## 13. Season: goalie stats, overtime fix (2026-10-22)

- Goalie stats in the season: GP, W, GA, SA (SV% on the team screen, page 2). Details and addresses in `SEASON_MODE.md`
  ("Goalie stats"). Slot 0 of the game's per-player arrays is the goalie in net (goals/shots against), not a team total.
- Fixed: in the simulated league almost every loss counted as an overtime loss. The overtime flag was in `d1`, which the
  scorer/shot distribution (`dist`) takes as its goals argument, so it was non-zero whenever the team scored. Now kept in `OTF`
  (`$FFF0FA`). Seasons played before the fix keep their wrong OTL counts.
- Standings: separate **OTW** and **OTL** columns (jdc). Overtime wins are counted in the team row's spare word (`+14`);
  W now shows regulation wins, points unchanged (2 per win, 1 per OT loss or tie).
- **League table after every season game** (jdc): all 32 teams by points with the playoff tree's team banners, GP, PTS and
  the gap to the leader, your team marked, Up/Down to scroll, Start to go on (`season/table.s`, hook `$17336`).
- The table after a game shows one division per screen (Left/Right), the gap is to the division leader.
