Add a first-party MDL mod-build system and reorganize modding/ under src/

modding/mods/ holds message edits as plain JSON (type, index, language,
plus only the changed fields - a long body can live in a sibling .txt to
avoid escaping in JSON), so mod authoring is reviewable in git instead of
living only in hacked's GUI. mdlp (modding/src/mdlp/, its own Docker image
mdlp-image/) turns them into the same mod folder/zip hacked's own Save
produces, reusing hacked's ss1/edit, ss1/world and ss1/content/text
packages via a go.mod replace - no GUI/GLFW/GTK needed. `make mods`
builds every mod into dist/mods/<name>/, scoped per-mod so it never
touches hacked's own build output; `make` (no target) now builds both.

Fixes a bug this surfaced: hacked's FileLocation.AbsolutePathFrom
silently writes into the current directory instead of the given output
folder when passed a relative path (the GUI never hits this since its
folder picker is always absolute) - mdlp now resolves its own paths
up front.

res/run-mods.sh/.bat are new companion launchers to run.sh/.bat: they
pass every dist/mods/*/mod folder to the engine as a separate absolute
argument, since ProcessModDirectory() only scans one directory level
deep. Wired into `make dist`/`dist-win` only, not the release archives.

modding/hacked/, its patches, sqweek-dialog/, its patches, and the new
mdlp/ move under modding/src/, separating source from the surrounding
Docker/script scaffolding; all path references updated to match.

NOTICE.txt gains a section for hacked's distributed build, listing hacked
(New BSD), sqweek/dialog (ISC - corrected from a wrong "New BSD" note),
and hacked's other direct Go dependencies.
This commit is contained in:
ml
2026-09-26 15:41:56 +02:00
parent c0952898ae
commit 389abd7de7
24 changed files with 839 additions and 35 deletions
+4 -1
View File
@@ -73,7 +73,7 @@ dist: dist-linux dist-win
dist-linux: engine assets
@echo "== Assembling $(DIST_DIR) =="
rm -rf "$(DIST_DIR)/systemshock" "$(DIST_DIR)/lib" "$(DIST_DIR)/res" \
"$(DIST_DIR)/shaders" "$(DIST_DIR)/run.sh"
"$(DIST_DIR)/shaders" "$(DIST_DIR)/run.sh" "$(DIST_DIR)/run-mods.sh"
mkdir -p "$(DIST_DIR)/lib" "$(DIST_DIR)/res/data" "$(DIST_DIR)/res/sound"
cp "$(ENGINE_OUT)/systemshock" "$(DIST_DIR)/"
cp -a "$(ENGINE_OUT)/lib/." "$(DIST_DIR)/lib/"
@@ -83,6 +83,8 @@ dist-linux: engine assets
cp -a "$(ASSETS_DIR)/sound/." "$(DIST_DIR)/res/sound/"
cp res/run.sh "$(DIST_DIR)/run.sh"
chmod +x "$(DIST_DIR)/run.sh"
cp res/run-mods.sh "$(DIST_DIR)/run-mods.sh"
chmod +x "$(DIST_DIR)/run-mods.sh"
@echo "== Done - run $(DIST_DIR)/run.sh to play =="
# Windows counterpart of dist-linux. Unlike dist/, the DLLs sit flat
@@ -100,6 +102,7 @@ dist-win: engine-win assets
cp -a "$(ASSETS_DIR)/data/." "$(DIST_WIN_DIR)/res/data/"
cp -a "$(ASSETS_DIR)/sound/." "$(DIST_WIN_DIR)/res/sound/"
cp res/run.bat "$(DIST_WIN_DIR)/run.bat"
cp res/run-mods.bat "$(DIST_WIN_DIR)/run-mods.bat"
@echo "== Done - run $(DIST_WIN_DIR)/run.bat (or systemshock.exe) to play =="
# Builds both platforms' redistributable archives. `package-linux`/
+14
View File
@@ -35,3 +35,17 @@ cross-build") additionally bundles:
statically into systemshock.exe instead of shipped as a DLL, under the
GCC Runtime Library Exception - this doesn't subject the rest of the
binary to the GPL.)
The modding tools' `hacked` build (see modding/README.md; output under
modding/dist/*.tgz and *.zip) additionally bundles:
- InkyBlackness HackEd (https://github.com/inkyblackness/hacked), vendored
unmodified as modding/src/hacked/. It is New BSD-licensed.
- github.com/sqweek/dialog, vendored and patched (see
modding/src/sqweek-dialog-patches/) as modding/src/sqweek-dialog/. It is
ISC-licensed.
- hacked's further direct Go dependencies (fetched at build time, not
vendored in this repository): github.com/inkyblackness/imgui-go (New BSD);
github.com/go-gl/glfw, github.com/go-gl/mathgl, and golang.org/x/image
(BSD 3-Clause); github.com/go-gl/gl, github.com/asticode/go-astisub, and
github.com/lucasb-eyer/go-colorful (MIT).
+8 -1
View File
@@ -80,7 +80,14 @@ Section "5.1. Installing and playing" has the details.
- `res/assets/` - where you place your own purchased copy of the game (see
"3. Game assets" below); `res/assets/extract_assets.sh` extracts it into
`ss_ee/`.
- `res/run.sh` - the launcher script, copied into `dist/` on build.
- `res/run.sh` / `res/run.bat` - the launcher scripts, copied into
`dist`/`dist-win` on build. `res/run-mods.sh` / `res/run-mods.bat` are
companion launchers that additionally load every mod built under the
`modding/` subproject's own `dist/mods/` (see
[modding/README.md](modding/README.md) section 4.3) - copied into
`dist`/`dist-win` the same way, but not into the redistributable
`package`/`package-win` archives, since that build output is local and
git-ignored.
- `res/questshock.config.default.json` - the bundled default configuration
(see [CONFIG.md](CONFIG.md)); `res/controller_profiles/` - the per-controller
illustrations and labels shown in the Controller Options tab (see
+22 -3
View File
@@ -1,16 +1,24 @@
# Builds the modding subproject - see README.md.
#
# Targets:
# all (default) - alias for `hacked`.
# all (default) - `hacked` and `mods`.
# build-image - builds the hacked-build-image Docker image
# (./hacked-build-image.sh). Only needed once, or after
# hacked-build-image/ or hacked/go.mod|go.sum changes.
# hacked - compiles hacked/ for Linux and Windows via that image,
# offline. Output lands in dist/.
# mdlp-image - builds the mdlp-image Docker image (./mdlp-image.sh).
# Only needed once, or after mdlp-image/ or
# mdlp/go.mod|go.sum changes.
# mods - builds every mod under mods/ via that image, offline.
# Set MODS="name1 name2" to build only those. Output
# lands in dist/mods/.
# clean - removes dist/.
.PHONY: all build-image hacked clean
.PHONY: all build-image hacked mdlp-image mods clean
all: hacked
export MODS
all: hacked mods
build-image:
./hacked-build-image.sh
@@ -26,5 +34,16 @@ hacked:
./hacked-build-run-image.sh; \
fi
mdlp-image:
./mdlp-image.sh
# Same already-in-the-image shortcut as `hacked` above, for MDLP_IMAGE.
mods:
@if [ -n "$$MDLP_IMAGE" ]; then \
bash mdlp-image/process-mods.sh; \
else \
./mdlp-run-image.sh; \
fi
clean:
rm -rf dist
+144 -17
View File
@@ -4,34 +4,59 @@ Tools and reference material for modding System Shock (1994) itself - distinct
from the rest of this repository, which is the [QuestShock](../README.md) VR
port of the game engine. Everything modding-related lives under this folder.
Both pieces here come from the [InkyBlackness](https://inkyblackness.github.io)
project and are vendored unmodified, each under its own upstream license (see
below) - neither is relicensed by this repository's own MIT license.
`hacked` and `ss-specs` come from the [InkyBlackness](https://inkyblackness.github.io)
project; `sqweek-dialog` is a separate third-party dependency of `hacked`.
All three are vendored unmodified under `src/`, each under its own upstream
license (see below) - none is relicensed by this repository's own MIT
license. `mdlp` (also under `src/`) is first-party code written for this
repository.
## 1. Layout
- `hacked/` - [InkyBlackness HackEd](https://github.com/inkyblackness/hacked),
- `src/` - every vendored and first-party source tree, kept apart from the
Docker/script scaffolding that builds them:
- `hacked/` - [InkyBlackness HackEd](https://github.com/inkyblackness/hacked),
the graphical mod editor. A vendored source snapshot (New BSD License),
taken from the `main` branch on 2026-09-26. To update it, replace this
directory with a fresh checkout and update that date.
- `ss-specs/` - [InkyBlackness ss-specs](https://github.com/inkyblackness/ss-specs),
the unofficial file-format specifications the editor (and any other modding
tool) is written against. Reference documentation only, not built. A
vendored snapshot (CC BY-NC-SA 4.0 - **non-commercial**, unlike the rest of
this repository), taken from the `main` branch on 2026-09-26.
- `hacked-patches/` - numbered `patch -p1` files applied to a scratch copy
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.
- `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).
- `sqweek-dialog-patches/` - patches for `sqweek-dialog/`, same pattern as
`hacked-patches/`.
- `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),
the unofficial file-format specifications the editor (and any other
modding tool) is written against. Reference documentation only, not
built. A vendored snapshot (CC BY-NC-SA 4.0 - **non-commercial**, unlike
the rest of this repository), taken from the `main` branch on
2026-09-26.
- `hacked-build-image/` - the Dockerfile (and supporting scripts) for the
`hacked` build environment: the Go toolchain plus every dependency
`hacked/go.mod` pins, fetched once at image-build time so the actual build
needs no network access. Mirrors the main project's own `build-image/`
(see the repository root's [README.md](../README.md) section 4) one level
down, for this subproject.
`src/hacked/go.mod` pins, fetched once at image-build time so the actual
build needs no network access. Mirrors the main project's own
`build-image/` (see the repository root's [README.md](../README.md)
section 4) one level down, for this subproject.
- `hacked-build-image.sh` / `hacked-build-run-image.sh` /
`hacked-build-upload-image.sh` - build the image, run it to compile
`hacked/`, and push it to a registry, respectively. Same pattern as the
root project's own `build-image.sh`/`run-image.sh`/`upload-image.sh`.
- `Makefile` - `hacked` (default) builds `hacked/` via the image; `build-image`
(re)builds the image itself; `clean` removes `dist/`.
- `dist/` - build output (git-ignored), created by `make hacked`.
- `mdlp-image/`, `mdlp-image.sh` / `mdlp-run-image.sh` / `mdlp-upload-image.sh` -
the same pattern again, for `mdlp` (section 4.2).
- `mods/` - mod definitions in the Mod Definition Language (section 4).
- `Makefile` - `hacked` (default) builds `hacked/` via the image;
`build-image` (re)builds that image; `mdlp-image` builds the `mdlp` image;
`mods` processes every mod under `mods/` via that image; `clean` removes
`dist/`.
- `dist/` - build output (git-ignored): `make hacked` under `linux/`/`win/`,
`make mods` under `mods/`.
Shockolate (the engine `../engine/` vendors) already has its own modding
support (level/resource files, `res/data/`); this folder is about the
@@ -51,7 +76,7 @@ to install locally.
cd modding
# 1. Build the hacked-build-image (once, or after hacked-build-image/ or
# hacked/go.mod|go.sum changes)
# src/hacked/go.mod|go.sum changes)
./hacked-build-image.sh
# 2. Compile hacked/ for Linux and Windows
@@ -111,3 +136,105 @@ Rebecca Lansing's first email.
`ProcessModDirectory()` reports nothing on a bad path). When the path is
right, the terminal shows `ProcessModDirectory ...` / `Found resource
file: ...` / `Loading mod file ...` for confirmation.
## 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.
### 4.1. Layout
```
mods/
<mod-name>/
messages/
<label>.json # one message's overrides
<label>.txt # optional: a text field's body, referenced by ...File
```
Each `messages/*.json` overrides one message (email, log, or fragment) by
type and index. Only fields present in the file are changed - everything
else is read from the game's own data (Static World Data), the same
inheritance the Messages window in `hacked` shows via its "(read-only)"
label:
| Field | Meaning |
|---|---|
| `type` | `Mails`, `Logs`, or `Fragments` |
| `index` | The message's index within that type |
| `language` | `Default`, `French`, or `German` - omit for `Default` |
| `title`, `sender`, `subject` | Plain strings |
| `verboseText` / `verboseTextFile` | The long body, inline or from a sibling file (avoids escaping newlines/quotes in JSON) |
| `terseText` / `terseTextFile` | The short body, inline or from a sibling file |
| `nextMessage`, `isInterrupt`, `colorIndex`, `leftDisplay`, `rightDisplay` | See `ElectronicMessage` in `src/hacked/ss1/content/text/ElectronicMessage.go` |
`mods/mymod/` is a worked example matching section 3's Rebecca's-mail edit,
expressed as MDL instead of a `hacked` session.
### 4.2. `mdlp`
`src/mdlp/` is first-party Go source (not vendored - edit it directly, no
patch system needed). It reuses `hacked`'s own resource read/write code
(`ss1/edit`, `ss1/world`, `ss1/content/text`) through a `go.mod` `replace`
pointing at the sibling `../hacked` (both under `src/`), instead of
reimplementing System Shock's file formats. It needs no GUI, GLFW, or GTK -
only the packages that model resource data.
`mdlp-image/` is its own Docker build/runtime environment, separate from
`hacked-build-image/`: mod processing is expected to grow further external
tool dependencies (image/audio conversion, etc.) of its own over time,
independent of `hacked`'s GTK/GLFW/mingw toolchain.
### 4.3. Building mods
```sh
cd modding
# 1. Build the mdlp-image (once, or after mdlp-image/ or
# src/mdlp/go.mod|go.sum changes)
./mdlp-image.sh
# 2. Process every mod under mods/
make mods
# ...or only some of them:
MODS="mymod" make mods
```
Needs the game's own data directory at `../res/assets/ss_ee/data` (see the
root [README.md](../README.md) section 3) as read-only reference data, so
overrides that touch only some fields of a message can inherit the rest.
Output lands in `dist/mods/<mod-name>/`: a `mod/` folder (the same shape
`hacked`'s own Project window Save produces) and `<mod-name>.zip`.
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
`make dist` (root [README.md](../README.md) section 4) alongside the plain
`run.sh`/`run.bat`:
```sh
../dist/run-mods.sh
```
It scans `dist/mods/*/mod` and passes each one it finds to `run.sh` as a
separate, absolute argument - the engine's `ProcessModDirectory()` only scans
one directory level deep (see `res/run-mods.sh`'s own comment), so it can't
simply be pointed at `dist/mods/` itself. To test a single mod instead, use
`run.sh` directly the same way as section 3's step 5:
```sh
../dist/run.sh "$(pwd)/dist/mods/mymod/mod"
```
### 4.4. Publishing the mdlp-image
Same as `hacked-build-image/` (section 2.1): `./mdlp-upload-image.sh` reads
`REGISTRY`/`REGISTRY_USER`/`REGISTRY_PASSWORD` and (optionally)
`MDLP_REGISTRY_IMAGE` from `../registry.env`. Don't bump
`mdlp-image/VERSION` just because the Dockerfile changed - only once a build
with the current version has actually succeeded, same policy as the root
project's own `build-image/VERSION`.
+3 -3
View File
@@ -1,9 +1,9 @@
# Build environment for modding/hacked/ (vendored from
# Build environment for modding/src/hacked/ (vendored from
# https://github.com/inkyblackness/hacked, see ../README.md). Fetches the Go
# toolchain and this module's dependencies once, here, at image-build time, so
# ../hacked-build-run-image.sh can compile fully offline afterward. Rebuild
# with ../hacked-build-image.sh whenever a version below changes, or whenever
# ../hacked/go.mod or go.sum changes (its dependencies are baked into the
# ../src/hacked/go.mod or go.sum changes (its dependencies are baked into the
# image's module cache).
FROM ubuntu:22.04
@@ -40,7 +40,7 @@ ENV GOPATH=/opt/go
# container never needs network access to build it. Only go.mod/go.sum are
# copied in for this - not the rest of the source - so editing hacked's own
# .go files doesn't invalidate this layer's cache.
COPY hacked/go.mod hacked/go.sum /tmp/hacked-deps/
COPY src/hacked/go.mod src/hacked/go.sum /tmp/hacked-deps/
RUN cd /tmp/hacked-deps && go mod download && rm -rf /tmp/hacked-deps
COPY hacked-build-image/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
+7 -7
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Compiles modding/hacked/ for Linux and Windows (amd64), entirely offline -
# every Go module dependency was already fetched into the build-image at
# image-build time (see Dockerfile). Baked into the image at
# Compiles modding/src/hacked/ for Linux and Windows (amd64), entirely
# offline - every Go module dependency was already fetched into the
# build-image at image-build time (see Dockerfile). Baked into the image at
# /usr/local/bin/build-hacked.sh (see Dockerfile's CMD) - run via
# ../hacked-build-run-image.sh, which bind-mounts modding/ at /workspace, not
# directly.
@@ -29,12 +29,12 @@ echo "== Preparing scratch, patched copies of hacked/ and sqweek-dialog/ =="
# scratch copies must keep that same relative layout.
rm -rf "$SCRATCH_DIR"
mkdir -p "$SCRATCH_DIR"
cp -a "$MODDING_ROOT/hacked" "$HACKED_SRC"
cp -a "$MODDING_ROOT/sqweek-dialog" "$DIALOG_SRC"
for p in "$MODDING_ROOT"/hacked-patches/*.patch; do
cp -a "$MODDING_ROOT/src/hacked" "$HACKED_SRC"
cp -a "$MODDING_ROOT/src/sqweek-dialog" "$DIALOG_SRC"
for p in "$MODDING_ROOT"/src/hacked-patches/*.patch; do
patch -p1 -d "$HACKED_SRC" < "$p"
done
for p in "$MODDING_ROOT"/sqweek-dialog-patches/*.patch; do
for p in "$MODDING_ROOT"/src/sqweek-dialog-patches/*.patch; do
patch -p1 -d "$DIALOG_SRC" < "$p"
done
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Builds the mdlp-image (mdlp-image/Dockerfile) locally, tagged with
# mdlp-image/VERSION and :latest. Does not push - test it with
# ./mdlp-run-image.sh first, then publish with ./mdlp-upload-image.sh.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(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 "$ROOT/../registry.env" ]; then
# shellcheck disable=SC1091
source "$ROOT/../registry.env"
IMAGE="${MDLP_REGISTRY_IMAGE:-$IMAGE}"
fi
VERSION="$(<"$ROOT/mdlp-image/VERSION")"
[ -n "$VERSION" ] || fail "mdlp-image/VERSION is empty"
echo "== Building $IMAGE:$VERSION =="
docker build \
-f "$ROOT/mdlp-image/Dockerfile" \
-t "$IMAGE:$VERSION" \
-t "$IMAGE:latest" \
"$ROOT"
echo "== Done =="
echo "Image: $IMAGE:$VERSION (and :latest)"
echo "Test it with ./mdlp-run-image.sh, then publish with ./mdlp-upload-image.sh"
+51
View File
@@ -0,0 +1,51 @@
# Runtime environment for modding/src/mdlp/ (the first-party Mod Definition
# Language Processor - see ../README.md): builds mdlp from source and runs it
# against modding/mods/ to produce modding/dist/mods/. Kept as its own image,
# separate from hacked-build-image/, because mod processing is expected to
# grow its own external tool dependencies (image/audio conversion, etc.)
# independent of hacked's own GTK/GLFW/mingw toolchain.
FROM ubuntu:22.04
# go.mod's own "go" directive is a minimum toolchain version, not a version to
# fetch - pin a stable release that already satisfies it so `go build` never
# needs to fetch a newer toolchain over the network at build time.
ARG GO_VERSION=1.27.1
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl gosu \
&& rm -rf /var/lib/apt/lists/*
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" \
&& rm "go${GO_VERSION}.linux-amd64.tar.gz"
ENV PATH=/usr/local/go/bin:${PATH}
ENV GOTOOLCHAIN=local
ENV GOPATH=/opt/go
# Populates /opt/go/pkg/mod with every dependency mdlp/go.mod pins, so the
# container never needs network access to build it. mdlp/go.mod replaces
# github.com/inkyblackness/hacked with the local ../hacked (see ../README.md),
# so that module's own go.mod/go.sum have to be present too for dependency
# resolution - only these two go.mod/go.sum pairs are copied in for this, not
# the rest of either source tree, so editing .go files doesn't invalidate
# this layer's cache.
COPY src/hacked/go.mod src/hacked/go.sum /tmp/mdlp-deps/hacked/
COPY src/mdlp/go.mod src/mdlp/go.sum /tmp/mdlp-deps/mdlp/
RUN cd /tmp/mdlp-deps/mdlp && go mod download && rm -rf /tmp/mdlp-deps
COPY mdlp-image/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
COPY mdlp-image/process-mods.sh /usr/local/bin/process-mods.sh
# 755, not +x: see hacked-build-image/Dockerfile's identical comment - the
# entrypoint drops to a `builder` user matching the host's UID/GID, which is
# never in these root-owned files' group.
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh /usr/local/bin/process-mods.sh
WORKDIR /workspace/modding
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["/usr/local/bin/process-mods.sh"]
# Marks a shell as already running inside this image, same convention as
# hacked-build-image/Dockerfile - lets ../Makefile compile directly instead
# of trying to docker-run this image again from inside itself.
ENV MDLP_IMAGE=1
+1
View File
@@ -0,0 +1 @@
1
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Drops from root (needed to read the Go toolchain and module cache under
# /usr/local/go and /opt/go) to a user matching the host's UID/GID, so
# anything written into the bind-mounted repo (modding/dist/, ...) is owned
# by the host user instead of root.
set -euo pipefail
USER_UID="${HOST_UID:-1000}"
USER_GID="${HOST_GID:-1000}"
groupadd -g "$USER_GID" builder 2>/dev/null || true
useradd -u "$USER_UID" -g "$USER_GID" -m -s /bin/bash builder 2>/dev/null || true
# /workspace (the bind-mounted repo) may actually be owned by a group the
# host user only has via *supplementary* membership rather than their
# primary GID above - e.g. a VirtualBox vboxsf shared folder, which shows
# up as root:vboxsf on the host and would otherwise be inaccessible to
# `builder` here (falls through to "other", which vboxsf's default mode
# leaves with no permissions at all). Join whatever group actually owns
# /workspace too, if it differs.
WORKSPACE_GID="$(stat -c %g /workspace 2>/dev/null || true)"
if [ -n "$WORKSPACE_GID" ] && [ "$WORKSPACE_GID" != "$USER_GID" ]; then
groupadd -g "$WORKSPACE_GID" workspace 2>/dev/null || true
usermod -aG "$WORKSPACE_GID" builder 2>/dev/null || true
fi
# The Go module cache under /opt/go was populated as root at image-build
# time (see Dockerfile) - readable by anyone, but go build also wants to
# write GOCACHE (build cache) under there by default. Point it at a
# per-run tmpfs-friendly location inside the builder's own home instead of
# fighting permissions on the image's baked-in GOPATH.
export GOCACHE=/home/builder/.cache/go-build
export GOMODCACHE=/opt/go/pkg/mod
exec gosu builder "$@"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Builds modding/src/mdlp/ (the first-party Mod Definition Language
# Processor, see ../README.md) and runs it against modding/mods/ to produce
# modding/dist/mods/, entirely offline - mdlp's own Go module dependencies
# were already fetched into the image at image-build time (see Dockerfile).
#
# Baked into the image at /usr/local/bin/process-mods.sh (see Dockerfile's
# CMD) - run via ../mdlp-run-image.sh, which bind-mounts the whole
# repository (not just modding/) at /workspace, since mdlp also needs the
# game's own data directory outside modding/.
#
# Set MODS="name1 name2" (before calling ../mdlp-run-image.sh) to build only
# those mods; unset or empty builds every subdirectory under mods/.
set -euo pipefail
MODDING_ROOT="$(pwd)"
REPO_ROOT="$(cd .. && pwd)"
DATA_DIR="${MDLP_DATA_DIR:-$REPO_ROOT/res/assets/ss_ee/data}"
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 "== Building mdlp =="
BIN_DIR="$(mktemp -d)"
trap 'rm -rf "$BIN_DIR"' EXIT
(cd "$MODDING_ROOT/src/mdlp" && go build -trimpath -o "$BIN_DIR/mdlp" .)
MDLP_ARGS=(-data "$DATA_DIR" -mods "$MODDING_ROOT/mods" -out "$OUT_DIR")
if [ -n "${MODS:-}" ]; then
MDLP_ARGS+=(-only "$MODS")
fi
echo "== Processing mods =="
"$BIN_DIR/mdlp" "${MDLP_ARGS[@]}"
echo "== Done =="
echo "Output: $OUT_DIR"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Runs the mdlp-image to build the mod(s) defined under modding/mods/ (see
# README.md) into modding/dist/mods/, entirely offline - every Go module
# dependency mdlp needs was already fetched into the image by
# ./mdlp-image.sh.
#
# Mounts the whole repository, not just modding/, because mdlp also needs the
# game's own data directory (res/assets/ss_ee/data) as read-only reference
# data (see the root README.md section 3 for how that gets populated).
#
# Usage: MODS="name1 name2" ./mdlp-run-image.sh [command...]
# With no arguments, runs the image's baked-in process-mods.sh (its default
# CMD), building every mod under mods/ (or only those named in MODS, if
# set). Pass a command (e.g. `bash`) to get a shell in the build
# environment instead, useful for debugging a failed build.
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" \
-e HOST_UID="$(id -u)" \
-e HOST_GID="$(id -g)" \
-e MODS="${MODS:-}" \
"$IMAGE" \
"$@"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Pushes the mdlp-image - already built locally with ./mdlp-image.sh, and
# ideally verified with ./mdlp-run-image.sh - to a container registry, so CI
# or other machines can reuse it without rebuilding the whole toolchain.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 \
|| fail "docker not found in PATH"
[ -f "$ROOT/../registry.env" ] \
|| fail "../registry.env not found - copy ../registry.env.example to ../registry.env and fill in your registry credentials"
# shellcheck disable=SC1091
source "$ROOT/../registry.env"
: "${REGISTRY:?registry.env must set REGISTRY}"
: "${REGISTRY_USER:?registry.env must set REGISTRY_USER}"
: "${REGISTRY_PASSWORD:?registry.env must set REGISTRY_PASSWORD}"
MDLP_REGISTRY_IMAGE="${MDLP_REGISTRY_IMAGE:-questshock/mdlp}"
VERSION="$(<"$ROOT/mdlp-image/VERSION")"
[ -n "$VERSION" ] || fail "mdlp-image/VERSION is empty"
docker image inspect "$MDLP_REGISTRY_IMAGE:$VERSION" >/dev/null 2>&1 \
|| fail "$MDLP_REGISTRY_IMAGE:$VERSION not found locally - run ./mdlp-image.sh first"
echo "== Logging in to $REGISTRY =="
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
echo "== Pushing $MDLP_REGISTRY_IMAGE:$VERSION and :latest =="
docker push "$MDLP_REGISTRY_IMAGE:$VERSION"
docker push "$MDLP_REGISTRY_IMAGE:latest"
echo "== Done =="
echo "Image: $MDLP_REGISTRY_IMAGE:$VERSION"
@@ -0,0 +1,6 @@
{
"type": "Logs",
"index": 21,
"sender": "Sender: #2-4601",
"verboseTextFile": "log-021.txt"
}
@@ -0,0 +1 @@
Looks like Diego's happy with my work... they're firing up the sleep machine for me now. Gotta admit, when the goons from Tri-Op caught me I thought for sure they'd take me off line. Instead, Diego just asks for a "favor." Hack him into SHODAN, and all is forgiven. Plus, six months in a healing coma earns me a cyberjack interface even TriOp's execs couldn't swing. I'll be king of the Net. Even so, I have just handed the most powerful AI in the system to a fumbling corp VP, and there's no telling what'll happen. They tell me the coma leaves you foggy, so I'll leave myself some reminders. First off, the combo to the healing suite is 451. Second, I've stashed some useful stuff in the maintenance hatch under the grating "north" of the healing suite. Last and best, I finished the system analyzer which'll let me keep an eye on Shodan's processes. It's in the storage closet outside the sleep machine. In ten minutes it's off to bed for half a year. Good night. Questshock Mod Version: 0.0.1
@@ -0,0 +1,7 @@
{
"type": "Mails",
"index": 26,
"sender": "Rebecca Lansing1",
"title": "Rebecca-001",
"verboseTextFile": "mail-026.txt"
}
@@ -0,0 +1 @@
Listen to me very carefully HACKER. Something has gone terribly wrong on Citadel station. You might be the last survivor. Communications are being jammed. We know that something on board is attempting to harm humanity. The mining laser is being charged for a possible strike against Earth populations. On your level is the office of a Dr. D'Arcy, who may know something about the laser. The office is located near the central hub, on a balcony, with the medical icon clearly visible. When the laser is destroyed, you will have to reach the bridge and stop whatever is responsible for the catastrophe. We believe that the biological labs and security systems have gone haywire, so be careful. If you pull this off, we will be able to get you out alive. Good luck.
+12
View File
@@ -0,0 +1,12 @@
module questshock/mdlp
go 1.25.0
// hacked/ is vendored locally (see ../../README.md); mdlp reuses its resource
// read/write code (ss1/edit, ss1/world, ss1/content/text) instead of
// duplicating System Shock's file formats.
require github.com/inkyblackness/hacked v0.0.0
require github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
replace github.com/inkyblackness/hacked => ../hacked
+10
View File
@@ -0,0 +1,10 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+301
View File
@@ -0,0 +1,301 @@
// 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.
package main
import (
"archive/zip"
"encoding/json"
"flag"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"sort"
"strings"
"github.com/inkyblackness/hacked/ss1/content/text"
"github.com/inkyblackness/hacked/ss1/edit"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/world"
"github.com/inkyblackness/hacked/ss1/world/ids"
)
var messageTypeIDs = map[string]resource.ID{
"Mails": ids.MailsStart,
"Logs": ids.LogsStart,
"Fragments": ids.FragmentsStart,
}
var languagesByName = map[string]resource.Language{
"Default": resource.LangDefault,
"French": resource.LangFrench,
"German": resource.LangGerman,
}
// messageDef is one message entry's MDL definition. A field is left
// unchanged from the game's own data unless present here - pointers (and the
// two *File variants for the body text) distinguish "not set" from a
// legitimate zero value.
type messageDef struct {
Type string `json:"type"`
Index int `json:"index"`
Language string `json:"language"`
Title *string `json:"title"`
Sender *string `json:"sender"`
Subject *string `json:"subject"`
VerboseText *string `json:"verboseText"`
VerboseTextFile *string `json:"verboseTextFile"`
TerseText *string `json:"terseText"`
TerseTextFile *string `json:"terseTextFile"`
NextMessage *int `json:"nextMessage"`
IsInterrupt *bool `json:"isInterrupt"`
ColorIndex *int `json:"colorIndex"`
LeftDisplay *int `json:"leftDisplay"`
RightDisplay *int `json:"rightDisplay"`
}
// immediateCommander applies a command straight to the mod instead of
// keeping an undo history - mdlp is one-shot, it never needs to undo.
type immediateCommander struct{ mod *world.Mod }
func (c immediateCommander) Queue(command cmd.Command) {
c.mod.Modify(func(modder world.Modder) {
_ = command.Do(modder)
})
}
func main() {
dataDir := flag.String("data", "", "directory with the game's own .res/.dat files (Static World Data)")
modsDir := flag.String("mods", "mods", "directory containing one subdirectory per mod definition")
outDir := flag.String("out", "dist/mods", "directory to write built mods into")
only := flag.String("only", "", "space-separated list of mod names to build (default: all subdirectories of -mods)")
flag.Parse()
if *dataDir == "" {
log.Fatal("-data is required")
}
// hacked's own FileLocation.AbsolutePathFrom (used by ProjectService.
// SaveModUnder) assumes an absolute base path - given a relative one, its
// filepath.Rel resolution silently cancels out and writes resources into
// the current directory instead of under -out. Resolve every path
// up front rather than relying on that undocumented assumption.
mustAbs := func(name, path string) string {
abs, err := filepath.Abs(path)
if err != nil {
log.Fatalf("resolving %s %q: %v", name, path, err)
}
return abs
}
*dataDir = mustAbs("-data", *dataDir)
*modsDir = mustAbs("-mods", *modsDir)
*outDir = mustAbs("-out", *outDir)
names, err := modNames(*modsDir, *only)
if err != nil {
log.Fatalf("listing mods: %v", err)
}
if len(names) == 0 {
log.Fatalf("no mods found under %s", *modsDir)
}
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 {
log.Fatalf("%s: %v", name, err)
}
}
}
func modNames(modsDir, only string) ([]string, error) {
if strings.TrimSpace(only) != "" {
return strings.Fields(only), nil
}
entries, err := os.ReadDir(modsDir)
if err != nil {
return nil, err
}
var names []string
for _, entry := range entries {
if entry.IsDir() {
names = append(names, entry.Name())
}
}
sort.Strings(names)
return names, nil
}
func buildMod(dataDir, modDir, outDir, name 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)
}
if err := mod.World().InsertEntry(0, entry); err != nil {
return fmt.Errorf("registering game data: %w", err)
}
cp := text.DefaultCodepage()
messageCache := text.NewElectronicMessageCache(cp, mod)
defs, err := filepath.Glob(filepath.Join(modDir, "messages", "*.json"))
if err != nil {
return err
}
for _, defFile := range defs {
if err := applyMessageDef(mod, messageCache, cp, defFile); err != nil {
return fmt.Errorf("%s: %w", defFile, err)
}
}
modOutDir := filepath.Join(outDir, "mod")
if err := os.RemoveAll(modOutDir); err != nil {
return err
}
if err := os.MkdirAll(modOutDir, 0o755); err != nil {
return err
}
registry := &cmd.TransactionBuilder{Commander: immediateCommander{mod: mod}}
project := edit.NewProjectService(registry, mod)
if err := project.SaveModUnder(modOutDir); err != nil {
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
}
func applyMessageDef(mod *world.Mod, cache *text.ElectronicMessageCache, cp text.Codepage, defFile string) error {
raw, err := os.ReadFile(defFile)
if err != nil {
return err
}
var def messageDef
if err := json.Unmarshal(raw, &def); err != nil {
return err
}
startID, ok := messageTypeIDs[def.Type]
if !ok {
return fmt.Errorf("unknown message type %q", def.Type)
}
lang := resource.LangDefault
if def.Language != "" {
l, ok := languagesByName[def.Language]
if !ok {
return fmt.Errorf("unknown language %q", def.Language)
}
lang = l
}
key := resource.KeyOf(startID, lang, def.Index)
msg, err := cache.Message(key)
if err != nil {
msg = text.EmptyElectronicMessage()
}
defDir := filepath.Dir(defFile)
readSidecar := func(name string) (string, error) {
body, err := os.ReadFile(filepath.Join(defDir, name))
if err != nil {
return "", fmt.Errorf("reading %s: %w", name, err)
}
return string(body), nil
}
if def.Title != nil {
msg.Title = *def.Title
}
if def.Sender != nil {
msg.Sender = *def.Sender
}
if def.Subject != nil {
msg.Subject = *def.Subject
}
switch {
case def.VerboseTextFile != nil:
if msg.VerboseText, err = readSidecar(*def.VerboseTextFile); err != nil {
return err
}
case def.VerboseText != nil:
msg.VerboseText = *def.VerboseText
}
switch {
case def.TerseTextFile != nil:
if msg.TerseText, err = readSidecar(*def.TerseTextFile); err != nil {
return err
}
case def.TerseText != nil:
msg.TerseText = *def.TerseText
}
if def.NextMessage != nil {
msg.NextMessage = *def.NextMessage
}
if def.IsInterrupt != nil {
msg.IsInterrupt = *def.IsInterrupt
}
if def.ColorIndex != nil {
msg.ColorIndex = *def.ColorIndex
}
if def.LeftDisplay != nil {
msg.LeftDisplay = *def.LeftDisplay
}
if def.RightDisplay != nil {
msg.RightDisplay = *def.RightDisplay
}
data := msg.Encode(cp)
id := startID.Plus(def.Index)
mod.Modify(func(modder world.Modder) {
modder.SetResourceBlocks(lang, id, data)
})
return nil
}
func zipDir(srcDir, zipPath string) error {
f, err := os.Create(zipPath)
if err != nil {
return err
}
defer f.Close()
zw := zip.NewWriter(f)
defer zw.Close()
return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
w, err := zw.Create(rel)
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
_, err = w.Write(data)
return err
})
}
+4
View File
@@ -12,3 +12,7 @@ REGISTRY_PASSWORD=
# same file. REGISTRY/REGISTRY_USER/REGISTRY_PASSWORD above are shared with
# it for pushing.
HACKED_REGISTRY_IMAGE=cr.ladkau.de/questshock/hacked-builder
# Optional: modding/mdlp-image.sh's own image name, read from this same file.
# REGISTRY/REGISTRY_USER/REGISTRY_PASSWORD above are shared with it too.
MDLP_REGISTRY_IMAGE=cr.ladkau.de/questshock/mdlp
+20
View File
@@ -0,0 +1,20 @@
@echo off
setlocal enabledelayedexpansion
REM Launches System Shock with every mod built under modding\dist\mods\
REM applied (see modding/README.md section 4). Each mod's own
REM modding\dist\mods\<name>\mod\ folder is passed to systemshock.exe as a
REM separate, absolute argument - see run-mods.sh's comment for why (same
REM engine, same one-level directory scan and same relative-path pitfall).
cd /d "%~dp0"
set "MODS_DIR=%~dp0..\modding\dist\mods"
set "MOD_ARGS="
if exist "%MODS_DIR%" (
for /d %%M in ("%MODS_DIR%\*") do (
if exist "%%M\mod" set "MOD_ARGS=!MOD_ARGS! "%%~fM\mod""
)
)
systemshock.exe !MOD_ARGS! %*
exit /b %errorlevel%
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Launches System Shock with every mod built under modding/dist/mods/
# applied (see modding/README.md section 4). Each mod's own
# modding/dist/mods/<name>/mod/ folder is passed to run.sh as a separate,
# absolute argument - the engine's ProcessModDirectory()
# (engine/src/MacSrc/Modding.c) only scans one directory level deep, so it
# can never be pointed at modding/dist/mods/ itself and find the mods nested
# a level further inside, and a relative argument would resolve against the
# wrong directory once run.sh changes into its own.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
MODS_DIR="$(pwd)/../modding/dist/mods"
MOD_ARGS=()
if [ -d "$MODS_DIR" ]; then
for mod in "$MODS_DIR"/*/mod; do
[ -d "$mod" ] && MOD_ARGS+=("$(cd "$mod" && pwd)")
done
fi
exec ./run.sh "${MOD_ARGS[@]}" "$@"