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
+14 -2
View File
@@ -28,7 +28,8 @@ These come from the maintainer. Follow them without being asked again.
1. **Never commit.** Do not run `git commit` (or amend, rebase, push), even if 1. **Never commit.** Do not run `git commit` (or amend, rebase, push), even if
asked to commit. Draft the commit message and show it; the maintainer runs asked to commit. Draft the commit message and show it; the maintainer runs
the commit. Mention any new untracked file that has to be `git add`ed the commit. Mention any new untracked file that has to be `git add`ed
(typically a new patch under `android/engine-patches/`). (typically a new patch under `android/engine-patches/` or
`engine-patches/`).
2. **Do not build.** Do not run `make apk`, `./gradlew`, or `make android-studio` 2. **Do not build.** Do not run `make apk`, `./gradlew`, or `make android-studio`
to check that a change compiles. The maintainer deploys from Android Studio. to check that a change compiles. The maintainer deploys from Android Studio.
`make apk` and `make android-studio` overwrite `android/engine.properties` `make apk` and `make android-studio` overwrite `android/engine.properties`
@@ -38,7 +39,9 @@ These come from the maintainer. Follow them without being asked again.
tested. tested.
3. **`engine/` and `android/gl4es-src/` are never edited.** They are vendored 3. **`engine/` and `android/gl4es-src/` are never edited.** They are vendored
snapshots. Every change to them is a numbered patch under snapshots. Every change to them is a numbered patch under
`android/engine-patches/` or `android/gl4es-patches/`, applied to a scratch `android/engine-patches/` or `android/gl4es-patches/` (Quest-specific
fixes) or `engine-patches/` (general fixes, applied to the desktop
Linux/Windows builds too - README section 4.4), applied to a scratch
copy at build time. (`engine/` is GPLv3, see README section 6.) copy at build time. (`engine/` is GPLv3, see README section 6.)
4. **Patch numbers stay contiguous** (`01`, `02`, ... with no gap). When you 4. **Patch numbers stay contiguous** (`01`, `02`, ... with no gap). When you
insert or delete a patch, renumber the rest and update every reference to insert or delete a patch, renumber the rest and update every reference to
@@ -68,6 +71,7 @@ These come from the maintainer. Follow them without being asked again.
|---|---| |---|---|
| `engine/` | Vendored Shockolate source. Read-only for us. | | `engine/` | Vendored Shockolate source. Read-only for us. |
| `android/engine-patches/` | Numbered `patch -p1` files applied to a scratch copy of `engine/`. | | `android/engine-patches/` | Numbered `patch -p1` files applied to a scratch copy of `engine/`. |
| `engine-patches/` | Same, but general fixes applied to the desktop Linux/Windows builds too (README section 4.4), not Quest-specific ones. |
| `android/gl4es-src/`, `android/gl4es-patches/` | Vendored GL4ES (desktop GL to GLES) and its patches. | | `android/gl4es-src/`, `android/gl4es-patches/` | Vendored GL4ES (desktop GL to GLES) and its patches. |
| `android/app/src/main/cpp/` | First-party native code (section 4). | | `android/app/src/main/cpp/` | First-party native code (section 4). |
| `android/app/src/main/java/de/ladkau/questshock/` | First-party Java (section 4). `org/libsdl/app/` is vendored SDL2 glue (zlib). | | `android/app/src/main/java/de/ladkau/questshock/` | First-party Java (section 4). `org/libsdl/app/` is vendored SDL2 glue (zlib). |
@@ -185,6 +189,14 @@ running" and gates the cheats and the menu pause.
## 6. Engine patches ## 6. Engine patches
This section is written for `android/engine-patches/`; `engine-patches/`
(general fixes, also applied to the desktop Linux/Windows builds - README
section 4.4) follows the same process below except: no `#ifdef __ANDROID__`
guard (these aren't Android-specific), save as
`engine-patches/NN-desktop-<topic>.patch`, and there is no Gradle
`stageEngine` to worry about going stale - `build-image/build-engine.sh`/
`build-engine-win.sh` re-stage and re-patch on every build.
Rules: Rules:
- Guard every engine change with `#ifdef __ANDROID__` so the desktop build is - Guard every engine change with `#ifdef __ANDROID__` so the desktop build is
+79 -5
View File
@@ -69,7 +69,11 @@ Section "5.1. Installing and playing" has the details.
## 2. Layout ## 2. Layout
- `engine/` - a vendored snapshot of the Shockolate engine source. Built - `engine/` - a vendored snapshot of the Shockolate engine source. Built
via Docker; see "4. Desktop build" below. via Docker; see "4. Desktop build" below. `engine-patches/`
holds general (not Android-specific) fixes applied to a scratch copy at
build time - same convention as `android/engine-patches/` below, but
for the Linux/Windows desktop builds; `engine/` itself is never
modified. See "4.4. Engine patch reference" below for what each one does.
- `build-image/` - the Dockerfile (and supporting scripts) for the engine - `build-image/` - the Dockerfile (and supporting scripts) for the engine
build environment. Every third-party dependency the engine needs to build environment. Every third-party dependency the engine needs to
compile (SDL2, SDL2_mixer, the fluidsynth-lite MIDI synth, a MIDI compile (SDL2, SDL2_mixer, the fluidsynth-lite MIDI synth, a MIDI
@@ -244,10 +248,11 @@ of `build-image/Dockerfile`) - no Windows machine, Wine, or VM involved
in building it. `engine/CMakeLists.txt` already has working `WIN32`/ in building it. `engine/CMakeLists.txt` already has working `WIN32`/
`MINGW` branches from Shockolate's own upstream Windows build (built `MINGW` branches from Shockolate's own upstream Windows build (built
natively via Git Bash/MinGW on a real Windows machine - see natively via Git Bash/MinGW on a real Windows machine - see
`engine/build_win64.sh`/`engine/appveyor.yml`), so unlike the Quest `engine/build_win64.sh`/`engine/appveyor.yml`) - this cross-compiles those
build's `android/engine-patches/`, no source patching is needed here - same branches offline and reproducibly, from Linux, via Docker. It applies
this cross-compiles those same branches offline and reproducibly, from the same `engine-patches/` series the Linux build does (section
Linux, via Docker. 4.4); unlike the Quest build's `android/engine-patches/`, no
Windows-specific source patching is needed on top of that.
`dist-win/`/the packaged `.zip` ship `systemshock.exe` with its DLLs `dist-win/`/the packaged `.zip` ship `systemshock.exe` with its DLLs
(SDL2, SDL2_mixer, GLEW, fluidsynth-lite, plus the MinGW pthread runtime) (SDL2, SDL2_mixer, GLEW, fluidsynth-lite, plus the MinGW pthread runtime)
@@ -306,6 +311,75 @@ Both CI systems read the key from these secrets: `ANDROID_KEYSTORE_BASE64`,
produces a debug APK and the GitHub build fails on a tag. `*.jks` and produces a debug APK and the GitHub build fails on a tag. `*.jks` and
`*.keystore` are git-ignored. `*.keystore` are git-ignored.
### 4.4. Engine patch reference
`engine-patches/` (applied to a scratch copy of `engine/` by
`build-image/build-engine.sh` and `build-engine-win.sh`) is a plain,
numbered patch series, same convention as the Quest build's
`android/engine-patches/` (section 5.4) - `engine/` itself is never
modified. It only carries fixes that are general (not Android-specific);
anything Quest/OpenXR-only belongs in `android/engine-patches/` instead,
which the desktop build does not apply.
- **`01-desktop-texture-row-length.patch`** - `opengl_cache_texture()`/
`set_texture()` (`OpenGL.cc`) passed the original 8-bit bitmap's own row
stride (`bm->row`) to `GL_UNPACK_ROW_LENGTH`, describing the wrong
layout to OpenGL for the actually-uploaded buffer (a separately
allocated RGBA32 conversion, `converted->pixels`) whenever the two
strides differ - corrupting the texture upload. Uses
`converted->pitch / 4` instead.
- **`02-desktop-audio-error-detail.patch`** - logs `SDL_GetError()`
alongside "Couldn't open audio device" instead of just the bare message.
- **`03-desktop-fix-mod-resource-priority.patch`** - `LoadModFiles()`
(`Modding.c`) registers mod resource files once at startup, but two
places re-open the base game's own object/texture art files afterward -
and opening a resource file re-registers every ID it contains,
unconditionally, with no check for whether a mod already claimed it (see
`ResReadDirEntries()` in `resfile.c`). That silently reverted any mod
override for that art back to the base game's own graphics, twice over:
`obj_set_secondary_properties()` (`objsim.c`) re-opens `objart2.res`/
`objart3.res` transiently, just to read frame-count metadata; later in
the same startup sequence, `load_dynamic_memory()` (`cybmem.c`) re-opens
`texture.res`/`handart.res`/`objart2.res`/`objart3.res` again, this time
persistently - these are the handles actual gameplay renders from, so
this second point is what mattered for the mod override actually
showing up in-game. Fixed by re-running `LoadModFiles()` right after each
place's re-opens, so mods regain priority. Re-registering a mod file's
resource IDs means actually reopening it (there's no "just re-register"
primitive), so `LoadModFiles()` now closes each mod file's previous
handle first - one open handle per mod file at a time, rather than
leaking a fresh file slot on every repeat call (a new game or a loaded
save can both re-run this whole sequence).
- **`04-desktop-fix-critter-sprite-scale.patch`** - critters render as
camera-facing 2D billboards (`gameobj.c`'s `FAUBJ_CRIT` case), not
UV-mapped 3D polygons, and their on-screen size comes directly from the
bitmap's own pixel width/height (`Bitmap.c`'s `g3_bitmap_common()`, via
`_g3d_bitmap_u_anchor`/`_g3d_bitmap_v_anchor` and `bm_w`/`bm_h`) - so a
mod that replaces a critter's art with a higher-resolution bitmap for
more detail made the critter itself render bigger, proportionally to
the resolution increase. Fixed by caching every critter view's base-game
bitmap size (`objsim.c`'s `cache_critter_native_size()`/
`get_critter_native_size()`, keyed by resource ID) while
`objart2.res`/`objart3.res`'s base-game data is still the active
resolver in `obj_set_secondary_properties()` (before patch 03's
`LoadModFiles()` call hands priority to a mod), then using that cached
size instead of the bitmap's own (possibly modded) dimensions when
computing the sprite's on-screen extent - threaded through
`_fr_draw_bitmap()` and `g3_light_anchor_bitmap()`'s new `native_w`/
`native_h` parameters, consumed and reset by `g3_bitmap_common()` so an
unrelated bitmap draw never inherits a stale override. A mod's higher
resolution now only changes visible detail, not the object's size.
This depends on the mod's bitmap keeping a real anchor point in its own
`Header.Area`, in the original, un-upscaled unit space (see
`modding/src/mdlp/upscale.go`'s `imageToBitmap()`) - `_fr_draw_bitmap()`
only forwards `native_w`/`native_h` down the anchor-aware rendering path
when given a positive anchor, so a zeroed-out one silently skips this
patch's override entirely, and an anchor scaled to the mod's higher
resolution instead of left in the original unit space visibly offsets
the sprite from where it should sit, since it shares its coordinate
system with this patch's own size override rather than with the
bitmap's real pixel dimensions.
## 5. Android / Quest build ## 5. Android / Quest build
**Prerequisites:** **Prerequisites:**
+1 -1
View File
@@ -124,7 +124,7 @@ ENV DEBIAN_FRONTEND=noninteractive
# of packages that didn't change. # of packages that didn't change.
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake make git curl ca-certificates pkg-config gosu \ build-essential cmake make git curl ca-certificates pkg-config gosu \
libgl1-mesa-dev libglx-dev libxext-dev libx11-dev libxrandr-dev \ patch libgl1-mesa-dev libglx-dev libxext-dev libx11-dev libxrandr-dev \
libxi-dev libxfixes-dev libxss-dev libxinerama-dev libxcursor-dev \ libxi-dev libxfixes-dev libxss-dev libxinerama-dev libxcursor-dev \
libogg-dev libvorbis-dev libasound2-dev openssh-client \ libogg-dev libvorbis-dev libasound2-dev openssh-client \
openjdk-17-jdk-headless unzip mingw-w64 zip \ openjdk-17-jdk-headless unzip mingw-w64 zip \
+9 -2
View File
@@ -5,8 +5,9 @@
# already has working WIN32/MINGW branches (the upstream Shockolate # already has working WIN32/MINGW branches (the upstream Shockolate
# project's own build_win64.sh/appveyor.yml build the same way, natively # project's own build_win64.sh/appveyor.yml build the same way, natively
# on Windows via Git Bash/MinGW; this cross-compiles the same branches # on Windows via Git Bash/MinGW; this cross-compiles the same branches
# fully offline and reproducibly from Linux instead), so no source # fully offline and reproducibly from Linux instead), so building it needs
# patching is needed, unlike the Quest build's android/engine-patches/. # no patches of its own beyond engine-patches/ (applied below),
# unlike the Quest build's android/engine-patches/.
# #
# Builds from a scratch copy of engine/ (like android/engine-patches/ # Builds from a scratch copy of engine/ (like android/engine-patches/
# does for the Quest build) rather than engine/ itself: its BUNDLED # does for the Quest build) rather than engine/ itself: its BUNDLED
@@ -47,6 +48,12 @@ fi
rm -rf "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir" rm -rf "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir"
cp "${CP_FLAGS[@]}" "$REPO_ROOT/engine" "$SCRATCH_ENGINE" cp "${CP_FLAGS[@]}" "$REPO_ROOT/engine" "$SCRATCH_ENGINE"
echo "== Applying engine-patches/ =="
for p in "$REPO_ROOT"/engine-patches/*.patch; do
patch -p1 -d "$SCRATCH_ENGINE" < "$p"
done
cd "$SCRATCH_ENGINE" cd "$SCRATCH_ENGINE"
# If engine/ has ever been built in-source directly (`make dist`/`make # If engine/ has ever been built in-source directly (`make dist`/`make
+28 -3
View File
@@ -8,16 +8,41 @@
# container - see the Makefile's `engine` target, which picks whichever # container - see the Makefile's `engine` target, which picks whichever
# of these applies). # of these applies).
# #
# Builds from a scratch copy of engine/, patched with engine-patches/
# (see that directory and README.md section 4.4) - same reasoning as
# build-engine-win.sh's own scratch copy: engine/ itself is never modified in
# place (README.md section 1).
#
# Output lands in engine/.build-output/: the systemshock binary, the # Output lands in engine/.build-output/: the systemshock binary, the
# shared libraries it needs at runtime (lib/), and a default MIDI # shared libraries it needs at runtime (lib/), and a default MIDI
# soundfont - everything the root Makefile needs to assemble dist/. # soundfont - everything the root Makefile needs to assemble dist/.
set -euo pipefail set -euo pipefail
REPO_ROOT="$(pwd)" REPO_ROOT="$(pwd)"
ENGINE_DIR="$REPO_ROOT/engine" SCRATCH_ENGINE="$REPO_ROOT/build/linux-engine"
OUT_DIR="$ENGINE_DIR/.build-output" OUT_DIR="$REPO_ROOT/engine/.build-output"
cd "$ENGINE_DIR" echo "== Preparing a scratch copy of engine/, patched with engine-patches/ =="
rm -rf "$SCRATCH_ENGINE"
mkdir -p "$(dirname "$SCRATCH_ENGINE")"
cp -a "$REPO_ROOT/engine" "$SCRATCH_ENGINE"
# A checkout built in place by an older version of this script (before it
# staged a scratch copy) can still have build_ext/CMakeCache.txt etc.
# sitting in the real engine/ - copied along above, they'd make CMake
# refuse to configure this copy ("directory is different than the
# directory where CMakeCache.txt was created"). Same cleanup
# build-engine-win.sh's own scratch copy does.
rm -rf "$SCRATCH_ENGINE"/build_ext "$SCRATCH_ENGINE"/CMakeCache.txt \
"$SCRATCH_ENGINE"/CMakeFiles "$SCRATCH_ENGINE"/cmake_install.cmake \
"$SCRATCH_ENGINE"/Makefile "$SCRATCH_ENGINE"/systemshock \
"$SCRATCH_ENGINE"/src/Libraries/CMakeFiles
for p in "$REPO_ROOT"/engine-patches/*.patch; do
patch -p1 -d "$SCRATCH_ENGINE" < "$p"
done
cd "$SCRATCH_ENGINE"
echo "== Wiring up prebuilt SDL2/SDL2_mixer/fluidsynth-lite from the image ==" echo "== Wiring up prebuilt SDL2/SDL2_mixer/fluidsynth-lite from the image =="
rm -rf build_ext rm -rf build_ext
@@ -0,0 +1,30 @@
--- a/src/MacSrc/OpenGL.cc 2026-07-19 13:11:51.000000000 +0200
+++ b/src/MacSrc/OpenGL.cc 2026-09-27 11:42:22.497868697 +0200
@@ -562,7 +562,14 @@
static bool opengl_cache_texture(CachedTexture toCache, grs_bitmap *bm) {
SDL_GL_MakeCurrent(window, context);
- glPixelStorei(GL_UNPACK_ROW_LENGTH, bm->row);
+ // GL_UNPACK_ROW_LENGTH must describe the buffer actually passed to
+ // glTexImage2D() below (toCache.converted->pixels, a separately
+ // allocated SDL_PIXELFORMAT_RGBA32 surface from
+ // SDL_ConvertSurfaceFormat()) - not bm->row, the original 8-bit source
+ // bitmap's own unrelated row stride. Using bm->row here describes the
+ // wrong stride to OpenGL whenever the two differ, corrupting the
+ // texture upload.
+ glPixelStorei(GL_UNPACK_ROW_LENGTH, toCache.converted->pitch / 4);
if (texturesByBitsPtr.size() < MAX_CACHED_TEXTURES) {
// We have enough room, generate the new texture
@@ -696,7 +703,10 @@
t->lastDrawTime = *tmd_ticks;
if (isDirty) {
- glPixelStorei(GL_UNPACK_ROW_LENGTH, bm->row);
+ // Same fix as opengl_cache_texture() above - t->converted->pixels is
+ // what's actually uploaded, so its own pitch is what
+ // GL_UNPACK_ROW_LENGTH needs to describe, not bm->row.
+ glPixelStorei(GL_UNPACK_ROW_LENGTH, t->converted->pitch / 4);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bm->w, bm->h, 0, GL_RGBA, GL_UNSIGNED_BYTE, t->converted->pixels);
}
}
@@ -0,0 +1,11 @@
--- a/src/MacSrc/SDLSound.c 2026-07-19 13:11:51.000000000 +0200
+++ b/src/MacSrc/SDLSound.c 2026-09-27 11:42:37.969726579 +0200
@@ -42,7 +42,7 @@
}
if (Mix_OpenAudio(48000, AUDIO_S16SYS, 2, 2048) < 0) {
- ERROR("%s: Couldn't open audio device", __FUNCTION__);
+ ERROR("%s: Couldn't open audio device: %s", __FUNCTION__, SDL_GetError());
}
Mix_AllocateChannels(SND_MAX_SAMPLES);
@@ -0,0 +1,98 @@
--- a/src/MacSrc/Modding.c 2026-07-19 13:11:51.000000000 +0200
+++ b/src/MacSrc/Modding.c 2026-09-27 19:44:24.718059783 +0200
@@ -27,6 +27,11 @@
char *modding_additional_files[MAX_MOD_FILES];
int num_mod_files;
+// One filenum per modding_additional_files[] entry, or -1 if not open yet -
+// lets LoadModFiles() close a mod file's previous handle before reopening it
+// (see LoadModFiles() below for why it needs to be called more than once).
+static int32_t modding_loaded_filenum[MAX_MOD_FILES];
+
int StringEndsWith(char *src, char *dst) {
char *s = strrchr(src, '.');
@@ -86,6 +91,7 @@
modding_archive_override = NULL;
for (int i = 0; i < MAX_MOD_FILES; i++) {
modding_additional_files[i] = NULL;
+ modding_loaded_filenum[i] = -1;
}
// Now go process args
@@ -135,10 +141,24 @@
}
int LoadModFiles() {
+ // Called again by obj_set_secondary_properties() (objsim.c) after it
+ // re-opens the base game's own object art files, which would otherwise
+ // silently reclaim any resource ID a mod file already overrode here (see
+ // ResReadDirEntries() in resfile.c - opening a file re-registers every
+ // resource ID it contains, last one in wins, with no check for whether
+ // an ID is already claimed). Re-registering means actually re-opening
+ // (there is no "just re-register" primitive), so a mod file already open
+ // from an earlier call is closed first - one open handle per mod file at
+ // a time, rather than leaking a fresh resFile[] slot on every repeat call
+ // (new game, load game - object_data_load() can run more than once per
+ // process).
for (int i = 0; i < num_mod_files; i++) {
if (modding_additional_files[i] != NULL) {
+ if (modding_loaded_filenum[i] >= 0) {
+ ResCloseFile(modding_loaded_filenum[i]);
+ }
printf("Loading mod file %s\n", modding_additional_files[i]);
- ResOpenFile(modding_additional_files[i]);
+ modding_loaded_filenum[i] = ResOpenFile(modding_additional_files[i]);
}
}
--- a/src/GameSrc/objsim.c 2026-07-19 13:11:51.000000000 +0200
+++ b/src/GameSrc/objsim.c 2026-09-27 19:44:17.014441306 +0200
@@ -43,6 +43,7 @@
#include "gettmaps.h"
#include "lvldata.h"
#include "mapflags.h"
+#include "Modding.h"
#include "objart2.h"
#include "objart3.h"
#include "objects.h"
@@ -1877,6 +1878,13 @@
// Warning(("Problem opening object art cache!\n"));
return (ERR_NOEFFECT);
}
+ // The two opens above re-register every resource ID objart2.res/
+ // objart3.res contain, reclaiming any of them a mod file already
+ // overrode (see LoadModFiles() in Modding.c) - restore mod priority
+ // for the reftable reads below. load_dynamic_memory() (cybmem.c) reopens
+ // these same two files again later, persistently, for actual rendering -
+ // restoring priority there too.
+ LoadModFiles();
// Now go and set specific computed data, like frame counts
for (i = 0; i < NUM_CRITTER; i++) {
--- a/src/GameSrc/cybmem.c 2026-07-19 13:11:51.000000000 +0200
+++ b/src/GameSrc/cybmem.c 2026-09-27 19:44:24.718059783 +0200
@@ -33,6 +33,7 @@
#include "textmaps.h"
#include "objcrit.h"
#include "dynmem.h"
+#include "Modding.h"
#include "Shock.h"
#include "sideicon.h"
#include "criterr.h"
@@ -120,6 +121,15 @@
critical_error(CRITERR_RES | 8);
}
+ // The four opens above re-register every resource ID their files
+ // contain, reclaiming any of them a mod file already overrode (see
+ // LoadModFiles() in Modding.c) - these are the persistent handles
+ // actual gameplay renders from (unlike obj_set_secondary_properties()'s
+ // own transient open/close of objart2.res/objart3.res for reading
+ // frame-count metadata), so this is the last point mod priority needs
+ // restoring before rendering.
+ LoadModFiles();
+
loadcount |= mask;
}
@@ -0,0 +1,222 @@
--- a/src/GameSrc/objsim.c 2026-09-27 19:44:17.014441306 +0200
+++ b/src/GameSrc/objsim.c 2026-09-27 20:44:23.147083066 +0200
@@ -1867,6 +1867,40 @@
return (OK);
}
+// Every critter view's base-game bitmap size, keyed by resource ID - filled
+// in by cache_critter_native_size() below while objart2.res/objart3.res's
+// base-game data is still the active resolver for these IDs (before
+// LoadModFiles() hands priority to a mod again), read back by
+// get_critter_native_size() in gameobj.c. A mod that replaces a critter's
+// art with a different-resolution bitmap changes how much detail is visible,
+// not the object's on-screen size: g3_bitmap_common() (Bitmap.c) uses this
+// cached size instead of the (possibly modded, possibly larger or smaller)
+// bitmap's own width/height when computing the sprite's world-space extent.
+// Id is uint16_t, so a flat array indexed by it needs no bounds check.
+static short s_critterNativeW[65536];
+static short s_critterNativeH[65536];
+
+static void cache_critter_native_size(Id id) {
+ FrameDesc *f;
+ if (s_critterNativeW[id] != 0)
+ return;
+ f = (FrameDesc *)RefLock(MKREF(id, 0));
+ if (f == NULL)
+ return;
+ s_critterNativeW[id] = f->bm.w;
+ s_critterNativeH[id] = f->bm.h;
+ RefUnlock(MKREF(id, 0));
+}
+
+// w/h are left untouched (so a caller can default to 0, meaning "no cached
+// native size, use the bitmap's own dimensions") if id was never cached.
+void get_critter_native_size(Id id, short *w, short *h) {
+ if (s_critterNativeW[id] == 0)
+ return;
+ *w = s_critterNativeW[id];
+ *h = s_critterNativeH[id];
+}
+
errtype obj_set_secondary_properties() {
char i, j;
RefTable *prt;
@@ -1878,6 +1912,28 @@
// Warning(("Problem opening object art cache!\n"));
return (ERR_NOEFFECT);
}
+
+ // Snapshot every critter view's base-game bitmap size while it's still
+ // the active resolver for these IDs, before LoadModFiles() below hands
+ // priority back to a mod - see cache_critter_native_size() and its own
+ // comment. Full 8-view postures need every view individually (their IDs
+ // are 8 consecutive resource IDs, one per view); front-only postures are
+ // a single ID (matching the frame-count loop below).
+ for (i = 0; i < NUM_CRITTER; i++) {
+ if (CritterProps[i].views > 1) {
+ for (j = 0; j < NUM_CRITTER_POSTURES; j++) {
+ if (j >= FIRST_FRONT_POSTURE) {
+ cache_critter_native_size(posture_bases[j] + i);
+ } else {
+ int v;
+ for (v = 0; v < 8; v++) {
+ cache_critter_native_size(critter_id_table[i] + posture_bases[j] + v);
+ }
+ }
+ }
+ }
+ }
+
// The two opens above re-register every resource ID objart2.res/
// objart3.res contain, reclaiming any of them a mod file already
// overrode (see LoadModFiles() in Modding.c) - restore mod priority
--- a/src/GameSrc/Headers/objsim.h 2026-07-19 13:11:51.000000000 +0200
+++ b/src/GameSrc/Headers/objsim.h 2026-09-27 19:39:39.585009491 +0200
@@ -108,6 +108,7 @@
errtype obj_load_properties();
errtype obj_create_player(ObjLoc *plr_loc);
Ref ref_from_critter_data(ObjID oid, int triple, byte posture, short frame, short view);
+void get_critter_native_size(Id id, short *w, short *h);
errtype obj_zero_unused();
grs_bitmap *obj_get_model_data(ObjID id, fix *x, fix *y, fix *z, grs_bitmap *bm2, Ref *ref1, Ref *ref2);
errtype obj_model_hack(ObjID id, uchar *hack_x, uchar *hack_y, uchar *hack_z, uchar *hack_type);
--- a/src/GameSrc/gameobj.c 2026-07-19 13:11:51.000000000 +0200
+++ b/src/GameSrc/gameobj.c 2026-09-27 20:44:35.195019539 +0200
@@ -104,7 +104,7 @@
void draw_ice(void);
void draw_ice_wall(void);
void _fr_draw_tmtile(grs_bitmap *draw_bm, int col_val, g3s_phandle *plst, uchar dblface, uchar use_lighting);
-void _fr_draw_bitmap(grs_bitmap *draw_bm, int dist, int sc, int anch_x, int anch_y);
+void _fr_draw_bitmap(grs_bitmap *draw_bm, int dist, int sc, int anch_x, int anch_y, short native_w, short native_h);
int munge_val(int val, int range, int delta) {
int base = range - delta;
@@ -575,7 +575,7 @@
// note this always has show_obj's p for p and _fdt_dist for dist.. perhaps shouldnt pass them
//#pragma disable_message(202)
-void _fr_draw_bitmap(grs_bitmap *draw_bm, int dist, int sc, int anch_x, int anch_y) {
+void _fr_draw_bitmap(grs_bitmap *draw_bm, int dist, int sc, int anch_x, int anch_y, short native_w, short native_h) {
#ifdef SMOOTH_BITMAPS
grs_canvas tmp_can;
grs_bitmap tmp_bm;
@@ -597,7 +597,7 @@
if ((anch_x <= 0) && (anch_y <= 0))
bitmap_verts = g3_light_bitmap(draw_bm, anchor);
else
- bitmap_verts = g3_light_anchor_bitmap(draw_bm, anchor, anch_x, anch_y);
+ bitmap_verts = g3_light_anchor_bitmap(draw_bm, anchor, anch_x, anch_y, native_w, native_h);
if (sc)
g3_set_bitmap_scale(fix_make(0, (int)(2048 / 3)), fix_make(0, (int)(2048 / 3)));
@@ -855,7 +855,7 @@
if ((anchors_3d[o3drep].x > 0) || (anchors_3d[o3drep].y > 0))
_o_rad = 0;
_fr_draw_bitmap(bitmaps_3d[o3drep], _fdt_dist, ObjProps[objtrip].flags & MY_IM_LARGE, anchors_3d[o3drep].x,
- anchors_3d[o3drep].y);
+ anchors_3d[o3drep].y, 0, 0);
if (global_fullmap->cyber)
if (obj_ICE_ICE_BABY(_fr_cobj))
draw_ice();
@@ -995,7 +995,7 @@
trgp = tpdata->bits + tele_bm.h * tele_bm.w;
LG_memcpy(dstp, srcp, trgp - srcp);
- _fr_draw_bitmap(&tele_bm, _fdt_dist, FALSE, anch.ul.x, anch.ul.y);
+ _fr_draw_bitmap(&tele_bm, _fdt_dist, FALSE, anch.ul.x, anch.ul.y, 0, 0);
release_critter_bitmap_fast(ref);
return;
}
@@ -1016,11 +1016,15 @@
#endif
break;
}
- _fr_draw_bitmap(tpdata, _fdt_dist, 0, anch.ul.x, anch.ul.y);
+ {
+ short native_w = 0, native_h = 0;
+ get_critter_native_size(REFID(ref), &native_w, &native_h);
+ _fr_draw_bitmap(tpdata, _fdt_dist, 0, anch.ul.x, anch.ul.y, native_w, native_h);
+ }
release_critter_bitmap_fast(ref);
} else {
tpdata = bitmaps_3d[o3drep + view];
- _fr_draw_bitmap(tpdata, _fdt_dist, 0, -1, -1);
+ _fr_draw_bitmap(tpdata, _fdt_dist, 0, -1, -1, 0, 0);
}
} break;
--- a/src/Libraries/3D/Source/Bitmap.c 2026-07-19 13:11:51.000000000 +0200
+++ b/src/Libraries/3D/Source/Bitmap.c 2026-09-27 20:44:47.378954871 +0200
@@ -88,6 +88,14 @@
// fix _g3d_bitmap_y_iscale = 0x010000;
long _g3d_bitmap_u_anchor = 0;
long _g3d_bitmap_v_anchor = 0;
+// Non-zero overrides the bitmap's own w/h in g3_bitmap_common()'s world-space
+// size computation below, decoupling a bitmap's on-screen size from its
+// actual pixel dimensions - set by g3_light_anchor_bitmap(), consumed and
+// reset back to 0 by g3_bitmap_common() so it never leaks into an unrelated
+// bitmap drawn via a different entry point (g3_bitmap()/g3_light_bitmap()/
+// do_bitmap() never set these).
+short _g3d_bitmap_w_override = 0;
+short _g3d_bitmap_h_override = 0;
fix _g3d_roll_matrix[6];
uchar *_g3d_bitmap_clut;
int _g3d_light_flag;
@@ -132,10 +140,13 @@
return (g3_bitmap_common(bm, (g3s_phandle)p));
}
-grs_vertex **g3_light_anchor_bitmap(grs_bitmap *bm, g3s_phandle p, short u_anchor, short v_anchor) {
+grs_vertex **g3_light_anchor_bitmap(grs_bitmap *bm, g3s_phandle p, short u_anchor, short v_anchor, short w_override,
+ short h_override) {
_g3d_light_flag = 2;
_g3d_bitmap_u_anchor = u_anchor;
_g3d_bitmap_v_anchor = v_anchor;
+ _g3d_bitmap_w_override = w_override;
+ _g3d_bitmap_h_override = h_override;
_g3d_bitmap_clut = (p->i & 0x00ff00) + grd_screen->ltab;
_g3d_bitmap_poly = vpl;
return (g3_bitmap_common(bm, p));
@@ -185,6 +196,15 @@
long rm2;
long rm3;
+ // Read and reset right at entry, before any early return below - a
+ // pending override must never survive to affect a later, unrelated
+ // bitmap draw that doesn't set it itself (see _g3d_bitmap_w_override's
+ // own comment).
+ short w_override = _g3d_bitmap_w_override;
+ short h_override = _g3d_bitmap_h_override;
+ _g3d_bitmap_w_override = 0;
+ _g3d_bitmap_h_override = 0;
+
#ifdef stereo_on
if (_g3d_stereo & 1) {
@@ -209,9 +229,11 @@
if ((p->codes & CC_CLIP_OVERFLOW) != 0)
return (0L);
- // copy a few things into locals
- bm_w = bm->w;
- bm_h = bm->h;
+ // copy a few things into locals - w_override/h_override, if set (see
+ // _g3d_bitmap_w_override's own comment), take the place of the bitmap's
+ // own pixel dimensions here.
+ bm_w = w_override ? w_override : bm->w;
+ bm_h = h_override ? h_override : bm->h;
tempL = p->gZ; // mov eax,[edi].z
tempL <<= 8; // sal eax,8 ;should be
--- a/src/Libraries/3D/Source/3d.h 2026-07-19 13:11:51.000000000 +0200
+++ b/src/Libraries/3D/Source/3d.h 2026-09-27 19:42:26.528424908 +0200
@@ -720,7 +720,8 @@
grs_vertex **g3_light_bitmap(grs_bitmap *bm, g3s_phandle p);
// MLA #pragma aux g3_light_bitmap "*" parm [esi] [edi] modify [eax ebx ecx
// edx];
-grs_vertex **g3_light_anchor_bitmap(grs_bitmap *bm, g3s_phandle p, short u_anchor, short v_anchor);
+grs_vertex **g3_light_anchor_bitmap(grs_bitmap *bm, g3s_phandle p, short u_anchor, short v_anchor, short w_override,
+ short h_override);
// MLA #pragma aux g3_light_anchor_bitmap "*" parm [esi] [edi] [eax] [edx]
// modify [eax ebx ecx edx];
grs_vertex **g3_full_light_bitmap(grs_bitmap *bm, grs_vertex **p);
+43 -1
View File
@@ -13,8 +13,24 @@
# mods - builds every mod under mods/ via that image, offline. # mods - builds every mod under mods/ via that image, offline.
# Set MODS="name1 name2" to build only those. Output # Set MODS="name1 name2" to build only those. Output
# lands in dist/mods/. # 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/. # 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 export MODS
@@ -45,5 +61,31 @@ mods:
./mdlp-run-image.sh; \ ./mdlp-run-image.sh; \
fi 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: clean:
rm -rf dist rm -rf dist
+53 -10
View File
@@ -23,13 +23,13 @@ repository.
of `hacked/` at build time (same pattern as the root project's of `hacked/` at build time (same pattern as the root project's
`android/engine-patches/`, see the repository root's `android/engine-patches/`, see the repository root's
[AGENTS.md](../AGENTS.md) section 6) - `hacked/` itself is never edited [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 - `sqweek-dialog/` - `github.com/sqweek/dialog`, `hacked`'s native
file-dialog dependency. Vendored at the exact version `hacked/go.mod` file-dialog dependency. Vendored at the exact version `hacked/go.mod`
would otherwise pull from the module proxy (ISC License), so it can be 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 - `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 - `mdlp/` - the **Mod Definition Language Processor** (section 4). First-party
Go source, not vendored - edit it directly, no patch system needed. Go source, not vendored - edit it directly, no patch system needed.
- `ss-specs/` - [InkyBlackness ss-specs](https://github.com/inkyblackness/ss-specs), - `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 external tools that create and inspect that content, not about changing the
engine itself. 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` ## 2. Building `hacked`
Packages `dist/<version>.linux64.tgz` and `dist/<version>.win64.zip` (native 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/) ## 4. Mod Definition Language (mods/)
The workflow above is manual and its result (whatever you clicked through in MDL is a small, declarative JSON format for describing a mod's content -
`hacked`) isn't checked into git. `mods/` holds the same kind of edits as one file per piece of content (a message override, an upscaled sprite, and
plain, reviewable JSON instead, and `mdlp` (the **Mod Definition Language so on), naming the game resource it touches and the values to set, rather
Processor**, `mdlp/`) turns them into the same kind of mod folder and a zip, than the resource's actual binary data. `mdlp` (the **Mod Definition
reproducibly and offline. 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 ### 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 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. 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 Output lands in `dist/mods/<mod-name>/mod/` (the same shape `hacked`'s own
`hacked`'s own Project window Save produces) and `<mod-name>.zip`. 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` 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 (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. # needs to fetch a newer toolchain over the network at build time.
ARG GO_VERSION=1.27.1 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 \ RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl gosu \ ca-certificates curl gosu patch python3 python3-pip \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN pip3 install --no-cache-dir onnxruntime pillow numpy
# 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" \ RUN curl -sSLO "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \
&& tar -C /usr/local -xzf "go${GO_VERSION}.linux-amd64.tar.gz" \ && tar -C /usr/local -xzf "go${GO_VERSION}.linux-amd64.tar.gz" \
+11 -1
View File
@@ -22,10 +22,20 @@ OUT_DIR="$MODDING_ROOT/dist/mods"
[ -d "$DATA_DIR" ] \ [ -d "$DATA_DIR" ] \
|| { echo "Game data not found at $DATA_DIR - see the root README.md section 3." >&2; exit 1; } || { 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 ==" echo "== Building mdlp =="
BIN_DIR="$(mktemp -d)" BIN_DIR="$(mktemp -d)"
trap 'rm -rf "$BIN_DIR"' EXIT 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") MDLP_ARGS=(-data "$DATA_DIR" -mods "$MODDING_ROOT/mods" -out "$OUT_DIR")
if [ -n "${MODS:-}" ]; then 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 // Command mdlp (Mod Definition Language Processor) turns the declarative mod
// definitions under modding/mods/ into hacked-compatible mod folders and zip // definitions under modding/mods/ into hacked-compatible mod folders under
// archives under modding/dist/mods/. See ../../README.md for the MDL schema. // modding/dist/mods/, packaged together as modding/dist/questshock-mods.zip.
// See ../../README.md for the MDL schema.
package main package main
import ( import (
@@ -15,6 +16,7 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/inkyblackness/hacked/ss1/content/bitmap"
"github.com/inkyblackness/hacked/ss1/content/text" "github.com/inkyblackness/hacked/ss1/content/text"
"github.com/inkyblackness/hacked/ss1/edit" "github.com/inkyblackness/hacked/ss1/edit"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
@@ -108,10 +110,17 @@ func main() {
for _, name := range names { for _, name := range names {
fmt.Printf("== Building mod %q ==\n", name) fmt.Printf("== Building mod %q ==\n", name)
modOutDir := filepath.Join(*outDir, 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) 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) { func modNames(modsDir, only string) ([]string, error) {
@@ -132,13 +141,19 @@ func modNames(modsDir, only string) ([]string, error) {
return names, nil 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() {}) mod := world.NewMod(func([]resource.ID, []resource.ID) {}, func() {})
entry, err := world.NewManifestEntryFrom([]string{dataDir}) entry, err := world.NewManifestEntryFrom([]string{dataDir})
if err != nil { if err != nil {
return fmt.Errorf("loading game data from %s: %w", dataDir, err) 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 { if err := mod.World().InsertEntry(0, entry); err != nil {
return fmt.Errorf("registering game data: %w", err) 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") modOutDir := filepath.Join(outDir, "mod")
if err := os.RemoveAll(modOutDir); err != nil { if err := os.RemoveAll(modOutDir); err != nil {
return err return err
@@ -170,12 +201,7 @@ func buildMod(dataDir, modDir, outDir, name string) error {
return fmt.Errorf("saving mod: %w", err) 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(" mod: %s\n", modOutDir)
fmt.Printf(" zip: %s\n", zipPath)
return nil 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"))
}