Compare commits
4 Commits
ab2b778f44
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 345f8b5f17 | |||
| 90303894ce | |||
| a8d7ff76df | |||
| 9d0a0d5798 |
@@ -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()`
|
||||||
@@ -380,6 +388,131 @@ which the desktop build does not apply.
|
|||||||
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.
|
||||||
|
|
||||||
|
`g3_bitmap_common()`'s world-space size and its texture-coordinate range
|
||||||
|
are computed from the same `bm_w`/`bm_h` locals, but only the former
|
||||||
|
should ever use this patch's override - the pixels actually being
|
||||||
|
sampled are still the bitmap's own real (possibly upscaled) buffer,
|
||||||
|
regardless of what on-screen size it's drawn at. Overriding both left the
|
||||||
|
texture-coordinate range capped at the smaller, un-upscaled size while
|
||||||
|
the real bitmap underneath it was larger, so the renderer only ever
|
||||||
|
sampled that smaller top-left region of it - cropping off anything past
|
||||||
|
it, such as an upscaled critter's limbs extending beyond its original
|
||||||
|
sprite's bounding box. OpenGL never showed this: `opengl_bitmap()`
|
||||||
|
ignores `g3_bitmap_common()`'s computed `u`/`v` entirely and always maps
|
||||||
|
the *whole* bound texture over the quad via fixed `0.0`-`1.0` corners.
|
||||||
|
The software renderer's own scanline texture mapper (`h_map()`,
|
||||||
|
`gentm.c`) has no such shortcut - it takes `u`/`v` as literal pixel
|
||||||
|
coordinates into the bitmap and interpolates between exactly the values
|
||||||
|
it's given, so a capped range there directly caps how much of the real
|
||||||
|
bitmap it ever reads. Fixed by splitting `g3_bitmap_common()`'s single
|
||||||
|
`bm_w`/`bm_h` into the override-aware pair (world-space size only) and a
|
||||||
|
`tex_w`/`tex_h` pair that always holds the bitmap's own real dimensions,
|
||||||
|
used for every texture-coordinate computation (including the later
|
||||||
|
blend-buffer-size check, which sizes a buffer against the same real
|
||||||
|
pixel data these coordinates sample from).
|
||||||
|
- **`05-desktop-fix-texture-cache-collision.patch`** - `OpenGL.cc`'s
|
||||||
|
per-bitmap GL texture cache (`texturesByBitsPtr`) was keyed by
|
||||||
|
`bm->bits | (w << 32) | (h << 48)`, a plain bitwise OR rather than a
|
||||||
|
genuinely disjoint encoding - `bm->bits` is a full pointer, and a 64-bit
|
||||||
|
Linux heap/mmap address routinely has non-zero bits in the 32-47 range
|
||||||
|
the width field occupies, so a different `(bits, w, h)` triple can
|
||||||
|
collide with an already-cached entry's key. `bm->bits` itself is a
|
||||||
|
recycled slot in the resource system's own bounded LRU cache, not a
|
||||||
|
stable per-resource address, making a collision a real risk rather than
|
||||||
|
a theoretical one. On a collision, `set_texture()` (same file) treats it
|
||||||
|
as a genuine cache hit and reuses that entry's SDL surfaces - allocated
|
||||||
|
for whatever `(w, h)` was cached before - for an `SDL_memmove` and
|
||||||
|
`glTexImage2D` sized to the *current* bitmap's byte count, corrupting
|
||||||
|
the result with a buffer overflow or leftover stale pixel data from a
|
||||||
|
previous, differently-sized bitmap. Fixed by keying the cache on a
|
||||||
|
`std::tuple<void *, int, int>` instead, which `std::map` compares
|
||||||
|
field-by-field with no collision risk - a hit now always means an exact
|
||||||
|
`(bits, w, h)` match, so the cached surfaces are guaranteed to already
|
||||||
|
be sized correctly for the current bitmap.
|
||||||
|
|
||||||
|
A second, independent bug in the same cache: on a hit, `set_texture()`
|
||||||
|
"refreshed" the cached `SDL_Surface` with
|
||||||
|
`SDL_memmove(t->bitmap->pixels, bm->bits, ...)` - but `t->bitmap` was
|
||||||
|
created by `convert_texture()` via `SDL_CreateRGBSurfaceFrom(bm->bits,
|
||||||
|
...)`, which wraps the given pointer rather than copying it, so
|
||||||
|
`t->bitmap->pixels` *is* `bm->bits` from whenever this entry was first
|
||||||
|
cached. Since a hit means the current call's `bm->bits` is that same
|
||||||
|
pointer by definition (it's part of the key), that memmove was always a
|
||||||
|
no-op self-copy - it never refreshed anything. `bm->bits` is a recycled
|
||||||
|
resource-cache slot reused for different content across draws while its
|
||||||
|
address stays the same, so a hit here was never a guarantee the pixels
|
||||||
|
underneath were still current, only that this entry's GL texture and
|
||||||
|
converted-surface buffers are the right size to reuse. Fixed by
|
||||||
|
re-pointing `t->bitmap->pixels`/`pitch` at the current frame's real
|
||||||
|
source (`bm->bits`, or the RSD8-decoded buffer) before blitting, instead
|
||||||
|
of copying into what was already the same address. Most noticeable with
|
||||||
|
content whose per-frame bitmap dimensions vary a lot, such as critter
|
||||||
|
animations after an AI upscale mod re-exports each frame at its own
|
||||||
|
independently upscaled size (see `modding/README.md` section 5) -
|
||||||
|
vanilla assets mostly dodge the first bug because many critter
|
||||||
|
animations reuse the same bounding-box size across most of their
|
||||||
|
frames, but the second bug affects any critter whose rendering revisits
|
||||||
|
the same resource-cache slot across different draws, regardless of mod
|
||||||
|
content.
|
||||||
|
|
||||||
|
### 4.5. Debugging
|
||||||
|
|
||||||
|
`engine-debug-tools/` holds engine instrumentation that's useful for
|
||||||
|
diagnosing rendering bugs but has no place in a normal build - kept
|
||||||
|
separate from `engine-patches/` (section 4.4) so it's never silently
|
||||||
|
included in `make dist`/`make package`.
|
||||||
|
|
||||||
|
- `engine-debug-tools/engine-patches/` - patches in the same format and
|
||||||
|
numbered the same way as `engine-patches/` (section 4.4's own process
|
||||||
|
applies: scratch copy, `patch -p1`, regenerate via `diff -u` rather than
|
||||||
|
hand-editing hunks), applied on top of the regular `engine-patches/`
|
||||||
|
series, never in place of it. Two patches so far:
|
||||||
|
- **`01`** - a debug dump hotkey: pressing **K** in-game dumps every
|
||||||
|
frame of the currently/most-recently-drawn critter's animation to
|
||||||
|
`dumps/` (created next to the running binary) as raw palette-index
|
||||||
|
pixel data - 2-byte LE width, 2-byte LE height, then width\*height
|
||||||
|
index bytes, one file per frame, named `id<resource-id>_frame<N>.raw`.
|
||||||
|
Deliberately independent of the renderer's own texture/GL state, so a
|
||||||
|
frame already corrupted in the dump is a decode-time bug, while a
|
||||||
|
frame that's clean in the dump but wrong on screen is a rendering-time
|
||||||
|
one.
|
||||||
|
- **`02`** - RSD8 decode tracing: `gr_rsd8_convert()`/`gr_rsd8_unpack()`
|
||||||
|
(`engine/src/Libraries/2D/Source/RSD/`) print a `[debug rsd8]` summary
|
||||||
|
per frame (decode path, source/destination dimensions, bytes written
|
||||||
|
versus the bitmap's own area) and a `[debug rsd8 op]` line per RLE
|
||||||
|
opcode consumed, gated by a flag `debug_dump_critter_animation()` (01,
|
||||||
|
above) sets only around its own decode calls - tracing covers exactly
|
||||||
|
the frames the K hotkey dumps, not every RSD8 decode in the game. Each
|
||||||
|
frame is also decoded a second time back to back and logged as
|
||||||
|
`identical`/`DIFFERS` against the first, to tell a decode result that's
|
||||||
|
deterministic given the compressed source (a bad source stream, or a
|
||||||
|
decoder bug it triggers) apart from one that depends on something else,
|
||||||
|
such as a resource-cache slot being reused mid-decode.
|
||||||
|
- `engine-debug-tools/dump2png/` - converts those `.raw` dumps into
|
||||||
|
viewable PNGs using the real game palette (the dump format has no
|
||||||
|
palette of its own, to keep the in-game dump path from depending on the
|
||||||
|
renderer's own palette/SDL state). Builds the same way
|
||||||
|
`modding/gen_animgallery.sh` builds `animgallery` - against
|
||||||
|
`modding/src/hacked` inside `modding/mdlp-image`'s Go toolchain, since
|
||||||
|
neither has a cgo dependency of its own - via
|
||||||
|
`engine-debug-tools/build-dump2png.sh <output-path>`.
|
||||||
|
|
||||||
|
`make dist_debug` builds everything above into `dist/` alongside the
|
||||||
|
normal `make dist` output: the engine with `engine-debug-tools/
|
||||||
|
engine-patches/` applied in addition to the regular `engine-patches/`
|
||||||
|
series, plus `dist/dump2png` and `dist/convert-dumps.sh` (a `dump2png`
|
||||||
|
wrapper, same convention as `run-mods.sh` wrapping `run.sh`, pointed at
|
||||||
|
`dist/`'s own `res/data` and `dumps/` so no arguments are needed). There's
|
||||||
|
one `dist/` - whichever of `dist`/`dist_debug` ran last determines whether
|
||||||
|
the debug hotkey, `dump2png` and `convert-dumps.sh` are present; running
|
||||||
|
the other target again overwrites it back.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make dist_debug
|
||||||
|
./dist/run.sh # or run-mods.sh - play until the bug shows, press K, quit
|
||||||
|
./dist/convert-dumps.sh # writes dist/dumps-png/*.png from dist/dumps/*.raw
|
||||||
|
```
|
||||||
|
|
||||||
## 5. Android / Quest build
|
## 5. Android / Quest build
|
||||||
|
|
||||||
**Prerequisites:**
|
**Prerequisites:**
|
||||||
|
|||||||
@@ -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 (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)
|
+ if (f == NULL)
|
||||||
+ return;
|
+ continue;
|
||||||
+ s_critterNativeW[id] = f->bm.w;
|
+ s_critterNativeW[id][frame] = f->bm.w;
|
||||||
+ s_critterNativeH[id] = f->bm.h;
|
+ s_critterNativeH[id][frame] = f->bm.h;
|
||||||
+ RefUnlock(MKREF(id, 0));
|
+ 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;
|
||||||
@@ -177,7 +200,15 @@
|
|||||||
_g3d_bitmap_clut = (p->i & 0x00ff00) + grd_screen->ltab;
|
_g3d_bitmap_clut = (p->i & 0x00ff00) + grd_screen->ltab;
|
||||||
_g3d_bitmap_poly = vpl;
|
_g3d_bitmap_poly = vpl;
|
||||||
return (g3_bitmap_common(bm, p));
|
return (g3_bitmap_common(bm, p));
|
||||||
@@ -185,6 +196,15 @@
|
@@ -176,6 +187,7 @@
|
||||||
|
grs_vertex *tempG1, *tempG2;
|
||||||
|
fix tempResult;
|
||||||
|
int32_t bm_w, bm_h;
|
||||||
|
+ int32_t tex_w, tex_h;
|
||||||
|
fix dx, dy;
|
||||||
|
|
||||||
|
// MLA- these were globals, I made them locals for PPC speed, they aren't
|
||||||
|
@@ -185,6 +197,15 @@
|
||||||
long rm2;
|
long rm2;
|
||||||
long rm3;
|
long rm3;
|
||||||
|
|
||||||
@@ -193,7 +224,7 @@
|
|||||||
#ifdef stereo_on
|
#ifdef stereo_on
|
||||||
if (_g3d_stereo & 1) {
|
if (_g3d_stereo & 1) {
|
||||||
|
|
||||||
@@ -209,9 +229,11 @@
|
@@ -209,9 +230,23 @@
|
||||||
if ((p->codes & CC_CLIP_OVERFLOW) != 0)
|
if ((p->codes & CC_CLIP_OVERFLOW) != 0)
|
||||||
return (0L);
|
return (0L);
|
||||||
|
|
||||||
@@ -202,14 +233,73 @@
|
|||||||
- bm_h = bm->h;
|
- bm_h = bm->h;
|
||||||
+ // copy a few things into locals - w_override/h_override, if set (see
|
+ // copy a few things into locals - w_override/h_override, if set (see
|
||||||
+ // _g3d_bitmap_w_override's own comment), take the place of the bitmap's
|
+ // _g3d_bitmap_w_override's own comment), take the place of the bitmap's
|
||||||
+ // own pixel dimensions here.
|
+ // own pixel dimensions here, for the on-screen quad's world-space size
|
||||||
|
+ // only. tex_w/tex_h stay the bitmap's own real dimensions always: the
|
||||||
|
+ // quad's u/v bounds (below) are pixel coordinates into bm's own actual
|
||||||
|
+ // pixel data - the software renderer's scanline texture mapper
|
||||||
|
+ // (h_map(), gentm.c) interpolates and samples them literally, so
|
||||||
|
+ // overriding them to a smaller size here would make it only ever read
|
||||||
|
+ // part of a bitmap bigger than that override (such as an upscaled mod's
|
||||||
|
+ // replacement art), cropping the rest. OpenGL doesn't read u/v from here
|
||||||
|
+ // at all (opengl_bitmap() always maps the whole real texture over the
|
||||||
|
+ // quad), which is why this only ever showed up in the software
|
||||||
|
+ // renderer.
|
||||||
+ bm_w = w_override ? w_override : bm->w;
|
+ bm_w = w_override ? w_override : bm->w;
|
||||||
+ bm_h = h_override ? h_override : bm->h;
|
+ bm_h = h_override ? h_override : bm->h;
|
||||||
|
+ tex_w = bm->w;
|
||||||
|
+ tex_h = bm->h;
|
||||||
|
|
||||||
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
|
@@ -299,7 +334,7 @@
|
||||||
+++ b/src/Libraries/3D/Source/3d.h 2026-09-27 19:42:26.528424908 +0200
|
tempG1->y = tempL2;
|
||||||
|
|
||||||
|
// vpl[1][2] = bitmap.w-1
|
||||||
|
- tempG1->u = (bm_w - 1) << 16;
|
||||||
|
+ tempG1->u = (tex_w - 1) << 16;
|
||||||
|
|
||||||
|
// vpl[1][3] = 0
|
||||||
|
tempG1->v = 0;
|
||||||
|
@@ -326,10 +361,10 @@
|
||||||
|
tempG1->y = tempL2;
|
||||||
|
|
||||||
|
// vpl[2][2] = bitmap.w-1
|
||||||
|
- tempG1->u = (bm_w - 1) << 16;
|
||||||
|
+ tempG1->u = (tex_w - 1) << 16;
|
||||||
|
|
||||||
|
// vpl[2][3] = bitmap.h-1
|
||||||
|
- tempG1->v = (bm_h - 1) << 16;
|
||||||
|
+ tempG1->v = (tex_h - 1) << 16;
|
||||||
|
|
||||||
|
// vpl[2][4] undefined
|
||||||
|
|
||||||
|
@@ -356,7 +391,7 @@
|
||||||
|
tempG1->u = 0;
|
||||||
|
|
||||||
|
// vpl[3][3] = bitmap.h
|
||||||
|
- tempG1->v = bm_h << 16;
|
||||||
|
+ tempG1->v = tex_h << 16;
|
||||||
|
|
||||||
|
// vpl[3][4] undefined
|
||||||
|
|
||||||
|
@@ -380,11 +415,13 @@
|
||||||
|
if (dy > dx)
|
||||||
|
dx = dy; // get max value in dx
|
||||||
|
|
||||||
|
- // shift down because bm_w isn't fixed, and we only double when twice the size
|
||||||
|
+ // shift down because tex_w isn't fixed, and we only double when twice the size
|
||||||
|
dx >>= 0x11;
|
||||||
|
|
||||||
|
// make sure doubled bitmap won't overflow unpack buffer (that's roughly 1/5 of 64k)
|
||||||
|
- if ((bm_w <= dx) && (bm_w * bm_h <= 12000)) {
|
||||||
|
+ // - against the bitmap's own real size (tex_w/tex_h), same as the u/v this doubles
|
||||||
|
+ // just below: both operate on bm's actual pixel data, not the on-screen override.
|
||||||
|
+ if ((tex_w <= dx) && (tex_w * tex_h <= 12000)) {
|
||||||
|
// first fix all u's and v's
|
||||||
|
for (tempL = 0; tempL < 4; tempL++) {
|
||||||
|
_g3d_bitmap_poly[tempL]->u <<= 1;
|
||||||
|
--- a/src/Libraries/3D/Source/3d.h
|
||||||
|
+++ b/src/Libraries/3D/Source/3d.h
|
||||||
@@ -720,7 +720,8 @@
|
@@ -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);
|
||||||
+4
-1
@@ -18,7 +18,9 @@
|
|||||||
# animgallery_mods - same, but with every built mod under dist/mods/
|
# animgallery_mods - same, but with every built mod under dist/mods/
|
||||||
# layered on top (later wins), in build/gallery-mods/ -
|
# layered on top (later wins), in build/gallery-mods/ -
|
||||||
# shows what a mod actually changes without launching
|
# shows what a mod actually changes without launching
|
||||||
# the game. Run `make mods` first.
|
# the game. Every card for a resource a mod overrides
|
||||||
|
# shows the original alongside it for comparison. Run
|
||||||
|
# `make mods` first.
|
||||||
# animgallery_dist - every built mod under dist/mods/ layered on top of
|
# animgallery_dist - every built mod under dist/mods/ layered on top of
|
||||||
# the base game, like animgallery_mods, but restricted
|
# the base game, like animgallery_mods, but restricted
|
||||||
# to only the resources a mod actually overrides, in
|
# to only the resources a mod actually overrides, in
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
# Run `make mods` first.
|
# Run `make mods` first.
|
||||||
# (animgallery_base/_mods/_dist all write into this
|
# (animgallery_base/_mods/_dist all write into this
|
||||||
# subproject's own build/, not the repo root's.)
|
# subproject's own build/, not the repo root's.)
|
||||||
|
#
|
||||||
# clean - removes dist/.
|
# clean - removes dist/.
|
||||||
.PHONY: all build-image hacked mdlp-image mods animgallery_base animgallery_mods animgallery_dist clean
|
.PHONY: all build-image hacked mdlp-image mods animgallery_base animgallery_mods animgallery_dist clean
|
||||||
|
|
||||||
|
|||||||
@@ -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,27 @@ 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 |
|
||||||
|
| `considerPreviousFrames` | Optional, same id format as `resources` - an id here that isn't also in `resources` is simply never applied, not an error. A listed resource with 3+ frames gets a temporal-consistency pass (section 5) that pulls a frame's upscaled pixel back toward the rest of the animation's own consensus wherever it disagrees by more than the real low-res source justifies - a real mitigation for per-frame upscale flicker. Lets one resource opt in without splitting it into its own file or changing `model`/`scale` for the rest. |
|
||||||
|
|
||||||
|
Available `model` values (baked into `mdlp-image`, see
|
||||||
|
`mdlp-image/upscale_batch.py`):
|
||||||
|
|
||||||
|
| `model` | Checkpoint | Best for |
|
||||||
|
|---|---|---|
|
||||||
|
| `realesrgan-x4plus-anime` | Real-ESRGAN's 6-block anime/illustration RRDBNet (16.7M params) | Flat-color sprite art - keeps hard edges instead of smoothing them the way a photo/video model would. Default choice. |
|
||||||
|
| `realesrgan-x4plus` | Real-ESRGAN's general-purpose photo RRDBNet (16.7M params) | A specific resource where the anime model genuinely mis-renders a region (seen on some high-contrast color boundaries) - less edge-sharp overall, much slower, and can mis-render its own different regions, so check the result before keeping it |
|
||||||
|
| `realesr-general-x4v3` | Real-ESRGAN's compact general-purpose SRVGGNet (1.2M params) | A specific resource neither RRDBNet model renders cleanly - noticeably softer than either, but the most robust against both of their failure modes. Not a blanket replacement, just a last resort for one stubborn sprite |
|
||||||
|
|
||||||
### 4.2. `mdlp`
|
### 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 +304,121 @@ Same as `hacked-build-image/` (section 2.1): `./mdlp-upload-image.sh` reads
|
|||||||
`mdlp-image/VERSION` just because the Dockerfile changed - only once a build
|
`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. Each card
|
||||||
|
shows the looping GIF plus a static filmstrip of every one of its frames
|
||||||
|
side by side, so one specific frame can be singled out and compared without
|
||||||
|
having to pause the animation by eye. When two or more `-data` directories
|
||||||
|
are layered this way, a card for any resource a later layer actually
|
||||||
|
overrides shows both versions side by side - the first directory's own
|
||||||
|
("base") and the winning one ("this build") - so a mod's effect is always
|
||||||
|
checked against the original it's replacing, not recalled from memory or a
|
||||||
|
separate base-only gallery run. Cards are sectioned by the `-data`
|
||||||
|
directory they come from - "base game" first, then one section per mod
|
||||||
|
(named after `dist/mods/<name>`) - so a mod's own animations, for example
|
||||||
|
all of `upscale_medbot`'s, are listed together.
|
||||||
|
|
||||||
|
`gen_animgallery.sh` is a from-scratch Go decoder, not the game engine -
|
||||||
|
it never exercises the engine's own texture upload/caching code at all.
|
||||||
|
A clean gallery render rules out a bug in the mod's resource *data*, not a
|
||||||
|
bug in how the engine *renders* that data - the two are independent, and a
|
||||||
|
problem can exist in only one of them. Actually running the built mod
|
||||||
|
(`../dist/run-mods.sh` or `run.sh` with a single mod folder, section 4.3)
|
||||||
|
is the only way to check the second. This in-engine verification has so
|
||||||
|
far only been through the desktop build, not the Quest headset - both
|
||||||
|
read the same resource data and the engine-side bugs found here
|
||||||
|
(`engine-patches/`, root [README.md](../README.md) section 4's patch
|
||||||
|
reference) were resource-data-adjacent, not OpenXR-specific, so a fix at
|
||||||
|
this level should carry over, but it isn't headset-confirmed until tested
|
||||||
|
there.
|
||||||
|
|
||||||
|
The AI-upscale pipeline (`src/mdlp/upscale.go`) and the engine's own
|
||||||
|
rendering of its output have a failure mode worth specifically checking
|
||||||
|
for, found by comparing upscaled output against the base game across many
|
||||||
|
resources rather than trusting a handful of spot-checks:
|
||||||
|
|
||||||
|
- **Wrong-hue speckling in dark or muted regions.** SS1's palette has a
|
||||||
|
handful of fully-saturated "pure color ramp" entries (pure cyan, red,
|
||||||
|
green, ...) that sit deceptively close, in color-distance terms, to
|
||||||
|
common dark/muted shading colors - a plain nearest-color search, with or
|
||||||
|
without dithering, can pick one of these for a near-black pixel the AI
|
||||||
|
upscaler produced, since color distance near black barely penalizes an
|
||||||
|
unrelated hue. `isPureRampColor` excludes these entries from
|
||||||
|
re-quantization entirely rather than relying on a brightness or
|
||||||
|
saturation threshold, which proved too easy for a genuinely
|
||||||
|
dark-but-muted pixel to slip past.
|
||||||
|
- **Per-frame hallucination in an animation's upscaled output.**
|
||||||
|
`upscale_batch.py` runs every frame through the ONNX model independently,
|
||||||
|
with no shared state between frames - a well-known limitation of
|
||||||
|
single-image super-resolution. On a low-detail/ambiguous source region
|
||||||
|
the model can guess differently frame to frame, so a detail (an extra
|
||||||
|
claw, a stray highlight) pops in for a few frames and back out, even
|
||||||
|
though the real low-resolution source barely changed there. Review by eye
|
||||||
|
(`make animgallery_mods`/`animgallery_dist`, section 4.3) before trusting
|
||||||
|
a new upscale definition - this is invisible from a single frame or a
|
||||||
|
handful of spot-checks and needs to be checked per-animation, ideally
|
||||||
|
with the GIF slowed down and its base-vs-this-build comparison columns
|
||||||
|
(both features of the gallery itself) rather than played at real speed.
|
||||||
|
|
||||||
|
`considerPreviousFrames` (section 4.1) is a real mitigation, not a
|
||||||
|
different model/scale gamble, for a resource found to have this problem:
|
||||||
|
list just the resources that need it and the rest of the definition keeps
|
||||||
|
its own `model`/`scale` unchanged. For each listed resource with 3+
|
||||||
|
frames, after independent per-frame upscaling, each frame's pixels are
|
||||||
|
compared against the per-pixel median across every frame of the same
|
||||||
|
(cyclic) animation - does the upscaled difference from that consensus
|
||||||
|
exceed what the real low-res source's own difference from its consensus
|
||||||
|
justifies - and pulled to the median where it does, on the same model the
|
||||||
|
rest of the definition already uses. Try it before reaching for a
|
||||||
|
different model or scale. It needs 3+ frames for a meaningful consensus (a
|
||||||
|
2-frame animation has no independent third frame to vote with), and never
|
||||||
|
touches alpha or
|
||||||
|
an already-transparent pixel.
|
||||||
|
|
||||||
|
`encodeFrames` compresses every frame against an all-zero reference
|
||||||
|
instead of diffing against the previous frame's real pixels, because
|
||||||
|
that's the only convention the game's actual RSD8 decoder implements - so
|
||||||
|
an animation's frames never depend on each other, and a size change
|
||||||
|
between poses isn't a concern to test for. This is confirmed directly
|
||||||
|
against Night Dive's officially published original Mac source
|
||||||
|
(`ShockMac/Libraries/2D/Source/RSD/{RSDCVT.C,RSDUnpack.c}`), not just the
|
||||||
|
vendored Shockolate snapshot in `engine/`: both files are logic-identical
|
||||||
|
to their vendored counterparts (same 1994 RCS revision comments, same
|
||||||
|
unconditional `LG_memset(p_dst, kSkipColor, ...)` zero-fill in every skip
|
||||||
|
branch), and `RSDUnpack.c` additionally carries the original hand-written
|
||||||
|
68K assembly decoder alongside its later PowerPC/C port - its own skip
|
||||||
|
handler also zeroes an output register rather than reading from a
|
||||||
|
reference. Two independently-authored implementations, spanning different
|
||||||
|
CPU architectures and years apart, agreeing on "skip means zero" down to
|
||||||
|
matching source comments ("skip (zero) bytes") rules this out as an
|
||||||
|
overlooked bug rather than deliberate, original design.
|
||||||
|
|
||||||
|
The RLE format's own opcode grammar does permit the alternative reading -
|
||||||
|
"skip" meaning "retain the reference image's value" rather than "write
|
||||||
|
zero" (`ss-specs` documents this as relevant to compressing "video
|
||||||
|
clips", which is what led `hacked`'s own GIF-import path, and therefore
|
||||||
|
earlier versions of `encodeFrames`, to use it). Searching the original
|
||||||
|
source for anywhere that convention is actually exercised turned up
|
||||||
|
nothing: SS1's two "video clip" features - cutscenes (`MoviePlay.c`) and
|
||||||
|
"video mail" (`VMAIL.C`, itself built directly on `MoviePlay.h`) - both
|
||||||
|
use an entirely separate movie codec, not RSD8, and neither references
|
||||||
|
the RSD8 decoder at all.
|
||||||
|
|
||||||
|
`gen_animgallery.sh` and the real engine therefore always agree on an
|
||||||
|
upscaled animation's frames; a difference between them points at a
|
||||||
|
genuine engine rendering bug (root [README.md](../README.md) section 4's
|
||||||
|
patch reference) rather than this pipeline's own output, and
|
||||||
|
`engine-debug-tools/`'s debug dump hotkey (root README.md section 4.5
|
||||||
|
"Debugging") dumps the engine's own decoded frame data independent of its
|
||||||
|
renderer to tell the two apart.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "Mails",
|
|
||||||
"index": 26,
|
|
||||||
"sender": "Rebecca Lansing1",
|
|
||||||
"title": "Rebecca-001",
|
|
||||||
"verboseTextFile": "mail-026.txt"
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Listen to me very carefully HACKER. Something has gone terribly wrong on Citadel station. You might be the last survivor. Communications are being jammed. We know that something on board is attempting to harm humanity. The mining laser is being charged for a possible strike against Earth populations. On your level is the office of a Dr. D'Arcy, who may know something about the laser. The office is located near the central hub, on a balcony, with the medical icon clearly visible. When the laser is destroyed, you will have to reach the bridge and stop whatever is responsible for the catastrophe. We believe that the biological labs and security systems have gone haywire, so be careful. If you pull this off, we will be able to get you out alive. Good luck.
|
|
||||||
@@ -22,5 +22,6 @@
|
|||||||
"0x7B6",
|
"0x7B6",
|
||||||
"0x7B7",
|
"0x7B7",
|
||||||
"0x84C"
|
"0x84C"
|
||||||
]
|
],
|
||||||
|
"considerPreviousFrames": ["0x7B1"]
|
||||||
}
|
}
|
||||||
|
|||||||
+373
-79
@@ -1,9 +1,15 @@
|
|||||||
// Command animgallery scans a game data directory for every multi-frame
|
// Command animgallery scans a game data directory for every multi-frame
|
||||||
// bitmap resource (both dedicated "Animation" descriptors, e.g. video mail,
|
// bitmap resource (both dedicated "Animation" descriptors, e.g. video mail,
|
||||||
// and plain multi-block compound bitmaps, e.g. critter sprites - System
|
// and plain multi-block compound bitmaps, e.g. critter sprites - System
|
||||||
// Shock uses both conventions) and renders each one as an animated GIF plus
|
// Shock uses both conventions) and renders each one as an animated GIF, plus
|
||||||
// an HTML index page, so they can be browsed by eye instead of guessed at
|
// a static filmstrip of every frame side by side (the loop alone doesn't let
|
||||||
// by resource ID.
|
// a specific frame be singled out), into an HTML index page, so they can be
|
||||||
|
// browsed by eye instead of guessed at by resource ID. Layering a mod's own
|
||||||
|
// build output over the base game data (repeated -data) renders the result
|
||||||
|
// as actually played - and for any resource the mod overrides, the base
|
||||||
|
// game's own version alongside it, for a direct comparison. The index page
|
||||||
|
// sections its cards by the -data layer they come from: the base game, then
|
||||||
|
// each mod.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,7 +18,9 @@ import (
|
|||||||
"html"
|
"html"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
"image/gif"
|
"image/gif"
|
||||||
|
"image/png"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -36,6 +44,18 @@ type galleryEntry struct {
|
|||||||
height int
|
height int
|
||||||
transparent bool
|
transparent bool
|
||||||
gifName string
|
gifName string
|
||||||
|
filmstripName string
|
||||||
|
// group names the winning -data layer (see groupName), dirIndex is that
|
||||||
|
// layer's position among the -data directories - the index page
|
||||||
|
// sections its cards by group, in dirIndex order.
|
||||||
|
group string
|
||||||
|
dirIndex int
|
||||||
|
// baseGifName/baseFilmstripName are set only when this resource's
|
||||||
|
// winning layer isn't the first -data directory - the same content
|
||||||
|
// rendered from that first (base) directory alone, for a side-by-side
|
||||||
|
// comparison against what a later layer (a mod) overrides it with.
|
||||||
|
baseGifName string
|
||||||
|
baseFilmstripName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// dataDirs collects repeated -data flags, in the order given - later ones
|
// dataDirs collects repeated -data flags, in the order given - later ones
|
||||||
@@ -48,6 +68,20 @@ type dataDirs []string
|
|||||||
func (d *dataDirs) String() string { return strings.Join(*d, ",") }
|
func (d *dataDirs) String() string { return strings.Join(*d, ",") }
|
||||||
func (d *dataDirs) Set(v string) error { *d = append(*d, v); return nil }
|
func (d *dataDirs) Set(v string) error { *d = append(*d, v); return nil }
|
||||||
|
|
||||||
|
// groupName labels one -data layer for the index page: "base game" for the
|
||||||
|
// first one, otherwise the mod's name. A built mod lives at
|
||||||
|
// dist/mods/<name>/mod, so a directory named "mod" is labeled by its parent.
|
||||||
|
func groupName(absDir string, dirIndex int) string {
|
||||||
|
if dirIndex == 0 {
|
||||||
|
return "base game"
|
||||||
|
}
|
||||||
|
name := filepath.Base(absDir)
|
||||||
|
if name == "mod" {
|
||||||
|
name = filepath.Base(filepath.Dir(absDir))
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
// loadDataDir merges every .res/.dat file directly inside dir into one
|
// loadDataDir merges every .res/.dat file directly inside dir into one
|
||||||
// manifest entry, bypassing hacked's own fileAllowlist
|
// manifest entry, bypassing hacked's own fileAllowlist
|
||||||
// (ss1/world/FileLoader.go): that check is only skipped when a file is
|
// (ss1/world/FileLoader.go): that check is only skipped when a file is
|
||||||
@@ -129,11 +163,13 @@ func main() {
|
|||||||
// later layer (a mod) overrides.
|
// later layer (a mod) overrides.
|
||||||
sourceFileByKey := make(map[resource.Key]string)
|
sourceFileByKey := make(map[resource.Key]string)
|
||||||
sourceDirIndexByKey := make(map[resource.Key]int)
|
sourceDirIndexByKey := make(map[resource.Key]int)
|
||||||
|
groupByDirIndex := make(map[int]string)
|
||||||
for dirIndex, dir := range dirs {
|
for dirIndex, dir := range dirs {
|
||||||
absDir, err := filepath.Abs(dir)
|
absDir, err := filepath.Abs(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("resolving -data %q: %v", dir, err)
|
log.Fatalf("resolving -data %q: %v", dir, err)
|
||||||
}
|
}
|
||||||
|
groupByDirIndex[dirIndex] = groupName(absDir, dirIndex)
|
||||||
entry, err := loadDataDir(absDir)
|
entry, err := loadDataDir(absDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A mod that doesn't touch any resource file yet (e.g. an
|
// A mod that doesn't touch any resource file yet (e.g. an
|
||||||
@@ -167,6 +203,32 @@ func main() {
|
|||||||
log.Printf("warning: could not load the default game palette (%v); falling back to grayscale", err)
|
log.Printf("warning: could not load the default game palette (%v); falling back to grayscale", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// baseMod holds only the first -data directory, unlayered - resolving a
|
||||||
|
// resource through it instead of the full mod gives back what a later
|
||||||
|
// layer (a mod) is actually overriding, for a side-by-side comparison on
|
||||||
|
// every entry one of those layers changes. Reloaded independently of the
|
||||||
|
// dirs loop above rather than reusing its ManifestEntry, to keep that
|
||||||
|
// loop's own bookkeeping (sourceFileByKey, ...) untangled from this.
|
||||||
|
var baseMod *world.Mod
|
||||||
|
if len(dirs) > 1 {
|
||||||
|
baseAbsDir, err := filepath.Abs(dirs[0])
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("resolving -data %q: %v", dirs[0], err)
|
||||||
|
}
|
||||||
|
baseEntry, err := loadDataDir(baseAbsDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("base-layer comparison disabled: reloading %s: %v", baseAbsDir, err)
|
||||||
|
} else {
|
||||||
|
sort.Slice(baseEntry.Resources, func(i, j int) bool { return baseEntry.Resources[i].ID < baseEntry.Resources[j].ID })
|
||||||
|
candidate := world.NewMod(func([]resource.ID, []resource.ID) {}, func() {})
|
||||||
|
if err := candidate.World().InsertEntry(candidate.World().EntryCount(), baseEntry); err != nil {
|
||||||
|
log.Printf("base-layer comparison disabled: registering %s: %v", baseAbsDir, err)
|
||||||
|
} else {
|
||||||
|
baseMod = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
keys := make([]resource.Key, 0, len(sourceFileByKey))
|
keys := make([]resource.Key, 0, len(sourceFileByKey))
|
||||||
for key := range sourceFileByKey {
|
for key := range sourceFileByKey {
|
||||||
if *onlyChanged && sourceDirIndexByKey[key] == 0 {
|
if *onlyChanged && sourceDirIndexByKey[key] == 0 {
|
||||||
@@ -187,28 +249,32 @@ func main() {
|
|||||||
// Re-resolved through the layered mod, not the raw per-directory
|
// Re-resolved through the layered mod, not the raw per-directory
|
||||||
// viewer, so an overriding mod's version wins here exactly like it
|
// viewer, so an overriding mod's version wins here exactly like it
|
||||||
// would for the running game.
|
// would for the running game.
|
||||||
view, err := mod.LocalizedResources(key.Lang).Select(key.ID)
|
entry, ok, err := renderForMod(mod, defaultPal, sourceFile, key.ID, key.Lang, gifDir, "", *minFrames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("skipping %s (%s): %v", key.ID, sourceFile, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry.dirIndex = sourceDirIndexByKey[key]
|
||||||
|
entry.group = groupByDirIndex[entry.dirIndex]
|
||||||
|
|
||||||
|
// A later layer wins this key over the first (base) -data directory
|
||||||
|
// - also render the base directory's own version of it, so the two
|
||||||
|
// can be shown side by side instead of only the result actually
|
||||||
|
// in use.
|
||||||
|
if baseMod != nil && sourceDirIndexByKey[key] != 0 {
|
||||||
|
baseEntry, ok, err := renderForMod(baseMod, defaultPal, sourceFile, key.ID, key.Lang, gifDir, "_base", *minFrames)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("skipping base comparison for %s (%s): %v", key.ID, sourceFile, err)
|
||||||
|
} else if ok {
|
||||||
|
entry.baseGifName = baseEntry.gifName
|
||||||
|
entry.baseFilmstripName = baseEntry.filmstripName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
|
||||||
case view.ContentType() == resource.Animation:
|
|
||||||
entry, err := safeRenderAnimationDescriptor(mod, defaultPal, sourceFile, key.ID, key.Lang, gifDir)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("skipping animation %s (%s): %v", key.ID, sourceFile, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entries = append(entries, entry)
|
entries = append(entries, entry)
|
||||||
|
|
||||||
case view.ContentType() == resource.Bitmap && view.BlockCount() >= *minFrames:
|
|
||||||
entry, err := safeRenderMultiBlockBitmap(view, defaultPal, sourceFile, key.ID, key.Lang, gifDir)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("skipping bitmap %s (%s): %v", key.ID, sourceFile, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entries = append(entries, entry)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(entries, func(i, j int) bool {
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
@@ -228,30 +294,31 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// decodeFrames reads every block of a compound bitmap resource as a
|
// decodeFrames reads every block of a compound bitmap resource as a
|
||||||
// sequence of frames, each one RLE-diffed against the previous - the same
|
// sequence of frames. Each block's RLE data can carry a "skip" run, which
|
||||||
// chaining hacked's own editor/animations/View.go uses when importing a
|
// bitmap.DecodeReferenced leaves untouched in whatever buffer it's handed -
|
||||||
// GIF, done here in reverse (decode instead of encode).
|
// the provider function below always hands it a fresh zero buffer, never a
|
||||||
|
// previous block's pixels, because the game's actual RSD8 decoder
|
||||||
|
// (engine/src/Libraries/2D/Source/RSD/{rsdcvt.c,RSDUnpack.c}, see
|
||||||
|
// upscale.go's encodeFrames) treats a skip run as literal zero for every
|
||||||
|
// resource type including critter sprites, with no mechanism to carry a
|
||||||
|
// reference image's pixels forward. hacked's own multi-block Bitmap preview
|
||||||
|
// (editor/bitmaps/View.go) matches that: it always calls Texture() with no
|
||||||
|
// reference. A non-zero buffer here would ghost a previous block's stale
|
||||||
|
// pixels into the current block's skip-run regions.
|
||||||
func decodeFrames(view resource.View) ([]*bitmap.Bitmap, error) {
|
func decodeFrames(view resource.View) ([]*bitmap.Bitmap, error) {
|
||||||
count := view.BlockCount()
|
count := view.BlockCount()
|
||||||
frames := make([]*bitmap.Bitmap, 0, count)
|
frames := make([]*bitmap.Bitmap, 0, count)
|
||||||
var prevPixels []byte
|
|
||||||
for i := 0; i < count; i++ {
|
for i := 0; i < count; i++ {
|
||||||
reader, err := view.Block(i)
|
reader, err := view.Block(i)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("block %d: %w", i, err)
|
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||||
}
|
}
|
||||||
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||||
size := int(w) * int(h)
|
return make([]byte, int(w)*int(h)), nil
|
||||||
buf := make([]byte, size)
|
|
||||||
if len(prevPixels) == size {
|
|
||||||
copy(buf, prevPixels)
|
|
||||||
}
|
|
||||||
return buf, nil
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("block %d: %w", i, err)
|
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||||
}
|
}
|
||||||
prevPixels = bmp.Pixels
|
|
||||||
frames = append(frames, bmp)
|
frames = append(frames, bmp)
|
||||||
}
|
}
|
||||||
return frames, nil
|
return frames, nil
|
||||||
@@ -265,34 +332,55 @@ func paletteFor(bmp *bitmap.Bitmap, fallback bitmap.Palette) color.Palette {
|
|||||||
return pal.ColorPalette(false)
|
return pal.ColorPalette(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeGIF(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette, delays []int) error {
|
// gallerySpeedFactor slows every gallery GIF down to a fraction of real
|
||||||
|
// in-game speed, since the real speed is often too fast to inspect a frame's
|
||||||
|
// detail by eye (the point of this gallery).
|
||||||
|
const gallerySpeedFactor = 3
|
||||||
|
|
||||||
|
// writeGIF composes every frame onto one shared canvas, sized to the larger
|
||||||
|
// of minWidth/minHeight (the format's own declared animation extent, 0 if
|
||||||
|
// unknown) and the largest individual frame, and places each frame's pixels
|
||||||
|
// at its top-left corner. A frame smaller than the canvas is a storage-level
|
||||||
|
// crop, not a smaller sprite (System Shock animation frames can be
|
||||||
|
// RLE-delta-encoded against the previous frame's unchanged pixels, see
|
||||||
|
// bitmap.DecodeReferenced), so every encoded GIF frame must still cover the
|
||||||
|
// whole canvas itself - otherwise GIF disposal rules leave the uncovered
|
||||||
|
// border showing whatever the previous frame drew there, bleeding stale
|
||||||
|
// content from frame to frame (visible in an external GIF viewer; System
|
||||||
|
// Shock itself always swaps in a complete texture per frame, so this never
|
||||||
|
// shows up in-game).
|
||||||
|
func writeGIF(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette, delays []int, minWidth, minHeight int) error {
|
||||||
f, err := os.Create(path)
|
f, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
maxWidth, maxHeight := 0, 0
|
canvasWidth, canvasHeight := minWidth, minHeight
|
||||||
for _, bmp := range frames {
|
for _, bmp := range frames {
|
||||||
if int(bmp.Header.Width) > maxWidth {
|
if int(bmp.Header.Width) > canvasWidth {
|
||||||
maxWidth = int(bmp.Header.Width)
|
canvasWidth = int(bmp.Header.Width)
|
||||||
}
|
}
|
||||||
if int(bmp.Header.Height) > maxHeight {
|
if int(bmp.Header.Height) > canvasHeight {
|
||||||
maxHeight = int(bmp.Header.Height)
|
canvasHeight = int(bmp.Header.Height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoopCount: 0 means loop forever in Go's image/gif API - inverted from
|
// LoopCount: 0 means loop forever in Go's image/gif API - inverted from
|
||||||
// the raw GIF format's own NETSCAPE2.0 convention. -1 (this package's
|
// the raw GIF format's own NETSCAPE2.0 convention. -1 (this package's
|
||||||
// zero value) means play once, which is why these looped only once
|
// zero value) means play once.
|
||||||
// before this fix.
|
out := gif.GIF{LoopCount: 0, Config: image.Config{Width: canvasWidth, Height: canvasHeight}}
|
||||||
out := gif.GIF{LoopCount: 0, Config: image.Config{Width: maxWidth, Height: maxHeight}}
|
canvasRect := image.Rect(0, 0, canvasWidth, canvasHeight)
|
||||||
for i, bmp := range frames {
|
for i, bmp := range frames {
|
||||||
colorPal := paletteFor(bmp, fallbackPal)
|
colorPal := paletteFor(bmp, fallbackPal)
|
||||||
rect := image.Rect(0, 0, int(bmp.Header.Width), int(bmp.Header.Height))
|
img := image.NewPaletted(canvasRect, colorPal)
|
||||||
img := image.NewPaletted(rect, colorPal)
|
frameWidth, frameHeight := int(bmp.Header.Width), int(bmp.Header.Height)
|
||||||
if len(bmp.Pixels) == len(img.Pix) {
|
if len(bmp.Pixels) == frameWidth*frameHeight {
|
||||||
copy(img.Pix, bmp.Pixels)
|
for y := 0; y < frameHeight; y++ {
|
||||||
|
srcOffset := y * frameWidth
|
||||||
|
dstOffset := img.PixOffset(0, y)
|
||||||
|
copy(img.Pix[dstOffset:dstOffset+frameWidth], bmp.Pixels[srcOffset:srcOffset+frameWidth])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out.Image = append(out.Image, img)
|
out.Image = append(out.Image, img)
|
||||||
out.Delay = append(out.Delay, delays[i])
|
out.Delay = append(out.Delay, delays[i])
|
||||||
@@ -300,20 +388,114 @@ func writeGIF(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette,
|
|||||||
return gif.EncodeAll(f, &out)
|
return gif.EncodeAll(f, &out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// glyph5x7 is a minimal 5-column x 7-row bitmap font, hand-drawn rather than
|
||||||
|
// pulled from golang.org/x/image/font/basicfont: that package is only a
|
||||||
|
// test-only transitive dependency of hacked, never actually fetched into
|
||||||
|
// mdlp-image's offline module cache, so using it here would need a
|
||||||
|
// Dockerfile change and an image rebuild for a handful of label glyphs.
|
||||||
|
// Only the characters writeFilmstrip's "FRAME N" labels ever use are
|
||||||
|
// defined; '#' is a lit pixel, anything else is blank.
|
||||||
|
var glyph5x7 = map[rune][7]string{
|
||||||
|
' ': {" ", " ", " ", " ", " ", " ", " "},
|
||||||
|
'0': {".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."},
|
||||||
|
'1': {"..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."},
|
||||||
|
'2': {".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"},
|
||||||
|
'3': {".###.", "#...#", "....#", "..##.", "....#", "#...#", ".###."},
|
||||||
|
'4': {"...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."},
|
||||||
|
'5': {"#####", "#....", "####.", "....#", "....#", "#...#", ".###."},
|
||||||
|
'6': {".###.", "#....", "#....", "####.", "#...#", "#...#", ".###."},
|
||||||
|
'7': {"#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..."},
|
||||||
|
'8': {".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."},
|
||||||
|
'9': {".###.", "#...#", "#...#", ".####", "....#", "....#", ".###."},
|
||||||
|
'A': {".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"},
|
||||||
|
'E': {"#####", "#....", "#....", "####.", "#....", "#....", "#####"},
|
||||||
|
'F': {"#####", "#....", "#....", "####.", "#....", "#....", "#...."},
|
||||||
|
'M': {"#...#", "##.##", "#.#.#", "#...#", "#...#", "#...#", "#...#"},
|
||||||
|
'R': {"####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawLabel renders s (upper-cased, unknown runes blank) at (x, y) in a
|
||||||
|
// fixed 5x7 pixel font, one pixel of gap between characters - small and
|
||||||
|
// legible enough for a "FRAME N" strip label, with no font-rendering
|
||||||
|
// dependency.
|
||||||
|
func drawLabel(canvas draw.Image, x, y int, s string, c color.Color) {
|
||||||
|
col := x
|
||||||
|
for _, r := range strings.ToUpper(s) {
|
||||||
|
glyph, ok := glyph5x7[r]
|
||||||
|
if !ok {
|
||||||
|
glyph = glyph5x7[' ']
|
||||||
|
}
|
||||||
|
for row, line := range glyph {
|
||||||
|
for bit, ch := range line {
|
||||||
|
if ch == '#' {
|
||||||
|
canvas.Set(col+bit, y+row, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
col += 6 // 5px glyph + 1px gap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFilmstrip renders every frame side by side at native size, each
|
||||||
|
// labeled "frame N", separated by a thin gap, as a static complement to the
|
||||||
|
// looping GIF - the index page otherwise only shows the loop in motion,
|
||||||
|
// never all of an animation's frames side by side for comparison, or which
|
||||||
|
// index a given frame is.
|
||||||
|
func writeFilmstrip(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
const gap = 2
|
||||||
|
const labelHeight = 14
|
||||||
|
totalWidth, maxHeight := -gap, 0
|
||||||
|
for _, bmp := range frames {
|
||||||
|
totalWidth += int(bmp.Header.Width) + gap
|
||||||
|
if int(bmp.Header.Height) > maxHeight {
|
||||||
|
maxHeight = int(bmp.Header.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, totalWidth, labelHeight+maxHeight))
|
||||||
|
draw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.Black), image.Point{}, draw.Src)
|
||||||
|
labelColor := color.RGBA{200, 200, 200, 255}
|
||||||
|
|
||||||
|
x := 0
|
||||||
|
for i, bmp := range frames {
|
||||||
|
drawLabel(canvas, x+1, 2, fmt.Sprintf("FRAME %d", i), labelColor)
|
||||||
|
|
||||||
|
colorPal := paletteFor(bmp, fallbackPal)
|
||||||
|
rect := image.Rect(0, 0, int(bmp.Header.Width), int(bmp.Header.Height))
|
||||||
|
img := image.NewPaletted(rect, colorPal)
|
||||||
|
if len(bmp.Pixels) == len(img.Pix) {
|
||||||
|
copy(img.Pix, bmp.Pixels)
|
||||||
|
}
|
||||||
|
dstRect := image.Rect(x, labelHeight, x+int(bmp.Header.Width), labelHeight+int(bmp.Header.Height))
|
||||||
|
draw.Draw(canvas, dstRect, img, image.Point{}, draw.Src)
|
||||||
|
x += int(bmp.Header.Width) + gap
|
||||||
|
}
|
||||||
|
return png.Encode(f, canvas)
|
||||||
|
}
|
||||||
|
|
||||||
// safeRenderMultiBlockBitmap recovers from panics in hacked's own decode
|
// safeRenderMultiBlockBitmap recovers from panics in hacked's own decode
|
||||||
// code (malformed or non-image compound resources can crash rle.Decompress
|
// code (malformed or non-image compound resources can crash rle.Decompress
|
||||||
// rather than return an error) so one bad resource never aborts a scan that
|
// rather than return an error) so one bad resource never aborts a scan that
|
||||||
// is meant to be re-run repeatedly during modding.
|
// is meant to be re-run repeatedly during modding.
|
||||||
func safeRenderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (entry galleryEntry, err error) {
|
func safeRenderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix string) (entry galleryEntry, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
err = fmt.Errorf("panic: %v", r)
|
err = fmt.Errorf("panic: %v", r)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return renderMultiBlockBitmap(view, fallbackPal, sourceFile, id, lang, outDir)
|
return renderMultiBlockBitmap(view, fallbackPal, sourceFile, id, lang, outDir, suffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (galleryEntry, error) {
|
// suffix distinguishes a base-layer comparison render ("_base") from the
|
||||||
|
// normal, winning-layer one ("") for the same resource ID/language, so the
|
||||||
|
// two don't collide on the same output filenames in outDir.
|
||||||
|
func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix string) (galleryEntry, error) {
|
||||||
frames, err := decodeFrames(view)
|
frames, err := decodeFrames(view)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return galleryEntry{}, err
|
return galleryEntry{}, err
|
||||||
@@ -323,10 +505,17 @@ func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sour
|
|||||||
}
|
}
|
||||||
delays := make([]int, len(frames))
|
delays := make([]int, len(frames))
|
||||||
for i := range delays {
|
for i := range delays {
|
||||||
delays[i] = 10 // 100ms - no explicit timing exists for this raw form
|
delays[i] = 10 * gallerySpeedFactor // no explicit timing exists for this raw form
|
||||||
}
|
}
|
||||||
gifName := fmt.Sprintf("%04X_%s.gif", int(id), lang.String())
|
gifName := fmt.Sprintf("%04X_%s%s.gif", int(id), lang.String(), suffix)
|
||||||
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays); err != nil {
|
// No separate declared canvas size exists for this content type (unlike
|
||||||
|
// Animation descriptors below) - writeGIF falls back to the largest
|
||||||
|
// individual frame.
|
||||||
|
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays, 0, 0); err != nil {
|
||||||
|
return galleryEntry{}, err
|
||||||
|
}
|
||||||
|
filmstripName := fmt.Sprintf("%04X_%s%s_frames.png", int(id), lang.String(), suffix)
|
||||||
|
if err := writeFilmstrip(filepath.Join(outDir, filmstripName), frames, fallbackPal); err != nil {
|
||||||
return galleryEntry{}, err
|
return galleryEntry{}, err
|
||||||
}
|
}
|
||||||
return galleryEntry{
|
return galleryEntry{
|
||||||
@@ -335,21 +524,24 @@ func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sour
|
|||||||
width: int(frames[0].Header.Width), height: int(frames[0].Header.Height),
|
width: int(frames[0].Header.Width), height: int(frames[0].Header.Height),
|
||||||
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
||||||
gifName: gifName,
|
gifName: gifName,
|
||||||
|
filmstripName: filmstripName,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// safeRenderAnimationDescriptor is safeRenderMultiBlockBitmap's counterpart
|
// safeRenderAnimationDescriptor is safeRenderMultiBlockBitmap's counterpart
|
||||||
// for the Animation-descriptor path.
|
// for the Animation-descriptor path.
|
||||||
func safeRenderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (entry galleryEntry, err error) {
|
func safeRenderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix string) (entry galleryEntry, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
err = fmt.Errorf("panic: %v", r)
|
err = fmt.Errorf("panic: %v", r)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return renderAnimationDescriptor(mod, fallbackPal, sourceFile, id, lang, outDir)
|
return renderAnimationDescriptor(mod, fallbackPal, sourceFile, id, lang, outDir, suffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (galleryEntry, error) {
|
// suffix distinguishes a base-layer comparison render ("_base") from the
|
||||||
|
// normal, winning-layer one ("") - see renderMultiBlockBitmap.
|
||||||
|
func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix string) (galleryEntry, error) {
|
||||||
view, err := mod.LocalizedResources(lang).Select(id)
|
view, err := mod.LocalizedResources(lang).Select(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return galleryEntry{}, err
|
return galleryEntry{}, err
|
||||||
@@ -375,7 +567,6 @@ func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourc
|
|||||||
|
|
||||||
var frames []*bitmap.Bitmap
|
var frames []*bitmap.Bitmap
|
||||||
var delays []int
|
var delays []int
|
||||||
var prevPixels []byte
|
|
||||||
frameIndex := 0
|
frameIndex := 0
|
||||||
for _, entry := range anim.Entries {
|
for _, entry := range anim.Entries {
|
||||||
for frameIndex <= int(entry.LastFrame) {
|
for frameIndex <= int(entry.LastFrame) {
|
||||||
@@ -388,22 +579,21 @@ func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourc
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
}
|
}
|
||||||
|
// Always a fresh zero buffer, never a previous frame's real
|
||||||
|
// pixels - see decodeFrames' own comment for why that's what
|
||||||
|
// matches the game's actual RSD8 decoder (and hacked's own
|
||||||
|
// bitmap preview) rather than ghosting stale content into a
|
||||||
|
// skip run.
|
||||||
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||||
size := int(w) * int(h)
|
return make([]byte, int(w)*int(h)), nil
|
||||||
buf := make([]byte, size)
|
|
||||||
if len(prevPixels) == size {
|
|
||||||
copy(buf, prevPixels)
|
|
||||||
}
|
|
||||||
return buf, nil
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
}
|
}
|
||||||
prevPixels = bmp.Pixels
|
|
||||||
frames = append(frames, bmp)
|
frames = append(frames, bmp)
|
||||||
delay := int(entry.FrameTime) / 10
|
delay := int(entry.FrameTime) / 10 * gallerySpeedFactor
|
||||||
if delay <= 0 {
|
if delay <= 0 {
|
||||||
delay = 10
|
delay = 10 * gallerySpeedFactor
|
||||||
}
|
}
|
||||||
delays = append(delays, delay)
|
delays = append(delays, delay)
|
||||||
frameIndex++
|
frameIndex++
|
||||||
@@ -413,8 +603,14 @@ func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourc
|
|||||||
return galleryEntry{}, fmt.Errorf("no frames decoded")
|
return galleryEntry{}, fmt.Errorf("no frames decoded")
|
||||||
}
|
}
|
||||||
|
|
||||||
gifName := fmt.Sprintf("%04X_%s.gif", int(id), lang.String())
|
gifName := fmt.Sprintf("%04X_%s%s.gif", int(id), lang.String(), suffix)
|
||||||
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays); err != nil {
|
// anim.Width/Height is this format's own declared canvas, which can be
|
||||||
|
// larger than any single frame's stored (possibly delta-cropped) size.
|
||||||
|
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays, int(anim.Width), int(anim.Height)); err != nil {
|
||||||
|
return galleryEntry{}, err
|
||||||
|
}
|
||||||
|
filmstripName := fmt.Sprintf("%04X_%s%s_frames.png", int(id), lang.String(), suffix)
|
||||||
|
if err := writeFilmstrip(filepath.Join(outDir, filmstripName), frames, fallbackPal); err != nil {
|
||||||
return galleryEntry{}, err
|
return galleryEntry{}, err
|
||||||
}
|
}
|
||||||
return galleryEntry{
|
return galleryEntry{
|
||||||
@@ -423,9 +619,32 @@ func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourc
|
|||||||
width: int(anim.Width), height: int(anim.Height),
|
width: int(anim.Width), height: int(anim.Height),
|
||||||
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
||||||
gifName: gifName,
|
gifName: gifName,
|
||||||
|
filmstripName: filmstripName,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderForMod resolves id/lang through m (a specific layering - the full
|
||||||
|
// mod, or just a base game directory on its own) and renders it, the same
|
||||||
|
// content-type dispatch the main scan uses, so it can also be called a
|
||||||
|
// second time against a base-only Mod to render a comparison. ok is false
|
||||||
|
// (with a nil error) when id/lang isn't an eligible animated resource in m
|
||||||
|
// at all, as opposed to being eligible but failing to decode or render.
|
||||||
|
func renderForMod(m *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir, suffix string, minFrames int) (entry galleryEntry, ok bool, err error) {
|
||||||
|
view, err := m.LocalizedResources(lang).Select(id)
|
||||||
|
if err != nil {
|
||||||
|
return galleryEntry{}, false, nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case view.ContentType() == resource.Animation:
|
||||||
|
entry, err = safeRenderAnimationDescriptor(m, fallbackPal, sourceFile, id, lang, outDir, suffix)
|
||||||
|
return entry, true, err
|
||||||
|
case view.ContentType() == resource.Bitmap && view.BlockCount() >= minFrames:
|
||||||
|
entry, err = safeRenderMultiBlockBitmap(view, fallbackPal, sourceFile, id, lang, outDir, suffix)
|
||||||
|
return entry, true, err
|
||||||
|
}
|
||||||
|
return galleryEntry{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
// contentTypeName maps a galleryEntry.kind back to the resource.ContentType
|
// contentTypeName maps a galleryEntry.kind back to the resource.ContentType
|
||||||
// name mdlp will need (its own decode path differs: an "Animation"
|
// name mdlp will need (its own decode path differs: an "Animation"
|
||||||
// descriptor's frames live at anim.ResourceID+N, a "Bitmap"'s frames are
|
// descriptor's frames live at anim.ResourceID+N, a "Bitmap"'s frames are
|
||||||
@@ -460,40 +679,76 @@ func writeIndex(path string, entries []galleryEntry) error {
|
|||||||
b.WriteString("#mdl-output{width:98%;font-family:monospace;font-size:12px;margin-top:8px;background:#000;color:#9f9;border:1px solid #444}")
|
b.WriteString("#mdl-output{width:98%;font-family:monospace;font-size:12px;margin-top:8px;background:#000;color:#9f9;border:1px solid #444}")
|
||||||
b.WriteString(".card{display:inline-block;margin:8px;padding:8px;background:#222;vertical-align:top}")
|
b.WriteString(".card{display:inline-block;margin:8px;padding:8px;background:#222;vertical-align:top}")
|
||||||
b.WriteString(".card img{image-rendering:pixelated;background:#000;display:block}")
|
b.WriteString(".card img{image-rendering:pixelated;background:#000;display:block}")
|
||||||
|
b.WriteString(".card img.frames{margin-top:4px;max-width:100%}")
|
||||||
|
b.WriteString(".compare{display:flex;gap:10px}")
|
||||||
|
b.WriteString(".compare-label{font-size:11px;color:#8ab;text-transform:uppercase;margin-bottom:2px}")
|
||||||
b.WriteString(".meta{font-size:12px;color:#aaa;margin-top:4px}")
|
b.WriteString(".meta{font-size:12px;color:#aaa;margin-top:4px}")
|
||||||
b.WriteString(".meta label{cursor:pointer}")
|
b.WriteString(".meta label{cursor:pointer}")
|
||||||
b.WriteString("#basket{min-height:60px;background:#1a2a1a;border:1px dashed #4a6a4a;padding:8px;margin-bottom:16px}")
|
b.WriteString("#basket{min-height:60px;background:#1a2a1a;border:1px dashed #4a6a4a;padding:8px;margin-bottom:16px}")
|
||||||
b.WriteString("#basket:empty::before{content:'(nothing selected yet - check animations below to add them here)';color:#678;font-style:italic}")
|
b.WriteString("#basket:empty::before{content:'(nothing selected yet - check animations below to add them here)';color:#678;font-style:italic}")
|
||||||
b.WriteString("#basket .card{background:#2a3a2a}</style></head><body>")
|
b.WriteString("#basket .card{background:#2a3a2a}</style></head><body>")
|
||||||
|
|
||||||
|
// One quick-filter button per group, in -data order.
|
||||||
|
groupNames := make(map[int]string)
|
||||||
|
for _, e := range entries {
|
||||||
|
groupNames[e.dirIndex] = e.group
|
||||||
|
}
|
||||||
|
groupIndexes := make([]int, 0, len(groupNames))
|
||||||
|
for i := range groupNames {
|
||||||
|
groupIndexes = append(groupIndexes, i)
|
||||||
|
}
|
||||||
|
sort.Ints(groupIndexes)
|
||||||
|
groupButtons := `<br><span id="group-filters">Groups:`
|
||||||
|
for _, i := range groupIndexes {
|
||||||
|
name := html.EscapeString(groupNames[i])
|
||||||
|
groupButtons += fmt.Sprintf(` <button type="button" class="group-filter" data-filter="%s">%s</button>`, name, name)
|
||||||
|
}
|
||||||
|
groupButtons += `</span>`
|
||||||
|
|
||||||
b.WriteString(`<div id="controls">` +
|
b.WriteString(`<div id="controls">` +
|
||||||
`<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>` +
|
||||||
|
`<button id="clear-filter" type="button">Clear filter</button>` +
|
||||||
`<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>` +
|
groupButtons +
|
||||||
|
`<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>`)
|
||||||
|
|
||||||
fmt.Fprintf(&b, "<h1>%d animated resource(s)</h1>", len(entries))
|
fmt.Fprintf(&b, "<h1>%d animated resource(s)</h1>", len(entries))
|
||||||
|
|
||||||
// Animation descriptors and multi-block bitmaps are genuinely distinct
|
// Cards are sectioned per -data layer (the base game, then each mod), and
|
||||||
// content types (different decode path, see contentTypeName) - grouped
|
// within one by content type: animation descriptors and multi-block
|
||||||
// into separate sections rather than interleaved.
|
// bitmaps are genuinely distinct (different decode path, see
|
||||||
var animations, bitmaps []galleryEntry
|
// contentTypeName).
|
||||||
|
type section struct {
|
||||||
|
group string
|
||||||
|
dirIndex int
|
||||||
|
animations, bitmaps []galleryEntry
|
||||||
|
}
|
||||||
|
var sections []*section
|
||||||
|
sectionByDir := make(map[int]*section)
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
|
sec := sectionByDir[e.dirIndex]
|
||||||
|
if sec == nil {
|
||||||
|
sec = §ion{group: e.group, dirIndex: e.dirIndex}
|
||||||
|
sectionByDir[e.dirIndex] = sec
|
||||||
|
sections = append(sections, sec)
|
||||||
|
}
|
||||||
if contentTypeName(e.kind) == "Animation" {
|
if contentTypeName(e.kind) == "Animation" {
|
||||||
animations = append(animations, e)
|
sec.animations = append(sec.animations, e)
|
||||||
} else {
|
} else {
|
||||||
bitmaps = append(bitmaps, e)
|
sec.bitmaps = append(sec.bitmaps, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sort.Slice(sections, func(i, j int) bool { return sections[i].dirIndex < sections[j].dirIndex })
|
||||||
|
|
||||||
writeCards := func(group []galleryEntry, idPrefix string) {
|
writeCards := func(group []galleryEntry, idPrefix string) {
|
||||||
for i, e := range group {
|
for i, e := range group {
|
||||||
@@ -503,11 +758,26 @@ func writeIndex(path string, entries []galleryEntry) error {
|
|||||||
}
|
}
|
||||||
search := strings.ToLower(strings.Join([]string{
|
search := strings.ToLower(strings.Join([]string{
|
||||||
fmt.Sprintf("0x%x", int(e.id)), fmt.Sprintf("%d", int(e.id)),
|
fmt.Sprintf("0x%x", int(e.id)), fmt.Sprintf("%d", int(e.id)),
|
||||||
e.lang.String(), e.kind, e.sourceFile,
|
e.lang.String(), e.kind, e.sourceFile, e.group,
|
||||||
fmt.Sprintf("%dx%d", e.width, e.height), transparency,
|
fmt.Sprintf("%dx%d", e.width, e.height), transparency,
|
||||||
}, " "))
|
}, " "))
|
||||||
fmt.Fprintf(&b, "<div class=\"card\" data-search=\"%s\">", html.EscapeString(search))
|
fmt.Fprintf(&b, "<div class=\"card\" data-search=\"%s\">", html.EscapeString(search))
|
||||||
|
if e.baseGifName != "" {
|
||||||
|
b.WriteString("<div class=\"compare\">")
|
||||||
|
b.WriteString("<div class=\"compare-col\"><div class=\"compare-label\">base</div>")
|
||||||
|
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.baseGifName), e.width*2, e.height*2)
|
||||||
|
fmt.Fprintf(&b, "<img class=\"frames\" src=\"gifs/%s\" title=\"every base frame, in order\">", html.EscapeString(e.baseFilmstripName))
|
||||||
|
b.WriteString("</div>")
|
||||||
|
b.WriteString("<div class=\"compare-col\"><div class=\"compare-label\">this build</div>")
|
||||||
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
|
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
|
||||||
|
fmt.Fprintf(&b, "<img class=\"frames\" src=\"gifs/%s\" title=\"every frame, in order\">", html.EscapeString(e.filmstripName))
|
||||||
|
b.WriteString("</div></div>")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
|
||||||
|
if e.filmstripName != "" {
|
||||||
|
fmt.Fprintf(&b, "<img class=\"frames\" src=\"gifs/%s\" title=\"every frame, in order\">", html.EscapeString(e.filmstripName))
|
||||||
|
}
|
||||||
|
}
|
||||||
fmt.Fprintf(&b, "<div class=\"meta\"><label><input type=\"checkbox\" class=\"sel\" id=\"sel-%s-%d\" data-id=\"%s\"> ",
|
fmt.Fprintf(&b, "<div class=\"meta\"><label><input type=\"checkbox\" class=\"sel\" id=\"sel-%s-%d\" data-id=\"%s\"> ",
|
||||||
idPrefix, i, html.EscapeString(mdlResourceID(e.id, e.lang)))
|
idPrefix, i, html.EscapeString(mdlResourceID(e.id, e.lang)))
|
||||||
fmt.Fprintf(&b, "ID 0x%X (%d) · %s</label><br>%dx%d, %d frame(s), %s · %s · %s</div>",
|
fmt.Fprintf(&b, "ID 0x%X (%d) · %s</label><br>%dx%d, %d frame(s), %s · %s · %s</div>",
|
||||||
@@ -516,15 +786,24 @@ func writeIndex(path string, entries []galleryEntry) error {
|
|||||||
b.WriteString("</div>")
|
b.WriteString("</div>")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "<h2>Animations (%d)</h2>", len(animations))
|
for _, sec := range sections {
|
||||||
writeCards(animations, "a")
|
b.WriteString("<div class=\"group\">")
|
||||||
fmt.Fprintf(&b, "<h2>Bitmaps (%d)</h2>", len(bitmaps))
|
fmt.Fprintf(&b, "<h2>%s (%d)</h2>", html.EscapeString(sec.group), len(sec.animations)+len(sec.bitmaps))
|
||||||
writeCards(bitmaps, "b")
|
if len(sec.animations) > 0 {
|
||||||
|
fmt.Fprintf(&b, "<h3>Animations (%d)</h3>", len(sec.animations))
|
||||||
|
writeCards(sec.animations, fmt.Sprintf("%da", sec.dirIndex))
|
||||||
|
}
|
||||||
|
if len(sec.bitmaps) > 0 {
|
||||||
|
fmt.Fprintf(&b, "<h3>Bitmaps (%d)</h3>", len(sec.bitmaps))
|
||||||
|
writeCards(sec.bitmaps, fmt.Sprintf("%db", sec.dirIndex))
|
||||||
|
}
|
||||||
|
b.WriteString("</div>")
|
||||||
|
}
|
||||||
|
|
||||||
b.WriteString(`<script>
|
b.WriteString(`<script>
|
||||||
var basket = document.getElementById('basket');
|
var basket = document.getElementById('basket');
|
||||||
// Where each card lives when not selected, so unchecking it puts it back
|
// Where each card lives when not selected, so unchecking it puts it back
|
||||||
// in its original spot (Animations/Bitmaps section, original order)
|
// in its original spot (its group's Animations/Bitmaps section, original order)
|
||||||
// instead of just appending it back at the end.
|
// instead of just appending it back at the end.
|
||||||
var cardHomes = new Map();
|
var cardHomes = new Map();
|
||||||
Array.prototype.forEach.call(document.querySelectorAll('.card'), function (card) {
|
Array.prototype.forEach.call(document.querySelectorAll('.card'), function (card) {
|
||||||
@@ -584,9 +863,24 @@ function applyFilter() {
|
|||||||
card.style.display = match ? '' : 'none';
|
card.style.display = match ? '' : 'none';
|
||||||
if (match) shown++;
|
if (match) shown++;
|
||||||
});
|
});
|
||||||
|
// A group with no visible card left hides its headings too.
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.group'), function (group) {
|
||||||
|
var visible = Array.prototype.some.call(group.querySelectorAll('.card'), function (card) {
|
||||||
|
return card.style.display !== 'none';
|
||||||
|
});
|
||||||
|
group.style.display = visible ? '' : 'none';
|
||||||
|
});
|
||||||
document.getElementById('shown-count').textContent = shown + ' / ' + cards.length + ' shown';
|
document.getElementById('shown-count').textContent = shown + ' / ' + cards.length + ' shown';
|
||||||
}
|
}
|
||||||
document.getElementById('search').addEventListener('input', applyFilter);
|
document.getElementById('search').addEventListener('input', applyFilter);
|
||||||
|
function setFilter(text) {
|
||||||
|
document.getElementById('search').value = text;
|
||||||
|
applyFilter();
|
||||||
|
}
|
||||||
|
document.getElementById('clear-filter').addEventListener('click', function () { setFilter(''); });
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.group-filter'), function (btn) {
|
||||||
|
btn.addEventListener('click', function () { setFilter(btn.dataset.filter); });
|
||||||
|
});
|
||||||
applyFilter();
|
applyFilter();
|
||||||
</script>`)
|
</script>`)
|
||||||
|
|
||||||
|
|||||||
+330
-43
@@ -13,10 +13,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
"image/png"
|
"image/png"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -46,12 +49,24 @@ 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. ConsiderPreviousFrames, same id format as
|
||||||
|
// Resources, turns on stabilizeTemporalFrames (below) for just the
|
||||||
|
// resources listed, not the whole definition. An id here that isn't also in
|
||||||
|
// Resources is simply never looked up, not an error.
|
||||||
type upscaleDef struct {
|
type upscaleDef struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Scale int `json:"scale"`
|
Scale int `json:"scale"`
|
||||||
Resources []string `json:"resources"`
|
Resources []string `json:"resources"`
|
||||||
|
ConsiderPreviousFrames []string `json:"considerPreviousFrames"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// resourceLangKey identifies one resource (ignoring frame/block index) -
|
||||||
|
// what ConsiderPreviousFrames' entries are matched against, parsed through
|
||||||
|
// the same parseMdlResource as Resources itself.
|
||||||
|
type resourceLangKey struct {
|
||||||
|
id resource.ID
|
||||||
|
lang resource.Language
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseMdlResource splits an animgallery-generated resource string
|
// parseMdlResource splits an animgallery-generated resource string
|
||||||
@@ -85,6 +100,7 @@ type resourceFrames struct {
|
|||||||
lang resource.Language
|
lang resource.Language
|
||||||
contentType resource.ContentType
|
contentType resource.ContentType
|
||||||
frames []*bitmap.Bitmap
|
frames []*bitmap.Bitmap
|
||||||
|
considerPreviousFrames bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyUpscaleDef(mod *world.Mod, pal bitmap.Palette, defFile string) error {
|
func applyUpscaleDef(mod *world.Mod, pal bitmap.Palette, defFile string) error {
|
||||||
@@ -103,6 +119,15 @@ func applyUpscaleDef(mod *world.Mod, pal bitmap.Palette, defFile string) error {
|
|||||||
def.Scale = 2
|
def.Scale = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
considerSet := make(map[resourceLangKey]bool, len(def.ConsiderPreviousFrames))
|
||||||
|
for _, label := range def.ConsiderPreviousFrames {
|
||||||
|
id, lang, err := parseMdlResource(label)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("considerPreviousFrames: %w", err)
|
||||||
|
}
|
||||||
|
considerSet[resourceLangKey{id, lang}] = true
|
||||||
|
}
|
||||||
|
|
||||||
all := make([]resourceFrames, 0, len(def.Resources))
|
all := make([]resourceFrames, 0, len(def.Resources))
|
||||||
for i, label := range def.Resources {
|
for i, label := range def.Resources {
|
||||||
fmt.Printf(" decoding %d/%d: %s\n", i+1, len(def.Resources), label)
|
fmt.Printf(" decoding %d/%d: %s\n", i+1, len(def.Resources), label)
|
||||||
@@ -142,7 +167,10 @@ func applyUpscaleDef(mod *world.Mod, pal bitmap.Palette, defFile string) error {
|
|||||||
if len(frames) == 0 {
|
if len(frames) == 0 {
|
||||||
return fmt.Errorf("%s: no frames decoded", label)
|
return fmt.Errorf("%s: no frames decoded", label)
|
||||||
}
|
}
|
||||||
all = append(all, resourceFrames{label: label, id: id, lang: lang, contentType: contentType, frames: frames})
|
all = append(all, resourceFrames{
|
||||||
|
label: label, id: id, lang: lang, contentType: contentType, frames: frames,
|
||||||
|
considerPreviousFrames: considerSet[resourceLangKey{id, lang}],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
upscaledByResource, err := upscaleBatch(all, pal, def.Model, def.Scale)
|
upscaledByResource, err := upscaleBatch(all, pal, def.Model, def.Scale)
|
||||||
@@ -169,31 +197,25 @@ func applyUpscaleDef(mod *world.Mod, pal bitmap.Palette, defFile string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// decodeViewFrames reads every block of a compound bitmap resource as a
|
// decodeViewFrames reads every block of a compound bitmap resource as a
|
||||||
// sequence of frames, each one RLE-diffed against the previous - mirrors
|
// sequence of frames - mirrors animgallery's decodeFrames, including always
|
||||||
// animgallery's decodeFrames. Each block declares its own width/height, so
|
// handing DecodeReferenced a fresh zero buffer rather than the previous
|
||||||
// the reference buffer handed to DecodeReferenced must match that block's
|
// block's real pixels (see that function's own comment for why: the game's
|
||||||
// own size, not blindly reuse the previous frame's.
|
// actual RSD8 decoder never carries a reference frame's pixels into a skip
|
||||||
|
// run, and neither does hacked's own multi-block Bitmap preview).
|
||||||
func decodeViewFrames(view resource.View) ([]*bitmap.Bitmap, error) {
|
func decodeViewFrames(view resource.View) ([]*bitmap.Bitmap, error) {
|
||||||
count := view.BlockCount()
|
count := view.BlockCount()
|
||||||
frames := make([]*bitmap.Bitmap, 0, count)
|
frames := make([]*bitmap.Bitmap, 0, count)
|
||||||
var prevPixels []byte
|
|
||||||
for i := 0; i < count; i++ {
|
for i := 0; i < count; i++ {
|
||||||
reader, err := view.Block(i)
|
reader, err := view.Block(i)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("block %d: %w", i, err)
|
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||||
}
|
}
|
||||||
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||||
size := int(w) * int(h)
|
return make([]byte, int(w)*int(h)), nil
|
||||||
buf := make([]byte, size)
|
|
||||||
if len(prevPixels) == size {
|
|
||||||
copy(buf, prevPixels)
|
|
||||||
}
|
|
||||||
return buf, nil
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("block %d: %w", i, err)
|
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||||
}
|
}
|
||||||
prevPixels = bmp.Pixels
|
|
||||||
frames = append(frames, bmp)
|
frames = append(frames, bmp)
|
||||||
}
|
}
|
||||||
return frames, nil
|
return frames, nil
|
||||||
@@ -201,7 +223,8 @@ func decodeViewFrames(view resource.View) ([]*bitmap.Bitmap, error) {
|
|||||||
|
|
||||||
// decodeAnimationFrames resolves an Animation descriptor's actual frame
|
// decodeAnimationFrames resolves an Animation descriptor's actual frame
|
||||||
// bitmaps, stored as separate resources at anim.ResourceID+N - mirrors
|
// bitmaps, stored as separate resources at anim.ResourceID+N - mirrors
|
||||||
// animgallery's renderAnimationDescriptor.
|
// animgallery's renderAnimationDescriptor, including the same always-zero
|
||||||
|
// reference buffer (see decodeViewFrames' comment).
|
||||||
func decodeAnimationFrames(mod *world.Mod, lang resource.Language, anim bitmap.Animation) ([]*bitmap.Bitmap, error) {
|
func decodeAnimationFrames(mod *world.Mod, lang resource.Language, anim bitmap.Animation) ([]*bitmap.Bitmap, error) {
|
||||||
lastFrame := 0
|
lastFrame := 0
|
||||||
for _, entry := range anim.Entries {
|
for _, entry := range anim.Entries {
|
||||||
@@ -210,7 +233,6 @@ func decodeAnimationFrames(mod *world.Mod, lang resource.Language, anim bitmap.A
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var frames []*bitmap.Bitmap
|
var frames []*bitmap.Bitmap
|
||||||
var prevPixels []byte
|
|
||||||
for frameIndex := 0; frameIndex <= lastFrame; frameIndex++ {
|
for frameIndex := 0; frameIndex <= lastFrame; frameIndex++ {
|
||||||
frameView, err := mod.LocalizedResources(lang).Select(anim.ResourceID.Plus(frameIndex))
|
frameView, err := mod.LocalizedResources(lang).Select(anim.ResourceID.Plus(frameIndex))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -221,17 +243,11 @@ func decodeAnimationFrames(mod *world.Mod, lang resource.Language, anim bitmap.A
|
|||||||
return nil, fmt.Errorf("frame %d: %w", frameIndex, err)
|
return nil, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
}
|
}
|
||||||
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||||
size := int(w) * int(h)
|
return make([]byte, int(w)*int(h)), nil
|
||||||
buf := make([]byte, size)
|
|
||||||
if len(prevPixels) == size {
|
|
||||||
copy(buf, prevPixels)
|
|
||||||
}
|
|
||||||
return buf, nil
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("frame %d: %w", frameIndex, err)
|
return nil, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
}
|
}
|
||||||
prevPixels = bmp.Pixels
|
|
||||||
frames = append(frames, bmp)
|
frames = append(frames, bmp)
|
||||||
}
|
}
|
||||||
return frames, nil
|
return frames, nil
|
||||||
@@ -282,11 +298,95 @@ func bitmapToImage(bmp *bitmap.Bitmap, pal bitmap.Palette) *image.RGBA {
|
|||||||
// sprite renders at the right size but visibly offset from where it should
|
// 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 +394,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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,6 +450,142 @@ func imageToBitmap(img *image.RGBA, pal bitmap.Palette, transparent bool, origAn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// temporalStabilizationMagnitude is the per-pixel RGB delta (0-255) above
|
||||||
|
// which stabilizeTemporalFrames treats a frame's excess deviation from the
|
||||||
|
// rest of its animation as worth correcting - see that function's own
|
||||||
|
// comment for what "excess" means here.
|
||||||
|
const temporalStabilizationMagnitude = 40.0
|
||||||
|
|
||||||
|
// canvasSize returns the pixel dimensions, at the given upscale factor,
|
||||||
|
// every frame of a resource should be treated as occupying for temporal
|
||||||
|
// comparison: the largest individual frame's own native size. Frames can be
|
||||||
|
// individually smaller than that (a storage-level crop against unchanged
|
||||||
|
// pixels, not a smaller sprite - see animgallery's writeGIF, which hit the
|
||||||
|
// same issue), so padToCanvas places each one at its top-left corner of
|
||||||
|
// this shared canvas before stabilizeTemporalFrames compares them pixel for
|
||||||
|
// pixel.
|
||||||
|
func canvasSize(frames []*bitmap.Bitmap, scale int) (int, int) {
|
||||||
|
w, h := 0, 0
|
||||||
|
for _, f := range frames {
|
||||||
|
if int(f.Header.Width) > w {
|
||||||
|
w = int(f.Header.Width)
|
||||||
|
}
|
||||||
|
if int(f.Header.Height) > h {
|
||||||
|
h = int(f.Header.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w * scale, h * scale
|
||||||
|
}
|
||||||
|
|
||||||
|
// padToCanvas returns img unchanged if it is already canvas-sized, or a new
|
||||||
|
// canvasW x canvasH image with img placed at its top-left corner (and
|
||||||
|
// everything else transparent) otherwise.
|
||||||
|
func padToCanvas(img *image.RGBA, canvasW, canvasH int) *image.RGBA {
|
||||||
|
b := img.Bounds()
|
||||||
|
if b.Dx() == canvasW && b.Dy() == canvasH {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, canvasW, canvasH))
|
||||||
|
draw.Draw(canvas, b, img, b.Min, draw.Src)
|
||||||
|
return canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
// resizeNearest resizes src to w x h with nearest-neighbor sampling, so a
|
||||||
|
// real source pixel is compared against itself in stabilizeTemporalFrames
|
||||||
|
// rather than a resize filter's blend of its neighbors.
|
||||||
|
func resizeNearest(src *image.RGBA, w, h int) *image.RGBA {
|
||||||
|
sb := src.Bounds()
|
||||||
|
sw, sh := sb.Dx(), sb.Dy()
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
sy := sb.Min.Y + y*sh/h
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
sx := sb.Min.X + x*sw/w
|
||||||
|
dst.Set(x, y, src.At(sx, sy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// stabilizeTemporalFrames mitigates per-frame upscale hallucination:
|
||||||
|
// upscale_batch.py runs every frame through the ONNX model independently,
|
||||||
|
// with no shared state between frames - a well-known limitation of
|
||||||
|
// single-image super-resolution. On a low-detail/ambiguous source region
|
||||||
|
// the model can guess differently frame to frame, so a detail (an extra
|
||||||
|
// claw, a stray highlight) pops in for a few frames and back out, even
|
||||||
|
// though the real low-resolution source barely changed there. This compares
|
||||||
|
// each frame's upscaled RGB value against the per-pixel median across every
|
||||||
|
// frame of the same (cyclic) animation - does the upscaled difference from
|
||||||
|
// that consensus exceed what the real low-res source's own difference from
|
||||||
|
// its consensus justifies - and corrects it where it does.
|
||||||
|
//
|
||||||
|
// The comparison is against the median across all frames, not just a
|
||||||
|
// frame's immediate predecessor: comparing only against the predecessor can
|
||||||
|
// pull a consistent frame toward an actual outlier whenever the outlier
|
||||||
|
// happens to come first in the sequence. The median is robust to that
|
||||||
|
// regardless of where the outlier frame falls in the loop.
|
||||||
|
//
|
||||||
|
// Needs at least 3 frames for a meaningful consensus (two frames have no
|
||||||
|
// independent third reference to vote with), so fewer than that are left
|
||||||
|
// untouched. Alpha is never touched, and a frame's own near-transparent
|
||||||
|
// pixel is never corrected (it re-quantizes to the background index
|
||||||
|
// regardless of its RGB value - see imageToBitmap's own 0x80 threshold).
|
||||||
|
func stabilizeTemporalFrames(upscaled, sourceResized []*image.RGBA) {
|
||||||
|
n := len(upscaled)
|
||||||
|
if n < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w, h := upscaled[0].Bounds().Dx(), upscaled[0].Bounds().Dy()
|
||||||
|
|
||||||
|
median := func(vals []float64) float64 {
|
||||||
|
sorted := append([]float64(nil), vals...)
|
||||||
|
sort.Float64s(sorted)
|
||||||
|
mid := len(sorted) / 2
|
||||||
|
if len(sorted)%2 == 0 {
|
||||||
|
return (sorted[mid-1] + sorted[mid]) / 2
|
||||||
|
}
|
||||||
|
return sorted[mid]
|
||||||
|
}
|
||||||
|
|
||||||
|
outR, outG, outB := make([]float64, n), make([]float64, n), make([]float64, n)
|
||||||
|
srcR, srcG, srcB := make([]float64, n), make([]float64, n), make([]float64, n)
|
||||||
|
corrected := make([][]byte, n)
|
||||||
|
for i := range upscaled {
|
||||||
|
corrected[i] = append([]byte(nil), upscaled[i].Pix...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
ur, ug, ub, _ := upscaled[i].At(x, y).RGBA()
|
||||||
|
outR[i], outG[i], outB[i] = float64(ur>>8), float64(ug>>8), float64(ub>>8)
|
||||||
|
sr, sg, sb, _ := sourceResized[i].At(x, y).RGBA()
|
||||||
|
srcR[i], srcG[i], srcB[i] = float64(sr>>8), float64(sg>>8), float64(sb>>8)
|
||||||
|
}
|
||||||
|
medR, medG, medB := median(outR), median(outG), median(outB)
|
||||||
|
srcMedR, srcMedG, srcMedB := median(srcR), median(srcG), median(srcB)
|
||||||
|
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
_, _, _, ua := upscaled[i].At(x, y).RGBA()
|
||||||
|
if ua>>8 < 0x80 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outDev := (math.Abs(outR[i]-medR) + math.Abs(outG[i]-medG) + math.Abs(outB[i]-medB)) / 3
|
||||||
|
srcDev := (math.Abs(srcR[i]-srcMedR) + math.Abs(srcG[i]-srcMedG) + math.Abs(srcB[i]-srcMedB)) / 3
|
||||||
|
if excess := outDev - srcDev; excess > temporalStabilizationMagnitude {
|
||||||
|
off := upscaled[i].PixOffset(x, y)
|
||||||
|
corrected[i][off+0] = clamp255(medR)
|
||||||
|
corrected[i][off+1] = clamp255(medG)
|
||||||
|
corrected[i][off+2] = clamp255(medB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range upscaled {
|
||||||
|
copy(upscaled[i].Pix, corrected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// upscaleBatch runs every frame from every given resource through exactly
|
// upscaleBatch runs every frame from every given resource through exactly
|
||||||
// one upscale_batch.py invocation (its own directory-in/directory-out batch
|
// one upscale_batch.py invocation (its own directory-in/directory-out batch
|
||||||
// mode) and returns each resource's upscaled frames in the same order as
|
// mode) and returns each resource's upscaled frames in the same order as
|
||||||
@@ -370,13 +631,10 @@ func upscaleBatch(all []resourceFrames, pal bitmap.Palette, model string, scale
|
|||||||
fmt.Printf(" upscaling %d resource(s), %d frame(s) total (model %s, scale %d):\n",
|
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.
|
||||||
@@ -388,8 +646,8 @@ func upscaleBatch(all []resourceFrames, pal bitmap.Palette, model string, scale
|
|||||||
|
|
||||||
result := make([][]*bitmap.Bitmap, len(all))
|
result := make([][]*bitmap.Bitmap, len(all))
|
||||||
for ri, rf := range all {
|
for ri, rf := range all {
|
||||||
upscaled := make([]*bitmap.Bitmap, len(rf.frames))
|
rgbaFrames := make([]*image.RGBA, len(rf.frames))
|
||||||
for fi, frame := range rf.frames {
|
for fi := range rf.frames {
|
||||||
outPath := filepath.Join(outDir, frameName(ri, fi))
|
outPath := filepath.Join(outDir, frameName(ri, fi))
|
||||||
outFile, err := os.Open(outPath)
|
outFile, err := os.Open(outPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -403,9 +661,29 @@ func upscaleBatch(all []resourceFrames, pal bitmap.Palette, model string, scale
|
|||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
return nil, closeErr
|
return nil, closeErr
|
||||||
}
|
}
|
||||||
rgba := toRGBA(upscaledImg)
|
rgbaFrames[fi] = toRGBA(upscaledImg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rf.considerPreviousFrames {
|
||||||
|
canvasW, canvasH := canvasSize(rf.frames, scale)
|
||||||
|
canvasFrames := make([]*image.RGBA, len(rgbaFrames))
|
||||||
|
sourceResized := make([]*image.RGBA, len(rf.frames))
|
||||||
|
for fi, frame := range rf.frames {
|
||||||
|
canvasFrames[fi] = padToCanvas(rgbaFrames[fi], canvasW, canvasH)
|
||||||
|
resized := resizeNearest(bitmapToImage(frame, pal), int(frame.Header.Width)*scale, int(frame.Header.Height)*scale)
|
||||||
|
sourceResized[fi] = padToCanvas(resized, canvasW, canvasH)
|
||||||
|
}
|
||||||
|
stabilizeTemporalFrames(canvasFrames, sourceResized)
|
||||||
|
for fi := range rgbaFrames {
|
||||||
|
b := rgbaFrames[fi].Bounds()
|
||||||
|
draw.Draw(rgbaFrames[fi], b, canvasFrames[fi], b.Min, draw.Src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upscaled := make([]*bitmap.Bitmap, len(rf.frames))
|
||||||
|
for fi, frame := range rf.frames {
|
||||||
transparent := frame.Header.Flags&bitmap.FlagTransparent != 0
|
transparent := frame.Header.Flags&bitmap.FlagTransparent != 0
|
||||||
upscaled[fi] = imageToBitmap(rgba, pal, transparent, frame.Header.Area)
|
upscaled[fi] = imageToBitmap(rgbaFrames[fi], pal, transparent, frame.Header.Area)
|
||||||
}
|
}
|
||||||
result[ri] = upscaled
|
result[ri] = upscaled
|
||||||
}
|
}
|
||||||
@@ -426,17 +704,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