Extends the MDL system beyond text overrides: bitmaps/<label>.json names a model/scale and resource list, and mdlp extracts each resource's frames, runs them through an ONNX Runtime CPU upscaler baked into mdlp-image, re-quantizes to the game's palette, and writes the result back - same build-time pipeline as message overrides. Adds animgallery, a standalone viewer that renders any data directory's animated resources as an HTML/GIF gallery with a checkbox-driven MDL JSON generator, and -only-changed to preview just what a mod overrides against the base game. Fixes a real data race in hacked's ChangeNotifier.hashAll() (found via go build -race causing non-deterministic resource counts), via a new numbered patch rather than editing the vendored source. Adds engine-patches/, a general-fixes counterpart to android/engine-patches/ now staged and applied to the desktop builds too (previously unpatched): a real GL_UNPACK_ROW_LENGTH stride bug, an audio error-logging improvement, and two critter-rendering fixes - mod art was losing to the base game's own re-opened resource files, and critters rendered at the wrong size/position once that was fixed, both from how the billboard renderer derives on-screen size and placement straight from bitmap pixel dimensions. Updates README.md and AGENTS.md to document engine-patches/.
This commit is contained in:
+43
-1
@@ -13,8 +13,24 @@
|
||||
# mods - builds every mod under mods/ via that image, offline.
|
||||
# Set MODS="name1 name2" to build only those. Output
|
||||
# lands in dist/mods/.
|
||||
# animgallery_base - browses res/assets/ss_ee/data alone: every animated
|
||||
# resource as a looping GIF in build/gallery-base/.
|
||||
# animgallery_mods - same, but with every built mod under dist/mods/
|
||||
# layered on top (later wins), in build/gallery-mods/ -
|
||||
# shows what a mod actually changes without launching
|
||||
# the game. Run `make mods` first.
|
||||
# animgallery_dist - every built mod under dist/mods/ layered on top of
|
||||
# the base game, like animgallery_mods, but restricted
|
||||
# to only the resources a mod actually overrides, in
|
||||
# build/gallery-dist/ - a focused "what did my mods
|
||||
# change" view, in the base game's own colors (unlike
|
||||
# a mod's own output alone, which has no palette
|
||||
# resource and would render in grayscale).
|
||||
# Run `make mods` first.
|
||||
# (animgallery_base/_mods/_dist all write into this
|
||||
# subproject's own build/, not the repo root's.)
|
||||
# clean - removes dist/.
|
||||
.PHONY: all build-image hacked mdlp-image mods clean
|
||||
.PHONY: all build-image hacked mdlp-image mods animgallery_base animgallery_mods animgallery_dist clean
|
||||
|
||||
export MODS
|
||||
|
||||
@@ -45,5 +61,31 @@ mods:
|
||||
./mdlp-run-image.sh; \
|
||||
fi
|
||||
|
||||
animgallery_base:
|
||||
./gen_animgallery.sh -data res/assets/ss_ee/data -out modding/build/gallery-base
|
||||
|
||||
# Layers every built mod's mod/ folder on top of the base assets, later
|
||||
# wins - same discovery pattern as res/run-mods.sh.
|
||||
animgallery_mods:
|
||||
@ARGS="-data res/assets/ss_ee/data"; \
|
||||
for d in dist/mods/*/mod; do \
|
||||
[ -d "$$d" ] && ARGS="$$ARGS -data modding/$$d"; \
|
||||
done; \
|
||||
./gen_animgallery.sh $$ARGS -out modding/build/gallery-mods
|
||||
|
||||
# Same layering as animgallery_mods, but -only-changed restricts the output
|
||||
# to resources a mod layer actually overrides.
|
||||
animgallery_dist:
|
||||
@ARGS="-data res/assets/ss_ee/data"; \
|
||||
FOUND=""; \
|
||||
for d in dist/mods/*/mod; do \
|
||||
[ -d "$$d" ] && ARGS="$$ARGS -data modding/$$d" && FOUND=1; \
|
||||
done; \
|
||||
if [ -z "$$FOUND" ]; then \
|
||||
echo "PREFLIGHT FAIL: no built mods found under dist/mods/ - run 'make mods' first" >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
./gen_animgallery.sh $$ARGS -only-changed -out modding/build/gallery-dist
|
||||
|
||||
clean:
|
||||
rm -rf dist
|
||||
|
||||
+53
-10
@@ -23,13 +23,13 @@ repository.
|
||||
of `hacked/` at build time (same pattern as the root project's
|
||||
`android/engine-patches/`, see the repository root's
|
||||
[AGENTS.md](../AGENTS.md) section 6) - `hacked/` itself is never edited
|
||||
in place.
|
||||
in place. See section 1.1 for what each patch does.
|
||||
- `sqweek-dialog/` - `github.com/sqweek/dialog`, `hacked`'s native
|
||||
file-dialog dependency. Vendored at the exact version `hacked/go.mod`
|
||||
would otherwise pull from the module proxy (ISC License), so it can be
|
||||
patched the same way as `hacked/` itself (see section 2's crash fix).
|
||||
patched the same way as `hacked/` itself (see section 1.1).
|
||||
- `sqweek-dialog-patches/` - patches for `sqweek-dialog/`, same pattern as
|
||||
`hacked-patches/`.
|
||||
`hacked-patches/` (section 1.1).
|
||||
- `mdlp/` - the **Mod Definition Language Processor** (section 4). First-party
|
||||
Go source, not vendored - edit it directly, no patch system needed.
|
||||
- `ss-specs/` - [InkyBlackness ss-specs](https://github.com/inkyblackness/ss-specs),
|
||||
@@ -63,6 +63,40 @@ support (level/resource files, `res/data/`); this folder is about the
|
||||
external tools that create and inspect that content, not about changing the
|
||||
engine itself.
|
||||
|
||||
### 1.1. Patch reference
|
||||
|
||||
`src/hacked-patches/` and `src/sqweek-dialog-patches/` (applied to scratch
|
||||
copies of `src/hacked/` and `src/sqweek-dialog/` respectively) are both
|
||||
plain, numbered patch files applied in order at build time - neither
|
||||
vendored source is ever modified in place. What each one does:
|
||||
|
||||
`src/hacked-patches/`:
|
||||
|
||||
- **`01-use-patched-dialog.patch`** - adds a `go.mod` `replace` directive
|
||||
pointing `github.com/sqweek/dialog` at the local, patched
|
||||
`../sqweek-dialog` instead of the upstream module, so `hacked` (and
|
||||
anything else building against this source, such as `mdlp`/`animgallery`)
|
||||
picks up the crash fix below.
|
||||
- **`02-fix-hashall-race.patch`** - fixes a real data race in
|
||||
`ss1/resource/ChangeNotifier.go`'s `hashAll()`: it hashes many resources
|
||||
concurrently (one goroutine per ID) to detect what changed after a
|
||||
modification, but only guarded the resource *lookup* with a mutex, not
|
||||
the actual block-reading inside `hashResource()` - resources from the
|
||||
same underlying file share a stateful reader, so concurrent unguarded
|
||||
reads corrupted each other, non-deterministically. Found via `go build
|
||||
-race` after `animgallery` produced a different resource count on every
|
||||
run against the exact same input. The mutex now guards the whole hash
|
||||
computation per resource, not just the lookup.
|
||||
|
||||
`src/sqweek-dialog-patches/`:
|
||||
|
||||
- **`01-xinitthreads.patch`** - adds `XInitThreads()` before `gtk_init()`
|
||||
in the Linux backend. Without it, `hacked` segfaults the moment a native
|
||||
GTK3 dialog (file/folder browse) opens while a GLFW/OpenGL context is
|
||||
live: GLFW's own X11 backend installs a process-wide Xlib error handler
|
||||
that conflicts with GTK's unless Xlib has been told up front that it
|
||||
will be used from multiple threads.
|
||||
|
||||
## 2. Building `hacked`
|
||||
|
||||
Packages `dist/<version>.linux64.tgz` and `dist/<version>.win64.zip` (native
|
||||
@@ -139,11 +173,16 @@ Rebecca Lansing's first email.
|
||||
|
||||
## 4. Mod Definition Language (mods/)
|
||||
|
||||
The workflow above is manual and its result (whatever you clicked through in
|
||||
`hacked`) isn't checked into git. `mods/` holds the same kind of edits as
|
||||
plain, reviewable JSON instead, and `mdlp` (the **Mod Definition Language
|
||||
Processor**, `mdlp/`) turns them into the same kind of mod folder and a zip,
|
||||
reproducibly and offline.
|
||||
MDL is a small, declarative JSON format for describing a mod's content -
|
||||
one file per piece of content (a message override, an upscaled sprite, and
|
||||
so on), naming the game resource it touches and the values to set, rather
|
||||
than the resource's actual binary data. `mdlp` (the **Mod Definition
|
||||
Language Processor**, `mdlp/`) reads these files and produces the same kind
|
||||
of mod folder `hacked` itself would save, reproducibly and offline. Since a
|
||||
plain-text description is all that's checked into git, mods stay reviewable
|
||||
and reproducible from the game's own data alone (see section 4.3) - unlike
|
||||
the workflow in section 3, whose result only ever lives in whatever you
|
||||
clicked through in `hacked`.
|
||||
|
||||
### 4.1. Layout
|
||||
|
||||
@@ -208,8 +247,12 @@ Needs the game's own data directory at `../res/assets/ss_ee/data` (see the
|
||||
root [README.md](../README.md) section 3) as read-only reference data, so
|
||||
overrides that touch only some fields of a message can inherit the rest.
|
||||
|
||||
Output lands in `dist/mods/<mod-name>/`: a `mod/` folder (the same shape
|
||||
`hacked`'s own Project window Save produces) and `<mod-name>.zip`.
|
||||
Output lands in `dist/mods/<mod-name>/mod/` (the same shape `hacked`'s own
|
||||
Project window Save produces) for each mod, plus one combined
|
||||
`dist/questshock-mods.zip` packaging all of them together. Nothing but
|
||||
`mods/`'s own JSON/text files needs to be checked into git - the game's own
|
||||
data (already required locally, already git-ignored) plus those files is
|
||||
always enough to reproduce this output from scratch.
|
||||
|
||||
Test every built mod at once with `run-mods.sh` (Linux) / `run-mods.bat`
|
||||
(Windows), copied into the root project's own `dist/`/`dist-win/` by
|
||||
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds and runs modding/src/animgallery/ (see README.md section 4) inside
|
||||
# the mdlp-image's Go toolchain - animgallery has no cgo dependencies of its
|
||||
# own, so it reuses that image rather than needing one of its own.
|
||||
#
|
||||
# Every argument is forwarded verbatim to animgallery itself. -data is
|
||||
# repeatable and layers directories in the order given, later wins - the
|
||||
# whole repository is mounted at /workspace here, so paths are relative to
|
||||
# the repository root, for example:
|
||||
#
|
||||
# ./gen_animgallery.sh -data res/assets/ss_ee/data -out modding/build/gallery
|
||||
# ./gen_animgallery.sh -data res/assets/ss_ee/data \
|
||||
# -data modding/dist/mods/mymod/mod -out modding/build/gallery-mymod
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
MODDING_ROOT="$(pwd)"
|
||||
REPO_ROOT="$(cd .. && pwd)"
|
||||
|
||||
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 "$MODDING_ROOT/../registry.env" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
source "$MODDING_ROOT/../registry.env"
|
||||
IMAGE="${MDLP_REGISTRY_IMAGE:-$IMAGE}"
|
||||
fi
|
||||
IMAGE_TAG="$(<"$MODDING_ROOT/mdlp-image/VERSION")"
|
||||
[ -n "$IMAGE_TAG" ] || fail "mdlp-image/VERSION is empty"
|
||||
IMAGE="$IMAGE:$IMAGE_TAG"
|
||||
|
||||
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|
||||
|| fail "$IMAGE not found locally - run ./mdlp-image.sh first"
|
||||
|
||||
TTY_FLAGS="-i"
|
||||
[ -t 1 ] && TTY_FLAGS="-it"
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
docker run --rm $TTY_FLAGS \
|
||||
-v "$REPO_ROOT:/workspace" \
|
||||
-w /workspace \
|
||||
-e HOST_UID="$(id -u)" \
|
||||
-e HOST_GID="$(id -g)" \
|
||||
"$IMAGE" \
|
||||
bash -c '
|
||||
set -euo pipefail
|
||||
# animgallery, like mdlp, builds against ../hacked via its own
|
||||
# go.mod replace - stage a patched copy (see src/hacked-patches/)
|
||||
# rather than building against the unpatched vendored source.
|
||||
SCRATCH_DIR="modding/build/animgallery-src"
|
||||
rm -rf "$SCRATCH_DIR"
|
||||
mkdir -p "$SCRATCH_DIR"
|
||||
bash modding/mdlp-image/stage-hacked.sh modding "$SCRATCH_DIR/hacked"
|
||||
cp -a modding/src/animgallery "$SCRATCH_DIR/animgallery"
|
||||
(cd "$SCRATCH_DIR/animgallery" && go build -o /tmp/animgallery .)
|
||||
exec /tmp/animgallery "$@"
|
||||
' _ "$@"
|
||||
@@ -11,10 +11,33 @@ FROM ubuntu:22.04
|
||||
# needs to fetch a newer toolchain over the network at build time.
|
||||
ARG GO_VERSION=1.27.1
|
||||
|
||||
# patch: applies src/hacked-patches/ to the scratch copy of hacked/ that
|
||||
# process-mods.sh (and ../gen_animgallery.sh) stage before building against
|
||||
# it - see stage-hacked.sh. python3/pip: runs the AI upscaler below via
|
||||
# plain CPU inference (ONNX Runtime). Not a Vulkan-based tool: this image
|
||||
# has no real GPU, and Vulkan software emulation (Mesa lavapipe) gives
|
||||
# wrong colors for ML inference workloads.
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl gosu \
|
||||
ca-certificates curl gosu patch python3 python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN pip3 install --no-cache-dir onnxruntime pillow numpy
|
||||
|
||||
# The AI upscaler model for mdlp's upscale mod type (see ../README.md
|
||||
# section 4): RealESRGAN_x4plus_anime_6B, a 6-block RRDBNet ONNX-exported
|
||||
# from xinntao/Real-ESRGAN's anime-illustration checkpoint (BSD-3-Clause).
|
||||
# It's trained on flat-color line art, so it keeps hard sprite edges rather
|
||||
# than smoothing them the way a photo- or video-denoising model would. A
|
||||
# mod's own "model" field (upscaleDef in ../src/mdlp/upscale.go) is not
|
||||
# consulted; every resource goes through this one model. upscale_batch.py
|
||||
# resizes its fixed native 4x output down to whatever scale a mod actually
|
||||
# asks for.
|
||||
ARG UPSCALE_MODEL_URL=https://huggingface.co/skillsafe-ai/realesrgan-x4plus-anime-6b/resolve/main/model.onnx
|
||||
RUN mkdir -p /opt/upscale-model \
|
||||
&& curl -sSL -o /opt/upscale-model/model.onnx "$UPSCALE_MODEL_URL"
|
||||
COPY mdlp-image/upscale_batch.py /usr/local/bin/upscale_batch.py
|
||||
RUN chmod 755 /usr/local/bin/upscale_batch.py
|
||||
ENV UPSCALE_MODEL_PATH=/opt/upscale-model/model.onnx
|
||||
|
||||
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" \
|
||||
|
||||
@@ -22,10 +22,20 @@ OUT_DIR="$MODDING_ROOT/dist/mods"
|
||||
[ -d "$DATA_DIR" ] \
|
||||
|| { echo "Game data not found at $DATA_DIR - see the root README.md section 3." >&2; exit 1; }
|
||||
|
||||
echo "== Staging a patched copy of hacked/ for mdlp to build against =="
|
||||
# mdlp's own go.mod points a `replace` at the sibling ../hacked - stage a
|
||||
# patched copy here (never build src/hacked/ in place) so mdlp picks up the
|
||||
# same fixes hacked's own binary does (see src/hacked-patches/).
|
||||
SCRATCH_DIR="$MODDING_ROOT/build/mdlp-src"
|
||||
rm -rf "$SCRATCH_DIR"
|
||||
mkdir -p "$SCRATCH_DIR"
|
||||
bash "$MODDING_ROOT/mdlp-image/stage-hacked.sh" "$MODDING_ROOT" "$SCRATCH_DIR/hacked"
|
||||
cp -a "$MODDING_ROOT/src/mdlp" "$SCRATCH_DIR/mdlp"
|
||||
|
||||
echo "== Building mdlp =="
|
||||
BIN_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$BIN_DIR"' EXIT
|
||||
(cd "$MODDING_ROOT/src/mdlp" && go build -trimpath -o "$BIN_DIR/mdlp" .)
|
||||
(cd "$SCRATCH_DIR/mdlp" && go build -trimpath -o "$BIN_DIR/mdlp" .)
|
||||
|
||||
MDLP_ARGS=(-data "$DATA_DIR" -mods "$MODDING_ROOT/mods" -out "$OUT_DIR")
|
||||
if [ -n "${MODS:-}" ]; then
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stages a scratch, patched copy of src/hacked/ at the given destination -
|
||||
# shared by process-mods.sh (mdlp) and ../gen_animgallery.sh (animgallery),
|
||||
# both of which build against hacked's source directly (via their own
|
||||
# go.mod's `replace ../hacked`) and so both need the same patches applied
|
||||
# that hacked-build-image/build-hacked.sh already applies for hacked's own
|
||||
# binary. src/hacked/ is never built or patched in place.
|
||||
#
|
||||
# Usage: stage-hacked.sh <modding-root> <dest-dir-for-hacked>
|
||||
set -euo pipefail
|
||||
|
||||
MODDING_ROOT="$1"
|
||||
DEST="$2"
|
||||
|
||||
rm -rf "$DEST"
|
||||
mkdir -p "$(dirname "$DEST")"
|
||||
cp -a "$MODDING_ROOT/src/hacked" "$DEST"
|
||||
for p in "$MODDING_ROOT"/src/hacked-patches/*.patch; do
|
||||
patch -p1 -d "$DEST" < "$p"
|
||||
done
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Upscales every PNG in one directory into another, via one ONNX Runtime
|
||||
CPU session shared across the whole batch (same reasoning as mdlp's own
|
||||
Go-side batching: the fixed per-invocation model-load cost otherwise
|
||||
dominates for the many small sprite frames one upscale definition has).
|
||||
|
||||
Runs on CPU only, not Vulkan: this Docker image has no real GPU, and
|
||||
Vulkan software emulation (Mesa lavapipe) gives wrong colors for ML
|
||||
inference workloads.
|
||||
|
||||
Usage: upscale_batch.py <model.onnx> <in-dir> <out-dir> <scale>
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def main():
|
||||
model_path, in_dir, out_dir, scale_arg = sys.argv[1:5]
|
||||
scale = int(scale_arg)
|
||||
|
||||
session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
|
||||
input_name = session.get_inputs()[0].name
|
||||
|
||||
names = sorted(n for n in os.listdir(in_dir) if n.lower().endswith(".png"))
|
||||
for name in names:
|
||||
img = Image.open(os.path.join(in_dir, name)).convert("RGBA")
|
||||
rgb = img.convert("RGB")
|
||||
alpha = img.split()[3]
|
||||
|
||||
# The model has no concept of transparency (3-channel RGB in/out) -
|
||||
# run it on the flattened RGB only, and scale the alpha mask
|
||||
# separately with a plain resize. Alpha edges don't need AI
|
||||
# enhancement, just to end up the right size.
|
||||
arr = np.asarray(rgb, dtype=np.float32) / 255.0
|
||||
arr = np.transpose(arr, (2, 0, 1))[np.newaxis, ...]
|
||||
output = session.run(None, {input_name: arr})[0]
|
||||
output = np.clip(output[0], 0.0, 1.0) * 255.0
|
||||
output = np.transpose(output, (1, 2, 0)).astype(np.uint8)
|
||||
upscaled_rgb = Image.fromarray(output, "RGB")
|
||||
|
||||
target_size = (img.width * scale, img.height * scale)
|
||||
if upscaled_rgb.size != target_size:
|
||||
# The model upscales at its own fixed native factor - resize to
|
||||
# whatever scale the mod actually asked for.
|
||||
upscaled_rgb = upscaled_rgb.resize(target_size, Image.LANCZOS)
|
||||
upscaled_alpha = alpha.resize(target_size, Image.LANCZOS)
|
||||
|
||||
result = Image.merge("RGBA", (*upscaled_rgb.split(), upscaled_alpha))
|
||||
result.save(os.path.join(out_dir, name))
|
||||
|
||||
print(f"upscaled {len(names)} frame(s)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"model": "realesrgan-x4plus-anime",
|
||||
"scale": 2,
|
||||
"resources": [
|
||||
"0x583",
|
||||
"0x5CD",
|
||||
"0x617",
|
||||
"0x689",
|
||||
"0x68A",
|
||||
"0x68B",
|
||||
"0x68C",
|
||||
"0x68D",
|
||||
"0x68E",
|
||||
"0x68F",
|
||||
"0x690",
|
||||
"0x7B0",
|
||||
"0x7B1",
|
||||
"0x7B2",
|
||||
"0x7B3",
|
||||
"0x7B4",
|
||||
"0x7B5",
|
||||
"0x7B6",
|
||||
"0x7B7",
|
||||
"0x84C"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"model": "realesrgan-x4plus-anime",
|
||||
"scale": 2,
|
||||
"resources": [
|
||||
"0x580",
|
||||
"0x5A5",
|
||||
"0x5CA",
|
||||
"0x5EF",
|
||||
"0x614",
|
||||
"0x671",
|
||||
"0x672",
|
||||
"0x673",
|
||||
"0x674",
|
||||
"0x675",
|
||||
"0x676",
|
||||
"0x677",
|
||||
"0x678",
|
||||
"0x798",
|
||||
"0x799",
|
||||
"0x79A",
|
||||
"0x79B",
|
||||
"0x79C",
|
||||
"0x79D",
|
||||
"0x79E",
|
||||
"0x79F",
|
||||
"0x849"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"model": "realesrgan-x4plus-anime",
|
||||
"scale": 2,
|
||||
"resources": [
|
||||
"0x578",
|
||||
"0x59D",
|
||||
"0x5C2",
|
||||
"0x5E7",
|
||||
"0x60C",
|
||||
"0x631",
|
||||
"0x632",
|
||||
"0x633",
|
||||
"0x634",
|
||||
"0x635",
|
||||
"0x636",
|
||||
"0x637",
|
||||
"0x638",
|
||||
"0x661",
|
||||
"0x662",
|
||||
"0x663",
|
||||
"0x664",
|
||||
"0x665",
|
||||
"0x666",
|
||||
"0x667",
|
||||
"0x668",
|
||||
"0x758",
|
||||
"0x759",
|
||||
"0x75A",
|
||||
"0x75B",
|
||||
"0x75C",
|
||||
"0x75D",
|
||||
"0x75E",
|
||||
"0x75F",
|
||||
"0x841"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
module questshock/animgallery
|
||||
|
||||
go 1.25.0
|
||||
|
||||
// hacked/ is vendored locally (see ../../README.md); animgallery reuses its
|
||||
// resource decoding 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 => ../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,595 @@
|
||||
// Command animgallery scans a game data directory for every multi-frame
|
||||
// bitmap resource (both dedicated "Animation" descriptors, e.g. video mail,
|
||||
// and plain multi-block compound bitmaps, e.g. critter sprites - System
|
||||
// Shock uses both conventions) and renders each one as an animated GIF plus
|
||||
// an HTML index page, so they can be browsed by eye instead of guessed at
|
||||
// by resource ID.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"html"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/world"
|
||||
"github.com/inkyblackness/hacked/ss1/world/ids"
|
||||
)
|
||||
|
||||
// galleryEntry describes one rendered animation for the HTML index.
|
||||
type galleryEntry struct {
|
||||
id resource.ID
|
||||
lang resource.Language
|
||||
sourceFile string
|
||||
kind string
|
||||
frameCount int
|
||||
width int
|
||||
height int
|
||||
transparent bool
|
||||
gifName string
|
||||
}
|
||||
|
||||
// dataDirs collects repeated -data flags, in the order given - later ones
|
||||
// take priority over earlier ones for any resource ID they both define,
|
||||
// same as System Shock's own overlay convention. This lets a mod's build
|
||||
// output (modding/dist/mods/<name>/mod/) be layered on top of the base
|
||||
// game assets to preview the combined, as-played result.
|
||||
type dataDirs []string
|
||||
|
||||
func (d *dataDirs) String() string { return strings.Join(*d, ",") }
|
||||
func (d *dataDirs) Set(v string) error { *d = append(*d, v); return nil }
|
||||
|
||||
// loadDataDir merges every .res/.dat file directly inside dir into one
|
||||
// manifest entry, bypassing hacked's own fileAllowlist
|
||||
// (ss1/world/FileLoader.go): that check is only skipped when a file is
|
||||
// passed directly as the sole argument, not when given a directory to
|
||||
// scan, so a directory containing a resource file with a name it doesn't
|
||||
// recognize (like mdlp's own "unknown.res" fallback - see
|
||||
// [[project_modding_hacked_build]] memory) is otherwise silently treated
|
||||
// as if it had no resources at all. The real game engine has no such
|
||||
// restriction - it matches any .res/.dat file by extension alone
|
||||
// (engine/src/MacSrc/Modding.c's StringEndsWith), which this mirrors.
|
||||
func loadDataDir(dir string) (*world.ManifestEntry, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
merged := &world.ManifestEntry{Origin: []string{dir}, ID: dir}
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||
if ext != ".res" && ext != ".dat" {
|
||||
continue
|
||||
}
|
||||
fileEntry, err := world.NewManifestEntryFrom([]string{filepath.Join(dir, e.Name())})
|
||||
if err != nil {
|
||||
// Not every .res/.dat file is actually resource-formatted (e.g.
|
||||
// ambrtabl.dat is a raw lookup table) - hacked's own
|
||||
// fileAllowlist silently protects against exactly this for
|
||||
// canonical filenames, but bypassing it here (the whole point,
|
||||
// for names like "unknown.res" it doesn't recognize) means we
|
||||
// have to tolerate that ourselves instead: skip a file that
|
||||
// doesn't parse rather than failing the whole directory over it.
|
||||
continue
|
||||
}
|
||||
merged.Resources = append(merged.Resources, fileEntry.Resources...)
|
||||
if len(fileEntry.ObjectProperties) > 0 {
|
||||
merged.ObjectProperties = fileEntry.ObjectProperties
|
||||
}
|
||||
if len(fileEntry.TextureProperties) > 0 {
|
||||
merged.TextureProperties = fileEntry.TextureProperties
|
||||
}
|
||||
found = true
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("no .res/.dat files found in %s", dir)
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
var dirs dataDirs
|
||||
flag.Var(&dirs, "data", "directory with .res/.dat files - repeat to layer several, later wins (e.g. base game assets, then a built mod's mod/ folder)")
|
||||
outDir := flag.String("out", "gallery", "directory to write index.html and every .gif into")
|
||||
minFrames := flag.Int("min-frames", 2, "skip plain multi-block bitmaps with fewer blocks than this (Animation-type resources are always included)")
|
||||
onlyChanged := flag.Bool("only-changed", false, "skip resources whose winning -data layer is the first one - pass a base layer followed by one or more mod layers to preview only what those later layers change, in the base layer's own colors")
|
||||
flag.Parse()
|
||||
|
||||
if len(dirs) == 0 {
|
||||
log.Fatal("-data is required (repeat it to layer several directories)")
|
||||
}
|
||||
absOutDir, err := filepath.Abs(*outDir)
|
||||
if err != nil {
|
||||
log.Fatalf("resolving -out: %v", err)
|
||||
}
|
||||
gifDir := filepath.Join(absOutDir, "gifs")
|
||||
if err := os.MkdirAll(gifDir, 0o755); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
mod := world.NewMod(func([]resource.ID, []resource.ID) {}, func() {})
|
||||
// sourceFileByKey records, per (language, id), which directory's
|
||||
// resource "wins" after layering - the same last-one-wins rule
|
||||
// Manifest.Filter itself uses for entries inserted in this order.
|
||||
// sourceDirIndexByKey records the same winner as an index into dirs,
|
||||
// for -only-changed to tell a first-layer (base) resource from one a
|
||||
// later layer (a mod) overrides.
|
||||
sourceFileByKey := make(map[resource.Key]string)
|
||||
sourceDirIndexByKey := make(map[resource.Key]int)
|
||||
for dirIndex, dir := range dirs {
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
log.Fatalf("resolving -data %q: %v", dir, err)
|
||||
}
|
||||
entry, err := loadDataDir(absDir)
|
||||
if err != nil {
|
||||
// A mod that doesn't touch any resource file yet (e.g. an
|
||||
// upscale mod before mdlp supports that content type) produces
|
||||
// an empty build directory - skip it rather than aborting the
|
||||
// whole layered scan over one contributing -data directory.
|
||||
log.Printf("skipping -data %s: %v", absDir, err)
|
||||
continue
|
||||
}
|
||||
// NewManifestEntryFrom builds entry.Resources by ranging over a Go
|
||||
// map, so its order (and therefore which file "wins" a Selector's
|
||||
// last-one-wins tie-break for any resource ID more than one file
|
||||
// defines) is randomized per process run. Sort it ourselves so the
|
||||
// same input always resolves the same way.
|
||||
sort.Slice(entry.Resources, func(i, j int) bool { return entry.Resources[i].ID < entry.Resources[j].ID })
|
||||
if err := mod.World().InsertEntry(mod.World().EntryCount(), entry); err != nil {
|
||||
log.Fatalf("registering game data from %s: %v", absDir, err)
|
||||
}
|
||||
for _, loc := range entry.Resources {
|
||||
for _, id := range loc.Viewer.IDs() {
|
||||
key := resource.KeyOf(id, loc.Language, 0)
|
||||
sourceFileByKey[key] = fmt.Sprintf("%s (%s)", loc.ID, filepath.Base(absDir))
|
||||
sourceDirIndexByKey[key] = dirIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
palCache := bitmap.NewPaletteCache(mod)
|
||||
defaultPal, err := palCache.Palette(resource.KeyOf(ids.GamePalettesStart, resource.LangDefault, 0))
|
||||
if err != nil {
|
||||
log.Printf("warning: could not load the default game palette (%v); falling back to grayscale", err)
|
||||
}
|
||||
|
||||
keys := make([]resource.Key, 0, len(sourceFileByKey))
|
||||
for key := range sourceFileByKey {
|
||||
if *onlyChanged && sourceDirIndexByKey[key] == 0 {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].ID != keys[j].ID {
|
||||
return keys[i].ID < keys[j].ID
|
||||
}
|
||||
return keys[i].Lang < keys[j].Lang
|
||||
})
|
||||
|
||||
var entries []galleryEntry
|
||||
for _, key := range keys {
|
||||
sourceFile := sourceFileByKey[key]
|
||||
// Re-resolved through the layered mod, not the raw per-directory
|
||||
// viewer, so an overriding mod's version wins here exactly like it
|
||||
// would for the running game.
|
||||
view, err := mod.LocalizedResources(key.Lang).Select(key.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case view.ContentType() == resource.Animation:
|
||||
entry, err := safeRenderAnimationDescriptor(mod, defaultPal, sourceFile, key.ID, key.Lang, gifDir)
|
||||
if err != nil {
|
||||
log.Printf("skipping animation %s (%s): %v", key.ID, sourceFile, err)
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
|
||||
case view.ContentType() == resource.Bitmap && view.BlockCount() >= *minFrames:
|
||||
entry, err := safeRenderMultiBlockBitmap(view, defaultPal, sourceFile, key.ID, key.Lang, gifDir)
|
||||
if err != nil {
|
||||
log.Printf("skipping bitmap %s (%s): %v", key.ID, sourceFile, err)
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].id != entries[j].id {
|
||||
return entries[i].id < entries[j].id
|
||||
}
|
||||
return entries[i].lang < entries[j].lang
|
||||
})
|
||||
|
||||
indexPath := filepath.Join(absOutDir, "index.html")
|
||||
if err := writeIndex(indexPath, entries); err != nil {
|
||||
log.Fatalf("writing %s: %v", indexPath, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d animated resource(s).\n", len(entries))
|
||||
fmt.Printf("Index: %s\n", indexPath)
|
||||
}
|
||||
|
||||
// decodeFrames reads every block of a compound bitmap resource as a
|
||||
// sequence of frames, each one RLE-diffed against the previous - the same
|
||||
// chaining hacked's own editor/animations/View.go uses when importing a
|
||||
// GIF, done here in reverse (decode instead of encode).
|
||||
func decodeFrames(view resource.View) ([]*bitmap.Bitmap, error) {
|
||||
count := view.BlockCount()
|
||||
frames := make([]*bitmap.Bitmap, 0, count)
|
||||
var prevPixels []byte
|
||||
for i := 0; i < count; i++ {
|
||||
reader, err := view.Block(i)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||
}
|
||||
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||
size := int(w) * int(h)
|
||||
buf := make([]byte, size)
|
||||
if len(prevPixels) == size {
|
||||
copy(buf, prevPixels)
|
||||
}
|
||||
return buf, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||
}
|
||||
prevPixels = bmp.Pixels
|
||||
frames = append(frames, bmp)
|
||||
}
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
func paletteFor(bmp *bitmap.Bitmap, fallback bitmap.Palette) color.Palette {
|
||||
pal := fallback
|
||||
if bmp.Palette != nil {
|
||||
pal = *bmp.Palette
|
||||
}
|
||||
return pal.ColorPalette(false)
|
||||
}
|
||||
|
||||
func writeGIF(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette, delays []int) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
maxWidth, maxHeight := 0, 0
|
||||
for _, bmp := range frames {
|
||||
if int(bmp.Header.Width) > maxWidth {
|
||||
maxWidth = int(bmp.Header.Width)
|
||||
}
|
||||
if int(bmp.Header.Height) > maxHeight {
|
||||
maxHeight = int(bmp.Header.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// LoopCount: 0 means loop forever in Go's image/gif API - inverted from
|
||||
// the raw GIF format's own NETSCAPE2.0 convention. -1 (this package's
|
||||
// zero value) means play once, which is why these looped only once
|
||||
// before this fix.
|
||||
out := gif.GIF{LoopCount: 0, Config: image.Config{Width: maxWidth, Height: maxHeight}}
|
||||
for i, bmp := range frames {
|
||||
colorPal := paletteFor(bmp, fallbackPal)
|
||||
rect := image.Rect(0, 0, int(bmp.Header.Width), int(bmp.Header.Height))
|
||||
img := image.NewPaletted(rect, colorPal)
|
||||
if len(bmp.Pixels) == len(img.Pix) {
|
||||
copy(img.Pix, bmp.Pixels)
|
||||
}
|
||||
out.Image = append(out.Image, img)
|
||||
out.Delay = append(out.Delay, delays[i])
|
||||
}
|
||||
return gif.EncodeAll(f, &out)
|
||||
}
|
||||
|
||||
// safeRenderMultiBlockBitmap recovers from panics in hacked's own decode
|
||||
// code (malformed or non-image compound resources can crash rle.Decompress
|
||||
// rather than return an error) so one bad resource never aborts a scan that
|
||||
// is meant to be re-run repeatedly during modding.
|
||||
func safeRenderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (entry galleryEntry, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return renderMultiBlockBitmap(view, fallbackPal, sourceFile, id, lang, outDir)
|
||||
}
|
||||
|
||||
func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (galleryEntry, error) {
|
||||
frames, err := decodeFrames(view)
|
||||
if err != nil {
|
||||
return galleryEntry{}, err
|
||||
}
|
||||
if len(frames) == 0 {
|
||||
return galleryEntry{}, fmt.Errorf("no frames")
|
||||
}
|
||||
delays := make([]int, len(frames))
|
||||
for i := range delays {
|
||||
delays[i] = 10 // 100ms - no explicit timing exists for this raw form
|
||||
}
|
||||
gifName := fmt.Sprintf("%04X_%s.gif", int(id), lang.String())
|
||||
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays); err != nil {
|
||||
return galleryEntry{}, err
|
||||
}
|
||||
return galleryEntry{
|
||||
id: id, lang: lang, sourceFile: sourceFile, kind: "multi-block bitmap",
|
||||
frameCount: len(frames),
|
||||
width: int(frames[0].Header.Width), height: int(frames[0].Header.Height),
|
||||
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
||||
gifName: gifName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// safeRenderAnimationDescriptor is safeRenderMultiBlockBitmap's counterpart
|
||||
// for the Animation-descriptor path.
|
||||
func safeRenderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (entry galleryEntry, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return renderAnimationDescriptor(mod, fallbackPal, sourceFile, id, lang, outDir)
|
||||
}
|
||||
|
||||
func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (galleryEntry, error) {
|
||||
view, err := mod.LocalizedResources(lang).Select(id)
|
||||
if err != nil {
|
||||
return galleryEntry{}, err
|
||||
}
|
||||
reader, err := view.Block(0)
|
||||
if err != nil {
|
||||
return galleryEntry{}, err
|
||||
}
|
||||
anim, err := bitmap.ReadAnimation(reader)
|
||||
if err != nil {
|
||||
return galleryEntry{}, err
|
||||
}
|
||||
if len(anim.Entries) == 0 {
|
||||
return galleryEntry{}, fmt.Errorf("no animation entries")
|
||||
}
|
||||
|
||||
lastFrame := 0
|
||||
for _, entry := range anim.Entries {
|
||||
if int(entry.LastFrame) > lastFrame {
|
||||
lastFrame = int(entry.LastFrame)
|
||||
}
|
||||
}
|
||||
|
||||
var frames []*bitmap.Bitmap
|
||||
var delays []int
|
||||
var prevPixels []byte
|
||||
frameIndex := 0
|
||||
for _, entry := range anim.Entries {
|
||||
for frameIndex <= int(entry.LastFrame) {
|
||||
frameKey := resource.KeyOf(anim.ResourceID, lang, frameIndex)
|
||||
frameView, err := mod.LocalizedResources(lang).Select(frameKey.ID)
|
||||
if err != nil {
|
||||
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||
}
|
||||
reader, err := frameView.Block(0)
|
||||
if err != nil {
|
||||
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||
}
|
||||
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||
size := int(w) * int(h)
|
||||
buf := make([]byte, size)
|
||||
if len(prevPixels) == size {
|
||||
copy(buf, prevPixels)
|
||||
}
|
||||
return buf, nil
|
||||
})
|
||||
if err != nil {
|
||||
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||
}
|
||||
prevPixels = bmp.Pixels
|
||||
frames = append(frames, bmp)
|
||||
delay := int(entry.FrameTime) / 10
|
||||
if delay <= 0 {
|
||||
delay = 10
|
||||
}
|
||||
delays = append(delays, delay)
|
||||
frameIndex++
|
||||
}
|
||||
}
|
||||
if len(frames) == 0 {
|
||||
return galleryEntry{}, fmt.Errorf("no frames decoded")
|
||||
}
|
||||
|
||||
gifName := fmt.Sprintf("%04X_%s.gif", int(id), lang.String())
|
||||
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays); err != nil {
|
||||
return galleryEntry{}, err
|
||||
}
|
||||
return galleryEntry{
|
||||
id: id, lang: lang, sourceFile: sourceFile, kind: "animation descriptor",
|
||||
frameCount: len(frames),
|
||||
width: int(anim.Width), height: int(anim.Height),
|
||||
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
||||
gifName: gifName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// contentTypeName maps a galleryEntry.kind back to the resource.ContentType
|
||||
// name mdlp will need (its own decode path differs: an "Animation"
|
||||
// descriptor's frames live at anim.ResourceID+N, a "Bitmap"'s frames are
|
||||
// just its own blocks) - see kind's assignment in renderAnimationDescriptor/
|
||||
// renderMultiBlockBitmap.
|
||||
func contentTypeName(kind string) string {
|
||||
if kind == "animation descriptor" {
|
||||
return "Animation"
|
||||
}
|
||||
return "Bitmap"
|
||||
}
|
||||
|
||||
// mdlResourceID formats the resourceId string an MDL upscale definition
|
||||
// uses to identify one resource: a resource ID alone is not always unique -
|
||||
// e.g. 0x25 exists as a separate resource per language - so a non-Any
|
||||
// language is appended (0x25_German). The bare form (0xA54) implies
|
||||
// _Any, the language nearly everything we care about upscaling (critter
|
||||
// art, etc.) actually uses.
|
||||
func mdlResourceID(id resource.ID, lang resource.Language) string {
|
||||
if lang == resource.LangAny {
|
||||
return fmt.Sprintf("0x%X", int(id))
|
||||
}
|
||||
return fmt.Sprintf("0x%X_%s", int(id), lang.String())
|
||||
}
|
||||
|
||||
func writeIndex(path string, entries []galleryEntry) error {
|
||||
var b strings.Builder
|
||||
b.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>System Shock animation gallery</title>")
|
||||
b.WriteString("<style>body{font-family:sans-serif;background:#111;color:#eee}")
|
||||
b.WriteString("#controls{position:sticky;top:0;background:#181818;padding:10px;z-index:10;border-bottom:1px solid #444}")
|
||||
b.WriteString("#controls label{margin-right:12px}")
|
||||
b.WriteString("#mdl-output{width:98%;font-family:monospace;font-size:12px;margin-top:8px;background:#000;color:#9f9;border:1px solid #444}")
|
||||
b.WriteString(".card{display:inline-block;margin:8px;padding:8px;background:#222;vertical-align:top}")
|
||||
b.WriteString(".card img{image-rendering:pixelated;background:#000;display:block}")
|
||||
b.WriteString(".meta{font-size:12px;color:#aaa;margin-top:4px}")
|
||||
b.WriteString(".meta label{cursor:pointer}")
|
||||
b.WriteString("#basket{min-height:60px;background:#1a2a1a;border:1px dashed #4a6a4a;padding:8px;margin-bottom:16px}")
|
||||
b.WriteString("#basket:empty::before{content:'(nothing selected yet - check animations below to add them here)';color:#678;font-style:italic}")
|
||||
b.WriteString("#basket .card{background:#2a3a2a}</style></head><body>")
|
||||
|
||||
b.WriteString(`<div id="controls">` +
|
||||
`<label>Model: <select id="model">` +
|
||||
`<option>realesrgan-x4plus-anime</option>` +
|
||||
`<option>realesrgan-x4plus</option>` +
|
||||
`<option>realesr-animevideov3</option>` +
|
||||
`</select></label>` +
|
||||
`<label>Scale: <input id="scale" type="number" value="2" min="1" max="4" style="width:4em"></label>` +
|
||||
`<button id="clear-sel" type="button">Clear selection</button>` +
|
||||
`<br><label>Filter: <input type="text" id="search" placeholder="id, language, file, transparent..." style="width:22em"></label>` +
|
||||
`<span id="shown-count"></span>` +
|
||||
`<textarea id="mdl-output" rows="12" readonly placeholder="Select animations below to generate an MDL upscale definition here - copy it into modding/mods/<name>/upscale/<label>.json"></textarea>` +
|
||||
`</div>`)
|
||||
|
||||
b.WriteString(`<h2>Selected (<span id="basket-count">0</span>)</h2><div id="basket"></div>`)
|
||||
|
||||
fmt.Fprintf(&b, "<h1>%d animated resource(s)</h1>", len(entries))
|
||||
|
||||
// Animation descriptors and multi-block bitmaps are genuinely distinct
|
||||
// content types (different decode path, see contentTypeName) - grouped
|
||||
// into separate sections rather than interleaved.
|
||||
var animations, bitmaps []galleryEntry
|
||||
for _, e := range entries {
|
||||
if contentTypeName(e.kind) == "Animation" {
|
||||
animations = append(animations, e)
|
||||
} else {
|
||||
bitmaps = append(bitmaps, e)
|
||||
}
|
||||
}
|
||||
|
||||
writeCards := func(group []galleryEntry, idPrefix string) {
|
||||
for i, e := range group {
|
||||
transparency := "opaque"
|
||||
if e.transparent {
|
||||
transparency = "transparent"
|
||||
}
|
||||
search := strings.ToLower(strings.Join([]string{
|
||||
fmt.Sprintf("0x%x", int(e.id)), fmt.Sprintf("%d", int(e.id)),
|
||||
e.lang.String(), e.kind, e.sourceFile,
|
||||
fmt.Sprintf("%dx%d", e.width, e.height), transparency,
|
||||
}, " "))
|
||||
fmt.Fprintf(&b, "<div class=\"card\" data-search=\"%s\">", html.EscapeString(search))
|
||||
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
|
||||
fmt.Fprintf(&b, "<div class=\"meta\"><label><input type=\"checkbox\" class=\"sel\" id=\"sel-%s-%d\" data-id=\"%s\"> ",
|
||||
idPrefix, i, html.EscapeString(mdlResourceID(e.id, e.lang)))
|
||||
fmt.Fprintf(&b, "ID 0x%X (%d) · %s</label><br>%dx%d, %d frame(s), %s · %s · %s</div>",
|
||||
int(e.id), int(e.id), html.EscapeString(e.lang.String()),
|
||||
e.width, e.height, e.frameCount, transparency, html.EscapeString(e.kind), html.EscapeString(e.sourceFile))
|
||||
b.WriteString("</div>")
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "<h2>Animations (%d)</h2>", len(animations))
|
||||
writeCards(animations, "a")
|
||||
fmt.Fprintf(&b, "<h2>Bitmaps (%d)</h2>", len(bitmaps))
|
||||
writeCards(bitmaps, "b")
|
||||
|
||||
b.WriteString(`<script>
|
||||
var basket = document.getElementById('basket');
|
||||
// Where each card lives when not selected, so unchecking it puts it back
|
||||
// in its original spot (Animations/Bitmaps section, original order)
|
||||
// instead of just appending it back at the end.
|
||||
var cardHomes = new Map();
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.card'), function (card) {
|
||||
cardHomes.set(card, { parent: card.parentNode, next: card.nextSibling });
|
||||
});
|
||||
|
||||
function moveCard(checkbox) {
|
||||
var card = checkbox.closest('.card');
|
||||
if (checkbox.checked) {
|
||||
basket.appendChild(card);
|
||||
} else {
|
||||
var home = cardHomes.get(card);
|
||||
home.parent.insertBefore(card, home.next);
|
||||
}
|
||||
document.getElementById('basket-count').textContent = basket.children.length;
|
||||
}
|
||||
|
||||
function updateMdl() {
|
||||
var model = document.getElementById('model').value;
|
||||
var scale = parseInt(document.getElementById('scale').value, 10) || 2;
|
||||
var boxes = Array.prototype.filter.call(document.querySelectorAll('.sel'), function (b) { return b.checked; });
|
||||
// A bare resource ID isn't always unique (e.g. 0x25 exists as a separate
|
||||
// resource per language), so each entry here is "0x<ID>" or, when the
|
||||
// resource's language isn't Any, "0x<ID>_<Language>" - mdlp resolves the
|
||||
// rest (content type, etc.) from the resource itself. The MDL
|
||||
// definition only needs what it can't derive: the shared upscale
|
||||
// parameters, and which resources to apply them to.
|
||||
var mdl = {
|
||||
model: model,
|
||||
scale: scale,
|
||||
resources: boxes.map(function (b) { return b.dataset.id; })
|
||||
};
|
||||
document.getElementById('mdl-output').value = JSON.stringify(mdl, null, 2);
|
||||
}
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.sel'), function (b) {
|
||||
b.addEventListener('change', function () { moveCard(b); updateMdl(); });
|
||||
});
|
||||
document.getElementById('model').addEventListener('change', updateMdl);
|
||||
document.getElementById('scale').addEventListener('input', updateMdl);
|
||||
document.getElementById('clear-sel').addEventListener('click', function () {
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.sel'), function (b) {
|
||||
if (b.checked) {
|
||||
b.checked = false;
|
||||
moveCard(b);
|
||||
}
|
||||
});
|
||||
updateMdl();
|
||||
});
|
||||
updateMdl();
|
||||
|
||||
function applyFilter() {
|
||||
var query = document.getElementById('search').value.trim().toLowerCase();
|
||||
var cards = document.querySelectorAll('.card');
|
||||
var shown = 0;
|
||||
Array.prototype.forEach.call(cards, function (card) {
|
||||
var match = !query || card.dataset.search.indexOf(query) !== -1;
|
||||
card.style.display = match ? '' : 'none';
|
||||
if (match) shown++;
|
||||
});
|
||||
document.getElementById('shown-count').textContent = shown + ' / ' + cards.length + ' shown';
|
||||
}
|
||||
document.getElementById('search').addEventListener('input', applyFilter);
|
||||
applyFilter();
|
||||
</script>`)
|
||||
|
||||
b.WriteString("</body></html>")
|
||||
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
--- a/ss1/resource/ChangeNotifier.go
|
||||
+++ b/ss1/resource/ChangeNotifier.go
|
||||
@@ -50,24 +50,26 @@
|
||||
func (notifier ChangeNotifier) hashAll(ids []ID) (hashes resourceHashSnapshot, failedIDs []ID) {
|
||||
var failedMap IDMarkerMap
|
||||
hashes = make(resourceHashSnapshot)
|
||||
- var selectMutex sync.Mutex
|
||||
+ // Guards the whole of hashResource, not just Select: resources from the
|
||||
+ // same underlying file share a stateful reader (e.g. a *io.SectionReader
|
||||
+ // tracking a read offset), so the actual block reads inside
|
||||
+ // hashResource race just as much as selection would - locking only
|
||||
+ // Select still let concurrent goroutines corrupt each other's reads.
|
||||
+ var ioMutex sync.Mutex
|
||||
var mapMutex sync.Mutex
|
||||
for _, lang := range Languages() {
|
||||
hashByResourceID := make(resourceHashes)
|
||||
hashes[lang] = hashByResourceID
|
||||
selector := notifier.Localizer.LocalizedResources(lang)
|
||||
- selectorFunc := func(id ID) (View, error) {
|
||||
- selectMutex.Lock()
|
||||
- defer selectMutex.Unlock()
|
||||
- return selector.Select(id)
|
||||
- }
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, id := range ids {
|
||||
wg.Add(1)
|
||||
go func(id ID) {
|
||||
defer wg.Done()
|
||||
- hash, hashErr := notifier.hashResource(selectorFunc, id)
|
||||
+ ioMutex.Lock()
|
||||
+ hash, hashErr := notifier.hashResource(selector.Select, id)
|
||||
+ ioMutex.Unlock()
|
||||
mapMutex.Lock()
|
||||
defer mapMutex.Unlock()
|
||||
if hashErr == nil {
|
||||
@@ -1,6 +1,7 @@
|
||||
// Command mdlp (Mod Definition Language Processor) turns the declarative mod
|
||||
// definitions under modding/mods/ into hacked-compatible mod folders and zip
|
||||
// archives under modding/dist/mods/. See ../../README.md for the MDL schema.
|
||||
// definitions under modding/mods/ into hacked-compatible mod folders under
|
||||
// modding/dist/mods/, packaged together as modding/dist/questshock-mods.zip.
|
||||
// See ../../README.md for the MDL schema.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||
"github.com/inkyblackness/hacked/ss1/content/text"
|
||||
"github.com/inkyblackness/hacked/ss1/edit"
|
||||
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
|
||||
@@ -108,10 +110,17 @@ func main() {
|
||||
for _, name := range names {
|
||||
fmt.Printf("== Building mod %q ==\n", name)
|
||||
modOutDir := filepath.Join(*outDir, name)
|
||||
if err := buildMod(*dataDir, filepath.Join(*modsDir, name), modOutDir, name); err != nil {
|
||||
if err := buildMod(*dataDir, filepath.Join(*modsDir, name), modOutDir); err != nil {
|
||||
log.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
zipPath := filepath.Join(filepath.Dir(*outDir), "questshock-mods.zip")
|
||||
fmt.Println("== Packaging ==")
|
||||
if err := zipDir(*outDir, zipPath); err != nil {
|
||||
log.Fatalf("packaging %s: %v", zipPath, err)
|
||||
}
|
||||
fmt.Printf(" zip: %s\n", zipPath)
|
||||
}
|
||||
|
||||
func modNames(modsDir, only string) ([]string, error) {
|
||||
@@ -132,13 +141,19 @@ func modNames(modsDir, only string) ([]string, error) {
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func buildMod(dataDir, modDir, outDir, name string) error {
|
||||
func buildMod(dataDir, modDir, outDir string) error {
|
||||
mod := world.NewMod(func([]resource.ID, []resource.ID) {}, func() {})
|
||||
|
||||
entry, err := world.NewManifestEntryFrom([]string{dataDir})
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading game data from %s: %w", dataDir, err)
|
||||
}
|
||||
// NewManifestEntryFrom builds entry.Resources by ranging over a Go map,
|
||||
// so its order (and therefore which file "wins" a Selector's
|
||||
// last-one-wins tie-break for any resource ID more than one file
|
||||
// defines) is randomized per process run. Sort it ourselves so the same
|
||||
// input always resolves the same way.
|
||||
sort.Slice(entry.Resources, func(i, j int) bool { return entry.Resources[i].ID < entry.Resources[j].ID })
|
||||
if err := mod.World().InsertEntry(0, entry); err != nil {
|
||||
return fmt.Errorf("registering game data: %w", err)
|
||||
}
|
||||
@@ -156,6 +171,22 @@ func buildMod(dataDir, modDir, outDir, name string) error {
|
||||
}
|
||||
}
|
||||
|
||||
upscaleDefs, err := upscaleDefFiles(modDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(upscaleDefs) > 0 {
|
||||
pal, err := bitmap.NewPaletteCache(mod).Palette(resource.KeyOf(ids.GamePalettesStart, resource.LangDefault, 0))
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading game palette: %w", err)
|
||||
}
|
||||
for _, defFile := range upscaleDefs {
|
||||
if err := applyUpscaleDef(mod, pal, defFile); err != nil {
|
||||
return fmt.Errorf("%s: %w", defFile, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modOutDir := filepath.Join(outDir, "mod")
|
||||
if err := os.RemoveAll(modOutDir); err != nil {
|
||||
return err
|
||||
@@ -170,12 +201,7 @@ func buildMod(dataDir, modDir, outDir, name string) error {
|
||||
return fmt.Errorf("saving mod: %w", err)
|
||||
}
|
||||
|
||||
zipPath := filepath.Join(outDir, name+".zip")
|
||||
if err := zipDir(modOutDir, zipPath); err != nil {
|
||||
return fmt.Errorf("packaging zip: %w", err)
|
||||
}
|
||||
fmt.Printf(" mod: %s\n", modOutDir)
|
||||
fmt.Printf(" zip: %s\n", zipPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
// AI upscaling: modding/mods/<name>/bitmaps/*.json declares a shared set of
|
||||
// upscale parameters plus a list of resources to apply them to (see
|
||||
// ../../README.md section 4). Applying one means: decode its frames from
|
||||
// Static World Data, run each through the ONNX Runtime CPU upscaler baked
|
||||
// into mdlp-image, re-quantize the result to the game's own palette, and
|
||||
// write it back - all offline, at build time.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/serial/rle"
|
||||
"github.com/inkyblackness/hacked/ss1/world"
|
||||
)
|
||||
|
||||
// Every resource this file writes ends up in a file literally named
|
||||
// "unknown.res", since ids.Info() (hacked's own resource-metadata registry)
|
||||
// doesn't know any of the resource IDs mdlp upscales. A brand-new override
|
||||
// resource can only be created through the Modder interface, which always
|
||||
// names it via that registry's fallback, and hacked's own change-tracking
|
||||
// (what SaveModUnder actually writes) is keyed by the filename at write
|
||||
// time in a private field mdlp has no way to update, so renaming the file
|
||||
// afterward isn't an option. Harmless to leave as-is: the engine matches an
|
||||
// override by resource ID, not by which .res file carries it (the same
|
||||
// reason a mod can name its own override files anything).
|
||||
|
||||
var languageByName = map[string]resource.Language{
|
||||
"default": resource.LangDefault,
|
||||
"french": resource.LangFrench,
|
||||
"german": resource.LangGerman,
|
||||
"any": resource.LangAny,
|
||||
}
|
||||
|
||||
// upscaleDef is one modding/mods/<name>/bitmaps/<label>.json file: a shared
|
||||
// set of upscale parameters and every resource to apply them to. Model
|
||||
// is not consulted yet - mdlp-image bakes in a single ONNX model, applied
|
||||
// regardless of this field's value.
|
||||
type upscaleDef struct {
|
||||
Model string `json:"model"`
|
||||
Scale int `json:"scale"`
|
||||
Resources []string `json:"resources"`
|
||||
}
|
||||
|
||||
// parseMdlResource splits an animgallery-generated resource string
|
||||
// ("0x758" or "0x25_German") into its ID and language. A bare ID implies
|
||||
// language Any.
|
||||
func parseMdlResource(s string) (resource.ID, resource.Language, error) {
|
||||
hexPart := s
|
||||
lang := resource.LangAny
|
||||
if idx := strings.LastIndex(s, "_"); idx >= 0 {
|
||||
hexPart = s[:idx]
|
||||
name := strings.ToLower(s[idx+1:])
|
||||
l, ok := languageByName[name]
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("unknown language suffix in %q", s)
|
||||
}
|
||||
lang = l
|
||||
}
|
||||
hexPart = strings.TrimPrefix(strings.TrimPrefix(hexPart, "0x"), "0X")
|
||||
v, err := strconv.ParseUint(hexPart, 16, 32)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid resource id in %q: %w", s, err)
|
||||
}
|
||||
return resource.ID(v), lang, nil
|
||||
}
|
||||
|
||||
// resourceFrames is one resource's decoded, not-yet-upscaled frames,
|
||||
// waiting to be batched into a single upscale_batch.py invocation.
|
||||
type resourceFrames struct {
|
||||
label string // the original "resources" entry, for error messages
|
||||
id resource.ID
|
||||
lang resource.Language
|
||||
contentType resource.ContentType
|
||||
frames []*bitmap.Bitmap
|
||||
}
|
||||
|
||||
func applyUpscaleDef(mod *world.Mod, pal bitmap.Palette, defFile string) error {
|
||||
raw, err := os.ReadFile(defFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var def upscaleDef
|
||||
if err := json.Unmarshal(raw, &def); err != nil {
|
||||
return err
|
||||
}
|
||||
if def.Model == "" {
|
||||
return fmt.Errorf("model is required")
|
||||
}
|
||||
if def.Scale <= 0 {
|
||||
def.Scale = 2
|
||||
}
|
||||
|
||||
all := make([]resourceFrames, 0, len(def.Resources))
|
||||
for i, label := range def.Resources {
|
||||
fmt.Printf(" decoding %d/%d: %s\n", i+1, len(def.Resources), label)
|
||||
id, lang, err := parseMdlResource(label)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
view, err := mod.LocalizedResources(lang).Select(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: resource not found: %w", label, err)
|
||||
}
|
||||
|
||||
contentType := view.ContentType()
|
||||
var frames []*bitmap.Bitmap
|
||||
switch contentType {
|
||||
case resource.Animation:
|
||||
reader, err := view.Block(0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
anim, err := bitmap.ReadAnimation(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
frames, err = decodeAnimationFrames(mod, lang, anim)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
case resource.Bitmap:
|
||||
frames, err = decodeViewFrames(view)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%s: unsupported content type %s", label, contentType)
|
||||
}
|
||||
if len(frames) == 0 {
|
||||
return fmt.Errorf("%s: no frames decoded", label)
|
||||
}
|
||||
all = append(all, resourceFrames{label: label, id: id, lang: lang, contentType: contentType, frames: frames})
|
||||
}
|
||||
|
||||
upscaledByResource, err := upscaleBatch(all, pal, def.Model, def.Scale)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, rf := range all {
|
||||
fmt.Printf(" writing %d/%d: %s\n", i+1, len(all), rf.label)
|
||||
upscaled := upscaledByResource[i]
|
||||
if rf.contentType == resource.Animation {
|
||||
for frameIdx, frame := range upscaled {
|
||||
if err := writeRawResource(mod, rf.lang, rf.id.Plus(frameIdx), resource.Bitmap, encodeFrames([]*bitmap.Bitmap{frame})); err != nil {
|
||||
return fmt.Errorf("%s: %w", rf.label, err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := writeRawResource(mod, rf.lang, rf.id, resource.Bitmap, encodeFrames(upscaled)); err != nil {
|
||||
return fmt.Errorf("%s: %w", rf.label, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeViewFrames reads every block of a compound bitmap resource as a
|
||||
// sequence of frames, each one RLE-diffed against the previous - mirrors
|
||||
// animgallery's decodeFrames. Each block declares its own width/height, so
|
||||
// the reference buffer handed to DecodeReferenced must match that block's
|
||||
// own size, not blindly reuse the previous frame's.
|
||||
func decodeViewFrames(view resource.View) ([]*bitmap.Bitmap, error) {
|
||||
count := view.BlockCount()
|
||||
frames := make([]*bitmap.Bitmap, 0, count)
|
||||
var prevPixels []byte
|
||||
for i := 0; i < count; i++ {
|
||||
reader, err := view.Block(i)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||
}
|
||||
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||
size := int(w) * int(h)
|
||||
buf := make([]byte, size)
|
||||
if len(prevPixels) == size {
|
||||
copy(buf, prevPixels)
|
||||
}
|
||||
return buf, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||
}
|
||||
prevPixels = bmp.Pixels
|
||||
frames = append(frames, bmp)
|
||||
}
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
// decodeAnimationFrames resolves an Animation descriptor's actual frame
|
||||
// bitmaps, stored as separate resources at anim.ResourceID+N - mirrors
|
||||
// animgallery's renderAnimationDescriptor.
|
||||
func decodeAnimationFrames(mod *world.Mod, lang resource.Language, anim bitmap.Animation) ([]*bitmap.Bitmap, error) {
|
||||
lastFrame := 0
|
||||
for _, entry := range anim.Entries {
|
||||
if int(entry.LastFrame) > lastFrame {
|
||||
lastFrame = int(entry.LastFrame)
|
||||
}
|
||||
}
|
||||
var frames []*bitmap.Bitmap
|
||||
var prevPixels []byte
|
||||
for frameIndex := 0; frameIndex <= lastFrame; frameIndex++ {
|
||||
frameView, err := mod.LocalizedResources(lang).Select(anim.ResourceID.Plus(frameIndex))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||
}
|
||||
reader, err := frameView.Block(0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||
}
|
||||
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||
size := int(w) * int(h)
|
||||
buf := make([]byte, size)
|
||||
if len(prevPixels) == size {
|
||||
copy(buf, prevPixels)
|
||||
}
|
||||
return buf, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||
}
|
||||
prevPixels = bmp.Pixels
|
||||
frames = append(frames, bmp)
|
||||
}
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
// bitmapToImage renders a decoded frame as RGBA, treating palette index 0
|
||||
// as fully transparent when the source bitmap says so - the upscaler needs
|
||||
// to see real transparency, not just whatever color happens to sit at
|
||||
// index 0, or it will treat the background like normal image content.
|
||||
func bitmapToImage(bmp *bitmap.Bitmap, pal bitmap.Palette) *image.RGBA {
|
||||
effPal := pal
|
||||
if bmp.Palette != nil {
|
||||
effPal = *bmp.Palette
|
||||
}
|
||||
colors := effPal.ColorPalette(false)
|
||||
transparent := bmp.Header.Flags&bitmap.FlagTransparent != 0
|
||||
|
||||
w, h := int(bmp.Header.Width), int(bmp.Header.Height)
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for i, idx := range bmp.Pixels {
|
||||
if transparent && idx == 0 {
|
||||
continue // already zero alpha
|
||||
}
|
||||
r, g, b, _ := colors[idx].RGBA()
|
||||
img.Pix[i*4+0] = byte(r >> 8)
|
||||
img.Pix[i*4+1] = byte(g >> 8)
|
||||
img.Pix[i*4+2] = byte(b >> 8)
|
||||
img.Pix[i*4+3] = 0xff
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// imageToBitmap re-quantizes an RGBA image back to the game's palette.
|
||||
// Pixels below the alpha threshold become palette index 0 directly
|
||||
// (skipping color matching entirely) rather than trusting the nearest-color
|
||||
// search to land on it - Bitmapper has no concept of transparency, it would
|
||||
// happily map a half-transparent edge pixel to any color that looks close.
|
||||
//
|
||||
// origAnchor is the original frame's own Area[0]/Area[1] - for critter/object
|
||||
// art this is a single anchor point (Area[2]/[3] are always exactly
|
||||
// Area[0]/[1]+1, a degenerate one-pixel rect, not a real bounding box), which
|
||||
// the engine's billboard sprite renderer (g3_bitmap_common(), see
|
||||
// engine-patches/04-desktop-fix-critter-sprite-scale.patch) uses to place the
|
||||
// sprite in world space. Written through unscaled, not scaled by the same
|
||||
// factor as the image: that patch's cache overrides the *size* half of this
|
||||
// bitmap's world-space math with the original, un-upscaled dimensions, so
|
||||
// the anchor has to stay in that same native unit space to match, or the
|
||||
// sprite renders at the right size but visibly offset from where it should
|
||||
// sit. Writing {0,0} here (as if there were no anchor) makes the renderer
|
||||
// fall back to a path that ignores that patch's size override entirely.
|
||||
func imageToBitmap(img *image.RGBA, pal bitmap.Palette, transparent bool, origAnchor bitmap.Area) *bitmap.Bitmap {
|
||||
bounds := img.Bounds()
|
||||
w, h := bounds.Dx(), bounds.Dy()
|
||||
bitmapper := bitmap.NewBitmapper(&pal)
|
||||
pixels := make([]byte, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
c := img.RGBAAt(bounds.Min.X+x, bounds.Min.Y+y)
|
||||
if transparent && c.A < 0x80 {
|
||||
pixels[y*w+x] = 0
|
||||
continue
|
||||
}
|
||||
pixels[y*w+x] = bitmapper.MapColor(color.RGBA{R: c.R, G: c.G, B: c.B, A: 0xff})
|
||||
}
|
||||
}
|
||||
|
||||
highestBitShift := func(v int16) (r byte) {
|
||||
for v>>r != 1 {
|
||||
r++
|
||||
}
|
||||
return
|
||||
}
|
||||
flags := bitmap.Flag(0)
|
||||
if transparent {
|
||||
flags = bitmap.FlagTransparent
|
||||
}
|
||||
anchorX := origAnchor[0]
|
||||
anchorY := origAnchor[1]
|
||||
return &bitmap.Bitmap{
|
||||
Header: bitmap.Header{
|
||||
Type: bitmap.TypeCompressed8Bit,
|
||||
Flags: flags,
|
||||
Width: int16(w),
|
||||
Height: int16(h),
|
||||
Stride: uint16(w),
|
||||
WidthFactor: highestBitShift(int16(w)),
|
||||
HeightFactor: highestBitShift(int16(h)),
|
||||
Area: bitmap.Area{anchorX, anchorY, anchorX + 1, anchorY + 1},
|
||||
},
|
||||
Pixels: pixels,
|
||||
}
|
||||
}
|
||||
|
||||
// upscaleBatch runs every frame from every given resource through exactly
|
||||
// one upscale_batch.py invocation (its own directory-in/directory-out batch
|
||||
// mode) and returns each resource's upscaled frames in the same order as
|
||||
// the input slice. A single model load is shared across the whole batch -
|
||||
// the fixed per-process model-load cost otherwise dwarfs the actual
|
||||
// upscaling time for the many small sprite frames one upscale definition
|
||||
// typically has.
|
||||
func upscaleBatch(all []resourceFrames, pal bitmap.Palette, model string, scale int) ([][]*bitmap.Bitmap, error) {
|
||||
inDir, err := os.MkdirTemp("", "mdlp-upscale-in-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(inDir)
|
||||
outDir, err := os.MkdirTemp("", "mdlp-upscale-out-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(outDir)
|
||||
|
||||
frameName := func(resIdx, frameIdx int) string { return fmt.Sprintf("%d_%d.png", resIdx, frameIdx) }
|
||||
|
||||
for ri, rf := range all {
|
||||
for fi, frame := range rf.frames {
|
||||
f, err := os.Create(filepath.Join(inDir, frameName(ri, fi)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = png.Encode(f, bitmapToImage(frame, pal))
|
||||
closeErr := f.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, closeErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalFrames := 0
|
||||
for _, rf := range all {
|
||||
totalFrames += len(rf.frames)
|
||||
}
|
||||
fmt.Printf(" upscaling %d resource(s), %d frame(s) total (model %s, scale %d):\n",
|
||||
len(all), totalFrames, model, scale)
|
||||
|
||||
// UPSCALE_MODEL_PATH (set by mdlp-image's Dockerfile) points at the ONNX
|
||||
// model baked into the image.
|
||||
modelPath := os.Getenv("UPSCALE_MODEL_PATH")
|
||||
if modelPath == "" {
|
||||
return nil, fmt.Errorf("UPSCALE_MODEL_PATH is not set - see mdlp-image/Dockerfile")
|
||||
}
|
||||
cmd := exec.Command("python3", "/usr/local/bin/upscale_batch.py", modelPath, inDir, outDir, strconv.Itoa(scale))
|
||||
// Streamed live, not buffered via CombinedOutput: a single directory
|
||||
// batch can run for a while, and silence until the whole thing exits
|
||||
// gives no sense that it's still making progress.
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("upscale_batch.py: %w", err)
|
||||
}
|
||||
|
||||
result := make([][]*bitmap.Bitmap, len(all))
|
||||
for ri, rf := range all {
|
||||
upscaled := make([]*bitmap.Bitmap, len(rf.frames))
|
||||
for fi, frame := range rf.frames {
|
||||
outPath := filepath.Join(outDir, frameName(ri, fi))
|
||||
outFile, err := os.Open(outPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s frame %d: %w", rf.label, fi, err)
|
||||
}
|
||||
upscaledImg, err := png.Decode(outFile)
|
||||
closeErr := outFile.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s frame %d: %w", rf.label, fi, err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, closeErr
|
||||
}
|
||||
rgba := toRGBA(upscaledImg)
|
||||
transparent := frame.Header.Flags&bitmap.FlagTransparent != 0
|
||||
upscaled[fi] = imageToBitmap(rgba, pal, transparent, frame.Header.Area)
|
||||
}
|
||||
result[ri] = upscaled
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func toRGBA(img image.Image) *image.RGBA {
|
||||
if rgba, ok := img.(*image.RGBA); ok {
|
||||
return rgba
|
||||
}
|
||||
b := img.Bounds()
|
||||
converted := image.NewRGBA(b)
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
converted.Set(x, y, img.At(x, y))
|
||||
}
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
// encodeFrames RLE-diffs each frame against the previous, matching the
|
||||
// convention hacked's own editor/animations/View.go uses when importing a
|
||||
// GIF (the first frame diffs against an implicit all-zero reference).
|
||||
func encodeFrames(frames []*bitmap.Bitmap) [][]byte {
|
||||
blocks := make([][]byte, 0, len(frames))
|
||||
var prevPixels []byte
|
||||
for _, bmp := range frames {
|
||||
buf := new(bytes.Buffer)
|
||||
_ = binary.Write(buf, binary.LittleEndian, &bmp.Header)
|
||||
_ = rle.Compress(buf, bmp.Pixels, prevPixels)
|
||||
prevPixels = bmp.Pixels
|
||||
blocks = append(blocks, buf.Bytes())
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
// writeRawResource writes a brand-new override resource with explicit
|
||||
// metadata, bypassing hacked's own ids.Info()-based defaults (see
|
||||
// upscaleFileFallback's comment for why that's necessary here).
|
||||
func writeRawResource(mod *world.Mod, lang resource.Language, id resource.ID, contentType resource.ContentType, blocks [][]byte) error {
|
||||
mod.Modify(func(modder world.Modder) {
|
||||
modder.SetResourceBlocks(lang, id, blocks)
|
||||
})
|
||||
for _, loc := range mod.ModifiedResources() {
|
||||
if loc.Language != lang {
|
||||
continue
|
||||
}
|
||||
if _, err := loc.Store.Resource(id); err != nil {
|
||||
continue
|
||||
}
|
||||
return loc.Store.Put(id, resource.Resource{
|
||||
Properties: resource.Properties{Compound: true, ContentType: contentType, Compressed: true},
|
||||
Blocks: resource.BlocksFrom(blocks),
|
||||
})
|
||||
}
|
||||
return fmt.Errorf("internal error: resource not found in mod after write")
|
||||
}
|
||||
|
||||
func upscaleDefFiles(modDir string) ([]string, error) {
|
||||
return filepath.Glob(filepath.Join(modDir, "bitmaps", "*.json"))
|
||||
}
|
||||
Reference in New Issue
Block a user