A Python tool that does what JagView 1.0 does - decode Jaguar pixel formats out of a ROM image - and then keeps going: it searches a ROM for graphics, guesses their width, finds their palettes, and unpacks compressed ones.
Nothing about any particular game is baked in. Everything is driven from the command line, so the same commands work on any Jaguar cartridge image.
python3 jagfx.py <command> [options]
Requires Python 3.7+ and Pillow. Nothing else.
Everything above came out of one cartridge with extract_ssie_gfx.bat: three
uncompressed 16-bit screens, six of the nine LZ-packed 8bpp banks decoded with
the palette each screen actually loads, and three of the seven pitch surfaces.
Pulling a known image out of a ROM is easy once you know the offset, the width and the pixel format. The hard part is that a 2 MB cartridge has none of that written down: graphics have no headers, no magic numbers and no file table. JagView's answer is to let you scroll through the whole ROM by hand until something recognisable appears.
jagfx automates the search instead:
scansweeps the ROM looking for regions whose bytes repeat at a fixed row stride - the signature of bitmap data - and reports the offset, size and the widths that stride implies for each pixel format.widthsdoes the same for one offset, ranking candidate widths so you can settle an image whose edges you can see but whose width you can't.clut/matchclutfind palettes: the first by looking for tables that look like palettes, the second by working backwards from indexed pixel data to the palette that makes it look like a picture.findcodec/streamsdeal with packed graphics, described below.maprenders whole address ranges as one image, for when you want to eyeball a region the way JagView does but all at once.
| Command | What it does |
|---|---|
formats |
list the pixel formats and codecs |
info |
summarise what kind of data lives where in a ROM |
map |
render a whole range as one tall image, or as pages |
extract |
pull one image at a known offset (optionally unpacking first) |
tiles |
slice a run of fixed-size tiles or sprite frames into a sheet |
bookmarks |
read (and extract from) a JagView bookmarks.txt |
scan |
hunt for bitmap data automatically |
widths |
guess the row width at a given offset |
clut |
find or dump palettes |
matchclut |
find the palette that best fits some indexed pixels |
findcodec |
work out how a blob is compressed by trying everything |
streams |
sweep a ROM for packed blobs a codec can decode |
ptrtable |
find tables of ROM pointers |
unpack |
decompress a blob |
python3 jagfx.py <command> --help documents every option.
Offsets are plain file offsets by default, and accept 0x, $, h or decimal
notation. Prefix an offset with @ to give a CPU address instead, which is
resolved through --base:
# a cartridge image mapped at $800000
python3 jagfx.py --base 0x800000 extract rom.jag @0x8ca508 -f cry16 -w 320 -H 256 -o title.png--base is global and goes before the command.
| Name | Bits | Notes |
|---|---|---|
cry16 |
16 | CRY - 4 bits cyan, 4 bits red, 8 bits intensity |
cry16-7 |
16 | CRY with the low intensity bit masked off |
rgb16 |
16 | Jaguar RGB: R5 in bits 15..11, B5 in 10..6, G6 in 5..0 |
rgb15 |
16 | as rgb16 with a 5-bit green |
rgb16le |
16 | rgb16 read from a byte-swapped dump |
rgb24-68k |
32 | 24-bit RGB in 68000 byte order |
rgb24 |
32 | 24-bit RGB in GPU/DSP byte order |
pal1/2/4/8 |
1/2/4/8 | CLUT indexed, most significant bits first |
grey8 |
8 | 8bpp shown as grey when no palette is known |
The CRY conversion tables are taken verbatim from JagView, so output matches it
byte for byte. rgb16 is genuinely R5 B5 G6 - the green really does live in
the low six bits, which is why JagView labels it "RGB 5-5-6".
Supply a palette to the indexed formats with --clut OFFSET (a 16-bit table in
the ROM), or --clut-file (a raw dump, a JASC .pal or a GIMP .gpl). CLUT
entries are themselves 16-bit pixels, so --clut-format takes any 16-bit
format name and defaults to cry16 - but check, because a game can hold RGB16
in the CLUT just as happily.
clut -o writes a palette out, picking the format from the extension:
| Extension | Format |
|---|---|
.txt |
Paint.NET palette - one AARRGGBB line per entry |
.pal |
JASC-PAL |
.gpl |
GIMP palette |
.png |
swatch grid, for eyeballing |
--transparent N writes that entry with alpha 00 (.txt only).
--indexed writes a palette PNG that keeps the original index bytes with the
CLUT attached, rather than flattening to RGB. That is usually what you want for
CLUT formats: the file is a fraction of the size, the indices survive for
re-import or recolouring, and PNG's tRNS chunk carries per-index transparency.
--transparent N marks palette index N transparent, which is what you want for
sprite sheets - they reserve an index for the background and without it every
frame comes out on a slab of whatever colour the palette puts there.
--alpha is the general form: comma separated RANGE[:ALPHA[:RRGGBB]] items,
so --alpha 0,28-31:102:000000 knocks out the background and turns a
pre-darkened shadow ramp into 40% black.
Telling 8bpp from 16bpp is harder than it sounds, because 8bpp data read as
16bpp still shows the right picture at half the width, just in nonsense
colours. widths prints a byte-depth line for this: it compares the byte
distributions at even and odd positions, which are near-identical for one byte
per pixel and wildly different for two (CRY's chroma and intensity bytes mean
different things).
bookmarks reads JagView's bookmarks.txt directly:
python3 jagfx.py bookmarks -F tools/bookmarks.txt
python3 jagfx.py bookmarks rom.jag -F tools/bookmarks.txt --filter "Sensible" -o out/One wrinkle worth knowing: the number JagView stores is not a ROM offset.
JagView pre-converts the entire ROM into a 24-bit RGB buffer and saves the
caret as a byte offset into that, so the stored value is three times the pixel
index. jagfx converts in both directions, so offsets it prints are real ROM
offsets and bookmarks it writes load correctly in JagView.
scan --bookmarks found.txt writes its discoveries back out in JagView's
format, so you can hunt with jagfx and then browse the results in JagView.
Only worth doing for uncompressed data, though - JagView has no depacker, so a
bookmark pointing at a packed blob just shows noise.
Jaguar cartridges have no standard compression - every studio wrote its own,
usually something small enough to run as a GPU routine. So instead of
hard-coding one scheme, jagfx ships a family of parameterised codecs and a
brute-force identifier.
Codecs: lz-bits, lzss, lzss-rel, rle, rle-word, packbits, zlib,
none.
findcodec tries every codec across a wide parameter sweep and ranks the
results. Ranking uses more than output length: a correct depacker produces
output that repeats at a single row stride, that stride divides the output
evenly, and the byte distribution is not uniform. Wrong parameters cheerfully
chew through the input and emit noise that uses all 256 values evenly, which is
the giveaway.
python3 jagfx.py --base 0x800000 findcodec rom.jag @0x8a7840 --size 0x8000 --preview guess.pngIt is a heuristic, not an oracle - read the table and use --preview to compare
the top few visually.
streams sweeps a whole range trying to depack at every offset and reports
where a codec produces a long, coherent result. Packed blobs have no magic
number, so trying everywhere is the only way to find them. Each hit reports how
many packed bytes it consumed, and the sweep resumes exactly there, so blobs
stored back to back are all found.
python3 jagfx.py --base 0x800000 streams rom.jag -c lz-bits --min-output 0x8000 -o unpacked/extract -c CODEC unpacks and decodes in one step.
The default parameters describe a bit-oriented LZSS with an 8 KB ring buffer:
1 <8 bits> emit that literal byte
0 <13-bit offset> <4 bits> copy (len + 3) bytes from ring[offset]
0 <13 zero bits> end of stream
Everything produced is mirrored into the ring buffer as it is emitted, and the write position starts at 1.
The offset is an absolute index into the ring, not a back-distance, and
this distinction is nastier than it looks. Both readings consume the bitstream
identically, so the wrong one still produces output of exactly the right
length that terminates in exactly the right place - only the copied bytes are
wrong. The result is an image with correct structure and correct dimensions
that is speckled with noise, which reads as "nearly right, needs a tweak"
rather than "wrong algorithm". Set window=0 for the back-distance variant;
findcodec always tries both.
Override anything with
--codec-options '{"off_bits": 12, "len_bits": 5, "window": 0}'.
The commands below reproduce a full teardown of one cartridge. Nothing here is built into the tool - it is all discovered with the commands above.
The ROM is a cartridge image mapped at $800000, so every command uses
--base 0x800000.
bookmarks gives three known images, and widths confirms 320 pixels:
python3 jagfx.py --base 0x800000 bookmarks "Sensible Soccer International Edition (1995).jag" -o out/Following the boot loader's copy table gives their exact bounds:
| Address | File offset | Size | Contents |
|---|---|---|---|
$8ca508 |
0x0ca508 |
0x28000 |
320×256 cry16 background |
$8f2508 |
0x0f2508 |
0x28000 |
320×256 cry16 photo |
$91a508 |
0x11a508 |
0x1f400 |
320×200 rgb16 Renegade logo |
$8b6508 |
0x0b6508 |
0x14000 |
320×256 pal8 |
python3 jagfx.py --base 0x800000 extract "…jag" @0x91a508 -f rgb16 -w 320 -H 200 -o logo.pngLeftover build strings in the ROM name nine .lzj files (cjcgraf.lzj,
pitchr.lzj, cjcteam.lzj …) alongside the uncompressed .cry, .rgb and
.256 ones - so the in-game art is packed with a scheme the developers wrote
themselves, and it is decompressed by a GPU routine that only exists in GPU
RAM at runtime, so there is no 68000 depacker in the cart to read.
findcodec identifies the shape, and streams finds every blob. Packed blobs
are phrase aligned, so --align 8 both speeds the sweep up and stops it
locking onto an offset a byte or two early:
python3 jagfx.py --base 0x800000 streams "…jag" -c lz-bits --limit 0x14000 \
--min-output 0x8000 --align 8 -o unpacked/That finds exactly nine streams - one per named .lzj file - each unpacking to
exactly 81920 bytes = 320×256 at 8bpp:
| Offset | Packed | Contents |
|---|---|---|
0x0947c0 |
19474 | scoreboard font, alphabet, HALF-TIME/FULL-TIME, kit icons |
0x0993d8 |
15528 | flags, goal, pitch diagram, small logo |
0x09d0b0 |
12850 | referee and player frames, tactics icons |
0x0a02e8 |
12779 | player animation frames |
0x0a35c0 |
17024 | "Sensible Soccer International Edition" title logo |
0x0a7840 |
20450 | large kit graphics - shirts, shorts, socks |
0x0ac828 |
13100 | player animation sheet |
0x0afb58 |
13443 | player animation sheet |
0x0b2fe0 |
13603 | player animation sheet |
extract_ssie_gfx.bat in this repo runs the whole extraction, and doubles as a
worked reference for every offset and palette above.
They are packed back to back and all nine chain: decompress one, round its consumed length up to the next multiple of 8, and that is the next stream.
All nine are 8bpp indexed, which widths will tell you - its byte-depth line
reports near-zero divergence between even and odd byte positions, the
signature of one byte per pixel. (Rendering them as 160×256 CRY16 also
produces legible shapes, because 8bpp data read as 16bpp still shows the right
picture at half width with nonsense colours. The divergence test settles it.)
To render one, transparent background and all:
python3 jagfx.py --base 0x800000 extract "…jag" @0x8ac828 \
-c lz-bits -f pal8 -w 320 -H 256 --clut 0x6fa42 --transparent 0 \
-o sprites.pngThe game keeps two 256-entry CRY palettes and copies whichever the current
screen needs into the hardware CLUT at $F00400:
| DRAM | File | Used by |
|---|---|---|
$1a8e2 |
0x6fa42 |
in-game - pitch, players, menus |
$1a4e2 |
0x6f642 |
team / player screen - kits and faces |
They differ in what slots 8–31 hold. In-game those are the grass ramp; on the
team screen the same slots hold hair and skin ramps. Render the kit bank under
the in-game palette and the player portraits come out olive-green, because
their faces are being painted with grass colours. Only 0x8a7840 needs the
second palette.
The pitch is different again, and shows the pattern in its purest form. Each of
the seven surface types has its own 16-entry ramp, and the tile that uses it
is indexed into its own band: tile 0 uses indices 16–31, tile 1 uses 32–47, and
so on up to 112–127. The seven ramps sit back to back at DRAM $1ad08
(file 0x6fe68), so reading a 256-entry CLUT from 32 bytes earlier drops all
of them onto the right indices at once - and these are RGB16, not CRY:
python3 jagfx.py --base 0x800000 tiles "…jag" @0x8b6508 -f pal8 \
-w 64 -H 64 --sheet-width 320 -n 7 \
--clut 0x6fe48 --clut-format rgb16 --indexed --split pitch/That is worth remembering in general: a Jaguar CLUT is 256 entries of shared
state that each screen overwrites, and different regions of it can be in
different colour spaces. "The palette for this image" is a property of when
it is drawn, not of the image. matchclut can suggest a candidate, but the
reliable route is to find the code that loads $F00400 (or a staging buffer
copied into it) and see which table it reads.
Worth recording because it is not obvious from the data alone. The routine at
DRAM $198470 takes a single 64×64 tile, keeps the low nibble of each 8bpp
index, packs two pixels per byte and lays the tile out 6 across by 5 down into
a 384×320 4bpp buffer at $59540. The scroll counters wrap at 320 and 256, so
the field is a seamlessly repeating 320×256 area with one tile of slack - the
visible window can be blitted from any scroll offset without wrap logic. There
is no tilemap; the whole pitch surface is one tile.
The markings are not in that buffer. Three of them exist as sprites in the
09D0B0 bank, at the source rects a display list at file 0x24958 names:
| Source | Size | What |
|---|---|---|
| (16, 105) | 128×85 | centre circle, with the halfway-line stubs |
| (144, 137) | 96×15 | penalty arc |
| (144, 154) | 96×15 | penalty arc |
They use indices 16–24 - the pitch's own ramp, with 16 as the white - which is
how you tell them apart from the tactics-screen mini pitch, a separate 167×128
picture in the 0993D8 bank that uses indices 32–41 and 96–105 instead.
What is not known is the coordinate table that positions the markings, or anything that draws the straight lines. Nothing in the 68000 code writes to the grass buffer after the tiler runs, so the lines must be drawn per frame from world coordinates - blitter fills or OP objects - and that logic lives in the GPU routine, which only exists in GPU RAM at runtime. Reconstructing a complete pitch would need that traced in an emulator.
For reference, the boot code at $802020 copies two blobs into DRAM and jumps
to the second:
| Cartridge | Length | Runs at | Contents |
|---|---|---|---|
$8020c8 |
0x2b920 |
$190000 |
engine, NTSC |
$82d9e8 |
0x2b778 |
$190000 |
engine, PAL |
$859160 |
0x16ef0 |
$004000 |
main code, NTSC |
$870050 |
0x16d58 |
$004000 |
main code, PAL |
$886da8 |
- | - | data begins |
That mapping is what makes DRAM addresses in the disassembly resolvable back to file offsets, and it is worth working out for any ROM before chasing pointers.
The package works as an importable module too:
from jagfx import rom, render, compression, scan
r = rom.Rom("game.jag", base=0x800000)
img = render.extract(r, 0x0ca508, 320, 256, "cry16")
img.save("title.png")
packed = r.data[0x0a7840:]
pixels = compression.decompress("lz-bits", packed, limit=0x14000)
clut = rom.read_clut(r, 0x6fa42, 256, "cry16")
render.decode_image(pixels, 320, 256, "pal8", clut).save("pitch.png")
for hit in scan.merge(scan.sweep(r, 0xb0000, 0x140000)):
print(hex(hit["offset"]), hit["stride"])jagfx.py launcher - python3 jagfx.py <command>
jagfx/cry.py CRY colour space tables
jagfx/formats.py pixel format decoders
jagfx/rom.py ROM container, palettes, JagView bookmarks
jagfx/render.py images, contact sheets, ROM maps
jagfx/scan.py stride detection, region classification, palette matching
jagfx/compression.py codecs and the brute-force identifier
jagfx/cli.py command line interface
