[cmd] Add extract-imgs command

This command takes ntData and chrData from extracted .studybox roms and
constructs images.  The image data can contain either background or
sprite data.
This commit is contained in:
Zorchenhimer 2025-11-07 22:23:32 -05:00
parent 1ded375e60
commit 8527db5b81
Signed by: Zorchenhimer
GPG Key ID: 70A1AB767AAB9C20
4 changed files with 594 additions and 19 deletions

View File

@ -1,12 +1,10 @@
.PHONY: all
all: bin/script-decode bin/sbutil bin/just-stats
all: bin/script-decode bin/sbutil bin/just-stats bin/extract-imgs
bin/script-decode: cmd/script-decode.go script/*.go
go build -o $@ $<
bin/script-decode: script/*.go
bin/sbutil: rom/*.go
bin/just-stats: script/*.go
bin/sbutil: cmd/sbutil.go rom/*.go
go build -o $@ $<
bin/just-stats: cmd/just-stats.go script/*.go
bin/%: cmd/%.go
go build -o $@ $<

575
cmd/extract-imgs.go Normal file
View File

@ -0,0 +1,575 @@
package main
import (
"fmt"
"os"
"image"
"image/draw"
"image/color"
"image/png"
//"strings"
"encoding/binary"
"github.com/alexflint/go-arg"
nesimg "github.com/zorchenhimer/go-retroimg"
//"github.com/zorchenhimer/go-retroimg/palette"
)
type Arguments struct {
Nametable string `arg:"--nt,required"`
Chr string `arg:"--chr,required"`
Output string `arg:"--output,required"`
IsSprite bool `arg:"--sprites"`
SpriteSheet bool `arg:"--sprite-sheet"`
NoBg bool `arg:"--no-bg"` // don't draw hot-pink background
}
var (
tileMissing *nesimg.Tile
)
func main() {
args := &Arguments{}
arg.MustParse(args)
var err error
tileMissing, err = nesimg.NewTileFromPlanes([][]byte{
{0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33},
{0x0F, 0x0F, 0x0F, 0x0F, 0x0F, 0x0F, 0x0F, 0x0F} })
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err = run(args); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(args *Arguments) error {
fmt.Println("--")
fmt.Println("Nametable:", args.Nametable)
fmt.Println("CHR:", args.Chr)
fmt.Println("Output:", args.Output)
fmt.Println("IsSprite:", args.IsSprite)
fmt.Println("SpriteSheet:", args.SpriteSheet)
fmt.Println("--")
chrFile, err := os.Open(args.Chr)
if err != nil {
return err
}
defer chrFile.Close()
raw := nesimg.NewRawChr(chrFile)
tiles, err := raw.ReadAllTiles(nesimg.BD_2bpp)
if err != nil {
return err
}
//fmt.Printf("first tile: %#v\n", tiles[0])
//tileUniform_00 := image.NewUniformPaletted(nesimg.DefaultPal_2bpp, 0)
//tileUniform_F0 := image.NewUniformPaletted(nesimg.DefaultPal_2bpp, 1)
//tileUniform_0F := image.NewUniformPaletted(nesimg.DefaultPal_2bpp, 2)
//tileUniform_FF := image.NewUniformPaletted(nesimg.DefaultPal_2bpp, 3)
tileUniform_00, err := nesimg.NewTileFromPlanes([][]byte{
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} })
if err != nil {
return err
}
tileUniform_F0, err := nesimg.NewTileFromPlanes([][]byte{
{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF},
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} })
if err != nil {
return err
}
tileUniform_0F, err := nesimg.NewTileFromPlanes([][]byte{
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF} })
if err != nil {
return err
}
tileUniform_FF, err := nesimg.NewTileFromPlanes([][]byte{
{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF},
{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF} })
if err != nil {
return err
}
tiles = append([]*nesimg.Tile{
tileUniform_00,
tileUniform_F0,
tileUniform_0F,
tileUniform_FF,
}, tiles...)
ntData, err := os.ReadFile(args.Nametable)
if err != nil {
return err
}
layers, err := ReadData(ntData, tiles, args.IsSprite)
if err != nil {
return fmt.Errorf("ReadData() error: %w", err)
}
uni := image.NewUniform(color.RGBA{0xFF, 0x00, 0xFF, 0xFF})
var screen *image.RGBA
if args.SpriteSheet {
maxWidth := 0
maxHeight := 0
for _, l := range layers {
maxWidth += l.Bounds().Dx()
if l.Bounds().Dy() > maxHeight {
maxHeight = l.Bounds().Dy()
}
}
screen = image.NewRGBA(image.Rect(0, 0, maxWidth, maxHeight))
if !args.NoBg {
draw.Draw(screen, screen.Bounds(), uni, image.Pt(0, 0), draw.Over)
}
loc := image.Pt(0, 0)
for _, l := range layers {
draw.Draw(screen, l.Bounds().Add(loc), l, image.Pt(0, 0), draw.Over)
loc = loc.Add(image.Pt(l.Bounds().Dx(), 0))
}
} else {
screen = image.NewRGBA(image.Rect(0, 0, 32*8, 30*8))
if !args.NoBg {
draw.Draw(screen, screen.Bounds(), uni, image.Pt(0, 0), draw.Over)
}
for _, l := range layers {
if args.IsSprite {
draw.Draw(screen, l.Bounds().Add(l.Location), l, image.Pt(0, 0), draw.Over)
} else {
draw.Draw(screen, screen.Bounds(), l, image.Pt(0, 0), draw.Over)
}
}
}
output, err := os.Create(args.Output)
if err != nil {
return err
}
defer output.Close()
err = png.Encode(output, screen)
if err != nil {
return err
}
//img := image.NewRGBA(image.Rect(0, 0, 32*8, 30*8))
return nil
}
//type UniformPaletted struct {
// Palette color.Palette
// Index int
//}
//
//func NewUniformPaletted(palette color.Palette, idx int) *UniformPaletted {
// if idx >= len(palette) {
// panic("Index out of palette range")
// }
//
// return &UniformPaletted{
// Palette: palette,
// Index: idx,
// }
//}
//
//func (u *UniformPaletted) At(x, y int) color.Color {
// if u.Index >= len(u.Palette) {
// panic("Index out of palette range")
// }
//
// return u.Palette[u.Index]
//}
//
//func (u *UniformPaletted) ColorModel() color.Model {
// return u.Palette
//}
//
//func (u *UniformPaletted) Bounds() image.Rectangle {
// // Copied from image.Uniform.Bounds()
// return image.Rectangle{image.Point{-1e9, -1e9}, image.Point{1e9, 1e9}}
//}
func BytesToInt(raw []byte) int {
if len(raw) > 2 {
panic("only 8 and 16 bit numbers for now")
}
if len(raw) == 1 {
return int(raw[0])
}
return int(raw[1])<<8 | int(raw[0])
}
type DataHeader struct {
PaletteOffset uint16
ArgB uint16
ImageCount uint8
}
func (h DataHeader) String() string {
return fmt.Sprintf("{DataHeader PaletteOffset:$%04X ArgB:$%04X ImageCount:%d}",
h.PaletteOffset,
h.ArgB,
h.ImageCount,
)
}
type ImageHeader struct {
Width uint8
Height uint8
AttrLength uint16
// in pixels
X uint8
Y uint8
}
func (h ImageHeader) String() string {
return fmt.Sprintf("{ImageHeader Width:%d[%02X] Height:%d[%02X] AttrLength:$%04X XCoord:%d[%02X] YCoord:%d[%02X]}",
h.Width, h.Width,
h.Height, h.Height,
h.AttrLength,
h.X, h.X,
h.Y, h.Y,
)
}
func ReadData(raw []byte, tiles []*nesimg.Tile, isSprites bool) ([]*Layer, error) {
//raw, err := io.ReadAll(r)
//if err != nil {
// return nil, err
//}
if isSprites {
tiles = tiles[4:]
}
dataHeader := &DataHeader{}
_, err := binary.Decode(raw, binary.LittleEndian, dataHeader)
if err != nil {
return nil, err
}
dataHeader.PaletteOffset += 4
fmt.Println(dataHeader)
imgHeaders := []*ImageHeader{}
for i := 0; i < int(dataHeader.ImageCount); i++ {
head := &ImageHeader{}
_, err = binary.Decode(raw[4+(i*6)+1:], binary.LittleEndian, head)
if err != nil {
return nil, err
}
fmt.Println(head)
imgHeaders = append(imgHeaders, head)
}
palettes := []color.Palette{}
colorIds := [][]string{}
for i := 0; i < 4; i++ {
p := color.Palette{}
idlist := []string{}
for j := 0; j < 4; j++ {
v := raw[int(dataHeader.PaletteOffset)+(i*4)+j]
if v == 0x3D {
v = 0x0F
}
c, ok := NesColors[v]
if !ok {
return nil, fmt.Errorf("Color value $%02X invalid", v)
}
p = append(p, c)
idlist = append(idlist, fmt.Sprintf("%02X", v))
}
palettes = append(palettes, p)
colorIds = append(colorIds, idlist)
}
fmt.Println("Palettes:")
for i := 0; i < len(palettes); i++ {
fmt.Printf("%s: %v\n", colorIds[i], palettes[i])
}
nesTileSize := image.Rect(0, 0, 8, 8)
offset := 5 + len(imgHeaders) * 6
layers := []*Layer{}
for _, head := range imgHeaders {
rect := image.Rect(0, 0, 32, 30)
if isSprites {
rect = image.Rect(0, 0, int(head.Width), int(head.Height))
}
l := NewLayer(
rect,
nesTileSize,
palettes,
)
l.Transparency = isSprites
l.Location = image.Pt(int(head.X), int(head.Y))
l.IsSprite = isSprites
//tileIds := []int{}
if isSprites {
for idx, b := range raw[offset:offset+(int(head.Width)*int(head.Height))] {
id := int(uint(b)) // is this required to ignore negatives?
if id < len(tiles) {
l.Tiles[idx] = tiles[id]
}
}
} else {
for idx, b := range raw[offset:offset+(int(head.Width)*int(head.Height))] {
row := idx / int(head.Width)
col := idx % int(head.Width)
arrIdx := ((row+int(head.Y/8)) * 32) + (col+int(head.X/8))
//fmt.Printf("%d ", arrIdx)
id := int(uint(b)) // is this required to ignore negatives?
if id < len(tiles) {
l.Tiles[arrIdx] = tiles[id]
//tileIds = append(tileIds, id)
}
}
}
//for idx, t := range tileIds {
// fmt.Printf("[%d] %02X\n", idx, t)
//}
offset += int(head.Width)*int(head.Height)
if isSprites {
l.Attributes = raw[offset:offset+int(head.AttrLength)]
} else {
// FIXME: This will break on background chunks that aren't a full screen.
// Need to verify the anchor point in the firmware for this case (for
// when the tile anchor point isn't aligned to an attribute byte).
// Partial screen attributes will also be a different size than 8*8.
l.SetAttributes(raw[offset:offset+int(head.AttrLength)])
}
offset += int(head.AttrLength)
layers = append(layers, l)
}
return layers, nil
}
type Layer struct {
Tiles []*nesimg.Tile
Attributes []byte
Palettes []color.Palette
TileSize image.Rectangle
Location image.Point
Width int
Height int
Transparency bool // true for sprites
IsSprite bool
Solid bool
}
func NewLayer(layerSize image.Rectangle, tileSize image.Rectangle, palettes []color.Palette) *Layer {
return &Layer{
Tiles: make([]*nesimg.Tile, layerSize.Dx()*layerSize.Dy()),
Attributes: make([]byte, layerSize.Dx()*layerSize.Dy()),
TileSize: tileSize,
Location: image.Pt(0, 0),
Width: layerSize.Dx(),
Height: layerSize.Dy(),
Palettes: palettes,
}
}
func (l *Layer) At(x, y int) color.Color {
if l.Solid {
return color.RGBA{0x00, 0x00, 0x00, 0xFF}
}
width, height := l.TileSize.Dx(), l.TileSize.Dy()
row := y / height
col := x / width
tx := x % width
ty := y % height
tileIdx := (row*l.Width)+col
if x == 89 && y == 101 {
fmt.Printf("row:%d col:%d tx:%d ty:%d tileIdx:%d width:%d height:%d l.Width:%d l.Height:%d\n",
row, col, tx, ty, tileIdx, width, height, l.Width, l.Height,
)
}
if l.Tiles[tileIdx] == nil {
return color.RGBA{0x00, 0x00, 0x00, 0x00}
//return color.RGBA{0xFF, 0x00, 0xFF, 0xFF}
}
colorIdx := l.Tiles[tileIdx].ColorIndexAt(tx, ty)
if l.Transparency && colorIdx == 0 {
return color.RGBA{0x00, 0x00, 0x00, 0x00}
}
palIdx := l.Attributes[tileIdx]
return l.Palettes[palIdx][colorIdx]
}
func (l *Layer) Bounds() image.Rectangle {
return image.Rect(0, 0, l.TileSize.Max.X*l.Width, l.TileSize.Max.Y*l.Height)
}
func (l *Layer) ColorModel() color.Model {
return color.RGBAModel
}
func (sc *Layer) SetAttributes(data []byte) error {
if len(data) != 64 {
return fmt.Errorf("Attribute data must be 64 bytes")
}
sc.Attributes = make([]byte, 32*30)
for row := 0; row < 8; row++ {
for col := 0; col < 8; col++ {
src := row*8+col
start := (row*32)*4 + (col*4)
raw := data[src]
br := (raw >> 6) & 0x03
bl := (raw >> 4) & 0x03
tr := (raw >> 2) & 0x03
tl := raw & 0x03
//if row == 0 && col == 0 {
// fmt.Printf("br:%02X bl:%02X tr:%02X tl:%02X\n", br, bl, tr, tl)
//}
sc.Attributes[start+0+(32*0)] = tl
sc.Attributes[start+1+(32*0)] = tl
sc.Attributes[start+0+(32*1)] = tl
sc.Attributes[start+1+(32*1)] = tl
sc.Attributes[start+2+(32*0)] = tr
sc.Attributes[start+3+(32*0)] = tr
sc.Attributes[start+2+(32*1)] = tr
sc.Attributes[start+3+(32*1)] = tr
if row < 7 {
sc.Attributes[start+0+(32*2)] = bl
sc.Attributes[start+1+(32*2)] = bl
sc.Attributes[start+0+(32*3)] = bl
sc.Attributes[start+1+(32*3)] = bl
sc.Attributes[start+2+(32*2)] = br
sc.Attributes[start+3+(32*2)] = br
sc.Attributes[start+2+(32*3)] = br
sc.Attributes[start+3+(32*3)] = br
}
}
}
return nil
}
var NesColors map[byte]color.Color = map[byte]color.Color{
0x00: color.RGBA{0x66, 0x66, 0x66, 0xFF},
0x10: color.RGBA{0xAD, 0xAD, 0xAD, 0xFF},
0x20: color.RGBA{0xFF, 0xFF, 0xEF, 0xFF},
0x30: color.RGBA{0xFF, 0xFF, 0xEF, 0xFF},
0x01: color.RGBA{0x00, 0x2A, 0x88, 0xFF},
0x11: color.RGBA{0x15, 0x5F, 0xD9, 0xFF},
0x21: color.RGBA{0x64, 0xB0, 0xFF, 0xFF},
0x31: color.RGBA{0xC0, 0xDF, 0xFF, 0xFF},
0x02: color.RGBA{0x14, 0x12, 0xA7, 0xFF},
0x12: color.RGBA{0x42, 0x40, 0xFF, 0xFF},
0x22: color.RGBA{0x92, 0x90, 0xFF, 0xFF},
0x32: color.RGBA{0xD3, 0xD2, 0xFF, 0xFF},
0x03: color.RGBA{0x3B, 0x00, 0xA4, 0xFF},
0x13: color.RGBA{0x75, 0x27, 0xFE, 0xFF},
0x23: color.RGBA{0xC6, 0x76, 0xFF, 0xFF},
0x33: color.RGBA{0xE8, 0xC8, 0xFF, 0xFF},
0x04: color.RGBA{0x5C, 0x00, 0x7E, 0xFF},
0x14: color.RGBA{0xA0, 0x1A, 0xCC, 0xFF},
0x24: color.RGBA{0xF3, 0x6A, 0xFF, 0xFF},
0x34: color.RGBA{0xFB, 0xC2, 0xFF, 0xFF},
0x05: color.RGBA{0x6E, 0x00, 0x40, 0xFF},
0x15: color.RGBA{0xB7, 0x1E, 0x7B, 0xFF},
0x25: color.RGBA{0xFE, 0x6E, 0xCC, 0xFF},
0x35: color.RGBA{0xFE, 0xC4, 0xEA, 0xFF},
0x06: color.RGBA{0x6C, 0x06, 0x00, 0xFF},
0x16: color.RGBA{0xB5, 0x31, 0x20, 0xFF},
0x26: color.RGBA{0xFE, 0x81, 0x70, 0xFF},
0x36: color.RGBA{0xFE, 0xCC, 0xC5, 0xFF},
0x07: color.RGBA{0x56, 0x1D, 0x00, 0xFF},
0x17: color.RGBA{0x99, 0x4E, 0x00, 0xFF},
0x27: color.RGBA{0xEA, 0x9E, 0x22, 0xFF},
0x37: color.RGBA{0xF7, 0xD8, 0xA5, 0xFF},
0x08: color.RGBA{0x33, 0x35, 0x00, 0xFF},
0x18: color.RGBA{0x6B, 0x6D, 0x00, 0xFF},
0x28: color.RGBA{0xBC, 0xBE, 0x00, 0xFF},
0x38: color.RGBA{0xE4, 0xE5, 0x94, 0xFF},
0x09: color.RGBA{0x0B, 0x48, 0x00, 0xFF},
0x19: color.RGBA{0x38, 0x87, 0x00, 0xFF},
0x29: color.RGBA{0x88, 0xD8, 0x00, 0xFF},
0x39: color.RGBA{0xCF, 0xEF, 0x96, 0xFF},
0x0A: color.RGBA{0x00, 0x52, 0x00, 0xFF},
0x1A: color.RGBA{0x0C, 0x93, 0x00, 0xFF},
0x2A: color.RGBA{0x5C, 0xE4, 0x30, 0xFF},
0x3A: color.RGBA{0xBD, 0xF4, 0xAB, 0xFF},
0x0B: color.RGBA{0x00, 0x4F, 0x08, 0xFF},
0x1B: color.RGBA{0x00, 0x8F, 0x32, 0xFF},
0x2B: color.RGBA{0x45, 0xE0, 0x82, 0xFF},
0x3B: color.RGBA{0xB3, 0xF3, 0xCC, 0xFF},
0x0C: color.RGBA{0x00, 0x40, 0x4D, 0xFF},
0x1C: color.RGBA{0x00, 0x7C, 0x8D, 0xFF},
0x2C: color.RGBA{0x48, 0xCD, 0xDE, 0xFF},
0x3C: color.RGBA{0xB5, 0xEB, 0xF2, 0xFF},
0x0D: color.RGBA{0x00, 0x00, 0x00, 0xFF},
0x1D: color.RGBA{0x00, 0x7C, 0x8D, 0xFF},
0x2D: color.RGBA{0x4F, 0x4F, 0x4F, 0xFF},
0x3D: color.RGBA{0xB8, 0xB8, 0xB8, 0xFF},
0x0E: color.RGBA{0x00, 0x00, 0x00, 0xFF},
0x1E: color.RGBA{0x00, 0x00, 0x00, 0xFF},
0x2E: color.RGBA{0x00, 0x00, 0x00, 0xFF},
0x3E: color.RGBA{0x00, 0x00, 0x00, 0xFF},
0x0F: color.RGBA{0x00, 0x00, 0x00, 0xFF},
0x1F: color.RGBA{0x00, 0x00, 0x00, 0xFF},
0x2F: color.RGBA{0x00, 0x00, 0x00, 0xFF},
0x3F: color.RGBA{0x00, 0x00, 0x00, 0xFF},
}

9
go.mod
View File

@ -1,7 +1,10 @@
module git.zorchenhimer.com/Zorchenhimer/go-studybox
go 1.22.2
go 1.24.1
require github.com/alexflint/go-arg v1.4.3
require (
github.com/alexflint/go-arg v1.5.1
github.com/zorchenhimer/go-retroimg v0.0.0-20250928212219-4e2260a7a700
)
require github.com/alexflint/go-scalar v1.1.0 // indirect
require github.com/alexflint/go-scalar v1.2.0 // indirect

17
go.sum
View File

@ -1,16 +1,15 @@
github.com/alexflint/go-arg v1.4.3 h1:9rwwEBpMXfKQKceuZfYcwuc/7YY7tWJbFsgG5cAU/uo=
github.com/alexflint/go-arg v1.4.3/go.mod h1:3PZ/wp/8HuqRZMUUgu7I+e1qcpUbvmS258mRXkFH4IA=
github.com/alexflint/go-scalar v1.1.0 h1:aaAouLLzI9TChcPXotr6gUhq+Scr8rl0P9P4PnltbhM=
github.com/alexflint/go-scalar v1.1.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/alexflint/go-arg v1.5.1 h1:nBuWUCpuRy0snAG+uIJ6N0UvYxpxA0/ghA/AaHxlT8Y=
github.com/alexflint/go-arg v1.5.1/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8=
github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
github.com/zorchenhimer/go-retroimg v0.0.0-20250928212219-4e2260a7a700 h1:bsB3c08qVDW7tJ5lXdyvkTjCmHa+of5TmHL5Y9f7LJs=
github.com/zorchenhimer/go-retroimg v0.0.0-20250928212219-4e2260a7a700/go.mod h1:iQUJQkvvbgycl7TS2OWdSC0+kHYypOASX129xmnv+SE=
gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=