Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

jagfx - Atari Jaguar graphics extractor and ROM searcher

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.

Graphics extracted from Sensible Soccer International Edition

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.


Why searching matters

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:

  • scan sweeps 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.
  • widths does 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 / matchclut find 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 / streams deal with packed graphics, described below.
  • map renders whole address ranges as one image, for when you want to eyeball a region the way JagView does but all at once.

Commands

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 and the address base

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.


Pixel formats

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).


JagView compatibility

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.


Compression

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.png

It 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.

lz-bits

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}'.

Worked example: Sensible Soccer International Edition (1995)

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.

Uncompressed screens

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.png

The packed in-game graphics

Leftover 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.png

The 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.

How the pitch is drawn

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.

ROM layout

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.


Library use

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"])

Files

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

About

Atari Jaguar graphics extractor and ROM searcher

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages