4 Commits

Author SHA1 Message Date
ml 345f8b5f17 Group animgallery cards by mod and add group quick filters
build / build (push) Successful in 2m49s
2026-10-27 08:27:59 +01:00
ml 90303894ce Fix animgallery rendering bugs and mitigate AI-upscale frame flicker
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.
2026-10-27 07:54:19 +01:00
ml a8d7ff76df Fix critter sprites cropped in the software renderer
build / build (push) Successful in 2m41s
g3_bitmap_common() (Bitmap.c) used the same bm_w/bm_h for both the
on-screen quad size and its texture-coordinate bounds. The former
should use patch 04's native-size override for a modded/upscaled
bitmap; the latter must always use the bitmap's own real dimensions,
since that's still what's actually being sampled. OpenGL never hit
this (opengl_bitmap() ignores these bounds and always maps the whole
real texture), but the software renderer's h_map() takes them as
literal pixel coordinates to interpolate, so the override capped how
much of a larger bitmap it ever read - cropping anything past the
original sprite's bounds, such as an upscaled critter's limbs.

Split into a separate tex_w/tex_h pair, always the bitmap's real
size, used for every texture-coordinate computation.

README.md: document this, and the previously-undocumented
engine-debug-tools RSD8 decode tracing patch (02).
2026-10-18 07:33:56 +02:00
ml 9d0a0d5798 Add engine-debug-tools and fix critter animation rendering bugs
build / build (push) Successful in 2m52s
- engine-debug-tools/: a debug dump hotkey + dump2png to inspect
  decoded critter frames independent of the renderer (see root
  README.md's "Debugging" section).
- engine-patches/04,05: fix critter sprite scale and an OpenGL
  texture cache collision, found using that tooling.
- mdlp: fix encodeFrames RLE-diffing upscaled frames against a
  previous-frame reference the real RSD8 decoder never implements
  (verified against Night Dive's original Mac source down to the
  68K assembly) - was rendering as scattered static in-game.
- Upscale pipeline: better color quantization (dithering, pure-ramp
  exclusion) and per-resource model selection.
2026-10-18 06:59:47 +02:00
25 changed files with 1927 additions and 221 deletions
+4
View File
@@ -47,3 +47,7 @@
# modding/README.md)
/modding/dist/
/modding/build/
# engine-debug-tools/dump2png's own scratch, patched source copy (see
# engine-debug-tools/build-dump2png.sh)
/engine-debug-tools/build/
+17 -1
View File
@@ -21,7 +21,7 @@ ASSETS_DIR := res/assets/ss_ee
BUILD_DIR := build
ARCH := $(shell uname -m)
.PHONY: all dist dist-linux dist-win build-image engine engine-win assets \
.PHONY: all dist dist-linux dist-win dist_debug build-image engine engine-win assets \
package package-linux package-win apk android-studio clean
all: dist
@@ -87,6 +87,22 @@ dist-linux: engine assets
chmod +x "$(DIST_DIR)/run-mods.sh"
@echo "== Done - run $(DIST_DIR)/run.sh to play =="
# Same as dist-linux, but also applies engine-debug-tools/engine-patches/
# (debug-only instrumentation - currently a dump hotkey, see
# engine-debug-tools/ and README.md's "Debugging" section), builds
# engine-debug-tools/dump2png into $(DIST_DIR) alongside it, and copies in
# convert-dumps.sh (res/convert-dumps.sh - a dump2png wrapper pointed at
# $(DIST_DIR)'s own res/data and dumps/, same convention as run-mods.sh
# wrapping run.sh). Overwrites whatever dist-linux last produced in
# $(DIST_DIR) - there's one dist/, and which of these two targets built it
# last is the only difference.
dist_debug: export ENGINE_DEBUG_TOOLS := 1
dist_debug: dist-linux
./engine-debug-tools/build-dump2png.sh "$(DIST_DIR)/dump2png"
cp res/convert-dumps.sh "$(DIST_DIR)/convert-dumps.sh"
chmod +x "$(DIST_DIR)/convert-dumps.sh"
@echo "== dist_debug: debug dump hotkey + $(DIST_DIR)/dump2png + $(DIST_DIR)/convert-dumps.sh are ready =="
# Windows counterpart of dist-linux. Unlike dist/, the DLLs sit flat
# alongside systemshock.exe instead of in a lib/ subdirectory - Windows'
# default DLL search order already checks the executable's own directory
+141 -8
View File
@@ -358,17 +358,25 @@ which the desktop build does not apply.
mod that replaces a critter's art with a higher-resolution bitmap for
more detail made the critter itself render bigger, proportionally to
the resolution increase. Fixed by caching every critter view's base-game
bitmap size (`objsim.c`'s `cache_critter_native_size()`/
`get_critter_native_size()`, keyed by resource ID) while
`objart2.res`/`objart3.res`'s base-game data is still the active
resolver in `obj_set_secondary_properties()` (before patch 03's
`LoadModFiles()` call hands priority to a mod), then using that cached
size instead of the bitmap's own (possibly modded) dimensions when
computing the sprite's on-screen extent - threaded through
bitmap size per animation frame (`objsim.c`'s
`cache_critter_native_size()`/`get_critter_native_size()`, keyed by
resource ID and frame index - an animation's bounding box commonly
changes size between poses, so a single per-view size doesn't fit every
frame of it) while `objart2.res`/`objart3.res`'s base-game data is still
the active resolver in `obj_set_secondary_properties()` (before patch
03's `LoadModFiles()` call hands priority to a mod), then using that
cached size instead of the bitmap's own (possibly modded) dimensions
when computing the sprite's on-screen extent - threaded through
`_fr_draw_bitmap()` and `g3_light_anchor_bitmap()`'s new `native_w`/
`native_h` parameters, consumed and reset by `g3_bitmap_common()` so an
unrelated bitmap draw never inherits a stale override. A mod's higher
resolution now only changes visible detail, not the object's size.
resolution now only changes visible detail, not the object's size. Since
this same size also sets `g3_bitmap_common()`'s texture coordinate
range, not just its world-space extent, caching only the first frame's
size and reusing it for every frame of an animation also visibly
corrupted the UV mapping on any frame whose real size differed from the
first - caught once a mod's per-frame sizes started genuinely varying
(see `modding/README.md` section 5).
This depends on the mod's bitmap keeping a real anchor point in its own
`Header.Area`, in the original, un-upscaled unit space (see
`modding/src/mdlp/upscale.go`'s `imageToBitmap()`) - `_fr_draw_bitmap()`
@@ -380,6 +388,131 @@ which the desktop build does not apply.
system with this patch's own size override rather than with the
bitmap's real pixel dimensions.
`g3_bitmap_common()`'s world-space size and its texture-coordinate range
are computed from the same `bm_w`/`bm_h` locals, but only the former
should ever use this patch's override - the pixels actually being
sampled are still the bitmap's own real (possibly upscaled) buffer,
regardless of what on-screen size it's drawn at. Overriding both left the
texture-coordinate range capped at the smaller, un-upscaled size while
the real bitmap underneath it was larger, so the renderer only ever
sampled that smaller top-left region of it - cropping off anything past
it, such as an upscaled critter's limbs extending beyond its original
sprite's bounding box. OpenGL never showed this: `opengl_bitmap()`
ignores `g3_bitmap_common()`'s computed `u`/`v` entirely and always maps
the *whole* bound texture over the quad via fixed `0.0`-`1.0` corners.
The software renderer's own scanline texture mapper (`h_map()`,
`gentm.c`) has no such shortcut - it takes `u`/`v` as literal pixel
coordinates into the bitmap and interpolates between exactly the values
it's given, so a capped range there directly caps how much of the real
bitmap it ever reads. Fixed by splitting `g3_bitmap_common()`'s single
`bm_w`/`bm_h` into the override-aware pair (world-space size only) and a
`tex_w`/`tex_h` pair that always holds the bitmap's own real dimensions,
used for every texture-coordinate computation (including the later
blend-buffer-size check, which sizes a buffer against the same real
pixel data these coordinates sample from).
- **`05-desktop-fix-texture-cache-collision.patch`** - `OpenGL.cc`'s
per-bitmap GL texture cache (`texturesByBitsPtr`) was keyed by
`bm->bits | (w << 32) | (h << 48)`, a plain bitwise OR rather than a
genuinely disjoint encoding - `bm->bits` is a full pointer, and a 64-bit
Linux heap/mmap address routinely has non-zero bits in the 32-47 range
the width field occupies, so a different `(bits, w, h)` triple can
collide with an already-cached entry's key. `bm->bits` itself is a
recycled slot in the resource system's own bounded LRU cache, not a
stable per-resource address, making a collision a real risk rather than
a theoretical one. On a collision, `set_texture()` (same file) treats it
as a genuine cache hit and reuses that entry's SDL surfaces - allocated
for whatever `(w, h)` was cached before - for an `SDL_memmove` and
`glTexImage2D` sized to the *current* bitmap's byte count, corrupting
the result with a buffer overflow or leftover stale pixel data from a
previous, differently-sized bitmap. Fixed by keying the cache on a
`std::tuple<void *, int, int>` instead, which `std::map` compares
field-by-field with no collision risk - a hit now always means an exact
`(bits, w, h)` match, so the cached surfaces are guaranteed to already
be sized correctly for the current bitmap.
A second, independent bug in the same cache: on a hit, `set_texture()`
"refreshed" the cached `SDL_Surface` with
`SDL_memmove(t->bitmap->pixels, bm->bits, ...)` - but `t->bitmap` was
created by `convert_texture()` via `SDL_CreateRGBSurfaceFrom(bm->bits,
...)`, which wraps the given pointer rather than copying it, so
`t->bitmap->pixels` *is* `bm->bits` from whenever this entry was first
cached. Since a hit means the current call's `bm->bits` is that same
pointer by definition (it's part of the key), that memmove was always a
no-op self-copy - it never refreshed anything. `bm->bits` is a recycled
resource-cache slot reused for different content across draws while its
address stays the same, so a hit here was never a guarantee the pixels
underneath were still current, only that this entry's GL texture and
converted-surface buffers are the right size to reuse. Fixed by
re-pointing `t->bitmap->pixels`/`pitch` at the current frame's real
source (`bm->bits`, or the RSD8-decoded buffer) before blitting, instead
of copying into what was already the same address. Most noticeable with
content whose per-frame bitmap dimensions vary a lot, such as critter
animations after an AI upscale mod re-exports each frame at its own
independently upscaled size (see `modding/README.md` section 5) -
vanilla assets mostly dodge the first bug because many critter
animations reuse the same bounding-box size across most of their
frames, but the second bug affects any critter whose rendering revisits
the same resource-cache slot across different draws, regardless of mod
content.
### 4.5. Debugging
`engine-debug-tools/` holds engine instrumentation that's useful for
diagnosing rendering bugs but has no place in a normal build - kept
separate from `engine-patches/` (section 4.4) so it's never silently
included in `make dist`/`make package`.
- `engine-debug-tools/engine-patches/` - patches in the same format and
numbered the same way as `engine-patches/` (section 4.4's own process
applies: scratch copy, `patch -p1`, regenerate via `diff -u` rather than
hand-editing hunks), applied on top of the regular `engine-patches/`
series, never in place of it. Two patches so far:
- **`01`** - a debug dump hotkey: pressing **K** in-game dumps every
frame of the currently/most-recently-drawn critter's animation to
`dumps/` (created next to the running binary) as raw palette-index
pixel data - 2-byte LE width, 2-byte LE height, then width\*height
index bytes, one file per frame, named `id<resource-id>_frame<N>.raw`.
Deliberately independent of the renderer's own texture/GL state, so a
frame already corrupted in the dump is a decode-time bug, while a
frame that's clean in the dump but wrong on screen is a rendering-time
one.
- **`02`** - RSD8 decode tracing: `gr_rsd8_convert()`/`gr_rsd8_unpack()`
(`engine/src/Libraries/2D/Source/RSD/`) print a `[debug rsd8]` summary
per frame (decode path, source/destination dimensions, bytes written
versus the bitmap's own area) and a `[debug rsd8 op]` line per RLE
opcode consumed, gated by a flag `debug_dump_critter_animation()` (01,
above) sets only around its own decode calls - tracing covers exactly
the frames the K hotkey dumps, not every RSD8 decode in the game. Each
frame is also decoded a second time back to back and logged as
`identical`/`DIFFERS` against the first, to tell a decode result that's
deterministic given the compressed source (a bad source stream, or a
decoder bug it triggers) apart from one that depends on something else,
such as a resource-cache slot being reused mid-decode.
- `engine-debug-tools/dump2png/` - converts those `.raw` dumps into
viewable PNGs using the real game palette (the dump format has no
palette of its own, to keep the in-game dump path from depending on the
renderer's own palette/SDL state). Builds the same way
`modding/gen_animgallery.sh` builds `animgallery` - against
`modding/src/hacked` inside `modding/mdlp-image`'s Go toolchain, since
neither has a cgo dependency of its own - via
`engine-debug-tools/build-dump2png.sh <output-path>`.
`make dist_debug` builds everything above into `dist/` alongside the
normal `make dist` output: the engine with `engine-debug-tools/
engine-patches/` applied in addition to the regular `engine-patches/`
series, plus `dist/dump2png` and `dist/convert-dumps.sh` (a `dump2png`
wrapper, same convention as `run-mods.sh` wrapping `run.sh`, pointed at
`dist/`'s own `res/data` and `dumps/` so no arguments are needed). There's
one `dist/` - whichever of `dist`/`dist_debug` ran last determines whether
the debug hotkey, `dump2png` and `convert-dumps.sh` are present; running
the other target again overwrites it back.
```sh
make dist_debug
./dist/run.sh # or run-mods.sh - play until the bug shows, press K, quit
./dist/convert-dumps.sh # writes dist/dumps-png/*.png from dist/dumps/*.raw
```
## 5. Android / Quest build
**Prerequisites:**
+11
View File
@@ -42,6 +42,17 @@ for p in "$REPO_ROOT"/engine-patches/*.patch; do
patch -p1 -d "$SCRATCH_ENGINE" < "$p"
done
# ENGINE_DEBUG_TOOLS=1 (set by the Makefile's dist_debug target) additionally
# applies engine-debug-tools/engine-patches/ - debug-only instrumentation
# (see root README.md's "Debugging" section) that stays out of every normal
# build.
if [ -n "${ENGINE_DEBUG_TOOLS:-}" ]; then
echo "== ENGINE_DEBUG_TOOLS set - also applying engine-debug-tools/engine-patches/ =="
for p in "$REPO_ROOT"/engine-debug-tools/engine-patches/*.patch; do
patch -p1 -d "$SCRATCH_ENGINE" < "$p"
done
fi
cd "$SCRATCH_ENGINE"
echo "== Wiring up prebuilt SDL2/SDL2_mixer/fluidsynth-lite from the image =="
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Builds dump2png (see dump2png/main.go) inside modding/mdlp-image's Go
# toolchain - dump2png has no cgo dependencies of its own, so it reuses that
# image rather than needing one of its own, same reasoning as
# modding/gen_animgallery.sh.
#
# Usage: ./build-dump2png.sh <output-path>
# <output-path> is relative to the repository root (typically
# dist/dump2png - see the root Makefile's dist_debug target and
# README.md's "Debugging" section) - it's written on both sides of the
# bind mount below, so an absolute or ../-escaping path won't resolve to
# the same file on the host afterward.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
REPO_ROOT="$(cd .. && pwd)"
[ "$#" -eq 1 ] || { echo "usage: $0 <output-path-relative-to-repo-root>" >&2; exit 1; }
OUT_PATH="$1"
case "$OUT_PATH" in
/*) echo "PREFLIGHT FAIL: <output-path> must be relative to the repo root, not absolute: $OUT_PATH" >&2; exit 1 ;;
esac
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 \
|| fail "docker not found in PATH"
IMAGE="${MDLP_REGISTRY_IMAGE:-questshock/mdlp}"
if [ -f "$REPO_ROOT/registry.env" ]; then
# shellcheck disable=SC1091
source "$REPO_ROOT/registry.env"
IMAGE="${MDLP_REGISTRY_IMAGE:-$IMAGE}"
fi
IMAGE_TAG="$(<"$REPO_ROOT/modding/mdlp-image/VERSION")"
[ -n "$IMAGE_TAG" ] || fail "modding/mdlp-image/VERSION is empty"
IMAGE="$IMAGE:$IMAGE_TAG"
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|| fail "$IMAGE not found locally - run modding/mdlp-image.sh first"
docker run --rm \
-v "$REPO_ROOT:/workspace" \
-w /workspace \
-e HOST_UID="$(id -u)" \
-e HOST_GID="$(id -g)" \
"$IMAGE" \
bash -c '
set -euo pipefail
# dump2png builds against ../../modding/src/hacked via its own
# go.mod replace (matching that relative depth exactly, since
# engine-debug-tools/ sits next to modding/ at the repo root) -
# stage a patched copy (see modding/src/hacked-patches/) at that
# same relative position rather than building against the
# unpatched vendored source.
SCRATCH_DIR="engine-debug-tools/build/dump2png-src"
rm -rf "$SCRATCH_DIR"
mkdir -p "$SCRATCH_DIR/engine-debug-tools"
bash modding/mdlp-image/stage-hacked.sh modding "$SCRATCH_DIR/modding/src/hacked"
cp -a engine-debug-tools/dump2png "$SCRATCH_DIR/engine-debug-tools/dump2png"
mkdir -p "$(dirname "/workspace/$1")"
(cd "$SCRATCH_DIR/engine-debug-tools/dump2png" && go build -o "/workspace/$1" .)
' _ "$OUT_PATH"
echo "Built: $OUT_PATH"
+12
View File
@@ -0,0 +1,12 @@
module questshock/dump2png
go 1.25.0
// hacked/ is vendored under modding/src/ (see ../../modding/README.md);
// dump2png reuses its resource/palette reading code (ss1/world,
// ss1/content/bitmap) instead of duplicating System Shock's file formats.
require github.com/inkyblackness/hacked v0.0.0
require github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
replace github.com/inkyblackness/hacked => ../../modding/src/hacked
+10
View File
@@ -0,0 +1,10 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+105
View File
@@ -0,0 +1,105 @@
// dump2png converts the raw indexed-pixel frame dumps written by the
// engine's debug dump hotkey (see ../engine-patches/ and the root
// README.md's "Debugging" section) into viewable PNGs, using the real game
// palette - see that same README section for the dump file format.
package main
import (
"encoding/binary"
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"sort"
"strings"
"github.com/inkyblackness/hacked/ss1/content/bitmap"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/world"
)
func main() {
if len(os.Args) != 4 {
fmt.Fprintln(os.Stderr, "usage: dump2png <game-data-dir> <dumps-dir> <out-dir>")
os.Exit(1)
}
dataDir, inDir, outDir := os.Args[1], os.Args[2], os.Args[3]
entries, err := os.ReadDir(dataDir)
if err != nil {
panic(err)
}
merged := &world.ManifestEntry{Origin: []string{dataDir}, ID: dataDir}
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext != ".res" && ext != ".dat" {
continue
}
fe, err := world.NewManifestEntryFrom([]string{filepath.Join(dataDir, e.Name())})
if err != nil {
continue
}
merged.Resources = append(merged.Resources, fe.Resources...)
}
sort.Slice(merged.Resources, func(i, j int) bool { return merged.Resources[i].ID < merged.Resources[j].ID })
mod := world.NewMod(func([]resource.ID, []resource.ID) {}, func() {})
if err := mod.World().InsertEntry(0, merged); err != nil {
panic(err)
}
pal, err := bitmap.NewPaletteCache(mod).Palette(resource.KeyOf(0x02BC, resource.LangDefault, 0))
if err != nil {
panic(err)
}
colors := pal.ColorPalette(false)
if err := os.MkdirAll(outDir, 0o755); err != nil {
panic(err)
}
files, err := os.ReadDir(inDir)
if err != nil {
panic(err)
}
for _, fi := range files {
if !strings.HasSuffix(fi.Name(), ".raw") {
continue
}
raw, err := os.ReadFile(filepath.Join(inDir, fi.Name()))
if err != nil {
fmt.Println("skip", fi.Name(), err)
continue
}
if len(raw) < 4 {
continue
}
w := int(binary.LittleEndian.Uint16(raw[0:2]))
h := int(binary.LittleEndian.Uint16(raw[2:4]))
pixels := raw[4:]
if len(pixels) < w*h {
fmt.Println("truncated", fi.Name())
continue
}
img := image.NewRGBA(image.Rect(0, 0, w, h))
for i := 0; i < w*h; i++ {
r, g, b, _ := colors[pixels[i]].RGBA()
img.Set(i%w, i/w, color.RGBA{R: byte(r >> 8), G: byte(g >> 8), B: byte(b >> 8), A: 0xff})
}
outPath := filepath.Join(outDir, strings.TrimSuffix(fi.Name(), ".raw")+".png")
of, err := os.Create(outPath)
if err != nil {
fmt.Println("create failed", outPath, err)
continue
}
err = png.Encode(of, img)
of.Close()
if err != nil {
fmt.Println("encode failed", outPath, err)
continue
}
fmt.Println("wrote", outPath, w, h)
}
}
@@ -0,0 +1,153 @@
--- a/src/GameSrc/objsim.c
+++ b/src/GameSrc/objsim.c
@@ -26,8 +26,10 @@
// Object simulator code for Citadel
#define __OBJSIM_SRC
-#include <string.h>
+#include <stdio.h>
#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
#include "Shock.h"
@@ -1924,6 +1926,93 @@
*h = s_critterNativeH[id][frame];
}
+// Resource id of the last-drawn critter animation, updated every frame from
+// gameobj.c's FAUBJ_CRIT case - read by debug_dump_critter_animation() on
+// the debug dump hotkey (see engine-debug-tools/, root README.md's
+// "Debugging" section). Debug-build only: see
+// engine-debug-tools/engine-patches/.
+static Id s_lastCritterId = 0;
+
+void debug_track_critter_draw(Id id) { s_lastCritterId = id; }
+
+// Dumps every frame of the last-drawn critter's current animation (the
+// whole resource id, not just the currently-visible frame) as raw
+// palette-index bytes, one file per frame, independent of the renderer's
+// own texture/GL state - lets a corrupted frame be told apart from
+// corruption introduced later, in rendering. Format: 2-byte LE width,
+// 2-byte LE height, then width*height index bytes - converted to a viewable
+// image by engine-debug-tools/dump2png, not here, to avoid this debug path
+// depending on the renderer's own palette/SDL state.
+void debug_dump_critter_animation(void) {
+ RefTable *prt;
+ int numFrames, frame;
+ char path[256];
+ FILE *f;
+
+ if (s_lastCritterId == 0) {
+ fprintf(stderr, "[debug dump] no critter drawn yet\n");
+ return;
+ }
+
+ mkdir("dumps", 0755); // ignore EEXIST - fopen below reports any real failure
+
+ prt = (RefTable *)ResLock(s_lastCritterId);
+ if (prt == NULL) {
+ fprintf(stderr, "[debug dump] ResLock(%d) failed\n", (int)s_lastCritterId);
+ return;
+ }
+ numFrames = prt->numRefs;
+ ResUnlock(s_lastCritterId);
+
+ fprintf(stderr, "[debug dump] id=%d numFrames=%d\n", (int)s_lastCritterId, numFrames);
+
+ for (frame = 0; frame < numFrames; frame++) {
+ FrameDesc *fd;
+ grs_bitmap decoded;
+ uchar *bits;
+ short w, h;
+
+ fd = (FrameDesc *)RefLock(MKREF(s_lastCritterId, frame));
+ if (fd == NULL) {
+ fprintf(stderr, "[debug dump] RefLock frame %d failed\n", frame);
+ continue;
+ }
+
+ if (fd->bm.type == BMT_RSD8) {
+ if (gr_rsd8_convert(&fd->bm, &decoded) != 0 /* GR_UNPACK_RSD8_OK */) {
+ fprintf(stderr, "[debug dump] gr_rsd8_convert frame %d failed\n", frame);
+ RefUnlock(MKREF(s_lastCritterId, frame));
+ continue;
+ }
+ bits = decoded.bits;
+ w = decoded.w;
+ h = decoded.h;
+ } else {
+ bits = fd->bm.bits;
+ w = fd->bm.w;
+ h = fd->bm.h;
+ }
+
+ snprintf(path, sizeof(path), "dumps/id%d_frame%d.raw", (int)s_lastCritterId, frame);
+ f = fopen(path, "wb");
+ if (f != NULL) {
+ uchar hdr[4];
+ hdr[0] = (uchar)(w & 0xFF);
+ hdr[1] = (uchar)((w >> 8) & 0xFF);
+ hdr[2] = (uchar)(h & 0xFF);
+ hdr[3] = (uchar)((h >> 8) & 0xFF);
+ fwrite(hdr, 1, 4, f);
+ fwrite(bits, 1, (size_t)w * (size_t)h, f);
+ fclose(f);
+ fprintf(stderr, "[debug dump] wrote %s (%dx%d)\n", path, (int)w, (int)h);
+ } else {
+ fprintf(stderr, "[debug dump] failed to open %s for writing\n", path);
+ }
+
+ RefUnlock(MKREF(s_lastCritterId, frame));
+ }
+}
+
errtype obj_set_secondary_properties() {
char i, j;
RefTable *prt;
--- a/src/GameSrc/Headers/objsim.h
+++ b/src/GameSrc/Headers/objsim.h
@@ -109,6 +109,10 @@
errtype obj_create_player(ObjLoc *plr_loc);
Ref ref_from_critter_data(ObjID oid, int triple, byte posture, short frame, short view);
void get_critter_native_size(Id id, short frame, short *w, short *h);
+// Debug-build only - see engine-debug-tools/, root README.md's "Debugging"
+// section.
+void debug_track_critter_draw(Id id);
+void debug_dump_critter_animation(void);
errtype obj_zero_unused();
grs_bitmap *obj_get_model_data(ObjID id, fix *x, fix *y, fix *z, grs_bitmap *bm2, Ref *ref1, Ref *ref2);
errtype obj_model_hack(ObjID id, uchar *hack_x, uchar *hack_y, uchar *hack_z, uchar *hack_type);
--- a/src/GameSrc/gameobj.c
+++ b/src/GameSrc/gameobj.c
@@ -1019,6 +1019,7 @@
{
short native_w = 0, native_h = 0;
get_critter_native_size(REFID(ref), REFINDEX(ref), &native_w, &native_h);
+ debug_track_critter_draw(REFID(ref));
_fr_draw_bitmap(tpdata, _fdt_dist, 0, anch.ul.x, anch.ul.y, native_w, native_h);
}
release_critter_bitmap_fast(ref);
--- a/src/Libraries/INPUT/Source/sdl_events.c
+++ b/src/Libraries/INPUT/Source/sdl_events.c
@@ -481,6 +481,19 @@
SDL_Event ev;
while (SDL_PollEvent(&ev)) {
+ // Debug-build only (engine-debug-tools/, root README.md's
+ // "Debugging" section): K dumps every frame of the last-drawn
+ // critter's current animation to dumps/. Checked here, before the
+ // switch below dispatches on event type at all, so it can never
+ // collide with an existing game keybinding - a plain letter rather
+ // than Insert, which some keyboards don't send at all (compact
+ // layouts, or routed through Fn).
+ if (ev.type == SDL_KEYDOWN && ev.key.keysym.sym == SDLK_k) {
+ extern void debug_dump_critter_animation(void);
+ debug_dump_critter_animation();
+ continue;
+ }
+
switch (ev.type) {
case SDL_QUIT:
// a bit hacky at this place, but this would allow exiting the game via the window's [x] button
@@ -0,0 +1,166 @@
--- a/src/Libraries/2D/Source/RSD/rsdcvt.c
+++ b/src/Libraries/2D/Source/RSD/rsdcvt.c
@@ -37,6 +37,7 @@
*
*/
#include <assert.h>
+#include <stdio.h>
#include <string.h>
#include "grs.h"
#include "bitmap.h"
@@ -47,6 +48,14 @@
uchar *grd_unpack_buf=NULL;
+// Set by debug_dump_critter_animation() (engine-debug-tools/, root
+// README.md's "Debugging" section) around its own gr_rsd8_convert() calls,
+// so a trace of this decode's internals - which path ran, whether it wrote
+// fewer bytes than the bitmap's own area, and (gr_rsd8_unpack(),
+// RSDUnpack.c) each opcode consumed - prints only for those calls, not
+// every RSD8 decode in the game. Debug-build only.
+int qs_debug_rsd8_trace = 0;
+
/*************************************************/
/* Puts 0's in place of skips and pads with 0's. */
/* i.e., grd_unpack_buf is entirely overwritten. */
@@ -138,6 +147,15 @@
}
}
rsd_done:
+ if (qs_debug_rsd8_trace) {
+ long written = (long)(p_dst - dbm->bits);
+ long expected = (long)dbm->row * (long)dbm->h;
+ fprintf(stderr,
+ "[debug rsd8] path=%s sbm(w=%d h=%d row=%d) dbm(row=%d h=%d) "
+ "written=%ld expected=%ld short_by=%ld\n",
+ (dbm->w == dbm->row) ? "fast" : "slow", sbm->w, sbm->h, sbm->row,
+ dbm->row, dbm->h, written, expected, expected - written);
+ }
if ((over_run = (dbm->bits+dbm->row*dbm->h)-p_dst)) {
assert(over_run > 0);
LG_memset (p_dst, kSkipColor, over_run);
--- a/src/Libraries/2D/Source/RSD/RSDUnpack.c
+++ b/src/Libraries/2D/Source/RSD/RSDUnpack.c
@@ -22,6 +22,7 @@
//
#include "grs.h"
+#include <stdio.h>
#include <stdbool.h>
//----------------------------------------------------------------------------
@@ -34,6 +35,10 @@
short count,count2;
ushort longcode;
uint32_t longval, *longdest, *longsrc;
+
+ extern int qs_debug_rsd8_trace; // rsdcvt.c - engine-debug-tools/, root README.md's "Debugging" section
+ uchar *src_start = src, *dest_start = dest;
+ long op_index = 0;
do
{
@@ -42,6 +47,7 @@
{
count = *(src++); // get count
val = *(src++); // get val
+ if (qs_debug_rsd8_trace) fprintf(stderr, "[debug rsd8 op] #%ld src_off=%ld dest_off=%ld type=run count=%d val=%d\n", op_index++, (long)(src-src_start), (long)(dest-dest_start), (int)count, (int)val);
if (count>=kMinLongLoop) // if at least kMinLongLoop bytes, do long word stuff
{
@@ -63,6 +69,7 @@
else if (code<0x80) // dump (copy) bytes
{
count = code;
+ if (qs_debug_rsd8_trace) fprintf(stderr, "[debug rsd8 op] #%ld src_off=%ld dest_off=%ld type=dump count=%d\n", op_index++, (long)(src-src_start), (long)(dest-dest_start), (int)count);
if (code>=kMinLongLoop) // if at least kMinLongLoop bytes, do long word stuff
{
count2 = count>>2;
@@ -84,6 +91,7 @@
{
count = code & 0x007f; // clear high byte
val = longval = 0L;
+ if (qs_debug_rsd8_trace) fprintf(stderr, "[debug rsd8 op] #%ld src_off=%ld dest_off=%ld type=skip count=%d\n", op_index++, (long)(src-src_start), (long)(dest-dest_start), (int)count);
if (count>=kMinLongLoop) // if at least kMinLongLoop bytes, do long word stuff
{
@@ -105,11 +113,15 @@
longcode = * (ushort *) src;
src += 2L;
- if (!longcode) break; // done?
+ if (!longcode) {
+ if (qs_debug_rsd8_trace) fprintf(stderr, "[debug rsd8 op] #%ld src_off=%ld dest_off=%ld type=terminator\n", op_index++, (long)(src-src_start), (long)(dest-dest_start));
+ break; // done?
+ }
else if (longcode<0x8000) // skip (zero)
{
count = longcode;
val = longval = 0L;
+ if (qs_debug_rsd8_trace) fprintf(stderr, "[debug rsd8 op] #%ld src_off=%ld dest_off=%ld type=longskip count=%d\n", op_index++, (long)(src-src_start), (long)(dest-dest_start), (int)count);
if (count>=kMinLongLoop) // if at least kMinLongLoop bytes, do long word stuff
{
@@ -129,6 +141,7 @@
else if (longcode<0xC000) // dump (copy)
{
count = longcode & 0x7fff; // clear high bit
+ if (qs_debug_rsd8_trace) fprintf(stderr, "[debug rsd8 op] #%ld src_off=%ld dest_off=%ld type=longdump count=%d\n", op_index++, (long)(src-src_start), (long)(dest-dest_start), (int)count);
if (count>=kMinLongLoop) // if at least kMinLongLoop bytes, do long word stuff
{
@@ -151,6 +164,7 @@
{
count = longcode & 0x3fff;
val = *(src++); // get val
+ if (qs_debug_rsd8_trace) fprintf(stderr, "[debug rsd8 op] #%ld src_off=%ld dest_off=%ld type=longrun count=%d val=%d\n", op_index++, (long)(src-src_start), (long)(dest-dest_start), (int)count, (int)val);
if (count>=kMinLongLoop) // if at least kMinLongLoop bytes, do long word stuff
{
--- a/src/GameSrc/objsim.c
+++ b/src/GameSrc/objsim.c
@@ -1979,11 +1979,43 @@
}
if (fd->bm.type == BMT_RSD8) {
- if (gr_rsd8_convert(&fd->bm, &decoded) != 0 /* GR_UNPACK_RSD8_OK */) {
+ extern int qs_debug_rsd8_trace; // rsdcvt.c - engine-debug-tools/, root README.md's "Debugging" section
+ uchar *first_copy;
+ size_t nbytes;
+ int rsd8_ok;
+
+ qs_debug_rsd8_trace = 1;
+ rsd8_ok = gr_rsd8_convert(&fd->bm, &decoded) == 0 /* GR_UNPACK_RSD8_OK */;
+ qs_debug_rsd8_trace = 0;
+ if (!rsd8_ok) {
fprintf(stderr, "[debug dump] gr_rsd8_convert frame %d failed\n", frame);
RefUnlock(MKREF(s_lastCritterId, frame));
continue;
}
+
+ // Re-decode the same compressed source (fd->bm.bits, unchanged
+ // by the first call - only grd_unpack_buf, the shared
+ // decode-output buffer, gets overwritten) a second time and
+ // compare: decode runs back to back on this single thread, with
+ // nothing else able to touch fd->bm.bits or grd_unpack_buf in
+ // between, so a byte-for-byte match here means any garbled
+ // pixels are a deterministic property of this exact compressed
+ // stream (bad source data, or a decoder bug it triggers), not a
+ // one-off from a resource-cache slot being reused mid-decode.
+ nbytes = (size_t)decoded.w * (size_t)decoded.h;
+ first_copy = malloc(nbytes);
+ if (first_copy != NULL) {
+ memcpy(first_copy, decoded.bits, nbytes);
+
+ qs_debug_rsd8_trace = 1;
+ gr_rsd8_convert(&fd->bm, &decoded);
+ qs_debug_rsd8_trace = 0;
+
+ fprintf(stderr, "[debug dump] frame %d repeat-decode of bm.bits=%p %s\n", frame,
+ (void *)fd->bm.bits, memcmp(first_copy, decoded.bits, nbytes) == 0 ? "identical" : "DIFFERS");
+ free(first_copy);
+ }
+
bits = decoded.bits;
w = decoded.w;
h = decoded.h;
@@ -1,11 +1,12 @@
--- a/src/GameSrc/objsim.c 2026-09-27 19:44:17.014441306 +0200
+++ b/src/GameSrc/objsim.c 2026-09-27 20:44:23.147083066 +0200
@@ -1867,6 +1867,40 @@
--- a/src/GameSrc/objsim.c
+++ b/src/GameSrc/objsim.c
@@ -1867,6 +1867,63 @@
return (OK);
}
+// Every critter view's base-game bitmap size, keyed by resource ID - filled
+// in by cache_critter_native_size() below while objart2.res/objart3.res's
+// Every critter view's base-game bitmap size, keyed by resource ID and
+// frame index within that animation - filled in by
+// cache_critter_native_size() below while objart2.res/objart3.res's
+// base-game data is still the active resolver for these IDs (before
+// LoadModFiles() hands priority to a mod again), read back by
+// get_critter_native_size() in gameobj.c. A mod that replaces a critter's
@@ -13,35 +14,57 @@
+// not the object's on-screen size: g3_bitmap_common() (Bitmap.c) uses this
+// cached size instead of the (possibly modded, possibly larger or smaller)
+// bitmap's own width/height when computing the sprite's world-space extent.
+// Keyed per frame, not just per view: an animation's bounding box commonly
+// changes size between poses, same as the base game's own art does, so a
+// single per-view size doesn't fit every frame of it - using one here left
+// every frame but the first using the wrong override, visibly corrupting
+// the sprite's UV mapping (g3_bitmap_common() uses this same size for
+// texture coordinates, not just world-space extent) once a mod's per-frame
+// sizes diverge from each other. MAX_CRITTER_FRAMES comfortably covers any
+// animation in the base game's own data; cache_critter_native_size() clamps
+// to it defensively rather than overrunning the array if that ever changes.
+// Id is uint16_t, so a flat array indexed by it needs no bounds check.
+static short s_critterNativeW[65536];
+static short s_critterNativeH[65536];
+#define MAX_CRITTER_FRAMES 32
+static short s_critterNativeW[65536][MAX_CRITTER_FRAMES];
+static short s_critterNativeH[65536][MAX_CRITTER_FRAMES];
+
+static void cache_critter_native_size(Id id) {
+ RefTable *prt;
+ FrameDesc *f;
+ if (s_critterNativeW[id] != 0)
+ int numFrames, frame;
+ if (s_critterNativeW[id][0] != 0)
+ return;
+ f = (FrameDesc *)RefLock(MKREF(id, 0));
+ prt = (RefTable *)ResLock(id);
+ if (prt == NULL)
+ return;
+ numFrames = prt->numRefs;
+ ResUnlock(id);
+ if (numFrames > MAX_CRITTER_FRAMES)
+ numFrames = MAX_CRITTER_FRAMES;
+ for (frame = 0; frame < numFrames; frame++) {
+ f = (FrameDesc *)RefLock(MKREF(id, frame));
+ if (f == NULL)
+ return;
+ s_critterNativeW[id] = f->bm.w;
+ s_critterNativeH[id] = f->bm.h;
+ RefUnlock(MKREF(id, 0));
+ continue;
+ s_critterNativeW[id][frame] = f->bm.w;
+ s_critterNativeH[id][frame] = f->bm.h;
+ RefUnlock(MKREF(id, frame));
+ }
+}
+
+// w/h are left untouched (so a caller can default to 0, meaning "no cached
+// native size, use the bitmap's own dimensions") if id was never cached.
+void get_critter_native_size(Id id, short *w, short *h) {
+ if (s_critterNativeW[id] == 0)
+// native size, use the bitmap's own dimensions") if id/frame was never
+// cached.
+void get_critter_native_size(Id id, short frame, short *w, short *h) {
+ if ((frame < 0) || (frame >= MAX_CRITTER_FRAMES) || (s_critterNativeW[id][frame] == 0))
+ return;
+ *w = s_critterNativeW[id];
+ *h = s_critterNativeH[id];
+ *w = s_critterNativeW[id][frame];
+ *h = s_critterNativeH[id][frame];
+}
+
errtype obj_set_secondary_properties() {
char i, j;
RefTable *prt;
@@ -1878,6 +1912,28 @@
@@ -1878,6 +1935,28 @@
// Warning(("Problem opening object art cache!\n"));
return (ERR_NOEFFECT);
}
@@ -70,18 +93,18 @@
// The two opens above re-register every resource ID objart2.res/
// objart3.res contain, reclaiming any of them a mod file already
// overrode (see LoadModFiles() in Modding.c) - restore mod priority
--- a/src/GameSrc/Headers/objsim.h 2026-07-19 13:11:51.000000000 +0200
+++ b/src/GameSrc/Headers/objsim.h 2026-09-27 19:39:39.585009491 +0200
--- a/src/GameSrc/Headers/objsim.h
+++ b/src/GameSrc/Headers/objsim.h
@@ -108,6 +108,7 @@
errtype obj_load_properties();
errtype obj_create_player(ObjLoc *plr_loc);
Ref ref_from_critter_data(ObjID oid, int triple, byte posture, short frame, short view);
+void get_critter_native_size(Id id, short *w, short *h);
+void get_critter_native_size(Id id, short frame, short *w, short *h);
errtype obj_zero_unused();
grs_bitmap *obj_get_model_data(ObjID id, fix *x, fix *y, fix *z, grs_bitmap *bm2, Ref *ref1, Ref *ref2);
errtype obj_model_hack(ObjID id, uchar *hack_x, uchar *hack_y, uchar *hack_z, uchar *hack_type);
--- a/src/GameSrc/gameobj.c 2026-07-19 13:11:51.000000000 +0200
+++ b/src/GameSrc/gameobj.c 2026-09-27 20:44:35.195019539 +0200
--- a/src/GameSrc/gameobj.c
+++ b/src/GameSrc/gameobj.c
@@ -104,7 +104,7 @@
void draw_ice(void);
void draw_ice_wall(void);
@@ -134,7 +157,7 @@
- _fr_draw_bitmap(tpdata, _fdt_dist, 0, anch.ul.x, anch.ul.y);
+ {
+ short native_w = 0, native_h = 0;
+ get_critter_native_size(REFID(ref), &native_w, &native_h);
+ get_critter_native_size(REFID(ref), REFINDEX(ref), &native_w, &native_h);
+ _fr_draw_bitmap(tpdata, _fdt_dist, 0, anch.ul.x, anch.ul.y, native_w, native_h);
+ }
release_critter_bitmap_fast(ref);
@@ -145,8 +168,8 @@
}
} break;
--- a/src/Libraries/3D/Source/Bitmap.c 2026-07-19 13:11:51.000000000 +0200
+++ b/src/Libraries/3D/Source/Bitmap.c 2026-09-27 20:44:47.378954871 +0200
--- a/src/Libraries/3D/Source/Bitmap.c
+++ b/src/Libraries/3D/Source/Bitmap.c
@@ -88,6 +88,14 @@
// fix _g3d_bitmap_y_iscale = 0x010000;
long _g3d_bitmap_u_anchor = 0;
@@ -177,7 +200,15 @@
_g3d_bitmap_clut = (p->i & 0x00ff00) + grd_screen->ltab;
_g3d_bitmap_poly = vpl;
return (g3_bitmap_common(bm, p));
@@ -185,6 +196,15 @@
@@ -176,6 +187,7 @@
grs_vertex *tempG1, *tempG2;
fix tempResult;
int32_t bm_w, bm_h;
+ int32_t tex_w, tex_h;
fix dx, dy;
// MLA- these were globals, I made them locals for PPC speed, they aren't
@@ -185,6 +197,15 @@
long rm2;
long rm3;
@@ -193,7 +224,7 @@
#ifdef stereo_on
if (_g3d_stereo & 1) {
@@ -209,9 +229,11 @@
@@ -209,9 +230,23 @@
if ((p->codes & CC_CLIP_OVERFLOW) != 0)
return (0L);
@@ -202,14 +233,73 @@
- bm_h = bm->h;
+ // copy a few things into locals - w_override/h_override, if set (see
+ // _g3d_bitmap_w_override's own comment), take the place of the bitmap's
+ // own pixel dimensions here.
+ // own pixel dimensions here, for the on-screen quad's world-space size
+ // only. tex_w/tex_h stay the bitmap's own real dimensions always: the
+ // quad's u/v bounds (below) are pixel coordinates into bm's own actual
+ // pixel data - the software renderer's scanline texture mapper
+ // (h_map(), gentm.c) interpolates and samples them literally, so
+ // overriding them to a smaller size here would make it only ever read
+ // part of a bitmap bigger than that override (such as an upscaled mod's
+ // replacement art), cropping the rest. OpenGL doesn't read u/v from here
+ // at all (opengl_bitmap() always maps the whole real texture over the
+ // quad), which is why this only ever showed up in the software
+ // renderer.
+ bm_w = w_override ? w_override : bm->w;
+ bm_h = h_override ? h_override : bm->h;
+ tex_w = bm->w;
+ tex_h = bm->h;
tempL = p->gZ; // mov eax,[edi].z
tempL <<= 8; // sal eax,8 ;should be
--- a/src/Libraries/3D/Source/3d.h 2026-07-19 13:11:51.000000000 +0200
+++ b/src/Libraries/3D/Source/3d.h 2026-09-27 19:42:26.528424908 +0200
@@ -299,7 +334,7 @@
tempG1->y = tempL2;
// vpl[1][2] = bitmap.w-1
- tempG1->u = (bm_w - 1) << 16;
+ tempG1->u = (tex_w - 1) << 16;
// vpl[1][3] = 0
tempG1->v = 0;
@@ -326,10 +361,10 @@
tempG1->y = tempL2;
// vpl[2][2] = bitmap.w-1
- tempG1->u = (bm_w - 1) << 16;
+ tempG1->u = (tex_w - 1) << 16;
// vpl[2][3] = bitmap.h-1
- tempG1->v = (bm_h - 1) << 16;
+ tempG1->v = (tex_h - 1) << 16;
// vpl[2][4] undefined
@@ -356,7 +391,7 @@
tempG1->u = 0;
// vpl[3][3] = bitmap.h
- tempG1->v = bm_h << 16;
+ tempG1->v = tex_h << 16;
// vpl[3][4] undefined
@@ -380,11 +415,13 @@
if (dy > dx)
dx = dy; // get max value in dx
- // shift down because bm_w isn't fixed, and we only double when twice the size
+ // shift down because tex_w isn't fixed, and we only double when twice the size
dx >>= 0x11;
// make sure doubled bitmap won't overflow unpack buffer (that's roughly 1/5 of 64k)
- if ((bm_w <= dx) && (bm_w * bm_h <= 12000)) {
+ // - against the bitmap's own real size (tex_w/tex_h), same as the u/v this doubles
+ // just below: both operate on bm's actual pixel data, not the on-screen override.
+ if ((tex_w <= dx) && (tex_w * tex_h <= 12000)) {
// first fix all u's and v's
for (tempL = 0; tempL < 4; tempL++) {
_g3d_bitmap_poly[tempL]->u <<= 1;
--- a/src/Libraries/3D/Source/3d.h
+++ b/src/Libraries/3D/Source/3d.h
@@ -720,7 +720,8 @@
grs_vertex **g3_light_bitmap(grs_bitmap *bm, g3s_phandle p);
// MLA #pragma aux g3_light_bitmap "*" parm [esi] [edi] modify [eax ebx ecx
@@ -0,0 +1,84 @@
--- a/src/MacSrc/OpenGL.cc
+++ b/src/MacSrc/OpenGL.cc
@@ -41,6 +41,7 @@
#include <map>
#include <sstream>
+#include <tuple>
struct CachedTexture {
SDL_Surface *bitmap;
@@ -83,8 +84,23 @@
static SDL_Palette *opaquePalette;
static SDL_Palette *transparentPalette;
-// Texture cache to keep SDL surfaces and GL textures in memory
-static std::map<uint64_t, CachedTexture> texturesByBitsPtr;
+// Texture cache to keep SDL surfaces and GL textures in memory. Keyed by the
+// full (bits, w, h) tuple, not a packed uint64_t - bm->bits is a full
+// pointer, and a 64-bit Linux heap/mmap address routinely has non-zero bits
+// in the 32-47 range, so OR-packing w/h on top of it (as this used to do)
+// can collide: a genuinely different (bits, w, h) triple produces the same
+// packed key as a cached entry for a different bitmap, so a lookup reports a
+// false "hit" and set_texture() reuses that entry's SDL surfaces - sized for
+// the old entry's own dimensions - for an SDL_memmove and glTexImage2D using
+// the *current* bitmap's byte count, corrupting the result with a buffer
+// overflow or leftover stale pixel data from whatever was cached before.
+// bm->bits itself is a recycled slot in the resource system's own bounded
+// LRU cache, not a stable per-resource address, so a collision is a real
+// risk in practice, not just theoretical - most noticeable with content
+// whose per-frame bitmap dimensions vary a lot (critter animations with
+// different poses per frame, especially after an AI upscale mod re-exports
+// each frame at its own independently-upscaled size).
+static std::map<std::tuple<void *, int, int>, CachedTexture> texturesByBitsPtr;
static float view_scale;
static int phys_width;
@@ -577,7 +593,7 @@
bind_texture(toCache.texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bm->w, bm->h, 0, GL_RGBA, GL_UNSIGNED_BYTE, toCache.converted->pixels);
- texturesByBitsPtr[(uint64_t)bm->bits | ((uint64_t)bm->w << 32u) | ((uint64_t)bm->h << 48u)] = toCache;
+ texturesByBitsPtr[std::make_tuple(bm->bits, (int)bm->w, (int)bm->h)] = toCache;
return true;
}
@@ -589,7 +605,7 @@
}
static CachedTexture *opengl_get_texture(grs_bitmap *bm) {
- auto iter = texturesByBitsPtr.find((uint64_t)bm->bits | ((uint64_t)bm->w << 32u) | ((uint64_t)bm->h << 48u));
+ auto iter = texturesByBitsPtr.find(std::make_tuple(bm->bits, (int)bm->w, (int)bm->h));
if (iter != texturesByBitsPtr.end()) {
return &iter->second;
}
@@ -685,13 +701,27 @@
isDirty = true;
}
} else {
+ // t->bitmap wraps its source pixels rather than owning a copy (see
+ // convert_texture()), and a cache hit means bm->bits is by
+ // definition the same pointer this entry was first cached with
+ // (opengl_get_texture()'s key includes it) - so memmove-ing
+ // bm->bits into t->bitmap->pixels used to be a no-op self-copy,
+ // never actually refreshing anything. bm->bits is a slot in the
+ // resource system's own bounded LRU cache, reused for different
+ // content across draws while its address stays the same, so a hit
+ // here doesn't mean the pixels are still current - only that this
+ // entry's GL texture object and converted-surface buffer are the
+ // right size to reuse. Re-pointing at the current source instead
+ // keeps every other field (dimensions, palette, clip rect) intact
+ // while making the surface actually see this draw's real pixels.
if (bm->type == BMT_RSD8) {
grs_bitmap decoded;
gr_rsd8_convert(bm, &decoded);
- SDL_memmove(t->bitmap->pixels, decoded.bits, bm->w * bm->h);
+ t->bitmap->pixels = decoded.bits;
} else {
- SDL_memmove(t->bitmap->pixels, bm->bits, bm->w * bm->h);
+ t->bitmap->pixels = bm->bits;
}
+ t->bitmap->pitch = bm->row;
setPaletteForBitmap(bm, t->bitmap);
SDL_BlitSurface(t->bitmap, nullptr, t->converted, nullptr);
+4 -1
View File
@@ -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
+141
View File
@@ -192,6 +192,8 @@ mods/
messages/
<label>.json # one message's overrides
<label>.txt # optional: a text field's body, referenced by ...File
bitmaps/
<label>.json # an AI-upscaled set of critter/object art resources
```
Each `messages/*.json` overrides one message (email, log, or fragment) by
@@ -213,6 +215,27 @@ label:
`mods/mymod/` is a worked example matching section 3's Rebecca's-mail edit,
expressed as MDL instead of a `hacked` session.
Each `bitmaps/*.json` names a shared upscale configuration and the
resources to apply it to - `gen_animgallery.sh`'s own checkbox-driven MDL
generator (its rendered gallery page has a selection UI that assembles this
exact JSON shape from whatever's checked) produces this format directly:
| Field | Meaning |
|---|---|
| `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<ID>"` (language `Any`) or `"0x<ID>_<Language>"` (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`):
| `model` | Checkpoint | Best for |
|---|---|---|
| `realesrgan-x4plus-anime` | Real-ESRGAN's 6-block anime/illustration RRDBNet (16.7M params) | Flat-color sprite art - keeps hard edges instead of smoothing them the way a photo/video model would. Default choice. |
| `realesrgan-x4plus` | Real-ESRGAN's general-purpose photo RRDBNet (16.7M params) | A specific resource where the anime model genuinely mis-renders a region (seen on some high-contrast color boundaries) - less edge-sharp overall, much slower, and can mis-render its own different regions, so check the result before keeping it |
| `realesr-general-x4v3` | Real-ESRGAN's compact general-purpose SRVGGNet (1.2M params) | A specific resource neither RRDBNet model renders cleanly - noticeably softer than either, but the most robust against both of their failure modes. Not a blanket replacement, just a last resort for one stubborn sprite |
### 4.2. `mdlp`
`src/mdlp/` is first-party Go source (not vendored - edit it directly, no
@@ -281,3 +304,121 @@ Same as `hacked-build-image/` (section 2.1): `./mdlp-upload-image.sh` reads
`mdlp-image/VERSION` just because the Dockerfile changed - only once a build
with the current version has actually succeeded, same policy as the root
project's own `build-image/VERSION`.
## 5. Testing
Verifying a mod's built output (particularly an AI-upscaled one, section
4.1) means comparing its rendered result against the unmodified base game,
not just eyeballing the result in isolation - a subtly wrong color or a
corrupted frame can look plausible on its own. `gen_animgallery.sh` renders
straight from a built mod's own resource data (`dist/mods/<name>/mod`), the
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/<name>/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. 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. Cards are sectioned by the `-data`
directory they come from - "base game" first, then one section per mod
(named after `dist/mods/<name>`) - so a mod's own animations, for example
all of `upscale_medbot`'s, are listed together.
`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.
A clean gallery render rules out a bug in the mod's resource *data*, not a
bug in how the engine *renders* that data - the two are independent, and a
problem can exist in only one of them. Actually running the built mod
(`../dist/run-mods.sh` or `run.sh` with a single mod folder, section 4.3)
is the only way to check the second. This in-engine verification has so
far only been through the desktop build, not the Quest headset - both
read the same resource data and the engine-side bugs found here
(`engine-patches/`, root [README.md](../README.md) section 4's patch
reference) were resource-data-adjacent, not OpenXR-specific, so a fix at
this level should carry over, but it isn't headset-confirmed until tested
there.
The AI-upscale pipeline (`src/mdlp/upscale.go`) and the engine's own
rendering of its output have a failure mode worth specifically checking
for, found by comparing upscaled output against the base game across many
resources rather than trusting a handful of spot-checks:
- **Wrong-hue speckling in dark or muted regions.** SS1's palette has a
handful of fully-saturated "pure color ramp" entries (pure cyan, red,
green, ...) that sit deceptively close, in color-distance terms, to
common dark/muted shading colors - a plain nearest-color search, with or
without dithering, can pick one of these for a near-black pixel the AI
upscaler produced, since color distance near black barely penalizes an
unrelated hue. `isPureRampColor` excludes these entries from
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
that's the only convention the game's actual RSD8 decoder implements - so
an animation's frames never depend on each other, and a size change
between poses isn't a concern to test for. This is confirmed directly
against Night Dive's officially published original Mac source
(`ShockMac/Libraries/2D/Source/RSD/{RSDCVT.C,RSDUnpack.c}`), not just the
vendored Shockolate snapshot in `engine/`: both files are logic-identical
to their vendored counterparts (same 1994 RCS revision comments, same
unconditional `LG_memset(p_dst, kSkipColor, ...)` zero-fill in every skip
branch), and `RSDUnpack.c` additionally carries the original hand-written
68K assembly decoder alongside its later PowerPC/C port - its own skip
handler also zeroes an output register rather than reading from a
reference. Two independently-authored implementations, spanning different
CPU architectures and years apart, agreeing on "skip means zero" down to
matching source comments ("skip (zero) bytes") rules this out as an
overlooked bug rather than deliberate, original design.
The RLE format's own opcode grammar does permit the alternative reading -
"skip" meaning "retain the reference image's value" rather than "write
zero" (`ss-specs` documents this as relevant to compressing "video
clips", which is what led `hacked`'s own GIF-import path, and therefore
earlier versions of `encodeFrames`, to use it). Searching the original
source for anywhere that convention is actually exercised turned up
nothing: SS1's two "video clip" features - cutscenes (`MoviePlay.c`) and
"video mail" (`VMAIL.C`, itself built directly on `MoviePlay.h`) - both
use an entirely separate movie codec, not RSD8, and neither references
the RSD8 decoder at all.
`gen_animgallery.sh` and the real engine therefore always agree on an
upscaled animation's frames; a difference between them points at a
genuine engine rendering bug (root [README.md](../README.md) section 4's
patch reference) rather than this pipeline's own output, and
`engine-debug-tools/`'s debug dump hotkey (root README.md section 4.5
"Debugging") dumps the engine's own decoded frame data independent of its
renderer to tell the two apart.
+34 -14
View File
@@ -21,23 +21,43 @@ RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl gosu patch python3 python3-pip \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install --no-cache-dir onnxruntime pillow numpy
RUN pip3 install --no-cache-dir onnxruntime pillow numpy scipy
# The AI upscaler model for mdlp's upscale mod type (see ../README.md
# section 4): RealESRGAN_x4plus_anime_6B, a 6-block RRDBNet ONNX-exported
# from xinntao/Real-ESRGAN's anime-illustration checkpoint (BSD-3-Clause).
# It's trained on flat-color line art, so it keeps hard sprite edges rather
# than smoothing them the way a photo- or video-denoising model would. A
# mod's own "model" field (upscaleDef in ../src/mdlp/upscale.go) is not
# consulted; every resource goes through this one model. upscale_batch.py
# resizes its fixed native 4x output down to whatever scale a mod actually
# asks for.
ARG UPSCALE_MODEL_URL=https://huggingface.co/skillsafe-ai/realesrgan-x4plus-anime-6b/resolve/main/model.onnx
RUN mkdir -p /opt/upscale-model \
&& curl -sSL -o /opt/upscale-model/model.onnx "$UPSCALE_MODEL_URL"
# The AI upscaler models for mdlp's upscale mod type (see ../README.md
# section 4), selected per mod by a bitmaps/*.json file's own "model" field
# (upscaleDef in ../src/mdlp/upscale.go) - upscale_batch.py resolves a name
# straight to $UPSCALE_MODELS_DIR/<name>.onnx, and picks tiled or single-pass
# inference based on each model's own declared ONNX input shape (fixed vs
# dynamic height/width), so adding another model here needs no code change.
# Each model's RGB output is re-quantized to SS1's fixed 256-color palette
# with Floyd-Steinberg dithering (imageToBitmap in ../src/mdlp/upscale.go),
# not a plain nearest-color map - without it, any model's smooth gradients
# can land on a visibly wrong, much darker palette entry in a solid patch
# rather than unnoticeable dithering noise.
# All three are BSD-3-Clause ONNX exports of xinntao/Real-ESRGAN checkpoints:
# - realesrgan-x4plus-anime: a 6-block RRDBNet trained on flat-color
# illustration, keeping hard sprite edges rather than smoothing them the
# way a photo- or video-denoising model would. Dynamic input shape. Default
# choice - sharpest result on most sprites.
# - realesrgan-x4plus: the general-purpose photo checkpoint (16.7M-param
# RRDBNet). Fixed 128x128 input, needs tiling for anything larger, and
# much slower than the other two. Worth trying when the anime model
# mis-renders a specific sprite, but can itself mis-render different ones.
# - realesr-general-x4v3: a compact 1.2M-param SRVGGNet, dynamic input
# shape, ~100x faster than realesrgan-x4plus. Noticeably softer than
# either RRDBNet model, but the most robust against both models' failure
# modes - the model to reach for on a specific sprite neither RRDBNet
# model renders cleanly, not as a blanket replacement.
ARG UPSCALE_MODEL_ANIME_URL=https://huggingface.co/skillsafe-ai/realesrgan-x4plus-anime-6b/resolve/main/model.onnx
ARG UPSCALE_MODEL_GENERAL_URL=https://huggingface.co/qualcomm/Real-ESRGAN-x4plus/resolve/6f5749d5adff39de0127e42bc45c8ec8e6f76a66/Real-ESRGAN-x4plus.onnx
ARG UPSCALE_MODEL_COMPACT_URL=https://huggingface.co/jonathanst29/tinier-upscale-models/resolve/main/realesr-general-x4v3.onnx
RUN mkdir -p /opt/upscale-models \
&& curl -sSL -o /opt/upscale-models/realesrgan-x4plus-anime.onnx "$UPSCALE_MODEL_ANIME_URL" \
&& curl -sSL -o /opt/upscale-models/realesrgan-x4plus.onnx "$UPSCALE_MODEL_GENERAL_URL" \
&& curl -sSL -o /opt/upscale-models/realesr-general-x4v3.onnx "$UPSCALE_MODEL_COMPACT_URL"
COPY mdlp-image/upscale_batch.py /usr/local/bin/upscale_batch.py
RUN chmod 755 /usr/local/bin/upscale_batch.py
ENV UPSCALE_MODEL_PATH=/opt/upscale-model/model.onnx
ENV UPSCALE_MODELS_DIR=/opt/upscale-models
RUN curl -sSLO "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \
&& tar -C /usr/local -xzf "go${GO_VERSION}.linux-amd64.tar.gz" \
+102 -9
View File
@@ -8,7 +8,13 @@ Runs on CPU only, not Vulkan: this Docker image has no real GPU, and
Vulkan software emulation (Mesa lavapipe) gives wrong colors for ML
inference workloads.
Usage: upscale_batch.py <model.onnx> <in-dir> <out-dir> <scale>
Resolves <model-name> to $UPSCALE_MODELS_DIR/<model-name>.onnx and picks
single-pass or tiled inference from that model's own declared ONNX input
shape - a model with a fixed height/width (e.g. 128x128) needs tiling for
any image bigger than that; one with symbolic dimensions runs the whole
image through in one pass, whatever its size.
Usage: upscale_batch.py <model-name> <in-dir> <out-dir> <scale>
"""
import sys
import os
@@ -16,31 +22,118 @@ import os
import numpy as np
import onnxruntime as ort
from PIL import Image
from scipy import ndimage
def extend_opaque_color(rgb, alpha, threshold=128):
"""Replaces every below-threshold-alpha pixel's RGB with its nearest
opaque neighbor's, so the model (RGB-only, see run_tile()) sees a
smooth color field instead of a hard edge against flat black -
otherwise its convolutions blend genuine edge colors with that black
background, leaving dark fringing on the final (correctly re-masked)
output."""
transparent = np.asarray(alpha) < threshold
if not transparent.any() or transparent.all():
return rgb
_, indices = ndimage.distance_transform_edt(transparent, return_indices=True)
arr = np.asarray(rgb)
filled = arr[indices[0], indices[1]]
return Image.fromarray(np.where(transparent[..., None], filled, arr))
def run_tile(session, input_name, tile_rgb):
"""Runs one tile (already at the model's required size) and returns its
upscaled RGB as a uint8 HxWx3 array - handles both the with-batch-dim
([1,3,H,W]) and without ([3,H,W]) output conventions different ONNX
exports use."""
arr = tile_rgb.astype(np.float32) / 255.0
arr = np.transpose(arr, (2, 0, 1))[np.newaxis, ...]
out = session.run(None, {input_name: arr})[0]
if out.ndim == 4:
out = out[0]
out = np.clip(out, 0.0, 1.0) * 255.0
return np.transpose(out, (1, 2, 0)).astype(np.uint8)
def upscale_dynamic(session, input_name, rgb_image):
return Image.fromarray(run_tile(session, input_name, np.asarray(rgb_image)))
def upscale_tiled(session, input_name, rgb_image, tile_size, native_scale):
"""Splits an image larger than the model's fixed input size into
overlapping tile_size x tile_size tiles (edge-padding any tile that
would otherwise run past the image bounds), runs each individually, and
stitches the results back into one canvas - later tiles simply
overwrite the small overlap region of earlier ones, which is invisible
at sprite scale."""
src = np.asarray(rgb_image)
h, w = src.shape[:2]
canvas = np.zeros((h * native_scale, w * native_scale, 3), dtype=np.uint8)
overlap = 16
stride = tile_size - overlap
y = 0
while True:
ty = min(y, max(0, h - tile_size))
x = 0
while True:
tx = min(x, max(0, w - tile_size))
tile = src[ty:ty + tile_size, tx:tx + tile_size]
th, tw = tile.shape[:2]
if th < tile_size or tw < tile_size:
tile = np.pad(tile, ((0, tile_size - th), (0, tile_size - tw), (0, 0)), mode="edge")
out = run_tile(session, input_name, tile)[: th * native_scale, : tw * native_scale]
oy, ox = ty * native_scale, tx * native_scale
canvas[oy:oy + th * native_scale, ox:ox + tw * native_scale] = out
if tx + tile_size >= w:
break
x += stride
if ty + tile_size >= h:
break
y += stride
return Image.fromarray(canvas)
def main():
model_path, in_dir, out_dir, scale_arg = sys.argv[1:5]
model_name, in_dir, out_dir, scale_arg = sys.argv[1:5]
scale = int(scale_arg)
models_dir = os.environ["UPSCALE_MODELS_DIR"]
model_path = os.path.join(models_dir, f"{model_name}.onnx")
if not os.path.isfile(model_path):
available = sorted(f[:-5] for f in os.listdir(models_dir) if f.endswith(".onnx"))
sys.exit(f"unknown upscale model {model_name!r} - available: {', '.join(available)}")
session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
input_name = session.get_inputs()[0].name
input_info = session.get_inputs()[0]
input_name = input_info.name
# A fixed (integer) height/width means this model only ever accepts
# that exact input size - a dynamic one (reported as a symbolic name
# such as "height") accepts any size in one pass. For a fixed-size
# model, its own declared output shape gives the native scale factor
# directly, with no need to run it just to find out.
fixed_size = input_info.shape[2] if isinstance(input_info.shape[2], int) else None
native_scale = session.get_outputs()[0].shape[-1] // fixed_size if fixed_size else None
names = sorted(n for n in os.listdir(in_dir) if n.lower().endswith(".png"))
for name in names:
img = Image.open(os.path.join(in_dir, name)).convert("RGBA")
rgb = img.convert("RGB")
alpha = img.split()[3]
rgb = extend_opaque_color(rgb, alpha)
# The model has no concept of transparency (3-channel RGB in/out) -
# run it on the flattened RGB only, and scale the alpha mask
# separately with a plain resize. Alpha edges don't need AI
# enhancement, just to end up the right size.
arr = np.asarray(rgb, dtype=np.float32) / 255.0
arr = np.transpose(arr, (2, 0, 1))[np.newaxis, ...]
output = session.run(None, {input_name: arr})[0]
output = np.clip(output[0], 0.0, 1.0) * 255.0
output = np.transpose(output, (1, 2, 0)).astype(np.uint8)
upscaled_rgb = Image.fromarray(output, "RGB")
if fixed_size is not None and (img.width > fixed_size or img.height > fixed_size):
upscaled_rgb = upscale_tiled(session, input_name, rgb, fixed_size, native_scale)
elif fixed_size is not None:
padded = np.pad(np.asarray(rgb), ((0, fixed_size - img.height), (0, fixed_size - img.width), (0, 0)), mode="edge")
out = run_tile(session, input_name, padded)
upscaled_rgb = Image.fromarray(out[: img.height * native_scale, : img.width * native_scale])
else:
upscaled_rgb = upscale_dynamic(session, input_name, rgb)
target_size = (img.width * scale, img.height * scale)
if upscaled_rgb.size != target_size:
@@ -1,7 +0,0 @@
{
"type": "Mails",
"index": 26,
"sender": "Rebecca Lansing1",
"title": "Rebecca-001",
"verboseTextFile": "mail-026.txt"
}
@@ -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.
@@ -22,5 +22,6 @@
"0x7B6",
"0x7B7",
"0x84C"
]
],
"considerPreviousFrames": ["0x7B1"]
}
+373 -79
View File
@@ -1,9 +1,15 @@
// 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. The index page
// sections its cards by the -data layer they come from: the base game, then
// each mod.
package main
import (
@@ -12,7 +18,9 @@ import (
"html"
"image"
"image/color"
"image/draw"
"image/gif"
"image/png"
"log"
"os"
"path/filepath"
@@ -36,6 +44,18 @@ type galleryEntry struct {
height int
transparent bool
gifName string
filmstripName string
// group names the winning -data layer (see groupName), dirIndex is that
// layer's position among the -data directories - the index page
// sections its cards by group, in dirIndex order.
group string
dirIndex int
// 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
@@ -48,6 +68,20 @@ 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 }
// groupName labels one -data layer for the index page: "base game" for the
// first one, otherwise the mod's name. A built mod lives at
// dist/mods/<name>/mod, so a directory named "mod" is labeled by its parent.
func groupName(absDir string, dirIndex int) string {
if dirIndex == 0 {
return "base game"
}
name := filepath.Base(absDir)
if name == "mod" {
name = filepath.Base(filepath.Dir(absDir))
}
return name
}
// 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
@@ -129,11 +163,13 @@ func main() {
// later layer (a mod) overrides.
sourceFileByKey := make(map[resource.Key]string)
sourceDirIndexByKey := make(map[resource.Key]int)
groupByDirIndex := make(map[int]string)
for dirIndex, dir := range dirs {
absDir, err := filepath.Abs(dir)
if err != nil {
log.Fatalf("resolving -data %q: %v", dir, err)
}
groupByDirIndex[dirIndex] = groupName(absDir, dirIndex)
entry, err := loadDataDir(absDir)
if err != nil {
// A mod that doesn't touch any resource file yet (e.g. an
@@ -167,6 +203,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 +249,32 @@ 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
}
entry.dirIndex = sourceDirIndexByKey[key]
entry.group = groupByDirIndex[entry.dirIndex]
// 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
}
}
switch {
case view.ContentType() == resource.Animation:
entry, err := safeRenderAnimationDescriptor(mod, defaultPal, sourceFile, key.ID, key.Lang, gifDir)
if err != nil {
log.Printf("skipping animation %s (%s): %v", key.ID, sourceFile, err)
continue
}
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)
}
}
sort.Slice(entries, func(i, j int) bool {
@@ -228,30 +294,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 +332,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 +388,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,10 +505,17 @@ 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{
@@ -335,21 +524,24 @@ func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sour
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 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 +567,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 +579,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,8 +603,14 @@ 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{
@@ -423,9 +619,32 @@ func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourc
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
@@ -460,40 +679,76 @@ 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}")
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>")
// One quick-filter button per group, in -data order.
groupNames := make(map[int]string)
for _, e := range entries {
groupNames[e.dirIndex] = e.group
}
groupIndexes := make([]int, 0, len(groupNames))
for i := range groupNames {
groupIndexes = append(groupIndexes, i)
}
sort.Ints(groupIndexes)
groupButtons := `<br><span id="group-filters">Groups:`
for _, i := range groupIndexes {
name := html.EscapeString(groupNames[i])
groupButtons += fmt.Sprintf(` <button type="button" class="group-filter" data-filter="%s">%s</button>`, name, name)
}
groupButtons += `</span>`
b.WriteString(`<div id="controls">` +
`<label>Model: <select id="model">` +
`<option>realesrgan-x4plus-anime</option>` +
`<option>realesrgan-x4plus</option>` +
`<option>realesr-animevideov3</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>` +
`<button id="clear-filter" type="button">Clear filter</button>` +
`<span id="shown-count"></span>` +
`<textarea id="mdl-output" rows="12" readonly placeholder="Select animations below to generate an MDL upscale definition here - copy it into modding/mods/&lt;name&gt;/upscale/&lt;label&gt;.json"></textarea>` +
groupButtons +
`<textarea id="mdl-output" rows="12" readonly placeholder="Select animations below to generate an MDL upscale definition here - copy it into modding/mods/&lt;name&gt;/bitmaps/&lt;label&gt;.json"></textarea>` +
`</div>`)
b.WriteString(`<h2>Selected (<span id="basket-count">0</span>)</h2><div id="basket"></div>`)
fmt.Fprintf(&b, "<h1>%d animated resource(s)</h1>", len(entries))
// Animation descriptors and multi-block bitmaps are genuinely distinct
// content types (different decode path, see contentTypeName) - grouped
// into separate sections rather than interleaved.
var animations, bitmaps []galleryEntry
// Cards are sectioned per -data layer (the base game, then each mod), and
// within one by content type: animation descriptors and multi-block
// bitmaps are genuinely distinct (different decode path, see
// contentTypeName).
type section struct {
group string
dirIndex int
animations, bitmaps []galleryEntry
}
var sections []*section
sectionByDir := make(map[int]*section)
for _, e := range entries {
sec := sectionByDir[e.dirIndex]
if sec == nil {
sec = &section{group: e.group, dirIndex: e.dirIndex}
sectionByDir[e.dirIndex] = sec
sections = append(sections, sec)
}
if contentTypeName(e.kind) == "Animation" {
animations = append(animations, e)
sec.animations = append(sec.animations, e)
} else {
bitmaps = append(bitmaps, e)
sec.bitmaps = append(sec.bitmaps, e)
}
}
sort.Slice(sections, func(i, j int) bool { return sections[i].dirIndex < sections[j].dirIndex })
writeCards := func(group []galleryEntry, idPrefix string) {
for i, e := range group {
@@ -503,11 +758,26 @@ func writeIndex(path string, entries []galleryEntry) error {
}
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,
e.lang.String(), e.kind, e.sourceFile, e.group,
fmt.Sprintf("%dx%d", e.width, e.height), transparency,
}, " "))
fmt.Fprintf(&b, "<div class=\"card\" data-search=\"%s\">", html.EscapeString(search))
if e.baseGifName != "" {
b.WriteString("<div class=\"compare\">")
b.WriteString("<div class=\"compare-col\"><div class=\"compare-label\">base</div>")
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.baseGifName), e.width*2, e.height*2)
fmt.Fprintf(&b, "<img class=\"frames\" src=\"gifs/%s\" title=\"every base frame, in order\">", html.EscapeString(e.baseFilmstripName))
b.WriteString("</div>")
b.WriteString("<div class=\"compare-col\"><div class=\"compare-label\">this build</div>")
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
fmt.Fprintf(&b, "<img class=\"frames\" src=\"gifs/%s\" title=\"every frame, in order\">", html.EscapeString(e.filmstripName))
b.WriteString("</div></div>")
} else {
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
if e.filmstripName != "" {
fmt.Fprintf(&b, "<img class=\"frames\" src=\"gifs/%s\" title=\"every frame, in order\">", html.EscapeString(e.filmstripName))
}
}
fmt.Fprintf(&b, "<div class=\"meta\"><label><input type=\"checkbox\" class=\"sel\" id=\"sel-%s-%d\" data-id=\"%s\"> ",
idPrefix, i, html.EscapeString(mdlResourceID(e.id, e.lang)))
fmt.Fprintf(&b, "ID 0x%X (%d) &middot; %s</label><br>%dx%d, %d frame(s), %s &middot; %s &middot; %s</div>",
@@ -516,15 +786,24 @@ func writeIndex(path string, entries []galleryEntry) error {
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")
for _, sec := range sections {
b.WriteString("<div class=\"group\">")
fmt.Fprintf(&b, "<h2>%s (%d)</h2>", html.EscapeString(sec.group), len(sec.animations)+len(sec.bitmaps))
if len(sec.animations) > 0 {
fmt.Fprintf(&b, "<h3>Animations (%d)</h3>", len(sec.animations))
writeCards(sec.animations, fmt.Sprintf("%da", sec.dirIndex))
}
if len(sec.bitmaps) > 0 {
fmt.Fprintf(&b, "<h3>Bitmaps (%d)</h3>", len(sec.bitmaps))
writeCards(sec.bitmaps, fmt.Sprintf("%db", sec.dirIndex))
}
b.WriteString("</div>")
}
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)
// in its original spot (its group's 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) {
@@ -584,9 +863,24 @@ function applyFilter() {
card.style.display = match ? '' : 'none';
if (match) shown++;
});
// A group with no visible card left hides its headings too.
Array.prototype.forEach.call(document.querySelectorAll('.group'), function (group) {
var visible = Array.prototype.some.call(group.querySelectorAll('.card'), function (card) {
return card.style.display !== 'none';
});
group.style.display = visible ? '' : 'none';
});
document.getElementById('shown-count').textContent = shown + ' / ' + cards.length + ' shown';
}
document.getElementById('search').addEventListener('input', applyFilter);
function setFilter(text) {
document.getElementById('search').value = text;
applyFilter();
}
document.getElementById('clear-filter').addEventListener('click', function () { setFilter(''); });
Array.prototype.forEach.call(document.querySelectorAll('.group-filter'), function (btn) {
btn.addEventListener('click', function () { setFilter(btn.dataset.filter); });
});
applyFilter();
</script>`)
+330 -43
View File
@@ -13,10 +13,13 @@ import (
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"math"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -46,12 +49,24 @@ var languageByName = map[string]resource.Language{
// upscaleDef is one modding/mods/<name>/bitmaps/<label>.json file: a shared
// set of upscale parameters and every resource to apply them to. Model
// is not consulted yet - mdlp-image bakes in a single ONNX model, applied
// regardless of this field's value.
// names a .onnx file baked into mdlp-image - see upscale_batch.py for the
// set currently available. ConsiderPreviousFrames, same id format as
// Resources, turns on stabilizeTemporalFrames (below) for just the
// resources listed, not the whole definition. An id here that isn't also in
// Resources is simply never looked up, not an error.
type upscaleDef struct {
Model string `json:"model"`
Scale int `json:"scale"`
Resources []string `json:"resources"`
ConsiderPreviousFrames []string `json:"considerPreviousFrames"`
}
// resourceLangKey identifies one resource (ignoring frame/block index) -
// what ConsiderPreviousFrames' entries are matched against, parsed through
// the same parseMdlResource as Resources itself.
type resourceLangKey struct {
id resource.ID
lang resource.Language
}
// parseMdlResource splits an animgallery-generated resource string
@@ -85,6 +100,7 @@ type resourceFrames struct {
lang resource.Language
contentType resource.ContentType
frames []*bitmap.Bitmap
considerPreviousFrames bool
}
func applyUpscaleDef(mod *world.Mod, pal bitmap.Palette, defFile string) error {
@@ -103,6 +119,15 @@ func applyUpscaleDef(mod *world.Mod, pal bitmap.Palette, defFile string) error {
def.Scale = 2
}
considerSet := make(map[resourceLangKey]bool, len(def.ConsiderPreviousFrames))
for _, label := range def.ConsiderPreviousFrames {
id, lang, err := parseMdlResource(label)
if err != nil {
return fmt.Errorf("considerPreviousFrames: %w", err)
}
considerSet[resourceLangKey{id, lang}] = true
}
all := make([]resourceFrames, 0, len(def.Resources))
for i, label := range def.Resources {
fmt.Printf(" decoding %d/%d: %s\n", i+1, len(def.Resources), label)
@@ -142,7 +167,10 @@ func applyUpscaleDef(mod *world.Mod, pal bitmap.Palette, defFile string) error {
if len(frames) == 0 {
return fmt.Errorf("%s: no frames decoded", label)
}
all = append(all, resourceFrames{label: label, id: id, lang: lang, contentType: contentType, frames: frames})
all = append(all, resourceFrames{
label: label, id: id, lang: lang, contentType: contentType, frames: frames,
considerPreviousFrames: considerSet[resourceLangKey{id, lang}],
})
}
upscaledByResource, err := upscaleBatch(all, pal, def.Model, def.Scale)
@@ -169,31 +197,25 @@ func applyUpscaleDef(mod *world.Mod, pal bitmap.Palette, defFile string) error {
}
// decodeViewFrames reads every block of a compound bitmap resource as a
// sequence of frames, each one RLE-diffed against the previous - mirrors
// animgallery's decodeFrames. Each block declares its own width/height, so
// the reference buffer handed to DecodeReferenced must match that block's
// own size, not blindly reuse the previous frame's.
// sequence of frames - mirrors animgallery's decodeFrames, including always
// handing DecodeReferenced a fresh zero buffer rather than the previous
// block's real pixels (see that function's own comment for why: the game's
// actual RSD8 decoder never carries a reference frame's pixels into a skip
// run, and neither does hacked's own multi-block Bitmap preview).
func decodeViewFrames(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
@@ -201,7 +223,8 @@ func decodeViewFrames(view resource.View) ([]*bitmap.Bitmap, error) {
// decodeAnimationFrames resolves an Animation descriptor's actual frame
// bitmaps, stored as separate resources at anim.ResourceID+N - mirrors
// animgallery's renderAnimationDescriptor.
// animgallery's renderAnimationDescriptor, including the same always-zero
// reference buffer (see decodeViewFrames' comment).
func decodeAnimationFrames(mod *world.Mod, lang resource.Language, anim bitmap.Animation) ([]*bitmap.Bitmap, error) {
lastFrame := 0
for _, entry := range anim.Entries {
@@ -210,7 +233,6 @@ func decodeAnimationFrames(mod *world.Mod, lang resource.Language, anim bitmap.A
}
}
var frames []*bitmap.Bitmap
var prevPixels []byte
for frameIndex := 0; frameIndex <= lastFrame; frameIndex++ {
frameView, err := mod.LocalizedResources(lang).Select(anim.ResourceID.Plus(frameIndex))
if err != nil {
@@ -221,17 +243,11 @@ func decodeAnimationFrames(mod *world.Mod, lang resource.Language, anim bitmap.A
return nil, fmt.Errorf("frame %d: %w", frameIndex, 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("frame %d: %w", frameIndex, err)
}
prevPixels = bmp.Pixels
frames = append(frames, bmp)
}
return frames, nil
@@ -282,11 +298,95 @@ func bitmapToImage(bmp *bitmap.Bitmap, pal bitmap.Palette) *image.RGBA {
// sprite renders at the right size but visibly offset from where it should
// sit. Writing {0,0} here (as if there were no anchor) makes the renderer
// fall back to a path that ignores that patch's size override entirely.
func clamp255(v float64) byte {
return byte(math.Max(0, math.Min(255, v)))
}
// isPureRampColor flags a palette entry from one of SS1's synthetic "pure
// color ramp" sections (used for UI/effect colors) rather than naturalistic
// sprite shading: either two of its three channels are exactly zero (a pure
// red/green/blue ramp), or one channel is exactly zero and the other two
// are exactly equal (a pure cyan/magenta/yellow ramp). A muted shading
// color, even a dark or desaturated one, never has two channels in an exact
// relationship like this - skin tones and dyed cloth have no reason to - so
// these entries are excluded from upscale re-quantization entirely rather
// than relying on a saturation/distance threshold to rule them out case by
// case: a target this dark reads as "saturated" under any max/min-based
// metric even when it's visually indistinguishable from a neutral dark
// gray, which is exactly what let these entries win a plain nearest-color
// search in the first place.
func isPureRampColor(c color.Color) bool {
r, g, b, _ := c.RGBA()
r, g, b = r>>8, g>>8, b>>8
zeros := 0
if r == 0 {
zeros++
}
if g == 0 {
zeros++
}
if b == 0 {
zeros++
}
if zeros >= 2 {
return true
}
return zeros == 1 && ((r == 0 && g == b) || (g == 0 && r == b) || (b == 0 && r == g))
}
// nearestSpriteColor finds the closest palette entry to target by plain
// squared-RGB distance, skipping Bitmapper's own reserved indices (outside
// 0x01-0x02, 0x08-0x0A, 0x20-0xFF) and the pure-ramp entries isPureRampColor
// flags. Squared-RGB rather than bitmap.Bitmapper's Lab-space search: close
// enough for re-quantizing an already-upscaled sprite, and avoids
// duplicating that package's unexported Lab-distance machinery just to add
// the ramp exclusion.
func nearestSpriteColor(palColors []color.Color, target color.RGBA) byte {
tr, tg, tb, _ := target.RGBA()
var best byte
bestDist := math.Inf(1)
for i, pc := range palColors {
isRegularColor := (i >= 0x01 && i <= 0x02) || (i >= 0x08 && i <= 0x0A) || i >= 0x20
if !isRegularColor || isPureRampColor(pc) {
continue
}
pr, pg, pb, _ := pc.RGBA()
dr := float64(tr>>8) - float64(pr>>8)
dg := float64(tg>>8) - float64(pg>>8)
db := float64(tb>>8) - float64(pb>>8)
if dist := dr*dr + dg*dg + db*db; dist < bestDist {
bestDist = dist
best = byte(i)
}
}
return best
}
func imageToBitmap(img *image.RGBA, pal bitmap.Palette, transparent bool, origAnchor bitmap.Area) *bitmap.Bitmap {
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
bitmapper := bitmap.NewBitmapper(&pal)
palColors := pal.ColorPalette(false)
pixels := make([]byte, w*h)
// Floyd-Steinberg error diffusion: a straight nearest-palette-color map
// (SS1's fixed 256-color palette, no per-sprite palette) can push an AI
// upscaler's smooth color gradients onto a visibly wrong palette entry
// wherever the true color sits in a gap between two distant entries -
// diffusing each pixel's quantization error into its unprocessed
// neighbors spreads that mismatch into unnoticeable dithering noise
// instead of a solid wrong-colored patch.
errR := make([]float64, w*h)
errG := make([]float64, w*h)
errB := make([]float64, w*h)
addErr := func(x, y int, er, eg, eb, weight float64) {
if x < 0 || x >= w || y < 0 || y >= h {
return
}
i := y*w + x
errR[i] += er * weight
errG[i] += eg * weight
errB[i] += eb * weight
}
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
c := img.RGBAAt(bounds.Min.X+x, bounds.Min.Y+y)
@@ -294,7 +394,32 @@ func imageToBitmap(img *image.RGBA, pal bitmap.Palette, transparent bool, origAn
pixels[y*w+x] = 0
continue
}
pixels[y*w+x] = bitmapper.MapColor(color.RGBA{R: c.R, G: c.G, B: c.B, A: 0xff})
i := y*w + x
r, g, b := float64(c.R)+errR[i], float64(c.G)+errG[i], float64(c.B)+errB[i]
idx := nearestSpriteColor(palColors, color.RGBA{R: clamp255(r), G: clamp255(g), B: clamp255(b), A: 0xff})
pixels[i] = idx
pr, pg, pb, _ := palColors[idx].RGBA()
er := r - float64(pr>>8)
eg := g - float64(pg>>8)
eb := b - float64(pb>>8)
// Skip diffusion into transparent neighbors so dithering noise
// never bleeds past the sprite's own silhouette.
rightOK := !(transparent && x+1 < w && img.RGBAAt(bounds.Min.X+x+1, bounds.Min.Y+y).A < 0x80)
belowOK := y+1 < h && !(transparent && img.RGBAAt(bounds.Min.X+x, bounds.Min.Y+y+1).A < 0x80)
belowLeftOK := y+1 < h && x > 0 && !(transparent && img.RGBAAt(bounds.Min.X+x-1, bounds.Min.Y+y+1).A < 0x80)
belowRightOK := y+1 < h && x+1 < w && !(transparent && img.RGBAAt(bounds.Min.X+x+1, bounds.Min.Y+y+1).A < 0x80)
if rightOK {
addErr(x+1, y, er, eg, eb, 7.0/16.0)
}
if belowLeftOK {
addErr(x-1, y+1, er, eg, eb, 3.0/16.0)
}
if belowOK {
addErr(x, y+1, er, eg, eb, 5.0/16.0)
}
if belowRightOK {
addErr(x+1, y+1, er, eg, eb, 1.0/16.0)
}
}
}
@@ -325,6 +450,142 @@ func imageToBitmap(img *image.RGBA, pal bitmap.Palette, transparent bool, origAn
}
}
// temporalStabilizationMagnitude is the per-pixel RGB delta (0-255) above
// which stabilizeTemporalFrames treats a frame's excess deviation from the
// rest of its animation as worth correcting - see that function's own
// comment for what "excess" means here.
const temporalStabilizationMagnitude = 40.0
// canvasSize returns the pixel dimensions, at the given upscale factor,
// every frame of a resource should be treated as occupying for temporal
// comparison: the largest individual frame's own native size. Frames can be
// individually smaller than that (a storage-level crop against unchanged
// pixels, not a smaller sprite - see animgallery's writeGIF, which hit the
// same issue), so padToCanvas places each one at its top-left corner of
// this shared canvas before stabilizeTemporalFrames compares them pixel for
// pixel.
func canvasSize(frames []*bitmap.Bitmap, scale int) (int, int) {
w, h := 0, 0
for _, f := range frames {
if int(f.Header.Width) > w {
w = int(f.Header.Width)
}
if int(f.Header.Height) > h {
h = int(f.Header.Height)
}
}
return w * scale, h * scale
}
// padToCanvas returns img unchanged if it is already canvas-sized, or a new
// canvasW x canvasH image with img placed at its top-left corner (and
// everything else transparent) otherwise.
func padToCanvas(img *image.RGBA, canvasW, canvasH int) *image.RGBA {
b := img.Bounds()
if b.Dx() == canvasW && b.Dy() == canvasH {
return img
}
canvas := image.NewRGBA(image.Rect(0, 0, canvasW, canvasH))
draw.Draw(canvas, b, img, b.Min, draw.Src)
return canvas
}
// resizeNearest resizes src to w x h with nearest-neighbor sampling, so a
// real source pixel is compared against itself in stabilizeTemporalFrames
// rather than a resize filter's blend of its neighbors.
func resizeNearest(src *image.RGBA, w, h int) *image.RGBA {
sb := src.Bounds()
sw, sh := sb.Dx(), sb.Dy()
dst := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
sy := sb.Min.Y + y*sh/h
for x := 0; x < w; x++ {
sx := sb.Min.X + x*sw/w
dst.Set(x, y, src.At(sx, sy))
}
}
return dst
}
// stabilizeTemporalFrames mitigates per-frame upscale hallucination:
// 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. This compares
// each frame's upscaled RGB value 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 corrects it where it does.
//
// The comparison is against the median across all frames, not just a
// frame's immediate predecessor: comparing only against the predecessor can
// pull a consistent frame toward an actual outlier whenever the outlier
// happens to come first in the sequence. The median is robust to that
// regardless of where the outlier frame falls in the loop.
//
// Needs at least 3 frames for a meaningful consensus (two frames have no
// independent third reference to vote with), so fewer than that are left
// untouched. Alpha is never touched, and a frame's own near-transparent
// pixel is never corrected (it re-quantizes to the background index
// regardless of its RGB value - see imageToBitmap's own 0x80 threshold).
func stabilizeTemporalFrames(upscaled, sourceResized []*image.RGBA) {
n := len(upscaled)
if n < 3 {
return
}
w, h := upscaled[0].Bounds().Dx(), upscaled[0].Bounds().Dy()
median := func(vals []float64) float64 {
sorted := append([]float64(nil), vals...)
sort.Float64s(sorted)
mid := len(sorted) / 2
if len(sorted)%2 == 0 {
return (sorted[mid-1] + sorted[mid]) / 2
}
return sorted[mid]
}
outR, outG, outB := make([]float64, n), make([]float64, n), make([]float64, n)
srcR, srcG, srcB := make([]float64, n), make([]float64, n), make([]float64, n)
corrected := make([][]byte, n)
for i := range upscaled {
corrected[i] = append([]byte(nil), upscaled[i].Pix...)
}
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
for i := 0; i < n; i++ {
ur, ug, ub, _ := upscaled[i].At(x, y).RGBA()
outR[i], outG[i], outB[i] = float64(ur>>8), float64(ug>>8), float64(ub>>8)
sr, sg, sb, _ := sourceResized[i].At(x, y).RGBA()
srcR[i], srcG[i], srcB[i] = float64(sr>>8), float64(sg>>8), float64(sb>>8)
}
medR, medG, medB := median(outR), median(outG), median(outB)
srcMedR, srcMedG, srcMedB := median(srcR), median(srcG), median(srcB)
for i := 0; i < n; i++ {
_, _, _, ua := upscaled[i].At(x, y).RGBA()
if ua>>8 < 0x80 {
continue
}
outDev := (math.Abs(outR[i]-medR) + math.Abs(outG[i]-medG) + math.Abs(outB[i]-medB)) / 3
srcDev := (math.Abs(srcR[i]-srcMedR) + math.Abs(srcG[i]-srcMedG) + math.Abs(srcB[i]-srcMedB)) / 3
if excess := outDev - srcDev; excess > temporalStabilizationMagnitude {
off := upscaled[i].PixOffset(x, y)
corrected[i][off+0] = clamp255(medR)
corrected[i][off+1] = clamp255(medG)
corrected[i][off+2] = clamp255(medB)
}
}
}
}
for i := range upscaled {
copy(upscaled[i].Pix, corrected[i])
}
}
// upscaleBatch runs every frame from every given resource through exactly
// one upscale_batch.py invocation (its own directory-in/directory-out batch
// mode) and returns each resource's upscaled frames in the same order as
@@ -370,13 +631,10 @@ func upscaleBatch(all []resourceFrames, pal bitmap.Palette, model string, scale
fmt.Printf(" upscaling %d resource(s), %d frame(s) total (model %s, scale %d):\n",
len(all), totalFrames, model, scale)
// UPSCALE_MODEL_PATH (set by mdlp-image's Dockerfile) points at the ONNX
// model baked into the image.
modelPath := os.Getenv("UPSCALE_MODEL_PATH")
if modelPath == "" {
return nil, fmt.Errorf("UPSCALE_MODEL_PATH is not set - see mdlp-image/Dockerfile")
}
cmd := exec.Command("python3", "/usr/local/bin/upscale_batch.py", modelPath, inDir, outDir, strconv.Itoa(scale))
// upscale_batch.py resolves model (this def's own "model" field) against
// $UPSCALE_MODELS_DIR (set by mdlp-image's Dockerfile) itself, and fails
// with the available model names if it doesn't recognize this one.
cmd := exec.Command("python3", "/usr/local/bin/upscale_batch.py", model, inDir, outDir, strconv.Itoa(scale))
// Streamed live, not buffered via CombinedOutput: a single directory
// batch can run for a while, and silence until the whole thing exits
// gives no sense that it's still making progress.
@@ -388,8 +646,8 @@ func upscaleBatch(all []resourceFrames, pal bitmap.Palette, model string, scale
result := make([][]*bitmap.Bitmap, len(all))
for ri, rf := range all {
upscaled := make([]*bitmap.Bitmap, len(rf.frames))
for fi, frame := range rf.frames {
rgbaFrames := make([]*image.RGBA, len(rf.frames))
for fi := range rf.frames {
outPath := filepath.Join(outDir, frameName(ri, fi))
outFile, err := os.Open(outPath)
if err != nil {
@@ -403,9 +661,29 @@ func upscaleBatch(all []resourceFrames, pal bitmap.Palette, model string, scale
if closeErr != nil {
return nil, closeErr
}
rgba := toRGBA(upscaledImg)
rgbaFrames[fi] = toRGBA(upscaledImg)
}
if rf.considerPreviousFrames {
canvasW, canvasH := canvasSize(rf.frames, scale)
canvasFrames := make([]*image.RGBA, len(rgbaFrames))
sourceResized := make([]*image.RGBA, len(rf.frames))
for fi, frame := range rf.frames {
canvasFrames[fi] = padToCanvas(rgbaFrames[fi], canvasW, canvasH)
resized := resizeNearest(bitmapToImage(frame, pal), int(frame.Header.Width)*scale, int(frame.Header.Height)*scale)
sourceResized[fi] = padToCanvas(resized, canvasW, canvasH)
}
stabilizeTemporalFrames(canvasFrames, sourceResized)
for fi := range rgbaFrames {
b := rgbaFrames[fi].Bounds()
draw.Draw(rgbaFrames[fi], b, canvasFrames[fi], b.Min, draw.Src)
}
}
upscaled := make([]*bitmap.Bitmap, len(rf.frames))
for fi, frame := range rf.frames {
transparent := frame.Header.Flags&bitmap.FlagTransparent != 0
upscaled[fi] = imageToBitmap(rgba, pal, transparent, frame.Header.Area)
upscaled[fi] = imageToBitmap(rgbaFrames[fi], pal, transparent, frame.Header.Area)
}
result[ri] = upscaled
}
@@ -426,17 +704,26 @@ func toRGBA(img image.Image) *image.RGBA {
return converted
}
// encodeFrames RLE-diffs each frame against the previous, matching the
// convention hacked's own editor/animations/View.go uses when importing a
// GIF (the first frame diffs against an implicit all-zero reference).
// encodeFrames RLE-compresses each frame against an all-zero reference (the
// "still image" convention - see ss-specs media/Bitmaps.md). The RLE format
// also grammatically allows a "skip" run to mean "copy from a reference
// image" rather than "write zero" (the "video clip" convention, which
// hacked's own GIF-import path uses) - but the game's actual RSD8 decoder
// (engine/src/Libraries/2D/Source/RSD/{rsdcvt.c,RSDUnpack.c}, confirmed
// against Night Dive's published original source down to the hand-written
// 68K assembly) only ever implements the zero-reference case, for every
// resource type checked including critter sprites: it writes literal zero
// for every skip run unconditionally, with no mechanism to carry a
// reference image's pixels forward at all. Compressing against the real
// previous frame here produced RLE streams only hacked's own reader could
// decode correctly - the game rendered scattered noise wherever a frame
// carried content unchanged from the one before it.
func encodeFrames(frames []*bitmap.Bitmap) [][]byte {
blocks := make([][]byte, 0, len(frames))
var prevPixels []byte
for _, bmp := range frames {
buf := new(bytes.Buffer)
_ = binary.Write(buf, binary.LittleEndian, &bmp.Header)
_ = rle.Compress(buf, bmp.Pixels, prevPixels)
prevPixels = bmp.Pixels
_ = rle.Compress(buf, bmp.Pixels, nil)
blocks = append(blocks, buf.Bytes())
}
return blocks
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Converts every raw frame dump written by the debug dump hotkey (press
# K in-game - see root README.md's "Debugging" section) into a viewable
# PNG under dumps-png/, using dump2png (only present in a `make dist_debug`
# build, alongside this script).
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
if [ ! -x ./dump2png ]; then
echo "dump2png not found next to this script - this needs a debug build" >&2
echo "(see root README.md's \"Debugging\" section: make dist_debug)" >&2
exit 1
fi
if [ ! -d dumps ]; then
echo "No dumps/ directory here yet - press K in-game first to write some frame dumps" >&2
exit 1
fi
./dump2png res/data dumps dumps-png
+4 -1
View File
@@ -4,4 +4,7 @@
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
export LD_LIBRARY_PATH="$(pwd)/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
exec ./systemshock "$@"
# Not `exec`: piping through tee needs this script's own process to stay
# around as the pipeline. `pipefail` (set above) still makes the script
# exit with systemshock's own exit code, not tee's.
./systemshock "$@" 2>&1 | tee "$(pwd)/systemshock.log"
Binary file not shown.
+5 -1
View File
@@ -43,13 +43,17 @@ TTY_FLAGS="-i"
# build-image/prepare-android-project.sh --host-paths) write paths that
# resolve on the HOST filesystem instead of this container's own
# /workspace - needed so a gradlew/Android Studio running natively on the
# host afterwards can find them.
# host afterwards can find them. ENGINE_DEBUG_TOOLS: forwarded so the
# Makefile's dist_debug target can tell build-engine.sh to also apply
# engine-debug-tools/engine-patches/ (see root README.md's "Debugging"
# section) - empty/unset for every normal build.
# shellcheck disable=SC2086
docker run --rm $TTY_FLAGS \
-v "$ROOT:/workspace" \
-e HOST_UID="$(id -u)" \
-e HOST_GID="$(id -g)" \
-e HOST_REPO_ROOT="$ROOT" \
-e ENGINE_DEBUG_TOOLS="${ENGINE_DEBUG_TOOLS:-}" \
"$IMAGE" \
"$@"