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:
@@ -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
|
||||||
|
|||||||
@@ -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:**
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds and runs modding/src/animgallery/ (see README.md section 4) inside
|
||||||
|
# the mdlp-image's Go toolchain - animgallery has no cgo dependencies of its
|
||||||
|
# own, so it reuses that image rather than needing one of its own.
|
||||||
|
#
|
||||||
|
# Every argument is forwarded verbatim to animgallery itself. -data is
|
||||||
|
# repeatable and layers directories in the order given, later wins - the
|
||||||
|
# whole repository is mounted at /workspace here, so paths are relative to
|
||||||
|
# the repository root, for example:
|
||||||
|
#
|
||||||
|
# ./gen_animgallery.sh -data res/assets/ss_ee/data -out modding/build/gallery
|
||||||
|
# ./gen_animgallery.sh -data res/assets/ss_ee/data \
|
||||||
|
# -data modding/dist/mods/mymod/mod -out modding/build/gallery-mymod
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
MODDING_ROOT="$(pwd)"
|
||||||
|
REPO_ROOT="$(cd .. && pwd)"
|
||||||
|
|
||||||
|
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 \
|
||||||
|
|| fail "docker not found in PATH"
|
||||||
|
|
||||||
|
IMAGE="${MDLP_REGISTRY_IMAGE:-questshock/mdlp}"
|
||||||
|
if [ -f "$MODDING_ROOT/../registry.env" ]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$MODDING_ROOT/../registry.env"
|
||||||
|
IMAGE="${MDLP_REGISTRY_IMAGE:-$IMAGE}"
|
||||||
|
fi
|
||||||
|
IMAGE_TAG="$(<"$MODDING_ROOT/mdlp-image/VERSION")"
|
||||||
|
[ -n "$IMAGE_TAG" ] || fail "mdlp-image/VERSION is empty"
|
||||||
|
IMAGE="$IMAGE:$IMAGE_TAG"
|
||||||
|
|
||||||
|
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|
||||||
|
|| fail "$IMAGE not found locally - run ./mdlp-image.sh first"
|
||||||
|
|
||||||
|
TTY_FLAGS="-i"
|
||||||
|
[ -t 1 ] && TTY_FLAGS="-it"
|
||||||
|
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
docker run --rm $TTY_FLAGS \
|
||||||
|
-v "$REPO_ROOT:/workspace" \
|
||||||
|
-w /workspace \
|
||||||
|
-e HOST_UID="$(id -u)" \
|
||||||
|
-e HOST_GID="$(id -g)" \
|
||||||
|
"$IMAGE" \
|
||||||
|
bash -c '
|
||||||
|
set -euo pipefail
|
||||||
|
# animgallery, like mdlp, builds against ../hacked via its own
|
||||||
|
# go.mod replace - stage a patched copy (see src/hacked-patches/)
|
||||||
|
# rather than building against the unpatched vendored source.
|
||||||
|
SCRATCH_DIR="modding/build/animgallery-src"
|
||||||
|
rm -rf "$SCRATCH_DIR"
|
||||||
|
mkdir -p "$SCRATCH_DIR"
|
||||||
|
bash modding/mdlp-image/stage-hacked.sh modding "$SCRATCH_DIR/hacked"
|
||||||
|
cp -a modding/src/animgallery "$SCRATCH_DIR/animgallery"
|
||||||
|
(cd "$SCRATCH_DIR/animgallery" && go build -o /tmp/animgallery .)
|
||||||
|
exec /tmp/animgallery "$@"
|
||||||
|
' _ "$@"
|
||||||
@@ -11,10 +11,33 @@ FROM ubuntu:22.04
|
|||||||
# needs to fetch a newer toolchain over the network at build time.
|
# 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" \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stages a scratch, patched copy of src/hacked/ at the given destination -
|
||||||
|
# shared by process-mods.sh (mdlp) and ../gen_animgallery.sh (animgallery),
|
||||||
|
# both of which build against hacked's source directly (via their own
|
||||||
|
# go.mod's `replace ../hacked`) and so both need the same patches applied
|
||||||
|
# that hacked-build-image/build-hacked.sh already applies for hacked's own
|
||||||
|
# binary. src/hacked/ is never built or patched in place.
|
||||||
|
#
|
||||||
|
# Usage: stage-hacked.sh <modding-root> <dest-dir-for-hacked>
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MODDING_ROOT="$1"
|
||||||
|
DEST="$2"
|
||||||
|
|
||||||
|
rm -rf "$DEST"
|
||||||
|
mkdir -p "$(dirname "$DEST")"
|
||||||
|
cp -a "$MODDING_ROOT/src/hacked" "$DEST"
|
||||||
|
for p in "$MODDING_ROOT"/src/hacked-patches/*.patch; do
|
||||||
|
patch -p1 -d "$DEST" < "$p"
|
||||||
|
done
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Upscales every PNG in one directory into another, via one ONNX Runtime
|
||||||
|
CPU session shared across the whole batch (same reasoning as mdlp's own
|
||||||
|
Go-side batching: the fixed per-invocation model-load cost otherwise
|
||||||
|
dominates for the many small sprite frames one upscale definition has).
|
||||||
|
|
||||||
|
Runs on CPU only, not Vulkan: this Docker image has no real GPU, and
|
||||||
|
Vulkan software emulation (Mesa lavapipe) gives wrong colors for ML
|
||||||
|
inference workloads.
|
||||||
|
|
||||||
|
Usage: upscale_batch.py <model.onnx> <in-dir> <out-dir> <scale>
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import onnxruntime as ort
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
model_path, in_dir, out_dir, scale_arg = sys.argv[1:5]
|
||||||
|
scale = int(scale_arg)
|
||||||
|
|
||||||
|
session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
|
||||||
|
input_name = session.get_inputs()[0].name
|
||||||
|
|
||||||
|
names = sorted(n for n in os.listdir(in_dir) if n.lower().endswith(".png"))
|
||||||
|
for name in names:
|
||||||
|
img = Image.open(os.path.join(in_dir, name)).convert("RGBA")
|
||||||
|
rgb = img.convert("RGB")
|
||||||
|
alpha = img.split()[3]
|
||||||
|
|
||||||
|
# The model has no concept of transparency (3-channel RGB in/out) -
|
||||||
|
# run it on the flattened RGB only, and scale the alpha mask
|
||||||
|
# separately with a plain resize. Alpha edges don't need AI
|
||||||
|
# enhancement, just to end up the right size.
|
||||||
|
arr = np.asarray(rgb, dtype=np.float32) / 255.0
|
||||||
|
arr = np.transpose(arr, (2, 0, 1))[np.newaxis, ...]
|
||||||
|
output = session.run(None, {input_name: arr})[0]
|
||||||
|
output = np.clip(output[0], 0.0, 1.0) * 255.0
|
||||||
|
output = np.transpose(output, (1, 2, 0)).astype(np.uint8)
|
||||||
|
upscaled_rgb = Image.fromarray(output, "RGB")
|
||||||
|
|
||||||
|
target_size = (img.width * scale, img.height * scale)
|
||||||
|
if upscaled_rgb.size != target_size:
|
||||||
|
# The model upscales at its own fixed native factor - resize to
|
||||||
|
# whatever scale the mod actually asked for.
|
||||||
|
upscaled_rgb = upscaled_rgb.resize(target_size, Image.LANCZOS)
|
||||||
|
upscaled_alpha = alpha.resize(target_size, Image.LANCZOS)
|
||||||
|
|
||||||
|
result = Image.merge("RGBA", (*upscaled_rgb.split(), upscaled_alpha))
|
||||||
|
result.save(os.path.join(out_dir, name))
|
||||||
|
|
||||||
|
print(f"upscaled {len(names)} frame(s)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"model": "realesrgan-x4plus-anime",
|
||||||
|
"scale": 2,
|
||||||
|
"resources": [
|
||||||
|
"0x583",
|
||||||
|
"0x5CD",
|
||||||
|
"0x617",
|
||||||
|
"0x689",
|
||||||
|
"0x68A",
|
||||||
|
"0x68B",
|
||||||
|
"0x68C",
|
||||||
|
"0x68D",
|
||||||
|
"0x68E",
|
||||||
|
"0x68F",
|
||||||
|
"0x690",
|
||||||
|
"0x7B0",
|
||||||
|
"0x7B1",
|
||||||
|
"0x7B2",
|
||||||
|
"0x7B3",
|
||||||
|
"0x7B4",
|
||||||
|
"0x7B5",
|
||||||
|
"0x7B6",
|
||||||
|
"0x7B7",
|
||||||
|
"0x84C"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"model": "realesrgan-x4plus-anime",
|
||||||
|
"scale": 2,
|
||||||
|
"resources": [
|
||||||
|
"0x580",
|
||||||
|
"0x5A5",
|
||||||
|
"0x5CA",
|
||||||
|
"0x5EF",
|
||||||
|
"0x614",
|
||||||
|
"0x671",
|
||||||
|
"0x672",
|
||||||
|
"0x673",
|
||||||
|
"0x674",
|
||||||
|
"0x675",
|
||||||
|
"0x676",
|
||||||
|
"0x677",
|
||||||
|
"0x678",
|
||||||
|
"0x798",
|
||||||
|
"0x799",
|
||||||
|
"0x79A",
|
||||||
|
"0x79B",
|
||||||
|
"0x79C",
|
||||||
|
"0x79D",
|
||||||
|
"0x79E",
|
||||||
|
"0x79F",
|
||||||
|
"0x849"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"model": "realesrgan-x4plus-anime",
|
||||||
|
"scale": 2,
|
||||||
|
"resources": [
|
||||||
|
"0x578",
|
||||||
|
"0x59D",
|
||||||
|
"0x5C2",
|
||||||
|
"0x5E7",
|
||||||
|
"0x60C",
|
||||||
|
"0x631",
|
||||||
|
"0x632",
|
||||||
|
"0x633",
|
||||||
|
"0x634",
|
||||||
|
"0x635",
|
||||||
|
"0x636",
|
||||||
|
"0x637",
|
||||||
|
"0x638",
|
||||||
|
"0x661",
|
||||||
|
"0x662",
|
||||||
|
"0x663",
|
||||||
|
"0x664",
|
||||||
|
"0x665",
|
||||||
|
"0x666",
|
||||||
|
"0x667",
|
||||||
|
"0x668",
|
||||||
|
"0x758",
|
||||||
|
"0x759",
|
||||||
|
"0x75A",
|
||||||
|
"0x75B",
|
||||||
|
"0x75C",
|
||||||
|
"0x75D",
|
||||||
|
"0x75E",
|
||||||
|
"0x75F",
|
||||||
|
"0x841"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
module questshock/animgallery
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
// hacked/ is vendored locally (see ../../README.md); animgallery reuses its
|
||||||
|
// resource decoding code (ss1/world, ss1/content/bitmap) instead of
|
||||||
|
// duplicating System Shock's file formats.
|
||||||
|
require github.com/inkyblackness/hacked v0.0.0
|
||||||
|
|
||||||
|
require github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
|
||||||
|
replace github.com/inkyblackness/hacked => ../hacked
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
@@ -0,0 +1,595 @@
|
|||||||
|
// Command animgallery scans a game data directory for every multi-frame
|
||||||
|
// bitmap resource (both dedicated "Animation" descriptors, e.g. video mail,
|
||||||
|
// and plain multi-block compound bitmaps, e.g. critter sprites - System
|
||||||
|
// Shock uses both conventions) and renders each one as an animated GIF plus
|
||||||
|
// an HTML index page, so they can be browsed by eye instead of guessed at
|
||||||
|
// by resource ID.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/gif"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||||
|
"github.com/inkyblackness/hacked/ss1/resource"
|
||||||
|
"github.com/inkyblackness/hacked/ss1/world"
|
||||||
|
"github.com/inkyblackness/hacked/ss1/world/ids"
|
||||||
|
)
|
||||||
|
|
||||||
|
// galleryEntry describes one rendered animation for the HTML index.
|
||||||
|
type galleryEntry struct {
|
||||||
|
id resource.ID
|
||||||
|
lang resource.Language
|
||||||
|
sourceFile string
|
||||||
|
kind string
|
||||||
|
frameCount int
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
transparent bool
|
||||||
|
gifName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// dataDirs collects repeated -data flags, in the order given - later ones
|
||||||
|
// take priority over earlier ones for any resource ID they both define,
|
||||||
|
// same as System Shock's own overlay convention. This lets a mod's build
|
||||||
|
// output (modding/dist/mods/<name>/mod/) be layered on top of the base
|
||||||
|
// game assets to preview the combined, as-played result.
|
||||||
|
type dataDirs []string
|
||||||
|
|
||||||
|
func (d *dataDirs) String() string { return strings.Join(*d, ",") }
|
||||||
|
func (d *dataDirs) Set(v string) error { *d = append(*d, v); return nil }
|
||||||
|
|
||||||
|
// loadDataDir merges every .res/.dat file directly inside dir into one
|
||||||
|
// manifest entry, bypassing hacked's own fileAllowlist
|
||||||
|
// (ss1/world/FileLoader.go): that check is only skipped when a file is
|
||||||
|
// passed directly as the sole argument, not when given a directory to
|
||||||
|
// scan, so a directory containing a resource file with a name it doesn't
|
||||||
|
// recognize (like mdlp's own "unknown.res" fallback - see
|
||||||
|
// [[project_modding_hacked_build]] memory) is otherwise silently treated
|
||||||
|
// as if it had no resources at all. The real game engine has no such
|
||||||
|
// restriction - it matches any .res/.dat file by extension alone
|
||||||
|
// (engine/src/MacSrc/Modding.c's StringEndsWith), which this mirrors.
|
||||||
|
func loadDataDir(dir string) (*world.ManifestEntry, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := &world.ManifestEntry{Origin: []string{dir}, ID: dir}
|
||||||
|
found := false
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||||
|
if ext != ".res" && ext != ".dat" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fileEntry, err := world.NewManifestEntryFrom([]string{filepath.Join(dir, e.Name())})
|
||||||
|
if err != nil {
|
||||||
|
// Not every .res/.dat file is actually resource-formatted (e.g.
|
||||||
|
// ambrtabl.dat is a raw lookup table) - hacked's own
|
||||||
|
// fileAllowlist silently protects against exactly this for
|
||||||
|
// canonical filenames, but bypassing it here (the whole point,
|
||||||
|
// for names like "unknown.res" it doesn't recognize) means we
|
||||||
|
// have to tolerate that ourselves instead: skip a file that
|
||||||
|
// doesn't parse rather than failing the whole directory over it.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged.Resources = append(merged.Resources, fileEntry.Resources...)
|
||||||
|
if len(fileEntry.ObjectProperties) > 0 {
|
||||||
|
merged.ObjectProperties = fileEntry.ObjectProperties
|
||||||
|
}
|
||||||
|
if len(fileEntry.TextureProperties) > 0 {
|
||||||
|
merged.TextureProperties = fileEntry.TextureProperties
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("no .res/.dat files found in %s", dir)
|
||||||
|
}
|
||||||
|
return merged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var dirs dataDirs
|
||||||
|
flag.Var(&dirs, "data", "directory with .res/.dat files - repeat to layer several, later wins (e.g. base game assets, then a built mod's mod/ folder)")
|
||||||
|
outDir := flag.String("out", "gallery", "directory to write index.html and every .gif into")
|
||||||
|
minFrames := flag.Int("min-frames", 2, "skip plain multi-block bitmaps with fewer blocks than this (Animation-type resources are always included)")
|
||||||
|
onlyChanged := flag.Bool("only-changed", false, "skip resources whose winning -data layer is the first one - pass a base layer followed by one or more mod layers to preview only what those later layers change, in the base layer's own colors")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if len(dirs) == 0 {
|
||||||
|
log.Fatal("-data is required (repeat it to layer several directories)")
|
||||||
|
}
|
||||||
|
absOutDir, err := filepath.Abs(*outDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("resolving -out: %v", err)
|
||||||
|
}
|
||||||
|
gifDir := filepath.Join(absOutDir, "gifs")
|
||||||
|
if err := os.MkdirAll(gifDir, 0o755); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mod := world.NewMod(func([]resource.ID, []resource.ID) {}, func() {})
|
||||||
|
// sourceFileByKey records, per (language, id), which directory's
|
||||||
|
// resource "wins" after layering - the same last-one-wins rule
|
||||||
|
// Manifest.Filter itself uses for entries inserted in this order.
|
||||||
|
// sourceDirIndexByKey records the same winner as an index into dirs,
|
||||||
|
// for -only-changed to tell a first-layer (base) resource from one a
|
||||||
|
// later layer (a mod) overrides.
|
||||||
|
sourceFileByKey := make(map[resource.Key]string)
|
||||||
|
sourceDirIndexByKey := make(map[resource.Key]int)
|
||||||
|
for dirIndex, dir := range dirs {
|
||||||
|
absDir, err := filepath.Abs(dir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("resolving -data %q: %v", dir, err)
|
||||||
|
}
|
||||||
|
entry, err := loadDataDir(absDir)
|
||||||
|
if err != nil {
|
||||||
|
// A mod that doesn't touch any resource file yet (e.g. an
|
||||||
|
// upscale mod before mdlp supports that content type) produces
|
||||||
|
// an empty build directory - skip it rather than aborting the
|
||||||
|
// whole layered scan over one contributing -data directory.
|
||||||
|
log.Printf("skipping -data %s: %v", absDir, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// NewManifestEntryFrom builds entry.Resources by ranging over a Go
|
||||||
|
// map, so its order (and therefore which file "wins" a Selector's
|
||||||
|
// last-one-wins tie-break for any resource ID more than one file
|
||||||
|
// defines) is randomized per process run. Sort it ourselves so the
|
||||||
|
// same input always resolves the same way.
|
||||||
|
sort.Slice(entry.Resources, func(i, j int) bool { return entry.Resources[i].ID < entry.Resources[j].ID })
|
||||||
|
if err := mod.World().InsertEntry(mod.World().EntryCount(), entry); err != nil {
|
||||||
|
log.Fatalf("registering game data from %s: %v", absDir, err)
|
||||||
|
}
|
||||||
|
for _, loc := range entry.Resources {
|
||||||
|
for _, id := range loc.Viewer.IDs() {
|
||||||
|
key := resource.KeyOf(id, loc.Language, 0)
|
||||||
|
sourceFileByKey[key] = fmt.Sprintf("%s (%s)", loc.ID, filepath.Base(absDir))
|
||||||
|
sourceDirIndexByKey[key] = dirIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
palCache := bitmap.NewPaletteCache(mod)
|
||||||
|
defaultPal, err := palCache.Palette(resource.KeyOf(ids.GamePalettesStart, resource.LangDefault, 0))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("warning: could not load the default game palette (%v); falling back to grayscale", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]resource.Key, 0, len(sourceFileByKey))
|
||||||
|
for key := range sourceFileByKey {
|
||||||
|
if *onlyChanged && sourceDirIndexByKey[key] == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Slice(keys, func(i, j int) bool {
|
||||||
|
if keys[i].ID != keys[j].ID {
|
||||||
|
return keys[i].ID < keys[j].ID
|
||||||
|
}
|
||||||
|
return keys[i].Lang < keys[j].Lang
|
||||||
|
})
|
||||||
|
|
||||||
|
var entries []galleryEntry
|
||||||
|
for _, key := range keys {
|
||||||
|
sourceFile := sourceFileByKey[key]
|
||||||
|
// Re-resolved through the layered mod, not the raw per-directory
|
||||||
|
// viewer, so an overriding mod's version wins here exactly like it
|
||||||
|
// would for the running game.
|
||||||
|
view, err := mod.LocalizedResources(key.Lang).Select(key.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case view.ContentType() == resource.Animation:
|
||||||
|
entry, err := safeRenderAnimationDescriptor(mod, defaultPal, sourceFile, key.ID, key.Lang, gifDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("skipping animation %s (%s): %v", key.ID, sourceFile, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
|
||||||
|
case view.ContentType() == resource.Bitmap && view.BlockCount() >= *minFrames:
|
||||||
|
entry, err := safeRenderMultiBlockBitmap(view, defaultPal, sourceFile, key.ID, key.Lang, gifDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("skipping bitmap %s (%s): %v", key.ID, sourceFile, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
|
if entries[i].id != entries[j].id {
|
||||||
|
return entries[i].id < entries[j].id
|
||||||
|
}
|
||||||
|
return entries[i].lang < entries[j].lang
|
||||||
|
})
|
||||||
|
|
||||||
|
indexPath := filepath.Join(absOutDir, "index.html")
|
||||||
|
if err := writeIndex(indexPath, entries); err != nil {
|
||||||
|
log.Fatalf("writing %s: %v", indexPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Found %d animated resource(s).\n", len(entries))
|
||||||
|
fmt.Printf("Index: %s\n", indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeFrames reads every block of a compound bitmap resource as a
|
||||||
|
// sequence of frames, each one RLE-diffed against the previous - the same
|
||||||
|
// chaining hacked's own editor/animations/View.go uses when importing a
|
||||||
|
// GIF, done here in reverse (decode instead of encode).
|
||||||
|
func decodeFrames(view resource.View) ([]*bitmap.Bitmap, error) {
|
||||||
|
count := view.BlockCount()
|
||||||
|
frames := make([]*bitmap.Bitmap, 0, count)
|
||||||
|
var prevPixels []byte
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
reader, err := view.Block(i)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||||
|
}
|
||||||
|
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||||
|
size := int(w) * int(h)
|
||||||
|
buf := make([]byte, size)
|
||||||
|
if len(prevPixels) == size {
|
||||||
|
copy(buf, prevPixels)
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||||
|
}
|
||||||
|
prevPixels = bmp.Pixels
|
||||||
|
frames = append(frames, bmp)
|
||||||
|
}
|
||||||
|
return frames, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func paletteFor(bmp *bitmap.Bitmap, fallback bitmap.Palette) color.Palette {
|
||||||
|
pal := fallback
|
||||||
|
if bmp.Palette != nil {
|
||||||
|
pal = *bmp.Palette
|
||||||
|
}
|
||||||
|
return pal.ColorPalette(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeGIF(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette, delays []int) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
maxWidth, maxHeight := 0, 0
|
||||||
|
for _, bmp := range frames {
|
||||||
|
if int(bmp.Header.Width) > maxWidth {
|
||||||
|
maxWidth = int(bmp.Header.Width)
|
||||||
|
}
|
||||||
|
if int(bmp.Header.Height) > maxHeight {
|
||||||
|
maxHeight = int(bmp.Header.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoopCount: 0 means loop forever in Go's image/gif API - inverted from
|
||||||
|
// the raw GIF format's own NETSCAPE2.0 convention. -1 (this package's
|
||||||
|
// zero value) means play once, which is why these looped only once
|
||||||
|
// before this fix.
|
||||||
|
out := gif.GIF{LoopCount: 0, Config: image.Config{Width: maxWidth, Height: maxHeight}}
|
||||||
|
for i, bmp := range frames {
|
||||||
|
colorPal := paletteFor(bmp, fallbackPal)
|
||||||
|
rect := image.Rect(0, 0, int(bmp.Header.Width), int(bmp.Header.Height))
|
||||||
|
img := image.NewPaletted(rect, colorPal)
|
||||||
|
if len(bmp.Pixels) == len(img.Pix) {
|
||||||
|
copy(img.Pix, bmp.Pixels)
|
||||||
|
}
|
||||||
|
out.Image = append(out.Image, img)
|
||||||
|
out.Delay = append(out.Delay, delays[i])
|
||||||
|
}
|
||||||
|
return gif.EncodeAll(f, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeRenderMultiBlockBitmap recovers from panics in hacked's own decode
|
||||||
|
// code (malformed or non-image compound resources can crash rle.Decompress
|
||||||
|
// rather than return an error) so one bad resource never aborts a scan that
|
||||||
|
// is meant to be re-run repeatedly during modding.
|
||||||
|
func safeRenderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (entry galleryEntry, err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
err = fmt.Errorf("panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return renderMultiBlockBitmap(view, fallbackPal, sourceFile, id, lang, outDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (galleryEntry, error) {
|
||||||
|
frames, err := decodeFrames(view)
|
||||||
|
if err != nil {
|
||||||
|
return galleryEntry{}, err
|
||||||
|
}
|
||||||
|
if len(frames) == 0 {
|
||||||
|
return galleryEntry{}, fmt.Errorf("no frames")
|
||||||
|
}
|
||||||
|
delays := make([]int, len(frames))
|
||||||
|
for i := range delays {
|
||||||
|
delays[i] = 10 // 100ms - no explicit timing exists for this raw form
|
||||||
|
}
|
||||||
|
gifName := fmt.Sprintf("%04X_%s.gif", int(id), lang.String())
|
||||||
|
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays); err != nil {
|
||||||
|
return galleryEntry{}, err
|
||||||
|
}
|
||||||
|
return galleryEntry{
|
||||||
|
id: id, lang: lang, sourceFile: sourceFile, kind: "multi-block bitmap",
|
||||||
|
frameCount: len(frames),
|
||||||
|
width: int(frames[0].Header.Width), height: int(frames[0].Header.Height),
|
||||||
|
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
||||||
|
gifName: gifName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeRenderAnimationDescriptor is safeRenderMultiBlockBitmap's counterpart
|
||||||
|
// for the Animation-descriptor path.
|
||||||
|
func safeRenderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (entry galleryEntry, err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
err = fmt.Errorf("panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return renderAnimationDescriptor(mod, fallbackPal, sourceFile, id, lang, outDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (galleryEntry, error) {
|
||||||
|
view, err := mod.LocalizedResources(lang).Select(id)
|
||||||
|
if err != nil {
|
||||||
|
return galleryEntry{}, err
|
||||||
|
}
|
||||||
|
reader, err := view.Block(0)
|
||||||
|
if err != nil {
|
||||||
|
return galleryEntry{}, err
|
||||||
|
}
|
||||||
|
anim, err := bitmap.ReadAnimation(reader)
|
||||||
|
if err != nil {
|
||||||
|
return galleryEntry{}, err
|
||||||
|
}
|
||||||
|
if len(anim.Entries) == 0 {
|
||||||
|
return galleryEntry{}, fmt.Errorf("no animation entries")
|
||||||
|
}
|
||||||
|
|
||||||
|
lastFrame := 0
|
||||||
|
for _, entry := range anim.Entries {
|
||||||
|
if int(entry.LastFrame) > lastFrame {
|
||||||
|
lastFrame = int(entry.LastFrame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var frames []*bitmap.Bitmap
|
||||||
|
var delays []int
|
||||||
|
var prevPixels []byte
|
||||||
|
frameIndex := 0
|
||||||
|
for _, entry := range anim.Entries {
|
||||||
|
for frameIndex <= int(entry.LastFrame) {
|
||||||
|
frameKey := resource.KeyOf(anim.ResourceID, lang, frameIndex)
|
||||||
|
frameView, err := mod.LocalizedResources(lang).Select(frameKey.ID)
|
||||||
|
if err != nil {
|
||||||
|
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
|
}
|
||||||
|
reader, err := frameView.Block(0)
|
||||||
|
if err != nil {
|
||||||
|
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
|
}
|
||||||
|
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||||
|
size := int(w) * int(h)
|
||||||
|
buf := make([]byte, size)
|
||||||
|
if len(prevPixels) == size {
|
||||||
|
copy(buf, prevPixels)
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
|
}
|
||||||
|
prevPixels = bmp.Pixels
|
||||||
|
frames = append(frames, bmp)
|
||||||
|
delay := int(entry.FrameTime) / 10
|
||||||
|
if delay <= 0 {
|
||||||
|
delay = 10
|
||||||
|
}
|
||||||
|
delays = append(delays, delay)
|
||||||
|
frameIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(frames) == 0 {
|
||||||
|
return galleryEntry{}, fmt.Errorf("no frames decoded")
|
||||||
|
}
|
||||||
|
|
||||||
|
gifName := fmt.Sprintf("%04X_%s.gif", int(id), lang.String())
|
||||||
|
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays); err != nil {
|
||||||
|
return galleryEntry{}, err
|
||||||
|
}
|
||||||
|
return galleryEntry{
|
||||||
|
id: id, lang: lang, sourceFile: sourceFile, kind: "animation descriptor",
|
||||||
|
frameCount: len(frames),
|
||||||
|
width: int(anim.Width), height: int(anim.Height),
|
||||||
|
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
||||||
|
gifName: gifName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentTypeName maps a galleryEntry.kind back to the resource.ContentType
|
||||||
|
// name mdlp will need (its own decode path differs: an "Animation"
|
||||||
|
// descriptor's frames live at anim.ResourceID+N, a "Bitmap"'s frames are
|
||||||
|
// just its own blocks) - see kind's assignment in renderAnimationDescriptor/
|
||||||
|
// renderMultiBlockBitmap.
|
||||||
|
func contentTypeName(kind string) string {
|
||||||
|
if kind == "animation descriptor" {
|
||||||
|
return "Animation"
|
||||||
|
}
|
||||||
|
return "Bitmap"
|
||||||
|
}
|
||||||
|
|
||||||
|
// mdlResourceID formats the resourceId string an MDL upscale definition
|
||||||
|
// uses to identify one resource: a resource ID alone is not always unique -
|
||||||
|
// e.g. 0x25 exists as a separate resource per language - so a non-Any
|
||||||
|
// language is appended (0x25_German). The bare form (0xA54) implies
|
||||||
|
// _Any, the language nearly everything we care about upscaling (critter
|
||||||
|
// art, etc.) actually uses.
|
||||||
|
func mdlResourceID(id resource.ID, lang resource.Language) string {
|
||||||
|
if lang == resource.LangAny {
|
||||||
|
return fmt.Sprintf("0x%X", int(id))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("0x%X_%s", int(id), lang.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeIndex(path string, entries []galleryEntry) error {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>System Shock animation gallery</title>")
|
||||||
|
b.WriteString("<style>body{font-family:sans-serif;background:#111;color:#eee}")
|
||||||
|
b.WriteString("#controls{position:sticky;top:0;background:#181818;padding:10px;z-index:10;border-bottom:1px solid #444}")
|
||||||
|
b.WriteString("#controls label{margin-right:12px}")
|
||||||
|
b.WriteString("#mdl-output{width:98%;font-family:monospace;font-size:12px;margin-top:8px;background:#000;color:#9f9;border:1px solid #444}")
|
||||||
|
b.WriteString(".card{display:inline-block;margin:8px;padding:8px;background:#222;vertical-align:top}")
|
||||||
|
b.WriteString(".card img{image-rendering:pixelated;background:#000;display:block}")
|
||||||
|
b.WriteString(".meta{font-size:12px;color:#aaa;margin-top:4px}")
|
||||||
|
b.WriteString(".meta label{cursor:pointer}")
|
||||||
|
b.WriteString("#basket{min-height:60px;background:#1a2a1a;border:1px dashed #4a6a4a;padding:8px;margin-bottom:16px}")
|
||||||
|
b.WriteString("#basket:empty::before{content:'(nothing selected yet - check animations below to add them here)';color:#678;font-style:italic}")
|
||||||
|
b.WriteString("#basket .card{background:#2a3a2a}</style></head><body>")
|
||||||
|
|
||||||
|
b.WriteString(`<div id="controls">` +
|
||||||
|
`<label>Model: <select id="model">` +
|
||||||
|
`<option>realesrgan-x4plus-anime</option>` +
|
||||||
|
`<option>realesrgan-x4plus</option>` +
|
||||||
|
`<option>realesr-animevideov3</option>` +
|
||||||
|
`</select></label>` +
|
||||||
|
`<label>Scale: <input id="scale" type="number" value="2" min="1" max="4" style="width:4em"></label>` +
|
||||||
|
`<button id="clear-sel" type="button">Clear selection</button>` +
|
||||||
|
`<br><label>Filter: <input type="text" id="search" placeholder="id, language, file, transparent..." style="width:22em"></label>` +
|
||||||
|
`<span id="shown-count"></span>` +
|
||||||
|
`<textarea id="mdl-output" rows="12" readonly placeholder="Select animations below to generate an MDL upscale definition here - copy it into modding/mods/<name>/upscale/<label>.json"></textarea>` +
|
||||||
|
`</div>`)
|
||||||
|
|
||||||
|
b.WriteString(`<h2>Selected (<span id="basket-count">0</span>)</h2><div id="basket"></div>`)
|
||||||
|
|
||||||
|
fmt.Fprintf(&b, "<h1>%d animated resource(s)</h1>", len(entries))
|
||||||
|
|
||||||
|
// Animation descriptors and multi-block bitmaps are genuinely distinct
|
||||||
|
// content types (different decode path, see contentTypeName) - grouped
|
||||||
|
// into separate sections rather than interleaved.
|
||||||
|
var animations, bitmaps []galleryEntry
|
||||||
|
for _, e := range entries {
|
||||||
|
if contentTypeName(e.kind) == "Animation" {
|
||||||
|
animations = append(animations, e)
|
||||||
|
} else {
|
||||||
|
bitmaps = append(bitmaps, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeCards := func(group []galleryEntry, idPrefix string) {
|
||||||
|
for i, e := range group {
|
||||||
|
transparency := "opaque"
|
||||||
|
if e.transparent {
|
||||||
|
transparency = "transparent"
|
||||||
|
}
|
||||||
|
search := strings.ToLower(strings.Join([]string{
|
||||||
|
fmt.Sprintf("0x%x", int(e.id)), fmt.Sprintf("%d", int(e.id)),
|
||||||
|
e.lang.String(), e.kind, e.sourceFile,
|
||||||
|
fmt.Sprintf("%dx%d", e.width, e.height), transparency,
|
||||||
|
}, " "))
|
||||||
|
fmt.Fprintf(&b, "<div class=\"card\" data-search=\"%s\">", html.EscapeString(search))
|
||||||
|
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
|
||||||
|
fmt.Fprintf(&b, "<div class=\"meta\"><label><input type=\"checkbox\" class=\"sel\" id=\"sel-%s-%d\" data-id=\"%s\"> ",
|
||||||
|
idPrefix, i, html.EscapeString(mdlResourceID(e.id, e.lang)))
|
||||||
|
fmt.Fprintf(&b, "ID 0x%X (%d) · %s</label><br>%dx%d, %d frame(s), %s · %s · %s</div>",
|
||||||
|
int(e.id), int(e.id), html.EscapeString(e.lang.String()),
|
||||||
|
e.width, e.height, e.frameCount, transparency, html.EscapeString(e.kind), html.EscapeString(e.sourceFile))
|
||||||
|
b.WriteString("</div>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "<h2>Animations (%d)</h2>", len(animations))
|
||||||
|
writeCards(animations, "a")
|
||||||
|
fmt.Fprintf(&b, "<h2>Bitmaps (%d)</h2>", len(bitmaps))
|
||||||
|
writeCards(bitmaps, "b")
|
||||||
|
|
||||||
|
b.WriteString(`<script>
|
||||||
|
var basket = document.getElementById('basket');
|
||||||
|
// Where each card lives when not selected, so unchecking it puts it back
|
||||||
|
// in its original spot (Animations/Bitmaps section, original order)
|
||||||
|
// instead of just appending it back at the end.
|
||||||
|
var cardHomes = new Map();
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.card'), function (card) {
|
||||||
|
cardHomes.set(card, { parent: card.parentNode, next: card.nextSibling });
|
||||||
|
});
|
||||||
|
|
||||||
|
function moveCard(checkbox) {
|
||||||
|
var card = checkbox.closest('.card');
|
||||||
|
if (checkbox.checked) {
|
||||||
|
basket.appendChild(card);
|
||||||
|
} else {
|
||||||
|
var home = cardHomes.get(card);
|
||||||
|
home.parent.insertBefore(card, home.next);
|
||||||
|
}
|
||||||
|
document.getElementById('basket-count').textContent = basket.children.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMdl() {
|
||||||
|
var model = document.getElementById('model').value;
|
||||||
|
var scale = parseInt(document.getElementById('scale').value, 10) || 2;
|
||||||
|
var boxes = Array.prototype.filter.call(document.querySelectorAll('.sel'), function (b) { return b.checked; });
|
||||||
|
// A bare resource ID isn't always unique (e.g. 0x25 exists as a separate
|
||||||
|
// resource per language), so each entry here is "0x<ID>" or, when the
|
||||||
|
// resource's language isn't Any, "0x<ID>_<Language>" - mdlp resolves the
|
||||||
|
// rest (content type, etc.) from the resource itself. The MDL
|
||||||
|
// definition only needs what it can't derive: the shared upscale
|
||||||
|
// parameters, and which resources to apply them to.
|
||||||
|
var mdl = {
|
||||||
|
model: model,
|
||||||
|
scale: scale,
|
||||||
|
resources: boxes.map(function (b) { return b.dataset.id; })
|
||||||
|
};
|
||||||
|
document.getElementById('mdl-output').value = JSON.stringify(mdl, null, 2);
|
||||||
|
}
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.sel'), function (b) {
|
||||||
|
b.addEventListener('change', function () { moveCard(b); updateMdl(); });
|
||||||
|
});
|
||||||
|
document.getElementById('model').addEventListener('change', updateMdl);
|
||||||
|
document.getElementById('scale').addEventListener('input', updateMdl);
|
||||||
|
document.getElementById('clear-sel').addEventListener('click', function () {
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.sel'), function (b) {
|
||||||
|
if (b.checked) {
|
||||||
|
b.checked = false;
|
||||||
|
moveCard(b);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateMdl();
|
||||||
|
});
|
||||||
|
updateMdl();
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
var query = document.getElementById('search').value.trim().toLowerCase();
|
||||||
|
var cards = document.querySelectorAll('.card');
|
||||||
|
var shown = 0;
|
||||||
|
Array.prototype.forEach.call(cards, function (card) {
|
||||||
|
var match = !query || card.dataset.search.indexOf(query) !== -1;
|
||||||
|
card.style.display = match ? '' : 'none';
|
||||||
|
if (match) shown++;
|
||||||
|
});
|
||||||
|
document.getElementById('shown-count').textContent = shown + ' / ' + cards.length + ' shown';
|
||||||
|
}
|
||||||
|
document.getElementById('search').addEventListener('input', applyFilter);
|
||||||
|
applyFilter();
|
||||||
|
</script>`)
|
||||||
|
|
||||||
|
b.WriteString("</body></html>")
|
||||||
|
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
--- a/ss1/resource/ChangeNotifier.go
|
||||||
|
+++ b/ss1/resource/ChangeNotifier.go
|
||||||
|
@@ -50,24 +50,26 @@
|
||||||
|
func (notifier ChangeNotifier) hashAll(ids []ID) (hashes resourceHashSnapshot, failedIDs []ID) {
|
||||||
|
var failedMap IDMarkerMap
|
||||||
|
hashes = make(resourceHashSnapshot)
|
||||||
|
- var selectMutex sync.Mutex
|
||||||
|
+ // Guards the whole of hashResource, not just Select: resources from the
|
||||||
|
+ // same underlying file share a stateful reader (e.g. a *io.SectionReader
|
||||||
|
+ // tracking a read offset), so the actual block reads inside
|
||||||
|
+ // hashResource race just as much as selection would - locking only
|
||||||
|
+ // Select still let concurrent goroutines corrupt each other's reads.
|
||||||
|
+ var ioMutex sync.Mutex
|
||||||
|
var mapMutex sync.Mutex
|
||||||
|
for _, lang := range Languages() {
|
||||||
|
hashByResourceID := make(resourceHashes)
|
||||||
|
hashes[lang] = hashByResourceID
|
||||||
|
selector := notifier.Localizer.LocalizedResources(lang)
|
||||||
|
- selectorFunc := func(id ID) (View, error) {
|
||||||
|
- selectMutex.Lock()
|
||||||
|
- defer selectMutex.Unlock()
|
||||||
|
- return selector.Select(id)
|
||||||
|
- }
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id ID) {
|
||||||
|
defer wg.Done()
|
||||||
|
- hash, hashErr := notifier.hashResource(selectorFunc, id)
|
||||||
|
+ ioMutex.Lock()
|
||||||
|
+ hash, hashErr := notifier.hashResource(selector.Select, id)
|
||||||
|
+ ioMutex.Unlock()
|
||||||
|
mapMutex.Lock()
|
||||||
|
defer mapMutex.Unlock()
|
||||||
|
if hashErr == nil {
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Command mdlp (Mod Definition Language Processor) turns the declarative mod
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,469 @@
|
|||||||
|
// AI upscaling: modding/mods/<name>/bitmaps/*.json declares a shared set of
|
||||||
|
// upscale parameters plus a list of resources to apply them to (see
|
||||||
|
// ../../README.md section 4). Applying one means: decode its frames from
|
||||||
|
// Static World Data, run each through the ONNX Runtime CPU upscaler baked
|
||||||
|
// into mdlp-image, re-quantize the result to the game's own palette, and
|
||||||
|
// write it back - all offline, at build time.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||||
|
"github.com/inkyblackness/hacked/ss1/resource"
|
||||||
|
"github.com/inkyblackness/hacked/ss1/serial/rle"
|
||||||
|
"github.com/inkyblackness/hacked/ss1/world"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every resource this file writes ends up in a file literally named
|
||||||
|
// "unknown.res", since ids.Info() (hacked's own resource-metadata registry)
|
||||||
|
// doesn't know any of the resource IDs mdlp upscales. A brand-new override
|
||||||
|
// resource can only be created through the Modder interface, which always
|
||||||
|
// names it via that registry's fallback, and hacked's own change-tracking
|
||||||
|
// (what SaveModUnder actually writes) is keyed by the filename at write
|
||||||
|
// time in a private field mdlp has no way to update, so renaming the file
|
||||||
|
// afterward isn't an option. Harmless to leave as-is: the engine matches an
|
||||||
|
// override by resource ID, not by which .res file carries it (the same
|
||||||
|
// reason a mod can name its own override files anything).
|
||||||
|
|
||||||
|
var languageByName = map[string]resource.Language{
|
||||||
|
"default": resource.LangDefault,
|
||||||
|
"french": resource.LangFrench,
|
||||||
|
"german": resource.LangGerman,
|
||||||
|
"any": resource.LangAny,
|
||||||
|
}
|
||||||
|
|
||||||
|
// upscaleDef is one modding/mods/<name>/bitmaps/<label>.json file: a shared
|
||||||
|
// set of upscale parameters and every resource to apply them to. Model
|
||||||
|
// is not consulted yet - mdlp-image bakes in a single ONNX model, applied
|
||||||
|
// regardless of this field's value.
|
||||||
|
type upscaleDef struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Scale int `json:"scale"`
|
||||||
|
Resources []string `json:"resources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMdlResource splits an animgallery-generated resource string
|
||||||
|
// ("0x758" or "0x25_German") into its ID and language. A bare ID implies
|
||||||
|
// language Any.
|
||||||
|
func parseMdlResource(s string) (resource.ID, resource.Language, error) {
|
||||||
|
hexPart := s
|
||||||
|
lang := resource.LangAny
|
||||||
|
if idx := strings.LastIndex(s, "_"); idx >= 0 {
|
||||||
|
hexPart = s[:idx]
|
||||||
|
name := strings.ToLower(s[idx+1:])
|
||||||
|
l, ok := languageByName[name]
|
||||||
|
if !ok {
|
||||||
|
return 0, 0, fmt.Errorf("unknown language suffix in %q", s)
|
||||||
|
}
|
||||||
|
lang = l
|
||||||
|
}
|
||||||
|
hexPart = strings.TrimPrefix(strings.TrimPrefix(hexPart, "0x"), "0X")
|
||||||
|
v, err := strconv.ParseUint(hexPart, 16, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("invalid resource id in %q: %w", s, err)
|
||||||
|
}
|
||||||
|
return resource.ID(v), lang, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resourceFrames is one resource's decoded, not-yet-upscaled frames,
|
||||||
|
// waiting to be batched into a single upscale_batch.py invocation.
|
||||||
|
type resourceFrames struct {
|
||||||
|
label string // the original "resources" entry, for error messages
|
||||||
|
id resource.ID
|
||||||
|
lang resource.Language
|
||||||
|
contentType resource.ContentType
|
||||||
|
frames []*bitmap.Bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUpscaleDef(mod *world.Mod, pal bitmap.Palette, defFile string) error {
|
||||||
|
raw, err := os.ReadFile(defFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var def upscaleDef
|
||||||
|
if err := json.Unmarshal(raw, &def); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if def.Model == "" {
|
||||||
|
return fmt.Errorf("model is required")
|
||||||
|
}
|
||||||
|
if def.Scale <= 0 {
|
||||||
|
def.Scale = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
all := make([]resourceFrames, 0, len(def.Resources))
|
||||||
|
for i, label := range def.Resources {
|
||||||
|
fmt.Printf(" decoding %d/%d: %s\n", i+1, len(def.Resources), label)
|
||||||
|
id, lang, err := parseMdlResource(label)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
view, err := mod.LocalizedResources(lang).Select(id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: resource not found: %w", label, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := view.ContentType()
|
||||||
|
var frames []*bitmap.Bitmap
|
||||||
|
switch contentType {
|
||||||
|
case resource.Animation:
|
||||||
|
reader, err := view.Block(0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", label, err)
|
||||||
|
}
|
||||||
|
anim, err := bitmap.ReadAnimation(reader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", label, err)
|
||||||
|
}
|
||||||
|
frames, err = decodeAnimationFrames(mod, lang, anim)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", label, err)
|
||||||
|
}
|
||||||
|
case resource.Bitmap:
|
||||||
|
frames, err = decodeViewFrames(view)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", label, err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s: unsupported content type %s", label, contentType)
|
||||||
|
}
|
||||||
|
if len(frames) == 0 {
|
||||||
|
return fmt.Errorf("%s: no frames decoded", label)
|
||||||
|
}
|
||||||
|
all = append(all, resourceFrames{label: label, id: id, lang: lang, contentType: contentType, frames: frames})
|
||||||
|
}
|
||||||
|
|
||||||
|
upscaledByResource, err := upscaleBatch(all, pal, def.Model, def.Scale)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, rf := range all {
|
||||||
|
fmt.Printf(" writing %d/%d: %s\n", i+1, len(all), rf.label)
|
||||||
|
upscaled := upscaledByResource[i]
|
||||||
|
if rf.contentType == resource.Animation {
|
||||||
|
for frameIdx, frame := range upscaled {
|
||||||
|
if err := writeRawResource(mod, rf.lang, rf.id.Plus(frameIdx), resource.Bitmap, encodeFrames([]*bitmap.Bitmap{frame})); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", rf.label, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := writeRawResource(mod, rf.lang, rf.id, resource.Bitmap, encodeFrames(upscaled)); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", rf.label, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeViewFrames reads every block of a compound bitmap resource as a
|
||||||
|
// sequence of frames, each one RLE-diffed against the previous - mirrors
|
||||||
|
// animgallery's decodeFrames. Each block declares its own width/height, so
|
||||||
|
// the reference buffer handed to DecodeReferenced must match that block's
|
||||||
|
// own size, not blindly reuse the previous frame's.
|
||||||
|
func decodeViewFrames(view resource.View) ([]*bitmap.Bitmap, error) {
|
||||||
|
count := view.BlockCount()
|
||||||
|
frames := make([]*bitmap.Bitmap, 0, count)
|
||||||
|
var prevPixels []byte
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
reader, err := view.Block(i)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||||
|
}
|
||||||
|
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||||
|
size := int(w) * int(h)
|
||||||
|
buf := make([]byte, size)
|
||||||
|
if len(prevPixels) == size {
|
||||||
|
copy(buf, prevPixels)
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||||
|
}
|
||||||
|
prevPixels = bmp.Pixels
|
||||||
|
frames = append(frames, bmp)
|
||||||
|
}
|
||||||
|
return frames, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeAnimationFrames resolves an Animation descriptor's actual frame
|
||||||
|
// bitmaps, stored as separate resources at anim.ResourceID+N - mirrors
|
||||||
|
// animgallery's renderAnimationDescriptor.
|
||||||
|
func decodeAnimationFrames(mod *world.Mod, lang resource.Language, anim bitmap.Animation) ([]*bitmap.Bitmap, error) {
|
||||||
|
lastFrame := 0
|
||||||
|
for _, entry := range anim.Entries {
|
||||||
|
if int(entry.LastFrame) > lastFrame {
|
||||||
|
lastFrame = int(entry.LastFrame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var frames []*bitmap.Bitmap
|
||||||
|
var prevPixels []byte
|
||||||
|
for frameIndex := 0; frameIndex <= lastFrame; frameIndex++ {
|
||||||
|
frameView, err := mod.LocalizedResources(lang).Select(anim.ResourceID.Plus(frameIndex))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
|
}
|
||||||
|
reader, err := frameView.Block(0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
|
}
|
||||||
|
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||||
|
size := int(w) * int(h)
|
||||||
|
buf := make([]byte, size)
|
||||||
|
if len(prevPixels) == size {
|
||||||
|
copy(buf, prevPixels)
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||||
|
}
|
||||||
|
prevPixels = bmp.Pixels
|
||||||
|
frames = append(frames, bmp)
|
||||||
|
}
|
||||||
|
return frames, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bitmapToImage renders a decoded frame as RGBA, treating palette index 0
|
||||||
|
// as fully transparent when the source bitmap says so - the upscaler needs
|
||||||
|
// to see real transparency, not just whatever color happens to sit at
|
||||||
|
// index 0, or it will treat the background like normal image content.
|
||||||
|
func bitmapToImage(bmp *bitmap.Bitmap, pal bitmap.Palette) *image.RGBA {
|
||||||
|
effPal := pal
|
||||||
|
if bmp.Palette != nil {
|
||||||
|
effPal = *bmp.Palette
|
||||||
|
}
|
||||||
|
colors := effPal.ColorPalette(false)
|
||||||
|
transparent := bmp.Header.Flags&bitmap.FlagTransparent != 0
|
||||||
|
|
||||||
|
w, h := int(bmp.Header.Width), int(bmp.Header.Height)
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
for i, idx := range bmp.Pixels {
|
||||||
|
if transparent && idx == 0 {
|
||||||
|
continue // already zero alpha
|
||||||
|
}
|
||||||
|
r, g, b, _ := colors[idx].RGBA()
|
||||||
|
img.Pix[i*4+0] = byte(r >> 8)
|
||||||
|
img.Pix[i*4+1] = byte(g >> 8)
|
||||||
|
img.Pix[i*4+2] = byte(b >> 8)
|
||||||
|
img.Pix[i*4+3] = 0xff
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageToBitmap re-quantizes an RGBA image back to the game's palette.
|
||||||
|
// Pixels below the alpha threshold become palette index 0 directly
|
||||||
|
// (skipping color matching entirely) rather than trusting the nearest-color
|
||||||
|
// search to land on it - Bitmapper has no concept of transparency, it would
|
||||||
|
// happily map a half-transparent edge pixel to any color that looks close.
|
||||||
|
//
|
||||||
|
// origAnchor is the original frame's own Area[0]/Area[1] - for critter/object
|
||||||
|
// art this is a single anchor point (Area[2]/[3] are always exactly
|
||||||
|
// Area[0]/[1]+1, a degenerate one-pixel rect, not a real bounding box), which
|
||||||
|
// the engine's billboard sprite renderer (g3_bitmap_common(), see
|
||||||
|
// engine-patches/04-desktop-fix-critter-sprite-scale.patch) uses to place the
|
||||||
|
// sprite in world space. Written through unscaled, not scaled by the same
|
||||||
|
// factor as the image: that patch's cache overrides the *size* half of this
|
||||||
|
// bitmap's world-space math with the original, un-upscaled dimensions, so
|
||||||
|
// the anchor has to stay in that same native unit space to match, or the
|
||||||
|
// sprite renders at the right size but visibly offset from where it should
|
||||||
|
// sit. Writing {0,0} here (as if there were no anchor) makes the renderer
|
||||||
|
// fall back to a path that ignores that patch's size override entirely.
|
||||||
|
func imageToBitmap(img *image.RGBA, pal bitmap.Palette, transparent bool, origAnchor bitmap.Area) *bitmap.Bitmap {
|
||||||
|
bounds := img.Bounds()
|
||||||
|
w, h := bounds.Dx(), bounds.Dy()
|
||||||
|
bitmapper := bitmap.NewBitmapper(&pal)
|
||||||
|
pixels := make([]byte, w*h)
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
c := img.RGBAAt(bounds.Min.X+x, bounds.Min.Y+y)
|
||||||
|
if transparent && c.A < 0x80 {
|
||||||
|
pixels[y*w+x] = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pixels[y*w+x] = bitmapper.MapColor(color.RGBA{R: c.R, G: c.G, B: c.B, A: 0xff})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
highestBitShift := func(v int16) (r byte) {
|
||||||
|
for v>>r != 1 {
|
||||||
|
r++
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flags := bitmap.Flag(0)
|
||||||
|
if transparent {
|
||||||
|
flags = bitmap.FlagTransparent
|
||||||
|
}
|
||||||
|
anchorX := origAnchor[0]
|
||||||
|
anchorY := origAnchor[1]
|
||||||
|
return &bitmap.Bitmap{
|
||||||
|
Header: bitmap.Header{
|
||||||
|
Type: bitmap.TypeCompressed8Bit,
|
||||||
|
Flags: flags,
|
||||||
|
Width: int16(w),
|
||||||
|
Height: int16(h),
|
||||||
|
Stride: uint16(w),
|
||||||
|
WidthFactor: highestBitShift(int16(w)),
|
||||||
|
HeightFactor: highestBitShift(int16(h)),
|
||||||
|
Area: bitmap.Area{anchorX, anchorY, anchorX + 1, anchorY + 1},
|
||||||
|
},
|
||||||
|
Pixels: pixels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// upscaleBatch runs every frame from every given resource through exactly
|
||||||
|
// one upscale_batch.py invocation (its own directory-in/directory-out batch
|
||||||
|
// mode) and returns each resource's upscaled frames in the same order as
|
||||||
|
// the input slice. A single model load is shared across the whole batch -
|
||||||
|
// the fixed per-process model-load cost otherwise dwarfs the actual
|
||||||
|
// upscaling time for the many small sprite frames one upscale definition
|
||||||
|
// typically has.
|
||||||
|
func upscaleBatch(all []resourceFrames, pal bitmap.Palette, model string, scale int) ([][]*bitmap.Bitmap, error) {
|
||||||
|
inDir, err := os.MkdirTemp("", "mdlp-upscale-in-")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(inDir)
|
||||||
|
outDir, err := os.MkdirTemp("", "mdlp-upscale-out-")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(outDir)
|
||||||
|
|
||||||
|
frameName := func(resIdx, frameIdx int) string { return fmt.Sprintf("%d_%d.png", resIdx, frameIdx) }
|
||||||
|
|
||||||
|
for ri, rf := range all {
|
||||||
|
for fi, frame := range rf.frames {
|
||||||
|
f, err := os.Create(filepath.Join(inDir, frameName(ri, fi)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = png.Encode(f, bitmapToImage(frame, pal))
|
||||||
|
closeErr := f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return nil, closeErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalFrames := 0
|
||||||
|
for _, rf := range all {
|
||||||
|
totalFrames += len(rf.frames)
|
||||||
|
}
|
||||||
|
fmt.Printf(" upscaling %d resource(s), %d frame(s) total (model %s, scale %d):\n",
|
||||||
|
len(all), totalFrames, model, scale)
|
||||||
|
|
||||||
|
// UPSCALE_MODEL_PATH (set by mdlp-image's Dockerfile) points at the ONNX
|
||||||
|
// model baked into the image.
|
||||||
|
modelPath := os.Getenv("UPSCALE_MODEL_PATH")
|
||||||
|
if modelPath == "" {
|
||||||
|
return nil, fmt.Errorf("UPSCALE_MODEL_PATH is not set - see mdlp-image/Dockerfile")
|
||||||
|
}
|
||||||
|
cmd := exec.Command("python3", "/usr/local/bin/upscale_batch.py", modelPath, inDir, outDir, strconv.Itoa(scale))
|
||||||
|
// Streamed live, not buffered via CombinedOutput: a single directory
|
||||||
|
// batch can run for a while, and silence until the whole thing exits
|
||||||
|
// gives no sense that it's still making progress.
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("upscale_batch.py: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([][]*bitmap.Bitmap, len(all))
|
||||||
|
for ri, rf := range all {
|
||||||
|
upscaled := make([]*bitmap.Bitmap, len(rf.frames))
|
||||||
|
for fi, frame := range rf.frames {
|
||||||
|
outPath := filepath.Join(outDir, frameName(ri, fi))
|
||||||
|
outFile, err := os.Open(outPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s frame %d: %w", rf.label, fi, err)
|
||||||
|
}
|
||||||
|
upscaledImg, err := png.Decode(outFile)
|
||||||
|
closeErr := outFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s frame %d: %w", rf.label, fi, err)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return nil, closeErr
|
||||||
|
}
|
||||||
|
rgba := toRGBA(upscaledImg)
|
||||||
|
transparent := frame.Header.Flags&bitmap.FlagTransparent != 0
|
||||||
|
upscaled[fi] = imageToBitmap(rgba, pal, transparent, frame.Header.Area)
|
||||||
|
}
|
||||||
|
result[ri] = upscaled
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toRGBA(img image.Image) *image.RGBA {
|
||||||
|
if rgba, ok := img.(*image.RGBA); ok {
|
||||||
|
return rgba
|
||||||
|
}
|
||||||
|
b := img.Bounds()
|
||||||
|
converted := image.NewRGBA(b)
|
||||||
|
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||||
|
for x := b.Min.X; x < b.Max.X; x++ {
|
||||||
|
converted.Set(x, y, img.At(x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return converted
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeFrames RLE-diffs each frame against the previous, matching the
|
||||||
|
// convention hacked's own editor/animations/View.go uses when importing a
|
||||||
|
// GIF (the first frame diffs against an implicit all-zero reference).
|
||||||
|
func encodeFrames(frames []*bitmap.Bitmap) [][]byte {
|
||||||
|
blocks := make([][]byte, 0, len(frames))
|
||||||
|
var prevPixels []byte
|
||||||
|
for _, bmp := range frames {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_ = binary.Write(buf, binary.LittleEndian, &bmp.Header)
|
||||||
|
_ = rle.Compress(buf, bmp.Pixels, prevPixels)
|
||||||
|
prevPixels = bmp.Pixels
|
||||||
|
blocks = append(blocks, buf.Bytes())
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRawResource writes a brand-new override resource with explicit
|
||||||
|
// metadata, bypassing hacked's own ids.Info()-based defaults (see
|
||||||
|
// upscaleFileFallback's comment for why that's necessary here).
|
||||||
|
func writeRawResource(mod *world.Mod, lang resource.Language, id resource.ID, contentType resource.ContentType, blocks [][]byte) error {
|
||||||
|
mod.Modify(func(modder world.Modder) {
|
||||||
|
modder.SetResourceBlocks(lang, id, blocks)
|
||||||
|
})
|
||||||
|
for _, loc := range mod.ModifiedResources() {
|
||||||
|
if loc.Language != lang {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := loc.Store.Resource(id); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return loc.Store.Put(id, resource.Resource{
|
||||||
|
Properties: resource.Properties{Compound: true, ContentType: contentType, Compressed: true},
|
||||||
|
Blocks: resource.BlocksFrom(blocks),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return fmt.Errorf("internal error: resource not found in mod after write")
|
||||||
|
}
|
||||||
|
|
||||||
|
func upscaleDefFiles(modDir string) ([]string, error) {
|
||||||
|
return filepath.Glob(filepath.Join(modDir, "bitmaps", "*.json"))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user