Files
questshock/modding/src/animgallery/main.go
T
ml 90303894ce
build / build (push) Successful in 2m52s
Fix animgallery rendering bugs and mitigate AI-upscale frame flicker
- writeGIF now pads every frame to one shared canvas instead of
  writing each at its own (possibly storage-cropped) size - GIF
  disposal rules were showing a previous frame's stale pixels through
  the uncovered border of a smaller frame, bleeding content between
  frames in the gallery.
- decodeFrames/decodeViewFrames/decodeAnimationFrames now always hand
  bitmap.DecodeReferenced a zero buffer instead of the previous
  frame/block's real pixels. The game's actual RSD8 decoder treats a
  "skip" run as literal zero, with no mechanism to carry a reference
  frame's pixels forward (confirmed against Night Dive's published
  source), and hacked's own multi-block Bitmap preview decodes the
  same way. The previous behavior ghosted a previous frame's stale
  content into the current frame's skip-run regions, in both the
  gallery and the AI upscaler's source input.
- index.html now shows a labeled per-frame filmstrip next to each
  animated GIF, and a base-vs-this-build side-by-side comparison for
  every resource a mod overrides.
- Gallery GIFs play back at a third of real speed by default, for
  easier frame-by-frame review.
- modding/mods/*/bitmaps/*.json gains considerPreviousFrames: a
  per-resource list that runs a temporal-consistency pass (median
  across the animation's own frames) on independently-upscaled frames
  to reduce per-frame AI flicker. Applied to 0x7B1 in upscale_medbot.
- Drop the rebecca-test example mod.
2026-10-27 07:54:19 +01:00

808 lines
34 KiB
Go

// Command animgallery scans a game data directory for every multi-frame
// bitmap resource (both dedicated "Animation" descriptors, e.g. video mail,
// and plain multi-block compound bitmaps, e.g. critter sprites - System
// Shock uses both conventions) and renders each one as an animated GIF, plus
// a static filmstrip of every frame side by side (the loop alone doesn't let
// a specific frame be singled out), into an HTML index page, so they can be
// browsed by eye instead of guessed at by resource ID. Layering a mod's own
// build output over the base game data (repeated -data) renders the result
// as actually played - and for any resource the mod overrides, the base
// game's own version alongside it, for a direct comparison.
package main
import (
"flag"
"fmt"
"html"
"image"
"image/color"
"image/draw"
"image/gif"
"image/png"
"log"
"os"
"path/filepath"
"sort"
"strings"
"github.com/inkyblackness/hacked/ss1/content/bitmap"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/world"
"github.com/inkyblackness/hacked/ss1/world/ids"
)
// galleryEntry describes one rendered animation for the HTML index.
type galleryEntry struct {
id resource.ID
lang resource.Language
sourceFile string
kind string
frameCount int
width int
height int
transparent bool
gifName string
filmstripName string
// baseGifName/baseFilmstripName are set only when this resource's
// winning layer isn't the first -data directory - the same content
// rendered from that first (base) directory alone, for a side-by-side
// comparison against what a later layer (a mod) overrides it with.
baseGifName string
baseFilmstripName string
}
// dataDirs collects repeated -data flags, in the order given - later ones
// take priority over earlier ones for any resource ID they both define,
// same as System Shock's own overlay convention. This lets a mod's build
// output (modding/dist/mods/<name>/mod/) be layered on top of the base
// game assets to preview the combined, as-played result.
type dataDirs []string
func (d *dataDirs) String() string { return strings.Join(*d, ",") }
func (d *dataDirs) Set(v string) error { *d = append(*d, v); return nil }
// loadDataDir merges every .res/.dat file directly inside dir into one
// manifest entry, bypassing hacked's own fileAllowlist
// (ss1/world/FileLoader.go): that check is only skipped when a file is
// passed directly as the sole argument, not when given a directory to
// scan, so a directory containing a resource file with a name it doesn't
// recognize (like mdlp's own "unknown.res" fallback - see
// [[project_modding_hacked_build]] memory) is otherwise silently treated
// as if it had no resources at all. The real game engine has no such
// restriction - it matches any .res/.dat file by extension alone
// (engine/src/MacSrc/Modding.c's StringEndsWith), which this mirrors.
func loadDataDir(dir string) (*world.ManifestEntry, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
merged := &world.ManifestEntry{Origin: []string{dir}, ID: dir}
found := false
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext != ".res" && ext != ".dat" {
continue
}
fileEntry, err := world.NewManifestEntryFrom([]string{filepath.Join(dir, e.Name())})
if err != nil {
// Not every .res/.dat file is actually resource-formatted (e.g.
// ambrtabl.dat is a raw lookup table) - hacked's own
// fileAllowlist silently protects against exactly this for
// canonical filenames, but bypassing it here (the whole point,
// for names like "unknown.res" it doesn't recognize) means we
// have to tolerate that ourselves instead: skip a file that
// doesn't parse rather than failing the whole directory over it.
continue
}
merged.Resources = append(merged.Resources, fileEntry.Resources...)
if len(fileEntry.ObjectProperties) > 0 {
merged.ObjectProperties = fileEntry.ObjectProperties
}
if len(fileEntry.TextureProperties) > 0 {
merged.TextureProperties = fileEntry.TextureProperties
}
found = true
}
if !found {
return nil, fmt.Errorf("no .res/.dat files found in %s", dir)
}
return merged, nil
}
func main() {
var dirs dataDirs
flag.Var(&dirs, "data", "directory with .res/.dat files - repeat to layer several, later wins (e.g. base game assets, then a built mod's mod/ folder)")
outDir := flag.String("out", "gallery", "directory to write index.html and every .gif into")
minFrames := flag.Int("min-frames", 2, "skip plain multi-block bitmaps with fewer blocks than this (Animation-type resources are always included)")
onlyChanged := flag.Bool("only-changed", false, "skip resources whose winning -data layer is the first one - pass a base layer followed by one or more mod layers to preview only what those later layers change, in the base layer's own colors")
flag.Parse()
if len(dirs) == 0 {
log.Fatal("-data is required (repeat it to layer several directories)")
}
absOutDir, err := filepath.Abs(*outDir)
if err != nil {
log.Fatalf("resolving -out: %v", err)
}
gifDir := filepath.Join(absOutDir, "gifs")
if err := os.MkdirAll(gifDir, 0o755); err != nil {
log.Fatal(err)
}
mod := world.NewMod(func([]resource.ID, []resource.ID) {}, func() {})
// sourceFileByKey records, per (language, id), which directory's
// resource "wins" after layering - the same last-one-wins rule
// Manifest.Filter itself uses for entries inserted in this order.
// sourceDirIndexByKey records the same winner as an index into dirs,
// for -only-changed to tell a first-layer (base) resource from one a
// later layer (a mod) overrides.
sourceFileByKey := make(map[resource.Key]string)
sourceDirIndexByKey := make(map[resource.Key]int)
for dirIndex, dir := range dirs {
absDir, err := filepath.Abs(dir)
if err != nil {
log.Fatalf("resolving -data %q: %v", dir, err)
}
entry, err := loadDataDir(absDir)
if err != nil {
// A mod that doesn't touch any resource file yet (e.g. an
// upscale mod before mdlp supports that content type) produces
// an empty build directory - skip it rather than aborting the
// whole layered scan over one contributing -data directory.
log.Printf("skipping -data %s: %v", absDir, err)
continue
}
// NewManifestEntryFrom builds entry.Resources by ranging over a Go
// map, so its order (and therefore which file "wins" a Selector's
// last-one-wins tie-break for any resource ID more than one file
// defines) is randomized per process run. Sort it ourselves so the
// same input always resolves the same way.
sort.Slice(entry.Resources, func(i, j int) bool { return entry.Resources[i].ID < entry.Resources[j].ID })
if err := mod.World().InsertEntry(mod.World().EntryCount(), entry); err != nil {
log.Fatalf("registering game data from %s: %v", absDir, err)
}
for _, loc := range entry.Resources {
for _, id := range loc.Viewer.IDs() {
key := resource.KeyOf(id, loc.Language, 0)
sourceFileByKey[key] = fmt.Sprintf("%s (%s)", loc.ID, filepath.Base(absDir))
sourceDirIndexByKey[key] = dirIndex
}
}
}
palCache := bitmap.NewPaletteCache(mod)
defaultPal, err := palCache.Palette(resource.KeyOf(ids.GamePalettesStart, resource.LangDefault, 0))
if err != nil {
log.Printf("warning: could not load the default game palette (%v); falling back to grayscale", err)
}
// baseMod holds only the first -data directory, unlayered - resolving a
// resource through it instead of the full mod gives back what a later
// layer (a mod) is actually overriding, for a side-by-side comparison on
// every entry one of those layers changes. Reloaded independently of the
// dirs loop above rather than reusing its ManifestEntry, to keep that
// loop's own bookkeeping (sourceFileByKey, ...) untangled from this.
var baseMod *world.Mod
if len(dirs) > 1 {
baseAbsDir, err := filepath.Abs(dirs[0])
if err != nil {
log.Fatalf("resolving -data %q: %v", dirs[0], err)
}
baseEntry, err := loadDataDir(baseAbsDir)
if err != nil {
log.Printf("base-layer comparison disabled: reloading %s: %v", baseAbsDir, err)
} else {
sort.Slice(baseEntry.Resources, func(i, j int) bool { return baseEntry.Resources[i].ID < baseEntry.Resources[j].ID })
candidate := world.NewMod(func([]resource.ID, []resource.ID) {}, func() {})
if err := candidate.World().InsertEntry(candidate.World().EntryCount(), baseEntry); err != nil {
log.Printf("base-layer comparison disabled: registering %s: %v", baseAbsDir, err)
} else {
baseMod = candidate
}
}
}
keys := make([]resource.Key, 0, len(sourceFileByKey))
for key := range sourceFileByKey {
if *onlyChanged && sourceDirIndexByKey[key] == 0 {
continue
}
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].ID != keys[j].ID {
return keys[i].ID < keys[j].ID
}
return keys[i].Lang < keys[j].Lang
})
var entries []galleryEntry
for _, key := range keys {
sourceFile := sourceFileByKey[key]
// Re-resolved through the layered mod, not the raw per-directory
// viewer, so an overriding mod's version wins here exactly like it
// would for the running game.
entry, ok, err := renderForMod(mod, defaultPal, sourceFile, key.ID, key.Lang, gifDir, "", *minFrames)
if err != nil {
log.Printf("skipping %s (%s): %v", key.ID, sourceFile, err)
continue
}
if !ok {
continue
}
// A later layer wins this key over the first (base) -data directory
// - also render the base directory's own version of it, so the two
// can be shown side by side instead of only the result actually
// in use.
if baseMod != nil && sourceDirIndexByKey[key] != 0 {
baseEntry, ok, err := renderForMod(baseMod, defaultPal, sourceFile, key.ID, key.Lang, gifDir, "_base", *minFrames)
if err != nil {
log.Printf("skipping base comparison for %s (%s): %v", key.ID, sourceFile, err)
} else if ok {
entry.baseGifName = baseEntry.gifName
entry.baseFilmstripName = baseEntry.filmstripName
}
}
entries = append(entries, entry)
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].id != entries[j].id {
return entries[i].id < entries[j].id
}
return entries[i].lang < entries[j].lang
})
indexPath := filepath.Join(absOutDir, "index.html")
if err := writeIndex(indexPath, entries); err != nil {
log.Fatalf("writing %s: %v", indexPath, err)
}
fmt.Printf("Found %d animated resource(s).\n", len(entries))
fmt.Printf("Index: %s\n", indexPath)
}
// decodeFrames reads every block of a compound bitmap resource as a
// sequence of frames. Each block's RLE data can carry a "skip" run, which
// bitmap.DecodeReferenced leaves untouched in whatever buffer it's handed -
// the provider function below always hands it a fresh zero buffer, never a
// previous block's pixels, because the game's actual RSD8 decoder
// (engine/src/Libraries/2D/Source/RSD/{rsdcvt.c,RSDUnpack.c}, see
// upscale.go's encodeFrames) treats a skip run as literal zero for every
// resource type including critter sprites, with no mechanism to carry a
// reference image's pixels forward. hacked's own multi-block Bitmap preview
// (editor/bitmaps/View.go) matches that: it always calls Texture() with no
// reference. A non-zero buffer here would ghost a previous block's stale
// pixels into the current block's skip-run regions.
func decodeFrames(view resource.View) ([]*bitmap.Bitmap, error) {
count := view.BlockCount()
frames := make([]*bitmap.Bitmap, 0, count)
for i := 0; i < count; i++ {
reader, err := view.Block(i)
if err != nil {
return nil, fmt.Errorf("block %d: %w", i, err)
}
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
return make([]byte, int(w)*int(h)), nil
})
if err != nil {
return nil, fmt.Errorf("block %d: %w", i, err)
}
frames = append(frames, bmp)
}
return frames, nil
}
func paletteFor(bmp *bitmap.Bitmap, fallback bitmap.Palette) color.Palette {
pal := fallback
if bmp.Palette != nil {
pal = *bmp.Palette
}
return pal.ColorPalette(false)
}
// gallerySpeedFactor slows every gallery GIF down to a fraction of real
// in-game speed, since the real speed is often too fast to inspect a frame's
// detail by eye (the point of this gallery).
const gallerySpeedFactor = 3
// writeGIF composes every frame onto one shared canvas, sized to the larger
// of minWidth/minHeight (the format's own declared animation extent, 0 if
// unknown) and the largest individual frame, and places each frame's pixels
// at its top-left corner. A frame smaller than the canvas is a storage-level
// crop, not a smaller sprite (System Shock animation frames can be
// RLE-delta-encoded against the previous frame's unchanged pixels, see
// bitmap.DecodeReferenced), so every encoded GIF frame must still cover the
// whole canvas itself - otherwise GIF disposal rules leave the uncovered
// border showing whatever the previous frame drew there, bleeding stale
// content from frame to frame (visible in an external GIF viewer; System
// Shock itself always swaps in a complete texture per frame, so this never
// shows up in-game).
func writeGIF(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette, delays []int, minWidth, minHeight int) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
canvasWidth, canvasHeight := minWidth, minHeight
for _, bmp := range frames {
if int(bmp.Header.Width) > canvasWidth {
canvasWidth = int(bmp.Header.Width)
}
if int(bmp.Header.Height) > canvasHeight {
canvasHeight = int(bmp.Header.Height)
}
}
// LoopCount: 0 means loop forever in Go's image/gif API - inverted from
// the raw GIF format's own NETSCAPE2.0 convention. -1 (this package's
// zero value) means play once.
out := gif.GIF{LoopCount: 0, Config: image.Config{Width: canvasWidth, Height: canvasHeight}}
canvasRect := image.Rect(0, 0, canvasWidth, canvasHeight)
for i, bmp := range frames {
colorPal := paletteFor(bmp, fallbackPal)
img := image.NewPaletted(canvasRect, colorPal)
frameWidth, frameHeight := int(bmp.Header.Width), int(bmp.Header.Height)
if len(bmp.Pixels) == frameWidth*frameHeight {
for y := 0; y < frameHeight; y++ {
srcOffset := y * frameWidth
dstOffset := img.PixOffset(0, y)
copy(img.Pix[dstOffset:dstOffset+frameWidth], bmp.Pixels[srcOffset:srcOffset+frameWidth])
}
}
out.Image = append(out.Image, img)
out.Delay = append(out.Delay, delays[i])
}
return gif.EncodeAll(f, &out)
}
// glyph5x7 is a minimal 5-column x 7-row bitmap font, hand-drawn rather than
// pulled from golang.org/x/image/font/basicfont: that package is only a
// test-only transitive dependency of hacked, never actually fetched into
// mdlp-image's offline module cache, so using it here would need a
// Dockerfile change and an image rebuild for a handful of label glyphs.
// Only the characters writeFilmstrip's "FRAME N" labels ever use are
// defined; '#' is a lit pixel, anything else is blank.
var glyph5x7 = map[rune][7]string{
' ': {" ", " ", " ", " ", " ", " ", " "},
'0': {".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."},
'1': {"..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."},
'2': {".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"},
'3': {".###.", "#...#", "....#", "..##.", "....#", "#...#", ".###."},
'4': {"...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."},
'5': {"#####", "#....", "####.", "....#", "....#", "#...#", ".###."},
'6': {".###.", "#....", "#....", "####.", "#...#", "#...#", ".###."},
'7': {"#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..."},
'8': {".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."},
'9': {".###.", "#...#", "#...#", ".####", "....#", "....#", ".###."},
'A': {".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"},
'E': {"#####", "#....", "#....", "####.", "#....", "#....", "#####"},
'F': {"#####", "#....", "#....", "####.", "#....", "#....", "#...."},
'M': {"#...#", "##.##", "#.#.#", "#...#", "#...#", "#...#", "#...#"},
'R': {"####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"},
}
// drawLabel renders s (upper-cased, unknown runes blank) at (x, y) in a
// fixed 5x7 pixel font, one pixel of gap between characters - small and
// legible enough for a "FRAME N" strip label, with no font-rendering
// dependency.
func drawLabel(canvas draw.Image, x, y int, s string, c color.Color) {
col := x
for _, r := range strings.ToUpper(s) {
glyph, ok := glyph5x7[r]
if !ok {
glyph = glyph5x7[' ']
}
for row, line := range glyph {
for bit, ch := range line {
if ch == '#' {
canvas.Set(col+bit, y+row, c)
}
}
}
col += 6 // 5px glyph + 1px gap
}
}
// writeFilmstrip renders every frame side by side at native size, each
// labeled "frame N", separated by a thin gap, as a static complement to the
// looping GIF - the index page otherwise only shows the loop in motion,
// never all of an animation's frames side by side for comparison, or which
// index a given frame is.
func writeFilmstrip(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
const gap = 2
const labelHeight = 14
totalWidth, maxHeight := -gap, 0
for _, bmp := range frames {
totalWidth += int(bmp.Header.Width) + gap
if int(bmp.Header.Height) > maxHeight {
maxHeight = int(bmp.Header.Height)
}
}
canvas := image.NewRGBA(image.Rect(0, 0, totalWidth, labelHeight+maxHeight))
draw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.Black), image.Point{}, draw.Src)
labelColor := color.RGBA{200, 200, 200, 255}
x := 0
for i, bmp := range frames {
drawLabel(canvas, x+1, 2, fmt.Sprintf("FRAME %d", i), labelColor)
colorPal := paletteFor(bmp, fallbackPal)
rect := image.Rect(0, 0, int(bmp.Header.Width), int(bmp.Header.Height))
img := image.NewPaletted(rect, colorPal)
if len(bmp.Pixels) == len(img.Pix) {
copy(img.Pix, bmp.Pixels)
}
dstRect := image.Rect(x, labelHeight, x+int(bmp.Header.Width), labelHeight+int(bmp.Header.Height))
draw.Draw(canvas, dstRect, img, image.Point{}, draw.Src)
x += int(bmp.Header.Width) + gap
}
return png.Encode(f, canvas)
}
// safeRenderMultiBlockBitmap recovers from panics in hacked's own decode
// code (malformed or non-image compound resources can crash rle.Decompress
// rather than return an error) so one bad resource never aborts a scan that
// is meant to be re-run repeatedly during modding.
func safeRenderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix string) (entry galleryEntry, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
return renderMultiBlockBitmap(view, fallbackPal, sourceFile, id, lang, outDir, suffix)
}
// suffix distinguishes a base-layer comparison render ("_base") from the
// normal, winning-layer one ("") for the same resource ID/language, so the
// two don't collide on the same output filenames in outDir.
func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix string) (galleryEntry, error) {
frames, err := decodeFrames(view)
if err != nil {
return galleryEntry{}, err
}
if len(frames) == 0 {
return galleryEntry{}, fmt.Errorf("no frames")
}
delays := make([]int, len(frames))
for i := range delays {
delays[i] = 10 * gallerySpeedFactor // no explicit timing exists for this raw form
}
gifName := fmt.Sprintf("%04X_%s%s.gif", int(id), lang.String(), suffix)
// No separate declared canvas size exists for this content type (unlike
// Animation descriptors below) - writeGIF falls back to the largest
// individual frame.
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays, 0, 0); err != nil {
return galleryEntry{}, err
}
filmstripName := fmt.Sprintf("%04X_%s%s_frames.png", int(id), lang.String(), suffix)
if err := writeFilmstrip(filepath.Join(outDir, filmstripName), frames, fallbackPal); err != nil {
return galleryEntry{}, err
}
return galleryEntry{
id: id, lang: lang, sourceFile: sourceFile, kind: "multi-block bitmap",
frameCount: len(frames),
width: int(frames[0].Header.Width), height: int(frames[0].Header.Height),
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
gifName: gifName,
filmstripName: filmstripName,
}, nil
}
// safeRenderAnimationDescriptor is safeRenderMultiBlockBitmap's counterpart
// for the Animation-descriptor path.
func safeRenderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix string) (entry galleryEntry, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
return renderAnimationDescriptor(mod, fallbackPal, sourceFile, id, lang, outDir, suffix)
}
// suffix distinguishes a base-layer comparison render ("_base") from the
// normal, winning-layer one ("") - see renderMultiBlockBitmap.
func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix string) (galleryEntry, error) {
view, err := mod.LocalizedResources(lang).Select(id)
if err != nil {
return galleryEntry{}, err
}
reader, err := view.Block(0)
if err != nil {
return galleryEntry{}, err
}
anim, err := bitmap.ReadAnimation(reader)
if err != nil {
return galleryEntry{}, err
}
if len(anim.Entries) == 0 {
return galleryEntry{}, fmt.Errorf("no animation entries")
}
lastFrame := 0
for _, entry := range anim.Entries {
if int(entry.LastFrame) > lastFrame {
lastFrame = int(entry.LastFrame)
}
}
var frames []*bitmap.Bitmap
var delays []int
frameIndex := 0
for _, entry := range anim.Entries {
for frameIndex <= int(entry.LastFrame) {
frameKey := resource.KeyOf(anim.ResourceID, lang, frameIndex)
frameView, err := mod.LocalizedResources(lang).Select(frameKey.ID)
if err != nil {
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
}
reader, err := frameView.Block(0)
if err != nil {
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
}
// Always a fresh zero buffer, never a previous frame's real
// pixels - see decodeFrames' own comment for why that's what
// matches the game's actual RSD8 decoder (and hacked's own
// bitmap preview) rather than ghosting stale content into a
// skip run.
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
return make([]byte, int(w)*int(h)), nil
})
if err != nil {
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
}
frames = append(frames, bmp)
delay := int(entry.FrameTime) / 10 * gallerySpeedFactor
if delay <= 0 {
delay = 10 * gallerySpeedFactor
}
delays = append(delays, delay)
frameIndex++
}
}
if len(frames) == 0 {
return galleryEntry{}, fmt.Errorf("no frames decoded")
}
gifName := fmt.Sprintf("%04X_%s%s.gif", int(id), lang.String(), suffix)
// anim.Width/Height is this format's own declared canvas, which can be
// larger than any single frame's stored (possibly delta-cropped) size.
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays, int(anim.Width), int(anim.Height)); err != nil {
return galleryEntry{}, err
}
filmstripName := fmt.Sprintf("%04X_%s%s_frames.png", int(id), lang.String(), suffix)
if err := writeFilmstrip(filepath.Join(outDir, filmstripName), frames, fallbackPal); err != nil {
return galleryEntry{}, err
}
return galleryEntry{
id: id, lang: lang, sourceFile: sourceFile, kind: "animation descriptor",
frameCount: len(frames),
width: int(anim.Width), height: int(anim.Height),
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
gifName: gifName,
filmstripName: filmstripName,
}, nil
}
// renderForMod resolves id/lang through m (a specific layering - the full
// mod, or just a base game directory on its own) and renders it, the same
// content-type dispatch the main scan uses, so it can also be called a
// second time against a base-only Mod to render a comparison. ok is false
// (with a nil error) when id/lang isn't an eligible animated resource in m
// at all, as opposed to being eligible but failing to decode or render.
func renderForMod(m *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix string, minFrames int) (entry galleryEntry, ok bool, err error) {
view, err := m.LocalizedResources(lang).Select(id)
if err != nil {
return galleryEntry{}, false, nil
}
switch {
case view.ContentType() == resource.Animation:
entry, err = safeRenderAnimationDescriptor(m, fallbackPal, sourceFile, id, lang, outDir, suffix)
return entry, true, err
case view.ContentType() == resource.Bitmap && view.BlockCount() >= minFrames:
entry, err = safeRenderMultiBlockBitmap(view, fallbackPal, sourceFile, id, lang, outDir, suffix)
return entry, true, err
}
return galleryEntry{}, false, nil
}
// contentTypeName maps a galleryEntry.kind back to the resource.ContentType
// name mdlp will need (its own decode path differs: an "Animation"
// descriptor's frames live at anim.ResourceID+N, a "Bitmap"'s frames are
// just its own blocks) - see kind's assignment in renderAnimationDescriptor/
// renderMultiBlockBitmap.
func contentTypeName(kind string) string {
if kind == "animation descriptor" {
return "Animation"
}
return "Bitmap"
}
// mdlResourceID formats the resourceId string an MDL upscale definition
// uses to identify one resource: a resource ID alone is not always unique -
// e.g. 0x25 exists as a separate resource per language - so a non-Any
// language is appended (0x25_German). The bare form (0xA54) implies
// _Any, the language nearly everything we care about upscaling (critter
// art, etc.) actually uses.
func mdlResourceID(id resource.ID, lang resource.Language) string {
if lang == resource.LangAny {
return fmt.Sprintf("0x%X", int(id))
}
return fmt.Sprintf("0x%X_%s", int(id), lang.String())
}
func writeIndex(path string, entries []galleryEntry) error {
var b strings.Builder
b.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>System Shock animation gallery</title>")
b.WriteString("<style>body{font-family:sans-serif;background:#111;color:#eee}")
b.WriteString("#controls{position:sticky;top:0;background:#181818;padding:10px;z-index:10;border-bottom:1px solid #444}")
b.WriteString("#controls label{margin-right:12px}")
b.WriteString("#mdl-output{width:98%;font-family:monospace;font-size:12px;margin-top:8px;background:#000;color:#9f9;border:1px solid #444}")
b.WriteString(".card{display:inline-block;margin:8px;padding:8px;background:#222;vertical-align:top}")
b.WriteString(".card img{image-rendering:pixelated;background:#000;display:block}")
b.WriteString(".card img.frames{margin-top:4px;max-width:100%}")
b.WriteString(".compare{display:flex;gap:10px}")
b.WriteString(".compare-label{font-size:11px;color:#8ab;text-transform:uppercase;margin-bottom:2px}")
b.WriteString(".meta{font-size:12px;color:#aaa;margin-top:4px}")
b.WriteString(".meta label{cursor:pointer}")
b.WriteString("#basket{min-height:60px;background:#1a2a1a;border:1px dashed #4a6a4a;padding:8px;margin-bottom:16px}")
b.WriteString("#basket:empty::before{content:'(nothing selected yet - check animations below to add them here)';color:#678;font-style:italic}")
b.WriteString("#basket .card{background:#2a3a2a}</style></head><body>")
b.WriteString(`<div id="controls">` +
`<label>Model: <select id="model">` +
`<option>realesrgan-x4plus-anime</option>` +
`<option>realesrgan-x4plus</option>` +
`<option>realesr-general-x4v3</option>` +
`</select></label>` +
`<label>Scale: <input id="scale" type="number" value="2" min="1" max="4" style="width:4em"></label>` +
`<button id="clear-sel" type="button">Clear selection</button>` +
`<br><label>Filter: <input type="text" id="search" placeholder="id, language, file, transparent..." style="width:22em"></label>` +
`<span id="shown-count"></span>` +
`<textarea id="mdl-output" rows="12" readonly placeholder="Select animations below to generate an MDL upscale definition here - copy it into modding/mods/&lt;name&gt;/bitmaps/&lt;label&gt;.json"></textarea>` +
`</div>`)
b.WriteString(`<h2>Selected (<span id="basket-count">0</span>)</h2><div id="basket"></div>`)
fmt.Fprintf(&b, "<h1>%d animated resource(s)</h1>", len(entries))
// Animation descriptors and multi-block bitmaps are genuinely distinct
// content types (different decode path, see contentTypeName) - grouped
// into separate sections rather than interleaved.
var animations, bitmaps []galleryEntry
for _, e := range entries {
if contentTypeName(e.kind) == "Animation" {
animations = append(animations, e)
} else {
bitmaps = append(bitmaps, e)
}
}
writeCards := func(group []galleryEntry, idPrefix string) {
for i, e := range group {
transparency := "opaque"
if e.transparent {
transparency = "transparent"
}
search := strings.ToLower(strings.Join([]string{
fmt.Sprintf("0x%x", int(e.id)), fmt.Sprintf("%d", int(e.id)),
e.lang.String(), e.kind, e.sourceFile,
fmt.Sprintf("%dx%d", e.width, e.height), transparency,
}, " "))
fmt.Fprintf(&b, "<div class=\"card\" data-search=\"%s\">", html.EscapeString(search))
if e.baseGifName != "" {
b.WriteString("<div class=\"compare\">")
b.WriteString("<div class=\"compare-col\"><div class=\"compare-label\">base</div>")
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.baseGifName), e.width*2, e.height*2)
fmt.Fprintf(&b, "<img class=\"frames\" src=\"gifs/%s\" title=\"every base frame, in order\">", html.EscapeString(e.baseFilmstripName))
b.WriteString("</div>")
b.WriteString("<div class=\"compare-col\"><div class=\"compare-label\">this build</div>")
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
fmt.Fprintf(&b, "<img class=\"frames\" src=\"gifs/%s\" title=\"every frame, in order\">", html.EscapeString(e.filmstripName))
b.WriteString("</div></div>")
} else {
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
if e.filmstripName != "" {
fmt.Fprintf(&b, "<img class=\"frames\" src=\"gifs/%s\" title=\"every frame, in order\">", html.EscapeString(e.filmstripName))
}
}
fmt.Fprintf(&b, "<div class=\"meta\"><label><input type=\"checkbox\" class=\"sel\" id=\"sel-%s-%d\" data-id=\"%s\"> ",
idPrefix, i, html.EscapeString(mdlResourceID(e.id, e.lang)))
fmt.Fprintf(&b, "ID 0x%X (%d) &middot; %s</label><br>%dx%d, %d frame(s), %s &middot; %s &middot; %s</div>",
int(e.id), int(e.id), html.EscapeString(e.lang.String()),
e.width, e.height, e.frameCount, transparency, html.EscapeString(e.kind), html.EscapeString(e.sourceFile))
b.WriteString("</div>")
}
}
fmt.Fprintf(&b, "<h2>Animations (%d)</h2>", len(animations))
writeCards(animations, "a")
fmt.Fprintf(&b, "<h2>Bitmaps (%d)</h2>", len(bitmaps))
writeCards(bitmaps, "b")
b.WriteString(`<script>
var basket = document.getElementById('basket');
// Where each card lives when not selected, so unchecking it puts it back
// in its original spot (Animations/Bitmaps section, original order)
// instead of just appending it back at the end.
var cardHomes = new Map();
Array.prototype.forEach.call(document.querySelectorAll('.card'), function (card) {
cardHomes.set(card, { parent: card.parentNode, next: card.nextSibling });
});
function moveCard(checkbox) {
var card = checkbox.closest('.card');
if (checkbox.checked) {
basket.appendChild(card);
} else {
var home = cardHomes.get(card);
home.parent.insertBefore(card, home.next);
}
document.getElementById('basket-count').textContent = basket.children.length;
}
function updateMdl() {
var model = document.getElementById('model').value;
var scale = parseInt(document.getElementById('scale').value, 10) || 2;
var boxes = Array.prototype.filter.call(document.querySelectorAll('.sel'), function (b) { return b.checked; });
// A bare resource ID isn't always unique (e.g. 0x25 exists as a separate
// resource per language), so each entry here is "0x<ID>" or, when the
// resource's language isn't Any, "0x<ID>_<Language>" - mdlp resolves the
// rest (content type, etc.) from the resource itself. The MDL
// definition only needs what it can't derive: the shared upscale
// parameters, and which resources to apply them to.
var mdl = {
model: model,
scale: scale,
resources: boxes.map(function (b) { return b.dataset.id; })
};
document.getElementById('mdl-output').value = JSON.stringify(mdl, null, 2);
}
Array.prototype.forEach.call(document.querySelectorAll('.sel'), function (b) {
b.addEventListener('change', function () { moveCard(b); updateMdl(); });
});
document.getElementById('model').addEventListener('change', updateMdl);
document.getElementById('scale').addEventListener('input', updateMdl);
document.getElementById('clear-sel').addEventListener('click', function () {
Array.prototype.forEach.call(document.querySelectorAll('.sel'), function (b) {
if (b.checked) {
b.checked = false;
moveCard(b);
}
});
updateMdl();
});
updateMdl();
function applyFilter() {
var query = document.getElementById('search').value.trim().toLowerCase();
var cards = document.querySelectorAll('.card');
var shown = 0;
Array.prototype.forEach.call(cards, function (card) {
var match = !query || card.dataset.search.indexOf(query) !== -1;
card.style.display = match ? '' : 'none';
if (match) shown++;
});
document.getElementById('shown-count').textContent = shown + ' / ' + cards.length + ' shown';
}
document.getElementById('search').addEventListener('input', applyFilter);
applyFilter();
</script>`)
b.WriteString("</body></html>")
return os.WriteFile(path, []byte(b.String()), 0o644)
}