Compare commits
11 Commits
v0.5.1
..
ab2b778f44
| Author | SHA1 | Date | |
|---|---|---|---|
| ab2b778f44 | |||
| 389abd7de7 | |||
| c0952898ae | |||
| e410d82680 | |||
| c91080aba7 | |||
| cdb960dc71 | |||
| 790b4ffd21 | |||
| 135e474a14 | |||
| 295f4869c1 | |||
| f0a83df667 | |||
| 9586c58a67 |
@@ -69,7 +69,25 @@ jobs:
|
||||
dist/shockolate-*-linux-*.tar.gz
|
||||
dist/shockolate-*-windows-*.zip
|
||||
|
||||
# The release key is stored base64-encoded in ANDROID_KEYSTORE_BASE64
|
||||
# (see README section 4.3). Without it the APK is a debug build.
|
||||
- name: Prepare release signing
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$KEYSTORE_BASE64" ]; then
|
||||
echo "No signing key: the APK will be a debug build"
|
||||
exit 0
|
||||
fi
|
||||
echo "$KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP:-/tmp}/questshock-release.jks"
|
||||
echo "QUESTSHOCK_KEYSTORE=${RUNNER_TEMP:-/tmp}/questshock-release.jks" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Quest APK
|
||||
env:
|
||||
QUESTSHOCK_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
QUESTSHOCK_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
QUESTSHOCK_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
# QUESTSHOCK_BUILD_IMAGE is already set (see Preflight above), so
|
||||
# this runs build-apk.sh directly instead of trying to docker-run
|
||||
# the image again - same reasoning as `make package` above.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
name: release
|
||||
|
||||
# GitHub's build. The Gitea build is .gitea/workflows/build.yml.
|
||||
#
|
||||
# Pushing a tag vX.Y.Z builds the Linux and Windows packages and the Quest APK
|
||||
# inside the build-image and attaches them to a GitHub release. A manual run
|
||||
# builds the same files and keeps them as a workflow artifact without
|
||||
# releasing them.
|
||||
#
|
||||
# The build-image (build-image/Dockerfile) is published to GHCR, tagged with a
|
||||
# hash of the files that define it, and only built when that tag does not
|
||||
# exist yet.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
name: ${{ steps.name.outputs.name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Name the image after the files that define it
|
||||
id: name
|
||||
run: |
|
||||
set -euo pipefail
|
||||
hash="$(cat build-image/Dockerfile build-image/docker-entrypoint.sh build-image/build-engine.sh | sha256sum | cut -c1-16)"
|
||||
echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/questshock-builder:${hash}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push the image if it does not exist yet
|
||||
env:
|
||||
IMAGE: ${{ steps.name.outputs.name }}
|
||||
REGISTRY_USER: ${{ github.actor }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "$REGISTRY_PASSWORD" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin
|
||||
if docker manifest inspect "$IMAGE" >/dev/null 2>&1; then
|
||||
echo "$IMAGE already exists"
|
||||
exit 0
|
||||
fi
|
||||
# The image is large and the runner's disk is small.
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
|
||||
# The label links the package to this repository, so the build job
|
||||
# can pull it with the workflow's token.
|
||||
docker build -f build-image/Dockerfile \
|
||||
--label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \
|
||||
-t "$IMAGE" .
|
||||
docker push "$IMAGE"
|
||||
|
||||
build:
|
||||
needs: image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
# The job runs inside the build-image, so `make` compiles directly instead
|
||||
# of starting the image itself (QUESTSHOCK_BUILD_IMAGE is set in it).
|
||||
container:
|
||||
image: ${{ needs.image.outputs.name }}
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The version comes from `git describe --tags`.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build the Linux and Windows packages
|
||||
run: make package
|
||||
|
||||
# The release key is stored base64-encoded in ANDROID_KEYSTORE_BASE64; see
|
||||
# README section 4.3. A tag build must be signed with it.
|
||||
- name: Prepare release signing
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$KEYSTORE_BASE64" ]; then
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
echo "::error::ANDROID_KEYSTORE_BASE64 is not set: a release APK must be signed"
|
||||
exit 1
|
||||
fi
|
||||
echo "No signing key: the APK will be a debug build"
|
||||
exit 0
|
||||
fi
|
||||
echo "$KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP:-/tmp}/questshock-release.jks"
|
||||
echo "QUESTSHOCK_KEYSTORE=${RUNNER_TEMP:-/tmp}/questshock-release.jks" >> "$GITHUB_ENV"
|
||||
|
||||
# Needs network access for Gradle's own dependency resolution.
|
||||
- name: Build the Quest APK
|
||||
env:
|
||||
QUESTSHOCK_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
QUESTSHOCK_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
QUESTSHOCK_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: make apk
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: questshock
|
||||
path: |
|
||||
dist/shockolate-*-linux-*.tar.gz
|
||||
dist/shockolate-*-windows-*.zip
|
||||
dist/questshock-*-android-*.apk
|
||||
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: questshock
|
||||
path: dist
|
||||
|
||||
- name: Create the release, or update its files if it exists
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
|
||||
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
|
||||
else
|
||||
gh release create "$GITHUB_REF_NAME" dist/* \
|
||||
--title "$GITHUB_REF_NAME" \
|
||||
--generate-notes \
|
||||
--notes "Sideload the APK onto a Meta Quest (see README section 5.1). The game data is not included: you need your own copy of System Shock: Enhanced Edition."
|
||||
fi
|
||||
@@ -38,3 +38,12 @@
|
||||
|
||||
# Local registry credentials (see registry.env.example)
|
||||
/registry.env
|
||||
|
||||
# Signing keys
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# modding/hacked build output and scratch, patched source copies (see
|
||||
# modding/README.md)
|
||||
/modding/dist/
|
||||
/modding/build/
|
||||
|
||||
@@ -16,7 +16,7 @@ a cross-platform port of the original engine, in an Android app that:
|
||||
- shows a menu and an on-screen keyboard as separate OpenXR quads, operated by
|
||||
a laser pointer from either controller;
|
||||
- maps controller inputs to game actions through a configurable binding layer;
|
||||
- persists settings in `/sdcard/questshock/questshock.config.json`.
|
||||
- persists settings in `/sdcard/QuestShock/questshock.config.json`.
|
||||
|
||||
A desktop (Linux/Windows) build of the same engine exists for development
|
||||
without a headset; it has none of the Android/OpenXR code.
|
||||
@@ -28,17 +28,20 @@ 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`
|
||||
with container-only paths and break an open Android Studio project. What you
|
||||
can do is dry-run patches (section 6) and run static consistency checks
|
||||
(section 12). Say plainly in your report that the change was not built or
|
||||
(section 13). Say plainly in your report that the change was not built or
|
||||
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,13 +71,16 @@ 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). |
|
||||
| `android/app/build.gradle` | Gradle project; the `stageEngine` task stages the patched engine. |
|
||||
| `android/force-rebuild.sh` | Stops Gradle daemons and wipes staged/build output for a fully fresh native build. |
|
||||
| `build-image/` | Docker build environment (`Dockerfile`, `VERSION`, `prepare-android-project.sh`, `build-apk.sh`, engine build scripts). |
|
||||
| `res/` | Bundled resources: `questshock.config.default.json`, `controller_profiles/` (JSON + PNG per controller), `assets/` (where the user's own game data goes, git-ignored), `run.sh`/`run.bat`. |
|
||||
| `res/` | Bundled resources: `questshock.config.default.json`, `controller_profiles/` (JSON + PNG per controller), `splash.png` (startup splash), `logo.png` (1024x1024 logo, master of the launcher icons in `android/app/src/main/res/mipmap-*/`), `logo_small.png` (the README header), `screenshots/` (headset captures and videos named `questshock_v<version>_<nnn>.<ext>`, numbered per version), `store/` (store-listing artwork, generated from `logo.png`/`splash.png`/screenshots, not used by the app itself), `assets/` (where the user's own game data goes, git-ignored), `run.sh`/`run.bat`. |
|
||||
| `.gitea/workflows/build.yml`, `.github/workflows/release.yml` | CI. Gitea is the primary repository and builds on every push; GitHub is a mirror whose workflow builds and publishes a release when a `vX.Y.Z` tag is pushed. Each platform has its own file. |
|
||||
| `modding/` | Separate subproject: System Shock modding tools (InkyBlackness `hacked`, `ss-specs`), unrelated to the VR app. Own `README.md`, `Makefile` and Docker build-image; see that README rather than this file. |
|
||||
| `Makefile` | `engine`, `dist`, `package`, `apk`, `android-studio`, `clean`, ... (see README section 2). |
|
||||
| `README.md`, `CONFIG.md`, `NOTICE.txt` | User docs, config reference, license notice. |
|
||||
|
||||
@@ -94,13 +100,14 @@ not print it or commit it.
|
||||
| `xr_input.c/.h` | Controller input: OpenXR actions, raw-input layer, action bindings, capture-mode rebinding, key injection into the engine, free view, cyberspace scaling, snap-turn/smooth-move of the player's position, pointer rays. The largest file. |
|
||||
| `xr_mouse.c/.h` | Injects mouse motion and clicks into the engine. |
|
||||
| `qs_config.c/.h` | Configuration: JSON parse, apply, write; screen profiles; menu-driven requests; cheats; menu-triggered pause. |
|
||||
| `qs_splash.c/.h` | Startup splash: `qs_splash_show()` fades the image (`res/splash.png`, decoded by Java and passed in through `nativeSetSplashImage`) in and out on the game screen. Called from engine patch 27. |
|
||||
| `questshock_native.c` | JNI exports and small native helpers (`nativeChdir`, `nativeSendPrintableChar`, ...). |
|
||||
|
||||
### 4.2 Java classes
|
||||
|
||||
| Class | Role |
|
||||
|---|---|
|
||||
| `QuestShockActivity` | `SDLActivity` subclass. Creates `/sdcard/questshock/`, extracts bundled files, blocks engine start until the game data exists. |
|
||||
| `QuestShockActivity` | `SDLActivity` subclass. Creates `/sdcard/QuestShock/`, extracts bundled files, blocks engine start until the game data exists. |
|
||||
| `OverlayPanel` | Base for the off-screen panels: builds the view tree, renders it to a texture, turns ray hits into touch events. |
|
||||
| `MenuOverlay` | The menu: Main Menu, Video Options, Controller Options and Cheats tabs. Singleton. |
|
||||
| `KeyboardOverlay` | The on-screen keyboard. |
|
||||
@@ -116,6 +123,10 @@ not print it or commit it.
|
||||
first-party code log under the tag `QuestShock`.
|
||||
- Bindings, config and menu state are owned natively. Java only displays and
|
||||
requests.
|
||||
- `gScreenWide`/`gScreenHigh` are only set by the engine's
|
||||
`ChangeScreenSize()`, so they are 0 until its first mode change. Code that
|
||||
runs during startup (such as `qs_splash_show()`) takes the screen size from
|
||||
`xr_get_game_swapchain_size()` instead.
|
||||
|
||||
### 4.4 Threads and cross-thread requests
|
||||
|
||||
@@ -135,7 +146,7 @@ not print it or commit it.
|
||||
`qs_config_frame_tick()`) is called from `xr_input.c`.
|
||||
- JNI symbol names must match the Java `native` declarations exactly
|
||||
(`Java_de_ladkau_questshock_<Class>_<method>`). A mismatch only fails at
|
||||
runtime, so check it (section 12).
|
||||
runtime, so check it (section 13).
|
||||
|
||||
### 4.5 Off-screen views (`OverlayPanel`, `MenuOverlay`, `KeyboardOverlay`)
|
||||
|
||||
@@ -178,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-<topic>.patch`, and there is no Gradle
|
||||
`stageEngine` to worry about going stale - `build-image/build-engine.sh`/
|
||||
`build-engine-win.sh` re-stage and re-patch on every build.
|
||||
|
||||
Rules:
|
||||
|
||||
- Guard every engine change with `#ifdef __ANDROID__` so the desktop build is
|
||||
@@ -314,20 +333,36 @@ toggles.
|
||||
|
||||
## 10. Bundled files on the device
|
||||
|
||||
`QuestShockActivity` extracts these into `/sdcard/questshock/`:
|
||||
`QuestShockActivity` extracts these into `/sdcard/QuestShock/`:
|
||||
|
||||
- shaders, the default soundfont and `GET_ASSETS_QUEST.txt` (once);
|
||||
- `questshock.config.default.json` and `controller_profiles/` (every launch,
|
||||
overwriting only the bundled filenames so user-added profiles survive).
|
||||
|
||||
`res/controller_profiles/` and the default config are staged into APK assets by
|
||||
`build-image/prepare-android-project.sh`; new staged directories must also be
|
||||
declared as `inputs.dir(...)` of `stageEngine` in `android/app/build.gradle`.
|
||||
`res/splash.png` stays in the APK assets and is not extracted to the device;
|
||||
`QuestShockActivity` decodes it and hands the pixels to `qs_splash.c`.
|
||||
|
||||
`res/controller_profiles/`, `res/splash.png` and the default config are staged
|
||||
into APK assets by `build-image/prepare-android-project.sh`; every staged file
|
||||
or directory must also be declared as `inputs.file(...)`/`inputs.dir(...)` of
|
||||
`stageEngine` in `android/app/build.gradle`.
|
||||
|
||||
The proprietary game data (`res/data/`, `res/sound/`) is never bundled or
|
||||
committed. The app refuses to start the engine until it is present.
|
||||
|
||||
## 11. Code and documentation style
|
||||
## 11. Signing
|
||||
|
||||
Distributed APKs are release builds signed with one fixed key, so users can
|
||||
update in place. `build-image/build-apk.sh` reads the key from
|
||||
`QUESTSHOCK_KEYSTORE`, `QUESTSHOCK_KEYSTORE_PASSWORD`, `QUESTSHOCK_KEY_ALIAS`
|
||||
and `QUESTSHOCK_KEY_PASSWORD`; `android/app/build.gradle` applies it to the
|
||||
`release` build type only when `QUESTSHOCK_KEYSTORE` is set. Both CI workflows
|
||||
decode it from the `ANDROID_KEYSTORE_BASE64` secret. Without a key the build is
|
||||
a debug APK (`...-debug.apk`), which is only for testing. Never commit a
|
||||
keystore or a password, print them, or put them in a workflow file (README
|
||||
section 4.3 lists the secrets).
|
||||
|
||||
## 12. Code and documentation style
|
||||
|
||||
- Match the surrounding code: naming, comment density, idiom. C is plain C with
|
||||
`static` helpers and `//` comments; Java targets Android API 34 and uses only the SDK plus
|
||||
@@ -343,7 +378,7 @@ committed. The app refuses to start the engine until it is present.
|
||||
- English, French and German strings live in `Strings.java`; keep the three
|
||||
entries of each key on the same `put(...)`.
|
||||
|
||||
## 12. Verifying a change without building
|
||||
## 13. Verifying a change without building
|
||||
|
||||
Because you may not build (rule 2), check statically and say so in the report:
|
||||
|
||||
@@ -365,7 +400,7 @@ Then report what was checked, what was not (build, on-device behaviour), and
|
||||
any device check the maintainer should do (for example a new patch that may
|
||||
need `android/force-rebuild.sh`).
|
||||
|
||||
## 13. Commit messages
|
||||
## 14. Commit messages
|
||||
|
||||
Draft only (rule 1). Use a short imperative subject (about 70 characters), a
|
||||
blank line, then a body that explains what the change does and why: the
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
QuestShock can load a persistent, human-editable configuration file at
|
||||
startup, in addition to (and independent of) the engine's own `prefs.txt`.
|
||||
|
||||
- **Location**: `/sdcard/questshock/questshock.config.json` - the same
|
||||
- **Location**: `/sdcard/QuestShock/questshock.config.json` - the same
|
||||
folder you already copy `res/data`/`res/sound` into (see README.md
|
||||
section 5.1).
|
||||
- **Format**: plain JSON, grouped by concern. Each top-level key is a
|
||||
@@ -421,7 +421,7 @@ profile is active.
|
||||
|
||||
The Controller Options tab shows a labeled illustration of whichever
|
||||
controller is actually active, driven by `res/controller_profiles/*.json`
|
||||
(bundled with the app, copied to `/sdcard/questshock/controller_profiles/`
|
||||
(bundled with the app, copied to `/sdcard/QuestShock/controller_profiles/`
|
||||
on every launch, refreshing the known bundled files without touching any
|
||||
extra ones you add there yourself). Each 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`/
|
||||
|
||||
@@ -8,6 +8,9 @@ and is not relicensed by this project's MIT license.
|
||||
|
||||
Game assets are not included - see res/GET_ASSETS.txt.
|
||||
|
||||
System Shock is a trademark of its respective owners. This is an unofficial
|
||||
fan project and is not affiliated with, endorsed by or supported by them.
|
||||
|
||||
The Android build additionally bundles:
|
||||
|
||||
- GL4ES (https://github.com/ptitSeb/gl4es), vendored and patched (see
|
||||
@@ -32,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).
|
||||
|
||||
@@ -17,6 +17,45 @@ button, stick direction and game hotkey can be assigned to a game action.
|
||||
Questshock runs entirely on the headset (tested on a Meta Quest 2) as a
|
||||
sideloaded Android app, and needs your own copy of the game data.
|
||||
|
||||
> **Unofficial fan project.** Questshock is not affiliated with, endorsed by or
|
||||
> supported by the rights holders of System Shock. It contains no game data: you
|
||||
> need your own copy of System Shock: Enhanced Edition.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_001.jpg" alt="Exploring the station on the virtual screen"><br><sub>Exploring the station on the virtual screen</sub></td>
|
||||
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_003.jpg" alt="Reading an email - the laser pointer clicks like a mouse"><br><sub>Reading an email - the laser pointer clicks like a mouse</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_005.jpg" alt="Cyberspace"><br><sub>Cyberspace</sub></td>
|
||||
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_006.jpg" alt="The game screen, the menu and the keyboard float independently"><br><sub>The game screen, the menu and the keyboard float independently</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_007.jpg" alt="The VR menu"><br><sub>The VR menu</sub></td>
|
||||
<td width="50%"><img src="res/screenshots/questshock_v0.5.3_008.jpg" alt="Assigning controller inputs to game actions"><br><sub>Assigning controller inputs to game actions</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
[A short video](res/screenshots/questshock_v0.5.3_010.mp4) shows the game in use.
|
||||
|
||||
## Quick start
|
||||
|
||||
You need a Meta Quest (tested on a Quest 2) and your own copy of the game data.
|
||||
|
||||
1. Get **System Shock: Enhanced Edition** (for example from
|
||||
[gog.com](https://www.gog.com/)); see "3. Game assets" below.
|
||||
2. Download `questshock-<version>-android-arm64.apk` from the repository's
|
||||
Releases page and sideload it onto the headset with
|
||||
[SideQuest](https://sidequestvr.com/) or `adb install`.
|
||||
3. Launch it once. It creates `/sdcard/QuestShock/`. Copy your game's `res/data/`
|
||||
and `res/sound/` folders into `/sdcard/QuestShock/res/`.
|
||||
4. Launch it again. The left controller's menu button opens the menu; the
|
||||
default controls are in "5.1.1. Menu, controls and cheats" below.
|
||||
|
||||
Section "5.1. Installing and playing" has the details.
|
||||
|
||||
## 1. Design principles
|
||||
|
||||
- **Standalone-headset-only.** The game must run entirely on the headset
|
||||
@@ -30,7 +69,11 @@ sideloaded Android app, and needs your own copy of the game data.
|
||||
## 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
|
||||
@@ -41,11 +84,23 @@ sideloaded Android app, and needs your own copy of the game data.
|
||||
- `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
|
||||
[CONFIG.md](CONFIG.md)). Both are copied into the APK.
|
||||
[CONFIG.md](CONFIG.md)). Both are copied into the APK, as is
|
||||
`res/splash.png`, the startup splash image. `res/logo.png` is the 1024x1024
|
||||
logo, the master of the launcher icon
|
||||
(`android/app/src/main/res/mipmap-*/ic_launcher.png` are scaled copies of it)
|
||||
and the image to use for store listings; `res/logo_small.png` is the copy
|
||||
shown at the top of this file.
|
||||
- `Makefile` - targets:
|
||||
- `all` (default) - alias for `dist`.
|
||||
- `build-image` - builds the Docker build-image (`./build-image.sh`).
|
||||
@@ -64,9 +119,11 @@ sideloaded Android app, and needs your own copy of the game data.
|
||||
proprietary game assets (ships `res/GET_ASSETS.txt` in their place).
|
||||
See "4.1. Packaging a distributable build" below.
|
||||
- `apk` - builds `dist/questshock-<version>-android-arm64.apk` for
|
||||
Android-based OpenXR headsets. Unlike every other target, this needs
|
||||
network access at build time (Gradle/AGP's own dependency
|
||||
resolution). See "5. Android / Quest build" below.
|
||||
Android-based OpenXR headsets (a debug build, `...-arm64-debug.apk`,
|
||||
when there is no signing key - see "4.3. Releases and signing" below).
|
||||
Unlike every other target, this needs network access at build time
|
||||
(Gradle/AGP's own dependency resolution). See "5. Android / Quest
|
||||
build" below.
|
||||
- `android-studio` - stages `engine/`/`android/gl4es-src/` (scratch,
|
||||
patched copies) and the Android prebuilts with host-resolvable paths,
|
||||
so Android Studio can compile and deploy `android/` natively instead
|
||||
@@ -191,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)
|
||||
@@ -210,6 +268,118 @@ package on every push, using the build-image as its container (so no
|
||||
extra setup is needed in CI beyond the image itself), and publishes the
|
||||
resulting tarball to dl.ladkau.de.
|
||||
|
||||
### 4.3. Releases and signing
|
||||
|
||||
The repository is developed on Gitea, and the GitHub repository is a mirror of
|
||||
it. `.github/workflows/release.yml` is GitHub's own workflow (the Gitea build
|
||||
is `.gitea/workflows/build.yml`):
|
||||
|
||||
- Pushing a tag `vX.Y.Z` builds the Linux and Windows packages and the Quest
|
||||
APK inside the build-image and attaches them to a GitHub release with the
|
||||
same name. A manual run ("Run workflow") builds the same files without
|
||||
releasing them and keeps them as a downloadable workflow artifact.
|
||||
- The build-image is pushed to GitHub's container registry (`ghcr.io`) when it
|
||||
does not exist there yet. Its tag is a hash of `build-image/Dockerfile`,
|
||||
`docker-entrypoint.sh` and `build-engine.sh`, so a change to any of them
|
||||
builds a new image (`build-image/VERSION` is not used). The first run is slow
|
||||
because it builds the image.
|
||||
- The APK is signed with the release key described below.
|
||||
|
||||
**Signing.** Android only updates an installed app from an APK signed with the
|
||||
same key, so every distributed APK is signed with one fixed key. `make apk`
|
||||
(`build-image/build-apk.sh`) builds and signs the release APK when
|
||||
`QUESTSHOCK_KEYSTORE` is set, together with `QUESTSHOCK_KEYSTORE_PASSWORD` and
|
||||
optionally `QUESTSHOCK_KEY_ALIAS` (default `questshock`) and
|
||||
`QUESTSHOCK_KEY_PASSWORD` (default: the keystore password). Without a keystore
|
||||
it builds a debug APK, `questshock-<version>-android-arm64-debug.apk`, signed
|
||||
with a per-machine debug key. That is what Android Studio's Run uses too; an
|
||||
installed build has to be uninstalled before one signed with another key can be
|
||||
installed.
|
||||
|
||||
Create the key once and keep the file and its password safe: without them,
|
||||
users have to uninstall the app to get an update.
|
||||
|
||||
```sh
|
||||
keytool -genkeypair -v -storetype PKCS12 -keystore questshock-release.jks \
|
||||
-alias questshock -keyalg RSA -keysize 4096 -validity 10000
|
||||
base64 -w0 questshock-release.jks # the value of ANDROID_KEYSTORE_BASE64
|
||||
```
|
||||
|
||||
Both CI systems read the key from these secrets: `ANDROID_KEYSTORE_BASE64`,
|
||||
`ANDROID_KEYSTORE_PASSWORD`, and optionally `ANDROID_KEY_ALIAS` and
|
||||
`ANDROID_KEY_PASSWORD`. Without `ANDROID_KEYSTORE_BASE64`, the Gitea build
|
||||
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:**
|
||||
@@ -224,7 +394,8 @@ resulting tarball to dl.ladkau.de.
|
||||
apk` needs its own separate toolchain - see "5.2. Building natively in
|
||||
Android Studio" below.
|
||||
|
||||
`make apk` builds `dist/questshock-<version>-android-arm64.apk` - an
|
||||
`make apk` builds `dist/questshock-<version>-android-arm64.apk` (or
|
||||
`...-arm64-debug.apk`, see "4.3. Releases and signing" above) - an
|
||||
immersive OpenXR app (see `android/app/src/main/cpp/xr_session.c`) that
|
||||
can be sideloaded onto any Android-based VR headset with OpenXR support
|
||||
(Meta Quest, Pico, etc. - see "1. Design principles" above), not just one
|
||||
@@ -293,17 +464,17 @@ debugging enabled.)
|
||||
1. Install the APK with [SideQuest](https://sidequestvr.com/) (or `adb
|
||||
install`).
|
||||
2. Launch it once. It'll ask for storage permission, then create
|
||||
`/sdcard/questshock/` and extract its own bundled files (shaders, a
|
||||
`/sdcard/QuestShock/` and extract its own bundled files (shaders, a
|
||||
default MIDI soundfont) there - `res/data/` and `res/sound/` are
|
||||
deliberately left missing, since that's the proprietary game data.
|
||||
3. With the headset connected to a PC, use SideQuest's file browser (or
|
||||
any MTP file manager) to copy your own `res/data/` and `res/sound/`
|
||||
(see `/sdcard/questshock/GET_ASSETS_QUEST.txt`, extracted in step 2,
|
||||
(see `/sdcard/QuestShock/GET_ASSETS_QUEST.txt`, extracted in step 2,
|
||||
for exactly what's needed and where it comes from) into
|
||||
`/sdcard/questshock/res/`.
|
||||
`/sdcard/QuestShock/res/`.
|
||||
4. Launch it again.
|
||||
|
||||
`/sdcard/questshock/` also holds an optional persistent settings file,
|
||||
`/sdcard/QuestShock/` also holds an optional persistent settings file,
|
||||
`questshock.config.json`, loaded automatically at startup and saved/loaded
|
||||
from the in-headset menu - see [CONFIG.md](CONFIG.md) for the documented
|
||||
options. Every launch also refreshes `questshock.config.default.json` (the
|
||||
@@ -314,6 +485,9 @@ alone.
|
||||
|
||||
#### 5.1.1. Menu, controls and cheats
|
||||
|
||||
At startup the QuestShock title (`res/splash.png`) fades in and out on the
|
||||
virtual screen for a few seconds, before the game's own intro screens.
|
||||
|
||||
The controller's menu button (left controller) opens and closes the menu. It
|
||||
can't be reassigned. Opening it pauses a running game; closing it resumes the
|
||||
game, unless it was already paused. Aim with either hand's ray and pull the
|
||||
@@ -411,7 +585,7 @@ the same as any other Android Studio project:
|
||||
Android Studio builds, installs, and launches the app on the headset
|
||||
directly. No manual `adb install` or SideQuest step needed for this
|
||||
loop.
|
||||
4. `/sdcard/questshock/` (game assets, extracted shaders/soundfont)
|
||||
4. `/sdcard/QuestShock/` (game assets, extracted shaders/soundfont)
|
||||
persists across reinstalls from Android Studio, so this loop doesn't
|
||||
require re-copying `res/data/`/`res/sound/` on every iteration - only
|
||||
a full uninstall or wiping that directory clears it.
|
||||
@@ -611,6 +785,9 @@ each one does:
|
||||
- **`26-android-cheat-refill.patch`** - adds `qs_cheat_refill()` to
|
||||
`hkeyfunc.c`, which tops up health, energy, ammo and items (grenades and
|
||||
drugs the player already holds) for the Cheats tab's unlimited switches.
|
||||
- **`27-android-startup-splash.patch`** - `setup.c`'s `splash_draw()` calls
|
||||
`qs_splash_show()` first, which fades the QuestShock title in and out on the
|
||||
game screen before the engine's own splash screens.
|
||||
|
||||
`android/gl4es-patches/`:
|
||||
|
||||
@@ -958,6 +1135,10 @@ under the **GNU GPLv3** (see `engine/LICENSE`) - it is included unchanged
|
||||
and is *not* relicensed by this project's MIT license. Any build or
|
||||
distribution of the compiled engine must comply with the GPLv3.
|
||||
|
||||
System Shock is a trademark of its respective owners. Questshock is an
|
||||
unofficial fan project and is not affiliated with, endorsed by or supported by
|
||||
them.
|
||||
|
||||
The game assets extracted into `res/assets/ss_ee/` are proprietary,
|
||||
copyrighted game data owned by their respective rightsholders - they are
|
||||
never committed to this repository (see `.gitignore`) and must be
|
||||
|
||||
@@ -107,6 +107,7 @@ def stageEngineTask = tasks.register("stageEngine", Exec) {
|
||||
// Staged into assets/ by prepare-android-project.sh.
|
||||
inputs.file("${repoRoot}/res/questshock.config.default.json")
|
||||
inputs.dir("${repoRoot}/res/controller_profiles")
|
||||
inputs.file("${repoRoot}/res/splash.png")
|
||||
outputs.dir("${repoRoot}/build/android-engine")
|
||||
outputs.dir("${repoRoot}/build/android-gl4es")
|
||||
outputs.dir("${repoRoot}/build/android-prebuilt")
|
||||
@@ -138,7 +139,7 @@ android {
|
||||
// 29, not compileSdk's 34, is deliberate - see build-image/Dockerfile's
|
||||
// comment on ANDROID_PLATFORM_VERSION: paired with
|
||||
// requestLegacyExternalStorage in the manifest, this keeps
|
||||
// /sdcard/questshock/ a plain, unrestricted shared folder instead of
|
||||
// /sdcard/QuestShock/ a plain, unrestricted shared folder instead of
|
||||
// Android 11+ scoped storage, on the Quest 2/3/3S/Pro headsets this
|
||||
// targets - not just whatever's the current API level today.
|
||||
minSdkVersion 24
|
||||
@@ -199,7 +200,7 @@ android {
|
||||
"-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \
|
||||
"-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH", \
|
||||
"-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384", \
|
||||
"-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c;${projectDir}/src/main/cpp/xr_session.c;${projectDir}/src/main/cpp/xr_input.c;${projectDir}/src/main/cpp/xr_overlay.c;${projectDir}/src/main/cpp/xr_swapchain.c;${projectDir}/src/main/cpp/xr_mouse.c;${projectDir}/src/main/cpp/qs_config.c"
|
||||
"-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c;${projectDir}/src/main/cpp/xr_session.c;${projectDir}/src/main/cpp/xr_input.c;${projectDir}/src/main/cpp/xr_overlay.c;${projectDir}/src/main/cpp/xr_swapchain.c;${projectDir}/src/main/cpp/xr_mouse.c;${projectDir}/src/main/cpp/qs_config.c;${projectDir}/src/main/cpp/qs_splash.c"
|
||||
abiFilters 'arm64-v8a'
|
||||
}
|
||||
}
|
||||
@@ -211,9 +212,28 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// The release key comes from the environment (build-image/build-apk.sh,
|
||||
// CI secrets). Without it there is no release signing, and the release
|
||||
// build type is left unsigned.
|
||||
def releaseKeystore = System.getenv("QUESTSHOCK_KEYSTORE")
|
||||
def releaseStorePassword = System.getenv("QUESTSHOCK_KEYSTORE_PASSWORD")
|
||||
signingConfigs {
|
||||
release {
|
||||
if (releaseKeystore) {
|
||||
storeFile file(releaseKeystore)
|
||||
storePassword releaseStorePassword
|
||||
keyAlias System.getenv("QUESTSHOCK_KEY_ALIAS") ?: "questshock"
|
||||
keyPassword System.getenv("QUESTSHOCK_KEY_PASSWORD") ?: releaseStorePassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
if (releaseKeystore) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
OpenXR session to actually start. -->
|
||||
<meta-data android:name="com.oculus.vr.focusaware" android:value="true" />
|
||||
|
||||
<!-- Meta-specific: the headsets this app is built for. Other OpenXR
|
||||
runtimes ignore it. -->
|
||||
<meta-data android:name="com.oculus.supportedDevices"
|
||||
android:value="quest2|questpro|quest3|quest3s" />
|
||||
|
||||
<activity android:name="de.ladkau.questshock.QuestShockActivity"
|
||||
android:label="@string/app_name"
|
||||
android:alwaysRetainTaskState="true"
|
||||
@@ -51,13 +56,10 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<!-- The vendor-neutral, Khronos-defined way to declare an
|
||||
immersive OpenXR entry point (vs. e.g. Meta's own
|
||||
com.oculus.intent.category.VR) - see this project's
|
||||
"favor OpenXR" rule in the README. If Horizon OS still
|
||||
launches this as a flat panel on-device, the next thing
|
||||
to try is adding a com.oculus.supportedDevices meta-data
|
||||
listing the target Quest models - not confirmed
|
||||
necessary from static analysis alone. -->
|
||||
immersive OpenXR entry point, plus Meta's own VR
|
||||
category, which Horizon OS looks for to treat the app as
|
||||
a VR app. Other runtimes ignore the Meta one. -->
|
||||
<category android:name="com.oculus.intent.category.VR" />
|
||||
<category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
|
||||
@@ -10,7 +10,7 @@ extern "C" {
|
||||
// Persistent, human-editable JSON config file - see CONFIG.md at the repo
|
||||
// root for the documented schema. Lives at a plain relative path
|
||||
// ("questshock.config.json") since the whole engine already runs with its
|
||||
// cwd chdir()'d to the user-facing /sdcard/questshock/ folder (see
|
||||
// cwd chdir()'d to the user-facing /sdcard/QuestShock/ folder (see
|
||||
// QuestShockActivity.java's nativeChdir()) before main() ever runs - the
|
||||
// same way prefs.txt/keybinds.txt (Prefs.c) already do.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#include "qs_splash.h"
|
||||
|
||||
#include <SDL.h>
|
||||
#include <android/log.h>
|
||||
#include <stdbool.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "xr_session.h"
|
||||
|
||||
#define QS_SPLASH_HOLD_MS 2500
|
||||
#define QS_SPLASH_FADE_MS 500
|
||||
#define QS_SPLASH_TOTAL_MS (QS_SPLASH_HOLD_MS + 2 * QS_SPLASH_FADE_MS)
|
||||
// The XR session only starts once frames are polled, so the display time is
|
||||
// counted from the first submitted frame; this bounds the wait for it.
|
||||
#define QS_SPLASH_SESSION_TIMEOUT_MS 5000
|
||||
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "QuestShock", __VA_ARGS__)
|
||||
|
||||
extern void pump_events(void);
|
||||
// OpenGL.cc (engine patch 11): draws a software frame into the XR game swapchain.
|
||||
extern void android_composite_software_frame(SDL_Surface *ui);
|
||||
|
||||
static SDL_Surface *s_image = NULL;
|
||||
|
||||
void qs_splash_set_image(const uint32_t *argb, int width, int height) {
|
||||
if (s_image != NULL) {
|
||||
SDL_FreeSurface(s_image);
|
||||
s_image = NULL;
|
||||
}
|
||||
if (argb == NULL || width <= 0 || height <= 0)
|
||||
return;
|
||||
s_image = SDL_CreateRGBSurfaceWithFormat(0, width, height, 32, SDL_PIXELFORMAT_ARGB8888);
|
||||
if (s_image == NULL)
|
||||
return;
|
||||
for (int y = 0; y < height; y++)
|
||||
memcpy((uint8_t *)s_image->pixels + (size_t)y * s_image->pitch, argb + (size_t)y * width,
|
||||
(size_t)width * sizeof(uint32_t));
|
||||
SDL_SetSurfaceBlendMode(s_image, SDL_BLENDMODE_BLEND);
|
||||
}
|
||||
|
||||
// 0-255 brightness at `elapsed` ms after the first frame.
|
||||
static int qs_splash_level(uint32_t elapsed) {
|
||||
uint32_t edge = elapsed < QS_SPLASH_TOTAL_MS - elapsed ? elapsed : QS_SPLASH_TOTAL_MS - elapsed;
|
||||
if (edge >= QS_SPLASH_FADE_MS)
|
||||
return 255;
|
||||
return (int)(255 * edge / QS_SPLASH_FADE_MS);
|
||||
}
|
||||
|
||||
void qs_splash_show(void) {
|
||||
if (s_image == NULL) {
|
||||
LOGI("Startup splash: no image");
|
||||
return;
|
||||
}
|
||||
int width, height;
|
||||
xr_get_game_swapchain_size(&width, &height);
|
||||
if (width <= 0 || height <= 0) {
|
||||
LOGI("Startup splash: no XR game swapchain");
|
||||
return;
|
||||
}
|
||||
|
||||
// The image is stretched over the whole swapchain image, so it is
|
||||
// letterboxed into a canvas of that size to keep its aspect ratio.
|
||||
SDL_Surface *canvas =
|
||||
SDL_CreateRGBSurfaceWithFormat(0, width, height, 32, SDL_PIXELFORMAT_RGBA32);
|
||||
if (canvas == NULL)
|
||||
return;
|
||||
const float scale = SDL_min((float)width / s_image->w, (float)height / s_image->h);
|
||||
SDL_Rect dst;
|
||||
dst.w = (int)(s_image->w * scale);
|
||||
dst.h = (int)(s_image->h * scale);
|
||||
dst.x = (width - dst.w) / 2;
|
||||
dst.y = (height - dst.h) / 2;
|
||||
|
||||
const uint32_t waitStart = SDL_GetTicks();
|
||||
uint32_t shownStart = 0;
|
||||
uint32_t elapsed = 0;
|
||||
int drawnLevel = -1;
|
||||
bool done = false;
|
||||
|
||||
while (!done) {
|
||||
pump_events();
|
||||
xr_poll_events();
|
||||
if (xr_frame_begin()) {
|
||||
if (shownStart == 0)
|
||||
shownStart = SDL_GetTicks();
|
||||
elapsed = SDL_GetTicks() - shownStart;
|
||||
if (elapsed >= QS_SPLASH_TOTAL_MS) {
|
||||
elapsed = QS_SPLASH_TOTAL_MS;
|
||||
done = true;
|
||||
}
|
||||
const int level = qs_splash_level(elapsed);
|
||||
if (level != drawnLevel) {
|
||||
drawnLevel = level;
|
||||
SDL_FillRect(canvas, NULL, SDL_MapRGB(canvas->format, 0, 0, 0));
|
||||
SDL_SetSurfaceColorMod(s_image, level, level, level);
|
||||
SDL_BlitScaled(s_image, NULL, canvas, &dst);
|
||||
}
|
||||
android_composite_software_frame(canvas);
|
||||
} else {
|
||||
SDL_Delay(10);
|
||||
if (shownStart == 0 && SDL_GetTicks() - waitStart > QS_SPLASH_SESSION_TIMEOUT_MS)
|
||||
done = true;
|
||||
}
|
||||
xr_frame_end();
|
||||
}
|
||||
|
||||
LOGI("Startup splash: %s after %u ms", shownStart != 0 ? "shown" : "XR session did not start",
|
||||
(unsigned)(SDL_GetTicks() - waitStart));
|
||||
SDL_FreeSurface(canvas);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Startup splash: the QuestShock title, shown on the game screen before the
|
||||
// engine's own splash sequence.
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// Stores a copy of the splash image (straight-alpha ARGB, one int per pixel).
|
||||
// Called from JNI before the engine starts; replaces any earlier image. Without
|
||||
// an image qs_splash_show() does nothing.
|
||||
void qs_splash_set_image(const uint32_t *argb, int width, int height);
|
||||
|
||||
// Engine thread only (called from engine patch 27). Fades the image in and out
|
||||
// on the game screen and returns when done, or after a few seconds if the XR
|
||||
// session never starts.
|
||||
void qs_splash_show(void);
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <SDL.h>
|
||||
|
||||
#include "qs_config.h"
|
||||
#include "qs_splash.h"
|
||||
#include "xr_input.h"
|
||||
#include "xr_overlay.h"
|
||||
#include "xr_session.h"
|
||||
@@ -22,6 +23,15 @@ Java_de_ladkau_questshock_QuestShockActivity_nativeChdir(JNIEnv *env, jclass cla
|
||||
(*env)->ReleaseStringUTFChars(env, path, cpath);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_de_ladkau_questshock_QuestShockActivity_nativeSetSplashImage(
|
||||
JNIEnv *env, jclass clazz, jintArray pixels, jint width, jint height) {
|
||||
jint *data = (*env)->GetIntArrayElements(env, pixels, NULL);
|
||||
if (data == NULL)
|
||||
return;
|
||||
qs_splash_set_image((const uint32_t *)data, width, height);
|
||||
(*env)->ReleaseIntArrayElements(env, pixels, data, JNI_ABORT);
|
||||
}
|
||||
|
||||
// KeyboardOverlay's hand-built on-screen keyboard (see
|
||||
// KeyboardOverlay.handleCharKeyDown()/handleCharKeyUp()) has no real
|
||||
// Android IME session behind it, so printable characters can't reach the
|
||||
|
||||
@@ -871,6 +871,11 @@ void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half
|
||||
}
|
||||
}
|
||||
|
||||
void xr_get_game_swapchain_size(int *width, int *height) {
|
||||
*width = g_game_swapchain.width;
|
||||
*height = g_game_swapchain.height;
|
||||
}
|
||||
|
||||
void xr_get_game_resolution(int *width, int *height) {
|
||||
// Not g_game_swapchain.width/height: that's the fixed physical size of
|
||||
// the swapchain image the game is composited into (see xr_init()) -
|
||||
|
||||
@@ -102,6 +102,12 @@ void xr_session_set_game_screen_shape(float width_m, float curve_deg, float dist
|
||||
// swapchain.
|
||||
void xr_get_game_resolution(int *width, int *height);
|
||||
|
||||
// Pixel size of the game quad's swapchain image, fixed at launch (0, 0 before
|
||||
// xr_init()). The quad's aspect ratio is this size, so full-quad content
|
||||
// drawn outside the engine's own frame path (the startup splash) is laid out
|
||||
// against it.
|
||||
void xr_get_game_swapchain_size(int *width, int *height);
|
||||
|
||||
// Exponentially-smoothed frame rate/frame time, updated once per real
|
||||
// rendered frame in xr_frame_end() - read by MenuOverlay's header (see
|
||||
// questshock_native.c's nativeGetFps()/nativeGetFrameTimeMs()). 0 before
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.AssetManager;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.SurfaceHolder;
|
||||
@@ -18,7 +20,7 @@ import org.libsdl.app.SDLActivity;
|
||||
import org.libsdl.app.SDLSurface;
|
||||
|
||||
/**
|
||||
* Prepares /sdcard/questshock/ the way Shockolate's plain relative-path
|
||||
* Prepares /sdcard/QuestShock/ the way Shockolate's plain relative-path
|
||||
* file I/O ("res/data/...", confirmed via grep - none of it goes through
|
||||
* SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile
|
||||
* doesn't apply here) expects to find it - see android/engine-patches/ for
|
||||
@@ -40,7 +42,7 @@ public class QuestShockActivity extends SDLActivity {
|
||||
private static final String TAG = "QuestShock";
|
||||
private static final int PERMISSION_REQUEST_STORAGE = 1;
|
||||
// Package-visible: MenuOverlay reads controller_profiles/ from here.
|
||||
static final String GAME_DIR = "/sdcard/questshock";
|
||||
static final String GAME_DIR = "/sdcard/QuestShock";
|
||||
|
||||
// Loaded here (rather than waiting for SDLActivity's own
|
||||
// getLibraries()/loadLibraries(), which only runs from super.onCreate())
|
||||
@@ -58,6 +60,9 @@ public class QuestShockActivity extends SDLActivity {
|
||||
// has no public chdir() (confirmed against the actual API 34 stub jar).
|
||||
private static native void nativeChdir(String path);
|
||||
|
||||
// Straight-alpha ARGB pixels of the startup splash (qs_splash.c).
|
||||
private static native void nativeSetSplashImage(int[] pixels, int width, int height);
|
||||
|
||||
@Override
|
||||
protected String[] getLibraries() {
|
||||
return new String[] {
|
||||
@@ -187,11 +192,31 @@ public class QuestShockActivity extends SDLActivity {
|
||||
// before clearing mBrokenLibraries below so the engine can never
|
||||
// start pre-chdir.
|
||||
nativeChdir(GAME_DIR);
|
||||
loadSplashImage();
|
||||
|
||||
// Assets confirmed present - safe to let the native engine start now.
|
||||
SDLActivity.mBrokenLibraries = false;
|
||||
}
|
||||
|
||||
// The engine shows this at startup (qs_splash.c); without it there is no splash.
|
||||
private void loadSplashImage() {
|
||||
try (InputStream in = getAssets().open("splash.png")) {
|
||||
Bitmap bitmap = BitmapFactory.decodeStream(in);
|
||||
if (bitmap == null) {
|
||||
Log.e(TAG, "Could not decode splash.png");
|
||||
return;
|
||||
}
|
||||
int width = bitmap.getWidth();
|
||||
int height = bitmap.getHeight();
|
||||
int[] pixels = new int[width * height];
|
||||
bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
|
||||
bitmap.recycle();
|
||||
nativeSetSplashImage(pixels, width, height);
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Could not read splash.png", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isNonEmptyDir(File dir) {
|
||||
String[] entries = dir.list();
|
||||
return entries != null && entries.length > 0;
|
||||
@@ -219,7 +244,7 @@ public class QuestShockActivity extends SDLActivity {
|
||||
+ "merging or extraction required.\n\n"
|
||||
+ "Then relaunch.\n\n"
|
||||
+ "(These instructions are also in GET_ASSETS_QUEST.txt in the "
|
||||
+ "questshock folder on this device, if you need to read them again.)")
|
||||
+ "QuestShock folder on this device, if you need to read them again.)")
|
||||
.setCancelable(false)
|
||||
.setPositiveButton("Exit", (dialog, which) -> finish())
|
||||
.create()
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">System Shock</string>
|
||||
<string name="app_name">QuestShock</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
--- a/src/GameSrc/setup.c 2026-09-20 16:04:46.197310679 +0200
|
||||
+++ b/src/GameSrc/setup.c 2026-09-20 16:05:08.400330327 +0200
|
||||
@@ -58,6 +58,9 @@
|
||||
#include "gamestrn.h"
|
||||
|
||||
#include "mainloop.h"
|
||||
+#ifdef __ANDROID__
|
||||
+#include "qs_splash.h"
|
||||
+#endif
|
||||
#include "tools.h"
|
||||
#include "input.h"
|
||||
#include "game_screen.h"
|
||||
@@ -1300,6 +1303,11 @@
|
||||
if (!show_splash)
|
||||
return;
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+ // The QuestShock title, before the engine's own splash screens.
|
||||
+ qs_splash_show();
|
||||
+#endif
|
||||
+
|
||||
// Need to load the splash palette file
|
||||
|
||||
INFO("Loading splshpal.res");
|
||||
@@ -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 \
|
||||
|
||||
@@ -24,16 +24,33 @@ VERSION="$("$REPO_ROOT/build-image/version.sh")"
|
||||
VERSION_CODE="$(git -C "$REPO_ROOT" rev-list --count HEAD)"
|
||||
echo "Version: $VERSION (versionCode $VERSION_CODE)"
|
||||
|
||||
echo "== Building the APK (gradlew assembleDebug) =="
|
||||
# A release APK is built and signed when QUESTSHOCK_KEYSTORE is set (with
|
||||
# QUESTSHOCK_KEYSTORE_PASSWORD, and optionally QUESTSHOCK_KEY_ALIAS,
|
||||
# default "questshock", and QUESTSHOCK_KEY_PASSWORD, default the keystore
|
||||
# password). Without it the APK is a debug build, signed with a per-machine
|
||||
# debug key.
|
||||
if [ -n "${QUESTSHOCK_KEYSTORE:-}" ]; then
|
||||
[ -f "$QUESTSHOCK_KEYSTORE" ] || { echo "BUILD FAIL: QUESTSHOCK_KEYSTORE ($QUESTSHOCK_KEYSTORE) does not exist" >&2; exit 1; }
|
||||
[ -n "${QUESTSHOCK_KEYSTORE_PASSWORD:-}" ] || { echo "BUILD FAIL: QUESTSHOCK_KEYSTORE_PASSWORD is not set" >&2; exit 1; }
|
||||
GRADLE_TASK=assembleRelease
|
||||
APK="$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk"
|
||||
PKG_SUFFIX=""
|
||||
else
|
||||
echo "== QUESTSHOCK_KEYSTORE is not set: building a debug APK, not for distribution =="
|
||||
GRADLE_TASK=assembleDebug
|
||||
APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk"
|
||||
PKG_SUFFIX="-debug"
|
||||
fi
|
||||
|
||||
echo "== Building the APK (gradlew $GRADLE_TASK) =="
|
||||
cd "$ANDROID_DIR"
|
||||
./gradlew --no-daemon assembleDebug \
|
||||
./gradlew --no-daemon "$GRADLE_TASK" \
|
||||
"-PquestshockVersionName=$VERSION" "-PquestshockVersionCode=$VERSION_CODE"
|
||||
|
||||
APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk"
|
||||
[ -f "$APK" ] || { echo "BUILD FAIL: $APK not found after assembleDebug" >&2; exit 1; }
|
||||
[ -f "$APK" ] || { echo "BUILD FAIL: $APK not found after $GRADLE_TASK" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$REPO_ROOT/dist"
|
||||
PKG_NAME="questshock-$VERSION-android-arm64.apk"
|
||||
PKG_NAME="questshock-$VERSION-android-arm64$PKG_SUFFIX.apk"
|
||||
cp "$APK" "$REPO_ROOT/dist/$PKG_NAME"
|
||||
|
||||
echo "== Done =="
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -93,6 +93,7 @@ cp "/opt/prebuilt/soundfont/default.sf2" "$ASSETS_DIR/res/soundfont.sf2"
|
||||
cp "$REPO_ROOT/res/assets/GET_ASSETS_QUEST.txt" "$ASSETS_DIR/GET_ASSETS_QUEST.txt"
|
||||
cp "$REPO_ROOT/res/questshock.config.default.json" "$ASSETS_DIR/questshock.config.default.json"
|
||||
cp -a "$REPO_ROOT/res/controller_profiles" "$ASSETS_DIR/controller_profiles"
|
||||
cp "$REPO_ROOT/res/splash.png" "$ASSETS_DIR/splash.png"
|
||||
|
||||
echo "== Staging prebuilt Android SDL2/SDL2_mixer/fluidsynth-lite/openxr .so's into jniLibs =="
|
||||
# gl4es is deliberately absent here - it's no longer prebuilt, it's compiled
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
--- a/src/MacSrc/OpenGL.cc 2026-07-19 13:11:51.000000000 +0200
|
||||
+++ b/src/MacSrc/OpenGL.cc 2026-09-27 11:42:22.497868697 +0200
|
||||
@@ -562,7 +562,14 @@
|
||||
static bool opengl_cache_texture(CachedTexture toCache, grs_bitmap *bm) {
|
||||
SDL_GL_MakeCurrent(window, context);
|
||||
|
||||
- glPixelStorei(GL_UNPACK_ROW_LENGTH, bm->row);
|
||||
+ // GL_UNPACK_ROW_LENGTH must describe the buffer actually passed to
|
||||
+ // glTexImage2D() below (toCache.converted->pixels, a separately
|
||||
+ // allocated SDL_PIXELFORMAT_RGBA32 surface from
|
||||
+ // SDL_ConvertSurfaceFormat()) - not bm->row, the original 8-bit source
|
||||
+ // bitmap's own unrelated row stride. Using bm->row here describes the
|
||||
+ // wrong stride to OpenGL whenever the two differ, corrupting the
|
||||
+ // texture upload.
|
||||
+ glPixelStorei(GL_UNPACK_ROW_LENGTH, toCache.converted->pitch / 4);
|
||||
|
||||
if (texturesByBitsPtr.size() < MAX_CACHED_TEXTURES) {
|
||||
// We have enough room, generate the new texture
|
||||
@@ -696,7 +703,10 @@
|
||||
t->lastDrawTime = *tmd_ticks;
|
||||
|
||||
if (isDirty) {
|
||||
- glPixelStorei(GL_UNPACK_ROW_LENGTH, bm->row);
|
||||
+ // Same fix as opengl_cache_texture() above - t->converted->pixels is
|
||||
+ // what's actually uploaded, so its own pitch is what
|
||||
+ // GL_UNPACK_ROW_LENGTH needs to describe, not bm->row.
|
||||
+ glPixelStorei(GL_UNPACK_ROW_LENGTH, t->converted->pitch / 4);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bm->w, bm->h, 0, GL_RGBA, GL_UNSIGNED_BYTE, t->converted->pixels);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
--- a/src/MacSrc/SDLSound.c 2026-07-19 13:11:51.000000000 +0200
|
||||
+++ b/src/MacSrc/SDLSound.c 2026-09-27 11:42:37.969726579 +0200
|
||||
@@ -42,7 +42,7 @@
|
||||
}
|
||||
|
||||
if (Mix_OpenAudio(48000, AUDIO_S16SYS, 2, 2048) < 0) {
|
||||
- ERROR("%s: Couldn't open audio device", __FUNCTION__);
|
||||
+ ERROR("%s: Couldn't open audio device: %s", __FUNCTION__, SDL_GetError());
|
||||
}
|
||||
|
||||
Mix_AllocateChannels(SND_MAX_SAMPLES);
|
||||
@@ -0,0 +1,98 @@
|
||||
--- a/src/MacSrc/Modding.c 2026-07-19 13:11:51.000000000 +0200
|
||||
+++ b/src/MacSrc/Modding.c 2026-09-27 19:44:24.718059783 +0200
|
||||
@@ -27,6 +27,11 @@
|
||||
char *modding_additional_files[MAX_MOD_FILES];
|
||||
int num_mod_files;
|
||||
|
||||
+// One filenum per modding_additional_files[] entry, or -1 if not open yet -
|
||||
+// lets LoadModFiles() close a mod file's previous handle before reopening it
|
||||
+// (see LoadModFiles() below for why it needs to be called more than once).
|
||||
+static int32_t modding_loaded_filenum[MAX_MOD_FILES];
|
||||
+
|
||||
int StringEndsWith(char *src, char *dst) {
|
||||
char *s = strrchr(src, '.');
|
||||
|
||||
@@ -86,6 +91,7 @@
|
||||
modding_archive_override = NULL;
|
||||
for (int i = 0; i < MAX_MOD_FILES; i++) {
|
||||
modding_additional_files[i] = NULL;
|
||||
+ modding_loaded_filenum[i] = -1;
|
||||
}
|
||||
|
||||
// Now go process args
|
||||
@@ -135,10 +141,24 @@
|
||||
}
|
||||
|
||||
int LoadModFiles() {
|
||||
+ // Called again by obj_set_secondary_properties() (objsim.c) after it
|
||||
+ // re-opens the base game's own object art files, which would otherwise
|
||||
+ // silently reclaim any resource ID a mod file already overrode here (see
|
||||
+ // ResReadDirEntries() in resfile.c - opening a file re-registers every
|
||||
+ // resource ID it contains, last one in wins, with no check for whether
|
||||
+ // an ID is already claimed). Re-registering means actually re-opening
|
||||
+ // (there is no "just re-register" primitive), so a mod file already open
|
||||
+ // from an earlier call is closed first - one open handle per mod file at
|
||||
+ // a time, rather than leaking a fresh resFile[] slot on every repeat call
|
||||
+ // (new game, load game - object_data_load() can run more than once per
|
||||
+ // process).
|
||||
for (int i = 0; i < num_mod_files; i++) {
|
||||
if (modding_additional_files[i] != NULL) {
|
||||
+ if (modding_loaded_filenum[i] >= 0) {
|
||||
+ ResCloseFile(modding_loaded_filenum[i]);
|
||||
+ }
|
||||
printf("Loading mod file %s\n", modding_additional_files[i]);
|
||||
- ResOpenFile(modding_additional_files[i]);
|
||||
+ modding_loaded_filenum[i] = ResOpenFile(modding_additional_files[i]);
|
||||
}
|
||||
}
|
||||
|
||||
--- a/src/GameSrc/objsim.c 2026-07-19 13:11:51.000000000 +0200
|
||||
+++ b/src/GameSrc/objsim.c 2026-09-27 19:44:17.014441306 +0200
|
||||
@@ -43,6 +43,7 @@
|
||||
#include "gettmaps.h"
|
||||
#include "lvldata.h"
|
||||
#include "mapflags.h"
|
||||
+#include "Modding.h"
|
||||
#include "objart2.h"
|
||||
#include "objart3.h"
|
||||
#include "objects.h"
|
||||
@@ -1877,6 +1878,13 @@
|
||||
// Warning(("Problem opening object art cache!\n"));
|
||||
return (ERR_NOEFFECT);
|
||||
}
|
||||
+ // The two opens above re-register every resource ID objart2.res/
|
||||
+ // objart3.res contain, reclaiming any of them a mod file already
|
||||
+ // overrode (see LoadModFiles() in Modding.c) - restore mod priority
|
||||
+ // for the reftable reads below. load_dynamic_memory() (cybmem.c) reopens
|
||||
+ // these same two files again later, persistently, for actual rendering -
|
||||
+ // restoring priority there too.
|
||||
+ LoadModFiles();
|
||||
|
||||
// Now go and set specific computed data, like frame counts
|
||||
for (i = 0; i < NUM_CRITTER; i++) {
|
||||
--- a/src/GameSrc/cybmem.c 2026-07-19 13:11:51.000000000 +0200
|
||||
+++ b/src/GameSrc/cybmem.c 2026-09-27 19:44:24.718059783 +0200
|
||||
@@ -33,6 +33,7 @@
|
||||
#include "textmaps.h"
|
||||
#include "objcrit.h"
|
||||
#include "dynmem.h"
|
||||
+#include "Modding.h"
|
||||
#include "Shock.h"
|
||||
#include "sideicon.h"
|
||||
#include "criterr.h"
|
||||
@@ -120,6 +121,15 @@
|
||||
critical_error(CRITERR_RES | 8);
|
||||
}
|
||||
|
||||
+ // The four opens above re-register every resource ID their files
|
||||
+ // contain, reclaiming any of them a mod file already overrode (see
|
||||
+ // LoadModFiles() in Modding.c) - these are the persistent handles
|
||||
+ // actual gameplay renders from (unlike obj_set_secondary_properties()'s
|
||||
+ // own transient open/close of objart2.res/objart3.res for reading
|
||||
+ // frame-count metadata), so this is the last point mod priority needs
|
||||
+ // restoring before rendering.
|
||||
+ LoadModFiles();
|
||||
+
|
||||
loadcount |= mask;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
--- a/src/GameSrc/objsim.c 2026-09-27 19:44:17.014441306 +0200
|
||||
+++ b/src/GameSrc/objsim.c 2026-09-27 20:44:23.147083066 +0200
|
||||
@@ -1867,6 +1867,40 @@
|
||||
return (OK);
|
||||
}
|
||||
|
||||
+// Every critter view's base-game bitmap size, keyed by resource ID - filled
|
||||
+// in by cache_critter_native_size() below while objart2.res/objart3.res's
|
||||
+// base-game data is still the active resolver for these IDs (before
|
||||
+// LoadModFiles() hands priority to a mod again), read back by
|
||||
+// get_critter_native_size() in gameobj.c. A mod that replaces a critter's
|
||||
+// art with a different-resolution bitmap changes how much detail is visible,
|
||||
+// not the object's on-screen size: g3_bitmap_common() (Bitmap.c) uses this
|
||||
+// cached size instead of the (possibly modded, possibly larger or smaller)
|
||||
+// bitmap's own width/height when computing the sprite's world-space extent.
|
||||
+// Id is uint16_t, so a flat array indexed by it needs no bounds check.
|
||||
+static short s_critterNativeW[65536];
|
||||
+static short s_critterNativeH[65536];
|
||||
+
|
||||
+static void cache_critter_native_size(Id id) {
|
||||
+ FrameDesc *f;
|
||||
+ if (s_critterNativeW[id] != 0)
|
||||
+ return;
|
||||
+ f = (FrameDesc *)RefLock(MKREF(id, 0));
|
||||
+ if (f == NULL)
|
||||
+ return;
|
||||
+ s_critterNativeW[id] = f->bm.w;
|
||||
+ s_critterNativeH[id] = f->bm.h;
|
||||
+ RefUnlock(MKREF(id, 0));
|
||||
+}
|
||||
+
|
||||
+// w/h are left untouched (so a caller can default to 0, meaning "no cached
|
||||
+// native size, use the bitmap's own dimensions") if id was never cached.
|
||||
+void get_critter_native_size(Id id, short *w, short *h) {
|
||||
+ if (s_critterNativeW[id] == 0)
|
||||
+ return;
|
||||
+ *w = s_critterNativeW[id];
|
||||
+ *h = s_critterNativeH[id];
|
||||
+}
|
||||
+
|
||||
errtype obj_set_secondary_properties() {
|
||||
char i, j;
|
||||
RefTable *prt;
|
||||
@@ -1878,6 +1912,28 @@
|
||||
// Warning(("Problem opening object art cache!\n"));
|
||||
return (ERR_NOEFFECT);
|
||||
}
|
||||
+
|
||||
+ // Snapshot every critter view's base-game bitmap size while it's still
|
||||
+ // the active resolver for these IDs, before LoadModFiles() below hands
|
||||
+ // priority back to a mod - see cache_critter_native_size() and its own
|
||||
+ // comment. Full 8-view postures need every view individually (their IDs
|
||||
+ // are 8 consecutive resource IDs, one per view); front-only postures are
|
||||
+ // a single ID (matching the frame-count loop below).
|
||||
+ for (i = 0; i < NUM_CRITTER; i++) {
|
||||
+ if (CritterProps[i].views > 1) {
|
||||
+ for (j = 0; j < NUM_CRITTER_POSTURES; j++) {
|
||||
+ if (j >= FIRST_FRONT_POSTURE) {
|
||||
+ cache_critter_native_size(posture_bases[j] + i);
|
||||
+ } else {
|
||||
+ int v;
|
||||
+ for (v = 0; v < 8; v++) {
|
||||
+ cache_critter_native_size(critter_id_table[i] + posture_bases[j] + v);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
// The two opens above re-register every resource ID objart2.res/
|
||||
// objart3.res contain, reclaiming any of them a mod file already
|
||||
// overrode (see LoadModFiles() in Modding.c) - restore mod priority
|
||||
--- a/src/GameSrc/Headers/objsim.h 2026-07-19 13:11:51.000000000 +0200
|
||||
+++ b/src/GameSrc/Headers/objsim.h 2026-09-27 19:39:39.585009491 +0200
|
||||
@@ -108,6 +108,7 @@
|
||||
errtype obj_load_properties();
|
||||
errtype obj_create_player(ObjLoc *plr_loc);
|
||||
Ref ref_from_critter_data(ObjID oid, int triple, byte posture, short frame, short view);
|
||||
+void get_critter_native_size(Id id, short *w, short *h);
|
||||
errtype obj_zero_unused();
|
||||
grs_bitmap *obj_get_model_data(ObjID id, fix *x, fix *y, fix *z, grs_bitmap *bm2, Ref *ref1, Ref *ref2);
|
||||
errtype obj_model_hack(ObjID id, uchar *hack_x, uchar *hack_y, uchar *hack_z, uchar *hack_type);
|
||||
--- a/src/GameSrc/gameobj.c 2026-07-19 13:11:51.000000000 +0200
|
||||
+++ b/src/GameSrc/gameobj.c 2026-09-27 20:44:35.195019539 +0200
|
||||
@@ -104,7 +104,7 @@
|
||||
void draw_ice(void);
|
||||
void draw_ice_wall(void);
|
||||
void _fr_draw_tmtile(grs_bitmap *draw_bm, int col_val, g3s_phandle *plst, uchar dblface, uchar use_lighting);
|
||||
-void _fr_draw_bitmap(grs_bitmap *draw_bm, int dist, int sc, int anch_x, int anch_y);
|
||||
+void _fr_draw_bitmap(grs_bitmap *draw_bm, int dist, int sc, int anch_x, int anch_y, short native_w, short native_h);
|
||||
|
||||
int munge_val(int val, int range, int delta) {
|
||||
int base = range - delta;
|
||||
@@ -575,7 +575,7 @@
|
||||
|
||||
// note this always has show_obj's p for p and _fdt_dist for dist.. perhaps shouldnt pass them
|
||||
//#pragma disable_message(202)
|
||||
-void _fr_draw_bitmap(grs_bitmap *draw_bm, int dist, int sc, int anch_x, int anch_y) {
|
||||
+void _fr_draw_bitmap(grs_bitmap *draw_bm, int dist, int sc, int anch_x, int anch_y, short native_w, short native_h) {
|
||||
#ifdef SMOOTH_BITMAPS
|
||||
grs_canvas tmp_can;
|
||||
grs_bitmap tmp_bm;
|
||||
@@ -597,7 +597,7 @@
|
||||
if ((anch_x <= 0) && (anch_y <= 0))
|
||||
bitmap_verts = g3_light_bitmap(draw_bm, anchor);
|
||||
else
|
||||
- bitmap_verts = g3_light_anchor_bitmap(draw_bm, anchor, anch_x, anch_y);
|
||||
+ bitmap_verts = g3_light_anchor_bitmap(draw_bm, anchor, anch_x, anch_y, native_w, native_h);
|
||||
if (sc)
|
||||
g3_set_bitmap_scale(fix_make(0, (int)(2048 / 3)), fix_make(0, (int)(2048 / 3)));
|
||||
|
||||
@@ -855,7 +855,7 @@
|
||||
if ((anchors_3d[o3drep].x > 0) || (anchors_3d[o3drep].y > 0))
|
||||
_o_rad = 0;
|
||||
_fr_draw_bitmap(bitmaps_3d[o3drep], _fdt_dist, ObjProps[objtrip].flags & MY_IM_LARGE, anchors_3d[o3drep].x,
|
||||
- anchors_3d[o3drep].y);
|
||||
+ anchors_3d[o3drep].y, 0, 0);
|
||||
if (global_fullmap->cyber)
|
||||
if (obj_ICE_ICE_BABY(_fr_cobj))
|
||||
draw_ice();
|
||||
@@ -995,7 +995,7 @@
|
||||
trgp = tpdata->bits + tele_bm.h * tele_bm.w;
|
||||
LG_memcpy(dstp, srcp, trgp - srcp);
|
||||
|
||||
- _fr_draw_bitmap(&tele_bm, _fdt_dist, FALSE, anch.ul.x, anch.ul.y);
|
||||
+ _fr_draw_bitmap(&tele_bm, _fdt_dist, FALSE, anch.ul.x, anch.ul.y, 0, 0);
|
||||
release_critter_bitmap_fast(ref);
|
||||
return;
|
||||
}
|
||||
@@ -1016,11 +1016,15 @@
|
||||
#endif
|
||||
break;
|
||||
}
|
||||
- _fr_draw_bitmap(tpdata, _fdt_dist, 0, anch.ul.x, anch.ul.y);
|
||||
+ {
|
||||
+ short native_w = 0, native_h = 0;
|
||||
+ get_critter_native_size(REFID(ref), &native_w, &native_h);
|
||||
+ _fr_draw_bitmap(tpdata, _fdt_dist, 0, anch.ul.x, anch.ul.y, native_w, native_h);
|
||||
+ }
|
||||
release_critter_bitmap_fast(ref);
|
||||
} else {
|
||||
tpdata = bitmaps_3d[o3drep + view];
|
||||
- _fr_draw_bitmap(tpdata, _fdt_dist, 0, -1, -1);
|
||||
+ _fr_draw_bitmap(tpdata, _fdt_dist, 0, -1, -1, 0, 0);
|
||||
}
|
||||
} break;
|
||||
|
||||
--- a/src/Libraries/3D/Source/Bitmap.c 2026-07-19 13:11:51.000000000 +0200
|
||||
+++ b/src/Libraries/3D/Source/Bitmap.c 2026-09-27 20:44:47.378954871 +0200
|
||||
@@ -88,6 +88,14 @@
|
||||
// fix _g3d_bitmap_y_iscale = 0x010000;
|
||||
long _g3d_bitmap_u_anchor = 0;
|
||||
long _g3d_bitmap_v_anchor = 0;
|
||||
+// Non-zero overrides the bitmap's own w/h in g3_bitmap_common()'s world-space
|
||||
+// size computation below, decoupling a bitmap's on-screen size from its
|
||||
+// actual pixel dimensions - set by g3_light_anchor_bitmap(), consumed and
|
||||
+// reset back to 0 by g3_bitmap_common() so it never leaks into an unrelated
|
||||
+// bitmap drawn via a different entry point (g3_bitmap()/g3_light_bitmap()/
|
||||
+// do_bitmap() never set these).
|
||||
+short _g3d_bitmap_w_override = 0;
|
||||
+short _g3d_bitmap_h_override = 0;
|
||||
fix _g3d_roll_matrix[6];
|
||||
uchar *_g3d_bitmap_clut;
|
||||
int _g3d_light_flag;
|
||||
@@ -132,10 +140,13 @@
|
||||
return (g3_bitmap_common(bm, (g3s_phandle)p));
|
||||
}
|
||||
|
||||
-grs_vertex **g3_light_anchor_bitmap(grs_bitmap *bm, g3s_phandle p, short u_anchor, short v_anchor) {
|
||||
+grs_vertex **g3_light_anchor_bitmap(grs_bitmap *bm, g3s_phandle p, short u_anchor, short v_anchor, short w_override,
|
||||
+ short h_override) {
|
||||
_g3d_light_flag = 2;
|
||||
_g3d_bitmap_u_anchor = u_anchor;
|
||||
_g3d_bitmap_v_anchor = v_anchor;
|
||||
+ _g3d_bitmap_w_override = w_override;
|
||||
+ _g3d_bitmap_h_override = h_override;
|
||||
_g3d_bitmap_clut = (p->i & 0x00ff00) + grd_screen->ltab;
|
||||
_g3d_bitmap_poly = vpl;
|
||||
return (g3_bitmap_common(bm, p));
|
||||
@@ -185,6 +196,15 @@
|
||||
long rm2;
|
||||
long rm3;
|
||||
|
||||
+ // Read and reset right at entry, before any early return below - a
|
||||
+ // pending override must never survive to affect a later, unrelated
|
||||
+ // bitmap draw that doesn't set it itself (see _g3d_bitmap_w_override's
|
||||
+ // own comment).
|
||||
+ short w_override = _g3d_bitmap_w_override;
|
||||
+ short h_override = _g3d_bitmap_h_override;
|
||||
+ _g3d_bitmap_w_override = 0;
|
||||
+ _g3d_bitmap_h_override = 0;
|
||||
+
|
||||
#ifdef stereo_on
|
||||
if (_g3d_stereo & 1) {
|
||||
|
||||
@@ -209,9 +229,11 @@
|
||||
if ((p->codes & CC_CLIP_OVERFLOW) != 0)
|
||||
return (0L);
|
||||
|
||||
- // copy a few things into locals
|
||||
- bm_w = bm->w;
|
||||
- bm_h = bm->h;
|
||||
+ // copy a few things into locals - w_override/h_override, if set (see
|
||||
+ // _g3d_bitmap_w_override's own comment), take the place of the bitmap's
|
||||
+ // own pixel dimensions here.
|
||||
+ bm_w = w_override ? w_override : bm->w;
|
||||
+ bm_h = h_override ? h_override : bm->h;
|
||||
|
||||
tempL = p->gZ; // mov eax,[edi].z
|
||||
tempL <<= 8; // sal eax,8 ;should be
|
||||
--- a/src/Libraries/3D/Source/3d.h 2026-07-19 13:11:51.000000000 +0200
|
||||
+++ b/src/Libraries/3D/Source/3d.h 2026-09-27 19:42:26.528424908 +0200
|
||||
@@ -720,7 +720,8 @@
|
||||
grs_vertex **g3_light_bitmap(grs_bitmap *bm, g3s_phandle p);
|
||||
// MLA #pragma aux g3_light_bitmap "*" parm [esi] [edi] modify [eax ebx ecx
|
||||
// edx];
|
||||
-grs_vertex **g3_light_anchor_bitmap(grs_bitmap *bm, g3s_phandle p, short u_anchor, short v_anchor);
|
||||
+grs_vertex **g3_light_anchor_bitmap(grs_bitmap *bm, g3s_phandle p, short u_anchor, short v_anchor, short w_override,
|
||||
+ short h_override);
|
||||
// MLA #pragma aux g3_light_anchor_bitmap "*" parm [esi] [edi] [eax] [edx]
|
||||
// modify [eax ebx ecx edx];
|
||||
grs_vertex **g3_full_light_bitmap(grs_bitmap *bm, grs_vertex **p);
|
||||
@@ -0,0 +1,91 @@
|
||||
# Builds the modding subproject - see README.md.
|
||||
#
|
||||
# Targets:
|
||||
# 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/.
|
||||
# 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 animgallery_base animgallery_mods animgallery_dist clean
|
||||
|
||||
export MODS
|
||||
|
||||
all: hacked mods
|
||||
|
||||
build-image:
|
||||
./hacked-build-image.sh
|
||||
|
||||
# If we're already running inside the hacked-build-image
|
||||
# (HACKED_BUILD_IMAGE, set by its Dockerfile), compile directly instead of
|
||||
# trying to docker-run it again - e.g. a CI job already using this image as
|
||||
# its container. Otherwise, run it via Docker as usual.
|
||||
hacked:
|
||||
@if [ -n "$$HACKED_BUILD_IMAGE" ]; then \
|
||||
bash hacked-build-image/build-hacked.sh; \
|
||||
else \
|
||||
./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
|
||||
|
||||
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
|
||||
@@ -0,0 +1,283 @@
|
||||
# Modding
|
||||
|
||||
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.
|
||||
|
||||
`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
|
||||
|
||||
- `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.
|
||||
- `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. 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 1.1).
|
||||
- `sqweek-dialog-patches/` - patches for `sqweek-dialog/`, same pattern as
|
||||
`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),
|
||||
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
|
||||
`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`.
|
||||
- `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
|
||||
external tools that create and inspect that content, not about changing the
|
||||
engine itself.
|
||||
|
||||
### 1.1. Patch reference
|
||||
|
||||
`src/hacked-patches/` and `src/sqweek-dialog-patches/` (applied to scratch
|
||||
copies of `src/hacked/` and `src/sqweek-dialog/` respectively) are both
|
||||
plain, numbered patch files applied in order at build time - neither
|
||||
vendored source is ever modified in place. What each one does:
|
||||
|
||||
`src/hacked-patches/`:
|
||||
|
||||
- **`01-use-patched-dialog.patch`** - adds a `go.mod` `replace` directive
|
||||
pointing `github.com/sqweek/dialog` at the local, patched
|
||||
`../sqweek-dialog` instead of the upstream module, so `hacked` (and
|
||||
anything else building against this source, such as `mdlp`/`animgallery`)
|
||||
picks up the crash fix below.
|
||||
- **`02-fix-hashall-race.patch`** - fixes a real data race in
|
||||
`ss1/resource/ChangeNotifier.go`'s `hashAll()`: it hashes many resources
|
||||
concurrently (one goroutine per ID) to detect what changed after a
|
||||
modification, but only guarded the resource *lookup* with a mutex, not
|
||||
the actual block-reading inside `hashResource()` - resources from the
|
||||
same underlying file share a stateful reader, so concurrent unguarded
|
||||
reads corrupted each other, non-deterministically. Found via `go build
|
||||
-race` after `animgallery` produced a different resource count on every
|
||||
run against the exact same input. The mutex now guards the whole hash
|
||||
computation per resource, not just the lookup.
|
||||
|
||||
`src/sqweek-dialog-patches/`:
|
||||
|
||||
- **`01-xinitthreads.patch`** - adds `XInitThreads()` before `gtk_init()`
|
||||
in the Linux backend. Without it, `hacked` segfaults the moment a native
|
||||
GTK3 dialog (file/folder browse) opens while a GLFW/OpenGL context is
|
||||
live: GLFW's own X11 backend installs a process-wide Xlib error handler
|
||||
that conflicts with GTK's unless Xlib has been told up front that it
|
||||
will be used from multiple threads.
|
||||
|
||||
## 2. Building `hacked`
|
||||
|
||||
Packages `dist/<version>.linux64.tgz` and `dist/<version>.win64.zip` (native
|
||||
Linux build, Windows amd64 cross-compiled via mingw-w64 - both from the same
|
||||
source, in one run).
|
||||
|
||||
**Prerequisites:** Docker - same as the root project, nothing modding-specific
|
||||
to install locally.
|
||||
|
||||
```sh
|
||||
cd modding
|
||||
|
||||
# 1. Build the hacked-build-image (once, or after hacked-build-image/ or
|
||||
# src/hacked/go.mod|go.sum changes)
|
||||
./hacked-build-image.sh
|
||||
|
||||
# 2. Compile hacked/ for Linux and Windows
|
||||
make hacked
|
||||
```
|
||||
|
||||
`<version>` is `hacked-<date>`, not an InkyBlackness release version:
|
||||
`hacked/` is a vendored snapshot here, not its own git checkout, so there is
|
||||
no tag to derive a real version from (see
|
||||
`hacked-build-image/build-hacked.sh`'s own comment).
|
||||
|
||||
Run the result: `dist/linux/hacked/hacked` on Linux,
|
||||
`dist\win\hacked\hacked.exe` on Windows. Needs OpenGL 3.2; usage is documented
|
||||
on [hacked's own wiki](https://github.com/inkyblackness/hacked/wiki).
|
||||
|
||||
### 2.1. Publishing the build-image
|
||||
|
||||
Same registry as the root project's own build-image (see the repository
|
||||
root's `registry.env.example`), under a separate image name so the two don't
|
||||
collide:
|
||||
|
||||
```sh
|
||||
./hacked-build-upload-image.sh
|
||||
```
|
||||
|
||||
Reads `REGISTRY`/`REGISTRY_USER`/`REGISTRY_PASSWORD` and (optionally)
|
||||
`HACKED_REGISTRY_IMAGE` from `../registry.env`, same as the root scripts.
|
||||
Don't bump `hacked-build-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. Example: Editing a message
|
||||
|
||||
`hacked` has no inline text editor - every text field is edited by right-click
|
||||
-> **Copy to Clipboard**, editing the text in an external editor, then
|
||||
right-click -> **Copy from Clipboard** back into the field. Example: changing
|
||||
Rebecca Lansing's first email.
|
||||
|
||||
1. Run `dist/linux/hacked/hacked` (or the Windows build).
|
||||
2. **Window -> Project.** Under **Static World Data**, **Add...** and browse to
|
||||
the game's whole `res/assets/ss_ee/data/` directory (see the root
|
||||
[README.md](../README.md) section 3 for where that comes from), so every
|
||||
`.res`/`.dat` file is available.
|
||||
3. **Window -> Messages** (F5). **Message Type**: `Mails`. **Index**: `26`
|
||||
(Rebecca's first mail). Edit **Sender**/**Subject**/**Title**/the message
|
||||
text via the clipboard round-trip above.
|
||||
4. Back in the **Project** window, under **Mod Location**, click **Save**
|
||||
(first time: **Browse...** to an empty folder, for example
|
||||
`./modding/mymod`).
|
||||
5. Test it from the root project's desktop build:
|
||||
```sh
|
||||
./dist/run.sh "$(pwd)/modding/mymod"
|
||||
```
|
||||
The mod-folder argument **must be an absolute path**: `run.sh` `cd`s into
|
||||
`dist/` before launching the engine, so a relative path resolves against
|
||||
the wrong directory and the mod silently fails to load (the engine's
|
||||
`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/)
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
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>/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
|
||||
`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`.
|
||||
@@ -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 "$@"
|
||||
' _ "$@"
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the hacked build-image (hacked-build-image/Dockerfile) locally, tagged with
|
||||
# hacked-build-image/VERSION and :latest. Does not push - test it with
|
||||
# ./hacked-build-run-image.sh first, then publish with
|
||||
# ./hacked-build-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="${HACKED_REGISTRY_IMAGE:-questshock/hacked-builder}"
|
||||
if [ -f "$ROOT/../registry.env" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
source "$ROOT/../registry.env"
|
||||
IMAGE="${HACKED_REGISTRY_IMAGE:-$IMAGE}"
|
||||
fi
|
||||
|
||||
VERSION="$(<"$ROOT/hacked-build-image/VERSION")"
|
||||
[ -n "$VERSION" ] || fail "hacked-build-image/VERSION is empty"
|
||||
|
||||
echo "== Building $IMAGE:$VERSION =="
|
||||
docker build \
|
||||
-f "$ROOT/hacked-build-image/Dockerfile" \
|
||||
-t "$IMAGE:$VERSION" \
|
||||
-t "$IMAGE:latest" \
|
||||
"$ROOT"
|
||||
|
||||
echo "== Done =="
|
||||
echo "Image: $IMAGE:$VERSION (and :latest)"
|
||||
echo "Test it with ./hacked-build-run-image.sh, then publish with ./hacked-build-upload-image.sh"
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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
|
||||
# ../src/hacked/go.mod or go.sum changes (its dependencies are baked into the
|
||||
# image's module cache).
|
||||
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
|
||||
|
||||
# build-essential/pkg-config/curl/ca-certificates/git/gosu: base toolchain.
|
||||
# mingw-w64: the Windows cross-build. libgl1-mesa-dev/xorg-dev: go-gl/glfw's
|
||||
# cgo bindings (Linux build only - the Windows cross-build talks to the Win32
|
||||
# API directly, via mingw-w64's own headers). libgtk-3-dev:
|
||||
# github.com/sqweek/dialog's Linux backend uses GTK3 via cgo (`pkg-config
|
||||
# gtk+-3.0`) for native file/message dialogs. zip: packages the Windows build
|
||||
# (see build-hacked.sh) - tar, for the Linux .tgz, is already part of the
|
||||
# base image.
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config curl ca-certificates git gosu \
|
||||
mingw-w64 \
|
||||
libgl1-mesa-dev xorg-dev \
|
||||
libgtk-3-dev \
|
||||
zip \
|
||||
&& 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 hacked/go.mod pins, so the
|
||||
# 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 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
|
||||
COPY hacked-build-image/build-hacked.sh /usr/local/bin/build-hacked.sh
|
||||
# 755, not +x: the entrypoint drops to a `builder` user matching the *host's*
|
||||
# UID/GID (see docker-entrypoint.sh), which is never in these root-owned
|
||||
# files' group, so it needs the image's own explicit world-read+execute here -
|
||||
# `chmod +x` alone only adds execute bits on top of whatever "other"
|
||||
# permissions the source file happened to have (host umask-dependent).
|
||||
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh /usr/local/bin/build-hacked.sh
|
||||
|
||||
WORKDIR /workspace
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["/usr/local/bin/build-hacked.sh"]
|
||||
|
||||
# Marks a shell as already running inside this image, same convention as the
|
||||
# main project's own build-image (see ../../build-image/Dockerfile) - lets
|
||||
# ../Makefile compile directly instead of trying to docker-run this image
|
||||
# again from inside itself (e.g. a CI job already using this image as its
|
||||
# container).
|
||||
ENV HACKED_BUILD_IMAGE=1
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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.
|
||||
#
|
||||
# Does not follow hacked/build.sh (its own upstream build script): that script
|
||||
# assumes hacked/ is its own git checkout (it derives its version from `git
|
||||
# describe --tags`), which isn't true here - hacked/ is a vendored snapshot,
|
||||
# not a git submodule, so `git describe` there would silently pick up
|
||||
# *questshock's* tags instead. Version is a plain build-date string instead.
|
||||
set -euo pipefail
|
||||
|
||||
MODDING_ROOT="$(pwd)"
|
||||
OUT_DIR="$MODDING_ROOT/dist"
|
||||
SCRATCH_DIR="$MODDING_ROOT/build"
|
||||
HACKED_SRC="$SCRATCH_DIR/hacked"
|
||||
DIALOG_SRC="$SCRATCH_DIR/sqweek-dialog"
|
||||
|
||||
VERSION="hacked-$(date -u +%Y%m%d)"
|
||||
echo "Version: $VERSION"
|
||||
|
||||
echo "== Preparing scratch, patched copies of hacked/ and sqweek-dialog/ =="
|
||||
# hacked/ and sqweek-dialog/ are vendored unmodified (see README.md) - never
|
||||
# built or patched in place. hacked-patches/'s go.mod edit points
|
||||
# sqweek/dialog at ../sqweek-dialog (a sibling directory here), so both
|
||||
# scratch copies must keep that same relative layout.
|
||||
rm -rf "$SCRATCH_DIR"
|
||||
mkdir -p "$SCRATCH_DIR"
|
||||
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"/src/sqweek-dialog-patches/*.patch; do
|
||||
patch -p1 -d "$DIALOG_SRC" < "$p"
|
||||
done
|
||||
|
||||
rm -rf "$OUT_DIR"
|
||||
mkdir -p "$OUT_DIR/linux/hacked" "$OUT_DIR/win/hacked"
|
||||
|
||||
cd "$HACKED_SRC"
|
||||
|
||||
echo "== Building Linux amd64 =="
|
||||
go build -gcflags "-d=checkptr=0" -ldflags "-X main.version=$VERSION" \
|
||||
-trimpath -o "$OUT_DIR/linux/hacked/hacked" .
|
||||
|
||||
echo "== Preparing the Windows icon/version-info resource =="
|
||||
# cmd/link automatically links in a *.syso file sitting in the main package
|
||||
# directory during a Windows build - no explicit flag needed, but it does mean
|
||||
# generating it (via windres, from hacked.rc/.ico/.exe.manifest) has to happen
|
||||
# right here, before go build runs, and be cleaned up after regardless of
|
||||
# outcome so it never leaks into a Linux build run from the same checkout.
|
||||
WIN_RES_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WIN_RES_DIR"; rm -f "$HACKED_SRC/main-res.syso"' EXIT
|
||||
cp "$HACKED_SRC"/_resources/build/win/* "$WIN_RES_DIR/"
|
||||
# No git tag to derive a real version from (hacked/ is a vendored snapshot,
|
||||
# not its own checkout) - same date-based fallback hacked/build.sh itself
|
||||
# uses when `git describe` fails.
|
||||
sed -i "s/§MAJOR/$(date -u +%Y)/;s/§MINOR/$(date -u +%m)/;s/§PATCH/$(date -u +%d)/" \
|
||||
"$WIN_RES_DIR/hacked.exe.manifest"
|
||||
x86_64-w64-mingw32-windres -o "$HACKED_SRC/main-res.syso" "$WIN_RES_DIR/hacked.rc"
|
||||
|
||||
echo "== Building Windows amd64 (cross-compiled via mingw-w64) =="
|
||||
# -extldflags -static-libgcc/-static-libstdc++: statically links the mingw
|
||||
# C/C++ runtime into hacked.exe, the same technique the main project's own
|
||||
# Windows cross-build uses (see ../../build-image/Dockerfile) - avoids having
|
||||
# to locate and ship libgcc_s_seh-1.dll/libstdc++-6.dll (their exact path
|
||||
# depends on the mingw-w64 package's gcc version). libwinpthread-1.dll still
|
||||
# needs shipping alongside the .exe; it isn't part of that static link.
|
||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 \
|
||||
CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ \
|
||||
go build -gcflags "-d=checkptr=0" \
|
||||
-ldflags "-X main.version=$VERSION -H=windowsgui -extldflags '-static-libgcc -static-libstdc++'" \
|
||||
-trimpath -o "$OUT_DIR/win/hacked/hacked.exe" .
|
||||
cp /usr/x86_64-w64-mingw32/lib/libwinpthread-1.dll "$OUT_DIR/win/hacked/"
|
||||
|
||||
echo "== Copying distribution resources =="
|
||||
for os in linux win; do
|
||||
packageDir="$OUT_DIR/$os/hacked"
|
||||
cp "$HACKED_SRC/LICENSE" "$packageDir/"
|
||||
cp -R "$HACKED_SRC/_resources/dist/." "$packageDir/"
|
||||
done
|
||||
|
||||
echo "== Packaging =="
|
||||
# $VERSION (hacked-<date>) is already a complete, self-describing name - no
|
||||
# separate package-name prefix needed on top of it.
|
||||
(cd "$OUT_DIR/linux" && tar -czf "$OUT_DIR/$VERSION.linux64.tgz" hacked)
|
||||
(cd "$OUT_DIR/win" && zip -rq "$OUT_DIR/$VERSION.win64.zip" hacked)
|
||||
|
||||
echo "== Done =="
|
||||
echo "Linux: $OUT_DIR/$VERSION.linux64.tgz"
|
||||
echo "Windows: $OUT_DIR/$VERSION.win64.zip"
|
||||
@@ -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 (hacked/_build/, ...) 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 "$@"
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the hacked build-image against this folder to compile hacked/ (the
|
||||
# vendored InkyBlackness HackEd snapshot, see README.md) for Linux and
|
||||
# Windows, entirely offline - every Go module dependency it needs was already
|
||||
# fetched into the image by ./hacked-build-image.sh.
|
||||
#
|
||||
# Output lands in dist/ (both platforms, packaged as .tgz/.zip).
|
||||
#
|
||||
# Usage: ./hacked-build-run-image.sh [command...]
|
||||
# With no arguments, runs the image's baked-in build-hacked.sh (its default
|
||||
# CMD). 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]}")"
|
||||
ROOT="$(pwd)"
|
||||
|
||||
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||
|
||||
command -v docker >/dev/null 2>&1 \
|
||||
|| fail "docker not found in PATH"
|
||||
|
||||
IMAGE="${HACKED_REGISTRY_IMAGE:-questshock/hacked-builder}"
|
||||
if [ -f "$ROOT/../registry.env" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
source "$ROOT/../registry.env"
|
||||
IMAGE="${HACKED_REGISTRY_IMAGE:-$IMAGE}"
|
||||
fi
|
||||
|
||||
IMAGE_TAG="$(<"$ROOT/hacked-build-image/VERSION")"
|
||||
[ -n "$IMAGE_TAG" ] || fail "hacked-build-image/VERSION is empty"
|
||||
IMAGE="$IMAGE:$IMAGE_TAG"
|
||||
|
||||
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|
||||
|| fail "$IMAGE not found locally - run ./hacked-build-image.sh first"
|
||||
|
||||
TTY_FLAGS="-i"
|
||||
[ -t 1 ] && TTY_FLAGS="-it"
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
docker run --rm $TTY_FLAGS \
|
||||
-v "$ROOT:/workspace" \
|
||||
-e HOST_UID="$(id -u)" \
|
||||
-e HOST_GID="$(id -g)" \
|
||||
"$IMAGE" \
|
||||
"$@"
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pushes the hacked build-image - already built locally with
|
||||
# ./hacked-build-image.sh, and ideally verified with
|
||||
# ./hacked-build-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}"
|
||||
HACKED_REGISTRY_IMAGE="${HACKED_REGISTRY_IMAGE:-questshock/hacked-builder}"
|
||||
|
||||
VERSION="$(<"$ROOT/hacked-build-image/VERSION")"
|
||||
[ -n "$VERSION" ] || fail "hacked-build-image/VERSION is empty"
|
||||
|
||||
docker image inspect "$HACKED_REGISTRY_IMAGE:$VERSION" >/dev/null 2>&1 \
|
||||
|| fail "$HACKED_REGISTRY_IMAGE:$VERSION not found locally - run ./hacked-build-image.sh first"
|
||||
|
||||
echo "== Logging in to $REGISTRY =="
|
||||
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
|
||||
|
||||
echo "== Pushing $HACKED_REGISTRY_IMAGE:$VERSION and :latest =="
|
||||
docker push "$HACKED_REGISTRY_IMAGE:$VERSION"
|
||||
docker push "$HACKED_REGISTRY_IMAGE:latest"
|
||||
|
||||
echo "== Done =="
|
||||
echo "Image: $HACKED_REGISTRY_IMAGE:$VERSION"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,74 @@
|
||||
# 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
|
||||
|
||||
# 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 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" \
|
||||
&& 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
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -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 "$@"
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/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 "== 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 "$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
|
||||
MDLP_ARGS+=(-only "$MODS")
|
||||
fi
|
||||
|
||||
echo "== Processing mods =="
|
||||
"$BIN_DIR/mdlp" "${MDLP_ARGS[@]}"
|
||||
|
||||
echo "== Done =="
|
||||
echo "Output: $OUT_DIR"
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stages a scratch, patched copy of src/hacked/ at the given destination -
|
||||
# shared by process-mods.sh (mdlp) and ../gen_animgallery.sh (animgallery),
|
||||
# both of which build against hacked's source directly (via their own
|
||||
# go.mod's `replace ../hacked`) and so both need the same patches applied
|
||||
# that hacked-build-image/build-hacked.sh already applies for hacked's own
|
||||
# binary. src/hacked/ is never built or patched in place.
|
||||
#
|
||||
# Usage: stage-hacked.sh <modding-root> <dest-dir-for-hacked>
|
||||
set -euo pipefail
|
||||
|
||||
MODDING_ROOT="$1"
|
||||
DEST="$2"
|
||||
|
||||
rm -rf "$DEST"
|
||||
mkdir -p "$(dirname "$DEST")"
|
||||
cp -a "$MODDING_ROOT/src/hacked" "$DEST"
|
||||
for p in "$MODDING_ROOT"/src/hacked-patches/*.patch; do
|
||||
patch -p1 -d "$DEST" < "$p"
|
||||
done
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Upscales every PNG in one directory into another, via one ONNX Runtime
|
||||
CPU session shared across the whole batch (same reasoning as mdlp's own
|
||||
Go-side batching: the fixed per-invocation model-load cost otherwise
|
||||
dominates for the many small sprite frames one upscale definition has).
|
||||
|
||||
Runs on CPU only, not Vulkan: this Docker image has no real GPU, and
|
||||
Vulkan software emulation (Mesa lavapipe) gives wrong colors for ML
|
||||
inference workloads.
|
||||
|
||||
Usage: upscale_batch.py <model.onnx> <in-dir> <out-dir> <scale>
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def main():
|
||||
model_path, in_dir, out_dir, scale_arg = sys.argv[1:5]
|
||||
scale = int(scale_arg)
|
||||
|
||||
session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
|
||||
input_name = session.get_inputs()[0].name
|
||||
|
||||
names = sorted(n for n in os.listdir(in_dir) if n.lower().endswith(".png"))
|
||||
for name in names:
|
||||
img = Image.open(os.path.join(in_dir, name)).convert("RGBA")
|
||||
rgb = img.convert("RGB")
|
||||
alpha = img.split()[3]
|
||||
|
||||
# The model has no concept of transparency (3-channel RGB in/out) -
|
||||
# run it on the flattened RGB only, and scale the alpha mask
|
||||
# separately with a plain resize. Alpha edges don't need AI
|
||||
# enhancement, just to end up the right size.
|
||||
arr = np.asarray(rgb, dtype=np.float32) / 255.0
|
||||
arr = np.transpose(arr, (2, 0, 1))[np.newaxis, ...]
|
||||
output = session.run(None, {input_name: arr})[0]
|
||||
output = np.clip(output[0], 0.0, 1.0) * 255.0
|
||||
output = np.transpose(output, (1, 2, 0)).astype(np.uint8)
|
||||
upscaled_rgb = Image.fromarray(output, "RGB")
|
||||
|
||||
target_size = (img.width * scale, img.height * scale)
|
||||
if upscaled_rgb.size != target_size:
|
||||
# The model upscales at its own fixed native factor - resize to
|
||||
# whatever scale the mod actually asked for.
|
||||
upscaled_rgb = upscaled_rgb.resize(target_size, Image.LANCZOS)
|
||||
upscaled_alpha = alpha.resize(target_size, Image.LANCZOS)
|
||||
|
||||
result = Image.merge("RGBA", (*upscaled_rgb.split(), upscaled_alpha))
|
||||
result.save(os.path.join(out_dir, name))
|
||||
|
||||
print(f"upscaled {len(names)} frame(s)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,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" \
|
||||
"$@"
|
||||
@@ -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.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"model": "realesrgan-x4plus-anime",
|
||||
"scale": 2,
|
||||
"resources": [
|
||||
"0x583",
|
||||
"0x5CD",
|
||||
"0x617",
|
||||
"0x689",
|
||||
"0x68A",
|
||||
"0x68B",
|
||||
"0x68C",
|
||||
"0x68D",
|
||||
"0x68E",
|
||||
"0x68F",
|
||||
"0x690",
|
||||
"0x7B0",
|
||||
"0x7B1",
|
||||
"0x7B2",
|
||||
"0x7B3",
|
||||
"0x7B4",
|
||||
"0x7B5",
|
||||
"0x7B6",
|
||||
"0x7B7",
|
||||
"0x84C"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"model": "realesrgan-x4plus-anime",
|
||||
"scale": 2,
|
||||
"resources": [
|
||||
"0x580",
|
||||
"0x5A5",
|
||||
"0x5CA",
|
||||
"0x5EF",
|
||||
"0x614",
|
||||
"0x671",
|
||||
"0x672",
|
||||
"0x673",
|
||||
"0x674",
|
||||
"0x675",
|
||||
"0x676",
|
||||
"0x677",
|
||||
"0x678",
|
||||
"0x798",
|
||||
"0x799",
|
||||
"0x79A",
|
||||
"0x79B",
|
||||
"0x79C",
|
||||
"0x79D",
|
||||
"0x79E",
|
||||
"0x79F",
|
||||
"0x849"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"model": "realesrgan-x4plus-anime",
|
||||
"scale": 2,
|
||||
"resources": [
|
||||
"0x578",
|
||||
"0x59D",
|
||||
"0x5C2",
|
||||
"0x5E7",
|
||||
"0x60C",
|
||||
"0x631",
|
||||
"0x632",
|
||||
"0x633",
|
||||
"0x634",
|
||||
"0x635",
|
||||
"0x636",
|
||||
"0x637",
|
||||
"0x638",
|
||||
"0x661",
|
||||
"0x662",
|
||||
"0x663",
|
||||
"0x664",
|
||||
"0x665",
|
||||
"0x666",
|
||||
"0x667",
|
||||
"0x668",
|
||||
"0x758",
|
||||
"0x759",
|
||||
"0x75A",
|
||||
"0x75B",
|
||||
"0x75C",
|
||||
"0x75D",
|
||||
"0x75E",
|
||||
"0x75F",
|
||||
"0x841"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
module questshock/animgallery
|
||||
|
||||
go 1.25.0
|
||||
|
||||
// hacked/ is vendored locally (see ../../README.md); animgallery reuses its
|
||||
// resource decoding code (ss1/world, ss1/content/bitmap) instead of
|
||||
// duplicating System Shock's file formats.
|
||||
require github.com/inkyblackness/hacked v0.0.0
|
||||
|
||||
require github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
|
||||
replace github.com/inkyblackness/hacked => ../hacked
|
||||
@@ -0,0 +1,10 @@
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
@@ -0,0 +1,595 @@
|
||||
// Command animgallery scans a game data directory for every multi-frame
|
||||
// bitmap resource (both dedicated "Animation" descriptors, e.g. video mail,
|
||||
// and plain multi-block compound bitmaps, e.g. critter sprites - System
|
||||
// Shock uses both conventions) and renders each one as an animated GIF plus
|
||||
// an HTML index page, so they can be browsed by eye instead of guessed at
|
||||
// by resource ID.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"html"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/world"
|
||||
"github.com/inkyblackness/hacked/ss1/world/ids"
|
||||
)
|
||||
|
||||
// galleryEntry describes one rendered animation for the HTML index.
|
||||
type galleryEntry struct {
|
||||
id resource.ID
|
||||
lang resource.Language
|
||||
sourceFile string
|
||||
kind string
|
||||
frameCount int
|
||||
width int
|
||||
height int
|
||||
transparent bool
|
||||
gifName string
|
||||
}
|
||||
|
||||
// dataDirs collects repeated -data flags, in the order given - later ones
|
||||
// take priority over earlier ones for any resource ID they both define,
|
||||
// same as System Shock's own overlay convention. This lets a mod's build
|
||||
// output (modding/dist/mods/<name>/mod/) be layered on top of the base
|
||||
// game assets to preview the combined, as-played result.
|
||||
type dataDirs []string
|
||||
|
||||
func (d *dataDirs) String() string { return strings.Join(*d, ",") }
|
||||
func (d *dataDirs) Set(v string) error { *d = append(*d, v); return nil }
|
||||
|
||||
// loadDataDir merges every .res/.dat file directly inside dir into one
|
||||
// manifest entry, bypassing hacked's own fileAllowlist
|
||||
// (ss1/world/FileLoader.go): that check is only skipped when a file is
|
||||
// passed directly as the sole argument, not when given a directory to
|
||||
// scan, so a directory containing a resource file with a name it doesn't
|
||||
// recognize (like mdlp's own "unknown.res" fallback - see
|
||||
// [[project_modding_hacked_build]] memory) is otherwise silently treated
|
||||
// as if it had no resources at all. The real game engine has no such
|
||||
// restriction - it matches any .res/.dat file by extension alone
|
||||
// (engine/src/MacSrc/Modding.c's StringEndsWith), which this mirrors.
|
||||
func loadDataDir(dir string) (*world.ManifestEntry, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
merged := &world.ManifestEntry{Origin: []string{dir}, ID: dir}
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||
if ext != ".res" && ext != ".dat" {
|
||||
continue
|
||||
}
|
||||
fileEntry, err := world.NewManifestEntryFrom([]string{filepath.Join(dir, e.Name())})
|
||||
if err != nil {
|
||||
// Not every .res/.dat file is actually resource-formatted (e.g.
|
||||
// ambrtabl.dat is a raw lookup table) - hacked's own
|
||||
// fileAllowlist silently protects against exactly this for
|
||||
// canonical filenames, but bypassing it here (the whole point,
|
||||
// for names like "unknown.res" it doesn't recognize) means we
|
||||
// have to tolerate that ourselves instead: skip a file that
|
||||
// doesn't parse rather than failing the whole directory over it.
|
||||
continue
|
||||
}
|
||||
merged.Resources = append(merged.Resources, fileEntry.Resources...)
|
||||
if len(fileEntry.ObjectProperties) > 0 {
|
||||
merged.ObjectProperties = fileEntry.ObjectProperties
|
||||
}
|
||||
if len(fileEntry.TextureProperties) > 0 {
|
||||
merged.TextureProperties = fileEntry.TextureProperties
|
||||
}
|
||||
found = true
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("no .res/.dat files found in %s", dir)
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
var dirs dataDirs
|
||||
flag.Var(&dirs, "data", "directory with .res/.dat files - repeat to layer several, later wins (e.g. base game assets, then a built mod's mod/ folder)")
|
||||
outDir := flag.String("out", "gallery", "directory to write index.html and every .gif into")
|
||||
minFrames := flag.Int("min-frames", 2, "skip plain multi-block bitmaps with fewer blocks than this (Animation-type resources are always included)")
|
||||
onlyChanged := flag.Bool("only-changed", false, "skip resources whose winning -data layer is the first one - pass a base layer followed by one or more mod layers to preview only what those later layers change, in the base layer's own colors")
|
||||
flag.Parse()
|
||||
|
||||
if len(dirs) == 0 {
|
||||
log.Fatal("-data is required (repeat it to layer several directories)")
|
||||
}
|
||||
absOutDir, err := filepath.Abs(*outDir)
|
||||
if err != nil {
|
||||
log.Fatalf("resolving -out: %v", err)
|
||||
}
|
||||
gifDir := filepath.Join(absOutDir, "gifs")
|
||||
if err := os.MkdirAll(gifDir, 0o755); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
mod := world.NewMod(func([]resource.ID, []resource.ID) {}, func() {})
|
||||
// sourceFileByKey records, per (language, id), which directory's
|
||||
// resource "wins" after layering - the same last-one-wins rule
|
||||
// Manifest.Filter itself uses for entries inserted in this order.
|
||||
// sourceDirIndexByKey records the same winner as an index into dirs,
|
||||
// for -only-changed to tell a first-layer (base) resource from one a
|
||||
// later layer (a mod) overrides.
|
||||
sourceFileByKey := make(map[resource.Key]string)
|
||||
sourceDirIndexByKey := make(map[resource.Key]int)
|
||||
for dirIndex, dir := range dirs {
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
log.Fatalf("resolving -data %q: %v", dir, err)
|
||||
}
|
||||
entry, err := loadDataDir(absDir)
|
||||
if err != nil {
|
||||
// A mod that doesn't touch any resource file yet (e.g. an
|
||||
// upscale mod before mdlp supports that content type) produces
|
||||
// an empty build directory - skip it rather than aborting the
|
||||
// whole layered scan over one contributing -data directory.
|
||||
log.Printf("skipping -data %s: %v", absDir, err)
|
||||
continue
|
||||
}
|
||||
// NewManifestEntryFrom builds entry.Resources by ranging over a Go
|
||||
// map, so its order (and therefore which file "wins" a Selector's
|
||||
// last-one-wins tie-break for any resource ID more than one file
|
||||
// defines) is randomized per process run. Sort it ourselves so the
|
||||
// same input always resolves the same way.
|
||||
sort.Slice(entry.Resources, func(i, j int) bool { return entry.Resources[i].ID < entry.Resources[j].ID })
|
||||
if err := mod.World().InsertEntry(mod.World().EntryCount(), entry); err != nil {
|
||||
log.Fatalf("registering game data from %s: %v", absDir, err)
|
||||
}
|
||||
for _, loc := range entry.Resources {
|
||||
for _, id := range loc.Viewer.IDs() {
|
||||
key := resource.KeyOf(id, loc.Language, 0)
|
||||
sourceFileByKey[key] = fmt.Sprintf("%s (%s)", loc.ID, filepath.Base(absDir))
|
||||
sourceDirIndexByKey[key] = dirIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
palCache := bitmap.NewPaletteCache(mod)
|
||||
defaultPal, err := palCache.Palette(resource.KeyOf(ids.GamePalettesStart, resource.LangDefault, 0))
|
||||
if err != nil {
|
||||
log.Printf("warning: could not load the default game palette (%v); falling back to grayscale", err)
|
||||
}
|
||||
|
||||
keys := make([]resource.Key, 0, len(sourceFileByKey))
|
||||
for key := range sourceFileByKey {
|
||||
if *onlyChanged && sourceDirIndexByKey[key] == 0 {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].ID != keys[j].ID {
|
||||
return keys[i].ID < keys[j].ID
|
||||
}
|
||||
return keys[i].Lang < keys[j].Lang
|
||||
})
|
||||
|
||||
var entries []galleryEntry
|
||||
for _, key := range keys {
|
||||
sourceFile := sourceFileByKey[key]
|
||||
// Re-resolved through the layered mod, not the raw per-directory
|
||||
// viewer, so an overriding mod's version wins here exactly like it
|
||||
// would for the running game.
|
||||
view, err := mod.LocalizedResources(key.Lang).Select(key.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case view.ContentType() == resource.Animation:
|
||||
entry, err := safeRenderAnimationDescriptor(mod, defaultPal, sourceFile, key.ID, key.Lang, gifDir)
|
||||
if err != nil {
|
||||
log.Printf("skipping animation %s (%s): %v", key.ID, sourceFile, err)
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
|
||||
case view.ContentType() == resource.Bitmap && view.BlockCount() >= *minFrames:
|
||||
entry, err := safeRenderMultiBlockBitmap(view, defaultPal, sourceFile, key.ID, key.Lang, gifDir)
|
||||
if err != nil {
|
||||
log.Printf("skipping bitmap %s (%s): %v", key.ID, sourceFile, err)
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].id != entries[j].id {
|
||||
return entries[i].id < entries[j].id
|
||||
}
|
||||
return entries[i].lang < entries[j].lang
|
||||
})
|
||||
|
||||
indexPath := filepath.Join(absOutDir, "index.html")
|
||||
if err := writeIndex(indexPath, entries); err != nil {
|
||||
log.Fatalf("writing %s: %v", indexPath, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d animated resource(s).\n", len(entries))
|
||||
fmt.Printf("Index: %s\n", indexPath)
|
||||
}
|
||||
|
||||
// decodeFrames reads every block of a compound bitmap resource as a
|
||||
// sequence of frames, each one RLE-diffed against the previous - the same
|
||||
// chaining hacked's own editor/animations/View.go uses when importing a
|
||||
// GIF, done here in reverse (decode instead of encode).
|
||||
func decodeFrames(view resource.View) ([]*bitmap.Bitmap, error) {
|
||||
count := view.BlockCount()
|
||||
frames := make([]*bitmap.Bitmap, 0, count)
|
||||
var prevPixels []byte
|
||||
for i := 0; i < count; i++ {
|
||||
reader, err := view.Block(i)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||
}
|
||||
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||
size := int(w) * int(h)
|
||||
buf := make([]byte, size)
|
||||
if len(prevPixels) == size {
|
||||
copy(buf, prevPixels)
|
||||
}
|
||||
return buf, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("block %d: %w", i, err)
|
||||
}
|
||||
prevPixels = bmp.Pixels
|
||||
frames = append(frames, bmp)
|
||||
}
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
func paletteFor(bmp *bitmap.Bitmap, fallback bitmap.Palette) color.Palette {
|
||||
pal := fallback
|
||||
if bmp.Palette != nil {
|
||||
pal = *bmp.Palette
|
||||
}
|
||||
return pal.ColorPalette(false)
|
||||
}
|
||||
|
||||
func writeGIF(path string, frames []*bitmap.Bitmap, fallbackPal bitmap.Palette, delays []int) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
maxWidth, maxHeight := 0, 0
|
||||
for _, bmp := range frames {
|
||||
if int(bmp.Header.Width) > maxWidth {
|
||||
maxWidth = int(bmp.Header.Width)
|
||||
}
|
||||
if int(bmp.Header.Height) > maxHeight {
|
||||
maxHeight = int(bmp.Header.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// LoopCount: 0 means loop forever in Go's image/gif API - inverted from
|
||||
// the raw GIF format's own NETSCAPE2.0 convention. -1 (this package's
|
||||
// zero value) means play once, which is why these looped only once
|
||||
// before this fix.
|
||||
out := gif.GIF{LoopCount: 0, Config: image.Config{Width: maxWidth, Height: maxHeight}}
|
||||
for i, bmp := range frames {
|
||||
colorPal := paletteFor(bmp, fallbackPal)
|
||||
rect := image.Rect(0, 0, int(bmp.Header.Width), int(bmp.Header.Height))
|
||||
img := image.NewPaletted(rect, colorPal)
|
||||
if len(bmp.Pixels) == len(img.Pix) {
|
||||
copy(img.Pix, bmp.Pixels)
|
||||
}
|
||||
out.Image = append(out.Image, img)
|
||||
out.Delay = append(out.Delay, delays[i])
|
||||
}
|
||||
return gif.EncodeAll(f, &out)
|
||||
}
|
||||
|
||||
// safeRenderMultiBlockBitmap recovers from panics in hacked's own decode
|
||||
// code (malformed or non-image compound resources can crash rle.Decompress
|
||||
// rather than return an error) so one bad resource never aborts a scan that
|
||||
// is meant to be re-run repeatedly during modding.
|
||||
func safeRenderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (entry galleryEntry, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return renderMultiBlockBitmap(view, fallbackPal, sourceFile, id, lang, outDir)
|
||||
}
|
||||
|
||||
func renderMultiBlockBitmap(view resource.View, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (galleryEntry, error) {
|
||||
frames, err := decodeFrames(view)
|
||||
if err != nil {
|
||||
return galleryEntry{}, err
|
||||
}
|
||||
if len(frames) == 0 {
|
||||
return galleryEntry{}, fmt.Errorf("no frames")
|
||||
}
|
||||
delays := make([]int, len(frames))
|
||||
for i := range delays {
|
||||
delays[i] = 10 // 100ms - no explicit timing exists for this raw form
|
||||
}
|
||||
gifName := fmt.Sprintf("%04X_%s.gif", int(id), lang.String())
|
||||
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays); err != nil {
|
||||
return galleryEntry{}, err
|
||||
}
|
||||
return galleryEntry{
|
||||
id: id, lang: lang, sourceFile: sourceFile, kind: "multi-block bitmap",
|
||||
frameCount: len(frames),
|
||||
width: int(frames[0].Header.Width), height: int(frames[0].Header.Height),
|
||||
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
||||
gifName: gifName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// safeRenderAnimationDescriptor is safeRenderMultiBlockBitmap's counterpart
|
||||
// for the Animation-descriptor path.
|
||||
func safeRenderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (entry galleryEntry, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return renderAnimationDescriptor(mod, fallbackPal, sourceFile, id, lang, outDir)
|
||||
}
|
||||
|
||||
func renderAnimationDescriptor(mod *world.Mod, fallbackPal bitmap.Palette, sourceFile string, id resource.ID, lang resource.Language, outDir string) (galleryEntry, error) {
|
||||
view, err := mod.LocalizedResources(lang).Select(id)
|
||||
if err != nil {
|
||||
return galleryEntry{}, err
|
||||
}
|
||||
reader, err := view.Block(0)
|
||||
if err != nil {
|
||||
return galleryEntry{}, err
|
||||
}
|
||||
anim, err := bitmap.ReadAnimation(reader)
|
||||
if err != nil {
|
||||
return galleryEntry{}, err
|
||||
}
|
||||
if len(anim.Entries) == 0 {
|
||||
return galleryEntry{}, fmt.Errorf("no animation entries")
|
||||
}
|
||||
|
||||
lastFrame := 0
|
||||
for _, entry := range anim.Entries {
|
||||
if int(entry.LastFrame) > lastFrame {
|
||||
lastFrame = int(entry.LastFrame)
|
||||
}
|
||||
}
|
||||
|
||||
var frames []*bitmap.Bitmap
|
||||
var delays []int
|
||||
var prevPixels []byte
|
||||
frameIndex := 0
|
||||
for _, entry := range anim.Entries {
|
||||
for frameIndex <= int(entry.LastFrame) {
|
||||
frameKey := resource.KeyOf(anim.ResourceID, lang, frameIndex)
|
||||
frameView, err := mod.LocalizedResources(lang).Select(frameKey.ID)
|
||||
if err != nil {
|
||||
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||
}
|
||||
reader, err := frameView.Block(0)
|
||||
if err != nil {
|
||||
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||
}
|
||||
bmp, err := bitmap.DecodeReferenced(reader, func(w, h int16) ([]byte, error) {
|
||||
size := int(w) * int(h)
|
||||
buf := make([]byte, size)
|
||||
if len(prevPixels) == size {
|
||||
copy(buf, prevPixels)
|
||||
}
|
||||
return buf, nil
|
||||
})
|
||||
if err != nil {
|
||||
return galleryEntry{}, fmt.Errorf("frame %d: %w", frameIndex, err)
|
||||
}
|
||||
prevPixels = bmp.Pixels
|
||||
frames = append(frames, bmp)
|
||||
delay := int(entry.FrameTime) / 10
|
||||
if delay <= 0 {
|
||||
delay = 10
|
||||
}
|
||||
delays = append(delays, delay)
|
||||
frameIndex++
|
||||
}
|
||||
}
|
||||
if len(frames) == 0 {
|
||||
return galleryEntry{}, fmt.Errorf("no frames decoded")
|
||||
}
|
||||
|
||||
gifName := fmt.Sprintf("%04X_%s.gif", int(id), lang.String())
|
||||
if err := writeGIF(filepath.Join(outDir, gifName), frames, fallbackPal, delays); err != nil {
|
||||
return galleryEntry{}, err
|
||||
}
|
||||
return galleryEntry{
|
||||
id: id, lang: lang, sourceFile: sourceFile, kind: "animation descriptor",
|
||||
frameCount: len(frames),
|
||||
width: int(anim.Width), height: int(anim.Height),
|
||||
transparent: frames[0].Header.Flags&bitmap.FlagTransparent != 0,
|
||||
gifName: gifName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// contentTypeName maps a galleryEntry.kind back to the resource.ContentType
|
||||
// name mdlp will need (its own decode path differs: an "Animation"
|
||||
// descriptor's frames live at anim.ResourceID+N, a "Bitmap"'s frames are
|
||||
// just its own blocks) - see kind's assignment in renderAnimationDescriptor/
|
||||
// renderMultiBlockBitmap.
|
||||
func contentTypeName(kind string) string {
|
||||
if kind == "animation descriptor" {
|
||||
return "Animation"
|
||||
}
|
||||
return "Bitmap"
|
||||
}
|
||||
|
||||
// mdlResourceID formats the resourceId string an MDL upscale definition
|
||||
// uses to identify one resource: a resource ID alone is not always unique -
|
||||
// e.g. 0x25 exists as a separate resource per language - so a non-Any
|
||||
// language is appended (0x25_German). The bare form (0xA54) implies
|
||||
// _Any, the language nearly everything we care about upscaling (critter
|
||||
// art, etc.) actually uses.
|
||||
func mdlResourceID(id resource.ID, lang resource.Language) string {
|
||||
if lang == resource.LangAny {
|
||||
return fmt.Sprintf("0x%X", int(id))
|
||||
}
|
||||
return fmt.Sprintf("0x%X_%s", int(id), lang.String())
|
||||
}
|
||||
|
||||
func writeIndex(path string, entries []galleryEntry) error {
|
||||
var b strings.Builder
|
||||
b.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>System Shock animation gallery</title>")
|
||||
b.WriteString("<style>body{font-family:sans-serif;background:#111;color:#eee}")
|
||||
b.WriteString("#controls{position:sticky;top:0;background:#181818;padding:10px;z-index:10;border-bottom:1px solid #444}")
|
||||
b.WriteString("#controls label{margin-right:12px}")
|
||||
b.WriteString("#mdl-output{width:98%;font-family:monospace;font-size:12px;margin-top:8px;background:#000;color:#9f9;border:1px solid #444}")
|
||||
b.WriteString(".card{display:inline-block;margin:8px;padding:8px;background:#222;vertical-align:top}")
|
||||
b.WriteString(".card img{image-rendering:pixelated;background:#000;display:block}")
|
||||
b.WriteString(".meta{font-size:12px;color:#aaa;margin-top:4px}")
|
||||
b.WriteString(".meta label{cursor:pointer}")
|
||||
b.WriteString("#basket{min-height:60px;background:#1a2a1a;border:1px dashed #4a6a4a;padding:8px;margin-bottom:16px}")
|
||||
b.WriteString("#basket:empty::before{content:'(nothing selected yet - check animations below to add them here)';color:#678;font-style:italic}")
|
||||
b.WriteString("#basket .card{background:#2a3a2a}</style></head><body>")
|
||||
|
||||
b.WriteString(`<div id="controls">` +
|
||||
`<label>Model: <select id="model">` +
|
||||
`<option>realesrgan-x4plus-anime</option>` +
|
||||
`<option>realesrgan-x4plus</option>` +
|
||||
`<option>realesr-animevideov3</option>` +
|
||||
`</select></label>` +
|
||||
`<label>Scale: <input id="scale" type="number" value="2" min="1" max="4" style="width:4em"></label>` +
|
||||
`<button id="clear-sel" type="button">Clear selection</button>` +
|
||||
`<br><label>Filter: <input type="text" id="search" placeholder="id, language, file, transparent..." style="width:22em"></label>` +
|
||||
`<span id="shown-count"></span>` +
|
||||
`<textarea id="mdl-output" rows="12" readonly placeholder="Select animations below to generate an MDL upscale definition here - copy it into modding/mods/<name>/upscale/<label>.json"></textarea>` +
|
||||
`</div>`)
|
||||
|
||||
b.WriteString(`<h2>Selected (<span id="basket-count">0</span>)</h2><div id="basket"></div>`)
|
||||
|
||||
fmt.Fprintf(&b, "<h1>%d animated resource(s)</h1>", len(entries))
|
||||
|
||||
// Animation descriptors and multi-block bitmaps are genuinely distinct
|
||||
// content types (different decode path, see contentTypeName) - grouped
|
||||
// into separate sections rather than interleaved.
|
||||
var animations, bitmaps []galleryEntry
|
||||
for _, e := range entries {
|
||||
if contentTypeName(e.kind) == "Animation" {
|
||||
animations = append(animations, e)
|
||||
} else {
|
||||
bitmaps = append(bitmaps, e)
|
||||
}
|
||||
}
|
||||
|
||||
writeCards := func(group []galleryEntry, idPrefix string) {
|
||||
for i, e := range group {
|
||||
transparency := "opaque"
|
||||
if e.transparent {
|
||||
transparency = "transparent"
|
||||
}
|
||||
search := strings.ToLower(strings.Join([]string{
|
||||
fmt.Sprintf("0x%x", int(e.id)), fmt.Sprintf("%d", int(e.id)),
|
||||
e.lang.String(), e.kind, e.sourceFile,
|
||||
fmt.Sprintf("%dx%d", e.width, e.height), transparency,
|
||||
}, " "))
|
||||
fmt.Fprintf(&b, "<div class=\"card\" data-search=\"%s\">", html.EscapeString(search))
|
||||
fmt.Fprintf(&b, "<img src=\"gifs/%s\" width=\"%d\" height=\"%d\">", html.EscapeString(e.gifName), e.width*2, e.height*2)
|
||||
fmt.Fprintf(&b, "<div class=\"meta\"><label><input type=\"checkbox\" class=\"sel\" id=\"sel-%s-%d\" data-id=\"%s\"> ",
|
||||
idPrefix, i, html.EscapeString(mdlResourceID(e.id, e.lang)))
|
||||
fmt.Fprintf(&b, "ID 0x%X (%d) · %s</label><br>%dx%d, %d frame(s), %s · %s · %s</div>",
|
||||
int(e.id), int(e.id), html.EscapeString(e.lang.String()),
|
||||
e.width, e.height, e.frameCount, transparency, html.EscapeString(e.kind), html.EscapeString(e.sourceFile))
|
||||
b.WriteString("</div>")
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "<h2>Animations (%d)</h2>", len(animations))
|
||||
writeCards(animations, "a")
|
||||
fmt.Fprintf(&b, "<h2>Bitmaps (%d)</h2>", len(bitmaps))
|
||||
writeCards(bitmaps, "b")
|
||||
|
||||
b.WriteString(`<script>
|
||||
var basket = document.getElementById('basket');
|
||||
// Where each card lives when not selected, so unchecking it puts it back
|
||||
// in its original spot (Animations/Bitmaps section, original order)
|
||||
// instead of just appending it back at the end.
|
||||
var cardHomes = new Map();
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.card'), function (card) {
|
||||
cardHomes.set(card, { parent: card.parentNode, next: card.nextSibling });
|
||||
});
|
||||
|
||||
function moveCard(checkbox) {
|
||||
var card = checkbox.closest('.card');
|
||||
if (checkbox.checked) {
|
||||
basket.appendChild(card);
|
||||
} else {
|
||||
var home = cardHomes.get(card);
|
||||
home.parent.insertBefore(card, home.next);
|
||||
}
|
||||
document.getElementById('basket-count').textContent = basket.children.length;
|
||||
}
|
||||
|
||||
function updateMdl() {
|
||||
var model = document.getElementById('model').value;
|
||||
var scale = parseInt(document.getElementById('scale').value, 10) || 2;
|
||||
var boxes = Array.prototype.filter.call(document.querySelectorAll('.sel'), function (b) { return b.checked; });
|
||||
// A bare resource ID isn't always unique (e.g. 0x25 exists as a separate
|
||||
// resource per language), so each entry here is "0x<ID>" or, when the
|
||||
// resource's language isn't Any, "0x<ID>_<Language>" - mdlp resolves the
|
||||
// rest (content type, etc.) from the resource itself. The MDL
|
||||
// definition only needs what it can't derive: the shared upscale
|
||||
// parameters, and which resources to apply them to.
|
||||
var mdl = {
|
||||
model: model,
|
||||
scale: scale,
|
||||
resources: boxes.map(function (b) { return b.dataset.id; })
|
||||
};
|
||||
document.getElementById('mdl-output').value = JSON.stringify(mdl, null, 2);
|
||||
}
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.sel'), function (b) {
|
||||
b.addEventListener('change', function () { moveCard(b); updateMdl(); });
|
||||
});
|
||||
document.getElementById('model').addEventListener('change', updateMdl);
|
||||
document.getElementById('scale').addEventListener('input', updateMdl);
|
||||
document.getElementById('clear-sel').addEventListener('click', function () {
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.sel'), function (b) {
|
||||
if (b.checked) {
|
||||
b.checked = false;
|
||||
moveCard(b);
|
||||
}
|
||||
});
|
||||
updateMdl();
|
||||
});
|
||||
updateMdl();
|
||||
|
||||
function applyFilter() {
|
||||
var query = document.getElementById('search').value.trim().toLowerCase();
|
||||
var cards = document.querySelectorAll('.card');
|
||||
var shown = 0;
|
||||
Array.prototype.forEach.call(cards, function (card) {
|
||||
var match = !query || card.dataset.search.indexOf(query) !== -1;
|
||||
card.style.display = match ? '' : 'none';
|
||||
if (match) shown++;
|
||||
});
|
||||
document.getElementById('shown-count').textContent = shown + ' / ' + cards.length + ' shown';
|
||||
}
|
||||
document.getElementById('search').addEventListener('input', applyFilter);
|
||||
applyFilter();
|
||||
</script>`)
|
||||
|
||||
b.WriteString("</body></html>")
|
||||
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
--- a/go.mod
|
||||
+++ b/go.mod
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
go 1.25.0
|
||||
|
||||
+// Local, patched copy fixing a GTK3/GLFW X11 crash on Linux - see
|
||||
+// ../sqweek-dialog-patches/ and README.md.
|
||||
+replace github.com/sqweek/dialog => ../sqweek-dialog
|
||||
+
|
||||
require (
|
||||
github.com/asticode/go-astisub v0.12.1
|
||||
github.com/go-gl/gl v0.0.0-20210315015930-ae072cafe09d
|
||||
@@ -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 {
|
||||
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.{cmd,[cC][mM][dD]} text eol=crlf
|
||||
*.{bat,[bB][aA][tT]} text eol=crlf
|
||||
@@ -0,0 +1,5 @@
|
||||
.idea/
|
||||
_build/
|
||||
|
||||
imgui.ini
|
||||
*.syso
|
||||
@@ -0,0 +1,84 @@
|
||||
run:
|
||||
deadline: 10m
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- asciicheck
|
||||
- bodyclose
|
||||
- deadcode
|
||||
- depguard
|
||||
- dogsled
|
||||
- dupl
|
||||
- durationcheck
|
||||
- errcheck
|
||||
- errorlint
|
||||
- exportloopref
|
||||
- forbidigo
|
||||
- goconst
|
||||
- gocritic
|
||||
- godot
|
||||
- godox
|
||||
- goerr113
|
||||
- gofmt
|
||||
- goheader
|
||||
- gomoddirectives
|
||||
- gomodguard
|
||||
- goprintffuncname
|
||||
- gosimple
|
||||
- govet
|
||||
- importas
|
||||
- ineffassign
|
||||
- lll
|
||||
- makezero
|
||||
- misspell
|
||||
- nilerr
|
||||
- noctx
|
||||
- nolintlint
|
||||
- predeclared
|
||||
- promlinter
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
- staticcheck
|
||||
- structcheck
|
||||
- tagliatelle
|
||||
- testpackage
|
||||
- thelper
|
||||
- tparallel
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- varcheck
|
||||
- wastedassign
|
||||
- whitespace
|
||||
|
||||
# linters are disabled if their majority of issues is considered false-positive (intended code)
|
||||
# and the remaining issues (if existing) aren't worth it.
|
||||
disable:
|
||||
- gochecknoglobals
|
||||
- gochecknoinits
|
||||
- goimports # disabled because of so many false-positives with "imgui-go"
|
||||
- gosec
|
||||
- maligned
|
||||
- nakedret
|
||||
- prealloc
|
||||
- unparam
|
||||
- wsl
|
||||
- funlen
|
||||
- gocyclo # disabled for UI code
|
||||
- nestif # disabled for UI code
|
||||
- gocognit
|
||||
- stylecheck # enable again for doing all the package documentation.
|
||||
- gomnd # too many false positives with constants themselves.
|
||||
|
||||
issues:
|
||||
exclude:
|
||||
# too often for OpenGL constants
|
||||
- don't use ALL_CAPS in Go names
|
||||
exclude-use-default: false # disable filtering of defaults for better zero-issue policy
|
||||
max-per-linter: 0 # disable limit; report all issues of a linter
|
||||
max-same-issues: 0 # disable limit; report all issues of the same issue
|
||||
|
||||
linters-settings:
|
||||
lll:
|
||||
line-length: 160
|
||||
@@ -0,0 +1,38 @@
|
||||
## Contributing Guidelines
|
||||
|
||||
Thank you for considering to contribute to this application!
|
||||
|
||||
The following text lists guidelines for contributions.
|
||||
These guidelines don't have legal status, so use them as a reference and common sense - and feel free to update them as well!
|
||||
|
||||
|
||||
### "I just want to know..."
|
||||
|
||||
For questions, or general usage-information about HackEd, please refer to the [systemshock.org](https://systemshock.org) forums (`Engineering` subforum for modding in general, `Deck 13` for source development, including engine code), or refer to the [Wiki](https://github.com/inkyblackness/hacked/wiki).
|
||||
|
||||
You might also find information on the data files in the [ss-specs](https://github.com/inkyblackness/ss-specs) project.
|
||||
|
||||
|
||||
### Scope
|
||||
|
||||
This is an editor for mods and fan-missions of the video game System Shock (1994, and compatible), written in Go.
|
||||
|
||||
If there are extensions to the data files, please be sure that these were discussed and/or documented in `ss-specs`. Usability features are always welcome, I only ask to give them a second thought if there might be a more generic solution (i.e., don't immediately jump to the implementation of "I need X", rather think how a slightly different feature might both give you X and also Y in even a simpler form...)
|
||||
|
||||
Please also consider engine compatibility. If there are engine features not supported by all (especially the classic version), please make sure these are indicated to the user. Also refer to the documentation on the Wiki for such cases.
|
||||
|
||||
|
||||
### Code Style
|
||||
|
||||
Please make sure code is formatted according to `go fmt`, and use the following linter: [golangci-lint](https://github.com/golangci/golangci-lint).
|
||||
|
||||
> If there are linter errors that you didn't introduce, you don't have to clean them up - I might have missed them and will be handling them separately.
|
||||
|
||||
|
||||
### Testing Style
|
||||
|
||||
The code under `ss1` should be well tested, please try to keep up coverage. The code under `editor` is tricky to test - if you figure out how to test ImGui based stuff, I'd be happy for a pull request! At least utilities should have tests.
|
||||
|
||||
> I am aiming for a coverage of over 90% in there.
|
||||
|
||||
For testing use either the vanilla `testing` package, or augment it with the `github.com/stretchr/testify` framework. See existing examples.
|
||||
@@ -0,0 +1,27 @@
|
||||
New BSD License
|
||||
|
||||
© 2018, Christian Haas
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of "InkyBlackness" nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE LISTED COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,28 @@
|
||||
# InkyBlackness HackEd
|
||||
|
||||
HackEd is the main graphical editor application as part of the [InkyBlackness](https://inkyblackness.github.io) project, written in [Go](http://golang.org/). It allows editing of game files of the original System Shock (1994 and compatible) to create mods and fan-missions.
|
||||
|
||||
This application is the successor of the `deck` package of InkyBlackness, which provided the first public editor, even before the source release of the original engine. The rewrite was necessary due to several factors.
|
||||
|
||||
The [releases page](https://github.com/inkyblackness/hacked/releases) provides the latest binaries for both MS Windows and Linux.
|
||||
|
||||
## Documentation
|
||||
|
||||
Usage of the editor is documented on its [Wiki](https://github.com/inkyblackness/hacked/wiki) page.
|
||||
Answers and further help about modding can be furthermore found on the [systemshock.org](https://systemshock.org) forums, particularly the `Engineering` subforum.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Level editing details:
|
||||

|
||||
|
||||
Media editing details:
|
||||

|
||||
|
||||
4K glory showing all the windows:
|
||||

|
||||
|
||||
|
||||
## License
|
||||
|
||||
The project is available under the terms of the **New BSD License** (see LICENSE file).
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="InkyBlackness.hacked"
|
||||
version="§MAJOR.§MINOR.§PATCH.0"
|
||||
/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0"
|
||||
processorArchitecture="*"
|
||||
publicKeyToken="6595b64144ccf1df"
|
||||
language="*"
|
||||
/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,3 @@
|
||||
100 ICON "hacked.ico"
|
||||
100 24 "hacked.exe.manifest"
|
||||
GLFW_ICON ICON "hacked.ico"
|
||||
@@ -0,0 +1,17 @@
|
||||
# InkyBlackness - HackEd
|
||||
|
||||
This package contains the public distribution of the [InkyBlackness](https://inkyblackness.github.io) HackEd binary.
|
||||
|
||||
The executable is a modding tool for the DOS game [System Shock (1994)](http://en.wikipedia.org/wiki/System_Shock) and compatibles.
|
||||
|
||||
## Disclaimer
|
||||
A general warning: Keep backups of your files!
|
||||
This is an executables downloaded from the internet. If you are unsure what they do or how they work, refer to the sources [on GitHub](https://github.com/inkyblackness).
|
||||
|
||||
|
||||
## Requirements
|
||||
|
||||
HackEd is a GUI editor for the resources, including a map editor.
|
||||
The tool uses OpenGL 3.2 .
|
||||
|
||||
User documentation is available online here: https://github.com/inkyblackness/hacked/wiki
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
|
||||
pushd $(dirname "${0}") >/dev/null
|
||||
HACKED_BASE=$(pwd -L)
|
||||
FOLDER_NAME=inkyblackness-hacked
|
||||
|
||||
echo "Cleaning output directories..."
|
||||
rm -rf _build
|
||||
rm main-res.syso
|
||||
|
||||
mkdir -p $HACKED_BASE/_build/linux/$FOLDER_NAME
|
||||
mkdir -p $HACKED_BASE/_build/win/$FOLDER_NAME
|
||||
|
||||
echo "Determining version"
|
||||
|
||||
MAJOR=$(date +%Y)
|
||||
MINOR=$(date +%m)
|
||||
PATCH=$(date +%d)
|
||||
VERSION=$(git describe --tags)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Could not determine tag, defaulting to revision for version"
|
||||
REV=$(git rev-parse --short HEAD)
|
||||
VERSION="rev$REV"
|
||||
else
|
||||
VERSION_RAW=$(echo "$VERSION" | cut -d'-' -f 1 | cut -d'v' -f 2)
|
||||
fi
|
||||
|
||||
EXTRA=$(echo "$VERSION" | cut -d'-' -f 2)
|
||||
if [[ "$VERSION" == "$EXTRA" ]]; then
|
||||
MAJOR=$(echo "$VERSION_RAW" | cut -d'.' -f 1)
|
||||
MINOR=$(echo "$VERSION_RAW" | cut -d'.' -f 2)
|
||||
PATCH=$(echo "$VERSION_RAW" | cut -d'.' -f 3)
|
||||
fi
|
||||
echo "Determined version: $VERSION ($MAJOR.$MINOR.$PATCH)"
|
||||
|
||||
echo "Preparing build resources"
|
||||
mkdir -p $HACKED_BASE/_build/win_temp
|
||||
cp $HACKED_BASE/_resources/build/win/* $HACKED_BASE/_build/win_temp
|
||||
sed -i "s/§MAJOR/$MAJOR/g" $HACKED_BASE/_build/win_temp/hacked.exe.manifest
|
||||
sed -i "s/§MINOR/$MINOR/g" $HACKED_BASE/_build/win_temp/hacked.exe.manifest
|
||||
sed -i "s/§PATCH/$PATCH/g" $HACKED_BASE/_build/win_temp/hacked.exe.manifest
|
||||
x86_64-w64-mingw32-windres -o main-res.syso $HACKED_BASE/_build/win_temp/hacked.rc
|
||||
|
||||
echo "Building executables..."
|
||||
go build -gcflags "-d=checkptr=0" -ldflags "-X main.version=$VERSION" -a -o $HACKED_BASE/_build/linux/$FOLDER_NAME/hacked -trimpath $(pwd) .
|
||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CXX=x86_64-w64-mingw32-g++ CC=x86_64-w64-mingw32-gcc go build -gcflags "-d=checkptr=0" -ldflags "-X main.version=$VERSION -H=windowsgui" -a -o $HACKED_BASE/_build/win/$FOLDER_NAME/hacked.exe -trimpath $(pwd) .
|
||||
|
||||
echo "Copying distribution resources..."
|
||||
|
||||
for os in "linux" "win"; do
|
||||
packageDir=$HACKED_BASE/_build/$os/$FOLDER_NAME
|
||||
|
||||
cp $HACKED_BASE/LICENSE $packageDir
|
||||
cp -R $HACKED_BASE/_resources/dist/* $packageDir
|
||||
done
|
||||
|
||||
for lib in "libgcc_s_seh-1.dll" "libstdc++-6.dll" "libwinpthread-1.dll"; do
|
||||
for base in "/usr/x86_64-w64-mingw32/bin" "/usr/lib/gcc/x86_64-w64-mingw32/10-win32" "/usr/x86_64-w64-mingw32/lib"; do
|
||||
if [ -e $base/$lib ]; then
|
||||
cp $base/$lib $HACKED_BASE/_build/win/$FOLDER_NAME
|
||||
fi
|
||||
done
|
||||
if [ ! -f $HACKED_BASE/_build/win/$FOLDER_NAME/$lib ]; then
|
||||
echo " FAIL: file $lib is nowhere found"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Creating packages..."
|
||||
|
||||
cd $HACKED_BASE/_build/linux
|
||||
tar -cvzf $HACKED_BASE/_build/$FOLDER_NAME-$VERSION.linux64.tgz ./$FOLDER_NAME
|
||||
|
||||
cd $HACKED_BASE/_build/win
|
||||
zip -r $HACKED_BASE/_build/$FOLDER_NAME-$VERSION.win64.zip .
|
||||
|
||||
popd >/dev/null
|
||||
@@ -0,0 +1,4 @@
|
||||
package crash
|
||||
|
||||
// Handler is a function that tries to preserve some panic/stack info to the user.
|
||||
var Handler = func(versionInfo string) {}
|
||||
@@ -0,0 +1,42 @@
|
||||
package crash
|
||||
|
||||
//#include <windows.h>
|
||||
import "C"
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Handler = windowsHandler
|
||||
}
|
||||
|
||||
func windowsHandler(versionInfo string) {
|
||||
if e := recover(); e != nil {
|
||||
text := `There was a crash in the application. This is bad and I am sorry.
|
||||
|
||||
Perhaps you can make something with the details of the error below.
|
||||
If you can reproduce this, please make a screenshot of this box and report it with details on the http://www.systemshock.org forums.
|
||||
Thank you!
|
||||
|
||||
`
|
||||
text += fmt.Sprintf("%s:\n%s", e, debug.Stack())
|
||||
messageBox(text, "Something unexpected happened - "+versionInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func messageBox(text, caption string) {
|
||||
textArg, textFin := wrapString(text)
|
||||
defer textFin()
|
||||
captionArg, captionFin := wrapString(caption)
|
||||
defer captionFin()
|
||||
var hwnd unsafe.Pointer
|
||||
C.MessageBoxExA((*C.struct_HWND__)(hwnd), textArg, captionArg, C.UINT(0), C.WORD(0))
|
||||
}
|
||||
|
||||
func wrapString(value string) (wrapped *C.char, finisher func()) {
|
||||
wrapped = C.CString(value)
|
||||
finisher = func() { C.free(unsafe.Pointer(wrapped)) } // nolint: gas
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,945 @@
|
||||
package editor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/inkyblackness/imgui-go/v3"
|
||||
|
||||
"github.com/inkyblackness/hacked/editor/about"
|
||||
"github.com/inkyblackness/hacked/editor/animations"
|
||||
"github.com/inkyblackness/hacked/editor/archives"
|
||||
"github.com/inkyblackness/hacked/editor/bitmaps"
|
||||
"github.com/inkyblackness/hacked/editor/external"
|
||||
"github.com/inkyblackness/hacked/editor/graphics"
|
||||
"github.com/inkyblackness/hacked/editor/levels"
|
||||
"github.com/inkyblackness/hacked/editor/messages"
|
||||
"github.com/inkyblackness/hacked/editor/movies"
|
||||
"github.com/inkyblackness/hacked/editor/objects"
|
||||
"github.com/inkyblackness/hacked/editor/project"
|
||||
"github.com/inkyblackness/hacked/editor/render"
|
||||
"github.com/inkyblackness/hacked/editor/sounds"
|
||||
"github.com/inkyblackness/hacked/editor/texts"
|
||||
"github.com/inkyblackness/hacked/editor/textures"
|
||||
"github.com/inkyblackness/hacked/ss1"
|
||||
"github.com/inkyblackness/hacked/ss1/content/archive/level"
|
||||
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||
"github.com/inkyblackness/hacked/ss1/content/movie"
|
||||
"github.com/inkyblackness/hacked/ss1/content/sound"
|
||||
"github.com/inkyblackness/hacked/ss1/content/text"
|
||||
"github.com/inkyblackness/hacked/ss1/edit"
|
||||
"github.com/inkyblackness/hacked/ss1/edit/media"
|
||||
"github.com/inkyblackness/hacked/ss1/edit/undoable"
|
||||
"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"
|
||||
"github.com/inkyblackness/hacked/ui/gui"
|
||||
"github.com/inkyblackness/hacked/ui/input"
|
||||
"github.com/inkyblackness/hacked/ui/opengl"
|
||||
)
|
||||
|
||||
const (
|
||||
errCouldNotOpenFile ss1.StringError = "could not open file"
|
||||
errCouldNotReadFile ss1.StringError = "could not read file"
|
||||
)
|
||||
|
||||
type projectState struct {
|
||||
ProjectSettings *edit.ProjectSettings `json:",omitempty"`
|
||||
GameStateSettings *edit.GameStateSettings `json:",omitempty"`
|
||||
OpenWindows []string `json:",omitempty"`
|
||||
ActiveLevelIndex *int `json:",omitempty"`
|
||||
}
|
||||
|
||||
func projectStateFromFile(filename string) (projectState, error) {
|
||||
data, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return projectState{}, errCouldNotOpenFile
|
||||
}
|
||||
var state projectState
|
||||
err = json.Unmarshal(data, &state)
|
||||
if err != nil {
|
||||
return projectState{}, errCouldNotReadFile
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// SaveTo stores the state in a file with given filename.
|
||||
func (state projectState) SaveTo(filename string) error {
|
||||
bytes, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ioutil.WriteFile(filename, bytes, 0640)
|
||||
}
|
||||
|
||||
type lastProjectState struct {
|
||||
projectState
|
||||
LastProject string `json:",omitempty"`
|
||||
}
|
||||
|
||||
func lastProjectStateFromFile(filename string) lastProjectState {
|
||||
data, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return lastProjectState{}
|
||||
}
|
||||
var state lastProjectState
|
||||
err = json.Unmarshal(data, &state)
|
||||
if err != nil {
|
||||
return lastProjectState{}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// SaveTo stores the state in a file with given filename.
|
||||
func (state lastProjectState) SaveTo(filename string) error {
|
||||
bytes, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ioutil.WriteFile(filename, bytes, 0640)
|
||||
}
|
||||
|
||||
// Application is the root object of the graphical editor.
|
||||
// It is set up by the main method.
|
||||
type Application struct {
|
||||
// Version identifies the build of the application.
|
||||
Version string
|
||||
|
||||
// ConfigDir is the base path to store configuration in.
|
||||
ConfigDir string
|
||||
|
||||
window opengl.Window
|
||||
clipboard clipboardAdapter
|
||||
gl opengl.OpenGL
|
||||
|
||||
// FontFile specifies the font to use.
|
||||
FontFile string
|
||||
// FontSize specifies the font size to use.
|
||||
FontSize float32
|
||||
// GuiScale is applied when the window is initialized.
|
||||
GuiScale float32
|
||||
guiContext *gui.Context
|
||||
|
||||
lastModifier input.Modifier
|
||||
lastMouseX float32
|
||||
lastMouseY float32
|
||||
|
||||
txnBuilder cmd.TransactionBuilder
|
||||
cmdStack *cmd.Stack
|
||||
mod *world.Mod
|
||||
cp text.Codepage
|
||||
textLineCache *text.Cache
|
||||
textPageCache *text.Cache
|
||||
messagesCache *text.ElectronicMessageCache
|
||||
paletteCache *graphics.PaletteCache
|
||||
textureCache *graphics.TextureCache
|
||||
frameCache *graphics.FrameCache
|
||||
animationCache *bitmap.AnimationCache
|
||||
movieCache *movie.Cache
|
||||
soundEffectCache *sound.EffectCache
|
||||
|
||||
mapDisplay *levels.MapDisplay
|
||||
|
||||
levels *edit.EditableLevels
|
||||
levelSelection *edit.LevelSelectionService
|
||||
levelEditorService *edit.LevelEditorService
|
||||
|
||||
projectService *edit.ProjectService
|
||||
gameStateService *edit.GameStateService
|
||||
gameObjectsService *edit.GameObjectsService
|
||||
|
||||
projectView *project.View
|
||||
archiveView *archives.View
|
||||
levelControlView *levels.ControlView
|
||||
levelTilesView *levels.TilesView
|
||||
levelObjectsView *levels.ObjectsView
|
||||
messagesView *messages.View
|
||||
textsView *texts.View
|
||||
bitmapsView *bitmaps.View
|
||||
texturesView *textures.View
|
||||
animationsView *animations.View
|
||||
moviesView *movies.View
|
||||
soundEffectsView *sounds.View
|
||||
objectsView *objects.View
|
||||
aboutView *about.View
|
||||
licensesView *about.LicensesView
|
||||
|
||||
modalState gui.ModalStateWrapper
|
||||
|
||||
failureMessage string
|
||||
failurePending bool
|
||||
}
|
||||
|
||||
// InitializeWindow takes the given window and attaches the callbacks.
|
||||
func (app *Application) InitializeWindow(window opengl.Window) (err error) {
|
||||
app.window = window
|
||||
app.clipboard.window = window
|
||||
app.gl = window.OpenGL()
|
||||
|
||||
app.window.RestoreState(opengl.WindowStateFromFile(app.windowStateConfigFilename(), app.window.StateSnapshot()))
|
||||
|
||||
app.initSignalling()
|
||||
app.initWindowCallbacks()
|
||||
app.initOpenGL()
|
||||
|
||||
app.initModel()
|
||||
err = app.initGui()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
app.initView()
|
||||
app.restoreWorkspace()
|
||||
|
||||
app.onWindowResize(app.window.Size())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (app *Application) onWindowClosed() {
|
||||
if app.guiContext != nil {
|
||||
app.guiContext.Destroy()
|
||||
app.guiContext = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) initWindowCallbacks() {
|
||||
app.window.OnClosing(app.onWindowClosing)
|
||||
app.window.OnClosed(app.onWindowClosed)
|
||||
app.window.OnFileDropCallback(app.onFilesDropped)
|
||||
|
||||
app.window.OnResize(app.onWindowResize)
|
||||
|
||||
app.window.OnKeyPress(app.onKeyPress)
|
||||
app.window.OnKeyRelease(app.onKeyRelease)
|
||||
app.window.OnCharCallback(app.onChar)
|
||||
app.window.OnModifier(app.onModifier)
|
||||
|
||||
app.window.OnMouseMove(app.onMouseMove)
|
||||
app.window.OnMouseScroll(app.onMouseScroll)
|
||||
app.window.OnMouseButtonDown(app.onMouseButtonDown)
|
||||
app.window.OnMouseButtonUp(app.onMouseButtonUp)
|
||||
|
||||
app.window.OnRender(app.render)
|
||||
}
|
||||
|
||||
func (app *Application) render() {
|
||||
app.guiContext.NewFrame()
|
||||
|
||||
width, height := app.window.Size()
|
||||
app.gl.Viewport(0, 0, int32(width), int32(height))
|
||||
app.gl.ClearColor(0.0, 0.0, 0.0, 1.0)
|
||||
app.gl.Clear(opengl.COLOR_BUFFER_BIT)
|
||||
|
||||
paletteTexture, _ := app.paletteCache.Palette(0)
|
||||
app.mapDisplay.Render(
|
||||
paletteTexture, app.textureCache.Texture,
|
||||
app.levelTilesView.TextureDisplay(), app.levelTilesView.ColorDisplay())
|
||||
|
||||
app.handleFailure()
|
||||
app.renderMainMenu()
|
||||
|
||||
app.projectView.Render()
|
||||
app.updateAutoSave()
|
||||
|
||||
app.archiveView.Render()
|
||||
app.levelControlView.Render()
|
||||
app.levelTilesView.Render()
|
||||
app.levelObjectsView.Render()
|
||||
app.messagesView.Render()
|
||||
app.textsView.Render()
|
||||
app.bitmapsView.Render()
|
||||
app.texturesView.Render()
|
||||
app.animationsView.Render()
|
||||
app.moviesView.Render()
|
||||
app.soundEffectsView.Render()
|
||||
app.objectsView.Render()
|
||||
app.aboutView.Render()
|
||||
app.licensesView.Render()
|
||||
|
||||
app.modalState.Render()
|
||||
|
||||
app.guiContext.Render(app.bitmapTextureForUI)
|
||||
}
|
||||
|
||||
func (app *Application) initOpenGL() {
|
||||
app.gl.Disable(opengl.DEPTH_TEST)
|
||||
app.gl.Enable(opengl.BLEND)
|
||||
app.gl.BlendFunc(opengl.SRC_ALPHA, opengl.ONE_MINUS_SRC_ALPHA)
|
||||
}
|
||||
|
||||
func (app *Application) initGui() (err error) {
|
||||
app.initGuiSizes()
|
||||
param := gui.ContextParameters{
|
||||
ConfigDir: app.ConfigDir,
|
||||
FontFile: app.FontFile,
|
||||
FontSize: app.FontSize,
|
||||
}
|
||||
app.guiContext, err = gui.NewContext(app.window, param)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
imguiIO := imgui.CurrentIO()
|
||||
imguiIO.KeyMap(imgui.KeyTab, int(input.KeyTab))
|
||||
imguiIO.KeyMap(imgui.KeyLeftArrow, int(input.KeyLeft))
|
||||
imguiIO.KeyMap(imgui.KeyRightArrow, int(input.KeyRight))
|
||||
imguiIO.KeyMap(imgui.KeyUpArrow, int(input.KeyUp))
|
||||
imguiIO.KeyMap(imgui.KeyDownArrow, int(input.KeyDown))
|
||||
imguiIO.KeyMap(imgui.KeyPageUp, int(input.KeyPageUp))
|
||||
imguiIO.KeyMap(imgui.KeyPageDown, int(input.KeyPageDown))
|
||||
imguiIO.KeyMap(imgui.KeyHome, int(input.KeyHome))
|
||||
imguiIO.KeyMap(imgui.KeyEnd, int(input.KeyEnd))
|
||||
imguiIO.KeyMap(imgui.KeyInsert, int(input.KeyInsert))
|
||||
imguiIO.KeyMap(imgui.KeyDelete, int(input.KeyDelete))
|
||||
imguiIO.KeyMap(imgui.KeyBackspace, int(input.KeyBackspace))
|
||||
imguiIO.KeyMap(imgui.KeyEnter, int(input.KeyEnter))
|
||||
imguiIO.KeyMap(imgui.KeyEscape, int(input.KeyEscape))
|
||||
imguiIO.KeyMap(imgui.KeyA, int(input.KeyA))
|
||||
imguiIO.KeyMap(imgui.KeyC, int(input.KeyC))
|
||||
imguiIO.KeyMap(imgui.KeyV, int(input.KeyV))
|
||||
imguiIO.KeyMap(imgui.KeyX, int(input.KeyX))
|
||||
|
||||
app.initGuiStyle()
|
||||
|
||||
app.mapDisplay = levels.NewMapDisplay(app.gameObjectsService, app.levelSelection, app.levelEditorService,
|
||||
app.gl, app.GuiScale,
|
||||
app.gameTexture)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (app *Application) gameTexture(index level.TextureIndex) (*graphics.BitmapTexture, error) {
|
||||
key := resource.KeyOf(ids.LargeTextures.Plus(int(index)), resource.LangAny, 0)
|
||||
return app.textureCache.Texture(key)
|
||||
}
|
||||
|
||||
func (app *Application) bitmapTextureForUI(textureID imgui.TextureID) (palette uint32, texture uint32) {
|
||||
idType := byte(textureID >> 48)
|
||||
switch idType {
|
||||
case render.BitmapTextureTypeResource:
|
||||
paletteTexture, _ := app.paletteCache.Palette(0)
|
||||
if paletteTexture == nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
lang := resource.Language((textureID >> 32) & 0xFF)
|
||||
resourceID := resource.ID((textureID >> 16) & 0xFFFF)
|
||||
blockIndex := int(textureID & 0xFFFF)
|
||||
key := resource.KeyOf(resourceID, lang, blockIndex)
|
||||
tex, err := app.textureCache.Texture(key)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
return paletteTexture.Handle(), tex.Handle()
|
||||
case render.BitmapTextureTypeFrame:
|
||||
key := graphics.FrameCacheKey(textureID & 0xFFFF)
|
||||
return app.frameCache.HandlesForKey(key)
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func (app *Application) onWindowClosing() {
|
||||
windowState := app.window.StateSnapshot()
|
||||
_ = windowState.SaveTo(app.windowStateConfigFilename())
|
||||
|
||||
app.saveWorkspace()
|
||||
}
|
||||
|
||||
func (app *Application) onWindowResize(width int, height int) {
|
||||
app.mapDisplay.WindowResized(width, height)
|
||||
app.gl.Viewport(0, 0, int32(width), int32(height))
|
||||
}
|
||||
|
||||
func (app *Application) onFilesDropped(names []string) {
|
||||
app.modalState.HandleFiles(names)
|
||||
}
|
||||
|
||||
func (app *Application) onKeyPress(key input.Key, modifier input.Modifier) {
|
||||
app.lastModifier = modifier
|
||||
|
||||
usedGlobalShortcut := true
|
||||
switch {
|
||||
case key == input.KeyEscape:
|
||||
app.modalState.SetState(nil)
|
||||
case key == input.KeyUndo:
|
||||
app.tryUndo()
|
||||
case key == input.KeyRedo:
|
||||
app.tryRedo()
|
||||
case key == input.KeyNew:
|
||||
app.newProject()
|
||||
case key == input.KeySave:
|
||||
app.projectView.StartSavingMod()
|
||||
case key == input.KeyF1 && modifier.IsClear():
|
||||
*app.projectView.WindowOpen() = !*app.projectView.WindowOpen()
|
||||
case key == input.KeyF2 && modifier.IsClear():
|
||||
*app.levelControlView.WindowOpen() = !*app.levelControlView.WindowOpen()
|
||||
case key == input.KeyF3 && modifier.IsClear():
|
||||
*app.levelTilesView.WindowOpen() = !*app.levelTilesView.WindowOpen()
|
||||
case key == input.KeyF4 && modifier.IsClear():
|
||||
*app.levelObjectsView.WindowOpen() = !*app.levelObjectsView.WindowOpen()
|
||||
case key == input.KeyF5 && modifier.IsClear():
|
||||
*app.messagesView.WindowOpen() = !*app.messagesView.WindowOpen()
|
||||
default:
|
||||
usedGlobalShortcut = false
|
||||
}
|
||||
if usedGlobalShortcut {
|
||||
return
|
||||
}
|
||||
|
||||
if app.shouldReportKeyToImgui(key) {
|
||||
imgui.CurrentIO().KeyPress(int(key))
|
||||
app.updateKeyModifier()
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) onKeyRelease(key input.Key, modifier input.Modifier) {
|
||||
app.lastModifier = modifier
|
||||
|
||||
if app.shouldReportKeyToImgui(key) {
|
||||
imgui.CurrentIO().KeyRelease(int(key))
|
||||
app.updateKeyModifier()
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) shouldReportKeyToImgui(key input.Key) bool {
|
||||
keyIsModifier := key.AsModifier() != input.ModNone
|
||||
return keyIsModifier || app.guiContext.IsUsingKeyboard()
|
||||
}
|
||||
|
||||
func (app *Application) onChar(char rune) {
|
||||
if app.guiContext.IsUsingKeyboard() {
|
||||
imgui.CurrentIO().AddInputCharacters(string(char))
|
||||
return
|
||||
}
|
||||
|
||||
switch char {
|
||||
case 'v':
|
||||
_ = app.levelEditorService.PlaceObjectsOnFloor()
|
||||
case 'f':
|
||||
_ = app.levelEditorService.PlaceObjectsOnEyeLevel()
|
||||
case 'r':
|
||||
_ = app.levelEditorService.PlaceObjectsOnCeiling()
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) updateKeyModifier() {
|
||||
imguiIO := imgui.CurrentIO()
|
||||
imguiIO.KeyCtrl(int(input.KeyControl), int(input.KeyControl))
|
||||
imguiIO.KeyShift(int(input.KeyShift), int(input.KeyShift))
|
||||
imguiIO.KeyAlt(int(input.KeyAlt), int(input.KeyAlt))
|
||||
imguiIO.KeySuper(int(input.KeySuper), int(input.KeySuper))
|
||||
}
|
||||
|
||||
func (app *Application) onModifier(modifier input.Modifier) {
|
||||
app.lastModifier = modifier
|
||||
}
|
||||
|
||||
func (app *Application) modalActive() bool {
|
||||
return (app.modalState.State != nil) || (len(app.failureMessage) > 0)
|
||||
}
|
||||
|
||||
func (app *Application) tryUndo() {
|
||||
if !app.cmdStack.CanUndo() || app.modalActive() {
|
||||
return
|
||||
}
|
||||
imgui.ClearActiveID()
|
||||
err := app.projectService.ModifyModWith(app.cmdStack.Undo)
|
||||
if err != nil {
|
||||
app.onFailure("Undo", "", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) tryRedo() {
|
||||
if !app.cmdStack.CanRedo() || app.modalActive() {
|
||||
return
|
||||
}
|
||||
imgui.ClearActiveID()
|
||||
err := app.projectService.ModifyModWith(app.cmdStack.Redo)
|
||||
if err != nil {
|
||||
app.onFailure("Redo", "", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) onMouseMove(x, y float32) {
|
||||
app.lastMouseX = x
|
||||
app.lastMouseY = y
|
||||
app.guiContext.SetMousePosition(x, y)
|
||||
if !app.guiContext.IsUsingMouse() {
|
||||
app.mapDisplay.MouseMoved(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) onMouseScroll(dx, dy float32) {
|
||||
if !app.guiContext.IsUsingMouse() {
|
||||
app.mapDisplay.MouseScrolled(app.lastMouseX, app.lastMouseY, dx, dy, app.lastModifier)
|
||||
}
|
||||
app.guiContext.MouseScroll(dx, dy)
|
||||
}
|
||||
|
||||
func (app *Application) onMouseButtonDown(buttonMask uint32, modifier input.Modifier) {
|
||||
if !app.guiContext.IsUsingMouse() {
|
||||
app.mapDisplay.MouseButtonDown(app.lastMouseX, app.lastMouseY, buttonMask)
|
||||
}
|
||||
app.reportButtonChange(buttonMask, true)
|
||||
}
|
||||
|
||||
func (app *Application) onMouseButtonUp(buttonMask uint32, modifier input.Modifier) {
|
||||
if !app.guiContext.IsUsingMouse() {
|
||||
app.mapDisplay.MouseButtonUp(app.lastMouseX, app.lastMouseY, buttonMask, modifier)
|
||||
}
|
||||
app.reportButtonChange(buttonMask, false)
|
||||
}
|
||||
|
||||
func (app *Application) reportButtonChange(buttonMask uint32, down bool) {
|
||||
for buttonIndex := 0; buttonIndex < 32; buttonIndex++ {
|
||||
if (buttonMask & (1 << uint32(buttonIndex))) != 0 {
|
||||
app.guiContext.MouseButtonChanged(buttonIndex, down)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) initGuiSizes() {
|
||||
if app.GuiScale < 0.5 {
|
||||
app.GuiScale = 1.0
|
||||
} else if app.GuiScale > 10.0 {
|
||||
app.GuiScale = 10.0
|
||||
}
|
||||
|
||||
if app.FontSize <= 0 {
|
||||
app.FontSize = 16.0
|
||||
}
|
||||
|
||||
app.FontSize *= app.GuiScale
|
||||
}
|
||||
|
||||
func (app *Application) initGuiStyle() {
|
||||
if len(app.FontFile) == 0 {
|
||||
imgui.CurrentIO().SetFontGlobalScale(app.GuiScale)
|
||||
}
|
||||
imgui.CurrentStyle().ScaleAllSizes(app.GuiScale)
|
||||
|
||||
color := func(r, g, b byte, alpha float32) imgui.Vec4 {
|
||||
return imgui.Vec4{X: float32(r) / 255.0, Y: float32(g) / 255.0, Z: float32(b) / 255.0, W: alpha}
|
||||
}
|
||||
colorDoubleFull := func(alpha float32) imgui.Vec4 { return color(0xC4, 0x38, 0x9F, alpha) }
|
||||
colorDoubleDark := func(alpha float32) imgui.Vec4 { return color(0x31, 0x01, 0x38, alpha) }
|
||||
|
||||
colorTripleFull := func(alpha float32) imgui.Vec4 { return color(0x21, 0xFF, 0x43, alpha) }
|
||||
colorTripleDark := func(alpha float32) imgui.Vec4 { return color(0x06, 0xCC, 0x94, alpha) }
|
||||
colorTripleLight := func(alpha float32) imgui.Vec4 { return color(0x51, 0x99, 0x58, alpha) }
|
||||
|
||||
style := imgui.CurrentStyle()
|
||||
style.SetColor(imgui.StyleColorText, colorTripleFull(1.0))
|
||||
style.SetColor(imgui.StyleColorTextDisabled, colorTripleDark(1.0))
|
||||
|
||||
style.SetColor(imgui.StyleColorWindowBg, colorDoubleDark(0.80))
|
||||
style.SetColor(imgui.StyleColorPopupBg, colorDoubleDark(0.75))
|
||||
|
||||
style.SetColor(imgui.StyleColorTitleBgActive, colorTripleLight(1.0))
|
||||
style.SetColor(imgui.StyleColorFrameBg, colorTripleLight(0.54))
|
||||
|
||||
style.SetColor(imgui.StyleColorFrameBgHovered, colorTripleDark(0.4))
|
||||
style.SetColor(imgui.StyleColorFrameBgActive, colorTripleDark(0.67))
|
||||
style.SetColor(imgui.StyleColorCheckMark, colorTripleDark(1.0))
|
||||
style.SetColor(imgui.StyleColorSliderGrabActive, colorTripleDark(1.0))
|
||||
style.SetColor(imgui.StyleColorButton, colorTripleDark(0.4))
|
||||
style.SetColor(imgui.StyleColorButtonHovered, colorTripleDark(1.0))
|
||||
style.SetColor(imgui.StyleColorHeader, colorTripleLight(0.70))
|
||||
style.SetColor(imgui.StyleColorHeaderHovered, colorTripleDark(0.8))
|
||||
style.SetColor(imgui.StyleColorHeaderActive, colorTripleDark(1.0))
|
||||
style.SetColor(imgui.StyleColorResizeGrip, colorTripleDark(0.25))
|
||||
style.SetColor(imgui.StyleColorResizeGripHovered, colorTripleDark(0.67))
|
||||
style.SetColor(imgui.StyleColorResizeGripActive, colorTripleDark(0.95))
|
||||
style.SetColor(imgui.StyleColorTextSelectedBg, colorTripleDark(0.35))
|
||||
|
||||
style.SetColor(imgui.StyleColorTab, colorTripleLight(0.54))
|
||||
style.SetColor(imgui.StyleColorTabHovered, colorTripleLight(0.75))
|
||||
style.SetColor(imgui.StyleColorTabActive, colorTripleLight(1.0))
|
||||
|
||||
style.SetColor(imgui.StyleColorSliderGrab, colorDoubleFull(1.0))
|
||||
style.SetColor(imgui.StyleColorButtonActive, colorDoubleFull(1.0))
|
||||
style.SetColor(imgui.StyleColorSeparatorHovered, colorDoubleFull(0.78))
|
||||
style.SetColor(imgui.StyleColorSeparatorActive, colorTripleLight(1.0))
|
||||
}
|
||||
|
||||
func (app *Application) initSignalling() {
|
||||
app.cmdStack = new(cmd.Stack)
|
||||
app.txnBuilder.Commander = app
|
||||
}
|
||||
|
||||
func (app *Application) initModel() {
|
||||
app.mod = world.NewMod(app.resourcesChanged, app.modReset)
|
||||
|
||||
app.cp = text.DefaultCodepage()
|
||||
app.textLineCache = text.NewLineCache(app.cp, app.mod)
|
||||
app.textPageCache = text.NewPageCache(app.cp, app.mod)
|
||||
app.messagesCache = text.NewElectronicMessageCache(app.cp, app.mod)
|
||||
app.movieCache = movie.NewCache(app.cp, app.mod)
|
||||
app.soundEffectCache = sound.NewEffectCache(app.mod)
|
||||
|
||||
app.levels = edit.NewEditableLevels(&app.txnBuilder, app.mod)
|
||||
app.levelSelection = edit.NewLevelSelectionService(app.levels)
|
||||
|
||||
app.gameObjectsService = edit.NewGameObjectsService(app.mod)
|
||||
app.levelEditorService = edit.NewLevelEditorService(&app.txnBuilder, app.gameObjectsService, app.levels, app.levelSelection)
|
||||
|
||||
app.paletteCache = graphics.NewPaletteCache(app.gl, app.mod)
|
||||
app.textureCache = graphics.NewTextureCache(app.gl, app.mod)
|
||||
app.frameCache = graphics.NewFrameCache(app.gl)
|
||||
app.animationCache = bitmap.NewAnimationCache(app.mod)
|
||||
}
|
||||
|
||||
func (app *Application) resourcesChanged(modifiedIDs []resource.ID, failedIDs []resource.ID) {
|
||||
app.textLineCache.InvalidateResources(modifiedIDs)
|
||||
app.textPageCache.InvalidateResources(modifiedIDs)
|
||||
app.messagesCache.InvalidateResources(modifiedIDs)
|
||||
app.movieCache.InvalidateResources(modifiedIDs)
|
||||
app.soundEffectCache.InvalidateResources(modifiedIDs)
|
||||
app.levels.InvalidateResources(modifiedIDs)
|
||||
app.paletteCache.InvalidateResources(modifiedIDs)
|
||||
app.textureCache.InvalidateResources(modifiedIDs)
|
||||
app.animationCache.InvalidateResources(modifiedIDs)
|
||||
}
|
||||
|
||||
func (app *Application) modReset() {
|
||||
app.cmdStack = new(cmd.Stack)
|
||||
}
|
||||
|
||||
// nolint: lll
|
||||
func (app *Application) initView() {
|
||||
textViewer := media.NewTextViewerService(app.textLineCache, app.textPageCache, app.mod)
|
||||
textSetter := media.NewTextSetterService(app.cp)
|
||||
audioViewer := media.NewAudioViewerService(app.movieCache, app.mod)
|
||||
audioSetter := media.NewAudioSetterService()
|
||||
movieViewer := media.NewMovieViewerService(app.movieCache, app.mod)
|
||||
movieSetter := media.NewMovieSetterService(app.cp)
|
||||
soundEffectViewer := media.NewSoundViewerService(app.soundEffectCache, app.mod)
|
||||
soundEffectSetter := media.NewSoundSetterService()
|
||||
soundEffectService := undoable.NewSoundEffectService(edit.NewSoundEffectService(soundEffectViewer, soundEffectSetter), app)
|
||||
augmentedTextService := undoable.NewAugmentedTextService(edit.NewAugmentedTextService(textViewer, textSetter, audioViewer, audioSetter), app)
|
||||
movieService := undoable.NewMovieService(edit.NewMovieService(app.cp, movieViewer, movieSetter), app)
|
||||
|
||||
app.projectService = edit.NewProjectService(&app.txnBuilder, app.mod)
|
||||
app.gameStateService = edit.NewGameStateService(&app.txnBuilder)
|
||||
|
||||
app.projectView = project.NewView(app.projectService, &app.modalState, app.GuiScale, &app.txnBuilder)
|
||||
app.archiveView = archives.NewArchiveView(&app.txnBuilder, app.gameStateService, app.mod, app.textLineCache, app.cp, &app.modalState, app.GuiScale, app)
|
||||
app.levelControlView = levels.NewControlView(app.levels, app.levelSelection, app.levelEditorService, app.GuiScale, app.textLineCache, app.textureCache, &app.txnBuilder)
|
||||
app.levelTilesView = levels.NewTilesView(app.levelEditorService, app.GuiScale, app.textLineCache, app.textureCache, &app.txnBuilder)
|
||||
app.levelObjectsView = levels.NewObjectsView(app.gameObjectsService, app.levelEditorService, app.levelSelection, app.gameStateService, app.GuiScale, app.textLineCache, app.textureCache, &app.txnBuilder, app.gl)
|
||||
app.messagesView = messages.NewMessagesView(app.mod, app.messagesCache, app.cp, app.movieCache, app.textureCache, &app.modalState, app.clipboard, app.GuiScale, app)
|
||||
app.textsView = texts.NewTextsView(augmentedTextService, &app.modalState, app.clipboard, app.GuiScale)
|
||||
app.bitmapsView = bitmaps.NewBitmapsView(app.mod, app.textureCache, app.paletteCache, &app.modalState, app.clipboard, app.GuiScale, app)
|
||||
app.texturesView = textures.NewTexturesView(app.mod, app.textLineCache, app.cp, app.textureCache, app.paletteCache, &app.modalState, app.clipboard, app.GuiScale, app)
|
||||
app.animationsView = animations.NewAnimationsView(app.mod, app.textureCache, app.paletteCache, app.animationCache, &app.modalState, app.GuiScale, app)
|
||||
app.moviesView = movies.NewMoviesView(app.mod, app.frameCache, movieService, &app.modalState, app.GuiScale, app)
|
||||
app.soundEffectsView = sounds.NewSoundEffectsView(soundEffectService, &app.modalState, app.GuiScale)
|
||||
app.objectsView = objects.NewView(app.mod, app.textLineCache, app.cp, app.textureCache, app.paletteCache, &app.modalState, app.clipboard, app.GuiScale, app)
|
||||
app.aboutView = about.NewView(app.clipboard, app.GuiScale, app.Version)
|
||||
app.licensesView = about.NewLicensesView(app.GuiScale)
|
||||
}
|
||||
|
||||
// Queue requests to perform the given command.
|
||||
func (app *Application) Queue(command cmd.Command) {
|
||||
err := app.projectService.ModifyModWith(func(modder world.Modder) error {
|
||||
return app.cmdStack.Perform(command, modder)
|
||||
})
|
||||
if err != nil {
|
||||
app.onFailure("command", "", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) onFailure(source string, details string, err error) {
|
||||
app.failurePending = true
|
||||
app.failureMessage = fmt.Sprintf("Source: %v\nDetails: %v\nError: %v", source, details, err)
|
||||
}
|
||||
|
||||
func (app *Application) renderMainMenu() {
|
||||
windowEntry := func(name string, shortcut string, isOpen *bool) {
|
||||
if imgui.MenuItemV(name, shortcut, *isOpen, true) {
|
||||
*isOpen = !*isOpen
|
||||
}
|
||||
}
|
||||
|
||||
if imgui.BeginMainMenuBar() {
|
||||
if imgui.BeginMenu("File") {
|
||||
if imgui.MenuItemV("New Project", "Ctrl+N", false, true) {
|
||||
app.newProject()
|
||||
}
|
||||
if imgui.MenuItemV("Load Project", "", false, true) {
|
||||
app.loadProject()
|
||||
}
|
||||
if imgui.MenuItemV("Save Project", "", false, true) {
|
||||
app.saveProject()
|
||||
}
|
||||
imgui.Separator()
|
||||
if imgui.MenuItemV("Save Mod", "Ctrl+S", false, true) {
|
||||
app.projectView.StartSavingMod()
|
||||
}
|
||||
windowEntry("Project", "F1", app.projectView.WindowOpen())
|
||||
imgui.Separator()
|
||||
if imgui.MenuItem("Exit") {
|
||||
app.window.SetCloseRequest(true)
|
||||
}
|
||||
imgui.EndMenu()
|
||||
}
|
||||
if imgui.BeginMenu("Edit") {
|
||||
if imgui.MenuItemV("Undo", "Ctrl+Z", false, app.cmdStack.CanUndo()) {
|
||||
app.tryUndo()
|
||||
}
|
||||
if imgui.MenuItemV("Redo", "Ctrl+Y / Ctrl+Shift+Z", false, app.cmdStack.CanRedo()) {
|
||||
app.tryRedo()
|
||||
}
|
||||
imgui.EndMenu()
|
||||
}
|
||||
if imgui.BeginMenu("Window") {
|
||||
windowEntry("Archive", "", app.archiveView.WindowOpen())
|
||||
windowEntry("Level Control", "F2", app.levelControlView.WindowOpen())
|
||||
windowEntry("Level Tiles", "F3", app.levelTilesView.WindowOpen())
|
||||
windowEntry("Level Objects", "F4", app.levelObjectsView.WindowOpen())
|
||||
windowEntry("Messages", "F5", app.messagesView.WindowOpen())
|
||||
windowEntry("Texts", "", app.textsView.WindowOpen())
|
||||
windowEntry("Bitmaps", "", app.bitmapsView.WindowOpen())
|
||||
windowEntry("Textures", "", app.texturesView.WindowOpen())
|
||||
windowEntry("Animations", "", app.animationsView.WindowOpen())
|
||||
windowEntry("Movies", "", app.moviesView.WindowOpen())
|
||||
windowEntry("Sound Effects", "", app.soundEffectsView.WindowOpen())
|
||||
windowEntry("Game Objects", "", app.objectsView.WindowOpen())
|
||||
imgui.EndMenu()
|
||||
}
|
||||
if imgui.BeginMenu("Help") {
|
||||
if imgui.MenuItem("About...") {
|
||||
app.aboutView.Show()
|
||||
}
|
||||
if imgui.MenuItem("Licenses") {
|
||||
app.licensesView.Show()
|
||||
}
|
||||
imgui.EndMenu()
|
||||
}
|
||||
imgui.EndMainMenuBar()
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) handleFailure() {
|
||||
if app.failurePending {
|
||||
imgui.OpenPopup("Failure Message")
|
||||
app.failurePending = false
|
||||
}
|
||||
if imgui.BeginPopupModal("Failure Message") {
|
||||
imgui.Text(`Something went wrong. This is bad and I am sorry.
|
||||
|
||||
You have the option to "Ignore" this and hope for the best.
|
||||
This action also clears the undo/redo buffer.
|
||||
|
||||
Or you can simply "Exit" the application and then restart it.
|
||||
This action loses any pending changes.
|
||||
|
||||
Perhaps you can make something with the details of the error below.
|
||||
If you can reproduce this, please make a screenshot and
|
||||
report it with details on how to reproduce it on the
|
||||
http://www.systemshock.org forums. Thank you!
|
||||
`)
|
||||
imgui.Text("Version: " + app.Version)
|
||||
imgui.Separator()
|
||||
imgui.Text(app.failureMessage)
|
||||
imgui.Separator()
|
||||
if imgui.Button("Ignore") {
|
||||
app.failureMessage = ""
|
||||
app.cmdStack = new(cmd.Stack)
|
||||
imgui.CloseCurrentPopup()
|
||||
}
|
||||
imgui.SameLine()
|
||||
if imgui.Button("Exit") {
|
||||
app.window.SetCloseRequest(true)
|
||||
}
|
||||
imgui.EndPopup()
|
||||
}
|
||||
}
|
||||
|
||||
const settingsFileExtension = "hacked-project"
|
||||
|
||||
func projectFileTypes() []external.TypeInfo {
|
||||
return []external.TypeInfo{
|
||||
{
|
||||
Title: "HackEd Project File (*." + settingsFileExtension + ")",
|
||||
Extensions: []string{settingsFileExtension},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) newProject() {
|
||||
app.saveStoredProject()
|
||||
app.restoreProjectState(projectState{}, "")
|
||||
}
|
||||
|
||||
func (app *Application) loadProject() {
|
||||
app.saveStoredProject()
|
||||
|
||||
external.LoadFile(&app.modalState, projectFileTypes(), func(filename string) error {
|
||||
state, err := projectStateFromFile(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app.restoreProjectState(state, filename)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (app *Application) saveStoredProject() {
|
||||
currentFilename := app.projectService.CurrentStateFilename()
|
||||
if len(currentFilename) > 0 {
|
||||
currentState := app.currentProjectState()
|
||||
_ = currentState.SaveTo(currentFilename)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) saveProject() {
|
||||
currentState := app.currentProjectState()
|
||||
currentFilename := app.projectService.CurrentStateFilename()
|
||||
if (len(currentFilename) == 0) || (currentState.SaveTo(currentFilename) != nil) {
|
||||
external.SaveFile(&app.modalState, projectFileTypes(), func(filename string) error {
|
||||
completeFilename := filename
|
||||
dotExtension := "." + settingsFileExtension
|
||||
if !strings.HasSuffix(completeFilename, dotExtension) {
|
||||
completeFilename += dotExtension
|
||||
}
|
||||
err := currentState.SaveTo(completeFilename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app.projectService.SetCurrentStateFilename(completeFilename)
|
||||
app.window.SetTitleSuffix(completeFilename)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) currentProjectState() projectState {
|
||||
projectSettings := app.projectService.CurrentSettings()
|
||||
gameStateSettings := app.gameStateService.CurrentSettings()
|
||||
|
||||
windowOpenByName := app.windowOpenByName()
|
||||
var openWindows []string
|
||||
for key, open := range windowOpenByName {
|
||||
if *open {
|
||||
openWindows = append(openWindows, key)
|
||||
}
|
||||
}
|
||||
activeLevelIndex := app.levelSelection.CurrentLevelID()
|
||||
return projectState{
|
||||
ProjectSettings: &projectSettings,
|
||||
GameStateSettings: &gameStateSettings,
|
||||
OpenWindows: openWindows,
|
||||
ActiveLevelIndex: &activeLevelIndex,
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) saveWorkspace() {
|
||||
currentProjectState := app.currentProjectState()
|
||||
currentProjectStateFilename := app.projectService.CurrentStateFilename()
|
||||
if len(currentProjectStateFilename) > 0 {
|
||||
err := currentProjectState.SaveTo(currentProjectStateFilename)
|
||||
if err != nil {
|
||||
currentProjectStateFilename = ""
|
||||
} else {
|
||||
currentProjectState = projectState{}
|
||||
}
|
||||
}
|
||||
|
||||
lastProjectState := lastProjectState{
|
||||
projectState: currentProjectState,
|
||||
LastProject: currentProjectStateFilename,
|
||||
}
|
||||
_ = lastProjectState.SaveTo(app.lastProjectStateConfigFilename())
|
||||
}
|
||||
|
||||
func (app *Application) restoreWorkspace() {
|
||||
lastProjectState := lastProjectStateFromFile(app.lastProjectStateConfigFilename())
|
||||
|
||||
projectStateToRestore := lastProjectState.projectState
|
||||
|
||||
projectStateFilename := ""
|
||||
if len(lastProjectState.LastProject) > 0 {
|
||||
state, err := projectStateFromFile(lastProjectState.LastProject)
|
||||
if err == nil {
|
||||
projectStateToRestore = state
|
||||
projectStateFilename = lastProjectState.LastProject
|
||||
}
|
||||
}
|
||||
|
||||
app.restoreProjectState(projectStateToRestore, projectStateFilename)
|
||||
}
|
||||
|
||||
func (app *Application) restoreProjectState(state projectState, filename string) {
|
||||
var projectSettings edit.ProjectSettings
|
||||
if state.ProjectSettings != nil {
|
||||
projectSettings = *state.ProjectSettings
|
||||
}
|
||||
app.projectService.RestoreProject(projectSettings, filename)
|
||||
var gameStateSettings edit.GameStateSettings
|
||||
if state.GameStateSettings != nil {
|
||||
gameStateSettings = *state.GameStateSettings
|
||||
}
|
||||
app.gameStateService.RestoreSettings(gameStateSettings)
|
||||
|
||||
windowOpenByName := app.windowOpenByName()
|
||||
for _, open := range windowOpenByName {
|
||||
*open = false
|
||||
}
|
||||
for _, key := range state.OpenWindows {
|
||||
open := windowOpenByName[key]
|
||||
if open != nil {
|
||||
*open = true
|
||||
}
|
||||
}
|
||||
if len(state.OpenWindows) == 0 {
|
||||
*app.projectView.WindowOpen() = true
|
||||
}
|
||||
|
||||
activeLevel := world.StartingLevel
|
||||
if (state.ActiveLevelIndex != nil) && app.levels.IsLevelAvailable(*state.ActiveLevelIndex) {
|
||||
activeLevel = *state.ActiveLevelIndex
|
||||
}
|
||||
app.levelSelection.SetCurrentLevelID(activeLevel)
|
||||
|
||||
app.window.SetTitleSuffix(filename)
|
||||
}
|
||||
|
||||
func (app *Application) updateAutoSave() {
|
||||
status := app.projectService.SaveStatus()
|
||||
app.window.SetProjectModified(status.FilesModified > 0)
|
||||
if status.SavePending && (status.SaveIn == 0) {
|
||||
status.ConfirmPendingSave()
|
||||
app.projectView.StartSavingMod()
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) windowStateConfigFilename() string {
|
||||
return filepath.Join(app.ConfigDir, "WindowState.json")
|
||||
}
|
||||
|
||||
func (app *Application) lastProjectStateConfigFilename() string {
|
||||
return filepath.Join(app.ConfigDir, "LastProjectState.json")
|
||||
}
|
||||
|
||||
func (app *Application) windowOpenByName() map[string]*bool {
|
||||
return map[string]*bool{
|
||||
"project": app.projectView.WindowOpen(),
|
||||
"archive": app.archiveView.WindowOpen(),
|
||||
"levelControl": app.levelControlView.WindowOpen(),
|
||||
"levelTiles": app.levelTilesView.WindowOpen(),
|
||||
"levelObjects": app.levelObjectsView.WindowOpen(),
|
||||
"messages": app.messagesView.WindowOpen(),
|
||||
"texts": app.textsView.WindowOpen(),
|
||||
"bitmaps": app.bitmapsView.WindowOpen(),
|
||||
"textures": app.texturesView.WindowOpen(),
|
||||
"animations": app.animationsView.WindowOpen(),
|
||||
"movies": app.moviesView.WindowOpen(),
|
||||
"soundEffects": app.soundEffectsView.WindowOpen(),
|
||||
"gameObjects": app.objectsView.WindowOpen(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package editor
|
||||
|
||||
import "github.com/inkyblackness/hacked/ui/opengl"
|
||||
|
||||
type clipboardAdapter struct {
|
||||
window opengl.Window
|
||||
}
|
||||
|
||||
func (adapter clipboardAdapter) String() (string, error) {
|
||||
return adapter.window.ClipboardString()
|
||||
}
|
||||
|
||||
func (adapter clipboardAdapter) SetString(value string) {
|
||||
adapter.window.SetClipboardString(value)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package about
|
||||
|
||||
import (
|
||||
"github.com/inkyblackness/imgui-go/v3"
|
||||
)
|
||||
|
||||
// LicensesView handles the about display.
|
||||
type LicensesView struct {
|
||||
guiScale float32
|
||||
|
||||
model viewModel
|
||||
}
|
||||
|
||||
// NewLicensesView creates a new instance for the about display.
|
||||
func NewLicensesView(guiScale float32) *LicensesView {
|
||||
return &LicensesView{
|
||||
guiScale: guiScale,
|
||||
|
||||
model: freshViewModel(),
|
||||
}
|
||||
}
|
||||
|
||||
// Show requests to show the licenses view.
|
||||
func (view *LicensesView) Show() {
|
||||
view.model.windowOpen = true
|
||||
}
|
||||
|
||||
// Render requests to render the view.
|
||||
func (view *LicensesView) Render() {
|
||||
if view.model.windowOpen {
|
||||
imgui.OpenPopup("Licenses")
|
||||
view.model.windowOpen = false
|
||||
imgui.SetNextWindowSize(imgui.Vec2{X: 640 * view.guiScale, Y: 480 * view.guiScale})
|
||||
}
|
||||
if imgui.BeginPopupModalV("Licenses", nil, imgui.WindowFlagsHorizontalScrollbar|imgui.WindowFlagsNoSavedSettings) {
|
||||
view.renderContent()
|
||||
imgui.EndPopup()
|
||||
}
|
||||
}
|
||||
|
||||
func (view *LicensesView) renderContent() {
|
||||
if imgui.TreeNodeV("System Shock", imgui.TreeNodeFlagsDefaultOpen) {
|
||||
imgui.Text("System Shock by Night Dive Studios, LLC.\nOriginally by Looking Glass Technologies")
|
||||
imgui.TreePop()
|
||||
}
|
||||
add := func(title, license string) {
|
||||
if imgui.TreeNode(title) {
|
||||
imgui.Text(license)
|
||||
imgui.TreePop()
|
||||
}
|
||||
}
|
||||
|
||||
add("InkyBlackness - HackEd", inkyblacknessHackedLicense)
|
||||
add("InkyBlackness - imgui", inkyblacknessImGuiLicense)
|
||||
add("go-gl/glfw - Go bindings for GLFW 3", goglGlfwLicense)
|
||||
add("go-gl/gl", goglOpenGLLicense)
|
||||
add("go-gl/mathgl", goglMathGLLicense)
|
||||
add("Dear ImGui", dearImGuiLicense)
|
||||
add("GLFW", glfwLicense)
|
||||
add("MinGW-w64", mingwLicense)
|
||||
|
||||
imgui.Separator()
|
||||
if imgui.Button("OK") {
|
||||
imgui.CloseCurrentPopup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package about
|
||||
|
||||
import (
|
||||
"github.com/inkyblackness/imgui-go/v3"
|
||||
|
||||
"github.com/inkyblackness/hacked/editor/external"
|
||||
)
|
||||
|
||||
// View handles the about display.
|
||||
type View struct {
|
||||
version string
|
||||
guiScale float32
|
||||
clipboard external.Clipboard
|
||||
|
||||
model viewModel
|
||||
}
|
||||
|
||||
// NewView creates a new instance for the about display.
|
||||
func NewView(clipboard external.Clipboard, guiScale float32, version string) *View {
|
||||
return &View{
|
||||
version: version,
|
||||
guiScale: guiScale,
|
||||
clipboard: clipboard,
|
||||
|
||||
model: freshViewModel(),
|
||||
}
|
||||
}
|
||||
|
||||
// Show requests to show the about view.
|
||||
func (view *View) Show() {
|
||||
view.model.windowOpen = true
|
||||
}
|
||||
|
||||
// Render requests to render the view.
|
||||
func (view *View) Render() {
|
||||
if view.model.windowOpen {
|
||||
imgui.OpenPopup("About")
|
||||
view.model.windowOpen = false
|
||||
}
|
||||
if imgui.BeginPopupModalV("About", nil, imgui.WindowFlagsAlwaysAutoResize|imgui.WindowFlagsNoMove|imgui.WindowFlagsHorizontalScrollbar) {
|
||||
view.renderContent()
|
||||
imgui.EndPopup()
|
||||
}
|
||||
}
|
||||
|
||||
func (view *View) renderContent() {
|
||||
projectURL := "https://inkyblackness.github.io"
|
||||
communityURL := "https://www.systemshock.org"
|
||||
userguideURL := "https://github.com/inkyblackness/hacked/wiki"
|
||||
urlLine := func(title, url string) {
|
||||
imgui.Text(title + ": " + url)
|
||||
imgui.SameLine()
|
||||
if imgui.Button("-> Clip##" + title) {
|
||||
view.clipboard.SetString(url)
|
||||
}
|
||||
}
|
||||
|
||||
imgui.Text("InkyBlackness - HackEd - " + view.version)
|
||||
imgui.Separator()
|
||||
urlLine("User guide", userguideURL)
|
||||
urlLine("Community", communityURL)
|
||||
urlLine("Project", projectURL)
|
||||
imgui.Separator()
|
||||
imgui.Text("Thanks to: 3RDPlayer: testing, ToxicFrog: name idea.")
|
||||
imgui.Text("Written in Go. Because I can.")
|
||||
imgui.Separator()
|
||||
if imgui.Button("OK") {
|
||||
imgui.CloseCurrentPopup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package about
|
||||
|
||||
type viewModel struct {
|
||||
windowOpen bool
|
||||
}
|
||||
|
||||
func freshViewModel() viewModel {
|
||||
return viewModel{}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package about
|
||||
|
||||
var inkyblacknessHackedLicense = `New BSD License
|
||||
|
||||
© 2018, Christian Haas
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of "InkyBlackness" nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE LISTED COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
`
|
||||
|
||||
var inkyblacknessImGuiLicense = `New BSD License
|
||||
|
||||
© 2018, Christian Haas
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of "InkyBlackness" nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE LISTED COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
`
|
||||
|
||||
var dearImGuiLicense = `The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2018 Omar Cornut
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
`
|
||||
|
||||
var mingwLicense = `With exception of certain parts that are prominently marked as being
|
||||
in the Public Domain, BSD, or LGPL this Software is provided under the
|
||||
Zope Public License (ZPL) Version 2.1.
|
||||
|
||||
Copyright (c) 2009 - 2013 by the mingw-w64 project
|
||||
|
||||
See the AUTHORS file for the list of contributors to the mingw-w64 project.
|
||||
|
||||
This license has been certified as open source. It has also been designated
|
||||
as GPL compatible by the Free Software Foundation (FSF).
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions in source code must retain the accompanying copyright
|
||||
notice, this list of conditions, and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the accompanying
|
||||
copyright notice, this list of conditions, and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
3. Names of the copyright holders must not be used to endorse or promote
|
||||
products derived from this software without prior written permission
|
||||
from the copyright holders.
|
||||
4. The right to distribute this software or to use it for any purpose does
|
||||
not give you the right to use Servicemarks (sm) or Trademarks (tm) of
|
||||
the copyright holders. Use of them is covered by separate agreement
|
||||
with the copyright holders.
|
||||
5. If any files are modified, you must cause the modified files to carry
|
||||
prominent notices stating that you changed the files and the date of
|
||||
any change.
|
||||
|
||||
Disclaimer
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ''AS IS'' AND ANY EXPRESSED
|
||||
OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
|
||||
EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
|
||||
OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
|
||||
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
`
|
||||
|
||||
var goglGlfwLicense = `Copyright (c) 2012 The glfw3-go Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
`
|
||||
|
||||
var goglOpenGLLicense = `The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 Eric Woroshow
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
`
|
||||
|
||||
var goglMathGLLicense = `Copyright ©2013 The go-gl Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of the gonum project nor the names of its authors and
|
||||
contributors may be used to endorse or promote products derived from this
|
||||
software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
`
|
||||
|
||||
var glfwLicense = `Copyright (c) 2002-2006 Marcus Geelnard
|
||||
Copyright (c) 2006-2016 Camilla Löwy <elmindreda@glfw.org>
|
||||
|
||||
This software is provided 'as-is', without any express or implied
|
||||
warranty. In no event will the authors be held liable for any damages
|
||||
arising from the use of this software.
|
||||
|
||||
Permission is granted to anyone to use this software for any purpose,
|
||||
including commercial applications, and to alter it and redistribute it
|
||||
freely, subject to the following restrictions:
|
||||
|
||||
1. The origin of this software must not be misrepresented; you must not
|
||||
claim that you wrote the original software. If you use this software
|
||||
in a product, an acknowledgment in the product documentation would
|
||||
be appreciated but is not required.
|
||||
|
||||
2. Altered source versions must be plainly marked as such, and must not
|
||||
be misrepresented as being the original software.
|
||||
|
||||
3. This notice may not be removed or altered from any source
|
||||
distribution.
|
||||
`
|
||||
@@ -0,0 +1,41 @@
|
||||
package animations
|
||||
|
||||
import (
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/world"
|
||||
)
|
||||
|
||||
type setAnimationCommand struct {
|
||||
model *viewModel
|
||||
|
||||
animationKey resource.Key
|
||||
framesID resource.ID
|
||||
|
||||
oldAnimation []byte
|
||||
newAnimation []byte
|
||||
|
||||
oldFrames [][]byte
|
||||
newFrames [][]byte
|
||||
}
|
||||
|
||||
func (command setAnimationCommand) Do(modder world.Modder) error {
|
||||
return command.perform(modder, command.newAnimation, command.newFrames)
|
||||
}
|
||||
|
||||
func (command setAnimationCommand) Undo(modder world.Modder) error {
|
||||
return command.perform(modder, command.oldAnimation, command.oldFrames)
|
||||
}
|
||||
|
||||
func (command setAnimationCommand) perform(modder world.Modder, animData []byte, frames [][]byte) error {
|
||||
if len(frames) == 0 {
|
||||
modder.DelResource(command.animationKey.Lang, command.animationKey.ID.Plus(command.animationKey.Index))
|
||||
modder.DelResource(command.animationKey.Lang, command.framesID)
|
||||
} else {
|
||||
modder.SetResourceBlock(command.animationKey.Lang, command.animationKey.ID.Plus(command.animationKey.Index), 0, animData)
|
||||
modder.SetResourceBlocks(command.animationKey.Lang, command.framesID, frames)
|
||||
}
|
||||
|
||||
command.model.restoreFocus = true
|
||||
command.model.currentKey = command.animationKey
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package animations
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/inkyblackness/imgui-go/v3"
|
||||
|
||||
"github.com/inkyblackness/hacked/editor/external"
|
||||
"github.com/inkyblackness/hacked/editor/graphics"
|
||||
"github.com/inkyblackness/hacked/editor/render"
|
||||
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/serial/rle"
|
||||
"github.com/inkyblackness/hacked/ss1/world"
|
||||
"github.com/inkyblackness/hacked/ss1/world/ids"
|
||||
"github.com/inkyblackness/hacked/ui/gui"
|
||||
)
|
||||
|
||||
type animationInfo struct {
|
||||
title string
|
||||
}
|
||||
|
||||
var knownAnimationTypes = map[resource.ID]animationInfo{
|
||||
ids.VideoMailAnimationsStart: {title: "Video Mail Parts"},
|
||||
}
|
||||
|
||||
var knownAnimationTypesOrder = []resource.ID{
|
||||
ids.VideoMailAnimationsStart,
|
||||
}
|
||||
|
||||
// View provides edit controls for animations.
|
||||
type View struct {
|
||||
mod *world.Mod
|
||||
imageCache *graphics.TextureCache
|
||||
paletteCache *graphics.PaletteCache
|
||||
animationCache *bitmap.AnimationCache
|
||||
|
||||
modalStateMachine gui.ModalStateMachine
|
||||
guiScale float32
|
||||
commander cmd.Commander
|
||||
|
||||
model viewModel
|
||||
}
|
||||
|
||||
// NewAnimationsView returns a new instance.
|
||||
func NewAnimationsView(mod *world.Mod, imageCache *graphics.TextureCache, paletteCache *graphics.PaletteCache,
|
||||
animationCache *bitmap.AnimationCache,
|
||||
modalStateMachine gui.ModalStateMachine, guiScale float32, commander cmd.Commander) *View {
|
||||
view := &View{
|
||||
mod: mod,
|
||||
imageCache: imageCache,
|
||||
paletteCache: paletteCache,
|
||||
animationCache: animationCache,
|
||||
|
||||
modalStateMachine: modalStateMachine,
|
||||
guiScale: guiScale,
|
||||
commander: commander,
|
||||
|
||||
model: freshViewModel(),
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// WindowOpen returns the flag address, to be used with the main menu.
|
||||
func (view *View) WindowOpen() *bool {
|
||||
return &view.model.windowOpen
|
||||
}
|
||||
|
||||
// Render renders the view.
|
||||
func (view *View) Render() {
|
||||
if view.model.restoreFocus {
|
||||
imgui.SetNextWindowFocus()
|
||||
view.model.restoreFocus = false
|
||||
view.model.windowOpen = true
|
||||
}
|
||||
if view.model.windowOpen {
|
||||
imgui.SetNextWindowSizeV(imgui.Vec2{X: 800 * view.guiScale, Y: 300 * view.guiScale}, imgui.ConditionFirstUseEver)
|
||||
if imgui.BeginV("Animations", view.WindowOpen(), imgui.WindowFlagsNoCollapse|imgui.WindowFlagsHorizontalScrollbar) {
|
||||
view.renderContent()
|
||||
}
|
||||
imgui.End()
|
||||
}
|
||||
}
|
||||
|
||||
func (view *View) renderContent() {
|
||||
if imgui.BeginChildV("Properties", imgui.Vec2{X: 350 * view.guiScale, Y: 0}, false, 0) {
|
||||
imgui.PushItemWidth(-150 * view.guiScale)
|
||||
if imgui.BeginCombo("Animation Type", knownAnimationTypes[view.model.currentKey.ID].title) {
|
||||
for _, id := range knownAnimationTypesOrder {
|
||||
if imgui.SelectableV(knownAnimationTypes[id].title, id == view.model.currentKey.ID, 0, imgui.Vec2{}) {
|
||||
view.model.currentKey.ID = id
|
||||
view.model.currentKey.Index = 0
|
||||
view.model.currentFrame = 0
|
||||
}
|
||||
}
|
||||
imgui.EndCombo()
|
||||
}
|
||||
|
||||
info, _ := ids.Info(view.model.currentKey.ID)
|
||||
|
||||
if gui.StepSliderInt("Index", &view.model.currentKey.Index, 0, info.MaxCount-1) {
|
||||
view.model.currentFrame = 0
|
||||
}
|
||||
|
||||
imgui.Separator()
|
||||
|
||||
view.renderProperties()
|
||||
|
||||
imgui.PopItemWidth()
|
||||
}
|
||||
imgui.EndChild()
|
||||
imgui.SameLine()
|
||||
|
||||
if imgui.BeginChildV("Frames", imgui.Vec2{X: -1, Y: 0}, false, 0) {
|
||||
anim, hasAnim, _ := view.currentAnimation()
|
||||
|
||||
if hasAnim {
|
||||
frameKey := resource.KeyOf(anim.ResourceID, resource.LangAny, view.model.currentFrame)
|
||||
if view.cacheFrame(frameKey) {
|
||||
render.TextureImage("Frame", view.imageCache, frameKey,
|
||||
imgui.Vec2{X: float32(anim.Width) * view.guiScale, Y: float32(anim.Height) * view.guiScale})
|
||||
if imgui.Button("Export") {
|
||||
view.requestExport()
|
||||
}
|
||||
}
|
||||
imgui.SameLine()
|
||||
if imgui.Button("Import") {
|
||||
view.requestImport()
|
||||
}
|
||||
}
|
||||
}
|
||||
imgui.EndChild()
|
||||
}
|
||||
|
||||
func (view *View) cacheFrame(key resource.Key) bool {
|
||||
var err error
|
||||
var lastKey *resource.Key
|
||||
for index := 0; (index <= key.Index) && (err == nil); index++ {
|
||||
nextKey := resource.KeyOf(key.ID, key.Lang, index)
|
||||
_, err = view.imageCache.TextureReferenced(nextKey, lastKey)
|
||||
lastKey = &nextKey
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (view *View) renderProperties() {
|
||||
anim, hasAnim, _ := view.currentAnimation()
|
||||
widthString := ""
|
||||
heightString := ""
|
||||
lastFrame := 0
|
||||
|
||||
if hasAnim {
|
||||
widthString = fmt.Sprintf("%d", anim.Width)
|
||||
heightString = fmt.Sprintf("%d", anim.Height)
|
||||
for _, entry := range anim.Entries {
|
||||
lastFrame = int(entry.LastFrame)
|
||||
}
|
||||
}
|
||||
|
||||
imgui.LabelText("Width", widthString)
|
||||
imgui.LabelText("Height", heightString)
|
||||
|
||||
gui.StepSliderInt("Frame Index", &view.model.currentFrame, 0, lastFrame)
|
||||
}
|
||||
|
||||
func (view *View) currentAnimation() (bitmap.Animation, bool, bool) {
|
||||
key := resource.KeyOf(view.model.currentKey.ID.Plus(view.model.currentKey.Index), resource.LangAny, 0)
|
||||
anim, err := view.animationCache.Animation(key)
|
||||
if err != nil {
|
||||
return anim, false, true
|
||||
}
|
||||
readOnly := len(view.mod.ModifiedBlocks(resource.LangAny, key.ID)) == 0
|
||||
return anim, true, readOnly
|
||||
}
|
||||
|
||||
func (view *View) requestImport() {
|
||||
info := "File must be an animated GIF file.\nIdeally, it matches the game palette 1:1,\nothers are mapped closest fitting."
|
||||
types := []external.TypeInfo{{Title: "Animation files (*.gif)", Extensions: []string{"gif"}}}
|
||||
var fileHandler func(string)
|
||||
|
||||
fileHandler = func(filename string) {
|
||||
reader, err := os.Open(filename)
|
||||
if err != nil {
|
||||
external.Import(view.modalStateMachine, "Could not open file.\n"+info, types, fileHandler, true)
|
||||
return
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
data, err := gif.DecodeAll(reader)
|
||||
if err != nil {
|
||||
external.Import(view.modalStateMachine, "File not recognized as GIF.\n"+info, types, fileHandler, true)
|
||||
return
|
||||
}
|
||||
|
||||
palette, err := view.paletteCache.Palette(0)
|
||||
if err != nil {
|
||||
external.Import(view.modalStateMachine, "Can not import image without having a palette loaded.\n"+info, types, fileHandler, true)
|
||||
return
|
||||
}
|
||||
anim := bitmap.Animation{
|
||||
Width: int16(data.Config.Width),
|
||||
Height: int16(data.Config.Height),
|
||||
ResourceID: view.model.currentKey.ID.Plus(view.model.currentKey.Index).Plus(-12),
|
||||
IntroFlag: 0,
|
||||
}
|
||||
var frames [][]byte
|
||||
rawPalette := palette.Palette()
|
||||
|
||||
highestBitShift := func(value int16) (result byte) {
|
||||
if value != 0 {
|
||||
for (value >> result) != 1 {
|
||||
result++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var prevFrame []byte
|
||||
for index, img := range data.Image {
|
||||
if (img.Bounds().Max.X == data.Config.Width) && (img.Bounds().Max.Y == data.Config.Height) {
|
||||
bitmapper := bitmap.NewBitmapper(&rawPalette)
|
||||
bmp := bitmapper.Map(img)
|
||||
entry := bitmap.AnimationEntry{
|
||||
FirstFrame: byte(index),
|
||||
LastFrame: byte(index),
|
||||
FrameTime: int16(data.Delay[index] * 10),
|
||||
}
|
||||
anim.Entries = append(anim.Entries, entry)
|
||||
bmp.Header.Type = bitmap.TypeCompressed8Bit
|
||||
bmp.Header.WidthFactor = highestBitShift(bmp.Header.Width)
|
||||
bmp.Header.HeightFactor = highestBitShift(bmp.Header.Height)
|
||||
bmp.Header.Area = [4]int16{0, 0, anim.Width, anim.Height}
|
||||
bmp.Header.Stride = uint16(bmp.Header.Width)
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
_ = binary.Write(buf, binary.LittleEndian, &bmp.Header)
|
||||
_ = rle.Compress(buf, bmp.Pixels, prevFrame)
|
||||
prevFrame = bmp.Pixels
|
||||
frames = append(frames, buf.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
view.requestSetAnimation(anim, frames)
|
||||
}
|
||||
|
||||
external.Import(view.modalStateMachine, info, types, fileHandler, false)
|
||||
}
|
||||
|
||||
func (view *View) requestExport() {
|
||||
anim, hasAnim, _ := view.currentAnimation()
|
||||
if hasAnim {
|
||||
filename := fmt.Sprintf("Anim%04X.gif", int(view.model.currentKey.ID.Plus(view.model.currentKey.Index)))
|
||||
view.exportTo(filename, anim)
|
||||
}
|
||||
}
|
||||
|
||||
func (view *View) exportTo(filename string, anim bitmap.Animation) {
|
||||
info := "File to be written: " + filename
|
||||
var exportTo func(string)
|
||||
|
||||
exportTo = func(dirname string) {
|
||||
writer, err := os.Create(filepath.Join(dirname, filename))
|
||||
if err != nil {
|
||||
external.Export(view.modalStateMachine, "Could not create file.\n"+info, exportTo, true)
|
||||
return
|
||||
}
|
||||
defer func() { _ = writer.Close() }()
|
||||
|
||||
palTex, err := view.paletteCache.Palette(0)
|
||||
if err != nil {
|
||||
external.Export(view.modalStateMachine, "Could not create file. No palette loaded.\n"+info, exportTo, true)
|
||||
return
|
||||
}
|
||||
|
||||
colorPalette := palTex.Palette().ColorPalette(false)
|
||||
data := gif.GIF{
|
||||
Config: image.Config{
|
||||
Width: int(anim.Width),
|
||||
Height: int(anim.Height),
|
||||
ColorModel: colorPalette,
|
||||
},
|
||||
LoopCount: -1,
|
||||
}
|
||||
|
||||
imageRect := image.Rect(0, 0, data.Config.Width, data.Config.Height)
|
||||
frameIndex := 0
|
||||
for _, entry := range anim.Entries {
|
||||
for frameIndex <= int(entry.LastFrame) {
|
||||
frameKey := resource.KeyOf(anim.ResourceID, resource.LangAny, frameIndex)
|
||||
if !view.cacheFrame(frameKey) {
|
||||
external.Export(view.modalStateMachine, "Failed to cache frame.\n"+info, exportTo, true)
|
||||
return
|
||||
}
|
||||
frameTex, _ := view.imageCache.Texture(frameKey)
|
||||
frameImg := image.NewPaletted(imageRect, colorPalette)
|
||||
frameImg.Pix = frameTex.PixelData()
|
||||
data.Image = append(data.Image, frameImg)
|
||||
data.Delay = append(data.Delay, int(entry.FrameTime)/10)
|
||||
frameIndex++
|
||||
}
|
||||
}
|
||||
|
||||
err = gif.EncodeAll(writer, &data)
|
||||
if err != nil {
|
||||
external.Export(view.modalStateMachine, info, exportTo, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
external.Export(view.modalStateMachine, info, exportTo, false)
|
||||
}
|
||||
|
||||
func (view *View) requestSetAnimation(newAnim bitmap.Animation, newFrames [][]byte) {
|
||||
encodeAnim := func(anim bitmap.Animation) []byte {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
_ = bitmap.WriteAnimation(buf, anim)
|
||||
return buf.Bytes()
|
||||
}
|
||||
command := setAnimationCommand{
|
||||
model: &view.model,
|
||||
animationKey: view.model.currentKey,
|
||||
newAnimation: encodeAnim(newAnim),
|
||||
newFrames: newFrames,
|
||||
framesID: newAnim.ResourceID,
|
||||
}
|
||||
oldAnim, oldExisting, _ := view.currentAnimation()
|
||||
if oldExisting {
|
||||
command.oldAnimation = encodeAnim(oldAnim)
|
||||
}
|
||||
command.oldFrames = view.mod.ModifiedBlocks(view.model.currentKey.Lang, newAnim.ResourceID)
|
||||
view.commander.Queue(command)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package animations
|
||||
|
||||
import (
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/world/ids"
|
||||
)
|
||||
|
||||
type viewModel struct {
|
||||
windowOpen bool
|
||||
restoreFocus bool
|
||||
|
||||
currentKey resource.Key
|
||||
currentFrame int
|
||||
}
|
||||
|
||||
func freshViewModel() viewModel {
|
||||
return viewModel{
|
||||
currentKey: resource.KeyOf(ids.VideoMailAnimationsStart, resource.LangAny, 0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package archives
|
||||
|
||||
import (
|
||||
"github.com/inkyblackness/imgui-go/v3"
|
||||
|
||||
"github.com/inkyblackness/hacked/editor/values"
|
||||
"github.com/inkyblackness/hacked/ss1/content/archive"
|
||||
"github.com/inkyblackness/hacked/ss1/edit"
|
||||
"github.com/inkyblackness/hacked/ui/gui"
|
||||
)
|
||||
|
||||
type editBooleanVariableDialog struct {
|
||||
machine gui.ModalStateMachine
|
||||
view *View
|
||||
|
||||
service *edit.GameStateService
|
||||
index int
|
||||
varOverride bool
|
||||
info archive.GameVariableInfo
|
||||
|
||||
opened bool
|
||||
}
|
||||
|
||||
func (dialog *editBooleanVariableDialog) Render() {
|
||||
if !dialog.opened {
|
||||
dialog.opened = true
|
||||
dialog.varOverride = dialog.service.BooleanVariableOverride(dialog.index)
|
||||
dialog.info = dialog.service.BooleanVariable(dialog.index)
|
||||
imgui.OpenPopup("Edit boolean variable")
|
||||
}
|
||||
|
||||
imgui.SetNextWindowSize(imgui.Vec2{X: 600, Y: 0}.Times(dialog.view.guiScale))
|
||||
if imgui.BeginPopupModalV("Edit boolean variable", nil,
|
||||
imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsAlwaysAutoResize) {
|
||||
dialog.renderControls()
|
||||
imgui.Separator()
|
||||
if imgui.Button("OK") {
|
||||
dialog.applyChanges()
|
||||
dialog.close()
|
||||
}
|
||||
imgui.SameLine()
|
||||
if imgui.Button("Cancel") {
|
||||
dialog.close()
|
||||
}
|
||||
imgui.EndPopup()
|
||||
} else {
|
||||
dialog.machine.SetState(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (dialog *editBooleanVariableDialog) applyChanges() {
|
||||
if dialog.varOverride {
|
||||
_ = dialog.service.SetBooleanVariable(dialog.index, dialog.info)
|
||||
} else {
|
||||
_ = dialog.service.DefaultBooleanVariable(dialog.index)
|
||||
}
|
||||
}
|
||||
|
||||
func (dialog *editBooleanVariableDialog) close() {
|
||||
dialog.machine.SetState(nil)
|
||||
imgui.CloseCurrentPopup()
|
||||
}
|
||||
|
||||
func (dialog *editBooleanVariableDialog) renderControls() {
|
||||
imgui.PushItemWidth(-150 * dialog.view.guiScale)
|
||||
values.RenderUnifiedCheckboxCombo(false, "Override", values.UnifierFor(dialog.varOverride), func(newValue bool) {
|
||||
dialog.varOverride = newValue
|
||||
})
|
||||
|
||||
textFlags := imgui.InputTextFlagsNoUndoRedo
|
||||
if !dialog.varOverride {
|
||||
textFlags |= imgui.InputTextFlagsReadOnly
|
||||
}
|
||||
imgui.InputTextV("Name", &dialog.info.Name, textFlags, nil)
|
||||
imgui.InputTextMultilineV("Description", &dialog.info.Description, imgui.Vec2{X: 0, Y: 100 * dialog.view.guiScale},
|
||||
textFlags, nil)
|
||||
|
||||
imgui.Separator()
|
||||
defaultValues := map[int]string{0: "False", 1: "True"}
|
||||
valueLabels := map[bool]string{false: "Default", true: "Special"}
|
||||
hasSpecialValues := dialog.info.ValueNames != nil
|
||||
if dialog.varOverride {
|
||||
if imgui.BeginCombo("Values", valueLabels[hasSpecialValues]) {
|
||||
if imgui.SelectableV(valueLabels[false], !hasSpecialValues, 0, imgui.Vec2{}) {
|
||||
dialog.info.ValueNames = nil
|
||||
}
|
||||
if imgui.SelectableV(valueLabels[true], hasSpecialValues, 0, imgui.Vec2{}) {
|
||||
dialog.info.ValueNames = map[int16]string{0: defaultValues[0], 1: defaultValues[1]}
|
||||
}
|
||||
imgui.EndCombo()
|
||||
}
|
||||
} else {
|
||||
imgui.LabelText("Values", valueLabels[hasSpecialValues])
|
||||
}
|
||||
hasSpecialValues = dialog.info.ValueNames != nil
|
||||
if hasSpecialValues {
|
||||
for i := int16(0); i < 2; i++ {
|
||||
text := dialog.info.ValueNames[i]
|
||||
if imgui.InputTextV(defaultValues[int(i)], &text, textFlags, nil) {
|
||||
dialog.info.ValueNames[i] = text
|
||||
}
|
||||
}
|
||||
} else {
|
||||
imgui.LabelText(defaultValues[0], defaultValues[0])
|
||||
imgui.LabelText(defaultValues[1], defaultValues[1])
|
||||
}
|
||||
|
||||
initUnifier := values.UnifierFor(dialog.info.ResetValueInt())
|
||||
values.RenderUnifiedCombo(dialog.info.Hardcoded || !dialog.varOverride, "Reset Value", initUnifier,
|
||||
func(u values.Unifier) int { return int(u.Unified().(int16)) },
|
||||
func(value int) string {
|
||||
name, found := dialog.info.ValueNames[int16(value)]
|
||||
if found {
|
||||
return name
|
||||
}
|
||||
return defaultValues[value]
|
||||
},
|
||||
2,
|
||||
func(newValue int) {
|
||||
value16 := int16(newValue)
|
||||
dialog.info.InitValue = &value16
|
||||
})
|
||||
|
||||
imgui.PopItemWidth()
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package archives
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"github.com/inkyblackness/imgui-go/v3"
|
||||
|
||||
"github.com/inkyblackness/hacked/editor/values"
|
||||
"github.com/inkyblackness/hacked/ss1/content/archive"
|
||||
"github.com/inkyblackness/hacked/ss1/edit"
|
||||
"github.com/inkyblackness/hacked/ui/gui"
|
||||
)
|
||||
|
||||
type editIntegerVariableDialog struct {
|
||||
machine gui.ModalStateMachine
|
||||
view *View
|
||||
|
||||
service *edit.GameStateService
|
||||
index int
|
||||
varOverride bool
|
||||
info archive.GameVariableInfo
|
||||
|
||||
selectedEnum int
|
||||
opened bool
|
||||
}
|
||||
|
||||
func (dialog *editIntegerVariableDialog) Render() {
|
||||
if !dialog.opened {
|
||||
dialog.opened = true
|
||||
dialog.varOverride = dialog.service.IntegerVariableOverride(dialog.index)
|
||||
dialog.info = dialog.service.IntegerVariable(dialog.index)
|
||||
imgui.OpenPopup("Edit integer variable")
|
||||
}
|
||||
|
||||
imgui.SetNextWindowSize(imgui.Vec2{X: 600, Y: 0}.Times(dialog.view.guiScale))
|
||||
if imgui.BeginPopupModalV("Edit integer variable", nil,
|
||||
imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsAlwaysAutoResize) {
|
||||
dialog.renderControls()
|
||||
imgui.Separator()
|
||||
if imgui.Button("OK") {
|
||||
dialog.applyChanges()
|
||||
dialog.close()
|
||||
}
|
||||
imgui.SameLine()
|
||||
if imgui.Button("Cancel") {
|
||||
dialog.close()
|
||||
}
|
||||
imgui.EndPopup()
|
||||
} else {
|
||||
dialog.machine.SetState(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (dialog *editIntegerVariableDialog) applyChanges() {
|
||||
if dialog.varOverride {
|
||||
_ = dialog.service.SetIntegerVariable(dialog.index, dialog.info)
|
||||
} else {
|
||||
_ = dialog.service.DefaultIntegerVariable(dialog.index)
|
||||
}
|
||||
}
|
||||
|
||||
func (dialog *editIntegerVariableDialog) close() {
|
||||
dialog.machine.SetState(nil)
|
||||
imgui.CloseCurrentPopup()
|
||||
}
|
||||
|
||||
func (dialog *editIntegerVariableDialog) renderControls() {
|
||||
imgui.PushItemWidth(-150 * dialog.view.guiScale)
|
||||
values.RenderUnifiedCheckboxCombo(false, "Override", values.UnifierFor(dialog.varOverride), func(newValue bool) {
|
||||
dialog.varOverride = newValue
|
||||
})
|
||||
|
||||
textFlags := imgui.InputTextFlagsNoUndoRedo
|
||||
if !dialog.varOverride {
|
||||
textFlags |= imgui.InputTextFlagsReadOnly
|
||||
}
|
||||
imgui.InputTextV("Name", &dialog.info.Name, textFlags, nil)
|
||||
imgui.InputTextMultilineV("Description", &dialog.info.Description, imgui.Vec2{X: 0, Y: 100 * dialog.view.guiScale},
|
||||
textFlags, nil)
|
||||
|
||||
imgui.Separator()
|
||||
|
||||
valueLabels := map[bool]string{false: "Numeric", true: "Enumerated"}
|
||||
isEnumerated := len(dialog.info.ValueNames) > 0
|
||||
if imgui.BeginCombo("Values", valueLabels[isEnumerated]) {
|
||||
if imgui.SelectableV(valueLabels[false], !isEnumerated, 0, imgui.Vec2{}) {
|
||||
dialog.info.ValueNames = nil
|
||||
}
|
||||
if imgui.SelectableV(valueLabels[true], isEnumerated, 0, imgui.Vec2{}) {
|
||||
dialog.info.ValueNames = map[int16]string{0: "Zero"}
|
||||
dialog.selectedEnum = 0
|
||||
}
|
||||
imgui.EndCombo()
|
||||
}
|
||||
if len(dialog.info.ValueNames) > 0 {
|
||||
gui.StepSliderInt("Index", &dialog.selectedEnum, math.MinInt16, math.MaxInt16)
|
||||
if dialog.varOverride {
|
||||
text := dialog.info.ValueNames[int16(dialog.selectedEnum)]
|
||||
if imgui.InputTextV("Value", &text, textFlags, nil) {
|
||||
if len(text) > 0 {
|
||||
dialog.info.ValueNames[int16(dialog.selectedEnum)] = text
|
||||
} else {
|
||||
delete(dialog.info.ValueNames, int16(dialog.selectedEnum))
|
||||
if len(dialog.info.ValueNames) == 0 {
|
||||
dialog.info.ValueNames[0] = "Zero"
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
imgui.LabelText("Value", dialog.info.ValueNames[int16(dialog.selectedEnum)])
|
||||
}
|
||||
|
||||
resetValue := dialog.info.ResetValueInt()
|
||||
enumEntries := toEnumEntries(dialog.info.ValueNames)
|
||||
linearInitIndex := linearIndex(enumEntries, resetValue)
|
||||
initUnifier := values.UnifierFor(linearInitIndex)
|
||||
values.RenderUnifiedCombo(dialog.info.Hardcoded || !dialog.varOverride, "Reset Value", initUnifier,
|
||||
func(u values.Unifier) int { return u.Unified().(int) },
|
||||
func(value int) string {
|
||||
if value < 0 {
|
||||
return fmt.Sprintf("%d: (unmapped)", resetValue)
|
||||
}
|
||||
entry := enumEntries[value]
|
||||
return fmt.Sprintf("%d: '%s'", entry.index, entry.value)
|
||||
},
|
||||
len(enumEntries),
|
||||
func(newValue int) {
|
||||
dialog.info.InitValue = &enumEntries[newValue].index
|
||||
})
|
||||
} else {
|
||||
minValue := math.MinInt16
|
||||
maxValue := math.MaxInt16
|
||||
|
||||
if dialog.info.Limits != nil {
|
||||
minValue = int(dialog.info.Limits.Minimum)
|
||||
maxValue = int(dialog.info.Limits.Maximum)
|
||||
}
|
||||
if minValue > maxValue {
|
||||
maxValue = minValue
|
||||
}
|
||||
if dialog.varOverride {
|
||||
if gui.StepSliderInt("Minimum", &minValue, math.MinInt16, math.MaxInt16) {
|
||||
if minValue > maxValue {
|
||||
maxValue = minValue
|
||||
}
|
||||
}
|
||||
if gui.StepSliderInt("Maximum", &maxValue, math.MinInt16, math.MaxInt16) {
|
||||
if maxValue < minValue {
|
||||
minValue = maxValue
|
||||
}
|
||||
}
|
||||
if (minValue == math.MinInt16) && (maxValue == math.MaxInt16) {
|
||||
dialog.info.Limits = nil
|
||||
} else {
|
||||
if dialog.info.Limits == nil {
|
||||
dialog.info.Limits = &archive.GameVariableLimits{}
|
||||
}
|
||||
dialog.info.Limits.Minimum = int16(minValue)
|
||||
dialog.info.Limits.Maximum = int16(maxValue)
|
||||
}
|
||||
} else {
|
||||
imgui.LabelText("Minimum", fmt.Sprintf("%d", minValue))
|
||||
imgui.LabelText("Maximum", fmt.Sprintf("%d", maxValue))
|
||||
}
|
||||
|
||||
resetValue := int(dialog.info.ResetValueInt())
|
||||
if dialog.info.Hardcoded || !dialog.varOverride {
|
||||
imgui.LabelText("Reset Value", fmt.Sprintf("%d", resetValue))
|
||||
} else if gui.StepSliderInt("Reset Value", &resetValue, minValue, maxValue) {
|
||||
resetValue16 := int16(resetValue)
|
||||
dialog.info.InitValue = &resetValue16
|
||||
}
|
||||
}
|
||||
|
||||
imgui.PopItemWidth()
|
||||
}
|
||||
|
||||
type enumEntry struct {
|
||||
index int16
|
||||
value string
|
||||
}
|
||||
|
||||
func toEnumEntries(mapped map[int16]string) []enumEntry {
|
||||
minIndex := math.MaxInt16
|
||||
maxIndex := math.MinInt16
|
||||
for index := range mapped {
|
||||
if int(index) < minIndex {
|
||||
minIndex = int(index)
|
||||
}
|
||||
if int(index) > maxIndex {
|
||||
maxIndex = int(index)
|
||||
}
|
||||
}
|
||||
orderedEntries := make([]enumEntry, 0, len(mapped))
|
||||
for index, value := range mapped {
|
||||
orderedEntries = append(orderedEntries, enumEntry{
|
||||
index: index,
|
||||
value: value,
|
||||
})
|
||||
}
|
||||
sort.Slice(orderedEntries, func(a, b int) bool { return orderedEntries[a].index < orderedEntries[b].index })
|
||||
return orderedEntries
|
||||
}
|
||||
|
||||
func linearIndex(entries []enumEntry, index int16) int {
|
||||
for linear, entry := range entries {
|
||||
if entry.index == index {
|
||||
return linear
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package archives
|
||||
|
||||
import (
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/world"
|
||||
)
|
||||
|
||||
type setArchiveDataCommand struct {
|
||||
model *viewModel
|
||||
|
||||
selectedLevel int
|
||||
|
||||
oldData map[resource.ID][]byte
|
||||
newData map[resource.ID][]byte
|
||||
}
|
||||
|
||||
func (command setArchiveDataCommand) Do(modder world.Modder) error {
|
||||
command.delResources(modder, command.oldData)
|
||||
return command.perform(modder, command.newData)
|
||||
}
|
||||
|
||||
func (command setArchiveDataCommand) Undo(modder world.Modder) error {
|
||||
command.delResources(modder, command.newData)
|
||||
return command.perform(modder, command.oldData)
|
||||
}
|
||||
|
||||
func (command setArchiveDataCommand) delResources(modder world.Modder, data map[resource.ID][]byte) {
|
||||
for id := range data {
|
||||
modder.DelResource(resource.LangAny, id)
|
||||
}
|
||||
}
|
||||
|
||||
// nolint: interfacer
|
||||
func (command setArchiveDataCommand) perform(modder world.Modder, data map[resource.ID][]byte) error {
|
||||
for id, blockData := range data {
|
||||
if len(blockData) > 0 {
|
||||
modder.SetResourceBlocks(resource.LangAny, id, [][]byte{blockData})
|
||||
} else {
|
||||
modder.DelResource(resource.LangAny, id)
|
||||
}
|
||||
}
|
||||
command.model.restoreFocus = true
|
||||
command.model.selectedLevel = command.selectedLevel
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,934 @@
|
||||
package archives
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/inkyblackness/imgui-go/v3"
|
||||
|
||||
"github.com/inkyblackness/hacked/editor/values"
|
||||
"github.com/inkyblackness/hacked/ss1/content/archive"
|
||||
"github.com/inkyblackness/hacked/ss1/content/archive/level"
|
||||
"github.com/inkyblackness/hacked/ss1/content/archive/level/lvlids"
|
||||
"github.com/inkyblackness/hacked/ss1/content/archive/level/lvlobj"
|
||||
"github.com/inkyblackness/hacked/ss1/content/interpreters"
|
||||
"github.com/inkyblackness/hacked/ss1/content/numbers"
|
||||
"github.com/inkyblackness/hacked/ss1/content/object"
|
||||
"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/citadel"
|
||||
"github.com/inkyblackness/hacked/ss1/world/ids"
|
||||
"github.com/inkyblackness/hacked/ui/gui"
|
||||
)
|
||||
|
||||
const (
|
||||
hintUnknown = "???"
|
||||
)
|
||||
|
||||
var variableContextList = map[edit.VariableBaseContextIdentifier]struct {
|
||||
title string
|
||||
info string
|
||||
}{
|
||||
edit.VariableContextCitadel: {
|
||||
title: "Citadel Mission",
|
||||
info: "Variables are listed based on use on Citadel.\n" +
|
||||
"Useful for Citadel-based savegames and mods, such as 'New Game+' variants.",
|
||||
},
|
||||
edit.VariableContextEngine: {
|
||||
title: "Engine",
|
||||
info: "Variables are listed based on the hardcoded engine behaviour.\n" +
|
||||
"Useful for dedicated missions.",
|
||||
},
|
||||
}
|
||||
|
||||
// View provides edit controls for the archive.
|
||||
type View struct {
|
||||
registry cmd.Registry
|
||||
gameStateService *edit.GameStateService
|
||||
mod *world.Mod
|
||||
textCache *text.Cache
|
||||
cp text.Codepage
|
||||
|
||||
modalStateMachine gui.ModalStateMachine
|
||||
guiScale float32
|
||||
commander cmd.Commander
|
||||
|
||||
model viewModel
|
||||
}
|
||||
|
||||
// NewArchiveView returns a new instance.
|
||||
func NewArchiveView(registry cmd.Registry,
|
||||
gameStateService *edit.GameStateService, mod *world.Mod,
|
||||
textCache *text.Cache, cp text.Codepage,
|
||||
modalStateMachine gui.ModalStateMachine, guiScale float32, commander cmd.Commander) *View {
|
||||
view := &View{
|
||||
registry: registry,
|
||||
gameStateService: gameStateService,
|
||||
mod: mod,
|
||||
textCache: textCache,
|
||||
cp: cp,
|
||||
|
||||
modalStateMachine: modalStateMachine,
|
||||
guiScale: guiScale,
|
||||
commander: commander,
|
||||
|
||||
model: freshViewModel(),
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// WindowOpen returns the flag address, to be used with the main menu.
|
||||
func (view *View) WindowOpen() *bool {
|
||||
return &view.model.windowOpen
|
||||
}
|
||||
|
||||
// Render renders the view.
|
||||
func (view *View) Render() {
|
||||
if view.model.restoreFocus {
|
||||
imgui.SetNextWindowFocus()
|
||||
view.model.restoreFocus = false
|
||||
view.model.windowOpen = true
|
||||
}
|
||||
if view.model.windowOpen {
|
||||
imgui.SetNextWindowSizeV(imgui.Vec2{X: 650 * view.guiScale, Y: 400 * view.guiScale}, imgui.ConditionFirstUseEver)
|
||||
if imgui.BeginV("Archive", view.WindowOpen(), imgui.WindowFlagsNoCollapse) {
|
||||
view.renderContent()
|
||||
}
|
||||
imgui.End()
|
||||
}
|
||||
}
|
||||
|
||||
func (view *View) renderContent() {
|
||||
imgui.BeginTabBar("archive-tab")
|
||||
|
||||
if imgui.BeginTabItem("Levels") {
|
||||
view.renderLevelsContent()
|
||||
imgui.EndTabItem()
|
||||
}
|
||||
if imgui.BeginTabItem("Game State") {
|
||||
view.renderGameStateContent()
|
||||
imgui.EndTabItem()
|
||||
}
|
||||
|
||||
imgui.EndTabBar()
|
||||
}
|
||||
|
||||
func (view *View) renderLevelsContent() {
|
||||
imgui.BeginChildV("Levels", imgui.Vec2{X: -100 * view.guiScale, Y: 0}, true, 0)
|
||||
for id := 0; id < archive.MaxLevels; id++ {
|
||||
inMod := view.hasLevelInMod(id)
|
||||
info := fmt.Sprintf("%d", id)
|
||||
if !inMod {
|
||||
if view.hasGameStateInMod() && (id == view.effectiveGameState().CurrentLevel()) {
|
||||
// current level should really be in the archive.
|
||||
imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1.0, Y: 0.0, Z: 0.0, W: 1.0})
|
||||
} else {
|
||||
imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1.0, Y: 1.0, Z: 1.0, W: 0.8})
|
||||
}
|
||||
info += " (not in mod, read-only)"
|
||||
}
|
||||
if imgui.SelectableV(info, id == view.model.selectedLevel, 0, imgui.Vec2{}) {
|
||||
view.model.selectedLevel = id
|
||||
}
|
||||
if !inMod {
|
||||
imgui.PopStyleColor()
|
||||
}
|
||||
}
|
||||
imgui.EndChild()
|
||||
imgui.SameLine()
|
||||
imgui.BeginGroup()
|
||||
if imgui.ButtonV("Clear", imgui.Vec2{X: -1, Y: 0}) {
|
||||
view.requestClearLevel(view.model.selectedLevel)
|
||||
}
|
||||
if imgui.ButtonV("Remove", imgui.Vec2{X: -1, Y: 0}) {
|
||||
view.requestRemoveLevel(view.model.selectedLevel)
|
||||
}
|
||||
imgui.EndGroup()
|
||||
}
|
||||
|
||||
func (view *View) renderGameStateContent() {
|
||||
data := view.gameStateData()
|
||||
modStateIsDefaulting := false
|
||||
|
||||
imgui.PushItemWidth(-350 * view.guiScale)
|
||||
if data.isPresent() {
|
||||
modState := data.toInstance()
|
||||
if !modState.IsSavegame() {
|
||||
modStateIsDefaulting = modState.IsDefaulting()
|
||||
if modStateIsDefaulting {
|
||||
if imgui.Button("Override") {
|
||||
view.requestSetGameState(citadel.DefaultGameState().Raw())
|
||||
}
|
||||
if imgui.IsItemHovered() {
|
||||
imgui.SetTooltip("Values of new game archives are only considered by special engines.\n" +
|
||||
"Overriding them will only work in those.")
|
||||
}
|
||||
} else if imgui.Button("Remove") {
|
||||
view.requestSetGameState(archive.ZeroGameStateData())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readOnly := false
|
||||
var editState *archive.GameState
|
||||
if !data.isPresent() || modStateIsDefaulting {
|
||||
editState = citadel.DefaultGameState()
|
||||
readOnly = true
|
||||
} else {
|
||||
editState = data.imported().toInstance()
|
||||
}
|
||||
|
||||
onChange := func() {
|
||||
view.requestSetGameState(editState.Raw())
|
||||
}
|
||||
if imgui.TreeNodeV("Game State: General", imgui.TreeNodeFlagsDefaultOpen|imgui.TreeNodeFlagsFramed) {
|
||||
imgui.LabelText("Hacker Name", "\""+editState.HackerName(view.cp)+"\"")
|
||||
view.createPropertyControls(readOnly, editState.Instance, func(key string, modifier func(uint32) uint32) {
|
||||
view.setInterpreterValueKeyed(editState.Instance, key, modifier)
|
||||
onChange()
|
||||
})
|
||||
imgui.TreePop()
|
||||
}
|
||||
if imgui.TreeNodeV("Inventory", imgui.TreeNodeFlagsFramed) {
|
||||
view.createInventoryControls(readOnly, editState, onChange)
|
||||
imgui.TreePop()
|
||||
}
|
||||
if imgui.TreeNodeV("Hard-/Software", imgui.TreeNodeFlagsFramed) {
|
||||
view.createWareControls(readOnly, editState, onChange)
|
||||
imgui.TreePop()
|
||||
}
|
||||
if imgui.TreeNodeV("Game State: Variables", imgui.TreeNodeFlagsFramed) {
|
||||
view.createVariableControls(readOnly, editState, onChange)
|
||||
imgui.TreePop()
|
||||
}
|
||||
if imgui.TreeNodeV("Game State: Messages", imgui.TreeNodeFlagsFramed) {
|
||||
view.createMessageControls(readOnly, editState, onChange)
|
||||
imgui.TreePop()
|
||||
}
|
||||
|
||||
imgui.PopItemWidth()
|
||||
}
|
||||
|
||||
type gameStateData struct{ raw []byte }
|
||||
|
||||
func (data gameStateData) isPresent() bool {
|
||||
return len(data.raw) > 0
|
||||
}
|
||||
|
||||
func (data gameStateData) toInstance() *archive.GameState {
|
||||
return archive.NewGameState(data.raw)
|
||||
}
|
||||
|
||||
func (data gameStateData) snapshot() gameStateData {
|
||||
copied := make([]byte, len(data.raw))
|
||||
copy(copied, data.raw)
|
||||
return gameStateData{raw: copied}
|
||||
}
|
||||
|
||||
func (data gameStateData) imported() gameStateData {
|
||||
// Avoid truncating data when working with savegames.
|
||||
if len(data.raw) >= archive.GameStateSize {
|
||||
return data.snapshot()
|
||||
}
|
||||
copied := archive.ZeroGameStateData()
|
||||
copy(copied, data.raw)
|
||||
return gameStateData{raw: copied}
|
||||
}
|
||||
|
||||
func (view *View) gameStateData() gameStateData {
|
||||
raw := view.mod.ModifiedBlock(resource.LangAny, ids.GameState, 0)
|
||||
return gameStateData{raw: raw}
|
||||
}
|
||||
|
||||
func (view *View) effectiveGameState() *archive.GameState {
|
||||
state := view.gameStateData().toInstance()
|
||||
if state.IsDefaulting() {
|
||||
return citadel.DefaultGameState()
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func (view *View) hasGameStateInMod() bool {
|
||||
return len(view.mod.ModifiedBlocks(resource.LangAny, ids.GameState)) > 0
|
||||
}
|
||||
|
||||
func (view *View) hasLevelInMod(id int) bool {
|
||||
return len(view.mod.ModifiedBlocks(resource.LangAny, ids.LevelResourcesStart.Plus(lvlids.PerLevel*id+lvlids.FirstUsed))) > 0
|
||||
}
|
||||
|
||||
func (view *View) requestClearLevel(id int) {
|
||||
if (id >= 0) && (id < archive.MaxLevels) {
|
||||
command := setArchiveDataCommand{
|
||||
model: &view.model,
|
||||
selectedLevel: id,
|
||||
newData: make(map[resource.ID][]byte),
|
||||
oldData: make(map[resource.ID][]byte),
|
||||
}
|
||||
|
||||
if !view.hasGameStateInMod() {
|
||||
command.newData[ids.ArchiveName] = text.DefaultCodepage().Encode("Starting Game | by InkyBlackness HackEd")
|
||||
command.newData[ids.GameState] = archive.ZeroGameStateData()
|
||||
}
|
||||
|
||||
param := level.EmptyLevelParameters{
|
||||
Cyberspace: world.IsConsideredCyberspaceByDefault(id),
|
||||
}
|
||||
effectiveGameState := view.effectiveGameState()
|
||||
if id == effectiveGameState.CurrentLevel() {
|
||||
posX, posY := effectiveGameState.HackerMapPosition()
|
||||
param.MapModifier = func(x, y int, entry *level.TileMapEntry) {
|
||||
if (x == int(posX.Tile())) && (y == int(posY.Tile())) {
|
||||
entry.Type = level.TileTypeOpen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
levelData := level.EmptyLevelData(param)
|
||||
levelIDBegin := ids.LevelResourcesStart.Plus(lvlids.PerLevel * id)
|
||||
for offset, newData := range &levelData {
|
||||
if (offset < lvlids.FirstUsed) || (offset >= lvlids.FirstUnused) {
|
||||
continue
|
||||
}
|
||||
resourceID := levelIDBegin.Plus(offset)
|
||||
oldData := view.mod.ModifiedBlock(resource.LangAny, resourceID, 0)
|
||||
if len(oldData) > 0 {
|
||||
command.oldData[resourceID] = oldData
|
||||
}
|
||||
if len(newData) > 0 {
|
||||
command.newData[resourceID] = newData
|
||||
}
|
||||
}
|
||||
|
||||
view.commander.Queue(command)
|
||||
}
|
||||
}
|
||||
|
||||
func (view *View) requestRemoveLevel(id int) {
|
||||
if (id >= 0) && (id < archive.MaxLevels) && view.hasLevelInMod(id) {
|
||||
command := setArchiveDataCommand{
|
||||
model: &view.model,
|
||||
selectedLevel: id,
|
||||
newData: make(map[resource.ID][]byte),
|
||||
oldData: make(map[resource.ID][]byte),
|
||||
}
|
||||
|
||||
levelIDBegin := ids.LevelResourcesStart.Plus(lvlids.PerLevel * id)
|
||||
for offset := lvlids.FirstUsed; offset < lvlids.PerLevel; offset++ {
|
||||
resourceID := levelIDBegin.Plus(offset)
|
||||
oldData := view.mod.ModifiedBlock(resource.LangAny, resourceID, 0)
|
||||
if len(oldData) > 0 {
|
||||
command.oldData[resourceID] = oldData
|
||||
}
|
||||
}
|
||||
|
||||
view.commander.Queue(command)
|
||||
}
|
||||
}
|
||||
|
||||
func (view *View) requestSetGameState(newData []byte) {
|
||||
command := setArchiveDataCommand{
|
||||
model: &view.model,
|
||||
selectedLevel: view.model.selectedLevel,
|
||||
newData: make(map[resource.ID][]byte),
|
||||
oldData: make(map[resource.ID][]byte),
|
||||
}
|
||||
|
||||
command.oldData[ids.GameState] = view.gameStateData().snapshot().raw
|
||||
command.newData[ids.GameState] = newData
|
||||
|
||||
view.commander.Queue(command)
|
||||
}
|
||||
|
||||
func (view *View) createPropertyControls(readOnly bool, rootInterpreter *interpreters.Instance,
|
||||
updater func(string, func(uint32) uint32)) {
|
||||
objTypeRenderer := values.ObjectTypeControlRenderer{
|
||||
Meta: view.mod.ObjectProperties(),
|
||||
TextCache: view.textCache,
|
||||
}
|
||||
|
||||
var processInterpreter func(string, *interpreters.Instance)
|
||||
processInterpreter = func(path string, interpreter *interpreters.Instance) {
|
||||
for _, key := range interpreter.Keys() {
|
||||
fullKey := path + key
|
||||
unifier := values.NewUnifier()
|
||||
unifier.Add(int32(interpreter.Get(key)))
|
||||
simplifier := values.StandardSimplifier(readOnly, fullKey, unifier,
|
||||
func(modifier func(uint32) uint32) {
|
||||
updater(fullKey, modifier)
|
||||
}, objTypeRenderer)
|
||||
|
||||
interpreter.Describe(key, simplifier)
|
||||
}
|
||||
|
||||
for _, key := range interpreter.ActiveRefinements() {
|
||||
fullKey := path + key
|
||||
if len(fullKey) > 0 {
|
||||
imgui.Separator()
|
||||
imgui.Text(fullKey + ":")
|
||||
}
|
||||
processInterpreter(fullKey+".", interpreter.Refined(key))
|
||||
}
|
||||
}
|
||||
processInterpreter("", rootInterpreter)
|
||||
}
|
||||
|
||||
func (view *View) setInterpreterValueKeyed(instance *interpreters.Instance, key string, modifier func(uint32) uint32) {
|
||||
resolvedInterpreter := instance
|
||||
keys := strings.Split(key, ".")
|
||||
keyCount := len(keys)
|
||||
if len(keys) > 1 {
|
||||
for _, subKey := range keys[:keyCount-1] {
|
||||
resolvedInterpreter = resolvedInterpreter.Refined(subKey)
|
||||
}
|
||||
}
|
||||
resolvedInterpreter.Set(keys[keyCount-1], modifier(resolvedInterpreter.Get(keys[keyCount-1])))
|
||||
}
|
||||
|
||||
func (view *View) createInventoryControls(readOnly bool, gameState *archive.GameState, onChange func()) {
|
||||
if imgui.TreeNodeV("Weapons", imgui.TreeNodeFlagsFramed) {
|
||||
for i := 0; i < archive.InventoryWeaponSlots; i++ {
|
||||
if i > 0 {
|
||||
imgui.Separator()
|
||||
}
|
||||
imgui.PushID(fmt.Sprintf("weapon%d", i))
|
||||
view.createInventoryWeaponSlotControls(readOnly, gameState.InventoryWeaponSlot(i), onChange)
|
||||
imgui.PopID()
|
||||
}
|
||||
imgui.TreePop()
|
||||
}
|
||||
if imgui.TreeNodeV("Ammo", imgui.TreeNodeFlagsFramed) {
|
||||
for i := 0; i < archive.AmmoTypeCount; i++ {
|
||||
imgui.Separator()
|
||||
imgui.PushID(fmt.Sprintf("ammo%d", i))
|
||||
view.createInventoryAmmoControls(readOnly, gameState.InventoryAmmo(i), onChange)
|
||||
imgui.PopID()
|
||||
}
|
||||
imgui.TreePop()
|
||||
}
|
||||
if imgui.TreeNodeV("Grenades", imgui.TreeNodeFlagsFramed) {
|
||||
for i := 0; i < archive.GrenadeTypeCount; i++ {
|
||||
imgui.Separator()
|
||||
imgui.PushID(fmt.Sprintf("grenade%d", i))
|
||||
view.createInventoryGrenadeControls(readOnly, gameState.InventoryGrenade(i), onChange)
|
||||
imgui.PopID()
|
||||
}
|
||||
imgui.TreePop()
|
||||
}
|
||||
if imgui.TreeNodeV("Patches", imgui.TreeNodeFlagsFramed) {
|
||||
for i := 0; i < archive.GrenadeTypeCount; i++ {
|
||||
imgui.PushID(fmt.Sprintf("patch%d", i))
|
||||
view.createPatchStateControls(readOnly, gameState.PatchState(i), onChange)
|
||||
imgui.PopID()
|
||||
}
|
||||
imgui.TreePop()
|
||||
}
|
||||
if imgui.TreeNodeV("General", imgui.TreeNodeFlagsFramed) {
|
||||
for i := 0; i < archive.GeneralInventorySlotCount; i++ {
|
||||
imgui.PushID(fmt.Sprintf("general%d", i))
|
||||
view.createGeneralInventoryControls(readOnly, gameState.GeneralInventorySlot(i), onChange)
|
||||
imgui.PopID()
|
||||
}
|
||||
imgui.TreePop()
|
||||
}
|
||||
}
|
||||
|
||||
func (view *View) createWareControls(readOnly bool, gameState *archive.GameState, onChange func()) {
|
||||
if imgui.TreeNodeV("Hardware", imgui.TreeNodeFlagsFramed) {
|
||||
for i := 0; i < archive.HardwareTypeCount; i++ {
|
||||
imgui.PushID(fmt.Sprintf("hw%d", i))
|
||||
view.createHardwareControls(readOnly, gameState.HardwareState(i), onChange)
|
||||
imgui.PopID()
|
||||
}
|
||||
imgui.TreePop()
|
||||
}
|
||||
|
||||
if imgui.TreeNodeV("Software", imgui.TreeNodeFlagsFramed) {
|
||||
for i := 0; i < archive.SoftwareOffenseTypeCount; i++ {
|
||||
imgui.PushID(fmt.Sprintf("swoffense%d", i))
|
||||
view.createVersionedSoftwareControls(readOnly, gameState.VersionedOffenseSoftwareState(i), 0, onChange)
|
||||
imgui.PopID()
|
||||
}
|
||||
for i := 0; i < archive.SoftwareDefenseTypeCount; i++ {
|
||||
imgui.PushID(fmt.Sprintf("swdefense%d", i))
|
||||
view.createVersionedSoftwareControls(readOnly, gameState.VersionedDefenseSoftwareState(i), 1, onChange)
|
||||
imgui.PopID()
|
||||
}
|
||||
for i := 0; i < archive.SoftwareOneshotTypeCount; i++ {
|
||||
imgui.PushID(fmt.Sprintf("swoneshot%d", i))
|
||||
view.createCountedSoftwareControls(readOnly, gameState.OneshotSoftwareState(i), 2, onChange)
|
||||
imgui.PopID()
|
||||
}
|
||||
imgui.TreePop()
|
||||
}
|
||||
}
|
||||
|
||||
func (view *View) createHardwareControls(readOnly bool, state archive.HardwareState, onChange func()) {
|
||||
values.RenderUnifiedSliderInt(readOnly,
|
||||
fmt.Sprintf("Version (%s)", view.indexedName(object.ClassHardware, state.Index)),
|
||||
values.UnifierFor(state.Version()),
|
||||
func(u values.Unifier) int {
|
||||
return u.Unified().(int)
|
||||
}, func(value int) string {
|
||||
return "%d"
|
||||
}, 0, 4,
|
||||
func(newValue int) {
|
||||
state.SetVersion(newValue)
|
||||
onChange()
|
||||
})
|
||||
|
||||
values.RenderUnifiedCheckboxCombo(readOnly, "Is Active", values.UnifierFor(state.IsActive()), func(newActive bool) {
|
||||
state.SetActive(newActive)
|
||||
onChange()
|
||||
})
|
||||
}
|
||||
|
||||
func (view *View) createVersionedSoftwareControls(readOnly bool, sw archive.VersionedSoftwareState, subclass int, onChange func()) {
|
||||
values.RenderUnifiedSliderInt(readOnly,
|
||||
fmt.Sprintf("Version (%s)", view.tripleName(object.TripleFrom(int(object.ClassSoftware), subclass, sw.Index))),
|
||||
values.UnifierFor(sw.Version()),
|
||||
func(u values.Unifier) int {
|
||||
return u.Unified().(int)
|
||||
}, func(value int) string {
|
||||
return "%d"
|
||||
}, 0, 0xFF,
|
||||
func(newValue int) {
|
||||
sw.SetVersion(newValue)
|
||||
onChange()
|
||||
})
|
||||
}
|
||||
|
||||
func (view *View) createCountedSoftwareControls(readOnly bool, sw archive.CountedSoftwareState, subclass int, onChange func()) {
|
||||
values.RenderUnifiedSliderInt(readOnly,
|
||||
fmt.Sprintf("Count (%s)", view.tripleName(object.TripleFrom(int(object.ClassSoftware), subclass, sw.Index))),
|
||||
values.UnifierFor(sw.Count()),
|
||||
func(u values.Unifier) int {
|
||||
return u.Unified().(int)
|
||||
}, func(value int) string {
|
||||
return "%d"
|
||||
}, 0, 0xFF,
|
||||
func(newValue int) {
|
||||
sw.SetCount(newValue)
|
||||
onChange()
|
||||
})
|
||||
}
|
||||
|
||||
func (view *View) createGeneralInventoryControls(readOnly bool, slot archive.GeneralInventorySlot, onChange func()) {
|
||||
values.RenderUnifiedSliderInt(readOnly,
|
||||
fmt.Sprintf("ObjectIndex (Slot %d)", slot.Index+1),
|
||||
values.UnifierFor(slot.ObjectID()),
|
||||
func(u values.Unifier) int {
|
||||
return int(u.Unified().(level.ObjectID))
|
||||
}, func(value int) string {
|
||||
return "%d"
|
||||
}, 0, 871, // TODO: get ID limit of current level
|
||||
func(newValue int) {
|
||||
slot.SetObjectID(level.ObjectID(newValue))
|
||||
onChange()
|
||||
})
|
||||
}
|
||||
|
||||
func (view *View) createPatchStateControls(readOnly bool, patch archive.PatchState, onChange func()) {
|
||||
values.RenderUnifiedSliderInt(readOnly,
|
||||
fmt.Sprintf("Patch Count (%s)", view.indexedName(object.ClassDrug, patch.Index)),
|
||||
values.UnifierFor(patch.Count()),
|
||||
func(u values.Unifier) int {
|
||||
return u.Unified().(int)
|
||||
}, func(value int) string {
|
||||
return "%d"
|
||||
}, 0, 0xFF,
|
||||
func(newValue int) {
|
||||
patch.SetCount(newValue)
|
||||
onChange()
|
||||
})
|
||||
}
|
||||
|
||||
func (view *View) createInventoryGrenadeControls(readOnly bool, grenade archive.InventoryGrenade, onChange func()) {
|
||||
values.RenderUnifiedSliderInt(readOnly,
|
||||
fmt.Sprintf("Grenade Count (%s)", view.indexedName(object.ClassGrenade, grenade.Index)),
|
||||
values.UnifierFor(grenade.Count()),
|
||||
func(u values.Unifier) int {
|
||||
return u.Unified().(int)
|
||||
}, func(value int) string {
|
||||
return "%d"
|
||||
}, 0, 0xFF,
|
||||
func(newValue int) {
|
||||
grenade.SetCount(newValue)
|
||||
onChange()
|
||||
})
|
||||
|
||||
values.RenderUnifiedSliderInt(readOnly, "Timer", values.UnifierFor(grenade.TimerSetting()),
|
||||
func(u values.Unifier) int {
|
||||
return int(u.Unified().(archive.GrenadeTimerSetting))
|
||||
}, func(value int) string {
|
||||
setting := archive.GrenadeTimerSetting(value)
|
||||
return fmt.Sprintf("%.01fs -- raw: %%d", setting.InSeconds())
|
||||
}, 0, archive.GrenadeTimerMaximum,
|
||||
func(newValue int) {
|
||||
grenade.SetTimerSetting(archive.GrenadeTimerSetting(newValue))
|
||||
onChange()
|
||||
})
|
||||
}
|
||||
|
||||
func (view *View) createInventoryAmmoControls(readOnly bool, ammo archive.InventoryAmmo, onChange func()) {
|
||||
values.RenderUnifiedSliderInt(readOnly,
|
||||
fmt.Sprintf("Clip Count (%s)", view.indexedName(object.ClassAmmo, ammo.Index)),
|
||||
values.UnifierFor(ammo.FullClipCount()),
|
||||
func(u values.Unifier) int {
|
||||
return u.Unified().(int)
|
||||
}, func(value int) string {
|
||||
return "%d"
|
||||
}, 0, 0xFF,
|
||||
func(newValue int) {
|
||||
ammo.SetFullClipCount(newValue)
|
||||
onChange()
|
||||
})
|
||||
values.RenderUnifiedSliderInt(readOnly,
|
||||
"Extra rounds",
|
||||
values.UnifierFor(ammo.ExtraRoundsCount()),
|
||||
func(u values.Unifier) int {
|
||||
return u.Unified().(int)
|
||||
}, func(value int) string {
|
||||
return "%d"
|
||||
}, 0, 0xFF,
|
||||
func(newValue int) {
|
||||
ammo.SetExtraRoundsCount(newValue)
|
||||
onChange()
|
||||
})
|
||||
}
|
||||
|
||||
func (view *View) createInventoryWeaponSlotControls(readOnly bool, slot archive.InventoryWeaponSlot, onChange func()) {
|
||||
isInUse := slot.IsInUse()
|
||||
inUseName := fmt.Sprintf("Weapon %d In Use", slot.Index+1)
|
||||
|
||||
values.RenderUnifiedCheckboxCombo(readOnly, inUseName, values.UnifierFor(isInUse), func(newUse bool) {
|
||||
if newUse {
|
||||
slot.SetInUse(0, 0)
|
||||
} else {
|
||||
slot.SetFree()
|
||||
}
|
||||
onChange()
|
||||
})
|
||||
if !isInUse {
|
||||
return
|
||||
}
|
||||
|
||||
currentWeaponTriple := slot.Triple()
|
||||
if imgui.BeginCombo("Type", view.tripleName(currentWeaponTriple)) {
|
||||
allTypes := view.mod.ObjectProperties().TriplesInClass(currentWeaponTriple.Class)
|
||||
for _, triple := range allTypes {
|
||||
if imgui.SelectableV(view.tripleName(triple), triple == currentWeaponTriple, 0, imgui.Vec2{}) {
|
||||
slot.SetInUse(triple.Subclass, triple.Type)
|
||||
onChange()
|
||||
}
|
||||
}
|
||||
imgui.EndCombo()
|
||||
}
|
||||
isEnergyWeapon := currentWeaponTriple.Subclass >= 4
|
||||
if isEnergyWeapon {
|
||||
temperature, chargeAndOverload := slot.WeaponState()
|
||||
values.RenderUnifiedSliderInt(readOnly, "Temperature", values.UnifierFor(int(temperature)),
|
||||
func(u values.Unifier) int {
|
||||
return u.Unified().(int)
|
||||
}, func(value int) string {
|
||||
return fmt.Sprintf("%.02f%%%% -- raw: %%d", (float32(value)/float32(lvlobj.EnergyWeaponMaxTemperature))*100)
|
||||
}, 0x00, lvlobj.EnergyWeaponMaxTemperature,
|
||||
func(newValue int) {
|
||||
slot.SetWeaponState(byte(newValue), chargeAndOverload)
|
||||
onChange()
|
||||
})
|
||||
|
||||
overloadState := chargeAndOverload & 0x80
|
||||
charge := chargeAndOverload & 0x7F
|
||||
values.RenderUnifiedCheckboxCombo(readOnly, "Overload", values.UnifierFor(overloadState != 0), func(newOverload bool) {
|
||||
if newOverload {
|
||||
slot.SetWeaponState(temperature, 0x80|charge)
|
||||
} else {
|
||||
slot.SetWeaponState(temperature, charge)
|
||||
}
|
||||
onChange()
|
||||
})
|
||||
values.RenderUnifiedSliderInt(readOnly, "Charge", values.UnifierFor(int(charge)),
|
||||
func(u values.Unifier) int {
|
||||
return u.Unified().(int)
|
||||
}, func(value int) string {
|
||||
return fmt.Sprintf("%.02f%%%% -- raw: %%d", (float32(value)/float32(lvlobj.EnergyWeaponMaxEnergy))*100)
|
||||
}, 0x00, 0x7F,
|
||||
func(newValue int) {
|
||||
slot.SetWeaponState(temperature, overloadState|byte(newValue))
|
||||
onChange()
|
||||
})
|
||||
} else {
|
||||
rounds, ammoType := slot.WeaponState()
|
||||
values.RenderUnifiedSliderInt(readOnly, "Rounds", values.UnifierFor(int(rounds)),
|
||||
func(u values.Unifier) int {
|
||||
return u.Unified().(int)
|
||||
}, func(value int) string {
|
||||
return fmt.Sprintf("%d", value)
|
||||
}, 0x00, 0xFF,
|
||||
func(newValue int) {
|
||||
slot.SetWeaponState(byte(newValue), ammoType)
|
||||
onChange()
|
||||
})
|
||||
values.RenderUnifiedCombo(readOnly, "Ammo Type", values.UnifierFor(int(ammoType)),
|
||||
func(u values.Unifier) int {
|
||||
return u.Unified().(int)
|
||||
},
|
||||
func(value int) string {
|
||||
switch value {
|
||||
case 0:
|
||||
return "Standard"
|
||||
case 1:
|
||||
return "Special"
|
||||
default:
|
||||
return fmt.Sprintf("Unknown %d", ammoType)
|
||||
}
|
||||
}, 2,
|
||||
func(newValue int) {
|
||||
slot.SetWeaponState(rounds, byte(newValue))
|
||||
onChange()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (view *View) createVariableControls(readOnly bool, gameState *archive.GameState, onChange func()) {
|
||||
currentContextIdentifier := view.gameStateService.VariableBaseContext()
|
||||
if imgui.BeginCombo("Base Context", variableContextList[currentContextIdentifier].title) {
|
||||
contextIdentifiers := edit.VariableContextIdentifiers()
|
||||
for _, identifier := range contextIdentifiers {
|
||||
contextInfo := variableContextList[identifier]
|
||||
if imgui.SelectableV(contextInfo.title, identifier == currentContextIdentifier, 0, imgui.Vec2{}) {
|
||||
view.gameStateService.SetVariableBaseContext(identifier)
|
||||
}
|
||||
if imgui.IsItemHovered() {
|
||||
imgui.SetTooltip(contextInfo.info)
|
||||
}
|
||||
}
|
||||
imgui.EndCombo()
|
||||
}
|
||||
if imgui.IsItemHovered() {
|
||||
imgui.SetTooltip("The base context defines to what variables default to\n" +
|
||||
"in case they are not overridden by the project.")
|
||||
}
|
||||
|
||||
if imgui.Button("Remove All Overrides") {
|
||||
_ = view.gameStateService.DefaultAllVariables()
|
||||
}
|
||||
if imgui.IsItemHovered() {
|
||||
imgui.SetTooltip("Removes all overrides and their parameters.")
|
||||
}
|
||||
if !readOnly {
|
||||
imgui.SameLine()
|
||||
if imgui.Button("Reset All") {
|
||||
for i := 0; i < archive.BooleanVarCount; i++ {
|
||||
gameState.SetBooleanVar(i, view.gameStateService.BooleanVariable(i).ResetValueBool())
|
||||
}
|
||||
for i := 0; i < archive.IntegerVarCount; i++ {
|
||||
gameState.SetIntegerVar(i, view.gameStateService.IntegerVariable(i).ResetValueInt())
|
||||
}
|
||||
onChange()
|
||||
}
|
||||
if imgui.IsItemHovered() {
|
||||
imgui.SetTooltip("Resets all variables to their configured initial value.\n" +
|
||||
"To reset a single variable, hover over the specific variable and use the context menu.")
|
||||
}
|
||||
}
|
||||
|
||||
isSavegame := gameState.IsSavegame()
|
||||
if imgui.TreeNodeV("Boolean Variables", imgui.TreeNodeFlagsFramed) {
|
||||
intConverter := func(u values.Unifier) int {
|
||||
bValue := u.Unified().(bool)
|
||||
if bValue {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
defaultNames := map[int]string{0: "False", 1: "True"}
|
||||
|
||||
for i := 0; i < archive.BooleanVarCount; i++ {
|
||||
varIndex := i
|
||||
info := view.gameStateService.BooleanVariable(varIndex)
|
||||
|
||||
varReadOnly := readOnly || (info.Hardcoded && !isSavegame)
|
||||
varLabel := fmt.Sprintf("Var%03d", varIndex)
|
||||
varName := fmt.Sprintf("Var%03d: %s", varIndex, info.Name)
|
||||
varUnifier := values.UnifierFor(gameState.BooleanVar(varIndex))
|
||||
|
||||
values.RenderUnifiedCombo(varReadOnly, varName, varUnifier, intConverter,
|
||||
func(value int) string {
|
||||
name, found := info.ValueNames[int16(value)]
|
||||
if found {
|
||||
return name
|
||||
}
|
||||
return defaultNames[value]
|
||||
},
|
||||
2,
|
||||
func(newValue int) {
|
||||
gameState.SetBooleanVar(varIndex, newValue != 0)
|
||||
onChange()
|
||||
})
|
||||
|
||||
if imgui.BeginPopupContextItemV(varLabel+"-Popup", 1) {
|
||||
if imgui.Selectable("Edit info...") {
|
||||
view.modalStateMachine.SetState(&editBooleanVariableDialog{
|
||||
machine: view.modalStateMachine,
|
||||
view: view,
|
||||
service: view.gameStateService,
|
||||
index: varIndex,
|
||||
})
|
||||
}
|
||||
if !varReadOnly {
|
||||
imgui.Separator()
|
||||
if imgui.Selectable("Reset") {
|
||||
gameState.SetBooleanVar(varIndex, info.ResetValueBool())
|
||||
onChange()
|
||||
}
|
||||
}
|
||||
imgui.EndPopup()
|
||||
} else if imgui.IsItemHovered() && (len(info.Description) > 0) {
|
||||
imgui.SetTooltip(info.Description)
|
||||
}
|
||||
}
|
||||
imgui.TreePop()
|
||||
}
|
||||
if imgui.TreeNodeV("Integer Variables", imgui.TreeNodeFlagsFramed) {
|
||||
intConverter := func(u values.Unifier) int {
|
||||
return int(u.Unified().(int16))
|
||||
}
|
||||
for i := 0; i < archive.IntegerVarCount; i++ {
|
||||
varIndex := i
|
||||
info := view.gameStateService.IntegerVariable(varIndex)
|
||||
|
||||
varReadOnly := readOnly || (info.Hardcoded && !isSavegame)
|
||||
varLabel := fmt.Sprintf("Var%02d", varIndex)
|
||||
varName := fmt.Sprintf("Var%02d: %s", varIndex, info.Name)
|
||||
varUnifier := values.UnifierFor(gameState.IntegerVar(varIndex))
|
||||
changeHandler := func(newValue int) {
|
||||
gameState.SetIntegerVar(varIndex, int16(newValue))
|
||||
onChange()
|
||||
}
|
||||
|
||||
switch {
|
||||
case archive.IsRandomIntegerVariable(varIndex):
|
||||
values.RenderUnifiedSliderInt(readOnly, varName, varUnifier,
|
||||
func(u values.Unifier) int { return int(numbers.FromBinaryCodedDecimal(uint16(intConverter(u)))) },
|
||||
func(value int) string { return "%03d" },
|
||||
0, 999,
|
||||
func(newValue int) {
|
||||
changeHandler(int(numbers.ToBinaryCodedDecimal(uint16(newValue))))
|
||||
})
|
||||
case len(info.ValueNames) > 0:
|
||||
var max int16
|
||||
for key := range info.ValueNames {
|
||||
if key > max {
|
||||
max = key
|
||||
}
|
||||
}
|
||||
values.RenderUnifiedCombo(varReadOnly, varName, varUnifier, intConverter,
|
||||
func(value int) string {
|
||||
name, found := info.ValueNames[int16(value)]
|
||||
if found {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("Unknown %d", value)
|
||||
},
|
||||
int(max+1),
|
||||
changeHandler)
|
||||
default:
|
||||
min := math.MinInt16
|
||||
max := math.MaxInt16
|
||||
if info.Limits != nil {
|
||||
min = int(info.Limits.Minimum)
|
||||
max = int(info.Limits.Maximum)
|
||||
}
|
||||
|
||||
values.RenderUnifiedSliderInt(varReadOnly, varName, varUnifier, intConverter,
|
||||
func(value int) string {
|
||||
return "%d"
|
||||
},
|
||||
min, max,
|
||||
changeHandler)
|
||||
}
|
||||
if imgui.BeginPopupContextItemV(varLabel+"-Popup", 1) {
|
||||
if imgui.Selectable("Edit info...") {
|
||||
view.modalStateMachine.SetState(&editIntegerVariableDialog{
|
||||
machine: view.modalStateMachine,
|
||||
view: view,
|
||||
service: view.gameStateService,
|
||||
index: varIndex,
|
||||
})
|
||||
}
|
||||
if !varReadOnly {
|
||||
imgui.Separator()
|
||||
if imgui.Selectable("Reset") {
|
||||
gameState.SetIntegerVar(varIndex, info.ResetValueInt())
|
||||
onChange()
|
||||
}
|
||||
}
|
||||
imgui.EndPopup()
|
||||
} else if imgui.IsItemHovered() && (len(info.Description) > 0) {
|
||||
imgui.SetTooltip(info.Description)
|
||||
}
|
||||
}
|
||||
imgui.TreePop()
|
||||
}
|
||||
}
|
||||
|
||||
func (view *View) createMessageControls(readOnly bool, gameState *archive.GameState, onChange func()) {
|
||||
view.createMessageControlsFor(readOnly, "EMail",
|
||||
&view.model.emailIndex, archive.EMailCount, gameState.EMailState, onChange)
|
||||
view.createMessageControlsFor(readOnly, "Log",
|
||||
&view.model.logIndex, archive.LogCount, gameState.LogState, onChange)
|
||||
view.createMessageControlsFor(readOnly, "Fragment",
|
||||
&view.model.fragmentIndex, archive.FragmentCount, gameState.FragmentState, onChange)
|
||||
}
|
||||
|
||||
func (view *View) createMessageControlsFor(readOnly bool, name string, index *int, limit int,
|
||||
retriever func(int) archive.MessageState, onChange func()) {
|
||||
imgui.PushID(name)
|
||||
gui.StepSliderInt(name+" Index", index, 0, limit-1)
|
||||
messageState := retriever(*index)
|
||||
values.RenderUnifiedCheckboxCombo(readOnly, "Received", values.UnifierFor(messageState.Received()),
|
||||
func(newState bool) {
|
||||
messageState.SetReceived(newState)
|
||||
onChange()
|
||||
})
|
||||
values.RenderUnifiedCheckboxCombo(readOnly, "Read", values.UnifierFor(messageState.Read()),
|
||||
func(newState bool) {
|
||||
messageState.SetRead(newState)
|
||||
onChange()
|
||||
})
|
||||
imgui.PopID()
|
||||
}
|
||||
|
||||
func (view *View) tripleName(triple object.Triple) string {
|
||||
suffix := hintUnknown
|
||||
linearIndex := view.mod.ObjectProperties().TripleIndex(triple)
|
||||
if linearIndex >= 0 {
|
||||
key := resource.KeyOf(ids.ObjectLongNames, resource.LangDefault, linearIndex)
|
||||
objName, err := view.textCache.Text(key)
|
||||
if err == nil {
|
||||
suffix = singleLineString(objName)
|
||||
}
|
||||
}
|
||||
return triple.String() + ": " + suffix
|
||||
}
|
||||
|
||||
func (view *View) indexedName(class object.Class, index int) string {
|
||||
startIndex := view.mod.ObjectProperties().TripleIndex(object.TripleFrom(int(class), 0, 0))
|
||||
if startIndex >= 0 {
|
||||
key := resource.KeyOf(ids.ObjectLongNames, resource.LangDefault, startIndex+index)
|
||||
objName, err := view.textCache.Text(key)
|
||||
if err == nil {
|
||||
return singleLineString(objName)
|
||||
}
|
||||
}
|
||||
return hintUnknown
|
||||
}
|
||||
|
||||
func singleLineString(str string) string {
|
||||
return strings.ReplaceAll(strings.ReplaceAll(str, "\r", ""), "\n", "")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package archives
|
||||
|
||||
type viewModel struct {
|
||||
windowOpen bool
|
||||
restoreFocus bool
|
||||
|
||||
selectedLevel int
|
||||
|
||||
emailIndex int
|
||||
logIndex int
|
||||
fragmentIndex int
|
||||
}
|
||||
|
||||
func freshViewModel() viewModel {
|
||||
return viewModel{}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package bitmaps
|
||||
|
||||
import (
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/world"
|
||||
)
|
||||
|
||||
type setBitmapCommand struct {
|
||||
model *viewModel
|
||||
|
||||
displayKey resource.Key
|
||||
|
||||
resourceKey resource.Key
|
||||
oldData []byte
|
||||
newData []byte
|
||||
}
|
||||
|
||||
func (cmd setBitmapCommand) Do(modder world.Modder) error {
|
||||
return cmd.perform(modder, cmd.newData)
|
||||
}
|
||||
|
||||
func (cmd setBitmapCommand) Undo(modder world.Modder) error {
|
||||
return cmd.perform(modder, cmd.oldData)
|
||||
}
|
||||
|
||||
func (cmd setBitmapCommand) perform(modder world.Modder, data []byte) error {
|
||||
modder.SetResourceBlock(cmd.resourceKey.Lang, cmd.resourceKey.ID, cmd.resourceKey.Index, data)
|
||||
|
||||
cmd.model.restoreFocus = true
|
||||
cmd.model.currentKey = cmd.displayKey
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package bitmaps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/inkyblackness/imgui-go/v3"
|
||||
|
||||
"github.com/inkyblackness/hacked/editor/external"
|
||||
"github.com/inkyblackness/hacked/editor/graphics"
|
||||
"github.com/inkyblackness/hacked/editor/render"
|
||||
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||
"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"
|
||||
"github.com/inkyblackness/hacked/ui/gui"
|
||||
)
|
||||
|
||||
type bitmapInfo struct {
|
||||
title string
|
||||
languageSpecific bool
|
||||
bitmapType bitmap.Type
|
||||
bitmapFlags bitmap.Flag
|
||||
}
|
||||
|
||||
var knownBitmapTypes = map[resource.ID]bitmapInfo{
|
||||
ids.MfdDataBitmaps: {title: "MFD Data Images", languageSpecific: true, bitmapType: bitmap.TypeCompressed8Bit, bitmapFlags: bitmap.FlagTransparent},
|
||||
ids.ObjectMaterialBitmaps: {title: "Object Materials", languageSpecific: false, bitmapType: bitmap.TypeFlat8Bit, bitmapFlags: 0},
|
||||
ids.ObjectTextureBitmaps: {title: "Object Textures", languageSpecific: false, bitmapType: bitmap.TypeFlat8Bit, bitmapFlags: 0},
|
||||
ids.IconBitmaps: {title: "Wall Icons", languageSpecific: false, bitmapType: bitmap.TypeCompressed8Bit, bitmapFlags: bitmap.FlagTransparent},
|
||||
ids.GraffitiBitmaps: {title: "Graffiti", languageSpecific: false, bitmapType: bitmap.TypeCompressed8Bit, bitmapFlags: bitmap.FlagTransparent},
|
||||
ids.ScreenTextures: {title: "Screens", languageSpecific: false, bitmapType: bitmap.TypeCompressed8Bit, bitmapFlags: 0},
|
||||
}
|
||||
|
||||
var knownBitmapTypesOrder = []resource.ID{
|
||||
ids.MfdDataBitmaps,
|
||||
ids.ObjectMaterialBitmaps,
|
||||
ids.ObjectTextureBitmaps,
|
||||
ids.IconBitmaps,
|
||||
ids.GraffitiBitmaps,
|
||||
ids.ScreenTextures,
|
||||
}
|
||||
|
||||
// View provides edit controls for bitmaps.
|
||||
type View struct {
|
||||
mod *world.Mod
|
||||
imageCache *graphics.TextureCache
|
||||
paletteCache *graphics.PaletteCache
|
||||
|
||||
modalStateMachine gui.ModalStateMachine
|
||||
clipboard external.Clipboard
|
||||
guiScale float32
|
||||
commander cmd.Commander
|
||||
|
||||
model viewModel
|
||||
}
|
||||
|
||||
// NewBitmapsView returns a new instance.
|
||||
func NewBitmapsView(mod *world.Mod, imageCache *graphics.TextureCache, paletteCache *graphics.PaletteCache,
|
||||
modalStateMachine gui.ModalStateMachine, clipboard external.Clipboard,
|
||||
guiScale float32, commander cmd.Commander) *View {
|
||||
view := &View{
|
||||
mod: mod,
|
||||
imageCache: imageCache,
|
||||
paletteCache: paletteCache,
|
||||
|
||||
modalStateMachine: modalStateMachine,
|
||||
clipboard: clipboard,
|
||||
guiScale: guiScale,
|
||||
commander: commander,
|
||||
|
||||
model: freshViewModel(),
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// WindowOpen returns the flag address, to be used with the main menu.
|
||||
func (view *View) WindowOpen() *bool {
|
||||
return &view.model.windowOpen
|
||||
}
|
||||
|
||||
// Render renders the view.
|
||||
func (view *View) Render() {
|
||||
if view.model.restoreFocus {
|
||||
imgui.SetNextWindowFocus()
|
||||
view.model.restoreFocus = false
|
||||
view.model.windowOpen = true
|
||||
}
|
||||
if view.model.windowOpen {
|
||||
imgui.SetNextWindowSizeV(imgui.Vec2{X: 800 * view.guiScale, Y: 300 * view.guiScale}, imgui.ConditionFirstUseEver)
|
||||
if imgui.BeginV("Bitmaps", view.WindowOpen(), imgui.WindowFlagsNoCollapse|imgui.WindowFlagsHorizontalScrollbar) {
|
||||
view.renderContent()
|
||||
}
|
||||
imgui.End()
|
||||
}
|
||||
}
|
||||
|
||||
func (view *View) renderContent() {
|
||||
if imgui.BeginChildV("Properties", imgui.Vec2{X: 350 * view.guiScale, Y: 0}, false, 0) {
|
||||
imgui.PushItemWidth(-150 * view.guiScale)
|
||||
if imgui.BeginCombo("Bitmap Type", knownBitmapTypes[view.model.currentKey.ID].title) {
|
||||
for _, id := range knownBitmapTypesOrder {
|
||||
if imgui.SelectableV(knownBitmapTypes[id].title, id == view.model.currentKey.ID, 0, imgui.Vec2{}) {
|
||||
view.model.currentKey.ID = id
|
||||
view.model.currentKey.Index = 0
|
||||
}
|
||||
}
|
||||
imgui.EndCombo()
|
||||
}
|
||||
selectedType := knownBitmapTypes[view.model.currentKey.ID]
|
||||
if selectedType.languageSpecific {
|
||||
if imgui.BeginCombo("Language", view.model.currentKey.Lang.String()) {
|
||||
languages := resource.Languages()
|
||||
for _, lang := range languages {
|
||||
if imgui.SelectableV(lang.String(), lang == view.model.currentKey.Lang, 0, imgui.Vec2{}) {
|
||||
view.model.currentKey.Lang = lang
|
||||
}
|
||||
}
|
||||
imgui.EndCombo()
|
||||
}
|
||||
} else {
|
||||
view.model.currentKey.Lang = resource.LangAny
|
||||
}
|
||||
|
||||
info, _ := ids.Info(view.model.currentKey.ID)
|
||||
|
||||
gui.StepSliderInt("Index", &view.model.currentKey.Index, 0, info.MaxCount-1)
|
||||
|
||||
render.TextureSelector("###"+"IndexBitmap", -1, view.guiScale, info.MaxCount,
|
||||
view.model.currentKey.Index, view.imageCache,
|
||||
view.indexedResourceKey,
|
||||
func(index int) string { return fmt.Sprintf("%d", index) },
|
||||
func(newValue int) {
|
||||
view.model.currentKey.Index = newValue
|
||||
})
|
||||
|
||||
tex, err := view.imageCache.Texture(view.currentResourceKey())
|
||||
|
||||
if imgui.Button("Clear") {
|
||||
view.requestClear(selectedType)
|
||||
}
|
||||
imgui.SameLine()
|
||||
if imgui.Button("Import") {
|
||||
view.requestImport(selectedType)
|
||||
}
|
||||
if err == nil {
|
||||
imgui.SameLine()
|
||||
if imgui.Button("Export") {
|
||||
view.requestExport()
|
||||
}
|
||||
if view.hasModCurrentBitmap() {
|
||||
imgui.SameLine()
|
||||
if imgui.Button("Remove") {
|
||||
view.requestSetBitmapData(nil)
|
||||
}
|
||||
}
|
||||
|
||||
width, height := tex.Size()
|
||||
imgui.LabelText("Width", fmt.Sprintf("%d", int(width)))
|
||||
imgui.LabelText("Height", fmt.Sprintf("%d", int(height)))
|
||||
}
|
||||
|
||||
imgui.PopItemWidth()
|
||||
}
|
||||
imgui.EndChild()
|
||||
imgui.SameLine()
|
||||
render.TextureImage("Big texture", view.imageCache, view.currentResourceKey(), imgui.Vec2{X: 320 * view.guiScale, Y: 240 * view.guiScale})
|
||||
}
|
||||
|
||||
func (view *View) currentResourceKey() resource.Key {
|
||||
return view.indexedResourceKey(view.model.currentKey.Index)
|
||||
}
|
||||
|
||||
func (view *View) indexedResourceKey(index int) resource.Key {
|
||||
key := view.model.currentKey
|
||||
info, _ := ids.Info(view.model.currentKey.ID)
|
||||
if !info.List {
|
||||
key.ID = key.ID.Plus(index)
|
||||
key.Index = 0
|
||||
} else {
|
||||
key.Index = index
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func (view *View) hasModCurrentBitmap() bool {
|
||||
key := view.currentResourceKey()
|
||||
return len(view.mod.ModifiedBlock(key.Lang, key.ID, key.Index)) > 0
|
||||
}
|
||||
|
||||
func (view *View) requestExport() {
|
||||
key := view.currentResourceKey()
|
||||
texture, err := view.imageCache.Texture(key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
palette, err := view.paletteCache.Palette(0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
rawPalette := palette.Palette()
|
||||
filename := fmt.Sprintf("%05d_%03d_%s.png", key.ID.Value(), key.Index, key.Lang.String())
|
||||
width, height := texture.Size()
|
||||
bmp := bitmap.Bitmap{
|
||||
Header: bitmap.Header{
|
||||
Width: int16(width),
|
||||
Height: int16(height),
|
||||
},
|
||||
Pixels: texture.PixelData(),
|
||||
Palette: &rawPalette,
|
||||
}
|
||||
|
||||
external.ExportImage(view.modalStateMachine, filename, bmp)
|
||||
}
|
||||
|
||||
func (view *View) requestImport(bmpInfo bitmapInfo) {
|
||||
paletteRetriever := func() (bitmap.Palette, error) {
|
||||
palette, err := view.paletteCache.Palette(0)
|
||||
if err != nil {
|
||||
return bitmap.Palette{}, err
|
||||
}
|
||||
return palette.Palette(), nil
|
||||
}
|
||||
external.ImportImage(view.modalStateMachine, paletteRetriever, func(bmp bitmap.Bitmap) {
|
||||
view.requestSetBitmap(bmp, bmpInfo)
|
||||
})
|
||||
}
|
||||
|
||||
func (view *View) requestClear(bmpInfo bitmapInfo) {
|
||||
bmp := bitmap.Bitmap{
|
||||
Header: bitmap.Header{
|
||||
Width: 1,
|
||||
Height: 1,
|
||||
},
|
||||
Pixels: []byte{0x00},
|
||||
}
|
||||
view.requestSetBitmap(bmp, bmpInfo)
|
||||
}
|
||||
|
||||
func (view *View) requestSetBitmap(bmp bitmap.Bitmap, bmpInfo bitmapInfo) {
|
||||
highestBitShift := func(value int16) (result byte) {
|
||||
if value != 0 {
|
||||
for (value >> result) != 1 {
|
||||
result++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
bmp.Header.Flags = bmpInfo.bitmapFlags
|
||||
bmp.Header.Type = bmpInfo.bitmapType
|
||||
bmp.Header.WidthFactor = highestBitShift(bmp.Header.Width)
|
||||
bmp.Header.HeightFactor = highestBitShift(bmp.Header.Height)
|
||||
bmp.Header.Stride = uint16(bmp.Header.Width)
|
||||
data := bitmap.Encode(&bmp, 0)
|
||||
view.requestSetBitmapData(data)
|
||||
}
|
||||
|
||||
func (view *View) requestSetBitmapData(newData []byte) {
|
||||
resourceKey := view.currentResourceKey()
|
||||
|
||||
command := setBitmapCommand{
|
||||
displayKey: view.model.currentKey,
|
||||
model: &view.model,
|
||||
|
||||
resourceKey: resourceKey,
|
||||
oldData: view.mod.ModifiedBlock(resourceKey.Lang, resourceKey.ID, resourceKey.Index),
|
||||
newData: newData,
|
||||
}
|
||||
view.commander.Queue(command)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package bitmaps
|
||||
|
||||
import (
|
||||
"github.com/inkyblackness/hacked/ss1/resource"
|
||||
"github.com/inkyblackness/hacked/ss1/world/ids"
|
||||
)
|
||||
|
||||
type viewModel struct {
|
||||
windowOpen bool
|
||||
restoreFocus bool
|
||||
|
||||
currentKey resource.Key
|
||||
}
|
||||
|
||||
func freshViewModel() viewModel {
|
||||
return viewModel{
|
||||
currentKey: resource.KeyOf(ids.MfdDataBitmaps, resource.LangDefault, 0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/*
|
||||
Package editor and its sub-packages contain the editor-specifics of hacked.
|
||||
*/
|
||||
package editor
|
||||
@@ -0,0 +1,9 @@
|
||||
package external
|
||||
|
||||
// Clipboard represents a temporary storage.
|
||||
type Clipboard interface {
|
||||
// String returns the current value of the clipboard, if it is compatible with UTF-8.
|
||||
String() (string, error)
|
||||
// SetString sets the current value of the clipboard as UTF-8 string.
|
||||
SetString(value string)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/inkyblackness/hacked/ss1/content/audio"
|
||||
"github.com/inkyblackness/hacked/ss1/content/audio/wav"
|
||||
"github.com/inkyblackness/hacked/ss1/content/bitmap"
|
||||
"github.com/inkyblackness/hacked/ui/gui"
|
||||
)
|
||||
|
||||
// Export starts an export dialog series, calling the given callback with a folder name.
|
||||
func Export(machine gui.ModalStateMachine, info string, callback func(string), lastFailed bool) {
|
||||
machine.SetState(&exportStartState{
|
||||
machine: machine,
|
||||
callback: callback,
|
||||
info: info,
|
||||
withError: lastFailed,
|
||||
})
|
||||
}
|
||||
|
||||
// ExportAudio is a helper wrapper for exporting audio.
|
||||
func ExportAudio(machine gui.ModalStateMachine, filename string, sound audio.L8) {
|
||||
info := "File to be written: " + filename
|
||||
var dirHandler func(string)
|
||||
|
||||
dirHandler = func(dirname string) {
|
||||
writer, err := os.Create(filepath.Join(dirname, filename))
|
||||
if err != nil {
|
||||
Export(machine, info, dirHandler, true)
|
||||
return
|
||||
}
|
||||
defer func() { _ = writer.Close() }()
|
||||
err = wav.Save(writer, sound.SampleRate, sound.Samples)
|
||||
if err != nil {
|
||||
Export(machine, info, dirHandler, true)
|
||||
}
|
||||
}
|
||||
|
||||
Export(machine, info, dirHandler, false)
|
||||
}
|
||||
|
||||
// ExportImage is a helper wrapper for exporting single images.
|
||||
func ExportImage(machine gui.ModalStateMachine, filename string, bmp bitmap.Bitmap) {
|
||||
info := "File to be written: " + filename
|
||||
var exportTo func(string)
|
||||
|
||||
exportTo = func(dirname string) {
|
||||
writer, err := os.Create(filepath.Join(dirname, filename))
|
||||
if err != nil {
|
||||
Export(machine, "Could not create file.\n"+info, exportTo, true)
|
||||
return
|
||||
}
|
||||
defer func() { _ = writer.Close() }()
|
||||
|
||||
imageRect := image.Rect(0, 0, int(bmp.Header.Width), int(bmp.Header.Height))
|
||||
imagePal := bmp.Palette.ColorPalette(false)
|
||||
paletted := image.NewPaletted(imageRect, imagePal)
|
||||
paletted.Pix = bmp.Pixels
|
||||
err = png.Encode(writer, paletted)
|
||||
if err != nil {
|
||||
Export(machine, info, exportTo, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Export(machine, info, exportTo, false)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/inkyblackness/imgui-go/v3"
|
||||
|
||||
"github.com/inkyblackness/hacked/ui/gui"
|
||||
)
|
||||
|
||||
type exportStartState struct {
|
||||
machine gui.ModalStateMachine
|
||||
callback func(string)
|
||||
info string
|
||||
withError bool
|
||||
}
|
||||
|
||||
func (state exportStartState) Render() {
|
||||
imgui.OpenPopup("Export")
|
||||
nextState := &exportWaitingState{
|
||||
machine: state.machine,
|
||||
callback: state.callback,
|
||||
info: state.info,
|
||||
}
|
||||
if state.withError {
|
||||
nextState.failureTime = time.Now()
|
||||
}
|
||||
state.machine.SetState(nextState)
|
||||
}
|
||||
|
||||
func (state exportStartState) HandleFiles(names []string) {
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/inkyblackness/imgui-go/v3"
|
||||
"github.com/sqweek/dialog"
|
||||
|
||||
"github.com/inkyblackness/hacked/ui/gui"
|
||||
)
|
||||
|
||||
type exportWaitingState struct {
|
||||
machine gui.ModalStateMachine
|
||||
callback func(string)
|
||||
info string
|
||||
|
||||
failureTime time.Time
|
||||
}
|
||||
|
||||
func (state *exportWaitingState) Render() {
|
||||
if imgui.BeginPopupModalV("Export", nil,
|
||||
imgui.WindowFlagsNoResize|imgui.WindowFlagsNoMove|imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsAlwaysAutoResize) {
|
||||
imgui.Text("Waiting for folder.")
|
||||
if !state.failureTime.IsZero() {
|
||||
imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1, Y: 0, Z: 0, W: 1})
|
||||
imgui.Text("Previous attempt failed, could not save file.\nPlease check and try again.")
|
||||
imgui.PopStyleColor()
|
||||
if time.Since(state.failureTime).Seconds() > 5 {
|
||||
state.failureTime = time.Time{}
|
||||
}
|
||||
}
|
||||
imgui.Text(`From your file browser drag'n'drop the folder
|
||||
where to save the data into the editor window.
|
||||
`)
|
||||
imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1, Y: 0, Z: 0, W: 1})
|
||||
imgui.Text("Saving will potentially overwrite existing files in the folder.")
|
||||
imgui.PopStyleColor()
|
||||
imgui.Text(state.info)
|
||||
imgui.Separator()
|
||||
if imgui.Button("Browse...") {
|
||||
dlgBuilder := dialog.Directory()
|
||||
filename, err := dlgBuilder.Browse()
|
||||
if err == nil {
|
||||
state.HandleFiles([]string{filename})
|
||||
}
|
||||
}
|
||||
imgui.SameLine()
|
||||
if imgui.Button("Cancel") {
|
||||
state.machine.SetState(nil)
|
||||
imgui.CloseCurrentPopup()
|
||||
}
|
||||
imgui.EndPopup()
|
||||
} else {
|
||||
state.machine.SetState(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (state *exportWaitingState) HandleFiles(names []string) {
|
||||
dirPath, ok := state.verifyDir(names)
|
||||
if ok {
|
||||
state.machine.SetState(nil)
|
||||
state.callback(dirPath)
|
||||
} else {
|
||||
state.failureTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (state exportWaitingState) verifyDir(names []string) (string, bool) {
|
||||
if len(names) != 1 {
|
||||
return "", false
|
||||
}
|
||||
fileInfo, err := os.Stat(names[0])
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if !fileInfo.IsDir() {
|
||||
return "", false
|
||||
}
|
||||
return names[0], true
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"github.com/inkyblackness/imgui-go/v3"
|
||||
"github.com/sqweek/dialog"
|
||||
|
||||
"github.com/inkyblackness/hacked/ui/gui"
|
||||
)
|
||||
|
||||
// SingleFileHandler is a callback for a single selected file.
|
||||
type SingleFileHandler func(string) error
|
||||
|
||||
type singleFileRunner func(*dialog.FileBuilder) (string, error)
|
||||
|
||||
// SaveFile starts a dialog series to save a file.
|
||||
func SaveFile(machine gui.ModalStateMachine, types []TypeInfo, callback SingleFileHandler) {
|
||||
machine.SetState(&fileStartState{
|
||||
machine: machine,
|
||||
title: "Save",
|
||||
typeInfo: types,
|
||||
runner: func(builder *dialog.FileBuilder) (string, error) { return builder.Save() },
|
||||
callback: callback,
|
||||
})
|
||||
}
|
||||
|
||||
// LoadFile starts a dialog series to load a file.
|
||||
func LoadFile(machine gui.ModalStateMachine, types []TypeInfo, callback SingleFileHandler) {
|
||||
machine.SetState(&fileStartState{
|
||||
machine: machine,
|
||||
title: "Load",
|
||||
typeInfo: types,
|
||||
runner: func(builder *dialog.FileBuilder) (string, error) { return builder.Load() },
|
||||
callback: callback,
|
||||
})
|
||||
}
|
||||
|
||||
type fileStartState struct {
|
||||
machine gui.ModalStateMachine
|
||||
title string
|
||||
typeInfo []TypeInfo
|
||||
runner singleFileRunner
|
||||
callback SingleFileHandler
|
||||
}
|
||||
|
||||
func (state fileStartState) HandleFiles(names []string) {
|
||||
}
|
||||
|
||||
func (state fileStartState) Render() {
|
||||
imgui.OpenPopup(state.title)
|
||||
nextState := &fileWaitingState{
|
||||
machine: state.machine,
|
||||
title: state.title,
|
||||
typeInfo: state.typeInfo,
|
||||
runner: state.runner,
|
||||
callback: state.callback,
|
||||
}
|
||||
state.machine.SetState(nextState)
|
||||
}
|
||||
|
||||
type fileWaitingState struct {
|
||||
machine gui.ModalStateMachine
|
||||
title string
|
||||
typeInfo []TypeInfo
|
||||
runner singleFileRunner
|
||||
callback SingleFileHandler
|
||||
|
||||
renderCount int
|
||||
shouldOpenDialog bool
|
||||
problem string
|
||||
}
|
||||
|
||||
func (state *fileWaitingState) HandleFiles(names []string) {
|
||||
}
|
||||
|
||||
func (state *fileWaitingState) Render() {
|
||||
if imgui.BeginPopupModalV(state.title, nil,
|
||||
imgui.WindowFlagsNoResize|imgui.WindowFlagsNoMove|imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsAlwaysAutoResize) {
|
||||
if len(state.problem) == 0 {
|
||||
imgui.Text("Please use popup dialog...")
|
||||
} else {
|
||||
imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1, Y: 0, Z: 0, W: 1})
|
||||
imgui.Text(state.problem)
|
||||
imgui.PopStyleColor()
|
||||
}
|
||||
state.renderControls()
|
||||
imgui.EndPopup()
|
||||
|
||||
state.renderCount++
|
||||
if state.renderCount == 3 {
|
||||
state.shouldOpenDialog = true
|
||||
}
|
||||
|
||||
if state.shouldOpenDialog {
|
||||
state.shouldOpenDialog = false
|
||||
state.openDialog()
|
||||
}
|
||||
} else {
|
||||
state.machine.SetState(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (state *fileWaitingState) renderControls() {
|
||||
imgui.Separator()
|
||||
if imgui.Button("Browse...") {
|
||||
state.shouldOpenDialog = true
|
||||
}
|
||||
imgui.SameLine()
|
||||
if imgui.Button("Cancel") {
|
||||
state.machine.SetState(nil)
|
||||
imgui.CloseCurrentPopup()
|
||||
}
|
||||
}
|
||||
|
||||
func (state *fileWaitingState) openDialog() {
|
||||
dlgBuilder := dialog.File()
|
||||
for _, info := range state.typeInfo {
|
||||
dlgBuilder = dlgBuilder.Filter(info.Title, info.Extensions...)
|
||||
}
|
||||
dlgBuilder = dlgBuilder.Filter("All files (*.*)", "*")
|
||||
|
||||
filename, err := state.runner(dlgBuilder)
|
||||
state.machine.SetState(nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
state.reportFile(filename)
|
||||
}
|
||||
|
||||
func (state *fileWaitingState) reportFile(filename string) {
|
||||
state.problem = ""
|
||||
|
||||
err := state.callback(filename)
|
||||
if err != nil {
|
||||
state.problem = "Previous attempt failed.\nPlease check and try again.\nReason:\n" + err.Error()
|
||||
state.machine.SetState(state)
|
||||
}
|
||||
}
|
||||