Custom image.Image Format in Go

April 2018

Nachdem ich vor ein paar Wochen über Sprites in NES ROMs geschrieben habe, wollte ich gerne eine standardisiertere API anbieten als die verwendete:

Read(io.Reader) ([][]byte, error)

Entschieden habe ich mich dazu, ROMs wie Bilder zu verarbeiten, wozu sich

image.Decode(io.Reader) (image.Image, error)

anbietet. Es wählt einen Decoder aus zuvor registrierten Formaten aus.

Vor dem main()-Aufruf, werden alle zuvor erstellten init() Funktionen verarbeitet. Dort kann mit image.RegisterFormat ein neues Bildformat registriert werden. Die akzeptierten Argumente sind der Name des Formats (string, hier "nes"), ein fixer Teil des Headers zur Bestimmung des Formats: magic (string, Fragezeichen (?) sind Wildcards) und die Funktionen decode und decodeConfig.

decode(io.Reader) (image.Image, error)
decodeConfig(io.Reader) (image.Config, error)

Diese dekodieren das gesamte Bild resp. nur dessen Header.

Die ersten 4 Byte einer NES ROM sind fix: NES\x1a, gefolgt von zwei Byte, die je die Anzahl der PRG- und der CHR-Banks angeben. Ersteres speichert das Programm, letzteres dessen Grafik. Der verwendete magic-String kann also NES\1xa sein. Das Dekodieren ist im verlinkten Post genauer beschrieben.

func init() {
	image.RegisterFormat("nes", "NES\x1a", Decode, DecodeConfig)
}

Die decodeConfig-Funktion gibt ein image.Config-Struct zurück:

type Config struct {
	ColorModel    color.Model
	Width, Height int
}

Das verwendete ColorModel ist hier frei wählbar, da keine Referenz existiert. Um es einfach zu halten wird hier RGBA verwendet. Die Größe des Bildes setzt sich zusammen aus der Anzahl der CHR-Banks und der Positionierung der Sprites im Bild. Die hier beschriebene Implementation druckt 16 Sprites nebeneinander. Daraus ergibt sich eine Breite von 16 * 8, wobei 8 die Breite eines Sprites ist. Eine CHR-Bank hält 512 Sprites, wodurch das Bild |CHR-Banks| * 512 / 16 * 8 groß wird.

Zwischen dem Header und dem CHR-ROM liegt der PRG-ROM. Die Größe der beider ROMs ist im Header beschrieben, ist aber nicht im image.Config-Struct enthalten. Da die Informationen über das ColorModel und die Größe des zu erstellenden Bildes in beiden decode*-Funktionen relevant ist, wurde das parsen des Headers in ein nicht exportierte Funktion decodeConfig ausgelagert1.

decodeConfig(io.Reader) (image.Image, int, int, error)

DecodeConfig proxyt decodeConfig, während Decode auch die restlichen Bereiche der ROM ausließt und schließlich das Bild erstellt.

func decodeConfig(r io.Reader) (image.Config, int, int, error) {
	h := make([]byte, headerSize)
	if _, err := io.ReadFull(r, h); err != nil {
		return image.Config{}, 0, 0, errors.Wrap(err, "could not read NES header")
	}
	if h[5] == 0 {
		return image.Config{}, 0, 0, errors.New("no tiles in CHR ROM")
	}
	config := image.Config{
		ColorModel: color.RGBAModel,
		Width:      spritesPerRow * spriteWidth,
		Height:     int(h[5]) * spritesPerBank / spritesPerRow * spriteWidth,
	}
	return config, int(h[4]), int(h[5]), nil
}

// DecodeConfig returns the color model and dimensions of a NES ROM without
// decoding the entire image.
func DecodeConfig(r io.Reader) (image.Config, error) {
	c, _, _, err := decodeConfig(r)
	return c, err
}

// Decode reads a NES ROM from r and returns it as an image.Image.
func Decode(r io.Reader) (image.Image, error) {
	c, prgBanks, chrBanks, err := decodeConfig(r)
	if err != nil {
		return nil, err
	}
	img := image.NewRGBA(image.Rect(0, 0, c.Width, c.Height))

	// Actually decode the file.

	return img, nil
}

Nachdem beide Funktionen registriert sind, kann eine NES ROM genau wie Bilder mit image.Decode dekodiert werden:

import (
	"image"
	"os"

	_ "github.com/bake/nes"
)

func main() {
	r, err := os.Open("rom.nes")
	if err != nil {
		log.Fatal(err)
	}
	defer r.Close()
	img, _, err := image.Decode(r)
	if err != nil {
		log.Fatalf("could not decode image: %v", err)
	}
}

Als Beispiel für einen “realen” Anwendungsfall existiert der nes-viewer. Er nimmt eine ROM als Argument und öffnet ein Fenster in dem der CHR-ROM angezeigt wird. Das Fenster wird durch github.com/bake/canvas und SDL2 gezeichnet.

Die Implementation liegt auf github.com/bake/nes.


  1. Beide decode*-Funktionen müssen nicht öffentlich sein, sie sind es aber in den Implementationen der Standard Library. ↩︎