90303894ce
build / build (push) Successful in 2m52s
- 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.
808 lines
34 KiB
Go
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/<name>/bitmaps/<label>.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) · %s</label><br>%dx%d, %d frame(s), %s · %s · %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)
|
|
}
|