Everything here was derived from the cartridge images in roms/ using the tools
in tools/. Reproduce any of it with python3 tools/disasm.py info|discover.
| Region | SHA-1 | Header checksum |
|---|---|---|
| JU | 0c2fff7bc79ed26507c08ac47464c3af19f7ced7 |
B61C stored = computed |
| E | 5c1a2e327a656217604d4bae7e141764a7e59922 |
85FC stored = computed |
Both are 3,145,728 bytes (3 MB), title CHAOTIX, serial GM MK-84503-00,
(C)SEGA 1995.FEB. Both verify against their own header checksum, so the dumps
are clean. Work is done against the JU image; E differs by region byte and
localisation data.
The 32X is a Mega Drive with an add-on board, and a game runs on all of it:
| CPU | Clock | Role in this game |
|---|---|---|
| Motorola 68000 | 7.6 MHz | The game itself — engine, objects, level logic, sound driver |
| SH-2 master | 23 MHz | 32X-side command loop, driven by the 68000 |
| SH-2 slave | 23 MHz | Second renderer/worker, same command-loop shape |
| Z80 | 3.58 MHz | Sound sub-CPU |
The single most important structural finding: the SH-2 program is only 37,888 bytes total (a 36 KB SDRAM image plus a 1 KB overlay). Knuckles' Chaotix is a 68000 Sonic-engine game that uses the 32X as a video co-processor, not an SH-2 game. The 68000 is therefore the primary decompilation target; the SH-2 side is a comparatively small, tractable appendix.
module name 'MARS CHECK MODE'
SH-2 image ROM 0x077800 -> SDRAM 0x06000000, 36,864 bytes
master entry 0x060001A0 master VBR 0x06000000
slave entry 0x060001A4 slave VBR 0x06000080
68000 reset PC 0x000003F0 68000 SSP 0x00FF0200
Both SH-2 vector tables are 32 entries. Vector 0/2 are reset PC, 1/3 are reset
SP (0x06040000, the top of the 256 KB SDRAM). Every unused vector points at
0x060001A8, which is bra to itself — a deliberate hang on a stray interrupt.
The SH-2 encodes cache behaviour in the top three address bits, so each region
appears at several addresses (0x02000000 cached, 0x22000000 cache-through,
etc.). tools/mars.py:sh2_phys collapses these.
| Range | Contents |
|---|---|
0x00000000 |
per-CPU boot ROM |
0x00004000 |
32X system registers (comm ports, interrupt control) |
0x00004100 |
32X VDP registers |
0x00004200 |
palette, 512 bytes |
0x02000000 |
cartridge, 4 MB window |
0x04000000 |
frame buffer, 128 KB (+ overwrite image at 0x04020000) |
0x06000000 |
SDRAM, 256 KB — all SH-2 code runs here |
0xFFFFF000 |
on-chip peripherals (DMAC, FRT, INTC, WDT) |
| Range | Contents |
|---|---|
0x840000 |
32X frame buffer window (+ overwrite at 0x860000) |
0x880000 |
first 512 KB of cartridge, fixed |
0x900000 |
1 MB banked cartridge window |
0xA00000 |
Z80 space |
0xA10000 |
controller / version I/O |
0xA15100 |
32X system + PWM registers |
0xC00000 |
Genesis VDP |
0xFF0000 |
64 KB work RAM |
The 68000 vector table confirms this: exception vectors point at 0x00880B2E,
i.e. cartridge offset 0xB2E seen through the 0x880000 window. Vectors 9–11
point into 0xFFFFC0xx, a dispatch table the game builds in work RAM.
68000 (0x3F0) does the standard 32X handshake: masks interrupts, checks for
the literal "MARS" signature at 0xA130EC, waits on the adapter-control bit at
0xA15101, then proceeds into game init.
SH-2 master (0x060001A0) branches to 0x06000884, which programs the
free-running timer at 0xFFFFFE10, sets VBR/SP, points r14 at the system
registers (0x20004000), waits on a comm register, then spins until the 68000
writes "REDY" (0x52454459) to 0x06003610. It then enters a command loop:
poll comm register 0x20004020, look the command up in a table, and dispatch.
SH-2 slave (0x060001A4) is the same shape, reading its command byte from
0x20004005 and dispatching through a table of 16-bit offsets.
So the control relationship is: the 68000 drives; the SH-2s serve commands.
The SH-2 program is not only the SDRAM image. The slave's init routine at
0x06000284 copies 0x100 longs from cartridge offset 0x07FC00 into
0xC0000000 — the SH-2 cache data array — and then calls into it. Running
from the cache array avoids cartridge wait states, and the 32X allows the cache
to be used as directly addressable RAM.
This matters for the address model: the SH-2 encodes cache behaviour in the top
address bits, but only 0x00000000 (cached) and 0x20000000 (cache-through)
are mirrors of each other. 0x60000000 (cache address array) and 0xC0000000
(cache data array) are distinct storage. Collapsing all of them — as a naive
addr & 0x1FFFFFFF does — aliases this routine onto the boot ROM.
The overlay has its own header of bra stubs, one per entry point, mirroring
the SDRAM image's layout at 0x060001A0. Its tail is 0xAAAA/0xFFFF fill.
Two halves, and they belong to different processors.
The 32X's own is PWM, and the whole of it is that cache-array overlay. The
slave programs the unit from 0xC0000008 — cycle 0x417, control 0x0105, so
TM is 1 — and then does nothing but take the resulting timer interrupt, which is
its only clock. The handler enters at 0xC0000004; every second one mixes four
channels of 8-bit PCM at 0xC000012C and pushes two stereo pairs into the
sample FIFOs at 0x20004034 and 0x20004036. A channel's state is a 16-byte
table entry — start, length, loop, rate — and its volumes come out of the high
byte of the command word the master writes.
| register | |
|---|---|
0x20004030 |
control: TM in bits 11-8, RMD in 3-2, LMD in 1-0 |
0x20004032 |
cycle; one PWM cycle is cycle - 1 SH-2 cycles |
0x20004034 / 36 / 38 |
left / right / mono FIFO, three words deep |
A word written to a FIFO is a pulse width, so the cycle's midpoint is silence;
this driver centres on 0x200 against a midpoint of 523. A read of the same
address gives bit 15 full, bit 14 empty. The 68000 sees the same block at
0xA15130, and uses it only to clear the unit during its 32X init.
The Mega Drive's is a Z80 driver in the 8 KB at 0xA00000, uploaded by the
68000 and running under 4 KB of code. It writes the YM2612 through both port
pairs — $4000/$4001 for channels 1-3, $4002/$4003 for 4-6 — and the PSG
at $7F11, and it banks the 68000 window through $6000 to reach its data in
the cartridge. The 68000 also writes the PSG directly at 0xC00011. Every
audible sound in the opening is here: over the whole reference extract the 32X's
PWM output is a constant 0x200.
The Z80's map, and the two registers that decide whether it runs at all:
| Z80 sees | |
|---|---|
0x0000-0x1FFF |
its RAM, mirrored through 0x3FFF |
0x4000-0x5FFF |
the YM2612, four registers mirrored every four bytes |
0x6000 |
the bank register: one bit a write, nine writes to a bank |
0x7F00-0x7FFF |
the Mega Drive VDP, with the PSG at 0x7F11 |
0x8000-0xFFFF |
32 KB of the 68000's space at bank << 15 |
| 68000 sees | |
|---|---|
0xA00000-0xA01FFF |
the same 8 KB, one byte at a time |
0xA11100 |
bus request; bit 8 reads back set while the Z80 still has it |
0xA11200 |
reset, released on the rising edge |
Its program has no static existence: the 68000 assembles it into RAM at run
time, and the only place to read it from is a running machine's Z80 RAM. At the
console reset the reference's Z80 is already executing a leftover stub — di / im 1 / jp 0x005B — and the driver proper appears when the 68000 has loaded it
and pulsed reset, restarting at 0x0000 with xor a / ld bc,0x1FD9. It runs
9,528 instructions between two vertical interrupts, which are the only thing
that wakes it.
Nothing is audible during the logo, on any of the three. The driver's init
attenuates all four PSG channels to 15 and it never lifts one for as long as the
reference logs run; the 32X's PWM output is a constant 0x200 over the same
span; and the YM2612 gets 80 register writes, which is a voice being set up
rather than played. The music starts later — at 1,800 frames all three are
going, the PSG with three channels at attenuations 8, 5 and 7 and the FM on a
bass line an octave below them.
The YM2612 runs at the 68000's clock divided by 144, which is 53,266 samples a
second and 888.93 to a frame. This game never enables channel 6's DAC, the
usual way a Mega Drive plays a drum sample: zero writes to 0x2A in half a
minute of play, so whatever percussion there is comes from the PSG's noise
channel or from the 32X.
Three forms account for essentially all table-driven control flow, and recognising them is what makes discovery work at all:
| Shape | Entries | |
|---|---|---|
| A | mova Lbase,r0 ; mov.l @(r0,rM),rN ; jmp @rN |
32-bit absolute addresses |
| B | mova Lbase,r0 ; mov.w @(r0,rM),rN ; braf rN |
16-bit offsets from PC+4 |
| C | mov.l Lp,rB ; mov.b @(r0,rB),rT ; braf rT |
8-bit offsets from PC+4 |
In A and B the mova names the table exactly. In C the literal is an indexing
origin that sits before the real entries, so the table is instead located
immediately after the branch's delay slot, and it is bounded by the lowest
target it points at — a table cannot extend into the code it dispatches to.
Note also that bsrf/braf compute PC + 4 + Rn rather than jumping to Rn.
That is the standard far-call form, so treating the register as an absolute
address stops discovery at the very first one.
python3 tools/disasm.py discover
functions : 208
basic blocks : 1,772
instructions : 9,243 (18,486 bytes)
data (pool+tbl) : 2,448 bytes
dispatch tables : 15 recovered
unresolved : 8 indirect transfers
SDRAM blob : 55.4% classified; overlay: 488/1,024 bytes
Of the 16,432 unclassified bytes in the SDRAM blob, only ~190 still decode as plausible code; the rest are data tables and buffers. The blob is a mixed code+data image, so full byte coverage is not the goal — the goal is that every byte is either understood or provably not code.
The 8 remaining unresolved transfers are genuine runtime function pointers
(jsr @r12, jsr @r14), where the callee is chosen by the caller at run time.
Resolving them needs interprocedural dataflow, not better local pattern
matching.
python3 tools/emit_asm.py --verify emits a full listing and reassembles it
with sh-elf-as:
sh2_sdram 36,864 bytes identical
sh2_overlay_c0000000 1,024 bytes identical
Both hold for the JU and E images. This is the front end's correctness gate: if a single instruction were decoded wrongly, an operand mis-rendered, or a literal pool mistaken for code, the bytes would not match.
One caveat the round-trip cannot catch: bytes are bytes, so misclassifying data
as code still reassembles. Recursive descent does walk into literal pools via
architecturally-valid but semantically unreachable fallthrough edges — at
0x0600540E a bf falls into a pool, because the preceding bt.s already
consumed the T-set case. Data proven by an instruction reference therefore
outranks a code claim that arrived only by fallthrough.
A 16 KB-block entropy scan (~7.5+ = compressed, ~5 with high zero-fill =
code) shows the cartridge is dominated by compressed art in the classic
Sonic-engine style, with 68000 code concentrated below 0x200000:
000000-00BFFF data / 68000 code
00C000-00FFFF code
010000-01FFFF data + code
020000-07FFFF data (SH-2 image lives at 0x077800)
080000-09FFFF code + data
0A0000-11FFFF data
120000-16FFFF data
170000-2BFFFF mostly compressed art, interleaved with tables
2C0000-2FBFFF compressed art + data
2FC000-2FFFFF zero padding