diff --git a/AGENTS.md b/AGENTS.md index 5f7a348..1ee32c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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 - (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` to check that a change compiles. The maintainer deploys from Android Studio. `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. 3. **`engine/` and `android/gl4es-src/` are never edited.** They are vendored 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.) 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 @@ -68,6 +71,7 @@ These come from the maintainer. Follow them without being asked again. |---|---| | `engine/` | Vendored Shockolate source. Read-only for us. | | `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/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). | @@ -185,6 +189,14 @@ running" and gates the cheats and the menu pause. ## 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-.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: - Guard every engine change with `#ifdef __ANDROID__` so the desktop build is diff --git a/README.md b/README.md index 72fff3a..4126586 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,11 @@ Section "5.1. Installing and playing" has the details. ## 2. Layout - `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 environment. Every third-party dependency the engine needs to 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`/ `MINGW` branches from Shockolate's own upstream Windows build (built natively via Git Bash/MinGW on a real Windows machine - see -`engine/build_win64.sh`/`engine/appveyor.yml`), so unlike the Quest -build's `android/engine-patches/`, no source patching is needed here - -this cross-compiles those same branches offline and reproducibly, from -Linux, via Docker. +`engine/build_win64.sh`/`engine/appveyor.yml`) - this cross-compiles those +same branches offline and reproducibly, from Linux, via Docker. It applies +the same `engine-patches/` series the Linux build does (section +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 (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 `*.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 **Prerequisites:** diff --git a/build-image/Dockerfile b/build-image/Dockerfile index 898bbd4..4992ecb 100644 --- a/build-image/Dockerfile +++ b/build-image/Dockerfile @@ -124,7 +124,7 @@ ENV DEBIAN_FRONTEND=noninteractive # of packages that didn't change. RUN apt-get update && apt-get install -y --no-install-recommends \ 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 \ libogg-dev libvorbis-dev libasound2-dev openssh-client \ openjdk-17-jdk-headless unzip mingw-w64 zip \ diff --git a/build-image/build-engine-win.sh b/build-image/build-engine-win.sh index 485c876..b9bb534 100644 --- a/build-image/build-engine-win.sh +++ b/build-image/build-engine-win.sh @@ -5,8 +5,9 @@ # already has working WIN32/MINGW branches (the upstream Shockolate # 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 -# fully offline and reproducibly from Linux instead), so no source -# patching is needed, unlike the Quest build's android/engine-patches/. +# fully offline and reproducibly from Linux instead), so building it needs +# 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/ # does for the Quest build) rather than engine/ itself: its BUNDLED @@ -47,6 +48,12 @@ fi rm -rf "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir" 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" # If engine/ has ever been built in-source directly (`make dist`/`make diff --git a/build-image/build-engine.sh b/build-image/build-engine.sh index b649568..a5b27fc 100755 --- a/build-image/build-engine.sh +++ b/build-image/build-engine.sh @@ -8,16 +8,41 @@ # container - see the Makefile's `engine` target, which picks whichever # 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 # shared libraries it needs at runtime (lib/), and a default MIDI # soundfont - everything the root Makefile needs to assemble dist/. set -euo pipefail REPO_ROOT="$(pwd)" -ENGINE_DIR="$REPO_ROOT/engine" -OUT_DIR="$ENGINE_DIR/.build-output" +SCRATCH_ENGINE="$REPO_ROOT/build/linux-engine" +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 ==" rm -rf build_ext diff --git a/engine-patches/01-desktop-texture-row-length.patch b/engine-patches/01-desktop-texture-row-length.patch new file mode 100644 index 0000000..16f77d9 --- /dev/null +++ b/engine-patches/01-desktop-texture-row-length.patch @@ -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); + } + } diff --git a/engine-patches/02-desktop-audio-error-detail.patch b/engine-patches/02-desktop-audio-error-detail.patch new file mode 100644 index 0000000..d1f3708 --- /dev/null +++ b/engine-patches/02-desktop-audio-error-detail.patch @@ -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); diff --git a/engine-patches/03-desktop-fix-mod-resource-priority.patch b/engine-patches/03-desktop-fix-mod-resource-priority.patch new file mode 100644 index 0000000..52afc77 --- /dev/null +++ b/engine-patches/03-desktop-fix-mod-resource-priority.patch @@ -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; + } + diff --git a/engine-patches/04-desktop-fix-critter-sprite-scale.patch b/engine-patches/04-desktop-fix-critter-sprite-scale.patch new file mode 100644 index 0000000..fd01d36 --- /dev/null +++ b/engine-patches/04-desktop-fix-critter-sprite-scale.patch @@ -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); diff --git a/modding/Makefile b/modding/Makefile index 5fed485..024eebc 100644 --- a/modding/Makefile +++ b/modding/Makefile @@ -13,8 +13,24 @@ # mods - builds every mod under mods/ via that image, offline. # Set MODS="name1 name2" to build only those. Output # lands in dist/mods/. +# animgallery_base - browses res/assets/ss_ee/data alone: every animated +# resource as a looping GIF in build/gallery-base/. +# animgallery_mods - same, but with every built mod under dist/mods/ +# layered on top (later wins), in build/gallery-mods/ - +# shows what a mod actually changes without launching +# the game. Run `make mods` first. +# animgallery_dist - every built mod under dist/mods/ layered on top of +# the base game, like animgallery_mods, but restricted +# to only the resources a mod actually overrides, in +# build/gallery-dist/ - a focused "what did my mods +# change" view, in the base game's own colors (unlike +# a mod's own output alone, which has no palette +# resource and would render in grayscale). +# Run `make mods` first. +# (animgallery_base/_mods/_dist all write into this +# subproject's own build/, not the repo root's.) # clean - removes dist/. -.PHONY: all build-image hacked mdlp-image mods clean +.PHONY: all build-image hacked mdlp-image mods animgallery_base animgallery_mods animgallery_dist clean export MODS @@ -45,5 +61,31 @@ mods: ./mdlp-run-image.sh; \ fi +animgallery_base: + ./gen_animgallery.sh -data res/assets/ss_ee/data -out modding/build/gallery-base + +# Layers every built mod's mod/ folder on top of the base assets, later +# wins - same discovery pattern as res/run-mods.sh. +animgallery_mods: + @ARGS="-data res/assets/ss_ee/data"; \ + for d in dist/mods/*/mod; do \ + [ -d "$$d" ] && ARGS="$$ARGS -data modding/$$d"; \ + done; \ + ./gen_animgallery.sh $$ARGS -out modding/build/gallery-mods + +# Same layering as animgallery_mods, but -only-changed restricts the output +# to resources a mod layer actually overrides. +animgallery_dist: + @ARGS="-data res/assets/ss_ee/data"; \ + FOUND=""; \ + for d in dist/mods/*/mod; do \ + [ -d "$$d" ] && ARGS="$$ARGS -data modding/$$d" && FOUND=1; \ + done; \ + if [ -z "$$FOUND" ]; then \ + echo "PREFLIGHT FAIL: no built mods found under dist/mods/ - run 'make mods' first" >&2; \ + exit 1; \ + fi; \ + ./gen_animgallery.sh $$ARGS -only-changed -out modding/build/gallery-dist + clean: rm -rf dist diff --git a/modding/README.md b/modding/README.md index 7b98f67..1479a36 100644 --- a/modding/README.md +++ b/modding/README.md @@ -23,13 +23,13 @@ repository. of `hacked/` at build time (same pattern as the root project's `android/engine-patches/`, see the repository root's [AGENTS.md](../AGENTS.md) section 6) - `hacked/` itself is never edited - in place. + in place. See section 1.1 for what each patch does. - `sqweek-dialog/` - `github.com/sqweek/dialog`, `hacked`'s native file-dialog dependency. Vendored at the exact version `hacked/go.mod` would otherwise pull from the module proxy (ISC License), so it can be - patched the same way as `hacked/` itself (see section 2's crash fix). + patched the same way as `hacked/` itself (see section 1.1). - `sqweek-dialog-patches/` - patches for `sqweek-dialog/`, same pattern as - `hacked-patches/`. + `hacked-patches/` (section 1.1). - `mdlp/` - the **Mod Definition Language Processor** (section 4). First-party Go source, not vendored - edit it directly, no patch system needed. - `ss-specs/` - [InkyBlackness ss-specs](https://github.com/inkyblackness/ss-specs), @@ -63,6 +63,40 @@ support (level/resource files, `res/data/`); this folder is about the external tools that create and inspect that content, not about changing the engine itself. +### 1.1. Patch reference + +`src/hacked-patches/` and `src/sqweek-dialog-patches/` (applied to scratch +copies of `src/hacked/` and `src/sqweek-dialog/` respectively) are both +plain, numbered patch files applied in order at build time - neither +vendored source is ever modified in place. What each one does: + +`src/hacked-patches/`: + +- **`01-use-patched-dialog.patch`** - adds a `go.mod` `replace` directive + pointing `github.com/sqweek/dialog` at the local, patched + `../sqweek-dialog` instead of the upstream module, so `hacked` (and + anything else building against this source, such as `mdlp`/`animgallery`) + picks up the crash fix below. +- **`02-fix-hashall-race.patch`** - fixes a real data race in + `ss1/resource/ChangeNotifier.go`'s `hashAll()`: it hashes many resources + concurrently (one goroutine per ID) to detect what changed after a + modification, but only guarded the resource *lookup* with a mutex, not + the actual block-reading inside `hashResource()` - resources from the + same underlying file share a stateful reader, so concurrent unguarded + reads corrupted each other, non-deterministically. Found via `go build + -race` after `animgallery` produced a different resource count on every + run against the exact same input. The mutex now guards the whole hash + computation per resource, not just the lookup. + +`src/sqweek-dialog-patches/`: + +- **`01-xinitthreads.patch`** - adds `XInitThreads()` before `gtk_init()` + in the Linux backend. Without it, `hacked` segfaults the moment a native + GTK3 dialog (file/folder browse) opens while a GLFW/OpenGL context is + live: GLFW's own X11 backend installs a process-wide Xlib error handler + that conflicts with GTK's unless Xlib has been told up front that it + will be used from multiple threads. + ## 2. Building `hacked` Packages `dist/.linux64.tgz` and `dist/.win64.zip` (native @@ -139,11 +173,16 @@ Rebecca Lansing's first email. ## 4. Mod Definition Language (mods/) -The workflow above is manual and its result (whatever you clicked through in -`hacked`) isn't checked into git. `mods/` holds the same kind of edits as -plain, reviewable JSON instead, and `mdlp` (the **Mod Definition Language -Processor**, `mdlp/`) turns them into the same kind of mod folder and a zip, -reproducibly and offline. +MDL is a small, declarative JSON format for describing a mod's content - +one file per piece of content (a message override, an upscaled sprite, and +so on), naming the game resource it touches and the values to set, rather +than the resource's actual binary data. `mdlp` (the **Mod Definition +Language Processor**, `mdlp/`) reads these files and produces the same kind +of mod folder `hacked` itself would save, reproducibly and offline. Since a +plain-text description is all that's checked into git, mods stay reviewable +and reproducible from the game's own data alone (see section 4.3) - unlike +the workflow in section 3, whose result only ever lives in whatever you +clicked through in `hacked`. ### 4.1. Layout @@ -208,8 +247,12 @@ Needs the game's own data directory at `../res/assets/ss_ee/data` (see the root [README.md](../README.md) section 3) as read-only reference data, so overrides that touch only some fields of a message can inherit the rest. -Output lands in `dist/mods//`: a `mod/` folder (the same shape -`hacked`'s own Project window Save produces) and `.zip`. +Output lands in `dist/mods//mod/` (the same shape `hacked`'s own +Project window Save produces) for each mod, plus one combined +`dist/questshock-mods.zip` packaging all of them together. Nothing but +`mods/`'s own JSON/text files needs to be checked into git - the game's own +data (already required locally, already git-ignored) plus those files is +always enough to reproduce this output from scratch. Test every built mod at once with `run-mods.sh` (Linux) / `run-mods.bat` (Windows), copied into the root project's own `dist/`/`dist-win/` by diff --git a/modding/gen_animgallery.sh b/modding/gen_animgallery.sh new file mode 100755 index 0000000..eda6818 --- /dev/null +++ b/modding/gen_animgallery.sh @@ -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 "$@" + ' _ "$@" diff --git a/modding/mdlp-image/Dockerfile b/modding/mdlp-image/Dockerfile index 3fecdca..5723882 100644 --- a/modding/mdlp-image/Dockerfile +++ b/modding/mdlp-image/Dockerfile @@ -11,10 +11,33 @@ FROM ubuntu:22.04 # needs to fetch a newer toolchain over the network at build time. ARG GO_VERSION=1.27.1 +# patch: applies src/hacked-patches/ to the scratch copy of hacked/ that +# process-mods.sh (and ../gen_animgallery.sh) stage before building against +# it - see stage-hacked.sh. python3/pip: runs the AI upscaler below via +# plain CPU inference (ONNX Runtime). Not a Vulkan-based tool: this image +# has no real GPU, and Vulkan software emulation (Mesa lavapipe) gives +# wrong colors for ML inference workloads. RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - ca-certificates curl gosu \ + ca-certificates curl gosu patch python3 python3-pip \ && rm -rf /var/lib/apt/lists/* +RUN pip3 install --no-cache-dir onnxruntime pillow numpy + +# The AI upscaler model for mdlp's upscale mod type (see ../README.md +# section 4): RealESRGAN_x4plus_anime_6B, a 6-block RRDBNet ONNX-exported +# from xinntao/Real-ESRGAN's anime-illustration checkpoint (BSD-3-Clause). +# It's trained on flat-color line art, so it keeps hard sprite edges rather +# than smoothing them the way a photo- or video-denoising model would. A +# mod's own "model" field (upscaleDef in ../src/mdlp/upscale.go) is not +# consulted; every resource goes through this one model. upscale_batch.py +# resizes its fixed native 4x output down to whatever scale a mod actually +# asks for. +ARG UPSCALE_MODEL_URL=https://huggingface.co/skillsafe-ai/realesrgan-x4plus-anime-6b/resolve/main/model.onnx +RUN mkdir -p /opt/upscale-model \ + && curl -sSL -o /opt/upscale-model/model.onnx "$UPSCALE_MODEL_URL" +COPY mdlp-image/upscale_batch.py /usr/local/bin/upscale_batch.py +RUN chmod 755 /usr/local/bin/upscale_batch.py +ENV UPSCALE_MODEL_PATH=/opt/upscale-model/model.onnx RUN curl -sSLO "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \ && tar -C /usr/local -xzf "go${GO_VERSION}.linux-amd64.tar.gz" \ diff --git a/modding/mdlp-image/process-mods.sh b/modding/mdlp-image/process-mods.sh index 17618e0..4b04feb 100644 --- a/modding/mdlp-image/process-mods.sh +++ b/modding/mdlp-image/process-mods.sh @@ -22,10 +22,20 @@ OUT_DIR="$MODDING_ROOT/dist/mods" [ -d "$DATA_DIR" ] \ || { echo "Game data not found at $DATA_DIR - see the root README.md section 3." >&2; exit 1; } +echo "== Staging a patched copy of hacked/ for mdlp to build against ==" +# mdlp's own go.mod points a `replace` at the sibling ../hacked - stage a +# patched copy here (never build src/hacked/ in place) so mdlp picks up the +# same fixes hacked's own binary does (see src/hacked-patches/). +SCRATCH_DIR="$MODDING_ROOT/build/mdlp-src" +rm -rf "$SCRATCH_DIR" +mkdir -p "$SCRATCH_DIR" +bash "$MODDING_ROOT/mdlp-image/stage-hacked.sh" "$MODDING_ROOT" "$SCRATCH_DIR/hacked" +cp -a "$MODDING_ROOT/src/mdlp" "$SCRATCH_DIR/mdlp" + echo "== Building mdlp ==" BIN_DIR="$(mktemp -d)" trap 'rm -rf "$BIN_DIR"' EXIT -(cd "$MODDING_ROOT/src/mdlp" && go build -trimpath -o "$BIN_DIR/mdlp" .) +(cd "$SCRATCH_DIR/mdlp" && go build -trimpath -o "$BIN_DIR/mdlp" .) MDLP_ARGS=(-data "$DATA_DIR" -mods "$MODDING_ROOT/mods" -out "$OUT_DIR") if [ -n "${MODS:-}" ]; then diff --git a/modding/mdlp-image/stage-hacked.sh b/modding/mdlp-image/stage-hacked.sh new file mode 100755 index 0000000..fb1ea1a --- /dev/null +++ b/modding/mdlp-image/stage-hacked.sh @@ -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 +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 diff --git a/modding/mdlp-image/upscale_batch.py b/modding/mdlp-image/upscale_batch.py new file mode 100644 index 0000000..d1b8700 --- /dev/null +++ b/modding/mdlp-image/upscale_batch.py @@ -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 +""" +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() diff --git a/modding/mods/upscale_medbot/bitmaps/medbot.json b/modding/mods/upscale_medbot/bitmaps/medbot.json new file mode 100644 index 0000000..1b19546 --- /dev/null +++ b/modding/mods/upscale_medbot/bitmaps/medbot.json @@ -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" + ] +} diff --git a/modding/mods/upscale_mutant/bitmaps/mutants_infected.json b/modding/mods/upscale_mutant/bitmaps/mutants_infected.json new file mode 100644 index 0000000..fbc8565 --- /dev/null +++ b/modding/mods/upscale_mutant/bitmaps/mutants_infected.json @@ -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" + ] +} diff --git a/modding/mods/upscale_mutant/bitmaps/mutants_normal.json b/modding/mods/upscale_mutant/bitmaps/mutants_normal.json new file mode 100644 index 0000000..b392e35 --- /dev/null +++ b/modding/mods/upscale_mutant/bitmaps/mutants_normal.json @@ -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" + ] +} diff --git a/modding/src/animgallery/go.mod b/modding/src/animgallery/go.mod new file mode 100644 index 0000000..62c11eb --- /dev/null +++ b/modding/src/animgallery/go.mod @@ -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 diff --git a/modding/src/animgallery/go.sum b/modding/src/animgallery/go.sum new file mode 100644 index 0000000..f6ec887 --- /dev/null +++ b/modding/src/animgallery/go.sum @@ -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= diff --git a/modding/src/animgallery/main.go b/modding/src/animgallery/main.go new file mode 100644 index 0000000..af64c2f --- /dev/null +++ b/modding/src/animgallery/main.go @@ -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//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("System Shock animation gallery") + b.WriteString("") + + b.WriteString(`
` + + `` + + `` + + `` + + `
` + + `` + + `` + + `
`) + + b.WriteString(`

