Add engine-debug-tools and fix critter animation rendering bugs
build / build (push) Successful in 2m52s
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:
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 =="
|
||||
|
||||
Executable
+65
@@ -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"
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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.
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/<name>/upscale/<label>.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/<name>/bitmaps/<label>.json"></textarea>` +
|
||||
`</div>`)
|
||||
|
||||
b.WriteString(`<h2>Selected (<span id="basket-count">0</span>)</h2><div id="basket"></div>`)
|
||||
|
||||
+133
-17
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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" \
|
||||
"$@"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user