Metadata-Version: 2.4
Name: retro-roster-patcher
Version: 0.4.1
Summary: Patch real-world sports rosters into retro game ROMs
Author: hiitsgabe
License-Expression: MIT
Project-URL: Homepage, https://github.com/hiitsgabe/retro_roster_patcher
Project-URL: Repository, https://github.com/hiitsgabe/retro_roster_patcher
Project-URL: Issues, https://github.com/hiitsgabe/retro_roster_patcher/issues
Keywords: rom,patcher,retro,sports,roster
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Games/Entertainment
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: images
Requires-Dist: Pillow>=10; extra == "images"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: mypy>=1.11; extra == "dev"
Requires-Dist: build>=1.2; extra == "dev"
Dynamic: license-file

<p align="center">
  <img src="assets/logo.png" alt="" width="132">
</p>

<h1 align="center">Retro Roster Patcher</h1>

<p align="center">
  Patch real-world sports rosters into retro game ROMs.
</p>

<p align="center">
  <a href="https://github.com/hiitsgabe/retro_roster_patcher/actions/workflows/ci.yml"><img src="https://github.com/hiitsgabe/retro_roster_patcher/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
  <img src="https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue" alt="Python 3.11 | 3.12 | 3.13">
  <img src="https://img.shields.io/badge/license-MIT-green" alt="MIT">
</p>

Retro sports games shipped with the rosters of their release year and no way to change
them. This tool fetches a squad from a live sports API, maps each player onto the attribute
scale the game actually stores, and writes the result into the binary team tables of a ROM
you already own — so a 1994 cartridge lines up with a 2025 season.

| Game | `--game` | Platform | Sport | Providers |
| --- | --- | --- | --- | --- |
| International Superstar Soccer (SNES) | `iss-snes` | `snes` | `soccer` | `espn` |
| Ken Griffey Jr. Presents MLB (SNES) | `kgj-mlb-snes` | `snes` | `baseball` | `espn` |
| MVP Baseball (PSP) | `mvp-psp` | `psp` | `baseball` | `espn` |
| NBA Live 95 (Genesis) | `nbalive95-genesis` | `genesis` | `basketball` | `espn` |
| NHL 2005 (PS2) | `nhl05-ps2` | `ps2` | `hockey` | `espn`, `nhl` |
| NHL 07 (PSP) | `nhl07-psp` | `psp` | `hockey` | `espn`, `nhl` |
| NHL 94 (Genesis) | `nhl94-genesis` | `genesis` | `hockey` | `espn`, `nhl` |
| NHL 94 (SNES) | `nhl94-snes` | `snes` | `hockey` | `espn`, `nhl` |
| PES 2013 (PS2) | `pes2013-ps2` | `ps2` | `soccer` | `espn` |
| Winning Eleven 2002 | `we2002` | `psx` | `soccer` | `espn` |

Every provider is keyless: no credential to supply, and no flag or environment variable for
one. WE2002 also gets its menus translated out of Japanese as part of the same run.

**This project ships no game data.** It patches a ROM or ISO you supply, and never
redistributes one. The tests build synthetic images byte by byte rather than committing a
real dump.

Zero runtime dependencies — the standard library only, so it drops into an embedded or
sandboxed interpreter with no wheel to build. Usable as a library or as a CLI, and the CLI
speaks newline-delimited JSON so another process can drive it. It was extracted from a
pygame launcher for exactly that reason; a Flutter app over embedded CPython is the other
consumer.

## Patch without code: Retro Toolbox

