↓ Skip to main content

A Sensible Soccer ROM editor for the Mega Drive

·1231 words

I’ve put together a Sensible Soccer ROM editor for the Mega Drive. You can use it to change team and player names, formations, skills and kits, then download a modified ROM. It runs entirely in the browser, so there’s nothing to install and the ROM file stays on your computer.

The interface is React, but the more interesting part is the TypeScript library underneath it. Changing a player name isn’t quite as simple as finding some text in a hex editor and replacing it. The text is packed into five-bit characters, and changing its length can move all the teams which follow it.

Finding the team data
#

The two supported editions are International and Original/European. These have different team counts and store the data at different addresses:

Edition National teams Club teams Custom teams Pointer table
International 51 64 64 0x01EF22
Original/European 40 64 64 0x01EA42

Both use the same overall arrangement. There are three regions of team data, with a two-byte gap between each:

[national teams] [00 00] [club teams] [00 00] [custom teams]

A table of six big-endian 32-bit values holds the start and end addresses:

+0x00  national start
+0x04  club start
+0x08  custom start
+0x0C  national end
+0x10  club end
+0x14  custom end

The library checks the two known table locations first. If neither is valid, it scans word-aligned candidates in the first 0x30000 bytes of the ROM. It checks the address order, alignment, region gaps and the blocks within each region before accepting a table.

This avoids relying on particular team or player names to identify the data. Those are exactly the things the editor lets you change, so using them as a signature would cause problems when reopening an edited ROM!

Inside a team block
#

Each team starts with 150 bytes of attributes, followed by a variable-length text section:

Offset     Contents
0x00       Total block size, including text and padding (2 bytes)
0x02       Packed positions of team, country and coach names (6 bytes)
0x08       First and second kit attributes (10 bytes)
0x12       Team attributes (4 bytes)
0x16       16 player records, 8 bytes each (128 bytes)
0x96       Packed text
           Optional zero byte to align the next block

The size word makes it possible to walk through a region by adding each block’s size to its address. The decoder checks that every block stays within the region and that the walk finishes exactly at the region’s end pointer.

The player records pack several values into individual bytes. After the two-byte name position, one byte holds the position in its high four bits and the shirt number minus one in its low four bits. The next byte contains the head type, role and star-player flag:

Position/number byte:  pppp nnnn
Appearance byte:       ??? s rr hh

The ? bits aren’t edited. The writer starts with a copy of the original attribute block and masks in the fields it understands, preserving the remaining bits and bytes. This matters when working with a format where not every field is exposed in the editor.

Five-bit text
#

The team name, country, coach and sixteen player names are stored as nineteen strings in a single bitstream. Each character uses five bits, with zero marking the end of a string. The character set is:

0       End of string
1–26    A–Z
27      Space
28      -
29      '
30      .

For example, AB is encoded as:

00001 00010 00000
  A     B    end

That’s fifteen bits, so the next string doesn’t necessarily begin on a byte boundary. Padding each name to a whole byte would give the wrong result. The encoder joins all nineteen strings before packing them into bytes, then adds a byte at the end of the block if needed to keep the next team word-aligned.

Each string has a 16-bit position value in the attributes. This combines an even byte offset, relative to the start of the team block, with a bit offset:

packedPosition = (byteOffset << 5) | bitOffset;

The first string starts at byte 150, bit zero, giving 0x12C0. The bit offset runs from 0 to 15; when it crosses a word boundary, the byte offset advances by two.

To regenerate these positions, computePackedPositions() follows the game’s decoding loop, loading and rotating 32-bit values while consuming five bits at a time. One JavaScript detail to watch here is the right-shift operator: >> extends the sign bit, whereas >>> shifts in zeroes. The rotation code uses unsigned shifts so a value with bit 31 set doesn’t produce the wrong character.

The decoder also limits each string read to its containing block. A missing terminator must produce an error, rather than letting it carry on reading the next team’s attributes as text.

Writing the changes back
#

The library exposes decodeRom() and updateRom(), both working with Uint8Array ROM data. The decoded representation is an ordinary object with national, club and custom arrays, which is also the format used for JSON import and export.

The basic sequence in application code is:

import { decodeRom, updateRom } from './lib/sslib';

const romBytes = new Uint8Array(await file.arrayBuffer());
const teams = decodeRom(romBytes);

teams.national[0].team = 'MY TEAM';

// Validates the data and returns a new buffer, leaving romBytes alone.
const modifiedRom = updateRom(romBytes, teams);

This is an example for code inside the frontend, with file being the selected browser File. The edit can still fail if there isn’t enough space.

updateRom() validates the supplied data itself, rather than trusting the interface to have done it. This includes the original category counts, sixteen players per team, valid formation slots, text limits and the total encoded size.

It then rebuilds each team block, recalculates the string positions and block sizes, and joins the three regions with their two-byte gaps. Since a longer name can move everything after it, all six region pointers are rewritten. If the new data is shorter, the unused part of the old team-data region is cleared.

There isn’t an unlimited amount of room for longer names. The library counts the existing region plus consecutive zero-filled words immediately after it as available capacity, stopping at the first nonzero word. It doesn’t expand the ROM or relocate unrelated data. This is why the interface shows a byte budget as well as checking individual name lengths.

Finally, the writer adds the big-endian 16-bit words from 0x200 to the end of the ROM, keeps the low sixteen bits of the sum, and writes the checksum at 0x18E.

Checking the result
#

The tests include synthetic ROM data assembled separately from the encoder, so decoding isn’t only tested against bytes produced by the same implementation. They check that an unchanged synthetic ROM survives an update byte-for-byte, and that edited data can be decoded again with the expected values and checksum.

There are also tests for truncated blocks, invalid string positions, missing terminators and edits which exceed the available space. These are useful checks for code which is moving variable-length data around inside a binary file.

The source is on GitHub, with the library under frontend/src/lib/sslib and more format notes in rom-structure.md.

To try the editor, open your own .md or .bin ROM and select a team. Keep an untouched copy of the original, and download the modified ROM or export JSON before closing the tab — unsaved edits only live in memory. This is for the supported Mega Drive/Genesis editions, not the Amiga or PC versions, and the site doesn’t provide ROM downloads.