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.
This commit is contained in:
ml
2026-10-18 06:59:47 +02:00
parent ab2b778f44
commit 9d0a0d5798
21 changed files with 1185 additions and 82 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
+105 -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()`
@@ -379,6 +387,95 @@ which the desktop build does not apply.
the sprite from where it should sit, since it shares its coordinate
system with this patch's own size override rather than with the
bitmap's real pixel dimensions.
- **`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. Currently one patch, adding 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.
- `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
+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));
+ if (f == NULL)
+ prt = (RefTable *)ResLock(id);
+ if (prt == NULL)
+ return;
+ s_critterNativeW[id] = f->bm.w;
+ s_critterNativeH[id] = f->bm.h;
+ RefUnlock(MKREF(id, 0));
+ 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)
+ 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;
@@ -208,8 +231,8 @@
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
--- 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);
+102
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,26 @@ 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 |
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 +303,83 @@ 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.
`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.
`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:
+2 -2
View File
@@ -470,13 +470,13 @@ func writeIndex(path string, entries []galleryEntry) error {
`<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>` +
`<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>` +
`<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>`)
+133 -17
View File
@@ -14,6 +14,7 @@ import (
"image"
"image/color"
"image/png"
"math"
"os"
"os/exec"
"path/filepath"
@@ -46,8 +47,8 @@ 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.
type upscaleDef struct {
Model string `json:"model"`
Scale int `json:"scale"`
@@ -282,11 +283,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 +379,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)
}
}
}
@@ -370,13 +480,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.
@@ -426,17 +533,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" \
"$@"