Add AI-upscaled critter/object art as a new MDL mod type
build / build (push) Successful in 3m36s

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:
ml
2026-09-27 20:52:35 +02:00
parent 389abd7de7
commit ab2b778f44
25 changed files with 2009 additions and 35 deletions
+43 -1
View File
@@ -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
View File
@@ -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
+60
View File
@@ -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 "$@"
' _ "$@"
+24 -1
View File
@@ -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" \
+11 -1
View File
@@ -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
+20
View File
@@ -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
+59
View File
@@ -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"
]
}
+12
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+595
View File
@@ -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/&lt;name&gt;/upscale/&lt;label&gt;.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) &middot; %s</label><br>%dx%d, %d frame(s), %s &middot; %s &middot; %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 {
+35 -9
View File
@@ -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
}
+469
View File
@@ -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"))
}