From 90303894ce38b6aab6873a998354d54ea8ad3bfa Mon Sep 17 00:00:00 2001 From: ml Date: Tue, 27 Oct 2026 07:54:19 +0100 Subject: [PATCH] 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. --- modding/Makefile | 5 +- modding/README.md | 38 +- .../mods/rebecca-test/messages/mail-026.json | 7 - .../mods/rebecca-test/messages/mail-026.txt | 1 - .../mods/upscale_medbot/bitmaps/medbot.json | 3 +- modding/src/animgallery/main.go | 366 ++++++++++++++---- modding/src/mdlp/upscale.go | 241 ++++++++++-- 7 files changed, 538 insertions(+), 123 deletions(-) delete mode 100644 modding/mods/rebecca-test/messages/mail-026.json delete mode 100644 modding/mods/rebecca-test/messages/mail-026.txt diff --git a/modding/Makefile b/modding/Makefile index 024eebc..62b0ad0 100644 --- a/modding/Makefile +++ b/modding/Makefile @@ -18,7 +18,9 @@ # animgallery_mods - same, but with every built mod under dist/mods/ # layered on top (later wins), in build/gallery-mods/ - # shows what a mod actually changes without launching -# the game. Run `make mods` first. +# the game. Every card for a resource a mod overrides +# shows the original alongside it for comparison. Run +# `make mods` first. # animgallery_dist - every built mod under dist/mods/ layered on top of # the base game, like animgallery_mods, but restricted # to only the resources a mod actually overrides, in @@ -29,6 +31,7 @@ # Run `make mods` first. # (animgallery_base/_mods/_dist all write into this # subproject's own build/, not the repo root's.) +# # clean - removes dist/. .PHONY: all build-image hacked mdlp-image mods animgallery_base animgallery_mods animgallery_dist clean diff --git a/modding/README.md b/modding/README.md index 88dde7e..235b22d 100644 --- a/modding/README.md +++ b/modding/README.md @@ -225,6 +225,7 @@ exact JSON shape from whatever's checked) produces this format directly: | `model` | Which baked-in ONNX model to upscale with - see the table below | | `scale` | Output size as a multiple of the original (for example `2`) | | `resources` | Each entry is `"0x"` (language `Any`) or `"0x_"` (for example `"0x25_German"`), matching `gen_animgallery.sh`'s own id format | +| `considerPreviousFrames` | Optional, same id format as `resources` - an id here that isn't also in `resources` is simply never applied, not an error. A listed resource with 3+ frames gets a temporal-consistency pass (section 5) that pulls a frame's upscaled pixel back toward the rest of the animation's own consensus wherever it disagrees by more than the real low-res source justifies - a real mitigation for per-frame upscale flicker. Lets one resource opt in without splitting it into its own file or changing `model`/`scale` for the rest. | Available `model` values (baked into `mdlp-image`, see `mdlp-image/upscale_batch.py`): @@ -315,7 +316,15 @@ same bytes the engine loads, so a problem visible there is a problem in the mod, not an artifact of some other preview path. Regenerate it with `-data res/assets/ss_ee/data -data dist/mods//mod` layered over the base data to see the mod's effect, or with just the base `-data` argument to -check what the unmodified resource looks like for comparison. +check what the unmodified resource looks like for comparison. Each card +shows the looping GIF plus a static filmstrip of every one of its frames +side by side, so one specific frame can be singled out and compared without +having to pause the animation by eye. When two or more `-data` directories +are layered this way, a card for any resource a later layer actually +overrides shows both versions side by side - the first directory's own +("base") and the winning one ("this build") - so a mod's effect is always +checked against the original it's replacing, not recalled from memory or a +separate base-only gallery run. `gen_animgallery.sh` is a from-scratch Go decoder, not the game engine - it never exercises the engine's own texture upload/caching code at all. @@ -346,6 +355,33 @@ resources rather than trusting a handful of spot-checks: re-quantization entirely rather than relying on a brightness or saturation threshold, which proved too easy for a genuinely dark-but-muted pixel to slip past. +- **Per-frame hallucination in an animation's upscaled output.** + `upscale_batch.py` runs every frame through the ONNX model independently, + with no shared state between frames - a well-known limitation of + single-image super-resolution. On a low-detail/ambiguous source region + the model can guess differently frame to frame, so a detail (an extra + claw, a stray highlight) pops in for a few frames and back out, even + though the real low-resolution source barely changed there. Review by eye + (`make animgallery_mods`/`animgallery_dist`, section 4.3) before trusting + a new upscale definition - this is invisible from a single frame or a + handful of spot-checks and needs to be checked per-animation, ideally + with the GIF slowed down and its base-vs-this-build comparison columns + (both features of the gallery itself) rather than played at real speed. + + `considerPreviousFrames` (section 4.1) is a real mitigation, not a + different model/scale gamble, for a resource found to have this problem: + list just the resources that need it and the rest of the definition keeps + its own `model`/`scale` unchanged. For each listed resource with 3+ + frames, after independent per-frame upscaling, each frame's pixels are + compared against the per-pixel median across every frame of the same + (cyclic) animation - does the upscaled difference from that consensus + exceed what the real low-res source's own difference from its consensus + justifies - and pulled to the median where it does, on the same model the + rest of the definition already uses. Try it before reaching for a + different model or scale. It needs 3+ frames for a meaningful consensus (a + 2-frame animation has no independent third frame to vote with), and never + touches alpha or + an already-transparent pixel. `encodeFrames` compresses every frame against an all-zero reference instead of diffing against the previous frame's real pixels, because diff --git a/modding/mods/rebecca-test/messages/mail-026.json b/modding/mods/rebecca-test/messages/mail-026.json deleted file mode 100644 index a8557bc..0000000 --- a/modding/mods/rebecca-test/messages/mail-026.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "type": "Mails", - "index": 26, - "sender": "Rebecca Lansing1", - "title": "Rebecca-001", - "verboseTextFile": "mail-026.txt" -} diff --git a/modding/mods/rebecca-test/messages/mail-026.txt b/modding/mods/rebecca-test/messages/mail-026.txt deleted file mode 100644 index ef5a70e..0000000 --- a/modding/mods/rebecca-test/messages/mail-026.txt +++ /dev/null @@ -1 +0,0 @@ -Listen to me very carefully HACKER. Something has gone terribly wrong on Citadel station. You might be the last survivor. Communications are being jammed. We know that something on board is attempting to harm humanity. The mining laser is being charged for a possible strike against Earth populations. On your level is the office of a Dr. D'Arcy, who may know something about the laser. The office is located near the central hub, on a balcony, with the medical icon clearly visible. When the laser is destroyed, you will have to reach the bridge and stop whatever is responsible for the catastrophe. We believe that the biological labs and security systems have gone haywire, so be careful. If you pull this off, we will be able to get you out alive. Good luck. diff --git a/modding/mods/upscale_medbot/bitmaps/medbot.json b/modding/mods/upscale_medbot/bitmaps/medbot.json index 1b19546..768f610 100644 --- a/modding/mods/upscale_medbot/bitmaps/medbot.json +++ b/modding/mods/upscale_medbot/bitmaps/medbot.json @@ -22,5 +22,6 @@ "0x7B6", "0x7B7", "0x84C" - ] + ], + "considerPreviousFrames": ["0x7B1"] } diff --git a/modding/src/animgallery/main.go b/modding/src/animgallery/main.go index 0e04929..1bda26e 100644 --- a/modding/src/animgallery/main.go +++ b/modding/src/animgallery/main.go @@ -1,9 +1,13 @@ // 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 -// an HTML index page, so they can be browsed by eye instead of guessed at -// by resource ID. +// 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 ( @@ -12,7 +16,9 @@ import ( "html" "image" "image/color" + "image/draw" "image/gif" + "image/png" "log" "os" "path/filepath" @@ -27,15 +33,22 @@ import ( // 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 + 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 @@ -167,6 +180,32 @@ func main() { 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 { @@ -187,28 +226,30 @@ func main() { // 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. - view, err := mod.LocalizedResources(key.Lang).Select(key.ID) + 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 } - switch { - case view.ContentType() == resource.Animation: - entry, err := safeRenderAnimationDescriptor(mod, defaultPal, sourceFile, key.ID, key.Lang, gifDir) + // 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 animation %s (%s): %v", key.ID, sourceFile, err) - continue + 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) - - case view.ContentType() == resource.Bitmap && view.BlockCount() >= *minFrames: - entry, err := safeRenderMultiBlockBitmap(view, defaultPal, sourceFile, key.ID, key.Lang, gifDir) - if err != nil { - log.Printf("skipping bitmap %s (%s): %v", key.ID, sourceFile, err) - continue - } - entries = append(entries, entry) } + + entries = append(entries, entry) } sort.Slice(entries, func(i, j int) bool { @@ -228,30 +269,31 @@ func main() { } // decodeFrames reads every block of a compound bitmap resource as a -// sequence of frames, each one RLE-diffed against the previous - the same -// chaining hacked's own editor/animations/View.go uses when importing a -// GIF, done here in reverse (decode instead of encode). +// 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) - var prevPixels []byte 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) { - size := int(w) * int(h) - buf := make([]byte, size) - if len(prevPixels) == size { - copy(buf, prevPixels) - } - return buf, nil + return make([]byte, int(w)*int(h)), nil }) if err != nil { return nil, fmt.Errorf("block %d: %w", i, err) } - prevPixels = bmp.Pixels frames = append(frames, bmp) } return frames, nil @@ -265,34 +307,55 @@ func paletteFor(bmp *bitmap.Bitmap, fallback bitmap.Palette) color.Palette { return pal.ColorPalette(false) } -func writeGIF(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette, delays []int) error { +// 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() - maxWidth, maxHeight := 0, 0 + canvasWidth, canvasHeight := minWidth, minHeight for _, bmp := range frames { - if int(bmp.Header.Width) > maxWidth { - maxWidth = int(bmp.Header.Width) + if int(bmp.Header.Width) > canvasWidth { + canvasWidth = int(bmp.Header.Width) } - if int(bmp.Header.Height) > maxHeight { - maxHeight = int(bmp.Header.Height) + 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, which is why these looped only once - // before this fix. - out := gif.GIF{LoopCount: 0, Config: image.Config{Width: maxWidth, Height: maxHeight}} + // 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) - 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) + 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]) @@ -300,20 +363,114 @@ func writeGIF(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette, 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 string) (entry galleryEntry, err error) { +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) + return renderMultiBlockBitmap(view, fallbackPal, sourceFile, id, lang, outDir, suffix) } -func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (galleryEntry, error) { +// 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 @@ -323,33 +480,43 @@ func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sour } delays := make([]int, len(frames)) for i := range delays { - delays[i] = 10 // 100ms - no explicit timing exists for this raw form + delays[i] = 10 * gallerySpeedFactor // no explicit timing exists for this raw form } - gifName := fmt.Sprintf("%04X_%s.gif", int(id), lang.String()) - if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays); err != nil { + 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, + 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 string) (entry galleryEntry, err error) { +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) + return renderAnimationDescriptor(mod, fallbackPal, sourceFile, id, lang, outDir, suffix) } -func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (galleryEntry, error) { +// 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 @@ -375,7 +542,6 @@ func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourc var frames []*bitmap.Bitmap var delays []int - var prevPixels []byte frameIndex := 0 for _, entry := range anim.Entries { for frameIndex <= int(entry.LastFrame) { @@ -388,22 +554,21 @@ func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourc 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) { - size := int(w) * int(h) - buf := make([]byte, size) - if len(prevPixels) == size { - copy(buf, prevPixels) - } - return buf, nil + return make([]byte, int(w)*int(h)), nil }) if err != nil { return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err) } - prevPixels = bmp.Pixels frames = append(frames, bmp) - delay := int(entry.FrameTime) / 10 + delay := int(entry.FrameTime) / 10 * gallerySpeedFactor if delay <= 0 { - delay = 10 + delay = 10 * gallerySpeedFactor } delays = append(delays, delay) frameIndex++ @@ -413,19 +578,48 @@ func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourc return galleryEntry{}, fmt.Errorf("no frames decoded") } - gifName := fmt.Sprintf("%04X_%s.gif", int(id), lang.String()) - if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays); err != nil { + 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, + 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 @@ -460,6 +654,9 @@ func writeIndex(path string, entries []galleryEntry) error { 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}") @@ -507,7 +704,22 @@ func writeIndex(path string, entries []galleryEntry) error { fmt.Sprintf("%dx%d", e.width, e.height), transparency, }, " ")) fmt.Fprintf(&b, "
", html.EscapeString(search)) - fmt.Fprintf(&b, "", html.EscapeString(e.gifName), e.width*2, e.height*2) + 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
", diff --git a/modding/src/mdlp/upscale.go b/modding/src/mdlp/upscale.go index 21e53da..0622fc1 100644 --- a/modding/src/mdlp/upscale.go +++ b/modding/src/mdlp/upscale.go @@ -13,11 +13,13 @@ import ( "fmt" "image" "image/color" + "image/draw" "image/png" "math" "os" "os/exec" "path/filepath" + "sort" "strconv" "strings" @@ -48,11 +50,23 @@ var languageByName = map[string]resource.Language{ // upscaleDef is one modding/mods//bitmaps/