[Retro Toolbox](https://github.com/hiitsgabe/retro_toolbox) is the app for this library, a
graphical way to patch a game with no code and no terminal. Pick the game, the league and
the season, check or edit the teams, choose the screen art, and it writes the patched ROM.
It finds the game file already on your device, works with a gamepad, and runs on Android,
macOS, Windows and Linux handhelds. Get it from its
[releases](https://github.com/hiitsgabe/retro_toolbox/releases) or
[website](https://hiitsgabe.github.io/retro_toolbox/).

## Install

```bash
pip install retro-roster-patcher
```

## Quick start

```bash
retro-roster list
retro-roster analyze --rom nhl94.bin
retro-roster fetch --game nhl94-genesis --provider nhl --season 2025 --out rosters.json
retro-roster patch --game nhl94-genesis --rom nhl94.bin --out nhl94-2025.bin --rosters rosters.json
retro-roster images --game we2002 --rom we2002.bin --out art/
```

`fetch` and `patch` are separate verbs so you can inspect — or hand-edit — `rosters.json`
before anything touches a ROM. `patch --season N` does both in one run instead.

## Command line

Five verbs: `list`, `analyze`, `fetch`, `patch`, `images`. `req` below means the verb refuses
to run without the flag, `yes` means it accepts it, `-` means it does not.

| Flag | `list` | `analyze` | `fetch` | `patch` | `images` | Meaning |
| --- | --- | --- | --- | --- | --- | --- |
| `--help` | yes | yes | yes | yes | yes | usage for that verb |
| `--json` | yes | yes | yes | yes | yes | newline-delimited JSON on stdout instead of human text |
| `--game` | - | yes | req | req | req | patcher id from the table above |
| `--rom` | - | req | - | req | req | input ROM or ISO |
| `--out` | - | - | yes | req | req | where to write the rosters file / the patched ROM |
| `--season` | - | - | req | yes | - | season year, e.g. `2025` |
| `--rosters` | - | - | - | yes | - | patch from a `fetch` file instead of `--season` |
| `--slot-map` | - | - | - | yes | - | JSON list of slot mappings |
| `--language` | - | - | - | yes | - | menu language, for a game that ships translations |
| `--no-logos` | - | - | - | yes | - | draw no team logos: by default `we2002` and `iss-snes` draw each team's logo as its flag and `pes2013-ps2` as its crest; with this flag a team keeps the striped (`we2002`) or two-band (`iss-snes`) flag, and `pes2013-ps2` the disc's crests |
| `--blank-unused` | - | - | - | yes | - | `we2002`, `iss-snes` and `pes2013-ps2`: empty the name and flag (crest) of every slot the slot map leaves out; `iss-snes` blanks its player names too |
| `--image` | - | - | - | yes | - | `SLOT=PNG`, repeatable: replace a screen's art (`we2002`: `title`, `menu`, `boot`; `pes2013-ps2` and `nhl07-psp`: see the per-game table) |
| `--provider` | - | - | yes | yes | - | data provider, when the game offers more than one |
| `--league-id` | - | - | yes | yes | - | provider league id |
| `--cache-dir` | - | yes | yes | yes | yes | where caches and generated assets live |
| `--assets-dir` | - | - | yes | yes | - | directory of user-supplied assets, read only |

`--json` and `--help` are accepted on either side of the verb. `analyze` without `--game`
probes every registered patcher and reports the ones that recognise the file. `patch` needs
exactly one of `--season` and `--rosters`; both, or neither, is a usage error.

### What each game requires

| Game | Requires |
| --- | --- |
| `iss-snes` | `--league-id` for `fetch` and for `patch --season` — there is no default league. `--slot-map` for every `patch`: the ROM's 27 slots are national teams and the data is a club league, so there is nothing to match them by. An 8 Mbit (1 048 576-byte) SNES dump, headerless or with the 512-byte copier header. Two different checks, on purpose: `patch` refuses only a file too short to hold the 297 600 bytes this patcher writes, because that one provably cannot be patched, while `analyze` additionally reports `is_valid: false` unless the file clears the 1 MB floor and all 27 entries of each of three pointer tables — selection-screen names, in-game name tiles and team descriptions — dereference to something the writer could use. That second check is a guess about content, so it never blocks a `patch` the user asked for by name. Run on the retail cartridge in Mesen 2: the patched ROM boots, and the team screen shows the new names and eleven. The eleven is the team's most frequent starters over its last few matches on ESPN, placed into the game's 4-4-2 slots (a front three sends its third forward to midfield); places the lineup leaves open go to the season's most frequent starters, so a player with no starts never takes a place from one with starts. Surnames shared within a team are told apart (`G.Silva`, `L.Silva`, or first names). Each player's nine abilities (three speeds, kick power, shooting, technique, stamina, defense, offense) are written where the game reads them, scaled into the ranges the original players use; speed comes from position and age, because ESPN measures no pace. Shirt numbers are unique 1-15 per team. The ability layout was checked in Mesen 2 against the four bars of the pre-match Substituting screen. Each team's crest (its ESPN logo, or `logos/<team_id>.png` under `--assets-dir`) becomes its flag, 24×16 pixels in four colours on white, wherever the game shows a flag: team select, the cup's group table, the league's fixtures, Substituting, the VS banner and the match scoreboard (seen in Mesen 2). At that size fine lines and lettering are lost. All 27 flags share 2 688 bytes; when the crests do not fit, the largest crest that saves bytes by losing a colour loses one, down to two, and only when none can does the largest fall back to the two-band flag in the team's colours. Each of those is reported as a status line, and then the number of crests drawn. A team without a logo gets the two-band flag, and so does every team with `--no-logos`. The flag's first palette entry is also the colour of the team's shirt numbers on both kits (seen in Mesen 2), so it holds a colour that reads on every shade of both shirts: the crest's own when it stands out enough (by brightness first, then red-green hue), else the best of the crest's colours and red, yellow, blue, white, black and grey, with a near tie going to the brighter contrast. A crest without that colour gives up the one it misses least, and the flag's other colours take whichever order of the next three entries compresses smallest. Under budget pressure a crest loses colours one at a time, and only a crest that can lose no more falls back; with real crests nothing falls back, but two-colour crests far from the number colour can, on some kits. A team that keeps the cartridge's flag but gets new kits has that flag redrawn the same way. Eight teams (Holland, England, Denmark, Norway, Ireland, Belgium, Austria, Russia) borrowed a partner's flag on the team-select and table screens; each now has one of its own there, in OBJ tiles those screens leave unused. Every mapped team gets home and away kits in colours and a chest design from its pattern (see Kits). `--blank-unused` empties every slot the slot map leaves out: no name on the team-select screen, in its description or on the in-game name tiles, a transparent flag and blank player names; its kit and abilities stay, and it can still be picked and played (seen in Mesen 2 on team select, the International Cup group table, the World Series fixtures and through a match). |
| `kgj-mlb-snes` | No `--league-id`, and it *refuses* `--slot-map`: it matches each team by its abbreviation. A 2 097 152-byte SNES dump, headerless or with the 512-byte copier header — exactly those two sizes and nothing between, the strictest size test of any game here. That number is the ported reader's own and has never been checked against a real cartridge, so if the game is not a 16 Mbit ROM it refuses every genuine dump. The team tables are located by searching the image for a 14-byte marker rather than at a fixed offset, which is what makes the header need no arithmetic. `analyze` reports `is_valid: false` when that marker is missing, and also when it matches within 25 280 bytes of the end of the file, because the 28 team blocks would then run off the end and every write would be silently dropped. |
| `mvp-psp` | No `--league-id`, and it *refuses* `--slot-map`: it matches each team by its abbreviation. An **uncompressed** ISO 9660 disc image, and at least 686 122 913 bytes — `database.big` sits at a fixed sector 334 832 and runs for 386 977 bytes, so a shorter file does not contain the data at all. Two different checks, on purpose: `patch` refuses only a file that fails that arithmetic, because it provably cannot be patched, while `analyze` additionally reports `is_valid: false` unless the compressed section headers are where this game puts them *and* the disc's own `team` table carries at least one of MVP Baseball's 30 team ids. That second check is a guess about content, so it never blocks a `patch` the user asked for by name. A patch can still fail late, and loudly: each of the 19 database sections has a fixed allocation with no length word, so if a rebuilt table compresses larger than the one it replaces the run raises rather than silently keeping the original. Neither check has been run against a real disc. |
| `nbalive95-genesis` | No `--league-id`, and it *refuses* `--slot-map`: it matches each team by its abbreviation. A 2 MB (2 097 152-byte) Genesis dump. `analyze` reports `is_valid: false` unless all 360 player pointers resolve, which rules out every file shorter than 2 064 604 bytes — the ported reader's own 1 572 864-byte floor accepts those and then silently patches nothing for the last twelve teams. |
| `nhl05-ps2` | No `--league-id`, and it *refuses* `--slot-map`: it matches each team by its abbreviation. An **uncompressed** ISO 9660 disc image. Two different checks, on purpose: `patch` refuses a file whose ISO 9660 directory places `DB.VIV` past the end of it, because a truncated archive silently decompresses short and would be written back to the disc corrupted, while `analyze` additionally reports `is_valid: false` unless `DB.VIV` is a BIGF archive holding an `nhl2005.tdb` that decompresses to a TDB. That second check is a guess about content, so it never blocks a `patch` the user asked for by name — `patch` reaches the same fact with a better message, naming the archive's actual file list. Patches the 30 NHL club slots; the two All-Star sides are left alone, so Seattle and Vegas are dropped rather than given a slot. Neither check has been run against a real disc. |
| `nhl07-psp` | No `--league-id`, and it *refuses* `--slot-map`: it matches each team by its abbreviation. An **uncompressed** ISO 9660 disc image — a `.cso`, `.zso`, `.jso` or `.dax` is refused by name, with a message saying to decompress it, rather than reported as the wrong game. Two different checks, on purpose: `patch` refuses a file whose ISO 9660 directory places `db.viv` past the end of it, because a truncated archive silently decompresses short and would be written back to the disc corrupted, while `analyze` additionally reports `is_valid: false` unless `db.viv` is a BIGF archive holding an `nhlbioatt.tdb` that decompresses to a TDB. That second check is a guess about content, so it never blocks a `patch` the user asked for by name. Checked on a real disc (`ULUS10131`) and played in PPSSPP: ratings go in on the game's own 0-49 scale (shown as 50-99), weight as pounds above 150 and height as inches above 54. `db.viv` is written in place, or rebuilt compactly when a table outgrows its slot, and the game's cached copy of its directory (`db.bh` in `PRELOAD/FAPT.VIV`) is rewritten to match. Each written player's portrait in `FE/PORTAPT.VIV` gets the provider's head shot (NHL API or ESPN), or the game's own silhouette when there is none (needs the `images` extra; `patch(portraits=False)` keeps the disc's). Screen art slots `boot`, `title`, `menu`, `logo`, `player`, `loading`, `league_badge`, `players_badge` (`images` exports PNGs, `--image` writes every copy of a slot in place, with fewer colours when it would not fit). `patch(texts={id: text})` replaces front-end strings by id; none by default, so EA's copyright notice on the title stays as the disc has it (its field holds only those two lines). Known gaps: the All-Star slots (Seattle, Vegas) share bio records with NHL players, and salaries and career stats stay the previous player's. |
| `nhl94-genesis` | No `--league-id`. It *refuses* `--slot-map`: it matches each team by its three-letter code. |
| `nhl94-snes` | No `--league-id`, and it *refuses* `--slot-map` for the same reason as its Genesis sibling. An 8 Mbit (1 048 576-byte) dump, headerless or with the 512-byte copier header. `analyze` reports `is_valid: false` for a file whose 28 team blocks it cannot find, which includes every file too short to hold the pointer table 927 207 bytes in. |
| `pes2013-ps2` | `--league-id` for `fetch` and for `patch --season` — there is no default league. `--slot-map` for every `patch`: the 139 club slots are named after the game's own clubs (licensed or not), and `analyze` lists them so a league can be laid over any of them. An **uncompressed** ISO 9660 disc image of the US release (`SLUS_219.55`); `analyze` reports `is_valid: false` unless the boot file, `0_TEXT.AFS` and `OVER.AFS` are all in the root directory. A patched club gets its name and three-letter code in every language overlay, its ESPN logo, or `assets_dir/logos/<team_id>.png` when the patcher was built with an `assets_dir`, drawn as its crest (64x64 and 32x32 atlases, each drawn through one palette shared with the flags, so the flags are redrawn through the new palette too; `--no-logos` keeps the originals), and the names and shirt numbers of the players its squad already points at: the eleven who started most of the team's last five matches on ESPN come first, each taking the lineup place whose record holds the position he started in (the first eleven places are the club's formation), then the bench by position group, and the record's abilities are left as they were. The display name is the full name when it fits 15 characters, the shirt name the surname, as on the disc. The league itself is renamed and gets its ESPN logo on the team-select screen for every slot group (England 0-19, France 20-39, Italy 40-59, Netherlands 60-77, Spain 78-97) that received a team (the names are found relative to the untouched "Ligue 1" string, so patch an original disc, not a patched one). National teams are slots `1000` and up (`analyze` lists the 67 named ones; `national_slot_map` pairs them with league 2021, international friendlies, by code then name); a national squad only takes over records no club squad shares, so it may fill fewer than 23 places. Several leagues go in at once by merging their `MappedRosters` (`a.merge(b)`). `--blank-unused` empties the name, code and crest of every club the mapping left out, the name and icon of every league group without a team, and the name of every national team left out (clubs only when clubs were mapped, nationals only when nationals were). Kits: every mapped club and national team gets home and away kits drawn from colours (`retro_roster_patcher.core.kits.KitSpec`: shirt, secondary, pattern, shorts, socks, sleeves), by default plain kits in the provider's two team colours; `patch(kits={slot: {"home": spec, "away": spec}})` overrides a slot, a spec being a `KitSpec`, its dict or `{"image": png}` (a picture of the kit, read for its colours and pattern by `kit_from_image`; a 512x256 picture for a team with a licensed kit is instead painted into that kit's texture package, re-encrypted, and the kit stays licensed; `{"template": n}` is refused, the disc has no jersey designs to pick from), and `kits=False` keeps the disc's. The disc's shirt template draws vertical stripes and contrasting sleeves, so `stripes`, `pinstripes` and `hoops` become stripes and `halves`, `sash` and `chevron` plain shirts with contrasting sleeves; a kit so written is parametric, drawn from the colours even for a club whose shirt was a licensed texture. Club slots 130 and up have no kit record. Screen art slots `title`, `menu`, `loading`, `match` are UI textures (`images` exports PNGs, `--image` writes them with their own 256-colour palette). A rebuilt entry may run into the sector padding after it but never changes its size field, because the game hangs at boot when an entry's size changes — checked on a real disc in PCSX2. Real names compress worse than the disc's, so when the player entries outgrow that room the records no squad points at are blanked (names and abilities) and the national squads take fewer of them. |
| `we2002` | `--league-id` for `fetch` and for `patch --season` — there is no default league, and without one both fail with `CapabilityError` before any request goes out. Patch an original disc: the community English translation writes absolute offsets, so on another dump or a disc patched before (its screen files were rebuilt) it would corrupt the menu art; the patcher notices (the screen files no longer parse), restores the output from the input and reports the translation as skipped. `--slot-map` for every `patch`: the ROM's team slots are unnamed, so there is nothing to match teams against. Each team gets 23 players (a national slot holds them all, a Master League slot the first 14 or 15): the eleven who started most of the team's last five matches on ESPN take the first 11 places, the game's 4-4-2, filled back to front by the positions they started in, so a line with one player too many hands its most advanced one to the next line up (a back five's wing-back plays midfield, a front three's winger midfield) and a line one short takes the deepest of the next (a back three takes a wing-back); a second keeper sits. Places the lineup leaves open, or every place when ESPN has no lineup, go to the season's most frequent starters. The bench takes a keeper and one player from each line in turn, up to 2 keepers, 3 defenders, 2 midfielders and 4 forwards, so the 14 or 15 a Master League slot keeps have a sub for every line however the eleven was coded; the best of the rest take the last place. A player keeps his ESPN position (the byte the game shows) in whatever line the 4-4-2 puts him: the patcher writes no formation, so each slot plays the disc's own. Kits: every mapped team gets home and away kits drawn from colours (`retro_roster_patcher.core.kits.KitSpec`: shirt, secondary, pattern, shorts, socks, sleeves), by default plain kits in the provider's two team colours, painted into the team's 3D kit texture (`TEX_nn.BIN`) and its 2D team-select preview; `patch(kits={slot: {"home": kit, "away": kit}})` overrides a slot, a kit being a `KitSpec`, its dict, `{"image": png}` (the picture itself is mapped onto the texture: chest, sleeves, shorts, socks) or `{"template": n}` (one of the disc's 95 jersey designs, which `kit_templates` lists with a preview of each), and `kits=False` leaves the textures and previews as the disc has them. A spec or picture is painted over a plain white template (Classic England, or the design named) so the texture keeps its folds and seams: `stripes` are 4 px bands running on across the front, sides and sleeves, `pinstripes` a 1 px line every 4, `hoops` 4 px bands down each piece, `halves` the right half of the front with the right sleeve and side, `sash` a diagonal band across the front, `chevron` a V across the chest and `sleeves` contrasting sleeves; the collar always takes the contrast colour, the goalkeeper's half of the page and the number font keep their colours, and a file is held under the 36 864 bytes the game loads of it. Needs Pillow; without it a slot takes the whole texture of the team whose shirt colour is nearest, as before. |

League ids are the provider's own. ESPN's soccer ids run from 2001 (Premier League) through
2016, in `sports.espn.ESPN_LEAGUES`. An id the chosen provider does not know is reported as
`ApiError: League N not found`.

A slot map is a JSON array of `{"slot_index", "team_id", "team_name"}` objects, which is
what `SlotMapping.to_dict()` produces:

```python
import json
from pathlib import Path

from retro_roster_patcher import SlotMapping

mappings = [
    SlotMapping(slot_index=0, team_id=359, team_name="Arsenal"),
    SlotMapping(slot_index=1, team_id=364, team_name="Liverpool"),
]
Path("slot-map.json").write_text(json.dumps([m.to_dict() for m in mappings], indent=2))
```

### Translations and user assets

`--language` is honoured by games that ship translations — `we2002` takes `en`, `es`, `fr`
and `pt` — and is a usage error on a game that does not, rather than a flag silently
dropped.

`--assets-dir` is a directory the tool only ever reads. Its one use today is the community
WE2002 menu translation `w202-english.ppf`, which this project does not redistribute: drop
that file in and its menu records are merged into whichever language PPF `--language`
selected. Without it the roster patch still applies and the menus stay Japanese.

### Team logos

`we2002` draws every mapped team's logo where the game draws its flag: the
team-select grid, the small flag beside the selected team, and the large flag of
the match intro and scoreboard. The logo is the one the provider publishes for
the team (ESPN's `Team.logo_url`), downloaded once into `--cache-dir` and
rendered with Pillow, so this needs the `images` extra:

```bash
pip install "retro-roster-patcher[images]"
```

Without Pillow the roster patch still applies and the flags stay the striped
default, with one status line saying so. `--no-logos` turns the logos off on
purpose. `--blank-unused` deals with the slots a league does not fill: every
national and Master League slot outside the slot map gets an empty name, a
black kit and a fully transparent flag, so the menus show the mapped teams and
blank entries rather than the 2002 squads. To use your own image for a team, put a PNG at
`<assets-dir>/logos/<team_id>.png` (ESPN's team id, the one in the slot map)
and pass `--assets-dir`; it wins over the download.

How it works, and the limits that follow. A WE2002 flag is a *style* byte per
team (one of 61 shapes drawn as 4bpp bitmaps) plus a 16-colour palette per
team, so each logo needs a style of its own: the patcher picks styles no
untouched team uses first, then the least-used ones, and 53 styles exist, so a
league of more than 53 teams leaves the rest striped. Fifteen opaque colours
and a transparent background is what a style holds, which is what the logo is
reduced to: fourteen of its own colours plus white, because the copies of the
atlas in the stadium files (the sideline and crowd flags a match draws) get
the logo on a white ground, where the menu copies keep it on transparency.
Master League slot 22 draws through slot 10's palette and keeps the striped
flag. The bitmaps live LZSS-compressed in the team select, match HUD, editor
and stadium data files; those are re-encoded, and one that no longer fits
grows into the free sectors after it, with the executable's own file table
raised to match. The rewritten sectors carry fresh EDC checksums but
not recomputed ECC, as with the jersey textures.

### Screen art

`we2002` can also swap the art of three screens for your own PNGs. Each is a
*slot*: the texture page the game draws that screen from, with the pieces laid
out exactly as the game keeps them, so the first step is to export what is
there now and paint over it:

```bash
retro-roster images --game we2002 --rom we2002.bin --out art/
```

That writes `art/title.png` (the title logo, the ball and the credits),
`art/menu.png` (the main menu's ball, "Winning Eleven" banner, "2002" and
glow) and `art/boot.png` (the KONAMI logo and the JFA licence screen). Edit a
file in place, keeping its size, and hand it back on the next patch, either as
`--image menu=art/menu.png` (repeatable) or by leaving it at
`<assets-dir>/images/<slot>.png`. Each piece is reduced to its palette's colour
budget, 15 colours for the 4bpp title and menu, 255 for the 8bpp boot logos,
plus transparency, and the palette records are rewritten with it. The menu
ball's reflection is cleared whenever a menu image is given. The sky of the
title screen and the dark field behind the main menu are not textures and stay
as they are. Needs Pillow, like the logos.

### Kits

`we2002` draws every mapped team's home and away kits, the same `kits` contract
as `pes2013-ps2` (see the library section): the default kits by default (below),
a `KitSpec`, a picture or a template per slot on request,
`kits=False` to leave the disc's. Each team owns a 3D texture file (`TEX_nn.BIN`,
0-62 the national teams, 63-94 the Master League) holding the home and away
kit pages and their palettes, and the game composes the shirt, shorts and socks
from rectangles of that page. A spec is painted into those rectangles over the
art of a plain white template, each pixel's brightness kept, so the shirt keeps
its folds; a picture is mapped onto them (its upper middle on the chest, its
sides on the sleeves, its lower part on the shorts and socks); a template copies
one of the 95 designs as drawn, previews of which `kit_templates(rom_path)`
returns as 96x128 PNGs. To paint the texture itself, `kit_page_png(rom_path,
slot, which)` returns the slot's 128x256 page as a PNG (the regions outlined
and labelled in red unless `guides=False`; `template=n` reads a design
instead), and a 128x256 PNG handed back as `{"image": png}` is written as the
page: opaque pixels of the outfield half replace the art, transparent ones keep
it, so leave the guides out of what you upload. `kit_preview_png(rom_path, slot,
which, choice)` shows any such choice as a 96x128 kit photo before it is
written (`choice=None` for the kit as it is). The goalkeeper shares the page through a palette of its
own and is left alone, as are the shirt numbers. The 2D team-select preview is
a 16-colour sprite palette, written from the same colours (striped kits put the
contrast colour on the collar and shoulder zones). Needs Pillow; without
it each slot takes the whole texture of the shipped team whose shirt colour is
nearest, which is all earlier versions did.

`iss-snes` takes the same `kits` contract within what the cartridge holds.
Every team draws the same player tiles, so a kit is colours: the shirt in three
shades (body and sleeves alike), the shorts in three and the socks in two, for
home and away; the goalkeeper keeps the fixed green kit. The one drawing a team
owns is an 8x8 chest design in eight poses, drawn in its flag colours (palette
entries 12-15, entry 12 being the shirt numbers), so it is the same on both
shirts and only the home kit's pattern counts. A `KitSpec`'s pattern borrows
the nearest of the cartridge's own designs, nothing new is drawn: `stripes` and
`pinstripes` take Argentina's stripes, `hoops`, `sash` and `chevron` Germany's
chest band, and `plain`, `halves` and `sleeves` a blank chest (contrasting
sleeves and whole-shirt patterns cannot be drawn). The design shows in the
crest's colours, or the two-band flag's without a crest: the flag's last three
palette entries are reordered so the design takes the crest colour nearest the
spec's `secondary` (Argentina's stripes on a template take the crest colour
nearest sky blue), which on a crest without such a colour can be far from it,
and a design in entry 12 (Brazil's badge) is drawn in the shirt-number colour.
A patched team without a pattern gets a blank chest, never the old nation's
design. `{"template": n}` copies one of the 26 national kits, numbered by slot
(`kit_templates` previews each), its chest design included, as the retail
cartridge has it even when the image was patched before; `{"image": png}` is
read with `kit_from_image`. The byte that decides which team changes kit when
two look alike is set from the home shirt (a template's is copied); black and
grey shirts get values of their own, so black meets red without a change.
`kit_preview_png(rom_path, slot, which, choice, home)` draws the pre-match Team
Colors player from the cartridge's own tiles as a 96x144 PNG; for a kit as
written it matches Mesen 2 pixel for pixel, for a `choice` not yet written the
chest shows the colours the patch asks of the crest, and an away preview takes
its chest design from `home`, the team's home kit. `kits=False` keeps the
cartridge's kits and chest designs. Pictures and previews need Pillow.

#### Default kits and the kit catalogue

Every soccer patcher starts a team from `core.kits.default_kits(team)`. For a
club in the packaged catalogue, `src/retro_roster_patcher/data/kit_catalog.json`,
that is the club's real home and away kit (Flamengo in red and black hoops,
Juventus in black and white stripes, Feyenoord in red and white halves); for any
other team, plain kits in the provider's two colours. The catalogue holds the
footballdatabase.com world ranking's top 100 clubs and every Brasileirao Serie A
club, read from each club's English Wikipedia kit fields for the 2026-27 season
and checked by eye against Wikipedia's kit drawings. It is plain JSON so other
programs can use it: `clubs` is a list of `{name, country, espn_id, names,
wikipedia, ranking, home, away}`, the kits as `KitSpec` dicts (`shirt`,
`secondary`, `pattern`, `shorts`, `socks`, `sleeves`, colours `#rrggbb`). A club
with an `espn_id` is matched by that id only, so a namesake elsewhere never takes
its kit; one without (a league ESPN does not carry) is matched by name.
`tools/kit_catalog/` rebuilds it. Kit colours derived from Wikipedia,
[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), Wikipedia
contributors.

## Library

Everything the CLI does is a call on a `Patcher`. `list_patchers()` describes what is
registered without instantiating anything:

```python
from retro_roster_patcher import list_patchers

for info in list_patchers():
    print(info.game_id, info.platform, info.sport, info.providers)
```

`get_patcher(game_id)` returns the class; you construct it. The four methods are
`analyze_rom`, `fetch`, `map_rosters` and `patch`, in that order — `fetch` and `map_rosters`
are split from `patch` so a caller can preview, cache or edit between them without repeating
the network step. The block below is not executed by the test suite: it needs a provider and
a ROM.

```python no-run
from pathlib import Path

from retro_roster_patcher import RetroRosterError, get_patcher

patcher = get_patcher("nhl94-genesis")(
    cache_dir=Path("~/.cache/retro-roster-patcher").expanduser(),
    provider="nhl",
)
try:
    data = patcher.fetch(season=2025)
    rosters = patcher.map_rosters(data)
    result = patcher.patch(
        rom_path=Path("nhl94.bin"),
        output_path=Path("nhl94-2025.bin"),
        rosters=rosters,
    )
except RetroRosterError as exc:
    raise SystemExit(f"{type(exc).__name__}: {exc}") from exc

print(result.output_path, result.teams_patched, result.players_patched)
```

`RetroRosterError` heads the hierarchy, so one `except` catches everything this library
reports, including the filesystem errors it converts at its boundaries. Catch a subclass
when you want to react differently: `RomError`, `ApiError`, `MappingError`,
`CapabilityError`, `StorageError`, `MissingAssetError`. Constructors take optional
`on_status` and `on_partial` callbacks and `fetch`/`patch` take `on_progress`; the CLI wires
those to the events below.

`league_data_to_dict` and `league_data_from_dict` are the round trip behind
`fetch --out` / `patch --rosters`, so a consumer can hold rosters in its own store.

A game whose patcher has `supports_kits` takes a `kits` option on `patch`: absent or `True`
draws every mapped team's home and away kits from its colours (`default_kits`: plain kits in
the provider's two team colours), `False` keeps the ROM's, and a dict
`{slot: {"home": kit, "away": kit}}` overrides a slot, where a kit is a `KitSpec`, its dict,
`{"image": png}` (a picture of the kit, bytes or a path; a game that cannot take the picture
reads a `KitSpec` off it with `kit_from_image`), `{"template": n}` (one of the game's own
jersey designs, listed by the patcher's `kit_templates`) or `None` for the default.
`render_kit` draws a `KitSpec` as a PNG to preview it. `slot_groups` (name, first slot, count),
`supports_nationals` and `national_slot_base` describe a game's slot layout for a picker.
`max_leagues` says how many leagues one patch takes, the k-th filling the k-th group: 1 by
default and for `we2002`, whose two groups (Master League, national only) are one run of
slots; 5 for `pes2013-ps2`, one per named league (a league in "Other" would get no league
name or icon).

### Root exports

`from retro_roster_patcher import ...`

- Errors: `RetroRosterError`, `ApiError`, `CapabilityError`, `MappingError`,
  `MissingAssetError`, `RomError`, `StorageError`
- Registry: `Patcher`, `PatcherInfo`, `get_patcher`, `list_patchers`, `register`
- Game-side models: `MappedRosters`, `PatchResult`, `RomInfo`, `RomSlot`, `SlotMapping`
- Sports models: `League`, `LeagueData`, `Player`, `PlayerStats`, `Team`, `TeamRoster`
- Serialisation: `league_data_from_dict`, `league_data_to_dict`
- Finding a ROM on disk: `RomFinder`, `RomFinderConfig`, `RomFinderResult`
- Kits: `KitSpec`, `KitTemplate`, `PATTERNS`, `default_kits`, `kit_from_image`, `render_kit`
- Also: `Transport`, `__version__`

`from retro_roster_patcher.sports import ...`

- Clients: `EspnClient`, `NhlApiClient`, `Transport`, `team_colors`
- Models: `League`, `LeagueData`, `Player`, `PlayerStats`, `Team`, `TeamRoster`

## The `--json` protocol

With `--json`, stdout carries protocol and nothing else: one JSON object per line, flushed
per line. Human logs and progress go to stderr. This is the surface the pygame launcher and
the Flutter bridge code against.

### Events

Every line has an `event` key.

| `event` | Other keys | Emitted |
| --- | --- | --- |
| `status` | `msg` | when a step begins |
| `progress` | `pct` (0.0-1.0), `msg` | during a fetch or a patch |
| `partial` | `data` | for an intermediate payload worth rendering before the end |
| `result` | `ok` (always true), `kind`, plus the payload's own keys | on success, as the last line |
| `error` | `type` (the exception class name), `msg` | on failure, as the last line |

```json
{"event":"status","msg":"Fetching NHL teams..."}
{"event":"progress","pct":0.42,"msg":"Fetching Boston Bruins..."}
{"kind":"patch","output_path":"nhl94-2025.bin","teams_patched":26,"players_patched":598,"event":"result","ok":true}
```

Guarantees a consumer may rely on:

- Exactly one terminal line per run, `result` or `error`, and nothing after it.
- `status`, `progress` and `partial` appear zero or more times, only before the terminal
  line. `list` and `analyze` emit the terminal line alone.
- `pct` is clamped to `[0.0, 1.0]` and restarts at 0 for each phase, so it is not monotone
  across a whole `patch --season` run — that run fetches and then writes.
- `result` sets `event` and `ok` last, so a payload carrying keys of those names cannot
  overwrite the two a consumer parses on.
- `fetch` without `--out` emits the entire rosters payload as a `partial` before its
  `result`, because there is no file to point at.

### Result payloads

The `kind` key says which payload a `result` carries.

| `kind` | Verb | Payload keys |
| --- | --- | --- |
| `patchers` | `list` | `patchers[]`, each `game_id`, `platform`, `sport`, `requires_slot_mapping`, `providers[]` |
| `rom_info` | `analyze` | `matches[]`, each `path`, `size`, `game_id`, `is_valid`, `slots[]`, `extra` |
| `rosters` | `fetch` | `league`, `season`, `teams`, `players`, `output_path` |
| `patch` | `patch` | `output_path`, `teams_patched`, `players_patched` |
| `images` | `images` | `paths[]`, the PNG files written |

### Exit codes

| Exit | Meaning | On the stream |
| --- | --- | --- |
| `0` | success | a `result` line |
| `1` | a typed error, an interrupt, or a bug in this library | an `error` line |
| `2` | usage error | an `error` line, unless argparse rejected the argv itself |

A consumer that only reads the exit code still learns whether to look at stdout. Two edges
are worth knowing. When argparse rejects the argv — an unknown verb, a missing required flag
— it prints usage as plain text on stderr and exits 2 without writing any JSON, because no
renderer has been chosen yet. And an untyped exception is a bug in this project rather than
something you can act on: the `error` line is written and then the exception is re-raised
unchanged, so a Python traceback follows on stderr and the exit status is CPython's.

## How a player becomes a number

None of these games stores a rating anyone published. Every number below is this project's
own arithmetic over a provider's season totals, and the three do it differently. Some
attributes are measured; several are estimated from position and age, because no feed
publishes them at all.

### WE2002: ranked against the league, 1-9

Fifteen attributes per player. Ten are earned, five are always estimated.

The ten are grouped into categories — offensive, defensive, body balance, stamina, pass
accuracy, shoot power, shoot accuracy, technique, dribble, aggression — each a small formula
over `PlayerStats` fields (`offensive` is goals + 0.7 x assists + 0.3 x shots on target, and
so on). Every player in the *whole fetched league* is pooled, the formula is evaluated for
each, and a player's percentile is the share of the pool scoring strictly below him. That
percentile becomes the rating: 95th and up is a 9, then 85 → 8, 70 → 7, 50 → 6, 35 → 5,
20 → 4, 10 → 3, 3 → 2, and the rest a 1. Position adjustments follow (a midfielder gains a
point of passing, a goalkeeper is capped at 3 for shooting accuracy), then everything is
clamped back into 1-9.

So a 9 means top 5% *of the league you fetched*, not top 5% of the world. Patch the Premier
League and the Chilean Primera División from the same tool and the two ROMs are not on a
common scale.

The five that are never measured — speed, acceleration, jump power, heading and curve — come
from a position base nudged by age. A 24-year-old attacker is quick because attackers are
quick and he is young, not because anyone timed him.

**`unsupplied`, and why zero is not an answer.** Every count on `PlayerStats` is declared
`int` or `float` and is never `None`, so a consumer can do arithmetic without a guard. That
leaves a provider which does not measure duels nowhere to say so except by writing `0` — and
a zero reads as a *measurement*, ranking the player below everyone who was measured and
padding the denominator for everyone who was. `PlayerStats.unsupplied` names the fields
carrying filler rather than data. A player whose provider did not measure a category's
inputs is dropped from that category's ranking entirely, and his rating comes from a
position-and-age estimator instead of a percentile he was never in. ESPN, the only soccer
provider here, reports no duels and no dribbles, which is why body balance, technique and
dribble have estimators and the other seven categories do not.

A player with no stats at all, or with zero appearances, skips the percentiles and takes a
position default adjusted for age: under 23 gains pace and stamina and loses technique; from
31 it goes the other way.

### NHL94: measured against a fixed yardstick, 0-6

Twelve attributes on a 0-6 scale, and no league ranking anywhere. Each stat is scaled
linearly inside a fixed window, so a 40-goal season is a 6 no matter what the rest of the
league did:

| Attribute | From | Window |
| --- | --- | --- |
| Shot power, shot accuracy | goals | 0-40 |
| Pass accuracy | assists | 0-55 |
| Stick handling, offensive awareness | points | 0-90 |
| Defensive awareness | plus/minus | -30 to +40 |
| Roughness, aggression | penalty minutes | 0-80 |
| Agility (goalies) | save percentage | .880-.930 |
| Defensive awareness (goalies) | goals against average | 3.5 down to 2.0 |

A skater's speed, agility, checking and endurance are position defaults, with a single point
of speed and agility added past 50 points; a goalie's are fixed constants, and only the two
rows above are read off his stat line. Weight class is `(pounds - 140) // 8`, clamped to
0-14. A player the provider has no stats for gets the position defaults untouched.

### NBA Live 95: scaled inside fixed windows, 25-99

Sixteen attributes. The scale the ROM stores is 0-99, but the mapper clamps to a floor of
25, so nothing this tool writes is ever rated below 25. Every input is a per-game average
from ESPN's team-leaders endpoint, scaled linearly inside a window and clamped:

| Attribute | From | Window |
| --- | --- | --- |
| Field goals | field-goal percentage | .380-.550 |
| Three-point | three-point percentage | .250-.420 |
| Free throw | free-throw percentage | .600-.920 |
| Stealing | steals per game | 0.3-2.0 |
| Blocks | blocks per game | 0.1-2.5 |
| Offensive rebounding | offensive rebounds per game | 0.3-3.5 |
| Defensive rebounding | defensive rebounds per game | 1.0-9.0 |
| Passing | assists per game | 1.0-10.0 |
| Offensive awareness | points per game | 5.0-30.0 |
| Defensive awareness | 2 x steals + 1.5 x blocks + 0.5 x defensive rebounds | 1.0-12.0 |
| Quickness | 2 x steals + 0.5 x assists | 1.0-8.0 |
| Jumping | 2 x blocks, plus 5 if field-goal percentage is over .500 | 0.5-8.0 |
| Dribbling | 0.8 x assists + 2 x max(0, 2 - turnovers / assists) | 1.0-10.0 |
| Strength | 0.8 x rebounds, plus 2 for a centre or power forward | 1.0-10.0 |

The two attributes missing from that table, dunking and speed, are position constants with
one bonus each: dunking starts at 35 for a point guard through 60 for a power forward and
gains 10 above .520 shooting, and speed starts at 35 for a centre through 75 for a point
guard and gains 8 above 1.2 steals a game. A player the provider has no stats for gets a
whole row of position defaults and none of the windows above.

Height is always a position default — 6'2" at point guard through 6'11" at centre — because
ESPN's roster response carries no height. Weight is ESPN's when it reports one. Experience
is age minus 21, floored at zero. The roster is the top 12 by minutes played, two per
position and then the best remaining.

Two fields are written but never derived, and both are inherited from the code this port
came from. `season_stats` is 17 zeros for every patched player, which erases the 1994 season
line the cartridge shipped with — ESPN's leaders endpoint publishes averages, not the games,
minutes, makes and attempts those fields hold, so there is nothing honest to put there. Skin
tone and hair style are written as 0 for everyone, so every patched player looks the same.

In short: WE2002 grades on a curve, NHL94 and NBA Live 95 against absolute yardsticks, and
in all three the physical attributes are priors rather than measurements.

## Limits

- **Past seasons are not historical squads, except on the `nhl` provider.** ESPN's roster
  endpoints take no season parameter at all; they serve the current squad. `--season 2003`
  against ESPN therefore returns today's players labelled 2003, and reports success. For
  `nhl94-genesis`, `--provider nhl` is the real answer: the NHL API serves squads back to
  1993. For `we2002` and `nbalive95-genesis` there is no second provider, so a past season is
  a present squad with a past label. (Per-player soccer *statistics* are fetched per season
  and are genuinely that season's, as are NBA team leaders; only the squad list is current.)
- **Four patchers, three sports.** A fifth is a `Patcher` subclass and a `@register` line;
  see below.
- **No ROMs, no ISOs, no dumps.** You supply the image.
- **The community WE2002 English menu PPF is not redistributed.** See `--assets-dir`.

## Adding a game

A patcher is a `Patcher` subclass decorated with `@register`. Its package is imported from
`retro_roster_patcher/__init__.py`, at the bottom of the import block, so the decorator has
run by the time anyone calls `get_patcher`. The registry is an in-tree dict, not entry
points: every game here is first-party.

```python no-run
from retro_roster_patcher.core.patcher import Patcher
from retro_roster_patcher.core.registry import register


@register("mygame", platform="snes", sport="basketball", providers=("espn",))
class MyGamePatcher(Patcher):
    def analyze_rom(self, rom_path): ...
    def fetch(self, *, season, league_id=None, on_progress=None): ...
    def map_rosters(self, data, slot_mapping=None): ...
    def patch(self, *, rom_path, output_path, rosters, on_progress=None, **options): ...
```

`register`'s keyword arguments become the capability record `list` reports and the CLI
enforces. Set `requires_slot_mapping=True` if the ROM's team slots cannot be matched
automatically; `Patcher.check_slot_mapping` then rejects both a missing mapping and an
unwanted one.

## Development

```bash
pip install -e ".[dev]"
pytest
ruff check . && ruff format --check .
mypy src tests
```

That test run reports `5 deselected`: `tests/test_packaging.py` asserts the import came from
an installed distribution, so it fails against the source tree by design. Running that file
on its own selects nothing and exits `5`. CI's `wheel` job selects it with `-m packaging`
after installing a built wheel.

`tests/test_readme.py` pins this file against the code: the flag table against the argument
parser, the exported names against `__all__`, the event and payload tables against the
renderer and the command handlers, the games table against the registry, and every Python
example against the interpreter. A claim here that stops being true fails the build.
