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/README.md)
|
||||||
/modding/dist/
|
/modding/dist/
|
||||||
/modding/build/
|
/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
|
BUILD_DIR := build
|
||||||
ARCH := $(shell uname -m)
|
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
|
package package-linux package-win apk android-studio clean
|
||||||
|
|
||||||
all: dist
|
all: dist
|
||||||
@@ -87,6 +87,22 @@ dist-linux: engine assets
|
|||||||
chmod +x "$(DIST_DIR)/run-mods.sh"
|
chmod +x "$(DIST_DIR)/run-mods.sh"
|
||||||
@echo "== Done - run $(DIST_DIR)/run.sh to play =="
|
@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
|
# Windows counterpart of dist-linux. Unlike dist/, the DLLs sit flat
|
||||||
# alongside systemshock.exe instead of in a lib/ subdirectory - Windows'
|
# alongside systemshock.exe instead of in a lib/ subdirectory - Windows'
|
||||||
# default DLL search order already checks the executable's own directory
|
# 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
|
mod that replaces a critter's art with a higher-resolution bitmap for
|
||||||
more detail made the critter itself render bigger, proportionally to
|
more detail made the critter itself render bigger, proportionally to
|
||||||
the resolution increase. Fixed by caching every critter view's base-game
|
the resolution increase. Fixed by caching every critter view's base-game
|
||||||
bitmap size (`objsim.c`'s `cache_critter_native_size()`/
|
bitmap size per animation frame (`objsim.c`'s
|
||||||
`get_critter_native_size()`, keyed by resource ID) while
|
`cache_critter_native_size()`/`get_critter_native_size()`, keyed by
|
||||||
`objart2.res`/`objart3.res`'s base-game data is still the active
|
resource ID and frame index - an animation's bounding box commonly
|
||||||
resolver in `obj_set_secondary_properties()` (before patch 03's
|
changes size between poses, so a single per-view size doesn't fit every
|
||||||
`LoadModFiles()` call hands priority to a mod), then using that cached
|
frame of it) while `objart2.res`/`objart3.res`'s base-game data is still
|
||||||
size instead of the bitmap's own (possibly modded) dimensions when
|
the active resolver in `obj_set_secondary_properties()` (before patch
|
||||||
computing the sprite's on-screen extent - threaded through
|
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`/
|
`_fr_draw_bitmap()` and `g3_light_anchor_bitmap()`'s new `native_w`/
|
||||||
`native_h` parameters, consumed and reset by `g3_bitmap_common()` so an
|
`native_h` parameters, consumed and reset by `g3_bitmap_common()` so an
|
||||||
unrelated bitmap draw never inherits a stale override. A mod's higher
|
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
|
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
|
`Header.Area`, in the original, un-upscaled unit space (see
|
||||||
`modding/src/mdlp/upscale.go`'s `imageToBitmap()`) - `_fr_draw_bitmap()`
|
`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
|
the sprite from where it should sit, since it shares its coordinate
|
||||||
system with this patch's own size override rather than with the
|
system with this patch's own size override rather than with the
|
||||||
bitmap's real pixel dimensions.
|
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
|
## 5. Android / Quest build
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ for p in "$REPO_ROOT"/engine-patches/*.patch; do
|
|||||||
patch -p1 -d "$SCRATCH_ENGINE" < "$p"
|
patch -p1 -d "$SCRATCH_ENGINE" < "$p"
|
||||||
done
|
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"
|
cd "$SCRATCH_ENGINE"
|
||||||
|
|
||||||
echo "== Wiring up prebuilt SDL2/SDL2_mixer/fluidsynth-lite from the image =="
|
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
|
--- a/src/GameSrc/objsim.c
|
||||||
+++ b/src/GameSrc/objsim.c 2026-09-27 20:44:23.147083066 +0200
|
+++ b/src/GameSrc/objsim.c
|
||||||
@@ -1867,6 +1867,40 @@
|
@@ -1867,6 +1867,63 @@
|
||||||
return (OK);
|
return (OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
+// Every critter view's base-game bitmap size, keyed by resource ID - filled
|
+// Every critter view's base-game bitmap size, keyed by resource ID and
|
||||||
+// in by cache_critter_native_size() below while objart2.res/objart3.res's
|
+// 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
|
+// base-game data is still the active resolver for these IDs (before
|
||||||
+// LoadModFiles() hands priority to a mod again), read back by
|
+// LoadModFiles() hands priority to a mod again), read back by
|
||||||
+// get_critter_native_size() in gameobj.c. A mod that replaces a critter's
|
+// 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
|
+// 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)
|
+// cached size instead of the (possibly modded, possibly larger or smaller)
|
||||||
+// bitmap's own width/height when computing the sprite's world-space extent.
|
+// 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.
|
+// Id is uint16_t, so a flat array indexed by it needs no bounds check.
|
||||||
+static short s_critterNativeW[65536];
|
+#define MAX_CRITTER_FRAMES 32
|
||||||
+static short s_critterNativeH[65536];
|
+static short s_critterNativeW[65536][MAX_CRITTER_FRAMES];
|
||||||
|
+static short s_critterNativeH[65536][MAX_CRITTER_FRAMES];
|
||||||
+
|
+
|
||||||
+static void cache_critter_native_size(Id id) {
|
+static void cache_critter_native_size(Id id) {
|
||||||
|
+ RefTable *prt;
|
||||||
+ FrameDesc *f;
|
+ FrameDesc *f;
|
||||||
+ if (s_critterNativeW[id] != 0)
|
+ int numFrames, frame;
|
||||||
|
+ if (s_critterNativeW[id][0] != 0)
|
||||||
+ return;
|
+ return;
|
||||||
+ f = (FrameDesc *)RefLock(MKREF(id, 0));
|
+ prt = (RefTable *)ResLock(id);
|
||||||
+ if (f == NULL)
|
+ if (prt == NULL)
|
||||||
+ return;
|
+ return;
|
||||||
+ s_critterNativeW[id] = f->bm.w;
|
+ numFrames = prt->numRefs;
|
||||||
+ s_critterNativeH[id] = f->bm.h;
|
+ ResUnlock(id);
|
||||||
+ RefUnlock(MKREF(id, 0));
|
+ 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
|
+// 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.
|
+// native size, use the bitmap's own dimensions") if id/frame was never
|
||||||
+void get_critter_native_size(Id id, short *w, short *h) {
|
+// cached.
|
||||||
+ if (s_critterNativeW[id] == 0)
|
+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;
|
+ return;
|
||||||
+ *w = s_critterNativeW[id];
|
+ *w = s_critterNativeW[id][frame];
|
||||||
+ *h = s_critterNativeH[id];
|
+ *h = s_critterNativeH[id][frame];
|
||||||
+}
|
+}
|
||||||
+
|
+
|
||||||
errtype obj_set_secondary_properties() {
|
errtype obj_set_secondary_properties() {
|
||||||
char i, j;
|
char i, j;
|
||||||
RefTable *prt;
|
RefTable *prt;
|
||||||
@@ -1878,6 +1912,28 @@
|
@@ -1878,6 +1935,28 @@
|
||||||
// Warning(("Problem opening object art cache!\n"));
|
// Warning(("Problem opening object art cache!\n"));
|
||||||
return (ERR_NOEFFECT);
|
return (ERR_NOEFFECT);
|
||||||
}
|
}
|
||||||
@@ -70,18 +93,18 @@
|
|||||||
// The two opens above re-register every resource ID objart2.res/
|
// The two opens above re-register every resource ID objart2.res/
|
||||||
// objart3.res contain, reclaiming any of them a mod file already
|
// objart3.res contain, reclaiming any of them a mod file already
|
||||||
// overrode (see LoadModFiles() in Modding.c) - restore mod priority
|
// overrode (see LoadModFiles() in Modding.c) - restore mod priority
|
||||||
--- a/src/GameSrc/Headers/objsim.h 2026-07-19 13:11:51.000000000 +0200
|
--- a/src/GameSrc/Headers/objsim.h
|
||||||
+++ b/src/GameSrc/Headers/objsim.h 2026-09-27 19:39:39.585009491 +0200
|
+++ b/src/GameSrc/Headers/objsim.h
|
||||||
@@ -108,6 +108,7 @@
|
@@ -108,6 +108,7 @@
|
||||||
errtype obj_load_properties();
|
errtype obj_load_properties();
|
||||||
errtype obj_create_player(ObjLoc *plr_loc);
|
errtype obj_create_player(ObjLoc *plr_loc);
|
||||||
Ref ref_from_critter_data(ObjID oid, int triple, byte posture, short frame, short view);
|
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();
|
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);
|
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);
|
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
|
--- a/src/GameSrc/gameobj.c
|
||||||
+++ b/src/GameSrc/gameobj.c 2026-09-27 20:44:35.195019539 +0200
|
+++ b/src/GameSrc/gameobj.c
|
||||||
@@ -104,7 +104,7 @@
|
@@ -104,7 +104,7 @@
|
||||||
void draw_ice(void);
|
void draw_ice(void);
|
||||||
void draw_ice_wall(void);
|
void draw_ice_wall(void);
|
||||||
@@ -134,7 +157,7 @@
|
|||||||
- _fr_draw_bitmap(tpdata, _fdt_dist, 0, anch.ul.x, anch.ul.y);
|
- _fr_draw_bitmap(tpdata, _fdt_dist, 0, anch.ul.x, anch.ul.y);
|
||||||
+ {
|
+ {
|
||||||
+ short native_w = 0, native_h = 0;
|
+ 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);
|
+ _fr_draw_bitmap(tpdata, _fdt_dist, 0, anch.ul.x, anch.ul.y, native_w, native_h);
|
||||||
+ }
|
+ }
|
||||||
release_critter_bitmap_fast(ref);
|
release_critter_bitmap_fast(ref);
|
||||||
@@ -145,8 +168,8 @@
|
|||||||
}
|
}
|
||||||
} break;
|
} break;
|
||||||
|
|
||||||
--- a/src/Libraries/3D/Source/Bitmap.c 2026-07-19 13:11:51.000000000 +0200
|
--- a/src/Libraries/3D/Source/Bitmap.c
|
||||||
+++ b/src/Libraries/3D/Source/Bitmap.c 2026-09-27 20:44:47.378954871 +0200
|
+++ b/src/Libraries/3D/Source/Bitmap.c
|
||||||
@@ -88,6 +88,14 @@
|
@@ -88,6 +88,14 @@
|
||||||
// fix _g3d_bitmap_y_iscale = 0x010000;
|
// fix _g3d_bitmap_y_iscale = 0x010000;
|
||||||
long _g3d_bitmap_u_anchor = 0;
|
long _g3d_bitmap_u_anchor = 0;
|
||||||
@@ -208,8 +231,8 @@
|
|||||||
|
|
||||||
tempL = p->gZ; // mov eax,[edi].z
|
tempL = p->gZ; // mov eax,[edi].z
|
||||||
tempL <<= 8; // sal eax,8 ;should be
|
tempL <<= 8; // sal eax,8 ;should be
|
||||||
--- a/src/Libraries/3D/Source/3d.h 2026-07-19 13:11:51.000000000 +0200
|
--- a/src/Libraries/3D/Source/3d.h
|
||||||
+++ b/src/Libraries/3D/Source/3d.h 2026-09-27 19:42:26.528424908 +0200
|
+++ b/src/Libraries/3D/Source/3d.h
|
||||||
@@ -720,7 +720,8 @@
|
@@ -720,7 +720,8 @@
|
||||||
grs_vertex **g3_light_bitmap(grs_bitmap *bm, g3s_phandle p);
|
grs_vertex **g3_light_bitmap(grs_bitmap *bm, g3s_phandle p);
|
||||||
// MLA #pragma aux g3_light_bitmap "*" parm [esi] [edi] modify [eax ebx ecx
|
// 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/
|
messages/
|
||||||
<label>.json # one message's overrides
|
<label>.json # one message's overrides
|
||||||
<label>.txt # optional: a text field's body, referenced by ...File
|
<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
|
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,
|
`mods/mymod/` is a worked example matching section 3's Rebecca's-mail edit,
|
||||||
expressed as MDL instead of a `hacked` session.
|
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`
|
### 4.2. `mdlp`
|
||||||
|
|
||||||
`src/mdlp/` is first-party Go source (not vendored - edit it directly, no
|
`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
|
`mdlp-image/VERSION` just because the Dockerfile changed - only once a build
|
||||||
with the current version has actually succeeded, same policy as the root
|
with the current version has actually succeeded, same policy as the root
|
||||||
project's own `build-image/VERSION`.
|
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 \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
ca-certificates curl gosu patch python3 python3-pip \
|
ca-certificates curl gosu patch python3 python3-pip \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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
|
# The AI upscaler models for mdlp's upscale mod type (see ../README.md
|
||||||
# section 4): RealESRGAN_x4plus_anime_6B, a 6-block RRDBNet ONNX-exported
|
# section 4), selected per mod by a bitmaps/*.json file's own "model" field
|
||||||
# from xinntao/Real-ESRGAN's anime-illustration checkpoint (BSD-3-Clause).
|
# (upscaleDef in ../src/mdlp/upscale.go) - upscale_batch.py resolves a name
|
||||||
# It's trained on flat-color line art, so it keeps hard sprite edges rather
|
# straight to $UPSCALE_MODELS_DIR/<name>.onnx, and picks tiled or single-pass
|
||||||
# than smoothing them the way a photo- or video-denoising model would. A
|
# inference based on each model's own declared ONNX input shape (fixed vs
|
||||||
# mod's own "model" field (upscaleDef in ../src/mdlp/upscale.go) is not
|
# dynamic height/width), so adding another model here needs no code change.
|
||||||
# consulted; every resource goes through this one model. upscale_batch.py
|
# Each model's RGB output is re-quantized to SS1's fixed 256-color palette
|
||||||
# resizes its fixed native 4x output down to whatever scale a mod actually
|
# with Floyd-Steinberg dithering (imageToBitmap in ../src/mdlp/upscale.go),
|
||||||
# asks for.
|
# not a plain nearest-color map - without it, any model's smooth gradients
|
||||||
ARG UPSCALE_MODEL_URL=https://huggingface.co/skillsafe-ai/realesrgan-x4plus-anime-6b/resolve/main/model.onnx
|
# can land on a visibly wrong, much darker palette entry in a solid patch
|
||||||
RUN mkdir -p /opt/upscale-model \
|
# rather than unnoticeable dithering noise.
|
||||||
&& curl -sSL -o /opt/upscale-model/model.onnx "$UPSCALE_MODEL_URL"
|
# 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
|
COPY mdlp-image/upscale_batch.py /usr/local/bin/upscale_batch.py
|
||||||
RUN chmod 755 /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" \
|
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" \
|
&& 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
|
Vulkan software emulation (Mesa lavapipe) gives wrong colors for ML
|
||||||
inference workloads.
|
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 sys
|
||||||
import os
|
import os
|
||||||
@@ -16,31 +22,118 @@ import os
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import onnxruntime as ort
|
import onnxruntime as ort
|
||||||
from PIL import Image
|
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():
|
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)
|
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"])
|
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"))
|
names = sorted(n for n in os.listdir(in_dir) if n.lower().endswith(".png"))
|
||||||
for name in names:
|
for name in names:
|
||||||
img = Image.open(os.path.join(in_dir, name)).convert("RGBA")
|
img = Image.open(os.path.join(in_dir, name)).convert("RGBA")
|
||||||
rgb = img.convert("RGB")
|
rgb = img.convert("RGB")
|
||||||
alpha = img.split()[3]
|
alpha = img.split()[3]
|
||||||
|
rgb = extend_opaque_color(rgb, alpha)
|
||||||
|
|
||||||
# The model has no concept of transparency (3-channel RGB in/out) -
|
# The model has no concept of transparency (3-channel RGB in/out) -
|
||||||
# run it on the flattened RGB only, and scale the alpha mask
|
# run it on the flattened RGB only, and scale the alpha mask
|
||||||
# separately with a plain resize. Alpha edges don't need AI
|
# separately with a plain resize. Alpha edges don't need AI
|
||||||
# enhancement, just to end up the right size.
|
# enhancement, just to end up the right size.
|
||||||
arr = np.asarray(rgb, dtype=np.float32) / 255.0
|
if fixed_size is not None and (img.width > fixed_size or img.height > fixed_size):
|
||||||
arr = np.transpose(arr, (2, 0, 1))[np.newaxis, ...]
|
upscaled_rgb = upscale_tiled(session, input_name, rgb, fixed_size, native_scale)
|
||||||
output = session.run(None, {input_name: arr})[0]
|
elif fixed_size is not None:
|
||||||
output = np.clip(output[0], 0.0, 1.0) * 255.0
|
padded = np.pad(np.asarray(rgb), ((0, fixed_size - img.height), (0, fixed_size - img.width), (0, 0)), mode="edge")
|
||||||
output = np.transpose(output, (1, 2, 0)).astype(np.uint8)
|
out = run_tile(session, input_name, padded)
|
||||||
upscaled_rgb = Image.fromarray(output, "RGB")
|
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)
|
target_size = (img.width * scale, img.height * scale)
|
||||||
if upscaled_rgb.size != target_size:
|
if upscaled_rgb.size != target_size:
|
||||||
|
|||||||
@@ -470,13 +470,13 @@ func writeIndex(path string, entries []galleryEntry) error {
|
|||||||
`<label>Model: <select id="model">` +
|
`<label>Model: <select id="model">` +
|
||||||
`<option>realesrgan-x4plus-anime</option>` +
|
`<option>realesrgan-x4plus-anime</option>` +
|
||||||
`<option>realesrgan-x4plus</option>` +
|
`<option>realesrgan-x4plus</option>` +
|
||||||
`<option>realesr-animevideov3</option>` +
|
`<option>realesr-general-x4v3</option>` +
|
||||||
`</select></label>` +
|
`</select></label>` +
|
||||||
`<label>Scale: <input id="scale" type="number" value="2" min="1" max="4" style="width:4em"></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>` +
|
`<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>` +
|
`<br><label>Filter: <input type="text" id="search" placeholder="id, language, file, transparent..." style="width:22em"></label>` +
|
||||||
`<span id="shown-count"></span>` +
|
`<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>`)
|
`</div>`)
|
||||||
|
|
||||||
b.WriteString(`<h2>Selected (<span id="basket-count">0</span>)</h2><div id="basket"></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"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/png"
|
"image/png"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -46,8 +47,8 @@ var languageByName = map[string]resource.Language{
|
|||||||
|
|
||||||
// upscaleDef is one modding/mods/<name>/bitmaps/<label>.json file: a shared
|
// upscaleDef is one modding/mods/<name>/bitmaps/<label>.json file: a shared
|
||||||
// set of upscale parameters and every resource to apply them to. Model
|
// 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
|
// names a .onnx file baked into mdlp-image - see upscale_batch.py for the
|
||||||
// regardless of this field's value.
|
// set currently available.
|
||||||
type upscaleDef struct {
|
type upscaleDef struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Scale int `json:"scale"`
|
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
|
// 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
|
// 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.
|
// 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 {
|
func imageToBitmap(img *image.RGBA, pal bitmap.Palette, transparent bool, origAnchor bitmap.Area) *bitmap.Bitmap {
|
||||||
bounds := img.Bounds()
|
bounds := img.Bounds()
|
||||||
w, h := bounds.Dx(), bounds.Dy()
|
w, h := bounds.Dx(), bounds.Dy()
|
||||||
bitmapper := bitmap.NewBitmapper(&pal)
|
palColors := pal.ColorPalette(false)
|
||||||
pixels := make([]byte, w*h)
|
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 y := 0; y < h; y++ {
|
||||||
for x := 0; x < w; x++ {
|
for x := 0; x < w; x++ {
|
||||||
c := img.RGBAAt(bounds.Min.X+x, bounds.Min.Y+y)
|
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
|
pixels[y*w+x] = 0
|
||||||
continue
|
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",
|
fmt.Printf(" upscaling %d resource(s), %d frame(s) total (model %s, scale %d):\n",
|
||||||
len(all), totalFrames, model, scale)
|
len(all), totalFrames, model, scale)
|
||||||
|
|
||||||
// UPSCALE_MODEL_PATH (set by mdlp-image's Dockerfile) points at the ONNX
|
// upscale_batch.py resolves model (this def's own "model" field) against
|
||||||
// model baked into the image.
|
// $UPSCALE_MODELS_DIR (set by mdlp-image's Dockerfile) itself, and fails
|
||||||
modelPath := os.Getenv("UPSCALE_MODEL_PATH")
|
// with the available model names if it doesn't recognize this one.
|
||||||
if modelPath == "" {
|
cmd := exec.Command("python3", "/usr/local/bin/upscale_batch.py", model, inDir, outDir, strconv.Itoa(scale))
|
||||||
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))
|
|
||||||
// Streamed live, not buffered via CombinedOutput: a single directory
|
// Streamed live, not buffered via CombinedOutput: a single directory
|
||||||
// batch can run for a while, and silence until the whole thing exits
|
// batch can run for a while, and silence until the whole thing exits
|
||||||
// gives no sense that it's still making progress.
|
// gives no sense that it's still making progress.
|
||||||
@@ -426,17 +533,26 @@ func toRGBA(img image.Image) *image.RGBA {
|
|||||||
return converted
|
return converted
|
||||||
}
|
}
|
||||||
|
|
||||||
// encodeFrames RLE-diffs each frame against the previous, matching the
|
// encodeFrames RLE-compresses each frame against an all-zero reference (the
|
||||||
// convention hacked's own editor/animations/View.go uses when importing a
|
// "still image" convention - see ss-specs media/Bitmaps.md). The RLE format
|
||||||
// GIF (the first frame diffs against an implicit all-zero reference).
|
// 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 {
|
func encodeFrames(frames []*bitmap.Bitmap) [][]byte {
|
||||||
blocks := make([][]byte, 0, len(frames))
|
blocks := make([][]byte, 0, len(frames))
|
||||||
var prevPixels []byte
|
|
||||||
for _, bmp := range frames {
|
for _, bmp := range frames {
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
_ = binary.Write(buf, binary.LittleEndian, &bmp.Header)
|
_ = binary.Write(buf, binary.LittleEndian, &bmp.Header)
|
||||||
_ = rle.Compress(buf, bmp.Pixels, prevPixels)
|
_ = rle.Compress(buf, bmp.Pixels, nil)
|
||||||
prevPixels = bmp.Pixels
|
|
||||||
blocks = append(blocks, buf.Bytes())
|
blocks = append(blocks, buf.Bytes())
|
||||||
}
|
}
|
||||||
return blocks
|
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
|
set -euo pipefail
|
||||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
export LD_LIBRARY_PATH="$(pwd)/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
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
|
# build-image/prepare-android-project.sh --host-paths) write paths that
|
||||||
# resolve on the HOST filesystem instead of this container's own
|
# resolve on the HOST filesystem instead of this container's own
|
||||||
# /workspace - needed so a gradlew/Android Studio running natively on the
|
# /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
|
# shellcheck disable=SC2086
|
||||||
docker run --rm $TTY_FLAGS \
|
docker run --rm $TTY_FLAGS \
|
||||||
-v "$ROOT:/workspace" \
|
-v "$ROOT:/workspace" \
|
||||||
-e HOST_UID="$(id -u)" \
|
-e HOST_UID="$(id -u)" \
|
||||||
-e HOST_GID="$(id -g)" \
|
-e HOST_GID="$(id -g)" \
|
||||||
-e HOST_REPO_ROOT="$ROOT" \
|
-e HOST_REPO_ROOT="$ROOT" \
|
||||||
|
-e ENGINE_DEBUG_TOOLS="${ENGINE_DEBUG_TOOLS:-}" \
|
||||||
"$IMAGE" \
|
"$IMAGE" \
|
||||||
"$@"
|
"$@"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user