// 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//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("System Shock animation gallery") b.WriteString("") b.WriteString(`
` + `` + `` + `` + `
` + `` + `` + `
`) b.WriteString(`

Selected (0)

`) fmt.Fprintf(&b, "

%d animated resource(s)

", 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, "
", html.EscapeString(search)) if e.baseGifName != "" { b.WriteString("
") b.WriteString("
base
") fmt.Fprintf(&b, "", html.EscapeString(e.baseGifName), e.width*2, e.height*2) fmt.Fprintf(&b, "", html.EscapeString(e.baseFilmstripName)) b.WriteString("
") b.WriteString("
this build
") fmt.Fprintf(&b, "", html.EscapeString(e.gifName), e.width*2, e.height*2) fmt.Fprintf(&b, "", html.EscapeString(e.filmstripName)) b.WriteString("
") } else { fmt.Fprintf(&b, "", html.EscapeString(e.gifName), e.width*2, e.height*2) if e.filmstripName != "" { fmt.Fprintf(&b, "", html.EscapeString(e.filmstripName)) } } fmt.Fprintf(&b, "

%dx%d, %d frame(s), %s · %s · %s
", 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("
") } } fmt.Fprintf(&b, "

Animations (%d)

", len(animations)) writeCards(animations, "a") fmt.Fprintf(&b, "

Bitmaps (%d)

", len(bitmaps)) writeCards(bitmaps, "b") b.WriteString(``) b.WriteString("") return os.WriteFile(path, []byte(b.String()), 0o644) }