Selected (0)

`) + + fmt.Fprintf(&b, "

%d animated resource(s)

", 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, "
", html.EscapeString(search)) + fmt.Fprintf(&b, "", html.EscapeString(e.gifName), e.width*2, e.height*2) + fmt.Fprintf(&b, "

%dx%d, %d frame(s), %s · %s · %s
", + 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("
") + } + } + fmt.Fprintf(&b, "

Animations (%d)

", len(animations)) + writeCards(animations, "a") + fmt.Fprintf(&b, "

Bitmaps (%d)

", len(bitmaps)) + writeCards(bitmaps, "b") + + b.WriteString(``) + + b.WriteString("") + return os.WriteFile(path, []byte(b.String()), 0o644) +} diff --git a/modding/src/hacked-patches/02-fix-hashall-race.patch b/modding/src/hacked-patches/02-fix-hashall-race.patch new file mode 100644 index 0000000..73a9138 --- /dev/null +++ b/modding/src/hacked-patches/02-fix-hashall-race.patch @@ -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 { diff --git a/modding/src/mdlp/main.go b/modding/src/mdlp/main.go index b0cb20e..a9ae635 100644 --- a/modding/src/mdlp/main.go +++ b/modding/src/mdlp/main.go @@ -1,6 +1,7 @@ // Command mdlp (Mod Definition Language Processor) turns the declarative mod -// definitions under modding/mods/ into hacked-compatible mod folders and zip -// archives under modding/dist/mods/. See ../../README.md for the MDL schema. +// definitions under modding/mods/ into hacked-compatible mod folders under +// modding/dist/mods/, packaged together as modding/dist/questshock-mods.zip. +// See ../../README.md for the MDL schema. package main import ( @@ -15,6 +16,7 @@ import ( "sort" "strings" + "github.com/inkyblackness/hacked/ss1/content/bitmap" "github.com/inkyblackness/hacked/ss1/content/text" "github.com/inkyblackness/hacked/ss1/edit" "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" @@ -108,10 +110,17 @@ func main() { for _, name := range names { fmt.Printf("== Building mod %q ==\n", name) modOutDir := filepath.Join(*outDir, name) - if err := buildMod(*dataDir, filepath.Join(*modsDir, name), modOutDir, name); err != nil { + if err := buildMod(*dataDir, filepath.Join(*modsDir, name), modOutDir); err != nil { log.Fatalf("%s: %v", name, err) } } + + zipPath := filepath.Join(filepath.Dir(*outDir), "questshock-mods.zip") + fmt.Println("== Packaging ==") + if err := zipDir(*outDir, zipPath); err != nil { + log.Fatalf("packaging %s: %v", zipPath, err) + } + fmt.Printf(" zip: %s\n", zipPath) } func modNames(modsDir, only string) ([]string, error) { @@ -132,13 +141,19 @@ func modNames(modsDir, only string) ([]string, error) { return names, nil } -func buildMod(dataDir, modDir, outDir, name string) error { +func buildMod(dataDir, modDir, outDir string) error { mod := world.NewMod(func([]resource.ID, []resource.ID) {}, func() {}) entry, err := world.NewManifestEntryFrom([]string{dataDir}) if err != nil { return fmt.Errorf("loading game data from %s: %w", dataDir, err) } + // NewManifestEntryFrom builds entry.Resources by ranging over a Go map, + // so its order (and therefore which file "wins" a Selector's + // last-one-wins tie-break for any resource ID more than one file + // defines) is randomized per process run. Sort it ourselves so the same + // input always resolves the same way. + sort.Slice(entry.Resources, func(i, j int) bool { return entry.Resources[i].ID < entry.Resources[j].ID }) if err := mod.World().InsertEntry(0, entry); err != nil { return fmt.Errorf("registering game data: %w", err) } @@ -156,6 +171,22 @@ func buildMod(dataDir, modDir, outDir, name string) error { } } + upscaleDefs, err := upscaleDefFiles(modDir) + if err != nil { + return err + } + if len(upscaleDefs) > 0 { + pal, err := bitmap.NewPaletteCache(mod).Palette(resource.KeyOf(ids.GamePalettesStart, resource.LangDefault, 0)) + if err != nil { + return fmt.Errorf("loading game palette: %w", err) + } + for _, defFile := range upscaleDefs { + if err := applyUpscaleDef(mod, pal, defFile); err != nil { + return fmt.Errorf("%s: %w", defFile, err) + } + } + } + modOutDir := filepath.Join(outDir, "mod") if err := os.RemoveAll(modOutDir); err != nil { return err @@ -170,12 +201,7 @@ func buildMod(dataDir, modDir, outDir, name string) error { return fmt.Errorf("saving mod: %w", err) } - zipPath := filepath.Join(outDir, name+".zip") - if err := zipDir(modOutDir, zipPath); err != nil { - return fmt.Errorf("packaging zip: %w", err) - } fmt.Printf(" mod: %s\n", modOutDir) - fmt.Printf(" zip: %s\n", zipPath) return nil } diff --git a/modding/src/mdlp/upscale.go b/modding/src/mdlp/upscale.go new file mode 100644 index 0000000..b28b3f7 --- /dev/null +++ b/modding/src/mdlp/upscale.go @@ -0,0 +1,469 @@ +// AI upscaling: modding/mods//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//bitmaps/